Add projects
This commit is contained in:
parent
2d3a9ad623
commit
8b607dd700
1802 changed files with 503346 additions and 2 deletions
181
backend/internal/registry/codex_client_models.go
Normal file
181
backend/internal/registry/codex_client_models.go
Normal 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
|
||||
}
|
||||
208
backend/internal/registry/codex_client_models_test.go
Normal file
208
backend/internal/registry/codex_client_models_test.go
Normal 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
|
||||
}
|
||||
114
backend/internal/registry/codex_client_models_updater.go
Normal file
114
backend/internal/registry/codex_client_models_updater.go
Normal 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, ""
|
||||
}
|
||||
362
backend/internal/registry/model_definitions.go
Normal file
362
backend/internal/registry/model_definitions.go
Normal 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
|
||||
}
|
||||
113
backend/internal/registry/model_definitions_test.go
Normal file
113
backend/internal/registry/model_definitions_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
1434
backend/internal/registry/model_registry.go
Normal file
1434
backend/internal/registry/model_registry.go
Normal file
File diff suppressed because it is too large
Load diff
100
backend/internal/registry/model_registry_cache_test.go
Normal file
100
backend/internal/registry/model_registry_cache_test.go
Normal 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))
|
||||
}
|
||||
}
|
||||
100
backend/internal/registry/model_registry_grok_test.go
Normal file
100
backend/internal/registry/model_registry_grok_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
204
backend/internal/registry/model_registry_hook_test.go
Normal file
204
backend/internal/registry/model_registry_hook_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
198
backend/internal/registry/model_registry_safety_test.go
Normal file
198
backend/internal/registry/model_registry_safety_test.go
Normal 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"] = "aTime
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
370
backend/internal/registry/model_updater.go
Normal file
370
backend/internal/registry/model_updater.go
Normal 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
|
||||
}
|
||||
947
backend/internal/registry/models/codex_client_models.json
Normal file
947
backend/internal/registry/models/codex_client_models.json
Normal file
File diff suppressed because one or more lines are too long
3936
backend/internal/registry/models/models.json
Normal file
3936
backend/internal/registry/models/models.json
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue