From 2b847ac2ae15abf17469ead7cdf30b227db1f73c Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Wed, 30 Sep 2026 02:08:20 +0000 Subject: [PATCH] Add ConfigKit typed configuration loading core --- docs/internal/overview.md | 1 + docs/internal/sources.md | 13 ++ docs/oss/attribution.md | 33 +++ docs/roadmap/implementation.md | 2 + go.mod | 6 +- go.sum | 4 + internal/configuration/load.go | 301 ++++++++++++++++++++++++++++ internal/configuration/load_test.go | 138 +++++++++++++ 8 files changed, 497 insertions(+), 1 deletion(-) create mode 100644 internal/configuration/load.go create mode 100644 internal/configuration/load_test.go diff --git a/docs/internal/overview.md b/docs/internal/overview.md index 51d78e4..1609b06 100644 --- a/docs/internal/overview.md +++ b/docs/internal/overview.md @@ -17,6 +17,7 @@ contributor workflow and validation. | `examples/go-library/observe` | Demonstrates offline built-in HTTP retry and synchronous metadata observation across structured proposal and assessment runs using embedded sources, two profiles on one finite backend, and an application decision. It is not a public library package. | [Example program](../../examples/go-library/observe/main.go) | | `internal/backend` | Constructs each engine's immutable registry from maintained definitions and consumer additions, validates and defensively copies definitions through the shared JSON-value package, and consumes the LLM-owned OpenAI-compatible reserved request-field rule. | [Backend registry](../../internal/backend/registry.go) | | `internal/catalog` | Strictly validates imported immutable maintained backend and profile catalog assets before engine assembly uses them. | [Catalog adapter](../../internal/catalog/catalog.go), [internal sources](sources.md#profiles-and-built-ins) | +| `internal/configuration` | Loads keyed backend, profile, and prompt configuration through ConfigKit, recording effective field presence and validating final backend/profile values. It is not yet connected to engine assembly. | [Typed loader](../../internal/configuration/load.go), [internal sources](sources.md#typed-configuration-loading) | | `internal/capacity` | Owns engine-local bounded execution admission and FIFO model-generation permits for limited backend IDs, including cancellation-safe waiter removal and client wrapping. | [Internal capacity management](capacity.md) | | `internal/domain` | Defines internal framework values for requests, artifacts, prompt definitions, profiles, execution targets, rendering, generation, and validation, and owns source-neutral invariants for shared execution settings, OpenAI-compatible base endpoints, session identifiers, and output contracts. Source parsing, required fields, other source-specific normalization, defaulting, and boundary-specific error classification remain with their callers. | [Domain declarations](../../internal/domain/domain.go), [endpoint invariant](../../internal/domain/endpoint.go) | | `internal/engine` | Assembles external catalogs, Promptkit-owned profiles, repositories, registry, capacity, collaborators, repairer, and the one use-case runner for each engine instance. | [Engine assembly](../../internal/engine/engine.go), [internal sources](sources.md#profiles-and-built-ins), [internal runner](runner.md) | diff --git a/docs/internal/sources.md b/docs/internal/sources.md index 9ce53ce..1868e38 100644 --- a/docs/internal/sources.md +++ b/docs/internal/sources.md @@ -10,6 +10,19 @@ and the internal engine assembles them behind the public facade's source options The [framework format reference](../formats.md) owns the exact file fields, validation modes, built-in catalog, and source precedence. +## Typed Configuration Loading + +`internal/configuration` now contains a private ConfigKit adapter for keyed +backend, profile, and prompt mappings. It performs one layered load, records +effective field presence (including explicit null), normalizes backend and +profile fields through ConfigKit resolution, and validates final backend and +profile domain values. A lower-layer domain value replaced before the handoff +is not separately validated. Its focused tests exercise real ConfigKit loading. + +This loader is not yet wired into engine assembly. The public engine continues +to use the source and repository behavior described below; prompt resource +capture and full prompt validation are subsequent implementation work. + ## Prompt Definitions `internal/promptdef` uses one source-neutral flow for prompt selection and diff --git a/docs/oss/attribution.md b/docs/oss/attribution.md index a5dcfbd..3269233 100644 --- a/docs/oss/attribution.md +++ b/docs/oss/attribution.md @@ -16,6 +16,20 @@ License and notices: [CC BY-SA 4.0](licenses/CC-BY-SA-4.0.txt) | [Source](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843). +## ConfigKit + +Selected module: `gitea.maximumdirect.net/eric/ConfigKit` at `v0.5.0`. + +Copyright (C) 2026 Eric Rakestraw + +SPDX-License-Identifier: LGPL-3.0-or-later + +License and notices: [LGPL 3.0](../../LICENSE) | +[COPYRIGHT](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/dc09959cca4e105f49ec316c6d4ba585bd2340bd/COPYRIGHT) | +[LICENSE](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/dc09959cca4e105f49ec316c6d4ba585bd2340bd/LICENSE). + +[Source](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/dc09959cca4e105f49ec316c6d4ba585bd2340bd). + ## Promptkit OpenRouter Catalog Selected module: `gitea.maximumdirect.net/eric/promptkit-backend-openrouter/v2` @@ -91,3 +105,22 @@ License and notices: [MIT](licenses/MIT.txt) | [NOTICE](https://github.com/go-yaml/yaml/blob/f6f7691b1fdeb513f56608cd2c32c51f8194bf51/NOTICE). [Source](https://github.com/go-yaml/yaml/tree/f6f7691b1fdeb513f56608cd2c32c51f8194bf51). + +## Go YAML v4 + +Selected module: `go.yaml.in/yaml/v4` at `v4.0.0-rc.6` (transitive dependency). + +Copyright 2006-2010 Kirill Simonov + +Copyright 2011-2019 Canonical Ltd + +Copyright 2025 The go-yaml Project Contributors + +SPDX-License-Identifier: MIT AND Apache-2.0 + +License and notices: [MIT](licenses/MIT.txt) | +[Apache 2.0](licenses/Apache-2.0.txt) | +[LICENSE](https://github.com/yaml/go-yaml/blob/1c17b9cee72bf81ae73a8be50fbd7dd3a9985125/LICENSE) | +[NOTICE](https://github.com/yaml/go-yaml/blob/1c17b9cee72bf81ae73a8be50fbd7dd3a9985125/NOTICE). + +[Source](https://github.com/yaml/go-yaml/tree/1c17b9cee72bf81ae73a8be50fbd7dd3a9985125). diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 8b92af8..cfc1dd9 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -305,6 +305,8 @@ and cancellation remain at their established runtime boundaries. ## Stage 1 — Add The Dependency And Typed Loading Core +**Completed:** 2026-09-30. + Scope: module metadata, `internal/configuration` DTOs, real ConfigKit loading, and its focused tests. Leave engine assembly on the old path for this stage. diff --git a/go.mod b/go.mod index 030e5a4..1ecfbf4 100644 --- a/go.mod +++ b/go.mod @@ -3,10 +3,14 @@ module gitea.maximumdirect.net/eric/promptkit go 1.25.5 require ( + gitea.maximumdirect.net/eric/ConfigKit v0.5.0 gitea.maximumdirect.net/eric/promptkit-backend-openrouter/v2 v2.20260926.0 gitea.maximumdirect.net/eric/promptkit-backend-rakestrawhome/v2 v2.20260926.0 github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 gopkg.in/yaml.v3 v3.0.1 ) -require golang.org/x/text v0.14.0 // indirect +require ( + go.yaml.in/yaml/v4 v4.0.0-rc.6 // indirect + golang.org/x/text v0.14.0 // indirect +) diff --git a/go.sum b/go.sum index cb1d7ea..e6ee5eb 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +gitea.maximumdirect.net/eric/ConfigKit v0.5.0 h1:2KwG10+kApWM3avOoYA+p768xcbHUp7tQ2rqizBNpyg= +gitea.maximumdirect.net/eric/ConfigKit v0.5.0/go.mod h1:tYWpsrbLyu05r8+1MapWp5fN6ZrX9I8hU2xEPedYmYk= gitea.maximumdirect.net/eric/promptkit-backend-openrouter/v2 v2.20260926.0 h1:wdab+R+lgMZxzYwhbAuCrW7zdSQ1cW9ovBR6UzLmZR4= gitea.maximumdirect.net/eric/promptkit-backend-openrouter/v2 v2.20260926.0/go.mod h1:a3VBbh9oaGXD6ruxhtwHj+AWW6DY/SSyby1QtMmYSUo= gitea.maximumdirect.net/eric/promptkit-backend-rakestrawhome/v2 v2.20260926.0 h1:baCCIB6Ee5t0S4Zc++NTBIPcJEv5UPS6DlSrpZfykwg= @@ -6,6 +8,8 @@ github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxK github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4= +go.yaml.in/yaml/v4 v4.0.0-rc.6/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= diff --git a/internal/configuration/load.go b/internal/configuration/load.go new file mode 100644 index 0000000..4ee8ace --- /dev/null +++ b/internal/configuration/load.go @@ -0,0 +1,301 @@ +// Package configuration adapts ConfigKit's effective configuration to +// Promptkit's definition and execution boundaries. +package configuration + +import ( + "fmt" + "regexp" + "sort" + "strings" + + "gitea.maximumdirect.net/eric/ConfigKit" + "gitea.maximumdirect.net/eric/promptkit/internal/domain" + "gitea.maximumdirect.net/eric/promptkit/internal/jsonvalue" + "gitea.maximumdirect.net/eric/promptkit/internal/llm" +) + +type document struct { + Backends map[string]backendConfig `yaml:"backends"` + Profiles map[string]profileConfig `yaml:"profiles"` + Prompts map[string]map[string]promptConfig `yaml:"prompts"` +} + +type backendConfig struct { + Endpoint *string `yaml:"endpoint"` + APIKeyEnv *string `yaml:"api_key_env"` + ConcurrencyLimit *int `yaml:"concurrency_limit"` + QueueCapacity *int `yaml:"queue_capacity"` + ExtraParams map[string]any `yaml:"extra_params"` +} + +type profileConfig struct { + Backend *string `yaml:"backend"` + Endpoint *string `yaml:"endpoint"` + Model *string `yaml:"model"` + Temperature *float64 `yaml:"temperature"` + TopP *float64 `yaml:"top_p"` + MaxTokens *int `yaml:"max_tokens"` + TimeoutSeconds *int `yaml:"timeout_seconds"` + ServiceTier *string `yaml:"service_tier"` + ReasoningEffort *string `yaml:"reasoning_effort"` + APIKeyEnv *string `yaml:"api_key_env"` + APIKeyRequired *bool `yaml:"api_key_required"` + ExtraParams map[string]any `yaml:"extra_params"` +} + +type promptConfig struct { + DefaultProfile *string `yaml:"default_profile"` + Description *string `yaml:"description"` + SessionID *string `yaml:"session_id"` + Inputs []promptInputConfig `yaml:"inputs"` + Messages []promptMessageConfig `yaml:"messages"` + Output *promptOutputConfig `yaml:"output"` +} + +type promptInputConfig struct { + Name string `yaml:"name"` + Required bool `yaml:"required"` + ContentType string `yaml:"content_type"` + Description string `yaml:"description"` +} + +type promptMessageConfig struct { + Role *string `yaml:"role"` + Content *string `yaml:"content"` + ContentFile *string `yaml:"content_file"` + CacheControl *cacheControlConfig `yaml:"cache_control"` +} + +type cacheControlConfig struct { + Type *string `yaml:"type"` + TTL *string `yaml:"ttl"` +} + +type promptOutputConfig struct { + Format *string `yaml:"format"` + ValidationMode *string `yaml:"validation_mode"` + SchemaPath *string `yaml:"schema_path"` + RepairAttempts int `yaml:"repair_attempts"` +} + +// fieldState distinguishes omission, an explicit value, and an explicit null. +// Inherited values are explicit because ConfigKit has already expanded them. +type fieldState uint8 + +const ( + fieldAbsent fieldState = iota + fieldValue + fieldNull +) + +type loaded struct { + result *configkit.Result[document] + backends map[string]map[string]fieldState + profiles map[string]map[string]fieldState +} + +var envName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) + +var backendFields = []string{"endpoint", "api_key_env", "concurrency_limit", "queue_capacity", "extra_params"} +var profileFields = []string{"backend", "endpoint", "model", "temperature", "top_p", "max_tokens", "timeout_seconds", "service_tier", "reasoning_effort", "api_key_env", "api_key_required", "extra_params"} + +// load performs one ConfigKit lifecycle over the selected source tiers. Engine +// assembly is intentionally not connected to this adapter yet. +func load(main configkit.Input, underlays []configkit.Underlay) (*loaded, error) { + state := &loaded{ + backends: make(map[string]map[string]fieldState), + profiles: make(map[string]map[string]fieldState), + } + result, err := configkit.LoadLayered(main, configkit.LayeredOptions[document]{ + Underlays: underlays, + Definition: configkit.Definition[document]{ + Resolve: func(config *document, resolution *configkit.Resolution) error { + captureFields(config.Backends, "backends", backendFields, resolution, state.backends) + captureFields(config.Profiles, "profiles", profileFields, resolution, state.profiles) + resolveBackends(config.Backends, resolution) + resolveProfiles(config.Profiles, resolution) + return nil + }, + Validate: validateEffective, + }, + }) + if err != nil { + return nil, fmt.Errorf("load configuration: %w", err) + } + state.result = result + return state, nil +} + +func captureFields[T any](entries map[string]T, collection string, fields []string, resolution *configkit.Resolution, destination map[string]map[string]fieldState) { + for id := range entries { + states := make(map[string]fieldState, len(fields)) + for _, field := range fields { + info, exists := resolution.InputInfo(configkit.Keys(collection, id, field)) + switch { + case !exists || !info.Explicit: + states[field] = fieldAbsent + case info.Null: + states[field] = fieldNull + default: + states[field] = fieldValue + } + } + destination[id] = states + } +} + +func sortedKeys[T any](entries map[string]T) []string { + keys := make([]string, 0, len(entries)) + for key := range entries { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} + +func normalizeString(value **string, path configkit.Path, resolution *configkit.Resolution, normalized string) { + if value != nil && *value != nil && **value != normalized { + configkit.Normalize(resolution, path, value, &normalized, "normalize configuration value") + } +} + +func resolveBackends(backends map[string]backendConfig, resolution *configkit.Resolution) { + for _, id := range sortedKeys(backends) { + backend := backends[id] + base := configkit.Keys("backends", id) + if backend.Endpoint != nil { + endpoint, err := domain.NormalizeOpenAICompatibleBaseEndpoint(*backend.Endpoint) + if err != nil { + resolution.Error(base.Key("endpoint"), err.Error()) + } else { + normalizeString(&backend.Endpoint, base.Key("endpoint"), resolution, endpoint) + } + } + if backend.APIKeyEnv != nil { + normalizeString(&backend.APIKeyEnv, base.Key("api_key_env"), resolution, strings.TrimSpace(*backend.APIKeyEnv)) + } + if backend.ConcurrencyLimit != nil && *backend.ConcurrencyLimit > 0 && backend.QueueCapacity == nil { + capacity := 1024 + if resolution.IsExplicit(base.Key("queue_capacity")) { + configkit.Normalize(resolution, base.Key("queue_capacity"), &backend.QueueCapacity, &capacity, "select default queue capacity") + } else { + configkit.Default(resolution, base.Key("queue_capacity"), &backend.QueueCapacity, &capacity, "select default queue capacity") + } + } + backends[id] = backend + } +} + +func resolveProfiles(profiles map[string]profileConfig, resolution *configkit.Resolution) { + for _, id := range sortedKeys(profiles) { + profile := profiles[id] + base := configkit.Keys("profiles", id) + if profile.Endpoint != nil && *profile.Endpoint != "" { + endpoint, err := domain.NormalizeOpenAICompatibleBaseEndpoint(*profile.Endpoint) + if err != nil { + resolution.Error(base.Key("endpoint"), err.Error()) + } else { + normalizeString(&profile.Endpoint, base.Key("endpoint"), resolution, endpoint) + } + } + for _, field := range []struct { + name string + value **string + }{ + {"service_tier", &profile.ServiceTier}, + {"reasoning_effort", &profile.ReasoningEffort}, + {"api_key_env", &profile.APIKeyEnv}, + } { + if *field.value != nil { + normalizeString(field.value, base.Key(field.name), resolution, strings.TrimSpace(**field.value)) + } + } + profiles[id] = profile + } +} + +func validateEffective(config *document, validation *configkit.Validation) { + for _, id := range sortedKeys(config.Backends) { + backend := config.Backends[id] + base := configkit.Keys("backends", id) + if strings.TrimSpace(id) == "" { + validation.Error(base, "backend ID must not be blank") + } + if backend.Endpoint == nil || *backend.Endpoint == "" { + validation.Error(base.Key("endpoint"), "endpoint is required") + } + if backend.APIKeyEnv != nil && *backend.APIKeyEnv != "" && !envName.MatchString(*backend.APIKeyEnv) { + validation.Error(base.Key("api_key_env"), "environment variable name is invalid") + } + if backend.ConcurrencyLimit != nil && *backend.ConcurrencyLimit < 0 { + validation.Error(base.Key("concurrency_limit"), "concurrency limit must not be negative") + } + if backend.QueueCapacity != nil { + if *backend.QueueCapacity < 0 { + validation.Error(base.Key("queue_capacity"), "queue capacity must not be negative") + } + if backend.ConcurrencyLimit == nil || *backend.ConcurrencyLimit <= 0 { + validation.Error(base.Key("queue_capacity"), "queue capacity requires a positive concurrency limit") + } else if *backend.QueueCapacity > int(^uint(0)>>1)-*backend.ConcurrencyLimit { + validation.Error(base.Key("queue_capacity"), "total capacity overflows int") + } + } + validateExtraParams(backend.ExtraParams, base.Key("extra_params"), true, validation) + } + for _, id := range sortedKeys(config.Profiles) { + profile := config.Profiles[id] + base := configkit.Keys("profiles", id) + if strings.TrimSpace(id) == "" { + validation.Error(base, "profile ID must not be blank") + } + if profile.Backend != nil && *profile.Backend != "" { + if _, exists := config.Backends[*profile.Backend]; !exists { + validation.Error(base.Key("backend"), "backend is not defined") + } + } + if profile.Model != nil && *profile.Model != "" && strings.TrimSpace(*profile.Model) == "" { + validation.Error(base.Key("model"), "model must not be blank") + } + if profile.APIKeyEnv != nil && *profile.APIKeyEnv != "" && !envName.MatchString(*profile.APIKeyEnv) { + validation.Error(base.Key("api_key_env"), "environment variable name is invalid") + } + if profile.Temperature != nil { + if err := domain.ValidateExecutionTargetSettings(domain.ExecutionTarget{Temperature: *profile.Temperature}); err != nil { + validation.Error(base.Key("temperature"), err.Error()) + } + } + if profile.TopP != nil { + if err := domain.ValidateExecutionTargetSettings(domain.ExecutionTarget{TopP: *profile.TopP}); err != nil { + validation.Error(base.Key("top_p"), err.Error()) + } + } + if profile.MaxTokens != nil { + if err := domain.ValidateExecutionTargetSettings(domain.ExecutionTarget{MaxTokens: *profile.MaxTokens}); err != nil { + validation.Error(base.Key("max_tokens"), err.Error()) + } + } + if profile.TimeoutSeconds != nil { + if err := domain.ValidateExecutionTargetSettings(domain.ExecutionTarget{TimeoutSeconds: *profile.TimeoutSeconds}); err != nil { + validation.Error(base.Key("timeout_seconds"), err.Error()) + } + } + validateExtraParams(profile.ExtraParams, base.Key("extra_params"), false, validation) + } +} + +func validateExtraParams(values map[string]any, path configkit.Path, backend bool, validation *configkit.Validation) { + if values == nil { + return + } + if _, err := jsonvalue.CopyMap(values); err != nil { + validation.Error(path, "extra parameters must be a JSON-compatible map with nonempty keys") + return + } + if backend { + for key := range values { + if llm.IsReservedOpenAIChatRequestField(key) { + validation.Error(path.Key(key), "extra parameter conflicts with a request field") + } + } + } +} diff --git a/internal/configuration/load_test.go b/internal/configuration/load_test.go new file mode 100644 index 0000000..30bb52b --- /dev/null +++ b/internal/configuration/load_test.go @@ -0,0 +1,138 @@ +package configuration + +import ( + "errors" + "testing" + + "gitea.maximumdirect.net/eric/ConfigKit" +) + +func mapping(label string, values map[string]any) configkit.Input { + return configkit.Input{Source: configkit.Mapping(label, values)} +} + +func TestLoadComposesLiteralKeysAndEffectivePresence(t *testing.T) { + base := mapping("defaults", map[string]any{ + "backends": map[string]any{ + "local.v1": map[string]any{"endpoint": "http://localhost:8080/v1", "concurrency_limit": 2}, + }, + "profiles": map[string]any{ + "base/one": map[string]any{"backend": "local.v1", "temperature": 0.8, "top_p": 0.9, "api_key_required": true, + "extra_params": map[string]any{"provider": map[string]any{"first": 1, "second": 2}}}, + "child.v1": map[string]any{"extends": "base/one"}, + }, + "prompts": map[string]any{"invoice/summary": map[string]any{"v1.0": map[string]any{ + "messages": []any{map[string]any{"role": "user", "content": "Summarize."}}, + "output": map[string]any{"format": "text", "validation_mode": "basic"}, + }}}, + }) + overlay := mapping("operator", map[string]any{ + "backends": map[string]any{"local.v1": map[string]any{"queue_capacity": 0}}, + "profiles": map[string]any{"child.v1": map[string]any{ + "temperature": 0, "top_p": nil, "api_key_required": false, + "extra_params": map[string]any{"provider": map[string]any{"second": 3}}, + }}, + }) + loaded, err := load(configkit.Input{Source: configkit.NoSource()}, []configkit.Underlay{ + {Inputs: []configkit.Input{base}}, {Inputs: []configkit.Input{overlay}}, + }) + if err != nil { + t.Fatal(err) + } + profile := loaded.result.Config.Profiles["child.v1"] + if profile.Temperature == nil || *profile.Temperature != 0 || profile.TopP != nil || profile.APIKeyRequired == nil || *profile.APIKeyRequired { + t.Fatalf("effective values were not retained: %+v", profile) + } + if loaded.profiles["child.v1"]["temperature"] != fieldValue || loaded.profiles["child.v1"]["top_p"] != fieldNull || loaded.profiles["child.v1"]["api_key_required"] != fieldValue { + t.Fatalf("effective presence was lost: %+v", loaded.profiles["child.v1"]) + } + provider := profile.ExtraParams["provider"].(map[string]any) + if provider["first"] != int64(1) || provider["second"] != int64(3) { + t.Fatalf("recursive overlay failed: %+v", provider) + } + backend := loaded.result.Config.Backends["local.v1"] + if backend.QueueCapacity == nil || *backend.QueueCapacity != 0 || loaded.backends["local.v1"]["queue_capacity"] != fieldValue { + t.Fatalf("explicit zero queue capacity was lost: %+v", backend) + } + if loaded.result.Config.Prompts["invoice/summary"]["v1.0"].Messages[0].Content == nil { + t.Fatal("literal prompt ID and version were not decoded") + } +} + +func TestLoadRecordsBackendNormalizationAndNullDefault(t *testing.T) { + loaded, err := load(mapping("backend", map[string]any{"backends": map[string]any{ + "local": map[string]any{"endpoint": " HTTP://localhost:8080/v1 ", "api_key_env": " TOKEN_NAME ", + "concurrency_limit": 2, "queue_capacity": nil}, + }}), nil) + if err != nil { + t.Fatal(err) + } + backend := loaded.result.Config.Backends["local"] + if backend.Endpoint == nil || *backend.Endpoint != "http://localhost:8080/v1" || + backend.APIKeyEnv == nil || *backend.APIKeyEnv != "TOKEN_NAME" || + backend.QueueCapacity == nil || *backend.QueueCapacity != 1024 { + t.Fatalf("backend normalization/default was not applied: %+v", backend) + } + if loaded.backends["local"]["queue_capacity"] != fieldNull { + t.Fatalf("explicit null was not retained: %+v", loaded.backends["local"]) + } +} + +func TestLoadDefaultsAbsentQueueCapacity(t *testing.T) { + loaded, err := load(mapping("backend", map[string]any{"backends": map[string]any{ + "local": map[string]any{"endpoint": "http://localhost:8080/v1", "concurrency_limit": 1}, + }}), nil) + if err != nil { + t.Fatal(err) + } + backend := loaded.result.Config.Backends["local"] + if backend.QueueCapacity == nil || *backend.QueueCapacity != 1024 || loaded.backends["local"]["queue_capacity"] != fieldAbsent { + t.Fatalf("absent queue capacity did not receive its default: %+v", backend) + } +} + +func TestLoadRejectsStrictGroupConflict(t *testing.T) { + _, err := load(configkit.Input{Source: configkit.NoSource()}, []configkit.Underlay{{Inputs: []configkit.Input{ + mapping("first", map[string]any{"profiles": map[string]any{"shared": map[string]any{"model": "one"}}}), + mapping("second", map[string]any{"profiles": map[string]any{"shared": map[string]any{"model": "one"}}}), + }}}) + var loadError *configkit.LoadError + if !errors.As(err, &loadError) { + t.Fatalf("expected ConfigKit load error, got %v", err) + } +} + +func TestLoadValidatesOnlyFinalDomainValues(t *testing.T) { + underlay := mapping("defaults", map[string]any{ + "backends": map[string]any{"local": map[string]any{"endpoint": "not a URL"}}, + "profiles": map[string]any{"partial": map[string]any{"backend": "local"}}, + }) + main := mapping("operator", map[string]any{"backends": map[string]any{"local": map[string]any{"endpoint": "http://localhost:8080/v1"}}}) + loaded, err := load(main, []configkit.Underlay{{Inputs: []configkit.Input{underlay}}}) + if err != nil { + t.Fatalf("replaced lower-layer domain value should not fail: %v", err) + } + if loaded.result.Config.Profiles["partial"].Model != nil { + t.Fatal("unused partial profile unexpectedly gained a model") + } + + _, err = load(configkit.Input{Source: configkit.NoSource()}, []configkit.Underlay{{Inputs: []configkit.Input{ + mapping("invalid", map[string]any{"backends": map[string]any{"unused": map[string]any{"endpoint": "not a URL"}}}), + }}}) + var loadError *configkit.LoadError + if !errors.As(err, &loadError) { + t.Fatalf("expected path-aware effective backend failure, got %v", err) + } + if diagnostics := loadError.Diagnostics(); len(diagnostics) != 1 || diagnostics[0].Path.String() != "/backends/unused/endpoint" { + t.Fatalf("expected endpoint diagnostic, got %+v", diagnostics) + } +} + +func TestLoadKeepsSyntaxErrorsFromLowerTiers(t *testing.T) { + _, err := load(mapping("operator", map[string]any{"backends": map[string]any{"local": map[string]any{"endpoint": "http://localhost:8080"}}}), + []configkit.Underlay{{Inputs: []configkit.Input{{Source: configkit.Bytes("broken", []byte("backends: ["))}}}}) + var loadError *configkit.LoadError + if !errors.As(err, &loadError) { + t.Fatalf("expected lower-tier parsing error, got %v", err) + } +}