Migrate OpenRouter 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
|
Promptkit owns catalog parsing, validation, runtime behavior, credential
|
||||||
handling, and consumer documentation. This module owns the OpenRouter backend
|
handling, and consumer documentation. This module owns the OpenRouter backend
|
||||||
manifest and its maintained profile data. Consumers should use versions chosen
|
definition and its maintained profile data. Consumers should use versions chosen
|
||||||
by their Promptkit release rather than treating this module as a standalone
|
by their Promptkit release rather than treating this module as a standalone
|
||||||
runtime integration.
|
runtime integration.
|
||||||
|
|
||||||
@@ -16,17 +16,25 @@ and documentation.
|
|||||||
|
|
||||||
## Compatibility
|
## 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
|
upstream availability and may be added, removed, renamed, or modified without
|
||||||
deprecation or advance notice. Consumers that need a particular profile set
|
deprecation or advance notice. Consumers that need a particular profile set
|
||||||
should use the catalog version selected by their Promptkit release.
|
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
|
not share that profile-data exception. Their compatibility follows the normal
|
||||||
semantic-versioning rules. Published v1 releases retain their historical
|
semantic-versioning rules. Published v1 and `/v2` releases retain their historical
|
||||||
contract; this README does not retroactively alter it.
|
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 documents with
|
||||||
|
ordinary keyed ConfigKit inputs: `catalog/backend.yml` defines
|
||||||
|
`backends.openrouter`, and each file under `catalog/profiles/` defines one
|
||||||
|
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
|
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.
|
that a provider currently offers a model or endpoint named by the data.
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"schema_version": 1,
|
|
||||||
"id": "openrouter",
|
|
||||||
"endpoint": "https://openrouter.ai/api/v1",
|
|
||||||
"api_key_env": "OPENROUTER_API_KEY",
|
|
||||||
"concurrency_limit": 16,
|
|
||||||
"queue_capacity": 1024,
|
|
||||||
"extra_params": null
|
|
||||||
}
|
|
||||||
6
catalog/backend.yml
Normal file
6
catalog/backend.yml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
backends:
|
||||||
|
openrouter:
|
||||||
|
endpoint: https://openrouter.ai/api/v1
|
||||||
|
api_key_env: OPENROUTER_API_KEY
|
||||||
|
concurrency_limit: 16
|
||||||
|
queue_capacity: 1024
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
id: aion-2
|
profiles:
|
||||||
backend: openrouter
|
aion-2:
|
||||||
model: aion-labs/aion-2.0
|
backend: openrouter
|
||||||
temperature: 0.72
|
model: aion-labs/aion-2.0
|
||||||
reasoning_effort: high
|
temperature: 0.72
|
||||||
top_p: 0.95
|
reasoning_effort: high
|
||||||
timeout_seconds: 180
|
top_p: 0.95
|
||||||
service_tier: flex
|
timeout_seconds: 180
|
||||||
|
service_tier: flex
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
id: aion-3-0-mini
|
profiles:
|
||||||
backend: openrouter
|
aion-3-0-mini:
|
||||||
model: "aion-labs/aion-3.0-mini"
|
backend: openrouter
|
||||||
service_tier: flex
|
model: "aion-labs/aion-3.0-mini"
|
||||||
|
service_tier: flex
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
id: aion-3-0
|
profiles:
|
||||||
backend: openrouter
|
aion-3-0:
|
||||||
model: "aion-labs/aion-3.0"
|
backend: openrouter
|
||||||
service_tier: flex
|
model: "aion-labs/aion-3.0"
|
||||||
|
service_tier: flex
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
id: claude-fable-latest
|
profiles:
|
||||||
backend: openrouter
|
claude-fable-latest:
|
||||||
model: "~anthropic/claude-fable-latest"
|
backend: openrouter
|
||||||
reasoning_effort: high
|
model: "~anthropic/claude-fable-latest"
|
||||||
timeout_seconds: 600
|
reasoning_effort: high
|
||||||
service_tier: flex
|
timeout_seconds: 600
|
||||||
|
service_tier: flex
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
id: claude-haiku-latest
|
profiles:
|
||||||
backend: openrouter
|
claude-haiku-latest:
|
||||||
model: "~anthropic/claude-haiku-latest"
|
backend: openrouter
|
||||||
reasoning_effort: medium
|
model: "~anthropic/claude-haiku-latest"
|
||||||
timeout_seconds: 240
|
reasoning_effort: medium
|
||||||
service_tier: flex
|
timeout_seconds: 240
|
||||||
|
service_tier: flex
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
id: claude-opus-latest
|
profiles:
|
||||||
backend: openrouter
|
claude-opus-latest:
|
||||||
model: "~anthropic/claude-opus-latest"
|
backend: openrouter
|
||||||
reasoning_effort: high
|
model: "~anthropic/claude-opus-latest"
|
||||||
timeout_seconds: 240
|
reasoning_effort: high
|
||||||
service_tier: flex
|
timeout_seconds: 240
|
||||||
|
service_tier: flex
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
id: claude-sonnet-latest
|
profiles:
|
||||||
backend: openrouter
|
claude-sonnet-latest:
|
||||||
model: "~anthropic/claude-sonnet-latest"
|
backend: openrouter
|
||||||
reasoning_effort: high
|
model: "~anthropic/claude-sonnet-latest"
|
||||||
timeout_seconds: 240
|
reasoning_effort: high
|
||||||
service_tier: flex
|
timeout_seconds: 240
|
||||||
|
service_tier: flex
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
id: deepseek-flash-latest
|
profiles:
|
||||||
backend: openrouter
|
deepseek-flash-latest:
|
||||||
model: "~deepseek/deepseek-flash-latest"
|
backend: openrouter
|
||||||
service_tier: flex
|
model: "~deepseek/deepseek-flash-latest"
|
||||||
|
service_tier: flex
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
id: deepseek-pro-latest
|
profiles:
|
||||||
backend: openrouter
|
deepseek-pro-latest:
|
||||||
model: "~deepseek/deepseek-pro-latest"
|
backend: openrouter
|
||||||
service_tier: flex
|
model: "~deepseek/deepseek-pro-latest"
|
||||||
|
service_tier: flex
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
id: gemini-3-5-flash-lite
|
profiles:
|
||||||
backend: openrouter
|
gemini-3-5-flash-lite:
|
||||||
model: "google/gemini-3.5-flash-lite"
|
backend: openrouter
|
||||||
service_tier: flex
|
model: "google/gemini-3.5-flash-lite"
|
||||||
|
service_tier: flex
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
id: gemini-3-5-flash
|
profiles:
|
||||||
backend: openrouter
|
gemini-3-5-flash:
|
||||||
model: "google/gemini-3.5-flash"
|
backend: openrouter
|
||||||
service_tier: flex
|
model: "google/gemini-3.5-flash"
|
||||||
|
service_tier: flex
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
id: gemini-3-6-flash
|
profiles:
|
||||||
backend: openrouter
|
gemini-3-6-flash:
|
||||||
model: "google/gemini-3.6-flash"
|
backend: openrouter
|
||||||
service_tier: flex
|
model: "google/gemini-3.6-flash"
|
||||||
|
service_tier: flex
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
id: gemini-3-7-flash
|
profiles:
|
||||||
backend: openrouter
|
gemini-3-7-flash:
|
||||||
model: "google/gemini-3.7-flash"
|
backend: openrouter
|
||||||
service_tier: flex
|
model: "google/gemini-3.7-flash"
|
||||||
|
service_tier: flex
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
id: gemini-3-8-flash
|
profiles:
|
||||||
backend: openrouter
|
gemini-3-8-flash:
|
||||||
model: "google/gemini-3.8-flash"
|
backend: openrouter
|
||||||
service_tier: flex
|
model: "google/gemini-3.8-flash"
|
||||||
|
service_tier: flex
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
id: gemini-3-flash-lite
|
profiles:
|
||||||
backend: openrouter
|
gemini-3-flash-lite:
|
||||||
model: "google/gemini-3.1-flash-lite"
|
backend: openrouter
|
||||||
#temperature: 0.15
|
model: "google/gemini-3.1-flash-lite"
|
||||||
reasoning_effort: high
|
#temperature: 0.15
|
||||||
#top_p: 0.98
|
reasoning_effort: high
|
||||||
timeout_seconds: 240
|
#top_p: 0.98
|
||||||
service_tier: flex
|
timeout_seconds: 240
|
||||||
|
service_tier: flex
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
id: gemini-flash-latest
|
profiles:
|
||||||
backend: openrouter
|
gemini-flash-latest:
|
||||||
model: "~google/gemini-flash-latest"
|
backend: openrouter
|
||||||
#temperature: 0.15
|
model: "~google/gemini-flash-latest"
|
||||||
reasoning_effort: high
|
#temperature: 0.15
|
||||||
#top_p: 0.98
|
reasoning_effort: high
|
||||||
timeout_seconds: 240
|
#top_p: 0.98
|
||||||
service_tier: flex
|
timeout_seconds: 240
|
||||||
|
service_tier: flex
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
id: gemini-pro-latest
|
profiles:
|
||||||
backend: openrouter
|
gemini-pro-latest:
|
||||||
model: "~google/gemini-pro-latest"
|
backend: openrouter
|
||||||
#temperature: 0.15
|
model: "~google/gemini-pro-latest"
|
||||||
reasoning_effort: high
|
#temperature: 0.15
|
||||||
#top_p: 0.98
|
reasoning_effort: high
|
||||||
timeout_seconds: 240
|
#top_p: 0.98
|
||||||
service_tier: flex
|
timeout_seconds: 240
|
||||||
|
service_tier: flex
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
id: gemma-4-31b
|
profiles:
|
||||||
backend: openrouter
|
gemma-4-31b:
|
||||||
model: google/gemma-4-31b-it:exacto
|
backend: openrouter
|
||||||
temperature: 0.15
|
model: google/gemma-4-31b-it:exacto
|
||||||
reasoning_effort: high
|
temperature: 0.15
|
||||||
top_p: 0.98
|
reasoning_effort: high
|
||||||
timeout_seconds: 240
|
top_p: 0.98
|
||||||
service_tier: flex
|
timeout_seconds: 240
|
||||||
|
service_tier: flex
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
id: minimax-m2
|
profiles:
|
||||||
backend: openrouter
|
minimax-m2:
|
||||||
model: minimax/minimax-m2.5
|
backend: openrouter
|
||||||
temperature: 0.5
|
model: minimax/minimax-m2.5
|
||||||
reasoning_effort: high
|
temperature: 0.5
|
||||||
top_p: 0.95
|
reasoning_effort: high
|
||||||
timeout_seconds: 180
|
top_p: 0.95
|
||||||
service_tier: flex
|
timeout_seconds: 180
|
||||||
|
service_tier: flex
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
id: minimax-m3
|
profiles:
|
||||||
backend: openrouter
|
minimax-m3:
|
||||||
model: minimax/minimax-m3
|
backend: openrouter
|
||||||
#temperature: 0.5
|
model: minimax/minimax-m3
|
||||||
reasoning_effort: high
|
#temperature: 0.5
|
||||||
#top_p: 0.95
|
reasoning_effort: high
|
||||||
timeout_seconds: 180
|
#top_p: 0.95
|
||||||
service_tier: flex
|
timeout_seconds: 180
|
||||||
|
service_tier: flex
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
id: mistral-large-2512
|
profiles:
|
||||||
backend: openrouter
|
mistral-large-2512:
|
||||||
model: mistralai/mistral-large-2512
|
backend: openrouter
|
||||||
temperature: 0.15
|
model: mistralai/mistral-large-2512
|
||||||
top_p: 0.98
|
temperature: 0.15
|
||||||
timeout_seconds: 180
|
top_p: 0.98
|
||||||
|
timeout_seconds: 180
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
id: mistral-medium-3-5
|
profiles:
|
||||||
backend: openrouter
|
mistral-medium-3-5:
|
||||||
model: mistralai/mistral-medium-3-5
|
backend: openrouter
|
||||||
temperature: 0.15
|
model: mistralai/mistral-medium-3-5
|
||||||
reasoning_effort: high
|
temperature: 0.15
|
||||||
top_p: 0.98
|
reasoning_effort: high
|
||||||
timeout_seconds: 180
|
top_p: 0.98
|
||||||
|
timeout_seconds: 180
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
id: mistral-small-3
|
profiles:
|
||||||
backend: openrouter
|
mistral-small-3:
|
||||||
model: mistralai/mistral-small-3.2-24b-instruct
|
backend: openrouter
|
||||||
temperature: 0.05
|
model: mistralai/mistral-small-3.2-24b-instruct
|
||||||
top_p: 1.0
|
temperature: 0.05
|
||||||
timeout_seconds: 180
|
top_p: 1.0
|
||||||
|
timeout_seconds: 180
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
id: mistral-small-4
|
profiles:
|
||||||
backend: openrouter
|
mistral-small-4:
|
||||||
model: mistralai/mistral-small-2603
|
backend: openrouter
|
||||||
temperature: 0.1
|
model: mistralai/mistral-small-2603
|
||||||
reasoning_effort: high
|
temperature: 0.1
|
||||||
top_p: 0.98
|
reasoning_effort: high
|
||||||
timeout_seconds: 180
|
top_p: 0.98
|
||||||
|
timeout_seconds: 180
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
id: nemotron-3-ultra
|
profiles:
|
||||||
backend: openrouter
|
nemotron-3-ultra:
|
||||||
model: nvidia/nemotron-3-ultra-550b-a55b
|
backend: openrouter
|
||||||
reasoning_effort: high
|
model: nvidia/nemotron-3-ultra-550b-a55b
|
||||||
timeout_seconds: 180
|
reasoning_effort: high
|
||||||
service_tier: flex
|
timeout_seconds: 180
|
||||||
|
service_tier: flex
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
id: gpt-5-6-luna
|
profiles:
|
||||||
backend: openrouter
|
gpt-5-6-luna:
|
||||||
model: "openai/gpt-5.6-luna"
|
backend: openrouter
|
||||||
service_tier: flex
|
model: "openai/gpt-5.6-luna"
|
||||||
|
service_tier: flex
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
id: gpt-5-6-sol
|
profiles:
|
||||||
backend: openrouter
|
gpt-5-6-sol:
|
||||||
model: "openai/gpt-5.6-sol"
|
backend: openrouter
|
||||||
service_tier: flex
|
model: "openai/gpt-5.6-sol"
|
||||||
|
service_tier: flex
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
id: gpt-5-6-terra
|
profiles:
|
||||||
backend: openrouter
|
gpt-5-6-terra:
|
||||||
model: "openai/gpt-5.6-terra"
|
backend: openrouter
|
||||||
service_tier: flex
|
model: "openai/gpt-5.6-terra"
|
||||||
|
service_tier: flex
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
id: gpt-6-astra
|
profiles:
|
||||||
backend: openrouter
|
gpt-6-astra:
|
||||||
model: "openai/gpt-6-astra"
|
backend: openrouter
|
||||||
service_tier: flex
|
model: "openai/gpt-6-astra"
|
||||||
|
service_tier: flex
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
id: gpt-astra-latest
|
profiles:
|
||||||
backend: openrouter
|
gpt-astra-latest:
|
||||||
model: "~openai/gpt-astra-latest"
|
backend: openrouter
|
||||||
service_tier: flex
|
model: "~openai/gpt-astra-latest"
|
||||||
|
service_tier: flex
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
id: gpt-luna-latest
|
profiles:
|
||||||
backend: openrouter
|
gpt-luna-latest:
|
||||||
model: "~openai/gpt-luna-latest"
|
backend: openrouter
|
||||||
service_tier: flex
|
model: "~openai/gpt-luna-latest"
|
||||||
|
service_tier: flex
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
id: gpt-sol-latest
|
profiles:
|
||||||
backend: openrouter
|
gpt-sol-latest:
|
||||||
model: "~openai/gpt-sol-latest"
|
backend: openrouter
|
||||||
service_tier: flex
|
model: "~openai/gpt-sol-latest"
|
||||||
|
service_tier: flex
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
id: gpt-terra-latest
|
profiles:
|
||||||
backend: openrouter
|
gpt-terra-latest:
|
||||||
model: "~openai/gpt-terra-latest"
|
backend: openrouter
|
||||||
service_tier: flex
|
model: "~openai/gpt-terra-latest"
|
||||||
|
service_tier: flex
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
id: grok-latest
|
profiles:
|
||||||
backend: openrouter
|
grok-latest:
|
||||||
model: "~x-ai/grok-latest"
|
backend: openrouter
|
||||||
service_tier: flex
|
model: "~x-ai/grok-latest"
|
||||||
|
service_tier: flex
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
id: glm-flash-latest
|
profiles:
|
||||||
backend: openrouter
|
glm-flash-latest:
|
||||||
model: "~z-ai/glm-flash-latest"
|
backend: openrouter
|
||||||
service_tier: flex
|
model: "~z-ai/glm-flash-latest"
|
||||||
|
service_tier: flex
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
id: glm-latest
|
profiles:
|
||||||
backend: openrouter
|
glm-latest:
|
||||||
model: "~z-ai/glm-latest"
|
backend: openrouter
|
||||||
service_tier: flex
|
model: "~z-ai/glm-latest"
|
||||||
|
service_tier: flex
|
||||||
|
|||||||
241
catalog_test.go
241
catalog_test.go
@@ -2,11 +2,11 @@ package openrouter
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
|
"path"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -14,188 +14,145 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestCatalogLayoutAndContents(t *testing.T) {
|
func TestCatalogLayoutAndContents(t *testing.T) {
|
||||||
fsys := FS()
|
assets := FS()
|
||||||
if fsys == nil {
|
if assets == nil {
|
||||||
t.Fatal("FS returned nil")
|
t.Fatal("FS returned nil")
|
||||||
}
|
}
|
||||||
|
backendPath := Root + "/backend.yml"
|
||||||
manifestPath := Root + "/backend.json"
|
backendData, err := fs.ReadFile(assets, backendPath)
|
||||||
manifestData, err := fs.ReadFile(fsys, manifestPath)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("read manifest: %v", err)
|
t.Fatalf("read backend: %v", err)
|
||||||
}
|
}
|
||||||
assertOpenRouterManifest(t, manifestData)
|
assertBackend(t, backendData)
|
||||||
|
|
||||||
profilesRoot := Root + "/profiles"
|
profilesRoot := Root + "/profiles"
|
||||||
profileCount := 0
|
ids := make(map[string]string)
|
||||||
ids := map[string]string{}
|
err = fs.WalkDir(assets, Root, func(assetPath string, entry fs.DirEntry, walkErr error) error {
|
||||||
err = fs.WalkDir(fsys, Root, func(path string, entry fs.DirEntry, walkErr error) error {
|
|
||||||
if walkErr != nil {
|
if walkErr != nil {
|
||||||
return walkErr
|
return walkErr
|
||||||
}
|
}
|
||||||
if path == Root || path == profilesRoot || entry.IsDir() {
|
if assetPath == Root || assetPath == profilesRoot || entry.IsDir() {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if path == manifestPath {
|
if assetPath == backendPath {
|
||||||
if !entry.Type().IsRegular() {
|
if !entry.Type().IsRegular() {
|
||||||
return fmt.Errorf("manifest is not a regular file")
|
return fmt.Errorf("backend asset is not a regular file")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if !strings.HasPrefix(path, profilesRoot+"/") {
|
if !strings.HasPrefix(assetPath, profilesRoot+"/") {
|
||||||
return fmt.Errorf("unexpected catalog entry %q", path)
|
return fmt.Errorf("unexpected catalog entry %q", assetPath)
|
||||||
}
|
}
|
||||||
if !entry.Type().IsRegular() || !strings.HasSuffix(path, ".yml") {
|
if !entry.Type().IsRegular() || !strings.HasSuffix(assetPath, ".yml") {
|
||||||
return fmt.Errorf("profile asset %q is not a regular .yml file", path)
|
return fmt.Errorf("profile asset %q is not a regular .yml file", assetPath)
|
||||||
}
|
}
|
||||||
|
data, err := fs.ReadFile(assets, assetPath)
|
||||||
data, err := fs.ReadFile(fsys, path)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
id := assertOpenRouterProfile(t, path, data)
|
id := assertProfile(t, assetPath, data)
|
||||||
if previous, found := ids[id]; found {
|
if previous, found := ids[id]; found {
|
||||||
return fmt.Errorf("duplicate profile ID %q in %s and %s", id, previous, path)
|
return fmt.Errorf("duplicate profile ID %q in %s and %s", id, previous, assetPath)
|
||||||
}
|
}
|
||||||
ids[id] = path
|
ids[id] = assetPath
|
||||||
profileCount++
|
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("validate catalog layout: %v", err)
|
t.Fatalf("validate catalog layout: %v", err)
|
||||||
}
|
}
|
||||||
if profileCount == 0 {
|
if len(ids) != 36 {
|
||||||
t.Fatal("catalog contains no profiles")
|
t.Fatalf("profile inventory changed: got %d, want 36", len(ids))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func assertOpenRouterManifest(t *testing.T, data []byte) {
|
func assertBackend(t *testing.T, data []byte) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
root := decodeMapping(t, "backend", data)
|
||||||
type manifest struct {
|
backends := requireSingleMapping(t, "backend", root, "backends")
|
||||||
SchemaVersion int `json:"schema_version"`
|
if len(backends) != 1 || backends["openrouter"] == nil {
|
||||||
ID string `json:"id"`
|
t.Fatalf("backend asset must define only openrouter: %v", backends)
|
||||||
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"`
|
|
||||||
}
|
}
|
||||||
|
definition := mappingFields(t, "backend", backends["openrouter"])
|
||||||
decoder := json.NewDecoder(bytes.NewReader(data))
|
for field, want := range map[string]string{
|
||||||
decoder.DisallowUnknownFields()
|
"endpoint": "https://openrouter.ai/api/v1", "api_key_env": "OPENROUTER_API_KEY",
|
||||||
var value manifest
|
"concurrency_limit": "16", "queue_capacity": "1024",
|
||||||
if err := decoder.Decode(&value); err != nil {
|
} {
|
||||||
t.Fatalf("decode manifest: %v", err)
|
value := definition[field]
|
||||||
}
|
if value == nil || value.Value != want {
|
||||||
if err := requireJSONEnd(decoder); err != nil {
|
t.Fatalf("backend %s: got %v, want %q", field, value, want)
|
||||||
t.Fatalf("decode manifest: %v", err)
|
|
||||||
}
|
|
||||||
if value.SchemaVersion != 1 ||
|
|
||||||
value.ID != "openrouter" ||
|
|
||||||
value.Endpoint != "https://openrouter.ai/api/v1" ||
|
|
||||||
value.APIKeyEnv != "OPENROUTER_API_KEY" ||
|
|
||||||
value.ConcurrencyLimit != 16 ||
|
|
||||||
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 assertOpenRouterProfile(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 != "openrouter" {
|
|
||||||
t.Fatalf("profile %s does not select openrouter", 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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if extraParams := fields["extra_params"]; extraParams != nil {
|
if len(definition) != 4 {
|
||||||
assertNoSecretKeys(t, path, extraParams)
|
t.Fatalf("unexpected backend fields: %v", definition)
|
||||||
}
|
}
|
||||||
return id
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func requireYAMLEnd(decoder *yaml.Decoder) error {
|
func assertProfile(t *testing.T, assetPath string, data []byte) string {
|
||||||
|
t.Helper()
|
||||||
|
root := decodeMapping(t, assetPath, data)
|
||||||
|
profiles := requireSingleMapping(t, assetPath, root, "profiles")
|
||||||
|
if len(profiles) != 1 {
|
||||||
|
t.Fatalf("profile asset %s must define exactly one ID", assetPath)
|
||||||
|
}
|
||||||
|
for id, entry := range profiles {
|
||||||
|
if id == "" || id != strings.TrimSuffix(path.Base(assetPath), ".yml") {
|
||||||
|
t.Fatalf("profile asset %s has unexpected ID %q", assetPath, id)
|
||||||
|
}
|
||||||
|
fields := mappingFields(t, assetPath, entry)
|
||||||
|
if fields["backend"] == nil || fields["backend"].Value != "openrouter" {
|
||||||
|
t.Fatalf("profile %s does not select openrouter", assetPath)
|
||||||
|
}
|
||||||
|
if fields["model"] == nil || strings.TrimSpace(fields["model"].Value) == "" {
|
||||||
|
t.Fatalf("profile %s has no model", assetPath)
|
||||||
|
}
|
||||||
|
for _, obsolete := range []string{"id", "base_profile"} {
|
||||||
|
if fields[obsolete] != nil {
|
||||||
|
t.Fatalf("profile %s contains obsolete %s field", assetPath, obsolete)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
panic("unreachable")
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeMapping(t *testing.T, name string, data []byte) *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
|
var trailing yaml.Node
|
||||||
if err := decoder.Decode(&trailing); errors.Is(err, io.EOF) {
|
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
|
||||||
return nil
|
t.Fatalf("%s must contain exactly one YAML document: %v", name, err)
|
||||||
} else if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
return errors.New("profile must contain exactly one YAML document")
|
if document.Kind != yaml.DocumentNode || len(document.Content) != 1 || document.Content[0].Kind != yaml.MappingNode {
|
||||||
|
t.Fatalf("%s must contain one mapping document", name)
|
||||||
|
}
|
||||||
|
return document.Content[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
func yamlFields(mapping *yaml.Node) map[string]*yaml.Node {
|
func requireSingleMapping(t *testing.T, name string, parent *yaml.Node, key string) map[string]*yaml.Node {
|
||||||
fields := make(map[string]*yaml.Node, len(mapping.Content)/2)
|
t.Helper()
|
||||||
for index := 0; index+1 < len(mapping.Content); index += 2 {
|
fields := mappingFields(t, name, parent)
|
||||||
fields[mapping.Content[index].Value] = mapping.Content[index+1]
|
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
|
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
|
# Catalog Release Procedure
|
||||||
|
|
||||||
This repository releases source tags only. It has no generated artifacts. The
|
This repository releases source tags only. It has no generated artifacts. The
|
||||||
module path is `gitea.maximumdirect.net/eric/promptkit-backend-openrouter/v2`.
|
module path is `gitea.maximumdirect.net/eric/promptkit-backend-openrouter/v3`.
|
||||||
|
|
||||||
## Version selection
|
## 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
|
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
|
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
|
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,
|
./...`, `GOWORK=off go vet ./...`, `GOWORK=off go build ./...`, formatting,
|
||||||
repository-hygiene, credential, and ordinary module-resolution checks.
|
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
|
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
|
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
|
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
|
Finally, from a temporary module with `GOWORK=off`, no workspace, no
|
||||||
replacement, and no vendor tree, resolve the exact published
|
replacement, and no vendor tree, resolve the exact published
|
||||||
`gitea.maximumdirect.net/eric/promptkit-backend-openrouter/v2@version`. Verify
|
`gitea.maximumdirect.net/eric/promptkit-backend-openrouter/v3@version`. Verify
|
||||||
both the resolved module identity and version match the selected tag.
|
both the resolved module identity and version match the selected tag.
|
||||||
|
|||||||
Reference in New Issue
Block a user