From dfce4a6aac3e1b29a03c10fd155fd174053a4198 Mon Sep 17 00:00:00 2001 From: hex2077 Date: Tue, 23 Dec 2025 17:22:42 +0800 Subject: [PATCH] =?UTF-8?q?feat(provider):=20=E6=B7=BB=E5=8A=A0=E8=B7=A8?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B=20Fallback=20=E9=93=BE=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实现当主 Provider Type 无可用账号时自动切换到配置的 Fallback 类型功能,包括: 1. 在 config.json 中添加 providerFallbackChain 配置项 2. 扩展 ProviderPoolManager 支持 Fallback 逻辑 3. 新增 getApiServiceWithFallback 方法处理带 Fallback 的服务获取 4. 更新 UI 界面和文档说明 --- README-JA.md | 62 ++++++++++++++ README-ZH.md | 62 ++++++++++++++ README.md | 62 ++++++++++++++ config.json.example | 8 +- src/common.js | 49 ++++++----- src/config-manager.js | 3 +- src/provider-pool-manager.js | 157 ++++++++++++++++++++++++++++++++++- src/service-manager.js | 54 +++++++++++- src/ui-manager.js | 4 +- static/app/config-manager.js | 23 +++++ static/app/i18n.js | 8 ++ static/index.html | 11 +++ 12 files changed, 478 insertions(+), 25 deletions(-) diff --git a/README-JA.md b/README-JA.md index ace954b..c777be5 100644 --- a/README-JA.md +++ b/README-JA.md @@ -207,6 +207,68 @@ Web UI管理インターフェースでは、極めて迅速に認証設定を 3. **起動パラメータ設定**:`--provider-pools-file ` パラメータを使用してプール設定ファイルのパスを指定します 4. **ヘルスチェック**:システムは定期的にヘルスチェックを自動実行し、健全でないプロバイダーを使用しません +#### 高度な設定 + +##### 1. モデルフィルタリング設定 + +`notSupportedModels` 設定を通じてサポートされていないモデルを除外でき、システムは自動的にこれらのプロバイダーをスキップします。 + +**設定方法**:`provider_pools.json` でプロバイダーに `notSupportedModels` フィールドを追加: + +```json +{ + "gemini-cli-oauth": [ + { + "uuid": "provider-1", + "notSupportedModels": ["gemini-3.0-pro", "gemini-3.5-flash"], + "checkHealth": true + } + ] +} +``` + +**動作原理**: +- 特定のモデルをリクエストする際、システムは自動的にそのモデルをサポートしていないと設定されたプロバイダーをフィルタリングします +- そのモデルをサポートするプロバイダーのみがリクエストを処理するために選択されます + +**使用シナリオ**: +- 一部のアカウントは割り当てまたは権限の制限により特定のモデルにアクセスできない +- 異なるアカウントに異なるモデルアクセス権限を割り当てる必要がある + +##### 2. クロスタイプフォールバック設定 + +あるProvider Type(例:`gemini-cli-oauth`)のすべてのアカウントが429割り当て制限により枯渇したり、unhealthyとマークされた場合、システムは直接エラーを返すのではなく、互換性のある別のProvider Type(例:`gemini-antigravity`)に自動的にフォールバックできます。 + +**設定方法**:`config.json` に `providerFallbackChain` 設定を追加: + +```json +{ + "providerFallbackChain": { + "gemini-cli-oauth": ["gemini-antigravity"], + "gemini-antigravity": ["gemini-cli-oauth"], + "claude-kiro-oauth": ["claude-custom"], + "claude-custom": ["claude-kiro-oauth"] + } +} +``` + +**動作原理**: +1. メインのProvider Typeプールからhealthyなアカウントを選択しようとします +2. そのタイプのすべてのアカウントがunhealthyまたは429を返す場合: + - 設定されたフォールバックタイプを検索 + - フォールバックタイプがリクエストされたモデルをサポートしているか確認(プロトコル互換性チェック) + - フォールバックタイプのプールからhealthyなアカウントを選択 +3. 多段階降格チェーンをサポート:`gemini-cli-oauth → gemini-antigravity → openai-custom` +4. すべてのフォールバックタイプも利用できない場合のみエラーを返します + +**使用シナリオ**: +- バッチタスクシナリオでは、単一のProvider Typeの無料RPD割り当てが短時間で簡単に枯渇する可能性があります +- クロスタイプフォールバックを通じて、複数のProviderの独立した割り当てを十分に活用し、全体的な可用性とスループットを向上させることができます + +**注意事項**: +- フォールバックはプロトコル互換タイプ間でのみ発生します(例:`gemini-*` 間、`claude-*` 間) +- システムは自動的にターゲットProvider Typeがリクエストされたモデルをサポートしているか確認します + --- ### 📁 認証ファイル保存パス diff --git a/README-ZH.md b/README-ZH.md index d663989..d1b2d1a 100644 --- a/README-ZH.md +++ b/README-ZH.md @@ -206,6 +206,68 @@ 3. **启动参数配置**:使用 `--provider-pools-file ` 参数指定号池配置文件路径 4. **健康检查**:系统会定期自动执行健康检查,不使用不健康的提供商 +#### 高级配置 + +##### 1. 模型过滤配置 + +支持通过 `notSupportedModels` 配置排除不支持的模型,系统会自动跳过这些提供商。 + +**配置方式**:在 `provider_pools.json` 中为提供商添加 `notSupportedModels` 字段: + +```json +{ + "gemini-cli-oauth": [ + { + "uuid": "provider-1", + "notSupportedModels": ["gemini-3.0-pro", "gemini-3.5-flash"], + "checkHealth": true + } + ] +} +``` + +**工作原理**: +- 当请求特定模型时,系统会自动过滤掉配置了该模型为不支持的提供商 +- 只有支持该模型的提供商才会被选中处理请求 + +**使用场景**: +- 某些账号因配额或权限限制无法访问特定模型 +- 需要为不同账号分配不同的模型访问权限 + +##### 2. 跨类型 Fallback 配置 + +当某一 Provider Type(如 `gemini-cli-oauth`)下的所有账号都因 429 配额耗尽或被标记为 unhealthy 时,系统能够自动 fallback 到另一个兼容的 Provider Type(如 `gemini-antigravity`),而不是直接返回错误。 + +**配置方式**:在 `config.json` 中添加 `providerFallbackChain` 配置: + +```json +{ + "providerFallbackChain": { + "gemini-cli-oauth": ["gemini-antigravity"], + "gemini-antigravity": ["gemini-cli-oauth"], + "claude-kiro-oauth": ["claude-custom"], + "claude-custom": ["claude-kiro-oauth"] + } +} +``` + +**工作原理**: +1. 尝试从主 Provider Type 池选取 healthy 账号 +2. 如果该类型所有账号都 unhealthy: + - 查找配置的 fallback 类型 + - 检查 fallback 类型是否支持当前请求的模型(协议兼容性检查) + - 从 fallback 类型的池中选取 healthy 账号 +3. 支持多级降级链:`gemini-cli-oauth → gemini-antigravity → openai-custom` +4. 如果所有 fallback 类型也不可用,才返回错误 + +**使用场景**: +- 批量任务场景下,单一 Provider Type 的免费 RPD 配额容易在短时间内耗尽 +- 通过跨类型 Fallback,可以充分利用多种 Provider 的独立配额,提高整体可用性和吞吐量 + +**注意事项**: +- Fallback 只会在协议兼容的类型之间进行(如 `gemini-*` 之间、`claude-*` 之间) +- 系统会自动检查目标 Provider Type 是否支持当前请求的模型 + --- ### 📁 授权文件存储路径 diff --git a/README.md b/README.md index dee1aec..f418c0d 100644 --- a/README.md +++ b/README.md @@ -207,6 +207,68 @@ In the Web UI management interface, you can complete authorization configuration 3. **Startup Parameter Configuration**: Use the `--provider-pools-file ` parameter to specify the pool configuration file path 4. **Health Check**: The system will automatically perform periodic health checks and avoid using unhealthy providers +#### Advanced Configuration + +##### 1. Model Filtering Configuration + +Support excluding unsupported models through `notSupportedModels` configuration, the system will automatically skip these providers. + +**Configuration**: Add `notSupportedModels` field for providers in `provider_pools.json`: + +```json +{ + "gemini-cli-oauth": [ + { + "uuid": "provider-1", + "notSupportedModels": ["gemini-3.0-pro", "gemini-3.5-flash"], + "checkHealth": true + } + ] +} +``` + +**How It Works**: +- When requesting a specific model, the system automatically filters out providers that have configured the model as unsupported +- Only providers that support the model will be selected to handle the request + +**Use Cases**: +- Some accounts cannot access specific models due to quota or permission restrictions +- Need to assign different model access permissions to different accounts + +##### 2. Cross-Type Fallback Configuration + +When all accounts under a Provider Type (e.g., `gemini-cli-oauth`) are exhausted due to 429 quota limits or marked as unhealthy, the system can automatically fallback to another compatible Provider Type (e.g., `gemini-antigravity`) instead of returning an error directly. + +**Configuration**: Add `providerFallbackChain` configuration in `config.json`: + +```json +{ + "providerFallbackChain": { + "gemini-cli-oauth": ["gemini-antigravity"], + "gemini-antigravity": ["gemini-cli-oauth"], + "claude-kiro-oauth": ["claude-custom"], + "claude-custom": ["claude-kiro-oauth"] + } +} +``` + +**How It Works**: +1. Try to select a healthy account from the primary Provider Type pool +2. If all accounts in that type are unhealthy or return 429: + - Look up the configured fallback types + - Check if the fallback type supports the requested model (protocol compatibility check) + - Select a healthy account from the fallback type's pool +3. Supports multi-level degradation chains: `gemini-cli-oauth → gemini-antigravity → openai-custom` +4. Only returns an error if all fallback types are also unavailable + +**Use Cases**: +- In batch task scenarios, the free RPD quota of a single Provider Type can be easily exhausted in a short time +- Through cross-type Fallback, you can fully utilize the independent quotas of multiple Providers, improving overall availability and throughput + +**Notes**: +- Fallback only occurs between protocol-compatible types (e.g., between `gemini-*`, between `claude-*`) +- The system automatically checks if the target Provider Type supports the requested model + --- ### 📁 Authorization File Storage Paths diff --git a/config.json.example b/config.json.example index 4418014..9a1e3ae 100644 --- a/config.json.example +++ b/config.json.example @@ -33,5 +33,11 @@ "KIRO_REFRESH_IDC_URL": "https://oidc.{{region}}.amazonaws.com/token", "KIRO_BASE_URL": "https://codewhisperer.{{region}}.amazonaws.com/generateAssistantResponse", "KIRO_AMAZON_Q_URL": "https://codewhisperer.{{region}}.amazonaws.com/SendMessageStreaming", - "KIRO_USAGE_LIMITS_URL": "https://q.{{region}}.amazonaws.com/getUsageLimits" + "KIRO_USAGE_LIMITS_URL": "https://q.{{region}}.amazonaws.com/getUsageLimits", + "providerFallbackChain": { + "gemini-cli-oauth": ["gemini-antigravity"], + "gemini-antigravity": ["gemini-cli-oauth"], + "claude-kiro-oauth": ["claude-custom"], + "claude-custom": ["claude-kiro-oauth"] + } } \ No newline at end of file diff --git a/src/common.js b/src/common.js index 590f8ee..e4f866f 100644 --- a/src/common.js +++ b/src/common.js @@ -384,12 +384,39 @@ export async function handleContentGenerationRequest(req, res, service, endpoint }; const fromProvider = clientProviderMap[endpointType]; - const toProvider = CONFIG.MODEL_PROVIDER; + // 使用实际的提供商类型(可能是 fallback 后的类型) + let toProvider = CONFIG.actualProviderType || CONFIG.MODEL_PROVIDER; + let actualUuid = pooluuid; if (!fromProvider) { throw new Error(`Unsupported endpoint type for content generation: ${endpointType}`); } + // 2. Extract model and determine if the request is for streaming. + const { model, isStream } = _extractModelAndStreamInfo(req, originalRequestBody, fromProvider); + + if (!model) { + throw new Error("Could not determine the model from the request."); + } + console.log(`[Content Generation] Model: ${model}, Stream: ${isStream}`); + + // 2.5. 如果使用了提供商池,根据模型重新选择提供商(支持 Fallback) + // 注意:这里使用 skipUsageCount: true,因为初次选择时已经增加了 usageCount + if (providerPoolManager && CONFIG.providerPools && CONFIG.providerPools[CONFIG.MODEL_PROVIDER]) { + const { getApiServiceWithFallback } = await import('./service-manager.js'); + const result = await getApiServiceWithFallback(CONFIG, model); + + service = result.service; + toProvider = result.actualProviderType; + actualUuid = result.uuid || pooluuid; + + if (result.isFallback) { + console.log(`[Content Generation] Fallback activated: ${CONFIG.MODEL_PROVIDER} -> ${toProvider} (uuid: ${actualUuid})`); + } else { + console.log(`[Content Generation] Re-selected service adapter based on model: ${model}`); + } + } + // 1. Convert request body from client format to backend format, if necessary. let processedRequestBody = originalRequestBody; // fs.writeFile('originalRequestBody'+Date.now()+'.json', JSON.stringify(originalRequestBody)); @@ -400,22 +427,6 @@ export async function handleContentGenerationRequest(req, res, service, endpoint console.log(`[Request Convert] Request format matches backend provider. No conversion needed.`); } - // 2. Extract model and determine if the request is for streaming. - const { model, isStream } = _extractModelAndStreamInfo(req, originalRequestBody, fromProvider); - - if (!model) { - throw new Error("Could not determine the model from the request."); - } - console.log(`[Content Generation] Model: ${model}, Stream: ${isStream}`); - - // 2.5. 如果使用了提供商池,根据模型重新选择提供商 - // 注意:这里使用 skipUsageCount: true,因为初次选择时已经增加了 usageCount - if (providerPoolManager && CONFIG.providerPools && CONFIG.providerPools[CONFIG.MODEL_PROVIDER]) { - const { getApiService } = await import('./service-manager.js'); - service = await getApiService(CONFIG, model); - console.log(`[Content Generation] Re-selected service adapter based on model: ${model}`); - } - // 3. Apply system prompt from file if configured. processedRequestBody = await _applySystemPromptFromFile(CONFIG, processedRequestBody, toProvider); await _manageSystemPrompt(processedRequestBody, toProvider); @@ -426,9 +437,9 @@ export async function handleContentGenerationRequest(req, res, service, endpoint // 5. Call the appropriate stream or unary handler, passing the provider info. if (isStream) { - await handleStreamRequest(res, service, model, processedRequestBody, fromProvider, toProvider, CONFIG.PROMPT_LOG_MODE, PROMPT_LOG_FILENAME, providerPoolManager, pooluuid); + await handleStreamRequest(res, service, model, processedRequestBody, fromProvider, toProvider, CONFIG.PROMPT_LOG_MODE, PROMPT_LOG_FILENAME, providerPoolManager, actualUuid); } else { - await handleUnaryRequest(res, service, model, processedRequestBody, fromProvider, toProvider, CONFIG.PROMPT_LOG_MODE, PROMPT_LOG_FILENAME, providerPoolManager, pooluuid); + await handleUnaryRequest(res, service, model, processedRequestBody, fromProvider, toProvider, CONFIG.PROMPT_LOG_MODE, PROMPT_LOG_FILENAME, providerPoolManager, actualUuid); } } diff --git a/src/config-manager.js b/src/config-manager.js index 91ad177..21f161c 100644 --- a/src/config-manager.js +++ b/src/config-manager.js @@ -95,7 +95,8 @@ export async function initializeConfig(args = process.argv.slice(2), configFileP CRON_NEAR_MINUTES: 15, CRON_REFRESH_TOKEN: false, PROVIDER_POOLS_FILE_PATH: null, // 新增号池配置文件路径 - MAX_ERROR_COUNT: 3 // 提供商最大错误次数 + MAX_ERROR_COUNT: 3, // 提供商最大错误次数 + providerFallbackChain: {} // 跨类型 Fallback 链配置 }; console.log('[Config] Using default configuration.'); } diff --git a/src/provider-pool-manager.js b/src/provider-pool-manager.js index 6a5bc98..73f193e 100644 --- a/src/provider-pool-manager.js +++ b/src/provider-pool-manager.js @@ -1,6 +1,7 @@ import * as fs from 'fs'; // Import fs module import { getServiceAdapter } from './adapter.js'; -import { MODEL_PROVIDER } from './common.js'; +import { MODEL_PROVIDER, getProtocolPrefix } from './common.js'; +import { getProviderModels } from './provider-models.js'; import axios from 'axios'; /** @@ -36,6 +37,9 @@ export class ProviderPoolManager { this.saveTimer = null; this.pendingSaves = new Set(); // 记录待保存的 providerType + // Fallback 链配置 + this.fallbackChain = options.globalConfig?.providerFallbackChain || {}; + this.initializeProviderStatus(); } @@ -168,6 +172,157 @@ export class ProviderPoolManager { return selected.config; } + /** + * Selects a provider from the pool with fallback support. + * When the primary provider type has no healthy providers, it will try fallback types. + * @param {string} providerType - The primary type of provider to select. + * @param {string} [requestedModel] - Optional. The model name to filter providers by. + * @param {Object} [options] - Optional. Additional options. + * @param {boolean} [options.skipUsageCount] - Optional. If true, skip incrementing usage count. + * @returns {object|null} An object containing the selected provider's configuration and the actual provider type used, or null if no healthy provider is found. + */ + selectProviderWithFallback(providerType, requestedModel = null, options = {}) { + // 参数校验 + if (!providerType || typeof providerType !== 'string') { + this._log('error', `Invalid providerType: ${providerType}`); + return null; + } + + // 记录尝试过的类型,避免循环 + const triedTypes = new Set(); + const typesToTry = [providerType]; + + // 添加 fallback 类型到尝试列表 + const fallbackTypes = this.fallbackChain[providerType]; + if (!fallbackTypes || fallbackTypes.length === 0) { + this._log('info', `No fallback types configured for ${providerType}`); + const selectedConfig = this.selectProvider(providerType, requestedModel, options); + if (selectedConfig) { + return { + config: selectedConfig, + actualProviderType: providerType, + isFallback: false + }; + } + } + + if (Array.isArray(fallbackTypes)) { + typesToTry.push(...fallbackTypes); + } + for (const currentType of typesToTry) { + // 避免重复尝试 + if (triedTypes.has(currentType)) { + continue; + } + triedTypes.add(currentType); + + // 检查该类型是否有配置的池 + if (!this.providerStatus[currentType] || this.providerStatus[currentType].length === 0) { + this._log('info', `No provider pool configured for type: ${currentType}`); + continue; + } + + // 如果是 fallback 类型,需要检查模型兼容性 + if (currentType !== providerType && requestedModel) { + // 检查协议前缀是否兼容 + const primaryProtocol = getProtocolPrefix(providerType); + const fallbackProtocol = getProtocolPrefix(currentType); + + if (primaryProtocol !== fallbackProtocol) { + this._log('info', `Skipping fallback type ${currentType}: protocol mismatch (${primaryProtocol} vs ${fallbackProtocol})`); + continue; + } + + // 检查 fallback 类型是否支持请求的模型 + const supportedModels = getProviderModels(currentType); + if (supportedModels.length > 0 && !supportedModels.includes(requestedModel)) { + this._log('info', `Skipping fallback type ${currentType}: model ${requestedModel} not supported`); + continue; + } + } + + // 尝试从当前类型选择提供商 + const selectedConfig = this.selectProvider(currentType, requestedModel, options); + + if (selectedConfig) { + if (currentType !== providerType) { + this._log('info', `Fallback activated: ${providerType} -> ${currentType} (uuid: ${selectedConfig.uuid})`); + } + return { + config: selectedConfig, + actualProviderType: currentType, + isFallback: currentType !== providerType + }; + } + } + + this._log('warn', `None available provider found for ${providerType} or any of its fallback types: ${fallbackTypes?.join(', ') || 'none configured'}`); + return null; + } + + /** + * Gets the fallback chain for a given provider type. + * @param {string} providerType - The provider type to get fallback chain for. + * @returns {Array} The fallback chain array, or empty array if not configured. + */ + getFallbackChain(providerType) { + return this.fallbackChain[providerType] || []; + } + + /** + * Sets or updates the fallback chain for a provider type. + * @param {string} providerType - The provider type to set fallback chain for. + * @param {Array} fallbackTypes - Array of fallback provider types. + */ + setFallbackChain(providerType, fallbackTypes) { + if (!Array.isArray(fallbackTypes)) { + this._log('error', `Invalid fallbackTypes: must be an array`); + return; + } + this.fallbackChain[providerType] = fallbackTypes; + this._log('info', `Updated fallback chain for ${providerType}: ${fallbackTypes.join(' -> ')}`); + } + + /** + * Checks if all providers of a given type are unhealthy. + * @param {string} providerType - The provider type to check. + * @returns {boolean} True if all providers are unhealthy or disabled. + */ + isAllProvidersUnhealthy(providerType) { + const providers = this.providerStatus[providerType] || []; + if (providers.length === 0) { + return true; + } + return providers.every(p => !p.config.isHealthy || p.config.isDisabled); + } + + /** + * Gets statistics about provider health for a given type. + * @param {string} providerType - The provider type to get stats for. + * @returns {Object} Statistics object with total, healthy, unhealthy, and disabled counts. + */ + getProviderStats(providerType) { + const providers = this.providerStatus[providerType] || []; + const stats = { + total: providers.length, + healthy: 0, + unhealthy: 0, + disabled: 0 + }; + + for (const p of providers) { + if (p.config.isDisabled) { + stats.disabled++; + } else if (p.config.isHealthy) { + stats.healthy++; + } else { + stats.unhealthy++; + } + } + + return stats; + } + /** * Marks a provider as unhealthy (e.g., after an API error). * @param {string} providerType - The type of the provider. diff --git a/src/service-manager.js b/src/service-manager.js index 93f3220..50f2efd 100644 --- a/src/service-manager.js +++ b/src/service-manager.js @@ -163,7 +163,8 @@ export async function initApiService(config) { if (config.providerPools && Object.keys(config.providerPools).length > 0) { providerPoolManager = new ProviderPoolManager(config.providerPools, { globalConfig: config, - maxErrorCount: config.MAX_ERROR_COUNT ?? 3 + maxErrorCount: config.MAX_ERROR_COUNT ?? 3, + providerFallbackChain: config.providerFallbackChain || {}, }); console.log('[Initialization] ProviderPoolManager initialized with configured pools.'); // 健康检查将在服务器完全启动后执行 @@ -232,6 +233,55 @@ export async function getApiService(config, requestedModel = null, options = {}) return getServiceAdapter(serviceConfig); } +/** + * Get API service adapter with fallback support and return detailed result + * @param {Object} config - The current request configuration + * @param {string} [requestedModel] - Optional. The model name to filter providers by. + * @param {Object} [options] - Optional. Additional options. + * @returns {Promise} Object containing service adapter and metadata + */ +export async function getApiServiceWithFallback(config, requestedModel = null, options = {}) { + let serviceConfig = config; + let actualProviderType = config.MODEL_PROVIDER; + let isFallback = false; + let selectedUuid = null; + + if (providerPoolManager && config.providerPools && config.providerPools[config.MODEL_PROVIDER]) { + const selectedResult = providerPoolManager.selectProviderWithFallback( + config.MODEL_PROVIDER, + requestedModel, + { skipUsageCount: true } + ); + + if (selectedResult) { + const { config: selectedProviderConfig, actualProviderType: selectedType, isFallback: fallbackUsed } = selectedResult; + + // 合并选中的提供者配置到当前请求的 config 中 + serviceConfig = deepmerge(config, selectedProviderConfig); + delete serviceConfig.providerPools; + + actualProviderType = selectedType; + isFallback = fallbackUsed; + selectedUuid = selectedProviderConfig.uuid; + + // 如果发生了 fallback,需要更新 MODEL_PROVIDER + if (isFallback) { + serviceConfig.MODEL_PROVIDER = actualProviderType; + } + } + } + + const service = getServiceAdapter(serviceConfig); + + return { + service, + serviceConfig, + actualProviderType, + isFallback, + uuid: selectedUuid + }; +} + /** * Get the provider pool manager instance * @returns {Object} The provider pool manager @@ -343,4 +393,4 @@ export async function getProviderStatus(config, options = {}) { unhealthyCount, unhealthyRatio }; -} \ No newline at end of file +} diff --git a/src/ui-manager.js b/src/ui-manager.js index 626af9a..4ec0f1c 100644 --- a/src/ui-manager.js +++ b/src/ui-manager.js @@ -649,6 +649,7 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo if (newConfig.CRON_REFRESH_TOKEN !== undefined) currentConfig.CRON_REFRESH_TOKEN = newConfig.CRON_REFRESH_TOKEN; if (newConfig.PROVIDER_POOLS_FILE_PATH !== undefined) currentConfig.PROVIDER_POOLS_FILE_PATH = newConfig.PROVIDER_POOLS_FILE_PATH; if (newConfig.MAX_ERROR_COUNT !== undefined) currentConfig.MAX_ERROR_COUNT = newConfig.MAX_ERROR_COUNT; + if (newConfig.providerFallbackChain !== undefined) currentConfig.providerFallbackChain = newConfig.providerFallbackChain; // Handle system prompt update if (newConfig.systemPrompt !== undefined) { @@ -711,7 +712,8 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo CRON_NEAR_MINUTES: currentConfig.CRON_NEAR_MINUTES, CRON_REFRESH_TOKEN: currentConfig.CRON_REFRESH_TOKEN, PROVIDER_POOLS_FILE_PATH: currentConfig.PROVIDER_POOLS_FILE_PATH, - MAX_ERROR_COUNT: currentConfig.MAX_ERROR_COUNT + MAX_ERROR_COUNT: currentConfig.MAX_ERROR_COUNT, + providerFallbackChain: currentConfig.providerFallbackChain }; writeFileSync(configPath, JSON.stringify(configToSave, null, 2), 'utf-8'); diff --git a/static/app/config-manager.js b/static/app/config-manager.js index 55c4f69..42c8286 100644 --- a/static/app/config-manager.js +++ b/static/app/config-manager.js @@ -93,6 +93,7 @@ async function loadConfiguration() { const cronRefreshTokenEl = document.getElementById('cronRefreshToken'); const providerPoolsFilePathEl = document.getElementById('providerPoolsFilePath'); const maxErrorCountEl = document.getElementById('maxErrorCount'); + const providerFallbackChainEl = document.getElementById('providerFallbackChain'); if (systemPromptFilePathEl) systemPromptFilePathEl.value = data.SYSTEM_PROMPT_FILE_PATH || 'input_system_prompt.txt'; if (systemPromptModeEl) systemPromptModeEl.value = data.SYSTEM_PROMPT_MODE || 'append'; @@ -104,6 +105,15 @@ async function loadConfiguration() { if (cronRefreshTokenEl) cronRefreshTokenEl.checked = data.CRON_REFRESH_TOKEN || false; if (providerPoolsFilePathEl) providerPoolsFilePathEl.value = data.PROVIDER_POOLS_FILE_PATH; if (maxErrorCountEl) maxErrorCountEl.value = data.MAX_ERROR_COUNT || 3; + + // 加载 Fallback 链配置 + if (providerFallbackChainEl) { + if (data.providerFallbackChain && typeof data.providerFallbackChain === 'object') { + providerFallbackChainEl.value = JSON.stringify(data.providerFallbackChain, null, 2); + } else { + providerFallbackChainEl.value = ''; + } + } // 触发提供商配置显示 handleProviderChange(); @@ -223,6 +233,19 @@ async function saveConfiguration() { config.CRON_REFRESH_TOKEN = document.getElementById('cronRefreshToken')?.checked || false; config.PROVIDER_POOLS_FILE_PATH = document.getElementById('providerPoolsFilePath')?.value || ''; config.MAX_ERROR_COUNT = parseInt(document.getElementById('maxErrorCount')?.value || 3); + + // 保存 Fallback 链配置 + const fallbackChainValue = document.getElementById('providerFallbackChain')?.value?.trim() || ''; + if (fallbackChainValue) { + try { + config.providerFallbackChain = JSON.parse(fallbackChainValue); + } catch (e) { + showToast(t('common.error'), t('config.advanced.fallbackChainInvalid') || 'Fallback 链配置格式无效,请输入有效的 JSON', 'error'); + return; + } + } else { + config.providerFallbackChain = {}; + } try { await window.apiClient.post('/config', config); diff --git a/static/app/i18n.js b/static/app/i18n.js index e326a7e..6afdd2b 100644 --- a/static/app/i18n.js +++ b/static/app/i18n.js @@ -164,6 +164,10 @@ const translations = { 'config.advanced.maxErrorCount': '提供商最大错误次数', 'config.advanced.maxErrorCountPlaceholder': '默认: 3', 'config.advanced.maxErrorCountNote': '提供商连续错误达到此次数后将被标记为不健康,默认为 3 次', + 'config.advanced.fallbackChain': '跨类型 Fallback 链配置', + 'config.advanced.fallbackChainPlaceholder': '例如:\n{\n "gemini-cli-oauth": ["gemini-antigravity"],\n "gemini-antigravity": ["gemini-cli-oauth"],\n "claude-kiro-oauth": ["claude-custom"]\n}', + 'config.advanced.fallbackChainNote': '当某一 Provider Type 所有账号都不健康时,自动切换到配置的 Fallback 类型。JSON 格式,键为主类型,值为 Fallback 类型数组(按优先级排序)', + 'config.advanced.fallbackChainInvalid': 'Fallback 链配置格式无效,请输入有效的 JSON', 'config.advanced.systemPrompt': '系统提示', 'config.advanced.systemPromptPlaceholder': '输入系统提示...', 'config.advanced.adminPassword': '后台登录密码', @@ -529,6 +533,10 @@ const translations = { 'config.advanced.maxErrorCount': 'Provider Max Error Count', 'config.advanced.maxErrorCountPlaceholder': 'Default: 3', 'config.advanced.maxErrorCountNote': 'Provider will be marked as unhealthy after consecutive errors reach this count, default is 3', + 'config.advanced.fallbackChain': 'Cross-Type Fallback Chain Config', + 'config.advanced.fallbackChainPlaceholder': 'Example:\n{\n "gemini-cli-oauth": ["gemini-antigravity"],\n "gemini-antigravity": ["gemini-cli-oauth"],\n "claude-kiro-oauth": ["claude-custom"]\n}', + 'config.advanced.fallbackChainNote': 'When all accounts of a Provider Type are unhealthy, automatically switch to configured Fallback types. JSON format, key is primary type, value is Fallback type array (sorted by priority)', + 'config.advanced.fallbackChainInvalid': 'Invalid Fallback chain config format, please enter valid JSON', 'config.advanced.systemPrompt': 'System Prompt', 'config.advanced.systemPromptPlaceholder': 'Enter system prompt...', 'config.advanced.adminPassword': 'Admin Password', diff --git a/static/index.html b/static/index.html index cf4de60..9cc93db 100644 --- a/static/index.html +++ b/static/index.html @@ -758,6 +758,17 @@ 提供商连续错误达到此次数后将被标记为不健康,默认为 3 次 +
+ + + 当某一 Provider Type 所有账号都不健康时,自动切换到配置的 Fallback 类型。JSON 格式,键为主类型,值为 Fallback 类型数组(按优先级排序) +
+