From 03a1a656f4689d8234a99e37b47e78089b89889b Mon Sep 17 00:00:00 2001 From: hex2077 Date: Tue, 27 Jan 2026 16:31:23 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=20OpenAI=20Codex=20O?= =?UTF-8?q?Auth=20=E6=94=AF=E6=8C=81=E4=B8=8E=E7=94=A8=E9=87=8F=E6=9F=A5?= =?UTF-8?q?=E8=AF=A2=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加 Codex OAuth 提供商支持,包括核心服务、适配器和策略实现 - 在用量管理页面新增支持用量查询的提供商列表显示 - 为 Codex 添加专用的用量查询接口和格式化显示 - 更新 Docker 配置以暴露 Codex OAuth 回调端口 1455 - 完善多语言文档,记录 Codex 配置和使用方法 - 修复流式响应中工具调用的 finish_reason 处理逻辑 - 增强 AI 监控插件对数组类型 chunk 的处理能力 --- README-JA.md | 14 +- README-ZH.md | 14 +- README.md | 14 +- docker/docker-compose.build.yml | 1 + docker/docker-compose.yml | 1 + src/converters/strategies/ClaudeConverter.js | 585 +++++++- src/converters/strategies/CodexConverter.js | 1224 +++++++++++++---- src/converters/strategies/GeminiConverter.js | 338 ++++- src/converters/strategies/OpenAIConverter.js | 94 +- .../strategies/OpenAIResponsesConverter.js | 887 +++++++++--- src/plugins/ai-monitor/index.js | 19 +- src/providers/adapter.js | 12 + src/providers/gemini/antigravity-core.js | 4 +- src/providers/openai/codex-core.js | 103 +- .../openai/codex-responses-strategy.js | 124 ++ src/services/ui-manager.js | 5 + src/services/usage-service.js | 152 +- src/ui-modules/usage-api.js | 37 +- src/utils/common.js | 36 +- src/utils/provider-strategies.js | 3 +- static/app/i18n.js | 2 + static/app/usage-manager.js | 112 ++ static/components/section-usage.css | 60 + static/components/section-usage.html | 8 + 24 files changed, 3266 insertions(+), 583 deletions(-) create mode 100644 src/providers/openai/codex-responses-strategy.js diff --git a/README-JA.md b/README-JA.md index 7dedf18..ff70e4c 100644 --- a/README-JA.md +++ b/README-JA.md @@ -34,6 +34,7 @@ >
> クリックして詳細なバージョン履歴を展開 > +> - **2026.01.26** - Codexプロトコルサポートを追加:OpenAI Codex OAuth認証での接続に対応 > - **2026.01.25** - AI 監視プラグインの強化:AI プロトコル変換前後のリクエストパラメータとレスポンスの監視をサポート。ログ管理の最適化:統一されたログ形式、ビジュアル設定 > - **2026.01.15** - プロバイダープールマネージャーの最適化:非同期リフレッシュキューメカニズム、バッファキュー重複排除、グローバル並行制御、ノードウォームアップと自動期限切れ検出を追加 > - **2026.01.07** - iFlowプロトコルサポートの追加、OAuth認証方式でQwen、Kimi、DeepSeek、GLMシリーズモデルにアクセス可能、自動トークンリフレッシュ機能をサポート @@ -109,12 +110,12 @@ AIClient-2-APIを使い始める最も推奨される方法は、自動起動ス #### 🐳 Docker クイックスタート (推奨) ```bash -docker run -d -p 3000:3000 -p 8085-8087:8085-8087 -p 19876-19880:19876-19880 --restart=always -v "指定パス:/app/configs" --name aiclient2api justlikemaki/aiclient-2-api +docker run -d -p 3000:3000 -p 8085-8087:8085-8087 -p 1455:1455 -p 19876-19880:19876-19880 --restart=always -v "指定パス:/app/configs" --name aiclient2api justlikemaki/aiclient-2-api ``` **パラメータ説明**: - `-d`:バックグラウンドでコンテナを実行 -- `-p 3000:3000 ...`:ポートマッピング。3000はWeb UI用、その他はOAuthコールバック用(Gemini: 8085, Antigravity: 8086, Kiro: 19876-19880) +- `-p 3000:3000 ...`:ポートマッピング。3000はWeb UI用、その他はOAuthコールバック用(Gemini: 8085, Antigravity: 8086, iFlow: 8087, Codex: 1455, Kiro: 19876-19880) - `--restart=always`:コンテナ自動再起動ポリシー - `-v "指定パス:/app/configs"`:設定ディレクトリをマウント(「指定パス」を実際のパスに置き換えてください、例:`/home/user/aiclient-configs`) - `--name aiclient2api`:コンテナ名 @@ -256,6 +257,12 @@ Web UI管理インターフェースでは、極めて迅速に認証設定を 4. **サポートモデル**:Qwen3シリーズ、Kimi K2、DeepSeek V3/R1、GLM-4.6/4.7など 5. **自動リフレッシュ**:システムはトークンの期限切れが近づくと自動的にリフレッシュ、手動介入不要 +#### Codex OAuth設定 +1. **認証の生成**:Web UIの「プロバイダープール」または「設定管理」ページで、Codexの「認証生成」ボタンをクリック +2. **ブラウザログイン**:システムがOpenAI Codex認証ページを開き、OAuthログインを完了 +3. **自動保存**:認証成功後、システムがCodexのOAuth認証情報ファイルを自動保存 +4. **コールバックポート**:OAuthコールバックポート `1455` が占有されていないことを確認 + #### アカウントプール管理設定 1. **プール設定ファイルの作成**:[provider_pools.json.example](./configs/provider_pools.json.example) を参考に設定ファイルを作成します 2. **プールパラメータの設定**:config.json で `PROVIDER_POOLS_FILE_PATH` を設定し、プール設定ファイルを指定します @@ -278,6 +285,7 @@ Web UI管理インターフェースでは、極めて迅速に認証設定を | **Qwen** | `~/.qwen/oauth_creds.json` | Qwen OAuth認証情報 | | **Antigravity** | `~/.antigravity/oauth_creds.json` | Antigravity OAuth認証情報 (Claude 4.5 Opus サポート) | | **iFlow** | `~/.iflow/oauth_creds.json` | iFlow OAuth認証情報 (Qwen、Kimi、DeepSeek、GLM サポート) | +| **Codex** | `~/.codex/oauth_creds.json` | Codex OAuth認証情報 | > **説明**:`~`はユーザーホームディレクトリを表します(Windows: `C:\Users\ユーザー名`、Linux/macOS: `/home/ユーザー名`または`/Users/ユーザー名`) @@ -455,7 +463,7 @@ curl http://localhost:3000/ollama/api/chat \ **解決策**: - **ネットワーク接続を確認**:Google、アリババクラウドなどのサービスに正常にアクセスできることを確認 -- **ポート占有を確認**:OAuthコールバックには特定のポートが必要です(Gemini: 8085, Antigravity: 8086, Kiro: 19876-19880)、これらのポートが占有されていないことを確認 +- **ポート占有を確認**:OAuthコールバックには特定のポートが必要です(Gemini: 8085, Antigravity: 8086, iFlow: 8087, Codex: 1455, Kiro: 19876-19880)、これらのポートが占有されていないことを確認 - **ブラウザキャッシュをクリア**:シークレットモードを使用するか、ブラウザキャッシュをクリアして再試行 - **ファイアウォール設定を確認**:ファイアウォールがローカルコールバックポートへのアクセスを許可していることを確認 - **Dockerユーザー**:すべてのOAuthコールバックポートが正しくマッピングされていることを確認 diff --git a/README-ZH.md b/README-ZH.md index a7dd727..2ef410d 100644 --- a/README-ZH.md +++ b/README-ZH.md @@ -34,6 +34,7 @@ >
> 点击展开查看详细版本历史 > +> - **2026.01.26** - 新增 Codex 协议支持:支持 OpenAI Codex OAuth 授权接入 > - **2026.01.25** - 增强 AI 监控插件:支持监控 AI 协议转换前后的请求参数和响应。优化日志管理:统一日志格式,可视化配置 > - **2026.01.15** - 优化提供商池管理器:新增异步刷新队列机制、缓冲队列去重、全局并发控制,支持节点预热和自动过期检测 > - **2026.01.07** - 新增 iFlow 协议支持,通过 OAuth 认证方式访问 Qwen、Kimi、DeepSeek 和 GLM 系列模型,支持自动 token 刷新功能 @@ -108,12 +109,12 @@ #### 🐳 Docker 快捷启动 (推荐) ```bash -docker run -d -p 3000:3000 -p 8085-8087:8085-8087 -p 19876-19880:19876-19880 --restart=always -v "指定路径:/app/configs" --name aiclient2api justlikemaki/aiclient-2-api +docker run -d -p 3000:3000 -p 8085-8087:8085-8087 -p 1455:1455 -p 19876-19880:19876-19880 --restart=always -v "指定路径:/app/configs" --name aiclient2api justlikemaki/aiclient-2-api ``` **参数说明**: - `-d`:后台运行容器 -- `-p 3000:3000 ...`:端口映射。3000 为 Web UI,其余为 OAuth 回调端口(Gemini: 8085, Antigravity: 8086, Kiro: 19876-19880) +- `-p 3000:3000 ...`:端口映射。3000 为 Web UI,其余为 OAuth 回调端口(Gemini: 8085, Antigravity: 8086, iFlow: 8087, Codex: 1455, Kiro: 19876-19880) - `--restart=always`:容器自动重启策略 - `-v "指定路径:/app/configs"`:挂载配置目录(请将"指定路径"替换为实际路径,如 `/home/user/aiclient-configs`) - `--name aiclient2api`:容器名称 @@ -255,6 +256,12 @@ docker compose up -d 4. **支持模型**:Qwen3 系列、Kimi K2、DeepSeek V3/R1、GLM-4.6/4.7 等 5. **自动刷新**:系统会在 Token 即将过期时自动刷新,无需手动干预 +#### Codex OAuth 配置 +1. **生成授权**:在 Web UI 的"提供商池"或"配置管理"页面,点击 Codex 的"生成授权"按钮 +2. **浏览器登录**:系统将打开 OpenAI Codex 授权页面,完成 OAuth 登录 +3. **自动保存**:授权成功后,系统会自动保存 Codex 的 OAuth 凭据文件 +4. **回调端口**:确保 OAuth 回调端口 `1455` 未被占用 + #### 账号池管理配置 1. **创建号池配置文件**:参考 [provider_pools.json.example](./configs/provider_pools.json.example) 创建配置文件 2. **配置号池参数**:在 `configs/config.json` 中设置 `PROVIDER_POOLS_FILE_PATH` 指向号池配置文件 @@ -277,6 +284,7 @@ docker compose up -d | **Qwen** | `~/.qwen/oauth_creds.json` | Qwen OAuth 凭据 | | **Antigravity** | `~/.antigravity/oauth_creds.json` | Antigravity OAuth 凭据 (支持 Claude 4.5 Opus) | | **iFlow** | `~/.iflow/oauth_creds.json` | iFlow OAuth 凭据 (支持 Qwen、Kimi、DeepSeek、GLM) | +| **Codex** | `~/.codex/oauth_creds.json` | Codex OAuth 凭据 | > **说明**:`~` 表示用户主目录(Windows: `C:\Users\用户名`,Linux/macOS: `/home/用户名` 或 `/Users/用户名`) @@ -454,7 +462,7 @@ curl http://localhost:3000/ollama/api/chat \ **解决方案**: - **检查网络连接**:确保能够正常访问 Google、阿里云等服务 -- **检查端口占用**:OAuth 回调需要特定端口(Gemini: 8085, Antigravity: 8086, Kiro: 19876-19880),确保这些端口未被占用 +- **检查端口占用**:OAuth 回调需要特定端口(Gemini: 8085, Antigravity: 8086, iFlow: 8087, Codex: 1455, Kiro: 19876-19880),确保这些端口未被占用 - **清除浏览器缓存**:尝试使用无痕模式或清除浏览器缓存后重试 - **检查防火墙设置**:确保防火墙允许本地回调端口的访问 - **Docker 用户**:确保已正确映射所有 OAuth 回调端口 diff --git a/README.md b/README.md index d70b533..52b77fb 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ >
> Click to expand detailed version history > +> - **2026.01.26** - Added Codex protocol support: supports OpenAI Codex OAuth authorization access > - **2026.01.25** - Enhanced AI Monitor plugin: supports monitoring request parameters and responses before and after AI protocol conversion. Optimized log management: unified log format, visual configuration > - **2026.01.15** - Optimized provider pool manager: added async refresh queue mechanism, buffer queue deduplication, global concurrency control, node warmup and automatic expiry detection > - **2026.01.07** - Added iFlow protocol support, enabling access to Qwen, Kimi, DeepSeek, and GLM series models via OAuth authentication with automatic token refresh @@ -109,12 +110,12 @@ The most recommended way to use AIClient-2-API is to start it through an automat #### 🐳 Docker Quick Start (Recommended) ```bash -docker run -d -p 3000:3000 -p 8085-8087:8085-8087 -p 19876-19880:19876-19880 --restart=always -v "your_path:/app/configs" --name aiclient2api justlikemaki/aiclient-2-api +docker run -d -p 3000:3000 -p 8085-8087:8085-8087 -p 1455:1455 -p 19876-19880:19876-19880 --restart=always -v "your_path:/app/configs" --name aiclient2api justlikemaki/aiclient-2-api ``` **Parameter Description**: - `-d`: Run container in background -- `-p 3000:3000 ...`: Port mapping. 3000 is for Web UI, others are for OAuth callbacks (Gemini: 8085, Antigravity: 8086, Kiro: 19876-19880) +- `-p 3000:3000 ...`: Port mapping. 3000 is for Web UI, others are for OAuth callbacks (Gemini: 8085, Antigravity: 8086, iFlow: 8087, Codex: 1455, Kiro: 19876-19880) - `--restart=always`: Container auto-restart policy - `-v "your_path:/app/configs"`: Mount configuration directory (replace "your_path" with actual path, e.g., `/home/user/aiclient-configs`) - `--name aiclient2api`: Container name @@ -256,6 +257,12 @@ In the Web UI management interface, you can complete authorization configuration 4. **Supported Models**: Qwen3 series, Kimi K2, DeepSeek V3/R1, GLM-4.6/4.7, etc. 5. **Auto Refresh**: The system will automatically refresh tokens when they are about to expire, no manual intervention required +#### Codex OAuth Configuration +1. **Generate Authorization**: On the Web UI "Provider Pools" or "Configuration" page, click the "Generate Authorization" button for Codex +2. **Browser Login**: The system opens the OpenAI Codex authorization page to complete OAuth login +3. **Auto Save**: After successful authorization, the system automatically saves the Codex OAuth credential file +4. **Callback Port**: Ensure the OAuth callback port `1455` is not occupied + #### Account Pool Management Configuration 1. **Create Pool Configuration File**: Create a configuration file referencing [provider_pools.json.example](./configs/provider_pools.json.example) 2. **Configure Pool Parameters**: Set `PROVIDER_POOLS_FILE_PATH` in `configs/config.json` to point to the pool configuration file @@ -278,6 +285,7 @@ Default storage locations for authorization credential files of each service: | **Qwen** | `~/.qwen/oauth_creds.json` | Qwen OAuth credentials | | **Antigravity** | `~/.antigravity/oauth_creds.json` | Antigravity OAuth credentials (supports Claude 4.5 Opus) | | **iFlow** | `~/.iflow/oauth_creds.json` | iFlow OAuth credentials (supports Qwen, Kimi, DeepSeek, GLM) | +| **Codex** | `~/.codex/oauth_creds.json` | Codex OAuth credentials | > **Note**: `~` represents the user home directory (Windows: `C:\Users\username`, Linux/macOS: `/home/username` or `/Users/username`) @@ -455,7 +463,7 @@ When all accounts under a Provider Type (e.g., `gemini-cli-oauth`) are exhausted **Solutions**: - **Check Network Connection**: Ensure you can access Google, Alibaba Cloud, and other services normally -- **Check Port Occupation**: OAuth callbacks require specific ports (Gemini: 8085, Antigravity: 8086, Kiro: 19876-19880), ensure these ports are not occupied +- **Check Port Occupation**: OAuth callbacks require specific ports (Gemini: 8085, Antigravity: 8086, iFlow: 8087, Codex: 1455, Kiro: 19876-19880), ensure these ports are not occupied - **Clear Browser Cache**: Try using incognito mode or clearing browser cache and retry - **Check Firewall Settings**: Ensure the firewall allows access to local callback ports - **Docker Users**: Ensure all OAuth callback ports are correctly mapped diff --git a/docker/docker-compose.build.yml b/docker/docker-compose.build.yml index d30ee02..31fa518 100644 --- a/docker/docker-compose.build.yml +++ b/docker/docker-compose.build.yml @@ -10,6 +10,7 @@ services: ports: - "3000:3000" - "8085-8087:8085-8087" + - "1455:1455" - "19876-19880:19876-19880" volumes: - ./configs:/app/configs diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index a295400..6977d13 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -8,6 +8,7 @@ services: ports: - "3000:3000" - "8085-8087:8085-8087" + - "1455:1455" - "19876-19880:19876-19880" volumes: - ./configs:/app/configs diff --git a/src/converters/strategies/ClaudeConverter.js b/src/converters/strategies/ClaudeConverter.js index 3743ff7..975002f 100644 --- a/src/converters/strategies/ClaudeConverter.js +++ b/src/converters/strategies/ClaudeConverter.js @@ -51,6 +51,8 @@ export class ClaudeConverter extends BaseConverter { return this.toGeminiRequest(data); case MODEL_PROTOCOL_PREFIX.OPENAI_RESPONSES: return this.toOpenAIResponsesRequest(data); + case MODEL_PROTOCOL_PREFIX.CODEX: + return this.toCodexRequest(data); default: throw new Error(`Unsupported target protocol: ${targetProtocol}`); } @@ -67,6 +69,8 @@ export class ClaudeConverter extends BaseConverter { return this.toGeminiResponse(data, model); case MODEL_PROTOCOL_PREFIX.OPENAI_RESPONSES: return this.toOpenAIResponsesResponse(data, model); + case MODEL_PROTOCOL_PREFIX.CODEX: + return this.toCodexResponse(data, model); default: throw new Error(`Unsupported target protocol: ${targetProtocol}`); } @@ -83,6 +87,8 @@ export class ClaudeConverter extends BaseConverter { return this.toGeminiStreamChunk(chunk, model); case MODEL_PROTOCOL_PREFIX.OPENAI_RESPONSES: return this.toOpenAIResponsesStreamChunk(chunk, model); + case MODEL_PROTOCOL_PREFIX.CODEX: + return this.toCodexStreamChunk(chunk, model); default: throw new Error(`Unsupported target protocol: ${targetProtocol}`); } @@ -542,7 +548,7 @@ export class ClaudeConverter extends BaseConverter { stopReason === 'tool_use' ? 'tool_calls' : stopReason || 'stop'; - return { + const chunk = { id: chunkId, object: "chat.completion.chunk", created: timestamp, @@ -552,8 +558,11 @@ export class ClaudeConverter extends BaseConverter { index: 0, delta: {}, finish_reason: finishReason - }], - usage: claudeChunk.usage ? { + }] + }; + + if(claudeChunk.usage){ + chunk.usage = { prompt_tokens: claudeChunk.usage.input_tokens || 0, completion_tokens: claudeChunk.usage.output_tokens || 0, total_tokens: (claudeChunk.usage.input_tokens || 0) + (claudeChunk.usage.output_tokens || 0), @@ -561,24 +570,28 @@ export class ClaudeConverter extends BaseConverter { prompt_tokens_details: { cached_tokens: claudeChunk.usage.cache_read_input_tokens || 0 } - } : undefined - }; + }; + } + + return chunk; } // message_stop 事件 if (claudeChunk.type === 'message_stop') { - return { - id: chunkId, - object: "chat.completion.chunk", - created: timestamp, - model: model, - system_fingerprint: "", - choices: [{ - index: 0, - delta: {}, - finish_reason: 'stop' - }] - }; + return null; + // const chunk = { + // id: chunkId, + // object: "chat.completion.chunk", + // created: timestamp, + // model: model, + // system_fingerprint: "", + // choices: [{ + // index: 0, + // delta: {}, + // finish_reason: 'stop' + // }] + // }; + // return chunk; } // 兼容旧格式:如果是字符串,直接作为文本内容 @@ -817,7 +830,7 @@ export class ClaudeConverter extends BaseConverter { } break; - // [FIX] 参考 ag/request.rs 添加 thinking 块处理 + // 添加 thinking 块处理 case 'thinking': if (typeof block.thinking === 'string' && block.thinking.length > 0) { const thinkingPart = { @@ -878,7 +891,7 @@ export class ClaudeConverter extends BaseConverter { case 'tool_result': // 转换为 Gemini functionResponse 格式 - // [FIX] 参考 ag/request.rs 的实现,正确处理 tool_use_id 到函数名的映射 + // 的实现,正确处理 tool_use_id 到函数名的映射 const toolCallId = block.tool_use_id; if (toolCallId) { // 尝试从之前的 tool_use 块中查找对应的函数名 @@ -902,7 +915,7 @@ export class ClaudeConverter extends BaseConverter { // 获取响应数据 let responseData = block.content; - // [FIX] 参考 ag/request.rs 的 tool_result_compressor 逻辑 + // 的 tool_result_compressor 逻辑 // 处理嵌套的 content 数组(如图片等) if (Array.isArray(responseData)) { // 提取文本内容 @@ -1103,7 +1116,7 @@ export class ClaudeConverter extends BaseConverter { } break; - // [FIX] 参考 ag/response.rs 添加 thinking 块处理 + // 添加 thinking 块处理 case 'thinking': if (block.thinking) { const thinkingPart = { @@ -1435,22 +1448,120 @@ export class ClaudeConverter extends BaseConverter { * Claude请求 -> OpenAI Responses请求 */ toOpenAIResponsesRequest(claudeRequest) { - // 转换为OpenAI Responses格式 const responsesRequest = { model: claudeRequest.model, - max_tokens: checkAndAssignOrDefault(claudeRequest.max_tokens, OPENAI_DEFAULT_MAX_TOKENS), - temperature: checkAndAssignOrDefault(claudeRequest.temperature, OPENAI_DEFAULT_TEMPERATURE), - top_p: checkAndAssignOrDefault(claudeRequest.top_p, OPENAI_DEFAULT_TOP_P), + instructions: '', + input: [], + stream: claudeRequest.stream || false, + max_output_tokens: claudeRequest.max_tokens, + temperature: claudeRequest.temperature, + top_p: claudeRequest.top_p }; // 处理系统指令 if (claudeRequest.system) { - responsesRequest.instructions = claudeRequest.system; + if (Array.isArray(claudeRequest.system)) { + responsesRequest.instructions = claudeRequest.system.map(s => typeof s === 'string' ? s : s.text).join('\n'); + } else { + responsesRequest.instructions = claudeRequest.system; + } + } + + // 处理 thinking 配置 + if (claudeRequest.thinking && claudeRequest.thinking.type === 'enabled') { + responsesRequest.reasoning = { + effort: determineReasoningEffortFromBudget(claudeRequest.thinking.budget_tokens) + }; } // 处理消息 if (claudeRequest.messages && Array.isArray(claudeRequest.messages)) { - responsesRequest.input = claudeRequest.messages; + claudeRequest.messages.forEach(msg => { + const role = msg.role; + const content = msg.content; + + if (Array.isArray(content)) { + // 检查是否包含 tool_result + const toolResult = content.find(c => c.type === 'tool_result'); + if (toolResult) { + responsesRequest.input.push({ + type: 'function_call_output', + call_id: toolResult.tool_use_id, + output: typeof toolResult.content === 'string' ? toolResult.content : JSON.stringify(toolResult.content) + }); + return; + } + + // 检查是否包含 tool_use + const toolUse = content.find(c => c.type === 'tool_use'); + if (toolUse) { + responsesRequest.input.push({ + type: 'function_call', + call_id: toolUse.id, + name: toolUse.name, + arguments: typeof toolUse.input === 'string' ? toolUse.input : JSON.stringify(toolUse.input) + }); + return; + } + + const responsesContent = content.map(c => { + if (c.type === 'text') { + return { + type: role === 'assistant' ? 'output_text' : 'input_text', + text: c.text + }; + } else if (c.type === 'image') { + return { + type: 'input_image', + image_url: { + url: `data:${c.source.media_type};base64,${c.source.data}` + } + }; + } + return null; + }).filter(Boolean); + + if (responsesContent.length > 0) { + responsesRequest.input.push({ + type: 'message', + role: role, + content: responsesContent + }); + } + } else if (typeof content === 'string') { + responsesRequest.input.push({ + type: 'message', + role: role, + content: [{ + type: role === 'assistant' ? 'output_text' : 'input_text', + text: content + }] + }); + } + }); + } + + // 处理工具 + if (claudeRequest.tools && Array.isArray(claudeRequest.tools)) { + responsesRequest.tools = claudeRequest.tools.map(tool => ({ + type: 'function', + name: tool.name, + description: tool.description, + parameters: tool.input_schema || { type: 'object', properties: {} } + })); + } + + if (claudeRequest.tool_choice) { + if (claudeRequest.tool_choice.type === 'auto') { + responsesRequest.tool_choice = 'auto'; + } else if (claudeRequest.tool_choice.type === 'any') { + responsesRequest.tool_choice = 'required'; + } else if (claudeRequest.tool_choice.type === 'tool') { + responsesRequest.tool_choice = { + type: 'function', + function: { name: claudeRequest.tool_choice.name } + }; + } } return responsesRequest; @@ -1648,6 +1759,426 @@ export class ClaudeConverter extends BaseConverter { return events; } + + // ========================================================================= + // Claude -> Codex 转换 + // ========================================================================= + + /** + * 应用简单缩短规则缩短工具名称 + */ + _shortenNameIfNeeded(name) { + const limit = 64; + if (name.length <= limit) { + return name; + } + if (name.startsWith("mcp__")) { + const idx = name.lastIndexOf("__"); + if (idx > 0) { + const cand = "mcp__" + name.substring(idx + 2); + if (cand.length > limit) { + return cand.substring(0, limit); + } + return cand; + } + } + return name.substring(0, limit); + } + + /** + * 构建短名称映射以确保请求内唯一性 + */ + _buildShortNameMap(names) { + const limit = 64; + const used = new Set(); + const m = {}; + + const baseCandidate = (n) => { + if (n.length <= limit) { + return n; + } + if (n.startsWith("mcp__")) { + const idx = n.lastIndexOf("__"); + if (idx > 0) { + let cand = "mcp__" + n.substring(idx + 2); + if (cand.length > limit) { + cand = cand.substring(0, limit); + } + return cand; + } + } + return n.substring(0, limit); + }; + + const makeUnique = (cand) => { + if (!used.has(cand)) { + return cand; + } + const base = cand; + for (let i = 1; ; i++) { + const suffix = "_" + i; + const allowed = limit - suffix.length; + let tmp = base; + if (tmp.length > (allowed < 0 ? 0 : allowed)) { + tmp = tmp.substring(0, allowed < 0 ? 0 : allowed); + } + tmp = tmp + suffix; + if (!used.has(tmp)) { + return tmp; + } + } + }; + + for (const n of names) { + const cand = baseCandidate(n); + const uniq = makeUnique(cand); + used.add(uniq); + m[n] = uniq; + } + return m; + } + + /** + * 标准化工具参数,确保对象 Schema 包含 properties + */ + _normalizeToolParameters(schema) { + if (!schema || typeof schema !== 'object') { + return { type: 'object', properties: {} }; + } + const result = { ...schema }; + if (!result.type) { + result.type = 'object'; + } + if (result.type === 'object' && !result.properties) { + result.properties = {}; + } + return result; + } + + /** + * Claude请求 -> Codex请求 + */ + toCodexRequest(claudeRequest) { + const codexRequest = { + model: claudeRequest.model, + instructions: '', + input: [], + stream: true, + store: false, + parallel_tool_calls: true, + reasoning: { + effort: claudeRequest.reasoning?.effort || 'medium', + summary: 'auto' + }, + include: ['reasoning.encrypted_content'] + }; + + // 处理系统指令 + if (claudeRequest.system) { + let instructions = ''; + if (Array.isArray(claudeRequest.system)) { + instructions = claudeRequest.system.map(s => typeof s === 'string' ? s : s.text).join('\n'); + } else { + instructions = claudeRequest.system; + } + codexRequest.instructions = instructions; + + // 处理 Codex 中的系统消息(作为 developer 角色添加到 input) + const systemParts = Array.isArray(claudeRequest.system) ? claudeRequest.system : [{ type: 'text', text: claudeRequest.system }]; + const developerMessage = { + type: 'message', + role: 'developer', + content: [] + }; + + systemParts.forEach(part => { + if (part.type === 'text') { + developerMessage.content.push({ + type: 'input_text', + text: part.text + }); + } else if (typeof part === 'string') { + developerMessage.content.push({ + type: 'input_text', + text: part + }); + } + }); + + if (developerMessage.content.length > 0) { + codexRequest.input.push(developerMessage); + } + } + + // 处理工具并构建短名称映射 + let shortMap = {}; + if (claudeRequest.tools && Array.isArray(claudeRequest.tools)) { + const toolNames = claudeRequest.tools.map(t => t.name).filter(Boolean); + shortMap = this._buildShortNameMap(toolNames); + + codexRequest.tools = claudeRequest.tools.map(tool => { + // 特殊处理:将 Claude Web Search 工具映射到 Codex web_search + if (tool.type === "web_search_20250305") { + return { type: "web_search" }; + } + + let name = tool.name; + if (shortMap[name]) { + name = shortMap[name]; + } else { + name = this._shortenNameIfNeeded(name); + } + + const convertedTool = { + type: 'function', + name: name, + description: tool.description || '', + parameters: this._normalizeToolParameters(tool.input_schema), + strict: false + }; + + // 移除 parameters.$schema + if (convertedTool.parameters && convertedTool.parameters.$schema) { + delete convertedTool.parameters.$schema; + } + + return convertedTool; + }); + codexRequest.tool_choice = "auto"; + } + + // 处理消息 + if (claudeRequest.messages && Array.isArray(claudeRequest.messages)) { + for (const msg of claudeRequest.messages) { + const role = msg.role; + const content = msg.content; + + let currentMessage = { + type: 'message', + role: role, + content: [] + }; + + const flushMessage = () => { + if (currentMessage.content.length > 0) { + codexRequest.input.push({ ...currentMessage }); + currentMessage.content = []; + } + }; + + const appendTextContent = (text) => { + const partType = role === 'assistant' ? 'output_text' : 'input_text'; + currentMessage.content.push({ + type: partType, + text: text + }); + }; + + const appendImageContent = (data, mediaType) => { + currentMessage.content.push({ + type: 'input_image', + image_url: `data:${mediaType};base64,${data}` + }); + }; + + if (Array.isArray(content)) { + for (const block of content) { + switch (block.type) { + case 'text': + appendTextContent(block.text); + break; + case 'image': + if (block.source) { + const data = block.source.data || block.source.base64 || ''; + const mediaType = block.source.media_type || block.source.mime_type || 'application/octet-stream'; + if (data) { + appendImageContent(data, mediaType); + } + } + break; + case 'tool_use': + flushMessage(); + let toolName = block.name; + if (shortMap[toolName]) { + toolName = shortMap[toolName]; + } else { + toolName = this._shortenNameIfNeeded(toolName); + } + codexRequest.input.push({ + type: 'function_call', + call_id: block.id, + name: toolName, + arguments: typeof block.input === 'string' ? block.input : JSON.stringify(block.input || {}) + }); + break; + case 'tool_result': + flushMessage(); + codexRequest.input.push({ + type: 'function_call_output', + call_id: block.tool_use_id, + output: typeof block.content === 'string' ? block.content : JSON.stringify(block.content || "") + }); + break; + } + } + } else if (typeof content === 'string') { + appendTextContent(content); + } + flushMessage(); + } + } + + // 处理 thinking 转换 + if (claudeRequest.thinking && claudeRequest.thinking.type === "enabled") { + const budgetTokens = claudeRequest.thinking.budget_tokens; + codexRequest.reasoning.effort = determineReasoningEffortFromBudget(budgetTokens); + } else if (claudeRequest.thinking && claudeRequest.thinking.type === "disabled") { + codexRequest.reasoning.effort = determineReasoningEffortFromBudget(0); + } + + // 注入 Codex 指令 (对应 末尾的特殊逻辑) + // 注意:这里需要检查是否需要注入 "EXECUTE ACCORDING TO THE FOLLOWING INSTRUCTIONS!!!" + // 通过 misc.GetCodexInstructionsEnabled() 判断,这里我们参考其逻辑 + const shouldInjectInstructions = process.env.CODEX_INSTRUCTIONS_ENABLED === 'true'; // 假设环境变量控制 + if (shouldInjectInstructions && codexRequest.input.length > 0) { + const firstInput = codexRequest.input[0]; + const firstText = firstInput.content && firstInput.content[0] && firstInput.content[0].text; + const instructions = "EXECUTE ACCORDING TO THE FOLLOWING INSTRUCTIONS!!!"; + if (firstText !== instructions) { + codexRequest.input.unshift({ + type: 'message', + role: 'user', + content: [{ + type: 'input_text', + text: instructions + }] + }); + } + } + + return codexRequest; + } + + /** + * Claude响应 -> Codex响应 (实际上是 Codex 转 Claude) + */ + toCodexResponse(codexResponse, model) { + const content = []; + let stopReason = "end_turn"; + + if (codexResponse.response?.output) { + codexResponse.response.output.forEach(item => { + if (item.type === 'message' && item.content) { + const textPart = item.content.find(c => c.type === 'output_text'); + if (textPart) content.push({ type: 'text', text: textPart.text }); + } else if (item.type === 'reasoning' && item.summary) { + const textPart = item.summary.find(c => c.type === 'summary_text'); + if (textPart) content.push({ type: 'thinking', thinking: textPart.text }); + } else if (item.type === 'function_call') { + stopReason = "tool_use"; + content.push({ + type: 'tool_use', + id: item.call_id, + name: item.name, + input: typeof item.arguments === 'string' ? JSON.parse(item.arguments) : item.arguments + }); + } + }); + } + + return { + id: codexResponse.response?.id || `msg_${uuidv4().replace(/-/g, '')}`, + type: "message", + role: "assistant", + model: model, + content: content, + stop_reason: stopReason, + usage: { + input_tokens: codexResponse.response?.usage?.input_tokens || 0, + output_tokens: codexResponse.response?.usage?.output_tokens || 0 + } + }; + } + + /** + * Claude流式响应 -> Codex流式响应 (实际上是 Codex 转 Claude) + */ + toCodexStreamChunk(codexChunk, model) { + const type = codexChunk.type; + const resId = codexChunk.response?.id || 'default'; + + if (type === 'response.created') { + return { + type: "message_start", + message: { + id: codexChunk.response.id, + type: "message", + role: "assistant", + model: model, + usage: { input_tokens: 0, output_tokens: 0 } + } + }; + } + + if (type === 'response.reasoning_summary_text.delta') { + return { + type: "content_block_delta", + index: 0, + delta: { type: "thinking_delta", thinking: codexChunk.delta } + }; + } + + if (type === 'response.output_text.delta') { + return { + type: "content_block_delta", + index: 0, + delta: { type: "text_delta", text: codexChunk.delta } + }; + } + + if (type === 'response.output_item.done' && codexChunk.item?.type === 'function_call') { + return [ + { + type: "content_block_start", + index: 0, + content_block: { + type: "tool_use", + id: codexChunk.item.call_id, + name: codexChunk.item.name, + input: {} + } + }, + { + type: "content_block_delta", + index: 0, + delta: { + type: "input_json_delta", + partial_json: typeof codexChunk.item.arguments === 'string' ? codexChunk.item.arguments : JSON.stringify(codexChunk.item.arguments) + } + }, + { + type: "content_block_stop", + index: 0 + } + ]; + } + + if (type === 'response.completed') { + return [ + { + type: "message_delta", + delta: { stop_reason: "end_turn" }, + usage: { + input_tokens: codexChunk.response.usage?.input_tokens || 0, + output_tokens: codexChunk.response.usage?.output_tokens || 0 + } + }, + { type: "message_stop" } + ]; + } + + return null; + } } export default ClaudeConverter; \ No newline at end of file diff --git a/src/converters/strategies/CodexConverter.js b/src/converters/strategies/CodexConverter.js index 3f3375c..7feb441 100644 --- a/src/converters/strategies/CodexConverter.js +++ b/src/converters/strategies/CodexConverter.js @@ -3,26 +3,36 @@ * 处理 OpenAI 协议与 Codex 协议之间的转换 */ -import crypto from 'crypto'; +import { v4 as uuidv4 } from 'uuid'; import { BaseConverter } from '../BaseConverter.js'; import { MODEL_PROTOCOL_PREFIX } from '../../utils/common.js'; +import { + generateResponseCreated, + generateResponseInProgress, + generateOutputItemAdded, + generateContentPartAdded, + generateOutputTextDone, + generateContentPartDone, + generateOutputItemDone, + generateResponseCompleted +} from '../../providers/openai/openai-responses-core.mjs'; export class CodexConverter extends BaseConverter { constructor() { super('codex'); - this.toolNameMap = new Map(); // 工具名称缩短/恢复映射 - this.reverseToolNameMap = new Map(); // 反向映射 + this.toolNameMap = new Map(); // 工具名称缩短映射: original -> short + this.reverseToolNameMap = new Map(); // 反向映射: short -> original + this.streamParams = new Map(); // 用于存储流式状态,key 为响应 ID 或临时标识 } /** * 转换请求 */ convertRequest(data, targetProtocol) { - if (targetProtocol === 'codex') { + if (targetProtocol === MODEL_PROTOCOL_PREFIX.CODEX) { return this.toCodexRequest(data); - } else if (targetProtocol === MODEL_PROTOCOL_PREFIX.OPENAI) { - // Codex → OpenAI (通常不需要,因为 Codex 响应会直接转换) - return data; + } else if (targetProtocol === MODEL_PROTOCOL_PREFIX.OPENAI_RESPONSES) { + return this.toOpenAIResponsesRequest(data); } throw new Error(`Unsupported target protocol: ${targetProtocol}`); } @@ -31,62 +41,130 @@ export class CodexConverter extends BaseConverter { * 转换响应 */ convertResponse(data, targetProtocol, model) { - if (targetProtocol === MODEL_PROTOCOL_PREFIX.OPENAI) { - return this.toOpenAIResponse(data, model); + switch (targetProtocol) { + case MODEL_PROTOCOL_PREFIX.OPENAI: + return this.toOpenAIResponse(data, model); + case MODEL_PROTOCOL_PREFIX.OPENAI_RESPONSES: + return this.toOpenAIResponsesResponse(data, model); + case MODEL_PROTOCOL_PREFIX.GEMINI: + return this.toGeminiResponse(data, model); + case MODEL_PROTOCOL_PREFIX.CLAUDE: + return this.toClaudeResponse(data, model); + case MODEL_PROTOCOL_PREFIX.CODEX: + return data; // Codex to Codex + default: + throw new Error(`Unsupported target protocol: ${targetProtocol}`); } - throw new Error(`Unsupported target protocol: ${targetProtocol}`); + } + + /** + * OpenAI Responses → Codex 请求转换 (或处理已经转换好的数据) + */ + toOpenAIResponsesRequest(data) { + // 如果输入已经是 OpenAI Responses 格式,将其转换为 Codex 格式 + return this.toCodexRequest(data); } /** * 转换流式响应块 */ convertStreamChunk(chunk, targetProtocol, model) { - if (targetProtocol === MODEL_PROTOCOL_PREFIX.OPENAI) { - return this.toOpenAIStreamChunk(chunk, model); + switch (targetProtocol) { + case MODEL_PROTOCOL_PREFIX.OPENAI: + return this.toOpenAIStreamChunk(chunk, model); + case MODEL_PROTOCOL_PREFIX.OPENAI_RESPONSES: + return this.toOpenAIResponsesStreamChunk(chunk, model); + case MODEL_PROTOCOL_PREFIX.GEMINI: + return this.toGeminiStreamChunk(chunk, model); + case MODEL_PROTOCOL_PREFIX.CLAUDE: + return this.toClaudeStreamChunk(chunk, model); + default: + throw new Error(`Unsupported target protocol: ${targetProtocol}`); } - throw new Error(`Unsupported target protocol: ${targetProtocol}`); } /** * OpenAI → Codex 请求转换 */ toCodexRequest(data) { + // 构建工具名称映射 + this.buildToolNameMap(data.tools || []); + const codexRequest = { model: data.model, instructions: this.buildInstructions(data), input: this.convertMessages(data.messages || []), - stream: data.stream || false, + stream: true, store: false, reasoning: { - effort: 'medium', + effort: data.reasoning_effort || 'medium', summary: 'auto' }, parallel_tool_calls: true, include: ['reasoning.encrypted_content'] }; - // 添加工具 + // 处理 OpenAI Responses 特有的 instructions 和 input 字段(如果存在) + if (data.instructions && !codexRequest.instructions) { + codexRequest.instructions = data.instructions; + } + + if (data.input && Array.isArray(data.input) && codexRequest.input.length === 0) { + // 如果是 OpenAI Responses 格式的 input + for (const item of data.input) { + if (item.type === 'message') { + codexRequest.input.push({ + type: 'message', + role: item.role === 'system' ? 'developer' : item.role, + content: Array.isArray(item.content) ? item.content.map(c => ({ + type: item.role === 'assistant' ? 'output_text' : 'input_text', + text: c.text + })) : [{ + type: item.role === 'assistant' ? 'output_text' : 'input_text', + text: item.content + }] + }); + } + } + } + if (data.tools && data.tools.length > 0) { codexRequest.tools = this.convertTools(data.tools); - codexRequest.tool_choice = data.tool_choice || 'auto'; } - // 添加响应格式 - if (data.response_format) { - codexRequest.text = { - format: this.convertResponseFormat(data.response_format) - }; + if (data.tool_choice) { + codexRequest.tool_choice = this.convertToolChoice(data.tool_choice); } - // 添加推理强度(如果指定) - if (data.reasoning_effort) { - codexRequest.reasoning.effort = data.reasoning_effort; + if (data.response_format || data.text?.verbosity) { + const textObj = {}; + if (data.response_format) { + textObj.format = this.convertResponseFormat(data.response_format); + } + if (data.text?.verbosity) { + textObj.verbosity = data.text.verbosity; + } + codexRequest.text = textObj; } - /* - Codex doesn't support temperature, top_p, top_k, max_tokens anymore in the new protocol. - Removed mapping for these fields. - */ + // 在 input 开头注入特殊指令(如果配置允许) + // 这里我们默认开启,因为这是为了确保 Codex 遵循指令 + if (codexRequest.input.length > 0 && codexRequest.instructions) { + const firstMsg = codexRequest.input[0]; + const specialInstruction = "EXECUTE ACCORDING TO THE FOLLOWING INSTRUCTIONS!!!"; + const firstText = firstMsg.content?.[0]?.text; + + if (firstMsg.role === 'user' && firstText !== specialInstruction) { + codexRequest.input.unshift({ + type: "message", + role: "user", + content: [{ + type: "input_text", + text: specialInstruction + }] + }); + } + } return codexRequest; } @@ -95,22 +173,22 @@ export class CodexConverter extends BaseConverter { * 构建指令 */ buildInstructions(data) { - // 提取系统消息 + // 首先检查显式的 instructions 字段 (OpenAI Responses) + if (data.instructions) return data.instructions; + const systemMessages = (data.messages || []).filter(m => m.role === 'system'); if (systemMessages.length > 0) { return systemMessages.map(m => { if (typeof m.content === 'string') { return m.content; } else if (Array.isArray(m.content)) { - return m.content - .filter(part => part.type === 'text') - .map(part => part.text) - .join('\n'); + const textPart = m.content.find(part => part.type === 'text'); + return textPart ? textPart.text : ''; } return ''; - }).join('\n'); + }).join('\n').trim(); } - return 'You are a helpful assistant.'; + return ''; } /** @@ -118,34 +196,58 @@ export class CodexConverter extends BaseConverter { */ convertMessages(messages) { const input = []; - const nonSystemMessages = messages.filter(m => m.role !== 'system'); - for (const msg of nonSystemMessages) { - if (msg.role === 'user' || msg.role === 'assistant') { - input.push({ - type: 'message', - role: msg.role === 'assistant' ? 'developer' : msg.role, - content: this.convertMessageContent(msg.content, msg.role) - }); + for (const msg of messages) { + const role = msg.role; - // 处理助手消息中的工具调用 - if (msg.role === 'assistant' && msg.tool_calls) { - for (const toolCall of msg.tool_calls) { - const shortName = this.getShortToolName(toolCall.function.name); - input.push({ - type: 'function_call', - call_id: toolCall.id, - name: shortName, - arguments: JSON.parse(toolCall.function.arguments) - }); - } - } - } else if (msg.role === 'tool') { + if (role === 'tool' || role === 'tool_result') { input.push({ type: 'function_call_output', - call_id: msg.tool_call_id, - output: msg.content + call_id: msg.tool_call_id || msg.tool_use_id, + output: typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content) }); + } else { + const codexMsg = { + type: 'message', + role: role === 'system' ? 'developer' : (role === 'model' ? 'assistant' : role), + content: this.convertMessageContent(msg.content, role) + }; + + if (codexMsg.content.length > 0) { + input.push(codexMsg); + } + + if ((role === 'assistant' || role === 'model') && msg.tool_calls) { + for (const toolCall of msg.tool_calls) { + if (toolCall.type === 'function' || toolCall.function) { + const func = toolCall.function || toolCall; + const originalName = func.name; + const shortName = this.toolNameMap.get(originalName) || this.shortenToolName(originalName); + input.push({ + type: 'function_call', + call_id: toolCall.id, + name: shortName, + arguments: typeof func.arguments === 'string' ? func.arguments : JSON.stringify(func.arguments) + }); + } + } + } + + // 处理 Claude 格式的 tool_use + if (role === 'assistant' && Array.isArray(msg.content)) { + for (const part of msg.content) { + if (part.type === 'tool_use') { + const originalName = part.name; + const shortName = this.toolNameMap.get(originalName) || this.shortenToolName(originalName); + input.push({ + type: 'function_call', + call_id: part.id, + name: shortName, + arguments: typeof part.input === 'string' ? part.input : JSON.stringify(part.input) + }); + } + } + } } } @@ -156,90 +258,172 @@ export class CodexConverter extends BaseConverter { * 转换消息内容 */ convertMessageContent(content, role) { + if (!content) return []; + + const isAssistant = role === 'assistant' || role === 'model'; + if (typeof content === 'string') { return [{ - type: role === 'user' ? 'input_text' : 'output_text', + type: isAssistant ? 'output_text' : 'input_text', text: content }]; } if (Array.isArray(content)) { return content.map(part => { - if (part.type === 'text') { + if (typeof part === 'string') { return { - type: role === 'user' ? 'input_text' : 'output_text', - text: part.text - }; - } else if (part.type === 'image_url') { - return { - type: 'input_image', - image_url: part.image_url.url + type: isAssistant ? 'output_text' : 'input_text', + text: part }; } - return part; - }); + if (part.type === 'text') { + return { + type: isAssistant ? 'output_text' : 'input_text', + text: part.text + }; + } else if ((part.type === 'image_url' || part.type === 'image') && !isAssistant) { + let url = ''; + if (part.image_url) { + url = typeof part.image_url === 'string' ? part.image_url : part.image_url.url; + } else if (part.source && part.source.type === 'base64') { + url = `data:${part.source.media_type};base64,${part.source.data}`; + } + return url ? { + type: 'input_image', + image_url: url + } : null; + } + return null; + }).filter(Boolean); } return []; } /** - * 转换工具 + * 构建工具名称映射 */ - convertTools(tools) { + buildToolNameMap(tools) { this.toolNameMap.clear(); this.reverseToolNameMap.clear(); + const names = []; + for (const t of tools) { + if (t.type === 'function' && t.function?.name) { + names.push(t.function.name); + } else if (t.name) { + names.push(t.name); + } + } + + if (names.length === 0) return; + + const limit = 64; + const used = new Set(); + + const baseCandidate = (n) => { + if (n.length <= limit) return n; + if (n.startsWith('mcp__')) { + const idx = n.lastIndexOf('__'); + if (idx > 0) { + let cand = 'mcp__' + n.slice(idx + 2); + return cand.length > limit ? cand.slice(0, limit) : cand; + } + } + return n.slice(0, limit); + }; + + for (const n of names) { + let cand = baseCandidate(n); + let uniq = cand; + if (used.has(uniq)) { + for (let i = 1; ; i++) { + const suffix = '_' + i; + const allowed = limit - suffix.length; + const base = cand.slice(0, Math.max(0, allowed)); + const tmp = base + suffix; + if (!used.has(tmp)) { + uniq = tmp; + break; + } + } + } + used.add(uniq); + this.toolNameMap.set(n, uniq); + this.reverseToolNameMap.set(uniq, n); + } + } + + /** + * 转换工具 + */ + convertTools(tools) { return tools.map(tool => { - const originalName = tool.function.name; - const shortName = this.shortenToolName(originalName); + // 处理 Claude 的 web_search + if (tool.type === "web_search_20250305") { + return { type: "web_search" }; + } - this.toolNameMap.set(originalName, shortName); - this.reverseToolNameMap.set(shortName, originalName); + if (tool.type !== 'function' && !tool.name) { + return tool; + } - return { + const func = tool.function || tool; + const originalName = func.name; + const shortName = this.toolNameMap.get(originalName) || this.shortenToolName(originalName); + + const result = { type: 'function', name: shortName, - description: tool.function.description, - parameters: tool.function.parameters + description: func.description, + parameters: func.parameters || func.input_schema || { type: 'object', properties: {} }, + strict: func.strict !== undefined ? func.strict : false }; + + // 清理参数 + if (result.parameters && result.parameters.$schema) { + delete result.parameters.$schema; + } + + return result; }); } /** - * 缩短工具名称(最多 64 字符) + * 转换 tool_choice */ - shortenToolName(name) { - if (name.length <= 64) { - return name; + convertToolChoice(toolChoice) { + if (typeof toolChoice === 'string') { + return toolChoice; } - // 保留 mcp__ 前缀和最后一段 - if (name.startsWith('mcp__')) { - const parts = name.split('__'); - if (parts.length > 2) { - const prefix = 'mcp__'; - const lastPart = parts[parts.length - 1]; - const maxLastPartLength = 64 - prefix.length - 1; // -1 for underscore - - if (lastPart.length <= maxLastPartLength) { - return prefix + lastPart; - } else { - return prefix + lastPart.slice(0, maxLastPartLength); - } - } + if (toolChoice.type === 'function') { + const name = toolChoice.function?.name; + const shortName = name ? (this.toolNameMap.get(name) || this.shortenToolName(name)) : ''; + return { + type: 'function', + name: shortName + }; } - // 使用哈希创建唯一的短名称 - const hash = crypto.createHash('md5').update(name).digest('hex').slice(0, 8); - return name.slice(0, 55) + '_' + hash; + return toolChoice; } /** - * 获取短工具名称 + * 缩短工具名称 */ - getShortToolName(originalName) { - return this.toolNameMap.get(originalName) || originalName; + shortenToolName(name) { + const limit = 64; + if (name.length <= limit) return name; + if (name.startsWith('mcp__')) { + const idx = name.lastIndexOf('__'); + if (idx > 0) { + let cand = 'mcp__' + name.slice(idx + 2); + return cand.length > limit ? cand.slice(0, limit) : cand; + } + } + return name.slice(0, limit); } /** @@ -270,83 +454,86 @@ export class CodexConverter extends BaseConverter { /** * Codex → OpenAI 响应转换(非流式) */ - toOpenAIResponse(data, model) { - const response = data.response || data; - - const message = { - role: 'assistant', - content: '' - }; - - // 提取文本内容和工具调用 - const textParts = []; - const toolCalls = []; - - if (response.output) { - for (const item of response.output) { - if (item.type === 'message') { - for (const content of item.content || []) { - if (content.type === 'output_text') { - textParts.push(content.text); - } - } - } else if (item.type === 'function_call') { - const originalName = this.getOriginalToolName(item.name); - toolCalls.push({ - id: item.call_id, - type: 'function', - function: { - name: originalName, - arguments: JSON.stringify(item.arguments) - } - }); - } - } + toOpenAIResponse(rawJSON, model) { + const root = typeof rawJSON === 'string' ? JSON.parse(rawJSON) : rawJSON; + if (root.type !== 'response.completed') { + return null; } - message.content = textParts.join(''); - if (toolCalls.length > 0) { - message.tool_calls = toolCalls; - } + const response = root.response; + const unixTimestamp = response.created_at || Math.floor(Date.now() / 1000); - // 提取推理内容 - let reasoningContent = ''; - if (response.output) { - for (const item of response.output) { - if (item.summary) { - reasoningContent = item.summary; - break; - } - } - } - - return { + const openaiResponse = { id: response.id || `chatcmpl-${Date.now()}`, object: 'chat.completion', - created: Math.floor(Date.now() / 1000), - model: model, + created: unixTimestamp, + model: response.model || model, choices: [{ index: 0, - message: message, - finish_reason: this.mapFinishReason(response.status), - ...(reasoningContent && { reasoning_content: reasoningContent }) + message: { + role: 'assistant', + content: null, + reasoning_content: null, + tool_calls: null + }, + finish_reason: null, + native_finish_reason: null }], usage: { prompt_tokens: response.usage?.input_tokens || 0, completion_tokens: response.usage?.output_tokens || 0, - total_tokens: response.usage?.total_tokens || 0, - ...(response.usage?.input_tokens_details?.cached_tokens && { - prompt_tokens_details: { - cached_tokens: response.usage.input_tokens_details.cached_tokens - } - }), - ...(response.usage?.output_tokens_details?.reasoning_tokens && { - completion_tokens_details: { - reasoning_tokens: response.usage.output_tokens_details.reasoning_tokens - } - }) + total_tokens: response.usage?.total_tokens || 0 } }; + + if (response.usage?.output_tokens_details?.reasoning_tokens) { + openaiResponse.usage.completion_tokens_details = { + reasoning_tokens: response.usage.output_tokens_details.reasoning_tokens + }; + } + + const output = response.output || []; + let contentText = ''; + let reasoningText = ''; + const toolCalls = []; + + for (const item of output) { + switch (item.type) { + case 'reasoning': + if (Array.isArray(item.summary)) { + const summaryItem = item.summary.find(s => s.type === 'summary_text'); + if (summaryItem) reasoningText = summaryItem.text; + } + break; + case 'message': + if (Array.isArray(item.content)) { + const contentItem = item.content.find(c => c.type === 'output_text'); + if (contentItem) contentText = contentItem.text; + } + break; + case 'function_call': + toolCalls.push({ + id: item.call_id || `call_${Date.now()}_${toolCalls.length}`, + type: 'function', + function: { + name: this.getOriginalToolName(item.name), + arguments: typeof item.arguments === 'string' ? item.arguments : JSON.stringify(item.arguments) + } + }); + break; + } + } + + if (contentText) openaiResponse.choices[0].message.content = contentText; + if (reasoningText) openaiResponse.choices[0].message.reasoning_content = reasoningText; + if (toolCalls.length > 0) openaiResponse.choices[0].message.tool_calls = toolCalls; + + if (response.status === 'completed') { + openaiResponse.choices[0].finish_reason = toolCalls.length > 0 ? 'tool_calls' : 'stop'; + openaiResponse.choices[0].native_finish_reason = 'stop'; + } + + return openaiResponse; } /** @@ -354,130 +541,631 @@ export class CodexConverter extends BaseConverter { */ toOpenAIStreamChunk(chunk, model) { const type = chunk.type; + // 使用固定的 key 来存储当前流的状态 + const stateKey = 'openai_stream_current'; + + if (!this.streamParams.has(stateKey)) { + this.streamParams.set(stateKey, { + model: model, + createdAt: Math.floor(Date.now() / 1000), + responseID: chunk.response?.id || `chatcmpl-${Date.now()}`, + functionCallIndex: 0, // 初始值为 0,第一个 function_call 的 index 为 0 + isFirstChunk: true // 标记是否是第一个内容 chunk + }); + } + const state = this.streamParams.get(stateKey); + + // 构建模板时使用当前状态中的值 + const buildTemplate = () => ({ + id: state.responseID, + object: 'chat.completion.chunk', + created: state.createdAt, + model: state.model, + choices: [{ + index: 0, + delta: { + role: 'assistant', + content: null, + reasoning_content: null, + tool_calls: null + }, + finish_reason: null, + native_finish_reason: null + }] + }); - // response.created - 存储元数据 if (type === 'response.created') { - return { - id: chunk.response.id, - object: 'chat.completion.chunk', - created: Math.floor(Date.now() / 1000), - model: model, - choices: [{ - index: 0, - delta: { role: 'assistant' }, - finish_reason: null - }] - }; + // 更新状态中的 responseID + state.responseID = chunk.response.id; + state.createdAt = chunk.response.created_at || state.createdAt; + state.model = chunk.response.model || state.model; + // 重置 functionCallIndex,确保每个新请求从 0 开始 + state.functionCallIndex = 0; + state.isFirstChunk = true; + // response.created 不发送 chunk,等待第一个内容 chunk + return null; } - // response.output_text.delta - 文本内容 - if (type === 'response.output_text.delta') { - return { - id: `chatcmpl-${Date.now()}`, - object: 'chat.completion.chunk', - created: Math.floor(Date.now() / 1000), - model: model, - choices: [{ - index: 0, - delta: { content: chunk.delta }, - finish_reason: null - }] - }; - } - - // response.reasoning_summary_text.delta - 推理内容 if (type === 'response.reasoning_summary_text.delta') { - return { - id: `chatcmpl-${Date.now()}`, - object: 'chat.completion.chunk', - created: Math.floor(Date.now() / 1000), - model: model, - choices: [{ - index: 0, - delta: { reasoning_content: chunk.delta }, - finish_reason: null - }] - }; + const results = []; + // 如果是第一个内容 chunk,先发送带 role 的 chunk + if (state.isFirstChunk) { + const firstTemplate = buildTemplate(); + firstTemplate.choices[0].delta = { + role: 'assistant', + content: null, + reasoning_content: chunk.delta, + tool_calls: null + }; + results.push(firstTemplate); + state.isFirstChunk = false; + } else { + const template = buildTemplate(); + template.choices[0].delta = { + role: 'assistant', + content: null, + reasoning_content: chunk.delta, + tool_calls: null + }; + results.push(template); + } + return results.length === 1 ? results[0] : results; + } + + if (type === 'response.reasoning_summary_text.done') { + const template = buildTemplate(); + template.choices[0].delta = { + role: 'assistant', + content: null, + reasoning_content: '\n\n', + tool_calls: null + }; + return template; + } + + if (type === 'response.output_text.delta') { + const results = []; + // 如果是第一个内容 chunk,先发送带 role 的 chunk + if (state.isFirstChunk) { + const firstTemplate = buildTemplate(); + firstTemplate.choices[0].delta = { + role: 'assistant', + content: chunk.delta, + reasoning_content: null, + tool_calls: null + }; + results.push(firstTemplate); + state.isFirstChunk = false; + } else { + const template = buildTemplate(); + template.choices[0].delta = { + role: 'assistant', + content: chunk.delta, + reasoning_content: null, + tool_calls: null + }; + results.push(template); + } + return results.length === 1 ? results[0] : results; } - // response.output_item.done - 工具调用完成 if (type === 'response.output_item.done' && chunk.item?.type === 'function_call') { - const originalName = this.getOriginalToolName(chunk.item.name); - return { - id: `chatcmpl-${Date.now()}`, - object: 'chat.completion.chunk', - created: Math.floor(Date.now() / 1000), - model: model, - choices: [{ - index: 0, - delta: { - tool_calls: [{ - index: 0, - id: chunk.item.call_id, - type: 'function', - function: { - name: originalName, - arguments: JSON.stringify(chunk.item.arguments) - } - }] - }, - finish_reason: null + const currentIndex = state.functionCallIndex; + state.functionCallIndex++; // 递增,为下一个 function_call 准备 + const template = buildTemplate(); + template.choices[0].delta = { + role: 'assistant', + content: null, + reasoning_content: null, + tool_calls: [{ + index: currentIndex, + id: chunk.item.call_id, + type: 'function', + function: { + name: this.getOriginalToolName(chunk.item.name), + arguments: typeof chunk.item.arguments === 'string' ? chunk.item.arguments : JSON.stringify(chunk.item.arguments) + } }] }; + return template; } - // response.completed - 完成 if (type === 'response.completed') { - return { - id: chunk.response.id, - object: 'chat.completion.chunk', - created: Math.floor(Date.now() / 1000), - model: model, - choices: [{ - index: 0, - delta: {}, - finish_reason: this.mapFinishReason(chunk.response.status) - }], - usage: { - prompt_tokens: chunk.response.usage?.input_tokens || 0, - completion_tokens: chunk.response.usage?.output_tokens || 0, - total_tokens: chunk.response.usage?.total_tokens || 0, - ...(chunk.response.usage?.input_tokens_details?.cached_tokens && { - prompt_tokens_details: { - cached_tokens: chunk.response.usage.input_tokens_details.cached_tokens - } - }), - ...(chunk.response.usage?.output_tokens_details?.reasoning_tokens && { - completion_tokens_details: { - reasoning_tokens: chunk.response.usage.output_tokens_details.reasoning_tokens - } - }) - } + const template = buildTemplate(); + const finishReason = state.functionCallIndex > 0 ? 'tool_calls' : 'stop'; + template.choices[0].delta = { + role: null, + content: null, + reasoning_content: null, + tool_calls: null }; + template.choices[0].finish_reason = finishReason; + template.choices[0].native_finish_reason = finishReason; + template.usage = { + prompt_tokens: chunk.response.usage?.input_tokens || 0, + completion_tokens: chunk.response.usage?.output_tokens || 0, + total_tokens: chunk.response.usage?.total_tokens || 0 + }; + if (chunk.response.usage?.output_tokens_details?.reasoning_tokens) { + template.usage.completion_tokens_details = { + reasoning_tokens: chunk.response.usage.output_tokens_details.reasoning_tokens + }; + } + // 完成后清理状态 + this.streamParams.delete(stateKey); + return template; + } + + return null; + } + + /** + * 转换模型列表 + */ + convertModelList(data, targetProtocol) { + return data; + } + + /** + * Codex → OpenAI Responses 响应转换 + */ + toOpenAIResponsesResponse(rawJSON, model) { + const root = typeof rawJSON === 'string' ? JSON.parse(rawJSON) : rawJSON; + if (root.type !== 'response.completed') { + return null; + } + + const response = root.response; + const unixTimestamp = response.created_at || Math.floor(Date.now() / 1000); + + const output = []; + let hasToolCall = false; + + if (response.output && Array.isArray(response.output)) { + for (const item of response.output) { + if (item.type === 'reasoning') { + let reasoningText = ''; + if (Array.isArray(item.summary)) { + const summaryItem = item.summary.find(s => s.type === 'summary_text'); + if (summaryItem) reasoningText = summaryItem.text; + } + if (reasoningText) { + output.push({ + id: `msg_${uuidv4().replace(/-/g, '')}`, + type: "message", + role: "assistant", + status: "completed", + content: [{ + type: "reasoning", + text: reasoningText + }] + }); + } + } else if (item.type === 'message') { + let contentText = ''; + if (Array.isArray(item.content)) { + const contentItem = item.content.find(c => c.type === 'output_text'); + if (contentItem) contentText = contentItem.text; + } + if (contentText) { + output.push({ + id: `msg_${uuidv4().replace(/-/g, '')}`, + type: "message", + role: "assistant", + status: "completed", + content: [{ + type: "output_text", + text: contentText, + annotations: [] + }] + }); + } + } else if (item.type === 'function_call') { + hasToolCall = true; + output.push({ + id: item.call_id || `call_${uuidv4().replace(/-/g, '')}`, + type: "function_call", + name: this.getOriginalToolName(item.name), + arguments: typeof item.arguments === 'string' ? item.arguments : JSON.stringify(item.arguments), + status: "completed" + }); + } + } + } + + return { + id: response.id || `resp_${uuidv4().replace(/-/g, '')}`, + object: "response", + created_at: unixTimestamp, + model: response.model || model, + status: "completed", + output: output, + usage: { + input_tokens: response.usage?.input_tokens || 0, + output_tokens: response.usage?.output_tokens || 0, + total_tokens: response.usage?.total_tokens || 0, + output_tokens_details: { + reasoning_tokens: response.usage?.output_tokens_details?.reasoning_tokens || 0 + } + } + }; + } + + /** + * Codex → Gemini 响应转换 + */ + toGeminiResponse(rawJSON, model) { + const root = typeof rawJSON === 'string' ? JSON.parse(rawJSON) : rawJSON; + if (root.type !== 'response.completed') { + return null; + } + + const response = root.response; + const parts = []; + + if (response.output && Array.isArray(response.output)) { + for (const item of response.output) { + if (item.type === 'reasoning') { + let reasoningText = ''; + if (Array.isArray(item.summary)) { + const summaryItem = item.summary.find(s => s.type === 'summary_text'); + if (summaryItem) reasoningText = summaryItem.text; + } + if (reasoningText) { + parts.push({ text: reasoningText, thought: true }); + } + } else if (item.type === 'message') { + let contentText = ''; + if (Array.isArray(item.content)) { + const contentItem = item.content.find(c => c.type === 'output_text'); + if (contentItem) contentText = contentItem.text; + } + if (contentText) { + parts.push({ text: contentText }); + } + } else if (item.type === 'function_call') { + parts.push({ + functionCall: { + name: this.getOriginalToolName(item.name), + args: typeof item.arguments === 'string' ? JSON.parse(item.arguments) : item.arguments + } + }); + } + } + } + + return { + candidates: [{ + content: { + role: "model", + parts: parts + }, + finishReason: "STOP" + }], + usageMetadata: { + promptTokenCount: response.usage?.input_tokens || 0, + candidatesTokenCount: response.usage?.output_tokens || 0, + totalTokenCount: response.usage?.total_tokens || 0 + }, + modelVersion: response.model || model, + responseId: response.id + }; + } + + /** + * Codex → Claude 响应转换 + */ + toClaudeResponse(rawJSON, model) { + const root = typeof rawJSON === 'string' ? JSON.parse(rawJSON) : rawJSON; + if (root.type !== 'response.completed') { + return null; + } + + const response = root.response; + const content = []; + let stopReason = "end_turn"; + + if (response.output && Array.isArray(response.output)) { + for (const item of response.output) { + if (item.type === 'reasoning') { + let reasoningText = ''; + if (Array.isArray(item.summary)) { + const summaryItem = item.summary.find(s => s.type === 'summary_text'); + if (summaryItem) reasoningText = summaryItem.text; + } + if (reasoningText) { + content.push({ type: "thinking", thinking: reasoningText }); + } + } else if (item.type === 'message') { + let contentText = ''; + if (Array.isArray(item.content)) { + const contentItem = item.content.find(c => c.type === 'output_text'); + if (contentItem) contentText = contentItem.text; + } + if (contentText) { + content.push({ type: "text", text: contentText }); + } + } else if (item.type === 'function_call') { + stopReason = "tool_use"; + content.push({ + type: "tool_use", + id: item.call_id || `call_${uuidv4().replace(/-/g, '')}`, + name: this.getOriginalToolName(item.name), + input: typeof item.arguments === 'string' ? JSON.parse(item.arguments) : item.arguments + }); + } + } + } + + return { + id: response.id || `msg_${uuidv4().replace(/-/g, '')}`, + type: "message", + role: "assistant", + model: response.model || model, + content: content, + stop_reason: stopReason, + usage: { + input_tokens: response.usage?.input_tokens || 0, + output_tokens: response.usage?.output_tokens || 0 + } + }; + } + + /** + * Codex → OpenAI Responses 流式响应转换 + */ + toOpenAIResponsesStreamChunk(chunk, model) { + const type = chunk.type; + const resId = chunk.response?.id || 'default'; + + if (!this.streamParams.has(resId)) { + this.streamParams.set(resId, { + model: model, + createdAt: Math.floor(Date.now() / 1000), + responseID: resId, + functionCallIndex: -1, + eventsSent: new Set() + }); + } + const state = this.streamParams.get(resId); + const events = []; + + if (type === 'response.created') { + state.responseID = chunk.response.id; + state.model = chunk.response.model || state.model; + events.push( + generateResponseCreated(state.responseID, state.model), + generateResponseInProgress(state.responseID) + ); + return events; + } + + if (type === 'response.reasoning_summary_text.delta') { + events.push({ + type: "response.reasoning_summary_text.delta", + response_id: state.responseID, + delta: chunk.delta + }); + return events; + } + + if (type === 'response.output_text.delta') { + if (!state.eventsSent.has('output_item_added')) { + events.push(generateOutputItemAdded(state.responseID)); + state.eventsSent.add('output_item_added'); + } + if (!state.eventsSent.has('content_part_added')) { + events.push(generateContentPartAdded(state.responseID)); + state.eventsSent.add('content_part_added'); + } + events.push({ + type: "response.output_text.delta", + response_id: state.responseID, + delta: chunk.delta + }); + return events; + } + + if (type === 'response.output_item.done' && chunk.item?.type === 'function_call') { + events.push({ + type: "response.output_item.added", + response_id: state.responseID, + item: { + id: chunk.item.call_id, + type: "function_call", + name: this.getOriginalToolName(chunk.item.name), + arguments: typeof chunk.item.arguments === 'string' ? chunk.item.arguments : JSON.stringify(chunk.item.arguments), + status: "completed" + } + }); + events.push({ + type: "response.output_item.done", + response_id: state.responseID, + item_id: chunk.item.call_id + }); + return events; + } + + if (type === 'response.completed') { + events.push( + generateOutputTextDone(state.responseID), + generateContentPartDone(state.responseID), + generateOutputItemDone(state.responseID) + ); + const completedEvent = generateResponseCompleted(state.responseID); + completedEvent.response.usage = { + input_tokens: chunk.response.usage?.input_tokens || 0, + output_tokens: chunk.response.usage?.output_tokens || 0, + total_tokens: chunk.response.usage?.total_tokens || 0 + }; + events.push(completedEvent); + this.streamParams.delete(resId); + return events; + } + + return null; + } + + /** + * Codex → Gemini 流式响应转换 + */ + toGeminiStreamChunk(chunk, model) { + const type = chunk.type; + const resId = chunk.response?.id || 'default'; + + if (!this.streamParams.has(resId)) { + this.streamParams.set(resId, { + model: model, + createdAt: Math.floor(Date.now() / 1000), + responseID: resId + }); + } + const state = this.streamParams.get(resId); + + const template = { + candidates: [{ + content: { + role: "model", + parts: [] + } + }], + modelVersion: state.model, + responseId: state.responseID + }; + + if (type === 'response.reasoning_summary_text.delta') { + template.candidates[0].content.parts.push({ text: chunk.delta, thought: true }); + return template; + } + + if (type === 'response.output_text.delta') { + template.candidates[0].content.parts.push({ text: chunk.delta }); + return template; + } + + if (type === 'response.output_item.done' && chunk.item?.type === 'function_call') { + template.candidates[0].content.parts.push({ + functionCall: { + name: this.getOriginalToolName(chunk.item.name), + args: typeof chunk.item.arguments === 'string' ? JSON.parse(chunk.item.arguments) : chunk.item.arguments + } + }); + return template; + } + + if (type === 'response.completed') { + template.candidates[0].finishReason = "STOP"; + template.usageMetadata = { + promptTokenCount: chunk.response.usage?.input_tokens || 0, + candidatesTokenCount: chunk.response.usage?.output_tokens || 0, + totalTokenCount: chunk.response.usage?.total_tokens || 0 + }; + this.streamParams.delete(resId); + return template; + } + + return null; + } + + /** + * Codex → Claude 流式响应转换 + */ + toClaudeStreamChunk(chunk, model) { + const type = chunk.type; + const resId = chunk.response?.id || 'default'; + + if (!this.streamParams.has(resId)) { + this.streamParams.set(resId, { + model: model, + createdAt: Math.floor(Date.now() / 1000), + responseID: resId, + blockIndex: 0 + }); + } + const state = this.streamParams.get(resId); + + if (type === 'response.created') { + state.responseID = chunk.response.id; + return { + type: "message_start", + message: { + id: state.responseID, + type: "message", + role: "assistant", + model: state.model, + usage: { input_tokens: 0, output_tokens: 0 } + } + }; + } + + if (type === 'response.reasoning_summary_text.delta') { + return { + type: "content_block_delta", + index: state.blockIndex, + delta: { type: "thinking_delta", thinking: chunk.delta } + }; + } + + if (type === 'response.output_text.delta') { + return { + type: "content_block_delta", + index: state.blockIndex, + delta: { type: "text_delta", text: chunk.delta } + }; + } + + if (type === 'response.output_item.done' && chunk.item?.type === 'function_call') { + const events = [ + { + type: "content_block_start", + index: state.blockIndex, + content_block: { + type: "tool_use", + id: chunk.item.call_id, + name: this.getOriginalToolName(chunk.item.name), + input: {} + } + }, + { + type: "content_block_delta", + index: state.blockIndex, + delta: { + type: "input_json_delta", + partial_json: typeof chunk.item.arguments === 'string' ? chunk.item.arguments : JSON.stringify(chunk.item.arguments) + } + }, + { + type: "content_block_stop", + index: state.blockIndex + } + ]; + state.blockIndex++; + return events; + } + + if (type === 'response.completed') { + const events = [ + { + type: "message_delta", + delta: { stop_reason: "end_turn" }, + usage: { + input_tokens: chunk.response.usage?.input_tokens || 0, + output_tokens: chunk.response.usage?.output_tokens || 0 + } + }, + { type: "message_stop" } + ]; + this.streamParams.delete(resId); + return events; } - // 其他事件类型暂时忽略 return null; } - /** - * 映射完成原因 - */ - mapFinishReason(status) { - const mapping = { - 'completed': 'stop', - 'incomplete': 'length', - 'failed': 'error', - 'cancelled': 'stop' - }; - return mapping[status] || 'stop'; - } - /** * 转换模型列表 */ convertModelList(data, targetProtocol) { - // Codex 使用 OpenAI 格式的模型列表,无需转换 return data; } } diff --git a/src/converters/strategies/GeminiConverter.js b/src/converters/strategies/GeminiConverter.js index c1e7b53..a8c2753 100644 --- a/src/converters/strategies/GeminiConverter.js +++ b/src/converters/strategies/GeminiConverter.js @@ -27,7 +27,6 @@ import { } from '../../providers/openai/openai-responses-core.mjs'; /** - * [FIX] 参考 ag/response.rs 和 ag/streaming.rs 的 remap_function_call_args 函数 * 修复 Gemini 返回的工具参数名称问题 * Gemini 有时会使用不同的参数名称,需要映射到 Claude Code 期望的格式 */ @@ -176,6 +175,8 @@ export class GeminiConverter extends BaseConverter { return this.toClaudeRequest(data); case MODEL_PROTOCOL_PREFIX.OPENAI_RESPONSES: return this.toOpenAIResponsesRequest(data); + case MODEL_PROTOCOL_PREFIX.CODEX: + return this.toCodexRequest(data); default: throw new Error(`Unsupported target protocol: ${targetProtocol}`); } @@ -192,6 +193,8 @@ export class GeminiConverter extends BaseConverter { return this.toClaudeResponse(data, model); case MODEL_PROTOCOL_PREFIX.OPENAI_RESPONSES: return this.toOpenAIResponsesResponse(data, model); + case MODEL_PROTOCOL_PREFIX.CODEX: + return this.toCodexResponse(data, model); default: throw new Error(`Unsupported target protocol: ${targetProtocol}`); } @@ -208,6 +211,8 @@ export class GeminiConverter extends BaseConverter { return this.toClaudeStreamChunk(chunk, model); case MODEL_PROTOCOL_PREFIX.OPENAI_RESPONSES: return this.toOpenAIResponsesStreamChunk(chunk, model); + case MODEL_PROTOCOL_PREFIX.CODEX: + return this.toCodexStreamChunk(chunk, model); default: throw new Error(`Unsupported target protocol: ${targetProtocol}`); } @@ -357,13 +362,25 @@ export class GeminiConverter extends BaseConverter { // 处理finishReason let finishReason = null; if (candidate.finishReason) { - finishReason = candidate.finishReason === 'STOP' ? 'stop' : - candidate.finishReason === 'MAX_TOKENS' ? 'length' : - candidate.finishReason.toLowerCase(); + const finishReasonMap = { + 'FINISH_REASON_UNSPECIFIED': 'stop', + 'STOP': 'stop', + 'MAX_TOKENS': 'length', + 'SAFETY': 'content_filter', + 'RECITATION': 'content_filter', + 'OTHER': 'stop', + 'BLOCKLIST': 'content_filter', + 'PROHIBITED_CONTENT': 'content_filter', + 'SPII': 'content_filter', + 'MALFORMED_FUNCTION_CALL': 'stop', + 'MODEL_ARMOR': 'content_filter', + }; + finishReason = finishReasonMap[candidate.finishReason] || 'stop'; } - // 如果包含工具调用,且完成原因为 stop,则将完成原因修改为 tool_calls - if (toolCalls.length > 0 && finishReason === 'stop') { + // [FIX] 适配 Gemini 流式:Gemini 的最后一条流式消息通常不带 functionCall + // 如果当前 chunk 包含工具调用,直接将其标记为 tool_calls + if (toolCalls.length > 0) { finishReason = 'tool_calls'; } @@ -377,7 +394,7 @@ export class GeminiConverter extends BaseConverter { return null; } - return { + const chunk = { id: `chatcmpl-${uuidv4()}`, object: "chat.completion.chunk", created: Math.floor(Date.now() / 1000), @@ -387,7 +404,10 @@ export class GeminiConverter extends BaseConverter { delta: delta, finish_reason: finishReason, }], - usage: geminiChunk.usageMetadata ? { + }; + + if(geminiChunk.usageMetadata){ + chunk.usage = { prompt_tokens: geminiChunk.usageMetadata.promptTokenCount || 0, completion_tokens: geminiChunk.usageMetadata.candidatesTokenCount || 0, total_tokens: geminiChunk.usageMetadata.totalTokenCount || 0, @@ -398,19 +418,10 @@ export class GeminiConverter extends BaseConverter { completion_tokens_details: { reasoning_tokens: geminiChunk.usageMetadata.thoughtsTokenCount || 0 } - } : { - prompt_tokens: 0, - completion_tokens: 0, - total_tokens: 0, - cached_tokens: 0, - prompt_tokens_details: { - cached_tokens: 0 - }, - completion_tokens_details: { - reasoning_tokens: 0 - } - }, - }; + }; + } + + return chunk; } /** @@ -589,7 +600,7 @@ export class GeminiConverter extends BaseConverter { const finishReason = candidate.finishReason; let stopReason = "end_turn"; - // [FIX] 参考 ag/response.rs - 如果有工具调用,stop_reason 应该是 "tool_use" + // - 如果有工具调用,stop_reason 应该是 "tool_use" if (hasToolUse) { stopReason = 'tool_use'; } else if (finishReason) { @@ -644,7 +655,7 @@ export class GeminiConverter extends BaseConverter { if (candidate) { const parts = candidate.content?.parts; - // [FIX] 参考 ag/streaming.rs 处理 thinking 和 text 块 + // thinking 和 text 块 if (parts && Array.isArray(parts)) { const results = []; let hasToolUse = false; @@ -826,7 +837,7 @@ export class GeminiConverter extends BaseConverter { parts.forEach(part => { if (!part) return; - // [FIX] 参考 ag/response.rs 处理 thinking 块 + // 处理 thinking 块 // Gemini 使用 thought: true 和 thoughtSignature 表示思考内容 // [FIX] 同时支持 thoughtSignature 和 thought_signature(Gemini CLI 可能使用下划线格式) if (part.text) { @@ -947,7 +958,7 @@ export class GeminiConverter extends BaseConverter { if (candidate.content && candidate.content.parts) { for (const part of candidate.content.parts) { - // [FIX] 参考 ag/response.rs 处理 thinking 块 + // 处理 thinking 块 if (part.text) { if (part.thought === true) { // 这是一个 thinking 块 @@ -1034,34 +1045,85 @@ export class GeminiConverter extends BaseConverter { toOpenAIResponsesRequest(geminiRequest) { const responsesRequest = { model: geminiRequest.model, - max_tokens: checkAndAssignOrDefault(geminiRequest.generationConfig?.maxOutputTokens, OPENAI_DEFAULT_MAX_TOKENS), - temperature: checkAndAssignOrDefault(geminiRequest.generationConfig?.temperature, OPENAI_DEFAULT_TEMPERATURE), - top_p: checkAndAssignOrDefault(geminiRequest.generationConfig?.topP, OPENAI_DEFAULT_TOP_P), + instructions: '', + input: [], + stream: geminiRequest.stream || false, + max_output_tokens: geminiRequest.generationConfig?.maxOutputTokens, + temperature: geminiRequest.generationConfig?.temperature, + top_p: geminiRequest.generationConfig?.topP }; // 处理系统指令 if (geminiRequest.systemInstruction && geminiRequest.systemInstruction.parts) { - const instructionsText = geminiRequest.systemInstruction.parts + responsesRequest.instructions = geminiRequest.systemInstruction.parts .filter(p => p.text) .map(p => p.text) .join('\n'); - if (instructionsText) { - responsesRequest.instructions = instructionsText; - } } - // 处理输入 + // 处理内容 if (geminiRequest.contents && Array.isArray(geminiRequest.contents)) { - const lastContent = geminiRequest.contents[geminiRequest.contents.length - 1]; - if (lastContent && lastContent.parts) { - const inputText = lastContent.parts - .filter(p => p.text) - .map(p => p.text) - .join(' '); - if (inputText) { - responsesRequest.input = inputText; - } - } + geminiRequest.contents.forEach(content => { + const role = content.role === 'model' ? 'assistant' : 'user'; + const parts = content.parts || []; + + parts.forEach(part => { + if (part.text) { + responsesRequest.input.push({ + type: 'message', + role: role, + content: [{ + type: role === 'assistant' ? 'output_text' : 'input_text', + text: part.text + }] + }); + } + + if (part.functionCall) { + responsesRequest.input.push({ + type: 'function_call', + call_id: part.functionCall.id || `call_${uuidv4().replace(/-/g, '').slice(0, 24)}`, + name: part.functionCall.name, + arguments: typeof part.functionCall.args === 'string' + ? part.functionCall.args + : JSON.stringify(part.functionCall.args) + }); + } + + if (part.functionResponse) { + responsesRequest.input.push({ + type: 'function_call_output', + call_id: part.functionResponse.name, // Gemini 通常使用 name 作为关联 + output: typeof part.functionResponse.response?.result === 'string' + ? part.functionResponse.response.result + : JSON.stringify(part.functionResponse.response || {}) + }); + } + + if (part.inlineData) { + responsesRequest.input.push({ + type: 'message', + role: role, + content: [{ + type: 'input_image', + image_url: { + url: `data:${part.inlineData.mimeType};base64,${part.inlineData.data}` + } + }] + }); + } + }); + }); + } + + // 处理工具 + if (geminiRequest.tools && geminiRequest.tools[0]?.functionDeclarations) { + responsesRequest.tools = geminiRequest.tools[0].functionDeclarations.map(fn => ({ + type: 'function', + name: fn.name, + description: fn.description, + parameters: fn.parameters || fn.parametersJsonSchema || { type: 'object', properties: {} } + })); } return responsesRequest; @@ -1221,6 +1283,196 @@ export class GeminiConverter extends BaseConverter { return events; } + + // ========================================================================= + // Gemini -> Codex 转换 + // ========================================================================= + + /** + * Gemini请求 -> Codex请求 + */ + toCodexRequest(geminiRequest) { + // 使用 CodexConverter 进行转换,因为 CodexConverter.js 中已经实现了 OpenAI -> Codex 的逻辑 + // 我们需要先将 Gemini 转为 OpenAI 格式,再转为 Codex 格式 + const openaiRequest = this.toOpenAIRequest(geminiRequest); + + // 注意:这里我们直接在 GeminiConverter 中实现逻辑,避免循环依赖 + const codexRequest = { + model: openaiRequest.model, + instructions: '', + input: [], + stream: geminiRequest.stream || false, + store: false, + reasoning: { + effort: 'medium', + summary: 'auto' + }, + parallel_tool_calls: true, + include: ['reasoning.encrypted_content'] + }; + + // 处理系统指令 + if (geminiRequest.systemInstruction && geminiRequest.systemInstruction.parts) { + codexRequest.instructions = geminiRequest.systemInstruction.parts + .filter(p => p.text) + .map(p => p.text) + .join('\n'); + } + + // 处理内容 + if (geminiRequest.contents && Array.isArray(geminiRequest.contents)) { + const pendingCallIDs = []; + + geminiRequest.contents.forEach(content => { + const role = content.role === 'model' ? 'assistant' : 'user'; + const parts = content.parts || []; + + parts.forEach(part => { + if (part.text) { + codexRequest.input.push({ + type: 'message', + role: role, + content: [{ + type: role === 'assistant' ? 'output_text' : 'input_text', + text: part.text + }] + }); + } + + if (part.functionCall) { + const callId = `call_${uuidv4().replace(/-/g, '').slice(0, 24)}`; + pendingCallIDs.push(callId); + codexRequest.input.push({ + type: 'function_call', + call_id: callId, + name: part.functionCall.name, + arguments: typeof part.functionCall.args === 'string' + ? part.functionCall.args + : JSON.stringify(part.functionCall.args) + }); + } + + if (part.functionResponse) { + const callId = pendingCallIDs.shift() || `call_${uuidv4().replace(/-/g, '').slice(0, 24)}`; + codexRequest.input.push({ + type: 'function_call_output', + call_id: callId, + output: typeof part.functionResponse.response?.result === 'string' + ? part.functionResponse.response.result + : JSON.stringify(part.functionResponse.response || {}) + }); + } + }); + }); + } + + // 处理工具 + if (geminiRequest.tools && geminiRequest.tools[0]?.functionDeclarations) { + codexRequest.tools = geminiRequest.tools[0].functionDeclarations.map(fn => ({ + type: 'function', + name: fn.name, + description: fn.description, + parameters: fn.parameters || { type: 'object', properties: {} } + })); + } + + return codexRequest; + } + + /** + * Gemini响应 -> Codex响应 (实际上是 Codex 转 Gemini) + */ + toCodexResponse(geminiResponse, model) { + // 这里实际上是实现 Codex -> Gemini 的非流式转换 + // 为了保持接口一致,我们按照其他 Converter 的命名习惯 + const parts = []; + if (geminiResponse.response?.output) { + geminiResponse.response.output.forEach(item => { + if (item.type === 'message' && item.content) { + const textPart = item.content.find(c => c.type === 'output_text'); + if (textPart) parts.push({ text: textPart.text }); + } else if (item.type === 'reasoning' && item.summary) { + const textPart = item.summary.find(c => c.type === 'summary_text'); + if (textPart) parts.push({ text: textPart.text, thought: true }); + } else if (item.type === 'function_call') { + parts.push({ + functionCall: { + name: item.name, + args: typeof item.arguments === 'string' ? JSON.parse(item.arguments) : item.arguments + } + }); + } + }); + } + + return { + candidates: [{ + content: { + role: 'model', + parts: parts + }, + finishReason: 'STOP' + }], + usageMetadata: { + promptTokenCount: geminiResponse.response?.usage?.input_tokens || 0, + candidatesTokenCount: geminiResponse.response?.usage?.output_tokens || 0, + totalTokenCount: geminiResponse.response?.usage?.total_tokens || 0 + }, + modelVersion: model, + responseId: geminiResponse.response?.id + }; + } + + /** + * Gemini流式响应 -> Codex流式响应 (实际上是 Codex 转 Gemini) + */ + toCodexStreamChunk(codexChunk, model) { + const type = codexChunk.type; + const resId = codexChunk.response?.id || 'default'; + + const template = { + candidates: [{ + content: { + role: "model", + parts: [] + } + }], + modelVersion: model, + responseId: resId + }; + + if (type === 'response.reasoning_summary_text.delta') { + template.candidates[0].content.parts.push({ text: codexChunk.delta, thought: true }); + return template; + } + + if (type === 'response.output_text.delta') { + template.candidates[0].content.parts.push({ text: codexChunk.delta }); + return template; + } + + if (type === 'response.output_item.done' && codexChunk.item?.type === 'function_call') { + template.candidates[0].content.parts.push({ + functionCall: { + name: codexChunk.item.name, + args: typeof codexChunk.item.arguments === 'string' ? JSON.parse(codexChunk.item.arguments) : codexChunk.item.arguments + } + }); + return template; + } + + if (type === 'response.completed') { + template.candidates[0].finishReason = "STOP"; + template.usageMetadata = { + promptTokenCount: codexChunk.response.usage?.input_tokens || 0, + candidatesTokenCount: codexChunk.response.usage?.output_tokens || 0, + totalTokenCount: codexChunk.response.usage?.total_tokens || 0 + }; + return template; + } + + return null; + } } export default GeminiConverter; \ No newline at end of file diff --git a/src/converters/strategies/OpenAIConverter.js b/src/converters/strategies/OpenAIConverter.js index 2d887e7..99079a1 100644 --- a/src/converters/strategies/OpenAIConverter.js +++ b/src/converters/strategies/OpenAIConverter.js @@ -1345,34 +1345,86 @@ export class OpenAIConverter extends BaseConverter { toOpenAIResponsesRequest(openaiRequest) { const responsesRequest = { model: openaiRequest.model, - messages: [] + instructions: '', + input: [], + stream: openaiRequest.stream || false, + max_output_tokens: openaiRequest.max_tokens, + temperature: openaiRequest.temperature, + top_p: openaiRequest.top_p, + parallel_tool_calls: openaiRequest.parallel_tool_calls, + tool_choice: openaiRequest.tool_choice }; - // 转换messages - if (openaiRequest.messages && openaiRequest.messages.length > 0) { - responsesRequest.messages = openaiRequest.messages.map(msg => ({ - role: msg.role, - content: typeof msg.content === 'string' - ? [{ type: 'input_text', text: msg.content }] - : msg.content - })); + const { systemInstruction, nonSystemMessages } = extractSystemMessages(openaiRequest.messages || []); + + if (systemInstruction) { + responsesRequest.instructions = extractText(systemInstruction.parts[0].text); } - // 转换其他参数 - if (openaiRequest.temperature !== undefined) { - responsesRequest.temperature = openaiRequest.temperature; + if (openaiRequest.reasoning_effort) { + responsesRequest.reasoning = { + effort: openaiRequest.reasoning_effort + }; } - if (openaiRequest.max_tokens !== undefined) { - responsesRequest.max_output_tokens = openaiRequest.max_tokens; - } - if (openaiRequest.top_p !== undefined) { - responsesRequest.top_p = openaiRequest.top_p; + + // 转换messages到input + for (const msg of nonSystemMessages) { + if (msg.role === 'tool') { + responsesRequest.input.push({ + type: 'function_call_output', + call_id: msg.tool_call_id, + output: msg.content + }); + } else if (msg.role === 'assistant' && msg.tool_calls?.length) { + for (const tc of msg.tool_calls) { + responsesRequest.input.push({ + type: 'function_call', + call_id: tc.id, + name: tc.function.name, + arguments: tc.function.arguments + }); + } + } else { + let content = []; + if (typeof msg.content === 'string') { + content.push({ + type: msg.role === 'assistant' ? 'output_text' : 'input_text', + text: msg.content + }); + } else if (Array.isArray(msg.content)) { + msg.content.forEach(c => { + if (c.type === 'text') { + content.push({ + type: msg.role === 'assistant' ? 'output_text' : 'input_text', + text: c.text + }); + } else if (c.type === 'image_url') { + content.push({ + type: 'input_image', + image_url: c.image_url + }); + } + }); + } + + if (content.length > 0) { + responsesRequest.input.push({ + type: 'message', + role: msg.role, + content: content + }); + } + } } + + // 处理工具 if (openaiRequest.tools) { - responsesRequest.tools = openaiRequest.tools; - } - if (openaiRequest.tool_choice) { - responsesRequest.tool_choice = openaiRequest.tool_choice; + responsesRequest.tools = openaiRequest.tools.map(t => ({ + type: t.type || 'function', + name: t.function?.name, + description: t.function?.description, + parameters: t.function?.parameters + })); } return responsesRequest; diff --git a/src/converters/strategies/OpenAIResponsesConverter.js b/src/converters/strategies/OpenAIResponsesConverter.js index 83e3ec5..858b9f2 100644 --- a/src/converters/strategies/OpenAIResponsesConverter.js +++ b/src/converters/strategies/OpenAIResponsesConverter.js @@ -3,6 +3,7 @@ * 处理 OpenAI Responses API 格式与其他协议之间的转换 */ +import { v4 as uuidv4 } from 'uuid'; import { BaseConverter } from '../BaseConverter.js'; import { MODEL_PROTOCOL_PREFIX } from '../../utils/common.js'; import { @@ -12,6 +13,16 @@ import { GEMINI_DEFAULT_INPUT_TOKEN_LIMIT, GEMINI_DEFAULT_OUTPUT_TOKEN_LIMIT } from '../utils.js'; +import { + generateResponseCreated, + generateResponseInProgress, + generateOutputItemAdded, + generateContentPartAdded, + generateOutputTextDone, + generateContentPartDone, + generateOutputItemDone, + generateResponseCompleted +} from '../../providers/openai/openai-responses-core.mjs'; /** * OpenAI Responses API 转换器类 @@ -37,6 +48,8 @@ export class OpenAIResponsesConverter extends BaseConverter { return this.toClaudeRequest(data); case MODEL_PROTOCOL_PREFIX.GEMINI: return this.toGeminiRequest(data); + case MODEL_PROTOCOL_PREFIX.CODEX: + return this.toCodexRequest(data); default: throw new Error(`Unsupported target protocol: ${toProtocol}`); } @@ -53,6 +66,8 @@ export class OpenAIResponsesConverter extends BaseConverter { return this.toClaudeResponse(data, model); case MODEL_PROTOCOL_PREFIX.GEMINI: return this.toGeminiResponse(data, model); + case MODEL_PROTOCOL_PREFIX.CODEX: + return this.toCodexResponse(data, model); default: throw new Error(`Unsupported target protocol: ${toProtocol}`); } @@ -69,6 +84,8 @@ export class OpenAIResponsesConverter extends BaseConverter { return this.toClaudeStreamChunk(chunk, model); case MODEL_PROTOCOL_PREFIX.GEMINI: return this.toGeminiStreamChunk(chunk, model); + case MODEL_PROTOCOL_PREFIX.CODEX: + return this.toCodexStreamChunk(chunk, model); default: throw new Error(`Unsupported target protocol: ${toProtocol}`); } @@ -104,6 +121,22 @@ export class OpenAIResponsesConverter extends BaseConverter { stream: responsesRequest.stream || false }; + // 复制其他参数 + if (responsesRequest.temperature !== undefined) { + openaiRequest.temperature = responsesRequest.temperature; + } + if (responsesRequest.max_output_tokens !== undefined) { + openaiRequest.max_tokens = responsesRequest.max_output_tokens; + } else if (responsesRequest.max_tokens !== undefined) { + openaiRequest.max_tokens = responsesRequest.max_tokens; + } + if (responsesRequest.top_p !== undefined) { + openaiRequest.top_p = responsesRequest.top_p; + } + if (responsesRequest.parallel_tool_calls !== undefined) { + openaiRequest.parallel_tool_calls = responsesRequest.parallel_tool_calls; + } + // OpenAI Responses API 使用 instructions 和 input 字段 // 需要转换为标准的 messages 格式 if (responsesRequest.instructions) { @@ -117,19 +150,50 @@ export class OpenAIResponsesConverter extends BaseConverter { // input 包含用户消息和历史对话 if (responsesRequest.input && Array.isArray(responsesRequest.input)) { responsesRequest.input.forEach(item => { - if (item.type === 'message') { - // 提取消息内容 - const content = item.content - .filter(c => c.type === 'input_text') - .map(c => c.text) - .join('\n'); + const itemType = item.type || (item.role ? 'message' : ''); + + switch (itemType) { + case 'message': + // 提取消息内容 + let content = ''; + if (Array.isArray(item.content)) { + content = item.content + .filter(c => c.type === 'input_text' || c.type === 'output_text') + .map(c => c.text) + .join('\n'); + } else if (typeof item.content === 'string') { + content = item.content; + } + + if (content || item.role === 'assistant') { + openaiRequest.messages.push({ + role: item.role, + content: content + }); + } + break; - if (content) { + case 'function_call': openaiRequest.messages.push({ - role: item.role, - content: content + role: 'assistant', + tool_calls: [{ + id: item.call_id, + type: 'function', + function: { + name: item.name, + arguments: typeof item.arguments === 'string' ? item.arguments : JSON.stringify(item.arguments) + } + }] }); - } + break; + + case 'function_call_output': + openaiRequest.messages.push({ + role: 'tool', + tool_call_id: item.call_id, + content: item.output + }); + break; } }); } @@ -144,15 +208,25 @@ export class OpenAIResponsesConverter extends BaseConverter { }); } - // 复制其他参数 - if (responsesRequest.temperature !== undefined) { - openaiRequest.temperature = responsesRequest.temperature; + // 处理工具 + if (responsesRequest.tools && Array.isArray(responsesRequest.tools)) { + openaiRequest.tools = responsesRequest.tools.map(tool => { + if (tool.type && tool.type !== 'function') { + return tool; + } + return { + type: 'function', + function: { + name: tool.name, + description: tool.description, + parameters: tool.parameters || tool.parametersJsonSchema || { type: 'object', properties: {} } + } + }; + }); } - if (responsesRequest.max_tokens !== undefined) { - openaiRequest.max_tokens = responsesRequest.max_tokens; - } - if (responsesRequest.top_p !== undefined) { - openaiRequest.top_p = responsesRequest.top_p; + + if (responsesRequest.tool_choice) { + openaiRequest.tool_choice = responsesRequest.tool_choice; } return openaiRequest; @@ -162,21 +236,53 @@ export class OpenAIResponsesConverter extends BaseConverter { * 将 OpenAI Responses 响应转换为标准 OpenAI 响应 */ toOpenAIResponse(responsesResponse, model) { - // OpenAI Responses 格式已经很接近标准 OpenAI 格式 - return { - id: responsesResponse.id || `chatcmpl-${Date.now()}`, - object: 'chat.completion', - created: responsesResponse.created || Math.floor(Date.now() / 1000), - model: model || responsesResponse.model, - choices: responsesResponse.choices || [{ - index: 0, - message: { - role: 'assistant', - content: responsesResponse.content || '' - }, - finish_reason: responsesResponse.finish_reason || 'stop' - }], - usage: responsesResponse.usage ? { + const choices = []; + let usage = { + prompt_tokens: 0, + completion_tokens: 0, + total_tokens: 0, + prompt_tokens_details: { cached_tokens: 0 }, + completion_tokens_details: { reasoning_tokens: 0 } + }; + + if (responsesResponse.output && Array.isArray(responsesResponse.output)) { + responsesResponse.output.forEach((item, index) => { + if (item.type === 'message') { + const content = item.content + ?.filter(c => c.type === 'output_text') + .map(c => c.text) + .join('') || ''; + + choices.push({ + index: index, + message: { + role: 'assistant', + content: content + }, + finish_reason: responsesResponse.status === 'completed' ? 'stop' : null + }); + } else if (item.type === 'function_call') { + choices.push({ + index: index, + message: { + role: 'assistant', + tool_calls: [{ + id: item.call_id, + type: 'function', + function: { + name: item.name, + arguments: item.arguments + } + }] + }, + finish_reason: 'tool_calls' + }); + } + }); + } + + if (responsesResponse.usage) { + usage = { prompt_tokens: responsesResponse.usage.input_tokens || 0, completion_tokens: responsesResponse.usage.output_tokens || 0, total_tokens: responsesResponse.usage.total_tokens || 0, @@ -186,17 +292,23 @@ export class OpenAIResponsesConverter extends BaseConverter { completion_tokens_details: { reasoning_tokens: responsesResponse.usage.output_tokens_details?.reasoning_tokens || 0 } - } : { - prompt_tokens: 0, - completion_tokens: 0, - total_tokens: 0, - prompt_tokens_details: { - cached_tokens: 0 + }; + } + + return { + id: responsesResponse.id || `chatcmpl-${Date.now()}`, + object: 'chat.completion', + created: responsesResponse.created_at || Math.floor(Date.now() / 1000), + model: model || responsesResponse.model, + choices: choices.length > 0 ? choices : [{ + index: 0, + message: { + role: 'assistant', + content: '' }, - completion_tokens_details: { - reasoning_tokens: 0 - } - } + finish_reason: 'stop' + }], + usage: usage }; } @@ -204,17 +316,44 @@ export class OpenAIResponsesConverter extends BaseConverter { * 将 OpenAI Responses 流式块转换为标准 OpenAI 流式块 */ toOpenAIStreamChunk(responsesChunk, model) { + const resId = responsesChunk.response?.id || responsesChunk.id || `chatcmpl-${Date.now()}`; + const created = responsesChunk.response?.created_at || responsesChunk.created || Math.floor(Date.now() / 1000); + + const delta = {}; + let finish_reason = null; + + if (responsesChunk.type === 'response.output_text.delta') { + delta.content = responsesChunk.delta; + } else if (responsesChunk.type === 'response.function_call_arguments.delta') { + delta.tool_calls = [{ + index: responsesChunk.output_index || 0, + function: { + arguments: responsesChunk.delta + } + }]; + } else if (responsesChunk.type === 'response.output_item.added' && responsesChunk.item?.type === 'function_call') { + delta.tool_calls = [{ + index: responsesChunk.output_index || 0, + id: responsesChunk.item.call_id, + type: 'function', + function: { + name: responsesChunk.item.name, + arguments: '' + } + }]; + } else if (responsesChunk.type === 'response.completed') { + finish_reason = 'stop'; + } + return { - id: responsesChunk.id || `chatcmpl-${Date.now()}`, + id: resId, object: 'chat.completion.chunk', - created: responsesChunk.created || Math.floor(Date.now() / 1000), - model: model || responsesChunk.model, - choices: responsesChunk.choices || [{ + created: created, + model: model || responsesChunk.response?.model || responsesChunk.model, + choices: [{ index: 0, - delta: { - content: responsesChunk.delta?.content || '' - }, - finish_reason: responsesChunk.finish_reason || null + delta: delta, + finish_reason: finish_reason }] }; } @@ -230,7 +369,7 @@ export class OpenAIResponsesConverter extends BaseConverter { const claudeRequest = { model: responsesRequest.model, messages: [], - max_tokens: responsesRequest.max_tokens || CLAUDE_DEFAULT_MAX_TOKENS, + max_tokens: responsesRequest.max_output_tokens || responsesRequest.max_tokens || CLAUDE_DEFAULT_MAX_TOKENS, stream: responsesRequest.stream || false }; @@ -239,50 +378,102 @@ export class OpenAIResponsesConverter extends BaseConverter { claudeRequest.system = responsesRequest.instructions; } + // 处理 reasoning effort + if (responsesRequest.reasoning?.effort) { + claudeRequest.thinking = { + type: 'enabled', + budget_tokens: 1024 // 默认 budget + }; + } + // 处理 input 数组中的消息 if (responsesRequest.input && Array.isArray(responsesRequest.input)) { responsesRequest.input.forEach(item => { - if (item.type === 'message') { - const content = item.content - .filter(c => c.type === 'input_text') - .map(c => c.text) - .join('\n'); + const itemType = item.type || (item.role ? 'message' : ''); + + switch (itemType) { + case 'message': + const content = []; + if (Array.isArray(item.content)) { + item.content.forEach(c => { + if (c.type === 'input_text' || c.type === 'output_text') { + content.push({ type: 'text', text: c.text }); + } else if (c.type === 'input_image') { + const url = c.image_url?.url || c.url; + if (url && url.startsWith('data:')) { + const [mediaInfo, data] = url.split(';base64,'); + const mediaType = mediaInfo.replace('data:', ''); + content.push({ + type: 'image', + source: { + type: 'base64', + media_type: mediaType, + data: data + } + }); + } + } + }); + } else if (typeof item.content === 'string') { + content.push({ type: 'text', text: item.content }); + } + + if (content.length > 0) { + claudeRequest.messages.push({ + role: item.role === 'assistant' ? 'assistant' : 'user', + content: content.length === 1 && content[0].type === 'text' ? content[0].text : content + }); + } + break; - if (content) { + case 'function_call': claudeRequest.messages.push({ - role: item.role === 'assistant' ? 'assistant' : 'user', - content: content + role: 'assistant', + content: [{ + type: 'tool_use', + id: item.call_id || `toolu_${uuidv4().replace(/-/g, '')}`, + name: item.name, + input: typeof item.arguments === 'string' ? JSON.parse(item.arguments) : item.arguments + }] }); - } + break; + + case 'function_call_output': + claudeRequest.messages.push({ + role: 'user', + content: [{ + type: 'tool_result', + tool_use_id: item.call_id, + content: item.output + }] + }); + break; } }); } - // 如果有标准的 messages 字段,也支持 - if (responsesRequest.messages && Array.isArray(responsesRequest.messages)) { - const { systemMessages, otherMessages } = extractSystemMessages( - responsesRequest.messages - ); - - if (!claudeRequest.system && systemMessages.length > 0) { - const systemTexts = systemMessages.map(msg => extractText(msg.content)); - claudeRequest.system = systemTexts.join('\n'); + // 处理工具 + if (responsesRequest.tools && Array.isArray(responsesRequest.tools)) { + claudeRequest.tools = responsesRequest.tools.map(tool => ({ + name: tool.name, + description: tool.description, + input_schema: tool.parameters || tool.parametersJsonSchema || { type: 'object', properties: {} } + })); + } + + if (responsesRequest.tool_choice) { + if (typeof responsesRequest.tool_choice === 'string') { + if (responsesRequest.tool_choice === 'auto') { + claudeRequest.tool_choice = { type: 'auto' }; + } else if (responsesRequest.tool_choice === 'required') { + claudeRequest.tool_choice = { type: 'any' }; + } + } else if (responsesRequest.tool_choice.type === 'function') { + claudeRequest.tool_choice = { + type: 'tool', + name: responsesRequest.tool_choice.function.name + }; } - - otherMessages.forEach(msg => { - claudeRequest.messages.push({ - role: msg.role === 'assistant' ? 'assistant' : 'user', - content: typeof msg.content === 'string' ? msg.content : extractText(msg.content) - }); - }); - } - - // 复制其他参数 - if (responsesRequest.temperature !== undefined) { - claudeRequest.temperature = responsesRequest.temperature; - } - if (responsesRequest.top_p !== undefined) { - claudeRequest.top_p = responsesRequest.top_p; } return claudeRequest; @@ -292,30 +483,42 @@ export class OpenAIResponsesConverter extends BaseConverter { * 将 OpenAI Responses 响应转换为 Claude 响应 */ toClaudeResponse(responsesResponse, model) { - const content = responsesResponse.choices?.[0]?.message?.content || - responsesResponse.content || ''; + const content = []; + let stop_reason = 'end_turn'; + + if (responsesResponse.output && Array.isArray(responsesResponse.output)) { + responsesResponse.output.forEach(item => { + if (item.type === 'message') { + const text = item.content + ?.filter(c => c.type === 'output_text') + .map(c => c.text) + .join('') || ''; + if (text) { + content.push({ type: 'text', text: text }); + } + } else if (item.type === 'function_call') { + content.push({ + type: 'tool_use', + id: item.call_id, + name: item.name, + input: typeof item.arguments === 'string' ? JSON.parse(item.arguments) : item.arguments + }); + stop_reason = 'tool_use'; + } + }); + } return { id: responsesResponse.id || `msg_${Date.now()}`, type: 'message', role: 'assistant', - content: [{ - type: 'text', - text: content - }], + content: content, model: model || responsesResponse.model, - stop_reason: responsesResponse.choices?.[0]?.finish_reason || 'end_turn', + stop_reason: stop_reason, usage: { - input_tokens: responsesResponse.usage?.input_tokens || responsesResponse.usage?.prompt_tokens || 0, - cache_creation_input_tokens: 0, - cache_read_input_tokens: responsesResponse.usage?.input_tokens_details?.cached_tokens || 0, - output_tokens: responsesResponse.usage?.output_tokens || responsesResponse.usage?.completion_tokens || 0, - prompt_tokens: responsesResponse.usage?.input_tokens || responsesResponse.usage?.prompt_tokens || 0, - completion_tokens: responsesResponse.usage?.output_tokens || responsesResponse.usage?.completion_tokens || 0, - total_tokens: responsesResponse.usage?.total_tokens || - ((responsesResponse.usage?.input_tokens || responsesResponse.usage?.prompt_tokens || 0) + - (responsesResponse.usage?.output_tokens || responsesResponse.usage?.completion_tokens || 0)), - cached_tokens: responsesResponse.usage?.input_tokens_details?.cached_tokens || 0 + input_tokens: responsesResponse.usage?.input_tokens || 0, + output_tokens: responsesResponse.usage?.output_tokens || 0, + total_tokens: responsesResponse.usage?.total_tokens || 0 } }; } @@ -324,37 +527,61 @@ export class OpenAIResponsesConverter extends BaseConverter { * 将 OpenAI Responses 流式块转换为 Claude 流式块 */ toClaudeStreamChunk(responsesChunk, model) { - const delta = responsesChunk.choices?.[0]?.delta || responsesChunk.delta || {}; - const finishReason = responsesChunk.choices?.[0]?.finish_reason || - responsesChunk.finish_reason; - - if (finishReason) { + if (responsesChunk.type === 'response.created') { return { - type: 'message_stop' + type: 'message_start', + message: { + id: responsesChunk.response.id, + type: 'message', + role: 'assistant', + content: [], + model: model || responsesChunk.response.model + } }; } - if (delta.content) { + if (responsesChunk.type === 'response.output_text.delta') { return { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', - text: delta.content + text: responsesChunk.delta } }; } - return { - type: 'message_start', - message: { - id: responsesChunk.id || `msg_${Date.now()}`, - type: 'message', - role: 'assistant', - content: [], - model: model || responsesChunk.model - } - }; + if (responsesChunk.type === 'response.function_call_arguments.delta') { + return { + type: 'content_block_delta', + index: responsesChunk.output_index || 0, + delta: { + type: 'input_json_delta', + partial_json: responsesChunk.delta + } + }; + } + + if (responsesChunk.type === 'response.output_item.added' && responsesChunk.item?.type === 'function_call') { + return { + type: 'content_block_start', + index: responsesChunk.output_index || 0, + content_block: { + type: 'tool_use', + id: responsesChunk.item.call_id, + name: responsesChunk.item.name, + input: {} + } + }; + } + + if (responsesChunk.type === 'response.completed') { + return { + type: 'message_stop' + }; + } + + return null; } // ============================================================================= @@ -382,67 +609,92 @@ export class OpenAIResponsesConverter extends BaseConverter { // 处理 input 数组中的消息 if (responsesRequest.input && Array.isArray(responsesRequest.input)) { responsesRequest.input.forEach(item => { - // 如果 item 没有 type 属性,默认为 message - // 或者 item.type 明确为 message - if (!item.type || item.type === 'message') { - let content = ''; - if (Array.isArray(item.content)) { - content = item.content - .filter(c => c.type === 'input_text') - .map(c => c.text) - .join('\n'); - } else if (typeof item.content === 'string') { - content = item.content; - } + const itemType = item.type || (item.role ? 'message' : ''); + + switch (itemType) { + case 'message': + const parts = []; + if (Array.isArray(item.content)) { + item.content.forEach(c => { + if (c.type === 'input_text' || c.type === 'output_text') { + parts.push({ text: c.text }); + } else if (c.type === 'input_image') { + const url = c.image_url?.url || c.url; + if (url && url.startsWith('data:')) { + const [mediaInfo, data] = url.split(';base64,'); + const mimeType = mediaInfo.replace('data:', ''); + parts.push({ + inlineData: { + mimeType: mimeType, + data: data + } + }); + } + } + }); + } else if (typeof item.content === 'string') { + parts.push({ text: item.content }); + } + + if (parts.length > 0) { + geminiRequest.contents.push({ + role: item.role === 'assistant' ? 'model' : 'user', + parts: parts + }); + } + break; - if (content) { + case 'function_call': geminiRequest.contents.push({ - role: item.role === 'assistant' ? 'model' : 'user', + role: 'model', parts: [{ - text: content + functionCall: { + name: item.name, + args: typeof item.arguments === 'string' ? JSON.parse(item.arguments) : item.arguments + } }] }); - } + break; + + case 'function_call_output': + geminiRequest.contents.push({ + role: 'user', // Gemini function response role is user or tool? usually user/model + parts: [{ + functionResponse: { + name: item.name, + response: { content: item.output } + } + }] + }); + break; } }); } - // 如果有标准的 messages 字段,也支持 - if (responsesRequest.messages && Array.isArray(responsesRequest.messages)) { - const { systemMessages, otherMessages } = extractSystemMessages( - responsesRequest.messages - ); - - if (!geminiRequest.systemInstruction && systemMessages.length > 0) { - const systemTexts = systemMessages.map(msg => extractText(msg.content)); - geminiRequest.systemInstruction = { - parts: [{ - text: systemTexts.join('\n') - }] - }; - } - - otherMessages.forEach(msg => { - geminiRequest.contents.push({ - role: msg.role === 'assistant' ? 'model' : 'user', - parts: [{ - text: typeof msg.content === 'string' ? msg.content : extractText(msg.content) - }] - }); - }); - } - // 设置生成配置 if (responsesRequest.temperature !== undefined) { geminiRequest.generationConfig.temperature = responsesRequest.temperature; } - if (responsesRequest.max_tokens !== undefined) { + if (responsesRequest.max_output_tokens !== undefined) { + geminiRequest.generationConfig.maxOutputTokens = responsesRequest.max_output_tokens; + } else if (responsesRequest.max_tokens !== undefined) { geminiRequest.generationConfig.maxOutputTokens = responsesRequest.max_tokens; } if (responsesRequest.top_p !== undefined) { geminiRequest.generationConfig.topP = responsesRequest.top_p; } + // 处理工具 + if (responsesRequest.tools && Array.isArray(responsesRequest.tools)) { + geminiRequest.tools = [{ + functionDeclarations: responsesRequest.tools.map(tool => ({ + name: tool.name, + description: tool.description, + parameters: tool.parameters || tool.parametersJsonSchema || { type: 'object', properties: {} } + })) + }]; + } + return geminiRequest; } @@ -450,38 +702,43 @@ export class OpenAIResponsesConverter extends BaseConverter { * 将 OpenAI Responses 响应转换为 Gemini 响应 */ toGeminiResponse(responsesResponse, model) { - const content = responsesResponse.choices?.[0]?.message?.content || - responsesResponse.content || ''; + const parts = []; + let finishReason = 'STOP'; + + if (responsesResponse.output && Array.isArray(responsesResponse.output)) { + responsesResponse.output.forEach(item => { + if (item.type === 'message') { + const text = item.content + ?.filter(c => c.type === 'output_text') + .map(c => c.text) + .join('') || ''; + if (text) { + parts.push({ text: text }); + } + } else if (item.type === 'function_call') { + parts.push({ + functionCall: { + name: item.name, + args: typeof item.arguments === 'string' ? JSON.parse(item.arguments) : item.arguments + } + }); + } + }); + } return { candidates: [{ content: { - parts: [{ - text: content - }], + parts: parts, role: 'model' }, - finishReason: this.mapFinishReason( - responsesResponse.choices?.[0]?.finish_reason || 'STOP' - ), + finishReason: finishReason, index: 0 }], usageMetadata: { - promptTokenCount: responsesResponse.usage?.input_tokens || responsesResponse.usage?.prompt_tokens || 0, - candidatesTokenCount: responsesResponse.usage?.output_tokens || responsesResponse.usage?.completion_tokens || 0, - totalTokenCount: responsesResponse.usage?.total_tokens || - ((responsesResponse.usage?.input_tokens || responsesResponse.usage?.prompt_tokens || 0) + - (responsesResponse.usage?.output_tokens || responsesResponse.usage?.completion_tokens || 0)), - cachedContentTokenCount: responsesResponse.usage?.input_tokens_details?.cached_tokens || 0, - promptTokensDetails: [{ - modality: "TEXT", - tokenCount: responsesResponse.usage?.input_tokens || responsesResponse.usage?.prompt_tokens || 0 - }], - candidatesTokensDetails: [{ - modality: "TEXT", - tokenCount: responsesResponse.usage?.output_tokens || responsesResponse.usage?.completion_tokens || 0 - }], - thoughtsTokenCount: responsesResponse.usage?.output_tokens_details?.reasoning_tokens || 0 + promptTokenCount: responsesResponse.usage?.input_tokens || 0, + candidatesTokenCount: responsesResponse.usage?.output_tokens || 0, + totalTokenCount: responsesResponse.usage?.total_tokens || 0 } }; } @@ -490,22 +747,37 @@ export class OpenAIResponsesConverter extends BaseConverter { * 将 OpenAI Responses 流式块转换为 Gemini 流式块 */ toGeminiStreamChunk(responsesChunk, model) { - const delta = responsesChunk.choices?.[0]?.delta || responsesChunk.delta || {}; - const finishReason = responsesChunk.choices?.[0]?.finish_reason || - responsesChunk.finish_reason; + if (responsesChunk.type === 'response.output_text.delta') { + return { + candidates: [{ + content: { + parts: [{ text: responsesChunk.delta }], + role: 'model' + }, + index: 0 + }] + }; + } - return { - candidates: [{ - content: { - parts: delta.content ? [{ - text: delta.content - }] : [], - role: 'model' - }, - finishReason: finishReason ? this.mapFinishReason(finishReason) : null, - index: 0 - }] - }; + if (responsesChunk.type === 'response.function_call_arguments.delta') { + // Gemini 不太支持流式 functionCall 参数,这里只能简单映射 + return { + candidates: [{ + content: { + parts: [{ + functionCall: { + name: '', // 无法在 delta 中获取名称 + args: responsesChunk.delta + } + }], + role: 'model' + }, + index: 0 + }] + }; + } + + return null; } // ============================================================================= @@ -578,5 +850,218 @@ export class OpenAIResponsesConverter extends BaseConverter { }; } -} + // ============================================================================= + // 转换到 Codex 格式 + // ============================================================================= + /** + * OpenAI Responses → Codex 请求转换 + */ + toCodexRequest(responsesRequest) { + const codexRequest = { + model: responsesRequest.model, + instructions: responsesRequest.instructions || '', + input: [], + stream: responsesRequest.stream || false, + store: false, + reasoning: { + effort: responsesRequest.reasoning?.effort || 'medium', + summary: 'auto' + }, + parallel_tool_calls: responsesRequest.parallel_tool_calls ?? true, + include: ['reasoning.encrypted_content'] + }; + + // 处理 input + if (responsesRequest.input && Array.isArray(responsesRequest.input)) { + for (const item of responsesRequest.input) { + const itemType = item.type || (item.role ? 'message' : ''); + + if (itemType === 'message') { + const content = []; + if (Array.isArray(item.content)) { + item.content.forEach(c => { + content.push({ + type: item.role === 'assistant' ? 'output_text' : 'input_text', + text: c.text + }); + }); + } else if (typeof item.content === 'string') { + content.push({ + type: item.role === 'assistant' ? 'output_text' : 'input_text', + text: item.content + }); + } + + codexRequest.input.push({ + type: 'message', + role: item.role === 'system' ? 'developer' : item.role, + content: content + }); + } else if (itemType === 'function_call') { + codexRequest.input.push({ + type: 'function_call', + call_id: item.call_id, + name: item.name, + arguments: typeof item.arguments === 'string' ? item.arguments : JSON.stringify(item.arguments) + }); + } else if (itemType === 'function_call_output') { + codexRequest.input.push({ + type: 'function_call_output', + call_id: item.call_id, + output: item.output + }); + } + } + } + + // 处理工具 + if (responsesRequest.tools) { + codexRequest.tools = responsesRequest.tools.map(tool => ({ + type: 'function', + name: tool.name, + description: tool.description, + parameters: tool.parameters || tool.parametersJsonSchema || { type: 'object', properties: {} } + })); + } + + return codexRequest; + } + + /** + * OpenAI Responses → Codex 响应转换 (实际上是 Codex 转 OpenAI Responses) + */ + toCodexResponse(codexResponse, model) { + const output = []; + const responseData = codexResponse.response || codexResponse; + + if (responseData.output && Array.isArray(responseData.output)) { + responseData.output.forEach(item => { + if (item.type === 'message' && item.content) { + const content = item.content.map(c => ({ + type: c.type === 'output_text' ? 'output_text' : 'input_text', + text: c.text, + annotations: [] + })); + output.push({ + id: item.id || `msg_${uuidv4().replace(/-/g, '')}`, + type: "message", + role: item.role || "assistant", + status: item.status || "completed", + content: content + }); + } else if (item.type === 'reasoning') { + output.push({ + id: item.id || `rs_${uuidv4().replace(/-/g, '')}`, + type: "reasoning", + status: item.status || "completed", + summary: item.summary || [] + }); + } else if (item.type === 'function_call') { + output.push({ + id: item.id || `fc_${uuidv4().replace(/-/g, '')}`, + call_id: item.call_id, + type: "function_call", + name: item.name, + arguments: typeof item.arguments === 'string' ? item.arguments : JSON.stringify(item.arguments), + status: item.status || "completed" + }); + } + }); + } + + return { + id: responseData.id || `resp_${uuidv4().replace(/-/g, '')}`, + object: "response", + created_at: responseData.created_at || Math.floor(Date.now() / 1000), + model: model || responseData.model, + status: responseData.status || "completed", + output: output, + usage: { + input_tokens: responseData.usage?.input_tokens || 0, + output_tokens: responseData.usage?.output_tokens || 0, + total_tokens: responseData.usage?.total_tokens || 0 + } + }; + } + + /** + * OpenAI Responses → Codex 流式响应转换 (实际上是 Codex 转 OpenAI Responses) + */ + toCodexStreamChunk(codexChunk, model) { + const type = codexChunk.type; + const resId = codexChunk.response?.id || 'default'; + const events = []; + + if (type === 'response.created') { + events.push( + generateResponseCreated(resId, model || codexChunk.response?.model), + generateResponseInProgress(resId) + ); + return events; + } + + if (type === 'response.reasoning_summary_text.delta') { + events.push({ + type: "response.reasoning_summary_text.delta", + response_id: resId, + item_id: codexChunk.item_id, + output_index: codexChunk.output_index, + summary_index: codexChunk.summary_index, + delta: codexChunk.delta + }); + return events; + } + + if (type === 'response.output_text.delta') { + events.push({ + type: "response.output_text.delta", + response_id: resId, + item_id: codexChunk.item_id, + output_index: codexChunk.output_index, + content_index: codexChunk.content_index, + delta: codexChunk.delta + }); + return events; + } + + if (type === 'response.function_call_arguments.delta') { + events.push({ + type: "response.function_call_arguments.delta", + response_id: resId, + item_id: codexChunk.item_id, + output_index: codexChunk.output_index, + delta: codexChunk.delta + }); + return events; + } + + if (type === 'response.output_item.added') { + events.push({ + type: "response.output_item.added", + response_id: resId, + output_index: codexChunk.output_index, + item: codexChunk.item + }); + return events; + } + + if (type === 'response.completed') { + const completedEvent = generateResponseCompleted(resId); + completedEvent.response = { + ...completedEvent.response, + ...codexChunk.response + }; + events.push(completedEvent); + return events; + } + + // 透传其他 response.* 事件 + if (type && type.startsWith('response.')) { + return [codexChunk]; + } + + return null; + } + +} diff --git a/src/plugins/ai-monitor/index.js b/src/plugins/ai-monitor/index.js index 7063d8e..6e259ac 100644 --- a/src/plugins/ai-monitor/index.js +++ b/src/plugins/ai-monitor/index.js @@ -111,8 +111,23 @@ const aiMonitorPlugin = { } const cache = aiMonitorPlugin.streamCache.get(requestId); - cache.nativeChunks.push(nativeChunk); - cache.convertedChunks.push(chunkToSend); + + // 过滤 null 值,并判断是否为数组类型 + if (nativeChunk != null) { + if (Array.isArray(nativeChunk)) { + cache.nativeChunks.push(...nativeChunk.filter(item => item != null)); + } else { + cache.nativeChunks.push(nativeChunk); + } + } + + if (chunkToSend != null) { + if (Array.isArray(chunkToSend)) { + cache.convertedChunks.push(...chunkToSend.filter(item => item != null)); + } else { + cache.convertedChunks.push(chunkToSend); + } + } }, /** diff --git a/src/providers/adapter.js b/src/providers/adapter.js index ba972f1..749d3e5 100644 --- a/src/providers/adapter.js +++ b/src/providers/adapter.js @@ -568,6 +568,18 @@ export class CodexApiServiceAdapter extends ApiServiceAdapter { isExpiryDateNear() { return this.codexApiService.isExpiryDateNear(); } + + /** + * 获取用量限制信息 + * @returns {Promise} 用量限制信息 + */ + async getUsageLimits() { + if (!this.codexApiService.isInitialized) { + logger.warn("codexApiService not initialized, attempting to re-initialize..."); + await this.codexApiService.initialize(); + } + return this.codexApiService.getUsageLimits(); + } } // Forward API 服务适配器 diff --git a/src/providers/gemini/antigravity-core.js b/src/providers/gemini/antigravity-core.js index 15dc14b..1a9e3bf 100644 --- a/src/providers/gemini/antigravity-core.js +++ b/src/providers/gemini/antigravity-core.js @@ -36,7 +36,7 @@ const httpsAgent = new https.Agent({ const CREDENTIALS_DIR = '.antigravity'; const CREDENTIALS_FILE = 'oauth_creds.json'; -// Base URLs - 按照 Go 代码的降级顺序 +// Base URLs const ANTIGRAVITY_BASE_URL_DAILY = 'https://daily-cloudcode-pa.googleapis.com'; const ANTIGRAVITY_SANDBOX_BASE_URL_DAILY = 'https://daily-cloudcode-pa.sandbox.googleapis.com'; const ANTIGRAVITY_BASE_URL_PROD = 'https://autopush-cloudcode-pa.sandbox.googleapis.com'; @@ -735,7 +735,7 @@ export class AntigravityApiService { this.projectId = config.PROJECT_ID; this.uuid = config.uuid; // 保存 uuid 用于缓存管理 - // 多环境降级顺序 - 按照 Go 代码的顺序 + // 多环境降级顺序 this.baseURLs = this.getBaseURLFallbackOrder(config); // 保存代理配置供后续使用 diff --git a/src/providers/openai/codex-core.js b/src/providers/openai/codex-core.js index 83a8c3b..d756b28 100644 --- a/src/providers/openai/codex-core.js +++ b/src/providers/openai/codex-core.js @@ -141,7 +141,7 @@ export class CodexApiService { } const url = `${this.baseUrl}/responses`; - const body = this.prepareRequestBody(model, requestBody, false); + const body = this.prepareRequestBody(model, requestBody, true); const headers = this.buildHeaders(body.prompt_cache_key); try { @@ -488,5 +488,106 @@ export class CodexApiService { this.cleanupInterval = null; } } + + /** + * 获取使用限制信息 + * @returns {Promise} 使用限制信息(通用格式) + */ + async getUsageLimits() { + if (!this.isInitialized) { + await this.initialize(); + } + + try { + const url = 'https://chatgpt.com/backend-api/wham/usage'; + const headers = { + 'user-agent': 'codex_cli_rs/0.89.0 (Windows 10.0.26100; x86_64) WindowsTerminal', + 'authorization': `Bearer ${this.accessToken}`, + 'chatgpt-account-id': this.accountId, + 'accept': '*/*', + 'host': 'chatgpt.com', + 'Connection': 'close' + }; + + const config = { + headers, + timeout: 30000 // 30 秒超时 + }; + + // 配置代理 + const proxyConfig = getProxyConfigForProvider(this.config, 'codex'); + if (proxyConfig) { + config.httpAgent = proxyConfig.httpAgent; + config.httpsAgent = proxyConfig.httpsAgent; + } + + const response = await axios.get(url, config); + + // 解析响应数据并转换为通用格式 + const data = response.data; + + // 通用格式:{ lastUpdated, models: { "model-id": { remaining, resetTime, resetTimeRaw } } } + const result = { + lastUpdated: Date.now(), + models: {} + }; + + // 从 rate_limit 提取配额信息 + // Codex 使用百分比表示使用量,我们需要转换为剩余量 + if (data.rate_limit) { + const primaryWindow = data.rate_limit.primary_window; + const secondaryWindow = data.rate_limit.secondary_window; + + // 使用主窗口的数据作为主要配额信息 + if (primaryWindow) { + // remaining = 1 - (used_percent / 100) + const remaining = 1 - (primaryWindow.used_percent || 0) / 100; + const resetTime = primaryWindow.reset_at ? new Date(primaryWindow.reset_at * 1000).toISOString() : null; + + // 为所有 Codex 模型设置相同的配额信息 + const codexModels = ['default']; + for (const modelId of codexModels) { + result.models[modelId] = { + remaining: Math.max(0, Math.min(1, remaining)), // 确保在 0-1 之间 + resetTime: resetTime, + resetTimeRaw: primaryWindow.reset_at + }; + } + } + } + + // 保存原始响应数据供需要时使用 + result.raw = { + planType: data.plan_type || 'unknown', + rateLimit: data.rate_limit, + codeReviewRateLimit: data.code_review_rate_limit, + credits: data.credits + }; + + logger.info(`[Codex] Successfully fetched usage limits for plan: ${result.raw.planType}`); + return result; + } catch (error) { + if (error.response?.status === 401) { + logger.info('[Codex] Received 401 during getUsageLimits. Triggering background refresh via PoolManager...'); + + // 标记当前凭证为不健康 + const poolManager = getProviderPoolManager(); + if (poolManager && this.uuid) { + logger.info(`[Codex] Marking credential ${this.uuid} as needs refresh. Reason: 401 Unauthorized in getUsageLimits`); + poolManager.markProviderNeedRefresh(MODEL_PROVIDER.CODEX_API, { + uuid: this.uuid + }); + error.credentialMarkedUnhealthy = true; + } + + // Mark error for credential switch without recording error count + error.shouldSwitchCredential = true; + error.skipErrorCount = true; + } + + logger.error('[Codex] Failed to get usage limits:', error.message); + throw error; + } + } } diff --git a/src/providers/openai/codex-responses-strategy.js b/src/providers/openai/codex-responses-strategy.js new file mode 100644 index 0000000..be61a83 --- /dev/null +++ b/src/providers/openai/codex-responses-strategy.js @@ -0,0 +1,124 @@ +import { ProviderStrategy } from '../../utils/provider-strategy.js'; +import logger from '../../utils/logger.js'; +import { extractSystemPromptFromRequestBody, MODEL_PROTOCOL_PREFIX } from '../../utils/common.js'; + +/** + * OpenAI Responses API strategy implementation. + * Migrated from Chat Completions API to Responses API. + */ +class CodexResponsesAPIStrategy extends ProviderStrategy { + extractModelAndStreamInfo(req, requestBody) { + const model = requestBody.model; + const isStream = requestBody.stream === true; + return { model, isStream }; + } + + extractResponseText(response) { + if (!response.output) { + return ''; + } + + // In Responses API, output is an array of items + for (const item of response.output) { + if (item.type === 'message' && item.content && item.content.length > 0) { + for (const content of item.content) { + if (content.type === 'output_text' && content.text) { + return content.text; + } + } + } + } + return ''; + } + + extractPromptText(requestBody) { + // In Responses API, input can be a string or array of items + if (typeof requestBody.input === 'string') { + return requestBody.input; + } else if (Array.isArray(requestBody.input)) { + // If input is an array of items/messages, get the last user content + const userInputItems = requestBody.input.filter(item => + (item.role && item.role === 'user') || + (item.type && item.type === 'message' && item.role === 'user') || + (item.type && item.type === 'user') + ); + + if (userInputItems.length > 0) { + const lastInput = userInputItems[userInputItems.length - 1]; + if (typeof lastInput.content === 'string') { + return lastInput.content; + } else if (Array.isArray(lastInput.content)) { + return lastInput.content.map(item => item.text || item.content || '').join('\n'); + } + } + } + return ''; + } + + async applySystemPromptFromFile(config, requestBody) { + if (!config.SYSTEM_PROMPT_FILE_PATH) { + return requestBody; + } + + const filePromptContent = config.SYSTEM_PROMPT_CONTENT; + if (filePromptContent === null) { + return requestBody; + } + + // In Responses API, system instructions are typically passed in 'instructions' field + // or in the input array with role: 'developer' + requestBody.instructions = requestBody.instructions || filePromptContent; + + // If using instructions field is not desired, append to input array instead + if (!requestBody.instructions || config.SYSTEM_PROMPT_MODE === 'append') { + if (typeof requestBody.input === 'string') { + // Convert to array format to add system message + requestBody.input = [ + { role: 'developer', content: filePromptContent }, + { role: 'user', content: requestBody.input } + ]; + } else if (Array.isArray(requestBody.input)) { + // Check if system message already exists + const systemMessageIndex = requestBody.input.findIndex(m => + m.role === 'developer' || (m.type && m.type === 'developer') + ); + + if (systemMessageIndex !== -1) { + requestBody.input[systemMessageIndex].content = filePromptContent; + } else { + requestBody.input.unshift({ role: 'developer', content: filePromptContent }); + } + } else { + // If input is not defined, initialize with system message + requestBody.input = [{ role: 'developer', content: filePromptContent }]; + } + } else if (requestBody.instructions) { + // If system prompt mode is not append, then replace the instructions + requestBody.instructions = filePromptContent; + } + + logger.info(`[System Prompt] Applied system prompt from ${config.SYSTEM_PROMPT_FILE_PATH} in '${config.SYSTEM_PROMPT_MODE}' mode for provider 'responses'.`); + + return requestBody; + } + + async manageSystemPrompt(requestBody) { + // For Responses API, we may extract instructions or system messages from input + let incomingSystemText = ''; + + if (requestBody.instructions) { + incomingSystemText = requestBody.instructions; + } else if (Array.isArray(requestBody.input)) { + const systemMessage = requestBody.input.find(item => + item.role === 'developer' || (item.type && item.type === 'developer') + ); + if (systemMessage && systemMessage.content) { + incomingSystemText = systemMessage.content; + } + } + + await this._updateSystemPromptFile(incomingSystemText, MODEL_PROTOCOL_PREFIX.OPENAI); + } +} + +export { CodexResponsesAPIStrategy }; diff --git a/src/services/ui-manager.js b/src/services/ui-manager.js index 9f269f8..1bc5708 100644 --- a/src/services/ui-manager.js +++ b/src/services/ui-manager.js @@ -268,6 +268,11 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo return await usageApi.handleGetUsage(req, res, currentConfig, providerPoolManager); } + // Get supported providers for usage query + if (method === 'GET' && pathParam === '/api/usage/supported-providers') { + return await usageApi.handleGetSupportedProviders(req, res); + } + // Get usage limits for a specific provider type const usageProviderMatch = pathParam.match(/^\/api\/usage\/([^\/]+)$/); if (method === 'GET' && usageProviderMatch) { diff --git a/src/services/usage-service.js b/src/services/usage-service.js index 77fcb07..fb838c8 100644 --- a/src/services/usage-service.js +++ b/src/services/usage-service.js @@ -17,6 +17,7 @@ export class UsageService { [MODEL_PROVIDER.KIRO_API]: this.getKiroUsage.bind(this), [MODEL_PROVIDER.GEMINI_CLI]: this.getGeminiUsage.bind(this), [MODEL_PROVIDER.ANTIGRAVITY]: this.getAntigravityUsage.bind(this), + [MODEL_PROVIDER.CODEX_API]: this.getCodexUsage.bind(this), }; } @@ -157,6 +158,32 @@ export class UsageService { throw new Error(`Antigravity 服务实例不支持用量查询: ${providerKey}`); } + /** + * 获取 Codex 提供商的用量信息 + * @param {string} [uuid] - 可选的提供商实例 UUID + * @returns {Promise} Codex 用量信息 + */ + async getCodexUsage(uuid = null) { + const providerKey = uuid ? MODEL_PROVIDER.CODEX_API + uuid : MODEL_PROVIDER.CODEX_API; + const adapter = serviceInstances[providerKey]; + + if (!adapter) { + throw new Error(`Codex 服务实例未找到: ${providerKey}`); + } + + // 使用适配器的 getUsageLimits 方法 + if (typeof adapter.getUsageLimits === 'function') { + return adapter.getUsageLimits(); + } + + // 兼容直接访问 codexApiService 的情况 + if (adapter.codexApiService && typeof adapter.codexApiService.getUsageLimits === 'function') { + return adapter.codexApiService.getUsageLimits(); + } + + throw new Error(`Codex 服务实例不支持用量查询: ${providerKey}`); + } + /** * 获取支持用量查询的提供商列表 * @returns {Array} 支持的提供商类型列表 @@ -523,4 +550,127 @@ export function formatAntigravityUsage(usageData) { } return result; -} \ No newline at end of file +} + +/** + * 格式化 Codex 用量信息为易读格式(映射到 Kiro 数据结构) + * @param {Object} usageData - 原始用量数据 + * @returns {Object} 格式化后的用量信息 + */ +export function formatCodexUsage(usageData) { + if (!usageData) { + return null; + } + + const TZ_OFFSET = 8 * 60 * 60 * 1000; // Beijing timezone offset + + /** + * 将 UTC 时间转换为北京时间 + * @param {string} utcString - UTC 时间字符串 + * @returns {string} 北京时间字符串 + */ + function utcToBeijing(utcString) { + try { + if (!utcString) return '--'; + const utcDate = new Date(utcString); + const beijingTime = new Date(utcDate.getTime() + TZ_OFFSET); + return beijingTime + .toLocaleString('zh-CN', { + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit' + }) + .replace(/\//g, '-'); + } catch (e) { + return '--'; + } + } + + const result = { + // 基本信息 - 映射到 Kiro 结构 + daysUntilReset: null, + nextDateReset: null, + + // 订阅信息 + subscription: { + title: usageData.raw?.planType ? `Codex (${usageData.raw.planType})` : 'Codex OAuth', + type: 'openai-codex-oauth', + upgradeCapability: null, + overageCapability: null + }, + + // 用户信息 + user: { + email: null, + userId: null + }, + + // 用量明细 + usageBreakdown: [] + }; + + // 从 raw.rateLimit 提取重置时间 + if (usageData.raw?.rateLimit?.primaryWindow?.resetAt) { + const resetTimestamp = usageData.raw.rateLimit.primaryWindow.resetAt; + result.nextDateReset = new Date(resetTimestamp * 1000).toISOString(); + // 计算距离重置的天数 + const resetDate = new Date(resetTimestamp * 1000); + const now = new Date(); + const diffTime = resetDate.getTime() - now.getTime(); + result.daysUntilReset = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); + } + + // 解析模型配额信息 + if (usageData.models && typeof usageData.models === 'object') { + for (const [modelName, modelInfo] of Object.entries(usageData.models)) { + // Codex 返回的数据结构:{ remaining, resetTime, resetTimeRaw } + // remaining 是 0-1 之间的比例值,表示剩余配额百分比 + const remainingPercent = typeof modelInfo.remaining === 'number' ? modelInfo.remaining : 1; + const usedPercent = 1 - remainingPercent; + + const item = { + resourceType: 'MODEL_USAGE', + displayName: modelInfo.displayName || modelName, + displayNamePlural: modelInfo.displayName || modelName, + unit: 'quota', + currency: null, + + // 当前用量 - Codex 返回的是剩余比例,转换为已用比例(百分比形式) + currentUsage: Math.round(usedPercent * 100), + usageLimit: 100, // 以百分比表示,总量为 100% + + // 超额信息 + currentOverages: 0, + overageCap: 0, + overageRate: null, + overageCharges: 0, + + // 下次重置时间 + nextDateReset: modelInfo.resetTimeRaw ? new Date(modelInfo.resetTimeRaw * 1000).toISOString() : + (modelInfo.resetTime ? new Date(modelInfo.resetTime).toISOString() : null), + + // 免费试用信息 + freeTrial: null, + + // 奖励信息 + bonuses: [], + + // 额外的 Codex 特有信息 + modelName: modelName, + remaining: remainingPercent, + remainingPercent: Math.round(remainingPercent * 100), // 剩余百分比 + resetTime: (modelInfo.resetTimeRaw || modelInfo.resetTime) ? + utcToBeijing(modelInfo.resetTimeRaw ? new Date(modelInfo.resetTimeRaw * 1000).toISOString() : modelInfo.resetTime) : '--', + resetTimeRaw: modelInfo.resetTimeRaw || modelInfo.resetTime || null, + + // 注入 raw 窗口信息以便前端使用 + rateLimit: usageData.raw?.rateLimit + }; + + result.usageBreakdown.push(item); + } + } + + return result; +} diff --git a/src/ui-modules/usage-api.js b/src/ui-modules/usage-api.js index 09695bc..e0e4a67 100644 --- a/src/ui-modules/usage-api.js +++ b/src/ui-modules/usage-api.js @@ -1,7 +1,7 @@ import { CONFIG } from '../core/config-manager.js'; import logger from '../utils/logger.js'; import { serviceInstances, getServiceAdapter } from '../providers/adapter.js'; -import { formatKiroUsage, formatGeminiUsage, formatAntigravityUsage } from '../services/usage-service.js'; +import { formatKiroUsage, formatGeminiUsage, formatAntigravityUsage, formatCodexUsage } from '../services/usage-service.js'; import { readUsageCache, writeUsageCache, readProviderUsageCache, updateProviderUsageCache } from './usage-cache.js'; import path from 'path'; @@ -18,7 +18,7 @@ async function getAllProvidersUsage(currentConfig, providerPoolManager) { }; // 支持用量查询的提供商列表 - const supportedProviders = ['claude-kiro-oauth', 'gemini-cli-oauth', 'gemini-antigravity']; + const supportedProviders = ['claude-kiro-oauth', 'gemini-cli-oauth', 'gemini-antigravity', 'openai-codex-oauth']; // 并发获取所有提供商的用量数据 const usagePromises = supportedProviders.map(async (providerType) => { @@ -169,6 +169,17 @@ async function getAdapterUsage(adapter, providerType) { } throw new Error('This adapter does not support usage query'); } + + if (providerType === 'openai-codex-oauth') { + if (typeof adapter.getUsageLimits === 'function') { + const rawUsage = await adapter.getUsageLimits(); + return formatCodexUsage(rawUsage); + } else if (adapter.codexApiService && typeof adapter.codexApiService.getUsageLimits === 'function') { + const rawUsage = await adapter.codexApiService.getUsageLimits(); + return formatCodexUsage(rawUsage); + } + throw new Error('This adapter does not support usage query'); + } throw new Error(`Unsupported provider type: ${providerType}`); } @@ -194,6 +205,7 @@ function getProviderDisplayName(provider, providerType) { 'claude-kiro-oauth': 'KIRO_OAUTH_CREDS_FILE_PATH', 'gemini-cli-oauth': 'GEMINI_OAUTH_CREDS_FILE_PATH', 'gemini-antigravity': 'ANTIGRAVITY_OAUTH_CREDS_FILE_PATH', + 'openai-codex-oauth': 'CODEX_OAUTH_CREDS_FILE_PATH', 'openai-qwen-oauth': 'QWEN_OAUTH_CREDS_FILE_PATH', 'openai-iflow': 'IFLOW_TOKEN_FILE_PATH' }[providerType]; @@ -208,6 +220,27 @@ function getProviderDisplayName(provider, providerType) { return 'Unnamed'; } +/** + * 获取支持用量查询的提供商列表 + */ +export async function handleGetSupportedProviders(req, res) { + try { + const supportedProviders = ['claude-kiro-oauth', 'gemini-cli-oauth', 'gemini-antigravity', 'openai-codex-oauth']; + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(supportedProviders)); + return true; + } catch (error) { + logger.error('[Usage API] Failed to get supported providers:', error); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + error: { + message: 'Failed to get supported providers: ' + error.message + } + })); + return true; + } +} + /** * 获取所有提供商的用量限制 */ diff --git a/src/utils/common.js b/src/utils/common.js index 6ecd330..b529eb8 100644 --- a/src/utils/common.js +++ b/src/utils/common.js @@ -297,7 +297,8 @@ export async function handleStreamRequest(res, service, model, requestBody, from requestBody.model = model; const nativeStream = await service.generateContentStream(model, requestBody); const addEvent = getProtocolPrefix(fromProvider) === MODEL_PROTOCOL_PREFIX.CLAUDE || getProtocolPrefix(fromProvider) === MODEL_PROTOCOL_PREFIX.OPENAI_RESPONSES; - const openStop = getProtocolPrefix(fromProvider) === MODEL_PROTOCOL_PREFIX.OPENAI ; + + let hasToolCall = false; try { for await (const nativeChunk of nativeStream) { @@ -335,6 +336,35 @@ export async function handleStreamRequest(res, service, model, requestBody, from const chunksToSend = Array.isArray(chunkToSend) ? chunkToSend : [chunkToSend]; for (const chunk of chunksToSend) { + // [FIX] 跟踪工具调用并在结束时修正 finish_reason + // OpenAI 格式 + if (chunk.choices?.[0]?.delta?.tool_calls || chunk.choices?.[0]?.finish_reason === 'tool_calls') { + hasToolCall = true; + } + // Claude 格式 + if (chunk.type === 'content_block_start' && chunk.content_block?.type === 'tool_use') { + hasToolCall = true; + } + if (chunk.type === 'message_delta' && (chunk.delta?.stop_reason === 'tool_use' || chunk.stop_reason === 'tool_use')) { + hasToolCall = true; + } + // Gemini 格式 + if (chunk.candidates?.[0]?.content?.parts?.some(p => p.functionCall)) { + hasToolCall = true; + } + + // 如果之前有工具调用,且当前 chunk 是正常结束,修正为 tool_calls / tool_use / FINISH_REASON_TOOL_CALLS + if (hasToolCall && needsConversion) { + if (chunk.choices?.[0]?.finish_reason === 'stop') { + chunk.choices[0].finish_reason = 'tool_calls'; + } else if (chunk.type === 'message_delta' && chunk.delta?.stop_reason === 'end_turn') { + chunk.delta.stop_reason = 'tool_use'; + } else if (chunk.candidates?.[0]?.finishReason === 'STOP' || chunk.candidates?.[0]?.finishReason === 'stop') { + // 修正 Gemini 原生格式的结束原因 + chunk.candidates[0].finishReason = 'TOOL_CALLS'; + } + } + if (addEvent) { // fullOldResponseJson += chunk.type+"\n"; // fullResponseJson += chunk.type+"\n"; @@ -348,10 +378,6 @@ export async function handleStreamRequest(res, service, model, requestBody, from // logger.info(`data: ${JSON.stringify(chunk)}\n`); } } - if (openStop && needsConversion) { - res.write(`data: ${JSON.stringify(getOpenAIStreamChunkStop(model))}\n\n`); - // logger.info(`data: ${JSON.stringify(getOpenAIStreamChunkStop(model))}\n`); - } // 流式请求成功完成,统计使用次数,错误次数重置为0 if (providerPoolManager && pooluuid) { diff --git a/src/utils/provider-strategies.js b/src/utils/provider-strategies.js index b4495c7..5c02b1a 100644 --- a/src/utils/provider-strategies.js +++ b/src/utils/provider-strategies.js @@ -3,6 +3,7 @@ import { GeminiStrategy } from '../providers/gemini/gemini-strategy.js'; import { OpenAIStrategy } from '../providers/openai/openai-strategy.js'; import { ClaudeStrategy } from '../providers/claude/claude-strategy.js'; import { ResponsesAPIStrategy } from '../providers/openai/openai-responses-strategy.js'; +import { CodexResponsesAPIStrategy } from '../providers/openai/codex-responses-strategy.js'; import { ForwardStrategy } from '../providers/forward/forward-strategy.js'; /** @@ -20,7 +21,7 @@ class ProviderStrategyFactory { case MODEL_PROTOCOL_PREFIX.CLAUDE: return new ClaudeStrategy(); case MODEL_PROTOCOL_PREFIX.CODEX: - return new ResponsesAPIStrategy(); + return new CodexResponsesAPIStrategy(); case MODEL_PROTOCOL_PREFIX.FORWARD: return new ForwardStrategy(); default: diff --git a/static/app/i18n.js b/static/app/i18n.js index a4e2a2a..872fa82 100644 --- a/static/app/i18n.js +++ b/static/app/i18n.js @@ -517,6 +517,7 @@ const translations = { 'usage.refresh': '刷新用量', 'usage.lastUpdate': '上次更新: {time}', 'usage.lastUpdateCache': '缓存时间: {time}', + 'usage.supportedProvidersPrefix': '支持用量查询的提供商:', 'usage.loading': '正在加载用量数据...', 'usage.empty': '点击"刷新用量"按钮获取授权文件用量信息', 'usage.noData': '暂无用量数据', @@ -1275,6 +1276,7 @@ const translations = { 'usage.refresh': 'Refresh Usage', 'usage.lastUpdate': 'Last Update: {time}', 'usage.lastUpdateCache': 'Cache Time: {time}', + 'usage.supportedProvidersPrefix': 'Providers supporting usage query:', 'usage.loading': 'Loading usage data...', 'usage.empty': 'Click "Refresh Usage" button to get authorization file usage information', 'usage.noData': 'No usage data available', diff --git a/static/app/usage-manager.js b/static/app/usage-manager.js index 6bd550c..4f416e7 100644 --- a/static/app/usage-manager.js +++ b/static/app/usage-manager.js @@ -15,6 +15,39 @@ export function initUsageManager() { // 初始化时自动加载缓存数据 loadUsage(); + loadSupportedProviders(); +} + +/** + * 加载支持用量查询的提供商列表 + */ +async function loadSupportedProviders() { + const listEl = document.getElementById('supportedProvidersList'); + if (!listEl) return; + + try { + const response = await fetch('/api/usage/supported-providers', { + method: 'GET', + headers: getAuthHeaders() + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + const providers = await response.json(); + + listEl.innerHTML = ''; + providers.forEach(provider => { + const tag = document.createElement('span'); + tag.className = 'provider-tag'; + tag.textContent = getProviderDisplayName(provider); + listEl.appendChild(tag); + }); + } catch (error) { + console.error('获取支持的提供商列表失败:', error); + listEl.innerHTML = 'Failed to load'; + } } /** @@ -467,6 +500,11 @@ function renderUsageDetails(usage) { * @returns {string} HTML 字符串 */ function createUsageBreakdownHTML(breakdown) { + // 特殊处理 Codex + if (breakdown.rateLimit && breakdown.rateLimit.primary_window) { + return createCodexUsageBreakdownHTML(breakdown); + } + const usagePercent = breakdown.usageLimit > 0 ? Math.min(100, (breakdown.currentUsage / breakdown.usageLimit) * 100) : 0; @@ -514,6 +552,78 @@ function createUsageBreakdownHTML(breakdown) { return html; } +/** + * 创建 Codex 专用的用量明细 HTML + * @param {Object} breakdown - 包含 rateLimit 的用量明细 + * @returns {string} HTML 字符串 + */ +function createCodexUsageBreakdownHTML(breakdown) { + const rl = breakdown.rateLimit; + const primary = rl.primary_window; + const secondary = rl.secondary_window; + + const primaryPercent = primary.used_percent || 0; + const primaryProgressClass = primaryPercent >= 90 ? 'danger' : (primaryPercent >= 70 ? 'warning' : 'normal'); + + const primaryLimitHours = Math.round(primary.limit_window_seconds / 3600); + const primaryResetText = formatTimeRemaining(primary.reset_after_seconds); + + let html = ` +
+
+ ${primaryLimitHours} 小时限制 + ${primaryPercent}% +
+
+
+
+
+ 将在 ${primaryResetText} 后重置 +
+ `; + + if (secondary) { + const secondaryPercent = secondary.used_percent || 0; + const secondaryProgressClass = secondaryPercent >= 90 ? 'danger' : (secondaryPercent >= 70 ? 'warning' : 'normal'); + const secondaryResetText = formatTimeRemaining(secondary.reset_after_seconds); + + html += ` +
+
+ 每周限制 + ${secondaryPercent}% +
+
+
+
+
+ 将在 ${secondaryResetText} 后重置 +
+
+ `; + } + + html += '
'; + return html; +} + +/** + * 格式化剩余时间 + * @param {number} seconds - 秒数 + * @returns {string} 格式化后的时间 + */ +function formatTimeRemaining(seconds) { + if (seconds <= 0) return '即将'; + + const days = Math.floor(seconds / 86400); + const hours = Math.floor((seconds % 86400) / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + + if (days > 0) return `${days}天${hours}小时`; + if (hours > 0) return `${hours}小时${minutes}分`; + return `${minutes}分钟`; +} + /** * 计算总用量(包含基础用量、免费试用和奖励) * @param {Array} usageBreakdown - 用量明细数组 @@ -569,6 +679,7 @@ function getProviderDisplayName(providerType) { 'claude-kiro-oauth': 'Claude Kiro OAuth', 'gemini-cli-oauth': 'Gemini CLI OAuth', 'gemini-antigravity': 'Gemini Antigravity', + 'openai-codex-oauth': 'Codex OAuth', 'openai-qwen-oauth': 'Qwen OAuth' }; return names[providerType] || providerType; @@ -584,6 +695,7 @@ function getProviderIcon(providerType) { 'claude-kiro-oauth': 'fas fa-robot', 'gemini-cli-oauth': 'fas fa-gem', 'gemini-antigravity': 'fas fa-rocket', + 'openai-codex-oauth': 'fas fa-terminal', 'openai-qwen-oauth': 'fas fa-code' }; return icons[providerType] || 'fas fa-server'; diff --git a/static/components/section-usage.css b/static/components/section-usage.css index 72e88b4..06e5509 100644 --- a/static/components/section-usage.css +++ b/static/components/section-usage.css @@ -20,6 +20,46 @@ color: var(--text-secondary); } +.usage-info-banner { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 1rem; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 0.5rem; + margin-bottom: 1.5rem; + font-size: 0.875rem; + color: var(--text-secondary); +} + +.usage-info-banner i { + color: var(--primary-color); +} + +.supported-providers-tags { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.provider-tag { + background: var(--bg-primary); + border: 1px solid var(--border-color); + padding: 0.125rem 0.5rem; + border-radius: 9999px; + font-size: 0.75rem; + color: var(--text-primary); + font-weight: 500; +} + +.loading-inline { + display: inline-flex; + align-items: center; + gap: 0.25rem; + color: var(--text-tertiary); +} + .usage-loading, .usage-error, .usage-empty { text-align: center; padding: 3rem; @@ -367,3 +407,23 @@ [data-theme="dark"] .pagination-container { background: var(--bg-tertiary); } [data-theme="dark"] .page-btn { background: var(--bg-primary); border-color: var(--border-color); color: var(--text-primary); } [data-theme="dark"] .page-jump-input { background: var(--bg-primary); border-color: var(--border-color); color: var(--text-primary); } + +/* Codex 专用样式 */ +.codex-usage-item { + border-left: 3px solid var(--primary-color); +} + +.codex-reset-info { + font-size: 0.65rem; + color: var(--text-tertiary); + margin-top: 0.25rem; + display: flex; + align-items: center; + gap: 0.25rem; +} + +.codex-secondary-usage { + margin-top: 0.75rem; + padding-top: 0.75rem; + border-top: 1px dashed var(--border-color); +} diff --git a/static/components/section-usage.html b/static/components/section-usage.html index faed652..6dece64 100644 --- a/static/components/section-usage.html +++ b/static/components/section-usage.html @@ -9,6 +9,14 @@ 上次更新: -- + +
+ + 支持用量查询的提供商: + + + +