feat(registry): remote-refresh Codex client model catalog (#4276)

Load codex_client_models.json like models.json with validation, revisioned
handler reloads, CI bake, and Home-aware updater gates so list IDs still
come from Home while templates can refresh on the edge.
This commit is contained in:
sususu98
2026-07-13 17:47:35 +08:00
committed by GitHub
parent e674f19129
commit 4fe2c60c51
16 changed files with 812 additions and 75 deletions

View File

@@ -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"

View File

@@ -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

View File

@@ -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

View File

@@ -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: |

View File

@@ -10,8 +10,8 @@
//
// --auths-dir <path> Directory containing auth JSON files (default: config auth-dir)
// --config <path> Config file path (default: "config.yaml")
// --output <path> Output JSON file path (default: "codex_models.json")
// --client-version <ver> Codex client_version query value (default: "0.133.0")
// --output <path> Output JSON file path (default: "codex_client_models.json")
// --client-version <ver> 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")
}

View File

@@ -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")
}
}

View File

@@ -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]

View File

@@ -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)
}
})
}
}

View File

@@ -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)
}

View File

@@ -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 {

View File

@@ -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()

View File

@@ -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
}

View File

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

View File

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

View File

@@ -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
}

View File

@@ -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)
}
}