From 1ce6f6da86c5a34c6a78f53dca9febc414436231 Mon Sep 17 00:00:00 2001 From: hex2077 Date: Sun, 30 Nov 2025 21:51:17 +0800 Subject: [PATCH] =?UTF-8?q?feat(antigravity):=20=E6=96=B0=E5=A2=9EAntigrav?= =?UTF-8?q?ity=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 添加对Google编程Antigravity客户端API的支持,包括: - 新增Antigravity OAuth认证配置 - 添加Antigravity API服务适配器 - 更新UI管理界面支持Antigravity授权 - 新增Antigravity模型列表 - 更新多语言文档 - 添加OAuth处理模块 --- README-JA.md | 22 +- README-ZH.md | 12 +- README.md | 28 +- config.json.example | 1 + provider_pools.json.example | 28 ++ src/adapter.js | 44 +++ src/common.js | 1 + src/gemini/antigravity-core.js | 664 +++++++++++++++++++++++++++++++++ src/oauth-handlers.js | 486 ++++++++++++++++++++++++ src/provider-models.js | 7 + src/ui-manager.js | 53 +++ static/app/provider-manager.js | 178 ++++++++- static/app/styles.css | 316 ++++++++++++++++ static/index.html | 88 ++++- 14 files changed, 1902 insertions(+), 26 deletions(-) create mode 100644 src/gemini/antigravity-core.js create mode 100644 src/oauth-handlers.js diff --git a/README-JA.md b/README-JA.md index b481060..abd29d4 100644 --- a/README-JA.md +++ b/README-JA.md @@ -30,6 +30,7 @@ > > **📅 バージョン更新ログ** > +> - **2025.11.30** - Antigravityプロトコルサポートの追加、Google内部インターフェース経由でGemini 3 Pro、Claude Sonnet 4.5などのモデルへのアクセスをサポート > - **2025.11.16** - Ollamaプロトコルサポートの追加、統一インターフェースでサポートされるすべてのモデルにアクセス > - **2025.11.11** - Web UI管理コントロールコンソールの追加、リアルタイム設定管理与健康状態モニタリングをサポート > - **2025.11.06** - Gemini 3 プレビュー版のサポートを追加、モデル互換性とパフォーマンス最適化を向上 @@ -252,6 +253,12 @@ install-and-run.bat 3. **ベストプラクティス**:**Claude Code**との併用を推奨、最適な体験を得られる 4. **重要なお知らせ**:Kiroサービス使用ポリシーが更新されました、最新の使用制限と条件については公式ウェブサイトをご確認ください +#### アカウントプール管理設定 +1. **プール設定ファイルの作成**:[provider_pools.json.example](./provider_pools.json.example) を参考に設定ファイルを作成します +2. **プールパラメータの設定**:config.json で `PROVIDER_POOLS_FILE_PATH` を設定し、プール設定ファイルを指定します +3. **起動パラメータ設定**:`--provider-pools-file ` パラメータを使用してプール設定ファイルのパスを指定します +4. **ヘルスチェック**:システムは定期的にヘルスチェックを自動実行し、健全でないプロバイダーを削除します + #### OpenAI Responses API * **適用シナリオ**:Codexなど、OpenAI Responses APIを使用した構造化対話が必要なシナリオに適用 * **設定方法**: @@ -331,6 +338,7 @@ curl http://localhost:3000/ollama/api/chat \ | **Gemini** | `~/.gemini/oauth_creds.json` | OAuth認証情報 | | **Kiro** | `~/.aws/sso/cache/kiro-auth-token.json` | Kiro認証トークン | | **Qwen** | `~/.qwen/oauth_creds.json` | Qwen OAuth認証情報 | +| **Antigravity** | `~/.antigravity/oauth_creds.json` | Antigravity OAuth認証情報 | > **説明**:`~`はユーザーホームディレクトリを表します(Windows: `C:\Users\ユーザー名`、Linux/macOS: `/home/ユーザー名`または`/Users/ユーザー名`) > @@ -354,7 +362,7 @@ curl http://localhost:3000/ollama/api/chat \ | パラメータ | タイプ | デフォルト値 | 説明 | |------|------|--------|------| -| `--model-provider` | string | gemini-cli-oauth | AIモデルプロバイダー、選択可能値:openai-custom, claude-custom, gemini-cli-oauth, claude-kiro-oauth, openai-qwen-oauth, openaiResponses-custom | +| `--model-provider` | string | gemini-cli-oauth | AIモデルプロバイダー、選択可能値:openai-custom, claude-custom, gemini-cli-oauth, claude-kiro-oauth, openai-qwen-oauth, openaiResponses-custom, gemini-antigravity | ### 🧠 OpenAI互換プロバイダーパラメータ @@ -391,6 +399,12 @@ curl http://localhost:3000/ollama/api/chat \ |------|------|--------|------| | `--qwen-oauth-creds-file` | string | null | Qwen OAuth認証情報JSONファイルパス(`model-provider`が`openai-qwen-oauth`の場合必須) | +### 🌌 Antigravity OAuth認証パラメータ + +| パラメータ | タイプ | デフォルト値 | 説明 | +|------|------|--------|------| +| `--antigravity-oauth-creds-file` | string | null | Antigravity OAuth認証情報JSONファイルパス(`model-provider`が`gemini-antigravity`の場合オプション) | + ### 🔄 OpenAI Responses APIパラメータ | パラメータ | タイプ | デフォルト値 | 説明 | @@ -464,6 +478,9 @@ node src/api-server.js --system-prompt-file custom-prompt.txt --system-prompt-mo node src/api-server.js --log-prompts console node src/api-server.js --log-prompts file --prompt-log-base-name my-logs +# アカウントプールの設定 +node src/api-server.js --provider-pools-file ./provider_pools.json + # 完全な例 node src/api-server.js \ --host 0.0.0.0 \ @@ -475,7 +492,8 @@ node src/api-server.js \ --system-prompt-file ./custom-system-prompt.txt \ --system-prompt-mode overwrite \ --log-prompts file \ - --prompt-log-base-name api-logs + --prompt-log-base-name api-logs \ + --provider-pools-file ./provider_pools.json ``` --- diff --git a/README-ZH.md b/README-ZH.md index 4713750..d7f6cb8 100644 --- a/README-ZH.md +++ b/README-ZH.md @@ -30,6 +30,7 @@ > > **📅 版本更新日志** > +> - **2025.11.30** - 新增 Antigravity 协议支持,支持通过 Google 内部接口访问 Gemini 3 Pro、Claude Sonnet 4.5 等模型 > - **2025.11.16** - 新增 Ollama 协议支持,统一接口访问所有支持的模型(Claude、Gemini、Qwen、OpenAI等) > - **2025.11.11** - 新增 Web UI 管理控制台,支持实时配置管理和健康状态监控 > - **2025.11.06** - 新增对 Gemini 3 预览版的支持,增强模型兼容性和性能优化 @@ -330,6 +331,7 @@ curl http://localhost:3000/ollama/api/chat \ | **Gemini** | `~/.gemini/oauth_creds.json` | OAuth 认证凭据 | | **Kiro** | `~/.aws/sso/cache/kiro-auth-token.json` | Kiro 认证令牌 | | **Qwen** | `~/.qwen/oauth_creds.json` | Qwen OAuth 凭据 | +| **Antigravity** | `~/.antigravity/oauth_creds.json` | Antigravity OAuth 凭据 | > **说明**:`~` 表示用户主目录(Windows: `C:\Users\用户名`,Linux/macOS: `/home/用户名` 或 `/Users/用户名`) > @@ -353,7 +355,7 @@ curl http://localhost:3000/ollama/api/chat \ | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| -| `--model-provider` | string | gemini-cli-oauth | AI 模型提供商,可选值:openai-custom, claude-custom, gemini-cli-oauth, claude-kiro-oauth, openai-qwen-oauth, openaiResponses-custom | +| `--model-provider` | string | gemini-cli-oauth | AI 模型提供商,可选值:openai-custom, claude-custom, gemini-cli-oauth, claude-kiro-oauth, openai-qwen-oauth, openaiResponses-custom, gemini-antigravity | ### 🧠 OpenAI 兼容提供商参数 @@ -388,7 +390,13 @@ curl http://localhost:3000/ollama/api/chat \ | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| -| `--qwen-oauth-creds-file` | string | null | Qwen OAuth 凭据 JSON 文件路径 (当 `model-provider` 为 `openai-qwen-oauth` 时必需) | +| `--qwen-oauth-creds-file` | string | null | Qwen OAuth 凭据 JSON 文件路径 (当 `model-provider` 为 `openai-qwen-oauth` 时可选) | + +### 🌌 Antigravity OAuth 认证参数 + +| 参数 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `--antigravity-oauth-creds-file` | string | null | Antigravity OAuth 凭据 JSON 文件路径 (当 `model-provider` 为 `gemini-antigravity` 时可选) | ### 🔄 OpenAI Responses API 参数 diff --git a/README.md b/README.md index 09fb0fa..7e6a42e 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ > > **📅 Version Update Log** > +> - **2025.11.30** - Added Antigravity protocol support, enabling access to Gemini 3 Pro, Claude Sonnet 4.5, and other models via Google internal interfaces > - **2025.11.16** - Added Ollama protocol support, unified interface to access all supported models (Claude, Gemini, Qwen, OpenAI, etc.) > - **2025.11.11** - Added Web UI management console, supporting real-time configuration management and health status monitoring > - **2025.11.06** - Added support for Gemini 3 Preview, enhanced model compatibility and performance optimization @@ -251,13 +252,11 @@ Seamlessly supports the following latest large models, simply configure the corr 3. **Best Practice**: Recommended to use with **Claude Code** for optimal experience 4. **Important Notice**: Kiro service usage policy has been updated, please visit the official website for the latest usage restrictions and terms -#### OpenAI Responses API -* **Application Scenario**: Suitable for scenarios requiring structured dialogue using OpenAI Responses API, such as Codex -* **Configuration Method**: - * Method 1: Set `MODEL_PROVIDER` to `openaiResponses-custom` in [`config.json`](./config.json) - * Method 2: Use startup parameter `--model-provider openaiResponses-custom` - * Method 3: Use path routing `/openaiResponses-custom` -* **Required Parameters**: Provide valid API key and base URL +#### Account Pool Management Configuration +1. **Create Pool Configuration File**: Create a configuration file referencing [provider_pools.json.example](./provider_pools.json.example) +2. **Configure Pool Parameter**: Set `PROVIDER_POOLS_FILE_PATH` in config.json to point to the pool configuration file +3. **Startup Parameter Configuration**: Use `--provider-pools-file ` parameter to specify the pool configuration file path +4. **Health Check**: The system will automatically perform periodic health checks and remove unhealthy providers --- @@ -330,6 +329,7 @@ Default storage locations for authorization credential files of each service: | **Gemini** | `~/.gemini/oauth_creds.json` | OAuth authentication credentials | | **Kiro** | `~/.aws/sso/cache/kiro-auth-token.json` | Kiro authentication token | | **Qwen** | `~/.qwen/oauth_creds.json` | Qwen OAuth credentials | +| **Antigravity** | `~/.antigravity/oauth_creds.json` | Antigravity OAuth credentials | > **Note**: `~` represents the user home directory (Windows: `C:\Users\username`, Linux/macOS: `/home/username` or `/Users/username`) > @@ -353,7 +353,7 @@ This project supports rich command-line parameter configuration, allowing flexib | Parameter | Type | Default Value | Description | |------|------|--------|------| -| `--model-provider` | string | gemini-cli-oauth | AI model provider, optional values: openai-custom, claude-custom, gemini-cli-oauth, claude-kiro-oauth, openai-qwen-oauth, openaiResponses-custom | +| `--model-provider` | string | gemini-cli-oauth | AI model provider, optional values: openai-custom, claude-custom, gemini-cli-oauth, claude-kiro-oauth, openai-qwen-oauth, openaiResponses-custom, gemini-antigravity | ### 🧠 OpenAI Compatible Provider Parameters @@ -390,6 +390,12 @@ This project supports rich command-line parameter configuration, allowing flexib |------|------|--------|------| | `--qwen-oauth-creds-file` | string | null | Qwen OAuth credentials JSON file path (required when `model-provider` is `openai-qwen-oauth`) | +### 🌌 Antigravity OAuth Authentication Parameters + +| Parameter | Type | Default Value | Description | +|------|------|--------|------| +| `--antigravity-oauth-creds-file` | string | null | Antigravity OAuth credentials JSON file path (optional when `model-provider` is `gemini-antigravity`) | + ### 🔄 OpenAI Responses API Parameters | Parameter | Type | Default Value | Description | @@ -463,6 +469,9 @@ node src/api-server.js --system-prompt-file custom-prompt.txt --system-prompt-mo node src/api-server.js --log-prompts console node src/api-server.js --log-prompts file --prompt-log-base-name my-logs +# Configure Account Pool +node src/api-server.js --provider-pools-file ./provider_pools.json + # Complete example node src/api-server.js \ --host 0.0.0.0 \ @@ -474,7 +483,8 @@ node src/api-server.js \ --system-prompt-file ./custom-system-prompt.txt \ --system-prompt-mode overwrite \ --log-prompts file \ - --prompt-log-base-name api-logs + --prompt-log-base-name api-logs \ + --provider-pools-file ./provider_pools.json ``` --- diff --git a/config.json.example b/config.json.example index 3d316d7..8d484ef 100644 --- a/config.json.example +++ b/config.json.example @@ -10,6 +10,7 @@ "PROJECT_ID": null, "GEMINI_OAUTH_CREDS_BASE64": null, "GEMINI_OAUTH_CREDS_FILE_PATH": null, + "ANTIGRAVITY_OAUTH_CREDS_FILE_PATH": null, "KIRO_OAUTH_CREDS_BASE64": null, "KIRO_OAUTH_CREDS_FILE_PATH": null, "QWEN_OAUTH_CREDS_FILE_PATH": null, diff --git a/provider_pools.json.example b/provider_pools.json.example index 7b01a21..0a416c4 100644 --- a/provider_pools.json.example +++ b/provider_pools.json.example @@ -139,5 +139,33 @@ "errorCount": 0, "lastErrorTime": null } + ], + "gemini-antigravity": [ + { + "ANTIGRAVITY_OAUTH_CREDS_FILE_PATH": "./antigravity_creds1.json", + "PROJECT_ID": "antigravity-project-1", + "uuid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "checkModelName": null, + "checkHealth": true, + "isHealthy": true, + "isDisabled": false, + "lastUsed": null, + "usageCount": 0, + "errorCount": 0, + "lastErrorTime": null + }, + { + "ANTIGRAVITY_OAUTH_CREDS_FILE_PATH": "./antigravity_creds2.json", + "PROJECT_ID": "antigravity-project-2", + "uuid": "f0e9d8c7-b6a5-4321-fedc-ba9876543210", + "checkModelName": null, + "checkHealth": true, + "isHealthy": true, + "isDisabled": false, + "lastUsed": null, + "usageCount": 0, + "errorCount": 0, + "lastErrorTime": null + } ] } \ No newline at end of file diff --git a/src/adapter.js b/src/adapter.js index 4c81104..368964f 100644 --- a/src/adapter.js +++ b/src/adapter.js @@ -1,5 +1,6 @@ import { OpenAIResponsesApiService } from './openai/openai-responses-core.js'; // 导入OpenAIResponsesApiService import { GeminiApiService } from './gemini/gemini-core.js'; // 导入geminiApiService +import { AntigravityApiService } from './gemini/antigravity-core.js'; // 导入AntigravityApiService import { OpenAIApiService } from './openai/openai-core.js'; // 导入OpenAIApiService import { ClaudeApiService } from './claude/claude-core.js'; // 导入ClaudeApiService import { KiroApiService } from './claude/claude-kiro.js'; // 导入KiroApiService @@ -96,6 +97,46 @@ export class GeminiApiServiceAdapter extends ApiServiceAdapter { } } +// Antigravity API 服务适配器 +export class AntigravityApiServiceAdapter extends ApiServiceAdapter { + constructor(config) { + super(); + this.antigravityApiService = new AntigravityApiService(config); + } + + async generateContent(model, requestBody) { + if (!this.antigravityApiService.isInitialized) { + console.warn("antigravityApiService not initialized, attempting to re-initialize..."); + await this.antigravityApiService.initialize(); + } + return this.antigravityApiService.generateContent(model, requestBody); + } + + async *generateContentStream(model, requestBody) { + if (!this.antigravityApiService.isInitialized) { + console.warn("antigravityApiService not initialized, attempting to re-initialize..."); + await this.antigravityApiService.initialize(); + } + yield* this.antigravityApiService.generateContentStream(model, requestBody); + } + + async listModels() { + if (!this.antigravityApiService.isInitialized) { + console.warn("antigravityApiService not initialized, attempting to re-initialize..."); + await this.antigravityApiService.initialize(); + } + return this.antigravityApiService.listModels(); + } + + async refreshToken() { + if (this.antigravityApiService.isExpiryDateNear() === true) { + console.log(`[Antigravity] Expiry date is near, refreshing token...`); + return this.antigravityApiService.initializeAuth(true); + } + return Promise.resolve(); + } +} + // OpenAI API 服务适配器 export class OpenAIApiServiceAdapter extends ApiServiceAdapter { constructor(config) { @@ -290,6 +331,9 @@ export function getServiceAdapter(config) { case MODEL_PROVIDER.GEMINI_CLI: serviceInstances[providerKey] = new GeminiApiServiceAdapter(config); break; + case MODEL_PROVIDER.ANTIGRAVITY: + serviceInstances[providerKey] = new AntigravityApiServiceAdapter(config); + break; case MODEL_PROVIDER.CLAUDE_CUSTOM: serviceInstances[providerKey] = new ClaudeApiServiceAdapter(config); break; diff --git a/src/common.js b/src/common.js index 131376e..38c71bf 100644 --- a/src/common.js +++ b/src/common.js @@ -23,6 +23,7 @@ export const MODEL_PROTOCOL_PREFIX = { export const MODEL_PROVIDER = { // Model provider constants GEMINI_CLI: 'gemini-cli-oauth', + ANTIGRAVITY: 'gemini-antigravity', OPENAI_CUSTOM: 'openai-custom', OPENAI_CUSTOM_RESPONSES: 'openaiResponses-custom', CLAUDE_CUSTOM: 'claude-custom', diff --git a/src/gemini/antigravity-core.js b/src/gemini/antigravity-core.js new file mode 100644 index 0000000..de84e6a --- /dev/null +++ b/src/gemini/antigravity-core.js @@ -0,0 +1,664 @@ +import { OAuth2Client } from 'google-auth-library'; +import * as http from 'http'; +import { promises as fs } from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import * as readline from 'readline'; +import { v4 as uuidv4 } from 'uuid'; +import { API_ACTIONS, formatExpiryTime } from '../common.js'; +import { getProviderModels } from '../provider-models.js'; + +// --- Constants --- +const AUTH_REDIRECT_PORT = 8086; +const CREDENTIALS_DIR = '.antigravity'; +const CREDENTIALS_FILE = 'oauth_creds.json'; +const ANTIGRAVITY_BASE_URL_DAILY = 'https://daily-cloudcode-pa.sandbox.googleapis.com'; +const ANTIGRAVITY_BASE_URL_AUTOPUSH = '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 REFRESH_SKEW = 3000; // 3000秒(50分钟)提前刷新Token + +// 获取 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-claude-sonnet-4-5': 'claude-sonnet-4-5', + 'gemini-claude-sonnet-4-5-thinking': 'claude-sonnet-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', + 'claude-sonnet-4-5': 'gemini-claude-sonnet-4-5', + 'claude-sonnet-4-5-thinking': 'gemini-claude-sonnet-4-5-thinking' +}; + +// 不支持的模型列表 +const UNSUPPORTED_MODELS = ['chat_20706', 'chat_23310', 'gemini-2.5-flash-thinking', 'gemini-3-pro-low', 'gemini-2.5-pro']; + +/** + * 将别名转换为真实模型名 + */ +function alias2ModelName(modelName) { + return MODEL_ALIAS_MAP[modelName] || modelName; +} + +/** + * 将真实模型名转换为别名 + */ +function modelName2Alias(modelName) { + if (UNSUPPORTED_MODELS.includes(modelName)) { + return ''; + } + return MODEL_NAME_MAP[modelName] || modelName; +} + +/** + * 生成随机请求ID + */ +function generateRequestID() { + return 'agent-' + uuidv4(); +} + +/** + * 生成随机会话ID + */ +function generateSessionID() { + const n = Math.floor(Math.random() * 9000000000000000000); + return '-' + n.toString(); +} + +/** + * 生成随机项目ID + */ +function generateProjectID() { + const adjectives = ['useful', 'bright', 'swift', 'calm', 'bold']; + const nouns = ['fuze', 'wave', 'spark', 'flow', 'core']; + const adj = adjectives[Math.floor(Math.random() * adjectives.length)]; + const noun = nouns[Math.floor(Math.random() * nouns.length)]; + const randomPart = uuidv4().toLowerCase().substring(0, 5); + return `${adj}-${noun}-${randomPart}`; +} + +/** + * 将 Gemini 格式请求转换为 Antigravity 格式 + */ +function geminiToAntigravity(modelName, payload) { + // 深拷贝请求体,避免修改原始对象 + let template = JSON.parse(JSON.stringify(payload)); + + // 设置基本字段 + template.model = modelName; + template.userAgent = 'antigravity'; + template.project = generateProjectID(); + template.requestId = generateRequestID(); + + // 确保 request 对象存在 + if (!template.request) { + template.request = {}; + } + + // 设置会话ID + template.request.sessionId = generateSessionID(); + + // 删除安全设置 + if (template.request.safetySettings) { + delete template.request.safetySettings; + } + + // 设置工具配置 + if (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; + } + + // 处理 Thinking 配置 + if (!modelName.startsWith('gemini-3-')) { + if (template.request.generationConfig && + template.request.generationConfig.thinkingConfig && + template.request.generationConfig.thinkingConfig.thinkingLevel) { + delete template.request.generationConfig.thinkingConfig.thinkingLevel; + template.request.generationConfig.thinkingConfig.thinkingBudget = -1; + } + } + + // 处理 Claude Sonnet 模型的工具声明 + if (modelName.startsWith('claude-sonnet-')) { + 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; + } + }); + } + }); + } + } + + return template; +} + +/** + * 将 Antigravity 响应转换为 Gemini 格式 + */ +function toGeminiApiResponse(antigravityResponse) { + if (!antigravityResponse) return null; + + const compliantResponse = { + candidates: antigravityResponse.candidates + }; + + if (antigravityResponse.usageMetadata) { + compliantResponse.usageMetadata = antigravityResponse.usageMetadata; + } + + if (antigravityResponse.promptFeedback) { + compliantResponse.promptFeedback = antigravityResponse.promptFeedback; + } + + if (antigravityResponse.automaticFunctionCallingHistory) { + compliantResponse.automaticFunctionCallingHistory = antigravityResponse.automaticFunctionCallingHistory; + } + + return compliantResponse; +} + +/** + * 确保请求体中的内容部分都有角色属性 + */ +function ensureRolesInContents(requestBody) { + delete requestBody.model; + + if (requestBody.system_instruction) { + requestBody.systemInstruction = requestBody.system_instruction; + delete requestBody.system_instruction; + } + + if (requestBody.systemInstruction && !requestBody.systemInstruction.role) { + requestBody.systemInstruction.role = 'user'; + } + + if (requestBody.contents && Array.isArray(requestBody.contents)) { + requestBody.contents.forEach(content => { + if (!content.role) { + content.role = 'user'; + } + }); + } + + return requestBody; +} + +export class AntigravityApiService { + constructor(config) { + this.authClient = new OAuth2Client(OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET); + this.availableModels = []; + this.isInitialized = false; + + this.config = config; + this.host = config.HOST; + this.oauthCredsFilePath = config.ANTIGRAVITY_OAUTH_CREDS_FILE_PATH; + this.baseURL = ANTIGRAVITY_BASE_URL_DAILY; // 使用通用 GEMINI_BASE_URL 配置 + this.userAgent = DEFAULT_USER_AGENT; // 支持通用 USER_AGENT 配置 + + // 多环境降级顺序 + this.baseURLs = this.baseURL ? [this.baseURL] : [ + ANTIGRAVITY_BASE_URL_DAILY, + ANTIGRAVITY_BASE_URL_AUTOPUSH + // ANTIGRAVITY_BASE_URL_PROD // 生产环境已注释 + ]; + } + + async initialize() { + if (this.isInitialized) return; + console.log('[Antigravity] Initializing Antigravity API Service...'); + await this.initializeAuth(); + + if (!this.projectId) { + this.projectId = generateProjectID(); + console.log(`[Antigravity] Generated Project ID: ${this.projectId}`); + } else { + console.log(`[Antigravity] Using provided Project ID: ${this.projectId}`); + } + + // 获取可用模型 + await this.fetchAvailableModels(); + + this.isInitialized = true; + console.log(`[Antigravity] Initialization complete. Project ID: ${this.projectId}`); + } + + async initializeAuth(forceRefresh = false) { + if (this.authClient.credentials.access_token && !forceRefresh) { + // 检查 Token 是否即将过期 + if (!this.isTokenExpiringSoon()) { + return; + } + } + + // Antigravity 不支持 base64 配置,直接使用文件路径 + + const credPath = this.oauthCredsFilePath || path.join(os.homedir(), CREDENTIALS_DIR, CREDENTIALS_FILE); + try { + const data = await fs.readFile(credPath, "utf8"); + const credentials = JSON.parse(data); + this.authClient.setCredentials(credentials); + console.log('[Antigravity Auth] Authentication configured successfully from file.'); + + if (forceRefresh) { + console.log('[Antigravity Auth] Forcing token refresh...'); + const { credentials: newCredentials } = await this.authClient.refreshAccessToken(); + this.authClient.setCredentials(newCredentials); + // 保存刷新后的凭证 + await fs.writeFile(credPath, JSON.stringify(newCredentials, null, 2)); + console.log('[Antigravity Auth] Token refreshed and saved successfully.'); + } + } catch (error) { + console.error('[Antigravity Auth] Error initializing authentication:', error.code); + if (error.code === 'ENOENT' || error.code === 400) { + console.log(`[Antigravity Auth] Credentials file '${credPath}' not found. Starting new authentication flow...`); + const newTokens = await this.getNewToken(credPath); + this.authClient.setCredentials(newTokens); + console.log('[Antigravity Auth] New token obtained and loaded into memory.'); + } else { + console.error('[Antigravity Auth] Failed to initialize authentication from file:', error); + throw new Error(`Failed to load OAuth credentials.`); + } + } + } + + async getNewToken(credPath) { + let host = this.host; + if (!host || host === 'undefined') { + host = '127.0.0.1'; + } + const redirectUri = `http://${host}:${AUTH_REDIRECT_PORT}`; + this.authClient.redirectUri = redirectUri; + + return new Promise((resolve, reject) => { + const authUrl = this.authClient.generateAuthUrl({ + access_type: 'offline', + scope: ['https://www.googleapis.com/auth/cloud-platform'] + }); + + console.log('\n[Antigravity Auth] Please open this URL in your browser to authenticate:'); + console.log(authUrl, '\n'); + + const server = http.createServer(async (req, res) => { + try { + const url = new URL(req.url, redirectUri); + const code = url.searchParams.get('code'); + const errorParam = url.searchParams.get('error'); + + if (code) { + console.log(`[Antigravity Auth] Received successful callback from Google: ${req.url}`); + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end('Authentication successful! You can close this browser tab.'); + server.close(); + + const { tokens } = await this.authClient.getToken(code); + await fs.mkdir(path.dirname(credPath), { recursive: true }); + await fs.writeFile(credPath, JSON.stringify(tokens, null, 2)); + console.log('[Antigravity Auth] New token received and saved to file.'); + resolve(tokens); + } else if (errorParam) { + const errorMessage = `Authentication failed. Google returned an error: ${errorParam}.`; + res.writeHead(400, { 'Content-Type': 'text/plain' }); + res.end(errorMessage); + server.close(); + reject(new Error(errorMessage)); + } else { + console.log(`[Antigravity Auth] Ignoring irrelevant request: ${req.url}`); + res.writeHead(204); + res.end(); + } + } catch (e) { + if (server.listening) server.close(); + reject(e); + } + }); + + server.on('error', (err) => { + if (err.code === 'EADDRINUSE') { + const errorMessage = `[Antigravity Auth] Port ${AUTH_REDIRECT_PORT} on ${host} is already in use.`; + console.error(errorMessage); + reject(new Error(errorMessage)); + } else { + reject(err); + } + }); + + server.listen(AUTH_REDIRECT_PORT, host); + }); + } + + isTokenExpiringSoon() { + if (!this.authClient.credentials.expiry_date) { + return true; + } + const currentTime = Date.now(); + const expiryTime = this.authClient.credentials.expiry_date; + const refreshSkewMs = REFRESH_SKEW * 1000; + return expiryTime <= (currentTime + refreshSkewMs); + } + + async fetchAvailableModels() { + console.log('[Antigravity] Fetching available models...'); + + for (const baseURL of this.baseURLs) { + try { + const modelsURL = `${baseURL}/${ANTIGRAVITY_API_VERSION}:fetchAvailableModels`; + const requestOptions = { + url: modelsURL, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'User-Agent': this.userAgent + }, + responseType: 'json', + body: JSON.stringify({}) + }; + + const res = await this.authClient.request(requestOptions); + + if (res.data && res.data.models) { + const models = Object.keys(res.data.models); + this.availableModels = models + .map(modelName2Alias) + .filter(alias => alias !== ''); + + console.log(`[Antigravity] Available models: [${this.availableModels.join(', ')}]`); + return; + } + } catch (error) { + console.error(`[Antigravity] Failed to fetch models from ${baseURL}:`, error.message); + } + } + + console.warn('[Antigravity] Failed to fetch models from all endpoints. Using default models.'); + this.availableModels = ANTIGRAVITY_MODELS; + } + + async listModels() { + if (!this.isInitialized) await this.initialize(); + + const now = Math.floor(Date.now() / 1000); + const formattedModels = this.availableModels.map(modelId => { + const displayName = modelId.split('-').map(word => + word.charAt(0).toUpperCase() + word.slice(1) + ).join(' '); + + const modelInfo = { + name: `models/${modelId}`, + version: '1.0.0', + displayName: displayName, + description: `Antigravity model: ${modelId}`, + inputTokenLimit: 32768, + outputTokenLimit: 8192, + supportedGenerationMethods: ['generateContent', 'streamGenerateContent'], + object: 'model', + created: now, + ownedBy: 'antigravity', + type: 'antigravity' + }; + + if (modelId.endsWith('-thinking') || modelId.includes('-thinking-')) { + modelInfo.thinking = { + min: 1024, + max: 100000, + zeroAllowed: false, + dynamicAllowed: true + }; + } + + return modelInfo; + }); + + return { models: formattedModels }; + } + + async callApi(method, body, isRetry = false, retryCount = 0, baseURLIndex = 0) { + const maxRetries = this.config.REQUEST_MAX_RETRIES || 3; + const baseDelay = this.config.REQUEST_BASE_DELAY || 1000; + + if (baseURLIndex >= this.baseURLs.length) { + throw new Error('All Antigravity base URLs failed'); + } + + const baseURL = this.baseURLs[baseURLIndex]; + + try { + const requestOptions = { + url: `${baseURL}/${ANTIGRAVITY_API_VERSION}:${method}`, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'User-Agent': this.userAgent + }, + responseType: 'json', + body: JSON.stringify(body) + }; + + const res = await this.authClient.request(requestOptions); + return res.data; + } catch (error) { + console.error(`[Antigravity API] Error calling ${method} on ${baseURL}:`, error.response?.status, error.message); + + if ((error.response?.status === 400 || error.response?.status === 401) && !isRetry) { + console.log('[Antigravity API] Received 401/400. Refreshing auth and retrying...'); + await this.initializeAuth(true); + return this.callApi(method, body, true, retryCount, baseURLIndex); + } + + if (error.response?.status === 429) { + if (baseURLIndex + 1 < this.baseURLs.length) { + console.log(`[Antigravity API] Rate limited on ${baseURL}. Trying next base URL...`); + return this.callApi(method, body, isRetry, retryCount, baseURLIndex + 1); + } else if (retryCount < maxRetries) { + const delay = baseDelay * Math.pow(2, retryCount); + console.log(`[Antigravity API] Rate limited. Retrying in ${delay}ms...`); + await new Promise(resolve => setTimeout(resolve, delay)); + return this.callApi(method, body, isRetry, retryCount + 1, 0); + } + } + + if (!error.response && baseURLIndex + 1 < this.baseURLs.length) { + console.log(`[Antigravity API] Network error on ${baseURL}. Trying next base URL...`); + return this.callApi(method, body, isRetry, retryCount, baseURLIndex + 1); + } + + if (error.response?.status >= 500 && error.response?.status < 600 && retryCount < maxRetries) { + const delay = baseDelay * Math.pow(2, retryCount); + console.log(`[Antigravity API] Server error ${error.response.status}. Retrying in ${delay}ms...`); + await new Promise(resolve => setTimeout(resolve, delay)); + return this.callApi(method, body, isRetry, retryCount + 1, baseURLIndex); + } + + throw error; + } + } + + async * streamApi(method, body, isRetry = false, retryCount = 0, baseURLIndex = 0) { + const maxRetries = this.config.REQUEST_MAX_RETRIES || 3; + const baseDelay = this.config.REQUEST_BASE_DELAY || 1000; + + if (baseURLIndex >= this.baseURLs.length) { + throw new Error('All Antigravity base URLs failed'); + } + + const baseURL = this.baseURLs[baseURLIndex]; + + try { + const requestOptions = { + url: `${baseURL}/${ANTIGRAVITY_API_VERSION}:${method}`, + method: 'POST', + params: { alt: 'sse' }, + headers: { + 'Content-Type': 'application/json', + 'Accept': 'text/event-stream', + 'User-Agent': this.userAgent + }, + responseType: 'stream', + body: JSON.stringify(body) + }; + + const res = await this.authClient.request(requestOptions); + + if (res.status !== 200) { + let errorBody = ''; + for await (const chunk of res.data) { + errorBody += chunk.toString(); + } + throw new Error(`Upstream API Error (Status ${res.status}): ${errorBody}`); + } + + yield* this.parseSSEStream(res.data); + } catch (error) { + console.error(`[Antigravity API] Error during stream ${method} on ${baseURL}:`, error.response?.status, error.message); + + if ((error.response?.status === 400 || error.response?.status === 401) && !isRetry) { + console.log('[Antigravity API] Received 401/400 during stream. Refreshing auth and retrying...'); + await this.initializeAuth(true); + yield* this.streamApi(method, body, true, retryCount, baseURLIndex); + return; + } + + if (error.response?.status === 429) { + if (baseURLIndex + 1 < this.baseURLs.length) { + console.log(`[Antigravity API] Rate limited on ${baseURL}. Trying next base URL...`); + yield* this.streamApi(method, body, isRetry, retryCount, baseURLIndex + 1); + return; + } else if (retryCount < maxRetries) { + const delay = baseDelay * Math.pow(2, retryCount); + console.log(`[Antigravity API] Rate limited during stream. Retrying in ${delay}ms...`); + await new Promise(resolve => setTimeout(resolve, delay)); + yield* this.streamApi(method, body, isRetry, retryCount + 1, 0); + return; + } + } + + if (!error.response && baseURLIndex + 1 < this.baseURLs.length) { + console.log(`[Antigravity API] Network error on ${baseURL}. Trying next base URL...`); + yield* this.streamApi(method, body, isRetry, retryCount, baseURLIndex + 1); + return; + } + + if (error.response?.status >= 500 && error.response?.status < 600 && retryCount < maxRetries) { + const delay = baseDelay * Math.pow(2, retryCount); + console.log(`[Antigravity API] Server error ${error.response.status} during stream. Retrying in ${delay}ms...`); + await new Promise(resolve => setTimeout(resolve, delay)); + yield* this.streamApi(method, body, isRetry, retryCount + 1, baseURLIndex); + return; + } + + throw error; + } + } + + async * parseSSEStream(stream) { + const rl = readline.createInterface({ + input: stream, + crlfDelay: Infinity + }); + + let buffer = []; + for await (const line of rl) { + if (line.startsWith('data: ')) { + buffer.push(line.slice(6)); + } else if (line === '' && buffer.length > 0) { + try { + yield JSON.parse(buffer.join('\n')); + } catch (e) { + console.error('[Antigravity Stream] Failed to parse JSON chunk:', buffer.join('\n')); + } + buffer = []; + } + } + + if (buffer.length > 0) { + try { + yield JSON.parse(buffer.join('\n')); + } catch (e) { + console.error('[Antigravity Stream] Failed to parse final JSON chunk:', buffer.join('\n')); + } + } + } + + async generateContent(model, requestBody) { + console.log(`[Antigravity Auth Token] Time until expiry: ${formatExpiryTime(this.authClient.credentials.expiry_date)}`); + + let selectedModel = model; + if (!this.availableModels.includes(model)) { + console.warn(`[Antigravity] Model '${model}' not found. Using default model: '${this.availableModels[0]}'`); + selectedModel = this.availableModels[0]; + } + + // 深拷贝请求体 + const processedRequestBody = ensureRolesInContents(JSON.parse(JSON.stringify(requestBody))); + const actualModelName = alias2ModelName(selectedModel); + + // 将处理后的请求体转换为 Antigravity 格式 + const payload = geminiToAntigravity(actualModelName, { request: processedRequestBody }); + + // 设置模型名称为实际模型名 + payload.model = actualModelName; + + const response = await this.callApi('generateContent', payload); + return toGeminiApiResponse(response.response); + } + + async * generateContentStream(model, requestBody) { + console.log(`[Antigravity Auth Token] Time until expiry: ${formatExpiryTime(this.authClient.credentials.expiry_date)}`); + + let selectedModel = model; + if (!this.availableModels.includes(model)) { + console.warn(`[Antigravity] Model '${model}' not found. Using default model: '${this.availableModels[0]}'`); + selectedModel = this.availableModels[0]; + } + + // 深拷贝请求体 + const processedRequestBody = ensureRolesInContents(JSON.parse(JSON.stringify(requestBody))); + const actualModelName = alias2ModelName(selectedModel); + + // 将处理后的请求体转换为 Antigravity 格式 + const payload = geminiToAntigravity(actualModelName, { request: processedRequestBody }); + + // 设置模型名称为实际模型名 + payload.model = actualModelName; + + const stream = this.streamApi('streamGenerateContent', payload); + for await (const chunk of stream) { + yield toGeminiApiResponse(chunk.response); + } + } + + isExpiryDateNear() { + try { + const currentTime = Date.now(); + const cronNearMinutesInMillis = (this.config.CRON_NEAR_MINUTES || 10) * 60 * 1000; + console.log(`[Antigravity] Expiry date: ${this.authClient.credentials.expiry_date}, Current time: ${currentTime}, ${this.config.CRON_NEAR_MINUTES || 10} minutes from now: ${currentTime + cronNearMinutesInMillis}`); + return this.authClient.credentials.expiry_date <= (currentTime + cronNearMinutesInMillis); + } catch (error) { + console.error(`[Antigravity] Error checking expiry date: ${error.message}`); + return false; + } + } +} \ No newline at end of file diff --git a/src/oauth-handlers.js b/src/oauth-handlers.js new file mode 100644 index 0000000..bb9fa37 --- /dev/null +++ b/src/oauth-handlers.js @@ -0,0 +1,486 @@ +import { OAuth2Client } from 'google-auth-library'; +import http from 'http'; +import fs from 'fs'; +import path from 'path'; +import os from 'os'; +import crypto from 'crypto'; +import open from 'open'; +import { broadcastEvent } from './ui-manager.js'; + +/** + * OAuth 提供商配置 + */ +const OAUTH_PROVIDERS = { + 'gemini-cli-oauth': { + clientId: '681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com', + clientSecret: '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', + port: 8085, + credentialsDir: '.gemini', + credentialsFile: 'oauth_creds.json', + scope: ['https://www.googleapis.com/auth/cloud-platform'], + logPrefix: '[Gemini Auth]' + }, + 'gemini-antigravity': { + clientId: '1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com', + clientSecret: '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', + port: 8086, + credentialsDir: '.antigravity', + credentialsFile: 'oauth_creds.json', + scope: ['https://www.googleapis.com/auth/cloud-platform'], + logPrefix: '[Antigravity Auth]' + } +}; + +/** + * 活动的服务器实例管理 + */ +const activeServers = new Map(); + +/** + * 活动的轮询任务管理 + */ +const activePollingTasks = new Map(); + +/** + * Qwen OAuth 配置 + */ +const QWEN_OAUTH_CONFIG = { + clientId: 'f0304373b74a44d2b584a3fb70ca9e56', + scope: 'openid profile email model.completion', + deviceCodeEndpoint: 'https://chat.qwen.ai/api/v1/oauth2/device/code', + tokenEndpoint: 'https://chat.qwen.ai/api/v1/oauth2/token', + grantType: 'urn:ietf:params:oauth:grant-type:device_code', + credentialsDir: '.qwen', + credentialsFile: 'oauth_creds.json', + logPrefix: '[Qwen Auth]' +}; + +/** + * 生成 HTML 响应页面 + * @param {boolean} isSuccess - 是否成功 + * @param {string} message - 显示消息 + * @returns {string} HTML 内容 + */ +function generateResponsePage(isSuccess, message) { + const title = isSuccess ? '授权成功!' : '授权失败'; + + return ` + + + + + ${title} + + +
+

${title}

+

${message}

+
+ +`; +} + +/** + * 关闭指定端口的活动服务器 + * @param {number} port - 端口号 + * @returns {Promise} + */ +async function closeActiveServer(port) { + const existingServer = activeServers.get(port); + if (existingServer && existingServer.listening) { + return new Promise((resolve) => { + existingServer.close(() => { + activeServers.delete(port); + console.log(`[OAuth] 已关闭端口 ${port} 上的旧服务器`); + resolve(); + }); + }); + } +} + +/** + * 创建 OAuth 回调服务器 + * @param {Object} config - OAuth 提供商配置 + * @param {string} redirectUri - 重定向 URI + * @param {OAuth2Client} authClient - OAuth2 客户端 + * @param {string} credPath - 凭据保存路径 + * @param {string} provider - 提供商标识 + * @returns {Promise} HTTP 服务器实例 + */ +async function createOAuthCallbackServer(config, redirectUri, authClient, credPath, provider) { + // 先关闭该端口上的旧服务器 + await closeActiveServer(config.port); + + return new Promise((resolve, reject) => { + const server = http.createServer(async (req, res) => { + try { + const url = new URL(req.url, redirectUri); + const code = url.searchParams.get('code'); + const errorParam = url.searchParams.get('error'); + + if (code) { + console.log(`${config.logPrefix} 收到来自 Google 的成功回调: ${req.url}`); + + try { + const { tokens } = await authClient.getToken(code); + await fs.promises.mkdir(path.dirname(credPath), { recursive: true }); + await fs.promises.writeFile(credPath, JSON.stringify(tokens, null, 2)); + console.log(`${config.logPrefix} 新令牌已接收并保存到文件`); + + // 广播授权成功事件 + broadcastEvent('oauth_success', { + provider: provider, + credPath: credPath, + timestamp: new Date().toISOString() + }); + + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end(generateResponsePage(true, '您可以关闭此页面')); + } catch (tokenError) { + console.error(`${config.logPrefix} 获取令牌失败:`, tokenError); + res.writeHead(500, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end(generateResponsePage(false, `获取令牌失败: ${tokenError.message}`)); + } finally { + server.close(() => { + activeServers.delete(config.port); + }); + } + } else if (errorParam) { + const errorMessage = `授权失败。Google 返回错误: ${errorParam}`; + console.error(`${config.logPrefix}`, errorMessage); + + res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end(generateResponsePage(false, errorMessage)); + server.close(() => { + activeServers.delete(config.port); + }); + } else { + console.log(`${config.logPrefix} 忽略无关请求: ${req.url}`); + res.writeHead(204); + res.end(); + } + } catch (error) { + console.error(`${config.logPrefix} 处理回调时出错:`, error); + res.writeHead(500, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end(generateResponsePage(false, `服务器错误: ${error.message}`)); + + if (server.listening) { + server.close(() => { + activeServers.delete(config.port); + }); + } + } + }); + + server.on('error', (err) => { + if (err.code === 'EADDRINUSE') { + console.error(`${config.logPrefix} 端口 ${config.port} 已被占用`); + reject(new Error(`端口 ${config.port} 已被占用`)); + } else { + console.error(`${config.logPrefix} 服务器错误:`, err); + reject(err); + } + }); + + const host = 'localhost'; + server.listen(config.port, host, () => { + console.log(`${config.logPrefix} OAuth 回调服务器已启动于 ${host}:${config.port}`); + activeServers.set(config.port, server); + resolve(server); + }); + }); +} + +/** + * 处理 Google OAuth 授权(通用函数) + * @param {string} providerKey - 提供商键名 + * @param {Object} currentConfig - 当前配置对象 + * @returns {Promise} 返回授权URL和相关信息 + */ +async function handleGoogleOAuth(providerKey, currentConfig) { + const config = OAUTH_PROVIDERS[providerKey]; + if (!config) { + throw new Error(`未知的提供商: ${providerKey}`); + } + + const host = 'localhost'; + const redirectUri = `http://${host}:${config.port}`; + + const authClient = new OAuth2Client(config.clientId, config.clientSecret); + authClient.redirectUri = redirectUri; + + const authUrl = authClient.generateAuthUrl({ + access_type: 'offline', + scope: config.scope + }); + + // 启动回调服务器 + const credPath = path.join(os.homedir(), config.credentialsDir, config.credentialsFile); + + try { + await createOAuthCallbackServer(config, redirectUri, authClient, credPath, providerKey); + } catch (error) { + throw new Error(`启动回调服务器失败: ${error.message}`); + } + + return { + authUrl, + authInfo: { + provider: providerKey, + redirectUri: redirectUri, + port: config.port, + instructions: '请在浏览器中打开此链接进行授权,授权完成后会自动保存凭据文件' + } + }; +} + +/** + * 处理 Gemini CLI OAuth 授权 + * @param {Object} currentConfig - 当前配置对象 + * @returns {Promise} 返回授权URL和相关信息 + */ +export async function handleGeminiCliOAuth(currentConfig) { + return handleGoogleOAuth('gemini-cli-oauth', currentConfig); +} + +/** + * 处理 Gemini Antigravity OAuth 授权 + * @param {Object} currentConfig - 当前配置对象 + * @returns {Promise} 返回授权URL和相关信息 + */ +export async function handleGeminiAntigravityOAuth(currentConfig) { + return handleGoogleOAuth('gemini-antigravity', currentConfig); +} + +/** + * 生成 PKCE 代码验证器 + * @returns {string} Base64URL 编码的随机字符串 + */ +function generateCodeVerifier() { + return crypto.randomBytes(32).toString('base64url'); +} + +/** + * 生成 PKCE 代码挑战 + * @param {string} codeVerifier - 代码验证器 + * @returns {string} Base64URL 编码的 SHA256 哈希 + */ +function generateCodeChallenge(codeVerifier) { + const hash = crypto.createHash('sha256'); + hash.update(codeVerifier); + return hash.digest('base64url'); +} + +/** + * 停止活动的轮询任务 + * @param {string} taskId - 任务标识符 + */ +function stopPollingTask(taskId) { + const task = activePollingTasks.get(taskId); + if (task) { + task.shouldStop = true; + activePollingTasks.delete(taskId); + console.log(`${QWEN_OAUTH_CONFIG.logPrefix} 已停止轮询任务: ${taskId}`); + } +} + +/** + * 轮询获取 Qwen OAuth 令牌 + * @param {string} deviceCode - 设备代码 + * @param {string} codeVerifier - PKCE 代码验证器 + * @param {number} interval - 轮询间隔(秒) + * @param {number} expiresIn - 过期时间(秒) + * @param {string} taskId - 任务标识符 + * @returns {Promise} 返回令牌信息 + */ +async function pollQwenToken(deviceCode, codeVerifier, interval = 5, expiresIn = 300, taskId = 'default') { + const credPath = path.join(os.homedir(), QWEN_OAUTH_CONFIG.credentialsDir, QWEN_OAUTH_CONFIG.credentialsFile); + const maxAttempts = Math.floor(expiresIn / interval); + let attempts = 0; + + // 创建任务控制对象 + const taskControl = { shouldStop: false }; + activePollingTasks.set(taskId, taskControl); + + console.log(`${QWEN_OAUTH_CONFIG.logPrefix} 开始轮询令牌 [${taskId}],间隔 ${interval} 秒,最多尝试 ${maxAttempts} 次`); + + const poll = async () => { + // 检查是否需要停止 + if (taskControl.shouldStop) { + console.log(`${QWEN_OAUTH_CONFIG.logPrefix} 轮询任务 [${taskId}] 已被停止`); + throw new Error('轮询任务已被取消'); + } + + if (attempts >= maxAttempts) { + activePollingTasks.delete(taskId); + throw new Error('授权超时,请重新开始授权流程'); + } + + attempts++; + + const bodyData = { + client_id: QWEN_OAUTH_CONFIG.clientId, + device_code: deviceCode, + grant_type: QWEN_OAUTH_CONFIG.grantType, + code_verifier: codeVerifier + }; + + const formBody = Object.entries(bodyData) + .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`) + .join('&'); + + try { + const response = await fetch(QWEN_OAUTH_CONFIG.tokenEndpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Accept': 'application/json' + }, + body: formBody + }); + + const data = await response.json(); + + if (response.ok && data.access_token) { + // 成功获取令牌 + console.log(`${QWEN_OAUTH_CONFIG.logPrefix} 成功获取令牌 [${taskId}]`); + + // 保存令牌到文件 + await fs.promises.mkdir(path.dirname(credPath), { recursive: true }); + await fs.promises.writeFile(credPath, JSON.stringify(data, null, 2)); + console.log(`${QWEN_OAUTH_CONFIG.logPrefix} 令牌已保存到 ${credPath}`); + + // 清理任务 + activePollingTasks.delete(taskId); + + // 广播授权成功事件 + broadcastEvent('oauth_success', { + provider: 'openai-qwen-oauth', + credPath: credPath, + timestamp: new Date().toISOString() + }); + + return data; + } + + // 检查错误类型 + if (data.error === 'authorization_pending') { + // 用户尚未完成授权,继续轮询 + console.log(`${QWEN_OAUTH_CONFIG.logPrefix} 等待用户授权 [${taskId}]... (第 ${attempts}/${maxAttempts} 次尝试)`); + await new Promise(resolve => setTimeout(resolve, interval * 1000)); + return poll(); + } else if (data.error === 'slow_down') { + // 需要降低轮询频率 + console.log(`${QWEN_OAUTH_CONFIG.logPrefix} 降低轮询频率`); + await new Promise(resolve => setTimeout(resolve, (interval + 5) * 1000)); + return poll(); + } else if (data.error === 'expired_token') { + activePollingTasks.delete(taskId); + throw new Error('设备代码已过期,请重新开始授权流程'); + } else if (data.error === 'access_denied') { + activePollingTasks.delete(taskId); + throw new Error('用户拒绝了授权请求'); + } else { + activePollingTasks.delete(taskId); + throw new Error(`授权失败: ${data.error || '未知错误'}`); + } + } catch (error) { + if (error.message.includes('授权') || error.message.includes('过期') || error.message.includes('拒绝')) { + throw error; + } + console.error(`${QWEN_OAUTH_CONFIG.logPrefix} 轮询出错:`, error); + // 网络错误,继续重试 + await new Promise(resolve => setTimeout(resolve, interval * 1000)); + return poll(); + } + }; + + return poll(); +} + +/** + * 处理 Qwen OAuth 授权(设备授权流程) + * @param {Object} currentConfig - 当前配置对象 + * @returns {Promise} 返回授权URL和相关信息 + */ +export async function handleQwenOAuth(currentConfig) { + const codeVerifier = generateCodeVerifier(); + const codeChallenge = generateCodeChallenge(codeVerifier); + + const bodyData = { + client_id: QWEN_OAUTH_CONFIG.clientId, + scope: QWEN_OAUTH_CONFIG.scope, + code_challenge: codeChallenge, + code_challenge_method: 'S256' + }; + + const formBody = Object.entries(bodyData) + .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`) + .join('&'); + + try { + const response = await fetch(QWEN_OAUTH_CONFIG.deviceCodeEndpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Accept': 'application/json' + }, + body: formBody + }); + + if (!response.ok) { + throw new Error(`Qwen OAuth请求失败: ${response.status} ${response.statusText}`); + } + + const deviceAuth = await response.json(); + + if (!deviceAuth.device_code || !deviceAuth.verification_uri_complete) { + throw new Error('Qwen OAuth响应格式错误,缺少必要字段'); + } + + // 启动后台轮询获取令牌 + const interval = deviceAuth.interval || 5; + // const expiresIn = deviceAuth.expires_in || 1800; + const expiresIn = 300; + + // 生成唯一的任务ID + const taskId = `qwen-${deviceAuth.device_code.substring(0, 8)}-${Date.now()}`; + + // 先停止之前可能存在的所有 Qwen 轮询任务 + for (const [existingTaskId] of activePollingTasks.entries()) { + if (existingTaskId.startsWith('qwen-')) { + stopPollingTask(existingTaskId); + } + } + + // 不等待轮询完成,立即返回授权信息 + pollQwenToken(deviceAuth.device_code, codeVerifier, interval, expiresIn, taskId) + .catch(error => { + console.error(`${QWEN_OAUTH_CONFIG.logPrefix} 轮询失败 [${taskId}]:`, error); + // 广播授权失败事件 + broadcastEvent('oauth_error', { + provider: 'openai-qwen-oauth', + error: error.message, + timestamp: new Date().toISOString() + }); + }); + + return { + authUrl: deviceAuth.verification_uri_complete, + authInfo: { + provider: 'openai-qwen-oauth', + deviceCode: deviceAuth.device_code, + userCode: deviceAuth.user_code, + verificationUri: deviceAuth.verification_uri, + verificationUriComplete: deviceAuth.verification_uri_complete, + expiresIn: expiresIn, + interval: interval, + codeVerifier: codeVerifier, + instructions: '请在浏览器中打开此链接并输入用户码进行授权。授权完成后,系统会自动轮询获取访问令牌。' + } + }; + } catch (error) { + console.error(`${QWEN_OAUTH_CONFIG.logPrefix} 请求失败:`, error); + throw new Error(`Qwen OAuth 授权失败: ${error.message}`); + } +} \ No newline at end of file diff --git a/src/provider-models.js b/src/provider-models.js index 28150aa..3c1caa3 100644 --- a/src/provider-models.js +++ b/src/provider-models.js @@ -12,6 +12,13 @@ export const PROVIDER_MODELS = { 'gemini-2.5-flash-preview-09-2025', 'gemini-3-pro-preview' ], + 'gemini-antigravity': [ + 'gemini-2.5-computer-use-preview-10-2025', + 'gemini-3-pro-image-preview', + 'gemini-3-pro-preview', + 'gemini-claude-sonnet-4-5', + 'gemini-claude-sonnet-4-5-thinking' + ], 'claude-custom': [], 'claude-kiro-oauth': [ 'claude-opus-4-5', diff --git a/src/ui-manager.js b/src/ui-manager.js index af0deb9..44a7536 100644 --- a/src/ui-manager.js +++ b/src/ui-manager.js @@ -8,6 +8,7 @@ import { getAllProviderModels, getProviderModels } from './provider-models.js'; import { CONFIG } from './config-manager.js'; import { serviceInstances } from './adapter.js'; import { initApiService } from './service-manager.js'; +import { handleGeminiCliOAuth, handleGeminiAntigravityOAuth, handleQwenOAuth } from './oauth-handlers.js'; // Token存储到本地文件中 const TOKEN_STORE_FILE = 'token-store.json'; @@ -1016,6 +1017,58 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo } } + // Generate OAuth authorization URL for providers + const generateAuthUrlMatch = pathParam.match(/^\/api\/providers\/([^\/]+)\/generate-auth-url$/); + if (method === 'POST' && generateAuthUrlMatch) { + const providerType = decodeURIComponent(generateAuthUrlMatch[1]); + + try { + let authUrl = ''; + let authInfo = {}; + + // 根据提供商类型生成授权链接并启动回调服务器 + if (providerType === 'gemini-cli-oauth') { + const result = await handleGeminiCliOAuth(currentConfig); + authUrl = result.authUrl; + authInfo = result.authInfo; + } else if (providerType === 'gemini-antigravity') { + const result = await handleGeminiAntigravityOAuth(currentConfig); + authUrl = result.authUrl; + authInfo = result.authInfo; + } else if (providerType === 'openai-qwen-oauth') { + const result = await handleQwenOAuth(currentConfig); + authUrl = result.authUrl; + authInfo = result.authInfo; + } else { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + error: { + message: `不支持的提供商类型: ${providerType}` + } + })); + return true; + } + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: true, + authUrl: authUrl, + authInfo: authInfo + })); + return true; + + } catch (error) { + console.error(`[UI API] Failed to generate auth URL for ${providerType}:`, error); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + error: { + message: `生成授权链接失败: ${error.message}` + } + })); + return true; + } + } + // Server-Sent Events for real-time updates if (method === 'GET' && pathParam === '/api/events') { res.writeHead(200, { diff --git a/static/app/provider-manager.js b/static/app/provider-manager.js index 48ad031..a466eb0 100644 --- a/static/app/provider-manager.js +++ b/static/app/provider-manager.js @@ -106,6 +106,7 @@ function renderProviders(providers) { // 定义所有支持的提供商显示顺序 const providerDisplayOrder = [ 'gemini-cli-oauth', + 'gemini-antigravity', 'openai-custom', 'claude-custom', 'claude-kiro-oauth', @@ -175,9 +176,12 @@ function renderProviders(providers) {
${providerType}
-
- - ${statusText} +
+ ${generateAuthButton(providerType)} +
+ + ${statusText} +
@@ -212,6 +216,15 @@ function renderProviders(providers) { }); container.appendChild(providerDiv); + + // 为授权按钮添加事件监听 + const authBtn = providerDiv.querySelector('.generate-auth-btn'); + if (authBtn) { + authBtn.addEventListener('click', (e) => { + e.stopPropagation(); // 阻止事件冒泡到父元素 + handleGenerateAuthUrl(providerType); + }); + } }); // 更新统计卡片数据 @@ -300,6 +313,165 @@ async function openProviderManager(providerType) { } } +/** + * 生成授权按钮HTML + * @param {string} providerType - 提供商类型 + * @returns {string} 授权按钮HTML + */ +function generateAuthButton(providerType) { + // 只为支持OAuth的提供商显示授权按钮 + const oauthProviders = ['gemini-cli-oauth', 'gemini-antigravity', 'openai-qwen-oauth']; + + if (!oauthProviders.includes(providerType)) { + return ''; + } + + return ` + + `; +} + +/** + * 处理生成授权链接 + * @param {string} providerType - 提供商类型 + */ +async function handleGenerateAuthUrl(providerType) { + try { + showToast('正在生成授权链接...', 'info'); + + const response = await window.apiClient.post( + `/providers/${encodeURIComponent(providerType)}/generate-auth-url`, + {} + ); + + if (response.success && response.authUrl) { + // 显示授权信息模态框 + showAuthModal(response.authUrl, response.authInfo); + } else { + showToast('生成授权链接失败', 'error'); + } + } catch (error) { + console.error('生成授权链接失败:', error); + showToast(`生成授权链接失败: ${error.message}`, 'error'); + } +} + +/** + * 显示授权信息模态框 + * @param {string} authUrl - 授权URL + * @param {Object} authInfo - 授权信息 + */ +function showAuthModal(authUrl, authInfo) { + const modal = document.createElement('div'); + modal.className = 'modal-overlay'; + modal.style.display = 'flex'; + + let instructionsHtml = ''; + if (authInfo.provider === 'openai-qwen-oauth') { + instructionsHtml = ` +
+

授权步骤:

+
    +
  1. 点击下方按钮在浏览器中打开授权页面
  2. +
  3. 在授权页面输入用户码: ${authInfo.userCode}
  4. +
  5. 完成授权后,系统会自动获取访问令牌
  6. +
  7. 授权有效期: ${Math.floor(authInfo.expiresIn / 60)} 分钟
  8. +
+

${authInfo.instructions}

+
+ `; + } else { + instructionsHtml = ` +
+
+
+ ⚠️ 重要提醒:回调地址限制 +

OAuth回调地址的 host 必须是 localhost127.0.0.1,否则授权将无法完成!

+

当前回调地址: ${authInfo.redirectUri}

+

如果当前配置的 host 不是 localhost 或 127.0.0.1,请先修改配置后重新生成授权链接。

+
+
+

授权步骤:

+
    +
  1. 确认上方回调地址的 host 是 localhost 或 127.0.0.1
  2. +
  3. 点击下方按钮在浏览器中打开授权页面
  4. +
  5. 使用您的Google账号登录并授权
  6. +
  7. 授权完成后,凭据文件会自动保存
  8. +
+

${authInfo.instructions}

+
+ `; + } + + modal.innerHTML = ` + + `; + + document.body.appendChild(modal); + + // 关闭按钮事件 + const closeBtn = modal.querySelector('.modal-close'); + const cancelBtn = modal.querySelector('.modal-cancel'); + [closeBtn, cancelBtn].forEach(btn => { + btn.addEventListener('click', () => { + modal.remove(); + }); + }); + + // 复制链接按钮 + const copyBtn = modal.querySelector('.copy-btn'); + copyBtn.addEventListener('click', () => { + const input = modal.querySelector('.auth-url-input'); + input.select(); + document.execCommand('copy'); + showToast('授权链接已复制到剪贴板', 'success'); + }); + + // 在浏览器中打开按钮 + const openBtn = modal.querySelector('.open-auth-btn'); + openBtn.addEventListener('click', () => { + window.open(authUrl, '_blank'); + showToast('已在新标签页中打开授权页面', 'success'); + }); + + // 点击遮罩层关闭 + modal.addEventListener('click', (e) => { + if (e.target === modal) { + modal.remove(); + } + }); +} + // 导入工具函数 import { formatUptime } from './utils.js'; diff --git a/static/app/styles.css b/static/app/styles.css index 45bb74d..a8c9c56 100644 --- a/static/app/styles.css +++ b/static/app/styles.css @@ -660,6 +660,12 @@ textarea.form-control { color: var(--text-primary); } +.provider-header-right { + display: flex; + align-items: center; + gap: 0.75rem; +} + .provider-status { display: inline-flex; align-items: center; @@ -3097,3 +3103,313 @@ input:checked + .toggle-slider:before { .form-grid.full-width { grid-column: 1 / -1; } + +/* 授权按钮样式 - 与健康状态样式类似 */ +.generate-auth-btn { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.25rem 0.75rem; + background: #e0f2fe; + color: #0369a1; + border: none; + border-radius: 9999px; + font-size: 0.75rem; + font-weight: 500; + cursor: pointer; + transition: var(--transition); +} + +.generate-auth-btn:hover { + background: #bae6fd; + color: #075985; + transform: translateY(-1px); +} + +.generate-auth-btn:active { + transform: translateY(0); +} + +.generate-auth-btn i { + font-size: 0.75rem; +} + +/* 授权模态框样式 */ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(4px); + display: none; + justify-content: center; + align-items: center; + z-index: 1000; + animation: fadeIn 0.3s ease; +} + +.modal-content { + background: var(--bg-primary); + border-radius: 0.75rem; + width: 90%; + max-width: 600px; + max-height: 85vh; + overflow: hidden; + box-shadow: 0 25px 80px rgba(0, 0, 0, 0.4); + display: flex; + flex-direction: column; + animation: modalSlideIn 0.3s ease; +} + +.modal-header { + padding: 1.5rem 2rem; + border-bottom: 1px solid var(--border-color); + display: flex; + justify-content: space-between; + align-items: center; + background: linear-gradient(135deg, #f0f9ff 0%, #ffffff 100%); +} + +.modal-header h3 { + margin: 0; + font-size: 1.25rem; + font-weight: 600; + color: var(--text-primary); + display: flex; + align-items: center; + gap: 0.5rem; +} + +.modal-header h3 i { + color: var(--primary-color); +} + +.modal-close { + background: none; + border: none; + font-size: 1.5rem; + cursor: pointer; + color: #6c757d; + padding: 0.5rem; + border-radius: 50%; + transition: var(--transition); + width: 2rem; + height: 2rem; + display: flex; + align-items: center; + justify-content: center; + line-height: 1; +} + +.modal-close:hover { + background: #e9ecef; + color: #495057; + transform: rotate(90deg); +} + +.modal-body { + padding: 2rem; + flex: 1; + overflow-y: auto; +} + +.modal-footer { + padding: 1.5rem 2rem; + border-top: 1px solid var(--border-color); + display: flex; + justify-content: flex-end; + gap: 1rem; + background: linear-gradient(135deg, #f9fafb 0%, #ffffff 100%); +} + +.modal-cancel { + padding: 0.75rem 1.5rem; + background: var(--bg-tertiary); + color: var(--text-primary); + border: 1px solid var(--border-color); + border-radius: 0.375rem; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: var(--transition); +} + +.modal-cancel:hover { + background: #e5e7eb; + border-color: #9ca3af; + transform: translateY(-1px); +} + +.open-auth-btn { + padding: 0.75rem 1.5rem; + background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%); + color: white; + border: none; + border-radius: 0.375rem; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: var(--transition); + box-shadow: 0 2px 8px rgba(5, 150, 105, 0.3); + display: inline-flex; + align-items: center; + gap: 0.5rem; +} + +.open-auth-btn:hover { + background: linear-gradient(135deg, #047857 0%, #059669 100%); + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(5, 150, 105, 0.4); +} + +.open-auth-btn:active { + transform: translateY(0); +} + +.open-auth-btn i { + font-size: 0.875rem; +} + +/* 授权信息样式 */ +.auth-info { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.auth-info p { + margin: 0; + font-size: 0.875rem; + color: var(--text-secondary); +} + +.auth-info strong { + color: var(--text-primary); + font-weight: 600; +} + +.auth-instructions { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 0.5rem; + padding: 1.5rem; + border-left: 4px solid var(--primary-color); +} + +.auth-instructions h4 { + margin: 0 0 1rem 0; + font-size: 1rem; + font-weight: 600; + color: var(--text-primary); +} + +.auth-instructions ol { + margin: 0 0 1rem 1.5rem; + padding: 0; +} + +.auth-instructions li { + margin-bottom: 0.5rem; + font-size: 0.875rem; + color: var(--text-secondary); + line-height: 1.5; +} + +.auth-note { + margin: 1rem 0 0 0; + padding: 0.75rem; + background: #fef3c7; + border: 1px solid #fbbf24; + border-radius: 0.375rem; + font-size: 0.875rem; + color: #92400e; +} + +.auth-url-section { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.auth-url-section label { + font-size: 0.875rem; + font-weight: 500; + color: var(--text-primary); +} + +.auth-url-container { + position: relative; + display: flex; + align-items: center; +} + +.auth-url-input { + flex: 1; + padding: 0.75rem; + padding-right: 3rem; + border: 1px solid var(--border-color); + border-radius: 0.375rem; + font-size: 0.875rem; + font-family: 'Courier New', monospace; + background: var(--bg-tertiary); + color: var(--text-primary); +} + +.auth-url-input:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 3px rgba(5, 150, 105, 0.1); +} + +.auth-url-container .copy-btn { + position: absolute; + right: 0.5rem; + top: 50%; + transform: translateY(-50%); + background: var(--primary-color); + color: white; + border: none; + padding: 0.5rem; + border-radius: 0.25rem; + cursor: pointer; + transition: var(--transition); +} + +.auth-url-container .copy-btn:hover { + background: #047857; +} + +.open-auth-btn { + display: inline-flex; + align-items: center; + gap: 0.5rem; +} + +/* 响应式调整 */ +@media (max-width: 768px) { + .modal-content { + width: 95%; + max-height: 90vh; + } + + .modal-header, + .modal-body, + .modal-footer { + padding: 1rem 1.5rem; + } + + .auth-instructions { + padding: 1rem; + } + + .modal-footer { + flex-direction: column; + } + + .generate-auth-btn { + font-size: 0.75rem; + padding: 0.4rem 0.8rem; + } +} diff --git a/static/index.html b/static/index.html index d5c56cb..3aa5d3a 100644 --- a/static/index.html +++ b/static/index.html @@ -154,11 +154,11 @@
-
+
- -

Qwen OAuth

- 突破限制 + +

Gemini Antigravity

+ 突破限制/实验性
@@ -171,15 +171,15 @@
- /openai-qwen-oauth/v1/chat/completions + /gemini-antigravity/v1/chat/completions
-
curl http://localhost:3000/openai-qwen-oauth/v1/chat/completions \
+                                            
curl http://localhost:3000/gemini-antigravity/v1/chat/completions \
   -H "Content-Type: application/json" \
   -H "Authorization: Bearer YOUR_API_KEY" \
   -d '{
-    "model": "qwen-turbo",
+    "model": "gemini-3-pro-preview",
     "messages": [{"role": "user", "content": "Hello!"}],
     "max_tokens": 1000
   }'
@@ -190,15 +190,15 @@
- /openai-qwen-oauth/v1/messages + /gemini-antigravity/v1/messages
-
curl http://localhost:3000/openai-qwen-oauth/v1/messages \
+                                            
curl http://localhost:3000/gemini-antigravity/v1/messages \
   -H "Content-Type: application/json" \
   -H "X-API-Key: YOUR_API_KEY" \
   -d '{
-    "model": "qwen-turbo",
+    "model": "gemini-3-pro-preview",
     "max_tokens": 1000,
     "messages": [{"role": "user", "content": "Hello!"}]
   }'
@@ -365,6 +365,59 @@
+ +
+
+ +

Qwen OAuth

+ 突破限制 +
+
+ +
+ + +
+ + +
+
+ + /openai-qwen-oauth/v1/chat/completions +
+
+ +
curl http://localhost:3000/openai-qwen-oauth/v1/chat/completions \
+  -H "Content-Type: application/json" \
+  -H "Authorization: Bearer YOUR_API_KEY" \
+  -d '{
+    "model": "qwen-turbo",
+    "messages": [{"role": "user", "content": "Hello!"}],
+    "max_tokens": 1000
+  }'
+
+
+ + +
+
+ + /openai-qwen-oauth/v1/messages +
+
+ +
curl http://localhost:3000/openai-qwen-oauth/v1/messages \
+  -H "Content-Type: application/json" \
+  -H "X-API-Key: YOUR_API_KEY" \
+  -d '{
+    "model": "qwen-turbo",
+    "max_tokens": 1000,
+    "messages": [{"role": "user", "content": "Hello!"}]
+  }'
+
+
+
+
@@ -428,6 +481,7 @@ + +
+ Antigravity 使用 Google OAuth 认证,需要提供凭据文件路径 +
+
+