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>
|
> <details>
|
||||||
> <summary>クリックして詳細なバージョン履歴を展開</summary>
|
> <summary>クリックして詳細なバージョン履歴を展開</summary>
|
||||||
>
|
>
|
||||||
|
> - **2026.01.26** - Codexプロトコルサポートを追加:OpenAI Codex OAuth認証での接続に対応
|
||||||
> - **2026.01.25** - AI 監視プラグインの強化:AI プロトコル変換前後のリクエストパラメータとレスポンスの監視をサポート。ログ管理の最適化:統一されたログ形式、ビジュアル設定
|
> - **2026.01.25** - AI 監視プラグインの強化:AI プロトコル変換前後のリクエストパラメータとレスポンスの監視をサポート。ログ管理の最適化:統一されたログ形式、ビジュアル設定
|
||||||
> - **2026.01.15** - プロバイダープールマネージャーの最適化:非同期リフレッシュキューメカニズム、バッファキュー重複排除、グローバル並行制御、ノードウォームアップと自動期限切れ検出を追加
|
> - **2026.01.15** - プロバイダープールマネージャーの最適化:非同期リフレッシュキューメカニズム、バッファキュー重複排除、グローバル並行制御、ノードウォームアップと自動期限切れ検出を追加
|
||||||
> - **2026.01.07** - iFlowプロトコルサポートの追加、OAuth認証方式でQwen、Kimi、DeepSeek、GLMシリーズモデルにアクセス可能、自動トークンリフレッシュ機能をサポート
|
> - **2026.01.07** - iFlowプロトコルサポートの追加、OAuth認証方式でQwen、Kimi、DeepSeek、GLMシリーズモデルにアクセス可能、自動トークンリフレッシュ機能をサポート
|
||||||
|
|
@ -109,12 +110,12 @@ AIClient-2-APIを使い始める最も推奨される方法は、自動起動ス
|
||||||
#### 🐳 Docker クイックスタート (推奨)
|
#### 🐳 Docker クイックスタート (推奨)
|
||||||
|
|
||||||
```bash
|
```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`:バックグラウンドでコンテナを実行
|
- `-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`:コンテナ自動再起動ポリシー
|
- `--restart=always`:コンテナ自動再起動ポリシー
|
||||||
- `-v "指定パス:/app/configs"`:設定ディレクトリをマウント(「指定パス」を実際のパスに置き換えてください、例:`/home/user/aiclient-configs`)
|
- `-v "指定パス:/app/configs"`:設定ディレクトリをマウント(「指定パス」を実際のパスに置き換えてください、例:`/home/user/aiclient-configs`)
|
||||||
- `--name aiclient2api`:コンテナ名
|
- `--name aiclient2api`:コンテナ名
|
||||||
|
|
@ -256,6 +257,12 @@ Web UI管理インターフェースでは、極めて迅速に認証設定を
|
||||||
4. **サポートモデル**:Qwen3シリーズ、Kimi K2、DeepSeek V3/R1、GLM-4.6/4.7など
|
4. **サポートモデル**:Qwen3シリーズ、Kimi K2、DeepSeek V3/R1、GLM-4.6/4.7など
|
||||||
5. **自動リフレッシュ**:システムはトークンの期限切れが近づくと自動的にリフレッシュ、手動介入不要
|
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) を参考に設定ファイルを作成します
|
1. **プール設定ファイルの作成**:[provider_pools.json.example](./configs/provider_pools.json.example) を参考に設定ファイルを作成します
|
||||||
2. **プールパラメータの設定**:config.json で `PROVIDER_POOLS_FILE_PATH` を設定し、プール設定ファイルを指定します
|
2. **プールパラメータの設定**:config.json で `PROVIDER_POOLS_FILE_PATH` を設定し、プール設定ファイルを指定します
|
||||||
|
|
@ -278,6 +285,7 @@ Web UI管理インターフェースでは、極めて迅速に認証設定を
|
||||||
| **Qwen** | `~/.qwen/oauth_creds.json` | Qwen OAuth認証情報 |
|
| **Qwen** | `~/.qwen/oauth_creds.json` | Qwen OAuth認証情報 |
|
||||||
| **Antigravity** | `~/.antigravity/oauth_creds.json` | Antigravity OAuth認証情報 (Claude 4.5 Opus サポート) |
|
| **Antigravity** | `~/.antigravity/oauth_creds.json` | Antigravity OAuth認証情報 (Claude 4.5 Opus サポート) |
|
||||||
| **iFlow** | `~/.iflow/oauth_creds.json` | iFlow OAuth認証情報 (Qwen、Kimi、DeepSeek、GLM サポート) |
|
| **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/ユーザー名`)
|
> **説明**:`~`はユーザーホームディレクトリを表します(Windows: `C:\Users\ユーザー名`、Linux/macOS: `/home/ユーザー名`または`/Users/ユーザー名`)
|
||||||
|
|
||||||
|
|
@ -455,7 +463,7 @@ curl http://localhost:3000/ollama/api/chat \
|
||||||
|
|
||||||
**解決策**:
|
**解決策**:
|
||||||
- **ネットワーク接続を確認**:Google、アリババクラウドなどのサービスに正常にアクセスできることを確認
|
- **ネットワーク接続を確認**:Google、アリババクラウドなどのサービスに正常にアクセスできることを確認
|
||||||
- **ポート占有を確認**:OAuthコールバックには特定のポートが必要です(Gemini: 8085, Antigravity: 8086, Kiro: 19876-19880)、これらのポートが占有されていないことを確認
|
- **ポート占有を確認**:OAuthコールバックには特定のポートが必要です(Gemini: 8085, Antigravity: 8086, iFlow: 8087, Codex: 1455, Kiro: 19876-19880)、これらのポートが占有されていないことを確認
|
||||||
- **ブラウザキャッシュをクリア**:シークレットモードを使用するか、ブラウザキャッシュをクリアして再試行
|
- **ブラウザキャッシュをクリア**:シークレットモードを使用するか、ブラウザキャッシュをクリアして再試行
|
||||||
- **ファイアウォール設定を確認**:ファイアウォールがローカルコールバックポートへのアクセスを許可していることを確認
|
- **ファイアウォール設定を確認**:ファイアウォールがローカルコールバックポートへのアクセスを許可していることを確認
|
||||||
- **Dockerユーザー**:すべてのOAuthコールバックポートが正しくマッピングされていることを確認
|
- **Dockerユーザー**:すべてのOAuthコールバックポートが正しくマッピングされていることを確認
|
||||||
|
|
|
||||||
14
README-ZH.md
14
README-ZH.md
|
|
@ -34,6 +34,7 @@
|
||||||
> <details>
|
> <details>
|
||||||
> <summary>点击展开查看详细版本历史</summary>
|
> <summary>点击展开查看详细版本历史</summary>
|
||||||
>
|
>
|
||||||
|
> - **2026.01.26** - 新增 Codex 协议支持:支持 OpenAI Codex OAuth 授权接入
|
||||||
> - **2026.01.25** - 增强 AI 监控插件:支持监控 AI 协议转换前后的请求参数和响应。优化日志管理:统一日志格式,可视化配置
|
> - **2026.01.25** - 增强 AI 监控插件:支持监控 AI 协议转换前后的请求参数和响应。优化日志管理:统一日志格式,可视化配置
|
||||||
> - **2026.01.15** - 优化提供商池管理器:新增异步刷新队列机制、缓冲队列去重、全局并发控制,支持节点预热和自动过期检测
|
> - **2026.01.15** - 优化提供商池管理器:新增异步刷新队列机制、缓冲队列去重、全局并发控制,支持节点预热和自动过期检测
|
||||||
> - **2026.01.07** - 新增 iFlow 协议支持,通过 OAuth 认证方式访问 Qwen、Kimi、DeepSeek 和 GLM 系列模型,支持自动 token 刷新功能
|
> - **2026.01.07** - 新增 iFlow 协议支持,通过 OAuth 认证方式访问 Qwen、Kimi、DeepSeek 和 GLM 系列模型,支持自动 token 刷新功能
|
||||||
|
|
@ -108,12 +109,12 @@
|
||||||
#### 🐳 Docker 快捷启动 (推荐)
|
#### 🐳 Docker 快捷启动 (推荐)
|
||||||
|
|
||||||
```bash
|
```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`:后台运行容器
|
- `-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`:容器自动重启策略
|
- `--restart=always`:容器自动重启策略
|
||||||
- `-v "指定路径:/app/configs"`:挂载配置目录(请将"指定路径"替换为实际路径,如 `/home/user/aiclient-configs`)
|
- `-v "指定路径:/app/configs"`:挂载配置目录(请将"指定路径"替换为实际路径,如 `/home/user/aiclient-configs`)
|
||||||
- `--name aiclient2api`:容器名称
|
- `--name aiclient2api`:容器名称
|
||||||
|
|
@ -255,6 +256,12 @@ docker compose up -d
|
||||||
4. **支持模型**:Qwen3 系列、Kimi K2、DeepSeek V3/R1、GLM-4.6/4.7 等
|
4. **支持模型**:Qwen3 系列、Kimi K2、DeepSeek V3/R1、GLM-4.6/4.7 等
|
||||||
5. **自动刷新**:系统会在 Token 即将过期时自动刷新,无需手动干预
|
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) 创建配置文件
|
1. **创建号池配置文件**:参考 [provider_pools.json.example](./configs/provider_pools.json.example) 创建配置文件
|
||||||
2. **配置号池参数**:在 `configs/config.json` 中设置 `PROVIDER_POOLS_FILE_PATH` 指向号池配置文件
|
2. **配置号池参数**:在 `configs/config.json` 中设置 `PROVIDER_POOLS_FILE_PATH` 指向号池配置文件
|
||||||
|
|
@ -277,6 +284,7 @@ docker compose up -d
|
||||||
| **Qwen** | `~/.qwen/oauth_creds.json` | Qwen OAuth 凭据 |
|
| **Qwen** | `~/.qwen/oauth_creds.json` | Qwen OAuth 凭据 |
|
||||||
| **Antigravity** | `~/.antigravity/oauth_creds.json` | Antigravity OAuth 凭据 (支持 Claude 4.5 Opus) |
|
| **Antigravity** | `~/.antigravity/oauth_creds.json` | Antigravity OAuth 凭据 (支持 Claude 4.5 Opus) |
|
||||||
| **iFlow** | `~/.iflow/oauth_creds.json` | iFlow OAuth 凭据 (支持 Qwen、Kimi、DeepSeek、GLM) |
|
| **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/用户名`)
|
> **说明**:`~` 表示用户主目录(Windows: `C:\Users\用户名`,Linux/macOS: `/home/用户名` 或 `/Users/用户名`)
|
||||||
|
|
||||||
|
|
@ -454,7 +462,7 @@ curl http://localhost:3000/ollama/api/chat \
|
||||||
|
|
||||||
**解决方案**:
|
**解决方案**:
|
||||||
- **检查网络连接**:确保能够正常访问 Google、阿里云等服务
|
- **检查网络连接**:确保能够正常访问 Google、阿里云等服务
|
||||||
- **检查端口占用**:OAuth 回调需要特定端口(Gemini: 8085, Antigravity: 8086, Kiro: 19876-19880),确保这些端口未被占用
|
- **检查端口占用**:OAuth 回调需要特定端口(Gemini: 8085, Antigravity: 8086, iFlow: 8087, Codex: 1455, Kiro: 19876-19880),确保这些端口未被占用
|
||||||
- **清除浏览器缓存**:尝试使用无痕模式或清除浏览器缓存后重试
|
- **清除浏览器缓存**:尝试使用无痕模式或清除浏览器缓存后重试
|
||||||
- **检查防火墙设置**:确保防火墙允许本地回调端口的访问
|
- **检查防火墙设置**:确保防火墙允许本地回调端口的访问
|
||||||
- **Docker 用户**:确保已正确映射所有 OAuth 回调端口
|
- **Docker 用户**:确保已正确映射所有 OAuth 回调端口
|
||||||
|
|
|
||||||
14
README.md
14
README.md
|
|
@ -34,6 +34,7 @@
|
||||||
> <details>
|
> <details>
|
||||||
> <summary>Click to expand detailed version history</summary>
|
> <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.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.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
|
> - **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)
|
#### 🐳 Docker Quick Start (Recommended)
|
||||||
|
|
||||||
```bash
|
```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**:
|
**Parameter Description**:
|
||||||
- `-d`: Run container in background
|
- `-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
|
- `--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`)
|
- `-v "your_path:/app/configs"`: Mount configuration directory (replace "your_path" with actual path, e.g., `/home/user/aiclient-configs`)
|
||||||
- `--name aiclient2api`: Container name
|
- `--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.
|
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
|
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
|
#### Account Pool Management Configuration
|
||||||
1. **Create Pool Configuration File**: Create a configuration file referencing [provider_pools.json.example](./configs/provider_pools.json.example)
|
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
|
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 |
|
| **Qwen** | `~/.qwen/oauth_creds.json` | Qwen OAuth credentials |
|
||||||
| **Antigravity** | `~/.antigravity/oauth_creds.json` | Antigravity OAuth credentials (supports Claude 4.5 Opus) |
|
| **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) |
|
| **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`)
|
> **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**:
|
**Solutions**:
|
||||||
- **Check Network Connection**: Ensure you can access Google, Alibaba Cloud, and other services normally
|
- **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
|
- **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
|
- **Check Firewall Settings**: Ensure the firewall allows access to local callback ports
|
||||||
- **Docker Users**: Ensure all OAuth callback ports are correctly mapped
|
- **Docker Users**: Ensure all OAuth callback ports are correctly mapped
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ services:
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
- "8085-8087:8085-8087"
|
- "8085-8087:8085-8087"
|
||||||
|
- "1455:1455"
|
||||||
- "19876-19880:19876-19880"
|
- "19876-19880:19876-19880"
|
||||||
volumes:
|
volumes:
|
||||||
- ./configs:/app/configs
|
- ./configs:/app/configs
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ services:
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
- "8085-8087:8085-8087"
|
- "8085-8087:8085-8087"
|
||||||
|
- "1455:1455"
|
||||||
- "19876-19880:19876-19880"
|
- "19876-19880:19876-19880"
|
||||||
volumes:
|
volumes:
|
||||||
- ./configs:/app/configs
|
- ./configs:/app/configs
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,8 @@ export class ClaudeConverter extends BaseConverter {
|
||||||
return this.toGeminiRequest(data);
|
return this.toGeminiRequest(data);
|
||||||
case MODEL_PROTOCOL_PREFIX.OPENAI_RESPONSES:
|
case MODEL_PROTOCOL_PREFIX.OPENAI_RESPONSES:
|
||||||
return this.toOpenAIResponsesRequest(data);
|
return this.toOpenAIResponsesRequest(data);
|
||||||
|
case MODEL_PROTOCOL_PREFIX.CODEX:
|
||||||
|
return this.toCodexRequest(data);
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unsupported target protocol: ${targetProtocol}`);
|
throw new Error(`Unsupported target protocol: ${targetProtocol}`);
|
||||||
}
|
}
|
||||||
|
|
@ -67,6 +69,8 @@ export class ClaudeConverter extends BaseConverter {
|
||||||
return this.toGeminiResponse(data, model);
|
return this.toGeminiResponse(data, model);
|
||||||
case MODEL_PROTOCOL_PREFIX.OPENAI_RESPONSES:
|
case MODEL_PROTOCOL_PREFIX.OPENAI_RESPONSES:
|
||||||
return this.toOpenAIResponsesResponse(data, model);
|
return this.toOpenAIResponsesResponse(data, model);
|
||||||
|
case MODEL_PROTOCOL_PREFIX.CODEX:
|
||||||
|
return this.toCodexResponse(data, model);
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unsupported target protocol: ${targetProtocol}`);
|
throw new Error(`Unsupported target protocol: ${targetProtocol}`);
|
||||||
}
|
}
|
||||||
|
|
@ -83,6 +87,8 @@ export class ClaudeConverter extends BaseConverter {
|
||||||
return this.toGeminiStreamChunk(chunk, model);
|
return this.toGeminiStreamChunk(chunk, model);
|
||||||
case MODEL_PROTOCOL_PREFIX.OPENAI_RESPONSES:
|
case MODEL_PROTOCOL_PREFIX.OPENAI_RESPONSES:
|
||||||
return this.toOpenAIResponsesStreamChunk(chunk, model);
|
return this.toOpenAIResponsesStreamChunk(chunk, model);
|
||||||
|
case MODEL_PROTOCOL_PREFIX.CODEX:
|
||||||
|
return this.toCodexStreamChunk(chunk, model);
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unsupported target protocol: ${targetProtocol}`);
|
throw new Error(`Unsupported target protocol: ${targetProtocol}`);
|
||||||
}
|
}
|
||||||
|
|
@ -542,7 +548,7 @@ export class ClaudeConverter extends BaseConverter {
|
||||||
stopReason === 'tool_use' ? 'tool_calls' :
|
stopReason === 'tool_use' ? 'tool_calls' :
|
||||||
stopReason || 'stop';
|
stopReason || 'stop';
|
||||||
|
|
||||||
return {
|
const chunk = {
|
||||||
id: chunkId,
|
id: chunkId,
|
||||||
object: "chat.completion.chunk",
|
object: "chat.completion.chunk",
|
||||||
created: timestamp,
|
created: timestamp,
|
||||||
|
|
@ -552,8 +558,11 @@ export class ClaudeConverter extends BaseConverter {
|
||||||
index: 0,
|
index: 0,
|
||||||
delta: {},
|
delta: {},
|
||||||
finish_reason: finishReason
|
finish_reason: finishReason
|
||||||
}],
|
}]
|
||||||
usage: claudeChunk.usage ? {
|
};
|
||||||
|
|
||||||
|
if(claudeChunk.usage){
|
||||||
|
chunk.usage = {
|
||||||
prompt_tokens: claudeChunk.usage.input_tokens || 0,
|
prompt_tokens: claudeChunk.usage.input_tokens || 0,
|
||||||
completion_tokens: claudeChunk.usage.output_tokens || 0,
|
completion_tokens: claudeChunk.usage.output_tokens || 0,
|
||||||
total_tokens: (claudeChunk.usage.input_tokens || 0) + (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: {
|
prompt_tokens_details: {
|
||||||
cached_tokens: claudeChunk.usage.cache_read_input_tokens || 0
|
cached_tokens: claudeChunk.usage.cache_read_input_tokens || 0
|
||||||
}
|
}
|
||||||
} : undefined
|
};
|
||||||
};
|
}
|
||||||
|
|
||||||
|
return chunk;
|
||||||
}
|
}
|
||||||
|
|
||||||
// message_stop 事件
|
// message_stop 事件
|
||||||
if (claudeChunk.type === 'message_stop') {
|
if (claudeChunk.type === 'message_stop') {
|
||||||
return {
|
return null;
|
||||||
id: chunkId,
|
// const chunk = {
|
||||||
object: "chat.completion.chunk",
|
// id: chunkId,
|
||||||
created: timestamp,
|
// object: "chat.completion.chunk",
|
||||||
model: model,
|
// created: timestamp,
|
||||||
system_fingerprint: "",
|
// model: model,
|
||||||
choices: [{
|
// system_fingerprint: "",
|
||||||
index: 0,
|
// choices: [{
|
||||||
delta: {},
|
// index: 0,
|
||||||
finish_reason: 'stop'
|
// delta: {},
|
||||||
}]
|
// finish_reason: 'stop'
|
||||||
};
|
// }]
|
||||||
|
// };
|
||||||
|
// return chunk;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 兼容旧格式:如果是字符串,直接作为文本内容
|
// 兼容旧格式:如果是字符串,直接作为文本内容
|
||||||
|
|
@ -817,7 +830,7 @@ export class ClaudeConverter extends BaseConverter {
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
// [FIX] 参考 ag/request.rs 添加 thinking 块处理
|
// 添加 thinking 块处理
|
||||||
case 'thinking':
|
case 'thinking':
|
||||||
if (typeof block.thinking === 'string' && block.thinking.length > 0) {
|
if (typeof block.thinking === 'string' && block.thinking.length > 0) {
|
||||||
const thinkingPart = {
|
const thinkingPart = {
|
||||||
|
|
@ -878,7 +891,7 @@ export class ClaudeConverter extends BaseConverter {
|
||||||
|
|
||||||
case 'tool_result':
|
case 'tool_result':
|
||||||
// 转换为 Gemini functionResponse 格式
|
// 转换为 Gemini functionResponse 格式
|
||||||
// [FIX] 参考 ag/request.rs 的实现,正确处理 tool_use_id 到函数名的映射
|
// 的实现,正确处理 tool_use_id 到函数名的映射
|
||||||
const toolCallId = block.tool_use_id;
|
const toolCallId = block.tool_use_id;
|
||||||
if (toolCallId) {
|
if (toolCallId) {
|
||||||
// 尝试从之前的 tool_use 块中查找对应的函数名
|
// 尝试从之前的 tool_use 块中查找对应的函数名
|
||||||
|
|
@ -902,7 +915,7 @@ export class ClaudeConverter extends BaseConverter {
|
||||||
// 获取响应数据
|
// 获取响应数据
|
||||||
let responseData = block.content;
|
let responseData = block.content;
|
||||||
|
|
||||||
// [FIX] 参考 ag/request.rs 的 tool_result_compressor 逻辑
|
// 的 tool_result_compressor 逻辑
|
||||||
// 处理嵌套的 content 数组(如图片等)
|
// 处理嵌套的 content 数组(如图片等)
|
||||||
if (Array.isArray(responseData)) {
|
if (Array.isArray(responseData)) {
|
||||||
// 提取文本内容
|
// 提取文本内容
|
||||||
|
|
@ -1103,7 +1116,7 @@ export class ClaudeConverter extends BaseConverter {
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
// [FIX] 参考 ag/response.rs 添加 thinking 块处理
|
// 添加 thinking 块处理
|
||||||
case 'thinking':
|
case 'thinking':
|
||||||
if (block.thinking) {
|
if (block.thinking) {
|
||||||
const thinkingPart = {
|
const thinkingPart = {
|
||||||
|
|
@ -1435,22 +1448,120 @@ export class ClaudeConverter extends BaseConverter {
|
||||||
* Claude请求 -> OpenAI Responses请求
|
* Claude请求 -> OpenAI Responses请求
|
||||||
*/
|
*/
|
||||||
toOpenAIResponsesRequest(claudeRequest) {
|
toOpenAIResponsesRequest(claudeRequest) {
|
||||||
// 转换为OpenAI Responses格式
|
|
||||||
const responsesRequest = {
|
const responsesRequest = {
|
||||||
model: claudeRequest.model,
|
model: claudeRequest.model,
|
||||||
max_tokens: checkAndAssignOrDefault(claudeRequest.max_tokens, OPENAI_DEFAULT_MAX_TOKENS),
|
instructions: '',
|
||||||
temperature: checkAndAssignOrDefault(claudeRequest.temperature, OPENAI_DEFAULT_TEMPERATURE),
|
input: [],
|
||||||
top_p: checkAndAssignOrDefault(claudeRequest.top_p, OPENAI_DEFAULT_TOP_P),
|
stream: claudeRequest.stream || false,
|
||||||
|
max_output_tokens: claudeRequest.max_tokens,
|
||||||
|
temperature: claudeRequest.temperature,
|
||||||
|
top_p: claudeRequest.top_p
|
||||||
};
|
};
|
||||||
|
|
||||||
// 处理系统指令
|
// 处理系统指令
|
||||||
if (claudeRequest.system) {
|
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)) {
|
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;
|
return responsesRequest;
|
||||||
|
|
@ -1648,6 +1759,426 @@ export class ClaudeConverter extends BaseConverter {
|
||||||
|
|
||||||
return events;
|
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;
|
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';
|
} from '../../providers/openai/openai-responses-core.mjs';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* [FIX] 参考 ag/response.rs 和 ag/streaming.rs 的 remap_function_call_args 函数
|
|
||||||
* 修复 Gemini 返回的工具参数名称问题
|
* 修复 Gemini 返回的工具参数名称问题
|
||||||
* Gemini 有时会使用不同的参数名称,需要映射到 Claude Code 期望的格式
|
* Gemini 有时会使用不同的参数名称,需要映射到 Claude Code 期望的格式
|
||||||
*/
|
*/
|
||||||
|
|
@ -176,6 +175,8 @@ export class GeminiConverter extends BaseConverter {
|
||||||
return this.toClaudeRequest(data);
|
return this.toClaudeRequest(data);
|
||||||
case MODEL_PROTOCOL_PREFIX.OPENAI_RESPONSES:
|
case MODEL_PROTOCOL_PREFIX.OPENAI_RESPONSES:
|
||||||
return this.toOpenAIResponsesRequest(data);
|
return this.toOpenAIResponsesRequest(data);
|
||||||
|
case MODEL_PROTOCOL_PREFIX.CODEX:
|
||||||
|
return this.toCodexRequest(data);
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unsupported target protocol: ${targetProtocol}`);
|
throw new Error(`Unsupported target protocol: ${targetProtocol}`);
|
||||||
}
|
}
|
||||||
|
|
@ -192,6 +193,8 @@ export class GeminiConverter extends BaseConverter {
|
||||||
return this.toClaudeResponse(data, model);
|
return this.toClaudeResponse(data, model);
|
||||||
case MODEL_PROTOCOL_PREFIX.OPENAI_RESPONSES:
|
case MODEL_PROTOCOL_PREFIX.OPENAI_RESPONSES:
|
||||||
return this.toOpenAIResponsesResponse(data, model);
|
return this.toOpenAIResponsesResponse(data, model);
|
||||||
|
case MODEL_PROTOCOL_PREFIX.CODEX:
|
||||||
|
return this.toCodexResponse(data, model);
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unsupported target protocol: ${targetProtocol}`);
|
throw new Error(`Unsupported target protocol: ${targetProtocol}`);
|
||||||
}
|
}
|
||||||
|
|
@ -208,6 +211,8 @@ export class GeminiConverter extends BaseConverter {
|
||||||
return this.toClaudeStreamChunk(chunk, model);
|
return this.toClaudeStreamChunk(chunk, model);
|
||||||
case MODEL_PROTOCOL_PREFIX.OPENAI_RESPONSES:
|
case MODEL_PROTOCOL_PREFIX.OPENAI_RESPONSES:
|
||||||
return this.toOpenAIResponsesStreamChunk(chunk, model);
|
return this.toOpenAIResponsesStreamChunk(chunk, model);
|
||||||
|
case MODEL_PROTOCOL_PREFIX.CODEX:
|
||||||
|
return this.toCodexStreamChunk(chunk, model);
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unsupported target protocol: ${targetProtocol}`);
|
throw new Error(`Unsupported target protocol: ${targetProtocol}`);
|
||||||
}
|
}
|
||||||
|
|
@ -357,13 +362,25 @@ export class GeminiConverter extends BaseConverter {
|
||||||
// 处理finishReason
|
// 处理finishReason
|
||||||
let finishReason = null;
|
let finishReason = null;
|
||||||
if (candidate.finishReason) {
|
if (candidate.finishReason) {
|
||||||
finishReason = candidate.finishReason === 'STOP' ? 'stop' :
|
const finishReasonMap = {
|
||||||
candidate.finishReason === 'MAX_TOKENS' ? 'length' :
|
'FINISH_REASON_UNSPECIFIED': 'stop',
|
||||||
candidate.finishReason.toLowerCase();
|
'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
|
// [FIX] 适配 Gemini 流式:Gemini 的最后一条流式消息通常不带 functionCall
|
||||||
if (toolCalls.length > 0 && finishReason === 'stop') {
|
// 如果当前 chunk 包含工具调用,直接将其标记为 tool_calls
|
||||||
|
if (toolCalls.length > 0) {
|
||||||
finishReason = 'tool_calls';
|
finishReason = 'tool_calls';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -377,7 +394,7 @@ export class GeminiConverter extends BaseConverter {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
const chunk = {
|
||||||
id: `chatcmpl-${uuidv4()}`,
|
id: `chatcmpl-${uuidv4()}`,
|
||||||
object: "chat.completion.chunk",
|
object: "chat.completion.chunk",
|
||||||
created: Math.floor(Date.now() / 1000),
|
created: Math.floor(Date.now() / 1000),
|
||||||
|
|
@ -387,7 +404,10 @@ export class GeminiConverter extends BaseConverter {
|
||||||
delta: delta,
|
delta: delta,
|
||||||
finish_reason: finishReason,
|
finish_reason: finishReason,
|
||||||
}],
|
}],
|
||||||
usage: geminiChunk.usageMetadata ? {
|
};
|
||||||
|
|
||||||
|
if(geminiChunk.usageMetadata){
|
||||||
|
chunk.usage = {
|
||||||
prompt_tokens: geminiChunk.usageMetadata.promptTokenCount || 0,
|
prompt_tokens: geminiChunk.usageMetadata.promptTokenCount || 0,
|
||||||
completion_tokens: geminiChunk.usageMetadata.candidatesTokenCount || 0,
|
completion_tokens: geminiChunk.usageMetadata.candidatesTokenCount || 0,
|
||||||
total_tokens: geminiChunk.usageMetadata.totalTokenCount || 0,
|
total_tokens: geminiChunk.usageMetadata.totalTokenCount || 0,
|
||||||
|
|
@ -398,19 +418,10 @@ export class GeminiConverter extends BaseConverter {
|
||||||
completion_tokens_details: {
|
completion_tokens_details: {
|
||||||
reasoning_tokens: geminiChunk.usageMetadata.thoughtsTokenCount || 0
|
reasoning_tokens: geminiChunk.usageMetadata.thoughtsTokenCount || 0
|
||||||
}
|
}
|
||||||
} : {
|
};
|
||||||
prompt_tokens: 0,
|
}
|
||||||
completion_tokens: 0,
|
|
||||||
total_tokens: 0,
|
return chunk;
|
||||||
cached_tokens: 0,
|
|
||||||
prompt_tokens_details: {
|
|
||||||
cached_tokens: 0
|
|
||||||
},
|
|
||||||
completion_tokens_details: {
|
|
||||||
reasoning_tokens: 0
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -589,7 +600,7 @@ export class GeminiConverter extends BaseConverter {
|
||||||
const finishReason = candidate.finishReason;
|
const finishReason = candidate.finishReason;
|
||||||
let stopReason = "end_turn";
|
let stopReason = "end_turn";
|
||||||
|
|
||||||
// [FIX] 参考 ag/response.rs - 如果有工具调用,stop_reason 应该是 "tool_use"
|
// - 如果有工具调用,stop_reason 应该是 "tool_use"
|
||||||
if (hasToolUse) {
|
if (hasToolUse) {
|
||||||
stopReason = 'tool_use';
|
stopReason = 'tool_use';
|
||||||
} else if (finishReason) {
|
} else if (finishReason) {
|
||||||
|
|
@ -644,7 +655,7 @@ export class GeminiConverter extends BaseConverter {
|
||||||
if (candidate) {
|
if (candidate) {
|
||||||
const parts = candidate.content?.parts;
|
const parts = candidate.content?.parts;
|
||||||
|
|
||||||
// [FIX] 参考 ag/streaming.rs 处理 thinking 和 text 块
|
// thinking 和 text 块
|
||||||
if (parts && Array.isArray(parts)) {
|
if (parts && Array.isArray(parts)) {
|
||||||
const results = [];
|
const results = [];
|
||||||
let hasToolUse = false;
|
let hasToolUse = false;
|
||||||
|
|
@ -826,7 +837,7 @@ export class GeminiConverter extends BaseConverter {
|
||||||
parts.forEach(part => {
|
parts.forEach(part => {
|
||||||
if (!part) return;
|
if (!part) return;
|
||||||
|
|
||||||
// [FIX] 参考 ag/response.rs 处理 thinking 块
|
// 处理 thinking 块
|
||||||
// Gemini 使用 thought: true 和 thoughtSignature 表示思考内容
|
// Gemini 使用 thought: true 和 thoughtSignature 表示思考内容
|
||||||
// [FIX] 同时支持 thoughtSignature 和 thought_signature(Gemini CLI 可能使用下划线格式)
|
// [FIX] 同时支持 thoughtSignature 和 thought_signature(Gemini CLI 可能使用下划线格式)
|
||||||
if (part.text) {
|
if (part.text) {
|
||||||
|
|
@ -947,7 +958,7 @@ export class GeminiConverter extends BaseConverter {
|
||||||
|
|
||||||
if (candidate.content && candidate.content.parts) {
|
if (candidate.content && candidate.content.parts) {
|
||||||
for (const part of candidate.content.parts) {
|
for (const part of candidate.content.parts) {
|
||||||
// [FIX] 参考 ag/response.rs 处理 thinking 块
|
// 处理 thinking 块
|
||||||
if (part.text) {
|
if (part.text) {
|
||||||
if (part.thought === true) {
|
if (part.thought === true) {
|
||||||
// 这是一个 thinking 块
|
// 这是一个 thinking 块
|
||||||
|
|
@ -1034,34 +1045,85 @@ export class GeminiConverter extends BaseConverter {
|
||||||
toOpenAIResponsesRequest(geminiRequest) {
|
toOpenAIResponsesRequest(geminiRequest) {
|
||||||
const responsesRequest = {
|
const responsesRequest = {
|
||||||
model: geminiRequest.model,
|
model: geminiRequest.model,
|
||||||
max_tokens: checkAndAssignOrDefault(geminiRequest.generationConfig?.maxOutputTokens, OPENAI_DEFAULT_MAX_TOKENS),
|
instructions: '',
|
||||||
temperature: checkAndAssignOrDefault(geminiRequest.generationConfig?.temperature, OPENAI_DEFAULT_TEMPERATURE),
|
input: [],
|
||||||
top_p: checkAndAssignOrDefault(geminiRequest.generationConfig?.topP, OPENAI_DEFAULT_TOP_P),
|
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) {
|
if (geminiRequest.systemInstruction && geminiRequest.systemInstruction.parts) {
|
||||||
const instructionsText = geminiRequest.systemInstruction.parts
|
responsesRequest.instructions = geminiRequest.systemInstruction.parts
|
||||||
.filter(p => p.text)
|
.filter(p => p.text)
|
||||||
.map(p => p.text)
|
.map(p => p.text)
|
||||||
.join('\n');
|
.join('\n');
|
||||||
if (instructionsText) {
|
|
||||||
responsesRequest.instructions = instructionsText;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理输入
|
// 处理内容
|
||||||
if (geminiRequest.contents && Array.isArray(geminiRequest.contents)) {
|
if (geminiRequest.contents && Array.isArray(geminiRequest.contents)) {
|
||||||
const lastContent = geminiRequest.contents[geminiRequest.contents.length - 1];
|
geminiRequest.contents.forEach(content => {
|
||||||
if (lastContent && lastContent.parts) {
|
const role = content.role === 'model' ? 'assistant' : 'user';
|
||||||
const inputText = lastContent.parts
|
const parts = content.parts || [];
|
||||||
.filter(p => p.text)
|
|
||||||
.map(p => p.text)
|
parts.forEach(part => {
|
||||||
.join(' ');
|
if (part.text) {
|
||||||
if (inputText) {
|
responsesRequest.input.push({
|
||||||
responsesRequest.input = inputText;
|
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;
|
return responsesRequest;
|
||||||
|
|
@ -1221,6 +1283,196 @@ export class GeminiConverter extends BaseConverter {
|
||||||
|
|
||||||
return events;
|
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;
|
export default GeminiConverter;
|
||||||
|
|
@ -1345,34 +1345,86 @@ export class OpenAIConverter extends BaseConverter {
|
||||||
toOpenAIResponsesRequest(openaiRequest) {
|
toOpenAIResponsesRequest(openaiRequest) {
|
||||||
const responsesRequest = {
|
const responsesRequest = {
|
||||||
model: openaiRequest.model,
|
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
|
const { systemInstruction, nonSystemMessages } = extractSystemMessages(openaiRequest.messages || []);
|
||||||
if (openaiRequest.messages && openaiRequest.messages.length > 0) {
|
|
||||||
responsesRequest.messages = openaiRequest.messages.map(msg => ({
|
if (systemInstruction) {
|
||||||
role: msg.role,
|
responsesRequest.instructions = extractText(systemInstruction.parts[0].text);
|
||||||
content: typeof msg.content === 'string'
|
|
||||||
? [{ type: 'input_text', text: msg.content }]
|
|
||||||
: msg.content
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 转换其他参数
|
if (openaiRequest.reasoning_effort) {
|
||||||
if (openaiRequest.temperature !== undefined) {
|
responsesRequest.reasoning = {
|
||||||
responsesRequest.temperature = openaiRequest.temperature;
|
effort: openaiRequest.reasoning_effort
|
||||||
|
};
|
||||||
}
|
}
|
||||||
if (openaiRequest.max_tokens !== undefined) {
|
|
||||||
responsesRequest.max_output_tokens = openaiRequest.max_tokens;
|
// 转换messages到input
|
||||||
}
|
for (const msg of nonSystemMessages) {
|
||||||
if (openaiRequest.top_p !== undefined) {
|
if (msg.role === 'tool') {
|
||||||
responsesRequest.top_p = openaiRequest.top_p;
|
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) {
|
if (openaiRequest.tools) {
|
||||||
responsesRequest.tools = openaiRequest.tools;
|
responsesRequest.tools = openaiRequest.tools.map(t => ({
|
||||||
}
|
type: t.type || 'function',
|
||||||
if (openaiRequest.tool_choice) {
|
name: t.function?.name,
|
||||||
responsesRequest.tool_choice = openaiRequest.tool_choice;
|
description: t.function?.description,
|
||||||
|
parameters: t.function?.parameters
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
return responsesRequest;
|
return responsesRequest;
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -111,8 +111,23 @@ const aiMonitorPlugin = {
|
||||||
}
|
}
|
||||||
|
|
||||||
const cache = aiMonitorPlugin.streamCache.get(requestId);
|
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() {
|
isExpiryDateNear() {
|
||||||
return this.codexApiService.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 服务适配器
|
// Forward API 服务适配器
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ const httpsAgent = new https.Agent({
|
||||||
const CREDENTIALS_DIR = '.antigravity';
|
const CREDENTIALS_DIR = '.antigravity';
|
||||||
const CREDENTIALS_FILE = 'oauth_creds.json';
|
const CREDENTIALS_FILE = 'oauth_creds.json';
|
||||||
|
|
||||||
// Base URLs - 按照 Go 代码的降级顺序
|
// Base URLs
|
||||||
const ANTIGRAVITY_BASE_URL_DAILY = 'https://daily-cloudcode-pa.googleapis.com';
|
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_SANDBOX_BASE_URL_DAILY = 'https://daily-cloudcode-pa.sandbox.googleapis.com';
|
||||||
const ANTIGRAVITY_BASE_URL_PROD = 'https://autopush-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.projectId = config.PROJECT_ID;
|
||||||
this.uuid = config.uuid; // 保存 uuid 用于缓存管理
|
this.uuid = config.uuid; // 保存 uuid 用于缓存管理
|
||||||
|
|
||||||
// 多环境降级顺序 - 按照 Go 代码的顺序
|
// 多环境降级顺序
|
||||||
this.baseURLs = this.getBaseURLFallbackOrder(config);
|
this.baseURLs = this.getBaseURLFallbackOrder(config);
|
||||||
|
|
||||||
// 保存代理配置供后续使用
|
// 保存代理配置供后续使用
|
||||||
|
|
|
||||||
|
|
@ -141,7 +141,7 @@ export class CodexApiService {
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = `${this.baseUrl}/responses`;
|
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);
|
const headers = this.buildHeaders(body.prompt_cache_key);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -488,5 +488,106 @@ export class CodexApiService {
|
||||||
this.cleanupInterval = null;
|
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);
|
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
|
// Get usage limits for a specific provider type
|
||||||
const usageProviderMatch = pathParam.match(/^\/api\/usage\/([^\/]+)$/);
|
const usageProviderMatch = pathParam.match(/^\/api\/usage\/([^\/]+)$/);
|
||||||
if (method === 'GET' && usageProviderMatch) {
|
if (method === 'GET' && usageProviderMatch) {
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ export class UsageService {
|
||||||
[MODEL_PROVIDER.KIRO_API]: this.getKiroUsage.bind(this),
|
[MODEL_PROVIDER.KIRO_API]: this.getKiroUsage.bind(this),
|
||||||
[MODEL_PROVIDER.GEMINI_CLI]: this.getGeminiUsage.bind(this),
|
[MODEL_PROVIDER.GEMINI_CLI]: this.getGeminiUsage.bind(this),
|
||||||
[MODEL_PROVIDER.ANTIGRAVITY]: this.getAntigravityUsage.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}`);
|
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>} 支持的提供商类型列表
|
* @returns {Array<string>} 支持的提供商类型列表
|
||||||
|
|
@ -523,4 +550,127 @@ export function formatAntigravityUsage(usageData) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
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 { CONFIG } from '../core/config-manager.js';
|
||||||
import logger from '../utils/logger.js';
|
import logger from '../utils/logger.js';
|
||||||
import { serviceInstances, getServiceAdapter } from '../providers/adapter.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 { readUsageCache, writeUsageCache, readProviderUsageCache, updateProviderUsageCache } from './usage-cache.js';
|
||||||
import path from 'path';
|
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) => {
|
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');
|
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}`);
|
throw new Error(`Unsupported provider type: ${providerType}`);
|
||||||
}
|
}
|
||||||
|
|
@ -194,6 +205,7 @@ function getProviderDisplayName(provider, providerType) {
|
||||||
'claude-kiro-oauth': 'KIRO_OAUTH_CREDS_FILE_PATH',
|
'claude-kiro-oauth': 'KIRO_OAUTH_CREDS_FILE_PATH',
|
||||||
'gemini-cli-oauth': 'GEMINI_OAUTH_CREDS_FILE_PATH',
|
'gemini-cli-oauth': 'GEMINI_OAUTH_CREDS_FILE_PATH',
|
||||||
'gemini-antigravity': 'ANTIGRAVITY_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-qwen-oauth': 'QWEN_OAUTH_CREDS_FILE_PATH',
|
||||||
'openai-iflow': 'IFLOW_TOKEN_FILE_PATH'
|
'openai-iflow': 'IFLOW_TOKEN_FILE_PATH'
|
||||||
}[providerType];
|
}[providerType];
|
||||||
|
|
@ -208,6 +220,27 @@ function getProviderDisplayName(provider, providerType) {
|
||||||
return 'Unnamed';
|
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;
|
requestBody.model = model;
|
||||||
const nativeStream = await service.generateContentStream(model, requestBody);
|
const nativeStream = await service.generateContentStream(model, requestBody);
|
||||||
const addEvent = getProtocolPrefix(fromProvider) === MODEL_PROTOCOL_PREFIX.CLAUDE || getProtocolPrefix(fromProvider) === MODEL_PROTOCOL_PREFIX.OPENAI_RESPONSES;
|
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 {
|
try {
|
||||||
for await (const nativeChunk of nativeStream) {
|
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];
|
const chunksToSend = Array.isArray(chunkToSend) ? chunkToSend : [chunkToSend];
|
||||||
|
|
||||||
for (const chunk of chunksToSend) {
|
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) {
|
if (addEvent) {
|
||||||
// fullOldResponseJson += chunk.type+"\n";
|
// fullOldResponseJson += chunk.type+"\n";
|
||||||
// fullResponseJson += 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`);
|
// 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
|
// 流式请求成功完成,统计使用次数,错误次数重置为0
|
||||||
if (providerPoolManager && pooluuid) {
|
if (providerPoolManager && pooluuid) {
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { GeminiStrategy } from '../providers/gemini/gemini-strategy.js';
|
||||||
import { OpenAIStrategy } from '../providers/openai/openai-strategy.js';
|
import { OpenAIStrategy } from '../providers/openai/openai-strategy.js';
|
||||||
import { ClaudeStrategy } from '../providers/claude/claude-strategy.js';
|
import { ClaudeStrategy } from '../providers/claude/claude-strategy.js';
|
||||||
import { ResponsesAPIStrategy } from '../providers/openai/openai-responses-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';
|
import { ForwardStrategy } from '../providers/forward/forward-strategy.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -20,7 +21,7 @@ class ProviderStrategyFactory {
|
||||||
case MODEL_PROTOCOL_PREFIX.CLAUDE:
|
case MODEL_PROTOCOL_PREFIX.CLAUDE:
|
||||||
return new ClaudeStrategy();
|
return new ClaudeStrategy();
|
||||||
case MODEL_PROTOCOL_PREFIX.CODEX:
|
case MODEL_PROTOCOL_PREFIX.CODEX:
|
||||||
return new ResponsesAPIStrategy();
|
return new CodexResponsesAPIStrategy();
|
||||||
case MODEL_PROTOCOL_PREFIX.FORWARD:
|
case MODEL_PROTOCOL_PREFIX.FORWARD:
|
||||||
return new ForwardStrategy();
|
return new ForwardStrategy();
|
||||||
default:
|
default:
|
||||||
|
|
|
||||||
|
|
@ -517,6 +517,7 @@ const translations = {
|
||||||
'usage.refresh': '刷新用量',
|
'usage.refresh': '刷新用量',
|
||||||
'usage.lastUpdate': '上次更新: {time}',
|
'usage.lastUpdate': '上次更新: {time}',
|
||||||
'usage.lastUpdateCache': '缓存时间: {time}',
|
'usage.lastUpdateCache': '缓存时间: {time}',
|
||||||
|
'usage.supportedProvidersPrefix': '支持用量查询的提供商:',
|
||||||
'usage.loading': '正在加载用量数据...',
|
'usage.loading': '正在加载用量数据...',
|
||||||
'usage.empty': '点击"刷新用量"按钮获取授权文件用量信息',
|
'usage.empty': '点击"刷新用量"按钮获取授权文件用量信息',
|
||||||
'usage.noData': '暂无用量数据',
|
'usage.noData': '暂无用量数据',
|
||||||
|
|
@ -1275,6 +1276,7 @@ const translations = {
|
||||||
'usage.refresh': 'Refresh Usage',
|
'usage.refresh': 'Refresh Usage',
|
||||||
'usage.lastUpdate': 'Last Update: {time}',
|
'usage.lastUpdate': 'Last Update: {time}',
|
||||||
'usage.lastUpdateCache': 'Cache Time: {time}',
|
'usage.lastUpdateCache': 'Cache Time: {time}',
|
||||||
|
'usage.supportedProvidersPrefix': 'Providers supporting usage query:',
|
||||||
'usage.loading': 'Loading usage data...',
|
'usage.loading': 'Loading usage data...',
|
||||||
'usage.empty': 'Click "Refresh Usage" button to get authorization file usage information',
|
'usage.empty': 'Click "Refresh Usage" button to get authorization file usage information',
|
||||||
'usage.noData': 'No usage data available',
|
'usage.noData': 'No usage data available',
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,39 @@ export function initUsageManager() {
|
||||||
|
|
||||||
// 初始化时自动加载缓存数据
|
// 初始化时自动加载缓存数据
|
||||||
loadUsage();
|
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 字符串
|
* @returns {string} HTML 字符串
|
||||||
*/
|
*/
|
||||||
function createUsageBreakdownHTML(breakdown) {
|
function createUsageBreakdownHTML(breakdown) {
|
||||||
|
// 特殊处理 Codex
|
||||||
|
if (breakdown.rateLimit && breakdown.rateLimit.primary_window) {
|
||||||
|
return createCodexUsageBreakdownHTML(breakdown);
|
||||||
|
}
|
||||||
|
|
||||||
const usagePercent = breakdown.usageLimit > 0
|
const usagePercent = breakdown.usageLimit > 0
|
||||||
? Math.min(100, (breakdown.currentUsage / breakdown.usageLimit) * 100)
|
? Math.min(100, (breakdown.currentUsage / breakdown.usageLimit) * 100)
|
||||||
: 0;
|
: 0;
|
||||||
|
|
@ -514,6 +552,78 @@ function createUsageBreakdownHTML(breakdown) {
|
||||||
return html;
|
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 - 用量明细数组
|
* @param {Array} usageBreakdown - 用量明细数组
|
||||||
|
|
@ -569,6 +679,7 @@ function getProviderDisplayName(providerType) {
|
||||||
'claude-kiro-oauth': 'Claude Kiro OAuth',
|
'claude-kiro-oauth': 'Claude Kiro OAuth',
|
||||||
'gemini-cli-oauth': 'Gemini CLI OAuth',
|
'gemini-cli-oauth': 'Gemini CLI OAuth',
|
||||||
'gemini-antigravity': 'Gemini Antigravity',
|
'gemini-antigravity': 'Gemini Antigravity',
|
||||||
|
'openai-codex-oauth': 'Codex OAuth',
|
||||||
'openai-qwen-oauth': 'Qwen OAuth'
|
'openai-qwen-oauth': 'Qwen OAuth'
|
||||||
};
|
};
|
||||||
return names[providerType] || providerType;
|
return names[providerType] || providerType;
|
||||||
|
|
@ -584,6 +695,7 @@ function getProviderIcon(providerType) {
|
||||||
'claude-kiro-oauth': 'fas fa-robot',
|
'claude-kiro-oauth': 'fas fa-robot',
|
||||||
'gemini-cli-oauth': 'fas fa-gem',
|
'gemini-cli-oauth': 'fas fa-gem',
|
||||||
'gemini-antigravity': 'fas fa-rocket',
|
'gemini-antigravity': 'fas fa-rocket',
|
||||||
|
'openai-codex-oauth': 'fas fa-terminal',
|
||||||
'openai-qwen-oauth': 'fas fa-code'
|
'openai-qwen-oauth': 'fas fa-code'
|
||||||
};
|
};
|
||||||
return icons[providerType] || 'fas fa-server';
|
return icons[providerType] || 'fas fa-server';
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,46 @@
|
||||||
color: var(--text-secondary);
|
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 {
|
.usage-loading, .usage-error, .usage-empty {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 3rem;
|
padding: 3rem;
|
||||||
|
|
@ -367,3 +407,23 @@
|
||||||
[data-theme="dark"] .pagination-container { background: var(--bg-tertiary); }
|
[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-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); }
|
[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>
|
</button>
|
||||||
<span class="usage-last-update" id="usageLastUpdate" data-i18n="usage.lastUpdate" data-i18n-params='{"time":"--"}'>上次更新: --</span>
|
<span class="usage-last-update" id="usageLastUpdate" data-i18n="usage.lastUpdate" data-i18n-params='{"time":"--"}'>上次更新: --</span>
|
||||||
</div>
|
</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;">
|
<div class="usage-loading" id="usageLoading" style="display: none;">
|
||||||
<i class="fas fa-spinner fa-spin"></i> <span data-i18n="usage.loading">正在加载用量数据...</span>
|
<i class="fas fa-spinner fa-spin"></i> <span data-i18n="usage.loading">正在加载用量数据...</span>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue