Add the OpenRouter catalog module

This commit is contained in:
2026-08-26 14:52:14 +00:00
parent dd203359b1
commit 1b8fb1da3e
31 changed files with 448 additions and 1 deletions

View File

@@ -1,2 +1,30 @@
# promptkit-backend-openrouter
# Promptkit OpenRouter Catalog
This repository publishes immutable OpenRouter catalog assets for Promptkit.
It supplies catalog data only; it does not implement provider behavior, load
credentials, or make network requests.
Promptkit owns catalog parsing, validation, runtime behavior, credential
handling, and consumer documentation. This module owns the OpenRouter backend
manifest and its maintained profile data. Consumers should use versions chosen
by their Promptkit release rather than treating this module as a standalone
runtime integration.
The profile assets originated in Promptkit. Their copyright holder has
intentionally authorized their distribution in this repository under its
LGPL-3.0 terms; this is a deliberate relicensing decision, not an automatic
consequence of extracting a dependency.
## Compatibility
Catalog IDs are stable. New profiles are additive. Corrections are released as
compatible catalog updates when they preserve the profile contract; deprecated
profiles are announced before removal in a compatible-major release. Manifest
schema changes follow semantic-version compatibility rules and retain support
for compatible published catalog versions.
## Validation and releases
Run the offline Go tests, race tests, vet, build, formatting, hygiene, and
module-resolution checks before release. The complete source-only release
procedure is in [docs/release.md](docs/release.md).

19
catalog.go Normal file
View File

@@ -0,0 +1,19 @@
// Package openrouter supplies immutable OpenRouter catalog assets for
// Promptkit. It does not implement provider behavior or runtime configuration.
package openrouter
import (
"embed"
"io/fs"
)
// Root is the filesystem root containing this module's catalog assets.
const Root = "catalog"
//go:embed catalog
var catalogFS embed.FS
// FS returns the immutable embedded filesystem containing the catalog assets.
func FS() fs.FS {
return catalogFS
}

9
catalog/backend.json Normal file
View File

@@ -0,0 +1,9 @@
{
"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
}

View File

@@ -0,0 +1,8 @@
id: aion-2
backend: openrouter
model: aion-labs/aion-2.0
temperature: 0.72
reasoning_effort: high
top_p: 0.95
timeout_seconds: 180
service_tier: flex

View File

@@ -0,0 +1,6 @@
id: claude-fable-latest
backend: openrouter
model: "~anthropic/claude-fable-latest"
reasoning_effort: high
timeout_seconds: 600
service_tier: flex

View File

@@ -0,0 +1,6 @@
id: claude-haiku-latest
backend: openrouter
model: "~anthropic/claude-haiku-latest"
reasoning_effort: medium
timeout_seconds: 240
service_tier: flex

View File

@@ -0,0 +1,6 @@
id: claude-opus-latest
backend: openrouter
model: "~anthropic/claude-opus-latest"
reasoning_effort: high
timeout_seconds: 240
service_tier: flex

View File

@@ -0,0 +1,6 @@
id: claude-sonnet-latest
backend: openrouter
model: "~anthropic/claude-sonnet-latest"
reasoning_effort: high
timeout_seconds: 240
service_tier: flex

View File

@@ -0,0 +1,6 @@
id: deepseek-3-2
backend: openrouter
model: deepseek/deepseek-v3.2
reasoning_effort: high
timeout_seconds: 180
service_tier: flex

View File

@@ -0,0 +1,6 @@
id: deepseek-4-flash
backend: openrouter
model: deepseek/deepseek-v4-flash
#reasoning_effort: medium
timeout_seconds: 180
service_tier: flex

View File

@@ -0,0 +1,6 @@
id: deepseek-4-pro
backend: openrouter
model: deepseek/deepseek-v4-pro
reasoning_effort: high
timeout_seconds: 180
service_tier: flex

View File

@@ -0,0 +1,8 @@
id: gemini-2-flash-lite
backend: openrouter
model: "google/gemini-2.5-flash-lite"
#temperature: 0.15
reasoning_effort: high
#top_p: 0.98
timeout_seconds: 240
service_tier: flex

View File

@@ -0,0 +1,8 @@
id: gemini-2-flash
backend: openrouter
model: "google/gemini-2.5-flash"
#temperature: 0.15
reasoning_effort: high
#top_p: 0.98
timeout_seconds: 240
service_tier: flex

View File

@@ -0,0 +1,8 @@
id: gemini-2-pro
backend: openrouter
model: "google/gemini-2.5-pro"
#temperature: 0.15
reasoning_effort: high
#top_p: 0.98
timeout_seconds: 240
service_tier: flex

View File

@@ -0,0 +1,8 @@
id: gemini-3-flash-lite
backend: openrouter
model: "google/gemini-3.1-flash-lite"
#temperature: 0.15
reasoning_effort: high
#top_p: 0.98
timeout_seconds: 240
service_tier: flex

View File

@@ -0,0 +1,8 @@
id: gemini-flash-latest
backend: openrouter
model: "~google/gemini-flash-latest"
#temperature: 0.15
reasoning_effort: high
#top_p: 0.98
timeout_seconds: 240
service_tier: flex

View File

@@ -0,0 +1,8 @@
id: gemini-pro-latest
backend: openrouter
model: "~google/gemini-pro-latest"
#temperature: 0.15
reasoning_effort: high
#top_p: 0.98
timeout_seconds: 240
service_tier: flex

View File

@@ -0,0 +1,8 @@
id: gemma-4-31b
backend: openrouter
model: google/gemma-4-31b-it:exacto
temperature: 0.15
reasoning_effort: high
top_p: 0.98
timeout_seconds: 240
service_tier: flex

View File

@@ -0,0 +1,8 @@
id: minimax-m2
backend: openrouter
model: minimax/minimax-m2.5
temperature: 0.5
reasoning_effort: high
top_p: 0.95
timeout_seconds: 180
service_tier: flex

View File

@@ -0,0 +1,8 @@
id: minimax-m3
backend: openrouter
model: minimax/minimax-m3
#temperature: 0.5
reasoning_effort: high
#top_p: 0.95
timeout_seconds: 180
service_tier: flex

View File

@@ -0,0 +1,6 @@
id: mistral-large-2512
backend: openrouter
model: mistralai/mistral-large-2512
temperature: 0.15
top_p: 0.98
timeout_seconds: 180

View File

@@ -0,0 +1,7 @@
id: mistral-medium-3-5
backend: openrouter
model: mistralai/mistral-medium-3-5
temperature: 0.15
reasoning_effort: high
top_p: 0.98
timeout_seconds: 180

View File

@@ -0,0 +1,6 @@
id: mistral-small-3
backend: openrouter
model: mistralai/mistral-small-3.2-24b-instruct
temperature: 0.05
top_p: 1.0
timeout_seconds: 180

View File

@@ -0,0 +1,7 @@
id: mistral-small-4
backend: openrouter
model: mistralai/mistral-small-2603
temperature: 0.1
reasoning_effort: high
top_p: 0.98
timeout_seconds: 180

View File

@@ -0,0 +1,6 @@
id: nemotron-3-ultra
backend: openrouter
model: nvidia/nemotron-3-ultra-550b-a55b
reasoning_effort: high
timeout_seconds: 180
service_tier: flex

View File

@@ -0,0 +1,6 @@
id: gpt-5-mini
backend: openrouter
model: "openai/gpt-5.4-mini"
reasoning_effort: high
timeout_seconds: 240
service_tier: flex

View File

@@ -0,0 +1,6 @@
id: gpt-5-nano
backend: openrouter
model: "openai/gpt-5.4-nano"
reasoning_effort: high
timeout_seconds: 240
service_tier: flex

201
catalog_test.go Normal file
View File

@@ -0,0 +1,201 @@
package openrouter
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 {
t.Fatal("FS returned nil")
}
manifestPath := Root + "/backend.json"
manifestData, err := fs.ReadFile(fsys, manifestPath)
if err != nil {
t.Fatalf("read manifest: %v", err)
}
assertOpenRouterManifest(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 {
if walkErr != nil {
return walkErr
}
if path == Root || path == profilesRoot || entry.IsDir() {
return nil
}
if path == manifestPath {
if !entry.Type().IsRegular() {
return fmt.Errorf("manifest is not a regular file")
}
return nil
}
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 := assertOpenRouterProfile(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++
return nil
})
if err != nil {
t.Fatalf("validate catalog layout: %v", err)
}
if profileCount == 0 {
t.Fatal("catalog contains no profiles")
}
}
func assertOpenRouterManifest(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 != "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 {
assertNoSecretKeys(t, path, extraParams)
}
return id
}
func requireYAMLEnd(decoder *yaml.Decoder) error {
var trailing yaml.Node
if err := decoder.Decode(&trailing); errors.Is(err, io.EOF) {
return nil
} else if err != nil {
return err
}
return errors.New("profile must contain exactly one YAML document")
}
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]
}
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)
}

15
docs/release.md Normal file
View File

@@ -0,0 +1,15 @@
# Catalog Release Procedure
This repository releases source tags only. It has no generated artifacts.
Before creating a semantic version tag, start from a clean, synchronized
`main` branch. Confirm that no Go workspace, `replace` directive, or `vendor`
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.
Create an annotated tag only after that validation succeeds. Do not create or
move an existing tag. Push `main`, push only the selected tag, and verify the
remote tag identifies the intended commit. Finally, from a temporary module
with `GOWORK=off` and no replacement, resolve the published module version and
verify the resolved version is the selected tag.

5
go.mod Normal file
View File

@@ -0,0 +1,5 @@
module gitea.maximumdirect.net/eric/promptkit-backend-openrouter
go 1.25.5
require gopkg.in/yaml.v3 v3.0.1

4
go.sum Normal file
View File

@@ -0,0 +1,4 @@
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=