Migrate Rakestrawhome catalog assets to keyed configuration
This commit is contained in:
20
README.md
20
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.
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
6
catalog/backend.yml
Normal file
6
catalog/backend.yml
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
240
catalog_test.go
240
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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user