// 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(), "."), promptkit.WithFallbackProfileFS(promptassets.ProfileFS(), "."), } 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, APIKeyEnv: 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)) } }