diff --git a/README-JA.md b/README-JA.md index bc9c239..a5bb59b 100644 --- a/README-JA.md +++ b/README-JA.md @@ -31,6 +31,9 @@ > > **📅 バージョン更新ログ** > +>
+> クリックして詳細なバージョン履歴を展開 +> > - **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つのプロトコル相互変換、自動インテリジェント切り替え +>
--- @@ -204,6 +208,9 @@ docker compose up -d ### 🔐 認証設定ガイド +
+クリックして各プロバイダーの認証設定詳細手順を展開 + > **💡 ヒント**:最適な体験を得るために、**Web UIコンソール**を通じてビジュアルに認証管理を行うことを推奨します。 #### 🌐 Web UI クイック認証 (推奨) @@ -245,8 +252,13 @@ Web UI管理インターフェースでは、極めて迅速に認証設定を 3. **起動パラメータ設定**:`--provider-pools-file ` パラメータを使用してプール設定ファイルのパスを指定します 4. **ヘルスチェック**:システムは定期的にヘルスチェックを自動実行し、健全でないプロバイダーを使用しません +
+ ### 📁 認証ファイル保存パス +
+クリックして各サービスの認証情報のデフォルト保存場所を展開 + 各サービスの認証情報ファイルのデフォルト保存場所: | サービス | デフォルトパス | 説明 | @@ -257,9 +269,11 @@ Web UI管理インターフェースでは、極めて迅速に認証設定を | **Antigravity** | `~/.antigravity/oauth_creds.json` | Antigravity OAuth認証情報 (Claude 4.5 Opus サポート) | > **説明**:`~`はユーザーホームディレクトリを表します(Windows: `C:\Users\ユーザー名`、Linux/macOS: `/home/ユーザー名`または`/Users/ユーザー名`) -> + > **カスタムパス**:設定ファイルの関連パラメータまたは環境変数でカスタム保存場所を指定可能 +
+ --- ### 🦙 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 \ ### 高度な設定 +
+クリックしてプロキシ設定、モデルフィルタリング、Fallbackなどの高度な設定を展開 + #### 1. プロキシ設定 本プロジェクトは柔軟なプロキシ設定をサポートしており、異なるプロバイダーに統一プロキシを設定したり、プロバイダー独自のプロキシ済みエンドポイントを使用したりできます。 @@ -410,10 +429,15 @@ curl http://localhost:3000/ollama/api/chat \ - フォールバックはプロトコル互換タイプ間でのみ発生します(例:`gemini-*` 間、`claude-*` 間) - システムは自動的にターゲットProvider Typeがリクエストされたモデルをサポートしているか確認します +
+ --- ## ❓ よくある質問 +
+クリックしてよくある質問と解決策を展開(ポート占有、Docker起動、429エラーなど) + ### 1. OAuth認証失敗 **問題の説明**:「認証生成」をクリックした後、ブラウザで認証ページが開きますが、認証が失敗するか完了できません。 @@ -532,6 +556,8 @@ kill -9 - **リクエストヘッダー形式を確認**:リクエストに正しい形式のAuthorizationヘッダーが含まれていることを確認、例:`Authorization: Bearer your-api-key` - **サービスログを確認**:Web UIの「リアルタイムログ」ページで詳細なエラーメッセージを確認し、具体的な原因を特定 +
+ --- ## 📄 オープンソースライセンス diff --git a/README-ZH.md b/README-ZH.md index 7fedf91..9591bbb 100644 --- a/README-ZH.md +++ b/README-ZH.md @@ -31,6 +31,9 @@ > > **📅 版本更新日志** > +>
+> 点击展开查看详细版本历史 +> > - **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 三协议互转,自动智能切换 +>
--- ## 💡 核心优势 @@ -203,6 +207,9 @@ docker compose up -d ### 🔐 授权配置指南 +
+点击展开查看各提供商授权配置详细步骤 + > **💡 提示**:为了获得最佳体验,建议通过 **Web UI 控制台** 进行可视化授权管理。 #### 🌐 Web UI 快捷授权 (推荐) @@ -244,8 +251,13 @@ docker compose up -d 3. **启动参数配置**:使用 `--provider-pools-file ` 参数指定号池配置文件路径 4. **健康检查**:系统会定期自动执行健康检查,不使用不健康的提供商 +
+ ### 📁 授权文件存储路径 +
+点击展开查看各服务授权凭据的默认存储位置 + 各服务的授权凭据文件默认存储位置: | 服务 | 默认路径 | 说明 | @@ -259,6 +271,8 @@ docker compose up -d > **自定义路径**:可通过配置文件中的相关参数或环境变量指定自定义存储位置 +
+ --- ### 🦙 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 \ ### 高级配置 +
+点击展开查看代理配置、模型过滤及 Fallback 等高级设置 + #### 1. 代理配置 本项目支持灵活的代理配置,可以为不同的提供商配置统一代理或使用提供商自带的已代理端点。 @@ -409,10 +428,15 @@ curl http://localhost:3000/ollama/api/chat \ - Fallback 只会在协议兼容的类型之间进行(如 `gemini-*` 之间、`claude-*` 之间) - 系统会自动检查目标 Provider Type 是否支持当前请求的模型 +
+ --- ## ❓ 常见问题 +
+点击展开查看常见问题及解决方案(端口占用、Docker 启动、429 错误等) + ### 1. OAuth 授权失败 **问题描述**:点击"生成授权"后,浏览器打开授权页面但授权失败或无法完成。 @@ -531,6 +555,8 @@ kill -9 - **检查请求头格式**:确保请求中包含正确格式的 Authorization 头,如 `Authorization: Bearer your-api-key` - **查看服务日志**:在 Web UI 的"实时日志"页面查看详细错误信息,定位具体原因 +
+ --- ## 📄 开源许可 diff --git a/README.md b/README.md index 50a1d1b..827b139 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,9 @@ > > **📅 Version Update Log** > +>
+> Click to expand detailed version history +> > - **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 +>
--- @@ -204,6 +208,9 @@ Seamlessly support the following latest large models, just configure the corresp ### 🔐 Authorization Configuration Guide +
+Click to expand detailed authorization configuration steps for each provider + > **💡 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 ` parameter to specify the pool configuration file path 4. **Health Check**: The system will automatically perform periodic health checks and avoid using unhealthy providers +
+ ### 📁 Authorization File Storage Paths +
+Click to expand default storage locations for authorization credentials + 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 +
+ --- ### 🦙 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 +
+Click to expand proxy configuration, model filtering, and Fallback advanced settings + #### 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 +
+ --- ## ❓ FAQ +
+Click to expand FAQ and solutions (port occupation, Docker startup, 429 errors, etc.) + ### 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 +
+ --- ## 📄 Open Source License diff --git a/src/claude/claude-kiro.js b/src/claude/claude-kiro.js index 73c165e..6da932e 100644 --- a/src/claude/claude-kiro.js +++ b/src/claude/claude-kiro.js @@ -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) { diff --git a/src/common.js b/src/common.js index 147a329..b2f0cd8 100644 --- a/src/common.js +++ b/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 ' + ] + }; + + default: + return defaultSuggestions; + } +} + /** * 从请求体中提取系统提示词。 * @param {Object} requestBody - 请求体对象。 diff --git a/src/converters/strategies/ClaudeConverter.js b/src/converters/strategies/ClaudeConverter.js index 15a0bd9..5960054 100644 --- a/src/converters/strategies/ClaudeConverter.js +++ b/src/converters/strategies/ClaudeConverter.js @@ -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响应 */ diff --git a/src/converters/strategies/OllamaConverter.js b/src/converters/strategies/OllamaConverter.js index 3bd8ffd..733592e 100644 --- a/src/converters/strategies/OllamaConverter.js +++ b/src/converters/strategies/OllamaConverter.js @@ -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; diff --git a/src/converters/strategies/OpenAIConverter.js b/src/converters/strategies/OpenAIConverter.js index d970f22..913802b 100644 --- a/src/converters/strategies/OpenAIConverter.js +++ b/src/converters/strategies/OpenAIConverter.js @@ -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 */ diff --git a/src/converters/utils.js b/src/converters/utils.js index 7797a5e..55c5933 100644 --- a/src/converters/utils.js +++ b/src/converters/utils.js @@ -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; diff --git a/src/gemini/antigravity-core.js b/src/gemini/antigravity-core.js index a039f7b..27d43a3 100644 --- a/src/gemini/antigravity-core.js +++ b/src/gemini/antigravity-core.js @@ -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; // 成功获取后退出循环 } diff --git a/src/gemini/gemini-core.js b/src/gemini/gemini-core.js index cd14ac3..065ff28 100644 --- a/src/gemini/gemini-core.js +++ b/src/gemini/gemini-core.js @@ -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; diff --git a/src/ollama-handler.js b/src/ollama-handler.js index d427e97..8f78086 100644 --- a/src/ollama-handler.js +++ b/src/ollama-handler.js @@ -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); } } diff --git a/src/provider-models.js b/src/provider-models.js index 4c2e666..48471fb 100644 --- a/src/provider-models.js +++ b/src/provider-models.js @@ -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' diff --git a/src/request-handler.js b/src/request-handler.js index 77b1e8c..bcc1de8 100644 --- a/src/request-handler.js +++ b/src/request-handler.js @@ -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); } }; } \ No newline at end of file diff --git a/static/app/i18n.js b/static/app/i18n.js index 0d6eec0..6d5f46d 100644 --- a/static/app/i18n.js +++ b/static/app/i18n.js @@ -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', diff --git a/static/index.html b/static/index.html index f39fe41..819194b 100644 --- a/static/index.html +++ b/static/index.html @@ -17,12 +17,12 @@

AIClient2API 管理控制台

- - - 连接中... + + +