feat: 新增 OpenAI Codex OAuth 支持与用量查询功能
- 添加 Codex OAuth 提供商支持,包括核心服务、适配器和策略实现 - 在用量管理页面新增支持用量查询的提供商列表显示 - 为 Codex 添加专用的用量查询接口和格式化显示 - 更新 Docker 配置以暴露 Codex OAuth 回调端口 1455 - 完善多语言文档,记录 Codex 配置和使用方法 - 修复流式响应中工具调用的 finish_reason 处理逻辑 - 增强 AI 监控插件对数组类型 chunk 的处理能力
This commit is contained in:
parent
a5236b6709
commit
03a1a656f4
24 changed files with 3266 additions and 583 deletions
14
README-JA.md
14
README-JA.md
|
|
@ -34,6 +34,7 @@
|
|||
> <details>
|
||||
> <summary>クリックして詳細なバージョン履歴を展開</summary>
|
||||
>
|
||||
> - **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コールバックポートが正しくマッピングされていることを確認
|
||||
|
|
|
|||
14
README-ZH.md
14
README-ZH.md
|
|
@ -34,6 +34,7 @@
|
|||
> <details>
|
||||
> <summary>点击展开查看详细版本历史</summary>
|
||||
>
|
||||
> - **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 回调端口
|
||||
|
|
|
|||
14
README.md
14
README.md
|
|
@ -34,6 +34,7 @@
|
|||
> <details>
|
||||
> <summary>Click to expand detailed version history</summary>
|
||||
>
|
||||
> - **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
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ services:
|
|||
ports:
|
||||
- "3000:3000"
|
||||
- "8085-8087:8085-8087"
|
||||
- "1455:1455"
|
||||
- "19876-19880:19876-19880"
|
||||
volumes:
|
||||
- ./configs:/app/configs
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ services:
|
|||
ports:
|
||||
- "3000:3000"
|
||||
- "8085-8087:8085-8087"
|
||||
- "1455:1455"
|
||||
- "19876-19880:19876-19880"
|
||||
volumes:
|
||||
- ./configs:/app/configs
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -568,6 +568,18 @@ export class CodexApiServiceAdapter extends ApiServiceAdapter {
|
|||
isExpiryDateNear() {
|
||||
return this.codexApiService.isExpiryDateNear();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用量限制信息
|
||||
* @returns {Promise<Object>} 用量限制信息
|
||||
*/
|
||||
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 服务适配器
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
// 保存代理配置供后续使用
|
||||
|
|
|
|||
|
|
@ -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<Object>} 使用限制信息(通用格式)
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
124
src/providers/openai/codex-responses-strategy.js
Normal file
124
src/providers/openai/codex-responses-strategy.js
Normal file
|
|
@ -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 };
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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<Object>} 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<string>} 支持的提供商类型列表
|
||||
|
|
@ -523,4 +550,127 @@ export function formatAntigravityUsage(usageData) {
|
|||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有提供商的用量限制
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 = '<span class="error-text">Failed to load</span>';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -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 = `
|
||||
<div class="breakdown-item-compact codex-usage-item">
|
||||
<div class="breakdown-header-compact">
|
||||
<span class="breakdown-name"><i class="fas fa-clock"></i> ${primaryLimitHours} 小时限制</span>
|
||||
<span class="breakdown-usage">${primaryPercent}%</span>
|
||||
</div>
|
||||
<div class="progress-bar-small ${primaryProgressClass}">
|
||||
<div class="progress-fill" style="width: ${primaryPercent}%"></div>
|
||||
</div>
|
||||
<div class="codex-reset-info">
|
||||
<i class="fas fa-history"></i> 将在 ${primaryResetText} 后重置
|
||||
</div>
|
||||
`;
|
||||
|
||||
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 += `
|
||||
<div class="codex-secondary-usage">
|
||||
<div class="breakdown-header-compact">
|
||||
<span class="breakdown-name"><i class="fas fa-calendar-alt"></i> 每周限制</span>
|
||||
<span class="breakdown-usage">${secondaryPercent}%</span>
|
||||
</div>
|
||||
<div class="progress-bar-small ${secondaryProgressClass}">
|
||||
<div class="progress-fill" style="width: ${secondaryPercent}%"></div>
|
||||
</div>
|
||||
<div class="codex-reset-info">
|
||||
<i class="fas fa-history"></i> 将在 ${secondaryResetText} 后重置
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
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';
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,14 @@
|
|||
</button>
|
||||
<span class="usage-last-update" id="usageLastUpdate" data-i18n="usage.lastUpdate" data-i18n-params='{"time":"--"}'>上次更新: --</span>
|
||||
</div>
|
||||
|
||||
<div class="usage-info-banner">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
<span data-i18n="usage.supportedProvidersPrefix">支持用量查询的提供商:</span>
|
||||
<span id="supportedProvidersList" class="supported-providers-tags">
|
||||
<span class="loading-inline"><i class="fas fa-spinner fa-spin"></i></span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="usage-loading" id="usageLoading" style="display: none;">
|
||||
<i class="fas fa-spinner fa-spin"></i> <span data-i18n="usage.loading">正在加载用量数据...</span>
|
||||
|
|
|
|||
Loading…
Reference in a new issue