From 2a7fd1e8972c29ad5cfaa584228e972da56f1a65 Mon Sep 17 00:00:00 2001 From: Loocor Date: Fri, 2 Jan 2026 17:15:09 +0800 Subject: [PATCH 1/6] Add CodMate description to README_CN.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 添加 CodMate 应用的描述,提供 CLI AI 会话管理功能。 --- README_CN.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README_CN.md b/README_CN.md index d89a360c..f8d9fe56 100644 --- a/README_CN.md +++ b/README_CN.md @@ -109,6 +109,10 @@ CLIProxyAPI 已内置对 [Amp CLI](https://ampcode.com) 和 Amp IDE 扩展的支 CLI 封装器,用于通过 CLIProxyAPI OAuth 即时切换多个 Claude 账户和替代模型(Gemini, Codex, Antigravity),无需 API 密钥。 +### [CodMate](https://github.com/loocor/CodMate) + +原生 macOS SwiftUI 应用,用于管理 CLI AI 会话(Claude Code、Codex、Gemini CLI),提供统一的提供商管理、Git 审查、项目组织、全局搜索和终端集成。集成 CLIProxyAPI 为 Codex、Claude、Gemini、Antigravity 和 Qwen Code 提供统一的 OAuth 认证,支持内置和第三方提供商通过单一代理端点重路由 - OAuth 提供商无需 API 密钥。 + ### [ProxyPal](https://github.com/heyhuynhgiabuu/proxypal) 基于 macOS 平台的原生 CLIProxyAPI GUI:配置供应商、模型映射以及OAuth端点,无需 API 密钥。 From dca8d5ded88c0b58565af45dfef16596eb6949ae Mon Sep 17 00:00:00 2001 From: Loocor Date: Fri, 2 Jan 2026 17:15:38 +0800 Subject: [PATCH 2/6] Add CodMate app information to README Added CodMate section to README with app details. --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 4b4d9abc..9283be03 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,10 @@ Browser-based tool to translate SRT subtitles using your Gemini subscription via CLI wrapper for instant switching between multiple Claude accounts and alternative models (Gemini, Codex, Antigravity) via CLIProxyAPI OAuth - no API keys needed +### [CodMate](https://github.com/loocor/CodMate) + +Native macOS SwiftUI app for managing CLI AI sessions (Codex, Claude Code, Gemini CLI) with unified provider management, Git review, project organization, global search, and terminal integration. Integrates CLIProxyAPI to provide OAuth authentication for Codex, Claude, Gemini, Antigravity, and Qwen Code, with built-in and third-party provider rerouting through a single proxy endpoint - no API keys needed for OAuth providers. + ### [ProxyPal](https://github.com/heyhuynhgiabuu/proxypal) Native macOS GUI for managing CLIProxyAPI: configure providers, model mappings, and endpoints via OAuth - no API keys needed. From 672e8549c0b8c98f900f0e577ff274d42c60ea49 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Sat, 3 Jan 2026 21:31:53 +0800 Subject: [PATCH 3/6] docs: reorganize README to adjust CodMate placement Moved CodMate entry under ProxyPal in both English and Chinese README files for consistency in structure and better readability. --- README.md | 8 ++++---- README_CN.md | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 9283be03..f74ab391 100644 --- a/README.md +++ b/README.md @@ -110,10 +110,6 @@ Browser-based tool to translate SRT subtitles using your Gemini subscription via CLI wrapper for instant switching between multiple Claude accounts and alternative models (Gemini, Codex, Antigravity) via CLIProxyAPI OAuth - no API keys needed -### [CodMate](https://github.com/loocor/CodMate) - -Native macOS SwiftUI app for managing CLI AI sessions (Codex, Claude Code, Gemini CLI) with unified provider management, Git review, project organization, global search, and terminal integration. Integrates CLIProxyAPI to provide OAuth authentication for Codex, Claude, Gemini, Antigravity, and Qwen Code, with built-in and third-party provider rerouting through a single proxy endpoint - no API keys needed for OAuth providers. - ### [ProxyPal](https://github.com/heyhuynhgiabuu/proxypal) Native macOS GUI for managing CLIProxyAPI: configure providers, model mappings, and endpoints via OAuth - no API keys needed. @@ -122,6 +118,10 @@ Native macOS GUI for managing CLIProxyAPI: configure providers, model mappings, Native macOS menu bar app that unifies Claude, Gemini, OpenAI, Qwen, and Antigravity subscriptions with real-time quota tracking and smart auto-failover for AI coding tools like Claude Code, OpenCode, and Droid - no API keys needed. +### [CodMate](https://github.com/loocor/CodMate) + +Native macOS SwiftUI app for managing CLI AI sessions (Codex, Claude Code, Gemini CLI) with unified provider management, Git review, project organization, global search, and terminal integration. Integrates CLIProxyAPI to provide OAuth authentication for Codex, Claude, Gemini, Antigravity, and Qwen Code, with built-in and third-party provider rerouting through a single proxy endpoint - no API keys needed for OAuth providers. + > [!NOTE] > If you developed a project based on CLIProxyAPI, please open a PR to add it to this list. diff --git a/README_CN.md b/README_CN.md index f8d9fe56..6e8a455c 100644 --- a/README_CN.md +++ b/README_CN.md @@ -109,10 +109,6 @@ CLIProxyAPI 已内置对 [Amp CLI](https://ampcode.com) 和 Amp IDE 扩展的支 CLI 封装器,用于通过 CLIProxyAPI OAuth 即时切换多个 Claude 账户和替代模型(Gemini, Codex, Antigravity),无需 API 密钥。 -### [CodMate](https://github.com/loocor/CodMate) - -原生 macOS SwiftUI 应用,用于管理 CLI AI 会话(Claude Code、Codex、Gemini CLI),提供统一的提供商管理、Git 审查、项目组织、全局搜索和终端集成。集成 CLIProxyAPI 为 Codex、Claude、Gemini、Antigravity 和 Qwen Code 提供统一的 OAuth 认证,支持内置和第三方提供商通过单一代理端点重路由 - OAuth 提供商无需 API 密钥。 - ### [ProxyPal](https://github.com/heyhuynhgiabuu/proxypal) 基于 macOS 平台的原生 CLIProxyAPI GUI:配置供应商、模型映射以及OAuth端点,无需 API 密钥。 @@ -121,6 +117,10 @@ CLI 封装器,用于通过 CLIProxyAPI OAuth 即时切换多个 Claude 账户 原生 macOS 菜单栏应用,统一管理 Claude、Gemini、OpenAI、Qwen 和 Antigravity 订阅,提供实时配额追踪和智能自动故障转移,支持 Claude Code、OpenCode 和 Droid 等 AI 编程工具,无需 API 密钥。 +### [CodMate](https://github.com/loocor/CodMate) + +原生 macOS SwiftUI 应用,用于管理 CLI AI 会话(Claude Code、Codex、Gemini CLI),提供统一的提供商管理、Git 审查、项目组织、全局搜索和终端集成。集成 CLIProxyAPI 为 Codex、Claude、Gemini、Antigravity 和 Qwen Code 提供统一的 OAuth 认证,支持内置和第三方提供商通过单一代理端点重路由 - OAuth 提供商无需 API 密钥。 + > [!NOTE] > 如果你开发了基于 CLIProxyAPI 的项目,请提交一个 PR(拉取请求)将其添加到此列表中。 From 7a77b23f2df5eb1878468aeb1e115f1227674ad1 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Sun, 4 Jan 2026 00:26:08 +0800 Subject: [PATCH 4/6] feat(executor): add token refresh timeout and improve context handling during refresh Introduced `tokenRefreshTimeout` constant for token refresh operations and enhanced context propagation for `refreshToken` by embedding roundtrip information if available. Adjusted `refreshAuth` to ensure default context initialization and handle cancellation errors appropriately. --- internal/runtime/executor/antigravity_executor.go | 11 +++++++++-- sdk/cliproxy/auth/conductor.go | 7 +++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/internal/runtime/executor/antigravity_executor.go b/internal/runtime/executor/antigravity_executor.go index f0116faa..0baba498 100644 --- a/internal/runtime/executor/antigravity_executor.go +++ b/internal/runtime/executor/antigravity_executor.go @@ -45,6 +45,7 @@ const ( defaultAntigravityAgent = "antigravity/1.104.0 darwin/arm64" antigravityAuthType = "antigravity" refreshSkew = 3000 * time.Second + tokenRefreshTimeout = 30 * time.Second ) var ( @@ -914,7 +915,13 @@ func (e *AntigravityExecutor) ensureAccessToken(ctx context.Context, auth *clipr if accessToken != "" && expiry.After(time.Now().Add(refreshSkew)) { return accessToken, nil, nil } - updated, errRefresh := e.refreshToken(ctx, auth.Clone()) + refreshCtx := context.Background() + if ctx != nil { + if rt, ok := ctx.Value("cliproxy.roundtripper").(http.RoundTripper); ok && rt != nil { + refreshCtx = context.WithValue(refreshCtx, "cliproxy.roundtripper", rt) + } + } + updated, errRefresh := e.refreshToken(refreshCtx, auth.Clone()) if errRefresh != nil { return "", nil, errRefresh } @@ -944,7 +951,7 @@ func (e *AntigravityExecutor) refreshToken(ctx context.Context, auth *cliproxyau httpReq.Header.Set("User-Agent", defaultAntigravityAgent) httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") - httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0) + httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, tokenRefreshTimeout) httpResp, errDo := httpClient.Do(httpReq) if errDo != nil { return auth, errDo diff --git a/sdk/cliproxy/auth/conductor.go b/sdk/cliproxy/auth/conductor.go index c480d965..7839fa1e 100644 --- a/sdk/cliproxy/auth/conductor.go +++ b/sdk/cliproxy/auth/conductor.go @@ -1536,6 +1536,9 @@ func (m *Manager) markRefreshPending(id string, now time.Time) bool { } func (m *Manager) refreshAuth(ctx context.Context, id string) { + if ctx == nil { + ctx = context.Background() + } m.mu.RLock() auth := m.auths[id] var exec ProviderExecutor @@ -1548,6 +1551,10 @@ func (m *Manager) refreshAuth(ctx context.Context, id string) { } cloned := auth.Clone() updated, err := exec.Refresh(ctx, cloned) + if err != nil && errors.Is(err, context.Canceled) { + log.Debugf("refresh canceled for %s, %s", auth.Provider, auth.ID) + return + } log.Debugf("refreshed %s, %s, %v", auth.Provider, auth.ID, err) now := time.Now() if err != nil { From 44b6c872e200c19a01cfde2ec55867f7774b9992 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Sun, 4 Jan 2026 01:18:29 +0800 Subject: [PATCH 5/6] feat(config): add support for `Fork` in OAuth model mappings with alias handling Implemented `Fork` flag in `ModelNameMapping` to allow aliases as additional models while preserving the original model ID. Updated the `applyOAuthModelMappings` logic, added tests for `Fork` behavior, and updated documentation and examples accordingly. --- config.example.yaml | 1 + internal/config/config.go | 9 ++- internal/config/oauth_model_mappings_test.go | 27 +++++++++ internal/watcher/diff/oauth_model_mappings.go | 3 + sdk/cliproxy/service.go | 51 ++++++++++++++-- .../service_oauth_model_mappings_test.go | 58 +++++++++++++++++++ 6 files changed, 141 insertions(+), 8 deletions(-) create mode 100644 internal/config/oauth_model_mappings_test.go create mode 100644 sdk/cliproxy/service_oauth_model_mappings_test.go diff --git a/config.example.yaml b/config.example.yaml index 2a35fe68..332fba70 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -206,6 +206,7 @@ ws-auth: false # gemini-cli: # - name: "gemini-2.5-pro" # original model name under this channel # alias: "g2.5p" # client-visible alias +# fork: true # when true, keep original and also add the alias as an extra model (default: false) # vertex: # - name: "gemini-2.5-pro" # alias: "g2.5p" diff --git a/internal/config/config.go b/internal/config/config.go index 668764d9..e8ae3554 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -145,11 +145,14 @@ type RoutingConfig struct { Strategy string `yaml:"strategy,omitempty" json:"strategy,omitempty"` } -// ModelNameMapping defines a model ID rename mapping for a specific channel. -// It maps the original model name (Name) to the client-visible alias (Alias). +// ModelNameMapping defines a model ID mapping for a specific channel. +// It maps the upstream model name (Name) to the client-visible alias (Alias). +// When Fork is true, the alias is added as an additional model in listings while +// keeping the original model ID available. type ModelNameMapping struct { Name string `yaml:"name" json:"name"` Alias string `yaml:"alias" json:"alias"` + Fork bool `yaml:"fork,omitempty" json:"fork,omitempty"` } // AmpModelMapping defines a model name mapping for Amp CLI requests. @@ -551,7 +554,7 @@ func (cfg *Config) SanitizeOAuthModelMappings() { } seenName[nameKey] = struct{}{} seenAlias[aliasKey] = struct{}{} - clean = append(clean, ModelNameMapping{Name: name, Alias: alias}) + clean = append(clean, ModelNameMapping{Name: name, Alias: alias, Fork: mapping.Fork}) } if len(clean) > 0 { out[channel] = clean diff --git a/internal/config/oauth_model_mappings_test.go b/internal/config/oauth_model_mappings_test.go new file mode 100644 index 00000000..7b801a79 --- /dev/null +++ b/internal/config/oauth_model_mappings_test.go @@ -0,0 +1,27 @@ +package config + +import "testing" + +func TestSanitizeOAuthModelMappings_PreservesForkFlag(t *testing.T) { + cfg := &Config{ + OAuthModelMappings: map[string][]ModelNameMapping{ + " CoDeX ": { + {Name: " gpt-5 ", Alias: " g5 ", Fork: true}, + {Name: "gpt-6", Alias: "g6"}, + }, + }, + } + + cfg.SanitizeOAuthModelMappings() + + mappings := cfg.OAuthModelMappings["codex"] + if len(mappings) != 2 { + t.Fatalf("expected 2 sanitized mappings, got %d", len(mappings)) + } + if mappings[0].Name != "gpt-5" || mappings[0].Alias != "g5" || !mappings[0].Fork { + t.Fatalf("expected first mapping to be gpt-5->g5 fork=true, got name=%q alias=%q fork=%v", mappings[0].Name, mappings[0].Alias, mappings[0].Fork) + } + if mappings[1].Name != "gpt-6" || mappings[1].Alias != "g6" || mappings[1].Fork { + t.Fatalf("expected second mapping to be gpt-6->g6 fork=false, got name=%q alias=%q fork=%v", mappings[1].Name, mappings[1].Alias, mappings[1].Fork) + } +} diff --git a/internal/watcher/diff/oauth_model_mappings.go b/internal/watcher/diff/oauth_model_mappings.go index 9228dbab..c002855c 100644 --- a/internal/watcher/diff/oauth_model_mappings.go +++ b/internal/watcher/diff/oauth_model_mappings.go @@ -80,6 +80,9 @@ func summarizeOAuthModelMappingList(list []config.ModelNameMapping) OAuthModelMa continue } key := name + "->" + alias + if mapping.Fork { + key += "|fork" + } if _, exists := seen[key]; exists { continue } diff --git a/sdk/cliproxy/service.go b/sdk/cliproxy/service.go index 21690f8e..b91ce015 100644 --- a/sdk/cliproxy/service.go +++ b/sdk/cliproxy/service.go @@ -1231,7 +1231,13 @@ func applyOAuthModelMappings(cfg *config.Config, provider, authKind string, mode if len(mappings) == 0 { return models } - forward := make(map[string]string, len(mappings)) + + type mappingEntry struct { + alias string + fork bool + } + + forward := make(map[string]mappingEntry, len(mappings)) for i := range mappings { name := strings.TrimSpace(mappings[i].Name) alias := strings.TrimSpace(mappings[i].Alias) @@ -1245,7 +1251,7 @@ func applyOAuthModelMappings(cfg *config.Config, provider, authKind string, mode if _, exists := forward[key]; exists { continue } - forward[key] = alias + forward[key] = mappingEntry{alias: alias, fork: mappings[i].Fork} } if len(forward) == 0 { return models @@ -1260,10 +1266,45 @@ func applyOAuthModelMappings(cfg *config.Config, provider, authKind string, mode if id == "" { continue } - mappedID := id - if to, ok := forward[strings.ToLower(id)]; ok && strings.TrimSpace(to) != "" { - mappedID = strings.TrimSpace(to) + key := strings.ToLower(id) + entry, ok := forward[key] + if !ok { + if _, exists := seen[key]; exists { + continue + } + seen[key] = struct{}{} + out = append(out, model) + continue } + mappedID := strings.TrimSpace(entry.alias) + if mappedID == "" { + if _, exists := seen[key]; exists { + continue + } + seen[key] = struct{}{} + out = append(out, model) + continue + } + + if entry.fork { + if _, exists := seen[key]; !exists { + seen[key] = struct{}{} + out = append(out, model) + } + aliasKey := strings.ToLower(mappedID) + if _, exists := seen[aliasKey]; exists { + continue + } + seen[aliasKey] = struct{}{} + clone := *model + clone.ID = mappedID + if clone.Name != "" { + clone.Name = rewriteModelInfoName(clone.Name, id, mappedID) + } + out = append(out, &clone) + continue + } + uniqueKey := strings.ToLower(mappedID) if _, exists := seen[uniqueKey]; exists { continue diff --git a/sdk/cliproxy/service_oauth_model_mappings_test.go b/sdk/cliproxy/service_oauth_model_mappings_test.go new file mode 100644 index 00000000..7d8da08a --- /dev/null +++ b/sdk/cliproxy/service_oauth_model_mappings_test.go @@ -0,0 +1,58 @@ +package cliproxy + +import ( + "testing" + + "github.com/router-for-me/CLIProxyAPI/v6/sdk/config" +) + +func TestApplyOAuthModelMappings_Rename(t *testing.T) { + cfg := &config.Config{ + OAuthModelMappings: map[string][]config.ModelNameMapping{ + "codex": { + {Name: "gpt-5", Alias: "g5"}, + }, + }, + } + models := []*ModelInfo{ + {ID: "gpt-5", Name: "models/gpt-5"}, + } + + out := applyOAuthModelMappings(cfg, "codex", "oauth", models) + if len(out) != 1 { + t.Fatalf("expected 1 model, got %d", len(out)) + } + if out[0].ID != "g5" { + t.Fatalf("expected model id %q, got %q", "g5", out[0].ID) + } + if out[0].Name != "models/g5" { + t.Fatalf("expected model name %q, got %q", "models/g5", out[0].Name) + } +} + +func TestApplyOAuthModelMappings_ForkAddsAlias(t *testing.T) { + cfg := &config.Config{ + OAuthModelMappings: map[string][]config.ModelNameMapping{ + "codex": { + {Name: "gpt-5", Alias: "g5", Fork: true}, + }, + }, + } + models := []*ModelInfo{ + {ID: "gpt-5", Name: "models/gpt-5"}, + } + + out := applyOAuthModelMappings(cfg, "codex", "oauth", models) + if len(out) != 2 { + t.Fatalf("expected 2 models, got %d", len(out)) + } + if out[0].ID != "gpt-5" { + t.Fatalf("expected first model id %q, got %q", "gpt-5", out[0].ID) + } + if out[1].ID != "g5" { + t.Fatalf("expected second model id %q, got %q", "g5", out[1].ID) + } + if out[1].Name != "models/g5" { + t.Fatalf("expected forked model name %q, got %q", "models/g5", out[1].Name) + } +} From 7815ee338dae062901aa89f58a82bfcfad888729 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Sun, 4 Jan 2026 01:36:51 +0800 Subject: [PATCH 6/6] fix(translator): adjust `message_delta` emission boundary in Claude-to-OpenAI conversion Fixed incorrect boundary logic for `message_delta` emission, ensuring proper handling of usage updates and `emitMessageStopIfNeeded` within the response loop. --- .../openai/claude/openai_claude_response.go | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/internal/translator/openai/claude/openai_claude_response.go b/internal/translator/openai/claude/openai_claude_response.go index 27ab082b..1629545d 100644 --- a/internal/translator/openai/claude/openai_claude_response.go +++ b/internal/translator/openai/claude/openai_claude_response.go @@ -299,17 +299,16 @@ func convertOpenAIStreamingChunkToAnthropic(rawJSON []byte, param *ConvertOpenAI inputTokens = promptTokens.Int() outputTokens = completionTokens.Int() } + // Send message_delta with usage + messageDeltaJSON := `{"type":"message_delta","delta":{"stop_reason":"","stop_sequence":null},"usage":{"input_tokens":0,"output_tokens":0}}` + messageDeltaJSON, _ = sjson.Set(messageDeltaJSON, "delta.stop_reason", mapOpenAIFinishReasonToAnthropic(param.FinishReason)) + messageDeltaJSON, _ = sjson.Set(messageDeltaJSON, "usage.input_tokens", inputTokens) + messageDeltaJSON, _ = sjson.Set(messageDeltaJSON, "usage.output_tokens", outputTokens) + results = append(results, "event: message_delta\ndata: "+messageDeltaJSON+"\n\n") + param.MessageDeltaSent = true + + emitMessageStopIfNeeded(param, &results) } - // Send message_delta with usage - messageDeltaJSON := `{"type":"message_delta","delta":{"stop_reason":"","stop_sequence":null},"usage":{"input_tokens":0,"output_tokens":0}}` - messageDeltaJSON, _ = sjson.Set(messageDeltaJSON, "delta.stop_reason", mapOpenAIFinishReasonToAnthropic(param.FinishReason)) - messageDeltaJSON, _ = sjson.Set(messageDeltaJSON, "usage.input_tokens", inputTokens) - messageDeltaJSON, _ = sjson.Set(messageDeltaJSON, "usage.output_tokens", outputTokens) - results = append(results, "event: message_delta\ndata: "+messageDeltaJSON+"\n\n") - param.MessageDeltaSent = true - - emitMessageStopIfNeeded(param, &results) - } return results