From 17b6c7bca551b7a997275684bec2ad1150456dfc Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Wed, 30 Sep 2026 02:46:33 +0000 Subject: [PATCH] Integrate released catalog sources with effective configuration --- docs/internal/overview.md | 3 +- docs/internal/sources.md | 18 +- docs/oss/attribution.md | 34 ++- docs/roadmap/implementation.md | 2 + go.mod | 2 + go.sum | 4 + internal/catalog/catalog.go | 267 +++------------------ internal/catalog/catalog_test.go | 376 ++++-------------------------- internal/configuration/load.go | 5 + internal/configuration/targets.go | 8 + internal/engine/engine.go | 8 +- internal/legacycatalog/catalog.go | 250 ++++++++++++++++++++ 12 files changed, 398 insertions(+), 579 deletions(-) create mode 100644 internal/legacycatalog/catalog.go diff --git a/docs/internal/overview.md b/docs/internal/overview.md index e14d3c3..832e2b0 100644 --- a/docs/internal/overview.md +++ b/docs/internal/overview.md @@ -16,7 +16,7 @@ contributor workflow and validation. | `examples/go-library/run` | Demonstrates an offline downstream consumer using a prompt file, in-memory profile, inline input, an injected deterministic model client, and `Run`. It is not a public library package. | [Example program](../../examples/go-library/run/main.go) | | `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/catalog` | Selects released keyed catalog assets and PromptKit-owned profile definitions as ConfigKit underlays; effective validation belongs to the configuration loader. | [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; records effective field presence, validates final values, captures indexed prompt definitions and referenced message content, and constructs immutable backend and presence-aware profile snapshots. 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) | @@ -25,6 +25,7 @@ contributor workflow and validation. | `internal/defaults` | Defines application-neutral framework constants and constructs the default execution target. It contains no CLI, server, or inbound HTTP limits. | [Framework defaults](../../internal/defaults/defaults.go) | | `internal/filecatalog` | Provides deterministic YAML discovery and path helpers for operating-system filesystems and `fs.FS` sources. | [File catalog](../../internal/filecatalog/catalog.go) | | `internal/jsonvalue` | Validates and deeply copies bounded JSON-compatible extra-parameter and prepared-schema trees while preserving supported concrete value types and rejecting cycles or excessive depth and work. | [JSON values](../../internal/jsonvalue/jsonvalue.go) | +| `internal/legacycatalog` | Temporarily supports the existing public engine construction path with the previously published flat catalogs until engine assembly switches to ConfigKit. | [Existing catalog adapter](../../internal/legacycatalog/catalog.go), [internal sources](sources.md#profiles-and-built-ins) | | `internal/promptdef` | Loads strictly decoded, validated prompt definitions from filesystem and `fs.FS` sources, including version selection and contained file-backed message content. | [Framework formats](../formats.md), [prompt-definition repository](../../internal/promptdef/filesystem_repository.go) | | `internal/profile` | Owns immutable in-memory and filesystem profile repositories, local profile validation, raw-source overlays with error-preserving fallback, and inherited-profile resolution. | [Framework formats](../formats.md), [profile repositories](../../internal/profile/filesystem_repository.go), [internal sources](sources.md#profiles-and-built-ins) | | `internal/prompt` | Renders prompt messages from Go templates with artifact, variable, session, and cache-control data. | [Go-template renderer](../../internal/prompt/go_renderer.go) | diff --git a/docs/internal/sources.md b/docs/internal/sources.md index 66d8a37..25deefa 100644 --- a/docs/internal/sources.md +++ b/docs/internal/sources.md @@ -88,15 +88,17 @@ publishes independently copied values from memory. It does not resolve profile inheritance. Configured consumer sources continue to use the lazy point lookup repositories described above. -`internal/catalog` validates the imported immutable OpenRouter and -Rakestrawhome asset modules as one private adapter boundary. It enforces their -manifest, layout, profile ownership, inheritance, and secret-safety rules -before returning raw catalog sources. Internal engine assembly uses those validated -catalogs as the lowest-precedence profile source. +`internal/catalog` selects the released keyed OpenRouter and Rakestrawhome +assets as one ConfigKit underlay and PromptKit-owned profiles as the next. +It discovers YAML inputs and supplies passive filesystem context without +parsing or pre-validating catalog definitions. The configuration loader +validates only the final effective backend and profile values, including +consumer overlays of maintained bases or backends. The new path has no catalog +source-layout, ownership-prefix, connection-field, or secret-key rejection. -Normalized external catalog profile IDs with the exact `promptkit-` prefix are -rejected at this adapter boundary. That reservation applies only to imported -catalogs; configured consumer sources retain the ordinary profile-ID rules. +Until public engine assembly switches to that loader, `internal/legacycatalog` +supports the existing public construction path with the published flat `/v2` +assets. The repository behavior below describes that temporary path. The overlay repository consults the next repository only when the higher-precedence repository reports that a profile is absent. A reliably diff --git a/docs/oss/attribution.md b/docs/oss/attribution.md index 3269233..06de135 100644 --- a/docs/oss/attribution.md +++ b/docs/oss/attribution.md @@ -30,7 +30,7 @@ License and notices: [LGPL 3.0](../../LICENSE) | [Source](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/dc09959cca4e105f49ec316c6d4ba585bd2340bd). -## Promptkit OpenRouter Catalog +## Promptkit OpenRouter Catalog v2 Selected module: `gitea.maximumdirect.net/eric/promptkit-backend-openrouter/v2` at `v2.20260926.0`. @@ -45,7 +45,22 @@ License and notices: [MIT](licenses/MIT.txt) | [Source](https://gitea.maximumdirect.net/eric/promptkit-backend-openrouter/src/commit/4f731d217e0bc39151c010f50fb5912706e7b6b3). -## Promptkit Rakestrawhome Catalog +## Promptkit OpenRouter Catalog v3 + +Selected module: `gitea.maximumdirect.net/eric/promptkit-backend-openrouter/v3` +at `v3.20260930.0`. + +Copyright (C) 2026 Eric Rakestraw + +SPDX-License-Identifier: MIT + +License and notices: [MIT](licenses/MIT.txt) | +[COPYRIGHT](https://gitea.maximumdirect.net/eric/promptkit-backend-openrouter/src/commit/f17d208cb7ad1fb043cda2266cd3011d11b40aa0/COPYRIGHT) | +[LICENSE](https://gitea.maximumdirect.net/eric/promptkit-backend-openrouter/src/commit/f17d208cb7ad1fb043cda2266cd3011d11b40aa0/LICENSE). + +[Source](https://gitea.maximumdirect.net/eric/promptkit-backend-openrouter/src/commit/f17d208cb7ad1fb043cda2266cd3011d11b40aa0). + +## Promptkit Rakestrawhome Catalog v2 Selected module: `gitea.maximumdirect.net/eric/promptkit-backend-rakestrawhome/v2` at `v2.20260926.0`. @@ -60,6 +75,21 @@ License and notices: [MIT](licenses/MIT.txt) | [Source](https://gitea.maximumdirect.net/eric/promptkit-backend-rakestrawhome/src/commit/48918a3ad39fd99843cda94a86133b1b3881d596). +## Promptkit Rakestrawhome Catalog v3 + +Selected module: `gitea.maximumdirect.net/eric/promptkit-backend-rakestrawhome/v3` +at `v3.20260930.0`. + +Copyright (C) 2026 Eric Rakestraw + +SPDX-License-Identifier: MIT + +License and notices: [MIT](licenses/MIT.txt) | +[COPYRIGHT](https://gitea.maximumdirect.net/eric/promptkit-backend-rakestrawhome/src/commit/993443d88c451901f4c917fa00c204fd3b54d28d/COPYRIGHT) | +[LICENSE](https://gitea.maximumdirect.net/eric/promptkit-backend-rakestrawhome/src/commit/993443d88c451901f4c917fa00c204fd3b54d28d/LICENSE). + +[Source](https://gitea.maximumdirect.net/eric/promptkit-backend-rakestrawhome/src/commit/993443d88c451901f4c917fa00c204fd3b54d28d). + ## jsonschema Selected module: `github.com/santhosh-tekuri/jsonschema/v6` at `v6.0.2`. diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 29a3823..ef61b0a 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -436,6 +436,8 @@ version, or legacy-to-new runtime converter to bypass it. ## Stage 6 — Integrate Catalog Sources And Maintained Profiles +**Completed:** 2026-09-30. + Scope: `internal/catalog`, maintained definitions, catalog integration tests, dependency pins/notices. Keep this separate from public API migration. diff --git a/go.mod b/go.mod index 1ecfbf4..f10f425 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,9 @@ 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-openrouter/v3 v3.20260930.0 gitea.maximumdirect.net/eric/promptkit-backend-rakestrawhome/v2 v2.20260926.0 + gitea.maximumdirect.net/eric/promptkit-backend-rakestrawhome/v3 v3.20260930.0 github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 gopkg.in/yaml.v3 v3.0.1 ) diff --git a/go.sum b/go.sum index e6ee5eb..0e2a728 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,12 @@ gitea.maximumdirect.net/eric/ConfigKit v0.5.0 h1:2KwG10+kApWM3avOoYA+p768xcbHUp7 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-openrouter/v3 v3.20260930.0 h1:lzfCDbzJQRGzIlRPRprnAV9hVVL5zUQy80VMQ0kbYa0= +gitea.maximumdirect.net/eric/promptkit-backend-openrouter/v3 v3.20260930.0/go.mod h1:HjS0f+3q0IdHCTEz+VVAEgIwIWJFAZUjuKdAtvtqKdg= gitea.maximumdirect.net/eric/promptkit-backend-rakestrawhome/v2 v2.20260926.0 h1:baCCIB6Ee5t0S4Zc++NTBIPcJEv5UPS6DlSrpZfykwg= gitea.maximumdirect.net/eric/promptkit-backend-rakestrawhome/v2 v2.20260926.0/go.mod h1:mtgD1E0SGm0e/I8Ffhz+AYX7GN7RKUWJXehNFIKHs4Y= +gitea.maximumdirect.net/eric/promptkit-backend-rakestrawhome/v3 v3.20260930.0 h1:h12ZTRldAmpLAomj0oQRGteFxHLN1LPE6XDl9zJNbm8= +gitea.maximumdirect.net/eric/promptkit-backend-rakestrawhome/v3 v3.20260930.0/go.mod h1:OEFBeKdjTsLzqJwLmXI2O5gG3r64FktcVJVb3mBrRfg= github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= diff --git a/internal/catalog/catalog.go b/internal/catalog/catalog.go index 8dfdb53..ebd43a5 100644 --- a/internal/catalog/catalog.go +++ b/internal/catalog/catalog.go @@ -1,249 +1,52 @@ -// Package catalog validates immutable maintained backend catalog assets. +// Package catalog selects the maintained configuration assets supplied with +// PromptKit. ConfigKit owns their composition and effective validation. package catalog import ( - "bytes" "context" - "encoding/json" - "errors" "fmt" - "io" "io/fs" - "path" - "sort" - "strings" + "gitea.maximumdirect.net/eric/ConfigKit" + openrouter "gitea.maximumdirect.net/eric/promptkit-backend-openrouter/v3" + rakestrawhome "gitea.maximumdirect.net/eric/promptkit-backend-rakestrawhome/v3" "gitea.maximumdirect.net/eric/promptkit/internal/backend" - "gitea.maximumdirect.net/eric/promptkit/internal/domain" - "gitea.maximumdirect.net/eric/promptkit/internal/profile" + "gitea.maximumdirect.net/eric/promptkit/internal/filecatalog" ) -// Source identifies one immutable backend catalog asset tree. -type Source struct { - Name string - ExpectedBackendID string - FS fs.FS - Root string -} - -// Set is the validated maintained backend and raw profile catalog. -type Set struct { - Backends []domain.Backend - Profiles profile.Repository - profileIDs []string -} - -// Load validates and combines immutable catalog sources in source order. -func Load(sources ...Source) (Set, error) { - if len(sources) == 0 { - return Set{}, errors.New("at least one catalog source is required") - } - loaded := Set{Backends: make([]domain.Backend, 0, len(sources))} - names := map[string]bool{} - backendIDs := map[string]bool{} - profileIDs := map[string]bool{} - for _, source := range sources { - if err := validateSource(source, names); err != nil { - return Set{}, err - } - names[source.Name] = true - definition, err := loadBackend(source) - if err != nil { - return Set{}, err - } - if backendIDs[definition.ID] { - return Set{}, fmt.Errorf("catalog %s: backend ID duplicates an earlier catalog", source.Name) - } - backendIDs[definition.ID] = true - repository, metadata, err := profile.LoadFSRepository(context.Background(), source.FS, path.Join(source.Root, "profiles")) - if err != nil { - return Set{}, fmt.Errorf("catalog %s: %w", source.Name, err) - } - if len(metadata) == 0 { - return Set{}, fmt.Errorf("catalog %s: profiles must not be empty", source.Name) - } - resolvingRepository := profile.NewResolvingRepository(repository) - for _, entry := range metadata { - if strings.HasPrefix(entry.ID, "promptkit-") { - return Set{}, fmt.Errorf("catalog %s: %s uses the reserved promptkit- profile ID prefix", source.Name, entry.Path) - } - if profileIDs[entry.ID] { - return Set{}, fmt.Errorf("catalog %s: %s duplicates an earlier profile ID", source.Name, entry.Path) - } - if containsField(entry.ExplicitFields, "endpoint") || containsField(entry.ExplicitFields, "api_key_env") { - return Set{}, fmt.Errorf("catalog %s: %s contains connection metadata", source.Name, entry.Path) - } - value, err := repository.GetProfile(context.Background(), entry.ID) - if err != nil { - return Set{}, fmt.Errorf("catalog %s: %s: %w", source.Name, entry.Path, err) - } - if err := rejectSecretKeys(value.ExtraParams); err != nil { - return Set{}, fmt.Errorf("catalog %s: %s: prohibited extra parameter key", source.Name, entry.Path) - } - resolved, err := resolvingRepository.GetProfile(context.Background(), entry.ID) - if err != nil { - return Set{}, fmt.Errorf("catalog %s: %s has invalid profile inheritance", source.Name, entry.Path) - } - if resolved.BackendID != definition.ID { - return Set{}, fmt.Errorf("catalog %s: %s selects a different backend", source.Name, entry.Path) - } - profileIDs[entry.ID] = true - loaded.profileIDs = append(loaded.profileIDs, entry.ID) - } - if loaded.Profiles == nil { - loaded.Profiles = repository - } else { - loaded.Profiles = profile.NewOverlayRepository(loaded.Profiles, repository) - } - loaded.Backends = append(loaded.Backends, definition) - } - sort.Strings(loaded.profileIDs) - return loaded, nil -} - -func validateSource(source Source, names map[string]bool) error { - if strings.TrimSpace(source.Name) == "" || source.Name != strings.TrimSpace(source.Name) { - return errors.New("catalog source name must not be blank") - } - if names[source.Name] { - return fmt.Errorf("duplicate catalog source name %q", source.Name) - } - if source.FS == nil { - return fmt.Errorf("catalog %s: filesystem is nil", source.Name) - } - if source.Root == "." || source.Root == "" || !fs.ValidPath(source.Root) { - return fmt.Errorf("catalog %s: asset root is invalid", source.Name) - } - if strings.TrimSpace(source.ExpectedBackendID) == "" { - return fmt.Errorf("catalog %s: expected backend ID is blank", source.Name) - } - return validateLayout(source) -} - -func validateLayout(source Source) error { - manifestPath := path.Join(source.Root, "backend.json") - profilesRoot := path.Join(source.Root, "profiles") - return fs.WalkDir(source.FS, source.Root, func(assetPath string, entry fs.DirEntry, err error) error { - if err != nil { - return err - } - if assetPath == source.Root { - if !entry.IsDir() { - return fmt.Errorf("catalog %s: invalid asset path %s", source.Name, assetPath) - } - return nil - } - if entry.IsDir() { - if assetPath == profilesRoot || strings.HasPrefix(assetPath, profilesRoot+"/") { - return nil - } - return fmt.Errorf("catalog %s: invalid asset path %s", source.Name, assetPath) - } - if assetPath == manifestPath && entry.Type().IsRegular() { - return nil - } - if strings.HasPrefix(assetPath, profilesRoot+"/") && entry.Type().IsRegular() && strings.HasSuffix(assetPath, ".yml") { - return nil - } - return fmt.Errorf("catalog %s: invalid asset path %s", source.Name, assetPath) - }) -} - -func loadBackend(source Source) (domain.Backend, error) { - data, err := fs.ReadFile(source.FS, path.Join(source.Root, "backend.json")) +// Underlays selects external catalogs followed by PromptKit-owned profiles. +// Neither group is interpreted before the final effective configuration loads. +func Underlays() ([]configkit.Underlay, error) { + openrouterInputs, err := assetInputs("OpenRouter catalog", openrouter.FS(), openrouter.Root) if err != nil { - return domain.Backend{}, fmt.Errorf("catalog %s: backend.json: %w", source.Name, err) + return nil, err } - type manifest struct { - SchemaVersion *int `json:"schema_version"` - ID *string `json:"id"` - Endpoint *string `json:"endpoint"` - APIKeyEnv *string `json:"api_key_env"` - ConcurrencyLimit *int `json:"concurrency_limit"` - QueueCapacity *int `json:"queue_capacity"` - ExtraParams json.RawMessage `json:"extra_params"` - } - decoder := json.NewDecoder(bytes.NewReader(data)) - decoder.DisallowUnknownFields() - var value manifest - if err := decoder.Decode(&value); err != nil { - return domain.Backend{}, fmt.Errorf("catalog %s: backend.json: invalid manifest", source.Name) - } - var trailing any - if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { - return domain.Backend{}, fmt.Errorf("catalog %s: backend.json: invalid manifest", source.Name) - } - if value.SchemaVersion == nil || *value.SchemaVersion != 1 || value.ID == nil || value.Endpoint == nil || value.APIKeyEnv == nil || value.ConcurrencyLimit == nil || value.QueueCapacity == nil || value.ExtraParams == nil { - return domain.Backend{}, fmt.Errorf("catalog %s: backend.json: required field is missing or unsupported", source.Name) - } - if *value.ID != source.ExpectedBackendID { - return domain.Backend{}, fmt.Errorf("catalog %s: backend ID does not match expected ID", source.Name) - } - if strings.TrimSpace(*value.APIKeyEnv) == "" { - return domain.Backend{}, fmt.Errorf("catalog %s: backend.json: api key environment variable must not be blank", source.Name) - } - extraParams, err := decodeExtraParams(value.ExtraParams) + rakestrawhomeInputs, err := assetInputs("Rakestrawhome catalog", rakestrawhome.FS(), rakestrawhome.Root) if err != nil { - return domain.Backend{}, fmt.Errorf("catalog %s: backend.json: invalid extra parameters", source.Name) + return nil, err } - if err := rejectSecretKeys(extraParams); err != nil { - return domain.Backend{}, fmt.Errorf("catalog %s: backend.json: prohibited extra parameter key", source.Name) - } - normalized, err := backend.NormalizeDefinition(domain.Backend{ID: *value.ID, Endpoint: *value.Endpoint, APIKeyEnv: *value.APIKeyEnv, ExtraParams: extraParams, ConcurrencyLimit: *value.ConcurrencyLimit, QueueCapacity: *value.QueueCapacity, QueueCapacitySet: true}) + catalogInputs := append(openrouterInputs, rakestrawhomeInputs...) + owned := configkit.Input{Source: configkit.Mapping("PromptKit-owned profiles", map[string]any{ + "profiles": map[string]any{ + "promptkit-openrouter-standard": map[string]any{"extends": "gemini-flash-latest"}, + "promptkit-openrouter-auto": map[string]any{"backend": backend.OpenRouterID, "model": "openrouter/auto"}, + }, + })} + return []configkit.Underlay{{Inputs: catalogInputs}, {Inputs: []configkit.Input{owned}}}, nil +} + +func assetInputs(label string, filesystem fs.FS, root string) ([]configkit.Input, error) { + files, err := filecatalog.FindFSYAMLFiles(context.Background(), filesystem, root) if err != nil { - return domain.Backend{}, fmt.Errorf("catalog %s: backend.json: invalid backend definition", source.Name) + return nil, fmt.Errorf("discover %s configuration: %w", label, err) } - return normalized, nil -} - -func decodeExtraParams(data []byte) (map[string]any, error) { - if bytes.Equal(bytes.TrimSpace(data), []byte("null")) { - return nil, nil - } - decoder := json.NewDecoder(bytes.NewReader(data)) - decoder.UseNumber() - var value map[string]any - if err := decoder.Decode(&value); err != nil || value == nil { - return nil, errors.New("extra parameters must be an object or null") - } - var trailing any - if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { - return nil, errors.New("extra parameters must contain exactly one JSON value") - } - return value, nil -} - -func containsField(fields []string, target string) bool { - for _, field := range fields { - if field == target { - return true - } - } - return false -} - -func rejectSecretKeys(value map[string]any) error { - return rejectSecretValue(value) -} - -func rejectSecretValue(value any) error { - switch value := value.(type) { - case map[string]any: - for key, child := range value { - switch strings.ToLower(key) { - case "api_key", "apikey", "authorization", "credential", "credentials", "password", "secret", "token", "access_token": - return errors.New("prohibited key") - } - if err := rejectSecretValue(child); err != nil { - return err - } - } - case []any: - for _, child := range value { - if err := rejectSecretValue(child); err != nil { - return err - } - } - } - return nil + namespace := configkit.NewFilesystem(label, filesystem) + inputs := make([]configkit.Input, 0, len(files)) + for _, name := range files { + inputs = append(inputs, configkit.Input{ + Source: namespace.File(name), + Filesystem: &configkit.FilesystemContext{FS: &namespace, Root: root}, + }) + } + return inputs, nil } diff --git a/internal/catalog/catalog_test.go b/internal/catalog/catalog_test.go index 7b99d13..2513f5d 100644 --- a/internal/catalog/catalog_test.go +++ b/internal/catalog/catalog_test.go @@ -2,355 +2,67 @@ package catalog import ( "context" - "encoding/json" - "fmt" - "io/fs" - "strings" + "errors" "testing" - "testing/fstest" - openrouter "gitea.maximumdirect.net/eric/promptkit-backend-openrouter/v2" - rakestrawhome "gitea.maximumdirect.net/eric/promptkit-backend-rakestrawhome/v2" + "gitea.maximumdirect.net/eric/ConfigKit" + "gitea.maximumdirect.net/eric/promptkit/internal/configuration" ) -func TestLoadPublishedCatalogs(t *testing.T) { - loaded, err := Load( - Source{Name: "OpenRouter", ExpectedBackendID: "openrouter", FS: openrouter.FS(), Root: openrouter.Root}, - Source{Name: "Rakestrawhome", ExpectedBackendID: "rakestrawhome", FS: rakestrawhome.FS(), Root: rakestrawhome.Root}, - ) +func TestPublishedCatalogsResolveThroughEffectiveConfiguration(t *testing.T) { + underlays, err := Underlays() if err != nil { - t.Fatalf("load published catalogs: %v", err) + t.Fatal(err) } - if loaded.Profiles == nil || len(loaded.profileIDs) == 0 { - t.Fatalf("published catalogs have no usable profiles: %#v", loaded) + state, err := configuration.Load(configkit.Input{Source: configkit.NoSource()}, underlays) + if err != nil { + t.Fatal(err) } - if _, err := loaded.Profiles.GetProfile(context.Background(), "gemini-flash-latest"); err != nil { - t.Fatalf("load maintained standard-profile base: %v", err) + openrouter, err := state.GetBackend("openrouter") + if err != nil || openrouter.Endpoint != "https://openrouter.ai/api/v1" || openrouter.ConcurrencyLimit != 16 || openrouter.QueueCapacity != 1024 { + t.Fatalf("OpenRouter backend: %+v, %v", openrouter, err) } - - expectedBackends := map[string]struct { - endpoint string - apiKeyEnv string - concurrencyLimit int - queueCapacity int - }{ - "openrouter": { - endpoint: "https://openrouter.ai/api/v1", - apiKeyEnv: "OPENROUTER_API_KEY", - concurrencyLimit: 16, - queueCapacity: 1024, - }, - "rakestrawhome": { - endpoint: "https://inference.ai.rakestrawhome.com/v1", - apiKeyEnv: "RAKESTRAWHOME_INFERENCE_API_KEY", - concurrencyLimit: 4, - queueCapacity: 1024, - }, + rakestrawhome, err := state.GetBackend("rakestrawhome") + if err != nil || rakestrawhome.Endpoint != "https://inference.ai.rakestrawhome.com/v1" || rakestrawhome.ConcurrencyLimit != 4 { + t.Fatalf("Rakestrawhome backend: %+v, %v", rakestrawhome, err) } - if len(loaded.Backends) != len(expectedBackends) { - t.Fatalf("published backend count = %d, want %d", len(loaded.Backends), len(expectedBackends)) - } - for _, definition := range loaded.Backends { - expected, found := expectedBackends[definition.ID] - if !found { - t.Fatalf("unexpected published backend %q", definition.ID) - } - if definition.Endpoint != expected.endpoint || - definition.APIKeyEnv != expected.apiKeyEnv || - definition.ConcurrencyLimit != expected.concurrencyLimit || - definition.QueueCapacity != expected.queueCapacity || - !definition.QueueCapacitySet || - definition.ExtraParams != nil { - t.Fatalf("unexpected published backend definition: %#v", definition) - } - } -} - -func TestLoadRejectsInvalidSources(t *testing.T) { - for name, sources := range map[string][]Source{ - "none": nil, - "blank name": {{Name: " ", ExpectedBackendID: "openrouter", FS: openrouter.FS(), Root: openrouter.Root}}, - "duplicate name": { - {Name: "same", ExpectedBackendID: "openrouter", FS: openrouter.FS(), Root: openrouter.Root}, - {Name: "same", ExpectedBackendID: "rakestrawhome", FS: rakestrawhome.FS(), Root: rakestrawhome.Root}, - }, - "nil filesystem": {{Name: "missing", ExpectedBackendID: "openrouter", Root: openrouter.Root}}, - "invalid root": {{Name: "invalid-root", ExpectedBackendID: "openrouter", FS: openrouter.FS(), Root: "."}}, - "blank expected backend": {{Name: "blank-backend", ExpectedBackendID: " ", FS: openrouter.FS(), Root: openrouter.Root}}, + for id, want := range map[string]string{ + "promptkit-openrouter-standard": "~google/gemini-flash-latest", + "promptkit-openrouter-auto": "openrouter/auto", + "rakestrawhome-gemma-4-31b": "google/gemma-4-31b-it", } { - t.Run(name, func(t *testing.T) { - if _, err := Load(sources...); err == nil { - t.Fatal("expected error") - } - }) - } -} - -func TestLoadRejectsInvalidLayouts(t *testing.T) { - tests := map[string]func(fstest.MapFS){ - "unexpected file": func(fsys fstest.MapFS) { - fsys["catalog/notes.txt"] = &fstest.MapFile{Data: []byte("unexpected")} - }, - "unexpected directory": func(fsys fstest.MapFS) { - fsys["catalog/unexpected"] = &fstest.MapFile{Mode: fs.ModeDir} - }, - "nonregular manifest": func(fsys fstest.MapFS) { - fsys["catalog/backend.json"].Mode = fs.ModeSymlink - }, - "wrong profile extension": func(fsys fstest.MapFS) { - fsys["catalog/profiles/extra.yaml"] = &fstest.MapFile{Data: []byte(validProfile("extra", "one"))} - }, - "nonregular profile": func(fsys fstest.MapFS) { - fsys["catalog/profiles/one-profile.yml"].Mode = fs.ModeSymlink - }, - } - for name, mutate := range tests { - t.Run(name, func(t *testing.T) { - fsys := validCatalogFS("one") - mutate(fsys) - if _, err := Load(testSource("one", fsys)); err == nil { - t.Fatal("expected invalid layout error") - } - }) - } -} - -func TestLoadRejectsInvalidManifests(t *testing.T) { - tests := map[string]string{ - "malformed": `{`, - "trailing value": validManifest("one", "TEST_API_KEY", "null") + `{}`, - "missing fields": `{"schema_version":1,"id":"one"}`, - "unsupported version": strings.Replace(validManifest("one", "TEST_API_KEY", "null"), `"schema_version":1`, `"schema_version":2`, 1), - "unknown field": strings.Replace(validManifest("one", "TEST_API_KEY", "null"), `"extra_params":null`, `"extra_params":null,"unknown":true`, 1), - "blank API key env": validManifest("one", " ", "null"), - "invalid API key env": validManifest("one", "LEAK-MARKER", "null"), - "invalid endpoint": strings.Replace(validManifest("one", "TEST_API_KEY", "null"), `https://one.example/v1`, `ftp://leak-marker.invalid/v1`, 1), - "zero concurrency": strings.Replace(validManifest("one", "TEST_API_KEY", "null"), `"concurrency_limit":2`, `"concurrency_limit":0`, 1), - "non-object parameters": validManifest("one", "TEST_API_KEY", `[]`), - "secret parameter": validManifest("one", "TEST_API_KEY", `{"nested":{"token":"leak-marker"}}`), - } - for name, manifest := range tests { - t.Run(name, func(t *testing.T) { - fsys := validCatalogFS("one") - fsys["catalog/backend.json"].Data = []byte(manifest) - _, err := Load(testSource("one", fsys)) - if err == nil { - t.Fatal("expected invalid manifest error") - } - if strings.Contains(strings.ToLower(err.Error()), "leak-marker") { - t.Fatalf("catalog error exposed manifest content: %v", err) - } - }) - } -} - -func TestLoadPreservesManifestJSONNumbers(t *testing.T) { - fsys := validCatalogFS("one") - fsys["catalog/backend.json"].Data = []byte(validManifest( - "one", - "TEST_API_KEY", - `{"large":9007199254740993,"nested":[1.25]}`, - )) - loaded, err := Load(testSource("one", fsys)) - if err != nil { - t.Fatalf("load catalog: %v", err) - } - if got := loaded.Backends[0].ExtraParams["large"]; got != json.Number("9007199254740993") { - t.Fatalf("large JSON integer = %#v, want preserved json.Number", got) - } - nested := loaded.Backends[0].ExtraParams["nested"].([]any) - if nested[0] != json.Number("1.25") { - t.Fatalf("nested JSON number = %#v, want preserved json.Number", nested[0]) - } -} - -func TestLoadRejectsInvalidCatalogProfiles(t *testing.T) { - tests := map[string]func(fstest.MapFS){ - "empty": func(fsys fstest.MapFS) { - delete(fsys, "catalog/profiles/one-profile.yml") - fsys["catalog/profiles"] = &fstest.MapFile{Mode: fs.ModeDir} - }, - "malformed": func(fsys fstest.MapFS) { - fsys["catalog/profiles/one-profile.yml"].Data = []byte("id: [") - }, - "raw API key": func(fsys fstest.MapFS) { - fsys["catalog/profiles/one-profile.yml"].Data = []byte(validProfile("one-profile", "one") + "api_key: leak-marker\n") - }, - "endpoint field": func(fsys fstest.MapFS) { - fsys["catalog/profiles/one-profile.yml"].Data = []byte(validProfile("one-profile", "one") + "endpoint: ''\n") - }, - "API key environment field": func(fsys fstest.MapFS) { - fsys["catalog/profiles/one-profile.yml"].Data = []byte(validProfile("one-profile", "one") + "api_key_env: ''\n") - }, - "owner mismatch": func(fsys fstest.MapFS) { - fsys["catalog/profiles/one-profile.yml"].Data = []byte(validProfile("one-profile", "other")) - }, - "missing base": func(fsys fstest.MapFS) { - fsys["catalog/profiles/one-profile.yml"].Data = []byte("id: one-profile\nbase_profile: leak-marker\n") - }, - "cyclic base": func(fsys fstest.MapFS) { - fsys["catalog/profiles/one-profile.yml"].Data = []byte("id: one-profile\nbase_profile: second\n") - fsys["catalog/profiles/second.yml"] = &fstest.MapFile{Data: []byte("id: second\nbase_profile: one-profile\n")} - }, - "secret profile parameter": func(fsys fstest.MapFS) { - fsys["catalog/profiles/one-profile.yml"].Data = []byte(validProfile("one-profile", "one") + "extra_params:\n nested:\n password: leak-marker\n") - }, - "unknown field is redacted": func(fsys fstest.MapFS) { - fsys["catalog/profiles/one-profile.yml"].Data = []byte(validProfile("one-profile", "one") + "leak_marker: leak-marker\n") - }, - } - for name, mutate := range tests { - t.Run(name, func(t *testing.T) { - fsys := validCatalogFS("one") - mutate(fsys) - _, err := Load(testSource("one", fsys)) - if err == nil { - t.Fatal("expected invalid profile error") - } - if strings.Contains(strings.ToLower(err.Error()), "leak-marker") { - t.Fatalf("catalog error exposed profile content: %v", err) - } - }) - } -} - -func TestLoadReservesPromptkitProfilePrefixForCatalogs(t *testing.T) { - tests := map[string]struct { - profileID string - wantError bool - }{ - "maintained name": { - profileID: "promptkit-openrouter-standard", - wantError: true, - }, - "other reserved name": { - profileID: "promptkit-example", - wantError: true, - }, - "ordinary name": { - profileID: "ordinary-profile", - }, - } - for name, test := range tests { - t.Run(name, func(t *testing.T) { - fsys := validCatalogFS("one") - fsys["catalog/profiles/one-profile.yml"].Data = []byte(validProfile(test.profileID, "one")) - - _, err := Load(testSource("one", fsys)) - if (err != nil) != test.wantError { - t.Fatalf("Load() error = %v, want error %t", err, test.wantError) - } - if err != nil && !strings.Contains(err.Error(), "catalog one: one-profile.yml") { - t.Fatalf("Load() error = %v, want catalog source and path context", err) - } - }) - } -} - -func TestLoadRejectsCrossCatalogConflicts(t *testing.T) { - t.Run("duplicate backend", func(t *testing.T) { - second := catalogFS("one", map[string]string{ - "catalog/profiles/second.yml": validProfile("second", "one"), - }, "null") - _, err := Load( - Source{Name: "first", ExpectedBackendID: "one", FS: validCatalogFS("one"), Root: "catalog"}, - Source{Name: "second", ExpectedBackendID: "one", FS: second, Root: "catalog"}, - ) - if err == nil { - t.Fatal("expected duplicate backend error") + profile, err := state.GetProfile(context.Background(), id) + if err != nil || profile.ResolvedTarget.Model != want { + t.Fatalf("profile %s: %+v, %v", id, profile, err) } - }) - - t.Run("duplicate profile", func(t *testing.T) { - first := catalogFS("one", map[string]string{ - "catalog/profiles/shared.yml": validProfile("shared", "one"), - }, "null") - second := catalogFS("two", map[string]string{ - "catalog/profiles/shared.yml": validProfile("shared", "two"), - }, "null") - _, err := Load(testSource("one", first), testSource("two", second)) - if err == nil { - t.Fatal("expected duplicate profile error") - } - }) - - t.Run("cross-catalog base", func(t *testing.T) { - first := catalogFS("one", map[string]string{ - "catalog/profiles/base.yml": validProfile("base", "one"), - }, "null") - second := catalogFS("two", map[string]string{ - "catalog/profiles/child.yml": "id: child\nbase_profile: base\n", - }, "null") - _, err := Load(testSource("one", first), testSource("two", second)) - if err == nil { - t.Fatal("expected cross-catalog base error") - } - }) + } } -func TestLoadReturnsDefensiveCatalogValues(t *testing.T) { - fsys := catalogFS("one", map[string]string{ - "catalog/profiles/one-profile.yml": validProfile("one-profile", "one") + "extra_params:\n nested:\n value: profile\n", - }, `{"nested":{"value":"backend"}}`) - loaded, err := Load(testSource("one", fsys)) +func TestConsumerOverlayReplacesCatalogDefaultsBeforeValidation(t *testing.T) { + underlays, err := Underlays() if err != nil { - t.Fatalf("load catalog: %v", err) + t.Fatal(err) } - loaded.Backends[0].ExtraParams["nested"].(map[string]any)["value"] = "changed" - profileValue, err := loaded.Profiles.GetProfile(context.Background(), "one-profile") + underlays = append(underlays, configkit.Underlay{Inputs: []configkit.Input{{Source: configkit.Mapping("operator", map[string]any{ + "backends": map[string]any{"openrouter": map[string]any{"endpoint": "invalid lower value"}}, + })}}}) + main := configkit.Input{Source: configkit.Mapping("application", map[string]any{ + "backends": map[string]any{"openrouter": map[string]any{"endpoint": "https://alternate.example/v1"}}, + "profiles": map[string]any{"gemini-flash-latest": map[string]any{"model": "consumer-model"}}, + })} + state, err := configuration.Load(main, underlays) if err != nil { - t.Fatalf("load profile: %v", err) + t.Fatalf("replaced lower-layer value failed final validation: %v", err) } - profileValue.ExtraParams["nested"].(map[string]any)["value"] = "changed" - - again, err := Load(testSource("one", fsys)) - if err != nil { - t.Fatalf("reload catalog: %v", err) + profile, err := state.GetProfile(context.Background(), "promptkit-openrouter-standard") + if err != nil || profile.ResolvedTarget.Model != "consumer-model" || profile.ResolvedTarget.Endpoint != "https://alternate.example/v1" { + t.Fatalf("maintained profile did not observe final configuration: %+v, %v", profile, err) } - if got := again.Backends[0].ExtraParams["nested"].(map[string]any)["value"]; got != "backend" { - t.Fatalf("backend mutation escaped returned set: %#v", got) - } - againProfile, err := loaded.Profiles.GetProfile(context.Background(), "one-profile") - if err != nil { - t.Fatalf("reload profile: %v", err) - } - if got := againProfile.ExtraParams["nested"].(map[string]any)["value"]; got != "profile" { - t.Fatalf("profile mutation escaped returned value: %#v", got) + _, err = configuration.Load(configkit.Input{Source: configkit.Mapping("invalid final", map[string]any{ + "backends": map[string]any{"openrouter": map[string]any{"endpoint": "invalid final value"}}, + })}, underlays[:2]) + var loadError *configkit.LoadError + if !errors.As(err, &loadError) { + t.Fatalf("invalid final backend did not produce a ConfigKit load error: %v", err) } } - -func testSource(id string, fsys fs.FS) Source { - return Source{Name: id, ExpectedBackendID: id, FS: fsys, Root: "catalog"} -} - -func validCatalogFS(id string) fstest.MapFS { - return catalogFS(id, map[string]string{ - "catalog/profiles/" + id + "-profile.yml": validProfile(id+"-profile", id), - }, "null") -} - -func catalogFS(id string, profiles map[string]string, extraParams string) fstest.MapFS { - fsys := fstest.MapFS{ - "catalog/backend.json": &fstest.MapFile{Data: []byte(validManifest(id, "TEST_API_KEY", extraParams))}, - } - for name, content := range profiles { - fsys[name] = &fstest.MapFile{Data: []byte(content)} - } - return fsys -} - -func validManifest(id, apiKeyEnv, extraParams string) string { - return fmt.Sprintf( - `{"schema_version":1,"id":%q,"endpoint":%q,"api_key_env":%q,"concurrency_limit":2,"queue_capacity":3,"extra_params":%s}`, - id, - "https://"+id+".example/v1", - apiKeyEnv, - extraParams, - ) -} - -func validProfile(id, backendID string) string { - return fmt.Sprintf("id: %s\nbackend: %s\nmodel: test-model\n", id, backendID) -} - -var _ fs.FS = openrouter.FS() diff --git a/internal/configuration/load.go b/internal/configuration/load.go index 496a5e4..56683e7 100644 --- a/internal/configuration/load.go +++ b/internal/configuration/load.go @@ -104,6 +104,11 @@ 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 captures one immutable effective configuration for internal consumers. +func Load(main configkit.Input, underlays []configkit.Underlay) (*loaded, error) { + return load(main, underlays) +} + // 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) { diff --git a/internal/configuration/targets.go b/internal/configuration/targets.go index bbedb1a..8468c46 100644 --- a/internal/configuration/targets.go +++ b/internal/configuration/targets.go @@ -169,3 +169,11 @@ func (state *loaded) GetProfile(ctx context.Context, id string) (*domain.Executi profile.ResolvedTarget = &target return &profile, nil } + +// GetBackend returns an independently owned effective backend definition. +func (state *loaded) GetBackend(id string) (domain.Backend, error) { + if state == nil { + return domain.Backend{}, fmt.Errorf("backend registry is not configured") + } + return state.registry.GetBackend(id) +} diff --git a/internal/engine/engine.go b/internal/engine/engine.go index e05e7de..4d72f98 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -16,10 +16,10 @@ import ( "gitea.maximumdirect.net/eric/promptkit/internal/artifact" "gitea.maximumdirect.net/eric/promptkit/internal/backend" "gitea.maximumdirect.net/eric/promptkit/internal/capacity" - "gitea.maximumdirect.net/eric/promptkit/internal/catalog" "gitea.maximumdirect.net/eric/promptkit/internal/defaults" "gitea.maximumdirect.net/eric/promptkit/internal/domain" "gitea.maximumdirect.net/eric/promptkit/internal/generation" + "gitea.maximumdirect.net/eric/promptkit/internal/legacycatalog" "gitea.maximumdirect.net/eric/promptkit/internal/llm" "gitea.maximumdirect.net/eric/promptkit/internal/profile" "gitea.maximumdirect.net/eric/promptkit/internal/prompt" @@ -171,9 +171,9 @@ func New(cfg Config, options Options) (*Engine, error) { promptDefinitions = promptdef.NewFilesystemRepository(cfg.PromptDir) } - maintained, err := catalog.Load( - catalog.Source{Name: "OpenRouter", ExpectedBackendID: backend.OpenRouterID, FS: openrouter.FS(), Root: openrouter.Root}, - catalog.Source{Name: "Rakestrawhome", ExpectedBackendID: backend.RakestrawHomeID, FS: rakestrawhome.FS(), Root: rakestrawhome.Root}, + maintained, err := legacycatalog.Load( + legacycatalog.Source{Name: "OpenRouter", ExpectedBackendID: backend.OpenRouterID, FS: openrouter.FS(), Root: openrouter.Root}, + legacycatalog.Source{Name: "Rakestrawhome", ExpectedBackendID: backend.RakestrawHomeID, FS: rakestrawhome.FS(), Root: rakestrawhome.Root}, ) if err != nil { return nil, fmt.Errorf("failed to load maintained catalogs: %w", err) diff --git a/internal/legacycatalog/catalog.go b/internal/legacycatalog/catalog.go new file mode 100644 index 0000000..e800f11 --- /dev/null +++ b/internal/legacycatalog/catalog.go @@ -0,0 +1,250 @@ +// Package legacycatalog supports the existing public construction path while +// catalog configuration moves to the effective ConfigKit loader. +package legacycatalog + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "path" + "sort" + "strings" + + "gitea.maximumdirect.net/eric/promptkit/internal/backend" + "gitea.maximumdirect.net/eric/promptkit/internal/domain" + "gitea.maximumdirect.net/eric/promptkit/internal/profile" +) + +// Source identifies one immutable backend catalog asset tree. +type Source struct { + Name string + ExpectedBackendID string + FS fs.FS + Root string +} + +// Set is the validated maintained backend and raw profile catalog. +type Set struct { + Backends []domain.Backend + Profiles profile.Repository + profileIDs []string +} + +// Load validates and combines immutable catalog sources in source order. +func Load(sources ...Source) (Set, error) { + if len(sources) == 0 { + return Set{}, errors.New("at least one catalog source is required") + } + loaded := Set{Backends: make([]domain.Backend, 0, len(sources))} + names := map[string]bool{} + backendIDs := map[string]bool{} + profileIDs := map[string]bool{} + for _, source := range sources { + if err := validateSource(source, names); err != nil { + return Set{}, err + } + names[source.Name] = true + definition, err := loadBackend(source) + if err != nil { + return Set{}, err + } + if backendIDs[definition.ID] { + return Set{}, fmt.Errorf("catalog %s: backend ID duplicates an earlier catalog", source.Name) + } + backendIDs[definition.ID] = true + repository, metadata, err := profile.LoadFSRepository(context.Background(), source.FS, path.Join(source.Root, "profiles")) + if err != nil { + return Set{}, fmt.Errorf("catalog %s: %w", source.Name, err) + } + if len(metadata) == 0 { + return Set{}, fmt.Errorf("catalog %s: profiles must not be empty", source.Name) + } + resolvingRepository := profile.NewResolvingRepository(repository) + for _, entry := range metadata { + if strings.HasPrefix(entry.ID, "promptkit-") { + return Set{}, fmt.Errorf("catalog %s: %s uses the reserved promptkit- profile ID prefix", source.Name, entry.Path) + } + if profileIDs[entry.ID] { + return Set{}, fmt.Errorf("catalog %s: %s duplicates an earlier profile ID", source.Name, entry.Path) + } + if containsField(entry.ExplicitFields, "endpoint") || containsField(entry.ExplicitFields, "api_key_env") { + return Set{}, fmt.Errorf("catalog %s: %s contains connection metadata", source.Name, entry.Path) + } + value, err := repository.GetProfile(context.Background(), entry.ID) + if err != nil { + return Set{}, fmt.Errorf("catalog %s: %s: %w", source.Name, entry.Path, err) + } + if err := rejectSecretKeys(value.ExtraParams); err != nil { + return Set{}, fmt.Errorf("catalog %s: %s: prohibited extra parameter key", source.Name, entry.Path) + } + resolved, err := resolvingRepository.GetProfile(context.Background(), entry.ID) + if err != nil { + return Set{}, fmt.Errorf("catalog %s: %s has invalid profile inheritance", source.Name, entry.Path) + } + if resolved.BackendID != definition.ID { + return Set{}, fmt.Errorf("catalog %s: %s selects a different backend", source.Name, entry.Path) + } + profileIDs[entry.ID] = true + loaded.profileIDs = append(loaded.profileIDs, entry.ID) + } + if loaded.Profiles == nil { + loaded.Profiles = repository + } else { + loaded.Profiles = profile.NewOverlayRepository(loaded.Profiles, repository) + } + loaded.Backends = append(loaded.Backends, definition) + } + sort.Strings(loaded.profileIDs) + return loaded, nil +} + +func validateSource(source Source, names map[string]bool) error { + if strings.TrimSpace(source.Name) == "" || source.Name != strings.TrimSpace(source.Name) { + return errors.New("catalog source name must not be blank") + } + if names[source.Name] { + return fmt.Errorf("duplicate catalog source name %q", source.Name) + } + if source.FS == nil { + return fmt.Errorf("catalog %s: filesystem is nil", source.Name) + } + if source.Root == "." || source.Root == "" || !fs.ValidPath(source.Root) { + return fmt.Errorf("catalog %s: asset root is invalid", source.Name) + } + if strings.TrimSpace(source.ExpectedBackendID) == "" { + return fmt.Errorf("catalog %s: expected backend ID is blank", source.Name) + } + return validateLayout(source) +} + +func validateLayout(source Source) error { + manifestPath := path.Join(source.Root, "backend.json") + profilesRoot := path.Join(source.Root, "profiles") + return fs.WalkDir(source.FS, source.Root, func(assetPath string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if assetPath == source.Root { + if !entry.IsDir() { + return fmt.Errorf("catalog %s: invalid asset path %s", source.Name, assetPath) + } + return nil + } + if entry.IsDir() { + if assetPath == profilesRoot || strings.HasPrefix(assetPath, profilesRoot+"/") { + return nil + } + return fmt.Errorf("catalog %s: invalid asset path %s", source.Name, assetPath) + } + if assetPath == manifestPath && entry.Type().IsRegular() { + return nil + } + if strings.HasPrefix(assetPath, profilesRoot+"/") && entry.Type().IsRegular() && strings.HasSuffix(assetPath, ".yml") { + return nil + } + return fmt.Errorf("catalog %s: invalid asset path %s", source.Name, assetPath) + }) +} + +func loadBackend(source Source) (domain.Backend, error) { + data, err := fs.ReadFile(source.FS, path.Join(source.Root, "backend.json")) + if err != nil { + return domain.Backend{}, fmt.Errorf("catalog %s: backend.json: %w", source.Name, err) + } + type manifest struct { + SchemaVersion *int `json:"schema_version"` + ID *string `json:"id"` + Endpoint *string `json:"endpoint"` + APIKeyEnv *string `json:"api_key_env"` + ConcurrencyLimit *int `json:"concurrency_limit"` + QueueCapacity *int `json:"queue_capacity"` + ExtraParams json.RawMessage `json:"extra_params"` + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + var value manifest + if err := decoder.Decode(&value); err != nil { + return domain.Backend{}, fmt.Errorf("catalog %s: backend.json: invalid manifest", source.Name) + } + var trailing any + if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { + return domain.Backend{}, fmt.Errorf("catalog %s: backend.json: invalid manifest", source.Name) + } + if value.SchemaVersion == nil || *value.SchemaVersion != 1 || value.ID == nil || value.Endpoint == nil || value.APIKeyEnv == nil || value.ConcurrencyLimit == nil || value.QueueCapacity == nil || value.ExtraParams == nil { + return domain.Backend{}, fmt.Errorf("catalog %s: backend.json: required field is missing or unsupported", source.Name) + } + if *value.ID != source.ExpectedBackendID { + return domain.Backend{}, fmt.Errorf("catalog %s: backend ID does not match expected ID", source.Name) + } + if strings.TrimSpace(*value.APIKeyEnv) == "" { + return domain.Backend{}, fmt.Errorf("catalog %s: backend.json: api key environment variable must not be blank", source.Name) + } + extraParams, err := decodeExtraParams(value.ExtraParams) + if err != nil { + return domain.Backend{}, fmt.Errorf("catalog %s: backend.json: invalid extra parameters", source.Name) + } + if err := rejectSecretKeys(extraParams); err != nil { + return domain.Backend{}, fmt.Errorf("catalog %s: backend.json: prohibited extra parameter key", source.Name) + } + normalized, err := backend.NormalizeDefinition(domain.Backend{ID: *value.ID, Endpoint: *value.Endpoint, APIKeyEnv: *value.APIKeyEnv, ExtraParams: extraParams, ConcurrencyLimit: *value.ConcurrencyLimit, QueueCapacity: *value.QueueCapacity, QueueCapacitySet: true}) + if err != nil { + return domain.Backend{}, fmt.Errorf("catalog %s: backend.json: invalid backend definition", source.Name) + } + return normalized, nil +} + +func decodeExtraParams(data []byte) (map[string]any, error) { + if bytes.Equal(bytes.TrimSpace(data), []byte("null")) { + return nil, nil + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.UseNumber() + var value map[string]any + if err := decoder.Decode(&value); err != nil || value == nil { + return nil, errors.New("extra parameters must be an object or null") + } + var trailing any + if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { + return nil, errors.New("extra parameters must contain exactly one JSON value") + } + return value, nil +} + +func containsField(fields []string, target string) bool { + for _, field := range fields { + if field == target { + return true + } + } + return false +} + +func rejectSecretKeys(value map[string]any) error { + return rejectSecretValue(value) +} + +func rejectSecretValue(value any) error { + switch value := value.(type) { + case map[string]any: + for key, child := range value { + switch strings.ToLower(key) { + case "api_key", "apikey", "authorization", "credential", "credentials", "password", "secret", "token", "access_token": + return errors.New("prohibited key") + } + if err := rejectSecretValue(child); err != nil { + return err + } + } + case []any: + for _, child := range value { + if err := rejectSecretValue(child); err != nil { + return err + } + } + } + return nil +}