diff --git a/configuration_inspection_contract_test.go b/configuration_inspection_contract_test.go index 774ff09..302e8f2 100644 --- a/configuration_inspection_contract_test.go +++ b/configuration_inspection_contract_test.go @@ -23,13 +23,19 @@ func TestConfigurationInspectionReportsLiteralKeysAndRedactedHistory(t *testing. "endpoint": "https://example.invalid/v1", "api_key_env": "OPTIONAL_KEY", "extra_params": map[string]any{"token": "private-sentinel", "visible": "public-value"}, }}, - "profiles": map[string]any{ - "base.v1": map[string]any{"backend": "local", "model": "default-model"}, - "literal/id": map[string]any{"extends": "base.v1"}, - }, + "collections": map[string]any{"literal": map[string]any{ + "backends": map[string]any{"local": map[string]any{ + "endpoint": "https://example.invalid/v1", + "extra_params": map[string]any{"token": "namespaced-private-sentinel"}, + }}, + "profiles": map[string]any{ + "base.v1": map[string]any{"backend": "local", "model": "default-model"}, + "id": map[string]any{"extends": "base.v1"}, + }, + }}, })}}}, {Inputs: []configkit.Input{{Source: configkit.Mapping("operator", map[string]any{ - "profiles": map[string]any{"base.v1": map[string]any{"model": "operator-model"}}, + "collections": map[string]any{"literal": map[string]any{"profiles": map[string]any{"base.v1": map[string]any{"model": "operator-model"}}}}, })}}}, }, }}) @@ -40,17 +46,17 @@ func TestConfigurationInspectionReportsLiteralKeysAndRedactedHistory(t *testing. if err != nil { t.Fatalf("inspect configuration: %v", err) } - modelPath := configkit.Keys("profiles", "base.v1", "model") + modelPath := configkit.Keys("collections", "literal", "profiles", "base.v1", "model") model, found := view.Explain(modelPath) if !found || !strings.Contains(model.Effective.YAML, "operator-model") || len(model.History) < 2 { t.Fatalf("layered model explanation = (%+v, %t)", model, found) } - inheritedPath := configkit.Keys("profiles", "literal/id", "model") + inheritedPath := configkit.Keys("collections", "literal", "profiles", "id", "model") inherited, found := view.Explain(inheritedPath) if !found || !strings.Contains(inherited.Effective.YAML, "operator-model") || len(inherited.History) == 0 || !view.Metadata().IsExplicit(inheritedPath) { t.Fatalf("literal-key inheritance explanation = (%+v, %t)", inherited, found) } - if inheritedPath.String() != "/profiles/literal~1id/model" { + if inheritedPath.String() != "/collections/literal/profiles/id/model" { t.Fatalf("literal-key path = %q", inheritedPath) } secretPath := configkit.Keys("backends", "local", "extra_params", "token") @@ -58,6 +64,11 @@ func TestConfigurationInspectionReportsLiteralKeysAndRedactedHistory(t *testing. if !found || !secret.Effective.Redacted || strings.Contains(secret.Effective.YAML, "private-sentinel") { t.Fatalf("credential-shaped provider value was not redacted: (%+v, %t)", secret, found) } + namespacedSecretPath := configkit.Keys("collections", "literal", "backends", "local", "extra_params", "token") + namespacedSecret, found := view.Explain(namespacedSecretPath) + if !found || !namespacedSecret.Effective.Redacted || strings.Contains(namespacedSecret.Effective.YAML, "namespaced-private-sentinel") { + t.Fatalf("namespaced provider value was not redacted: (%+v, %t)", namespacedSecret, found) + } var effective bytes.Buffer if err := view.WriteEffective(&effective); err != nil { t.Fatalf("write effective configuration: %v", err) @@ -66,7 +77,7 @@ func TestConfigurationInspectionReportsLiteralKeysAndRedactedHistory(t *testing. t.Fatalf("unexpected effective report: %s", effective.String()) } var explanation bytes.Buffer - if err := view.WriteExplanation(&explanation, secretPath); err != nil || strings.Contains(explanation.String(), "private-sentinel") { + if err := view.WriteExplanation(&explanation, namespacedSecretPath); err != nil || strings.Contains(explanation.String(), "namespaced-private-sentinel") { t.Fatalf("secret explanation = (%q, %v)", explanation.String(), err) } diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 514cbcf..25815b3 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -508,6 +508,14 @@ release metadata, sibling inheritance, headerless collection fragments, resource named `version` in nested mappings, and header/declaration distinctions. Run `GOWORK=off go test ./internal/configuration .`. +Completion: final root and collection resources are interpreted from their +effective ConfigKit paths and indexed under full runtime IDs without re-keying +the retained result. Collection namespaces and local keys are validated; +collection release labels, sibling inheritance, inherited relative content, +presence/null handling, and namespaced secret redaction are covered. Capacity +and profile snapshots use full backend identities. Validation passed: +`GOWORK=off go test ./...`, `GOWORK=off go vet ./...`, and `git diff --check`. + ## Stage 9: Make Application Tests Independent Of Bundled Catalogs Scope: owned fixtures and examples that currently assume automatic provider data. diff --git a/internal/configuration/inspection.go b/internal/configuration/inspection.go index 80148cd..dfd0c18 100644 --- a/internal/configuration/inspection.go +++ b/internal/configuration/inspection.go @@ -66,6 +66,11 @@ func secretProviderPath(path configkit.Path) bool { } func providerExtraParamsPath(parts []string) bool { - return len(parts) >= 4 && parts[1] != "" && - (parts[0] == "backends" || parts[0] == "profiles") && parts[2] == "extra_params" + if len(parts) >= 4 && parts[1] != "" && + (parts[0] == "backends" || parts[0] == "profiles") && parts[2] == "extra_params" { + return true + } + return len(parts) >= 6 && parts[0] == "collections" && parts[1] != "" && + (parts[2] == "backends" || parts[2] == "profiles") && + parts[3] != "" && parts[4] == "extra_params" } diff --git a/internal/configuration/inspection_test.go b/internal/configuration/inspection_test.go index b872a12..6d00588 100644 --- a/internal/configuration/inspection_test.go +++ b/internal/configuration/inspection_test.go @@ -14,8 +14,11 @@ func TestSecretProviderPathRecognizesResourceExtraParams(t *testing.T) { }{ {name: "backend nested secret", path: configkit.Keys("backends", "provider", "extra_params", "nested", "Token"), want: true}, {name: "profile nested secret", path: configkit.Keys("profiles", "assistant", "extra_params", "authorization"), want: true}, + {name: "collection backend nested secret", path: configkit.Keys("collections", "provider", "backends", "default", "extra_params", "nested", "Token"), want: true}, + {name: "collection profile secret", path: configkit.Keys("collections", "assistant", "profiles", "model", "extra_params", "authorization"), want: true}, {name: "unrelated nested mapping", path: configkit.Keys("application", "backends", "provider", "extra_params", "token"), want: false}, {name: "extra params outside resource map", path: configkit.Keys("backends", "provider", "options", "extra_params", "token"), want: false}, + {name: "collection extra params outside resource map", path: configkit.Keys("collections", "provider", "settings", "backends", "default", "extra_params", "token"), want: false}, {name: "missing resource key", path: configkit.Keys("backends", "", "extra_params", "token"), want: false}, {name: "ordinary provider value", path: configkit.Keys("profiles", "assistant", "extra_params", "provider", "mode"), want: false}, } diff --git a/internal/configuration/listing_test.go b/internal/configuration/listing_test.go index 3e5cf7b..5fd1631 100644 --- a/internal/configuration/listing_test.go +++ b/internal/configuration/listing_test.go @@ -15,16 +15,11 @@ func TestLoadedListsSortedEffectiveIdentities(t *testing.T) { "backends": map[string]any{ "local": map[string]any{"endpoint": "http://localhost:8080/v1"}, }, - "profiles": map[string]any{ - "z/profile": map[string]any{"backend": "local", "model": "z-model"}, - "partial/profile": map[string]any{"backend": "local"}, - "a/profile": map[string]any{"backend": "local", "model": "a-model"}, - " z/profile ": map[string]any{"backend": "local", "model": "spaced-model"}, - }, - "prompts": map[string]any{ - "z/prompt": listingPromptDefinition(), - "a/prompt": listingPromptDefinition(), - " z/prompt ": listingPromptDefinition(), + "collections": map[string]any{ + "z": map[string]any{"profiles": map[string]any{"profile": map[string]any{"backend": "local", "model": "z-model"}}, "prompts": map[string]any{"prompt": listingPromptDefinition()}}, + "partial": map[string]any{"profiles": map[string]any{"profile": map[string]any{"backend": "local"}}}, + "a": map[string]any{"profiles": map[string]any{"profile": map[string]any{"backend": "local", "model": "a-model"}}, "prompts": map[string]any{"prompt": listingPromptDefinition()}}, + " z": map[string]any{"profiles": map[string]any{"profile ": map[string]any{"backend": "local", "model": "spaced-model"}}, "prompts": map[string]any{"prompt ": listingPromptDefinition()}}, }, }), nil) if err != nil { diff --git a/internal/configuration/load.go b/internal/configuration/load.go index 1e60149..a95a7bb 100644 --- a/internal/configuration/load.go +++ b/internal/configuration/load.go @@ -17,10 +17,18 @@ import ( ) type document struct { - Version *string `yaml:"version"` - Backends map[string]backendConfig `yaml:"backends"` - Profiles map[string]profileConfig `yaml:"profiles"` - Prompts map[string]promptConfig `yaml:"prompts"` + Version *string `yaml:"version"` + Backends map[string]backendConfig `yaml:"backends"` + Profiles map[string]profileConfig `yaml:"profiles"` + Prompts map[string]promptConfig `yaml:"prompts"` + Collections map[string]collectionConfig `yaml:"collections"` +} + +type collectionConfig struct { + ReleaseVersion *string `yaml:"release_version"` + Backends map[string]backendConfig `yaml:"backends"` + Profiles map[string]profileConfig `yaml:"profiles"` + Prompts map[string]promptConfig `yaml:"prompts"` } type backendConfig struct { @@ -126,6 +134,15 @@ type resourceBases struct { prompts configkit.Path } +type resourceSet struct { + isCollection bool + namespace string + bases resourceBases + backends map[string]backendConfig + profiles map[string]profileConfig + prompts map[string]promptConfig +} + func rootResourceBases() resourceBases { return resourceBases{ backends: configkit.Keys("backends"), @@ -134,6 +151,61 @@ func rootResourceBases() resourceBases { } } +func resourceSets(config *document) []resourceSet { + sets := make([]resourceSet, 0, len(config.Collections)+1) + sets = append(sets, resourceSet{ + bases: rootResourceBases(), backends: config.Backends, profiles: config.Profiles, prompts: config.Prompts, + }) + for _, namespace := range sortedKeys(config.Collections) { + collection := config.Collections[namespace] + base := configkit.Keys("collections", namespace) + sets = append(sets, resourceSet{ + isCollection: true, + namespace: namespace, + bases: resourceBases{ + backends: base.Key("backends"), + profiles: base.Key("profiles"), + prompts: base.Key("prompts"), + }, + backends: collection.Backends, + profiles: collection.Profiles, + prompts: collection.Prompts, + }) + } + return sets +} + +func indexedResourceID(namespace, localKey string) string { + qualified, valid := domain.QualifyLocalResourceID(namespace, localKey) + if valid { + return qualified + } + if namespace == "" { + return localKey + } + return namespace + "/" + localKey +} + +func indexedBackends(config *document) map[string]backendConfig { + backends := make(map[string]backendConfig) + for _, resources := range resourceSets(config) { + for localKey, definition := range resources.backends { + backends[indexedResourceID(resources.namespace, localKey)] = definition + } + } + return backends +} + +func indexedProfiles(config *document) map[string]profileConfig { + profiles := make(map[string]profileConfig) + for _, resources := range resourceSets(config) { + for localKey, definition := range resources.profiles { + profiles[indexedResourceID(resources.namespace, localKey)] = definition + } + } + return profiles +} + var envName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) var backendFields = []string{"endpoint", "api_key_env", "concurrency_limit", "queue_capacity", "extra_params", "smoke_tests"} @@ -146,7 +218,6 @@ func Load(main configkit.Input, underlays []configkit.Underlay) (*loaded, error) // load performs one ConfigKit lifecycle over the selected source tiers. func load(main configkit.Input, underlays []configkit.Underlay) (*loaded, error) { - bases := rootResourceBases() state := &loaded{ backends: make(map[string]map[string]fieldState), profiles: make(map[string]map[string]fieldState), @@ -162,16 +233,18 @@ func load(main configkit.Input, underlays []configkit.Underlay) (*loaded, error) }, Secret: secretProviderPath, Resolve: func(config *document, resolution *configkit.Resolution) error { - captureFields(config.Backends, bases.backends, backendFields, resolution, state.backends) - captureFields(config.Profiles, bases.profiles, profileFields, resolution, state.profiles) - capturePromptMetadata(config.Prompts, bases.prompts, resolution, state.promptMetadata) - resolveBackends(config.Backends, bases.backends, resolution) - resolveProfiles(config.Profiles, bases.profiles, resolution) - resolvePrompts(config.Prompts, bases.prompts, resolution) + for _, resources := range resourceSets(config) { + captureFields(resources.backends, resources.bases.backends, resources.namespace, backendFields, resolution, state.backends) + captureFields(resources.profiles, resources.bases.profiles, resources.namespace, profileFields, resolution, state.profiles) + capturePromptMetadata(resources.prompts, resources.bases.prompts, resources.namespace, resolution, state.promptMetadata) + resolveBackends(resources.backends, resources.bases.backends, resolution) + resolveProfiles(resources.profiles, resources.bases.profiles, resolution) + resolvePrompts(resources.prompts, resources.bases.prompts, resolution) + } return nil }, Validate: func(config *document, validation *configkit.Validation) { - validateEffective(config, bases, validation) + validateEffective(config, validation) }, }, }) @@ -179,8 +252,10 @@ func load(main configkit.Input, underlays []configkit.Underlay) (*loaded, error) return nil, fmt.Errorf("load configuration: %w", err) } state.result = result - if err := state.materializePrompts(result.Config.Prompts, bases.prompts); err != nil { - return nil, fmt.Errorf("load configuration: %w", err) + for _, resources := range resourceSets(&result.Config) { + if err := state.materializePrompts(resources.prompts, resources.bases.prompts, resources.namespace); err != nil { + return nil, fmt.Errorf("load configuration: %w", err) + } } if err := state.buildExecutionProfiles(); err != nil { return nil, fmt.Errorf("load configuration: %w", err) @@ -207,7 +282,7 @@ type promptInputPresence struct { description fieldState } -func capturePromptMetadata(prompts map[string]promptConfig, basePath configkit.Path, resolution *configkit.Resolution, destination map[string][]promptInputPresence) { +func capturePromptMetadata(prompts map[string]promptConfig, basePath configkit.Path, namespace string, resolution *configkit.Resolution, destination map[string][]promptInputPresence) { for _, id := range sortedKeys(prompts) { inputs := prompts[id].Inputs states := make([]promptInputPresence, len(inputs)) @@ -219,7 +294,7 @@ func capturePromptMetadata(prompts map[string]promptConfig, basePath configkit.P description: captureFieldState(path.Key("description"), resolution), } } - destination[id] = states + destination[indexedResourceID(namespace, id)] = states } } @@ -235,7 +310,7 @@ func captureFieldState(path configkit.Path, resolution *configkit.Resolution) fi } } -func captureFields[T any](entries map[string]T, basePath configkit.Path, fields []string, resolution *configkit.Resolution, destination map[string]map[string]fieldState) { +func captureFields[T any](entries map[string]T, basePath configkit.Path, namespace 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 { @@ -249,7 +324,7 @@ func captureFields[T any](entries map[string]T, basePath configkit.Path, fields states[field] = fieldValue } } - destination[id] = states + destination[indexedResourceID(namespace, id)] = states } } @@ -355,83 +430,111 @@ func resolveProfiles(profiles map[string]profileConfig, basePath configkit.Path, } } -func validateEffective(config *document, bases resourceBases, validation *configkit.Validation) { - for _, id := range sortedKeys(config.Backends) { - backend := config.Backends[id] - base := bases.backends.Key(id) - if strings.TrimSpace(id) == "" { - validation.Error(base, "backend ID must not be blank") +func validateEffective(config *document, validation *configkit.Validation) { + sets := resourceSets(config) + backendIDs := make(map[string]struct{}) + for _, resources := range sets { + if resources.isCollection && !domain.IsValidResourceNamespace(resources.namespace) { + validation.Error(configkit.Keys("collections", resources.namespace), "collection namespace must be nonblank and contain no slash") } - if backend.Endpoint == nil || *backend.Endpoint == "" { - validation.Error(base.Key("endpoint"), "endpoint is required") + for localKey := range resources.backends { + backendIDs[indexedResourceID(resources.namespace, localKey)] = struct{}{} } - 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") + } + for _, resources := range sets { + for _, id := range sortedKeys(resources.backends) { + backend := resources.backends[id] + base := resources.bases.backends.Key(id) + if !domain.IsValidLocalResourceKey(id) { + validation.Error(base, "backend key must be a nonblank local key without a slash") } - 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") + if backend.Endpoint == nil || *backend.Endpoint == "" { + validation.Error(base.Key("endpoint"), "endpoint is required") } - } - validateExtraParams(backend.ExtraParams, base.Key("extra_params"), true, validation) - if smokeTests := backend.SmokeTests; smokeTests != nil { - smokePath := base.Key("smoke_tests") - if smokeTests.DefaultProfile != nil && strings.TrimSpace(*smokeTests.DefaultProfile) == "" { - validation.Error(smokePath.Key("default_profile"), "default profile must not be blank") + if backend.APIKeyEnv != nil && *backend.APIKeyEnv != "" && !envName.MatchString(*backend.APIKeyEnv) { + validation.Error(base.Key("api_key_env"), "environment variable name is invalid") } - if authentication := smokeTests.Authentication; authentication != nil { - kindPath := smokePath.Key("authentication").Key("kind") - if authentication.Kind == nil { - validation.Error(kindPath, "authentication kind is required") - } else if *authentication.Kind != "none" && *authentication.Kind != "openrouter_key" { - validation.Error(kindPath, "authentication kind must be none or openrouter_key") + 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) + if smokeTests := backend.SmokeTests; smokeTests != nil { + smokePath := base.Key("smoke_tests") + if smokeTests.DefaultProfile != nil { + if strings.TrimSpace(*smokeTests.DefaultProfile) == "" { + validation.Error(smokePath.Key("default_profile"), "default profile must not be blank") + } else if !domain.IsValidResourceID(*smokeTests.DefaultProfile) { + validation.Error(smokePath.Key("default_profile"), "default profile reference is malformed") + } + } + if authentication := smokeTests.Authentication; authentication != nil { + kindPath := smokePath.Key("authentication").Key("kind") + if authentication.Kind == nil { + validation.Error(kindPath, "authentication kind is required") + } else if *authentication.Kind != "none" && *authentication.Kind != "openrouter_key" { + validation.Error(kindPath, "authentication kind must be none or openrouter_key") + } } } } - } - for _, id := range sortedKeys(config.Profiles) { - profile := config.Profiles[id] - base := bases.profiles.Key(id) - if strings.TrimSpace(id) == "" { - validation.Error(base, "profile ID must not be blank") + for _, id := range sortedKeys(resources.profiles) { + profile := resources.profiles[id] + base := resources.bases.profiles.Key(id) + if !domain.IsValidLocalResourceKey(id) { + validation.Error(base, "profile key must be a nonblank local key without a slash") + } + if profile.Backend != nil && *profile.Backend != "" { + if !domain.IsValidResourceID(*profile.Backend) { + validation.Error(base.Key("backend"), "backend reference is malformed") + } else if _, exists := backendIDs[*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.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) } - if profile.Backend != nil && *profile.Backend != "" { - if _, exists := config.Backends[*profile.Backend]; !exists { - validation.Error(base.Key("backend"), "backend is not defined") + for _, id := range sortedKeys(resources.prompts) { + prompt := resources.prompts[id] + base := resources.bases.prompts.Key(id) + if !domain.IsValidLocalResourceKey(id) { + validation.Error(base, "prompt key must be a nonblank local key without a slash") + } + if prompt.DefaultProfile != nil && *prompt.DefaultProfile != "" && !domain.IsValidResourceID(*prompt.DefaultProfile) { + validation.Error(base.Key("default_profile"), "default profile reference is malformed") } } - if profile.Model != nil && *profile.Model != "" && strings.TrimSpace(*profile.Model) == "" { - validation.Error(base.Key("model"), "model must not be blank") - } - 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) } } diff --git a/internal/configuration/load_test.go b/internal/configuration/load_test.go index 51b5149..7efe42d 100644 --- a/internal/configuration/load_test.go +++ b/internal/configuration/load_test.go @@ -85,39 +85,55 @@ func TestLoadConsumesAcceptedUnderlayVersionAndValidatesHeaderlessPayload(t *tes func TestLoadComposesLiteralKeysAndEffectivePresence(t *testing.T) { base := mapping("defaults", map[string]any{ + "version": int64(1), "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{ - "version": "v1.0", - "messages": []any{map[string]any{"role": "user", "content": "Summarize."}}, - "output": map[string]any{"format": "text", "validation_mode": "basic"}, + "collections": map[string]any{"base": map[string]any{ + "release_version": "", + "backends": map[string]any{ + "local.v1": map[string]any{"endpoint": "http://localhost:8080/v1", "concurrency_limit": 3}, + }, + "profiles": map[string]any{ + "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": "one"}, + "namespaced": map[string]any{"backend": "base/local.v1", "model": "collection-model"}, + }, + }, "invoice": map[string]any{ + "release_version": "r7", + "prompts": map[string]any{ + "summary": map[string]any{ + "version": "v1.0", + "messages": []any{map[string]any{"role": "user", "content": "Summarize."}}, + "output": map[string]any{"format": "text", "validation_mode": "basic"}, + }, + "version": map[string]any{ + "messages": []any{map[string]any{"role": "user", "content": "Nested ID."}}, + "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{ + "collections": map[string]any{"base": map[string]any{"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{ + loaded, err := load(mapping("main", map[string]any{"version": "1"}), []configkit.Underlay{ {Inputs: []configkit.Input{base}}, {Inputs: []configkit.Input{overlay}}, }) if err != nil { t.Fatal(err) } - profile := loaded.result.Config.Profiles["child.v1"] + profile := loaded.result.Config.Collections["base"].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"]) + if loaded.profiles["base/child.v1"]["temperature"] != fieldValue || loaded.profiles["base/child.v1"]["top_p"] != fieldNull || loaded.profiles["base/child.v1"]["api_key_required"] != fieldValue { + t.Fatalf("effective presence was lost: %+v", loaded.profiles["base/child.v1"]) } provider := profile.ExtraParams["provider"].(map[string]any) if provider["first"] != int64(1) || provider["second"] != int64(3) { @@ -127,9 +143,69 @@ func TestLoadComposesLiteralKeysAndEffectivePresence(t *testing.T) { 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"].Messages[0].Content == nil || loaded.result.Config.Prompts["invoice/summary"].Version == nil || *loaded.result.Config.Prompts["invoice/summary"].Version != "v1.0" { + if _, err := loaded.registry.GetBackend("base/local.v1"); err != nil { + t.Fatalf("collection backend was not indexed by its full ID: %v", err) + } + collectionProfile, exists := loaded.indexedProfiles["base/namespaced"] + if !exists || collectionProfile.ID != "base/namespaced" || collectionProfile.ResolvedTarget == nil || collectionProfile.ResolvedTarget.BackendID != "base/local.v1" { + t.Fatalf("collection profile snapshot = %+v, exists %t", collectionProfile, exists) + } + if loaded.result.Config.Collections["invoice"].Prompts["summary"].Messages[0].Content == nil || loaded.result.Config.Collections["invoice"].Prompts["summary"].Version == nil || *loaded.result.Config.Collections["invoice"].Prompts["summary"].Version != "v1.0" { t.Fatal("literal prompt ID and version were not decoded") } + if loaded.result.Config.Version == nil || *loaded.result.Config.Version != "1" || + loaded.result.Config.Collections["base"].ReleaseVersion == nil || *loaded.result.Config.Collections["base"].ReleaseVersion != "" || + loaded.result.Config.Collections["invoice"].ReleaseVersion == nil || *loaded.result.Config.Collections["invoice"].ReleaseVersion != "r7" { + t.Fatalf("root or collection version metadata was not retained: %+v", loaded.result.Config) + } + if _, _, err := loaded.prompt("invoice/version"); err != nil { + t.Fatalf("resource named version was not indexed under its qualified identity: %v", err) + } +} + +func TestLoadRejectsInvalidCollectionAndResourceKeys(t *testing.T) { + for _, test := range []struct { + name string + values map[string]any + message string + }{ + { + name: "namespace with separator", + values: map[string]any{"collections": map[string]any{"bad/name": map[string]any{}}}, + message: "collection namespace must be nonblank and contain no slash", + }, + { + name: "root resource with separator", + values: map[string]any{"backends": map[string]any{ + "bad/name": map[string]any{"endpoint": "https://example.test/v1"}, + }}, + message: "backend key must be a nonblank local key without a slash", + }, + { + name: "collection resource with separator", + values: map[string]any{"collections": map[string]any{"good": map[string]any{ + "prompts": map[string]any{"bad/name": map[string]any{ + "messages": []any{map[string]any{"role": "user", "content": "Hello."}}, + "output": map[string]any{"format": "text", "validation_mode": "basic"}, + }}, + }}}, + message: "prompt key must be a nonblank local key without a slash", + }, + } { + t.Run(test.name, func(t *testing.T) { + _, err := load(mapping("invalid names", test.values), nil) + var loadErr *configkit.LoadError + if err == nil || !errors.As(err, &loadErr) { + t.Fatalf("load error = %v, want ConfigKit validation error", err) + } + for _, diagnostic := range loadErr.Diagnostics() { + if diagnostic.Message == test.message { + return + } + } + t.Fatalf("load diagnostics = %+v, want message %q", loadErr.Diagnostics(), test.message) + }) + } } func TestLoadRecordsBackendNormalizationAndNullDefault(t *testing.T) { diff --git a/internal/configuration/prompts.go b/internal/configuration/prompts.go index 0ff01e2..5162b94 100644 --- a/internal/configuration/prompts.go +++ b/internal/configuration/prompts.go @@ -81,8 +81,10 @@ func resolvePrompts(prompts map[string]promptConfig, basePath configkit.Path, re } } -func (state *loaded) materializePrompts(prompts map[string]promptConfig, basePath configkit.Path) error { - state.prompts = make(map[string]*promptEntry, len(prompts)) +func (state *loaded) materializePrompts(prompts map[string]promptConfig, basePath configkit.Path, namespace string) error { + if state.prompts == nil { + state.prompts = make(map[string]*promptEntry) + } var metadata *configkit.Metadata lookupOrigin := func(path configkit.Path) (configkit.FieldInfo, bool) { if metadata == nil { @@ -96,11 +98,12 @@ func (state *loaded) materializePrompts(prompts map[string]promptConfig, basePat if strings.TrimSpace(id) == "" { return fmt.Errorf("%s: prompt ID must not be blank", promptBase) } - entry, err := state.materializePrompt(id, prompts[id].Version, prompts[id], promptBase, lookupOrigin) + fullID := indexedResourceID(namespace, id) + entry, err := state.materializePrompt(fullID, prompts[id].Version, prompts[id], promptBase, lookupOrigin) if err != nil { return err } - state.prompts[id] = entry + state.prompts[fullID] = entry } return nil } diff --git a/internal/configuration/prompts_test.go b/internal/configuration/prompts_test.go index 1820163..3e14290 100644 --- a/internal/configuration/prompts_test.go +++ b/internal/configuration/prompts_test.go @@ -16,8 +16,8 @@ import ( func TestLoadCapturesInheritedImportedContent(t *testing.T) { files := fstest.MapFS{ - "app/main.yaml": {Data: []byte("configkit:\n imports:\n - path: ../catalog/base.yaml\nprompts:\n child:\n extends: base\n")}, - "catalog/base.yaml": {Data: []byte("prompts:\n base:\n version: \"v1\"\n messages:\n - role: ' USER '\n cache_control:\n type: ' ephemeral '\n ttl: ' 1h '\n content_file: ./body.md\n output:\n format: text\n validation_mode: basic\n")}, + "app/main.yaml": {Data: []byte("configkit:\n imports:\n - path: ../catalog/base.yaml\ncollections:\n docs:\n prompts:\n child:\n extends: base\n")}, + "catalog/base.yaml": {Data: []byte("collections:\n docs:\n release_version: r1\n prompts:\n base:\n version: \"v1\"\n messages:\n - role: ' USER '\n cache_control:\n type: ' ephemeral '\n ttl: ' 1h '\n content_file: ./body.md\n output:\n format: text\n validation_mode: basic\n")}, "catalog/body.md": {Data: []byte("Captured {{.name}}")}, } namespace := configkit.NewFilesystem("embedded", files) @@ -32,7 +32,7 @@ func TestLoadCapturesInheritedImportedContent(t *testing.T) { } t.Fatal(err) } - definition, inspection, err := state.prompt("child") + definition, inspection, err := state.prompt("docs/child") if err != nil { t.Fatal(err) } @@ -43,7 +43,7 @@ func TestLoadCapturesInheritedImportedContent(t *testing.T) { if message.Role != "user" || message.CacheControl == nil || message.CacheControl.Type != "ephemeral" || message.CacheControl.TTL != "1h" { t.Fatalf("message metadata was not normalized: %+v", message) } - field, ok := state.result.Metadata().Lookup(configkit.Keys("prompts", "child", "messages").Index(0).Key("content_file")) + field, ok := state.result.Metadata().Lookup(configkit.Keys("collections", "docs", "prompts", "child", "messages").Index(0).Key("content_file")) if !ok || len(field.Origin.Locations) != 1 { t.Fatalf("content origin was lost during normalization: %+v", field) } @@ -57,12 +57,12 @@ func TestLoadCapturesInheritedImportedContent(t *testing.T) { t.Fatalf("content origin = %q, want catalog/base.yaml", originPath) } files["catalog/body.md"].Data = []byte("Changed") - again, againInspection, err := state.prompt("child") + again, againInspection, err := state.prompt("docs/child") if err != nil || again.Templates[0].Content != "Captured {{.name}}" || againInspection.PromptHash != inspection.PromptHash { t.Fatalf("captured definition changed after source mutation: %+v, %+v, %v", again, againInspection, err) } definition.Templates[0].Content = "caller mutation" - third, _, err := state.prompt("child") + third, _, err := state.prompt("docs/child") if err != nil || third.Templates[0].Content != "Captured {{.name}}" { t.Fatalf("caller mutation escaped into index: %+v, %v", third, err) } diff --git a/internal/configuration/targets.go b/internal/configuration/targets.go index d7fd6ad..771d295 100644 --- a/internal/configuration/targets.go +++ b/internal/configuration/targets.go @@ -12,9 +12,11 @@ import ( ) func (state *loaded) buildExecutionProfiles() error { - definitions := make(map[string]domain.Backend, len(state.result.Config.Backends)) - for _, id := range sortedKeys(state.result.Config.Backends) { - config := state.result.Config.Backends[id] + backendConfigs := indexedBackends(&state.result.Config) + profileConfigs := indexedProfiles(&state.result.Config) + definitions := make(map[string]domain.Backend, len(backendConfigs)) + for _, id := range sortedKeys(backendConfigs) { + config := backendConfigs[id] definition := domain.Backend{ID: id, ExtraParams: config.ExtraParams} if config.SmokeTests != nil { smokeTests := &domain.BackendSmokeTestConfig{DefaultProfile: config.SmokeTests.DefaultProfile} @@ -49,9 +51,9 @@ func (state *loaded) buildExecutionProfiles() error { return err } state.registry = registry - state.indexedProfiles = make(map[string]domain.ExecutionProfile, len(state.result.Config.Profiles)) - for _, id := range sortedKeys(state.result.Config.Profiles) { - config := state.result.Config.Profiles[id] + state.indexedProfiles = make(map[string]domain.ExecutionProfile, len(profileConfigs)) + for _, id := range sortedKeys(profileConfigs) { + config := profileConfigs[id] fields := state.profiles[id] target := defaults.ExecutionTargetDefault() if config.Backend != nil && *config.Backend != "" { diff --git a/internal/domain/resource_identity.go b/internal/domain/resource_identity.go index d9078a9..2bb66ba 100644 --- a/internal/domain/resource_identity.go +++ b/internal/domain/resource_identity.go @@ -38,3 +38,27 @@ func resourceNamespaceFromID(id string) (string, bool) { namespace, _, _ := strings.Cut(id, "/") return namespace, true } + +// IsValidResourceID reports whether id is an unqualified resource ID or one +// valid namespace/local-ID pair. +func IsValidResourceID(id string) bool { return validResourceID(id) } + +// IsValidResourceNamespace reports whether namespace is nonblank and contains +// no namespace separator. +func IsValidResourceNamespace(namespace string) bool { return validResourceNamespace(namespace) } + +// IsValidLocalResourceKey reports whether key is a nonblank key for a +// namespace-local resource mapping. +func IsValidLocalResourceKey(key string) bool { return validLocalResourceKey(key) } + +// QualifyLocalResourceID combines a validated namespace and local mapping key. +// An empty namespace leaves a valid unqualified key unchanged. +func QualifyLocalResourceID(namespace, key string) (string, bool) { + if !validLocalResourceKey(key) || (namespace != "" && !validResourceNamespace(namespace)) { + return "", false + } + if namespace == "" { + return key, true + } + return namespace + "/" + key, true +} diff --git a/listing_contract_test.go b/listing_contract_test.go index d38fe35..97e445f 100644 --- a/listing_contract_test.go +++ b/listing_contract_test.go @@ -111,23 +111,26 @@ func TestListIdentitiesPreserveContextErrorsWithoutPartialResults(t *testing.T) func TestListIdentitiesUseEffectiveLayeredMembership(t *testing.T) { underlay := configkit.Input{Source: configkit.Mapping("consumer defaults", map[string]any{ - "profiles": map[string]any{ - "base/profile": map[string]any{"endpoint": "https://example.test/v1", "model": "base-model"}, - "child/profile": map[string]any{"extends": "base/profile"}, - "overlay/profile": map[string]any{"endpoint": "https://example.test/v1", "model": "old-model"}, - }, + "collections": map[string]any{"shared": map[string]any{ + "profiles": map[string]any{ + "base": map[string]any{"endpoint": "https://example.test/v1", "model": "base-model"}, + "child": map[string]any{"extends": "base"}, + "overlay": map[string]any{"endpoint": "https://example.test/v1", "model": "old-model"}, + }, + "prompts": map[string]any{ + "prompt": promptDefinitionWithVersion("v1", listingContractPrompt()), + }, + }}, "prompts": map[string]any{ - "shared/prompt": promptDefinitionWithVersion("v1", listingContractPrompt()), "underlay-only": promptDefinitionWithVersion("v1", listingContractPrompt()), }, })} main := configkit.Input{Source: configkit.Mapping("application overlay", map[string]any{ - "profiles": map[string]any{ - "overlay/profile": map[string]any{"model": "effective-model"}, - }, + "collections": map[string]any{"shared": map[string]any{"profiles": map[string]any{ + "overlay": map[string]any{"model": "effective-model"}, + }}}, "prompts": map[string]any{ - "shared/prompt": promptDefinitionWithVersion("v2", listingContractPrompt()), - "main-only": promptDefinitionWithVersion("v3", listingContractPrompt()), + "main-only": promptDefinitionWithVersion("v3", listingContractPrompt()), }, })} engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{ @@ -143,24 +146,24 @@ func TestListIdentitiesUseEffectiveLayeredMembership(t *testing.T) { t.Fatal(err) } for _, id := range []string{ - "base/profile", "child/profile", "overlay/profile", + "shared/base", "shared/child", "shared/overlay", "promptkit-openrouter-standard", "promptkit-openrouter-auto", } { if !containsListingProfile(profiles, id) { t.Errorf("profile listing %#v does not include %q", profiles, id) } } - for _, id := range []string{"base/profile", "child/profile", "overlay/profile"} { + for _, id := range []string{"shared/base", "shared/child", "shared/overlay"} { if got := countListingProfile(profiles, id); got != 1 { t.Errorf("profile %q appears %d times, want 1", id, got) } } - overlay, err := engine.InspectProfile(context.Background(), "overlay/profile") + overlay, err := engine.InspectProfile(context.Background(), "shared/overlay") if err != nil || overlay == nil || overlay.EffectiveModelParams.Model != "effective-model" { t.Fatalf("overlay profile did not resolve to its effective definition: (%+v, %v)", overlay, err) } - child, err := engine.InspectProfile(context.Background(), "child/profile") + child, err := engine.InspectProfile(context.Background(), "shared/child") if err != nil || child == nil || child.EffectiveModelParams.Model != "base-model" { t.Fatalf("inherited child profile did not resolve its effective definition: (%+v, %v)", child, err) } @@ -171,13 +174,13 @@ func TestListIdentitiesUseEffectiveLayeredMembership(t *testing.T) { } wantPrompts := []promptkit.PromptIdentity{ {ID: "main-only", Version: stringPointerForTest("v3")}, - {ID: "shared/prompt", Version: stringPointerForTest("v2")}, + {ID: "shared/prompt", Version: stringPointerForTest("v1")}, {ID: "underlay-only", Version: stringPointerForTest("v1")}, } if !reflect.DeepEqual(prompts, wantPrompts) { t.Fatalf("effective prompt identities = %#v, want %#v", prompts, wantPrompts) } - if got := countListingPrompt(prompts, "shared/prompt", "v2"); got != 1 { + if got := countListingPrompt(prompts, "shared/prompt", "v1"); got != 1 { t.Fatalf("overlaid prompt identity appears %d times, want 1", got) } }