mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-09-03 06:35:00 +08:00
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:
22
.github/scripts/refresh-model-catalogs.sh
vendored
Normal file
22
.github/scripts/refresh-model-catalogs.sh
vendored
Normal 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"
|
||||
18
.github/workflows/docker-image.yml
vendored
18
.github/workflows/docker-image.yml
vendored
@@ -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
|
||||
|
||||
6
.github/workflows/pr-test-build.yml
vendored
6
.github/workflows/pr-test-build.yml
vendored
@@ -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
|
||||
|
||||
49
.github/workflows/release.yaml
vendored
49
.github/workflows/release.yaml
vendored
@@ -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: |
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
48
cmd/fetch_codex_models/main_test.go
Normal file
48
cmd/fetch_codex_models/main_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
32
cmd/validate_codex_models/main.go
Normal file
32
cmd/validate_codex_models/main.go
Normal 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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
208
internal/registry/codex_client_models_test.go
Normal file
208
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
internal/registry/codex_client_models_updater.go
Normal file
114
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, ""
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user