Add Promptkit configuration and inspection seams

This commit is contained in:
2026-07-31 04:27:36 +00:00
parent 6064af2295
commit 9a17a8de93
15 changed files with 584 additions and 2 deletions

View File

@@ -145,6 +145,21 @@ source keys are `observations`, `current`, `narrative`, `alerts`, `discussion`,
| `timeout` | `2m` | Must be greater than zero. | | `timeout` | `2m` | Must be greater than zero. |
| `extra_args` | empty | Optional extra arguments passed to Scriptorium commands. | | `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` ### `workspace`
| Field | Default | | Field | Default |

View File

@@ -23,6 +23,12 @@ protocols, and report definitions belong in [the CLI reference](../cli.md),
[the configuration reference](../config.md), [operations](../operations.md), [the configuration reference](../config.md), [operations](../operations.md),
and their focused integration and internal documents. 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 ## Single-Report Workflow
`GenerateDetailed` first collects weather data, then resolves the requested `GenerateDetailed` first collects weather data, then resolves the requested

View File

@@ -12,6 +12,11 @@ CLI overrides, obtains the current time, and constructs either an
`app.GenerateRequest` or an `app.BatchRequest`. It delegates generation and `app.GenerateRequest` or an `app.BatchRequest`. It delegates generation and
batch execution to `internal/app`. 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 For inspection, it loads configuration, builds the appropriate app inspection
request, and writes the returned value. Inspection is read-only; the inspected 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) artifact types and user invocation remain owned by the [CLI reference](../cli.md)

View File

@@ -108,7 +108,8 @@ func (adapter *Adapter) InspectProfile(ctx context.Context, profileID string) (p
ProfileID: inspection.ProfileID, ProfileID: inspection.ProfileID,
BackendID: inspection.EffectiveModelParams.BackendID, BackendID: inspection.EffectiveModelParams.BackendID,
ModelName: inspection.EffectiveModelParams.Model, ModelName: inspection.EffectiveModelParams.Model,
CredentialRequired: inspection.APIKeyRequired || inspection.EffectiveModelParams.APIKeyEnv != "", CredentialRequired: inspection.APIKeyRequired,
APIKeyEnv: inspection.EffectiveModelParams.APIKeyEnv,
}, nil }, nil
} }

View File

@@ -323,6 +323,10 @@ api_key_env: WEATHERREPORTER_TEST_MISSING_KEY
if err != nil { if err != nil {
t.Fatalf("newAdapterForTest(credential) error = %v", err) 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 := testExecuteRequest()
request.ProfileID = "credential-profile" request.ProfileID = "credential-profile"
result, err := credentialAdapter.Execute(context.Background(), request, nil) result, err := credentialAdapter.Execute(context.Background(), request, nil)

View File

@@ -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)
}

View File

@@ -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"},
}
}

View File

@@ -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,
}
}

View File

@@ -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
}

View File

@@ -40,7 +40,8 @@ Options:
` `
type Runner struct { 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 { func Run(ctx context.Context, args []string, stdout io.Writer, stderr io.Writer) error {

View File

@@ -28,6 +28,7 @@ type Config struct {
Notify NotifyConfig `yaml:"notify"` Notify NotifyConfig `yaml:"notify"`
MissingSource MissingSourceConfig `yaml:"missing_source"` MissingSource MissingSourceConfig `yaml:"missing_source"`
Scriptorium ScriptoriumConfig `yaml:"scriptorium"` Scriptorium ScriptoriumConfig `yaml:"scriptorium"`
Promptkit PromptkitConfig `yaml:"promptkit"`
Workspace WorkspaceConfig `yaml:"workspace"` Workspace WorkspaceConfig `yaml:"workspace"`
Dayparts []DaypartConfig `yaml:"dayparts"` Dayparts []DaypartConfig `yaml:"dayparts"`
RecentChange RecentChangeConfig `yaml:"recent_change"` RecentChange RecentChangeConfig `yaml:"recent_change"`
@@ -89,6 +90,19 @@ type ScriptoriumConfig struct {
ExtraArgs []string `yaml:"extra_args"` 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 { type WorkspaceConfig struct {
Root string `yaml:"root"` Root string `yaml:"root"`
SnapshotsDir string `yaml:"snapshots_dir"` SnapshotsDir string `yaml:"snapshots_dir"`

View File

@@ -47,6 +47,12 @@ func Defaults() Config {
Binary: "scriptorium", Binary: "scriptorium",
Timeout: 2 * time.Minute, Timeout: 2 * time.Minute,
}, },
Promptkit: PromptkitConfig{
Timeout: 2 * time.Minute,
Local: PromptkitLocalConfig{
ConcurrencyLimit: 1,
},
},
Workspace: WorkspaceConfig{ Workspace: WorkspaceConfig{
Root: "workspace", Root: "workspace",
SnapshotsDir: "snapshots", SnapshotsDir: "snapshots",

View File

@@ -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)
}
})
}
}

View File

@@ -65,6 +65,9 @@ func Validate(cfg Config) error {
if cfg.Scriptorium.Timeout <= 0 { if cfg.Scriptorium.Timeout <= 0 {
return fmt.Errorf("scriptorium.timeout must be greater than zero") return fmt.Errorf("scriptorium.timeout must be greater than zero")
} }
if err := validatePromptkit(cfg.Promptkit); err != nil {
return err
}
if cfg.Workspace.Root == "" { if cfg.Workspace.Root == "" {
return fmt.Errorf("workspace.root is required") return fmt.Errorf("workspace.root is required")
} }
@@ -85,6 +88,25 @@ func Validate(cfg Config) error {
return nil 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 { func validateDistributorNotify(cfg DistributorNotifyConfig) error {
if !cfg.Enabled { if !cfg.Enabled {
return nil return nil

View File

@@ -57,6 +57,7 @@ type ProfileInspection struct {
BackendID string BackendID string
ModelName string ModelName string
CredentialRequired bool CredentialRequired bool
APIKeyEnv string
} }
// ExecuteRequest selects one exact prompt execution. DataPackage is the exact // ExecuteRequest selects one exact prompt execution. DataPackage is the exact