From d627b91b4f7665e49a9b00508e38c77d9906d82c Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Thu, 30 Jul 2026 05:09:54 +0000 Subject: [PATCH] Add local PromptKit backend configuration --- internal/core/config/config.go | 14 ++- .../config/effective_config_contract_test.go | 27 +++++ internal/core/config/file_config.go | 24 ++++- .../core/config/file_config_contract_test.go | 99 +++++++++++++++++++ internal/core/config/validation.go | 22 +++++ .../core/config/validation_contract_test.go | 67 +++++++++++++ 6 files changed, 249 insertions(+), 4 deletions(-) diff --git a/internal/core/config/config.go b/internal/core/config/config.go index 9361bf4..c545ed6 100644 --- a/internal/core/config/config.go +++ b/internal/core/config/config.go @@ -16,8 +16,14 @@ type Config struct { } type PromptKitConfig struct { - ProfileDir string `json:"profile_dir,omitempty"` - ProfileFile string `json:"profile_file,omitempty"` + ProfileDir string `json:"profile_dir,omitempty"` + ProfileFile string `json:"profile_file,omitempty"` + LocalBackend *PromptKitLocalBackendConfig `json:"local_backend,omitempty"` +} + +type PromptKitLocalBackendConfig struct { + Endpoint string `json:"endpoint"` + ConcurrencyLimit int `json:"concurrency_limit"` } type ConcurrencyConfig struct { @@ -66,6 +72,10 @@ func Default() Config { func cloneConfig(in Config) Config { out := in + if in.PromptKit.LocalBackend != nil { + localBackend := *in.PromptKit.LocalBackend + out.PromptKit.LocalBackend = &localBackend + } out.Concurrency.StageWorkers = cloneIntMap(in.Concurrency.StageWorkers) out.Pipelines = make(map[string]pipeline.PipelineProfile, len(in.Pipelines)) for key, profile := range in.Pipelines { diff --git a/internal/core/config/effective_config_contract_test.go b/internal/core/config/effective_config_contract_test.go index 1a1602d..ecb6157 100644 --- a/internal/core/config/effective_config_contract_test.go +++ b/internal/core/config/effective_config_contract_test.go @@ -107,6 +107,33 @@ func TestEffectiveConfigPreservesPromptKitProfileSource(t *testing.T) { } } +func TestEffectiveConfigOwnsPromptKitLocalBackend(t *testing.T) { + cfg := configForEffectiveTests(t, effectiveProfile()) + cfg.PromptKit.LocalBackend = &PromptKitLocalBackendConfig{ + Endpoint: "http://localhost:8000/v1", + ConcurrencyLimit: 2, + } + effective, err := cfg.Resolve(ResolveInput{PipelineID: "main", Catalog: effectiveCatalog(t)}) + if err != nil { + t.Fatalf("Resolve() error = %v", err) + } + if effective.Config.PromptKit.LocalBackend == nil { + t.Fatal("effective local backend = nil") + } + if effective.Config.PromptKit.LocalBackend == cfg.PromptKit.LocalBackend { + t.Fatal("effective local backend aliases input config") + } + + cfg.PromptKit.LocalBackend.Endpoint = "http://changed-input.example/v1" + if effective.Config.PromptKit.LocalBackend.Endpoint != "http://localhost:8000/v1" { + t.Fatalf("input mutation changed effective config: %#v", effective.Config.PromptKit.LocalBackend) + } + effective.Config.PromptKit.LocalBackend.ConcurrencyLimit = 9 + if cfg.PromptKit.LocalBackend.ConcurrencyLimit != 2 { + t.Fatalf("effective mutation changed input config: %#v", cfg.PromptKit.LocalBackend) + } +} + func TestEffectiveConfigResolutionFailuresRetainContext(t *testing.T) { tests := []struct { name string diff --git a/internal/core/config/file_config.go b/internal/core/config/file_config.go index 7c3d3d9..510496e 100644 --- a/internal/core/config/file_config.go +++ b/internal/core/config/file_config.go @@ -23,8 +23,14 @@ type FileConfig struct { } type FilePromptKitConfig struct { - ProfileDir *string `yaml:"profile_dir,omitempty"` - ProfileFile *string `yaml:"profile_file,omitempty"` + ProfileDir *string `yaml:"profile_dir,omitempty"` + ProfileFile *string `yaml:"profile_file,omitempty"` + LocalBackend *FilePromptKitLocalBackendConfig `yaml:"local_backend,omitempty"` +} + +type FilePromptKitLocalBackendConfig struct { + Endpoint *string `yaml:"endpoint,omitempty"` + ConcurrencyLimit *int `yaml:"concurrency_limit,omitempty"` } type FilePipelineProfile struct { @@ -468,6 +474,20 @@ func (c *Config) applyFileConfigWithLookup(fileCfg FileConfig, lookup func(strin } c.PromptKit.ProfileFile = value } + if fileCfg.PromptKit.LocalBackend != nil { + if fileCfg.PromptKit.LocalBackend.Endpoint == nil { + return fmt.Errorf("promptkit.local_backend.endpoint must not be empty when set") + } + endpoint := strings.TrimSpace(*fileCfg.PromptKit.LocalBackend.Endpoint) + if endpoint == "" { + return fmt.Errorf("promptkit.local_backend.endpoint must not be empty when set") + } + localBackend := PromptKitLocalBackendConfig{Endpoint: endpoint} + if fileCfg.PromptKit.LocalBackend.ConcurrencyLimit != nil { + localBackend.ConcurrencyLimit = *fileCfg.PromptKit.LocalBackend.ConcurrencyLimit + } + c.PromptKit.LocalBackend = &localBackend + } } for _, pipelineID := range pipelineIDs { diff --git a/internal/core/config/file_config_contract_test.go b/internal/core/config/file_config_contract_test.go index a8a0bd3..e3559fb 100644 --- a/internal/core/config/file_config_contract_test.go +++ b/internal/core/config/file_config_contract_test.go @@ -97,6 +97,100 @@ func TestFilePromptKitProfileSourcesSurviveConfigBoundaries(t *testing.T) { } } +func TestFilePromptKitLocalBackendSurvivesConfigBoundaries(t *testing.T) { + tests := []struct { + name string + concurrencyYAML string + wantConcurrency int + }{ + {name: "omitted concurrency defaults to zero"}, + {name: "positive concurrency is preserved", concurrencyYAML: " concurrency_limit: 2\n", wantConcurrency: 2}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + file := parseFileConfig(t, "version: 4\npromptkit:\n local_backend:\n endpoint: ' http://localhost:8000/v1 '\n"+tt.concurrencyYAML) + cfg := Default() + if err := cfg.ApplyFileConfig(file); err != nil { + t.Fatalf("ApplyFileConfig() error = %v", err) + } + want := PromptKitLocalBackendConfig{ + Endpoint: "http://localhost:8000/v1", + ConcurrencyLimit: tt.wantConcurrency, + } + if cfg.PromptKit.LocalBackend == nil || *cfg.PromptKit.LocalBackend != want { + t.Fatalf("local backend config = %#v, want %#v", cfg.PromptKit.LocalBackend, want) + } + + *file.PromptKit.LocalBackend.Endpoint = "http://changed.example/v1" + if file.PromptKit.LocalBackend.ConcurrencyLimit != nil { + *file.PromptKit.LocalBackend.ConcurrencyLimit = 99 + } + if *cfg.PromptKit.LocalBackend != want { + t.Fatalf("effective config aliases parsed file model: %#v", cfg.PromptKit.LocalBackend) + } + + cloned := cloneConfig(cfg) + if cloned.PromptKit.LocalBackend == cfg.PromptKit.LocalBackend || *cloned.PromptKit.LocalBackend != want { + t.Fatalf("cloned local backend = %#v, want detached %#v", cloned.PromptKit.LocalBackend, want) + } + cloned.PromptKit.LocalBackend.Endpoint = "http://clone.example/v1" + if *cfg.PromptKit.LocalBackend != want { + t.Fatalf("mutating clone changed source config: %#v", cfg.PromptKit.LocalBackend) + } + + redacted := cfg.Redacted() + if redacted.PromptKit.LocalBackend == cfg.PromptKit.LocalBackend || *redacted.PromptKit.LocalBackend != want { + t.Fatalf("redacted local backend = %#v, want detached %#v", redacted.PromptKit.LocalBackend, want) + } + + data, err := json.Marshal(cfg) + if err != nil { + t.Fatalf("json.Marshal() error = %v", err) + } + var payload struct { + PromptKit map[string]json.RawMessage `json:"promptkit"` + } + if err := json.Unmarshal(data, &payload); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + localJSON, ok := payload.PromptKit["local_backend"] + if !ok { + t.Fatalf("runtime PromptKit JSON keys = %v, want local_backend", payload.PromptKit) + } + var localPayload map[string]json.RawMessage + if err := json.Unmarshal(localJSON, &localPayload); err != nil { + t.Fatalf("unmarshal local_backend JSON: %v", err) + } + if _, ok := localPayload["endpoint"]; !ok { + t.Fatalf("runtime local_backend JSON keys = %v, want endpoint", localPayload) + } + if _, ok := localPayload["concurrency_limit"]; !ok { + t.Fatalf("runtime local_backend JSON keys = %v, want concurrency_limit", localPayload) + } + }) + } +} + +func TestFilePromptKitLocalBackendRequiresEndpoint(t *testing.T) { + for _, tt := range []struct { + name string + yaml string + }{ + {name: "missing", yaml: "version: 4\npromptkit:\n local_backend: {}\n"}, + {name: "empty", yaml: "version: 4\npromptkit:\n local_backend:\n endpoint: ''\n"}, + {name: "blank", yaml: "version: 4\npromptkit:\n local_backend:\n endpoint: ' '\n"}, + } { + t.Run(tt.name, func(t *testing.T) { + file := parseFileConfig(t, tt.yaml) + cfg := Default() + err := cfg.ApplyFileConfig(file) + if err == nil || !strings.Contains(err.Error(), "promptkit.local_backend.endpoint") { + t.Fatalf("ApplyFileConfig() error = %v, want endpoint field context", err) + } + }) + } +} + func TestFilePromptKitExplicitEmptyProfileSourcesAreRejected(t *testing.T) { for _, field := range []string{"profile_dir", "profile_file"} { t.Run(field, func(t *testing.T) { @@ -188,6 +282,11 @@ func TestFileConfigRejectsUnknownCurrentAndRemovedFields(t *testing.T) { yaml: "version: 4\ncache:\n checkpoints:\n enabled: definitely\n", want: "cannot unmarshal", }, + { + name: "local backend field", + yaml: "version: 4\npromptkit:\n local_backend:\n endpoint: http://localhost:8000/v1\n unknown: true\n", + want: "field unknown not found", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/internal/core/config/validation.go b/internal/core/config/validation.go index 152b8b1..a015d71 100644 --- a/internal/core/config/validation.go +++ b/internal/core/config/validation.go @@ -2,6 +2,7 @@ package config import ( "fmt" + "net/url" "sort" "strings" @@ -53,6 +54,27 @@ func validatePromptKit(cfg PromptKitConfig) error { if strings.TrimSpace(cfg.ProfileDir) != "" && strings.TrimSpace(cfg.ProfileFile) != "" { return fmt.Errorf("promptkit profile_dir and profile_file are mutually exclusive") } + if cfg.LocalBackend == nil { + return nil + } + endpoint := strings.TrimSpace(cfg.LocalBackend.Endpoint) + if endpoint == "" { + return fmt.Errorf("promptkit.local_backend.endpoint must not be empty when set") + } + parsed, err := url.Parse(endpoint) + if err != nil || + (!strings.EqualFold(parsed.Scheme, "http") && !strings.EqualFold(parsed.Scheme, "https")) || + !parsed.IsAbs() || + parsed.Hostname() == "" || + parsed.User != nil || + parsed.RawQuery != "" || + parsed.ForceQuery || + strings.Contains(endpoint, "#") { + return fmt.Errorf("promptkit.local_backend.endpoint must be an absolute HTTP or HTTPS URL with a host and no user information, query, or fragment") + } + if cfg.LocalBackend.ConcurrencyLimit < 0 { + return fmt.Errorf("promptkit.local_backend.concurrency_limit must not be negative") + } return nil } diff --git a/internal/core/config/validation_contract_test.go b/internal/core/config/validation_contract_test.go index ff49358..7459776 100644 --- a/internal/core/config/validation_contract_test.go +++ b/internal/core/config/validation_contract_test.go @@ -93,6 +93,73 @@ func TestValidatePromptKitSourcesAreMutuallyExclusive(t *testing.T) { assertValidationContains(t, cfg, "promptkit profile_dir and profile_file are mutually exclusive") } +func TestValidatePromptKitLocalBackendEndpoints(t *testing.T) { + tests := []struct { + name string + endpoint string + profileSource PromptKitConfig + }{ + { + name: "HTTP endpoint with path and profile directory", + endpoint: "http://localhost:8000/v1", + profileSource: PromptKitConfig{ProfileDir: "./profiles"}, + }, + { + name: "case-insensitive HTTPS endpoint and profile file", + endpoint: "HTTPS://inference.example.test/api", + profileSource: PromptKitConfig{ProfileFile: "./profiles.yml"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := Default() + cfg.PromptKit = tt.profileSource + cfg.PromptKit.LocalBackend = &PromptKitLocalBackendConfig{ + Endpoint: tt.endpoint, + ConcurrencyLimit: 2, + } + if err := cfg.Validate(); err != nil { + t.Fatalf("Validate() error = %v", err) + } + }) + } +} + +func TestValidatePromptKitLocalBackendRejectsInvalidValues(t *testing.T) { + tests := []struct { + name string + endpoint string + concurrencyLimit int + want string + }{ + {name: "blank endpoint", endpoint: " ", want: "promptkit.local_backend.endpoint"}, + {name: "relative URL", endpoint: "localhost:8000/v1", want: "promptkit.local_backend.endpoint"}, + {name: "unsupported scheme", endpoint: "ftp://localhost/model", want: "promptkit.local_backend.endpoint"}, + {name: "missing host", endpoint: "http:///v1", want: "promptkit.local_backend.endpoint"}, + {name: "user information", endpoint: "http://user:secret@localhost/v1", want: "promptkit.local_backend.endpoint"}, + {name: "query", endpoint: "http://localhost/v1?model=example", want: "promptkit.local_backend.endpoint"}, + {name: "empty query", endpoint: "http://localhost/v1?", want: "promptkit.local_backend.endpoint"}, + {name: "fragment", endpoint: "http://localhost/v1#model", want: "promptkit.local_backend.endpoint"}, + {name: "empty fragment", endpoint: "http://localhost/v1#", want: "promptkit.local_backend.endpoint"}, + { + name: "negative concurrency", + endpoint: "http://localhost:8000/v1", + concurrencyLimit: -1, + want: "promptkit.local_backend.concurrency_limit", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := Default() + cfg.PromptKit.LocalBackend = &PromptKitLocalBackendConfig{ + Endpoint: tt.endpoint, + ConcurrencyLimit: tt.concurrencyLimit, + } + assertValidationContains(t, cfg, tt.want) + }) + } +} + func TestValidateStateSurfaceRules(t *testing.T) { tests := []struct { name string