feat: 改进错误处理、Ollama兼容性和文档结构
refactor(converters): 优化Claude到Gemini的转换逻辑 fix(kiro): 修复社交认证刷新问题 perf(ollama): 提升模型列表获取效率 docs: 为README添加可折叠区块 style: 清理控制台日志
This commit is contained in:
parent
bcc2f1eb59
commit
8f39295655
16 changed files with 1734 additions and 292 deletions
30
README-JA.md
30
README-JA.md
|
|
@ -31,6 +31,9 @@
|
|||
>
|
||||
> **📅 バージョン更新ログ**
|
||||
>
|
||||
> <details>
|
||||
> <summary>クリックして詳細なバージョン履歴を展開</summary>
|
||||
>
|
||||
> - **2026.01.03** - テーマ切替機能を追加し、プロバイダープール初期化を最適化、プロバイダーのデフォルト設定を使用するフォールバック戦略を削除
|
||||
> - **2025.12.30** - メインプロセス管理と自動更新機能を追加
|
||||
> - **2025.12.25** - 設定ファイル統一管理:すべての設定を `configs/` ディレクトリに集約。Dockerユーザーはマウントパスを `-v "ローカルパス:/app/configs"` に更新が必要
|
||||
|
|
@ -47,6 +50,7 @@
|
|||
> - **開発済み履歴**
|
||||
> - Gemini CLI、Kiroなどのクライアント2APIをサポート
|
||||
> - OpenAI、Claude、Geminiの3つのプロトコル相互変換、自動インテリジェント切り替え
|
||||
> </details>
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -204,6 +208,9 @@ docker compose up -d
|
|||
|
||||
### 🔐 認証設定ガイド
|
||||
|
||||
<details>
|
||||
<summary>クリックして各プロバイダーの認証設定詳細手順を展開</summary>
|
||||
|
||||
> **💡 ヒント**:最適な体験を得るために、**Web UIコンソール**を通じてビジュアルに認証管理を行うことを推奨します。
|
||||
|
||||
#### 🌐 Web UI クイック認証 (推奨)
|
||||
|
|
@ -245,8 +252,13 @@ Web UI管理インターフェースでは、極めて迅速に認証設定を
|
|||
3. **起動パラメータ設定**:`--provider-pools-file <path>` パラメータを使用してプール設定ファイルのパスを指定します
|
||||
4. **ヘルスチェック**:システムは定期的にヘルスチェックを自動実行し、健全でないプロバイダーを使用しません
|
||||
|
||||
</details>
|
||||
|
||||
### 📁 認証ファイル保存パス
|
||||
|
||||
<details>
|
||||
<summary>クリックして各サービスの認証情報のデフォルト保存場所を展開</summary>
|
||||
|
||||
各サービスの認証情報ファイルのデフォルト保存場所:
|
||||
|
||||
| サービス | デフォルトパス | 説明 |
|
||||
|
|
@ -257,9 +269,11 @@ Web UI管理インターフェースでは、極めて迅速に認証設定を
|
|||
| **Antigravity** | `~/.antigravity/oauth_creds.json` | Antigravity OAuth認証情報 (Claude 4.5 Opus サポート) |
|
||||
|
||||
> **説明**:`~`はユーザーホームディレクトリを表します(Windows: `C:\Users\ユーザー名`、Linux/macOS: `/home/ユーザー名`または`/Users/ユーザー名`)
|
||||
>
|
||||
|
||||
> **カスタムパス**:設定ファイルの関連パラメータまたは環境変数でカスタム保存場所を指定可能
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
### 🦙 Ollamaプロトコル使用例
|
||||
|
|
@ -270,13 +284,15 @@ Web UI管理インターフェースでは、極めて迅速に認証設定を
|
|||
|
||||
1. **利用可能なすべてのモデルをリスト表示**:
|
||||
```bash
|
||||
curl http://localhost:3000/ollama/api/tags
|
||||
curl http://localhost:3000/ollama/api/tags \
|
||||
-H "Authorization: Bearer your-api-key"
|
||||
```
|
||||
|
||||
2. **チャットインターフェース**:
|
||||
```bash
|
||||
curl http://localhost:3000/ollama/api/chat \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer your-api-key" \
|
||||
-d '{
|
||||
"model": "[Claude] claude-sonnet-4.5",
|
||||
"messages": [
|
||||
|
|
@ -296,6 +312,9 @@ curl http://localhost:3000/ollama/api/chat \
|
|||
|
||||
### 高度な設定
|
||||
|
||||
<details>
|
||||
<summary>クリックしてプロキシ設定、モデルフィルタリング、Fallbackなどの高度な設定を展開</summary>
|
||||
|
||||
#### 1. プロキシ設定
|
||||
|
||||
本プロジェクトは柔軟なプロキシ設定をサポートしており、異なるプロバイダーに統一プロキシを設定したり、プロバイダー独自のプロキシ済みエンドポイントを使用したりできます。
|
||||
|
|
@ -410,10 +429,15 @@ curl http://localhost:3000/ollama/api/chat \
|
|||
- フォールバックはプロトコル互換タイプ間でのみ発生します(例:`gemini-*` 間、`claude-*` 間)
|
||||
- システムは自動的にターゲットProvider Typeがリクエストされたモデルをサポートしているか確認します
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## ❓ よくある質問
|
||||
|
||||
<details>
|
||||
<summary>クリックしてよくある質問と解決策を展開(ポート占有、Docker起動、429エラーなど)</summary>
|
||||
|
||||
### 1. OAuth認証失敗
|
||||
|
||||
**問題の説明**:「認証生成」をクリックした後、ブラウザで認証ページが開きますが、認証が失敗するか完了できません。
|
||||
|
|
@ -532,6 +556,8 @@ kill -9 <PID>
|
|||
- **リクエストヘッダー形式を確認**:リクエストに正しい形式のAuthorizationヘッダーが含まれていることを確認、例:`Authorization: Bearer your-api-key`
|
||||
- **サービスログを確認**:Web UIの「リアルタイムログ」ページで詳細なエラーメッセージを確認し、具体的な原因を特定
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 📄 オープンソースライセンス
|
||||
|
|
|
|||
28
README-ZH.md
28
README-ZH.md
|
|
@ -31,6 +31,9 @@
|
|||
>
|
||||
> **📅 版本更新日志**
|
||||
>
|
||||
> <details>
|
||||
> <summary>点击展开查看详细版本历史</summary>
|
||||
>
|
||||
> - **2026.01.03** - 新增主题切换功能并优化提供商池初始化,移除使用提供商默认配置的降级策略
|
||||
> - **2025.12.30** - 添加主进程管理和自动更新功能
|
||||
> - **2025.12.25** - 配置文件统一管理:所有配置集中到 `configs/` 目录,Docker 用户需更新挂载路径为 `-v "本地路径:/app/configs"`
|
||||
|
|
@ -47,6 +50,7 @@
|
|||
> - **历史已开发**
|
||||
> - 支持 Gemini CLI、Kiro 等客户端2API
|
||||
> - OpenAI ,Claude ,Gemini 三协议互转,自动智能切换
|
||||
> </details>
|
||||
---
|
||||
|
||||
## 💡 核心优势
|
||||
|
|
@ -203,6 +207,9 @@ docker compose up -d
|
|||
|
||||
### 🔐 授权配置指南
|
||||
|
||||
<details>
|
||||
<summary>点击展开查看各提供商授权配置详细步骤</summary>
|
||||
|
||||
> **💡 提示**:为了获得最佳体验,建议通过 **Web UI 控制台** 进行可视化授权管理。
|
||||
|
||||
#### 🌐 Web UI 快捷授权 (推荐)
|
||||
|
|
@ -244,8 +251,13 @@ docker compose up -d
|
|||
3. **启动参数配置**:使用 `--provider-pools-file <path>` 参数指定号池配置文件路径
|
||||
4. **健康检查**:系统会定期自动执行健康检查,不使用不健康的提供商
|
||||
|
||||
</details>
|
||||
|
||||
### 📁 授权文件存储路径
|
||||
|
||||
<details>
|
||||
<summary>点击展开查看各服务授权凭据的默认存储位置</summary>
|
||||
|
||||
各服务的授权凭据文件默认存储位置:
|
||||
|
||||
| 服务 | 默认路径 | 说明 |
|
||||
|
|
@ -259,6 +271,8 @@ docker compose up -d
|
|||
|
||||
> **自定义路径**:可通过配置文件中的相关参数或环境变量指定自定义存储位置
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
### 🦙 Ollama 协议使用示例
|
||||
|
|
@ -269,13 +283,15 @@ docker compose up -d
|
|||
|
||||
1. **列出所有可用模型**:
|
||||
```bash
|
||||
curl http://localhost:3000/ollama/api/tags
|
||||
curl http://localhost:3000/ollama/api/tags \
|
||||
-H "Authorization: Bearer your-api-key"
|
||||
```
|
||||
|
||||
2. **聊天接口**:
|
||||
```bash
|
||||
curl http://localhost:3000/ollama/api/chat \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer your-api-key" \
|
||||
-d '{
|
||||
"model": "[Claude] claude-sonnet-4.5",
|
||||
"messages": [
|
||||
|
|
@ -295,6 +311,9 @@ curl http://localhost:3000/ollama/api/chat \
|
|||
|
||||
### 高级配置
|
||||
|
||||
<details>
|
||||
<summary>点击展开查看代理配置、模型过滤及 Fallback 等高级设置</summary>
|
||||
|
||||
#### 1. 代理配置
|
||||
|
||||
本项目支持灵活的代理配置,可以为不同的提供商配置统一代理或使用提供商自带的已代理端点。
|
||||
|
|
@ -409,10 +428,15 @@ curl http://localhost:3000/ollama/api/chat \
|
|||
- Fallback 只会在协议兼容的类型之间进行(如 `gemini-*` 之间、`claude-*` 之间)
|
||||
- 系统会自动检查目标 Provider Type 是否支持当前请求的模型
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## ❓ 常见问题
|
||||
|
||||
<details>
|
||||
<summary>点击展开查看常见问题及解决方案(端口占用、Docker 启动、429 错误等)</summary>
|
||||
|
||||
### 1. OAuth 授权失败
|
||||
|
||||
**问题描述**:点击"生成授权"后,浏览器打开授权页面但授权失败或无法完成。
|
||||
|
|
@ -531,6 +555,8 @@ kill -9 <PID>
|
|||
- **检查请求头格式**:确保请求中包含正确格式的 Authorization 头,如 `Authorization: Bearer your-api-key`
|
||||
- **查看服务日志**:在 Web UI 的"实时日志"页面查看详细错误信息,定位具体原因
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 📄 开源许可
|
||||
|
|
|
|||
30
README.md
30
README.md
|
|
@ -31,6 +31,9 @@
|
|||
>
|
||||
> **📅 Version Update Log**
|
||||
>
|
||||
> <details>
|
||||
> <summary>Click to expand detailed version history</summary>
|
||||
>
|
||||
> - **2026.01.03** - Added theme switching functionality and optimized provider pool initialization, removed the fallback strategy of using provider default configuration
|
||||
> - **2025.12.30** - Added main process management and automatic update functionality
|
||||
> - **2025.12.25** - Unified configuration management: All configs centralized to `configs/` directory. Docker users need to update mount path to `-v "local_path:/app/configs"`
|
||||
|
|
@ -47,6 +50,7 @@
|
|||
> - **History Developed**
|
||||
> - Support Gemini CLI, Kiro and other client2API
|
||||
> - OpenAI, Claude, Gemini three-protocol mutual conversion, automatic intelligent switching
|
||||
> </details>
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -204,6 +208,9 @@ Seamlessly support the following latest large models, just configure the corresp
|
|||
|
||||
### 🔐 Authorization Configuration Guide
|
||||
|
||||
<details>
|
||||
<summary>Click to expand detailed authorization configuration steps for each provider</summary>
|
||||
|
||||
> **💡 Tip**: For the best experience, it is recommended to manage authorization visually through the **Web UI console**.
|
||||
|
||||
#### 🌐 Web UI Quick Authorization (Recommended)
|
||||
|
|
@ -245,8 +252,13 @@ In the Web UI management interface, you can complete authorization configuration
|
|||
3. **Startup Parameter Configuration**: Use the `--provider-pools-file <path>` parameter to specify the pool configuration file path
|
||||
4. **Health Check**: The system will automatically perform periodic health checks and avoid using unhealthy providers
|
||||
|
||||
</details>
|
||||
|
||||
### 📁 Authorization File Storage Paths
|
||||
|
||||
<details>
|
||||
<summary>Click to expand default storage locations for authorization credentials</summary>
|
||||
|
||||
Default storage locations for authorization credential files of each service:
|
||||
|
||||
| Service | Default Path | Description |
|
||||
|
|
@ -257,9 +269,11 @@ Default storage locations for authorization credential files of each service:
|
|||
| **Antigravity** | `~/.antigravity/oauth_creds.json` | Antigravity OAuth credentials (supports Claude 4.5 Opus) |
|
||||
|
||||
> **Note**: `~` represents the user home directory (Windows: `C:\Users\username`, Linux/macOS: `/home/username` or `/Users/username`)
|
||||
>
|
||||
|
||||
> **Custom Path**: Can specify custom storage location via relevant parameters in configuration file or environment variables
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
### 🦙 Ollama Protocol Usage Examples
|
||||
|
|
@ -270,13 +284,15 @@ This project supports the Ollama protocol, allowing access to all supported mode
|
|||
|
||||
1. **List all available models**:
|
||||
```bash
|
||||
curl http://localhost:3000/ollama/api/tags
|
||||
curl http://localhost:3000/ollama/api/tags \
|
||||
-H "Authorization: Bearer your-api-key"
|
||||
```
|
||||
|
||||
2. **Chat interface**:
|
||||
```bash
|
||||
curl http://localhost:3000/ollama/api/chat \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer your-api-key" \
|
||||
-d '{
|
||||
"model": "[Claude] claude-sonnet-4.5",
|
||||
"messages": [
|
||||
|
|
@ -296,6 +312,9 @@ curl http://localhost:3000/ollama/api/chat \
|
|||
|
||||
### Advanced Configuration
|
||||
|
||||
<details>
|
||||
<summary>Click to expand proxy configuration, model filtering, and Fallback advanced settings</summary>
|
||||
|
||||
#### 1. Proxy Configuration
|
||||
|
||||
This project supports flexible proxy configuration, allowing you to configure a unified proxy for different providers or use provider-specific proxied endpoints.
|
||||
|
|
@ -410,10 +429,15 @@ When all accounts under a Provider Type (e.g., `gemini-cli-oauth`) are exhausted
|
|||
- Fallback only occurs between protocol-compatible types (e.g., between `gemini-*`, between `claude-*`)
|
||||
- The system automatically checks if the target Provider Type supports the requested model
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## ❓ FAQ
|
||||
|
||||
<details>
|
||||
<summary>Click to expand FAQ and solutions (port occupation, Docker startup, 429 errors, etc.)</summary>
|
||||
|
||||
### 1. OAuth Authorization Failed
|
||||
|
||||
**Problem Description**: After clicking "Generate Authorization", the browser opens the authorization page but authorization fails or cannot be completed.
|
||||
|
|
@ -532,6 +556,8 @@ Or modify the port configuration in `configs/config.json` to use a different por
|
|||
- **Check Request Header Format**: Ensure the request contains the correct Authorization header format, such as `Authorization: Bearer your-api-key`
|
||||
- **Check Service Logs**: View detailed error messages on the "Real-time Logs" page in Web UI to locate the specific cause
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 📄 Open Source License
|
||||
|
|
|
|||
|
|
@ -308,6 +308,7 @@ export class KiroApiService {
|
|||
|
||||
this.modelName = KIRO_CONSTANTS.DEFAULT_MODEL_NAME;
|
||||
this.axiosInstance = null; // Initialize later in async method
|
||||
this.axiosSocialRefreshInstance = null;
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
|
|
@ -361,6 +362,10 @@ export class KiroApiService {
|
|||
configureAxiosProxy(axiosConfig, this.config, 'claude-kiro-oauth');
|
||||
|
||||
this.axiosInstance = axios.create(axiosConfig);
|
||||
|
||||
axiosConfig.headers = new Headers();
|
||||
axiosConfig.headers.set('Content-Type', KIRO_CONSTANTS.CONTENT_TYPE_JSON);
|
||||
this.axiosSocialRefreshInstance = axios.create(axiosConfig);
|
||||
this.isInitialized = true;
|
||||
}
|
||||
|
||||
|
|
@ -496,8 +501,15 @@ async initializeAuth(forceRefresh = false) {
|
|||
requestBody.clientSecret = this.clientSecret;
|
||||
requestBody.grantType = 'refresh_token';
|
||||
}
|
||||
const response = await this.axiosInstance.post(refreshUrl, requestBody);
|
||||
console.log('[Kiro Auth] Token refresh response: ok');
|
||||
|
||||
let response = null;
|
||||
if (this.authMethod === KIRO_CONSTANTS.AUTH_METHOD_SOCIAL) {
|
||||
response = await this.axiosSocialRefreshInstance.post(refreshUrl, requestBody);
|
||||
console.log('[Kiro Auth] Token refresh social response: ok');
|
||||
}else{
|
||||
response = await this.axiosInstance.post(refreshUrl, requestBody);
|
||||
console.log('[Kiro Auth] Token refresh idc response: ok');
|
||||
}
|
||||
|
||||
if (response.data && response.data.accessToken) {
|
||||
this.accessToken = response.data.accessToken;
|
||||
|
|
@ -659,9 +671,15 @@ async initializeAuth(forceRefresh = false) {
|
|||
}
|
||||
}
|
||||
|
||||
// Add remaining user/assistant messages to history
|
||||
// 保留最近 5 条历史消息中的图片
|
||||
const keepImageThreshold = 5;
|
||||
for (let i = startIndex; i < processedMessages.length - 1; i++) {
|
||||
const message = processedMessages[i];
|
||||
// 计算当前消息距离最后一条消息的位置(从后往前数)
|
||||
const distanceFromEnd = (processedMessages.length - 1) - i;
|
||||
// 如果距离末尾不超过 5 条,则保留图片
|
||||
const shouldKeepImages = distanceFromEnd <= keepImageThreshold;
|
||||
|
||||
if (message.role === 'user') {
|
||||
let userInputMessage = {
|
||||
content: '',
|
||||
|
|
@ -670,6 +688,7 @@ async initializeAuth(forceRefresh = false) {
|
|||
};
|
||||
let imageCount = 0;
|
||||
let toolResults = [];
|
||||
let images = [];
|
||||
|
||||
if (Array.isArray(message.content)) {
|
||||
for (const part of message.content) {
|
||||
|
|
@ -682,22 +701,37 @@ async initializeAuth(forceRefresh = false) {
|
|||
toolUseId: part.tool_use_id
|
||||
});
|
||||
} else if (part.type === 'image') {
|
||||
// 历史消息中的图片不保留 base64 数据,只记录数量
|
||||
// 避免请求体过大导致 400 错误
|
||||
imageCount++;
|
||||
if (shouldKeepImages) {
|
||||
// 最近 5 条消息内的图片保留原始数据
|
||||
images.push({
|
||||
format: part.source.media_type.split('/')[1],
|
||||
source: {
|
||||
bytes: part.source.data
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// 超过 5 条历史记录的图片只记录数量
|
||||
imageCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
userInputMessage.content = this.getContentText(message);
|
||||
}
|
||||
|
||||
// 如果历史消息中有图片,添加占位符说明
|
||||
// 如果有保留的图片,添加到消息中
|
||||
if (images.length > 0) {
|
||||
userInputMessage.images = images;
|
||||
console.log(`[Kiro] Kept ${images.length} image(s) in recent history message (distance from end: ${distanceFromEnd})`);
|
||||
}
|
||||
|
||||
// 如果有被替换的图片,添加占位符说明
|
||||
if (imageCount > 0) {
|
||||
const imagePlaceholder = `[此消息包含 ${imageCount} 张图片,已在历史记录中省略]`;
|
||||
userInputMessage.content = userInputMessage.content
|
||||
? `${userInputMessage.content}\n${imagePlaceholder}`
|
||||
userInputMessage.content = userInputMessage.content
|
||||
? `${userInputMessage.content}\n${imagePlaceholder}`
|
||||
: imagePlaceholder;
|
||||
console.log(`[Kiro] Replaced ${imageCount} image(s) with placeholder in history message`);
|
||||
console.log(`[Kiro] Replaced ${imageCount} image(s) with placeholder in old history message (distance from end: ${distanceFromEnd})`);
|
||||
}
|
||||
|
||||
if (toolResults.length > 0) {
|
||||
|
|
|
|||
201
src/common.js
201
src/common.js
|
|
@ -482,58 +482,49 @@ export function extractPromptText(requestBody, provider) {
|
|||
return strategy.extractPromptText(requestBody);
|
||||
}
|
||||
|
||||
export function handleError(res, error) {
|
||||
const statusCode = error.response?.status || 500;
|
||||
export function handleError(res, error, provider = null) {
|
||||
const statusCode = error.response?.status || error.statusCode || error.status || error.code || 500;
|
||||
let errorMessage = error.message;
|
||||
let suggestions = [];
|
||||
|
||||
// 仅在没有传入错误信息时,才使用默认消息;否则只添加建议
|
||||
const hasOriginalMessage = error.message && error.message.trim() !== '';
|
||||
|
||||
// 根据提供商获取适配的错误信息和建议
|
||||
const providerSuggestions = _getProviderSpecificSuggestions(statusCode, provider);
|
||||
|
||||
// Provide detailed information and suggestions for different error types
|
||||
switch (statusCode) {
|
||||
case 401:
|
||||
errorMessage = 'Authentication failed. Please check your credentials.';
|
||||
suggestions = [
|
||||
'Verify your OAuth credentials are valid',
|
||||
'Try re-authenticating by deleting the credentials file',
|
||||
'Check if your Google Cloud project has the necessary permissions'
|
||||
];
|
||||
suggestions = providerSuggestions.auth;
|
||||
break;
|
||||
case 403:
|
||||
errorMessage = 'Access forbidden. Insufficient permissions.';
|
||||
suggestions = [
|
||||
'Ensure your Google Cloud project has the Code Assist API enabled',
|
||||
'Check if your account has the necessary permissions',
|
||||
'Verify the project ID is correct'
|
||||
];
|
||||
suggestions = providerSuggestions.permission;
|
||||
break;
|
||||
case 429:
|
||||
errorMessage = 'Too many requests. Rate limit exceeded.';
|
||||
suggestions = [
|
||||
'The request has been automatically retried with exponential backoff',
|
||||
'If the issue persists, try reducing the request frequency',
|
||||
'Consider upgrading your API quota if available'
|
||||
];
|
||||
suggestions = providerSuggestions.rateLimit;
|
||||
break;
|
||||
case 500:
|
||||
case 502:
|
||||
case 503:
|
||||
case 504:
|
||||
errorMessage = 'Server error occurred. This is usually temporary.';
|
||||
suggestions = [
|
||||
'The request has been automatically retried',
|
||||
'If the issue persists, try again in a few minutes',
|
||||
'Check Google Cloud status page for service outages'
|
||||
];
|
||||
suggestions = providerSuggestions.serverError;
|
||||
break;
|
||||
default:
|
||||
if (statusCode >= 400 && statusCode < 500) {
|
||||
errorMessage = `Client error (${statusCode}): ${error.message}`;
|
||||
suggestions = ['Check your request format and parameters'];
|
||||
suggestions = providerSuggestions.clientError;
|
||||
} else if (statusCode >= 500) {
|
||||
errorMessage = `Server error (${statusCode}): ${error.message}`;
|
||||
suggestions = ['This is a server-side issue, please try again later'];
|
||||
suggestions = providerSuggestions.serverError;
|
||||
}
|
||||
}
|
||||
|
||||
errorMessage = hasOriginalMessage ? error.message.trim() : errorMessage;
|
||||
console.error(`\n[Server] Request failed (${statusCode}): ${errorMessage}`);
|
||||
if (suggestions.length > 0) {
|
||||
console.error('[Server] Suggestions:');
|
||||
|
|
@ -558,6 +549,168 @@ export function handleError(res, error) {
|
|||
res.end(JSON.stringify(errorPayload));
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据提供商类型获取适配的错误建议
|
||||
* @param {number} statusCode - HTTP 状态码
|
||||
* @param {string|null} provider - 提供商类型
|
||||
* @returns {Object} 包含各类错误建议的对象
|
||||
*/
|
||||
function _getProviderSpecificSuggestions(statusCode, provider) {
|
||||
const protocolPrefix = provider ? getProtocolPrefix(provider) : null;
|
||||
|
||||
// 默认/通用建议
|
||||
const defaultSuggestions = {
|
||||
auth: [
|
||||
'Verify your API key or credentials are valid',
|
||||
'Check if your credentials have expired',
|
||||
'Ensure the API key has the necessary permissions'
|
||||
],
|
||||
permission: [
|
||||
'Check if your account has the necessary permissions',
|
||||
'Verify the API endpoint is accessible with your credentials',
|
||||
'Contact your administrator if permissions are restricted'
|
||||
],
|
||||
rateLimit: [
|
||||
'The request has been automatically retried with exponential backoff',
|
||||
'If the issue persists, try reducing the request frequency',
|
||||
'Consider upgrading your API quota if available'
|
||||
],
|
||||
serverError: [
|
||||
'The request has been automatically retried',
|
||||
'If the issue persists, try again in a few minutes',
|
||||
'Check the service status page for outages'
|
||||
],
|
||||
clientError: [
|
||||
'Check your request format and parameters',
|
||||
'Verify the model name is correct',
|
||||
'Ensure all required fields are provided'
|
||||
]
|
||||
};
|
||||
|
||||
// 根据提供商返回特定建议
|
||||
switch (protocolPrefix) {
|
||||
case MODEL_PROTOCOL_PREFIX.GEMINI:
|
||||
return {
|
||||
auth: [
|
||||
'Verify your OAuth credentials are valid',
|
||||
'Try re-authenticating by deleting the credentials file',
|
||||
'Check if your Google Cloud project has the necessary permissions'
|
||||
],
|
||||
permission: [
|
||||
'Ensure your Google Cloud project has the Gemini API enabled',
|
||||
'Check if your account has the necessary permissions',
|
||||
'Verify the project ID is correct'
|
||||
],
|
||||
rateLimit: [
|
||||
'The request has been automatically retried with exponential backoff',
|
||||
'If the issue persists, try reducing the request frequency',
|
||||
'Consider upgrading your Google Cloud API quota'
|
||||
],
|
||||
serverError: [
|
||||
'The request has been automatically retried',
|
||||
'If the issue persists, try again in a few minutes',
|
||||
'Check Google Cloud status page for service outages'
|
||||
],
|
||||
clientError: [
|
||||
'Check your request format and parameters',
|
||||
'Verify the model name is a valid Gemini model',
|
||||
'Ensure all required fields are provided'
|
||||
]
|
||||
};
|
||||
|
||||
case MODEL_PROTOCOL_PREFIX.OPENAI:
|
||||
case MODEL_PROTOCOL_PREFIX.OPENAI_RESPONSES:
|
||||
return {
|
||||
auth: [
|
||||
'Verify your OpenAI API key is valid',
|
||||
'Check if your API key has expired or been revoked',
|
||||
'Ensure the API key is correctly formatted (starts with sk-)'
|
||||
],
|
||||
permission: [
|
||||
'Check if your OpenAI account has access to the requested model',
|
||||
'Verify your organization settings allow this operation',
|
||||
'Ensure you have sufficient credits in your account'
|
||||
],
|
||||
rateLimit: [
|
||||
'The request has been automatically retried with exponential backoff',
|
||||
'If the issue persists, try reducing the request frequency',
|
||||
'Consider upgrading your OpenAI usage tier for higher limits'
|
||||
],
|
||||
serverError: [
|
||||
'The request has been automatically retried',
|
||||
'If the issue persists, try again in a few minutes',
|
||||
'Check OpenAI status page (status.openai.com) for outages'
|
||||
],
|
||||
clientError: [
|
||||
'Check your request format and parameters',
|
||||
'Verify the model name is a valid OpenAI model',
|
||||
'Ensure the message format is correct (role and content fields)'
|
||||
]
|
||||
};
|
||||
|
||||
case MODEL_PROTOCOL_PREFIX.CLAUDE:
|
||||
return {
|
||||
auth: [
|
||||
'Verify your Anthropic API key is valid',
|
||||
'Check if your API key has expired or been revoked',
|
||||
'Ensure the x-api-key header is correctly set'
|
||||
],
|
||||
permission: [
|
||||
'Check if your Anthropic account has access to the requested model',
|
||||
'Verify your account is in good standing',
|
||||
'Ensure you have sufficient credits in your account'
|
||||
],
|
||||
rateLimit: [
|
||||
'The request has been automatically retried with exponential backoff',
|
||||
'If the issue persists, try reducing the request frequency',
|
||||
'Consider upgrading your Anthropic usage tier for higher limits'
|
||||
],
|
||||
serverError: [
|
||||
'The request has been automatically retried',
|
||||
'If the issue persists, try again in a few minutes',
|
||||
'Check Anthropic status page for service outages'
|
||||
],
|
||||
clientError: [
|
||||
'Check your request format and parameters',
|
||||
'Verify the model name is a valid Claude model',
|
||||
'Ensure the message format follows Anthropic API specifications'
|
||||
]
|
||||
};
|
||||
|
||||
case MODEL_PROTOCOL_PREFIX.OLLAMA:
|
||||
return {
|
||||
auth: [
|
||||
'Ollama typically does not require authentication',
|
||||
'If using a custom setup, verify your credentials',
|
||||
'Check if the Ollama server requires authentication'
|
||||
],
|
||||
permission: [
|
||||
'Verify the Ollama server is accessible',
|
||||
'Check if the requested model is available locally',
|
||||
'Ensure the Ollama server allows the requested operation'
|
||||
],
|
||||
rateLimit: [
|
||||
'The local Ollama server may be overloaded',
|
||||
'Try reducing concurrent requests',
|
||||
'Consider increasing server resources if running locally'
|
||||
],
|
||||
serverError: [
|
||||
'Check if the Ollama server is running',
|
||||
'Verify the server address and port are correct',
|
||||
'Check Ollama server logs for detailed error information'
|
||||
],
|
||||
clientError: [
|
||||
'Check your request format and parameters',
|
||||
'Verify the model name is available in your Ollama installation',
|
||||
'Try pulling the model first with: ollama pull <model-name>'
|
||||
]
|
||||
};
|
||||
|
||||
default:
|
||||
return defaultSuggestions;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从请求体中提取系统提示词。
|
||||
* @param {Object} requestBody - 请求体对象。
|
||||
|
|
|
|||
|
|
@ -742,6 +742,9 @@ export class ClaudeConverter extends BaseConverter {
|
|||
// Claude -> Gemini 转换
|
||||
// =========================================================================
|
||||
|
||||
// Gemini Claude thought signature constant
|
||||
static GEMINI_CLAUDE_THOUGHT_SIGNATURE = "skip_thought_signature_validator";
|
||||
|
||||
/**
|
||||
* Claude请求 -> Gemini请求
|
||||
*/
|
||||
|
|
@ -755,40 +758,148 @@ export class ClaudeConverter extends BaseConverter {
|
|||
contents: []
|
||||
};
|
||||
|
||||
// 处理系统指令
|
||||
// 处理系统指令 - 支持数组和字符串格式
|
||||
if (claudeRequest.system) {
|
||||
let incomingSystemText = null;
|
||||
if (typeof claudeRequest.system === 'string') {
|
||||
incomingSystemText = claudeRequest.system;
|
||||
if (Array.isArray(claudeRequest.system)) {
|
||||
// 数组格式的系统指令
|
||||
const systemParts = [];
|
||||
claudeRequest.system.forEach(systemPrompt => {
|
||||
if (systemPrompt && systemPrompt.type === 'text' && typeof systemPrompt.text === 'string') {
|
||||
systemParts.push({ text: systemPrompt.text });
|
||||
}
|
||||
});
|
||||
if (systemParts.length > 0) {
|
||||
geminiRequest.systemInstruction = {
|
||||
role: 'user',
|
||||
parts: systemParts
|
||||
};
|
||||
}
|
||||
} else if (typeof claudeRequest.system === 'string') {
|
||||
// 字符串格式的系统指令
|
||||
geminiRequest.systemInstruction = {
|
||||
parts: [{ text: claudeRequest.system }]
|
||||
};
|
||||
} else if (typeof claudeRequest.system === 'object') {
|
||||
incomingSystemText = JSON.stringify(claudeRequest.system);
|
||||
// 对象格式的系统指令
|
||||
geminiRequest.systemInstruction = {
|
||||
parts: [{ text: JSON.stringify(claudeRequest.system) }]
|
||||
};
|
||||
}
|
||||
geminiRequest.systemInstruction = {
|
||||
parts: [{ text: incomingSystemText }]
|
||||
};
|
||||
}
|
||||
|
||||
// 处理消息
|
||||
if (Array.isArray(claudeRequest.messages)) {
|
||||
claudeRequest.messages.forEach(message => {
|
||||
if (!message || typeof message !== 'object' || !message.role || !message.content) {
|
||||
if (!message || typeof message !== 'object' || !message.role) {
|
||||
console.warn("Skipping invalid message in claudeRequest.messages.");
|
||||
return;
|
||||
}
|
||||
|
||||
const geminiRole = message.role === 'assistant' ? 'model' : 'user';
|
||||
const processedParts = this.processClaudeContentToGeminiParts(message.content);
|
||||
const content = message.content;
|
||||
|
||||
const functionResponsePart = processedParts.find(part => part.functionResponse);
|
||||
if (functionResponsePart) {
|
||||
geminiRequest.contents.push({
|
||||
role: 'function',
|
||||
parts: [functionResponsePart]
|
||||
// 处理内容
|
||||
if (Array.isArray(content)) {
|
||||
const parts = [];
|
||||
|
||||
content.forEach(block => {
|
||||
if (!block || typeof block !== 'object') return;
|
||||
|
||||
switch (block.type) {
|
||||
case 'text':
|
||||
if (typeof block.text === 'string') {
|
||||
parts.push({ text: block.text });
|
||||
}
|
||||
break;
|
||||
|
||||
case 'tool_use':
|
||||
// 转换为 Gemini functionCall 格式
|
||||
if (block.name && block.input) {
|
||||
const args = typeof block.input === 'string'
|
||||
? block.input
|
||||
: JSON.stringify(block.input);
|
||||
|
||||
// 验证 args 是有效的 JSON 对象
|
||||
try {
|
||||
const parsedArgs = JSON.parse(args);
|
||||
if (parsedArgs && typeof parsedArgs === 'object') {
|
||||
parts.push({
|
||||
thoughtSignature: ClaudeConverter.GEMINI_CLAUDE_THOUGHT_SIGNATURE,
|
||||
functionCall: {
|
||||
name: block.name,
|
||||
args: parsedArgs
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// 如果解析失败,尝试直接使用 input
|
||||
if (block.input && typeof block.input === 'object') {
|
||||
parts.push({
|
||||
thoughtSignature: ClaudeConverter.GEMINI_CLAUDE_THOUGHT_SIGNATURE,
|
||||
functionCall: {
|
||||
name: block.name,
|
||||
args: block.input
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'tool_result':
|
||||
// 转换为 Gemini functionResponse 格式
|
||||
const toolCallId = block.tool_use_id;
|
||||
if (toolCallId) {
|
||||
// 从 tool_use_id 中提取函数名
|
||||
// 格式通常是 "funcName-uuid" 或直接是函数名
|
||||
let funcName = toolCallId;
|
||||
const toolCallIdParts = toolCallId.split('-');
|
||||
if (toolCallIdParts.length > 1) {
|
||||
// 移除最后一个部分(UUID),保留函数名
|
||||
funcName = toolCallIdParts.slice(0, -1).join('-');
|
||||
}
|
||||
|
||||
// 获取响应数据
|
||||
let responseData = block.content;
|
||||
if (typeof responseData !== 'string') {
|
||||
responseData = JSON.stringify(responseData);
|
||||
}
|
||||
|
||||
parts.push({
|
||||
functionResponse: {
|
||||
name: funcName,
|
||||
response: {
|
||||
result: responseData
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'image':
|
||||
if (block.source && block.source.type === 'base64') {
|
||||
parts.push({
|
||||
inlineData: {
|
||||
mimeType: block.source.media_type,
|
||||
data: block.source.data
|
||||
}
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
} else if (processedParts.length > 0) {
|
||||
|
||||
if (parts.length > 0) {
|
||||
geminiRequest.contents.push({
|
||||
role: geminiRole,
|
||||
parts: parts
|
||||
});
|
||||
}
|
||||
} else if (typeof content === 'string') {
|
||||
// 字符串内容
|
||||
geminiRequest.contents.push({
|
||||
role: geminiRole,
|
||||
parts: processedParts
|
||||
parts: [{ text: content }]
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
@ -796,36 +907,74 @@ export class ClaudeConverter extends BaseConverter {
|
|||
|
||||
// 添加生成配置
|
||||
const generationConfig = {};
|
||||
generationConfig.maxOutputTokens = checkAndAssignOrDefault(claudeRequest.max_tokens, GEMINI_DEFAULT_MAX_TOKENS);
|
||||
generationConfig.temperature = checkAndAssignOrDefault(claudeRequest.temperature, GEMINI_DEFAULT_TEMPERATURE);
|
||||
generationConfig.topP = checkAndAssignOrDefault(claudeRequest.top_p, GEMINI_DEFAULT_TOP_P);
|
||||
|
||||
if (claudeRequest.max_tokens !== undefined) {
|
||||
generationConfig.maxOutputTokens = claudeRequest.max_tokens;
|
||||
}
|
||||
if (claudeRequest.temperature !== undefined) {
|
||||
generationConfig.temperature = claudeRequest.temperature;
|
||||
}
|
||||
if (claudeRequest.top_p !== undefined) {
|
||||
generationConfig.topP = claudeRequest.top_p;
|
||||
}
|
||||
if (claudeRequest.top_k !== undefined) {
|
||||
generationConfig.topK = claudeRequest.top_k;
|
||||
}
|
||||
|
||||
// 处理 thinking 配置 - 转换为 Gemini thinkingBudget
|
||||
if (claudeRequest.thinking && claudeRequest.thinking.type === 'enabled') {
|
||||
if (claudeRequest.thinking.budget_tokens !== undefined) {
|
||||
const budget = claudeRequest.thinking.budget_tokens;
|
||||
if (!generationConfig.thinkingConfig) {
|
||||
generationConfig.thinkingConfig = {};
|
||||
}
|
||||
generationConfig.thinkingConfig.thinkingBudget = budget;
|
||||
generationConfig.thinkingConfig.include_thoughts = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(generationConfig).length > 0) {
|
||||
geminiRequest.generationConfig = generationConfig;
|
||||
}
|
||||
|
||||
// 处理工具
|
||||
if (Array.isArray(claudeRequest.tools)) {
|
||||
geminiRequest.tools = [{
|
||||
functionDeclarations: claudeRequest.tools.map(tool => {
|
||||
if (!tool || typeof tool !== 'object' || !tool.name) {
|
||||
console.warn("Skipping invalid tool declaration in claudeRequest.tools.");
|
||||
return null;
|
||||
}
|
||||
|
||||
delete tool.input_schema.$schema;
|
||||
return {
|
||||
name: String(tool.name),
|
||||
description: String(tool.description || ''),
|
||||
parameters: tool.input_schema && typeof tool.input_schema === 'object'
|
||||
? tool.input_schema
|
||||
: { type: 'object', properties: {} }
|
||||
};
|
||||
}).filter(Boolean)
|
||||
}];
|
||||
// 处理工具 - 使用 parametersJsonSchema 格式
|
||||
if (Array.isArray(claudeRequest.tools) && claudeRequest.tools.length > 0) {
|
||||
const functionDeclarations = [];
|
||||
|
||||
if (geminiRequest.tools[0].functionDeclarations.length === 0) {
|
||||
delete geminiRequest.tools;
|
||||
claudeRequest.tools.forEach(tool => {
|
||||
if (!tool || typeof tool !== 'object' || !tool.name) {
|
||||
console.warn("Skipping invalid tool declaration in claudeRequest.tools.");
|
||||
return;
|
||||
}
|
||||
|
||||
// 清理 input_schema
|
||||
let inputSchema = tool.input_schema;
|
||||
if (inputSchema && typeof inputSchema === 'object') {
|
||||
// 创建副本以避免修改原始对象
|
||||
inputSchema = JSON.parse(JSON.stringify(inputSchema));
|
||||
// 清理不需要的字段
|
||||
delete inputSchema.$schema;
|
||||
// 清理 URL 格式(Gemini 不支持)
|
||||
this.cleanUrlFormatFromSchema(inputSchema);
|
||||
}
|
||||
|
||||
const funcDecl = {
|
||||
name: String(tool.name),
|
||||
description: String(tool.description || '')
|
||||
};
|
||||
|
||||
// 使用 parametersJsonSchema 而不是 parameters
|
||||
if (inputSchema) {
|
||||
funcDecl.parametersJsonSchema = inputSchema;
|
||||
}
|
||||
|
||||
functionDeclarations.push(funcDecl);
|
||||
});
|
||||
|
||||
if (functionDeclarations.length > 0) {
|
||||
geminiRequest.tools = [{
|
||||
functionDeclarations: functionDeclarations
|
||||
}];
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -834,9 +983,55 @@ export class ClaudeConverter extends BaseConverter {
|
|||
geminiRequest.toolConfig = this.buildGeminiToolConfigFromClaude(claudeRequest.tool_choice);
|
||||
}
|
||||
|
||||
// 添加默认安全设置
|
||||
geminiRequest.safetySettings = this.getDefaultSafetySettings();
|
||||
|
||||
return geminiRequest;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理 JSON Schema 中的 URL 格式
|
||||
* Gemini 不支持 "format": "uri"
|
||||
*/
|
||||
cleanUrlFormatFromSchema(schema) {
|
||||
if (!schema || typeof schema !== 'object') return;
|
||||
|
||||
// 如果是属性对象,检查并清理 format
|
||||
if (schema.type === 'string' && schema.format === 'uri') {
|
||||
delete schema.format;
|
||||
}
|
||||
|
||||
// 递归处理 properties
|
||||
if (schema.properties && typeof schema.properties === 'object') {
|
||||
Object.values(schema.properties).forEach(prop => {
|
||||
this.cleanUrlFormatFromSchema(prop);
|
||||
});
|
||||
}
|
||||
|
||||
// 递归处理 items(数组类型)
|
||||
if (schema.items) {
|
||||
this.cleanUrlFormatFromSchema(schema.items);
|
||||
}
|
||||
|
||||
// 递归处理 additionalProperties
|
||||
if (schema.additionalProperties && typeof schema.additionalProperties === 'object') {
|
||||
this.cleanUrlFormatFromSchema(schema.additionalProperties);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取默认的 Gemini 安全设置
|
||||
*/
|
||||
getDefaultSafetySettings() {
|
||||
return [
|
||||
{ category: "HARM_CATEGORY_HARASSMENT", threshold: "OFF" },
|
||||
{ category: "HARM_CATEGORY_HATE_SPEECH", threshold: "OFF" },
|
||||
{ category: "HARM_CATEGORY_SEXUALLY_EXPLICIT", threshold: "OFF" },
|
||||
{ category: "HARM_CATEGORY_DANGEROUS_CONTENT", threshold: "OFF" },
|
||||
{ category: "HARM_CATEGORY_CIVIC_INTEGRITY", threshold: "OFF" }
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude响应 -> Gemini响应
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -228,11 +228,32 @@ export class OllamaConverter extends BaseConverter {
|
|||
ollamaResponse.done_reason = response.stop_reason === 'end_turn' ? 'stop' : response.stop_reason;
|
||||
}
|
||||
}
|
||||
// Handle Gemini format (candidates array)
|
||||
else if (response.candidates && response.candidates.length > 0) {
|
||||
const candidate = response.candidates[0];
|
||||
let textContent = '';
|
||||
if (candidate.content && candidate.content.parts) {
|
||||
textContent = candidate.content.parts
|
||||
.filter(part => part.text)
|
||||
.map(part => part.text)
|
||||
.join('');
|
||||
}
|
||||
|
||||
ollamaResponse.message = {
|
||||
role: candidate.content?.role || 'assistant',
|
||||
content: textContent
|
||||
};
|
||||
|
||||
if (candidate.finishReason) {
|
||||
ollamaResponse.done_reason = candidate.finishReason.toLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
// Add usage statistics if available
|
||||
if (response.usage) {
|
||||
ollamaResponse.prompt_eval_count = response.usage.prompt_tokens || response.usage.input_tokens || 0;
|
||||
ollamaResponse.eval_count = response.usage.completion_tokens || response.usage.output_tokens || 0;
|
||||
const usage = response.usage || response.usageMetadata;
|
||||
if (usage) {
|
||||
ollamaResponse.prompt_eval_count = usage.prompt_tokens || usage.input_tokens || usage.promptTokenCount || 0;
|
||||
ollamaResponse.eval_count = usage.completion_tokens || usage.output_tokens || usage.candidatesTokenCount || 0;
|
||||
ollamaResponse.total_duration = 0;
|
||||
ollamaResponse.load_duration = 0;
|
||||
ollamaResponse.prompt_eval_duration = 0;
|
||||
|
|
@ -275,11 +296,28 @@ export class OllamaConverter extends BaseConverter {
|
|||
ollamaResponse.done_reason = response.stop_reason === 'end_turn' ? 'stop' : response.stop_reason;
|
||||
}
|
||||
}
|
||||
// Handle Gemini format
|
||||
else if (response.candidates && response.candidates.length > 0) {
|
||||
const candidate = response.candidates[0];
|
||||
let textContent = '';
|
||||
if (candidate.content && candidate.content.parts) {
|
||||
textContent = candidate.content.parts
|
||||
.filter(part => part.text)
|
||||
.map(part => part.text)
|
||||
.join('');
|
||||
}
|
||||
ollamaResponse.response = textContent;
|
||||
|
||||
if (candidate.finishReason) {
|
||||
ollamaResponse.done_reason = candidate.finishReason.toLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
// Add usage statistics
|
||||
if (response.usage) {
|
||||
ollamaResponse.prompt_eval_count = response.usage.prompt_tokens || response.usage.input_tokens || 0;
|
||||
ollamaResponse.eval_count = response.usage.completion_tokens || response.usage.output_tokens || 0;
|
||||
const genUsage = response.usage || response.usageMetadata;
|
||||
if (genUsage) {
|
||||
ollamaResponse.prompt_eval_count = genUsage.prompt_tokens || genUsage.input_tokens || genUsage.promptTokenCount || 0;
|
||||
ollamaResponse.eval_count = genUsage.completion_tokens || genUsage.output_tokens || genUsage.candidatesTokenCount || 0;
|
||||
ollamaResponse.total_duration = 0;
|
||||
ollamaResponse.load_duration = 0;
|
||||
ollamaResponse.prompt_eval_duration = 0;
|
||||
|
|
|
|||
|
|
@ -547,124 +547,532 @@ export class OpenAIConverter extends BaseConverter {
|
|||
// OpenAI -> Gemini 转换
|
||||
// =========================================================================
|
||||
|
||||
// Gemini Openai thought signature constant
|
||||
static GEMINI_OPENAI_THOUGHT_SIGNATURE = "skip_thought_signature_validator";
|
||||
/**
|
||||
* OpenAI请求 -> Gemini请求
|
||||
*/
|
||||
toGeminiRequest(openaiRequest) {
|
||||
const messages = openaiRequest.messages || [];
|
||||
const { systemInstruction, nonSystemMessages } = extractSystemMessages(messages);
|
||||
const model = openaiRequest.model || '';
|
||||
|
||||
// 构建 tool_call_id -> function_name 映射
|
||||
const tcID2Name = {};
|
||||
for (const message of messages) {
|
||||
if (message.role === 'assistant' && message.tool_calls) {
|
||||
for (const tc of message.tool_calls) {
|
||||
if (tc.type === 'function' && tc.id && tc.function?.name) {
|
||||
tcID2Name[tc.id] = tc.function.name;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 构建 tool_call_id -> response 映射
|
||||
const toolResponses = {};
|
||||
for (const message of messages) {
|
||||
if (message.role === 'tool' && message.tool_call_id) {
|
||||
toolResponses[message.tool_call_id] = message.content;
|
||||
}
|
||||
}
|
||||
|
||||
const processedMessages = [];
|
||||
let lastMessage = null;
|
||||
let systemInstruction = null;
|
||||
|
||||
for (const message of nonSystemMessages) {
|
||||
const geminiRole = message.role === 'assistant' ? 'model' : message.role;
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const message = messages[i];
|
||||
const role = message.role;
|
||||
const content = message.content;
|
||||
|
||||
if (geminiRole === 'tool') {
|
||||
// Save previous model response with functionCall
|
||||
if (lastMessage) {
|
||||
processedMessages.push(lastMessage);
|
||||
lastMessage = null;
|
||||
if (role === 'system') {
|
||||
// system -> system_instruction
|
||||
if (messages.length > 1) {
|
||||
if (typeof content === 'string') {
|
||||
systemInstruction = {
|
||||
role: 'user',
|
||||
parts: [{ text: content }]
|
||||
};
|
||||
} else if (Array.isArray(content)) {
|
||||
const parts = content
|
||||
.filter(item => item.type === 'text' && item.text)
|
||||
.map(item => ({ text: item.text }));
|
||||
if (parts.length > 0) {
|
||||
systemInstruction = {
|
||||
role: 'user',
|
||||
parts: parts
|
||||
};
|
||||
}
|
||||
} else if (typeof content === 'object' && content.type === 'text') {
|
||||
systemInstruction = {
|
||||
role: 'user',
|
||||
parts: [{ text: content.text }]
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// 只有一条 system 消息时,作为 user 消息处理
|
||||
const node = { role: 'user', parts: [] };
|
||||
if (typeof content === 'string') {
|
||||
node.parts.push({ text: content });
|
||||
} else if (Array.isArray(content)) {
|
||||
for (const item of content) {
|
||||
if (item.type === 'text' && item.text) {
|
||||
node.parts.push({ text: item.text });
|
||||
}
|
||||
}
|
||||
}
|
||||
if (node.parts.length > 0) {
|
||||
processedMessages.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
// Get function name from message.name or via tool_call_id
|
||||
let functionName = message.name;
|
||||
if (!functionName && message.tool_call_id) {
|
||||
const currentIndex = nonSystemMessages.indexOf(message);
|
||||
for (let i = currentIndex - 1; i >= 0; i--) {
|
||||
const prevMsg = nonSystemMessages[i];
|
||||
if (prevMsg.role === 'assistant' && prevMsg.tool_calls) {
|
||||
const toolCall = prevMsg.tool_calls.find(tc => tc.id === message.tool_call_id);
|
||||
if (toolCall?.function?.name) {
|
||||
functionName = toolCall.function.name;
|
||||
} else if (role === 'user') {
|
||||
// user -> user content
|
||||
const node = { role: 'user', parts: [] };
|
||||
if (typeof content === 'string') {
|
||||
node.parts.push({ text: content });
|
||||
} else if (Array.isArray(content)) {
|
||||
for (const item of content) {
|
||||
if (!item) continue;
|
||||
switch (item.type) {
|
||||
case 'text':
|
||||
if (item.text) {
|
||||
node.parts.push({ text: item.text });
|
||||
}
|
||||
break;
|
||||
case 'image_url':
|
||||
if (item.image_url) {
|
||||
const imageUrl = typeof item.image_url === 'string'
|
||||
? item.image_url
|
||||
: item.image_url.url;
|
||||
if (imageUrl && imageUrl.startsWith('data:')) {
|
||||
const commaIndex = imageUrl.indexOf(',');
|
||||
if (commaIndex > 5) {
|
||||
const header = imageUrl.substring(5, commaIndex);
|
||||
const semicolonIndex = header.indexOf(';');
|
||||
if (semicolonIndex > 0) {
|
||||
const mimeType = header.substring(0, semicolonIndex);
|
||||
const data = imageUrl.substring(commaIndex + 1);
|
||||
node.parts.push({
|
||||
inlineData: {
|
||||
mimeType: mimeType,
|
||||
data: data
|
||||
},
|
||||
thoughtSignature: OpenAIConverter.GEMINI_OPENAI_THOUGHT_SIGNATURE
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (imageUrl) {
|
||||
node.parts.push({
|
||||
fileData: {
|
||||
mimeType: 'image/jpeg',
|
||||
fileUri: imageUrl
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'file':
|
||||
if (item.file) {
|
||||
const filename = item.file.filename || '';
|
||||
const fileData = item.file.file_data || '';
|
||||
const ext = filename.includes('.')
|
||||
? filename.split('.').pop().toLowerCase()
|
||||
: '';
|
||||
const mimeTypes = {
|
||||
'pdf': 'application/pdf',
|
||||
'txt': 'text/plain',
|
||||
'html': 'text/html',
|
||||
'css': 'text/css',
|
||||
'js': 'application/javascript',
|
||||
'json': 'application/json',
|
||||
'xml': 'application/xml',
|
||||
'csv': 'text/csv',
|
||||
'md': 'text/markdown',
|
||||
'py': 'text/x-python',
|
||||
'java': 'text/x-java',
|
||||
'c': 'text/x-c',
|
||||
'cpp': 'text/x-c++',
|
||||
'h': 'text/x-c',
|
||||
'hpp': 'text/x-c++',
|
||||
'go': 'text/x-go',
|
||||
'rs': 'text/x-rust',
|
||||
'ts': 'text/typescript',
|
||||
'tsx': 'text/typescript',
|
||||
'jsx': 'text/javascript',
|
||||
'png': 'image/png',
|
||||
'jpg': 'image/jpeg',
|
||||
'jpeg': 'image/jpeg',
|
||||
'gif': 'image/gif',
|
||||
'webp': 'image/webp',
|
||||
'svg': 'image/svg+xml',
|
||||
'mp3': 'audio/mpeg',
|
||||
'wav': 'audio/wav',
|
||||
'mp4': 'video/mp4',
|
||||
'webm': 'video/webm'
|
||||
};
|
||||
const mimeType = mimeTypes[ext];
|
||||
if (mimeType && fileData) {
|
||||
node.parts.push({
|
||||
inlineData: {
|
||||
mimeType: mimeType,
|
||||
data: fileData
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (node.parts.length > 0) {
|
||||
processedMessages.push(node);
|
||||
}
|
||||
} else if (role === 'assistant') {
|
||||
// assistant -> model content
|
||||
const node = { role: 'model', parts: [] };
|
||||
|
||||
// 处理文本内容
|
||||
if (typeof content === 'string' && content) {
|
||||
node.parts.push({ text: content });
|
||||
} else if (Array.isArray(content)) {
|
||||
for (const item of content) {
|
||||
if (!item) continue;
|
||||
if (item.type === 'text' && item.text) {
|
||||
node.parts.push({ text: item.text });
|
||||
} else if (item.type === 'image_url' && item.image_url) {
|
||||
const imageUrl = typeof item.image_url === 'string'
|
||||
? item.image_url
|
||||
: item.image_url.url;
|
||||
if (imageUrl && imageUrl.startsWith('data:')) {
|
||||
const commaIndex = imageUrl.indexOf(',');
|
||||
if (commaIndex > 5) {
|
||||
const header = imageUrl.substring(5, commaIndex);
|
||||
const semicolonIndex = header.indexOf(';');
|
||||
if (semicolonIndex > 0) {
|
||||
const mimeType = header.substring(0, semicolonIndex);
|
||||
const data = imageUrl.substring(commaIndex + 1);
|
||||
node.parts.push({
|
||||
inlineData: {
|
||||
mimeType: mimeType,
|
||||
data: data
|
||||
},
|
||||
thoughtSignature: OpenAIConverter.GEMINI_OPENAI_THOUGHT_SIGNATURE
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build functionResponse according to Gemini API spec
|
||||
const parsedContent = safeParseJSON(message.content);
|
||||
const contentStr = typeof parsedContent === 'string' ? parsedContent : JSON.stringify(parsedContent);
|
||||
|
||||
processedMessages.push({
|
||||
role: 'user',
|
||||
parts: [{
|
||||
functionResponse: {
|
||||
name: functionName || 'unknown',
|
||||
response: {
|
||||
name: functionName || 'unknown',
|
||||
content: contentStr
|
||||
}
|
||||
// 处理 tool_calls -> functionCall
|
||||
if (message.tool_calls && Array.isArray(message.tool_calls)) {
|
||||
const functionCallIds = [];
|
||||
for (const tc of message.tool_calls) {
|
||||
if (tc.type !== 'function') continue;
|
||||
const fid = tc.id || '';
|
||||
const fname = tc.function?.name || '';
|
||||
const fargs = tc.function?.arguments || '{}';
|
||||
|
||||
let argsObj;
|
||||
try {
|
||||
argsObj = typeof fargs === 'string' ? JSON.parse(fargs) : fargs;
|
||||
} catch (e) {
|
||||
argsObj = {};
|
||||
}
|
||||
}]
|
||||
});
|
||||
lastMessage = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
let processedContent = this.processOpenAIContentToGeminiParts(message.content);
|
||||
|
||||
// Add tool_calls as functionCall to parts
|
||||
if (message.tool_calls && Array.isArray(message.tool_calls)) {
|
||||
for (const toolCall of message.tool_calls) {
|
||||
if (toolCall.function) {
|
||||
processedContent.push({
|
||||
|
||||
node.parts.push({
|
||||
functionCall: {
|
||||
name: toolCall.function.name,
|
||||
args: safeParseJSON(toolCall.function.arguments)
|
||||
}
|
||||
name: fname,
|
||||
args: argsObj
|
||||
},
|
||||
thoughtSignature: OpenAIConverter.GEMINI_OPENAI_THOUGHT_SIGNATURE
|
||||
});
|
||||
|
||||
if (fid) {
|
||||
functionCallIds.push(fid);
|
||||
}
|
||||
}
|
||||
|
||||
// 添加 model 消息
|
||||
if (node.parts.length > 0) {
|
||||
processedMessages.push(node);
|
||||
}
|
||||
|
||||
// 添加对应的 functionResponse(作为 user 消息)
|
||||
if (functionCallIds.length > 0) {
|
||||
const toolNode = { role: 'user', parts: [] };
|
||||
for (const fid of functionCallIds) {
|
||||
const name = tcID2Name[fid];
|
||||
if (name) {
|
||||
let resp = toolResponses[fid] || '{}';
|
||||
// 确保 resp 是字符串
|
||||
if (typeof resp !== 'string') {
|
||||
resp = JSON.stringify(resp);
|
||||
}
|
||||
toolNode.parts.push({
|
||||
functionResponse: {
|
||||
name: name,
|
||||
response: {
|
||||
result: resp
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
if (toolNode.parts.length > 0) {
|
||||
processedMessages.push(toolNode);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 没有 tool_calls,直接添加
|
||||
if (node.parts.length > 0) {
|
||||
processedMessages.push(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (lastMessage && lastMessage.role === geminiRole && !message.tool_calls &&
|
||||
Array.isArray(processedContent) && processedContent.every(p => p.text) &&
|
||||
Array.isArray(lastMessage.parts) && lastMessage.parts.every(p => p.text)) {
|
||||
lastMessage.parts.push(...processedContent);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (lastMessage) processedMessages.push(lastMessage);
|
||||
lastMessage = { role: geminiRole, parts: processedContent };
|
||||
// tool 消息已经在 assistant 的 tool_calls 处理中合并了,这里跳过
|
||||
}
|
||||
if (lastMessage) processedMessages.push(lastMessage);
|
||||
|
||||
// 构建 Gemini 请求
|
||||
const geminiRequest = {
|
||||
contents: processedMessages.filter(item => item.parts && item.parts.length > 0)
|
||||
};
|
||||
|
||||
if (systemInstruction) geminiRequest.systemInstruction = systemInstruction;
|
||||
// 添加 model
|
||||
if (model) {
|
||||
geminiRequest.model = model;
|
||||
}
|
||||
|
||||
if (openaiRequest.tools?.length) {
|
||||
geminiRequest.tools = [{
|
||||
functionDeclarations: openaiRequest.tools.map(t => {
|
||||
if (!t || typeof t !== 'object' || !t.function) return null;
|
||||
const func = t.function;
|
||||
const parameters = cleanJsonSchema(func.parameters || {});
|
||||
return {
|
||||
name: String(func.name || ''),
|
||||
description: String(func.description || ''),
|
||||
parameters: parameters
|
||||
};
|
||||
}).filter(Boolean)
|
||||
}];
|
||||
if (geminiRequest.tools[0].functionDeclarations.length === 0) {
|
||||
delete geminiRequest.tools;
|
||||
// 添加 system_instruction
|
||||
if (systemInstruction) {
|
||||
geminiRequest.system_instruction = systemInstruction;
|
||||
}
|
||||
|
||||
// 处理 reasoning_effort -> thinkingConfig
|
||||
if (openaiRequest.reasoning_effort) {
|
||||
const effort = String(openaiRequest.reasoning_effort).toLowerCase().trim();
|
||||
if (this.modelSupportsThinking(model)) {
|
||||
if (this.isGemini3Model(model)) {
|
||||
// Gemini 3 模型使用 thinkingLevel
|
||||
if (effort === 'none') {
|
||||
// 不添加 thinkingConfig
|
||||
} else if (effort === 'auto') {
|
||||
geminiRequest.generationConfig = geminiRequest.generationConfig || {};
|
||||
geminiRequest.generationConfig.thinkingConfig = {
|
||||
includeThoughts: true
|
||||
};
|
||||
} else {
|
||||
const level = this.validateGemini3ThinkingLevel(model, effort);
|
||||
if (level) {
|
||||
geminiRequest.generationConfig = geminiRequest.generationConfig || {};
|
||||
geminiRequest.generationConfig.thinkingConfig = {
|
||||
thinkingLevel: level
|
||||
};
|
||||
}
|
||||
}
|
||||
} else if (!this.modelUsesThinkingLevels(model)) {
|
||||
// 使用 thinkingBudget 的模型
|
||||
geminiRequest.generationConfig = geminiRequest.generationConfig || {};
|
||||
geminiRequest.generationConfig.thinkingConfig = this.applyReasoningEffortToGemini(effort);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 处理 extra_body.google.thinking_config(Cherry Studio 扩展)
|
||||
if (!openaiRequest.reasoning_effort && openaiRequest.extra_body?.google?.thinking_config) {
|
||||
const tc = openaiRequest.extra_body.google.thinking_config;
|
||||
if (this.modelSupportsThinking(model) && !this.modelUsesThinkingLevels(model)) {
|
||||
geminiRequest.generationConfig = geminiRequest.generationConfig || {};
|
||||
geminiRequest.generationConfig.thinkingConfig = geminiRequest.generationConfig.thinkingConfig || {};
|
||||
|
||||
let setBudget = false;
|
||||
let budget = 0;
|
||||
|
||||
if (tc.thinkingBudget !== undefined) {
|
||||
budget = parseInt(tc.thinkingBudget, 10);
|
||||
geminiRequest.generationConfig.thinkingConfig.thinkingBudget = budget;
|
||||
setBudget = true;
|
||||
} else if (tc.thinking_budget !== undefined) {
|
||||
budget = parseInt(tc.thinking_budget, 10);
|
||||
geminiRequest.generationConfig.thinkingConfig.thinkingBudget = budget;
|
||||
setBudget = true;
|
||||
}
|
||||
|
||||
if (tc.includeThoughts !== undefined) {
|
||||
geminiRequest.generationConfig.thinkingConfig.includeThoughts = tc.includeThoughts;
|
||||
} else if (tc.include_thoughts !== undefined) {
|
||||
geminiRequest.generationConfig.thinkingConfig.includeThoughts = tc.include_thoughts;
|
||||
} else if (setBudget && budget !== 0) {
|
||||
geminiRequest.generationConfig.thinkingConfig.includeThoughts = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 处理 modalities -> responseModalities
|
||||
if (openaiRequest.modalities && Array.isArray(openaiRequest.modalities)) {
|
||||
const responseMods = [];
|
||||
for (const m of openaiRequest.modalities) {
|
||||
const mod = String(m).toLowerCase();
|
||||
if (mod === 'text') {
|
||||
responseMods.push('TEXT');
|
||||
} else if (mod === 'image') {
|
||||
responseMods.push('IMAGE');
|
||||
}
|
||||
}
|
||||
if (responseMods.length > 0) {
|
||||
geminiRequest.generationConfig = geminiRequest.generationConfig || {};
|
||||
geminiRequest.generationConfig.responseModalities = responseMods;
|
||||
}
|
||||
}
|
||||
|
||||
// 处理 image_config(OpenRouter 风格)
|
||||
if (openaiRequest.image_config) {
|
||||
const imgCfg = openaiRequest.image_config;
|
||||
if (imgCfg.aspect_ratio) {
|
||||
geminiRequest.generationConfig = geminiRequest.generationConfig || {};
|
||||
geminiRequest.generationConfig.imageConfig = geminiRequest.generationConfig.imageConfig || {};
|
||||
geminiRequest.generationConfig.imageConfig.aspectRatio = imgCfg.aspect_ratio;
|
||||
}
|
||||
if (imgCfg.image_size) {
|
||||
geminiRequest.generationConfig = geminiRequest.generationConfig || {};
|
||||
geminiRequest.generationConfig.imageConfig = geminiRequest.generationConfig.imageConfig || {};
|
||||
geminiRequest.generationConfig.imageConfig.imageSize = imgCfg.image_size;
|
||||
}
|
||||
}
|
||||
|
||||
// 处理 tools -> functionDeclarations
|
||||
if (openaiRequest.tools?.length) {
|
||||
const functionDeclarations = [];
|
||||
let hasGoogleSearch = false;
|
||||
|
||||
for (const t of openaiRequest.tools) {
|
||||
if (!t || typeof t !== 'object') continue;
|
||||
|
||||
if (t.type === 'function' && t.function) {
|
||||
const func = t.function;
|
||||
let fnDecl = {
|
||||
name: String(func.name || ''),
|
||||
description: String(func.description || '')
|
||||
};
|
||||
|
||||
// 处理 parameters -> parametersJsonSchema
|
||||
if (func.parameters) {
|
||||
fnDecl.parametersJsonSchema = cleanJsonSchema(func.parameters);
|
||||
} else {
|
||||
fnDecl.parametersJsonSchema = {
|
||||
type: 'object',
|
||||
properties: {}
|
||||
};
|
||||
}
|
||||
|
||||
functionDeclarations.push(fnDecl);
|
||||
}
|
||||
|
||||
// 处理 google_search 工具
|
||||
if (t.google_search) {
|
||||
hasGoogleSearch = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (functionDeclarations.length > 0 || hasGoogleSearch) {
|
||||
geminiRequest.tools = [{}];
|
||||
if (functionDeclarations.length > 0) {
|
||||
geminiRequest.tools[0].functionDeclarations = functionDeclarations;
|
||||
}
|
||||
if (hasGoogleSearch) {
|
||||
const googleSearchTool = openaiRequest.tools.find(t => t.google_search);
|
||||
geminiRequest.tools[0].googleSearch = googleSearchTool.google_search;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 处理 tool_choice
|
||||
if (openaiRequest.tool_choice) {
|
||||
geminiRequest.toolConfig = this.buildGeminiToolConfig(openaiRequest.tool_choice);
|
||||
}
|
||||
|
||||
const config = this.buildGeminiGenerationConfig(openaiRequest, openaiRequest.model);
|
||||
if (Object.keys(config).length) geminiRequest.generationConfig = config;
|
||||
// 构建 generationConfig
|
||||
const config = this.buildGeminiGenerationConfig(openaiRequest, model);
|
||||
if (Object.keys(config).length) {
|
||||
geminiRequest.generationConfig = {
|
||||
...config,
|
||||
...(geminiRequest.generationConfig || {})
|
||||
};
|
||||
}
|
||||
|
||||
// 添加默认安全设置
|
||||
geminiRequest.safetySettings = this.getDefaultSafetySettings();
|
||||
|
||||
return geminiRequest;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查模型是否支持 thinking
|
||||
*/
|
||||
modelSupportsThinking(model) {
|
||||
if (!model) return false;
|
||||
const m = model.toLowerCase();
|
||||
return m.includes('2.5') || m.includes('thinking') || m.includes('2.0-flash-thinking');
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否是 Gemini 3 模型
|
||||
*/
|
||||
isGemini3Model(model) {
|
||||
if (!model) return false;
|
||||
const m = model.toLowerCase();
|
||||
return m.includes('gemini-3') || m.includes('gemini3');
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查模型是否使用 thinking levels(而不是 budget)
|
||||
*/
|
||||
modelUsesThinkingLevels(model) {
|
||||
if (!model) return false;
|
||||
// Gemini 3 模型使用 levels,其他使用 budget
|
||||
return this.isGemini3Model(model);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 Gemini 3 thinking level
|
||||
*/
|
||||
validateGemini3ThinkingLevel(model, effort) {
|
||||
const validLevels = ['low', 'medium', 'high'];
|
||||
if (validLevels.includes(effort)) {
|
||||
return effort.toUpperCase();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 reasoning_effort 转换为 Gemini thinkingConfig
|
||||
*/
|
||||
applyReasoningEffortToGemini(effort) {
|
||||
const effortToBudget = {
|
||||
'low': 1024,
|
||||
'medium': 8192,
|
||||
'high': 24576
|
||||
};
|
||||
const budget = effortToBudget[effort] || effortToBudget['medium'];
|
||||
return {
|
||||
thinkingBudget: budget,
|
||||
includeThoughts: true
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取默认安全设置
|
||||
*/
|
||||
getDefaultSafetySettings() {
|
||||
return [
|
||||
{ category: "HARM_CATEGORY_HARASSMENT", threshold: "OFF" },
|
||||
{ category: "HARM_CATEGORY_HATE_SPEECH", threshold: "OFF" },
|
||||
{ category: "HARM_CATEGORY_SEXUALLY_EXPLICIT", threshold: "OFF" },
|
||||
{ category: "HARM_CATEGORY_DANGEROUS_CONTENT", threshold: "OFF" },
|
||||
{ category: "HARM_CATEGORY_CIVIC_INTEGRITY", threshold: "OFF" }
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理OpenAI内容到Gemini parts
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -229,6 +229,12 @@ export function extractAndProcessSystemMessages(messages) {
|
|||
|
||||
/**
|
||||
* 清理JSON Schema属性(移除Gemini不支持的属性)
|
||||
* Google Gemini API 只支持有限的 JSON Schema 属性,不支持以下属性:
|
||||
* - exclusiveMinimum, exclusiveMaximum, minimum, maximum
|
||||
* - minLength, maxLength, minItems, maxItems
|
||||
* - pattern, format, default, const
|
||||
* - additionalProperties, $schema, $ref, $id
|
||||
* - allOf, anyOf, oneOf, not
|
||||
* @param {Object} schema - JSON Schema
|
||||
* @returns {Object} 清理后的JSON Schema
|
||||
*/
|
||||
|
|
@ -237,23 +243,39 @@ export function cleanJsonSchemaProperties(schema) {
|
|||
return schema;
|
||||
}
|
||||
|
||||
// 如果是数组,递归处理每个元素
|
||||
if (Array.isArray(schema)) {
|
||||
return schema.map(item => cleanJsonSchemaProperties(item));
|
||||
}
|
||||
|
||||
// Gemini 支持的 JSON Schema 属性白名单
|
||||
const allowedKeys = [
|
||||
"type",
|
||||
"description",
|
||||
"properties",
|
||||
"required",
|
||||
"enum",
|
||||
"items",
|
||||
"nullable"
|
||||
];
|
||||
|
||||
const sanitized = {};
|
||||
for (const [key, value] of Object.entries(schema)) {
|
||||
if (["type", "description", "properties", "required", "enum", "items"].includes(key)) {
|
||||
sanitized[key] = value;
|
||||
if (allowedKeys.includes(key)) {
|
||||
// 对于需要递归处理的属性
|
||||
if (key === 'properties' && typeof value === 'object' && value !== null) {
|
||||
const cleanProperties = {};
|
||||
for (const [propName, propSchema] of Object.entries(value)) {
|
||||
cleanProperties[propName] = cleanJsonSchemaProperties(propSchema);
|
||||
}
|
||||
sanitized[key] = cleanProperties;
|
||||
} else if (key === 'items') {
|
||||
sanitized[key] = cleanJsonSchemaProperties(value);
|
||||
} else {
|
||||
sanitized[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (sanitized.properties && typeof sanitized.properties === 'object') {
|
||||
const cleanProperties = {};
|
||||
for (const [propName, propSchema] of Object.entries(sanitized.properties)) {
|
||||
cleanProperties[propName] = cleanJsonSchemaProperties(propSchema);
|
||||
}
|
||||
sanitized.properties = cleanProperties;
|
||||
}
|
||||
|
||||
if (sanitized.items) {
|
||||
sanitized.items = cleanJsonSchemaProperties(sanitized.items);
|
||||
// 其他属性(如 exclusiveMinimum, minimum, maximum, pattern 等)被忽略
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
|
||||
import { OAuth2Client } from 'google-auth-library';
|
||||
import * as http from 'http';
|
||||
import * as https from 'https';
|
||||
import * as crypto from 'crypto';
|
||||
import { promises as fs } from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
|
|
@ -11,6 +13,7 @@ import { formatExpiryTime } from '../common.js';
|
|||
import { getProviderModels } from '../provider-models.js';
|
||||
import { handleGeminiAntigravityOAuth } from '../oauth-handlers.js';
|
||||
import { getProxyConfigForProvider, getGoogleAuthProxyConfig } from '../proxy-utils.js';
|
||||
import { cleanJsonSchemaProperties } from '../converters/utils.js';
|
||||
|
||||
// 配置 HTTP/HTTPS agent 限制连接池大小,避免资源泄漏
|
||||
const httpAgent = new http.Agent({
|
||||
|
|
@ -29,35 +32,44 @@ const httpsAgent = new https.Agent({
|
|||
// --- Constants ---
|
||||
const CREDENTIALS_DIR = '.antigravity';
|
||||
const CREDENTIALS_FILE = 'oauth_creds.json';
|
||||
const DEFAULT_ANTIGRAVITY_BASE_URL_DAILY = 'https://daily-cloudcode-pa.sandbox.googleapis.com';
|
||||
const DEFAULT_ANTIGRAVITY_BASE_URL_AUTOPUSH = 'https://autopush-cloudcode-pa.sandbox.googleapis.com';
|
||||
|
||||
// Base URLs - 按照 Go 代码的降级顺序
|
||||
const ANTIGRAVITY_BASE_URL_DAILY = 'https://daily-cloudcode-pa.googleapis.com';
|
||||
const ANTIGRAVITY_SANDBOX_BASE_URL_DAILY = 'https://daily-cloudcode-pa.sandbox.googleapis.com';
|
||||
const ANTIGRAVITY_BASE_URL_PROD = 'https://autopush-cloudcode-pa.sandbox.googleapis.com';
|
||||
|
||||
const ANTIGRAVITY_API_VERSION = 'v1internal';
|
||||
const OAUTH_CLIENT_ID = '1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com';
|
||||
const OAUTH_CLIENT_SECRET = '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';
|
||||
const DEFAULT_USER_AGENT = 'antigravity/1.11.5 windows/amd64';
|
||||
const DEFAULT_USER_AGENT = 'antigravity/1.104.0 darwin/arm64';
|
||||
const REFRESH_SKEW = 3000; // 3000秒(50分钟)提前刷新Token
|
||||
|
||||
// Thinking 配置相关常量
|
||||
const DEFAULT_THINKING_MIN = 1024;
|
||||
const DEFAULT_THINKING_MAX = 100000;
|
||||
|
||||
// 获取 Antigravity 模型列表
|
||||
const ANTIGRAVITY_MODELS = getProviderModels('gemini-antigravity');
|
||||
|
||||
// 模型别名映射
|
||||
// 模型别名映射 - 别名 -> 真实模型名
|
||||
const MODEL_ALIAS_MAP = {
|
||||
'gemini-2.5-computer-use-preview-10-2025': 'rev19-uic3-1p',
|
||||
'gemini-3-pro-image-preview': 'gemini-3-pro-image',
|
||||
'gemini-3-pro-preview': 'gemini-3-pro-high',
|
||||
'gemini-3-flash-preview': 'gemini-3-flash',
|
||||
'gemini-2.5-flash': 'gemini-2.5-flash',
|
||||
'gemini-2.5-flash-preview': 'gemini-2.5-flash',
|
||||
'gemini-claude-sonnet-4-5': 'claude-sonnet-4-5',
|
||||
'gemini-claude-sonnet-4-5-thinking': 'claude-sonnet-4-5-thinking',
|
||||
'gemini-claude-opus-4-5-thinking': 'claude-opus-4-5-thinking'
|
||||
};
|
||||
|
||||
// 真实模型名 -> 别名
|
||||
const MODEL_NAME_MAP = {
|
||||
'rev19-uic3-1p': 'gemini-2.5-computer-use-preview-10-2025',
|
||||
'gemini-3-pro-image': 'gemini-3-pro-image-preview',
|
||||
'gemini-3-pro-high': 'gemini-3-pro-preview',
|
||||
'gemini-3-flash': 'gemini-3-flash-preview',
|
||||
'gemini-2.5-flash': 'gemini-2.5-flash',
|
||||
'gemini-2.5-flash': 'gemini-2.5-flash-preview',
|
||||
'claude-sonnet-4-5': 'gemini-claude-sonnet-4-5',
|
||||
'claude-sonnet-4-5-thinking': 'gemini-claude-sonnet-4-5-thinking',
|
||||
'claude-opus-4-5-thinking': 'gemini-claude-opus-4-5-thinking'
|
||||
|
|
@ -65,6 +77,8 @@ const MODEL_NAME_MAP = {
|
|||
|
||||
/**
|
||||
* 将别名转换为真实模型名
|
||||
* @param {string} modelName - 模型别名
|
||||
* @returns {string} 真实模型名
|
||||
*/
|
||||
function alias2ModelName(modelName) {
|
||||
return MODEL_ALIAS_MAP[modelName];
|
||||
|
|
@ -72,13 +86,48 @@ function alias2ModelName(modelName) {
|
|||
|
||||
/**
|
||||
* 将真实模型名转换为别名
|
||||
* @param {string} modelName - 真实模型名
|
||||
* @returns {string|null} 模型别名,如果不支持则返回 null
|
||||
*/
|
||||
function modelName2Alias(modelName) {
|
||||
return MODEL_NAME_MAP[modelName];
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查模型是否为 Claude 模型
|
||||
* @param {string} modelName - 模型名称
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isClaude(modelName) {
|
||||
return modelName && modelName.toLowerCase().includes('claude');
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否为图像模型
|
||||
* @param {string} modelName - 模型名称
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isImageModel(modelName) {
|
||||
return modelName && modelName.toLowerCase().includes('image');
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查模型是否支持 Thinking
|
||||
* @param {string} modelName - 模型名称
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function modelSupportsThinking(modelName) {
|
||||
if (!modelName) return false;
|
||||
const name = modelName.toLowerCase();
|
||||
// 支持 thinking 的模型:gemini-3-*, gemini-2.5-*, claude-*-thinking
|
||||
return name.startsWith('gemini-3-') ||
|
||||
name.startsWith('gemini-2.5-') ||
|
||||
name.includes('-thinking');
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成随机请求ID
|
||||
* @returns {string}
|
||||
*/
|
||||
function generateRequestID() {
|
||||
return 'agent-' + uuidv4();
|
||||
|
|
@ -86,14 +135,44 @@ function generateRequestID() {
|
|||
|
||||
/**
|
||||
* 生成随机会话ID
|
||||
* @returns {string}
|
||||
*/
|
||||
function generateSessionID() {
|
||||
const n = Math.floor(Math.random() * 9000000000000000000);
|
||||
const n = Math.floor(Math.random() * 9000);
|
||||
return '-' + n.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 基于请求内容生成稳定的会话ID
|
||||
* 使用第一个用户消息的 SHA256 哈希值
|
||||
* @param {Object} payload - 请求体
|
||||
* @returns {string} 稳定的会话ID
|
||||
*/
|
||||
function generateStableSessionID(payload) {
|
||||
try {
|
||||
const contents = payload?.request?.contents;
|
||||
if (Array.isArray(contents)) {
|
||||
for (const content of contents) {
|
||||
if (content.role === 'user') {
|
||||
const text = content.parts?.[0]?.text;
|
||||
if (text) {
|
||||
const hash = crypto.createHash('sha256').update(text).digest();
|
||||
// 取前8字节转换为 BigInt,然后取正数
|
||||
const n = hash.readBigUInt64BE(0) & BigInt('0x7FFFFFFFFFFFFFFF');
|
||||
return '-' + n.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// 如果解析失败,回退到随机会话ID
|
||||
}
|
||||
return generateSessionID();
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成随机项目ID
|
||||
* @returns {string}
|
||||
*/
|
||||
function generateProjectID() {
|
||||
const adjectives = ['useful', 'bright', 'swift', 'calm', 'bold'];
|
||||
|
|
@ -104,12 +183,83 @@ function generateProjectID() {
|
|||
return `${adj}-${noun}-${randomPart}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 规范化 Thinking Budget
|
||||
* @param {string} modelName - 模型名称
|
||||
* @param {number} budget - 原始 budget 值
|
||||
* @returns {number} 规范化后的 budget
|
||||
*/
|
||||
function normalizeThinkingBudget(modelName, budget) {
|
||||
// -1 表示动态/无限制
|
||||
if (budget === -1) return -1;
|
||||
|
||||
// 获取模型的 thinking 限制
|
||||
const min = DEFAULT_THINKING_MIN;
|
||||
const max = DEFAULT_THINKING_MAX;
|
||||
|
||||
// 限制在有效范围内
|
||||
if (budget < min) return min;
|
||||
if (budget > max) return max;
|
||||
return budget;
|
||||
}
|
||||
|
||||
/**
|
||||
* 规范化 Antigravity Thinking 配置
|
||||
* 对于 Claude 模型,确保 thinking budget < max_tokens
|
||||
* @param {string} modelName - 模型名称
|
||||
* @param {Object} payload - 请求体
|
||||
* @param {boolean} isClaudeModel - 是否为 Claude 模型
|
||||
* @returns {Object} 处理后的请求体
|
||||
*/
|
||||
function normalizeAntigravityThinking(modelName, payload, isClaudeModel) {
|
||||
// 如果模型不支持 thinking,移除 thinking 配置
|
||||
if (!modelSupportsThinking(modelName)) {
|
||||
if (payload?.request?.generationConfig?.thinkingConfig) {
|
||||
delete payload.request.generationConfig.thinkingConfig;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
const thinkingConfig = payload?.request?.generationConfig?.thinkingConfig;
|
||||
if (!thinkingConfig) return payload;
|
||||
|
||||
const budget = thinkingConfig.thinkingBudget;
|
||||
if (budget === undefined) return payload;
|
||||
|
||||
let normalizedBudget = normalizeThinkingBudget(modelName, budget);
|
||||
|
||||
// 对于 Claude 模型,确保 thinking budget < max_tokens
|
||||
if (isClaudeModel) {
|
||||
const maxTokens = payload?.request?.generationConfig?.maxOutputTokens;
|
||||
if (maxTokens && maxTokens > 0 && normalizedBudget >= maxTokens) {
|
||||
normalizedBudget = maxTokens - 1;
|
||||
}
|
||||
|
||||
// 检查最小 budget
|
||||
const minBudget = DEFAULT_THINKING_MIN;
|
||||
if (normalizedBudget >= 0 && normalizedBudget < minBudget) {
|
||||
// Budget 低于最小值,移除 thinking 配置
|
||||
delete payload.request.generationConfig.thinkingConfig;
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
|
||||
payload.request.generationConfig.thinkingConfig.thinkingBudget = normalizedBudget;
|
||||
return payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 Gemini 格式请求转换为 Antigravity 格式
|
||||
* @param {string} modelName - 模型名称
|
||||
* @param {Object} payload - 请求体
|
||||
* @param {string} projectId - 项目ID
|
||||
* @returns {Object} 转换后的请求体
|
||||
*/
|
||||
function geminiToAntigravity(modelName, payload, projectId) {
|
||||
// 深拷贝请求体,避免修改原始对象
|
||||
let template = JSON.parse(JSON.stringify(payload));
|
||||
|
||||
const isClaudeModel = isClaude(modelName);
|
||||
|
||||
// 设置基本字段
|
||||
template.model = modelName;
|
||||
|
|
@ -122,8 +272,8 @@ function geminiToAntigravity(modelName, payload, projectId) {
|
|||
template.request = {};
|
||||
}
|
||||
|
||||
// 设置会话ID
|
||||
template.request.sessionId = generateSessionID();
|
||||
// 设置会话ID - 使用稳定的会话ID
|
||||
template.request.sessionId = generateStableSessionID(template);
|
||||
|
||||
// 删除安全设置
|
||||
if (template.request.safetySettings) {
|
||||
|
|
@ -131,19 +281,24 @@ function geminiToAntigravity(modelName, payload, projectId) {
|
|||
}
|
||||
|
||||
// 设置工具配置
|
||||
if (template.request.toolConfig) {
|
||||
if (!template.request.toolConfig.functionCallingConfig) {
|
||||
template.request.toolConfig.functionCallingConfig = {};
|
||||
}
|
||||
template.request.toolConfig.functionCallingConfig.mode = 'VALIDATED';
|
||||
if (!template.request.toolConfig) {
|
||||
template.request.toolConfig = {};
|
||||
}
|
||||
if (!template.request.toolConfig.functionCallingConfig) {
|
||||
template.request.toolConfig.functionCallingConfig = {};
|
||||
}
|
||||
template.request.toolConfig.functionCallingConfig.mode = 'VALIDATED';
|
||||
|
||||
// 删除 maxOutputTokens
|
||||
if (template.request.generationConfig && template.request.generationConfig.maxOutputTokens) {
|
||||
delete template.request.generationConfig.maxOutputTokens;
|
||||
// 对于非 Claude 模型,删除 maxOutputTokens
|
||||
// Claude 模型需要保留 maxOutputTokens
|
||||
if (!isClaudeModel) {
|
||||
if (template.request.generationConfig && template.request.generationConfig.maxOutputTokens) {
|
||||
delete template.request.generationConfig.maxOutputTokens;
|
||||
}
|
||||
}
|
||||
|
||||
// 处理 Thinking 配置
|
||||
// 对于非 gemini-3-* 模型,将 thinkingLevel 转换为 thinkingBudget
|
||||
if (!modelName.startsWith('gemini-3-')) {
|
||||
if (template.request.generationConfig &&
|
||||
template.request.generationConfig.thinkingConfig &&
|
||||
|
|
@ -153,28 +308,281 @@ function geminiToAntigravity(modelName, payload, projectId) {
|
|||
}
|
||||
}
|
||||
|
||||
// 处理 Claude 模型的工具声明 (包括 sonnet 和 opus)
|
||||
if (modelName.startsWith('claude-sonnet-') || modelName.startsWith('claude-opus-')) {
|
||||
if (template.request.tools && Array.isArray(template.request.tools)) {
|
||||
template.request.tools.forEach(tool => {
|
||||
if (tool.functionDeclarations && Array.isArray(tool.functionDeclarations)) {
|
||||
tool.functionDeclarations.forEach(funcDecl => {
|
||||
if (funcDecl.parametersJsonSchema) {
|
||||
funcDecl.parameters = funcDecl.parametersJsonSchema;
|
||||
delete funcDecl.parameters.$schema;
|
||||
delete funcDecl.parametersJsonSchema;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
// 清理所有工具声明中的 JSON Schema 属性(移除 Google API 不支持的属性如 exclusiveMinimum 等)
|
||||
if (template.request.tools && Array.isArray(template.request.tools)) {
|
||||
template.request.tools.forEach((tool) => {
|
||||
if (tool.functionDeclarations && Array.isArray(tool.functionDeclarations)) {
|
||||
tool.functionDeclarations.forEach((funcDecl) => {
|
||||
// 对于 Claude 模型,处理 parametersJsonSchema
|
||||
if (isClaudeModel && funcDecl.parametersJsonSchema) {
|
||||
funcDecl.parameters = cleanJsonSchemaProperties(funcDecl.parametersJsonSchema);
|
||||
delete funcDecl.parameters.$schema;
|
||||
delete funcDecl.parametersJsonSchema;
|
||||
} else if (funcDecl.parameters) {
|
||||
funcDecl.parameters = cleanJsonSchemaProperties(funcDecl.parameters);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 如果是图像模型,增加参数 "generationConfig.imageConfig.imageSize": "4K"
|
||||
if (isImageModel(modelName)) {
|
||||
if (!template.request.generationConfig) {
|
||||
template.request.generationConfig = {};
|
||||
}
|
||||
|
||||
if (!template.request.generationConfig.imageConfig) {
|
||||
template.request.generationConfig.imageConfig = {};
|
||||
}
|
||||
template.request.generationConfig.imageConfig.imageSize = '4K';
|
||||
if (!template.request.generationConfig.thinkingConfig) {
|
||||
template.request.generationConfig.thinkingConfig = {};
|
||||
}
|
||||
template.request.generationConfig.thinkingConfig.includeThoughts = false;
|
||||
}
|
||||
|
||||
// 规范化 Thinking 配置
|
||||
template = normalizeAntigravityThinking(modelName, template, isClaudeModel);
|
||||
|
||||
return template;
|
||||
}
|
||||
|
||||
/**
|
||||
* 过滤 SSE 中的 usageMetadata(仅在最终块中保留)
|
||||
* @param {string} line - SSE 行数据
|
||||
* @returns {string} 过滤后的行数据
|
||||
*/
|
||||
function filterSSEUsageMetadata(line) {
|
||||
if (!line || typeof line !== 'string') return line;
|
||||
|
||||
// 检查是否是 data: 开头的 SSE 数据
|
||||
if (!line.startsWith('data: ')) return line;
|
||||
|
||||
try {
|
||||
const jsonStr = line.slice(6); // 移除 'data: ' 前缀
|
||||
const data = JSON.parse(jsonStr);
|
||||
|
||||
// 检查是否有 finishReason,如果没有则移除 usageMetadata
|
||||
const hasFinishReason = data?.response?.candidates?.[0]?.finishReason ||
|
||||
data?.candidates?.[0]?.finishReason;
|
||||
|
||||
if (!hasFinishReason) {
|
||||
// 移除 usageMetadata
|
||||
if (data.response) {
|
||||
delete data.response.usageMetadata;
|
||||
}
|
||||
if (data.usageMetadata) {
|
||||
delete data.usageMetadata;
|
||||
}
|
||||
return 'data: ' + JSON.stringify(data);
|
||||
}
|
||||
} catch (e) {
|
||||
// 解析失败,返回原始数据
|
||||
}
|
||||
|
||||
return line;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将流式响应转换为非流式响应
|
||||
* 用于 Claude 模型的非流式请求(实际上是流式请求然后合并)
|
||||
* @param {Buffer|string} stream - 流式响应数据
|
||||
* @returns {Object} 合并后的非流式响应
|
||||
*/
|
||||
function convertStreamToNonStream(stream) {
|
||||
const lines = stream.toString().split('\n');
|
||||
|
||||
let responseTemplate = '';
|
||||
let traceId = '';
|
||||
let finishReason = '';
|
||||
let modelVersion = '';
|
||||
let responseId = '';
|
||||
let role = '';
|
||||
let usageRaw = null;
|
||||
const parts = [];
|
||||
|
||||
// 用于合并连续的 text 和 thought 部分
|
||||
let pendingKind = '';
|
||||
let pendingText = '';
|
||||
let pendingThoughtSig = '';
|
||||
|
||||
const flushPending = () => {
|
||||
if (!pendingKind) return;
|
||||
|
||||
const text = pendingText;
|
||||
if (pendingKind === 'text') {
|
||||
if (text.trim()) {
|
||||
parts.push({ text: text });
|
||||
}
|
||||
} else if (pendingKind === 'thought') {
|
||||
if (text.trim() || pendingThoughtSig) {
|
||||
const part = { thought: true, text: text };
|
||||
if (pendingThoughtSig) {
|
||||
part.thoughtSignature = pendingThoughtSig;
|
||||
}
|
||||
parts.push(part);
|
||||
}
|
||||
}
|
||||
|
||||
pendingKind = '';
|
||||
pendingText = '';
|
||||
pendingThoughtSig = '';
|
||||
};
|
||||
|
||||
const normalizePart = (part) => {
|
||||
const m = { ...part };
|
||||
// 处理 thoughtSignature / thought_signature
|
||||
const sig = part.thoughtSignature || part.thought_signature;
|
||||
if (sig) {
|
||||
m.thoughtSignature = sig;
|
||||
delete m.thought_signature;
|
||||
}
|
||||
// 处理 inline_data -> inlineData
|
||||
if (m.inline_data) {
|
||||
m.inlineData = m.inline_data;
|
||||
delete m.inline_data;
|
||||
}
|
||||
return m;
|
||||
};
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(trimmed);
|
||||
} catch (e) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let responseNode = data.response;
|
||||
if (!responseNode) {
|
||||
if (data.candidates) {
|
||||
responseNode = data;
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
responseTemplate = JSON.stringify(responseNode);
|
||||
|
||||
if (data.traceId) {
|
||||
traceId = data.traceId;
|
||||
}
|
||||
|
||||
if (responseNode.candidates?.[0]?.content?.role) {
|
||||
role = responseNode.candidates[0].content.role;
|
||||
}
|
||||
|
||||
if (responseNode.candidates?.[0]?.finishReason) {
|
||||
finishReason = responseNode.candidates[0].finishReason;
|
||||
}
|
||||
|
||||
if (responseNode.modelVersion) {
|
||||
modelVersion = responseNode.modelVersion;
|
||||
}
|
||||
|
||||
if (responseNode.responseId) {
|
||||
responseId = responseNode.responseId;
|
||||
}
|
||||
|
||||
if (responseNode.usageMetadata) {
|
||||
usageRaw = responseNode.usageMetadata;
|
||||
} else if (data.usageMetadata) {
|
||||
usageRaw = data.usageMetadata;
|
||||
}
|
||||
|
||||
const partsArray = responseNode.candidates?.[0]?.content?.parts;
|
||||
if (Array.isArray(partsArray)) {
|
||||
for (const part of partsArray) {
|
||||
const hasFunctionCall = part.functionCall !== undefined;
|
||||
const hasInlineData = part.inlineData !== undefined || part.inline_data !== undefined;
|
||||
const sig = part.thoughtSignature || part.thought_signature || '';
|
||||
const text = part.text || '';
|
||||
const thought = part.thought || false;
|
||||
|
||||
if (hasFunctionCall || hasInlineData) {
|
||||
flushPending();
|
||||
parts.push(normalizePart(part));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (thought || part.text !== undefined) {
|
||||
const kind = thought ? 'thought' : 'text';
|
||||
if (pendingKind && pendingKind !== kind) {
|
||||
flushPending();
|
||||
}
|
||||
pendingKind = kind;
|
||||
pendingText += text;
|
||||
if (kind === 'thought' && sig) {
|
||||
pendingThoughtSig = sig;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
flushPending();
|
||||
parts.push(normalizePart(part));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flushPending();
|
||||
|
||||
// 构建最终响应
|
||||
if (!responseTemplate) {
|
||||
responseTemplate = '{"candidates":[{"content":{"role":"model","parts":[]}}]}';
|
||||
}
|
||||
|
||||
let result = JSON.parse(responseTemplate);
|
||||
|
||||
// 设置 parts
|
||||
if (!result.candidates) {
|
||||
result.candidates = [{ content: { role: 'model', parts: [] } }];
|
||||
}
|
||||
if (!result.candidates[0]) {
|
||||
result.candidates[0] = { content: { role: 'model', parts: [] } };
|
||||
}
|
||||
if (!result.candidates[0].content) {
|
||||
result.candidates[0].content = { role: 'model', parts: [] };
|
||||
}
|
||||
result.candidates[0].content.parts = parts;
|
||||
|
||||
if (role) {
|
||||
result.candidates[0].content.role = role;
|
||||
}
|
||||
if (finishReason) {
|
||||
result.candidates[0].finishReason = finishReason;
|
||||
}
|
||||
if (modelVersion) {
|
||||
result.modelVersion = modelVersion;
|
||||
}
|
||||
if (responseId) {
|
||||
result.responseId = responseId;
|
||||
}
|
||||
if (usageRaw) {
|
||||
result.usageMetadata = usageRaw;
|
||||
} else if (!result.usageMetadata) {
|
||||
result.usageMetadata = {
|
||||
promptTokenCount: 0,
|
||||
candidatesTokenCount: 0,
|
||||
totalTokenCount: 0
|
||||
};
|
||||
}
|
||||
|
||||
// 包装为最终格式
|
||||
const output = {
|
||||
response: result,
|
||||
traceId: traceId || ''
|
||||
};
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 Antigravity 响应转换为 Gemini 格式
|
||||
* @param {Object} antigravityResponse - Antigravity 响应
|
||||
* @returns {Object|null} Gemini 格式响应
|
||||
*/
|
||||
function toGeminiApiResponse(antigravityResponse) {
|
||||
if (!antigravityResponse) return null;
|
||||
|
|
@ -200,6 +608,8 @@ function toGeminiApiResponse(antigravityResponse) {
|
|||
|
||||
/**
|
||||
* 确保请求体中的内容部分都有角色属性
|
||||
* @param {Object} requestBody - 请求体
|
||||
* @returns {Object} 处理后的请求体
|
||||
*/
|
||||
function ensureRolesInContents(requestBody) {
|
||||
delete requestBody.model;
|
||||
|
|
@ -254,21 +664,32 @@ export class AntigravityApiService {
|
|||
this.userAgent = DEFAULT_USER_AGENT; // 支持通用 USER_AGENT 配置
|
||||
this.projectId = config.PROJECT_ID;
|
||||
|
||||
// Initialize instance-specific endpoints
|
||||
this.baseUrlDaily = config.ANTIGRAVITY_BASE_URL_DAILY || DEFAULT_ANTIGRAVITY_BASE_URL_DAILY;
|
||||
this.baseUrlAutopush = config.ANTIGRAVITY_BASE_URL_AUTOPUSH || DEFAULT_ANTIGRAVITY_BASE_URL_AUTOPUSH;
|
||||
|
||||
// 多环境降级顺序
|
||||
this.baseURLs = [
|
||||
this.baseUrlDaily,
|
||||
this.baseUrlAutopush
|
||||
// ANTIGRAVITY_BASE_URL_PROD // 生产环境已注释
|
||||
];
|
||||
// 多环境降级顺序 - 按照 Go 代码的顺序
|
||||
this.baseURLs = this.getBaseURLFallbackOrder(config);
|
||||
|
||||
// 保存代理配置供后续使用
|
||||
this.proxyConfig = getProxyConfigForProvider(config, 'gemini-antigravity');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Base URL 降级顺序
|
||||
* @param {Object} config - 配置对象
|
||||
* @returns {string[]} Base URL 列表
|
||||
*/
|
||||
getBaseURLFallbackOrder(config) {
|
||||
// 如果配置了自定义 base_url,只使用该 URL
|
||||
if (config.ANTIGRAVITY_BASE_URL) {
|
||||
return [config.ANTIGRAVITY_BASE_URL.replace(/\/$/, '')];
|
||||
}
|
||||
|
||||
// 默认降级顺序:daily -> sandbox -> prod
|
||||
return [
|
||||
ANTIGRAVITY_BASE_URL_DAILY,
|
||||
ANTIGRAVITY_SANDBOX_BASE_URL_DAILY,
|
||||
ANTIGRAVITY_BASE_URL_PROD
|
||||
];
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
if (this.isInitialized) return;
|
||||
console.log('[Antigravity] Initializing Antigravity API Service...');
|
||||
|
|
@ -478,12 +899,13 @@ export class AntigravityApiService {
|
|||
};
|
||||
|
||||
const res = await this.authClient.request(requestOptions);
|
||||
console.log(`[Antigravity] Raw response from ${baseURL}:`, Object.keys(res.data.models));
|
||||
// console.log(`[Antigravity] Raw response from ${baseURL}:`, Object.keys(res.data.models));
|
||||
if (res.data && res.data.models) {
|
||||
const models = Object.keys(res.data.models);
|
||||
this.availableModels = models
|
||||
.map(modelName2Alias)
|
||||
.filter(alias => alias !== undefined && alias !== '' && alias !== null);
|
||||
.filter(alias => alias !== undefined && alias !== '' && alias !== null)
|
||||
.filter(alias => ANTIGRAVITY_MODELS.includes(alias));
|
||||
|
||||
console.log(`[Antigravity] Available models: [${this.availableModels.join(', ')}]`);
|
||||
return;
|
||||
|
|
@ -680,8 +1102,10 @@ export class AntigravityApiService {
|
|||
});
|
||||
|
||||
let buffer = [];
|
||||
for await (const line of rl) {
|
||||
for await (let line of rl) {
|
||||
if (line.startsWith('data: ')) {
|
||||
// 过滤 usageMetadata(仅在最终块中保留)
|
||||
line = filterSSEUsageMetadata(line);
|
||||
buffer.push(line.slice(6));
|
||||
} else if (line === '' && buffer.length > 0) {
|
||||
try {
|
||||
|
|
@ -714,6 +1138,7 @@ export class AntigravityApiService {
|
|||
// 深拷贝请求体
|
||||
const processedRequestBody = ensureRolesInContents(JSON.parse(JSON.stringify(requestBody)));
|
||||
const actualModelName = alias2ModelName(selectedModel);
|
||||
const isClaudeModel = isClaude(actualModelName);
|
||||
|
||||
// 将处理后的请求体转换为 Antigravity 格式
|
||||
const payload = geminiToAntigravity(actualModelName, { request: processedRequestBody }, this.projectId);
|
||||
|
|
@ -721,10 +1146,42 @@ export class AntigravityApiService {
|
|||
// 设置模型名称为实际模型名
|
||||
payload.model = actualModelName;
|
||||
|
||||
// 对于 Claude 模型,使用流式请求然后转换为非流式响应
|
||||
if (isClaudeModel) {
|
||||
return await this.executeClaudeNonStream(payload);
|
||||
}
|
||||
|
||||
const response = await this.callApi('generateContent', payload);
|
||||
return toGeminiApiResponse(response.response);
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行 Claude 非流式请求
|
||||
* Claude 模型实际上使用流式请求,然后将结果合并为非流式响应
|
||||
* @param {Object} payload - 请求体
|
||||
* @returns {Object} 非流式响应
|
||||
*/
|
||||
async executeClaudeNonStream(payload) {
|
||||
const chunks = [];
|
||||
|
||||
try {
|
||||
const stream = this.streamApi('streamGenerateContent', payload);
|
||||
for await (const chunk of stream) {
|
||||
if (chunk) {
|
||||
chunks.push(JSON.stringify(chunk));
|
||||
}
|
||||
}
|
||||
|
||||
// 将流式响应转换为非流式响应
|
||||
const streamData = chunks.join('\n');
|
||||
const nonStreamResponse = convertStreamToNonStream(streamData);
|
||||
return toGeminiApiResponse(nonStreamResponse.response);
|
||||
} catch (error) {
|
||||
console.error('[Antigravity] Claude non-stream execution error:', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async * generateContentStream(model, requestBody) {
|
||||
console.log(`[Antigravity Auth Token] Time until expiry: ${formatExpiryTime(this.authClient.credentials.expiry_date)}`);
|
||||
|
||||
|
|
@ -819,7 +1276,7 @@ export class AntigravityApiService {
|
|||
// 遍历模型数据,提取配额信息
|
||||
for (const [modelId, modelData] of Object.entries(modelsData)) {
|
||||
const aliasName = modelName2Alias(modelId);
|
||||
if (aliasName == null ||aliasName === '') continue; // 跳过不支持的模型
|
||||
if (aliasName == null || aliasName === '') continue; // 跳过不支持的模型
|
||||
|
||||
const modelInfo = {
|
||||
remaining: 0,
|
||||
|
|
@ -843,7 +1300,6 @@ export class AntigravityApiService {
|
|||
sortedModels[key] = result.models[key];
|
||||
});
|
||||
result.models = sortedModels;
|
||||
// console.log(`[Antigravity] Sorted Models:`, sortedModels);
|
||||
console.log(`[Antigravity] Successfully fetched quotas for ${Object.keys(result.models).length} models`);
|
||||
break; // 成功获取后退出循环
|
||||
}
|
||||
|
|
|
|||
|
|
@ -639,7 +639,7 @@ export class GeminiApiService {
|
|||
};
|
||||
|
||||
const res = await this.authClient.request(requestOptions);
|
||||
console.log(`[Gemini] retrieveUserQuota success`);
|
||||
// console.log(`[Gemini] retrieveUserQuota success`, JSON.stringify(res.data));
|
||||
if (res.data && res.data.buckets) {
|
||||
const buckets = res.data.buckets;
|
||||
|
||||
|
|
|
|||
|
|
@ -377,12 +377,15 @@ export async function handleOllamaEndpointsAfterAuth(method, path, req, res, api
|
|||
|
||||
/**
|
||||
* 处理 Ollama /api/tags 端点(列出模型)
|
||||
* Note: apiService can be null when called before provider selection (e.g., from /ollama/api/tags)
|
||||
* In this case, we fetch models from all healthy providers in the pool
|
||||
*/
|
||||
export async function handleOllamaTags(req, res, apiService, currentConfig, providerPoolManager) {
|
||||
try {
|
||||
console.log('[Ollama] Handling /api/tags request');
|
||||
|
||||
const ollamaConverter = ConverterFactory.getConverter(MODEL_PROTOCOL_PREFIX.OLLAMA);
|
||||
const { getServiceAdapter } = await import('./adapter.js');
|
||||
|
||||
// Helper to fetch and convert models from a provider
|
||||
const fetchProviderModels = async (providerType, service) => {
|
||||
|
|
@ -402,31 +405,53 @@ export async function handleOllamaTags(req, res, apiService, currentConfig, prov
|
|||
};
|
||||
|
||||
// Collect fetch promises
|
||||
const fetchPromises = [fetchProviderModels(currentConfig.MODEL_PROVIDER, apiService)];
|
||||
const fetchPromises = [];
|
||||
const processedProviderTypes = new Set();
|
||||
|
||||
// Add provider pool fetches
|
||||
// If apiService is provided, use it for the default provider
|
||||
if (apiService) {
|
||||
fetchPromises.push(fetchProviderModels(currentConfig.MODEL_PROVIDER, apiService));
|
||||
processedProviderTypes.add(currentConfig.MODEL_PROVIDER);
|
||||
}
|
||||
|
||||
// Add provider pool fetches (for all healthy providers)
|
||||
if (providerPoolManager?.providerPools) {
|
||||
const { getServiceAdapter } = await import('./adapter.js');
|
||||
|
||||
for (const [providerType, providers] of Object.entries(providerPoolManager.providerPools)) {
|
||||
if (providerType === currentConfig.MODEL_PROVIDER) continue;
|
||||
// Skip if already processed
|
||||
if (processedProviderTypes.has(providerType)) continue;
|
||||
|
||||
const healthyProvider = providers.find(p => p.isHealthy);
|
||||
const healthyProvider = providers.find(p => p.isHealthy && !p.isDisabled);
|
||||
if (healthyProvider) {
|
||||
const tempConfig = { ...currentConfig, ...healthyProvider, MODEL_PROVIDER: providerType };
|
||||
const service = getServiceAdapter(tempConfig);
|
||||
fetchPromises.push(fetchProviderModels(providerType, service));
|
||||
processedProviderTypes.add(providerType);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no providers available, return empty list
|
||||
if (fetchPromises.length === 0) {
|
||||
console.warn('[Ollama] No healthy providers available to fetch models');
|
||||
const response = { models: [] };
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/json',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Server': `ollama/${OLLAMA_VERSION}`
|
||||
});
|
||||
res.end(JSON.stringify(response));
|
||||
return;
|
||||
}
|
||||
|
||||
// Execute all fetches in parallel
|
||||
const results = await Promise.all(fetchPromises);
|
||||
const allModels = results.flat();
|
||||
|
||||
console.log(`[Ollama] Fetched ${allModels.length} models from ${processedProviderTypes.size} provider(s)`);
|
||||
|
||||
const response = { models: allModels };
|
||||
|
||||
res.writeHead(200, {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/json',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Server': `ollama/${OLLAMA_VERSION}`
|
||||
|
|
@ -434,7 +459,7 @@ export async function handleOllamaTags(req, res, apiService, currentConfig, prov
|
|||
res.end(JSON.stringify(response));
|
||||
} catch (error) {
|
||||
console.error('[Ollama Tags Error]', error);
|
||||
handleError(res, error);
|
||||
handleError(res, error, MODEL_PROTOCOL_PREFIX.OLLAMA);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -459,7 +484,7 @@ export async function handleOllamaShow(req, res) {
|
|||
res.end(JSON.stringify(showResponse));
|
||||
} catch (error) {
|
||||
console.error('[Ollama Show Error]', error);
|
||||
handleError(res, error);
|
||||
handleError(res, error, MODEL_PROTOCOL_PREFIX.OLLAMA);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -478,18 +503,20 @@ export function handleOllamaVersion(res) {
|
|||
res.end(JSON.stringify(response));
|
||||
} catch (error) {
|
||||
console.error('[Ollama Version Error]', error);
|
||||
handleError(res, error);
|
||||
handleError(res, error, MODEL_PROTOCOL_PREFIX.OLLAMA);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 Ollama /api/chat 端点
|
||||
* Note: apiService can be null when called before provider selection
|
||||
*/
|
||||
export async function handleOllamaChat(req, res, apiService, currentConfig, providerPoolManager) {
|
||||
try {
|
||||
console.log('[Ollama] Handling /api/chat request');
|
||||
|
||||
const ollamaRequest = await getRequestBody(req);
|
||||
const { getServiceAdapter } = await import('./adapter.js');
|
||||
|
||||
// Determine provider based on model name
|
||||
const rawModelName = ollamaRequest.model;
|
||||
|
|
@ -499,26 +526,35 @@ export async function handleOllamaChat(req, res, apiService, currentConfig, prov
|
|||
|
||||
console.log(`[Ollama] Model: ${modelName}, Detected provider: ${detectedProvider}`);
|
||||
|
||||
// If provider is different, get the appropriate service
|
||||
// Get the appropriate service based on detected provider
|
||||
let actualApiService = apiService;
|
||||
let actualConfig = currentConfig;
|
||||
|
||||
if (detectedProvider !== currentConfig.MODEL_PROVIDER && providerPoolManager) {
|
||||
// Select provider from pool
|
||||
const providerConfig = providerPoolManager.selectProvider(detectedProvider, modelName, { skipUsageCount: true });
|
||||
if (providerConfig) {
|
||||
actualConfig = {
|
||||
...currentConfig,
|
||||
...providerConfig,
|
||||
MODEL_PROVIDER: detectedProvider
|
||||
};
|
||||
|
||||
// Get service adapter for the detected provider
|
||||
const { getServiceAdapter } = await import('./adapter.js');
|
||||
// If apiService is null or provider is different, get the appropriate service from pool
|
||||
if (!apiService || detectedProvider !== currentConfig.MODEL_PROVIDER) {
|
||||
if (providerPoolManager) {
|
||||
// Select provider from pool
|
||||
const providerConfig = providerPoolManager.selectProvider(detectedProvider, modelName, { skipUsageCount: true });
|
||||
if (providerConfig) {
|
||||
actualConfig = {
|
||||
...currentConfig,
|
||||
...providerConfig,
|
||||
MODEL_PROVIDER: detectedProvider
|
||||
};
|
||||
actualApiService = getServiceAdapter(actualConfig);
|
||||
console.log(`[Ollama] Using provider from pool: ${detectedProvider}`);
|
||||
} else {
|
||||
// No healthy provider in pool, try to create service directly
|
||||
console.warn(`[Ollama] No healthy provider found for ${detectedProvider} in pool`);
|
||||
if (!apiService) {
|
||||
throw new Error(`No healthy provider available for ${detectedProvider}`);
|
||||
}
|
||||
}
|
||||
} else if (!apiService) {
|
||||
// No pool manager and no apiService, try to create service directly
|
||||
actualConfig = { ...currentConfig, MODEL_PROVIDER: detectedProvider };
|
||||
actualApiService = getServiceAdapter(actualConfig);
|
||||
console.log(`[Ollama] Switched to provider: ${detectedProvider}`);
|
||||
} else {
|
||||
console.warn(`[Ollama] No healthy provider found for ${detectedProvider}, using default`);
|
||||
console.log(`[Ollama] Created service adapter for: ${detectedProvider}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -574,18 +610,20 @@ export async function handleOllamaChat(req, res, apiService, currentConfig, prov
|
|||
}
|
||||
} catch (error) {
|
||||
console.error('[Ollama Chat Error]', error);
|
||||
handleError(res, error);
|
||||
handleError(res, error, MODEL_PROTOCOL_PREFIX.OLLAMA);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 Ollama /api/generate 端点
|
||||
* Note: apiService can be null when called before provider selection
|
||||
*/
|
||||
export async function handleOllamaGenerate(req, res, apiService, currentConfig, providerPoolManager) {
|
||||
try {
|
||||
console.log('[Ollama] Handling /api/generate request');
|
||||
|
||||
const ollamaRequest = await getRequestBody(req);
|
||||
const { getServiceAdapter } = await import('./adapter.js');
|
||||
|
||||
// Determine provider based on model name
|
||||
const rawModelName = ollamaRequest.model;
|
||||
|
|
@ -595,26 +633,35 @@ export async function handleOllamaGenerate(req, res, apiService, currentConfig,
|
|||
|
||||
console.log(`[Ollama] Model: ${modelName}, Detected provider: ${detectedProvider}`);
|
||||
|
||||
// If provider is different, get the appropriate service
|
||||
// Get the appropriate service based on detected provider
|
||||
let actualApiService = apiService;
|
||||
let actualConfig = currentConfig;
|
||||
|
||||
if (detectedProvider !== currentConfig.MODEL_PROVIDER && providerPoolManager) {
|
||||
// Select provider from pool
|
||||
const providerConfig = providerPoolManager.selectProvider(detectedProvider, modelName, { skipUsageCount: true });
|
||||
if (providerConfig) {
|
||||
actualConfig = {
|
||||
...currentConfig,
|
||||
...providerConfig,
|
||||
MODEL_PROVIDER: detectedProvider
|
||||
};
|
||||
|
||||
// Get service adapter for the detected provider
|
||||
const { getServiceAdapter } = await import('./adapter.js');
|
||||
// If apiService is null or provider is different, get the appropriate service from pool
|
||||
if (!apiService || detectedProvider !== currentConfig.MODEL_PROVIDER) {
|
||||
if (providerPoolManager) {
|
||||
// Select provider from pool
|
||||
const providerConfig = providerPoolManager.selectProvider(detectedProvider, modelName, { skipUsageCount: true });
|
||||
if (providerConfig) {
|
||||
actualConfig = {
|
||||
...currentConfig,
|
||||
...providerConfig,
|
||||
MODEL_PROVIDER: detectedProvider
|
||||
};
|
||||
actualApiService = getServiceAdapter(actualConfig);
|
||||
console.log(`[Ollama] Using provider from pool: ${detectedProvider}`);
|
||||
} else {
|
||||
// No healthy provider in pool, try to create service directly
|
||||
console.warn(`[Ollama] No healthy provider found for ${detectedProvider} in pool`);
|
||||
if (!apiService) {
|
||||
throw new Error(`No healthy provider available for ${detectedProvider}`);
|
||||
}
|
||||
}
|
||||
} else if (!apiService) {
|
||||
// No pool manager and no apiService, try to create service directly
|
||||
actualConfig = { ...currentConfig, MODEL_PROVIDER: detectedProvider };
|
||||
actualApiService = getServiceAdapter(actualConfig);
|
||||
console.log(`[Ollama] Switched to provider: ${detectedProvider}`);
|
||||
} else {
|
||||
console.warn(`[Ollama] No healthy provider found for ${detectedProvider}, using default`);
|
||||
console.log(`[Ollama] Created service adapter for: ${detectedProvider}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -670,7 +717,7 @@ export async function handleOllamaGenerate(req, res, apiService, currentConfig,
|
|||
}
|
||||
} catch (error) {
|
||||
console.error('[Ollama Generate Error]', error);
|
||||
handleError(res, error);
|
||||
handleError(res, error, MODEL_PROTOCOL_PREFIX.OLLAMA);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ export const PROVIDER_MODELS = {
|
|||
'gemini-3-pro-image-preview',
|
||||
'gemini-3-pro-preview',
|
||||
'gemini-3-flash-preview',
|
||||
'gemini-2.5-flash',
|
||||
'gemini-2.5-flash-preview',
|
||||
'gemini-claude-sonnet-4-5',
|
||||
'gemini-claude-sonnet-4-5-thinking',
|
||||
'gemini-claude-opus-4-5-thinking'
|
||||
|
|
|
|||
|
|
@ -110,7 +110,7 @@ export function createRequestHandler(config, providerPoolManager) {
|
|||
return true;
|
||||
} catch (error) {
|
||||
console.log(`[Server] req provider_health error: ${error.message}`);
|
||||
handleError(res, { statusCode: 500, message: `Failed to get providers health: ${error.message}` });
|
||||
handleError(res, { statusCode: 500, message: `Failed to get providers health: ${error.message}` }, currentConfig.MODEL_PROVIDER);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
|
@ -125,8 +125,11 @@ export function createRequestHandler(config, providerPoolManager) {
|
|||
}
|
||||
|
||||
// Check if the first path segment matches a MODEL_PROVIDER and switch if it does
|
||||
// Note: 'ollama' is not a valid MODEL_PROVIDER, it's a protocol prefix for Ollama API compatibility
|
||||
const pathSegments = path.split('/').filter(segment => segment.length > 0);
|
||||
if (pathSegments.length > 0) {
|
||||
const isOllamaPath = pathSegments[0] === 'ollama' || path.startsWith('/api/');
|
||||
|
||||
if (pathSegments.length > 0 && !isOllamaPath) {
|
||||
const firstSegment = pathSegments[0];
|
||||
const isValidProvider = Object.values(MODEL_PROVIDER).includes(firstSegment);
|
||||
if (firstSegment && isValidProvider) {
|
||||
|
|
@ -140,12 +143,28 @@ export function createRequestHandler(config, providerPoolManager) {
|
|||
}
|
||||
}
|
||||
|
||||
// Check authentication for API requests (before Ollama handling to allow unauthenticated Ollama endpoints)
|
||||
if (!isAuthorized(req, requestUrl, currentConfig.REQUIRED_API_KEY)) {
|
||||
res.writeHead(401, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: { message: 'Unauthorized: API key is invalid or missing.' } }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle Ollama request BEFORE getting apiService (Ollama endpoints handle their own provider selection)
|
||||
// This is important because Ollama /api/tags aggregates models from ALL providers, not just the default one
|
||||
if (isOllamaPath) {
|
||||
const { handled, normalizedPath } = await handleOllamaRequest(method, path, requestUrl, req, res, null, currentConfig, providerPoolManager);
|
||||
if (handled) return;
|
||||
// If not handled by Ollama handler, continue with normal flow
|
||||
path = normalizedPath;
|
||||
}
|
||||
|
||||
// 获取或选择 API Service 实例
|
||||
let apiService;
|
||||
try {
|
||||
apiService = await getApiService(currentConfig);
|
||||
} catch (error) {
|
||||
handleError(res, { statusCode: 500, message: `Failed to get API service: ${error.message}` });
|
||||
handleError(res, { statusCode: 500, message: `Failed to get API service: ${error.message}` }, currentConfig.MODEL_PROVIDER);
|
||||
const poolManager = getProviderPoolManager();
|
||||
if (poolManager) {
|
||||
poolManager.markProviderUnhealthy(currentConfig.MODEL_PROVIDER, {
|
||||
|
|
@ -155,13 +174,6 @@ export function createRequestHandler(config, providerPoolManager) {
|
|||
return;
|
||||
}
|
||||
|
||||
// Check authentication for API requests
|
||||
if (!isAuthorized(req, requestUrl, currentConfig.REQUIRED_API_KEY)) {
|
||||
res.writeHead(401, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: { message: 'Unauthorized: API key is invalid or missing.' } }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle count_tokens requests (Anthropic API compatible)
|
||||
if (path.includes('/count_tokens') && method === 'POST') {
|
||||
try {
|
||||
|
|
@ -188,18 +200,13 @@ export function createRequestHandler(config, providerPoolManager) {
|
|||
return true;
|
||||
} catch (error) {
|
||||
console.error(`[Server] count_tokens error: ${error.message}`);
|
||||
handleError(res, { statusCode: 500, message: `Failed to count tokens: ${error.message}` });
|
||||
handleError(res, { statusCode: 500, message: `Failed to count tokens: ${error.message}` }, currentConfig.MODEL_PROVIDER);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Handle Ollama request (normalize path and route to appropriate endpoints)
|
||||
const { handled, normalizedPath } = await handleOllamaRequest(method, path, requestUrl, req, res, apiService, currentConfig, providerPoolManager);
|
||||
if (handled) return;
|
||||
path = normalizedPath;
|
||||
|
||||
// Handle API requests
|
||||
// Handle API requests (Ollama requests are already handled above before apiService is obtained)
|
||||
const apiHandled = await handleAPIRequests(method, path, req, res, currentConfig, apiService, providerPoolManager, PROMPT_LOG_FILENAME);
|
||||
if (apiHandled) return;
|
||||
|
||||
|
|
@ -207,7 +214,7 @@ export function createRequestHandler(config, providerPoolManager) {
|
|||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: { message: 'Not Found' } }));
|
||||
} catch (error) {
|
||||
handleError(res, error);
|
||||
handleError(res, error, currentConfig.MODEL_PROVIDER);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -3,6 +3,8 @@ const translations = {
|
|||
'zh-CN': {
|
||||
// Header
|
||||
'header.title': 'AIClient2API 管理控制台',
|
||||
'header.github': 'GitHub 仓库',
|
||||
'header.themeToggle': '切换主题',
|
||||
'header.status.connecting': '连接中...',
|
||||
'header.status.connected': '已连接',
|
||||
'header.status.disconnected': '连接断开',
|
||||
|
|
@ -423,6 +425,8 @@ const translations = {
|
|||
'en-US': {
|
||||
// Header
|
||||
'header.title': 'AIClient2API Management Console',
|
||||
'header.github': 'GitHub Repository',
|
||||
'header.themeToggle': 'Toggle Theme',
|
||||
'header.status.connecting': 'Connecting...',
|
||||
'header.status.connected': 'Connected',
|
||||
'header.status.disconnected': 'Disconnected',
|
||||
|
|
|
|||
|
|
@ -17,12 +17,12 @@
|
|||
<div class="header-content">
|
||||
<h1><i class="fas fa-robot"></i> <span class="header-title" data-i18n="header.title">AIClient2API 管理控制台</span></h1>
|
||||
<div class="header-controls">
|
||||
<a href="https://github.com/justlovemaki/AIClient-2-API" target="_blank" rel="noopener noreferrer" class="github-link" title="GitHub" data-i18n-title="header.github">
|
||||
<i class="fab fa-github"></i>
|
||||
</a>
|
||||
<span class="status-badge" id="serverStatus">
|
||||
<i class="fas fa-circle"></i> <span class="status-text" data-i18n="header.status.connecting">连接中...</span>
|
||||
</span>
|
||||
<a href="https://github.com/justlovemaki/AIClient-2-API" target="_blank" rel="noopener noreferrer" class="github-link" title="GitHub" data-i18n-title="header.github">
|
||||
<i class="fab fa-github"></i>
|
||||
</a>
|
||||
<button id="themeToggleBtn" class="theme-toggle" aria-label="Toggle Theme" data-i18n-aria-label="header.themeToggle" title="切换主题" data-i18n-title="header.themeToggle">
|
||||
<i class="fas fa-moon"></i>
|
||||
<i class="fas fa-sun"></i>
|
||||
|
|
|
|||
Loading…
Reference in a new issue