From 626134e5e1d300aa003b92ab2205d08b6c60a824 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Tue, 3 Nov 2026 03:27:17 +0000 Subject: [PATCH] Record backend and profile normalization --- docs/roadmap/implementation.md | 4 + internal/configuration/interpretation_test.go | 26 +++- internal/configuration/load.go | 127 +++++------------- internal/configuration/load_test.go | 18 ++- internal/configuration/resolve_targets.go | 102 ++++++++++++++ 5 files changed, 171 insertions(+), 106 deletions(-) create mode 100644 internal/configuration/resolve_targets.go diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 88e1a72..3879961 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -121,6 +121,10 @@ Validation: focused `go test ./internal/configuration`, once the intermediate callback wiring supports it. Cover absent/null/zero capacity and missing/zero/ positive concurrency, optional endpoint overrides, and invalid endpoints. +Completion: backend and profile fields are normalized during resolution with +field-level records and queue-default dependency evidence; endpoint validation +is non-mutating. Focused configuration tests pass on Go 1.27.0. + ## Stage 3: Record Prompt Metadata And Configured Messages Scope: prompt resolution helpers, `prompts.go`, and prompt loading tests. diff --git a/internal/configuration/interpretation_test.go b/internal/configuration/interpretation_test.go index 518b9a0..383c368 100644 --- a/internal/configuration/interpretation_test.go +++ b/internal/configuration/interpretation_test.go @@ -8,7 +8,7 @@ import ( "go.maximumdirect.net/configkit" ) -func TestTargetInterpretationPreservesComposedNestedValues(t *testing.T) { +func TestTargetResolutionNormalizesNestedValues(t *testing.T) { state, err := load(mapping("padded target", map[string]any{ "backends": []any{map[string]any{ "id": "local", "endpoint": " HTTPS://example.invalid/v1 ", "concurrency_limit": 1, @@ -33,17 +33,29 @@ func TestTargetInterpretationPreservesComposedNestedValues(t *testing.T) { t.Fatalf("interpreted profile = %+v, %v", profile, err) } composed := state.result.Config.Backends[0] - if *composed.Endpoint != " HTTPS://example.invalid/v1 " || composed.QueueCapacity != nil || - *composed.SmokeTests.Reachability.Endpoint != " https://example.invalid/health " || - *composed.SmokeTests.Authentication.Kind != " NONE " || *composed.SmokeTests.Authentication.Endpoint != " https://example.invalid/key " || - *state.result.Config.Profiles[0].ServiceTier != " standard " || *state.result.Config.Profiles[0].APIKeyEnv != " TOKEN " { - t.Fatalf("composed values were mutated: %+v", state.result.Config) + if *composed.Endpoint != "https://example.invalid/v1" || composed.QueueCapacity == nil || *composed.QueueCapacity != 1024 || + *composed.SmokeTests.Reachability.Endpoint != "https://example.invalid/health" || + *composed.SmokeTests.Authentication.Kind != "none" || *composed.SmokeTests.Authentication.Endpoint != "https://example.invalid/key" || + *state.result.Config.Profiles[0].ServiceTier != "standard" || *state.result.Config.Profiles[0].APIKeyEnv != "TOKEN" { + t.Fatalf("resolved values were not normalized: %+v", state.result.Config) } path := configkit.Keys("backends").Index(0).Key("smoke_tests").Key("authentication").Key("endpoint") field, found := state.result.Metadata().Lookup(path) - if !found || len(field.Origin.Locations) == 0 || field.Origin.Operation == configkit.ChangeNormalization { + if !found || field.Origin.Operation != configkit.ChangeNormalization { t.Fatalf("nested field origin changed: %+v", field) } + for _, path := range []configkit.Path{ + configkit.Keys("backends").Index(0).Key("endpoint"), + configkit.Keys("backends").Index(0).Key("smoke_tests").Key("reachability").Key("endpoint"), + configkit.Keys("backends").Index(0).Key("smoke_tests").Key("authentication").Key("kind"), + configkit.Keys("profiles").Index(0).Key("service_tier"), + configkit.Keys("profiles").Index(0).Key("api_key_env"), + } { + field, found := state.result.Metadata().Lookup(path) + if !found || field.Origin.Operation != configkit.ChangeNormalization { + t.Errorf("normalization at %v: %+v", path, field) + } + } } func TestPromptInterpretationPreservesComposedOutputAndRejectsDuplicateNames(t *testing.T) { diff --git a/internal/configuration/load.go b/internal/configuration/load.go index d1db903..4214bfc 100644 --- a/internal/configuration/load.go +++ b/internal/configuration/load.go @@ -4,7 +4,6 @@ package configuration import ( "fmt" - "net/http" "regexp" "sort" "strings" @@ -243,6 +242,10 @@ func configurationDefinition(state *loaded) configkit.Definition[document] { captureFields(resources.profiles, func(v profileConfig) string { return v.ID }, resources.bases.profiles, resources.namespace, profileFields, resolution, state.profiles) capturePromptMetadata(resources.prompts, resources.bases.prompts, resources.namespace, resolution, state.promptMetadata) } + for _, resources := range resourceSets(config) { + resolveBackends(resources.backends, resources.bases.backends, resolution) + resolveProfiles(resources.profiles, resources.bases.profiles, resolution) + } return nil }, Validate: func(config *document, validation *configkit.Validation) { @@ -372,6 +375,29 @@ func captureFields[T any](entries []T, identity func(T) string, basePath configk } } +// eligibleResourceList leaves invalid identities to the existing validation +// boundaries, without mutating a list that cannot be recorded by identity. +func eligibleResourceList[T any](entries []T, identity func(T) string) bool { + seen := make(map[string]struct{}, len(entries)) + for _, entry := range entries { + id := identity(entry) + if !domain.IsValidLocalResourceKey(id) { + return false + } + if _, exists := seen[id]; exists { + return false + } + seen[id] = struct{}{} + } + return true +} + +func recordStringNormalization(value **string, path configkit.Path, resolution *configkit.Resolution, normalized string) { + if *value != nil && **value != normalized { + configkit.Normalize(resolution, path, value, &normalized, "Normalize configured value") + } +} + func sortedKeys[T any](entries map[string]T) []string { keys := make([]string, 0, len(entries)) for key := range entries { @@ -383,16 +409,11 @@ func sortedKeys[T any](entries map[string]T) []string { func interpretConfiguration(config *document, validation *configkit.Validation) document { interpreted := *config - interpreted.Backends = interpretBackends(config.Backends, configkit.Keys("backends"), validation) - interpreted.Profiles = interpretProfiles(config.Profiles, configkit.Keys("profiles"), validation) interpreted.Prompts = interpretPrompts(config.Prompts) if config.Collections != nil { interpreted.Collections = make(map[string]collectionConfig, len(config.Collections)) for namespace, original := range config.Collections { collection := original - base := configkit.Keys("collections", namespace) - collection.Backends = interpretBackends(original.Backends, base.Key("backends"), validation) - collection.Profiles = interpretProfiles(original.Profiles, base.Key("profiles"), validation) collection.Prompts = interpretPrompts(original.Prompts) interpreted.Collections[namespace] = collection } @@ -400,94 +421,6 @@ func interpretConfiguration(config *document, validation *configkit.Validation) return interpreted } -func interpretBackends(source []backendConfig, basePath configkit.Path, validation *configkit.Validation) []backendConfig { - if source == nil { - return nil - } - backends := append([]backendConfig{}, source...) - for index := range backends { - backend := &backends[index] - base := basePath.Index(index) - if backend.Endpoint != nil { - endpoint, err := domain.NormalizeOpenAICompatibleBaseEndpoint(*backend.Endpoint) - if err != nil { - validation.Error(base.Key("endpoint"), err.Error()) - } else { - backend.Endpoint = &endpoint - } - } - if backend.APIKeyEnv != nil { - value := strings.TrimSpace(*backend.APIKeyEnv) - backend.APIKeyEnv = &value - } - if backend.SmokeTests != nil { - smokeTests := *backend.SmokeTests - backend.SmokeTests = &smokeTests - smokePath := base.Key("smoke_tests") - if smokeTests.Reachability != nil { - reachability := *smokeTests.Reachability - smokeTests.Reachability = &reachability - normalizeDiagnosticEndpoint(&reachability.Endpoint, smokePath.Key("reachability").Key("endpoint"), validation) - } - if smokeTests.Authentication != nil { - authentication := *smokeTests.Authentication - smokeTests.Authentication = &authentication - if authentication.Kind != nil { - kind := strings.ToLower(strings.TrimSpace(*authentication.Kind)) - authentication.Kind = &kind - } - normalizeDiagnosticEndpoint(&authentication.Endpoint, smokePath.Key("authentication").Key("endpoint"), validation) - } - } - if backend.ConcurrencyLimit != nil && *backend.ConcurrencyLimit > 0 && backend.QueueCapacity == nil { - capacity := 1024 - backend.QueueCapacity = &capacity - } - } - return backends -} - -func normalizeDiagnosticEndpoint(value **string, path configkit.Path, validation *configkit.Validation) { - if value == nil || *value == nil { - return - } - normalized := strings.TrimSpace(**value) - request, err := http.NewRequest(http.MethodGet, normalized, nil) - if normalized == "" || err != nil || request.URL == nil || - (strings.ToLower(request.URL.Scheme) != "http" && strings.ToLower(request.URL.Scheme) != "https") || - request.URL.Hostname() == "" { - validation.Error(path, "diagnostic endpoint must be an absolute HTTP(S) URL with a host") - return - } - *value = &normalized -} - -func interpretProfiles(source []profileConfig, basePath configkit.Path, validation *configkit.Validation) []profileConfig { - if source == nil { - return nil - } - profiles := append([]profileConfig{}, source...) - for index := range profiles { - profile := &profiles[index] - base := basePath.Index(index) - if profile.Endpoint != nil && *profile.Endpoint != "" { - endpoint, err := domain.NormalizeOpenAICompatibleBaseEndpoint(*profile.Endpoint) - if err != nil { - validation.Error(base.Key("endpoint"), err.Error()) - } else { - profile.Endpoint = &endpoint - } - } - for _, value := range []**string{&profile.ServiceTier, &profile.ReasoningEffort, &profile.APIKeyEnv} { - if *value != nil { - normalized := strings.TrimSpace(**value) - *value = &normalized - } - } - } - return profiles -} - func validateEffective(config *document, validation *configkit.Validation) { sets := resourceSets(config) backendIDs := make(map[string]struct{}) @@ -502,6 +435,7 @@ func validateEffective(config *document, validation *configkit.Validation) { for _, resources := range sets { for index, backend := range resources.backends { base := resources.bases.backends.Index(index) + validateBackendEndpoints(backend, base, validation) if !domain.IsValidLocalResourceKey(backend.ID) { validation.Error(base.Key("id"), "backend ID must be a nonblank local key without a slash") } @@ -546,6 +480,11 @@ func validateEffective(config *document, validation *configkit.Validation) { } for index, profile := range resources.profiles { base := resources.bases.profiles.Index(index) + if profile.Endpoint != nil && *profile.Endpoint != "" { + if _, err := domain.NormalizeOpenAICompatibleBaseEndpoint(*profile.Endpoint); err != nil { + validation.Error(base.Key("endpoint"), err.Error()) + } + } if !domain.IsValidLocalResourceKey(profile.ID) { validation.Error(base.Key("id"), "profile ID must be a nonblank local key without a slash") } diff --git a/internal/configuration/load_test.go b/internal/configuration/load_test.go index e8c31cb..8d2bdca 100644 --- a/internal/configuration/load_test.go +++ b/internal/configuration/load_test.go @@ -534,17 +534,25 @@ func TestLoadInterpretsBackendNormalizationAndNullDefault(t *testing.T) { t.Fatalf("backend normalization/default was not applied: %+v", backend) } composed := loaded.result.Config.Backends[0] - if composed.Endpoint == nil || *composed.Endpoint != " HTTP://localhost:8080/v1 " || - composed.APIKeyEnv == nil || *composed.APIKeyEnv != " TOKEN_NAME " || composed.QueueCapacity != nil { - t.Fatalf("composed backend was changed: %+v", composed) + if composed.Endpoint == nil || *composed.Endpoint != "http://localhost:8080/v1" || + composed.APIKeyEnv == nil || *composed.APIKeyEnv != "TOKEN_NAME" || composed.QueueCapacity == nil || *composed.QueueCapacity != 1024 { + t.Fatalf("resolved backend was not normalized: %+v", composed) } if loaded.backends["local"]["queue_capacity"] != fieldNull { t.Fatalf("explicit null was not retained: %+v", loaded.backends["local"]) } field, found := loaded.result.Metadata().Lookup(configkit.Keys("backends").Index(0).Key("endpoint")) - if !found || field.Origin.Operation == configkit.ChangeNormalization || len(field.Origin.Locations) == 0 { + if !found || field.Origin.Operation != configkit.ChangeNormalization { t.Fatalf("endpoint origin was not preserved: %+v", field) } + field, found = loaded.result.Metadata().Lookup(configkit.Keys("backends").Index(0).Key("queue_capacity")) + if !found || field.Origin.Operation != configkit.ChangeDerivedDefault { + t.Fatalf("queue capacity default was not recorded: %+v", field) + } + field, found = loaded.result.Metadata().Lookup(configkit.Keys("backends").Index(0).Key("concurrency_limit")) + if !found || field.Origin.Operation == configkit.ChangeNormalization || field.Origin.Operation == configkit.ChangeDerivedDefault { + t.Fatalf("unchanged concurrency acquired a resolution record: %+v", field) + } } func TestLoadBackendSmokeTestsOmissionAndNull(t *testing.T) { @@ -711,7 +719,7 @@ func TestLoadDefaultsAbsentQueueCapacity(t *testing.T) { t.Fatal(err) } backend, err := loaded.GetBackend("local") - if err != nil || backend.APIKeyEnv != "" || backend.QueueCapacity != 1024 || loaded.result.Config.Backends[0].QueueCapacity != nil || loaded.backends["local"]["queue_capacity"] != fieldAbsent { + if err != nil || backend.APIKeyEnv != "" || backend.QueueCapacity != 1024 || loaded.result.Config.Backends[0].QueueCapacity == nil || *loaded.result.Config.Backends[0].QueueCapacity != 1024 || loaded.backends["local"]["queue_capacity"] != fieldAbsent { t.Fatalf("absent queue capacity did not receive its default: %+v", backend) } } diff --git a/internal/configuration/resolve_targets.go b/internal/configuration/resolve_targets.go new file mode 100644 index 0000000..d8e741b --- /dev/null +++ b/internal/configuration/resolve_targets.go @@ -0,0 +1,102 @@ +package configuration + +import ( + "net/http" + "strings" + + "go.maximumdirect.net/configkit" + "go.maximumdirect.net/promptkit/internal/domain" +) + +func resolveBackends(entries []backendConfig, base configkit.Path, resolution *configkit.Resolution) { + if !eligibleResourceList(entries, func(v backendConfig) string { return v.ID }) { + return + } + for i := range entries { + entry := &entries[i] + path := base.Index(i) + if entry.Endpoint != nil { + if normalized, err := domain.NormalizeOpenAICompatibleBaseEndpoint(*entry.Endpoint); err == nil { + recordStringNormalization(&entry.Endpoint, path.Key("endpoint"), resolution, normalized) + } + } + if entry.APIKeyEnv != nil { + recordStringNormalization(&entry.APIKeyEnv, path.Key("api_key_env"), resolution, strings.TrimSpace(*entry.APIKeyEnv)) + } + if entry.ConcurrencyLimit != nil && *entry.ConcurrencyLimit > 0 && entry.QueueCapacity == nil { + capacity := 1024 + entry.QueueCapacity = &capacity + resolution.RecordDerivedDefault(path.Key("queue_capacity"), "Derive queue capacity", path.Key("concurrency_limit")) + } + if entry.SmokeTests == nil { + continue + } + smoke := entry.SmokeTests + smokePath := path.Key("smoke_tests") + if smoke.Reachability != nil { + resolveDiagnosticEndpoint(&smoke.Reachability.Endpoint, smokePath.Key("reachability").Key("endpoint"), resolution) + } + if smoke.Authentication != nil { + auth := smoke.Authentication + authPath := smokePath.Key("authentication") + if auth.Kind != nil { + recordStringNormalization(&auth.Kind, authPath.Key("kind"), resolution, strings.ToLower(strings.TrimSpace(*auth.Kind))) + } + resolveDiagnosticEndpoint(&auth.Endpoint, authPath.Key("endpoint"), resolution) + } + } +} + +func diagnosticEndpointValid(value string) bool { + normalized := strings.TrimSpace(value) + request, err := http.NewRequest(http.MethodGet, normalized, nil) + return normalized != "" && err == nil && request.URL != nil && (strings.EqualFold(request.URL.Scheme, "http") || strings.EqualFold(request.URL.Scheme, "https")) && request.URL.Hostname() != "" +} + +func validateBackendEndpoints(entry backendConfig, path configkit.Path, validation *configkit.Validation) { + if entry.Endpoint != nil { + if _, err := domain.NormalizeOpenAICompatibleBaseEndpoint(*entry.Endpoint); err != nil { + validation.Error(path.Key("endpoint"), err.Error()) + } + } + if entry.SmokeTests == nil { + return + } + smoke := entry.SmokeTests + path = path.Key("smoke_tests") + if smoke.Reachability != nil && smoke.Reachability.Endpoint != nil && !diagnosticEndpointValid(*smoke.Reachability.Endpoint) { + validation.Error(path.Key("reachability").Key("endpoint"), "diagnostic endpoint must be an absolute HTTP(S) URL with a host") + } + if smoke.Authentication != nil && smoke.Authentication.Endpoint != nil && !diagnosticEndpointValid(*smoke.Authentication.Endpoint) { + validation.Error(path.Key("authentication").Key("endpoint"), "diagnostic endpoint must be an absolute HTTP(S) URL with a host") + } +} + +func resolveDiagnosticEndpoint(value **string, path configkit.Path, resolution *configkit.Resolution) { + if *value != nil && diagnosticEndpointValid(**value) { + recordStringNormalization(value, path, resolution, strings.TrimSpace(**value)) + } +} + +func resolveProfiles(entries []profileConfig, base configkit.Path, resolution *configkit.Resolution) { + if !eligibleResourceList(entries, func(v profileConfig) string { return v.ID }) { + return + } + for i := range entries { + entry := &entries[i] + path := base.Index(i) + if entry.Endpoint != nil && *entry.Endpoint != "" { + if normalized, err := domain.NormalizeOpenAICompatibleBaseEndpoint(*entry.Endpoint); err == nil { + recordStringNormalization(&entry.Endpoint, path.Key("endpoint"), resolution, normalized) + } + } + for _, field := range []struct { + value **string + name string + }{{&entry.ServiceTier, "service_tier"}, {&entry.ReasoningEffort, "reasoning_effort"}, {&entry.APIKeyEnv, "api_key_env"}} { + if *field.value != nil { + recordStringNormalization(field.value, path.Key(field.name), resolution, strings.TrimSpace(**field.value)) + } + } + } +}