diff --git a/docs/config.md b/docs/config.md index b787dc4..e867b90 100644 --- a/docs/config.md +++ b/docs/config.md @@ -145,6 +145,21 @@ source keys are `observations`, `current`, `narrative`, `alerts`, `discussion`, | `timeout` | `2m` | Must be greater than zero. | | `extra_args` | empty | Optional extra arguments passed to Scriptorium commands. | +### `promptkit` + +Promptkit configuration prepares the local executor and prompt/profile checks. +Scriptorium remains the active generator until the Promptkit execution workflow +is enabled. + +| Field | Default | Rules | +| --- | --- | --- | +| `profile` | empty | Optional explicit execution profile. Otherwise the prompt's declared default is used. | +| `profile_file` | empty | Optional external profile file. Cannot be combined with `profile_dir`. | +| `profile_dir` | empty | Optional external profile directory. Cannot be combined with `profile_file`. | +| `timeout` | `2m` | Must be greater than zero. | +| `local.endpoint` | empty | Optional absolute URL for the conventional local backend. A blank endpoint leaves it unregistered. | +| `local.concurrency_limit` | `1` | Maximum local backend concurrency. `0` is unlimited; negative values are invalid. | + ### `workspace` | Field | Default | diff --git a/docs/internal/app-orchestration.md b/docs/internal/app-orchestration.md index ffbc0fb..009eb6e 100644 --- a/docs/internal/app-orchestration.md +++ b/docs/internal/app-orchestration.md @@ -23,6 +23,12 @@ protocols, and report definitions belong in [the CLI reference](../cli.md), [the configuration reference](../config.md), [operations](../operations.md), and their focused integration and internal documents. +`InspectPromptExecution` is a side-effect-free preflight helper for the prompt +workflow. It verifies the exact report prompt version, its required YAML input, +the generated-text JSON Schema contract, the selected profile, and any required +environment credential before collection or persistence begins. It returns only +safe project-owned identity and provenance values. + ## Single-Report Workflow `GenerateDetailed` first collects weather data, then resolves the requested diff --git a/docs/internal/cli.md b/docs/internal/cli.md index 8a5365b..27e38c3 100644 --- a/docs/internal/cli.md +++ b/docs/internal/cli.md @@ -12,6 +12,11 @@ CLI overrides, obtains the current time, and constructs either an `app.GenerateRequest` or an `app.BatchRequest`. It delegates generation and batch execution to `internal/app`. +`Runner` also owns a project-owned prompt-executor factory seam. Its production +factory maps `promptkit` configuration to the Promptkit adapter, while tests can +inject a factory without importing dependency types. Construction is retained as +a separate seam until the generation workflow begins using that executor. + For inspection, it loads configuration, builds the appropriate app inspection request, and writes the returned value. Inspection is read-only; the inspected artifact types and user invocation remain owned by the [CLI reference](../cli.md) diff --git a/internal/adapters/promptkit/adapter.go b/internal/adapters/promptkit/adapter.go index 1696a2f..57af287 100644 --- a/internal/adapters/promptkit/adapter.go +++ b/internal/adapters/promptkit/adapter.go @@ -108,7 +108,8 @@ func (adapter *Adapter) InspectProfile(ctx context.Context, profileID string) (p ProfileID: inspection.ProfileID, BackendID: inspection.EffectiveModelParams.BackendID, ModelName: inspection.EffectiveModelParams.Model, - CredentialRequired: inspection.APIKeyRequired || inspection.EffectiveModelParams.APIKeyEnv != "", + CredentialRequired: inspection.APIKeyRequired, + APIKeyEnv: inspection.EffectiveModelParams.APIKeyEnv, }, nil } diff --git a/internal/adapters/promptkit/adapter_test.go b/internal/adapters/promptkit/adapter_test.go index f6a79b6..521ef0f 100644 --- a/internal/adapters/promptkit/adapter_test.go +++ b/internal/adapters/promptkit/adapter_test.go @@ -323,6 +323,10 @@ api_key_env: WEATHERREPORTER_TEST_MISSING_KEY if err != nil { t.Fatalf("newAdapterForTest(credential) error = %v", err) } + credentialProfile, err := credentialAdapter.InspectProfile(context.Background(), "credential-profile") + if err != nil || credentialProfile.CredentialRequired || credentialProfile.APIKeyEnv != "WEATHERREPORTER_TEST_MISSING_KEY" { + t.Fatalf("credential profile/error = %#v/%v", credentialProfile, err) + } request := testExecuteRequest() request.ProfileID = "credential-profile" result, err := credentialAdapter.Execute(context.Background(), request, nil) diff --git a/internal/app/prompt_inspection.go b/internal/app/prompt_inspection.go new file mode 100644 index 0000000..ea8d47e --- /dev/null +++ b/internal/app/prompt_inspection.go @@ -0,0 +1,106 @@ +package app + +import ( + "context" + "os" + "strings" + + "gitea.maximumdirect.net/eric/weatherreporter/internal/config" + "gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec" + "gitea.maximumdirect.net/eric/weatherreporter/internal/report" +) + +// PromptInspectionRequest contains the non-executing inputs required to +// validate one report's configured prompt and profile. +type PromptInspectionRequest struct { + Resolved report.Resolved + Executor promptexec.Executor + Promptkit config.PromptkitConfig + LookupEnv func(string) (string, bool) +} + +// PromptInspectionResult contains only safe identity and provenance from a +// prompt/profile inspection. +type PromptInspectionResult struct { + PromptID string + PromptVersion string + PromptHash string + ProfileID string + BackendID string + ModelName string +} + +// InspectPromptExecution validates the exact prompt and profile needed for a +// report before collection, execution, or durable writes begin. +func InspectPromptExecution(ctx context.Context, req PromptInspectionRequest) (PromptInspectionResult, error) { + if req.Executor == nil { + return PromptInspectionResult{}, promptexec.NewError(promptexec.InvalidConfiguration, "prompt executor is required", nil) + } + definition := req.Resolved.Definition + if strings.TrimSpace(definition.PromptID) == "" || strings.TrimSpace(definition.PromptVersion) == "" { + return PromptInspectionResult{}, promptexec.NewError(promptexec.InvalidConfiguration, "report prompt identity is incomplete", nil) + } + inspection, err := req.Executor.InspectPrompt(ctx, definition.PromptID, definition.PromptVersion) + if err != nil { + return PromptInspectionResult{}, promptInspectionError("prompt inspection failed", err) + } + if inspection.PromptID != definition.PromptID || inspection.PromptVersion != definition.PromptVersion { + return PromptInspectionResult{}, promptexec.NewError(promptexec.InvalidConfiguration, "prompt inspection did not return the requested prompt version", nil) + } + if !validPromptInput(inspection.Inputs) { + return PromptInspectionResult{}, promptexec.NewError(promptexec.InvalidConfiguration, "prompt must declare exactly one required application/yaml data_package input", nil) + } + if !validPromptOutput(definition, inspection.Output) { + return PromptInspectionResult{}, promptexec.NewError(promptexec.InvalidConfiguration, "prompt must declare the report JSON Schema output contract", nil) + } + profileID := req.Promptkit.Profile + if profileID == "" { + profileID = inspection.DefaultProfileID + } + if strings.TrimSpace(profileID) == "" { + return PromptInspectionResult{}, promptexec.NewError(promptexec.InvalidConfiguration, "prompt has no execution profile", nil) + } + profile, err := req.Executor.InspectProfile(ctx, profileID) + if err != nil { + return PromptInspectionResult{}, promptInspectionError("profile inspection failed", err) + } + if profile.ProfileID != profileID { + return PromptInspectionResult{}, promptexec.NewError(promptexec.InvalidConfiguration, "profile inspection did not return the selected profile", nil) + } + if profile.CredentialRequired { + return PromptInspectionResult{}, promptexec.NewError(promptexec.MissingCredential, "selected profile requires an unsupported direct API key", nil) + } + if strings.TrimSpace(profile.APIKeyEnv) != "" { + lookupEnv := req.LookupEnv + if lookupEnv == nil { + lookupEnv = os.LookupEnv + } + value, present := lookupEnv(profile.APIKeyEnv) + if !present || strings.TrimSpace(value) == "" { + return PromptInspectionResult{}, promptexec.NewError(promptexec.MissingCredential, "selected profile credential is unavailable", nil) + } + } + return PromptInspectionResult{ + PromptID: inspection.PromptID, + PromptVersion: inspection.PromptVersion, + PromptHash: inspection.PromptHash, + ProfileID: profile.ProfileID, + BackendID: profile.BackendID, + ModelName: profile.ModelName, + }, nil +} + +func validPromptInput(inputs []promptexec.InputDefinition) bool { + return len(inputs) == 1 && inputs[0].Name == "data_package" && inputs[0].Required && inputs[0].ContentType == "application/yaml" +} + +func validPromptOutput(definition report.Definition, output promptexec.OutputContract) bool { + return output.Format == "json" && output.ValidationMode == "json_schema" && output.SchemaPath == definition.GeneratedTextSchemaID+".generated_text.schema.json" +} + +func promptInspectionError(operation string, err error) error { + if promptexec.CategoryOf(err) != "" { + return err + } + return promptexec.NewError(promptexec.InvalidConfiguration, operation, err) +} diff --git a/internal/app/prompt_inspection_test.go b/internal/app/prompt_inspection_test.go new file mode 100644 index 0000000..4aa0307 --- /dev/null +++ b/internal/app/prompt_inspection_test.go @@ -0,0 +1,163 @@ +package app + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "gitea.maximumdirect.net/eric/weatherreporter/internal/config" + "gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec" + "gitea.maximumdirect.net/eric/weatherreporter/internal/report" +) + +func TestInspectPromptExecutionSelectsDefaultAndOverrideProfiles(t *testing.T) { + resolved := inspectionResolved(t) + executor := &inspectionExecutor{ + prompt: validPromptInspection(resolved.Definition), + profiles: map[string]promptexec.ProfileInspection{ + "default-profile": {ProfileID: "default-profile", BackendID: "local", ModelName: "default-model"}, + "override-profile": {ProfileID: "override-profile", BackendID: "cloud", ModelName: "override-model"}, + }, + } + defaultResult, err := InspectPromptExecution(context.Background(), PromptInspectionRequest{Resolved: resolved, Executor: executor}) + if err != nil { + t.Fatalf("InspectPromptExecution(default) error = %v", err) + } + if defaultResult.ProfileID != "default-profile" || defaultResult.ModelName != "default-model" { + t.Fatalf("default result = %#v", defaultResult) + } + overrideResult, err := InspectPromptExecution(context.Background(), PromptInspectionRequest{ + Resolved: resolved, Executor: executor, Promptkit: config.PromptkitConfig{Profile: "override-profile"}, + }) + if err != nil { + t.Fatalf("InspectPromptExecution(override) error = %v", err) + } + if overrideResult.ProfileID != "override-profile" || overrideResult.ModelName != "override-model" { + t.Fatalf("override result = %#v", overrideResult) + } + if len(executor.promptRequests) != 2 || executor.promptRequests[0].version != resolved.Definition.PromptVersion || executor.profileRequests[0] != "default-profile" || executor.profileRequests[1] != "override-profile" { + t.Fatalf("inspection requests = prompts %#v profiles %#v", executor.promptRequests, executor.profileRequests) + } +} + +func TestInspectPromptExecutionRejectsInvalidContractsAndCredentials(t *testing.T) { + resolved := inspectionResolved(t) + basePrompt := validPromptInspection(resolved.Definition) + tests := []struct { + name string + prompt promptexec.PromptInspection + profile promptexec.ProfileInspection + lookupEnv func(string) (string, bool) + wantCategory promptexec.ErrorCategory + }{ + { + name: "extra input", + prompt: func() promptexec.PromptInspection { + value := basePrompt + value.Inputs = append(value.Inputs, promptexec.InputDefinition{Name: "unexpected"}) + return value + }(), + wantCategory: promptexec.InvalidConfiguration, + }, + { + name: "wrong schema", + prompt: func() promptexec.PromptInspection { + value := basePrompt + value.Output.SchemaPath = "unexpected.schema.json" + return value + }(), + wantCategory: promptexec.InvalidConfiguration, + }, + { + name: "direct key", + prompt: basePrompt, + profile: promptexec.ProfileInspection{ProfileID: "default-profile", CredentialRequired: true}, + wantCategory: promptexec.MissingCredential, + }, + { + name: "missing environment credential", + prompt: basePrompt, + profile: promptexec.ProfileInspection{ProfileID: "default-profile", APIKeyEnv: "PROMPT_API_KEY"}, + lookupEnv: func(string) (string, bool) { return "", false }, + wantCategory: promptexec.MissingCredential, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + executor := &inspectionExecutor{prompt: test.prompt, profiles: map[string]promptexec.ProfileInspection{"default-profile": test.profile}} + _, err := InspectPromptExecution(context.Background(), PromptInspectionRequest{Resolved: resolved, Executor: executor, LookupEnv: test.lookupEnv}) + if err == nil || promptexec.CategoryOf(err) != test.wantCategory { + t.Fatalf("error/category = %v/%q, want %q", err, promptexec.CategoryOf(err), test.wantCategory) + } + }) + } +} + +func TestInspectPromptExecutionReturnsSafeInspectionError(t *testing.T) { + resolved := inspectionResolved(t) + executor := &inspectionExecutor{promptErr: errors.New("provider response contains resolved-secret-value")} + _, err := InspectPromptExecution(context.Background(), PromptInspectionRequest{Resolved: resolved, Executor: executor}) + if err == nil || promptexec.CategoryOf(err) != promptexec.InvalidConfiguration { + t.Fatalf("error/category = %v/%q", err, promptexec.CategoryOf(err)) + } + if strings.Contains(err.Error(), "resolved-secret-value") { + t.Fatalf("inspection error leaks provider value: %v", err) + } +} + +type inspectionPromptRequest struct { + id string + version string +} + +type inspectionExecutor struct { + prompt promptexec.PromptInspection + profiles map[string]promptexec.ProfileInspection + promptErr error + promptRequests []inspectionPromptRequest + profileRequests []string +} + +func (e *inspectionExecutor) InspectPrompt(_ context.Context, id string, version string) (promptexec.PromptInspection, error) { + e.promptRequests = append(e.promptRequests, inspectionPromptRequest{id: id, version: version}) + if e.promptErr != nil { + return promptexec.PromptInspection{}, e.promptErr + } + return e.prompt, nil +} + +func (e *inspectionExecutor) InspectProfile(_ context.Context, id string) (promptexec.ProfileInspection, error) { + e.profileRequests = append(e.profileRequests, id) + value, ok := e.profiles[id] + if !ok { + return promptexec.ProfileInspection{}, errors.New("profile missing") + } + return value, nil +} + +func (e *inspectionExecutor) Execute(context.Context, promptexec.ExecuteRequest, promptexec.PreparationCallback) (*promptexec.Execution, error) { + return nil, errors.New("unexpected execution") +} + +func inspectionResolved(t *testing.T) report.Resolved { + t.Helper() + resolved, err := report.DefaultRegistry().Resolve(report.Daily, report.ResolveRequest{ + Now: time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC), + Date: time.Date(2026, 5, 29, 0, 0, 0, 0, time.UTC), + Location: time.UTC, + }) + if err != nil { + t.Fatalf("Resolve() error = %v", err) + } + return resolved +} + +func validPromptInspection(definition report.Definition) promptexec.PromptInspection { + return promptexec.PromptInspection{ + PromptID: definition.PromptID, PromptVersion: definition.PromptVersion, PromptHash: "prompt-hash", DefaultProfileID: "default-profile", + Inputs: []promptexec.InputDefinition{{Name: "data_package", Required: true, ContentType: "application/yaml"}}, + Output: promptexec.OutputContract{Format: "json", ValidationMode: "json_schema", SchemaPath: definition.GeneratedTextSchemaID + ".generated_text.schema.json"}, + } +} diff --git a/internal/cli/executor_factory.go b/internal/cli/executor_factory.go new file mode 100644 index 0000000..15a51c3 --- /dev/null +++ b/internal/cli/executor_factory.go @@ -0,0 +1,59 @@ +package cli + +import ( + "time" + + promptkitadapter "gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/promptkit" + "gitea.maximumdirect.net/eric/weatherreporter/internal/config" + "gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec" +) + +// PromptExecutorConfig is the project-owned construction input for one prompt +// executor. It keeps adapter implementation types out of Runner's API. +type PromptExecutorConfig struct { + Profile string + ProfileFile string + ProfileDirectory string + Timeout time.Duration + LocalEndpoint string + LocalConcurrencyLimit int +} + +// ExecutorFactory constructs one executor for an action. +type ExecutorFactory func(PromptExecutorConfig) (promptexec.Executor, error) + +func (r Runner) promptExecutor(cfg config.PromptkitConfig) (promptexec.Executor, error) { + factory := r.ExecutorFactory + if factory == nil { + factory = newPromptkitExecutor + } + return factory(promptExecutorConfig(cfg)) +} + +func promptExecutorConfig(cfg config.PromptkitConfig) PromptExecutorConfig { + result := PromptExecutorConfig{ + Profile: cfg.Profile, + ProfileFile: cfg.ProfileFile, + ProfileDirectory: cfg.ProfileDir, + Timeout: cfg.Timeout, + } + if cfg.Local.Endpoint != "" { + result.LocalEndpoint = cfg.Local.Endpoint + result.LocalConcurrencyLimit = cfg.Local.ConcurrencyLimit + } + return result +} + +func newPromptkitExecutor(cfg PromptExecutorConfig) (promptexec.Executor, error) { + return promptkitadapter.New(promptkitAdapterConfig(cfg)) +} + +func promptkitAdapterConfig(cfg PromptExecutorConfig) promptkitadapter.Config { + return promptkitadapter.Config{ + ProfileDirectory: cfg.ProfileDirectory, + ProfileFile: cfg.ProfileFile, + LocalEndpoint: cfg.LocalEndpoint, + LocalConcurrencyLimit: cfg.LocalConcurrencyLimit, + Timeout: cfg.Timeout, + } +} diff --git a/internal/cli/executor_factory_test.go b/internal/cli/executor_factory_test.go new file mode 100644 index 0000000..dfb19d1 --- /dev/null +++ b/internal/cli/executor_factory_test.go @@ -0,0 +1,78 @@ +package cli + +import ( + "context" + "testing" + "time" + + promptkitadapter "gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/promptkit" + "gitea.maximumdirect.net/eric/weatherreporter/internal/config" + "gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec" +) + +func TestRunnerPromptExecutorMapsConfigurationOnce(t *testing.T) { + var calls int + var received PromptExecutorConfig + runner := Runner{ExecutorFactory: func(value PromptExecutorConfig) (promptexec.Executor, error) { + calls++ + received = value + return factoryExecutor{}, nil + }} + executor, err := runner.promptExecutor(config.PromptkitConfig{ + Profile: "selected-profile", + ProfileFile: "/etc/weatherreporter/profile.yml", + Timeout: 45 * time.Second, + Local: config.PromptkitLocalConfig{ + Endpoint: "http://127.0.0.1:8080", + ConcurrencyLimit: 3, + }, + }) + if err != nil || executor == nil || calls != 1 { + t.Fatalf("executor/error/calls = %#v/%v/%d", executor, err, calls) + } + want := PromptExecutorConfig{ + Profile: "selected-profile", ProfileFile: "/etc/weatherreporter/profile.yml", Timeout: 45 * time.Second, + LocalEndpoint: "http://127.0.0.1:8080", LocalConcurrencyLimit: 3, + } + if received != want { + t.Fatalf("factory config = %#v, want %#v", received, want) + } +} + +func TestPromptExecutorConfigLeavesBlankLocalBackendUnregistered(t *testing.T) { + value := promptExecutorConfig(config.PromptkitConfig{ + Timeout: 2 * time.Minute, + Local: config.PromptkitLocalConfig{ConcurrencyLimit: 1}, + }) + if value.LocalEndpoint != "" || value.LocalConcurrencyLimit != 0 { + t.Fatalf("executor config = %#v, want no local backend", value) + } +} + +func TestPromptkitAdapterConfigMapsExecutorSettings(t *testing.T) { + adapterConfig := promptkitAdapterConfig(PromptExecutorConfig{ + ProfileDirectory: "/etc/weatherreporter/profiles", + Timeout: 30 * time.Second, LocalEndpoint: "http://127.0.0.1:8080", LocalConcurrencyLimit: 2, + }) + want := promptkitadapter.Config{ + ProfileDirectory: "/etc/weatherreporter/profiles", + Timeout: 30 * time.Second, LocalEndpoint: "http://127.0.0.1:8080", LocalConcurrencyLimit: 2, + } + if adapterConfig != want { + t.Fatalf("adapter config = %#v, want %#v", adapterConfig, want) + } +} + +type factoryExecutor struct{} + +func (factoryExecutor) InspectPrompt(context.Context, string, string) (promptexec.PromptInspection, error) { + return promptexec.PromptInspection{}, nil +} + +func (factoryExecutor) InspectProfile(context.Context, string) (promptexec.ProfileInspection, error) { + return promptexec.ProfileInspection{}, nil +} + +func (factoryExecutor) Execute(context.Context, promptexec.ExecuteRequest, promptexec.PreparationCallback) (*promptexec.Execution, error) { + return nil, nil +} diff --git a/internal/cli/root.go b/internal/cli/root.go index 245ac2f..13c2e63 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -40,7 +40,8 @@ Options: ` type Runner struct { - Clock timeutil.Clock + Clock timeutil.Clock + ExecutorFactory ExecutorFactory } func Run(ctx context.Context, args []string, stdout io.Writer, stderr io.Writer) error { diff --git a/internal/config/config.go b/internal/config/config.go index f2acb0a..06c8795 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -28,6 +28,7 @@ type Config struct { Notify NotifyConfig `yaml:"notify"` MissingSource MissingSourceConfig `yaml:"missing_source"` Scriptorium ScriptoriumConfig `yaml:"scriptorium"` + Promptkit PromptkitConfig `yaml:"promptkit"` Workspace WorkspaceConfig `yaml:"workspace"` Dayparts []DaypartConfig `yaml:"dayparts"` RecentChange RecentChangeConfig `yaml:"recent_change"` @@ -89,6 +90,19 @@ type ScriptoriumConfig struct { ExtraArgs []string `yaml:"extra_args"` } +type PromptkitConfig struct { + Profile string `yaml:"profile"` + ProfileFile string `yaml:"profile_file"` + ProfileDir string `yaml:"profile_dir"` + Timeout time.Duration `yaml:"timeout"` + Local PromptkitLocalConfig `yaml:"local"` +} + +type PromptkitLocalConfig struct { + Endpoint string `yaml:"endpoint"` + ConcurrencyLimit int `yaml:"concurrency_limit"` +} + type WorkspaceConfig struct { Root string `yaml:"root"` SnapshotsDir string `yaml:"snapshots_dir"` diff --git a/internal/config/defaults.go b/internal/config/defaults.go index e2964a5..e8cb114 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -47,6 +47,12 @@ func Defaults() Config { Binary: "scriptorium", Timeout: 2 * time.Minute, }, + Promptkit: PromptkitConfig{ + Timeout: 2 * time.Minute, + Local: PromptkitLocalConfig{ + ConcurrencyLimit: 1, + }, + }, Workspace: WorkspaceConfig{ Root: "workspace", SnapshotsDir: "snapshots", diff --git a/internal/config/promptkit_test.go b/internal/config/promptkit_test.go new file mode 100644 index 0000000..b06cf6d --- /dev/null +++ b/internal/config/promptkit_test.go @@ -0,0 +1,101 @@ +package config + +import ( + "strings" + "testing" + "time" + + "gopkg.in/yaml.v3" +) + +func TestPromptkitDefaultsAndYAML(t *testing.T) { + cfg := Defaults() + if cfg.Promptkit.Timeout != 2*time.Minute || cfg.Promptkit.Local.ConcurrencyLimit != 1 { + t.Fatalf("Promptkit defaults = %#v", cfg.Promptkit) + } + if err := yaml.Unmarshal([]byte(` +promptkit: + profile: selected + profile_file: /etc/weatherreporter/profile.yml + timeout: 45s + local: + endpoint: http://127.0.0.1:8080 + concurrency_limit: 0 +`), &cfg); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if cfg.Promptkit.Profile != "selected" || cfg.Promptkit.ProfileFile != "/etc/weatherreporter/profile.yml" || cfg.Promptkit.Timeout != 45*time.Second || cfg.Promptkit.Local.Endpoint != "http://127.0.0.1:8080" || cfg.Promptkit.Local.ConcurrencyLimit != 0 { + t.Fatalf("Promptkit YAML = %#v", cfg.Promptkit) + } + if err := Validate(cfg); err != nil { + t.Fatalf("Validate() error = %v", err) + } +} + +func TestValidatePromptkit(t *testing.T) { + tests := []struct { + name string + mutate func(*PromptkitConfig) + wantErr string + }{ + { + name: "profile sources conflict", + mutate: func(cfg *PromptkitConfig) { + cfg.ProfileFile = "profile.yml" + cfg.ProfileDir = "profiles" + }, + wantErr: "profile_file", + }, + { + name: "nonpositive timeout", + mutate: func(cfg *PromptkitConfig) { + cfg.Timeout = 0 + }, + wantErr: "timeout", + }, + { + name: "invalid local endpoint", + mutate: func(cfg *PromptkitConfig) { + cfg.Local.Endpoint = "not a URL" + }, + wantErr: "local.endpoint", + }, + { + name: "negative local concurrency", + mutate: func(cfg *PromptkitConfig) { + cfg.Local.ConcurrencyLimit = -1 + }, + wantErr: "concurrency_limit", + }, + { + name: "unlimited local concurrency", + mutate: func(cfg *PromptkitConfig) { + cfg.Local.Endpoint = "http://127.0.0.1:8080" + cfg.Local.ConcurrencyLimit = 0 + }, + }, + { + name: "unregistered local backend", + mutate: func(cfg *PromptkitConfig) { + cfg.Local.Endpoint = "" + cfg.Local.ConcurrencyLimit = 1 + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + cfg := Defaults() + test.mutate(&cfg.Promptkit) + err := Validate(cfg) + if test.wantErr == "" { + if err != nil { + t.Fatalf("Validate() error = %v", err) + } + return + } + if err == nil || !strings.Contains(err.Error(), test.wantErr) { + t.Fatalf("Validate() error = %v, want %q", err, test.wantErr) + } + }) + } +} diff --git a/internal/config/validate.go b/internal/config/validate.go index a880a91..f28bd26 100644 --- a/internal/config/validate.go +++ b/internal/config/validate.go @@ -65,6 +65,9 @@ func Validate(cfg Config) error { if cfg.Scriptorium.Timeout <= 0 { return fmt.Errorf("scriptorium.timeout must be greater than zero") } + if err := validatePromptkit(cfg.Promptkit); err != nil { + return err + } if cfg.Workspace.Root == "" { return fmt.Errorf("workspace.root is required") } @@ -85,6 +88,25 @@ func Validate(cfg Config) error { return nil } +func validatePromptkit(cfg PromptkitConfig) error { + if cfg.ProfileFile != "" && cfg.ProfileDir != "" { + return fmt.Errorf("promptkit.profile_file and promptkit.profile_dir cannot both be configured") + } + if cfg.Timeout <= 0 { + return fmt.Errorf("promptkit.timeout must be greater than zero") + } + if cfg.Local.Endpoint != "" { + parsed, err := url.Parse(cfg.Local.Endpoint) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return fmt.Errorf("promptkit.local.endpoint must be an absolute URL when configured") + } + } + if cfg.Local.ConcurrencyLimit < 0 { + return fmt.Errorf("promptkit.local.concurrency_limit must be zero or greater") + } + return nil +} + func validateDistributorNotify(cfg DistributorNotifyConfig) error { if !cfg.Enabled { return nil diff --git a/internal/promptexec/promptexec.go b/internal/promptexec/promptexec.go index 6a985f8..ee6791a 100644 --- a/internal/promptexec/promptexec.go +++ b/internal/promptexec/promptexec.go @@ -57,6 +57,7 @@ type ProfileInspection struct { BackendID string ModelName string CredentialRequired bool + APIKeyEnv string } // ExecuteRequest selects one exact prompt execution. DataPackage is the exact