From f8cebb934361267f33010f1524545ccf9267ba5b Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Thu, 27 Nov 2025 21:57:07 +0800 Subject: [PATCH 01/15] feat(config): add per-key model blacklist for providers --- config.example.yaml | 6 +++ internal/config/config.go | 9 ++++ internal/watcher/watcher.go | 31 ++++++++++++ sdk/cliproxy/service.go | 94 ++++++++++++++++++++++++++++++++++++- 4 files changed, 138 insertions(+), 2 deletions(-) diff --git a/config.example.yaml b/config.example.yaml index 8457e103..02e085b1 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -62,6 +62,8 @@ ws-auth: false # headers: # X-Custom-Header: "custom-value" # proxy-url: "socks5://proxy.example.com:1080" +# model-blacklist: +# - "gemini-2.0-pro-exp" # exclude specific models from this provider # - api-key: "AIzaSy...02" # API keys for official Generative Language API (legacy compatibility) @@ -76,6 +78,8 @@ ws-auth: false # headers: # X-Custom-Header: "custom-value" # proxy-url: "socks5://proxy.example.com:1080" # optional: per-key proxy override +# model-blacklist: +# - "gpt-5" # exclude specific models from this provider # Claude API keys #claude-api-key: @@ -88,6 +92,8 @@ ws-auth: false # models: # - name: "claude-3-5-sonnet-20241022" # upstream model name # alias: "claude-sonnet-latest" # client alias mapped to the upstream model +# model-blacklist: +# - "claude-3-5-sonnet-20241022" # exclude specific models from this provider # OpenAI compatibility providers #openai-compatibility: diff --git a/internal/config/config.go b/internal/config/config.go index 31920075..0e1e3966 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -157,6 +157,9 @@ type ClaudeKey struct { // Headers optionally adds extra HTTP headers for requests sent with this key. Headers map[string]string `yaml:"headers,omitempty" json:"headers,omitempty"` + + // ModelBlacklist lists model IDs that should be excluded for this provider. + ModelBlacklist []string `yaml:"model-blacklist,omitempty" json:"model-blacklist,omitempty"` } // ClaudeModel describes a mapping between an alias and the actual upstream model name. @@ -183,6 +186,9 @@ type CodexKey struct { // Headers optionally adds extra HTTP headers for requests sent with this key. Headers map[string]string `yaml:"headers,omitempty" json:"headers,omitempty"` + + // ModelBlacklist lists model IDs that should be excluded for this provider. + ModelBlacklist []string `yaml:"model-blacklist,omitempty" json:"model-blacklist,omitempty"` } // GeminiKey represents the configuration for a Gemini API key, @@ -199,6 +205,9 @@ type GeminiKey struct { // Headers optionally adds extra HTTP headers for requests sent with this key. Headers map[string]string `yaml:"headers,omitempty" json:"headers,omitempty"` + + // ModelBlacklist lists model IDs that should be excluded for this provider. + ModelBlacklist []string `yaml:"model-blacklist,omitempty" json:"model-blacklist,omitempty"` } // OpenAICompatibility represents the configuration for OpenAI API compatibility diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index 0d955064..54789524 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -450,6 +450,28 @@ func computeClaudeModelsHash(models []config.ClaudeModel) string { return hex.EncodeToString(sum[:]) } +func computeModelBlacklistHash(blacklist []string) string { + if len(blacklist) == 0 { + return "" + } + normalized := make([]string, 0, len(blacklist)) + for _, entry := range blacklist { + if trimmed := strings.TrimSpace(entry); trimmed != "" { + normalized = append(normalized, strings.ToLower(trimmed)) + } + } + if len(normalized) == 0 { + return "" + } + sort.Strings(normalized) + data, err := json.Marshal(normalized) + if err != nil || len(data) == 0 { + return "" + } + sum := sha256.Sum256(data) + return hex.EncodeToString(sum[:]) +} + // SetClients sets the file-based clients. // SetClients removed // SetAPIKeyClients removed @@ -838,6 +860,9 @@ func (w *Watcher) SnapshotCoreAuths() []*coreauth.Auth { if base != "" { attrs["base_url"] = base } + if hash := computeModelBlacklistHash(entry.ModelBlacklist); hash != "" { + attrs["model_blacklist_hash"] = hash + } addConfigHeadersToAttrs(entry.Headers, attrs) a := &coreauth.Auth{ ID: id, @@ -870,6 +895,9 @@ func (w *Watcher) SnapshotCoreAuths() []*coreauth.Auth { if hash := computeClaudeModelsHash(ck.Models); hash != "" { attrs["models_hash"] = hash } + if hash := computeModelBlacklistHash(ck.ModelBlacklist); hash != "" { + attrs["model_blacklist_hash"] = hash + } addConfigHeadersToAttrs(ck.Headers, attrs) proxyURL := strings.TrimSpace(ck.ProxyURL) a := &coreauth.Auth{ @@ -899,6 +927,9 @@ func (w *Watcher) SnapshotCoreAuths() []*coreauth.Auth { if ck.BaseURL != "" { attrs["base_url"] = ck.BaseURL } + if hash := computeModelBlacklistHash(ck.ModelBlacklist); hash != "" { + attrs["model_blacklist_hash"] = hash + } addConfigHeadersToAttrs(ck.Headers, attrs) proxyURL := strings.TrimSpace(ck.ProxyURL) a := &coreauth.Auth{ diff --git a/sdk/cliproxy/service.go b/sdk/cliproxy/service.go index 6e303ed2..0405c6ae 100644 --- a/sdk/cliproxy/service.go +++ b/sdk/cliproxy/service.go @@ -640,6 +640,9 @@ func (s *Service) registerModelsForAuth(a *coreauth.Auth) { switch provider { case "gemini": models = registry.GetGeminiModels() + if entry := s.resolveConfigGeminiKey(a); entry != nil { + models = applyModelBlacklist(models, entry.ModelBlacklist) + } case "vertex": // Vertex AI Gemini supports the same model identifiers as Gemini. models = registry.GetGeminiVertexModels() @@ -653,11 +656,17 @@ func (s *Service) registerModelsForAuth(a *coreauth.Auth) { cancel() case "claude": models = registry.GetClaudeModels() - if entry := s.resolveConfigClaudeKey(a); entry != nil && len(entry.Models) > 0 { - models = buildClaudeConfigModels(entry) + if entry := s.resolveConfigClaudeKey(a); entry != nil { + if len(entry.Models) > 0 { + models = buildClaudeConfigModels(entry) + } + models = applyModelBlacklist(models, entry.ModelBlacklist) } case "codex": models = registry.GetOpenAIModels() + if entry := s.resolveConfigCodexKey(a); entry != nil { + models = applyModelBlacklist(models, entry.ModelBlacklist) + } case "qwen": models = registry.GetQwenModels() case "iflow": @@ -749,7 +758,10 @@ func (s *Service) registerModelsForAuth(a *coreauth.Auth) { key = strings.ToLower(strings.TrimSpace(a.Provider)) } GlobalModelRegistry().RegisterClient(a.ID, key, models) + return } + + GlobalModelRegistry().UnregisterClient(a.ID) } func (s *Service) resolveConfigClaudeKey(auth *coreauth.Auth) *config.ClaudeKey { @@ -791,6 +803,84 @@ func (s *Service) resolveConfigClaudeKey(auth *coreauth.Auth) *config.ClaudeKey return nil } +func (s *Service) resolveConfigGeminiKey(auth *coreauth.Auth) *config.GeminiKey { + if auth == nil || s.cfg == nil { + return nil + } + var attrKey, attrBase string + if auth.Attributes != nil { + attrKey = strings.TrimSpace(auth.Attributes["api_key"]) + attrBase = strings.TrimSpace(auth.Attributes["base_url"]) + } + for i := range s.cfg.GeminiKey { + entry := &s.cfg.GeminiKey[i] + cfgKey := strings.TrimSpace(entry.APIKey) + cfgBase := strings.TrimSpace(entry.BaseURL) + if attrKey != "" && strings.EqualFold(cfgKey, attrKey) { + if cfgBase == "" || strings.EqualFold(cfgBase, attrBase) { + return entry + } + continue + } + if attrKey == "" && attrBase != "" && strings.EqualFold(cfgBase, attrBase) { + return entry + } + } + return nil +} + +func (s *Service) resolveConfigCodexKey(auth *coreauth.Auth) *config.CodexKey { + if auth == nil || s.cfg == nil { + return nil + } + var attrKey, attrBase string + if auth.Attributes != nil { + attrKey = strings.TrimSpace(auth.Attributes["api_key"]) + attrBase = strings.TrimSpace(auth.Attributes["base_url"]) + } + for i := range s.cfg.CodexKey { + entry := &s.cfg.CodexKey[i] + cfgKey := strings.TrimSpace(entry.APIKey) + cfgBase := strings.TrimSpace(entry.BaseURL) + if attrKey != "" && strings.EqualFold(cfgKey, attrKey) { + if cfgBase == "" || strings.EqualFold(cfgBase, attrBase) { + return entry + } + continue + } + if attrKey == "" && attrBase != "" && strings.EqualFold(cfgBase, attrBase) { + return entry + } + } + return nil +} + +func applyModelBlacklist(models []*ModelInfo, blacklist []string) []*ModelInfo { + if len(models) == 0 || len(blacklist) == 0 { + return models + } + blocked := make(map[string]struct{}, len(blacklist)) + for _, item := range blacklist { + if trimmed := strings.TrimSpace(item); trimmed != "" { + blocked[strings.ToLower(trimmed)] = struct{}{} + } + } + if len(blocked) == 0 { + return models + } + filtered := make([]*ModelInfo, 0, len(models)) + for _, model := range models { + if model == nil { + continue + } + if _, blockedModel := blocked[strings.ToLower(strings.TrimSpace(model.ID))]; blockedModel { + continue + } + filtered = append(filtered, model) + } + return filtered +} + func buildClaudeConfigModels(entry *config.ClaudeKey) []*ModelInfo { if entry == nil || len(entry.Models) == 0 { return nil From 5983e3ec8748975a5016c3e3669eab53ec314404 Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Fri, 28 Nov 2025 10:37:10 +0800 Subject: [PATCH 02/15] feat(auth): add oauth provider model blacklist --- config.example.yaml | 19 +++++++++++++ internal/config/config.go | 3 ++ internal/watcher/watcher.go | 56 +++++++++++++++++++++++++++++++------ sdk/cliproxy/service.go | 36 ++++++++++++++++++++++-- 4 files changed, 102 insertions(+), 12 deletions(-) diff --git a/config.example.yaml b/config.example.yaml index 02e085b1..0ef2121f 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -127,3 +127,22 @@ ws-auth: false # protocol: "codex" # restricts the rule to a specific protocol, options: openai, gemini, claude, codex # params: # JSON path (gjson/sjson syntax) -> value # "reasoning.effort": "high" + +# OAuth provider model blacklist +#oauth-model-blacklist: +# gemini-cli: +# - "gemini-3-pro-preview" +# vertex: +# - "gemini-3-pro-preview" +# aistudio: +# - "gemini-3-pro-preview" +# antigravity: +# - "gemini-3-pro-preview" +# claude: +# - "claude-3-5-haiku-20241022" +# codex: +# - "gpt-5-codex-mini" +# qwen: +# - "vision-model" +# iflow: +# - "tstars2.0" diff --git a/internal/config/config.go b/internal/config/config.go index 0e1e3966..67fb9e99 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -83,6 +83,9 @@ type Config struct { // Payload defines default and override rules for provider payload parameters. Payload PayloadConfig `yaml:"payload" json:"payload"` + + // OAuthModelBlacklist defines per-provider global model blacklists applied to OAuth/file-backed auth entries. + OAuthModelBlacklist map[string][]string `yaml:"oauth-model-blacklist,omitempty" json:"oauth-model-blacklist,omitempty"` } // TLSConfig holds HTTPS server settings. diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index 54789524..5f035718 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -472,6 +472,46 @@ func computeModelBlacklistHash(blacklist []string) string { return hex.EncodeToString(sum[:]) } +func applyAuthModelBlacklistMeta(auth *coreauth.Auth, cfg *config.Config, perKey []string, authKind string) { + if auth == nil || cfg == nil { + return + } + authKindKey := strings.ToLower(strings.TrimSpace(authKind)) + seen := make(map[string]struct{}) + add := func(list []string) { + for _, entry := range list { + if trimmed := strings.TrimSpace(entry); trimmed != "" { + key := strings.ToLower(trimmed) + if _, exists := seen[key]; exists { + continue + } + seen[key] = struct{}{} + } + } + } + if authKindKey == "apikey" { + add(perKey) + } else if cfg.OAuthModelBlacklist != nil { + providerKey := strings.ToLower(strings.TrimSpace(auth.Provider)) + add(cfg.OAuthModelBlacklist[providerKey]) + } + combined := make([]string, 0, len(seen)) + for k := range seen { + combined = append(combined, k) + } + sort.Strings(combined) + hash := computeModelBlacklistHash(combined) + if auth.Attributes == nil { + auth.Attributes = make(map[string]string) + } + if hash != "" { + auth.Attributes["model_blacklist_hash"] = hash + } + if authKind != "" { + auth.Attributes["auth_kind"] = authKind + } +} + // SetClients sets the file-based clients. // SetClients removed // SetAPIKeyClients removed @@ -860,9 +900,6 @@ func (w *Watcher) SnapshotCoreAuths() []*coreauth.Auth { if base != "" { attrs["base_url"] = base } - if hash := computeModelBlacklistHash(entry.ModelBlacklist); hash != "" { - attrs["model_blacklist_hash"] = hash - } addConfigHeadersToAttrs(entry.Headers, attrs) a := &coreauth.Auth{ ID: id, @@ -874,6 +911,7 @@ func (w *Watcher) SnapshotCoreAuths() []*coreauth.Auth { CreatedAt: now, UpdatedAt: now, } + applyAuthModelBlacklistMeta(a, cfg, entry.ModelBlacklist, "apikey") out = append(out, a) } // Claude API keys -> synthesize auths @@ -895,9 +933,6 @@ func (w *Watcher) SnapshotCoreAuths() []*coreauth.Auth { if hash := computeClaudeModelsHash(ck.Models); hash != "" { attrs["models_hash"] = hash } - if hash := computeModelBlacklistHash(ck.ModelBlacklist); hash != "" { - attrs["model_blacklist_hash"] = hash - } addConfigHeadersToAttrs(ck.Headers, attrs) proxyURL := strings.TrimSpace(ck.ProxyURL) a := &coreauth.Auth{ @@ -910,6 +945,7 @@ func (w *Watcher) SnapshotCoreAuths() []*coreauth.Auth { CreatedAt: now, UpdatedAt: now, } + applyAuthModelBlacklistMeta(a, cfg, ck.ModelBlacklist, "apikey") out = append(out, a) } // Codex API keys -> synthesize auths @@ -927,9 +963,6 @@ func (w *Watcher) SnapshotCoreAuths() []*coreauth.Auth { if ck.BaseURL != "" { attrs["base_url"] = ck.BaseURL } - if hash := computeModelBlacklistHash(ck.ModelBlacklist); hash != "" { - attrs["model_blacklist_hash"] = hash - } addConfigHeadersToAttrs(ck.Headers, attrs) proxyURL := strings.TrimSpace(ck.ProxyURL) a := &coreauth.Auth{ @@ -942,6 +975,7 @@ func (w *Watcher) SnapshotCoreAuths() []*coreauth.Auth { CreatedAt: now, UpdatedAt: now, } + applyAuthModelBlacklistMeta(a, cfg, ck.ModelBlacklist, "apikey") out = append(out, a) } for i := range cfg.OpenAICompatibility { @@ -1102,8 +1136,12 @@ func (w *Watcher) SnapshotCoreAuths() []*coreauth.Auth { CreatedAt: now, UpdatedAt: now, } + applyAuthModelBlacklistMeta(a, cfg, nil, "oauth") if provider == "gemini-cli" { if virtuals := synthesizeGeminiVirtualAuths(a, metadata, now); len(virtuals) > 0 { + for _, v := range virtuals { + applyAuthModelBlacklistMeta(v, cfg, nil, "oauth") + } out = append(out, a) out = append(out, virtuals...) continue diff --git a/sdk/cliproxy/service.go b/sdk/cliproxy/service.go index 0405c6ae..4b6e70fc 100644 --- a/sdk/cliproxy/service.go +++ b/sdk/cliproxy/service.go @@ -617,6 +617,7 @@ func (s *Service) registerModelsForAuth(a *coreauth.Auth) { if a == nil || a.ID == "" { return } + authKind := strings.ToLower(strings.TrimSpace(a.Attributes["auth_kind"])) if a.Attributes != nil { if v := strings.TrimSpace(a.Attributes["gemini_virtual_primary"]); strings.EqualFold(v, "true") { GlobalModelRegistry().UnregisterClient(a.ID) @@ -636,41 +637,57 @@ func (s *Service) registerModelsForAuth(a *coreauth.Auth) { if compatDetected { provider = "openai-compatibility" } + blacklist := s.oauthBlacklist(provider, authKind) var models []*ModelInfo switch provider { case "gemini": models = registry.GetGeminiModels() if entry := s.resolveConfigGeminiKey(a); entry != nil { - models = applyModelBlacklist(models, entry.ModelBlacklist) + if authKind == "apikey" { + blacklist = entry.ModelBlacklist + } } + models = applyModelBlacklist(models, blacklist) case "vertex": // Vertex AI Gemini supports the same model identifiers as Gemini. models = registry.GetGeminiVertexModels() + models = applyModelBlacklist(models, blacklist) case "gemini-cli": models = registry.GetGeminiCLIModels() + models = applyModelBlacklist(models, blacklist) case "aistudio": models = registry.GetAIStudioModels() + models = applyModelBlacklist(models, blacklist) case "antigravity": ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) models = executor.FetchAntigravityModels(ctx, a, s.cfg) cancel() + models = applyModelBlacklist(models, blacklist) case "claude": models = registry.GetClaudeModels() if entry := s.resolveConfigClaudeKey(a); entry != nil { if len(entry.Models) > 0 { models = buildClaudeConfigModels(entry) } - models = applyModelBlacklist(models, entry.ModelBlacklist) + if authKind == "apikey" { + blacklist = entry.ModelBlacklist + } } + models = applyModelBlacklist(models, blacklist) case "codex": models = registry.GetOpenAIModels() if entry := s.resolveConfigCodexKey(a); entry != nil { - models = applyModelBlacklist(models, entry.ModelBlacklist) + if authKind == "apikey" { + blacklist = entry.ModelBlacklist + } } + models = applyModelBlacklist(models, blacklist) case "qwen": models = registry.GetQwenModels() + models = applyModelBlacklist(models, blacklist) case "iflow": models = registry.GetIFlowModels() + models = applyModelBlacklist(models, blacklist) default: // Handle OpenAI-compatibility providers by name using config if s.cfg != nil { @@ -855,6 +872,19 @@ func (s *Service) resolveConfigCodexKey(auth *coreauth.Auth) *config.CodexKey { return nil } +func (s *Service) oauthBlacklist(provider, authKind string) []string { + cfg := s.cfg + if cfg == nil { + return nil + } + authKindKey := strings.ToLower(strings.TrimSpace(authKind)) + providerKey := strings.ToLower(strings.TrimSpace(provider)) + if authKindKey == "apikey" { + return nil + } + return cfg.OAuthModelBlacklist[providerKey] +} + func applyModelBlacklist(models []*ModelInfo, blacklist []string) []*ModelInfo { if len(models) == 0 || len(blacklist) == 0 { return models From db1119dd7897360bc580685579d47896a2525348 Mon Sep 17 00:00:00 2001 From: Ben Vargas Date: Sat, 29 Nov 2025 04:53:54 -0700 Subject: [PATCH 03/15] fix(amp): add /threads.rss root-level route for AMP CLI AMP CLI requests /threads.rss at the root level, but the AMP module only registered routes under /api/*. This caused a 404 error during AMP CLI startup. Add the missing root-level route with the same security middleware (noCORS, optional localhost restriction) as other management routes. --- internal/api/modules/amp/routes.go | 8 ++++++++ internal/api/modules/amp/routes_test.go | 1 + 2 files changed, 9 insertions(+) diff --git a/internal/api/modules/amp/routes.go b/internal/api/modules/amp/routes.go index 8e5189ad..5bc0dc25 100644 --- a/internal/api/modules/amp/routes.go +++ b/internal/api/modules/amp/routes.go @@ -111,6 +111,14 @@ func (m *AmpModule) registerManagementRoutes(engine *gin.Engine, baseHandler *ha ampAPI.Any("/otel", proxyHandler) ampAPI.Any("/otel/*path", proxyHandler) + // Root-level routes that AMP CLI expects without /api prefix + // These need the same security middleware as the /api/* routes + rootMiddleware := []gin.HandlerFunc{noCORSMiddleware()} + if restrictToLocalhost { + rootMiddleware = append(rootMiddleware, localhostOnlyMiddleware()) + } + engine.GET("/threads.rss", append(rootMiddleware, proxyHandler)...) + // Google v1beta1 passthrough with OAuth fallback // AMP CLI uses non-standard paths like /publishers/google/models/... // We bridge these to our standard Gemini handler to enable local OAuth. diff --git a/internal/api/modules/amp/routes_test.go b/internal/api/modules/amp/routes_test.go index 12240981..51bd7abd 100644 --- a/internal/api/modules/amp/routes_test.go +++ b/internal/api/modules/amp/routes_test.go @@ -37,6 +37,7 @@ func TestRegisterManagementRoutes(t *testing.T) { {"/api/meta", http.MethodGet}, {"/api/telemetry", http.MethodGet}, {"/api/threads", http.MethodGet}, + {"/threads.rss", http.MethodGet}, // Root-level route (no /api prefix) {"/api/otel", http.MethodGet}, // Google v1beta1 bridge should still proxy non-model requests (GET) and allow POST {"/api/provider/google/v1beta1/models", http.MethodGet}, From 6a191358affe7a46044872f354ba2ea79076ab3e Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Sat, 29 Nov 2025 20:30:11 +0800 Subject: [PATCH 04/15] fix(auth): fix runtime auth reload on oauth blacklist change --- internal/watcher/watcher.go | 185 +++++++++++++++++++++++++++++++++++- sdk/cliproxy/service.go | 32 ++++++- sdk/cliproxy/types.go | 17 +++- sdk/cliproxy/watcher.go | 3 + 4 files changed, 229 insertions(+), 8 deletions(-) diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index 5f035718..c10a18a3 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -30,6 +30,16 @@ import ( log "github.com/sirupsen/logrus" ) +func matchProvider(provider string, targets []string) (string, bool) { + p := strings.ToLower(strings.TrimSpace(provider)) + for _, t := range targets { + if strings.EqualFold(p, strings.TrimSpace(t)) { + return p, true + } + } + return p, false +} + // storePersister captures persistence-capable token store methods used by the watcher. type storePersister interface { PersistConfig(ctx context.Context) error @@ -54,6 +64,7 @@ type Watcher struct { lastConfigHash string authQueue chan<- AuthUpdate currentAuths map[string]*coreauth.Auth + runtimeAuths map[string]*coreauth.Auth dispatchMu sync.Mutex dispatchCond *sync.Cond pendingUpdates map[string]AuthUpdate @@ -169,7 +180,7 @@ func (w *Watcher) Start(ctx context.Context) error { go w.processEvents(ctx) // Perform an initial full reload based on current config and auth dir - w.reloadClients(true) + w.reloadClients(true, nil) return nil } @@ -221,9 +232,57 @@ func (w *Watcher) SetAuthUpdateQueue(queue chan<- AuthUpdate) { } } +// DispatchRuntimeAuthUpdate allows external runtime providers (e.g., websocket-driven auths) +// to push auth updates through the same queue used by file/config watchers. +// Returns true if the update was enqueued; false if no queue is configured. +func (w *Watcher) DispatchRuntimeAuthUpdate(update AuthUpdate) bool { + if w == nil { + return false + } + w.clientsMutex.Lock() + if w.runtimeAuths == nil { + w.runtimeAuths = make(map[string]*coreauth.Auth) + } + switch update.Action { + case AuthUpdateActionAdd, AuthUpdateActionModify: + if update.Auth != nil && update.Auth.ID != "" { + clone := update.Auth.Clone() + w.runtimeAuths[clone.ID] = clone + if w.currentAuths == nil { + w.currentAuths = make(map[string]*coreauth.Auth) + } + w.currentAuths[clone.ID] = clone.Clone() + } + case AuthUpdateActionDelete: + id := update.ID + if id == "" && update.Auth != nil { + id = update.Auth.ID + } + if id != "" { + delete(w.runtimeAuths, id) + if w.currentAuths != nil { + delete(w.currentAuths, id) + } + } + } + w.clientsMutex.Unlock() + if w.getAuthQueue() == nil { + return false + } + w.dispatchAuthUpdates([]AuthUpdate{update}) + return true +} + func (w *Watcher) refreshAuthState() { auths := w.SnapshotCoreAuths() w.clientsMutex.Lock() + if len(w.runtimeAuths) > 0 { + for _, a := range w.runtimeAuths { + if a != nil { + auths = append(auths, a.Clone()) + } + } + } updates := w.prepareAuthUpdatesLocked(auths) w.clientsMutex.Unlock() w.dispatchAuthUpdates(updates) @@ -472,6 +531,80 @@ func computeModelBlacklistHash(blacklist []string) string { return hex.EncodeToString(sum[:]) } +type modelBlacklistSummary struct { + hash string + count int +} + +func summarizeModelBlacklist(list []string) modelBlacklistSummary { + if len(list) == 0 { + return modelBlacklistSummary{} + } + seen := make(map[string]struct{}, len(list)) + normalized := make([]string, 0, len(list)) + for _, entry := range list { + if trimmed := strings.ToLower(strings.TrimSpace(entry)); trimmed != "" { + if _, exists := seen[trimmed]; exists { + continue + } + seen[trimmed] = struct{}{} + normalized = append(normalized, trimmed) + } + } + sort.Strings(normalized) + return modelBlacklistSummary{ + hash: computeModelBlacklistHash(normalized), + count: len(normalized), + } +} + +func summarizeOAuthBlacklistMap(entries map[string][]string) map[string]modelBlacklistSummary { + if len(entries) == 0 { + return nil + } + out := make(map[string]modelBlacklistSummary, len(entries)) + for k, v := range entries { + key := strings.ToLower(strings.TrimSpace(k)) + if key == "" { + continue + } + out[key] = summarizeModelBlacklist(v) + } + return out +} + +func diffOAuthBlacklistChanges(oldMap, newMap map[string][]string) ([]string, []string) { + oldSummary := summarizeOAuthBlacklistMap(oldMap) + newSummary := summarizeOAuthBlacklistMap(newMap) + keys := make(map[string]struct{}, len(oldSummary)+len(newSummary)) + for k := range oldSummary { + keys[k] = struct{}{} + } + for k := range newSummary { + keys[k] = struct{}{} + } + changes := make([]string, 0, len(keys)) + affected := make([]string, 0, len(keys)) + for key := range keys { + oldInfo, okOld := oldSummary[key] + newInfo, okNew := newSummary[key] + switch { + case okOld && !okNew: + changes = append(changes, fmt.Sprintf("oauth-model-blacklist[%s]: removed", key)) + affected = append(affected, key) + case !okOld && okNew: + changes = append(changes, fmt.Sprintf("oauth-model-blacklist[%s]: added (%d entries)", key, newInfo.count)) + affected = append(affected, key) + case okOld && okNew && oldInfo.hash != newInfo.hash: + changes = append(changes, fmt.Sprintf("oauth-model-blacklist[%s]: updated (%d -> %d entries)", key, oldInfo.count, newInfo.count)) + affected = append(affected, key) + } + } + sort.Strings(changes) + sort.Strings(affected) + return changes, affected +} + func applyAuthModelBlacklistMeta(auth *coreauth.Auth, cfg *config.Config, perKey []string, authKind string) { if auth == nil || cfg == nil { return @@ -696,6 +829,11 @@ func (w *Watcher) reloadConfig() bool { w.config = newConfig w.clientsMutex.Unlock() + var affectedOAuthProviders []string + if oldConfig != nil { + _, affectedOAuthProviders = diffOAuthBlacklistChanges(oldConfig.OAuthModelBlacklist, newConfig.OAuthModelBlacklist) + } + // Always apply the current log level based on the latest config. // This ensures logrus reflects the desired level even if change detection misses. util.SetLogLevel(newConfig) @@ -721,12 +859,12 @@ func (w *Watcher) reloadConfig() bool { log.Infof("config successfully reloaded, triggering client reload") // Reload clients with new config - w.reloadClients(authDirChanged) + w.reloadClients(authDirChanged, affectedOAuthProviders) return true } // reloadClients performs a full scan and reload of all clients. -func (w *Watcher) reloadClients(rescanAuth bool) { +func (w *Watcher) reloadClients(rescanAuth bool, affectedOAuthProviders []string) { log.Debugf("starting full client load process") w.clientsMutex.RLock() @@ -738,6 +876,28 @@ func (w *Watcher) reloadClients(rescanAuth bool) { return } + if len(affectedOAuthProviders) > 0 { + w.clientsMutex.Lock() + if w.currentAuths != nil { + filtered := make(map[string]*coreauth.Auth, len(w.currentAuths)) + for id, auth := range w.currentAuths { + if auth == nil { + continue + } + provider := strings.ToLower(strings.TrimSpace(auth.Provider)) + if _, match := matchProvider(provider, affectedOAuthProviders); match { + continue + } + filtered[id] = auth + } + w.currentAuths = filtered + log.Debugf("applying oauth-model-blacklist to providers %v", affectedOAuthProviders) + } else { + w.currentAuths = nil + } + w.clientsMutex.Unlock() + } + // Unregister all old API key clients before creating new ones // no legacy clients to unregister @@ -1533,6 +1693,11 @@ func buildConfigChangeDetails(oldCfg, newCfg *config.Config) []string { if !equalStringMap(o.Headers, n.Headers) { changes = append(changes, fmt.Sprintf("gemini[%d].headers: updated", i)) } + oldBL := summarizeModelBlacklist(o.ModelBlacklist) + newBL := summarizeModelBlacklist(n.ModelBlacklist) + if oldBL.hash != newBL.hash { + changes = append(changes, fmt.Sprintf("gemini[%d].model-blacklist: updated (%d -> %d entries)", i, oldBL.count, newBL.count)) + } } if !reflect.DeepEqual(trimStrings(oldCfg.GlAPIKey), trimStrings(newCfg.GlAPIKey)) { changes = append(changes, "generative-language-api-key: values updated (legacy view, redacted)") @@ -1561,6 +1726,11 @@ func buildConfigChangeDetails(oldCfg, newCfg *config.Config) []string { if !equalStringMap(o.Headers, n.Headers) { changes = append(changes, fmt.Sprintf("claude[%d].headers: updated", i)) } + oldBL := summarizeModelBlacklist(o.ModelBlacklist) + newBL := summarizeModelBlacklist(n.ModelBlacklist) + if oldBL.hash != newBL.hash { + changes = append(changes, fmt.Sprintf("claude[%d].model-blacklist: updated (%d -> %d entries)", i, oldBL.count, newBL.count)) + } } } @@ -1586,9 +1756,18 @@ func buildConfigChangeDetails(oldCfg, newCfg *config.Config) []string { if !equalStringMap(o.Headers, n.Headers) { changes = append(changes, fmt.Sprintf("codex[%d].headers: updated", i)) } + oldBL := summarizeModelBlacklist(o.ModelBlacklist) + newBL := summarizeModelBlacklist(n.ModelBlacklist) + if oldBL.hash != newBL.hash { + changes = append(changes, fmt.Sprintf("codex[%d].model-blacklist: updated (%d -> %d entries)", i, oldBL.count, newBL.count)) + } } } + if entries, _ := diffOAuthBlacklistChanges(oldCfg.OAuthModelBlacklist, newCfg.OAuthModelBlacklist); len(entries) > 0 { + changes = append(changes, entries...) + } + // Remote management (never print the key) if oldCfg.RemoteManagement.AllowRemote != newCfg.RemoteManagement.AllowRemote { changes = append(changes, fmt.Sprintf("remote-management.allow-remote: %t -> %t", oldCfg.RemoteManagement.AllowRemote, newCfg.RemoteManagement.AllowRemote)) diff --git a/sdk/cliproxy/service.go b/sdk/cliproxy/service.go index 4b6e70fc..9eb1d584 100644 --- a/sdk/cliproxy/service.go +++ b/sdk/cliproxy/service.go @@ -146,6 +146,27 @@ func (s *Service) consumeAuthUpdates(ctx context.Context) { } } +func (s *Service) emitAuthUpdate(ctx context.Context, update watcher.AuthUpdate) { + if s == nil { + return + } + if ctx == nil { + ctx = context.Background() + } + if s.watcher != nil && s.watcher.DispatchRuntimeAuthUpdate(update) { + return + } + if s.authUpdates != nil { + select { + case s.authUpdates <- update: + return + default: + log.Debugf("auth update queue saturated, applying inline action=%v id=%s", update.Action, update.ID) + } + } + s.handleAuthUpdate(ctx, update) +} + func (s *Service) handleAuthUpdate(ctx context.Context, update watcher.AuthUpdate) { if s == nil { return @@ -220,7 +241,11 @@ func (s *Service) wsOnConnected(channelID string) { Metadata: map[string]any{"email": channelID}, // metadata drives logging and usage tracking } log.Infof("websocket provider connected: %s", channelID) - s.applyCoreAuthAddOrUpdate(context.Background(), auth) + s.emitAuthUpdate(context.Background(), watcher.AuthUpdate{ + Action: watcher.AuthUpdateActionAdd, + ID: auth.ID, + Auth: auth, + }) } func (s *Service) wsOnDisconnected(channelID string, reason error) { @@ -237,7 +262,10 @@ func (s *Service) wsOnDisconnected(channelID string, reason error) { log.Infof("websocket provider disconnected: %s", channelID) } ctx := context.Background() - s.applyCoreAuthRemoval(ctx, channelID) + s.emitAuthUpdate(ctx, watcher.AuthUpdate{ + Action: watcher.AuthUpdateActionDelete, + ID: channelID, + }) } func (s *Service) applyCoreAuthAddOrUpdate(ctx context.Context, auth *coreauth.Auth) { diff --git a/sdk/cliproxy/types.go b/sdk/cliproxy/types.go index 1d577153..b44185d1 100644 --- a/sdk/cliproxy/types.go +++ b/sdk/cliproxy/types.go @@ -83,9 +83,10 @@ type WatcherWrapper struct { start func(ctx context.Context) error stop func() error - setConfig func(cfg *config.Config) - snapshotAuths func() []*coreauth.Auth - setUpdateQueue func(queue chan<- watcher.AuthUpdate) + setConfig func(cfg *config.Config) + snapshotAuths func() []*coreauth.Auth + setUpdateQueue func(queue chan<- watcher.AuthUpdate) + dispatchRuntimeUpdate func(update watcher.AuthUpdate) bool } // Start proxies to the underlying watcher Start implementation. @@ -112,6 +113,16 @@ func (w *WatcherWrapper) SetConfig(cfg *config.Config) { w.setConfig(cfg) } +// DispatchRuntimeAuthUpdate forwards runtime auth updates (e.g., websocket providers) +// into the watcher-managed auth update queue when available. +// Returns true if the update was enqueued successfully. +func (w *WatcherWrapper) DispatchRuntimeAuthUpdate(update watcher.AuthUpdate) bool { + if w == nil || w.dispatchRuntimeUpdate == nil { + return false + } + return w.dispatchRuntimeUpdate(update) +} + // SetClients updates the watcher file-backed clients registry. // SetClients and SetAPIKeyClients removed; watcher manages its own caches diff --git a/sdk/cliproxy/watcher.go b/sdk/cliproxy/watcher.go index 81e4c18a..921e2068 100644 --- a/sdk/cliproxy/watcher.go +++ b/sdk/cliproxy/watcher.go @@ -28,5 +28,8 @@ func defaultWatcherFactory(configPath, authDir string, reload func(*config.Confi setUpdateQueue: func(queue chan<- watcher.AuthUpdate) { w.SetAuthUpdateQueue(queue) }, + dispatchRuntimeUpdate: func(update watcher.AuthUpdate) bool { + return w.DispatchRuntimeAuthUpdate(update) + }, }, nil } From c43f0ea7b19fa02cce29837b60a143d7938bb34c Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Sat, 29 Nov 2025 21:23:47 +0800 Subject: [PATCH 05/15] refactor(config): rename model blacklist fields to excluded models --- config.example.yaml | 10 ++--- internal/config/config.go | 16 +++---- internal/watcher/watcher.go | 86 ++++++++++++++++++------------------- sdk/cliproxy/service.go | 38 ++++++++-------- 4 files changed, 75 insertions(+), 75 deletions(-) diff --git a/config.example.yaml b/config.example.yaml index 0ef2121f..4b7c8883 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -62,7 +62,7 @@ ws-auth: false # headers: # X-Custom-Header: "custom-value" # proxy-url: "socks5://proxy.example.com:1080" -# model-blacklist: +# excluded-models: # - "gemini-2.0-pro-exp" # exclude specific models from this provider # - api-key: "AIzaSy...02" @@ -78,7 +78,7 @@ ws-auth: false # headers: # X-Custom-Header: "custom-value" # proxy-url: "socks5://proxy.example.com:1080" # optional: per-key proxy override -# model-blacklist: +# excluded-models: # - "gpt-5" # exclude specific models from this provider # Claude API keys @@ -92,7 +92,7 @@ ws-auth: false # models: # - name: "claude-3-5-sonnet-20241022" # upstream model name # alias: "claude-sonnet-latest" # client alias mapped to the upstream model -# model-blacklist: +# excluded-models: # - "claude-3-5-sonnet-20241022" # exclude specific models from this provider # OpenAI compatibility providers @@ -128,8 +128,8 @@ ws-auth: false # params: # JSON path (gjson/sjson syntax) -> value # "reasoning.effort": "high" -# OAuth provider model blacklist -#oauth-model-blacklist: +# OAuth provider excluded models +#oauth-excluded-models: # gemini-cli: # - "gemini-3-pro-preview" # vertex: diff --git a/internal/config/config.go b/internal/config/config.go index 67fb9e99..10440395 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -84,8 +84,8 @@ type Config struct { // Payload defines default and override rules for provider payload parameters. Payload PayloadConfig `yaml:"payload" json:"payload"` - // OAuthModelBlacklist defines per-provider global model blacklists applied to OAuth/file-backed auth entries. - OAuthModelBlacklist map[string][]string `yaml:"oauth-model-blacklist,omitempty" json:"oauth-model-blacklist,omitempty"` + // OAuthExcludedModels defines per-provider global model exclusions applied to OAuth/file-backed auth entries. + OAuthExcludedModels map[string][]string `yaml:"oauth-excluded-models,omitempty" json:"oauth-excluded-models,omitempty"` } // TLSConfig holds HTTPS server settings. @@ -161,8 +161,8 @@ type ClaudeKey struct { // Headers optionally adds extra HTTP headers for requests sent with this key. Headers map[string]string `yaml:"headers,omitempty" json:"headers,omitempty"` - // ModelBlacklist lists model IDs that should be excluded for this provider. - ModelBlacklist []string `yaml:"model-blacklist,omitempty" json:"model-blacklist,omitempty"` + // ExcludedModels lists model IDs that should be excluded for this provider. + ExcludedModels []string `yaml:"excluded-models,omitempty" json:"excluded-models,omitempty"` } // ClaudeModel describes a mapping between an alias and the actual upstream model name. @@ -190,8 +190,8 @@ type CodexKey struct { // Headers optionally adds extra HTTP headers for requests sent with this key. Headers map[string]string `yaml:"headers,omitempty" json:"headers,omitempty"` - // ModelBlacklist lists model IDs that should be excluded for this provider. - ModelBlacklist []string `yaml:"model-blacklist,omitempty" json:"model-blacklist,omitempty"` + // ExcludedModels lists model IDs that should be excluded for this provider. + ExcludedModels []string `yaml:"excluded-models,omitempty" json:"excluded-models,omitempty"` } // GeminiKey represents the configuration for a Gemini API key, @@ -209,8 +209,8 @@ type GeminiKey struct { // Headers optionally adds extra HTTP headers for requests sent with this key. Headers map[string]string `yaml:"headers,omitempty" json:"headers,omitempty"` - // ModelBlacklist lists model IDs that should be excluded for this provider. - ModelBlacklist []string `yaml:"model-blacklist,omitempty" json:"model-blacklist,omitempty"` + // ExcludedModels lists model IDs that should be excluded for this provider. + ExcludedModels []string `yaml:"excluded-models,omitempty" json:"excluded-models,omitempty"` } // OpenAICompatibility represents the configuration for OpenAI API compatibility diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index c10a18a3..a284541a 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -509,12 +509,12 @@ func computeClaudeModelsHash(models []config.ClaudeModel) string { return hex.EncodeToString(sum[:]) } -func computeModelBlacklistHash(blacklist []string) string { - if len(blacklist) == 0 { +func computeExcludedModelsHash(excluded []string) string { + if len(excluded) == 0 { return "" } - normalized := make([]string, 0, len(blacklist)) - for _, entry := range blacklist { + normalized := make([]string, 0, len(excluded)) + for _, entry := range excluded { if trimmed := strings.TrimSpace(entry); trimmed != "" { normalized = append(normalized, strings.ToLower(trimmed)) } @@ -531,14 +531,14 @@ func computeModelBlacklistHash(blacklist []string) string { return hex.EncodeToString(sum[:]) } -type modelBlacklistSummary struct { +type excludedModelsSummary struct { hash string count int } -func summarizeModelBlacklist(list []string) modelBlacklistSummary { +func summarizeExcludedModels(list []string) excludedModelsSummary { if len(list) == 0 { - return modelBlacklistSummary{} + return excludedModelsSummary{} } seen := make(map[string]struct{}, len(list)) normalized := make([]string, 0, len(list)) @@ -552,30 +552,30 @@ func summarizeModelBlacklist(list []string) modelBlacklistSummary { } } sort.Strings(normalized) - return modelBlacklistSummary{ - hash: computeModelBlacklistHash(normalized), + return excludedModelsSummary{ + hash: computeExcludedModelsHash(normalized), count: len(normalized), } } -func summarizeOAuthBlacklistMap(entries map[string][]string) map[string]modelBlacklistSummary { +func summarizeOAuthExcludedModels(entries map[string][]string) map[string]excludedModelsSummary { if len(entries) == 0 { return nil } - out := make(map[string]modelBlacklistSummary, len(entries)) + out := make(map[string]excludedModelsSummary, len(entries)) for k, v := range entries { key := strings.ToLower(strings.TrimSpace(k)) if key == "" { continue } - out[key] = summarizeModelBlacklist(v) + out[key] = summarizeExcludedModels(v) } return out } -func diffOAuthBlacklistChanges(oldMap, newMap map[string][]string) ([]string, []string) { - oldSummary := summarizeOAuthBlacklistMap(oldMap) - newSummary := summarizeOAuthBlacklistMap(newMap) +func diffOAuthExcludedModelChanges(oldMap, newMap map[string][]string) ([]string, []string) { + oldSummary := summarizeOAuthExcludedModels(oldMap) + newSummary := summarizeOAuthExcludedModels(newMap) keys := make(map[string]struct{}, len(oldSummary)+len(newSummary)) for k := range oldSummary { keys[k] = struct{}{} @@ -590,13 +590,13 @@ func diffOAuthBlacklistChanges(oldMap, newMap map[string][]string) ([]string, [] newInfo, okNew := newSummary[key] switch { case okOld && !okNew: - changes = append(changes, fmt.Sprintf("oauth-model-blacklist[%s]: removed", key)) + changes = append(changes, fmt.Sprintf("oauth-excluded-models[%s]: removed", key)) affected = append(affected, key) case !okOld && okNew: - changes = append(changes, fmt.Sprintf("oauth-model-blacklist[%s]: added (%d entries)", key, newInfo.count)) + changes = append(changes, fmt.Sprintf("oauth-excluded-models[%s]: added (%d entries)", key, newInfo.count)) affected = append(affected, key) case okOld && okNew && oldInfo.hash != newInfo.hash: - changes = append(changes, fmt.Sprintf("oauth-model-blacklist[%s]: updated (%d -> %d entries)", key, oldInfo.count, newInfo.count)) + changes = append(changes, fmt.Sprintf("oauth-excluded-models[%s]: updated (%d -> %d entries)", key, oldInfo.count, newInfo.count)) affected = append(affected, key) } } @@ -605,7 +605,7 @@ func diffOAuthBlacklistChanges(oldMap, newMap map[string][]string) ([]string, [] return changes, affected } -func applyAuthModelBlacklistMeta(auth *coreauth.Auth, cfg *config.Config, perKey []string, authKind string) { +func applyAuthExcludedModelsMeta(auth *coreauth.Auth, cfg *config.Config, perKey []string, authKind string) { if auth == nil || cfg == nil { return } @@ -624,21 +624,21 @@ func applyAuthModelBlacklistMeta(auth *coreauth.Auth, cfg *config.Config, perKey } if authKindKey == "apikey" { add(perKey) - } else if cfg.OAuthModelBlacklist != nil { + } else if cfg.OAuthExcludedModels != nil { providerKey := strings.ToLower(strings.TrimSpace(auth.Provider)) - add(cfg.OAuthModelBlacklist[providerKey]) + add(cfg.OAuthExcludedModels[providerKey]) } combined := make([]string, 0, len(seen)) for k := range seen { combined = append(combined, k) } sort.Strings(combined) - hash := computeModelBlacklistHash(combined) + hash := computeExcludedModelsHash(combined) if auth.Attributes == nil { auth.Attributes = make(map[string]string) } if hash != "" { - auth.Attributes["model_blacklist_hash"] = hash + auth.Attributes["excluded_models_hash"] = hash } if authKind != "" { auth.Attributes["auth_kind"] = authKind @@ -831,7 +831,7 @@ func (w *Watcher) reloadConfig() bool { var affectedOAuthProviders []string if oldConfig != nil { - _, affectedOAuthProviders = diffOAuthBlacklistChanges(oldConfig.OAuthModelBlacklist, newConfig.OAuthModelBlacklist) + _, affectedOAuthProviders = diffOAuthExcludedModelChanges(oldConfig.OAuthExcludedModels, newConfig.OAuthExcludedModels) } // Always apply the current log level based on the latest config. @@ -891,7 +891,7 @@ func (w *Watcher) reloadClients(rescanAuth bool, affectedOAuthProviders []string filtered[id] = auth } w.currentAuths = filtered - log.Debugf("applying oauth-model-blacklist to providers %v", affectedOAuthProviders) + log.Debugf("applying oauth-excluded-models to providers %v", affectedOAuthProviders) } else { w.currentAuths = nil } @@ -1071,7 +1071,7 @@ func (w *Watcher) SnapshotCoreAuths() []*coreauth.Auth { CreatedAt: now, UpdatedAt: now, } - applyAuthModelBlacklistMeta(a, cfg, entry.ModelBlacklist, "apikey") + applyAuthExcludedModelsMeta(a, cfg, entry.ExcludedModels, "apikey") out = append(out, a) } // Claude API keys -> synthesize auths @@ -1105,7 +1105,7 @@ func (w *Watcher) SnapshotCoreAuths() []*coreauth.Auth { CreatedAt: now, UpdatedAt: now, } - applyAuthModelBlacklistMeta(a, cfg, ck.ModelBlacklist, "apikey") + applyAuthExcludedModelsMeta(a, cfg, ck.ExcludedModels, "apikey") out = append(out, a) } // Codex API keys -> synthesize auths @@ -1135,7 +1135,7 @@ func (w *Watcher) SnapshotCoreAuths() []*coreauth.Auth { CreatedAt: now, UpdatedAt: now, } - applyAuthModelBlacklistMeta(a, cfg, ck.ModelBlacklist, "apikey") + applyAuthExcludedModelsMeta(a, cfg, ck.ExcludedModels, "apikey") out = append(out, a) } for i := range cfg.OpenAICompatibility { @@ -1296,11 +1296,11 @@ func (w *Watcher) SnapshotCoreAuths() []*coreauth.Auth { CreatedAt: now, UpdatedAt: now, } - applyAuthModelBlacklistMeta(a, cfg, nil, "oauth") + applyAuthExcludedModelsMeta(a, cfg, nil, "oauth") if provider == "gemini-cli" { if virtuals := synthesizeGeminiVirtualAuths(a, metadata, now); len(virtuals) > 0 { for _, v := range virtuals { - applyAuthModelBlacklistMeta(v, cfg, nil, "oauth") + applyAuthExcludedModelsMeta(v, cfg, nil, "oauth") } out = append(out, a) out = append(out, virtuals...) @@ -1693,10 +1693,10 @@ func buildConfigChangeDetails(oldCfg, newCfg *config.Config) []string { if !equalStringMap(o.Headers, n.Headers) { changes = append(changes, fmt.Sprintf("gemini[%d].headers: updated", i)) } - oldBL := summarizeModelBlacklist(o.ModelBlacklist) - newBL := summarizeModelBlacklist(n.ModelBlacklist) - if oldBL.hash != newBL.hash { - changes = append(changes, fmt.Sprintf("gemini[%d].model-blacklist: updated (%d -> %d entries)", i, oldBL.count, newBL.count)) + oldExcluded := summarizeExcludedModels(o.ExcludedModels) + newExcluded := summarizeExcludedModels(n.ExcludedModels) + if oldExcluded.hash != newExcluded.hash { + changes = append(changes, fmt.Sprintf("gemini[%d].excluded-models: updated (%d -> %d entries)", i, oldExcluded.count, newExcluded.count)) } } if !reflect.DeepEqual(trimStrings(oldCfg.GlAPIKey), trimStrings(newCfg.GlAPIKey)) { @@ -1726,10 +1726,10 @@ func buildConfigChangeDetails(oldCfg, newCfg *config.Config) []string { if !equalStringMap(o.Headers, n.Headers) { changes = append(changes, fmt.Sprintf("claude[%d].headers: updated", i)) } - oldBL := summarizeModelBlacklist(o.ModelBlacklist) - newBL := summarizeModelBlacklist(n.ModelBlacklist) - if oldBL.hash != newBL.hash { - changes = append(changes, fmt.Sprintf("claude[%d].model-blacklist: updated (%d -> %d entries)", i, oldBL.count, newBL.count)) + oldExcluded := summarizeExcludedModels(o.ExcludedModels) + newExcluded := summarizeExcludedModels(n.ExcludedModels) + if oldExcluded.hash != newExcluded.hash { + changes = append(changes, fmt.Sprintf("claude[%d].excluded-models: updated (%d -> %d entries)", i, oldExcluded.count, newExcluded.count)) } } } @@ -1756,15 +1756,15 @@ func buildConfigChangeDetails(oldCfg, newCfg *config.Config) []string { if !equalStringMap(o.Headers, n.Headers) { changes = append(changes, fmt.Sprintf("codex[%d].headers: updated", i)) } - oldBL := summarizeModelBlacklist(o.ModelBlacklist) - newBL := summarizeModelBlacklist(n.ModelBlacklist) - if oldBL.hash != newBL.hash { - changes = append(changes, fmt.Sprintf("codex[%d].model-blacklist: updated (%d -> %d entries)", i, oldBL.count, newBL.count)) + oldExcluded := summarizeExcludedModels(o.ExcludedModels) + newExcluded := summarizeExcludedModels(n.ExcludedModels) + if oldExcluded.hash != newExcluded.hash { + changes = append(changes, fmt.Sprintf("codex[%d].excluded-models: updated (%d -> %d entries)", i, oldExcluded.count, newExcluded.count)) } } } - if entries, _ := diffOAuthBlacklistChanges(oldCfg.OAuthModelBlacklist, newCfg.OAuthModelBlacklist); len(entries) > 0 { + if entries, _ := diffOAuthExcludedModelChanges(oldCfg.OAuthExcludedModels, newCfg.OAuthExcludedModels); len(entries) > 0 { changes = append(changes, entries...) } diff --git a/sdk/cliproxy/service.go b/sdk/cliproxy/service.go index 9eb1d584..8e980bd9 100644 --- a/sdk/cliproxy/service.go +++ b/sdk/cliproxy/service.go @@ -665,32 +665,32 @@ func (s *Service) registerModelsForAuth(a *coreauth.Auth) { if compatDetected { provider = "openai-compatibility" } - blacklist := s.oauthBlacklist(provider, authKind) + excluded := s.oauthExcludedModels(provider, authKind) var models []*ModelInfo switch provider { case "gemini": models = registry.GetGeminiModels() if entry := s.resolveConfigGeminiKey(a); entry != nil { if authKind == "apikey" { - blacklist = entry.ModelBlacklist + excluded = entry.ExcludedModels } } - models = applyModelBlacklist(models, blacklist) + models = applyExcludedModels(models, excluded) case "vertex": // Vertex AI Gemini supports the same model identifiers as Gemini. models = registry.GetGeminiVertexModels() - models = applyModelBlacklist(models, blacklist) + models = applyExcludedModels(models, excluded) case "gemini-cli": models = registry.GetGeminiCLIModels() - models = applyModelBlacklist(models, blacklist) + models = applyExcludedModels(models, excluded) case "aistudio": models = registry.GetAIStudioModels() - models = applyModelBlacklist(models, blacklist) + models = applyExcludedModels(models, excluded) case "antigravity": ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) models = executor.FetchAntigravityModels(ctx, a, s.cfg) cancel() - models = applyModelBlacklist(models, blacklist) + models = applyExcludedModels(models, excluded) case "claude": models = registry.GetClaudeModels() if entry := s.resolveConfigClaudeKey(a); entry != nil { @@ -698,24 +698,24 @@ func (s *Service) registerModelsForAuth(a *coreauth.Auth) { models = buildClaudeConfigModels(entry) } if authKind == "apikey" { - blacklist = entry.ModelBlacklist + excluded = entry.ExcludedModels } } - models = applyModelBlacklist(models, blacklist) + models = applyExcludedModels(models, excluded) case "codex": models = registry.GetOpenAIModels() if entry := s.resolveConfigCodexKey(a); entry != nil { if authKind == "apikey" { - blacklist = entry.ModelBlacklist + excluded = entry.ExcludedModels } } - models = applyModelBlacklist(models, blacklist) + models = applyExcludedModels(models, excluded) case "qwen": models = registry.GetQwenModels() - models = applyModelBlacklist(models, blacklist) + models = applyExcludedModels(models, excluded) case "iflow": models = registry.GetIFlowModels() - models = applyModelBlacklist(models, blacklist) + models = applyExcludedModels(models, excluded) default: // Handle OpenAI-compatibility providers by name using config if s.cfg != nil { @@ -900,7 +900,7 @@ func (s *Service) resolveConfigCodexKey(auth *coreauth.Auth) *config.CodexKey { return nil } -func (s *Service) oauthBlacklist(provider, authKind string) []string { +func (s *Service) oauthExcludedModels(provider, authKind string) []string { cfg := s.cfg if cfg == nil { return nil @@ -910,15 +910,15 @@ func (s *Service) oauthBlacklist(provider, authKind string) []string { if authKindKey == "apikey" { return nil } - return cfg.OAuthModelBlacklist[providerKey] + return cfg.OAuthExcludedModels[providerKey] } -func applyModelBlacklist(models []*ModelInfo, blacklist []string) []*ModelInfo { - if len(models) == 0 || len(blacklist) == 0 { +func applyExcludedModels(models []*ModelInfo, excluded []string) []*ModelInfo { + if len(models) == 0 || len(excluded) == 0 { return models } - blocked := make(map[string]struct{}, len(blacklist)) - for _, item := range blacklist { + blocked := make(map[string]struct{}, len(excluded)) + for _, item := range excluded { if trimmed := strings.TrimSpace(item); trimmed != "" { blocked[strings.ToLower(trimmed)] = struct{}{} } From 022aa81be11887a7f78636f76b10071e9c67e3d1 Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Sun, 30 Nov 2025 08:02:00 +0800 Subject: [PATCH 06/15] feat(cliproxy): support wildcard exclusions for models --- sdk/cliproxy/service.go | 65 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 59 insertions(+), 6 deletions(-) diff --git a/sdk/cliproxy/service.go b/sdk/cliproxy/service.go index 8e980bd9..c2ebba8d 100644 --- a/sdk/cliproxy/service.go +++ b/sdk/cliproxy/service.go @@ -917,28 +917,81 @@ func applyExcludedModels(models []*ModelInfo, excluded []string) []*ModelInfo { if len(models) == 0 || len(excluded) == 0 { return models } - blocked := make(map[string]struct{}, len(excluded)) + + patterns := make([]string, 0, len(excluded)) for _, item := range excluded { if trimmed := strings.TrimSpace(item); trimmed != "" { - blocked[strings.ToLower(trimmed)] = struct{}{} + patterns = append(patterns, strings.ToLower(trimmed)) } } - if len(blocked) == 0 { + if len(patterns) == 0 { return models } + filtered := make([]*ModelInfo, 0, len(models)) for _, model := range models { if model == nil { continue } - if _, blockedModel := blocked[strings.ToLower(strings.TrimSpace(model.ID))]; blockedModel { - continue + modelID := strings.ToLower(strings.TrimSpace(model.ID)) + blocked := false + for _, pattern := range patterns { + if matchWildcard(pattern, modelID) { + blocked = true + break + } + } + if !blocked { + filtered = append(filtered, model) } - filtered = append(filtered, model) } return filtered } +// matchWildcard performs case-insensitive wildcard matching where '*' matches any substring. +func matchWildcard(pattern, value string) bool { + if pattern == "" { + return false + } + + // Fast path for exact match (no wildcard present). + if !strings.Contains(pattern, "*") { + return pattern == value + } + + parts := strings.Split(pattern, "*") + // Handle prefix. + if prefix := parts[0]; prefix != "" { + if !strings.HasPrefix(value, prefix) { + return false + } + value = value[len(prefix):] + } + + // Handle suffix. + if suffix := parts[len(parts)-1]; suffix != "" { + if !strings.HasSuffix(value, suffix) { + return false + } + value = value[:len(value)-len(suffix)] + } + + // Handle middle segments in order. + for i := 1; i < len(parts)-1; i++ { + segment := parts[i] + if segment == "" { + continue + } + idx := strings.Index(value, segment) + if idx < 0 { + return false + } + value = value[idx+len(segment):] + } + + return true +} + func buildClaudeConfigModels(entry *config.ClaudeKey) []*ModelInfo { if entry == nil || len(entry.Models) == 0 { return nil From c4e3646b75f42f1cd58386616d3fef9a1afbc22a Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Sun, 30 Nov 2025 11:55:47 +0800 Subject: [PATCH 07/15] docs(config): expand model exclusion examples --- config.example.yaml | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/config.example.yaml b/config.example.yaml index 4b7c8883..9dfca5bc 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -63,7 +63,10 @@ ws-auth: false # X-Custom-Header: "custom-value" # proxy-url: "socks5://proxy.example.com:1080" # excluded-models: -# - "gemini-2.0-pro-exp" # exclude specific models from this provider +# - "gemini-2.5-pro" # exclude specific models from this provider (exact match) +# - "gemini-2.5-*" # wildcard matching prefix (e.g. gemini-2.5-flash, gemini-2.5-pro) +# - "*-preview" # wildcard matching suffix (e.g. gemini-3-pro-preview) +# - "*flash*" # wildcard matching substring (e.g. gemini-2.5-flash-lite) # - api-key: "AIzaSy...02" # API keys for official Generative Language API (legacy compatibility) @@ -79,7 +82,10 @@ ws-auth: false # X-Custom-Header: "custom-value" # proxy-url: "socks5://proxy.example.com:1080" # optional: per-key proxy override # excluded-models: -# - "gpt-5" # exclude specific models from this provider +# - "gpt-5.1" # exclude specific models (exact match) +# - "gpt-5-*" # wildcard matching prefix (e.g. gpt-5-medium, gpt-5-codex) +# - "*-mini" # wildcard matching suffix (e.g. gpt-5-codex-mini) +# - "*codex*" # wildcard matching substring (e.g. gpt-5-codex-low) # Claude API keys #claude-api-key: @@ -93,7 +99,10 @@ ws-auth: false # - name: "claude-3-5-sonnet-20241022" # upstream model name # alias: "claude-sonnet-latest" # client alias mapped to the upstream model # excluded-models: -# - "claude-3-5-sonnet-20241022" # exclude specific models from this provider +# - "claude-opus-4-5-20251101" # exclude specific models (exact match) +# - "claude-3-*" # wildcard matching prefix (e.g. claude-3-7-sonnet-20250219) +# - "*-think" # wildcard matching suffix (e.g. claude-opus-4-5-thinking) +# - "*haiku*" # wildcard matching substring (e.g. claude-3-5-haiku-20241022) # OpenAI compatibility providers #openai-compatibility: @@ -131,7 +140,10 @@ ws-auth: false # OAuth provider excluded models #oauth-excluded-models: # gemini-cli: -# - "gemini-3-pro-preview" +# - "gemini-2.5-pro" # exclude specific models (exact match) +# - "gemini-2.5-*" # wildcard matching prefix (e.g. gemini-2.5-flash, gemini-2.5-pro) +# - "*-preview" # wildcard matching suffix (e.g. gemini-3-pro-preview) +# - "*flash*" # wildcard matching substring (e.g. gemini-2.5-flash-lite) # vertex: # - "gemini-3-pro-preview" # aistudio: From 7b7871ede29aad123f90f0049a9a5849bcea8bda Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Sun, 30 Nov 2025 13:38:23 +0800 Subject: [PATCH 08/15] feat(api): add oauth excluded model management --- .../api/handlers/management/config_lists.go | 89 +++++++++++++++++++ internal/api/server.go | 5 ++ internal/config/config.go | 55 ++++++++++++ 3 files changed, 149 insertions(+) diff --git a/internal/api/handlers/management/config_lists.go b/internal/api/handlers/management/config_lists.go index b4b43b0f..71193084 100644 --- a/internal/api/handlers/management/config_lists.go +++ b/internal/api/handlers/management/config_lists.go @@ -223,6 +223,7 @@ func (h *Handler) PatchGeminiKey(c *gin.Context) { value.APIKey = strings.TrimSpace(value.APIKey) value.BaseURL = strings.TrimSpace(value.BaseURL) value.ProxyURL = strings.TrimSpace(value.ProxyURL) + value.ExcludedModels = config.NormalizeExcludedModels(value.ExcludedModels) if value.APIKey == "" { // Treat empty API key as delete. if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.GeminiKey) { @@ -504,6 +505,91 @@ func (h *Handler) DeleteOpenAICompat(c *gin.Context) { c.JSON(400, gin.H{"error": "missing name or index"}) } +// oauth-excluded-models: map[string][]string +func (h *Handler) GetOAuthExcludedModels(c *gin.Context) { + c.JSON(200, gin.H{"oauth-excluded-models": config.NormalizeOAuthExcludedModels(h.cfg.OAuthExcludedModels)}) +} + +func (h *Handler) PutOAuthExcludedModels(c *gin.Context) { + data, err := c.GetRawData() + if err != nil { + c.JSON(400, gin.H{"error": "failed to read body"}) + return + } + var entries map[string][]string + if err = json.Unmarshal(data, &entries); err != nil { + var wrapper struct { + Items map[string][]string `json:"items"` + } + if err2 := json.Unmarshal(data, &wrapper); err2 != nil { + c.JSON(400, gin.H{"error": "invalid body"}) + return + } + entries = wrapper.Items + } + h.cfg.OAuthExcludedModels = config.NormalizeOAuthExcludedModels(entries) + h.persist(c) +} + +func (h *Handler) PatchOAuthExcludedModels(c *gin.Context) { + var body struct { + Provider *string `json:"provider"` + Models []string `json:"models"` + } + if err := c.ShouldBindJSON(&body); err != nil || body.Provider == nil { + c.JSON(400, gin.H{"error": "invalid body"}) + return + } + provider := strings.ToLower(strings.TrimSpace(*body.Provider)) + if provider == "" { + c.JSON(400, gin.H{"error": "invalid provider"}) + return + } + normalized := config.NormalizeExcludedModels(body.Models) + if len(normalized) == 0 { + if h.cfg.OAuthExcludedModels == nil { + c.JSON(404, gin.H{"error": "provider not found"}) + return + } + if _, ok := h.cfg.OAuthExcludedModels[provider]; !ok { + c.JSON(404, gin.H{"error": "provider not found"}) + return + } + delete(h.cfg.OAuthExcludedModels, provider) + if len(h.cfg.OAuthExcludedModels) == 0 { + h.cfg.OAuthExcludedModels = nil + } + h.persist(c) + return + } + if h.cfg.OAuthExcludedModels == nil { + h.cfg.OAuthExcludedModels = make(map[string][]string) + } + h.cfg.OAuthExcludedModels[provider] = normalized + h.persist(c) +} + +func (h *Handler) DeleteOAuthExcludedModels(c *gin.Context) { + provider := strings.ToLower(strings.TrimSpace(c.Query("provider"))) + if provider == "" { + c.JSON(400, gin.H{"error": "missing provider"}) + return + } + if h.cfg.OAuthExcludedModels == nil { + c.JSON(404, gin.H{"error": "provider not found"}) + return + } + if _, ok := h.cfg.OAuthExcludedModels[provider]; !ok { + c.JSON(404, gin.H{"error": "provider not found"}) + return + } + delete(h.cfg.OAuthExcludedModels, provider) + if len(h.cfg.OAuthExcludedModels) == 0 { + h.cfg.OAuthExcludedModels = nil + } + h.persist(c) +} + // codex-api-key: []CodexKey func (h *Handler) GetCodexKeys(c *gin.Context) { c.JSON(200, gin.H{"codex-api-key": h.cfg.CodexKey}) @@ -533,6 +619,7 @@ func (h *Handler) PutCodexKeys(c *gin.Context) { entry.BaseURL = strings.TrimSpace(entry.BaseURL) entry.ProxyURL = strings.TrimSpace(entry.ProxyURL) entry.Headers = config.NormalizeHeaders(entry.Headers) + entry.ExcludedModels = config.NormalizeExcludedModels(entry.ExcludedModels) if entry.BaseURL == "" { continue } @@ -557,6 +644,7 @@ func (h *Handler) PatchCodexKey(c *gin.Context) { value.BaseURL = strings.TrimSpace(value.BaseURL) value.ProxyURL = strings.TrimSpace(value.ProxyURL) value.Headers = config.NormalizeHeaders(value.Headers) + value.ExcludedModels = config.NormalizeExcludedModels(value.ExcludedModels) // If base-url becomes empty, delete instead of update if value.BaseURL == "" { if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.CodexKey) { @@ -694,6 +782,7 @@ func normalizeClaudeKey(entry *config.ClaudeKey) { entry.BaseURL = strings.TrimSpace(entry.BaseURL) entry.ProxyURL = strings.TrimSpace(entry.ProxyURL) entry.Headers = config.NormalizeHeaders(entry.Headers) + entry.ExcludedModels = config.NormalizeExcludedModels(entry.ExcludedModels) if len(entry.Models) == 0 { return } diff --git a/internal/api/server.go b/internal/api/server.go index 3dd78c93..ab9c0354 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -543,6 +543,11 @@ func (s *Server) registerManagementRoutes() { mgmt.PATCH("/openai-compatibility", s.mgmt.PatchOpenAICompat) mgmt.DELETE("/openai-compatibility", s.mgmt.DeleteOpenAICompat) + mgmt.GET("/oauth-excluded-models", s.mgmt.GetOAuthExcludedModels) + mgmt.PUT("/oauth-excluded-models", s.mgmt.PutOAuthExcludedModels) + mgmt.PATCH("/oauth-excluded-models", s.mgmt.PatchOAuthExcludedModels) + mgmt.DELETE("/oauth-excluded-models", s.mgmt.DeleteOAuthExcludedModels) + mgmt.GET("/auth-files", s.mgmt.ListAuthFiles) mgmt.GET("/auth-files/download", s.mgmt.DownloadAuthFile) mgmt.POST("/auth-files", s.mgmt.UploadAuthFile) diff --git a/internal/config/config.go b/internal/config/config.go index 10440395..97b5a0c2 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -334,6 +334,9 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) { // Sanitize OpenAI compatibility providers: drop entries without base-url cfg.SanitizeOpenAICompatibility() + // Normalize OAuth provider model exclusion map. + cfg.OAuthExcludedModels = NormalizeOAuthExcludedModels(cfg.OAuthExcludedModels) + // Return the populated configuration struct. return &cfg, nil } @@ -371,6 +374,7 @@ func (cfg *Config) SanitizeCodexKeys() { e := cfg.CodexKey[i] e.BaseURL = strings.TrimSpace(e.BaseURL) e.Headers = NormalizeHeaders(e.Headers) + e.ExcludedModels = NormalizeExcludedModels(e.ExcludedModels) if e.BaseURL == "" { continue } @@ -387,6 +391,7 @@ func (cfg *Config) SanitizeClaudeKeys() { for i := range cfg.ClaudeKey { entry := &cfg.ClaudeKey[i] entry.Headers = NormalizeHeaders(entry.Headers) + entry.ExcludedModels = NormalizeExcludedModels(entry.ExcludedModels) } } @@ -407,6 +412,7 @@ func (cfg *Config) SanitizeGeminiKeys() { entry.BaseURL = strings.TrimSpace(entry.BaseURL) entry.ProxyURL = strings.TrimSpace(entry.ProxyURL) entry.Headers = NormalizeHeaders(entry.Headers) + entry.ExcludedModels = NormalizeExcludedModels(entry.ExcludedModels) if _, exists := seen[entry.APIKey]; exists { continue } @@ -469,6 +475,55 @@ func NormalizeHeaders(headers map[string]string) map[string]string { return clean } +// NormalizeExcludedModels trims, lowercases, and deduplicates model exclusion patterns. +// It preserves the order of first occurrences and drops empty entries. +func NormalizeExcludedModels(models []string) []string { + if len(models) == 0 { + return nil + } + seen := make(map[string]struct{}, len(models)) + out := make([]string, 0, len(models)) + for _, raw := range models { + trimmed := strings.ToLower(strings.TrimSpace(raw)) + if trimmed == "" { + continue + } + if _, exists := seen[trimmed]; exists { + continue + } + seen[trimmed] = struct{}{} + out = append(out, trimmed) + } + if len(out) == 0 { + return nil + } + return out +} + +// NormalizeOAuthExcludedModels cleans provider -> excluded models mappings by normalizing provider keys +// and applying model exclusion normalization to each entry. +func NormalizeOAuthExcludedModels(entries map[string][]string) map[string][]string { + if len(entries) == 0 { + return nil + } + out := make(map[string][]string, len(entries)) + for provider, models := range entries { + key := strings.ToLower(strings.TrimSpace(provider)) + if key == "" { + continue + } + normalized := NormalizeExcludedModels(models) + if len(normalized) == 0 { + continue + } + out[key] = normalized + } + if len(out) == 0 { + return nil + } + return out +} + // hashSecret hashes the given secret using bcrypt. func hashSecret(secret string) (string, error) { // Use default cost for simplicity. From a748e93fd959e688e7e79d83643327dc6ac8ada6 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Sun, 30 Nov 2025 16:56:29 +0800 Subject: [PATCH 09/15] **fix(executor, auth): ensure index assignment consistency for auth objects** - Updated `usage_helpers.go` to call `EnsureIndex()` for proper index assignment in reporter initialization. - Adjusted `auth/manager.go` to assign auth indices inside a locked section when they are unassigned, ensuring thread safety and consistency. --- internal/runtime/executor/usage_helpers.go | 2 +- sdk/cliproxy/auth/manager.go | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/internal/runtime/executor/usage_helpers.go b/internal/runtime/executor/usage_helpers.go index 266a300e..5669d9bc 100644 --- a/internal/runtime/executor/usage_helpers.go +++ b/internal/runtime/executor/usage_helpers.go @@ -37,7 +37,7 @@ func newUsageReporter(ctx context.Context, provider, model string, auth *cliprox } if auth != nil { reporter.authID = auth.ID - reporter.authIndex = auth.Index + reporter.authIndex = auth.EnsureIndex() } return reporter } diff --git a/sdk/cliproxy/auth/manager.go b/sdk/cliproxy/auth/manager.go index eef70ee5..dc7887e7 100644 --- a/sdk/cliproxy/auth/manager.go +++ b/sdk/cliproxy/auth/manager.go @@ -1118,6 +1118,14 @@ func (m *Manager) pickNext(ctx context.Context, provider, model string, opts cli } authCopy := selected.Clone() m.mu.RUnlock() + if !selected.indexAssigned { + m.mu.Lock() + if current := m.auths[authCopy.ID]; current != nil && !current.indexAssigned { + current.EnsureIndex() + authCopy = current.Clone() + } + m.mu.Unlock() + } return authCopy, executor, nil } From 32d3809f8c2e09f57c7b04369e215e4902187272 Mon Sep 17 00:00:00 2001 From: auroraflux <14947763+auroraflux@users.noreply.github.com> Date: Sun, 30 Nov 2025 00:29:50 -0800 Subject: [PATCH 10/15] **feat(util): add -reasoning suffix support for Gemini models** Adds support for the `-reasoning` model name suffix which enables thinking/reasoning mode with dynamic budget. This allows clients to request reasoning-enabled inference using model names like `gemini-2.5-flash-reasoning` without explicit configuration. The suffix is normalized to the base model (e.g., gemini-2.5-flash) with thinkingBudget=-1 (dynamic) and include_thoughts=true. Follows the existing pattern established by -nothinking and -thinking-N suffixes. --- .../runtime/executor/antigravity_executor.go | 19 +++++++++++++++++++ internal/util/gemini_thinking.go | 9 +++++++++ 2 files changed, 28 insertions(+) diff --git a/internal/runtime/executor/antigravity_executor.go b/internal/runtime/executor/antigravity_executor.go index 13373537..bcc64310 100644 --- a/internal/runtime/executor/antigravity_executor.go +++ b/internal/runtime/executor/antigravity_executor.go @@ -17,6 +17,7 @@ import ( "github.com/google/uuid" "github.com/router-for-me/CLIProxyAPI/v6/internal/config" "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" + "github.com/router-for-me/CLIProxyAPI/v6/internal/util" cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor" sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator" @@ -58,6 +59,20 @@ func (e *AntigravityExecutor) Identifier() string { return antigravityAuthType } // PrepareRequest implements ProviderExecutor. func (e *AntigravityExecutor) PrepareRequest(_ *http.Request, _ *cliproxyauth.Auth) error { return nil } +// applyThinkingMetadata applies thinking config from model suffix metadata (e.g., -reasoning, -thinking-N). +// It trusts user intent when suffix is used, even if registry doesn't have Thinking metadata. +func applyThinkingMetadata(translated []byte, metadata map[string]any, model string) []byte { + budgetOverride, includeOverride, ok := util.GeminiThinkingFromMetadata(metadata) + if !ok { + return translated + } + if budgetOverride != nil && util.ModelSupportsThinking(model) { + norm := util.NormalizeThinkingBudget(model, *budgetOverride) + budgetOverride = &norm + } + return util.ApplyGeminiCLIThinkingConfig(translated, budgetOverride, includeOverride) +} + // Execute handles non-streaming requests via the antigravity generate endpoint. func (e *AntigravityExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) { token, updatedAuth, errToken := e.ensureAccessToken(ctx, auth) @@ -75,6 +90,8 @@ func (e *AntigravityExecutor) Execute(ctx context.Context, auth *cliproxyauth.Au to := sdktranslator.FromString("antigravity") translated := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), false) + translated = applyThinkingMetadata(translated, req.Metadata, req.Model) + baseURLs := antigravityBaseURLFallbackOrder(auth) httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0) @@ -166,6 +183,8 @@ func (e *AntigravityExecutor) ExecuteStream(ctx context.Context, auth *cliproxya to := sdktranslator.FromString("antigravity") translated := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), true) + translated = applyThinkingMetadata(translated, req.Metadata, req.Model) + baseURLs := antigravityBaseURLFallbackOrder(auth) httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0) diff --git a/internal/util/gemini_thinking.go b/internal/util/gemini_thinking.go index d7481621..14077fa0 100644 --- a/internal/util/gemini_thinking.go +++ b/internal/util/gemini_thinking.go @@ -34,6 +34,15 @@ func ParseGeminiThinkingSuffix(model string) (string, *int, *bool, bool) { return base, &budgetValue, &include, true } + // Handle "-reasoning" suffix: enables thinking with dynamic budget (-1) + // Maps: gemini-2.5-flash-reasoning -> gemini-2.5-flash with thinkingBudget=-1 + if strings.HasSuffix(lower, "-reasoning") { + base := model[:len(model)-len("-reasoning")] + budgetValue := -1 // Dynamic budget + include := true + return base, &budgetValue, &include, true + } + idx := strings.LastIndex(lower, "-thinking-") if idx == -1 { return model, nil, nil, false From 75e278c7a5d3ed6c845574cc84cac0324b7d941e Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Sun, 30 Nov 2025 20:56:29 +0800 Subject: [PATCH 11/15] feat(registry): add thinking support to gemini models --- internal/registry/model_definitions.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/internal/registry/model_definitions.go b/internal/registry/model_definitions.go index 51f984f2..fd4bd428 100644 --- a/internal/registry/model_definitions.go +++ b/internal/registry/model_definitions.go @@ -222,6 +222,7 @@ func GetGeminiModels() []*ModelInfo { InputTokenLimit: 1048576, OutputTokenLimit: 65536, SupportedGenerationMethods: []string{"generateContent", "countTokens", "createCachedContent", "batchGenerateContent"}, + Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true}, }, } } @@ -301,6 +302,7 @@ func GetGeminiVertexModels() []*ModelInfo { InputTokenLimit: 1048576, OutputTokenLimit: 65536, SupportedGenerationMethods: []string{"generateContent", "countTokens", "createCachedContent", "batchGenerateContent"}, + Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true}, }, } } From c52ef08e6707df8c9aca96aed04b71f8674a4e07 Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Sun, 30 Nov 2025 12:40:35 -0500 Subject: [PATCH 12/15] docs: add CCS to projects list --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 90d5d465..948936a6 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,10 @@ Native macOS menu bar app to use your Claude Code & ChatGPT subscriptions with A Browser-based tool to translate SRT subtitles using your Gemini subscription via CLIProxyAPI with automatic validation/error correction - no API keys needed +### [CCS (Claude Code Switch)](https://github.com/kaitranntt/ccs) + +CLI wrapper for instant switching between multiple Claude accounts and alternative models (Gemini, Codex, AGY) via CLIProxyAPI OAuth - no API keys needed + > [!NOTE] > If you developed a project based on CLIProxyAPI, please open a PR to add it to this list. From 85eb926482a96fb6dc2fb9a1226f898d0ad7545f Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Sun, 30 Nov 2025 12:43:12 -0500 Subject: [PATCH 13/15] fix: change AGY to Antigravity --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 948936a6..1ab5ca16 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ Browser-based tool to translate SRT subtitles using your Gemini subscription via ### [CCS (Claude Code Switch)](https://github.com/kaitranntt/ccs) -CLI wrapper for instant switching between multiple Claude accounts and alternative models (Gemini, Codex, AGY) via CLIProxyAPI OAuth - no API keys needed +CLI wrapper for instant switching between multiple Claude accounts and alternative models (Gemini, Codex, Antigravity) via CLIProxyAPI OAuth - no API keys needed > [!NOTE] > If you developed a project based on CLIProxyAPI, please open a PR to add it to this list. From 1c6f4be8ae3e3dfb07ec73bb9de46054c9b91347 Mon Sep 17 00:00:00 2001 From: auroraflux <14947763+auroraflux@users.noreply.github.com> Date: Sun, 30 Nov 2025 01:53:16 -0800 Subject: [PATCH 14/15] refactor(executor): dedupe thinking metadata helpers across Gemini executors Extract applyThinkingMetadata and applyThinkingMetadataCLI helpers to payload_helpers.go and use them across all four Gemini-based executors: - gemini_executor.go (Execute, ExecuteStream, CountTokens) - gemini_cli_executor.go (Execute, ExecuteStream, CountTokens) - aistudio_executor.go (translateRequest) - antigravity_executor.go (Execute, ExecuteStream) This eliminates code duplication introduced in the -reasoning suffix PR and centralizes the thinking config application logic. Net reduction: 28 lines of code. --- .../runtime/executor/aistudio_executor.go | 8 +---- .../runtime/executor/antigravity_executor.go | 19 ++--------- .../runtime/executor/gemini_cli_executor.go | 27 ++-------------- internal/runtime/executor/gemini_executor.go | 24 ++------------ internal/runtime/executor/payload_helpers.go | 32 +++++++++++++++++++ 5 files changed, 41 insertions(+), 69 deletions(-) diff --git a/internal/runtime/executor/aistudio_executor.go b/internal/runtime/executor/aistudio_executor.go index 9b59488f..61a06721 100644 --- a/internal/runtime/executor/aistudio_executor.go +++ b/internal/runtime/executor/aistudio_executor.go @@ -308,13 +308,7 @@ func (e *AIStudioExecutor) translateRequest(req cliproxyexecutor.Request, opts c from := opts.SourceFormat to := sdktranslator.FromString("gemini") payload := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), stream) - if budgetOverride, includeOverride, ok := util.GeminiThinkingFromMetadata(req.Metadata); ok && util.ModelSupportsThinking(req.Model) { - if budgetOverride != nil { - norm := util.NormalizeThinkingBudget(req.Model, *budgetOverride) - budgetOverride = &norm - } - payload = util.ApplyGeminiThinkingConfig(payload, budgetOverride, includeOverride) - } + payload = applyThinkingMetadata(payload, req.Metadata, req.Model) payload = util.ConvertThinkingLevelToBudget(payload) payload = util.StripThinkingConfigIfUnsupported(req.Model, payload) payload = fixGeminiImageAspectRatio(req.Model, payload) diff --git a/internal/runtime/executor/antigravity_executor.go b/internal/runtime/executor/antigravity_executor.go index bcc64310..d702ef36 100644 --- a/internal/runtime/executor/antigravity_executor.go +++ b/internal/runtime/executor/antigravity_executor.go @@ -17,7 +17,6 @@ import ( "github.com/google/uuid" "github.com/router-for-me/CLIProxyAPI/v6/internal/config" "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" - "github.com/router-for-me/CLIProxyAPI/v6/internal/util" cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor" sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator" @@ -59,20 +58,6 @@ func (e *AntigravityExecutor) Identifier() string { return antigravityAuthType } // PrepareRequest implements ProviderExecutor. func (e *AntigravityExecutor) PrepareRequest(_ *http.Request, _ *cliproxyauth.Auth) error { return nil } -// applyThinkingMetadata applies thinking config from model suffix metadata (e.g., -reasoning, -thinking-N). -// It trusts user intent when suffix is used, even if registry doesn't have Thinking metadata. -func applyThinkingMetadata(translated []byte, metadata map[string]any, model string) []byte { - budgetOverride, includeOverride, ok := util.GeminiThinkingFromMetadata(metadata) - if !ok { - return translated - } - if budgetOverride != nil && util.ModelSupportsThinking(model) { - norm := util.NormalizeThinkingBudget(model, *budgetOverride) - budgetOverride = &norm - } - return util.ApplyGeminiCLIThinkingConfig(translated, budgetOverride, includeOverride) -} - // Execute handles non-streaming requests via the antigravity generate endpoint. func (e *AntigravityExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) { token, updatedAuth, errToken := e.ensureAccessToken(ctx, auth) @@ -90,7 +75,7 @@ func (e *AntigravityExecutor) Execute(ctx context.Context, auth *cliproxyauth.Au to := sdktranslator.FromString("antigravity") translated := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), false) - translated = applyThinkingMetadata(translated, req.Metadata, req.Model) + translated = applyThinkingMetadataCLI(translated, req.Metadata, req.Model) baseURLs := antigravityBaseURLFallbackOrder(auth) httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0) @@ -183,7 +168,7 @@ func (e *AntigravityExecutor) ExecuteStream(ctx context.Context, auth *cliproxya to := sdktranslator.FromString("antigravity") translated := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), true) - translated = applyThinkingMetadata(translated, req.Metadata, req.Model) + translated = applyThinkingMetadataCLI(translated, req.Metadata, req.Model) baseURLs := antigravityBaseURLFallbackOrder(auth) httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0) diff --git a/internal/runtime/executor/gemini_cli_executor.go b/internal/runtime/executor/gemini_cli_executor.go index f49d0133..0a4477f7 100644 --- a/internal/runtime/executor/gemini_cli_executor.go +++ b/internal/runtime/executor/gemini_cli_executor.go @@ -62,15 +62,8 @@ func (e *GeminiCLIExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth from := opts.SourceFormat to := sdktranslator.FromString("gemini-cli") - budgetOverride, includeOverride, hasOverride := util.GeminiThinkingFromMetadata(req.Metadata) basePayload := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), false) - if hasOverride && util.ModelSupportsThinking(req.Model) { - if budgetOverride != nil { - norm := util.NormalizeThinkingBudget(req.Model, *budgetOverride) - budgetOverride = &norm - } - basePayload = util.ApplyGeminiCLIThinkingConfig(basePayload, budgetOverride, includeOverride) - } + basePayload = applyThinkingMetadataCLI(basePayload, req.Metadata, req.Model) basePayload = util.StripThinkingConfigIfUnsupported(req.Model, basePayload) basePayload = fixGeminiCLIImageAspectRatio(req.Model, basePayload) basePayload = applyPayloadConfigWithRoot(e.cfg, req.Model, "gemini", "request", basePayload) @@ -204,15 +197,8 @@ func (e *GeminiCLIExecutor) ExecuteStream(ctx context.Context, auth *cliproxyaut from := opts.SourceFormat to := sdktranslator.FromString("gemini-cli") - budgetOverride, includeOverride, hasOverride := util.GeminiThinkingFromMetadata(req.Metadata) basePayload := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), true) - if hasOverride && util.ModelSupportsThinking(req.Model) { - if budgetOverride != nil { - norm := util.NormalizeThinkingBudget(req.Model, *budgetOverride) - budgetOverride = &norm - } - basePayload = util.ApplyGeminiCLIThinkingConfig(basePayload, budgetOverride, includeOverride) - } + basePayload = applyThinkingMetadataCLI(basePayload, req.Metadata, req.Model) basePayload = util.StripThinkingConfigIfUnsupported(req.Model, basePayload) basePayload = fixGeminiCLIImageAspectRatio(req.Model, basePayload) basePayload = applyPayloadConfigWithRoot(e.cfg, req.Model, "gemini", "request", basePayload) @@ -408,16 +394,9 @@ func (e *GeminiCLIExecutor) CountTokens(ctx context.Context, auth *cliproxyauth. var lastStatus int var lastBody []byte - budgetOverride, includeOverride, hasOverride := util.GeminiThinkingFromMetadata(req.Metadata) for _, attemptModel := range models { payload := sdktranslator.TranslateRequest(from, to, attemptModel, bytes.Clone(req.Payload), false) - if hasOverride && util.ModelSupportsThinking(req.Model) { - if budgetOverride != nil { - norm := util.NormalizeThinkingBudget(req.Model, *budgetOverride) - budgetOverride = &norm - } - payload = util.ApplyGeminiCLIThinkingConfig(payload, budgetOverride, includeOverride) - } + payload = applyThinkingMetadataCLI(payload, req.Metadata, req.Model) payload = deleteJSONField(payload, "project") payload = deleteJSONField(payload, "model") payload = deleteJSONField(payload, "request.safetySettings") diff --git a/internal/runtime/executor/gemini_executor.go b/internal/runtime/executor/gemini_executor.go index 520d6474..fc7b8e19 100644 --- a/internal/runtime/executor/gemini_executor.go +++ b/internal/runtime/executor/gemini_executor.go @@ -79,13 +79,7 @@ func (e *GeminiExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r from := opts.SourceFormat to := sdktranslator.FromString("gemini") body := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), false) - if budgetOverride, includeOverride, ok := util.GeminiThinkingFromMetadata(req.Metadata); ok && util.ModelSupportsThinking(req.Model) { - if budgetOverride != nil { - norm := util.NormalizeThinkingBudget(req.Model, *budgetOverride) - budgetOverride = &norm - } - body = util.ApplyGeminiThinkingConfig(body, budgetOverride, includeOverride) - } + body = applyThinkingMetadata(body, req.Metadata, req.Model) body = util.StripThinkingConfigIfUnsupported(req.Model, body) body = fixGeminiImageAspectRatio(req.Model, body) body = applyPayloadConfig(e.cfg, req.Model, body) @@ -174,13 +168,7 @@ func (e *GeminiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A from := opts.SourceFormat to := sdktranslator.FromString("gemini") body := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), true) - if budgetOverride, includeOverride, ok := util.GeminiThinkingFromMetadata(req.Metadata); ok && util.ModelSupportsThinking(req.Model) { - if budgetOverride != nil { - norm := util.NormalizeThinkingBudget(req.Model, *budgetOverride) - budgetOverride = &norm - } - body = util.ApplyGeminiThinkingConfig(body, budgetOverride, includeOverride) - } + body = applyThinkingMetadata(body, req.Metadata, req.Model) body = util.StripThinkingConfigIfUnsupported(req.Model, body) body = fixGeminiImageAspectRatio(req.Model, body) body = applyPayloadConfig(e.cfg, req.Model, body) @@ -288,13 +276,7 @@ func (e *GeminiExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Aut from := opts.SourceFormat to := sdktranslator.FromString("gemini") translatedReq := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), false) - if budgetOverride, includeOverride, ok := util.GeminiThinkingFromMetadata(req.Metadata); ok && util.ModelSupportsThinking(req.Model) { - if budgetOverride != nil { - norm := util.NormalizeThinkingBudget(req.Model, *budgetOverride) - budgetOverride = &norm - } - translatedReq = util.ApplyGeminiThinkingConfig(translatedReq, budgetOverride, includeOverride) - } + translatedReq = applyThinkingMetadata(translatedReq, req.Metadata, req.Model) translatedReq = util.StripThinkingConfigIfUnsupported(req.Model, translatedReq) translatedReq = fixGeminiImageAspectRatio(req.Model, translatedReq) respCtx := context.WithValue(ctx, "alt", opts.Alt) diff --git a/internal/runtime/executor/payload_helpers.go b/internal/runtime/executor/payload_helpers.go index 4055f895..1465533a 100644 --- a/internal/runtime/executor/payload_helpers.go +++ b/internal/runtime/executor/payload_helpers.go @@ -4,10 +4,42 @@ import ( "strings" "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + "github.com/router-for-me/CLIProxyAPI/v6/internal/util" "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) +// applyThinkingMetadata applies thinking config from model suffix metadata (e.g., -reasoning, -thinking-N) +// for standard Gemini format payloads. It normalizes the budget when the model supports thinking. +func applyThinkingMetadata(payload []byte, metadata map[string]any, model string) []byte { + budgetOverride, includeOverride, ok := util.GeminiThinkingFromMetadata(metadata) + if !ok { + return payload + } + if !util.ModelSupportsThinking(model) { + return payload + } + if budgetOverride != nil { + norm := util.NormalizeThinkingBudget(model, *budgetOverride) + budgetOverride = &norm + } + return util.ApplyGeminiThinkingConfig(payload, budgetOverride, includeOverride) +} + +// applyThinkingMetadataCLI applies thinking config from model suffix metadata (e.g., -reasoning, -thinking-N) +// for Gemini CLI format payloads (nested under "request"). It normalizes the budget when the model supports thinking. +func applyThinkingMetadataCLI(payload []byte, metadata map[string]any, model string) []byte { + budgetOverride, includeOverride, ok := util.GeminiThinkingFromMetadata(metadata) + if !ok { + return payload + } + if budgetOverride != nil && util.ModelSupportsThinking(model) { + norm := util.NormalizeThinkingBudget(model, *budgetOverride) + budgetOverride = &norm + } + return util.ApplyGeminiCLIThinkingConfig(payload, budgetOverride, includeOverride) +} + // applyPayloadConfig applies payload default and override rules from configuration // to the given JSON payload for the specified model. // Defaults only fill missing fields, while overrides always overwrite existing values. From 717c703bff56f547da4fc96cc3759deda3f71b12 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Mon, 1 Dec 2025 07:22:42 +0800 Subject: [PATCH 15/15] docs(readme): add CCS (Claude Code Switch) to projects list --- README_CN.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README_CN.md b/README_CN.md index be0aa234..d6c0c9bf 100644 --- a/README_CN.md +++ b/README_CN.md @@ -89,6 +89,10 @@ CLIProxyAPI 已内置对 [Amp CLI](https://ampcode.com) 和 Amp IDE 扩展的支 一款基于浏览器的 SRT 字幕翻译工具,可通过 CLI 代理 API 使用您的 Gemini 订阅。内置自动验证与错误修正功能,无需 API 密钥。 +### [CCS (Claude Code Switch)](https://github.com/kaitranntt/ccs) + +CLI 封装器,用于通过 CLIProxyAPI OAuth 即时切换多个 Claude 账户和替代模型(Gemini, Codex, Antigravity),无需 API 密钥。 + > [!NOTE] > 如果你开发了基于 CLIProxyAPI 的项目,请提交一个 PR(拉取请求)将其添加到此列表中。