Record backend and profile normalization
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
102
internal/configuration/resolve_targets.go
Normal file
102
internal/configuration/resolve_targets.go
Normal file
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user