diff --git a/README.md b/README.md index 1f16ecd..246a9d7 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ credentials, or make network requests. Promptkit owns catalog parsing, validation, runtime behavior, credential handling, and consumer documentation. This module owns the Rakestrawhome -backend manifest and its maintained profile data. Consumers should use versions +backend definition and its maintained profile data. Consumers should use versions chosen by their Promptkit release rather than treating this module as a standalone runtime integration. @@ -16,17 +16,25 @@ and documentation. ## Compatibility -This compatibility policy applies to the `/v2` module. Catalog profiles track +This compatibility policy applies to the `/v3` module. Catalog profiles track upstream availability and may be added, removed, renamed, or modified without deprecation or advance notice. Consumers that need a particular profile set should use the catalog version selected by their Promptkit release. -Backend definitions, the exported Go API, and the catalog manifest schema do +Backend definitions, the exported Go API, and the catalog asset format do not share that profile-data exception. Their compatibility follows the normal -semantic-versioning rules. Published v1 releases retain their historical -contract; this README does not retroactively alter it. +semantic-versioning rules. Published v1 and `/v2` releases retain their historical +contracts; this README does not retroactively alter them. -Catalog releases use dated versions in the form `v2.YYYYMMDD.N`. The date +The `/v3` module replaces the `/v2` manifest and flat profile document with +ordinary keyed ConfigKit inputs: `catalog/backend.yml` defines +`backends.rakestrawhome`, and the file under `catalog/profiles/` defines its +literal ID beneath `profiles`. Profile inheritance uses `extends` instead of +`base_profile`. This asset-format change is incompatible with `/v2` consumers; +they should remain on a published `/v2` version. The exported `FS` and `Root` +remain the asset access surface. + +Catalog releases use dated versions in the form `v3.YYYYMMDD.N`. The date identifies when the catalog snapshot was published in UTC; it does not certify that a provider currently offers a model or endpoint named by the data. diff --git a/catalog/backend.json b/catalog/backend.json deleted file mode 100644 index 81c16c5..0000000 --- a/catalog/backend.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "schema_version": 1, - "id": "rakestrawhome", - "endpoint": "https://inference.ai.rakestrawhome.com/v1", - "api_key_env": "RAKESTRAWHOME_INFERENCE_API_KEY", - "concurrency_limit": 4, - "queue_capacity": 1024, - "extra_params": null -} diff --git a/catalog/backend.yml b/catalog/backend.yml new file mode 100644 index 0000000..c8ad9a9 --- /dev/null +++ b/catalog/backend.yml @@ -0,0 +1,6 @@ +backends: + rakestrawhome: + endpoint: https://inference.ai.rakestrawhome.com/v1 + api_key_env: RAKESTRAWHOME_INFERENCE_API_KEY + concurrency_limit: 4 + queue_capacity: 1024 diff --git a/catalog/profiles/google/rakestrawhome-gemma-4-31b.yml b/catalog/profiles/google/rakestrawhome-gemma-4-31b.yml index d81fe5e..4fa650e 100644 --- a/catalog/profiles/google/rakestrawhome-gemma-4-31b.yml +++ b/catalog/profiles/google/rakestrawhome-gemma-4-31b.yml @@ -1,3 +1,4 @@ -id: rakestrawhome-gemma-4-31b -backend: rakestrawhome -model: google/gemma-4-31b-it +profiles: + rakestrawhome-gemma-4-31b: + backend: rakestrawhome + model: google/gemma-4-31b-it diff --git a/catalog_test.go b/catalog_test.go index bba3ef9..e5a6457 100644 --- a/catalog_test.go +++ b/catalog_test.go @@ -2,203 +2,113 @@ package rakestrawhome import ( "bytes" - "encoding/json" "errors" "fmt" "io" "io/fs" - "strings" "testing" "gopkg.in/yaml.v3" ) func TestCatalogLayoutAndContents(t *testing.T) { - fsys := FS() - if fsys == nil { + assets := FS() + if assets == nil { t.Fatal("FS returned nil") } - - manifestPath := Root + "/backend.json" - manifestData, err := fs.ReadFile(fsys, manifestPath) - if err != nil { - t.Fatalf("read manifest: %v", err) + backendPath := Root + "/backend.yml" + profilePath := Root + "/profiles/google/rakestrawhome-gemma-4-31b.yml" + for _, assetPath := range []string{backendPath, profilePath} { + entry, err := fs.Stat(assets, assetPath) + if err != nil || !entry.Mode().IsRegular() { + t.Fatalf("asset %s is not a regular file: %v", assetPath, err) + } } - assertRakestrawhomeManifest(t, manifestData) - - profilesRoot := Root + "/profiles" - profileCount := 0 - ids := map[string]string{} - err = fs.WalkDir(fsys, Root, func(path string, entry fs.DirEntry, walkErr error) error { + count := 0 + err := fs.WalkDir(assets, Root, func(assetPath string, entry fs.DirEntry, walkErr error) error { if walkErr != nil { return walkErr } - if path == Root || path == profilesRoot || entry.IsDir() { + if entry.IsDir() { return nil } - if path == manifestPath { - if !entry.Type().IsRegular() { - return fmt.Errorf("manifest is not a regular file") - } - return nil + if assetPath != backendPath && assetPath != profilePath { + return fmt.Errorf("unexpected catalog asset %q", assetPath) } - if !strings.HasPrefix(path, profilesRoot+"/") { - return fmt.Errorf("unexpected catalog entry %q", path) - } - if !entry.Type().IsRegular() || !strings.HasSuffix(path, ".yml") { - return fmt.Errorf("profile asset %q is not a regular .yml file", path) - } - - data, err := fs.ReadFile(fsys, path) - if err != nil { - return err - } - id := assertRakestrawhomeProfile(t, path, data) - if previous, found := ids[id]; found { - return fmt.Errorf("duplicate profile ID %q in %s and %s", id, previous, path) - } - ids[id] = path - profileCount++ + count++ return nil }) + if err != nil || count != 2 { + t.Fatalf("unexpected catalog layout: %d assets, %v", count, err) + } + + backendData, err := fs.ReadFile(assets, backendPath) if err != nil { - t.Fatalf("validate catalog layout: %v", err) + t.Fatal(err) } - if profileCount != 1 { - t.Fatalf("catalog contains %d profiles, want 1", profileCount) - } - if _, found := ids["rakestrawhome-gemma-4-31b"]; !found { - t.Fatal("catalog does not contain the maintained Rakestrawhome profile") - } -} - -func assertRakestrawhomeManifest(t *testing.T, data []byte) { - t.Helper() - - 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 { - t.Fatalf("decode manifest: %v", err) - } - if err := requireJSONEnd(decoder); err != nil { - t.Fatalf("decode manifest: %v", err) - } - if value.SchemaVersion != 1 || - value.ID != "rakestrawhome" || - value.Endpoint != "https://inference.ai.rakestrawhome.com/v1" || - value.APIKeyEnv != "RAKESTRAWHOME_INFERENCE_API_KEY" || - value.ConcurrencyLimit != 4 || - value.QueueCapacity != 1024 || - string(value.ExtraParams) != "null" { - t.Fatalf("unexpected manifest: %#v", value) - } -} - -func requireJSONEnd(decoder *json.Decoder) error { - var trailing any - if err := decoder.Decode(&trailing); errors.Is(err, io.EOF) { - return nil - } else if err != nil { - return err - } - return errors.New("catalog manifest must contain exactly one JSON value") -} - -func assertRakestrawhomeProfile(t *testing.T, path string, data []byte) string { - t.Helper() - - var document yaml.Node - decoder := yaml.NewDecoder(bytes.NewReader(data)) - if err := decoder.Decode(&document); err != nil { - t.Fatalf("decode profile %s: %v", path, err) - } - if err := requireYAMLEnd(decoder); err != nil { - t.Fatalf("decode profile %s: %v", path, err) - } - if document.Kind != yaml.DocumentNode || len(document.Content) != 1 || document.Content[0].Kind != yaml.MappingNode { - t.Fatalf("profile %s must contain one mapping document", path) - } - - fields := yamlFields(document.Content[0]) - idField := fields["id"] - if idField == nil { - t.Fatalf("profile %s has no ID", path) - } - id := strings.TrimSpace(idField.Value) - if id == "" { - t.Fatalf("profile %s has a blank ID", path) - } - if fields["backend"] == nil || fields["backend"].Value != "rakestrawhome" { - t.Fatalf("profile %s does not select rakestrawhome", path) - } - for _, field := range []string{"endpoint", "api_key_env", "api_key"} { - if fields[field] != nil { - t.Fatalf("profile %s contains prohibited %s field", path, field) + backend := singleMapping(t, "backend", singleMapping(t, "backend", decodeMapping(t, "backend", backendData), "backends"), "rakestrawhome") + for field, want := range map[string]string{ + "endpoint": "https://inference.ai.rakestrawhome.com/v1", + "api_key_env": "RAKESTRAWHOME_INFERENCE_API_KEY", + "concurrency_limit": "4", "queue_capacity": "1024", + } { + value := backend[field] + if value == nil || value.Value != want { + t.Fatalf("backend %s: got %v, want %q", field, value, want) } } - if extraParams := fields["extra_params"]; extraParams != nil { - assertNoSecretKeys(t, path, extraParams) + if len(backend) != 4 { + t.Fatalf("unexpected backend fields: %v", backend) + } + + profileData, err := fs.ReadFile(assets, profilePath) + if err != nil { + t.Fatal(err) + } + profile := singleMapping(t, "profile", singleMapping(t, "profile", decodeMapping(t, "profile", profileData), "profiles"), "rakestrawhome-gemma-4-31b") + if len(profile) != 2 || profile["backend"] == nil || profile["backend"].Value != "rakestrawhome" || + profile["model"] == nil || profile["model"].Value != "google/gemma-4-31b-it" { + t.Fatalf("profile values changed: %v", profile) } - return id } -func requireYAMLEnd(decoder *yaml.Decoder) error { +func decodeMapping(t *testing.T, name string, data []byte) map[string]*yaml.Node { + t.Helper() + decoder := yaml.NewDecoder(bytes.NewReader(data)) + var document yaml.Node + if err := decoder.Decode(&document); err != nil { + t.Fatalf("decode %s: %v", name, err) + } var trailing yaml.Node - if err := decoder.Decode(&trailing); errors.Is(err, io.EOF) { - return nil - } else if err != nil { - return err + if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { + t.Fatalf("%s must contain exactly one YAML document: %v", name, err) } - return errors.New("profile must contain exactly one YAML document") + if document.Kind != yaml.DocumentNode || len(document.Content) != 1 { + t.Fatalf("%s must contain one mapping document", name) + } + return mappingFields(t, name, document.Content[0]) } -func yamlFields(mapping *yaml.Node) map[string]*yaml.Node { - fields := make(map[string]*yaml.Node, len(mapping.Content)/2) - for index := 0; index+1 < len(mapping.Content); index += 2 { - fields[mapping.Content[index].Value] = mapping.Content[index+1] +func singleMapping(t *testing.T, name string, fields map[string]*yaml.Node, key string) map[string]*yaml.Node { + t.Helper() + if len(fields) != 1 || fields[key] == nil { + t.Fatalf("%s must contain only %s", name, key) + } + return mappingFields(t, name, fields[key]) +} + +func mappingFields(t *testing.T, name string, node *yaml.Node) map[string]*yaml.Node { + t.Helper() + if node.Kind != yaml.MappingNode { + t.Fatalf("%s must contain a mapping", name) + } + fields := make(map[string]*yaml.Node, len(node.Content)/2) + for index := 0; index+1 < len(node.Content); index += 2 { + key := node.Content[index].Value + if _, exists := fields[key]; exists { + t.Fatalf("%s repeats key %q", name, key) + } + fields[key] = node.Content[index+1] } return fields } - -func assertNoSecretKeys(t *testing.T, path string, node *yaml.Node) { - t.Helper() - - forbidden := map[string]bool{ - "api_key": true, - "apikey": true, - "authorization": true, - "credential": true, - "credentials": true, - "password": true, - "secret": true, - "token": true, - "access_token": true, - } - var visit func(*yaml.Node) - visit = func(current *yaml.Node) { - if current.Kind == yaml.MappingNode { - for index := 0; index+1 < len(current.Content); index += 2 { - if forbidden[strings.ToLower(current.Content[index].Value)] { - t.Fatalf("profile %s contains a prohibited extra_params key", path) - } - visit(current.Content[index+1]) - } - return - } - for _, child := range current.Content { - visit(child) - } - } - visit(node) -} diff --git a/docs/release.md b/docs/release.md index cb72a10..3c4830d 100644 --- a/docs/release.md +++ b/docs/release.md @@ -1,11 +1,11 @@ # Catalog Release Procedure This repository releases source tags only. It has no generated artifacts. The -module path is `gitea.maximumdirect.net/eric/promptkit-backend-rakestrawhome/v2`. +module path is `gitea.maximumdirect.net/eric/promptkit-backend-rakestrawhome/v3`. ## Version selection -Release versions use `v2.YYYYMMDD.N`. `YYYYMMDD` is the actual UTC publication +Release versions use `v3.YYYYMMDD.N`. `YYYYMMDD` is the actual UTC publication date. Begin at revision `0` for a date and increment `N` for each additional release on that date. Versions are immutable: correct a published catalog with a new dated version rather than moving or recreating a tag. This repository @@ -20,7 +20,7 @@ tree is active, then run `GOWORK=off go test ./...`, `GOWORK=off go test -race ./...`, `GOWORK=off go vet ./...`, `GOWORK=off go build ./...`, formatting, repository-hygiene, credential, and ordinary module-resolution checks. -Confirm the module reports the `/v2` identity before publication. Create an +Confirm the module reports the `/v3` identity before publication. Create an annotated tag only after validation succeeds. Do not create or move an existing tag. Publish the validated source commit through `main`, then push only the selected annotated tag and verify that the remote tag and its peeled commit @@ -28,5 +28,5 @@ identify that source commit. Finally, from a temporary module with `GOWORK=off`, no workspace, no replacement, and no vendor tree, resolve the exact published -`gitea.maximumdirect.net/eric/promptkit-backend-rakestrawhome/v2@version`. +`gitea.maximumdirect.net/eric/promptkit-backend-rakestrawhome/v3@version`. Verify both the resolved module identity and version match the selected tag. diff --git a/go.mod b/go.mod index ade14c1..5465d6c 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module gitea.maximumdirect.net/eric/promptkit-backend-rakestrawhome/v2 +module gitea.maximumdirect.net/eric/promptkit-backend-rakestrawhome/v3 go 1.25.5