From 41cf3135ce0c64cf92f6ddeeb47a6b82443ae947 Mon Sep 17 00:00:00 2001 From: excel Date: Tue, 10 Mar 2026 19:28:14 +0800 Subject: [PATCH 1/2] feat(codex): add tier-aware model catalog refresh --- internal/registry/codex_catalog.go | 377 ++++++++ internal/registry/codex_catalog_test.go | 89 ++ internal/registry/model_definitions.go | 3 +- internal/registry/models/codex_models.json | 1010 ++++++++++++++++++++ internal/watcher/synthesizer/file.go | 6 + sdk/auth/codex_device.go | 3 + sdk/auth/filestore.go | 16 + sdk/auth/filestore_codex_plan_test.go | 66 ++ sdk/cliproxy/service.go | 33 +- sdk/cliproxy/service_codex_models_test.go | 146 +++ 10 files changed, 1747 insertions(+), 2 deletions(-) create mode 100644 internal/registry/codex_catalog.go create mode 100644 internal/registry/codex_catalog_test.go create mode 100644 internal/registry/models/codex_models.json create mode 100644 sdk/auth/filestore_codex_plan_test.go create mode 100644 sdk/cliproxy/service_codex_models_test.go diff --git a/internal/registry/codex_catalog.go b/internal/registry/codex_catalog.go new file mode 100644 index 0000000000..6a1c929a58 --- /dev/null +++ b/internal/registry/codex_catalog.go @@ -0,0 +1,377 @@ +package registry + +import ( + "context" + _ "embed" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strings" + "sync" + "time" + + sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config" + log "github.com/sirupsen/logrus" + "golang.org/x/net/proxy" +) + +const codexModelsFetchTimeout = 15 * time.Second + +var codexModelsURLs = []string{ + "https://raw.githubusercontent.com/router-for-me/models/refs/heads/main/models.json", + "https://models.router-for.me/models.json", +} + +//go:embed models/codex_models.json +var embeddedCodexModelsJSON []byte + +type codexModelsJSON struct { + CodexFree []*ModelInfo `json:"codex-free"` + CodexTeam []*ModelInfo `json:"codex-team"` + CodexPlus []*ModelInfo `json:"codex-plus"` + CodexPro []*ModelInfo `json:"codex-pro"` +} + +type codexCatalogStore struct { + mu sync.RWMutex + data *codexModelsJSON +} + +var globalCodexCatalog = &codexCatalogStore{} +var codexUpdaterOnce sync.Once + +func init() { + if err := loadCodexModelsCatalogFromBytes(embeddedCodexModelsJSON, "embed"); err != nil { + panic(fmt.Sprintf("registry: failed to parse embedded codex catalog: %v", err)) + } +} + +func StartCodexModelsUpdater(ctx context.Context, cfg *sdkconfig.SDKConfig, onUpdated func()) { + codexUpdaterOnce.Do(func() { + go func() { + if err := refreshCodexModelsCatalog(ctx, cfg); err != nil { + log.Warnf("codex models refresh failed, using embedded catalog: %v", err) + return + } + if onUpdated != nil { + onUpdated() + } + }() + }) +} + +func GetCodexFreeModels() []*ModelInfo { + return cloneCodexModelInfos(getCodexCatalog().CodexFree) +} + +func GetCodexTeamModels() []*ModelInfo { + return cloneCodexModelInfos(getCodexCatalog().CodexTeam) +} + +func GetCodexPlusModels() []*ModelInfo { + return cloneCodexModelInfos(getCodexCatalog().CodexPlus) +} + +func GetCodexProModels() []*ModelInfo { + return cloneCodexModelInfos(getCodexCatalog().CodexPro) +} + +func GetCodexModelsForPlan(planType string) []*ModelInfo { + switch NormalizeCodexPlanType(planType) { + case "pro": + return GetCodexProModels() + case "plus": + return GetCodexPlusModels() + case "team": + return GetCodexTeamModels() + case "free": + fallthrough + default: + return GetCodexFreeModels() + } +} + +func GetCodexModelsUnion() []*ModelInfo { + catalog := getCodexCatalog() + sections := [][]*ModelInfo{catalog.CodexFree, catalog.CodexTeam, catalog.CodexPlus, catalog.CodexPro} + seen := make(map[string]struct{}) + out := make([]*ModelInfo, 0) + for _, models := range sections { + for _, model := range models { + if model == nil || strings.TrimSpace(model.ID) == "" { + continue + } + if _, ok := seen[model.ID]; ok { + continue + } + seen[model.ID] = struct{}{} + out = append(out, cloneModelInfo(model)) + } + } + return out +} + +func NormalizeCodexPlanType(planType string) string { + switch strings.ToLower(strings.TrimSpace(planType)) { + case "free": + return "free" + case "team", "business", "enterprise", "edu", "education": + return "team" + case "plus": + return "plus" + case "pro": + return "pro" + default: + return "" + } +} + +func ResolveCodexPlanType(attributes map[string]string, metadata map[string]any) string { + if attributes != nil { + for _, key := range []string{"plan_type", "chatgpt_plan_type"} { + if plan := NormalizeCodexPlanType(attributes[key]); plan != "" { + return plan + } + } + } + plan, _ := EnsureCodexPlanTypeMetadata(metadata) + return plan +} + +func EnsureCodexPlanTypeMetadata(metadata map[string]any) (string, bool) { + if metadata == nil { + return "", false + } + for _, key := range []string{"plan_type", "chatgpt_plan_type"} { + if raw, ok := metadata[key].(string); ok { + if plan := NormalizeCodexPlanType(raw); plan != "" { + if current, _ := metadata["plan_type"].(string); NormalizeCodexPlanType(current) != plan { + metadata["plan_type"] = plan + return plan, true + } + return plan, false + } + } + } + idToken := firstString(metadata, "id_token") + if idToken == "" { + idToken = nestedString(metadata, "token", "id_token") + } + if idToken == "" { + idToken = nestedString(metadata, "tokens", "id_token") + } + if idToken == "" { + return "", false + } + plan, err := extractCodexPlanTypeFromJWT(idToken) + if err != nil { + return "", false + } + if plan == "" { + return "", false + } + metadata["plan_type"] = plan + return plan, true +} + +func getCodexCatalog() *codexModelsJSON { + globalCodexCatalog.mu.RLock() + data := globalCodexCatalog.data + globalCodexCatalog.mu.RUnlock() + if data != nil { + return data + } + return &codexModelsJSON{} +} + +func refreshCodexModelsCatalog(ctx context.Context, cfg *sdkconfig.SDKConfig) error { + if ctx == nil { + ctx = context.Background() + } + if cfg == nil { + cfg = &sdkconfig.SDKConfig{} + } + client := newCodexCatalogHTTPClient(cfg) + var errs []string + for _, rawURL := range codexModelsURLs { + url := strings.TrimSpace(rawURL) + if url == "" { + continue + } + requestCtx, cancel := context.WithTimeout(ctx, codexModelsFetchTimeout) + req, err := http.NewRequestWithContext(requestCtx, http.MethodGet, url, nil) + if err != nil { + cancel() + errs = append(errs, fmt.Sprintf("%s: create request: %v", url, err)) + continue + } + resp, err := client.Do(req) + if err != nil { + cancel() + errs = append(errs, fmt.Sprintf("%s: %v", url, err)) + continue + } + body, readErr := io.ReadAll(resp.Body) + _ = resp.Body.Close() + cancel() + if readErr != nil { + errs = append(errs, fmt.Sprintf("%s: read body: %v", url, readErr)) + continue + } + if resp.StatusCode != http.StatusOK { + errs = append(errs, fmt.Sprintf("%s: status %d", url, resp.StatusCode)) + continue + } + if err := loadCodexModelsCatalogFromBytes(body, url); err != nil { + errs = append(errs, err.Error()) + continue + } + log.Infof("codex models catalog refreshed from %s", url) + return nil + } + if len(errs) == 0 { + return fmt.Errorf("no codex catalog source URLs configured") + } + return fmt.Errorf("%s", strings.Join(errs, "; ")) +} + +func loadCodexModelsCatalogFromBytes(data []byte, source string) error { + var parsed codexModelsJSON + if err := json.Unmarshal(data, &parsed); err != nil { + return fmt.Errorf("%s: decode codex catalog: %w", source, err) + } + if err := validateCodexModelsCatalog(&parsed); err != nil { + return fmt.Errorf("%s: validate codex catalog: %w", source, err) + } + globalCodexCatalog.mu.Lock() + globalCodexCatalog.data = &parsed + globalCodexCatalog.mu.Unlock() + return nil +} + +func validateCodexModelsCatalog(data *codexModelsJSON) error { + if data == nil { + return fmt.Errorf("catalog is nil") + } + sections := []struct { + name string + models []*ModelInfo + }{ + {name: "codex-free", models: data.CodexFree}, + {name: "codex-team", models: data.CodexTeam}, + {name: "codex-plus", models: data.CodexPlus}, + {name: "codex-pro", models: data.CodexPro}, + } + for _, section := range sections { + if len(section.models) == 0 { + return fmt.Errorf("%s section is empty", section.name) + } + seen := make(map[string]struct{}, len(section.models)) + for i, model := range section.models { + if model == nil { + return fmt.Errorf("%s[%d] is null", section.name, i) + } + id := strings.TrimSpace(model.ID) + if id == "" { + return fmt.Errorf("%s[%d] has empty id", section.name, i) + } + if _, ok := seen[id]; ok { + return fmt.Errorf("%s contains duplicate model id %q", section.name, id) + } + seen[id] = struct{}{} + } + } + return nil +} + +func firstString(metadata map[string]any, key string) string { + if metadata == nil { + return "" + } + if value, ok := metadata[key].(string); ok { + return strings.TrimSpace(value) + } + return "" +} + +func nestedString(metadata map[string]any, parent, key string) string { + if metadata == nil { + return "" + } + raw, ok := metadata[parent] + if !ok { + return "" + } + child, ok := raw.(map[string]any) + if !ok { + return "" + } + value, _ := child[key].(string) + return strings.TrimSpace(value) +} + +func extractCodexPlanTypeFromJWT(token string) (string, error) { + parts := strings.Split(strings.TrimSpace(token), ".") + if len(parts) != 3 { + return "", fmt.Errorf("invalid jwt format") + } + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return "", err + } + var claims struct { + Auth struct { + PlanType string `json:"chatgpt_plan_type"` + } `json:"https://api.openai.com/auth"` + } + if err := json.Unmarshal(payload, &claims); err != nil { + return "", err + } + return NormalizeCodexPlanType(claims.Auth.PlanType), nil +} + +func newCodexCatalogHTTPClient(cfg *sdkconfig.SDKConfig) *http.Client { + client := &http.Client{Timeout: codexModelsFetchTimeout} + if cfg == nil || strings.TrimSpace(cfg.ProxyURL) == "" { + return client + } + proxyURL, err := url.Parse(strings.TrimSpace(cfg.ProxyURL)) + if err != nil { + return client + } + switch proxyURL.Scheme { + case "http", "https": + client.Transport = &http.Transport{Proxy: http.ProxyURL(proxyURL)} + case "socks5": + var auth *proxy.Auth + if proxyURL.User != nil { + password, _ := proxyURL.User.Password() + auth = &proxy.Auth{User: proxyURL.User.Username(), Password: password} + } + dialer, err := proxy.SOCKS5("tcp", proxyURL.Host, auth, proxy.Direct) + if err != nil { + return client + } + client.Transport = &http.Transport{ + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + return dialer.Dial(network, addr) + }, + } + } + return client +} + +func cloneCodexModelInfos(models []*ModelInfo) []*ModelInfo { + if len(models) == 0 { + return nil + } + out := make([]*ModelInfo, len(models)) + for i, model := range models { + out[i] = cloneModelInfo(model) + } + return out +} diff --git a/internal/registry/codex_catalog_test.go b/internal/registry/codex_catalog_test.go new file mode 100644 index 0000000000..a131d2c25a --- /dev/null +++ b/internal/registry/codex_catalog_test.go @@ -0,0 +1,89 @@ +package registry + +import ( + "encoding/base64" + "encoding/json" + "strings" + "testing" +) + +func TestEnsureCodexPlanTypeMetadataExtractsFromJWT(t *testing.T) { + metadata := map[string]any{ + "type": "codex", + "id_token": testCodexJWT(t, "team"), + } + plan, changed := EnsureCodexPlanTypeMetadata(metadata) + if !changed { + t.Fatal("expected metadata to be updated from id_token") + } + if plan != "team" { + t.Fatalf("expected team plan, got %q", plan) + } + if got, _ := metadata["plan_type"].(string); got != "team" { + t.Fatalf("expected metadata plan_type team, got %#v", metadata["plan_type"]) + } +} + +func TestGetCodexModelsForPlanUsesSafeFallback(t *testing.T) { + models := GetCodexModelsForPlan("unknown") + if len(models) == 0 { + t.Fatal("expected codex models for unknown plan fallback") + } + for _, model := range models { + if model == nil { + continue + } + if model.ID == "gpt-5.4" || model.ID == "gpt-5.3-codex-spark" { + t.Fatalf("expected unknown plan to avoid higher-tier model %q", model.ID) + } + } +} + +func TestGetStaticModelDefinitionsByChannelCodexReturnsTierUnion(t *testing.T) { + models := GetStaticModelDefinitionsByChannel("codex") + if len(models) == 0 { + t.Fatal("expected codex static model definitions") + } + seen := make(map[string]struct{}, len(models)) + for _, model := range models { + if model == nil { + continue + } + if _, ok := seen[model.ID]; ok { + t.Fatalf("duplicate codex model %q in union", model.ID) + } + seen[model.ID] = struct{}{} + } + for _, required := range []string{"gpt-5.2-codex", "gpt-5.3-codex", "gpt-5.3-codex-spark", "gpt-5.4"} { + if _, ok := seen[required]; !ok { + t.Fatalf("expected codex union to include %q", required) + } + } +} + +func TestLookupStaticModelInfoFindsCodexTierModel(t *testing.T) { + model := LookupStaticModelInfo("gpt-5.3-codex-spark") + if model == nil { + t.Fatal("expected spark model lookup to succeed") + } + if !strings.EqualFold(model.DisplayName, "GPT 5.3 Codex Spark") { + t.Fatalf("unexpected display name: %+v", model) + } +} + +func testCodexJWT(t *testing.T, planType string) string { + t.Helper() + header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none","typ":"JWT"}`)) + payloadRaw, err := json.Marshal(map[string]any{ + "email": "tester@example.com", + "https://api.openai.com/auth": map[string]any{ + "chatgpt_account_id": "acct_123", + "chatgpt_plan_type": planType, + }, + }) + if err != nil { + t.Fatalf("marshal payload: %v", err) + } + payload := base64.RawURLEncoding.EncodeToString(payloadRaw) + return header + "." + payload + ".signature" +} diff --git a/internal/registry/model_definitions.go b/internal/registry/model_definitions.go index c17969795c..bd2d2893b8 100644 --- a/internal/registry/model_definitions.go +++ b/internal/registry/model_definitions.go @@ -35,7 +35,7 @@ func GetStaticModelDefinitionsByChannel(channel string) []*ModelInfo { case "aistudio": return GetAIStudioModels() case "codex": - return GetOpenAIModels() + return GetCodexModelsUnion() case "qwen": return GetQwenModels() case "iflow": @@ -84,6 +84,7 @@ func LookupStaticModelInfo(modelID string) *ModelInfo { GetGeminiCLIModels(), GetAIStudioModels(), GetOpenAIModels(), + GetCodexModelsUnion(), GetQwenModels(), GetIFlowModels(), GetKimiModels(), diff --git a/internal/registry/models/codex_models.json b/internal/registry/models/codex_models.json new file mode 100644 index 0000000000..d99b38460b --- /dev/null +++ b/internal/registry/models/codex_models.json @@ -0,0 +1,1010 @@ +{ + "codex-free": [ + { + "id": "gpt-5", + "object": "model", + "created": 1754524800, + "owned_by": "openai", + "type": "openai", + "display_name": "GPT 5", + "version": "gpt-5-2025-08-07", + "description": "Stable version of GPT 5, The best model for coding and agentic tasks across domains.", + "context_length": 400000, + "max_completion_tokens": 128000, + "supported_parameters": [ + "tools" + ], + "thinking": { + "levels": [ + "minimal", + "low", + "medium", + "high" + ] + } + }, + { + "id": "gpt-5-codex", + "object": "model", + "created": 1757894400, + "owned_by": "openai", + "type": "openai", + "display_name": "GPT 5 Codex", + "version": "gpt-5-2025-09-15", + "description": "Stable version of GPT 5 Codex, The best model for coding and agentic tasks across domains.", + "context_length": 400000, + "max_completion_tokens": 128000, + "supported_parameters": [ + "tools" + ], + "thinking": { + "levels": [ + "low", + "medium", + "high" + ] + } + }, + { + "id": "gpt-5-codex-mini", + "object": "model", + "created": 1762473600, + "owned_by": "openai", + "type": "openai", + "display_name": "GPT 5 Codex Mini", + "version": "gpt-5-2025-11-07", + "description": "Stable version of GPT 5 Codex Mini: cheaper, faster, but less capable version of GPT 5 Codex.", + "context_length": 400000, + "max_completion_tokens": 128000, + "supported_parameters": [ + "tools" + ], + "thinking": { + "levels": [ + "low", + "medium", + "high" + ] + } + }, + { + "id": "gpt-5.1", + "object": "model", + "created": 1762905600, + "owned_by": "openai", + "type": "openai", + "display_name": "GPT 5", + "version": "gpt-5.1-2025-11-12", + "description": "Stable version of GPT 5, The best model for coding and agentic tasks across domains.", + "context_length": 400000, + "max_completion_tokens": 128000, + "supported_parameters": [ + "tools" + ], + "thinking": { + "levels": [ + "none", + "low", + "medium", + "high" + ] + } + }, + { + "id": "gpt-5.1-codex", + "object": "model", + "created": 1762905600, + "owned_by": "openai", + "type": "openai", + "display_name": "GPT 5.1 Codex", + "version": "gpt-5.1-2025-11-12", + "description": "Stable version of GPT 5.1 Codex, The best model for coding and agentic tasks across domains.", + "context_length": 400000, + "max_completion_tokens": 128000, + "supported_parameters": [ + "tools" + ], + "thinking": { + "levels": [ + "low", + "medium", + "high" + ] + } + }, + { + "id": "gpt-5.1-codex-mini", + "object": "model", + "created": 1762905600, + "owned_by": "openai", + "type": "openai", + "display_name": "GPT 5.1 Codex Mini", + "version": "gpt-5.1-2025-11-12", + "description": "Stable version of GPT 5.1 Codex Mini: cheaper, faster, but less capable version of GPT 5.1 Codex.", + "context_length": 400000, + "max_completion_tokens": 128000, + "supported_parameters": [ + "tools" + ], + "thinking": { + "levels": [ + "low", + "medium", + "high" + ] + } + }, + { + "id": "gpt-5.1-codex-max", + "object": "model", + "created": 1763424000, + "owned_by": "openai", + "type": "openai", + "display_name": "GPT 5.1 Codex Max", + "version": "gpt-5.1-max", + "description": "Stable version of GPT 5.1 Codex Max", + "context_length": 400000, + "max_completion_tokens": 128000, + "supported_parameters": [ + "tools" + ], + "thinking": { + "levels": [ + "low", + "medium", + "high", + "xhigh" + ] + } + }, + { + "id": "gpt-5.2", + "object": "model", + "created": 1765440000, + "owned_by": "openai", + "type": "openai", + "display_name": "GPT 5.2", + "version": "gpt-5.2", + "description": "Stable version of GPT 5.2", + "context_length": 400000, + "max_completion_tokens": 128000, + "supported_parameters": [ + "tools" + ], + "thinking": { + "levels": [ + "none", + "low", + "medium", + "high", + "xhigh" + ] + } + }, + { + "id": "gpt-5.2-codex", + "object": "model", + "created": 1765440000, + "owned_by": "openai", + "type": "openai", + "display_name": "GPT 5.2 Codex", + "version": "gpt-5.2", + "description": "Stable version of GPT 5.2 Codex, The best model for coding and agentic tasks across domains.", + "context_length": 400000, + "max_completion_tokens": 128000, + "supported_parameters": [ + "tools" + ], + "thinking": { + "levels": [ + "low", + "medium", + "high", + "xhigh" + ] + } + } + ], + "codex-team": [ + { + "id": "gpt-5", + "object": "model", + "created": 1754524800, + "owned_by": "openai", + "type": "openai", + "display_name": "GPT 5", + "version": "gpt-5-2025-08-07", + "description": "Stable version of GPT 5, The best model for coding and agentic tasks across domains.", + "context_length": 400000, + "max_completion_tokens": 128000, + "supported_parameters": [ + "tools" + ], + "thinking": { + "levels": [ + "minimal", + "low", + "medium", + "high" + ] + } + }, + { + "id": "gpt-5-codex", + "object": "model", + "created": 1757894400, + "owned_by": "openai", + "type": "openai", + "display_name": "GPT 5 Codex", + "version": "gpt-5-2025-09-15", + "description": "Stable version of GPT 5 Codex, The best model for coding and agentic tasks across domains.", + "context_length": 400000, + "max_completion_tokens": 128000, + "supported_parameters": [ + "tools" + ], + "thinking": { + "levels": [ + "low", + "medium", + "high" + ] + } + }, + { + "id": "gpt-5-codex-mini", + "object": "model", + "created": 1762473600, + "owned_by": "openai", + "type": "openai", + "display_name": "GPT 5 Codex Mini", + "version": "gpt-5-2025-11-07", + "description": "Stable version of GPT 5 Codex Mini: cheaper, faster, but less capable version of GPT 5 Codex.", + "context_length": 400000, + "max_completion_tokens": 128000, + "supported_parameters": [ + "tools" + ], + "thinking": { + "levels": [ + "low", + "medium", + "high" + ] + } + }, + { + "id": "gpt-5.1", + "object": "model", + "created": 1762905600, + "owned_by": "openai", + "type": "openai", + "display_name": "GPT 5", + "version": "gpt-5.1-2025-11-12", + "description": "Stable version of GPT 5, The best model for coding and agentic tasks across domains.", + "context_length": 400000, + "max_completion_tokens": 128000, + "supported_parameters": [ + "tools" + ], + "thinking": { + "levels": [ + "none", + "low", + "medium", + "high" + ] + } + }, + { + "id": "gpt-5.1-codex", + "object": "model", + "created": 1762905600, + "owned_by": "openai", + "type": "openai", + "display_name": "GPT 5.1 Codex", + "version": "gpt-5.1-2025-11-12", + "description": "Stable version of GPT 5.1 Codex, The best model for coding and agentic tasks across domains.", + "context_length": 400000, + "max_completion_tokens": 128000, + "supported_parameters": [ + "tools" + ], + "thinking": { + "levels": [ + "low", + "medium", + "high" + ] + } + }, + { + "id": "gpt-5.1-codex-mini", + "object": "model", + "created": 1762905600, + "owned_by": "openai", + "type": "openai", + "display_name": "GPT 5.1 Codex Mini", + "version": "gpt-5.1-2025-11-12", + "description": "Stable version of GPT 5.1 Codex Mini: cheaper, faster, but less capable version of GPT 5.1 Codex.", + "context_length": 400000, + "max_completion_tokens": 128000, + "supported_parameters": [ + "tools" + ], + "thinking": { + "levels": [ + "low", + "medium", + "high" + ] + } + }, + { + "id": "gpt-5.1-codex-max", + "object": "model", + "created": 1763424000, + "owned_by": "openai", + "type": "openai", + "display_name": "GPT 5.1 Codex Max", + "version": "gpt-5.1-max", + "description": "Stable version of GPT 5.1 Codex Max", + "context_length": 400000, + "max_completion_tokens": 128000, + "supported_parameters": [ + "tools" + ], + "thinking": { + "levels": [ + "low", + "medium", + "high", + "xhigh" + ] + } + }, + { + "id": "gpt-5.2", + "object": "model", + "created": 1765440000, + "owned_by": "openai", + "type": "openai", + "display_name": "GPT 5.2", + "version": "gpt-5.2", + "description": "Stable version of GPT 5.2", + "context_length": 400000, + "max_completion_tokens": 128000, + "supported_parameters": [ + "tools" + ], + "thinking": { + "levels": [ + "none", + "low", + "medium", + "high", + "xhigh" + ] + } + }, + { + "id": "gpt-5.2-codex", + "object": "model", + "created": 1765440000, + "owned_by": "openai", + "type": "openai", + "display_name": "GPT 5.2 Codex", + "version": "gpt-5.2", + "description": "Stable version of GPT 5.2 Codex, The best model for coding and agentic tasks across domains.", + "context_length": 400000, + "max_completion_tokens": 128000, + "supported_parameters": [ + "tools" + ], + "thinking": { + "levels": [ + "low", + "medium", + "high", + "xhigh" + ] + } + }, + { + "id": "gpt-5.3-codex", + "object": "model", + "created": 1770307200, + "owned_by": "openai", + "type": "openai", + "display_name": "GPT 5.3 Codex", + "version": "gpt-5.3", + "description": "Stable version of GPT 5.3 Codex, The best model for coding and agentic tasks across domains.", + "context_length": 400000, + "max_completion_tokens": 128000, + "supported_parameters": [ + "tools" + ], + "thinking": { + "levels": [ + "low", + "medium", + "high", + "xhigh" + ] + } + }, + { + "id": "gpt-5.4", + "object": "model", + "created": 1772668800, + "owned_by": "openai", + "type": "openai", + "display_name": "GPT 5.4", + "version": "gpt-5.4", + "description": "Stable version of GPT 5.4", + "context_length": 1050000, + "max_completion_tokens": 128000, + "supported_parameters": [ + "tools" + ], + "thinking": { + "levels": [ + "low", + "medium", + "high", + "xhigh" + ] + } + } + ], + "codex-plus": [ + { + "id": "gpt-5", + "object": "model", + "created": 1754524800, + "owned_by": "openai", + "type": "openai", + "display_name": "GPT 5", + "version": "gpt-5-2025-08-07", + "description": "Stable version of GPT 5, The best model for coding and agentic tasks across domains.", + "context_length": 400000, + "max_completion_tokens": 128000, + "supported_parameters": [ + "tools" + ], + "thinking": { + "levels": [ + "minimal", + "low", + "medium", + "high" + ] + } + }, + { + "id": "gpt-5-codex", + "object": "model", + "created": 1757894400, + "owned_by": "openai", + "type": "openai", + "display_name": "GPT 5 Codex", + "version": "gpt-5-2025-09-15", + "description": "Stable version of GPT 5 Codex, The best model for coding and agentic tasks across domains.", + "context_length": 400000, + "max_completion_tokens": 128000, + "supported_parameters": [ + "tools" + ], + "thinking": { + "levels": [ + "low", + "medium", + "high" + ] + } + }, + { + "id": "gpt-5-codex-mini", + "object": "model", + "created": 1762473600, + "owned_by": "openai", + "type": "openai", + "display_name": "GPT 5 Codex Mini", + "version": "gpt-5-2025-11-07", + "description": "Stable version of GPT 5 Codex Mini: cheaper, faster, but less capable version of GPT 5 Codex.", + "context_length": 400000, + "max_completion_tokens": 128000, + "supported_parameters": [ + "tools" + ], + "thinking": { + "levels": [ + "low", + "medium", + "high" + ] + } + }, + { + "id": "gpt-5.1", + "object": "model", + "created": 1762905600, + "owned_by": "openai", + "type": "openai", + "display_name": "GPT 5", + "version": "gpt-5.1-2025-11-12", + "description": "Stable version of GPT 5, The best model for coding and agentic tasks across domains.", + "context_length": 400000, + "max_completion_tokens": 128000, + "supported_parameters": [ + "tools" + ], + "thinking": { + "levels": [ + "none", + "low", + "medium", + "high" + ] + } + }, + { + "id": "gpt-5.1-codex", + "object": "model", + "created": 1762905600, + "owned_by": "openai", + "type": "openai", + "display_name": "GPT 5.1 Codex", + "version": "gpt-5.1-2025-11-12", + "description": "Stable version of GPT 5.1 Codex, The best model for coding and agentic tasks across domains.", + "context_length": 400000, + "max_completion_tokens": 128000, + "supported_parameters": [ + "tools" + ], + "thinking": { + "levels": [ + "low", + "medium", + "high" + ] + } + }, + { + "id": "gpt-5.1-codex-mini", + "object": "model", + "created": 1762905600, + "owned_by": "openai", + "type": "openai", + "display_name": "GPT 5.1 Codex Mini", + "version": "gpt-5.1-2025-11-12", + "description": "Stable version of GPT 5.1 Codex Mini: cheaper, faster, but less capable version of GPT 5.1 Codex.", + "context_length": 400000, + "max_completion_tokens": 128000, + "supported_parameters": [ + "tools" + ], + "thinking": { + "levels": [ + "low", + "medium", + "high" + ] + } + }, + { + "id": "gpt-5.1-codex-max", + "object": "model", + "created": 1763424000, + "owned_by": "openai", + "type": "openai", + "display_name": "GPT 5.1 Codex Max", + "version": "gpt-5.1-max", + "description": "Stable version of GPT 5.1 Codex Max", + "context_length": 400000, + "max_completion_tokens": 128000, + "supported_parameters": [ + "tools" + ], + "thinking": { + "levels": [ + "low", + "medium", + "high", + "xhigh" + ] + } + }, + { + "id": "gpt-5.2", + "object": "model", + "created": 1765440000, + "owned_by": "openai", + "type": "openai", + "display_name": "GPT 5.2", + "version": "gpt-5.2", + "description": "Stable version of GPT 5.2", + "context_length": 400000, + "max_completion_tokens": 128000, + "supported_parameters": [ + "tools" + ], + "thinking": { + "levels": [ + "none", + "low", + "medium", + "high", + "xhigh" + ] + } + }, + { + "id": "gpt-5.2-codex", + "object": "model", + "created": 1765440000, + "owned_by": "openai", + "type": "openai", + "display_name": "GPT 5.2 Codex", + "version": "gpt-5.2", + "description": "Stable version of GPT 5.2 Codex, The best model for coding and agentic tasks across domains.", + "context_length": 400000, + "max_completion_tokens": 128000, + "supported_parameters": [ + "tools" + ], + "thinking": { + "levels": [ + "low", + "medium", + "high", + "xhigh" + ] + } + }, + { + "id": "gpt-5.3-codex", + "object": "model", + "created": 1770307200, + "owned_by": "openai", + "type": "openai", + "display_name": "GPT 5.3 Codex", + "version": "gpt-5.3", + "description": "Stable version of GPT 5.3 Codex, The best model for coding and agentic tasks across domains.", + "context_length": 400000, + "max_completion_tokens": 128000, + "supported_parameters": [ + "tools" + ], + "thinking": { + "levels": [ + "low", + "medium", + "high", + "xhigh" + ] + } + }, + { + "id": "gpt-5.3-codex-spark", + "object": "model", + "created": 1770912000, + "owned_by": "openai", + "type": "openai", + "display_name": "GPT 5.3 Codex Spark", + "version": "gpt-5.3", + "description": "Ultra-fast coding model.", + "context_length": 128000, + "max_completion_tokens": 128000, + "supported_parameters": [ + "tools" + ], + "thinking": { + "levels": [ + "low", + "medium", + "high", + "xhigh" + ] + } + }, + { + "id": "gpt-5.4", + "object": "model", + "created": 1772668800, + "owned_by": "openai", + "type": "openai", + "display_name": "GPT 5.4", + "version": "gpt-5.4", + "description": "Stable version of GPT 5.4", + "context_length": 1050000, + "max_completion_tokens": 128000, + "supported_parameters": [ + "tools" + ], + "thinking": { + "levels": [ + "low", + "medium", + "high", + "xhigh" + ] + } + } + ], + "codex-pro": [ + { + "id": "gpt-5", + "object": "model", + "created": 1754524800, + "owned_by": "openai", + "type": "openai", + "display_name": "GPT 5", + "version": "gpt-5-2025-08-07", + "description": "Stable version of GPT 5, The best model for coding and agentic tasks across domains.", + "context_length": 400000, + "max_completion_tokens": 128000, + "supported_parameters": [ + "tools" + ], + "thinking": { + "levels": [ + "minimal", + "low", + "medium", + "high" + ] + } + }, + { + "id": "gpt-5-codex", + "object": "model", + "created": 1757894400, + "owned_by": "openai", + "type": "openai", + "display_name": "GPT 5 Codex", + "version": "gpt-5-2025-09-15", + "description": "Stable version of GPT 5 Codex, The best model for coding and agentic tasks across domains.", + "context_length": 400000, + "max_completion_tokens": 128000, + "supported_parameters": [ + "tools" + ], + "thinking": { + "levels": [ + "low", + "medium", + "high" + ] + } + }, + { + "id": "gpt-5-codex-mini", + "object": "model", + "created": 1762473600, + "owned_by": "openai", + "type": "openai", + "display_name": "GPT 5 Codex Mini", + "version": "gpt-5-2025-11-07", + "description": "Stable version of GPT 5 Codex Mini: cheaper, faster, but less capable version of GPT 5 Codex.", + "context_length": 400000, + "max_completion_tokens": 128000, + "supported_parameters": [ + "tools" + ], + "thinking": { + "levels": [ + "low", + "medium", + "high" + ] + } + }, + { + "id": "gpt-5.1", + "object": "model", + "created": 1762905600, + "owned_by": "openai", + "type": "openai", + "display_name": "GPT 5", + "version": "gpt-5.1-2025-11-12", + "description": "Stable version of GPT 5, The best model for coding and agentic tasks across domains.", + "context_length": 400000, + "max_completion_tokens": 128000, + "supported_parameters": [ + "tools" + ], + "thinking": { + "levels": [ + "none", + "low", + "medium", + "high" + ] + } + }, + { + "id": "gpt-5.1-codex", + "object": "model", + "created": 1762905600, + "owned_by": "openai", + "type": "openai", + "display_name": "GPT 5.1 Codex", + "version": "gpt-5.1-2025-11-12", + "description": "Stable version of GPT 5.1 Codex, The best model for coding and agentic tasks across domains.", + "context_length": 400000, + "max_completion_tokens": 128000, + "supported_parameters": [ + "tools" + ], + "thinking": { + "levels": [ + "low", + "medium", + "high" + ] + } + }, + { + "id": "gpt-5.1-codex-mini", + "object": "model", + "created": 1762905600, + "owned_by": "openai", + "type": "openai", + "display_name": "GPT 5.1 Codex Mini", + "version": "gpt-5.1-2025-11-12", + "description": "Stable version of GPT 5.1 Codex Mini: cheaper, faster, but less capable version of GPT 5.1 Codex.", + "context_length": 400000, + "max_completion_tokens": 128000, + "supported_parameters": [ + "tools" + ], + "thinking": { + "levels": [ + "low", + "medium", + "high" + ] + } + }, + { + "id": "gpt-5.1-codex-max", + "object": "model", + "created": 1763424000, + "owned_by": "openai", + "type": "openai", + "display_name": "GPT 5.1 Codex Max", + "version": "gpt-5.1-max", + "description": "Stable version of GPT 5.1 Codex Max", + "context_length": 400000, + "max_completion_tokens": 128000, + "supported_parameters": [ + "tools" + ], + "thinking": { + "levels": [ + "low", + "medium", + "high", + "xhigh" + ] + } + }, + { + "id": "gpt-5.2", + "object": "model", + "created": 1765440000, + "owned_by": "openai", + "type": "openai", + "display_name": "GPT 5.2", + "version": "gpt-5.2", + "description": "Stable version of GPT 5.2", + "context_length": 400000, + "max_completion_tokens": 128000, + "supported_parameters": [ + "tools" + ], + "thinking": { + "levels": [ + "none", + "low", + "medium", + "high", + "xhigh" + ] + } + }, + { + "id": "gpt-5.2-codex", + "object": "model", + "created": 1765440000, + "owned_by": "openai", + "type": "openai", + "display_name": "GPT 5.2 Codex", + "version": "gpt-5.2", + "description": "Stable version of GPT 5.2 Codex, The best model for coding and agentic tasks across domains.", + "context_length": 400000, + "max_completion_tokens": 128000, + "supported_parameters": [ + "tools" + ], + "thinking": { + "levels": [ + "low", + "medium", + "high", + "xhigh" + ] + } + }, + { + "id": "gpt-5.3-codex", + "object": "model", + "created": 1770307200, + "owned_by": "openai", + "type": "openai", + "display_name": "GPT 5.3 Codex", + "version": "gpt-5.3", + "description": "Stable version of GPT 5.3 Codex, The best model for coding and agentic tasks across domains.", + "context_length": 400000, + "max_completion_tokens": 128000, + "supported_parameters": [ + "tools" + ], + "thinking": { + "levels": [ + "low", + "medium", + "high", + "xhigh" + ] + } + }, + { + "id": "gpt-5.3-codex-spark", + "object": "model", + "created": 1770912000, + "owned_by": "openai", + "type": "openai", + "display_name": "GPT 5.3 Codex Spark", + "version": "gpt-5.3", + "description": "Ultra-fast coding model.", + "context_length": 128000, + "max_completion_tokens": 128000, + "supported_parameters": [ + "tools" + ], + "thinking": { + "levels": [ + "low", + "medium", + "high", + "xhigh" + ] + } + }, + { + "id": "gpt-5.4", + "object": "model", + "created": 1772668800, + "owned_by": "openai", + "type": "openai", + "display_name": "GPT 5.4", + "version": "gpt-5.4", + "description": "Stable version of GPT 5.4", + "context_length": 1050000, + "max_completion_tokens": 128000, + "supported_parameters": [ + "tools" + ], + "thinking": { + "levels": [ + "low", + "medium", + "high", + "xhigh" + ] + } + } + ] +} diff --git a/internal/watcher/synthesizer/file.go b/internal/watcher/synthesizer/file.go index 02a0cefac8..8a75dda08d 100644 --- a/internal/watcher/synthesizer/file.go +++ b/internal/watcher/synthesizer/file.go @@ -10,6 +10,7 @@ import ( "strings" "time" + "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" "github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/geminicli" coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" ) @@ -136,6 +137,11 @@ func synthesizeFileAuths(ctx *SynthesisContext, fullPath string, data []byte) [] CreatedAt: now, UpdatedAt: now, } + if provider == "codex" { + if plan, _ := registry.EnsureCodexPlanTypeMetadata(metadata); plan != "" { + a.Attributes["plan_type"] = plan + } + } // Read priority from auth file. if rawPriority, ok := metadata["priority"]; ok { switch v := rawPriority.(type) { diff --git a/sdk/auth/codex_device.go b/sdk/auth/codex_device.go index 78a95af801..ad99e1bb74 100644 --- a/sdk/auth/codex_device.go +++ b/sdk/auth/codex_device.go @@ -275,6 +275,9 @@ func (a *CodexAuthenticator) buildAuthRecord(authSvc *codex.CodexAuth, authBundl metadata := map[string]any{ "email": tokenStorage.Email, } + if planType != "" { + metadata["plan_type"] = planType + } fmt.Println("Codex authentication successful") if authBundle.APIKey != "" { diff --git a/sdk/auth/filestore.go b/sdk/auth/filestore.go index 987d305e88..ec87f34f3d 100644 --- a/sdk/auth/filestore.go +++ b/sdk/auth/filestore.go @@ -15,6 +15,7 @@ import ( "sync" "time" + "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" ) @@ -197,6 +198,16 @@ func (s *FileTokenStore) readAuthFile(path, baseDir string) (*cliproxyauth.Auth, if provider == "" { provider = "unknown" } + if provider == "codex" { + if _, changed := registry.EnsureCodexPlanTypeMetadata(metadata); changed { + if raw, errMarshal := json.Marshal(metadata); errMarshal == nil { + if file, errOpen := os.OpenFile(path, os.O_WRONLY|os.O_TRUNC, 0o600); errOpen == nil { + _, _ = file.Write(raw) + _ = file.Close() + } + } + } + } if provider == "antigravity" || provider == "gemini" { projectID := "" if pid, ok := metadata["project_id"].(string); ok { @@ -254,6 +265,11 @@ func (s *FileTokenStore) readAuthFile(path, baseDir string) (*cliproxyauth.Auth, if email, ok := metadata["email"].(string); ok && email != "" { auth.Attributes["email"] = email } + if provider == "codex" { + if plan := registry.ResolveCodexPlanType(auth.Attributes, metadata); plan != "" { + auth.Attributes["plan_type"] = plan + } + } return auth, nil } diff --git a/sdk/auth/filestore_codex_plan_test.go b/sdk/auth/filestore_codex_plan_test.go new file mode 100644 index 0000000000..0dd78845f7 --- /dev/null +++ b/sdk/auth/filestore_codex_plan_test.go @@ -0,0 +1,66 @@ +package auth + +import ( + "encoding/base64" + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestReadAuthFile_CodexRecoversPlanTypeFromIDToken(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "codex-user.json") + raw := map[string]any{ + "type": "codex", + "email": "tester@example.com", + "id_token": testCodexJWT(t, "plus"), + } + payload, err := json.Marshal(raw) + if err != nil { + t.Fatalf("marshal auth file: %v", err) + } + if err := os.WriteFile(path, payload, 0o600); err != nil { + t.Fatalf("write auth file: %v", err) + } + + store := NewFileTokenStore() + auth, err := store.readAuthFile(path, dir) + if err != nil { + t.Fatalf("read auth file: %v", err) + } + if auth == nil { + t.Fatal("expected auth") + } + if got := auth.Attributes["plan_type"]; got != "plus" { + t.Fatalf("expected plan_type plus, got %q", got) + } + updated, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read updated auth file: %v", err) + } + var metadata map[string]any + if err := json.Unmarshal(updated, &metadata); err != nil { + t.Fatalf("unmarshal updated auth file: %v", err) + } + if got, _ := metadata["plan_type"].(string); got != "plus" { + t.Fatalf("expected persisted plan_type plus, got %#v", metadata["plan_type"]) + } +} + +func testCodexJWT(t *testing.T, planType string) string { + t.Helper() + header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none","typ":"JWT"}`)) + payloadRaw, err := json.Marshal(map[string]any{ + "email": "tester@example.com", + "https://api.openai.com/auth": map[string]any{ + "chatgpt_account_id": "acct_123", + "chatgpt_plan_type": planType, + }, + }) + if err != nil { + t.Fatalf("marshal payload: %v", err) + } + payload := base64.RawURLEncoding.EncodeToString(payloadRaw) + return header + "." + payload + ".signature" +} diff --git a/sdk/cliproxy/service.go b/sdk/cliproxy/service.go index 10cc35f3d8..6b1306f7df 100644 --- a/sdk/cliproxy/service.go +++ b/sdk/cliproxy/service.go @@ -454,6 +454,19 @@ func (s *Service) rebindExecutors() { } } +func (s *Service) rebindCodexModels() { + if s == nil || s.coreManager == nil { + return + } + for _, auth := range s.coreManager.List() { + if auth == nil || !strings.EqualFold(strings.TrimSpace(auth.Provider), "codex") { + continue + } + s.registerModelsForAuth(auth) + s.coreManager.RefreshSchedulerEntry(auth.ID) + } +} + // Run starts the service and blocks until the context is cancelled or the server stops. // It initializes all components including authentication, file watching, HTTP server, // and starts processing requests. The method blocks until the context is cancelled. @@ -633,6 +646,11 @@ func (s *Service) Run(ctx context.Context) error { return fmt.Errorf("cliproxy: failed to start watcher: %w", err) } log.Info("file watcher started for config and auth directory changes") + if s.cfg != nil { + registry.StartCodexModelsUpdater(ctx, &s.cfg.SDKConfig, func() { + s.rebindCodexModels() + }) + } // Prefer core auth manager auto refresh if available. if s.coreManager != nil { @@ -829,14 +847,27 @@ func (s *Service) registerModelsForAuth(a *coreauth.Auth) { } models = applyExcludedModels(models, excluded) case "codex": - models = registry.GetOpenAIModels() if entry := s.resolveConfigCodexKey(a); entry != nil { if len(entry.Models) > 0 { models = buildCodexConfigModels(entry) + } else { + models = registry.GetCodexModelsUnion() } if authKind == "apikey" { excluded = entry.ExcludedModels } + } else { + codexPlanType := registry.ResolveCodexPlanType(a.Attributes, a.Metadata) + if a.Attributes == nil { + a.Attributes = make(map[string]string) + } + if codexPlanType != "" { + a.Attributes["plan_type"] = codexPlanType + models = registry.GetCodexModelsForPlan(codexPlanType) + } else { + delete(a.Attributes, "plan_type") + models = registry.GetCodexModelsUnion() + } } models = applyExcludedModels(models, excluded) case "qwen": diff --git a/sdk/cliproxy/service_codex_models_test.go b/sdk/cliproxy/service_codex_models_test.go new file mode 100644 index 0000000000..d89ac034fa --- /dev/null +++ b/sdk/cliproxy/service_codex_models_test.go @@ -0,0 +1,146 @@ +package cliproxy + +import ( + "testing" + + internalconfig "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" + coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" + "github.com/router-for-me/CLIProxyAPI/v6/sdk/config" +) + +func TestRegisterModelsForAuth_CodexFreePlanSkipsHigherTierModels(t *testing.T) { + service := &Service{cfg: &config.Config{}} + auth := &coreauth.Auth{ + ID: "auth-codex-free", + Provider: "codex", + Status: coreauth.StatusActive, + Metadata: map[string]any{"plan_type": "free"}, + } + models := registerCodexModelsForTest(t, service, auth) + assertMissingModel(t, models, "gpt-5.3-codex") + assertMissingModel(t, models, "gpt-5.4") + assertMissingModel(t, models, "gpt-5.3-codex-spark") +} + +func TestRegisterModelsForAuth_CodexTeamPlanIncludes54ButNotSpark(t *testing.T) { + service := &Service{cfg: &config.Config{}} + auth := &coreauth.Auth{ + ID: "auth-codex-team", + Provider: "codex", + Status: coreauth.StatusActive, + Metadata: map[string]any{"plan_type": "team"}, + } + models := registerCodexModelsForTest(t, service, auth) + assertHasModel(t, models, "gpt-5.3-codex") + assertHasModel(t, models, "gpt-5.4") + assertMissingModel(t, models, "gpt-5.3-codex-spark") +} + +func TestRegisterModelsForAuth_CodexPlusPlanIncludesSpark(t *testing.T) { + service := &Service{cfg: &config.Config{}} + auth := &coreauth.Auth{ + ID: "auth-codex-plus", + Provider: "codex", + Status: coreauth.StatusActive, + Metadata: map[string]any{"plan_type": "plus"}, + } + models := registerCodexModelsForTest(t, service, auth) + assertHasModel(t, models, "gpt-5.3-codex") + assertHasModel(t, models, "gpt-5.4") + assertHasModel(t, models, "gpt-5.3-codex-spark") +} + +func TestRegisterModelsForAuth_CodexUnknownPlanFallsBackToUnion(t *testing.T) { + service := &Service{cfg: &config.Config{}} + auth := &coreauth.Auth{ + ID: "auth-codex-unknown", + Provider: "codex", + Status: coreauth.StatusActive, + Metadata: map[string]any{}, + } + models := registerCodexModelsForTest(t, service, auth) + assertHasModel(t, models, "gpt-5.3-codex") + assertHasModel(t, models, "gpt-5.4") + assertHasModel(t, models, "gpt-5.3-codex-spark") +} + +func TestRegisterModelsForAuth_CodexAPIKeyWithoutModelsUsesUnion(t *testing.T) { + service := &Service{ + cfg: &config.Config{ + CodexKey: []config.CodexKey{{ + APIKey: "sk-test", + }}, + }, + } + auth := &coreauth.Auth{ + ID: "auth-codex-apikey", + Provider: "codex", + Status: coreauth.StatusActive, + Attributes: map[string]string{ + "auth_kind": "apikey", + "api_key": "sk-test", + }, + Metadata: map[string]any{}, + } + models := registerCodexModelsForTest(t, service, auth) + assertHasModel(t, models, "gpt-5.3-codex") + assertHasModel(t, models, "gpt-5.4") + assertHasModel(t, models, "gpt-5.3-codex-spark") +} + +func TestRegisterModelsForAuth_CodexAPIKeyExplicitModelsOverrideUnion(t *testing.T) { + service := &Service{ + cfg: &config.Config{ + CodexKey: []config.CodexKey{{ + APIKey: "sk-test-explicit", + Models: []internalconfig.CodexModel{{ + Name: "gpt-5.4", + Alias: "gpt-5.4", + }}, + }}, + }, + } + auth := &coreauth.Auth{ + ID: "auth-codex-apikey-explicit", + Provider: "codex", + Status: coreauth.StatusActive, + Attributes: map[string]string{ + "auth_kind": "apikey", + "api_key": "sk-test-explicit", + }, + Metadata: map[string]any{}, + } + models := registerCodexModelsForTest(t, service, auth) + assertHasModel(t, models, "gpt-5.4") + assertMissingModel(t, models, "gpt-5.3-codex") + assertMissingModel(t, models, "gpt-5.3-codex-spark") +} + +func registerCodexModelsForTest(t *testing.T, service *Service, auth *coreauth.Auth) []*registry.ModelInfo { + t.Helper() + reg := registry.GetGlobalRegistry() + reg.UnregisterClient(auth.ID) + t.Cleanup(func() { reg.UnregisterClient(auth.ID) }) + service.registerModelsForAuth(auth) + return reg.GetModelsForClient(auth.ID) +} + +func assertHasModel(t *testing.T, models []*registry.ModelInfo, id string) { + t.Helper() + for _, model := range models { + if model != nil && model.ID == id { + return + } + } + t.Fatalf("expected model %q, got %+v", id, models) +} + +func assertMissingModel(t *testing.T, models []*registry.ModelInfo, id string) { + t.Helper() + for _, model := range models { + if model != nil && model.ID == id { + t.Fatalf("did not expect model %q, got %+v", id, models) + } + } +} From 3834baf1c7cc092afe0f8fbf50986ca2caf4e029 Mon Sep 17 00:00:00 2001 From: excel Date: Wed, 11 Mar 2026 00:24:23 +0800 Subject: [PATCH 2/2] fix(codex): tighten startup refresh and filestore logging --- internal/registry/model_updater.go | 22 +++++++---- internal/registry/model_updater_test.go | 51 +++++++++++++++++++++++++ sdk/auth/filestore.go | 20 ++++++++-- 3 files changed, 83 insertions(+), 10 deletions(-) diff --git a/internal/registry/model_updater.go b/internal/registry/model_updater.go index 53cfda1f94..5f8d1f7c5b 100644 --- a/internal/registry/model_updater.go +++ b/internal/registry/model_updater.go @@ -34,6 +34,7 @@ type modelStore struct { var modelsCatalogStore = &modelStore{} var updaterOnce sync.Once +var updaterDone = make(chan struct{}) func init() { // Load embedded data as fallback on startup. @@ -44,22 +45,29 @@ func init() { // StartModelsUpdater runs a one-time models refresh on startup. // It returns immediately and performs the network refresh in the background. -// onUpdated is invoked once after the startup attempt finishes, even when the -// refresh falls back to the embedded catalog. +// Every invocation registers an onUpdated callback for the same one-time startup +// attempt, so callers added after initialization still observe completion. +// Callbacks also run when the refresh falls back to the embedded catalog. // Safe to call multiple times; only one refresh will run. func StartModelsUpdater(ctx context.Context, onUpdated func()) { updaterOnce.Do(func() { - go runModelsUpdater(ctx, onUpdated) + go func() { + defer close(updaterDone) + runModelsUpdater(ctx) + }() }) + if onUpdated != nil { + go func() { + <-updaterDone + onUpdated() + }() + } } -func runModelsUpdater(ctx context.Context, onUpdated func()) { +func runModelsUpdater(ctx context.Context) { // Try network fetch once on startup, then stop. // Periodic refresh is disabled - models are only refreshed at startup. tryRefreshModels(ctx) - if onUpdated != nil { - onUpdated() - } } func tryRefreshModels(ctx context.Context) { diff --git a/internal/registry/model_updater_test.go b/internal/registry/model_updater_test.go index 72371f5492..b513552bf4 100644 --- a/internal/registry/model_updater_test.go +++ b/internal/registry/model_updater_test.go @@ -18,11 +18,14 @@ func TestStartModelsUpdaterReturnsImmediatelyAndInvokesCallback(t *testing.T) { oldURLs := modelsURLs oldOnce := updaterOnce + oldDone := updaterDone modelsURLs = []string{server.URL} updaterOnce = sync.Once{} + updaterDone = make(chan struct{}) t.Cleanup(func() { modelsURLs = oldURLs updaterOnce = oldOnce + updaterDone = oldDone }) updated := make(chan struct{}, 1) @@ -52,11 +55,14 @@ func TestStartModelsUpdaterInvokesCallbackOnRefreshFailure(t *testing.T) { oldURLs := modelsURLs oldOnce := updaterOnce + oldDone := updaterDone modelsURLs = []string{server.URL} updaterOnce = sync.Once{} + updaterDone = make(chan struct{}) t.Cleanup(func() { modelsURLs = oldURLs updaterOnce = oldOnce + updaterDone = oldDone }) updated := make(chan struct{}, 1) @@ -73,3 +79,48 @@ func TestStartModelsUpdaterInvokesCallbackOnRefreshFailure(t *testing.T) { t.Fatal("expected callback even when startup refresh falls back to embedded models") } } + +func TestStartModelsUpdaterNotifiesCallbacksRegisteredAfterStart(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(150 * time.Millisecond) + _, _ = w.Write(embeddedModelsJSON) + })) + defer server.Close() + + oldURLs := modelsURLs + oldOnce := updaterOnce + oldDone := updaterDone + modelsURLs = []string{server.URL} + updaterOnce = sync.Once{} + updaterDone = make(chan struct{}) + t.Cleanup(func() { + modelsURLs = oldURLs + updaterOnce = oldOnce + updaterDone = oldDone + }) + + first := make(chan struct{}, 1) + second := make(chan struct{}, 1) + + StartModelsUpdater(context.Background(), func() { + select { + case first <- struct{}{}: + default: + } + }) + time.Sleep(25 * time.Millisecond) + StartModelsUpdater(context.Background(), func() { + select { + case second <- struct{}{}: + default: + } + }) + + for name, ch := range map[string]chan struct{}{"first": first, "second": second} { + select { + case <-ch: + case <-time.After(2 * time.Second): + t.Fatalf("expected %s callback to fire", name) + } + } +} diff --git a/sdk/auth/filestore.go b/sdk/auth/filestore.go index ec87f34f3d..37ea356295 100644 --- a/sdk/auth/filestore.go +++ b/sdk/auth/filestore.go @@ -17,6 +17,7 @@ import ( "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" + log "github.com/sirupsen/logrus" ) // FileTokenStore persists token records and auth metadata using the filesystem as backing storage. @@ -201,10 +202,11 @@ func (s *FileTokenStore) readAuthFile(path, baseDir string) (*cliproxyauth.Auth, if provider == "codex" { if _, changed := registry.EnsureCodexPlanTypeMetadata(metadata); changed { if raw, errMarshal := json.Marshal(metadata); errMarshal == nil { - if file, errOpen := os.OpenFile(path, os.O_WRONLY|os.O_TRUNC, 0o600); errOpen == nil { - _, _ = file.Write(raw) - _ = file.Close() + if errPersist := persistNormalizedMetadata(path, raw); errPersist != nil { + log.Warnf("auth filestore: failed to persist normalized codex plan_type for %s: %v", path, errPersist) } + } else { + log.Warnf("auth filestore: failed to marshal normalized codex metadata for %s: %v", path, errMarshal) } } } @@ -273,6 +275,18 @@ func (s *FileTokenStore) readAuthFile(path, baseDir string) (*cliproxyauth.Auth, return auth, nil } +func persistNormalizedMetadata(path string, raw []byte) error { + file, err := os.OpenFile(path, os.O_WRONLY|os.O_TRUNC, 0o600) + if err != nil { + return err + } + if _, errWrite := file.Write(raw); errWrite != nil { + _ = file.Close() + return errWrite + } + return file.Close() +} + func (s *FileTokenStore) idFor(path, baseDir string) string { id := path if baseDir != "" {