diff --git a/.gitignore b/.gitignore
index b086116945..0ef1222973 100644
--- a/.gitignore
+++ b/.gitignore
@@ -50,3 +50,4 @@ _bmad-output/*
# macOS
.DS_Store
._*
+.gocache/
diff --git a/.goreleaser.yml b/.goreleaser.yml
index f8bebfc1d9..c479255eaf 100644
--- a/.goreleaser.yml
+++ b/.goreleaser.yml
@@ -19,6 +19,8 @@ builds:
archives:
- id: "cli-proxy-api"
format: tar.gz
+ name_template: >-
+ {{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{- if eq .Arch "arm64" -}}aarch64{{- else -}}{{ .Arch }}{{- end -}}
format_overrides:
- goos: windows
format: zip
diff --git a/Dockerfile b/Dockerfile
index 3e10c4f9f8..b4caaee325 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -14,7 +14,7 @@ ARG BUILD_DATE=unknown
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w -X 'main.Version=${VERSION}' -X 'main.Commit=${COMMIT}' -X 'main.BuildDate=${BUILD_DATE}'" -o ./CLIProxyAPI ./cmd/server/
-FROM alpine:3.22.0
+FROM alpine:3.23
RUN apk add --no-cache tzdata
@@ -32,4 +32,4 @@ ENV TZ=Asia/Shanghai
RUN cp /usr/share/zoneinfo/${TZ} /etc/localtime && echo "${TZ}" > /etc/timezone
-CMD ["./CLIProxyAPI"]
\ No newline at end of file
+CMD ["./CLIProxyAPI"]
diff --git a/README.md b/README.md
index 53acdd5178..8064db7d77 100644
--- a/README.md
+++ b/README.md
@@ -10,23 +10,19 @@ So you can use local or multi-account CLI access with OpenAI(include Responses)/
## Sponsor
-[](https://z.ai/subscribe?ic=8JVLJQFSKB)
+[](https://www.packyapi.com/register?aff=cliproxyapi)
-This project is sponsored by Z.ai, supporting us with their GLM CODING PLAN.
+Thanks to PackyCode for sponsoring this project!
-GLM CODING PLAN is a subscription service designed for AI coding, starting at just $10/month. It provides access to their flagship GLM-4.7 & (GLM-5 Only Available for Pro Users)model across 10+ popular AI coding tools (Claude Code, Cline, Roo Code, etc.), offering developers top-tier, fast, and stable coding experiences.
+PackyCode is a reliable and efficient API relay service provider, offering relay services for Claude Code, Codex, Gemini, and more.
-Get 10% OFF GLM CODING PLAN:https://z.ai/subscribe?ic=8JVLJQFSKB
+PackyCode provides special discounts for our software users: register using this link and enter the "cliproxyapi" promo code during recharge to get 10% off.
---
- |
-Thanks to PackyCode for sponsoring this project! PackyCode is a reliable and efficient API relay service provider, offering relay services for Claude Code, Codex, Gemini, and more. PackyCode provides special discounts for our software users: register using this link and enter the "cliproxyapi" promo code during recharge to get 10% off. |
-
-
 |
Thanks to AICodeMirror for sponsoring this project! AICodeMirror provides official high-stability relay services for Claude Code / Codex / Gemini CLI, with enterprise-grade concurrency, fast invoicing, and 24/7 dedicated technical support. Claude Code / Codex / Gemini official channels at 38% / 2% / 9% of original price, with extra discounts on top-ups! AICodeMirror offers special benefits for CLIProxyAPI users: register via this link to enjoy 20% off your first top-up, and enterprise customers can get up to 25% off! |
@@ -35,12 +31,10 @@ Get 10% OFF GLM CODING PLAN:https://z.ai/subscribe?ic=8JVLJQFSKB
Huge thanks to BmoPlus for sponsoring this project! BmoPlus is a highly reliable AI account provider built strictly for heavy AI users and developers. They offer rock-solid, ready-to-use accounts and official top-up services for ChatGPT Plus / ChatGPT Pro (Full Warranty) / Claude Pro / Super Grok / Gemini Pro. By registering and ordering through BmoPlus - Premium AI Accounts & Top-ups, users can unlock the mind-blowing rate of 10% of the official GPT subscription price (90% OFF)! |
- |
-Thanks to LingtrueAPI for its sponsorship of this project! LingtrueAPI is a global large - model API intermediary service platform that provides API calling services for various top - notch models such as Claude Code, Codex, and Gemini. It is committed to enabling users to connect to global AI capabilities at low cost and with high stability. LingtrueAPI offers special discounts to users of this software: register using this link, and enter the promo code "LingtrueAPI" when making the first recharge to enjoy a 10% discount. |
-
-
- |
-Thanks to Poixe AI for sponsoring this project! Poixe AI provides reliable LLM API services. You can leverage the platform's API endpoints to seamlessly build AI-powered products. Additionally, you can become a vendor by providing AI API resources to the platform and earn revenue. Register through the exclusive CLIProxyAPI referral link and receive a bonus of $5 USD on your first top-up. |
+ |
+Thanks to VisionCoder for supporting this project. VisionCoder Developer Platform is a reliable and efficient API relay service provider, offering access to mainstream AI models such as Claude Code, Codex, and Gemini. It helps developers and teams integrate AI capabilities more easily and improve productivity.
+
+VisionCoder is also offering our users a limited-time Token Plan promotion: buy 1 month and get 1 month free. |
@@ -51,7 +45,7 @@ Get 10% OFF GLM CODING PLAN:https://z.ai/subscribe?ic=8JVLJQFSKB
- OpenAI Codex support (GPT models) via OAuth login
- Claude Code support via OAuth login
- Amp CLI and IDE extensions support with provider routing
-- Streaming and non-streaming responses
+- Streaming, non-streaming, and WebSocket responses where supported
- Function calling/tools support
- Multimodal input support (text and images)
- Multiple accounts with round-robin load balancing (Gemini, OpenAI, Claude)
@@ -72,6 +66,22 @@ CLIProxyAPI Guides: [https://help.router-for.me/](https://help.router-for.me/)
see [MANAGEMENT_API.md](https://help.router-for.me/management/api)
+## Usage Statistics
+
+Since v6.10.0, CLIProxyAPI and [CPAMC](https://github.com/router-for-me/Cli-Proxy-API-Management-Center) no longer ship built-in usage statistics. If you need usage statistics, use:
+
+### [CPA Usage Keeper](https://github.com/Willxup/cpa-usage-keeper)
+
+Standalone persistence and visualization service for CLIProxyAPI, with periodic data sync, SQLite storage, aggregate APIs, and a built-in dashboard for usage and statistics.
+
+### [CLIProxyAPI Usage Dashboard](https://github.com/zhanglunet/cliproxyapi-usage-dashboard)
+
+Local-first usage and quota dashboard for CLIProxyAPI. It collects per-request token usage from the Redis-compatible usage queue into SQLite, visualizes daily and recent-window usage by account and model, and shows Codex 5h/7d quota remaining in a local web UI.
+
+### [CPA-Manager](https://github.com/seakee/CPA-Manager)
+
+Full CLIProxyAPI management center with request-level monitoring and cost estimates. CPA-Manager tracks collected requests by account, model, channel, latency, status, and token usage; estimates cost with editable model prices and one-click LiteLLM price sync; persists events in SQLite; and provides Codex account-pool operations with batch inspection, quota detection, unhealthy account discovery, cleanup suggestions, and one-click execution for day-to-day multi-account maintenance.
+
## Amp CLI Support
CLIProxyAPI includes integrated support for [Amp CLI](https://ampcode.com) and Amp IDE extensions, enabling you to use your Google/ChatGPT/Claude OAuth subscriptions with Amp's coding tools:
@@ -120,7 +130,7 @@ Native macOS menu bar app to use your Claude Code & ChatGPT subscriptions with A
### [Subtitle Translator](https://github.com/VjayC/SRT-Subtitle-Translator-Validator)
-Browser-based tool to translate SRT subtitles using your Gemini subscription via CLIProxyAPI with automatic validation/error correction - no API keys needed
+A cross-platform desktop and web app to translate and validate SRT subtitles using your existing LLM subscriptions (Gemini, ChatGPT, Claude, etc.) via CLIProxyAPI - no API keys needed.
### [CCS (Claude Code Switch)](https://github.com/kaitranntt/ccs)
@@ -181,6 +191,14 @@ Cross-platform desktop app (macOS, Windows, Linux) wrapping CLIProxyAPI with a n
Ready-to-use cross-platform quota inspector for CLIProxyAPI, supporting per-account codex 5h/7d quota windows, plan-based sorting, status coloring, and multi-account summary analytics.
+### [CodexCliPlus](https://github.com/C4AL/CodexCliPlus)
+
+Windows-focused, local-first desktop management platform for Codex CLI built on CLIProxyAPI, focused on simplifying local setup, account and runtime management, and providing a more complete Codex CLI experience for local users.
+
+### [CLIProxy Pool Watch](https://github.com/murasame612/CLIProxyPoolWidget)
+
+Native macOS SwiftUI app for monitoring ChatGPT/Codex account quotas in CLIProxyAPI pools. Displays account availability, Plus-base capacity, 5-hour and weekly quota bars, plan weights, and restore forecasts through the Management API.
+
> [!NOTE]
> If you developed a project based on CLIProxyAPI, please open a PR to add it to this list.
@@ -198,6 +216,10 @@ Never stop coding. Smart routing to FREE & low-cost AI models with automatic fal
OmniRoute is an AI gateway for multi-provider LLMs: an OpenAI-compatible endpoint with smart routing, load balancing, retries, and fallbacks. Add policies, rate limits, caching, and observability for reliable, cost-aware inference.
+### [Playful Proxy API Panel (PPAP)](https://github.com/daishuge/playful-proxy-api-panel)
+
+A public CLIProxyAPI-compatible fork and bundled management panel. It keeps upstream-style usage while restoring built-in usage statistics, adding cache hit rate, first-byte latency, TPS tracking, and Docker-oriented self-hosted installation docs.
+
> [!NOTE]
> If you have developed a port of CLIProxyAPI or a project inspired by it, please open a PR to add it to this list.
diff --git a/README_CN.md b/README_CN.md
index 86ea954209..c912eb47a1 100644
--- a/README_CN.md
+++ b/README_CN.md
@@ -10,37 +10,31 @@
## 赞助商
-[](https://www.bigmodel.cn/claude-code?ic=RRVJPB5SII)
+[](https://www.packyapi.com/register?aff=cliproxyapi)
-本项目由 Z智谱 提供赞助, 他们通过 GLM CODING PLAN 对本项目提供技术支持。
+感谢 PackyCode 对本项目的赞助!
-GLM CODING PLAN 是专为AI编码打造的订阅套餐,每月最低仅需20元,即可在十余款主流AI编码工具如 Claude Code、Cline、Roo Code 中畅享智谱旗舰模型GLM-4.7(受限于算力,目前仅限Pro用户开放),为开发者提供顶尖的编码体验。
+PackyCode 是一家可靠高效的 API 中转服务商,提供 Claude Code、Codex、Gemini 等多种服务的中转。
-智谱AI为本产品提供了特别优惠,使用以下链接购买可以享受九折优惠:https://www.bigmodel.cn/claude-code?ic=RRVJPB5SII
+PackyCode 为本软件用户提供了特别优惠:使用此链接注册,并在充值时输入 "cliproxyapi" 优惠码即可享受九折优惠。
---
@@ -51,7 +45,7 @@ GLM CODING PLAN 是专为AI编码打造的订阅套餐,每月最低仅需20元
- 为 CLI 模型提供 OpenAI/Gemini/Claude/Codex 兼容的 API 端点
- 新增 OpenAI Codex(GPT 系列)支持(OAuth 登录)
- 新增 Claude Code 支持(OAuth 登录)
-- 支持流式与非流式响应
+- 支持流式、非流式响应,以及受支持场景下的 WebSocket 响应
- 函数调用/工具支持
- 多模态输入(文本、图片)
- 多账户支持与轮询负载均衡(Gemini、OpenAI、Claude)
@@ -72,6 +66,22 @@ CLIProxyAPI 用户手册: [https://help.router-for.me/](https://help.router-fo
请参见 [MANAGEMENT_API_CN.md](https://help.router-for.me/cn/management/api)
+## 使用量统计
+
+自v6.10.0版本以后,CLIProxyAPI及 [CPAMC](https://github.com/router-for-me/Cli-Proxy-API-Management-Center) 项目不再预置数据统计功能,如果有数据统计需求的请使用以下项目:
+
+### [CPA Usage Keeper](https://github.com/Willxup/cpa-usage-keeper)
+
+独立的 CLIProxyAPI 使用量持久化与可视化服务,定期同步 CLIProxyAPI 数据,存储到 SQLite,提供聚合 API,并内置使用量分析与统计仪表盘。
+
+### [CLIProxyAPI Usage Dashboard](https://github.com/zhanglunet/cliproxyapi-usage-dashboard)
+
+面向 CLIProxyAPI 的本地优先使用量与配额看板。它从 Redis 兼容使用量队列采集每次请求的 Token 消耗并写入 SQLite,按账号和模型可视化每日及最近时间窗口的用量,并在本地网页中显示 Codex 5h/7d 配额余量。
+
+### [CPA-Manager](https://github.com/seakee/CPA-Manager)
+
+面向 CLIProxyAPI 的完整管理中心,提供请求级监控和费用预估。CPA-Manager 可按账号、模型、渠道、延迟、状态和 token 用量追踪采集到的请求;支持可编辑模型价格与一键同步 LiteLLM 价格来估算费用;用 SQLite 持久化事件;并提供面向 Codex 账号池的批量巡检、配额识别、异常账号定位、清理建议与一键执行能力,适合多账号池的日常运维管理。
+
## Amp CLI 支持
CLIProxyAPI 已内置对 [Amp CLI](https://ampcode.com) 和 Amp IDE 扩展的支持,可让你使用自己的 Google/ChatGPT/Claude OAuth 订阅来配合 Amp 编码工具:
@@ -119,7 +129,7 @@ CLIProxyAPI 已内置对 [Amp CLI](https://ampcode.com) 和 Amp IDE 扩展的支
### [Subtitle Translator](https://github.com/VjayC/SRT-Subtitle-Translator-Validator)
-一款基于浏览器的 SRT 字幕翻译工具,可通过 CLI 代理 API 使用您的 Gemini 订阅。内置自动验证与错误修正功能,无需 API 密钥。
+一款跨平台的桌面和 Web 应用程序,可通过 CLIProxyAPI 使用您现有的 LLM 订阅(Gemini、ChatGPT、Claude, etc.)来翻译和验证 SRT 字幕 - 无需 API 密钥。
### [CCS (Claude Code Switch)](https://github.com/kaitranntt/ccs)
@@ -177,6 +187,14 @@ Shadow AI 是一款专为受限环境设计的 AI 辅助工具。提供无窗口
上手即用的面向 CLIProxyAPI 跨平台配额查询工具,支持按账号展示 codex 5h/7d 配额窗口、按计划排序、状态着色及多账号汇总分析。
+### [CodexCliPlus](https://github.com/C4AL/CodexCliPlus)
+
+基于 CLIProxyAPI 的 Windows Codex CLI 本地优先桌面管理平台,聚焦简化本机配置、账号与运行状态管理,并为本地用户提供更完整的 Codex CLI 使用体验。
+
+### [CLIProxy Pool Watch](https://github.com/murasame612/CLIProxyPoolWidget)
+
+原生 macOS SwiftUI 应用,用于监控 CLIProxyAPI 池中的 ChatGPT/Codex 账号额度。通过 Management API 展示账号可用状态、Plus 基准容量、5 小时与周额度进度条、套餐权重和恢复预测。
+
> [!NOTE]
> 如果你开发了基于 CLIProxyAPI 的项目,请提交一个 PR(拉取请求)将其添加到此列表中。
@@ -194,6 +212,10 @@ Shadow AI 是一款专为受限环境设计的 AI 辅助工具。提供无窗口
OmniRoute 是一个面向多供应商大语言模型的 AI 网关:它提供兼容 OpenAI 的端点,具备智能路由、负载均衡、重试及回退机制。通过添加策略、速率限制、缓存和可观测性,确保推理过程既可靠又具备成本意识。
+### [Playful Proxy API Panel (PPAP)](https://github.com/daishuge/playful-proxy-api-panel)
+
+一个公开的 CLIProxyAPI 兼容二开版本和配套管理面板,尽量保持与上游一致的使用方式,同时恢复内置使用量统计,并补充缓存命中率、首字响应时间、TPS 记录和面向 Docker 自托管的安装说明。
+
> [!NOTE]
> 如果你开发了 CLIProxyAPI 的移植或衍生项目,请提交 PR 将其添加到此列表中。
diff --git a/README_JA.md b/README_JA.md
index 8c34325b49..ba96c3c1e5 100644
--- a/README_JA.md
+++ b/README_JA.md
@@ -10,23 +10,19 @@ OAuth経由でOpenAI Codex(GPTモデル)およびClaude Codeもサポート
## スポンサー
-[](https://z.ai/subscribe?ic=8JVLJQFSKB)
+[](https://www.packyapi.com/register?aff=cliproxyapi)
-本プロジェクトはZ.aiにスポンサーされており、GLM CODING PLANの提供を受けています。
+PackyCodeのスポンサーシップに感謝します!
-GLM CODING PLANはAIコーディング向けに設計されたサブスクリプションサービスで、月額わずか$10から利用可能です。フラッグシップのGLM-4.7および(GLM-5はProユーザーのみ利用可能)モデルを10以上の人気AIコーディングツール(Claude Code、Cline、Roo Codeなど)で利用でき、開発者にトップクラスの高速かつ安定したコーディング体験を提供します。
+PackyCodeは信頼性が高く効率的なAPIリレーサービスプロバイダーで、Claude Code、Codex、Geminiなどのリレーサービスを提供しています。
-GLM CODING PLANを10%割引で取得:https://z.ai/subscribe?ic=8JVLJQFSKB
+PackyCodeは当ソフトウェアのユーザーに特別割引を提供しています:こちらのリンクから登録し、チャージ時にプロモーションコード「cliproxyapi」を入力すると10%割引になります。
---
- |
-PackyCodeのスポンサーシップに感謝します!PackyCodeは信頼性が高く効率的なAPIリレーサービスプロバイダーで、Claude Code、Codex、Geminiなどのリレーサービスを提供しています。PackyCodeは当ソフトウェアのユーザーに特別割引を提供しています:こちらのリンクから登録し、チャージ時にプロモーションコード「cliproxyapi」を入力すると10%割引になります。 |
-
-
 |
AICodeMirrorのスポンサーシップに感謝します!AICodeMirrorはClaude Code / Codex / Gemini CLI向けの公式高安定性リレーサービスを提供しており、エンタープライズグレードの同時接続、迅速な請求書発行、24時間365日の専任技術サポートを備えています。Claude Code / Codex / Geminiの公式チャネルが元の価格の38% / 2% / 9%で利用でき、チャージ時にはさらに割引があります!CLIProxyAPIユーザー向けの特別特典:こちらのリンクから登録すると、初回チャージが20%割引になり、エンタープライズのお客様は最大25%割引を受けられます! |
@@ -35,12 +31,8 @@ GLM CODING PLANを10%割引で取得:https://z.ai/subscribe?ic=8JVLJQFSKB
本プロジェクトにご支援いただいた BmoPlus に感謝いたします!BmoPlusは、AIサブスクリプションのヘビーユーザー向けに特化した信頼性の高いAIアカウントサービスプロバイダーであり、安定した ChatGPT Plus / ChatGPT Pro (完全保証) / Claude Pro / Super Grok / Gemini Pro の公式代行チャージおよび即納アカウントを提供しています。こちらのBmoPlus AIアカウント専門店/代行チャージ経由でご登録・ご注文いただいたユーザー様は、GPTを 公式サイト価格の約1割(90% OFF) という驚異的な価格でご利用いただけます! |
- |
-LingtrueAPIのスポンサーシップに感謝します!LingtrueAPIはグローバルな大規模モデルAPIリレーサービスプラットフォームで、Claude Code、Codex、GeminiなどのトップモデルAPI呼び出しサービスを提供し、ユーザーが低コストかつ高い安定性で世界中のAI能力に接続できるよう支援しています。LingtrueAPIは本ソフトウェアのユーザーに特別割引を提供しています:こちらのリンクから登録し、初回チャージ時にプロモーションコード「LingtrueAPI」を入力すると10%割引になります。 |
-
-
- |
-Poixe AIのスポンサーシップに感謝します!Poixe AIは信頼できるAIモデルAPIサービスを提供しており、プラットフォームが提供するLLM APIを使って簡単にAI製品を構築できます。また、サプライヤーとしてプラットフォームに大規模モデルのリソースを提供し、収益を得ることも可能です。CLIProxyAPIの専用リンクから登録すると、チャージ時に追加で$5が付与されます。 |
+ |
+VisionCoderのご支援に感謝します!VisionCoder 開発プラットフォーム は、信頼性が高く効率的なAPIリレーサービスプロバイダーで、Claude Code、Codex、Geminiなどの主要AIモデルを提供し、開発者やチームがより簡単にAI機能を統合して生産性を向上できるよう支援します。さらに、VisionCoderはユーザー向けに Token Plan の期間限定キャンペーン(1か月購入で1か月分プレゼント)も提供しています。 |
@@ -51,7 +43,7 @@ GLM CODING PLANを10%割引で取得:https://z.ai/subscribe?ic=8JVLJQFSKB
- OAuthログインによるOpenAI Codexサポート(GPTモデル)
- OAuthログインによるClaude Codeサポート
- プロバイダールーティングによるAmp CLIおよびIDE拡張機能のサポート
-- ストリーミングおよび非ストリーミングレスポンス
+- ストリーミング、非ストリーミング、および対応環境でのWebSocketレスポンス
- 関数呼び出し/ツールのサポート
- マルチモーダル入力サポート(テキストと画像)
- ラウンドロビン負荷分散による複数アカウント対応(Gemini、OpenAI、Claude)
@@ -72,6 +64,22 @@ CLIProxyAPIガイド:[https://help.router-for.me/](https://help.router-for.me/
[MANAGEMENT_API.md](https://help.router-for.me/management/api)を参照
+## 使用量統計
+
+v6.10.0以降、CLIProxyAPIおよび [CPAMC](https://github.com/router-for-me/Cli-Proxy-API-Management-Center) プロジェクトには使用量統計機能がプリセットされなくなりました。使用量統計が必要な場合は、次のプロジェクトをご利用ください:
+
+### [CPA Usage Keeper](https://github.com/Willxup/cpa-usage-keeper)
+
+CLIProxyAPI向けの独立した使用量永続化・可視化サービス。CLIProxyAPIデータを定期同期してSQLiteに保存し、集計APIと、使用量や各種統計を確認できる組み込みダッシュボードを提供します。
+
+### [CLIProxyAPI Usage Dashboard](https://github.com/zhanglunet/cliproxyapi-usage-dashboard)
+
+CLIProxyAPI向けのローカル優先の使用量・クォータダッシュボード。Redis互換の使用量キューからリクエストごとのToken使用量を収集してSQLiteに保存し、アカウント別・モデル別の日次および直近時間枠の使用量を可視化し、Codex 5h/7dクォータ残量をローカルWeb UIで表示します。
+
+### [CPA-Manager](https://github.com/seakee/CPA-Manager)
+
+リクエスト単位の監視とコスト推定を備えたCLIProxyAPI向けのフル管理センターです。CPA-Managerは、収集したリクエストをアカウント、モデル、チャネル、レイテンシ、ステータス、Token使用量ごとに追跡し、編集可能なモデル価格とLiteLLM価格のワンクリック同期でコストを推定します。SQLiteでイベントを永続化し、Codexアカウントプール向けに一括検査、クォータ判定、異常アカウント検出、クリーンアップ提案、ワンクリック実行を提供し、日常的なマルチアカウント運用に適しています。
+
## Amp CLIサポート
CLIProxyAPIは[Amp CLI](https://ampcode.com)およびAmp IDE拡張機能の統合サポートを含んでおり、Google/ChatGPT/ClaudeのOAuthサブスクリプションをAmpのコーディングツールで使用できます:
@@ -120,7 +128,7 @@ macOSネイティブのメニューバーアプリで、Claude CodeとChatGPTの
### [Subtitle Translator](https://github.com/VjayC/SRT-Subtitle-Translator-Validator)
-CLIProxyAPI経由でGeminiサブスクリプションを使用してSRT字幕を翻訳するブラウザベースのツール。自動検証/エラー修正機能付き - APIキー不要
+CLIProxyAPI経由で既存のLLMサブスクリプション(Gemini、ChatGPT、Claude, etc.)を使用してSRT字幕を翻訳および検証する、クロスプラットフォームのデスクトップおよびWebアプリ - APIキー不要。
### [CCS (Claude Code Switch)](https://github.com/kaitranntt/ccs)
@@ -178,6 +186,14 @@ CLIProxyAPIをネイティブGUIでラップしたクロスプラットフォー
CLIProxyAPI向けのすぐに使えるクロスプラットフォームのクォータ確認ツール。アカウントごとの codex 5h/7d クォータ表示、プラン別ソート、ステータス色分け、複数アカウントの集計分析に対応。
+### [CodexCliPlus](https://github.com/C4AL/CodexCliPlus)
+
+CLIProxyAPIを基盤にしたWindows向けのローカル優先Codex CLIデスクトップ管理プラットフォーム。ローカル設定、アカウント、実行状態の管理を簡素化し、ローカルユーザーにより包括的なCodex CLI体験を提供します。
+
+### [CLIProxy Pool Watch](https://github.com/murasame612/CLIProxyPoolWidget)
+
+CLIProxyAPIプール内のChatGPT/Codexアカウントクォータを監視するmacOSネイティブSwiftUIアプリ。Management APIを通じて、アカウントの可用性、Plus基準の容量、5時間/週次クォータバー、プラン重み、復元予測を表示します。
+
> [!NOTE]
> CLIProxyAPIをベースにプロジェクトを開発した場合は、PRを送ってこのリストに追加してください。
@@ -195,6 +211,10 @@ CLIProxyAPIに触発されたNext.js実装。インストールと使用が簡
OmniRouteはマルチプロバイダーLLM向けのAIゲートウェイです:スマートルーティング、負荷分散、リトライ、フォールバックを備えたOpenAI互換エンドポイント。ポリシー、レート制限、キャッシュ、可観測性を追加して、信頼性が高くコストを意識した推論を実現します。
+### [Playful Proxy API Panel (PPAP)](https://github.com/daishuge/playful-proxy-api-panel)
+
+上流に近い使い方を維持する公開CLIProxyAPI互換フォーク兼管理パネルです。内蔵の使用量統計を復元し、キャッシュヒット率、初回バイト待ち時間、TPSの記録、Docker向けのセルフホスト手順を追加しています。
+
> [!NOTE]
> CLIProxyAPIの移植版またはそれに触発されたプロジェクトを開発した場合は、PRを送ってこのリストに追加してください。
diff --git a/assets/packycode-cn.png b/assets/packycode-cn.png
new file mode 100644
index 0000000000..3e34d6caed
Binary files /dev/null and b/assets/packycode-cn.png differ
diff --git a/assets/packycode-en.png b/assets/packycode-en.png
new file mode 100644
index 0000000000..90f716e2a4
Binary files /dev/null and b/assets/packycode-en.png differ
diff --git a/assets/visioncoder.png b/assets/visioncoder.png
new file mode 100644
index 0000000000..24b1760ce5
Binary files /dev/null and b/assets/visioncoder.png differ
diff --git a/cmd/fetch_antigravity_models/main.go b/cmd/fetch_antigravity_models/main.go
index d4328eb32f..250bcbdfa3 100644
--- a/cmd/fetch_antigravity_models/main.go
+++ b/cmd/fetch_antigravity_models/main.go
@@ -25,11 +25,11 @@ import (
"strings"
"time"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/logging"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
- sdkauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
- coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
- "github.com/router-for-me/CLIProxyAPI/v6/sdk/proxyutil"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/logging"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/misc"
+ sdkauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/auth"
+ coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
+ "github.com/router-for-me/CLIProxyAPI/v7/sdk/proxyutil"
log "github.com/sirupsen/logrus"
"github.com/tidwall/gjson"
)
diff --git a/cmd/server/main.go b/cmd/server/main.go
index b8707f0a43..1ef8300661 100644
--- a/cmd/server/main.go
+++ b/cmd/server/main.go
@@ -10,28 +10,31 @@ import (
"fmt"
"io"
"io/fs"
+ "net"
"net/url"
"os"
"path/filepath"
+ "strconv"
"strings"
"time"
"github.com/joho/godotenv"
- configaccess "github.com/router-for-me/CLIProxyAPI/v6/internal/access/config_access"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/buildinfo"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/cmd"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/logging"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/managementasset"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/store"
- _ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/tui"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/usage"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
- sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
- coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
+ configaccess "github.com/router-for-me/CLIProxyAPI/v7/internal/access/config_access"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/buildinfo"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/cmd"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/home"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/logging"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/managementasset"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/misc"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/redisqueue"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/store"
+ _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/tui"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/util"
+ sdkAuth "github.com/router-for-me/CLIProxyAPI/v7/sdk/auth"
+ coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
log "github.com/sirupsen/logrus"
)
@@ -70,6 +73,8 @@ func main() {
var vertexImportPrefix string
var configPath string
var password string
+ var homeAddr string
+ var homePassword string
var tuiMode bool
var standalone bool
var localModel bool
@@ -88,6 +93,8 @@ func main() {
flag.StringVar(&vertexImport, "vertex-import", "", "Import Vertex service account key JSON file")
flag.StringVar(&vertexImportPrefix, "vertex-import-prefix", "", "Prefix for Vertex model namespacing (use with -vertex-import)")
flag.StringVar(&password, "password", "", "")
+ flag.StringVar(&homeAddr, "home", "", "Home control plane address in host:port format (loads config from home and skips local config file)")
+ flag.StringVar(&homePassword, "home-password", "", "Home control plane password (Redis AUTH)")
flag.BoolVar(&tuiMode, "tui", false, "Start with terminal management UI")
flag.BoolVar(&standalone, "standalone", false, "In TUI mode, start an embedded local server")
flag.BoolVar(&localModel, "local-model", false, "Use embedded model catalog only, skip remote model fetching")
@@ -126,6 +133,7 @@ func main() {
var err error
var cfg *config.Config
var isCloudDeploy bool
+ var configLoadedFromHome bool
var (
usePostgresStore bool
pgStoreDSN string
@@ -236,7 +244,68 @@ func main() {
// Determine and load the configuration file.
// Prefer the Postgres store when configured, otherwise fallback to git or local files.
var configFilePath string
- if usePostgresStore {
+ if strings.TrimSpace(homeAddr) != "" {
+ configLoadedFromHome = true
+ trimmedHomePassword := strings.TrimSpace(homePassword)
+ host, portStr, errSplit := net.SplitHostPort(strings.TrimSpace(homeAddr))
+ if errSplit != nil {
+ log.Errorf("invalid -home address %q (expected host:port): %v", homeAddr, errSplit)
+ return
+ }
+ host = strings.TrimSpace(host)
+ if host == "" {
+ log.Errorf("invalid -home address %q: host is empty", homeAddr)
+ return
+ }
+ port, errPort := strconv.Atoi(strings.TrimSpace(portStr))
+ if errPort != nil || port <= 0 {
+ log.Errorf("invalid -home address %q: invalid port %q", homeAddr, portStr)
+ return
+ }
+
+ homeCfg := config.HomeConfig{
+ Enabled: true,
+ Host: host,
+ Port: port,
+ Password: trimmedHomePassword,
+ }
+ homeClient := home.New(homeCfg)
+ defer homeClient.Close()
+
+ ctxHome, cancelHome := context.WithTimeout(context.Background(), 30*time.Second)
+ raw, errGetConfig := homeClient.GetConfig(ctxHome)
+ cancelHome()
+ if errGetConfig != nil {
+ log.Errorf("failed to fetch config from home: %v", errGetConfig)
+ return
+ }
+
+ parsed, errParseConfig := config.ParseConfigBytes(raw)
+ if errParseConfig != nil {
+ log.Errorf("failed to parse config payload from home: %v", errParseConfig)
+ return
+ }
+ if parsed == nil {
+ parsed = &config.Config{}
+ }
+ parsed.Home = homeCfg
+ parsed.Port = 8317 // Default to 8317 for home mode, can be overridden by home config
+ parsed.UsageStatisticsEnabled = true
+ cfg = parsed
+
+ // Keep a non-empty config path for downstream components (log paths, management assets, etc),
+ // but do not require the file to exist when loading config from home.
+ if strings.TrimSpace(configPath) != "" {
+ configFilePath = configPath
+ } else {
+ configFilePath = filepath.Join(wd, "config.yaml")
+ }
+
+ // Local stores are intentionally disabled when config is loaded from home.
+ usePostgresStore = false
+ useObjectStore = false
+ useGitStore = false
+ } else if usePostgresStore {
if pgStoreLocalPath == "" {
pgStoreLocalPath = wd
}
@@ -400,24 +469,29 @@ func main() {
// In cloud deploy mode, check if we have a valid configuration
var configFileExists bool
if isCloudDeploy {
- if info, errStat := os.Stat(configFilePath); errStat != nil {
- // Don't mislead: API server will not start until configuration is provided.
- log.Info("Cloud deploy mode: No configuration file detected; standing by for configuration")
- configFileExists = false
- } else if info.IsDir() {
- log.Info("Cloud deploy mode: Config path is a directory; standing by for configuration")
- configFileExists = false
- } else if cfg.Port == 0 {
- // LoadConfigOptional returns empty config when file is empty or invalid.
- // Config file exists but is empty or invalid; treat as missing config
- log.Info("Cloud deploy mode: Configuration file is empty or invalid; standing by for valid configuration")
- configFileExists = false
+ if configLoadedFromHome && cfg != nil {
+ configFileExists = cfg.Port != 0
} else {
- log.Info("Cloud deploy mode: Configuration file detected; starting service")
- configFileExists = true
+ if info, errStat := os.Stat(configFilePath); errStat != nil {
+ // Don't mislead: API server will not start until configuration is provided.
+ log.Info("Cloud deploy mode: No configuration file detected; standing by for configuration")
+ configFileExists = false
+ } else if info.IsDir() {
+ log.Info("Cloud deploy mode: Config path is a directory; standing by for configuration")
+ configFileExists = false
+ } else if cfg.Port == 0 {
+ // LoadConfigOptional returns empty config when file is empty or invalid.
+ // Config file exists but is empty or invalid; treat as missing config
+ log.Info("Cloud deploy mode: Configuration file is empty or invalid; standing by for valid configuration")
+ configFileExists = false
+ } else {
+ log.Info("Cloud deploy mode: Configuration file detected; starting service")
+ configFileExists = true
+ }
}
}
- usage.SetStatisticsEnabled(cfg.UsageStatisticsEnabled)
+ redisqueue.SetUsageStatisticsEnabled(cfg.UsageStatisticsEnabled)
+ redisqueue.SetRetentionSeconds(cfg.RedisUsageQueueRetentionSeconds)
coreauth.SetQuotaCooldownDisabled(cfg.DisableCooling)
if err = logging.ConfigureLogOutput(cfg); err != nil {
@@ -495,8 +569,10 @@ func main() {
// Standalone mode: start an embedded local server and connect TUI client to it.
managementasset.StartAutoUpdater(context.Background(), configFilePath)
misc.StartAntigravityVersionUpdater(context.Background())
- if !localModel {
+ if !localModel && !cfg.Home.Enabled {
registry.StartModelsUpdater(context.Background())
+ } else if cfg.Home.Enabled {
+ log.Info("Home mode: remote model updates disabled")
}
hook := tui.NewLogHook(2000)
hook.SetFormatter(&logging.LogFormatter{})
@@ -571,8 +647,10 @@ func main() {
// Start the main proxy service
managementasset.StartAutoUpdater(context.Background(), configFilePath)
misc.StartAntigravityVersionUpdater(context.Background())
- if !localModel {
+ if !localModel && !cfg.Home.Enabled {
registry.StartModelsUpdater(context.Background())
+ } else if cfg.Home.Enabled {
+ log.Info("Home mode: remote model updates disabled")
}
cmd.StartService(cfg, configFilePath, password)
}
diff --git a/config.example.yaml b/config.example.yaml
index 734dd7d522..53efec44ee 100644
--- a/config.example.yaml
+++ b/config.example.yaml
@@ -11,6 +11,13 @@ tls:
cert: ""
key: ""
+# Optional "home" control plane integration over Redis protocol.
+home:
+ enabled: false
+ host: "127.0.0.1"
+ port: 6379
+ password: ""
+
# Management API settings
remote-management:
# Whether to allow remote (non-localhost) management access.
@@ -66,6 +73,11 @@ error-logs-max-files: 10
# When false, disable in-memory usage statistics aggregation
usage-statistics-enabled: false
+# How long (in seconds) Redis usage queue items are retained in memory for the RESP interface (LPOP/RPOP).
+# Note: the in-process Redis RESP usage output is disabled when home.enabled is true.
+# Default: 60. Max: 3600.
+redis-usage-queue-retention-seconds: 60
+
# Proxy URL. Supports socks5/http/https protocols. Example: socks5://user:pass@192.168.1.1:1080/
# Per-entry proxy-url also supports "direct" or "none" to bypass both the global proxy-url and environment proxies explicitly.
proxy-url: ""
@@ -90,6 +102,11 @@ max-retry-interval: 30
# When true, disable auth/model cooldown scheduling globally (prevents blackout windows after failure states).
disable-cooling: false
+# disable-image-generation supports: false (default), true, or "chat".
+# - true: disable image_generation everywhere (also returns 404 for /v1/images/generations and /v1/images/edits).
+# - "chat": disable image_generation injection on non-images endpoints, but keep /v1/images/generations and /v1/images/edits enabled.
+disable-image-generation: false
+
# Core auth auto-refresh worker pool size (OAuth/file-based auth token refresh).
# When > 0, overrides the default worker count (16).
# auth-auto-refresh-workers: 16
@@ -98,14 +115,15 @@ disable-cooling: false
quota-exceeded:
switch-project: true # Whether to automatically switch to another project when a quota is exceeded
switch-preview-model: true # Whether to automatically switch to a preview model when a quota is exceeded
- antigravity-credits: true # Whether to retry Antigravity quota_exhausted 429s once with enabledCreditTypes=["GOOGLE_ONE_AI"]
+ antigravity-credits: true # Whether to use credits as last-resort fallback when all free-tier auths are exhausted for Claude models
# Routing strategy for selecting credentials when multiple match.
routing:
strategy: "round-robin" # round-robin (default), fill-first
# Enable universal session-sticky routing for all clients.
- # Session IDs are extracted from: X-Session-ID header, Idempotency-Key,
- # metadata.user_id, conversation_id, or first few messages hash.
+ # Session IDs are extracted from: metadata.user_id (Claude Code session format),
+ # X-Session-ID, Session_id (Codex), X-Amp-Thread-Id (Amp CLI),
+ # X-Client-Request-Id (PI), conversation_id, or first few messages hash.
# Automatic failover is always enabled when bound auth becomes unavailable.
session-affinity: false # default: false
# How long session-to-auth bindings are retained. Default: 1h
@@ -139,6 +157,7 @@ nonstream-keepalive-interval: 0
# gemini-api-key:
# - api-key: "AIzaSy...01"
# prefix: "test" # optional: require calls like "test/gemini-3-pro-preview" to target this credential
+# disable-cooling: false # optional: per-auth override for auth/model cooldown scheduling
# base-url: "https://generativelanguage.googleapis.com"
# headers:
# X-Custom-Header: "custom-value"
@@ -158,6 +177,7 @@ nonstream-keepalive-interval: 0
# codex-api-key:
# - api-key: "sk-atSM..."
# prefix: "test" # optional: require calls like "test/gpt-5-codex" to target this credential
+# disable-cooling: false # optional: per-auth override for auth/model cooldown scheduling
# base-url: "https://www.example.com" # use the custom codex API endpoint
# headers:
# X-Custom-Header: "custom-value"
@@ -177,6 +197,7 @@ nonstream-keepalive-interval: 0
# - api-key: "sk-atSM..." # use the official claude API key, no need to set the base url
# - api-key: "sk-atSM..."
# prefix: "test" # optional: require calls like "test/claude-sonnet-latest" to target this credential
+# disable-cooling: false # optional: per-auth override for auth/model cooldown scheduling
# base-url: "https://www.example.com" # use the custom claude API endpoint
# headers:
# X-Custom-Header: "custom-value"
@@ -229,8 +250,10 @@ nonstream-keepalive-interval: 0
# OpenAI compatibility providers
# openai-compatibility:
# - name: "openrouter" # The name of the provider; it will be used in the user agent and other places.
+# disabled: false # optional: set to true to disable this provider without removing it
# prefix: "test" # optional: require calls like "test/kimi-k2" to target this provider's credentials
# base-url: "https://openrouter.ai/api/v1" # The base URL of the provider.
+# disable-cooling: false # optional: per-provider override for auth/model cooldown scheduling
# headers:
# X-Custom-Header: "custom-value"
# api-key-entries:
@@ -336,6 +359,19 @@ nonstream-keepalive-interval: 0
# codex:
# - name: "gpt-5"
# alias: "g5"
+# kiro:
+# - name: "kiro-claude-opus-4-7"
+# alias: "claude-opus-4-7"
+# fork: true
+# - name: "kiro-claude-opus-4-7-agentic"
+# alias: "claude-opus-4-7-agentic"
+# fork: true
+# - name: "kiro-claude-sonnet-4-6"
+# alias: "claude-sonnet-4-6"
+# fork: true
+# - name: "kiro-claude-sonnet-4-5"
+# alias: "claude-sonnet-4-5"
+# fork: true
# kimi:
# - name: "kimi-k2.5"
# alias: "k2.5"
diff --git a/docker-build.sh b/docker-build.sh
index 4538b80716..ebe7d92384 100644
--- a/docker-build.sh
+++ b/docker-build.sh
@@ -5,123 +5,13 @@
# This script automates the process of building and running the Docker container
# with version information dynamically injected at build time.
-# Hidden feature: Preserve usage statistics across rebuilds
-# Usage: ./docker-build.sh --with-usage
-# First run prompts for management API key, saved to temp/stats/.api_secret
-
set -euo pipefail
-STATS_DIR="temp/stats"
-STATS_FILE="${STATS_DIR}/.usage_backup.json"
-SECRET_FILE="${STATS_DIR}/.api_secret"
-WITH_USAGE=false
-
-get_port() {
- if [[ -f "config.yaml" ]]; then
- grep -E "^port:" config.yaml | sed -E 's/^port: *["'"'"']?([0-9]+)["'"'"']?.*$/\1/'
- else
- echo "8317"
- fi
-}
-
-export_stats_api_secret() {
- if [[ -f "${SECRET_FILE}" ]]; then
- API_SECRET=$(cat "${SECRET_FILE}")
- else
- if [[ ! -d "${STATS_DIR}" ]]; then
- mkdir -p "${STATS_DIR}"
- fi
- echo "First time using --with-usage. Management API key required."
- read -r -p "Enter management key: " -s API_SECRET
- echo
- echo "${API_SECRET}" > "${SECRET_FILE}"
- chmod 600 "${SECRET_FILE}"
- fi
-}
-
-check_container_running() {
- local port
- port=$(get_port)
-
- if ! curl -s -o /dev/null -w "%{http_code}" "http://localhost:${port}/" | grep -q "200"; then
- echo "Error: cli-proxy-api service is not responding at localhost:${port}"
- echo "Please start the container first or use without --with-usage flag."
- exit 1
- fi
-}
-
-export_stats() {
- local port
- port=$(get_port)
-
- if [[ ! -d "${STATS_DIR}" ]]; then
- mkdir -p "${STATS_DIR}"
- fi
- check_container_running
- echo "Exporting usage statistics..."
- EXPORT_RESPONSE=$(curl -s -w "\n%{http_code}" -H "X-Management-Key: ${API_SECRET}" \
- "http://localhost:${port}/v0/management/usage/export")
- HTTP_CODE=$(echo "${EXPORT_RESPONSE}" | tail -n1)
- RESPONSE_BODY=$(echo "${EXPORT_RESPONSE}" | sed '$d')
-
- if [[ "${HTTP_CODE}" != "200" ]]; then
- echo "Export failed (HTTP ${HTTP_CODE}): ${RESPONSE_BODY}"
- exit 1
- fi
-
- echo "${RESPONSE_BODY}" > "${STATS_FILE}"
- echo "Statistics exported to ${STATS_FILE}"
-}
-
-import_stats() {
- local port
- port=$(get_port)
-
- echo "Importing usage statistics..."
- IMPORT_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \
- -H "X-Management-Key: ${API_SECRET}" \
- -H "Content-Type: application/json" \
- -d @"${STATS_FILE}" \
- "http://localhost:${port}/v0/management/usage/import")
- IMPORT_CODE=$(echo "${IMPORT_RESPONSE}" | tail -n1)
- IMPORT_BODY=$(echo "${IMPORT_RESPONSE}" | sed '$d')
-
- if [[ "${IMPORT_CODE}" == "200" ]]; then
- echo "Statistics imported successfully"
- else
- echo "Import failed (HTTP ${IMPORT_CODE}): ${IMPORT_BODY}"
- fi
-
- rm -f "${STATS_FILE}"
-}
-
-wait_for_service() {
- local port
- port=$(get_port)
-
- echo "Waiting for service to be ready..."
- for i in {1..30}; do
- if curl -s -o /dev/null -w "%{http_code}" "http://localhost:${port}/" | grep -q "200"; then
- break
- fi
- sleep 1
- done
- sleep 2
-}
-
-case "${1:-}" in
- "")
- ;;
- "--with-usage")
- WITH_USAGE=true
- export_stats_api_secret
- ;;
- *)
- echo "Error: unknown option '${1}'. Did you mean '--with-usage'?"
- echo "Usage: ./docker-build.sh [--with-usage]"
- exit 1
- ;;
-esac
+if [[ "${1:-}" != "" ]]; then
+ echo "Error: unknown option '${1}'."
+ echo "Usage: ./docker-build.sh"
+ exit 1
+fi
# --- Step 1: Choose Environment ---
echo "Please select an option:"
@@ -133,14 +23,7 @@ read -r -p "Enter choice [1-2]: " choice
case "$choice" in
1)
echo "--- Running with Pre-built Image ---"
- if [[ "${WITH_USAGE}" == "true" ]]; then
- export_stats
- fi
docker compose up -d --remove-orphans --no-build
- if [[ "${WITH_USAGE}" == "true" ]]; then
- wait_for_service
- import_stats
- fi
echo "Services are starting from remote image."
echo "Run 'docker compose logs -f' to see the logs."
;;
@@ -167,18 +50,9 @@ case "$choice" in
--build-arg COMMIT="${COMMIT}" \
--build-arg BUILD_DATE="${BUILD_DATE}"
- if [[ "${WITH_USAGE}" == "true" ]]; then
- export_stats
- fi
-
echo "Starting the services..."
docker compose up -d --remove-orphans --pull never
- if [[ "${WITH_USAGE}" == "true" ]]; then
- wait_for_service
- import_stats
- fi
-
echo "Build complete. Services are starting."
echo "Run 'docker compose logs -f' to see the logs."
;;
diff --git a/examples/custom-provider/main.go b/examples/custom-provider/main.go
index fdbae275e8..6f37c341de 100644
--- a/examples/custom-provider/main.go
+++ b/examples/custom-provider/main.go
@@ -24,14 +24,14 @@ import (
"time"
"github.com/gin-gonic/gin"
- "github.com/router-for-me/CLIProxyAPI/v6/sdk/api"
- sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
- "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy"
- coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
- clipexec "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
- "github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
- "github.com/router-for-me/CLIProxyAPI/v6/sdk/logging"
- sdktr "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
+ "github.com/router-for-me/CLIProxyAPI/v7/sdk/api"
+ sdkAuth "github.com/router-for-me/CLIProxyAPI/v7/sdk/auth"
+ "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy"
+ coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
+ clipexec "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
+ "github.com/router-for-me/CLIProxyAPI/v7/sdk/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/sdk/logging"
+ sdktr "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
)
const (
diff --git a/examples/http-request/main.go b/examples/http-request/main.go
index a667a9ca0c..1e0215ecea 100644
--- a/examples/http-request/main.go
+++ b/examples/http-request/main.go
@@ -16,8 +16,8 @@ import (
"strings"
"time"
- coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
- clipexec "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
+ coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
+ clipexec "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
log "github.com/sirupsen/logrus"
)
diff --git a/examples/translator/main.go b/examples/translator/main.go
index 88f142a3d2..524a303eb8 100644
--- a/examples/translator/main.go
+++ b/examples/translator/main.go
@@ -4,8 +4,8 @@ import (
"context"
"fmt"
- "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
- _ "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator/builtin"
+ "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
+ _ "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator/builtin"
)
func main() {
diff --git a/go.mod b/go.mod
index 7ad363a716..9ad89ae44c 100644
--- a/go.mod
+++ b/go.mod
@@ -1,4 +1,4 @@
-module github.com/router-for-me/CLIProxyAPI/v6
+module github.com/router-for-me/CLIProxyAPI/v7
go 1.26.0
@@ -31,6 +31,12 @@ require (
gopkg.in/yaml.v3 v3.0.1
)
+require (
+ github.com/cespare/xxhash/v2 v2.3.0 // indirect
+ github.com/redis/go-redis/v9 v9.19.0 // indirect
+ go.uber.org/atomic v1.11.0 // indirect
+)
+
require (
cloud.google.com/go/compute/metadata v0.3.0 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
diff --git a/go.sum b/go.sum
index e811b0123b..5f0a03fbef 100644
--- a/go.sum
+++ b/go.sum
@@ -18,6 +18,8 @@ github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
+github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
+github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
@@ -158,6 +160,8 @@ github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0=
github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/redis/go-redis/v9 v9.19.0 h1:XPVaaPSnG6RhYf7p+rmSa9zZfeVAnWsH5h3lxthOm/k=
+github.com/redis/go-redis/v9 v9.19.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo=
github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
@@ -203,6 +207,8 @@ github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65E
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
+go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
+go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
diff --git a/internal/access/config_access/provider.go b/internal/access/config_access/provider.go
index 84e8abcb0e..915160b76f 100644
--- a/internal/access/config_access/provider.go
+++ b/internal/access/config_access/provider.go
@@ -5,8 +5,8 @@ import (
"net/http"
"strings"
- sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access"
- sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
+ sdkaccess "github.com/router-for-me/CLIProxyAPI/v7/sdk/access"
+ sdkconfig "github.com/router-for-me/CLIProxyAPI/v7/sdk/config"
)
// Register ensures the config-access provider is available to the access manager.
diff --git a/internal/access/reconcile.go b/internal/access/reconcile.go
index 36601f9998..d71e2b8d28 100644
--- a/internal/access/reconcile.go
+++ b/internal/access/reconcile.go
@@ -6,9 +6,9 @@ import (
"sort"
"strings"
- configaccess "github.com/router-for-me/CLIProxyAPI/v6/internal/access/config_access"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
- sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access"
+ configaccess "github.com/router-for-me/CLIProxyAPI/v7/internal/access/config_access"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ sdkaccess "github.com/router-for-me/CLIProxyAPI/v7/sdk/access"
log "github.com/sirupsen/logrus"
)
diff --git a/internal/api/buffered_conn.go b/internal/api/buffered_conn.go
new file mode 100644
index 0000000000..5eb55f9658
--- /dev/null
+++ b/internal/api/buffered_conn.go
@@ -0,0 +1,32 @@
+package api
+
+import (
+ "bufio"
+ "crypto/tls"
+ "net"
+)
+
+type bufferedConn struct {
+ net.Conn
+ reader *bufio.Reader
+}
+
+func (c *bufferedConn) Read(p []byte) (int, error) {
+ if c == nil {
+ return 0, net.ErrClosed
+ }
+ if c.reader == nil {
+ return c.Conn.Read(p)
+ }
+ return c.reader.Read(p)
+}
+
+func (c *bufferedConn) ConnectionState() tls.ConnectionState {
+ if c == nil || c.Conn == nil {
+ return tls.ConnectionState{}
+ }
+ if stater, ok := c.Conn.(interface{ ConnectionState() tls.ConnectionState }); ok {
+ return stater.ConnectionState()
+ }
+ return tls.ConnectionState{}
+}
diff --git a/internal/api/handlers/management/api_key_usage.go b/internal/api/handlers/management/api_key_usage.go
new file mode 100644
index 0000000000..dbe6fbd998
--- /dev/null
+++ b/internal/api/handlers/management/api_key_usage.go
@@ -0,0 +1,107 @@
+package management
+
+import (
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/gin-gonic/gin"
+ coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
+)
+
+type apiKeyUsageEntry struct {
+ Success int64 `json:"success"`
+ Failed int64 `json:"failed"`
+ RecentRequests []coreauth.RecentRequestBucket `json:"recent_requests"`
+}
+
+func mergeRecentRequestBuckets(dst, src []coreauth.RecentRequestBucket) []coreauth.RecentRequestBucket {
+ if len(dst) == 0 {
+ return src
+ }
+ if len(src) == 0 {
+ return dst
+ }
+ if len(dst) != len(src) {
+ n := len(dst)
+ if len(src) < n {
+ n = len(src)
+ }
+ for i := 0; i < n; i++ {
+ dst[i].Success += src[i].Success
+ dst[i].Failed += src[i].Failed
+ }
+ return dst
+ }
+ for i := range dst {
+ dst[i].Success += src[i].Success
+ dst[i].Failed += src[i].Failed
+ }
+ return dst
+}
+
+// GetAPIKeyUsage returns recent request buckets for all in-memory api_key auths,
+// grouped by provider and keyed by "base_url|api_key".
+func (h *Handler) GetAPIKeyUsage(c *gin.Context) {
+ if h == nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "handler not initialized"})
+ return
+ }
+
+ h.mu.Lock()
+ manager := h.authManager
+ h.mu.Unlock()
+ if manager == nil {
+ c.JSON(http.StatusServiceUnavailable, gin.H{"error": "core auth manager unavailable"})
+ return
+ }
+
+ now := time.Now()
+ out := make(map[string]map[string]apiKeyUsageEntry)
+ for _, auth := range manager.List() {
+ if auth == nil {
+ continue
+ }
+ kind, apiKey := auth.AccountInfo()
+ if !strings.EqualFold(strings.TrimSpace(kind), "api_key") {
+ continue
+ }
+ apiKey = strings.TrimSpace(apiKey)
+ if apiKey == "" {
+ continue
+ }
+ baseURL := ""
+ if auth.Attributes != nil {
+ baseURL = strings.TrimSpace(auth.Attributes["base_url"])
+ if baseURL == "" {
+ baseURL = strings.TrimSpace(auth.Attributes["base-url"])
+ }
+ }
+ compositeKey := baseURL + "|" + apiKey
+ provider := strings.ToLower(strings.TrimSpace(auth.Provider))
+ if provider == "" {
+ provider = "unknown"
+ }
+
+ recent := auth.RecentRequestsSnapshot(now)
+ providerBucket, ok := out[provider]
+ if !ok {
+ providerBucket = make(map[string]apiKeyUsageEntry)
+ out[provider] = providerBucket
+ }
+ if existing, exists := providerBucket[compositeKey]; exists {
+ existing.Success += auth.Success
+ existing.Failed += auth.Failed
+ existing.RecentRequests = mergeRecentRequestBuckets(existing.RecentRequests, recent)
+ providerBucket[compositeKey] = existing
+ continue
+ }
+ providerBucket[compositeKey] = apiKeyUsageEntry{
+ Success: auth.Success,
+ Failed: auth.Failed,
+ RecentRequests: recent,
+ }
+ }
+
+ c.JSON(http.StatusOK, out)
+}
diff --git a/internal/api/handlers/management/api_key_usage_test.go b/internal/api/handlers/management/api_key_usage_test.go
new file mode 100644
index 0000000000..f2be17d7db
--- /dev/null
+++ b/internal/api/handlers/management/api_key_usage_test.go
@@ -0,0 +1,95 @@
+package management
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
+)
+
+func sumRecentRequestBuckets(buckets []coreauth.RecentRequestBucket) (int64, int64) {
+ var success int64
+ var failed int64
+ for _, bucket := range buckets {
+ success += bucket.Success
+ failed += bucket.Failed
+ }
+ return success, failed
+}
+
+func TestGetAPIKeyUsage_GroupsByProviderAndAPIKey(t *testing.T) {
+ t.Setenv("MANAGEMENT_PASSWORD", "")
+ gin.SetMode(gin.TestMode)
+
+ manager := coreauth.NewManager(nil, nil, nil)
+ if _, err := manager.Register(context.Background(), &coreauth.Auth{
+ ID: "codex-auth",
+ Provider: "codex",
+ Attributes: map[string]string{
+ "api_key": "codex-key",
+ "base_url": "https://codex.example.com",
+ },
+ }); err != nil {
+ t.Fatalf("register codex auth: %v", err)
+ }
+ if _, err := manager.Register(context.Background(), &coreauth.Auth{
+ ID: "claude-auth",
+ Provider: "claude",
+ Attributes: map[string]string{
+ "api_key": "claude-key",
+ "base_url": "https://claude.example.com",
+ },
+ }); err != nil {
+ t.Fatalf("register claude auth: %v", err)
+ }
+
+ manager.MarkResult(context.Background(), coreauth.Result{AuthID: "codex-auth", Provider: "codex", Model: "gpt-5", Success: true})
+ manager.MarkResult(context.Background(), coreauth.Result{AuthID: "codex-auth", Provider: "codex", Model: "gpt-5", Success: false})
+ manager.MarkResult(context.Background(), coreauth.Result{AuthID: "claude-auth", Provider: "claude", Model: "claude-4", Success: true})
+
+ h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: t.TempDir()}, manager)
+
+ rec := httptest.NewRecorder()
+ ginCtx, _ := gin.CreateTestContext(rec)
+ req := httptest.NewRequest(http.MethodGet, "/v0/management/api-key-usage", nil)
+ ginCtx.Request = req
+ h.GetAPIKeyUsage(ginCtx)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ var payload map[string]map[string]apiKeyUsageEntry
+ if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
+ t.Fatalf("decode payload: %v", err)
+ }
+
+ codexEntry := payload["codex"]["https://codex.example.com|codex-key"]
+ if codexEntry.Success != 1 || codexEntry.Failed != 1 {
+ t.Fatalf("codex totals = %d/%d, want 1/1", codexEntry.Success, codexEntry.Failed)
+ }
+ if len(codexEntry.RecentRequests) != 20 {
+ t.Fatalf("codex buckets len = %d, want 20", len(codexEntry.RecentRequests))
+ }
+ codexSuccess, codexFailed := sumRecentRequestBuckets(codexEntry.RecentRequests)
+ if codexSuccess != 1 || codexFailed != 1 {
+ t.Fatalf("codex totals = %d/%d, want 1/1", codexSuccess, codexFailed)
+ }
+
+ claudeEntry := payload["claude"]["https://claude.example.com|claude-key"]
+ if claudeEntry.Success != 1 || claudeEntry.Failed != 0 {
+ t.Fatalf("claude totals = %d/%d, want 1/0", claudeEntry.Success, claudeEntry.Failed)
+ }
+ if len(claudeEntry.RecentRequests) != 20 {
+ t.Fatalf("claude buckets len = %d, want 20", len(claudeEntry.RecentRequests))
+ }
+ claudeSuccess, claudeFailed := sumRecentRequestBuckets(claudeEntry.RecentRequests)
+ if claudeSuccess != 1 || claudeFailed != 0 {
+ t.Fatalf("claude totals = %d/%d, want 1/0", claudeSuccess, claudeFailed)
+ }
+}
diff --git a/internal/api/handlers/management/api_tools.go b/internal/api/handlers/management/api_tools.go
index cb4805e9ef..f10850701a 100644
--- a/internal/api/handlers/management/api_tools.go
+++ b/internal/api/handlers/management/api_tools.go
@@ -11,10 +11,10 @@ import (
"time"
"github.com/gin-gonic/gin"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/geminicli"
- coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
- "github.com/router-for-me/CLIProxyAPI/v6/sdk/proxyutil"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/geminicli"
+ coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
+ "github.com/router-for-me/CLIProxyAPI/v7/sdk/proxyutil"
log "github.com/sirupsen/logrus"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
@@ -766,6 +766,9 @@ func resolveOpenAICompatAPIKeyProxyURL(cfg *config.Config, auth *coreauth.Auth,
for i := range cfg.OpenAICompatibility {
compat := &cfg.OpenAICompatibility[i]
+ if compat.Disabled {
+ continue
+ }
for _, candidate := range candidates {
if candidate != "" && strings.EqualFold(strings.TrimSpace(candidate), compat.Name) {
for j := range compat.APIKeyEntries {
diff --git a/internal/api/handlers/management/api_tools_test.go b/internal/api/handlers/management/api_tools_test.go
index b27fe6395a..b089eb4a6e 100644
--- a/internal/api/handlers/management/api_tools_test.go
+++ b/internal/api/handlers/management/api_tools_test.go
@@ -5,9 +5,9 @@ import (
"net/http"
"testing"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
- coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
- sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
+ sdkconfig "github.com/router-for-me/CLIProxyAPI/v7/sdk/config"
)
func TestAPICallTransportDirectBypassesGlobalProxy(t *testing.T) {
diff --git a/internal/api/handlers/management/auth_files.go b/internal/api/handlers/management/auth_files.go
index 8f7b8c5e19..d7e798977e 100644
--- a/internal/api/handlers/management/auth_files.go
+++ b/internal/api/handlers/management/auth_files.go
@@ -22,17 +22,17 @@ import (
"time"
"github.com/gin-gonic/gin"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/antigravity"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/claude"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/codex"
- geminiAuth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/gemini"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/kimi"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
- sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
- coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/antigravity"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/claude"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/codex"
+ geminiAuth "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/gemini"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/kimi"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/misc"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/util"
+ sdkAuth "github.com/router-for-me/CLIProxyAPI/v7/sdk/auth"
+ coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
log "github.com/sirupsen/logrus"
"github.com/tidwall/gjson"
"golang.org/x/oauth2"
@@ -388,6 +388,9 @@ func (h *Handler) buildAuthFileEntry(auth *coreauth.Auth) gin.H {
"source": "memory",
"size": int64(0),
}
+ entry["success"] = auth.Success
+ entry["failed"] = auth.Failed
+ entry["recent_requests"] = auth.RecentRequestsSnapshot(time.Now())
if email := authEmail(auth); email != "" {
entry["email"] = email
}
@@ -2395,23 +2398,10 @@ func performGeminiCLISetup(ctx context.Context, httpClient *http.Client, storage
finalProjectID := projectID
if responseProjectID != "" {
if explicitProject && !strings.EqualFold(responseProjectID, projectID) {
- // Check if this is a free user (gen-lang-client projects or free/legacy tier)
- isFreeUser := strings.HasPrefix(projectID, "gen-lang-client-") ||
- strings.EqualFold(tierID, "FREE") ||
- strings.EqualFold(tierID, "LEGACY")
-
- if isFreeUser {
- // For free users, use backend project ID for preview model access
- log.Infof("Gemini onboarding: frontend project %s maps to backend project %s", projectID, responseProjectID)
- log.Infof("Using backend project ID: %s (recommended for preview model access)", responseProjectID)
- finalProjectID = responseProjectID
- } else {
- // Pro users: keep requested project ID (original behavior)
- log.Warnf("Gemini onboarding returned project %s instead of requested %s; keeping requested project ID.", responseProjectID, projectID)
- }
- } else {
- finalProjectID = responseProjectID
+ log.Infof("Gemini onboarding: requested project %s maps to backend project %s", projectID, responseProjectID)
+ log.Infof("Using backend project ID: %s", responseProjectID)
}
+ finalProjectID = responseProjectID
}
storage.ProjectID = strings.TrimSpace(finalProjectID)
diff --git a/internal/api/handlers/management/auth_files_batch_test.go b/internal/api/handlers/management/auth_files_batch_test.go
index 44cdbd5b5f..ec001ae586 100644
--- a/internal/api/handlers/management/auth_files_batch_test.go
+++ b/internal/api/handlers/management/auth_files_batch_test.go
@@ -12,8 +12,8 @@ import (
"testing"
"github.com/gin-gonic/gin"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
- coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
)
func TestUploadAuthFile_BatchMultipart(t *testing.T) {
diff --git a/internal/api/handlers/management/auth_files_delete_test.go b/internal/api/handlers/management/auth_files_delete_test.go
index 7b7b888c4b..a57c9993ad 100644
--- a/internal/api/handlers/management/auth_files_delete_test.go
+++ b/internal/api/handlers/management/auth_files_delete_test.go
@@ -11,8 +11,8 @@ import (
"testing"
"github.com/gin-gonic/gin"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
- coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
)
func TestDeleteAuthFile_UsesAuthPathFromManager(t *testing.T) {
diff --git a/internal/api/handlers/management/auth_files_download_test.go b/internal/api/handlers/management/auth_files_download_test.go
index a2a20d305a..88024fbba5 100644
--- a/internal/api/handlers/management/auth_files_download_test.go
+++ b/internal/api/handlers/management/auth_files_download_test.go
@@ -9,7 +9,7 @@ import (
"testing"
"github.com/gin-gonic/gin"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
)
func TestDownloadAuthFile_ReturnsFile(t *testing.T) {
diff --git a/internal/api/handlers/management/auth_files_download_windows_test.go b/internal/api/handlers/management/auth_files_download_windows_test.go
index 8c174ccf51..88fc7f1146 100644
--- a/internal/api/handlers/management/auth_files_download_windows_test.go
+++ b/internal/api/handlers/management/auth_files_download_windows_test.go
@@ -11,7 +11,7 @@ import (
"testing"
"github.com/gin-gonic/gin"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
)
func TestDownloadAuthFile_PreventsWindowsSlashTraversal(t *testing.T) {
diff --git a/internal/api/handlers/management/auth_files_patch_fields_test.go b/internal/api/handlers/management/auth_files_patch_fields_test.go
index 3ca70012c0..568700a0d6 100644
--- a/internal/api/handlers/management/auth_files_patch_fields_test.go
+++ b/internal/api/handlers/management/auth_files_patch_fields_test.go
@@ -9,8 +9,8 @@ import (
"testing"
"github.com/gin-gonic/gin"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
- coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
)
func TestPatchAuthFileFields_MergeHeadersAndDeleteEmptyValues(t *testing.T) {
diff --git a/internal/api/handlers/management/auth_files_recent_requests_test.go b/internal/api/handlers/management/auth_files_recent_requests_test.go
new file mode 100644
index 0000000000..404bf4848f
--- /dev/null
+++ b/internal/api/handlers/management/auth_files_recent_requests_test.go
@@ -0,0 +1,94 @@
+package management
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
+)
+
+func TestListAuthFiles_IncludesRecentRequestsBuckets(t *testing.T) {
+ t.Setenv("MANAGEMENT_PASSWORD", "")
+ gin.SetMode(gin.TestMode)
+
+ manager := coreauth.NewManager(nil, nil, nil)
+ record := &coreauth.Auth{
+ ID: "runtime-only-auth-1",
+ Provider: "codex",
+ Attributes: map[string]string{
+ "runtime_only": "true",
+ },
+ Metadata: map[string]any{
+ "type": "codex",
+ },
+ }
+ if _, errRegister := manager.Register(context.Background(), record); errRegister != nil {
+ t.Fatalf("failed to register auth record: %v", errRegister)
+ }
+
+ h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: t.TempDir()}, manager)
+ h.tokenStore = &memoryAuthStore{}
+
+ rec := httptest.NewRecorder()
+ ginCtx, _ := gin.CreateTestContext(rec)
+ req := httptest.NewRequest(http.MethodGet, "/v0/management/auth-files", nil)
+ ginCtx.Request = req
+
+ h.ListAuthFiles(ginCtx)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("expected list status %d, got %d with body %s", http.StatusOK, rec.Code, rec.Body.String())
+ }
+
+ var payload map[string]any
+ if errUnmarshal := json.Unmarshal(rec.Body.Bytes(), &payload); errUnmarshal != nil {
+ t.Fatalf("failed to decode list payload: %v", errUnmarshal)
+ }
+ filesRaw, ok := payload["files"].([]any)
+ if !ok {
+ t.Fatalf("expected files array, payload: %#v", payload)
+ }
+ if len(filesRaw) != 1 {
+ t.Fatalf("expected 1 auth entry, got %d", len(filesRaw))
+ }
+
+ fileEntry, ok := filesRaw[0].(map[string]any)
+ if !ok {
+ t.Fatalf("expected file entry object, got %#v", filesRaw[0])
+ }
+
+ if _, ok := fileEntry["success"].(float64); !ok {
+ t.Fatalf("expected success number, got %#v", fileEntry["success"])
+ }
+ if _, ok := fileEntry["failed"].(float64); !ok {
+ t.Fatalf("expected failed number, got %#v", fileEntry["failed"])
+ }
+
+ recentRaw, ok := fileEntry["recent_requests"].([]any)
+ if !ok {
+ t.Fatalf("expected recent_requests array, got %#v", fileEntry["recent_requests"])
+ }
+ if len(recentRaw) != 20 {
+ t.Fatalf("expected 20 recent_requests buckets, got %d", len(recentRaw))
+ }
+ for idx, item := range recentRaw {
+ bucket, ok := item.(map[string]any)
+ if !ok {
+ t.Fatalf("expected bucket object at %d, got %#v", idx, item)
+ }
+ if _, ok := bucket["time"].(string); !ok {
+ t.Fatalf("expected bucket time string at %d, got %#v", idx, bucket["time"])
+ }
+ if _, ok := bucket["success"].(float64); !ok {
+ t.Fatalf("expected bucket success number at %d, got %#v", idx, bucket["success"])
+ }
+ if _, ok := bucket["failed"].(float64); !ok {
+ t.Fatalf("expected bucket failed number at %d, got %#v", idx, bucket["failed"])
+ }
+ }
+}
diff --git a/internal/api/handlers/management/config_auth_index.go b/internal/api/handlers/management/config_auth_index.go
new file mode 100644
index 0000000000..f2bbc2ff38
--- /dev/null
+++ b/internal/api/handlers/management/config_auth_index.go
@@ -0,0 +1,243 @@
+package management
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/watcher/synthesizer"
+)
+
+type geminiKeyWithAuthIndex struct {
+ config.GeminiKey
+ AuthIndex string `json:"auth-index,omitempty"`
+}
+
+type claudeKeyWithAuthIndex struct {
+ config.ClaudeKey
+ AuthIndex string `json:"auth-index,omitempty"`
+}
+
+type codexKeyWithAuthIndex struct {
+ config.CodexKey
+ AuthIndex string `json:"auth-index,omitempty"`
+}
+
+type vertexCompatKeyWithAuthIndex struct {
+ config.VertexCompatKey
+ AuthIndex string `json:"auth-index,omitempty"`
+}
+
+type openAICompatibilityAPIKeyWithAuthIndex struct {
+ config.OpenAICompatibilityAPIKey
+ AuthIndex string `json:"auth-index,omitempty"`
+}
+
+type openAICompatibilityWithAuthIndex struct {
+ Name string `json:"name"`
+ Priority int `json:"priority,omitempty"`
+ Disabled bool `json:"disabled"`
+ Prefix string `json:"prefix,omitempty"`
+ BaseURL string `json:"base-url"`
+ APIKeyEntries []openAICompatibilityAPIKeyWithAuthIndex `json:"api-key-entries,omitempty"`
+ Models []config.OpenAICompatibilityModel `json:"models,omitempty"`
+ Headers map[string]string `json:"headers,omitempty"`
+ AuthIndex string `json:"auth-index,omitempty"`
+}
+
+func (h *Handler) liveAuthIndexByID() map[string]string {
+ out := map[string]string{}
+ if h == nil {
+ return out
+ }
+ h.mu.Lock()
+ manager := h.authManager
+ h.mu.Unlock()
+ if manager == nil {
+ return out
+ }
+ // authManager.List() returns clones, so EnsureIndex only affects these copies.
+ for _, auth := range manager.List() {
+ if auth == nil {
+ continue
+ }
+ id := strings.TrimSpace(auth.ID)
+ if id == "" {
+ continue
+ }
+ idx := strings.TrimSpace(auth.Index)
+ if idx == "" {
+ idx = auth.EnsureIndex()
+ }
+ if idx == "" {
+ continue
+ }
+ out[id] = idx
+ }
+ return out
+}
+
+func (h *Handler) geminiKeysWithAuthIndex() []geminiKeyWithAuthIndex {
+ if h == nil {
+ return nil
+ }
+ liveIndexByID := h.liveAuthIndexByID()
+
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ if h.cfg == nil {
+ return nil
+ }
+
+ idGen := synthesizer.NewStableIDGenerator()
+ out := make([]geminiKeyWithAuthIndex, len(h.cfg.GeminiKey))
+ for i := range h.cfg.GeminiKey {
+ entry := h.cfg.GeminiKey[i]
+ authIndex := ""
+ if key := strings.TrimSpace(entry.APIKey); key != "" {
+ id, _ := idGen.Next("gemini:apikey", key, entry.BaseURL)
+ authIndex = liveIndexByID[id]
+ }
+ out[i] = geminiKeyWithAuthIndex{
+ GeminiKey: entry,
+ AuthIndex: authIndex,
+ }
+ }
+ return out
+}
+
+func (h *Handler) claudeKeysWithAuthIndex() []claudeKeyWithAuthIndex {
+ if h == nil {
+ return nil
+ }
+ liveIndexByID := h.liveAuthIndexByID()
+
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ if h.cfg == nil {
+ return nil
+ }
+
+ idGen := synthesizer.NewStableIDGenerator()
+ out := make([]claudeKeyWithAuthIndex, len(h.cfg.ClaudeKey))
+ for i := range h.cfg.ClaudeKey {
+ entry := h.cfg.ClaudeKey[i]
+ authIndex := ""
+ if key := strings.TrimSpace(entry.APIKey); key != "" {
+ id, _ := idGen.Next("claude:apikey", key, entry.BaseURL)
+ authIndex = liveIndexByID[id]
+ }
+ out[i] = claudeKeyWithAuthIndex{
+ ClaudeKey: entry,
+ AuthIndex: authIndex,
+ }
+ }
+ return out
+}
+
+func (h *Handler) codexKeysWithAuthIndex() []codexKeyWithAuthIndex {
+ if h == nil {
+ return nil
+ }
+ liveIndexByID := h.liveAuthIndexByID()
+
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ if h.cfg == nil {
+ return nil
+ }
+
+ idGen := synthesizer.NewStableIDGenerator()
+ out := make([]codexKeyWithAuthIndex, len(h.cfg.CodexKey))
+ for i := range h.cfg.CodexKey {
+ entry := h.cfg.CodexKey[i]
+ authIndex := ""
+ if key := strings.TrimSpace(entry.APIKey); key != "" {
+ id, _ := idGen.Next("codex:apikey", key, entry.BaseURL)
+ authIndex = liveIndexByID[id]
+ }
+ out[i] = codexKeyWithAuthIndex{
+ CodexKey: entry,
+ AuthIndex: authIndex,
+ }
+ }
+ return out
+}
+
+func (h *Handler) vertexCompatKeysWithAuthIndex() []vertexCompatKeyWithAuthIndex {
+ if h == nil {
+ return nil
+ }
+ liveIndexByID := h.liveAuthIndexByID()
+
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ if h.cfg == nil {
+ return nil
+ }
+
+ idGen := synthesizer.NewStableIDGenerator()
+ out := make([]vertexCompatKeyWithAuthIndex, len(h.cfg.VertexCompatAPIKey))
+ for i := range h.cfg.VertexCompatAPIKey {
+ entry := h.cfg.VertexCompatAPIKey[i]
+ id, _ := idGen.Next("vertex:apikey", entry.APIKey, entry.BaseURL, entry.ProxyURL)
+ authIndex := liveIndexByID[id]
+ out[i] = vertexCompatKeyWithAuthIndex{
+ VertexCompatKey: entry,
+ AuthIndex: authIndex,
+ }
+ }
+ return out
+}
+
+func (h *Handler) openAICompatibilityWithAuthIndex() []openAICompatibilityWithAuthIndex {
+ if h == nil {
+ return nil
+ }
+ liveIndexByID := h.liveAuthIndexByID()
+
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ if h.cfg == nil {
+ return nil
+ }
+
+ normalized := normalizedOpenAICompatibilityEntries(h.cfg.OpenAICompatibility)
+ out := make([]openAICompatibilityWithAuthIndex, len(normalized))
+ idGen := synthesizer.NewStableIDGenerator()
+ for i := range normalized {
+ entry := normalized[i]
+ providerName := strings.ToLower(strings.TrimSpace(entry.Name))
+ if providerName == "" {
+ providerName = "openai-compatibility"
+ }
+ idKind := fmt.Sprintf("openai-compatibility:%s", providerName)
+
+ response := openAICompatibilityWithAuthIndex{
+ Name: entry.Name,
+ Priority: entry.Priority,
+ Disabled: entry.Disabled,
+ Prefix: entry.Prefix,
+ BaseURL: entry.BaseURL,
+ Models: entry.Models,
+ Headers: entry.Headers,
+ AuthIndex: "",
+ }
+ if len(entry.APIKeyEntries) == 0 {
+ id, _ := idGen.Next(idKind, entry.BaseURL)
+ response.AuthIndex = liveIndexByID[id]
+ } else {
+ response.APIKeyEntries = make([]openAICompatibilityAPIKeyWithAuthIndex, len(entry.APIKeyEntries))
+ for j := range entry.APIKeyEntries {
+ apiKeyEntry := entry.APIKeyEntries[j]
+ id, _ := idGen.Next(idKind, apiKeyEntry.APIKey, entry.BaseURL, apiKeyEntry.ProxyURL)
+ response.APIKeyEntries[j] = openAICompatibilityAPIKeyWithAuthIndex{
+ OpenAICompatibilityAPIKey: apiKeyEntry,
+ AuthIndex: liveIndexByID[id],
+ }
+ }
+ }
+ out[i] = response
+ }
+ return out
+}
diff --git a/internal/api/handlers/management/config_basic.go b/internal/api/handlers/management/config_basic.go
index f77e91e9ba..a0818aa8ae 100644
--- a/internal/api/handlers/management/config_basic.go
+++ b/internal/api/handlers/management/config_basic.go
@@ -11,9 +11,9 @@ import (
"time"
"github.com/gin-gonic/gin"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
- sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/util"
+ sdkconfig "github.com/router-for-me/CLIProxyAPI/v7/sdk/config"
log "github.com/sirupsen/logrus"
"gopkg.in/yaml.v3"
)
diff --git a/internal/api/handlers/management/config_lists.go b/internal/api/handlers/management/config_lists.go
index fbaad956e0..f8ef3203c7 100644
--- a/internal/api/handlers/management/config_lists.go
+++ b/internal/api/handlers/management/config_lists.go
@@ -6,7 +6,7 @@ import (
"strings"
"github.com/gin-gonic/gin"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
)
// Generic helpers for list[string]
@@ -120,7 +120,7 @@ func (h *Handler) DeleteAPIKeys(c *gin.Context) {
// gemini-api-key: []GeminiKey
func (h *Handler) GetGeminiKeys(c *gin.Context) {
- c.JSON(200, gin.H{"gemini-api-key": h.cfg.GeminiKey})
+ c.JSON(200, gin.H{"gemini-api-key": h.geminiKeysWithAuthIndex()})
}
func (h *Handler) PutGeminiKeys(c *gin.Context) {
data, err := c.GetRawData()
@@ -139,9 +139,11 @@ func (h *Handler) PutGeminiKeys(c *gin.Context) {
}
arr = obj.Items
}
+ h.mu.Lock()
+ defer h.mu.Unlock()
h.cfg.GeminiKey = append([]config.GeminiKey(nil), arr...)
h.cfg.SanitizeGeminiKeys()
- h.persist(c)
+ h.persistLocked(c)
}
func (h *Handler) PatchGeminiKey(c *gin.Context) {
type geminiKeyPatch struct {
@@ -161,6 +163,9 @@ func (h *Handler) PatchGeminiKey(c *gin.Context) {
c.JSON(400, gin.H{"error": "invalid body"})
return
}
+
+ h.mu.Lock()
+ defer h.mu.Unlock()
targetIndex := -1
if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.GeminiKey) {
targetIndex = *body.Index
@@ -187,7 +192,7 @@ func (h *Handler) PatchGeminiKey(c *gin.Context) {
if trimmed == "" {
h.cfg.GeminiKey = append(h.cfg.GeminiKey[:targetIndex], h.cfg.GeminiKey[targetIndex+1:]...)
h.cfg.SanitizeGeminiKeys()
- h.persist(c)
+ h.persistLocked(c)
return
}
entry.APIKey = trimmed
@@ -209,10 +214,12 @@ func (h *Handler) PatchGeminiKey(c *gin.Context) {
}
h.cfg.GeminiKey[targetIndex] = entry
h.cfg.SanitizeGeminiKeys()
- h.persist(c)
+ h.persistLocked(c)
}
func (h *Handler) DeleteGeminiKey(c *gin.Context) {
+ h.mu.Lock()
+ defer h.mu.Unlock()
if val := strings.TrimSpace(c.Query("api-key")); val != "" {
if baseRaw, okBase := c.GetQuery("base-url"); okBase {
base := strings.TrimSpace(baseRaw)
@@ -226,7 +233,7 @@ func (h *Handler) DeleteGeminiKey(c *gin.Context) {
if len(out) != len(h.cfg.GeminiKey) {
h.cfg.GeminiKey = out
h.cfg.SanitizeGeminiKeys()
- h.persist(c)
+ h.persistLocked(c)
} else {
c.JSON(404, gin.H{"error": "item not found"})
}
@@ -253,7 +260,7 @@ func (h *Handler) DeleteGeminiKey(c *gin.Context) {
}
h.cfg.GeminiKey = append(h.cfg.GeminiKey[:matchIndex], h.cfg.GeminiKey[matchIndex+1:]...)
h.cfg.SanitizeGeminiKeys()
- h.persist(c)
+ h.persistLocked(c)
return
}
if idxStr := c.Query("index"); idxStr != "" {
@@ -261,7 +268,7 @@ func (h *Handler) DeleteGeminiKey(c *gin.Context) {
if _, err := fmt.Sscanf(idxStr, "%d", &idx); err == nil && idx >= 0 && idx < len(h.cfg.GeminiKey) {
h.cfg.GeminiKey = append(h.cfg.GeminiKey[:idx], h.cfg.GeminiKey[idx+1:]...)
h.cfg.SanitizeGeminiKeys()
- h.persist(c)
+ h.persistLocked(c)
return
}
}
@@ -270,7 +277,7 @@ func (h *Handler) DeleteGeminiKey(c *gin.Context) {
// claude-api-key: []ClaudeKey
func (h *Handler) GetClaudeKeys(c *gin.Context) {
- c.JSON(200, gin.H{"claude-api-key": h.cfg.ClaudeKey})
+ c.JSON(200, gin.H{"claude-api-key": h.claudeKeysWithAuthIndex()})
}
func (h *Handler) PutClaudeKeys(c *gin.Context) {
data, err := c.GetRawData()
@@ -292,9 +299,11 @@ func (h *Handler) PutClaudeKeys(c *gin.Context) {
for i := range arr {
normalizeClaudeKey(&arr[i])
}
+ h.mu.Lock()
+ defer h.mu.Unlock()
h.cfg.ClaudeKey = arr
h.cfg.SanitizeClaudeKeys()
- h.persist(c)
+ h.persistLocked(c)
}
func (h *Handler) PatchClaudeKey(c *gin.Context) {
type claudeKeyPatch struct {
@@ -315,6 +324,9 @@ func (h *Handler) PatchClaudeKey(c *gin.Context) {
c.JSON(400, gin.H{"error": "invalid body"})
return
}
+
+ h.mu.Lock()
+ defer h.mu.Unlock()
targetIndex := -1
if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.ClaudeKey) {
targetIndex = *body.Index
@@ -358,10 +370,12 @@ func (h *Handler) PatchClaudeKey(c *gin.Context) {
normalizeClaudeKey(&entry)
h.cfg.ClaudeKey[targetIndex] = entry
h.cfg.SanitizeClaudeKeys()
- h.persist(c)
+ h.persistLocked(c)
}
func (h *Handler) DeleteClaudeKey(c *gin.Context) {
+ h.mu.Lock()
+ defer h.mu.Unlock()
if val := strings.TrimSpace(c.Query("api-key")); val != "" {
if baseRaw, okBase := c.GetQuery("base-url"); okBase {
base := strings.TrimSpace(baseRaw)
@@ -374,7 +388,7 @@ func (h *Handler) DeleteClaudeKey(c *gin.Context) {
}
h.cfg.ClaudeKey = out
h.cfg.SanitizeClaudeKeys()
- h.persist(c)
+ h.persistLocked(c)
return
}
@@ -396,7 +410,7 @@ func (h *Handler) DeleteClaudeKey(c *gin.Context) {
h.cfg.ClaudeKey = append(h.cfg.ClaudeKey[:matchIndex], h.cfg.ClaudeKey[matchIndex+1:]...)
}
h.cfg.SanitizeClaudeKeys()
- h.persist(c)
+ h.persistLocked(c)
return
}
if idxStr := c.Query("index"); idxStr != "" {
@@ -405,7 +419,7 @@ func (h *Handler) DeleteClaudeKey(c *gin.Context) {
if err == nil && idx >= 0 && idx < len(h.cfg.ClaudeKey) {
h.cfg.ClaudeKey = append(h.cfg.ClaudeKey[:idx], h.cfg.ClaudeKey[idx+1:]...)
h.cfg.SanitizeClaudeKeys()
- h.persist(c)
+ h.persistLocked(c)
return
}
}
@@ -414,7 +428,7 @@ func (h *Handler) DeleteClaudeKey(c *gin.Context) {
// openai-compatibility: []OpenAICompatibility
func (h *Handler) GetOpenAICompat(c *gin.Context) {
- c.JSON(200, gin.H{"openai-compatibility": normalizedOpenAICompatibilityEntries(h.cfg.OpenAICompatibility)})
+ c.JSON(200, gin.H{"openai-compatibility": h.openAICompatibilityWithAuthIndex()})
}
func (h *Handler) PutOpenAICompat(c *gin.Context) {
data, err := c.GetRawData()
@@ -440,14 +454,17 @@ func (h *Handler) PutOpenAICompat(c *gin.Context) {
filtered = append(filtered, arr[i])
}
}
+ h.mu.Lock()
+ defer h.mu.Unlock()
h.cfg.OpenAICompatibility = filtered
h.cfg.SanitizeOpenAICompatibility()
- h.persist(c)
+ h.persistLocked(c)
}
func (h *Handler) PatchOpenAICompat(c *gin.Context) {
type openAICompatPatch struct {
Name *string `json:"name"`
Prefix *string `json:"prefix"`
+ Disabled *bool `json:"disabled"`
BaseURL *string `json:"base-url"`
APIKeyEntries *[]config.OpenAICompatibilityAPIKey `json:"api-key-entries"`
Models *[]config.OpenAICompatibilityModel `json:"models"`
@@ -462,6 +479,9 @@ func (h *Handler) PatchOpenAICompat(c *gin.Context) {
c.JSON(400, gin.H{"error": "invalid body"})
return
}
+
+ h.mu.Lock()
+ defer h.mu.Unlock()
targetIndex := -1
if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.OpenAICompatibility) {
targetIndex = *body.Index
@@ -487,12 +507,15 @@ func (h *Handler) PatchOpenAICompat(c *gin.Context) {
if body.Value.Prefix != nil {
entry.Prefix = strings.TrimSpace(*body.Value.Prefix)
}
+ if body.Value.Disabled != nil {
+ entry.Disabled = *body.Value.Disabled
+ }
if body.Value.BaseURL != nil {
trimmed := strings.TrimSpace(*body.Value.BaseURL)
if trimmed == "" {
h.cfg.OpenAICompatibility = append(h.cfg.OpenAICompatibility[:targetIndex], h.cfg.OpenAICompatibility[targetIndex+1:]...)
h.cfg.SanitizeOpenAICompatibility()
- h.persist(c)
+ h.persistLocked(c)
return
}
entry.BaseURL = trimmed
@@ -509,10 +532,12 @@ func (h *Handler) PatchOpenAICompat(c *gin.Context) {
normalizeOpenAICompatibilityEntry(&entry)
h.cfg.OpenAICompatibility[targetIndex] = entry
h.cfg.SanitizeOpenAICompatibility()
- h.persist(c)
+ h.persistLocked(c)
}
func (h *Handler) DeleteOpenAICompat(c *gin.Context) {
+ h.mu.Lock()
+ defer h.mu.Unlock()
if name := c.Query("name"); name != "" {
out := make([]config.OpenAICompatibility, 0, len(h.cfg.OpenAICompatibility))
for _, v := range h.cfg.OpenAICompatibility {
@@ -522,7 +547,7 @@ func (h *Handler) DeleteOpenAICompat(c *gin.Context) {
}
h.cfg.OpenAICompatibility = out
h.cfg.SanitizeOpenAICompatibility()
- h.persist(c)
+ h.persistLocked(c)
return
}
if idxStr := c.Query("index"); idxStr != "" {
@@ -531,7 +556,7 @@ func (h *Handler) DeleteOpenAICompat(c *gin.Context) {
if err == nil && idx >= 0 && idx < len(h.cfg.OpenAICompatibility) {
h.cfg.OpenAICompatibility = append(h.cfg.OpenAICompatibility[:idx], h.cfg.OpenAICompatibility[idx+1:]...)
h.cfg.SanitizeOpenAICompatibility()
- h.persist(c)
+ h.persistLocked(c)
return
}
}
@@ -540,7 +565,7 @@ func (h *Handler) DeleteOpenAICompat(c *gin.Context) {
// vertex-api-key: []VertexCompatKey
func (h *Handler) GetVertexCompatKeys(c *gin.Context) {
- c.JSON(200, gin.H{"vertex-api-key": h.cfg.VertexCompatAPIKey})
+ c.JSON(200, gin.H{"vertex-api-key": h.vertexCompatKeysWithAuthIndex()})
}
func (h *Handler) PutVertexCompatKeys(c *gin.Context) {
data, err := c.GetRawData()
@@ -566,9 +591,11 @@ func (h *Handler) PutVertexCompatKeys(c *gin.Context) {
return
}
}
+ h.mu.Lock()
+ defer h.mu.Unlock()
h.cfg.VertexCompatAPIKey = append([]config.VertexCompatKey(nil), arr...)
h.cfg.SanitizeVertexCompatKeys()
- h.persist(c)
+ h.persistLocked(c)
}
func (h *Handler) PatchVertexCompatKey(c *gin.Context) {
type vertexCompatPatch struct {
@@ -589,6 +616,9 @@ func (h *Handler) PatchVertexCompatKey(c *gin.Context) {
c.JSON(400, gin.H{"error": "invalid body"})
return
}
+
+ h.mu.Lock()
+ defer h.mu.Unlock()
targetIndex := -1
if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.VertexCompatAPIKey) {
targetIndex = *body.Index
@@ -615,7 +645,7 @@ func (h *Handler) PatchVertexCompatKey(c *gin.Context) {
if trimmed == "" {
h.cfg.VertexCompatAPIKey = append(h.cfg.VertexCompatAPIKey[:targetIndex], h.cfg.VertexCompatAPIKey[targetIndex+1:]...)
h.cfg.SanitizeVertexCompatKeys()
- h.persist(c)
+ h.persistLocked(c)
return
}
entry.APIKey = trimmed
@@ -628,7 +658,7 @@ func (h *Handler) PatchVertexCompatKey(c *gin.Context) {
if trimmed == "" {
h.cfg.VertexCompatAPIKey = append(h.cfg.VertexCompatAPIKey[:targetIndex], h.cfg.VertexCompatAPIKey[targetIndex+1:]...)
h.cfg.SanitizeVertexCompatKeys()
- h.persist(c)
+ h.persistLocked(c)
return
}
entry.BaseURL = trimmed
@@ -648,10 +678,12 @@ func (h *Handler) PatchVertexCompatKey(c *gin.Context) {
normalizeVertexCompatKey(&entry)
h.cfg.VertexCompatAPIKey[targetIndex] = entry
h.cfg.SanitizeVertexCompatKeys()
- h.persist(c)
+ h.persistLocked(c)
}
func (h *Handler) DeleteVertexCompatKey(c *gin.Context) {
+ h.mu.Lock()
+ defer h.mu.Unlock()
if val := strings.TrimSpace(c.Query("api-key")); val != "" {
if baseRaw, okBase := c.GetQuery("base-url"); okBase {
base := strings.TrimSpace(baseRaw)
@@ -664,7 +696,7 @@ func (h *Handler) DeleteVertexCompatKey(c *gin.Context) {
}
h.cfg.VertexCompatAPIKey = out
h.cfg.SanitizeVertexCompatKeys()
- h.persist(c)
+ h.persistLocked(c)
return
}
@@ -686,7 +718,7 @@ func (h *Handler) DeleteVertexCompatKey(c *gin.Context) {
h.cfg.VertexCompatAPIKey = append(h.cfg.VertexCompatAPIKey[:matchIndex], h.cfg.VertexCompatAPIKey[matchIndex+1:]...)
}
h.cfg.SanitizeVertexCompatKeys()
- h.persist(c)
+ h.persistLocked(c)
return
}
if idxStr := c.Query("index"); idxStr != "" {
@@ -695,7 +727,7 @@ func (h *Handler) DeleteVertexCompatKey(c *gin.Context) {
if errScan == nil && idx >= 0 && idx < len(h.cfg.VertexCompatAPIKey) {
h.cfg.VertexCompatAPIKey = append(h.cfg.VertexCompatAPIKey[:idx], h.cfg.VertexCompatAPIKey[idx+1:]...)
h.cfg.SanitizeVertexCompatKeys()
- h.persist(c)
+ h.persistLocked(c)
return
}
}
@@ -886,7 +918,7 @@ func (h *Handler) DeleteOAuthModelAlias(c *gin.Context) {
// codex-api-key: []CodexKey
func (h *Handler) GetCodexKeys(c *gin.Context) {
- c.JSON(200, gin.H{"codex-api-key": h.cfg.CodexKey})
+ c.JSON(200, gin.H{"codex-api-key": h.codexKeysWithAuthIndex()})
}
func (h *Handler) PutCodexKeys(c *gin.Context) {
data, err := c.GetRawData()
@@ -915,9 +947,11 @@ func (h *Handler) PutCodexKeys(c *gin.Context) {
}
filtered = append(filtered, entry)
}
+ h.mu.Lock()
+ defer h.mu.Unlock()
h.cfg.CodexKey = filtered
h.cfg.SanitizeCodexKeys()
- h.persist(c)
+ h.persistLocked(c)
}
func (h *Handler) PatchCodexKey(c *gin.Context) {
type codexKeyPatch struct {
@@ -938,6 +972,9 @@ func (h *Handler) PatchCodexKey(c *gin.Context) {
c.JSON(400, gin.H{"error": "invalid body"})
return
}
+
+ h.mu.Lock()
+ defer h.mu.Unlock()
targetIndex := -1
if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.CodexKey) {
targetIndex = *body.Index
@@ -968,7 +1005,7 @@ func (h *Handler) PatchCodexKey(c *gin.Context) {
if trimmed == "" {
h.cfg.CodexKey = append(h.cfg.CodexKey[:targetIndex], h.cfg.CodexKey[targetIndex+1:]...)
h.cfg.SanitizeCodexKeys()
- h.persist(c)
+ h.persistLocked(c)
return
}
entry.BaseURL = trimmed
@@ -988,10 +1025,12 @@ func (h *Handler) PatchCodexKey(c *gin.Context) {
normalizeCodexKey(&entry)
h.cfg.CodexKey[targetIndex] = entry
h.cfg.SanitizeCodexKeys()
- h.persist(c)
+ h.persistLocked(c)
}
func (h *Handler) DeleteCodexKey(c *gin.Context) {
+ h.mu.Lock()
+ defer h.mu.Unlock()
if val := strings.TrimSpace(c.Query("api-key")); val != "" {
if baseRaw, okBase := c.GetQuery("base-url"); okBase {
base := strings.TrimSpace(baseRaw)
@@ -1004,7 +1043,7 @@ func (h *Handler) DeleteCodexKey(c *gin.Context) {
}
h.cfg.CodexKey = out
h.cfg.SanitizeCodexKeys()
- h.persist(c)
+ h.persistLocked(c)
return
}
@@ -1026,7 +1065,7 @@ func (h *Handler) DeleteCodexKey(c *gin.Context) {
h.cfg.CodexKey = append(h.cfg.CodexKey[:matchIndex], h.cfg.CodexKey[matchIndex+1:]...)
}
h.cfg.SanitizeCodexKeys()
- h.persist(c)
+ h.persistLocked(c)
return
}
if idxStr := c.Query("index"); idxStr != "" {
@@ -1035,7 +1074,7 @@ func (h *Handler) DeleteCodexKey(c *gin.Context) {
if err == nil && idx >= 0 && idx < len(h.cfg.CodexKey) {
h.cfg.CodexKey = append(h.cfg.CodexKey[:idx], h.cfg.CodexKey[idx+1:]...)
h.cfg.SanitizeCodexKeys()
- h.persist(c)
+ h.persistLocked(c)
return
}
}
diff --git a/internal/api/handlers/management/config_lists_delete_keys_test.go b/internal/api/handlers/management/config_lists_delete_keys_test.go
index aaa43910e7..a548805eda 100644
--- a/internal/api/handlers/management/config_lists_delete_keys_test.go
+++ b/internal/api/handlers/management/config_lists_delete_keys_test.go
@@ -8,7 +8,7 @@ import (
"testing"
"github.com/gin-gonic/gin"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
)
func writeTestConfigFile(t *testing.T) string {
diff --git a/internal/api/handlers/management/handler.go b/internal/api/handlers/management/handler.go
index 45786b9d3e..0f884ef05a 100644
--- a/internal/api/handlers/management/handler.go
+++ b/internal/api/handlers/management/handler.go
@@ -13,11 +13,10 @@ import (
"time"
"github.com/gin-gonic/gin"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/buildinfo"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/usage"
- sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
- coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/buildinfo"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ sdkAuth "github.com/router-for-me/CLIProxyAPI/v7/sdk/auth"
+ coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
"golang.org/x/crypto/bcrypt"
)
@@ -41,7 +40,6 @@ type Handler struct {
attemptsMu sync.Mutex
failedAttempts map[string]*attemptInfo // keyed by client IP
authManager *coreauth.Manager
- usageStats *usage.RequestStatistics
tokenStore coreauth.Store
localPassword string
allowRemoteOverride bool
@@ -60,7 +58,6 @@ func NewHandler(cfg *config.Config, configFilePath string, manager *coreauth.Man
configFilePath: configFilePath,
failedAttempts: make(map[string]*attemptInfo),
authManager: manager,
- usageStats: usage.GetRequestStatistics(),
tokenStore: sdkAuth.GetTokenStore(),
allowRemoteOverride: envSecret != "",
envSecret: envSecret,
@@ -105,13 +102,24 @@ func NewHandlerWithoutConfigFilePath(cfg *config.Config, manager *coreauth.Manag
}
// SetConfig updates the in-memory config reference when the server hot-reloads.
-func (h *Handler) SetConfig(cfg *config.Config) { h.cfg = cfg }
+func (h *Handler) SetConfig(cfg *config.Config) {
+ if h == nil {
+ return
+ }
+ h.mu.Lock()
+ h.cfg = cfg
+ h.mu.Unlock()
+}
// SetAuthManager updates the auth manager reference used by management endpoints.
-func (h *Handler) SetAuthManager(manager *coreauth.Manager) { h.authManager = manager }
-
-// SetUsageStatistics allows replacing the usage statistics reference.
-func (h *Handler) SetUsageStatistics(stats *usage.RequestStatistics) { h.usageStats = stats }
+func (h *Handler) SetAuthManager(manager *coreauth.Manager) {
+ if h == nil {
+ return
+ }
+ h.mu.Lock()
+ h.authManager = manager
+ h.mu.Unlock()
+}
// SetLocalPassword configures the runtime-local password accepted for localhost requests.
func (h *Handler) SetLocalPassword(password string) { h.localPassword = password }
@@ -138,9 +146,6 @@ func (h *Handler) SetPostAuthHook(hook coreauth.PostAuthHook) {
// All requests (local and remote) require a valid management key.
// Additionally, remote access requires allow-remote-management=true.
func (h *Handler) Middleware() gin.HandlerFunc {
- const maxFailures = 5
- const banDuration = 30 * time.Minute
-
return func(c *gin.Context) {
c.Header("X-CPA-VERSION", buildinfo.Version)
c.Header("X-CPA-COMMIT", buildinfo.Commit)
@@ -148,64 +153,6 @@ func (h *Handler) Middleware() gin.HandlerFunc {
clientIP := c.ClientIP()
localClient := clientIP == "127.0.0.1" || clientIP == "::1"
- cfg := h.cfg
- var (
- allowRemote bool
- secretHash string
- )
- if cfg != nil {
- allowRemote = cfg.RemoteManagement.AllowRemote
- secretHash = cfg.RemoteManagement.SecretKey
- }
- if h.allowRemoteOverride {
- allowRemote = true
- }
- envSecret := h.envSecret
-
- fail := func() {}
- if !localClient {
- h.attemptsMu.Lock()
- ai := h.failedAttempts[clientIP]
- if ai != nil {
- if !ai.blockedUntil.IsZero() {
- if time.Now().Before(ai.blockedUntil) {
- remaining := time.Until(ai.blockedUntil).Round(time.Second)
- h.attemptsMu.Unlock()
- c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": fmt.Sprintf("IP banned due to too many failed attempts. Try again in %s", remaining)})
- return
- }
- // Ban expired, reset state
- ai.blockedUntil = time.Time{}
- ai.count = 0
- }
- }
- h.attemptsMu.Unlock()
-
- if !allowRemote {
- c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "remote management disabled"})
- return
- }
-
- fail = func() {
- h.attemptsMu.Lock()
- aip := h.failedAttempts[clientIP]
- if aip == nil {
- aip = &attemptInfo{}
- h.failedAttempts[clientIP] = aip
- }
- aip.count++
- aip.lastActivity = time.Now()
- if aip.count >= maxFailures {
- aip.blockedUntil = time.Now().Add(banDuration)
- aip.count = 0
- }
- h.attemptsMu.Unlock()
- }
- }
- if secretHash == "" && envSecret == "" {
- c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "remote management key not set"})
- return
- }
// Accept either Authorization: Bearer or X-Management-Key
var provided string
@@ -221,61 +168,126 @@ func (h *Handler) Middleware() gin.HandlerFunc {
provided = c.GetHeader("X-Management-Key")
}
- if provided == "" {
- if !localClient {
- fail()
- }
- c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing management key"})
+ allowed, statusCode, errMsg := h.AuthenticateManagementKey(clientIP, localClient, provided)
+ if !allowed {
+ c.AbortWithStatusJSON(statusCode, gin.H{"error": errMsg})
return
}
+ c.Next()
+ }
+}
- if localClient {
- if lp := h.localPassword; lp != "" {
- if subtle.ConstantTimeCompare([]byte(provided), []byte(lp)) == 1 {
- c.Next()
- return
- }
- }
+// AuthenticateManagementKey verifies the provided management key for the given client.
+// It mirrors the behaviour of Middleware() so non-HTTP callers can reuse the same logic.
+func (h *Handler) AuthenticateManagementKey(clientIP string, localClient bool, provided string) (bool, int, string) {
+ const maxFailures = 5
+ const banDuration = 30 * time.Minute
+
+ if h == nil {
+ return false, http.StatusForbidden, "remote management disabled"
+ }
+
+ cfg := h.cfg
+ var (
+ allowRemote bool
+ secretHash string
+ )
+ if cfg != nil {
+ allowRemote = cfg.RemoteManagement.AllowRemote
+ secretHash = cfg.RemoteManagement.SecretKey
+ }
+ if h.allowRemoteOverride {
+ allowRemote = true
+ }
+ envSecret := h.envSecret
+
+ now := time.Now()
+ h.attemptsMu.Lock()
+ ai := h.failedAttempts[clientIP]
+ if ai != nil && !ai.blockedUntil.IsZero() {
+ if now.Before(ai.blockedUntil) {
+ remaining := ai.blockedUntil.Sub(now).Round(time.Second)
+ h.attemptsMu.Unlock()
+ return false, http.StatusForbidden, fmt.Sprintf("IP banned due to too many failed attempts. Try again in %s", remaining)
}
+ // Ban expired, reset state
+ ai.blockedUntil = time.Time{}
+ ai.count = 0
+ }
+ h.attemptsMu.Unlock()
- if envSecret != "" && subtle.ConstantTimeCompare([]byte(provided), []byte(envSecret)) == 1 {
- if !localClient {
- h.attemptsMu.Lock()
- if ai := h.failedAttempts[clientIP]; ai != nil {
- ai.count = 0
- ai.blockedUntil = time.Time{}
- }
- h.attemptsMu.Unlock()
- }
- c.Next()
- return
+ if !localClient && !allowRemote {
+ return false, http.StatusForbidden, "remote management disabled"
+ }
+
+ fail := func() {
+ h.attemptsMu.Lock()
+ aip := h.failedAttempts[clientIP]
+ if aip == nil {
+ aip = &attemptInfo{}
+ h.failedAttempts[clientIP] = aip
+ }
+ aip.count++
+ aip.lastActivity = time.Now()
+ if aip.count >= maxFailures {
+ aip.blockedUntil = time.Now().Add(banDuration)
+ aip.count = 0
}
+ h.attemptsMu.Unlock()
+ }
- if secretHash == "" || bcrypt.CompareHashAndPassword([]byte(secretHash), []byte(provided)) != nil {
- if !localClient {
- fail()
- }
- c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid management key"})
- return
+ reset := func() {
+ h.attemptsMu.Lock()
+ if ai := h.failedAttempts[clientIP]; ai != nil {
+ ai.count = 0
+ ai.blockedUntil = time.Time{}
}
+ h.attemptsMu.Unlock()
+ }
+
+ if secretHash == "" && envSecret == "" {
+ return false, http.StatusForbidden, "remote management key not set"
+ }
- if !localClient {
- h.attemptsMu.Lock()
- if ai := h.failedAttempts[clientIP]; ai != nil {
- ai.count = 0
- ai.blockedUntil = time.Time{}
+ if provided == "" {
+ fail()
+ return false, http.StatusUnauthorized, "missing management key"
+ }
+
+ if localClient {
+ if lp := h.localPassword; lp != "" {
+ if subtle.ConstantTimeCompare([]byte(provided), []byte(lp)) == 1 {
+ reset()
+ return true, 0, ""
}
- h.attemptsMu.Unlock()
}
+ }
- c.Next()
+ if envSecret != "" && subtle.ConstantTimeCompare([]byte(provided), []byte(envSecret)) == 1 {
+ reset()
+ return true, 0, ""
+ }
+
+ if secretHash == "" || bcrypt.CompareHashAndPassword([]byte(secretHash), []byte(provided)) != nil {
+ fail()
+ return false, http.StatusUnauthorized, "invalid management key"
}
+
+ reset()
+
+ return true, 0, ""
}
// persist saves the current in-memory config to disk.
func (h *Handler) persist(c *gin.Context) bool {
h.mu.Lock()
defer h.mu.Unlock()
+ return h.persistLocked(c)
+}
+
+// persistLocked saves the current in-memory config to disk.
+// It expects the caller to hold h.mu.
+func (h *Handler) persistLocked(c *gin.Context) bool {
// Preserve comments when writing
if err := config.SaveConfigPreserveComments(h.configFilePath, h.cfg); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to save config: %v", err)})
diff --git a/internal/api/handlers/management/handler_test.go b/internal/api/handlers/management/handler_test.go
new file mode 100644
index 0000000000..a77dc36f35
--- /dev/null
+++ b/internal/api/handlers/management/handler_test.go
@@ -0,0 +1,38 @@
+package management
+
+import (
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+)
+
+func TestAuthenticateManagementKey_LocalhostIPBan_BlocksCorrectKeyDuringBan(t *testing.T) {
+ h := &Handler{
+ cfg: &config.Config{},
+ failedAttempts: make(map[string]*attemptInfo),
+ envSecret: "test-secret",
+ }
+
+ for i := 0; i < 5; i++ {
+ allowed, statusCode, errMsg := h.AuthenticateManagementKey("127.0.0.1", true, "wrong-secret")
+ if allowed {
+ t.Fatalf("expected auth to be denied at attempt %d", i+1)
+ }
+ if statusCode != http.StatusUnauthorized || errMsg != "invalid management key" {
+ t.Fatalf("unexpected auth failure at attempt %d: status=%d msg=%q", i+1, statusCode, errMsg)
+ }
+ }
+
+ allowed, statusCode, errMsg := h.AuthenticateManagementKey("127.0.0.1", true, "test-secret")
+ if allowed {
+ t.Fatalf("expected correct key to be denied while banned")
+ }
+ if statusCode != http.StatusForbidden {
+ t.Fatalf("expected forbidden status while banned, got %d", statusCode)
+ }
+ if !strings.HasPrefix(errMsg, "IP banned due to too many failed attempts. Try again in") {
+ t.Fatalf("unexpected banned message: %q", errMsg)
+ }
+}
diff --git a/internal/api/handlers/management/logs.go b/internal/api/handlers/management/logs.go
index b64cd61938..ca6d7eda81 100644
--- a/internal/api/handlers/management/logs.go
+++ b/internal/api/handlers/management/logs.go
@@ -13,7 +13,7 @@ import (
"time"
"github.com/gin-gonic/gin"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/logging"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/logging"
)
const (
diff --git a/internal/api/handlers/management/model_definitions.go b/internal/api/handlers/management/model_definitions.go
index 85ff314bf4..0d1b8af437 100644
--- a/internal/api/handlers/management/model_definitions.go
+++ b/internal/api/handlers/management/model_definitions.go
@@ -5,7 +5,7 @@ import (
"strings"
"github.com/gin-gonic/gin"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
)
// GetStaticModelDefinitions returns static model metadata for a given channel.
diff --git a/internal/api/handlers/management/test_store_test.go b/internal/api/handlers/management/test_store_test.go
index cf7dbaf7d0..2eaacd904f 100644
--- a/internal/api/handlers/management/test_store_test.go
+++ b/internal/api/handlers/management/test_store_test.go
@@ -4,7 +4,7 @@ import (
"context"
"sync"
- coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
+ coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
)
type memoryAuthStore struct {
diff --git a/internal/api/handlers/management/usage.go b/internal/api/handlers/management/usage.go
index 5f79408963..c1602c0423 100644
--- a/internal/api/handlers/management/usage.go
+++ b/internal/api/handlers/management/usage.go
@@ -2,78 +2,54 @@ package management
import (
"encoding/json"
+ "errors"
"net/http"
- "time"
+ "strconv"
+ "strings"
"github.com/gin-gonic/gin"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/usage"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/redisqueue"
)
-type usageExportPayload struct {
- Version int `json:"version"`
- ExportedAt time.Time `json:"exported_at"`
- Usage usage.StatisticsSnapshot `json:"usage"`
-}
-
-type usageImportPayload struct {
- Version int `json:"version"`
- Usage usage.StatisticsSnapshot `json:"usage"`
-}
+type usageQueueRecord []byte
-// GetUsageStatistics returns the in-memory request statistics snapshot.
-func (h *Handler) GetUsageStatistics(c *gin.Context) {
- var snapshot usage.StatisticsSnapshot
- if h != nil && h.usageStats != nil {
- snapshot = h.usageStats.Snapshot()
+func (r usageQueueRecord) MarshalJSON() ([]byte, error) {
+ if json.Valid(r) {
+ return append([]byte(nil), r...), nil
}
- c.JSON(http.StatusOK, gin.H{
- "usage": snapshot,
- "failed_requests": snapshot.FailureCount,
- })
+ return json.Marshal(string(r))
}
-// ExportUsageStatistics returns a complete usage snapshot for backup/migration.
-func (h *Handler) ExportUsageStatistics(c *gin.Context) {
- var snapshot usage.StatisticsSnapshot
- if h != nil && h.usageStats != nil {
- snapshot = h.usageStats.Snapshot()
+// GetUsageQueue pops queued usage records from the usage queue.
+func (h *Handler) GetUsageQueue(c *gin.Context) {
+ if h == nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "handler unavailable"})
+ return
}
- c.JSON(http.StatusOK, usageExportPayload{
- Version: 1,
- ExportedAt: time.Now().UTC(),
- Usage: snapshot,
- })
-}
-// ImportUsageStatistics merges a previously exported usage snapshot into memory.
-func (h *Handler) ImportUsageStatistics(c *gin.Context) {
- if h == nil || h.usageStats == nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": "usage statistics unavailable"})
+ count, errCount := parseUsageQueueCount(c.Query("count"))
+ if errCount != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": errCount.Error()})
return
}
- data, err := c.GetRawData()
- if err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read request body"})
- return
+ items := redisqueue.PopOldest(count)
+ records := make([]usageQueueRecord, 0, len(items))
+ for _, item := range items {
+ records = append(records, usageQueueRecord(append([]byte(nil), item...)))
}
- var payload usageImportPayload
- if err := json.Unmarshal(data, &payload); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"})
- return
+ c.JSON(http.StatusOK, records)
+}
+
+func parseUsageQueueCount(value string) (int, error) {
+ value = strings.TrimSpace(value)
+ if value == "" {
+ return 1, nil
}
- if payload.Version != 0 && payload.Version != 1 {
- c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported version"})
- return
+ count, errCount := strconv.Atoi(value)
+ if errCount != nil || count <= 0 {
+ return 0, errors.New("count must be a positive integer")
}
-
- result := h.usageStats.MergeSnapshot(payload.Usage)
- snapshot := h.usageStats.Snapshot()
- c.JSON(http.StatusOK, gin.H{
- "added": result.Added,
- "skipped": result.Skipped,
- "total_requests": snapshot.TotalRequests,
- "failed_requests": snapshot.FailureCount,
- })
+ return count, nil
}
diff --git a/internal/api/handlers/management/usage_test.go b/internal/api/handlers/management/usage_test.go
new file mode 100644
index 0000000000..bdb8aa2e29
--- /dev/null
+++ b/internal/api/handlers/management/usage_test.go
@@ -0,0 +1,98 @@
+package management
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/redisqueue"
+)
+
+func TestGetUsageQueuePopsRequestedRecords(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ withManagementUsageQueue(t, func() {
+ redisqueue.Enqueue([]byte(`{"id":1}`))
+ redisqueue.Enqueue([]byte(`{"id":2}`))
+ redisqueue.Enqueue([]byte(`{"id":3}`))
+
+ rec := httptest.NewRecorder()
+ ginCtx, _ := gin.CreateTestContext(rec)
+ ginCtx.Request = httptest.NewRequest(http.MethodGet, "/v0/management/usage-queue?count=2", nil)
+
+ h := &Handler{}
+ h.GetUsageQueue(ginCtx)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ var payload []json.RawMessage
+ if errUnmarshal := json.Unmarshal(rec.Body.Bytes(), &payload); errUnmarshal != nil {
+ t.Fatalf("unmarshal response: %v", errUnmarshal)
+ }
+ if len(payload) != 2 {
+ t.Fatalf("response records = %d, want 2", len(payload))
+ }
+ requireRecordID(t, payload[0], 1)
+ requireRecordID(t, payload[1], 2)
+
+ remaining := redisqueue.PopOldest(10)
+ if len(remaining) != 1 || string(remaining[0]) != `{"id":3}` {
+ t.Fatalf("remaining queue = %q, want third item only", remaining)
+ }
+ })
+}
+
+func TestGetUsageQueueInvalidCountDoesNotPop(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ withManagementUsageQueue(t, func() {
+ redisqueue.Enqueue([]byte(`{"id":1}`))
+
+ rec := httptest.NewRecorder()
+ ginCtx, _ := gin.CreateTestContext(rec)
+ ginCtx.Request = httptest.NewRequest(http.MethodGet, "/v0/management/usage-queue?count=0", nil)
+
+ h := &Handler{}
+ h.GetUsageQueue(ginCtx)
+
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("status = %d, want %d body=%s", rec.Code, http.StatusBadRequest, rec.Body.String())
+ }
+
+ remaining := redisqueue.PopOldest(10)
+ if len(remaining) != 1 || string(remaining[0]) != `{"id":1}` {
+ t.Fatalf("remaining queue = %q, want original item", remaining)
+ }
+ })
+}
+
+func withManagementUsageQueue(t *testing.T, fn func()) {
+ t.Helper()
+
+ prevQueueEnabled := redisqueue.Enabled()
+ redisqueue.SetEnabled(false)
+ redisqueue.SetEnabled(true)
+
+ defer func() {
+ redisqueue.SetEnabled(false)
+ redisqueue.SetEnabled(prevQueueEnabled)
+ }()
+
+ fn()
+}
+
+func requireRecordID(t *testing.T, raw json.RawMessage, want int) {
+ t.Helper()
+
+ var payload struct {
+ ID int `json:"id"`
+ }
+ if errUnmarshal := json.Unmarshal(raw, &payload); errUnmarshal != nil {
+ t.Fatalf("unmarshal record: %v", errUnmarshal)
+ }
+ if payload.ID != want {
+ t.Fatalf("record id = %d, want %d", payload.ID, want)
+ }
+}
diff --git a/internal/api/handlers/management/vertex_import.go b/internal/api/handlers/management/vertex_import.go
index bad066a270..bb064b9fb9 100644
--- a/internal/api/handlers/management/vertex_import.go
+++ b/internal/api/handlers/management/vertex_import.go
@@ -9,8 +9,8 @@ import (
"strings"
"github.com/gin-gonic/gin"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/vertex"
- coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/vertex"
+ coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
)
// ImportVertexCredential handles uploading a Vertex service account JSON and saving it as an auth record.
diff --git a/internal/api/middleware/request_logging.go b/internal/api/middleware/request_logging.go
index b57dd8aa42..7a10fad8a1 100644
--- a/internal/api/middleware/request_logging.go
+++ b/internal/api/middleware/request_logging.go
@@ -11,8 +11,8 @@ import (
"time"
"github.com/gin-gonic/gin"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/logging"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/logging"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/util"
)
const maxErrorOnlyCapturedRequestBodyBytes int64 = 1 << 20 // 1 MiB
diff --git a/internal/api/middleware/response_writer.go b/internal/api/middleware/response_writer.go
index 7f4892674a..5a89ed0fdf 100644
--- a/internal/api/middleware/response_writer.go
+++ b/internal/api/middleware/response_writer.go
@@ -10,8 +10,8 @@ import (
"time"
"github.com/gin-gonic/gin"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/logging"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/logging"
)
const requestBodyOverrideContextKey = "REQUEST_BODY_OVERRIDE"
diff --git a/internal/api/middleware/response_writer_test.go b/internal/api/middleware/response_writer_test.go
index f5c21deb8a..fa0bd54854 100644
--- a/internal/api/middleware/response_writer_test.go
+++ b/internal/api/middleware/response_writer_test.go
@@ -7,8 +7,8 @@ import (
"time"
"github.com/gin-gonic/gin"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/logging"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/logging"
)
func TestExtractRequestBodyPrefersOverride(t *testing.T) {
diff --git a/internal/api/modules/amp/amp.go b/internal/api/modules/amp/amp.go
index a12733e2a1..18c8ac1ef0 100644
--- a/internal/api/modules/amp/amp.go
+++ b/internal/api/modules/amp/amp.go
@@ -9,9 +9,9 @@ import (
"sync"
"github.com/gin-gonic/gin"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/api/modules"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
- sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/api/modules"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ sdkaccess "github.com/router-for-me/CLIProxyAPI/v7/sdk/access"
log "github.com/sirupsen/logrus"
)
diff --git a/internal/api/modules/amp/amp_test.go b/internal/api/modules/amp/amp_test.go
index 430c4b62a7..5ca01754a2 100644
--- a/internal/api/modules/amp/amp_test.go
+++ b/internal/api/modules/amp/amp_test.go
@@ -9,10 +9,10 @@ import (
"time"
"github.com/gin-gonic/gin"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/api/modules"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
- sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access"
- "github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/api/modules"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ sdkaccess "github.com/router-for-me/CLIProxyAPI/v7/sdk/access"
+ "github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers"
)
func TestAmpModule_Name(t *testing.T) {
diff --git a/internal/api/modules/amp/fallback_handlers.go b/internal/api/modules/amp/fallback_handlers.go
index e4e0f8a650..06e0a035d0 100644
--- a/internal/api/modules/amp/fallback_handlers.go
+++ b/internal/api/modules/amp/fallback_handlers.go
@@ -8,8 +8,8 @@ import (
"time"
"github.com/gin-gonic/gin"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/util"
log "github.com/sirupsen/logrus"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
diff --git a/internal/api/modules/amp/fallback_handlers_test.go b/internal/api/modules/amp/fallback_handlers_test.go
index a687fd116b..1aacaae21f 100644
--- a/internal/api/modules/amp/fallback_handlers_test.go
+++ b/internal/api/modules/amp/fallback_handlers_test.go
@@ -9,8 +9,8 @@ import (
"testing"
"github.com/gin-gonic/gin"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
)
func TestFallbackHandler_ModelMapping_PreservesThinkingSuffixAndRewritesResponse(t *testing.T) {
diff --git a/internal/api/modules/amp/model_mapping.go b/internal/api/modules/amp/model_mapping.go
index 4159a2b576..2b68866edf 100644
--- a/internal/api/modules/amp/model_mapping.go
+++ b/internal/api/modules/amp/model_mapping.go
@@ -7,9 +7,9 @@ import (
"strings"
"sync"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/util"
log "github.com/sirupsen/logrus"
)
diff --git a/internal/api/modules/amp/model_mapping_test.go b/internal/api/modules/amp/model_mapping_test.go
index 53165d22c3..dcfb07ee5e 100644
--- a/internal/api/modules/amp/model_mapping_test.go
+++ b/internal/api/modules/amp/model_mapping_test.go
@@ -3,8 +3,8 @@ package amp
import (
"testing"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
)
func TestNewModelMapper(t *testing.T) {
diff --git a/internal/api/modules/amp/proxy.go b/internal/api/modules/amp/proxy.go
index c8010854f3..54f4b734ba 100644
--- a/internal/api/modules/amp/proxy.go
+++ b/internal/api/modules/amp/proxy.go
@@ -14,7 +14,7 @@ import (
"strings"
"github.com/gin-gonic/gin"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/misc"
log "github.com/sirupsen/logrus"
)
diff --git a/internal/api/modules/amp/proxy_test.go b/internal/api/modules/amp/proxy_test.go
index 49dba956c0..2852efde3a 100644
--- a/internal/api/modules/amp/proxy_test.go
+++ b/internal/api/modules/amp/proxy_test.go
@@ -11,7 +11,7 @@ import (
"strings"
"testing"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
)
// Helper: compress data with gzip
diff --git a/internal/api/modules/amp/response_rewriter.go b/internal/api/modules/amp/response_rewriter.go
index 707fe576b4..895c494e74 100644
--- a/internal/api/modules/amp/response_rewriter.go
+++ b/internal/api/modules/amp/response_rewriter.go
@@ -123,6 +123,52 @@ func (rw *ResponseRewriter) Flush() {
var modelFieldPaths = []string{"message.model", "model", "modelVersion", "response.model", "response.modelVersion"}
+// ampCanonicalToolNames maps tool names to the exact casing expected by the
+// Amp mode tool whitelist (case-sensitive match).
+var ampCanonicalToolNames = map[string]string{
+ "bash": "Bash",
+ "read": "Read",
+ "grep": "Grep",
+ "glob": "glob",
+ "task": "Task",
+ "check": "Check",
+}
+
+// normalizeAmpToolNames fixes tool_use block names to match Amp's canonical casing.
+// Some upstream models return lowercase tool names (e.g. "bash" instead of "Bash")
+// which causes Amp's case-sensitive mode whitelist to reject them.
+func normalizeAmpToolNames(data []byte) []byte {
+ // Non-streaming: content[].name in tool_use blocks
+ for index, block := range gjson.GetBytes(data, "content").Array() {
+ if block.Get("type").String() != "tool_use" {
+ continue
+ }
+ name := block.Get("name").String()
+ if canonical, ok := ampCanonicalToolNames[strings.ToLower(name)]; ok && name != canonical {
+ path := fmt.Sprintf("content.%d.name", index)
+ var err error
+ data, err = sjson.SetBytes(data, path, canonical)
+ if err != nil {
+ log.Warnf("Amp ResponseRewriter: failed to normalize tool name %q to %q: %v", name, canonical, err)
+ }
+ }
+ }
+
+ // Streaming: content_block.name in content_block_start events
+ if gjson.GetBytes(data, "content_block.type").String() == "tool_use" {
+ name := gjson.GetBytes(data, "content_block.name").String()
+ if canonical, ok := ampCanonicalToolNames[strings.ToLower(name)]; ok && name != canonical {
+ var err error
+ data, err = sjson.SetBytes(data, "content_block.name", canonical)
+ if err != nil {
+ log.Warnf("Amp ResponseRewriter: failed to normalize streaming tool name %q to %q: %v", name, canonical, err)
+ }
+ }
+ }
+
+ return data
+}
+
// ensureAmpSignature injects empty signature fields into tool_use/thinking blocks
// in API responses so that the Amp TUI does not crash on P.signature.length.
func ensureAmpSignature(data []byte) []byte {
@@ -179,6 +225,7 @@ func (rw *ResponseRewriter) suppressAmpThinking(data []byte) []byte {
func (rw *ResponseRewriter) rewriteModelInResponse(data []byte) []byte {
data = ensureAmpSignature(data)
+ data = normalizeAmpToolNames(data)
data = rw.suppressAmpThinking(data)
if len(data) == 0 {
return data
@@ -278,6 +325,9 @@ func (rw *ResponseRewriter) rewriteStreamEvent(data []byte) []byte {
// Inject empty signature where needed
data = ensureAmpSignature(data)
+ // Normalize tool names to canonical casing
+ data = normalizeAmpToolNames(data)
+
// Rewrite model name
if rw.originalModel != "" {
for _, path := range modelFieldPaths {
diff --git a/internal/api/modules/amp/response_rewriter_test.go b/internal/api/modules/amp/response_rewriter_test.go
index ac95dfc64f..a3a350cb23 100644
--- a/internal/api/modules/amp/response_rewriter_test.go
+++ b/internal/api/modules/amp/response_rewriter_test.go
@@ -175,6 +175,57 @@ func TestSanitizeAmpRequestBody_MixedInvalidThinkingAndToolUseSignature(t *testi
}
}
+func TestNormalizeAmpToolNames_NonStreaming(t *testing.T) {
+ input := []byte(`{"content":[{"type":"tool_use","id":"toolu_01","name":"bash","input":{"cmd":"ls"}},{"type":"tool_use","id":"toolu_02","name":"read","input":{"path":"/tmp"}},{"type":"text","text":"hello"}]}`)
+ result := normalizeAmpToolNames(input)
+
+ if !contains(result, []byte(`"name":"Bash"`)) {
+ t.Errorf("expected bash->Bash, got %s", string(result))
+ }
+ if !contains(result, []byte(`"name":"Read"`)) {
+ t.Errorf("expected read->Read, got %s", string(result))
+ }
+ if contains(result, []byte(`"name":"bash"`)) {
+ t.Errorf("expected lowercase bash to be replaced, got %s", string(result))
+ }
+}
+
+func TestNormalizeAmpToolNames_Streaming(t *testing.T) {
+ input := []byte(`{"type":"content_block_start","index":1,"content_block":{"type":"tool_use","name":"grep","id":"toolu_01","input":{}}}`)
+ result := normalizeAmpToolNames(input)
+
+ if !contains(result, []byte(`"name":"Grep"`)) {
+ t.Errorf("expected grep->Grep in streaming, got %s", string(result))
+ }
+}
+
+func TestNormalizeAmpToolNames_AlreadyCorrect(t *testing.T) {
+ input := []byte(`{"content":[{"type":"tool_use","id":"toolu_01","name":"Bash","input":{"cmd":"ls"}}]}`)
+ result := normalizeAmpToolNames(input)
+
+ if string(result) != string(input) {
+ t.Errorf("expected no modification for correctly-cased tool, got %s", string(result))
+ }
+}
+
+func TestNormalizeAmpToolNames_GlobPreserved(t *testing.T) {
+ input := []byte(`{"content":[{"type":"tool_use","id":"toolu_01","name":"glob","input":{"pattern":"*.go"}}]}`)
+ result := normalizeAmpToolNames(input)
+
+ if string(result) != string(input) {
+ t.Errorf("expected glob to remain lowercase, got %s", string(result))
+ }
+}
+
+func TestNormalizeAmpToolNames_UnknownToolUntouched(t *testing.T) {
+ input := []byte(`{"content":[{"type":"tool_use","id":"toolu_01","name":"edit_file","input":{"path":"/tmp/x"}}]}`)
+ result := normalizeAmpToolNames(input)
+
+ if string(result) != string(input) {
+ t.Errorf("expected no modification for unknown tool, got %s", string(result))
+ }
+}
+
func contains(data, substr []byte) bool {
for i := 0; i <= len(data)-len(substr); i++ {
if string(data[i:i+len(substr)]) == string(substr) {
diff --git a/internal/api/modules/amp/routes.go b/internal/api/modules/amp/routes.go
index 456a50ac12..84023d156d 100644
--- a/internal/api/modules/amp/routes.go
+++ b/internal/api/modules/amp/routes.go
@@ -9,11 +9,11 @@ import (
"strings"
"github.com/gin-gonic/gin"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/logging"
- "github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers"
- "github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers/claude"
- "github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers/gemini"
- "github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers/openai"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/logging"
+ "github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers"
+ "github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers/claude"
+ "github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers/gemini"
+ "github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers/openai"
log "github.com/sirupsen/logrus"
)
@@ -21,12 +21,12 @@ import (
// from gin.Context to the request context for SecretSource lookup.
type clientAPIKeyContextKey struct{}
-// clientAPIKeyMiddleware injects the authenticated client API key from gin.Context["apiKey"]
+// clientAPIKeyMiddleware injects the authenticated client API key from gin.Context["userApiKey"]
// into the request context so that SecretSource can look it up for per-client upstream routing.
func clientAPIKeyMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
// Extract the client API key from gin context (set by AuthMiddleware)
- if apiKey, exists := c.Get("apiKey"); exists {
+ if apiKey, exists := c.Get("userApiKey"); exists {
if keyStr, ok := apiKey.(string); ok && keyStr != "" {
// Inject into request context for SecretSource.Get(ctx) to read
ctx := context.WithValue(c.Request.Context(), clientAPIKeyContextKey{}, keyStr)
@@ -199,6 +199,7 @@ func (m *AmpModule) registerManagementRoutes(engine *gin.Engine, baseHandler *ha
ampAPI.Any("/telemetry/*path", proxyHandler)
ampAPI.Any("/threads", proxyHandler)
ampAPI.Any("/threads/*path", proxyHandler)
+ ampAPI.Any("/thread-actors", proxyHandler)
ampAPI.Any("/otel", proxyHandler)
ampAPI.Any("/otel/*path", proxyHandler)
ampAPI.Any("/tab", proxyHandler)
diff --git a/internal/api/modules/amp/routes_test.go b/internal/api/modules/amp/routes_test.go
index bae890aec4..a500f8150c 100644
--- a/internal/api/modules/amp/routes_test.go
+++ b/internal/api/modules/amp/routes_test.go
@@ -6,7 +6,7 @@ import (
"testing"
"github.com/gin-gonic/gin"
- "github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers"
+ "github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers"
)
func TestRegisterManagementRoutes(t *testing.T) {
@@ -49,6 +49,7 @@ func TestRegisterManagementRoutes(t *testing.T) {
{"/api/meta", http.MethodGet},
{"/api/telemetry", http.MethodGet},
{"/api/threads", http.MethodGet},
+ {"/api/thread-actors", http.MethodPost},
{"/threads/", http.MethodGet},
{"/threads.rss", http.MethodGet}, // Root-level route (no /api prefix)
{"/api/otel", http.MethodGet},
diff --git a/internal/api/modules/amp/secret.go b/internal/api/modules/amp/secret.go
index f91c72ba9c..512d263d0c 100644
--- a/internal/api/modules/amp/secret.go
+++ b/internal/api/modules/amp/secret.go
@@ -10,7 +10,7 @@ import (
"sync"
"time"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
log "github.com/sirupsen/logrus"
)
diff --git a/internal/api/modules/amp/secret_test.go b/internal/api/modules/amp/secret_test.go
index 6a6f6ba265..17a75b15de 100644
--- a/internal/api/modules/amp/secret_test.go
+++ b/internal/api/modules/amp/secret_test.go
@@ -9,7 +9,7 @@ import (
"testing"
"time"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
log "github.com/sirupsen/logrus"
"github.com/sirupsen/logrus/hooks/test"
)
diff --git a/internal/api/modules/modules.go b/internal/api/modules/modules.go
index 8c5447d96d..5ddfa609c8 100644
--- a/internal/api/modules/modules.go
+++ b/internal/api/modules/modules.go
@@ -6,8 +6,8 @@ import (
"fmt"
"github.com/gin-gonic/gin"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
- "github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers"
)
// Context encapsulates the dependencies exposed to routing modules during
diff --git a/internal/api/mux_listener.go b/internal/api/mux_listener.go
new file mode 100644
index 0000000000..d9a0c9f401
--- /dev/null
+++ b/internal/api/mux_listener.go
@@ -0,0 +1,68 @@
+package api
+
+import (
+ "net"
+ "sync"
+)
+
+type muxListener struct {
+ addr net.Addr
+ connCh chan net.Conn
+ closeCh chan struct{}
+ once sync.Once
+}
+
+func newMuxListener(addr net.Addr, buffer int) *muxListener {
+ if buffer <= 0 {
+ buffer = 1
+ }
+ return &muxListener{
+ addr: addr,
+ connCh: make(chan net.Conn, buffer),
+ closeCh: make(chan struct{}),
+ }
+}
+
+func (l *muxListener) Put(conn net.Conn) error {
+ if conn == nil {
+ return nil
+ }
+ select {
+ case <-l.closeCh:
+ return net.ErrClosed
+ case l.connCh <- conn:
+ return nil
+ }
+}
+
+func (l *muxListener) Accept() (net.Conn, error) {
+ select {
+ case <-l.closeCh:
+ return nil, net.ErrClosed
+ case conn := <-l.connCh:
+ if conn == nil {
+ return nil, net.ErrClosed
+ }
+ return conn, nil
+ }
+}
+
+func (l *muxListener) Close() error {
+ if l == nil {
+ return nil
+ }
+ l.once.Do(func() {
+ close(l.closeCh)
+ })
+ return nil
+}
+
+func (l *muxListener) Addr() net.Addr {
+ if l == nil {
+ return &net.TCPAddr{}
+ }
+ if l.addr == nil {
+ return &net.TCPAddr{}
+ }
+ return l.addr
+}
diff --git a/internal/api/protocol_multiplexer.go b/internal/api/protocol_multiplexer.go
new file mode 100644
index 0000000000..b83e1164cf
--- /dev/null
+++ b/internal/api/protocol_multiplexer.go
@@ -0,0 +1,115 @@
+package api
+
+import (
+ "bufio"
+ "crypto/tls"
+ "errors"
+ "net"
+ "net/http"
+ "strings"
+
+ log "github.com/sirupsen/logrus"
+)
+
+func normalizeHTTPServeError(err error) error {
+ if err == nil {
+ return nil
+ }
+ if errors.Is(err, net.ErrClosed) {
+ return nil
+ }
+ if errors.Is(err, http.ErrServerClosed) {
+ return nil
+ }
+ return err
+}
+
+func normalizeListenerError(err error) error {
+ if err == nil {
+ return nil
+ }
+ if errors.Is(err, net.ErrClosed) {
+ return nil
+ }
+ return err
+}
+
+func (s *Server) acceptMuxConnections(listener net.Listener, httpListener *muxListener) error {
+ if s == nil || listener == nil {
+ return net.ErrClosed
+ }
+
+ for {
+ conn, errAccept := listener.Accept()
+ if errAccept != nil {
+ return errAccept
+ }
+ if conn == nil {
+ continue
+ }
+
+ tlsConn, ok := conn.(*tls.Conn)
+ if ok {
+ if errHandshake := tlsConn.Handshake(); errHandshake != nil {
+ if errClose := conn.Close(); errClose != nil {
+ log.Errorf("failed to close connection after TLS handshake error: %v", errClose)
+ }
+ continue
+ }
+ proto := strings.TrimSpace(tlsConn.ConnectionState().NegotiatedProtocol)
+ if proto == "h2" || proto == "http/1.1" {
+ if httpListener == nil {
+ if errClose := conn.Close(); errClose != nil {
+ log.Errorf("failed to close connection: %v", errClose)
+ }
+ continue
+ }
+ if errPut := httpListener.Put(tlsConn); errPut != nil {
+ if errClose := conn.Close(); errClose != nil {
+ log.Errorf("failed to close connection after HTTP routing failure: %v", errClose)
+ }
+ }
+ continue
+ }
+ }
+
+ reader := bufio.NewReader(conn)
+ prefix, errPeek := reader.Peek(1)
+ if errPeek != nil {
+ if errClose := conn.Close(); errClose != nil {
+ log.Errorf("failed to close connection after protocol peek failure: %v", errClose)
+ }
+ continue
+ }
+
+ if isRedisRESPPrefix(prefix[0]) {
+ if s.cfg != nil && s.cfg.Home.Enabled {
+ if errClose := conn.Close(); errClose != nil {
+ log.Errorf("failed to close redis connection while home mode is enabled: %v", errClose)
+ }
+ continue
+ }
+ if !s.managementRoutesEnabled.Load() {
+ if errClose := conn.Close(); errClose != nil {
+ log.Errorf("failed to close redis connection while management is disabled: %v", errClose)
+ }
+ continue
+ }
+ go s.handleRedisConnection(conn, reader)
+ continue
+ }
+
+ if httpListener == nil {
+ if errClose := conn.Close(); errClose != nil {
+ log.Errorf("failed to close connection without HTTP listener: %v", errClose)
+ }
+ continue
+ }
+
+ if errPut := httpListener.Put(&bufferedConn{Conn: conn, reader: reader}); errPut != nil {
+ if errClose := conn.Close(); errClose != nil {
+ log.Errorf("failed to close connection after HTTP routing failure: %v", errClose)
+ }
+ }
+ }
+}
diff --git a/internal/api/redis_queue_protocol.go b/internal/api/redis_queue_protocol.go
new file mode 100644
index 0000000000..6f3622d7bf
--- /dev/null
+++ b/internal/api/redis_queue_protocol.go
@@ -0,0 +1,377 @@
+package api
+
+import (
+ "bufio"
+ "errors"
+ "fmt"
+ "io"
+ "net"
+ "net/http"
+ "strconv"
+ "strings"
+
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/redisqueue"
+ log "github.com/sirupsen/logrus"
+)
+
+func isRedisRESPPrefix(prefix byte) bool {
+ switch prefix {
+ case '*', '$', '+', '-', ':':
+ return true
+ default:
+ return false
+ }
+}
+
+func (s *Server) handleRedisConnection(conn net.Conn, reader *bufio.Reader) {
+ if s == nil || conn == nil || reader == nil {
+ return
+ }
+
+ clientIP, localClient := resolveRemoteIP(conn.RemoteAddr())
+ authed := false
+ writer := bufio.NewWriter(conn)
+ defer func() {
+ if errClose := conn.Close(); errClose != nil {
+ log.Errorf("redis connection close error: %v", errClose)
+ }
+ }()
+
+ flush := func() bool {
+ if errFlush := writer.Flush(); errFlush != nil {
+ log.Errorf("redis protocol flush error: %v", errFlush)
+ return false
+ }
+ return true
+ }
+
+ if s.cfg != nil && s.cfg.Home.Enabled {
+ _ = writeRedisError(writer, "ERR redis usage output disabled in home mode")
+ _ = writer.Flush()
+ return
+ }
+
+ for {
+ if !s.managementRoutesEnabled.Load() {
+ return
+ }
+
+ args, err := readRESPArray(reader)
+ if err != nil {
+ if !errors.Is(err, io.EOF) {
+ _ = writeRedisError(writer, "ERR "+err.Error())
+ _ = writer.Flush()
+ }
+ return
+ }
+ if len(args) == 0 {
+ _ = writeRedisError(writer, "ERR empty command")
+ if !flush() {
+ return
+ }
+ continue
+ }
+
+ cmd := strings.ToUpper(strings.TrimSpace(args[0]))
+
+ if cmd != "AUTH" && !authed {
+ if s.mgmt != nil {
+ _, statusCode, errMsg := s.mgmt.AuthenticateManagementKey(clientIP, localClient, "")
+ if statusCode == http.StatusForbidden && strings.HasPrefix(errMsg, "IP banned due to too many failed attempts") {
+ _ = writeRedisError(writer, "ERR "+errMsg)
+ } else {
+ _ = writeRedisError(writer, "NOAUTH Authentication required.")
+ }
+ } else {
+ _ = writeRedisError(writer, "NOAUTH Authentication required.")
+ }
+ if !flush() {
+ return
+ }
+ continue
+ }
+
+ switch cmd {
+ case "AUTH":
+ password, ok := parseAuthPassword(args)
+ if !ok {
+ if s.mgmt != nil {
+ _, statusCode, errMsg := s.mgmt.AuthenticateManagementKey(clientIP, localClient, "")
+ if statusCode == http.StatusForbidden && strings.HasPrefix(errMsg, "IP banned due to too many failed attempts") {
+ _ = writeRedisError(writer, "ERR "+errMsg)
+ if !flush() {
+ return
+ }
+ continue
+ }
+ }
+ _ = writeRedisError(writer, "ERR wrong number of arguments for 'auth' command")
+ if !flush() {
+ return
+ }
+ continue
+ }
+ if s.mgmt == nil {
+ _ = writeRedisError(writer, "ERR remote management disabled")
+ if !flush() {
+ return
+ }
+ continue
+ }
+ allowed, _, errMsg := s.mgmt.AuthenticateManagementKey(clientIP, localClient, password)
+ if !allowed {
+ _ = writeRedisError(writer, "ERR "+errMsg)
+ if !flush() {
+ return
+ }
+ continue
+ }
+ authed = true
+ _ = writeRedisSimpleString(writer, "OK")
+ if !flush() {
+ return
+ }
+ case "LPOP", "RPOP":
+ if !authed {
+ _ = writeRedisError(writer, "NOAUTH Authentication required.")
+ if !flush() {
+ return
+ }
+ continue
+ }
+ count, hasCount, ok := parsePopCount(args)
+ if !ok {
+ _ = writeRedisError(writer, "ERR wrong number of arguments for '"+strings.ToLower(cmd)+"' command")
+ if !flush() {
+ return
+ }
+ continue
+ }
+ if count <= 0 {
+ _ = writeRedisError(writer, "ERR value is not an integer or out of range")
+ if !flush() {
+ return
+ }
+ continue
+ }
+ items := redisqueue.PopOldest(count)
+ if hasCount {
+ _ = writeRedisArrayOfBulkStrings(writer, items)
+ if !flush() {
+ return
+ }
+ continue
+ }
+ if len(items) == 0 {
+ _ = writeRedisNilBulkString(writer)
+ if !flush() {
+ return
+ }
+ continue
+ }
+ _ = writeRedisBulkString(writer, items[0])
+ if !flush() {
+ return
+ }
+ default:
+ _ = writeRedisError(writer, fmt.Sprintf("ERR unknown command '%s'", strings.ToLower(cmd)))
+ if !flush() {
+ return
+ }
+ }
+ }
+}
+
+func resolveRemoteIP(addr net.Addr) (ip string, localClient bool) {
+ if addr == nil {
+ return "", false
+ }
+
+ var host string
+ switch a := addr.(type) {
+ case *net.TCPAddr:
+ if a != nil && a.IP != nil {
+ if ip4 := a.IP.To4(); ip4 != nil {
+ host = ip4.String()
+ } else {
+ host = a.IP.String()
+ }
+ }
+ default:
+ host = addr.String()
+ if h, _, err := net.SplitHostPort(host); err == nil {
+ host = h
+ }
+ host = strings.TrimSpace(host)
+ if raw, _, ok := strings.Cut(host, "%"); ok {
+ host = raw
+ }
+ if parsed := net.ParseIP(host); parsed != nil {
+ if ip4 := parsed.To4(); ip4 != nil {
+ host = ip4.String()
+ } else {
+ host = parsed.String()
+ }
+ }
+ }
+
+ host = strings.TrimSpace(host)
+ localClient = host == "127.0.0.1" || host == "::1"
+ return host, localClient
+}
+
+func parseAuthPassword(args []string) (string, bool) {
+ switch len(args) {
+ case 2:
+ return args[1], true
+ case 3:
+ // Support AUTH by ignoring username for compatibility.
+ return args[2], true
+ default:
+ return "", false
+ }
+}
+
+func parsePopCount(args []string) (count int, hasCount bool, ok bool) {
+ if len(args) != 2 && len(args) != 3 {
+ return 0, false, false
+ }
+ if len(args) == 2 {
+ return 1, false, true
+ }
+ parsed, err := strconv.Atoi(strings.TrimSpace(args[2]))
+ if err != nil {
+ return 0, true, true
+ }
+ return parsed, true, true
+}
+
+func readRESPArray(reader *bufio.Reader) ([]string, error) {
+ prefix, err := reader.ReadByte()
+ if err != nil {
+ return nil, err
+ }
+ if prefix != '*' {
+ return nil, fmt.Errorf("protocol error")
+ }
+ line, err := readRESPLine(reader)
+ if err != nil {
+ return nil, err
+ }
+ count, err := strconv.Atoi(line)
+ if err != nil || count < 0 {
+ return nil, fmt.Errorf("protocol error")
+ }
+ args := make([]string, 0, count)
+ for i := 0; i < count; i++ {
+ value, err := readRESPString(reader)
+ if err != nil {
+ return nil, err
+ }
+ args = append(args, value)
+ }
+ return args, nil
+}
+
+func readRESPString(reader *bufio.Reader) (string, error) {
+ prefix, err := reader.ReadByte()
+ if err != nil {
+ return "", err
+ }
+ switch prefix {
+ case '$':
+ return readRESPBulkString(reader)
+ case '+', ':':
+ return readRESPLine(reader)
+ default:
+ return "", fmt.Errorf("protocol error")
+ }
+}
+
+func readRESPBulkString(reader *bufio.Reader) (string, error) {
+ line, err := readRESPLine(reader)
+ if err != nil {
+ return "", err
+ }
+ length, err := strconv.Atoi(line)
+ if err != nil {
+ return "", fmt.Errorf("protocol error")
+ }
+ if length < 0 {
+ return "", nil
+ }
+ buf := make([]byte, length+2)
+ if _, err := io.ReadFull(reader, buf); err != nil {
+ return "", err
+ }
+ if length+2 < 2 || buf[length] != '\r' || buf[length+1] != '\n' {
+ return "", fmt.Errorf("protocol error")
+ }
+ return string(buf[:length]), nil
+}
+
+func readRESPLine(reader *bufio.Reader) (string, error) {
+ line, err := reader.ReadString('\n')
+ if err != nil {
+ return "", err
+ }
+ line = strings.TrimSuffix(line, "\n")
+ line = strings.TrimSuffix(line, "\r")
+ return line, nil
+}
+
+func writeRedisSimpleString(writer *bufio.Writer, value string) error {
+ if writer == nil {
+ return net.ErrClosed
+ }
+ _, err := writer.WriteString("+" + value + "\r\n")
+ return err
+}
+
+func writeRedisError(writer *bufio.Writer, message string) error {
+ if writer == nil {
+ return net.ErrClosed
+ }
+ _, err := writer.WriteString("-" + message + "\r\n")
+ return err
+}
+
+func writeRedisNilBulkString(writer *bufio.Writer) error {
+ if writer == nil {
+ return net.ErrClosed
+ }
+ _, err := writer.WriteString("$-1\r\n")
+ return err
+}
+
+func writeRedisBulkString(writer *bufio.Writer, payload []byte) error {
+ if writer == nil {
+ return net.ErrClosed
+ }
+ if payload == nil {
+ return writeRedisNilBulkString(writer)
+ }
+ if _, err := writer.WriteString("$" + strconv.Itoa(len(payload)) + "\r\n"); err != nil {
+ return err
+ }
+ if _, err := writer.Write(payload); err != nil {
+ return err
+ }
+ _, err := writer.WriteString("\r\n")
+ return err
+}
+
+func writeRedisArrayOfBulkStrings(writer *bufio.Writer, items [][]byte) error {
+ if writer == nil {
+ return net.ErrClosed
+ }
+ if _, err := writer.WriteString("*" + strconv.Itoa(len(items)) + "\r\n"); err != nil {
+ return err
+ }
+ for i := range items {
+ if err := writeRedisBulkString(writer, items[i]); err != nil {
+ return err
+ }
+ }
+ return nil
+}
diff --git a/internal/api/redis_queue_protocol_integration_test.go b/internal/api/redis_queue_protocol_integration_test.go
new file mode 100644
index 0000000000..1586d37c85
--- /dev/null
+++ b/internal/api/redis_queue_protocol_integration_test.go
@@ -0,0 +1,513 @@
+package api
+
+import (
+ "bufio"
+ "bytes"
+ "errors"
+ "fmt"
+ "io"
+ "net"
+ "strconv"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/redisqueue"
+)
+
+type remoteAddrConn struct {
+ net.Conn
+ remoteAddr net.Addr
+}
+
+func (c *remoteAddrConn) RemoteAddr() net.Addr {
+ if c == nil {
+ return nil
+ }
+ return c.remoteAddr
+}
+
+func startRedisMuxListener(t *testing.T, server *Server) (addr string, stop func()) {
+ t.Helper()
+
+ listener, errListen := net.Listen("tcp", "127.0.0.1:0")
+ if errListen != nil {
+ t.Fatalf("failed to listen: %v", errListen)
+ }
+
+ errCh := make(chan error, 1)
+ go func() {
+ errCh <- server.acceptMuxConnections(listener, nil)
+ }()
+
+ stop = func() {
+ _ = listener.Close()
+ select {
+ case err := <-errCh:
+ if err != nil && !errors.Is(err, net.ErrClosed) {
+ t.Errorf("accept loop returned unexpected error: %v", err)
+ }
+ case <-time.After(2 * time.Second):
+ t.Errorf("timeout waiting for accept loop to exit")
+ }
+ }
+
+ return listener.Addr().String(), stop
+}
+
+func writeTestRESPCommand(conn net.Conn, args ...string) error {
+ if conn == nil {
+ return net.ErrClosed
+ }
+ if len(args) == 0 {
+ return nil
+ }
+
+ var buf bytes.Buffer
+ fmt.Fprintf(&buf, "*%d\r\n", len(args))
+ for _, arg := range args {
+ fmt.Fprintf(&buf, "$%d\r\n%s\r\n", len(arg), arg)
+ }
+ _, err := conn.Write(buf.Bytes())
+ return err
+}
+
+func readTestRESPLine(r *bufio.Reader) (string, error) {
+ line, err := r.ReadString('\n')
+ if err != nil {
+ return "", err
+ }
+ if !strings.HasSuffix(line, "\r\n") {
+ return "", fmt.Errorf("invalid RESP line terminator: %q", line)
+ }
+ return strings.TrimSuffix(line, "\r\n"), nil
+}
+
+func readTestRESPSimpleString(r *bufio.Reader) (string, error) {
+ prefix, err := r.ReadByte()
+ if err != nil {
+ return "", err
+ }
+ if prefix != '+' {
+ return "", fmt.Errorf("expected simple string prefix '+', got %q", prefix)
+ }
+ return readTestRESPLine(r)
+}
+
+func readTestRESPError(r *bufio.Reader) (string, error) {
+ prefix, err := r.ReadByte()
+ if err != nil {
+ return "", err
+ }
+ if prefix != '-' {
+ return "", fmt.Errorf("expected error prefix '-', got %q", prefix)
+ }
+ return readTestRESPLine(r)
+}
+
+func readTestRESPBulkString(r *bufio.Reader) ([]byte, error) {
+ prefix, err := r.ReadByte()
+ if err != nil {
+ return nil, err
+ }
+ if prefix != '$' {
+ return nil, fmt.Errorf("expected bulk string prefix '$', got %q", prefix)
+ }
+
+ line, err := readTestRESPLine(r)
+ if err != nil {
+ return nil, err
+ }
+ length, err := strconv.Atoi(line)
+ if err != nil {
+ return nil, fmt.Errorf("invalid bulk string length %q: %v", line, err)
+ }
+ if length == -1 {
+ return nil, nil
+ }
+ if length < -1 {
+ return nil, fmt.Errorf("invalid bulk string length %d", length)
+ }
+
+ payload := make([]byte, length+2)
+ if _, err := io.ReadFull(r, payload); err != nil {
+ return nil, err
+ }
+ if payload[length] != '\r' || payload[length+1] != '\n' {
+ return nil, fmt.Errorf("invalid bulk string terminator")
+ }
+ return payload[:length], nil
+}
+
+func readRESPArrayOfBulkStrings(r *bufio.Reader) ([][]byte, error) {
+ prefix, err := r.ReadByte()
+ if err != nil {
+ return nil, err
+ }
+ if prefix != '*' {
+ return nil, fmt.Errorf("expected array prefix '*', got %q", prefix)
+ }
+
+ line, err := readTestRESPLine(r)
+ if err != nil {
+ return nil, err
+ }
+ count, err := strconv.Atoi(line)
+ if err != nil {
+ return nil, fmt.Errorf("invalid array length %q: %v", line, err)
+ }
+ if count < 0 {
+ return nil, fmt.Errorf("invalid array length %d", count)
+ }
+
+ out := make([][]byte, 0, count)
+ for i := 0; i < count; i++ {
+ item, err := readTestRESPBulkString(r)
+ if err != nil {
+ return nil, err
+ }
+ out = append(out, item)
+ }
+ return out, nil
+}
+
+func TestRedisProtocol_ManagementDisabled_RejectsConnection(t *testing.T) {
+ t.Setenv("MANAGEMENT_PASSWORD", "")
+ redisqueue.SetEnabled(false)
+
+ server := newTestServer(t)
+ if server.managementRoutesEnabled.Load() {
+ t.Fatalf("expected managementRoutesEnabled to be false")
+ }
+
+ addr, stop := startRedisMuxListener(t, server)
+ t.Cleanup(stop)
+
+ conn, errDial := net.DialTimeout("tcp", addr, time.Second)
+ if errDial != nil {
+ t.Fatalf("failed to dial redis listener: %v", errDial)
+ }
+ t.Cleanup(func() { _ = conn.Close() })
+
+ _ = conn.SetDeadline(time.Now().Add(2 * time.Second))
+ if errWrite := writeTestRESPCommand(conn, "PING"); errWrite != nil {
+ t.Fatalf("failed to write RESP command: %v", errWrite)
+ }
+
+ buf := make([]byte, 1)
+ _, errRead := conn.Read(buf)
+ if errRead == nil {
+ t.Fatalf("expected connection to be closed when management is disabled")
+ }
+ if ne, ok := errRead.(net.Error); ok && ne.Timeout() {
+ t.Fatalf("expected connection to be closed when management is disabled, got timeout: %v", errRead)
+ }
+}
+
+func TestRedisProtocol_HomeEnabled_DisablesConnection(t *testing.T) {
+ t.Setenv("MANAGEMENT_PASSWORD", "test-management-password")
+ redisqueue.SetEnabled(false)
+ t.Cleanup(func() { redisqueue.SetEnabled(false) })
+
+ server := newTestServer(t)
+ if !server.managementRoutesEnabled.Load() {
+ t.Fatalf("expected managementRoutesEnabled to be true")
+ }
+ if server.cfg == nil {
+ t.Fatalf("expected server cfg to be non-nil")
+ }
+ server.cfg.Home.Enabled = true
+ redisqueue.SetEnabled(true)
+
+ addr, stop := startRedisMuxListener(t, server)
+ t.Cleanup(stop)
+
+ conn, errDial := net.DialTimeout("tcp", addr, time.Second)
+ if errDial != nil {
+ t.Fatalf("failed to dial redis listener: %v", errDial)
+ }
+ t.Cleanup(func() { _ = conn.Close() })
+
+ _ = conn.SetDeadline(time.Now().Add(2 * time.Second))
+ _ = writeTestRESPCommand(conn, "PING")
+
+ buf := make([]byte, 1)
+ _, errRead := conn.Read(buf)
+ if errRead == nil {
+ t.Fatalf("expected connection to be closed when home mode is enabled")
+ }
+ if ne, ok := errRead.(net.Error); ok && ne.Timeout() {
+ t.Fatalf("expected connection to be closed when home mode is enabled, got timeout: %v", errRead)
+ }
+}
+
+func TestRedisProtocol_AUTH_And_PopContracts(t *testing.T) {
+ const managementPassword = "test-management-password"
+
+ t.Setenv("MANAGEMENT_PASSWORD", managementPassword)
+ redisqueue.SetEnabled(false)
+ t.Cleanup(func() { redisqueue.SetEnabled(false) })
+
+ server := newTestServer(t)
+ if !server.managementRoutesEnabled.Load() {
+ t.Fatalf("expected managementRoutesEnabled to be true")
+ }
+
+ addr, stop := startRedisMuxListener(t, server)
+ t.Cleanup(stop)
+
+ conn, errDial := net.DialTimeout("tcp", addr, time.Second)
+ if errDial != nil {
+ t.Fatalf("failed to dial redis listener: %v", errDial)
+ }
+ t.Cleanup(func() { _ = conn.Close() })
+
+ reader := bufio.NewReader(conn)
+
+ _ = conn.SetDeadline(time.Now().Add(5 * time.Second))
+
+ if errWrite := writeTestRESPCommand(conn, "AUTH", "test-key"); errWrite != nil {
+ t.Fatalf("failed to write AUTH command: %v", errWrite)
+ }
+ if msg, err := readTestRESPError(reader); err != nil {
+ t.Fatalf("failed to read AUTH error: %v", err)
+ } else if msg != "ERR invalid management key" {
+ t.Fatalf("unexpected AUTH error: %q", msg)
+ }
+
+ if errWrite := writeTestRESPCommand(conn, "LPOP", "queue"); errWrite != nil {
+ t.Fatalf("failed to write LPOP command: %v", errWrite)
+ }
+ if msg, err := readTestRESPError(reader); err != nil {
+ t.Fatalf("failed to read LPOP NOAUTH error: %v", err)
+ } else if msg != "NOAUTH Authentication required." {
+ t.Fatalf("unexpected LPOP NOAUTH error: %q", msg)
+ }
+
+ if errWrite := writeTestRESPCommand(conn, "AUTH", managementPassword); errWrite != nil {
+ t.Fatalf("failed to write AUTH command: %v", errWrite)
+ }
+ if msg, err := readTestRESPSimpleString(reader); err != nil {
+ t.Fatalf("failed to read AUTH response: %v", err)
+ } else if msg != "OK" {
+ t.Fatalf("unexpected AUTH response: %q", msg)
+ }
+
+ if !redisqueue.Enabled() {
+ t.Fatalf("expected redisqueue to be enabled")
+ }
+ redisqueue.Enqueue([]byte("a"))
+ redisqueue.Enqueue([]byte("b"))
+ redisqueue.Enqueue([]byte("c"))
+
+ if errWrite := writeTestRESPCommand(conn, "RPOP", "queue"); errWrite != nil {
+ t.Fatalf("failed to write RPOP command: %v", errWrite)
+ }
+ if item, err := readTestRESPBulkString(reader); err != nil {
+ t.Fatalf("failed to read RPOP response: %v", err)
+ } else if string(item) != "a" {
+ t.Fatalf("unexpected RPOP item: %q", string(item))
+ }
+
+ if errWrite := writeTestRESPCommand(conn, "LPOP", "queue"); errWrite != nil {
+ t.Fatalf("failed to write LPOP command: %v", errWrite)
+ }
+ if item, err := readTestRESPBulkString(reader); err != nil {
+ t.Fatalf("failed to read LPOP response: %v", err)
+ } else if string(item) != "b" {
+ t.Fatalf("unexpected LPOP item: %q", string(item))
+ }
+
+ if errWrite := writeTestRESPCommand(conn, "RPOP", "queue", "10"); errWrite != nil {
+ t.Fatalf("failed to write RPOP count command: %v", errWrite)
+ }
+ items, errItems := readRESPArrayOfBulkStrings(reader)
+ if errItems != nil {
+ t.Fatalf("failed to read RPOP count response: %v", errItems)
+ }
+ if len(items) != 1 || string(items[0]) != "c" {
+ t.Fatalf("unexpected RPOP count items: %#v", items)
+ }
+
+ if errWrite := writeTestRESPCommand(conn, "LPOP", "queue"); errWrite != nil {
+ t.Fatalf("failed to write LPOP empty command: %v", errWrite)
+ }
+ item, errItem := readTestRESPBulkString(reader)
+ if errItem != nil {
+ t.Fatalf("failed to read LPOP empty response: %v", errItem)
+ }
+ if item != nil {
+ t.Fatalf("expected nil bulk string for empty queue, got %q", string(item))
+ }
+
+ if errWrite := writeTestRESPCommand(conn, "RPOP", "queue", "2"); errWrite != nil {
+ t.Fatalf("failed to write RPOP empty count command: %v", errWrite)
+ }
+ emptyItems, errEmpty := readRESPArrayOfBulkStrings(reader)
+ if errEmpty != nil {
+ t.Fatalf("failed to read RPOP empty count response: %v", errEmpty)
+ }
+ if len(emptyItems) != 0 {
+ t.Fatalf("expected empty array for empty queue with count, got %#v", emptyItems)
+ }
+}
+
+func TestRedisProtocol_IPBan_MirrorsManagementPolicy(t *testing.T) {
+ const managementPassword = "test-management-password"
+
+ t.Setenv("MANAGEMENT_PASSWORD", managementPassword)
+ redisqueue.SetEnabled(false)
+ t.Cleanup(func() { redisqueue.SetEnabled(false) })
+
+ server := newTestServer(t)
+ if !server.managementRoutesEnabled.Load() {
+ t.Fatalf("expected managementRoutesEnabled to be true")
+ }
+
+ clientConn, serverConn := net.Pipe()
+ t.Cleanup(func() { _ = clientConn.Close() })
+ t.Cleanup(func() { _ = serverConn.Close() })
+
+ fakeRemote := &net.TCPAddr{
+ IP: net.ParseIP("1.2.3.4"),
+ Port: 1234,
+ }
+ wrappedConn := &remoteAddrConn{Conn: serverConn, remoteAddr: fakeRemote}
+
+ go server.handleRedisConnection(wrappedConn, bufio.NewReader(wrappedConn))
+
+ reader := bufio.NewReader(clientConn)
+ _ = clientConn.SetDeadline(time.Now().Add(5 * time.Second))
+
+ for i := 0; i < 5; i++ {
+ if errWrite := writeTestRESPCommand(clientConn, "LPOP", "queue"); errWrite != nil {
+ t.Fatalf("failed to write LPOP command: %v", errWrite)
+ }
+ if msg, err := readTestRESPError(reader); err != nil {
+ t.Fatalf("failed to read LPOP NOAUTH error: %v", err)
+ } else if msg != "NOAUTH Authentication required." {
+ t.Fatalf("unexpected LPOP NOAUTH error at attempt %d: %q", i+1, msg)
+ }
+ }
+
+ if errWrite := writeTestRESPCommand(clientConn, "LPOP", "queue"); errWrite != nil {
+ t.Fatalf("failed to write LPOP command after failures: %v", errWrite)
+ }
+ msg, err := readTestRESPError(reader)
+ if err != nil {
+ t.Fatalf("failed to read LPOP banned error: %v", err)
+ }
+ if !strings.HasPrefix(msg, "ERR IP banned due to too many failed attempts. Try again in") {
+ t.Fatalf("unexpected LPOP banned error: %q", msg)
+ }
+}
+
+func TestRedisProtocol_AUTH_IPBan_BlocksCorrectPasswordDuringBan(t *testing.T) {
+ const managementPassword = "test-management-password"
+
+ t.Setenv("MANAGEMENT_PASSWORD", managementPassword)
+ redisqueue.SetEnabled(false)
+ t.Cleanup(func() { redisqueue.SetEnabled(false) })
+
+ server := newTestServer(t)
+ if !server.managementRoutesEnabled.Load() {
+ t.Fatalf("expected managementRoutesEnabled to be true")
+ }
+
+ clientConn, serverConn := net.Pipe()
+ t.Cleanup(func() { _ = clientConn.Close() })
+ t.Cleanup(func() { _ = serverConn.Close() })
+
+ fakeRemote := &net.TCPAddr{
+ IP: net.ParseIP("1.2.3.4"),
+ Port: 1234,
+ }
+ wrappedConn := &remoteAddrConn{Conn: serverConn, remoteAddr: fakeRemote}
+
+ go server.handleRedisConnection(wrappedConn, bufio.NewReader(wrappedConn))
+
+ reader := bufio.NewReader(clientConn)
+ _ = clientConn.SetDeadline(time.Now().Add(5 * time.Second))
+
+ for i := 0; i < 5; i++ {
+ if errWrite := writeTestRESPCommand(clientConn, "AUTH", "wrong-password"); errWrite != nil {
+ t.Fatalf("failed to write AUTH command: %v", errWrite)
+ }
+ if msg, err := readTestRESPError(reader); err != nil {
+ t.Fatalf("failed to read AUTH error: %v", err)
+ } else if msg != "ERR invalid management key" {
+ t.Fatalf("unexpected AUTH error at attempt %d: %q", i+1, msg)
+ }
+ }
+
+ for i := 0; i < 2; i++ {
+ if errWrite := writeTestRESPCommand(clientConn, "AUTH", "wrong-password"); errWrite != nil {
+ t.Fatalf("failed to write AUTH command after failures: %v", errWrite)
+ }
+ msg, err := readTestRESPError(reader)
+ if err != nil {
+ t.Fatalf("failed to read AUTH banned error: %v", err)
+ }
+ if !strings.HasPrefix(msg, "ERR IP banned due to too many failed attempts. Try again in") {
+ t.Fatalf("unexpected AUTH banned error at attempt %d: %q", i+6, msg)
+ }
+ }
+
+ if errWrite := writeTestRESPCommand(clientConn, "AUTH", managementPassword); errWrite != nil {
+ t.Fatalf("failed to write AUTH command with correct password: %v", errWrite)
+ }
+ msg, err := readTestRESPError(reader)
+ if err != nil {
+ t.Fatalf("failed to read AUTH banned error for correct password: %v", err)
+ }
+ if !strings.HasPrefix(msg, "ERR IP banned due to too many failed attempts. Try again in") {
+ t.Fatalf("unexpected AUTH banned error for correct password: %q", msg)
+ }
+}
+
+func TestRedisProtocol_LOCALHOST_AUTH_IPBan_BlocksCorrectPasswordDuringBan(t *testing.T) {
+ const managementPassword = "test-management-password"
+
+ t.Setenv("MANAGEMENT_PASSWORD", managementPassword)
+ redisqueue.SetEnabled(false)
+ t.Cleanup(func() { redisqueue.SetEnabled(false) })
+
+ server := newTestServer(t)
+ if !server.managementRoutesEnabled.Load() {
+ t.Fatalf("expected managementRoutesEnabled to be true")
+ }
+
+ addr, stop := startRedisMuxListener(t, server)
+ t.Cleanup(stop)
+
+ conn, errDial := net.DialTimeout("tcp", addr, time.Second)
+ if errDial != nil {
+ t.Fatalf("failed to dial redis listener: %v", errDial)
+ }
+ t.Cleanup(func() { _ = conn.Close() })
+
+ reader := bufio.NewReader(conn)
+ _ = conn.SetDeadline(time.Now().Add(5 * time.Second))
+
+ for i := 0; i < 5; i++ {
+ if errWrite := writeTestRESPCommand(conn, "AUTH", "wrong-password"); errWrite != nil {
+ t.Fatalf("failed to write AUTH command: %v", errWrite)
+ }
+ if msg, err := readTestRESPError(reader); err != nil {
+ t.Fatalf("failed to read AUTH error: %v", err)
+ } else if msg != "ERR invalid management key" {
+ t.Fatalf("unexpected AUTH error at attempt %d: %q", i+1, msg)
+ }
+ }
+
+ if errWrite := writeTestRESPCommand(conn, "AUTH", managementPassword); errWrite != nil {
+ t.Fatalf("failed to write AUTH command with correct password: %v", errWrite)
+ }
+ msg, err := readTestRESPError(reader)
+ if err != nil {
+ t.Fatalf("failed to read AUTH banned error for correct password: %v", err)
+ }
+ if !strings.HasPrefix(msg, "ERR IP banned due to too many failed attempts. Try again in") {
+ t.Fatalf("unexpected AUTH banned error for correct password: %q", msg)
+ }
+}
diff --git a/internal/api/server.go b/internal/api/server.go
index 075455ba83..04f1fb0ab0 100644
--- a/internal/api/server.go
+++ b/internal/api/server.go
@@ -7,37 +7,43 @@ package api
import (
"context"
"crypto/subtle"
+ "crypto/tls"
+ "encoding/json"
"errors"
"fmt"
+ "net"
"net/http"
"os"
"path/filepath"
"reflect"
+ "sort"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/gin-gonic/gin"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/access"
- managementHandlers "github.com/router-for-me/CLIProxyAPI/v6/internal/api/handlers/management"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/api/middleware"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/api/modules"
- ampmodule "github.com/router-for-me/CLIProxyAPI/v6/internal/api/modules/amp"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/cache"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/logging"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/managementasset"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/usage"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
- sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access"
- "github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers"
- "github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers/claude"
- "github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers/gemini"
- "github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers/openai"
- sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
- "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/access"
+ managementHandlers "github.com/router-for-me/CLIProxyAPI/v7/internal/api/handlers/management"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/api/middleware"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/api/modules"
+ ampmodule "github.com/router-for-me/CLIProxyAPI/v7/internal/api/modules/amp"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/cache"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/home"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/logging"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/managementasset"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/redisqueue"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/util"
+ sdkaccess "github.com/router-for-me/CLIProxyAPI/v7/sdk/access"
+ "github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers"
+ "github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers/claude"
+ "github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers/gemini"
+ "github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers/openai"
+ sdkAuth "github.com/router-for-me/CLIProxyAPI/v7/sdk/auth"
+ "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
log "github.com/sirupsen/logrus"
+ "golang.org/x/net/http2"
"gopkg.in/yaml.v3"
)
@@ -61,7 +67,9 @@ type ServerOption func(*serverOptionConfig)
func defaultRequestLoggerFactory(cfg *config.Config, configPath string) logging.RequestLogger {
configDir := filepath.Dir(configPath)
logsDir := logging.ResolveLogDirectory(cfg)
- return logging.NewFileRequestLogger(cfg.RequestLog, logsDir, configDir, cfg.ErrorLogsMaxFiles)
+ logger := logging.NewFileRequestLogger(cfg.RequestLog, logsDir, configDir, cfg.ErrorLogsMaxFiles)
+ logger.SetHomeEnabled(cfg != nil && cfg.Home.Enabled)
+ return logger
}
// WithMiddleware appends additional Gin middleware during server construction.
@@ -127,6 +135,12 @@ type Server struct {
// server is the underlying HTTP server.
server *http.Server
+ // muxBaseListener is the shared TCP listener used to serve both HTTP and Redis protocol traffic.
+ muxBaseListener net.Listener
+
+ // muxHTTPListener receives HTTP connections selected by the multiplexer.
+ muxHTTPListener *muxListener
+
// handlers contains the API handlers for processing requests.
handlers *handlers.BaseAPIHandler
@@ -275,6 +289,10 @@ func NewServer(cfg *config.Config, authManager *auth.Manager, accessManager *sdk
}
s.localPassword = optionState.localPassword
+ // Home heartbeat gate: when home is enabled, block all endpoints with 503 until the
+ // subscribe-config heartbeat connection is healthy.
+ engine.Use(s.homeHeartbeatMiddleware())
+
// Setup routes
s.setupRoutes()
@@ -299,6 +317,7 @@ func NewServer(cfg *config.Config, authManager *auth.Manager, accessManager *sdk
// or when a local management password is provided (e.g. TUI mode).
hasManagementSecret := cfg.RemoteManagement.SecretKey != "" || envManagementSecret || s.localPassword != ""
s.managementRoutesEnabled.Store(hasManagementSecret)
+ redisqueue.SetEnabled(hasManagementSecret || (cfg != nil && cfg.Home.Enabled))
if hasManagementSecret {
s.registerManagementRoutes()
}
@@ -316,12 +335,41 @@ func NewServer(cfg *config.Config, authManager *auth.Manager, accessManager *sdk
return s
}
+func (s *Server) homeHeartbeatMiddleware() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ if s == nil || s.cfg == nil || !s.cfg.Home.Enabled {
+ c.Next()
+ return
+ }
+ if c != nil && c.Request != nil {
+ path := c.Request.URL.Path
+ if strings.HasPrefix(path, "/v0/management/") || path == "/v0/management" || path == "/management.html" {
+ c.Next()
+ return
+ }
+ }
+ client := home.Current()
+ if client == nil || !client.HeartbeatOK() {
+ c.AbortWithStatus(http.StatusServiceUnavailable)
+ return
+ }
+ c.Next()
+ }
+}
+
// setupRoutes configures the API routes for the server.
// It defines the endpoints and associates them with their respective handlers.
func (s *Server) setupRoutes() {
- s.engine.GET("/healthz", func(c *gin.Context) {
+ healthzHandler := func(c *gin.Context) {
+ if c.Request.Method == http.MethodHead {
+ c.Status(http.StatusOK)
+ return
+ }
+
c.JSON(http.StatusOK, gin.H{"status": "ok"})
- })
+ }
+ s.engine.GET("/healthz", healthzHandler)
+ s.engine.HEAD("/healthz", healthzHandler)
s.engine.GET("/management.html", s.serveManagementControlPanel)
openaiHandlers := openai.NewOpenAIAPIHandler(s.handlers)
@@ -337,6 +385,8 @@ func (s *Server) setupRoutes() {
v1.GET("/models", s.unifiedModelsHandler(openaiHandlers, claudeCodeHandlers))
v1.POST("/chat/completions", openaiHandlers.ChatCompletions)
v1.POST("/completions", openaiHandlers.Completions)
+ v1.POST("/images/generations", openaiHandlers.ImagesGenerations)
+ v1.POST("/images/edits", openaiHandlers.ImagesEdits)
v1.POST("/messages", claudeCodeHandlers.ClaudeMessages)
v1.POST("/messages/count_tokens", claudeCodeHandlers.ClaudeCountTokens)
v1.GET("/responses", openaiResponsesHandlers.ResponsesWebsocket)
@@ -344,6 +394,15 @@ func (s *Server) setupRoutes() {
v1.POST("/responses/compact", openaiResponsesHandlers.Compact)
}
+ // Codex CLI direct route aliases (chatgpt_base_url compatible)
+ codexDirect := s.engine.Group("/backend-api/codex")
+ codexDirect.Use(AuthMiddleware(s.accessManager))
+ {
+ codexDirect.GET("/responses", openaiResponsesHandlers.ResponsesWebsocket)
+ codexDirect.POST("/responses", openaiResponsesHandlers.Responses)
+ codexDirect.POST("/responses/compact", openaiResponsesHandlers.Compact)
+ }
+
// Gemini compatible API routes
v1beta := s.engine.Group("/v1beta")
v1beta.Use(AuthMiddleware(s.accessManager))
@@ -478,9 +537,6 @@ func (s *Server) registerManagementRoutes() {
mgmt := s.engine.Group("/v0/management")
mgmt.Use(s.managementAvailabilityMiddleware(), s.mgmt.Middleware())
{
- mgmt.GET("/usage", s.mgmt.GetUsageStatistics)
- mgmt.GET("/usage/export", s.mgmt.ExportUsageStatistics)
- mgmt.POST("/usage/import", s.mgmt.ImportUsageStatistics)
mgmt.GET("/config", s.mgmt.GetConfig)
mgmt.GET("/config.yaml", s.mgmt.GetConfigYAML)
mgmt.PUT("/config.yaml", s.mgmt.PutConfigYAML)
@@ -525,6 +581,8 @@ func (s *Server) registerManagementRoutes() {
mgmt.PUT("/api-keys", s.mgmt.PutAPIKeys)
mgmt.PATCH("/api-keys", s.mgmt.PatchAPIKeys)
mgmt.DELETE("/api-keys", s.mgmt.DeleteAPIKeys)
+ mgmt.GET("/api-key-usage", s.mgmt.GetAPIKeyUsage)
+ mgmt.GET("/usage-queue", s.mgmt.GetUsageQueue)
mgmt.GET("/gemini-api-key", s.mgmt.GetGeminiKeys)
mgmt.PUT("/gemini-api-key", s.mgmt.PutGeminiKeys)
@@ -634,6 +692,14 @@ func (s *Server) registerManagementRoutes() {
func (s *Server) managementAvailabilityMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
+ if s == nil || s.cfg == nil {
+ c.AbortWithStatus(http.StatusNotFound)
+ return
+ }
+ if s.cfg.Home.Enabled {
+ c.AbortWithStatus(http.StatusNotFound)
+ return
+ }
if !s.managementRoutesEnabled.Load() {
c.AbortWithStatus(http.StatusNotFound)
return
@@ -644,7 +710,7 @@ func (s *Server) managementAvailabilityMiddleware() gin.HandlerFunc {
func (s *Server) serveManagementControlPanel(c *gin.Context) {
cfg := s.cfg
- if cfg == nil || cfg.RemoteManagement.DisableControlPanel {
+ if cfg == nil || cfg.Home.Enabled || cfg.RemoteManagement.DisableControlPanel {
c.AbortWithStatus(http.StatusNotFound)
return
}
@@ -756,6 +822,11 @@ func (s *Server) watchKeepAlive() {
// otherwise it routes to OpenAI handler.
func (s *Server) unifiedModelsHandler(openaiHandler *openai.OpenAIAPIHandler, claudeHandler *claude.ClaudeCodeAPIHandler) gin.HandlerFunc {
return func(c *gin.Context) {
+ if s != nil && s.cfg != nil && s.cfg.Home.Enabled {
+ s.handleHomeModels(c)
+ return
+ }
+
userAgent := c.GetHeader("User-Agent")
// Route to Claude handler if User-Agent starts with "claude-cli"
@@ -769,6 +840,170 @@ func (s *Server) unifiedModelsHandler(openaiHandler *openai.OpenAIAPIHandler, cl
}
}
+type homeModelEntry struct {
+ id string
+ created int64
+ ownedBy string
+ displayName string
+}
+
+func (s *Server) handleHomeModels(c *gin.Context) {
+ if s == nil || c == nil || c.Request == nil {
+ return
+ }
+ client := home.Current()
+ if client == nil {
+ c.JSON(http.StatusServiceUnavailable, handlers.ErrorResponse{
+ Error: handlers.ErrorDetail{
+ Message: "home control center unavailable",
+ Type: "server_error",
+ },
+ })
+ return
+ }
+
+ raw, errGet := client.GetModels(c.Request.Context())
+ if errGet != nil {
+ c.JSON(http.StatusBadGateway, handlers.ErrorResponse{
+ Error: handlers.ErrorDetail{
+ Message: errGet.Error(),
+ Type: "server_error",
+ },
+ })
+ return
+ }
+
+ entries, errDecode := decodeHomeModels(raw)
+ if errDecode != nil {
+ c.JSON(http.StatusBadGateway, handlers.ErrorResponse{
+ Error: handlers.ErrorDetail{
+ Message: errDecode.Error(),
+ Type: "server_error",
+ },
+ })
+ return
+ }
+
+ userAgent := c.GetHeader("User-Agent")
+ isClaude := strings.HasPrefix(userAgent, "claude-cli")
+
+ if isClaude {
+ out := make([]map[string]any, 0, len(entries))
+ for _, entry := range entries {
+ model := map[string]any{
+ "id": entry.id,
+ "object": "model",
+ "owned_by": entry.ownedBy,
+ }
+ if entry.created > 0 {
+ model["created_at"] = entry.created
+ }
+ if entry.displayName != "" {
+ model["display_name"] = entry.displayName
+ }
+ out = append(out, model)
+ }
+ firstID := ""
+ lastID := ""
+ if len(out) > 0 {
+ if id, ok := out[0]["id"].(string); ok {
+ firstID = id
+ }
+ if id, ok := out[len(out)-1]["id"].(string); ok {
+ lastID = id
+ }
+ }
+ c.JSON(http.StatusOK, gin.H{
+ "data": out,
+ "has_more": false,
+ "first_id": firstID,
+ "last_id": lastID,
+ })
+ return
+ }
+
+ filtered := make([]map[string]any, 0, len(entries))
+ for _, entry := range entries {
+ model := map[string]any{
+ "id": entry.id,
+ "object": "model",
+ }
+ if entry.created > 0 {
+ model["created"] = entry.created
+ }
+ if entry.ownedBy != "" {
+ model["owned_by"] = entry.ownedBy
+ }
+ filtered = append(filtered, model)
+ }
+ c.JSON(http.StatusOK, gin.H{
+ "object": "list",
+ "data": filtered,
+ })
+}
+
+func decodeHomeModels(raw []byte) ([]homeModelEntry, error) {
+ if len(raw) == 0 {
+ return nil, fmt.Errorf("home models payload is empty")
+ }
+
+ var bySection map[string][]map[string]any
+ if err := json.Unmarshal(raw, &bySection); err != nil {
+ return nil, fmt.Errorf("parse home models payload: %w", err)
+ }
+ if len(bySection) == 0 {
+ return nil, fmt.Errorf("home models payload has no sections")
+ }
+
+ seen := make(map[string]struct{})
+ out := make([]homeModelEntry, 0, 256)
+ for _, models := range bySection {
+ for _, model := range models {
+ id, _ := model["id"].(string)
+ id = strings.TrimSpace(id)
+ if id == "" {
+ continue
+ }
+ if _, ok := seen[id]; ok {
+ continue
+ }
+ seen[id] = struct{}{}
+
+ created := int64(0)
+ switch v := model["created"].(type) {
+ case float64:
+ created = int64(v)
+ case int64:
+ created = v
+ case int:
+ created = int64(v)
+ case json.Number:
+ if n, err := v.Int64(); err == nil {
+ created = n
+ }
+ }
+
+ ownedBy, _ := model["owned_by"].(string)
+ ownedBy = strings.TrimSpace(ownedBy)
+ displayName, _ := model["display_name"].(string)
+ displayName = strings.TrimSpace(displayName)
+
+ out = append(out, homeModelEntry{
+ id: id,
+ created: created,
+ ownedBy: ownedBy,
+ displayName: displayName,
+ })
+ }
+ }
+
+ sort.Slice(out, func(i, j int) bool { return out[i].id < out[j].id })
+ if len(out) == 0 {
+ return nil, fmt.Errorf("home models payload contains no models")
+ }
+ return out, nil
+}
+
// Start begins listening for and serving HTTP or HTTPS requests.
// It's a blocking call and will only return on an unrecoverable error.
//
@@ -779,26 +1014,98 @@ func (s *Server) Start() error {
return fmt.Errorf("failed to start HTTP server: server not initialized")
}
+ addr := s.server.Addr
+ listener, errListen := net.Listen("tcp", addr)
+ if errListen != nil {
+ return fmt.Errorf("failed to start HTTP server: %v", errListen)
+ }
+
useTLS := s.cfg != nil && s.cfg.TLS.Enable
if useTLS {
- cert := strings.TrimSpace(s.cfg.TLS.Cert)
- key := strings.TrimSpace(s.cfg.TLS.Key)
- if cert == "" || key == "" {
+ certPath := strings.TrimSpace(s.cfg.TLS.Cert)
+ keyPath := strings.TrimSpace(s.cfg.TLS.Key)
+ if certPath == "" || keyPath == "" {
+ if errClose := listener.Close(); errClose != nil {
+ log.Errorf("failed to close listener after TLS validation failure: %v", errClose)
+ }
return fmt.Errorf("failed to start HTTPS server: tls.cert or tls.key is empty")
}
- log.Debugf("Starting API server on %s with TLS", s.server.Addr)
- if errServeTLS := s.server.ListenAndServeTLS(cert, key); errServeTLS != nil && !errors.Is(errServeTLS, http.ErrServerClosed) {
- return fmt.Errorf("failed to start HTTPS server: %v", errServeTLS)
+ certPair, errLoad := tls.LoadX509KeyPair(certPath, keyPath)
+ if errLoad != nil {
+ if errClose := listener.Close(); errClose != nil {
+ log.Errorf("failed to close listener after TLS key pair load failure: %v", errClose)
+ }
+ return fmt.Errorf("failed to start HTTPS server: %v", errLoad)
}
- return nil
- }
- log.Debugf("Starting API server on %s", s.server.Addr)
- if errServe := s.server.ListenAndServe(); errServe != nil && !errors.Is(errServe, http.ErrServerClosed) {
- return fmt.Errorf("failed to start HTTP server: %v", errServe)
+ tlsConfig := &tls.Config{
+ Certificates: []tls.Certificate{certPair},
+ NextProtos: []string{"h2", "http/1.1"},
+ }
+ s.server.TLSConfig = tlsConfig
+ if errHTTP2 := http2.ConfigureServer(s.server, &http2.Server{}); errHTTP2 != nil {
+ log.Warnf("failed to configure HTTP/2: %v", errHTTP2)
+ }
+ listener = tls.NewListener(listener, tlsConfig)
+ log.Debugf("Starting API server on %s with TLS", addr)
+ } else {
+ log.Debugf("Starting API server on %s", addr)
}
- return nil
+ httpListener := newMuxListener(listener.Addr(), 1024)
+ s.muxBaseListener = listener
+ s.muxHTTPListener = httpListener
+
+ httpErrCh := make(chan error, 1)
+ acceptErrCh := make(chan error, 1)
+
+ go func() {
+ httpErrCh <- s.server.Serve(httpListener)
+ }()
+ go func() {
+ acceptErrCh <- s.acceptMuxConnections(listener, httpListener)
+ }()
+
+ select {
+ case errServe := <-httpErrCh:
+ if s.muxBaseListener != nil {
+ if errClose := s.muxBaseListener.Close(); errClose != nil && !errors.Is(errClose, net.ErrClosed) {
+ log.Debugf("failed to close shared listener after HTTP serve exit: %v", errClose)
+ }
+ }
+ if s.muxHTTPListener != nil {
+ _ = s.muxHTTPListener.Close()
+ }
+ errAccept := <-acceptErrCh
+ errServe = normalizeHTTPServeError(errServe)
+ errAccept = normalizeListenerError(errAccept)
+ if errServe != nil {
+ return fmt.Errorf("failed to start HTTP server: %v", errServe)
+ }
+ if errAccept != nil {
+ return fmt.Errorf("failed to start HTTP server: %v", errAccept)
+ }
+ return nil
+ case errAccept := <-acceptErrCh:
+ if s.muxHTTPListener != nil {
+ _ = s.muxHTTPListener.Close()
+ }
+ if s.muxBaseListener != nil {
+ if errClose := s.muxBaseListener.Close(); errClose != nil && !errors.Is(errClose, net.ErrClosed) {
+ log.Debugf("failed to close shared listener after accept loop exit: %v", errClose)
+ }
+ }
+ errServe := <-httpErrCh
+ errServe = normalizeHTTPServeError(errServe)
+ errAccept = normalizeListenerError(errAccept)
+ if errAccept != nil {
+ return fmt.Errorf("failed to start HTTP server: %v", errAccept)
+ }
+ if errServe != nil {
+ return fmt.Errorf("failed to start HTTP server: %v", errServe)
+ }
+ return nil
+ }
}
// Stop gracefully shuts down the API server without interrupting any
@@ -819,6 +1126,15 @@ func (s *Server) Stop(ctx context.Context) error {
}
}
+ if s.muxHTTPListener != nil {
+ _ = s.muxHTTPListener.Close()
+ }
+ if s.muxBaseListener != nil {
+ if errClose := s.muxBaseListener.Close(); errClose != nil && !errors.Is(errClose, net.ErrClosed) {
+ log.Debugf("failed to close shared listener: %v", errClose)
+ }
+ }
+
// Shutdown the HTTP server.
if err := s.server.Shutdown(ctx); err != nil {
return fmt.Errorf("failed to shutdown HTTP server: %v", err)
@@ -883,6 +1199,12 @@ func (s *Server) UpdateClients(cfg *config.Config) {
}
}
+ if oldCfg == nil || oldCfg.Home.Enabled != cfg.Home.Enabled {
+ if setter, ok := s.requestLogger.(interface{ SetHomeEnabled(bool) }); ok {
+ setter.SetHomeEnabled(cfg.Home.Enabled)
+ }
+ }
+
if oldCfg == nil || oldCfg.LoggingToFile != cfg.LoggingToFile || oldCfg.LogsMaxTotalSizeMB != cfg.LogsMaxTotalSizeMB {
if err := logging.ConfigureLogOutput(cfg); err != nil {
log.Errorf("failed to reconfigure log output: %v", err)
@@ -890,7 +1212,11 @@ func (s *Server) UpdateClients(cfg *config.Config) {
}
if oldCfg == nil || oldCfg.UsageStatisticsEnabled != cfg.UsageStatisticsEnabled {
- usage.SetStatisticsEnabled(cfg.UsageStatisticsEnabled)
+ redisqueue.SetUsageStatisticsEnabled(cfg.UsageStatisticsEnabled)
+ }
+
+ if oldCfg == nil || oldCfg.RedisUsageQueueRetentionSeconds != cfg.RedisUsageQueueRetentionSeconds {
+ redisqueue.SetRetentionSeconds(cfg.RedisUsageQueueRetentionSeconds)
}
if s.requestLogger != nil && (oldCfg == nil || oldCfg.ErrorLogsMaxFiles != cfg.ErrorLogsMaxFiles) {
@@ -903,6 +1229,10 @@ func (s *Server) UpdateClients(cfg *config.Config) {
auth.SetQuotaCooldownDisabled(cfg.DisableCooling)
}
+ if oldCfg != nil && oldCfg.DisableImageGeneration != cfg.DisableImageGeneration {
+ log.Infof("disable-image-generation updated: %v -> %v", oldCfg.DisableImageGeneration, cfg.DisableImageGeneration)
+ }
+
applySignatureCacheConfig(oldCfg, cfg)
if s.handlers != nil && s.handlers.AuthManager != nil {
@@ -945,6 +1275,7 @@ func (s *Server) UpdateClients(cfg *config.Config) {
s.managementRoutesEnabled.Store(!newSecretEmpty)
}
}
+ redisqueue.SetEnabled(s.managementRoutesEnabled.Load() || (cfg != nil && cfg.Home.Enabled))
s.applyAccessConfig(oldCfg, cfg)
s.cfg = cfg
@@ -977,11 +1308,14 @@ func (s *Server) UpdateClients(cfg *config.Config) {
}
// Count client sources from configuration and auth store.
- tokenStore := sdkAuth.GetTokenStore()
- if dirSetter, ok := tokenStore.(interface{ SetBaseDir(string) }); ok {
- dirSetter.SetBaseDir(cfg.AuthDir)
+ authEntries := 0
+ if cfg != nil && !cfg.Home.Enabled {
+ tokenStore := sdkAuth.GetTokenStore()
+ if dirSetter, ok := tokenStore.(interface{ SetBaseDir(string) }); ok {
+ dirSetter.SetBaseDir(cfg.AuthDir)
+ }
+ authEntries = util.CountAuthFiles(context.Background(), tokenStore)
}
- authEntries := util.CountAuthFiles(context.Background(), tokenStore)
geminiAPIKeyCount := len(cfg.GeminiKey)
claudeAPIKeyCount := len(cfg.ClaudeKey)
codexAPIKeyCount := len(cfg.CodexKey)
@@ -989,6 +1323,9 @@ func (s *Server) UpdateClients(cfg *config.Config) {
openAICompatCount := 0
for i := range cfg.OpenAICompatibility {
entry := cfg.OpenAICompatibility[i]
+ if entry.Disabled {
+ continue
+ }
openAICompatCount += len(entry.APIKeyEntries)
}
@@ -1026,7 +1363,7 @@ func AuthMiddleware(manager *sdkaccess.Manager) gin.HandlerFunc {
result, err := manager.Authenticate(c.Request.Context(), c.Request)
if err == nil {
if result != nil {
- c.Set("apiKey", result.Principal)
+ c.Set("userApiKey", result.Principal)
c.Set("accessProvider", result.Provider)
if len(result.Metadata) > 0 {
c.Set("accessMetadata", result.Metadata)
diff --git a/internal/api/server_test.go b/internal/api/server_test.go
index dbc2cd5a83..e107702a88 100644
--- a/internal/api/server_test.go
+++ b/internal/api/server_test.go
@@ -11,11 +11,12 @@ import (
"time"
gin "github.com/gin-gonic/gin"
- proxyconfig "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
- internallogging "github.com/router-for-me/CLIProxyAPI/v6/internal/logging"
- sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access"
- "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
- sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
+ proxyconfig "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ internallogging "github.com/router-for-me/CLIProxyAPI/v7/internal/logging"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/redisqueue"
+ sdkaccess "github.com/router-for-me/CLIProxyAPI/v7/sdk/access"
+ "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
+ sdkconfig "github.com/router-for-me/CLIProxyAPI/v7/sdk/config"
)
func newTestServer(t *testing.T) *Server {
@@ -50,25 +51,128 @@ func newTestServer(t *testing.T) *Server {
func TestHealthz(t *testing.T) {
server := newTestServer(t)
- req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
- rr := httptest.NewRecorder()
- server.engine.ServeHTTP(rr, req)
+ t.Run("GET", func(t *testing.T) {
+ req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
+ rr := httptest.NewRecorder()
+ server.engine.ServeHTTP(rr, req)
- if rr.Code != http.StatusOK {
- t.Fatalf("unexpected status code: got %d want %d; body=%s", rr.Code, http.StatusOK, rr.Body.String())
+ if rr.Code != http.StatusOK {
+ t.Fatalf("unexpected status code: got %d want %d; body=%s", rr.Code, http.StatusOK, rr.Body.String())
+ }
+
+ var resp struct {
+ Status string `json:"status"`
+ }
+ if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("failed to parse response JSON: %v; body=%s", err, rr.Body.String())
+ }
+ if resp.Status != "ok" {
+ t.Fatalf("unexpected response status: got %q want %q", resp.Status, "ok")
+ }
+ })
+
+ t.Run("HEAD", func(t *testing.T) {
+ req := httptest.NewRequest(http.MethodHead, "/healthz", nil)
+ rr := httptest.NewRecorder()
+ server.engine.ServeHTTP(rr, req)
+
+ if rr.Code != http.StatusOK {
+ t.Fatalf("unexpected status code: got %d want %d; body=%s", rr.Code, http.StatusOK, rr.Body.String())
+ }
+ if rr.Body.Len() != 0 {
+ t.Fatalf("expected empty body for HEAD request, got %q", rr.Body.String())
+ }
+ })
+}
+
+func TestManagementUsageRequiresManagementAuthAndPopsArray(t *testing.T) {
+ t.Setenv("MANAGEMENT_PASSWORD", "test-management-key")
+
+ prevQueueEnabled := redisqueue.Enabled()
+ redisqueue.SetEnabled(false)
+ t.Cleanup(func() {
+ redisqueue.SetEnabled(false)
+ redisqueue.SetEnabled(prevQueueEnabled)
+ })
+
+ server := newTestServer(t)
+
+ redisqueue.Enqueue([]byte(`{"id":1}`))
+ redisqueue.Enqueue([]byte(`{"id":2}`))
+
+ missingKeyReq := httptest.NewRequest(http.MethodGet, "/v0/management/usage-queue?count=2", nil)
+ missingKeyRR := httptest.NewRecorder()
+ server.engine.ServeHTTP(missingKeyRR, missingKeyReq)
+ if missingKeyRR.Code != http.StatusUnauthorized {
+ t.Fatalf("missing key status = %d, want %d body=%s", missingKeyRR.Code, http.StatusUnauthorized, missingKeyRR.Body.String())
}
- var resp struct {
- Status string `json:"status"`
+ legacyReq := httptest.NewRequest(http.MethodGet, "/v0/management/usage?count=2", nil)
+ legacyReq.Header.Set("Authorization", "Bearer test-management-key")
+ legacyRR := httptest.NewRecorder()
+ server.engine.ServeHTTP(legacyRR, legacyReq)
+ if legacyRR.Code != http.StatusNotFound {
+ t.Fatalf("legacy usage status = %d, want %d body=%s", legacyRR.Code, http.StatusNotFound, legacyRR.Body.String())
}
- if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
- t.Fatalf("failed to parse response JSON: %v; body=%s", err, rr.Body.String())
+
+ authReq := httptest.NewRequest(http.MethodGet, "/v0/management/usage-queue?count=2", nil)
+ authReq.Header.Set("Authorization", "Bearer test-management-key")
+ authRR := httptest.NewRecorder()
+ server.engine.ServeHTTP(authRR, authReq)
+ if authRR.Code != http.StatusOK {
+ t.Fatalf("authenticated status = %d, want %d body=%s", authRR.Code, http.StatusOK, authRR.Body.String())
}
- if resp.Status != "ok" {
- t.Fatalf("unexpected response status: got %q want %q", resp.Status, "ok")
+
+ var payload []json.RawMessage
+ if errUnmarshal := json.Unmarshal(authRR.Body.Bytes(), &payload); errUnmarshal != nil {
+ t.Fatalf("unmarshal response: %v body=%s", errUnmarshal, authRR.Body.String())
+ }
+ if len(payload) != 2 {
+ t.Fatalf("response records = %d, want 2", len(payload))
+ }
+ for i, raw := range payload {
+ var record struct {
+ ID int `json:"id"`
+ }
+ if errUnmarshal := json.Unmarshal(raw, &record); errUnmarshal != nil {
+ t.Fatalf("unmarshal record %d: %v", i, errUnmarshal)
+ }
+ if record.ID != i+1 {
+ t.Fatalf("record %d id = %d, want %d", i, record.ID, i+1)
+ }
+ }
+
+ if remaining := redisqueue.PopOldest(1); len(remaining) != 0 {
+ t.Fatalf("remaining queue = %q, want empty", remaining)
}
}
+func TestHomeEnabledHidesManagementEndpointsAndControlPanel(t *testing.T) {
+ t.Setenv("MANAGEMENT_PASSWORD", "test-management-key")
+
+ server := newTestServer(t)
+ server.cfg.Home.Enabled = true
+
+ t.Run("management endpoints return 404", func(t *testing.T) {
+ req := httptest.NewRequest(http.MethodGet, "/v0/management/config", nil)
+ req.Header.Set("Authorization", "Bearer test-management-key")
+ rr := httptest.NewRecorder()
+ server.engine.ServeHTTP(rr, req)
+ if rr.Code != http.StatusNotFound {
+ t.Fatalf("status = %d, want %d body=%s", rr.Code, http.StatusNotFound, rr.Body.String())
+ }
+ })
+
+ t.Run("management control panel returns 404", func(t *testing.T) {
+ req := httptest.NewRequest(http.MethodGet, "/management.html", nil)
+ rr := httptest.NewRecorder()
+ server.engine.ServeHTTP(rr, req)
+ if rr.Code != http.StatusNotFound {
+ t.Fatalf("status = %d, want %d body=%s", rr.Code, http.StatusNotFound, rr.Body.String())
+ }
+ })
+}
+
func TestAmpProviderModelRoutes(t *testing.T) {
testCases := []struct {
name string
diff --git a/internal/auth/antigravity/auth.go b/internal/auth/antigravity/auth.go
index 449f413fc1..7bee09bb66 100644
--- a/internal/auth/antigravity/auth.go
+++ b/internal/auth/antigravity/auth.go
@@ -11,8 +11,9 @@ import (
"strings"
"time"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/misc"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/util"
log "github.com/sirupsen/logrus"
)
@@ -36,17 +37,21 @@ type AntigravityAuth struct {
// NewAntigravityAuth creates a new Antigravity auth service.
func NewAntigravityAuth(cfg *config.Config, httpClient *http.Client) *AntigravityAuth {
- if httpClient != nil {
- return &AntigravityAuth{httpClient: httpClient}
- }
if cfg == nil {
cfg = &config.Config{}
}
+ if httpClient != nil {
+ return &AntigravityAuth{httpClient: httpClient}
+ }
return &AntigravityAuth{
httpClient: util.SetProxy(&cfg.SDKConfig, &http.Client{}),
}
}
+func (o *AntigravityAuth) loadCodeAssistUserAgent() string {
+ return misc.AntigravityLoadCodeAssistUserAgent("")
+}
+
// BuildAuthURL generates the OAuth authorization URL.
func (o *AntigravityAuth) BuildAuthURL(state, redirectURI string) string {
if strings.TrimSpace(redirectURI) == "" {
@@ -118,6 +123,7 @@ func (o *AntigravityAuth) FetchUserInfo(ctx context.Context, accessToken string)
return "", fmt.Errorf("antigravity userinfo: create request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+accessToken)
+ req.Header.Set("User-Agent", o.loadCodeAssistUserAgent())
resp, errDo := o.httpClient.Do(req)
if errDo != nil {
@@ -153,11 +159,12 @@ func (o *AntigravityAuth) FetchUserInfo(ctx context.Context, accessToken string)
// FetchProjectID retrieves the project ID for the authenticated user via loadCodeAssist
func (o *AntigravityAuth) FetchProjectID(ctx context.Context, accessToken string) (string, error) {
+ userAgent := o.loadCodeAssistUserAgent()
loadReqBody := map[string]any{
"metadata": map[string]string{
- "ideType": "ANTIGRAVITY",
- "platform": "PLATFORM_UNSPECIFIED",
- "pluginType": "GEMINI",
+ "ide_type": "ANTIGRAVITY",
+ "ide_version": misc.AntigravityVersionFromUserAgent(userAgent),
+ "ide_name": "antigravity",
},
}
@@ -173,9 +180,8 @@ func (o *AntigravityAuth) FetchProjectID(ctx context.Context, accessToken string
}
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("Content-Type", "application/json")
- req.Header.Set("User-Agent", APIUserAgent)
- req.Header.Set("X-Goog-Api-Client", APIClient)
- req.Header.Set("Client-Metadata", ClientMetadata)
+ req.Header.Set("User-Agent", userAgent)
+ req.Header.Set("X-Goog-Api-Client", misc.AntigravityGoogAPIClientUA)
resp, errDo := o.httpClient.Do(req)
if errDo != nil {
@@ -244,12 +250,13 @@ func (o *AntigravityAuth) FetchProjectID(ctx context.Context, accessToken string
// OnboardUser attempts to fetch the project ID via onboardUser by polling for completion
func (o *AntigravityAuth) OnboardUser(ctx context.Context, accessToken, tierID string) (string, error) {
log.Infof("Antigravity: onboarding user with tier: %s", tierID)
+ userAgent := o.loadCodeAssistUserAgent()
requestBody := map[string]any{
"tierId": tierID,
"metadata": map[string]string{
- "ideType": "ANTIGRAVITY",
- "platform": "PLATFORM_UNSPECIFIED",
- "pluginType": "GEMINI",
+ "ide_type": "ANTIGRAVITY",
+ "ide_version": misc.AntigravityVersionFromUserAgent(userAgent),
+ "ide_name": "antigravity",
},
}
@@ -277,9 +284,8 @@ func (o *AntigravityAuth) OnboardUser(ctx context.Context, accessToken, tierID s
}
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("Content-Type", "application/json")
- req.Header.Set("User-Agent", APIUserAgent)
- req.Header.Set("X-Goog-Api-Client", APIClient)
- req.Header.Set("Client-Metadata", ClientMetadata)
+ req.Header.Set("User-Agent", userAgent)
+ req.Header.Set("X-Goog-Api-Client", misc.AntigravityGoogAPIClientUA)
resp, errDo := o.httpClient.Do(req)
if errDo != nil {
diff --git a/internal/auth/antigravity/constants.go b/internal/auth/antigravity/constants.go
index 680c8e3c70..61e736971a 100644
--- a/internal/auth/antigravity/constants.go
+++ b/internal/auth/antigravity/constants.go
@@ -21,14 +21,11 @@ var Scopes = []string{
const (
TokenEndpoint = "https://oauth2.googleapis.com/token"
AuthEndpoint = "https://accounts.google.com/o/oauth2/v2/auth"
- UserInfoEndpoint = "https://www.googleapis.com/oauth2/v1/userinfo?alt=json"
+ UserInfoEndpoint = "https://www.googleapis.com/oauth2/v2/userinfo?alt=json"
)
// Antigravity API configuration
const (
- APIEndpoint = "https://cloudcode-pa.googleapis.com"
- APIVersion = "v1internal"
- APIUserAgent = "google-api-nodejs-client/9.15.1"
- APIClient = "google-cloud-sdk vscode_cloudshelleditor/0.1"
- ClientMetadata = `{"ideType":"IDE_UNSPECIFIED","platform":"PLATFORM_UNSPECIFIED","pluginType":"GEMINI"}`
+ APIEndpoint = "https://cloudcode-pa.googleapis.com"
+ APIVersion = "v1internal"
)
diff --git a/internal/auth/claude/anthropic_auth.go b/internal/auth/claude/anthropic_auth.go
index 6c770abf43..d7ca154296 100644
--- a/internal/auth/claude/anthropic_auth.go
+++ b/internal/auth/claude/anthropic_auth.go
@@ -6,15 +6,18 @@ package claude
import (
"context"
"encoding/json"
+ "errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
+ "sync"
"time"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
log "github.com/sirupsen/logrus"
+ "golang.org/x/sync/singleflight"
)
// OAuth configuration constants for Claude/Anthropic
@@ -23,8 +26,94 @@ const (
TokenURL = "https://api.anthropic.com/v1/oauth/token"
ClientID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
RedirectURI = "http://localhost:54545/callback"
+
+ claudeRefreshMinBackoff = 5 * time.Second
+ claudeRefreshMaxBackoff = 5 * time.Minute
+)
+
+var (
+ claudeRefreshGroup singleflight.Group
+ claudeRefreshMu sync.Mutex
+ claudeRefreshBlock = make(map[string]time.Time)
)
+type refreshHTTPError struct {
+ status int
+ message string
+ retryable bool
+}
+
+func (e *refreshHTTPError) Error() string {
+ return fmt.Sprintf("token refresh failed with status %d: %s", e.status, e.message)
+}
+
+func (e *refreshHTTPError) Retryable() bool {
+ return e != nil && e.retryable
+}
+
+func resetClaudeRefreshState() {
+ claudeRefreshMu.Lock()
+ defer claudeRefreshMu.Unlock()
+ claudeRefreshBlock = make(map[string]time.Time)
+ claudeRefreshGroup = singleflight.Group{}
+}
+
+func claudeRefreshBlockedUntil(refreshToken string) time.Time {
+ claudeRefreshMu.Lock()
+ defer claudeRefreshMu.Unlock()
+ return claudeRefreshBlock[refreshToken]
+}
+
+func setClaudeRefreshBlockedUntil(refreshToken string, until time.Time) {
+ claudeRefreshMu.Lock()
+ defer claudeRefreshMu.Unlock()
+ claudeRefreshBlock[refreshToken] = until
+}
+
+func clearClaudeRefreshBlockedUntil(refreshToken string) {
+ claudeRefreshMu.Lock()
+ defer claudeRefreshMu.Unlock()
+ delete(claudeRefreshBlock, refreshToken)
+}
+
+func clampClaudeRefreshBackoff(d time.Duration) time.Duration {
+ if d < claudeRefreshMinBackoff {
+ return claudeRefreshMinBackoff
+ }
+ if d > claudeRefreshMaxBackoff {
+ return claudeRefreshMaxBackoff
+ }
+ return d
+}
+
+func parseClaudeRetryAfter(resp *http.Response) time.Duration {
+ if resp == nil {
+ return claudeRefreshMinBackoff
+ }
+ if raw := strings.TrimSpace(resp.Header.Get("Retry-After")); raw != "" {
+ if seconds, err := time.ParseDuration(raw + "s"); err == nil {
+ return clampClaudeRefreshBackoff(seconds)
+ }
+ if when, err := http.ParseTime(raw); err == nil {
+ return clampClaudeRefreshBackoff(time.Until(when))
+ }
+ }
+ if raw := strings.TrimSpace(resp.Header.Get("Retry-After-Ms")); raw != "" {
+ if ms, err := time.ParseDuration(raw + "ms"); err == nil {
+ return clampClaudeRefreshBackoff(ms)
+ }
+ }
+ return claudeRefreshMinBackoff
+}
+
+func isClaudeRefreshRetryable(err error) bool {
+ var httpErr *refreshHTTPError
+ if errors.As(err, &httpErr) {
+ return httpErr.Retryable()
+ }
+ return true
+}
+
// tokenResponse represents the response structure from Anthropic's OAuth token endpoint.
// It contains access token, refresh token, and associated user/organization information.
type tokenResponse struct {
@@ -242,6 +331,35 @@ func (o *ClaudeAuth) RefreshTokens(ctx context.Context, refreshToken string) (*C
if refreshToken == "" {
return nil, fmt.Errorf("refresh token is required")
}
+ if blockedUntil := claudeRefreshBlockedUntil(refreshToken); blockedUntil.After(time.Now()) {
+ return nil, &refreshHTTPError{
+ status: http.StatusTooManyRequests,
+ message: fmt.Sprintf("refresh temporarily blocked until %s", blockedUntil.Format(time.RFC3339)),
+ retryable: false,
+ }
+ }
+
+ result, err, _ := claudeRefreshGroup.Do(refreshToken, func() (interface{}, error) {
+ return o.refreshTokensSingleFlight(context.WithoutCancel(ctx), refreshToken)
+ })
+ if err != nil {
+ return nil, err
+ }
+ tokenData, ok := result.(*ClaudeTokenData)
+ if !ok || tokenData == nil {
+ return nil, fmt.Errorf("token refresh failed: invalid single-flight result")
+ }
+ return tokenData, nil
+}
+
+func (o *ClaudeAuth) refreshTokensSingleFlight(ctx context.Context, refreshToken string) (*ClaudeTokenData, error) {
+ if blockedUntil := claudeRefreshBlockedUntil(refreshToken); blockedUntil.After(time.Now()) {
+ return nil, &refreshHTTPError{
+ status: http.StatusTooManyRequests,
+ message: fmt.Sprintf("refresh temporarily blocked until %s", blockedUntil.Format(time.RFC3339)),
+ retryable: false,
+ }
+ }
reqBody := map[string]interface{}{
"client_id": ClientID,
@@ -276,7 +394,17 @@ func (o *ClaudeAuth) RefreshTokens(ctx context.Context, refreshToken string) (*C
}
if resp.StatusCode != http.StatusOK {
- return nil, fmt.Errorf("token refresh failed with status %d: %s", resp.StatusCode, string(body))
+ message := string(body)
+ if resp.StatusCode == http.StatusTooManyRequests {
+ retryAfter := parseClaudeRetryAfter(resp)
+ setClaudeRefreshBlockedUntil(refreshToken, time.Now().Add(retryAfter))
+ return nil, &refreshHTTPError{status: resp.StatusCode, message: message, retryable: false}
+ }
+ return nil, &refreshHTTPError{
+ status: resp.StatusCode,
+ message: message,
+ retryable: resp.StatusCode >= http.StatusInternalServerError,
+ }
}
// log.Debugf("Token response: %s", string(body))
@@ -287,6 +415,8 @@ func (o *ClaudeAuth) RefreshTokens(ctx context.Context, refreshToken string) (*C
}
// Create token data
+ clearClaudeRefreshBlockedUntil(refreshToken)
+
return &ClaudeTokenData{
AccessToken: tokenResp.AccessToken,
RefreshToken: tokenResp.RefreshToken,
@@ -348,6 +478,9 @@ func (o *ClaudeAuth) RefreshTokensWithRetry(ctx context.Context, refreshToken st
lastErr = err
log.Warnf("Token refresh attempt %d failed: %v", attempt+1, err)
+ if !isClaudeRefreshRetryable(err) {
+ break
+ }
}
return nil, fmt.Errorf("token refresh failed after %d attempts: %w", maxRetries, lastErr)
diff --git a/internal/auth/claude/anthropic_auth_proxy_test.go b/internal/auth/claude/anthropic_auth_proxy_test.go
index 50c4875791..7cab9cd2f1 100644
--- a/internal/auth/claude/anthropic_auth_proxy_test.go
+++ b/internal/auth/claude/anthropic_auth_proxy_test.go
@@ -3,7 +3,7 @@ package claude
import (
"testing"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
"golang.org/x/net/proxy"
)
diff --git a/internal/auth/claude/anthropic_auth_test.go b/internal/auth/claude/anthropic_auth_test.go
new file mode 100644
index 0000000000..0b14d0834c
--- /dev/null
+++ b/internal/auth/claude/anthropic_auth_test.go
@@ -0,0 +1,123 @@
+package claude
+
+import (
+ "context"
+ "io"
+ "net/http"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+)
+
+type roundTripFunc func(*http.Request) (*http.Response, error)
+
+func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
+ return f(req)
+}
+
+func TestRefreshTokensWithRetry_429BlocksImmediateReplay(t *testing.T) {
+ resetClaudeRefreshState()
+ defer resetClaudeRefreshState()
+
+ var calls int32
+ auth := &ClaudeAuth{
+ httpClient: &http.Client{
+ Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
+ atomic.AddInt32(&calls, 1)
+ return &http.Response{
+ StatusCode: http.StatusTooManyRequests,
+ Body: io.NopCloser(strings.NewReader(`{"error":"rate_limited"}`)),
+ Header: http.Header{"Retry-After": []string{"60"}},
+ Request: req,
+ }, nil
+ }),
+ },
+ }
+
+ _, err := auth.RefreshTokensWithRetry(context.Background(), "dummy_refresh_token", 3)
+ if err == nil {
+ t.Fatalf("expected 429 refresh error")
+ }
+ if !strings.Contains(err.Error(), "status 429") {
+ t.Fatalf("expected status 429 in error, got %v", err)
+ }
+ if got := atomic.LoadInt32(&calls); got != 1 {
+ t.Fatalf("expected 1 refresh attempt after 429, got %d", got)
+ }
+
+ _, err = auth.RefreshTokensWithRetry(context.Background(), "dummy_refresh_token", 3)
+ if err == nil {
+ t.Fatalf("expected immediate blocked refresh error")
+ }
+ if got := atomic.LoadInt32(&calls); got != 1 {
+ t.Fatalf("expected blocked retry to avoid a second refresh call, got %d attempts", got)
+ }
+ if blockedUntil := claudeRefreshBlockedUntil("dummy_refresh_token"); !blockedUntil.After(time.Now()) {
+ t.Fatalf("expected blocked-until timestamp to be set, got %v", blockedUntil)
+ }
+}
+
+func TestRefreshTokens_DeduplicatesConcurrentRefresh(t *testing.T) {
+ resetClaudeRefreshState()
+ defer resetClaudeRefreshState()
+
+ var calls int32
+ started := make(chan struct{})
+ release := make(chan struct{})
+ var once sync.Once
+
+ auth := &ClaudeAuth{
+ httpClient: &http.Client{
+ Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
+ atomic.AddInt32(&calls, 1)
+ once.Do(func() { close(started) })
+ <-release
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader(`{
+ "access_token":"new-access",
+ "refresh_token":"new-refresh",
+ "token_type":"Bearer",
+ "expires_in":3600,
+ "account":{"email_address":"shared@example.com"}
+ }`)),
+ Header: make(http.Header),
+ Request: req,
+ }, nil
+ }),
+ },
+ }
+
+ results := make(chan *ClaudeTokenData, 2)
+ errs := make(chan error, 2)
+ runRefresh := func() {
+ td, err := auth.RefreshTokens(context.Background(), "shared-refresh-token")
+ results <- td
+ errs <- err
+ }
+
+ go runRefresh()
+ go runRefresh()
+
+ <-started
+ time.Sleep(20 * time.Millisecond)
+ if got := atomic.LoadInt32(&calls); got != 1 {
+ t.Fatalf("expected concurrent refresh to share a single upstream call, got %d", got)
+ }
+ close(release)
+
+ for i := 0; i < 2; i++ {
+ if err := <-errs; err != nil {
+ t.Fatalf("expected refresh to succeed, got %v", err)
+ }
+ td := <-results
+ if td == nil || td.AccessToken != "new-access" {
+ t.Fatalf("expected refreshed access token, got %#v", td)
+ }
+ }
+ if got := atomic.LoadInt32(&calls); got != 1 {
+ t.Fatalf("expected exactly 1 upstream refresh call, got %d", got)
+ }
+}
diff --git a/internal/auth/claude/token.go b/internal/auth/claude/token.go
index 6ebb0f2f8c..10aa3b4344 100644
--- a/internal/auth/claude/token.go
+++ b/internal/auth/claude/token.go
@@ -9,7 +9,7 @@ import (
"os"
"path/filepath"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/misc"
)
// ClaudeTokenStorage stores OAuth2 token information for Anthropic Claude API authentication.
diff --git a/internal/auth/claude/utls_transport.go b/internal/auth/claude/utls_transport.go
index 88b69c9bd9..f41087819f 100644
--- a/internal/auth/claude/utls_transport.go
+++ b/internal/auth/claude/utls_transport.go
@@ -8,8 +8,8 @@ import (
"sync"
tls "github.com/refraction-networking/utls"
- "github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
- "github.com/router-for-me/CLIProxyAPI/v6/sdk/proxyutil"
+ "github.com/router-for-me/CLIProxyAPI/v7/sdk/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/sdk/proxyutil"
log "github.com/sirupsen/logrus"
"golang.org/x/net/http2"
"golang.org/x/net/proxy"
diff --git a/internal/auth/codex/openai_auth.go b/internal/auth/codex/openai_auth.go
index 67b54b172d..681747caf5 100644
--- a/internal/auth/codex/openai_auth.go
+++ b/internal/auth/codex/openai_auth.go
@@ -14,8 +14,8 @@ import (
"strings"
"time"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/util"
log "github.com/sirupsen/logrus"
)
diff --git a/internal/auth/codex/openai_auth_test.go b/internal/auth/codex/openai_auth_test.go
index a7fe83072d..e7d939b0a3 100644
--- a/internal/auth/codex/openai_auth_test.go
+++ b/internal/auth/codex/openai_auth_test.go
@@ -8,7 +8,7 @@ import (
"sync/atomic"
"testing"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
)
type roundTripFunc func(*http.Request) (*http.Response, error)
diff --git a/internal/auth/codex/token.go b/internal/auth/codex/token.go
index 7f03207195..b2a7bcf21a 100644
--- a/internal/auth/codex/token.go
+++ b/internal/auth/codex/token.go
@@ -9,7 +9,7 @@ import (
"os"
"path/filepath"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/misc"
)
// CodexTokenStorage stores OAuth2 token information for OpenAI Codex API authentication.
diff --git a/internal/auth/gemini/gemini_auth.go b/internal/auth/gemini/gemini_auth.go
index 2995a1cb5e..5b9ee82d26 100644
--- a/internal/auth/gemini/gemini_auth.go
+++ b/internal/auth/gemini/gemini_auth.go
@@ -13,12 +13,12 @@ import (
"net/http"
"time"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/codex"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/browser"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
- "github.com/router-for-me/CLIProxyAPI/v6/sdk/proxyutil"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/codex"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/browser"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/misc"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/util"
+ "github.com/router-for-me/CLIProxyAPI/v7/sdk/proxyutil"
log "github.com/sirupsen/logrus"
"github.com/tidwall/gjson"
diff --git a/internal/auth/gemini/gemini_token.go b/internal/auth/gemini/gemini_token.go
index 6848b708e2..a6ea8c5151 100644
--- a/internal/auth/gemini/gemini_token.go
+++ b/internal/auth/gemini/gemini_token.go
@@ -10,7 +10,7 @@ import (
"path/filepath"
"strings"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/misc"
log "github.com/sirupsen/logrus"
)
diff --git a/internal/auth/kimi/kimi.go b/internal/auth/kimi/kimi.go
index ccb1a6c2ff..27c5f73b42 100644
--- a/internal/auth/kimi/kimi.go
+++ b/internal/auth/kimi/kimi.go
@@ -15,8 +15,8 @@ import (
"time"
"github.com/google/uuid"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/util"
log "github.com/sirupsen/logrus"
)
diff --git a/internal/auth/kimi/kimi_proxy_test.go b/internal/auth/kimi/kimi_proxy_test.go
index 130f34f52b..a95ba01dba 100644
--- a/internal/auth/kimi/kimi_proxy_test.go
+++ b/internal/auth/kimi/kimi_proxy_test.go
@@ -4,7 +4,7 @@ import (
"net/http"
"testing"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
)
func TestNewDeviceFlowClientWithDeviceIDAndProxyURL_OverrideDirectDisablesProxy(t *testing.T) {
diff --git a/internal/auth/kimi/token.go b/internal/auth/kimi/token.go
index 7320d760ef..347b546cbd 100644
--- a/internal/auth/kimi/token.go
+++ b/internal/auth/kimi/token.go
@@ -10,7 +10,7 @@ import (
"path/filepath"
"time"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/misc"
)
// KimiTokenStorage stores OAuth2 token information for Kimi API authentication.
diff --git a/internal/auth/vertex/vertex_credentials.go b/internal/auth/vertex/vertex_credentials.go
index 9f830994ed..db214bd6e2 100644
--- a/internal/auth/vertex/vertex_credentials.go
+++ b/internal/auth/vertex/vertex_credentials.go
@@ -8,7 +8,7 @@ import (
"os"
"path/filepath"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/misc"
log "github.com/sirupsen/logrus"
)
diff --git a/internal/cmd/anthropic_login.go b/internal/cmd/anthropic_login.go
index f7381461a6..cc1bfc8e7c 100644
--- a/internal/cmd/anthropic_login.go
+++ b/internal/cmd/anthropic_login.go
@@ -6,9 +6,9 @@ import (
"fmt"
"os"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/claude"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
- sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/claude"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ sdkAuth "github.com/router-for-me/CLIProxyAPI/v7/sdk/auth"
log "github.com/sirupsen/logrus"
)
diff --git a/internal/cmd/antigravity_login.go b/internal/cmd/antigravity_login.go
index 2efbaeee01..f2bd5505a2 100644
--- a/internal/cmd/antigravity_login.go
+++ b/internal/cmd/antigravity_login.go
@@ -4,8 +4,8 @@ import (
"context"
"fmt"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
- sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ sdkAuth "github.com/router-for-me/CLIProxyAPI/v7/sdk/auth"
log "github.com/sirupsen/logrus"
)
diff --git a/internal/cmd/auth_manager.go b/internal/cmd/auth_manager.go
index 2654717901..7896a7023a 100644
--- a/internal/cmd/auth_manager.go
+++ b/internal/cmd/auth_manager.go
@@ -1,7 +1,7 @@
package cmd
import (
- sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
+ sdkAuth "github.com/router-for-me/CLIProxyAPI/v7/sdk/auth"
)
// newAuthManager creates a new authentication manager instance with all supported
diff --git a/internal/cmd/kimi_login.go b/internal/cmd/kimi_login.go
index eb5f11fb37..ffc470fda0 100644
--- a/internal/cmd/kimi_login.go
+++ b/internal/cmd/kimi_login.go
@@ -4,8 +4,8 @@ import (
"context"
"fmt"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
- sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ sdkAuth "github.com/router-for-me/CLIProxyAPI/v7/sdk/auth"
log "github.com/sirupsen/logrus"
)
diff --git a/internal/cmd/login.go b/internal/cmd/login.go
index 16af718ebb..a71bb28263 100644
--- a/internal/cmd/login.go
+++ b/internal/cmd/login.go
@@ -17,12 +17,12 @@ import (
"strings"
"time"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/gemini"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
- sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
- cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/gemini"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/misc"
+ sdkAuth "github.com/router-for-me/CLIProxyAPI/v7/sdk/auth"
+ cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
log "github.com/sirupsen/logrus"
"github.com/tidwall/gjson"
)
@@ -333,42 +333,10 @@ func performGeminiCLISetup(ctx context.Context, httpClient *http.Client, storage
finalProjectID := projectID
if responseProjectID != "" {
if explicitProject && !strings.EqualFold(responseProjectID, projectID) {
- // Check if this is a free user (gen-lang-client projects or free/legacy tier)
- isFreeUser := strings.HasPrefix(projectID, "gen-lang-client-") ||
- strings.EqualFold(tierID, "FREE") ||
- strings.EqualFold(tierID, "LEGACY")
-
- if isFreeUser {
- // Interactive prompt for free users
- fmt.Printf("\nGoogle returned a different project ID:\n")
- fmt.Printf(" Requested (frontend): %s\n", projectID)
- fmt.Printf(" Returned (backend): %s\n\n", responseProjectID)
- fmt.Printf(" Backend project IDs have access to preview models (gemini-3-*).\n")
- fmt.Printf(" This is normal for free tier users.\n\n")
- fmt.Printf("Which project ID would you like to use?\n")
- fmt.Printf(" [1] Backend (recommended): %s\n", responseProjectID)
- fmt.Printf(" [2] Frontend: %s\n\n", projectID)
- fmt.Printf("Enter choice [1]: ")
-
- reader := bufio.NewReader(os.Stdin)
- choice, _ := reader.ReadString('\n')
- choice = strings.TrimSpace(choice)
-
- if choice == "2" {
- log.Infof("Using frontend project ID: %s", projectID)
- fmt.Println(". Warning: Frontend project IDs may not have access to preview models.")
- finalProjectID = projectID
- } else {
- log.Infof("Using backend project ID: %s (recommended)", responseProjectID)
- finalProjectID = responseProjectID
- }
- } else {
- // Pro users: keep requested project ID (original behavior)
- log.Warnf("Gemini onboarding returned project %s instead of requested %s; keeping requested project ID.", responseProjectID, projectID)
- }
- } else {
- finalProjectID = responseProjectID
+ log.Infof("Gemini onboarding: requested project %s maps to backend project %s", projectID, responseProjectID)
+ log.Infof("Using backend project ID: %s", responseProjectID)
}
+ finalProjectID = responseProjectID
}
storage.ProjectID = strings.TrimSpace(finalProjectID)
diff --git a/internal/cmd/openai_device_login.go b/internal/cmd/openai_device_login.go
index 1b7351e63a..3fa9307b9c 100644
--- a/internal/cmd/openai_device_login.go
+++ b/internal/cmd/openai_device_login.go
@@ -6,9 +6,9 @@ import (
"fmt"
"os"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/codex"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
- sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/codex"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ sdkAuth "github.com/router-for-me/CLIProxyAPI/v7/sdk/auth"
log "github.com/sirupsen/logrus"
)
diff --git a/internal/cmd/openai_login.go b/internal/cmd/openai_login.go
index 783a948400..ee8a025067 100644
--- a/internal/cmd/openai_login.go
+++ b/internal/cmd/openai_login.go
@@ -6,9 +6,9 @@ import (
"fmt"
"os"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/codex"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
- sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/codex"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ sdkAuth "github.com/router-for-me/CLIProxyAPI/v7/sdk/auth"
log "github.com/sirupsen/logrus"
)
diff --git a/internal/cmd/run.go b/internal/cmd/run.go
index d8c4f01938..38f189b4a9 100644
--- a/internal/cmd/run.go
+++ b/internal/cmd/run.go
@@ -10,9 +10,9 @@ import (
"syscall"
"time"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/api"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
- "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/api"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy"
log "github.com/sirupsen/logrus"
)
diff --git a/internal/cmd/vertex_import.go b/internal/cmd/vertex_import.go
index 4aa0d74b59..ffb6200b1a 100644
--- a/internal/cmd/vertex_import.go
+++ b/internal/cmd/vertex_import.go
@@ -9,11 +9,11 @@ import (
"os"
"strings"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/vertex"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
- sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
- coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/vertex"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/util"
+ sdkAuth "github.com/router-for-me/CLIProxyAPI/v7/sdk/auth"
+ coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
log "github.com/sirupsen/logrus"
)
diff --git a/internal/config/config.go b/internal/config/config.go
index 760d43ec4a..e032b43d41 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -13,7 +13,7 @@ import (
"strings"
"syscall"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
log "github.com/sirupsen/logrus"
"golang.org/x/crypto/bcrypt"
"gopkg.in/yaml.v3"
@@ -22,6 +22,7 @@ import (
const (
DefaultPanelGitHubRepository = "https://github.com/router-for-me/Cli-Proxy-API-Management-Center"
DefaultPprofAddr = "127.0.0.1:8316"
+ DefaultAuthDir = "~/.cli-proxy-api"
)
// Config represents the application's configuration, loaded from a YAML file.
@@ -36,6 +37,9 @@ type Config struct {
// TLS config controls HTTPS server settings.
TLS TLSConfig `yaml:"tls" json:"tls"`
+ // Home config enables the Redis-based control plane integration.
+ Home HomeConfig `yaml:"home" json:"-"`
+
// RemoteManagement nests management-related options under 'remote-management'.
RemoteManagement RemoteManagement `yaml:"remote-management" json:"-"`
@@ -65,6 +69,11 @@ type Config struct {
// UsageStatisticsEnabled toggles in-memory usage aggregation; when false, usage data is discarded.
UsageStatisticsEnabled bool `yaml:"usage-statistics-enabled" json:"usage-statistics-enabled"`
+ // RedisUsageQueueRetentionSeconds controls how long (in seconds) usage queue items
+ // are retained in memory for the Redis RESP interface (LPOP/RPOP).
+ // Default: 60. Max: 3600.
+ RedisUsageQueueRetentionSeconds int `yaml:"redis-usage-queue-retention-seconds" json:"redis-usage-queue-retention-seconds"`
+
// DisableCooling disables quota cooldown scheduling when true.
DisableCooling bool `yaml:"disable-cooling" json:"disable-cooling"`
@@ -206,8 +215,9 @@ type QuotaExceeded struct {
// SwitchPreviewModel indicates whether to automatically switch to a preview model when a quota is exceeded.
SwitchPreviewModel bool `yaml:"switch-preview-model" json:"switch-preview-model"`
- // AntigravityCredits indicates whether to retry Antigravity quota_exhausted 429s once
- // on the same credential with enabledCreditTypes=["GOOGLE_ONE_AI"].
+ // AntigravityCredits enables credits-based last-resort fallback for Claude models.
+ // When all free-tier auths are exhausted (429/503), the conductor retries with
+ // an auth that has available Google One AI credits.
AntigravityCredits bool `yaml:"antigravity-credits" json:"antigravity-credits"`
}
@@ -217,15 +227,11 @@ type RoutingConfig struct {
// Supported values: "round-robin" (default), "fill-first".
Strategy string `yaml:"strategy,omitempty" json:"strategy,omitempty"`
- // ClaudeCodeSessionAffinity enables session-sticky routing for Claude Code clients.
- // When enabled, requests with the same session ID (extracted from metadata.user_id)
- // are routed to the same auth credential when available.
- // Deprecated: Use SessionAffinity instead for universal session support.
- ClaudeCodeSessionAffinity bool `yaml:"claude-code-session-affinity,omitempty" json:"claude-code-session-affinity,omitempty"`
-
// SessionAffinity enables universal session-sticky routing for all clients.
// Session IDs are extracted from multiple sources:
- // X-Session-ID header, Idempotency-Key, metadata.user_id, conversation_id, or message hash.
+ // metadata.user_id (Claude Code session format), X-Session-ID, Session_id (Codex),
+ // X-Amp-Thread-Id (Amp CLI thread), X-Client-Request-Id (PI), metadata.user_id,
+ // conversation_id, or message hash.
// Automatic failover is always enabled when bound auth becomes unavailable.
SessionAffinity bool `yaml:"session-affinity,omitempty" json:"session-affinity,omitempty"`
@@ -392,6 +398,9 @@ type ClaudeKey struct {
// ExcludedModels lists model IDs that should be excluded for this provider.
ExcludedModels []string `yaml:"excluded-models,omitempty" json:"excluded-models,omitempty"`
+ // DisableCooling disables auth/model cooldown scheduling for this credential when true.
+ DisableCooling bool `yaml:"disable-cooling,omitempty" json:"disable-cooling,omitempty"`
+
// Cloak configures request cloaking for non-Claude-Code clients.
Cloak *CloakConfig `yaml:"cloak,omitempty" json:"cloak,omitempty"`
@@ -447,6 +456,9 @@ type CodexKey struct {
// ExcludedModels lists model IDs that should be excluded for this provider.
ExcludedModels []string `yaml:"excluded-models,omitempty" json:"excluded-models,omitempty"`
+
+ // DisableCooling disables auth/model cooldown scheduling for this credential when true.
+ DisableCooling bool `yaml:"disable-cooling,omitempty" json:"disable-cooling,omitempty"`
}
func (k CodexKey) GetAPIKey() string { return k.APIKey }
@@ -491,6 +503,9 @@ type GeminiKey struct {
// ExcludedModels lists model IDs that should be excluded for this provider.
ExcludedModels []string `yaml:"excluded-models,omitempty" json:"excluded-models,omitempty"`
+
+ // DisableCooling disables auth/model cooldown scheduling for this credential when true.
+ DisableCooling bool `yaml:"disable-cooling,omitempty" json:"disable-cooling,omitempty"`
}
func (k GeminiKey) GetAPIKey() string { return k.APIKey }
@@ -518,6 +533,9 @@ type OpenAICompatibility struct {
// Higher values are preferred; defaults to 0.
Priority int `yaml:"priority,omitempty" json:"priority,omitempty"`
+ // Disabled prevents this provider from being used for routing.
+ Disabled bool `yaml:"disabled,omitempty" json:"disabled,omitempty"`
+
// Prefix optionally namespaces model aliases for this provider (e.g., "teamA/kimi-k2").
Prefix string `yaml:"prefix,omitempty" json:"prefix,omitempty"`
@@ -532,6 +550,9 @@ type OpenAICompatibility struct {
// Headers optionally adds extra HTTP headers for requests sent to this provider.
Headers map[string]string `yaml:"headers,omitempty" json:"headers,omitempty"`
+
+ // DisableCooling disables auth/model cooldown scheduling for this provider when true.
+ DisableCooling bool `yaml:"disable-cooling,omitempty" json:"disable-cooling,omitempty"`
}
// OpenAICompatibilityAPIKey represents an API key configuration with optional proxy setting.
@@ -603,7 +624,9 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) {
cfg.LogsMaxTotalSizeMB = 0
cfg.ErrorLogsMaxFiles = 10
cfg.UsageStatisticsEnabled = false
+ cfg.RedisUsageQueueRetentionSeconds = 60
cfg.DisableCooling = false
+ cfg.DisableImageGeneration = DisableImageGenerationOff
cfg.Pprof.Enable = false
cfg.Pprof.Addr = DefaultPprofAddr
cfg.AmpCode.RestrictManagementToLocalhost = false // Default to false: API key auth is sufficient
@@ -664,6 +687,13 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) {
cfg.ErrorLogsMaxFiles = 10
}
+ if cfg.RedisUsageQueueRetentionSeconds <= 0 {
+ cfg.RedisUsageQueueRetentionSeconds = 60
+ } else if cfg.RedisUsageQueueRetentionSeconds > 3600 {
+ log.WithField("value", cfg.RedisUsageQueueRetentionSeconds).Warn("redis-usage-queue-retention-seconds too large; clamping to 3600")
+ cfg.RedisUsageQueueRetentionSeconds = 3600
+ }
+
if cfg.MaxRetryCredentials < 0 {
cfg.MaxRetryCredentials = 0
}
diff --git a/internal/config/disable_image_generation_mode.go b/internal/config/disable_image_generation_mode.go
new file mode 100644
index 0000000000..1712638b86
--- /dev/null
+++ b/internal/config/disable_image_generation_mode.go
@@ -0,0 +1,136 @@
+package config
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "strings"
+
+ "gopkg.in/yaml.v3"
+)
+
+// DisableImageGenerationMode is a tri-state config value for disable-image-generation.
+//
+// It supports:
+// - false: enabled
+// - true: disabled everywhere (including /v1/images/* endpoints)
+// - "chat": disabled for all non-images endpoints, but enabled for /v1/images/generations and /v1/images/edits
+type DisableImageGenerationMode int
+
+const (
+ DisableImageGenerationOff DisableImageGenerationMode = iota
+ DisableImageGenerationAll
+ DisableImageGenerationChat
+)
+
+func (m DisableImageGenerationMode) String() string {
+ switch m {
+ case DisableImageGenerationOff:
+ return "false"
+ case DisableImageGenerationAll:
+ return "true"
+ case DisableImageGenerationChat:
+ return "chat"
+ default:
+ return "false"
+ }
+}
+
+func (m DisableImageGenerationMode) MarshalYAML() (any, error) {
+ switch m {
+ case DisableImageGenerationAll:
+ return true, nil
+ case DisableImageGenerationChat:
+ return "chat", nil
+ default:
+ return false, nil
+ }
+}
+
+func (m *DisableImageGenerationMode) UnmarshalYAML(value *yaml.Node) error {
+ mode, err := parseDisableImageGenerationNode(value)
+ if err != nil {
+ return err
+ }
+ *m = mode
+ return nil
+}
+
+func (m DisableImageGenerationMode) MarshalJSON() ([]byte, error) {
+ switch m {
+ case DisableImageGenerationAll:
+ return []byte("true"), nil
+ case DisableImageGenerationChat:
+ return json.Marshal("chat")
+ default:
+ return []byte("false"), nil
+ }
+}
+
+func (m *DisableImageGenerationMode) UnmarshalJSON(data []byte) error {
+ mode, err := parseDisableImageGenerationJSON(data)
+ if err != nil {
+ return err
+ }
+ *m = mode
+ return nil
+}
+
+func parseDisableImageGenerationNode(value *yaml.Node) (DisableImageGenerationMode, error) {
+ if value == nil {
+ return DisableImageGenerationOff, nil
+ }
+
+ // First try a typed bool decode (covers unquoted true/false and YAML 1.1 bools).
+ var b bool
+ if err := value.Decode(&b); err == nil && value.Kind == yaml.ScalarNode && value.ShortTag() == "!!bool" {
+ if b {
+ return DisableImageGenerationAll, nil
+ }
+ return DisableImageGenerationOff, nil
+ }
+
+ // Fall back to string decoding (covers quoted "true"/"false" and "chat").
+ var s string
+ if err := value.Decode(&s); err != nil {
+ return DisableImageGenerationOff, fmt.Errorf("invalid disable-image-generation value")
+ }
+ return parseDisableImageGenerationString(s)
+}
+
+func parseDisableImageGenerationJSON(data []byte) (DisableImageGenerationMode, error) {
+ trimmed := bytes.TrimSpace(data)
+ if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) {
+ return DisableImageGenerationOff, nil
+ }
+
+ // bool
+ var b bool
+ if err := json.Unmarshal(trimmed, &b); err == nil {
+ if b {
+ return DisableImageGenerationAll, nil
+ }
+ return DisableImageGenerationOff, nil
+ }
+
+ // string
+ var s string
+ if err := json.Unmarshal(trimmed, &s); err != nil {
+ return DisableImageGenerationOff, fmt.Errorf("invalid disable-image-generation value")
+ }
+ return parseDisableImageGenerationString(s)
+}
+
+func parseDisableImageGenerationString(s string) (DisableImageGenerationMode, error) {
+ s = strings.TrimSpace(strings.ToLower(s))
+ switch s {
+ case "", "false", "0", "off", "no":
+ return DisableImageGenerationOff, nil
+ case "true", "1", "on", "yes":
+ return DisableImageGenerationAll, nil
+ case "chat":
+ return DisableImageGenerationChat, nil
+ default:
+ return DisableImageGenerationOff, fmt.Errorf("invalid disable-image-generation value %q (allowed: true, false, chat)", s)
+ }
+}
diff --git a/internal/config/disable_image_generation_mode_test.go b/internal/config/disable_image_generation_mode_test.go
new file mode 100644
index 0000000000..433a5cbf96
--- /dev/null
+++ b/internal/config/disable_image_generation_mode_test.go
@@ -0,0 +1,76 @@
+package config
+
+import (
+ "encoding/json"
+ "testing"
+
+ "gopkg.in/yaml.v3"
+)
+
+func TestDisableImageGenerationMode_UnmarshalYAML(t *testing.T) {
+ type wrapper struct {
+ V DisableImageGenerationMode `yaml:"disable-image-generation"`
+ }
+
+ {
+ var w wrapper
+ if err := yaml.Unmarshal([]byte("disable-image-generation: false\n"), &w); err != nil {
+ t.Fatalf("unmarshal false: %v", err)
+ }
+ if w.V != DisableImageGenerationOff {
+ t.Fatalf("false => %v, want %v", w.V, DisableImageGenerationOff)
+ }
+ }
+
+ {
+ var w wrapper
+ if err := yaml.Unmarshal([]byte("disable-image-generation: true\n"), &w); err != nil {
+ t.Fatalf("unmarshal true: %v", err)
+ }
+ if w.V != DisableImageGenerationAll {
+ t.Fatalf("true => %v, want %v", w.V, DisableImageGenerationAll)
+ }
+ }
+
+ {
+ var w wrapper
+ if err := yaml.Unmarshal([]byte("disable-image-generation: chat\n"), &w); err != nil {
+ t.Fatalf("unmarshal chat: %v", err)
+ }
+ if w.V != DisableImageGenerationChat {
+ t.Fatalf("chat => %v, want %v", w.V, DisableImageGenerationChat)
+ }
+ }
+}
+
+func TestDisableImageGenerationMode_UnmarshalJSON(t *testing.T) {
+ {
+ var v DisableImageGenerationMode
+ if err := json.Unmarshal([]byte("false"), &v); err != nil {
+ t.Fatalf("unmarshal false: %v", err)
+ }
+ if v != DisableImageGenerationOff {
+ t.Fatalf("false => %v, want %v", v, DisableImageGenerationOff)
+ }
+ }
+
+ {
+ var v DisableImageGenerationMode
+ if err := json.Unmarshal([]byte("true"), &v); err != nil {
+ t.Fatalf("unmarshal true: %v", err)
+ }
+ if v != DisableImageGenerationAll {
+ t.Fatalf("true => %v, want %v", v, DisableImageGenerationAll)
+ }
+ }
+
+ {
+ var v DisableImageGenerationMode
+ if err := json.Unmarshal([]byte(`"chat"`), &v); err != nil {
+ t.Fatalf("unmarshal chat: %v", err)
+ }
+ if v != DisableImageGenerationChat {
+ t.Fatalf("chat => %v, want %v", v, DisableImageGenerationChat)
+ }
+ }
+}
diff --git a/internal/config/home.go b/internal/config/home.go
new file mode 100644
index 0000000000..03c9173239
--- /dev/null
+++ b/internal/config/home.go
@@ -0,0 +1,9 @@
+package config
+
+// HomeConfig configures the optional "home" control plane integration over Redis protocol.
+type HomeConfig struct {
+ Enabled bool `yaml:"enabled" json:"enabled"`
+ Host string `yaml:"host" json:"-"`
+ Port int `yaml:"port" json:"-"`
+ Password string `yaml:"password" json:"-"`
+}
diff --git a/internal/config/parse.go b/internal/config/parse.go
new file mode 100644
index 0000000000..283740e5f0
--- /dev/null
+++ b/internal/config/parse.go
@@ -0,0 +1,89 @@
+package config
+
+import (
+ "fmt"
+ "strings"
+
+ log "github.com/sirupsen/logrus"
+ "golang.org/x/crypto/bcrypt"
+ "gopkg.in/yaml.v3"
+)
+
+// ParseConfigBytes parses a YAML configuration payload into Config and applies the same
+// in-memory normalizations as LoadConfigOptional, without persisting any changes to disk.
+func ParseConfigBytes(data []byte) (*Config, error) {
+ if len(data) == 0 {
+ return nil, fmt.Errorf("config payload is empty")
+ }
+
+ var cfg Config
+ // Keep defaults aligned with LoadConfigOptional.
+ cfg.Host = "" // Default empty: binds to all interfaces (IPv4 + IPv6)
+ cfg.LoggingToFile = false
+ cfg.LogsMaxTotalSizeMB = 0
+ cfg.ErrorLogsMaxFiles = 10
+ cfg.UsageStatisticsEnabled = false
+ cfg.RedisUsageQueueRetentionSeconds = 60
+ cfg.DisableCooling = false
+ cfg.DisableImageGeneration = DisableImageGenerationOff
+ cfg.Pprof.Enable = false
+ cfg.Pprof.Addr = DefaultPprofAddr
+ cfg.AmpCode.RestrictManagementToLocalhost = false // Default to false: API key auth is sufficient
+ cfg.RemoteManagement.PanelGitHubRepository = DefaultPanelGitHubRepository
+
+ if err := yaml.Unmarshal(data, &cfg); err != nil {
+ return nil, fmt.Errorf("parse config payload: %w", err)
+ }
+
+ // Hash remote management key if plaintext is detected (nested), but do NOT persist.
+ if cfg.RemoteManagement.SecretKey != "" && !looksLikeBcrypt(cfg.RemoteManagement.SecretKey) {
+ hashed, errHash := bcrypt.GenerateFromPassword([]byte(cfg.RemoteManagement.SecretKey), bcrypt.DefaultCost)
+ if errHash != nil {
+ return nil, fmt.Errorf("hash remote management key: %w", errHash)
+ }
+ cfg.RemoteManagement.SecretKey = string(hashed)
+ }
+
+ cfg.RemoteManagement.PanelGitHubRepository = strings.TrimSpace(cfg.RemoteManagement.PanelGitHubRepository)
+ if cfg.RemoteManagement.PanelGitHubRepository == "" {
+ cfg.RemoteManagement.PanelGitHubRepository = DefaultPanelGitHubRepository
+ }
+
+ cfg.Pprof.Addr = strings.TrimSpace(cfg.Pprof.Addr)
+ if cfg.Pprof.Addr == "" {
+ cfg.Pprof.Addr = DefaultPprofAddr
+ }
+
+ if cfg.LogsMaxTotalSizeMB < 0 {
+ cfg.LogsMaxTotalSizeMB = 0
+ }
+
+ if cfg.ErrorLogsMaxFiles < 0 {
+ cfg.ErrorLogsMaxFiles = 10
+ }
+
+ if cfg.RedisUsageQueueRetentionSeconds <= 0 {
+ cfg.RedisUsageQueueRetentionSeconds = 60
+ } else if cfg.RedisUsageQueueRetentionSeconds > 3600 {
+ log.WithField("value", cfg.RedisUsageQueueRetentionSeconds).Warn("redis-usage-queue-retention-seconds too large; clamping to 3600")
+ cfg.RedisUsageQueueRetentionSeconds = 3600
+ }
+
+ if cfg.MaxRetryCredentials < 0 {
+ cfg.MaxRetryCredentials = 0
+ }
+
+ // Apply the same sanitization pipeline.
+ cfg.SanitizeGeminiKeys()
+ cfg.SanitizeVertexCompatKeys()
+ cfg.SanitizeCodexKeys()
+ cfg.SanitizeCodexHeaderDefaults()
+ cfg.SanitizeClaudeHeaderDefaults()
+ cfg.SanitizeClaudeKeys()
+ cfg.SanitizeOpenAICompatibility()
+ cfg.OAuthExcludedModels = NormalizeOAuthExcludedModels(cfg.OAuthExcludedModels)
+ cfg.SanitizeOAuthModelAlias()
+ cfg.SanitizePayloadRules()
+
+ return &cfg, nil
+}
diff --git a/internal/config/sdk_config.go b/internal/config/sdk_config.go
index aa27526d1e..48c0fe5f17 100644
--- a/internal/config/sdk_config.go
+++ b/internal/config/sdk_config.go
@@ -9,6 +9,16 @@ type SDKConfig struct {
// ProxyURL is the URL of an optional proxy server to use for outbound requests.
ProxyURL string `yaml:"proxy-url" json:"proxy-url"`
+ // DisableImageGeneration controls whether the built-in image_generation tool is injected/allowed.
+ //
+ // Supported values:
+ // - false (default): image_generation is enabled everywhere (normal behavior).
+ // - true: image_generation is disabled everywhere. The server stops injecting it, removes it from request payloads,
+ // and returns 404 for /v1/images/generations and /v1/images/edits.
+ // - "chat": disable image_generation injection for all non-images endpoints (e.g. /v1/responses, /v1/chat/completions),
+ // while keeping /v1/images/generations and /v1/images/edits enabled and preserving image_generation there.
+ DisableImageGeneration DisableImageGenerationMode `yaml:"disable-image-generation" json:"disable-image-generation"`
+
// EnableGeminiCLIEndpoint controls whether Gemini CLI internal endpoints (/v1internal:*) are enabled.
// Default is false for safety; when false, /v1internal:* requests are rejected.
EnableGeminiCLIEndpoint bool `yaml:"enable-gemini-cli-endpoint" json:"enable-gemini-cli-endpoint"`
diff --git a/internal/home/client.go b/internal/home/client.go
new file mode 100644
index 0000000000..23082cc69c
--- /dev/null
+++ b/internal/home/client.go
@@ -0,0 +1,393 @@
+package home
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "strings"
+ "sync/atomic"
+ "time"
+
+ "github.com/redis/go-redis/v9"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ log "github.com/sirupsen/logrus"
+)
+
+const (
+ redisKeyConfig = "config"
+ redisChannelConfig = "config"
+ redisKeyModels = "models"
+ redisKeyUsage = "usage"
+ redisKeyRequestLog = "request-log"
+
+ homeReconnectInterval = time.Second
+)
+
+var (
+ ErrDisabled = errors.New("home client disabled")
+ ErrNotConnected = errors.New("home not connected")
+ ErrEmptyResponse = errors.New("home returned empty response")
+ ErrAuthNotFound = errors.New("home auth not found")
+ ErrConfigNotFound = errors.New("home config not found")
+ ErrModelsNotFound = errors.New("home models not found")
+)
+
+type Client struct {
+ homeCfg config.HomeConfig
+
+ cmd *redis.Client
+ sub *redis.Client
+
+ heartbeatOK atomic.Bool
+}
+
+func New(homeCfg config.HomeConfig) *Client {
+ return &Client{homeCfg: homeCfg}
+}
+
+func (c *Client) Enabled() bool {
+ if c == nil {
+ return false
+ }
+ return c.homeCfg.Enabled
+}
+
+func (c *Client) HeartbeatOK() bool {
+ if c == nil {
+ return false
+ }
+ if !c.Enabled() {
+ return false
+ }
+ return c.heartbeatOK.Load()
+}
+
+func (c *Client) Close() {
+ if c == nil {
+ return
+ }
+ c.heartbeatOK.Store(false)
+ if c.cmd != nil {
+ _ = c.cmd.Close()
+ }
+ if c.sub != nil {
+ _ = c.sub.Close()
+ }
+ c.cmd = nil
+ c.sub = nil
+}
+
+func (c *Client) addr() (string, bool) {
+ if c == nil {
+ return "", false
+ }
+ host := strings.TrimSpace(c.homeCfg.Host)
+ if host == "" {
+ return "", false
+ }
+ if c.homeCfg.Port <= 0 {
+ return "", false
+ }
+ return fmt.Sprintf("%s:%d", host, c.homeCfg.Port), true
+}
+
+func (c *Client) ensureClients() error {
+ if c == nil {
+ return ErrDisabled
+ }
+ if !c.Enabled() {
+ return ErrDisabled
+ }
+ addr, ok := c.addr()
+ if !ok {
+ return fmt.Errorf("home: invalid address (host=%q port=%d)", c.homeCfg.Host, c.homeCfg.Port)
+ }
+
+ if c.cmd == nil {
+ c.cmd = redis.NewClient(&redis.Options{
+ Addr: addr,
+ Password: c.homeCfg.Password,
+ })
+ }
+ if c.sub == nil {
+ c.sub = redis.NewClient(&redis.Options{
+ Addr: addr,
+ Password: c.homeCfg.Password,
+ })
+ }
+ return nil
+}
+
+func (c *Client) Ping(ctx context.Context) error {
+ if err := c.ensureClients(); err != nil {
+ return err
+ }
+ if c.cmd == nil {
+ return ErrNotConnected
+ }
+ return c.cmd.Ping(ctx).Err()
+}
+
+func (c *Client) GetConfig(ctx context.Context) ([]byte, error) {
+ if err := c.ensureClients(); err != nil {
+ return nil, err
+ }
+ raw, err := c.cmd.Get(ctx, redisKeyConfig).Bytes()
+ if errors.Is(err, redis.Nil) {
+ return nil, ErrConfigNotFound
+ }
+ if err != nil {
+ return nil, err
+ }
+ if len(raw) == 0 {
+ return nil, ErrEmptyResponse
+ }
+ return raw, nil
+}
+
+func (c *Client) GetModels(ctx context.Context) ([]byte, error) {
+ if err := c.ensureClients(); err != nil {
+ return nil, err
+ }
+ raw, err := c.cmd.Get(ctx, redisKeyModels).Bytes()
+ if errors.Is(err, redis.Nil) {
+ return nil, ErrModelsNotFound
+ }
+ if err != nil {
+ return nil, err
+ }
+ if len(raw) == 0 {
+ return nil, ErrEmptyResponse
+ }
+ return raw, nil
+}
+
+func headersToLowerMap(headers http.Header) map[string]string {
+ if len(headers) == 0 {
+ return nil
+ }
+ out := make(map[string]string, len(headers))
+ for key, values := range headers {
+ k := strings.ToLower(strings.TrimSpace(key))
+ if k == "" {
+ continue
+ }
+ if len(values) == 0 {
+ out[k] = ""
+ continue
+ }
+ trimmed := make([]string, 0, len(values))
+ for _, v := range values {
+ trimmed = append(trimmed, strings.TrimSpace(v))
+ }
+ out[k] = strings.Join(trimmed, ", ")
+ }
+ if len(out) == 0 {
+ return nil
+ }
+ return out
+}
+
+func newAuthDispatchRequest(requestedModel string, sessionID string, headers http.Header, count int) authDispatchRequest {
+ if count <= 0 {
+ count = 1
+ }
+ return authDispatchRequest{
+ Type: "auth",
+ Model: requestedModel,
+ Count: count,
+ SessionID: strings.TrimSpace(sessionID),
+ Headers: headersToLowerMap(headers),
+ }
+}
+
+func (c *Client) RPopAuth(ctx context.Context, requestedModel string, sessionID string, headers http.Header, count int) ([]byte, error) {
+ if err := c.ensureClients(); err != nil {
+ return nil, err
+ }
+ requestedModel = strings.TrimSpace(requestedModel)
+ if requestedModel == "" {
+ return nil, fmt.Errorf("home: requested model is empty")
+ }
+ req := newAuthDispatchRequest(requestedModel, sessionID, headers, count)
+ keyBytes, err := json.Marshal(&req)
+ if err != nil {
+ return nil, err
+ }
+
+ raw, err := c.cmd.RPop(ctx, string(keyBytes)).Bytes()
+ if errors.Is(err, redis.Nil) {
+ return nil, ErrAuthNotFound
+ }
+ if err != nil {
+ return nil, err
+ }
+ if len(raw) == 0 {
+ return nil, ErrEmptyResponse
+ }
+ return raw, nil
+}
+
+func (c *Client) GetRefreshAuth(ctx context.Context, authIndex string) ([]byte, error) {
+ if err := c.ensureClients(); err != nil {
+ return nil, err
+ }
+ authIndex = strings.TrimSpace(authIndex)
+ if authIndex == "" {
+ return nil, fmt.Errorf("home: auth_index is empty")
+ }
+ req := refreshRequest{
+ Type: "refresh",
+ AuthIndex: authIndex,
+ }
+ keyBytes, err := json.Marshal(&req)
+ if err != nil {
+ return nil, err
+ }
+
+ raw, err := c.cmd.Get(ctx, string(keyBytes)).Bytes()
+ if errors.Is(err, redis.Nil) {
+ return nil, ErrAuthNotFound
+ }
+ if err != nil {
+ return nil, err
+ }
+ if len(raw) == 0 {
+ return nil, ErrEmptyResponse
+ }
+ return raw, nil
+}
+
+func (c *Client) LPushUsage(ctx context.Context, payload []byte) error {
+ if err := c.ensureClients(); err != nil {
+ return err
+ }
+ if len(payload) == 0 {
+ return nil
+ }
+ return c.cmd.LPush(ctx, redisKeyUsage, payload).Err()
+}
+
+func (c *Client) RPushRequestLog(ctx context.Context, payload []byte) error {
+ if err := c.ensureClients(); err != nil {
+ return err
+ }
+ if len(payload) == 0 {
+ return nil
+ }
+ return c.cmd.RPush(ctx, redisKeyRequestLog, payload).Err()
+}
+
+// StartConfigSubscriber connects to home, fetches config once via GET config, then subscribes to
+// the "config" channel to receive runtime config updates.
+//
+// The subscription connection is treated as the home heartbeat. HeartbeatOK is set to true only
+// after the initial GET config succeeds and the SUBSCRIBE connection is established. When the
+// subscription ends unexpectedly, HeartbeatOK becomes false and the loop reconnects.
+func (c *Client) StartConfigSubscriber(ctx context.Context, onConfig func([]byte) error) {
+ if c == nil {
+ return
+ }
+ if !c.Enabled() {
+ return
+ }
+ if onConfig == nil {
+ return
+ }
+
+ for {
+ if ctx != nil {
+ select {
+ case <-ctx.Done():
+ c.heartbeatOK.Store(false)
+ return
+ default:
+ }
+ }
+
+ c.heartbeatOK.Store(false)
+ c.Close()
+
+ if errEnsure := c.ensureClients(); errEnsure != nil {
+ log.Warn("unable to connect to home control center, retrying in 1 second")
+ sleepWithContext(ctx, homeReconnectInterval)
+ continue
+ }
+
+ if errPing := c.Ping(ctx); errPing != nil {
+ log.Warn("unable to connect to home control center, retrying in 1 second")
+ sleepWithContext(ctx, homeReconnectInterval)
+ continue
+ }
+
+ raw, errGet := c.GetConfig(ctx)
+ if errGet != nil {
+ log.Warn("unable to fetch config from home control center, retrying in 1 second")
+ sleepWithContext(ctx, homeReconnectInterval)
+ continue
+ }
+ if errApply := onConfig(raw); errApply != nil {
+ log.Warn("unable to apply config from home control center, retrying in 1 second")
+ sleepWithContext(ctx, homeReconnectInterval)
+ continue
+ }
+
+ if c.sub == nil {
+ sleepWithContext(ctx, homeReconnectInterval)
+ continue
+ }
+
+ pubsub := c.sub.Subscribe(ctx, redisChannelConfig)
+ if pubsub == nil {
+ sleepWithContext(ctx, homeReconnectInterval)
+ continue
+ }
+
+ // Ensure the subscription is established before marking heartbeat OK.
+ if _, errReceive := pubsub.Receive(ctx); errReceive != nil {
+ _ = pubsub.Close()
+ sleepWithContext(ctx, homeReconnectInterval)
+ continue
+ }
+
+ c.heartbeatOK.Store(true)
+
+ for {
+ msg, errMsg := pubsub.ReceiveMessage(ctx)
+ if errMsg != nil {
+ _ = pubsub.Close()
+ c.heartbeatOK.Store(false)
+ sleepWithContext(ctx, homeReconnectInterval)
+ break
+ }
+ if msg == nil {
+ continue
+ }
+ if payload := strings.TrimSpace(msg.Payload); payload != "" {
+ if errApply := onConfig([]byte(payload)); errApply != nil {
+ log.Warn("failed to apply config update from home control center, ignoring")
+ }
+ }
+ }
+ }
+}
+
+func sleepWithContext(ctx context.Context, d time.Duration) {
+ if d <= 0 {
+ return
+ }
+ timer := time.NewTimer(d)
+ defer timer.Stop()
+ if ctx == nil {
+ <-timer.C
+ return
+ }
+ select {
+ case <-ctx.Done():
+ return
+ case <-timer.C:
+ return
+ }
+}
diff --git a/internal/home/client_test.go b/internal/home/client_test.go
new file mode 100644
index 0000000000..625e77bcac
--- /dev/null
+++ b/internal/home/client_test.go
@@ -0,0 +1,32 @@
+package home
+
+import (
+ "encoding/json"
+ "net/http"
+ "testing"
+)
+
+func TestAuthDispatchRequestIncludesCount(t *testing.T) {
+ req := newAuthDispatchRequest("gpt-5.4", "session-1", http.Header{"Authorization": {"Bearer test"}}, 2)
+
+ raw, err := json.Marshal(&req)
+ if err != nil {
+ t.Fatalf("marshal auth dispatch request: %v", err)
+ }
+
+ var payload map[string]any
+ if err := json.Unmarshal(raw, &payload); err != nil {
+ t.Fatalf("unmarshal auth dispatch request: %v", err)
+ }
+ if got := int(payload["count"].(float64)); got != 2 {
+ t.Fatalf("count = %d, want 2", got)
+ }
+}
+
+func TestAuthDispatchRequestDefaultsCountToOne(t *testing.T) {
+ req := newAuthDispatchRequest("gpt-5.4", "", nil, 0)
+
+ if req.Count != 1 {
+ t.Fatalf("count = %d, want 1", req.Count)
+ }
+}
diff --git a/internal/home/global.go b/internal/home/global.go
new file mode 100644
index 0000000000..a79121a487
--- /dev/null
+++ b/internal/home/global.go
@@ -0,0 +1,25 @@
+package home
+
+import "sync/atomic"
+
+var currentClient atomic.Value // *Client
+
+// SetCurrent sets the active home client used by runtime integrations.
+func SetCurrent(client *Client) {
+ currentClient.Store(client)
+}
+
+// Current returns the active home client instance, if any.
+func Current() *Client {
+ if v := currentClient.Load(); v != nil {
+ if client, ok := v.(*Client); ok {
+ return client
+ }
+ }
+ return nil
+}
+
+// ClearCurrent removes the active home client.
+func ClearCurrent() {
+ currentClient.Store((*Client)(nil))
+}
diff --git a/internal/home/requests.go b/internal/home/requests.go
new file mode 100644
index 0000000000..0757766468
--- /dev/null
+++ b/internal/home/requests.go
@@ -0,0 +1,14 @@
+package home
+
+type authDispatchRequest struct {
+ Type string `json:"type"`
+ Model string `json:"model"`
+ Count int `json:"count"`
+ SessionID string `json:"session_id,omitempty"`
+ Headers map[string]string `json:"headers,omitempty"`
+}
+
+type refreshRequest struct {
+ Type string `json:"type"`
+ AuthIndex string `json:"auth_index"`
+}
diff --git a/internal/interfaces/types.go b/internal/interfaces/types.go
index 9fb1e7f3b8..dfdfc02a84 100644
--- a/internal/interfaces/types.go
+++ b/internal/interfaces/types.go
@@ -3,7 +3,7 @@
// transformation operations, maintaining compatibility with the SDK translator package.
package interfaces
-import sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
+import sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
// Backwards compatible aliases for translator function types.
type TranslateRequestFunc = sdktranslator.RequestTransform
diff --git a/internal/logging/gin_logger.go b/internal/logging/gin_logger.go
index b94d7afe6d..6e3559b8c3 100644
--- a/internal/logging/gin_logger.go
+++ b/internal/logging/gin_logger.go
@@ -12,7 +12,7 @@ import (
"time"
"github.com/gin-gonic/gin"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/util"
log "github.com/sirupsen/logrus"
)
@@ -20,13 +20,17 @@ import (
var aiAPIPrefixes = []string{
"/v1/chat/completions",
"/v1/completions",
+ "/v1/images",
"/v1/messages",
"/v1/responses",
"/v1beta/models/",
"/api/provider/",
}
-const skipGinLogKey = "__gin_skip_request_logging__"
+const (
+ skipGinLogKey = "__gin_skip_request_logging__"
+ creditsUsedKey = "__antigravity_credits_used__"
+)
// GinLogrusLogger returns a Gin middleware handler that logs HTTP requests and responses
// using logrus. It captures request details including method, path, status code, latency,
@@ -78,6 +82,9 @@ func GinLogrusLogger() gin.HandlerFunc {
requestID = "--------"
}
logLine := fmt.Sprintf("%3d | %13v | %15s | %-7s \"%s\"", statusCode, latency, clientIP, method, path)
+ if creditsUsed(c) {
+ logLine += " [credits]"
+ }
if errorMessage != "" {
logLine = logLine + " | " + errorMessage
}
@@ -148,3 +155,15 @@ func shouldSkipGinRequestLogging(c *gin.Context) bool {
flag, ok := val.(bool)
return ok && flag
}
+
+func creditsUsed(c *gin.Context) bool {
+ if c == nil {
+ return false
+ }
+ val, exists := c.Get(creditsUsedKey)
+ if !exists {
+ return false
+ }
+ flag, ok := val.(bool)
+ return ok && flag
+}
diff --git a/internal/logging/gin_logger_test.go b/internal/logging/gin_logger_test.go
index 7de1833865..9bd3ddfba6 100644
--- a/internal/logging/gin_logger_test.go
+++ b/internal/logging/gin_logger_test.go
@@ -58,3 +58,12 @@ func TestGinLogrusRecoveryHandlesRegularPanic(t *testing.T) {
t.Fatalf("expected 500, got %d", recorder.Code)
}
}
+
+func TestIsAIAPIPathIncludesImages(t *testing.T) {
+ if !isAIAPIPath("/v1/images/generations") {
+ t.Fatalf("expected /v1/images/generations to be treated as AI API path")
+ }
+ if !isAIAPIPath("/v1/images/edits") {
+ t.Fatalf("expected /v1/images/edits to be treated as AI API path")
+ }
+}
diff --git a/internal/logging/global_logger.go b/internal/logging/global_logger.go
index 372222a545..4b4ef62c85 100644
--- a/internal/logging/global_logger.go
+++ b/internal/logging/global_logger.go
@@ -10,8 +10,8 @@ import (
"sync"
"github.com/gin-gonic/gin"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/util"
log "github.com/sirupsen/logrus"
"gopkg.in/natefinch/lumberjack.v2"
)
diff --git a/internal/logging/request_logger.go b/internal/logging/request_logger.go
index 2db2a504d3..44b2c95264 100644
--- a/internal/logging/request_logger.go
+++ b/internal/logging/request_logger.go
@@ -8,6 +8,8 @@ import (
"bytes"
"compress/flate"
"compress/gzip"
+ "context"
+ "encoding/json"
"fmt"
"io"
"os"
@@ -22,13 +24,23 @@ import (
"github.com/klauspost/compress/zstd"
log "github.com/sirupsen/logrus"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/buildinfo"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/buildinfo"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/home"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/util"
)
var requestLogID atomic.Uint64
+type homeRequestLogClient interface {
+ HeartbeatOK() bool
+ RPushRequestLog(ctx context.Context, payload []byte) error
+}
+
+var currentHomeRequestLogClient = func() homeRequestLogClient {
+ return home.Current()
+}
+
// RequestLogger defines the interface for logging HTTP requests and responses.
// It provides methods for logging both regular and streaming HTTP request/response cycles.
type RequestLogger interface {
@@ -148,6 +160,58 @@ type FileRequestLogger struct {
// errorLogsMaxFiles limits the number of error log files retained.
errorLogsMaxFiles int
+
+ homeEnabled bool
+}
+
+type homeRequestLogPayload struct {
+ Headers map[string][]string `json:"headers,omitempty"`
+ RequestLog string `json:"request_log,omitempty"`
+}
+
+func cloneHeaders(headers map[string][]string) map[string][]string {
+ if len(headers) == 0 {
+ return nil
+ }
+ out := make(map[string][]string, len(headers))
+ for key, values := range headers {
+ if strings.TrimSpace(key) == "" {
+ continue
+ }
+ if values == nil {
+ out[key] = nil
+ continue
+ }
+ copied := make([]string, len(values))
+ copy(copied, values)
+ out[key] = copied
+ }
+ if len(out) == 0 {
+ return nil
+ }
+ return out
+}
+
+func (l *FileRequestLogger) forwardRequestLogToHome(ctx context.Context, headers map[string][]string, logText string) error {
+ if l == nil || !l.homeEnabled {
+ return nil
+ }
+ client := currentHomeRequestLogClient()
+ if client == nil || !client.HeartbeatOK() {
+ return nil
+ }
+ payload := homeRequestLogPayload{
+ Headers: cloneHeaders(headers),
+ RequestLog: logText,
+ }
+ raw, errMarshal := json.Marshal(&payload)
+ if errMarshal != nil {
+ return errMarshal
+ }
+ if ctx == nil {
+ ctx = context.Background()
+ }
+ return client.RPushRequestLog(ctx, raw)
}
// NewFileRequestLogger creates a new file-based request logger.
@@ -173,7 +237,17 @@ func NewFileRequestLogger(enabled bool, logsDir string, configDir string, errorL
enabled: enabled,
logsDir: logsDir,
errorLogsMaxFiles: errorLogsMaxFiles,
+ homeEnabled: false,
+ }
+}
+
+// SetHomeEnabled toggles home request-log forwarding.
+// When enabled, request logs are not written to disk and are instead forwarded to home via Redis RESP.
+func (l *FileRequestLogger) SetHomeEnabled(enabled bool) {
+ if l == nil {
+ return
}
+ l.homeEnabled = enabled
}
// IsEnabled returns whether request logging is currently enabled.
@@ -231,6 +305,38 @@ func (l *FileRequestLogger) logRequest(url, method string, requestHeaders map[st
return nil
}
+ if l.homeEnabled && l.enabled {
+ responseToWrite, decompressErr := l.decompressResponse(responseHeaders, response)
+ if decompressErr != nil {
+ responseToWrite = response
+ }
+
+ var buf bytes.Buffer
+ writeErr := l.writeNonStreamingLog(
+ &buf,
+ url,
+ method,
+ requestHeaders,
+ body,
+ "",
+ websocketTimeline,
+ apiRequest,
+ apiResponse,
+ apiWebsocketTimeline,
+ apiResponseErrors,
+ statusCode,
+ responseHeaders,
+ responseToWrite,
+ decompressErr,
+ requestTimestamp,
+ apiResponseTimestamp,
+ )
+ if writeErr != nil {
+ return fmt.Errorf("failed to build request log content: %w", writeErr)
+ }
+ return l.forwardRequestLogToHome(context.Background(), requestHeaders, buf.String())
+ }
+
// Ensure logs directory exists
if errEnsure := l.ensureLogsDir(); errEnsure != nil {
return fmt.Errorf("failed to create logs directory: %w", errEnsure)
@@ -321,6 +427,14 @@ func (l *FileRequestLogger) LogStreamingRequest(url, method string, headers map[
return &NoOpStreamingLogWriter{}, nil
}
+ if l.homeEnabled {
+ client := home.Current()
+ if client == nil || !client.HeartbeatOK() {
+ return &NoOpStreamingLogWriter{}, nil
+ }
+ return newHomeStreamingLogWriter(url, method, headers, body, requestID), nil
+ }
+
// Ensure logs directory exists
if err := l.ensureLogsDir(); err != nil {
return nil, fmt.Errorf("failed to create logs directory: %w", err)
@@ -1498,3 +1612,165 @@ func (w *NoOpStreamingLogWriter) SetFirstChunkTimestamp(_ time.Time) {}
// Returns:
// - error: Always returns nil
func (w *NoOpStreamingLogWriter) Close() error { return nil }
+
+type homeStreamingLogWriter struct {
+ url string
+ method string
+ timestamp time.Time
+
+ requestHeaders map[string][]string
+ requestBody []byte
+
+ chunkChan chan []byte
+ doneChan chan struct{}
+
+ responseStatus int
+ statusWritten bool
+ responseHeaders map[string][]string
+ responseBody bytes.Buffer
+ apiRequest []byte
+ apiResponse []byte
+ apiWebsocketTime []byte
+ apiResponseTS time.Time
+ firstChunkTS time.Time
+}
+
+func newHomeStreamingLogWriter(url, method string, headers map[string][]string, body []byte, _ string) *homeStreamingLogWriter {
+ requestHeaders := make(map[string][]string, len(headers))
+ for key, values := range headers {
+ headerValues := make([]string, len(values))
+ copy(headerValues, values)
+ requestHeaders[key] = headerValues
+ }
+
+ writer := &homeStreamingLogWriter{
+ url: url,
+ method: method,
+ timestamp: time.Now(),
+ requestHeaders: requestHeaders,
+ requestBody: append([]byte(nil), body...),
+ chunkChan: make(chan []byte, 100),
+ doneChan: make(chan struct{}),
+ }
+
+ go writer.asyncWriter()
+ return writer
+}
+
+func (w *homeStreamingLogWriter) asyncWriter() {
+ defer close(w.doneChan)
+ for chunk := range w.chunkChan {
+ if len(chunk) == 0 {
+ continue
+ }
+ _, _ = w.responseBody.Write(chunk)
+ }
+}
+
+func (w *homeStreamingLogWriter) WriteChunkAsync(chunk []byte) {
+ if w == nil || w.chunkChan == nil || len(chunk) == 0 {
+ return
+ }
+ select {
+ case w.chunkChan <- append([]byte(nil), chunk...):
+ default:
+ }
+}
+
+func (w *homeStreamingLogWriter) WriteStatus(status int, headers map[string][]string) error {
+ if w == nil || status == 0 {
+ return nil
+ }
+ w.responseStatus = status
+ w.statusWritten = true
+ if headers != nil {
+ w.responseHeaders = make(map[string][]string, len(headers))
+ for key, values := range headers {
+ copied := make([]string, len(values))
+ copy(copied, values)
+ w.responseHeaders[key] = copied
+ }
+ }
+ return nil
+}
+
+func (w *homeStreamingLogWriter) WriteAPIRequest(apiRequest []byte) error {
+ if w == nil || len(apiRequest) == 0 {
+ return nil
+ }
+ w.apiRequest = bytes.Clone(apiRequest)
+ return nil
+}
+
+func (w *homeStreamingLogWriter) WriteAPIResponse(apiResponse []byte) error {
+ if w == nil || len(apiResponse) == 0 {
+ return nil
+ }
+ w.apiResponse = bytes.Clone(apiResponse)
+ return nil
+}
+
+func (w *homeStreamingLogWriter) WriteAPIWebsocketTimeline(apiWebsocketTimeline []byte) error {
+ if w == nil || len(apiWebsocketTimeline) == 0 {
+ return nil
+ }
+ w.apiWebsocketTime = bytes.Clone(apiWebsocketTimeline)
+ return nil
+}
+
+func (w *homeStreamingLogWriter) SetFirstChunkTimestamp(timestamp time.Time) {
+ if w == nil {
+ return
+ }
+ if !timestamp.IsZero() {
+ w.firstChunkTS = timestamp
+ w.apiResponseTS = timestamp
+ }
+}
+
+func (w *homeStreamingLogWriter) Close() error {
+ if w == nil {
+ return nil
+ }
+
+ client := currentHomeRequestLogClient()
+ if client == nil || !client.HeartbeatOK() {
+ return nil
+ }
+
+ if w.chunkChan != nil {
+ close(w.chunkChan)
+ <-w.doneChan
+ w.chunkChan = nil
+ }
+
+ responsePayload := w.responseBody.Bytes()
+
+ var buf bytes.Buffer
+ upstreamTransport := inferUpstreamTransport(w.apiRequest, w.apiResponse, w.apiWebsocketTime, nil)
+ if errWrite := writeRequestInfoWithBody(&buf, w.url, w.method, w.requestHeaders, w.requestBody, "", w.timestamp, "http", upstreamTransport, true); errWrite != nil {
+ return errWrite
+ }
+ if errWrite := writeAPISection(&buf, "=== API WEBSOCKET TIMELINE ===\n", "=== API WEBSOCKET TIMELINE", w.apiWebsocketTime, time.Time{}); errWrite != nil {
+ return errWrite
+ }
+ if errWrite := writeAPISection(&buf, "=== API REQUEST ===\n", "=== API REQUEST", w.apiRequest, time.Time{}); errWrite != nil {
+ return errWrite
+ }
+ if errWrite := writeAPISection(&buf, "=== API RESPONSE ===\n", "=== API RESPONSE", w.apiResponse, w.apiResponseTS); errWrite != nil {
+ return errWrite
+ }
+ if errWrite := writeResponseSection(&buf, w.responseStatus, w.statusWritten, w.responseHeaders, bytes.NewReader(responsePayload), nil, false); errWrite != nil {
+ return errWrite
+ }
+
+ payload := homeRequestLogPayload{
+ Headers: cloneHeaders(w.requestHeaders),
+ RequestLog: buf.String(),
+ }
+ raw, errMarshal := json.Marshal(&payload)
+ if errMarshal != nil {
+ return errMarshal
+ }
+ return client.RPushRequestLog(context.Background(), raw)
+}
diff --git a/internal/logging/request_logger_home_test.go b/internal/logging/request_logger_home_test.go
new file mode 100644
index 0000000000..f8cdf1e453
--- /dev/null
+++ b/internal/logging/request_logger_home_test.go
@@ -0,0 +1,154 @@
+package logging
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "net/http"
+ "os"
+ "testing"
+ "time"
+)
+
+type stubHomeRequestLogClient struct {
+ heartbeatOK bool
+ pushed [][]byte
+}
+
+func (c *stubHomeRequestLogClient) HeartbeatOK() bool { return c.heartbeatOK }
+
+func (c *stubHomeRequestLogClient) RPushRequestLog(_ context.Context, payload []byte) error {
+ c.pushed = append(c.pushed, bytes.Clone(payload))
+ return nil
+}
+
+func TestFileRequestLogger_HomeEnabled_ForwardsWhenRequestLogEnabled(t *testing.T) {
+ original := currentHomeRequestLogClient
+ defer func() {
+ currentHomeRequestLogClient = original
+ }()
+
+ stub := &stubHomeRequestLogClient{heartbeatOK: true}
+ currentHomeRequestLogClient = func() homeRequestLogClient {
+ return stub
+ }
+
+ logsDir := t.TempDir()
+ logger := NewFileRequestLogger(true, logsDir, "", 0)
+ logger.SetHomeEnabled(true)
+
+ requestHeaders := map[string][]string{
+ "Content-Type": {"application/json"},
+ "Authorization": {"Bearer secret"},
+ }
+
+ errLog := logger.LogRequest(
+ "/v1/chat/completions",
+ http.MethodPost,
+ requestHeaders,
+ []byte(`{"input":"hello"}`),
+ http.StatusOK,
+ map[string][]string{"Content-Type": {"application/json"}},
+ []byte(`{"ok":true}`),
+ nil,
+ nil,
+ nil,
+ nil,
+ nil,
+ "req-1",
+ time.Now(),
+ time.Now(),
+ )
+ if errLog != nil {
+ t.Fatalf("LogRequest error: %v", errLog)
+ }
+
+ entries, errRead := os.ReadDir(logsDir)
+ if errRead != nil {
+ t.Fatalf("failed to read logs dir: %v", errRead)
+ }
+ if len(entries) != 0 {
+ t.Fatalf("expected no local request log files, got entries: %+v", entries)
+ }
+
+ if len(stub.pushed) != 1 {
+ t.Fatalf("home pushed records = %d, want 1", len(stub.pushed))
+ }
+
+ var got struct {
+ Headers map[string][]string `json:"headers"`
+ RequestLog string `json:"request_log"`
+ }
+ if errUnmarshal := json.Unmarshal(stub.pushed[0], &got); errUnmarshal != nil {
+ t.Fatalf("unmarshal payload: %v payload=%s", errUnmarshal, string(stub.pushed[0]))
+ }
+ if got.Headers == nil || got.Headers["Content-Type"][0] != "application/json" {
+ t.Fatalf("headers.content-type = %+v, want application/json", got.Headers["Content-Type"])
+ }
+ if got.Headers == nil || got.Headers["Authorization"][0] != "Bearer secret" {
+ t.Fatalf("headers.authorization = %+v, want Bearer secret", got.Headers["Authorization"])
+ }
+ if got.RequestLog == "" {
+ t.Fatalf("request_log empty, want non-empty")
+ }
+}
+
+func TestFileRequestLogger_HomeEnabled_DoesNotForwardForcedErrorLogsWhenRequestLogDisabled(t *testing.T) {
+ original := currentHomeRequestLogClient
+ defer func() {
+ currentHomeRequestLogClient = original
+ }()
+
+ stub := &stubHomeRequestLogClient{heartbeatOK: true}
+ currentHomeRequestLogClient = func() homeRequestLogClient {
+ return stub
+ }
+
+ logsDir := t.TempDir()
+ logger := NewFileRequestLogger(false, logsDir, "", 0)
+ logger.SetHomeEnabled(true)
+
+ errLog := logger.LogRequestWithOptions(
+ "/v1/chat/completions",
+ http.MethodPost,
+ map[string][]string{"Content-Type": {"application/json"}},
+ []byte(`{"input":"hello"}`),
+ http.StatusBadGateway,
+ map[string][]string{"Content-Type": {"application/json"}},
+ []byte(`{"error":"upstream failure"}`),
+ nil,
+ nil,
+ nil,
+ nil,
+ nil,
+ true,
+ "req-2",
+ time.Now(),
+ time.Now(),
+ )
+ if errLog != nil {
+ t.Fatalf("LogRequestWithOptions error: %v", errLog)
+ }
+
+ if len(stub.pushed) != 0 {
+ t.Fatalf("home pushed records = %d, want 0", len(stub.pushed))
+ }
+
+ entries, errRead := os.ReadDir(logsDir)
+ if errRead != nil {
+ t.Fatalf("failed to read logs dir: %v", errRead)
+ }
+ found := false
+ for _, entry := range entries {
+ if entry.IsDir() {
+ continue
+ }
+ if entry.Name() != "" {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Fatalf("expected local forced error log file when request-log disabled")
+ }
+}
diff --git a/internal/logging/requestmeta.go b/internal/logging/requestmeta.go
new file mode 100644
index 0000000000..a28d7c6287
--- /dev/null
+++ b/internal/logging/requestmeta.go
@@ -0,0 +1,62 @@
+package logging
+
+import (
+ "context"
+ "sync/atomic"
+)
+
+type endpointKey struct{}
+type responseStatusKey struct{}
+
+type responseStatusHolder struct {
+ status atomic.Int32
+}
+
+func WithEndpoint(ctx context.Context, endpoint string) context.Context {
+ if ctx == nil {
+ ctx = context.Background()
+ }
+ return context.WithValue(ctx, endpointKey{}, endpoint)
+}
+
+func GetEndpoint(ctx context.Context) string {
+ if ctx == nil {
+ return ""
+ }
+ if endpoint, ok := ctx.Value(endpointKey{}).(string); ok {
+ return endpoint
+ }
+ return ""
+}
+
+func WithResponseStatusHolder(ctx context.Context) context.Context {
+ if ctx == nil {
+ ctx = context.Background()
+ }
+ if holder, ok := ctx.Value(responseStatusKey{}).(*responseStatusHolder); ok && holder != nil {
+ return ctx
+ }
+ return context.WithValue(ctx, responseStatusKey{}, &responseStatusHolder{})
+}
+
+func SetResponseStatus(ctx context.Context, status int) {
+ if ctx == nil || status <= 0 {
+ return
+ }
+ holder, ok := ctx.Value(responseStatusKey{}).(*responseStatusHolder)
+ if !ok || holder == nil {
+ return
+ }
+ holder.status.Store(int32(status))
+}
+
+func GetResponseStatus(ctx context.Context) int {
+ if ctx == nil {
+ return 0
+ }
+ holder, ok := ctx.Value(responseStatusKey{}).(*responseStatusHolder)
+ if !ok || holder == nil {
+ return 0
+ }
+ return int(holder.status.Load())
+}
diff --git a/internal/managementasset/updater.go b/internal/managementasset/updater.go
index ae2bc81956..ea7ca3f502 100644
--- a/internal/managementasset/updater.go
+++ b/internal/managementasset/updater.go
@@ -17,9 +17,9 @@ import (
"sync/atomic"
"time"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
- sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/util"
+ sdkconfig "github.com/router-for-me/CLIProxyAPI/v7/sdk/config"
log "github.com/sirupsen/logrus"
"golang.org/x/sync/singleflight"
)
diff --git a/internal/misc/antigravity_version.go b/internal/misc/antigravity_version.go
index 595cfefd96..0d187c254f 100644
--- a/internal/misc/antigravity_version.go
+++ b/internal/misc/antigravity_version.go
@@ -7,6 +7,7 @@ import (
"errors"
"fmt"
"net/http"
+ "strings"
"sync"
"time"
@@ -18,6 +19,8 @@ const (
antigravityFallbackVersion = "1.21.9"
antigravityVersionCacheTTL = 6 * time.Hour
antigravityFetchTimeout = 10 * time.Second
+ AntigravityNodeAPIClientUA = "google-api-nodejs-client/10.3.0"
+ AntigravityGoogAPIClientUA = "gl-node/22.21.1"
)
type antigravityRelease struct {
@@ -107,6 +110,65 @@ func AntigravityUserAgent() string {
return fmt.Sprintf("antigravity/%s darwin/arm64", AntigravityLatestVersion())
}
+func antigravityBaseUserAgent(userAgent string) string {
+ userAgent = strings.TrimSpace(userAgent)
+ if userAgent == "" {
+ return AntigravityUserAgent()
+ }
+ lower := strings.ToLower(userAgent)
+ if strings.HasPrefix(lower, "antigravity/") {
+ if idx := strings.Index(lower, " google-api-nodejs-client/"); idx >= 0 {
+ trimmed := strings.TrimSpace(userAgent[:idx])
+ if trimmed != "" {
+ return trimmed
+ }
+ }
+ }
+ return userAgent
+}
+
+// AntigravityRequestUserAgent returns the short Antigravity runtime UA used by
+// generate/stream/model-list requests.
+func AntigravityRequestUserAgent(userAgent string) string {
+ return antigravityBaseUserAgent(userAgent)
+}
+
+// AntigravityLoadCodeAssistUserAgent returns the long Antigravity control-plane
+// UA used by loadCodeAssist requests.
+func AntigravityLoadCodeAssistUserAgent(userAgent string) string {
+ userAgent = strings.TrimSpace(userAgent)
+ if userAgent == "" {
+ return AntigravityUserAgent() + " " + AntigravityNodeAPIClientUA
+ }
+ lower := strings.ToLower(userAgent)
+ if !strings.HasPrefix(lower, "antigravity/") {
+ return userAgent
+ }
+ if strings.Contains(lower, "google-api-nodejs-client/") {
+ return userAgent
+ }
+ return antigravityBaseUserAgent(userAgent) + " " + AntigravityNodeAPIClientUA
+}
+
+// AntigravityVersionFromUserAgent extracts the Antigravity version prefix from
+// either the short or long Antigravity UA forms.
+func AntigravityVersionFromUserAgent(userAgent string) string {
+ base := antigravityBaseUserAgent(userAgent)
+ lower := strings.ToLower(base)
+ if !strings.HasPrefix(lower, "antigravity/") {
+ return AntigravityLatestVersion()
+ }
+ rest := base[len("antigravity/"):]
+ if idx := strings.IndexAny(rest, " \t"); idx >= 0 {
+ rest = rest[:idx]
+ }
+ rest = strings.TrimSpace(rest)
+ if rest == "" {
+ return AntigravityLatestVersion()
+ }
+ return rest
+}
+
func fetchAntigravityLatestVersion(ctx context.Context) (string, error) {
if ctx == nil {
ctx = context.Background()
diff --git a/internal/misc/header_utils.go b/internal/misc/header_utils.go
index 5752a26956..ac022a9627 100644
--- a/internal/misc/header_utils.go
+++ b/internal/misc/header_utils.go
@@ -12,7 +12,7 @@ import (
const (
// GeminiCLIVersion is the version string reported in the User-Agent for upstream requests.
- GeminiCLIVersion = "0.31.0"
+ GeminiCLIVersion = "0.34.0"
// GeminiCLIApiClientHeader is the value for the X-Goog-Api-Client header sent to the Gemini CLI upstream.
GeminiCLIApiClientHeader = "google-genai-sdk/1.41.0 gl-node/v22.19.0"
@@ -46,7 +46,7 @@ func GeminiCLIUserAgent(model string) string {
if model == "" {
model = "unknown"
}
- return fmt.Sprintf("GeminiCLI/%s/%s (%s; %s)", GeminiCLIVersion, model, geminiCLIOS(), geminiCLIArch())
+ return fmt.Sprintf("GeminiCLI/%s/%s (%s; %s; terminal)", GeminiCLIVersion, model, geminiCLIOS(), geminiCLIArch())
}
// ScrubProxyAndFingerprintHeaders removes all headers that could reveal
diff --git a/internal/redisqueue/plugin.go b/internal/redisqueue/plugin.go
new file mode 100644
index 0000000000..e5b74cb24b
--- /dev/null
+++ b/internal/redisqueue/plugin.go
@@ -0,0 +1,160 @@
+package redisqueue
+
+import (
+ "context"
+ "encoding/json"
+ "strings"
+ "time"
+
+ internallogging "github.com/router-for-me/CLIProxyAPI/v7/internal/logging"
+ coreusage "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/usage"
+)
+
+func init() {
+ coreusage.RegisterPlugin(&usageQueuePlugin{})
+}
+
+type usageQueuePlugin struct{}
+
+func (p *usageQueuePlugin) HandleUsage(ctx context.Context, record coreusage.Record) {
+ if p == nil {
+ return
+ }
+ if !Enabled() || !UsageStatisticsEnabled() {
+ return
+ }
+
+ timestamp := record.RequestedAt
+ if timestamp.IsZero() {
+ timestamp = time.Now()
+ }
+
+ modelName := strings.TrimSpace(record.Model)
+ if modelName == "" {
+ modelName = "unknown"
+ }
+ aliasName := strings.TrimSpace(record.Alias)
+ if aliasName == "" {
+ aliasName = modelName
+ }
+ provider := strings.TrimSpace(record.Provider)
+ if provider == "" {
+ provider = "unknown"
+ }
+ authType := strings.TrimSpace(record.AuthType)
+ if authType == "" {
+ authType = "unknown"
+ }
+ apiKey := strings.TrimSpace(record.APIKey)
+ requestID := strings.TrimSpace(internallogging.GetRequestID(ctx))
+
+ tokens := tokenStats{
+ InputTokens: record.Detail.InputTokens,
+ OutputTokens: record.Detail.OutputTokens,
+ ReasoningTokens: record.Detail.ReasoningTokens,
+ CachedTokens: record.Detail.CachedTokens,
+ TotalTokens: record.Detail.TotalTokens,
+ }
+ if tokens.TotalTokens == 0 {
+ tokens.TotalTokens = tokens.InputTokens + tokens.OutputTokens + tokens.ReasoningTokens
+ }
+ if tokens.TotalTokens == 0 {
+ tokens.TotalTokens = tokens.InputTokens + tokens.OutputTokens + tokens.ReasoningTokens + tokens.CachedTokens
+ }
+
+ failed := record.Failed
+ if !failed {
+ failed = !resolveSuccess(ctx)
+ }
+ fail := resolveFail(ctx, record, failed)
+
+ detail := requestDetail{
+ Timestamp: timestamp,
+ LatencyMs: record.Latency.Milliseconds(),
+ Source: record.Source,
+ AuthIndex: record.AuthIndex,
+ Tokens: tokens,
+ Failed: failed,
+ Fail: fail,
+ }
+
+ payload, err := json.Marshal(queuedUsageDetail{
+ requestDetail: detail,
+ Provider: provider,
+ Model: modelName,
+ Alias: aliasName,
+ Endpoint: resolveEndpoint(ctx),
+ AuthType: authType,
+ APIKey: apiKey,
+ RequestID: requestID,
+ })
+ if err != nil {
+ return
+ }
+ Enqueue(payload)
+}
+
+type queuedUsageDetail struct {
+ requestDetail
+ Provider string `json:"provider"`
+ Model string `json:"model"`
+ Alias string `json:"alias"`
+ Endpoint string `json:"endpoint"`
+ AuthType string `json:"auth_type"`
+ APIKey string `json:"api_key"`
+ RequestID string `json:"request_id"`
+}
+
+type requestDetail struct {
+ Timestamp time.Time `json:"timestamp"`
+ LatencyMs int64 `json:"latency_ms"`
+ Source string `json:"source"`
+ AuthIndex string `json:"auth_index"`
+ Tokens tokenStats `json:"tokens"`
+ Failed bool `json:"failed"`
+ Fail failDetail `json:"fail"`
+}
+
+type tokenStats struct {
+ InputTokens int64 `json:"input_tokens"`
+ OutputTokens int64 `json:"output_tokens"`
+ ReasoningTokens int64 `json:"reasoning_tokens"`
+ CachedTokens int64 `json:"cached_tokens"`
+ TotalTokens int64 `json:"total_tokens"`
+}
+
+type failDetail struct {
+ StatusCode int `json:"status_code"`
+ Body string `json:"body"`
+}
+
+func resolveFail(ctx context.Context, record coreusage.Record, failed bool) failDetail {
+ fail := failDetail{
+ StatusCode: record.Fail.StatusCode,
+ Body: strings.TrimSpace(record.Fail.Body),
+ }
+ if !failed {
+ return failDetail{StatusCode: 200}
+ }
+ if fail.StatusCode <= 0 {
+ fail.StatusCode = internallogging.GetResponseStatus(ctx)
+ }
+ if fail.StatusCode <= 0 {
+ fail.StatusCode = 500
+ }
+ return fail
+}
+
+func resolveSuccess(ctx context.Context) bool {
+ status := internallogging.GetResponseStatus(ctx)
+ if status == 0 {
+ return true
+ }
+ return status < httpStatusBadRequest
+}
+
+func resolveEndpoint(ctx context.Context) string {
+ return strings.TrimSpace(internallogging.GetEndpoint(ctx))
+}
+
+const httpStatusBadRequest = 400
diff --git a/internal/redisqueue/plugin_test.go b/internal/redisqueue/plugin_test.go
new file mode 100644
index 0000000000..e2af6af709
--- /dev/null
+++ b/internal/redisqueue/plugin_test.go
@@ -0,0 +1,278 @@
+package redisqueue
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/gin-gonic/gin"
+ internallogging "github.com/router-for-me/CLIProxyAPI/v7/internal/logging"
+ coreusage "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/usage"
+)
+
+func TestUsageQueuePluginPayloadIncludesStableFieldsAndSuccess(t *testing.T) {
+ withEnabledQueue(t, func() {
+ ctx := internallogging.WithRequestID(context.Background(), "ctx-request-id")
+ ctx = internallogging.WithEndpoint(ctx, "POST /v1/chat/completions")
+ ctx = internallogging.WithResponseStatusHolder(ctx)
+ internallogging.SetResponseStatus(ctx, http.StatusOK)
+
+ plugin := &usageQueuePlugin{}
+ plugin.HandleUsage(ctx, coreusage.Record{
+ Provider: "openai",
+ Model: "gpt-5.4",
+ Alias: "client-gpt",
+ APIKey: "test-key",
+ AuthIndex: "0",
+ AuthType: "apikey",
+ Source: "user@example.com",
+ RequestedAt: time.Date(2026, 4, 25, 0, 0, 0, 0, time.UTC),
+ Latency: 1500 * time.Millisecond,
+ Detail: coreusage.Detail{
+ InputTokens: 10,
+ OutputTokens: 20,
+ TotalTokens: 30,
+ },
+ })
+
+ payload := popSinglePayload(t)
+ requireStringField(t, payload, "provider", "openai")
+ requireStringField(t, payload, "model", "gpt-5.4")
+ requireStringField(t, payload, "alias", "client-gpt")
+ requireStringField(t, payload, "endpoint", "POST /v1/chat/completions")
+ requireStringField(t, payload, "auth_type", "apikey")
+ requireMissingField(t, payload, "user_api_key")
+ requireStringField(t, payload, "request_id", "ctx-request-id")
+ requireBoolField(t, payload, "failed", false)
+ requireFailField(t, payload, http.StatusOK, "")
+ })
+}
+
+func TestUsageQueuePluginPayloadIncludesStableFieldsAndFailureAndGinRequestID(t *testing.T) {
+ withEnabledQueue(t, func() {
+ ctx := internallogging.WithRequestID(context.Background(), "gin-request-id")
+ ctx = internallogging.WithEndpoint(ctx, "GET /v1/responses")
+ ctx = internallogging.WithResponseStatusHolder(ctx)
+ internallogging.SetResponseStatus(ctx, http.StatusInternalServerError)
+
+ plugin := &usageQueuePlugin{}
+ plugin.HandleUsage(ctx, coreusage.Record{
+ Provider: "openai",
+ Model: "gpt-5.4-mini",
+ Alias: "client-mini",
+ APIKey: "test-key",
+ AuthIndex: "0",
+ AuthType: "apikey",
+ Source: "user@example.com",
+ RequestedAt: time.Date(2026, 4, 25, 0, 0, 0, 0, time.UTC),
+ Latency: 2500 * time.Millisecond,
+ Fail: coreusage.Failure{
+ StatusCode: http.StatusInternalServerError,
+ Body: "upstream failed",
+ },
+ Detail: coreusage.Detail{
+ InputTokens: 10,
+ OutputTokens: 20,
+ TotalTokens: 30,
+ },
+ })
+
+ payload := popSinglePayload(t)
+ requireStringField(t, payload, "provider", "openai")
+ requireStringField(t, payload, "model", "gpt-5.4-mini")
+ requireStringField(t, payload, "alias", "client-mini")
+ requireStringField(t, payload, "endpoint", "GET /v1/responses")
+ requireStringField(t, payload, "auth_type", "apikey")
+ requireMissingField(t, payload, "user_api_key")
+ requireStringField(t, payload, "request_id", "gin-request-id")
+ requireBoolField(t, payload, "failed", true)
+ requireFailField(t, payload, http.StatusInternalServerError, "upstream failed")
+ })
+}
+
+func TestUsageQueuePluginAsyncIgnoresRecycledGinContext(t *testing.T) {
+ withEnabledQueue(t, func() {
+ ginCtx := newTestGinContext(t, http.MethodPost, "/v1/chat/completions", http.StatusOK)
+ ctx := context.WithValue(context.Background(), "gin", ginCtx)
+ ctx = internallogging.WithRequestID(ctx, "ctx-request-id")
+ ctx = internallogging.WithEndpoint(ctx, "POST /v1/chat/completions")
+ ctx = internallogging.WithResponseStatusHolder(ctx)
+ internallogging.SetResponseStatus(ctx, http.StatusInternalServerError)
+
+ mgr := coreusage.NewManager(16)
+ defer mgr.Stop()
+
+ mgr.Register(pluginFunc(func(_ context.Context, _ coreusage.Record) {
+ ginCtx.Request = httptest.NewRequest(http.MethodGet, "http://example.com/v1/responses", nil)
+ ginCtx.Status(http.StatusOK)
+ }))
+ mgr.Register(&usageQueuePlugin{})
+
+ mgr.Publish(ctx, coreusage.Record{
+ Provider: "openai",
+ Model: "gpt-5.4",
+ Alias: "client-gpt",
+ APIKey: "test-key",
+ AuthIndex: "0",
+ AuthType: "apikey",
+ Source: "user@example.com",
+ RequestedAt: time.Date(2026, 4, 25, 0, 0, 0, 0, time.UTC),
+ Latency: 1500 * time.Millisecond,
+ Fail: coreusage.Failure{
+ StatusCode: http.StatusBadGateway,
+ Body: "bad gateway",
+ },
+ Detail: coreusage.Detail{
+ InputTokens: 10,
+ OutputTokens: 20,
+ TotalTokens: 30,
+ },
+ })
+
+ payload := waitForSinglePayload(t, 2*time.Second)
+ requireStringField(t, payload, "endpoint", "POST /v1/chat/completions")
+ requireStringField(t, payload, "alias", "client-gpt")
+ requireMissingField(t, payload, "user_api_key")
+ requireStringField(t, payload, "request_id", "ctx-request-id")
+ requireBoolField(t, payload, "failed", true)
+ requireFailField(t, payload, http.StatusBadGateway, "bad gateway")
+ })
+}
+
+func withEnabledQueue(t *testing.T, fn func()) {
+ t.Helper()
+
+ prevQueueEnabled := Enabled()
+ prevUsageEnabled := UsageStatisticsEnabled()
+
+ SetEnabled(false)
+ SetEnabled(true)
+ SetUsageStatisticsEnabled(true)
+
+ defer func() {
+ SetEnabled(false)
+ SetEnabled(prevQueueEnabled)
+ SetUsageStatisticsEnabled(prevUsageEnabled)
+ }()
+
+ fn()
+}
+
+func newTestGinContext(t *testing.T, method, path string, status int) *gin.Context {
+ t.Helper()
+
+ gin.SetMode(gin.TestMode)
+ recorder := httptest.NewRecorder()
+ ginCtx, _ := gin.CreateTestContext(recorder)
+ ginCtx.Request = httptest.NewRequest(method, "http://example.com"+path, nil)
+ if status != 0 {
+ ginCtx.Status(status)
+ }
+ return ginCtx
+}
+
+func popSinglePayload(t *testing.T) map[string]json.RawMessage {
+ t.Helper()
+
+ items := PopOldest(10)
+ if len(items) != 1 {
+ t.Fatalf("PopOldest() items = %d, want 1", len(items))
+ }
+
+ var payload map[string]json.RawMessage
+ if err := json.Unmarshal(items[0], &payload); err != nil {
+ t.Fatalf("unmarshal payload: %v", err)
+ }
+ return payload
+}
+
+func waitForSinglePayload(t *testing.T, timeout time.Duration) map[string]json.RawMessage {
+ t.Helper()
+
+ deadline := time.Now().Add(timeout)
+ for time.Now().Before(deadline) {
+ items := PopOldest(10)
+ if len(items) == 0 {
+ time.Sleep(10 * time.Millisecond)
+ continue
+ }
+ if len(items) != 1 {
+ t.Fatalf("PopOldest() items = %d, want 1", len(items))
+ }
+ var payload map[string]json.RawMessage
+ if err := json.Unmarshal(items[0], &payload); err != nil {
+ t.Fatalf("unmarshal payload: %v", err)
+ }
+ return payload
+ }
+ t.Fatalf("timeout waiting for queued payload")
+ return nil
+}
+
+func requireStringField(t *testing.T, payload map[string]json.RawMessage, key, want string) {
+ t.Helper()
+
+ raw, ok := payload[key]
+ if !ok {
+ t.Fatalf("payload missing %q", key)
+ }
+ var got string
+ if err := json.Unmarshal(raw, &got); err != nil {
+ t.Fatalf("unmarshal %q: %v", key, err)
+ }
+ if got != want {
+ t.Fatalf("%s = %q, want %q", key, got, want)
+ }
+}
+
+func requireMissingField(t *testing.T, payload map[string]json.RawMessage, key string) {
+ t.Helper()
+
+ if _, ok := payload[key]; ok {
+ t.Fatalf("payload unexpectedly contains %q", key)
+ }
+}
+
+type pluginFunc func(context.Context, coreusage.Record)
+
+func (fn pluginFunc) HandleUsage(ctx context.Context, record coreusage.Record) {
+ fn(ctx, record)
+}
+
+func requireBoolField(t *testing.T, payload map[string]json.RawMessage, key string, want bool) {
+ t.Helper()
+
+ raw, ok := payload[key]
+ if !ok {
+ t.Fatalf("payload missing %q", key)
+ }
+ var got bool
+ if err := json.Unmarshal(raw, &got); err != nil {
+ t.Fatalf("unmarshal %q: %v", key, err)
+ }
+ if got != want {
+ t.Fatalf("%s = %t, want %t", key, got, want)
+ }
+}
+
+func requireFailField(t *testing.T, payload map[string]json.RawMessage, wantStatus int, wantBody string) {
+ t.Helper()
+
+ raw, ok := payload["fail"]
+ if !ok {
+ t.Fatalf("payload missing %q", "fail")
+ }
+ var got struct {
+ StatusCode int `json:"status_code"`
+ Body string `json:"body"`
+ }
+ if err := json.Unmarshal(raw, &got); err != nil {
+ t.Fatalf("unmarshal fail: %v", err)
+ }
+ if got.StatusCode != wantStatus || got.Body != wantBody {
+ t.Fatalf("fail = {status_code:%d body:%q}, want {status_code:%d body:%q}", got.StatusCode, got.Body, wantStatus, wantBody)
+ }
+}
diff --git a/internal/redisqueue/queue.go b/internal/redisqueue/queue.go
new file mode 100644
index 0000000000..2fea58391a
--- /dev/null
+++ b/internal/redisqueue/queue.go
@@ -0,0 +1,155 @@
+package redisqueue
+
+import (
+ "sync"
+ "sync/atomic"
+ "time"
+)
+
+const (
+ defaultRetentionSeconds int64 = 60
+ maxRetentionSeconds int64 = 3600
+)
+
+type queueItem struct {
+ enqueuedAt time.Time
+ payload []byte
+}
+
+type queue struct {
+ mu sync.Mutex
+ items []queueItem
+ head int
+}
+
+var (
+ enabled atomic.Bool
+ retentionSeconds atomic.Int64
+ global queue
+)
+
+func init() {
+ retentionSeconds.Store(defaultRetentionSeconds)
+}
+
+func SetEnabled(value bool) {
+ enabled.Store(value)
+ if !value {
+ global.clear()
+ }
+}
+
+func Enabled() bool {
+ return enabled.Load()
+}
+
+func SetRetentionSeconds(value int) {
+ normalized := int64(value)
+ if normalized <= 0 {
+ normalized = defaultRetentionSeconds
+ } else if normalized > maxRetentionSeconds {
+ normalized = maxRetentionSeconds
+ }
+ retentionSeconds.Store(normalized)
+}
+
+func Enqueue(payload []byte) {
+ if !Enabled() {
+ return
+ }
+ if len(payload) == 0 {
+ return
+ }
+ global.enqueue(payload)
+}
+
+func PopOldest(count int) [][]byte {
+ if !Enabled() {
+ return nil
+ }
+ if count <= 0 {
+ return nil
+ }
+ return global.popOldest(count)
+}
+
+func (q *queue) clear() {
+ q.mu.Lock()
+ defer q.mu.Unlock()
+ q.items = nil
+ q.head = 0
+}
+
+func (q *queue) enqueue(payload []byte) {
+ now := time.Now()
+
+ q.mu.Lock()
+ defer q.mu.Unlock()
+
+ q.pruneLocked(now)
+ q.items = append(q.items, queueItem{
+ enqueuedAt: now,
+ payload: append([]byte(nil), payload...),
+ })
+ q.maybeCompactLocked()
+}
+
+func (q *queue) popOldest(count int) [][]byte {
+ now := time.Now()
+
+ q.mu.Lock()
+ defer q.mu.Unlock()
+
+ q.pruneLocked(now)
+ available := len(q.items) - q.head
+ if available <= 0 {
+ q.items = nil
+ q.head = 0
+ return nil
+ }
+ if count > available {
+ count = available
+ }
+
+ out := make([][]byte, 0, count)
+ for i := 0; i < count; i++ {
+ item := q.items[q.head+i]
+ out = append(out, item.payload)
+ }
+ q.head += count
+ q.maybeCompactLocked()
+ return out
+}
+
+func (q *queue) pruneLocked(now time.Time) {
+ if q.head >= len(q.items) {
+ q.items = nil
+ q.head = 0
+ return
+ }
+
+ windowSeconds := retentionSeconds.Load()
+ if windowSeconds <= 0 {
+ windowSeconds = defaultRetentionSeconds
+ }
+ cutoff := now.Add(-time.Duration(windowSeconds) * time.Second)
+ for q.head < len(q.items) && q.items[q.head].enqueuedAt.Before(cutoff) {
+ q.head++
+ }
+}
+
+func (q *queue) maybeCompactLocked() {
+ if q.head == 0 {
+ return
+ }
+ if q.head >= len(q.items) {
+ q.items = nil
+ q.head = 0
+ return
+ }
+ if q.head < 1024 && q.head*2 < len(q.items) {
+ return
+ }
+ q.items = append([]queueItem(nil), q.items[q.head:]...)
+ q.head = 0
+}
diff --git a/internal/redisqueue/usage_toggle.go b/internal/redisqueue/usage_toggle.go
new file mode 100644
index 0000000000..dddbeca692
--- /dev/null
+++ b/internal/redisqueue/usage_toggle.go
@@ -0,0 +1,16 @@
+package redisqueue
+
+import "sync/atomic"
+
+var usageStatisticsEnabled atomic.Bool
+
+func init() {
+ usageStatisticsEnabled.Store(true)
+}
+
+// SetUsageStatisticsEnabled toggles whether usage records are enqueued into the redisqueue payload buffer.
+// This is controlled by the config field `usage-statistics-enabled` and the corresponding management API.
+func SetUsageStatisticsEnabled(enabled bool) { usageStatisticsEnabled.Store(enabled) }
+
+// UsageStatisticsEnabled reports whether the usage queue plugin should publish records.
+func UsageStatisticsEnabled() bool { return usageStatisticsEnabled.Load() }
diff --git a/internal/registry/model_definitions.go b/internal/registry/model_definitions.go
index ab7258f845..7ac6b469ac 100644
--- a/internal/registry/model_definitions.go
+++ b/internal/registry/model_definitions.go
@@ -6,6 +6,8 @@ import (
"strings"
)
+const codexBuiltinImageModelID = "gpt-image-2"
+
// staticModelsJSON mirrors the top-level structure of models.json.
type staticModelsJSON struct {
Claude []*ModelInfo `json:"claude"`
@@ -48,22 +50,22 @@ func GetAIStudioModels() []*ModelInfo {
// GetCodexFreeModels returns model definitions for the Codex free plan tier.
func GetCodexFreeModels() []*ModelInfo {
- return cloneModelInfos(getModels().CodexFree)
+ return WithCodexBuiltins(cloneModelInfos(getModels().CodexFree))
}
// GetCodexTeamModels returns model definitions for the Codex team plan tier.
func GetCodexTeamModels() []*ModelInfo {
- return cloneModelInfos(getModels().CodexTeam)
+ return WithCodexBuiltins(cloneModelInfos(getModels().CodexTeam))
}
// GetCodexPlusModels returns model definitions for the Codex plus plan tier.
func GetCodexPlusModels() []*ModelInfo {
- return cloneModelInfos(getModels().CodexPlus)
+ return WithCodexBuiltins(cloneModelInfos(getModels().CodexPlus))
}
// GetCodexProModels returns model definitions for the Codex pro plan tier.
func GetCodexProModels() []*ModelInfo {
- return cloneModelInfos(getModels().CodexPro)
+ return WithCodexBuiltins(cloneModelInfos(getModels().CodexPro))
}
// GetKimiModels returns the standard Kimi (Moonshot AI) model definitions.
@@ -76,6 +78,71 @@ func GetAntigravityModels() []*ModelInfo {
return cloneModelInfos(getModels().Antigravity)
}
+// WithCodexBuiltins injects hard-coded Codex-only model definitions that should
+// not depend on remote models.json updates. Built-ins replace any matching IDs
+// already present in the provided slice.
+func WithCodexBuiltins(models []*ModelInfo) []*ModelInfo {
+ return upsertModelInfos(models, codexBuiltinImageModelInfo())
+}
+
+func codexBuiltinImageModelInfo() *ModelInfo {
+ return &ModelInfo{
+ ID: codexBuiltinImageModelID,
+ Object: "model",
+ Created: 1704067200, // 2024-01-01
+ OwnedBy: "openai",
+ Type: "openai",
+ DisplayName: "GPT Image 2",
+ Version: codexBuiltinImageModelID,
+ }
+}
+
+func upsertModelInfos(models []*ModelInfo, extras ...*ModelInfo) []*ModelInfo {
+ if len(extras) == 0 {
+ return models
+ }
+
+ extraIDs := make(map[string]struct{}, len(extras))
+ extraList := make([]*ModelInfo, 0, len(extras))
+ for _, extra := range extras {
+ if extra == nil {
+ continue
+ }
+ id := strings.TrimSpace(extra.ID)
+ if id == "" {
+ continue
+ }
+ key := strings.ToLower(id)
+ if _, exists := extraIDs[key]; exists {
+ continue
+ }
+ extraIDs[key] = struct{}{}
+ extraList = append(extraList, cloneModelInfo(extra))
+ }
+
+ if len(extraList) == 0 {
+ return models
+ }
+
+ filtered := make([]*ModelInfo, 0, len(models)+len(extraList))
+ for _, model := range models {
+ if model == nil {
+ continue
+ }
+ id := strings.TrimSpace(model.ID)
+ if id == "" {
+ continue
+ }
+ if _, exists := extraIDs[strings.ToLower(id)]; exists {
+ continue
+ }
+ filtered = append(filtered, model)
+ }
+
+ filtered = append(filtered, extraList...)
+ return filtered
+}
+
// cloneModelInfos returns a shallow copy of the slice with each element deep-cloned.
func cloneModelInfos(models []*ModelInfo) []*ModelInfo {
if len(models) == 0 {
diff --git a/internal/registry/model_definitions_test.go b/internal/registry/model_definitions_test.go
new file mode 100644
index 0000000000..bb2fc46046
--- /dev/null
+++ b/internal/registry/model_definitions_test.go
@@ -0,0 +1,94 @@
+package registry
+
+import "testing"
+
+func TestCodexFreeModelsExcludeGPT55(t *testing.T) {
+ model := findModelInfo(GetCodexFreeModels(), "gpt-5.5")
+ if model != nil {
+ t.Fatal("expected codex free tier to NOT include gpt-5.5")
+ }
+}
+
+func TestCodexStaticModelsIncludeGPT55(t *testing.T) {
+ tierModels := map[string][]*ModelInfo{
+ "team": GetCodexTeamModels(),
+ "plus": GetCodexPlusModels(),
+ "pro": GetCodexProModels(),
+ }
+
+ for tier, models := range tierModels {
+ t.Run(tier, func(t *testing.T) {
+ model := findModelInfo(models, "gpt-5.5")
+ if model == nil {
+ t.Fatalf("expected codex %s tier to include gpt-5.5", tier)
+ }
+ assertGPT55ModelInfo(t, tier, model)
+ })
+ }
+
+ model := LookupStaticModelInfo("gpt-5.5")
+ if model == nil {
+ t.Fatal("expected LookupStaticModelInfo to find gpt-5.5")
+ }
+ assertGPT55ModelInfo(t, "lookup", model)
+}
+
+func findModelInfo(models []*ModelInfo, id string) *ModelInfo {
+ for _, model := range models {
+ if model != nil && model.ID == id {
+ return model
+ }
+ }
+ return nil
+}
+
+func assertGPT55ModelInfo(t *testing.T, source string, model *ModelInfo) {
+ t.Helper()
+
+ if model.ID != "gpt-5.5" {
+ t.Fatalf("%s id mismatch: got %q", source, model.ID)
+ }
+ if model.Object != "model" {
+ t.Fatalf("%s object mismatch: got %q", source, model.Object)
+ }
+ if model.Created != 1776902400 {
+ t.Fatalf("%s created timestamp mismatch: got %d", source, model.Created)
+ }
+ if model.OwnedBy != "openai" {
+ t.Fatalf("%s owned_by mismatch: got %q", source, model.OwnedBy)
+ }
+ if model.Type != "openai" {
+ t.Fatalf("%s type mismatch: got %q", source, model.Type)
+ }
+ if model.DisplayName != "GPT 5.5" {
+ t.Fatalf("%s display name mismatch: got %q", source, model.DisplayName)
+ }
+ if model.Version != "gpt-5.5" {
+ t.Fatalf("%s version mismatch: got %q", source, model.Version)
+ }
+ if model.Description != "Frontier model for complex coding, research, and real-world work." {
+ t.Fatalf("%s description mismatch: got %q", source, model.Description)
+ }
+ if model.ContextLength != 272000 {
+ t.Fatalf("%s context length mismatch: got %d", source, model.ContextLength)
+ }
+ if model.MaxCompletionTokens != 128000 {
+ t.Fatalf("%s max completion tokens mismatch: got %d", source, model.MaxCompletionTokens)
+ }
+ if len(model.SupportedParameters) != 1 || model.SupportedParameters[0] != "tools" {
+ t.Fatalf("%s supported parameters mismatch: got %v", source, model.SupportedParameters)
+ }
+ if model.Thinking == nil {
+ t.Fatalf("%s missing thinking support", source)
+ }
+
+ want := []string{"low", "medium", "high", "xhigh"}
+ if len(model.Thinking.Levels) != len(want) {
+ t.Fatalf("%s thinking level count mismatch: got %d, want %d", source, len(model.Thinking.Levels), len(want))
+ }
+ for i, level := range want {
+ if model.Thinking.Levels[i] != level {
+ t.Fatalf("%s thinking level %d mismatch: got %q, want %q", source, i, model.Thinking.Levels[i], level)
+ }
+ }
+}
diff --git a/internal/registry/model_registry.go b/internal/registry/model_registry.go
index 3f3f530d27..4c215bb7af 100644
--- a/internal/registry/model_registry.go
+++ b/internal/registry/model_registry.go
@@ -11,7 +11,7 @@ import (
"sync"
"time"
- misc "github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
+ misc "github.com/router-for-me/CLIProxyAPI/v7/internal/misc"
log "github.com/sirupsen/logrus"
)
diff --git a/internal/registry/model_registry_safety_test.go b/internal/registry/model_registry_safety_test.go
index 5f4f65d298..be5bf7908c 100644
--- a/internal/registry/model_registry_safety_test.go
+++ b/internal/registry/model_registry_safety_test.go
@@ -136,13 +136,13 @@ func TestGetAvailableModelsReturnsClonedSupportedParameters(t *testing.T) {
}
func TestLookupModelInfoReturnsCloneForStaticDefinitions(t *testing.T) {
- first := LookupModelInfo("glm-4.6")
+ first := LookupModelInfo("claude-sonnet-4-6")
if first == nil || first.Thinking == nil || len(first.Thinking.Levels) == 0 {
t.Fatalf("expected static model with thinking levels, got %+v", first)
}
first.Thinking.Levels[0] = "mutated"
- second := LookupModelInfo("glm-4.6")
+ second := LookupModelInfo("claude-sonnet-4-6")
if second == nil || second.Thinking == nil || len(second.Thinking.Levels) == 0 || second.Thinking.Levels[0] == "mutated" {
t.Fatalf("expected static lookup clone, got %+v", second)
}
diff --git a/internal/registry/models/models.json b/internal/registry/models/models.json
index 65d8325169..fa56bb42a2 100644
--- a/internal/registry/models/models.json
+++ b/internal/registry/models/models.json
@@ -1292,6 +1292,52 @@
"xhigh"
]
}
+ },
+ {
+ "id": "gpt-5.5",
+ "object": "model",
+ "created": 1776902400,
+ "owned_by": "openai",
+ "type": "openai",
+ "display_name": "GPT 5.5",
+ "version": "gpt-5.5",
+ "description": "Frontier model for complex coding, research, and real-world work.",
+ "context_length": 272000,
+ "max_completion_tokens": 128000,
+ "supported_parameters": [
+ "tools"
+ ],
+ "thinking": {
+ "levels": [
+ "low",
+ "medium",
+ "high",
+ "xhigh"
+ ]
+ }
+ },
+ {
+ "id": "codex-auto-review",
+ "object": "model",
+ "created": 1776902400,
+ "owned_by": "openai",
+ "type": "openai",
+ "display_name": "Codex Auto Review",
+ "version": "Codex Auto Review",
+ "description": "Automatic approval review model for Codex.",
+ "context_length": 272000,
+ "max_completion_tokens": 128000,
+ "supported_parameters": [
+ "tools"
+ ],
+ "thinking": {
+ "levels": [
+ "low",
+ "medium",
+ "high",
+ "xhigh"
+ ]
+ }
}
],
"codex-team": [
@@ -1387,6 +1433,52 @@
"xhigh"
]
}
+ },
+ {
+ "id": "gpt-5.5",
+ "object": "model",
+ "created": 1776902400,
+ "owned_by": "openai",
+ "type": "openai",
+ "display_name": "GPT 5.5",
+ "version": "gpt-5.5",
+ "description": "Frontier model for complex coding, research, and real-world work.",
+ "context_length": 272000,
+ "max_completion_tokens": 128000,
+ "supported_parameters": [
+ "tools"
+ ],
+ "thinking": {
+ "levels": [
+ "low",
+ "medium",
+ "high",
+ "xhigh"
+ ]
+ }
+ },
+ {
+ "id": "codex-auto-review",
+ "object": "model",
+ "created": 1776902400,
+ "owned_by": "openai",
+ "type": "openai",
+ "display_name": "Codex Auto Review",
+ "version": "Codex Auto Review",
+ "description": "Automatic approval review model for Codex.",
+ "context_length": 272000,
+ "max_completion_tokens": 128000,
+ "supported_parameters": [
+ "tools"
+ ],
+ "thinking": {
+ "levels": [
+ "low",
+ "medium",
+ "high",
+ "xhigh"
+ ]
+ }
}
],
"codex-plus": [
@@ -1505,6 +1597,52 @@
"xhigh"
]
}
+ },
+ {
+ "id": "gpt-5.5",
+ "object": "model",
+ "created": 1776902400,
+ "owned_by": "openai",
+ "type": "openai",
+ "display_name": "GPT 5.5",
+ "version": "gpt-5.5",
+ "description": "Frontier model for complex coding, research, and real-world work.",
+ "context_length": 272000,
+ "max_completion_tokens": 128000,
+ "supported_parameters": [
+ "tools"
+ ],
+ "thinking": {
+ "levels": [
+ "low",
+ "medium",
+ "high",
+ "xhigh"
+ ]
+ }
+ },
+ {
+ "id": "codex-auto-review",
+ "object": "model",
+ "created": 1776902400,
+ "owned_by": "openai",
+ "type": "openai",
+ "display_name": "Codex Auto Review",
+ "version": "Codex Auto Review",
+ "description": "Automatic approval review model for Codex.",
+ "context_length": 272000,
+ "max_completion_tokens": 128000,
+ "supported_parameters": [
+ "tools"
+ ],
+ "thinking": {
+ "levels": [
+ "low",
+ "medium",
+ "high",
+ "xhigh"
+ ]
+ }
}
],
"codex-pro": [
@@ -1623,6 +1761,52 @@
"xhigh"
]
}
+ },
+ {
+ "id": "gpt-5.5",
+ "object": "model",
+ "created": 1776902400,
+ "owned_by": "openai",
+ "type": "openai",
+ "display_name": "GPT 5.5",
+ "version": "gpt-5.5",
+ "description": "Frontier model for complex coding, research, and real-world work.",
+ "context_length": 272000,
+ "max_completion_tokens": 128000,
+ "supported_parameters": [
+ "tools"
+ ],
+ "thinking": {
+ "levels": [
+ "low",
+ "medium",
+ "high",
+ "xhigh"
+ ]
+ }
+ },
+ {
+ "id": "codex-auto-review",
+ "object": "model",
+ "created": 1776902400,
+ "owned_by": "openai",
+ "type": "openai",
+ "display_name": "Codex Auto Review",
+ "version": "Codex Auto Review",
+ "description": "Automatic approval review model for Codex.",
+ "context_length": 272000,
+ "max_completion_tokens": 128000,
+ "supported_parameters": [
+ "tools"
+ ],
+ "thinking": {
+ "levels": [
+ "low",
+ "medium",
+ "high",
+ "xhigh"
+ ]
+ }
}
],
"kimi": [
@@ -1670,6 +1854,23 @@
"zero_allowed": true,
"dynamic_allowed": true
}
+ },
+ {
+ "id": "kimi-k2.6",
+ "object": "model",
+ "created": 1776729600,
+ "owned_by": "moonshot",
+ "type": "kimi",
+ "display_name": "Kimi K2.6",
+ "description": "Kimi K2.6 - Latest Moonshot AI coding model with improved capabilities",
+ "context_length": 262144,
+ "max_completion_tokens": 65536,
+ "thinking": {
+ "min": 1024,
+ "max": 32000,
+ "zero_allowed": true,
+ "dynamic_allowed": true
+ }
}
],
"antigravity": [
diff --git a/internal/runtime/executor/aistudio_executor.go b/internal/runtime/executor/aistudio_executor.go
index f53e3e4d1d..41365b5f7a 100644
--- a/internal/runtime/executor/aistudio_executor.go
+++ b/internal/runtime/executor/aistudio_executor.go
@@ -13,14 +13,14 @@ import (
"net/url"
"strings"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/executor/helps"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/wsrelay"
- 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"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/util"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/wsrelay"
+ cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
+ cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
+ sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
@@ -284,8 +284,11 @@ func (e *AIStudioExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth
processEvent := func(event wsrelay.StreamEvent) bool {
if event.Err != nil {
helps.RecordAPIResponseError(ctx, e.cfg, event.Err)
- reporter.PublishFailure(ctx)
- out <- cliproxyexecutor.StreamChunk{Err: fmt.Errorf("wsrelay: %v", event.Err)}
+ reporter.PublishFailure(ctx, event.Err)
+ select {
+ case out <- cliproxyexecutor.StreamChunk{Err: fmt.Errorf("wsrelay: %v", event.Err)}:
+ case <-ctx.Done():
+ }
return false
}
switch event.Type {
@@ -303,7 +306,11 @@ func (e *AIStudioExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth
}
lines := sdktranslator.TranslateStream(ctx, body.toFormat, opts.SourceFormat, req.Model, opts.OriginalRequest, translatedReq, filtered, ¶m)
for i := range lines {
- out <- cliproxyexecutor.StreamChunk{Payload: ensureColonSpacedJSON(lines[i])}
+ select {
+ case out <- cliproxyexecutor.StreamChunk{Payload: ensureColonSpacedJSON(lines[i])}:
+ case <-ctx.Done():
+ return false
+ }
}
break
}
@@ -319,14 +326,21 @@ func (e *AIStudioExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth
}
lines := sdktranslator.TranslateStream(ctx, body.toFormat, opts.SourceFormat, req.Model, opts.OriginalRequest, translatedReq, event.Payload, ¶m)
for i := range lines {
- out <- cliproxyexecutor.StreamChunk{Payload: ensureColonSpacedJSON(lines[i])}
+ select {
+ case out <- cliproxyexecutor.StreamChunk{Payload: ensureColonSpacedJSON(lines[i])}:
+ case <-ctx.Done():
+ return false
+ }
}
reporter.Publish(ctx, helps.ParseGeminiUsage(event.Payload))
return false
case wsrelay.MessageTypeError:
helps.RecordAPIResponseError(ctx, e.cfg, event.Err)
- reporter.PublishFailure(ctx)
- out <- cliproxyexecutor.StreamChunk{Err: fmt.Errorf("wsrelay: %v", event.Err)}
+ reporter.PublishFailure(ctx, event.Err)
+ select {
+ case out <- cliproxyexecutor.StreamChunk{Err: fmt.Errorf("wsrelay: %v", event.Err)}:
+ case <-ctx.Done():
+ }
return false
}
return true
@@ -400,7 +414,10 @@ func (e *AIStudioExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.A
}
// Refresh refreshes the authentication credentials (no-op for AI Studio).
-func (e *AIStudioExecutor) Refresh(_ context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) {
+func (e *AIStudioExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) {
+ if refreshed, handled, err := helps.RefreshAuthViaHome(ctx, e.cfg, auth); handled {
+ return refreshed, err
+ }
return auth, nil
}
@@ -428,7 +445,8 @@ func (e *AIStudioExecutor) translateRequest(req cliproxyexecutor.Request, opts c
}
payload = fixGeminiImageAspectRatio(baseModel, payload)
requestedModel := helps.PayloadRequestedModel(opts, req.Model)
- payload = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", payload, originalTranslated, requestedModel)
+ requestPath := helps.PayloadRequestPath(opts)
+ payload = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", payload, originalTranslated, requestedModel, requestPath)
payload, _ = sjson.DeleteBytes(payload, "generationConfig.maxOutputTokens")
payload, _ = sjson.DeleteBytes(payload, "generationConfig.responseMimeType")
payload, _ = sjson.DeleteBytes(payload, "generationConfig.responseJsonSchema")
diff --git a/internal/runtime/executor/antigravity_executor.go b/internal/runtime/executor/antigravity_executor.go
index 163b2d9279..2f8dff927c 100644
--- a/internal/runtime/executor/antigravity_executor.go
+++ b/internal/runtime/executor/antigravity_executor.go
@@ -23,18 +23,18 @@ import (
"time"
"github.com/google/uuid"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/cache"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/executor/helps"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
- antigravityclaude "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/antigravity/claude"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
- sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
- 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"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/cache"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/misc"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
+ antigravityclaude "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/antigravity/claude"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/util"
+ sdkAuth "github.com/router-for-me/CLIProxyAPI/v7/sdk/auth"
+ cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
+ cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
+ sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
log "github.com/sirupsen/logrus"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
@@ -52,8 +52,8 @@ const (
defaultAntigravityAgent = "antigravity/1.21.9 darwin/arm64" // fallback only; overridden at runtime by misc.AntigravityUserAgent()
antigravityAuthType = "antigravity"
refreshSkew = 3000 * time.Second
- antigravityCreditsRetryTTL = 5 * time.Hour
- antigravityCreditsAutoDisableDuration = 5 * time.Hour
+ antigravityCreditsHintRefreshInterval = 10 * time.Minute
+ antigravityCreditsHintRefreshTimeout = 5 * time.Second
antigravityShortQuotaCooldownThreshold = 5 * time.Minute
antigravityInstantRetryThreshold = 3 * time.Second
// systemInstruction = "You are Antigravity, a powerful agentic AI coding assistant designed by the Google Deepmind team working on Advanced Agentic Coding.You are pair programming with a USER to solve their coding task. The task may require creating a new codebase, modifying or debugging an existing codebase, or simply answering a question.**Absolute paths only****Proactiveness**"
@@ -62,8 +62,6 @@ const (
type antigravity429Category string
type antigravityCreditsFailureState struct {
- Count int
- DisabledUntil time.Time
PermanentlyDisabled bool
ExplicitBalanceExhausted bool
}
@@ -91,28 +89,85 @@ var (
randSource = rand.New(rand.NewSource(time.Now().UnixNano()))
randSourceMutex sync.Mutex
antigravityCreditsFailureByAuth sync.Map
- antigravityPreferCreditsByModel sync.Map
antigravityShortCooldownByAuth sync.Map
+ antigravityCreditsBalanceByAuth sync.Map // auth.ID → antigravityCreditsBalance
+ antigravityCreditsHintRefreshByID sync.Map // auth.ID → *antigravityCreditsHintRefreshState
antigravityQuotaExhaustedKeywords = []string{
"quota_exhausted",
"quota exhausted",
}
- antigravityCreditsExhaustedKeywords = []string{
- "google_one_ai",
- "insufficient credit",
- "insufficient credits",
- "not enough credit",
- "not enough credits",
- "credit exhausted",
- "credits exhausted",
- "credit balance",
- "minimumcreditamountforusage",
- "minimum credit amount for usage",
- "minimum credit",
- "resource has been exhausted",
- }
)
+type antigravityCreditsBalance struct {
+ CreditAmount float64
+ MinCreditAmount float64
+ PaidTierID string
+ Known bool
+}
+
+type antigravityCreditsHintRefreshState struct {
+ mu sync.Mutex
+ lastAttempt time.Time
+}
+
+func antigravityAuthHasCredits(auth *cliproxyauth.Auth) bool {
+ if auth == nil || strings.TrimSpace(auth.ID) == "" {
+ return false
+ }
+ if hint, ok := cliproxyauth.GetAntigravityCreditsHint(auth.ID); ok && hint.Known {
+ return hint.Available
+ }
+ val, ok := antigravityCreditsBalanceByAuth.Load(strings.TrimSpace(auth.ID))
+ if !ok {
+ return true // optimistic: assume credits available when balance unknown
+ }
+ bal, valid := val.(antigravityCreditsBalance)
+ if !valid {
+ antigravityCreditsBalanceByAuth.Delete(strings.TrimSpace(auth.ID))
+ return false
+ }
+ if !bal.Known {
+ return false
+ }
+ available := bal.CreditAmount >= bal.MinCreditAmount
+ cliproxyauth.SetAntigravityCreditsHint(strings.TrimSpace(auth.ID), cliproxyauth.AntigravityCreditsHint{
+ Known: true,
+ Available: available,
+ CreditAmount: bal.CreditAmount,
+ MinCreditAmount: bal.MinCreditAmount,
+ PaidTierID: bal.PaidTierID,
+ UpdatedAt: time.Now(),
+ })
+ return available
+}
+
+// parseMetaFloat extracts a float64 from auth.Metadata (handles string and numeric types).
+func parseMetaFloat(metadata map[string]any, key string) (float64, bool) {
+ v, ok := metadata[key]
+ if !ok {
+ return 0, false
+ }
+ switch typed := v.(type) {
+ case float64:
+ return typed, true
+ case int:
+ return float64(typed), true
+ case int64:
+ return float64(typed), true
+ case uint64:
+ return float64(typed), true
+ case json.Number:
+ if f, err := typed.Float64(); err == nil {
+ return f, true
+ }
+ case string:
+ if f, err := strconv.ParseFloat(strings.TrimSpace(typed), 64); err == nil {
+ return f, true
+ }
+ }
+ return 0, false
+}
+
// AntigravityExecutor proxies requests to the antigravity upstream.
type AntigravityExecutor struct {
cfg *config.Config
@@ -189,7 +244,7 @@ func validateAntigravityRequestSignatures(from sdktranslator.Format, rawJSON []b
if from.String() != "claude" {
return rawJSON, nil
}
- // Always strip thinking blocks with empty signatures (proxy-generated).
+ // Always strip thinking blocks with invalid signatures (empty or non-Claude-format).
rawJSON = antigravityclaude.StripEmptySignatureThinkingBlocks(rawJSON)
if cache.SignatureCacheEnabled() {
return rawJSON, nil
@@ -298,49 +353,46 @@ func decideAntigravity429(body []byte) antigravity429Decision {
decision.retryAfter = retryAfter
}
- lowerBody := strings.ToLower(string(body))
- for _, keyword := range antigravityQuotaExhaustedKeywords {
- if strings.Contains(lowerBody, keyword) {
- decision.kind = antigravity429DecisionFullQuotaExhausted
- decision.reason = "quota_exhausted"
- return decision
- }
- }
-
status := strings.TrimSpace(gjson.GetBytes(body, "error.status").String())
if !strings.EqualFold(status, "RESOURCE_EXHAUSTED") {
return decision
}
details := gjson.GetBytes(body, "error.details")
- if !details.Exists() || !details.IsArray() {
- decision.kind = antigravity429DecisionSoftRetry
- return decision
- }
-
- for _, detail := range details.Array() {
- if detail.Get("@type").String() != "type.googleapis.com/google.rpc.ErrorInfo" {
- continue
- }
- reason := strings.TrimSpace(detail.Get("reason").String())
- decision.reason = reason
- switch {
- case strings.EqualFold(reason, "QUOTA_EXHAUSTED"):
- decision.kind = antigravity429DecisionFullQuotaExhausted
- return decision
- case strings.EqualFold(reason, "RATE_LIMIT_EXCEEDED"):
- if decision.retryAfter == nil {
- decision.kind = antigravity429DecisionSoftRetry
- return decision
+ if details.Exists() && details.IsArray() {
+ for _, detail := range details.Array() {
+ if detail.Get("@type").String() != "type.googleapis.com/google.rpc.ErrorInfo" {
+ continue
}
+ reason := strings.TrimSpace(detail.Get("reason").String())
+ decision.reason = reason
switch {
- case *decision.retryAfter < antigravityInstantRetryThreshold:
- decision.kind = antigravity429DecisionInstantRetrySameAuth
- case *decision.retryAfter < antigravityShortQuotaCooldownThreshold:
- decision.kind = antigravity429DecisionShortCooldownSwitchAuth
- default:
+ case strings.EqualFold(reason, "QUOTA_EXHAUSTED"):
decision.kind = antigravity429DecisionFullQuotaExhausted
+ return decision
+ case strings.EqualFold(reason, "RATE_LIMIT_EXCEEDED"):
+ if decision.retryAfter == nil {
+ decision.kind = antigravity429DecisionSoftRetry
+ return decision
+ }
+ switch {
+ case *decision.retryAfter < antigravityInstantRetryThreshold:
+ decision.kind = antigravity429DecisionInstantRetrySameAuth
+ case *decision.retryAfter < antigravityShortQuotaCooldownThreshold:
+ decision.kind = antigravity429DecisionShortCooldownSwitchAuth
+ default:
+ decision.kind = antigravity429DecisionFullQuotaExhausted
+ }
+ return decision
}
+ }
+ }
+
+ lowerBody := strings.ToLower(string(body))
+ for _, keyword := range antigravityQuotaExhaustedKeywords {
+ if strings.Contains(lowerBody, keyword) {
+ decision.kind = antigravity429DecisionFullQuotaExhausted
+ decision.reason = "quota_exhausted"
return decision
}
}
@@ -349,81 +401,10 @@ func decideAntigravity429(body []byte) antigravity429Decision {
return decision
}
-func antigravityHasQuotaResetDelayOrModelInfo(body []byte) bool {
- if len(body) == 0 {
- return false
- }
- details := gjson.GetBytes(body, "error.details")
- if !details.Exists() || !details.IsArray() {
- return false
- }
- for _, detail := range details.Array() {
- if detail.Get("@type").String() != "type.googleapis.com/google.rpc.ErrorInfo" {
- continue
- }
- if strings.TrimSpace(detail.Get("metadata.quotaResetDelay").String()) != "" {
- return true
- }
- if strings.TrimSpace(detail.Get("metadata.model").String()) != "" {
- return true
- }
- }
- return false
-}
-
func antigravityCreditsRetryEnabled(cfg *config.Config) bool {
return cfg != nil && cfg.QuotaExceeded.AntigravityCredits
}
-func antigravityCreditsFailureStateForAuth(auth *cliproxyauth.Auth) (string, antigravityCreditsFailureState, bool) {
- if auth == nil || strings.TrimSpace(auth.ID) == "" {
- return "", antigravityCreditsFailureState{}, false
- }
- authID := strings.TrimSpace(auth.ID)
- value, ok := antigravityCreditsFailureByAuth.Load(authID)
- if !ok {
- return authID, antigravityCreditsFailureState{}, true
- }
- state, ok := value.(antigravityCreditsFailureState)
- if !ok {
- antigravityCreditsFailureByAuth.Delete(authID)
- return authID, antigravityCreditsFailureState{}, true
- }
- return authID, state, true
-}
-
-func antigravityCreditsDisabled(auth *cliproxyauth.Auth, now time.Time) bool {
- authID, state, ok := antigravityCreditsFailureStateForAuth(auth)
- if !ok {
- return false
- }
- if state.PermanentlyDisabled {
- return true
- }
- if state.DisabledUntil.IsZero() {
- return false
- }
- if state.DisabledUntil.After(now) {
- return true
- }
- antigravityCreditsFailureByAuth.Delete(authID)
- return false
-}
-
-func recordAntigravityCreditsFailure(auth *cliproxyauth.Auth, now time.Time) {
- authID, state, ok := antigravityCreditsFailureStateForAuth(auth)
- if !ok {
- return
- }
- if state.PermanentlyDisabled {
- antigravityCreditsFailureByAuth.Store(authID, state)
- return
- }
- state.Count++
- state.DisabledUntil = now.Add(antigravityCreditsAutoDisableDuration)
- antigravityCreditsFailureByAuth.Store(authID, state)
-}
-
func clearAntigravityCreditsFailureState(auth *cliproxyauth.Auth) {
if auth == nil || strings.TrimSpace(auth.ID) == "" {
return
@@ -440,6 +421,25 @@ func markAntigravityCreditsPermanentlyDisabled(auth *cliproxyauth.Auth) {
ExplicitBalanceExhausted: true,
}
antigravityCreditsFailureByAuth.Store(authID, state)
+ antigravityCreditsBalanceByAuth.Store(authID, antigravityCreditsBalance{
+ CreditAmount: 0,
+ MinCreditAmount: 1,
+ Known: true,
+ })
+ cliproxyauth.SetAntigravityCreditsHint(authID, cliproxyauth.AntigravityCreditsHint{
+ Known: true,
+ Available: false,
+ CreditAmount: 0,
+ MinCreditAmount: 1,
+ UpdatedAt: time.Now(),
+ })
+}
+
+func clearAntigravityCreditsPermanentlyDisabled(auth *cliproxyauth.Auth) {
+ if auth == nil || strings.TrimSpace(auth.ID) == "" {
+ return
+ }
+ antigravityCreditsFailureByAuth.Delete(strings.TrimSpace(auth.ID))
}
func antigravityHasExplicitCreditsBalanceExhaustedReason(body []byte) bool {
@@ -462,81 +462,6 @@ func antigravityHasExplicitCreditsBalanceExhaustedReason(body []byte) bool {
return false
}
-func antigravityPreferCreditsKey(auth *cliproxyauth.Auth, modelName string) string {
- if auth == nil {
- return ""
- }
- authID := strings.TrimSpace(auth.ID)
- modelName = strings.TrimSpace(modelName)
- if authID == "" || modelName == "" {
- return ""
- }
- return authID + "|" + modelName
-}
-
-func antigravityShouldPreferCredits(auth *cliproxyauth.Auth, modelName string, now time.Time) bool {
- key := antigravityPreferCreditsKey(auth, modelName)
- if key == "" {
- return false
- }
- value, ok := antigravityPreferCreditsByModel.Load(key)
- if !ok {
- return false
- }
- until, ok := value.(time.Time)
- if !ok || until.IsZero() {
- antigravityPreferCreditsByModel.Delete(key)
- return false
- }
- if !until.After(now) {
- antigravityPreferCreditsByModel.Delete(key)
- return false
- }
- return true
-}
-
-func markAntigravityPreferCredits(auth *cliproxyauth.Auth, modelName string, now time.Time, retryAfter *time.Duration) {
- key := antigravityPreferCreditsKey(auth, modelName)
- if key == "" {
- return
- }
- until := now.Add(antigravityCreditsRetryTTL)
- if retryAfter != nil && *retryAfter > 0 {
- until = now.Add(*retryAfter)
- }
- antigravityPreferCreditsByModel.Store(key, until)
-}
-
-func clearAntigravityPreferCredits(auth *cliproxyauth.Auth, modelName string) {
- key := antigravityPreferCreditsKey(auth, modelName)
- if key == "" {
- return
- }
- antigravityPreferCreditsByModel.Delete(key)
-}
-
-func shouldMarkAntigravityCreditsExhausted(statusCode int, body []byte, reqErr error) bool {
- if reqErr != nil || statusCode == 0 {
- return false
- }
- if statusCode >= http.StatusInternalServerError || statusCode == http.StatusRequestTimeout {
- return false
- }
- lowerBody := strings.ToLower(string(body))
- for _, keyword := range antigravityCreditsExhaustedKeywords {
- if strings.Contains(lowerBody, keyword) {
- if keyword == "resource has been exhausted" &&
- statusCode == http.StatusTooManyRequests &&
- decideAntigravity429(body).kind == antigravity429DecisionSoftRetry &&
- !antigravityHasQuotaResetDelayOrModelInfo(body) {
- return false
- }
- return true
- }
- }
- return false
-}
-
func newAntigravityStatusErr(statusCode int, body []byte) statusErr {
err := statusErr{code: statusCode, msg: string(body)}
if statusCode == http.StatusTooManyRequests {
@@ -547,136 +472,13 @@ func newAntigravityStatusErr(statusCode int, body []byte) statusErr {
return err
}
-func (e *AntigravityExecutor) attemptCreditsFallback(
- ctx context.Context,
- auth *cliproxyauth.Auth,
- httpClient *http.Client,
- token string,
- modelName string,
- payload []byte,
- stream bool,
- alt string,
- baseURL string,
- originalBody []byte,
-) (*http.Response, bool) {
- if !antigravityCreditsRetryEnabled(e.cfg) {
- return nil, false
- }
- if decideAntigravity429(originalBody).kind != antigravity429DecisionFullQuotaExhausted {
- return nil, false
- }
- now := time.Now()
- if shouldForcePermanentDisableCredits(originalBody) {
- clearAntigravityPreferCredits(auth, modelName)
- markAntigravityCreditsPermanentlyDisabled(auth)
- return nil, false
- }
-
- if antigravityHasExplicitCreditsBalanceExhaustedReason(originalBody) {
- clearAntigravityPreferCredits(auth, modelName)
- markAntigravityCreditsPermanentlyDisabled(auth)
- return nil, false
- }
-
- if antigravityCreditsDisabled(auth, now) {
- return nil, false
- }
- creditsPayload := injectEnabledCreditTypes(payload)
- if len(creditsPayload) == 0 {
- return nil, false
- }
-
- httpReq, errReq := e.buildRequest(ctx, auth, token, modelName, creditsPayload, stream, alt, baseURL)
- if errReq != nil {
- helps.RecordAPIResponseError(ctx, e.cfg, errReq)
- clearAntigravityPreferCredits(auth, modelName)
- recordAntigravityCreditsFailure(auth, now)
- return nil, true
- }
- httpResp, errDo := httpClient.Do(httpReq)
- if errDo != nil {
- helps.RecordAPIResponseError(ctx, e.cfg, errDo)
- clearAntigravityPreferCredits(auth, modelName)
- recordAntigravityCreditsFailure(auth, now)
- return nil, true
- }
- if httpResp.StatusCode >= http.StatusOK && httpResp.StatusCode < http.StatusMultipleChoices {
- retryAfter, _ := parseRetryDelay(originalBody)
- markAntigravityPreferCredits(auth, modelName, now, retryAfter)
- clearAntigravityCreditsFailureState(auth)
- return httpResp, true
- }
-
- helps.RecordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())
- bodyBytes, errRead := io.ReadAll(httpResp.Body)
- if errClose := httpResp.Body.Close(); errClose != nil {
- log.Errorf("antigravity executor: close credits fallback response body error: %v", errClose)
- }
- if errRead != nil {
- helps.RecordAPIResponseError(ctx, e.cfg, errRead)
- clearAntigravityPreferCredits(auth, modelName)
- recordAntigravityCreditsFailure(auth, now)
- return nil, true
- }
- helps.AppendAPIResponseChunk(ctx, e.cfg, bodyBytes)
- if shouldForcePermanentDisableCredits(bodyBytes) {
- clearAntigravityPreferCredits(auth, modelName)
- markAntigravityCreditsPermanentlyDisabled(auth)
- return nil, true
- }
-
- if antigravityHasExplicitCreditsBalanceExhaustedReason(bodyBytes) {
- clearAntigravityPreferCredits(auth, modelName)
- markAntigravityCreditsPermanentlyDisabled(auth)
- return nil, true
- }
-
- clearAntigravityPreferCredits(auth, modelName)
- recordAntigravityCreditsFailure(auth, now)
- return nil, true
-}
-
-func (e *AntigravityExecutor) handleDirectCreditsFailure(ctx context.Context, auth *cliproxyauth.Auth, modelName string, reqErr error) {
- if reqErr != nil {
- if shouldForcePermanentDisableCredits(reqErrBody(reqErr)) {
- clearAntigravityPreferCredits(auth, modelName)
- markAntigravityCreditsPermanentlyDisabled(auth)
- return
- }
-
- if antigravityHasExplicitCreditsBalanceExhaustedReason(reqErrBody(reqErr)) {
- clearAntigravityPreferCredits(auth, modelName)
- markAntigravityCreditsPermanentlyDisabled(auth)
- return
- }
-
- helps.RecordAPIResponseError(ctx, e.cfg, reqErr)
- }
- clearAntigravityPreferCredits(auth, modelName)
- recordAntigravityCreditsFailure(auth, time.Now())
-}
-func reqErrBody(reqErr error) []byte {
- if reqErr == nil {
- return nil
- }
- msg := reqErr.Error()
- if strings.TrimSpace(msg) == "" {
- return nil
- }
- return []byte(msg)
-}
-
-func shouldForcePermanentDisableCredits(body []byte) bool {
- return antigravityHasExplicitCreditsBalanceExhaustedReason(body)
-}
-
// Execute performs a non-streaming request to the Antigravity API.
func (e *AntigravityExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) {
if opts.Alt == "responses/compact" {
return resp, statusErr{code: http.StatusNotImplemented, msg: "/responses/compact not supported"}
}
baseModel := thinking.ParseSuffix(req.Model).ModelName
- if inCooldown, remaining := antigravityIsInShortCooldown(auth, baseModel, time.Now()); inCooldown {
+ if inCooldown, remaining := antigravityIsInShortCooldown(auth, baseModel, time.Now()); inCooldown && !antigravityShouldBypassShortCooldown(ctx, e.cfg) {
log.Debugf("antigravity executor: auth %s in short cooldown for model %s (%s remaining), returning 429 to switch auth", auth.ID, baseModel, remaining)
d := remaining
return resp, statusErr{code: http.StatusTooManyRequests, msg: fmt.Sprintf("auth in short cooldown, %s remaining", remaining), retryAfter: &d}
@@ -719,7 +521,10 @@ func (e *AntigravityExecutor) Execute(ctx context.Context, auth *cliproxyauth.Au
}
requestedModel := helps.PayloadRequestedModel(opts, req.Model)
- translated = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, "antigravity", "request", translated, originalTranslated, requestedModel)
+ requestPath := helps.PayloadRequestPath(opts)
+ translated = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, "antigravity", "request", translated, originalTranslated, requestedModel, requestPath)
+
+ useCredits := cliproxyauth.AntigravityCreditsRequested(ctx) && antigravityCreditsRetryEnabled(e.cfg)
baseURLs := antigravityBaseURLFallbackOrder(auth)
httpClient := newAntigravityHTTPClient(ctx, e.cfg, auth, 0)
@@ -733,11 +538,10 @@ attemptLoop:
for idx, baseURL := range baseURLs {
requestPayload := translated
- usedCreditsDirect := false
- if antigravityCreditsRetryEnabled(e.cfg) && antigravityShouldPreferCredits(auth, baseModel, time.Now()) {
- if creditsPayload := injectEnabledCreditTypes(translated); len(creditsPayload) > 0 {
- requestPayload = creditsPayload
- usedCreditsDirect = true
+ if useCredits {
+ if cp := injectEnabledCreditTypes(translated); len(cp) > 0 {
+ requestPayload = cp
+ helps.MarkCreditsUsed(ctx)
}
}
@@ -785,7 +589,6 @@ attemptLoop:
wait := antigravityInstantRetryDelay(*decision.retryAfter)
log.Debugf("antigravity executor: instant retry for model %s, waiting %s", baseModel, wait)
if errWait := antigravityWait(ctx, wait); errWait != nil {
-
return resp, errWait
}
}
@@ -794,34 +597,13 @@ attemptLoop:
case antigravity429DecisionShortCooldownSwitchAuth:
if decision.retryAfter != nil && *decision.retryAfter > 0 {
markAntigravityShortCooldown(auth, baseModel, time.Now(), *decision.retryAfter)
- log.Debugf("antigravity executor: short quota cooldown (%s) for model %s, recorded cooldown and skipping credits fallback", *decision.retryAfter, baseModel)
+ log.Debugf("antigravity executor: short quota cooldown (%s) for model %s, recorded cooldown", *decision.retryAfter, baseModel)
}
case antigravity429DecisionFullQuotaExhausted:
- if usedCreditsDirect {
- clearAntigravityPreferCredits(auth, baseModel)
- recordAntigravityCreditsFailure(auth, time.Now())
- } else {
- creditsResp, _ := e.attemptCreditsFallback(ctx, auth, httpClient, token, baseModel, translated, false, opts.Alt, baseURL, bodyBytes)
- if creditsResp != nil {
- helps.RecordAPIResponseMetadata(ctx, e.cfg, creditsResp.StatusCode, creditsResp.Header.Clone())
- creditsBody, errCreditsRead := io.ReadAll(creditsResp.Body)
- if errClose := creditsResp.Body.Close(); errClose != nil {
- log.Errorf("antigravity executor: close credits success response body error: %v", errClose)
- }
- if errCreditsRead != nil {
- helps.RecordAPIResponseError(ctx, e.cfg, errCreditsRead)
- err = errCreditsRead
- return resp, err
- }
- helps.AppendAPIResponseChunk(ctx, e.cfg, creditsBody)
- reporter.Publish(ctx, helps.ParseAntigravityUsage(creditsBody))
- var param any
- converted := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, opts.OriginalRequest, translated, creditsBody, ¶m)
- resp = cliproxyexecutor.Response{Payload: converted, Headers: creditsResp.Header.Clone()}
- reporter.EnsurePublished(ctx)
- return resp, nil
- }
+ if useCredits && antigravityHasExplicitCreditsBalanceExhaustedReason(bodyBytes) {
+ markAntigravityCreditsPermanentlyDisabled(auth)
}
+ // No credits logic - just fall through to error return below
}
}
@@ -870,6 +652,10 @@ attemptLoop:
return resp, err
}
+ // Success
+ if useCredits {
+ clearAntigravityCreditsFailureState(auth)
+ }
reporter.Publish(ctx, helps.ParseAntigravityUsage(bodyBytes))
var param any
converted := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, opts.OriginalRequest, translated, bodyBytes, ¶m)
@@ -895,7 +681,7 @@ attemptLoop:
// executeClaudeNonStream performs a claude non-streaming request to the Antigravity API.
func (e *AntigravityExecutor) executeClaudeNonStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) {
baseModel := thinking.ParseSuffix(req.Model).ModelName
- if inCooldown, remaining := antigravityIsInShortCooldown(auth, baseModel, time.Now()); inCooldown {
+ if inCooldown, remaining := antigravityIsInShortCooldown(auth, baseModel, time.Now()); inCooldown && !antigravityShouldBypassShortCooldown(ctx, e.cfg) {
log.Debugf("antigravity executor: auth %s in short cooldown for model %s (%s remaining), returning 429 to switch auth", auth.ID, baseModel, remaining)
d := remaining
return resp, statusErr{code: http.StatusTooManyRequests, msg: fmt.Sprintf("auth in short cooldown, %s remaining", remaining), retryAfter: &d}
@@ -933,7 +719,10 @@ func (e *AntigravityExecutor) executeClaudeNonStream(ctx context.Context, auth *
}
requestedModel := helps.PayloadRequestedModel(opts, req.Model)
- translated = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, "antigravity", "request", translated, originalTranslated, requestedModel)
+ requestPath := helps.PayloadRequestPath(opts)
+ translated = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, "antigravity", "request", translated, originalTranslated, requestedModel, requestPath)
+
+ useCredits := cliproxyauth.AntigravityCreditsRequested(ctx) && antigravityCreditsRetryEnabled(e.cfg)
baseURLs := antigravityBaseURLFallbackOrder(auth)
httpClient := newAntigravityHTTPClient(ctx, e.cfg, auth, 0)
@@ -948,11 +737,10 @@ attemptLoop:
for idx, baseURL := range baseURLs {
requestPayload := translated
- usedCreditsDirect := false
- if antigravityCreditsRetryEnabled(e.cfg) && antigravityShouldPreferCredits(auth, baseModel, time.Now()) {
- if creditsPayload := injectEnabledCreditTypes(translated); len(creditsPayload) > 0 {
- requestPayload = creditsPayload
- usedCreditsDirect = true
+ if useCredits {
+ if cp := injectEnabledCreditTypes(translated); len(cp) > 0 {
+ requestPayload = cp
+ helps.MarkCreditsUsed(ctx)
}
}
httpReq, errReq := e.buildRequest(ctx, auth, token, baseModel, requestPayload, true, opts.Alt, baseURL)
@@ -1014,7 +802,6 @@ attemptLoop:
wait := antigravityInstantRetryDelay(*decision.retryAfter)
log.Debugf("antigravity executor: instant retry for model %s, waiting %s", baseModel, wait)
if errWait := antigravityWait(ctx, wait); errWait != nil {
-
return resp, errWait
}
}
@@ -1023,25 +810,16 @@ attemptLoop:
case antigravity429DecisionShortCooldownSwitchAuth:
if decision.retryAfter != nil && *decision.retryAfter > 0 {
markAntigravityShortCooldown(auth, baseModel, time.Now(), *decision.retryAfter)
- log.Debugf("antigravity executor: short quota cooldown (%s) for model %s, recorded cooldown and skipping credits fallback", *decision.retryAfter, baseModel)
+ log.Debugf("antigravity executor: short quota cooldown (%s) for model %s, recorded cooldown", *decision.retryAfter, baseModel)
}
case antigravity429DecisionFullQuotaExhausted:
- if usedCreditsDirect {
- clearAntigravityPreferCredits(auth, baseModel)
- recordAntigravityCreditsFailure(auth, time.Now())
- } else {
- creditsResp, _ := e.attemptCreditsFallback(ctx, auth, httpClient, token, baseModel, translated, true, opts.Alt, baseURL, bodyBytes)
- if creditsResp != nil {
- httpResp = creditsResp
- helps.RecordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())
- }
+ if useCredits && antigravityHasExplicitCreditsBalanceExhaustedReason(bodyBytes) {
+ markAntigravityCreditsPermanentlyDisabled(auth)
}
+ // No credits logic - just fall through to error return below
}
}
- if httpResp.StatusCode >= http.StatusOK && httpResp.StatusCode < http.StatusMultipleChoices {
- goto streamSuccessClaudeNonStream
- }
lastStatus = httpResp.StatusCode
lastBody = append([]byte(nil), bodyBytes...)
lastErr = nil
@@ -1085,7 +863,10 @@ attemptLoop:
return resp, err
}
- streamSuccessClaudeNonStream:
+ // Stream success
+ if useCredits {
+ clearAntigravityCreditsFailureState(auth)
+ }
out := make(chan cliproxyexecutor.StreamChunk)
go func(resp *http.Response) {
defer close(out)
@@ -1117,7 +898,7 @@ attemptLoop:
}
if errScan := scanner.Err(); errScan != nil {
helps.RecordAPIResponseError(ctx, e.cfg, errScan)
- reporter.PublishFailure(ctx)
+ reporter.PublishFailure(ctx, errScan)
out <- cliproxyexecutor.StreamChunk{Err: errScan}
} else {
reporter.EnsurePublished(ctx)
@@ -1360,7 +1141,7 @@ func (e *AntigravityExecutor) ExecuteStream(ctx context.Context, auth *cliproxya
baseModel := thinking.ParseSuffix(req.Model).ModelName
ctx = context.WithValue(ctx, "alt", "")
- if inCooldown, remaining := antigravityIsInShortCooldown(auth, baseModel, time.Now()); inCooldown {
+ if inCooldown, remaining := antigravityIsInShortCooldown(auth, baseModel, time.Now()); inCooldown && !antigravityShouldBypassShortCooldown(ctx, e.cfg) {
log.Debugf("antigravity executor: auth %s in short cooldown for model %s (%s remaining), returning 429 to switch auth", auth.ID, baseModel, remaining)
d := remaining
return nil, statusErr{code: http.StatusTooManyRequests, msg: fmt.Sprintf("auth in short cooldown, %s remaining", remaining), retryAfter: &d}
@@ -1389,6 +1170,7 @@ func (e *AntigravityExecutor) ExecuteStream(ctx context.Context, auth *cliproxya
if updatedAuth != nil {
auth = updatedAuth
}
+
originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, true)
translated := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, true)
@@ -1398,7 +1180,10 @@ func (e *AntigravityExecutor) ExecuteStream(ctx context.Context, auth *cliproxya
}
requestedModel := helps.PayloadRequestedModel(opts, req.Model)
- translated = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, "antigravity", "request", translated, originalTranslated, requestedModel)
+ requestPath := helps.PayloadRequestPath(opts)
+ translated = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, "antigravity", "request", translated, originalTranslated, requestedModel, requestPath)
+
+ useCredits := cliproxyauth.AntigravityCreditsRequested(ctx) && antigravityCreditsRetryEnabled(e.cfg)
baseURLs := antigravityBaseURLFallbackOrder(auth)
httpClient := newAntigravityHTTPClient(ctx, e.cfg, auth, 0)
@@ -1413,11 +1198,10 @@ attemptLoop:
for idx, baseURL := range baseURLs {
requestPayload := translated
- usedCreditsDirect := false
- if antigravityCreditsRetryEnabled(e.cfg) && antigravityShouldPreferCredits(auth, baseModel, time.Now()) {
- if creditsPayload := injectEnabledCreditTypes(translated); len(creditsPayload) > 0 {
- requestPayload = creditsPayload
- usedCreditsDirect = true
+ if useCredits {
+ if cp := injectEnabledCreditTypes(translated); len(cp) > 0 {
+ requestPayload = cp
+ helps.MarkCreditsUsed(ctx)
}
}
httpReq, errReq := e.buildRequest(ctx, auth, token, baseModel, requestPayload, true, opts.Alt, baseURL)
@@ -1478,7 +1262,6 @@ attemptLoop:
wait := antigravityInstantRetryDelay(*decision.retryAfter)
log.Debugf("antigravity executor: instant retry for model %s, waiting %s", baseModel, wait)
if errWait := antigravityWait(ctx, wait); errWait != nil {
-
return nil, errWait
}
}
@@ -1487,25 +1270,16 @@ attemptLoop:
case antigravity429DecisionShortCooldownSwitchAuth:
if decision.retryAfter != nil && *decision.retryAfter > 0 {
markAntigravityShortCooldown(auth, baseModel, time.Now(), *decision.retryAfter)
- log.Debugf("antigravity executor: short quota cooldown (%s) for model %s, recorded cooldown and skipping credits fallback", *decision.retryAfter, baseModel)
+ log.Debugf("antigravity executor: short quota cooldown (%s) for model %s recorded", *decision.retryAfter, baseModel)
}
case antigravity429DecisionFullQuotaExhausted:
- if usedCreditsDirect {
- clearAntigravityPreferCredits(auth, baseModel)
- recordAntigravityCreditsFailure(auth, time.Now())
- } else {
- creditsResp, _ := e.attemptCreditsFallback(ctx, auth, httpClient, token, baseModel, translated, true, opts.Alt, baseURL, bodyBytes)
- if creditsResp != nil {
- httpResp = creditsResp
- helps.RecordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())
- }
+ if useCredits && antigravityHasExplicitCreditsBalanceExhaustedReason(bodyBytes) {
+ markAntigravityCreditsPermanentlyDisabled(auth)
}
+ // No credits logic - just fall through to error return below
}
}
- if httpResp.StatusCode >= http.StatusOK && httpResp.StatusCode < http.StatusMultipleChoices {
- goto streamSuccessExecuteStream
- }
lastStatus = httpResp.StatusCode
lastBody = append([]byte(nil), bodyBytes...)
lastErr = nil
@@ -1549,7 +1323,10 @@ attemptLoop:
return nil, err
}
- streamSuccessExecuteStream:
+ // Stream success
+ if useCredits {
+ clearAntigravityCreditsFailureState(auth)
+ }
out := make(chan cliproxyexecutor.StreamChunk)
go func(resp *http.Response) {
defer close(out)
@@ -1580,17 +1357,28 @@ attemptLoop:
chunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, translated, bytes.Clone(payload), ¶m)
for i := range chunks {
- out <- cliproxyexecutor.StreamChunk{Payload: chunks[i]}
+ select {
+ case out <- cliproxyexecutor.StreamChunk{Payload: chunks[i]}:
+ case <-ctx.Done():
+ return
+ }
}
}
tail := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, translated, []byte("[DONE]"), ¶m)
for i := range tail {
- out <- cliproxyexecutor.StreamChunk{Payload: tail[i]}
+ select {
+ case out <- cliproxyexecutor.StreamChunk{Payload: tail[i]}:
+ case <-ctx.Done():
+ return
+ }
}
if errScan := scanner.Err(); errScan != nil {
helps.RecordAPIResponseError(ctx, e.cfg, errScan)
- reporter.PublishFailure(ctx)
- out <- cliproxyexecutor.StreamChunk{Err: errScan}
+ reporter.PublishFailure(ctx, errScan)
+ select {
+ case out <- cliproxyexecutor.StreamChunk{Err: errScan}:
+ case <-ctx.Done():
+ }
} else {
reporter.EnsurePublished(ctx)
}
@@ -1614,6 +1402,9 @@ attemptLoop:
// Refresh refreshes the authentication credentials using the refresh token.
func (e *AntigravityExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) {
+ if refreshed, handled, err := helps.RefreshAuthViaHome(ctx, e.cfg, auth); handled {
+ return refreshed, err
+ }
if auth == nil {
return auth, nil
}
@@ -1792,6 +1583,7 @@ func (e *AntigravityExecutor) ensureAccessToken(ctx context.Context, auth *clipr
accessToken := metaStringValue(auth.Metadata, "access_token")
expiry := tokenExpiry(auth.Metadata)
if accessToken != "" && expiry.After(time.Now().Add(refreshSkew)) {
+ e.maybeRefreshAntigravityCreditsHint(ctx, auth, accessToken)
return accessToken, nil, nil
}
refreshCtx := context.Background()
@@ -1800,6 +1592,18 @@ func (e *AntigravityExecutor) ensureAccessToken(ctx context.Context, auth *clipr
refreshCtx = context.WithValue(refreshCtx, "cliproxy.roundtripper", rt)
}
}
+ if refreshed, handled, err := helps.RefreshAuthViaHome(refreshCtx, e.cfg, auth); handled {
+ if err != nil {
+ return "", nil, err
+ }
+ token := metaStringValue(refreshed.Metadata, "access_token")
+ if strings.TrimSpace(token) == "" {
+ return "", nil, statusErr{code: http.StatusUnauthorized, msg: "missing access token"}
+ }
+ e.maybeRefreshAntigravityCreditsHint(ctx, refreshed, token)
+ return token, refreshed, nil
+ }
+
updated, errRefresh := e.refreshToken(refreshCtx, auth.Clone())
if errRefresh != nil {
return "", nil, errRefresh
@@ -1807,6 +1611,63 @@ func (e *AntigravityExecutor) ensureAccessToken(ctx context.Context, auth *clipr
return metaStringValue(updated.Metadata, "access_token"), updated, nil
}
+func (e *AntigravityExecutor) maybeRefreshAntigravityCreditsHint(ctx context.Context, auth *cliproxyauth.Auth, accessToken string) {
+ if e == nil || auth == nil || !antigravityCreditsRetryEnabled(e.cfg) {
+ return
+ }
+ if ctx != nil && ctx.Err() != nil {
+ return
+ }
+ authID := strings.TrimSpace(auth.ID)
+ if authID == "" {
+ return
+ }
+ if hint, ok := cliproxyauth.GetAntigravityCreditsHint(authID); ok && hint.Known {
+ return
+ }
+ if strings.TrimSpace(accessToken) == "" {
+ accessToken = metaStringValue(auth.Metadata, "access_token")
+ }
+ if strings.TrimSpace(accessToken) == "" {
+ return
+ }
+
+ state := &antigravityCreditsHintRefreshState{}
+ if existing, loaded := antigravityCreditsHintRefreshByID.LoadOrStore(authID, state); loaded {
+ if cast, ok := existing.(*antigravityCreditsHintRefreshState); ok && cast != nil {
+ state = cast
+ } else {
+ antigravityCreditsHintRefreshByID.Delete(authID)
+ antigravityCreditsHintRefreshByID.Store(authID, state)
+ }
+ }
+
+ now := time.Now()
+ if !state.mu.TryLock() {
+ return
+ }
+ if !state.lastAttempt.IsZero() && now.Sub(state.lastAttempt) < antigravityCreditsHintRefreshInterval {
+ state.mu.Unlock()
+ return
+ }
+ state.lastAttempt = now
+
+ 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)
+ }
+ }
+ refreshCtx, cancel := context.WithTimeout(refreshCtx, antigravityCreditsHintRefreshTimeout)
+ authCopy := auth.Clone()
+
+ go func(state *antigravityCreditsHintRefreshState, auth *cliproxyauth.Auth, token string) {
+ defer cancel()
+ defer state.mu.Unlock()
+ e.updateAntigravityCreditsBalance(refreshCtx, auth, token)
+ }(state, authCopy, accessToken)
+}
+
func (e *AntigravityExecutor) refreshToken(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) {
if auth == nil {
return nil, statusErr{code: http.StatusUnauthorized, msg: "missing auth"}
@@ -1882,6 +1743,7 @@ func (e *AntigravityExecutor) refreshToken(ctx context.Context, auth *cliproxyau
if errProject := e.ensureAntigravityProjectID(ctx, auth, tokenResp.AccessToken); errProject != nil {
log.Warnf("antigravity executor: ensure project id failed: %v", errProject)
}
+ e.updateAntigravityCreditsBalance(ctx, auth, tokenResp.AccessToken)
return auth, nil
}
@@ -1918,6 +1780,107 @@ func (e *AntigravityExecutor) ensureAntigravityProjectID(ctx context.Context, au
return nil
}
+func (e *AntigravityExecutor) updateAntigravityCreditsBalance(ctx context.Context, auth *cliproxyauth.Auth, accessToken string) {
+ if auth == nil || strings.TrimSpace(auth.ID) == "" {
+ return
+ }
+ token := strings.TrimSpace(accessToken)
+ if token == "" {
+ token = metaStringValue(auth.Metadata, "access_token")
+ }
+ if token == "" {
+ return
+ }
+
+ userAgent := resolveLoadCodeAssistUserAgent(auth)
+ loadReqBody, errMarshal := json.Marshal(map[string]any{
+ "metadata": map[string]string{
+ "ide_type": "ANTIGRAVITY",
+ "ide_version": misc.AntigravityVersionFromUserAgent(userAgent),
+ "ide_name": "antigravity",
+ },
+ })
+ if errMarshal != nil {
+ log.Debugf("antigravity executor: marshal loadCodeAssist request error: %v", errMarshal)
+ return
+ }
+ baseURL := buildBaseURL(auth)
+ endpointURL := strings.TrimSuffix(baseURL, "/") + "/v1internal:loadCodeAssist"
+ httpReq, errReq := http.NewRequestWithContext(ctx, http.MethodPost, endpointURL, bytes.NewReader(loadReqBody))
+ if errReq != nil {
+ log.Debugf("antigravity executor: create loadCodeAssist request error: %v", errReq)
+ return
+ }
+ httpReq.Header.Set("Authorization", "Bearer "+token)
+ httpReq.Header.Set("Content-Type", "application/json")
+ httpReq.Header.Set("User-Agent", userAgent)
+ httpReq.Header.Set("X-Goog-Api-Client", misc.AntigravityGoogAPIClientUA)
+
+ httpClient := newAntigravityHTTPClient(ctx, e.cfg, auth, 0)
+ httpResp, errDo := httpClient.Do(httpReq)
+ if errDo != nil {
+ log.Debugf("antigravity executor: loadCodeAssist request error: %v", errDo)
+ return
+ }
+ defer func() {
+ if errClose := httpResp.Body.Close(); errClose != nil {
+ log.Errorf("antigravity executor: close loadCodeAssist response body error: %v", errClose)
+ }
+ }()
+
+ bodyBytes, errRead := io.ReadAll(httpResp.Body)
+ if errRead != nil || httpResp.StatusCode < http.StatusOK || httpResp.StatusCode >= http.StatusMultipleChoices {
+ log.Debugf("antigravity executor: loadCodeAssist returned status %d, err=%v", httpResp.StatusCode, errRead)
+ return
+ }
+
+ authID := strings.TrimSpace(auth.ID)
+ paidTierID := strings.TrimSpace(gjson.GetBytes(bodyBytes, "paidTier.id").String())
+
+ credits := gjson.GetBytes(bodyBytes, "paidTier.availableCredits")
+ if !credits.IsArray() {
+ cliproxyauth.SetAntigravityCreditsHint(authID, cliproxyauth.AntigravityCreditsHint{
+ Known: true,
+ Available: false,
+ PaidTierID: paidTierID,
+ UpdatedAt: time.Now(),
+ })
+ return
+ }
+ for _, credit := range credits.Array() {
+ if !strings.EqualFold(credit.Get("creditType").String(), "GOOGLE_ONE_AI") {
+ continue
+ }
+ creditAmount, errCA := strconv.ParseFloat(strings.TrimSpace(credit.Get("creditAmount").String()), 64)
+ if errCA != nil {
+ continue
+ }
+ minAmount, errMA := strconv.ParseFloat(strings.TrimSpace(credit.Get("minimumCreditAmountForUsage").String()), 64)
+ if errMA != nil {
+ continue
+ }
+ bal := antigravityCreditsBalance{
+ CreditAmount: creditAmount,
+ MinCreditAmount: minAmount,
+ PaidTierID: paidTierID,
+ Known: true,
+ }
+ antigravityCreditsBalanceByAuth.Store(authID, bal)
+ cliproxyauth.SetAntigravityCreditsHint(authID, cliproxyauth.AntigravityCreditsHint{
+ Known: true,
+ Available: creditAmount >= minAmount,
+ CreditAmount: creditAmount,
+ MinCreditAmount: minAmount,
+ PaidTierID: paidTierID,
+ UpdatedAt: time.Now(),
+ })
+ if creditAmount >= minAmount {
+ clearAntigravityCreditsPermanentlyDisabled(auth)
+ }
+ return
+ }
+}
+
func (e *AntigravityExecutor) buildRequest(ctx context.Context, auth *cliproxyauth.Auth, token, modelName string, payload []byte, stream bool, alt, baseURL string) (*http.Request, error) {
if token == "" {
return nil, statusErr{code: http.StatusUnauthorized, msg: "missing access token"}
@@ -2149,19 +2112,28 @@ func resolveHost(base string) string {
}
func resolveUserAgent(auth *cliproxyauth.Auth) string {
+ return misc.AntigravityRequestUserAgent(antigravityConfiguredUserAgent(auth))
+}
+
+func resolveLoadCodeAssistUserAgent(auth *cliproxyauth.Auth) string {
+ return misc.AntigravityLoadCodeAssistUserAgent(antigravityConfiguredUserAgent(auth))
+}
+
+func antigravityConfiguredUserAgent(auth *cliproxyauth.Auth) string {
+ raw := ""
if auth != nil {
if auth.Attributes != nil {
if ua := strings.TrimSpace(auth.Attributes["user_agent"]); ua != "" {
- return ua
+ raw = ua
}
}
- if auth.Metadata != nil {
+ if raw == "" && auth.Metadata != nil {
if ua, ok := auth.Metadata["user_agent"].(string); ok && strings.TrimSpace(ua) != "" {
- return strings.TrimSpace(ua)
+ raw = strings.TrimSpace(ua)
}
}
}
- return misc.AntigravityUserAgent()
+ return raw
}
func antigravityRetryAttempts(auth *cliproxyauth.Auth, cfg *config.Config) int {
@@ -2220,6 +2192,10 @@ func antigravityShouldRetrySoftRateLimit(statusCode int, body []byte) bool {
return decideAntigravity429(body).kind == antigravity429DecisionSoftRetry
}
+func antigravityShouldBypassShortCooldown(ctx context.Context, cfg *config.Config) bool {
+ return cliproxyauth.AntigravityCreditsRequested(ctx) && antigravityCreditsRetryEnabled(cfg)
+}
+
func antigravitySoftRateLimitDelay(attempt int) time.Duration {
if attempt < 0 {
attempt = 0
@@ -2321,9 +2297,9 @@ var antigravityBaseURLFallbackOrder = func(auth *cliproxyauth.Auth) []string {
return []string{base}
}
return []string{
- antigravityBaseURLProd,
antigravityBaseURLDaily,
- antigravitySandboxBaseURLDaily,
+ antigravityBaseURLProd,
+ // antigravitySandboxBaseURLDaily,
}
}
diff --git a/internal/runtime/executor/antigravity_executor_buildrequest_test.go b/internal/runtime/executor/antigravity_executor_buildrequest_test.go
index ed2d79e632..f0711752e4 100644
--- a/internal/runtime/executor/antigravity_executor_buildrequest_test.go
+++ b/internal/runtime/executor/antigravity_executor_buildrequest_test.go
@@ -6,7 +6,7 @@ import (
"io"
"testing"
- cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
+ cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
)
func TestAntigravityBuildRequest_SanitizesGeminiToolSchema(t *testing.T) {
diff --git a/internal/runtime/executor/antigravity_executor_credits_test.go b/internal/runtime/executor/antigravity_executor_credits_test.go
index cf968ac794..e16e64434f 100644
--- a/internal/runtime/executor/antigravity_executor_credits_test.go
+++ b/internal/runtime/executor/antigravity_executor_credits_test.go
@@ -10,16 +10,17 @@ import (
"testing"
"time"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
- 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"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
+ cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
+ sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
)
func resetAntigravityCreditsRetryState() {
antigravityCreditsFailureByAuth = sync.Map{}
- antigravityPreferCreditsByModel = sync.Map{}
antigravityShortCooldownByAuth = sync.Map{}
+ antigravityCreditsBalanceByAuth = sync.Map{}
+ antigravityCreditsHintRefreshByID = sync.Map{}
}
func TestClassifyAntigravity429(t *testing.T) {
@@ -30,6 +31,43 @@ func TestClassifyAntigravity429(t *testing.T) {
}
})
+ t.Run("standard antigravity rate limit with ui message stays rate limited", func(t *testing.T) {
+ body := []byte(`{
+ "error": {
+ "code": 429,
+ "message": "You have exhausted your capacity on this model. Your quota will reset after 0s.",
+ "status": "RESOURCE_EXHAUSTED",
+ "details": [
+ {
+ "@type": "type.googleapis.com/google.rpc.ErrorInfo",
+ "reason": "RATE_LIMIT_EXCEEDED",
+ "domain": "cloudcode-pa.googleapis.com",
+ "metadata": {
+ "model": "claude-opus-4-6-thinking",
+ "quotaResetDelay": "479.417207ms",
+ "quotaResetTimeStamp": "2026-04-20T09:19:49Z",
+ "uiMessage": "true"
+ }
+ },
+ {
+ "@type": "type.googleapis.com/google.rpc.RetryInfo",
+ "retryDelay": "0.479417207s"
+ }
+ ]
+ }
+ }`)
+ if got := classifyAntigravity429(body); got != antigravity429RateLimited {
+ t.Fatalf("classifyAntigravity429() = %q, want %q", got, antigravity429RateLimited)
+ }
+ decision := decideAntigravity429(body)
+ if decision.kind != antigravity429DecisionInstantRetrySameAuth {
+ t.Fatalf("decideAntigravity429().kind = %q, want %q", decision.kind, antigravity429DecisionInstantRetrySameAuth)
+ }
+ if decision.retryAfter == nil {
+ t.Fatal("decideAntigravity429().retryAfter = nil")
+ }
+ })
+
t.Run("structured rate limit", func(t *testing.T) {
body := []byte(`{
"error": {
@@ -67,8 +105,31 @@ func TestClassifyAntigravity429(t *testing.T) {
})
}
+func TestAntigravityShouldRetryNoCapacity_Standard503(t *testing.T) {
+ body := []byte(`{
+ "error": {
+ "code": 503,
+ "message": "No capacity available for model gemini-3.1-flash-image on the server",
+ "status": "UNAVAILABLE",
+ "details": [
+ {
+ "@type": "type.googleapis.com/google.rpc.ErrorInfo",
+ "reason": "MODEL_CAPACITY_EXHAUSTED",
+ "domain": "cloudcode-pa.googleapis.com",
+ "metadata": {
+ "model": "gemini-3.1-flash-image"
+ }
+ }
+ ]
+ }
+ }`)
+ if !antigravityShouldRetryNoCapacity(http.StatusServiceUnavailable, body) {
+ t.Fatal("antigravityShouldRetryNoCapacity() = false, want true")
+ }
+}
+
func TestInjectEnabledCreditTypes(t *testing.T) {
- body := []byte(`{"model":"gemini-2.5-flash","request":{}}`)
+ body := []byte(`{"model":"claude-sonnet-4-6","request":{}}`)
got := injectEnabledCreditTypes(body)
if got == nil {
t.Fatal("injectEnabledCreditTypes() returned nil")
@@ -82,34 +143,18 @@ func TestInjectEnabledCreditTypes(t *testing.T) {
}
}
-func TestShouldMarkAntigravityCreditsExhausted(t *testing.T) {
- t.Run("credit errors are marked", func(t *testing.T) {
- for _, body := range [][]byte{
- []byte(`{"error":{"message":"Insufficient GOOGLE_ONE_AI credits"}}`),
- []byte(`{"error":{"message":"minimumCreditAmountForUsage requirement not met"}}`),
- } {
- if !shouldMarkAntigravityCreditsExhausted(http.StatusForbidden, body, nil) {
- t.Fatalf("shouldMarkAntigravityCreditsExhausted(%s) = false, want true", string(body))
- }
- }
- })
-
- t.Run("transient 429 resource exhausted is not marked", func(t *testing.T) {
- body := []byte(`{"error":{"code":429,"message":"Resource has been exhausted (e.g. check quota).","status":"RESOURCE_EXHAUSTED"}}`)
- if shouldMarkAntigravityCreditsExhausted(http.StatusTooManyRequests, body, nil) {
- t.Fatalf("shouldMarkAntigravityCreditsExhausted(%s) = true, want false", string(body))
- }
- })
-
- t.Run("resource exhausted with quota metadata is still marked", func(t *testing.T) {
- body := []byte(`{"error":{"code":429,"message":"Resource has been exhausted","status":"RESOURCE_EXHAUSTED","details":[{"@type":"type.googleapis.com/google.rpc.ErrorInfo","metadata":{"quotaResetDelay":"1h","model":"claude-sonnet-4-6"}}]}}`)
- if !shouldMarkAntigravityCreditsExhausted(http.StatusTooManyRequests, body, nil) {
- t.Fatalf("shouldMarkAntigravityCreditsExhausted(%s) = false, want true", string(body))
- }
- })
-
- if shouldMarkAntigravityCreditsExhausted(http.StatusServiceUnavailable, []byte(`{"error":{"message":"credits exhausted"}}`), nil) {
- t.Fatal("shouldMarkAntigravityCreditsExhausted() = true for 5xx, want false")
+func TestParseRetryDelay_HumanReadableDuration(t *testing.T) {
+ body := []byte(`{"error":{"message":"You have exhausted your capacity on this model. Your quota will reset after 1h43m56s."}}`)
+ retryAfter, err := parseRetryDelay(body)
+ if err != nil {
+ t.Fatalf("parseRetryDelay() error = %v", err)
+ }
+ if retryAfter == nil {
+ t.Fatal("parseRetryDelay() returned nil")
+ }
+ want := time.Hour + 43*time.Minute + 56*time.Second
+ if *retryAfter != want {
+ t.Fatalf("parseRetryDelay() = %v, want %v", *retryAfter, want)
}
}
@@ -147,7 +192,7 @@ func TestAntigravityExecute_RetriesTransient429ResourceExhausted(t *testing.T) {
}
resp, err := exec.Execute(context.Background(), auth, cliproxyexecutor.Request{
- Model: "gemini-2.5-flash",
+ Model: "claude-sonnet-4-6",
Payload: []byte(`{"request":{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}}`),
}, cliproxyexecutor.Options{
SourceFormat: sdktranslator.FormatAntigravity,
@@ -163,32 +208,23 @@ func TestAntigravityExecute_RetriesTransient429ResourceExhausted(t *testing.T) {
}
}
-func TestAntigravityExecute_RetriesQuotaExhaustedWithCredits(t *testing.T) {
+func TestAntigravityExecute_CreditsInjectedWhenConductorRequests(t *testing.T) {
resetAntigravityCreditsRetryState()
t.Cleanup(resetAntigravityCreditsRetryState)
- var (
- mu sync.Mutex
- requestBodies []string
- )
-
+ var requestBodies []string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
_ = r.Body.Close()
-
- mu.Lock()
- requestBodies = append(requestBodies, string(body))
- reqNum := len(requestBodies)
- mu.Unlock()
-
- if reqNum == 1 {
- w.WriteHeader(http.StatusTooManyRequests)
- _, _ = w.Write([]byte(`{"error":{"status":"RESOURCE_EXHAUSTED","message":"QUOTA_EXHAUSTED"}}`))
+ if r.URL.Path == "/v1internal:loadCodeAssist" {
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"paidTier":{"id":"tier-1","availableCredits":[{"creditType":"GOOGLE_ONE_AI","creditAmount":"25000","minimumCreditAmountForUsage":"50"}]}}`))
return
}
+ requestBodies = append(requestBodies, string(body))
if !strings.Contains(string(body), `"enabledCreditTypes":["GOOGLE_ONE_AI"]`) {
- t.Fatalf("second request body missing enabledCreditTypes: %s", string(body))
+ t.Fatalf("request body missing enabledCreditTypes: %s", string(body))
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"response":{"candidates":[{"content":{"role":"model","parts":[{"text":"ok"}]}}],"usageMetadata":{"promptTokenCount":1,"candidatesTokenCount":1,"totalTokenCount":2}}}`))
@@ -199,7 +235,7 @@ func TestAntigravityExecute_RetriesQuotaExhaustedWithCredits(t *testing.T) {
QuotaExceeded: config.QuotaExceeded{AntigravityCredits: true},
})
auth := &cliproxyauth.Auth{
- ID: "auth-credits-ok",
+ ID: "auth-credits-conductor",
Attributes: map[string]string{
"base_url": server.URL,
},
@@ -210,8 +246,11 @@ func TestAntigravityExecute_RetriesQuotaExhaustedWithCredits(t *testing.T) {
},
}
- resp, err := exec.Execute(context.Background(), auth, cliproxyexecutor.Request{
- Model: "gemini-2.5-flash",
+ // Simulate conductor setting credits requested flag in context
+ ctx := cliproxyauth.WithAntigravityCredits(context.Background())
+
+ resp, err := exec.Execute(ctx, auth, cliproxyexecutor.Request{
+ Model: "claude-sonnet-4-6",
Payload: []byte(`{"request":{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}}`),
}, cliproxyexecutor.Options{
SourceFormat: sdktranslator.FormatAntigravity,
@@ -222,21 +261,25 @@ func TestAntigravityExecute_RetriesQuotaExhaustedWithCredits(t *testing.T) {
if len(resp.Payload) == 0 {
t.Fatal("Execute() returned empty payload")
}
-
- mu.Lock()
- defer mu.Unlock()
- if len(requestBodies) != 2 {
- t.Fatalf("request count = %d, want 2", len(requestBodies))
+ if len(requestBodies) != 1 {
+ t.Fatalf("request count = %d, want 1", len(requestBodies))
}
}
-func TestAntigravityExecute_SkipsCreditsRetryWhenAlreadyExhausted(t *testing.T) {
+func TestAntigravityExecute_NoCreditsWithoutConductorFlag(t *testing.T) {
resetAntigravityCreditsRetryState()
t.Cleanup(resetAntigravityCreditsRetryState)
- var requestCount int
+ var requestBodies []string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- requestCount++
+ body, _ := io.ReadAll(r.Body)
+ _ = r.Body.Close()
+ if r.URL.Path == "/v1internal:loadCodeAssist" {
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"paidTier":{"id":"tier-1","availableCredits":[{"creditType":"GOOGLE_ONE_AI","creditAmount":"25000","minimumCreditAmountForUsage":"50"}]}}`))
+ return
+ }
+ requestBodies = append(requestBodies, string(body))
w.WriteHeader(http.StatusTooManyRequests)
_, _ = w.Write([]byte(`{"error":{"status":"RESOURCE_EXHAUSTED","message":"QUOTA_EXHAUSTED"}}`))
}))
@@ -246,7 +289,7 @@ func TestAntigravityExecute_SkipsCreditsRetryWhenAlreadyExhausted(t *testing.T)
QuotaExceeded: config.QuotaExceeded{AntigravityCredits: true},
})
auth := &cliproxyauth.Auth{
- ID: "auth-credits-exhausted",
+ ID: "auth-no-conductor-flag",
Attributes: map[string]string{
"base_url": server.URL,
},
@@ -256,10 +299,10 @@ func TestAntigravityExecute_SkipsCreditsRetryWhenAlreadyExhausted(t *testing.T)
"expired": time.Now().Add(1 * time.Hour).Format(time.RFC3339),
},
}
- recordAntigravityCreditsFailure(auth, time.Now())
+ // No conductor credits flag set in context
_, err := exec.Execute(context.Background(), auth, cliproxyexecutor.Request{
- Model: "gemini-2.5-flash",
+ Model: "claude-sonnet-4-6",
Payload: []byte(`{"request":{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}}`),
}, cliproxyexecutor.Options{
SourceFormat: sdktranslator.FormatAntigravity,
@@ -267,224 +310,194 @@ func TestAntigravityExecute_SkipsCreditsRetryWhenAlreadyExhausted(t *testing.T)
if err == nil {
t.Fatal("Execute() error = nil, want 429")
}
- sErr, ok := err.(statusErr)
- if !ok {
- t.Fatalf("Execute() error type = %T, want statusErr", err)
- }
- if got := sErr.StatusCode(); got != http.StatusTooManyRequests {
- t.Fatalf("Execute() status code = %d, want %d", got, http.StatusTooManyRequests)
+ if len(requestBodies) != 1 {
+ t.Fatalf("request count = %d, want 1", len(requestBodies))
}
- if requestCount != 1 {
- t.Fatalf("request count = %d, want 1", requestCount)
+ // Should NOT contain credits since conductor didn't request them
+ if strings.Contains(requestBodies[0], `"enabledCreditTypes"`) {
+ t.Fatalf("request should not contain enabledCreditTypes without conductor flag: %s", requestBodies[0])
}
}
-func TestAntigravityExecute_PrefersCreditsAfterSuccessfulFallback(t *testing.T) {
- resetAntigravityCreditsRetryState()
- t.Cleanup(resetAntigravityCreditsRetryState)
-
- var (
- mu sync.Mutex
- requestBodies []string
- )
+func TestAntigravityAuthHasCredits(t *testing.T) {
+ t.Run("sufficient balance", func(t *testing.T) {
+ resetAntigravityCreditsRetryState()
+ auth := &cliproxyauth.Auth{ID: "test-sufficient"}
+ antigravityCreditsBalanceByAuth.Store("test-sufficient", antigravityCreditsBalance{
+ CreditAmount: 25000,
+ MinCreditAmount: 50,
+ Known: true,
+ })
+ if !antigravityAuthHasCredits(auth) {
+ t.Fatal("antigravityAuthHasCredits() = false, want true")
+ }
+ })
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- body, _ := io.ReadAll(r.Body)
- _ = r.Body.Close()
+ t.Run("insufficient balance", func(t *testing.T) {
+ resetAntigravityCreditsRetryState()
+ auth := &cliproxyauth.Auth{ID: "test-insufficient"}
+ antigravityCreditsBalanceByAuth.Store("test-insufficient", antigravityCreditsBalance{
+ CreditAmount: 30,
+ MinCreditAmount: 50,
+ Known: true,
+ })
+ if antigravityAuthHasCredits(auth) {
+ t.Fatal("antigravityAuthHasCredits() = true, want false")
+ }
+ })
- mu.Lock()
- requestBodies = append(requestBodies, string(body))
- reqNum := len(requestBodies)
- mu.Unlock()
+ t.Run("no balance stored returns true (optimistic)", func(t *testing.T) {
+ resetAntigravityCreditsRetryState()
+ auth := &cliproxyauth.Auth{ID: "test-no-balance"}
+ if !antigravityAuthHasCredits(auth) {
+ t.Fatal("antigravityAuthHasCredits() = false with no balance stored, want true (optimistic default)")
+ }
+ })
- switch reqNum {
- case 1:
- w.WriteHeader(http.StatusTooManyRequests)
- _, _ = w.Write([]byte(`{"error":{"status":"RESOURCE_EXHAUSTED","details":[{"@type":"type.googleapis.com/google.rpc.ErrorInfo","reason":"QUOTA_EXHAUSTED"},{"@type":"type.googleapis.com/google.rpc.RetryInfo","retryDelay":"10s"}]}}`))
- case 2, 3:
- if !strings.Contains(string(body), `"enabledCreditTypes":["GOOGLE_ONE_AI"]`) {
- t.Fatalf("request %d body missing enabledCreditTypes: %s", reqNum, string(body))
- }
- w.Header().Set("Content-Type", "application/json")
- _, _ = w.Write([]byte(`{"response":{"candidates":[{"content":{"role":"model","parts":[{"text":"OK"}]}}],"usageMetadata":{"promptTokenCount":1,"candidatesTokenCount":1,"totalTokenCount":2}}}`))
- default:
- t.Fatalf("unexpected request count %d", reqNum)
+ t.Run("nil auth returns false", func(t *testing.T) {
+ if antigravityAuthHasCredits(nil) {
+ t.Fatal("antigravityAuthHasCredits(nil) = true, want false")
}
- }))
- defer server.Close()
+ })
- exec := NewAntigravityExecutor(&config.Config{
- QuotaExceeded: config.QuotaExceeded{AntigravityCredits: true},
+ t.Run("empty ID returns false", func(t *testing.T) {
+ auth := &cliproxyauth.Auth{}
+ if antigravityAuthHasCredits(auth) {
+ t.Fatal("antigravityAuthHasCredits(empty ID) = true, want false")
+ }
})
- auth := &cliproxyauth.Auth{
- ID: "auth-prefer-credits",
- Attributes: map[string]string{
- "base_url": server.URL,
- },
- Metadata: map[string]any{
- "access_token": "token",
- "project_id": "project-1",
- "expired": time.Now().Add(1 * time.Hour).Format(time.RFC3339),
- },
- }
- request := cliproxyexecutor.Request{
- Model: "gemini-2.5-flash",
- Payload: []byte(`{"request":{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}}`),
- }
- opts := cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatAntigravity}
+ t.Run("unknown balance returns false", func(t *testing.T) {
+ resetAntigravityCreditsRetryState()
+ auth := &cliproxyauth.Auth{ID: "test-unknown"}
+ antigravityCreditsBalanceByAuth.Store("test-unknown", antigravityCreditsBalance{
+ Known: false,
+ })
+ if antigravityAuthHasCredits(auth) {
+ t.Fatal("antigravityAuthHasCredits() = true for unknown balance, want false")
+ }
+ })
+}
- if _, err := exec.Execute(context.Background(), auth, request, opts); err != nil {
- t.Fatalf("first Execute() error = %v", err)
- }
- if _, err := exec.Execute(context.Background(), auth, request, opts); err != nil {
- t.Fatalf("second Execute() error = %v", err)
- }
+type roundTripperFunc func(*http.Request) (*http.Response, error)
- mu.Lock()
- defer mu.Unlock()
- if len(requestBodies) != 3 {
- t.Fatalf("request count = %d, want 3", len(requestBodies))
- }
- if strings.Contains(requestBodies[0], `"enabledCreditTypes":["GOOGLE_ONE_AI"]`) {
- t.Fatalf("first request unexpectedly used credits: %s", requestBodies[0])
- }
- if !strings.Contains(requestBodies[1], `"enabledCreditTypes":["GOOGLE_ONE_AI"]`) {
- t.Fatalf("fallback request missing credits: %s", requestBodies[1])
- }
- if !strings.Contains(requestBodies[2], `"enabledCreditTypes":["GOOGLE_ONE_AI"]`) {
- t.Fatalf("preferred request missing credits: %s", requestBodies[2])
- }
+func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) {
+ return f(req)
}
-func TestAntigravityExecute_PreservesBaseURLFallbackAfterCreditsRetryFailure(t *testing.T) {
+func TestEnsureAccessToken_WarmTokenLoadsCreditsHint(t *testing.T) {
resetAntigravityCreditsRetryState()
t.Cleanup(resetAntigravityCreditsRetryState)
- var (
- mu sync.Mutex
- firstCount int
- secondCount int
- )
-
- firstServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- body, _ := io.ReadAll(r.Body)
- _ = r.Body.Close()
-
- mu.Lock()
- firstCount++
- reqNum := firstCount
- mu.Unlock()
-
- switch reqNum {
- case 1:
- w.WriteHeader(http.StatusTooManyRequests)
- _, _ = w.Write([]byte(`{"error":{"status":"RESOURCE_EXHAUSTED","details":[{"@type":"type.googleapis.com/google.rpc.ErrorInfo","reason":"QUOTA_EXHAUSTED"}]}}`))
- case 2:
- if !strings.Contains(string(body), `"enabledCreditTypes":["GOOGLE_ONE_AI"]`) {
- t.Fatalf("credits retry missing enabledCreditTypes: %s", string(body))
- }
- w.WriteHeader(http.StatusForbidden)
- _, _ = w.Write([]byte(`{"error":{"message":"permission denied"}}`))
- default:
- t.Fatalf("unexpected first server request count %d", reqNum)
- }
- }))
- defer firstServer.Close()
-
- secondServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- mu.Lock()
- secondCount++
- mu.Unlock()
- w.Header().Set("Content-Type", "application/json")
- _, _ = w.Write([]byte(`{"response":{"candidates":[{"content":{"role":"model","parts":[{"text":"ok"}]}}],"usageMetadata":{"promptTokenCount":1,"candidatesTokenCount":1,"totalTokenCount":2}}}`))
- }))
- defer secondServer.Close()
-
exec := NewAntigravityExecutor(&config.Config{
QuotaExceeded: config.QuotaExceeded{AntigravityCredits: true},
})
auth := &cliproxyauth.Auth{
- ID: "auth-baseurl-fallback",
- Attributes: map[string]string{
- "base_url": firstServer.URL,
- },
+ ID: "auth-warm-token-credits",
Metadata: map[string]any{
"access_token": "token",
- "project_id": "project-1",
"expired": time.Now().Add(1 * time.Hour).Format(time.RFC3339),
},
}
+ ctx := context.WithValue(context.Background(), "cliproxy.roundtripper", roundTripperFunc(func(req *http.Request) (*http.Response, error) {
+ if req.URL.String() != "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist" {
+ t.Fatalf("unexpected request url %s", req.URL.String())
+ }
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Header: make(http.Header),
+ Body: io.NopCloser(strings.NewReader(`{"paidTier":{"id":"tier-1","availableCredits":[{"creditType":"GOOGLE_ONE_AI","creditAmount":"25000","minimumCreditAmountForUsage":"50"}]}}`)),
+ }, nil
+ }))
- originalOrder := antigravityBaseURLFallbackOrder
- defer func() { antigravityBaseURLFallbackOrder = originalOrder }()
- antigravityBaseURLFallbackOrder = func(auth *cliproxyauth.Auth) []string {
- return []string{firstServer.URL, secondServer.URL}
- }
-
- resp, err := exec.Execute(context.Background(), auth, cliproxyexecutor.Request{
- Model: "gemini-2.5-flash",
- Payload: []byte(`{"request":{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}}`),
- }, cliproxyexecutor.Options{
- SourceFormat: sdktranslator.FormatAntigravity,
- })
+ token, updatedAuth, err := exec.ensureAccessToken(ctx, auth)
if err != nil {
- t.Fatalf("Execute() error = %v", err)
+ t.Fatalf("ensureAccessToken() error = %v", err)
}
- if len(resp.Payload) == 0 {
- t.Fatal("Execute() returned empty payload")
+ if token != "token" {
+ t.Fatalf("ensureAccessToken() token = %q, want %q", token, "token")
+ }
+ if updatedAuth != nil {
+ t.Fatalf("ensureAccessToken() updatedAuth = %v, want nil", updatedAuth)
}
- if firstCount != 2 {
- t.Fatalf("first server request count = %d, want 2", firstCount)
+ deadline := time.Now().Add(2 * time.Second)
+ for time.Now().Before(deadline) && !cliproxyauth.HasKnownAntigravityCreditsHint(auth.ID) {
+ time.Sleep(10 * time.Millisecond)
}
- if secondCount != 1 {
- t.Fatalf("second server request count = %d, want 1", secondCount)
+ if !cliproxyauth.HasKnownAntigravityCreditsHint(auth.ID) {
+ t.Fatal("expected credits hint to be populated for warm token auth")
+ }
+ hint, ok := cliproxyauth.GetAntigravityCreditsHint(auth.ID)
+ if !ok {
+ t.Fatal("expected credits hint lookup to succeed")
+ }
+ if !hint.Available {
+ t.Fatalf("hint.Available = %v, want true", hint.Available)
+ }
+ if hint.CreditAmount != 25000 || hint.MinCreditAmount != 50 {
+ t.Fatalf("hint amounts = (%v, %v), want (25000, 50)", hint.CreditAmount, hint.MinCreditAmount)
}
}
-func TestAntigravityExecute_DoesNotDirectInjectCreditsWhenFlagDisabled(t *testing.T) {
+func TestUpdateAntigravityCreditsBalance_LoadCodeAssistUserAgent(t *testing.T) {
resetAntigravityCreditsRetryState()
t.Cleanup(resetAntigravityCreditsRetryState)
- var requestBodies []string
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- body, _ := io.ReadAll(r.Body)
- _ = r.Body.Close()
- requestBodies = append(requestBodies, string(body))
- w.WriteHeader(http.StatusTooManyRequests)
- _, _ = w.Write([]byte(`{"error":{"status":"RESOURCE_EXHAUSTED","message":"QUOTA_EXHAUSTED"}}`))
- }))
- defer server.Close()
-
- exec := NewAntigravityExecutor(&config.Config{
- QuotaExceeded: config.QuotaExceeded{AntigravityCredits: false},
- })
+ exec := NewAntigravityExecutor(&config.Config{})
+ const userAgent = "antigravity/1.23.2 windows/amd64 google-api-nodejs-client/10.3.0"
auth := &cliproxyauth.Auth{
- ID: "auth-flag-disabled",
- Attributes: map[string]string{
- "base_url": server.URL,
- },
- Metadata: map[string]any{
- "access_token": "token",
- "project_id": "project-1",
- "expired": time.Now().Add(1 * time.Hour).Format(time.RFC3339),
- },
+ ID: "auth-load-code-assist-ua",
+ Attributes: map[string]string{"user_agent": userAgent},
}
- markAntigravityPreferCredits(auth, "gemini-2.5-flash", time.Now(), nil)
+ ctx := context.WithValue(context.Background(), "cliproxy.roundtripper", roundTripperFunc(func(req *http.Request) (*http.Response, error) {
+ if req.URL.String() != "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist" {
+ t.Fatalf("unexpected request url %s", req.URL.String())
+ }
+ if got := req.Header.Get("User-Agent"); got != userAgent {
+ t.Fatalf("User-Agent = %q, want %q", got, userAgent)
+ }
+ if got := req.Header.Get("X-Goog-Api-Client"); got != "gl-node/22.21.1" {
+ t.Fatalf("X-Goog-Api-Client = %q, want %q", got, "gl-node/22.21.1")
+ }
+ body, _ := io.ReadAll(req.Body)
+ _ = req.Body.Close()
+ if string(body) != `{"metadata":{"ide_name":"antigravity","ide_type":"ANTIGRAVITY","ide_version":"1.23.2"}}` {
+ t.Fatalf("loadCodeAssist body = %s", string(body))
+ }
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Header: make(http.Header),
+ Body: io.NopCloser(strings.NewReader(`{"paidTier":{"id":"tier-1","availableCredits":[{"creditType":"GOOGLE_ONE_AI","creditAmount":"25000","minimumCreditAmountForUsage":"50"}]}}`)),
+ }, nil
+ }))
- _, err := exec.Execute(context.Background(), auth, cliproxyexecutor.Request{
- Model: "gemini-2.5-flash",
- Payload: []byte(`{"request":{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}}`),
- }, cliproxyexecutor.Options{
- SourceFormat: sdktranslator.FormatAntigravity,
- })
- if err == nil {
- t.Fatal("Execute() error = nil, want 429")
- }
- if len(requestBodies) != 1 {
- t.Fatalf("request count = %d, want 1", len(requestBodies))
- }
- if strings.Contains(requestBodies[0], `"enabledCreditTypes":["GOOGLE_ONE_AI"]`) {
- t.Fatalf("request unexpectedly used enabledCreditTypes with flag disabled: %s", requestBodies[0])
+ exec.updateAntigravityCreditsBalance(ctx, auth, "token")
+}
+
+func TestParseMetaFloat(t *testing.T) {
+ tests := []struct {
+ name string
+ value any
+ wantVal float64
+ wantOK bool
+ }{
+ {"string", "25000", 25000, true},
+ {"float64", float64(100), 100, true},
+ {"int", int(50), 50, true},
+ {"int64", int64(75), 75, true},
+ {"empty string", "", 0, false},
+ {"invalid string", "abc", 0, false},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ meta := map[string]any{"key": tt.value}
+ got, ok := parseMetaFloat(meta, "key")
+ if ok != tt.wantOK {
+ t.Fatalf("parseMetaFloat() ok = %v, want %v", ok, tt.wantOK)
+ }
+ if ok && got != tt.wantVal {
+ t.Fatalf("parseMetaFloat() = %f, want %f", got, tt.wantVal)
+ }
+ })
}
}
diff --git a/internal/runtime/executor/antigravity_executor_signature_test.go b/internal/runtime/executor/antigravity_executor_signature_test.go
index 226daf5c67..7d84bfe890 100644
--- a/internal/runtime/executor/antigravity_executor_signature_test.go
+++ b/internal/runtime/executor/antigravity_executor_signature_test.go
@@ -10,10 +10,10 @@ import (
"testing"
"time"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/cache"
- 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"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/cache"
+ cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
+ cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
+ sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
)
func testGeminiSignaturePayload() string {
diff --git a/internal/runtime/executor/claude_executor.go b/internal/runtime/executor/claude_executor.go
index 0311827bae..eb17864d6e 100644
--- a/internal/runtime/executor/claude_executor.go
+++ b/internal/runtime/executor/claude_executor.go
@@ -11,23 +11,22 @@ import (
"fmt"
"io"
"net/http"
- "net/textproto"
"strings"
"time"
"github.com/andybalholm/brotli"
"github.com/google/uuid"
"github.com/klauspost/compress/zstd"
- claudeauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/claude"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/executor/helps"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
- "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"
+ claudeauth "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/claude"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/misc"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/util"
+ cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
+ cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
+ sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
log "github.com/sirupsen/logrus"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
@@ -66,14 +65,13 @@ var oauthToolRenameMap = map[string]string{
"notebookedit": "NotebookEdit",
}
-// oauthToolRenameReverseMap is the inverse of oauthToolRenameMap for response decoding.
-var oauthToolRenameReverseMap = func() map[string]string {
- m := make(map[string]string, len(oauthToolRenameMap))
- for k, v := range oauthToolRenameMap {
- m[v] = k
- }
- return m
-}()
+// The reverse map is now computed per-request in remapOAuthToolNames so that
+// only names the client actually caused us to rewrite are restored on the
+// response. A global reverse map — as used previously — corrupted responses
+// for clients that sent mixed casing (e.g. Amp CLI sends `Bash` TitleCase
+// alongside `glob` lowercase; the request flagged renames via `glob→Glob`,
+// then the global reverse map incorrectly rewrote every `Bash` in the
+// response to `bash`, causing Amp to reject the tool_use as unknown).
// oauthToolsToRemove lists tool names that must be stripped from OAuth requests
// even after remapping. Currently empty — all tools are mapped instead of removed.
@@ -165,7 +163,8 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r
body = applyCloaking(ctx, e.cfg, auth, body, baseModel, apiKey)
requestedModel := helps.PayloadRequestedModel(opts, req.Model)
- body = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel)
+ requestPath := helps.PayloadRequestPath(opts)
+ body = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel, requestPath)
body = ensureModelMaxTokens(body, baseModel)
// Disable thinking if tool_choice forces tool use (Anthropic API constraint)
@@ -192,15 +191,9 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r
bodyForTranslation := body
bodyForUpstream := body
oauthToken := isClaudeOAuthToken(apiKey)
- oauthToolNamesRemapped := false
- if oauthToken && !auth.ToolPrefixDisabled() {
- bodyForUpstream = applyClaudeToolPrefix(body, claudeToolPrefix)
- }
- // Remap third-party tool names to Claude Code equivalents and remove
- // tools without official counterparts. This prevents Anthropic from
- // fingerprinting the request as third-party via tool naming patterns.
+ var oauthToolNamesReverseMap map[string]string
if oauthToken {
- bodyForUpstream, oauthToolNamesRemapped = remapOAuthToolNames(bodyForUpstream)
+ bodyForUpstream, oauthToolNamesReverseMap = prepareClaudeOAuthToolNamesForUpstream(bodyForUpstream, claudeToolPrefix, auth.ToolPrefixDisabled())
}
// Enable cch signing by default for OAuth tokens (not just experimental flag).
// Claude Code always computes cch; missing or invalid cch is a detectable fingerprint.
@@ -285,6 +278,10 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r
}
helps.AppendAPIResponseChunk(ctx, e.cfg, data)
if stream {
+ if errValidate := validateClaudeStreamingResponse(data); errValidate != nil {
+ helps.RecordAPIResponseError(ctx, e.cfg, errValidate)
+ return resp, errValidate
+ }
lines := bytes.Split(data, []byte("\n"))
for _, line := range lines {
if detail, ok := helps.ParseClaudeStreamUsage(line); ok {
@@ -294,13 +291,7 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r
} else {
reporter.Publish(ctx, helps.ParseClaudeUsage(data))
}
- if isClaudeOAuthToken(apiKey) && !auth.ToolPrefixDisabled() {
- data = stripClaudeToolPrefixFromResponse(data, claudeToolPrefix)
- }
- // Reverse the OAuth tool name remap so the downstream client sees original names.
- if isClaudeOAuthToken(apiKey) && oauthToolNamesRemapped {
- data = reverseRemapOAuthToolNames(data)
- }
+ data = restoreClaudeOAuthToolNamesFromResponse(data, claudeToolPrefix, auth.ToolPrefixDisabled(), oauthToolNamesReverseMap)
var param any
out := sdktranslator.TranslateNonStream(
ctx,
@@ -350,7 +341,8 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
body = applyCloaking(ctx, e.cfg, auth, body, baseModel, apiKey)
requestedModel := helps.PayloadRequestedModel(opts, req.Model)
- body = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel)
+ requestPath := helps.PayloadRequestPath(opts)
+ body = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel, requestPath)
body = ensureModelMaxTokens(body, baseModel)
// Disable thinking if tool_choice forces tool use (Anthropic API constraint)
@@ -374,15 +366,9 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
bodyForTranslation := body
bodyForUpstream := body
oauthToken := isClaudeOAuthToken(apiKey)
- oauthToolNamesRemapped := false
- if oauthToken && !auth.ToolPrefixDisabled() {
- bodyForUpstream = applyClaudeToolPrefix(body, claudeToolPrefix)
- }
- // Remap third-party tool names to Claude Code equivalents and remove
- // tools without official counterparts. This prevents Anthropic from
- // fingerprinting the request as third-party via tool naming patterns.
+ var oauthToolNamesReverseMap map[string]string
if oauthToken {
- bodyForUpstream, oauthToolNamesRemapped = remapOAuthToolNames(bodyForUpstream)
+ bodyForUpstream, oauthToolNamesReverseMap = prepareClaudeOAuthToolNamesForUpstream(bodyForUpstream, claudeToolPrefix, auth.ToolPrefixDisabled())
}
// Enable cch signing by default for OAuth tokens (not just experimental flag).
if oauthToken || experimentalCCHSigningEnabled(e.cfg, auth) {
@@ -473,22 +459,24 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
if detail, ok := helps.ParseClaudeStreamUsage(line); ok {
reporter.Publish(ctx, detail)
}
- if isClaudeOAuthToken(apiKey) && !auth.ToolPrefixDisabled() {
- line = stripClaudeToolPrefixFromStreamLine(line, claudeToolPrefix)
- }
- if isClaudeOAuthToken(apiKey) && oauthToolNamesRemapped {
- line = reverseRemapOAuthToolNamesFromStreamLine(line)
- }
+ line = restoreClaudeOAuthToolNamesFromStreamLine(line, claudeToolPrefix, auth.ToolPrefixDisabled(), oauthToolNamesReverseMap)
// Forward the line as-is to preserve SSE format
cloned := make([]byte, len(line)+1)
copy(cloned, line)
cloned[len(line)] = '\n'
- out <- cliproxyexecutor.StreamChunk{Payload: cloned}
+ select {
+ case out <- cliproxyexecutor.StreamChunk{Payload: cloned}:
+ case <-ctx.Done():
+ return
+ }
}
if errScan := scanner.Err(); errScan != nil {
helps.RecordAPIResponseError(ctx, e.cfg, errScan)
- reporter.PublishFailure(ctx)
- out <- cliproxyexecutor.StreamChunk{Err: errScan}
+ reporter.PublishFailure(ctx, errScan)
+ select {
+ case out <- cliproxyexecutor.StreamChunk{Err: errScan}:
+ case <-ctx.Done():
+ }
}
return
}
@@ -503,12 +491,7 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
if detail, ok := helps.ParseClaudeStreamUsage(line); ok {
reporter.Publish(ctx, detail)
}
- if isClaudeOAuthToken(apiKey) && !auth.ToolPrefixDisabled() {
- line = stripClaudeToolPrefixFromStreamLine(line, claudeToolPrefix)
- }
- if isClaudeOAuthToken(apiKey) && oauthToolNamesRemapped {
- line = reverseRemapOAuthToolNamesFromStreamLine(line)
- }
+ line = restoreClaudeOAuthToolNamesFromStreamLine(line, claudeToolPrefix, auth.ToolPrefixDisabled(), oauthToolNamesReverseMap)
chunks := sdktranslator.TranslateStream(
ctx,
to,
@@ -520,18 +503,83 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
¶m,
)
for i := range chunks {
- out <- cliproxyexecutor.StreamChunk{Payload: chunks[i]}
+ select {
+ case out <- cliproxyexecutor.StreamChunk{Payload: chunks[i]}:
+ case <-ctx.Done():
+ return
+ }
}
}
if errScan := scanner.Err(); errScan != nil {
helps.RecordAPIResponseError(ctx, e.cfg, errScan)
- reporter.PublishFailure(ctx)
- out <- cliproxyexecutor.StreamChunk{Err: errScan}
+ reporter.PublishFailure(ctx, errScan)
+ select {
+ case out <- cliproxyexecutor.StreamChunk{Err: errScan}:
+ case <-ctx.Done():
+ }
}
}()
return &cliproxyexecutor.StreamResult{Headers: httpResp.Header.Clone(), Chunks: out}, nil
}
+func validateClaudeStreamingResponse(data []byte) error {
+ scanner := bufio.NewScanner(bytes.NewReader(data))
+ scanner.Buffer(nil, 52_428_800)
+
+ hasData := false
+ hasMessageStart := false
+ hasMessageDelta := false
+
+ for scanner.Scan() {
+ line := bytes.TrimSpace(scanner.Bytes())
+ if len(line) == 0 || !bytes.HasPrefix(line, []byte("data:")) {
+ continue
+ }
+ payload := bytes.TrimSpace(line[len("data:"):])
+ if len(payload) == 0 || bytes.Equal(payload, []byte("[DONE]")) {
+ continue
+ }
+ hasData = true
+ if !gjson.ValidBytes(payload) {
+ return statusErr{code: http.StatusBadGateway, msg: "claude executor: upstream returned malformed stream data"}
+ }
+
+ root := gjson.ParseBytes(payload)
+ switch root.Get("type").String() {
+ case "error":
+ message := strings.TrimSpace(root.Get("error.message").String())
+ if message == "" {
+ message = strings.TrimSpace(root.Get("error.type").String())
+ }
+ if message == "" {
+ message = "unknown upstream error"
+ }
+ return statusErr{code: http.StatusBadGateway, msg: "claude executor: upstream returned error event: " + message}
+ case "message_start":
+ message := root.Get("message")
+ if strings.TrimSpace(message.Get("id").String()) == "" || strings.TrimSpace(message.Get("model").String()) == "" {
+ return statusErr{code: http.StatusBadGateway, msg: "claude executor: upstream stream message_start is missing id or model"}
+ }
+ hasMessageStart = true
+ case "message_delta":
+ hasMessageDelta = true
+ }
+ }
+ if errScan := scanner.Err(); errScan != nil {
+ return errScan
+ }
+ if !hasData {
+ return statusErr{code: http.StatusBadGateway, msg: "claude executor: upstream returned empty stream response"}
+ }
+ if !hasMessageStart {
+ return statusErr{code: http.StatusBadGateway, msg: "claude executor: upstream stream response is missing message_start"}
+ }
+ if !hasMessageDelta {
+ return statusErr{code: http.StatusBadGateway, msg: "claude executor: upstream stream response ended before message completion"}
+ }
+ return nil
+}
+
func (e *ClaudeExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
baseModel := thinking.ParseSuffix(req.Model).ModelName
@@ -558,12 +606,8 @@ func (e *ClaudeExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Aut
// Extract betas from body and convert to header (for count_tokens too)
var extraBetas []string
extraBetas, body = extractAndRemoveBetas(body)
- if isClaudeOAuthToken(apiKey) && !auth.ToolPrefixDisabled() {
- body = applyClaudeToolPrefix(body, claudeToolPrefix)
- }
- // Remap tool names for OAuth token requests to avoid third-party fingerprinting.
if isClaudeOAuthToken(apiKey) {
- body, _ = remapOAuthToolNames(body)
+ body, _ = prepareClaudeOAuthToolNamesForUpstream(body, claudeToolPrefix, auth.ToolPrefixDisabled())
}
url := fmt.Sprintf("%s/v1/messages/count_tokens?beta=true", baseURL)
@@ -647,6 +691,9 @@ func (e *ClaudeExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Aut
func (e *ClaudeExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) {
log.Debugf("claude executor: refresh called")
+ if refreshed, handled, err := helps.RefreshAuthViaHome(ctx, e.cfg, auth); handled {
+ return refreshed, err
+ }
if auth == nil {
return nil, fmt.Errorf("claude executor: auth is nil")
}
@@ -660,7 +707,7 @@ func (e *ClaudeExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (
return auth, nil
}
svc := claudeauth.NewClaudeAuthWithProxyURL(e.cfg, auth.ProxyURL)
- td, err := svc.RefreshTokens(ctx, refreshToken)
+ td, err := svc.RefreshTokensWithRetry(ctx, refreshToken, 3)
if err != nil {
return nil, err
}
@@ -911,15 +958,8 @@ func applyClaudeHeaders(r *http.Request, auth *cliproxyauth.Auth, apiKey string,
baseBetas += ",interleaved-thinking-2025-05-14"
}
- hasClaude1MHeader := false
- if ginHeaders != nil {
- if _, ok := ginHeaders[textproto.CanonicalMIMEHeaderKey("X-CPA-CLAUDE-1M")]; ok {
- hasClaude1MHeader = true
- }
- }
-
// Merge extra betas from request body and request flags.
- if len(extraBetas) > 0 || hasClaude1MHeader {
+ if len(extraBetas) > 0 {
existingSet := make(map[string]bool)
for _, b := range strings.Split(baseBetas, ",") {
betaName := strings.TrimSpace(b)
@@ -934,9 +974,6 @@ func applyClaudeHeaders(r *http.Request, auth *cliproxyauth.Auth, apiKey string,
existingSet[beta] = true
}
}
- if hasClaude1MHeader && !existingSet["context-1m-2025-08-07"] {
- baseBetas += ",context-1m-2025-08-07"
- }
}
r.Header.Set("Anthropic-Beta", baseBetas)
@@ -1013,6 +1050,36 @@ func isClaudeOAuthToken(apiKey string) bool {
return strings.Contains(apiKey, "sk-ant-oat")
}
+// prepareClaudeOAuthToolNamesForUpstream applies the Claude OAuth tool-name
+// transforms in the same order across request paths. Remap runs before prefixing
+// so any future non-empty prefix still composes correctly with the per-request
+// reverse map.
+func prepareClaudeOAuthToolNamesForUpstream(body []byte, prefix string, prefixDisabled bool) ([]byte, map[string]string) {
+ body, reverseMap := remapOAuthToolNames(body)
+ if !prefixDisabled {
+ body = applyClaudeToolPrefix(body, prefix)
+ }
+ return body, reverseMap
+}
+
+// restoreClaudeOAuthToolNamesFromResponse undoes the Claude OAuth tool-name
+// transforms for non-stream responses in reverse order.
+func restoreClaudeOAuthToolNamesFromResponse(body []byte, prefix string, prefixDisabled bool, reverseMap map[string]string) []byte {
+ if !prefixDisabled {
+ body = stripClaudeToolPrefixFromResponse(body, prefix)
+ }
+ return reverseRemapOAuthToolNames(body, reverseMap)
+}
+
+// restoreClaudeOAuthToolNamesFromStreamLine undoes the Claude OAuth tool-name
+// transforms for SSE lines in reverse order.
+func restoreClaudeOAuthToolNamesFromStreamLine(line []byte, prefix string, prefixDisabled bool, reverseMap map[string]string) []byte {
+ if !prefixDisabled {
+ line = stripClaudeToolPrefixFromStreamLine(line, prefix)
+ }
+ return reverseRemapOAuthToolNamesFromStreamLine(line, reverseMap)
+}
+
// remapOAuthToolNames renames third-party tool names to Claude Code equivalents
// and removes tools without an official counterpart. This prevents Anthropic from
// fingerprinting the request as a third-party client via tool naming patterns.
@@ -1020,8 +1087,25 @@ func isClaudeOAuthToken(apiKey string) bool {
// It operates on: tools[].name, tool_choice.name, and all tool_use/tool_reference
// references in messages. Removed tools' corresponding tool_result blocks are preserved
// (they just become orphaned, which is safe for Claude).
-func remapOAuthToolNames(body []byte) ([]byte, bool) {
- renamed := false
+//
+// The returned map is keyed on the upstream (TitleCase) name and maps to the
+// client-supplied original name. Callers MUST pass this map to the reverse
+// functions so only names the client actually caused us to rewrite are restored
+// on the response. A global reverse map (the previous implementation) incorrectly
+// rewrote names the client originally sent in TitleCase (e.g. Amp CLI's `Bash`)
+// when any OTHER tool in the same request triggered a forward rename (e.g.
+// Amp's `glob`→`Glob`), because the global reverse map contained `Bash`→`bash`
+// regardless of what the client originally sent.
+func remapOAuthToolNames(body []byte) ([]byte, map[string]string) {
+ reverseMap := make(map[string]string, len(oauthToolRenameMap))
+ recordRename := func(original, renamed string) {
+ // Preserve the first-seen original name if the same upstream name is
+ // produced from multiple call sites; they all map back identically.
+ if _, exists := reverseMap[renamed]; !exists {
+ reverseMap[renamed] = original
+ }
+ }
+
// 1. Rewrite tools array in a single pass (if present).
// IMPORTANT: do not mutate names first and then rebuild from an older gjson
// snapshot. gjson results are snapshots of the original bytes; rebuilding from a
@@ -1054,7 +1138,7 @@ func remapOAuthToolNames(body []byte) ([]byte, bool) {
updatedTool, err := sjson.Set(toolJSON, "name", newName)
if err == nil {
toolJSON = updatedTool
- renamed = true
+ recordRename(name, newName)
}
}
@@ -1079,7 +1163,7 @@ func remapOAuthToolNames(body []byte) ([]byte, bool) {
body, _ = sjson.DeleteBytes(body, "tool_choice")
} else if newName, ok := oauthToolRenameMap[tcName]; ok && newName != tcName {
body, _ = sjson.SetBytes(body, "tool_choice.name", newName)
- renamed = true
+ recordRename(tcName, newName)
}
}
@@ -1099,14 +1183,14 @@ func remapOAuthToolNames(body []byte) ([]byte, bool) {
if newName, ok := oauthToolRenameMap[name]; ok && newName != name {
path := fmt.Sprintf("messages.%d.content.%d.name", msgIndex.Int(), contentIndex.Int())
body, _ = sjson.SetBytes(body, path, newName)
- renamed = true
+ recordRename(name, newName)
}
case "tool_reference":
toolName := part.Get("tool_name").String()
if newName, ok := oauthToolRenameMap[toolName]; ok && newName != toolName {
path := fmt.Sprintf("messages.%d.content.%d.tool_name", msgIndex.Int(), contentIndex.Int())
body, _ = sjson.SetBytes(body, path, newName)
- renamed = true
+ recordRename(toolName, newName)
}
case "tool_result":
// Handle nested tool_reference blocks inside tool_result.content[]
@@ -1120,7 +1204,7 @@ func remapOAuthToolNames(body []byte) ([]byte, bool) {
if newName, ok := oauthToolRenameMap[nestedToolName]; ok && newName != nestedToolName {
nestedPath := fmt.Sprintf("messages.%d.content.%d.content.%d.tool_name", msgIndex.Int(), contentIndex.Int(), nestedIndex.Int())
body, _ = sjson.SetBytes(body, nestedPath, newName)
- renamed = true
+ recordRename(nestedToolName, newName)
}
}
return true
@@ -1133,13 +1217,16 @@ func remapOAuthToolNames(body []byte) ([]byte, bool) {
})
}
- return body, renamed
+ return body, reverseMap
}
-// reverseRemapOAuthToolNames reverses the tool name mapping for non-stream responses.
-// It maps Claude Code TitleCase names back to the original lowercase names so the
-// downstream client receives tool names it recognizes.
-func reverseRemapOAuthToolNames(body []byte) []byte {
+// reverseRemapOAuthToolNames reverses the tool name mapping for non-stream responses
+// using the per-request map produced by remapOAuthToolNames. Names the client sent
+// that were NOT forward-renamed are passed through unchanged.
+func reverseRemapOAuthToolNames(body []byte, reverseMap map[string]string) []byte {
+ if len(reverseMap) == 0 {
+ return body
+ }
content := gjson.GetBytes(body, "content")
if !content.Exists() || !content.IsArray() {
return body
@@ -1149,13 +1236,13 @@ func reverseRemapOAuthToolNames(body []byte) []byte {
switch partType {
case "tool_use":
name := part.Get("name").String()
- if origName, ok := oauthToolRenameReverseMap[name]; ok {
+ if origName, ok := reverseMap[name]; ok {
path := fmt.Sprintf("content.%d.name", index.Int())
body, _ = sjson.SetBytes(body, path, origName)
}
case "tool_reference":
toolName := part.Get("tool_name").String()
- if origName, ok := oauthToolRenameReverseMap[toolName]; ok {
+ if origName, ok := reverseMap[toolName]; ok {
path := fmt.Sprintf("content.%d.tool_name", index.Int())
body, _ = sjson.SetBytes(body, path, origName)
}
@@ -1165,8 +1252,12 @@ func reverseRemapOAuthToolNames(body []byte) []byte {
return body
}
-// reverseRemapOAuthToolNamesFromStreamLine reverses the tool name mapping for SSE stream lines.
-func reverseRemapOAuthToolNamesFromStreamLine(line []byte) []byte {
+// reverseRemapOAuthToolNamesFromStreamLine reverses the tool name mapping for SSE
+// stream lines, using the per-request reverseMap produced by remapOAuthToolNames.
+func reverseRemapOAuthToolNamesFromStreamLine(line []byte, reverseMap map[string]string) []byte {
+ if len(reverseMap) == 0 {
+ return line
+ }
payload := helps.JSONPayload(line)
if len(payload) == 0 || !gjson.ValidBytes(payload) {
return line
@@ -1184,7 +1275,7 @@ func reverseRemapOAuthToolNamesFromStreamLine(line []byte) []byte {
switch blockType {
case "tool_use":
name := contentBlock.Get("name").String()
- if origName, ok := oauthToolRenameReverseMap[name]; ok {
+ if origName, ok := reverseMap[name]; ok {
updated, err = sjson.SetBytes(payload, "content_block.name", origName)
if err != nil {
return line
@@ -1194,7 +1285,7 @@ func reverseRemapOAuthToolNamesFromStreamLine(line []byte) []byte {
}
case "tool_reference":
toolName := contentBlock.Get("tool_name").String()
- if origName, ok := oauthToolRenameReverseMap[toolName]; ok {
+ if origName, ok := reverseMap[toolName]; ok {
updated, err = sjson.SetBytes(payload, "content_block.tool_name", origName)
if err != nil {
return line
diff --git a/internal/runtime/executor/claude_executor_test.go b/internal/runtime/executor/claude_executor_test.go
index f456064dc6..f5bca55ab7 100644
--- a/internal/runtime/executor/claude_executor_test.go
+++ b/internal/runtime/executor/claude_executor_test.go
@@ -17,12 +17,12 @@ import (
"github.com/gin-gonic/gin"
"github.com/klauspost/compress/zstd"
xxHash64 "github.com/pierrec/xxHash/xxHash64"
- "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/runtime/executor/helps"
- 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"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps"
+ cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
+ cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
+ sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
@@ -936,6 +936,113 @@ func TestClaudeExecutor_GeneratesNewUserIDByDefault(t *testing.T) {
}
}
+func TestClaudeExecutor_ExecuteOpenAINonStreamRejectsEmptyClaudeStream(t *testing.T) {
+ _, err := executeOpenAIChatCompletionThroughClaude(t, "")
+ if err == nil {
+ t.Fatal("Execute error = nil, want empty stream error")
+ }
+ assertStatusErr(t, err, http.StatusBadGateway)
+ if !strings.Contains(err.Error(), "empty stream response") {
+ t.Fatalf("Execute error = %q, want empty stream response", err.Error())
+ }
+}
+
+func TestClaudeExecutor_ExecuteOpenAINonStreamRejectsClaudeErrorEvent(t *testing.T) {
+ body := `data: {"type":"error","error":{"type":"overloaded_error","message":"upstream overloaded"}}` + "\n"
+ _, err := executeOpenAIChatCompletionThroughClaude(t, body)
+ if err == nil {
+ t.Fatal("Execute error = nil, want upstream error event")
+ }
+ assertStatusErr(t, err, http.StatusBadGateway)
+ if !strings.Contains(err.Error(), "upstream overloaded") {
+ t.Fatalf("Execute error = %q, want upstream overloaded", err.Error())
+ }
+}
+
+func TestClaudeExecutor_ExecuteOpenAINonStreamRejectsIncompleteClaudeStream(t *testing.T) {
+ body := strings.Join([]string{
+ `data: {"type":"message_start","message":{"id":"msg_123","model":"claude-3-5-sonnet-20241022"}}`,
+ `data: {"type":"message_stop"}`,
+ ``,
+ }, "\n")
+
+ _, err := executeOpenAIChatCompletionThroughClaude(t, body)
+ if err == nil {
+ t.Fatal("Execute error = nil, want incomplete stream error")
+ }
+ assertStatusErr(t, err, http.StatusBadGateway)
+ if !strings.Contains(err.Error(), "ended before message completion") {
+ t.Fatalf("Execute error = %q, want incomplete stream error", err.Error())
+ }
+}
+
+func TestClaudeExecutor_ExecuteOpenAINonStreamConvertsValidClaudeStream(t *testing.T) {
+ body := strings.Join([]string{
+ `event: message_start`,
+ `data: {"type":"message_start","message":{"id":"msg_123","model":"claude-3-5-sonnet-20241022"}}`,
+ `event: content_block_delta`,
+ `data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"ok"}}`,
+ `event: message_delta`,
+ `data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"input_tokens":2,"output_tokens":1}}`,
+ `event: message_stop`,
+ `data: {"type":"message_stop"}`,
+ ``,
+ }, "\n")
+
+ resp, err := executeOpenAIChatCompletionThroughClaude(t, body)
+ if err != nil {
+ t.Fatalf("Execute error: %v", err)
+ }
+ if got := gjson.GetBytes(resp.Payload, "id").String(); got != "msg_123" {
+ t.Fatalf("response id = %q, want msg_123; payload=%s", got, string(resp.Payload))
+ }
+ if got := gjson.GetBytes(resp.Payload, "model").String(); got != "claude-3-5-sonnet-20241022" {
+ t.Fatalf("response model = %q, want claude-3-5-sonnet-20241022", got)
+ }
+ if got := gjson.GetBytes(resp.Payload, "choices.0.message.content").String(); got != "ok" {
+ t.Fatalf("response content = %q, want ok", got)
+ }
+ if got := gjson.GetBytes(resp.Payload, "usage.total_tokens").Int(); got != 3 {
+ t.Fatalf("usage.total_tokens = %d, want 3", got)
+ }
+}
+
+func executeOpenAIChatCompletionThroughClaude(t *testing.T, upstreamBody string) (cliproxyexecutor.Response, error) {
+ t.Helper()
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/event-stream")
+ _, _ = w.Write([]byte(upstreamBody))
+ }))
+ defer server.Close()
+
+ executor := NewClaudeExecutor(&config.Config{})
+ auth := &cliproxyauth.Auth{Attributes: map[string]string{
+ "api_key": "key-123",
+ "base_url": server.URL,
+ }}
+ payload := []byte(`{"model":"claude-3-5-sonnet-20241022","messages":[{"role":"user","content":"hi"}]}`)
+
+ return executor.Execute(context.Background(), auth, cliproxyexecutor.Request{
+ Model: "claude-3-5-sonnet-20241022",
+ Payload: payload,
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FromString("openai"),
+ })
+}
+
+func assertStatusErr(t *testing.T, err error, want int) {
+ t.Helper()
+
+ status, ok := err.(interface{ StatusCode() int })
+ if !ok {
+ t.Fatalf("error %T does not expose StatusCode", err)
+ }
+ if got := status.StatusCode(); got != want {
+ t.Fatalf("StatusCode() = %d, want %d", got, want)
+ }
+}
+
func TestStripClaudeToolPrefixFromResponse_NestedToolReference(t *testing.T) {
input := []byte(`{"content":[{"type":"tool_result","tool_use_id":"toolu_123","content":[{"type":"tool_reference","tool_name":"proxy_mcp__nia__manage_resource"}]}]}`)
out := stripClaudeToolPrefixFromResponse(input, "proxy_")
@@ -1714,7 +1821,27 @@ func TestClaudeExecutor_ExecuteStream_AcceptEncodingOverrideCannotBypassIdentity
}
}
-// Test case 1: String system prompt is preserved and converted to a content block
+func expectedClaudeCodeStaticPrompt() string {
+ return strings.Join([]string{
+ helps.ClaudeCodeIntro,
+ helps.ClaudeCodeSystem,
+ helps.ClaudeCodeDoingTasks,
+ helps.ClaudeCodeToneAndStyle,
+ helps.ClaudeCodeOutputEfficiency,
+ }, "\n\n")
+}
+
+func expectedForwardedSystemReminder(text string) string {
+ return fmt.Sprintf(`
+As you answer the user's questions, you can use the following context from the system:
+%s
+
+IMPORTANT: this context may or may not be relevant to your tasks. You should not respond to this context unless it is highly relevant to your task.
+
+`, text)
+}
+
+// Test case 1: String system prompt is preserved by forwarding it to the first user message
func TestCheckSystemInstructionsWithMode_StringSystemPreserved(t *testing.T) {
payload := []byte(`{"system":"You are a helpful assistant.","messages":[{"role":"user","content":"hi"}]}`)
@@ -1733,42 +1860,52 @@ func TestCheckSystemInstructionsWithMode_StringSystemPreserved(t *testing.T) {
if !strings.HasPrefix(blocks[0].Get("text").String(), "x-anthropic-billing-header:") {
t.Fatalf("blocks[0] should be billing header, got %q", blocks[0].Get("text").String())
}
- if blocks[1].Get("text").String() != "You are a Claude agent, built on Anthropic's Claude Agent SDK." {
+ if blocks[1].Get("text").String() != "You are Claude Code, Anthropic's official CLI for Claude." {
t.Fatalf("blocks[1] should be agent block, got %q", blocks[1].Get("text").String())
}
- if blocks[2].Get("text").String() != "You are a helpful assistant." {
- t.Fatalf("blocks[2] should be user system prompt, got %q", blocks[2].Get("text").String())
+ if blocks[2].Get("text").String() != expectedClaudeCodeStaticPrompt() {
+ t.Fatalf("blocks[2] should be static Claude Code prompt, got %q", blocks[2].Get("text").String())
}
- if blocks[2].Get("cache_control.type").String() != "ephemeral" {
- t.Fatalf("blocks[2] should have cache_control.type=ephemeral")
+ if blocks[2].Get("cache_control").Exists() {
+ t.Fatalf("blocks[2] should not have cache_control, got %s", blocks[2].Get("cache_control").Raw)
+ }
+
+ if got := gjson.GetBytes(out, "messages.0.content").String(); got != expectedForwardedSystemReminder("You are a helpful assistant.")+"hi" {
+ t.Fatalf("messages[0].content should include forwarded system prompt, got %q", got)
}
}
-// Test case 2: Strict mode drops the string system prompt
+// Test case 2: Strict mode keeps only the injected Claude Code system blocks
func TestCheckSystemInstructionsWithMode_StringSystemStrict(t *testing.T) {
payload := []byte(`{"system":"You are a helpful assistant.","messages":[{"role":"user","content":"hi"}]}`)
out := checkSystemInstructionsWithMode(payload, true)
blocks := gjson.GetBytes(out, "system").Array()
- if len(blocks) != 2 {
- t.Fatalf("strict mode should produce 2 blocks, got %d", len(blocks))
+ if len(blocks) != 3 {
+ t.Fatalf("strict mode should produce 3 injected blocks, got %d", len(blocks))
+ }
+ if got := gjson.GetBytes(out, "messages.0.content").String(); got != "hi" {
+ t.Fatalf("strict mode should not forward system prompt into messages, got %q", got)
}
}
-// Test case 3: Empty string system prompt does not produce a spurious block
+// Test case 3: Empty string system prompt does not alter the first user message
func TestCheckSystemInstructionsWithMode_EmptyStringSystemIgnored(t *testing.T) {
payload := []byte(`{"system":"","messages":[{"role":"user","content":"hi"}]}`)
out := checkSystemInstructionsWithMode(payload, false)
blocks := gjson.GetBytes(out, "system").Array()
- if len(blocks) != 2 {
- t.Fatalf("empty string system should produce 2 blocks, got %d", len(blocks))
+ if len(blocks) != 3 {
+ t.Fatalf("empty string system should still produce 3 injected blocks, got %d", len(blocks))
+ }
+ if got := gjson.GetBytes(out, "messages.0.content").String(); got != "hi" {
+ t.Fatalf("empty string system should not alter messages, got %q", got)
}
}
-// Test case 4: Array system prompt is unaffected by the string handling
+// Test case 4: Array system prompt is forwarded to the first user message
func TestCheckSystemInstructionsWithMode_ArraySystemStillWorks(t *testing.T) {
payload := []byte(`{"system":[{"type":"text","text":"Be concise."}],"messages":[{"role":"user","content":"hi"}]}`)
@@ -1778,12 +1915,15 @@ func TestCheckSystemInstructionsWithMode_ArraySystemStillWorks(t *testing.T) {
if len(blocks) != 3 {
t.Fatalf("expected 3 system blocks, got %d", len(blocks))
}
- if blocks[2].Get("text").String() != "Be concise." {
- t.Fatalf("blocks[2] should be user system prompt, got %q", blocks[2].Get("text").String())
+ if blocks[2].Get("text").String() != expectedClaudeCodeStaticPrompt() {
+ t.Fatalf("blocks[2] should be static Claude Code prompt, got %q", blocks[2].Get("text").String())
+ }
+ if got := gjson.GetBytes(out, "messages.0.content").String(); got != expectedForwardedSystemReminder("Be concise.")+"hi" {
+ t.Fatalf("messages[0].content should include forwarded array system prompt, got %q", got)
}
}
-// Test case 5: Special characters in string system prompt survive conversion
+// Test case 5: Special characters in string system prompt survive forwarding
func TestCheckSystemInstructionsWithMode_StringWithSpecialChars(t *testing.T) {
payload := []byte(`{"system":"Use tags & \"quotes\" in output.","messages":[{"role":"user","content":"hi"}]}`)
@@ -1793,8 +1933,8 @@ func TestCheckSystemInstructionsWithMode_StringWithSpecialChars(t *testing.T) {
if len(blocks) != 3 {
t.Fatalf("expected 3 system blocks, got %d", len(blocks))
}
- if blocks[2].Get("text").String() != `Use tags & "quotes" in output.` {
- t.Fatalf("blocks[2] text mangled, got %q", blocks[2].Get("text").String())
+ if got := gjson.GetBytes(out, "messages.0.content").String(); got != expectedForwardedSystemReminder(`Use tags & "quotes" in output.`)+"hi" {
+ t.Fatalf("forwarded system prompt text mangled, got %q", got)
}
}
@@ -1902,8 +2042,11 @@ func TestApplyCloaking_PreservesConfiguredStrictModeAndSensitiveWordsWhenModeOmi
out := applyCloaking(context.Background(), cfg, auth, payload, "claude-3-5-sonnet-20241022", "key-123")
blocks := gjson.GetBytes(out, "system").Array()
- if len(blocks) != 2 {
- t.Fatalf("expected strict mode to keep only injected system blocks, got %d", len(blocks))
+ if len(blocks) != 3 {
+ t.Fatalf("expected strict mode to keep the 3 injected Claude Code system blocks, got %d", len(blocks))
+ }
+ if got := gjson.GetBytes(out, "messages.0.content.#").Int(); got != 1 {
+ t.Fatalf("strict mode should not prepend a forwarded system reminder block, got %d content blocks", got)
}
if got := gjson.GetBytes(out, "messages.0.content.0.text").String(); !strings.Contains(got, "\u200B") {
t.Fatalf("expected configured sensitive word obfuscation to apply, got %q", got)
@@ -1953,19 +2096,16 @@ func TestNormalizeClaudeTemperatureForThinking_AfterForcedToolChoiceKeepsOrigina
func TestRemapOAuthToolNames_TitleCase_NoReverseNeeded(t *testing.T) {
body := []byte(`{"tools":[{"name":"Bash","description":"Run shell commands","input_schema":{"type":"object","properties":{"cmd":{"type":"string"}}}}],"messages":[{"role":"user","content":[{"type":"text","text":"hi"}]}]}`)
- out, renamed := remapOAuthToolNames(body)
- if renamed {
- t.Fatalf("renamed = true, want false")
+ out, reverseMap := remapOAuthToolNames(body)
+ if len(reverseMap) != 0 {
+ t.Fatalf("reverseMap = %v, want empty", reverseMap)
}
if got := gjson.GetBytes(out, "tools.0.name").String(); got != "Bash" {
t.Fatalf("tools.0.name = %q, want %q", got, "Bash")
}
resp := []byte(`{"content":[{"type":"tool_use","id":"toolu_01","name":"Bash","input":{"cmd":"ls"}}]}`)
- reversed := resp
- if renamed {
- reversed = reverseRemapOAuthToolNames(resp)
- }
+ reversed := reverseRemapOAuthToolNames(resp, reverseMap)
if got := gjson.GetBytes(reversed, "content.0.name").String(); got != "Bash" {
t.Fatalf("content.0.name = %q, want %q", got, "Bash")
}
@@ -1974,20 +2114,150 @@ func TestRemapOAuthToolNames_TitleCase_NoReverseNeeded(t *testing.T) {
func TestRemapOAuthToolNames_Lowercase_ReverseApplied(t *testing.T) {
body := []byte(`{"tools":[{"name":"bash","description":"Run shell commands","input_schema":{"type":"object","properties":{"cmd":{"type":"string"}}}}],"messages":[{"role":"user","content":[{"type":"text","text":"hi"}]}]}`)
- out, renamed := remapOAuthToolNames(body)
- if !renamed {
- t.Fatalf("renamed = false, want true")
+ out, reverseMap := remapOAuthToolNames(body)
+ if reverseMap["Bash"] != "bash" {
+ t.Fatalf("reverseMap = %v, want entry Bash->bash", reverseMap)
}
if got := gjson.GetBytes(out, "tools.0.name").String(); got != "Bash" {
t.Fatalf("tools.0.name = %q, want %q", got, "Bash")
}
resp := []byte(`{"content":[{"type":"tool_use","id":"toolu_01","name":"Bash","input":{"cmd":"ls"}}]}`)
- reversed := resp
- if renamed {
- reversed = reverseRemapOAuthToolNames(resp)
- }
+ reversed := reverseRemapOAuthToolNames(resp, reverseMap)
if got := gjson.GetBytes(reversed, "content.0.name").String(); got != "bash" {
t.Fatalf("content.0.name = %q, want %q", got, "bash")
}
}
+
+// TestRemapOAuthToolNames_MixedCase_OnlyRenamedToolsReversed is the regression
+// test for a case where a single request contains both a TitleCase tool (which
+// must pass through unchanged) and a lowercase tool that we forward-rename.
+// Before the fix, triggering ANY forward rename caused the reverse pass to
+// lowercase every TitleCase tool in the response using a global reverse map,
+// corrupting tool names the client originally sent in TitleCase (notably Amp
+// CLI's `Bash`, which its registry lookup cannot find as `bash`).
+func TestRemapOAuthToolNames_MixedCase_OnlyRenamedToolsReversed(t *testing.T) {
+ body := []byte(`{"tools":[` +
+ `{"name":"Bash","input_schema":{"type":"object","properties":{"cmd":{"type":"string"}}}},` +
+ `{"name":"glob","input_schema":{"type":"object","properties":{"filePattern":{"type":"string"}}}}` +
+ `]}`)
+
+ out, reverseMap := remapOAuthToolNames(body)
+
+ // Forward: TitleCase `Bash` is not a forward-map key, must pass through.
+ if got := gjson.GetBytes(out, "tools.0.name").String(); got != "Bash" {
+ t.Fatalf("tools.0.name = %q, want %q (TitleCase tool must not be renamed)", got, "Bash")
+ }
+ // Forward: `glob` is a forward-map key, upstream sees `Glob`.
+ if got := gjson.GetBytes(out, "tools.1.name").String(); got != "Glob" {
+ t.Fatalf("tools.1.name = %q, want %q", got, "Glob")
+ }
+
+ // Reverse map records ONLY the rename that happened.
+ if len(reverseMap) != 1 || reverseMap["Glob"] != "glob" {
+ t.Fatalf("reverseMap = %v, want {Glob:glob}", reverseMap)
+ }
+
+ // Upstream responds with a `Bash` tool_use. Since we never renamed `Bash`,
+ // reverseRemap MUST leave it alone.
+ bashResp := []byte(`{"content":[{"type":"tool_use","id":"toolu_01","name":"Bash","input":{"cmd":"ls"}}]}`)
+ reversed := reverseRemapOAuthToolNames(bashResp, reverseMap)
+ if got := gjson.GetBytes(reversed, "content.0.name").String(); got != "Bash" {
+ t.Fatalf("content.0.name = %q, want %q (Bash must be preserved; was never forward-renamed)", got, "Bash")
+ }
+
+ // Upstream responds with a `Glob` tool_use. Since we renamed `glob`→`Glob`,
+ // reverseRemap MUST restore the original `glob`.
+ globResp := []byte(`{"content":[{"type":"tool_use","id":"toolu_02","name":"Glob","input":{"filePattern":"**/*.go"}}]}`)
+ reversed = reverseRemapOAuthToolNames(globResp, reverseMap)
+ if got := gjson.GetBytes(reversed, "content.0.name").String(); got != "glob" {
+ t.Fatalf("content.0.name = %q, want %q (Glob must be restored to client's original `glob`)", got, "glob")
+ }
+}
+
+// TestReverseRemapOAuthToolNamesFromStreamLine_HonorsPerRequestMap guards the
+// SSE streaming code path against the same mixed-case bug.
+func TestReverseRemapOAuthToolNamesFromStreamLine_HonorsPerRequestMap(t *testing.T) {
+ reverseMap := map[string]string{"Glob": "glob"}
+
+ // Bash block was never renamed, must pass through as-is.
+ bashLine := []byte(`data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_01","name":"Bash","input":{}}}`)
+ out := reverseRemapOAuthToolNamesFromStreamLine(bashLine, reverseMap)
+ if !bytes.Contains(out, []byte(`"name":"Bash"`)) {
+ t.Fatalf("Bash should be preserved, got: %s", string(out))
+ }
+ if bytes.Contains(out, []byte(`"name":"bash"`)) {
+ t.Fatalf("Bash must not be lowercased, got: %s", string(out))
+ }
+
+ // Glob block IS in the reverseMap, must be restored to `glob`.
+ globLine := []byte(`data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_02","name":"Glob","input":{}}}`)
+ out = reverseRemapOAuthToolNamesFromStreamLine(globLine, reverseMap)
+ if !bytes.Contains(out, []byte(`"name":"glob"`)) {
+ t.Fatalf("Glob should be restored to glob, got: %s", string(out))
+ }
+}
+
+func TestPrepareClaudeOAuthToolNamesForUpstream_MixedCaseWithPrefix(t *testing.T) {
+ body := []byte(`{"tools":[` +
+ `{"name":"Bash","input_schema":{"type":"object","properties":{"cmd":{"type":"string"}}}},` +
+ `{"name":"glob","input_schema":{"type":"object","properties":{"filePattern":{"type":"string"}}}}` +
+ `],"messages":[{"role":"assistant","content":[` +
+ `{"type":"tool_use","id":"toolu_01","name":"Bash","input":{}},` +
+ `{"type":"tool_use","id":"toolu_02","name":"glob","input":{}}` +
+ `]}]}`)
+
+ out, reverseMap := prepareClaudeOAuthToolNamesForUpstream(body, "proxy_", false)
+
+ if got := gjson.GetBytes(out, "tools.0.name").String(); got != "proxy_Bash" {
+ t.Fatalf("tools.0.name = %q, want %q", got, "proxy_Bash")
+ }
+ if got := gjson.GetBytes(out, "tools.1.name").String(); got != "proxy_Glob" {
+ t.Fatalf("tools.1.name = %q, want %q", got, "proxy_Glob")
+ }
+ if got := gjson.GetBytes(out, "messages.0.content.0.name").String(); got != "proxy_Bash" {
+ t.Fatalf("messages.0.content.0.name = %q, want %q", got, "proxy_Bash")
+ }
+ if got := gjson.GetBytes(out, "messages.0.content.1.name").String(); got != "proxy_Glob" {
+ t.Fatalf("messages.0.content.1.name = %q, want %q", got, "proxy_Glob")
+ }
+ if len(reverseMap) != 1 || reverseMap["Glob"] != "glob" {
+ t.Fatalf("reverseMap = %v, want {Glob:glob}", reverseMap)
+ }
+}
+
+func TestRestoreClaudeOAuthToolNamesFromResponse_MixedCaseWithPrefix(t *testing.T) {
+ reverseMap := map[string]string{"Glob": "glob"}
+ resp := []byte(`{"content":[` +
+ `{"type":"tool_use","id":"toolu_01","name":"proxy_Bash","input":{}},` +
+ `{"type":"tool_use","id":"toolu_02","name":"proxy_Glob","input":{}}` +
+ `]}`)
+
+ out := restoreClaudeOAuthToolNamesFromResponse(resp, "proxy_", false, reverseMap)
+
+ if got := gjson.GetBytes(out, "content.0.name").String(); got != "Bash" {
+ t.Fatalf("content.0.name = %q, want %q", got, "Bash")
+ }
+ if got := gjson.GetBytes(out, "content.1.name").String(); got != "glob" {
+ t.Fatalf("content.1.name = %q, want %q", got, "glob")
+ }
+}
+
+func TestRestoreClaudeOAuthToolNamesFromStreamLine_MixedCaseWithPrefix(t *testing.T) {
+ reverseMap := map[string]string{"Glob": "glob"}
+
+ bashLine := []byte(`data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_01","name":"proxy_Bash","input":{}}}`)
+ out := restoreClaudeOAuthToolNamesFromStreamLine(bashLine, "proxy_", false, reverseMap)
+ if !bytes.Contains(out, []byte(`"name":"Bash"`)) {
+ t.Fatalf("Bash should be preserved, got: %s", string(out))
+ }
+ if bytes.Contains(out, []byte(`"name":"bash"`)) {
+ t.Fatalf("Bash must not be lowercased, got: %s", string(out))
+ }
+
+ globLine := []byte(`data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_02","name":"proxy_Glob","input":{}}}`)
+ out = restoreClaudeOAuthToolNamesFromStreamLine(globLine, "proxy_", false, reverseMap)
+ if !bytes.Contains(out, []byte(`"name":"glob"`)) {
+ t.Fatalf("Glob should be restored to glob, got: %s", string(out))
+ }
+}
diff --git a/internal/runtime/executor/claude_signing.go b/internal/runtime/executor/claude_signing.go
index 697a688265..060e86e846 100644
--- a/internal/runtime/executor/claude_signing.go
+++ b/internal/runtime/executor/claude_signing.go
@@ -6,8 +6,8 @@ import (
"strings"
xxHash64 "github.com/pierrec/xxHash/xxHash64"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
- cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
diff --git a/internal/runtime/executor/codex_executor.go b/internal/runtime/executor/codex_executor.go
index 41b1c32527..a1bbe6b84a 100644
--- a/internal/runtime/executor/codex_executor.go
+++ b/internal/runtime/executor/codex_executor.go
@@ -11,15 +11,15 @@ import (
"strings"
"time"
- codexauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/codex"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/executor/helps"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
- "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"
+ codexauth "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/codex"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/misc"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/util"
+ cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
+ cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
+ sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
log "github.com/sirupsen/logrus"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
@@ -30,12 +30,76 @@ import (
)
const (
- codexUserAgent = "codex-tui/0.118.0 (Mac OS 26.3.1; arm64) iTerm.app/3.6.9 (codex-tui; 0.118.0)"
- codexOriginator = "codex-tui"
+ codexUserAgent = "codex_cli_rs/0.118.0 (Mac OS 26.3.1; arm64) iTerm.app/3.6.9"
+ codexOriginator = "codex_cli_rs"
+ codexDefaultImageToolModel = "gpt-image-2"
)
var dataTag = []byte("data:")
+// Streamed Codex responses may emit response.output_item.done events while leaving
+// response.completed.response.output empty. Keep the stream path aligned with the
+// already-patched non-stream path by reconstructing response.output from those items.
+func collectCodexOutputItemDone(eventData []byte, outputItemsByIndex map[int64][]byte, outputItemsFallback *[][]byte) {
+ itemResult := gjson.GetBytes(eventData, "item")
+ if !itemResult.Exists() || itemResult.Type != gjson.JSON {
+ return
+ }
+ outputIndexResult := gjson.GetBytes(eventData, "output_index")
+ if outputIndexResult.Exists() {
+ outputItemsByIndex[outputIndexResult.Int()] = []byte(itemResult.Raw)
+ return
+ }
+ *outputItemsFallback = append(*outputItemsFallback, []byte(itemResult.Raw))
+}
+
+func patchCodexCompletedOutput(eventData []byte, outputItemsByIndex map[int64][]byte, outputItemsFallback [][]byte) []byte {
+ outputResult := gjson.GetBytes(eventData, "response.output")
+ shouldPatchOutput := (!outputResult.Exists() || !outputResult.IsArray() || len(outputResult.Array()) == 0) && (len(outputItemsByIndex) > 0 || len(outputItemsFallback) > 0)
+ if !shouldPatchOutput {
+ return eventData
+ }
+
+ indexes := make([]int64, 0, len(outputItemsByIndex))
+ for idx := range outputItemsByIndex {
+ indexes = append(indexes, idx)
+ }
+ sort.Slice(indexes, func(i, j int) bool {
+ return indexes[i] < indexes[j]
+ })
+
+ items := make([][]byte, 0, len(outputItemsByIndex)+len(outputItemsFallback))
+ for _, idx := range indexes {
+ items = append(items, outputItemsByIndex[idx])
+ }
+ items = append(items, outputItemsFallback...)
+
+ outputArray := []byte("[]")
+ if len(items) > 0 {
+ var buf bytes.Buffer
+ totalLen := 2
+ for _, item := range items {
+ totalLen += len(item)
+ }
+ if len(items) > 1 {
+ totalLen += len(items) - 1
+ }
+ buf.Grow(totalLen)
+ buf.WriteByte('[')
+ for i, item := range items {
+ if i > 0 {
+ buf.WriteByte(',')
+ }
+ buf.Write(item)
+ }
+ buf.WriteByte(']')
+ outputArray = buf.Bytes()
+ }
+
+ completedDataPatched, _ := sjson.SetRawBytes(eventData, "response.output", outputArray)
+ return completedDataPatched
+}
+
// CodexExecutor is a stateless executor for Codex (OpenAI Responses API entrypoint).
// If api_key is unavailable on auth, it falls back to legacy via ClientAdapter.
type CodexExecutor struct {
@@ -109,7 +173,8 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re
}
requestedModel := helps.PayloadRequestedModel(opts, req.Model)
- body = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel)
+ requestPath := helps.PayloadRequestPath(opts)
+ body = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel, requestPath)
body, _ = sjson.SetBytes(body, "model", baseModel)
body, _ = sjson.SetBytes(body, "stream", true)
body, _ = sjson.DeleteBytes(body, "previous_response_id")
@@ -117,6 +182,9 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re
body, _ = sjson.DeleteBytes(body, "safety_identifier")
body, _ = sjson.DeleteBytes(body, "stream_options")
body = normalizeCodexInstructions(body)
+ if e.cfg == nil || e.cfg.DisableImageGeneration == config.DisableImageGenerationOff {
+ body = ensureImageGenerationTool(body, baseModel, auth)
+ }
url := strings.TrimSuffix(baseURL, "/") + "/responses"
httpReq, err := e.cacheHelper(ctx, from, url, req, body)
@@ -199,6 +267,7 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re
if detail, ok := helps.ParseCodexUsage(eventData); ok {
reporter.Publish(ctx, detail)
}
+ publishCodexImageToolUsage(ctx, reporter, body, eventData)
completedData := eventData
outputResult := gjson.GetBytes(completedData, "response.output")
@@ -259,10 +328,14 @@ func (e *CodexExecutor) executeCompact(ctx context.Context, auth *cliproxyauth.A
}
requestedModel := helps.PayloadRequestedModel(opts, req.Model)
- body = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel)
+ requestPath := helps.PayloadRequestPath(opts)
+ body = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel, requestPath)
body, _ = sjson.SetBytes(body, "model", baseModel)
body, _ = sjson.DeleteBytes(body, "stream")
body = normalizeCodexInstructions(body)
+ if e.cfg == nil || e.cfg.DisableImageGeneration == config.DisableImageGenerationOff {
+ body = ensureImageGenerationTool(body, baseModel, auth)
+ }
url := strings.TrimSuffix(baseURL, "/") + "/responses/compact"
httpReq, err := e.cacheHelper(ctx, from, url, req, body)
@@ -350,13 +423,17 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au
}
requestedModel := helps.PayloadRequestedModel(opts, req.Model)
- body = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel)
+ requestPath := helps.PayloadRequestPath(opts)
+ body = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel, requestPath)
body, _ = sjson.DeleteBytes(body, "previous_response_id")
body, _ = sjson.DeleteBytes(body, "prompt_cache_retention")
body, _ = sjson.DeleteBytes(body, "safety_identifier")
body, _ = sjson.DeleteBytes(body, "stream_options")
body, _ = sjson.SetBytes(body, "model", baseModel)
body = normalizeCodexInstructions(body)
+ if e.cfg == nil || e.cfg.DisableImageGeneration == config.DisableImageGenerationOff {
+ body = ensureImageGenerationTool(body, baseModel, auth)
+ }
url := strings.TrimSuffix(baseURL, "/") + "/responses"
httpReq, err := e.cacheHelper(ctx, from, url, req, body)
@@ -414,28 +491,44 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au
scanner := bufio.NewScanner(httpResp.Body)
scanner.Buffer(nil, 52_428_800) // 50MB
var param any
+ outputItemsByIndex := make(map[int64][]byte)
+ var outputItemsFallback [][]byte
for scanner.Scan() {
line := scanner.Bytes()
helps.AppendAPIResponseChunk(ctx, e.cfg, line)
+ translatedLine := bytes.Clone(line)
if bytes.HasPrefix(line, dataTag) {
data := bytes.TrimSpace(line[5:])
- if gjson.GetBytes(data, "type").String() == "response.completed" {
+ switch gjson.GetBytes(data, "type").String() {
+ case "response.output_item.done":
+ collectCodexOutputItemDone(data, outputItemsByIndex, &outputItemsFallback)
+ case "response.completed":
if detail, ok := helps.ParseCodexUsage(data); ok {
reporter.Publish(ctx, detail)
}
+ publishCodexImageToolUsage(ctx, reporter, body, data)
+ data = patchCodexCompletedOutput(data, outputItemsByIndex, outputItemsFallback)
+ translatedLine = append([]byte("data: "), data...)
}
}
- chunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, originalPayload, body, bytes.Clone(line), ¶m)
+ chunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, originalPayload, body, translatedLine, ¶m)
for i := range chunks {
- out <- cliproxyexecutor.StreamChunk{Payload: chunks[i]}
+ select {
+ case out <- cliproxyexecutor.StreamChunk{Payload: chunks[i]}:
+ case <-ctx.Done():
+ return
+ }
}
}
if errScan := scanner.Err(); errScan != nil {
helps.RecordAPIResponseError(ctx, e.cfg, errScan)
- reporter.PublishFailure(ctx)
- out <- cliproxyexecutor.StreamChunk{Err: errScan}
+ reporter.PublishFailure(ctx, errScan)
+ select {
+ case out <- cliproxyexecutor.StreamChunk{Err: errScan}:
+ case <-ctx.Done():
+ }
}
}()
return &cliproxyexecutor.StreamResult{Headers: httpResp.Header.Clone(), Chunks: out}, nil
@@ -600,6 +693,9 @@ func countCodexInputTokens(enc tokenizer.Codec, body []byte) (int64, error) {
func (e *CodexExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) {
log.Debugf("codex executor: refresh called")
+ if refreshed, handled, err := helps.RefreshAuthViaHome(ctx, e.cfg, auth); handled {
+ return refreshed, err
+ }
if auth == nil {
return nil, statusErr{code: 500, msg: "codex executor: auth is nil"}
}
@@ -735,6 +831,7 @@ func newCodexStatusErr(statusCode int, body []byte) statusErr {
if isCodexModelCapacityError(body) {
errCode = http.StatusTooManyRequests
}
+ body = classifyCodexStatusError(errCode, body)
err := statusErr{code: errCode, msg: string(body)}
if retryAfter := parseCodexRetryAfter(errCode, body, time.Now()); retryAfter != nil {
err.retryAfter = retryAfter
@@ -742,6 +839,52 @@ func newCodexStatusErr(statusCode int, body []byte) statusErr {
return err
}
+func classifyCodexStatusError(statusCode int, body []byte) []byte {
+ code, errType, ok := codexStatusErrorClassification(statusCode, body)
+ if !ok {
+ return body
+ }
+ message := gjson.GetBytes(body, "error.message").String()
+ if message == "" {
+ message = gjson.GetBytes(body, "message").String()
+ }
+ if message == "" {
+ message = strings.TrimSpace(string(body))
+ }
+ if message == "" {
+ message = http.StatusText(statusCode)
+ }
+ out := []byte(`{"error":{}}`)
+ out, _ = sjson.SetBytes(out, "error.message", message)
+ out, _ = sjson.SetBytes(out, "error.type", errType)
+ out, _ = sjson.SetBytes(out, "error.code", code)
+ return out
+}
+
+func codexStatusErrorClassification(statusCode int, body []byte) (code string, errType string, ok bool) {
+ errorMessage := strings.ToLower(strings.TrimSpace(gjson.GetBytes(body, "error.message").String()))
+ if errorMessage == "" {
+ errorMessage = strings.ToLower(strings.TrimSpace(gjson.GetBytes(body, "message").String()))
+ }
+ lower := strings.ToLower(strings.TrimSpace(string(body)))
+ upstreamCode := strings.ToLower(strings.TrimSpace(gjson.GetBytes(body, "error.code").String()))
+ upstreamType := strings.ToLower(strings.TrimSpace(gjson.GetBytes(body, "error.type").String()))
+ isInvalidRequest := upstreamType == "" || upstreamType == "invalid_request_error"
+
+ switch {
+ case statusCode == http.StatusRequestEntityTooLarge || upstreamCode == "context_length_exceeded" || upstreamCode == "context_too_large" || isInvalidRequest && (strings.Contains(errorMessage, "context length") || strings.Contains(errorMessage, "context_length") || strings.Contains(errorMessage, "maximum context") || strings.Contains(errorMessage, "too many tokens")):
+ return "context_too_large", "invalid_request_error", true
+ case strings.Contains(lower, "invalid signature in thinking block") || strings.Contains(lower, "invalid_encrypted_content"):
+ return "thinking_signature_invalid", "invalid_request_error", true
+ case upstreamCode == "previous_response_not_found" || strings.Contains(lower, "previous_response_not_found") || strings.Contains(lower, "previous_response_id") && strings.Contains(lower, "not found"):
+ return "previous_response_not_found", "invalid_request_error", true
+ case statusCode == http.StatusUnauthorized || upstreamType == "authentication_error" || upstreamCode == "invalid_api_key" || strings.Contains(lower, "invalid or expired token") || strings.Contains(lower, "refresh_token_reused"):
+ return "auth_unavailable", "authentication_error", true
+ default:
+ return "", "", false
+ }
+}
+
func normalizeCodexInstructions(body []byte) []byte {
instructions := gjson.GetBytes(body, "instructions")
if !instructions.Exists() || instructions.Type == gjson.Null {
@@ -750,6 +893,66 @@ func normalizeCodexInstructions(body []byte) []byte {
return body
}
+var imageGenToolJSON = []byte(`{"type":"image_generation","output_format":"png"}`)
+var imageGenToolArrayJSON = []byte(`[{"type":"image_generation","output_format":"png"}]`)
+
+func isCodexFreePlanAuth(auth *cliproxyauth.Auth) bool {
+ if auth == nil || auth.Attributes == nil {
+ return false
+ }
+ if !strings.EqualFold(strings.TrimSpace(auth.Provider), "codex") {
+ return false
+ }
+ return strings.EqualFold(strings.TrimSpace(auth.Attributes["plan_type"]), "free")
+}
+
+func ensureImageGenerationTool(body []byte, baseModel string, auth *cliproxyauth.Auth) []byte {
+ if strings.HasSuffix(baseModel, "spark") {
+ return body
+ }
+ if isCodexFreePlanAuth(auth) {
+ return body
+ }
+
+ tools := gjson.GetBytes(body, "tools")
+ if !tools.Exists() || !tools.IsArray() {
+ body, _ = sjson.SetRawBytes(body, "tools", imageGenToolArrayJSON)
+ return body
+ }
+ for _, t := range tools.Array() {
+ if t.Get("type").String() == "image_generation" {
+ return body
+ }
+ }
+ body, _ = sjson.SetRawBytes(body, "tools.-1", imageGenToolJSON)
+ return body
+}
+
+func publishCodexImageToolUsage(ctx context.Context, reporter *helps.UsageReporter, body []byte, completedData []byte) {
+ detail, ok := helps.ParseCodexImageToolUsage(completedData)
+ if !ok {
+ return
+ }
+ reporter.EnsurePublished(ctx)
+ reporter.PublishAdditionalModel(ctx, codexImageGenerationToolModel(body), detail)
+}
+
+func codexImageGenerationToolModel(body []byte) string {
+ tools := gjson.GetBytes(body, "tools")
+ if tools.IsArray() {
+ for _, tool := range tools.Array() {
+ if tool.Get("type").String() != "image_generation" {
+ continue
+ }
+ if model := strings.TrimSpace(tool.Get("model").String()); model != "" {
+ return model
+ }
+ break
+ }
+ }
+ return codexDefaultImageToolModel
+}
+
func isCodexModelCapacityError(errorBody []byte) bool {
if len(errorBody) == 0 {
return false
diff --git a/internal/runtime/executor/codex_executor_cache_test.go b/internal/runtime/executor/codex_executor_cache_test.go
index 7a24fd9643..cb96a90289 100644
--- a/internal/runtime/executor/codex_executor_cache_test.go
+++ b/internal/runtime/executor/codex_executor_cache_test.go
@@ -8,15 +8,15 @@ import (
"github.com/gin-gonic/gin"
"github.com/google/uuid"
- cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
- sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
+ cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
+ sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
"github.com/tidwall/gjson"
)
func TestCodexExecutorCacheHelper_OpenAIChatCompletions_StablePromptCacheKeyFromAPIKey(t *testing.T) {
recorder := httptest.NewRecorder()
ginCtx, _ := gin.CreateTestContext(recorder)
- ginCtx.Set("apiKey", "test-api-key")
+ ginCtx.Set("userApiKey", "test-api-key")
ctx := context.WithValue(context.Background(), "gin", ginCtx)
executor := &CodexExecutor{}
diff --git a/internal/runtime/executor/codex_executor_compact_test.go b/internal/runtime/executor/codex_executor_compact_test.go
index 02c6db29fd..549cad9e77 100644
--- a/internal/runtime/executor/codex_executor_compact_test.go
+++ b/internal/runtime/executor/codex_executor_compact_test.go
@@ -7,10 +7,10 @@ import (
"net/http/httptest"
"testing"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
- 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"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
+ cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
+ sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
"github.com/tidwall/gjson"
)
diff --git a/internal/runtime/executor/codex_executor_imagegen_test.go b/internal/runtime/executor/codex_executor_imagegen_test.go
new file mode 100644
index 0000000000..89d2a1c2a3
--- /dev/null
+++ b/internal/runtime/executor/codex_executor_imagegen_test.go
@@ -0,0 +1,118 @@
+package executor
+
+import (
+ "testing"
+
+ cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
+ "github.com/tidwall/gjson"
+)
+
+func TestEnsureImageGenerationTool_NoTools(t *testing.T) {
+ body := []byte(`{"model":"gpt-5.4","input":"draw a cat"}`)
+ result := ensureImageGenerationTool(body, "gpt-5.4", nil)
+
+ tools := gjson.GetBytes(result, "tools")
+ if !tools.IsArray() {
+ t.Fatalf("expected tools array, got %v", tools.Type)
+ }
+ arr := tools.Array()
+ if len(arr) != 1 {
+ t.Fatalf("expected 1 tool, got %d", len(arr))
+ }
+ if arr[0].Get("type").String() != "image_generation" {
+ t.Fatalf("expected type=image_generation, got %s", arr[0].Get("type").String())
+ }
+ if arr[0].Get("output_format").String() != "png" {
+ t.Fatalf("expected output_format=png, got %s", arr[0].Get("output_format").String())
+ }
+}
+
+func TestEnsureImageGenerationTool_ExistingToolsWithoutImageGen(t *testing.T) {
+ body := []byte(`{"model":"gpt-5.4","tools":[{"type":"function","name":"get_weather","parameters":{}}]}`)
+ result := ensureImageGenerationTool(body, "gpt-5.4", nil)
+
+ tools := gjson.GetBytes(result, "tools")
+ arr := tools.Array()
+ if len(arr) != 2 {
+ t.Fatalf("expected 2 tools, got %d", len(arr))
+ }
+ if arr[0].Get("type").String() != "function" {
+ t.Fatalf("expected first tool type=function, got %s", arr[0].Get("type").String())
+ }
+ if arr[1].Get("type").String() != "image_generation" {
+ t.Fatalf("expected second tool type=image_generation, got %s", arr[1].Get("type").String())
+ }
+}
+
+func TestEnsureImageGenerationTool_AlreadyPresent(t *testing.T) {
+ body := []byte(`{"model":"gpt-5.4","tools":[{"type":"image_generation","output_format":"webp"},{"type":"function","name":"f1"}]}`)
+ result := ensureImageGenerationTool(body, "gpt-5.4", nil)
+
+ tools := gjson.GetBytes(result, "tools")
+ arr := tools.Array()
+ if len(arr) != 2 {
+ t.Fatalf("expected 2 tools (no duplicate), got %d", len(arr))
+ }
+ if arr[0].Get("output_format").String() != "webp" {
+ t.Fatalf("expected original output_format=webp preserved, got %s", arr[0].Get("output_format").String())
+ }
+}
+
+func TestEnsureImageGenerationTool_EmptyToolsArray(t *testing.T) {
+ body := []byte(`{"model":"gpt-5.4","tools":[]}`)
+ result := ensureImageGenerationTool(body, "gpt-5.4", nil)
+
+ tools := gjson.GetBytes(result, "tools")
+ arr := tools.Array()
+ if len(arr) != 1 {
+ t.Fatalf("expected 1 tool, got %d", len(arr))
+ }
+ if arr[0].Get("type").String() != "image_generation" {
+ t.Fatalf("expected type=image_generation, got %s", arr[0].Get("type").String())
+ }
+}
+
+func TestEnsureImageGenerationTool_WebSearchAndImageGen(t *testing.T) {
+ body := []byte(`{"model":"gpt-5.4","tools":[{"type":"web_search"}]}`)
+ result := ensureImageGenerationTool(body, "gpt-5.4", nil)
+
+ tools := gjson.GetBytes(result, "tools")
+ arr := tools.Array()
+ if len(arr) != 2 {
+ t.Fatalf("expected 2 tools, got %d", len(arr))
+ }
+ if arr[0].Get("type").String() != "web_search" {
+ t.Fatalf("expected first tool type=web_search, got %s", arr[0].Get("type").String())
+ }
+ if arr[1].Get("type").String() != "image_generation" {
+ t.Fatalf("expected second tool type=image_generation, got %s", arr[1].Get("type").String())
+ }
+}
+
+func TestEnsureImageGenerationTool_GPT53CodexSparkDoesNotInjectTool(t *testing.T) {
+ body := []byte(`{"model":"gpt-5.3-codex-spark","input":"draw a cat"}`)
+ result := ensureImageGenerationTool(body, "gpt-5.3-codex-spark", nil)
+
+ if string(result) != string(body) {
+ t.Fatalf("expected body to be unchanged, got %s", string(result))
+ }
+ if gjson.GetBytes(result, "tools").Exists() {
+ t.Fatalf("expected no tools for gpt-5.3-codex-spark, got %s", gjson.GetBytes(result, "tools").Raw)
+ }
+}
+
+func TestEnsureImageGenerationTool_FreeCodexAuthDoesNotInjectTool(t *testing.T) {
+ body := []byte(`{"model":"gpt-5.4","input":"draw a cat"}`)
+ freeAuth := &cliproxyauth.Auth{
+ Provider: "codex",
+ Attributes: map[string]string{"plan_type": "free"},
+ }
+ result := ensureImageGenerationTool(body, "gpt-5.4", freeAuth)
+
+ if string(result) != string(body) {
+ t.Fatalf("expected body to be unchanged, got %s", string(result))
+ }
+ if gjson.GetBytes(result, "tools").Exists() {
+ t.Fatalf("expected no tools for free codex auth, got %s", gjson.GetBytes(result, "tools").Raw)
+ }
+}
diff --git a/internal/runtime/executor/codex_executor_instructions_test.go b/internal/runtime/executor/codex_executor_instructions_test.go
index c5dc5aa813..b3c8ac18ac 100644
--- a/internal/runtime/executor/codex_executor_instructions_test.go
+++ b/internal/runtime/executor/codex_executor_instructions_test.go
@@ -7,10 +7,10 @@ import (
"net/http/httptest"
"testing"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
- 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"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
+ cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
+ sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
"github.com/tidwall/gjson"
)
diff --git a/internal/runtime/executor/codex_executor_retry_test.go b/internal/runtime/executor/codex_executor_retry_test.go
index 249d40d656..7207d5734c 100644
--- a/internal/runtime/executor/codex_executor_retry_test.go
+++ b/internal/runtime/executor/codex_executor_retry_test.go
@@ -1,6 +1,7 @@
package executor
import (
+ "encoding/json"
"net/http"
"strconv"
"testing"
@@ -73,6 +74,94 @@ func TestNewCodexStatusErrTreatsCapacityAsRetryableRateLimit(t *testing.T) {
}
}
+func TestNewCodexStatusErrClassifiesKnownCodexFailures(t *testing.T) {
+ tests := []struct {
+ name string
+ statusCode int
+ body []byte
+ wantStatus int
+ wantType string
+ wantCode string
+ }{
+ {
+ name: "context length status",
+ statusCode: http.StatusRequestEntityTooLarge,
+ body: []byte(`{"error":{"message":"context length exceeded","type":"invalid_request_error","code":"context_length_exceeded"}}`),
+ wantStatus: http.StatusRequestEntityTooLarge,
+ wantType: "invalid_request_error",
+ wantCode: "context_too_large",
+ },
+ {
+ name: "thinking signature",
+ statusCode: http.StatusBadRequest,
+ body: []byte(`{"error":{"message":"Invalid signature in thinking block","type":"invalid_request_error","code":"invalid_request_error"}}`),
+ wantStatus: http.StatusBadRequest,
+ wantType: "invalid_request_error",
+ wantCode: "thinking_signature_invalid",
+ },
+ {
+ name: "previous response missing",
+ statusCode: http.StatusBadRequest,
+ body: []byte(`{"error":{"message":"No response found for previous_response_id resp_123","type":"invalid_request_error","code":"previous_response_not_found"}}`),
+ wantStatus: http.StatusBadRequest,
+ wantType: "invalid_request_error",
+ wantCode: "previous_response_not_found",
+ },
+ {
+ name: "auth unavailable",
+ statusCode: http.StatusUnauthorized,
+ body: []byte(`{"error":{"message":"invalid or expired token","type":"authentication_error","code":"invalid_api_key"}}`),
+ wantStatus: http.StatusUnauthorized,
+ wantType: "authentication_error",
+ wantCode: "auth_unavailable",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ err := newCodexStatusErr(tc.statusCode, tc.body)
+
+ if got := err.StatusCode(); got != tc.wantStatus {
+ t.Fatalf("status code = %d, want %d", got, tc.wantStatus)
+ }
+ assertCodexErrorCode(t, err.Error(), tc.wantType, tc.wantCode)
+ })
+ }
+}
+
+func TestNewCodexStatusErrPreservesUnclassifiedErrors(t *testing.T) {
+ body := []byte(`{"error":{"message":"documentation mentions too many tokens, but this is a billing configuration failure","type":"server_error","code":"billing_config_error"}}`)
+
+ err := newCodexStatusErr(http.StatusBadGateway, body)
+
+ if got := err.StatusCode(); got != http.StatusBadGateway {
+ t.Fatalf("status code = %d, want %d", got, http.StatusBadGateway)
+ }
+ if got := err.Error(); got != string(body) {
+ t.Fatalf("error body = %s, want original %s", got, string(body))
+ }
+}
+
+func assertCodexErrorCode(t *testing.T, raw string, wantType string, wantCode string) {
+ t.Helper()
+
+ var payload struct {
+ Error struct {
+ Type string `json:"type"`
+ Code string `json:"code"`
+ } `json:"error"`
+ }
+ if err := json.Unmarshal([]byte(raw), &payload); err != nil {
+ t.Fatalf("error body is not valid JSON: %v; body=%s", err, raw)
+ }
+ if payload.Error.Type != wantType {
+ t.Fatalf("error.type = %q, want %q; body=%s", payload.Error.Type, wantType, raw)
+ }
+ if payload.Error.Code != wantCode {
+ t.Fatalf("error.code = %q, want %q; body=%s", payload.Error.Code, wantCode, raw)
+ }
+}
+
func itoa(v int64) string {
return strconv.FormatInt(v, 10)
}
diff --git a/internal/runtime/executor/codex_executor_stream_output_test.go b/internal/runtime/executor/codex_executor_stream_output_test.go
index 91d9b0761c..b814c3e96d 100644
--- a/internal/runtime/executor/codex_executor_stream_output_test.go
+++ b/internal/runtime/executor/codex_executor_stream_output_test.go
@@ -1,16 +1,17 @@
package executor
import (
+ "bytes"
"context"
"net/http"
"net/http/httptest"
"testing"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
- _ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator"
- 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"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator"
+ cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
+ cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
+ sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
"github.com/tidwall/gjson"
)
@@ -44,3 +45,53 @@ func TestCodexExecutorExecute_EmptyStreamCompletionOutputUsesOutputItemDone(t *t
t.Fatalf("choices.0.message.content = %q, want %q; payload=%s", gotContent, "ok", string(resp.Payload))
}
}
+
+func TestCodexExecutorExecuteStream_EmptyStreamCompletionOutputUsesOutputItemDone(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/event-stream")
+ _, _ = w.Write([]byte("data: {\"type\":\"response.output_item.done\",\"item\":{\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"ok\"}]},\"output_index\":0}\n"))
+ _, _ = w.Write([]byte("data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_1\",\"object\":\"response\",\"created_at\":1775555723,\"status\":\"completed\",\"model\":\"gpt-5.4-mini-2026-03-17\",\"output\":[],\"usage\":{\"input_tokens\":8,\"output_tokens\":28,\"total_tokens\":36}}}\n\n"))
+ }))
+ defer server.Close()
+
+ executor := NewCodexExecutor(&config.Config{})
+ auth := &cliproxyauth.Auth{Attributes: map[string]string{
+ "base_url": server.URL,
+ "api_key": "test",
+ }}
+
+ result, err := executor.ExecuteStream(context.Background(), auth, cliproxyexecutor.Request{
+ Model: "gpt-5.4-mini",
+ Payload: []byte(`{"model":"gpt-5.4-mini","input":"Say ok"}`),
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FromString("openai-response"),
+ Stream: true,
+ })
+ if err != nil {
+ t.Fatalf("ExecuteStream error: %v", err)
+ }
+
+ var completed []byte
+ for chunk := range result.Chunks {
+ if chunk.Err != nil {
+ t.Fatalf("stream chunk error: %v", chunk.Err)
+ }
+ payload := bytes.TrimSpace(chunk.Payload)
+ if !bytes.HasPrefix(payload, []byte("data:")) {
+ continue
+ }
+ data := bytes.TrimSpace(payload[5:])
+ if gjson.GetBytes(data, "type").String() == "response.completed" {
+ completed = append([]byte(nil), data...)
+ }
+ }
+
+ if len(completed) == 0 {
+ t.Fatal("missing response.completed chunk")
+ }
+
+ gotContent := gjson.GetBytes(completed, "response.output.0.content.0.text").String()
+ if gotContent != "ok" {
+ t.Fatalf("response.output[0].content[0].text = %q, want %q; completed=%s", gotContent, "ok", string(completed))
+ }
+}
diff --git a/internal/runtime/executor/codex_websockets_executor.go b/internal/runtime/executor/codex_websockets_executor.go
index 94c9b262e8..2b56f13b1c 100644
--- a/internal/runtime/executor/codex_websockets_executor.go
+++ b/internal/runtime/executor/codex_websockets_executor.go
@@ -18,15 +18,15 @@ import (
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/gorilla/websocket"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/executor/helps"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
- "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"
- "github.com/router-for-me/CLIProxyAPI/v6/sdk/proxyutil"
- sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/misc"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/util"
+ cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
+ cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
+ "github.com/router-for-me/CLIProxyAPI/v7/sdk/proxyutil"
+ sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
log "github.com/sirupsen/logrus"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
@@ -76,6 +76,9 @@ type codexWebsocketSession struct {
activeCancel context.CancelFunc
readerConn *websocket.Conn
+
+ upstreamDisconnectOnce sync.Once
+ upstreamDisconnectCh chan error
}
func NewCodexWebsocketsExecutor(cfg *config.Config) *CodexWebsocketsExecutor {
@@ -151,6 +154,22 @@ func (s *codexWebsocketSession) configureConn(conn *websocket.Conn) {
})
}
+func (s *codexWebsocketSession) notifyUpstreamDisconnect(err error) {
+ if s == nil {
+ return
+ }
+ s.upstreamDisconnectOnce.Do(func() {
+ if s.upstreamDisconnectCh == nil {
+ return
+ }
+ select {
+ case s.upstreamDisconnectCh <- err:
+ default:
+ }
+ close(s.upstreamDisconnectCh)
+ })
+}
+
func (e *CodexWebsocketsExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) {
if ctx == nil {
ctx = context.Background()
@@ -184,14 +203,15 @@ func (e *CodexWebsocketsExecutor) Execute(ctx context.Context, auth *cliproxyaut
}
requestedModel := helps.PayloadRequestedModel(opts, req.Model)
- body = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel)
+ requestPath := helps.PayloadRequestPath(opts)
+ body = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel, requestPath)
body, _ = sjson.SetBytes(body, "model", baseModel)
body, _ = sjson.SetBytes(body, "stream", true)
- body, _ = sjson.DeleteBytes(body, "previous_response_id")
body, _ = sjson.DeleteBytes(body, "prompt_cache_retention")
body, _ = sjson.DeleteBytes(body, "safety_identifier")
- if !gjson.GetBytes(body, "instructions").Exists() {
- body, _ = sjson.SetBytes(body, "instructions", "")
+ body = normalizeCodexInstructions(body)
+ if e.cfg == nil || e.cfg.DisableImageGeneration == config.DisableImageGenerationOff {
+ body = ensureImageGenerationTool(body, baseModel, auth)
}
httpURL := strings.TrimSuffix(baseURL, "/") + "/responses"
@@ -387,7 +407,12 @@ func (e *CodexWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *clipr
}
requestedModel := helps.PayloadRequestedModel(opts, req.Model)
- body = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, body, requestedModel)
+ requestPath := helps.PayloadRequestPath(opts)
+ body = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, body, requestedModel, requestPath)
+ body = normalizeCodexInstructions(body)
+ if e.cfg == nil || e.cfg.DisableImageGeneration == config.DisableImageGenerationOff {
+ body = ensureImageGenerationTool(body, baseModel, auth)
+ }
httpURL := strings.TrimSuffix(baseURL, "/") + "/responses"
wsURL, err := buildCodexResponsesWebsocketURL(httpURL)
@@ -555,7 +580,7 @@ func (e *CodexWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *clipr
terminateReason = "read_error"
terminateErr = errRead
helps.RecordAPIWebsocketError(ctx, e.cfg, "read", errRead)
- reporter.PublishFailure(ctx)
+ reporter.PublishFailure(ctx, errRead)
_ = send(cliproxyexecutor.StreamChunk{Err: errRead})
return
}
@@ -565,7 +590,7 @@ func (e *CodexWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *clipr
terminateReason = "unexpected_binary"
terminateErr = err
helps.RecordAPIWebsocketError(ctx, e.cfg, "unexpected_binary", err)
- reporter.PublishFailure(ctx)
+ reporter.PublishFailure(ctx, err)
if sess != nil {
e.invalidateUpstreamConn(sess, conn, "unexpected_binary", err)
}
@@ -585,7 +610,7 @@ func (e *CodexWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *clipr
terminateReason = "upstream_error"
terminateErr = wsErr
helps.RecordAPIWebsocketError(ctx, e.cfg, "upstream_error", wsErr)
- reporter.PublishFailure(ctx)
+ reporter.PublishFailure(ctx, wsErr)
if sess != nil {
e.invalidateUpstreamConn(sess, conn, "upstream_error", wsErr)
}
@@ -769,6 +794,11 @@ func buildCodexResponsesWebsocketURL(httpURL string) (string, error) {
parsed.Scheme = "ws"
case "https":
parsed.Scheme = "wss"
+ default:
+ return "", fmt.Errorf("codex websockets executor: unsupported responses websocket URL scheme %q", parsed.Scheme)
+ }
+ if strings.TrimSpace(parsed.Host) == "" {
+ return "", fmt.Errorf("codex websockets executor: responses websocket URL host is empty")
}
return parsed.String(), nil
}
@@ -802,6 +832,7 @@ func applyCodexPromptCacheHeaders(from sdktranslator.Format, req cliproxyexecuto
if cache.ID != "" {
rawJSON, _ = sjson.SetBytes(rawJSON, "prompt_cache_key", cache.ID)
+ setHeaderCasePreserved(headers, "session_id", cache.ID)
headers.Set("Conversation_id", cache.ID)
}
@@ -821,13 +852,19 @@ func applyCodexWebsocketHeaders(ctx context.Context, headers http.Header, auth *
ginHeaders = ginCtx.Request.Header.Clone()
}
- _, cfgBetaFeatures := codexHeaderDefaults(cfg, auth)
+ isAPIKey := codexAuthUsesAPIKey(auth)
+ cfgUserAgent, cfgBetaFeatures := codexHeaderDefaults(cfg, auth)
ensureHeaderWithPriority(headers, ginHeaders, "x-codex-beta-features", cfgBetaFeatures, "")
misc.EnsureHeader(headers, ginHeaders, "x-codex-turn-state", "")
misc.EnsureHeader(headers, ginHeaders, "x-codex-turn-metadata", "")
misc.EnsureHeader(headers, ginHeaders, "x-client-request-id", "")
misc.EnsureHeader(headers, ginHeaders, "x-responsesapi-include-timing-metrics", "")
misc.EnsureHeader(headers, ginHeaders, "Version", "")
+ if isAPIKey {
+ ensureHeaderWithPriority(headers, ginHeaders, "User-Agent", "", "")
+ } else {
+ ensureHeaderWithConfigPrecedence(headers, ginHeaders, "User-Agent", cfgUserAgent, codexUserAgent)
+ }
betaHeader := strings.TrimSpace(headers.Get("OpenAI-Beta"))
if betaHeader == "" && ginHeaders != nil {
@@ -838,16 +875,9 @@ func applyCodexWebsocketHeaders(ctx context.Context, headers http.Header, auth *
}
headers.Set("OpenAI-Beta", betaHeader)
if strings.Contains(headers.Get("User-Agent"), "Mac OS") {
- misc.EnsureHeader(headers, ginHeaders, "Session_id", uuid.NewString())
- }
- headers.Del("User-Agent")
-
- isAPIKey := false
- if auth != nil && auth.Attributes != nil {
- if v := strings.TrimSpace(auth.Attributes["api_key"]); v != "" {
- isAPIKey = true
- }
+ ensureHeaderCasePreserved(headers, ginHeaders, "session_id", "", uuid.NewString())
}
+ ensureHeaderCasePreserved(headers, ginHeaders, "session_id", "", "")
if originator := strings.TrimSpace(ginHeaders.Get("Originator")); originator != "" {
headers.Set("Originator", originator)
} else if !isAPIKey {
@@ -857,7 +887,7 @@ func applyCodexWebsocketHeaders(ctx context.Context, headers http.Header, auth *
if auth != nil && auth.Metadata != nil {
if accountID, ok := auth.Metadata["account_id"].(string); ok {
if trimmed := strings.TrimSpace(accountID); trimmed != "" {
- headers.Set("Chatgpt-Account-Id", trimmed)
+ setHeaderCasePreserved(headers, "ChatGPT-Account-ID", trimmed)
}
}
}
@@ -872,6 +902,77 @@ func applyCodexWebsocketHeaders(ctx context.Context, headers http.Header, auth *
return headers
}
+func codexAuthUsesAPIKey(auth *cliproxyauth.Auth) bool {
+ if auth == nil || auth.Attributes == nil {
+ return false
+ }
+ return strings.TrimSpace(auth.Attributes["api_key"]) != ""
+}
+
+func ensureHeaderCasePreserved(target http.Header, source http.Header, key, configValue, fallbackValue string) {
+ if target == nil {
+ return
+ }
+ if strings.TrimSpace(headerValueCaseInsensitive(target, key)) != "" {
+ return
+ }
+ if source != nil {
+ if val := strings.TrimSpace(headerValueCaseInsensitive(source, key)); val != "" {
+ setHeaderCasePreserved(target, key, val)
+ return
+ }
+ }
+ if val := strings.TrimSpace(configValue); val != "" {
+ setHeaderCasePreserved(target, key, val)
+ return
+ }
+ if val := strings.TrimSpace(fallbackValue); val != "" {
+ setHeaderCasePreserved(target, key, val)
+ }
+}
+
+func setHeaderCasePreserved(headers http.Header, key string, value string) {
+ if headers == nil {
+ return
+ }
+ key = strings.TrimSpace(key)
+ value = strings.TrimSpace(value)
+ if key == "" || value == "" {
+ return
+ }
+ deleteHeaderCaseInsensitive(headers, key)
+ headers[key] = []string{value}
+}
+
+func headerValueCaseInsensitive(headers http.Header, key string) string {
+ key = strings.TrimSpace(key)
+ if headers == nil || key == "" {
+ return ""
+ }
+ if val := strings.TrimSpace(headers.Get(key)); val != "" {
+ return val
+ }
+ for existingKey, values := range headers {
+ if !strings.EqualFold(existingKey, key) {
+ continue
+ }
+ for _, value := range values {
+ if trimmed := strings.TrimSpace(value); trimmed != "" {
+ return trimmed
+ }
+ }
+ }
+ return ""
+}
+
+func deleteHeaderCaseInsensitive(headers http.Header, key string) {
+ for existingKey := range headers {
+ if strings.EqualFold(existingKey, key) {
+ delete(headers, existingKey)
+ }
+ }
+}
+
func codexHeaderDefaults(cfg *config.Config, auth *cliproxyauth.Auth) (string, string) {
if cfg == nil || auth == nil {
return "", ""
@@ -955,25 +1056,55 @@ func parseCodexWebsocketError(payload []byte) (error, bool) {
return nil, false
}
- out := []byte(`{}`)
- if errNode := gjson.GetBytes(payload, "error"); errNode.Exists() {
- raw := errNode.Raw
- if errNode.Type == gjson.String {
- raw = errNode.Raw
- }
- out, _ = sjson.SetRawBytes(out, "error", []byte(raw))
- } else {
- out, _ = sjson.SetBytes(out, "error.type", "server_error")
- out, _ = sjson.SetBytes(out, "error.message", http.StatusText(status))
- }
-
+ out := buildCodexWebsocketErrorPayload(payload, status)
headers := parseCodexWebsocketErrorHeaders(payload)
+ statusError := statusErr{code: status, msg: string(out)}
+ if retryAfter := parseCodexRetryAfter(status, out, time.Now()); retryAfter != nil {
+ statusError.retryAfter = retryAfter
+ } else if isCodexWebsocketConnectionLimitError(payload) {
+ retryAfter := time.Duration(0)
+ statusError.retryAfter = &retryAfter
+ }
return statusErrWithHeaders{
- statusErr: statusErr{code: status, msg: string(out)},
+ statusErr: statusError,
headers: headers,
}, true
}
+func buildCodexWebsocketErrorPayload(payload []byte, status int) []byte {
+ out := []byte(`{}`)
+ out, _ = sjson.SetBytes(out, "status", status)
+
+ if bodyNode := gjson.GetBytes(payload, "body"); bodyNode.Exists() {
+ out, _ = sjson.SetRawBytes(out, "body", []byte(bodyNode.Raw))
+ if bodyErrorNode := bodyNode.Get("error"); bodyErrorNode.Exists() {
+ out, _ = sjson.SetRawBytes(out, "error", []byte(bodyErrorNode.Raw))
+ return out
+ }
+ }
+
+ if errNode := gjson.GetBytes(payload, "error"); errNode.Exists() {
+ out, _ = sjson.SetRawBytes(out, "error", []byte(errNode.Raw))
+ return out
+ }
+
+ out, _ = sjson.SetBytes(out, "error.type", "server_error")
+ out, _ = sjson.SetBytes(out, "error.message", http.StatusText(status))
+ return out
+}
+
+func isCodexWebsocketConnectionLimitError(payload []byte) bool {
+ if len(payload) == 0 {
+ return false
+ }
+ for _, path := range []string{"error.code", "error.type", "body.error.code", "body.error.type", "code", "error"} {
+ if strings.TrimSpace(gjson.GetBytes(payload, path).String()) == "websocket_connection_limit_reached" {
+ return true
+ }
+ }
+ return false
+}
+
func parseCodexWebsocketErrorHeaders(payload []byte) http.Header {
headersNode := gjson.GetBytes(payload, "headers")
if !headersNode.Exists() || !headersNode.IsObject() {
@@ -1109,11 +1240,22 @@ func (e *CodexWebsocketsExecutor) getOrCreateSession(sessionID string) *codexWeb
if sess, ok := store.sessions[sessionID]; ok && sess != nil {
return sess
}
- sess := &codexWebsocketSession{sessionID: sessionID}
+ sess := &codexWebsocketSession{
+ sessionID: sessionID,
+ upstreamDisconnectCh: make(chan error, 1),
+ }
store.sessions[sessionID] = sess
return sess
}
+func (e *CodexWebsocketsExecutor) UpstreamDisconnectChan(sessionID string) <-chan error {
+ sess := e.getOrCreateSession(sessionID)
+ if sess == nil {
+ return nil
+ }
+ return sess.upstreamDisconnectCh
+}
+
func (e *CodexWebsocketsExecutor) ensureUpstreamConn(ctx context.Context, auth *cliproxyauth.Auth, sess *codexWebsocketSession, authID string, wsURL string, headers http.Header) (*websocket.Conn, *http.Response, error) {
if sess == nil {
return e.dialCodexWebsocket(ctx, auth, wsURL, headers)
@@ -1242,6 +1384,7 @@ func (e *CodexWebsocketsExecutor) invalidateUpstreamConn(sess *codexWebsocketSes
sess.connMu.Unlock()
logCodexWebsocketDisconnected(sessionID, authID, wsURL, reason, err)
+ sess.notifyUpstreamDisconnect(err)
if errClose := conn.Close(); errClose != nil {
log.Errorf("codex websockets executor: close websocket error: %v", errClose)
}
@@ -1480,6 +1623,13 @@ func (e *CodexAutoExecutor) CloseExecutionSession(sessionID string) {
e.wsExec.CloseExecutionSession(sessionID)
}
+func (e *CodexAutoExecutor) UpstreamDisconnectChan(sessionID string) <-chan error {
+ if e == nil || e.wsExec == nil {
+ return nil
+ }
+ return e.wsExec.UpstreamDisconnectChan(sessionID)
+}
+
func codexWebsocketsEnabled(auth *cliproxyauth.Auth) bool {
if auth == nil {
return false
diff --git a/internal/runtime/executor/codex_websockets_executor_store_test.go b/internal/runtime/executor/codex_websockets_executor_store_test.go
index 1a23fa31b5..115ed066d2 100644
--- a/internal/runtime/executor/codex_websockets_executor_store_test.go
+++ b/internal/runtime/executor/codex_websockets_executor_store_test.go
@@ -3,7 +3,7 @@ package executor
import (
"testing"
- cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
+ cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
)
func TestCodexWebsocketsExecutor_SessionStoreSurvivesExecutorReplacement(t *testing.T) {
diff --git a/internal/runtime/executor/codex_websockets_executor_test.go b/internal/runtime/executor/codex_websockets_executor_test.go
index dec356de4c..4342ed8882 100644
--- a/internal/runtime/executor/codex_websockets_executor_test.go
+++ b/internal/runtime/executor/codex_websockets_executor_test.go
@@ -1,15 +1,22 @@
package executor
import (
+ "bytes"
"context"
+ "errors"
"net/http"
"net/http/httptest"
+ "strings"
"testing"
+ "time"
"github.com/gin-gonic/gin"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
- cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
- sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
+ "github.com/gorilla/websocket"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
+ cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
+ sdkconfig "github.com/router-for-me/CLIProxyAPI/v7/sdk/config"
+ sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
"github.com/tidwall/gjson"
)
@@ -32,14 +39,138 @@ func TestBuildCodexWebsocketRequestBodyPreservesPreviousResponseID(t *testing.T)
}
}
+func TestCodexWebsocketsExecutePreservesPreviousResponseIDUpstream(t *testing.T) {
+ upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }}
+ capturedPayload := make(chan []byte, 1)
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/responses" {
+ t.Fatalf("request path = %s, want /responses", r.URL.Path)
+ }
+ conn, err := upgrader.Upgrade(w, r, nil)
+ if err != nil {
+ t.Fatalf("upgrade websocket: %v", err)
+ }
+ defer func() { _ = conn.Close() }()
+
+ msgType, payload, err := conn.ReadMessage()
+ if err != nil {
+ t.Fatalf("read upstream websocket message: %v", err)
+ }
+ if msgType != websocket.TextMessage {
+ t.Fatalf("message type = %d, want text", msgType)
+ }
+ capturedPayload <- bytes.Clone(payload)
+
+ completed := []byte(`{"type":"response.completed","response":{"id":"resp-2","output":[],"usage":{"input_tokens":0,"output_tokens":0,"total_tokens":0}}}`)
+ if errWrite := conn.WriteMessage(websocket.TextMessage, completed); errWrite != nil {
+ t.Fatalf("write completed websocket message: %v", errWrite)
+ }
+ }))
+ defer server.Close()
+
+ exec := NewCodexWebsocketsExecutor(&config.Config{SDKConfig: config.SDKConfig{DisableImageGeneration: config.DisableImageGenerationAll}})
+ auth := &cliproxyauth.Auth{Attributes: map[string]string{"api_key": "sk-test", "base_url": server.URL}}
+ req := cliproxyexecutor.Request{
+ Model: "gpt-5-codex",
+ Payload: []byte(`{"model":"gpt-5-codex","previous_response_id":"resp-1","input":[{"type":"message","id":"msg-1"}]}`),
+ }
+ opts := cliproxyexecutor.Options{SourceFormat: sdktranslator.FromString("codex")}
+
+ if _, err := exec.Execute(context.Background(), auth, req, opts); err != nil {
+ t.Fatalf("Execute() error = %v", err)
+ }
+
+ select {
+ case payload := <-capturedPayload:
+ if got := gjson.GetBytes(payload, "type").String(); got != "response.create" {
+ t.Fatalf("upstream type = %s, want response.create; payload=%s", got, payload)
+ }
+ if got := gjson.GetBytes(payload, "previous_response_id").String(); got != "resp-1" {
+ t.Fatalf("upstream previous_response_id = %s, want resp-1; payload=%s", got, payload)
+ }
+ case <-time.After(5 * time.Second):
+ t.Fatal("timed out waiting for upstream websocket payload")
+ }
+}
+
+func TestCodexWebsocketsUpstreamDisconnectChanSignalsOnInvalidate(t *testing.T) {
+ upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }}
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ conn, err := upgrader.Upgrade(w, r, nil)
+ if err != nil {
+ t.Errorf("upgrade websocket: %v", err)
+ return
+ }
+ defer func() { _ = conn.Close() }()
+ for {
+ if _, _, errRead := conn.ReadMessage(); errRead != nil {
+ return
+ }
+ }
+ }))
+ defer server.Close()
+
+ wsURL := "ws" + strings.TrimPrefix(server.URL, "http")
+ conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
+ if err != nil {
+ t.Fatalf("dial websocket: %v", err)
+ }
+ defer func() { _ = conn.Close() }()
+
+ exec := NewCodexWebsocketsExecutor(&config.Config{})
+ sessionID := "sess-1"
+ disconnectCh := exec.UpstreamDisconnectChan(sessionID)
+ if disconnectCh == nil {
+ t.Fatal("expected disconnect channel")
+ }
+
+ sess := exec.getOrCreateSession(sessionID)
+ if sess == nil {
+ t.Fatal("expected session")
+ }
+ sess.connMu.Lock()
+ sess.conn = conn
+ sess.authID = "auth-1"
+ sess.wsURL = "ws://example.test/responses"
+ sess.readerConn = conn
+ sess.connMu.Unlock()
+
+ upstreamErr := errors.New("upstream gone")
+ exec.invalidateUpstreamConn(sess, conn, "test_invalidate", upstreamErr)
+
+ select {
+ case errRead, ok := <-disconnectCh:
+ if !ok {
+ t.Fatal("expected disconnect channel to deliver error before closing")
+ }
+ if errRead == nil || errRead.Error() != upstreamErr.Error() {
+ t.Fatalf("disconnect error = %v, want %v", errRead, upstreamErr)
+ }
+ case <-time.After(5 * time.Second):
+ t.Fatal("timed out waiting for disconnect signal")
+ }
+}
+
func TestApplyCodexWebsocketHeadersDefaultsToCurrentResponsesBeta(t *testing.T) {
headers := applyCodexWebsocketHeaders(context.Background(), http.Header{}, nil, "", nil)
if got := headers.Get("OpenAI-Beta"); got != codexResponsesWebsocketBetaHeaderValue {
t.Fatalf("OpenAI-Beta = %s, want %s", got, codexResponsesWebsocketBetaHeaderValue)
}
- if got := headers.Get("User-Agent"); got != "" {
- t.Fatalf("User-Agent = %s, want empty", got)
+ if got := headers.Get("User-Agent"); got != codexUserAgent {
+ t.Fatalf("User-Agent = %s, want %s", got, codexUserAgent)
+ }
+ if !strings.HasPrefix(codexUserAgent, codexOriginator+"/") {
+ t.Fatalf("default Codex User-Agent = %s, want prefix %s/", codexUserAgent, codexOriginator)
+ }
+ if strings.HasPrefix(codexUserAgent, "codex-tui/") {
+ t.Fatalf("default Codex User-Agent = %s, must not use stale codex-tui prefix", codexUserAgent)
+ }
+ if strings.Contains(codexUserAgent, "(codex-tui;") {
+ t.Fatalf("default Codex User-Agent = %s, must not include stale codex-tui suffix", codexUserAgent)
+ }
+ if got := headers.Get("Originator"); got != codexOriginator {
+ t.Fatalf("Originator = %s, want %s", got, codexOriginator)
}
if got := headers.Get("Version"); got != "" {
t.Fatalf("Version = %q, want empty", got)
@@ -62,9 +193,11 @@ func TestApplyCodexWebsocketHeadersPassesThroughClientIdentityHeaders(t *testing
}
ctx := contextWithGinHeaders(map[string]string{
"Originator": "Codex Desktop",
+ "User-Agent": "codex_cli_rs/0.1.0",
"Version": "0.115.0-alpha.27",
"X-Codex-Turn-Metadata": `{"turn_id":"turn-1"}`,
"X-Client-Request-Id": "019d2233-e240-7162-992d-38df0a2a0e0d",
+ "session_id": "sess-client",
})
headers := applyCodexWebsocketHeaders(ctx, http.Header{}, auth, "", nil)
@@ -72,6 +205,9 @@ func TestApplyCodexWebsocketHeadersPassesThroughClientIdentityHeaders(t *testing
if got := headers.Get("Originator"); got != "Codex Desktop" {
t.Fatalf("Originator = %s, want %s", got, "Codex Desktop")
}
+ if got := headers.Get("User-Agent"); got != "codex_cli_rs/0.1.0" {
+ t.Fatalf("User-Agent = %s, want %s", got, "codex_cli_rs/0.1.0")
+ }
if got := headers.Get("Version"); got != "0.115.0-alpha.27" {
t.Fatalf("Version = %s, want %s", got, "0.115.0-alpha.27")
}
@@ -81,6 +217,12 @@ func TestApplyCodexWebsocketHeadersPassesThroughClientIdentityHeaders(t *testing
if got := headers.Get("X-Client-Request-Id"); got != "019d2233-e240-7162-992d-38df0a2a0e0d" {
t.Fatalf("X-Client-Request-Id = %s, want %s", got, "019d2233-e240-7162-992d-38df0a2a0e0d")
}
+ if got := headerValueCaseInsensitive(headers, "session_id"); got != "sess-client" {
+ t.Fatalf("session_id = %s, want sess-client", got)
+ }
+ if _, ok := headers["session_id"]; !ok {
+ t.Fatalf("expected lowercase session_id header key, got %#v", headers)
+ }
}
func TestApplyCodexWebsocketHeadersUsesConfigDefaultsForOAuth(t *testing.T) {
@@ -97,8 +239,8 @@ func TestApplyCodexWebsocketHeadersUsesConfigDefaultsForOAuth(t *testing.T) {
headers := applyCodexWebsocketHeaders(context.Background(), http.Header{}, auth, "", cfg)
- if got := headers.Get("User-Agent"); got != "" {
- t.Fatalf("User-Agent = %s, want empty", got)
+ if got := headers.Get("User-Agent"); got != "my-codex-client/1.0" {
+ t.Fatalf("User-Agent = %s, want %s", got, "my-codex-client/1.0")
}
if got := headers.Get("x-codex-beta-features"); got != "feature-a,feature-b" {
t.Fatalf("x-codex-beta-features = %s, want %s", got, "feature-a,feature-b")
@@ -129,8 +271,8 @@ func TestApplyCodexWebsocketHeadersPrefersExistingHeadersOverClientAndConfig(t *
got := applyCodexWebsocketHeaders(ctx, headers, auth, "", cfg)
- if gotVal := got.Get("User-Agent"); gotVal != "" {
- t.Fatalf("User-Agent = %s, want empty", gotVal)
+ if gotVal := got.Get("User-Agent"); gotVal != "existing-ua" {
+ t.Fatalf("User-Agent = %s, want %s", gotVal, "existing-ua")
}
if gotVal := got.Get("x-codex-beta-features"); gotVal != "existing-beta" {
t.Fatalf("x-codex-beta-features = %s, want %s", gotVal, "existing-beta")
@@ -155,8 +297,8 @@ func TestApplyCodexWebsocketHeadersConfigUserAgentOverridesClientHeader(t *testi
headers := applyCodexWebsocketHeaders(ctx, http.Header{}, auth, "", cfg)
- if got := headers.Get("User-Agent"); got != "" {
- t.Fatalf("User-Agent = %s, want empty", got)
+ if got := headers.Get("User-Agent"); got != "config-ua" {
+ t.Fatalf("User-Agent = %s, want %s", got, "config-ua")
}
if got := headers.Get("x-codex-beta-features"); got != "client-beta" {
t.Fatalf("x-codex-beta-features = %s, want %s", got, "client-beta")
@@ -183,6 +325,131 @@ func TestApplyCodexWebsocketHeadersIgnoresConfigForAPIKeyAuth(t *testing.T) {
if got := headers.Get("x-codex-beta-features"); got != "" {
t.Fatalf("x-codex-beta-features = %q, want empty", got)
}
+ if got := headers.Get("Originator"); got != "" {
+ t.Fatalf("Originator = %s, want empty", got)
+ }
+}
+
+func TestApplyCodexWebsocketHeadersPreservesExplicitAPIKeyUserAgent(t *testing.T) {
+ auth := &cliproxyauth.Auth{Provider: "codex", Attributes: map[string]string{"api_key": "sk-test"}}
+ ctx := contextWithGinHeaders(map[string]string{"User-Agent": "api-key-client/1.0", "Originator": "explicit-origin"})
+
+ headers := applyCodexWebsocketHeaders(ctx, http.Header{}, auth, "sk-test", nil)
+
+ if got := headers.Get("User-Agent"); got != "api-key-client/1.0" {
+ t.Fatalf("User-Agent = %s, want api-key-client/1.0", got)
+ }
+ if got := headers.Get("Originator"); got != "explicit-origin" {
+ t.Fatalf("Originator = %s, want explicit-origin", got)
+ }
+}
+
+func TestApplyCodexPromptCacheHeadersSetsLowercaseSessionAndLegacyConversation(t *testing.T) {
+ req := cliproxyexecutor.Request{Model: "gpt-5-codex", Payload: []byte(`{"prompt_cache_key":"cache-1"}`)}
+
+ _, headers := applyCodexPromptCacheHeaders("openai-response", req, []byte(`{"model":"gpt-5-codex"}`))
+
+ if got := headerValueCaseInsensitive(headers, "session_id"); got != "cache-1" {
+ t.Fatalf("session_id = %s, want cache-1", got)
+ }
+ if _, ok := headers["session_id"]; !ok {
+ t.Fatalf("expected lowercase session_id key, got %#v", headers)
+ }
+ if got := headers.Get("Conversation_id"); got != "cache-1" {
+ t.Fatalf("Conversation_id = %s, want cache-1", got)
+ }
+}
+
+func TestApplyCodexWebsocketHeadersUsesCanonicalAccountHeader(t *testing.T) {
+ auth := &cliproxyauth.Auth{Provider: "codex", Metadata: map[string]any{"account_id": "acct-1"}}
+
+ headers := applyCodexWebsocketHeaders(context.Background(), http.Header{}, auth, "", nil)
+
+ if got := headerValueCaseInsensitive(headers, "ChatGPT-Account-ID"); got != "acct-1" {
+ t.Fatalf("ChatGPT-Account-ID = %s, want acct-1", got)
+ }
+ values, ok := headers["ChatGPT-Account-ID"]
+ if !ok {
+ t.Fatalf("expected exact ChatGPT-Account-ID key, got %#v", headers)
+ }
+ if len(values) != 1 || values[0] != "acct-1" {
+ t.Fatalf("ChatGPT-Account-ID values = %#v, want [acct-1]", values)
+ }
+}
+
+func TestBuildCodexResponsesWebsocketURLRequiresHTTPURL(t *testing.T) {
+ if got, err := buildCodexResponsesWebsocketURL("https://example.com/backend/responses"); err != nil || got != "wss://example.com/backend/responses" {
+ t.Fatalf("https URL = %q, %v; want wss URL", got, err)
+ }
+ if _, err := buildCodexResponsesWebsocketURL("ftp://example.com/responses"); err == nil {
+ t.Fatalf("expected unsupported scheme error")
+ }
+ if _, err := buildCodexResponsesWebsocketURL("https:///responses"); err == nil {
+ t.Fatalf("expected empty host error")
+ }
+}
+
+func TestParseCodexWebsocketErrorMarksConnectionLimitRetryable(t *testing.T) {
+ err, ok := parseCodexWebsocketError([]byte(`{"type":"error","status":429,"error":{"code":"websocket_connection_limit_reached","message":"too many websockets"},"headers":{"retry-after":"1"}}`))
+ if !ok {
+ t.Fatalf("expected websocket error")
+ }
+ status, ok := err.(interface{ StatusCode() int })
+ if !ok || status.StatusCode() != http.StatusTooManyRequests {
+ t.Fatalf("status = %#v, want 429", err)
+ }
+ retryable, ok := err.(interface{ RetryAfter() *time.Duration })
+ if !ok || retryable.RetryAfter() == nil {
+ t.Fatalf("expected retryable websocket connection limit error")
+ }
+ if got := *retryable.RetryAfter(); got != 0 {
+ t.Fatalf("retryAfter = %v, want connection-limit fallback 0", got)
+ }
+ withHeaders, ok := err.(interface{ Headers() http.Header })
+ if !ok || withHeaders.Headers().Get("retry-after") != "1" {
+ t.Fatalf("headers = %#v, want retry-after", err)
+ }
+}
+
+func TestParseCodexWebsocketErrorUsesUsageLimitRetryMetadata(t *testing.T) {
+ err, ok := parseCodexWebsocketError([]byte(`{"type":"error","status":429,"body":{"error":{"type":"usage_limit_reached","message":"usage limit reached","resets_in_seconds":7}}}`))
+ if !ok {
+ t.Fatalf("expected websocket error")
+ }
+
+ retryable, ok := err.(interface{ RetryAfter() *time.Duration })
+ if !ok || retryable.RetryAfter() == nil {
+ t.Fatalf("expected retryable usage limit websocket error")
+ }
+ if got := *retryable.RetryAfter(); got != 7*time.Second {
+ t.Fatalf("retryAfter = %v, want 7s", got)
+ }
+}
+
+func TestParseCodexWebsocketErrorPreservesWrappedBodyAndHeaders(t *testing.T) {
+ err, ok := parseCodexWebsocketError([]byte(`{"type":"error","status":429,"body":{"error":{"code":"websocket_connection_limit_reached","type":"server_error","message":"too many websocket connections"}},"headers":{"x-request-id":"req-1"}}`))
+ if !ok {
+ t.Fatalf("expected websocket error")
+ }
+
+ parsed := gjson.Parse(err.Error())
+ if got := parsed.Get("status").Int(); got != http.StatusTooManyRequests {
+ t.Fatalf("wrapped status = %d, want 429; payload=%s", got, err.Error())
+ }
+ if got := parsed.Get("body.error.code").String(); got != "websocket_connection_limit_reached" {
+ t.Fatalf("wrapped body error code = %s, want websocket_connection_limit_reached; payload=%s", got, err.Error())
+ }
+ if got := parsed.Get("error.code").String(); got != "websocket_connection_limit_reached" {
+ t.Fatalf("surface error code = %s, want websocket_connection_limit_reached; payload=%s", got, err.Error())
+ }
+ retryable, ok := err.(interface{ RetryAfter() *time.Duration })
+ if !ok || retryable.RetryAfter() == nil {
+ t.Fatalf("expected body.error.code websocket connection limit to be retryable")
+ }
+ withHeaders, ok := err.(interface{ Headers() http.Header })
+ if !ok || withHeaders.Headers().Get("x-request-id") != "req-1" {
+ t.Fatalf("headers = %#v, want x-request-id", err)
+ }
}
func TestApplyCodexHeadersUsesConfigUserAgentForOAuth(t *testing.T) {
diff --git a/internal/runtime/executor/gemini_cli_executor.go b/internal/runtime/executor/gemini_cli_executor.go
index d2df610966..a298fe8a0e 100644
--- a/internal/runtime/executor/gemini_cli_executor.go
+++ b/internal/runtime/executor/gemini_cli_executor.go
@@ -16,15 +16,15 @@ import (
"strings"
"time"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/executor/helps"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/geminicli"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
- "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"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/misc"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/geminicli"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/util"
+ cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
+ cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
+ sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
log "github.com/sirupsen/logrus"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
@@ -139,7 +139,8 @@ func (e *GeminiCLIExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth
basePayload = fixGeminiCLIImageAspectRatio(baseModel, basePayload)
requestedModel := helps.PayloadRequestedModel(opts, req.Model)
- basePayload = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, "gemini", "request", basePayload, originalTranslated, requestedModel)
+ requestPath := helps.PayloadRequestPath(opts)
+ basePayload = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, "gemini", "request", basePayload, originalTranslated, requestedModel, requestPath)
action := "generateContent"
if req.Metadata != nil {
@@ -294,7 +295,8 @@ func (e *GeminiCLIExecutor) ExecuteStream(ctx context.Context, auth *cliproxyaut
basePayload = fixGeminiCLIImageAspectRatio(baseModel, basePayload)
requestedModel := helps.PayloadRequestedModel(opts, req.Model)
- basePayload = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, "gemini", "request", basePayload, originalTranslated, requestedModel)
+ requestPath := helps.PayloadRequestPath(opts)
+ basePayload = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, "gemini", "request", basePayload, originalTranslated, requestedModel, requestPath)
projectID := resolveGeminiProjectID(auth)
@@ -409,28 +411,44 @@ func (e *GeminiCLIExecutor) ExecuteStream(ctx context.Context, auth *cliproxyaut
if bytes.HasPrefix(line, dataTag) {
segments := sdktranslator.TranslateStream(respCtx, to, from, attemptModel, opts.OriginalRequest, reqBody, bytes.Clone(line), ¶m)
for i := range segments {
- out <- cliproxyexecutor.StreamChunk{Payload: segments[i]}
+ select {
+ case out <- cliproxyexecutor.StreamChunk{Payload: segments[i]}:
+ case <-ctx.Done():
+ return
+ }
}
}
}
segments := sdktranslator.TranslateStream(respCtx, to, from, attemptModel, opts.OriginalRequest, reqBody, []byte("[DONE]"), ¶m)
for i := range segments {
- out <- cliproxyexecutor.StreamChunk{Payload: segments[i]}
+ select {
+ case out <- cliproxyexecutor.StreamChunk{Payload: segments[i]}:
+ case <-ctx.Done():
+ return
+ }
}
if errScan := scanner.Err(); errScan != nil {
helps.RecordAPIResponseError(ctx, e.cfg, errScan)
- reporter.PublishFailure(ctx)
- out <- cliproxyexecutor.StreamChunk{Err: errScan}
+ reporter.PublishFailure(ctx, errScan)
+ select {
+ case out <- cliproxyexecutor.StreamChunk{Err: errScan}:
+ case <-ctx.Done():
+ }
+ return
}
+ reporter.EnsurePublished(ctx)
return
}
data, errRead := io.ReadAll(resp.Body)
if errRead != nil {
helps.RecordAPIResponseError(ctx, e.cfg, errRead)
- reporter.PublishFailure(ctx)
- out <- cliproxyexecutor.StreamChunk{Err: errRead}
+ reporter.PublishFailure(ctx, errRead)
+ select {
+ case out <- cliproxyexecutor.StreamChunk{Err: errRead}:
+ case <-ctx.Done():
+ }
return
}
helps.AppendAPIResponseChunk(ctx, e.cfg, data)
@@ -438,12 +456,20 @@ func (e *GeminiCLIExecutor) ExecuteStream(ctx context.Context, auth *cliproxyaut
var param any
segments := sdktranslator.TranslateStream(respCtx, to, from, attemptModel, opts.OriginalRequest, reqBody, data, ¶m)
for i := range segments {
- out <- cliproxyexecutor.StreamChunk{Payload: segments[i]}
+ select {
+ case out <- cliproxyexecutor.StreamChunk{Payload: segments[i]}:
+ case <-ctx.Done():
+ return
+ }
}
segments = sdktranslator.TranslateStream(respCtx, to, from, attemptModel, opts.OriginalRequest, reqBody, []byte("[DONE]"), ¶m)
for i := range segments {
- out <- cliproxyexecutor.StreamChunk{Payload: segments[i]}
+ select {
+ case out <- cliproxyexecutor.StreamChunk{Payload: segments[i]}:
+ case <-ctx.Done():
+ return
+ }
}
}(httpResp, append([]byte(nil), payload...), attemptModel)
@@ -573,7 +599,10 @@ func (e *GeminiCLIExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.
}
// Refresh refreshes the authentication credentials (no-op for Gemini CLI).
-func (e *GeminiCLIExecutor) Refresh(_ context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) {
+func (e *GeminiCLIExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) {
+ if refreshed, handled, err := helps.RefreshAuthViaHome(ctx, e.cfg, auth); handled {
+ return refreshed, err
+ }
return auth, nil
}
@@ -583,37 +612,43 @@ func prepareGeminiCLITokenSource(ctx context.Context, cfg *config.Config, auth *
return nil, nil, fmt.Errorf("gemini-cli auth metadata missing")
}
- var base map[string]any
- if tokenRaw, ok := metadata["token"].(map[string]any); ok && tokenRaw != nil {
- base = cloneMap(tokenRaw)
- } else {
- base = make(map[string]any)
- }
+ buildToken := func(meta map[string]any) (map[string]any, oauth2.Token) {
+ var base map[string]any
+ if tokenRaw, ok := meta["token"].(map[string]any); ok && tokenRaw != nil {
+ base = cloneMap(tokenRaw)
+ } else {
+ base = make(map[string]any)
+ }
- var token oauth2.Token
- if len(base) > 0 {
- if raw, err := json.Marshal(base); err == nil {
- _ = json.Unmarshal(raw, &token)
+ var token oauth2.Token
+ if len(base) > 0 {
+ if raw, err := json.Marshal(base); err == nil {
+ _ = json.Unmarshal(raw, &token)
+ }
}
- }
- if token.AccessToken == "" {
- token.AccessToken = stringValue(metadata, "access_token")
- }
- if token.RefreshToken == "" {
- token.RefreshToken = stringValue(metadata, "refresh_token")
- }
- if token.TokenType == "" {
- token.TokenType = stringValue(metadata, "token_type")
- }
- if token.Expiry.IsZero() {
- if expiry := stringValue(metadata, "expiry"); expiry != "" {
- if ts, err := time.Parse(time.RFC3339, expiry); err == nil {
- token.Expiry = ts
+ if token.AccessToken == "" {
+ token.AccessToken = stringValue(meta, "access_token")
+ }
+ if token.RefreshToken == "" {
+ token.RefreshToken = stringValue(meta, "refresh_token")
+ }
+ if token.TokenType == "" {
+ token.TokenType = stringValue(meta, "token_type")
+ }
+ if token.Expiry.IsZero() {
+ if expiry := stringValue(meta, "expiry"); expiry != "" {
+ if ts, err := time.Parse(time.RFC3339, expiry); err == nil {
+ token.Expiry = ts
+ }
}
}
+
+ return base, token
}
+ base, token := buildToken(metadata)
+
conf := &oauth2.Config{
ClientID: geminiOAuthClientID,
ClientSecret: geminiOAuthClientSecret,
@@ -626,6 +661,29 @@ func prepareGeminiCLITokenSource(ctx context.Context, cfg *config.Config, auth *
ctxToken = context.WithValue(ctxToken, oauth2.HTTPClient, httpClient)
}
+ if cfg != nil && cfg.Home.Enabled {
+ now := time.Now()
+ if token.AccessToken == "" || (!token.Expiry.IsZero() && token.Expiry.Before(now.Add(30*time.Second))) {
+ refreshed, handled, errRefresh := helps.RefreshAuthViaHome(ctx, cfg, auth)
+ if handled {
+ if errRefresh != nil {
+ return nil, nil, errRefresh
+ }
+ auth = refreshed
+ metadata = geminiOAuthMetadata(auth)
+ if metadata == nil {
+ return nil, nil, fmt.Errorf("gemini-cli auth metadata missing")
+ }
+ base, token = buildToken(metadata)
+ }
+ }
+ if token.AccessToken == "" {
+ return nil, nil, fmt.Errorf("gemini-cli access token missing")
+ }
+ updateGeminiCLITokenMetadata(auth, base, &token)
+ return oauth2.StaticTokenSource(&token), base, nil
+ }
+
src := conf.TokenSource(ctxToken, &token)
currentToken, err := src.Token()
if err != nil {
@@ -898,7 +956,14 @@ func parseRetryDelay(errorBody []byte) (*time.Duration, error) {
if matches := re.FindStringSubmatch(message); len(matches) > 1 {
seconds, err := strconv.Atoi(matches[1])
if err == nil {
- return new(time.Duration(seconds) * time.Second), nil
+ duration := time.Duration(seconds) * time.Second
+ return &duration, nil
+ }
+ }
+ reHuman := regexp.MustCompile(`after\s+((?:\d+h)?(?:\d+m)?(?:\d+s)?)\.?`)
+ if matches := reHuman.FindStringSubmatch(strings.ToLower(message)); len(matches) > 1 {
+ if duration, err := time.ParseDuration(matches[1]); err == nil && duration > 0 {
+ return &duration, nil
}
}
}
diff --git a/internal/runtime/executor/gemini_executor.go b/internal/runtime/executor/gemini_executor.go
index fb4fbfdaf2..e8fa2e405f 100644
--- a/internal/runtime/executor/gemini_executor.go
+++ b/internal/runtime/executor/gemini_executor.go
@@ -12,13 +12,13 @@ import (
"net/http"
"strings"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/executor/helps"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
- "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"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/util"
+ cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
+ cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
+ sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
log "github.com/sirupsen/logrus"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
@@ -132,7 +132,8 @@ func (e *GeminiExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r
body = fixGeminiImageAspectRatio(baseModel, body)
requestedModel := helps.PayloadRequestedModel(opts, req.Model)
- body = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel)
+ requestPath := helps.PayloadRequestPath(opts)
+ body = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel, requestPath)
body, _ = sjson.SetBytes(body, "model", baseModel)
action := "generateContent"
@@ -239,7 +240,8 @@ func (e *GeminiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
body = fixGeminiImageAspectRatio(baseModel, body)
requestedModel := helps.PayloadRequestedModel(opts, req.Model)
- body = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel)
+ requestPath := helps.PayloadRequestPath(opts)
+ body = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel, requestPath)
body, _ = sjson.SetBytes(body, "model", baseModel)
baseURL := resolveGeminiBaseURL(auth)
@@ -322,17 +324,28 @@ func (e *GeminiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
}
lines := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, body, bytes.Clone(payload), ¶m)
for i := range lines {
- out <- cliproxyexecutor.StreamChunk{Payload: lines[i]}
+ select {
+ case out <- cliproxyexecutor.StreamChunk{Payload: lines[i]}:
+ case <-ctx.Done():
+ return
+ }
}
}
lines := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, body, []byte("[DONE]"), ¶m)
for i := range lines {
- out <- cliproxyexecutor.StreamChunk{Payload: lines[i]}
+ select {
+ case out <- cliproxyexecutor.StreamChunk{Payload: lines[i]}:
+ case <-ctx.Done():
+ return
+ }
}
if errScan := scanner.Err(); errScan != nil {
helps.RecordAPIResponseError(ctx, e.cfg, errScan)
- reporter.PublishFailure(ctx)
- out <- cliproxyexecutor.StreamChunk{Err: errScan}
+ reporter.PublishFailure(ctx, errScan)
+ select {
+ case out <- cliproxyexecutor.StreamChunk{Err: errScan}:
+ case <-ctx.Done():
+ }
}
}()
return &cliproxyexecutor.StreamResult{Headers: httpResp.Header.Clone(), Chunks: out}, nil
@@ -424,7 +437,10 @@ func (e *GeminiExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Aut
}
// Refresh refreshes the authentication credentials (no-op for Gemini API key).
-func (e *GeminiExecutor) Refresh(_ context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) {
+func (e *GeminiExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) {
+ if refreshed, handled, err := helps.RefreshAuthViaHome(ctx, e.cfg, auth); handled {
+ return refreshed, err
+ }
return auth, nil
}
diff --git a/internal/runtime/executor/gemini_vertex_executor.go b/internal/runtime/executor/gemini_vertex_executor.go
index 50e66219ac..b899524c6a 100644
--- a/internal/runtime/executor/gemini_vertex_executor.go
+++ b/internal/runtime/executor/gemini_vertex_executor.go
@@ -14,14 +14,14 @@ import (
"strings"
"time"
- vertexauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/vertex"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/executor/helps"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
- "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"
+ vertexauth "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/vertex"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/util"
+ cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
+ cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
+ sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
log "github.com/sirupsen/logrus"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
@@ -294,7 +294,10 @@ func (e *GeminiVertexExecutor) CountTokens(ctx context.Context, auth *cliproxyau
}
// Refresh refreshes the authentication credentials (no-op for Vertex).
-func (e *GeminiVertexExecutor) Refresh(_ context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) {
+func (e *GeminiVertexExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) {
+ if refreshed, handled, err := helps.RefreshAuthViaHome(ctx, e.cfg, auth); handled {
+ return refreshed, err
+ }
return auth, nil
}
@@ -335,8 +338,10 @@ func (e *GeminiVertexExecutor) executeWithServiceAccount(ctx context.Context, au
body = fixGeminiImageAspectRatio(baseModel, body)
requestedModel := helps.PayloadRequestedModel(opts, req.Model)
- body = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel)
+ requestPath := helps.PayloadRequestPath(opts)
+ body = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel, requestPath)
body, _ = sjson.SetBytes(body, "model", baseModel)
+ body = helps.StripVertexOpenAIResponsesToolCallIDs(body, from.String())
}
action := getVertexAction(baseModel, false)
@@ -455,8 +460,10 @@ func (e *GeminiVertexExecutor) executeWithAPIKey(ctx context.Context, auth *clip
body = fixGeminiImageAspectRatio(baseModel, body)
requestedModel := helps.PayloadRequestedModel(opts, req.Model)
- body = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel)
+ requestPath := helps.PayloadRequestPath(opts)
+ body = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel, requestPath)
body, _ = sjson.SetBytes(body, "model", baseModel)
+ body = helps.StripVertexOpenAIResponsesToolCallIDs(body, from.String())
action := getVertexAction(baseModel, false)
if req.Metadata != nil {
@@ -565,8 +572,10 @@ func (e *GeminiVertexExecutor) executeStreamWithServiceAccount(ctx context.Conte
body = fixGeminiImageAspectRatio(baseModel, body)
requestedModel := helps.PayloadRequestedModel(opts, req.Model)
- body = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel)
+ requestPath := helps.PayloadRequestPath(opts)
+ body = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel, requestPath)
body, _ = sjson.SetBytes(body, "model", baseModel)
+ body = helps.StripVertexOpenAIResponsesToolCallIDs(body, from.String())
action := getVertexAction(baseModel, true)
baseURL := vertexBaseURL(location)
@@ -653,17 +662,28 @@ func (e *GeminiVertexExecutor) executeStreamWithServiceAccount(ctx context.Conte
}
lines := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, body, bytes.Clone(line), ¶m)
for i := range lines {
- out <- cliproxyexecutor.StreamChunk{Payload: lines[i]}
+ select {
+ case out <- cliproxyexecutor.StreamChunk{Payload: lines[i]}:
+ case <-ctx.Done():
+ return
+ }
}
}
lines := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, body, []byte("[DONE]"), ¶m)
for i := range lines {
- out <- cliproxyexecutor.StreamChunk{Payload: lines[i]}
+ select {
+ case out <- cliproxyexecutor.StreamChunk{Payload: lines[i]}:
+ case <-ctx.Done():
+ return
+ }
}
if errScan := scanner.Err(); errScan != nil {
helps.RecordAPIResponseError(ctx, e.cfg, errScan)
- reporter.PublishFailure(ctx)
- out <- cliproxyexecutor.StreamChunk{Err: errScan}
+ reporter.PublishFailure(ctx, errScan)
+ select {
+ case out <- cliproxyexecutor.StreamChunk{Err: errScan}:
+ case <-ctx.Done():
+ }
}
}()
return &cliproxyexecutor.StreamResult{Headers: httpResp.Header.Clone(), Chunks: out}, nil
@@ -694,8 +714,10 @@ func (e *GeminiVertexExecutor) executeStreamWithAPIKey(ctx context.Context, auth
body = fixGeminiImageAspectRatio(baseModel, body)
requestedModel := helps.PayloadRequestedModel(opts, req.Model)
- body = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel)
+ requestPath := helps.PayloadRequestPath(opts)
+ body = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel, requestPath)
body, _ = sjson.SetBytes(body, "model", baseModel)
+ body = helps.StripVertexOpenAIResponsesToolCallIDs(body, from.String())
action := getVertexAction(baseModel, true)
// For API key auth, use simpler URL format without project/location
@@ -782,17 +804,28 @@ func (e *GeminiVertexExecutor) executeStreamWithAPIKey(ctx context.Context, auth
}
lines := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, body, bytes.Clone(line), ¶m)
for i := range lines {
- out <- cliproxyexecutor.StreamChunk{Payload: lines[i]}
+ select {
+ case out <- cliproxyexecutor.StreamChunk{Payload: lines[i]}:
+ case <-ctx.Done():
+ return
+ }
}
}
lines := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, body, []byte("[DONE]"), ¶m)
for i := range lines {
- out <- cliproxyexecutor.StreamChunk{Payload: lines[i]}
+ select {
+ case out <- cliproxyexecutor.StreamChunk{Payload: lines[i]}:
+ case <-ctx.Done():
+ return
+ }
}
if errScan := scanner.Err(); errScan != nil {
helps.RecordAPIResponseError(ctx, e.cfg, errScan)
- reporter.PublishFailure(ctx)
- out <- cliproxyexecutor.StreamChunk{Err: errScan}
+ reporter.PublishFailure(ctx, errScan)
+ select {
+ case out <- cliproxyexecutor.StreamChunk{Err: errScan}:
+ case <-ctx.Done():
+ }
}
}()
return &cliproxyexecutor.StreamResult{Headers: httpResp.Header.Clone(), Chunks: out}, nil
@@ -814,6 +847,7 @@ func (e *GeminiVertexExecutor) countTokensWithServiceAccount(ctx context.Context
translatedReq = fixGeminiImageAspectRatio(baseModel, translatedReq)
translatedReq, _ = sjson.SetBytes(translatedReq, "model", baseModel)
+ translatedReq = helps.StripVertexOpenAIResponsesToolCallIDs(translatedReq, from.String())
respCtx := context.WithValue(ctx, "alt", opts.Alt)
translatedReq, _ = sjson.DeleteBytes(translatedReq, "tools")
translatedReq, _ = sjson.DeleteBytes(translatedReq, "generationConfig")
@@ -903,6 +937,7 @@ func (e *GeminiVertexExecutor) countTokensWithAPIKey(ctx context.Context, auth *
translatedReq = fixGeminiImageAspectRatio(baseModel, translatedReq)
translatedReq, _ = sjson.SetBytes(translatedReq, "model", baseModel)
+ translatedReq = helps.StripVertexOpenAIResponsesToolCallIDs(translatedReq, from.String())
respCtx := context.WithValue(ctx, "alt", opts.Alt)
translatedReq, _ = sjson.DeleteBytes(translatedReq, "tools")
translatedReq, _ = sjson.DeleteBytes(translatedReq, "generationConfig")
diff --git a/internal/runtime/executor/helps/claude_device_profile.go b/internal/runtime/executor/helps/claude_device_profile.go
index 154901b53b..09f04929fe 100644
--- a/internal/runtime/executor/helps/claude_device_profile.go
+++ b/internal/runtime/executor/helps/claude_device_profile.go
@@ -11,8 +11,8 @@ import (
"sync"
"time"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
- cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
)
const (
diff --git a/internal/runtime/executor/helps/home_refresh.go b/internal/runtime/executor/helps/home_refresh.go
new file mode 100644
index 0000000000..e52fdd2435
--- /dev/null
+++ b/internal/runtime/executor/helps/home_refresh.go
@@ -0,0 +1,91 @@
+package helps
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "strings"
+
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/home"
+ cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
+)
+
+type homeStatusErr struct {
+ code int
+ msg string
+}
+
+func (e homeStatusErr) Error() string {
+ if e.msg != "" {
+ return e.msg
+ }
+ return fmt.Sprintf("status %d", e.code)
+}
+
+func (e homeStatusErr) StatusCode() int { return e.code }
+
+type homeErrorEnvelope struct {
+ Error *homeErrorDetail `json:"error"`
+}
+
+type homeErrorDetail struct {
+ Type string `json:"type"`
+ Message string `json:"message"`
+ Code string `json:"code,omitempty"`
+}
+
+// RefreshAuthViaHome replaces local refresh logic when home control plane integration is enabled.
+// It returns (updatedAuth, true, nil) when home refresh succeeds; (nil, true, err) when home is
+// enabled but refresh fails; and (nil, false, nil) when home is disabled.
+func RefreshAuthViaHome(ctx context.Context, cfg *config.Config, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, bool, error) {
+ if cfg == nil || !cfg.Home.Enabled {
+ return nil, false, nil
+ }
+ if ctx == nil {
+ ctx = context.Background()
+ }
+ if auth == nil {
+ return nil, true, homeStatusErr{code: http.StatusInternalServerError, msg: "home refresh: auth is nil"}
+ }
+
+ client := home.Current()
+ if client == nil || !client.HeartbeatOK() {
+ return nil, true, homeStatusErr{code: http.StatusServiceUnavailable, msg: "home control center unavailable"}
+ }
+
+ authIndex := strings.TrimSpace(auth.Index)
+ if authIndex == "" {
+ authIndex = strings.TrimSpace(auth.EnsureIndex())
+ }
+ if authIndex == "" {
+ return nil, true, homeStatusErr{code: http.StatusBadGateway, msg: "home refresh: auth_index is empty"}
+ }
+
+ raw, err := client.GetRefreshAuth(ctx, authIndex)
+ if err != nil {
+ return nil, true, homeStatusErr{code: http.StatusBadGateway, msg: err.Error()}
+ }
+
+ var env homeErrorEnvelope
+ if errUnmarshal := json.Unmarshal(raw, &env); errUnmarshal == nil && env.Error != nil {
+ code := strings.TrimSpace(env.Error.Type)
+ if code == "" {
+ code = strings.TrimSpace(env.Error.Code)
+ }
+ msg := strings.TrimSpace(env.Error.Message)
+ if msg == "" {
+ msg = "home returned error"
+ }
+ return nil, true, homeStatusErr{code: http.StatusBadGateway, msg: msg}
+ }
+
+ var updated cliproxyauth.Auth
+ if errUnmarshal := json.Unmarshal(raw, &updated); errUnmarshal != nil {
+ return nil, true, homeStatusErr{code: http.StatusBadGateway, msg: "home returned invalid auth payload"}
+ }
+ updated.Index = authIndex
+ updated.EnsureIndex()
+ return &updated, true, nil
+}
diff --git a/internal/runtime/executor/helps/logging_helpers.go b/internal/runtime/executor/helps/logging_helpers.go
index 767c882016..fa7143347e 100644
--- a/internal/runtime/executor/helps/logging_helpers.go
+++ b/internal/runtime/executor/helps/logging_helpers.go
@@ -12,9 +12,9 @@ import (
"time"
"github.com/gin-gonic/gin"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/logging"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/logging"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/util"
log "github.com/sirupsen/logrus"
"github.com/tidwall/gjson"
)
@@ -24,6 +24,7 @@ const (
apiRequestKey = "API_REQUEST"
apiResponseKey = "API_RESPONSE"
apiWebsocketTimelineKey = "API_WEBSOCKET_TIMELINE"
+ creditsUsedKey = "__antigravity_credits_used__"
)
// UpstreamRequestLog captures the outbound upstream request details for logging.
@@ -568,3 +569,24 @@ func LogWithRequestID(ctx context.Context) *log.Entry {
}
return log.WithField("request_id", requestID)
}
+
+// MarkCreditsUsed flags the request as having used AI credits for billing.
+func MarkCreditsUsed(ctx context.Context) {
+ ginCtx := ginContextFrom(ctx)
+ if ginCtx != nil {
+ ginCtx.Set(creditsUsedKey, true)
+ }
+}
+
+// CreditsUsed returns true if the request used AI credits.
+func CreditsUsed(ctx context.Context) bool {
+ ginCtx := ginContextFrom(ctx)
+ if ginCtx != nil {
+ if val, exists := ginCtx.Get(creditsUsedKey); exists {
+ if b, ok := val.(bool); ok {
+ return b
+ }
+ }
+ }
+ return false
+}
diff --git a/internal/runtime/executor/helps/payload_helpers.go b/internal/runtime/executor/helps/payload_helpers.go
index 73514c2dd1..af69a488c3 100644
--- a/internal/runtime/executor/helps/payload_helpers.go
+++ b/internal/runtime/executor/helps/payload_helpers.go
@@ -4,9 +4,9 @@ import (
"encoding/json"
"strings"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
- cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
+ cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
@@ -16,139 +16,167 @@ import (
// and restricts matches to the given protocol when supplied. Defaults are checked
// against the original payload when provided. requestedModel carries the client-visible
// model name before alias resolution so payload rules can target aliases precisely.
-func ApplyPayloadConfigWithRoot(cfg *config.Config, model, protocol, root string, payload, original []byte, requestedModel string) []byte {
+// requestPath is the inbound HTTP request path (when available) used for endpoint-scoped gates.
+func ApplyPayloadConfigWithRoot(cfg *config.Config, model, protocol, root string, payload, original []byte, requestedModel string, requestPath string) []byte {
if cfg == nil || len(payload) == 0 {
return payload
}
- rules := cfg.Payload
- if len(rules.Default) == 0 && len(rules.DefaultRaw) == 0 && len(rules.Override) == 0 && len(rules.OverrideRaw) == 0 && len(rules.Filter) == 0 {
- return payload
- }
- model = strings.TrimSpace(model)
- requestedModel = strings.TrimSpace(requestedModel)
- if model == "" && requestedModel == "" {
- return payload
- }
- candidates := payloadModelCandidates(model, requestedModel)
out := payload
- source := original
- if len(source) == 0 {
- source = payload
- }
- appliedDefaults := make(map[string]struct{})
- // Apply default rules: first write wins per field across all matching rules.
- for i := range rules.Default {
- rule := &rules.Default[i]
- if !payloadModelRulesMatch(rule.Models, protocol, candidates) {
- continue
- }
- for path, value := range rule.Params {
- fullPath := buildPayloadPath(root, path)
- if fullPath == "" {
- continue
- }
- if gjson.GetBytes(source, fullPath).Exists() {
- continue
- }
- if _, ok := appliedDefaults[fullPath]; ok {
- continue
- }
- updated, errSet := sjson.SetBytes(out, fullPath, value)
- if errSet != nil {
- continue
- }
- out = updated
- appliedDefaults[fullPath] = struct{}{}
+
+ // Apply disable-image-generation filtering before payload rules so config payload
+ // overrides can explicitly re-enable image_generation when desired.
+ if cfg.DisableImageGeneration != config.DisableImageGenerationOff {
+ if cfg.DisableImageGeneration != config.DisableImageGenerationChat || !isImagesEndpointRequestPath(requestPath) {
+ out = removeToolTypeFromPayloadWithRoot(out, root, "image_generation")
+ out = removeToolChoiceFromPayloadWithRoot(out, root, "image_generation")
}
}
- // Apply default raw rules: first write wins per field across all matching rules.
- for i := range rules.DefaultRaw {
- rule := &rules.DefaultRaw[i]
- if !payloadModelRulesMatch(rule.Models, protocol, candidates) {
- continue
- }
- for path, value := range rule.Params {
- fullPath := buildPayloadPath(root, path)
- if fullPath == "" {
- continue
+
+ rules := cfg.Payload
+ hasPayloadRules := len(rules.Default) != 0 || len(rules.DefaultRaw) != 0 || len(rules.Override) != 0 || len(rules.OverrideRaw) != 0 || len(rules.Filter) != 0
+ if hasPayloadRules {
+ model = strings.TrimSpace(model)
+ requestedModel = strings.TrimSpace(requestedModel)
+ if model != "" || requestedModel != "" {
+ candidates := payloadModelCandidates(model, requestedModel)
+ source := original
+ if len(source) == 0 {
+ source = payload
}
- if gjson.GetBytes(source, fullPath).Exists() {
- continue
+ appliedDefaults := make(map[string]struct{})
+ // Apply default rules: first write wins per field across all matching rules.
+ for i := range rules.Default {
+ rule := &rules.Default[i]
+ if !payloadModelRulesMatch(rule.Models, protocol, candidates) {
+ continue
+ }
+ for path, value := range rule.Params {
+ fullPath := buildPayloadPath(root, path)
+ if fullPath == "" {
+ continue
+ }
+ if gjson.GetBytes(source, fullPath).Exists() {
+ continue
+ }
+ if _, ok := appliedDefaults[fullPath]; ok {
+ continue
+ }
+ updated, errSet := sjson.SetBytes(out, fullPath, value)
+ if errSet != nil {
+ continue
+ }
+ out = updated
+ appliedDefaults[fullPath] = struct{}{}
+ }
}
- if _, ok := appliedDefaults[fullPath]; ok {
- continue
+ // Apply default raw rules: first write wins per field across all matching rules.
+ for i := range rules.DefaultRaw {
+ rule := &rules.DefaultRaw[i]
+ if !payloadModelRulesMatch(rule.Models, protocol, candidates) {
+ continue
+ }
+ for path, value := range rule.Params {
+ fullPath := buildPayloadPath(root, path)
+ if fullPath == "" {
+ continue
+ }
+ if gjson.GetBytes(source, fullPath).Exists() {
+ continue
+ }
+ if _, ok := appliedDefaults[fullPath]; ok {
+ continue
+ }
+ rawValue, ok := payloadRawValue(value)
+ if !ok {
+ continue
+ }
+ updated, errSet := sjson.SetRawBytes(out, fullPath, rawValue)
+ if errSet != nil {
+ continue
+ }
+ out = updated
+ appliedDefaults[fullPath] = struct{}{}
+ }
}
- rawValue, ok := payloadRawValue(value)
- if !ok {
- continue
- }
- updated, errSet := sjson.SetRawBytes(out, fullPath, rawValue)
- if errSet != nil {
- continue
+ // Apply override rules: last write wins per field across all matching rules.
+ for i := range rules.Override {
+ rule := &rules.Override[i]
+ if !payloadModelRulesMatch(rule.Models, protocol, candidates) {
+ continue
+ }
+ for path, value := range rule.Params {
+ fullPath := buildPayloadPath(root, path)
+ if fullPath == "" {
+ continue
+ }
+ updated, errSet := sjson.SetBytes(out, fullPath, value)
+ if errSet != nil {
+ continue
+ }
+ out = updated
+ }
}
- out = updated
- appliedDefaults[fullPath] = struct{}{}
- }
- }
- // Apply override rules: last write wins per field across all matching rules.
- for i := range rules.Override {
- rule := &rules.Override[i]
- if !payloadModelRulesMatch(rule.Models, protocol, candidates) {
- continue
- }
- for path, value := range rule.Params {
- fullPath := buildPayloadPath(root, path)
- if fullPath == "" {
- continue
+ // Apply override raw rules: last write wins per field across all matching rules.
+ for i := range rules.OverrideRaw {
+ rule := &rules.OverrideRaw[i]
+ if !payloadModelRulesMatch(rule.Models, protocol, candidates) {
+ continue
+ }
+ for path, value := range rule.Params {
+ fullPath := buildPayloadPath(root, path)
+ if fullPath == "" {
+ continue
+ }
+ rawValue, ok := payloadRawValue(value)
+ if !ok {
+ continue
+ }
+ updated, errSet := sjson.SetRawBytes(out, fullPath, rawValue)
+ if errSet != nil {
+ continue
+ }
+ out = updated
+ }
}
- updated, errSet := sjson.SetBytes(out, fullPath, value)
- if errSet != nil {
- continue
+ // Apply filter rules: remove matching paths from payload.
+ for i := range rules.Filter {
+ rule := &rules.Filter[i]
+ if !payloadModelRulesMatch(rule.Models, protocol, candidates) {
+ continue
+ }
+ for _, path := range rule.Params {
+ fullPath := buildPayloadPath(root, path)
+ if fullPath == "" {
+ continue
+ }
+ updated, errDel := sjson.DeleteBytes(out, fullPath)
+ if errDel != nil {
+ continue
+ }
+ out = updated
+ }
}
- out = updated
}
}
- // Apply override raw rules: last write wins per field across all matching rules.
- for i := range rules.OverrideRaw {
- rule := &rules.OverrideRaw[i]
- if !payloadModelRulesMatch(rule.Models, protocol, candidates) {
- continue
- }
- for path, value := range rule.Params {
- fullPath := buildPayloadPath(root, path)
- if fullPath == "" {
- continue
- }
- rawValue, ok := payloadRawValue(value)
- if !ok {
- continue
- }
- updated, errSet := sjson.SetRawBytes(out, fullPath, rawValue)
- if errSet != nil {
- continue
- }
- out = updated
- }
+ return out
+}
+
+func isImagesEndpointRequestPath(path string) bool {
+ path = strings.TrimSpace(path)
+ if path == "" {
+ return false
}
- // Apply filter rules: remove matching paths from payload.
- for i := range rules.Filter {
- rule := &rules.Filter[i]
- if !payloadModelRulesMatch(rule.Models, protocol, candidates) {
- continue
- }
- for _, path := range rule.Params {
- fullPath := buildPayloadPath(root, path)
- if fullPath == "" {
- continue
- }
- updated, errDel := sjson.DeleteBytes(out, fullPath)
- if errDel != nil {
- continue
- }
- out = updated
- }
+ if path == "/v1/images/generations" || path == "/v1/images/edits" {
+ return true
}
- return out
+ // Be tolerant of prefix routers that may report a longer matched route.
+ if strings.HasSuffix(path, "/v1/images/generations") || strings.HasSuffix(path, "/v1/images/edits") {
+ return true
+ }
+ if strings.HasSuffix(path, "/images/generations") || strings.HasSuffix(path, "/images/edits") {
+ return true
+ }
+ return false
}
func payloadModelRulesMatch(rules []config.PayloadModelRule, protocol string, models []string) bool {
@@ -226,6 +254,95 @@ func buildPayloadPath(root, path string) string {
return r + "." + p
}
+func removeToolTypeFromPayloadWithRoot(payload []byte, root string, toolType string) []byte {
+ if len(payload) == 0 {
+ return payload
+ }
+ toolType = strings.TrimSpace(toolType)
+ if toolType == "" {
+ return payload
+ }
+ toolsPath := buildPayloadPath(root, "tools")
+ return removeToolTypeFromToolsArray(payload, toolsPath, toolType)
+}
+
+func removeToolChoiceFromPayloadWithRoot(payload []byte, root string, toolType string) []byte {
+ if len(payload) == 0 {
+ return payload
+ }
+ toolType = strings.TrimSpace(toolType)
+ if toolType == "" {
+ return payload
+ }
+ toolChoicePath := buildPayloadPath(root, "tool_choice")
+ return removeToolChoiceFromPayload(payload, toolChoicePath, toolType)
+}
+
+func removeToolChoiceFromPayload(payload []byte, toolChoicePath string, toolType string) []byte {
+ choice := gjson.GetBytes(payload, toolChoicePath)
+ if !choice.Exists() {
+ return payload
+ }
+ if choice.Type == gjson.String {
+ if strings.EqualFold(strings.TrimSpace(choice.String()), toolType) {
+ updated, errDel := sjson.DeleteBytes(payload, toolChoicePath)
+ if errDel == nil {
+ return updated
+ }
+ }
+ return payload
+ }
+ if choice.Type != gjson.JSON {
+ return payload
+ }
+ choiceType := strings.TrimSpace(choice.Get("type").String())
+ if strings.EqualFold(choiceType, toolType) {
+ updated, errDel := sjson.DeleteBytes(payload, toolChoicePath)
+ if errDel == nil {
+ return updated
+ }
+ return payload
+ }
+ if strings.EqualFold(choiceType, "tool") {
+ name := strings.TrimSpace(choice.Get("name").String())
+ if strings.EqualFold(name, toolType) {
+ updated, errDel := sjson.DeleteBytes(payload, toolChoicePath)
+ if errDel == nil {
+ return updated
+ }
+ }
+ }
+ return payload
+}
+
+func removeToolTypeFromToolsArray(payload []byte, toolsPath string, toolType string) []byte {
+ tools := gjson.GetBytes(payload, toolsPath)
+ if !tools.Exists() || !tools.IsArray() {
+ return payload
+ }
+ removed := false
+ filtered := []byte(`[]`)
+ for _, tool := range tools.Array() {
+ if tool.Get("type").String() == toolType {
+ removed = true
+ continue
+ }
+ updated, errSet := sjson.SetRawBytes(filtered, "-1", []byte(tool.Raw))
+ if errSet != nil {
+ continue
+ }
+ filtered = updated
+ }
+ if !removed {
+ return payload
+ }
+ updated, errSet := sjson.SetRawBytes(payload, toolsPath, filtered)
+ if errSet != nil {
+ return payload
+ }
+ return updated
+}
+
func payloadRawValue(value any) ([]byte, bool) {
if value == nil {
return nil, false
@@ -273,6 +390,24 @@ func PayloadRequestedModel(opts cliproxyexecutor.Options, fallback string) strin
}
}
+func PayloadRequestPath(opts cliproxyexecutor.Options) string {
+ if len(opts.Metadata) == 0 {
+ return ""
+ }
+ raw, ok := opts.Metadata[cliproxyexecutor.RequestPathMetadataKey]
+ if !ok || raw == nil {
+ return ""
+ }
+ switch v := raw.(type) {
+ case string:
+ return strings.TrimSpace(v)
+ case []byte:
+ return strings.TrimSpace(string(v))
+ default:
+ return ""
+ }
+}
+
// matchModelPattern performs simple wildcard matching where '*' matches zero or more characters.
// Examples:
//
diff --git a/internal/runtime/executor/helps/payload_helpers_disable_image_generation_test.go b/internal/runtime/executor/helps/payload_helpers_disable_image_generation_test.go
new file mode 100644
index 0000000000..0faf012b35
--- /dev/null
+++ b/internal/runtime/executor/helps/payload_helpers_disable_image_generation_test.go
@@ -0,0 +1,134 @@
+package helps
+
+import (
+ "testing"
+
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ "github.com/tidwall/gjson"
+)
+
+func TestApplyPayloadConfigWithRoot_DisableImageGeneration_RemovesToolsEntry(t *testing.T) {
+ cfg := &config.Config{
+ SDKConfig: config.SDKConfig{DisableImageGeneration: config.DisableImageGenerationAll},
+ }
+ payload := []byte(`{"tools":[{"type":"image_generation","output_format":"png"},{"type":"function","name":"f1"}]}`)
+
+ out := ApplyPayloadConfigWithRoot(cfg, "gpt-5.4", "openai-response", "", payload, nil, "", "")
+
+ tools := gjson.GetBytes(out, "tools")
+ if !tools.Exists() || !tools.IsArray() {
+ t.Fatalf("expected tools array, got %v", tools.Type)
+ }
+ arr := tools.Array()
+ if len(arr) != 1 {
+ t.Fatalf("expected 1 tool after removal, got %d", len(arr))
+ }
+ if got := arr[0].Get("type").String(); got != "function" {
+ t.Fatalf("expected remaining tool type=function, got %q", got)
+ }
+}
+
+func TestApplyPayloadConfigWithRoot_DisableImageGeneration_RemovesToolsEntryWithRoot(t *testing.T) {
+ cfg := &config.Config{
+ SDKConfig: config.SDKConfig{DisableImageGeneration: config.DisableImageGenerationAll},
+ }
+ payload := []byte(`{"request":{"tools":[{"type":"image_generation"},{"type":"web_search"}]}}`)
+
+ out := ApplyPayloadConfigWithRoot(cfg, "gpt-5.4", "gemini-cli", "request", payload, nil, "", "")
+
+ tools := gjson.GetBytes(out, "request.tools")
+ if !tools.Exists() || !tools.IsArray() {
+ t.Fatalf("expected request.tools array, got %v", tools.Type)
+ }
+ arr := tools.Array()
+ if len(arr) != 1 {
+ t.Fatalf("expected 1 tool after removal, got %d", len(arr))
+ }
+ if got := arr[0].Get("type").String(); got != "web_search" {
+ t.Fatalf("expected remaining tool type=web_search, got %q", got)
+ }
+}
+
+func TestApplyPayloadConfigWithRoot_DisableImageGeneration_RemovesToolChoiceByType(t *testing.T) {
+ cfg := &config.Config{
+ SDKConfig: config.SDKConfig{DisableImageGeneration: config.DisableImageGenerationAll},
+ }
+ payload := []byte(`{"tools":[{"type":"image_generation"},{"type":"function","name":"f1"}],"tool_choice":{"type":"image_generation"}}`)
+
+ out := ApplyPayloadConfigWithRoot(cfg, "gpt-5.4", "openai-response", "", payload, nil, "", "")
+
+ if gjson.GetBytes(out, "tool_choice").Exists() {
+ t.Fatalf("expected tool_choice to be removed")
+ }
+}
+
+func TestApplyPayloadConfigWithRoot_DisableImageGeneration_RemovesToolChoiceByNameWithRoot(t *testing.T) {
+ cfg := &config.Config{
+ SDKConfig: config.SDKConfig{DisableImageGeneration: config.DisableImageGenerationAll},
+ }
+ payload := []byte(`{"request":{"tools":[{"type":"image_generation"},{"type":"web_search"}],"tool_choice":{"type":"tool","name":"image_generation"}}}`)
+
+ out := ApplyPayloadConfigWithRoot(cfg, "gpt-5.4", "gemini-cli", "request", payload, nil, "", "")
+
+ if gjson.GetBytes(out, "request.tool_choice").Exists() {
+ t.Fatalf("expected request.tool_choice to be removed")
+ }
+}
+
+func TestApplyPayloadConfigWithRoot_DisableImageGenerationChat_KeepsImageGenerationOnImagesEndpoints(t *testing.T) {
+ cfg := &config.Config{
+ SDKConfig: config.SDKConfig{DisableImageGeneration: config.DisableImageGenerationChat},
+ }
+ payload := []byte(`{"tools":[{"type":"image_generation"},{"type":"function","name":"f1"}],"tool_choice":{"type":"image_generation"}}`)
+
+ out := ApplyPayloadConfigWithRoot(cfg, "gpt-5.4", "openai-response", "", payload, nil, "", "/v1/images/generations")
+
+ tools := gjson.GetBytes(out, "tools")
+ if !tools.Exists() || !tools.IsArray() {
+ t.Fatalf("expected tools array, got %v", tools.Type)
+ }
+ arr := tools.Array()
+ if len(arr) != 2 {
+ t.Fatalf("expected 2 tools (no removal), got %d", len(arr))
+ }
+ if !gjson.GetBytes(out, "tool_choice").Exists() {
+ t.Fatalf("expected tool_choice to be kept on images endpoint")
+ }
+}
+
+func TestApplyPayloadConfigWithRoot_DisableImageGeneration_PayloadOverrideCanRestoreImageGeneration(t *testing.T) {
+ cfg := &config.Config{
+ SDKConfig: config.SDKConfig{DisableImageGeneration: config.DisableImageGenerationAll},
+ Payload: config.PayloadConfig{
+ OverrideRaw: []config.PayloadRule{
+ {
+ Models: []config.PayloadModelRule{
+ {Name: "gpt-5.4", Protocol: "openai-response"},
+ },
+ Params: map[string]any{
+ "tools": `[{"type":"image_generation"},{"type":"function","name":"f1"}]`,
+ "tool_choice": `{"type":"image_generation"}`,
+ },
+ },
+ },
+ },
+ }
+ payload := []byte(`{"tools":[{"type":"image_generation"},{"type":"function","name":"f1"}],"tool_choice":{"type":"image_generation"}}`)
+
+ out := ApplyPayloadConfigWithRoot(cfg, "gpt-5.4", "openai-response", "", payload, nil, "", "")
+
+ tools := gjson.GetBytes(out, "tools")
+ if !tools.Exists() || !tools.IsArray() {
+ t.Fatalf("expected tools array, got %v", tools.Type)
+ }
+ arr := tools.Array()
+ if len(arr) != 2 {
+ t.Fatalf("expected 2 tools after payload override, got %d", len(arr))
+ }
+ if got := arr[0].Get("type").String(); got != "image_generation" {
+ t.Fatalf("expected first tool type=image_generation, got %q", got)
+ }
+ if !gjson.GetBytes(out, "tool_choice").Exists() {
+ t.Fatalf("expected tool_choice to be restored by payload override")
+ }
+}
diff --git a/internal/runtime/executor/helps/proxy_helpers.go b/internal/runtime/executor/helps/proxy_helpers.go
index 022bc65c17..91fdc9be49 100644
--- a/internal/runtime/executor/helps/proxy_helpers.go
+++ b/internal/runtime/executor/helps/proxy_helpers.go
@@ -6,9 +6,9 @@ import (
"strings"
"time"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
- cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
- "github.com/router-for-me/CLIProxyAPI/v6/sdk/proxyutil"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
+ "github.com/router-for-me/CLIProxyAPI/v7/sdk/proxyutil"
log "github.com/sirupsen/logrus"
)
diff --git a/internal/runtime/executor/helps/proxy_helpers_test.go b/internal/runtime/executor/helps/proxy_helpers_test.go
index 3311716765..fb57b6b745 100644
--- a/internal/runtime/executor/helps/proxy_helpers_test.go
+++ b/internal/runtime/executor/helps/proxy_helpers_test.go
@@ -5,9 +5,9 @@ import (
"net/http"
"testing"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
- cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
- sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
+ sdkconfig "github.com/router-for-me/CLIProxyAPI/v7/sdk/config"
)
func TestNewProxyAwareHTTPClientDirectBypassesGlobalProxy(t *testing.T) {
diff --git a/internal/runtime/executor/helps/thinking_providers.go b/internal/runtime/executor/helps/thinking_providers.go
index bbd019624d..a776136fde 100644
--- a/internal/runtime/executor/helps/thinking_providers.go
+++ b/internal/runtime/executor/helps/thinking_providers.go
@@ -1,11 +1,11 @@
package helps
import (
- _ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/antigravity"
- _ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/claude"
- _ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/codex"
- _ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/gemini"
- _ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/geminicli"
- _ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/kimi"
- _ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/openai"
+ _ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking/provider/antigravity"
+ _ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking/provider/claude"
+ _ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking/provider/codex"
+ _ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking/provider/gemini"
+ _ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking/provider/geminicli"
+ _ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking/provider/kimi"
+ _ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking/provider/openai"
)
diff --git a/internal/runtime/executor/helps/usage_helpers.go b/internal/runtime/executor/helps/usage_helpers.go
index 8da8fd1e7a..dd76362e10 100644
--- a/internal/runtime/executor/helps/usage_helpers.go
+++ b/internal/runtime/executor/helps/usage_helpers.go
@@ -3,14 +3,15 @@ package helps
import (
"bytes"
"context"
+ "errors"
"fmt"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
- cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
- "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/usage"
+ cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
+ "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/usage"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
@@ -18,8 +19,10 @@ import (
type UsageReporter struct {
provider string
model string
+ alias string
authID string
authIndex string
+ authType string
apiKey string
source string
requestedAt time.Time
@@ -28,12 +31,18 @@ type UsageReporter struct {
func NewUsageReporter(ctx context.Context, provider, model string, auth *cliproxyauth.Auth) *UsageReporter {
apiKey := APIKeyFromContext(ctx)
+ alias := usage.RequestedModelAliasFromContext(ctx)
+ if alias == "" {
+ alias = model
+ }
reporter := &UsageReporter{
provider: provider,
model: model,
+ alias: strings.TrimSpace(alias),
requestedAt: time.Now(),
apiKey: apiKey,
source: resolveUsageSource(auth, apiKey),
+ authType: resolveUsageAuthType(auth),
}
if auth != nil {
reporter.authID = auth.ID
@@ -43,11 +52,34 @@ func NewUsageReporter(ctx context.Context, provider, model string, auth *cliprox
}
func (r *UsageReporter) Publish(ctx context.Context, detail usage.Detail) {
- r.publishWithOutcome(ctx, detail, false)
+ r.publishWithOutcome(ctx, detail, false, usage.Failure{})
+}
+
+func (r *UsageReporter) PublishAdditionalModel(ctx context.Context, model string, detail usage.Detail) {
+ record, ok := r.buildAdditionalModelRecord(model, detail)
+ if !ok {
+ return
+ }
+ usage.PublishRecord(ctx, record)
+}
+
+func (r *UsageReporter) buildAdditionalModelRecord(model string, detail usage.Detail) (usage.Record, bool) {
+ if r == nil {
+ return usage.Record{}, false
+ }
+ model = strings.TrimSpace(model)
+ if model == "" {
+ return usage.Record{}, false
+ }
+ detail = normalizeUsageDetailTotal(detail)
+ if !hasNonZeroTokenUsage(detail) {
+ return usage.Record{}, false
+ }
+ return r.buildRecordForModel(model, detail, false, usage.Failure{}), true
}
-func (r *UsageReporter) PublishFailure(ctx context.Context) {
- r.publishWithOutcome(ctx, usage.Detail{}, true)
+func (r *UsageReporter) PublishFailure(ctx context.Context, errs ...error) {
+ r.publishWithOutcome(ctx, usage.Detail{}, true, failFromErrors(errs...))
}
func (r *UsageReporter) TrackFailure(ctx context.Context, errPtr *error) {
@@ -55,23 +87,36 @@ func (r *UsageReporter) TrackFailure(ctx context.Context, errPtr *error) {
return
}
if *errPtr != nil {
- r.PublishFailure(ctx)
+ r.PublishFailure(ctx, *errPtr)
}
}
-func (r *UsageReporter) publishWithOutcome(ctx context.Context, detail usage.Detail, failed bool) {
+func (r *UsageReporter) publishWithOutcome(ctx context.Context, detail usage.Detail, failed bool, fail usage.Failure) {
if r == nil {
return
}
+ detail = normalizeUsageDetailTotal(detail)
+ r.once.Do(func() {
+ usage.PublishRecord(ctx, r.buildRecord(detail, failed, fail))
+ })
+}
+
+func normalizeUsageDetailTotal(detail usage.Detail) usage.Detail {
if detail.TotalTokens == 0 {
total := detail.InputTokens + detail.OutputTokens + detail.ReasoningTokens
if total > 0 {
detail.TotalTokens = total
}
}
- r.once.Do(func() {
- usage.PublishRecord(ctx, r.buildRecord(detail, failed))
- })
+ return detail
+}
+
+func hasNonZeroTokenUsage(detail usage.Detail) bool {
+ return detail.InputTokens != 0 ||
+ detail.OutputTokens != 0 ||
+ detail.ReasoningTokens != 0 ||
+ detail.CachedTokens != 0 ||
+ detail.TotalTokens != 0
}
// ensurePublished guarantees that a usage record is emitted exactly once.
@@ -83,28 +128,59 @@ func (r *UsageReporter) EnsurePublished(ctx context.Context) {
return
}
r.once.Do(func() {
- usage.PublishRecord(ctx, r.buildRecord(usage.Detail{}, false))
+ usage.PublishRecord(ctx, r.buildRecord(usage.Detail{}, false, usage.Failure{}))
})
}
-func (r *UsageReporter) buildRecord(detail usage.Detail, failed bool) usage.Record {
+func (r *UsageReporter) buildRecord(detail usage.Detail, failed bool, failures ...usage.Failure) usage.Record {
+ var fail usage.Failure
+ if len(failures) > 0 {
+ fail = failures[0]
+ }
+ if r == nil {
+ return usage.Record{Detail: detail, Failed: failed, Fail: fail}
+ }
+ return r.buildRecordForModel(r.model, detail, failed, fail)
+}
+
+func (r *UsageReporter) buildRecordForModel(model string, detail usage.Detail, failed bool, fail usage.Failure) usage.Record {
if r == nil {
- return usage.Record{Detail: detail, Failed: failed}
+ return usage.Record{Model: model, Detail: detail, Failed: failed, Fail: fail}
}
return usage.Record{
Provider: r.provider,
- Model: r.model,
+ Model: model,
+ Alias: r.alias,
Source: r.source,
APIKey: r.apiKey,
AuthID: r.authID,
AuthIndex: r.authIndex,
+ AuthType: r.authType,
RequestedAt: r.requestedAt,
Latency: r.latency(),
Failed: failed,
+ Fail: fail,
Detail: detail,
}
}
+func failFromErrors(errs ...error) usage.Failure {
+ for _, err := range errs {
+ if err == nil {
+ continue
+ }
+ fail := usage.Failure{
+ Body: strings.TrimSpace(err.Error()),
+ }
+ var se interface{ StatusCode() int }
+ if errors.As(err, &se) && se != nil {
+ fail.StatusCode = se.StatusCode()
+ }
+ return fail
+ }
+ return usage.Failure{}
+}
+
func (r *UsageReporter) latency() time.Duration {
if r == nil || r.requestedAt.IsZero() {
return 0
@@ -124,7 +200,7 @@ func APIKeyFromContext(ctx context.Context) string {
if !ok || ginCtx == nil {
return ""
}
- if v, exists := ginCtx.Get("apiKey"); exists {
+ if v, exists := ginCtx.Get("userApiKey"); exists {
switch value := v.(type) {
case string:
return value
@@ -181,30 +257,58 @@ func resolveUsageSource(auth *cliproxyauth.Auth, ctxAPIKey string) string {
return ""
}
+func resolveUsageAuthType(auth *cliproxyauth.Auth) string {
+ if auth == nil {
+ return ""
+ }
+ kind, _ := auth.AccountInfo()
+ kind = strings.TrimSpace(kind)
+ if kind == "api_key" {
+ return "apikey"
+ }
+ return kind
+}
+
func ParseCodexUsage(data []byte) (usage.Detail, bool) {
usageNode := gjson.ParseBytes(data).Get("response.usage")
- if !usageNode.Exists() {
+ if !hasOpenAIStyleUsageTokenFields(usageNode) {
return usage.Detail{}, false
}
- detail := usage.Detail{
- InputTokens: usageNode.Get("input_tokens").Int(),
- OutputTokens: usageNode.Get("output_tokens").Int(),
- TotalTokens: usageNode.Get("total_tokens").Int(),
- }
- if cached := usageNode.Get("input_tokens_details.cached_tokens"); cached.Exists() {
- detail.CachedTokens = cached.Int()
- }
- if reasoning := usageNode.Get("output_tokens_details.reasoning_tokens"); reasoning.Exists() {
- detail.ReasoningTokens = reasoning.Int()
+ return parseOpenAIStyleUsageNode(usageNode), true
+}
+
+func ParseCodexImageToolUsage(data []byte) (usage.Detail, bool) {
+ usageNode := gjson.ParseBytes(data).Get("response.tool_usage.image_gen")
+ if !hasOpenAIStyleUsageTokenFields(usageNode) {
+ return usage.Detail{}, false
}
- return detail, true
+ return parseOpenAIStyleUsageNode(usageNode), true
}
func ParseOpenAIUsage(data []byte) usage.Detail {
usageNode := gjson.ParseBytes(data).Get("usage")
- if !usageNode.Exists() {
+ if !hasOpenAIStyleUsageTokenFields(usageNode) {
return usage.Detail{}
}
+ return parseOpenAIStyleUsageNode(usageNode)
+}
+
+func hasOpenAIStyleUsageTokenFields(usageNode gjson.Result) bool {
+ if !usageNode.Exists() || !usageNode.IsObject() {
+ return false
+ }
+ return usageNode.Get("prompt_tokens").Exists() ||
+ usageNode.Get("input_tokens").Exists() ||
+ usageNode.Get("completion_tokens").Exists() ||
+ usageNode.Get("output_tokens").Exists() ||
+ usageNode.Get("total_tokens").Exists() ||
+ usageNode.Get("prompt_tokens_details.cached_tokens").Exists() ||
+ usageNode.Get("input_tokens_details.cached_tokens").Exists() ||
+ usageNode.Get("completion_tokens_details.reasoning_tokens").Exists() ||
+ usageNode.Get("output_tokens_details.reasoning_tokens").Exists()
+}
+
+func parseOpenAIStyleUsageNode(usageNode gjson.Result) usage.Detail {
inputNode := usageNode.Get("prompt_tokens")
if !inputNode.Exists() {
inputNode = usageNode.Get("input_tokens")
@@ -241,21 +345,10 @@ func ParseOpenAIStreamUsage(line []byte) (usage.Detail, bool) {
return usage.Detail{}, false
}
usageNode := gjson.GetBytes(payload, "usage")
- if !usageNode.Exists() {
+ if !hasOpenAIStyleUsageTokenFields(usageNode) {
return usage.Detail{}, false
}
- detail := usage.Detail{
- InputTokens: usageNode.Get("prompt_tokens").Int(),
- OutputTokens: usageNode.Get("completion_tokens").Int(),
- TotalTokens: usageNode.Get("total_tokens").Int(),
- }
- if cached := usageNode.Get("prompt_tokens_details.cached_tokens"); cached.Exists() {
- detail.CachedTokens = cached.Int()
- }
- if reasoning := usageNode.Get("completion_tokens_details.reasoning_tokens"); reasoning.Exists() {
- detail.ReasoningTokens = reasoning.Int()
- }
- return detail, true
+ return parseOpenAIStyleUsageNode(usageNode), true
}
func ParseClaudeUsage(data []byte) usage.Detail {
@@ -311,12 +404,22 @@ func parseGeminiFamilyUsageDetail(node gjson.Result) usage.Detail {
return detail
}
+func hasGeminiFamilyUsageTokenFields(node gjson.Result) bool {
+ return node.Get("promptTokenCount").Exists() ||
+ node.Get("candidatesTokenCount").Exists() ||
+ node.Get("thoughtsTokenCount").Exists() ||
+ node.Get("totalTokenCount").Exists() ||
+ node.Get("cachedContentTokenCount").Exists()
+}
+
func ParseGeminiCLIUsage(data []byte) usage.Detail {
usageNode := gjson.ParseBytes(data)
- node := usageNode.Get("response.usageMetadata")
- if !node.Exists() {
- node = usageNode.Get("response.usage_metadata")
- }
+ node := firstExistingUsageNode(usageNode,
+ "response.usageMetadata",
+ "response.usage_metadata",
+ "usageMetadata",
+ "usage_metadata",
+ )
if !node.Exists() {
return usage.Detail{}
}
@@ -355,16 +458,32 @@ func ParseGeminiCLIStreamUsage(line []byte) (usage.Detail, bool) {
if len(payload) == 0 || !gjson.ValidBytes(payload) {
return usage.Detail{}, false
}
- node := gjson.GetBytes(payload, "response.usageMetadata")
+ root := gjson.ParseBytes(payload)
+ node := firstExistingUsageNode(root,
+ "response.usageMetadata",
+ "response.usage_metadata",
+ "usageMetadata",
+ "usage_metadata",
+ )
if !node.Exists() {
- node = gjson.GetBytes(payload, "usage_metadata")
+ return usage.Detail{}, false
}
- if !node.Exists() {
+ if !hasGeminiFamilyUsageTokenFields(node) {
return usage.Detail{}, false
}
return parseGeminiFamilyUsageDetail(node), true
}
+func firstExistingUsageNode(root gjson.Result, paths ...string) gjson.Result {
+ for _, path := range paths {
+ node := root.Get(path)
+ if node.Exists() {
+ return node
+ }
+ }
+ return gjson.Result{}
+}
+
func ParseAntigravityUsage(data []byte) usage.Detail {
usageNode := gjson.ParseBytes(data)
node := usageNode.Get("response.usageMetadata")
diff --git a/internal/runtime/executor/helps/usage_helpers_test.go b/internal/runtime/executor/helps/usage_helpers_test.go
index 1a5648e89b..bd0a9c21ba 100644
--- a/internal/runtime/executor/helps/usage_helpers_test.go
+++ b/internal/runtime/executor/helps/usage_helpers_test.go
@@ -1,10 +1,11 @@
package helps
import (
+ "context"
"testing"
"time"
- "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/usage"
+ "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/usage"
)
func TestParseOpenAIUsageChatCompletions(t *testing.T) {
@@ -47,6 +48,88 @@ func TestParseOpenAIUsageResponses(t *testing.T) {
}
}
+func TestParseOpenAIUsageIgnoresNullUsage(t *testing.T) {
+ data := []byte(`{"usage":null}`)
+ detail := ParseOpenAIUsage(data)
+ if detail != (usage.Detail{}) {
+ t.Fatalf("detail = %+v, want zero detail", detail)
+ }
+}
+
+func TestParseOpenAIStreamUsageIgnoresNullUsage(t *testing.T) {
+ line := []byte(`data: {"id":"chunk_1","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"hi"},"finish_reason":null}],"usage":null}`)
+ if detail, ok := ParseOpenAIStreamUsage(line); ok {
+ t.Fatalf("ParseOpenAIStreamUsage() = (%+v, true), want false for null usage", detail)
+ }
+}
+
+func TestParseOpenAIStreamUsageResponsesFields(t *testing.T) {
+ line := []byte(`data: {"id":"chunk_1","object":"chat.completion.chunk","choices":[],"usage":{"input_tokens":8,"output_tokens":5,"total_tokens":13,"input_tokens_details":{"cached_tokens":3},"output_tokens_details":{"reasoning_tokens":2}}}`)
+ detail, ok := ParseOpenAIStreamUsage(line)
+ if !ok {
+ t.Fatal("ParseOpenAIStreamUsage() ok = false, want true")
+ }
+ if detail.InputTokens != 8 {
+ t.Fatalf("input tokens = %d, want %d", detail.InputTokens, 8)
+ }
+ if detail.OutputTokens != 5 {
+ t.Fatalf("output tokens = %d, want %d", detail.OutputTokens, 5)
+ }
+ if detail.TotalTokens != 13 {
+ t.Fatalf("total tokens = %d, want %d", detail.TotalTokens, 13)
+ }
+ if detail.CachedTokens != 3 {
+ t.Fatalf("cached tokens = %d, want %d", detail.CachedTokens, 3)
+ }
+ if detail.ReasoningTokens != 2 {
+ t.Fatalf("reasoning tokens = %d, want %d", detail.ReasoningTokens, 2)
+ }
+}
+
+func TestParseGeminiCLIUsage_TopLevelUsageMetadata(t *testing.T) {
+ data := []byte(`{"usageMetadata":{"promptTokenCount":11,"candidatesTokenCount":7,"thoughtsTokenCount":3,"totalTokenCount":21,"cachedContentTokenCount":5}}`)
+ detail := ParseGeminiCLIUsage(data)
+ if detail.InputTokens != 11 {
+ t.Fatalf("input tokens = %d, want %d", detail.InputTokens, 11)
+ }
+ if detail.OutputTokens != 7 {
+ t.Fatalf("output tokens = %d, want %d", detail.OutputTokens, 7)
+ }
+ if detail.ReasoningTokens != 3 {
+ t.Fatalf("reasoning tokens = %d, want %d", detail.ReasoningTokens, 3)
+ }
+ if detail.TotalTokens != 21 {
+ t.Fatalf("total tokens = %d, want %d", detail.TotalTokens, 21)
+ }
+ if detail.CachedTokens != 5 {
+ t.Fatalf("cached tokens = %d, want %d", detail.CachedTokens, 5)
+ }
+}
+
+func TestParseGeminiCLIStreamUsage_ResponseSnakeCaseUsageMetadata(t *testing.T) {
+ line := []byte(`data: {"response":{"usage_metadata":{"promptTokenCount":13,"candidatesTokenCount":2,"totalTokenCount":15}}}`)
+ detail, ok := ParseGeminiCLIStreamUsage(line)
+ if !ok {
+ t.Fatal("ParseGeminiCLIStreamUsage() ok = false, want true")
+ }
+ if detail.InputTokens != 13 {
+ t.Fatalf("input tokens = %d, want %d", detail.InputTokens, 13)
+ }
+ if detail.OutputTokens != 2 {
+ t.Fatalf("output tokens = %d, want %d", detail.OutputTokens, 2)
+ }
+ if detail.TotalTokens != 15 {
+ t.Fatalf("total tokens = %d, want %d", detail.TotalTokens, 15)
+ }
+}
+
+func TestParseGeminiCLIStreamUsage_IgnoresTrafficTypeOnlyUsageMetadata(t *testing.T) {
+ line := []byte(`data: {"response":{"usageMetadata":{"trafficType":"ON_DEMAND"}}}`)
+ if detail, ok := ParseGeminiCLIStreamUsage(line); ok {
+ t.Fatalf("ParseGeminiCLIStreamUsage() = (%+v, true), want false for traffic-only usage metadata", detail)
+ }
+}
+
func TestUsageReporterBuildRecordIncludesLatency(t *testing.T) {
reporter := &UsageReporter{
provider: "openai",
@@ -62,3 +145,34 @@ func TestUsageReporterBuildRecordIncludesLatency(t *testing.T) {
t.Fatalf("latency = %v, want <= 3s", record.Latency)
}
}
+
+func TestUsageReporterBuildRecordIncludesRequestedModelAlias(t *testing.T) {
+ ctx := usage.WithRequestedModelAlias(context.Background(), "client-gpt")
+ reporter := NewUsageReporter(ctx, "openai", "gpt-5.4", nil)
+
+ record := reporter.buildRecord(usage.Detail{TotalTokens: 3}, false)
+ if record.Model != "gpt-5.4" {
+ t.Fatalf("model = %q, want %q", record.Model, "gpt-5.4")
+ }
+ if record.Alias != "client-gpt" {
+ t.Fatalf("alias = %q, want %q", record.Alias, "client-gpt")
+ }
+}
+
+func TestUsageReporterBuildAdditionalModelRecordSkipsZeroTokens(t *testing.T) {
+ reporter := &UsageReporter{
+ provider: "codex",
+ model: "gpt-5.4",
+ requestedAt: time.Now(),
+ }
+
+ if _, ok := reporter.buildAdditionalModelRecord("gpt-image-2", usage.Detail{}); ok {
+ t.Fatalf("expected all-zero token usage to be skipped")
+ }
+ if _, ok := reporter.buildAdditionalModelRecord("gpt-image-2", usage.Detail{InputTokens: 2}); !ok {
+ t.Fatalf("expected non-zero input token usage to be recorded")
+ }
+ if _, ok := reporter.buildAdditionalModelRecord("gpt-image-2", usage.Detail{CachedTokens: 2}); !ok {
+ t.Fatalf("expected non-zero cached token usage to be recorded")
+ }
+}
diff --git a/internal/runtime/executor/helps/utls_client.go b/internal/runtime/executor/helps/utls_client.go
index 39512a58de..29174e47b6 100644
--- a/internal/runtime/executor/helps/utls_client.go
+++ b/internal/runtime/executor/helps/utls_client.go
@@ -8,9 +8,9 @@ import (
"time"
tls "github.com/refraction-networking/utls"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
- cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
- "github.com/router-for-me/CLIProxyAPI/v6/sdk/proxyutil"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
+ "github.com/router-for-me/CLIProxyAPI/v7/sdk/proxyutil"
log "github.com/sirupsen/logrus"
"golang.org/x/net/http2"
"golang.org/x/net/proxy"
diff --git a/internal/runtime/executor/helps/vertex_payload_helpers.go b/internal/runtime/executor/helps/vertex_payload_helpers.go
new file mode 100644
index 0000000000..4c84fae45e
--- /dev/null
+++ b/internal/runtime/executor/helps/vertex_payload_helpers.go
@@ -0,0 +1,43 @@
+package helps
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/tidwall/gjson"
+ "github.com/tidwall/sjson"
+)
+
+// StripVertexOpenAIResponsesToolCallIDs removes OpenAI Responses call IDs that
+// Vertex rejects in Gemini functionCall/functionResponse payloads.
+func StripVertexOpenAIResponsesToolCallIDs(payload []byte, sourceFormat string) []byte {
+ if !strings.EqualFold(strings.TrimSpace(sourceFormat), "openai-response") {
+ return payload
+ }
+
+ contents := gjson.GetBytes(payload, "contents")
+ if !contents.IsArray() {
+ return payload
+ }
+
+ out := payload
+ for contentIndex, content := range contents.Array() {
+ parts := content.Get("parts")
+ if !parts.IsArray() {
+ continue
+ }
+ for partIndex, part := range parts.Array() {
+ if part.Get("functionCall.id").Exists() {
+ if updated, errDelete := sjson.DeleteBytes(out, fmt.Sprintf("contents.%d.parts.%d.functionCall.id", contentIndex, partIndex)); errDelete == nil {
+ out = updated
+ }
+ }
+ if part.Get("functionResponse.id").Exists() {
+ if updated, errDelete := sjson.DeleteBytes(out, fmt.Sprintf("contents.%d.parts.%d.functionResponse.id", contentIndex, partIndex)); errDelete == nil {
+ out = updated
+ }
+ }
+ }
+ }
+ return out
+}
diff --git a/internal/runtime/executor/kimi_executor.go b/internal/runtime/executor/kimi_executor.go
index 931e3a569f..6cfaec2052 100644
--- a/internal/runtime/executor/kimi_executor.go
+++ b/internal/runtime/executor/kimi_executor.go
@@ -13,14 +13,14 @@ import (
"strings"
"time"
- kimiauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/kimi"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/executor/helps"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
- "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"
+ kimiauth "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/kimi"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/util"
+ cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
+ cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
+ sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
log "github.com/sirupsen/logrus"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
@@ -108,7 +108,8 @@ func (e *KimiExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req
}
requestedModel := helps.PayloadRequestedModel(opts, req.Model)
- body = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel)
+ requestPath := helps.PayloadRequestPath(opts)
+ body = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel, requestPath)
body, err = normalizeKimiToolMessageLinks(body)
if err != nil {
return resp, err
@@ -217,7 +218,8 @@ func (e *KimiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Aut
return nil, fmt.Errorf("kimi executor: failed to set stream_options in payload: %w", err)
}
requestedModel := helps.PayloadRequestedModel(opts, req.Model)
- body = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel)
+ requestPath := helps.PayloadRequestPath(opts)
+ body = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel, requestPath)
body, err = normalizeKimiToolMessageLinks(body)
if err != nil {
return nil, err
@@ -288,17 +290,28 @@ func (e *KimiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Aut
}
chunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, body, bytes.Clone(line), ¶m)
for i := range chunks {
- out <- cliproxyexecutor.StreamChunk{Payload: chunks[i]}
+ select {
+ case out <- cliproxyexecutor.StreamChunk{Payload: chunks[i]}:
+ case <-ctx.Done():
+ return
+ }
}
}
doneChunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, body, []byte("[DONE]"), ¶m)
for i := range doneChunks {
- out <- cliproxyexecutor.StreamChunk{Payload: doneChunks[i]}
+ select {
+ case out <- cliproxyexecutor.StreamChunk{Payload: doneChunks[i]}:
+ case <-ctx.Done():
+ return
+ }
}
if errScan := scanner.Err(); errScan != nil {
helps.RecordAPIResponseError(ctx, e.cfg, errScan)
- reporter.PublishFailure(ctx)
- out <- cliproxyexecutor.StreamChunk{Err: errScan}
+ reporter.PublishFailure(ctx, errScan)
+ select {
+ case out <- cliproxyexecutor.StreamChunk{Err: errScan}:
+ case <-ctx.Done():
+ }
}
}()
return &cliproxyexecutor.StreamResult{Headers: httpResp.Header.Clone(), Chunks: out}, nil
@@ -320,7 +333,17 @@ func normalizeKimiToolMessageLinks(body []byte) ([]byte, error) {
return body, nil
}
- out := body
+ msgs := messages.Array()
+ out, dropped, err := filterKimiEmptyAssistantMessages(body, msgs)
+ if err != nil {
+ return body, err
+ }
+ if dropped > 0 {
+ log.WithField("dropped_assistant_messages", dropped).Debug("kimi executor: dropped empty assistant messages")
+ }
+
+ messages = gjson.GetBytes(out, "messages")
+ msgs = messages.Array()
pending := make([]string, 0)
patched := 0
patchedReasoning := 0
@@ -338,7 +361,6 @@ func normalizeKimiToolMessageLinks(body []byte) ([]byte, error) {
}
}
- msgs := messages.Array()
for msgIdx := range msgs {
msg := msgs[msgIdx]
role := strings.TrimSpace(msg.Get("role").String())
@@ -426,6 +448,96 @@ func normalizeKimiToolMessageLinks(body []byte) ([]byte, error) {
return out, nil
}
+func filterKimiEmptyAssistantMessages(body []byte, msgs []gjson.Result) ([]byte, int, error) {
+ kept := make([]string, 0, len(msgs))
+ dropped := 0
+ for _, msg := range msgs {
+ if shouldDropKimiAssistantMessage(msg) {
+ dropped++
+ continue
+ }
+ kept = append(kept, msg.Raw)
+ }
+ if dropped == 0 {
+ return body, 0, nil
+ }
+
+ rawMessages := []byte("[" + strings.Join(kept, ",") + "]")
+ out, err := sjson.SetRawBytes(body, "messages", rawMessages)
+ if err != nil {
+ return body, 0, fmt.Errorf("kimi executor: failed to drop empty assistant messages: %w", err)
+ }
+ return out, dropped, nil
+}
+
+func shouldDropKimiAssistantMessage(msg gjson.Result) bool {
+ if strings.TrimSpace(msg.Get("role").String()) != "assistant" {
+ return false
+ }
+ if hasKimiToolCalls(msg) || hasKimiLegacyFunctionCall(msg) || hasKimiAssistantReasoning(msg) {
+ return false
+ }
+ return isKimiAssistantContentEmpty(msg.Get("content"))
+}
+
+func hasKimiToolCalls(msg gjson.Result) bool {
+ toolCalls := msg.Get("tool_calls")
+ return toolCalls.Exists() && toolCalls.IsArray() && len(toolCalls.Array()) > 0
+}
+
+func hasKimiLegacyFunctionCall(msg gjson.Result) bool {
+ functionCall := msg.Get("function_call")
+ if !functionCall.Exists() || functionCall.Type == gjson.Null {
+ return false
+ }
+ if functionCall.IsObject() && strings.TrimSpace(functionCall.Raw) == "{}" {
+ return false
+ }
+ return strings.TrimSpace(functionCall.Raw) != ""
+}
+
+func hasKimiAssistantReasoning(msg gjson.Result) bool {
+ reasoning := msg.Get("reasoning_content")
+ return reasoning.Exists() && strings.TrimSpace(reasoning.String()) != ""
+}
+
+func isKimiAssistantContentEmpty(content gjson.Result) bool {
+ if !content.Exists() || content.Type == gjson.Null {
+ return true
+ }
+ if content.Type == gjson.String {
+ return strings.TrimSpace(content.String()) == ""
+ }
+ if !content.IsArray() {
+ return false
+ }
+ for _, part := range content.Array() {
+ if !isKimiAssistantContentPartEmpty(part) {
+ return false
+ }
+ }
+ return true
+}
+
+func isKimiAssistantContentPartEmpty(part gjson.Result) bool {
+ if !part.Exists() || part.Type == gjson.Null {
+ return true
+ }
+ if part.Type == gjson.String {
+ return strings.TrimSpace(part.String()) == ""
+ }
+ if !part.IsObject() {
+ return false
+ }
+ if text := part.Get("text"); text.Exists() {
+ return strings.TrimSpace(text.String()) == ""
+ }
+ if strings.TrimSpace(part.Get("type").String()) == "text" {
+ return true
+ }
+ return strings.TrimSpace(part.Raw) == "{}"
+}
+
func fallbackAssistantReasoning(msg gjson.Result, hasLatest bool, latest string) string {
if hasLatest && strings.TrimSpace(latest) != "" {
return latest
@@ -457,6 +569,9 @@ func fallbackAssistantReasoning(msg gjson.Result, hasLatest bool, latest string)
// Refresh refreshes the Kimi token using the refresh token.
func (e *KimiExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) {
log.Debugf("kimi executor: refresh called")
+ if refreshed, handled, err := helps.RefreshAuthViaHome(ctx, e.cfg, auth); handled {
+ return refreshed, err
+ }
if auth == nil {
return nil, fmt.Errorf("kimi executor: auth is nil")
}
diff --git a/internal/runtime/executor/kimi_executor_test.go b/internal/runtime/executor/kimi_executor_test.go
index 210ddb0ef9..f3de70f1bd 100644
--- a/internal/runtime/executor/kimi_executor_test.go
+++ b/internal/runtime/executor/kimi_executor_test.go
@@ -203,3 +203,70 @@ func TestNormalizeKimiToolMessageLinks_RepairsIDsAndReasoningTogether(t *testing
t.Fatalf("messages.2.reasoning_content = %q, want %q", got, "r1")
}
}
+
+func TestNormalizeKimiToolMessageLinks_DropsEmptyAssistantWithoutToolLink(t *testing.T) {
+ body := []byte(`{
+ "messages":[
+ {"role":"user","content":"start"},
+ {"role":"assistant","content":""},
+ {"role":"assistant","content":" "},
+ {"role":"assistant","content":"","tool_calls":null},
+ {"role":"assistant","content":[{"type":"text","text":" "}]},
+ {"role":"assistant"},
+ {"role":"assistant","content":"keep"},
+ {"role":"user","content":"next"}
+ ]
+ }`)
+
+ out, err := normalizeKimiToolMessageLinks(body)
+ if err != nil {
+ t.Fatalf("normalizeKimiToolMessageLinks() error = %v", err)
+ }
+
+ messages := gjson.GetBytes(out, "messages").Array()
+ if len(messages) != 3 {
+ t.Fatalf("messages length = %d, want 3, raw = %s", len(messages), gjson.GetBytes(out, "messages").Raw)
+ }
+ if got := messages[0].Get("content").String(); got != "start" {
+ t.Fatalf("messages.0.content = %q, want %q", got, "start")
+ }
+ if got := messages[1].Get("content").String(); got != "keep" {
+ t.Fatalf("messages.1.content = %q, want %q", got, "keep")
+ }
+ if got := messages[2].Get("content").String(); got != "next" {
+ t.Fatalf("messages.2.content = %q, want %q", got, "next")
+ }
+}
+
+func TestNormalizeKimiToolMessageLinks_PreservesAssistantWithToolLinkOrReasoning(t *testing.T) {
+ body := []byte(`{
+ "messages":[
+ {"role":"assistant","content":"","tool_calls":[{"id":"call_1","type":"function","function":{"name":"list_directory","arguments":"{}"}}]},
+ {"role":"assistant","content":"","function_call":{"name":"legacy_call","arguments":"{}"}},
+ {"role":"assistant","content":"","reasoning_content":"thought"},
+ {"role":"assistant","content":[{"type":"text","text":" visible "}]}
+ ]
+ }`)
+
+ out, err := normalizeKimiToolMessageLinks(body)
+ if err != nil {
+ t.Fatalf("normalizeKimiToolMessageLinks() error = %v", err)
+ }
+
+ messages := gjson.GetBytes(out, "messages").Array()
+ if len(messages) != 4 {
+ t.Fatalf("messages length = %d, want 4, raw = %s", len(messages), gjson.GetBytes(out, "messages").Raw)
+ }
+ if !messages[0].Get("tool_calls").Exists() {
+ t.Fatalf("messages.0.tool_calls should exist")
+ }
+ if !messages[1].Get("function_call").Exists() {
+ t.Fatalf("messages.1.function_call should exist")
+ }
+ if got := messages[2].Get("reasoning_content").String(); got != "thought" {
+ t.Fatalf("messages.2.reasoning_content = %q, want %q", got, "thought")
+ }
+ if got := messages[3].Get("content.0.text").String(); got != " visible " {
+ t.Fatalf("messages.3.content.0.text = %q, want %q", got, " visible ")
+ }
+}
diff --git a/internal/runtime/executor/openai_compat_executor.go b/internal/runtime/executor/openai_compat_executor.go
index 7f202055a4..82fc9e97d8 100644
--- a/internal/runtime/executor/openai_compat_executor.go
+++ b/internal/runtime/executor/openai_compat_executor.go
@@ -10,13 +10,13 @@ import (
"strings"
"time"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/executor/helps"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
- "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"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/util"
+ cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
+ cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
+ sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
log "github.com/sirupsen/logrus"
"github.com/tidwall/sjson"
)
@@ -96,19 +96,21 @@ func (e *OpenAICompatExecutor) Execute(ctx context.Context, auth *cliproxyauth.A
originalPayload := originalPayloadSource
originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, opts.Stream)
translated := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, opts.Stream)
+
+ translated, err = thinking.ApplyThinking(translated, req.Model, from.String(), to.String(), e.Identifier())
+ if err != nil {
+ return resp, err
+ }
+
requestedModel := helps.PayloadRequestedModel(opts, req.Model)
- translated = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", translated, originalTranslated, requestedModel)
+ requestPath := helps.PayloadRequestPath(opts)
+ translated = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", translated, originalTranslated, requestedModel, requestPath)
if opts.Alt == "responses/compact" {
if updated, errDelete := sjson.DeleteBytes(translated, "stream"); errDelete == nil {
translated = updated
}
}
- translated, err = thinking.ApplyThinking(translated, req.Model, from.String(), to.String(), e.Identifier())
- if err != nil {
- return resp, err
- }
-
url := strings.TrimSuffix(baseURL, "/") + endpoint
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(translated))
if err != nil {
@@ -198,14 +200,16 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy
originalPayload := originalPayloadSource
originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, true)
translated := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, true)
- requestedModel := helps.PayloadRequestedModel(opts, req.Model)
- translated = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", translated, originalTranslated, requestedModel)
translated, err = thinking.ApplyThinking(translated, req.Model, from.String(), to.String(), e.Identifier())
if err != nil {
return nil, err
}
+ requestedModel := helps.PayloadRequestedModel(opts, req.Model)
+ requestPath := helps.PayloadRequestPath(opts)
+ translated = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", translated, originalTranslated, requestedModel, requestPath)
+
// Request usage data in the final streaming chunk so that token statistics
// are captured even when the upstream is an OpenAI-compatible provider.
translated, _ = sjson.SetBytes(translated, "stream_options.include_usage", true)
@@ -279,32 +283,57 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy
if detail, ok := helps.ParseOpenAIStreamUsage(line); ok {
reporter.Publish(ctx, detail)
}
- if len(line) == 0 {
+ trimmedLine := bytes.TrimSpace(line)
+ if len(trimmedLine) == 0 {
continue
}
- if !bytes.HasPrefix(line, []byte("data:")) {
+ if !bytes.HasPrefix(trimmedLine, []byte("data:")) {
+ if bytes.HasPrefix(trimmedLine, []byte(":")) || bytes.HasPrefix(trimmedLine, []byte("event:")) ||
+ bytes.HasPrefix(trimmedLine, []byte("id:")) || bytes.HasPrefix(trimmedLine, []byte("retry:")) {
+ continue
+ }
+ if bytes.HasPrefix(trimmedLine, []byte("{")) || bytes.HasPrefix(trimmedLine, []byte("[")) {
+ streamErr := statusErr{code: http.StatusBadGateway, msg: string(trimmedLine)}
+ helps.RecordAPIResponseError(ctx, e.cfg, streamErr)
+ reporter.PublishFailure(ctx, streamErr)
+ select {
+ case out <- cliproxyexecutor.StreamChunk{Err: streamErr}:
+ case <-ctx.Done():
+ }
+ return
+ }
continue
}
- // OpenAI-compatible streams are SSE: lines typically prefixed with "data: ".
- // Pass through translator; it yields one or more chunks for the target schema.
- chunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, translated, bytes.Clone(line), ¶m)
+ // OpenAI-compatible streams must use SSE data lines.
+ chunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, translated, bytes.Clone(trimmedLine), ¶m)
for i := range chunks {
- out <- cliproxyexecutor.StreamChunk{Payload: chunks[i]}
+ select {
+ case out <- cliproxyexecutor.StreamChunk{Payload: chunks[i]}:
+ case <-ctx.Done():
+ return
+ }
}
}
if errScan := scanner.Err(); errScan != nil {
helps.RecordAPIResponseError(ctx, e.cfg, errScan)
- reporter.PublishFailure(ctx)
- out <- cliproxyexecutor.StreamChunk{Err: errScan}
+ reporter.PublishFailure(ctx, errScan)
+ select {
+ case out <- cliproxyexecutor.StreamChunk{Err: errScan}:
+ case <-ctx.Done():
+ }
} else {
// In case the upstream close the stream without a terminal [DONE] marker.
// Feed a synthetic done marker through the translator so pending
// response.completed events are still emitted exactly once.
chunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, translated, []byte("data: [DONE]"), ¶m)
for i := range chunks {
- out <- cliproxyexecutor.StreamChunk{Payload: chunks[i]}
+ select {
+ case out <- cliproxyexecutor.StreamChunk{Payload: chunks[i]}:
+ case <-ctx.Done():
+ return
+ }
}
}
// Ensure we record the request if no usage chunk was ever seen
@@ -345,7 +374,9 @@ func (e *OpenAICompatExecutor) CountTokens(ctx context.Context, auth *cliproxyau
// Refresh is a no-op for API-key based compatibility providers.
func (e *OpenAICompatExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) {
log.Debugf("openai compat executor: refresh called")
- _ = ctx
+ if refreshed, handled, err := helps.RefreshAuthViaHome(ctx, e.cfg, auth); handled {
+ return refreshed, err
+ }
return auth, nil
}
@@ -378,6 +409,9 @@ func (e *OpenAICompatExecutor) resolveCompatConfig(auth *cliproxyauth.Auth) *con
}
for i := range e.cfg.OpenAICompatibility {
compat := &e.cfg.OpenAICompatibility[i]
+ if compat.Disabled {
+ continue
+ }
for _, candidate := range candidates {
if candidate != "" && strings.EqualFold(strings.TrimSpace(candidate), compat.Name) {
return compat
diff --git a/internal/runtime/executor/openai_compat_executor_compact_test.go b/internal/runtime/executor/openai_compat_executor_compact_test.go
index fe2812623b..3aab5c9b01 100644
--- a/internal/runtime/executor/openai_compat_executor_compact_test.go
+++ b/internal/runtime/executor/openai_compat_executor_compact_test.go
@@ -5,12 +5,13 @@ import (
"io"
"net/http"
"net/http/httptest"
+ "strings"
"testing"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
- 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"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
+ cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
+ sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
"github.com/tidwall/gjson"
)
@@ -56,3 +57,125 @@ func TestOpenAICompatExecutorCompactPassthrough(t *testing.T) {
t.Fatalf("payload = %s", string(resp.Payload))
}
}
+
+func TestOpenAICompatExecutorPayloadOverrideWinsOverThinkingSuffix(t *testing.T) {
+ var gotBody []byte
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ body, _ := io.ReadAll(r.Body)
+ gotBody = body
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"id":"chatcmpl_1","object":"chat.completion","choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}`))
+ }))
+ defer server.Close()
+
+ executor := NewOpenAICompatExecutor("openai-compatibility", &config.Config{
+ Payload: config.PayloadConfig{
+ Override: []config.PayloadRule{
+ {
+ Models: []config.PayloadModelRule{
+ {Name: "custom-openai", Protocol: "openai"},
+ },
+ Params: map[string]any{
+ "reasoning_effort": "low",
+ },
+ },
+ },
+ },
+ })
+ auth := &cliproxyauth.Auth{Attributes: map[string]string{
+ "base_url": server.URL + "/v1",
+ "api_key": "test",
+ }}
+ payload := []byte(`{"model":"custom-openai(high)","messages":[{"role":"user","content":"hi"}]}`)
+ _, err := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{
+ Model: "custom-openai(high)",
+ Payload: payload,
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FromString("openai"),
+ Stream: false,
+ })
+ if err != nil {
+ t.Fatalf("Execute error: %v", err)
+ }
+ if got := gjson.GetBytes(gotBody, "reasoning_effort").String(); got != "low" {
+ t.Fatalf("reasoning_effort = %q, want %q; body=%s", got, "low", string(gotBody))
+ }
+}
+
+func TestOpenAICompatExecutorStreamRejectsPlainJSONAfterBlankLines(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/event-stream")
+ _, _ = w.Write([]byte("\n\n: openrouter processing\n\nevent: error\n"))
+ _, _ = w.Write([]byte(`{"error":{"message":"upstream failed","type":"server_error"}}` + "\n"))
+ }))
+ defer server.Close()
+
+ executor := NewOpenAICompatExecutor("openai-compatibility", &config.Config{})
+ auth := &cliproxyauth.Auth{Attributes: map[string]string{
+ "base_url": server.URL + "/v1",
+ "api_key": "test",
+ }}
+ result, err := executor.ExecuteStream(context.Background(), auth, cliproxyexecutor.Request{
+ Model: "openrouter-model",
+ Payload: []byte(`{"model":"openrouter-model","messages":[{"role":"user","content":"hi"}],"stream":true}`),
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FromString("openai"),
+ Stream: true,
+ })
+ if err != nil {
+ t.Fatalf("ExecuteStream error: %v", err)
+ }
+
+ var gotErr error
+ for chunk := range result.Chunks {
+ if chunk.Err != nil {
+ gotErr = chunk.Err
+ break
+ }
+ }
+ if gotErr == nil {
+ t.Fatalf("expected plain JSON stream error")
+ }
+ if status, ok := gotErr.(interface{ StatusCode() int }); !ok || status.StatusCode() != http.StatusBadGateway {
+ t.Fatalf("stream error status = %v, want %d", gotErr, http.StatusBadGateway)
+ }
+ if !strings.Contains(gotErr.Error(), "upstream failed") {
+ t.Fatalf("stream error = %v", gotErr)
+ }
+}
+
+func TestOpenAICompatExecutorStreamSkipsKeepAliveUntilDataLine(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/event-stream")
+ _, _ = w.Write([]byte("\n\n: openrouter processing\n\nevent: ping\nid: 1\nretry: 1000\n"))
+ _, _ = w.Write([]byte(`data: {"id":"chatcmpl_1","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"hello"},"finish_reason":null}]}` + "\n"))
+ }))
+ defer server.Close()
+
+ executor := NewOpenAICompatExecutor("openai-compatibility", &config.Config{})
+ auth := &cliproxyauth.Auth{Attributes: map[string]string{
+ "base_url": server.URL + "/v1",
+ "api_key": "test",
+ }}
+ result, err := executor.ExecuteStream(context.Background(), auth, cliproxyexecutor.Request{
+ Model: "openrouter-model",
+ Payload: []byte(`{"model":"openrouter-model","messages":[{"role":"user","content":"hi"}],"stream":true}`),
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FromString("openai"),
+ Stream: true,
+ })
+ if err != nil {
+ t.Fatalf("ExecuteStream error: %v", err)
+ }
+
+ var got strings.Builder
+ for chunk := range result.Chunks {
+ if chunk.Err != nil {
+ t.Fatalf("unexpected stream error: %v", chunk.Err)
+ }
+ got.Write(chunk.Payload)
+ }
+ if gjson.Get(got.String(), "choices.0.delta.content").String() != "hello" {
+ t.Fatalf("stream payload = %s", got.String())
+ }
+}
diff --git a/internal/store/gitstore.go b/internal/store/gitstore.go
index bd84d99a23..ba9fe59e2b 100644
--- a/internal/store/gitstore.go
+++ b/internal/store/gitstore.go
@@ -18,7 +18,7 @@ import (
"github.com/go-git/go-git/v6/plumbing/object"
"github.com/go-git/go-git/v6/plumbing/transport"
"github.com/go-git/go-git/v6/plumbing/transport/http"
- cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
+ cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
)
// gcInterval defines minimum time between garbage collection runs.
@@ -287,10 +287,18 @@ func (s *GitTokenStore) Save(_ context.Context, auth *cliproxyauth.Auth) (string
switch {
case auth.Storage != nil:
+ if auth.Metadata == nil {
+ auth.Metadata = make(map[string]any)
+ }
+ auth.Metadata["disabled"] = auth.Disabled
+ if setter, ok := auth.Storage.(interface{ SetMetadata(map[string]any) }); ok {
+ setter.SetMetadata(auth.Metadata)
+ }
if err = auth.Storage.SaveTokenToFile(path); err != nil {
return "", err
}
case auth.Metadata != nil:
+ auth.Metadata["disabled"] = auth.Disabled
raw, errMarshal := json.Marshal(auth.Metadata)
if errMarshal != nil {
return "", fmt.Errorf("auth filestore: marshal metadata failed: %w", errMarshal)
diff --git a/internal/store/objectstore.go b/internal/store/objectstore.go
index a33f6ef8f4..5626e6c65b 100644
--- a/internal/store/objectstore.go
+++ b/internal/store/objectstore.go
@@ -17,8 +17,8 @@ import (
"github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
- cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/misc"
+ cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
log "github.com/sirupsen/logrus"
)
@@ -184,10 +184,18 @@ func (s *ObjectTokenStore) Save(ctx context.Context, auth *cliproxyauth.Auth) (s
switch {
case auth.Storage != nil:
+ if auth.Metadata == nil {
+ auth.Metadata = make(map[string]any)
+ }
+ auth.Metadata["disabled"] = auth.Disabled
+ if setter, ok := auth.Storage.(interface{ SetMetadata(map[string]any) }); ok {
+ setter.SetMetadata(auth.Metadata)
+ }
if err = auth.Storage.SaveTokenToFile(path); err != nil {
return "", err
}
case auth.Metadata != nil:
+ auth.Metadata["disabled"] = auth.Disabled
raw, errMarshal := json.Marshal(auth.Metadata)
if errMarshal != nil {
return "", fmt.Errorf("object store: marshal metadata: %w", errMarshal)
diff --git a/internal/store/postgresstore.go b/internal/store/postgresstore.go
index 527b25cc12..43b125003d 100644
--- a/internal/store/postgresstore.go
+++ b/internal/store/postgresstore.go
@@ -14,8 +14,8 @@ import (
"time"
_ "github.com/jackc/pgx/v5/stdlib"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
- cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/misc"
+ cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
log "github.com/sirupsen/logrus"
)
@@ -214,10 +214,18 @@ func (s *PostgresStore) Save(ctx context.Context, auth *cliproxyauth.Auth) (stri
switch {
case auth.Storage != nil:
+ if auth.Metadata == nil {
+ auth.Metadata = make(map[string]any)
+ }
+ auth.Metadata["disabled"] = auth.Disabled
+ if setter, ok := auth.Storage.(interface{ SetMetadata(map[string]any) }); ok {
+ setter.SetMetadata(auth.Metadata)
+ }
if err = auth.Storage.SaveTokenToFile(path); err != nil {
return "", err
}
case auth.Metadata != nil:
+ auth.Metadata["disabled"] = auth.Disabled
raw, errMarshal := json.Marshal(auth.Metadata)
if errMarshal != nil {
return "", fmt.Errorf("postgres store: marshal metadata: %w", errMarshal)
diff --git a/internal/thinking/apply.go b/internal/thinking/apply.go
index 1edeac874c..d422a8d8b2 100644
--- a/internal/thinking/apply.go
+++ b/internal/thinking/apply.go
@@ -4,7 +4,7 @@ package thinking
import (
"strings"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
log "github.com/sirupsen/logrus"
"github.com/tidwall/gjson"
)
diff --git a/internal/thinking/apply_user_defined_test.go b/internal/thinking/apply_user_defined_test.go
index aa24ab8e9c..c485d2521a 100644
--- a/internal/thinking/apply_user_defined_test.go
+++ b/internal/thinking/apply_user_defined_test.go
@@ -3,9 +3,9 @@ package thinking_test
import (
"testing"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
- _ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/claude"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
+ _ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking/provider/claude"
"github.com/tidwall/gjson"
)
diff --git a/internal/thinking/convert.go b/internal/thinking/convert.go
index b22a0879ed..31945daa7c 100644
--- a/internal/thinking/convert.go
+++ b/internal/thinking/convert.go
@@ -3,7 +3,7 @@ package thinking
import (
"strings"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
)
// levelToBudgetMap defines the standard Level → Budget mapping.
diff --git a/internal/thinking/provider/antigravity/apply.go b/internal/thinking/provider/antigravity/apply.go
index d202035fc6..0a8f1c4537 100644
--- a/internal/thinking/provider/antigravity/apply.go
+++ b/internal/thinking/provider/antigravity/apply.go
@@ -9,8 +9,8 @@ package antigravity
import (
"strings"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
diff --git a/internal/thinking/provider/claude/apply.go b/internal/thinking/provider/claude/apply.go
index 275be46924..140a8135f7 100644
--- a/internal/thinking/provider/claude/apply.go
+++ b/internal/thinking/provider/claude/apply.go
@@ -9,8 +9,8 @@
package claude
import (
- "github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
diff --git a/internal/thinking/provider/codex/apply.go b/internal/thinking/provider/codex/apply.go
index 0f33635950..83f5ae8457 100644
--- a/internal/thinking/provider/codex/apply.go
+++ b/internal/thinking/provider/codex/apply.go
@@ -7,8 +7,8 @@
package codex
import (
- "github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
diff --git a/internal/thinking/provider/gemini/apply.go b/internal/thinking/provider/gemini/apply.go
index 39bb4231d0..8e6e83f330 100644
--- a/internal/thinking/provider/gemini/apply.go
+++ b/internal/thinking/provider/gemini/apply.go
@@ -12,8 +12,8 @@
package gemini
import (
- "github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
diff --git a/internal/thinking/provider/geminicli/apply.go b/internal/thinking/provider/geminicli/apply.go
index 5908b6bce5..e9311e8c18 100644
--- a/internal/thinking/provider/geminicli/apply.go
+++ b/internal/thinking/provider/geminicli/apply.go
@@ -5,8 +5,8 @@
package geminicli
import (
- "github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
diff --git a/internal/thinking/provider/kimi/apply.go b/internal/thinking/provider/kimi/apply.go
index ff47c46d03..ea3ed572f0 100644
--- a/internal/thinking/provider/kimi/apply.go
+++ b/internal/thinking/provider/kimi/apply.go
@@ -7,8 +7,8 @@ package kimi
import (
"fmt"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
diff --git a/internal/thinking/provider/kimi/apply_test.go b/internal/thinking/provider/kimi/apply_test.go
index 707f11c758..78069424ed 100644
--- a/internal/thinking/provider/kimi/apply_test.go
+++ b/internal/thinking/provider/kimi/apply_test.go
@@ -3,8 +3,8 @@ package kimi
import (
"testing"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
"github.com/tidwall/gjson"
)
diff --git a/internal/thinking/provider/openai/apply.go b/internal/thinking/provider/openai/apply.go
index c77c1ab8e4..1e87b72b37 100644
--- a/internal/thinking/provider/openai/apply.go
+++ b/internal/thinking/provider/openai/apply.go
@@ -6,8 +6,8 @@
package openai
import (
- "github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
diff --git a/internal/thinking/types.go b/internal/thinking/types.go
index a31d798197..39868a02f4 100644
--- a/internal/thinking/types.go
+++ b/internal/thinking/types.go
@@ -4,7 +4,7 @@
// thinking configurations across various AI providers (Claude, Gemini, OpenAI, Codex, Antigravity, Kimi).
package thinking
-import "github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
+import "github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
// ThinkingMode represents the type of thinking configuration mode.
type ThinkingMode int
diff --git a/internal/thinking/validate.go b/internal/thinking/validate.go
index 4a3ca97ce8..2baa93f1da 100644
--- a/internal/thinking/validate.go
+++ b/internal/thinking/validate.go
@@ -5,7 +5,7 @@ import (
"fmt"
"strings"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
log "github.com/sirupsen/logrus"
)
diff --git a/internal/translator/antigravity/claude/antigravity_claude_request.go b/internal/translator/antigravity/claude/antigravity_claude_request.go
index 8ae69648db..7f36b11ccb 100644
--- a/internal/translator/antigravity/claude/antigravity_claude_request.go
+++ b/internal/translator/antigravity/claude/antigravity_claude_request.go
@@ -8,10 +8,10 @@ package claude
import (
"strings"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/cache"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/common"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/cache"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/common"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/util"
log "github.com/sirupsen/logrus"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
diff --git a/internal/translator/antigravity/claude/antigravity_claude_request_test.go b/internal/translator/antigravity/claude/antigravity_claude_request_test.go
index 919e29062a..bb3cdf4f34 100644
--- a/internal/translator/antigravity/claude/antigravity_claude_request_test.go
+++ b/internal/translator/antigravity/claude/antigravity_claude_request_test.go
@@ -6,7 +6,7 @@ import (
"strings"
"testing"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/cache"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/cache"
"github.com/tidwall/gjson"
"google.golang.org/protobuf/encoding/protowire"
)
diff --git a/internal/translator/antigravity/claude/antigravity_claude_response.go b/internal/translator/antigravity/claude/antigravity_claude_response.go
index 17a31f217f..427551df6c 100644
--- a/internal/translator/antigravity/claude/antigravity_claude_response.go
+++ b/internal/translator/antigravity/claude/antigravity_claude_response.go
@@ -15,9 +15,9 @@ import (
"sync/atomic"
"time"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/cache"
- translatorcommon "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/common"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/cache"
+ translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/util"
log "github.com/sirupsen/logrus"
"github.com/tidwall/gjson"
diff --git a/internal/translator/antigravity/claude/antigravity_claude_response_test.go b/internal/translator/antigravity/claude/antigravity_claude_response_test.go
index 05a3df899d..1490ab3cbd 100644
--- a/internal/translator/antigravity/claude/antigravity_claude_response_test.go
+++ b/internal/translator/antigravity/claude/antigravity_claude_response_test.go
@@ -6,7 +6,7 @@ import (
"strings"
"testing"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/cache"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/cache"
)
// ============================================================================
diff --git a/internal/translator/antigravity/claude/init.go b/internal/translator/antigravity/claude/init.go
index 21fe0b26ed..4d9bd721ff 100644
--- a/internal/translator/antigravity/claude/init.go
+++ b/internal/translator/antigravity/claude/init.go
@@ -1,9 +1,9 @@
package claude
import (
- . "github.com/router-for-me/CLIProxyAPI/v6/internal/constant"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator"
+ . "github.com/router-for-me/CLIProxyAPI/v7/internal/constant"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/translator"
)
func init() {
diff --git a/internal/translator/antigravity/claude/signature_validation.go b/internal/translator/antigravity/claude/signature_validation.go
index 63203abdce..f82fc2e364 100644
--- a/internal/translator/antigravity/claude/signature_validation.go
+++ b/internal/translator/antigravity/claude/signature_validation.go
@@ -53,7 +53,7 @@ import (
"strings"
"unicode/utf8"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/cache"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/cache"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
"google.golang.org/protobuf/encoding/protowire"
diff --git a/internal/translator/antigravity/gemini/antigravity_gemini_request.go b/internal/translator/antigravity/gemini/antigravity_gemini_request.go
index 3612c0fb1a..b33b9c40e1 100644
--- a/internal/translator/antigravity/gemini/antigravity_gemini_request.go
+++ b/internal/translator/antigravity/gemini/antigravity_gemini_request.go
@@ -9,8 +9,8 @@ import (
"fmt"
"strings"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/common"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/common"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/util"
log "github.com/sirupsen/logrus"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
diff --git a/internal/translator/antigravity/gemini/antigravity_gemini_response.go b/internal/translator/antigravity/gemini/antigravity_gemini_response.go
index 7b43c48db2..b0deb7320a 100644
--- a/internal/translator/antigravity/gemini/antigravity_gemini_response.go
+++ b/internal/translator/antigravity/gemini/antigravity_gemini_response.go
@@ -9,7 +9,7 @@ import (
"bytes"
"context"
- translatorcommon "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/common"
+ translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
diff --git a/internal/translator/antigravity/gemini/init.go b/internal/translator/antigravity/gemini/init.go
index 3955824863..dcb331618a 100644
--- a/internal/translator/antigravity/gemini/init.go
+++ b/internal/translator/antigravity/gemini/init.go
@@ -1,9 +1,9 @@
package gemini
import (
- . "github.com/router-for-me/CLIProxyAPI/v6/internal/constant"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator"
+ . "github.com/router-for-me/CLIProxyAPI/v7/internal/constant"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/translator"
)
func init() {
diff --git a/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request.go b/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request.go
index b33be50bd0..0d9ee6fe0a 100644
--- a/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request.go
+++ b/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request.go
@@ -6,9 +6,9 @@ import (
"fmt"
"strings"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/common"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/misc"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/common"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/util"
log "github.com/sirupsen/logrus"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
diff --git a/internal/translator/antigravity/openai/chat-completions/antigravity_openai_response.go b/internal/translator/antigravity/openai/chat-completions/antigravity_openai_response.go
index 9188c75a2c..2be24102ff 100644
--- a/internal/translator/antigravity/openai/chat-completions/antigravity_openai_response.go
+++ b/internal/translator/antigravity/openai/chat-completions/antigravity_openai_response.go
@@ -13,10 +13,10 @@ import (
"sync/atomic"
"time"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/util"
log "github.com/sirupsen/logrus"
- . "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/openai/chat-completions"
+ . "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/openai/chat-completions"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
diff --git a/internal/translator/antigravity/openai/chat-completions/init.go b/internal/translator/antigravity/openai/chat-completions/init.go
index 5c5c71e461..2217e7919c 100644
--- a/internal/translator/antigravity/openai/chat-completions/init.go
+++ b/internal/translator/antigravity/openai/chat-completions/init.go
@@ -1,9 +1,9 @@
package chat_completions
import (
- . "github.com/router-for-me/CLIProxyAPI/v6/internal/constant"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator"
+ . "github.com/router-for-me/CLIProxyAPI/v7/internal/constant"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/translator"
)
func init() {
diff --git a/internal/translator/antigravity/openai/responses/antigravity_openai-responses_request.go b/internal/translator/antigravity/openai/responses/antigravity_openai-responses_request.go
index 90bfa14c05..94a6b852b0 100644
--- a/internal/translator/antigravity/openai/responses/antigravity_openai-responses_request.go
+++ b/internal/translator/antigravity/openai/responses/antigravity_openai-responses_request.go
@@ -1,8 +1,8 @@
package responses
import (
- . "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/antigravity/gemini"
- . "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/openai/responses"
+ . "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/antigravity/gemini"
+ . "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/openai/responses"
)
func ConvertOpenAIResponsesRequestToAntigravity(modelName string, inputRawJSON []byte, stream bool) []byte {
diff --git a/internal/translator/antigravity/openai/responses/antigravity_openai-responses_response.go b/internal/translator/antigravity/openai/responses/antigravity_openai-responses_response.go
index a087e0bd0f..3256950461 100644
--- a/internal/translator/antigravity/openai/responses/antigravity_openai-responses_response.go
+++ b/internal/translator/antigravity/openai/responses/antigravity_openai-responses_response.go
@@ -3,7 +3,7 @@ package responses
import (
"context"
- . "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/openai/responses"
+ . "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/openai/responses"
"github.com/tidwall/gjson"
)
diff --git a/internal/translator/antigravity/openai/responses/init.go b/internal/translator/antigravity/openai/responses/init.go
index 8d13703239..49041f2905 100644
--- a/internal/translator/antigravity/openai/responses/init.go
+++ b/internal/translator/antigravity/openai/responses/init.go
@@ -1,9 +1,9 @@
package responses
import (
- . "github.com/router-for-me/CLIProxyAPI/v6/internal/constant"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator"
+ . "github.com/router-for-me/CLIProxyAPI/v7/internal/constant"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/translator"
)
func init() {
diff --git a/internal/translator/claude/gemini-cli/claude_gemini-cli_request.go b/internal/translator/claude/gemini-cli/claude_gemini-cli_request.go
index 831d784db3..fd68a957f5 100644
--- a/internal/translator/claude/gemini-cli/claude_gemini-cli_request.go
+++ b/internal/translator/claude/gemini-cli/claude_gemini-cli_request.go
@@ -6,7 +6,7 @@
package geminiCLI
import (
- . "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/claude/gemini"
+ . "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/claude/gemini"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
diff --git a/internal/translator/claude/gemini-cli/claude_gemini-cli_response.go b/internal/translator/claude/gemini-cli/claude_gemini-cli_response.go
index 62e2650fd9..858886c272 100644
--- a/internal/translator/claude/gemini-cli/claude_gemini-cli_response.go
+++ b/internal/translator/claude/gemini-cli/claude_gemini-cli_response.go
@@ -7,8 +7,8 @@ package geminiCLI
import (
"context"
- . "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/claude/gemini"
- translatorcommon "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/common"
+ . "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/claude/gemini"
+ translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common"
)
// ConvertClaudeResponseToGeminiCLI converts Claude Code streaming response format to Gemini CLI format.
diff --git a/internal/translator/claude/gemini-cli/init.go b/internal/translator/claude/gemini-cli/init.go
index ca364a6ee0..33a1332daf 100644
--- a/internal/translator/claude/gemini-cli/init.go
+++ b/internal/translator/claude/gemini-cli/init.go
@@ -1,9 +1,9 @@
package geminiCLI
import (
- . "github.com/router-for-me/CLIProxyAPI/v6/internal/constant"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator"
+ . "github.com/router-for-me/CLIProxyAPI/v7/internal/constant"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/translator"
)
func init() {
diff --git a/internal/translator/claude/gemini/claude_gemini_request.go b/internal/translator/claude/gemini/claude_gemini_request.go
index d2a215e7de..d716d28f35 100644
--- a/internal/translator/claude/gemini/claude_gemini_request.go
+++ b/internal/translator/claude/gemini/claude_gemini_request.go
@@ -14,9 +14,9 @@ import (
"strings"
"github.com/google/uuid"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/util"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
diff --git a/internal/translator/claude/gemini/claude_gemini_response.go b/internal/translator/claude/gemini/claude_gemini_response.go
index 846c26056f..3f127e3205 100644
--- a/internal/translator/claude/gemini/claude_gemini_response.go
+++ b/internal/translator/claude/gemini/claude_gemini_response.go
@@ -12,7 +12,7 @@ import (
"strings"
"time"
- translatorcommon "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/common"
+ translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
diff --git a/internal/translator/claude/gemini/init.go b/internal/translator/claude/gemini/init.go
index 8924f62c87..0ed533cebf 100644
--- a/internal/translator/claude/gemini/init.go
+++ b/internal/translator/claude/gemini/init.go
@@ -1,9 +1,9 @@
package gemini
import (
- . "github.com/router-for-me/CLIProxyAPI/v6/internal/constant"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator"
+ . "github.com/router-for-me/CLIProxyAPI/v7/internal/constant"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/translator"
)
func init() {
diff --git a/internal/translator/claude/openai/chat-completions/claude_openai_request.go b/internal/translator/claude/openai/chat-completions/claude_openai_request.go
index e9d8d35b09..bad56d1273 100644
--- a/internal/translator/claude/openai/chat-completions/claude_openai_request.go
+++ b/internal/translator/claude/openai/chat-completions/claude_openai_request.go
@@ -14,8 +14,8 @@ import (
"strings"
"github.com/google/uuid"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
diff --git a/internal/translator/claude/openai/chat-completions/claude_openai_response.go b/internal/translator/claude/openai/chat-completions/claude_openai_response.go
index 1fd3f2ae16..99c7523874 100644
--- a/internal/translator/claude/openai/chat-completions/claude_openai_response.go
+++ b/internal/translator/claude/openai/chat-completions/claude_openai_response.go
@@ -25,10 +25,19 @@ type ConvertAnthropicResponseToOpenAIParams struct {
CreatedAt int64
ResponseID string
FinishReason string
+ Usage claudeUsageTokens
// Tool calls accumulator for streaming
ToolCallsAccumulator map[int]*ToolCallAccumulator
}
+type claudeUsageTokens struct {
+ InputTokens int64
+ OutputTokens int64
+ CacheCreationInputTokens int64
+ CacheReadInputTokens int64
+ HasUsage bool
+}
+
// ToolCallAccumulator holds the state for accumulating tool call data
type ToolCallAccumulator struct {
ID string
@@ -36,15 +45,30 @@ type ToolCallAccumulator struct {
Arguments strings.Builder
}
-func calculateClaudeUsageTokens(usage gjson.Result) (promptTokens, completionTokens, totalTokens, cachedTokens int64) {
- inputTokens := usage.Get("input_tokens").Int()
- completionTokens = usage.Get("output_tokens").Int()
- cachedTokens = usage.Get("cache_read_input_tokens").Int()
- cacheCreationInputTokens := usage.Get("cache_creation_input_tokens").Int()
+func (u *claudeUsageTokens) Merge(usage gjson.Result) {
+ if !usage.Exists() {
+ return
+ }
+ u.HasUsage = true
+ if inputTokens := usage.Get("input_tokens"); inputTokens.Exists() {
+ u.InputTokens = inputTokens.Int()
+ }
+ if outputTokens := usage.Get("output_tokens"); outputTokens.Exists() {
+ u.OutputTokens = outputTokens.Int()
+ }
+ if cacheCreationInputTokens := usage.Get("cache_creation_input_tokens"); cacheCreationInputTokens.Exists() {
+ u.CacheCreationInputTokens = cacheCreationInputTokens.Int()
+ }
+ if cacheReadInputTokens := usage.Get("cache_read_input_tokens"); cacheReadInputTokens.Exists() {
+ u.CacheReadInputTokens = cacheReadInputTokens.Int()
+ }
+}
- promptTokens = inputTokens + cacheCreationInputTokens + cachedTokens
+func (u claudeUsageTokens) OpenAIUsage() (promptTokens, completionTokens, totalTokens, cachedTokens int64) {
+ cachedTokens = u.CacheReadInputTokens
+ promptTokens = u.InputTokens + u.CacheCreationInputTokens + cachedTokens
+ completionTokens = u.OutputTokens
totalTokens = promptTokens + completionTokens
-
return promptTokens, completionTokens, totalTokens, cachedTokens
}
@@ -112,6 +136,7 @@ func ConvertClaudeResponseToOpenAI(_ context.Context, modelName string, original
if (*param).(*ConvertAnthropicResponseToOpenAIParams).ToolCallsAccumulator == nil {
(*param).(*ConvertAnthropicResponseToOpenAIParams).ToolCallsAccumulator = make(map[int]*ToolCallAccumulator)
}
+ (*param).(*ConvertAnthropicResponseToOpenAIParams).Usage.Merge(message.Get("usage"))
}
return [][]byte{template}
@@ -215,7 +240,8 @@ func ConvertClaudeResponseToOpenAI(_ context.Context, modelName string, original
// Handle usage information for token counts
if usage := root.Get("usage"); usage.Exists() {
- promptTokens, completionTokens, totalTokens, cachedTokens := calculateClaudeUsageTokens(usage)
+ (*param).(*ConvertAnthropicResponseToOpenAIParams).Usage.Merge(usage)
+ promptTokens, completionTokens, totalTokens, cachedTokens := (*param).(*ConvertAnthropicResponseToOpenAIParams).Usage.OpenAIUsage()
template, _ = sjson.SetBytes(template, "usage.prompt_tokens", promptTokens)
template, _ = sjson.SetBytes(template, "usage.completion_tokens", completionTokens)
template, _ = sjson.SetBytes(template, "usage.total_tokens", totalTokens)
@@ -296,6 +322,7 @@ func ConvertClaudeResponseToOpenAINonStream(_ context.Context, _ string, origina
var stopReason string
var contentParts []string
var reasoningParts []string
+ usageTokens := claudeUsageTokens{}
toolCallsAccumulator := make(map[int]*ToolCallAccumulator)
for _, chunk := range chunks {
@@ -309,6 +336,7 @@ func ConvertClaudeResponseToOpenAINonStream(_ context.Context, _ string, origina
messageID = message.Get("id").String()
model = message.Get("model").String()
createdAt = time.Now().Unix()
+ usageTokens.Merge(message.Get("usage"))
}
case "content_block_start":
@@ -371,15 +399,19 @@ func ConvertClaudeResponseToOpenAINonStream(_ context.Context, _ string, origina
}
}
if usage := root.Get("usage"); usage.Exists() {
- promptTokens, completionTokens, totalTokens, cachedTokens := calculateClaudeUsageTokens(usage)
- out, _ = sjson.SetBytes(out, "usage.prompt_tokens", promptTokens)
- out, _ = sjson.SetBytes(out, "usage.completion_tokens", completionTokens)
- out, _ = sjson.SetBytes(out, "usage.total_tokens", totalTokens)
- out, _ = sjson.SetBytes(out, "usage.prompt_tokens_details.cached_tokens", cachedTokens)
+ usageTokens.Merge(usage)
}
}
}
+ if usageTokens.HasUsage {
+ promptTokens, completionTokens, totalTokens, cachedTokens := usageTokens.OpenAIUsage()
+ out, _ = sjson.SetBytes(out, "usage.prompt_tokens", promptTokens)
+ out, _ = sjson.SetBytes(out, "usage.completion_tokens", completionTokens)
+ out, _ = sjson.SetBytes(out, "usage.total_tokens", totalTokens)
+ out, _ = sjson.SetBytes(out, "usage.prompt_tokens_details.cached_tokens", cachedTokens)
+ }
+
// Set basic response fields including message ID, creation time, and model
out, _ = sjson.SetBytes(out, "id", messageID)
out, _ = sjson.SetBytes(out, "created", createdAt)
diff --git a/internal/translator/claude/openai/chat-completions/claude_openai_response_test.go b/internal/translator/claude/openai/chat-completions/claude_openai_response_test.go
index 7bd6eb1f15..5a9a6d3ad5 100644
--- a/internal/translator/claude/openai/chat-completions/claude_openai_response_test.go
+++ b/internal/translator/claude/openai/chat-completions/claude_openai_response_test.go
@@ -37,6 +37,44 @@ func TestConvertClaudeResponseToOpenAI_StreamUsageIncludesCachedTokens(t *testin
}
}
+func TestConvertClaudeResponseToOpenAI_StreamUsageMergesMessageStartUsage(t *testing.T) {
+ ctx := context.Background()
+ var param any
+
+ ConvertClaudeResponseToOpenAI(
+ ctx,
+ "claude-opus-4-6",
+ nil,
+ nil,
+ []byte(`data: {"type":"message_start","message":{"id":"msg_123","model":"claude-opus-4-6","usage":{"input_tokens":13,"output_tokens":1,"cache_read_input_tokens":22000,"cache_creation_input_tokens":31}}}`),
+ ¶m,
+ )
+ out := ConvertClaudeResponseToOpenAI(
+ ctx,
+ "claude-opus-4-6",
+ nil,
+ nil,
+ []byte(`data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":4}}`),
+ ¶m,
+ )
+ if len(out) != 1 {
+ t.Fatalf("expected 1 chunk, got %d", len(out))
+ }
+
+ if gotPromptTokens := gjson.GetBytes(out[0], "usage.prompt_tokens").Int(); gotPromptTokens != 22044 {
+ t.Fatalf("expected prompt_tokens %d, got %d", 22044, gotPromptTokens)
+ }
+ if gotCompletionTokens := gjson.GetBytes(out[0], "usage.completion_tokens").Int(); gotCompletionTokens != 4 {
+ t.Fatalf("expected completion_tokens %d, got %d", 4, gotCompletionTokens)
+ }
+ if gotTotalTokens := gjson.GetBytes(out[0], "usage.total_tokens").Int(); gotTotalTokens != 22048 {
+ t.Fatalf("expected total_tokens %d, got %d", 22048, gotTotalTokens)
+ }
+ if gotCachedTokens := gjson.GetBytes(out[0], "usage.prompt_tokens_details.cached_tokens").Int(); gotCachedTokens != 22000 {
+ t.Fatalf("expected cached_tokens %d, got %d", 22000, gotCachedTokens)
+ }
+}
+
func TestConvertClaudeResponseToOpenAINonStream_UsageIncludesCachedTokens(t *testing.T) {
rawJSON := []byte("data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_123\",\"model\":\"claude-opus-4-6\"}}\n" +
"data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"input_tokens\":13,\"output_tokens\":4,\"cache_read_input_tokens\":22000,\"cache_creation_input_tokens\":31}}\n")
@@ -56,3 +94,23 @@ func TestConvertClaudeResponseToOpenAINonStream_UsageIncludesCachedTokens(t *tes
t.Fatalf("expected cached_tokens %d, got %d", 22000, gotCachedTokens)
}
}
+
+func TestConvertClaudeResponseToOpenAINonStream_UsageMergesMessageStartUsage(t *testing.T) {
+ rawJSON := []byte("data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_123\",\"model\":\"claude-opus-4-6\",\"usage\":{\"input_tokens\":13,\"output_tokens\":1,\"cache_read_input_tokens\":22000,\"cache_creation_input_tokens\":31}}}\n" +
+ "data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":4}}\n")
+
+ out := ConvertClaudeResponseToOpenAINonStream(context.Background(), "", nil, nil, rawJSON, nil)
+
+ if gotPromptTokens := gjson.GetBytes(out, "usage.prompt_tokens").Int(); gotPromptTokens != 22044 {
+ t.Fatalf("expected prompt_tokens %d, got %d", 22044, gotPromptTokens)
+ }
+ if gotCompletionTokens := gjson.GetBytes(out, "usage.completion_tokens").Int(); gotCompletionTokens != 4 {
+ t.Fatalf("expected completion_tokens %d, got %d", 4, gotCompletionTokens)
+ }
+ if gotTotalTokens := gjson.GetBytes(out, "usage.total_tokens").Int(); gotTotalTokens != 22048 {
+ t.Fatalf("expected total_tokens %d, got %d", 22048, gotTotalTokens)
+ }
+ if gotCachedTokens := gjson.GetBytes(out, "usage.prompt_tokens_details.cached_tokens").Int(); gotCachedTokens != 22000 {
+ t.Fatalf("expected cached_tokens %d, got %d", 22000, gotCachedTokens)
+ }
+}
diff --git a/internal/translator/claude/openai/chat-completions/init.go b/internal/translator/claude/openai/chat-completions/init.go
index a18840bace..7474fb2a38 100644
--- a/internal/translator/claude/openai/chat-completions/init.go
+++ b/internal/translator/claude/openai/chat-completions/init.go
@@ -1,9 +1,9 @@
package chat_completions
import (
- . "github.com/router-for-me/CLIProxyAPI/v6/internal/constant"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator"
+ . "github.com/router-for-me/CLIProxyAPI/v7/internal/constant"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/translator"
)
func init() {
diff --git a/internal/translator/claude/openai/responses/claude_openai-responses_request.go b/internal/translator/claude/openai/responses/claude_openai-responses_request.go
index 514129ca9b..1398749573 100644
--- a/internal/translator/claude/openai/responses/claude_openai-responses_request.go
+++ b/internal/translator/claude/openai/responses/claude_openai-responses_request.go
@@ -9,8 +9,8 @@ import (
"strings"
"github.com/google/uuid"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
@@ -339,25 +339,21 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte
})
}
+ includedToolNames := map[string]struct{}{}
+ toolNameMap := map[string]string{}
+
// tools mapping: parameters -> input_schema
if tools := root.Get("tools"); tools.Exists() && tools.IsArray() {
toolsJSON := []byte("[]")
tools.ForEach(func(_, tool gjson.Result) bool {
- tJSON := []byte(`{"name":"","description":"","input_schema":{}}`)
- if n := tool.Get("name"); n.Exists() {
- tJSON, _ = sjson.SetBytes(tJSON, "name", n.String())
- }
- if d := tool.Get("description"); d.Exists() {
- tJSON, _ = sjson.SetBytes(tJSON, "description", d.String())
- }
-
- if params := tool.Get("parameters"); params.Exists() {
- tJSON, _ = sjson.SetRawBytes(tJSON, "input_schema", []byte(params.Raw))
- } else if params = tool.Get("parametersJsonSchema"); params.Exists() {
- tJSON, _ = sjson.SetRawBytes(tJSON, "input_schema", []byte(params.Raw))
+ convertedTools := convertResponsesToolToClaudeTools(tool, toolNameMap)
+ for _, tJSON := range convertedTools {
+ toolName := gjson.GetBytes(tJSON, "name").String()
+ if toolName != "" {
+ includedToolNames[toolName] = struct{}{}
+ }
+ toolsJSON, _ = sjson.SetRawBytes(toolsJSON, "-1", tJSON)
}
-
- toolsJSON, _ = sjson.SetRawBytes(toolsJSON, "-1", tJSON)
return true
})
if parsedTools := gjson.ParseBytes(toolsJSON); parsedTools.IsArray() && len(parsedTools.Array()) > 0 {
@@ -375,14 +371,24 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte
case "none":
// Leave unset; implies no tools
case "required":
- out, _ = sjson.SetRawBytes(out, "tool_choice", []byte(`{"type":"any"}`))
+ if len(includedToolNames) > 0 {
+ out, _ = sjson.SetRawBytes(out, "tool_choice", []byte(`{"type":"any"}`))
+ }
}
case gjson.JSON:
if toolChoice.Get("type").String() == "function" {
fn := toolChoice.Get("function.name").String()
- toolChoiceJSON := []byte(`{"name":"","type":"tool"}`)
- toolChoiceJSON, _ = sjson.SetBytes(toolChoiceJSON, "name", fn)
- out, _ = sjson.SetRawBytes(out, "tool_choice", toolChoiceJSON)
+ if fn == "" {
+ fn = toolChoice.Get("name").String()
+ }
+ if mappedName := toolNameMap[fn]; mappedName != "" {
+ fn = mappedName
+ }
+ if _, ok := includedToolNames[fn]; ok {
+ toolChoiceJSON := []byte(`{"name":"","type":"tool"}`)
+ toolChoiceJSON, _ = sjson.SetBytes(toolChoiceJSON, "name", fn)
+ out, _ = sjson.SetRawBytes(out, "tool_choice", toolChoiceJSON)
+ }
}
default:
@@ -391,3 +397,167 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte
return out
}
+
+func convertResponsesToolToClaudeTools(tool gjson.Result, toolNameMap map[string]string) [][]byte {
+ toolType := strings.TrimSpace(tool.Get("type").String())
+ switch toolType {
+ case "", "function":
+ if tJSON, ok := convertResponsesFunctionToolToClaude(tool, ""); ok {
+ return [][]byte{tJSON}
+ }
+ case "namespace":
+ return convertResponsesNamespaceToolToClaude(tool, toolNameMap)
+ case "web_search":
+ if tJSON, ok := convertResponsesWebSearchToolToClaude(tool); ok {
+ if name := gjson.GetBytes(tJSON, "name").String(); name != "" {
+ toolNameMap[name] = name
+ }
+ return [][]byte{tJSON}
+ }
+ default:
+ if isUnsupportedOpenAIBuiltinToolType(toolType) {
+ return nil
+ }
+ if tool.Get("name").String() != "" {
+ return [][]byte{[]byte(tool.Raw)}
+ }
+ }
+ return nil
+}
+
+func convertResponsesNamespaceToolToClaude(tool gjson.Result, toolNameMap map[string]string) [][]byte {
+ namespaceName := strings.TrimSpace(tool.Get("name").String())
+ children := tool.Get("tools")
+ if !children.Exists() || !children.IsArray() {
+ return nil
+ }
+
+ var out [][]byte
+ children.ForEach(func(_, child gjson.Result) bool {
+ childName := responsesToolName(child)
+ qualifiedName := qualifyResponsesNamespaceToolName(namespaceName, childName)
+ if tJSON, ok := convertResponsesFunctionToolToClaude(child, qualifiedName); ok {
+ out = append(out, tJSON)
+ toolNameMap[qualifiedName] = qualifiedName
+ if childName != "" {
+ toolNameMap[childName] = qualifiedName
+ }
+ }
+ return true
+ })
+ return out
+}
+
+func convertResponsesFunctionToolToClaude(tool gjson.Result, overrideName string) ([]byte, bool) {
+ name := strings.TrimSpace(overrideName)
+ if name == "" {
+ name = responsesToolName(tool)
+ }
+ if name == "" {
+ return nil, false
+ }
+
+ tJSON := []byte(`{"name":"","description":"","input_schema":{}}`)
+ tJSON, _ = sjson.SetBytes(tJSON, "name", name)
+ if d := responsesToolDescription(tool); d != "" {
+ tJSON, _ = sjson.SetBytes(tJSON, "description", d)
+ }
+ tJSON, _ = sjson.SetRawBytes(tJSON, "input_schema", normalizeClaudeToolInputSchema(responsesToolParameters(tool)))
+ return tJSON, true
+}
+
+func convertResponsesWebSearchToolToClaude(tool gjson.Result) ([]byte, bool) {
+ if externalWebAccess := tool.Get("external_web_access"); externalWebAccess.Exists() && !externalWebAccess.Bool() {
+ return nil, false
+ }
+
+ name := strings.TrimSpace(tool.Get("name").String())
+ if name == "" {
+ name = "web_search"
+ }
+ tJSON := []byte(`{"type":"web_search_20250305","name":""}`)
+ tJSON, _ = sjson.SetBytes(tJSON, "name", name)
+ if maxUses := tool.Get("max_uses"); maxUses.Exists() {
+ tJSON, _ = sjson.SetBytes(tJSON, "max_uses", maxUses.Int())
+ }
+ if allowedDomains := tool.Get("filters.allowed_domains"); allowedDomains.Exists() && allowedDomains.IsArray() {
+ tJSON, _ = sjson.SetRawBytes(tJSON, "allowed_domains", []byte(allowedDomains.Raw))
+ }
+ if userLocation := tool.Get("user_location"); userLocation.Exists() && userLocation.IsObject() {
+ tJSON, _ = sjson.SetRawBytes(tJSON, "user_location", []byte(userLocation.Raw))
+ }
+ return tJSON, true
+}
+
+func responsesToolName(tool gjson.Result) string {
+ if name := strings.TrimSpace(tool.Get("name").String()); name != "" {
+ return name
+ }
+ return strings.TrimSpace(tool.Get("function.name").String())
+}
+
+func responsesToolDescription(tool gjson.Result) string {
+ if description := tool.Get("description").String(); description != "" {
+ return description
+ }
+ return tool.Get("function.description").String()
+}
+
+func responsesToolParameters(tool gjson.Result) gjson.Result {
+ for _, path := range []string{
+ "parameters",
+ "parametersJsonSchema",
+ "input_schema",
+ "function.parameters",
+ "function.parametersJsonSchema",
+ } {
+ if parameters := tool.Get(path); parameters.Exists() {
+ return parameters
+ }
+ }
+ return gjson.Result{}
+}
+
+func normalizeClaudeToolInputSchema(parameters gjson.Result) []byte {
+ raw := strings.TrimSpace(parameters.Raw)
+ if raw == "" || raw == "null" || !gjson.Valid(raw) {
+ return []byte(`{"type":"object","properties":{}}`)
+ }
+ result := gjson.Parse(raw)
+ if !result.IsObject() {
+ return []byte(`{"type":"object","properties":{}}`)
+ }
+ schema := []byte(raw)
+ schemaType := result.Get("type").String()
+ if schemaType == "" {
+ schema, _ = sjson.SetBytes(schema, "type", "object")
+ schemaType = "object"
+ }
+ if schemaType == "object" && !result.Get("properties").Exists() {
+ schema, _ = sjson.SetRawBytes(schema, "properties", []byte(`{}`))
+ }
+ return schema
+}
+
+func qualifyResponsesNamespaceToolName(namespaceName, childName string) string {
+ childName = strings.TrimSpace(childName)
+ if childName == "" || namespaceName == "" || strings.HasPrefix(childName, "mcp__") {
+ return childName
+ }
+ if strings.HasPrefix(childName, namespaceName) {
+ return childName
+ }
+ if strings.HasSuffix(namespaceName, "__") {
+ return namespaceName + childName
+ }
+ return namespaceName + "__" + childName
+}
+
+func isUnsupportedOpenAIBuiltinToolType(toolType string) bool {
+ switch toolType {
+ case "image_generation", "file_search", "code_interpreter", "computer_use_preview":
+ return true
+ default:
+ return false
+ }
+}
diff --git a/internal/translator/claude/openai/responses/claude_openai-responses_response.go b/internal/translator/claude/openai/responses/claude_openai-responses_response.go
index ef2cc1f845..6c6b96b30d 100644
--- a/internal/translator/claude/openai/responses/claude_openai-responses_response.go
+++ b/internal/translator/claude/openai/responses/claude_openai-responses_response.go
@@ -8,7 +8,7 @@ import (
"strings"
"time"
- translatorcommon "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/common"
+ translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
@@ -26,7 +26,8 @@ type claudeToResponsesState struct {
FuncNames map[int]string // index -> function name
FuncCallIDs map[int]string // index -> call id
// message text aggregation
- TextBuf strings.Builder
+ TextBuf strings.Builder
+ CurrentTextBuf strings.Builder
// reasoning state
ReasoningActive bool
ReasoningItemID string
@@ -80,6 +81,7 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin
st.CreatedAt = time.Now().Unix()
// Reset per-message aggregation state
st.TextBuf.Reset()
+ st.CurrentTextBuf.Reset()
st.ReasoningBuf.Reset()
st.ReasoningActive = false
st.InTextBlock = false
@@ -128,6 +130,7 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin
if typ == "text" {
// open message item + content part
st.InTextBlock = true
+ st.CurrentTextBuf.Reset()
st.CurrentMsgID = fmt.Sprintf("msg_%s_0", st.ResponseID)
item := []byte(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"message","status":"in_progress","content":[],"role":"assistant"}}`)
item, _ = sjson.SetBytes(item, "sequence_number", nextSeq())
@@ -189,6 +192,7 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin
out = append(out, emitEvent("response.output_text.delta", msg))
// aggregate text for response.output
st.TextBuf.WriteString(t.String())
+ st.CurrentTextBuf.WriteString(t.String())
}
} else if dt == "input_json_delta" {
idx := int(root.Get("index").Int())
@@ -220,17 +224,21 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin
case "content_block_stop":
idx := int(root.Get("index").Int())
if st.InTextBlock {
+ fullText := st.CurrentTextBuf.String()
done := []byte(`{"type":"response.output_text.done","sequence_number":0,"item_id":"","output_index":0,"content_index":0,"text":"","logprobs":[]}`)
done, _ = sjson.SetBytes(done, "sequence_number", nextSeq())
done, _ = sjson.SetBytes(done, "item_id", st.CurrentMsgID)
+ done, _ = sjson.SetBytes(done, "text", fullText)
out = append(out, emitEvent("response.output_text.done", done))
partDone := []byte(`{"type":"response.content_part.done","sequence_number":0,"item_id":"","output_index":0,"content_index":0,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":""}}`)
partDone, _ = sjson.SetBytes(partDone, "sequence_number", nextSeq())
partDone, _ = sjson.SetBytes(partDone, "item_id", st.CurrentMsgID)
+ partDone, _ = sjson.SetBytes(partDone, "part.text", fullText)
out = append(out, emitEvent("response.content_part.done", partDone))
final := []byte(`{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{"id":"","type":"message","status":"completed","content":[{"type":"output_text","text":""}],"role":"assistant"}}`)
final, _ = sjson.SetBytes(final, "sequence_number", nextSeq())
final, _ = sjson.SetBytes(final, "item.id", st.CurrentMsgID)
+ final, _ = sjson.SetBytes(final, "item.content.0.text", fullText)
out = append(out, emitEvent("response.output_item.done", final))
st.InTextBlock = false
} else if st.InFuncBlock {
diff --git a/internal/translator/claude/openai/responses/init.go b/internal/translator/claude/openai/responses/init.go
index 595fecc6ef..575c9ec71a 100644
--- a/internal/translator/claude/openai/responses/init.go
+++ b/internal/translator/claude/openai/responses/init.go
@@ -1,9 +1,9 @@
package responses
import (
- . "github.com/router-for-me/CLIProxyAPI/v6/internal/constant"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator"
+ . "github.com/router-for-me/CLIProxyAPI/v7/internal/constant"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/translator"
)
func init() {
diff --git a/internal/translator/codex/claude/codex_claude_request.go b/internal/translator/codex/claude/codex_claude_request.go
index adff9a038d..029db14e7d 100644
--- a/internal/translator/codex/claude/codex_claude_request.go
+++ b/internal/translator/codex/claude/codex_claude_request.go
@@ -6,11 +6,12 @@
package claude
import (
+ "encoding/base64"
"fmt"
"strconv"
"strings"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
@@ -39,6 +40,7 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool)
template := []byte(`{"model":"","instructions":"","input":[]}`)
rootResult := gjson.ParseBytes(rawJSON)
+ toolNameMap := buildReverseMapFromClaudeOriginalToShort(rawJSON)
template, _ = sjson.SetBytes(template, "model", modelName)
// Process system messages and convert them to input content format.
@@ -120,6 +122,22 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool)
hasContent = true
}
+ appendReasoningContent := func(part gjson.Result) {
+ if messageRole != "assistant" {
+ return
+ }
+
+ signature := part.Get("signature").String()
+ if !isFernetLikeReasoningSignature(signature) {
+ return
+ }
+
+ flushMessage()
+ reasoningItem := []byte(`{"type":"reasoning","summary":[],"content":null}`)
+ reasoningItem, _ = sjson.SetBytes(reasoningItem, "encrypted_content", signature)
+ template, _ = sjson.SetRawBytes(template, "input.-1", reasoningItem)
+ }
+
messageContentsResult := messageResult.Get("content")
if messageContentsResult.IsArray() {
messageContentResults := messageContentsResult.Array()
@@ -130,6 +148,8 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool)
switch contentType {
case "text":
appendTextContent(messageContentResult.Get("text").String())
+ case "thinking":
+ appendReasoningContent(messageContentResult)
case "image":
sourceResult := messageContentResult.Get("source")
if sourceResult.Exists() {
@@ -155,8 +175,7 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool)
functionCallMessage, _ = sjson.SetBytes(functionCallMessage, "call_id", messageContentResult.Get("id").String())
{
name := messageContentResult.Get("name").String()
- toolMap := buildReverseMapFromClaudeOriginalToShort(rawJSON)
- if short, ok := toolMap[name]; ok {
+ if short, ok := toolNameMap[name]; ok {
name = short
} else {
name = shortenNameIfNeeded(name)
@@ -230,23 +249,14 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool)
toolsResult := rootResult.Get("tools")
if toolsResult.IsArray() {
template, _ = sjson.SetRawBytes(template, "tools", []byte(`[]`))
- template, _ = sjson.SetBytes(template, "tool_choice", `auto`)
+ webSearchToolNames := buildClaudeWebSearchToolNameSet(toolsResult)
+ template, _ = sjson.SetRawBytes(template, "tool_choice", convertClaudeToolChoiceToCodex(rootResult.Get("tool_choice"), toolNameMap, webSearchToolNames))
toolResults := toolsResult.Array()
- // Build short name map from declared tools
- var names []string
- for i := 0; i < len(toolResults); i++ {
- n := toolResults[i].Get("name").String()
- if n != "" {
- names = append(names, n)
- }
- }
- shortMap := buildShortNameMap(names)
for i := 0; i < len(toolResults); i++ {
toolResult := toolResults[i]
// Special handling: map Claude web search tool to Codex web_search
- if toolResult.Get("type").String() == "web_search_20250305" {
- // Replace the tool content entirely with {"type":"web_search"}
- template, _ = sjson.SetRawBytes(template, "tools.-1", []byte(`{"type":"web_search"}`))
+ if isClaudeWebSearchToolType(toolResult.Get("type").String()) {
+ template, _ = sjson.SetRawBytes(template, "tools.-1", convertClaudeWebSearchToolToCodex(toolResult))
continue
}
tool := []byte(toolResult.Raw)
@@ -254,7 +264,7 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool)
// Apply shortened name if needed
if v := toolResult.Get("name"); v.Exists() {
name := v.String()
- if short, ok := shortMap[name]; ok {
+ if short, ok := toolNameMap[name]; ok {
name = short
} else {
name = shortenNameIfNeeded(name)
@@ -318,6 +328,114 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool)
return template
}
+// isFernetLikeReasoningSignature checks only the encrypted_content envelope shape
+// observed in OpenAI reasoning signatures. It does not authenticate source or payload type.
+func isFernetLikeReasoningSignature(signature string) bool {
+ const (
+ fernetVersionLen = 1
+ fernetTimestamp = 8
+ fernetIV = 16
+ fernetHMAC = 32
+ aesBlockSize = 16
+ )
+
+ signature = strings.TrimSpace(signature)
+ if !strings.HasPrefix(signature, "gAAAA") {
+ return false
+ }
+
+ decoded, err := base64.URLEncoding.DecodeString(signature)
+ if err != nil {
+ decoded, err = base64.RawURLEncoding.DecodeString(signature)
+ if err != nil {
+ return false
+ }
+ }
+
+ minLen := fernetVersionLen + fernetTimestamp + fernetIV + aesBlockSize + fernetHMAC
+ if len(decoded) < minLen || decoded[0] != 0x80 {
+ return false
+ }
+
+ ciphertextLen := len(decoded) - fernetVersionLen - fernetTimestamp - fernetIV - fernetHMAC
+ return ciphertextLen > 0 && ciphertextLen%aesBlockSize == 0
+}
+
+func isClaudeWebSearchToolType(toolType string) bool {
+ return toolType == "web_search_20250305" || toolType == "web_search_20260209"
+}
+
+func buildClaudeWebSearchToolNameSet(tools gjson.Result) map[string]struct{} {
+ names := map[string]struct{}{}
+ if !tools.IsArray() {
+ return names
+ }
+
+ tools.ForEach(func(_, tool gjson.Result) bool {
+ toolType := tool.Get("type").String()
+ if !isClaudeWebSearchToolType(toolType) {
+ return true
+ }
+
+ if name := tool.Get("name").String(); name != "" {
+ names[name] = struct{}{}
+ }
+ return true
+ })
+
+ return names
+}
+
+func convertClaudeToolChoiceToCodex(toolChoice gjson.Result, toolNameMap map[string]string, webSearchToolNames map[string]struct{}) []byte {
+ if !toolChoice.Exists() || toolChoice.Type == gjson.Null {
+ return []byte(`"auto"`)
+ }
+
+ choiceType := toolChoice.Get("type").String()
+ if choiceType == "" && toolChoice.Type == gjson.String {
+ choiceType = toolChoice.String()
+ }
+
+ switch choiceType {
+ case "auto", "":
+ return []byte(`"auto"`)
+ case "any":
+ return []byte(`"required"`)
+ case "none":
+ return []byte(`"none"`)
+ case "tool":
+ name := toolChoice.Get("name").String()
+ if _, ok := webSearchToolNames[name]; ok {
+ return []byte(`{"type":"web_search"}`)
+ }
+ if short, ok := toolNameMap[name]; ok {
+ name = short
+ } else {
+ name = shortenNameIfNeeded(name)
+ }
+ if name == "" {
+ return []byte(`"auto"`)
+ }
+
+ choice := []byte(`{"type":"function","name":""}`)
+ choice, _ = sjson.SetBytes(choice, "name", name)
+ return choice
+ default:
+ return []byte(`"auto"`)
+ }
+}
+
+func convertClaudeWebSearchToolToCodex(tool gjson.Result) []byte {
+ out := []byte(`{"type":"web_search"}`)
+ if allowedDomains := tool.Get("allowed_domains"); allowedDomains.Exists() && allowedDomains.IsArray() {
+ out, _ = sjson.SetRawBytes(out, "filters.allowed_domains", []byte(allowedDomains.Raw))
+ }
+ if userLocation := tool.Get("user_location"); userLocation.Exists() && userLocation.IsObject() {
+ out, _ = sjson.SetRawBytes(out, "user_location", []byte(userLocation.Raw))
+ }
+ return out
+}
+
// shortenNameIfNeeded applies a simple shortening rule for a single name.
func shortenNameIfNeeded(name string) string {
const limit = 64
diff --git a/internal/translator/codex/claude/codex_claude_request_test.go b/internal/translator/codex/claude/codex_claude_request_test.go
index 3cf0236962..16bb46c9ef 100644
--- a/internal/translator/codex/claude/codex_claude_request_test.go
+++ b/internal/translator/codex/claude/codex_claude_request_test.go
@@ -1,6 +1,8 @@
package claude
import (
+ "encoding/base64"
+ "strings"
"testing"
"github.com/tidwall/gjson"
@@ -133,3 +135,278 @@ func TestConvertClaudeRequestToCodex_ParallelToolCalls(t *testing.T) {
})
}
}
+
+func TestConvertClaudeRequestToCodex_ToolChoiceModeMapping(t *testing.T) {
+ tests := []struct {
+ name string
+ claudeToolChoice string
+ wantCodexToolChoice string
+ }{
+ {
+ name: "Any requires at least one tool",
+ claudeToolChoice: `{"type":"any"}`,
+ wantCodexToolChoice: "required",
+ },
+ {
+ name: "None disables tools",
+ claudeToolChoice: `{"type":"none"}`,
+ wantCodexToolChoice: "none",
+ },
+ {
+ name: "Auto stays auto",
+ claudeToolChoice: `{"type":"auto"}`,
+ wantCodexToolChoice: "auto",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ inputJSON := `{
+ "model": "claude-3-opus",
+ "tools": [
+ {"name": "lookup", "description": "Lookup", "input_schema": {"type":"object","properties":{}}}
+ ],
+ "tool_choice": ` + tt.claudeToolChoice + `,
+ "messages": [{"role": "user", "content": "hello"}]
+ }`
+
+ result := ConvertClaudeRequestToCodex("test-model", []byte(inputJSON), false)
+ resultJSON := gjson.ParseBytes(result)
+
+ if got := resultJSON.Get("tool_choice").String(); got != tt.wantCodexToolChoice {
+ t.Fatalf("tool_choice = %q, want %q. Output: %s", got, tt.wantCodexToolChoice, string(result))
+ }
+ })
+ }
+}
+
+func TestConvertClaudeRequestToCodex_ToolChoiceSpecificFunctionUsesConvertedName(t *testing.T) {
+ longName := "mcp__server_with_a_very_long_name_that_exceeds_sixty_four_characters__search"
+ inputJSON := `{
+ "model": "claude-3-opus",
+ "tools": [
+ {"name": "` + longName + `", "description": "Search", "input_schema": {"type":"object","properties":{}}}
+ ],
+ "tool_choice": {"type":"tool","name":"` + longName + `"},
+ "messages": [{"role": "user", "content": "hello"}]
+ }`
+
+ result := ConvertClaudeRequestToCodex("test-model", []byte(inputJSON), false)
+ resultJSON := gjson.ParseBytes(result)
+
+ if got := resultJSON.Get("tool_choice.type").String(); got != "function" {
+ t.Fatalf("tool_choice.type = %q, want function. Output: %s", got, string(result))
+ }
+ toolName := resultJSON.Get("tools.0.name").String()
+ choiceName := resultJSON.Get("tool_choice.name").String()
+ if choiceName != toolName {
+ t.Fatalf("tool_choice.name = %q, want converted tool name %q. Output: %s", choiceName, toolName, string(result))
+ }
+ if choiceName == longName {
+ t.Fatalf("tool_choice.name should use shortened Codex tool name. Output: %s", string(result))
+ }
+}
+
+func TestConvertClaudeRequestToCodex_WebSearchToolMapping(t *testing.T) {
+ inputJSON := `{
+ "model": "claude-3-opus",
+ "tools": [
+ {
+ "type": "web_search_20260209",
+ "name": "web_search",
+ "allowed_domains": ["example.com"],
+ "blocked_domains": ["blocked.example"],
+ "user_location": {
+ "type": "approximate",
+ "city": "Beijing",
+ "country": "CN",
+ "timezone": "Asia/Shanghai"
+ }
+ }
+ ],
+ "tool_choice": {"type":"tool","name":"web_search"},
+ "messages": [{"role": "user", "content": "hello"}]
+ }`
+
+ result := ConvertClaudeRequestToCodex("test-model", []byte(inputJSON), false)
+ resultJSON := gjson.ParseBytes(result)
+
+ if got := resultJSON.Get("tools.0.type").String(); got != "web_search" {
+ t.Fatalf("tools.0.type = %q, want web_search. Output: %s", got, string(result))
+ }
+ if got := resultJSON.Get("tools.0.filters.allowed_domains.0").String(); got != "example.com" {
+ t.Fatalf("tools.0.filters.allowed_domains.0 = %q, want example.com. Output: %s", got, string(result))
+ }
+ if resultJSON.Get("tools.0.blocked_domains").Exists() {
+ t.Fatalf("tools.0.blocked_domains should not be forwarded to Codex. Output: %s", string(result))
+ }
+ if got := resultJSON.Get("tools.0.user_location.city").String(); got != "Beijing" {
+ t.Fatalf("tools.0.user_location.city = %q, want Beijing. Output: %s", got, string(result))
+ }
+ if got := resultJSON.Get("tool_choice.type").String(); got != "web_search" {
+ t.Fatalf("tool_choice.type = %q, want web_search. Output: %s", got, string(result))
+ }
+}
+
+func TestConvertClaudeRequestToCodex_WebSearchToolChoiceUsesDeclaredTypedToolName(t *testing.T) {
+ inputJSON := `{
+ "model": "claude-opus-4-7",
+ "tools": [
+ {"type": "web_search_20250305", "name": "browser_search"},
+ {"name": "web_search", "description": "Local search", "input_schema": {"type":"object","properties":{}}}
+ ],
+ "tool_choice": {"type":"tool","name":"web_search"},
+ "messages": [{"role": "user", "content": "hello"}]
+ }`
+
+ result := ConvertClaudeRequestToCodex("test-model", []byte(inputJSON), false)
+ resultJSON := gjson.ParseBytes(result)
+
+ if got := resultJSON.Get("tool_choice.type").String(); got != "function" {
+ t.Fatalf("tool_choice.type = %q, want function. Output: %s", got, string(result))
+ }
+ if got := resultJSON.Get("tool_choice.name").String(); got != "web_search" {
+ t.Fatalf("tool_choice.name = %q, want web_search. Output: %s", got, string(result))
+ }
+}
+
+func TestConvertClaudeRequestToCodex_AssistantThinkingSignatureToReasoningItem(t *testing.T) {
+ signature := validCodexReasoningSignature()
+ inputJSON := `{
+ "model": "claude-3-opus",
+ "messages": [
+ {
+ "role": "assistant",
+ "content": [
+ {
+ "type": "thinking",
+ "thinking": "visible summary must not be replayed",
+ "signature": "` + signature + `"
+ },
+ {
+ "type": "text",
+ "text": "visible answer"
+ }
+ ]
+ },
+ {
+ "role": "user",
+ "content": "continue"
+ }
+ ]
+ }`
+
+ result := ConvertClaudeRequestToCodex("test-model", []byte(inputJSON), false)
+ resultJSON := gjson.ParseBytes(result)
+ inputs := resultJSON.Get("input").Array()
+ if len(inputs) != 3 {
+ t.Fatalf("got %d input items, want 3. Output: %s", len(inputs), string(result))
+ }
+
+ reasoning := inputs[0]
+ if got := reasoning.Get("type").String(); got != "reasoning" {
+ t.Fatalf("first input type = %q, want reasoning. Output: %s", got, string(result))
+ }
+ if got := reasoning.Get("encrypted_content").String(); got != signature {
+ t.Fatalf("encrypted_content = %q, want %q", got, signature)
+ }
+ if got := reasoning.Get("summary").Raw; got != "[]" {
+ t.Fatalf("summary = %s, want []", got)
+ }
+ if got := reasoning.Get("content").Raw; got != "null" {
+ t.Fatalf("content = %s, want null", got)
+ }
+
+ assistantMessage := inputs[1]
+ if got := assistantMessage.Get("role").String(); got != "assistant" {
+ t.Fatalf("second input role = %q, want assistant. Output: %s", got, string(result))
+ }
+ if got := assistantMessage.Get("content.0.type").String(); got != "output_text" {
+ t.Fatalf("assistant content type = %q, want output_text", got)
+ }
+ if got := assistantMessage.Get("content.0.text").String(); got != "visible answer" {
+ t.Fatalf("assistant text = %q, want visible answer", got)
+ }
+ if strings.Contains(string(result), "visible summary must not be replayed") {
+ t.Fatalf("thinking text should not be replayed into Codex input. Output: %s", string(result))
+ }
+}
+
+func TestConvertClaudeRequestToCodex_IgnoresNonCodexThinkingSignatures(t *testing.T) {
+ tests := []struct {
+ name string
+ inputJSON string
+ }{
+ {
+ name: "Ignore user thinking even with Codex-shaped signature",
+ inputJSON: `{
+ "model": "claude-3-opus",
+ "messages": [
+ {
+ "role": "user",
+ "content": [
+ {
+ "type": "thinking",
+ "thinking": "user supplied thinking",
+ "signature": "` + validCodexReasoningSignature() + `"
+ },
+ {
+ "type": "text",
+ "text": "hello"
+ }
+ ]
+ }
+ ]
+ }`,
+ },
+ {
+ name: "Ignore Anthropic native signature",
+ inputJSON: `{
+ "model": "claude-3-opus",
+ "messages": [
+ {
+ "role": "assistant",
+ "content": [
+ {
+ "type": "thinking",
+ "thinking": "anthropic thinking",
+ "signature": "Eo8Canthropic-state"
+ },
+ {
+ "type": "text",
+ "text": "visible answer"
+ }
+ ]
+ }
+ ]
+ }`,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := ConvertClaudeRequestToCodex("test-model", []byte(tt.inputJSON), false)
+ if got := countRequestInputItemsByType(result, "reasoning"); got != 0 {
+ t.Fatalf("got %d reasoning items, want 0. Output: %s", got, string(result))
+ }
+ })
+ }
+}
+
+func countRequestInputItemsByType(result []byte, itemType string) int {
+ count := 0
+ gjson.GetBytes(result, "input").ForEach(func(_, item gjson.Result) bool {
+ if item.Get("type").String() == itemType {
+ count++
+ }
+ return true
+ })
+ return count
+}
+
+func validCodexReasoningSignature() string {
+ raw := make([]byte, 1+8+16+16+32)
+ raw[0] = 0x80
+ raw[8] = 1
+ return base64.URLEncoding.EncodeToString(raw)
+}
diff --git a/internal/translator/codex/claude/codex_claude_response.go b/internal/translator/codex/claude/codex_claude_response.go
index 388b907ae9..7a40ca4c55 100644
--- a/internal/translator/codex/claude/codex_claude_response.go
+++ b/internal/translator/codex/claude/codex_claude_response.go
@@ -11,8 +11,8 @@ import (
"context"
"strings"
- translatorcommon "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/common"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
+ translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/util"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
@@ -31,6 +31,7 @@ type ConvertCodexResponseToClaudeParams struct {
ThinkingBlockOpen bool
ThinkingStopPending bool
ThinkingSignature string
+ ThinkingSummarySeen bool
}
// ConvertCodexResponseToClaude performs sophisticated streaming response format conversion.
@@ -67,7 +68,7 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa
params := (*param).(*ConvertCodexResponseToClaudeParams)
if params.ThinkingBlockOpen && params.ThinkingStopPending {
switch rootResult.Get("type").String() {
- case "response.content_part.added", "response.completed":
+ case "response.content_part.added", "response.completed", "response.incomplete":
output = append(output, finalizeCodexThinkingBlock(params)...)
}
}
@@ -86,12 +87,8 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa
if params.ThinkingBlockOpen && params.ThinkingStopPending {
output = append(output, finalizeCodexThinkingBlock(params)...)
}
- template = []byte(`{"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}`)
- template, _ = sjson.SetBytes(template, "index", params.BlockIndex)
- params.ThinkingBlockOpen = true
- params.ThinkingStopPending = false
-
- output = translatorcommon.AppendSSEEventBytes(output, "content_block_start", template, 2)
+ params.ThinkingSummarySeen = true
+ output = append(output, startCodexThinkingBlock(params)...)
} else if typeStr == "response.reasoning_summary_text.delta" {
template = []byte(`{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":""}}`)
template, _ = sjson.SetBytes(template, "index", params.BlockIndex)
@@ -100,9 +97,6 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa
output = translatorcommon.AppendSSEEventBytes(output, "content_block_delta", template, 2)
} else if typeStr == "response.reasoning_summary_part.done" {
params.ThinkingStopPending = true
- if params.ThinkingSignature != "" {
- output = append(output, finalizeCodexThinkingBlock(params)...)
- }
} else if typeStr == "response.content_part.added" {
template = []byte(`{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`)
template, _ = sjson.SetBytes(template, "index", params.BlockIndex)
@@ -123,18 +117,12 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa
params.BlockIndex++
output = translatorcommon.AppendSSEEventBytes(output, "content_block_stop", template, 2)
- } else if typeStr == "response.completed" {
+ } else if typeStr == "response.completed" || typeStr == "response.incomplete" {
template = []byte(`{"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null},"usage":{"input_tokens":0,"output_tokens":0}}`)
- p := params.HasToolCall
- stopReason := rootResult.Get("response.stop_reason").String()
- if p {
- template, _ = sjson.SetBytes(template, "delta.stop_reason", "tool_use")
- } else if stopReason == "max_tokens" || stopReason == "stop" {
- template, _ = sjson.SetBytes(template, "delta.stop_reason", stopReason)
- } else {
- template, _ = sjson.SetBytes(template, "delta.stop_reason", "end_turn")
- }
- inputTokens, outputTokens, cachedTokens := extractResponsesUsage(rootResult.Get("response.usage"))
+ responseData := rootResult.Get("response")
+ template, _ = sjson.SetBytes(template, "delta.stop_reason", mapCodexStopReasonToClaude(codexStopReason(responseData), params.HasToolCall))
+ template = setClaudeStopSequence(template, "delta.stop_sequence", responseData)
+ inputTokens, outputTokens, cachedTokens := extractResponsesUsage(responseData.Get("usage"))
template, _ = sjson.SetBytes(template, "usage.input_tokens", inputTokens)
template, _ = sjson.SetBytes(template, "usage.output_tokens", outputTokens)
if cachedTokens > 0 {
@@ -169,10 +157,8 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa
output = translatorcommon.AppendSSEEventBytes(output, "content_block_delta", template, 2)
} else if itemType == "reasoning" {
+ params.ThinkingSummarySeen = false
params.ThinkingSignature = itemResult.Get("encrypted_content").String()
- if params.ThinkingStopPending {
- output = append(output, finalizeCodexThinkingBlock(params)...)
- }
}
} else if typeStr == "response.output_item.done" {
itemResult := rootResult.Get("item")
@@ -229,8 +215,13 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa
if signature := itemResult.Get("encrypted_content").String(); signature != "" {
params.ThinkingSignature = signature
}
- output = append(output, finalizeCodexThinkingBlock(params)...)
+ if params.ThinkingSummarySeen {
+ output = append(output, finalizeCodexThinkingBlock(params)...)
+ } else {
+ output = append(output, finalizeCodexSignatureOnlyThinkingBlock(params)...)
+ }
params.ThinkingSignature = ""
+ params.ThinkingSummarySeen = false
}
} else if typeStr == "response.function_call_arguments.delta" {
params.HasReceivedArgumentsDelta = true
@@ -262,7 +253,8 @@ func ConvertCodexResponseToClaudeNonStream(_ context.Context, _ string, original
revNames := buildReverseMapFromClaudeOriginalShortToOriginal(originalRequestRawJSON)
rootResult := gjson.ParseBytes(rawJSON)
- if rootResult.Get("type").String() != "response.completed" {
+ typeStr := rootResult.Get("type").String()
+ if typeStr != "response.completed" && typeStr != "response.incomplete" {
return []byte{}
}
@@ -374,18 +366,57 @@ func ConvertCodexResponseToClaudeNonStream(_ context.Context, _ string, original
})
}
+ out, _ = sjson.SetBytes(out, "stop_reason", mapCodexStopReasonToClaude(codexStopReason(responseData), hasToolCall))
+ out = setClaudeStopSequence(out, "stop_sequence", responseData)
+
+ return out
+}
+
+func codexStopReason(responseData gjson.Result) string {
if stopReason := responseData.Get("stop_reason"); stopReason.Exists() && stopReason.String() != "" {
- out, _ = sjson.SetBytes(out, "stop_reason", stopReason.String())
- } else if hasToolCall {
- out, _ = sjson.SetBytes(out, "stop_reason", "tool_use")
- } else {
- out, _ = sjson.SetBytes(out, "stop_reason", "end_turn")
+ if stopReason.String() == "stop" && codexStopSequence(responseData).String() != "" {
+ return "stop_sequence"
+ }
+ return stopReason.String()
+ }
+ if reason := responseData.Get("incomplete_details.reason"); reason.Exists() && reason.String() != "" {
+ return reason.String()
}
+ if codexStopSequence(responseData).String() != "" {
+ return "stop_sequence"
+ }
+ return ""
+}
- if stopSequence := responseData.Get("stop_sequence"); stopSequence.Exists() && stopSequence.String() != "" {
- out, _ = sjson.SetRawBytes(out, "stop_sequence", []byte(stopSequence.Raw))
+func mapCodexStopReasonToClaude(stopReason string, hasToolCall bool) string {
+ if hasToolCall {
+ return "tool_use"
}
+ switch stopReason {
+ case "", "stop", "completed":
+ return "end_turn"
+ case "max_tokens", "max_output_tokens":
+ return "max_tokens"
+ case "tool_use", "tool_calls", "function_call":
+ return "tool_use"
+ case "end_turn", "stop_sequence", "pause_turn", "refusal", "model_context_window_exceeded":
+ return stopReason
+ case "content_filter":
+ return "refusal"
+ default:
+ return "end_turn"
+ }
+}
+
+func codexStopSequence(responseData gjson.Result) gjson.Result {
+ return responseData.Get("stop_sequence")
+}
+
+func setClaudeStopSequence(out []byte, path string, responseData gjson.Result) []byte {
+ if stopSequence := codexStopSequence(responseData); stopSequence.Exists() && stopSequence.String() != "" {
+ out, _ = sjson.SetRawBytes(out, path, []byte(stopSequence.Raw))
+ }
return out
}
@@ -437,6 +468,29 @@ func ClaudeTokenCount(_ context.Context, count int64) []byte {
return translatorcommon.ClaudeInputTokensJSON(count)
}
+func startCodexThinkingBlock(params *ConvertCodexResponseToClaudeParams) []byte {
+ if params.ThinkingBlockOpen {
+ return nil
+ }
+
+ template := []byte(`{"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}`)
+ template, _ = sjson.SetBytes(template, "index", params.BlockIndex)
+ params.ThinkingBlockOpen = true
+ params.ThinkingStopPending = false
+
+ return translatorcommon.AppendSSEEventBytes(nil, "content_block_start", template, 2)
+}
+
+func finalizeCodexSignatureOnlyThinkingBlock(params *ConvertCodexResponseToClaudeParams) []byte {
+ if params.ThinkingSignature == "" {
+ return nil
+ }
+
+ output := startCodexThinkingBlock(params)
+ output = append(output, finalizeCodexThinkingBlock(params)...)
+ return output
+}
+
func finalizeCodexThinkingBlock(params *ConvertCodexResponseToClaudeParams) []byte {
if !params.ThinkingBlockOpen {
return nil
diff --git a/internal/translator/codex/claude/codex_claude_response_test.go b/internal/translator/codex/claude/codex_claude_response_test.go
index c36c9edb68..565e8156bb 100644
--- a/internal/translator/codex/claude/codex_claude_response_test.go
+++ b/internal/translator/codex/claude/codex_claude_response_test.go
@@ -243,6 +243,147 @@ func TestConvertCodexResponseToClaude_StreamThinkingUsesEarlyCapturedSignatureWh
}
}
+func TestConvertCodexResponseToClaude_StreamThinkingUsesFinalDoneSignature(t *testing.T) {
+ ctx := context.Background()
+ originalRequest := []byte(`{"messages":[]}`)
+ var param any
+
+ chunks := [][]byte{
+ []byte("data: {\"type\":\"response.output_item.added\",\"item\":{\"type\":\"reasoning\",\"encrypted_content\":\"enc_sig_initial\"}}"),
+ []byte("data: {\"type\":\"response.reasoning_summary_part.added\"}"),
+ []byte("data: {\"type\":\"response.reasoning_summary_text.delta\",\"delta\":\"Let me think\"}"),
+ []byte("data: {\"type\":\"response.reasoning_summary_part.done\"}"),
+ []byte("data: {\"type\":\"response.output_item.done\",\"item\":{\"type\":\"reasoning\",\"encrypted_content\":\"enc_sig_final\"}}"),
+ }
+
+ var outputs [][]byte
+ for _, chunk := range chunks {
+ outputs = append(outputs, ConvertCodexResponseToClaude(ctx, "", originalRequest, nil, chunk, ¶m)...)
+ }
+
+ signatureDeltaCount := 0
+ events := []string{}
+ for _, out := range outputs {
+ for _, line := range strings.Split(string(out), "\n") {
+ if !strings.HasPrefix(line, "data: ") {
+ continue
+ }
+ data := gjson.Parse(strings.TrimPrefix(line, "data: "))
+ if data.Get("type").String() == "content_block_start" && data.Get("content_block.type").String() == "thinking" {
+ events = append(events, "thinking_start")
+ }
+ if data.Get("type").String() == "content_block_delta" && data.Get("delta.type").String() == "thinking_delta" {
+ events = append(events, "thinking_delta")
+ }
+ if data.Get("type").String() == "content_block_stop" && data.Get("index").Int() == 0 {
+ events = append(events, "thinking_stop")
+ }
+ if data.Get("type").String() != "content_block_delta" || data.Get("delta.type").String() != "signature_delta" {
+ continue
+ }
+ events = append(events, "signature_delta")
+ signatureDeltaCount++
+ if got := data.Get("delta.signature").String(); got != "enc_sig_final" {
+ t.Fatalf("signature delta = %q, want final done signature", got)
+ }
+ }
+ }
+
+ if signatureDeltaCount != 1 {
+ t.Fatalf("expected one signature_delta, got %d", signatureDeltaCount)
+ }
+ if got, want := strings.Join(events, ","), "thinking_start,thinking_delta,signature_delta,thinking_stop"; got != want {
+ t.Fatalf("thinking event order = %s, want %s", got, want)
+ }
+}
+
+func TestConvertCodexResponseToClaude_StreamSignatureOnlyReasoningEmitsThinkingSignature(t *testing.T) {
+ ctx := context.Background()
+ originalRequest := []byte(`{"messages":[]}`)
+ var param any
+
+ chunks := [][]byte{
+ []byte("data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_123\",\"model\":\"gpt-5\"}}"),
+ []byte("data: {\"type\":\"response.output_item.added\",\"item\":{\"type\":\"reasoning\",\"encrypted_content\":\"enc_sig_initial\"}}"),
+ []byte("data: {\"type\":\"response.output_item.done\",\"item\":{\"type\":\"reasoning\",\"encrypted_content\":\"enc_sig_only\"}}"),
+ []byte("data: {\"type\":\"response.content_part.added\"}"),
+ []byte("data: {\"type\":\"response.output_text.delta\",\"delta\":\"ok\"}"),
+ }
+
+ var outputs [][]byte
+ for _, chunk := range chunks {
+ outputs = append(outputs, ConvertCodexResponseToClaude(ctx, "", originalRequest, nil, chunk, ¶m)...)
+ }
+
+ thinkingStartFound := false
+ thinkingDeltaFound := false
+ signatureDeltaFound := false
+ thinkingStopFound := false
+ textStartIndex := int64(-1)
+ events := []string{}
+
+ for _, out := range outputs {
+ for _, line := range strings.Split(string(out), "\n") {
+ if !strings.HasPrefix(line, "data: ") {
+ continue
+ }
+ data := gjson.Parse(strings.TrimPrefix(line, "data: "))
+ switch data.Get("type").String() {
+ case "content_block_start":
+ if data.Get("content_block.type").String() == "thinking" {
+ events = append(events, "thinking_start")
+ thinkingStartFound = true
+ if got := data.Get("index").Int(); got != 0 {
+ t.Fatalf("thinking block index = %d, want 0", got)
+ }
+ }
+ if data.Get("content_block.type").String() == "text" {
+ events = append(events, "text_start")
+ textStartIndex = data.Get("index").Int()
+ }
+ case "content_block_delta":
+ switch data.Get("delta.type").String() {
+ case "thinking_delta":
+ thinkingDeltaFound = true
+ case "signature_delta":
+ events = append(events, "signature_delta")
+ signatureDeltaFound = true
+ if got := data.Get("index").Int(); got != 0 {
+ t.Fatalf("signature delta index = %d, want 0", got)
+ }
+ if got := data.Get("delta.signature").String(); got != "enc_sig_only" {
+ t.Fatalf("unexpected signature delta: %q", got)
+ }
+ }
+ case "content_block_stop":
+ if data.Get("index").Int() == 0 {
+ events = append(events, "thinking_stop")
+ thinkingStopFound = true
+ }
+ }
+ }
+ }
+
+ if !thinkingStartFound {
+ t.Fatal("expected signature-only reasoning to start a thinking block")
+ }
+ if thinkingDeltaFound {
+ t.Fatal("did not expect thinking_delta when upstream omitted summary text")
+ }
+ if !signatureDeltaFound {
+ t.Fatal("expected signature_delta from encrypted_content-only reasoning")
+ }
+ if !thinkingStopFound {
+ t.Fatal("expected signature-only thinking block to stop")
+ }
+ if textStartIndex != 1 {
+ t.Fatalf("text block index = %d, want 1 after signature-only thinking block", textStartIndex)
+ }
+ if got, want := strings.Join(events, ","), "thinking_start,signature_delta,thinking_stop,text_start"; got != want {
+ t.Fatalf("signature-only event order = %s, want %s", got, want)
+ }
+}
+
func TestConvertCodexResponseToClaudeNonStream_ThinkingIncludesSignature(t *testing.T) {
ctx := context.Background()
originalRequest := []byte(`{"messages":[]}`)
@@ -317,3 +458,207 @@ func TestConvertCodexResponseToClaude_StreamEmptyOutputUsesOutputItemDoneMessage
t.Fatalf("expected fallback content from response.output_item.done message; outputs=%q", outputs)
}
}
+
+func TestConvertCodexResponseToClaude_StreamStopReasonMapping(t *testing.T) {
+ tests := []struct {
+ name string
+ chunks [][]byte
+ wantReason string
+ }{
+ {
+ name: "Stop maps to end_turn",
+ chunks: [][]byte{
+ []byte("data: {\"type\":\"response.completed\",\"response\":{\"stop_reason\":\"stop\",\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}"),
+ },
+ wantReason: "end_turn",
+ },
+ {
+ name: "Incomplete max output maps to max_tokens",
+ chunks: [][]byte{
+ []byte("data: {\"type\":\"response.incomplete\",\"response\":{\"incomplete_details\":{\"reason\":\"max_output_tokens\"},\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}"),
+ },
+ wantReason: "max_tokens",
+ },
+ {
+ name: "Tool call wins over stop",
+ chunks: [][]byte{
+ []byte("data: {\"type\":\"response.output_item.added\",\"item\":{\"type\":\"function_call\",\"call_id\":\"call_1\",\"name\":\"lookup\"}}"),
+ []byte("data: {\"type\":\"response.completed\",\"response\":{\"stop_reason\":\"stop\",\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}"),
+ },
+ wantReason: "tool_use",
+ },
+ {
+ name: "Content filter maps to Claude refusal",
+ chunks: [][]byte{
+ []byte("data: {\"type\":\"response.incomplete\",\"response\":{\"incomplete_details\":{\"reason\":\"content_filter\"},\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}"),
+ },
+ wantReason: "refusal",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ ctx := context.Background()
+ originalRequest := []byte(`{"tools":[{"name":"lookup","input_schema":{"type":"object","properties":{}}}]}`)
+ var param any
+ var outputs [][]byte
+
+ for _, chunk := range tt.chunks {
+ outputs = append(outputs, ConvertCodexResponseToClaude(ctx, "", originalRequest, nil, chunk, ¶m)...)
+ }
+
+ got, ok := findClaudeStreamStopReason(outputs)
+ if !ok {
+ t.Fatalf("did not find message_delta stop_reason; outputs=%q", outputs)
+ }
+ if got != tt.wantReason {
+ t.Fatalf("stop_reason = %q, want %q. Outputs=%q", got, tt.wantReason, outputs)
+ }
+ })
+ }
+}
+
+func TestConvertCodexResponseToClaude_StreamStopSequenceMapping(t *testing.T) {
+ ctx := context.Background()
+ originalRequest := []byte(`{"messages":[]}`)
+ var param any
+
+ outputs := ConvertCodexResponseToClaude(ctx, "", originalRequest, nil, []byte("data: {\"type\":\"response.completed\",\"response\":{\"stop_reason\":\"stop\",\"stop_sequence\":\"\\nEND\",\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}"), ¶m)
+ messageDelta, ok := findClaudeStreamMessageDelta(outputs)
+ if !ok {
+ t.Fatalf("did not find message_delta; outputs=%q", outputs)
+ }
+ if got := messageDelta.Get("delta.stop_reason").String(); got != "stop_sequence" {
+ t.Fatalf("stop_reason = %q, want stop_sequence. Outputs=%q", got, outputs)
+ }
+ if got := messageDelta.Get("delta.stop_sequence").String(); got != "\nEND" {
+ t.Fatalf("stop_sequence = %q, want newline END. Outputs=%q", got, outputs)
+ }
+}
+
+func TestConvertCodexResponseToClaudeNonStream_StopReasonMapping(t *testing.T) {
+ tests := []struct {
+ name string
+ response []byte
+ wantReason string
+ }{
+ {
+ name: "Stop maps to end_turn",
+ response: []byte(`{
+ "type":"response.completed",
+ "response":{
+ "id":"resp_1",
+ "model":"gpt-5",
+ "stop_reason":"stop",
+ "usage":{"input_tokens":1,"output_tokens":1},
+ "output":[]
+ }
+ }`),
+ wantReason: "end_turn",
+ },
+ {
+ name: "Incomplete max output maps to max_tokens",
+ response: []byte(`{
+ "type":"response.incomplete",
+ "response":{
+ "id":"resp_1",
+ "model":"gpt-5",
+ "incomplete_details":{"reason":"max_output_tokens"},
+ "usage":{"input_tokens":1,"output_tokens":1},
+ "output":[]
+ }
+ }`),
+ wantReason: "max_tokens",
+ },
+ {
+ name: "Tool call wins over stop",
+ response: []byte(`{
+ "type":"response.completed",
+ "response":{
+ "id":"resp_1",
+ "model":"gpt-5",
+ "stop_reason":"stop",
+ "usage":{"input_tokens":1,"output_tokens":1},
+ "output":[{"type":"function_call","call_id":"call_1","name":"lookup","arguments":"{}"}]
+ }
+ }`),
+ wantReason: "tool_use",
+ },
+ {
+ name: "Content filter maps to Claude refusal",
+ response: []byte(`{
+ "type":"response.incomplete",
+ "response":{
+ "id":"resp_1",
+ "model":"gpt-5",
+ "incomplete_details":{"reason":"content_filter"},
+ "usage":{"input_tokens":1,"output_tokens":1},
+ "output":[]
+ }
+ }`),
+ wantReason: "refusal",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ ctx := context.Background()
+ originalRequest := []byte(`{"tools":[{"name":"lookup","input_schema":{"type":"object","properties":{}}}]}`)
+ out := ConvertCodexResponseToClaudeNonStream(ctx, "", originalRequest, nil, tt.response, nil)
+ parsed := gjson.ParseBytes(out)
+
+ if got := parsed.Get("stop_reason").String(); got != tt.wantReason {
+ t.Fatalf("stop_reason = %q, want %q. Output: %s", got, tt.wantReason, string(out))
+ }
+ })
+ }
+}
+
+func TestConvertCodexResponseToClaudeNonStream_StopSequenceMapping(t *testing.T) {
+ ctx := context.Background()
+ originalRequest := []byte(`{"messages":[]}`)
+ response := []byte(`{
+ "type":"response.completed",
+ "response":{
+ "id":"resp_1",
+ "model":"gpt-5",
+ "stop_reason":"stop",
+ "stop_sequence":"\nEND",
+ "usage":{"input_tokens":1,"output_tokens":1},
+ "output":[]
+ }
+ }`)
+
+ out := ConvertCodexResponseToClaudeNonStream(ctx, "", originalRequest, nil, response, nil)
+ parsed := gjson.ParseBytes(out)
+
+ if got := parsed.Get("stop_reason").String(); got != "stop_sequence" {
+ t.Fatalf("stop_reason = %q, want stop_sequence. Output: %s", got, string(out))
+ }
+ if got := parsed.Get("stop_sequence").String(); got != "\nEND" {
+ t.Fatalf("stop_sequence = %q, want newline END. Output: %s", got, string(out))
+ }
+}
+
+func findClaudeStreamStopReason(outputs [][]byte) (string, bool) {
+ messageDelta, ok := findClaudeStreamMessageDelta(outputs)
+ if !ok {
+ return "", false
+ }
+ return messageDelta.Get("delta.stop_reason").String(), true
+}
+
+func findClaudeStreamMessageDelta(outputs [][]byte) (gjson.Result, bool) {
+ for _, out := range outputs {
+ for _, line := range strings.Split(string(out), "\n") {
+ if !strings.HasPrefix(line, "data: ") {
+ continue
+ }
+ data := gjson.Parse(strings.TrimPrefix(line, "data: "))
+ if data.Get("type").String() == "message_delta" {
+ return data, true
+ }
+ }
+ }
+ return gjson.Result{}, false
+}
diff --git a/internal/translator/codex/claude/init.go b/internal/translator/codex/claude/init.go
index 7126edc303..af44b9dd49 100644
--- a/internal/translator/codex/claude/init.go
+++ b/internal/translator/codex/claude/init.go
@@ -1,9 +1,9 @@
package claude
import (
- . "github.com/router-for-me/CLIProxyAPI/v6/internal/constant"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator"
+ . "github.com/router-for-me/CLIProxyAPI/v7/internal/constant"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/translator"
)
func init() {
diff --git a/internal/translator/codex/gemini-cli/codex_gemini-cli_request.go b/internal/translator/codex/gemini-cli/codex_gemini-cli_request.go
index 8b32453d26..b69bab11ee 100644
--- a/internal/translator/codex/gemini-cli/codex_gemini-cli_request.go
+++ b/internal/translator/codex/gemini-cli/codex_gemini-cli_request.go
@@ -6,7 +6,7 @@
package geminiCLI
import (
- . "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/codex/gemini"
+ . "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/codex/gemini"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
diff --git a/internal/translator/codex/gemini-cli/codex_gemini-cli_request_test.go b/internal/translator/codex/gemini-cli/codex_gemini-cli_request_test.go
new file mode 100644
index 0000000000..fc41452b10
--- /dev/null
+++ b/internal/translator/codex/gemini-cli/codex_gemini-cli_request_test.go
@@ -0,0 +1,78 @@
+package geminiCLI
+
+import (
+ "testing"
+
+ "github.com/tidwall/gjson"
+)
+
+func TestConvertGeminiCLIRequestToCodex_PreservesSchemaPropertyNamedType(t *testing.T) {
+ input := []byte(`{
+ "request": {
+ "tools": [
+ {
+ "functionDeclarations": [
+ {
+ "name": "ask_user",
+ "description": "Ask the user one or more questions.",
+ "parametersJsonSchema": {
+ "type": "object",
+ "properties": {
+ "questions": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "header": {
+ "type": "string"
+ },
+ "type": {
+ "default": "choice",
+ "description": "Question type.",
+ "enum": [
+ "choice",
+ "text",
+ "yesno"
+ ],
+ "type": "string"
+ }
+ },
+ "required": [
+ "question",
+ "header",
+ "type"
+ ]
+ }
+ }
+ },
+ "required": [
+ "questions"
+ ]
+ }
+ }
+ ]
+ }
+ ]
+ }
+ }`)
+
+ out := ConvertGeminiCLIRequestToCodex("gpt-5.2", input, true)
+ tool := gjson.GetBytes(out, "tools.0")
+ if got := tool.Get("type").String(); got != "function" {
+ t.Fatalf("expected tool type %q, got %q; output=%s", "function", got, string(out))
+ }
+
+ typeProperty := tool.Get("parameters.properties.questions.items.properties.type")
+ if !typeProperty.IsObject() {
+ t.Fatalf("expected schema property named type to stay an object; output=%s", string(out))
+ }
+ if got := typeProperty.Get("type").String(); got != "string" {
+ t.Fatalf("expected schema property type %q, got %q; output=%s", "string", got, string(out))
+ }
+ if got := typeProperty.Get("default").String(); got != "choice" {
+ t.Fatalf("expected default %q, got %q; output=%s", "choice", got, string(out))
+ }
+ if got := typeProperty.Get("enum.2").String(); got != "yesno" {
+ t.Fatalf("expected enum value %q, got %q; output=%s", "yesno", got, string(out))
+ }
+}
diff --git a/internal/translator/codex/gemini-cli/codex_gemini-cli_response.go b/internal/translator/codex/gemini-cli/codex_gemini-cli_response.go
index 0f0068c842..01dbc0f831 100644
--- a/internal/translator/codex/gemini-cli/codex_gemini-cli_response.go
+++ b/internal/translator/codex/gemini-cli/codex_gemini-cli_response.go
@@ -7,8 +7,8 @@ package geminiCLI
import (
"context"
- . "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/codex/gemini"
- translatorcommon "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/common"
+ . "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/codex/gemini"
+ translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common"
)
// ConvertCodexResponseToGeminiCLI converts Codex streaming response format to Gemini CLI format.
diff --git a/internal/translator/codex/gemini-cli/init.go b/internal/translator/codex/gemini-cli/init.go
index 8bcd3de5fd..2958e0a825 100644
--- a/internal/translator/codex/gemini-cli/init.go
+++ b/internal/translator/codex/gemini-cli/init.go
@@ -1,9 +1,9 @@
package geminiCLI
import (
- . "github.com/router-for-me/CLIProxyAPI/v6/internal/constant"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator"
+ . "github.com/router-for-me/CLIProxyAPI/v7/internal/constant"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/translator"
)
func init() {
diff --git a/internal/translator/codex/gemini/codex_gemini_request.go b/internal/translator/codex/gemini/codex_gemini_request.go
index 23dae7d71e..5789890f20 100644
--- a/internal/translator/codex/gemini/codex_gemini_request.go
+++ b/internal/translator/codex/gemini/codex_gemini_request.go
@@ -12,8 +12,8 @@ import (
"strconv"
"strings"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/util"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
@@ -284,7 +284,11 @@ func ConvertGeminiRequestToCodex(modelName string, inputRawJSON []byte, _ bool)
util.Walk(toolsResult, "", "type", &pathsToLower)
for _, p := range pathsToLower {
fullPath := fmt.Sprintf("tools.%s", p)
- out, _ = sjson.SetBytes(out, fullPath, strings.ToLower(gjson.GetBytes(out, fullPath).String()))
+ typeValue := gjson.GetBytes(out, fullPath)
+ if typeValue.Type != gjson.String {
+ continue
+ }
+ out, _ = sjson.SetBytes(out, fullPath, strings.ToLower(typeValue.String()))
}
return out
diff --git a/internal/translator/codex/gemini/codex_gemini_response.go b/internal/translator/codex/gemini/codex_gemini_response.go
index f6ef87710a..ecf9cf4de8 100644
--- a/internal/translator/codex/gemini/codex_gemini_response.go
+++ b/internal/translator/codex/gemini/codex_gemini_response.go
@@ -7,9 +7,11 @@ package gemini
import (
"bytes"
"context"
+ "crypto/sha256"
+ "strings"
"time"
- translatorcommon "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/common"
+ translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
@@ -25,6 +27,7 @@ type ConvertCodexResponseToGeminiParams struct {
ResponseID string
LastStorageOutput []byte
HasOutputTextDelta bool
+ LastImageHashByID map[string][32]byte
}
// ConvertCodexResponseToGemini converts Codex streaming response format to Gemini format.
@@ -48,6 +51,7 @@ func ConvertCodexResponseToGemini(_ context.Context, modelName string, originalR
ResponseID: "",
LastStorageOutput: nil,
HasOutputTextDelta: false,
+ LastImageHashByID: make(map[string][32]byte),
}
}
@@ -74,10 +78,63 @@ func ConvertCodexResponseToGemini(_ context.Context, modelName string, originalR
template, _ = sjson.SetBytes(template, "responseId", params.ResponseID)
}
+ if typeStr == "response.image_generation_call.partial_image" {
+ itemID := rootResult.Get("item_id").String()
+ b64 := rootResult.Get("partial_image_b64").String()
+ if b64 == "" {
+ return [][]byte{}
+ }
+ if itemID != "" {
+ if params.LastImageHashByID == nil {
+ params.LastImageHashByID = make(map[string][32]byte)
+ }
+ hash := sha256.Sum256([]byte(b64))
+ if last, ok := params.LastImageHashByID[itemID]; ok && last == hash {
+ return [][]byte{}
+ }
+ params.LastImageHashByID[itemID] = hash
+ }
+
+ outputFormat := rootResult.Get("output_format").String()
+ mimeType := mimeTypeFromCodexOutputFormat(outputFormat)
+
+ part := []byte(`{"inlineData":{"data":"","mimeType":""}}`)
+ part, _ = sjson.SetBytes(part, "inlineData.data", b64)
+ part, _ = sjson.SetBytes(part, "inlineData.mimeType", mimeType)
+ template, _ = sjson.SetRawBytes(template, "candidates.0.content.parts.-1", part)
+ return [][]byte{template}
+ }
+
// Handle function call completion
if typeStr == "response.output_item.done" {
itemResult := rootResult.Get("item")
itemType := itemResult.Get("type").String()
+ if itemType == "image_generation_call" {
+ itemID := itemResult.Get("id").String()
+ b64 := itemResult.Get("result").String()
+ if b64 == "" {
+ return [][]byte{}
+ }
+ if itemID != "" {
+ if params.LastImageHashByID == nil {
+ params.LastImageHashByID = make(map[string][32]byte)
+ }
+ hash := sha256.Sum256([]byte(b64))
+ if last, ok := params.LastImageHashByID[itemID]; ok && last == hash {
+ return [][]byte{}
+ }
+ params.LastImageHashByID[itemID] = hash
+ }
+
+ outputFormat := itemResult.Get("output_format").String()
+ mimeType := mimeTypeFromCodexOutputFormat(outputFormat)
+
+ part := []byte(`{"inlineData":{"data":"","mimeType":""}}`)
+ part, _ = sjson.SetBytes(part, "inlineData.data", b64)
+ part, _ = sjson.SetBytes(part, "inlineData.mimeType", mimeType)
+ template, _ = sjson.SetRawBytes(template, "candidates.0.content.parts.-1", part)
+ return [][]byte{template}
+ }
if itemType == "function_call" {
// Create function call part
functionCall := []byte(`{"functionCall":{"name":"","args":{}}}`)
@@ -270,6 +327,20 @@ func ConvertCodexResponseToGeminiNonStream(_ context.Context, modelName string,
})
}
+ case "image_generation_call":
+ flushPendingFunctionCalls()
+ b64 := value.Get("result").String()
+ if b64 == "" {
+ break
+ }
+ outputFormat := value.Get("output_format").String()
+ mimeType := mimeTypeFromCodexOutputFormat(outputFormat)
+
+ part := []byte(`{"inlineData":{"data":"","mimeType":""}}`)
+ part, _ = sjson.SetBytes(part, "inlineData.data", b64)
+ part, _ = sjson.SetBytes(part, "inlineData.mimeType", mimeType)
+ template, _ = sjson.SetRawBytes(template, "candidates.0.content.parts.-1", part)
+
case "function_call":
// Collect function call for potential merging with consecutive ones
hasToolCall = true
@@ -342,3 +413,24 @@ func buildReverseMapFromGeminiOriginal(original []byte) map[string]string {
func GeminiTokenCount(ctx context.Context, count int64) []byte {
return translatorcommon.GeminiTokenCountJSON(count)
}
+
+func mimeTypeFromCodexOutputFormat(outputFormat string) string {
+ if outputFormat == "" {
+ return "image/png"
+ }
+ if strings.Contains(outputFormat, "/") {
+ return outputFormat
+ }
+ switch strings.ToLower(outputFormat) {
+ case "png":
+ return "image/png"
+ case "jpg", "jpeg":
+ return "image/jpeg"
+ case "webp":
+ return "image/webp"
+ case "gif":
+ return "image/gif"
+ default:
+ return "image/png"
+ }
+}
diff --git a/internal/translator/codex/gemini/codex_gemini_response_test.go b/internal/translator/codex/gemini/codex_gemini_response_test.go
index b8f227beb5..547ee84715 100644
--- a/internal/translator/codex/gemini/codex_gemini_response_test.go
+++ b/internal/translator/codex/gemini/codex_gemini_response_test.go
@@ -33,3 +33,79 @@ func TestConvertCodexResponseToGemini_StreamEmptyOutputUsesOutputItemDoneMessage
t.Fatalf("expected fallback content from response.output_item.done message; outputs=%q", outputs)
}
}
+
+func TestConvertCodexResponseToGemini_StreamPartialImageEmitsInlineData(t *testing.T) {
+ ctx := context.Background()
+ originalRequest := []byte(`{"tools":[]}`)
+ var param any
+
+ chunk := []byte(`data: {"type":"response.image_generation_call.partial_image","item_id":"ig_123","output_format":"png","partial_image_b64":"aGVsbG8=","partial_image_index":0}`)
+ out := ConvertCodexResponseToGemini(ctx, "gemini-2.5-pro", originalRequest, nil, chunk, ¶m)
+ if len(out) != 1 {
+ t.Fatalf("expected 1 chunk, got %d", len(out))
+ }
+
+ got := gjson.GetBytes(out[0], "candidates.0.content.parts.0.inlineData.data").String()
+ if got != "aGVsbG8=" {
+ t.Fatalf("expected inlineData.data %q, got %q; chunk=%s", "aGVsbG8=", got, string(out[0]))
+ }
+
+ gotMime := gjson.GetBytes(out[0], "candidates.0.content.parts.0.inlineData.mimeType").String()
+ if gotMime != "image/png" {
+ t.Fatalf("expected inlineData.mimeType %q, got %q; chunk=%s", "image/png", gotMime, string(out[0]))
+ }
+
+ out = ConvertCodexResponseToGemini(ctx, "gemini-2.5-pro", originalRequest, nil, chunk, ¶m)
+ if len(out) != 0 {
+ t.Fatalf("expected duplicate image chunk to be suppressed, got %d", len(out))
+ }
+}
+
+func TestConvertCodexResponseToGemini_StreamImageGenerationCallDoneEmitsInlineData(t *testing.T) {
+ ctx := context.Background()
+ originalRequest := []byte(`{"tools":[]}`)
+ var param any
+
+ out := ConvertCodexResponseToGemini(ctx, "gemini-2.5-pro", originalRequest, nil, []byte(`data: {"type":"response.image_generation_call.partial_image","item_id":"ig_123","output_format":"png","partial_image_b64":"aGVsbG8=","partial_image_index":0}`), ¶m)
+ if len(out) != 1 {
+ t.Fatalf("expected 1 chunk, got %d", len(out))
+ }
+
+ out = ConvertCodexResponseToGemini(ctx, "gemini-2.5-pro", originalRequest, nil, []byte(`data: {"type":"response.output_item.done","item":{"id":"ig_123","type":"image_generation_call","output_format":"png","result":"aGVsbG8="}}`), ¶m)
+ if len(out) != 0 {
+ t.Fatalf("expected output_item.done to be suppressed when identical to last partial image, got %d", len(out))
+ }
+
+ out = ConvertCodexResponseToGemini(ctx, "gemini-2.5-pro", originalRequest, nil, []byte(`data: {"type":"response.output_item.done","item":{"id":"ig_123","type":"image_generation_call","output_format":"jpeg","result":"Ymll"}}`), ¶m)
+ if len(out) != 1 {
+ t.Fatalf("expected 1 chunk, got %d", len(out))
+ }
+
+ got := gjson.GetBytes(out[0], "candidates.0.content.parts.0.inlineData.data").String()
+ if got != "Ymll" {
+ t.Fatalf("expected inlineData.data %q, got %q; chunk=%s", "Ymll", got, string(out[0]))
+ }
+
+ gotMime := gjson.GetBytes(out[0], "candidates.0.content.parts.0.inlineData.mimeType").String()
+ if gotMime != "image/jpeg" {
+ t.Fatalf("expected inlineData.mimeType %q, got %q; chunk=%s", "image/jpeg", gotMime, string(out[0]))
+ }
+}
+
+func TestConvertCodexResponseToGemini_NonStreamImageGenerationCallAddsInlineDataPart(t *testing.T) {
+ ctx := context.Background()
+ originalRequest := []byte(`{"tools":[]}`)
+
+ raw := []byte(`{"type":"response.completed","response":{"id":"resp_123","created_at":1700000000,"usage":{"input_tokens":1,"output_tokens":1},"output":[{"type":"message","content":[{"type":"output_text","text":"ok"}]},{"type":"image_generation_call","output_format":"png","result":"aGVsbG8="}]}}`)
+ out := ConvertCodexResponseToGeminiNonStream(ctx, "gemini-2.5-pro", originalRequest, nil, raw, nil)
+
+ got := gjson.GetBytes(out, "candidates.0.content.parts.1.inlineData.data").String()
+ if got != "aGVsbG8=" {
+ t.Fatalf("expected inlineData.data %q, got %q; chunk=%s", "aGVsbG8=", got, string(out))
+ }
+
+ gotMime := gjson.GetBytes(out, "candidates.0.content.parts.1.inlineData.mimeType").String()
+ if gotMime != "image/png" {
+ t.Fatalf("expected inlineData.mimeType %q, got %q; chunk=%s", "image/png", gotMime, string(out))
+ }
+}
diff --git a/internal/translator/codex/gemini/init.go b/internal/translator/codex/gemini/init.go
index 41d30559a6..b670d8d9b4 100644
--- a/internal/translator/codex/gemini/init.go
+++ b/internal/translator/codex/gemini/init.go
@@ -1,9 +1,9 @@
package gemini
import (
- . "github.com/router-for-me/CLIProxyAPI/v6/internal/constant"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator"
+ . "github.com/router-for-me/CLIProxyAPI/v7/internal/constant"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/translator"
)
func init() {
diff --git a/internal/translator/codex/openai/chat-completions/codex_openai_request.go b/internal/translator/codex/openai/chat-completions/codex_openai_request.go
index 6cc701e707..569e06e316 100644
--- a/internal/translator/codex/openai/chat-completions/codex_openai_request.go
+++ b/internal/translator/codex/openai/chat-completions/codex_openai_request.go
@@ -121,13 +121,13 @@ func ConvertOpenAIRequestToCodex(modelName string, inputRawJSON []byte, stream b
case "tool":
// Handle tool response messages as top-level function_call_output objects
toolCallID := m.Get("tool_call_id").String()
- content := m.Get("content").String()
+ content := m.Get("content")
// Create function_call_output object
funcOutput := []byte(`{}`)
funcOutput, _ = sjson.SetBytes(funcOutput, "type", "function_call_output")
funcOutput, _ = sjson.SetBytes(funcOutput, "call_id", toolCallID)
- funcOutput, _ = sjson.SetBytes(funcOutput, "output", content)
+ funcOutput = setToolCallOutputContent(funcOutput, content)
out, _ = sjson.SetRawBytes(out, "input.-1", funcOutput)
default:
@@ -359,6 +359,91 @@ func ConvertOpenAIRequestToCodex(modelName string, inputRawJSON []byte, stream b
return out
}
+func setToolCallOutputContent(funcOutput []byte, content gjson.Result) []byte {
+ switch {
+ case content.Type == gjson.String:
+ funcOutput, _ = sjson.SetBytes(funcOutput, "output", content.String())
+ case content.IsArray():
+ output := []byte(`[]`)
+ for _, item := range content.Array() {
+ output = appendToolOutputContentPart(output, item)
+ }
+ funcOutput, _ = sjson.SetRawBytes(funcOutput, "output", output)
+ default:
+ fallbackOutput := content.Raw
+ if fallbackOutput == "" {
+ fallbackOutput = content.String()
+ }
+ funcOutput, _ = sjson.SetBytes(funcOutput, "output", fallbackOutput)
+ }
+ return funcOutput
+}
+
+func appendToolOutputContentPart(output []byte, item gjson.Result) []byte {
+ switch item.Get("type").String() {
+ case "text":
+ part := []byte(`{}`)
+ part, _ = sjson.SetBytes(part, "type", "input_text")
+ part, _ = sjson.SetBytes(part, "text", item.Get("text").String())
+ output, _ = sjson.SetRawBytes(output, "-1", part)
+ case "image_url":
+ imageURL := item.Get("image_url.url").String()
+ fileID := item.Get("image_url.file_id").String()
+ if imageURL == "" && fileID == "" {
+ return appendToolOutputFallbackPart(output, item)
+ }
+ part := []byte(`{}`)
+ part, _ = sjson.SetBytes(part, "type", "input_image")
+ if imageURL != "" {
+ part, _ = sjson.SetBytes(part, "image_url", imageURL)
+ }
+ if fileID != "" {
+ part, _ = sjson.SetBytes(part, "file_id", fileID)
+ }
+ if detail := item.Get("image_url.detail").String(); detail != "" {
+ part, _ = sjson.SetBytes(part, "detail", detail)
+ }
+ output, _ = sjson.SetRawBytes(output, "-1", part)
+ case "file":
+ fileID := item.Get("file.file_id").String()
+ fileData := item.Get("file.file_data").String()
+ fileURL := item.Get("file.file_url").String()
+ if fileID == "" && fileData == "" && fileURL == "" {
+ return appendToolOutputFallbackPart(output, item)
+ }
+ part := []byte(`{}`)
+ part, _ = sjson.SetBytes(part, "type", "input_file")
+ if fileID != "" {
+ part, _ = sjson.SetBytes(part, "file_id", fileID)
+ }
+ if fileData != "" {
+ part, _ = sjson.SetBytes(part, "file_data", fileData)
+ }
+ if fileURL != "" {
+ part, _ = sjson.SetBytes(part, "file_url", fileURL)
+ }
+ if filename := item.Get("file.filename").String(); filename != "" {
+ part, _ = sjson.SetBytes(part, "filename", filename)
+ }
+ output, _ = sjson.SetRawBytes(output, "-1", part)
+ default:
+ output = appendToolOutputFallbackPart(output, item)
+ }
+ return output
+}
+
+func appendToolOutputFallbackPart(output []byte, item gjson.Result) []byte {
+ text := item.Raw
+ if text == "" {
+ text = item.String()
+ }
+ part := []byte(`{}`)
+ part, _ = sjson.SetBytes(part, "type", "input_text")
+ part, _ = sjson.SetBytes(part, "text", text)
+ output, _ = sjson.SetRawBytes(output, "-1", part)
+ return output
+}
+
// shortenNameIfNeeded applies the simple shortening rule for a single name.
// If the name length exceeds 64, it will try to preserve the "mcp__" prefix and last segment.
// Otherwise it truncates to 64 characters.
diff --git a/internal/translator/codex/openai/chat-completions/codex_openai_request_test.go b/internal/translator/codex/openai/chat-completions/codex_openai_request_test.go
index 84c8dad2cc..e31db6d373 100644
--- a/internal/translator/codex/openai/chat-completions/codex_openai_request_test.go
+++ b/internal/translator/codex/openai/chat-completions/codex_openai_request_test.go
@@ -176,6 +176,182 @@ func TestToolCallWithContent(t *testing.T) {
}
}
+func TestToolCallOutputWithMultimodalContent(t *testing.T) {
+ input := []byte(`{
+ "model": "gpt-4o",
+ "messages": [
+ {"role": "user", "content": "Show me the generated result."},
+ {
+ "role": "assistant",
+ "content": null,
+ "tool_calls": [
+ {
+ "id": "call_output_1",
+ "type": "function",
+ "function": {"name": "render_output", "arguments": "{}"}
+ }
+ ]
+ },
+ {
+ "role": "tool",
+ "tool_call_id": "call_output_1",
+ "content": [
+ {"type":"text","text":"Rendered result attached."},
+ {"type":"image_url","image_url":{"url":"https://example.com/generated.png","detail":"high"}},
+ {"type":"image_url","image_url":{"file_id":"file-img-123"}},
+ {"type":"file","file":{"file_id":"file-doc-123","filename":"doc.pdf"}},
+ {"type":"file","file":{"file_data":"SGVsbG8=","filename":"inline.txt"}},
+ {"type":"file","file":{"file_url":"https://example.com/report.pdf","filename":"report.pdf"}}
+ ]
+ }
+ ],
+ "tools": [
+ {
+ "type": "function",
+ "function": {"name": "render_output", "description": "Render output", "parameters": {"type": "object", "properties": {}}}
+ }
+ ]
+ }`)
+
+ out := ConvertOpenAIRequestToCodex("gpt-4o", input, true)
+ result := string(out)
+
+ output := gjson.Get(result, "input.2.output")
+ if !output.IsArray() {
+ t.Fatalf("expected tool output to be an array, got: %s", output.Raw)
+ }
+
+ parts := output.Array()
+ if len(parts) != 6 {
+ t.Fatalf("expected 6 output parts, got %d: %s", len(parts), output.Raw)
+ }
+ if parts[0].Get("type").String() != "input_text" || parts[0].Get("text").String() != "Rendered result attached." {
+ t.Fatalf("part 0: expected input_text with rendered text, got %s", parts[0].Raw)
+ }
+ if parts[1].Get("type").String() != "input_image" {
+ t.Fatalf("part 1: expected input_image, got %s", parts[1].Raw)
+ }
+ if parts[1].Get("image_url").String() != "https://example.com/generated.png" {
+ t.Errorf("part 1: unexpected image_url %s", parts[1].Get("image_url").String())
+ }
+ if parts[1].Get("detail").String() != "high" {
+ t.Errorf("part 1: unexpected detail %s", parts[1].Get("detail").String())
+ }
+ if parts[2].Get("type").String() != "input_image" || parts[2].Get("file_id").String() != "file-img-123" {
+ t.Fatalf("part 2: expected file_id-backed input_image, got %s", parts[2].Raw)
+ }
+ if parts[3].Get("type").String() != "input_file" || parts[3].Get("file_id").String() != "file-doc-123" {
+ t.Fatalf("part 3: expected file_id-backed input_file, got %s", parts[3].Raw)
+ }
+ if parts[3].Get("filename").String() != "doc.pdf" {
+ t.Errorf("part 3: unexpected filename %s", parts[3].Get("filename").String())
+ }
+ if parts[4].Get("type").String() != "input_file" || parts[4].Get("file_data").String() != "SGVsbG8=" {
+ t.Fatalf("part 4: expected file_data-backed input_file, got %s", parts[4].Raw)
+ }
+ if parts[5].Get("type").String() != "input_file" || parts[5].Get("file_url").String() != "https://example.com/report.pdf" {
+ t.Fatalf("part 5: expected file_url-backed input_file, got %s", parts[5].Raw)
+ }
+}
+
+func TestToolCallOutputFallsBackForInvalidStructuredParts(t *testing.T) {
+ input := []byte(`{
+ "model": "gpt-4o",
+ "messages": [
+ {"role": "user", "content": "Check tool output."},
+ {
+ "role": "assistant",
+ "content": null,
+ "tool_calls": [
+ {"id": "call_invalid_parts", "type": "function", "function": {"name": "inspect", "arguments": "{}"}}
+ ]
+ },
+ {
+ "role": "tool",
+ "tool_call_id": "call_invalid_parts",
+ "content": [
+ {"type":"image_url","image_url":{"detail":"low"}},
+ {"type":"file","file":{"filename":"orphan.txt"}},
+ {"type":"unknown_type","foo":"bar","nested":{"a":1}}
+ ]
+ }
+ ],
+ "tools": [
+ {"type": "function", "function": {"name": "inspect", "description": "Inspect", "parameters": {"type": "object", "properties": {}}}}
+ ]
+ }`)
+
+ out := ConvertOpenAIRequestToCodex("gpt-4o", input, true)
+ result := string(out)
+
+ parts := gjson.Get(result, "input.2.output").Array()
+ if len(parts) != 3 {
+ t.Fatalf("expected 3 output parts, got %d: %s", len(parts), gjson.Get(result, "input.2.output").Raw)
+ }
+
+ expectedFallbacks := []string{
+ `{"type":"image_url","image_url":{"detail":"low"}}`,
+ `{"type":"file","file":{"filename":"orphan.txt"}}`,
+ `{"type":"unknown_type","foo":"bar","nested":{"a":1}}`,
+ }
+ for i, expectedFallback := range expectedFallbacks {
+ if parts[i].Get("type").String() != "input_text" {
+ t.Fatalf("part %d: expected input_text fallback, got %s", i, parts[i].Raw)
+ }
+ if parts[i].Get("text").String() != expectedFallback {
+ t.Fatalf("part %d: expected fallback %s, got %s", i, expectedFallback, parts[i].Get("text").String())
+ }
+ }
+}
+
+func TestToolCallOutputWithNonStringJSONContent(t *testing.T) {
+ tests := []struct {
+ name string
+ content string
+ expectedOutput string
+ }{
+ {name: "null", content: `null`, expectedOutput: `null`},
+ {name: "object", content: `{"status":"ok","count":2}`, expectedOutput: `{"status":"ok","count":2}`},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ input := []byte(`{
+ "model": "gpt-4o",
+ "messages": [
+ {"role": "user", "content": "Check tool output."},
+ {
+ "role": "assistant",
+ "content": null,
+ "tool_calls": [
+ {"id": "call_json", "type": "function", "function": {"name": "inspect", "arguments": "{}"}}
+ ]
+ },
+ {
+ "role": "tool",
+ "tool_call_id": "call_json",
+ "content": ` + tt.content + `
+ }
+ ],
+ "tools": [
+ {"type": "function", "function": {"name": "inspect", "description": "Inspect", "parameters": {"type": "object", "properties": {}}}}
+ ]
+ }`)
+
+ out := ConvertOpenAIRequestToCodex("gpt-4o", input, true)
+ result := string(out)
+
+ output := gjson.Get(result, "input.2.output")
+ if !output.Exists() {
+ t.Fatalf("expected output field to exist: %s", gjson.Get(result, "input.2").Raw)
+ }
+ if output.String() != tt.expectedOutput {
+ t.Fatalf("expected output %s, got %s", tt.expectedOutput, output.String())
+ }
+ })
+ }
+}
+
// Parallel tool calls: assistant invokes 3 tools at once, all call_ids
// and outputs must be translated and paired correctly.
func TestMultipleToolCalls(t *testing.T) {
diff --git a/internal/translator/codex/openai/chat-completions/codex_openai_response.go b/internal/translator/codex/openai/chat-completions/codex_openai_response.go
index afae35d48d..75b5b848b3 100644
--- a/internal/translator/codex/openai/chat-completions/codex_openai_response.go
+++ b/internal/translator/codex/openai/chat-completions/codex_openai_response.go
@@ -8,6 +8,8 @@ package chat_completions
import (
"bytes"
"context"
+ "crypto/sha256"
+ "strings"
"time"
"github.com/tidwall/gjson"
@@ -26,6 +28,7 @@ type ConvertCliToOpenAIParams struct {
FunctionCallIndex int
HasReceivedArgumentsDelta bool
HasToolCallAnnounced bool
+ LastImageHashByItemID map[string][32]byte
}
// ConvertCodexResponseToOpenAI translates a single chunk of a streaming response from the
@@ -51,6 +54,7 @@ func ConvertCodexResponseToOpenAI(_ context.Context, modelName string, originalR
FunctionCallIndex: -1,
HasReceivedArgumentsDelta: false,
HasToolCallAnnounced: false,
+ LastImageHashByItemID: make(map[string][32]byte),
}
}
@@ -70,6 +74,9 @@ func ConvertCodexResponseToOpenAI(_ context.Context, modelName string, originalR
(*param).(*ConvertCliToOpenAIParams).ResponseID = rootResult.Get("response.id").String()
(*param).(*ConvertCliToOpenAIParams).CreatedAt = rootResult.Get("response.created_at").Int()
(*param).(*ConvertCliToOpenAIParams).Model = rootResult.Get("response.model").String()
+ if (*param).(*ConvertCliToOpenAIParams).LastImageHashByItemID == nil {
+ (*param).(*ConvertCliToOpenAIParams).LastImageHashByItemID = make(map[string][32]byte)
+ }
return [][]byte{}
}
@@ -120,6 +127,39 @@ func ConvertCodexResponseToOpenAI(_ context.Context, modelName string, originalR
template, _ = sjson.SetBytes(template, "choices.0.delta.role", "assistant")
template, _ = sjson.SetBytes(template, "choices.0.delta.content", deltaResult.String())
}
+ } else if dataType == "response.image_generation_call.partial_image" {
+ itemID := rootResult.Get("item_id").String()
+ b64 := rootResult.Get("partial_image_b64").String()
+ if b64 == "" {
+ return [][]byte{}
+ }
+ if itemID != "" {
+ p := (*param).(*ConvertCliToOpenAIParams)
+ if p.LastImageHashByItemID == nil {
+ p.LastImageHashByItemID = make(map[string][32]byte)
+ }
+ hash := sha256.Sum256([]byte(b64))
+ if last, ok := p.LastImageHashByItemID[itemID]; ok && last == hash {
+ return [][]byte{}
+ }
+ p.LastImageHashByItemID[itemID] = hash
+ }
+
+ outputFormat := rootResult.Get("output_format").String()
+ mimeType := mimeTypeFromCodexOutputFormat(outputFormat)
+ imageURL := "data:" + mimeType + ";base64," + b64
+
+ imagesResult := gjson.GetBytes(template, "choices.0.delta.images")
+ if !imagesResult.Exists() || !imagesResult.IsArray() {
+ template, _ = sjson.SetRawBytes(template, "choices.0.delta.images", []byte(`[]`))
+ }
+ imageIndex := len(gjson.GetBytes(template, "choices.0.delta.images").Array())
+ imagePayload := []byte(`{"type":"image_url","image_url":{"url":""}}`)
+ imagePayload, _ = sjson.SetBytes(imagePayload, "index", imageIndex)
+ imagePayload, _ = sjson.SetBytes(imagePayload, "image_url.url", imageURL)
+
+ template, _ = sjson.SetBytes(template, "choices.0.delta.role", "assistant")
+ template, _ = sjson.SetRawBytes(template, "choices.0.delta.images.-1", imagePayload)
} else if dataType == "response.completed" {
finishReason := "stop"
if (*param).(*ConvertCliToOpenAIParams).FunctionCallIndex != -1 {
@@ -183,7 +223,46 @@ func ConvertCodexResponseToOpenAI(_ context.Context, modelName string, originalR
} else if dataType == "response.output_item.done" {
itemResult := rootResult.Get("item")
- if !itemResult.Exists() || itemResult.Get("type").String() != "function_call" {
+ if !itemResult.Exists() {
+ return [][]byte{}
+ }
+ itemType := itemResult.Get("type").String()
+ if itemType == "image_generation_call" {
+ itemID := itemResult.Get("id").String()
+ b64 := itemResult.Get("result").String()
+ if b64 == "" {
+ return [][]byte{}
+ }
+ if itemID != "" {
+ p := (*param).(*ConvertCliToOpenAIParams)
+ if p.LastImageHashByItemID == nil {
+ p.LastImageHashByItemID = make(map[string][32]byte)
+ }
+ hash := sha256.Sum256([]byte(b64))
+ if last, ok := p.LastImageHashByItemID[itemID]; ok && last == hash {
+ return [][]byte{}
+ }
+ p.LastImageHashByItemID[itemID] = hash
+ }
+
+ outputFormat := itemResult.Get("output_format").String()
+ mimeType := mimeTypeFromCodexOutputFormat(outputFormat)
+ imageURL := "data:" + mimeType + ";base64," + b64
+
+ imagesResult := gjson.GetBytes(template, "choices.0.delta.images")
+ if !imagesResult.Exists() || !imagesResult.IsArray() {
+ template, _ = sjson.SetRawBytes(template, "choices.0.delta.images", []byte(`[]`))
+ }
+ imageIndex := len(gjson.GetBytes(template, "choices.0.delta.images").Array())
+ imagePayload := []byte(`{"type":"image_url","image_url":{"url":""}}`)
+ imagePayload, _ = sjson.SetBytes(imagePayload, "index", imageIndex)
+ imagePayload, _ = sjson.SetBytes(imagePayload, "image_url.url", imageURL)
+
+ template, _ = sjson.SetBytes(template, "choices.0.delta.role", "assistant")
+ template, _ = sjson.SetRawBytes(template, "choices.0.delta.images.-1", imagePayload)
+ return [][]byte{template}
+ }
+ if itemType != "function_call" {
return [][]byte{}
}
@@ -285,6 +364,7 @@ func ConvertCodexResponseToOpenAINonStream(_ context.Context, _ string, original
// Process the output array for content and function calls
var toolCalls [][]byte
+ var images [][]byte
outputResult := responseResult.Get("output")
if outputResult.IsArray() {
outputArray := outputResult.Array()
@@ -339,6 +419,19 @@ func ConvertCodexResponseToOpenAINonStream(_ context.Context, _ string, original
}
toolCalls = append(toolCalls, functionCallTemplate)
+ case "image_generation_call":
+ b64 := outputItem.Get("result").String()
+ if b64 == "" {
+ break
+ }
+ outputFormat := outputItem.Get("output_format").String()
+ mimeType := mimeTypeFromCodexOutputFormat(outputFormat)
+ imageURL := "data:" + mimeType + ";base64," + b64
+
+ imagePayload := []byte(`{"type":"image_url","image_url":{"url":""}}`)
+ imagePayload, _ = sjson.SetBytes(imagePayload, "index", len(images))
+ imagePayload, _ = sjson.SetBytes(imagePayload, "image_url.url", imageURL)
+ images = append(images, imagePayload)
}
}
@@ -361,6 +454,15 @@ func ConvertCodexResponseToOpenAINonStream(_ context.Context, _ string, original
}
template, _ = sjson.SetBytes(template, "choices.0.message.role", "assistant")
}
+
+ // Add images if any
+ if len(images) > 0 {
+ template, _ = sjson.SetRawBytes(template, "choices.0.message.images", []byte(`[]`))
+ for _, image := range images {
+ template, _ = sjson.SetRawBytes(template, "choices.0.message.images.-1", image)
+ }
+ template, _ = sjson.SetBytes(template, "choices.0.message.role", "assistant")
+ }
}
// Extract and set the finish reason based on status
@@ -409,3 +511,24 @@ func buildReverseMapFromOriginalOpenAI(original []byte) map[string]string {
}
return rev
}
+
+func mimeTypeFromCodexOutputFormat(outputFormat string) string {
+ if outputFormat == "" {
+ return "image/png"
+ }
+ if strings.Contains(outputFormat, "/") {
+ return outputFormat
+ }
+ switch strings.ToLower(outputFormat) {
+ case "png":
+ return "image/png"
+ case "jpg", "jpeg":
+ return "image/jpeg"
+ case "webp":
+ return "image/webp"
+ case "gif":
+ return "image/gif"
+ default:
+ return "image/png"
+ }
+}
diff --git a/internal/translator/codex/openai/chat-completions/codex_openai_response_test.go b/internal/translator/codex/openai/chat-completions/codex_openai_response_test.go
index 534884c229..a6bb486fdf 100644
--- a/internal/translator/codex/openai/chat-completions/codex_openai_response_test.go
+++ b/internal/translator/codex/openai/chat-completions/codex_openai_response_test.go
@@ -90,3 +90,62 @@ func TestConvertCodexResponseToOpenAI_ToolCallArgumentsDeltaOmitsNullContentFiel
t.Fatalf("expected tool call arguments delta to exist, got %s", string(out[0]))
}
}
+
+func TestConvertCodexResponseToOpenAI_StreamPartialImageEmitsDeltaImages(t *testing.T) {
+ ctx := context.Background()
+ var param any
+
+ chunk := []byte(`data: {"type":"response.image_generation_call.partial_image","item_id":"ig_123","output_format":"png","partial_image_b64":"aGVsbG8=","partial_image_index":0}`)
+
+ out := ConvertCodexResponseToOpenAI(ctx, "gpt-5.4", nil, nil, chunk, ¶m)
+ if len(out) != 1 {
+ t.Fatalf("expected 1 chunk, got %d", len(out))
+ }
+
+ gotURL := gjson.GetBytes(out[0], "choices.0.delta.images.0.image_url.url").String()
+ if gotURL != "data:image/png;base64,aGVsbG8=" {
+ t.Fatalf("expected image url %q, got %q; chunk=%s", "data:image/png;base64,aGVsbG8=", gotURL, string(out[0]))
+ }
+
+ out = ConvertCodexResponseToOpenAI(ctx, "gpt-5.4", nil, nil, chunk, ¶m)
+ if len(out) != 0 {
+ t.Fatalf("expected duplicate image chunk to be suppressed, got %d", len(out))
+ }
+}
+
+func TestConvertCodexResponseToOpenAI_StreamImageGenerationCallDoneEmitsDeltaImages(t *testing.T) {
+ ctx := context.Background()
+ var param any
+
+ out := ConvertCodexResponseToOpenAI(ctx, "gpt-5.4", nil, nil, []byte(`data: {"type":"response.image_generation_call.partial_image","item_id":"ig_123","output_format":"png","partial_image_b64":"aGVsbG8=","partial_image_index":0}`), ¶m)
+ if len(out) != 1 {
+ t.Fatalf("expected 1 chunk, got %d", len(out))
+ }
+
+ out = ConvertCodexResponseToOpenAI(ctx, "gpt-5.4", nil, nil, []byte(`data: {"type":"response.output_item.done","item":{"id":"ig_123","type":"image_generation_call","output_format":"png","result":"aGVsbG8="}}`), ¶m)
+ if len(out) != 0 {
+ t.Fatalf("expected output_item.done to be suppressed when identical to last partial image, got %d", len(out))
+ }
+
+ out = ConvertCodexResponseToOpenAI(ctx, "gpt-5.4", nil, nil, []byte(`data: {"type":"response.output_item.done","item":{"id":"ig_123","type":"image_generation_call","output_format":"jpeg","result":"Ymll"}}`), ¶m)
+ if len(out) != 1 {
+ t.Fatalf("expected 1 chunk, got %d", len(out))
+ }
+
+ gotURL := gjson.GetBytes(out[0], "choices.0.delta.images.0.image_url.url").String()
+ if gotURL != "data:image/jpeg;base64,Ymll" {
+ t.Fatalf("expected image url %q, got %q; chunk=%s", "data:image/jpeg;base64,Ymll", gotURL, string(out[0]))
+ }
+}
+
+func TestConvertCodexResponseToOpenAI_NonStreamImageGenerationCallAddsMessageImages(t *testing.T) {
+ ctx := context.Background()
+
+ raw := []byte(`{"type":"response.completed","response":{"id":"resp_123","created_at":1700000000,"model":"gpt-5.4","status":"completed","usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2},"output":[{"type":"message","content":[{"type":"output_text","text":"ok"}]},{"type":"image_generation_call","output_format":"png","result":"aGVsbG8="}]}}`)
+ out := ConvertCodexResponseToOpenAINonStream(ctx, "gpt-5.4", nil, nil, raw, nil)
+
+ gotURL := gjson.GetBytes(out, "choices.0.message.images.0.image_url.url").String()
+ if gotURL != "data:image/png;base64,aGVsbG8=" {
+ t.Fatalf("expected image url %q, got %q; chunk=%s", "data:image/png;base64,aGVsbG8=", gotURL, string(out))
+ }
+}
diff --git a/internal/translator/codex/openai/chat-completions/init.go b/internal/translator/codex/openai/chat-completions/init.go
index 8f782fdae1..94db2a7db8 100644
--- a/internal/translator/codex/openai/chat-completions/init.go
+++ b/internal/translator/codex/openai/chat-completions/init.go
@@ -1,9 +1,9 @@
package chat_completions
import (
- . "github.com/router-for-me/CLIProxyAPI/v6/internal/constant"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator"
+ . "github.com/router-for-me/CLIProxyAPI/v7/internal/constant"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/translator"
)
func init() {
diff --git a/internal/translator/codex/openai/responses/init.go b/internal/translator/codex/openai/responses/init.go
index cab759f297..24e7e3561c 100644
--- a/internal/translator/codex/openai/responses/init.go
+++ b/internal/translator/codex/openai/responses/init.go
@@ -1,9 +1,9 @@
package responses
import (
- . "github.com/router-for-me/CLIProxyAPI/v6/internal/constant"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator"
+ . "github.com/router-for-me/CLIProxyAPI/v7/internal/constant"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/translator"
)
func init() {
diff --git a/internal/translator/gemini-cli/claude/gemini-cli_claude_request.go b/internal/translator/gemini-cli/claude/gemini-cli_claude_request.go
index 57ebbc2cde..3e77b3f757 100644
--- a/internal/translator/gemini-cli/claude/gemini-cli_claude_request.go
+++ b/internal/translator/gemini-cli/claude/gemini-cli_claude_request.go
@@ -8,8 +8,8 @@ package claude
import (
"strings"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/common"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/common"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/util"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
diff --git a/internal/translator/gemini-cli/claude/gemini-cli_claude_response.go b/internal/translator/gemini-cli/claude/gemini-cli_claude_response.go
index 0bf4d6225c..607d6b9fc0 100644
--- a/internal/translator/gemini-cli/claude/gemini-cli_claude_response.go
+++ b/internal/translator/gemini-cli/claude/gemini-cli_claude_response.go
@@ -14,8 +14,8 @@ import (
"sync/atomic"
"time"
- translatorcommon "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/common"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
+ translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/util"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
diff --git a/internal/translator/gemini-cli/claude/init.go b/internal/translator/gemini-cli/claude/init.go
index 79ed03c68e..fa2fabdf77 100644
--- a/internal/translator/gemini-cli/claude/init.go
+++ b/internal/translator/gemini-cli/claude/init.go
@@ -1,9 +1,9 @@
package claude
import (
- . "github.com/router-for-me/CLIProxyAPI/v6/internal/constant"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator"
+ . "github.com/router-for-me/CLIProxyAPI/v7/internal/constant"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/translator"
)
func init() {
diff --git a/internal/translator/gemini-cli/gemini/gemini-cli_gemini_request.go b/internal/translator/gemini-cli/gemini/gemini-cli_gemini_request.go
index 9bdce33973..83dc626041 100644
--- a/internal/translator/gemini-cli/gemini/gemini-cli_gemini_request.go
+++ b/internal/translator/gemini-cli/gemini/gemini-cli_gemini_request.go
@@ -9,8 +9,8 @@ import (
"fmt"
"strings"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/common"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/common"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/util"
log "github.com/sirupsen/logrus"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
diff --git a/internal/translator/gemini-cli/gemini/gemini-cli_gemini_response.go b/internal/translator/gemini-cli/gemini/gemini-cli_gemini_response.go
index 8e23f1d3d6..0e100c1489 100644
--- a/internal/translator/gemini-cli/gemini/gemini-cli_gemini_response.go
+++ b/internal/translator/gemini-cli/gemini/gemini-cli_gemini_response.go
@@ -9,7 +9,7 @@ import (
"bytes"
"context"
- translatorcommon "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/common"
+ translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
diff --git a/internal/translator/gemini-cli/gemini/init.go b/internal/translator/gemini-cli/gemini/init.go
index fbad4ab50b..1c2f38f215 100644
--- a/internal/translator/gemini-cli/gemini/init.go
+++ b/internal/translator/gemini-cli/gemini/init.go
@@ -1,9 +1,9 @@
package gemini
import (
- . "github.com/router-for-me/CLIProxyAPI/v6/internal/constant"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator"
+ . "github.com/router-for-me/CLIProxyAPI/v7/internal/constant"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/translator"
)
func init() {
diff --git a/internal/translator/gemini-cli/openai/chat-completions/gemini-cli_openai_request.go b/internal/translator/gemini-cli/openai/chat-completions/gemini-cli_openai_request.go
index 95bca2d7b6..1aa3132b49 100644
--- a/internal/translator/gemini-cli/openai/chat-completions/gemini-cli_openai_request.go
+++ b/internal/translator/gemini-cli/openai/chat-completions/gemini-cli_openai_request.go
@@ -6,9 +6,9 @@ import (
"fmt"
"strings"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/common"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/misc"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/common"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/util"
log "github.com/sirupsen/logrus"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
diff --git a/internal/translator/gemini-cli/openai/chat-completions/gemini-cli_openai_response.go b/internal/translator/gemini-cli/openai/chat-completions/gemini-cli_openai_response.go
index 0947371a5a..926040588e 100644
--- a/internal/translator/gemini-cli/openai/chat-completions/gemini-cli_openai_response.go
+++ b/internal/translator/gemini-cli/openai/chat-completions/gemini-cli_openai_response.go
@@ -13,8 +13,8 @@ import (
"sync/atomic"
"time"
- . "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/openai/chat-completions"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
+ . "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/openai/chat-completions"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/util"
log "github.com/sirupsen/logrus"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
diff --git a/internal/translator/gemini-cli/openai/chat-completions/init.go b/internal/translator/gemini-cli/openai/chat-completions/init.go
index 3bd76c517d..fcd85f2450 100644
--- a/internal/translator/gemini-cli/openai/chat-completions/init.go
+++ b/internal/translator/gemini-cli/openai/chat-completions/init.go
@@ -1,9 +1,9 @@
package chat_completions
import (
- . "github.com/router-for-me/CLIProxyAPI/v6/internal/constant"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator"
+ . "github.com/router-for-me/CLIProxyAPI/v7/internal/constant"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/translator"
)
func init() {
diff --git a/internal/translator/gemini-cli/openai/responses/gemini-cli_openai-responses_request.go b/internal/translator/gemini-cli/openai/responses/gemini-cli_openai-responses_request.go
index 657e45fdb2..bea4b7a1fe 100644
--- a/internal/translator/gemini-cli/openai/responses/gemini-cli_openai-responses_request.go
+++ b/internal/translator/gemini-cli/openai/responses/gemini-cli_openai-responses_request.go
@@ -1,8 +1,8 @@
package responses
import (
- . "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini-cli/gemini"
- . "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/openai/responses"
+ . "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini-cli/gemini"
+ . "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/openai/responses"
)
func ConvertOpenAIResponsesRequestToGeminiCLI(modelName string, inputRawJSON []byte, stream bool) []byte {
diff --git a/internal/translator/gemini-cli/openai/responses/gemini-cli_openai-responses_response.go b/internal/translator/gemini-cli/openai/responses/gemini-cli_openai-responses_response.go
index 9bb3ced9ef..29db8c19ef 100644
--- a/internal/translator/gemini-cli/openai/responses/gemini-cli_openai-responses_response.go
+++ b/internal/translator/gemini-cli/openai/responses/gemini-cli_openai-responses_response.go
@@ -3,7 +3,7 @@ package responses
import (
"context"
- . "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/openai/responses"
+ . "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/openai/responses"
"github.com/tidwall/gjson"
)
diff --git a/internal/translator/gemini-cli/openai/responses/init.go b/internal/translator/gemini-cli/openai/responses/init.go
index b25d670851..e1d437715f 100644
--- a/internal/translator/gemini-cli/openai/responses/init.go
+++ b/internal/translator/gemini-cli/openai/responses/init.go
@@ -1,9 +1,9 @@
package responses
import (
- . "github.com/router-for-me/CLIProxyAPI/v6/internal/constant"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator"
+ . "github.com/router-for-me/CLIProxyAPI/v7/internal/constant"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/translator"
)
func init() {
diff --git a/internal/translator/gemini/claude/gemini_claude_request.go b/internal/translator/gemini/claude/gemini_claude_request.go
index e230f5fd0d..454668cbc2 100644
--- a/internal/translator/gemini/claude/gemini_claude_request.go
+++ b/internal/translator/gemini/claude/gemini_claude_request.go
@@ -9,9 +9,9 @@ import (
"fmt"
"strings"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/common"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/common"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/util"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
diff --git a/internal/translator/gemini/claude/gemini_claude_response.go b/internal/translator/gemini/claude/gemini_claude_response.go
index 28722de1db..797636d857 100644
--- a/internal/translator/gemini/claude/gemini_claude_response.go
+++ b/internal/translator/gemini/claude/gemini_claude_response.go
@@ -13,8 +13,8 @@ import (
"strings"
"sync/atomic"
- translatorcommon "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/common"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
+ translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/util"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
diff --git a/internal/translator/gemini/claude/init.go b/internal/translator/gemini/claude/init.go
index 66fe51e739..d03140957c 100644
--- a/internal/translator/gemini/claude/init.go
+++ b/internal/translator/gemini/claude/init.go
@@ -1,9 +1,9 @@
package claude
import (
- . "github.com/router-for-me/CLIProxyAPI/v6/internal/constant"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator"
+ . "github.com/router-for-me/CLIProxyAPI/v7/internal/constant"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/translator"
)
func init() {
diff --git a/internal/translator/gemini/gemini-cli/gemini_gemini-cli_request.go b/internal/translator/gemini/gemini-cli/gemini_gemini-cli_request.go
index 1b2cdb4636..71e7b4a5fd 100644
--- a/internal/translator/gemini/gemini-cli/gemini_gemini-cli_request.go
+++ b/internal/translator/gemini/gemini-cli/gemini_gemini-cli_request.go
@@ -8,8 +8,8 @@ package geminiCLI
import (
"fmt"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/common"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/common"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/util"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
diff --git a/internal/translator/gemini/gemini-cli/gemini_gemini-cli_response.go b/internal/translator/gemini/gemini-cli/gemini_gemini-cli_response.go
index d15ea21acc..36fa0d39b5 100644
--- a/internal/translator/gemini/gemini-cli/gemini_gemini-cli_response.go
+++ b/internal/translator/gemini/gemini-cli/gemini_gemini-cli_response.go
@@ -8,7 +8,7 @@ import (
"bytes"
"context"
- translatorcommon "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/common"
+ translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common"
"github.com/tidwall/sjson"
)
diff --git a/internal/translator/gemini/gemini-cli/init.go b/internal/translator/gemini/gemini-cli/init.go
index 2c2224f7d0..ed18b5f0af 100644
--- a/internal/translator/gemini/gemini-cli/init.go
+++ b/internal/translator/gemini/gemini-cli/init.go
@@ -1,9 +1,9 @@
package geminiCLI
import (
- . "github.com/router-for-me/CLIProxyAPI/v6/internal/constant"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator"
+ . "github.com/router-for-me/CLIProxyAPI/v7/internal/constant"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/translator"
)
func init() {
diff --git a/internal/translator/gemini/gemini/gemini_gemini_request.go b/internal/translator/gemini/gemini/gemini_gemini_request.go
index abc176b2e2..35e22d7160 100644
--- a/internal/translator/gemini/gemini/gemini_gemini_request.go
+++ b/internal/translator/gemini/gemini/gemini_gemini_request.go
@@ -7,8 +7,8 @@ import (
"fmt"
"strings"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/common"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/common"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/util"
log "github.com/sirupsen/logrus"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
diff --git a/internal/translator/gemini/gemini/gemini_gemini_response.go b/internal/translator/gemini/gemini/gemini_gemini_response.go
index 242dd98059..74669a7e72 100644
--- a/internal/translator/gemini/gemini/gemini_gemini_response.go
+++ b/internal/translator/gemini/gemini/gemini_gemini_response.go
@@ -4,7 +4,7 @@ import (
"bytes"
"context"
- translatorcommon "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/common"
+ translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common"
)
// PassthroughGeminiResponseStream forwards Gemini responses unchanged.
diff --git a/internal/translator/gemini/gemini/init.go b/internal/translator/gemini/gemini/init.go
index 28c9708338..ca9de2c672 100644
--- a/internal/translator/gemini/gemini/init.go
+++ b/internal/translator/gemini/gemini/init.go
@@ -1,9 +1,9 @@
package gemini
import (
- . "github.com/router-for-me/CLIProxyAPI/v6/internal/constant"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator"
+ . "github.com/router-for-me/CLIProxyAPI/v7/internal/constant"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/translator"
)
// Register a no-op response translator and a request normalizer for Gemini→Gemini.
diff --git a/internal/translator/gemini/openai/chat-completions/gemini_openai_request.go b/internal/translator/gemini/openai/chat-completions/gemini_openai_request.go
index c0c4d329f5..20eaec76f9 100644
--- a/internal/translator/gemini/openai/chat-completions/gemini_openai_request.go
+++ b/internal/translator/gemini/openai/chat-completions/gemini_openai_request.go
@@ -6,9 +6,9 @@ import (
"fmt"
"strings"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/common"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/misc"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/common"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/util"
log "github.com/sirupsen/logrus"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
diff --git a/internal/translator/gemini/openai/chat-completions/gemini_openai_response.go b/internal/translator/gemini/openai/chat-completions/gemini_openai_response.go
index 3dc5b095c3..cc9117f905 100644
--- a/internal/translator/gemini/openai/chat-completions/gemini_openai_response.go
+++ b/internal/translator/gemini/openai/chat-completions/gemini_openai_response.go
@@ -13,7 +13,7 @@ import (
"sync/atomic"
"time"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/util"
log "github.com/sirupsen/logrus"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
diff --git a/internal/translator/gemini/openai/chat-completions/init.go b/internal/translator/gemini/openai/chat-completions/init.go
index 800e07db3d..2eb673310f 100644
--- a/internal/translator/gemini/openai/chat-completions/init.go
+++ b/internal/translator/gemini/openai/chat-completions/init.go
@@ -1,9 +1,9 @@
package chat_completions
import (
- . "github.com/router-for-me/CLIProxyAPI/v6/internal/constant"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator"
+ . "github.com/router-for-me/CLIProxyAPI/v7/internal/constant"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/translator"
)
func init() {
diff --git a/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go b/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go
index 8f3a59fa45..e741757641 100644
--- a/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go
+++ b/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go
@@ -4,8 +4,8 @@ import (
"encoding/json"
"strings"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/common"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/common"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/util"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
diff --git a/internal/translator/gemini/openai/responses/gemini_openai-responses_response.go b/internal/translator/gemini/openai/responses/gemini_openai-responses_response.go
index 15729aae92..36d30df753 100644
--- a/internal/translator/gemini/openai/responses/gemini_openai-responses_response.go
+++ b/internal/translator/gemini/openai/responses/gemini_openai-responses_response.go
@@ -8,8 +8,8 @@ import (
"sync/atomic"
"time"
- translatorcommon "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/common"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
+ translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/util"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
diff --git a/internal/translator/gemini/openai/responses/init.go b/internal/translator/gemini/openai/responses/init.go
index b53cac3d81..404dd68ae5 100644
--- a/internal/translator/gemini/openai/responses/init.go
+++ b/internal/translator/gemini/openai/responses/init.go
@@ -1,9 +1,9 @@
package responses
import (
- . "github.com/router-for-me/CLIProxyAPI/v6/internal/constant"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator"
+ . "github.com/router-for-me/CLIProxyAPI/v7/internal/constant"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/translator"
)
func init() {
diff --git a/internal/translator/init.go b/internal/translator/init.go
index 084ea7ac23..5f88a400ec 100644
--- a/internal/translator/init.go
+++ b/internal/translator/init.go
@@ -1,36 +1,36 @@
package translator
import (
- _ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/claude/gemini"
- _ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/claude/gemini-cli"
- _ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/claude/openai/chat-completions"
- _ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/claude/openai/responses"
+ _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/claude/gemini"
+ _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/claude/gemini-cli"
+ _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/claude/openai/chat-completions"
+ _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/claude/openai/responses"
- _ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/codex/claude"
- _ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/codex/gemini"
- _ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/codex/gemini-cli"
- _ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/codex/openai/chat-completions"
- _ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/codex/openai/responses"
+ _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/codex/claude"
+ _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/codex/gemini"
+ _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/codex/gemini-cli"
+ _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/codex/openai/chat-completions"
+ _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/codex/openai/responses"
- _ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini-cli/claude"
- _ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini-cli/gemini"
- _ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini-cli/openai/chat-completions"
- _ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini-cli/openai/responses"
+ _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini-cli/claude"
+ _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini-cli/gemini"
+ _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini-cli/openai/chat-completions"
+ _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini-cli/openai/responses"
- _ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/claude"
- _ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/gemini"
- _ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/gemini-cli"
- _ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/openai/chat-completions"
- _ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/openai/responses"
+ _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/claude"
+ _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/gemini"
+ _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/gemini-cli"
+ _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/openai/chat-completions"
+ _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/openai/responses"
- _ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/openai/claude"
- _ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/openai/gemini"
- _ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/openai/gemini-cli"
- _ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/openai/openai/chat-completions"
- _ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/openai/openai/responses"
+ _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/openai/claude"
+ _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/openai/gemini"
+ _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/openai/gemini-cli"
+ _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/openai/openai/chat-completions"
+ _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/openai/openai/responses"
- _ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/antigravity/claude"
- _ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/antigravity/gemini"
- _ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/antigravity/openai/chat-completions"
- _ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/antigravity/openai/responses"
+ _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/antigravity/claude"
+ _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/antigravity/gemini"
+ _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/antigravity/openai/chat-completions"
+ _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/antigravity/openai/responses"
)
diff --git a/internal/translator/openai/claude/init.go b/internal/translator/openai/claude/init.go
index 0e0f82eae9..baeeca84bc 100644
--- a/internal/translator/openai/claude/init.go
+++ b/internal/translator/openai/claude/init.go
@@ -1,9 +1,9 @@
package claude
import (
- . "github.com/router-for-me/CLIProxyAPI/v6/internal/constant"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator"
+ . "github.com/router-for-me/CLIProxyAPI/v7/internal/constant"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/translator"
)
func init() {
diff --git a/internal/translator/openai/claude/openai_claude_request.go b/internal/translator/openai/claude/openai_claude_request.go
index f12dd0c694..99fc2763ff 100644
--- a/internal/translator/openai/claude/openai_claude_request.go
+++ b/internal/translator/openai/claude/openai_claude_request.go
@@ -8,7 +8,7 @@ package claude
import (
"strings"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
diff --git a/internal/translator/openai/claude/openai_claude_response.go b/internal/translator/openai/claude/openai_claude_response.go
index 46c75898c4..1925539c19 100644
--- a/internal/translator/openai/claude/openai_claude_response.go
+++ b/internal/translator/openai/claude/openai_claude_response.go
@@ -10,8 +10,8 @@ import (
"context"
"strings"
- translatorcommon "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/common"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
+ translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/util"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
@@ -236,7 +236,7 @@ func convertOpenAIStreamingChunkToAnthropic(rawJSON []byte, param *ConvertOpenAI
// Handle function name
if function := toolCall.Get("function"); function.Exists() {
- if name := function.Get("name"); name.Exists() {
+ if name := function.Get("name"); name.Exists() && name.String() != "" {
accumulator.Name = util.MapToolName(param.ToolNameMap, name.String())
stopThinkingContentBlock(param, &results)
diff --git a/internal/translator/openai/claude/openai_claude_response_test.go b/internal/translator/openai/claude/openai_claude_response_test.go
new file mode 100644
index 0000000000..8c36fc3d8c
--- /dev/null
+++ b/internal/translator/openai/claude/openai_claude_response_test.go
@@ -0,0 +1,41 @@
+package claude
+
+import (
+ "bytes"
+ "context"
+ "testing"
+)
+
+func TestConvertOpenAIResponseToClaude_StreamIgnoresNullToolNameDelta(t *testing.T) {
+ originalRequest := []byte(`{"stream":true}`)
+ var param any
+
+ firstChunks := ConvertOpenAIResponseToClaude(
+ context.Background(),
+ "test-model",
+ originalRequest,
+ nil,
+ []byte(`data: {"id":"chatcmpl_1","model":"test-model","created":1,"choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"read_file","arguments":""}}]},"finish_reason":null}]}`),
+ ¶m,
+ )
+ firstOutput := bytes.Join(firstChunks, nil)
+ if !bytes.Contains(firstOutput, []byte(`"name":"read_file"`)) {
+ t.Fatalf("expected first chunk to start read_file tool block, got %s", string(firstOutput))
+ }
+
+ secondChunks := ConvertOpenAIResponseToClaude(
+ context.Background(),
+ "test-model",
+ originalRequest,
+ nil,
+ []byte(`data: {"id":"chatcmpl_1","model":"test-model","created":1,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"name":null,"arguments":"{\"path\":\"/tmp/a\"}"}}]},"finish_reason":null}]}`),
+ ¶m,
+ )
+ secondOutput := bytes.Join(secondChunks, nil)
+ if bytes.Contains(secondOutput, []byte(`content_block_start`)) {
+ t.Fatalf("did not expect null tool name delta to start a new content block, got %s", string(secondOutput))
+ }
+ if bytes.Contains(secondOutput, []byte(`"name":""`)) {
+ t.Fatalf("did not expect null tool name delta to emit an empty tool name, got %s", string(secondOutput))
+ }
+}
diff --git a/internal/translator/openai/gemini-cli/init.go b/internal/translator/openai/gemini-cli/init.go
index 12aec5ec90..7b52d06dc0 100644
--- a/internal/translator/openai/gemini-cli/init.go
+++ b/internal/translator/openai/gemini-cli/init.go
@@ -1,9 +1,9 @@
package geminiCLI
import (
- . "github.com/router-for-me/CLIProxyAPI/v6/internal/constant"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator"
+ . "github.com/router-for-me/CLIProxyAPI/v7/internal/constant"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/translator"
)
func init() {
diff --git a/internal/translator/openai/gemini-cli/openai_gemini_request.go b/internal/translator/openai/gemini-cli/openai_gemini_request.go
index 847c278f36..c651826669 100644
--- a/internal/translator/openai/gemini-cli/openai_gemini_request.go
+++ b/internal/translator/openai/gemini-cli/openai_gemini_request.go
@@ -6,7 +6,7 @@
package geminiCLI
import (
- . "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/openai/gemini"
+ . "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/openai/gemini"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
diff --git a/internal/translator/openai/gemini-cli/openai_gemini_response.go b/internal/translator/openai/gemini-cli/openai_gemini_response.go
index a7369dbfe9..e54e08fc27 100644
--- a/internal/translator/openai/gemini-cli/openai_gemini_response.go
+++ b/internal/translator/openai/gemini-cli/openai_gemini_response.go
@@ -8,8 +8,8 @@ package geminiCLI
import (
"context"
- translatorcommon "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/common"
- . "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/openai/gemini"
+ translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common"
+ . "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/openai/gemini"
)
// ConvertOpenAIResponseToGeminiCLI converts OpenAI Chat Completions streaming response format to Gemini API format.
diff --git a/internal/translator/openai/gemini/init.go b/internal/translator/openai/gemini/init.go
index 4f056ace9f..24ae281eff 100644
--- a/internal/translator/openai/gemini/init.go
+++ b/internal/translator/openai/gemini/init.go
@@ -1,9 +1,9 @@
package gemini
import (
- . "github.com/router-for-me/CLIProxyAPI/v6/internal/constant"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator"
+ . "github.com/router-for-me/CLIProxyAPI/v7/internal/constant"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/translator"
)
func init() {
diff --git a/internal/translator/openai/gemini/openai_gemini_request.go b/internal/translator/openai/gemini/openai_gemini_request.go
index b4edbb1df6..7369de88df 100644
--- a/internal/translator/openai/gemini/openai_gemini_request.go
+++ b/internal/translator/openai/gemini/openai_gemini_request.go
@@ -11,7 +11,7 @@ import (
"math/big"
"strings"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
diff --git a/internal/translator/openai/gemini/openai_gemini_response.go b/internal/translator/openai/gemini/openai_gemini_response.go
index 092a778eac..439ae8fbd7 100644
--- a/internal/translator/openai/gemini/openai_gemini_response.go
+++ b/internal/translator/openai/gemini/openai_gemini_response.go
@@ -12,7 +12,7 @@ import (
"strconv"
"strings"
- translatorcommon "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/common"
+ translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
diff --git a/internal/translator/openai/openai/chat-completions/init.go b/internal/translator/openai/openai/chat-completions/init.go
index 90fa3dcd90..bfe82cea72 100644
--- a/internal/translator/openai/openai/chat-completions/init.go
+++ b/internal/translator/openai/openai/chat-completions/init.go
@@ -1,9 +1,9 @@
package chat_completions
import (
- . "github.com/router-for-me/CLIProxyAPI/v6/internal/constant"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator"
+ . "github.com/router-for-me/CLIProxyAPI/v7/internal/constant"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/translator"
)
func init() {
diff --git a/internal/translator/openai/openai/responses/init.go b/internal/translator/openai/openai/responses/init.go
index e6f60e0e13..c47081bae3 100644
--- a/internal/translator/openai/openai/responses/init.go
+++ b/internal/translator/openai/openai/responses/init.go
@@ -1,9 +1,9 @@
package responses
import (
- . "github.com/router-for-me/CLIProxyAPI/v6/internal/constant"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator"
+ . "github.com/router-for-me/CLIProxyAPI/v7/internal/constant"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/translator"
)
func init() {
diff --git a/internal/translator/openai/openai/responses/openai_openai-responses_request.go b/internal/translator/openai/openai/responses/openai_openai-responses_request.go
index 2366c9c37b..15acf7cdb4 100644
--- a/internal/translator/openai/openai/responses/openai_openai-responses_request.go
+++ b/internal/translator/openai/openai/responses/openai_openai-responses_request.go
@@ -57,11 +57,72 @@ func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu
// Convert input array to messages
if input := root.Get("input"); input.Exists() && input.IsArray() {
- input.ForEach(func(_, item gjson.Result) bool {
+ inputItems := input.Array()
+ outputCallIDs := make(map[string]struct{})
+ for _, item := range inputItems {
+ if item.Get("type").String() != "function_call_output" {
+ continue
+ }
+ callID := strings.TrimSpace(item.Get("call_id").String())
+ if callID == "" {
+ continue
+ }
+ outputCallIDs[callID] = struct{}{}
+ }
+
+ pendingToolCalls := make([]interface{}, 0)
+ pendingToolCallIDs := make([]string, 0)
+ awaitingToolOutputs := make(map[string]struct{})
+ deferredMessages := make([][]byte, 0)
+
+ flushPendingToolCalls := func() {
+ if len(pendingToolCalls) == 0 {
+ return
+ }
+ assistantMessage := []byte(`{"role":"assistant","tool_calls":[]}`)
+ assistantMessage, _ = sjson.SetBytes(assistantMessage, "tool_calls", pendingToolCalls)
+ out, _ = sjson.SetRawBytes(out, "messages.-1", assistantMessage)
+ for _, id := range pendingToolCallIDs {
+ if strings.TrimSpace(id) == "" {
+ continue
+ }
+ awaitingToolOutputs[id] = struct{}{}
+ }
+ pendingToolCalls = pendingToolCalls[:0]
+ pendingToolCallIDs = pendingToolCallIDs[:0]
+ }
+ flushDeferredMessages := func() {
+ for _, message := range deferredMessages {
+ out, _ = sjson.SetRawBytes(out, "messages.-1", message)
+ }
+ deferredMessages = deferredMessages[:0]
+ }
+ hasAwaitingToolOutput := func() bool {
+ for id := range awaitingToolOutputs {
+ if _, ok := outputCallIDs[id]; ok {
+ return true
+ }
+ }
+ return false
+ }
+ appendRegularMessage := func(message []byte) {
+ // Keep tool-call adjacency strict for providers that require
+ // assistant(tool_calls) -> tool(tool_call_id) with no message in between.
+ if hasAwaitingToolOutput() {
+ deferredMessages = append(deferredMessages, message)
+ return
+ }
+ out, _ = sjson.SetRawBytes(out, "messages.-1", message)
+ }
+
+ for _, item := range inputItems {
itemType := item.Get("type").String()
if itemType == "" && item.Get("role").String() != "" {
itemType = "message"
}
+ if itemType != "function_call" {
+ flushPendingToolCalls()
+ }
switch itemType {
case "message", "":
@@ -109,12 +170,10 @@ func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu
message, _ = sjson.SetBytes(message, "content", content.String())
}
- out, _ = sjson.SetRawBytes(out, "messages.-1", message)
+ appendRegularMessage(message)
case "function_call":
- // Handle function call conversion to assistant message with tool_calls
- assistantMessage := []byte(`{"role":"assistant","tool_calls":[]}`)
-
+ // Buffer consecutive function calls and emit them as one assistant message.
toolCall := []byte(`{"id":"","type":"function","function":{"name":"","arguments":""}}`)
if callId := item.Get("call_id"); callId.Exists() {
@@ -128,16 +187,19 @@ func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu
if arguments := item.Get("arguments"); arguments.Exists() {
toolCall, _ = sjson.SetBytes(toolCall, "function.arguments", arguments.String())
}
-
- assistantMessage, _ = sjson.SetRawBytes(assistantMessage, "tool_calls.0", toolCall)
- out, _ = sjson.SetRawBytes(out, "messages.-1", assistantMessage)
+ pendingToolCalls = append(pendingToolCalls, gjson.ParseBytes(toolCall).Value())
+ if callID := strings.TrimSpace(item.Get("call_id").String()); callID != "" {
+ pendingToolCallIDs = append(pendingToolCallIDs, callID)
+ }
case "function_call_output":
// Handle function call output conversion to tool message
toolMessage := []byte(`{"role":"tool","tool_call_id":"","content":""}`)
+ callID := ""
if callId := item.Get("call_id"); callId.Exists() {
- toolMessage, _ = sjson.SetBytes(toolMessage, "tool_call_id", callId.String())
+ callID = strings.TrimSpace(callId.String())
+ toolMessage, _ = sjson.SetBytes(toolMessage, "tool_call_id", callID)
}
if output := item.Get("output"); output.Exists() {
@@ -145,10 +207,17 @@ func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu
}
out, _ = sjson.SetRawBytes(out, "messages.-1", toolMessage)
+ if callID != "" {
+ delete(awaitingToolOutputs, callID)
+ }
+ if len(awaitingToolOutputs) == 0 && len(deferredMessages) > 0 {
+ flushDeferredMessages()
+ }
}
- return true
- })
+ }
+ flushPendingToolCalls()
+ flushDeferredMessages()
} else if input.Type == gjson.String {
msg := []byte(`{}`)
msg, _ = sjson.SetBytes(msg, "role", "user")
diff --git a/internal/translator/openai/openai/responses/openai_openai-responses_request_test.go b/internal/translator/openai/openai/responses/openai_openai-responses_request_test.go
new file mode 100644
index 0000000000..9dd0e288b2
--- /dev/null
+++ b/internal/translator/openai/openai/responses/openai_openai-responses_request_test.go
@@ -0,0 +1,124 @@
+package responses
+
+import (
+ "bytes"
+ "encoding/json"
+ "testing"
+
+ "github.com/tidwall/gjson"
+)
+
+func prettyJSONForTest(raw []byte) string {
+ if !gjson.ValidBytes(raw) {
+ return string(raw)
+ }
+ var out bytes.Buffer
+ if err := json.Indent(&out, raw, "", " "); err != nil {
+ return string(raw)
+ }
+ return out.String()
+}
+
+func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_MergeConsecutiveFunctionCalls(t *testing.T) {
+ raw := []byte(`{
+ "input": [
+ {"type":"function_call","call_id":"exec_command:0","name":"exec_command","arguments":"{\"cmd\":\"ls\"}"},
+ {"type":"function_call","call_id":"exec_command:1","name":"exec_command","arguments":"{\"cmd\":\"pwd\"}"},
+ {"type":"function_call_output","call_id":"exec_command:0","output":"ok0"},
+ {"type":"function_call_output","call_id":"exec_command:1","output":"ok1"}
+ ]
+ }`)
+ t.Logf("input json:\n%s", prettyJSONForTest(raw))
+
+ out := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("kimi-k2.6", raw, true)
+ t.Logf("output json:\n%s", prettyJSONForTest(out))
+
+ msgs := gjson.GetBytes(out, "messages")
+ if !msgs.Exists() || !msgs.IsArray() {
+ t.Fatalf("messages should be an array")
+ }
+ if got := len(msgs.Array()); got != 3 {
+ t.Fatalf("messages count = %d, want %d", got, 3)
+ }
+
+ if got := gjson.GetBytes(out, "messages.0.role").String(); got != "assistant" {
+ t.Fatalf("messages.0.role = %q, want %q", got, "assistant")
+ }
+ if got := len(gjson.GetBytes(out, "messages.0.tool_calls").Array()); got != 2 {
+ t.Fatalf("messages.0.tool_calls length = %d, want %d", got, 2)
+ }
+ if got := gjson.GetBytes(out, "messages.0.tool_calls.0.id").String(); got != "exec_command:0" {
+ t.Fatalf("messages.0.tool_calls.0.id = %q, want %q", got, "exec_command:0")
+ }
+ if got := gjson.GetBytes(out, "messages.0.tool_calls.1.id").String(); got != "exec_command:1" {
+ t.Fatalf("messages.0.tool_calls.1.id = %q, want %q", got, "exec_command:1")
+ }
+
+ if got := gjson.GetBytes(out, "messages.1.tool_call_id").String(); got != "exec_command:0" {
+ t.Fatalf("messages.1.tool_call_id = %q, want %q", got, "exec_command:0")
+ }
+ if got := gjson.GetBytes(out, "messages.2.tool_call_id").String(); got != "exec_command:1" {
+ t.Fatalf("messages.2.tool_call_id = %q, want %q", got, "exec_command:1")
+ }
+}
+
+func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_SplitFunctionCallsWhenInterrupted(t *testing.T) {
+ raw := []byte(`{
+ "input": [
+ {"type":"function_call","call_id":"call_a","name":"tool_a","arguments":"{}"},
+ {"type":"message","role":"user","content":"next"},
+ {"type":"function_call","call_id":"call_b","name":"tool_b","arguments":"{}"}
+ ]
+ }`)
+ t.Logf("input json:\n%s", prettyJSONForTest(raw))
+
+ out := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("kimi-k2.6", raw, false)
+ t.Logf("output json:\n%s", prettyJSONForTest(out))
+
+ if got := len(gjson.GetBytes(out, "messages").Array()); got != 3 {
+ t.Fatalf("messages count = %d, want %d", got, 3)
+ }
+ if got := gjson.GetBytes(out, "messages.0.tool_calls.0.id").String(); got != "call_a" {
+ t.Fatalf("messages.0.tool_calls.0.id = %q, want %q", got, "call_a")
+ }
+ if got := gjson.GetBytes(out, "messages.2.tool_calls.0.id").String(); got != "call_b" {
+ t.Fatalf("messages.2.tool_calls.0.id = %q, want %q", got, "call_b")
+ }
+}
+
+func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_DefersMessageUntilToolOutput(t *testing.T) {
+ raw := []byte(`{
+ "input": [
+ {"type":"function_call","call_id":"call_x","name":"exec_command","arguments":"{\"cmd\":\"echo hi\"}"},
+ {"type":"message","role":"user","content":"Approved command prefix saved"},
+ {"type":"function_call_output","call_id":"call_x","output":"ok"},
+ {"type":"message","role":"user","content":"next"}
+ ]
+ }`)
+ t.Logf("input json:\n%s", prettyJSONForTest(raw))
+
+ out := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("kimi-k2.6", raw, true)
+ t.Logf("output json:\n%s", prettyJSONForTest(out))
+
+ if got := len(gjson.GetBytes(out, "messages").Array()); got != 4 {
+ t.Fatalf("messages count = %d, want %d", got, 4)
+ }
+ if got := gjson.GetBytes(out, "messages.0.role").String(); got != "assistant" {
+ t.Fatalf("messages.0.role = %q, want %q", got, "assistant")
+ }
+ if got := gjson.GetBytes(out, "messages.1.role").String(); got != "tool" {
+ t.Fatalf("messages.1.role = %q, want %q", got, "tool")
+ }
+ if got := gjson.GetBytes(out, "messages.1.tool_call_id").String(); got != "call_x" {
+ t.Fatalf("messages.1.tool_call_id = %q, want %q", got, "call_x")
+ }
+ if got := gjson.GetBytes(out, "messages.2.role").String(); got != "user" {
+ t.Fatalf("messages.2.role = %q, want %q", got, "user")
+ }
+ if got := gjson.GetBytes(out, "messages.2.content").String(); got != "Approved command prefix saved" {
+ t.Fatalf("messages.2.content = %q, want %q", got, "Approved command prefix saved")
+ }
+ if got := gjson.GetBytes(out, "messages.3.content").String(); got != "next" {
+ t.Fatalf("messages.3.content = %q, want %q", got, "next")
+ }
+}
diff --git a/internal/translator/openai/openai/responses/openai_openai-responses_response.go b/internal/translator/openai/openai/responses/openai_openai-responses_response.go
index 8a44aede44..8895b68445 100644
--- a/internal/translator/openai/openai/responses/openai_openai-responses_response.go
+++ b/internal/translator/openai/openai/responses/openai_openai-responses_response.go
@@ -9,7 +9,7 @@ import (
"sync/atomic"
"time"
- translatorcommon "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/common"
+ translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
diff --git a/internal/translator/translator/translator.go b/internal/translator/translator/translator.go
index ab3f68a99d..88766a83bb 100644
--- a/internal/translator/translator/translator.go
+++ b/internal/translator/translator/translator.go
@@ -7,8 +7,8 @@ package translator
import (
"context"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
- sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
+ sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
)
// registry holds the default translator registry instance.
diff --git a/internal/tui/app.go b/internal/tui/app.go
index b9ee9e1a3a..c0a7c3a8ab 100644
--- a/internal/tui/app.go
+++ b/internal/tui/app.go
@@ -18,7 +18,6 @@ const (
tabAuthFiles
tabAPIKeys
tabOAuth
- tabUsage
tabLogs
)
@@ -40,7 +39,6 @@ type App struct {
auth authTabModel
keys keysTabModel
oauth oauthTabModel
- usage usageTabModel
logs logsTabModel
client *Client
@@ -50,7 +48,7 @@ type App struct {
ready bool
// Track which tabs have been initialized (fetched data)
- initialized [7]bool
+ initialized [6]bool
}
type authConnectMsg struct {
@@ -81,10 +79,9 @@ func NewApp(port int, secretKey string, hook *LogHook) App {
auth: newAuthTabModel(client),
keys: newKeysTabModel(client),
oauth: newOAuthTabModel(client),
- usage: newUsageTabModel(client),
logs: newLogsTabModel(client, hook),
client: client,
- initialized: [7]bool{
+ initialized: [6]bool{
tabDashboard: true,
tabLogs: true,
},
@@ -92,7 +89,7 @@ func NewApp(port int, secretKey string, hook *LogHook) App {
app.refreshTabs()
if authRequired {
- app.initialized = [7]bool{}
+ app.initialized = [6]bool{}
}
app.setAuthInputPrompt()
return app
@@ -128,7 +125,6 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
a.auth.SetSize(contentW, contentH)
a.keys.SetSize(contentW, contentH)
a.oauth.SetSize(contentW, contentH)
- a.usage.SetSize(contentW, contentH)
a.logs.SetSize(contentW, contentH)
return a, nil
@@ -142,7 +138,7 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
a.authenticated = true
a.logsEnabled = a.standalone || isLogsEnabledFromConfig(msg.cfg)
a.refreshTabs()
- a.initialized = [7]bool{}
+ a.initialized = [6]bool{}
a.initialized[tabDashboard] = true
cmds := []tea.Cmd{a.dashboard.Init()}
if a.logsEnabled {
@@ -258,8 +254,6 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
a.keys, cmd = a.keys.Update(msg)
case tabOAuth:
a.oauth, cmd = a.oauth.Update(msg)
- case tabUsage:
- a.usage, cmd = a.usage.Update(msg)
case tabLogs:
a.logs, cmd = a.logs.Update(msg)
}
@@ -322,8 +316,6 @@ func (a *App) initTabIfNeeded(_ int) tea.Cmd {
return a.keys.Init()
case tabOAuth:
return a.oauth.Init()
- case tabUsage:
- return a.usage.Init()
case tabLogs:
if !a.logsEnabled {
return nil
@@ -360,8 +352,6 @@ func (a App) View() string {
sb.WriteString(a.keys.View())
case tabOAuth:
sb.WriteString(a.oauth.View())
- case tabUsage:
- sb.WriteString(a.usage.View())
case tabLogs:
if a.logsEnabled {
sb.WriteString(a.logs.View())
@@ -529,10 +519,6 @@ func (a App) broadcastToAllTabs(msg tea.Msg) (tea.Model, tea.Cmd) {
if cmd != nil {
cmds = append(cmds, cmd)
}
- a.usage, cmd = a.usage.Update(msg)
- if cmd != nil {
- cmds = append(cmds, cmd)
- }
a.logs, cmd = a.logs.Update(msg)
if cmd != nil {
cmds = append(cmds, cmd)
diff --git a/internal/tui/client.go b/internal/tui/client.go
index 6f75d6befc..747f30b985 100644
--- a/internal/tui/client.go
+++ b/internal/tui/client.go
@@ -140,11 +140,6 @@ func (c *Client) PutConfigYAML(yamlContent string) error {
return err
}
-// GetUsage fetches usage statistics.
-func (c *Client) GetUsage() (map[string]any, error) {
- return c.getJSON("/v0/management/usage")
-}
-
// GetAuthFiles lists auth credential files.
// API returns {"files": [...]}.
func (c *Client) GetAuthFiles() ([]map[string]any, error) {
diff --git a/internal/tui/dashboard.go b/internal/tui/dashboard.go
index 8561fe9c5b..99b5409c2e 100644
--- a/internal/tui/dashboard.go
+++ b/internal/tui/dashboard.go
@@ -22,14 +22,12 @@ type dashboardModel struct {
// Cached data for re-rendering on locale change
lastConfig map[string]any
- lastUsage map[string]any
lastAuthFiles []map[string]any
lastAPIKeys []string
}
type dashboardDataMsg struct {
config map[string]any
- usage map[string]any
authFiles []map[string]any
apiKeys []string
err error
@@ -47,25 +45,24 @@ func (m dashboardModel) Init() tea.Cmd {
func (m dashboardModel) fetchData() tea.Msg {
cfg, cfgErr := m.client.GetConfig()
- usage, usageErr := m.client.GetUsage()
authFiles, authErr := m.client.GetAuthFiles()
apiKeys, keysErr := m.client.GetAPIKeys()
var err error
- for _, e := range []error{cfgErr, usageErr, authErr, keysErr} {
+ for _, e := range []error{cfgErr, authErr, keysErr} {
if e != nil {
err = e
break
}
}
- return dashboardDataMsg{config: cfg, usage: usage, authFiles: authFiles, apiKeys: apiKeys, err: err}
+ return dashboardDataMsg{config: cfg, authFiles: authFiles, apiKeys: apiKeys, err: err}
}
func (m dashboardModel) Update(msg tea.Msg) (dashboardModel, tea.Cmd) {
switch msg := msg.(type) {
case localeChangedMsg:
// Re-render immediately with cached data using new locale
- m.content = m.renderDashboard(m.lastConfig, m.lastUsage, m.lastAuthFiles, m.lastAPIKeys)
+ m.content = m.renderDashboard(m.lastConfig, m.lastAuthFiles, m.lastAPIKeys)
m.viewport.SetContent(m.content)
// Also fetch fresh data in background
return m, m.fetchData
@@ -78,11 +75,10 @@ func (m dashboardModel) Update(msg tea.Msg) (dashboardModel, tea.Cmd) {
m.err = nil
// Cache data for locale switching
m.lastConfig = msg.config
- m.lastUsage = msg.usage
m.lastAuthFiles = msg.authFiles
m.lastAPIKeys = msg.apiKeys
- m.content = m.renderDashboard(msg.config, msg.usage, msg.authFiles, msg.apiKeys)
+ m.content = m.renderDashboard(msg.config, msg.authFiles, msg.apiKeys)
}
m.viewport.SetContent(m.content)
return m, nil
@@ -121,7 +117,7 @@ func (m dashboardModel) View() string {
return m.viewport.View()
}
-func (m dashboardModel) renderDashboard(cfg, usage map[string]any, authFiles []map[string]any, apiKeys []string) string {
+func (m dashboardModel) renderDashboard(cfg map[string]any, authFiles []map[string]any, apiKeys []string) string {
var sb strings.Builder
sb.WriteString(titleStyle.Render(T("dashboard_title")))
@@ -138,7 +134,7 @@ func (m dashboardModel) renderDashboard(cfg, usage map[string]any, authFiles []m
// ━━━ Stats Cards ━━━
cardWidth := 25
if m.width > 0 {
- cardWidth = (m.width - 6) / 4
+ cardWidth = (m.width - 2) / 2
if cardWidth < 18 {
cardWidth = 18
}
@@ -173,34 +169,7 @@ func (m dashboardModel) renderDashboard(cfg, usage map[string]any, authFiles []m
lipgloss.NewStyle().Foreground(colorMuted).Render(fmt.Sprintf("%s (%d %s)", T("auth_files_label"), activeAuth, T("active_suffix"))),
))
- // Card 3: Total Requests
- totalReqs := int64(0)
- successReqs := int64(0)
- failedReqs := int64(0)
- totalTokens := int64(0)
- if usage != nil {
- if usageMap, ok := usage["usage"].(map[string]any); ok {
- totalReqs = int64(getFloat(usageMap, "total_requests"))
- successReqs = int64(getFloat(usageMap, "success_count"))
- failedReqs = int64(getFloat(usageMap, "failure_count"))
- totalTokens = int64(getFloat(usageMap, "total_tokens"))
- }
- }
- card3 := cardStyle.Render(fmt.Sprintf(
- "%s\n%s",
- lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("214")).Render(fmt.Sprintf("📈 %d", totalReqs)),
- lipgloss.NewStyle().Foreground(colorMuted).Render(fmt.Sprintf("%s (✓%d ✗%d)", T("total_requests"), successReqs, failedReqs)),
- ))
-
- // Card 4: Total Tokens
- tokenStr := formatLargeNumber(totalTokens)
- card4 := cardStyle.Render(fmt.Sprintf(
- "%s\n%s",
- lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("170")).Render(fmt.Sprintf("🔤 %s", tokenStr)),
- lipgloss.NewStyle().Foreground(colorMuted).Render(T("total_tokens")),
- ))
-
- sb.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, card1, " ", card2, " ", card3, " ", card4))
+ sb.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, card1, " ", card2))
sb.WriteString("\n\n")
// ━━━ Current Config ━━━
@@ -258,38 +227,6 @@ func (m dashboardModel) renderDashboard(cfg, usage map[string]any, authFiles []m
sb.WriteString("\n")
- // ━━━ Per-Model Usage ━━━
- if usage != nil {
- if usageMap, ok := usage["usage"].(map[string]any); ok {
- if apis, ok := usageMap["apis"].(map[string]any); ok && len(apis) > 0 {
- sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorHighlight).Render(T("model_stats")))
- sb.WriteString("\n")
- sb.WriteString(strings.Repeat("─", minInt(m.width, 60)))
- sb.WriteString("\n")
-
- header := fmt.Sprintf(" %-40s %10s %12s", T("model"), T("requests"), T("tokens"))
- sb.WriteString(tableHeaderStyle.Render(header))
- sb.WriteString("\n")
-
- for _, apiSnap := range apis {
- if apiMap, ok := apiSnap.(map[string]any); ok {
- if models, ok := apiMap["models"].(map[string]any); ok {
- for model, v := range models {
- if stats, ok := v.(map[string]any); ok {
- reqs := int64(getFloat(stats, "total_requests"))
- toks := int64(getFloat(stats, "total_tokens"))
- row := fmt.Sprintf(" %-40s %10d %12s", truncate(model, 40), reqs, formatLargeNumber(toks))
- sb.WriteString(tableCellStyle.Render(row))
- sb.WriteString("\n")
- }
- }
- }
- }
- }
- }
- }
- }
-
return sb.String()
}
diff --git a/internal/tui/i18n.go b/internal/tui/i18n.go
index f6a33ca481..a4c0ac1658 100644
--- a/internal/tui/i18n.go
+++ b/internal/tui/i18n.go
@@ -50,8 +50,8 @@ var locales = map[string]map[string]string{
// ──────────────────────────────────────────
// Tab names
// ──────────────────────────────────────────
-var zhTabNames = []string{"仪表盘", "配置", "认证文件", "API 密钥", "OAuth", "使用统计", "日志"}
-var enTabNames = []string{"Dashboard", "Config", "Auth Files", "API Keys", "OAuth", "Usage", "Logs"}
+var zhTabNames = []string{"仪表盘", "配置", "认证文件", "API 密钥", "OAuth", "日志"}
+var enTabNames = []string{"Dashboard", "Config", "Auth Files", "API Keys", "OAuth", "Logs"}
// TabNames returns tab names in the current locale.
func TabNames() []string {
diff --git a/internal/tui/usage_tab.go b/internal/tui/usage_tab.go
deleted file mode 100644
index 6b9fef5e11..0000000000
--- a/internal/tui/usage_tab.go
+++ /dev/null
@@ -1,418 +0,0 @@
-package tui
-
-import (
- "fmt"
- "sort"
- "strings"
-
- "github.com/charmbracelet/bubbles/viewport"
- tea "github.com/charmbracelet/bubbletea"
- "github.com/charmbracelet/lipgloss"
-)
-
-// usageTabModel displays usage statistics with charts and breakdowns.
-type usageTabModel struct {
- client *Client
- viewport viewport.Model
- usage map[string]any
- err error
- width int
- height int
- ready bool
-}
-
-type usageDataMsg struct {
- usage map[string]any
- err error
-}
-
-func newUsageTabModel(client *Client) usageTabModel {
- return usageTabModel{
- client: client,
- }
-}
-
-func (m usageTabModel) Init() tea.Cmd {
- return m.fetchData
-}
-
-func (m usageTabModel) fetchData() tea.Msg {
- usage, err := m.client.GetUsage()
- return usageDataMsg{usage: usage, err: err}
-}
-
-func (m usageTabModel) Update(msg tea.Msg) (usageTabModel, tea.Cmd) {
- switch msg := msg.(type) {
- case localeChangedMsg:
- m.viewport.SetContent(m.renderContent())
- return m, nil
- case usageDataMsg:
- if msg.err != nil {
- m.err = msg.err
- } else {
- m.err = nil
- m.usage = msg.usage
- }
- m.viewport.SetContent(m.renderContent())
- return m, nil
-
- case tea.KeyMsg:
- if msg.String() == "r" {
- return m, m.fetchData
- }
- var cmd tea.Cmd
- m.viewport, cmd = m.viewport.Update(msg)
- return m, cmd
- }
-
- var cmd tea.Cmd
- m.viewport, cmd = m.viewport.Update(msg)
- return m, cmd
-}
-
-func (m *usageTabModel) SetSize(w, h int) {
- m.width = w
- m.height = h
- if !m.ready {
- m.viewport = viewport.New(w, h)
- m.viewport.SetContent(m.renderContent())
- m.ready = true
- } else {
- m.viewport.Width = w
- m.viewport.Height = h
- }
-}
-
-func (m usageTabModel) View() string {
- if !m.ready {
- return T("loading")
- }
- return m.viewport.View()
-}
-
-func (m usageTabModel) renderContent() string {
- var sb strings.Builder
-
- sb.WriteString(titleStyle.Render(T("usage_title")))
- sb.WriteString("\n")
- sb.WriteString(helpStyle.Render(T("usage_help")))
- sb.WriteString("\n\n")
-
- if m.err != nil {
- sb.WriteString(errorStyle.Render("⚠ Error: " + m.err.Error()))
- sb.WriteString("\n")
- return sb.String()
- }
-
- if m.usage == nil {
- sb.WriteString(subtitleStyle.Render(T("usage_no_data")))
- sb.WriteString("\n")
- return sb.String()
- }
-
- usageMap, _ := m.usage["usage"].(map[string]any)
- if usageMap == nil {
- sb.WriteString(subtitleStyle.Render(T("usage_no_data")))
- sb.WriteString("\n")
- return sb.String()
- }
-
- totalReqs := int64(getFloat(usageMap, "total_requests"))
- successCnt := int64(getFloat(usageMap, "success_count"))
- failureCnt := int64(getFloat(usageMap, "failure_count"))
- totalTokens := int64(getFloat(usageMap, "total_tokens"))
-
- // ━━━ Overview Cards ━━━
- cardWidth := 20
- if m.width > 0 {
- cardWidth = (m.width - 6) / 4
- if cardWidth < 16 {
- cardWidth = 16
- }
- }
- cardStyle := lipgloss.NewStyle().
- Border(lipgloss.RoundedBorder()).
- BorderForeground(lipgloss.Color("240")).
- Padding(0, 1).
- Width(cardWidth).
- Height(3)
-
- // Total Requests
- card1 := cardStyle.Copy().BorderForeground(lipgloss.Color("111")).Render(fmt.Sprintf(
- "%s\n%s\n%s",
- lipgloss.NewStyle().Foreground(colorMuted).Render(T("usage_total_reqs")),
- lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("111")).Render(fmt.Sprintf("%d", totalReqs)),
- lipgloss.NewStyle().Foreground(colorMuted).Render(fmt.Sprintf("● %s: %d ● %s: %d", T("usage_success"), successCnt, T("usage_failure"), failureCnt)),
- ))
-
- // Total Tokens
- card2 := cardStyle.Copy().BorderForeground(lipgloss.Color("214")).Render(fmt.Sprintf(
- "%s\n%s\n%s",
- lipgloss.NewStyle().Foreground(colorMuted).Render(T("usage_total_tokens")),
- lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("214")).Render(formatLargeNumber(totalTokens)),
- lipgloss.NewStyle().Foreground(colorMuted).Render(fmt.Sprintf("%s: %s", T("usage_total_token_l"), formatLargeNumber(totalTokens))),
- ))
-
- // RPM
- rpm := float64(0)
- if totalReqs > 0 {
- if rByH, ok := usageMap["requests_by_hour"].(map[string]any); ok && len(rByH) > 0 {
- rpm = float64(totalReqs) / float64(len(rByH)) / 60.0
- }
- }
- card3 := cardStyle.Copy().BorderForeground(lipgloss.Color("76")).Render(fmt.Sprintf(
- "%s\n%s\n%s",
- lipgloss.NewStyle().Foreground(colorMuted).Render(T("usage_rpm")),
- lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("76")).Render(fmt.Sprintf("%.2f", rpm)),
- lipgloss.NewStyle().Foreground(colorMuted).Render(fmt.Sprintf("%s: %d", T("usage_total_reqs"), totalReqs)),
- ))
-
- // TPM
- tpm := float64(0)
- if totalTokens > 0 {
- if tByH, ok := usageMap["tokens_by_hour"].(map[string]any); ok && len(tByH) > 0 {
- tpm = float64(totalTokens) / float64(len(tByH)) / 60.0
- }
- }
- card4 := cardStyle.Copy().BorderForeground(lipgloss.Color("170")).Render(fmt.Sprintf(
- "%s\n%s\n%s",
- lipgloss.NewStyle().Foreground(colorMuted).Render(T("usage_tpm")),
- lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("170")).Render(fmt.Sprintf("%.2f", tpm)),
- lipgloss.NewStyle().Foreground(colorMuted).Render(fmt.Sprintf("%s: %s", T("usage_total_tokens"), formatLargeNumber(totalTokens))),
- ))
-
- sb.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, card1, " ", card2, " ", card3, " ", card4))
- sb.WriteString("\n\n")
-
- // ━━━ Requests by Hour (ASCII bar chart) ━━━
- if rByH, ok := usageMap["requests_by_hour"].(map[string]any); ok && len(rByH) > 0 {
- sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorHighlight).Render(T("usage_req_by_hour")))
- sb.WriteString("\n")
- sb.WriteString(strings.Repeat("─", minInt(m.width, 60)))
- sb.WriteString("\n")
- sb.WriteString(renderBarChart(rByH, m.width-6, lipgloss.Color("111")))
- sb.WriteString("\n")
- }
-
- // ━━━ Tokens by Hour ━━━
- if tByH, ok := usageMap["tokens_by_hour"].(map[string]any); ok && len(tByH) > 0 {
- sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorHighlight).Render(T("usage_tok_by_hour")))
- sb.WriteString("\n")
- sb.WriteString(strings.Repeat("─", minInt(m.width, 60)))
- sb.WriteString("\n")
- sb.WriteString(renderBarChart(tByH, m.width-6, lipgloss.Color("214")))
- sb.WriteString("\n")
- }
-
- // ━━━ Requests by Day ━━━
- if rByD, ok := usageMap["requests_by_day"].(map[string]any); ok && len(rByD) > 0 {
- sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorHighlight).Render(T("usage_req_by_day")))
- sb.WriteString("\n")
- sb.WriteString(strings.Repeat("─", minInt(m.width, 60)))
- sb.WriteString("\n")
- sb.WriteString(renderBarChart(rByD, m.width-6, lipgloss.Color("76")))
- sb.WriteString("\n")
- }
-
- // ━━━ API Detail Stats ━━━
- if apis, ok := usageMap["apis"].(map[string]any); ok && len(apis) > 0 {
- sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorHighlight).Render(T("usage_api_detail")))
- sb.WriteString("\n")
- sb.WriteString(strings.Repeat("─", minInt(m.width, 80)))
- sb.WriteString("\n")
-
- header := fmt.Sprintf(" %-30s %10s %12s", "API", T("requests"), T("tokens"))
- sb.WriteString(tableHeaderStyle.Render(header))
- sb.WriteString("\n")
-
- for apiName, apiSnap := range apis {
- if apiMap, ok := apiSnap.(map[string]any); ok {
- apiReqs := int64(getFloat(apiMap, "total_requests"))
- apiToks := int64(getFloat(apiMap, "total_tokens"))
-
- row := fmt.Sprintf(" %-30s %10d %12s",
- truncate(maskKey(apiName), 30), apiReqs, formatLargeNumber(apiToks))
- sb.WriteString(lipgloss.NewStyle().Bold(true).Render(row))
- sb.WriteString("\n")
-
- // Per-model breakdown
- if models, ok := apiMap["models"].(map[string]any); ok {
- for model, v := range models {
- if stats, ok := v.(map[string]any); ok {
- mReqs := int64(getFloat(stats, "total_requests"))
- mToks := int64(getFloat(stats, "total_tokens"))
- mRow := fmt.Sprintf(" ├─ %-28s %10d %12s",
- truncate(model, 28), mReqs, formatLargeNumber(mToks))
- sb.WriteString(tableCellStyle.Render(mRow))
- sb.WriteString("\n")
-
- // Token type breakdown from details
- sb.WriteString(m.renderTokenBreakdown(stats))
-
- // Latency breakdown from details
- sb.WriteString(m.renderLatencyBreakdown(stats))
- }
- }
- }
- }
- }
- }
-
- sb.WriteString("\n")
- return sb.String()
-}
-
-// renderTokenBreakdown aggregates input/output/cached/reasoning tokens from model details.
-func (m usageTabModel) renderTokenBreakdown(modelStats map[string]any) string {
- details, ok := modelStats["details"]
- if !ok {
- return ""
- }
- detailList, ok := details.([]any)
- if !ok || len(detailList) == 0 {
- return ""
- }
-
- var inputTotal, outputTotal, cachedTotal, reasoningTotal int64
- for _, d := range detailList {
- dm, ok := d.(map[string]any)
- if !ok {
- continue
- }
- tokens, ok := dm["tokens"].(map[string]any)
- if !ok {
- continue
- }
- inputTotal += int64(getFloat(tokens, "input_tokens"))
- outputTotal += int64(getFloat(tokens, "output_tokens"))
- cachedTotal += int64(getFloat(tokens, "cached_tokens"))
- reasoningTotal += int64(getFloat(tokens, "reasoning_tokens"))
- }
-
- if inputTotal == 0 && outputTotal == 0 && cachedTotal == 0 && reasoningTotal == 0 {
- return ""
- }
-
- parts := []string{}
- if inputTotal > 0 {
- parts = append(parts, fmt.Sprintf("%s:%s", T("usage_input"), formatLargeNumber(inputTotal)))
- }
- if outputTotal > 0 {
- parts = append(parts, fmt.Sprintf("%s:%s", T("usage_output"), formatLargeNumber(outputTotal)))
- }
- if cachedTotal > 0 {
- parts = append(parts, fmt.Sprintf("%s:%s", T("usage_cached"), formatLargeNumber(cachedTotal)))
- }
- if reasoningTotal > 0 {
- parts = append(parts, fmt.Sprintf("%s:%s", T("usage_reasoning"), formatLargeNumber(reasoningTotal)))
- }
-
- return fmt.Sprintf(" │ %s\n",
- lipgloss.NewStyle().Foreground(colorMuted).Render(strings.Join(parts, " ")))
-}
-
-// renderLatencyBreakdown aggregates latency_ms from model details and displays avg/min/max.
-func (m usageTabModel) renderLatencyBreakdown(modelStats map[string]any) string {
- details, ok := modelStats["details"]
- if !ok {
- return ""
- }
- detailList, ok := details.([]any)
- if !ok || len(detailList) == 0 {
- return ""
- }
-
- var totalLatency int64
- var count int
- var minLatency, maxLatency int64
- first := true
-
- for _, d := range detailList {
- dm, ok := d.(map[string]any)
- if !ok {
- continue
- }
- latencyMs := int64(getFloat(dm, "latency_ms"))
- if latencyMs <= 0 {
- continue
- }
- totalLatency += latencyMs
- count++
- if first {
- minLatency = latencyMs
- maxLatency = latencyMs
- first = false
- } else {
- if latencyMs < minLatency {
- minLatency = latencyMs
- }
- if latencyMs > maxLatency {
- maxLatency = latencyMs
- }
- }
- }
-
- if count == 0 {
- return ""
- }
-
- avgLatency := totalLatency / int64(count)
- return fmt.Sprintf(" │ %s: avg %dms min %dms max %dms\n",
- lipgloss.NewStyle().Foreground(colorMuted).Render(T("usage_time")),
- avgLatency, minLatency, maxLatency)
-}
-
-// renderBarChart renders a simple ASCII horizontal bar chart.
-func renderBarChart(data map[string]any, maxBarWidth int, barColor lipgloss.Color) string {
- if maxBarWidth < 10 {
- maxBarWidth = 10
- }
-
- // Sort keys
- keys := make([]string, 0, len(data))
- for k := range data {
- keys = append(keys, k)
- }
- sort.Strings(keys)
-
- // Find max value
- maxVal := float64(0)
- for _, k := range keys {
- v := getFloat(data, k)
- if v > maxVal {
- maxVal = v
- }
- }
- if maxVal == 0 {
- return ""
- }
-
- barStyle := lipgloss.NewStyle().Foreground(barColor)
- var sb strings.Builder
-
- labelWidth := 12
- barAvail := maxBarWidth - labelWidth - 12
- if barAvail < 5 {
- barAvail = 5
- }
-
- for _, k := range keys {
- v := getFloat(data, k)
- barLen := int(v / maxVal * float64(barAvail))
- if barLen < 1 && v > 0 {
- barLen = 1
- }
- bar := strings.Repeat("█", barLen)
- label := k
- if len(label) > labelWidth {
- label = label[:labelWidth]
- }
- sb.WriteString(fmt.Sprintf(" %-*s %s %s\n",
- labelWidth, label,
- barStyle.Render(bar),
- lipgloss.NewStyle().Foreground(colorMuted).Render(fmt.Sprintf("%.0f", v)),
- ))
- }
-
- return sb.String()
-}
diff --git a/internal/tui/usage_tab_test.go b/internal/tui/usage_tab_test.go
deleted file mode 100644
index 4fffcd989f..0000000000
--- a/internal/tui/usage_tab_test.go
+++ /dev/null
@@ -1,134 +0,0 @@
-package tui
-
-import (
- "strings"
- "testing"
-)
-
-func TestRenderLatencyBreakdown(t *testing.T) {
- tests := []struct {
- name string
- modelStats map[string]any
- wantEmpty bool
- wantContains string
- }{
- {
- name: "no details",
- modelStats: map[string]any{},
- wantEmpty: true,
- },
- {
- name: "empty details",
- modelStats: map[string]any{
- "details": []any{},
- },
- wantEmpty: true,
- },
- {
- name: "details with zero latency",
- modelStats: map[string]any{
- "details": []any{
- map[string]any{
- "latency_ms": float64(0),
- },
- },
- },
- wantEmpty: true,
- },
- {
- name: "single request with latency",
- modelStats: map[string]any{
- "details": []any{
- map[string]any{
- "latency_ms": float64(1500),
- },
- },
- },
- wantEmpty: false,
- wantContains: "avg 1500ms min 1500ms max 1500ms",
- },
- {
- name: "multiple requests with varying latency",
- modelStats: map[string]any{
- "details": []any{
- map[string]any{
- "latency_ms": float64(100),
- },
- map[string]any{
- "latency_ms": float64(200),
- },
- map[string]any{
- "latency_ms": float64(300),
- },
- },
- },
- wantEmpty: false,
- wantContains: "avg 200ms min 100ms max 300ms",
- },
- {
- name: "mixed valid and invalid latency values",
- modelStats: map[string]any{
- "details": []any{
- map[string]any{
- "latency_ms": float64(500),
- },
- map[string]any{
- "latency_ms": float64(0),
- },
- map[string]any{
- "latency_ms": float64(1500),
- },
- },
- },
- wantEmpty: false,
- wantContains: "avg 1000ms min 500ms max 1500ms",
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- m := usageTabModel{}
- result := m.renderLatencyBreakdown(tt.modelStats)
-
- if tt.wantEmpty {
- if result != "" {
- t.Errorf("renderLatencyBreakdown() = %q, want empty string", result)
- }
- return
- }
-
- if result == "" {
- t.Errorf("renderLatencyBreakdown() = empty, want non-empty string")
- return
- }
-
- if tt.wantContains != "" && !strings.Contains(result, tt.wantContains) {
- t.Errorf("renderLatencyBreakdown() = %q, want to contain %q", result, tt.wantContains)
- }
- })
- }
-}
-
-func TestUsageTimeTranslations(t *testing.T) {
- prevLocale := CurrentLocale()
- t.Cleanup(func() {
- SetLocale(prevLocale)
- })
-
- tests := []struct {
- locale string
- want string
- }{
- {locale: "en", want: "Time"},
- {locale: "zh", want: "时间"},
- }
-
- for _, tt := range tests {
- t.Run(tt.locale, func(t *testing.T) {
- SetLocale(tt.locale)
- if got := T("usage_time"); got != tt.want {
- t.Fatalf("T(usage_time) = %q, want %q", got, tt.want)
- }
- })
- }
-}
diff --git a/internal/usage/logger_plugin.go b/internal/usage/logger_plugin.go
deleted file mode 100644
index 803d005ee2..0000000000
--- a/internal/usage/logger_plugin.go
+++ /dev/null
@@ -1,484 +0,0 @@
-// Package usage provides usage tracking and logging functionality for the CLI Proxy API server.
-// It includes plugins for monitoring API usage, token consumption, and other metrics
-// to help with observability and billing purposes.
-package usage
-
-import (
- "context"
- "fmt"
- "strings"
- "sync"
- "sync/atomic"
- "time"
-
- "github.com/gin-gonic/gin"
- coreusage "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/usage"
-)
-
-var statisticsEnabled atomic.Bool
-
-func init() {
- statisticsEnabled.Store(true)
- coreusage.RegisterPlugin(NewLoggerPlugin())
-}
-
-// LoggerPlugin collects in-memory request statistics for usage analysis.
-// It implements coreusage.Plugin to receive usage records emitted by the runtime.
-type LoggerPlugin struct {
- stats *RequestStatistics
-}
-
-// NewLoggerPlugin constructs a new logger plugin instance.
-//
-// Returns:
-// - *LoggerPlugin: A new logger plugin instance wired to the shared statistics store.
-func NewLoggerPlugin() *LoggerPlugin { return &LoggerPlugin{stats: defaultRequestStatistics} }
-
-// HandleUsage implements coreusage.Plugin.
-// It updates the in-memory statistics store whenever a usage record is received.
-//
-// Parameters:
-// - ctx: The context for the usage record
-// - record: The usage record to aggregate
-func (p *LoggerPlugin) HandleUsage(ctx context.Context, record coreusage.Record) {
- if !statisticsEnabled.Load() {
- return
- }
- if p == nil || p.stats == nil {
- return
- }
- p.stats.Record(ctx, record)
-}
-
-// SetStatisticsEnabled toggles whether in-memory statistics are recorded.
-func SetStatisticsEnabled(enabled bool) { statisticsEnabled.Store(enabled) }
-
-// StatisticsEnabled reports the current recording state.
-func StatisticsEnabled() bool { return statisticsEnabled.Load() }
-
-// RequestStatistics maintains aggregated request metrics in memory.
-type RequestStatistics struct {
- mu sync.RWMutex
-
- totalRequests int64
- successCount int64
- failureCount int64
- totalTokens int64
-
- apis map[string]*apiStats
-
- requestsByDay map[string]int64
- requestsByHour map[int]int64
- tokensByDay map[string]int64
- tokensByHour map[int]int64
-}
-
-// apiStats holds aggregated metrics for a single API key.
-type apiStats struct {
- TotalRequests int64
- TotalTokens int64
- Models map[string]*modelStats
-}
-
-// modelStats holds aggregated metrics for a specific model within an API.
-type modelStats struct {
- TotalRequests int64
- TotalTokens int64
- Details []RequestDetail
-}
-
-// RequestDetail stores the timestamp, latency, and token usage for a single request.
-type RequestDetail struct {
- Timestamp time.Time `json:"timestamp"`
- LatencyMs int64 `json:"latency_ms"`
- Source string `json:"source"`
- AuthIndex string `json:"auth_index"`
- Tokens TokenStats `json:"tokens"`
- Failed bool `json:"failed"`
-}
-
-// TokenStats captures the token usage breakdown for a request.
-type TokenStats struct {
- InputTokens int64 `json:"input_tokens"`
- OutputTokens int64 `json:"output_tokens"`
- ReasoningTokens int64 `json:"reasoning_tokens"`
- CachedTokens int64 `json:"cached_tokens"`
- TotalTokens int64 `json:"total_tokens"`
-}
-
-// StatisticsSnapshot represents an immutable view of the aggregated metrics.
-type StatisticsSnapshot struct {
- TotalRequests int64 `json:"total_requests"`
- SuccessCount int64 `json:"success_count"`
- FailureCount int64 `json:"failure_count"`
- TotalTokens int64 `json:"total_tokens"`
-
- APIs map[string]APISnapshot `json:"apis"`
-
- RequestsByDay map[string]int64 `json:"requests_by_day"`
- RequestsByHour map[string]int64 `json:"requests_by_hour"`
- TokensByDay map[string]int64 `json:"tokens_by_day"`
- TokensByHour map[string]int64 `json:"tokens_by_hour"`
-}
-
-// APISnapshot summarises metrics for a single API key.
-type APISnapshot struct {
- TotalRequests int64 `json:"total_requests"`
- TotalTokens int64 `json:"total_tokens"`
- Models map[string]ModelSnapshot `json:"models"`
-}
-
-// ModelSnapshot summarises metrics for a specific model.
-type ModelSnapshot struct {
- TotalRequests int64 `json:"total_requests"`
- TotalTokens int64 `json:"total_tokens"`
- Details []RequestDetail `json:"details"`
-}
-
-var defaultRequestStatistics = NewRequestStatistics()
-
-// GetRequestStatistics returns the shared statistics store.
-func GetRequestStatistics() *RequestStatistics { return defaultRequestStatistics }
-
-// NewRequestStatistics constructs an empty statistics store.
-func NewRequestStatistics() *RequestStatistics {
- return &RequestStatistics{
- apis: make(map[string]*apiStats),
- requestsByDay: make(map[string]int64),
- requestsByHour: make(map[int]int64),
- tokensByDay: make(map[string]int64),
- tokensByHour: make(map[int]int64),
- }
-}
-
-// Record ingests a new usage record and updates the aggregates.
-func (s *RequestStatistics) Record(ctx context.Context, record coreusage.Record) {
- if s == nil {
- return
- }
- if !statisticsEnabled.Load() {
- return
- }
- timestamp := record.RequestedAt
- if timestamp.IsZero() {
- timestamp = time.Now()
- }
- detail := normaliseDetail(record.Detail)
- totalTokens := detail.TotalTokens
- statsKey := record.APIKey
- if statsKey == "" {
- statsKey = resolveAPIIdentifier(ctx, record)
- }
- failed := record.Failed
- if !failed {
- failed = !resolveSuccess(ctx)
- }
- success := !failed
- modelName := record.Model
- if modelName == "" {
- modelName = "unknown"
- }
- dayKey := timestamp.Format("2006-01-02")
- hourKey := timestamp.Hour()
-
- s.mu.Lock()
- defer s.mu.Unlock()
-
- s.totalRequests++
- if success {
- s.successCount++
- } else {
- s.failureCount++
- }
- s.totalTokens += totalTokens
-
- stats, ok := s.apis[statsKey]
- if !ok {
- stats = &apiStats{Models: make(map[string]*modelStats)}
- s.apis[statsKey] = stats
- }
- s.updateAPIStats(stats, modelName, RequestDetail{
- Timestamp: timestamp,
- LatencyMs: normaliseLatency(record.Latency),
- Source: record.Source,
- AuthIndex: record.AuthIndex,
- Tokens: detail,
- Failed: failed,
- })
-
- s.requestsByDay[dayKey]++
- s.requestsByHour[hourKey]++
- s.tokensByDay[dayKey] += totalTokens
- s.tokensByHour[hourKey] += totalTokens
-}
-
-func (s *RequestStatistics) updateAPIStats(stats *apiStats, model string, detail RequestDetail) {
- stats.TotalRequests++
- stats.TotalTokens += detail.Tokens.TotalTokens
- modelStatsValue, ok := stats.Models[model]
- if !ok {
- modelStatsValue = &modelStats{}
- stats.Models[model] = modelStatsValue
- }
- modelStatsValue.TotalRequests++
- modelStatsValue.TotalTokens += detail.Tokens.TotalTokens
- modelStatsValue.Details = append(modelStatsValue.Details, detail)
-}
-
-// Snapshot returns a copy of the aggregated metrics for external consumption.
-func (s *RequestStatistics) Snapshot() StatisticsSnapshot {
- result := StatisticsSnapshot{}
- if s == nil {
- return result
- }
-
- s.mu.RLock()
- defer s.mu.RUnlock()
-
- result.TotalRequests = s.totalRequests
- result.SuccessCount = s.successCount
- result.FailureCount = s.failureCount
- result.TotalTokens = s.totalTokens
-
- result.APIs = make(map[string]APISnapshot, len(s.apis))
- for apiName, stats := range s.apis {
- apiSnapshot := APISnapshot{
- TotalRequests: stats.TotalRequests,
- TotalTokens: stats.TotalTokens,
- Models: make(map[string]ModelSnapshot, len(stats.Models)),
- }
- for modelName, modelStatsValue := range stats.Models {
- requestDetails := make([]RequestDetail, len(modelStatsValue.Details))
- copy(requestDetails, modelStatsValue.Details)
- apiSnapshot.Models[modelName] = ModelSnapshot{
- TotalRequests: modelStatsValue.TotalRequests,
- TotalTokens: modelStatsValue.TotalTokens,
- Details: requestDetails,
- }
- }
- result.APIs[apiName] = apiSnapshot
- }
-
- result.RequestsByDay = make(map[string]int64, len(s.requestsByDay))
- for k, v := range s.requestsByDay {
- result.RequestsByDay[k] = v
- }
-
- result.RequestsByHour = make(map[string]int64, len(s.requestsByHour))
- for hour, v := range s.requestsByHour {
- key := formatHour(hour)
- result.RequestsByHour[key] = v
- }
-
- result.TokensByDay = make(map[string]int64, len(s.tokensByDay))
- for k, v := range s.tokensByDay {
- result.TokensByDay[k] = v
- }
-
- result.TokensByHour = make(map[string]int64, len(s.tokensByHour))
- for hour, v := range s.tokensByHour {
- key := formatHour(hour)
- result.TokensByHour[key] = v
- }
-
- return result
-}
-
-type MergeResult struct {
- Added int64 `json:"added"`
- Skipped int64 `json:"skipped"`
-}
-
-// MergeSnapshot merges an exported statistics snapshot into the current store.
-// Existing data is preserved and duplicate request details are skipped.
-func (s *RequestStatistics) MergeSnapshot(snapshot StatisticsSnapshot) MergeResult {
- result := MergeResult{}
- if s == nil {
- return result
- }
-
- s.mu.Lock()
- defer s.mu.Unlock()
-
- seen := make(map[string]struct{})
- for apiName, stats := range s.apis {
- if stats == nil {
- continue
- }
- for modelName, modelStatsValue := range stats.Models {
- if modelStatsValue == nil {
- continue
- }
- for _, detail := range modelStatsValue.Details {
- seen[dedupKey(apiName, modelName, detail)] = struct{}{}
- }
- }
- }
-
- for apiName, apiSnapshot := range snapshot.APIs {
- apiName = strings.TrimSpace(apiName)
- if apiName == "" {
- continue
- }
- stats, ok := s.apis[apiName]
- if !ok || stats == nil {
- stats = &apiStats{Models: make(map[string]*modelStats)}
- s.apis[apiName] = stats
- } else if stats.Models == nil {
- stats.Models = make(map[string]*modelStats)
- }
- for modelName, modelSnapshot := range apiSnapshot.Models {
- modelName = strings.TrimSpace(modelName)
- if modelName == "" {
- modelName = "unknown"
- }
- for _, detail := range modelSnapshot.Details {
- detail.Tokens = normaliseTokenStats(detail.Tokens)
- if detail.LatencyMs < 0 {
- detail.LatencyMs = 0
- }
- if detail.Timestamp.IsZero() {
- detail.Timestamp = time.Now()
- }
- key := dedupKey(apiName, modelName, detail)
- if _, exists := seen[key]; exists {
- result.Skipped++
- continue
- }
- seen[key] = struct{}{}
- s.recordImported(apiName, modelName, stats, detail)
- result.Added++
- }
- }
- }
-
- return result
-}
-
-func (s *RequestStatistics) recordImported(apiName, modelName string, stats *apiStats, detail RequestDetail) {
- totalTokens := detail.Tokens.TotalTokens
- if totalTokens < 0 {
- totalTokens = 0
- }
-
- s.totalRequests++
- if detail.Failed {
- s.failureCount++
- } else {
- s.successCount++
- }
- s.totalTokens += totalTokens
-
- s.updateAPIStats(stats, modelName, detail)
-
- dayKey := detail.Timestamp.Format("2006-01-02")
- hourKey := detail.Timestamp.Hour()
-
- s.requestsByDay[dayKey]++
- s.requestsByHour[hourKey]++
- s.tokensByDay[dayKey] += totalTokens
- s.tokensByHour[hourKey] += totalTokens
-}
-
-func dedupKey(apiName, modelName string, detail RequestDetail) string {
- timestamp := detail.Timestamp.UTC().Format(time.RFC3339Nano)
- tokens := normaliseTokenStats(detail.Tokens)
- return fmt.Sprintf(
- "%s|%s|%s|%s|%s|%t|%d|%d|%d|%d|%d",
- apiName,
- modelName,
- timestamp,
- detail.Source,
- detail.AuthIndex,
- detail.Failed,
- tokens.InputTokens,
- tokens.OutputTokens,
- tokens.ReasoningTokens,
- tokens.CachedTokens,
- tokens.TotalTokens,
- )
-}
-
-func resolveAPIIdentifier(ctx context.Context, record coreusage.Record) string {
- if ctx != nil {
- if ginCtx, ok := ctx.Value("gin").(*gin.Context); ok && ginCtx != nil {
- path := ginCtx.FullPath()
- if path == "" && ginCtx.Request != nil {
- path = ginCtx.Request.URL.Path
- }
- method := ""
- if ginCtx.Request != nil {
- method = ginCtx.Request.Method
- }
- if path != "" {
- if method != "" {
- return method + " " + path
- }
- return path
- }
- }
- }
- if record.Provider != "" {
- return record.Provider
- }
- return "unknown"
-}
-
-func resolveSuccess(ctx context.Context) bool {
- if ctx == nil {
- return true
- }
- ginCtx, ok := ctx.Value("gin").(*gin.Context)
- if !ok || ginCtx == nil {
- return true
- }
- status := ginCtx.Writer.Status()
- if status == 0 {
- return true
- }
- return status < httpStatusBadRequest
-}
-
-const httpStatusBadRequest = 400
-
-func normaliseDetail(detail coreusage.Detail) TokenStats {
- tokens := TokenStats{
- InputTokens: detail.InputTokens,
- OutputTokens: detail.OutputTokens,
- ReasoningTokens: detail.ReasoningTokens,
- CachedTokens: detail.CachedTokens,
- TotalTokens: detail.TotalTokens,
- }
- if tokens.TotalTokens == 0 {
- tokens.TotalTokens = detail.InputTokens + detail.OutputTokens + detail.ReasoningTokens
- }
- if tokens.TotalTokens == 0 {
- tokens.TotalTokens = detail.InputTokens + detail.OutputTokens + detail.ReasoningTokens + detail.CachedTokens
- }
- return tokens
-}
-
-func normaliseTokenStats(tokens TokenStats) TokenStats {
- if tokens.TotalTokens == 0 {
- tokens.TotalTokens = tokens.InputTokens + tokens.OutputTokens + tokens.ReasoningTokens
- }
- if tokens.TotalTokens == 0 {
- tokens.TotalTokens = tokens.InputTokens + tokens.OutputTokens + tokens.ReasoningTokens + tokens.CachedTokens
- }
- return tokens
-}
-
-func normaliseLatency(latency time.Duration) int64 {
- if latency <= 0 {
- return 0
- }
- return latency.Milliseconds()
-}
-
-func formatHour(hour int) string {
- if hour < 0 {
- hour = 0
- }
- hour = hour % 24
- return fmt.Sprintf("%02d", hour)
-}
diff --git a/internal/usage/logger_plugin_test.go b/internal/usage/logger_plugin_test.go
deleted file mode 100644
index 842b3f0cad..0000000000
--- a/internal/usage/logger_plugin_test.go
+++ /dev/null
@@ -1,96 +0,0 @@
-package usage
-
-import (
- "context"
- "testing"
- "time"
-
- coreusage "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/usage"
-)
-
-func TestRequestStatisticsRecordIncludesLatency(t *testing.T) {
- stats := NewRequestStatistics()
- stats.Record(context.Background(), coreusage.Record{
- APIKey: "test-key",
- Model: "gpt-5.4",
- RequestedAt: time.Date(2026, 3, 20, 12, 0, 0, 0, time.UTC),
- Latency: 1500 * time.Millisecond,
- Detail: coreusage.Detail{
- InputTokens: 10,
- OutputTokens: 20,
- TotalTokens: 30,
- },
- })
-
- snapshot := stats.Snapshot()
- details := snapshot.APIs["test-key"].Models["gpt-5.4"].Details
- if len(details) != 1 {
- t.Fatalf("details len = %d, want 1", len(details))
- }
- if details[0].LatencyMs != 1500 {
- t.Fatalf("latency_ms = %d, want 1500", details[0].LatencyMs)
- }
-}
-
-func TestRequestStatisticsMergeSnapshotDedupIgnoresLatency(t *testing.T) {
- stats := NewRequestStatistics()
- timestamp := time.Date(2026, 3, 20, 12, 0, 0, 0, time.UTC)
- first := StatisticsSnapshot{
- APIs: map[string]APISnapshot{
- "test-key": {
- Models: map[string]ModelSnapshot{
- "gpt-5.4": {
- Details: []RequestDetail{{
- Timestamp: timestamp,
- LatencyMs: 0,
- Source: "user@example.com",
- AuthIndex: "0",
- Tokens: TokenStats{
- InputTokens: 10,
- OutputTokens: 20,
- TotalTokens: 30,
- },
- }},
- },
- },
- },
- },
- }
- second := StatisticsSnapshot{
- APIs: map[string]APISnapshot{
- "test-key": {
- Models: map[string]ModelSnapshot{
- "gpt-5.4": {
- Details: []RequestDetail{{
- Timestamp: timestamp,
- LatencyMs: 2500,
- Source: "user@example.com",
- AuthIndex: "0",
- Tokens: TokenStats{
- InputTokens: 10,
- OutputTokens: 20,
- TotalTokens: 30,
- },
- }},
- },
- },
- },
- },
- }
-
- result := stats.MergeSnapshot(first)
- if result.Added != 1 || result.Skipped != 0 {
- t.Fatalf("first merge = %+v, want added=1 skipped=0", result)
- }
-
- result = stats.MergeSnapshot(second)
- if result.Added != 0 || result.Skipped != 1 {
- t.Fatalf("second merge = %+v, want added=0 skipped=1", result)
- }
-
- snapshot := stats.Snapshot()
- details := snapshot.APIs["test-key"].Models["gpt-5.4"].Details
- if len(details) != 1 {
- t.Fatalf("details len = %d, want 1", len(details))
- }
-}
diff --git a/internal/util/header_helpers.go b/internal/util/header_helpers.go
index c53c291f10..0b8d72bcb4 100644
--- a/internal/util/header_helpers.go
+++ b/internal/util/header_helpers.go
@@ -47,6 +47,14 @@ func applyCustomHeaders(r *http.Request, headers map[string]string) {
if k == "" || v == "" {
continue
}
+ // net/http reads Host from req.Host (not req.Header) when writing
+ // a real request, so we must mirror it there. Some callers pass
+ // synthetic requests (e.g. &http.Request{Header: ...}) and only
+ // consume r.Header afterwards, so keep the value in the header
+ // map too.
+ if http.CanonicalHeaderKey(k) == "Host" {
+ r.Host = v
+ }
r.Header.Set(k, v)
}
}
diff --git a/internal/util/provider.go b/internal/util/provider.go
index ce0ed1a397..6313f58e32 100644
--- a/internal/util/provider.go
+++ b/internal/util/provider.go
@@ -7,8 +7,8 @@ import (
"net/url"
"strings"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
log "github.com/sirupsen/logrus"
)
@@ -98,6 +98,9 @@ func IsOpenAICompatibilityAlias(modelName string, cfg *config.Config) bool {
}
for _, compat := range cfg.OpenAICompatibility {
+ if compat.Disabled {
+ continue
+ }
for _, model := range compat.Models {
if model.Alias == modelName {
return true
@@ -123,6 +126,9 @@ func GetOpenAICompatibilityConfig(alias string, cfg *config.Config) (*config.Ope
}
for _, compat := range cfg.OpenAICompatibility {
+ if compat.Disabled {
+ continue
+ }
for _, model := range compat.Models {
if model.Alias == alias {
return &compat, &model
diff --git a/internal/util/proxy.go b/internal/util/proxy.go
index 9b57ca1733..781dd54dc0 100644
--- a/internal/util/proxy.go
+++ b/internal/util/proxy.go
@@ -6,8 +6,8 @@ package util
import (
"net/http"
- "github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
- "github.com/router-for-me/CLIProxyAPI/v6/sdk/proxyutil"
+ "github.com/router-for-me/CLIProxyAPI/v7/sdk/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/sdk/proxyutil"
log "github.com/sirupsen/logrus"
)
diff --git a/internal/util/util.go b/internal/util/util.go
index 9bf630f299..2c50cf67b5 100644
--- a/internal/util/util.go
+++ b/internal/util/util.go
@@ -11,7 +11,7 @@ import (
"regexp"
"strings"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
log "github.com/sirupsen/logrus"
)
@@ -73,9 +73,10 @@ func SetLogLevel(cfg *config.Config) {
// ResolveAuthDir normalizes the auth directory path for consistent reuse throughout the app.
// It expands a leading tilde (~) to the user's home directory and returns a cleaned path.
+// If authDir is empty, it defaults to ~/.cli-proxy-api.
func ResolveAuthDir(authDir string) (string, error) {
if authDir == "" {
- return "", nil
+ authDir = config.DefaultAuthDir
}
if strings.HasPrefix(authDir, "~") {
home, err := os.UserHomeDir()
diff --git a/internal/watcher/clients.go b/internal/watcher/clients.go
index 7746f4ad3b..0a46660e8b 100644
--- a/internal/watcher/clients.go
+++ b/internal/watcher/clients.go
@@ -13,11 +13,11 @@ import (
"strings"
"time"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/watcher/diff"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/watcher/synthesizer"
- coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/util"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/watcher/diff"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/watcher/synthesizer"
+ coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
log "github.com/sirupsen/logrus"
)
@@ -357,6 +357,9 @@ func BuildAPIKeyClients(cfg *config.Config) (int, int, int, int, int) {
}
if len(cfg.OpenAICompatibility) > 0 {
for _, compatConfig := range cfg.OpenAICompatibility {
+ if compatConfig.Disabled {
+ continue
+ }
openAICompatCount += len(compatConfig.APIKeyEntries)
}
}
diff --git a/internal/watcher/config_reload.go b/internal/watcher/config_reload.go
index 1bbf4ef239..0471f8b3f2 100644
--- a/internal/watcher/config_reload.go
+++ b/internal/watcher/config_reload.go
@@ -9,9 +9,9 @@ import (
"reflect"
"time"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/watcher/diff"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/util"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/watcher/diff"
"gopkg.in/yaml.v3"
log "github.com/sirupsen/logrus"
diff --git a/internal/watcher/diff/auth_diff.go b/internal/watcher/diff/auth_diff.go
index 4b6e600852..39fe5e886d 100644
--- a/internal/watcher/diff/auth_diff.go
+++ b/internal/watcher/diff/auth_diff.go
@@ -5,7 +5,7 @@ import (
"fmt"
"strings"
- coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
+ coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
)
// BuildAuthChangeDetails computes a redacted, human-readable list of auth field changes.
diff --git a/internal/watcher/diff/config_diff.go b/internal/watcher/diff/config_diff.go
index 11f9093e80..c206049e43 100644
--- a/internal/watcher/diff/config_diff.go
+++ b/internal/watcher/diff/config_diff.go
@@ -6,7 +6,7 @@ import (
"reflect"
"strings"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
)
// BuildConfigChangeDetails computes a redacted, human-readable list of config changes.
@@ -39,9 +39,15 @@ func BuildConfigChangeDetails(oldCfg, newCfg *config.Config) []string {
if oldCfg.UsageStatisticsEnabled != newCfg.UsageStatisticsEnabled {
changes = append(changes, fmt.Sprintf("usage-statistics-enabled: %t -> %t", oldCfg.UsageStatisticsEnabled, newCfg.UsageStatisticsEnabled))
}
+ if oldCfg.RedisUsageQueueRetentionSeconds != newCfg.RedisUsageQueueRetentionSeconds {
+ changes = append(changes, fmt.Sprintf("redis-usage-queue-retention-seconds: %d -> %d", oldCfg.RedisUsageQueueRetentionSeconds, newCfg.RedisUsageQueueRetentionSeconds))
+ }
if oldCfg.DisableCooling != newCfg.DisableCooling {
changes = append(changes, fmt.Sprintf("disable-cooling: %t -> %t", oldCfg.DisableCooling, newCfg.DisableCooling))
}
+ if oldCfg.DisableImageGeneration != newCfg.DisableImageGeneration {
+ changes = append(changes, fmt.Sprintf("disable-image-generation: %v -> %v", oldCfg.DisableImageGeneration, newCfg.DisableImageGeneration))
+ }
if oldCfg.RequestLog != newCfg.RequestLog {
changes = append(changes, fmt.Sprintf("request-log: %t -> %t", oldCfg.RequestLog, newCfg.RequestLog))
}
diff --git a/internal/watcher/diff/config_diff_test.go b/internal/watcher/diff/config_diff_test.go
index 2d45aa5743..192791ea74 100644
--- a/internal/watcher/diff/config_diff_test.go
+++ b/internal/watcher/diff/config_diff_test.go
@@ -3,8 +3,8 @@ package diff
import (
"testing"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
- sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ sdkconfig "github.com/router-for-me/CLIProxyAPI/v7/sdk/config"
)
func TestBuildConfigChangeDetails(t *testing.T) {
@@ -279,6 +279,7 @@ func TestBuildConfigChangeDetails_FlagsAndKeys(t *testing.T) {
APIKeys: []string{" key-1 ", "key-2"},
ForceModelPrefix: true,
NonStreamKeepAliveInterval: 5,
+ DisableImageGeneration: config.DisableImageGenerationAll,
},
}
@@ -287,6 +288,7 @@ func TestBuildConfigChangeDetails_FlagsAndKeys(t *testing.T) {
expectContains(t, details, "logging-to-file: false -> true")
expectContains(t, details, "usage-statistics-enabled: false -> true")
expectContains(t, details, "disable-cooling: false -> true")
+ expectContains(t, details, "disable-image-generation: false -> true")
expectContains(t, details, "request-log: false -> true")
expectContains(t, details, "request-retry: 1 -> 2")
expectContains(t, details, "max-retry-credentials: 1 -> 3")
@@ -403,9 +405,10 @@ func TestBuildConfigChangeDetails_AllBranches(t *testing.T) {
SecretKey: "",
},
SDKConfig: sdkconfig.SDKConfig{
- RequestLog: true,
- ProxyURL: "http://new-proxy",
- APIKeys: []string{"keyB"},
+ RequestLog: true,
+ ProxyURL: "http://new-proxy",
+ APIKeys: []string{"keyB"},
+ DisableImageGeneration: config.DisableImageGenerationAll,
},
OAuthExcludedModels: map[string][]string{"p1": {"b", "c"}, "p2": {"d"}},
OpenAICompatibility: []config.OpenAICompatibility{
@@ -431,6 +434,7 @@ func TestBuildConfigChangeDetails_AllBranches(t *testing.T) {
expectContains(t, changes, "logging-to-file: false -> true")
expectContains(t, changes, "usage-statistics-enabled: false -> true")
expectContains(t, changes, "disable-cooling: false -> true")
+ expectContains(t, changes, "disable-image-generation: false -> true")
expectContains(t, changes, "request-retry: 1 -> 2")
expectContains(t, changes, "max-retry-credentials: 1 -> 3")
expectContains(t, changes, "max-retry-interval: 1 -> 3")
diff --git a/internal/watcher/diff/model_hash.go b/internal/watcher/diff/model_hash.go
index 5779faccd7..fed3386a7a 100644
--- a/internal/watcher/diff/model_hash.go
+++ b/internal/watcher/diff/model_hash.go
@@ -7,7 +7,7 @@ import (
"sort"
"strings"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
)
// ComputeOpenAICompatModelsHash returns a stable hash for OpenAI-compat models.
diff --git a/internal/watcher/diff/model_hash_test.go b/internal/watcher/diff/model_hash_test.go
index db06ebd12c..b687d4da2e 100644
--- a/internal/watcher/diff/model_hash_test.go
+++ b/internal/watcher/diff/model_hash_test.go
@@ -3,7 +3,7 @@ package diff
import (
"testing"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
)
func TestComputeOpenAICompatModelsHash_Deterministic(t *testing.T) {
diff --git a/internal/watcher/diff/models_summary.go b/internal/watcher/diff/models_summary.go
index 9c2aa91ac4..4c9b035a16 100644
--- a/internal/watcher/diff/models_summary.go
+++ b/internal/watcher/diff/models_summary.go
@@ -6,7 +6,7 @@ import (
"sort"
"strings"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
)
type GeminiModelsSummary struct {
diff --git a/internal/watcher/diff/oauth_excluded.go b/internal/watcher/diff/oauth_excluded.go
index 2039cf4898..d632062840 100644
--- a/internal/watcher/diff/oauth_excluded.go
+++ b/internal/watcher/diff/oauth_excluded.go
@@ -7,7 +7,7 @@ import (
"sort"
"strings"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
)
type ExcludedModelsSummary struct {
diff --git a/internal/watcher/diff/oauth_excluded_test.go b/internal/watcher/diff/oauth_excluded_test.go
index f5ad391358..8643f59447 100644
--- a/internal/watcher/diff/oauth_excluded_test.go
+++ b/internal/watcher/diff/oauth_excluded_test.go
@@ -3,7 +3,7 @@ package diff
import (
"testing"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
)
func TestSummarizeExcludedModels_NormalizesAndDedupes(t *testing.T) {
diff --git a/internal/watcher/diff/oauth_model_alias.go b/internal/watcher/diff/oauth_model_alias.go
index c5a17d2940..8c14089b9f 100644
--- a/internal/watcher/diff/oauth_model_alias.go
+++ b/internal/watcher/diff/oauth_model_alias.go
@@ -7,7 +7,7 @@ import (
"sort"
"strings"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
)
type OAuthModelAliasSummary struct {
diff --git a/internal/watcher/diff/openai_compat.go b/internal/watcher/diff/openai_compat.go
index 6b01aed296..31d0bcd99d 100644
--- a/internal/watcher/diff/openai_compat.go
+++ b/internal/watcher/diff/openai_compat.go
@@ -7,7 +7,7 @@ import (
"sort"
"strings"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
)
// DiffOpenAICompatibility produces human-readable change descriptions.
@@ -66,6 +66,9 @@ func describeOpenAICompatibilityUpdate(oldEntry, newEntry config.OpenAICompatibi
oldModelCount := countOpenAIModels(oldEntry.Models)
newModelCount := countOpenAIModels(newEntry.Models)
details := make([]string, 0, 3)
+ if oldEntry.Disabled != newEntry.Disabled {
+ details = append(details, fmt.Sprintf("disabled %t -> %t", oldEntry.Disabled, newEntry.Disabled))
+ }
if oldKeyCount != newKeyCount {
details = append(details, fmt.Sprintf("api-keys %d -> %d", oldKeyCount, newKeyCount))
}
diff --git a/internal/watcher/diff/openai_compat_test.go b/internal/watcher/diff/openai_compat_test.go
index db33db1487..5683671ae4 100644
--- a/internal/watcher/diff/openai_compat_test.go
+++ b/internal/watcher/diff/openai_compat_test.go
@@ -4,7 +4,7 @@ import (
"strings"
"testing"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
)
func TestDiffOpenAICompatibility(t *testing.T) {
diff --git a/internal/watcher/dispatcher.go b/internal/watcher/dispatcher.go
index 3d7d7527b3..d0182e2c25 100644
--- a/internal/watcher/dispatcher.go
+++ b/internal/watcher/dispatcher.go
@@ -9,9 +9,9 @@ import (
"sync"
"time"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/watcher/synthesizer"
- coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/watcher/synthesizer"
+ coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
)
var snapshotCoreAuthsFunc = snapshotCoreAuths
diff --git a/internal/watcher/synthesizer/config.go b/internal/watcher/synthesizer/config.go
index 52ae9a4808..1eea3dc112 100644
--- a/internal/watcher/synthesizer/config.go
+++ b/internal/watcher/synthesizer/config.go
@@ -5,8 +5,8 @@ import (
"strconv"
"strings"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/watcher/diff"
- coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/watcher/diff"
+ coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
)
// ConfigSynthesizer generates Auth entries from configuration API keys.
@@ -60,6 +60,10 @@ func (s *ConfigSynthesizer) synthesizeGeminiKeys(ctx *SynthesisContext) []*corea
"source": fmt.Sprintf("config:gemini[%s]", token),
"api_key": key,
}
+ metadata := map[string]any{}
+ if entry.DisableCooling {
+ metadata["disable_cooling"] = true
+ }
if entry.Priority != 0 {
attrs["priority"] = strconv.Itoa(entry.Priority)
}
@@ -78,10 +82,14 @@ func (s *ConfigSynthesizer) synthesizeGeminiKeys(ctx *SynthesisContext) []*corea
Status: coreauth.StatusActive,
ProxyURL: proxyURL,
Attributes: attrs,
+ Metadata: metadata,
CreatedAt: now,
UpdatedAt: now,
}
ApplyAuthExcludedModelsMeta(a, cfg, entry.ExcludedModels, "apikey")
+ if len(a.Metadata) == 0 {
+ a.Metadata = nil
+ }
out = append(out, a)
}
return out
@@ -107,6 +115,10 @@ func (s *ConfigSynthesizer) synthesizeClaudeKeys(ctx *SynthesisContext) []*corea
"source": fmt.Sprintf("config:claude[%s]", token),
"api_key": key,
}
+ metadata := map[string]any{}
+ if ck.DisableCooling {
+ metadata["disable_cooling"] = true
+ }
if ck.Priority != 0 {
attrs["priority"] = strconv.Itoa(ck.Priority)
}
@@ -126,10 +138,14 @@ func (s *ConfigSynthesizer) synthesizeClaudeKeys(ctx *SynthesisContext) []*corea
Status: coreauth.StatusActive,
ProxyURL: proxyURL,
Attributes: attrs,
+ Metadata: metadata,
CreatedAt: now,
UpdatedAt: now,
}
ApplyAuthExcludedModelsMeta(a, cfg, ck.ExcludedModels, "apikey")
+ if len(a.Metadata) == 0 {
+ a.Metadata = nil
+ }
out = append(out, a)
}
return out
@@ -154,6 +170,10 @@ func (s *ConfigSynthesizer) synthesizeCodexKeys(ctx *SynthesisContext) []*coreau
"source": fmt.Sprintf("config:codex[%s]", token),
"api_key": key,
}
+ metadata := map[string]any{}
+ if ck.DisableCooling {
+ metadata["disable_cooling"] = true
+ }
if ck.Priority != 0 {
attrs["priority"] = strconv.Itoa(ck.Priority)
}
@@ -176,10 +196,14 @@ func (s *ConfigSynthesizer) synthesizeCodexKeys(ctx *SynthesisContext) []*coreau
Status: coreauth.StatusActive,
ProxyURL: proxyURL,
Attributes: attrs,
+ Metadata: metadata,
CreatedAt: now,
UpdatedAt: now,
}
ApplyAuthExcludedModelsMeta(a, cfg, ck.ExcludedModels, "apikey")
+ if len(a.Metadata) == 0 {
+ a.Metadata = nil
+ }
out = append(out, a)
}
return out
@@ -194,12 +218,16 @@ func (s *ConfigSynthesizer) synthesizeOpenAICompat(ctx *SynthesisContext) []*cor
out := make([]*coreauth.Auth, 0)
for i := range cfg.OpenAICompatibility {
compat := &cfg.OpenAICompatibility[i]
+ if compat.Disabled {
+ continue
+ }
prefix := strings.TrimSpace(compat.Prefix)
providerName := strings.ToLower(strings.TrimSpace(compat.Name))
if providerName == "" {
providerName = "openai-compatibility"
}
base := strings.TrimSpace(compat.BaseURL)
+ disableCooling := compat.DisableCooling
// Handle new APIKeyEntries format (preferred)
createdEntries := 0
@@ -215,6 +243,10 @@ func (s *ConfigSynthesizer) synthesizeOpenAICompat(ctx *SynthesisContext) []*cor
"compat_name": compat.Name,
"provider_key": providerName,
}
+ metadata := map[string]any{}
+ if disableCooling {
+ metadata["disable_cooling"] = true
+ }
if compat.Priority != 0 {
attrs["priority"] = strconv.Itoa(compat.Priority)
}
@@ -233,9 +265,13 @@ func (s *ConfigSynthesizer) synthesizeOpenAICompat(ctx *SynthesisContext) []*cor
Status: coreauth.StatusActive,
ProxyURL: proxyURL,
Attributes: attrs,
+ Metadata: metadata,
CreatedAt: now,
UpdatedAt: now,
}
+ if len(a.Metadata) == 0 {
+ a.Metadata = nil
+ }
out = append(out, a)
createdEntries++
}
@@ -249,6 +285,10 @@ func (s *ConfigSynthesizer) synthesizeOpenAICompat(ctx *SynthesisContext) []*cor
"compat_name": compat.Name,
"provider_key": providerName,
}
+ metadata := map[string]any{}
+ if disableCooling {
+ metadata["disable_cooling"] = true
+ }
if compat.Priority != 0 {
attrs["priority"] = strconv.Itoa(compat.Priority)
}
@@ -263,9 +303,13 @@ func (s *ConfigSynthesizer) synthesizeOpenAICompat(ctx *SynthesisContext) []*cor
Prefix: prefix,
Status: coreauth.StatusActive,
Attributes: attrs,
+ Metadata: metadata,
CreatedAt: now,
UpdatedAt: now,
}
+ if len(a.Metadata) == 0 {
+ a.Metadata = nil
+ }
out = append(out, a)
}
}
diff --git a/internal/watcher/synthesizer/config_test.go b/internal/watcher/synthesizer/config_test.go
index 437f18d11e..c8526a654a 100644
--- a/internal/watcher/synthesizer/config_test.go
+++ b/internal/watcher/synthesizer/config_test.go
@@ -4,8 +4,8 @@ import (
"testing"
"time"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
- coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
)
func TestNewConfigSynthesizer(t *testing.T) {
@@ -68,11 +68,26 @@ func TestConfigSynthesizer_GeminiKeys(t *testing.T) {
if auths[0].Attributes["api_key"] != "test-key-123" {
t.Errorf("expected api_key test-key-123, got %s", auths[0].Attributes["api_key"])
}
+ if auths[0].Metadata != nil {
+ t.Errorf("expected metadata to be nil when disable_cooling not set, got %v", auths[0].Metadata)
+ }
if auths[0].Status != coreauth.StatusActive {
t.Errorf("expected status active, got %s", auths[0].Status)
}
},
},
+ {
+ name: "gemini key disable cooling",
+ geminiKeys: []config.GeminiKey{
+ {APIKey: "test-key-123", Prefix: "team-a", DisableCooling: true},
+ },
+ wantLen: 1,
+ validate: func(t *testing.T, auths []*coreauth.Auth) {
+ if v, ok := auths[0].Metadata["disable_cooling"].(bool); !ok || !v {
+ t.Errorf("expected disable_cooling=true, got %v", auths[0].Metadata["disable_cooling"])
+ }
+ },
+ },
{
name: "gemini key with base url and proxy",
geminiKeys: []config.GeminiKey{
@@ -160,9 +175,10 @@ func TestConfigSynthesizer_ClaudeKeys(t *testing.T) {
Config: &config.Config{
ClaudeKey: []config.ClaudeKey{
{
- APIKey: "sk-ant-api-xxx",
- Prefix: "main",
- BaseURL: "https://api.anthropic.com",
+ APIKey: "sk-ant-api-xxx",
+ Prefix: "main",
+ BaseURL: "https://api.anthropic.com",
+ DisableCooling: true,
Models: []config.ClaudeModel{
{Name: "claude-3-opus"},
{Name: "claude-3-sonnet"},
@@ -197,6 +213,9 @@ func TestConfigSynthesizer_ClaudeKeys(t *testing.T) {
if _, ok := auths[0].Attributes["models_hash"]; !ok {
t.Error("expected models_hash in attributes")
}
+ if v, ok := auths[0].Metadata["disable_cooling"].(bool); !ok || !v {
+ t.Errorf("expected disable_cooling=true, got %v", auths[0].Metadata["disable_cooling"])
+ }
}
func TestConfigSynthesizer_ClaudeKeys_SkipsEmptyAndHeaders(t *testing.T) {
@@ -231,11 +250,12 @@ func TestConfigSynthesizer_CodexKeys(t *testing.T) {
Config: &config.Config{
CodexKey: []config.CodexKey{
{
- APIKey: "codex-key-123",
- Prefix: "dev",
- BaseURL: "https://api.openai.com",
- ProxyURL: "http://proxy.local",
- Websockets: true,
+ APIKey: "codex-key-123",
+ Prefix: "dev",
+ BaseURL: "https://api.openai.com",
+ ProxyURL: "http://proxy.local",
+ Websockets: true,
+ DisableCooling: true,
},
},
},
@@ -263,6 +283,9 @@ func TestConfigSynthesizer_CodexKeys(t *testing.T) {
if auths[0].Attributes["websockets"] != "true" {
t.Errorf("expected websockets=true, got %s", auths[0].Attributes["websockets"])
}
+ if v, ok := auths[0].Metadata["disable_cooling"].(bool); !ok || !v {
+ t.Errorf("expected disable_cooling=true, got %v", auths[0].Metadata["disable_cooling"])
+ }
}
func TestConfigSynthesizer_CodexKeys_SkipsEmptyAndHeaders(t *testing.T) {
@@ -301,8 +324,9 @@ func TestConfigSynthesizer_OpenAICompat(t *testing.T) {
name: "with APIKeyEntries",
compat: []config.OpenAICompatibility{
{
- Name: "CustomProvider",
- BaseURL: "https://custom.api.com",
+ Name: "CustomProvider",
+ BaseURL: "https://custom.api.com",
+ DisableCooling: true,
APIKeyEntries: []config.OpenAICompatibilityAPIKey{
{APIKey: "key-1"},
{APIKey: "key-2"},
@@ -365,6 +389,13 @@ func TestConfigSynthesizer_OpenAICompat(t *testing.T) {
if len(auths) != tt.wantLen {
t.Fatalf("expected %d auths, got %d", tt.wantLen, len(auths))
}
+ if tt.name == "with APIKeyEntries" {
+ for i := range auths {
+ if v, ok := auths[i].Metadata["disable_cooling"].(bool); !ok || !v {
+ t.Fatalf("expected auth[%d].disable_cooling=true, got %v", i, auths[i].Metadata["disable_cooling"])
+ }
+ }
+ }
})
}
}
diff --git a/internal/watcher/synthesizer/context.go b/internal/watcher/synthesizer/context.go
index d973289a3a..f92b41ddaf 100644
--- a/internal/watcher/synthesizer/context.go
+++ b/internal/watcher/synthesizer/context.go
@@ -3,7 +3,7 @@ package synthesizer
import (
"time"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
)
// SynthesisContext provides the context needed for auth synthesis.
diff --git a/internal/watcher/synthesizer/file.go b/internal/watcher/synthesizer/file.go
index 49a635e7e8..47990bc154 100644
--- a/internal/watcher/synthesizer/file.go
+++ b/internal/watcher/synthesizer/file.go
@@ -10,9 +10,9 @@ import (
"strings"
"time"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/codex"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/geminicli"
- coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/codex"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/geminicli"
+ coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
)
// FileSynthesizer generates Auth entries from OAuth JSON files.
diff --git a/internal/watcher/synthesizer/file_test.go b/internal/watcher/synthesizer/file_test.go
index f3e4497923..63b394aaf5 100644
--- a/internal/watcher/synthesizer/file_test.go
+++ b/internal/watcher/synthesizer/file_test.go
@@ -8,8 +8,8 @@ import (
"testing"
"time"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
- coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
)
func TestNewFileSynthesizer(t *testing.T) {
diff --git a/internal/watcher/synthesizer/helpers.go b/internal/watcher/synthesizer/helpers.go
index 102dc77e22..19b4c896f1 100644
--- a/internal/watcher/synthesizer/helpers.go
+++ b/internal/watcher/synthesizer/helpers.go
@@ -7,9 +7,9 @@ import (
"sort"
"strings"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/watcher/diff"
- coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/watcher/diff"
+ coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
)
// StableIDGenerator generates stable, deterministic IDs for auth entries.
diff --git a/internal/watcher/synthesizer/helpers_test.go b/internal/watcher/synthesizer/helpers_test.go
index 46b9c8a053..69ba85d60d 100644
--- a/internal/watcher/synthesizer/helpers_test.go
+++ b/internal/watcher/synthesizer/helpers_test.go
@@ -5,9 +5,9 @@ import (
"strings"
"testing"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/watcher/diff"
- coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/watcher/diff"
+ coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
)
func TestNewStableIDGenerator(t *testing.T) {
diff --git a/internal/watcher/synthesizer/interface.go b/internal/watcher/synthesizer/interface.go
index 1a9aedc965..e0962c11c9 100644
--- a/internal/watcher/synthesizer/interface.go
+++ b/internal/watcher/synthesizer/interface.go
@@ -5,7 +5,7 @@
package synthesizer
import (
- coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
+ coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
)
// AuthSynthesizer defines the interface for generating Auth entries from various sources.
diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go
index cf890a4c46..c18cd84d08 100644
--- a/internal/watcher/watcher.go
+++ b/internal/watcher/watcher.go
@@ -10,11 +10,11 @@ import (
"time"
"github.com/fsnotify/fsnotify"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
"gopkg.in/yaml.v3"
- sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
- coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
+ sdkAuth "github.com/router-for-me/CLIProxyAPI/v7/sdk/auth"
+ coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
log "github.com/sirupsen/logrus"
)
diff --git a/internal/watcher/watcher_test.go b/internal/watcher/watcher_test.go
index 00a7a14360..bb3b557777 100644
--- a/internal/watcher/watcher_test.go
+++ b/internal/watcher/watcher_test.go
@@ -14,11 +14,11 @@ import (
"time"
"github.com/fsnotify/fsnotify"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/watcher/diff"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/watcher/synthesizer"
- sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
- coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/watcher/diff"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/watcher/synthesizer"
+ sdkAuth "github.com/router-for-me/CLIProxyAPI/v7/sdk/auth"
+ coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
"gopkg.in/yaml.v3"
)
diff --git a/sdk/api/handlers/claude/code_handlers.go b/sdk/api/handlers/claude/code_handlers.go
index 074ffc0d07..464f385eb5 100644
--- a/sdk/api/handlers/claude/code_handlers.go
+++ b/sdk/api/handlers/claude/code_handlers.go
@@ -16,10 +16,10 @@ import (
"net/http"
"github.com/gin-gonic/gin"
- . "github.com/router-for-me/CLIProxyAPI/v6/internal/constant"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
- "github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers"
+ . "github.com/router-for-me/CLIProxyAPI/v7/internal/constant"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
+ "github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers"
log "github.com/sirupsen/logrus"
"github.com/tidwall/gjson"
)
diff --git a/sdk/api/handlers/gemini/gemini-cli_handlers.go b/sdk/api/handlers/gemini/gemini-cli_handlers.go
index 4c5ddf80f9..de79f05b7c 100644
--- a/sdk/api/handlers/gemini/gemini-cli_handlers.go
+++ b/sdk/api/handlers/gemini/gemini-cli_handlers.go
@@ -15,10 +15,10 @@ import (
"time"
"github.com/gin-gonic/gin"
- . "github.com/router-for-me/CLIProxyAPI/v6/internal/constant"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
- "github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers"
+ . "github.com/router-for-me/CLIProxyAPI/v7/internal/constant"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/util"
+ "github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers"
log "github.com/sirupsen/logrus"
"github.com/tidwall/gjson"
)
diff --git a/sdk/api/handlers/gemini/gemini_handlers.go b/sdk/api/handlers/gemini/gemini_handlers.go
index e51ad19bc5..60aed26a55 100644
--- a/sdk/api/handlers/gemini/gemini_handlers.go
+++ b/sdk/api/handlers/gemini/gemini_handlers.go
@@ -13,10 +13,10 @@ import (
"time"
"github.com/gin-gonic/gin"
- . "github.com/router-for-me/CLIProxyAPI/v6/internal/constant"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
- "github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers"
+ . "github.com/router-for-me/CLIProxyAPI/v7/internal/constant"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
+ "github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers"
)
// GeminiAPIHandler contains the handlers for Gemini API endpoints.
diff --git a/sdk/api/handlers/handlers.go b/sdk/api/handlers/handlers.go
index 49e73d4637..6e0adb6417 100644
--- a/sdk/api/handlers/handlers.go
+++ b/sdk/api/handlers/handlers.go
@@ -14,14 +14,14 @@ import (
"time"
"github.com/gin-gonic/gin"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/logging"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
- coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
- coreexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
- "github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
- sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/logging"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/util"
+ coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
+ coreexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
+ "github.com/router-for-me/CLIProxyAPI/v7/sdk/config"
+ sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
"golang.org/x/net/context"
)
@@ -55,6 +55,7 @@ const (
type pinnedAuthContextKey struct{}
type selectedAuthCallbackContextKey struct{}
type executionSessionContextKey struct{}
+type disallowFreeAuthContextKey struct{}
// WithPinnedAuthID returns a child context that requests execution on a specific auth ID.
func WithPinnedAuthID(ctx context.Context, authID string) context.Context {
@@ -91,6 +92,14 @@ func WithExecutionSessionID(ctx context.Context, sessionID string) context.Conte
return context.WithValue(ctx, executionSessionContextKey{}, sessionID)
}
+// WithDisallowFreeAuth returns a child context that requests skipping known free-tier credentials.
+func WithDisallowFreeAuth(ctx context.Context) context.Context {
+ if ctx == nil {
+ ctx = context.Background()
+ }
+ return context.WithValue(ctx, disallowFreeAuthContextKey{}, true)
+}
+
// BuildErrorResponseBody builds an OpenAI-compatible JSON error response body.
// If errText is already valid JSON, it is returned as-is to preserve upstream error payloads.
func BuildErrorResponseBody(status int, errText string) []byte {
@@ -189,9 +198,14 @@ func requestExecutionMetadata(ctx context.Context) map[string]any {
// Idempotency-Key is an optional client-supplied header used to correlate retries.
// Only include it if the client explicitly provides it.
key := ""
+ requestPath := ""
if ctx != nil {
if ginCtx, ok := ctx.Value("gin").(*gin.Context); ok && ginCtx != nil && ginCtx.Request != nil {
key = strings.TrimSpace(ginCtx.GetHeader("Idempotency-Key"))
+ requestPath = strings.TrimSpace(ginCtx.FullPath())
+ if requestPath == "" && ginCtx.Request.URL != nil {
+ requestPath = strings.TrimSpace(ginCtx.Request.URL.Path)
+ }
}
}
@@ -199,6 +213,9 @@ func requestExecutionMetadata(ctx context.Context) map[string]any {
if key != "" {
meta[idempotencyKeyMetadataKey] = key
}
+ if requestPath != "" {
+ meta[coreexecutor.RequestPathMetadataKey] = requestPath
+ }
if pinnedAuthID := pinnedAuthIDFromContext(ctx); pinnedAuthID != "" {
meta[coreexecutor.PinnedAuthMetadataKey] = pinnedAuthID
}
@@ -208,9 +225,25 @@ func requestExecutionMetadata(ctx context.Context) map[string]any {
if executionSessionID := executionSessionIDFromContext(ctx); executionSessionID != "" {
meta[coreexecutor.ExecutionSessionMetadataKey] = executionSessionID
}
+ if disallowFreeAuthFromContext(ctx) {
+ meta[coreexecutor.DisallowFreeAuthMetadataKey] = true
+ }
return meta
}
+// headersFromContext extracts the original HTTP request headers from the gin context
+// embedded in the provided context. This allows session affinity selectors to read
+// client headers like X-Amp-Thread-Id.
+func headersFromContext(ctx context.Context) http.Header {
+ if ctx == nil {
+ return nil
+ }
+ if ginCtx, ok := ctx.Value("gin").(*gin.Context); ok && ginCtx != nil && ginCtx.Request != nil {
+ return ginCtx.Request.Header.Clone()
+ }
+ return nil
+}
+
func pinnedAuthIDFromContext(ctx context.Context) string {
if ctx == nil {
return ""
@@ -252,6 +285,14 @@ func executionSessionIDFromContext(ctx context.Context) string {
}
}
+func disallowFreeAuthFromContext(ctx context.Context) bool {
+ if ctx == nil {
+ return false
+ }
+ raw, ok := ctx.Value(disallowFreeAuthContextKey{}).(bool)
+ return ok && raw
+}
+
// BaseAPIHandler contains the handlers for API endpoints.
// It holds a pool of clients to interact with the backend service and manages
// load balancing, client selection, and configuration.
@@ -334,11 +375,32 @@ func (h *BaseAPIHandler) GetContextWithCancel(handler interfaces.APIHandler, c *
if requestCtx != nil && logging.GetRequestID(parentCtx) == "" {
if requestID := logging.GetRequestID(requestCtx); requestID != "" {
parentCtx = logging.WithRequestID(parentCtx, requestID)
- } else if requestID := logging.GetGinRequestID(c); requestID != "" {
+ } else if requestID = logging.GetGinRequestID(c); requestID != "" {
parentCtx = logging.WithRequestID(parentCtx, requestID)
}
}
newCtx, cancel := context.WithCancel(parentCtx)
+
+ endpoint := ""
+ if c != nil && c.Request != nil {
+ path := strings.TrimSpace(c.FullPath())
+ if path == "" && c.Request.URL != nil {
+ path = strings.TrimSpace(c.Request.URL.Path)
+ }
+ if path != "" {
+ method := strings.TrimSpace(c.Request.Method)
+ if method != "" {
+ endpoint = method + " " + path
+ } else {
+ endpoint = path
+ }
+ }
+ }
+ if endpoint != "" {
+ newCtx = logging.WithEndpoint(newCtx, endpoint)
+ }
+ newCtx = logging.WithResponseStatusHolder(newCtx)
+
cancelCtx := newCtx
if requestCtx != nil && requestCtx != parentCtx {
go func() {
@@ -352,6 +414,9 @@ func (h *BaseAPIHandler) GetContextWithCancel(handler interfaces.APIHandler, c *
newCtx = context.WithValue(newCtx, "gin", c)
newCtx = context.WithValue(newCtx, "handler", handler)
return newCtx, func(params ...interface{}) {
+ if c != nil {
+ logging.SetResponseStatus(cancelCtx, c.Writer.Status())
+ }
if h.Cfg.RequestLog && len(params) == 1 {
if existing, exists := c.Get("API_RESPONSE"); exists {
if existingBytes, ok := existing.([]byte); ok && len(bytes.TrimSpace(existingBytes)) > 0 {
@@ -474,7 +539,7 @@ func (h *BaseAPIHandler) ExecuteWithAuthManager(ctx context.Context, handlerType
return nil, nil, errMsg
}
reqMeta := requestExecutionMetadata(ctx)
- reqMeta[coreexecutor.RequestedModelMetadataKey] = normalizedModel
+ reqMeta[coreexecutor.RequestedModelMetadataKey] = modelName
payload := rawJSON
if len(payload) == 0 {
payload = nil
@@ -488,6 +553,7 @@ func (h *BaseAPIHandler) ExecuteWithAuthManager(ctx context.Context, handlerType
Alt: alt,
OriginalRequest: rawJSON,
SourceFormat: sdktranslator.FromString(handlerType),
+ Headers: headersFromContext(ctx),
}
opts.Metadata = reqMeta
resp, err := h.AuthManager.Execute(ctx, providers, req, opts)
@@ -521,7 +587,7 @@ func (h *BaseAPIHandler) ExecuteCountWithAuthManager(ctx context.Context, handle
return nil, nil, errMsg
}
reqMeta := requestExecutionMetadata(ctx)
- reqMeta[coreexecutor.RequestedModelMetadataKey] = normalizedModel
+ reqMeta[coreexecutor.RequestedModelMetadataKey] = modelName
payload := rawJSON
if len(payload) == 0 {
payload = nil
@@ -535,6 +601,7 @@ func (h *BaseAPIHandler) ExecuteCountWithAuthManager(ctx context.Context, handle
Alt: alt,
OriginalRequest: rawJSON,
SourceFormat: sdktranslator.FromString(handlerType),
+ Headers: headersFromContext(ctx),
}
opts.Metadata = reqMeta
resp, err := h.AuthManager.ExecuteCount(ctx, providers, req, opts)
@@ -572,7 +639,7 @@ func (h *BaseAPIHandler) ExecuteStreamWithAuthManager(ctx context.Context, handl
return nil, nil, errChan
}
reqMeta := requestExecutionMetadata(ctx)
- reqMeta[coreexecutor.RequestedModelMetadataKey] = normalizedModel
+ reqMeta[coreexecutor.RequestedModelMetadataKey] = modelName
payload := rawJSON
if len(payload) == 0 {
payload = nil
@@ -586,6 +653,7 @@ func (h *BaseAPIHandler) ExecuteStreamWithAuthManager(ctx context.Context, handl
Alt: alt,
OriginalRequest: rawJSON,
SourceFormat: sdktranslator.FromString(handlerType),
+ Headers: headersFromContext(ctx),
}
opts.Metadata = reqMeta
streamResult, err := h.AuthManager.ExecuteStream(ctx, providers, req, opts)
@@ -782,19 +850,38 @@ func (h *BaseAPIHandler) getRequestDetails(modelName string) (providers []string
resolvedModelName := modelName
initialSuffix := thinking.ParseSuffix(modelName)
if initialSuffix.ModelName == "auto" {
- resolvedBase := util.ResolveAutoModel(initialSuffix.ModelName)
- if initialSuffix.HasSuffix {
- resolvedModelName = fmt.Sprintf("%s(%s)", resolvedBase, initialSuffix.RawSuffix)
+ if h != nil && h.AuthManager != nil && h.AuthManager.HomeEnabled() {
+ resolvedModelName = modelName
} else {
- resolvedModelName = resolvedBase
+ resolvedBase := util.ResolveAutoModel(initialSuffix.ModelName)
+ if initialSuffix.HasSuffix {
+ resolvedModelName = fmt.Sprintf("%s(%s)", resolvedBase, initialSuffix.RawSuffix)
+ } else {
+ resolvedModelName = resolvedBase
+ }
}
} else {
- resolvedModelName = util.ResolveAutoModel(modelName)
+ if h != nil && h.AuthManager != nil && h.AuthManager.HomeEnabled() {
+ resolvedModelName = modelName
+ } else {
+ resolvedModelName = util.ResolveAutoModel(modelName)
+ }
}
parsed := thinking.ParseSuffix(resolvedModelName)
baseModel := strings.TrimSpace(parsed.ModelName)
+ if strings.EqualFold(baseModel, "gpt-image-2") {
+ return nil, "", &interfaces.ErrorMessage{
+ StatusCode: http.StatusServiceUnavailable,
+ Error: fmt.Errorf("model %s is only supported on /v1/images/generations and /v1/images/edits", baseModel),
+ }
+ }
+
+ if h != nil && h.AuthManager != nil && h.AuthManager.HomeEnabled() {
+ return []string{"home"}, resolvedModelName, nil
+ }
+
providers = util.GetProviderName(baseModel)
// Fallback: if baseModel has no provider but differs from resolvedModelName,
// try using the full model name. This handles edge cases where custom models
diff --git a/sdk/api/handlers/handlers_error_response_test.go b/sdk/api/handlers/handlers_error_response_test.go
index 917971c245..0c206e386f 100644
--- a/sdk/api/handlers/handlers_error_response_test.go
+++ b/sdk/api/handlers/handlers_error_response_test.go
@@ -9,9 +9,9 @@ import (
"testing"
"github.com/gin-gonic/gin"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
- coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
- sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
+ coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
+ sdkconfig "github.com/router-for-me/CLIProxyAPI/v7/sdk/config"
)
func TestWriteErrorResponse_AddonHeadersDisabledByDefault(t *testing.T) {
diff --git a/sdk/api/handlers/handlers_metadata_test.go b/sdk/api/handlers/handlers_metadata_test.go
index 99af872dc0..c5e94f963e 100644
--- a/sdk/api/handlers/handlers_metadata_test.go
+++ b/sdk/api/handlers/handlers_metadata_test.go
@@ -3,7 +3,7 @@ package handlers
import (
"testing"
- coreexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
+ coreexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
"golang.org/x/net/context"
)
diff --git a/sdk/api/handlers/handlers_request_details_test.go b/sdk/api/handlers/handlers_request_details_test.go
index b0f6b13262..3110cbc561 100644
--- a/sdk/api/handlers/handlers_request_details_test.go
+++ b/sdk/api/handlers/handlers_request_details_test.go
@@ -1,13 +1,15 @@
package handlers
import (
+ "net/http"
"reflect"
+ "strings"
"testing"
"time"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
- coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
- sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
+ coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
+ sdkconfig "github.com/router-for-me/CLIProxyAPI/v7/sdk/config"
)
func TestGetRequestDetails_PreservesSuffix(t *testing.T) {
@@ -116,3 +118,22 @@ func TestGetRequestDetails_PreservesSuffix(t *testing.T) {
})
}
}
+
+func TestGetRequestDetails_ImageModelReturns503(t *testing.T) {
+ handler := NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, coreauth.NewManager(nil, nil, nil))
+
+ _, _, errMsg := handler.getRequestDetails("gpt-image-2")
+ if errMsg == nil {
+ t.Fatalf("expected error for gpt-image-2, got nil")
+ }
+ if errMsg.StatusCode != http.StatusServiceUnavailable {
+ t.Fatalf("unexpected status code: got %d want %d", errMsg.StatusCode, http.StatusServiceUnavailable)
+ }
+ if errMsg.Error == nil {
+ t.Fatalf("expected error message, got nil")
+ }
+ msg := errMsg.Error.Error()
+ if !strings.Contains(msg, "/v1/images/generations") || !strings.Contains(msg, "/v1/images/edits") {
+ t.Fatalf("unexpected error message: %q", msg)
+ }
+}
diff --git a/sdk/api/handlers/handlers_stream_bootstrap_test.go b/sdk/api/handlers/handlers_stream_bootstrap_test.go
index f357962f0a..551baac374 100644
--- a/sdk/api/handlers/handlers_stream_bootstrap_test.go
+++ b/sdk/api/handlers/handlers_stream_bootstrap_test.go
@@ -8,11 +8,11 @@ import (
"sync"
"testing"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
- coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
- coreexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
- sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
+ coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
+ coreexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
+ sdkconfig "github.com/router-for-me/CLIProxyAPI/v7/sdk/config"
)
type failOnceStreamExecutor struct {
diff --git a/sdk/api/handlers/openai/openai_handlers.go b/sdk/api/handlers/openai/openai_handlers.go
index 4b4a9833bd..29dc0ea0b1 100644
--- a/sdk/api/handlers/openai/openai_handlers.go
+++ b/sdk/api/handlers/openai/openai_handlers.go
@@ -14,11 +14,11 @@ import (
"sync"
"github.com/gin-gonic/gin"
- . "github.com/router-for-me/CLIProxyAPI/v6/internal/constant"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
- responsesconverter "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/openai/openai/responses"
- "github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers"
+ . "github.com/router-for-me/CLIProxyAPI/v7/internal/constant"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
+ responsesconverter "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/openai/openai/responses"
+ "github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
diff --git a/sdk/api/handlers/openai/openai_images_handlers.go b/sdk/api/handlers/openai/openai_images_handlers.go
new file mode 100644
index 0000000000..6e6e8ef6ff
--- /dev/null
+++ b/sdk/api/handlers/openai/openai_images_handlers.go
@@ -0,0 +1,963 @@
+package openai
+
+import (
+ "bytes"
+ "context"
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "io"
+ "mime/multipart"
+ "net/http"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/gin-gonic/gin"
+ internalconfig "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
+ "github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers"
+ log "github.com/sirupsen/logrus"
+ "github.com/tidwall/gjson"
+ "github.com/tidwall/sjson"
+)
+
+const (
+ defaultImagesMainModel = "gpt-5.4-mini"
+ defaultImagesToolModel = "gpt-image-2"
+ imagesGenerationsPath = "/v1/images/generations"
+ imagesEditsPath = "/v1/images/edits"
+)
+
+type imageCallResult struct {
+ Result string
+ RevisedPrompt string
+ OutputFormat string
+ Size string
+ Background string
+ Quality string
+}
+
+type sseFrameAccumulator struct {
+ pending []byte
+}
+
+func (a *sseFrameAccumulator) AddChunk(chunk []byte) [][]byte {
+ if len(chunk) == 0 {
+ return nil
+ }
+
+ if responsesSSENeedsLineBreak(a.pending, chunk) {
+ a.pending = append(a.pending, '\n')
+ }
+ a.pending = append(a.pending, chunk...)
+
+ var frames [][]byte
+ for {
+ frameLen := responsesSSEFrameLen(a.pending)
+ if frameLen == 0 {
+ break
+ }
+ frames = append(frames, a.pending[:frameLen])
+ copy(a.pending, a.pending[frameLen:])
+ a.pending = a.pending[:len(a.pending)-frameLen]
+ }
+
+ if len(bytes.TrimSpace(a.pending)) == 0 {
+ a.pending = a.pending[:0]
+ return frames
+ }
+ if len(a.pending) == 0 || !responsesSSECanEmitWithoutDelimiter(a.pending) {
+ return frames
+ }
+ frames = append(frames, a.pending)
+ a.pending = a.pending[:0]
+ return frames
+}
+
+func (a *sseFrameAccumulator) Flush() [][]byte {
+ if len(a.pending) == 0 {
+ return nil
+ }
+
+ var frames [][]byte
+ for {
+ frameLen := responsesSSEFrameLen(a.pending)
+ if frameLen == 0 {
+ break
+ }
+ frames = append(frames, a.pending[:frameLen])
+ copy(a.pending, a.pending[frameLen:])
+ a.pending = a.pending[:len(a.pending)-frameLen]
+ }
+
+ if len(bytes.TrimSpace(a.pending)) == 0 {
+ a.pending = nil
+ return frames
+ }
+ if responsesSSECanEmitWithoutDelimiter(a.pending) {
+ frames = append(frames, a.pending)
+ }
+ a.pending = nil
+ return frames
+}
+
+func isSupportedImagesModel(model string) bool {
+ baseModel := strings.TrimSpace(model)
+ if idx := strings.LastIndex(baseModel, "/"); idx >= 0 && idx < len(baseModel)-1 {
+ baseModel = strings.TrimSpace(baseModel[idx+1:])
+ }
+ return baseModel == defaultImagesToolModel
+}
+
+func rejectUnsupportedImagesModel(c *gin.Context, model string) bool {
+ if isSupportedImagesModel(model) {
+ return false
+ }
+
+ c.JSON(http.StatusBadRequest, handlers.ErrorResponse{
+ Error: handlers.ErrorDetail{
+ Message: fmt.Sprintf("Model %s is not supported on %s or %s. Use %s.", model, imagesGenerationsPath, imagesEditsPath, defaultImagesToolModel),
+ Type: "invalid_request_error",
+ },
+ })
+ return true
+}
+
+func mimeTypeFromOutputFormat(outputFormat string) string {
+ if outputFormat == "" {
+ return "image/png"
+ }
+ if strings.Contains(outputFormat, "/") {
+ return outputFormat
+ }
+ switch strings.ToLower(strings.TrimSpace(outputFormat)) {
+ case "png":
+ return "image/png"
+ case "jpg", "jpeg":
+ return "image/jpeg"
+ case "webp":
+ return "image/webp"
+ default:
+ return "image/png"
+ }
+}
+
+func multipartFileToDataURL(fileHeader *multipart.FileHeader) (string, error) {
+ if fileHeader == nil {
+ return "", fmt.Errorf("upload file is nil")
+ }
+ f, err := fileHeader.Open()
+ if err != nil {
+ return "", fmt.Errorf("open upload file failed: %w", err)
+ }
+ defer func() {
+ if errClose := f.Close(); errClose != nil {
+ log.Errorf("openai images: close upload file error: %v", errClose)
+ }
+ }()
+
+ data, err := io.ReadAll(f)
+ if err != nil {
+ return "", fmt.Errorf("read upload file failed: %w", err)
+ }
+
+ mediaType := strings.TrimSpace(fileHeader.Header.Get("Content-Type"))
+ if mediaType == "" {
+ mediaType = http.DetectContentType(data)
+ }
+
+ b64 := base64.StdEncoding.EncodeToString(data)
+ return "data:" + mediaType + ";base64," + b64, nil
+}
+
+func parseIntField(raw string, fallback int64) int64 {
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
+ return fallback
+ }
+ v, err := strconv.ParseInt(raw, 10, 64)
+ if err != nil {
+ return fallback
+ }
+ return v
+}
+
+func parseBoolField(raw string, fallback bool) bool {
+ raw = strings.TrimSpace(strings.ToLower(raw))
+ if raw == "" {
+ return fallback
+ }
+ switch raw {
+ case "1", "true", "yes", "on":
+ return true
+ case "0", "false", "no", "off":
+ return false
+ default:
+ return fallback
+ }
+}
+
+func (h *OpenAIAPIHandler) ImagesGenerations(c *gin.Context) {
+ if h != nil && h.BaseAPIHandler != nil && h.BaseAPIHandler.Cfg != nil && h.BaseAPIHandler.Cfg.DisableImageGeneration == internalconfig.DisableImageGenerationAll {
+ c.AbortWithStatus(http.StatusNotFound)
+ return
+ }
+
+ rawJSON, err := c.GetRawData()
+ if err != nil {
+ c.JSON(http.StatusBadRequest, handlers.ErrorResponse{
+ Error: handlers.ErrorDetail{
+ Message: fmt.Sprintf("Invalid request: %v", err),
+ Type: "invalid_request_error",
+ },
+ })
+ return
+ }
+ if !json.Valid(rawJSON) {
+ c.JSON(http.StatusBadRequest, handlers.ErrorResponse{
+ Error: handlers.ErrorDetail{
+ Message: "Invalid request: body must be valid JSON",
+ Type: "invalid_request_error",
+ },
+ })
+ return
+ }
+
+ imageModel := strings.TrimSpace(gjson.GetBytes(rawJSON, "model").String())
+ if imageModel == "" {
+ imageModel = defaultImagesToolModel
+ }
+ if rejectUnsupportedImagesModel(c, imageModel) {
+ return
+ }
+
+ prompt := strings.TrimSpace(gjson.GetBytes(rawJSON, "prompt").String())
+ if prompt == "" {
+ c.JSON(http.StatusBadRequest, handlers.ErrorResponse{
+ Error: handlers.ErrorDetail{
+ Message: "Invalid request: prompt is required",
+ Type: "invalid_request_error",
+ },
+ })
+ return
+ }
+
+ responseFormat := strings.TrimSpace(gjson.GetBytes(rawJSON, "response_format").String())
+ if responseFormat == "" {
+ responseFormat = "b64_json"
+ }
+ stream := gjson.GetBytes(rawJSON, "stream").Bool()
+
+ tool := []byte(`{"type":"image_generation","action":"generate"}`)
+ tool, _ = sjson.SetBytes(tool, "model", imageModel)
+
+ if v := strings.TrimSpace(gjson.GetBytes(rawJSON, "size").String()); v != "" {
+ tool, _ = sjson.SetBytes(tool, "size", v)
+ }
+ if v := strings.TrimSpace(gjson.GetBytes(rawJSON, "quality").String()); v != "" {
+ tool, _ = sjson.SetBytes(tool, "quality", v)
+ }
+ if v := strings.TrimSpace(gjson.GetBytes(rawJSON, "background").String()); v != "" {
+ tool, _ = sjson.SetBytes(tool, "background", v)
+ }
+ if v := strings.TrimSpace(gjson.GetBytes(rawJSON, "output_format").String()); v != "" {
+ tool, _ = sjson.SetBytes(tool, "output_format", v)
+ }
+ if v := gjson.GetBytes(rawJSON, "output_compression"); v.Exists() {
+ if v.Type == gjson.Number {
+ tool, _ = sjson.SetBytes(tool, "output_compression", v.Int())
+ }
+ }
+ if v := gjson.GetBytes(rawJSON, "partial_images"); v.Exists() {
+ if v.Type == gjson.Number {
+ tool, _ = sjson.SetBytes(tool, "partial_images", v.Int())
+ }
+ }
+ if v := strings.TrimSpace(gjson.GetBytes(rawJSON, "moderation").String()); v != "" {
+ tool, _ = sjson.SetBytes(tool, "moderation", v)
+ }
+
+ responsesReq := buildImagesResponsesRequest(prompt, nil, tool)
+ if stream {
+ h.streamImagesFromResponses(c, responsesReq, responseFormat, "image_generation")
+ return
+ }
+ h.collectImagesFromResponses(c, responsesReq, responseFormat)
+}
+
+func (h *OpenAIAPIHandler) ImagesEdits(c *gin.Context) {
+ if h != nil && h.BaseAPIHandler != nil && h.BaseAPIHandler.Cfg != nil && h.BaseAPIHandler.Cfg.DisableImageGeneration == internalconfig.DisableImageGenerationAll {
+ c.AbortWithStatus(http.StatusNotFound)
+ return
+ }
+
+ contentType := strings.ToLower(strings.TrimSpace(c.GetHeader("Content-Type")))
+ if strings.HasPrefix(contentType, "application/json") {
+ h.imagesEditsFromJSON(c)
+ return
+ }
+ if strings.HasPrefix(contentType, "multipart/form-data") || contentType == "" {
+ h.imagesEditsFromMultipart(c)
+ return
+ }
+
+ c.JSON(http.StatusBadRequest, handlers.ErrorResponse{
+ Error: handlers.ErrorDetail{
+ Message: fmt.Sprintf("Invalid request: unsupported Content-Type %q", contentType),
+ Type: "invalid_request_error",
+ },
+ })
+}
+
+func (h *OpenAIAPIHandler) imagesEditsFromMultipart(c *gin.Context) {
+ form, err := c.MultipartForm()
+ if err != nil {
+ c.JSON(http.StatusBadRequest, handlers.ErrorResponse{
+ Error: handlers.ErrorDetail{
+ Message: fmt.Sprintf("Invalid request: %v", err),
+ Type: "invalid_request_error",
+ },
+ })
+ return
+ }
+
+ imageModel := strings.TrimSpace(c.PostForm("model"))
+ if imageModel == "" {
+ imageModel = defaultImagesToolModel
+ }
+ if rejectUnsupportedImagesModel(c, imageModel) {
+ return
+ }
+
+ prompt := strings.TrimSpace(c.PostForm("prompt"))
+ if prompt == "" {
+ c.JSON(http.StatusBadRequest, handlers.ErrorResponse{
+ Error: handlers.ErrorDetail{
+ Message: "Invalid request: prompt is required",
+ Type: "invalid_request_error",
+ },
+ })
+ return
+ }
+
+ var imageFiles []*multipart.FileHeader
+ if files := form.File["image[]"]; len(files) > 0 {
+ imageFiles = files
+ } else if files := form.File["image"]; len(files) > 0 {
+ imageFiles = files
+ }
+ if len(imageFiles) == 0 {
+ c.JSON(http.StatusBadRequest, handlers.ErrorResponse{
+ Error: handlers.ErrorDetail{
+ Message: "Invalid request: image is required",
+ Type: "invalid_request_error",
+ },
+ })
+ return
+ }
+
+ images := make([]string, 0, len(imageFiles))
+ for _, fh := range imageFiles {
+ dataURL, err := multipartFileToDataURL(fh)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, handlers.ErrorResponse{
+ Error: handlers.ErrorDetail{
+ Message: fmt.Sprintf("Invalid request: %v", err),
+ Type: "invalid_request_error",
+ },
+ })
+ return
+ }
+ images = append(images, dataURL)
+ }
+
+ var maskDataURL *string
+ if maskFiles := form.File["mask"]; len(maskFiles) > 0 && maskFiles[0] != nil {
+ dataURL, err := multipartFileToDataURL(maskFiles[0])
+ if err != nil {
+ c.JSON(http.StatusBadRequest, handlers.ErrorResponse{
+ Error: handlers.ErrorDetail{
+ Message: fmt.Sprintf("Invalid request: %v", err),
+ Type: "invalid_request_error",
+ },
+ })
+ return
+ }
+ maskDataURL = &dataURL
+ }
+
+ responseFormat := strings.TrimSpace(c.PostForm("response_format"))
+ if responseFormat == "" {
+ responseFormat = "b64_json"
+ }
+ stream := parseBoolField(c.PostForm("stream"), false)
+
+ tool := []byte(`{"type":"image_generation","action":"edit"}`)
+ tool, _ = sjson.SetBytes(tool, "model", imageModel)
+
+ if v := strings.TrimSpace(c.PostForm("size")); v != "" {
+ tool, _ = sjson.SetBytes(tool, "size", v)
+ }
+ if v := strings.TrimSpace(c.PostForm("quality")); v != "" {
+ tool, _ = sjson.SetBytes(tool, "quality", v)
+ }
+ if v := strings.TrimSpace(c.PostForm("background")); v != "" {
+ tool, _ = sjson.SetBytes(tool, "background", v)
+ }
+ if v := strings.TrimSpace(c.PostForm("output_format")); v != "" {
+ tool, _ = sjson.SetBytes(tool, "output_format", v)
+ }
+ if v := strings.TrimSpace(c.PostForm("input_fidelity")); v != "" {
+ tool, _ = sjson.SetBytes(tool, "input_fidelity", v)
+ }
+ if v := strings.TrimSpace(c.PostForm("moderation")); v != "" {
+ tool, _ = sjson.SetBytes(tool, "moderation", v)
+ }
+
+ if v := strings.TrimSpace(c.PostForm("output_compression")); v != "" {
+ tool, _ = sjson.SetBytes(tool, "output_compression", parseIntField(v, 0))
+ }
+ if v := strings.TrimSpace(c.PostForm("partial_images")); v != "" {
+ tool, _ = sjson.SetBytes(tool, "partial_images", parseIntField(v, 0))
+ }
+
+ if maskDataURL != nil && strings.TrimSpace(*maskDataURL) != "" {
+ tool, _ = sjson.SetBytes(tool, "input_image_mask.image_url", strings.TrimSpace(*maskDataURL))
+ }
+
+ responsesReq := buildImagesResponsesRequest(prompt, images, tool)
+ if stream {
+ h.streamImagesFromResponses(c, responsesReq, responseFormat, "image_edit")
+ return
+ }
+ h.collectImagesFromResponses(c, responsesReq, responseFormat)
+}
+
+func (h *OpenAIAPIHandler) imagesEditsFromJSON(c *gin.Context) {
+ rawJSON, err := c.GetRawData()
+ if err != nil {
+ c.JSON(http.StatusBadRequest, handlers.ErrorResponse{
+ Error: handlers.ErrorDetail{
+ Message: fmt.Sprintf("Invalid request: %v", err),
+ Type: "invalid_request_error",
+ },
+ })
+ return
+ }
+ if !json.Valid(rawJSON) {
+ c.JSON(http.StatusBadRequest, handlers.ErrorResponse{
+ Error: handlers.ErrorDetail{
+ Message: "Invalid request: body must be valid JSON",
+ Type: "invalid_request_error",
+ },
+ })
+ return
+ }
+
+ imageModel := strings.TrimSpace(gjson.GetBytes(rawJSON, "model").String())
+ if imageModel == "" {
+ imageModel = defaultImagesToolModel
+ }
+ if rejectUnsupportedImagesModel(c, imageModel) {
+ return
+ }
+
+ prompt := strings.TrimSpace(gjson.GetBytes(rawJSON, "prompt").String())
+ if prompt == "" {
+ c.JSON(http.StatusBadRequest, handlers.ErrorResponse{
+ Error: handlers.ErrorDetail{
+ Message: "Invalid request: prompt is required",
+ Type: "invalid_request_error",
+ },
+ })
+ return
+ }
+
+ var images []string
+ imagesResult := gjson.GetBytes(rawJSON, "images")
+ if imagesResult.IsArray() {
+ for _, img := range imagesResult.Array() {
+ url := strings.TrimSpace(img.Get("image_url").String())
+ if url == "" {
+ continue
+ }
+ images = append(images, url)
+ }
+ }
+ if len(images) == 0 {
+ c.JSON(http.StatusBadRequest, handlers.ErrorResponse{
+ Error: handlers.ErrorDetail{
+ Message: "Invalid request: images[].image_url is required (file_id is not supported)",
+ Type: "invalid_request_error",
+ },
+ })
+ return
+ }
+
+ var maskDataURL *string
+ if mask := gjson.GetBytes(rawJSON, "mask.image_url"); mask.Exists() {
+ url := strings.TrimSpace(mask.String())
+ if url != "" {
+ maskDataURL = &url
+ }
+ } else if mask := gjson.GetBytes(rawJSON, "mask.file_id"); mask.Exists() {
+ c.JSON(http.StatusBadRequest, handlers.ErrorResponse{
+ Error: handlers.ErrorDetail{
+ Message: "Invalid request: mask.file_id is not supported (use mask.image_url instead)",
+ Type: "invalid_request_error",
+ },
+ })
+ return
+ }
+
+ responseFormat := strings.TrimSpace(gjson.GetBytes(rawJSON, "response_format").String())
+ if responseFormat == "" {
+ responseFormat = "b64_json"
+ }
+ stream := gjson.GetBytes(rawJSON, "stream").Bool()
+
+ tool := []byte(`{"type":"image_generation","action":"edit"}`)
+ tool, _ = sjson.SetBytes(tool, "model", imageModel)
+
+ for _, field := range []string{"size", "quality", "background", "output_format", "input_fidelity", "moderation"} {
+ if v := strings.TrimSpace(gjson.GetBytes(rawJSON, field).String()); v != "" {
+ tool, _ = sjson.SetBytes(tool, field, v)
+ }
+ }
+
+ for _, field := range []string{"output_compression", "partial_images"} {
+ if v := gjson.GetBytes(rawJSON, field); v.Exists() && v.Type == gjson.Number {
+ tool, _ = sjson.SetBytes(tool, field, v.Int())
+ }
+ }
+
+ if maskDataURL != nil && strings.TrimSpace(*maskDataURL) != "" {
+ tool, _ = sjson.SetBytes(tool, "input_image_mask.image_url", strings.TrimSpace(*maskDataURL))
+ }
+
+ responsesReq := buildImagesResponsesRequest(prompt, images, tool)
+ if stream {
+ h.streamImagesFromResponses(c, responsesReq, responseFormat, "image_edit")
+ return
+ }
+ h.collectImagesFromResponses(c, responsesReq, responseFormat)
+}
+
+func buildImagesResponsesRequest(prompt string, images []string, toolJSON []byte) []byte {
+ req := []byte(`{"instructions":"","stream":true,"reasoning":{"effort":"medium","summary":"auto"},"parallel_tool_calls":true,"include":["reasoning.encrypted_content"],"model":"","store":false,"tool_choice":{"type":"image_generation"}}`)
+ mainModel := defaultImagesMainModel
+ if len(toolJSON) > 0 && json.Valid(toolJSON) {
+ toolModel := strings.TrimSpace(gjson.GetBytes(toolJSON, "model").String())
+ if idx := strings.LastIndex(toolModel, "/"); idx > 0 && idx < len(toolModel)-1 {
+ prefix := strings.TrimSpace(toolModel[:idx])
+ if prefix != "" {
+ mainModel = prefix + "/" + defaultImagesMainModel
+ }
+ }
+ }
+ req, _ = sjson.SetBytes(req, "model", mainModel)
+
+ input := []byte(`[{"type":"message","role":"user","content":[{"type":"input_text","text":""}]}]`)
+ input, _ = sjson.SetBytes(input, "0.content.0.text", prompt)
+ contentIndex := 1
+ for _, img := range images {
+ if strings.TrimSpace(img) == "" {
+ continue
+ }
+ part := []byte(`{"type":"input_image","image_url":""}`)
+ part, _ = sjson.SetBytes(part, "image_url", img)
+ path := fmt.Sprintf("0.content.%d", contentIndex)
+ input, _ = sjson.SetRawBytes(input, path, part)
+ contentIndex++
+ }
+ req, _ = sjson.SetRawBytes(req, "input", input)
+
+ req, _ = sjson.SetRawBytes(req, "tools", []byte(`[]`))
+ if len(toolJSON) > 0 && json.Valid(toolJSON) {
+ req, _ = sjson.SetRawBytes(req, "tools.-1", toolJSON)
+ }
+ return req
+}
+
+func (h *OpenAIAPIHandler) collectImagesFromResponses(c *gin.Context, responsesReq []byte, responseFormat string) {
+ c.Header("Content-Type", "application/json")
+
+ cliCtx, cliCancel := h.GetContextWithCancel(h, c, context.Background())
+ cliCtx = handlers.WithDisallowFreeAuth(cliCtx)
+ stopKeepAlive := h.StartNonStreamingKeepAlive(c, cliCtx)
+
+ mainModel := strings.TrimSpace(gjson.GetBytes(responsesReq, "model").String())
+ if mainModel == "" {
+ mainModel = defaultImagesMainModel
+ }
+ dataChan, upstreamHeaders, errChan := h.ExecuteStreamWithAuthManager(cliCtx, "openai-response", mainModel, responsesReq, "")
+
+ out, errMsg := collectImagesFromResponsesStream(cliCtx, dataChan, errChan, responseFormat)
+ stopKeepAlive()
+ if errMsg != nil {
+ h.WriteErrorResponse(c, errMsg)
+ if errMsg.Error != nil {
+ cliCancel(errMsg.Error)
+ } else {
+ cliCancel(nil)
+ }
+ return
+ }
+ handlers.WriteUpstreamHeaders(c.Writer.Header(), upstreamHeaders)
+ _, _ = c.Writer.Write(out)
+ cliCancel()
+}
+
+func collectImagesFromResponsesStream(ctx context.Context, data <-chan []byte, errs <-chan *interfaces.ErrorMessage, responseFormat string) ([]byte, *interfaces.ErrorMessage) {
+ acc := &sseFrameAccumulator{}
+
+ processFrame := func(frame []byte) ([]byte, bool, *interfaces.ErrorMessage) {
+ for _, line := range bytes.Split(frame, []byte("\n")) {
+ trimmed := bytes.TrimSpace(bytes.TrimRight(line, "\r"))
+ if len(trimmed) == 0 {
+ continue
+ }
+ if !bytes.HasPrefix(trimmed, []byte("data:")) {
+ continue
+ }
+ payload := bytes.TrimSpace(trimmed[len("data:"):])
+ if len(payload) == 0 || bytes.Equal(payload, []byte("[DONE]")) {
+ continue
+ }
+ if !json.Valid(payload) {
+ return nil, false, &interfaces.ErrorMessage{StatusCode: http.StatusBadGateway, Error: fmt.Errorf("invalid SSE data JSON")}
+ }
+
+ if gjson.GetBytes(payload, "type").String() != "response.completed" {
+ continue
+ }
+
+ results, createdAt, usageRaw, firstMeta, err := extractImagesFromResponsesCompleted(payload)
+ if err != nil {
+ return nil, false, &interfaces.ErrorMessage{StatusCode: http.StatusBadGateway, Error: err}
+ }
+ if len(results) == 0 {
+ return nil, false, &interfaces.ErrorMessage{StatusCode: http.StatusBadGateway, Error: fmt.Errorf("upstream did not return image output")}
+ }
+ out, err := buildImagesAPIResponse(results, createdAt, usageRaw, firstMeta, responseFormat)
+ if err != nil {
+ return nil, false, &interfaces.ErrorMessage{StatusCode: http.StatusInternalServerError, Error: err}
+ }
+ return out, true, nil
+ }
+ return nil, false, nil
+ }
+
+ for {
+ select {
+ case <-ctx.Done():
+ return nil, &interfaces.ErrorMessage{StatusCode: http.StatusRequestTimeout, Error: ctx.Err()}
+ case errMsg, ok := <-errs:
+ if ok && errMsg != nil {
+ return nil, errMsg
+ }
+ errs = nil
+ case chunk, ok := <-data:
+ if !ok {
+ for _, frame := range acc.Flush() {
+ if out, done, errMsg := processFrame(frame); errMsg != nil {
+ return nil, errMsg
+ } else if done {
+ return out, nil
+ }
+ }
+ return nil, &interfaces.ErrorMessage{StatusCode: http.StatusBadGateway, Error: fmt.Errorf("stream disconnected before completion")}
+ }
+ for _, frame := range acc.AddChunk(chunk) {
+ if out, done, errMsg := processFrame(frame); errMsg != nil {
+ return nil, errMsg
+ } else if done {
+ return out, nil
+ }
+ }
+ }
+ }
+}
+
+func extractImagesFromResponsesCompleted(payload []byte) (results []imageCallResult, createdAt int64, usageRaw []byte, firstMeta imageCallResult, err error) {
+ if gjson.GetBytes(payload, "type").String() != "response.completed" {
+ return nil, 0, nil, imageCallResult{}, fmt.Errorf("unexpected event type")
+ }
+
+ createdAt = gjson.GetBytes(payload, "response.created_at").Int()
+ if createdAt <= 0 {
+ createdAt = time.Now().Unix()
+ }
+
+ output := gjson.GetBytes(payload, "response.output")
+ if output.IsArray() {
+ for _, item := range output.Array() {
+ if item.Get("type").String() != "image_generation_call" {
+ continue
+ }
+ res := strings.TrimSpace(item.Get("result").String())
+ if res == "" {
+ continue
+ }
+ entry := imageCallResult{
+ Result: res,
+ RevisedPrompt: strings.TrimSpace(item.Get("revised_prompt").String()),
+ OutputFormat: strings.TrimSpace(item.Get("output_format").String()),
+ Size: strings.TrimSpace(item.Get("size").String()),
+ Background: strings.TrimSpace(item.Get("background").String()),
+ Quality: strings.TrimSpace(item.Get("quality").String()),
+ }
+ if len(results) == 0 {
+ firstMeta = entry
+ }
+ results = append(results, entry)
+ }
+ }
+
+ if usage := gjson.GetBytes(payload, "response.tool_usage.image_gen"); usage.Exists() && usage.IsObject() {
+ usageRaw = []byte(usage.Raw)
+ }
+
+ return results, createdAt, usageRaw, firstMeta, nil
+}
+
+func buildImagesAPIResponse(results []imageCallResult, createdAt int64, usageRaw []byte, firstMeta imageCallResult, responseFormat string) ([]byte, error) {
+ out := []byte(`{"created":0,"data":[]}`)
+ out, _ = sjson.SetBytes(out, "created", createdAt)
+
+ responseFormat = strings.ToLower(strings.TrimSpace(responseFormat))
+ if responseFormat == "" {
+ responseFormat = "b64_json"
+ }
+
+ for _, img := range results {
+ item := []byte(`{}`)
+ if responseFormat == "url" {
+ mt := mimeTypeFromOutputFormat(img.OutputFormat)
+ item, _ = sjson.SetBytes(item, "url", "data:"+mt+";base64,"+img.Result)
+ } else {
+ item, _ = sjson.SetBytes(item, "b64_json", img.Result)
+ }
+ if img.RevisedPrompt != "" {
+ item, _ = sjson.SetBytes(item, "revised_prompt", img.RevisedPrompt)
+ }
+ out, _ = sjson.SetRawBytes(out, "data.-1", item)
+ }
+
+ if firstMeta.Background != "" {
+ out, _ = sjson.SetBytes(out, "background", firstMeta.Background)
+ }
+ if firstMeta.OutputFormat != "" {
+ out, _ = sjson.SetBytes(out, "output_format", firstMeta.OutputFormat)
+ }
+ if firstMeta.Quality != "" {
+ out, _ = sjson.SetBytes(out, "quality", firstMeta.Quality)
+ }
+ if firstMeta.Size != "" {
+ out, _ = sjson.SetBytes(out, "size", firstMeta.Size)
+ }
+
+ if len(usageRaw) > 0 && json.Valid(usageRaw) {
+ out, _ = sjson.SetRawBytes(out, "usage", usageRaw)
+ }
+
+ return out, nil
+}
+
+func (h *OpenAIAPIHandler) streamImagesFromResponses(c *gin.Context, responsesReq []byte, responseFormat string, streamPrefix string) {
+ flusher, ok := c.Writer.(http.Flusher)
+ if !ok {
+ c.JSON(http.StatusInternalServerError, handlers.ErrorResponse{
+ Error: handlers.ErrorDetail{
+ Message: "Streaming not supported",
+ Type: "server_error",
+ },
+ })
+ return
+ }
+
+ cliCtx, cliCancel := h.GetContextWithCancel(h, c, context.Background())
+ cliCtx = handlers.WithDisallowFreeAuth(cliCtx)
+ mainModel := strings.TrimSpace(gjson.GetBytes(responsesReq, "model").String())
+ if mainModel == "" {
+ mainModel = defaultImagesMainModel
+ }
+ dataChan, upstreamHeaders, errChan := h.ExecuteStreamWithAuthManager(cliCtx, "openai-response", mainModel, responsesReq, "")
+
+ setSSEHeaders := func() {
+ c.Header("Content-Type", "text/event-stream")
+ c.Header("Cache-Control", "no-cache")
+ c.Header("Connection", "keep-alive")
+ c.Header("Access-Control-Allow-Origin", "*")
+ }
+
+ writeEvent := func(eventName string, dataJSON []byte) {
+ if strings.TrimSpace(eventName) != "" {
+ _, _ = fmt.Fprintf(c.Writer, "event: %s\n", eventName)
+ }
+ _, _ = fmt.Fprintf(c.Writer, "data: %s\n\n", string(dataJSON))
+ flusher.Flush()
+ }
+
+ // Peek for first chunk/error so we can still return a JSON error body.
+ for {
+ select {
+ case <-c.Request.Context().Done():
+ cliCancel(c.Request.Context().Err())
+ return
+ case errMsg, ok := <-errChan:
+ if !ok {
+ errChan = nil
+ continue
+ }
+ h.WriteErrorResponse(c, errMsg)
+ if errMsg != nil {
+ cliCancel(errMsg.Error)
+ } else {
+ cliCancel(nil)
+ }
+ return
+ case chunk, ok := <-dataChan:
+ if !ok {
+ setSSEHeaders()
+ handlers.WriteUpstreamHeaders(c.Writer.Header(), upstreamHeaders)
+ _, _ = c.Writer.Write([]byte("\n"))
+ flusher.Flush()
+ cliCancel(nil)
+ return
+ }
+
+ setSSEHeaders()
+ handlers.WriteUpstreamHeaders(c.Writer.Header(), upstreamHeaders)
+
+ h.forwardImagesStream(cliCtx, c, flusher, func(err error) { cliCancel(err) }, dataChan, errChan, chunk, responseFormat, streamPrefix, writeEvent)
+ return
+ }
+ }
+}
+
+func (h *OpenAIAPIHandler) forwardImagesStream(ctx context.Context, c *gin.Context, flusher http.Flusher, cancel func(error), data <-chan []byte, errs <-chan *interfaces.ErrorMessage, firstChunk []byte, responseFormat string, streamPrefix string, writeEvent func(string, []byte)) {
+ acc := &sseFrameAccumulator{}
+
+ responseFormat = strings.ToLower(strings.TrimSpace(responseFormat))
+ if responseFormat == "" {
+ responseFormat = "b64_json"
+ }
+
+ emitError := func(errMsg *interfaces.ErrorMessage) {
+ if errMsg == nil {
+ return
+ }
+ status := http.StatusInternalServerError
+ if errMsg.StatusCode > 0 {
+ status = errMsg.StatusCode
+ }
+ errText := http.StatusText(status)
+ if errMsg.Error != nil && strings.TrimSpace(errMsg.Error.Error()) != "" {
+ errText = errMsg.Error.Error()
+ }
+ body := handlers.BuildErrorResponseBody(status, errText)
+ writeEvent("error", body)
+ }
+
+ processFrame := func(frame []byte) (done bool) {
+ for _, line := range bytes.Split(frame, []byte("\n")) {
+ trimmed := bytes.TrimSpace(bytes.TrimRight(line, "\r"))
+ if len(trimmed) == 0 || !bytes.HasPrefix(trimmed, []byte("data:")) {
+ continue
+ }
+ payload := bytes.TrimSpace(trimmed[len("data:"):])
+ if len(payload) == 0 || bytes.Equal(payload, []byte("[DONE]")) || !json.Valid(payload) {
+ continue
+ }
+
+ switch gjson.GetBytes(payload, "type").String() {
+ case "response.image_generation_call.partial_image":
+ b64 := strings.TrimSpace(gjson.GetBytes(payload, "partial_image_b64").String())
+ if b64 == "" {
+ continue
+ }
+ outputFormat := strings.TrimSpace(gjson.GetBytes(payload, "output_format").String())
+ index := gjson.GetBytes(payload, "partial_image_index").Int()
+ eventName := streamPrefix + ".partial_image"
+ data := []byte(`{"type":"","partial_image_index":0}`)
+ data, _ = sjson.SetBytes(data, "type", eventName)
+ data, _ = sjson.SetBytes(data, "partial_image_index", index)
+ if responseFormat == "url" {
+ mt := mimeTypeFromOutputFormat(outputFormat)
+ data, _ = sjson.SetBytes(data, "url", "data:"+mt+";base64,"+b64)
+ } else {
+ data, _ = sjson.SetBytes(data, "b64_json", b64)
+ }
+ writeEvent(eventName, data)
+ case "response.completed":
+ results, _, usageRaw, _, err := extractImagesFromResponsesCompleted(payload)
+ if err != nil {
+ emitError(&interfaces.ErrorMessage{StatusCode: http.StatusBadGateway, Error: err})
+ return true
+ }
+ if len(results) == 0 {
+ emitError(&interfaces.ErrorMessage{StatusCode: http.StatusBadGateway, Error: fmt.Errorf("upstream did not return image output")})
+ return true
+ }
+ eventName := streamPrefix + ".completed"
+ for _, img := range results {
+ data := []byte(`{"type":""}`)
+ data, _ = sjson.SetBytes(data, "type", eventName)
+ if responseFormat == "url" {
+ mt := mimeTypeFromOutputFormat(img.OutputFormat)
+ data, _ = sjson.SetBytes(data, "url", "data:"+mt+";base64,"+img.Result)
+ } else {
+ data, _ = sjson.SetBytes(data, "b64_json", img.Result)
+ }
+ if len(usageRaw) > 0 && json.Valid(usageRaw) {
+ data, _ = sjson.SetRawBytes(data, "usage", usageRaw)
+ }
+ writeEvent(eventName, data)
+ }
+ return true
+ }
+ }
+ return false
+ }
+
+ for _, frame := range acc.AddChunk(firstChunk) {
+ if processFrame(frame) {
+ cancel(nil)
+ return
+ }
+ }
+
+ for {
+ select {
+ case <-c.Request.Context().Done():
+ cancel(c.Request.Context().Err())
+ return
+ case errMsg, ok := <-errs:
+ if ok && errMsg != nil {
+ emitError(errMsg)
+ cancel(errMsg.Error)
+ return
+ }
+ errs = nil
+ case chunk, ok := <-data:
+ if !ok {
+ for _, frame := range acc.Flush() {
+ if processFrame(frame) {
+ cancel(nil)
+ return
+ }
+ }
+ cancel(nil)
+ return
+ }
+ for _, frame := range acc.AddChunk(chunk) {
+ if processFrame(frame) {
+ cancel(nil)
+ return
+ }
+ }
+ }
+ }
+}
diff --git a/sdk/api/handlers/openai/openai_images_handlers_test.go b/sdk/api/handlers/openai/openai_images_handlers_test.go
new file mode 100644
index 0000000000..7796599619
--- /dev/null
+++ b/sdk/api/handlers/openai/openai_images_handlers_test.go
@@ -0,0 +1,146 @@
+package openai
+
+import (
+ "bytes"
+ "io"
+ "mime/multipart"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+ internalconfig "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers"
+ sdkconfig "github.com/router-for-me/CLIProxyAPI/v7/sdk/config"
+ "github.com/tidwall/gjson"
+)
+
+func performImagesEndpointRequest(t *testing.T, endpointPath string, contentType string, body io.Reader, handler gin.HandlerFunc) *httptest.ResponseRecorder {
+ t.Helper()
+
+ gin.SetMode(gin.TestMode)
+ router := gin.New()
+ router.POST(endpointPath, handler)
+
+ req := httptest.NewRequest(http.MethodPost, endpointPath, body)
+ if contentType != "" {
+ req.Header.Set("Content-Type", contentType)
+ }
+ resp := httptest.NewRecorder()
+ router.ServeHTTP(resp, req)
+ return resp
+}
+
+func assertUnsupportedImagesModelResponse(t *testing.T, resp *httptest.ResponseRecorder, model string) {
+ t.Helper()
+
+ if resp.Code != http.StatusBadRequest {
+ t.Fatalf("status = %d, want %d: %s", resp.Code, http.StatusBadRequest, resp.Body.String())
+ }
+
+ message := gjson.GetBytes(resp.Body.Bytes(), "error.message").String()
+ expectedMessage := "Model " + model + " is not supported on " + imagesGenerationsPath + " or " + imagesEditsPath + ". Use " + defaultImagesToolModel + "."
+ if message != expectedMessage {
+ t.Fatalf("error message = %q, want %q", message, expectedMessage)
+ }
+ if errorType := gjson.GetBytes(resp.Body.Bytes(), "error.type").String(); errorType != "invalid_request_error" {
+ t.Fatalf("error type = %q, want invalid_request_error", errorType)
+ }
+}
+
+func TestImagesModelValidationAllowsGPTImage2WithOptionalPrefix(t *testing.T) {
+ for _, model := range []string{"gpt-image-2", "codex/gpt-image-2"} {
+ if !isSupportedImagesModel(model) {
+ t.Fatalf("expected %s to be supported", model)
+ }
+ }
+ if isSupportedImagesModel("gpt-5.4-mini") {
+ t.Fatal("expected gpt-5.4-mini to be rejected")
+ }
+}
+
+func TestImagesGenerationsRejectsUnsupportedModel(t *testing.T) {
+ handler := &OpenAIAPIHandler{}
+ body := strings.NewReader(`{"model":"gpt-5.4-mini","prompt":"draw a square"}`)
+
+ resp := performImagesEndpointRequest(t, imagesGenerationsPath, "application/json", body, handler.ImagesGenerations)
+
+ assertUnsupportedImagesModelResponse(t, resp, "gpt-5.4-mini")
+}
+
+func TestImagesEditsJSONRejectsUnsupportedModel(t *testing.T) {
+ handler := &OpenAIAPIHandler{}
+ body := strings.NewReader(`{"model":"gpt-5.4-mini","prompt":"edit this","images":[{"image_url":"data:image/png;base64,AA=="}]}`)
+
+ resp := performImagesEndpointRequest(t, imagesEditsPath, "application/json", body, handler.ImagesEdits)
+
+ assertUnsupportedImagesModelResponse(t, resp, "gpt-5.4-mini")
+}
+
+func TestImagesEditsMultipartRejectsUnsupportedModel(t *testing.T) {
+ handler := &OpenAIAPIHandler{}
+ var body bytes.Buffer
+ writer := multipart.NewWriter(&body)
+ if err := writer.WriteField("model", "gpt-5.4-mini"); err != nil {
+ t.Fatalf("write model field: %v", err)
+ }
+ if err := writer.WriteField("prompt", "edit this"); err != nil {
+ t.Fatalf("write prompt field: %v", err)
+ }
+ if errClose := writer.Close(); errClose != nil {
+ t.Fatalf("close multipart writer: %v", errClose)
+ }
+
+ resp := performImagesEndpointRequest(t, imagesEditsPath, writer.FormDataContentType(), &body, handler.ImagesEdits)
+
+ assertUnsupportedImagesModelResponse(t, resp, "gpt-5.4-mini")
+}
+
+func TestImagesGenerations_DisableImageGeneration_Returns404(t *testing.T) {
+ base := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{DisableImageGeneration: internalconfig.DisableImageGenerationAll}, nil)
+ handler := NewOpenAIAPIHandler(base)
+ body := strings.NewReader(`{"prompt":"draw a square"}`)
+
+ resp := performImagesEndpointRequest(t, imagesGenerationsPath, "application/json", body, handler.ImagesGenerations)
+
+ if resp.Code != http.StatusNotFound {
+ t.Fatalf("status = %d, want %d: %s", resp.Code, http.StatusNotFound, resp.Body.String())
+ }
+}
+
+func TestImagesEdits_DisableImageGeneration_Returns404(t *testing.T) {
+ base := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{DisableImageGeneration: internalconfig.DisableImageGenerationAll}, nil)
+ handler := NewOpenAIAPIHandler(base)
+ body := strings.NewReader(`{"prompt":"edit this","images":[{"image_url":"data:image/png;base64,AA=="}]}`)
+
+ resp := performImagesEndpointRequest(t, imagesEditsPath, "application/json", body, handler.ImagesEdits)
+
+ if resp.Code != http.StatusNotFound {
+ t.Fatalf("status = %d, want %d: %s", resp.Code, http.StatusNotFound, resp.Body.String())
+ }
+}
+
+func TestImagesGenerations_DisableImageGenerationChat_DoesNotReturn404(t *testing.T) {
+ base := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{DisableImageGeneration: internalconfig.DisableImageGenerationChat}, nil)
+ handler := NewOpenAIAPIHandler(base)
+ body := strings.NewReader(`{"model":"gpt-5.4-mini","prompt":"draw a square"}`)
+
+ resp := performImagesEndpointRequest(t, imagesGenerationsPath, "application/json", body, handler.ImagesGenerations)
+
+ if resp.Code != http.StatusBadRequest {
+ t.Fatalf("status = %d, want %d: %s", resp.Code, http.StatusBadRequest, resp.Body.String())
+ }
+}
+
+func TestImagesEdits_DisableImageGenerationChat_DoesNotReturn404(t *testing.T) {
+ base := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{DisableImageGeneration: internalconfig.DisableImageGenerationChat}, nil)
+ handler := NewOpenAIAPIHandler(base)
+ body := strings.NewReader(`{"model":"gpt-5.4-mini","prompt":"edit this","images":[{"image_url":"data:image/png;base64,AA=="}]}`)
+
+ resp := performImagesEndpointRequest(t, imagesEditsPath, "application/json", body, handler.ImagesEdits)
+
+ if resp.Code != http.StatusBadRequest {
+ t.Fatalf("status = %d, want %d: %s", resp.Code, http.StatusBadRequest, resp.Body.String())
+ }
+}
diff --git a/sdk/api/handlers/openai/openai_responses_compact_test.go b/sdk/api/handlers/openai/openai_responses_compact_test.go
index dcfcc99a7c..48b7e3bbde 100644
--- a/sdk/api/handlers/openai/openai_responses_compact_test.go
+++ b/sdk/api/handlers/openai/openai_responses_compact_test.go
@@ -9,11 +9,11 @@ import (
"testing"
"github.com/gin-gonic/gin"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
- "github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers"
- coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
- coreexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
- sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
+ "github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers"
+ coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
+ coreexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
+ sdkconfig "github.com/router-for-me/CLIProxyAPI/v7/sdk/config"
)
type compactCaptureExecutor struct {
diff --git a/sdk/api/handlers/openai/openai_responses_handlers.go b/sdk/api/handlers/openai/openai_responses_handlers.go
index 8969ce2f6d..5b2c006a30 100644
--- a/sdk/api/handlers/openai/openai_responses_handlers.go
+++ b/sdk/api/handlers/openai/openai_responses_handlers.go
@@ -13,12 +13,13 @@ import (
"fmt"
"io"
"net/http"
+ "sort"
"github.com/gin-gonic/gin"
- . "github.com/router-for-me/CLIProxyAPI/v6/internal/constant"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
- "github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers"
+ . "github.com/router-for-me/CLIProxyAPI/v7/internal/constant"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
+ "github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
@@ -45,7 +46,10 @@ func writeResponsesSSEChunk(w io.Writer, chunk []byte) {
}
type responsesSSEFramer struct {
- pending []byte
+ pending []byte
+ outputItems map[int][]byte
+ outputOrder []int
+ unindexedOutputItems [][]byte
}
func (f *responsesSSEFramer) WriteChunk(w io.Writer, chunk []byte) {
@@ -61,7 +65,7 @@ func (f *responsesSSEFramer) WriteChunk(w io.Writer, chunk []byte) {
if frameLen == 0 {
break
}
- writeResponsesSSEChunk(w, f.pending[:frameLen])
+ f.writeFrame(w, f.pending[:frameLen])
copy(f.pending, f.pending[frameLen:])
f.pending = f.pending[:len(f.pending)-frameLen]
}
@@ -72,7 +76,7 @@ func (f *responsesSSEFramer) WriteChunk(w io.Writer, chunk []byte) {
if len(f.pending) == 0 || !responsesSSECanEmitWithoutDelimiter(f.pending) {
return
}
- writeResponsesSSEChunk(w, f.pending)
+ f.writeFrame(w, f.pending)
f.pending = f.pending[:0]
}
@@ -88,10 +92,133 @@ func (f *responsesSSEFramer) Flush(w io.Writer) {
f.pending = f.pending[:0]
return
}
- writeResponsesSSEChunk(w, f.pending)
+ f.writeFrame(w, f.pending)
f.pending = f.pending[:0]
}
+func (f *responsesSSEFramer) writeFrame(w io.Writer, frame []byte) {
+ writeResponsesSSEChunk(w, f.repairFrame(frame))
+}
+
+func (f *responsesSSEFramer) repairFrame(frame []byte) []byte {
+ payload, ok := responsesSSEDataPayload(frame)
+ if !ok || len(payload) == 0 || bytes.Equal(payload, []byte("[DONE]")) || !json.Valid(payload) {
+ return frame
+ }
+
+ switch gjson.GetBytes(payload, "type").String() {
+ case "response.output_item.done":
+ f.recordOutputItem(payload)
+ case "response.completed":
+ repaired := f.repairCompletedPayload(payload)
+ if !bytes.Equal(repaired, payload) {
+ return responsesSSEFrameWithData(frame, repaired)
+ }
+ }
+ return frame
+}
+
+func responsesSSEDataPayload(frame []byte) ([]byte, bool) {
+ var payload []byte
+ found := false
+ for _, line := range bytes.Split(frame, []byte("\n")) {
+ line = bytes.TrimRight(line, "\r")
+ trimmed := bytes.TrimSpace(line)
+ if !bytes.HasPrefix(trimmed, []byte("data:")) {
+ continue
+ }
+ data := bytes.TrimSpace(trimmed[len("data:"):])
+ if found {
+ payload = append(payload, '\n')
+ }
+ payload = append(payload, data...)
+ found = true
+ }
+ return payload, found
+}
+
+func responsesSSEFrameWithData(frame, payload []byte) []byte {
+ var out bytes.Buffer
+ for _, line := range bytes.Split(frame, []byte("\n")) {
+ line = bytes.TrimRight(line, "\r")
+ trimmed := bytes.TrimSpace(line)
+ if len(trimmed) == 0 || bytes.HasPrefix(trimmed, []byte("data:")) {
+ continue
+ }
+ out.Write(line)
+ out.WriteByte('\n')
+ }
+ for _, line := range bytes.Split(payload, []byte("\n")) {
+ out.WriteString("data: ")
+ out.Write(line)
+ out.WriteByte('\n')
+ }
+ out.WriteByte('\n')
+ return out.Bytes()
+}
+
+func (f *responsesSSEFramer) recordOutputItem(payload []byte) {
+ item := gjson.GetBytes(payload, "item")
+ if !item.Exists() || !item.IsObject() || item.Get("type").String() == "" {
+ return
+ }
+
+ if outputIndex := gjson.GetBytes(payload, "output_index"); outputIndex.Exists() {
+ index := int(outputIndex.Int())
+ if f.outputItems == nil {
+ f.outputItems = make(map[int][]byte)
+ }
+ if _, exists := f.outputItems[index]; !exists {
+ f.outputOrder = append(f.outputOrder, index)
+ }
+ f.outputItems[index] = append([]byte(nil), item.Raw...)
+ return
+ }
+
+ f.unindexedOutputItems = append(f.unindexedOutputItems, append([]byte(nil), item.Raw...))
+}
+
+func (f *responsesSSEFramer) repairCompletedPayload(payload []byte) []byte {
+ if len(f.outputOrder) == 0 && len(f.unindexedOutputItems) == 0 {
+ return payload
+ }
+ output := gjson.GetBytes(payload, "response.output")
+ if output.Exists() && (!output.IsArray() || len(output.Array()) > 0) {
+ return payload
+ }
+
+ var outputJSON bytes.Buffer
+ outputJSON.WriteByte('[')
+ indexes := append([]int(nil), f.outputOrder...)
+ sort.Ints(indexes)
+ written := 0
+ for _, index := range indexes {
+ item, ok := f.outputItems[index]
+ if !ok {
+ continue
+ }
+ if written > 0 {
+ outputJSON.WriteByte(',')
+ }
+ outputJSON.Write(item)
+ written++
+ }
+ for _, item := range f.unindexedOutputItems {
+ if written > 0 {
+ outputJSON.WriteByte(',')
+ }
+ outputJSON.Write(item)
+ written++
+ }
+ outputJSON.WriteByte(']')
+
+ repaired, err := sjson.SetRawBytes(payload, "response.output", outputJSON.Bytes())
+ if err != nil {
+ return payload
+ }
+ return repaired
+}
+
func responsesSSEFrameLen(chunk []byte) int {
if len(chunk) == 0 {
return 0
diff --git a/sdk/api/handlers/openai/openai_responses_handlers_stream_error_test.go b/sdk/api/handlers/openai/openai_responses_handlers_stream_error_test.go
index 771e46b88b..54d1467589 100644
--- a/sdk/api/handlers/openai/openai_responses_handlers_stream_error_test.go
+++ b/sdk/api/handlers/openai/openai_responses_handlers_stream_error_test.go
@@ -8,9 +8,9 @@ import (
"testing"
"github.com/gin-gonic/gin"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
- "github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers"
- sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
+ "github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers"
+ sdkconfig "github.com/router-for-me/CLIProxyAPI/v7/sdk/config"
)
func TestForwardResponsesStreamTerminalErrorUsesResponsesErrorChunk(t *testing.T) {
diff --git a/sdk/api/handlers/openai/openai_responses_handlers_stream_test.go b/sdk/api/handlers/openai/openai_responses_handlers_stream_test.go
index ef16fe80ac..0742b9b3d3 100644
--- a/sdk/api/handlers/openai/openai_responses_handlers_stream_test.go
+++ b/sdk/api/handlers/openai/openai_responses_handlers_stream_test.go
@@ -7,9 +7,10 @@ import (
"testing"
"github.com/gin-gonic/gin"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
- "github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers"
- sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
+ "github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers"
+ sdkconfig "github.com/router-for-me/CLIProxyAPI/v7/sdk/config"
+ "github.com/tidwall/gjson"
)
func newResponsesStreamTestHandler(t *testing.T) (*OpenAIResponsesAPIHandler, *httptest.ResponseRecorder, *gin.Context, http.Flusher) {
@@ -53,12 +54,108 @@ func TestForwardResponsesStreamSeparatesDataOnlySSEChunks(t *testing.T) {
t.Errorf("unexpected first event.\nGot: %q\nWant: %q", parts[0], expectedPart1)
}
- expectedPart2 := "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp-1\",\"output\":[]}}"
+ expectedPart2 := "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp-1\",\"output\":[{\"type\":\"function_call\",\"arguments\":\"{}\"}]}}"
if parts[1] != expectedPart2 {
t.Errorf("unexpected second event.\nGot: %q\nWant: %q", parts[1], expectedPart2)
}
}
+func TestForwardResponsesStreamRepairsEmptyCompletedOutputFromDoneItems(t *testing.T) {
+ h, recorder, c, flusher := newResponsesStreamTestHandler(t)
+
+ data := make(chan []byte, 3)
+ errs := make(chan *interfaces.ErrorMessage)
+ data <- []byte(`data: {"type":"response.output_item.done","output_index":0,"item":{"type":"reasoning","id":"rs-1","summary":[]}}`)
+ data <- []byte(`data: {"type":"response.output_item.done","output_index":1,"item":{"type":"function_call","id":"fc-1","call_id":"call-1","name":"shell","arguments":"{\"cmd\":\"pwd\"}","status":"completed"}}`)
+ data <- []byte(`data: {"type":"response.completed","response":{"id":"resp-1","output":[]}}`)
+ close(data)
+ close(errs)
+
+ h.forwardResponsesStream(c, flusher, func(error) {}, data, errs, nil)
+
+ parts := strings.Split(strings.TrimSpace(recorder.Body.String()), "\n\n")
+ if len(parts) != 3 {
+ t.Fatalf("expected 3 SSE events, got %d. Body: %q", len(parts), recorder.Body.String())
+ }
+
+ payload := strings.TrimPrefix(parts[2], "data: ")
+ output := gjson.Get(payload, "response.output")
+ if !output.IsArray() || len(output.Array()) != 2 {
+ t.Fatalf("expected repaired completed output with 2 items, got %s", output.Raw)
+ }
+ if got := gjson.Get(payload, "response.output.1.name").String(); got != "shell" {
+ t.Fatalf("expected function_call name to be preserved, got %q in %s", got, payload)
+ }
+ if got := gjson.Get(payload, "response.output.1.arguments").String(); got != `{"cmd":"pwd"}` {
+ t.Fatalf("expected function_call arguments to be preserved, got %q in %s", got, payload)
+ }
+}
+
+func TestForwardResponsesStreamRepairsMixedIndexedAndUnindexedDoneItems(t *testing.T) {
+ h, recorder, c, flusher := newResponsesStreamTestHandler(t)
+
+ data := make(chan []byte, 3)
+ errs := make(chan *interfaces.ErrorMessage)
+ data <- []byte(`data: {"type":"response.output_item.done","output_index":1,"item":{"type":"function_call","id":"fc-1","call_id":"call-1","name":"shell","arguments":"{}","status":"completed"}}`)
+ data <- []byte(`data: {"type":"response.output_item.done","item":{"type":"message","id":"msg-1","role":"assistant","content":[{"type":"output_text","text":"done"}]}}`)
+ data <- []byte(`data: {"type":"response.completed","response":{"id":"resp-1","output":[]}}`)
+ close(data)
+ close(errs)
+
+ h.forwardResponsesStream(c, flusher, func(error) {}, data, errs, nil)
+
+ parts := strings.Split(strings.TrimSpace(recorder.Body.String()), "\n\n")
+ if len(parts) != 3 {
+ t.Fatalf("expected 3 SSE events, got %d. Body: %q", len(parts), recorder.Body.String())
+ }
+
+ payload := strings.TrimPrefix(parts[2], "data: ")
+ output := gjson.Get(payload, "response.output")
+ if !output.IsArray() || len(output.Array()) != 2 {
+ t.Fatalf("expected repaired completed output with 2 items, got %s", output.Raw)
+ }
+ if got := gjson.Get(payload, "response.output.0.name").String(); got != "shell" {
+ t.Fatalf("expected indexed function_call to be preserved first, got %q in %s", got, payload)
+ }
+ if got := gjson.Get(payload, "response.output.1.id").String(); got != "msg-1" {
+ t.Fatalf("expected unindexed message to be appended, got %q in %s", got, payload)
+ }
+}
+
+func TestForwardResponsesStreamRepairsMultilineCompletedOutputAsSSEDataLines(t *testing.T) {
+ h, recorder, c, flusher := newResponsesStreamTestHandler(t)
+
+ data := make(chan []byte, 2)
+ errs := make(chan *interfaces.ErrorMessage)
+ data <- []byte(`data: {"type":"response.output_item.done","item":{"type":"function_call","arguments":"{}"}}`)
+ data <- []byte("data: {\"type\":\"response.completed\",\ndata: \"response\":{\"id\":\"resp-1\",\"output\":[]}}\n\n")
+ close(data)
+ close(errs)
+
+ h.forwardResponsesStream(c, flusher, func(error) {}, data, errs, nil)
+
+ parts := strings.Split(strings.TrimSpace(recorder.Body.String()), "\n\n")
+ if len(parts) != 2 {
+ t.Fatalf("expected 2 SSE events, got %d. Body: %q", len(parts), recorder.Body.String())
+ }
+
+ completedFrame := []byte(parts[1])
+ for _, line := range strings.Split(parts[1], "\n") {
+ if line != "" && !strings.HasPrefix(line, "data: ") {
+ t.Fatalf("expected every completed payload line to be an SSE data line, got %q in %q", line, parts[1])
+ }
+ }
+
+ payload, ok := responsesSSEDataPayload(completedFrame)
+ if !ok {
+ t.Fatalf("expected completed frame to contain data payload: %q", parts[1])
+ }
+ output := gjson.GetBytes(payload, "response.output")
+ if !output.IsArray() || len(output.Array()) != 1 {
+ t.Fatalf("expected repaired completed output with 1 item, got %s from %q", output.Raw, payload)
+ }
+}
+
func TestForwardResponsesStreamReassemblesSplitSSEEventChunks(t *testing.T) {
h, recorder, c, flusher := newResponsesStreamTestHandler(t)
diff --git a/sdk/api/handlers/openai/openai_responses_websocket.go b/sdk/api/handlers/openai/openai_responses_websocket.go
index 2f6b14a779..574338fd75 100644
--- a/sdk/api/handlers/openai/openai_responses_websocket.go
+++ b/sdk/api/handlers/openai/openai_responses_websocket.go
@@ -13,13 +13,13 @@ import (
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/gorilla/websocket"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
- "github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers"
- coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
- cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/util"
+ "github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers"
+ coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
+ cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
log "github.com/sirupsen/logrus"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
@@ -56,6 +56,31 @@ func (h *OpenAIResponsesAPIHandler) ResponsesWebsocket(c *gin.Context) {
retainResponsesWebsocketToolCaches(downstreamSessionKey)
clientIP := websocketClientAddress(c)
log.Infof("responses websocket: client connected id=%s remote=%s", passthroughSessionID, clientIP)
+
+ wsDone := make(chan struct{})
+ defer close(wsDone)
+
+ if h != nil && h.AuthManager != nil {
+ if exec, ok := h.AuthManager.Executor("codex"); ok && exec != nil {
+ type upstreamDisconnectSubscriber interface {
+ UpstreamDisconnectChan(sessionID string) <-chan error
+ }
+ if subscriber, ok := exec.(upstreamDisconnectSubscriber); ok && subscriber != nil {
+ disconnectCh := subscriber.UpstreamDisconnectChan(passthroughSessionID)
+ if disconnectCh != nil {
+ go func() {
+ select {
+ case <-wsDone:
+ return
+ case <-disconnectCh:
+ _ = conn.Close()
+ }
+ }()
+ }
+ }
+ }
+ }
+
var wsTerminateErr error
var wsTimelineLog strings.Builder
defer func() {
@@ -79,6 +104,16 @@ func (h *OpenAIResponsesAPIHandler) ResponsesWebsocket(c *gin.Context) {
var lastRequest []byte
lastResponseOutput := []byte("[]")
pinnedAuthID := ""
+ sessionAuthByID := func(authID string) (*coreauth.Auth, bool) {
+ if h == nil || h.AuthManager == nil {
+ return nil, false
+ }
+ if auth, ok := h.AuthManager.GetExecutionSessionAuthByID(passthroughSessionID, authID); ok {
+ return auth, true
+ }
+ return h.AuthManager.GetByID(authID)
+ }
+ forceTranscriptReplayNextRequest := false
for {
msgType, payload, errReadMessage := conn.ReadMessage()
@@ -104,8 +139,8 @@ func (h *OpenAIResponsesAPIHandler) ResponsesWebsocket(c *gin.Context) {
appendWebsocketTimelineEvent(&wsTimelineLog, "request", payload, time.Now())
allowIncrementalInputWithPreviousResponseID := false
- if pinnedAuthID != "" && h != nil && h.AuthManager != nil {
- if pinnedAuth, ok := h.AuthManager.GetByID(pinnedAuthID); ok && pinnedAuth != nil {
+ if pinnedAuthID != "" {
+ if pinnedAuth, ok := sessionAuthByID(pinnedAuthID); ok && pinnedAuth != nil {
allowIncrementalInputWithPreviousResponseID = websocketUpstreamSupportsIncrementalInput(pinnedAuth.Attributes, pinnedAuth.Metadata)
}
} else {
@@ -115,6 +150,22 @@ func (h *OpenAIResponsesAPIHandler) ResponsesWebsocket(c *gin.Context) {
}
allowIncrementalInputWithPreviousResponseID = h.websocketUpstreamSupportsIncrementalInputForModel(requestModelName)
}
+ if forceTranscriptReplayNextRequest {
+ allowIncrementalInputWithPreviousResponseID = false
+ }
+
+ allowCompactionReplayBypass := false
+ if pinnedAuthID != "" {
+ if pinnedAuth, ok := sessionAuthByID(pinnedAuthID); ok && pinnedAuth != nil {
+ allowCompactionReplayBypass = responsesWebsocketAuthSupportsCompactionReplay(pinnedAuth)
+ }
+ } else {
+ requestModelName := strings.TrimSpace(gjson.GetBytes(payload, "model").String())
+ if requestModelName == "" {
+ requestModelName = strings.TrimSpace(gjson.GetBytes(lastRequest, "model").String())
+ }
+ allowCompactionReplayBypass = h.websocketUpstreamSupportsCompactionReplayForModel(requestModelName)
+ }
var requestJSON []byte
var updatedLastRequest []byte
@@ -124,6 +175,7 @@ func (h *OpenAIResponsesAPIHandler) ResponsesWebsocket(c *gin.Context) {
lastRequest,
lastResponseOutput,
allowIncrementalInputWithPreviousResponseID,
+ allowCompactionReplayBypass,
)
if errMsg != nil {
h.LoggingAPIResponseError(context.WithValue(context.Background(), "gin", c), errMsg)
@@ -165,7 +217,13 @@ func (h *OpenAIResponsesAPIHandler) ResponsesWebsocket(c *gin.Context) {
requestJSON = repairResponsesWebsocketToolCalls(downstreamSessionKey, requestJSON)
updatedLastRequest = bytes.Clone(requestJSON)
+ previousLastRequest := bytes.Clone(lastRequest)
+ previousLastResponseOutput := bytes.Clone(lastResponseOutput)
+ forcedTranscriptReplay := forceTranscriptReplayNextRequest
lastRequest = updatedLastRequest
+ if forcedTranscriptReplay {
+ forceTranscriptReplayNextRequest = false
+ }
modelName := gjson.GetBytes(requestJSON, "model").String()
cliCtx, cliCancel := h.GetContextWithCancel(h, c, context.Background())
@@ -179,7 +237,7 @@ func (h *OpenAIResponsesAPIHandler) ResponsesWebsocket(c *gin.Context) {
if authID == "" || h == nil || h.AuthManager == nil {
return
}
- selectedAuth, ok := h.AuthManager.GetByID(authID)
+ selectedAuth, ok := sessionAuthByID(authID)
if !ok || selectedAuth == nil {
return
}
@@ -190,12 +248,19 @@ func (h *OpenAIResponsesAPIHandler) ResponsesWebsocket(c *gin.Context) {
}
dataChan, _, errChan := h.ExecuteStreamWithAuthManager(cliCtx, h.HandlerType(), modelName, requestJSON, "")
- completedOutput, errForward := h.forwardResponsesWebsocket(c, conn, cliCancel, dataChan, errChan, &wsTimelineLog, passthroughSessionID)
+ completedOutput, forwardErrMsg, errForward := h.forwardResponsesWebsocket(c, conn, cliCancel, dataChan, errChan, &wsTimelineLog, passthroughSessionID)
if errForward != nil {
wsTerminateErr = errForward
log.Warnf("responses websocket: forward failed id=%s error=%v", passthroughSessionID, errForward)
return
}
+ if shouldReleaseResponsesWebsocketPinnedAuth(forwardErrMsg) {
+ pinnedAuthID = ""
+ forceTranscriptReplayNextRequest = true
+ lastRequest = previousLastRequest
+ lastResponseOutput = previousLastResponseOutput
+ continue
+ }
lastResponseOutput = completedOutput
}
}
@@ -222,10 +287,10 @@ func websocketUpgradeHeaders(req *http.Request) http.Header {
}
func normalizeResponsesWebsocketRequest(rawJSON []byte, lastRequest []byte, lastResponseOutput []byte) ([]byte, []byte, *interfaces.ErrorMessage) {
- return normalizeResponsesWebsocketRequestWithMode(rawJSON, lastRequest, lastResponseOutput, true)
+ return normalizeResponsesWebsocketRequestWithMode(rawJSON, lastRequest, lastResponseOutput, true, true)
}
-func normalizeResponsesWebsocketRequestWithMode(rawJSON []byte, lastRequest []byte, lastResponseOutput []byte, allowIncrementalInputWithPreviousResponseID bool) ([]byte, []byte, *interfaces.ErrorMessage) {
+func normalizeResponsesWebsocketRequestWithMode(rawJSON []byte, lastRequest []byte, lastResponseOutput []byte, allowIncrementalInputWithPreviousResponseID bool, allowCompactionReplayBypass bool) ([]byte, []byte, *interfaces.ErrorMessage) {
requestType := strings.TrimSpace(gjson.GetBytes(rawJSON, "type").String())
switch requestType {
case wsRequestTypeCreate:
@@ -233,10 +298,10 @@ func normalizeResponsesWebsocketRequestWithMode(rawJSON []byte, lastRequest []by
if len(lastRequest) == 0 {
return normalizeResponseCreateRequest(rawJSON)
}
- return normalizeResponseSubsequentRequest(rawJSON, lastRequest, lastResponseOutput, allowIncrementalInputWithPreviousResponseID)
+ return normalizeResponseSubsequentRequest(rawJSON, lastRequest, lastResponseOutput, allowIncrementalInputWithPreviousResponseID, allowCompactionReplayBypass)
case wsRequestTypeAppend:
// log.Infof("responses websocket: response.append request")
- return normalizeResponseSubsequentRequest(rawJSON, lastRequest, lastResponseOutput, allowIncrementalInputWithPreviousResponseID)
+ return normalizeResponseSubsequentRequest(rawJSON, lastRequest, lastResponseOutput, allowIncrementalInputWithPreviousResponseID, allowCompactionReplayBypass)
default:
return nil, lastRequest, &interfaces.ErrorMessage{
StatusCode: http.StatusBadRequest,
@@ -265,7 +330,7 @@ func normalizeResponseCreateRequest(rawJSON []byte) ([]byte, []byte, *interfaces
return normalized, bytes.Clone(normalized), nil
}
-func normalizeResponseSubsequentRequest(rawJSON []byte, lastRequest []byte, lastResponseOutput []byte, allowIncrementalInputWithPreviousResponseID bool) ([]byte, []byte, *interfaces.ErrorMessage) {
+func normalizeResponseSubsequentRequest(rawJSON []byte, lastRequest []byte, lastResponseOutput []byte, allowIncrementalInputWithPreviousResponseID bool, allowCompactionReplayBypass bool) ([]byte, []byte, *interfaces.ErrorMessage) {
if len(lastRequest) == 0 {
return nil, lastRequest, &interfaces.ErrorMessage{
StatusCode: http.StatusBadRequest,
@@ -315,20 +380,37 @@ func normalizeResponseSubsequentRequest(rawJSON []byte, lastRequest []byte, last
}
}
- existingInput := gjson.GetBytes(lastRequest, "input")
- mergedInput, errMerge := mergeJSONArrayRaw(existingInput.Raw, normalizeJSONArrayRaw(lastResponseOutput))
- if errMerge != nil {
- return nil, lastRequest, &interfaces.ErrorMessage{
- StatusCode: http.StatusBadRequest,
- Error: fmt.Errorf("invalid previous response output: %w", errMerge),
+ // When the client sends a compact replay for a downstream that can consume it
+ // directly, the input already carries the canonical history. In that case,
+ // skip merging with stale lastRequest/lastResponseOutput to avoid breaking
+ // function_call / function_call_output pairings.
+ // See: https://github.com/router-for-me/CLIProxyAPI/issues/2207
+ var mergedInput string
+ if allowCompactionReplayBypass && inputContainsFullTranscript(nextInput) {
+ log.Infof("responses websocket: full transcript detected, skipping stale merge (input items=%d)", len(nextInput.Array()))
+ mergedInput = nextInput.Raw
+ } else {
+ appendInputRaw := nextInput.Raw
+ if inputContainsFullTranscript(nextInput) {
+ appendInputRaw = inputWithoutCompactionItems(nextInput)
}
- }
- mergedInput, errMerge = mergeJSONArrayRaw(mergedInput, nextInput.Raw)
- if errMerge != nil {
- return nil, lastRequest, &interfaces.ErrorMessage{
- StatusCode: http.StatusBadRequest,
- Error: fmt.Errorf("invalid request input: %w", errMerge),
+ existingInput := gjson.GetBytes(lastRequest, "input")
+ var errMerge error
+ mergedInput, errMerge = mergeJSONArrayRaw(existingInput.Raw, normalizeJSONArrayRaw(lastResponseOutput))
+ if errMerge != nil {
+ return nil, lastRequest, &interfaces.ErrorMessage{
+ StatusCode: http.StatusBadRequest,
+ Error: fmt.Errorf("invalid previous response output: %w", errMerge),
+ }
+ }
+
+ mergedInput, errMerge = mergeJSONArrayRaw(mergedInput, appendInputRaw)
+ if errMerge != nil {
+ return nil, lastRequest, &interfaces.ErrorMessage{
+ StatusCode: http.StatusBadRequest,
+ Error: fmt.Errorf("invalid request input: %w", errMerge),
+ }
}
}
dedupedInput, errDedupeFunctionCalls := dedupeFunctionCallsByCallID(mergedInput)
@@ -480,72 +562,104 @@ func websocketUpstreamSupportsIncrementalInput(attributes map[string]string, met
}
func (h *OpenAIResponsesAPIHandler) websocketUpstreamSupportsIncrementalInputForModel(modelName string) bool {
- if h == nil || h.AuthManager == nil {
+ auths, _ := h.responsesWebsocketAvailableAuthsForModel(modelName)
+ for _, auth := range auths {
+ if websocketUpstreamSupportsIncrementalInput(auth.Attributes, auth.Metadata) {
+ return true
+ }
+ }
+ return false
+}
+
+func (h *OpenAIResponsesAPIHandler) websocketUpstreamSupportsCompactionReplayForModel(modelName string) bool {
+ auths, _ := h.responsesWebsocketAvailableAuthsForModel(modelName)
+ if len(auths) == 0 {
return false
}
+ for _, auth := range auths {
+ if !responsesWebsocketAuthSupportsCompactionReplay(auth) {
+ return false
+ }
+ }
+ return true
+}
+
+func (h *OpenAIResponsesAPIHandler) responsesWebsocketAvailableAuthsForModel(modelName string) ([]*coreauth.Auth, string) {
+ if h == nil || h.AuthManager == nil {
+ return nil, ""
+ }
+ resolvedModelName := responsesWebsocketResolvedModelName(modelName)
+ providerSet, modelKey := responsesWebsocketProviderSetForModel(resolvedModelName)
+ if len(providerSet) == 0 {
+ return nil, modelKey
+ }
- resolvedModelName := modelName
+ registryRef := registry.GetGlobalRegistry()
+ now := time.Now()
+ auths := h.AuthManager.List()
+ available := make([]*coreauth.Auth, 0, len(auths))
+ for _, auth := range auths {
+ if !responsesWebsocketAuthMatchesModel(auth, providerSet, modelKey, registryRef, now) {
+ continue
+ }
+ available = append(available, auth)
+ }
+ return available, modelKey
+}
+
+func responsesWebsocketResolvedModelName(modelName string) string {
initialSuffix := thinking.ParseSuffix(modelName)
if initialSuffix.ModelName == "auto" {
resolvedBase := util.ResolveAutoModel(initialSuffix.ModelName)
if initialSuffix.HasSuffix {
- resolvedModelName = fmt.Sprintf("%s(%s)", resolvedBase, initialSuffix.RawSuffix)
- } else {
- resolvedModelName = resolvedBase
+ return fmt.Sprintf("%s(%s)", resolvedBase, initialSuffix.RawSuffix)
}
- } else {
- resolvedModelName = util.ResolveAutoModel(modelName)
+ return resolvedBase
}
+ return util.ResolveAutoModel(modelName)
+}
+func responsesWebsocketProviderSetForModel(resolvedModelName string) (map[string]struct{}, string) {
parsed := thinking.ParseSuffix(resolvedModelName)
baseModel := strings.TrimSpace(parsed.ModelName)
providers := util.GetProviderName(baseModel)
if len(providers) == 0 && baseModel != resolvedModelName {
providers = util.GetProviderName(resolvedModelName)
}
- if len(providers) == 0 {
- return false
- }
-
providerSet := make(map[string]struct{}, len(providers))
- for i := 0; i < len(providers); i++ {
- providerKey := strings.TrimSpace(strings.ToLower(providers[i]))
+ for _, provider := range providers {
+ providerKey := strings.TrimSpace(strings.ToLower(provider))
if providerKey == "" {
continue
}
providerSet[providerKey] = struct{}{}
}
- if len(providerSet) == 0 {
- return false
- }
-
modelKey := baseModel
if modelKey == "" {
modelKey = strings.TrimSpace(resolvedModelName)
}
- registryRef := registry.GetGlobalRegistry()
- now := time.Now()
- auths := h.AuthManager.List()
- for i := 0; i < len(auths); i++ {
- auth := auths[i]
- if auth == nil {
- continue
- }
- providerKey := strings.TrimSpace(strings.ToLower(auth.Provider))
- if _, ok := providerSet[providerKey]; !ok {
- continue
- }
- if modelKey != "" && registryRef != nil && !registryRef.ClientSupportsModel(auth.ID, modelKey) {
- continue
- }
- if !responsesWebsocketAuthAvailableForModel(auth, modelKey, now) {
- continue
- }
- if websocketUpstreamSupportsIncrementalInput(auth.Attributes, auth.Metadata) {
- return true
- }
+ return providerSet, modelKey
+}
+
+func responsesWebsocketAuthMatchesModel(auth *coreauth.Auth, providerSet map[string]struct{}, modelKey string, registryRef *registry.ModelRegistry, now time.Time) bool {
+ if auth == nil {
+ return false
}
- return false
+ providerKey := strings.TrimSpace(strings.ToLower(auth.Provider))
+ if _, ok := providerSet[providerKey]; !ok {
+ return false
+ }
+ if modelKey != "" && registryRef != nil && !registryRef.ClientSupportsModel(auth.ID, modelKey) {
+ return false
+ }
+ return responsesWebsocketAuthAvailableForModel(auth, modelKey, now)
+}
+
+func responsesWebsocketAuthSupportsCompactionReplay(auth *coreauth.Auth) bool {
+ if auth == nil {
+ return false
+ }
+ return strings.EqualFold(strings.TrimSpace(auth.Provider), "codex")
}
func responsesWebsocketAuthAvailableForModel(auth *coreauth.Auth, modelName string, now time.Time) bool {
@@ -691,6 +805,42 @@ func mergeJSONArrayRaw(existingRaw, appendRaw string) (string, error) {
return string(out), nil
}
+// inputContainsFullTranscript returns true when the input array carries compact
+// replay markers that indicate the client already sent the full conversation
+// transcript. Merging that input with stale lastRequest/lastResponseOutput
+// would duplicate or break function_call/function_call_output pairings, so the
+// caller should use the input as-is.
+//
+// Assistant messages alone are not enough to classify the payload as a replay:
+// incremental websocket requests may legitimately append assistant items.
+func inputContainsFullTranscript(input gjson.Result) bool {
+ if !input.IsArray() {
+ return false
+ }
+ for _, item := range input.Array() {
+ t := item.Get("type").String()
+ if t == "compaction" || t == "compaction_summary" {
+ return true
+ }
+ }
+ return false
+}
+
+func inputWithoutCompactionItems(input gjson.Result) string {
+ if !input.IsArray() {
+ return normalizeJSONArrayRaw([]byte(input.Raw))
+ }
+ filtered := make([]string, 0, len(input.Array()))
+ for _, item := range input.Array() {
+ t := item.Get("type").String()
+ if t == "compaction" || t == "compaction_summary" {
+ continue
+ }
+ filtered = append(filtered, item.Raw)
+ }
+ return "[" + strings.Join(filtered, ",") + "]"
+}
+
func normalizeJSONArrayRaw(raw []byte) string {
trimmed := strings.TrimSpace(string(raw))
if trimmed == "" {
@@ -711,7 +861,7 @@ func (h *OpenAIResponsesAPIHandler) forwardResponsesWebsocket(
errs <-chan *interfaces.ErrorMessage,
wsTimelineLog *strings.Builder,
sessionID string,
-) ([]byte, error) {
+) ([]byte, *interfaces.ErrorMessage, error) {
completed := false
completedOutput := []byte("[]")
downstreamSessionKey := ""
@@ -723,7 +873,7 @@ func (h *OpenAIResponsesAPIHandler) forwardResponsesWebsocket(
select {
case <-c.Request.Context().Done():
cancel(c.Request.Context().Err())
- return completedOutput, c.Request.Context().Err()
+ return completedOutput, nil, c.Request.Context().Err()
case errMsg, ok := <-errs:
if !ok {
errs = nil
@@ -748,7 +898,7 @@ func (h *OpenAIResponsesAPIHandler) forwardResponsesWebsocket(
// errWrite,
// )
cancel(errMsg.Error)
- return completedOutput, errWrite
+ return completedOutput, errMsg, errWrite
}
}
if errMsg != nil {
@@ -756,7 +906,7 @@ func (h *OpenAIResponsesAPIHandler) forwardResponsesWebsocket(
} else {
cancel(nil)
}
- return completedOutput, nil
+ return completedOutput, errMsg, nil
case chunk, ok := <-data:
if !ok {
if !completed {
@@ -782,13 +932,13 @@ func (h *OpenAIResponsesAPIHandler) forwardResponsesWebsocket(
errWrite,
)
cancel(errMsg.Error)
- return completedOutput, errWrite
+ return completedOutput, errMsg, errWrite
}
cancel(errMsg.Error)
- return completedOutput, nil
+ return completedOutput, errMsg, nil
}
cancel(nil)
- return completedOutput, nil
+ return completedOutput, nil, nil
}
payloads := websocketJSONPayloadsFromChunk(chunk)
@@ -815,13 +965,31 @@ func (h *OpenAIResponsesAPIHandler) forwardResponsesWebsocket(
errWrite,
)
cancel(errWrite)
- return completedOutput, errWrite
+ return completedOutput, nil, errWrite
}
}
}
}
}
+func shouldReleaseResponsesWebsocketPinnedAuth(errMsg *interfaces.ErrorMessage) bool {
+ if errMsg == nil {
+ return false
+ }
+ status := errMsg.StatusCode
+ if status <= 0 && errMsg.Error != nil {
+ if se, ok := errMsg.Error.(interface{ StatusCode() int }); ok && se != nil {
+ status = se.StatusCode()
+ }
+ }
+ switch status {
+ case http.StatusUnauthorized, http.StatusPaymentRequired, http.StatusForbidden, http.StatusTooManyRequests:
+ return true
+ default:
+ return false
+ }
+}
+
func responseCompletedOutputFromPayload(payload []byte) []byte {
output := gjson.GetBytes(payload, "response.output")
if output.Exists() && output.IsArray() {
diff --git a/sdk/api/handlers/openai/openai_responses_websocket_test.go b/sdk/api/handlers/openai/openai_responses_websocket_test.go
index ecfc90b31b..7ff58fa3c8 100644
--- a/sdk/api/handlers/openai/openai_responses_websocket_test.go
+++ b/sdk/api/handlers/openai/openai_responses_websocket_test.go
@@ -14,12 +14,12 @@ import (
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
- "github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers"
- coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
- coreexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
- sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
+ "github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers"
+ coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
+ coreexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
+ sdkconfig "github.com/router-for-me/CLIProxyAPI/v7/sdk/config"
"github.com/tidwall/gjson"
)
@@ -69,6 +69,95 @@ type websocketAuthCaptureExecutor struct {
authIDs []string
}
+type websocketPinnedFailoverExecutor struct {
+ mu sync.Mutex
+ authIDs []string
+ calls map[string]int
+ payloads map[string][][]byte
+}
+
+type websocketPinnedFailoverStatusError struct {
+ status int
+ msg string
+}
+
+func (e websocketPinnedFailoverStatusError) Error() string { return e.msg }
+
+func (e websocketPinnedFailoverStatusError) StatusCode() int { return e.status }
+
+type websocketUpstreamDisconnectExecutor struct {
+ mu sync.Mutex
+ subscribed chan string
+ sessions map[string]chan error
+}
+
+func (e *websocketUpstreamDisconnectExecutor) Identifier() string { return "codex" }
+
+func (e *websocketUpstreamDisconnectExecutor) UpstreamDisconnectChan(sessionID string) <-chan error {
+ sessionID = strings.TrimSpace(sessionID)
+ if sessionID == "" {
+ return nil
+ }
+ e.mu.Lock()
+ if e.sessions == nil {
+ e.sessions = make(map[string]chan error)
+ }
+ ch, ok := e.sessions[sessionID]
+ if !ok {
+ ch = make(chan error, 1)
+ e.sessions[sessionID] = ch
+ }
+ subscribed := e.subscribed
+ e.mu.Unlock()
+
+ if subscribed != nil {
+ select {
+ case subscribed <- sessionID:
+ default:
+ }
+ }
+ return ch
+}
+
+func (e *websocketUpstreamDisconnectExecutor) TriggerDisconnect(sessionID string, err error) {
+ sessionID = strings.TrimSpace(sessionID)
+ if sessionID == "" {
+ return
+ }
+ e.mu.Lock()
+ ch := e.sessions[sessionID]
+ delete(e.sessions, sessionID)
+ e.mu.Unlock()
+ if ch == nil {
+ return
+ }
+ select {
+ case ch <- err:
+ default:
+ }
+ close(ch)
+}
+
+func (e *websocketUpstreamDisconnectExecutor) Execute(context.Context, *coreauth.Auth, coreexecutor.Request, coreexecutor.Options) (coreexecutor.Response, error) {
+ return coreexecutor.Response{}, errors.New("not implemented")
+}
+
+func (e *websocketUpstreamDisconnectExecutor) ExecuteStream(context.Context, *coreauth.Auth, coreexecutor.Request, coreexecutor.Options) (*coreexecutor.StreamResult, error) {
+ return nil, errors.New("not implemented")
+}
+
+func (e *websocketUpstreamDisconnectExecutor) Refresh(_ context.Context, auth *coreauth.Auth) (*coreauth.Auth, error) {
+ return auth, nil
+}
+
+func (e *websocketUpstreamDisconnectExecutor) CountTokens(context.Context, *coreauth.Auth, coreexecutor.Request, coreexecutor.Options) (coreexecutor.Response, error) {
+ return coreexecutor.Response{}, errors.New("not implemented")
+}
+
+func (e *websocketUpstreamDisconnectExecutor) HttpRequest(context.Context, *coreauth.Auth, *http.Request) (*http.Response, error) {
+ return nil, errors.New("not implemented")
+}
+
func (e *websocketAuthCaptureExecutor) Identifier() string { return "test-provider" }
func (e *websocketAuthCaptureExecutor) Execute(context.Context, *coreauth.Auth, coreexecutor.Request, coreexecutor.Options) (coreexecutor.Response, error) {
@@ -106,6 +195,76 @@ func (e *websocketAuthCaptureExecutor) AuthIDs() []string {
return append([]string(nil), e.authIDs...)
}
+func (e *websocketPinnedFailoverExecutor) Identifier() string { return "test-provider" }
+
+func (e *websocketPinnedFailoverExecutor) Execute(context.Context, *coreauth.Auth, coreexecutor.Request, coreexecutor.Options) (coreexecutor.Response, error) {
+ return coreexecutor.Response{}, errors.New("not implemented")
+}
+
+func (e *websocketPinnedFailoverExecutor) ExecuteStream(_ context.Context, auth *coreauth.Auth, req coreexecutor.Request, _ coreexecutor.Options) (*coreexecutor.StreamResult, error) {
+ authID := ""
+ if auth != nil {
+ authID = auth.ID
+ }
+
+ e.mu.Lock()
+ if e.calls == nil {
+ e.calls = make(map[string]int)
+ }
+ if e.payloads == nil {
+ e.payloads = make(map[string][][]byte)
+ }
+ e.authIDs = append(e.authIDs, authID)
+ e.calls[authID]++
+ call := e.calls[authID]
+ e.payloads[authID] = append(e.payloads[authID], bytes.Clone(req.Payload))
+ e.mu.Unlock()
+
+ if authID == "auth-a" && call == 2 {
+ chunks := make(chan coreexecutor.StreamChunk, 1)
+ chunks <- coreexecutor.StreamChunk{Err: websocketPinnedFailoverStatusError{
+ status: http.StatusTooManyRequests,
+ msg: `{"error":{"message":"quota exhausted","type":"rate_limit_error","code":"rate_limit_exceeded"}}`,
+ }}
+ close(chunks)
+ return &coreexecutor.StreamResult{Chunks: chunks}, nil
+ }
+
+ chunks := make(chan coreexecutor.StreamChunk, 1)
+ chunks <- coreexecutor.StreamChunk{Payload: []byte(fmt.Sprintf(`{"type":"response.completed","response":{"id":"resp-%s-%d","output":[{"type":"message","id":"out-%s-%d"}]}}`, authID, call, authID, call))}
+ close(chunks)
+ return &coreexecutor.StreamResult{Chunks: chunks}, nil
+}
+
+func (e *websocketPinnedFailoverExecutor) Refresh(_ context.Context, auth *coreauth.Auth) (*coreauth.Auth, error) {
+ return auth, nil
+}
+
+func (e *websocketPinnedFailoverExecutor) CountTokens(context.Context, *coreauth.Auth, coreexecutor.Request, coreexecutor.Options) (coreexecutor.Response, error) {
+ return coreexecutor.Response{}, errors.New("not implemented")
+}
+
+func (e *websocketPinnedFailoverExecutor) HttpRequest(context.Context, *coreauth.Auth, *http.Request) (*http.Response, error) {
+ return nil, errors.New("not implemented")
+}
+
+func (e *websocketPinnedFailoverExecutor) AuthIDs() []string {
+ e.mu.Lock()
+ defer e.mu.Unlock()
+ return append([]string(nil), e.authIDs...)
+}
+
+func (e *websocketPinnedFailoverExecutor) Payloads(authID string) [][]byte {
+ e.mu.Lock()
+ defer e.mu.Unlock()
+ src := e.payloads[authID]
+ out := make([][]byte, len(src))
+ for i := range src {
+ out[i] = bytes.Clone(src[i])
+ }
+ return out
+}
+
func (e *websocketCaptureExecutor) Identifier() string { return "test-provider" }
func (e *websocketCaptureExecutor) Execute(context.Context, *coreauth.Auth, coreexecutor.Request, coreexecutor.Options) (coreexecutor.Response, error) {
@@ -242,7 +401,7 @@ func TestNormalizeResponsesWebsocketRequestWithPreviousResponseIDIncremental(t *
]`)
raw := []byte(`{"type":"response.create","previous_response_id":"resp-1","input":[{"type":"function_call_output","call_id":"call-1","id":"tool-out-1"}]}`)
- normalized, next, errMsg := normalizeResponsesWebsocketRequestWithMode(raw, lastRequest, lastResponseOutput, true)
+ normalized, next, errMsg := normalizeResponsesWebsocketRequestWithMode(raw, lastRequest, lastResponseOutput, true, false)
if errMsg != nil {
t.Fatalf("unexpected error: %v", errMsg.Error)
}
@@ -278,7 +437,7 @@ func TestNormalizeResponsesWebsocketRequestWithPreviousResponseIDMergedWhenIncre
]`)
raw := []byte(`{"type":"response.create","previous_response_id":"resp-1","input":[{"type":"function_call_output","call_id":"call-1","id":"tool-out-1"}]}`)
- normalized, next, errMsg := normalizeResponsesWebsocketRequestWithMode(raw, lastRequest, lastResponseOutput, false)
+ normalized, next, errMsg := normalizeResponsesWebsocketRequestWithMode(raw, lastRequest, lastResponseOutput, false, false)
if errMsg != nil {
t.Fatalf("unexpected error: %v", errMsg.Error)
}
@@ -503,6 +662,34 @@ func TestRepairResponsesWebsocketToolCallsInsertsCachedCallForOrphanOutput(t *te
}
}
+func TestRepairResponsesWebsocketToolCallsInsertsCachedCallForPreviousResponseOutput(t *testing.T) {
+ outputCache := newWebsocketToolOutputCache(time.Minute, 10)
+ callCache := newWebsocketToolOutputCache(time.Minute, 10)
+ sessionKey := "session-1"
+
+ callCache.record(sessionKey, "call-1", []byte(`{"type":"function_call","id":"fc-1","call_id":"call-1","name":"tool"}`))
+
+ raw := []byte(`{"previous_response_id":"resp-latest","input":[{"type":"function_call_output","call_id":"call-1","id":"tool-out-1","output":"ok"},{"type":"message","id":"msg-1"}]}`)
+ repaired := repairResponsesWebsocketToolCallsWithCaches(outputCache, callCache, sessionKey, raw)
+
+ if got := gjson.GetBytes(repaired, "previous_response_id").String(); got != "resp-latest" {
+ t.Fatalf("previous_response_id = %q, want resp-latest", got)
+ }
+ input := gjson.GetBytes(repaired, "input").Array()
+ if len(input) != 3 {
+ t.Fatalf("repaired input len = %d, want 3: %s", len(input), repaired)
+ }
+ if input[0].Get("type").String() != "function_call" || input[0].Get("call_id").String() != "call-1" {
+ t.Fatalf("missing inserted call: %s", input[0].Raw)
+ }
+ if input[1].Get("type").String() != "function_call_output" || input[1].Get("call_id").String() != "call-1" {
+ t.Fatalf("unexpected output item: %s", input[1].Raw)
+ }
+ if input[2].Get("type").String() != "message" || input[2].Get("id").String() != "msg-1" {
+ t.Fatalf("unexpected trailing item: %s", input[2].Raw)
+ }
+}
+
func TestRepairResponsesWebsocketToolCallsDropsOrphanOutputWhenCallMissing(t *testing.T) {
outputCache := newWebsocketToolOutputCache(time.Minute, 10)
callCache := newWebsocketToolOutputCache(time.Minute, 10)
@@ -681,7 +868,7 @@ func TestForwardResponsesWebsocketPreservesCompletedEvent(t *testing.T) {
close(errCh)
var timelineLog strings.Builder
- completedOutput, err := (*OpenAIResponsesAPIHandler)(nil).forwardResponsesWebsocket(
+ completedOutput, errMsg, err := (*OpenAIResponsesAPIHandler)(nil).forwardResponsesWebsocket(
ctx,
conn,
func(...interface{}) {},
@@ -694,6 +881,10 @@ func TestForwardResponsesWebsocketPreservesCompletedEvent(t *testing.T) {
serverErrCh <- err
return
}
+ if errMsg != nil {
+ serverErrCh <- fmt.Errorf("unexpected websocket error message: %v", errMsg.Error)
+ return
+ }
if gjson.GetBytes(completedOutput, "0.id").String() != "out-1" {
serverErrCh <- errors.New("completed output not captured")
return
@@ -760,7 +951,7 @@ func TestForwardResponsesWebsocketLogsAttemptedResponseOnWriteFailure(t *testing
return
}
- _, err = (*OpenAIResponsesAPIHandler)(nil).forwardResponsesWebsocket(
+ _, _, err = (*OpenAIResponsesAPIHandler)(nil).forwardResponsesWebsocket(
ctx,
conn,
func(...interface{}) {},
@@ -844,6 +1035,43 @@ func TestResponsesWebsocketTimelineRecordsDisconnectEvent(t *testing.T) {
}
}
+func TestResponsesWebsocketClosesOnCodexUpstreamDisconnect(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+
+ executor := &websocketUpstreamDisconnectExecutor{subscribed: make(chan string, 1)}
+ manager := coreauth.NewManager(nil, nil, nil)
+ manager.RegisterExecutor(executor)
+ base := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, manager)
+ h := NewOpenAIResponsesAPIHandler(base)
+
+ router := gin.New()
+ router.GET("/v1/responses/ws", h.ResponsesWebsocket)
+ server := httptest.NewServer(router)
+ defer server.Close()
+
+ wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/v1/responses/ws"
+ conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
+ if err != nil {
+ t.Fatalf("dial websocket: %v", err)
+ }
+ defer func() { _ = conn.Close() }()
+
+ var sessionID string
+ select {
+ case sessionID = <-executor.subscribed:
+ case <-time.After(5 * time.Second):
+ t.Fatal("timed out waiting for upstream disconnect subscription")
+ }
+
+ executor.TriggerDisconnect(sessionID, errors.New("upstream disconnected"))
+
+ _ = conn.SetReadDeadline(time.Now().Add(2 * time.Second))
+ _, _, err = conn.ReadMessage()
+ if err == nil {
+ t.Fatalf("expected downstream websocket to close after upstream disconnect")
+ }
+}
+
func TestWebsocketUpstreamSupportsIncrementalInputForModel(t *testing.T) {
manager := coreauth.NewManager(nil, nil, nil)
auth := &coreauth.Auth{
@@ -867,6 +1095,53 @@ func TestWebsocketUpstreamSupportsIncrementalInputForModel(t *testing.T) {
}
}
+func TestWebsocketUpstreamSupportsCompactionReplayForModel(t *testing.T) {
+ manager := coreauth.NewManager(nil, nil, nil)
+ auth := &coreauth.Auth{
+ ID: "auth-codex",
+ Provider: "codex",
+ Status: coreauth.StatusActive,
+ }
+ if _, err := manager.Register(context.Background(), auth); err != nil {
+ t.Fatalf("Register auth: %v", err)
+ }
+ registry.GetGlobalRegistry().RegisterClient(auth.ID, auth.Provider, []*registry.ModelInfo{{ID: "test-model"}})
+ t.Cleanup(func() {
+ registry.GetGlobalRegistry().UnregisterClient(auth.ID)
+ })
+
+ base := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, manager)
+ h := NewOpenAIResponsesAPIHandler(base)
+ if !h.websocketUpstreamSupportsCompactionReplayForModel("test-model") {
+ t.Fatalf("expected codex upstream to support compaction replay")
+ }
+}
+
+func TestWebsocketUpstreamSupportsCompactionReplayForModelFalseWhenMixedBackends(t *testing.T) {
+ manager := coreauth.NewManager(nil, nil, nil)
+ auths := []*coreauth.Auth{
+ {ID: "auth-codex", Provider: "codex", Status: coreauth.StatusActive},
+ {ID: "auth-claude", Provider: "claude", Status: coreauth.StatusActive},
+ }
+ for _, auth := range auths {
+ if _, err := manager.Register(context.Background(), auth); err != nil {
+ t.Fatalf("Register auth %s: %v", auth.ID, err)
+ }
+ registry.GetGlobalRegistry().RegisterClient(auth.ID, auth.Provider, []*registry.ModelInfo{{ID: "test-model"}})
+ }
+ t.Cleanup(func() {
+ for _, auth := range auths {
+ registry.GetGlobalRegistry().UnregisterClient(auth.ID)
+ }
+ })
+
+ base := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, manager)
+ h := NewOpenAIResponsesAPIHandler(base)
+ if h.websocketUpstreamSupportsCompactionReplayForModel("test-model") {
+ t.Fatalf("expected mixed backend model to disable compaction replay bypass")
+ }
+}
+
func TestResponsesWebsocketPrewarmHandledLocallyForSSEUpstream(t *testing.T) {
gin.SetMode(gin.TestMode)
@@ -1066,6 +1341,99 @@ func TestResponsesWebsocketPinsOnlyWebsocketCapableAuth(t *testing.T) {
}
}
+func TestResponsesWebsocketReleasesPinnedAuthAfterQuotaError(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+
+ selector := &orderedWebsocketSelector{order: []string{"auth-a", "auth-b"}}
+ executor := &websocketPinnedFailoverExecutor{}
+ manager := coreauth.NewManager(nil, selector, nil)
+ manager.RegisterExecutor(executor)
+
+ authA := &coreauth.Auth{
+ ID: "auth-a",
+ Provider: executor.Identifier(),
+ Status: coreauth.StatusActive,
+ Attributes: map[string]string{"websockets": "true"},
+ }
+ if _, err := manager.Register(context.Background(), authA); err != nil {
+ t.Fatalf("Register auth A: %v", err)
+ }
+ authB := &coreauth.Auth{
+ ID: "auth-b",
+ Provider: executor.Identifier(),
+ Status: coreauth.StatusActive,
+ Attributes: map[string]string{"websockets": "true"},
+ }
+ if _, err := manager.Register(context.Background(), authB); err != nil {
+ t.Fatalf("Register auth B: %v", err)
+ }
+
+ registry.GetGlobalRegistry().RegisterClient(authA.ID, authA.Provider, []*registry.ModelInfo{{ID: "quota-model"}})
+ registry.GetGlobalRegistry().RegisterClient(authB.ID, authB.Provider, []*registry.ModelInfo{{ID: "quota-model"}})
+ t.Cleanup(func() {
+ registry.GetGlobalRegistry().UnregisterClient(authA.ID)
+ registry.GetGlobalRegistry().UnregisterClient(authB.ID)
+ })
+
+ base := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, manager)
+ h := NewOpenAIResponsesAPIHandler(base)
+ router := gin.New()
+ router.GET("/v1/responses/ws", h.ResponsesWebsocket)
+
+ server := httptest.NewServer(router)
+ defer server.Close()
+
+ wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/v1/responses/ws"
+ conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
+ if err != nil {
+ t.Fatalf("dial websocket: %v", err)
+ }
+ defer func() {
+ if errClose := conn.Close(); errClose != nil {
+ t.Fatalf("close websocket: %v", errClose)
+ }
+ }()
+
+ requests := []string{
+ `{"type":"response.create","model":"quota-model","input":[{"type":"message","id":"msg-1"}]}`,
+ `{"type":"response.create","previous_response_id":"resp-auth-a-1","input":[{"type":"message","id":"msg-2"}]}`,
+ `{"type":"response.create","previous_response_id":"resp-auth-a-1","input":[{"type":"message","id":"msg-3"}]}`,
+ }
+ wantTypes := []string{wsEventTypeCompleted, wsEventTypeError, wsEventTypeCompleted}
+ for i := range requests {
+ if errWrite := conn.WriteMessage(websocket.TextMessage, []byte(requests[i])); errWrite != nil {
+ t.Fatalf("write websocket message %d: %v", i+1, errWrite)
+ }
+ _, payload, errReadMessage := conn.ReadMessage()
+ if errReadMessage != nil {
+ t.Fatalf("read websocket message %d: %v", i+1, errReadMessage)
+ }
+ if got := gjson.GetBytes(payload, "type").String(); got != wantTypes[i] {
+ t.Fatalf("message %d payload type = %s, want %s: %s", i+1, got, wantTypes[i], payload)
+ }
+ if i == 1 && int(gjson.GetBytes(payload, "status").Int()) != http.StatusTooManyRequests {
+ t.Fatalf("quota payload status = %d, want %d: %s", gjson.GetBytes(payload, "status").Int(), http.StatusTooManyRequests, payload)
+ }
+ }
+
+ if got := executor.AuthIDs(); len(got) != 3 || got[0] != "auth-a" || got[1] != "auth-a" || got[2] != "auth-b" {
+ t.Fatalf("selected auth IDs = %v, want [auth-a auth-a auth-b]", got)
+ }
+
+ authBPayloads := executor.Payloads("auth-b")
+ if len(authBPayloads) != 1 {
+ t.Fatalf("auth-b payload count = %d, want 1", len(authBPayloads))
+ }
+ authBPayload := authBPayloads[0]
+ if gjson.GetBytes(authBPayload, "previous_response_id").Exists() {
+ t.Fatalf("previous_response_id leaked after auth failover: %s", authBPayload)
+ }
+ authBInput := gjson.GetBytes(authBPayload, "input").Raw
+ if !strings.Contains(authBInput, `"id":"msg-1"`) || !strings.Contains(authBInput, `"id":"msg-3"`) {
+ t.Fatalf("auth-b replay input missing expected transcript items: %s", authBInput)
+ }
+}
+
func TestNormalizeResponsesWebsocketRequestTreatsTranscriptReplacementAsReset(t *testing.T) {
lastRequest := []byte(`{"model":"test-model","stream":true,"input":[{"type":"message","id":"msg-1"},{"type":"function_call","id":"fc-1","call_id":"call-1"},{"type":"function_call_output","id":"tool-out-1","call_id":"call-1"},{"type":"message","id":"assistant-1","role":"assistant"}]}`)
lastResponseOutput := []byte(`[
@@ -1400,3 +1768,171 @@ func TestResponsesWebsocketCompactionResetsTurnStateOnTranscriptReplacement(t *t
t.Fatalf("post-compact function call id = %s, want call-1", items[0].Get("call_id").String())
}
}
+
+func TestInputContainsFullTranscriptFalseForAssistantMessageOnly(t *testing.T) {
+ input := gjson.Parse(`[
+ {"type":"message","role":"user","content":"hello"},
+ {"type":"message","role":"assistant","content":"hi there"}
+ ]`)
+ if inputContainsFullTranscript(input) {
+ t.Fatal("assistant message alone must not be treated as full transcript")
+ }
+}
+
+func TestInputContainsFullTranscriptDetectsCompactionItem(t *testing.T) {
+ for _, typ := range []string{"compaction", "compaction_summary"} {
+ input := gjson.Parse(`[{"type":"message","role":"user","content":"hello"},{"type":"` + typ + `","encrypted_content":"summary"}]`)
+ if !inputContainsFullTranscript(input) {
+ t.Fatalf("expected full transcript for type=%s", typ)
+ }
+ }
+}
+
+func TestInputContainsFullTranscriptFalseForIncremental(t *testing.T) {
+ // Normal incremental turns: user messages or function_call_output only.
+ for _, raw := range []string{
+ `[{"type":"function_call_output","call_id":"call-1","output":"result"}]`,
+ `[{"type":"message","role":"user","content":"next question"}]`,
+ `[]`,
+ } {
+ if inputContainsFullTranscript(gjson.Parse(raw)) {
+ t.Fatalf("incremental input must not be detected as full transcript: %s", raw)
+ }
+ }
+}
+
+func TestNormalizeSubsequentRequestCompactSkipsMerge(t *testing.T) {
+ lastRequest := []byte(`{"model":"gpt-5.4","stream":true,"input":[
+ {"type":"message","role":"user","id":"msg-1","content":"original long prompt"},
+ {"type":"message","role":"assistant","id":"msg-2","content":"original long response"},
+ {"type":"function_call","id":"fc-1","call_id":"call-old","name":"bash","arguments":"{}"},
+ {"type":"function_call_output","id":"fco-1","call_id":"call-old","output":"old result"}
+ ]}`)
+ lastResponseOutput := []byte(`[
+ {"type":"message","role":"assistant","id":"msg-3","content":"another assistant reply"},
+ {"type":"function_call","id":"fc-2","call_id":"call-stale","name":"read","arguments":"{}"}
+ ]`)
+
+ // Remote compact response: user messages + compaction item, NO assistant message.
+ // This is the primary compact scenario from Codex CLI.
+ raw := []byte(`{"type":"response.create","input":[
+ {"type":"message","role":"user","id":"msg-1c","content":"compacted user msg"},
+ {"type":"compaction","encrypted_content":"conversation summary"}
+ ]}`)
+
+ normalized, _, errMsg := normalizeResponsesWebsocketRequest(raw, lastRequest, lastResponseOutput)
+ if errMsg != nil {
+ t.Fatalf("unexpected error: %v", errMsg.Error)
+ }
+
+ input := gjson.GetBytes(normalized, "input").Array()
+ if len(input) != 2 {
+ t.Fatalf("input len = %d, want 2 (compacted only); stale state was not skipped", len(input))
+ }
+ if input[0].Get("id").String() != "msg-1c" {
+ t.Fatalf("input[0].id = %q, want %q", input[0].Get("id").String(), "msg-1c")
+ }
+ if input[1].Get("type").String() != "compaction" {
+ t.Fatalf("input[1].type = %q, want %q", input[1].Get("type").String(), "compaction")
+ }
+}
+
+func TestNormalizeSubsequentRequestCompactMergesWhenCompactionReplayUnsupported(t *testing.T) {
+ lastRequest := []byte(`{"model":"gpt-5.4","stream":true,"input":[
+ {"type":"message","role":"user","id":"msg-1","content":"original long prompt"},
+ {"type":"message","role":"assistant","id":"msg-2","content":"original long response"},
+ {"type":"function_call","id":"fc-1","call_id":"call-old","name":"bash","arguments":"{}"},
+ {"type":"function_call_output","id":"fco-1","call_id":"call-old","output":"old result"}
+ ]}`)
+ lastResponseOutput := []byte(`[
+ {"type":"message","role":"assistant","id":"msg-3","content":"another assistant reply"},
+ {"type":"function_call","id":"fc-2","call_id":"call-stale","name":"read","arguments":"{}"}
+ ]`)
+ raw := []byte(`{"type":"response.create","input":[
+ {"type":"message","role":"user","id":"msg-1c","content":"compacted user msg"},
+ {"type":"compaction","encrypted_content":"conversation summary"}
+ ]}`)
+
+ normalized, _, errMsg := normalizeResponsesWebsocketRequestWithMode(raw, lastRequest, lastResponseOutput, false, false)
+ if errMsg != nil {
+ t.Fatalf("unexpected error: %v", errMsg.Error)
+ }
+
+ input := gjson.GetBytes(normalized, "input").Array()
+ if len(input) != 7 {
+ t.Fatalf("input len = %d, want 7 (merged fallback without compaction items)", len(input))
+ }
+ wantIDs := []string{"msg-1", "msg-2", "fc-1", "fco-1", "msg-3", "fc-2", "msg-1c"}
+ for i, want := range wantIDs {
+ got := input[i].Get("id").String()
+ if got != want {
+ t.Fatalf("input[%d].id = %q, want %q", i, got, want)
+ }
+ }
+ for _, item := range input {
+ if item.Get("type").String() == "compaction" || item.Get("type").String() == "compaction_summary" {
+ t.Fatalf("compaction items must be stripped for unsupported downstream fallback: %s", item.Raw)
+ }
+ }
+}
+
+func TestNormalizeSubsequentRequestIncrementalInputStillMerges(t *testing.T) {
+ // Normal incremental flow: user sends function_call_output (no assistant message).
+ lastRequest := []byte(`{"model":"gpt-5.4","stream":true,"input":[
+ {"type":"message","role":"user","id":"msg-1","content":"hello"}
+ ]}`)
+ lastResponseOutput := []byte(`[
+ {"type":"message","role":"assistant","id":"msg-2","content":"let me check"},
+ {"type":"function_call","id":"fc-1","call_id":"call-1","name":"bash","arguments":"{}"}
+ ]`)
+ raw := []byte(`{"type":"response.create","input":[
+ {"type":"function_call_output","call_id":"call-1","id":"fco-1","output":"done"}
+ ]}`)
+
+ normalized, _, errMsg := normalizeResponsesWebsocketRequest(raw, lastRequest, lastResponseOutput)
+ if errMsg != nil {
+ t.Fatalf("unexpected error: %v", errMsg.Error)
+ }
+
+ input := gjson.GetBytes(normalized, "input").Array()
+
+ // Should be merged: msg-1 + msg-2 + fc-1 + fco-1 = 4 items
+ if len(input) != 4 {
+ t.Fatalf("input len = %d, want 4 (merged)", len(input))
+ }
+ wantIDs := []string{"msg-1", "msg-2", "fc-1", "fco-1"}
+ for i, want := range wantIDs {
+ got := input[i].Get("id").String()
+ if got != want {
+ t.Fatalf("input[%d].id = %q, want %q", i, got, want)
+ }
+ }
+}
+
+func TestNormalizeSubsequentRequestAssistantInputTriggersTranscriptReplacement(t *testing.T) {
+ // After dev's shouldReplaceWebsocketTranscript, assistant messages in input
+ // trigger transcript replacement (no merge with prior state).
+ lastRequest := []byte(`{"model":"gpt-5.4","stream":true,"input":[
+ {"type":"message","role":"user","id":"msg-1","content":"hello"}
+ ]}`)
+ lastResponseOutput := []byte(`[
+ {"type":"message","role":"assistant","id":"msg-2","content":"prior assistant"},
+ {"type":"function_call","id":"fc-1","call_id":"call-1","name":"bash","arguments":"{}"}
+ ]`)
+ raw := []byte(`{"type":"response.append","input":[
+ {"type":"message","role":"assistant","id":"msg-3","content":"patched assistant turn"}
+ ]}`)
+
+ normalized, _, errMsg := normalizeResponsesWebsocketRequest(raw, lastRequest, lastResponseOutput)
+ if errMsg != nil {
+ t.Fatalf("unexpected error: %v", errMsg.Error)
+ }
+
+ input := gjson.GetBytes(normalized, "input").Array()
+ if len(input) != 1 {
+ t.Fatalf("input len = %d, want 1 (transcript replacement, not merge)", len(input))
+ }
+ if input[0].Get("id").String() != "msg-3" {
+ t.Fatalf("input[0].id = %q, want %q", input[0].Get("id").String(), "msg-3")
+ }
+}
diff --git a/sdk/api/handlers/openai/openai_responses_websocket_toolcall_repair.go b/sdk/api/handlers/openai/openai_responses_websocket_toolcall_repair.go
index 1a5772ec70..c521bec049 100644
--- a/sdk/api/handlers/openai/openai_responses_websocket_toolcall_repair.go
+++ b/sdk/api/handlers/openai/openai_responses_websocket_toolcall_repair.go
@@ -300,11 +300,6 @@ func repairResponsesToolCallsArray(outputCache, callCache *websocketToolOutputCa
continue
}
- if allowOrphanOutputs {
- filtered = append(filtered, item)
- continue
- }
-
if _, ok := callPresent[callID]; ok {
filtered = append(filtered, item)
continue
@@ -322,6 +317,11 @@ func repairResponsesToolCallsArray(outputCache, callCache *websocketToolOutputCa
}
}
+ if allowOrphanOutputs {
+ filtered = append(filtered, item)
+ continue
+ }
+
// Drop orphaned function_call_output items; upstream rejects transcripts with missing calls.
continue
}
diff --git a/sdk/api/handlers/stream_forwarder.go b/sdk/api/handlers/stream_forwarder.go
index 401baca8fa..63ddc31e43 100644
--- a/sdk/api/handlers/stream_forwarder.go
+++ b/sdk/api/handlers/stream_forwarder.go
@@ -5,7 +5,7 @@ import (
"time"
"github.com/gin-gonic/gin"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
)
type StreamForwardOptions struct {
diff --git a/sdk/api/management.go b/sdk/api/management.go
index a5a1cfc490..689cda3dca 100644
--- a/sdk/api/management.go
+++ b/sdk/api/management.go
@@ -1,16 +1,21 @@
// Package api exposes helpers for embedding CLIProxyAPI.
//
-// It wraps internal management handler types so external projects can integrate
-// management endpoints without importing internal packages.
+// It wraps internal management handler types and helpers so external projects
+// can integrate management endpoints without importing internal packages.
package api
import (
+ "context"
+
"github.com/gin-gonic/gin"
- internalmanagement "github.com/router-for-me/CLIProxyAPI/v6/internal/api/handlers/management"
- coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
- "github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
+ internalmanagement "github.com/router-for-me/CLIProxyAPI/v7/internal/api/handlers/management"
+ coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
+ "github.com/router-for-me/CLIProxyAPI/v7/sdk/config"
)
+// Handler re-exports the management handler used by the internal HTTP API.
+type Handler = internalmanagement.Handler
+
// ManagementTokenRequester exposes a limited subset of management endpoints for requesting tokens.
type ManagementTokenRequester interface {
RequestAnthropicToken(*gin.Context)
@@ -23,13 +28,23 @@ type ManagementTokenRequester interface {
}
type managementTokenRequester struct {
- handler *internalmanagement.Handler
+ handler *Handler
+}
+
+// NewHandler creates a management handler for SDK consumers.
+func NewHandler(cfg *config.Config, configFilePath string, manager *coreauth.Manager) *Handler {
+ return internalmanagement.NewHandler(cfg, configFilePath, manager)
+}
+
+// NewHandlerWithoutConfigFilePath creates a management handler that skips config file persistence.
+func NewHandlerWithoutConfigFilePath(cfg *config.Config, manager *coreauth.Manager) *Handler {
+ return internalmanagement.NewHandlerWithoutConfigFilePath(cfg, manager)
}
// NewManagementTokenRequester creates a limited management handler exposing only token request endpoints.
func NewManagementTokenRequester(cfg *config.Config, manager *coreauth.Manager) ManagementTokenRequester {
return &managementTokenRequester{
- handler: internalmanagement.NewHandlerWithoutConfigFilePath(cfg, manager),
+ handler: NewHandlerWithoutConfigFilePath(cfg, manager),
}
}
@@ -60,3 +75,63 @@ func (m *managementTokenRequester) GetAuthStatus(c *gin.Context) {
func (m *managementTokenRequester) PostOAuthCallback(c *gin.Context) {
m.handler.PostOAuthCallback(c)
}
+
+// WriteConfig persists management configuration to disk.
+func WriteConfig(path string, data []byte) error {
+ return internalmanagement.WriteConfig(path, data)
+}
+
+// RegisterOAuthSession records a pending OAuth callback state.
+func RegisterOAuthSession(state, provider string) {
+ internalmanagement.RegisterOAuthSession(state, provider)
+}
+
+// SetOAuthSessionError stores an OAuth session error message.
+func SetOAuthSessionError(state, message string) {
+ internalmanagement.SetOAuthSessionError(state, message)
+}
+
+// CompleteOAuthSession marks a single OAuth session as completed.
+func CompleteOAuthSession(state string) {
+ internalmanagement.CompleteOAuthSession(state)
+}
+
+// CompleteOAuthSessionsByProvider removes all pending OAuth sessions for a provider.
+func CompleteOAuthSessionsByProvider(provider string) int {
+ return internalmanagement.CompleteOAuthSessionsByProvider(provider)
+}
+
+// GetOAuthSession returns the current OAuth session state.
+func GetOAuthSession(state string) (provider string, status string, ok bool) {
+ return internalmanagement.GetOAuthSession(state)
+}
+
+// IsOAuthSessionPending reports whether a provider/state pair is still pending.
+func IsOAuthSessionPending(state, provider string) bool {
+ return internalmanagement.IsOAuthSessionPending(state, provider)
+}
+
+// ValidateOAuthState validates an OAuth state token.
+func ValidateOAuthState(state string) error {
+ return internalmanagement.ValidateOAuthState(state)
+}
+
+// NormalizeOAuthProvider normalizes a provider name to its canonical form.
+func NormalizeOAuthProvider(provider string) (string, error) {
+ return internalmanagement.NormalizeOAuthProvider(provider)
+}
+
+// WriteOAuthCallbackFile writes an OAuth callback payload to disk.
+func WriteOAuthCallbackFile(authDir, provider, state, code, errorMessage string) (string, error) {
+ return internalmanagement.WriteOAuthCallbackFile(authDir, provider, state, code, errorMessage)
+}
+
+// WriteOAuthCallbackFileForPendingSession writes an OAuth callback payload for a pending session.
+func WriteOAuthCallbackFileForPendingSession(authDir, provider, state, code, errorMessage string) (string, error) {
+ return internalmanagement.WriteOAuthCallbackFileForPendingSession(authDir, provider, state, code, errorMessage)
+}
+
+// PopulateAuthContext copies auth metadata from a Gin context into a request context.
+func PopulateAuthContext(ctx context.Context, c *gin.Context) context.Context {
+ return internalmanagement.PopulateAuthContext(ctx, c)
+}
diff --git a/sdk/api/options.go b/sdk/api/options.go
index 8497884bf0..e2bbff78e9 100644
--- a/sdk/api/options.go
+++ b/sdk/api/options.go
@@ -8,10 +8,10 @@ import (
"time"
"github.com/gin-gonic/gin"
- internalapi "github.com/router-for-me/CLIProxyAPI/v6/internal/api"
- "github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers"
- "github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
- "github.com/router-for-me/CLIProxyAPI/v6/sdk/logging"
+ internalapi "github.com/router-for-me/CLIProxyAPI/v7/internal/api"
+ "github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers"
+ "github.com/router-for-me/CLIProxyAPI/v7/sdk/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/sdk/logging"
)
// ServerOption customises HTTP server construction.
diff --git a/sdk/auth/antigravity.go b/sdk/auth/antigravity.go
index d52bf1d259..0a947b20f0 100644
--- a/sdk/auth/antigravity.go
+++ b/sdk/auth/antigravity.go
@@ -8,12 +8,12 @@ import (
"strings"
"time"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/antigravity"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/browser"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
- coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/antigravity"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/browser"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/misc"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/util"
+ coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
log "github.com/sirupsen/logrus"
)
diff --git a/sdk/auth/claude.go b/sdk/auth/claude.go
index d82a718b2d..726fa922ae 100644
--- a/sdk/auth/claude.go
+++ b/sdk/auth/claude.go
@@ -7,13 +7,13 @@ import (
"strings"
"time"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/claude"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/browser"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/claude"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/browser"
// legacy client removed
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
- coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/misc"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/util"
+ coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
log "github.com/sirupsen/logrus"
)
diff --git a/sdk/auth/codex.go b/sdk/auth/codex.go
index 269e3d8b21..be58c9c5a6 100644
--- a/sdk/auth/codex.go
+++ b/sdk/auth/codex.go
@@ -7,13 +7,13 @@ import (
"strings"
"time"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/codex"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/browser"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/codex"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/browser"
// legacy client removed
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
- coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/misc"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/util"
+ coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
log "github.com/sirupsen/logrus"
)
diff --git a/sdk/auth/codex_device.go b/sdk/auth/codex_device.go
index 10f59fb97b..d7ea4e1fe9 100644
--- a/sdk/auth/codex_device.go
+++ b/sdk/auth/codex_device.go
@@ -13,11 +13,11 @@ import (
"strings"
"time"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/codex"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/browser"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
- coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/codex"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/browser"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/util"
+ coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
log "github.com/sirupsen/logrus"
)
diff --git a/sdk/auth/errors.go b/sdk/auth/errors.go
index 78fe9a17bd..f950e925ff 100644
--- a/sdk/auth/errors.go
+++ b/sdk/auth/errors.go
@@ -3,7 +3,7 @@ package auth
import (
"fmt"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
)
// ProjectSelectionError indicates that the user must choose a specific project ID.
diff --git a/sdk/auth/filestore.go b/sdk/auth/filestore.go
index f8f49f44ba..5675caac29 100644
--- a/sdk/auth/filestore.go
+++ b/sdk/auth/filestore.go
@@ -15,7 +15,7 @@ import (
"sync"
"time"
- cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
+ cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
)
// FileTokenStore persists token records and auth metadata using the filesystem as backing storage.
@@ -72,6 +72,10 @@ func (s *FileTokenStore) Save(ctx context.Context, auth *cliproxyauth.Auth) (str
switch {
case auth.Storage != nil:
+ if auth.Metadata == nil {
+ auth.Metadata = make(map[string]any)
+ }
+ auth.Metadata["disabled"] = auth.Disabled
if setter, ok := auth.Storage.(metadataSetter); ok {
setter.SetMetadata(auth.Metadata)
}
diff --git a/sdk/auth/filestore_disabled_test.go b/sdk/auth/filestore_disabled_test.go
new file mode 100644
index 0000000000..665f9ebf1f
--- /dev/null
+++ b/sdk/auth/filestore_disabled_test.go
@@ -0,0 +1,64 @@
+package auth
+
+import (
+ "context"
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "testing"
+
+ cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
+)
+
+type testTokenStorage struct {
+ meta map[string]any
+}
+
+func (s *testTokenStorage) SetMetadata(meta map[string]any) { s.meta = meta }
+
+func (s *testTokenStorage) SaveTokenToFile(authFilePath string) error {
+ raw, err := json.Marshal(s.meta)
+ if err != nil {
+ return err
+ }
+ return os.WriteFile(authFilePath, raw, 0o600)
+}
+
+func TestFileTokenStore_Save_DisabledPersistsFlagForTokenStorage(t *testing.T) {
+ ctx := context.Background()
+ baseDir := t.TempDir()
+ path := filepath.Join(baseDir, "disabled.json")
+
+ if err := os.WriteFile(path, []byte(`{"type":"test","disabled":true}`), 0o600); err != nil {
+ t.Fatalf("seed auth file: %v", err)
+ }
+
+ store := NewFileTokenStore()
+ store.SetBaseDir(baseDir)
+ storage := &testTokenStorage{}
+
+ auth := &cliproxyauth.Auth{
+ ID: "disabled.json",
+ Provider: "test",
+ FileName: "disabled.json",
+ Disabled: true,
+ Storage: storage,
+ Metadata: map[string]any{"type": "test"},
+ }
+
+ if _, err := store.Save(ctx, auth); err != nil {
+ t.Fatalf("Save() error: %v", err)
+ }
+
+ raw, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("read auth file: %v", err)
+ }
+ var meta map[string]any
+ if err := json.Unmarshal(raw, &meta); err != nil {
+ t.Fatalf("unmarshal auth file: %v", err)
+ }
+ if disabled, _ := meta["disabled"].(bool); !disabled {
+ t.Fatalf("disabled=%v, want true (raw=%s)", meta["disabled"], string(raw))
+ }
+}
diff --git a/sdk/auth/gemini.go b/sdk/auth/gemini.go
index 2b8f9c2b88..ba7c7728ad 100644
--- a/sdk/auth/gemini.go
+++ b/sdk/auth/gemini.go
@@ -5,10 +5,10 @@ import (
"fmt"
"time"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/gemini"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/gemini"
// legacy client removed
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
- coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
)
// GeminiAuthenticator implements the login flow for Google Gemini CLI accounts.
diff --git a/sdk/auth/interfaces.go b/sdk/auth/interfaces.go
index 64cf8ed035..e5582a0cc5 100644
--- a/sdk/auth/interfaces.go
+++ b/sdk/auth/interfaces.go
@@ -5,8 +5,8 @@ import (
"errors"
"time"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
- coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
)
var ErrRefreshNotSupported = errors.New("cliproxy auth: refresh not supported")
diff --git a/sdk/auth/kimi.go b/sdk/auth/kimi.go
index 12ae101e7d..4dbff1e87e 100644
--- a/sdk/auth/kimi.go
+++ b/sdk/auth/kimi.go
@@ -6,10 +6,10 @@ import (
"strings"
"time"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/kimi"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/browser"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
- coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/kimi"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/browser"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
log "github.com/sirupsen/logrus"
)
diff --git a/sdk/auth/manager.go b/sdk/auth/manager.go
index c6469a7d19..bceb5e196d 100644
--- a/sdk/auth/manager.go
+++ b/sdk/auth/manager.go
@@ -4,8 +4,8 @@ import (
"context"
"fmt"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
- coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
)
// Manager aggregates authenticators and coordinates persistence via a token store.
diff --git a/sdk/auth/refresh_registry.go b/sdk/auth/refresh_registry.go
index ae60f56a64..fe25231507 100644
--- a/sdk/auth/refresh_registry.go
+++ b/sdk/auth/refresh_registry.go
@@ -3,7 +3,7 @@ package auth
import (
"time"
- cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
+ cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
)
func init() {
diff --git a/sdk/auth/store_registry.go b/sdk/auth/store_registry.go
index 760449f8cf..1971947bc8 100644
--- a/sdk/auth/store_registry.go
+++ b/sdk/auth/store_registry.go
@@ -3,7 +3,7 @@ package auth
import (
"sync"
- coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
+ coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
)
var (
diff --git a/sdk/cliproxy/auth/antigravity_credits.go b/sdk/cliproxy/auth/antigravity_credits.go
new file mode 100644
index 0000000000..77b03bfd3e
--- /dev/null
+++ b/sdk/cliproxy/auth/antigravity_credits.go
@@ -0,0 +1,90 @@
+package auth
+
+import (
+ "context"
+ "strings"
+ "sync"
+ "time"
+)
+
+type antigravityUseCreditsContextKey struct{}
+
+// WithAntigravityCredits returns a child context that signals the executor to
+// inject enabledCreditTypes into the request payload.
+func WithAntigravityCredits(ctx context.Context) context.Context {
+ return context.WithValue(ctx, antigravityUseCreditsContextKey{}, true)
+}
+
+// AntigravityCreditsRequested reports whether the context carries the credits flag.
+func AntigravityCreditsRequested(ctx context.Context) bool {
+ if ctx == nil {
+ return false
+ }
+ v, _ := ctx.Value(antigravityUseCreditsContextKey{}).(bool)
+ return v
+}
+
+// AntigravityCreditsHint stores the latest known AI credits state for one auth.
+type AntigravityCreditsHint struct {
+ Known bool
+ Available bool
+ CreditAmount float64
+ MinCreditAmount float64
+ PaidTierID string
+ UpdatedAt time.Time
+}
+
+var antigravityCreditsHintByAuth sync.Map
+
+// SetAntigravityCreditsHint updates the latest known AI credits state for an auth.
+func SetAntigravityCreditsHint(authID string, hint AntigravityCreditsHint) {
+ authID = strings.TrimSpace(authID)
+ if authID == "" {
+ return
+ }
+ if hint.UpdatedAt.IsZero() {
+ hint.UpdatedAt = time.Now()
+ }
+ antigravityCreditsHintByAuth.Store(authID, hint)
+}
+
+// GetAntigravityCreditsHint returns the latest known AI credits state for an auth.
+func GetAntigravityCreditsHint(authID string) (AntigravityCreditsHint, bool) {
+ authID = strings.TrimSpace(authID)
+ if authID == "" {
+ return AntigravityCreditsHint{}, false
+ }
+ value, ok := antigravityCreditsHintByAuth.Load(authID)
+ if !ok {
+ return AntigravityCreditsHint{}, false
+ }
+ hint, ok := value.(AntigravityCreditsHint)
+ if !ok {
+ antigravityCreditsHintByAuth.Delete(authID)
+ return AntigravityCreditsHint{}, false
+ }
+ return hint, true
+}
+
+// HasKnownAntigravityCreditsHint reports whether credits state has been discovered for an auth.
+func HasKnownAntigravityCreditsHint(authID string) bool {
+ hint, ok := GetAntigravityCreditsHint(authID)
+ return ok && hint.Known
+}
+
+func antigravityCreditsAvailableForModel(auth *Auth, model string) bool {
+ if auth == nil {
+ return false
+ }
+ if !strings.EqualFold(strings.TrimSpace(auth.Provider), "antigravity") {
+ return false
+ }
+ if !strings.Contains(strings.ToLower(strings.TrimSpace(model)), "claude") {
+ return false
+ }
+ hint, ok := GetAntigravityCreditsHint(auth.ID)
+ if !ok || !hint.Known {
+ return false
+ }
+ return hint.Available
+}
diff --git a/sdk/cliproxy/auth/antigravity_credits_test.go b/sdk/cliproxy/auth/antigravity_credits_test.go
new file mode 100644
index 0000000000..34a475dc6a
--- /dev/null
+++ b/sdk/cliproxy/auth/antigravity_credits_test.go
@@ -0,0 +1,154 @@
+package auth
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "testing"
+ "time"
+
+ internalconfig "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
+ cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
+)
+
+type antigravityCreditsFallbackExecutor struct {
+ streamCreditsRequested []bool
+}
+
+func (e *antigravityCreditsFallbackExecutor) Identifier() string { return "antigravity" }
+
+func (e *antigravityCreditsFallbackExecutor) Execute(context.Context, *Auth, cliproxyexecutor.Request, cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
+ return cliproxyexecutor.Response{}, &Error{HTTPStatus: http.StatusNotImplemented, Message: "Execute not implemented"}
+}
+
+func (e *antigravityCreditsFallbackExecutor) ExecuteStream(ctx context.Context, _ *Auth, req cliproxyexecutor.Request, _ cliproxyexecutor.Options) (*cliproxyexecutor.StreamResult, error) {
+ creditsRequested := AntigravityCreditsRequested(ctx)
+ e.streamCreditsRequested = append(e.streamCreditsRequested, creditsRequested)
+ ch := make(chan cliproxyexecutor.StreamChunk, 1)
+ if !creditsRequested {
+ ch <- cliproxyexecutor.StreamChunk{Err: &Error{HTTPStatus: http.StatusTooManyRequests, Message: "quota exhausted"}}
+ close(ch)
+ return &cliproxyexecutor.StreamResult{Headers: http.Header{"X-Initial": {req.Model}}, Chunks: ch}, nil
+ }
+ ch <- cliproxyexecutor.StreamChunk{Payload: []byte("credits fallback")}
+ close(ch)
+ return &cliproxyexecutor.StreamResult{Headers: http.Header{"X-Credits": {req.Model}}, Chunks: ch}, nil
+}
+
+func (e *antigravityCreditsFallbackExecutor) Refresh(_ context.Context, auth *Auth) (*Auth, error) {
+ return auth, nil
+}
+
+func (e *antigravityCreditsFallbackExecutor) CountTokens(context.Context, *Auth, cliproxyexecutor.Request, cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
+ return cliproxyexecutor.Response{}, &Error{HTTPStatus: http.StatusNotImplemented, Message: "CountTokens not implemented"}
+}
+
+func (e *antigravityCreditsFallbackExecutor) HttpRequest(context.Context, *Auth, *http.Request) (*http.Response, error) {
+ return nil, &Error{HTTPStatus: http.StatusNotImplemented, Message: "HttpRequest not implemented"}
+}
+
+func TestManagerExecuteStream_AntigravityCreditsFallbackAfterBootstrap429(t *testing.T) {
+ const model = "claude-opus-4-6-thinking"
+ executor := &antigravityCreditsFallbackExecutor{}
+ manager := NewManager(nil, nil, nil)
+ manager.SetConfig(&internalconfig.Config{
+ QuotaExceeded: internalconfig.QuotaExceeded{AntigravityCredits: true},
+ })
+ manager.RegisterExecutor(executor)
+ registry.GetGlobalRegistry().RegisterClient("ag-credits", "antigravity", []*registry.ModelInfo{{ID: model}})
+ t.Cleanup(func() { registry.GetGlobalRegistry().UnregisterClient("ag-credits") })
+ if _, errRegister := manager.Register(context.Background(), &Auth{ID: "ag-credits", Provider: "antigravity"}); errRegister != nil {
+ t.Fatalf("register auth: %v", errRegister)
+ }
+
+ streamResult, errExecute := manager.ExecuteStream(context.Background(), []string{"antigravity"}, cliproxyexecutor.Request{Model: model}, cliproxyexecutor.Options{})
+ if errExecute != nil {
+ t.Fatalf("execute stream: %v", errExecute)
+ }
+
+ var payload []byte
+ for chunk := range streamResult.Chunks {
+ if chunk.Err != nil {
+ t.Fatalf("unexpected stream error: %v", chunk.Err)
+ }
+ payload = append(payload, chunk.Payload...)
+ }
+ if string(payload) != "credits fallback" {
+ t.Fatalf("payload = %q, want %q", string(payload), "credits fallback")
+ }
+ if got := streamResult.Headers.Get("X-Credits"); got != model {
+ t.Fatalf("X-Credits header = %q, want routed model", got)
+ }
+ if len(executor.streamCreditsRequested) != 2 {
+ t.Fatalf("stream calls = %d, want 2", len(executor.streamCreditsRequested))
+ }
+ if executor.streamCreditsRequested[0] || !executor.streamCreditsRequested[1] {
+ t.Fatalf("credits flags = %v, want [false true]", executor.streamCreditsRequested)
+ }
+}
+
+func TestStatusCodeFromError_UnwrapsStreamBootstrap429(t *testing.T) {
+ bootstrapErr := newStreamBootstrapError(&Error{HTTPStatus: http.StatusTooManyRequests, Message: "quota exhausted"}, nil)
+ wrappedErr := fmt.Errorf("conductor stream failed: %w", bootstrapErr)
+
+ if status := statusCodeFromError(wrappedErr); status != http.StatusTooManyRequests {
+ t.Fatalf("statusCodeFromError() = %d, want %d", status, http.StatusTooManyRequests)
+ }
+}
+
+func TestIsAuthBlockedForModel_ClaudeWithCreditsStillBlockedDuringCooldown(t *testing.T) {
+ auth := &Auth{
+ ID: "ag-1",
+ Provider: "antigravity",
+ ModelStates: map[string]*ModelState{
+ "claude-sonnet-4-6": {
+ Unavailable: true,
+ NextRetryAfter: time.Now().Add(10 * time.Minute),
+ Quota: QuotaState{
+ Exceeded: true,
+ NextRecoverAt: time.Now().Add(10 * time.Minute),
+ },
+ },
+ },
+ }
+
+ SetAntigravityCreditsHint(auth.ID, AntigravityCreditsHint{
+ Known: true,
+ Available: true,
+ UpdatedAt: time.Now(),
+ })
+
+ blocked, reason, _ := isAuthBlockedForModel(auth, "claude-sonnet-4-6", time.Now())
+ if !blocked || reason != blockReasonCooldown {
+ t.Fatalf("expected auth to be blocked during cooldown even with credits, got blocked=%v reason=%v", blocked, reason)
+ }
+}
+
+func TestIsAuthBlockedForModel_KeepsGeminiBlockedWithoutCreditsBypass(t *testing.T) {
+ auth := &Auth{
+ ID: "ag-2",
+ Provider: "antigravity",
+ ModelStates: map[string]*ModelState{
+ "gemini-3-flash": {
+ Unavailable: true,
+ NextRetryAfter: time.Now().Add(10 * time.Minute),
+ Quota: QuotaState{
+ Exceeded: true,
+ NextRecoverAt: time.Now().Add(10 * time.Minute),
+ },
+ },
+ },
+ }
+
+ SetAntigravityCreditsHint(auth.ID, AntigravityCreditsHint{
+ Known: true,
+ Available: true,
+ UpdatedAt: time.Now(),
+ })
+
+ blocked, reason, _ := isAuthBlockedForModel(auth, "gemini-3-flash", time.Now())
+ if !blocked || reason != blockReasonCooldown {
+ t.Fatalf("expected gemini auth to remain blocked, got blocked=%v reason=%v", blocked, reason)
+ }
+}
diff --git a/sdk/cliproxy/auth/api_key_model_alias_test.go b/sdk/cliproxy/auth/api_key_model_alias_test.go
index 70915d9e37..25da4df4ed 100644
--- a/sdk/cliproxy/auth/api_key_model_alias_test.go
+++ b/sdk/cliproxy/auth/api_key_model_alias_test.go
@@ -4,7 +4,7 @@ import (
"context"
"testing"
- internalconfig "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
+ internalconfig "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
)
func TestLookupAPIKeyUpstreamModel(t *testing.T) {
diff --git a/sdk/cliproxy/auth/auto_refresh_loop.go b/sdk/cliproxy/auth/auto_refresh_loop.go
index 9767ee5803..2b544631fe 100644
--- a/sdk/cliproxy/auth/auto_refresh_loop.go
+++ b/sdk/cliproxy/auth/auto_refresh_loop.go
@@ -336,7 +336,7 @@ func (l *authAutoRefreshLoop) remove(authID string) {
}
func nextRefreshCheckAt(now time.Time, auth *Auth, interval time.Duration) (time.Time, bool) {
- if auth == nil || auth.Disabled {
+ if auth == nil {
return time.Time{}, false
}
diff --git a/sdk/cliproxy/auth/auto_refresh_loop_test.go b/sdk/cliproxy/auth/auto_refresh_loop_test.go
index 420aae237a..e4edb2df55 100644
--- a/sdk/cliproxy/auth/auto_refresh_loop_test.go
+++ b/sdk/cliproxy/auth/auto_refresh_loop_test.go
@@ -34,9 +34,31 @@ func setRefreshLeadFactory(t *testing.T, provider string, factory func() *time.D
func TestNextRefreshCheckAt_DisabledUnschedule(t *testing.T) {
now := time.Date(2026, 4, 12, 0, 0, 0, 0, time.UTC)
- auth := &Auth{ID: "a1", Provider: "test", Disabled: true}
- if _, ok := nextRefreshCheckAt(now, auth, 15*time.Minute); ok {
- t.Fatalf("nextRefreshCheckAt() ok = true, want false")
+ expiry := now.Add(time.Hour)
+ lead := 10 * time.Minute
+ setRefreshLeadFactory(t, "disabled-schedule", func() *time.Duration {
+ d := lead
+ return &d
+ })
+
+ auth := &Auth{
+ ID: "a1",
+ Provider: "disabled-schedule",
+ Disabled: true,
+ Status: StatusDisabled,
+ Metadata: map[string]any{
+ "email": "x@example.com",
+ "expires_at": expiry.Format(time.RFC3339),
+ },
+ }
+
+ got, ok := nextRefreshCheckAt(now, auth, 15*time.Minute)
+ if !ok {
+ t.Fatalf("nextRefreshCheckAt() ok = false, want true")
+ }
+ want := expiry.Add(-lead)
+ if !got.Equal(want) {
+ t.Fatalf("nextRefreshCheckAt() = %s, want %s", got, want)
}
}
diff --git a/sdk/cliproxy/auth/conductor.go b/sdk/cliproxy/auth/conductor.go
index f58722039c..5d6a303568 100644
--- a/sdk/cliproxy/auth/conductor.go
+++ b/sdk/cliproxy/auth/conductor.go
@@ -16,12 +16,14 @@ import (
"time"
"github.com/google/uuid"
- internalconfig "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/logging"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
- cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
+ internalconfig "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/home"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/logging"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/util"
+ cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
+ coreusage "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/usage"
log "github.com/sirupsen/logrus"
)
@@ -49,6 +51,7 @@ type ExecutionSessionCloser interface {
}
const (
+ homeAuthCountMetadataKey = "__cliproxy_home_auth_count"
// CloseAllExecutionSessionsID asks an executor to release all active execution sessions.
// Executors that do not support this marker may ignore it.
CloseAllExecutionSessionsID = "__all_execution_sessions__"
@@ -64,8 +67,13 @@ const (
refreshMaxConcurrency = 16
refreshPendingBackoff = time.Minute
refreshFailureBackoff = 5 * time.Minute
- quotaBackoffBase = time.Second
- quotaBackoffMax = 30 * time.Minute
+ // refreshIneffectiveBackoff throttles refresh attempts when an executor returns
+ // success but the auth still evaluates as needing refresh (e.g. token expiry
+ // wasn't updated). Without this guard, the auto-refresh loop can tight-loop and
+ // burn CPU at idle.
+ refreshIneffectiveBackoff = 30 * time.Second
+ quotaBackoffBase = time.Second
+ quotaBackoffMax = 30 * time.Minute
)
var quotaCooldownDisabled atomic.Bool
@@ -143,6 +151,9 @@ type Manager struct {
mu sync.RWMutex
auths map[string]*Auth
scheduler *authScheduler
+ // homeRuntimeAuths caches auths returned by Home so websocket sessions can
+ // reuse an established upstream credential without dispatching every turn.
+ homeRuntimeAuths map[string]map[string]*Auth
// providerOffsets tracks per-model provider rotation state for multi-provider routing.
providerOffsets map[string]int
@@ -187,6 +198,7 @@ func NewManager(store Store, selector Selector, hook Hook) *Manager {
selector: selector,
hook: hook,
auths: make(map[string]*Auth),
+ homeRuntimeAuths: make(map[string]map[string]*Auth),
providerOffsets: make(map[string]int),
modelPoolOffsets: make(map[string]int),
}
@@ -368,9 +380,21 @@ func (m *Manager) SetConfig(cfg *internalconfig.Config) {
cfg = &internalconfig.Config{}
}
m.runtimeConfig.Store(cfg)
+ if !cfg.Home.Enabled {
+ m.clearHomeRuntimeAuths()
+ }
m.rebuildAPIKeyModelAliasFromRuntimeConfig()
}
+// HomeEnabled reports whether the home control plane integration is enabled in the runtime config.
+func (m *Manager) HomeEnabled() bool {
+ if m == nil {
+ return false
+ }
+ cfg, _ := m.runtimeConfig.Load().(*internalconfig.Config)
+ return cfg != nil && cfg.Home.Enabled
+}
+
func (m *Manager) lookupAPIKeyUpstreamModel(authID, requestedModel string) string {
if m == nil {
return ""
@@ -516,6 +540,11 @@ func preserveRequestedModelSuffix(requestedModel, resolved string) string {
}
func (m *Manager) executionModelCandidates(auth *Auth, routeModel string) []string {
+ if auth != nil && auth.Attributes != nil {
+ if homeModel := strings.TrimSpace(auth.Attributes[homeUpstreamModelAttributeKey]); homeModel != "" {
+ return []string{homeModel}
+ }
+ }
requestedModel := rewriteModelForAuth(routeModel, auth)
requestedModel = m.applyOAuthModelAlias(auth, requestedModel)
if pool := m.resolveOpenAICompatUpstreamModelPool(auth, requestedModel); len(pool) > 0 {
@@ -549,6 +578,14 @@ func (m *Manager) selectionModelKeyForAuth(auth *Auth, routeModel string) string
}
func (m *Manager) stateModelForExecution(auth *Auth, routeModel, upstreamModel string, pooled bool) string {
+ if auth != nil && auth.Attributes != nil {
+ if homeModel := strings.TrimSpace(auth.Attributes[homeUpstreamModelAttributeKey]); homeModel != "" {
+ if resolved := strings.TrimSpace(upstreamModel); resolved != "" {
+ return resolved
+ }
+ return homeModel
+ }
+ }
stateModel := executionResultModel(routeModel, upstreamModel, pooled)
selectionModel := m.selectionModelForAuth(auth, routeModel)
if canonicalModelKey(selectionModel) == canonicalModelKey(upstreamModel) && strings.TrimSpace(selectionModel) != "" {
@@ -822,6 +859,7 @@ func (m *Manager) executeStreamWithModelPool(ctx context.Context, executor Provi
if executor == nil {
return nil, &Error{Code: "executor_not_found", Message: "executor not registered"}
}
+ ctx = contextWithRequestedModelAlias(ctx, opts, routeModel)
var lastErr error
for idx, execModel := range execModels {
resultModel := m.stateModelForExecution(auth, routeModel, execModel, pooled)
@@ -1121,6 +1159,9 @@ func (m *Manager) Update(ctx context.Context, auth *Auth) (*Auth, error) {
auth.Index = existing.Index
auth.indexAssigned = existing.indexAssigned
}
+ auth.Success = existing.Success
+ auth.Failed = existing.Failed
+ auth.recentRequests = existing.recentRequests
if !existing.Disabled && existing.Status != StatusDisabled && !auth.Disabled && auth.Status != StatusDisabled {
if len(auth.ModelStates) == 0 && len(existing.ModelStates) > 0 {
auth.ModelStates = existing.ModelStates
@@ -1197,12 +1238,16 @@ func (m *Manager) Execute(ctx context.Context, providers []string, req cliproxye
}
}
if lastErr != nil {
+ if shouldAttemptAntigravityCreditsFallback(m, lastErr, normalized) {
+ if resp, ok := m.tryAntigravityCreditsExecute(ctx, req, opts); ok {
+ return resp, nil
+ }
+ }
return cliproxyexecutor.Response{}, lastErr
}
return cliproxyexecutor.Response{}, &Error{Code: "auth_not_found", Message: "no auth available"}
}
-// ExecuteCount performs a non-streaming execution using the configured selector and executor.
// It supports multiple providers for the same model and round-robins the starting provider per model.
func (m *Manager) ExecuteCount(ctx context.Context, providers []string, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
normalized := m.normalizeProviders(providers)
@@ -1259,6 +1304,15 @@ func (m *Manager) ExecuteStream(ctx context.Context, providers []string, req cli
}
}
if lastErr != nil {
+ if shouldAttemptAntigravityCreditsFallback(m, lastErr, normalized) {
+ if result, ok := m.tryAntigravityCreditsExecuteStream(ctx, req, opts); ok {
+ return result, nil
+ }
+ }
+ var bootstrapErr *streamBootstrapError
+ if errors.As(lastErr, &bootstrapErr) && bootstrapErr != nil {
+ return streamErrorResult(bootstrapErr.Headers(), bootstrapErr.cause), nil
+ }
return nil, lastErr
}
return nil, &Error{Code: "auth_not_found", Message: "no auth available"}
@@ -1270,19 +1324,25 @@ func (m *Manager) executeMixedOnce(ctx context.Context, providers []string, req
}
routeModel := req.Model
opts = ensureRequestedModelMetadata(opts, routeModel)
+ homeMode := m.HomeEnabled()
+ homeAuthCount := 1
tried := make(map[string]struct{})
attempted := make(map[string]struct{})
var lastErr error
for {
- if maxRetryCredentials > 0 && len(attempted) >= maxRetryCredentials {
+ if !homeMode && maxRetryCredentials > 0 && len(attempted) >= maxRetryCredentials {
if lastErr != nil {
return cliproxyexecutor.Response{}, lastErr
}
return cliproxyexecutor.Response{}, &Error{Code: "auth_not_found", Message: "no auth available"}
}
- auth, executor, provider, errPick := m.pickNextMixed(ctx, providers, routeModel, opts, tried)
+ pickOpts := opts
+ if homeMode {
+ pickOpts = withHomeAuthCount(opts, homeAuthCount)
+ }
+ auth, executor, provider, errPick := m.pickNextMixed(ctx, providers, routeModel, pickOpts, tried)
if errPick != nil {
- if lastErr != nil {
+ if shouldReturnLastErrorOnPickFailure(homeMode, lastErr, errPick) {
return cliproxyexecutor.Response{}, lastErr
}
return cliproxyexecutor.Response{}, errPick
@@ -1298,6 +1358,7 @@ func (m *Manager) executeMixedOnce(ctx context.Context, providers []string, req
execCtx = context.WithValue(execCtx, roundTripperContextKey{}, rt)
execCtx = context.WithValue(execCtx, "cliproxy.roundtripper", rt)
}
+ execCtx = contextWithRequestedModelAlias(execCtx, opts, routeModel)
models, pooled := m.preparedExecutionModels(auth, routeModel)
if len(models) == 0 {
@@ -1337,6 +1398,9 @@ func (m *Manager) executeMixedOnce(ctx context.Context, providers []string, req
return cliproxyexecutor.Response{}, authErr
}
lastErr = authErr
+ if homeMode {
+ homeAuthCount++
+ }
continue
}
}
@@ -1348,19 +1412,25 @@ func (m *Manager) executeCountMixedOnce(ctx context.Context, providers []string,
}
routeModel := req.Model
opts = ensureRequestedModelMetadata(opts, routeModel)
+ homeMode := m.HomeEnabled()
+ homeAuthCount := 1
tried := make(map[string]struct{})
attempted := make(map[string]struct{})
var lastErr error
for {
- if maxRetryCredentials > 0 && len(attempted) >= maxRetryCredentials {
+ if !homeMode && maxRetryCredentials > 0 && len(attempted) >= maxRetryCredentials {
if lastErr != nil {
return cliproxyexecutor.Response{}, lastErr
}
return cliproxyexecutor.Response{}, &Error{Code: "auth_not_found", Message: "no auth available"}
}
- auth, executor, provider, errPick := m.pickNextMixed(ctx, providers, routeModel, opts, tried)
+ pickOpts := opts
+ if homeMode {
+ pickOpts = withHomeAuthCount(opts, homeAuthCount)
+ }
+ auth, executor, provider, errPick := m.pickNextMixed(ctx, providers, routeModel, pickOpts, tried)
if errPick != nil {
- if lastErr != nil {
+ if shouldReturnLastErrorOnPickFailure(homeMode, lastErr, errPick) {
return cliproxyexecutor.Response{}, lastErr
}
return cliproxyexecutor.Response{}, errPick
@@ -1376,6 +1446,7 @@ func (m *Manager) executeCountMixedOnce(ctx context.Context, providers []string,
execCtx = context.WithValue(execCtx, roundTripperContextKey{}, rt)
execCtx = context.WithValue(execCtx, "cliproxy.roundtripper", rt)
}
+ execCtx = contextWithRequestedModelAlias(execCtx, opts, routeModel)
models, pooled := m.preparedExecutionModels(auth, routeModel)
if len(models) == 0 {
@@ -1415,6 +1486,9 @@ func (m *Manager) executeCountMixedOnce(ctx context.Context, providers []string,
return cliproxyexecutor.Response{}, authErr
}
lastErr = authErr
+ if homeMode {
+ homeAuthCount++
+ }
continue
}
}
@@ -1426,27 +1500,25 @@ func (m *Manager) executeStreamMixedOnce(ctx context.Context, providers []string
}
routeModel := req.Model
opts = ensureRequestedModelMetadata(opts, routeModel)
+ homeMode := m.HomeEnabled()
+ homeAuthCount := 1
tried := make(map[string]struct{})
attempted := make(map[string]struct{})
var lastErr error
for {
- if maxRetryCredentials > 0 && len(attempted) >= maxRetryCredentials {
+ if !homeMode && maxRetryCredentials > 0 && len(attempted) >= maxRetryCredentials {
if lastErr != nil {
- var bootstrapErr *streamBootstrapError
- if errors.As(lastErr, &bootstrapErr) && bootstrapErr != nil {
- return streamErrorResult(bootstrapErr.Headers(), bootstrapErr.cause), nil
- }
return nil, lastErr
}
return nil, &Error{Code: "auth_not_found", Message: "no auth available"}
}
- auth, executor, provider, errPick := m.pickNextMixed(ctx, providers, routeModel, opts, tried)
+ pickOpts := opts
+ if homeMode {
+ pickOpts = withHomeAuthCount(opts, homeAuthCount)
+ }
+ auth, executor, provider, errPick := m.pickNextMixed(ctx, providers, routeModel, pickOpts, tried)
if errPick != nil {
- if lastErr != nil {
- var bootstrapErr *streamBootstrapError
- if errors.As(lastErr, &bootstrapErr) && bootstrapErr != nil {
- return streamErrorResult(bootstrapErr.Headers(), bootstrapErr.cause), nil
- }
+ if shouldReturnLastErrorOnPickFailure(homeMode, lastErr, errPick) {
return nil, lastErr
}
return nil, errPick
@@ -1476,6 +1548,9 @@ func (m *Manager) executeStreamMixedOnce(ctx context.Context, providers []string
return nil, errStream
}
lastErr = errStream
+ if homeMode {
+ homeAuthCount++
+ }
continue
}
return streamResult, nil
@@ -1503,6 +1578,40 @@ func ensureRequestedModelMetadata(opts cliproxyexecutor.Options, requestedModel
return opts
}
+func withHomeAuthCount(opts cliproxyexecutor.Options, count int) cliproxyexecutor.Options {
+ if count <= 0 {
+ count = 1
+ }
+ meta := make(map[string]any, len(opts.Metadata)+1)
+ for k, v := range opts.Metadata {
+ meta[k] = v
+ }
+ meta[homeAuthCountMetadataKey] = count
+ opts.Metadata = meta
+ return opts
+}
+
+func homeAuthCountFromMetadata(meta map[string]any) int {
+ if len(meta) == 0 {
+ return 1
+ }
+ switch value := meta[homeAuthCountMetadataKey].(type) {
+ case int:
+ if value > 0 {
+ return value
+ }
+ case int64:
+ if value > 0 {
+ return int(value)
+ }
+ case float64:
+ if value > 0 {
+ return int(value)
+ }
+ }
+ return 1
+}
+
func hasRequestedModelMetadata(meta map[string]any) bool {
if len(meta) == 0 {
return false
@@ -1521,6 +1630,36 @@ func hasRequestedModelMetadata(meta map[string]any) bool {
}
}
+func contextWithRequestedModelAlias(ctx context.Context, opts cliproxyexecutor.Options, fallback string) context.Context {
+ alias := requestedModelAliasFromOptions(opts, fallback)
+ return coreusage.WithRequestedModelAlias(ctx, alias)
+}
+
+func requestedModelAliasFromOptions(opts cliproxyexecutor.Options, fallback string) string {
+ fallback = strings.TrimSpace(fallback)
+ if len(opts.Metadata) == 0 {
+ return fallback
+ }
+ raw, ok := opts.Metadata[cliproxyexecutor.RequestedModelMetadataKey]
+ if !ok || raw == nil {
+ return fallback
+ }
+ switch value := raw.(type) {
+ case string:
+ if strings.TrimSpace(value) == "" {
+ return fallback
+ }
+ return strings.TrimSpace(value)
+ case []byte:
+ if len(value) == 0 {
+ return fallback
+ }
+ return strings.TrimSpace(string(value))
+ default:
+ return fallback
+ }
+}
+
func pinnedAuthIDFromMetadata(meta map[string]any) string {
if len(meta) == 0 {
return ""
@@ -1539,6 +1678,38 @@ func pinnedAuthIDFromMetadata(meta map[string]any) string {
}
}
+func disallowFreeAuthFromMetadata(meta map[string]any) bool {
+ if len(meta) == 0 {
+ return false
+ }
+ raw, ok := meta[cliproxyexecutor.DisallowFreeAuthMetadataKey]
+ if !ok || raw == nil {
+ return false
+ }
+ switch val := raw.(type) {
+ case bool:
+ return val
+ case string:
+ parsed, err := strconv.ParseBool(strings.TrimSpace(val))
+ return err == nil && parsed
+ case []byte:
+ parsed, err := strconv.ParseBool(strings.TrimSpace(string(val)))
+ return err == nil && parsed
+ default:
+ return false
+ }
+}
+
+func isFreeCodexAuth(auth *Auth) bool {
+ if auth == nil || auth.Attributes == nil {
+ return false
+ }
+ if !strings.EqualFold(strings.TrimSpace(auth.Provider), "codex") {
+ return false
+ }
+ return strings.EqualFold(strings.TrimSpace(auth.Attributes["plan_type"]), "free")
+}
+
func publishSelectedAuthMetadata(meta map[string]any, authID string) {
if len(meta) == 0 {
return
@@ -1757,6 +1928,9 @@ func resolveOpenAICompatConfig(cfg *internalconfig.Config, providerKey, compatNa
}
for i := range cfg.OpenAICompatibility {
compat := &cfg.OpenAICompatibility[i]
+ if compat.Disabled {
+ continue
+ }
for _, candidate := range candidates {
if candidate != "" && strings.EqualFold(strings.TrimSpace(candidate), compat.Name) {
return compat
@@ -1976,6 +2150,12 @@ func (m *Manager) MarkResult(ctx context.Context, result Result) {
m.mu.Lock()
if auth, ok := m.auths[result.AuthID]; ok && auth != nil {
now := time.Now()
+ auth.recordRecentRequest(now, result.Success)
+ if result.Success {
+ auth.Success++
+ } else {
+ auth.Failed++
+ }
if result.Success {
if result.Model != "" {
@@ -2285,6 +2465,13 @@ func cloneError(err *Error) *Error {
}
}
+func errorString(err error) string {
+ if err == nil {
+ return ""
+ }
+ return err.Error()
+}
+
func statusCodeFromError(err error) int {
if err == nil {
return 0
@@ -2314,7 +2501,8 @@ func retryAfterFromError(err error) *time.Duration {
if retryAfter == nil {
return nil
}
- return new(*retryAfter)
+ value := *retryAfter
+ return &value
}
func statusCodeFromResult(err *Error) int {
@@ -2404,11 +2592,18 @@ func isRequestInvalidError(err error) bool {
status := statusCodeFromError(err)
switch status {
case http.StatusBadRequest:
- return strings.Contains(err.Error(), "invalid_request_error")
+ msg := err.Error()
+ return strings.Contains(msg, "invalid_request_error") ||
+ strings.Contains(msg, "INVALID_ARGUMENT") ||
+ strings.Contains(msg, "FAILED_PRECONDITION")
case http.StatusNotFound:
return isRequestScopedNotFoundMessage(err.Error())
case http.StatusUnprocessableEntity:
return true
+ case http.StatusInternalServerError:
+ msg := err.Error()
+ return strings.Contains(msg, "\"status\":\"UNKNOWN\"") ||
+ strings.Contains(msg, "\"status\": \"UNKNOWN\"")
default:
return false
}
@@ -2530,6 +2725,23 @@ func (m *Manager) GetByID(id string) (*Auth, bool) {
return auth.Clone(), true
}
+// GetExecutionSessionAuthByID retrieves a Home runtime auth scoped to an execution session.
+func (m *Manager) GetExecutionSessionAuthByID(sessionID string, authID string) (*Auth, bool) {
+ sessionID = strings.TrimSpace(sessionID)
+ authID = strings.TrimSpace(authID)
+ if m == nil || sessionID == "" || authID == "" {
+ return nil, false
+ }
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+ sessionAuths := m.homeRuntimeAuths[sessionID]
+ auth := sessionAuths[authID]
+ if auth == nil {
+ return nil, false
+ }
+ return auth.Clone(), true
+}
+
// Executor returns the registered provider executor for a provider key.
func (m *Manager) Executor(provider string) (ProviderExecutor, bool) {
if m == nil {
@@ -2563,12 +2775,17 @@ func (m *Manager) CloseExecutionSession(sessionID string) {
return
}
- m.mu.RLock()
+ m.mu.Lock()
+ if sessionID == CloseAllExecutionSessionsID {
+ m.clearHomeRuntimeAuthsLocked()
+ } else {
+ m.clearHomeRuntimeAuthsForSessionLocked(sessionID)
+ }
executors := make([]ProviderExecutor, 0, len(m.executors))
for _, exec := range m.executors {
executors = append(executors, exec)
}
- m.mu.RUnlock()
+ m.mu.Unlock()
for i := range executors {
if closer, ok := executors[i].(ExecutionSessionCloser); ok && closer != nil {
@@ -2607,7 +2824,13 @@ func (m *Manager) routeAwareSelectionRequired(auth *Auth, routeModel string) boo
}
func (m *Manager) pickNextLegacy(ctx context.Context, provider, model string, opts cliproxyexecutor.Options, tried map[string]struct{}) (*Auth, ProviderExecutor, error) {
+ if m.HomeEnabled() {
+ auth, exec, _, err := m.pickNextViaHome(ctx, model, opts, tried)
+ return auth, exec, err
+ }
+
pinnedAuthID := pinnedAuthIDFromMetadata(opts.Metadata)
+ disallowFreeAuth := disallowFreeAuthFromMetadata(opts.Metadata)
m.mu.RLock()
executor, okExecutor := m.executors[provider]
@@ -2632,6 +2855,9 @@ func (m *Manager) pickNextLegacy(ctx context.Context, provider, model string, op
if pinnedAuthID != "" && candidate.ID != pinnedAuthID {
continue
}
+ if disallowFreeAuth && isFreeCodexAuth(candidate) {
+ continue
+ }
if _, used := tried[candidate.ID]; used {
continue
}
@@ -2672,6 +2898,11 @@ func (m *Manager) pickNextLegacy(ctx context.Context, provider, model string, op
}
func (m *Manager) pickNext(ctx context.Context, provider, model string, opts cliproxyexecutor.Options, tried map[string]struct{}) (*Auth, ProviderExecutor, error) {
+ if m.HomeEnabled() {
+ auth, exec, _, err := m.pickNextViaHome(ctx, model, opts, tried)
+ return auth, exec, err
+ }
+
if !m.useSchedulerFastPath() {
return m.pickNextLegacy(ctx, provider, model, opts, tried)
}
@@ -2695,31 +2926,46 @@ func (m *Manager) pickNext(ctx context.Context, provider, model string, opts cli
if !okExecutor {
return nil, nil, &Error{Code: "executor_not_found", Message: "executor not registered"}
}
- selected, errPick := m.scheduler.pickSingle(ctx, provider, model, opts, tried)
- if errPick != nil && model != "" && shouldRetrySchedulerPick(errPick) {
- m.syncScheduler()
- selected, errPick = m.scheduler.pickSingle(ctx, provider, model, opts, tried)
- }
- if errPick != nil {
- return nil, nil, errPick
- }
- if selected == nil {
- return nil, nil, &Error{Code: "auth_not_found", Message: "selector returned no auth"}
- }
- authCopy := selected.Clone()
- if !selected.indexAssigned {
- m.mu.Lock()
- if current := m.auths[authCopy.ID]; current != nil && !current.indexAssigned {
- current.EnsureIndex()
- authCopy = current.Clone()
+ disallowFreeAuth := disallowFreeAuthFromMetadata(opts.Metadata)
+ for {
+ selected, errPick := m.scheduler.pickSingle(ctx, provider, model, opts, tried)
+ if errPick != nil && model != "" && shouldRetrySchedulerPick(errPick) {
+ m.syncScheduler()
+ selected, errPick = m.scheduler.pickSingle(ctx, provider, model, opts, tried)
}
- m.mu.Unlock()
+ if errPick != nil {
+ return nil, nil, errPick
+ }
+ if selected == nil {
+ return nil, nil, &Error{Code: "auth_not_found", Message: "selector returned no auth"}
+ }
+ if disallowFreeAuth && isFreeCodexAuth(selected) {
+ if tried == nil {
+ tried = make(map[string]struct{})
+ }
+ tried[selected.ID] = struct{}{}
+ continue
+ }
+ authCopy := selected.Clone()
+ 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
}
- return authCopy, executor, nil
}
func (m *Manager) pickNextMixedLegacy(ctx context.Context, providers []string, model string, opts cliproxyexecutor.Options, tried map[string]struct{}) (*Auth, ProviderExecutor, string, error) {
+ if m.HomeEnabled() {
+ return m.pickNextViaHome(ctx, model, opts, tried)
+ }
+
pinnedAuthID := pinnedAuthIDFromMetadata(opts.Metadata)
+ disallowFreeAuth := disallowFreeAuthFromMetadata(opts.Metadata)
providerSet := make(map[string]struct{}, len(providers))
for _, provider := range providers {
@@ -2751,6 +2997,9 @@ func (m *Manager) pickNextMixedLegacy(ctx context.Context, providers []string, m
if pinnedAuthID != "" && candidate.ID != pinnedAuthID {
continue
}
+ if disallowFreeAuth && isFreeCodexAuth(candidate) {
+ continue
+ }
providerKey := strings.TrimSpace(strings.ToLower(candidate.Provider))
if providerKey == "" {
continue
@@ -2807,6 +3056,10 @@ func (m *Manager) pickNextMixedLegacy(ctx context.Context, providers []string, m
}
func (m *Manager) pickNextMixed(ctx context.Context, providers []string, model string, opts cliproxyexecutor.Options, tried map[string]struct{}) (*Auth, ProviderExecutor, string, error) {
+ if m.HomeEnabled() {
+ return m.pickNextViaHome(ctx, model, opts, tried)
+ }
+
if !m.useSchedulerFastPath() {
return m.pickNextMixedLegacy(ctx, providers, model, opts, tried)
}
@@ -2854,33 +3107,492 @@ func (m *Manager) pickNextMixed(ctx context.Context, providers []string, model s
m.mu.RUnlock()
}
- selected, providerKey, errPick := m.scheduler.pickMixed(ctx, eligibleProviders, model, opts, tried)
- if errPick != nil && model != "" && shouldRetrySchedulerPick(errPick) {
- m.syncScheduler()
- selected, providerKey, errPick = m.scheduler.pickMixed(ctx, eligibleProviders, model, opts, tried)
+ disallowFreeAuth := disallowFreeAuthFromMetadata(opts.Metadata)
+ for {
+ selected, providerKey, errPick := m.scheduler.pickMixed(ctx, eligibleProviders, model, opts, tried)
+ if errPick != nil && model != "" && shouldRetrySchedulerPick(errPick) {
+ m.syncScheduler()
+ selected, providerKey, errPick = m.scheduler.pickMixed(ctx, eligibleProviders, model, opts, tried)
+ }
+ if errPick != nil {
+ return nil, nil, "", errPick
+ }
+ if selected == nil {
+ return nil, nil, "", &Error{Code: "auth_not_found", Message: "selector returned no auth"}
+ }
+ if disallowFreeAuth && isFreeCodexAuth(selected) {
+ if tried == nil {
+ tried = make(map[string]struct{})
+ }
+ tried[selected.ID] = struct{}{}
+ continue
+ }
+ executor, okExecutor := m.Executor(providerKey)
+ if !okExecutor {
+ return nil, nil, "", &Error{Code: "executor_not_found", Message: "executor not registered"}
+ }
+ authCopy := selected.Clone()
+ 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, providerKey, nil
}
- if errPick != nil {
- return nil, nil, "", errPick
+}
+
+type homeErrorEnvelope struct {
+ Error *homeErrorDetail `json:"error"`
+}
+
+type homeErrorDetail struct {
+ Type string `json:"type"`
+ Message string `json:"message"`
+ Code string `json:"code,omitempty"`
+}
+
+const (
+ homeUpstreamModelAttributeKey = "home_upstream_model"
+ homeRequestRetryExceededErrorCode = "request_retry_exceeded"
+)
+
+func isHomeRequestRetryExceededError(err error) bool {
+ var authErr *Error
+ if !errors.As(err, &authErr) || authErr == nil {
+ return false
}
- if selected == nil {
- return nil, nil, "", &Error{Code: "auth_not_found", Message: "selector returned no auth"}
+ return strings.EqualFold(strings.TrimSpace(authErr.Code), homeRequestRetryExceededErrorCode)
+}
+
+func shouldReturnLastErrorOnPickFailure(homeMode bool, lastErr error, errPick error) bool {
+ if lastErr == nil {
+ return false
}
- executor, okExecutor := m.Executor(providerKey)
- if !okExecutor {
- return nil, nil, "", &Error{Code: "executor_not_found", Message: "executor not registered"}
+ if !homeMode {
+ return true
}
- authCopy := selected.Clone()
- if !selected.indexAssigned {
- m.mu.Lock()
- if current := m.auths[authCopy.ID]; current != nil && !current.indexAssigned {
- current.EnsureIndex()
- authCopy = current.Clone()
+ return isHomeRequestRetryExceededError(errPick)
+}
+
+type homeAuthDispatchResponse struct {
+ Model string `json:"model"`
+ Provider string `json:"provider"`
+ AuthIndex string `json:"auth_index"`
+ UserAPIKey string `json:"user_api_key"`
+ Auth Auth `json:"auth"`
+}
+
+func setHomeUserAPIKeyOnGinContext(ctx context.Context, apiKey string) {
+ apiKey = strings.TrimSpace(apiKey)
+ if apiKey == "" || ctx == nil {
+ return
+ }
+ ginCtx, ok := ctx.Value("gin").(interface{ Set(string, any) })
+ if !ok || ginCtx == nil {
+ return
+ }
+ ginCtx.Set("userApiKey", apiKey)
+}
+
+func homeExecutionSessionIDFromMetadata(meta map[string]any) string {
+ if len(meta) == 0 {
+ return ""
+ }
+ raw, ok := meta[cliproxyexecutor.ExecutionSessionMetadataKey]
+ if !ok || raw == nil {
+ return ""
+ }
+ switch value := raw.(type) {
+ case string:
+ return strings.TrimSpace(value)
+ case []byte:
+ return strings.TrimSpace(string(value))
+ default:
+ return ""
+ }
+}
+
+func (m *Manager) clearHomeRuntimeAuths() {
+ if m == nil {
+ return
+ }
+ m.mu.Lock()
+ m.clearHomeRuntimeAuthsLocked()
+ m.mu.Unlock()
+}
+
+func (m *Manager) clearHomeRuntimeAuthsLocked() {
+ if m == nil {
+ return
+ }
+ m.homeRuntimeAuths = make(map[string]map[string]*Auth)
+}
+
+func (m *Manager) clearHomeRuntimeAuthsForSessionLocked(sessionID string) {
+ sessionID = strings.TrimSpace(sessionID)
+ if m == nil || sessionID == "" {
+ return
+ }
+ delete(m.homeRuntimeAuths, sessionID)
+}
+
+func (m *Manager) rememberHomeRuntimeAuth(sessionID string, auth *Auth) {
+ sessionID = strings.TrimSpace(sessionID)
+ authID := ""
+ if auth != nil {
+ authID = strings.TrimSpace(auth.ID)
+ }
+ if m == nil || auth == nil || sessionID == "" || authID == "" || !authWebsocketsEnabled(auth) {
+ return
+ }
+ m.mu.Lock()
+ if m.homeRuntimeAuths == nil {
+ m.homeRuntimeAuths = make(map[string]map[string]*Auth)
+ }
+ sessionAuths := m.homeRuntimeAuths[sessionID]
+ if sessionAuths == nil {
+ sessionAuths = make(map[string]*Auth)
+ m.homeRuntimeAuths[sessionID] = sessionAuths
+ }
+ sessionAuths[authID] = auth.Clone()
+ m.mu.Unlock()
+}
+
+func (m *Manager) homeRuntimeAuthByID(sessionID string, authID string) (*Auth, ProviderExecutor, string, bool) {
+ sessionID = strings.TrimSpace(sessionID)
+ authID = strings.TrimSpace(authID)
+ if m == nil || sessionID == "" || authID == "" {
+ return nil, nil, "", false
+ }
+ m.mu.RLock()
+ sessionAuths := m.homeRuntimeAuths[sessionID]
+ auth := sessionAuths[authID]
+ m.mu.RUnlock()
+ if auth == nil || !authWebsocketsEnabled(auth) {
+ return nil, nil, "", false
+ }
+ providerKey := strings.ToLower(strings.TrimSpace(auth.Provider))
+ if providerKey == "" {
+ return nil, nil, "", false
+ }
+ executor, ok := m.Executor(providerKey)
+ if !ok && auth.Attributes != nil && strings.TrimSpace(auth.Attributes["base_url"]) != "" {
+ executor, ok = m.Executor("openai-compatibility")
+ if ok {
+ providerKey = "openai-compatibility"
}
- m.mu.Unlock()
+ }
+ if !ok {
+ return nil, nil, "", false
+ }
+ return auth.Clone(), executor, providerKey, true
+}
+
+func (m *Manager) pickNextViaHome(ctx context.Context, model string, opts cliproxyexecutor.Options, tried map[string]struct{}) (*Auth, ProviderExecutor, string, error) {
+ if m == nil {
+ return nil, nil, "", &Error{Code: "auth_not_found", Message: "no auth available"}
+ }
+ if ctx == nil {
+ ctx = context.Background()
+ }
+ executionSessionID := homeExecutionSessionIDFromMetadata(opts.Metadata)
+ count := homeAuthCountFromMetadata(opts.Metadata)
+ if cliproxyexecutor.DownstreamWebsocket(ctx) && executionSessionID != "" && count <= 1 {
+ if pinnedAuthID := pinnedAuthIDFromMetadata(opts.Metadata); pinnedAuthID != "" {
+ _, alreadyTried := tried[pinnedAuthID]
+ if !alreadyTried {
+ if auth, executor, providerKey, ok := m.homeRuntimeAuthByID(executionSessionID, pinnedAuthID); ok {
+ return auth, executor, providerKey, nil
+ }
+ }
+ }
+ }
+
+ client := home.Current()
+ if client == nil || !client.HeartbeatOK() {
+ return nil, nil, "", &Error{Code: "home_unavailable", Message: "home control center unavailable", HTTPStatus: http.StatusServiceUnavailable}
+ }
+
+ requestedModel := requestedModelFromMetadata(opts.Metadata, model)
+ sessionID := ExtractSessionID(opts.Headers, opts.OriginalRequest, opts.Metadata)
+
+ raw, err := client.RPopAuth(ctx, requestedModel, sessionID, opts.Headers, count)
+ if err != nil {
+ return nil, nil, "", &Error{Code: "auth_not_found", Message: err.Error(), HTTPStatus: http.StatusServiceUnavailable}
+ }
+
+ var env homeErrorEnvelope
+ if errUnmarshal := json.Unmarshal(raw, &env); errUnmarshal == nil && env.Error != nil {
+ code := strings.TrimSpace(env.Error.Type)
+ if code == "" {
+ code = strings.TrimSpace(env.Error.Code)
+ }
+ msg := strings.TrimSpace(env.Error.Message)
+ if msg == "" {
+ msg = "home returned error"
+ }
+ status := http.StatusBadGateway
+ switch strings.ToLower(code) {
+ case "model_not_found":
+ status = http.StatusNotFound
+ case "authentication_error", "unauthorized":
+ status = http.StatusUnauthorized
+ }
+ return nil, nil, "", &Error{Code: code, Message: msg, HTTPStatus: status}
+ }
+
+ var dispatch homeAuthDispatchResponse
+ if errUnmarshal := json.Unmarshal(raw, &dispatch); errUnmarshal != nil {
+ return nil, nil, "", &Error{Code: "invalid_auth", Message: "home returned invalid auth payload", HTTPStatus: http.StatusBadGateway}
+ }
+ setHomeUserAPIKeyOnGinContext(ctx, dispatch.UserAPIKey)
+ auth := dispatch.Auth
+ if strings.TrimSpace(auth.ID) == "" {
+ // Backward compatibility: older home instances returned the auth directly.
+ if errUnmarshal := json.Unmarshal(raw, &auth); errUnmarshal != nil {
+ return nil, nil, "", &Error{Code: "invalid_auth", Message: "home returned invalid auth payload", HTTPStatus: http.StatusBadGateway}
+ }
+ }
+ if upstreamModel := strings.TrimSpace(dispatch.Model); upstreamModel != "" {
+ if auth.Attributes == nil {
+ auth.Attributes = make(map[string]string, 1)
+ }
+ auth.Attributes[homeUpstreamModelAttributeKey] = upstreamModel
+ }
+ if strings.TrimSpace(auth.ID) == "" {
+ return nil, nil, "", &Error{Code: "invalid_auth", Message: "home returned auth without id", HTTPStatus: http.StatusBadGateway}
+ }
+ providerKey := strings.ToLower(strings.TrimSpace(auth.Provider))
+ if providerKey == "" {
+ return nil, nil, "", &Error{Code: "invalid_auth", Message: "home returned auth without provider", HTTPStatus: http.StatusBadGateway}
+ }
+
+ homeAuthIndex := strings.TrimSpace(dispatch.AuthIndex)
+ if homeAuthIndex != "" {
+ auth.Index = homeAuthIndex
+ auth.indexAssigned = true
+ } else {
+ auth.EnsureIndex()
+ }
+
+ executor, ok := m.Executor(providerKey)
+ if !ok && auth.Attributes != nil && strings.TrimSpace(auth.Attributes["base_url"]) != "" {
+ executor, ok = m.Executor("openai-compatibility")
+ if ok {
+ providerKey = "openai-compatibility"
+ }
+ }
+ if !ok {
+ return nil, nil, "", &Error{Code: "executor_not_found", Message: "executor not registered", HTTPStatus: http.StatusBadGateway}
+ }
+
+ authCopy := auth.Clone()
+ if cliproxyexecutor.DownstreamWebsocket(ctx) && executionSessionID != "" && authWebsocketsEnabled(authCopy) {
+ m.rememberHomeRuntimeAuth(executionSessionID, authCopy)
}
return authCopy, executor, providerKey, nil
}
+func requestedModelFromMetadata(metadata map[string]any, fallback string) string {
+ if metadata != nil {
+ if v, ok := metadata[cliproxyexecutor.RequestedModelMetadataKey]; ok {
+ switch typed := v.(type) {
+ case string:
+ if trimmed := strings.TrimSpace(typed); trimmed != "" {
+ return trimmed
+ }
+ case []byte:
+ if trimmed := strings.TrimSpace(string(typed)); trimmed != "" {
+ return trimmed
+ }
+ }
+ }
+ }
+ fallback = strings.TrimSpace(fallback)
+ if fallback == "" {
+ return "unknown"
+ }
+ return fallback
+}
+
+func (m *Manager) findAllAntigravityCreditsCandidateAuths(routeModel string, opts cliproxyexecutor.Options) []creditsCandidateEntry {
+ if m == nil {
+ return nil
+ }
+ pinnedAuthID := pinnedAuthIDFromMetadata(opts.Metadata)
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+ var known []creditsCandidateEntry
+ var unknown []creditsCandidateEntry
+ for _, auth := range m.auths {
+ if auth == nil || auth.Disabled || auth.Status == StatusDisabled {
+ continue
+ }
+ if pinnedAuthID != "" && auth.ID != pinnedAuthID {
+ continue
+ }
+ if !strings.EqualFold(strings.TrimSpace(auth.Provider), "antigravity") {
+ continue
+ }
+ if !strings.Contains(strings.ToLower(strings.TrimSpace(routeModel)), "claude") {
+ continue
+ }
+ providerKey := strings.TrimSpace(strings.ToLower(auth.Provider))
+ executor, ok := m.executors[providerKey]
+ if !ok {
+ continue
+ }
+
+ hint, okHint := GetAntigravityCreditsHint(auth.ID)
+ if okHint && hint.Known {
+ if !hint.Available {
+ continue
+ }
+ known = append(known, creditsCandidateEntry{
+ auth: auth.Clone(),
+ executor: executor,
+ provider: providerKey,
+ })
+ continue
+ }
+ unknown = append(unknown, creditsCandidateEntry{
+ auth: auth.Clone(),
+ executor: executor,
+ provider: providerKey,
+ })
+ }
+ sort.Slice(known, func(i, j int) bool {
+ return known[i].auth.ID < known[j].auth.ID
+ })
+ sort.Slice(unknown, func(i, j int) bool {
+ return unknown[i].auth.ID < unknown[j].auth.ID
+ })
+ return append(known, unknown...)
+}
+
+type creditsCandidateEntry struct {
+ auth *Auth
+ executor ProviderExecutor
+ provider string
+}
+
+func shouldAttemptAntigravityCreditsFallback(m *Manager, lastErr error, providers []string) bool {
+ status := statusCodeFromError(lastErr)
+ log.WithFields(log.Fields{
+ "lastErr": errorString(lastErr),
+ "status": status,
+ "providers": providers,
+ }).Debug("shouldAttemptAntigravityCreditsFallback")
+ if m == nil || lastErr == nil {
+ return false
+ }
+ if len(providers) > 0 {
+ hasAntigravity := false
+ for _, p := range providers {
+ if strings.EqualFold(strings.TrimSpace(p), "antigravity") {
+ hasAntigravity = true
+ break
+ }
+ }
+ if !hasAntigravity {
+ return false
+ }
+ }
+ cfg, _ := m.runtimeConfig.Load().(*internalconfig.Config)
+ if cfg == nil || !cfg.QuotaExceeded.AntigravityCredits {
+ return false
+ }
+ switch status {
+ case http.StatusTooManyRequests, http.StatusServiceUnavailable:
+ return true
+ case 0:
+ var authErr *Error
+ if errors.As(lastErr, &authErr) && authErr != nil {
+ return authErr.Code == "auth_not_found" || authErr.Code == "auth_unavailable" || authErr.Code == "model_cooldown"
+ }
+ var cooldownErr *modelCooldownError
+ if errors.As(lastErr, &cooldownErr) {
+ return true
+ }
+ return false
+ default:
+ return false
+ }
+}
+
+func (m *Manager) tryAntigravityCreditsExecute(ctx context.Context, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, bool) {
+ routeModel := req.Model
+ candidates := m.findAllAntigravityCreditsCandidateAuths(routeModel, opts)
+ for _, c := range candidates {
+ if ctx.Err() != nil {
+ return cliproxyexecutor.Response{}, false
+ }
+ creditsCtx := WithAntigravityCredits(ctx)
+ if rt := m.roundTripperFor(c.auth); rt != nil {
+ creditsCtx = context.WithValue(creditsCtx, roundTripperContextKey{}, rt)
+ creditsCtx = context.WithValue(creditsCtx, "cliproxy.roundtripper", rt)
+ }
+ creditsOpts := ensureRequestedModelMetadata(opts, routeModel)
+ creditsCtx = contextWithRequestedModelAlias(creditsCtx, creditsOpts, routeModel)
+ publishSelectedAuthMetadata(creditsOpts.Metadata, c.auth.ID)
+ models := m.executionModelCandidates(c.auth, routeModel)
+ if len(models) == 0 {
+ continue
+ }
+ for _, upstreamModel := range models {
+ resultModel := m.stateModelForExecution(c.auth, routeModel, upstreamModel, len(models) > 1)
+ execReq := req
+ execReq.Model = upstreamModel
+ resp, errExec := c.executor.Execute(creditsCtx, c.auth, execReq, creditsOpts)
+ result := Result{AuthID: c.auth.ID, Provider: c.provider, Model: resultModel, Success: errExec == nil}
+ if errExec != nil {
+ result.Error = &Error{Message: errExec.Error()}
+ if se, ok := errors.AsType[cliproxyexecutor.StatusError](errExec); ok && se != nil {
+ result.Error.HTTPStatus = se.StatusCode()
+ }
+ if ra := retryAfterFromError(errExec); ra != nil {
+ result.RetryAfter = ra
+ }
+ m.MarkResult(creditsCtx, result)
+ continue
+ }
+ m.MarkResult(creditsCtx, result)
+ return resp, true
+ }
+ }
+ return cliproxyexecutor.Response{}, false
+}
+
+func (m *Manager) tryAntigravityCreditsExecuteStream(ctx context.Context, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (*cliproxyexecutor.StreamResult, bool) {
+ routeModel := req.Model
+ candidates := m.findAllAntigravityCreditsCandidateAuths(routeModel, opts)
+ for _, c := range candidates {
+ if ctx.Err() != nil {
+ return nil, false
+ }
+ creditsCtx := WithAntigravityCredits(ctx)
+ if rt := m.roundTripperFor(c.auth); rt != nil {
+ creditsCtx = context.WithValue(creditsCtx, roundTripperContextKey{}, rt)
+ creditsCtx = context.WithValue(creditsCtx, "cliproxy.roundtripper", rt)
+ }
+ creditsOpts := ensureRequestedModelMetadata(opts, routeModel)
+ publishSelectedAuthMetadata(creditsOpts.Metadata, c.auth.ID)
+ models := m.executionModelCandidates(c.auth, routeModel)
+ if len(models) == 0 {
+ continue
+ }
+ result, errStream := m.executeStreamWithModelPool(creditsCtx, c.executor, c.auth, c.provider, req, creditsOpts, routeModel, models, len(models) > 1)
+ if errStream != nil {
+ continue
+ }
+ return result, true
+ }
+ return nil, false
+}
+
func (m *Manager) persist(ctx context.Context, auth *Auth) error {
if m.store == nil || auth == nil {
return nil
@@ -2965,7 +3677,7 @@ func (m *Manager) queueRefreshReschedule(authID string) {
}
func (m *Manager) shouldRefresh(a *Auth, now time.Time) bool {
- if a == nil || a.Disabled {
+ if a == nil {
return false
}
if !a.NextRefreshAfter.IsZero() && now.Before(a.NextRefreshAfter) {
@@ -3172,7 +3884,7 @@ func lookupMetadataTime(meta map[string]any, keys ...string) (time.Time, bool) {
func (m *Manager) markRefreshPending(id string, now time.Time) bool {
m.mu.Lock()
auth, ok := m.auths[id]
- if !ok || auth == nil || auth.Disabled {
+ if !ok || auth == nil {
m.mu.Unlock()
return false
}
@@ -3195,14 +3907,15 @@ func (m *Manager) refreshAuth(ctx context.Context, id string) {
m.mu.RLock()
auth := m.auths[id]
var exec ProviderExecutor
+ var cloned *Auth
if auth != nil {
exec = m.executors[auth.Provider]
+ cloned = auth.Clone()
}
m.mu.RUnlock()
if auth == nil || exec == nil {
return
}
- 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)
@@ -3240,6 +3953,9 @@ func (m *Manager) refreshAuth(ctx context.Context, id string) {
updated.NextRefreshAfter = time.Time{}
updated.LastError = nil
updated.UpdatedAt = now
+ if m.shouldRefresh(updated, now) {
+ updated.NextRefreshAfter = now.Add(refreshIneffectiveBackoff)
+ }
_, _ = m.Update(ctx, updated)
}
diff --git a/sdk/cliproxy/auth/conductor_credits_candidates_test.go b/sdk/cliproxy/auth/conductor_credits_candidates_test.go
new file mode 100644
index 0000000000..f9487b0b9b
--- /dev/null
+++ b/sdk/cliproxy/auth/conductor_credits_candidates_test.go
@@ -0,0 +1,61 @@
+package auth
+
+import (
+ "testing"
+ "time"
+
+ cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
+)
+
+func TestFindAllAntigravityCreditsCandidateAuths_PrefersKnownCreditsThenUnknown(t *testing.T) {
+ m := &Manager{
+ auths: map[string]*Auth{
+ "zz-credits": {ID: "zz-credits", Provider: "antigravity"},
+ "aa-unknown": {ID: "aa-unknown", Provider: "antigravity"},
+ "mm-no": {ID: "mm-no", Provider: "antigravity"},
+ },
+ executors: map[string]ProviderExecutor{
+ "antigravity": schedulerTestExecutor{},
+ },
+ }
+
+ SetAntigravityCreditsHint("zz-credits", AntigravityCreditsHint{
+ Known: true,
+ Available: true,
+ UpdatedAt: time.Now(),
+ })
+ SetAntigravityCreditsHint("mm-no", AntigravityCreditsHint{
+ Known: true,
+ Available: false,
+ UpdatedAt: time.Now(),
+ })
+
+ opts := cliproxyexecutor.Options{}
+
+ candidates := m.findAllAntigravityCreditsCandidateAuths("claude-sonnet-4-6", opts)
+ if len(candidates) != 2 {
+ t.Fatalf("candidates len = %d, want 2", len(candidates))
+ }
+ if candidates[0].auth.ID != "zz-credits" {
+ t.Fatalf("candidates[0].auth.ID = %q, want %q", candidates[0].auth.ID, "zz-credits")
+ }
+ if candidates[1].auth.ID != "aa-unknown" {
+ t.Fatalf("candidates[1].auth.ID = %q, want %q", candidates[1].auth.ID, "aa-unknown")
+ }
+
+ nonClaude := m.findAllAntigravityCreditsCandidateAuths("gemini-3-flash", opts)
+ if len(nonClaude) != 0 {
+ t.Fatalf("nonClaude len = %d, want 0", len(nonClaude))
+ }
+
+ pinnedOpts := cliproxyexecutor.Options{
+ Metadata: map[string]any{cliproxyexecutor.PinnedAuthMetadataKey: "aa-unknown"},
+ }
+ pinned := m.findAllAntigravityCreditsCandidateAuths("claude-sonnet-4-6", pinnedOpts)
+ if len(pinned) != 1 {
+ t.Fatalf("pinned len = %d, want 1", len(pinned))
+ }
+ if pinned[0].auth.ID != "aa-unknown" {
+ t.Fatalf("pinned[0].auth.ID = %q, want %q", pinned[0].auth.ID, "aa-unknown")
+ }
+}
diff --git a/sdk/cliproxy/auth/conductor_executor_replace_test.go b/sdk/cliproxy/auth/conductor_executor_replace_test.go
index 2ee91a87c1..99ecf466a6 100644
--- a/sdk/cliproxy/auth/conductor_executor_replace_test.go
+++ b/sdk/cliproxy/auth/conductor_executor_replace_test.go
@@ -6,7 +6,7 @@ import (
"sync"
"testing"
- cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
+ cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
)
type replaceAwareExecutor struct {
diff --git a/sdk/cliproxy/auth/conductor_oauth_alias_suspension_test.go b/sdk/cliproxy/auth/conductor_oauth_alias_suspension_test.go
index 8bc779e53d..ba8371dc61 100644
--- a/sdk/cliproxy/auth/conductor_oauth_alias_suspension_test.go
+++ b/sdk/cliproxy/auth/conductor_oauth_alias_suspension_test.go
@@ -7,23 +7,26 @@ import (
"testing"
"time"
- internalconfig "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
- cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
+ internalconfig "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
+ cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
+ coreusage "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/usage"
)
type aliasRoutingExecutor struct {
id string
- mu sync.Mutex
- executeModels []string
+ mu sync.Mutex
+ executeModels []string
+ executeAliases []string
}
func (e *aliasRoutingExecutor) Identifier() string { return e.id }
-func (e *aliasRoutingExecutor) Execute(_ context.Context, _ *Auth, req cliproxyexecutor.Request, _ cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
+func (e *aliasRoutingExecutor) Execute(ctx context.Context, _ *Auth, req cliproxyexecutor.Request, _ cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
e.mu.Lock()
e.executeModels = append(e.executeModels, req.Model)
+ e.executeAliases = append(e.executeAliases, coreusage.RequestedModelAliasFromContext(ctx))
e.mu.Unlock()
return cliproxyexecutor.Response{Payload: []byte(req.Model)}, nil
}
@@ -52,6 +55,14 @@ func (e *aliasRoutingExecutor) ExecuteModels() []string {
return out
}
+func (e *aliasRoutingExecutor) ExecuteAliases() []string {
+ e.mu.Lock()
+ defer e.mu.Unlock()
+ out := make([]string, len(e.executeAliases))
+ copy(out, e.executeAliases)
+ return out
+}
+
func TestManagerExecute_OAuthAliasBypassesBlockedRouteModel(t *testing.T) {
const (
provider = "antigravity"
@@ -108,4 +119,12 @@ func TestManagerExecute_OAuthAliasBypassesBlockedRouteModel(t *testing.T) {
if gotModels[0] != targetModel {
t.Fatalf("execute model = %q, want %q", gotModels[0], targetModel)
}
+
+ gotAliases := executor.ExecuteAliases()
+ if len(gotAliases) != 1 {
+ t.Fatalf("execute aliases len = %d, want 1", len(gotAliases))
+ }
+ if gotAliases[0] != routeModel {
+ t.Fatalf("execute alias = %q, want %q", gotAliases[0], routeModel)
+ }
}
diff --git a/sdk/cliproxy/auth/conductor_overrides_test.go b/sdk/cliproxy/auth/conductor_overrides_test.go
index f74621bec7..017602e362 100644
--- a/sdk/cliproxy/auth/conductor_overrides_test.go
+++ b/sdk/cliproxy/auth/conductor_overrides_test.go
@@ -8,9 +8,9 @@ import (
"time"
"github.com/google/uuid"
- internalconfig "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
- cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
+ internalconfig "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
+ cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
)
const requestScopedNotFoundMessage = "Item with id 'rs_0b5f3eb6f51f175c0169ca74e4a85881998539920821603a74' not found. Items are not persisted when `store` is set to false. Try again with `store` set to true, or remove this item from your input."
diff --git a/sdk/cliproxy/auth/conductor_recent_requests_test.go b/sdk/cliproxy/auth/conductor_recent_requests_test.go
new file mode 100644
index 0000000000..d2003b7ccb
--- /dev/null
+++ b/sdk/cliproxy/auth/conductor_recent_requests_test.go
@@ -0,0 +1,95 @@
+package auth
+
+import (
+ "context"
+ "testing"
+ "time"
+)
+
+func TestManagerMarkResultRecordsRecentRequests(t *testing.T) {
+ mgr := NewManager(nil, nil, nil)
+ auth := &Auth{
+ ID: "auth-1",
+ Provider: "antigravity",
+ Attributes: map[string]string{
+ "runtime_only": "true",
+ },
+ Metadata: map[string]any{
+ "type": "antigravity",
+ },
+ }
+
+ if _, err := mgr.Register(WithSkipPersist(context.Background()), auth); err != nil {
+ t.Fatalf("Register returned error: %v", err)
+ }
+
+ mgr.MarkResult(context.Background(), Result{AuthID: "auth-1", Provider: "antigravity", Model: "gpt-5", Success: true})
+ mgr.MarkResult(context.Background(), Result{AuthID: "auth-1", Provider: "antigravity", Model: "gpt-5", Success: false})
+
+ gotAuth, ok := mgr.GetByID("auth-1")
+ if !ok || gotAuth == nil {
+ t.Fatalf("GetByID returned ok=%v auth=%v", ok, gotAuth)
+ }
+
+ if gotAuth.Success != 1 || gotAuth.Failed != 1 {
+ t.Fatalf("auth totals = success=%d failed=%d, want 1/1", gotAuth.Success, gotAuth.Failed)
+ }
+
+ snapshot := gotAuth.RecentRequestsSnapshot(time.Now())
+ var successTotal int64
+ var failedTotal int64
+ for _, bucket := range snapshot {
+ successTotal += bucket.Success
+ failedTotal += bucket.Failed
+ }
+ if successTotal != 1 || failedTotal != 1 {
+ t.Fatalf("totals = success=%d failed=%d, want 1/1", successTotal, failedTotal)
+ }
+}
+
+func TestManagerUpdatePreservesRecentRequestsAndTotals(t *testing.T) {
+ mgr := NewManager(nil, nil, nil)
+ auth := &Auth{
+ ID: "auth-1",
+ Provider: "antigravity",
+ Metadata: map[string]any{
+ "type": "antigravity",
+ },
+ }
+ if _, err := mgr.Register(WithSkipPersist(context.Background()), auth); err != nil {
+ t.Fatalf("Register returned error: %v", err)
+ }
+
+ mgr.MarkResult(context.Background(), Result{AuthID: "auth-1", Provider: "antigravity", Model: "gpt-5", Success: true})
+
+ updated := &Auth{
+ ID: "auth-1",
+ Provider: "antigravity",
+ Metadata: map[string]any{
+ "type": "antigravity",
+ "note": "updated",
+ },
+ }
+ if _, err := mgr.Update(WithSkipPersist(context.Background()), updated); err != nil {
+ t.Fatalf("Update returned error: %v", err)
+ }
+
+ gotAuth, ok := mgr.GetByID("auth-1")
+ if !ok || gotAuth == nil {
+ t.Fatalf("GetByID returned ok=%v auth=%v", ok, gotAuth)
+ }
+ if gotAuth.Success != 1 || gotAuth.Failed != 0 {
+ t.Fatalf("auth totals = success=%d failed=%d, want 1/0", gotAuth.Success, gotAuth.Failed)
+ }
+
+ snapshot := gotAuth.RecentRequestsSnapshot(time.Now())
+ var successTotal int64
+ var failedTotal int64
+ for _, bucket := range snapshot {
+ successTotal += bucket.Success
+ failedTotal += bucket.Failed
+ }
+ if successTotal != 1 || failedTotal != 0 {
+ t.Fatalf("bucket totals = success=%d failed=%d, want 1/0", successTotal, failedTotal)
+ }
+}
diff --git a/sdk/cliproxy/auth/conductor_scheduler_refresh_test.go b/sdk/cliproxy/auth/conductor_scheduler_refresh_test.go
index 5c6eff7805..508cdfd137 100644
--- a/sdk/cliproxy/auth/conductor_scheduler_refresh_test.go
+++ b/sdk/cliproxy/auth/conductor_scheduler_refresh_test.go
@@ -6,8 +6,8 @@ import (
"net/http"
"testing"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
- cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
+ cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
)
type schedulerProviderTestExecutor struct {
diff --git a/sdk/cliproxy/auth/home_websocket_reuse_test.go b/sdk/cliproxy/auth/home_websocket_reuse_test.go
new file mode 100644
index 0000000000..28d4800429
--- /dev/null
+++ b/sdk/cliproxy/auth/home_websocket_reuse_test.go
@@ -0,0 +1,270 @@
+package auth
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "testing"
+
+ internalconfig "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
+)
+
+func TestPickNextViaHomeReusesPinnedWebsocketAuthWithoutHomeDispatch(t *testing.T) {
+ manager := NewManager(nil, nil, nil)
+ manager.SetConfig(&internalconfig.Config{Home: internalconfig.HomeConfig{Enabled: true}})
+ manager.RegisterExecutor(schedulerTestExecutor{})
+
+ auth := &Auth{
+ ID: "home-auth-1",
+ Provider: "test",
+ Status: StatusActive,
+ Attributes: map[string]string{
+ "websockets": "true",
+ homeUpstreamModelAttributeKey: "upstream-model",
+ },
+ Metadata: map[string]any{"email": "home@example.com"},
+ }
+ auth.EnsureIndex()
+ manager.rememberHomeRuntimeAuth("session-1", auth)
+ cachedAuth, ok := manager.GetExecutionSessionAuthByID("session-1", "home-auth-1")
+ if !ok || cachedAuth == nil || !authWebsocketsEnabled(cachedAuth) {
+ t.Fatalf("GetExecutionSessionAuthByID() did not expose remembered websocket home auth: auth=%#v ok=%v", cachedAuth, ok)
+ }
+
+ ctx := cliproxyexecutor.WithDownstreamWebsocket(context.Background())
+ opts := cliproxyexecutor.Options{
+ Metadata: map[string]any{
+ cliproxyexecutor.ExecutionSessionMetadataKey: "session-1",
+ cliproxyexecutor.PinnedAuthMetadataKey: "home-auth-1",
+ },
+ Headers: http.Header{"Authorization": {"Bearer client-key"}},
+ }
+
+ got, executor, provider, errPick := manager.pickNextViaHome(ctx, "gpt-5.4", opts, nil)
+ if errPick != nil {
+ t.Fatalf("pickNextViaHome() error = %v", errPick)
+ }
+ if got == nil || got.ID != "home-auth-1" {
+ t.Fatalf("pickNextViaHome() auth = %#v, want home-auth-1", got)
+ }
+ if executor == nil {
+ t.Fatal("pickNextViaHome() executor is nil")
+ }
+ if provider != "test" {
+ t.Fatalf("pickNextViaHome() provider = %q, want test", provider)
+ }
+}
+
+func TestPickNextViaHomeKeepsSameAuthIDPayloadSessionScoped(t *testing.T) {
+ manager := NewManager(nil, nil, nil)
+ manager.SetConfig(&internalconfig.Config{Home: internalconfig.HomeConfig{Enabled: true}})
+ manager.RegisterExecutor(schedulerTestExecutor{})
+
+ manager.rememberHomeRuntimeAuth("session-1", &Auth{
+ ID: "home-auth-1",
+ Provider: "test",
+ Status: StatusActive,
+ Attributes: map[string]string{
+ "websockets": "true",
+ homeUpstreamModelAttributeKey: "upstream-model-a",
+ },
+ })
+ manager.rememberHomeRuntimeAuth("session-2", &Auth{
+ ID: "home-auth-1",
+ Provider: "test",
+ Status: StatusActive,
+ Attributes: map[string]string{
+ "websockets": "true",
+ homeUpstreamModelAttributeKey: "upstream-model-b",
+ },
+ })
+
+ ctx := cliproxyexecutor.WithDownstreamWebsocket(context.Background())
+ optsSession1 := cliproxyexecutor.Options{
+ Metadata: map[string]any{
+ cliproxyexecutor.ExecutionSessionMetadataKey: "session-1",
+ cliproxyexecutor.PinnedAuthMetadataKey: "home-auth-1",
+ },
+ }
+ optsSession2 := cliproxyexecutor.Options{
+ Metadata: map[string]any{
+ cliproxyexecutor.ExecutionSessionMetadataKey: "session-2",
+ cliproxyexecutor.PinnedAuthMetadataKey: "home-auth-1",
+ },
+ }
+
+ gotSession1, _, _, errSession1 := manager.pickNextViaHome(ctx, "gpt-5.4", optsSession1, nil)
+ if errSession1 != nil {
+ t.Fatalf("pickNextViaHome(session-1) error = %v", errSession1)
+ }
+ if got := gotSession1.Attributes[homeUpstreamModelAttributeKey]; got != "upstream-model-a" {
+ t.Fatalf("pickNextViaHome(session-1) upstream model = %q, want upstream-model-a", got)
+ }
+
+ gotSession2, _, _, errSession2 := manager.pickNextViaHome(ctx, "gpt-5.4", optsSession2, nil)
+ if errSession2 != nil {
+ t.Fatalf("pickNextViaHome(session-2) error = %v", errSession2)
+ }
+ if got := gotSession2.Attributes[homeUpstreamModelAttributeKey]; got != "upstream-model-b" {
+ t.Fatalf("pickNextViaHome(session-2) upstream model = %q, want upstream-model-b", got)
+ }
+}
+
+func TestPickNextViaHomeDoesNotReuseTriedPinnedWebsocketAuth(t *testing.T) {
+ manager := NewManager(nil, nil, nil)
+ manager.SetConfig(&internalconfig.Config{Home: internalconfig.HomeConfig{Enabled: true}})
+ manager.RegisterExecutor(schedulerTestExecutor{})
+
+ auth := &Auth{
+ ID: "home-auth-1",
+ Provider: "test",
+ Status: StatusActive,
+ Attributes: map[string]string{
+ "websockets": "true",
+ },
+ }
+ manager.rememberHomeRuntimeAuth("session-1", auth)
+
+ ctx := cliproxyexecutor.WithDownstreamWebsocket(context.Background())
+ opts := cliproxyexecutor.Options{
+ Metadata: map[string]any{
+ cliproxyexecutor.ExecutionSessionMetadataKey: "session-1",
+ cliproxyexecutor.PinnedAuthMetadataKey: "home-auth-1",
+ },
+ }
+ tried := map[string]struct{}{"home-auth-1": {}}
+
+ got, executor, provider, errPick := manager.pickNextViaHome(ctx, "gpt-5.4", opts, tried)
+ if errPick == nil {
+ t.Fatal("pickNextViaHome() error is nil, want home unavailable error")
+ }
+ var authErr *Error
+ if !errors.As(errPick, &authErr) || authErr.Code != "home_unavailable" {
+ t.Fatalf("pickNextViaHome() error = %v, want home_unavailable", errPick)
+ }
+ if got != nil || executor != nil || provider != "" {
+ t.Fatalf("pickNextViaHome() reused tried auth: auth=%#v executor=%#v provider=%q", got, executor, provider)
+ }
+}
+
+func TestPickNextViaHomeDoesNotReusePinnedWebsocketAuthAfterFirstHomeAttempt(t *testing.T) {
+ manager := NewManager(nil, nil, nil)
+ manager.SetConfig(&internalconfig.Config{Home: internalconfig.HomeConfig{Enabled: true}})
+ manager.RegisterExecutor(schedulerTestExecutor{})
+
+ auth := &Auth{
+ ID: "home-auth-1",
+ Provider: "test",
+ Status: StatusActive,
+ Attributes: map[string]string{
+ "websockets": "true",
+ },
+ }
+ manager.rememberHomeRuntimeAuth("session-1", auth)
+
+ ctx := cliproxyexecutor.WithDownstreamWebsocket(context.Background())
+ opts := withHomeAuthCount(cliproxyexecutor.Options{
+ Metadata: map[string]any{
+ cliproxyexecutor.ExecutionSessionMetadataKey: "session-1",
+ cliproxyexecutor.PinnedAuthMetadataKey: "home-auth-1",
+ },
+ }, 2)
+
+ got, executor, provider, errPick := manager.pickNextViaHome(ctx, "gpt-5.4", opts, nil)
+ if errPick == nil {
+ t.Fatal("pickNextViaHome() error is nil, want home unavailable error")
+ }
+ var authErr *Error
+ if !errors.As(errPick, &authErr) || authErr.Code != "home_unavailable" {
+ t.Fatalf("pickNextViaHome() error = %v, want home_unavailable", errPick)
+ }
+ if got != nil || executor != nil || provider != "" {
+ t.Fatalf("pickNextViaHome() reused auth after first home attempt: auth=%#v executor=%#v provider=%q", got, executor, provider)
+ }
+}
+
+func TestPickNextViaHomeDoesNotReusePinnedNonWebsocketAuth(t *testing.T) {
+ manager := NewManager(nil, nil, nil)
+ manager.SetConfig(&internalconfig.Config{Home: internalconfig.HomeConfig{Enabled: true}})
+ manager.RegisterExecutor(schedulerTestExecutor{})
+
+ manager.mu.Lock()
+ manager.homeRuntimeAuths["session-1"] = map[string]*Auth{
+ "home-auth-1": &Auth{
+ ID: "home-auth-1",
+ Provider: "test",
+ Status: StatusActive,
+ },
+ }
+ manager.mu.Unlock()
+
+ ctx := cliproxyexecutor.WithDownstreamWebsocket(context.Background())
+ opts := cliproxyexecutor.Options{
+ Metadata: map[string]any{
+ cliproxyexecutor.ExecutionSessionMetadataKey: "session-1",
+ cliproxyexecutor.PinnedAuthMetadataKey: "home-auth-1",
+ },
+ Headers: http.Header{"Authorization": {"Bearer client-key"}},
+ }
+
+ got, executor, provider, errPick := manager.pickNextViaHome(ctx, "gpt-5.4", opts, nil)
+ if errPick == nil {
+ t.Fatal("pickNextViaHome() error is nil, want home unavailable error")
+ }
+ var authErr *Error
+ if !errors.As(errPick, &authErr) || authErr.Code != "home_unavailable" {
+ t.Fatalf("pickNextViaHome() error = %v, want home_unavailable", errPick)
+ }
+ if got != nil || executor != nil || provider != "" {
+ t.Fatalf("pickNextViaHome() reused non-websocket auth: auth=%#v executor=%#v provider=%q", got, executor, provider)
+ }
+}
+
+func TestHomeRuntimeAuthsClearWhenHomeDisabled(t *testing.T) {
+ manager := NewManager(nil, nil, nil)
+ manager.SetConfig(&internalconfig.Config{Home: internalconfig.HomeConfig{Enabled: true}})
+ manager.rememberHomeRuntimeAuth("session-1", &Auth{
+ ID: "home-auth-1",
+ Provider: "test",
+ Attributes: map[string]string{
+ "websockets": "true",
+ },
+ })
+
+ if _, ok := manager.GetExecutionSessionAuthByID("session-1", "home-auth-1"); !ok {
+ t.Fatal("expected remembered home auth before disabling home")
+ }
+
+ manager.SetConfig(&internalconfig.Config{})
+ if _, ok := manager.GetExecutionSessionAuthByID("session-1", "home-auth-1"); ok {
+ t.Fatal("remembered home auth was not cleared when home was disabled")
+ }
+}
+
+func TestCloseExecutionSessionClearsHomeRuntimeAuthForSession(t *testing.T) {
+ manager := NewManager(nil, nil, nil)
+ auth := &Auth{
+ ID: "home-auth-1",
+ Provider: "test",
+ Attributes: map[string]string{
+ "websockets": "true",
+ },
+ }
+
+ manager.rememberHomeRuntimeAuth("session-1", auth)
+ manager.rememberHomeRuntimeAuth("session-2", auth)
+
+ manager.CloseExecutionSession("session-1")
+ if _, ok := manager.GetExecutionSessionAuthByID("session-1", "home-auth-1"); ok {
+ t.Fatal("home auth for closed session was not cleared")
+ }
+ if _, ok := manager.GetExecutionSessionAuthByID("session-2", "home-auth-1"); !ok {
+ t.Fatal("home auth for another session was cleared")
+ }
+
+ manager.CloseExecutionSession("session-2")
+ if _, ok := manager.GetExecutionSessionAuthByID("session-2", "home-auth-1"); ok {
+ t.Fatal("home auth was not cleared when its last session closed")
+ }
+}
diff --git a/sdk/cliproxy/auth/oauth_model_alias.go b/sdk/cliproxy/auth/oauth_model_alias.go
index 46c82a9c53..7e6740d6bb 100644
--- a/sdk/cliproxy/auth/oauth_model_alias.go
+++ b/sdk/cliproxy/auth/oauth_model_alias.go
@@ -3,8 +3,8 @@ package auth
import (
"strings"
- internalconfig "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
+ internalconfig "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
)
type modelAliasEntry interface {
diff --git a/sdk/cliproxy/auth/oauth_model_alias_test.go b/sdk/cliproxy/auth/oauth_model_alias_test.go
index 73ddbe675d..521e158e55 100644
--- a/sdk/cliproxy/auth/oauth_model_alias_test.go
+++ b/sdk/cliproxy/auth/oauth_model_alias_test.go
@@ -3,7 +3,7 @@ package auth
import (
"testing"
- internalconfig "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
+ internalconfig "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
)
func TestResolveOAuthUpstreamModel_SuffixPreservation(t *testing.T) {
diff --git a/sdk/cliproxy/auth/openai_compat_pool_test.go b/sdk/cliproxy/auth/openai_compat_pool_test.go
index ff2c4dd040..f052c486f4 100644
--- a/sdk/cliproxy/auth/openai_compat_pool_test.go
+++ b/sdk/cliproxy/auth/openai_compat_pool_test.go
@@ -7,9 +7,9 @@ import (
"sync"
"testing"
- internalconfig "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
- cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
+ internalconfig "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
+ cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
)
type openAICompatPoolExecutor struct {
diff --git a/sdk/cliproxy/auth/scheduler.go b/sdk/cliproxy/auth/scheduler.go
index b5a3928286..9947f59c63 100644
--- a/sdk/cliproxy/auth/scheduler.go
+++ b/sdk/cliproxy/auth/scheduler.go
@@ -7,8 +7,8 @@ import (
"sync"
"time"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
- cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
+ cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
)
// schedulerStrategy identifies which built-in routing semantics the scheduler should apply.
diff --git a/sdk/cliproxy/auth/scheduler_benchmark_test.go b/sdk/cliproxy/auth/scheduler_benchmark_test.go
index 050a7cbd1e..4d160276f2 100644
--- a/sdk/cliproxy/auth/scheduler_benchmark_test.go
+++ b/sdk/cliproxy/auth/scheduler_benchmark_test.go
@@ -6,8 +6,8 @@ import (
"net/http"
"testing"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
- cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
+ cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
)
type schedulerBenchmarkExecutor struct {
diff --git a/sdk/cliproxy/auth/scheduler_test.go b/sdk/cliproxy/auth/scheduler_test.go
index d744ec32d0..864fa938e9 100644
--- a/sdk/cliproxy/auth/scheduler_test.go
+++ b/sdk/cliproxy/auth/scheduler_test.go
@@ -6,8 +6,8 @@ import (
"testing"
"time"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
- cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
+ cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
)
type schedulerTestExecutor struct{}
@@ -333,6 +333,39 @@ func TestManager_PickNextMixed_UsesWeightedProviderRotationBeforeCredentialRotat
}
}
+func TestManager_PickNextMixed_DisallowFreeAuthSkipsCodexFreePlan(t *testing.T) {
+ t.Parallel()
+
+ model := "gpt-5.4-mini"
+ registerSchedulerModels(t, "codex", model, "codex-a-free", "codex-b-plus")
+
+ manager := NewManager(nil, &RoundRobinSelector{}, nil)
+ manager.executors["codex"] = schedulerTestExecutor{}
+ if _, errRegister := manager.Register(context.Background(), &Auth{ID: "codex-a-free", Provider: "codex", Attributes: map[string]string{"plan_type": "free"}}); errRegister != nil {
+ t.Fatalf("Register(codex-a-free) error = %v", errRegister)
+ }
+ if _, errRegister := manager.Register(context.Background(), &Auth{ID: "codex-b-plus", Provider: "codex", Attributes: map[string]string{"plan_type": "plus"}}); errRegister != nil {
+ t.Fatalf("Register(codex-b-plus) error = %v", errRegister)
+ }
+
+ opts := cliproxyexecutor.Options{
+ Metadata: map[string]any{cliproxyexecutor.DisallowFreeAuthMetadataKey: true},
+ }
+ got, _, provider, errPick := manager.pickNextMixed(context.Background(), []string{"codex"}, model, opts, map[string]struct{}{})
+ if errPick != nil {
+ t.Fatalf("pickNextMixed() error = %v", errPick)
+ }
+ if got == nil {
+ t.Fatalf("pickNextMixed() auth = nil")
+ }
+ if provider != "codex" {
+ t.Fatalf("pickNextMixed() provider = %q, want %q", provider, "codex")
+ }
+ if got.ID != "codex-b-plus" {
+ t.Fatalf("pickNextMixed() auth.ID = %q, want %q", got.ID, "codex-b-plus")
+ }
+}
+
func TestManagerCustomSelector_FallsBackToLegacyPath(t *testing.T) {
t.Parallel()
diff --git a/sdk/cliproxy/auth/selector.go b/sdk/cliproxy/auth/selector.go
index 51275a3115..5e23c46f55 100644
--- a/sdk/cliproxy/auth/selector.go
+++ b/sdk/cliproxy/auth/selector.go
@@ -18,9 +18,9 @@ import (
log "github.com/sirupsen/logrus"
"github.com/tidwall/gjson"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/logging"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
- cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/logging"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
+ cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
)
// RoundRobinSelector provides a simple provider scoped round-robin selection strategy.
@@ -469,11 +469,14 @@ func NewSessionAffinitySelectorWithConfig(cfg SessionAffinityConfig) *SessionAff
// Pick selects an auth with session affinity when possible.
// Priority for session ID extraction:
-// 1. metadata.user_id (Claude Code format) - highest priority
+// 1. metadata.user_id (Claude Code format with _session_{uuid}) - highest priority
// 2. X-Session-ID header
-// 3. metadata.user_id (non-Claude Code format)
-// 4. conversation_id field
-// 5. Hash-based fallback from messages
+// 3. Session_id header (Codex)
+// 4. X-Amp-Thread-Id header (Amp CLI thread ID)
+// 5. X-Client-Request-Id header (PI)
+// 6. metadata.user_id (non-Claude Code format)
+// 7. conversation_id field in request body
+// 8. Stable hash from first few messages content (fallback)
//
// Note: The cache key includes provider, session ID, and model to handle cases where
// a session uses multiple models (e.g., gemini-2.5-pro and gemini-3-flash-preview)
@@ -570,9 +573,12 @@ func (s *SessionAffinitySelector) InvalidateAuth(authID string) {
// Priority order:
// 1. metadata.user_id (Claude Code format with _session_{uuid}) - highest priority for Claude Code clients
// 2. X-Session-ID header
-// 3. metadata.user_id (non-Claude Code format)
-// 4. conversation_id field in request body
-// 5. Stable hash from first few messages content (fallback)
+// 3. Session_id header (Codex)
+// 4. X-Amp-Thread-Id header (Amp CLI thread ID)
+// 5. X-Client-Request-Id header (PI)
+// 6. metadata.user_id (non-Claude Code format)
+// 7. conversation_id field in request body
+// 8. Stable hash from first few messages content (fallback)
func ExtractSessionID(headers http.Header, payload []byte, metadata map[string]any) string {
primary, _ := extractSessionIDs(headers, payload, metadata)
return primary
@@ -608,22 +614,43 @@ func extractSessionIDs(headers http.Header, payload []byte, metadata map[string]
}
}
+ // 3. Session_id header (Codex)
+ if headers != nil {
+ if sid := headers.Get("Session_id"); sid != "" {
+ return "codex:" + sid, ""
+ }
+ }
+
+ // 4. X-Amp-Thread-Id header (Amp CLI thread ID)
+ if headers != nil {
+ if tid := headers.Get("X-Amp-Thread-Id"); tid != "" {
+ return "amp:" + tid, ""
+ }
+ }
+
+ // 5. X-Client-Request-Id header (PI)
+ if headers != nil {
+ if rid := headers.Get("X-Client-Request-Id"); rid != "" {
+ return "clientreq:" + rid, ""
+ }
+ }
+
if len(payload) == 0 {
return "", ""
}
- // 3. metadata.user_id (non-Claude Code format)
+ // 6. metadata.user_id (non-Claude Code format)
userID := gjson.GetBytes(payload, "metadata.user_id").String()
if userID != "" {
return "user:" + userID, ""
}
- // 4. conversation_id field
+ // 7. conversation_id field
if convID := gjson.GetBytes(payload, "conversation_id").String(); convID != "" {
return "conv:" + convID, ""
}
- // 5. Hash-based fallback from message content
+ // 8. Hash-based fallback from message content
return extractMessageHashIDs(payload)
}
diff --git a/sdk/cliproxy/auth/selector_test.go b/sdk/cliproxy/auth/selector_test.go
index 560d3b9e97..99231bdf78 100644
--- a/sdk/cliproxy/auth/selector_test.go
+++ b/sdk/cliproxy/auth/selector_test.go
@@ -11,7 +11,7 @@ import (
"testing"
"time"
- cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
+ cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
)
func TestFillFirstSelectorPick_Deterministic(t *testing.T) {
@@ -776,6 +776,100 @@ func TestExtractSessionID_Headers(t *testing.T) {
}
}
+func TestExtractSessionID_CodexSessionIDHeader(t *testing.T) {
+ t.Parallel()
+
+ headers := make(http.Header)
+ headers.Set("Session_id", "codex-session-123")
+
+ got := ExtractSessionID(headers, nil, nil)
+ want := "codex:codex-session-123"
+ if got != want {
+ t.Errorf("ExtractSessionID() with Session_id = %q, want %q", got, want)
+ }
+}
+
+func TestExtractSessionID_ClientRequestIDHeader(t *testing.T) {
+ t.Parallel()
+
+ headers := make(http.Header)
+ headers.Set("X-Client-Request-Id", "pi-session-123")
+
+ got := ExtractSessionID(headers, nil, nil)
+ want := "clientreq:pi-session-123"
+ if got != want {
+ t.Errorf("ExtractSessionID() with X-Client-Request-Id = %q, want %q", got, want)
+ }
+}
+
+func TestExtractSessionID_CodexSessionIDPriorityOverClientRequestID(t *testing.T) {
+ t.Parallel()
+
+ headers := make(http.Header)
+ headers.Set("X-Client-Request-Id", "pi-session-123")
+ headers.Set("Session_id", "codex-session-456")
+
+ got := ExtractSessionID(headers, nil, nil)
+ want := "codex:codex-session-456"
+ if got != want {
+ t.Errorf("ExtractSessionID() = %q, want %q (Session_id should take priority over X-Client-Request-Id)", got, want)
+ }
+}
+
+func TestExtractSessionID_AmpThreadId(t *testing.T) {
+ t.Parallel()
+
+ headers := make(http.Header)
+ headers.Set("X-Amp-Thread-Id", "T-7873e6bd-6354-4a9a-be2c-c7702c6e1b64")
+
+ got := ExtractSessionID(headers, nil, nil)
+ want := "amp:T-7873e6bd-6354-4a9a-be2c-c7702c6e1b64"
+ if got != want {
+ t.Errorf("ExtractSessionID() with X-Amp-Thread-Id = %q, want %q", got, want)
+ }
+}
+
+func TestExtractSessionID_AmpThreadIdPriorityOverClientRequestID(t *testing.T) {
+ t.Parallel()
+
+ headers := make(http.Header)
+ headers.Set("X-Amp-Thread-Id", "T-priority-test")
+ headers.Set("X-Client-Request-Id", "pi-session-123")
+
+ got := ExtractSessionID(headers, nil, nil)
+ want := "amp:T-priority-test"
+ if got != want {
+ t.Errorf("ExtractSessionID() = %q, want %q (X-Amp-Thread-Id should take priority over X-Client-Request-Id)", got, want)
+ }
+}
+
+// TestExtractSessionID_AmpThreadIdLowerPriority verifies X-Amp-Thread-Id is lower
+// priority than Claude Code metadata.user_id but higher than conversation_id.
+func TestExtractSessionID_AmpThreadIdPriority(t *testing.T) {
+ t.Parallel()
+
+ // X-Amp-Thread-Id should be used when no Claude Code user_id is present
+ headers := make(http.Header)
+ headers.Set("X-Amp-Thread-Id", "T-priority-test")
+
+ payload := []byte(`{"conversation_id":"conv-12345"}`)
+ got := ExtractSessionID(headers, payload, nil)
+ want := "amp:T-priority-test"
+ if got != want {
+ t.Errorf("ExtractSessionID() = %q, want %q (Amp thread ID should take priority over conversation_id)", got, want)
+ }
+
+ // Claude Code user_id should take priority over X-Amp-Thread-Id
+ headers2 := make(http.Header)
+ headers2.Set("X-Amp-Thread-Id", "T-priority-test")
+ payload2 := []byte(`{"metadata":{"user_id":"user_xxx_account__session_ac980658-63bd-4fb3-97ba-8da64cb1e344"}}`)
+ got2 := ExtractSessionID(headers2, payload2, nil)
+ want2 := "claude:ac980658-63bd-4fb3-97ba-8da64cb1e344"
+ if got2 != want2 {
+ t.Errorf("ExtractSessionID() = %q, want %q (Claude Code should take priority over Amp thread ID)", got2, want2)
+ }
+}
+
// TestExtractSessionID_IdempotencyKey verifies that idempotency_key is intentionally
// ignored for session affinity (it's auto-generated per-request, causing cache misses).
func TestExtractSessionID_IdempotencyKey(t *testing.T) {
diff --git a/sdk/cliproxy/auth/types.go b/sdk/cliproxy/auth/types.go
index f30f4dc011..882c25eabd 100644
--- a/sdk/cliproxy/auth/types.go
+++ b/sdk/cliproxy/auth/types.go
@@ -7,12 +7,13 @@ import (
"encoding/json"
"net/http"
"net/url"
+ "path/filepath"
"strconv"
"strings"
"sync"
"time"
- baseauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth"
+ baseauth "github.com/router-for-me/CLIProxyAPI/v7/internal/auth"
)
// PostAuthHook defines a function that is called after an Auth record is created
@@ -92,7 +93,32 @@ type Auth struct {
// Runtime carries non-serialisable data used during execution (in-memory only).
Runtime any `json:"-"`
- indexAssigned bool `json:"-"`
+ Success int64 `json:"-"`
+ Failed int64 `json:"-"`
+
+ recentRequests recentRequestRing `json:"-"`
+ indexAssigned bool `json:"-"`
+}
+
+const (
+ recentRequestBucketSeconds int64 = 10 * 60
+ recentRequestBucketCount = 20
+)
+
+type recentRequestBucket struct {
+ bucketID int64
+ success int64
+ failed int64
+}
+
+type recentRequestRing struct {
+ buckets [recentRequestBucketCount]recentRequestBucket
+}
+
+type RecentRequestBucket struct {
+ Time string `json:"time"`
+ Success int64 `json:"success"`
+ Failed int64 `json:"failed"`
}
// QuotaState contains limiter tracking data for a credential.
@@ -125,6 +151,70 @@ type ModelState struct {
UpdatedAt time.Time `json:"updated_at"`
}
+func recentRequestBucketID(now time.Time) int64 {
+ if now.IsZero() {
+ return 0
+ }
+ return now.Unix() / recentRequestBucketSeconds
+}
+
+func recentRequestBucketIndex(bucketID int64) int {
+ mod := bucketID % int64(recentRequestBucketCount)
+ if mod < 0 {
+ mod += int64(recentRequestBucketCount)
+ }
+ return int(mod)
+}
+
+func formatRecentRequestBucketLabel(bucketID int64) string {
+ start := time.Unix(bucketID*recentRequestBucketSeconds, 0).In(time.Local)
+ end := start.Add(time.Duration(recentRequestBucketSeconds) * time.Second)
+ return start.Format("15:04") + "-" + end.Format("15:04")
+}
+
+func (a *Auth) recordRecentRequest(now time.Time, success bool) {
+ if a == nil {
+ return
+ }
+ bucketID := recentRequestBucketID(now)
+ idx := recentRequestBucketIndex(bucketID)
+ bucket := &a.recentRequests.buckets[idx]
+ if bucket.bucketID != bucketID {
+ bucket.bucketID = bucketID
+ bucket.success = 0
+ bucket.failed = 0
+ }
+ if success {
+ bucket.success++
+ return
+ }
+ bucket.failed++
+}
+
+func (a *Auth) RecentRequestsSnapshot(now time.Time) []RecentRequestBucket {
+ out := make([]RecentRequestBucket, 0, recentRequestBucketCount)
+ if a == nil {
+ return out
+ }
+
+ currentBucketID := recentRequestBucketID(now)
+ for i := recentRequestBucketCount - 1; i >= 0; i-- {
+ bucketID := currentBucketID - int64(i)
+ idx := recentRequestBucketIndex(bucketID)
+ bucket := a.recentRequests.buckets[idx]
+ entry := RecentRequestBucket{
+ Time: formatRecentRequestBucketLabel(bucketID),
+ }
+ if bucket.bucketID == bucketID {
+ entry.Success = bucket.success
+ entry.Failed = bucket.failed
+ }
+ out = append(out, entry)
+ }
+
+ return out
+}
+
// Clone shallow copies the Auth structure, duplicating maps to avoid accidental mutation.
func (a *Auth) Clone() *Auth {
if a == nil {
@@ -167,45 +257,65 @@ func (a *Auth) indexSeed() string {
return ""
}
- if fileName := strings.TrimSpace(a.FileName); fileName != "" {
- return "file:" + fileName
- }
-
- providerKey := strings.ToLower(strings.TrimSpace(a.Provider))
+ provider := strings.ToLower(strings.TrimSpace(a.Provider))
compatName := ""
baseURL := ""
apiKey := ""
- source := ""
+ filePath := ""
if a.Attributes != nil {
- if value := strings.TrimSpace(a.Attributes["provider_key"]); value != "" {
- providerKey = strings.ToLower(value)
- }
- compatName = strings.ToLower(strings.TrimSpace(a.Attributes["compat_name"]))
+ compatName = strings.TrimSpace(a.Attributes["compat_name"])
baseURL = strings.TrimSpace(a.Attributes["base_url"])
apiKey = strings.TrimSpace(a.Attributes["api_key"])
- source = strings.TrimSpace(a.Attributes["source"])
+ filePath = strings.TrimSpace(a.Attributes["path"])
+ if filePath == "" {
+ filePath = strings.TrimSpace(a.Attributes["source"])
+ }
+ }
+
+ if filePath == "" {
+ filePath = strings.TrimSpace(a.FileName)
+ }
+ if filePath == "" {
+ filePath = strings.TrimSpace(a.ID)
}
- proxyURL := strings.TrimSpace(a.ProxyURL)
- hasCredentialIdentity := compatName != "" || baseURL != "" || proxyURL != "" || apiKey != "" || source != ""
- if providerKey != "" && hasCredentialIdentity {
- parts := []string{"provider=" + providerKey}
- if compatName != "" {
- parts = append(parts, "compat="+compatName)
+ if filePath != "" && strings.HasSuffix(strings.ToLower(filePath), ".json") {
+ abs, errAbs := filepath.Abs(filePath)
+ if errAbs == nil && strings.TrimSpace(abs) != "" {
+ filePath = abs
}
- if baseURL != "" {
- parts = append(parts, "base="+baseURL)
+ filePath = filepath.Clean(filePath)
+
+ authType := ""
+ if a.Metadata != nil {
+ if rawType, ok := a.Metadata["type"].(string); ok {
+ authType = strings.TrimSpace(rawType)
+ }
}
- if proxyURL != "" {
- parts = append(parts, "proxy="+proxyURL)
+ if authType == "" {
+ authType = strings.TrimSpace(provider)
}
- if apiKey != "" {
- parts = append(parts, "api_key="+apiKey)
+ authType = strings.ToLower(strings.TrimSpace(authType))
+ if authType != "" {
+ return authType + ":" + filePath
}
- if source != "" {
- parts = append(parts, "source="+source)
+ }
+
+ apiPrefix := ""
+ if apiKey != "" {
+ switch {
+ case compatName != "" || strings.EqualFold(provider, "openai-compatibility"):
+ apiPrefix = "openai-compatibility"
+ case strings.EqualFold(provider, "gemini"):
+ apiPrefix = "gemini-api-key"
+ case strings.EqualFold(provider, "codex"):
+ apiPrefix = "codex-api-key"
+ case strings.EqualFold(provider, "claude"):
+ apiPrefix = "claude-api-key"
}
- return "config:" + strings.Join(parts, "\x00")
+ }
+ if apiPrefix != "" {
+ return apiPrefix + ":" + strings.TrimSpace(baseURL) + "+" + strings.TrimSpace(apiKey)
}
if id := strings.TrimSpace(a.ID); id != "" {
@@ -266,19 +376,28 @@ func (a *Auth) ProxyInfo() string {
return "via proxy"
}
-// DisableCoolingOverride returns the auth-file scoped disable_cooling override when present.
+// DisableCoolingOverride returns the auth scoped disable_cooling override when present.
// The value is read from metadata key "disable_cooling" (or legacy "disable-cooling").
+//
+// NOTE: This override is intentionally "true-only". When the metadata value is false, it is treated
+// as "not set" so the global disable-cooling flag can still take effect.
func (a *Auth) DisableCoolingOverride() (bool, bool) {
if a == nil || a.Metadata == nil {
return false, false
}
if val, ok := a.Metadata["disable_cooling"]; ok {
if parsed, okParse := parseBoolAny(val); okParse {
+ if !parsed {
+ return false, false
+ }
return parsed, true
}
}
if val, ok := a.Metadata["disable-cooling"]; ok {
if parsed, okParse := parseBoolAny(val); okParse {
+ if !parsed {
+ return false, false
+ }
return parsed, true
}
}
diff --git a/sdk/cliproxy/auth/types_test.go b/sdk/cliproxy/auth/types_test.go
index e7029385a3..f579bfda2e 100644
--- a/sdk/cliproxy/auth/types_test.go
+++ b/sdk/cliproxy/auth/types_test.go
@@ -1,6 +1,12 @@
package auth
-import "testing"
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
func TestToolPrefixDisabled(t *testing.T) {
var a *Auth
@@ -92,7 +98,108 @@ func TestEnsureIndexUsesCredentialIdentity(t *testing.T) {
if geminiIndex == altBaseIndex {
t.Fatalf("same provider/key with different base_url produced duplicate auth_index %q", geminiIndex)
}
- if geminiIndex == duplicateIndex {
- t.Fatalf("duplicate config entries should be separated by source-derived seed, got %q", geminiIndex)
+ if geminiIndex != duplicateIndex {
+ t.Fatalf("same provider/key with different source should share auth_index, got %q vs %q", geminiIndex, duplicateIndex)
+ }
+}
+
+func TestEnsureIndexUsesOAuthTypeAndAbsolutePath(t *testing.T) {
+ t.Parallel()
+
+ wd, errWd := os.Getwd()
+ if errWd != nil {
+ t.Fatalf("os.Getwd returned error: %v", errWd)
+ }
+
+ relPath := "test-oauth.json"
+ absPath := filepath.Join(wd, relPath)
+ expectedSeed := "gemini:" + filepath.Clean(absPath)
+ expectedIndex := stableAuthIndex(expectedSeed)
+
+ a := &Auth{
+ Provider: "gemini-cli",
+ Attributes: map[string]string{
+ "path": relPath,
+ },
+ Metadata: map[string]any{
+ "type": "gemini",
+ },
+ }
+
+ got := a.EnsureIndex()
+ if got == "" {
+ t.Fatal("auth index should not be empty")
+ }
+ if got != expectedIndex {
+ t.Fatalf("auth index = %q, want %q", got, expectedIndex)
+ }
+}
+
+func TestRecentRequestsSnapshotEmptyReturnsTwentyBuckets(t *testing.T) {
+ now := time.Unix(1_700_000_000, 0).In(time.Local)
+ a := &Auth{}
+
+ got := a.RecentRequestsSnapshot(now)
+ if len(got) != recentRequestBucketCount {
+ t.Fatalf("len = %d, want %d", len(got), recentRequestBucketCount)
+ }
+
+ currentBucketID := now.Unix() / recentRequestBucketSeconds
+ baseBucketID := currentBucketID - int64(recentRequestBucketCount-1)
+ for i, bucket := range got {
+ if bucket.Success != 0 || bucket.Failed != 0 {
+ t.Fatalf("bucket[%d] counts = %d/%d, want 0/0", i, bucket.Success, bucket.Failed)
+ }
+ if strings.TrimSpace(bucket.Time) == "" {
+ t.Fatalf("bucket[%d] time label is empty", i)
+ }
+ expectedBucketID := baseBucketID + int64(i)
+ start := time.Unix(expectedBucketID*recentRequestBucketSeconds, 0).In(time.Local)
+ end := start.Add(10 * time.Minute)
+ expected := start.Format("15:04") + "-" + end.Format("15:04")
+ if bucket.Time != expected {
+ t.Fatalf("bucket[%d] time = %q, want %q", i, bucket.Time, expected)
+ }
+ }
+}
+
+func TestRecentRequestsSnapshotIncludesCounts(t *testing.T) {
+ now := time.Unix(1_700_000_000, 0).In(time.Local)
+ a := &Auth{}
+
+ a.recordRecentRequest(now, true)
+ a.recordRecentRequest(now, false)
+
+ got := a.RecentRequestsSnapshot(now)
+ if len(got) != recentRequestBucketCount {
+ t.Fatalf("len = %d, want %d", len(got), recentRequestBucketCount)
+ }
+
+ newest := got[len(got)-1]
+ if newest.Success != 1 || newest.Failed != 1 {
+ t.Fatalf("newest bucket = success=%d failed=%d, want 1/1", newest.Success, newest.Failed)
+ }
+}
+
+func TestRecentRequestsSnapshotBucketAdvanceMovesCounts(t *testing.T) {
+ now := time.Unix(1_700_000_000, 0).In(time.Local)
+ next := now.Add(10 * time.Minute)
+ a := &Auth{}
+
+ a.recordRecentRequest(now, true)
+ a.recordRecentRequest(next, false)
+
+ got := a.RecentRequestsSnapshot(next)
+ if len(got) != recentRequestBucketCount {
+ t.Fatalf("len = %d, want %d", len(got), recentRequestBucketCount)
+ }
+
+ secondNewest := got[len(got)-2]
+ newest := got[len(got)-1]
+ if secondNewest.Success != 1 || secondNewest.Failed != 0 {
+ t.Fatalf("second newest bucket = success=%d failed=%d, want 1/0", secondNewest.Success, secondNewest.Failed)
+ }
+ if newest.Success != 0 || newest.Failed != 1 {
+ t.Fatalf("newest bucket = success=%d failed=%d, want 0/1", newest.Success, newest.Failed)
}
}
diff --git a/sdk/cliproxy/builder.go b/sdk/cliproxy/builder.go
index b8cf991c14..c7e187ee6b 100644
--- a/sdk/cliproxy/builder.go
+++ b/sdk/cliproxy/builder.go
@@ -8,12 +8,12 @@ import (
"strings"
"time"
- configaccess "github.com/router-for-me/CLIProxyAPI/v6/internal/access/config_access"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/api"
- sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access"
- sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
- coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
- "github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
+ configaccess "github.com/router-for-me/CLIProxyAPI/v7/internal/access/config_access"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/api"
+ sdkaccess "github.com/router-for-me/CLIProxyAPI/v7/sdk/access"
+ sdkAuth "github.com/router-for-me/CLIProxyAPI/v7/sdk/auth"
+ coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
+ "github.com/router-for-me/CLIProxyAPI/v7/sdk/config"
)
// Builder constructs a Service instance with customizable providers.
@@ -214,7 +214,7 @@ func (b *Builder) Build() (*Service, error) {
if b.cfg != nil {
strategy = strings.ToLower(strings.TrimSpace(b.cfg.Routing.Strategy))
// Support both legacy ClaudeCodeSessionAffinity and new universal SessionAffinity
- sessionAffinity = b.cfg.Routing.ClaudeCodeSessionAffinity || b.cfg.Routing.SessionAffinity
+ sessionAffinity = b.cfg.Routing.SessionAffinity
if ttlStr := strings.TrimSpace(b.cfg.Routing.SessionAffinityTTL); ttlStr != "" {
if parsed, err := time.ParseDuration(ttlStr); err == nil && parsed > 0 {
sessionAffinityTTL = parsed
diff --git a/sdk/cliproxy/executor/types.go b/sdk/cliproxy/executor/types.go
index 4ea8103947..fd1da2e537 100644
--- a/sdk/cliproxy/executor/types.go
+++ b/sdk/cliproxy/executor/types.go
@@ -4,12 +4,19 @@ import (
"net/http"
"net/url"
- sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
+ sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
)
// RequestedModelMetadataKey stores the client-requested model name in Options.Metadata.
const RequestedModelMetadataKey = "requested_model"
+// RequestPathMetadataKey stores the inbound HTTP request path (e.g. "/v1/images/generations") in Options.Metadata.
+// It is optional and may be absent for non-HTTP executions.
+const RequestPathMetadataKey = "request_path"
+
+// DisallowFreeAuthMetadataKey instructs auth selection to skip known free-tier credentials.
+const DisallowFreeAuthMetadataKey = "disallow_free_auth"
+
const (
// PinnedAuthMetadataKey locks execution to a specific auth ID.
PinnedAuthMetadataKey = "pinned_auth_id"
diff --git a/sdk/cliproxy/model_registry.go b/sdk/cliproxy/model_registry.go
index 01cea5b715..9cb928c98a 100644
--- a/sdk/cliproxy/model_registry.go
+++ b/sdk/cliproxy/model_registry.go
@@ -1,6 +1,6 @@
package cliproxy
-import "github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
+import "github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
// ModelInfo re-exports the registry model info structure.
type ModelInfo = registry.ModelInfo
diff --git a/sdk/cliproxy/pipeline/context.go b/sdk/cliproxy/pipeline/context.go
index fc6754eb97..4cffb0b4d9 100644
--- a/sdk/cliproxy/pipeline/context.go
+++ b/sdk/cliproxy/pipeline/context.go
@@ -4,9 +4,9 @@ import (
"context"
"net/http"
- 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"
+ cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
+ cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
+ sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
)
// Context encapsulates execution state shared across middleware, translators, and executors.
diff --git a/sdk/cliproxy/pprof_server.go b/sdk/cliproxy/pprof_server.go
index 3fafef4cd4..ec30b4bef3 100644
--- a/sdk/cliproxy/pprof_server.go
+++ b/sdk/cliproxy/pprof_server.go
@@ -9,7 +9,7 @@ import (
"sync"
"time"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
log "github.com/sirupsen/logrus"
)
diff --git a/sdk/cliproxy/providers.go b/sdk/cliproxy/providers.go
index 7ce89f76fe..542b2d9d6a 100644
--- a/sdk/cliproxy/providers.go
+++ b/sdk/cliproxy/providers.go
@@ -3,8 +3,8 @@ package cliproxy
import (
"context"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/watcher"
- "github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/watcher"
+ "github.com/router-for-me/CLIProxyAPI/v7/sdk/config"
)
// NewFileTokenClientProvider returns the default token-backed client loader.
diff --git a/sdk/cliproxy/rtprovider.go b/sdk/cliproxy/rtprovider.go
index 5c4f579a85..d07b4cb4f9 100644
--- a/sdk/cliproxy/rtprovider.go
+++ b/sdk/cliproxy/rtprovider.go
@@ -5,8 +5,8 @@ import (
"strings"
"sync"
- coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
- "github.com/router-for-me/CLIProxyAPI/v6/sdk/proxyutil"
+ coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
+ "github.com/router-for-me/CLIProxyAPI/v7/sdk/proxyutil"
log "github.com/sirupsen/logrus"
)
diff --git a/sdk/cliproxy/rtprovider_test.go b/sdk/cliproxy/rtprovider_test.go
index f907081e29..6ea08432c1 100644
--- a/sdk/cliproxy/rtprovider_test.go
+++ b/sdk/cliproxy/rtprovider_test.go
@@ -4,7 +4,7 @@ import (
"net/http"
"testing"
- coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
+ coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
)
func TestRoundTripperForDirectBypassesProxy(t *testing.T) {
diff --git a/sdk/cliproxy/service.go b/sdk/cliproxy/service.go
index 5e873d370b..8685872e0f 100644
--- a/sdk/cliproxy/service.go
+++ b/sdk/cliproxy/service.go
@@ -12,17 +12,20 @@ import (
"sync"
"time"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/api"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/executor"
- _ "github.com/router-for-me/CLIProxyAPI/v6/internal/usage"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/watcher"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/wsrelay"
- sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access"
- sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
- coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
- "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/usage"
- "github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/api"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/home"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/redisqueue"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/util"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/watcher"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/watcher/diff"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/wsrelay"
+ sdkaccess "github.com/router-for-me/CLIProxyAPI/v7/sdk/access"
+ sdkAuth "github.com/router-for-me/CLIProxyAPI/v7/sdk/auth"
+ coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
+ "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/usage"
+ "github.com/router-for-me/CLIProxyAPI/v7/sdk/config"
log "github.com/sirupsen/logrus"
)
@@ -36,6 +39,9 @@ type Service struct {
// cfgMu protects concurrent access to the configuration.
cfgMu sync.RWMutex
+ // configUpdateMu serializes config updates across watcher + home.
+ configUpdateMu sync.Mutex
+
// configPath is the path to the configuration file.
configPath string
@@ -89,6 +95,9 @@ type Service struct {
// wsGateway manages websocket Gemini providers.
wsGateway *wsrelay.Manager
+
+ homeClient *home.Client
+ homeCancel context.CancelFunc
}
// RegisterUsagePlugin registers a usage plugin on the global usage manager.
@@ -462,6 +471,270 @@ func (s *Service) rebindExecutors() {
}
}
+func (s *Service) applyConfigUpdate(newCfg *config.Config) {
+ if s == nil {
+ return
+ }
+
+ s.configUpdateMu.Lock()
+ defer s.configUpdateMu.Unlock()
+
+ previousStrategy := ""
+ var previousSessionAffinity bool
+ var previousSessionAffinityTTL string
+ s.cfgMu.RLock()
+ if s.cfg != nil {
+ previousStrategy = strings.ToLower(strings.TrimSpace(s.cfg.Routing.Strategy))
+ previousSessionAffinity = s.cfg.Routing.SessionAffinity
+ previousSessionAffinityTTL = s.cfg.Routing.SessionAffinityTTL
+ }
+ s.cfgMu.RUnlock()
+
+ if newCfg == nil {
+ s.cfgMu.RLock()
+ newCfg = s.cfg
+ s.cfgMu.RUnlock()
+ }
+ if newCfg == nil {
+ return
+ }
+
+ nextStrategy := strings.ToLower(strings.TrimSpace(newCfg.Routing.Strategy))
+ normalizeStrategy := func(strategy string) string {
+ switch strategy {
+ case "fill-first", "fillfirst", "ff":
+ return "fill-first"
+ default:
+ return "round-robin"
+ }
+ }
+ previousStrategy = normalizeStrategy(previousStrategy)
+ nextStrategy = normalizeStrategy(nextStrategy)
+
+ nextSessionAffinity := newCfg.Routing.SessionAffinity
+ nextSessionAffinityTTL := newCfg.Routing.SessionAffinityTTL
+
+ selectorChanged := previousStrategy != nextStrategy ||
+ previousSessionAffinity != nextSessionAffinity ||
+ previousSessionAffinityTTL != nextSessionAffinityTTL
+
+ if s.coreManager != nil && selectorChanged {
+ var selector coreauth.Selector
+ switch nextStrategy {
+ case "fill-first":
+ selector = &coreauth.FillFirstSelector{}
+ default:
+ selector = &coreauth.RoundRobinSelector{}
+ }
+
+ if nextSessionAffinity {
+ ttl := time.Hour
+ if ttlStr := strings.TrimSpace(nextSessionAffinityTTL); ttlStr != "" {
+ if parsed, err := time.ParseDuration(ttlStr); err == nil && parsed > 0 {
+ ttl = parsed
+ }
+ }
+ selector = coreauth.NewSessionAffinitySelectorWithConfig(coreauth.SessionAffinityConfig{
+ Fallback: selector,
+ TTL: ttl,
+ })
+ }
+
+ s.coreManager.SetSelector(selector)
+ }
+
+ s.applyRetryConfig(newCfg)
+ s.applyPprofConfig(newCfg)
+ if s.server != nil {
+ s.server.UpdateClients(newCfg)
+ }
+ s.cfgMu.Lock()
+ s.cfg = newCfg
+ s.cfgMu.Unlock()
+ if s.coreManager != nil {
+ s.coreManager.SetConfig(newCfg)
+ s.coreManager.SetOAuthModelAlias(newCfg.OAuthModelAlias)
+ }
+ s.rebindExecutors()
+}
+
+func forceHomeRuntimeConfig(cfg *config.Config) {
+ if cfg == nil {
+ return
+ }
+ cfg.APIKeys = nil
+ cfg.UsageStatisticsEnabled = true
+ cfg.DisableCooling = true
+ cfg.WebsocketAuth = false
+ cfg.EnableGeminiCLIEndpoint = false
+ cfg.RemoteManagement.AllowRemote = false
+ cfg.RemoteManagement.DisableControlPanel = true
+}
+
+func (s *Service) registerHomeExecutors() {
+ if s == nil || s.coreManager == nil || s.cfg == nil {
+ return
+ }
+
+ // Register baseline executors so home-dispatched auth entries can execute without
+ // requiring any local auth-dir credentials.
+ s.coreManager.RegisterExecutor(executor.NewCodexAutoExecutor(s.cfg))
+ s.coreManager.RegisterExecutor(executor.NewClaudeExecutor(s.cfg))
+ s.coreManager.RegisterExecutor(executor.NewGeminiExecutor(s.cfg))
+ s.coreManager.RegisterExecutor(executor.NewGeminiVertexExecutor(s.cfg))
+ s.coreManager.RegisterExecutor(executor.NewGeminiCLIExecutor(s.cfg))
+ s.coreManager.RegisterExecutor(executor.NewAIStudioExecutor(s.cfg, "", s.wsGateway))
+ s.coreManager.RegisterExecutor(executor.NewAntigravityExecutor(s.cfg))
+ s.coreManager.RegisterExecutor(executor.NewKimiExecutor(s.cfg))
+ s.coreManager.RegisterExecutor(executor.NewOpenAICompatExecutor("openai-compatibility", s.cfg))
+}
+
+func (s *Service) applyHomeOverlay(remoteCfg *config.Config) {
+ if s == nil || remoteCfg == nil {
+ return
+ }
+
+ s.cfgMu.RLock()
+ baseCfg := s.cfg
+ s.cfgMu.RUnlock()
+ if baseCfg == nil {
+ return
+ }
+
+ merged := *remoteCfg
+ merged.Host = baseCfg.Host
+ merged.Port = baseCfg.Port
+ merged.TLS = baseCfg.TLS
+ merged.Home = baseCfg.Home
+ forceHomeRuntimeConfig(&merged)
+
+ logHomeConfigChanges(baseCfg, &merged)
+ s.applyConfigUpdate(&merged)
+}
+
+func logHomeConfigChanges(oldCfg, newCfg *config.Config) {
+ if oldCfg == nil || newCfg == nil || !newCfg.Home.Enabled || (!oldCfg.Debug && !newCfg.Debug) {
+ return
+ }
+
+ details := diff.BuildConfigChangeDetails(oldCfg, newCfg)
+ if len(details) == 0 {
+ return
+ }
+
+ if newCfg.Debug && !log.IsLevelEnabled(log.DebugLevel) {
+ util.SetLogLevel(newCfg)
+ }
+
+ log.Debugf("home config changes detected:")
+ for _, detail := range details {
+ log.Debugf(" %s", detail)
+ }
+}
+
+func (s *Service) startHomeUsageForwarder(ctx context.Context, client *home.Client) {
+ if s == nil || client == nil {
+ return
+ }
+ if ctx == nil {
+ ctx = context.Background()
+ }
+
+ sleep := func(d time.Duration) bool {
+ if d <= 0 {
+ return true
+ }
+ timer := time.NewTimer(d)
+ defer timer.Stop()
+ select {
+ case <-ctx.Done():
+ return false
+ case <-timer.C:
+ return true
+ }
+ }
+
+ go func() {
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ default:
+ }
+
+ if !client.HeartbeatOK() {
+ if !sleep(time.Second) {
+ return
+ }
+ continue
+ }
+
+ items := redisqueue.PopOldest(64)
+ if len(items) == 0 {
+ if !sleep(500 * time.Millisecond) {
+ return
+ }
+ continue
+ }
+
+ for i := range items {
+ if errPush := client.LPushUsage(ctx, items[i]); errPush != nil {
+ for j := i; j < len(items); j++ {
+ redisqueue.Enqueue(items[j])
+ }
+ if !sleep(time.Second) {
+ return
+ }
+ break
+ }
+ }
+ }
+ }()
+}
+
+func (s *Service) startHomeSubscriber(ctx context.Context) {
+ if s == nil {
+ return
+ }
+ s.cfgMu.RLock()
+ cfg := s.cfg
+ s.cfgMu.RUnlock()
+ if cfg == nil || !cfg.Home.Enabled {
+ return
+ }
+
+ if s.homeCancel != nil {
+ s.homeCancel()
+ s.homeCancel = nil
+ }
+ if s.homeClient != nil {
+ s.homeClient.Close()
+ s.homeClient = nil
+ }
+
+ homeCtx := ctx
+ if homeCtx == nil {
+ homeCtx = context.Background()
+ }
+ homeCtx, cancel := context.WithCancel(homeCtx)
+ s.homeCancel = cancel
+
+ client := home.New(cfg.Home)
+ s.homeClient = client
+ home.SetCurrent(client)
+
+ go client.StartConfigSubscriber(homeCtx, func(raw []byte) error {
+ parsed, err := config.ParseConfigBytes(raw)
+ if err != nil {
+ log.Warnf("failed to parse home config payload: %v", err)
+ return err
+ }
+ s.applyHomeOverlay(parsed)
+ return nil
+ })
+ s.startHomeUsageForwarder(homeCtx, client)
+}
+
// Run starts the service and blocks until the context is cancelled or the server stops.
// It initializes all components including authentication, file watching, HTTP server,
// and starts processing requests. The method blocks until the context is cancelled.
@@ -480,6 +753,11 @@ func (s *Service) Run(ctx context.Context) error {
}
usage.StartDefault(ctx)
+ homeEnabled := s.cfg != nil && s.cfg.Home.Enabled
+ if homeEnabled {
+ forceHomeRuntimeConfig(s.cfg)
+ redisqueue.SetUsageStatisticsEnabled(true)
+ }
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
defer shutdownCancel()
@@ -489,32 +767,36 @@ func (s *Service) Run(ctx context.Context) error {
}
}()
- if err := s.ensureAuthDir(); err != nil {
- return err
+ if !homeEnabled {
+ if errEnsureAuthDir := s.ensureAuthDir(); errEnsureAuthDir != nil {
+ return errEnsureAuthDir
+ }
}
s.applyRetryConfig(s.cfg)
- if s.coreManager != nil {
+ if s.coreManager != nil && !homeEnabled {
if errLoad := s.coreManager.Load(ctx); errLoad != nil {
log.Warnf("failed to load auth store: %v", errLoad)
}
}
- tokenResult, err := s.tokenProvider.Load(ctx, s.cfg)
- if err != nil && !errors.Is(err, context.Canceled) {
- return err
- }
- if tokenResult == nil {
- tokenResult = &TokenClientResult{}
- }
+ if !homeEnabled {
+ tokenResult, err := s.tokenProvider.Load(ctx, s.cfg)
+ if err != nil && !errors.Is(err, context.Canceled) {
+ return err
+ }
+ if tokenResult == nil {
+ tokenResult = &TokenClientResult{}
+ }
- apiKeyResult, err := s.apiKeyProvider.Load(ctx, s.cfg)
- if err != nil && !errors.Is(err, context.Canceled) {
- return err
- }
- if apiKeyResult == nil {
- apiKeyResult = &APIKeyClientResult{}
+ apiKeyResult, err := s.apiKeyProvider.Load(ctx, s.cfg)
+ if err != nil && !errors.Is(err, context.Canceled) {
+ return err
+ }
+ if apiKeyResult == nil {
+ apiKeyResult = &APIKeyClientResult{}
+ }
}
// legacy clients removed; no caches to refresh
@@ -526,6 +808,10 @@ func (s *Service) Run(ctx context.Context) error {
s.authManager = newDefaultAuthManager()
}
+ if homeEnabled {
+ s.startHomeSubscriber(ctx)
+ }
+
s.ensureWebsocketGateway()
if s.server != nil && s.wsGateway != nil {
s.server.AttachWebsocketRoute(s.wsGateway.Path(), s.wsGateway.Handler())
@@ -547,6 +833,12 @@ func (s *Service) Run(ctx context.Context) error {
})
}
+ if homeEnabled {
+ s.registerHomeExecutors()
+ // Home mode does not expose in-process Redis RESP usage output; usage is forwarded to home instead.
+ redisqueue.SetEnabled(true)
+ }
+
if s.hooks.OnBeforeStart != nil {
s.hooks.OnBeforeStart(s.cfg)
}
@@ -607,107 +899,31 @@ func (s *Service) Run(ctx context.Context) error {
s.hooks.OnAfterStart(s)
}
- var watcherWrapper *WatcherWrapper
- reloadCallback := func(newCfg *config.Config) {
- previousStrategy := ""
- var previousSessionAffinity bool
- var previousSessionAffinityTTL string
- s.cfgMu.RLock()
- if s.cfg != nil {
- previousStrategy = strings.ToLower(strings.TrimSpace(s.cfg.Routing.Strategy))
- previousSessionAffinity = s.cfg.Routing.ClaudeCodeSessionAffinity || s.cfg.Routing.SessionAffinity
- previousSessionAffinityTTL = s.cfg.Routing.SessionAffinityTTL
- }
- s.cfgMu.RUnlock()
+ if !homeEnabled {
+ var watcherWrapper *WatcherWrapper
+ reloadCallback := func(newCfg *config.Config) { s.applyConfigUpdate(newCfg) }
- if newCfg == nil {
- s.cfgMu.RLock()
- newCfg = s.cfg
- s.cfgMu.RUnlock()
+ watcherWrapper, errCreate := s.watcherFactory(s.configPath, s.cfg.AuthDir, reloadCallback)
+ if errCreate != nil {
+ return fmt.Errorf("cliproxy: failed to create watcher: %w", errCreate)
}
- if newCfg == nil {
- return
+ s.watcher = watcherWrapper
+ s.ensureAuthUpdateQueue(ctx)
+ if s.authUpdates != nil {
+ watcherWrapper.SetAuthUpdateQueue(s.authUpdates)
}
+ watcherWrapper.SetConfig(s.cfg)
- nextStrategy := strings.ToLower(strings.TrimSpace(newCfg.Routing.Strategy))
- normalizeStrategy := func(strategy string) string {
- switch strategy {
- case "fill-first", "fillfirst", "ff":
- return "fill-first"
- default:
- return "round-robin"
- }
+ watcherCtx, watcherCancel := context.WithCancel(context.Background())
+ s.watcherCancel = watcherCancel
+ if errStart := watcherWrapper.Start(watcherCtx); errStart != nil {
+ return fmt.Errorf("cliproxy: failed to start watcher: %w", errStart)
}
- previousStrategy = normalizeStrategy(previousStrategy)
- nextStrategy = normalizeStrategy(nextStrategy)
-
- nextSessionAffinity := newCfg.Routing.ClaudeCodeSessionAffinity || newCfg.Routing.SessionAffinity
- nextSessionAffinityTTL := newCfg.Routing.SessionAffinityTTL
-
- selectorChanged := previousStrategy != nextStrategy ||
- previousSessionAffinity != nextSessionAffinity ||
- previousSessionAffinityTTL != nextSessionAffinityTTL
-
- if s.coreManager != nil && selectorChanged {
- var selector coreauth.Selector
- switch nextStrategy {
- case "fill-first":
- selector = &coreauth.FillFirstSelector{}
- default:
- selector = &coreauth.RoundRobinSelector{}
- }
-
- if nextSessionAffinity {
- ttl := time.Hour
- if ttlStr := strings.TrimSpace(nextSessionAffinityTTL); ttlStr != "" {
- if parsed, err := time.ParseDuration(ttlStr); err == nil && parsed > 0 {
- ttl = parsed
- }
- }
- selector = coreauth.NewSessionAffinitySelectorWithConfig(coreauth.SessionAffinityConfig{
- Fallback: selector,
- TTL: ttl,
- })
- }
-
- s.coreManager.SetSelector(selector)
- }
-
- s.applyRetryConfig(newCfg)
- s.applyPprofConfig(newCfg)
- if s.server != nil {
- s.server.UpdateClients(newCfg)
- }
- s.cfgMu.Lock()
- s.cfg = newCfg
- s.cfgMu.Unlock()
- if s.coreManager != nil {
- s.coreManager.SetConfig(newCfg)
- s.coreManager.SetOAuthModelAlias(newCfg.OAuthModelAlias)
- }
- s.rebindExecutors()
+ log.Info("file watcher started for config and auth directory changes")
}
- watcherWrapper, err = s.watcherFactory(s.configPath, s.cfg.AuthDir, reloadCallback)
- if err != nil {
- return fmt.Errorf("cliproxy: failed to create watcher: %w", err)
- }
- s.watcher = watcherWrapper
- s.ensureAuthUpdateQueue(ctx)
- if s.authUpdates != nil {
- watcherWrapper.SetAuthUpdateQueue(s.authUpdates)
- }
- watcherWrapper.SetConfig(s.cfg)
-
- watcherCtx, watcherCancel := context.WithCancel(context.Background())
- s.watcherCancel = watcherCancel
- if err = watcherWrapper.Start(watcherCtx); err != nil {
- return fmt.Errorf("cliproxy: failed to start watcher: %w", err)
- }
- log.Info("file watcher started for config and auth directory changes")
-
// Prefer core auth manager auto refresh if available.
- if s.coreManager != nil {
+ if s.coreManager != nil && !homeEnabled {
interval := 15 * time.Minute
s.coreManager.StartAutoRefresh(context.Background(), interval)
log.Infof("core auth auto-refresh started (interval=%s)", interval)
@@ -717,8 +933,8 @@ func (s *Service) Run(ctx context.Context) error {
case <-ctx.Done():
log.Debug("service context cancelled, shutting down...")
return ctx.Err()
- case err = <-s.serverErr:
- return err
+ case errServer := <-s.serverErr:
+ return errServer
}
}
@@ -741,6 +957,16 @@ func (s *Service) Shutdown(ctx context.Context) error {
ctx = context.Background()
}
+ if s.homeCancel != nil {
+ s.homeCancel()
+ s.homeCancel = nil
+ }
+ if s.homeClient != nil {
+ s.homeClient.Close()
+ s.homeClient = nil
+ }
+ home.ClearCurrent()
+
// legacy refresh loop removed; only stopping core auth manager below
if s.watcherCancel != nil {
@@ -968,6 +1194,9 @@ func (s *Service) registerModelsForAuth(a *coreauth.Auth) {
}
for i := range s.cfg.OpenAICompatibility {
compat := &s.cfg.OpenAICompatibility[i]
+ if compat.Disabled {
+ continue
+ }
if strings.EqualFold(compat.Name, compatName) {
isCompatAuth = true
// Convert compatibility models to registry models
@@ -1410,7 +1639,7 @@ func buildCodexConfigModels(entry *config.CodexKey) []*ModelInfo {
if entry == nil {
return nil
}
- return buildConfigModels(entry.Models, "openai", "openai")
+ return registry.WithCodexBuiltins(buildConfigModels(entry.Models, "openai", "openai"))
}
func rewriteModelInfoName(name, oldID, newID string) string {
diff --git a/sdk/cliproxy/service_codex_executor_binding_test.go b/sdk/cliproxy/service_codex_executor_binding_test.go
index bb4fc84e10..20a9cd7c86 100644
--- a/sdk/cliproxy/service_codex_executor_binding_test.go
+++ b/sdk/cliproxy/service_codex_executor_binding_test.go
@@ -3,8 +3,8 @@ package cliproxy
import (
"testing"
- coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
- "github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
+ coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
+ "github.com/router-for-me/CLIProxyAPI/v7/sdk/config"
)
func TestEnsureExecutorsForAuth_CodexDoesNotReplaceInNormalMode(t *testing.T) {
diff --git a/sdk/cliproxy/service_excluded_models_test.go b/sdk/cliproxy/service_excluded_models_test.go
index 198a5bed73..fc16c09561 100644
--- a/sdk/cliproxy/service_excluded_models_test.go
+++ b/sdk/cliproxy/service_excluded_models_test.go
@@ -4,8 +4,8 @@ import (
"strings"
"testing"
- coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
- "github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
+ coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
+ "github.com/router-for-me/CLIProxyAPI/v7/sdk/config"
)
func TestRegisterModelsForAuth_UsesPreMergedExcludedModelsAttribute(t *testing.T) {
diff --git a/sdk/cliproxy/service_oauth_model_alias_test.go b/sdk/cliproxy/service_oauth_model_alias_test.go
index 2caf7a178f..7405f7caca 100644
--- a/sdk/cliproxy/service_oauth_model_alias_test.go
+++ b/sdk/cliproxy/service_oauth_model_alias_test.go
@@ -3,7 +3,7 @@ package cliproxy
import (
"testing"
- "github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/sdk/config"
)
func TestApplyOAuthModelAlias_Rename(t *testing.T) {
diff --git a/sdk/cliproxy/service_stale_state_test.go b/sdk/cliproxy/service_stale_state_test.go
index 010218d966..53849eb349 100644
--- a/sdk/cliproxy/service_stale_state_test.go
+++ b/sdk/cliproxy/service_stale_state_test.go
@@ -5,9 +5,9 @@ import (
"testing"
"time"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
- coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
- "github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
+ coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
+ "github.com/router-for-me/CLIProxyAPI/v7/sdk/config"
)
func TestServiceApplyCoreAuthAddOrUpdate_DeleteReAddDoesNotInheritStaleRuntimeState(t *testing.T) {
@@ -99,3 +99,32 @@ func TestServiceApplyCoreAuthAddOrUpdate_DeleteReAddDoesNotInheritStaleRuntimeSt
t.Fatalf("expected re-added auth to re-register models in global registry")
}
}
+
+func TestForceHomeRuntimeConfigEnablesUsageStatistics(t *testing.T) {
+ cfg := &config.Config{
+ UsageStatisticsEnabled: false,
+ }
+
+ forceHomeRuntimeConfig(cfg)
+
+ if !cfg.UsageStatisticsEnabled {
+ t.Fatal("expected home runtime config to force usage statistics enabled")
+ }
+}
+
+func TestApplyHomeOverlayForcesUsageStatisticsEnabled(t *testing.T) {
+ baseCfg := &config.Config{}
+ baseCfg.Home.Enabled = true
+ service := &Service{cfg: baseCfg}
+
+ service.applyHomeOverlay(&config.Config{
+ UsageStatisticsEnabled: false,
+ })
+
+ if service.cfg == nil || !service.cfg.UsageStatisticsEnabled {
+ t.Fatal("expected home overlay to force usage statistics enabled")
+ }
+ if !service.cfg.Home.Enabled {
+ t.Fatal("expected home overlay to preserve local home settings")
+ }
+}
diff --git a/sdk/cliproxy/types.go b/sdk/cliproxy/types.go
index 1521dffee4..c30b712bdd 100644
--- a/sdk/cliproxy/types.go
+++ b/sdk/cliproxy/types.go
@@ -6,9 +6,9 @@ package cliproxy
import (
"context"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/watcher"
- coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
- "github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/watcher"
+ coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
+ "github.com/router-for-me/CLIProxyAPI/v7/sdk/config"
)
// TokenClientProvider loads clients backed by stored authentication tokens.
diff --git a/sdk/cliproxy/usage/manager.go b/sdk/cliproxy/usage/manager.go
index 8d24f51f4e..2305d9a484 100644
--- a/sdk/cliproxy/usage/manager.go
+++ b/sdk/cliproxy/usage/manager.go
@@ -2,6 +2,7 @@ package usage
import (
"context"
+ "strings"
"sync"
"time"
@@ -12,16 +13,25 @@ import (
type Record struct {
Provider string
Model string
+ Alias string
APIKey string
AuthID string
AuthIndex string
+ AuthType string
Source string
RequestedAt time.Time
Latency time.Duration
Failed bool
+ Fail Failure
Detail Detail
}
+// Failure holds HTTP failure metadata for an upstream request attempt.
+type Failure struct {
+ StatusCode int
+ Body string
+}
+
// Detail holds the token usage breakdown.
type Detail struct {
InputTokens int64
@@ -31,6 +41,36 @@ type Detail struct {
TotalTokens int64
}
+type requestedModelAliasContextKey struct{}
+
+// WithRequestedModelAlias stores the client-requested model name for usage sinks.
+func WithRequestedModelAlias(ctx context.Context, alias string) context.Context {
+ if ctx == nil {
+ ctx = context.Background()
+ }
+ alias = strings.TrimSpace(alias)
+ if alias == "" {
+ return ctx
+ }
+ return context.WithValue(ctx, requestedModelAliasContextKey{}, alias)
+}
+
+// RequestedModelAliasFromContext returns the client-requested model name stored in ctx.
+func RequestedModelAliasFromContext(ctx context.Context) string {
+ if ctx == nil {
+ return ""
+ }
+ raw := ctx.Value(requestedModelAliasContextKey{})
+ switch value := raw.(type) {
+ case string:
+ return strings.TrimSpace(value)
+ case []byte:
+ return strings.TrimSpace(string(value))
+ default:
+ return ""
+ }
+}
+
// Plugin consumes usage records emitted by the proxy runtime.
type Plugin interface {
HandleUsage(ctx context.Context, record Record)
diff --git a/sdk/cliproxy/watcher.go b/sdk/cliproxy/watcher.go
index caeadf19b9..e4a9081b41 100644
--- a/sdk/cliproxy/watcher.go
+++ b/sdk/cliproxy/watcher.go
@@ -3,9 +3,9 @@ package cliproxy
import (
"context"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/watcher"
- coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
- "github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/watcher"
+ coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
+ "github.com/router-for-me/CLIProxyAPI/v7/sdk/config"
)
func defaultWatcherFactory(configPath, authDir string, reload func(*config.Config)) (*WatcherWrapper, error) {
diff --git a/sdk/config/config.go b/sdk/config/config.go
index 14163418f7..d39e512de1 100644
--- a/sdk/config/config.go
+++ b/sdk/config/config.go
@@ -4,7 +4,7 @@
// embed CLIProxyAPI without importing internal packages.
package config
-import internalconfig "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
+import internalconfig "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
type SDKConfig = internalconfig.SDKConfig
@@ -41,6 +41,8 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) {
return internalconfig.LoadConfigOptional(configFile, optional)
}
+func ParseConfigBytes(data []byte) (*Config, error) { return internalconfig.ParseConfigBytes(data) }
+
func SaveConfigPreserveComments(configFile string, cfg *Config) error {
return internalconfig.SaveConfigPreserveComments(configFile, cfg)
}
diff --git a/sdk/logging/request_logger.go b/sdk/logging/request_logger.go
index ddbda6b8b0..5f8cf754e1 100644
--- a/sdk/logging/request_logger.go
+++ b/sdk/logging/request_logger.go
@@ -1,7 +1,7 @@
// Package logging re-exports request logging primitives for SDK consumers.
package logging
-import internallogging "github.com/router-for-me/CLIProxyAPI/v6/internal/logging"
+import internallogging "github.com/router-for-me/CLIProxyAPI/v7/internal/logging"
const defaultErrorLogsMaxFiles = 10
diff --git a/sdk/translator/builtin/builtin.go b/sdk/translator/builtin/builtin.go
index 798e43f1a9..f95e65870f 100644
--- a/sdk/translator/builtin/builtin.go
+++ b/sdk/translator/builtin/builtin.go
@@ -2,9 +2,9 @@
package builtin
import (
- sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
+ sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
- _ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator"
+ _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator"
)
// Registry exposes the default registry populated with all built-in translators.
diff --git a/test/amp_management_test.go b/test/amp_management_test.go
index e384ef0e8b..6c694db6fa 100644
--- a/test/amp_management_test.go
+++ b/test/amp_management_test.go
@@ -10,8 +10,8 @@ import (
"testing"
"github.com/gin-gonic/gin"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/api/handlers/management"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/api/handlers/management"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
)
func init() {
diff --git a/test/builtin_tools_translation_test.go b/test/builtin_tools_translation_test.go
index 07d7671544..70ee0ac1b9 100644
--- a/test/builtin_tools_translation_test.go
+++ b/test/builtin_tools_translation_test.go
@@ -3,9 +3,9 @@ package test
import (
"testing"
- _ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator"
+ _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator"
- sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
+ sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
"github.com/tidwall/gjson"
)
diff --git a/test/thinking_conversion_test.go b/test/thinking_conversion_test.go
index c6ade7b2a6..9173aa0194 100644
--- a/test/thinking_conversion_test.go
+++ b/test/thinking_conversion_test.go
@@ -2,24 +2,23 @@ package test
import (
"fmt"
- "strings"
"testing"
"time"
- _ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator"
+ _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator"
// Import provider packages to trigger init() registration of ProviderAppliers
- _ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/antigravity"
- _ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/claude"
- _ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/codex"
- _ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/gemini"
- _ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/geminicli"
- _ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/kimi"
- _ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/openai"
-
- "github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
- sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
+ _ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking/provider/antigravity"
+ _ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking/provider/claude"
+ _ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking/provider/codex"
+ _ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking/provider/gemini"
+ _ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking/provider/geminicli"
+ _ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking/provider/kimi"
+ _ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking/provider/openai"
+
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
+ sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
@@ -1066,12 +1065,12 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) {
expectErr: false,
},
- // Gemini Family Cross-Channel Consistency (Cases 106-114)
+ // Gemini Family Cross-Channel Consistency (Cases 90-95)
// Tests that gemini/gemini-cli/antigravity as same API family should have consistent validation behavior
- // Case 106: Gemini to Antigravity, budget 64000 (suffix) → clamped to Max
+ // Case 90: Gemini to Antigravity, budget 64000 (suffix) → clamped to Max
{
- name: "106",
+ name: "90",
from: "gemini",
to: "antigravity",
model: "gemini-budget-model(64000)",
@@ -1081,9 +1080,9 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) {
includeThoughts: "true",
expectErr: false,
},
- // Case 107: Gemini to Gemini-CLI, budget 64000 (suffix) → clamped to Max
+ // Case 91: Gemini to Gemini-CLI, budget 64000 (suffix) → clamped to Max
{
- name: "107",
+ name: "91",
from: "gemini",
to: "gemini-cli",
model: "gemini-budget-model(64000)",
@@ -1093,9 +1092,9 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) {
includeThoughts: "true",
expectErr: false,
},
- // Case 108: Gemini-CLI to Antigravity, budget 64000 (suffix) → clamped to Max
+ // Case 92: Gemini-CLI to Antigravity, budget 64000 (suffix) → clamped to Max
{
- name: "108",
+ name: "92",
from: "gemini-cli",
to: "antigravity",
model: "gemini-budget-model(64000)",
@@ -1105,9 +1104,9 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) {
includeThoughts: "true",
expectErr: false,
},
- // Case 109: Gemini-CLI to Gemini, budget 64000 (suffix) → clamped to Max
+ // Case 93: Gemini-CLI to Gemini, budget 64000 (suffix) → clamped to Max
{
- name: "109",
+ name: "93",
from: "gemini-cli",
to: "gemini",
model: "gemini-budget-model(64000)",
@@ -1117,9 +1116,9 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) {
includeThoughts: "true",
expectErr: false,
},
- // Case 110: Gemini to Antigravity, budget 8192 → passthrough (normal value)
+ // Case 94: Gemini to Antigravity, budget 8192 → passthrough (normal value)
{
- name: "110",
+ name: "94",
from: "gemini",
to: "antigravity",
model: "gemini-budget-model(8192)",
@@ -1129,9 +1128,9 @@ func TestThinkingE2EMatrix_Suffix(t *testing.T) {
includeThoughts: "true",
expectErr: false,
},
- // Case 111: Gemini-CLI to Antigravity, budget 8192 → passthrough (normal value)
+ // Case 95: Gemini-CLI to Antigravity, budget 8192 → passthrough (normal value)
{
- name: "111",
+ name: "95",
from: "gemini-cli",
to: "antigravity",
model: "gemini-budget-model(8192)",
@@ -2167,12 +2166,12 @@ func TestThinkingE2EMatrix_Body(t *testing.T) {
expectErr: true,
},
- // Gemini Family Cross-Channel Consistency (Cases 106-114)
+ // Gemini Family Cross-Channel Consistency (Cases 90-95)
// Tests that gemini/gemini-cli/antigravity as same API family should have consistent validation behavior
- // Case 106: Gemini to Antigravity, thinkingBudget=64000 → exceeds Max error (same family strict validation)
+ // Case 90: Gemini to Antigravity, thinkingBudget=64000 → exceeds Max error (same family strict validation)
{
- name: "106",
+ name: "90",
from: "gemini",
to: "antigravity",
model: "gemini-budget-model",
@@ -2180,9 +2179,9 @@ func TestThinkingE2EMatrix_Body(t *testing.T) {
expectField: "",
expectErr: true,
},
- // Case 107: Gemini to Gemini-CLI, thinkingBudget=64000 → exceeds Max error (same family strict validation)
+ // Case 91: Gemini to Gemini-CLI, thinkingBudget=64000 → exceeds Max error (same family strict validation)
{
- name: "107",
+ name: "91",
from: "gemini",
to: "gemini-cli",
model: "gemini-budget-model",
@@ -2190,9 +2189,9 @@ func TestThinkingE2EMatrix_Body(t *testing.T) {
expectField: "",
expectErr: true,
},
- // Case 108: Gemini-CLI to Antigravity, thinkingBudget=64000 → exceeds Max error (same family strict validation)
+ // Case 92: Gemini-CLI to Antigravity, thinkingBudget=64000 → exceeds Max error (same family strict validation)
{
- name: "108",
+ name: "92",
from: "gemini-cli",
to: "antigravity",
model: "gemini-budget-model",
@@ -2200,9 +2199,9 @@ func TestThinkingE2EMatrix_Body(t *testing.T) {
expectField: "",
expectErr: true,
},
- // Case 109: Gemini-CLI to Gemini, thinkingBudget=64000 → exceeds Max error (same family strict validation)
+ // Case 93: Gemini-CLI to Gemini, thinkingBudget=64000 → exceeds Max error (same family strict validation)
{
- name: "109",
+ name: "93",
from: "gemini-cli",
to: "gemini",
model: "gemini-budget-model",
@@ -2210,9 +2209,9 @@ func TestThinkingE2EMatrix_Body(t *testing.T) {
expectField: "",
expectErr: true,
},
- // Case 110: Gemini to Antigravity, thinkingBudget=8192 → passthrough (normal value)
+ // Case 94: Gemini to Antigravity, thinkingBudget=8192 → passthrough (normal value)
{
- name: "110",
+ name: "94",
from: "gemini",
to: "antigravity",
model: "gemini-budget-model",
@@ -2222,9 +2221,9 @@ func TestThinkingE2EMatrix_Body(t *testing.T) {
includeThoughts: "true",
expectErr: false,
},
- // Case 111: Gemini-CLI to Antigravity, thinkingBudget=8192 → passthrough (normal value)
+ // Case 95: Gemini-CLI to Antigravity, thinkingBudget=8192 → passthrough (normal value)
{
- name: "111",
+ name: "95",
from: "gemini-cli",
to: "antigravity",
model: "gemini-budget-model",
diff --git a/test/usage_logging_test.go b/test/usage_logging_test.go
index 41c2ee341a..bcf6d19254 100644
--- a/test/usage_logging_test.go
+++ b/test/usage_logging_test.go
@@ -2,21 +2,22 @@ package test
import (
"context"
+ "encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
- runtimeexecutor "github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/executor"
- internalusage "github.com/router-for-me/CLIProxyAPI/v6/internal/usage"
- 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"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v7/internal/redisqueue"
+ runtimeexecutor "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor"
+ cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
+ cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
+ sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
)
-func TestGeminiExecutorRecordsSuccessfulZeroUsageInStatistics(t *testing.T) {
+func TestGeminiExecutorRecordsSuccessfulZeroUsageInQueue(t *testing.T) {
model := fmt.Sprintf("gemini-2.5-flash-zero-usage-%d", time.Now().UnixNano())
source := fmt.Sprintf("zero-usage-%d@example.com", time.Now().UnixNano())
@@ -42,10 +43,15 @@ func TestGeminiExecutorRecordsSuccessfulZeroUsageInStatistics(t *testing.T) {
},
}
- prevStatsEnabled := internalusage.StatisticsEnabled()
- internalusage.SetStatisticsEnabled(true)
+ prevQueueEnabled := redisqueue.Enabled()
+ prevUsageEnabled := redisqueue.UsageStatisticsEnabled()
+ redisqueue.SetEnabled(false)
+ redisqueue.SetEnabled(true)
+ redisqueue.SetUsageStatisticsEnabled(true)
t.Cleanup(func() {
- internalusage.SetStatisticsEnabled(prevStatsEnabled)
+ redisqueue.SetEnabled(false)
+ redisqueue.SetEnabled(prevQueueEnabled)
+ redisqueue.SetUsageStatisticsEnabled(prevUsageEnabled)
})
_, err := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{
@@ -59,39 +65,58 @@ func TestGeminiExecutorRecordsSuccessfulZeroUsageInStatistics(t *testing.T) {
t.Fatalf("Execute error: %v", err)
}
- detail := waitForStatisticsDetail(t, "gemini", model, source)
- if detail.Failed {
- t.Fatalf("detail failed = true, want false")
- }
- if detail.Tokens.TotalTokens != 0 {
- t.Fatalf("total tokens = %d, want 0", detail.Tokens.TotalTokens)
- }
+ waitForQueuedUsageModelTotalTokens(t, "gemini", model, 0)
}
-func waitForStatisticsDetail(t *testing.T, apiName, model, source string) internalusage.RequestDetail {
+func waitForQueuedUsageModelTotalTokens(t *testing.T, wantProvider, wantModel string, wantTokens int64) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
- snapshot := internalusage.GetRequestStatistics().Snapshot()
- apiSnapshot, ok := snapshot.APIs[apiName]
- if !ok {
- time.Sleep(10 * time.Millisecond)
- continue
- }
- modelSnapshot, ok := apiSnapshot.Models[model]
- if !ok {
- time.Sleep(10 * time.Millisecond)
- continue
- }
- for _, detail := range modelSnapshot.Details {
- if detail.Source == source {
- return detail
+ items := redisqueue.PopOldest(10)
+ for _, item := range items {
+ got, ok := parseQueuedUsagePayload(t, item)
+ if !ok {
+ continue
}
+ if got.Provider != wantProvider || got.Model != wantModel {
+ continue
+ }
+ if got.Failed {
+ t.Fatalf("payload failed = true, want false")
+ }
+ if got.Tokens.TotalTokens != wantTokens {
+ t.Fatalf("payload total tokens = %d, want %d", got.Tokens.TotalTokens, wantTokens)
+ }
+ return
}
time.Sleep(10 * time.Millisecond)
}
- t.Fatalf("timed out waiting for statistics detail for api=%q model=%q source=%q", apiName, model, source)
- return internalusage.RequestDetail{}
+ t.Fatalf("timed out waiting for queued usage payload for provider=%q model=%q", wantProvider, wantModel)
+}
+
+type queuedUsagePayload struct {
+ Provider string `json:"provider"`
+ Model string `json:"model"`
+ Failed bool `json:"failed"`
+ Tokens struct {
+ TotalTokens int64 `json:"total_tokens"`
+ } `json:"tokens"`
+}
+
+func parseQueuedUsagePayload(t *testing.T, payload []byte) (queuedUsagePayload, bool) {
+ t.Helper()
+
+ var parsed queuedUsagePayload
+ if len(payload) == 0 {
+ return parsed, false
+ }
+ if err := json.Unmarshal(payload, &parsed); err != nil {
+ return parsed, false
+ }
+ if parsed.Provider == "" || parsed.Model == "" {
+ return parsed, false
+ }
+ return parsed, true
}