diff --git a/package.json b/package.json index a967544..5b1b23b 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "dependencies": { "@anthropic-ai/tokenizer": "^0.0.4", "adm-zip": "^0.5.16", - "axios": "^1.10.0", + "axios": "^1.14.0", "deepmerge": "^4.3.1", "dotenv": "^16.4.5", "google-auth-library": "^10.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8570703..3da5f07 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,8 +15,8 @@ importers: specifier: ^0.5.16 version: 0.5.16 axios: - specifier: ^1.10.0 - version: 1.13.4 + specifier: ^1.14.0 + version: 1.14.0 deepmerge: specifier: ^4.3.1 version: 4.3.1 @@ -883,8 +883,8 @@ packages: asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} - axios@1.13.4: - resolution: {integrity: sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg==} + axios@1.14.0: + resolution: {integrity: sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==} babel-jest@29.7.0: resolution: {integrity: sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==} @@ -1308,11 +1308,12 @@ packages: glob@10.5.0: resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me google-auth-library@10.5.0: resolution: {integrity: sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==} @@ -1834,8 +1835,9 @@ packages: resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} engines: {node: '>= 6'} - proxy-from-env@1.1.0: - resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} pure-rand@6.1.0: resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} @@ -3269,11 +3271,11 @@ snapshots: asynckit@0.4.0: {} - axios@1.13.4: + axios@1.14.0: dependencies: follow-redirects: 1.15.11 form-data: 4.0.5 - proxy-from-env: 1.1.0 + proxy-from-env: 2.1.0 transitivePeerDependencies: - debug @@ -4441,7 +4443,7 @@ snapshots: kleur: 3.0.3 sisteransi: 1.0.5 - proxy-from-env@1.1.0: {} + proxy-from-env@2.1.0: {} pure-rand@6.1.0: {} diff --git a/src/core/config-manager.js b/src/core/config-manager.js index 364dd99..0af5b88 100644 --- a/src/core/config-manager.js +++ b/src/core/config-manager.js @@ -76,6 +76,11 @@ export async function initializeConfig(args = process.argv.slice(2), configFileP LOGIN_MIN_INTERVAL: 5000, // 两次尝试之间的最小间隔(毫秒),默认1秒 PROVIDER_POOLS_FILE_PATH: null, // 新增号池配置文件路径 MAX_ERROR_COUNT: 10, // 提供商最大错误次数 + SCHEDULED_HEALTH_CHECK: { + enabled: false, + interval: 600000, + startupRun: false + }, providerFallbackChain: {}, // 跨类型 Fallback 链配置 LOG_ENABLED: true, LOG_OUTPUT_MODE: "all", @@ -126,6 +131,8 @@ export async function initializeConfig(args = process.argv.slice(2), configFileP { flag: '--login-max-attempts', configKey: 'LOGIN_MAX_ATTEMPTS', type: 'int' }, { flag: '--login-lockout-duration', configKey: 'LOGIN_LOCKOUT_DURATION', type: 'int' }, { flag: '--login-min-interval', configKey: 'LOGIN_MIN_INTERVAL', type: 'int' }, + { flag: '--scheduled-health-check-enabled', configKey: 'SCHEDULE_HEALTH_CHECK_ENABLED', type: 'bool' }, + { flag: '--scheduled-health-check-interval', configKey: 'SCHEDULE_HEALTH_CHECK_INTERVAL', type: 'int' }, ]; // Parse command-line arguments using definitions @@ -160,6 +167,14 @@ export async function initializeConfig(args = process.argv.slice(2), configFileP } } + // 合并定时健康检查的 CLI 配置 + if (currentConfig.SCHEDULE_HEALTH_CHECK_ENABLED !== undefined) { + currentConfig.SCHEDULED_HEALTH_CHECK.enabled = currentConfig.SCHEDULE_HEALTH_CHECK_ENABLED; + } + if (currentConfig.SCHEDULE_HEALTH_CHECK_INTERVAL !== undefined) { + currentConfig.SCHEDULED_HEALTH_CHECK.interval = currentConfig.SCHEDULE_HEALTH_CHECK_INTERVAL; + } + normalizeConfiguredProviders(currentConfig); if (!currentConfig.SYSTEM_PROMPT_FILE_PATH) { diff --git a/src/handlers/request-handler.js b/src/handlers/request-handler.js index ebc84da..57e830b 100644 --- a/src/handlers/request-handler.js +++ b/src/handlers/request-handler.js @@ -6,7 +6,7 @@ import { handleAPIRequests } from '../services/api-manager.js'; import { getApiService, getProviderStatus } from '../services/service-manager.js'; import { getProviderPoolManager } from '../services/service-manager.js'; import { MODEL_PROVIDER } from '../utils/common.js'; -import { getRegisteredProviders } from '../providers/adapter.js'; +import { getRegisteredProviders, isRegisteredProvider } from '../providers/adapter.js'; import { countTokensAnthropic } from '../utils/token-utils.js'; import { PROMPT_LOG_FILENAME } from '../core/config-manager.js'; import { getPluginManager } from '../core/plugin-manager.js'; @@ -152,8 +152,7 @@ export function createRequestHandler(config, providerPoolManager) { // Allow overriding MODEL_PROVIDER via request header const modelProviderHeader = req.headers['model-provider']; if (modelProviderHeader) { - const registeredProviders = getRegisteredProviders(); - if (registeredProviders.includes(modelProviderHeader)) { + if (isRegisteredProvider(modelProviderHeader)) { currentConfig.MODEL_PROVIDER = modelProviderHeader; logger.info(`[Config] MODEL_PROVIDER overridden by header to: ${currentConfig.MODEL_PROVIDER}`); } else { @@ -169,8 +168,7 @@ export function createRequestHandler(config, providerPoolManager) { if (pathSegments.length > 0) { const firstSegment = pathSegments[0]; - const registeredProviders = getRegisteredProviders(); - const isValidProvider = registeredProviders.includes(firstSegment); + const isValidProvider = isRegisteredProvider(firstSegment); const isAutoMode = firstSegment === MODEL_PROVIDER.AUTO; if (firstSegment && (isValidProvider || isAutoMode)) { diff --git a/src/providers/adapter.js b/src/providers/adapter.js index bfaceac..7f9436c 100644 --- a/src/providers/adapter.js +++ b/src/providers/adapter.js @@ -704,6 +704,26 @@ registerAdapter(MODEL_PROVIDER.GROK_CUSTOM, GrokApiServiceAdapter); // 用于存储服务适配器单例的映射 export const serviceInstances = {}; +/** + * 检查提供商是否已注册(支持前缀匹配) + * @param {string} provider - 提供商名称 + * @returns {boolean} - 是否有效 + */ +export function isRegisteredProvider(provider) { + if (adapterRegistry.has(provider)) { + return true; + } + + // 检查前缀 (例如 openai-custom-1 -> openai-custom) + for (const key of adapterRegistry.keys()) { + if (provider.startsWith(key + '-')) { + return true; + } + } + + return false; +} + // 服务适配器工厂 export function getServiceAdapter(config) { const customNameDisplay = config.customName ? ` (${config.customName})` : ''; @@ -712,7 +732,18 @@ export function getServiceAdapter(config) { const providerKey = config.uuid ? provider + config.uuid : provider; if (!serviceInstances[providerKey]) { - const AdapterClass = adapterRegistry.get(provider); + let AdapterClass = adapterRegistry.get(provider); + + // 如果没找到精确匹配,尝试通过前缀查找 (例如 openai-custom-1 -> openai-custom) + if (!AdapterClass) { + for (const [key, value] of adapterRegistry.entries()) { + if (provider === key || provider.startsWith(key + '-')) { + AdapterClass = value; + break; + } + } + } + if (AdapterClass) { serviceInstances[providerKey] = new AdapterClass(config); } else { diff --git a/src/providers/claude/claude-core.js b/src/providers/claude/claude-core.js index e95e3b5..89f5e22 100644 --- a/src/providers/claude/claude-core.js +++ b/src/providers/claude/claude-core.js @@ -63,13 +63,13 @@ export class ClaudeApiService { } // 配置自定义代理 - configureAxiosProxy(axiosConfig, this.config, MODEL_PROVIDER.CLAUDE_CUSTOM); + configureAxiosProxy(axiosConfig, this.config, this.config.MODEL_PROVIDER || MODEL_PROVIDER.CLAUDE_CUSTOM); return axios.create(axiosConfig); } _applySidecar(axiosConfig) { - return configureTLSSidecar(axiosConfig, this.config, MODEL_PROVIDER.CLAUDE_CUSTOM, this.baseUrl); + return configureTLSSidecar(axiosConfig, this.config, this.config.MODEL_PROVIDER || MODEL_PROVIDER.CLAUDE_CUSTOM, this.baseUrl); } /** diff --git a/src/providers/claude/claude-kiro.js b/src/providers/claude/claude-kiro.js index 7c185e3..cd7fa0d 100644 --- a/src/providers/claude/claude-kiro.js +++ b/src/providers/claude/claude-kiro.js @@ -494,7 +494,7 @@ export class KiroApiService { } // 配置自定义代理 - configureAxiosProxy(axiosConfig, this.config, 'claude-kiro-oauth'); + configureAxiosProxy(axiosConfig, this.config, this.config.MODEL_PROVIDER || MODEL_PROVIDER.KIRO_API); this.axiosInstance = axios.create(axiosConfig); @@ -505,7 +505,7 @@ export class KiroApiService { } _applySidecar(axiosConfig) { - return configureTLSSidecar(axiosConfig, this.config, MODEL_PROVIDER.KIRO_API); + return configureTLSSidecar(axiosConfig, this.config, this.config.MODEL_PROVIDER || MODEL_PROVIDER.KIRO_API); } /** @@ -744,7 +744,7 @@ async saveCredentialsToFile(filePath, newData) { // 刷新成功,重置 PoolManager 中的刷新状态并标记为健康 const poolManager = getProviderPoolManager(); if (poolManager && this.uuid) { - poolManager.resetProviderRefreshStatus(MODEL_PROVIDER.KIRO_API, this.uuid); + poolManager.resetProviderRefreshStatus(this.config.MODEL_PROVIDER || MODEL_PROVIDER.KIRO_API, this.uuid); } } else { throw new Error('Invalid refresh response: Missing accessToken'); @@ -1627,7 +1627,7 @@ async saveCredentialsToFile(filePath, newData) { _refreshUuid() { const poolManager = getProviderPoolManager(); if (poolManager && this.uuid) { - const newUuid = poolManager.refreshProviderUuid(MODEL_PROVIDER.KIRO_API, { + const newUuid = poolManager.refreshProviderUuid(this.config.MODEL_PROVIDER || MODEL_PROVIDER.KIRO_API, { uuid: this.uuid }); return newUuid; @@ -1649,7 +1649,7 @@ async saveCredentialsToFile(filePath, newData) { if (poolManager && this.uuid) { logger.info(`[Kiro] Marking credential ${this.uuid} as needs refresh. Reason: ${reason}`); // 使用新的 markProviderNeedRefresh 方法代替 markProviderUnhealthyImmediately - poolManager.markProviderNeedRefresh(MODEL_PROVIDER.KIRO_API, { + poolManager.markProviderNeedRefresh(this.config.MODEL_PROVIDER || MODEL_PROVIDER.KIRO_API, { uuid: this.uuid }); // Attach marker to error object to prevent duplicate marking in upper layers @@ -1674,7 +1674,7 @@ async saveCredentialsToFile(filePath, newData) { const poolManager = getProviderPoolManager(); if (poolManager && this.uuid) { logger.info(`[Kiro] Marking credential ${this.uuid} as unhealthy. Reason: ${reason}`); - poolManager.markProviderUnhealthyImmediately(MODEL_PROVIDER.KIRO_API, { + poolManager.markProviderUnhealthyImmediately(this.config.MODEL_PROVIDER || MODEL_PROVIDER.KIRO_API, { uuid: this.uuid }, reason); // Attach marker to error object to prevent duplicate marking in upper layers @@ -1701,7 +1701,7 @@ async saveCredentialsToFile(filePath, newData) { const poolManager = getProviderPoolManager(); if (poolManager && this.uuid) { logger.info(`[Kiro] Marking credential ${this.uuid} as unhealthy with recovery time. Reason: ${reason}, Recovery: ${recoveryTime?.toISOString()}`); - poolManager.markProviderUnhealthyWithRecoveryTime(MODEL_PROVIDER.KIRO_API, { + poolManager.markProviderUnhealthyWithRecoveryTime(this.config.MODEL_PROVIDER || MODEL_PROVIDER.KIRO_API, { uuid: this.uuid }, reason, recoveryTime); // Attach marker to error object to prevent duplicate marking in upper layers diff --git a/src/providers/forward/forward-core.js b/src/providers/forward/forward-core.js index 8730ac3..260dcd2 100644 --- a/src/providers/forward/forward-core.js +++ b/src/providers/forward/forward-core.js @@ -56,13 +56,13 @@ export class ForwardApiService { axiosConfig.proxy = false; } - configureAxiosProxy(axiosConfig, config, MODEL_PROVIDER.FORWARD_API); + configureAxiosProxy(axiosConfig, config, config.MODEL_PROVIDER || MODEL_PROVIDER.FORWARD_API); this.axiosInstance = axios.create(axiosConfig); } _applySidecar(axiosConfig) { - return configureTLSSidecar(axiosConfig, this.config, MODEL_PROVIDER.FORWARD_API, this.baseUrl); + return configureTLSSidecar(axiosConfig, this.config, this.config.MODEL_PROVIDER || MODEL_PROVIDER.FORWARD_API, this.baseUrl); } async callApi(endpoint, body, isRetry = false, retryCount = 0) { diff --git a/src/providers/gemini/antigravity-core.js b/src/providers/gemini/antigravity-core.js index 3b34ba0..c66fd2e 100644 --- a/src/providers/gemini/antigravity-core.js +++ b/src/providers/gemini/antigravity-core.js @@ -697,7 +697,7 @@ export class AntigravityApiService { }); // 检查是否需要使用代理 - const proxyConfig = getGoogleAuthProxyConfig(config, 'gemini-antigravity'); + const proxyConfig = getGoogleAuthProxyConfig(config, config.MODEL_PROVIDER || MODEL_PROVIDER.ANTIGRAVITY); // 配置 OAuth2Client 使用自定义的 HTTP agent const oauth2Options = { @@ -729,11 +729,11 @@ export class AntigravityApiService { this.baseURLs = this.getBaseURLFallbackOrder(config); // 保存代理配置供后续使用 - this.proxyConfig = getProxyConfigForProvider(config, 'gemini-antigravity'); + this.proxyConfig = getProxyConfigForProvider(config, config.MODEL_PROVIDER || MODEL_PROVIDER.ANTIGRAVITY); } _applySidecar(requestOptions) { - return configureTLSSidecar(requestOptions, this.config, MODEL_PROVIDER.ANTIGRAVITY); + return configureTLSSidecar(requestOptions, this.config, this.config.MODEL_PROVIDER || MODEL_PROVIDER.ANTIGRAVITY); } /** @@ -821,7 +821,7 @@ export class AntigravityApiService { // 刷新成功,重置 PoolManager 中的刷新状态并标记为健康 const poolManager = getProviderPoolManager(); if (poolManager && this.uuid) { - poolManager.resetProviderRefreshStatus(MODEL_PROVIDER.ANTIGRAVITY, this.uuid); + poolManager.resetProviderRefreshStatus(this.config.MODEL_PROVIDER || MODEL_PROVIDER.ANTIGRAVITY, this.uuid); } } else { logger.info(`[Antigravity Auth] No access token or refresh token. Starting new authentication flow...`); @@ -832,7 +832,7 @@ export class AntigravityApiService { // 认证成功,重置状态 const poolManager = getProviderPoolManager(); if (poolManager && this.uuid) { - poolManager.resetProviderRefreshStatus(MODEL_PROVIDER.ANTIGRAVITY, this.uuid); + poolManager.resetProviderRefreshStatus(this.config.MODEL_PROVIDER || MODEL_PROVIDER.ANTIGRAVITY, this.uuid); } } } catch (error) { @@ -1109,7 +1109,7 @@ export class AntigravityApiService { const poolManager = getProviderPoolManager(); if (poolManager && this.uuid) { logger.info(`[Antigravity] Marking credential ${this.uuid} as needs refresh. Reason: 401/400 Unauthorized`); - poolManager.markProviderNeedRefresh(MODEL_PROVIDER.ANTIGRAVITY, { + poolManager.markProviderNeedRefresh(this.config.MODEL_PROVIDER || MODEL_PROVIDER.ANTIGRAVITY, { uuid: this.uuid }); error.credentialMarkedUnhealthy = true; @@ -1212,7 +1212,7 @@ export class AntigravityApiService { const poolManager = getProviderPoolManager(); if (poolManager && this.uuid) { logger.info(`[Antigravity] Marking credential ${this.uuid} as needs refresh. Reason: 401/400 Unauthorized in stream`); - poolManager.markProviderNeedRefresh(MODEL_PROVIDER.ANTIGRAVITY, { + poolManager.markProviderNeedRefresh(this.config.MODEL_PROVIDER || MODEL_PROVIDER.ANTIGRAVITY, { uuid: this.uuid }); error.credentialMarkedUnhealthy = true; @@ -1315,7 +1315,7 @@ export class AntigravityApiService { const poolManager = getProviderPoolManager(); if (poolManager && this.uuid) { logger.info(`[Antigravity] Token is near expiry, marking credential ${this.uuid} for refresh`); - poolManager.markProviderNeedRefresh(MODEL_PROVIDER.ANTIGRAVITY, { + poolManager.markProviderNeedRefresh(this.config.MODEL_PROVIDER || MODEL_PROVIDER.ANTIGRAVITY, { uuid: this.uuid }); } @@ -1393,7 +1393,7 @@ export class AntigravityApiService { const poolManager = getProviderPoolManager(); if (poolManager && this.uuid) { logger.info(`[Antigravity] Token is near expiry, marking credential ${this.uuid} for refresh`); - poolManager.markProviderNeedRefresh(MODEL_PROVIDER.ANTIGRAVITY, { + poolManager.markProviderNeedRefresh(this.config.MODEL_PROVIDER || MODEL_PROVIDER.ANTIGRAVITY, { uuid: this.uuid }); } @@ -1442,13 +1442,6 @@ export class AntigravityApiService { async getUsageLimits() { if (!this.isInitialized) await this.initialize(); - // 注意:V2 架构下不再在 getUsageLimits 中同步刷新 token - // 如果 token 过期,PoolManager 后台会自动处理 - // if (this.isExpiryDateNear()) { - // logger.info('[Antigravity] Token is near expiry, refreshing before getUsageLimits request...'); - // await this.initializeAuth(true); - // } - try { const modelsWithQuotas = await this.getModelsWithQuotas(); return modelsWithQuotas; @@ -1487,15 +1480,12 @@ export class AntigravityApiService { this._applySidecar(requestOptions); const res = await this.authClient.request(requestOptions); - // logger.info(`[Antigravity] fetchAvailableModels success: ${JSON.stringify(res.data)}`); if (res.data) { - if (res.data.models) { const modelsData = res.data.models; // 遍历模型数据,提取配额信息 for (const [modelId, modelData] of Object.entries(modelsData)) { - // 参考 fetchAvailableModels 的逻辑修复 modelName2Alias 不存在的问题 if (!modelId || (!ANTIGRAVITY_MODELS.includes(modelId) && !modelId.startsWith('claude-'))) { continue; } diff --git a/src/providers/gemini/gemini-core.js b/src/providers/gemini/gemini-core.js index 0033cbf..b4f386c 100644 --- a/src/providers/gemini/gemini-core.js +++ b/src/providers/gemini/gemini-core.js @@ -289,7 +289,7 @@ export class GeminiApiService { }); // 检查是否需要使用代理 - const proxyConfig = getGoogleAuthProxyConfig(config, 'gemini-cli-oauth'); + const proxyConfig = getGoogleAuthProxyConfig(config, config.MODEL_PROVIDER || MODEL_PROVIDER.GEMINI_CLI); // 配置 OAuth2Client 使用自定义的 HTTP agent const oauth2Options = { @@ -312,6 +312,7 @@ export class GeminiApiService { this.config = config; this.host = config.HOST; + this.uuid = config.uuid; this.oauthCredsBase64 = config.GEMINI_OAUTH_CREDS_BASE64; this.oauthCredsFilePath = config.GEMINI_OAUTH_CREDS_FILE_PATH; this.projectId = config.PROJECT_ID; @@ -320,7 +321,7 @@ export class GeminiApiService { this.apiVersion = DEFAULT_CODE_ASSIST_API_VERSION; // 保存代理配置供后续使用 - this.proxyConfig = getProxyConfigForProvider(config, 'gemini-cli-oauth'); + this.proxyConfig = getProxyConfigForProvider(config, config.MODEL_PROVIDER || MODEL_PROVIDER.GEMINI_CLI); } async initialize() { @@ -345,7 +346,7 @@ export class GeminiApiService { } _applySidecar(requestOptions) { - return configureTLSSidecar(requestOptions, this.config, MODEL_PROVIDER.GEMINI_CLI); + return configureTLSSidecar(requestOptions, this.config, this.config.MODEL_PROVIDER || MODEL_PROVIDER.GEMINI_CLI); } /** @@ -412,7 +413,7 @@ export class GeminiApiService { // 刷新成功,重置 PoolManager 中的刷新状态并标记为健康 const poolManager = getProviderPoolManager(); if (poolManager && this.uuid) { - poolManager.resetProviderRefreshStatus(MODEL_PROVIDER.GEMINI_CLI, this.uuid); + poolManager.resetProviderRefreshStatus(this.config.MODEL_PROVIDER || MODEL_PROVIDER.GEMINI_CLI, this.uuid); } } else { logger.info(`[Gemini Auth] No access token or refresh token. Starting new authentication flow...`); @@ -423,7 +424,7 @@ export class GeminiApiService { // 认证成功,重置状态 const poolManager = getProviderPoolManager(); if (poolManager && this.uuid) { - poolManager.resetProviderRefreshStatus(MODEL_PROVIDER.GEMINI_CLI, this.uuid); + poolManager.resetProviderRefreshStatus(this.config.MODEL_PROVIDER || MODEL_PROVIDER.GEMINI_CLI, this.uuid); } } } catch (error) { @@ -598,7 +599,7 @@ export class GeminiApiService { const poolManager = getProviderPoolManager(); if (poolManager && this.uuid) { logger.info(`[Gemini] Marking credential ${this.uuid} as needs refresh. Reason: 401/400 Unauthorized`); - poolManager.markProviderNeedRefresh(MODEL_PROVIDER.GEMINI_CLI, { + poolManager.markProviderNeedRefresh(this.config.MODEL_PROVIDER || MODEL_PROVIDER.GEMINI_CLI, { uuid: this.uuid }); error.credentialMarkedUnhealthy = true; @@ -681,7 +682,7 @@ export class GeminiApiService { const poolManager = getProviderPoolManager(); if (poolManager && this.uuid) { logger.info(`[Gemini] Marking credential ${this.uuid} as needs refresh. Reason: 401/400 Unauthorized in stream`); - poolManager.markProviderNeedRefresh(MODEL_PROVIDER.GEMINI_CLI, { + poolManager.markProviderNeedRefresh(this.config.MODEL_PROVIDER || MODEL_PROVIDER.GEMINI_CLI, { uuid: this.uuid }); error.credentialMarkedUnhealthy = true; @@ -757,7 +758,7 @@ export class GeminiApiService { const poolManager = getProviderPoolManager(); if (poolManager && this.uuid) { logger.info(`[Gemini] Token is near expiry, marking credential ${this.uuid} for refresh`); - poolManager.markProviderNeedRefresh(MODEL_PROVIDER.GEMINI_CLI, { + poolManager.markProviderNeedRefresh(this.config.MODEL_PROVIDER || MODEL_PROVIDER.GEMINI_CLI, { uuid: this.uuid }); } @@ -796,7 +797,7 @@ export class GeminiApiService { const poolManager = getProviderPoolManager(); if (poolManager && this.uuid) { logger.info(`[Gemini] Token is near expiry, marking credential ${this.uuid} for refresh`); - poolManager.markProviderNeedRefresh(MODEL_PROVIDER.GEMINI_CLI, { + poolManager.markProviderNeedRefresh(this.config.MODEL_PROVIDER || MODEL_PROVIDER.GEMINI_CLI, { uuid: this.uuid }); } diff --git a/src/providers/grok/grok-core.js b/src/providers/grok/grok-core.js index 7fb962d..8e34b3d 100644 --- a/src/providers/grok/grok-core.js +++ b/src/providers/grok/grok-core.js @@ -129,14 +129,14 @@ export class GrokApiService { async acceptTos() { const axiosConfig = { method: 'post', url: `${this.baseUrl}/rest/app-chat/accept-tos`, headers: this.buildHeaders(), data: {}, httpAgent, httpsAgent, timeout: 15000 }; - configureAxiosProxy(axiosConfig, this.config, MODEL_PROVIDER.GROK_CUSTOM); + configureAxiosProxy(axiosConfig, this.config, this.config.MODEL_PROVIDER || MODEL_PROVIDER.GROK_CUSTOM); this._applySidecar(axiosConfig); try { await axios(axiosConfig); } catch (e) { logger.debug(`[Grok TOS] ${e.message}`); } } async setBirthDate() { const axiosConfig = { method: 'post', url: `${this.baseUrl}/rest/app-chat/set-birth-date`, headers: this.buildHeaders(), data: { "birthDate": "1990-01-01" }, httpAgent, httpsAgent, timeout: 15000 }; - configureAxiosProxy(axiosConfig, this.config, MODEL_PROVIDER.GROK_CUSTOM); + configureAxiosProxy(axiosConfig, this.config, this.config.MODEL_PROVIDER || MODEL_PROVIDER.GROK_CUSTOM); this._applySidecar(axiosConfig); try { await axios(axiosConfig); } catch (e) { logger.debug(`[Grok Birth] ${e.message}`); } } @@ -167,13 +167,13 @@ export class GrokApiService { timeout: 15000, responseType: 'arraybuffer' }; - configureAxiosProxy(axiosConfig, this.config, MODEL_PROVIDER.GROK_CUSTOM); + configureAxiosProxy(axiosConfig, this.config, this.config.MODEL_PROVIDER || MODEL_PROVIDER.GROK_CUSTOM); this._applySidecar(axiosConfig); try { await axios(axiosConfig); } catch (e) { throw e; } } _applySidecar(axiosConfig) { - return configureTLSSidecar(axiosConfig, this.config, MODEL_PROVIDER.GROK_CUSTOM); + return configureTLSSidecar(axiosConfig, this.config, this.config.MODEL_PROVIDER || MODEL_PROVIDER.GROK_CUSTOM); } async initialize() { @@ -190,7 +190,7 @@ export class GrokApiService { // await this.getUsageLimits(); return Promise.resolve(); const poolManager = getProviderPoolManager(); if (poolManager && this.uuid) { - poolManager.resetProviderRefreshStatus(MODEL_PROVIDER.GROK_CUSTOM, this.uuid); + poolManager.resetProviderRefreshStatus(this.config.MODEL_PROVIDER || MODEL_PROVIDER.GROK_CUSTOM, this.uuid); } } catch (error) { logger.error('[Grok] Failed to initialize authentication:', error); @@ -202,7 +202,7 @@ export class GrokApiService { const headers = this.buildHeaders(); const payload = { "requestKind": "DEFAULT", "modelName": "grok-3" }; const axiosConfig = { method: 'post', url: `${this.baseUrl}/rest/rate-limits`, headers, data: payload, httpAgent, httpsAgent, timeout: 30000 }; - configureAxiosProxy(axiosConfig, this.config, MODEL_PROVIDER.GROK_CUSTOM); + configureAxiosProxy(axiosConfig, this.config, this.config.MODEL_PROVIDER || MODEL_PROVIDER.GROK_CUSTOM); this._applySidecar(axiosConfig); try { const response = await axios(axiosConfig); @@ -282,7 +282,7 @@ export class GrokApiService { if (mediaUrl && mediaUrl.trim()) payload.mediaUrl = mediaUrl; const axiosConfig = { method: 'post', url: `${this.baseUrl}/rest/media/post/create`, headers, data: payload, httpAgent, httpsAgent, timeout: 30000 }; - configureAxiosProxy(axiosConfig, this.config, MODEL_PROVIDER.GROK_CUSTOM); + configureAxiosProxy(axiosConfig, this.config, this.config.MODEL_PROVIDER || MODEL_PROVIDER.GROK_CUSTOM); this._applySidecar(axiosConfig); try { const response = await axios(axiosConfig); @@ -302,7 +302,7 @@ export class GrokApiService { if (!idMatch) return videoUrl; const videoId = idMatch[1]; const axiosConfig = { method: 'post', url: `${this.baseUrl}/rest/media/video/upscale`, headers: this.buildHeaders(), data: { videoId }, httpAgent, httpsAgent, timeout: 30000 }; - configureAxiosProxy(axiosConfig, this.config, MODEL_PROVIDER.GROK_CUSTOM); + configureAxiosProxy(axiosConfig, this.config, this.config.MODEL_PROVIDER || MODEL_PROVIDER.GROK_CUSTOM); this._applySidecar(axiosConfig); try { const response = await axios(axiosConfig); @@ -329,7 +329,7 @@ export class GrokApiService { httpsAgent, timeout: 15000 }; - configureAxiosProxy(axiosConfig, this.config, MODEL_PROVIDER.GROK_CUSTOM); + configureAxiosProxy(axiosConfig, this.config, this.config.MODEL_PROVIDER || MODEL_PROVIDER.GROK_CUSTOM); this._applySidecar(axiosConfig); try { const response = await axios(axiosConfig); @@ -455,7 +455,7 @@ export class GrokApiService { rolloutId: "", modelResponse: null, cardAttachment: null, - cardAttachments: [], // 收集所有的卡片附件 + cardAttachments: [], streamingImageGenerationResponse: null, streamingVideoGenerationResponse: null, finalVideoUrl: null, @@ -743,7 +743,7 @@ export class GrokApiService { } if (!b64) return null; const axiosConfig = { method: 'post', url: `${this.baseUrl}/rest/app-chat/upload-file`, headers: this.buildHeaders(), data: { fileName: `file.${mime.split("/")[1] || "bin"}`, fileMimeType: mime, content: b64 }, httpAgent, httpsAgent, timeout: 30000 }; - configureAxiosProxy(axiosConfig, this.config, MODEL_PROVIDER.GROK_CUSTOM); + configureAxiosProxy(axiosConfig, this.config, this.config.MODEL_PROVIDER || MODEL_PROVIDER.GROK_CUSTOM); this._applySidecar(axiosConfig); try { return (await axios(axiosConfig)).data; } catch (error) { return null; } } @@ -763,7 +763,7 @@ export class GrokApiService { if (requestBody._requestBaseUrl) delete requestBody._requestBaseUrl; if (this.isExpiryDateNear() && getProviderPoolManager() && this.uuid) { - getProviderPoolManager().markProviderNeedRefresh(MODEL_PROVIDER.GROK_CUSTOM, { uuid: this.uuid }); + getProviderPoolManager().markProviderNeedRefresh(this.config.MODEL_PROVIDER || MODEL_PROVIDER.GROK_CUSTOM, { uuid: this.uuid }); } const rawModel = typeof model === 'string' ? model : ''; @@ -841,7 +841,7 @@ export class GrokApiService { const payload = this.buildPayload(model, requestBody); const axiosConfig = { method: 'post', url: this.chatApi, headers: this.buildHeaders(), data: payload, responseType: 'stream', httpAgent, httpsAgent, timeout: 60000, maxRedirects: 0 }; - configureAxiosProxy(axiosConfig, this.config, MODEL_PROVIDER.GROK_CUSTOM); + configureAxiosProxy(axiosConfig, this.config, this.config.MODEL_PROVIDER || MODEL_PROVIDER.GROK_CUSTOM); this._applySidecar(axiosConfig); try { diff --git a/src/providers/openai/codex-core.js b/src/providers/openai/codex-core.js index 3eceb68..909414f 100644 --- a/src/providers/openai/codex-core.js +++ b/src/providers/openai/codex-core.js @@ -40,7 +40,7 @@ export class CodexApiService { } _applySidecar(axiosConfig) { - return configureTLSSidecar(axiosConfig, this.config, MODEL_PROVIDER.CODEX_API, this.baseUrl); + return configureTLSSidecar(axiosConfig, this.config, this.config.MODEL_PROVIDER || MODEL_PROVIDER.CODEX_API, this.baseUrl); } /** @@ -148,7 +148,7 @@ export class CodexApiService { const poolManager = getProviderPoolManager(); if (poolManager && this.uuid) { logger.info(`[Codex] Token is near expiry, marking credential ${this.uuid} for background refresh`); - poolManager.markProviderNeedRefresh(MODEL_PROVIDER.CODEX_API, { + poolManager.markProviderNeedRefresh(this.config.MODEL_PROVIDER || MODEL_PROVIDER.CODEX_API, { uuid: this.uuid }); } @@ -195,7 +195,7 @@ export class CodexApiService { }; // 配置代理 - const proxyConfig = getProxyConfigForProvider(this.config, 'openai-codex-oauth'); + const proxyConfig = getProxyConfigForProvider(this.config, this.config.MODEL_PROVIDER || MODEL_PROVIDER.CODEX_API); if (proxyConfig) { config.httpAgent = proxyConfig.httpAgent; config.httpsAgent = proxyConfig.httpsAgent; @@ -272,7 +272,7 @@ export class CodexApiService { }; // 配置代理 - const proxyConfig = getProxyConfigForProvider(this.config, 'openai-codex-oauth'); + const proxyConfig = getProxyConfigForProvider(this.config, this.config.MODEL_PROVIDER || MODEL_PROVIDER.CODEX_API); if (proxyConfig) { config.httpAgent = proxyConfig.httpAgent; config.httpsAgent = proxyConfig.httpsAgent; @@ -454,7 +454,7 @@ export class CodexApiService { // 刷新成功,重置 PoolManager 中的刷新状态并标记为健康 const poolManager = getProviderPoolManager(); if (poolManager && this.uuid) { - poolManager.resetProviderRefreshStatus(MODEL_PROVIDER.CODEX_API, this.uuid); + poolManager.resetProviderRefreshStatus(this.config.MODEL_PROVIDER || MODEL_PROVIDER.CODEX_API, this.uuid); } logger.info('[Codex] Token refreshed successfully'); } catch (error) { @@ -688,7 +688,7 @@ export class CodexApiService { }; // 配置代理 - const proxyConfig = getProxyConfigForProvider(this.config, 'openai-codex-oauth'); + const proxyConfig = getProxyConfigForProvider(this.config, this.config.MODEL_PROVIDER || MODEL_PROVIDER.CODEX_API); if (proxyConfig) { config.httpAgent = proxyConfig.httpAgent; config.httpsAgent = proxyConfig.httpsAgent; diff --git a/src/providers/openai/openai-core.js b/src/providers/openai/openai-core.js index 6fe800a..0485fd6 100644 --- a/src/providers/openai/openai-core.js +++ b/src/providers/openai/openai-core.js @@ -47,13 +47,13 @@ export class OpenAIApiService { } // 配置自定义代理 - configureAxiosProxy(axiosConfig, config, MODEL_PROVIDER.OPENAI_CUSTOM); + configureAxiosProxy(axiosConfig, config, config.MODEL_PROVIDER || MODEL_PROVIDER.OPENAI_CUSTOM); this.axiosInstance = axios.create(axiosConfig); } _applySidecar(axiosConfig) { - return configureTLSSidecar(axiosConfig, this.config, MODEL_PROVIDER.OPENAI_CUSTOM, this.baseUrl); + return configureTLSSidecar(axiosConfig, this.config, this.config.MODEL_PROVIDER || MODEL_PROVIDER.OPENAI_CUSTOM, this.baseUrl); } async callApi(endpoint, body, isRetry = false, retryCount = 0) { diff --git a/src/providers/openai/openai-responses-core.js b/src/providers/openai/openai-responses-core.js index d12cbad..853a3fd 100644 --- a/src/providers/openai/openai-responses-core.js +++ b/src/providers/openai/openai-responses-core.js @@ -47,13 +47,13 @@ export class OpenAIResponsesApiService { } // 配置自定义代理 (使用 openai-custom 的代理配置) - configureAxiosProxy(axiosConfig, config, MODEL_PROVIDER.OPENAI_CUSTOM_RESPONSES); + configureAxiosProxy(axiosConfig, config, config.MODEL_PROVIDER || MODEL_PROVIDER.OPENAI_CUSTOM_RESPONSES); this.axiosInstance = axios.create(axiosConfig); } _applySidecar(axiosConfig) { - return configureTLSSidecar(axiosConfig, this.config, MODEL_PROVIDER.OPENAI_CUSTOM_RESPONSES, this.baseUrl); + return configureTLSSidecar(axiosConfig, this.config, this.config.MODEL_PROVIDER || MODEL_PROVIDER.OPENAI_CUSTOM_RESPONSES, this.baseUrl); } async callApi(endpoint, body, isRetry = false, retryCount = 0) { diff --git a/src/providers/openai/qwen-core.js b/src/providers/openai/qwen-core.js index 8d31638..e2621ca 100644 --- a/src/providers/openai/qwen-core.js +++ b/src/providers/openai/qwen-core.js @@ -230,7 +230,7 @@ export class QwenApiService { } // 配置自定义代理 - configureAxiosProxy(axiosConfig, this.config, 'openai-qwen-oauth'); + configureAxiosProxy(axiosConfig, this.config, this.config.MODEL_PROVIDER || MODEL_PROVIDER.QWEN_API); this.currentAxiosInstance = axios.create(axiosConfig); @@ -239,7 +239,7 @@ export class QwenApiService { } _applySidecar(axiosConfig) { - return configureTLSSidecar(axiosConfig, this.config, MODEL_PROVIDER.QWEN_API, this.baseUrl); + return configureTLSSidecar(axiosConfig, this.config, this.config.MODEL_PROVIDER || MODEL_PROVIDER.QWEN_API, this.baseUrl); } /** @@ -278,7 +278,7 @@ export class QwenApiService { if (forceRefresh || (credentials && credentials.access_token)) { const poolManager = getProviderPoolManager(); if (poolManager && this.uuid) { - poolManager.resetProviderRefreshStatus(MODEL_PROVIDER.QWEN_API, this.uuid); + poolManager.resetProviderRefreshStatus(this.config.MODEL_PROVIDER || MODEL_PROVIDER.QWEN_API, this.uuid); } } } catch (error) { @@ -331,7 +331,7 @@ export class QwenApiService { // 认证成功,重置状态 const poolManager = getProviderPoolManager(); if (poolManager && this.uuid) { - poolManager.resetProviderRefreshStatus(MODEL_PROVIDER.QWEN_API, this.uuid); + poolManager.resetProviderRefreshStatus(this.config.MODEL_PROVIDER || MODEL_PROVIDER.QWEN_API, this.uuid); } } } @@ -575,7 +575,7 @@ export class QwenApiService { } // 配置自定义代理 - configureAxiosProxy(axiosConfig, this.config, 'openai-qwen-oauth'); + configureAxiosProxy(axiosConfig, this.config, this.config.MODEL_PROVIDER || MODEL_PROVIDER.QWEN_API); this.currentAxiosInstance = axios.create(axiosConfig); @@ -629,7 +629,7 @@ export class QwenApiService { const poolManager = getProviderPoolManager(); if (poolManager && this.uuid) { logger.info(`[Qwen] Marking credential ${this.uuid} as needs refresh. Reason: Auth Error ${status}`); - poolManager.markProviderNeedRefresh(MODEL_PROVIDER.QWEN_API, { + poolManager.markProviderNeedRefresh(this.config.MODEL_PROVIDER || MODEL_PROVIDER.QWEN_API, { uuid: this.uuid }); error.credentialMarkedUnhealthy = true; @@ -677,7 +677,7 @@ export class QwenApiService { const poolManager = getProviderPoolManager(); if (poolManager && this.uuid) { logger.info(`[Qwen] Token is near expiry, marking credential ${this.uuid} for refresh`); - poolManager.markProviderNeedRefresh(MODEL_PROVIDER.QWEN_API, { + poolManager.markProviderNeedRefresh(this.config.MODEL_PROVIDER || MODEL_PROVIDER.QWEN_API, { uuid: this.uuid }); } diff --git a/src/providers/provider-models.js b/src/providers/provider-models.js index faa044c..8c815a0 100644 --- a/src/providers/provider-models.js +++ b/src/providers/provider-models.js @@ -116,7 +116,18 @@ export const PROVIDER_MODELS = { * @returns {Array} 模型列表 */ export function getProviderModels(providerType) { - return PROVIDER_MODELS[providerType] || []; + if (PROVIDER_MODELS[providerType]) { + return PROVIDER_MODELS[providerType]; + } + + // 尝试前缀匹配 (例如 openai-custom-1 -> openai-custom) + for (const key of Object.keys(PROVIDER_MODELS)) { + if (providerType.startsWith(key + '-')) { + return PROVIDER_MODELS[key]; + } + } + + return []; } /** diff --git a/src/providers/provider-pool-manager.js b/src/providers/provider-pool-manager.js index 345badf..334c9d3 100644 --- a/src/providers/provider-pool-manager.js +++ b/src/providers/provider-pool-manager.js @@ -1841,7 +1841,11 @@ export class ProviderPoolManager { for (const { providerType, provider, uuid, customName } of providersToCheck) { const providerCheckStart = Date.now(); - const checkModelName = provider.config.checkModelName || ProviderPoolManager.DEFAULT_HEALTH_CHECK_MODELS[providerType] || 'unknown'; + const baseProviderType = this._getBaseProviderType(providerType); + const checkModelName = provider.config.checkModelName || + ProviderPoolManager.DEFAULT_HEALTH_CHECK_MODELS[providerType] || + ProviderPoolManager.DEFAULT_HEALTH_CHECK_MODELS[baseProviderType] || + 'unknown'; const displayName = customName || uuid.substring(0, 8); try { @@ -1903,7 +1907,7 @@ export class ProviderPoolManager { } // OpenAI Custom Responses 使用特殊格式 - if (providerType === MODEL_PROVIDER.OPENAI_CUSTOM_RESPONSES) { + if (this._getBaseProviderType(providerType) === MODEL_PROVIDER.OPENAI_CUSTOM_RESPONSES) { requests.push({ input: [baseMessage], model: modelName @@ -1920,6 +1924,26 @@ export class ProviderPoolManager { return requests; } + /** + * 根据提供商类型获取基准提供商类型(用于查找配置和模型) + * 例如:openai-custom-1 -> openai-custom + * @private + */ + _getBaseProviderType(providerType) { + if (ProviderPoolManager.DEFAULT_HEALTH_CHECK_MODELS[providerType]) { + return providerType; + } + + // 尝试前缀匹配 + for (const key of Object.keys(ProviderPoolManager.DEFAULT_HEALTH_CHECK_MODELS)) { + if (providerType === key || providerType.startsWith(key + '-')) { + return key; + } + } + + return providerType; + } + /** * Performs an actual health check for a specific provider. * @@ -1934,8 +1958,10 @@ export class ProviderPoolManager { */ async _checkProviderHealth(providerType, providerConfig) { // 确定健康检查使用的模型名称 + const baseProviderType = this._getBaseProviderType(providerType); const modelName = providerConfig.checkModelName || - ProviderPoolManager.DEFAULT_HEALTH_CHECK_MODELS[providerType]; + ProviderPoolManager.DEFAULT_HEALTH_CHECK_MODELS[providerType] || + ProviderPoolManager.DEFAULT_HEALTH_CHECK_MODELS[baseProviderType]; if (!modelName) { this._log('warn', `Unknown provider type for health check: ${providerType}. Please check DEFAULT_HEALTH_CHECK_MODELS.`); diff --git a/src/services/service-manager.js b/src/services/service-manager.js index 747518f..47f1673 100644 --- a/src/services/service-manager.js +++ b/src/services/service-manager.js @@ -563,7 +563,8 @@ export async function getProviderStatus(config, options = {}) { 'gemini-antigravity': 'ANTIGRAVITY_OAUTH_CREDS_FILE_PATH', 'openai-iflow': 'IFLOW_TOKEN_FILE_PATH', 'forward-api': 'FORWARD_BASE_URL', - 'grok-custom': 'GROK_COOKIE_TOKEN' + 'grok-custom': 'GROK_COOKIE_TOKEN', + 'openai-codex-oauth': 'CODEX_OAUTH_CREDS_FILE_PATH' }; let providerPoolsSlim = []; let unhealthyProvideIdentifyList = []; @@ -575,7 +576,18 @@ export async function getProviderStatus(config, options = {}) { for (const key of Object.keys(providerPools)) { if (!Array.isArray(providerPools[key])) continue; if (filterProvider && key !== filterProvider) continue; - const identifyField = identifyFieldMap[key] || null; + + let identifyField = identifyFieldMap[key] || null; + if (!identifyField) { + // 尝试通过前缀查找 identifyField (例如 openai-custom-1 -> openai-custom) + for (const [prefix, field] of Object.entries(identifyFieldMap)) { + if (key.startsWith(prefix + '-')) { + identifyField = field; + break; + } + } + } + const slimArr = providerPools[key] .filter(item => { if (item.isDisabled) return false; diff --git a/src/services/ui-manager.js b/src/services/ui-manager.js index 91db632..cd71bb9 100644 --- a/src/services/ui-manager.js +++ b/src/services/ui-manager.js @@ -125,7 +125,7 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo // Get supported provider types based on registered adapters if (method === 'GET' && pathParam === '/api/providers/supported') { - return await providerApi.handleGetSupportedProviders(req, res); + return await providerApi.handleGetSupportedProviders(req, res, currentConfig, providerPoolManager); } // Get specific provider type details @@ -137,7 +137,7 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo // Get available models for all providers or specific provider type if (method === 'GET' && pathParam === '/api/provider-models') { - return await providerApi.handleGetProviderModels(req, res); + return await providerApi.handleGetProviderModels(req, res, currentConfig, providerPoolManager); } // Get available models for a specific provider type diff --git a/src/ui-modules/provider-api.js b/src/ui-modules/provider-api.js index 1d4cf3d..600f4d0 100644 --- a/src/ui-modules/provider-api.js +++ b/src/ui-modules/provider-api.js @@ -9,38 +9,53 @@ import { getRegisteredProviders } from '../providers/adapter.js'; // 文件级互斥锁:防止并发读写导致数据丢失 // 安全净化:移除用户输入字段中的危险内容(script、事件处理器、javascript:协议等), // 存储原始文本。HTML 转义统一由前端 escHtml() 负责,避免双编码问题。 -function sanitizeProviderData(provider) { +// 安全净化:移除用户输入字段中的危险内容,并可选地过滤敏感 API 密钥 +function sanitizeProviderData(provider, maskSensitive = false) { if (!provider || typeof provider !== 'object') return provider; const sanitized = { ...provider }; + + // 1. 过滤敏感字段(API Keys, Tokens 等) + if (maskSensitive) { + const sensitiveKeys = [ + 'OPENAI_API_KEY', 'CLAUDE_API_KEY', 'FORWARD_API_KEY', + 'GROK_COOKIE_TOKEN', 'GROK_CF_CLEARANCE', + 'refreshToken', 'accessToken', 'clientSecret' + ]; + + sensitiveKeys.forEach(key => { + if (sanitized[key]) { + // 对密钥进行脱敏显示(只保留前 4 位和后 4 位) + const val = sanitized[key]; + if (typeof val === 'string' && val.length > 10) { + sanitized[key] = val.substring(0, 4) + '****' + val.substring(val.length - 4); + } else { + sanitized[key] = '********'; + } + } + }); + } + + // 2. 净化 customName 中的 HTML/脚本 if (typeof sanitized.customName === 'string') { let name = sanitized.customName; - - // 拒绝包含危险协议 if (/(?:data|javascript|vbscript)\s*:/i.test(name)) { sanitized.customName = ''; return sanitized; } - - // 移除所有 HTML 标签(更安全的方式) name = name.replace(/<[^>]*>/g, ''); - - // 移除 HTML 事件处理器属性(onclick/onerror 等) name = name.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]*)/gi, ''); - - // 移除潜在的 HTML 实体编码攻击 name = name.replace(/&[#\w]+;/g, ''); - sanitized.customName = name.trim(); } return sanitized; } -function sanitizeProviderPools(pools) { +function sanitizeProviderPools(pools, maskSensitive = false) { if (!pools || typeof pools !== 'object') return pools; const sanitized = {}; for (const [type, providers] of Object.entries(pools)) { sanitized[type] = Array.isArray(providers) - ? providers.map(sanitizeProviderData) + ? providers.map(p => sanitizeProviderData(p, maskSensitive)) : providers; } return sanitized; @@ -70,34 +85,53 @@ function withFileLock(fn) { return next; } /** - * 获取提供商池摘要 + * 获取所有提供商的状态(包括支持的类型和号池组) */ export async function handleGetProviders(req, res, currentConfig, providerPoolManager) { - let providerPools = {}; - const filePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'configs/provider_pools.json'; - try { - if (providerPoolManager && providerPoolManager.providerPools) { - providerPools = providerPoolManager.providerPools; - } else if (filePath && existsSync(filePath)) { - const poolsData = JSON.parse(readFileSync(filePath, 'utf-8')); - providerPools = poolsData; - } - } catch (error) { - logger.warn('[UI API] Failed to load provider pools:', error.message); + if (!providerPoolManager) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: 'Provider pool manager not initialized' } })); + return true; } - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(sanitizeProviderPools(providerPools))); - return true; -} + // 1. 获取支持的基础提供商类型 + const registeredProviders = getRegisteredProviders(); + let poolTypes = []; + + // 2. 从管理器获取当前所有池的状态 + const providerStatus = {}; + for (const [type, providers] of Object.entries(providerPoolManager.providerStatus)) { + providerStatus[type] = providers.map(p => ({ + ...p.config, + activeRequests: p.state?.activeCount || 0, + waitingRequests: p.state?.waitingCount || 0 + })); + } + + // 3. 补全号池配置文件中的所有组 + const filePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'configs/provider_pools.json'; + try { + if (existsSync(filePath)) { + const poolsData = JSON.parse(readFileSync(filePath, 'utf-8')); + poolTypes = Object.keys(poolsData); + poolTypes.forEach(type => { + if (!providerStatus[type]) { + providerStatus[type] = []; + } + }); + } + } catch (error) { + logger.warn('[UI API] Failed to supplement provider status:', error.message); + } + + // 合并生成支持的类型列表 + const supportedProviders = [...new Set([...registeredProviders, ...poolTypes])]; -/** - * 获取支持的提供商类型(已注册适配器的) - */ -export async function handleGetSupportedProviders(req, res) { - const supportedProviders = getRegisteredProviders(); res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(supportedProviders)); + res.end(JSON.stringify({ + providers: sanitizeProviderPools(providerStatus, true), // 列表显示进行打码 + supportedProviders: supportedProviders + })); return true; } @@ -122,7 +156,7 @@ export async function handleGetProviderType(req, res, currentConfig, providerPoo res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ providerType, - providers: providers.map(sanitizeProviderData), + providers: providers.map(p => sanitizeProviderData(p, false)), // 详情页(用于编辑)不打码 totalCount: providers.length, healthyCount: providers.filter(p => p.isHealthy).length })); @@ -130,10 +164,62 @@ export async function handleGetProviderType(req, res, currentConfig, providerPoo } /** - * 获取所有提供商的可用模型 + * 获取支持的提供商类型(已注册适配器的,以及号池中已存在的自定义类型) */ -export async function handleGetProviderModels(req, res) { - const allModels = getAllProviderModels(); +export async function handleGetSupportedProviders(req, res, currentConfig, providerPoolManager) { + const registeredProviders = getRegisteredProviders(); + let poolTypes = []; + + const filePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'configs/provider_pools.json'; + try { + if (providerPoolManager && providerPoolManager.providerPools) { + poolTypes = Object.keys(providerPoolManager.providerPools); + } else if (filePath && existsSync(filePath)) { + const poolsData = JSON.parse(readFileSync(filePath, 'utf-8')); + poolTypes = Object.keys(poolsData); + } + } catch (error) { + logger.warn('[UI API] Failed to load provider pools for supported types:', error.message); + } + + // 合并注册的提供商和号池中的类型 + const supportedProviders = [...new Set([...registeredProviders, ...poolTypes])]; + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(supportedProviders)); + return true; +} + +/** + * 获取所有提供商的可用模型(支持动态配置组) + */ +export async function handleGetProviderModels(req, res, currentConfig, providerPoolManager) { + const registeredProviders = getRegisteredProviders(); + let poolTypes = []; + + // 获取所有存在的类型(基础 + 动态) + const filePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'configs/provider_pools.json'; + try { + if (providerPoolManager && providerPoolManager.providerPools) { + poolTypes = Object.keys(providerPoolManager.providerPools); + } else if (existsSync(filePath)) { + const poolsData = JSON.parse(readFileSync(filePath, 'utf-8')); + poolTypes = Object.keys(poolsData); + } + } catch (error) { + logger.warn('[UI API] Failed to load provider pools for models:', error.message); + } + + const allTypes = [...new Set([...registeredProviders, ...poolTypes])]; + const allModels = {}; + + allTypes.forEach(type => { + const models = getProviderModels(type); + if (models && models.length > 0) { + allModels[type] = models; + } + }); + res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(allModels)); return true; diff --git a/src/utils/common.js b/src/utils/common.js index c031ea4..3582277 100644 --- a/src/utils/common.js +++ b/src/utils/common.js @@ -961,7 +961,7 @@ export async function handleContentGenerationRequest(req, res, service, endpoint } // 为 forward provider 添加原始请求路径作为 endpoint - if (requestPath && toProvider === MODEL_PROVIDER.FORWARD_API) { + if (requestPath && getProtocolPrefix(toProvider) === MODEL_PROTOCOL_PREFIX.FORWARD) { logger.info(`[Forward API] Request path: ${requestPath}`); processedRequestBody.endpoint = requestPath; } diff --git a/src/utils/proxy-utils.js b/src/utils/proxy-utils.js index 45bd9ec..8053c19 100644 --- a/src/utils/proxy-utils.js +++ b/src/utils/proxy-utils.js @@ -54,7 +54,7 @@ export function parseProxyUrl(proxyUrl) { } /** - * 检查指定的提供商是否启用了代理 + * 检查指定的提供商是否启用了代理(支持前缀匹配) * @param {Object} config - 配置对象 * @param {string} providerType - 提供商类型 * @returns {boolean} 是否启用代理 @@ -69,7 +69,13 @@ export function isProxyEnabledForProvider(config, providerType) { return false; } - return enabledProviders.includes(providerType); + // 1. 尝试精确匹配 + if (enabledProviders.includes(providerType)) { + return true; + } + + // 2. 尝试前缀匹配 (例如 openai-custom-prod 继承 openai-custom 的配置) + return enabledProviders.some(p => providerType.startsWith(p + '-')); } /** @@ -112,7 +118,7 @@ export function configureAxiosProxy(axiosConfig, config, providerType) { } /** - * 检查指定的提供商是否启用了 TLS Sidecar + * 检查指定的提供商是否启用了 TLS Sidecar(支持前缀匹配) * @param {Object} config - 配置对象 * @param {string} providerType - 提供商类型 * @returns {boolean} 是否启用 TLS Sidecar @@ -127,7 +133,13 @@ export function isTLSSidecarEnabledForProvider(config, providerType) { return false; } - return enabledProviders.includes(providerType); + // 1. 尝试精确匹配 + if (enabledProviders.includes(providerType)) { + return true; + } + + // 2. 尝试前缀匹配 + return enabledProviders.some(p => providerType.startsWith(p + '-')); } /** diff --git a/static/app/app.js b/static/app/app.js index 2704104..4d38fb3 100644 --- a/static/app/app.js +++ b/static/app/app.js @@ -41,7 +41,8 @@ import { openProviderManager, showAuthModal, executeGenerateAuthUrl, - handleGenerateAuthUrl + handleGenerateAuthUrl, + showAddProviderGroupModal } from './provider-manager.js'; import { @@ -234,6 +235,7 @@ window.fileUploadHandler = fileUploadHandler; window.showAuthModal = showAuthModal; window.executeGenerateAuthUrl = executeGenerateAuthUrl; window.handleGenerateAuthUrl = handleGenerateAuthUrl; +window.showAddProviderGroupModal = showAddProviderGroupModal; // 配置管理相关全局函数 window.viewConfig = viewConfig; diff --git a/static/app/event-handlers.js b/static/app/event-handlers.js index 283bdcf..5377ecb 100644 --- a/static/app/event-handlers.js +++ b/static/app/event-handlers.js @@ -219,6 +219,18 @@ function initEventListeners() { performUpdateBtn.addEventListener('click', performUpdate); } + // 添加提供商组按钮 + const addProviderGroupBtn = document.getElementById('add-provider-group-btn'); + if (addProviderGroupBtn) { + addProviderGroupBtn.addEventListener('click', () => { + if (window.showAddProviderGroupModal) { + window.showAddProviderGroupModal(); + } else { + console.error('showAddProviderGroupModal function not found'); + } + }); + } + // 日志容器滚动 if (elements.logsContainer) { elements.logsContainer.addEventListener('scroll', () => { diff --git a/static/app/i18n.js b/static/app/i18n.js index 13a06ee..f4ee713 100644 --- a/static/app/i18n.js +++ b/static/app/i18n.js @@ -452,6 +452,12 @@ const translations = { // Providers 'providers.title': '提供商池管理', + 'providers.addGroup': '新的分组', + 'providers.addGroup.title': '添加新分组', + 'providers.addGroup.success': '分组创建成功,请添加账号', + 'providers.addGroup.error': '创建失败', + 'providers.addGroup.suffix': '分组名称 (后缀)', + 'providers.addGroup.suffixPlaceholder': '例如: qwen, glm, minimax', 'providers.note': '如使用客户端默认授权配置需使用空节点', 'providers.activeConnections': '活动连接', 'providers.activeProviders': '活跃提供商', @@ -1304,6 +1310,13 @@ const translations = { // Providers 'providers.title': 'Provider Pool Management', + 'providers.addGroup': 'Add Group', + 'providers.addGroup.title': 'Add New Configuration Group', + 'providers.addGroup.baseType': 'Base Type', + 'providers.addGroup.suffix': 'Suffix Name', + 'providers.addGroup.suffixPlaceholder': 'e.g., qwen, glm, minimax', + 'providers.addGroup.success': 'Configuration group created, please add accounts', + 'providers.addGroup.error': 'Creation failed', 'providers.note': 'If using default client authorization config, use an empty node', 'providers.activeConnections': 'Active Connections', 'providers.activeProviders': 'Active Providers', diff --git a/static/app/modal.js b/static/app/modal.js index f906030..60a1cd3 100644 --- a/static/app/modal.js +++ b/static/app/modal.js @@ -667,8 +667,13 @@ function renderProviderConfig(provider) { * @param {Object} provider - 提供商对象 * @returns {Array} 字段键数组 */ +/** + * 获取字段显示顺序 + * @param {Object} provider - 提供商对象 + * @returns {Array} 字段名数组 + */ function getFieldOrder(provider) { - const orderedFields = ['customName', 'checkModelName', 'checkHealth']; + const orderedFields = ['customName', 'checkModelName', 'checkHealth', 'concurrencyLimit', 'queueLimit']; // 需要排除的内部状态字段 const excludedFields = [ @@ -677,23 +682,10 @@ function getFieldOrder(provider) { 'notSupportedModels', 'refreshCount', 'needsRefresh', '_lastSelectionSeq' ]; - // 从 getProviderTypeFields 获取字段顺序映射 - const fieldOrderMap = { - 'openai-custom': ['OPENAI_API_KEY', 'OPENAI_BASE_URL'], - 'openaiResponses-custom': ['OPENAI_API_KEY', 'OPENAI_BASE_URL'], - 'claude-custom': ['CLAUDE_API_KEY', 'CLAUDE_BASE_URL'], - 'gemini-cli-oauth': ['PROJECT_ID', 'GEMINI_OAUTH_CREDS_FILE_PATH', 'GEMINI_BASE_URL'], - '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-codex-oauth': ['CODEX_OAUTH_CREDS_FILE_PATH', 'CODEX_EMAIL', 'CODEX_BASE_URL'], - 'grok-custom': ['GROK_COOKIE_TOKEN', 'GROK_CF_CLEARANCE', 'GROK_USER_AGENT', 'GROK_BASE_URL'], - 'forward-api': ['FORWARD_API_KEY', 'FORWARD_BASE_URL', 'FORWARD_HEADER_NAME', 'FORWARD_HEADER_VALUE_PREFIX'] - }; - - // 尝试从全局或当前模态框上下文中推断提供商类型 + // 尝试从当前模态框上下文中获取提供商类型 let providerType = currentProviderType; + + // 如果没有上下文类型,尝试从对象字段推断(回退逻辑) if (!providerType) { if (provider.OPENAI_API_KEY && provider.OPENAI_BASE_URL) { providerType = 'openai-custom'; @@ -718,8 +710,9 @@ function getFieldOrder(provider) { } } - // 获取该类型应该具有的所有字段(预定义顺序) - const predefinedOrder = providerType ? (fieldOrderMap[providerType] || []) : []; + // 直接从 utils.js 获取该类型的预定义字段列表(支持前缀匹配) + const predefinedFields = providerType ? getProviderTypeFields(providerType) : []; + const predefinedOrder = predefinedFields.map(f => f.id); // 获取当前对象中存在且不在预定义列表中的其他字段 const otherFields = Object.keys(provider).filter(key => @@ -734,12 +727,8 @@ function getFieldOrder(provider) { // 只有在字段确实存在于 provider 中,或者它是该提供商类型的预定义字段时才显示 return allExpectedFields.filter(key => - provider.hasOwnProperty(key) || predefinedOrder.includes(key) + Object.prototype.hasOwnProperty.call(provider, key) || predefinedOrder.includes(key) ); - - // 如果无法识别提供商类型,按字母顺序排序 - otherFields.sort(); - return [...orderedFields, ...otherFields].filter(key => provider.hasOwnProperty(key)); } /** diff --git a/static/app/models-manager.js b/static/app/models-manager.js index 25a5099..b976b13 100644 --- a/static/app/models-manager.js +++ b/static/app/models-manager.js @@ -199,10 +199,23 @@ function getProviderDisplayName(providerType) { 'openaiResponses-custom': 'OpenAI Responses Custom', 'openai-qwen-oauth': 'Qwen (OAuth)', 'openai-iflow': 'iFlow', - 'openai-codex-oauth': 'OpenAI Codex (OAuth)' + 'openai-codex-oauth': 'OpenAI Codex (OAuth)', + 'grok-custom': 'Grok Reverse' }; - return displayNames[providerType] || providerType; + if (displayNames[providerType]) { + return displayNames[providerType]; + } + + // 尝试前缀匹配 + for (const baseType in displayNames) { + if (providerType.startsWith(baseType + '-')) { + const suffix = providerType.substring(baseType.length + 1); + return `${displayNames[baseType]} (${suffix})`; + } + } + + return providerType; } /** @@ -220,13 +233,22 @@ function getProviderIcon(providerType) { } } - if (providerType.includes('gemini')) { - return 'fas fa-gem'; - } else if (providerType.includes('claude')) { - return 'fas fa-robot'; - } else if (providerType.includes('openai') || providerType.includes('qwen') || providerType.includes('iflow')) { - return 'fas fa-brain'; + const iconMap = { + 'gemini': 'fas fa-gem', + 'claude': 'fas fa-robot', + 'openai': 'fas fa-brain', + 'qwen': 'fas fa-brain', + 'iflow': 'fas fa-brain', + 'forward': 'fas fa-share-square', + 'grok': 'fas fa-search' + }; + + for (const key in iconMap) { + if (providerType.includes(key)) { + return iconMap[key]; + } } + return 'fas fa-server'; } diff --git a/static/app/provider-manager.js b/static/app/provider-manager.js index 1a9b12f..6238720 100644 --- a/static/app/provider-manager.js +++ b/static/app/provider-manager.js @@ -1,7 +1,7 @@ // 提供商管理功能模块 import { providerStats, updateProviderStats } from './constants.js'; -import { showToast, formatUptime, getProviderConfigs } from './utils.js'; +import { showToast, formatUptime, getProviderConfigs, getBaseProviderConfigs } from './utils.js'; import { fileUploadHandler } from './file-upload.js'; import { t, getCurrentLanguage } from './i18n.js'; import { renderRoutingExamples } from './routing-examples.js'; @@ -178,34 +178,36 @@ function updateTimeDisplay() { } /** - * 加载提供商列表 + * 加载提供商数据 + * @param {boolean} forceRefreshSupported - 是否强制刷新支持的提供商列表 */ -async function loadProviders() { +async function loadProviders(forceRefreshSupported = false) { try { - const providers = await window.apiClient.get('/providers'); + // 获取合并后的数据(包括 providers 和 supportedProviders) + const data = await window.apiClient.get('/providers'); + if (!data || !data.providers) return; - // 动态更新其他模块的提供商信息,只需更新一次 - if (!isStaticProviderConfigsUpdated) { - cachedSupportedProviders = await window.apiClient.get('/providers/supported'); + const { providers, supportedProviders } = data; + + // 检查支持列表是否发生了变化(或者是否尚未初始化) + const isChanged = !cachedSupportedProviders || + supportedProviders.length !== cachedSupportedProviders.length || + supportedProviders.some((p, i) => p !== cachedSupportedProviders[i]); + + // 如果强制刷新或是对象类型(可能是由事件触发),则也视为需要刷新 + const shouldForce = forceRefreshSupported === true || (typeof forceRefreshSupported === 'object'); + + if (isChanged || shouldForce) { + cachedSupportedProviders = supportedProviders; const providerConfigs = getProviderConfigs(cachedSupportedProviders); - // 动态更新凭据文件管理的提供商类型筛选项 - updateProviderFilterOptions(providerConfigs); - - // 动态更新仪表盘页面的路径路由调用示例 - renderRoutingExamples(providerConfigs); - - // 动态更新仪表盘页面的可用模型列表提供商信息 + // 动态更新各个页面的提供商信息 updateModelsProviderConfigs(providerConfigs); - - // 动态更新配置教程页面的提供商信息 updateTutorialProviderConfigs(providerConfigs); - - // 动态更新用量查询页面的提供商信息 updateUsageProviderConfigs(providerConfigs); - - // 动态更新配置管理页面的提供商选择标签 updateConfigProviderConfigs(providerConfigs); + updateProviderFilterOptions(providerConfigs); + renderRoutingExamples(providerConfigs); isStaticProviderConfigsUpdated = true; } @@ -319,6 +321,7 @@ function renderProviders(providers, supportedProviders = []) { ${displayName}
+ ${generateAddGroupButton(providerType)} ${generateAuthButton(providerType)}
@@ -359,6 +362,60 @@ function renderProviders(providers, supportedProviders = []) { container.appendChild(providerDiv); + // 为添加分组按钮添加事件监听 + const addGroupBtn = providerDiv.querySelector('.add-group-btn'); + if (addGroupBtn) { + addGroupBtn.addEventListener('click', (e) => { + e.stopPropagation(); + + // 使用自定义的主题风格 Prompt + showSimplePrompt( + t('providers.addGroup.title'), + t('providers.addGroup.suffixPlaceholder'), + async (suffix) => { + const cleanSuffix = suffix.toLowerCase().replace(/[^a-z0-9]/g, ''); + if (!cleanSuffix) { + showToast(t('common.warning'), '请输入有效的后缀(仅限字母和数字)', 'warning'); + return; + } + + const newProviderType = `${providerType}-${cleanSuffix}`; + + // 显示加载状态 + addGroupBtn.disabled = true; + const originalHtml = addGroupBtn.innerHTML; + addGroupBtn.innerHTML = ''; + + try { + const response = await window.apiClient.post('/providers', { + providerType: newProviderType, + providerConfig: { + customName: cleanSuffix.toUpperCase(), + isHealthy: true, + isDisabled: false, + usageCount: 0, + errorCount: 0 + } + }); + + if (response.success) { + showToast(t('common.success'), t('providers.addGroup.success'), 'success'); + await loadProviders(true); + setTimeout(() => openProviderManager(newProviderType), 500); + } else { + throw new Error(response.error?.message || 'Unknown error'); + } + } catch (error) { + console.error('Failed to add provider group:', error); + showToast(t('common.error'), t('providers.addGroup.error') + ': ' + error.message, 'error'); + addGroupBtn.disabled = false; + addGroupBtn.innerHTML = originalHtml; + } + } + ); + }); + } + // 为授权按钮添加事件监听 const authBtn = providerDiv.querySelector('.generate-auth-btn'); if (authBtn) { @@ -486,6 +543,74 @@ function generateAuthButton(providerType) { `; } +/** + * 显示一个极简的主题风格输入框 + * @param {string} title - 标题 + * @param {string} placeholder - 占位符 + * @param {function} callback - 确认回调 + */ +function showSimplePrompt(title, placeholder, callback) { + const overlay = document.createElement('div'); + overlay.className = 'modal-overlay'; + overlay.style.display = 'flex'; + overlay.style.zIndex = '3000'; + overlay.style.background = 'rgba(0, 0, 0, 0.2)'; + overlay.style.backdropFilter = 'blur(2px)'; + + overlay.innerHTML = ` + + `; + + document.body.appendChild(overlay); + + const input = overlay.querySelector('#simple-prompt-input'); + const submitBtn = overlay.querySelector('#simple-prompt-submit'); + + input.focus(); + + const finish = () => { + const val = input.value.trim(); + if (val) { + overlay.remove(); + callback(val); + } + }; + + submitBtn.onclick = finish; + input.onkeydown = (e) => { + if (e.key === 'Enter') finish(); + if (e.key === 'Escape') overlay.remove(); + }; + overlay.onclick = (e) => { + if (e.target === overlay) overlay.remove(); + }; +} + +/** + * 生成添加分组按钮HTML + * @param {string} providerType - 提供商类型 + * @returns {string} 按钮HTML + */ +function generateAddGroupButton(providerType) { + const allowedTypes = ['claude-custom', 'openai-custom', 'openaiResponses-custom']; + if (!allowedTypes.includes(providerType)) { + return ''; + } + + return ` + + `; +} + /** * 处理生成授权链接 * @param {string} providerType - 提供商类型 @@ -3121,16 +3246,148 @@ async function restartServiceAfterUpdate() { } } +/** + * 显示添加提供商组模态框 + * @param {string} defaultBaseType - 默认的基础类型 + */ +function showAddProviderGroupModal(defaultBaseType = null) { + const modal = document.createElement('div'); + modal.className = 'modal-overlay'; + modal.style.display = 'flex'; + modal.style.zIndex = '2000'; + + // 获取所有基础母版配置,并过滤掉当前已经存在的“自定义组” + // 确保下拉菜单只显示纯净的基础类型(如 openai-custom),而不显示已有的带后缀组 + const allBaseConfigs = getBaseProviderConfigs(); + const baseTypes = allBaseConfigs.filter(config => { + // 1. 必须在后端支持的列表中 + const isSupported = cachedSupportedProviders.includes(config.id); + + // 2. 限制只能添加特定类型的配置组 (Claude Custom, OpenAI Custom, OpenAI Responses) + const allowedTypes = ['claude-custom', 'openai-custom', 'openaiResponses-custom']; + const isAllowed = allowedTypes.includes(config.id); + + return isSupported && isAllowed; + }); + + let optionsHtml = baseTypes.map(type => { + const selected = (defaultBaseType && type.id === defaultBaseType) ? 'selected' : ''; + return ``; + }).join(''); + + const selectedConfig = allBaseConfigs.find(c => c.id === defaultBaseType); + const baseTypeSectionHtml = defaultBaseType ? ` +
+ +
+ + ${selectedConfig?.name || defaultBaseType} +
+ +
+ ` : ` +
+ + +
+ `; + + modal.innerHTML = ` + + `; + + document.body.appendChild(modal); + + const closeBtn = modal.querySelector('.modal-close'); + const cancelBtn = modal.querySelector('.modal-cancel'); + const submitBtn = modal.querySelector('.modal-submit'); + const suffixInput = modal.querySelector('#groupSuffix'); + const baseTypeSelect = modal.querySelector('#groupBaseType'); + + const closeModal = () => modal.remove(); + + [closeBtn, cancelBtn].forEach(btn => btn.addEventListener('click', closeModal)); + + submitBtn.addEventListener('click', async () => { + const baseType = baseTypeSelect.value; + const suffix = suffixInput.value.trim().toLowerCase().replace(/[^a-z0-9]/g, ''); + + if (!suffix) { + showToast(t('common.warning'), '请输入有效的后缀(仅限字母和数字)', 'warning'); + return; + } + + const newProviderType = `${baseType}-${suffix}`; + + submitBtn.disabled = true; + submitBtn.innerHTML = ''; + + try { + // 创建一个带后缀的新提供商组,并添加一个初始的空配置(或者让用户在随后的模态框中添加) + // 这里我们先创建一个临时的空配置,这样组就会在 dashboard 中显示出来 + const response = await window.apiClient.post('/providers', { + providerType: newProviderType, + providerConfig: { + customName: suffix.toUpperCase(), + isHealthy: true, + isDisabled: false, + usageCount: 0, + errorCount: 0 + } + }); + + if (response.success) { + showToast(t('common.success'), t('providers.addGroup.success'), 'success'); + closeModal(); + // 重新加载提供商列表,强制刷新支持的类型 + await loadProviders(true); + // 自动打开新创建的组的管理界面 + setTimeout(() => openProviderManager(newProviderType), 500); + } else { + throw new Error(response.error?.message || 'Unknown error'); + } + } catch (error) { + console.error('Failed to add provider group:', error); + showToast(t('common.error'), t('providers.addGroup.error') + ': ' + error.message, 'error'); + submitBtn.disabled = false; + submitBtn.innerHTML = ` ${t('common.confirm')}`; + } + }); +} + export { loadSystemInfo, updateTimeDisplay, loadProviders, - renderProviders, - updateProviderStatsDisplay, openProviderManager, showAuthModal, executeGenerateAuthUrl, handleGenerateAuthUrl, checkUpdate, - performUpdate + performUpdate, + showAddProviderGroupModal }; diff --git a/static/app/routing-examples.js b/static/app/routing-examples.js index ed71b3b..4caf762 100644 --- a/static/app/routing-examples.js +++ b/static/app/routing-examples.js @@ -453,8 +453,17 @@ function renderRoutingExamples(providerConfigs) { let routeInfo = routes.find(r => r.provider === config.id); - // 如果没找到,则创建一个默认的 + // 如果没找到,则创建一个默认的,并尝试继承基础类型的徽章 if (!routeInfo) { + // 尝试查找基础类型的路由信息以获取徽章 + let baseRouteInfo = null; + for (const r of routes) { + if (config.id.startsWith(r.provider + '-')) { + baseRouteInfo = r; + break; + } + } + routeInfo = { provider: config.id, name: config.name, @@ -462,14 +471,35 @@ function renderRoutingExamples(providerConfigs) { openai: `/${config.id}/v1/chat/completions`, claude: `/${config.id}/v1/messages` }, - description: t('dashboard.routing.oauth'), - badge: t('dashboard.routing.oauth'), - badgeClass: 'oauth' + description: baseRouteInfo ? baseRouteInfo.description : t('dashboard.routing.oauth'), + badge: baseRouteInfo ? baseRouteInfo.badge : t('dashboard.routing.oauth'), + badgeClass: baseRouteInfo ? baseRouteInfo.badgeClass : 'oauth' }; } - const icon = iconMap[config.id] || 'fa-route'; - const defaultModel = modelMap[config.id] || 'default-model'; + // 确定图标:尝试精确匹配,然后尝试前缀匹配 + let icon = iconMap[config.id]; + if (!icon) { + for (const baseId in iconMap) { + if (config.id.startsWith(baseId + '-')) { + icon = iconMap[baseId]; + break; + } + } + } + icon = icon || 'fa-route'; + + // 确定默认模型:尝试精确匹配,然后尝试前缀匹配 + let defaultModel = modelMap[config.id]; + if (!defaultModel) { + for (const baseId in modelMap) { + if (config.id.startsWith(baseId + '-')) { + defaultModel = modelMap[baseId]; + break; + } + } + } + defaultModel = defaultModel || 'default-model'; const hostname = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1' ? `http://${window.location.host}` : `${window.location.protocol}//${window.location.host}`; diff --git a/static/app/utils.js b/static/app/utils.js index f796c45..d43e29e 100644 --- a/static/app/utils.js +++ b/static/app/utils.js @@ -7,83 +7,115 @@ import { apiClient } from './auth.js'; * @param {string[]} supportedProviders - 已注册的提供商类型列表 * @returns {Object[]} 提供商配置对象数组 */ -function getProviderConfigs(supportedProviders = []) { +/** + * 获取所有基础提供商配置(母版) + * @returns {Object[]} 基础提供商配置数组 + */ +function getBaseProviderConfigs() { return [ { id: 'forward-api', name: 'NewAPI', - icon: 'fa-share-square', - visible: supportedProviders.includes('forward-api') + icon: 'fa-share-square' }, { id: 'gemini-cli-oauth', name: t('dashboard.routing.nodeName.gemini'), icon: 'fa-robot', - defaultPath: 'configs/gemini/', - visible: supportedProviders.includes('gemini-cli-oauth') + defaultPath: 'configs/gemini/' }, { id: 'gemini-antigravity', name: t('dashboard.routing.nodeName.antigravity'), icon: 'fa-rocket', - defaultPath: 'configs/antigravity/', - visible: supportedProviders.includes('gemini-antigravity') + defaultPath: 'configs/antigravity/' }, { id: 'claude-kiro-oauth', name: t('dashboard.routing.nodeName.kiro'), icon: 'fa-key', - defaultPath: 'configs/kiro/', - visible: supportedProviders.includes('claude-kiro-oauth') + defaultPath: 'configs/kiro/' }, { id: 'openai-codex-oauth', name: t('dashboard.routing.nodeName.codex'), icon: 'fa-code', - defaultPath: 'configs/codex/', - visible: supportedProviders.includes('openai-codex-oauth') + defaultPath: 'configs/codex/' }, { id: 'openai-qwen-oauth', name: t('dashboard.routing.nodeName.qwen'), icon: 'fa-cloud', - defaultPath: 'configs/qwen/', - visible: supportedProviders.includes('openai-qwen-oauth') + defaultPath: 'configs/qwen/' }, { id: 'openai-iflow', name: t('dashboard.routing.nodeName.iflow'), icon: 'fa-stream', - defaultPath: 'configs/iflow/', - visible: supportedProviders.includes('openai-iflow') + defaultPath: 'configs/iflow/' }, { id: 'grok-custom', name: t('dashboard.routing.nodeName.grok'), - icon: 'fa-user-secret', - visible: supportedProviders.includes('grok-custom') + icon: 'fa-user-secret' }, { id: 'openai-custom', name: t('dashboard.routing.nodeName.openai'), - icon: 'fa-microchip', - visible: supportedProviders.includes('openai-custom') + icon: 'fa-microchip' }, { id: 'claude-custom', name: t('dashboard.routing.nodeName.claude'), - icon: 'fa-brain', - visible: supportedProviders.includes('claude-custom') + icon: 'fa-brain' }, { id: 'openaiResponses-custom', name: 'OpenAI Responses', - icon: 'fa-reply-all', - visible: supportedProviders.includes('openaiResponses-custom') + icon: 'fa-reply-all' }, ]; } +/** + * 获取所有支持的提供商配置列表 + * @param {string[]} supportedProviders - 已注册的提供商类型列表 + * @returns {Object[]} 提供商配置对象数组 + */ +function getProviderConfigs(supportedProviders = []) { + const baseConfigs = getBaseProviderConfigs(); + + const result = []; + const usedIds = new Set(); + + // 1. 处理 supportedProviders 中匹配基础配置的类型 + baseConfigs.forEach(config => { + const isSupported = supportedProviders.includes(config.id); + result.push({ ...config, visible: isSupported }); + usedIds.add(config.id); + }); + + // 2. 处理带有后缀的自定义类型 (例如 openai-custom-test) + supportedProviders.forEach(providerId => { + if (usedIds.has(providerId)) return; + + // 查找匹配的前缀 + const baseConfig = baseConfigs.find(bc => providerId.startsWith(bc.id + '-')); + if (baseConfig) { + const suffix = providerId.substring(baseConfig.id.length + 1); + result.push({ + ...baseConfig, + id: providerId, + name: `${baseConfig.name} (${suffix})`, + visible: true + }); + usedIds.add(providerId); + } + }); + + return result; +} + /** * 格式化运行时间 * @param {number} seconds - 秒数 @@ -197,6 +229,7 @@ function getFieldLabel(key) { * @returns {Array} 字段配置数组 */ function getProviderTypeFields(providerType) { + // 基础配置字段定义 const fieldConfigs = { 'openai-custom': [ { @@ -420,7 +453,19 @@ function getProviderTypeFields(providerType) { ] }; - return fieldConfigs[providerType] || []; + // 1. 尝试精确匹配 + if (fieldConfigs[providerType]) { + return fieldConfigs[providerType]; + } + + // 2. 尝试匹配前缀 (例如 openai-custom-test -> openai-custom) + for (const baseType in fieldConfigs) { + if (providerType.startsWith(baseType + '-')) { + return fieldConfigs[baseType]; + } + } + + return []; } /** @@ -461,6 +506,7 @@ export { getFieldLabel, getProviderTypeFields, getProviderConfigs, + getBaseProviderConfigs, getProviderStats, apiRequest }; \ No newline at end of file diff --git a/static/components/section-providers.css b/static/components/section-providers.css index c14907d..f8304de 100644 --- a/static/components/section-providers.css +++ b/static/components/section-providers.css @@ -1138,6 +1138,28 @@ transform: translateY(-1px); } +/* 添加分组按钮样式 */ +.add-group-btn { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.25rem 0.75rem; + background: #ecfdf5; /* 绿色系背景 */ + color: #065f46; /* 绿色系文字 */ + border: none; + border-radius: 9999px; + font-size: 0.75rem; + font-weight: 500; + cursor: pointer; + transition: var(--transition); +} + +.add-group-btn:hover { + background: #10b981; + color: white; + transform: translateY(-1px); +} + /* 授权模态框样式 */ .modal-overlay { position: fixed; diff --git a/static/components/section-providers.html b/static/components/section-providers.html index 93b481a..69b520a 100644 --- a/static/components/section-providers.html +++ b/static/components/section-providers.html @@ -38,6 +38,9 @@
+
+ +