From fd6e9742a10000c328653a5a85f695f42165885a Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Mon, 2 Nov 2026 04:01:01 +0000 Subject: [PATCH] Use explicit IDs in configuration source builders --- backends.go | 17 ++++--- construction_contract_test.go | 47 +++++++++-------- docs/roadmap/implementation.md | 8 +++ profile_inheritance_contract_test.go | 11 ++-- profiles.go | 20 +++++--- sources_contract_test.go | 75 ++++++++++++++++++++++++++++ 6 files changed, 136 insertions(+), 42 deletions(-) create mode 100644 sources_contract_test.go diff --git a/backends.go b/backends.go index 4219528..263e125 100644 --- a/backends.go +++ b/backends.go @@ -14,13 +14,15 @@ const BackendRakestrawHome = backend.RakestrawHomeID // BackendLocal is a conventional ID for a caller-configured local backend. const BackendLocal = "local" -// BackendConfig is a presence-aware convenience value for a keyed backend +// BackendConfig is a presence-aware convenience value for an identified backend // definition. Nil pointer fields are omitted, while non-nil pointers preserve // explicit zero or blank values. A nil ExtraParams map is omitted; a non-nil // empty map supplies an empty mapping. A nil SmokeTests pointer is omitted; a // non-nil pointer supplies a smoke_tests mapping. Use raw ConfigKit Mapping // inputs for explicit null. BackendConfig has no stable JSON representation. type BackendConfig struct { + // ID is the local backend identity within its resource list. + ID string // Endpoint is the required absolute HTTP(S) base URL after all layers merge. Endpoint *string // APIKeyEnv optionally names an environment variable, not a credential. @@ -71,15 +73,16 @@ type AuthenticationProbeConfig struct { Endpoint *string } -// BackendSource creates a labeled ConfigKit mapping from keyed backend values. +// BackendSource creates a labeled ConfigKit mapping containing backend records +// in caller order. IDs are explicit and duplicates are left for loading to reject. // It encodes fields without validating them; NewEngine validates the final // effective configuration after all selected source layers are combined. // Mapping captures a copy of supported input values at creation; unsupported // values fail when the source is loaded. -func BackendSource(label string, backends map[string]BackendConfig) configkit.Source { - definitions := make(map[string]any, len(backends)) - for id, config := range backends { - fields := make(map[string]any) +func BackendSource(label string, backends []BackendConfig) configkit.Source { + definitions := make([]any, 0, len(backends)) + for _, config := range backends { + fields := map[string]any{"id": config.ID} if config.Endpoint != nil { fields["endpoint"] = *config.Endpoint } @@ -119,7 +122,7 @@ func BackendSource(label string, backends map[string]BackendConfig) configkit.So } fields["smoke_tests"] = smokeTests } - definitions[id] = fields + definitions = append(definitions, fields) } return configkit.Mapping(label, map[string]any{"backends": definitions}) } diff --git a/construction_contract_test.go b/construction_contract_test.go index ab5aae9..54f0779 100644 --- a/construction_contract_test.go +++ b/construction_contract_test.go @@ -40,16 +40,16 @@ func TestProfileEnvironmentLookupAcceptsNonShellNames(t *testing.T) { cfg.Sources.Underlays = []configkit.Underlay{{Inputs: []configkit.Input{*cfg.Sources.Main}}} lowerName := name cfg.Sources.Underlays = append(cfg.Sources.Underlays, configkit.Underlay{Inputs: []configkit.Input{{ - Source: promptkit.ProfileSource("default credentials", map[string]promptkit.ProfileConfig{ - "profile": {APIKeyEnv: &lowerName}, + Source: promptkit.ProfileSource("default credentials", []promptkit.ProfileConfig{ + {ID: "profile", APIKeyEnv: &lowerName}, }), }}}) env := " " + name + " " if test.clear { env = "" } - cfg.Sources.Main = &configkit.Input{Source: promptkit.ProfileSource("credentials", map[string]promptkit.ProfileConfig{ - "profile": {APIKeyEnv: &env}, + cfg.Sources.Main = &configkit.Input{Source: promptkit.ProfileSource("credentials", []promptkit.ProfileConfig{ + {ID: "profile", APIKeyEnv: &env}, })} var calls int var auth string @@ -81,13 +81,15 @@ func TestPublicConstructionWithLocalBackendAndProgrammaticProfile(t *testing.T) }}, })} client := &fakeLLMClient{response: &promptkit.GenerateResponse{Content: "reply"}} + localBackend := promptkit.LocalBackend("http://localhost:8000/v1", 2) + localBackend.ID = backendID engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{ Underlays: []configkit.Underlay{{Inputs: []configkit.Input{ - {Source: promptkit.BackendSource("local backend", map[string]promptkit.BackendConfig{ - backendID: promptkit.LocalBackend("http://localhost:8000/v1", 2), + {Source: promptkit.BackendSource("local backend", []promptkit.BackendConfig{ + localBackend, })}, - {Source: promptkit.ProfileSource("profile", map[string]promptkit.ProfileConfig{ - "offline": {Backend: &backendID, Model: &model}, + {Source: promptkit.ProfileSource("profile", []promptkit.ProfileConfig{ + {ID: "offline", Backend: &backendID, Model: &model}, })}, }}}, Main: &prompt, @@ -113,13 +115,13 @@ func TestFormerCatalogBackendIDsWorkWhenConfigured(t *testing.T) { model := "configured-model" engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{ Underlays: []configkit.Underlay{{Inputs: []configkit.Input{ - {Source: promptkit.BackendSource("application backends", map[string]promptkit.BackendConfig{ - openRouterID: {Endpoint: &openRouterEndpoint}, - rakestrawhomeID: {Endpoint: &rakestrawhomeEndpoint}, + {Source: promptkit.BackendSource("application backends", []promptkit.BackendConfig{ + {ID: openRouterID, Endpoint: &openRouterEndpoint}, + {ID: rakestrawhomeID, Endpoint: &rakestrawhomeEndpoint}, })}, - {Source: promptkit.ProfileSource("application profiles", map[string]promptkit.ProfileConfig{ - "openrouter-profile": {Backend: &openRouterID, Model: &model}, - "rakestrawhome-profile": {Backend: &rakestrawhomeID, Model: &model}, + {Source: promptkit.ProfileSource("application profiles", []promptkit.ProfileConfig{ + {ID: "openrouter-profile", Backend: &openRouterID, Model: &model}, + {ID: "rakestrawhome-profile", Backend: &rakestrawhomeID, Model: &model}, })}, }}}, }}) @@ -168,8 +170,8 @@ prompts: t.Fatalf("select operator inputs: %v", err) } programmaticModel := "programmatic-model" - programmatic := configkit.Input{Source: promptkit.ProfileSource("programmatic settings", map[string]promptkit.ProfileConfig{ - "application": {Model: &programmaticModel}, + programmatic := configkit.Input{Source: promptkit.ProfileSource("programmatic settings", []promptkit.ProfileConfig{ + {ID: "application", Model: &programmaticModel}, })} for _, test := range []struct { name string @@ -225,11 +227,11 @@ func TestPublicConstructionUsesProgrammaticBackendAndProfileSources(t *testing.T endpoint, backendID, model := "http://application.example/v1", "application-backend", "synthetic-model" engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{ Underlays: []configkit.Underlay{{Inputs: []configkit.Input{ - {Source: promptkit.BackendSource("operator backend", map[string]promptkit.BackendConfig{ - backendID: {Endpoint: &endpoint}, + {Source: promptkit.BackendSource("operator backend", []promptkit.BackendConfig{ + {ID: backendID, Endpoint: &endpoint}, })}, - {Source: promptkit.ProfileSource("operator profile", map[string]promptkit.ProfileConfig{ - "application-profile": {Backend: &backendID, Model: &model}, + {Source: promptkit.ProfileSource("operator profile", []promptkit.ProfileConfig{ + {ID: "application-profile", Backend: &backendID, Model: &model}, })}, }}}, }}) @@ -251,8 +253,9 @@ func TestBackendSourceOwnsSmokeTestValuesAndDefersDefaultLookup(t *testing.T) { reachabilityEndpoint := " https://probe.example/ready?check=1 " authenticationKind := " OPENROUTER_KEY " authenticationEndpoint := "https://probe.example/key?scope=current" - source := promptkit.BackendSource("diagnostic backend", map[string]promptkit.BackendConfig{ - "local": { + source := promptkit.BackendSource("diagnostic backend", []promptkit.BackendConfig{ + { + ID: "local", Endpoint: &backendEndpoint, SmokeTests: &promptkit.BackendSmokeTestConfig{ DefaultProfile: &defaultProfile, diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 1b63465..8d7bb5f 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -384,6 +384,14 @@ tests. Other callers are migrated in later stages. Validation: the public construction/source-builder tests demonstrate the new input shape and ownership. Do not add map compatibility or builder-only ID rules. +Completion: profile and backend builders now accept ordered explicit-ID slices, +preserve duplicates and owned values, and encode profile inheritance by sibling +identity. Migrated their owning construction callers and added focused tests +for order, copying, duplicate rejection, and real profile inheritance. Validation +passed: the isolated source-builder contract tests and `go build .` on Go 1.27.0, +formatting, and `git diff --check`. The full root test package remains deferred +until later callers are converted to the new signatures. + ## Stage 9: Remove Internal Prompt-Version Propagation Scope: domain values, prompt requirement snapshots, hashes, use cases, and diff --git a/profile_inheritance_contract_test.go b/profile_inheritance_contract_test.go index 17e663e..9152f5f 100644 --- a/profile_inheritance_contract_test.go +++ b/profile_inheritance_contract_test.go @@ -13,19 +13,20 @@ import ( ) func aliasPrompt() map[string]any { - return map[string]any{"prompts": map[string]any{"prepared": promptDefinitionWithVersion("1", map[string]any{ + return map[string]any{"prompts": []any{map[string]any{ + "id": "prepared", "default_profile": "child", "messages": []any{map[string]any{"role": "user", "content": "hello"}}, "output": map[string]any{"format": "text", "validation_mode": "none"}, - })}} + }}} } func TestProfileInheritanceConfiguredBaseAliasWorkflow(t *testing.T) { baseID, baseEndpoint, baseModel, reasoning, timeout := "base", "https://profile.example/v1", "synthetic-model", "high", 120 engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{ - Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: promptkit.ProfileSource("application alias", map[string]promptkit.ProfileConfig{ - "base": {Endpoint: &baseEndpoint, Model: &baseModel}, - "child": {Extends: &baseID, ReasoningEffort: &reasoning, TimeoutSeconds: &timeout}, + Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: promptkit.ProfileSource("application alias", []promptkit.ProfileConfig{ + {ID: "base", Endpoint: &baseEndpoint, Model: &baseModel}, + {ID: "child", Extends: &baseID, ReasoningEffort: &reasoning, TimeoutSeconds: &timeout}, })}}}}, Main: &configkit.Input{Source: configkit.Mapping("prompt", aliasPrompt())}, }}) diff --git a/profiles.go b/profiles.go index 6efa3eb..1d46bf6 100644 --- a/profiles.go +++ b/profiles.go @@ -2,12 +2,15 @@ package promptkit import "go.maximumdirect.net/configkit" -// ProfileConfig is a presence-aware convenience value for a keyed execution +// ProfileConfig is a presence-aware convenience value for an identified execution // profile. Nil fields are omitted; non-nil pointers preserve zero and blank // values. A nil ExtraParams map is omitted; a non-nil empty map supplies an // empty mapping, not a clear operation. Raw ConfigKit Mapping inputs can // express explicit null. ProfileConfig has no stable JSON representation. +// Extends, when supplied, names a sibling profile in the same resource list. type ProfileConfig struct { + // ID is the local profile identity within its resource list. + ID string Extends, Backend, Endpoint, Model *string Temperature, TopP *float64 MaxTokens, TimeoutSeconds *int @@ -17,17 +20,18 @@ type ProfileConfig struct { ExtraParams map[string]any } -// ProfileSource creates a labeled ConfigKit mapping from keyed profile values. +// ProfileSource creates a labeled ConfigKit mapping containing profile records +// in caller order. IDs are explicit and duplicates are left for loading to reject. // It encodes fields without validating them; NewEngine validates the final // effective configuration after all selected source layers are combined. // Mapping captures a copy of supported input values at creation; unsupported // values fail when the source is loaded. -func ProfileSource(label string, profiles map[string]ProfileConfig) configkit.Source { - definitions := make(map[string]any, len(profiles)) - for id, config := range profiles { - fields := make(map[string]any) +func ProfileSource(label string, profiles []ProfileConfig) configkit.Source { + definitions := make([]any, 0, len(profiles)) + for _, config := range profiles { + fields := map[string]any{"id": config.ID} if config.Extends != nil { - fields["extends"] = *config.Extends + fields["extends"] = map[string]any{"id": *config.Extends} } if config.Backend != nil { fields["backend"] = *config.Backend @@ -65,7 +69,7 @@ func ProfileSource(label string, profiles map[string]ProfileConfig) configkit.So if config.ExtraParams != nil { fields["extra_params"] = providerMapForMapping(config.ExtraParams) } - definitions[id] = fields + definitions = append(definitions, fields) } return configkit.Mapping(label, map[string]any{"profiles": definitions}) } diff --git a/sources_contract_test.go b/sources_contract_test.go new file mode 100644 index 0000000..22e9e33 --- /dev/null +++ b/sources_contract_test.go @@ -0,0 +1,75 @@ +package promptkit_test + +import ( + "bytes" + "context" + "errors" + "strings" + "testing" + + "gitea.maximumdirect.net/eric/promptkit" + "go.maximumdirect.net/configkit" +) + +func TestProgrammaticSourcesPreserveOrderAndRejectDuplicates(t *testing.T) { + endpoint := "https://example.invalid/v1" + params := map[string]any{"visible": "original"} + backendSource := promptkit.BackendSource("backends", []promptkit.BackendConfig{ + {ID: "zeta", Endpoint: &endpoint, ExtraParams: params}, {ID: "alpha", Endpoint: &endpoint}, + }) + profileSource := promptkit.ProfileSource("profiles", []promptkit.ProfileConfig{ + {ID: "zeta", ExtraParams: params}, {ID: "alpha"}, + }) + params["visible"] = "changed" + engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{ + Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: backendSource}, {Source: profileSource}}}}, + }}) + if err != nil { + t.Fatal(err) + } + view, err := engine.InspectConfiguration() + if err != nil { + t.Fatal(err) + } + var report bytes.Buffer + if err := view.WriteEffective(&report); err != nil { + t.Fatal(err) + } + contents := report.String() + if strings.Count(contents, "original") != 2 || strings.Contains(contents, "changed") { + t.Fatalf("source ownership was lost: %s", contents) + } + for _, kind := range []string{"backends", "profiles"} { + for index, id := range []string{"zeta", "alpha"} { + entry, found := view.Explain(configkit.Keys(kind).Index(index).Key("id")) + if !found || !strings.Contains(entry.Effective.YAML, id) { + t.Fatalf("%s entry %d = %+v, want %s", kind, index, entry, id) + } + } + } + for _, source := range []configkit.Source{ + promptkit.BackendSource("duplicate backends", []promptkit.BackendConfig{{ID: "duplicate", Endpoint: &endpoint}, {ID: "duplicate", Endpoint: &endpoint}}), + promptkit.ProfileSource("duplicate profiles", []promptkit.ProfileConfig{{ID: "duplicate"}, {ID: "duplicate"}}), + } { + _, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Main: &configkit.Input{Source: source}}}) + if !errors.Is(err, promptkit.ErrInvalidConfig) { + t.Fatalf("duplicate source error = %v, want ErrInvalidConfig", err) + } + } +} + +func TestProgrammaticProfileVariantInherits(t *testing.T) { + parent, endpoint, model, timeout := "base", "https://example.invalid/v1", "synthetic-model", 15 + source := promptkit.ProfileSource("profiles", []promptkit.ProfileConfig{ + {ID: parent, Endpoint: &endpoint, Model: &model}, + {ID: "child", Extends: &parent, TimeoutSeconds: &timeout}, + }) + engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Main: &configkit.Input{Source: source}}}) + if err != nil { + t.Fatal(err) + } + profile, err := engine.InspectProfile(context.Background(), "child") + if err != nil || profile.EffectiveModelParams.Model != model || profile.EffectiveModelParams.Endpoint != endpoint || profile.EffectiveModelParams.TimeoutSeconds != timeout { + t.Fatalf("inherited profile = %+v, %v", profile, err) + } +}