feat: 新增 OpenAI Codex OAuth 支持与用量查询功能

- 添加 Codex OAuth 提供商支持,包括核心服务、适配器和策略实现
- 在用量管理页面新增支持用量查询的提供商列表显示
- 为 Codex 添加专用的用量查询接口和格式化显示
- 更新 Docker 配置以暴露 Codex OAuth 回调端口 1455
- 完善多语言文档,记录 Codex 配置和使用方法
- 修复流式响应中工具调用的 finish_reason 处理逻辑
- 增强 AI 监控插件对数组类型 chunk 的处理能力
This commit is contained in:
hex2077 2026-01-27 16:31:23 +08:00
parent a5236b6709
commit 03a1a656f4
24 changed files with 3266 additions and 583 deletions

View file

@ -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コールバックポートが正しくマッピングされていることを確認

View file

@ -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 回调端口

View file

@ -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

View file

@ -10,6 +10,7 @@ services:
ports:
- "3000:3000"
- "8085-8087:8085-8087"
- "1455:1455"
- "19876-19880:19876-19880"
volumes:
- ./configs:/app/configs

View file

@ -8,6 +8,7 @@ services:
ports:
- "3000:3000"
- "8085-8087:8085-8087"
- "1455:1455"
- "19876-19880:19876-19880"
volumes:
- ./configs:/app/configs

View file

@ -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

View file

@ -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_signatureGemini 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;

View file

@ -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

View file

@ -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);
}
}
},
/**

View file

@ -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 服务适配器

View file

@ -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);
// 保存代理配置供后续使用

View file

@ -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;
}
}
}

View 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 };

View file

@ -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) {

View file

@ -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;
}

View file

@ -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;
}
}
/**
* 获取所有提供商的用量限制
*/

View file

@ -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) {

View file

@ -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:

View file

@ -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',

View file

@ -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';

View file

@ -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);
}

View file

@ -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>