322 lines
12 KiB
Go
322 lines
12 KiB
Go
// Package promptkitadapter implements promptexec with Promptkit.
|
|
package promptkitadapter
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
promptkit "gitea.maximumdirect.net/eric/promptkit"
|
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptassets"
|
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
|
|
)
|
|
|
|
// Config selects the Promptkit sources and optional local backend for one engine.
|
|
type Config struct {
|
|
ProfileDirectory string
|
|
ProfileFile string
|
|
LocalEndpoint string
|
|
LocalConcurrencyLimit int
|
|
Timeout time.Duration
|
|
}
|
|
|
|
// Adapter owns one Promptkit engine and its opaque prepared execution handles.
|
|
type Adapter struct {
|
|
engine *promptkit.Engine
|
|
}
|
|
|
|
var _ promptexec.Executor = (*Adapter)(nil)
|
|
|
|
// New constructs a Promptkit-backed executor from Weatherreporter-owned settings.
|
|
func New(config Config) (*Adapter, error) {
|
|
return newAdapter(config)
|
|
}
|
|
|
|
func newAdapter(config Config, additionalOptions ...promptkit.Option) (*Adapter, error) {
|
|
if config.ProfileDirectory != "" && config.ProfileFile != "" {
|
|
return nil, promptexec.NewError(promptexec.InvalidConfiguration, "profile directory and profile file cannot both be configured", nil)
|
|
}
|
|
if config.LocalEndpoint == "" && config.LocalConcurrencyLimit != 0 {
|
|
return nil, promptexec.NewError(promptexec.InvalidConfiguration, "local concurrency requires a local endpoint", nil)
|
|
}
|
|
|
|
options := []promptkit.Option{
|
|
promptkit.WithPromptFS(promptassets.PromptFS(), "."),
|
|
promptkit.WithSchemaFS(promptassets.SchemaFS(), "."),
|
|
}
|
|
if config.ProfileFile != "" {
|
|
options = append(options, promptkit.WithProfileFile(config.ProfileFile))
|
|
}
|
|
if config.LocalEndpoint != "" {
|
|
options = append(options, promptkit.WithBackend(promptkit.LocalBackend(config.LocalEndpoint, config.LocalConcurrencyLimit)))
|
|
}
|
|
options = append(options, additionalOptions...)
|
|
engine, err := promptkit.NewEngine(promptkit.Config{
|
|
ProfileDir: config.ProfileDirectory,
|
|
Timeout: config.Timeout,
|
|
}, options...)
|
|
if err != nil {
|
|
return nil, classifyConfigurationError(err)
|
|
}
|
|
return &Adapter{engine: engine}, nil
|
|
}
|
|
|
|
func newAdapterForTest(config Config, client promptkit.LLMClient) (*Adapter, error) {
|
|
return newAdapter(config, promptkit.WithLLMClient(client))
|
|
}
|
|
|
|
// InspectPrompt maps an exact Promptkit prompt inspection into project-owned values.
|
|
func (adapter *Adapter) InspectPrompt(ctx context.Context, promptID string, promptVersion string) (promptexec.PromptInspection, error) {
|
|
if adapter == nil || adapter.engine == nil {
|
|
return promptexec.PromptInspection{}, promptexec.NewError(promptexec.InvalidConfiguration, "prompt executor is not configured", nil)
|
|
}
|
|
inspection, err := adapter.engine.InspectPrompt(ctx, promptID, promptVersion)
|
|
if err != nil {
|
|
return promptexec.PromptInspection{}, classifyError(err)
|
|
}
|
|
inputs := make([]promptexec.InputDefinition, len(inspection.Inputs))
|
|
for index, input := range inspection.Inputs {
|
|
inputs[index] = promptexec.InputDefinition{
|
|
Name: input.Name,
|
|
Required: input.Required,
|
|
ContentType: input.ContentType,
|
|
Description: input.Description,
|
|
}
|
|
}
|
|
return promptexec.PromptInspection{
|
|
PromptID: inspection.PromptID,
|
|
PromptVersion: inspection.PromptVersion,
|
|
PromptHash: inspection.PromptHash,
|
|
DefaultProfileID: inspection.DefaultProfileID,
|
|
Inputs: inputs,
|
|
Output: outputContract(inspection.OutputContract),
|
|
}, nil
|
|
}
|
|
|
|
// InspectProfile maps one explicit Promptkit profile inspection into safe values.
|
|
func (adapter *Adapter) InspectProfile(ctx context.Context, profileID string) (promptexec.ProfileInspection, error) {
|
|
if adapter == nil || adapter.engine == nil {
|
|
return promptexec.ProfileInspection{}, promptexec.NewError(promptexec.InvalidConfiguration, "prompt executor is not configured", nil)
|
|
}
|
|
inspection, err := adapter.engine.InspectProfile(ctx, profileID)
|
|
if err != nil {
|
|
return promptexec.ProfileInspection{}, classifyError(err)
|
|
}
|
|
return promptexec.ProfileInspection{
|
|
ProfileID: inspection.ProfileID,
|
|
BackendID: inspection.EffectiveModelParams.BackendID,
|
|
ModelName: inspection.EffectiveModelParams.Model,
|
|
CredentialRequired: inspection.APIKeyRequired || inspection.EffectiveModelParams.APIKeyEnv != "",
|
|
}, nil
|
|
}
|
|
|
|
// Execute prepares one exact inline data package, invokes prepared after a
|
|
// successful preparation, and then runs the same opaque prepared handle.
|
|
func (adapter *Adapter) Execute(ctx context.Context, request promptexec.ExecuteRequest, preparedCallback promptexec.PreparationCallback) (*promptexec.Execution, error) {
|
|
if adapter == nil || adapter.engine == nil {
|
|
return nil, promptexec.NewError(promptexec.InvalidConfiguration, "prompt executor is not configured", nil)
|
|
}
|
|
prepared, err := adapter.engine.PrepareExecution(ctx, promptkit.RunRequest{
|
|
PromptID: request.PromptID,
|
|
PromptVersion: request.PromptVersion,
|
|
ProfileID: request.ProfileID,
|
|
Inputs: map[string]promptkit.ArtifactRef{
|
|
"data_package": promptkit.InlineWithURI(request.DataPackagePath, string(append([]byte(nil), request.DataPackage...))),
|
|
},
|
|
})
|
|
if err != nil {
|
|
return nil, classifyError(err)
|
|
}
|
|
defer prepared.Discard()
|
|
|
|
details := prepared.Details()
|
|
preparation, debug := preparationValues(details, request.DataPackagePath, request.CaptureDebug)
|
|
if preparedCallback != nil {
|
|
if err := preparedCallback(preparation, debug); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
result, err := adapter.engine.RunPrepared(ctx, prepared)
|
|
if err != nil {
|
|
return nil, classifyError(err)
|
|
}
|
|
return executionValue(result, request.DataPackagePath, request.CaptureDebug), nil
|
|
}
|
|
|
|
func outputContract(value promptkit.OutputContract) promptexec.OutputContract {
|
|
return promptexec.OutputContract{
|
|
Format: string(value.Format),
|
|
ValidationMode: string(value.ValidationMode),
|
|
SchemaPath: value.SchemaPath,
|
|
}
|
|
}
|
|
|
|
func preparationValues(value promptkit.PreparedRun, dataPackagePath string, captureDebug bool) (promptexec.Preparation, *promptexec.PreparationDebug) {
|
|
preparation := promptexec.Preparation{
|
|
PromptID: value.PromptID,
|
|
PromptVersion: value.PromptVersion,
|
|
PromptHash: value.PromptHash,
|
|
RenderedPromptHash: value.RenderedPromptHash,
|
|
InputHashes: copyInputHashes(value.InputHashes),
|
|
ProfileID: value.SelectedProfileID,
|
|
BackendID: value.SelectedBackendID,
|
|
ModelName: value.EffectiveModelParams.Model,
|
|
Output: outputContract(value.OutputContract),
|
|
StartedAt: value.StartTime,
|
|
EndedAt: value.EndTime,
|
|
Duration: time.Duration(value.DurationMS) * time.Millisecond,
|
|
DataPackagePath: dataPackagePath,
|
|
}
|
|
if !captureDebug {
|
|
return preparation, nil
|
|
}
|
|
debug := &promptexec.PreparationDebug{
|
|
RenderedMessages: renderedMessages(value.Messages),
|
|
Endpoint: value.EffectiveModelParams.Endpoint,
|
|
ParametersJSON: marshalDebugParameters(value.EffectiveModelParams),
|
|
}
|
|
if value.StructuredOutput != nil && value.StructuredOutput.JSONSchema != nil {
|
|
debug.StructuredSchema, _ = json.Marshal(value.StructuredOutput.JSONSchema.Schema)
|
|
}
|
|
return preparation, debug
|
|
}
|
|
|
|
func executionValue(value *promptkit.RunResult, dataPackagePath string, captureDebug bool) *promptexec.Execution {
|
|
if value == nil {
|
|
return nil
|
|
}
|
|
validation := promptexec.NewValidation(
|
|
promptexec.ValidationStatus(value.Validation.Status),
|
|
string(value.Validation.Mode),
|
|
value.Validation.SchemaPath,
|
|
value.Validation.Errors,
|
|
)
|
|
execution := &promptexec.Execution{
|
|
RunID: value.RunID,
|
|
PromptID: value.PromptID,
|
|
PromptVersion: value.PromptVersion,
|
|
PromptHash: value.PromptHash,
|
|
RenderedPromptHash: value.RenderedPromptHash,
|
|
InputHashes: copyInputHashes(value.InputHashes),
|
|
ProfileID: value.SelectedProfileID,
|
|
BackendID: value.SelectedBackendID,
|
|
ModelName: value.ModelName,
|
|
GeneratedHash: value.Artifact.Hash,
|
|
Usage: promptexec.TokenUsage{
|
|
PromptTokens: value.Usage.PromptTokens,
|
|
CompletionTokens: value.Usage.CompletionTokens,
|
|
TotalTokens: value.Usage.TotalTokens,
|
|
CachedTokens: value.Usage.CachedTokens,
|
|
CacheWriteTokens: value.Usage.CacheWriteTokens,
|
|
},
|
|
StartedAt: value.StartTime,
|
|
EndedAt: value.EndTime,
|
|
Duration: value.Duration,
|
|
Validation: validation,
|
|
DataPackagePath: dataPackagePath,
|
|
RawOutput: []byte(value.RawOutput),
|
|
}
|
|
if captureDebug {
|
|
execution.Debug = &promptexec.ExecutionDebug{
|
|
RawOutput: append([]byte(nil), value.RawOutput...),
|
|
ValidationDiagnostics: append([]string(nil), validation.Diagnostics...),
|
|
}
|
|
}
|
|
return execution
|
|
}
|
|
|
|
func renderedMessages(values []promptkit.RenderedMessage) []promptexec.RenderedMessage {
|
|
messages := make([]promptexec.RenderedMessage, len(values))
|
|
for index, value := range values {
|
|
messages[index] = promptexec.RenderedMessage{Role: value.Role, Content: value.Content}
|
|
}
|
|
return messages
|
|
}
|
|
|
|
func copyInputHashes(values map[string]string) map[string]string {
|
|
if values == nil {
|
|
return nil
|
|
}
|
|
copy := make(map[string]string, len(values))
|
|
for key, value := range values {
|
|
copy[key] = value
|
|
}
|
|
return copy
|
|
}
|
|
|
|
func marshalDebugParameters(value promptkit.ExecutionTarget) []byte {
|
|
parameters := struct {
|
|
Temperature float64 `json:"temperature"`
|
|
MaxTokens int `json:"max_tokens"`
|
|
TopP float64 `json:"top_p"`
|
|
TimeoutSeconds int `json:"timeout_seconds"`
|
|
ServiceTier string `json:"service_tier"`
|
|
ReasoningEffort string `json:"reasoning_effort"`
|
|
ExtraParams map[string]any `json:"extra_params"`
|
|
}{
|
|
Temperature: value.Temperature,
|
|
MaxTokens: value.MaxTokens,
|
|
TopP: value.TopP,
|
|
TimeoutSeconds: value.TimeoutSeconds,
|
|
ServiceTier: value.ServiceTier,
|
|
ReasoningEffort: value.ReasoningEffort,
|
|
ExtraParams: value.ExtraParams,
|
|
}
|
|
data, _ := json.Marshal(parameters)
|
|
return data
|
|
}
|
|
|
|
func classifyConfigurationError(err error) error {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
return promptexec.NewError(promptexec.InvalidConfiguration, "prompt executor configuration is invalid", err)
|
|
}
|
|
|
|
func classifyError(err error) error {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
if errors.Is(err, context.Canceled) {
|
|
return promptexec.NewError(promptexec.Canceled, "prompt operation was canceled", err)
|
|
}
|
|
if errors.Is(err, context.DeadlineExceeded) {
|
|
return promptexec.NewError(promptexec.DeadlineExceeded, "prompt operation exceeded its deadline", err)
|
|
}
|
|
var capacityError *promptkit.CapacityError
|
|
if errors.As(err, &capacityError) {
|
|
return promptexec.NewCapacityError(capacityError.BackendID, "prompt backend capacity is unavailable", err)
|
|
}
|
|
switch {
|
|
case errors.Is(err, promptkit.ErrInvalidConfig):
|
|
return promptexec.NewError(promptexec.InvalidConfiguration, "prompt executor configuration is invalid", err)
|
|
case errors.Is(err, promptkit.ErrPromptNotFound):
|
|
return promptexec.NewError(promptexec.PromptNotFound, "prompt definition was not found", err)
|
|
case errors.Is(err, promptkit.ErrPromptLoad):
|
|
return promptexec.NewError(promptexec.PromptLoad, "prompt definition could not be loaded", err)
|
|
case errors.Is(err, promptkit.ErrProfileNotFound):
|
|
return promptexec.NewError(promptexec.ProfileNotFound, "execution profile was not found", err)
|
|
case errors.Is(err, promptkit.ErrProfileLoad):
|
|
return promptexec.NewError(promptexec.ProfileLoad, "execution profile could not be loaded", err)
|
|
case errors.Is(err, promptkit.ErrAPIKeyEnvMissing):
|
|
return promptexec.NewError(promptexec.MissingCredential, "execution credential is unavailable", err)
|
|
case errors.Is(err, promptkit.ErrArtifactLoad):
|
|
return promptexec.NewError(promptexec.ArtifactLoad, "prompt input could not be loaded", err)
|
|
case errors.Is(err, promptkit.ErrPromptRender):
|
|
return promptexec.NewError(promptexec.PromptRender, "prompt could not be rendered", err)
|
|
case errors.Is(err, promptkit.ErrCapacityExceeded):
|
|
return promptexec.NewCapacityError("", "prompt backend capacity is unavailable", err)
|
|
case errors.Is(err, promptkit.ErrLLMGenerate):
|
|
return promptexec.NewError(promptexec.Generation, "prompt generation failed", err)
|
|
case errors.Is(err, promptkit.ErrValidation):
|
|
return promptexec.NewError(promptexec.OperationalValidation, "prompt output validation could not be completed", err)
|
|
case errors.Is(err, promptkit.ErrInvalidRequest), errors.Is(err, promptkit.ErrProfileRequired):
|
|
return promptexec.NewError(promptexec.InvalidRequest, "prompt execution request is invalid", err)
|
|
default:
|
|
return promptexec.NewError(promptexec.Generation, "prompt operation failed", fmt.Errorf("%w", err))
|
|
}
|
|
}
|