feat: 改进错误处理、Ollama兼容性和文档结构

refactor(converters): 优化Claude到Gemini的转换逻辑
fix(kiro): 修复社交认证刷新问题
perf(ollama): 提升模型列表获取效率
docs: 为README添加可折叠区块
style: 清理控制台日志
This commit is contained in:
hex2077 2026-01-06 18:14:02 +08:00
parent bcc2f1eb59
commit 8f39295655
16 changed files with 1734 additions and 292 deletions

View file

@ -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>
---
## 📄 オープンソースライセンス

View file

@ -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>
---
## 📄 开源许可

View file

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

View file

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

View file

@ -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 - 请求体对象

View file

@ -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响应
*/

View file

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

View file

@ -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_configCherry 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_configOpenRouter 风格)
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
*/

View file

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

View file

@ -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; // 成功获取后退出循环
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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