Add projects

This commit is contained in:
Alois 2026-08-24 00:10:41 +02:00
commit 8b607dd700
Signed by: alois
SSH key fingerprint: SHA256:GBzT2DXvAuGV9XIV5W3WrzVpjU54FThmxHXdbz95J24
1802 changed files with 503346 additions and 2 deletions

View file

@ -0,0 +1,181 @@
package registry
import (
"bytes"
_ "embed"
"encoding/json"
"fmt"
"math"
"strings"
"sync"
log "github.com/sirupsen/logrus"
)
//go:embed models/codex_client_models.json
var embeddedCodexClientModelsJSON []byte
type codexClientModelsPayload struct {
Models []map[string]any `json:"models"`
}
type codexClientModelsStore struct {
mu sync.RWMutex
data []byte
revision uint64
}
var codexClientCatalogStore = &codexClientModelsStore{}
func init() {
if _, err := loadCodexClientModelsFromBytes(embeddedCodexClientModelsJSON, "embed"); err != nil {
log.Warnf("registry: failed to parse embedded codex_client_models.json (Codex client catalog will remain unavailable until a valid remote refresh): %v", err)
}
}
// GetCodexClientModelsJSON returns the current Codex client model catalog.
func GetCodexClientModelsJSON() []byte {
data, _ := GetCodexClientModelsSnapshot()
return data
}
// GetCodexClientModelsRevision returns the current revision of the Codex client model catalog.
func GetCodexClientModelsRevision() uint64 {
codexClientCatalogStore.mu.RLock()
defer codexClientCatalogStore.mu.RUnlock()
return codexClientCatalogStore.revision
}
// GetCodexClientModelsSnapshot returns a consistent catalog copy and revision.
// The revision changes only when validated catalog content changes.
func GetCodexClientModelsSnapshot() ([]byte, uint64) {
codexClientCatalogStore.mu.RLock()
defer codexClientCatalogStore.mu.RUnlock()
return append([]byte(nil), codexClientCatalogStore.data...), codexClientCatalogStore.revision
}
func loadCodexClientModelsFromBytes(data []byte, source string) (bool, error) {
if err := ValidateCodexClientModelsJSON(data); err != nil {
return false, fmt.Errorf("%s: %w", source, err)
}
cloned := append([]byte(nil), data...)
codexClientCatalogStore.mu.Lock()
defer codexClientCatalogStore.mu.Unlock()
if bytes.Equal(codexClientCatalogStore.data, cloned) {
return false, nil
}
codexClientCatalogStore.data = cloned
codexClientCatalogStore.revision++
return true, nil
}
// ValidateCodexClientModelsJSON validates the fields required to serve a
// complete Codex client model catalog.
func ValidateCodexClientModelsJSON(data []byte) error {
var payload codexClientModelsPayload
if err := json.Unmarshal(data, &payload); err != nil {
return fmt.Errorf("decode Codex client model catalog: %w", err)
}
if len(payload.Models) == 0 {
return fmt.Errorf("Codex client model catalog has no models")
}
seen := make(map[string]struct{}, len(payload.Models))
for i, model := range payload.Models {
slug, err := requiredCodexClientModelString(model, "slug")
if err != nil {
return fmt.Errorf("Codex client model catalog models[%d]: %w", i, err)
}
if _, exists := seen[slug]; exists {
return fmt.Errorf("Codex client model catalog contains duplicate slug %q", slug)
}
seen[slug] = struct{}{}
if err = validateCodexClientModel(model); err != nil {
return fmt.Errorf("Codex client model catalog model %q: %w", slug, err)
}
}
if _, ok := seen["gpt-5.5"]; !ok {
return fmt.Errorf("Codex client model catalog is missing default template %q", "gpt-5.5")
}
return nil
}
func validateCodexClientModel(model map[string]any) error {
for _, field := range []string{
"display_name",
"description",
"base_instructions",
"minimal_client_version",
"visibility",
"default_reasoning_level",
} {
if _, err := requiredCodexClientModelString(model, field); err != nil {
return err
}
}
contextWindow, err := requiredCodexClientModelInteger(model, "context_window", true)
if err != nil {
return err
}
maxContextWindow, err := requiredCodexClientModelInteger(model, "max_context_window", true)
if err != nil {
return err
}
if contextWindow > maxContextWindow {
return fmt.Errorf("context_window %d exceeds max_context_window %d", contextWindow, maxContextWindow)
}
if _, err = requiredCodexClientModelInteger(model, "priority", false); err != nil {
return err
}
levels, ok := model["supported_reasoning_levels"].([]any)
if !ok || len(levels) == 0 {
return fmt.Errorf("field %q must be a non-empty array", "supported_reasoning_levels")
}
seenLevels := make(map[string]struct{}, len(levels))
for i, rawLevel := range levels {
level, ok := rawLevel.(map[string]any)
if !ok {
return fmt.Errorf("field %q entry %d must be an object", "supported_reasoning_levels", i)
}
effort, errEffort := requiredCodexClientModelString(level, "effort")
if errEffort != nil {
return fmt.Errorf("field %q entry %d: %w", "supported_reasoning_levels", i, errEffort)
}
if _, exists := seenLevels[effort]; exists {
return fmt.Errorf("field %q contains duplicate effort %q", "supported_reasoning_levels", effort)
}
seenLevels[effort] = struct{}{}
}
defaultLevel, _ := requiredCodexClientModelString(model, "default_reasoning_level")
if _, ok = seenLevels[defaultLevel]; !ok {
return fmt.Errorf("default_reasoning_level %q is not listed in supported_reasoning_levels", defaultLevel)
}
return nil
}
func requiredCodexClientModelString(model map[string]any, field string) (string, error) {
value, ok := model[field].(string)
value = strings.TrimSpace(value)
if !ok || value == "" {
return "", fmt.Errorf("field %q must be a non-empty string", field)
}
return value, nil
}
func requiredCodexClientModelInteger(model map[string]any, field string, positive bool) (int64, error) {
value, ok := model[field].(float64)
if !ok || math.IsNaN(value) || math.IsInf(value, 0) || math.Trunc(value) != value || value > math.MaxInt64 {
return 0, fmt.Errorf("field %q must be an integer", field)
}
if positive && value <= 0 {
return 0, fmt.Errorf("field %q must be positive", field)
}
if !positive && value < 0 {
return 0, fmt.Errorf("field %q must not be negative", field)
}
return int64(value), nil
}

View file

@ -0,0 +1,208 @@
package registry
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestEmbeddedCodexClientModelsCatalogIsValid(t *testing.T) {
data, revision := GetCodexClientModelsSnapshot()
if revision == 0 {
t.Fatal("embedded Codex client model catalog revision = 0, want non-zero")
}
if err := ValidateCodexClientModelsJSON(data); err != nil {
t.Fatalf("embedded Codex client model catalog is invalid: %v", err)
}
data[0] ^= 0xff
second, secondRevision := GetCodexClientModelsSnapshot()
if secondRevision != revision {
t.Fatalf("snapshot revision = %d, want %d", secondRevision, revision)
}
if err := ValidateCodexClientModelsJSON(second); err != nil {
t.Fatalf("mutating returned snapshot changed stored catalog: %v", err)
}
}
func TestValidateCodexClientModelsJSON(t *testing.T) {
validDefault := testCodexClientModel("gpt-5.5", 1)
validOther := testCodexClientModel("gpt-5.6-sol", 2)
emptySlug := testCodexClientModel("gpt-5.5", 1)
emptySlug["slug"] = ""
missingField := testCodexClientModel("gpt-5.5", 1)
delete(missingField, "base_instructions")
wrongFieldType := testCodexClientModel("gpt-5.5", 1)
wrongFieldType["context_window"] = "372000"
unsupportedDefault := testCodexClientModel("gpt-5.5", 1)
unsupportedDefault["default_reasoning_level"] = "high"
tests := []struct {
name string
raw []byte
}{
{name: "malformed", raw: []byte(`{"models":`)},
{name: "empty", raw: []byte(`{"models":[]}`)},
{name: "empty slug", raw: testCodexClientCatalog(t, emptySlug)},
{name: "duplicate slug", raw: testCodexClientCatalog(t, validDefault, validDefault)},
{name: "missing default", raw: testCodexClientCatalog(t, validOther)},
{name: "missing required field", raw: testCodexClientCatalog(t, missingField)},
{name: "wrong required field type", raw: testCodexClientCatalog(t, wrongFieldType)},
{name: "default reasoning level not supported", raw: testCodexClientCatalog(t, unsupportedDefault)},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if err := ValidateCodexClientModelsJSON(tt.raw); err == nil {
t.Fatal("ValidateCodexClientModelsJSON() error = nil, want error")
}
})
}
valid := testCodexClientCatalog(t, validDefault, validOther)
if err := ValidateCodexClientModelsJSON(valid); err != nil {
t.Fatalf("valid catalog rejected: %v", err)
}
}
func TestLoadCodexClientModelsRejectsInvalidWithoutReplacing(t *testing.T) {
original, _ := GetCodexClientModelsSnapshot()
t.Cleanup(func() {
if _, err := loadCodexClientModelsFromBytes(original, "test cleanup"); err != nil {
t.Fatalf("restore original catalog: %v", err)
}
})
valid := testCodexClientCatalog(t, testCodexClientModel("gpt-5.5", 1))
changed, err := loadCodexClientModelsFromBytes(valid, "test")
if err != nil {
t.Fatalf("load valid catalog: %v", err)
}
if !changed {
t.Fatal("load valid catalog changed = false, want true")
}
beforeInvalid, revision := GetCodexClientModelsSnapshot()
if _, err = loadCodexClientModelsFromBytes([]byte(`{"models":[]}`), "test invalid"); err == nil {
t.Fatal("load invalid catalog error = nil, want error")
}
afterInvalid, afterRevision := GetCodexClientModelsSnapshot()
if string(afterInvalid) != string(beforeInvalid) {
t.Fatal("invalid catalog replaced current snapshot")
}
if afterRevision != revision {
t.Fatalf("revision after invalid catalog = %d, want %d", afterRevision, revision)
}
}
func TestFetchCodexClientModelsFallsBackToNextURL(t *testing.T) {
invalidServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"models":[{"slug":"gpt-5.6-sol"}]}`))
}))
defer invalidServer.Close()
validCatalog := testCodexClientCatalog(t, testCodexClientModel("gpt-5.5", 1))
validServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
t.Errorf("method = %s, want GET", r.Method)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(validCatalog)
}))
defer validServer.Close()
previousURLs := codexClientModelsURLs
codexClientModelsURLs = []string{invalidServer.URL, validServer.URL}
t.Cleanup(func() { codexClientModelsURLs = previousURLs })
data, sourceURL := fetchCodexClientModelsFromRemote(context.Background())
if sourceURL != validServer.URL {
t.Fatalf("source URL = %q, want %q", sourceURL, validServer.URL)
}
if string(data) != string(validCatalog) {
t.Fatalf("catalog = %s, want %s", data, validCatalog)
}
}
func TestRefreshCodexClientModelsKeepsLastValidSnapshot(t *testing.T) {
original, _ := GetCodexClientModelsSnapshot()
previousURLs := codexClientModelsURLs
t.Cleanup(func() {
codexClientModelsURLs = previousURLs
if _, err := loadCodexClientModelsFromBytes(original, "test cleanup"); err != nil {
t.Fatalf("restore original catalog: %v", err)
}
})
lastValid := testCodexClientCatalog(t, testCodexClientModel("gpt-5.5", 1))
if _, err := loadCodexClientModelsFromBytes(lastValid, "test last valid"); err != nil {
t.Fatalf("load last valid catalog: %v", err)
}
tests := []struct {
name string
statusCode int
body string
}{
{name: "remote files missing", statusCode: http.StatusNotFound},
{name: "remote JSON malformed", statusCode: http.StatusOK, body: `{"models":`},
{name: "remote JSON incomplete", statusCode: http.StatusOK, body: `{"models":[{"slug":"gpt-5.5"}]}`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
servers := make([]*httptest.Server, 0, 2)
urls := make([]string, 0, 2)
for range 2 {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(tt.statusCode)
_, _ = w.Write([]byte(tt.body))
}))
servers = append(servers, server)
urls = append(urls, server.URL)
}
defer func() {
for _, server := range servers {
server.Close()
}
}()
before, revision := GetCodexClientModelsSnapshot()
codexClientModelsURLs = urls
tryRefreshCodexClientModels(context.Background(), "test refresh")
after, afterRevision := GetCodexClientModelsSnapshot()
if string(after) != string(before) {
t.Fatal("failed remote refresh replaced last valid catalog")
}
if afterRevision != revision {
t.Fatalf("revision after failed refresh = %d, want %d", afterRevision, revision)
}
})
}
}
func testCodexClientModel(slug string, priority int) map[string]any {
return map[string]any{
"slug": slug,
"display_name": "Test " + slug,
"description": "Test model",
"base_instructions": "Test instructions",
"minimal_client_version": "0.144.0",
"visibility": "list",
"context_window": 372000,
"max_context_window": 372000,
"priority": priority,
"default_reasoning_level": "medium",
"supported_reasoning_levels": []map[string]any{{"effort": "medium", "description": "Balanced"}},
}
}
func testCodexClientCatalog(t *testing.T, models ...map[string]any) []byte {
t.Helper()
data, err := json.Marshal(map[string]any{"models": models})
if err != nil {
t.Fatalf("marshal test Codex client catalog: %v", err)
}
return data
}

View file

@ -0,0 +1,114 @@
package registry
import (
"context"
"io"
"net/http"
"sync"
"time"
log "github.com/sirupsen/logrus"
)
const maxCodexClientModelsSize = 8 << 20
var codexClientModelsURLs = []string{
"https://raw.githubusercontent.com/router-for-me/models/refs/heads/main/codex_client_models.json",
"https://models.router-for.me/codex_client_models.json",
}
var codexClientModelsUpdaterOnce sync.Once
// StartCodexClientModelsUpdater starts a background updater that fetches the
// Codex client model catalog immediately and then refreshes it every 3 hours.
// Safe to call multiple times; only one updater will run.
func StartCodexClientModelsUpdater(ctx context.Context) {
codexClientModelsUpdaterOnce.Do(func() {
go runCodexClientModelsUpdater(ctx)
})
}
func runCodexClientModelsUpdater(ctx context.Context) {
tryRefreshCodexClientModels(ctx, "startup Codex client model refresh")
ticker := time.NewTicker(modelsRefreshInterval)
defer ticker.Stop()
log.Infof("periodic Codex client model refresh started (interval=%s)", modelsRefreshInterval)
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
tryRefreshCodexClientModels(ctx, "periodic Codex client model refresh")
}
}
}
func tryRefreshCodexClientModels(ctx context.Context, label string) {
data, sourceURL := fetchCodexClientModelsFromRemote(ctx)
if data == nil {
log.Warnf("%s: fetch failed from all URLs, keeping current data", label)
return
}
changed, err := loadCodexClientModelsFromBytes(data, sourceURL)
if err != nil {
log.Warnf("%s: fetched catalog rejected, keeping current data: %v", label, err)
return
}
if !changed {
log.Infof("%s completed from %s, no changes detected", label, sourceURL)
return
}
log.Infof("%s completed from %s, catalog updated", label, sourceURL)
}
func fetchCodexClientModelsFromRemote(ctx context.Context) ([]byte, string) {
client := &http.Client{Timeout: modelsFetchTimeout}
for _, sourceURL := range codexClientModelsURLs {
reqCtx, cancel := context.WithTimeout(ctx, modelsFetchTimeout)
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, sourceURL, nil)
if err != nil {
cancel()
log.Debugf("Codex client models fetch request creation failed for %s: %v", sourceURL, err)
continue
}
resp, err := client.Do(req)
if err != nil {
cancel()
log.Debugf("Codex client models fetch failed from %s: %v", sourceURL, err)
continue
}
if resp.StatusCode != http.StatusOK {
if errClose := resp.Body.Close(); errClose != nil {
log.Debugf("Codex client models response close failed for %s: %v", sourceURL, errClose)
}
cancel()
log.Debugf("Codex client models fetch returned %d from %s", resp.StatusCode, sourceURL)
continue
}
data, errRead := io.ReadAll(io.LimitReader(resp.Body, maxCodexClientModelsSize+1))
errClose := resp.Body.Close()
cancel()
if errRead != nil {
log.Debugf("Codex client models fetch read error from %s: %v", sourceURL, errRead)
continue
}
if errClose != nil {
log.Debugf("Codex client models response close failed for %s: %v", sourceURL, errClose)
continue
}
if len(data) > maxCodexClientModelsSize {
log.Warnf("Codex client models fetch from %s exceeded %d bytes", sourceURL, maxCodexClientModelsSize)
continue
}
if err := ValidateCodexClientModelsJSON(data); err != nil {
log.Warnf("Codex client models validate failed from %s: %v", sourceURL, err)
continue
}
return data, sourceURL
}
return nil, ""
}

View file

@ -0,0 +1,362 @@
// Package registry provides model definitions and lookup helpers for various AI providers.
// Static model metadata is loaded from the embedded models.json file and can be refreshed from network.
package registry
import (
"strings"
)
const (
codexBuiltinImage15ModelID = "gpt-image-1.5"
codexBuiltinImageModelID = "gpt-image-2"
xaiBuiltinImageModelID = "grok-imagine-image"
xaiBuiltinImageQualityModelID = "grok-imagine-image-quality"
xaiBuiltinImage20ModelID = "grok-imagine-image-2.0"
xaiBuiltinVideoModelID = "grok-imagine-video"
xaiBuiltinVideo15ModelID = "grok-imagine-video-1.5"
xaiBuiltinVideo15PreviewID = "grok-imagine-video-1.5-preview"
)
// staticModelsJSON mirrors the top-level structure of models.json.
type staticModelsJSON struct {
Claude []*ModelInfo `json:"claude"`
Gemini []*ModelInfo `json:"gemini"`
Vertex []*ModelInfo `json:"vertex"`
AIStudio []*ModelInfo `json:"aistudio"`
CodexFree []*ModelInfo `json:"codex-free"`
CodexTeam []*ModelInfo `json:"codex-team"`
CodexPlus []*ModelInfo `json:"codex-plus"`
CodexPro []*ModelInfo `json:"codex-pro"`
Kimi []*ModelInfo `json:"kimi"`
Antigravity []*ModelInfo `json:"antigravity"`
XAI []*ModelInfo `json:"xai"`
}
// GetClaudeModels returns the standard Claude model definitions.
func GetClaudeModels() []*ModelInfo {
return cloneModelInfos(getModels().Claude)
}
// GetGeminiModels returns the standard Gemini model definitions.
func GetGeminiModels() []*ModelInfo {
return cloneModelInfos(getModels().Gemini)
}
// GetGeminiVertexModels returns Gemini model definitions for Vertex AI.
func GetGeminiVertexModels() []*ModelInfo {
return cloneModelInfos(getModels().Vertex)
}
// GetAIStudioModels returns model definitions for AI Studio.
func GetAIStudioModels() []*ModelInfo {
return cloneModelInfos(getModels().AIStudio)
}
// GetCodexFreeModels returns model definitions for the Codex free plan tier.
func GetCodexFreeModels() []*ModelInfo {
return WithCodexBuiltins(cloneModelInfos(getModels().CodexFree))
}
// GetCodexTeamModels returns model definitions for the Codex team plan tier.
func GetCodexTeamModels() []*ModelInfo {
return WithCodexBuiltins(cloneModelInfos(getModels().CodexTeam))
}
// GetCodexPlusModels returns model definitions for the Codex plus plan tier.
func GetCodexPlusModels() []*ModelInfo {
return WithCodexBuiltins(cloneModelInfos(getModels().CodexPlus))
}
// GetCodexProModels returns model definitions for the Codex pro plan tier.
func GetCodexProModels() []*ModelInfo {
return WithCodexBuiltins(cloneModelInfos(getModels().CodexPro))
}
// GetKimiModels returns the standard Kimi (Moonshot AI) model definitions.
func GetKimiModels() []*ModelInfo {
return cloneModelInfos(getModels().Kimi)
}
// GetAntigravityModels returns the standard Antigravity model definitions.
func GetAntigravityModels() []*ModelInfo {
return cloneModelInfos(getModels().Antigravity)
}
// AntigravityWebSearchModelFor returns the Antigravity model that should run a
// native web search request for modelID.
func AntigravityWebSearchModelFor(modelID string) string {
modelID = normalizeAntigravityCapabilityModelID(modelID)
if modelID == "" {
return ""
}
for _, model := range GetGlobalRegistry().GetAvailableModelsByProvider("antigravity") {
if model == nil {
continue
}
currentModelID := normalizeAntigravityCapabilityModelID(model.ID)
if currentModelID == "" {
continue
}
if currentModelID == modelID {
if model.SupportsWebSearch {
return currentModelID
}
return ""
}
}
return ""
}
// GetXAIModels returns the standard xAI Grok model definitions.
func GetXAIModels() []*ModelInfo {
return WithXAIBuiltins(cloneModelInfos(getModels().XAI))
}
// WithCodexBuiltins injects hard-coded Codex-only model definitions that should
// not depend on remote models.json updates. Built-ins replace any matching IDs
// already present in the provided slice.
func WithCodexBuiltins(models []*ModelInfo) []*ModelInfo {
return upsertModelInfos(models, codexBuiltinImage15ModelInfo(), codexBuiltinImageModelInfo())
}
// WithXAIBuiltins injects hard-coded xAI image/video model definitions that should
// not depend on remote models.json updates.
func WithXAIBuiltins(models []*ModelInfo) []*ModelInfo {
return upsertModelInfos(models, xaiBuiltinImageModelInfo(), xaiBuiltinImageQualityModelInfo(), xaiBuiltinImage20ModelInfo(), xaiBuiltinVideoModelInfo(), xaiBuiltinVideo15ModelInfo(), xaiBuiltinVideo15PreviewModelInfo())
}
func normalizeAntigravityCapabilityModelID(modelID string) string {
modelID = strings.ToLower(strings.TrimSpace(modelID))
if open := strings.LastIndex(modelID, "("); open >= 0 && strings.HasSuffix(modelID, ")") {
modelID = strings.TrimSpace(modelID[:open])
}
return modelID
}
func codexBuiltinImage15ModelInfo() *ModelInfo {
return &ModelInfo{
ID: codexBuiltinImage15ModelID,
Object: "model",
Created: 1704067200, // 2024-01-01
OwnedBy: "openai",
Type: "openai",
DisplayName: "GPT Image 1.5",
Version: codexBuiltinImage15ModelID,
}
}
func codexBuiltinImageModelInfo() *ModelInfo {
return &ModelInfo{
ID: codexBuiltinImageModelID,
Object: "model",
Created: 1704067200, // 2024-01-01
OwnedBy: "openai",
Type: "openai",
DisplayName: "GPT Image 2",
Version: codexBuiltinImageModelID,
}
}
func xaiBuiltinImageModelInfo() *ModelInfo {
return &ModelInfo{
ID: xaiBuiltinImageModelID,
Object: "model",
Created: 1735689600, // 2025-01-01
OwnedBy: "xai",
Type: "xai",
DisplayName: "Grok Imagine Image",
Name: xaiBuiltinImageModelID,
Description: "xAI Grok image generation model.",
}
}
func xaiBuiltinImageQualityModelInfo() *ModelInfo {
return &ModelInfo{
ID: xaiBuiltinImageQualityModelID,
Object: "model",
Created: 1735689600, // 2025-01-01
OwnedBy: "xai",
Type: "xai",
DisplayName: "Grok Imagine Image Quality",
Name: xaiBuiltinImageQualityModelID,
Description: "xAI Grok higher-fidelity image generation model.",
}
}
func xaiBuiltinImage20ModelInfo() *ModelInfo {
return &ModelInfo{
ID: xaiBuiltinImage20ModelID,
Object: "model",
Created: 1786060800, // 2026-08-07
OwnedBy: "xai",
Type: "xai",
DisplayName: "Grok Imagine Image 2.0",
Name: xaiBuiltinImage20ModelID,
Description: "xAI Grok image generation model.",
}
}
func xaiBuiltinVideoModelInfo() *ModelInfo {
return &ModelInfo{
ID: xaiBuiltinVideoModelID,
Object: "model",
Created: 1735689600, // 2025-01-01
OwnedBy: "xai",
Type: "xai",
DisplayName: "Grok Imagine Video",
Name: xaiBuiltinVideoModelID,
Description: "xAI Grok video generation model.",
}
}
func xaiBuiltinVideo15ModelInfo() *ModelInfo {
return &ModelInfo{
ID: xaiBuiltinVideo15ModelID,
Object: "model",
Created: 1735689600, // 2025-01-01
OwnedBy: "xai",
Type: "xai",
DisplayName: "Grok Imagine Video 1.5",
Name: xaiBuiltinVideo15ModelID,
Description: "xAI Grok video generation model.",
}
}
func xaiBuiltinVideo15PreviewModelInfo() *ModelInfo {
return &ModelInfo{
ID: xaiBuiltinVideo15PreviewID,
Object: "model",
Created: 1735689600, // 2025-01-01
OwnedBy: "xai",
Type: "xai",
DisplayName: "Grok Imagine Video 1.5 Preview",
Name: xaiBuiltinVideo15PreviewID,
Description: "Compatibility alias for the xAI Grok video generation model.",
}
}
func upsertModelInfos(models []*ModelInfo, extras ...*ModelInfo) []*ModelInfo {
if len(extras) == 0 {
return models
}
extraIDs := make(map[string]struct{}, len(extras))
extraList := make([]*ModelInfo, 0, len(extras))
for _, extra := range extras {
if extra == nil {
continue
}
id := strings.TrimSpace(extra.ID)
if id == "" {
continue
}
key := strings.ToLower(id)
if _, exists := extraIDs[key]; exists {
continue
}
extraIDs[key] = struct{}{}
extraList = append(extraList, cloneModelInfo(extra))
}
if len(extraList) == 0 {
return models
}
filtered := make([]*ModelInfo, 0, len(models)+len(extraList))
for _, model := range models {
if model == nil {
continue
}
id := strings.TrimSpace(model.ID)
if id == "" {
continue
}
if _, exists := extraIDs[strings.ToLower(id)]; exists {
continue
}
filtered = append(filtered, model)
}
filtered = append(filtered, extraList...)
return filtered
}
// cloneModelInfos returns a shallow copy of the slice with each element deep-cloned.
func cloneModelInfos(models []*ModelInfo) []*ModelInfo {
if len(models) == 0 {
return nil
}
out := make([]*ModelInfo, len(models))
for i, m := range models {
out[i] = cloneModelInfo(m)
}
return out
}
// GetStaticModelDefinitionsByChannel returns static model definitions for a given channel/provider.
// It returns nil when the channel is unknown.
//
// Supported channels:
// - claude
// - gemini
// - gemini-interactions
// - vertex
// - aistudio
// - codex
// - kimi
// - antigravity
// - xai
func GetStaticModelDefinitionsByChannel(channel string) []*ModelInfo {
key := strings.ToLower(strings.TrimSpace(channel))
switch key {
case "claude":
return GetClaudeModels()
case "gemini":
return GetGeminiModels()
case "gemini-interactions":
return GetGeminiModels()
case "vertex":
return GetGeminiVertexModels()
case "aistudio":
return GetAIStudioModels()
case "codex":
return GetCodexProModels()
case "kimi":
return GetKimiModels()
case "antigravity":
return GetAntigravityModels()
case "xai", "x-ai", "grok":
return GetXAIModels()
default:
return nil
}
}
// LookupStaticModelInfo searches all static model definitions for a model by ID.
// Returns nil if no matching model is found.
func LookupStaticModelInfo(modelID string) *ModelInfo {
if modelID == "" {
return nil
}
data := getModels()
allModels := [][]*ModelInfo{
data.Claude,
data.Gemini,
data.Vertex,
data.AIStudio,
data.CodexPro,
data.Kimi,
data.Antigravity,
data.XAI,
}
for _, models := range allModels {
for _, m := range models {
if m != nil && m.ID == modelID {
return cloneModelInfo(m)
}
}
}
return nil
}

View file

@ -0,0 +1,113 @@
package registry
import "testing"
func TestGetStaticModelDefinitionsByChannelSupportsGeminiInteractions(t *testing.T) {
models := GetStaticModelDefinitionsByChannel("gemini-interactions")
if len(models) == 0 {
t.Fatal("GetStaticModelDefinitionsByChannel(gemini-interactions) returned no models")
}
}
func TestModelOverrideHeadersFromEmbeddedModels(t *testing.T) {
const wantUA = "codex-tui/0.144.0 (Mac OS 26.5.1; arm64) iTerm.app/3.6.11 (codex-tui; 0.144.0)"
got := ModelOverrideHeaders("gpt-5.6-luna")
if got == nil {
t.Fatal("ModelOverrideHeaders(gpt-5.6-luna) = nil, want headers")
}
if got["user-agent"] != wantUA {
t.Fatalf("user-agent = %q, want %q", got["user-agent"], wantUA)
}
if got := ModelOverrideHeaders("gpt-5.4"); got != nil {
t.Fatalf("ModelOverrideHeaders(gpt-5.4) = %#v, want nil", got)
}
}
func TestGeminiVertexModelsUseFlashLiteReleaseID(t *testing.T) {
const releaseID = "gemini-3.1-flash-lite"
const previewID = releaseID + "-preview"
for _, model := range GetGeminiVertexModels() {
if model == nil {
continue
}
if model.ID == previewID {
t.Fatalf("Vertex model ID = %q, want release ID %q", model.ID, releaseID)
}
if model.ID == releaseID {
return
}
}
t.Fatalf("Vertex models do not contain %q", releaseID)
}
func TestWithXAIBuiltinsIncludesImage20(t *testing.T) {
models := WithXAIBuiltins(nil)
for _, model := range models {
if model != nil && model.ID == xaiBuiltinImage20ModelID {
if model.Created != 1786060800 {
t.Fatalf("created = %d, want 1786060800 (2026-08-07)", model.Created)
}
return
}
}
t.Fatalf("expected xAI builtin model %s", xaiBuiltinImage20ModelID)
}
func TestWithXAIBuiltinsIncludesVideo15GAAndPreviewAlias(t *testing.T) {
models := WithXAIBuiltins(nil)
foundGA := false
foundPreviewAlias := false
for _, model := range models {
if model == nil {
continue
}
if model.ID == xaiBuiltinVideo15ModelID {
foundGA = true
}
if model.ID == xaiBuiltinVideo15PreviewID {
foundPreviewAlias = true
}
}
if !foundGA {
t.Fatalf("expected xAI builtin model %s", xaiBuiltinVideo15ModelID)
}
if !foundPreviewAlias {
t.Fatalf("expected xAI builtin compatibility alias %s", xaiBuiltinVideo15PreviewID)
}
}
func TestAntigravityWebSearchModelForRequiresRequestedModelCapability(t *testing.T) {
registryRef := GetGlobalRegistry()
registryRef.RegisterClient("test-antigravity-websearch-route", "antigravity", []*ModelInfo{
{ID: "gemini-route-test"},
{ID: "gemini-web-search-test", SupportsWebSearch: true},
})
registryRef.RegisterClient("test-gemini-websearch-route", "gemini", []*ModelInfo{
{ID: "gemini-cross-provider-route"},
{ID: "gemini-cross-provider-search", SupportsWebSearch: true},
})
t.Cleanup(func() {
registryRef.UnregisterClient("test-antigravity-websearch-route")
registryRef.UnregisterClient("test-gemini-websearch-route")
})
if got := AntigravityWebSearchModelFor("gemini-route-test"); got != "" {
t.Fatalf("route model without web search support should not get fallback model, got %q", got)
}
if got := AntigravityWebSearchModelFor("gemini-route-test(high)"); got != "" {
t.Fatalf("suffix route model without web search support should not get fallback model, got %q", got)
}
if got := AntigravityWebSearchModelFor("gemini-web-search-test"); got != "gemini-web-search-test" {
t.Fatalf("AntigravityWebSearchModelFor capable model = %q, want itself", got)
}
if got := AntigravityWebSearchModelFor("gemini-cross-provider-route"); got != "" {
t.Fatalf("cross-provider model should not get Antigravity web search model, got %q", got)
}
if got := AntigravityWebSearchModelFor("unknown-model"); got != "" {
t.Fatalf("unknown model should not get Antigravity web search model, got %q", got)
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,100 @@
package registry
import "testing"
func TestGetAvailableModelsReturnsClonedSnapshots(t *testing.T) {
r := newTestModelRegistry()
r.RegisterClient("client-1", "OpenAI", []*ModelInfo{{ID: "m1", OwnedBy: "team-a", DisplayName: "Model One"}})
first := r.GetAvailableModels("openai")
if len(first) != 1 {
t.Fatalf("expected 1 model, got %d", len(first))
}
first[0]["id"] = "mutated"
first[0]["display_name"] = "Mutated"
second := r.GetAvailableModels("openai")
if got := second[0]["id"]; got != "m1" {
t.Fatalf("expected cached snapshot to stay isolated, got id %v", got)
}
if got := second[0]["display_name"]; got != "Model One" {
t.Fatalf("expected cached snapshot to stay isolated, got display_name %v", got)
}
}
func TestGetAvailableModelsClaudeIncludesTokenLimits(t *testing.T) {
r := newTestModelRegistry()
r.RegisterClient("client-1", "Claude", []*ModelInfo{
{ID: "claude-sonnet-4-6", OwnedBy: "anthropic", Type: "claude", Created: 1771372800, ContextLength: 200000, MaxCompletionTokens: 64000},
{ID: "claude-no-limits", OwnedBy: "anthropic", Type: "claude"},
})
models := r.GetAvailableModels("claude")
byID := make(map[string]map[string]any, len(models))
for _, m := range models {
id, _ := m["id"].(string)
byID[id] = m
}
withLimits, ok := byID["claude-sonnet-4-6"]
if !ok {
t.Fatalf("expected claude-sonnet-4-6 in available models, got %v", byID)
}
if got := withLimits["max_input_tokens"]; got != 200000 {
t.Fatalf("expected max_input_tokens 200000, got %v", got)
}
if got := withLimits["max_tokens"]; got != 64000 {
t.Fatalf("expected max_tokens 64000, got %v", got)
}
if got := withLimits["created_at"]; got != "2026-02-18T00:00:00Z" {
t.Fatalf("expected created_at as RFC 3339 string, got %v", got)
}
withDefaults, ok := byID["claude-no-limits"]
if !ok {
t.Fatalf("expected claude-no-limits in available models, got %v", byID)
}
if got := withDefaults["max_input_tokens"]; got != DefaultClaudeMaxInputTokens {
t.Fatalf("expected fallback max_input_tokens %d, got %v", DefaultClaudeMaxInputTokens, got)
}
if got := withDefaults["max_tokens"]; got != DefaultClaudeMaxOutputTokens {
t.Fatalf("expected fallback max_tokens %d, got %v", DefaultClaudeMaxOutputTokens, got)
}
if got := withDefaults["display_name"]; got != "claude-no-limits" {
t.Fatalf("expected display_name to fall back to id, got %v", got)
}
if got := withDefaults["type"]; got != "model" {
t.Fatalf("expected type to default to model, got %v", got)
}
}
func TestGetAvailableModelsInvalidatesCacheOnRegistryChanges(t *testing.T) {
r := newTestModelRegistry()
r.RegisterClient("client-1", "OpenAI", []*ModelInfo{{ID: "m1", OwnedBy: "team-a", DisplayName: "Model One"}})
models := r.GetAvailableModels("openai")
if len(models) != 1 {
t.Fatalf("expected 1 model, got %d", len(models))
}
if got := models[0]["display_name"]; got != "Model One" {
t.Fatalf("expected initial display_name Model One, got %v", got)
}
r.RegisterClient("client-1", "OpenAI", []*ModelInfo{{ID: "m1", OwnedBy: "team-a", DisplayName: "Model One Updated"}})
models = r.GetAvailableModels("openai")
if got := models[0]["display_name"]; got != "Model One Updated" {
t.Fatalf("expected updated display_name after cache invalidation, got %v", got)
}
r.SuspendClientModel("client-1", "m1", "manual")
models = r.GetAvailableModels("openai")
if len(models) != 0 {
t.Fatalf("expected no available models after suspension, got %d", len(models))
}
r.ResumeClientModel("client-1", "m1")
models = r.GetAvailableModels("openai")
if len(models) != 1 {
t.Fatalf("expected model to reappear after resume, got %d", len(models))
}
}

View file

@ -0,0 +1,100 @@
package registry
import "testing"
func TestGetAvailableModelInfosPreservesMetadataAndAvailability(t *testing.T) {
modelRegistry := newTestModelRegistry()
modelRegistry.RegisterClient("openai-client", "openai", []*ModelInfo{
{ID: "z-model", DisplayName: "Z Model", ContextLength: 1000},
})
modelRegistry.RegisterClient("claude-client", "claude", []*ModelInfo{
{ID: "a-model", DisplayName: "A Model", ContextLength: 2000, Thinking: &ThinkingSupport{Levels: []string{"low", "high"}}},
})
modelRegistry.RegisterClient("xai-client", "xai", []*ModelInfo{{ID: "x-model"}})
modelRegistry.RegisterClient("suspended-client", "xai", []*ModelInfo{{ID: "hidden-model"}})
modelRegistry.SuspendClientModel("suspended-client", "hidden-model", "manual")
models := modelRegistry.GetAvailableModelInfos()
if len(models) != 3 {
t.Fatalf("available model count = %d, want 3", len(models))
}
if models[0].ID != "a-model" || models[1].ID != "x-model" || models[2].ID != "z-model" {
t.Fatalf("model order = [%s, %s, %s], want [a-model, x-model, z-model]", models[0].ID, models[1].ID, models[2].ID)
}
if models[0].Thinking == nil || len(models[0].Thinking.Levels) != 2 || models[0].Thinking.Levels[1] != "high" {
t.Fatalf("thinking metadata = %#v", models[0].Thinking)
}
for _, model := range models {
if model.ID == "hidden-model" {
t.Fatalf("suspended model returned: %#v", model)
}
}
models[0].Thinking.Levels[0] = "mutated"
fresh := modelRegistry.GetAvailableModelInfos()
if fresh[0].Thinking.Levels[0] != "low" {
t.Fatalf("snapshot was not cloned: %#v", fresh[0].Thinking.Levels)
}
}
func TestGetAvailableModelInfosHonorsQuotaAndSuspensionAvailability(t *testing.T) {
tests := []struct {
name string
clientCount int
quotaExceeded bool
quotaSuspended bool
manualSuspended bool
wantModelAvailable bool
}{
{
name: "quota cooldown remains listed",
quotaExceeded: true,
wantModelAvailable: true,
},
{
name: "quota suspension reason remains listed",
quotaSuspended: true,
wantModelAvailable: true,
},
{
name: "quota and non-quota suspensions are hidden",
clientCount: 2,
quotaExceeded: true,
quotaSuspended: true,
manualSuspended: true,
wantModelAvailable: false,
},
}
for _, testCase := range tests {
t.Run(testCase.name, func(t *testing.T) {
const modelID = "shared-model"
modelRegistry := newTestModelRegistry()
modelRegistry.RegisterClient("quota-client", "openai", []*ModelInfo{{ID: modelID}})
if testCase.clientCount > 1 {
modelRegistry.RegisterClient("manual-client", "openai", []*ModelInfo{{ID: modelID}})
}
if testCase.quotaExceeded {
modelRegistry.SetModelQuotaExceeded("quota-client", modelID)
}
if testCase.quotaSuspended {
modelRegistry.SuspendClientModel("quota-client", modelID, "quota")
}
if testCase.manualSuspended {
modelRegistry.SuspendClientModel("manual-client", modelID, "manual")
}
infos := modelRegistry.GetAvailableModelInfos()
gotInfoAvailable := len(infos) == 1 && infos[0] != nil && infos[0].ID == modelID
if gotInfoAvailable != testCase.wantModelAvailable {
t.Fatalf("GetAvailableModelInfos() available = %v, want %v; models = %#v", gotInfoAvailable, testCase.wantModelAvailable, infos)
}
models := modelRegistry.GetAvailableModels("openai")
gotListAvailable := len(models) == 1 && models[0]["id"] == modelID
if gotListAvailable != testCase.wantModelAvailable {
t.Fatalf("GetAvailableModels() available = %v, want %v; models = %#v", gotListAvailable, testCase.wantModelAvailable, models)
}
})
}
}

View file

@ -0,0 +1,204 @@
package registry
import (
"context"
"sync"
"testing"
"time"
)
func newTestModelRegistry() *ModelRegistry {
return &ModelRegistry{
models: make(map[string]*ModelRegistration),
clientModels: make(map[string][]string),
clientModelInfos: make(map[string]map[string]*ModelInfo),
clientProviders: make(map[string]string),
mutex: &sync.RWMutex{},
}
}
type registeredCall struct {
provider string
clientID string
models []*ModelInfo
}
type unregisteredCall struct {
provider string
clientID string
}
type capturingHook struct {
registeredCh chan registeredCall
unregisteredCh chan unregisteredCall
}
func (h *capturingHook) OnModelsRegistered(ctx context.Context, provider, clientID string, models []*ModelInfo) {
h.registeredCh <- registeredCall{provider: provider, clientID: clientID, models: models}
}
func (h *capturingHook) OnModelsUnregistered(ctx context.Context, provider, clientID string) {
h.unregisteredCh <- unregisteredCall{provider: provider, clientID: clientID}
}
func TestModelRegistryHook_OnModelsRegisteredCalled(t *testing.T) {
r := newTestModelRegistry()
hook := &capturingHook{
registeredCh: make(chan registeredCall, 1),
unregisteredCh: make(chan unregisteredCall, 1),
}
r.SetHook(hook)
inputModels := []*ModelInfo{
{ID: "m1", DisplayName: "Model One"},
{ID: "m2", DisplayName: "Model Two"},
}
r.RegisterClient("client-1", "OpenAI", inputModels)
select {
case call := <-hook.registeredCh:
if call.provider != "openai" {
t.Fatalf("provider mismatch: got %q, want %q", call.provider, "openai")
}
if call.clientID != "client-1" {
t.Fatalf("clientID mismatch: got %q, want %q", call.clientID, "client-1")
}
if len(call.models) != 2 {
t.Fatalf("models length mismatch: got %d, want %d", len(call.models), 2)
}
if call.models[0] == nil || call.models[0].ID != "m1" {
t.Fatalf("models[0] mismatch: got %#v, want ID=%q", call.models[0], "m1")
}
if call.models[1] == nil || call.models[1].ID != "m2" {
t.Fatalf("models[1] mismatch: got %#v, want ID=%q", call.models[1], "m2")
}
case <-time.After(2 * time.Second):
t.Fatal("timeout waiting for OnModelsRegistered hook call")
}
}
func TestModelRegistryHook_OnModelsUnregisteredCalled(t *testing.T) {
r := newTestModelRegistry()
hook := &capturingHook{
registeredCh: make(chan registeredCall, 1),
unregisteredCh: make(chan unregisteredCall, 1),
}
r.SetHook(hook)
r.RegisterClient("client-1", "OpenAI", []*ModelInfo{{ID: "m1"}})
select {
case <-hook.registeredCh:
case <-time.After(2 * time.Second):
t.Fatal("timeout waiting for OnModelsRegistered hook call")
}
r.UnregisterClient("client-1")
select {
case call := <-hook.unregisteredCh:
if call.provider != "openai" {
t.Fatalf("provider mismatch: got %q, want %q", call.provider, "openai")
}
if call.clientID != "client-1" {
t.Fatalf("clientID mismatch: got %q, want %q", call.clientID, "client-1")
}
case <-time.After(2 * time.Second):
t.Fatal("timeout waiting for OnModelsUnregistered hook call")
}
}
type blockingHook struct {
started chan struct{}
unblock chan struct{}
}
func (h *blockingHook) OnModelsRegistered(ctx context.Context, provider, clientID string, models []*ModelInfo) {
select {
case <-h.started:
default:
close(h.started)
}
<-h.unblock
}
func (h *blockingHook) OnModelsUnregistered(ctx context.Context, provider, clientID string) {}
func TestModelRegistryHook_DoesNotBlockRegisterClient(t *testing.T) {
r := newTestModelRegistry()
hook := &blockingHook{
started: make(chan struct{}),
unblock: make(chan struct{}),
}
r.SetHook(hook)
defer close(hook.unblock)
done := make(chan struct{})
go func() {
r.RegisterClient("client-1", "OpenAI", []*ModelInfo{{ID: "m1"}})
close(done)
}()
select {
case <-hook.started:
case <-time.After(2 * time.Second):
t.Fatal("timeout waiting for hook to start")
}
select {
case <-done:
case <-time.After(200 * time.Millisecond):
t.Fatal("RegisterClient appears to be blocked by hook")
}
if !r.ClientSupportsModel("client-1", "m1") {
t.Fatal("model registration failed; expected client to support model")
}
}
type panicHook struct {
registeredCalled chan struct{}
unregisteredCalled chan struct{}
}
func (h *panicHook) OnModelsRegistered(ctx context.Context, provider, clientID string, models []*ModelInfo) {
if h.registeredCalled != nil {
h.registeredCalled <- struct{}{}
}
panic("boom")
}
func (h *panicHook) OnModelsUnregistered(ctx context.Context, provider, clientID string) {
if h.unregisteredCalled != nil {
h.unregisteredCalled <- struct{}{}
}
panic("boom")
}
func TestModelRegistryHook_PanicDoesNotAffectRegistry(t *testing.T) {
r := newTestModelRegistry()
hook := &panicHook{
registeredCalled: make(chan struct{}, 1),
unregisteredCalled: make(chan struct{}, 1),
}
r.SetHook(hook)
r.RegisterClient("client-1", "OpenAI", []*ModelInfo{{ID: "m1"}})
select {
case <-hook.registeredCalled:
case <-time.After(2 * time.Second):
t.Fatal("timeout waiting for OnModelsRegistered hook call")
}
if !r.ClientSupportsModel("client-1", "m1") {
t.Fatal("model registration failed; expected client to support model")
}
r.UnregisterClient("client-1")
select {
case <-hook.unregisteredCalled:
case <-time.After(2 * time.Second):
t.Fatal("timeout waiting for OnModelsUnregistered hook call")
}
}

View file

@ -0,0 +1,198 @@
package registry
import (
"testing"
"time"
)
func TestGetModelInfoReturnsClone(t *testing.T) {
r := newTestModelRegistry()
r.RegisterClient("client-1", "gemini", []*ModelInfo{{
ID: "m1",
DisplayName: "Model One",
Thinking: &ThinkingSupport{Min: 1, Max: 2, Levels: []string{"low", "high"}},
}})
first := r.GetModelInfo("m1", "gemini")
if first == nil {
t.Fatal("expected model info")
}
first.DisplayName = "mutated"
first.Thinking.Levels[0] = "mutated"
second := r.GetModelInfo("m1", "gemini")
if second.DisplayName != "Model One" {
t.Fatalf("expected cloned display name, got %q", second.DisplayName)
}
if second.Thinking == nil || len(second.Thinking.Levels) == 0 || second.Thinking.Levels[0] != "low" {
t.Fatalf("expected cloned thinking levels, got %+v", second.Thinking)
}
}
func TestGetModelsForClientReturnsClones(t *testing.T) {
r := newTestModelRegistry()
r.RegisterClient("client-1", "gemini", []*ModelInfo{{
ID: "m1",
DisplayName: "Model One",
Thinking: &ThinkingSupport{Levels: []string{"low", "high"}},
}})
first := r.GetModelsForClient("client-1")
if len(first) != 1 || first[0] == nil {
t.Fatalf("expected one model, got %+v", first)
}
first[0].DisplayName = "mutated"
first[0].Thinking.Levels[0] = "mutated"
second := r.GetModelsForClient("client-1")
if len(second) != 1 || second[0] == nil {
t.Fatalf("expected one model on second fetch, got %+v", second)
}
if second[0].DisplayName != "Model One" {
t.Fatalf("expected cloned display name, got %q", second[0].DisplayName)
}
if second[0].Thinking == nil || len(second[0].Thinking.Levels) == 0 || second[0].Thinking.Levels[0] != "low" {
t.Fatalf("expected cloned thinking levels, got %+v", second[0].Thinking)
}
}
func TestGetAvailableModelsByProviderReturnsClones(t *testing.T) {
r := newTestModelRegistry()
r.RegisterClient("client-1", "gemini", []*ModelInfo{{
ID: "m1",
DisplayName: "Model One",
Thinking: &ThinkingSupport{Levels: []string{"low", "high"}},
}})
first := r.GetAvailableModelsByProvider("gemini")
if len(first) != 1 || first[0] == nil {
t.Fatalf("expected one model, got %+v", first)
}
first[0].DisplayName = "mutated"
first[0].Thinking.Levels[0] = "mutated"
second := r.GetAvailableModelsByProvider("gemini")
if len(second) != 1 || second[0] == nil {
t.Fatalf("expected one model on second fetch, got %+v", second)
}
if second[0].DisplayName != "Model One" {
t.Fatalf("expected cloned display name, got %q", second[0].DisplayName)
}
if second[0].Thinking == nil || len(second[0].Thinking.Levels) == 0 || second[0].Thinking.Levels[0] != "low" {
t.Fatalf("expected cloned thinking levels, got %+v", second[0].Thinking)
}
}
func TestCleanupExpiredQuotasInvalidatesAvailableModelsCache(t *testing.T) {
r := newTestModelRegistry()
r.RegisterClient("client-1", "openai", []*ModelInfo{{ID: "m1", Created: 1}})
r.SetModelQuotaExceeded("client-1", "m1")
if models := r.GetAvailableModels("openai"); len(models) != 1 {
t.Fatalf("expected cooldown model to remain listed before cleanup, got %d", len(models))
}
r.mutex.Lock()
quotaTime := time.Now().Add(-6 * time.Minute)
r.models["m1"].QuotaExceededClients["client-1"] = &quotaTime
r.mutex.Unlock()
r.CleanupExpiredQuotas()
if count := r.GetModelCount("m1"); count != 1 {
t.Fatalf("expected model count 1 after cleanup, got %d", count)
}
models := r.GetAvailableModels("openai")
if len(models) != 1 {
t.Fatalf("expected model to stay available after cleanup, got %d", len(models))
}
if got := models[0]["id"]; got != "m1" {
t.Fatalf("expected model id m1, got %v", got)
}
}
func TestGetAvailableModelsReturnsClonedSupportedParameters(t *testing.T) {
r := newTestModelRegistry()
r.RegisterClient("client-1", "openai", []*ModelInfo{{
ID: "m1",
DisplayName: "Model One",
SupportedParameters: []string{"temperature", "top_p"},
}})
first := r.GetAvailableModels("openai")
if len(first) != 1 {
t.Fatalf("expected one model, got %d", len(first))
}
params, ok := first[0]["supported_parameters"].([]string)
if !ok || len(params) != 2 {
t.Fatalf("expected supported_parameters slice, got %#v", first[0]["supported_parameters"])
}
params[0] = "mutated"
second := r.GetAvailableModels("openai")
params, ok = second[0]["supported_parameters"].([]string)
if !ok || len(params) != 2 || params[0] != "temperature" {
t.Fatalf("expected cloned supported_parameters, got %#v", second[0]["supported_parameters"])
}
}
func TestGetAvailableModelsIncludesMaxContextLengthOverride(t *testing.T) {
r := newTestModelRegistry()
const want = 1048576
r.RegisterClient("client-1", "openai", []*ModelInfo{{
ID: "deepseek-v4-flash",
ContextLength: want,
MaxContextLength: want,
}})
models := r.GetAvailableModels("openai")
if len(models) != 1 {
t.Fatalf("models length = %d, want 1", len(models))
}
if got := models[0]["context_length"]; got != want {
t.Fatalf("context_length = %#v, want %d", got, want)
}
if got := models[0]["max_context_length"]; got != want {
t.Fatalf("max_context_length = %#v, want %d", got, want)
}
}
func TestLookupModelInfoReturnsCloneForStaticDefinitions(t *testing.T) {
first := LookupModelInfo("claude-sonnet-4-6")
if first == nil || first.Thinking == nil || len(first.Thinking.Levels) == 0 {
t.Fatalf("expected static model with thinking levels, got %+v", first)
}
first.Thinking.Levels[0] = "mutated"
second := LookupModelInfo("claude-sonnet-4-6")
if second == nil || second.Thinking == nil || len(second.Thinking.Levels) == 0 || second.Thinking.Levels[0] == "mutated" {
t.Fatalf("expected static lookup clone, got %+v", second)
}
}
func TestLookupModelInfoIncludesClaudeSonnet5(t *testing.T) {
model := LookupModelInfo("claude-sonnet-5")
if model == nil {
t.Fatal("expected Claude Sonnet 5 static model")
}
if model.Type != "claude" {
t.Fatalf("Claude Sonnet 5 type = %q, want claude", model.Type)
}
if model.ContextLength != 1000000 {
t.Fatalf("Claude Sonnet 5 context length = %d, want 1000000", model.ContextLength)
}
if model.MaxCompletionTokens != 128000 {
t.Fatalf("Claude Sonnet 5 max completion tokens = %d, want 128000", model.MaxCompletionTokens)
}
if model.Thinking == nil || !model.Thinking.ZeroAllowed || !model.Thinking.DynamicAllowed || model.Thinking.Min != 0 || model.Thinking.Max != 0 {
t.Fatalf("expected Claude Sonnet 5 dynamic level-only thinking with zero allowed, got %+v", model.Thinking)
}
expectedLevels := []string{"low", "medium", "high", "xhigh", "max"}
if len(model.Thinking.Levels) != len(expectedLevels) {
t.Fatalf("Claude Sonnet 5 thinking levels = %+v, want %+v", model.Thinking.Levels, expectedLevels)
}
for i, level := range expectedLevels {
if model.Thinking.Levels[i] != level {
t.Fatalf("Claude Sonnet 5 thinking levels = %+v, want %+v", model.Thinking.Levels, expectedLevels)
}
}
}

View file

@ -0,0 +1,370 @@
package registry
import (
"context"
_ "embed"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"sync"
"time"
log "github.com/sirupsen/logrus"
)
const (
modelsFetchTimeout = 30 * time.Second
modelsRefreshInterval = 3 * time.Hour
)
var modelsURLs = []string{
"https://raw.githubusercontent.com/router-for-me/models/refs/heads/main/models.json",
"https://models.router-for.me/models.json",
}
//go:embed models/models.json
var embeddedModelsJSON []byte
type modelStore struct {
mu sync.RWMutex
data *staticModelsJSON
}
var modelsCatalogStore = &modelStore{}
var updaterOnce sync.Once
// ModelRefreshCallback is invoked when startup or periodic model refresh detects changes.
// changedProviders contains the provider names whose model definitions changed.
type ModelRefreshCallback func(changedProviders []string)
var (
refreshCallbackMu sync.Mutex
refreshCallback ModelRefreshCallback
pendingRefreshChanges []string
)
// SetModelRefreshCallback registers a callback that is invoked when startup or
// periodic model refresh detects changes. Only one callback is supported;
// subsequent calls replace the previous callback.
func SetModelRefreshCallback(cb ModelRefreshCallback) {
refreshCallbackMu.Lock()
refreshCallback = cb
var pending []string
if cb != nil && len(pendingRefreshChanges) > 0 {
pending = append([]string(nil), pendingRefreshChanges...)
pendingRefreshChanges = nil
}
refreshCallbackMu.Unlock()
if cb != nil && len(pending) > 0 {
cb(pending)
}
}
func init() {
// Load embedded data as fallback on startup.
if err := loadModelsFromBytes(embeddedModelsJSON, "embed"); err != nil {
log.Warnf("registry: failed to parse embedded models.json (embedded catalog may be incomplete or invalid; continuing startup and will rely on remote model refresh): %v", err)
}
}
// StartModelsUpdater starts a background updater that fetches models
// immediately on startup and then refreshes the model catalog every 3 hours.
// Safe to call multiple times; only one updater will run.
func StartModelsUpdater(ctx context.Context) {
updaterOnce.Do(func() {
go runModelsUpdater(ctx)
})
}
func runModelsUpdater(ctx context.Context) {
tryStartupRefresh(ctx)
periodicRefresh(ctx)
}
func periodicRefresh(ctx context.Context) {
ticker := time.NewTicker(modelsRefreshInterval)
defer ticker.Stop()
log.Infof("periodic model refresh started (interval=%s)", modelsRefreshInterval)
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
tryPeriodicRefresh(ctx)
}
}
}
// tryPeriodicRefresh fetches models from remote, compares with the current
// catalog, and notifies the registered callback if any provider changed.
func tryPeriodicRefresh(ctx context.Context) {
tryRefreshModels(ctx, "periodic model refresh")
}
// tryStartupRefresh fetches models from remote in the background during
// process startup. It uses the same change detection as periodic refresh so
// existing auth registrations can be updated after the callback is registered.
func tryStartupRefresh(ctx context.Context) {
tryRefreshModels(ctx, "startup model refresh")
}
func tryRefreshModels(ctx context.Context, label string) {
oldData := getModels()
parsed, url := fetchModelsFromRemote(ctx)
if parsed == nil {
log.Warnf("%s: fetch failed from all URLs, keeping current data", label)
return
}
// Detect changes before updating store.
changed := detectChangedProviders(oldData, parsed)
// Update store with new data regardless.
modelsCatalogStore.mu.Lock()
modelsCatalogStore.data = parsed
modelsCatalogStore.mu.Unlock()
if len(changed) == 0 {
log.Infof("%s completed from %s, no changes detected", label, url)
return
}
log.Infof("%s completed from %s, changes detected for providers: %v", label, url, changed)
notifyModelRefresh(changed)
}
// fetchModelsFromRemote tries all remote URLs and returns the parsed model catalog
// along with the URL it was fetched from. Returns (nil, "") if all fetches fail.
func fetchModelsFromRemote(ctx context.Context) (*staticModelsJSON, string) {
client := &http.Client{Timeout: modelsFetchTimeout}
for _, url := range modelsURLs {
reqCtx, cancel := context.WithTimeout(ctx, modelsFetchTimeout)
req, err := http.NewRequestWithContext(reqCtx, "GET", url, nil)
if err != nil {
cancel()
log.Debugf("models fetch request creation failed for %s: %v", url, err)
continue
}
resp, err := client.Do(req)
if err != nil {
cancel()
log.Debugf("models fetch failed from %s: %v", url, err)
continue
}
if resp.StatusCode != 200 {
resp.Body.Close()
cancel()
log.Debugf("models fetch returned %d from %s", resp.StatusCode, url)
continue
}
data, err := io.ReadAll(resp.Body)
resp.Body.Close()
cancel()
if err != nil {
log.Debugf("models fetch read error from %s: %v", url, err)
continue
}
var parsed staticModelsJSON
if err := json.Unmarshal(data, &parsed); err != nil {
log.Warnf("models parse failed from %s: %v", url, err)
continue
}
if err := validateModelsCatalog(&parsed); err != nil {
log.Warnf("models validate failed from %s: %v", url, err)
continue
}
return &parsed, url
}
return nil, ""
}
// detectChangedProviders compares two model catalogs and returns provider names
// whose model definitions differ. Gemini changes affect both Gemini protocols,
// while Codex tiers (free/team/plus/pro) are grouped under one "codex" provider.
func detectChangedProviders(oldData, newData *staticModelsJSON) []string {
if oldData == nil || newData == nil {
return nil
}
type section struct {
provider string
oldList []*ModelInfo
newList []*ModelInfo
}
sections := []section{
{"claude", oldData.Claude, newData.Claude},
{"gemini", oldData.Gemini, newData.Gemini},
{"gemini-interactions", oldData.Gemini, newData.Gemini},
{"vertex", oldData.Vertex, newData.Vertex},
{"aistudio", oldData.AIStudio, newData.AIStudio},
{"codex", oldData.CodexFree, newData.CodexFree},
{"codex", oldData.CodexTeam, newData.CodexTeam},
{"codex", oldData.CodexPlus, newData.CodexPlus},
{"codex", oldData.CodexPro, newData.CodexPro},
{"kimi", oldData.Kimi, newData.Kimi},
{"antigravity", oldData.Antigravity, newData.Antigravity},
{"xai", oldData.XAI, newData.XAI},
}
seen := make(map[string]bool, len(sections))
var changed []string
for _, s := range sections {
if seen[s.provider] {
continue
}
if modelSectionChanged(s.oldList, s.newList) {
changed = append(changed, s.provider)
seen[s.provider] = true
}
}
return changed
}
// modelSectionChanged reports whether two model slices differ.
func modelSectionChanged(a, b []*ModelInfo) bool {
if len(a) != len(b) {
return true
}
if len(a) == 0 {
return false
}
aj, err1 := json.Marshal(a)
bj, err2 := json.Marshal(b)
if err1 != nil || err2 != nil {
return true
}
return string(aj) != string(bj)
}
func notifyModelRefresh(changedProviders []string) {
if len(changedProviders) == 0 {
return
}
refreshCallbackMu.Lock()
cb := refreshCallback
if cb == nil {
pendingRefreshChanges = mergeProviderNames(pendingRefreshChanges, changedProviders)
refreshCallbackMu.Unlock()
return
}
refreshCallbackMu.Unlock()
cb(changedProviders)
}
func mergeProviderNames(existing, incoming []string) []string {
if len(incoming) == 0 {
return existing
}
seen := make(map[string]struct{}, len(existing)+len(incoming))
merged := make([]string, 0, len(existing)+len(incoming))
for _, provider := range existing {
name := strings.ToLower(strings.TrimSpace(provider))
if name == "" {
continue
}
if _, ok := seen[name]; ok {
continue
}
seen[name] = struct{}{}
merged = append(merged, name)
}
for _, provider := range incoming {
name := strings.ToLower(strings.TrimSpace(provider))
if name == "" {
continue
}
if _, ok := seen[name]; ok {
continue
}
seen[name] = struct{}{}
merged = append(merged, name)
}
return merged
}
func loadModelsFromBytes(data []byte, source string) error {
var parsed staticModelsJSON
if err := json.Unmarshal(data, &parsed); err != nil {
return fmt.Errorf("%s: decode models catalog: %w", source, err)
}
if err := validateModelsCatalog(&parsed); err != nil {
return fmt.Errorf("%s: validate models catalog: %w", source, err)
}
modelsCatalogStore.mu.Lock()
modelsCatalogStore.data = &parsed
modelsCatalogStore.mu.Unlock()
return nil
}
func getModels() *staticModelsJSON {
modelsCatalogStore.mu.RLock()
defer modelsCatalogStore.mu.RUnlock()
return modelsCatalogStore.data
}
func validateModelsCatalog(data *staticModelsJSON) error {
if data == nil {
return fmt.Errorf("catalog is nil")
}
requiredSections := []struct {
name string
models []*ModelInfo
}{
{name: "claude", models: data.Claude},
{name: "gemini", models: data.Gemini},
{name: "vertex", models: data.Vertex},
{name: "aistudio", models: data.AIStudio},
{name: "codex-free", models: data.CodexFree},
{name: "codex-team", models: data.CodexTeam},
{name: "codex-plus", models: data.CodexPlus},
{name: "codex-pro", models: data.CodexPro},
{name: "kimi", models: data.Kimi},
{name: "antigravity", models: data.Antigravity},
{name: "xai", models: data.XAI},
}
for _, section := range requiredSections {
if err := validateModelSection(section.name, section.models); err != nil {
return err
}
}
return nil
}
func validateModelSection(section string, models []*ModelInfo) error {
if len(models) == 0 {
log.Warnf("models catalog: %s section is empty, continuing without those model definitions", section)
return nil
}
seen := make(map[string]struct{}, len(models))
for i, model := range models {
if model == nil {
return fmt.Errorf("%s[%d] is null", section, i)
}
modelID := strings.TrimSpace(model.ID)
if modelID == "" {
return fmt.Errorf("%s[%d] has empty id", section, i)
}
if _, exists := seen[modelID]; exists {
return fmt.Errorf("%s contains duplicate model id %q", section, modelID)
}
seen[modelID] = struct{}{}
}
return nil
}

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff