feat(forward): 新增通用转发API提供商支持

- 添加 forward-api 提供商类型,支持将请求透明转发到任意API端点
- 实现 ForwardStrategy、ForwardApiService 和适配器,支持流式和非流式响应
- 在转换逻辑中跳过 forward 协议的数据转换以保持透明性
- 更新UI支持:添加提供商配置字段、多语言标签和显示名称
- 扩展提供商状态检查和健康监测配置
- 为转发请求保留原始路径作为端点参数
This commit is contained in:
hex2077 2026-01-23 18:33:56 +08:00
parent d3e83949cf
commit da8ad6cddb
15 changed files with 446 additions and 69 deletions

View file

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

View file

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

View file

@ -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: [] };
}
}
}

View file

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

View file

@ -72,7 +72,8 @@ export const PROVIDER_MODELS = {
'gpt-5.1-codex-max',
'gpt-5.2',
'gpt-5.2-codex'
]
],
'forward-api': []
};
/**

View file

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

View file

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

View file

@ -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 = [];

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 = `
<div class="provider-header">
<div class="provider-name">
<span class="provider-type-text">${providerType}</span>
<span class="provider-type-text">${displayName}</span>
</div>
<div class="provider-header-right">
${generateAuthButton(providerType)}

View file

@ -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 <span class="optional-tag">${t('config.optional')}</span>`,
label: `${t('modal.provider.field.baseUrl')} <span class="optional-tag">${t('config.optional')}</span>`,
type: 'text',
placeholder: 'https://codewhisperer.{{region}}.amazonaws.com/generateAssistantResponse'
},
{
id: 'KIRO_REFRESH_URL',
label: `Refresh URL <span class="optional-tag">${t('config.optional')}</span>`,
label: `${t('modal.provider.field.refreshUrl')} <span class="optional-tag">${t('config.optional')}</span>`,
type: 'text',
placeholder: 'https://prod.{{region}}.auth.desktop.kiro.dev/refreshToken'
},
{
id: 'KIRO_REFRESH_IDC_URL',
label: `Refresh IDC URL <span class="optional-tag">${t('config.optional')}</span>`,
label: `${t('modal.provider.field.refreshIdcUrl')} <span class="optional-tag">${t('config.optional')}</span>`,
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 <span class="optional-tag">${t('config.optional')}</span>`,
label: `${t('modal.provider.field.oauthBaseUrl')} <span class="optional-tag">${t('config.optional')}</span>`,
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')} <span class="optional-tag">${t('config.optional')}</span>`,
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 <span class="optional-tag">${t('config.optional')}</span>`,
label: `${t('modal.provider.field.dailyBaseUrl')} <span class="optional-tag">${t('config.optional')}</span>`,
type: 'text',
placeholder: 'https://daily-cloudcode-pa.sandbox.googleapis.com'
},
{
id: 'ANTIGRAVITY_BASE_URL_AUTOPUSH',
label: `Autopush Base URL <span class="optional-tag">${t('config.optional')}</span>`,
label: `${t('modal.provider.field.autopushBaseUrl')} <span class="optional-tag">${t('config.optional')}</span>`,
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')} <span class="optional-tag">${t('config.optional')}</span>`,
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')} <span class="optional-tag">${t('config.optional')}</span>`,
type: 'text',
placeholder: 'Authorization'
},
{
id: 'FORWARD_HEADER_VALUE_PREFIX',
label: `${t('modal.provider.field.headerPrefix')} <span class="optional-tag">${t('config.optional')}</span>`,
type: 'text',
placeholder: 'Bearer '
}
]
};