diff --git a/src/convert/convert.js b/src/convert/convert.js index 72dd8ab..5e6d1bc 100644 --- a/src/convert/convert.js +++ b/src/convert/convert.js @@ -44,6 +44,12 @@ export function convertData(data, type, fromProvider, toProvider, model) { const fromProtocol = getProtocolPrefix(fromProvider); const toProtocol = getProtocolPrefix(toProvider); + // 如果目标协议为 forward,直接返回原始数据,无需转换 + if (toProtocol === MODEL_PROTOCOL_PREFIX.FORWARD || fromProtocol === MODEL_PROTOCOL_PREFIX.FORWARD) { + console.log(`[Convert] Target protocol is forward, skipping conversion`); + return data; + } + // 从工厂获取转换器 const converter = ConverterFactory.getConverter(fromProtocol); diff --git a/src/providers/adapter.js b/src/providers/adapter.js index 2a0d7a2..33cbc60 100644 --- a/src/providers/adapter.js +++ b/src/providers/adapter.js @@ -7,6 +7,7 @@ import { KiroApiService } from './claude/claude-kiro.js'; // 导入KiroApiServic import { QwenApiService } from './openai/qwen-core.js'; // 导入QwenApiService import { IFlowApiService } from './openai/iflow-core.js'; // 导入IFlowApiService import { CodexApiService } from './openai/codex-core.js'; // 导入CodexApiService +import { ForwardApiService } from './forward/forward-core.js'; // 导入ForwardApiService import { MODEL_PROVIDER } from '../utils/common.js'; // 导入 MODEL_PROVIDER // 定义AI服务适配器接口 @@ -568,6 +569,38 @@ export class CodexApiServiceAdapter extends ApiServiceAdapter { } } +// Forward API 服务适配器 +export class ForwardApiServiceAdapter extends ApiServiceAdapter { + constructor(config) { + super(); + this.forwardApiService = new ForwardApiService(config); + } + + async generateContent(model, requestBody) { + return this.forwardApiService.generateContent(model, requestBody); + } + + async *generateContentStream(model, requestBody) { + yield* this.forwardApiService.generateContentStream(model, requestBody); + } + + async listModels() { + return this.forwardApiService.listModels(); + } + + async refreshToken() { + return Promise.resolve(); + } + + async forceRefreshToken() { + return Promise.resolve(); + } + + isExpiryDateNear() { + return false; + } +} + // 用于存储服务适配器单例的映射 export const serviceInstances = {}; @@ -606,6 +639,9 @@ export function getServiceAdapter(config) { case MODEL_PROVIDER.CODEX_API: serviceInstances[providerKey] = new CodexApiServiceAdapter(config); break; + case MODEL_PROVIDER.FORWARD_API: + serviceInstances[providerKey] = new ForwardApiServiceAdapter(config); + break; default: throw new Error(`Unsupported model provider: ${provider}`); } diff --git a/src/providers/forward/forward-core.js b/src/providers/forward/forward-core.js new file mode 100644 index 0000000..56463f9 --- /dev/null +++ b/src/providers/forward/forward-core.js @@ -0,0 +1,165 @@ +import axios from 'axios'; +import * as http from 'http'; +import * as https from 'https'; +import { configureAxiosProxy } from '../../utils/proxy-utils.js'; +import { isRetryableNetworkError } from '../../utils/common.js'; + +/** + * ForwardApiService - A provider that forwards requests to a specified API endpoint. + * Transparently passes all parameters and includes an API key in the headers. + */ +export class ForwardApiService { + constructor(config) { + if (!config.FORWARD_API_KEY) { + throw new Error("API Key is required for ForwardApiService (FORWARD_API_KEY)."); + } + if (!config.FORWARD_BASE_URL) { + throw new Error("Base URL is required for ForwardApiService (FORWARD_BASE_URL)."); + } + + this.config = config; + this.apiKey = config.FORWARD_API_KEY; + this.baseUrl = config.FORWARD_BASE_URL; + this.useSystemProxy = config?.USE_SYSTEM_PROXY_FORWARD ?? false; + this.headerName = config?.FORWARD_HEADER_NAME || 'Authorization'; + this.headerValuePrefix = config?.FORWARD_HEADER_VALUE_PREFIX || 'Bearer '; + + console.log(`[Forward] Base URL: ${this.baseUrl}, System proxy ${this.useSystemProxy ? 'enabled' : 'disabled'}`); + + const httpAgent = new http.Agent({ + keepAlive: true, + maxSockets: 100, + maxFreeSockets: 5, + timeout: 120000, + }); + const httpsAgent = new https.Agent({ + keepAlive: true, + maxSockets: 100, + maxFreeSockets: 5, + timeout: 120000, + }); + + const headers = { + 'Content-Type': 'application/json' + }; + headers[this.headerName] = `${this.headerValuePrefix}${this.apiKey}`; + + const axiosConfig = { + baseURL: this.baseUrl, + httpAgent, + httpsAgent, + headers, + }; + + if (!this.useSystemProxy) { + axiosConfig.proxy = false; + } + + configureAxiosProxy(axiosConfig, config, 'forward-custom'); + + this.axiosInstance = axios.create(axiosConfig); + } + + async callApi(endpoint, body, isRetry = false, retryCount = 0) { + const maxRetries = this.config.REQUEST_MAX_RETRIES || 3; + const baseDelay = this.config.REQUEST_BASE_DELAY || 1000; + + try { + const response = await this.axiosInstance.post(endpoint, body); + return response.data; + } catch (error) { + const status = error.response?.status; + const data = error.response?.data; + const errorCode = error.code; + const errorMessage = error.message || ''; + const isNetworkError = isRetryableNetworkError(error); + + if (status === 401 || status === 403) { + console.error(`[Forward API] Received ${status}. API Key might be invalid or expired.`); + throw error; + } + + if ((status === 429 || (status >= 500 && status < 600) || isNetworkError) && retryCount < maxRetries) { + const delay = baseDelay * Math.pow(2, retryCount); + console.log(`[Forward API] Error ${status || errorCode}. Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`); + await new Promise(resolve => setTimeout(resolve, delay)); + return this.callApi(endpoint, body, isRetry, retryCount + 1); + } + + console.error(`[Forward API] Error calling API (Status: ${status}, Code: ${errorCode}):`, data || error.message); + throw error; + } + } + + async *streamApi(endpoint, body, isRetry = false, retryCount = 0) { + const maxRetries = this.config.REQUEST_MAX_RETRIES || 3; + const baseDelay = this.config.REQUEST_BASE_DELAY || 1000; + + try { + const response = await this.axiosInstance.post(endpoint, body, { + responseType: 'stream' + }); + + const stream = response.data; + let buffer = ''; + + for await (const chunk of stream) { + buffer += chunk.toString(); + let newlineIndex; + while ((newlineIndex = buffer.indexOf('\n')) !== -1) { + const line = buffer.substring(0, newlineIndex).trim(); + buffer = buffer.substring(newlineIndex + 1); + + if (line.startsWith('data: ')) { + const jsonData = line.substring(6).trim(); + if (jsonData === '[DONE]') { + return; + } + try { + const parsedChunk = JSON.parse(jsonData); + yield parsedChunk; + } catch (e) { + // If it's not JSON, it might be a different format, but for a forwarder we try to parse common SSE formats + console.warn("[ForwardApiService] Failed to parse stream chunk JSON:", e.message, "Data:", jsonData); + } + } + } + } + } catch (error) { + const status = error.response?.status; + const errorCode = error.code; + const isNetworkError = isRetryableNetworkError(error); + + if ((status === 429 || (status >= 500 && status < 600) || isNetworkError) && retryCount < maxRetries) { + const delay = baseDelay * Math.pow(2, retryCount); + console.log(`[Forward API] Stream error ${status || errorCode}. Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`); + await new Promise(resolve => setTimeout(resolve, delay)); + yield* this.streamApi(endpoint, body, isRetry, retryCount + 1); + return; + } + + throw error; + } + } + + async generateContent(model, requestBody) { + // Transparently pass the endpoint if provided in requestBody, otherwise use default + const endpoint = requestBody.endpoint || ''; + return this.callApi(endpoint, requestBody); + } + + async *generateContentStream(model, requestBody) { + const endpoint = requestBody.endpoint || ''; + yield* this.streamApi(endpoint, requestBody); + } + + async listModels() { + try { + const response = await this.axiosInstance.get('/models'); + return response.data; + } catch (error) { + console.error(`Error listing Forward models:`, error.message); + return { data: [] }; + } + } +} diff --git a/src/providers/forward/forward-strategy.js b/src/providers/forward/forward-strategy.js new file mode 100644 index 0000000..92b7486 --- /dev/null +++ b/src/providers/forward/forward-strategy.js @@ -0,0 +1,53 @@ +import { ProviderStrategy } from '../../utils/provider-strategy.js'; + +/** + * Forward provider strategy implementation. + * Designed to be as transparent as possible. + */ +class ForwardStrategy extends ProviderStrategy { + extractModelAndStreamInfo(req, requestBody) { + const model = requestBody.model || 'default'; + const isStream = requestBody.stream === true; + return { model, isStream }; + } + + extractResponseText(response) { + // Attempt to extract text using common patterns (OpenAI, Claude, etc.) + if (response.choices && response.choices.length > 0) { + const choice = response.choices[0]; + if (choice.message && choice.message.content) { + return choice.message.content; + } else if (choice.delta && choice.delta.content) { + return choice.delta.content; + } + } + if (response.content && Array.isArray(response.content)) { + return response.content.map(c => c.text || '').join(''); + } + return ''; + } + + extractPromptText(requestBody) { + if (requestBody.messages && requestBody.messages.length > 0) { + const lastMessage = requestBody.messages[requestBody.messages.length - 1]; + let content = lastMessage.content; + if (typeof content === 'object' && content !== null) { + return JSON.stringify(content); + } + return content; + } + return ''; + } + + async applySystemPromptFromFile(config, requestBody) { + // For forwarder, we might want to skip automatic system prompt application + // to keep it transparent, but let's follow the base implementation just in case. + return requestBody; + } + + async manageSystemPrompt(requestBody) { + // No-op for transparency + } +} + +export { ForwardStrategy }; diff --git a/src/providers/provider-models.js b/src/providers/provider-models.js index db55437..aae4335 100644 --- a/src/providers/provider-models.js +++ b/src/providers/provider-models.js @@ -72,7 +72,8 @@ export const PROVIDER_MODELS = { 'gpt-5.1-codex-max', 'gpt-5.2', 'gpt-5.2-codex' - ] + ], + 'forward-api': [] }; /** diff --git a/src/providers/provider-pool-manager.js b/src/providers/provider-pool-manager.js index e1b5654..353734f 100644 --- a/src/providers/provider-pool-manager.js +++ b/src/providers/provider-pool-manager.js @@ -14,13 +14,14 @@ export class ProviderPoolManager { static DEFAULT_HEALTH_CHECK_MODELS = { 'gemini-cli-oauth': 'gemini-2.5-flash', 'gemini-antigravity': 'gemini-2.5-flash', - 'openai-custom': 'gpt-3.5-turbo', + 'openai-custom': 'gpt-4o-mini', 'claude-custom': 'claude-3-7-sonnet-20250219', 'claude-kiro-oauth': 'claude-haiku-4-5', 'openai-qwen-oauth': 'qwen3-coder-flash', 'openai-iflow': 'qwen3-coder-plus', 'openai-codex-oauth': 'gpt-5-codex-mini', - 'openaiResponses-custom': 'gpt-4o-mini' + 'openaiResponses-custom': 'gpt-4o-mini', + 'forward-api': 'gpt-4o-mini', }; constructor(providerPools, options = {}) { diff --git a/src/services/api-manager.js b/src/services/api-manager.js index 67e99f2..513b35b 100644 --- a/src/services/api-manager.js +++ b/src/services/api-manager.js @@ -36,20 +36,20 @@ export async function handleAPIRequests(method, path, req, res, currentConfig, a // Route content generation requests if (method === 'POST') { if (path === '/v1/chat/completions') { - await handleContentGenerationRequest(req, res, apiService, ENDPOINT_TYPE.OPENAI_CHAT, currentConfig, promptLogFilename, providerPoolManager, currentConfig.uuid); + await handleContentGenerationRequest(req, res, apiService, ENDPOINT_TYPE.OPENAI_CHAT, currentConfig, promptLogFilename, providerPoolManager, currentConfig.uuid, path); return true; } if (path === '/v1/responses') { - await handleContentGenerationRequest(req, res, apiService, ENDPOINT_TYPE.OPENAI_RESPONSES, currentConfig, promptLogFilename, providerPoolManager, currentConfig.uuid); + await handleContentGenerationRequest(req, res, apiService, ENDPOINT_TYPE.OPENAI_RESPONSES, currentConfig, promptLogFilename, providerPoolManager, currentConfig.uuid, path); return true; } const geminiUrlPattern = new RegExp(`/v1beta/models/(.+?):(${API_ACTIONS.GENERATE_CONTENT}|${API_ACTIONS.STREAM_GENERATE_CONTENT})`); if (geminiUrlPattern.test(path)) { - await handleContentGenerationRequest(req, res, apiService, ENDPOINT_TYPE.GEMINI_CONTENT, currentConfig, promptLogFilename, providerPoolManager, currentConfig.uuid); + await handleContentGenerationRequest(req, res, apiService, ENDPOINT_TYPE.GEMINI_CONTENT, currentConfig, promptLogFilename, providerPoolManager, currentConfig.uuid, path); return true; } if (path === '/v1/messages') { - await handleContentGenerationRequest(req, res, apiService, ENDPOINT_TYPE.CLAUDE_MESSAGE, currentConfig, promptLogFilename, providerPoolManager, currentConfig.uuid); + await handleContentGenerationRequest(req, res, apiService, ENDPOINT_TYPE.CLAUDE_MESSAGE, currentConfig, promptLogFilename, providerPoolManager, currentConfig.uuid, path); return true; } } diff --git a/src/services/service-manager.js b/src/services/service-manager.js index 84786d2..c47c4b6 100644 --- a/src/services/service-manager.js +++ b/src/services/service-manager.js @@ -395,7 +395,8 @@ export async function getProviderStatus(config, options = {}) { 'claude-kiro-oauth': 'KIRO_OAUTH_CREDS_FILE_PATH', 'openai-qwen-oauth': 'QWEN_OAUTH_CREDS_FILE_PATH', 'gemini-antigravity': 'ANTIGRAVITY_OAUTH_CREDS_FILE_PATH', - 'openai-iflow': 'IFLOW_TOKEN_FILE_PATH' + 'openai-iflow': 'IFLOW_TOKEN_FILE_PATH', + 'forward-api': 'FORWARD_BASE_URL' }; let providerPoolsSlim = []; let unhealthyProvideIdentifyList = []; diff --git a/src/ui-modules/provider-api.js b/src/ui-modules/provider-api.js index 2b1adcc..96ee31d 100644 --- a/src/ui-modules/provider-api.js +++ b/src/ui-modules/provider-api.js @@ -425,14 +425,16 @@ export async function handleResetProviderHealth(req, res, currentConfig, provide let resetCount = 0; providers.forEach(provider => { + // 统计 isHealthy 从 false 变为 true 的节点数量 if (!provider.isHealthy) { - provider.isHealthy = true; - provider.errorCount = 0; - provider.refreshCount = 0; - provider.needsRefresh = false; - provider.lastErrorTime = null; resetCount++; } + // 重置所有节点的状态 + provider.isHealthy = true; + provider.errorCount = 0; + provider.refreshCount = 0; + provider.needsRefresh = false; + provider.lastErrorTime = null; }); // Save to file diff --git a/src/utils/common.js b/src/utils/common.js index 1a1b6b2..547591d 100644 --- a/src/utils/common.js +++ b/src/utils/common.js @@ -56,6 +56,7 @@ export const MODEL_PROTOCOL_PREFIX = { CLAUDE: 'claude', OLLAMA: 'ollama', CODEX: 'codex', + FORWARD: 'forward', } export const MODEL_PROVIDER = { @@ -69,6 +70,7 @@ export const MODEL_PROVIDER = { QWEN_API: 'openai-qwen-oauth', IFLOW_API: 'openai-iflow', CODEX_API: 'openai-codex-oauth', + FORWARD_API: 'forward-api', } /** @@ -646,8 +648,9 @@ export async function handleModelListRequest(req, res, service, endpointType, CO * @param {Object} CONFIG - The server configuration object. * @param {string} PROMPT_LOG_FILENAME - The prompt log filename. */ -export async function handleContentGenerationRequest(req, res, service, endpointType, CONFIG, PROMPT_LOG_FILENAME, providerPoolManager, pooluuid) { +export async function handleContentGenerationRequest(req, res, service, endpointType, CONFIG, PROMPT_LOG_FILENAME, providerPoolManager, pooluuid, requestPath = null) { const originalRequestBody = await getRequestBody(req); + if (!originalRequestBody) { throw new Error("Request body is missing for content generation."); } @@ -711,6 +714,12 @@ export async function handleContentGenerationRequest(req, res, service, endpoint } else { console.log(`[Request Convert] Request format matches backend provider. No conversion needed.`); } + + // 为 forward provider 添加原始请求路径作为 endpoint + if (requestPath && toProvider === MODEL_PROVIDER.FORWARD_API) { + console.log(`[Forward API] Request path: ${requestPath}`); + processedRequestBody.endpoint = requestPath; + } // 3. Apply system prompt from file if configured. processedRequestBody = await _applySystemPromptFromFile(CONFIG, processedRequestBody, toProvider); diff --git a/src/utils/provider-strategies.js b/src/utils/provider-strategies.js index 89599d0..976c950 100644 --- a/src/utils/provider-strategies.js +++ b/src/utils/provider-strategies.js @@ -3,6 +3,7 @@ import { GeminiStrategy } from '../providers/gemini/gemini-strategy.js'; import { OpenAIStrategy } from '../providers/openai/openai-strategy.js'; import { ClaudeStrategy } from '../providers/claude/claude-strategy.js'; import { ResponsesAPIStrategy } from '../providers/openai/openai-responses-strategy.js'; +import { ForwardStrategy } from '../providers/forward/forward-strategy.js'; /** * Strategy factory that returns the appropriate strategy instance based on the provider protocol. @@ -21,6 +22,8 @@ class ProviderStrategyFactory { case MODEL_PROTOCOL_PREFIX.CODEX: // Codex 使用 OpenAI 策略(因为它基于 OpenAI 格式) return new OpenAIStrategy(); + case MODEL_PROTOCOL_PREFIX.FORWARD: + return new ForwardStrategy(); default: throw new Error(`Unsupported provider protocol: ${providerProtocol}`); } diff --git a/static/app/i18n.js b/static/app/i18n.js index a1adc79..10baa93 100644 --- a/static/app/i18n.js +++ b/static/app/i18n.js @@ -428,6 +428,29 @@ const translations = { 'modal.provider.refreshUuidConfirm': '确定要刷新此提供商的uuid吗?\n\n旧uuid: {oldUuid}\n\n刷新后将生成新的uuid,请确保没有其他系统依赖此uuid。', 'modal.provider.refreshUuid.success': 'uuid刷新成功\n\n旧uuid: {oldUuid}\n新uuid: {newUuid}', 'modal.provider.refreshUuid.failed': 'uuid刷新失败', + 'modal.provider.field.projectId': '项目 ID', + 'modal.provider.field.oauthPath': 'OAuth 凭据文件路径', + 'modal.provider.field.baseUrl': 'Base URL', + 'modal.provider.field.refreshUrl': 'Refresh URL', + 'modal.provider.field.refreshIdcUrl': 'Refresh IDC URL', + 'modal.provider.field.oauthBaseUrl': 'OAuth Base URL', + 'modal.provider.field.dailyBaseUrl': 'Daily Base URL', + 'modal.provider.field.autopushBaseUrl': 'Autopush Base URL', + 'modal.provider.field.headerName': 'Header 名称', + 'modal.provider.field.headerPrefix': 'Header 值前缀', + 'modal.provider.field.useSystemProxy': '使用系统代理', + 'modal.provider.field.apiKey': 'API 密钥', + 'modal.provider.field.apiKey.placeholder': '请输入 API 密钥', + 'modal.provider.field.projectId.placeholder': 'Google Cloud 项目 ID', + 'modal.provider.field.projectId.optional.placeholder': 'Google Cloud 项目 ID (留空自动发现)', + 'modal.provider.field.oauthPath.gemini.placeholder': '例如: ~/.gemini/oauth_creds.json', + 'modal.provider.field.oauthPath.kiro.placeholder': '例如: ~/.aws/sso/cache/kiro-auth-token.json', + 'modal.provider.field.oauthPath.qwen.placeholder': '例如: ~/.qwen/oauth_creds.json', + 'modal.provider.field.oauthPath.antigravity.placeholder': '例如: ~/.antigravity/oauth_creds.json', + 'modal.provider.field.oauthPath.iflow.placeholder': '例如: configs/iflow/oauth_creds.json', + 'modal.provider.field.oauthPath.codex.placeholder': '例如: configs/codex/oauth_creds.json', + 'modal.provider.field.email': '邮箱', + 'modal.provider.field.email.placeholder': '你的邮箱@example.com', 'modal.provider.load.failed': '加载提供商详情失败', 'modal.provider.auth.initializing': '正在初始化凭据生成...', @@ -1112,6 +1135,29 @@ const translations = { 'modal.provider.refreshUuidConfirm': 'Are you sure you want to refresh the uuid for this provider?\n\nOld uuid: {oldUuid}\n\nA new uuid will be generated. Make sure no other systems depend on this uuid.', 'modal.provider.refreshUuid.success': 'uuid refreshed successfully\n\nOld uuid: {oldUuid}\nNew uuid: {newUuid}', 'modal.provider.refreshUuid.failed': 'Failed to refresh uuid', + 'modal.provider.field.projectId': 'Project ID', + 'modal.provider.field.oauthPath': 'OAuth Credentials File Path', + 'modal.provider.field.baseUrl': 'Base URL', + 'modal.provider.field.refreshUrl': 'Refresh URL', + 'modal.provider.field.refreshIdcUrl': 'Refresh IDC URL', + 'modal.provider.field.oauthBaseUrl': 'OAuth Base URL', + 'modal.provider.field.dailyBaseUrl': 'Daily Base URL', + 'modal.provider.field.autopushBaseUrl': 'Autopush Base URL', + 'modal.provider.field.headerName': 'Header Name', + 'modal.provider.field.headerPrefix': 'Header Value Prefix', + 'modal.provider.field.useSystemProxy': 'Use System Proxy', + 'modal.provider.field.apiKey': 'API Key', + 'modal.provider.field.apiKey.placeholder': 'Please enter API Key', + 'modal.provider.field.projectId.placeholder': 'Google Cloud Project ID', + 'modal.provider.field.projectId.optional.placeholder': 'Google Cloud Project ID (Leave blank for discovery)', + 'modal.provider.field.oauthPath.gemini.placeholder': 'e.g.: ~/.gemini/oauth_creds.json', + 'modal.provider.field.oauthPath.kiro.placeholder': 'e.g.: ~/.aws/sso/cache/kiro-auth-token.json', + 'modal.provider.field.oauthPath.qwen.placeholder': 'e.g.: ~/.qwen/oauth_creds.json', + 'modal.provider.field.oauthPath.antigravity.placeholder': 'e.g.: ~/.antigravity/oauth_creds.json', + 'modal.provider.field.oauthPath.iflow.placeholder': 'e.g.: configs/iflow/oauth_creds.json', + 'modal.provider.field.oauthPath.codex.placeholder': 'e.g.: configs/codex/oauth_creds.json', + 'modal.provider.field.email': 'Email', + 'modal.provider.field.email.placeholder': 'your-email@example.com', 'modal.provider.load.failed': 'Failed to load provider details', 'modal.provider.auth.initializing': 'Initializing credential generation...', diff --git a/static/app/modal.js b/static/app/modal.js index 1c0a26f..9281b8d 100644 --- a/static/app/modal.js +++ b/static/app/modal.js @@ -686,7 +686,8 @@ function getFieldOrder(provider) { 'claude-kiro-oauth': ['KIRO_OAUTH_CREDS_FILE_PATH', 'KIRO_BASE_URL', 'KIRO_REFRESH_URL', 'KIRO_REFRESH_IDC_URL'], 'openai-qwen-oauth': ['QWEN_OAUTH_CREDS_FILE_PATH', 'QWEN_BASE_URL', 'QWEN_OAUTH_BASE_URL'], 'gemini-antigravity': ['PROJECT_ID', 'ANTIGRAVITY_OAUTH_CREDS_FILE_PATH', 'ANTIGRAVITY_BASE_URL_DAILY', 'ANTIGRAVITY_BASE_URL_AUTOPUSH'], - 'openai-iflow': ['IFLOW_OAUTH_CREDS_FILE_PATH', 'IFLOW_BASE_URL'] + 'openai-iflow': ['IFLOW_OAUTH_CREDS_FILE_PATH', 'IFLOW_BASE_URL'], + 'forward-api': ['FORWARD_API_KEY', 'FORWARD_BASE_URL', 'FORWARD_HEADER_NAME', 'FORWARD_HEADER_VALUE_PREFIX'] }; // 尝试从全局或当前模态框上下文中推断提供商类型 @@ -706,6 +707,8 @@ function getFieldOrder(provider) { providerType = 'gemini-antigravity'; } else if (provider.IFLOW_OAUTH_CREDS_FILE_PATH) { providerType = 'openai-iflow'; + } else if (provider.FORWARD_API_KEY) { + providerType = 'forward-api'; } } diff --git a/static/app/provider-manager.js b/static/app/provider-manager.js index 3e3ad32..96f97f6 100644 --- a/static/app/provider-manager.js +++ b/static/app/provider-manager.js @@ -205,31 +205,44 @@ function renderProviders(providers) { // 始终显示统计卡片 if (statsGrid) statsGrid.style.display = 'grid'; - // 定义所有支持的提供商显示顺序 - const providerDisplayOrder = [ - 'gemini-cli-oauth', - 'gemini-antigravity', - 'openai-custom', - 'claude-custom', - 'claude-kiro-oauth', - 'openai-qwen-oauth', - 'openaiResponses-custom', - 'openai-iflow', - 'openai-codex-oauth' + // 定义所有支持的提供商配置(顺序、显示名称、是否显示) + const providerConfigs = [ + { id: 'forward-api', name: 'NewAPI', visible: false }, + { id: 'gemini-cli-oauth', name: 'Gemini CLI OAuth', visible: true }, + { id: 'gemini-antigravity', name: 'Gemini Antigravity', visible: true }, + { id: 'openai-custom', name: 'OpenAI Custom', visible: true }, + { id: 'claude-custom', name: 'Claude Custom', visible: true }, + { id: 'claude-kiro-oauth', name: 'Claude Kiro OAuth', visible: true }, + { id: 'openai-qwen-oauth', name: 'OpenAI Qwen OAuth', visible: true }, + { id: 'openaiResponses-custom', name: 'OpenAI Responses', visible: true }, + { id: 'openai-iflow', name: 'OpenAI iFlow', visible: true }, + { id: 'openai-codex-oauth', name: 'OpenAI Codex OAuth', visible: true }, ]; + // 提取显示的 ID 顺序 + const providerDisplayOrder = providerConfigs.filter(c => c.visible !== false).map(c => c.id); + + // 建立 ID 到配置的映射,方便获取显示名称 + const configMap = providerConfigs.reduce((map, config) => { + map[config.id] = config; + return map; + }, {}); + // 获取所有提供商类型并按指定顺序排序 // 优先显示预定义的所有提供商类型,即使某些提供商没有数据也要显示 let allProviderTypes; if (hasProviders) { // 合并预定义类型和实际存在的类型,确保显示所有预定义提供商 const actualProviderTypes = Object.keys(providers); + // 只保留配置中标记为 visible 的,或者不在配置中的(默认显示) allProviderTypes = [...new Set([...providerDisplayOrder, ...actualProviderTypes])]; } else { allProviderTypes = providerDisplayOrder; } + + // 过滤掉明确设置为不显示的提供商 const sortedProviderTypes = providerDisplayOrder.filter(type => allProviderTypes.includes(type)) - .concat(allProviderTypes.filter(type => !providerDisplayOrder.includes(type))); + .concat(allProviderTypes.filter(type => !providerDisplayOrder.some(t => t === type) && !configMap[type]?.visible === false)); // 计算总统计 let totalAccounts = 0; @@ -237,6 +250,11 @@ function renderProviders(providers) { // 按照排序后的提供商类型渲染 sortedProviderTypes.forEach((providerType) => { + // 如果配置中明确设置为不显示,则跳过 + if (configMap[providerType] && configMap[providerType].visible === false) { + return; + } + const accounts = hasProviders ? providers[providerType] || [] : []; const providerDiv = document.createElement('div'); providerDiv.className = 'provider-item'; @@ -275,10 +293,13 @@ function renderProviders(providers) { const statusIcon = isEmptyState ? 'fa-info-circle' : (healthyCount === totalCount ? 'fa-check-circle' : 'fa-exclamation-triangle'); const statusText = isEmptyState ? t('providers.status.empty') : t('providers.status.healthy', { healthy: healthyCount, total: totalCount }); + // 获取显示名称 + const displayName = configMap[providerType]?.name || providerType; + providerDiv.innerHTML = `
- ${providerType} + ${displayName}
${generateAuthButton(providerType)} diff --git a/static/app/utils.js b/static/app/utils.js index 6236e2f..e8a809d 100644 --- a/static/app/utils.js +++ b/static/app/utils.js @@ -68,7 +68,6 @@ function showToast(title, message, type = 'info') { * @returns {string} 显示文案 */ function getFieldLabel(key) { - const isEn = getCurrentLanguage() === 'en-US'; const labelMap = { 'customName': t('modal.provider.customName') + ' ' + t('config.optional'), 'checkModelName': t('modal.provider.checkModelName') + ' ' + t('config.optional'), @@ -77,21 +76,27 @@ function getFieldLabel(key) { 'OPENAI_BASE_URL': 'OpenAI Base URL', 'CLAUDE_API_KEY': 'Claude API Key', 'CLAUDE_BASE_URL': 'Claude Base URL', - 'PROJECT_ID': isEn ? 'Project ID' : '项目ID', - 'GEMINI_OAUTH_CREDS_FILE_PATH': isEn ? 'OAuth Credentials File Path' : 'OAuth凭据文件路径', - 'KIRO_OAUTH_CREDS_FILE_PATH': isEn ? 'OAuth Credentials File Path' : 'OAuth凭据文件路径', - 'QWEN_OAUTH_CREDS_FILE_PATH': isEn ? 'OAuth Credentials File Path' : 'OAuth凭据文件路径', - 'ANTIGRAVITY_OAUTH_CREDS_FILE_PATH': isEn ? 'OAuth Credentials File Path' : 'OAuth凭据文件路径', - 'IFLOW_OAUTH_CREDS_FILE_PATH': isEn ? 'OAuth Credentials File Path' : 'OAuth凭据文件路径', + 'PROJECT_ID': t('modal.provider.field.projectId'), + 'GEMINI_OAUTH_CREDS_FILE_PATH': t('modal.provider.field.oauthPath'), + 'KIRO_OAUTH_CREDS_FILE_PATH': t('modal.provider.field.oauthPath'), + 'QWEN_OAUTH_CREDS_FILE_PATH': t('modal.provider.field.oauthPath'), + 'ANTIGRAVITY_OAUTH_CREDS_FILE_PATH': t('modal.provider.field.oauthPath'), + 'IFLOW_OAUTH_CREDS_FILE_PATH': t('modal.provider.field.oauthPath'), + 'CODEX_OAUTH_CREDS_FILE_PATH': t('modal.provider.field.oauthPath'), 'GEMINI_BASE_URL': 'Gemini Base URL', - 'KIRO_BASE_URL': 'Base URL', - 'KIRO_REFRESH_URL': 'Refresh URL', - 'KIRO_REFRESH_IDC_URL': 'Refresh IDC URL', + 'KIRO_BASE_URL': t('modal.provider.field.baseUrl'), + 'KIRO_REFRESH_URL': t('modal.provider.field.refreshUrl'), + 'KIRO_REFRESH_IDC_URL': t('modal.provider.field.refreshIdcUrl'), 'QWEN_BASE_URL': 'Qwen Base URL', - 'QWEN_OAUTH_BASE_URL': 'OAuth Base URL', - 'ANTIGRAVITY_BASE_URL_DAILY': 'Daily Base URL', - 'ANTIGRAVITY_BASE_URL_AUTOPUSH': 'Autopush Base URL', - 'IFLOW_BASE_URL': 'iFlow Base URL' + 'QWEN_OAUTH_BASE_URL': t('modal.provider.field.oauthBaseUrl'), + 'ANTIGRAVITY_BASE_URL_DAILY': t('modal.provider.field.dailyBaseUrl'), + 'ANTIGRAVITY_BASE_URL_AUTOPUSH': t('modal.provider.field.autopushBaseUrl'), + 'IFLOW_BASE_URL': 'iFlow Base URL', + 'FORWARD_API_KEY': 'Forward API Key', + 'FORWARD_BASE_URL': 'Forward Base URL', + 'FORWARD_HEADER_NAME': t('modal.provider.field.headerName'), + 'FORWARD_HEADER_VALUE_PREFIX': t('modal.provider.field.headerPrefix'), + 'USE_SYSTEM_PROXY_FORWARD': t('modal.provider.field.useSystemProxy') }; return labelMap[key] || key; @@ -103,12 +108,11 @@ function getFieldLabel(key) { * @returns {Array} 字段配置数组 */ function getProviderTypeFields(providerType) { - const isEn = getCurrentLanguage() === 'en-US'; const fieldConfigs = { 'openai-custom': [ { id: 'OPENAI_API_KEY', - label: 'OpenAI API Key', + label: t('modal.provider.field.apiKey'), type: 'password', placeholder: 'sk-...' }, @@ -122,7 +126,7 @@ function getProviderTypeFields(providerType) { 'openaiResponses-custom': [ { id: 'OPENAI_API_KEY', - label: 'OpenAI API Key', + label: t('modal.provider.field.apiKey'), type: 'password', placeholder: 'sk-...' }, @@ -150,15 +154,15 @@ function getProviderTypeFields(providerType) { 'gemini-cli-oauth': [ { id: 'PROJECT_ID', - label: isEn ? 'Project ID' : '项目ID', + label: t('modal.provider.field.projectId'), type: 'text', - placeholder: isEn ? 'Google Cloud Project ID' : 'Google Cloud项目ID' + placeholder: t('modal.provider.field.projectId.placeholder') }, { id: 'GEMINI_OAUTH_CREDS_FILE_PATH', - label: isEn ? 'OAuth Credentials File Path' : 'OAuth凭据文件路径', + label: t('modal.provider.field.oauthPath'), type: 'text', - placeholder: isEn ? 'e.g.: ~/.gemini/oauth_creds.json' : '例如: ~/.gemini/oauth_creds.json' + placeholder: t('modal.provider.field.oauthPath.gemini.placeholder') }, { id: 'GEMINI_BASE_URL', @@ -170,25 +174,25 @@ function getProviderTypeFields(providerType) { 'claude-kiro-oauth': [ { id: 'KIRO_OAUTH_CREDS_FILE_PATH', - label: isEn ? 'OAuth Credentials File Path' : 'OAuth凭据文件路径', + label: t('modal.provider.field.oauthPath'), type: 'text', - placeholder: isEn ? 'e.g.: ~/.aws/sso/cache/kiro-auth-token.json' : '例如: ~/.aws/sso/cache/kiro-auth-token.json' + placeholder: t('modal.provider.field.oauthPath.kiro.placeholder') }, { id: 'KIRO_BASE_URL', - label: `Base URL ${t('config.optional')}`, + label: `${t('modal.provider.field.baseUrl')} ${t('config.optional')}`, type: 'text', placeholder: 'https://codewhisperer.{{region}}.amazonaws.com/generateAssistantResponse' }, { id: 'KIRO_REFRESH_URL', - label: `Refresh URL ${t('config.optional')}`, + label: `${t('modal.provider.field.refreshUrl')} ${t('config.optional')}`, type: 'text', placeholder: 'https://prod.{{region}}.auth.desktop.kiro.dev/refreshToken' }, { id: 'KIRO_REFRESH_IDC_URL', - label: `Refresh IDC URL ${t('config.optional')}`, + label: `${t('modal.provider.field.refreshIdcUrl')} ${t('config.optional')}`, type: 'text', placeholder: 'https://oidc.{{region}}.amazonaws.com/token' } @@ -196,9 +200,9 @@ function getProviderTypeFields(providerType) { 'openai-qwen-oauth': [ { id: 'QWEN_OAUTH_CREDS_FILE_PATH', - label: isEn ? 'OAuth Credentials File Path' : 'OAuth凭据文件路径', + label: t('modal.provider.field.oauthPath'), type: 'text', - placeholder: isEn ? 'e.g.: ~/.qwen/oauth_creds.json' : '例如: ~/.qwen/oauth_creds.json' + placeholder: t('modal.provider.field.oauthPath.qwen.placeholder') }, { id: 'QWEN_BASE_URL', @@ -208,7 +212,7 @@ function getProviderTypeFields(providerType) { }, { id: 'QWEN_OAUTH_BASE_URL', - label: `OAuth Base URL ${t('config.optional')}`, + label: `${t('modal.provider.field.oauthBaseUrl')} ${t('config.optional')}`, type: 'text', placeholder: 'https://chat.qwen.ai' } @@ -216,25 +220,25 @@ function getProviderTypeFields(providerType) { 'gemini-antigravity': [ { id: 'PROJECT_ID', - label: isEn ? 'Project ID (Optional)' : '项目ID (选填)', + label: `${t('modal.provider.field.projectId')} ${t('config.optional')}`, type: 'text', - placeholder: isEn ? 'Google Cloud Project ID (Leave blank for discovery)' : 'Google Cloud项目ID (留空自动发现)' + placeholder: t('modal.provider.field.projectId.optional.placeholder') }, { id: 'ANTIGRAVITY_OAUTH_CREDS_FILE_PATH', - label: isEn ? 'OAuth Credentials File Path' : 'OAuth凭据文件路径', + label: t('modal.provider.field.oauthPath'), type: 'text', - placeholder: isEn ? 'e.g.: ~/.antigravity/oauth_creds.json' : '例如: ~/.antigravity/oauth_creds.json' + placeholder: t('modal.provider.field.oauthPath.antigravity.placeholder') }, { id: 'ANTIGRAVITY_BASE_URL_DAILY', - label: `Daily Base URL ${t('config.optional')}`, + label: `${t('modal.provider.field.dailyBaseUrl')} ${t('config.optional')}`, type: 'text', placeholder: 'https://daily-cloudcode-pa.sandbox.googleapis.com' }, { id: 'ANTIGRAVITY_BASE_URL_AUTOPUSH', - label: `Autopush Base URL ${t('config.optional')}`, + label: `${t('modal.provider.field.autopushBaseUrl')} ${t('config.optional')}`, type: 'text', placeholder: 'https://autopush-cloudcode-pa.sandbox.googleapis.com' } @@ -242,9 +246,9 @@ function getProviderTypeFields(providerType) { 'openai-iflow': [ { id: 'IFLOW_OAUTH_CREDS_FILE_PATH', - label: isEn ? 'OAuth Credentials File Path' : 'OAuth凭据文件路径', + label: t('modal.provider.field.oauthPath'), type: 'text', - placeholder: isEn ? 'e.g.: configs/iflow/oauth_creds.json' : '例如: configs/iflow/oauth_creds.json' + placeholder: t('modal.provider.field.oauthPath.iflow.placeholder') }, { id: 'IFLOW_BASE_URL', @@ -256,15 +260,15 @@ function getProviderTypeFields(providerType) { 'openai-codex-oauth': [ { id: 'CODEX_OAUTH_CREDS_FILE_PATH', - label: isEn ? 'OAuth Credentials File Path' : 'OAuth凭据文件路径', + label: t('modal.provider.field.oauthPath'), type: 'text', - placeholder: isEn ? 'e.g.: configs/codex/oauth_creds.json' : '例如: configs/codex/oauth_creds.json' + placeholder: t('modal.provider.field.oauthPath.codex.placeholder') }, { id: 'CODEX_EMAIL', - label: isEn ? 'Email (Optional)' : '邮箱 (选填)', + label: `${t('modal.provider.field.email')} ${t('config.optional')}`, type: 'email', - placeholder: isEn ? 'your-email@example.com' : '你的邮箱@example.com' + placeholder: t('modal.provider.field.email.placeholder') }, { id: 'CODEX_BASE_URL', @@ -272,6 +276,32 @@ function getProviderTypeFields(providerType) { type: 'text', placeholder: 'https://api.openai.com/v1/codex' } + ], + 'forward-api': [ + { + id: 'FORWARD_API_KEY', + label: t('modal.provider.field.apiKey'), + type: 'password', + placeholder: t('modal.provider.field.apiKey.placeholder') + }, + { + id: 'FORWARD_BASE_URL', + label: t('modal.provider.field.baseUrl'), + type: 'text', + placeholder: 'https://api.example.com' + }, + { + id: 'FORWARD_HEADER_NAME', + label: `${t('modal.provider.field.headerName')} ${t('config.optional')}`, + type: 'text', + placeholder: 'Authorization' + }, + { + id: 'FORWARD_HEADER_VALUE_PREFIX', + label: `${t('modal.provider.field.headerPrefix')} ${t('config.optional')}`, + type: 'text', + placeholder: 'Bearer ' + } ] };