diff --git a/.github/scripts/refresh-model-catalogs.sh b/.github/scripts/refresh-model-catalogs.sh new file mode 100644 index 000000000..5e660df65 --- /dev/null +++ b/.github/scripts/refresh-model-catalogs.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -euo pipefail + +models_repository="${MODELS_REPOSITORY_URL:-https://github.com/router-for-me/models.git}" +models_ref="${MODELS_REPOSITORY_REF:-main}" +catalog_dir="${MODEL_CATALOG_DIR:-internal/registry/models}" +codex_catalog="$catalog_dir/codex_client_models.json" +codex_candidate="$(mktemp)" +trap 'rm -f "$codex_candidate"' EXIT + +git fetch --depth 1 "$models_repository" "$models_ref" +git show FETCH_HEAD:models.json > "$catalog_dir/models.json" + +if git show FETCH_HEAD:codex_client_models.json > "$codex_candidate" && + go run ./cmd/validate_codex_models --file "$codex_candidate"; then + mv "$codex_candidate" "$codex_catalog" + printf 'Refreshed validated Codex client model catalog.\n' +else + printf '::warning::Remote Codex client model catalog is missing or invalid; using embedded fallback.\n' +fi + +go run ./cmd/validate_codex_models --file "$codex_catalog" diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 443462dfa..3781e65d0 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -15,10 +15,13 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true - name: Refresh models catalog - run: | - git fetch --depth 1 https://github.com/router-for-me/models.git main - git show FETCH_HEAD:models.json > internal/registry/models/models.json + run: bash .github/scripts/refresh-model-catalogs.sh - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Login to DockerHub @@ -50,10 +53,13 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true - name: Refresh models catalog - run: | - git fetch --depth 1 https://github.com/router-for-me/models.git main - git show FETCH_HEAD:models.json > internal/registry/models/models.json + run: bash .github/scripts/refresh-model-catalogs.sh - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Login to DockerHub diff --git a/.github/workflows/pr-test-build.yml b/.github/workflows/pr-test-build.yml index 75f4c520a..f1f0e2879 100644 --- a/.github/workflows/pr-test-build.yml +++ b/.github/workflows/pr-test-build.yml @@ -12,15 +12,13 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 - - name: Refresh models catalog - run: | - git fetch --depth 1 https://github.com/router-for-me/models.git main - git show FETCH_HEAD:models.json > internal/registry/models/models.json - name: Set up Go uses: actions/setup-go@v5 with: go-version-file: go.mod cache: true + - name: Refresh models catalog + run: bash .github/scripts/refresh-model-catalogs.sh - name: Build run: | go build -o test-output ./cmd/server diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index adaa3b867..df6416548 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -127,19 +127,16 @@ jobs: - uses: actions/checkout@v6 with: fetch-depth: 0 - - name: Refresh models catalog - shell: bash - run: | - set -euo pipefail - git fetch --depth 1 https://github.com/router-for-me/models.git main - git show FETCH_HEAD:models.json > internal/registry/models/models.json - - name: Fetch tags - shell: bash - run: git fetch --force --tags - uses: actions/setup-go@v6 with: go-version: ${{ env.GO_VERSION }} cache: true + - name: Refresh models catalog + shell: bash + run: bash .github/scripts/refresh-model-catalogs.sh + - name: Fetch tags + shell: bash + run: git fetch --force --tags - uses: actions/cache@v4 with: path: | @@ -257,12 +254,13 @@ jobs: - uses: actions/checkout@v6 with: fetch-depth: 0 + - uses: actions/setup-go@v6 + with: + go-version: ${{ env.GO_VERSION }} + cache: true - name: Refresh models catalog shell: bash - run: | - set -euo pipefail - git fetch --depth 1 https://github.com/router-for-me/models.git main - git show FETCH_HEAD:models.json > internal/registry/models/models.json + run: bash .github/scripts/refresh-model-catalogs.sh - name: Fetch tags shell: bash run: git fetch --force --tags @@ -388,19 +386,16 @@ jobs: - uses: actions/checkout@v6 with: fetch-depth: 0 - - name: Refresh models catalog - shell: bash - run: | - set -euo pipefail - git fetch --depth 1 https://github.com/router-for-me/models.git main - git show FETCH_HEAD:models.json > internal/registry/models/models.json - - name: Fetch tags - shell: bash - run: git fetch --force --tags - uses: actions/setup-go@v6 with: go-version: ${{ env.GO_VERSION }} cache: true + - name: Refresh models catalog + shell: bash + run: bash .github/scripts/refresh-model-catalogs.sh + - name: Fetch tags + shell: bash + run: git fetch --force --tags - uses: actions/cache@v4 with: path: | @@ -519,16 +514,14 @@ jobs: - uses: actions/checkout@v6 with: fetch-depth: 0 - - name: Refresh models catalog - run: | - git fetch --depth 1 https://github.com/router-for-me/models.git main - git show FETCH_HEAD:models.json > internal/registry/models/models.json - - name: Fetch tags - run: git fetch --force --tags - uses: actions/setup-go@v6 with: go-version: ${{ env.GO_VERSION }} cache: true + - name: Refresh models catalog + run: bash .github/scripts/refresh-model-catalogs.sh + - name: Fetch tags + run: git fetch --force --tags - uses: actions/cache@v4 with: path: | diff --git a/cmd/fetch_codex_models/main.go b/cmd/fetch_codex_models/main.go index 20fc5c8e1..1f787ffe2 100644 --- a/cmd/fetch_codex_models/main.go +++ b/cmd/fetch_codex_models/main.go @@ -10,8 +10,8 @@ // // --auths-dir Directory containing auth JSON files (default: config auth-dir) // --config Config file path (default: "config.yaml") -// --output Output JSON file path (default: "codex_models.json") -// --client-version Codex client_version query value (default: "0.133.0") +// --output Output JSON file path (default: "codex_client_models.json") +// --client-version Codex client_version query value (default: "0.144.1") // --pretty Pretty-print the output JSON (default: true) package main @@ -62,7 +62,7 @@ func main() { flag.StringVar(&authsDir, "auths-dir", "", "Directory containing auth JSON files (overrides config auth-dir)") flag.StringVar(&configPath, "config", "", "Configure File Path") - flag.StringVar(&outputPath, "output", "codex_models.json", "Output JSON file path") + flag.StringVar(&outputPath, "output", "codex_client_models.json", "Output JSON file path") flag.StringVar(&clientVersion, "client-version", defaultClientVersion, "Codex client_version query value") flag.BoolVar(&pretty, "pretty", true, "Pretty-print the output JSON") flag.Parse() @@ -296,11 +296,14 @@ func codexModelsURL(clientVersion string) (string, error) { func countModels(raw []byte) (int, error) { var payload struct { - Models []map[string]any `json:"models"` + Models []json.RawMessage `json:"models"` } if err := json.Unmarshal(raw, &payload); err != nil { return 0, fmt.Errorf("failed to parse response JSON: %w", err) } + // Keep this check intentionally loose: fetch_codex_models dumps the upstream + // Codex API payload. Strict CPA catalog validation belongs in + // cmd/validate_codex_models and registry.ValidateCodexClientModelsJSON. if payload.Models == nil { return 0, fmt.Errorf("response JSON does not contain models array") } diff --git a/cmd/fetch_codex_models/main_test.go b/cmd/fetch_codex_models/main_test.go new file mode 100644 index 000000000..716cd1a6e --- /dev/null +++ b/cmd/fetch_codex_models/main_test.go @@ -0,0 +1,48 @@ +package main + +import "testing" + +func TestCodexModelsURL(t *testing.T) { + got, err := codexModelsURL(" 0.144.1 ") + if err != nil { + t.Fatalf("codexModelsURL: %v", err) + } + want := "https://chatgpt.com/backend-api/codex/models?client_version=0.144.1" + if got != want { + t.Fatalf("codexModelsURL = %q, want %q", got, want) + } +} + +func TestCountModels(t *testing.T) { + count, err := countModels([]byte(`{"models":[{"slug":"a"},{"slug":"b"}]}`)) + if err != nil { + t.Fatalf("countModels(valid): %v", err) + } + if count != 2 { + t.Fatalf("countModels(valid) = %d, want 2", count) + } + + // Upstream dumps may omit CPA catalog-required fields; counting must still work. + count, err = countModels([]byte(`{"models":[{"slug":"gpt-5.6-sol"}]}`)) + if err != nil { + t.Fatalf("countModels(incomplete upstream model): %v", err) + } + if count != 1 { + t.Fatalf("countModels(incomplete upstream model) = %d, want 1", count) + } + + count, err = countModels([]byte(`{"models":[]}`)) + if err != nil { + t.Fatalf("countModels(empty): %v", err) + } + if count != 0 { + t.Fatalf("countModels(empty) = %d, want 0", count) + } + + if _, err := countModels([]byte(`{"models":`)); err == nil { + t.Fatal("countModels(malformed) error = nil, want error") + } + if _, err := countModels([]byte(`{}`)); err == nil { + t.Fatal("countModels(missing models) error = nil, want error") + } +} diff --git a/cmd/server/main.go b/cmd/server/main.go index facc85661..81c37cd7f 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -107,7 +107,7 @@ func main() { flag.BoolVar(&homeDisableClusterDiscovery, "home-disable-cluster-discovery", false, "Disable Home CLUSTER NODES discovery and keep using the configured -home-jwt address") flag.BoolVar(&tuiMode, "tui", false, "Start with terminal management UI") flag.BoolVar(&standalone, "standalone", false, "In TUI mode, start an embedded local server") - flag.BoolVar(&localModel, "local-model", false, "Use embedded model catalog only, skip remote model fetching") + flag.BoolVar(&localModel, "local-model", false, "Use embedded models.json and codex_client_models.json only, skip remote model catalog fetching") flag.CommandLine.Usage = func() { out := flag.CommandLine.Output() @@ -621,18 +621,14 @@ func main() { return } if localModel && (!tuiMode || standalone) { - log.Info("Local model mode: using embedded model catalog, remote model updates disabled") + log.Info("Local model mode: using embedded model catalogs, remote model updates disabled") } if tuiMode { if standalone { // Standalone mode: start an embedded local server and connect TUI client to it. managementasset.StartAutoUpdater(context.Background(), configFilePath) misc.StartAntigravityVersionUpdater(context.Background()) - if !localModel && !cfg.Home.Enabled { - registry.StartModelsUpdater(context.Background()) - } else if cfg.Home.Enabled { - log.Info("Home mode: remote model updates disabled") - } + startModelCatalogUpdaters(localModel, cfg.Home.Enabled) hook := tui.NewLogHook(2000) hook.SetFormatter(&logging.LogFormatter{}) log.AddHook(hook) @@ -706,16 +702,34 @@ func main() { // Start the main proxy service managementasset.StartAutoUpdater(context.Background(), configFilePath) misc.StartAntigravityVersionUpdater(context.Background()) - if !localModel && !cfg.Home.Enabled { - registry.StartModelsUpdater(context.Background()) - } else if cfg.Home.Enabled { - log.Info("Home mode: remote model updates disabled") - } + startModelCatalogUpdaters(localModel, cfg.Home.Enabled) cmd.StartServiceWithPluginHost(cfg, configFilePath, password, pluginHost, serverOptions...) } } } +// modelCatalogUpdaterPlan decides which remote model catalogs should refresh. +// Codex client templates still refresh under Home mode because the model list +// comes from Home IDs while template metadata stays edge-local. +func modelCatalogUpdaterPlan(localModel, homeEnabled bool) (startModels, startCodexClient bool) { + if localModel { + return false, false + } + return !homeEnabled, true +} + +func startModelCatalogUpdaters(localModel, homeEnabled bool) { + startModels, startCodexClient := modelCatalogUpdaterPlan(localModel, homeEnabled) + if startCodexClient { + registry.StartCodexClientModelsUpdater(context.Background()) + } + if startModels { + registry.StartModelsUpdater(context.Background()) + } else if homeEnabled { + log.Info("Home mode: remote models.json updates disabled; Codex client model list follows Home model IDs") + } +} + func pluginBootstrapConfigPath(args []string, defaultPath string) string { for i := 0; i < len(args); i++ { arg := args[i] diff --git a/cmd/server/main_test.go b/cmd/server/main_test.go index 02c779674..fce4be93b 100644 --- a/cmd/server/main_test.go +++ b/cmd/server/main_test.go @@ -87,3 +87,51 @@ func TestShouldEnableExampleAPIKeySafeMode(t *testing.T) { }) } } + +func TestModelCatalogUpdaterPlan(t *testing.T) { + tests := []struct { + name string + localModel bool + homeEnabled bool + wantModels bool + wantCodexClient bool + }{ + { + name: "normal CPA refreshes both catalogs", + localModel: false, + homeEnabled: false, + wantModels: true, + wantCodexClient: true, + }, + { + name: "home mode keeps models.json local and refreshes codex templates", + localModel: false, + homeEnabled: true, + wantModels: false, + wantCodexClient: true, + }, + { + name: "local-model disables both remote catalogs", + localModel: true, + homeEnabled: false, + wantModels: false, + wantCodexClient: false, + }, + { + name: "local-model disables both remote catalogs even under home", + localModel: true, + homeEnabled: true, + wantModels: false, + wantCodexClient: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotModels, gotCodex := modelCatalogUpdaterPlan(tt.localModel, tt.homeEnabled) + if gotModels != tt.wantModels || gotCodex != tt.wantCodexClient { + t.Fatalf("modelCatalogUpdaterPlan(%v, %v) = (%v, %v), want (%v, %v)", + tt.localModel, tt.homeEnabled, gotModels, gotCodex, tt.wantModels, tt.wantCodexClient) + } + }) + } +} diff --git a/cmd/validate_codex_models/main.go b/cmd/validate_codex_models/main.go new file mode 100644 index 000000000..0a44a8d5c --- /dev/null +++ b/cmd/validate_codex_models/main.go @@ -0,0 +1,32 @@ +// Command validate_codex_models validates a Codex client model catalog file. +package main + +import ( + "flag" + "fmt" + "os" + "strings" + + "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" +) + +func main() { + var inputPath string + flag.StringVar(&inputPath, "file", "", "Codex client model catalog JSON file") + flag.Parse() + + if strings.TrimSpace(inputPath) == "" { + fmt.Fprintln(os.Stderr, "error: --file is required") + os.Exit(2) + } + data, err := os.ReadFile(inputPath) + if err != nil { + fmt.Fprintf(os.Stderr, "error: read %s: %v\n", inputPath, err) + os.Exit(1) + } + if err = registry.ValidateCodexClientModelsJSON(data); err != nil { + fmt.Fprintf(os.Stderr, "error: invalid Codex client model catalog %s: %v\n", inputPath, err) + os.Exit(1) + } + fmt.Printf("Validated Codex client model catalog: %s\n", inputPath) +} diff --git a/internal/api/server.go b/internal/api/server.go index 920838a44..c56275231 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -1180,6 +1180,8 @@ func (s *Server) unifiedModelsHandler(openaiHandler *openai.OpenAIAPIHandler, cl } } +// handleHomeCodexClientModels builds the Codex client catalog from Home model IDs. +// Template metadata still comes from the local/remote codex_client_models catalog. func (s *Server) handleHomeCodexClientModels(c *gin.Context) { entries, ok := s.loadHomeModelEntries(c) if !ok { diff --git a/internal/api/server_test.go b/internal/api/server_test.go index 36f61383e..bb796c2b6 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -919,8 +919,9 @@ func TestModelsWithClientVersionReturnsCodexCatalog(t *testing.T) { if got, _ := custom["display_name"].(string); got != "Custom Codex Model" { t.Fatalf("custom display_name = %q, want Custom Codex Model", got) } - if got := int(codexClientTestPriority(custom["priority"])); got != 129 { - t.Fatalf("custom priority = %v, want 129", custom["priority"]) + wantCustomPriority := codexClientTestMaxTemplatePriority(t) + 100 + if got := int(codexClientTestPriority(custom["priority"])); got != wantCustomPriority { + t.Fatalf("custom priority = %v, want %d", custom["priority"], wantCustomPriority) } if got, _ := custom["description"].(string); got != "Custom model from registry" { t.Fatalf("custom description = %q, want Custom model from registry", got) @@ -987,6 +988,23 @@ func codexClientTestPriority(raw any) int { } } +func codexClientTestMaxTemplatePriority(t *testing.T) int { + t.Helper() + var payload struct { + Models []map[string]any `json:"models"` + } + if err := json.Unmarshal(registry.GetCodexClientModelsJSON(), &payload); err != nil { + t.Fatalf("parse Codex client model templates: %v", err) + } + maxPriority := 0 + for _, model := range payload.Models { + if priority := codexClientTestPriority(model["priority"]); priority > maxPriority { + maxPriority = priority + } + } + return maxPriority +} + func assertCodexSupportedReasoningLevels(t *testing.T, model map[string]any, want []string) { t.Helper() diff --git a/internal/registry/codex_client_models.go b/internal/registry/codex_client_models.go index f254d5e1e..8e601f11e 100644 --- a/internal/registry/codex_client_models.go +++ b/internal/registry/codex_client_models.go @@ -1,11 +1,174 @@ package registry -import _ "embed" +import ( + "bytes" + _ "embed" + "encoding/json" + "fmt" + "math" + "strings" + "sync" + + log "github.com/sirupsen/logrus" +) //go:embed models/codex_client_models.json -var codexClientModelsJSON []byte +var embeddedCodexClientModelsJSON []byte -// GetCodexClientModelsJSON returns the embedded Codex client model catalog. -func GetCodexClientModelsJSON() []byte { - return append([]byte(nil), codexClientModelsJSON...) +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 +} + +// 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 } diff --git a/internal/registry/codex_client_models_test.go b/internal/registry/codex_client_models_test.go new file mode 100644 index 000000000..e8e105d01 --- /dev/null +++ b/internal/registry/codex_client_models_test.go @@ -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 +} diff --git a/internal/registry/codex_client_models_updater.go b/internal/registry/codex_client_models_updater.go new file mode 100644 index 000000000..c556daad0 --- /dev/null +++ b/internal/registry/codex_client_models_updater.go @@ -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, "" +} diff --git a/sdk/api/handlers/openai/codex_client_models.go b/sdk/api/handlers/openai/codex_client_models.go index 68ec79953..642092df0 100644 --- a/sdk/api/handlers/openai/codex_client_models.go +++ b/sdk/api/handlers/openai/codex_client_models.go @@ -14,10 +14,12 @@ type codexClientModelsPayload struct { } var ( - codexClientModelTemplatesOnce sync.Once - codexClientModelTemplates map[string]map[string]any - codexClientDefaultTemplate map[string]any - codexClientModelTemplatesErr error + codexClientModelTemplatesMu sync.Mutex + codexClientModelTemplatesLoaded bool + codexClientModelTemplatesRevision uint64 + codexClientModelTemplates map[string]map[string]any + codexClientDefaultTemplate map[string]any + codexClientModelTemplatesErr error ) var codexClientAllowedReasoningLevels = map[string]struct{}{ @@ -133,26 +135,40 @@ func applyCodexClientNonTemplatePriorities(result []map[string]any, templates ma } func loadCodexClientModelTemplates() (map[string]map[string]any, map[string]any, error) { - codexClientModelTemplatesOnce.Do(func() { - var payload codexClientModelsPayload - codexClientModelTemplatesErr = json.Unmarshal(registry.GetCodexClientModelsJSON(), &payload) - if codexClientModelTemplatesErr != nil { - return - } + raw, revision := registry.GetCodexClientModelsSnapshot() + return loadCodexClientModelTemplatesSnapshot(raw, revision) +} - codexClientModelTemplates = make(map[string]map[string]any, len(payload.Models)) +func loadCodexClientModelTemplatesSnapshot(raw []byte, revision uint64) (map[string]map[string]any, map[string]any, error) { + codexClientModelTemplatesMu.Lock() + defer codexClientModelTemplatesMu.Unlock() + if codexClientModelTemplatesLoaded && codexClientModelTemplatesRevision == revision { + return codexClientModelTemplates, codexClientDefaultTemplate, codexClientModelTemplatesErr + } + + var payload codexClientModelsPayload + err := json.Unmarshal(raw, &payload) + var templates map[string]map[string]any + var defaultTemplate map[string]any + if err == nil { + templates = make(map[string]map[string]any, len(payload.Models)) for _, model := range payload.Models { slug := strings.TrimSpace(stringModelValue(model, "slug")) if slug == "" { continue } - codexClientModelTemplates[slug] = cloneCodexClientModelMap(model) + templates[slug] = cloneCodexClientModelMap(model) if slug == "gpt-5.5" { - codexClientDefaultTemplate = cloneCodexClientModelMap(model) + defaultTemplate = cloneCodexClientModelMap(model) } } - }) + } + codexClientModelTemplatesLoaded = true + codexClientModelTemplatesRevision = revision + codexClientModelTemplates = templates + codexClientDefaultTemplate = defaultTemplate + codexClientModelTemplatesErr = err return codexClientModelTemplates, codexClientDefaultTemplate, codexClientModelTemplatesErr } diff --git a/sdk/api/handlers/openai/codex_client_models_test.go b/sdk/api/handlers/openai/codex_client_models_test.go index 64c0e96ca..0cdd41311 100644 --- a/sdk/api/handlers/openai/codex_client_models_test.go +++ b/sdk/api/handlers/openai/codex_client_models_test.go @@ -174,3 +174,55 @@ func TestCodexClientModelsResponse_PreservesUltraReasoningEffort(t *testing.T) { t.Fatalf("supported_reasoning_levels = %#v, want ultra", levels) } + +func TestLoadCodexClientModelTemplatesRefreshesOnRevision(t *testing.T) { + codexClientModelTemplatesMu.Lock() + previousLoaded := codexClientModelTemplatesLoaded + previousRevision := codexClientModelTemplatesRevision + previousTemplates := codexClientModelTemplates + previousDefault := codexClientDefaultTemplate + previousErr := codexClientModelTemplatesErr + codexClientModelTemplatesLoaded = false + codexClientModelTemplatesMu.Unlock() + t.Cleanup(func() { + codexClientModelTemplatesMu.Lock() + codexClientModelTemplatesLoaded = previousLoaded + codexClientModelTemplatesRevision = previousRevision + codexClientModelTemplates = previousTemplates + codexClientDefaultTemplate = previousDefault + codexClientModelTemplatesErr = previousErr + codexClientModelTemplatesMu.Unlock() + }) + + first := []byte(`{"models":[{"slug":"gpt-5.5","display_name":"First"}]}`) + templates, defaultTemplate, err := loadCodexClientModelTemplatesSnapshot(first, 100) + if err != nil { + t.Fatalf("load first snapshot: %v", err) + } + if got := stringModelValue(templates["gpt-5.5"], "display_name"); got != "First" { + t.Fatalf("first display_name = %q, want First", got) + } + if got := stringModelValue(defaultTemplate, "display_name"); got != "First" { + t.Fatalf("first default display_name = %q, want First", got) + } + + second := []byte(`{"models":[{"slug":"gpt-5.5","display_name":"Second"}]}`) + templates, defaultTemplate, err = loadCodexClientModelTemplatesSnapshot(second, 101) + if err != nil { + t.Fatalf("load second snapshot: %v", err) + } + if got := stringModelValue(templates["gpt-5.5"], "display_name"); got != "Second" { + t.Fatalf("second display_name = %q, want Second", got) + } + if got := stringModelValue(defaultTemplate, "display_name"); got != "Second" { + t.Fatalf("second default display_name = %q, want Second", got) + } + + templates, _, err = loadCodexClientModelTemplatesSnapshot(first, 101) + if err != nil { + t.Fatalf("reload cached revision: %v", err) + } + if got := stringModelValue(templates["gpt-5.5"], "display_name"); got != "Second" { + t.Fatalf("cached display_name = %q, want Second", got) + } +}