From 69e14952b3f84f258a717224877e78453e65de01 Mon Sep 17 00:00:00 2001 From: YUZHEthefool <2804776511@qq.com> Date: Sat, 8 Aug 2026 00:17:34 +0800 Subject: [PATCH] =?UTF-8?q?feat(providers):=20=E6=94=AF=E6=8C=81=E5=8D=95?= =?UTF-8?q?=E4=BE=9B=E5=BA=94=E5=95=86=E5=A4=9A=20API=20Key=20=E4=B8=8E?= =?UTF-8?q?=E4=B8=BB=20Key=20=E4=BC=98=E5=85=88=E6=95=85=E9=9A=9C=E8=BD=AC?= =?UTF-8?q?=E7=A7=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 每个供应商可配置多个 API Key(参照 Cherry Studio)。请求优先使用主 Key (首个),流式开始前遇到限额/鉴权/网络瞬时等可重试错误时,自动切换到 下一个 Key 重试,Key 用尽回退主 Key 继续重试预算;一旦开始产出内容则 不再换 Key,避免半截输出。单 Key 链路(用量查询、Go 模型拉取)零回归。 - 数据模型:CustomProvider 新增 apiKeys?: string[] + apiKeyCount?: number (脱敏快照),apiKey 恒派生为 apiKeys[0],旧单 Key 快照自动迁移。 - 归一化:normalizeApiKeys 逐项 trim/去空/去重保序。 - 故障转移:streamRetry 增 apiKeyFailover,重试前 rotate;streamByApi 各 API 分支 factory 每次重读 attemptAuth(apiKey+鉴权头),仅替换鉴权头 保留代理路由/会话/自定义头。createProviderApiKeyFailover 构造 mutable holder + rotate 回调。 - 网关同步:providerApiKeyUpdates 由 Record 升级为 Record;脱敏摘除 apiKeys 并写 apiKeyCount。 - 桌面端 UI:API Key 改为多行编辑器(增删/统一显隐)+ 故障转移提示。 WebUI 保持单行脱敏输入 + "已配置 N 个 Key" 提示(替换语义)。 - Cherry Studio 导入:导入全部启用的 Key(v1 逗号分隔 / v2 {isEnabled, key} 数组),不再只取第一个、不再报"将使用第一个"警告。 - Rust 脱敏:redact_provider_credential 摘除 apiKeys、写 apiKeyCount。 - gateway web 镜像同步相同改动保持 gateway-build 一致。 Closes #365 --- crates/agent-gateway/web/src/i18n/config.ts | 10 + .../web/src/lib/settings/index.ts | 16 +- .../web/src/lib/settings/normalize.ts | 22 ++ .../web/src/lib/settings/sync.ts | 87 +++++-- .../src/pages/settings/ProvidersSection.tsx | 190 +++++++++++++--- .../commands/config/settings/cherry_import.rs | 16 +- .../src/commands/config/settings/providers.rs | 23 +- .../src/commands/config/settings/tests.rs | 2 +- crates/agent-gui/src/i18n/config.ts | 10 + .../src/lib/chat/runner/agentRunner.ts | 10 + crates/agent-gui/src/lib/providers/llm.ts | 2 + .../runtime/providerRuntimeConfig.ts | 18 +- .../lib/providers/runtime/requestOptions.ts | 68 ++++++ .../src/lib/providers/runtime/streamByApi.ts | 112 ++++++--- .../src/lib/providers/runtime/streamRetry.ts | 15 ++ .../lib/providers/runtime/textOnlyRuntime.ts | 10 + .../src/lib/providers/runtime/types.ts | 9 + crates/agent-gui/src/lib/settings/index.ts | 16 +- .../agent-gui/src/lib/settings/normalize.ts | 22 ++ crates/agent-gui/src/lib/settings/sync.ts | 87 +++++-- .../settings/CherryStudioImportModal.tsx | 2 +- .../src/pages/settings/ProvidersSection.tsx | 213 ++++++++++++++---- .../agent-gui/test/chat/agent-runner.test.mjs | 13 ++ .../test/settings/normalization.test.mjs | 5 +- 24 files changed, 811 insertions(+), 167 deletions(-) diff --git a/crates/agent-gateway/web/src/i18n/config.ts b/crates/agent-gateway/web/src/i18n/config.ts index 128865f22..412b0fcdc 100644 --- a/crates/agent-gateway/web/src/i18n/config.ts +++ b/crates/agent-gateway/web/src/i18n/config.ts @@ -1396,6 +1396,10 @@ export const translations: Record> = { "settings.close": "关闭", "settings.hideApiKey": "隐藏 API Key", "settings.showApiKey": "显示 API Key", + "settings.providerAddApiKey": "添加 API Key", + "settings.providerRemoveApiKey": "移除该 API Key", + "settings.providerApiKeysConfiguredCount": "已配置 {count} 个 API Key,重新填写将替换全部", + "settings.providerApiKeyHint": "支持添加多个 Key;优先使用首个,遇到限额/鉴权失败时自动切换下一个", "settings.requestFormat": "请求格式", "settings.reasoning": "思考强度", "settings.reasoning.off": "关闭", @@ -3601,6 +3605,12 @@ export const translations: Record> = { "settings.close": "Close", "settings.hideApiKey": "Hide API Key", "settings.showApiKey": "Show API Key", + "settings.providerAddApiKey": "Add API Key", + "settings.providerRemoveApiKey": "Remove this API Key", + "settings.providerApiKeysConfiguredCount": + "{count} API Key(s) configured — re-entering replaces all of them", + "settings.providerApiKeyHint": + "Multiple keys supported; the first key is used first, and on quota/auth failure it automatically falls over to the next", "settings.requestFormat": "Request Format", "settings.reasoning": "Reasoning", "settings.reasoning.off": "Off", diff --git a/crates/agent-gateway/web/src/lib/settings/index.ts b/crates/agent-gateway/web/src/lib/settings/index.ts index eeba39875..971c27426 100644 --- a/crates/agent-gateway/web/src/lib/settings/index.ts +++ b/crates/agent-gateway/web/src/lib/settings/index.ts @@ -21,7 +21,7 @@ import { MAX_CHAT_TRANSCRIPT_WIDTH, MIN_CHAT_TRANSCRIPT_WIDTH, } from "../transcript-width/transcriptWidthModel"; -import { normalizeApiKey, normalizeBaseUrl, normalizeModels } from "./normalize"; +import { normalizeApiKey, normalizeApiKeys, normalizeBaseUrl, normalizeModels } from "./normalize"; export { normalizeFontFamily } from "../fontFamily"; @@ -349,6 +349,14 @@ export type CustomProvider = { baseUrl: string; apiKey: string; apiKeyConfigured?: boolean; + /** + * 多 API Key 列表(多 Key 负载均衡/轮询)。归一化后恒为数组;apiKey 始终派生为 + * apiKeys[0](兼容仅读单 Key 的存量链路)。旧快照缺省时由 normalizeApiKeys 从 + * apiKey 迁移成单元素数组,零回归。 + */ + apiKeys?: string[]; + /** 仅脱敏快照携带:已配置(非空)Key 数量,供 WebUI 展示"已配置 N 个 Key"提示。 */ + apiKeyCount?: number; customHeaders?: { key: string; value: string }[]; models: ProviderModelConfig[]; modelOrder?: string[]; @@ -1454,7 +1462,8 @@ export function normalizeCustomProvider(input: unknown): CustomProvider { const models = normalizeProviderModelConfigs(obj.models, type); const modelOrder = normalizeProviderModelOrder(obj.modelOrder, models); const validModelIds = new Set(models.map((model) => model.id)); - const apiKey = normalizeApiKey(typeof obj.apiKey === "string" ? obj.apiKey : ""); + const apiKeys = normalizeApiKeys(obj.apiKeys, obj.apiKey); + const apiKey = apiKeys[0] ?? normalizeApiKey(typeof obj.apiKey === "string" ? obj.apiKey : ""); const id = typeof obj.id === "string" && obj.id.trim() ? obj.id.trim() : createUuid(); return { @@ -1465,7 +1474,8 @@ export function normalizeCustomProvider(input: unknown): CustomProvider { ? codexRouting.baseUrl : normalizeBaseUrl(typeof obj.baseUrl === "string" ? obj.baseUrl : ""), apiKey, - apiKeyConfigured: apiKey.length > 0 || obj.apiKeyConfigured === true, + apiKeys, + apiKeyConfigured: apiKeys.length > 0 || obj.apiKeyConfigured === true, customHeaders: normalizeCustomHeaders(obj.customHeaders), models, ...(modelOrder ? { modelOrder } : {}), diff --git a/crates/agent-gateway/web/src/lib/settings/normalize.ts b/crates/agent-gateway/web/src/lib/settings/normalize.ts index 83e0c9c1f..c50bc0aef 100644 --- a/crates/agent-gateway/web/src/lib/settings/normalize.ts +++ b/crates/agent-gateway/web/src/lib/settings/normalize.ts @@ -12,6 +12,28 @@ export function normalizeApiKey(input: string) { return input.trim(); } +/** + * 供应商多 API Key 归一化:输入可为数组(多 Key)或回退到单 Key 旧字段。 + * 逐项 trim、去空、去重,保持录入顺序。返回值始终是数组(可能为空), + * 由 normalizeCustomProvider 派生 apiKey = apiKeys[0] ?? ""。 + */ +export function normalizeApiKeys(apiKeys: unknown, apiKey: unknown): string[] { + const keys: string[] = []; + if (Array.isArray(apiKeys)) { + for (const value of apiKeys) { + if (typeof value !== "string") continue; + const trimmed = value.trim(); + if (trimmed && !keys.includes(trimmed)) keys.push(trimmed); + } + } + // 旧快照只有单 apiKey 字段:迁移成单元素数组,行为与改造前一致。 + if (keys.length === 0 && typeof apiKey === "string") { + const trimmed = apiKey.trim(); + if (trimmed) keys.push(trimmed); + } + return keys; +} + export function normalizeModels(input: string | string[]) { const lines = Array.isArray(input) ? input : input.split(/\r?\n/); const out: string[] = []; diff --git a/crates/agent-gateway/web/src/lib/settings/sync.ts b/crates/agent-gateway/web/src/lib/settings/sync.ts index 23bb8090f..8061e1325 100644 --- a/crates/agent-gateway/web/src/lib/settings/sync.ts +++ b/crates/agent-gateway/web/src/lib/settings/sync.ts @@ -8,7 +8,7 @@ import { workspaceProjectPathKey, } from "./index"; -export type GatewayProviderApiKeyUpdates = Record; +export type GatewayProviderApiKeyUpdates = Record; export type GatewayProviderUsageQuerySecretUpdates = Record< string, { @@ -42,8 +42,12 @@ export type GatewaySshSyncPatch = { after: string[]; }; }; -export type GatewaySettingsSyncProvider = Omit & { +export type GatewaySettingsSyncProvider = Omit< + AppSettings["customProviders"][number], + "apiKey" | "apiKeys" +> & { apiKeyConfigured?: boolean; + apiKeyCount?: number; }; export type GatewaySettingsSyncCustomSettings = Partial; @@ -97,7 +101,20 @@ function asObject(value: unknown): Record { } function apiKeyConfiguredForProvider(provider: AppSettings["customProviders"][number]) { - return provider.apiKey.trim().length > 0 || provider.apiKeyConfigured === true; + return ( + (Array.isArray(provider.apiKeys) ? provider.apiKeys.some((key) => key.trim().length > 0) : false) || + provider.apiKey.trim().length > 0 || + provider.apiKeyConfigured === true + ); +} + +/** 已配置(非空)Key 数量:优先数 apiKeys,回退到单 apiKey 旧字段。供脱敏快照展示。 */ +function apiKeyCountForProvider(provider: AppSettings["customProviders"][number]): number { + if (Array.isArray(provider.apiKeys)) { + const count = provider.apiKeys.filter((key) => key.trim().length > 0).length; + if (count > 0) return count; + } + return provider.apiKey.trim().length > 0 ? 1 : 0; } const DEFAULT_USAGE_QUERY_CONFIG: AppSettings["customProviders"][number]["usageQuery"] = { @@ -147,11 +164,12 @@ export function redactCustomProvidersForGateway( customProviders: AppSettings["customProviders"], ): GatewaySettingsSyncProvider[] { return customProviders.map((provider) => { - const { apiKey: _apiKey, ...rest } = provider; + const { apiKey: _apiKey, apiKeys: _apiKeys, ...rest } = provider; return { ...rest, usageQuery: redactUsageQueryConfig(provider.usageQuery), apiKeyConfigured: apiKeyConfiguredForProvider(provider), + apiKeyCount: apiKeyCountForProvider(provider), }; }); } @@ -162,8 +180,10 @@ export function redactCustomProvidersForWebStorage( return customProviders.map((provider) => ({ ...provider, apiKey: "", + apiKeys: [], usageQuery: redactUsageQueryConfig(provider.usageQuery), apiKeyConfigured: apiKeyConfiguredForProvider(provider), + apiKeyCount: apiKeyCountForProvider(provider), })); } @@ -227,10 +247,21 @@ function collectProviderApiKeyUpdates( ): GatewayProviderApiKeyUpdates | undefined { const updates: GatewayProviderApiKeyUpdates = {}; for (const provider of customProviders) { - const apiKey = provider.apiKey.trim(); - if (provider.id.trim() && apiKey) { - updates[provider.id] = apiKey; + const id = provider.id.trim(); + if (!id) continue; + const keys = (Array.isArray(provider.apiKeys) ? provider.apiKeys : []) + .map((key) => (typeof key === "string" ? key.trim() : "")) + .filter(Boolean); + // 去重保序:多 Key 列表里同一 Key 不重复发送。 + const deduped: string[] = []; + const seen = new Set(); + for (const key of keys) { + if (!seen.has(key)) { + seen.add(key); + deduped.push(key); + } } + if (deduped.length > 0) updates[id] = deduped; } return Object.keys(updates).length > 0 ? updates : undefined; } @@ -625,12 +656,22 @@ function mergeSyncedSystemSettings( function normalizeProviderApiKeyUpdates(value: unknown): GatewayProviderApiKeyUpdates { const source = asObject(value); const updates: GatewayProviderApiKeyUpdates = {}; - for (const [id, apiKey] of Object.entries(source)) { + for (const [id, raw] of Object.entries(source)) { const normalizedId = id.trim(); - const normalizedApiKey = typeof apiKey === "string" ? apiKey.trim() : ""; - if (normalizedId && normalizedApiKey) { - updates[normalizedId] = normalizedApiKey; + if (!normalizedId) continue; + // 兼容历史单字符串 sidecar:包成单元素数组。 + const rawKeys = Array.isArray(raw) ? raw : typeof raw === "string" ? [raw] : []; + const keys: string[] = []; + const seen = new Set(); + for (const entry of rawKeys) { + if (typeof entry !== "string") continue; + const trimmed = entry.trim(); + if (trimmed && !seen.has(trimmed)) { + seen.add(trimmed); + keys.push(trimmed); + } } + if (keys.length > 0) updates[normalizedId] = keys; } return updates; } @@ -757,8 +798,25 @@ function mergeSyncedCustomProviders( const id = typeof source.id === "string" ? source.id.trim() : ""; const currentProvider = id ? currentById.get(id) : undefined; const apiKeyUpdate = id ? apiKeyUpdates[id] : undefined; - const sourceApiKey = typeof source.apiKey === "string" ? source.apiKey.trim() : ""; - const apiKey = (apiKeyUpdate ?? sourceApiKey) || currentProvider?.apiKey || ""; + // current 侧经归一化 apiKeys 恒为数组;兜底未归一化的旧单 apiKey。 + const currentApiKeys = Array.isArray(currentProvider?.apiKeys) + ? currentProvider!.apiKeys + : currentProvider?.apiKey?.trim() + ? [currentProvider!.apiKey.trim()] + : []; + // 脱敏快照里 apiKeyConfigured === false 是显式清空信号(对齐 SSH passwordConfiguredCleared)。 + const cleared = source.apiKeyConfigured === false; + let apiKeys: string[]; + if (apiKeyUpdate && apiKeyUpdate.length > 0) { + // sidecar 明文:整体替换(WebUI 编辑即"重置为所填 Key 列表")。 + apiKeys = apiKeyUpdate; + } else if (cleared) { + apiKeys = []; + } else { + // 未编辑:沿用本端已存 Key 列表。 + apiKeys = currentApiKeys; + } + const apiKey = apiKeys[0] ?? ""; const sourceHasConfiguredFlag = Object.hasOwn(source, "apiKeyConfigured"); const usageQuery = Object.hasOwn(source, "usageQuery") ? mergeSyncedUsageQuery( @@ -771,8 +829,9 @@ function mergeSyncedCustomProviders( return { ...source, apiKey, + apiKeys, apiKeyConfigured: - apiKey.length > 0 || + apiKeys.length > 0 || source.apiKeyConfigured === true || (!sourceHasConfiguredFlag && currentProvider?.apiKeyConfigured === true), ...(usageQuery ? { usageQuery } : {}), diff --git a/crates/agent-gateway/web/src/pages/settings/ProvidersSection.tsx b/crates/agent-gateway/web/src/pages/settings/ProvidersSection.tsx index 868f93f08..7492f0d55 100644 --- a/crates/agent-gateway/web/src/pages/settings/ProvidersSection.tsx +++ b/crates/agent-gateway/web/src/pages/settings/ProvidersSection.tsx @@ -330,14 +330,27 @@ function itemsByIdOrder(items: readonly T[], order: re function ProviderModal({ providerType, initialData, onSave, onClose }: ModalProps) { const { t } = useLocale(); const isGatewayWebui = isGatewayWebuiRuntime(); - const initialApiKey = initialData?.apiKey ?? ""; - const initialUsesRedactedApiKey = - isGatewayWebui && initialApiKey.trim() === "" && initialData?.apiKeyConfigured === true; const [name, setName] = useState(initialData?.name ?? ""); const [baseUrl, setBaseUrl] = useState(initialData?.baseUrl ?? ""); - const [apiKey, setApiKey] = useState( - initialUsesRedactedApiKey ? REDACTED_API_KEY_DISPLAY : initialApiKey, + const initialApiKeys = Array.isArray(initialData?.apiKeys) + ? initialData.apiKeys.map((key) => key) + : initialData?.apiKey?.trim() + ? [initialData.apiKey] + : []; + // WebUI 永不下发明文 Key:脱敏快照里 apiKeys 为空、apiKeyConfigured=true, + // 用占位行表示"已有 N 个 Key 已配置";编辑即整体替换(无回归:WebUI 原本单 Key)。 + const initialUsesRedactedApiKey = + isGatewayWebui && initialApiKeys.length === 0 && initialData?.apiKeyConfigured === true; + const [apiKeys, setApiKeys] = useState( + initialUsesRedactedApiKey + ? [REDACTED_API_KEY_DISPLAY] + : initialApiKeys.length > 0 + ? initialApiKeys + : [""], ); + const configuredApiKeyCount = isGatewayWebui + ? initialData?.apiKeyCount ?? 0 + : initialApiKeys.filter((key) => key.trim()).length; const [customHeaders, setCustomHeaders] = useState(() => (initialData?.customHeaders ?? []).map((header) => ({ ...header })), ); @@ -419,8 +432,12 @@ function ProviderModal({ providerType, initialData, onSave, onClose }: ModalProp modelsRef.current = models; modelOrderRef.current = modelOrder; activeModelsRef.current = activeModels; - const apiKeyIsRedactedDisplay = initialUsesRedactedApiKey && apiKey === REDACTED_API_KEY_DISPLAY; - const apiKeyForRequest = apiKeyIsRedactedDisplay ? "" : apiKey.trim(); + const apiKeyIsRedactedDisplay = + initialUsesRedactedApiKey && apiKeys.length === 1 && apiKeys[0] === REDACTED_API_KEY_DISPLAY; + const realApiKeys = apiKeys + .map((key) => (key === REDACTED_API_KEY_DISPLAY ? "" : key.trim())) + .filter(Boolean); + const apiKeyForRequest = apiKeyIsRedactedDisplay ? "" : (realApiKeys[0] ?? ""); const canFetchModels = baseUrl.trim().length > 0 && apiKeyForRequest.length > 0; const persistedUsageQueryProviderId = getPersistedUsageQueryProviderId(initialData); const { confirm: requestUsageQueryConfirm, dialog: usageQueryConfirmDialog } = useConfirmDialog(); @@ -437,7 +454,7 @@ function ProviderModal({ providerType, initialData, onSave, onClose }: ModalProp // 变量实际生效值:查询专用覆盖优先,留空回退供应商自身配置(与 Rust // prepare_script_query 的解析顺序一致)。 const usageVariableBaseUrl = usageQuery.baseUrl.trim() || baseUrl.trim(); - const usageVariableApiKey = usageQuery.apiKey.trim() || apiKey.trim(); + const usageVariableApiKey = usageQuery.apiKey.trim() || apiKeyForRequest; // Token Plan 供应商:显式选择优先,否则按 Base URL 自动检测。 const activeCodingPlanProvider = usageQuery.codingPlanProvider || detectCodingPlanProvider(baseUrl); @@ -757,6 +774,16 @@ function ProviderModal({ providerType, initialData, onSave, onClose }: ModalProp setHeaderValidationSubmitted(false); } + function updateApiKey(index: number, value: string) { + setApiKeys((prev) => prev.map((key, keyIndex) => (keyIndex === index ? value : key))); + } + function removeApiKey(index: number) { + setApiKeys((prev) => prev.filter((_, keyIndex) => keyIndex !== index)); + } + function addApiKey() { + setApiKeys((prev) => [...prev, ""]); + } + function openHeaderSuggest(index: number) { const input = headerKeyRefs.current[index]; if (!input) return; @@ -841,14 +868,26 @@ function ProviderModal({ providerType, initialData, onSave, onClose }: ModalProp if (!confirmed) return; setCustomUsageQueryConfirmed(true); } - const nextApiKey = apiKeyIsRedactedDisplay ? "" : apiKey.trim(); + const trimmedApiKeys = apiKeys + .map((key) => (key === REDACTED_API_KEY_DISPLAY ? "" : key.trim())) + .filter(Boolean); + const dedupedApiKeys: string[] = []; + const seenApiKeys = new Set(); + for (const key of trimmedApiKeys) { + if (!seenApiKeys.has(key)) { + seenApiKeys.add(key); + dedupedApiKeys.push(key); + } + } + const nextApiKey = dedupedApiKeys[0] ?? ""; onSave({ name: name.trim(), type: providerType, baseUrl: baseUrl.trim(), apiKey: nextApiKey, + apiKeys: dedupedApiKeys, apiKeyConfigured: - nextApiKey.length > 0 || + dedupedApiKeys.length > 0 || apiKeyIsRedactedDisplay || (isGatewayWebui && initialData?.apiKeyConfigured === true), customHeaders, @@ -1157,32 +1196,111 @@ function ProviderModal({ providerType, initialData, onSave, onClose }: ModalProp
- -
- setApiKey(event.currentTarget.value)} - onFocus={(event) => { - if (apiKeyIsRedactedDisplay) event.currentTarget.select(); - }} - /> - -
+ {isGatewayWebui ? ( + <> + +
+ setApiKeys([event.currentTarget.value])} + onFocus={(event) => { + if (apiKeyIsRedactedDisplay) event.currentTarget.select(); + }} + /> + +
+ {apiKeyIsRedactedDisplay && configuredApiKeyCount > 0 ? ( +

+ {t("settings.providerApiKeysConfiguredCount").replace( + "{count}", + String(configuredApiKeyCount), + )} +

+ ) : null} + + ) : ( + <> +
+ + +
+
+ {apiKeys.map((key, index) => ( +
+ updateApiKey(index, event.currentTarget.value)} + onFocus={(event) => { + if (index === 0 && apiKeyIsRedactedDisplay) { + event.currentTarget.select(); + } + }} + /> + {apiKeys.length > 1 ? ( + + ) : null} +
+ ))} + +
+

+ {t("settings.providerApiKeyHint")} +

+ + )}
diff --git a/crates/agent-gui/src-tauri/src/commands/config/settings/cherry_import.rs b/crates/agent-gui/src-tauri/src/commands/config/settings/cherry_import.rs index b05ab4973..34c553922 100644 --- a/crates/agent-gui/src-tauri/src/commands/config/settings/cherry_import.rs +++ b/crates/agent-gui/src-tauri/src/commands/config/settings/cherry_import.rs @@ -7,7 +7,7 @@ pub struct CherryProviderImportItem { pub provider_type: String, pub name: String, pub base_url: String, - pub api_key: String, + pub api_keys: Vec, pub api_key_count: usize, pub request_format: String, pub enabled: bool, @@ -374,7 +374,6 @@ fn cherry_append_v1_provider( .and_then(Value::as_bool) .unwrap_or(true); let api_keys = cherry_split_v1_api_keys(&cherry_value_string(provider, "apiKey")); - let api_key = api_keys.first().cloned().unwrap_or_default(); let auth_type = cherry_value_string(provider, "authType"); let source_models = provider .get("models") @@ -422,8 +421,6 @@ fn cherry_append_v1_provider( .is_some_and(|headers| !headers.is_empty()) { "Cherry Studio 的自定义请求头不会同步".to_string() - } else if api_keys.len() > 1 { - format!("检测到 {} 个 API Key,将使用第一个", api_keys.len()) } else { String::new() }; @@ -432,7 +429,7 @@ fn cherry_append_v1_provider( let models_only_unsupported = !source_models.is_empty() && group.models.is_empty(); let reason = if auth_type == "oauth" { "OAuth 登录凭据不支持迁移".to_string() - } else if api_key.is_empty() { + } else if api_keys.is_empty() { "未配置可迁移的 API Key".to_string() } else if group.base_url.is_empty() { "未配置 Base URL".to_string() @@ -448,7 +445,7 @@ fn cherry_append_v1_provider( provider_type: group.protocol.provider_type().to_string(), name: name.clone(), base_url: group.base_url, - api_key: api_key.clone(), + api_keys: api_keys.clone(), api_key_count: api_keys.len(), request_format: group.protocol.request_format().to_string(), enabled, @@ -513,7 +510,6 @@ fn cherry_read_v2( { let endpoint_configs = cherry_parse_optional_json(endpoint_configs_text.as_deref()); let api_keys = cherry_v2_api_keys(api_keys_text.as_deref()); - let api_key = api_keys.first().cloned().unwrap_or_default(); let auth_config = cherry_parse_optional_json(auth_config_text.as_deref()); let auth_type = auth_config .get("type") @@ -597,8 +593,6 @@ fn cherry_read_v2( .is_some_and(|headers| !headers.is_empty()) { "Cherry Studio 的自定义请求头不会同步".to_string() - } else if api_keys.len() > 1 { - format!("检测到 {} 个启用 API Key,将使用第一个", api_keys.len()) } else { String::new() }; @@ -607,7 +601,7 @@ fn cherry_read_v2( let models_only_unsupported = source_model_count > 0 && group.models.is_empty(); let reason = if auth_type != "api-key" { format!("{auth_type} 登录凭据不支持迁移") - } else if api_key.is_empty() { + } else if api_keys.is_empty() { "未配置启用的 API Key".to_string() } else if group.base_url.is_empty() { "未配置当前协议的 Base URL".to_string() @@ -623,7 +617,7 @@ fn cherry_read_v2( provider_type: group.protocol.provider_type().to_string(), name: name.clone(), base_url: group.base_url, - api_key: api_key.clone(), + api_keys: api_keys.clone(), api_key_count: api_keys.len(), request_format: group.protocol.request_format().to_string(), enabled, diff --git a/crates/agent-gui/src-tauri/src/commands/config/settings/providers.rs b/crates/agent-gui/src-tauri/src/commands/config/settings/providers.rs index cedd35984..a93a41436 100644 --- a/crates/agent-gui/src-tauri/src/commands/config/settings/providers.rs +++ b/crates/agent-gui/src-tauri/src/commands/config/settings/providers.rs @@ -45,10 +45,25 @@ fn redact_provider_credential(provider: Value) -> Result { Some(Value::Null) | None => false, Some(_) => return Err("provider settings apiKey must be a string".to_string()), } || matches!(payload.get("apiKeyConfigured"), Some(Value::Bool(true))); - payload.insert( - "apiKeyConfigured".to_string(), - Value::Bool(api_key_configured), - ); + // 多 API Key:摘除明文数组,仅保留"是否已配置"与"已配置数量"供 WebUI 展示。 + let api_keys_count = match payload.remove("apiKeys") { + Some(Value::Array(items)) => items + .into_iter() + .filter(|value| matches!(value, Value::String(s) if !s.trim().is_empty())) + .count(), + Some(Value::Null) | None => 0, + Some(_) => return Err("provider settings apiKeys must be an array".to_string()), + }; + let configured = api_key_configured || api_keys_count > 0; + let count = if api_keys_count > 0 { + api_keys_count + } else if api_key_configured { + 1 + } else { + 0 + }; + payload.insert("apiKeyConfigured".to_string(), Value::Bool(configured)); + payload.insert("apiKeyCount".to_string(), Value::from(count as u64)); if let Some(usage_query) = payload.remove("usageQuery") { payload.insert("usageQuery".to_string(), redact_usage_query_secrets(usage_query)?); } diff --git a/crates/agent-gui/src-tauri/src/commands/config/settings/tests.rs b/crates/agent-gui/src-tauri/src/commands/config/settings/tests.rs index 4793f7443..a9a08bfa5 100644 --- a/crates/agent-gui/src-tauri/src/commands/config/settings/tests.rs +++ b/crates/agent-gui/src-tauri/src/commands/config/settings/tests.rs @@ -1426,7 +1426,7 @@ mod tests { assert_eq!(imported.len(), 2); assert!(imported.iter().all(|item| item.importable)); - assert!(imported.iter().all(|item| item.api_key == "secret")); + assert!(imported.iter().all(|item| item.api_keys == vec!["secret".to_string()])); assert!(imported.iter().all(|item| item.excluded_model_count == 1)); assert!(!cherry_model_is_chat_compatible( &json!({"type": ["image_generation"]}), diff --git a/crates/agent-gui/src/i18n/config.ts b/crates/agent-gui/src/i18n/config.ts index 09fd722a7..5d6ebcbbb 100644 --- a/crates/agent-gui/src/i18n/config.ts +++ b/crates/agent-gui/src/i18n/config.ts @@ -1466,6 +1466,10 @@ export const translations: Record> = { "settings.close": "关闭", "settings.hideApiKey": "隐藏 API Key", "settings.showApiKey": "显示 API Key", + "settings.providerAddApiKey": "添加 API Key", + "settings.providerRemoveApiKey": "移除该 API Key", + "settings.providerApiKeysConfiguredCount": "已配置 {count} 个 API Key,重新填写将替换全部", + "settings.providerApiKeyHint": "支持添加多个 Key;优先使用首个,遇到限额/鉴权失败时自动切换下一个", "settings.requestFormat": "请求格式", "settings.reasoning": "思考强度", "settings.reasoning.off": "关闭", @@ -3764,6 +3768,12 @@ export const translations: Record> = { "settings.close": "Close", "settings.hideApiKey": "Hide API Key", "settings.showApiKey": "Show API Key", + "settings.providerAddApiKey": "Add API Key", + "settings.providerRemoveApiKey": "Remove this API Key", + "settings.providerApiKeysConfiguredCount": + "{count} API Key(s) configured — re-entering replaces all of them", + "settings.providerApiKeyHint": + "Multiple keys supported; the first key is used first, and on quota/auth failure it automatically falls over to the next", "settings.requestFormat": "Request Format", "settings.reasoning": "Reasoning", "settings.reasoning.off": "Off", diff --git a/crates/agent-gui/src/lib/chat/runner/agentRunner.ts b/crates/agent-gui/src/lib/chat/runner/agentRunner.ts index d1d95391e..8c61efffb 100644 --- a/crates/agent-gui/src/lib/chat/runner/agentRunner.ts +++ b/crates/agent-gui/src/lib/chat/runner/agentRunner.ts @@ -22,6 +22,7 @@ import { finalizeProviderStreamOptions, normalizeErrorMessage, type ProviderRuntimeConfig, + createProviderApiKeyFailover, prepareProviderRequest, resolveProviderCacheRetention, type StreamOptionsEx, @@ -1280,9 +1281,17 @@ export async function runAssistantWithTools(params: { const hostedSearchProbeId = shouldProbeHostedSearch ? createHostedSearchProbeId(params.providerId) : undefined; + // 多 Key 故障转移:主 Key 优先,失败(429/限额/鉴权)时切下一个 Key 重试。 + const apiKeyFailover = createProviderApiKeyFailover({ + providerId: params.providerId, + apiKeys: params.runtime.apiKeys, + requestFormat: params.runtime.requestFormat, + sessionId: params.sessionId, + }); let streamOptions: StreamOptionsEx = { ...(options ?? {}), apiKey: options?.apiKey ?? params.runtime.apiKey, + attemptAuth: apiKeyFailover.attemptAuth, headers: withHostedSearchProbeHeader( { ...(options?.headers ?? {}), @@ -1314,6 +1323,7 @@ export async function runAssistantWithTools(params: { onRetryRecovered: () => { params.onToolStatus?.(`第 ${round} 轮:模型生成中...`); }, + ...(apiKeyFailover.failover ? { apiKeyFailover: apiKeyFailover.failover } : {}), }, }; diff --git a/crates/agent-gui/src/lib/providers/llm.ts b/crates/agent-gui/src/lib/providers/llm.ts index a144e8c11..4a916b81a 100644 --- a/crates/agent-gui/src/lib/providers/llm.ts +++ b/crates/agent-gui/src/lib/providers/llm.ts @@ -25,8 +25,10 @@ export { buildAnthropicAuthHeaders, buildGeminiAuthHeaders, buildOpenAIAuthHeaders, + buildProviderAuthHeaders, buildProviderRequestHeaders, buildProviderRequestMetadata, + createProviderApiKeyFailover, isValidCustomHeaderKey, prepareProviderRequest, resolveProviderCacheRetention, diff --git a/crates/agent-gui/src/lib/providers/runtime/providerRuntimeConfig.ts b/crates/agent-gui/src/lib/providers/runtime/providerRuntimeConfig.ts index 286ba0500..69c9cf5a8 100644 --- a/crates/agent-gui/src/lib/providers/runtime/providerRuntimeConfig.ts +++ b/crates/agent-gui/src/lib/providers/runtime/providerRuntimeConfig.ts @@ -11,6 +11,18 @@ import type { ProviderRuntimeConfig } from "./types"; * ProviderRuntimeConfig 的唯一构造点——全仓仅此一处注入品牌。任何调用方都只能 * 拿到完整对象并整体传递(需要改档位等请用展开派生),不得再逐字段转抄。 */ + +/** 故障转移候选 Key 列表:主 Key 在前,请求失败(限额/鉴权)时逐一切换。 */ +function resolveProviderApiKeys(provider: CustomProvider): string[] { + if (Array.isArray(provider.apiKeys)) { + const keys = provider.apiKeys + .map((key) => (typeof key === "string" ? key.trim() : "")) + .filter(Boolean); + if (keys.length > 0) return keys; + } + return provider.apiKey.trim() ? [provider.apiKey.trim()] : []; +} + export function createProviderRuntimeConfig( provider: CustomProvider, model: string, @@ -23,9 +35,13 @@ export function createProviderRuntimeConfig( }; const controls = normalizeChatRuntimeControlsForProvider(controlsInput, reasoningParams); const reasoningSupported = getChatRuntimeReasoningLevelsForProvider(reasoningParams).length > 0; + const apiKeys = resolveProviderApiKeys(provider); + // 主 Key 优先:apiKey 恒为首项,单 Key 链路(用量查询/Go 模型拉取)零回归; + // 多 Key 故障转移在 streamByApi 的 withStreamRetry 里按重试切换。 return { baseUrl: provider.baseUrl, - apiKey: provider.apiKey, + apiKey: apiKeys[0] ?? provider.apiKey, + apiKeys, customHeaders: provider.customHeaders, requestFormat: provider.requestFormat, reasoning: reasoningSupported diff --git a/crates/agent-gui/src/lib/providers/runtime/requestOptions.ts b/crates/agent-gui/src/lib/providers/runtime/requestOptions.ts index 7abd71703..0f7d8645a 100644 --- a/crates/agent-gui/src/lib/providers/runtime/requestOptions.ts +++ b/crates/agent-gui/src/lib/providers/runtime/requestOptions.ts @@ -39,6 +39,74 @@ function buildProviderAuthHeaders(providerId: ProviderId, apiKey: string): Recor return buildOpenAIAuthHeaders(apiKey); } +export { buildProviderAuthHeaders }; + +/** + * 多 API Key 故障转移:构造当次尝试凭据的 mutable holder + streamRetry.apiKeyFailover + * 的 rotate 回调。主 Key(apiKeys[0])优先;请求失败(429/限额/鉴权等可重试错误) + * 时 withStreamRetry 调用 rotate(attemptIndex) 切到下一个 Key,streamByApi 的 factory + * 在下次 factory() 调用重新读取 holder.apiKey/headers,重试落到新 Key 上。 + * + * attemptAuth.headers 只含鉴权头(authorization/x-api-key/x-goog-api-key),streamByApi + * 的 resolveAttemptHeaders 会把它替换进 options.headers、保留代理路由/会话/自定义头。 + * 单 Key(或仅 apiKey 旧快照)时不启用故障转移,返回 undefined。 + */ +export type ProviderApiKeyFailover = { + keys: string[]; + rotate: (attemptIndex: number) => void; +}; + +export type ProviderAttemptAuth = { + apiKey: string; + headers: Record; +}; + +export function createProviderApiKeyFailover(params: { + providerId: ProviderId; + apiKeys?: string[]; + requestFormat?: CodexRequestFormat; + sessionId?: string; +}): { attemptAuth: ProviderAttemptAuth | undefined; failover: ProviderApiKeyFailover | undefined } { + const keys = (Array.isArray(params.apiKeys) ? params.apiKeys : []) + .map((key) => (typeof key === "string" ? key.trim() : "")) + .filter(Boolean); + if (keys.length === 0) { + // 无候选 Key(旧快照/测试 fixture 仅 apiKey):不启用故障转移,也不设 attemptAuth, + // 让 streamByApi 的 factory 回退到 options.apiKey/options.headers,零回归。 + return { attemptAuth: undefined, failover: undefined }; + } + if (keys.length === 1) { + // 单 Key:不启用故障转移,attemptAuth 持主 Key 供 factory 一致读取。 + const apiKey = keys[0]; + return { + attemptAuth: { + apiKey, + headers: buildProviderRequestHeaders(params.providerId, apiKey, params.sessionId, params.requestFormat), + }, + failover: undefined, + }; + } + const attemptAuth: ProviderAttemptAuth = { + apiKey: keys[0], + headers: buildProviderRequestHeaders(params.providerId, keys[0], params.sessionId, params.requestFormat), + }; + const failover: ProviderApiKeyFailover = { + keys, + rotate: (attemptIndex: number) => { + // 主 Key=attemptIndex 0;重试逐一切到下一个,越界回主 Key(继续重试同一 Key)。 + const key = keys[attemptIndex] ?? keys[0]; + attemptAuth.apiKey = key; + attemptAuth.headers = buildProviderRequestHeaders( + params.providerId, + key, + params.sessionId, + params.requestFormat, + ); + }, + }; + return { attemptAuth, failover }; +} + export function buildProviderRequestHeaders( providerId: ProviderId, apiKey: string, diff --git a/crates/agent-gui/src/lib/providers/runtime/streamByApi.ts b/crates/agent-gui/src/lib/providers/runtime/streamByApi.ts index 46db028a8..8187d5497 100644 --- a/crates/agent-gui/src/lib/providers/runtime/streamByApi.ts +++ b/crates/agent-gui/src/lib/providers/runtime/streamByApi.ts @@ -70,20 +70,56 @@ function mapToolChoiceToGoogle( } function buildOpenAIBaseOptions(model: Model, options: StreamOptionsEx) { + // 多 Key 故障转移:当次尝试的 apiKey/headers 由 attemptAuth holder 提供, + // withStreamRetry 在重试前 rotate;缺省时回退到 options.apiKey/options.headers。 + const auth = options.attemptAuth; return { temperature: options.temperature, maxTokens: resolveMaxTokens(options.maxTokens, model.maxTokens), signal: options.signal, - apiKey: options.apiKey, + apiKey: auth?.apiKey ?? options.apiKey, cacheRetention: options.cacheRetention, sessionId: options.sessionId, - headers: options.headers, + headers: auth?.headers ?? options.headers, onPayload: options.onPayload, maxRetryDelayMs: options.maxRetryDelayMs, metadata: options.metadata, }; } +/** + * 鉴权头名:与 proxy.ts 的 UPSTREAM_HEADER_OVERRIDE_EXCLUDED_KEYS 同源——这些头 + * 不进覆盖包、由 SDK/常规通道下发,故障转移时按 Key 重建即可替换。 + */ +const PROVIDER_AUTH_HEADER_KEYS = new Set([ + "authorization", + "x-api-key", + "x-goog-api-key", +]); + +/** 当次尝试的鉴权凭据:优先 attemptAuth holder(故障转移会 rotate),回退 options。 */ +function resolveAttemptApiKey(options: StreamOptionsEx): string | undefined { + return options.attemptAuth?.apiKey ?? options.apiKey; +} +/** + * 当次尝试的 headers:保留 options.headers 里的代理路由/会话/自定义头, + * 仅把鉴权头(authorization/x-api-key/x-goog-api-key)替换为 attemptAuth 里的当次 Key 版本。 + * 未配置 attemptAuth 时回退到 options.headers(兼容单 Key 旧链路)。 + */ +function resolveAttemptHeaders(options: StreamOptionsEx): Record | undefined { + const authHeaders = options.attemptAuth?.headers; + if (!authHeaders) return options.headers as Record | undefined; + const base = (options.headers ?? {}) as Record; + const merged: Record = {}; + for (const [key, value] of Object.entries(base)) { + if (!PROVIDER_AUTH_HEADER_KEYS.has(key.toLowerCase())) merged[key] = value; + } + for (const [key, value] of Object.entries(authHeaders)) { + if (PROVIDER_AUTH_HEADER_KEYS.has(key.toLowerCase())) merged[key] = value; + } + return merged; +} + export function streamSimpleByApi(model: Model, context: Context, options: StreamOptionsEx) { switch (model.api) { case "anthropic-messages": { @@ -114,10 +150,11 @@ export function streamSimpleByApi(model: Model, context: Context, options: temperature: anthropicOptions.temperature, maxTokens: anthropicThinking.maxTokens, signal: anthropicOptions.signal, - apiKey: anthropicOptions.apiKey, + // 故障转移:每次 factory() 调用都重新读取当次 Key/headers。 + apiKey: resolveAttemptApiKey(anthropicOptions), cacheRetention: anthropicOptions.cacheRetention, sessionId: anthropicOptions.sessionId, - headers: anthropicOptions.headers, + headers: resolveAttemptHeaders(anthropicOptions), onPayload: anthropicOptions.onPayload, maxRetryDelayMs: anthropicOptions.maxRetryDelayMs, metadata: anthropicOptions.metadata, @@ -153,15 +190,16 @@ export function streamSimpleByApi(model: Model, context: Context, options: // tools」的请求直接 400("A tool_choice was set on the request but no tools // were specified")——compaction 摘要、标题生成等 text-only 请求没有工具, // 会踩中。tool_choice 在无工具时本就无意义,只在请求真正携带 tools 时下发。 - const openAIOptions: OpenAICompletionsOptions = { - ...buildOpenAIBaseOptions(model, openAICompletionsOptions), - reasoningEffort: clampOpenAIReasoningEffort(model, openAICompletionsOptions.reasoning), - toolChoice: openAICompletionsContext.tools?.length - ? mapToolChoiceToOpenAI(openAICompletionsOptions.toolChoice) - : undefined, - }; return withStreamRetry( () => { + // 故障转移:每次 factory() 调用都重新构建 options,读取当次 Key/headers。 + const openAIOptions: OpenAICompletionsOptions = { + ...buildOpenAIBaseOptions(model, openAICompletionsOptions), + reasoningEffort: clampOpenAIReasoningEffort(model, openAICompletionsOptions.reasoning), + toolChoice: openAICompletionsContext.tools?.length + ? mapToolChoiceToOpenAI(openAICompletionsOptions.toolChoice) + : undefined, + }; const source = streamOpenAICompletions( model as any, openAICompletionsContext, @@ -175,32 +213,36 @@ export function streamSimpleByApi(model: Model, context: Context, options: ); } case "openai-responses": { - const openAIOptions: OpenAIResponsesOptions = { - ...buildOpenAIBaseOptions(model, options), - reasoningEffort: clampOpenAIReasoningEffort(model, options.reasoning), - }; - return withStreamRetry(() => streamOpenAIResponses(model as any, context, openAIOptions), { - signal: options.signal, - ...options.streamRetry, - }); + return withStreamRetry( + () => { + const openAIOptions: OpenAIResponsesOptions = { + ...buildOpenAIBaseOptions(model, options), + reasoningEffort: clampOpenAIReasoningEffort(model, options.reasoning), + }; + return streamOpenAIResponses(model as any, context, openAIOptions); + }, + { signal: options.signal, ...options.streamRetry }, + ); } case "google-generative-ai": { - const googleOptions: GoogleOptions = { - temperature: options.temperature, - maxTokens: resolveMaxTokens(options.maxTokens, model.maxTokens), - signal: options.signal, - apiKey: options.apiKey, - headers: options.headers, - onPayload: options.onPayload, - maxRetryDelayMs: options.maxRetryDelayMs, - metadata: options.metadata, - thinking: resolveGeminiThinkingRuntime(model, options.reasoning), - toolChoice: mapToolChoiceToGoogle(options.toolChoice) ?? "none", - }; - return withStreamRetry(() => streamGoogle(model as any, context, googleOptions), { - signal: options.signal, - ...options.streamRetry, - }); + return withStreamRetry( + () => { + const googleOptions: GoogleOptions = { + temperature: options.temperature, + maxTokens: resolveMaxTokens(options.maxTokens, model.maxTokens), + signal: options.signal, + apiKey: resolveAttemptApiKey(options), + headers: resolveAttemptHeaders(options), + onPayload: options.onPayload, + maxRetryDelayMs: options.maxRetryDelayMs, + metadata: options.metadata, + thinking: resolveGeminiThinkingRuntime(model, options.reasoning), + toolChoice: mapToolChoiceToGoogle(options.toolChoice) ?? "none", + }; + return streamGoogle(model as any, context, googleOptions); + }, + { signal: options.signal, ...options.streamRetry }, + ); } default: throw new Error(`Unsupported model API: ${model.api}`); diff --git a/crates/agent-gui/src/lib/providers/runtime/streamRetry.ts b/crates/agent-gui/src/lib/providers/runtime/streamRetry.ts index f99e2cd43..afa9922d4 100644 --- a/crates/agent-gui/src/lib/providers/runtime/streamRetry.ts +++ b/crates/agent-gui/src/lib/providers/runtime/streamRetry.ts @@ -27,6 +27,17 @@ export type StreamRetryConfig = { onRetry?: (attempt: number, maxAttempts: number, errorMessage: string) => void; /** Invoked once a retried attempt commits its first content-bearing event. */ onRetryRecovered?: () => void; + /** + * 多 API Key 故障转移:主 Key 优先,请求失败(429/限额/鉴权等可重试错误)时 + * 切到下一个 Key 重试。`keys` 为候选列表(首项即主 Key),`rotate` 在每次重试 + * 前被调用以更新当次尝试的凭据;streamByApi 的 factory 在每次 factory() 调用 + * 时从 rotate 写入的 mutable holder 重新读取 apiKey/headers,从而让重试落到 + * 不同 Key 上。一旦开始流式产出(committed)即不再换 Key。 + */ + apiKeyFailover?: { + keys: string[]; + rotate: (attemptIndex: number) => void; + }; }; export type StreamRetryOptions = StreamRetryConfig & { @@ -100,6 +111,8 @@ export function withStreamRetry( const signal = options?.signal; const output = createAssistantMessageEventStream(); + // 多 Key 故障转移:第 0 次(主 Key)在首请求前对齐,重试时逐一切到下一个 Key。 + options?.apiKeyFailover?.rotate(0); const firstSource = factory(); void (async () => { @@ -137,6 +150,8 @@ export function withStreamRetry( hasRetried = true; try { await sleepWithAbort(computeStreamRetryBackoffMs(attempt - 1), signal); + // 切到下一个 Key(越界时 rotate 自行兜底,通常回到主 Key 重试)。 + options?.apiKeyFailover?.rotate(attempt - 1); source = factory(); continue; } catch { diff --git a/crates/agent-gui/src/lib/providers/runtime/textOnlyRuntime.ts b/crates/agent-gui/src/lib/providers/runtime/textOnlyRuntime.ts index 5e63a49d6..39eb64eeb 100644 --- a/crates/agent-gui/src/lib/providers/runtime/textOnlyRuntime.ts +++ b/crates/agent-gui/src/lib/providers/runtime/textOnlyRuntime.ts @@ -22,6 +22,7 @@ import { createModelFromConfig } from "./modelFactory"; import { finalizeProviderStreamOptions } from "./payloadPipeline"; import { buildProviderRequestMetadata, + createProviderApiKeyFailover, prepareProviderRequest, resolveProviderCacheRetention, toSimpleStreamReasoning, @@ -80,8 +81,16 @@ function buildTextOnlyStreamOptions(params: { }) && params.nativeWebSearch; const usesOpenAIChatNativeWebSearch = nativeWebSearch && params.providerId === "codex" && params.model.api === "openai-completions"; + // 多 Key 故障转移:主 Key 优先,失败时切下一个 Key 重试(见 createProviderApiKeyFailover)。 + const failover = createProviderApiKeyFailover({ + providerId: params.providerId, + apiKeys: params.runtime.apiKeys, + requestFormat: params.runtime.requestFormat, + sessionId, + }); const options: StreamOptionsEx = { apiKey: params.runtime.apiKey, + attemptAuth: failover.attemptAuth, headers: withHostedSearchProbeHeader(params.headers, params.hostedSearchProbeId), signal: params.signal, sessionId, @@ -105,6 +114,7 @@ function buildTextOnlyStreamOptions(params: { streamRetry: { onRetry: params.onRetryStatus, onRetryRecovered: params.onRetryRecovered, + ...(failover.failover ? { apiKeyFailover: failover.failover } : {}), }, }; return finalizeProviderStreamOptions({ diff --git a/crates/agent-gui/src/lib/providers/runtime/types.ts b/crates/agent-gui/src/lib/providers/runtime/types.ts index 0e2ffd3a7..c9779ced8 100644 --- a/crates/agent-gui/src/lib/providers/runtime/types.ts +++ b/crates/agent-gui/src/lib/providers/runtime/types.ts @@ -32,6 +32,8 @@ export type ProviderRuntimeConfig = { readonly [PROVIDER_RUNTIME_CONFIG_BRAND]: true; baseUrl: string; apiKey: string; + /** 故障转移候选 Key 列表(主 Key 在前,即 apiKey);单 Key 时为 [apiKey]。 */ + apiKeys: string[]; customHeaders?: CustomProvider["customHeaders"]; requestFormat?: CodexRequestFormat; reasoning?: ReasoningLevel; @@ -67,4 +69,11 @@ export type StreamOptionsEx = SimpleStreamOptions & { /** Escape hatch for the unified provider stream retry in streamByApi.ts. */ streamRetry?: StreamRetryConfig; recoverMissingFinishReason?: boolean; + /** + * 多 Key 故障转移的当次尝试凭据(mutable holder)。streamByApi 的 factory 在 + * 每次 factory() 调用时从这里读 apiKey/headers,withStreamRetry 在重试前通过 + * streamRetry.apiKeyFailover.rotate 更新它,让重试落到下一个 Key。未配置多 Key + * 时缺省,factory 回退到 options.apiKey/options.headers(兼容旧链路)。 + */ + attemptAuth?: { apiKey: string; headers: Record }; }; diff --git a/crates/agent-gui/src/lib/settings/index.ts b/crates/agent-gui/src/lib/settings/index.ts index a38f7516f..85e21103d 100644 --- a/crates/agent-gui/src/lib/settings/index.ts +++ b/crates/agent-gui/src/lib/settings/index.ts @@ -28,7 +28,7 @@ import { MAX_CHAT_TRANSCRIPT_WIDTH, MIN_CHAT_TRANSCRIPT_WIDTH, } from "../transcript-width/transcriptWidthModel"; -import { normalizeApiKey, normalizeBaseUrl, normalizeModels } from "./normalize"; +import { normalizeApiKey, normalizeApiKeys, normalizeBaseUrl, normalizeModels } from "./normalize"; export { normalizeFontFamily } from "../system/fontFamily"; @@ -368,6 +368,14 @@ export type CustomProvider = { baseUrl: string; apiKey: string; apiKeyConfigured?: boolean; + /** + * 多 API Key 列表(多 Key 负载均衡/轮询)。归一化后恒为数组;apiKey 始终派生为 + * apiKeys[0](兼容仅读单 Key 的存量链路)。旧快照缺省时由 normalizeApiKeys 从 + * apiKey 迁移成单元素数组,零回归。 + */ + apiKeys?: string[]; + /** 仅脱敏快照携带:已配置(非空)Key 数量,供 WebUI 展示"已配置 N 个 Key"提示。 */ + apiKeyCount?: number; customHeaders?: { key: string; value: string }[]; models: ProviderModelConfig[]; modelOrder?: string[]; @@ -1418,7 +1426,8 @@ export function normalizeCustomProvider(input: unknown): CustomProvider { const models = normalizeProviderModelConfigs(obj.models, type); const modelOrder = normalizeProviderModelOrder(obj.modelOrder, models); const validModelIds = new Set(models.map((model) => model.id)); - const apiKey = normalizeApiKey(typeof obj.apiKey === "string" ? obj.apiKey : ""); + const apiKeys = normalizeApiKeys(obj.apiKeys, obj.apiKey); + const apiKey = apiKeys[0] ?? normalizeApiKey(typeof obj.apiKey === "string" ? obj.apiKey : ""); const id = typeof obj.id === "string" && obj.id.trim() ? obj.id.trim() : createUuid(); return { @@ -1429,7 +1438,8 @@ export function normalizeCustomProvider(input: unknown): CustomProvider { ? codexRouting.baseUrl : normalizeBaseUrl(typeof obj.baseUrl === "string" ? obj.baseUrl : ""), apiKey, - apiKeyConfigured: apiKey.length > 0 || obj.apiKeyConfigured === true, + apiKeys, + apiKeyConfigured: apiKeys.length > 0 || obj.apiKeyConfigured === true, customHeaders: normalizeCustomHeaders(obj.customHeaders), models, ...(modelOrder ? { modelOrder } : {}), diff --git a/crates/agent-gui/src/lib/settings/normalize.ts b/crates/agent-gui/src/lib/settings/normalize.ts index 83e0c9c1f..c50bc0aef 100644 --- a/crates/agent-gui/src/lib/settings/normalize.ts +++ b/crates/agent-gui/src/lib/settings/normalize.ts @@ -12,6 +12,28 @@ export function normalizeApiKey(input: string) { return input.trim(); } +/** + * 供应商多 API Key 归一化:输入可为数组(多 Key)或回退到单 Key 旧字段。 + * 逐项 trim、去空、去重,保持录入顺序。返回值始终是数组(可能为空), + * 由 normalizeCustomProvider 派生 apiKey = apiKeys[0] ?? ""。 + */ +export function normalizeApiKeys(apiKeys: unknown, apiKey: unknown): string[] { + const keys: string[] = []; + if (Array.isArray(apiKeys)) { + for (const value of apiKeys) { + if (typeof value !== "string") continue; + const trimmed = value.trim(); + if (trimmed && !keys.includes(trimmed)) keys.push(trimmed); + } + } + // 旧快照只有单 apiKey 字段:迁移成单元素数组,行为与改造前一致。 + if (keys.length === 0 && typeof apiKey === "string") { + const trimmed = apiKey.trim(); + if (trimmed) keys.push(trimmed); + } + return keys; +} + export function normalizeModels(input: string | string[]) { const lines = Array.isArray(input) ? input : input.split(/\r?\n/); const out: string[] = []; diff --git a/crates/agent-gui/src/lib/settings/sync.ts b/crates/agent-gui/src/lib/settings/sync.ts index 6c7f503ee..5e686e505 100644 --- a/crates/agent-gui/src/lib/settings/sync.ts +++ b/crates/agent-gui/src/lib/settings/sync.ts @@ -8,7 +8,7 @@ import { workspaceProjectPathKey, } from "./index"; -export type GatewayProviderApiKeyUpdates = Record; +export type GatewayProviderApiKeyUpdates = Record; export type GatewayProviderUsageQuerySecretUpdates = Record< string, { @@ -42,8 +42,12 @@ export type GatewaySshSyncPatch = { after: string[]; }; }; -export type GatewaySettingsSyncProvider = Omit & { +export type GatewaySettingsSyncProvider = Omit< + AppSettings["customProviders"][number], + "apiKey" | "apiKeys" +> & { apiKeyConfigured?: boolean; + apiKeyCount?: number; }; export type GatewaySettingsSyncCustomSettings = Partial; @@ -97,7 +101,20 @@ function asObject(value: unknown): Record { } function apiKeyConfiguredForProvider(provider: AppSettings["customProviders"][number]) { - return provider.apiKey.trim().length > 0 || provider.apiKeyConfigured === true; + return ( + (Array.isArray(provider.apiKeys) ? provider.apiKeys.some((key) => key.trim().length > 0) : false) || + provider.apiKey.trim().length > 0 || + provider.apiKeyConfigured === true + ); +} + +/** 已配置(非空)Key 数量:优先数 apiKeys,回退到单 apiKey 旧字段。供脱敏快照展示。 */ +function apiKeyCountForProvider(provider: AppSettings["customProviders"][number]): number { + if (Array.isArray(provider.apiKeys)) { + const count = provider.apiKeys.filter((key) => key.trim().length > 0).length; + if (count > 0) return count; + } + return provider.apiKey.trim().length > 0 ? 1 : 0; } const DEFAULT_USAGE_QUERY_CONFIG: AppSettings["customProviders"][number]["usageQuery"] = { @@ -147,11 +164,12 @@ export function redactCustomProvidersForGateway( customProviders: AppSettings["customProviders"], ): GatewaySettingsSyncProvider[] { return customProviders.map((provider) => { - const { apiKey: _apiKey, ...rest } = provider; + const { apiKey: _apiKey, apiKeys: _apiKeys, ...rest } = provider; return { ...rest, usageQuery: redactUsageQueryConfig(provider.usageQuery), apiKeyConfigured: apiKeyConfiguredForProvider(provider), + apiKeyCount: apiKeyCountForProvider(provider), }; }); } @@ -162,8 +180,10 @@ export function redactCustomProvidersForWebStorage( return customProviders.map((provider) => ({ ...provider, apiKey: "", + apiKeys: [], usageQuery: redactUsageQueryConfig(provider.usageQuery), apiKeyConfigured: apiKeyConfiguredForProvider(provider), + apiKeyCount: apiKeyCountForProvider(provider), })); } @@ -227,10 +247,21 @@ function collectProviderApiKeyUpdates( ): GatewayProviderApiKeyUpdates | undefined { const updates: GatewayProviderApiKeyUpdates = {}; for (const provider of customProviders) { - const apiKey = provider.apiKey.trim(); - if (provider.id.trim() && apiKey) { - updates[provider.id] = apiKey; + const id = provider.id.trim(); + if (!id) continue; + const keys = (Array.isArray(provider.apiKeys) ? provider.apiKeys : []) + .map((key) => (typeof key === "string" ? key.trim() : "")) + .filter(Boolean); + // 去重保序:多 Key 列表里同一 Key 不重复发送。 + const deduped: string[] = []; + const seen = new Set(); + for (const key of keys) { + if (!seen.has(key)) { + seen.add(key); + deduped.push(key); + } } + if (deduped.length > 0) updates[id] = deduped; } return Object.keys(updates).length > 0 ? updates : undefined; } @@ -625,12 +656,22 @@ function mergeSyncedSystemSettings( function normalizeProviderApiKeyUpdates(value: unknown): GatewayProviderApiKeyUpdates { const source = asObject(value); const updates: GatewayProviderApiKeyUpdates = {}; - for (const [id, apiKey] of Object.entries(source)) { + for (const [id, raw] of Object.entries(source)) { const normalizedId = id.trim(); - const normalizedApiKey = typeof apiKey === "string" ? apiKey.trim() : ""; - if (normalizedId && normalizedApiKey) { - updates[normalizedId] = normalizedApiKey; + if (!normalizedId) continue; + // 兼容历史单字符串 sidecar:包成单元素数组。 + const rawKeys = Array.isArray(raw) ? raw : typeof raw === "string" ? [raw] : []; + const keys: string[] = []; + const seen = new Set(); + for (const entry of rawKeys) { + if (typeof entry !== "string") continue; + const trimmed = entry.trim(); + if (trimmed && !seen.has(trimmed)) { + seen.add(trimmed); + keys.push(trimmed); + } } + if (keys.length > 0) updates[normalizedId] = keys; } return updates; } @@ -757,8 +798,25 @@ function mergeSyncedCustomProviders( const id = typeof source.id === "string" ? source.id.trim() : ""; const currentProvider = id ? currentById.get(id) : undefined; const apiKeyUpdate = id ? apiKeyUpdates[id] : undefined; - const sourceApiKey = typeof source.apiKey === "string" ? source.apiKey.trim() : ""; - const apiKey = (apiKeyUpdate ?? sourceApiKey) || currentProvider?.apiKey || ""; + // current 侧经归一化 apiKeys 恒为数组;兜底未归一化的旧单 apiKey。 + const currentApiKeys = Array.isArray(currentProvider?.apiKeys) + ? currentProvider!.apiKeys + : currentProvider?.apiKey?.trim() + ? [currentProvider!.apiKey.trim()] + : []; + // 脱敏快照里 apiKeyConfigured === false 是显式清空信号(对齐 SSH passwordConfiguredCleared)。 + const cleared = source.apiKeyConfigured === false; + let apiKeys: string[]; + if (apiKeyUpdate && apiKeyUpdate.length > 0) { + // sidecar 明文:整体替换(WebUI 编辑即"重置为所填 Key 列表")。 + apiKeys = apiKeyUpdate; + } else if (cleared) { + apiKeys = []; + } else { + // 未编辑:沿用本端已存 Key 列表。 + apiKeys = currentApiKeys; + } + const apiKey = apiKeys[0] ?? ""; const sourceHasConfiguredFlag = Object.hasOwn(source, "apiKeyConfigured"); const usageQuery = Object.hasOwn(source, "usageQuery") ? mergeSyncedUsageQuery( @@ -771,8 +829,9 @@ function mergeSyncedCustomProviders( return { ...source, apiKey, + apiKeys, apiKeyConfigured: - apiKey.length > 0 || + apiKeys.length > 0 || source.apiKeyConfigured === true || (!sourceHasConfiguredFlag && currentProvider?.apiKeyConfigured === true), ...(usageQuery ? { usageQuery } : {}), diff --git a/crates/agent-gui/src/pages/settings/CherryStudioImportModal.tsx b/crates/agent-gui/src/pages/settings/CherryStudioImportModal.tsx index bbc0bc36d..7e74ea453 100644 --- a/crates/agent-gui/src/pages/settings/CherryStudioImportModal.tsx +++ b/crates/agent-gui/src/pages/settings/CherryStudioImportModal.tsx @@ -24,7 +24,7 @@ export type CherryProviderImportItem = { providerType: ProviderId; name: string; baseUrl: string; - apiKey: string; + apiKeys: string[]; apiKeyCount: number; requestFormat: CodexRequestFormat; enabled: boolean; diff --git a/crates/agent-gui/src/pages/settings/ProvidersSection.tsx b/crates/agent-gui/src/pages/settings/ProvidersSection.tsx index 74fd01f40..9a8d2626e 100644 --- a/crates/agent-gui/src/pages/settings/ProvidersSection.tsx +++ b/crates/agent-gui/src/pages/settings/ProvidersSection.tsx @@ -387,14 +387,27 @@ function itemsByIdOrder(items: readonly T[], order: re function ProviderModal({ providerType, initialData, onSave, onClose }: ModalProps) { const { t } = useLocale(); const isGatewayWebui = isGatewayWebuiRuntime(); - const initialApiKey = initialData?.apiKey ?? ""; - const initialUsesRedactedApiKey = - isGatewayWebui && initialApiKey.trim() === "" && initialData?.apiKeyConfigured === true; const [name, setName] = useState(initialData?.name ?? ""); const [baseUrl, setBaseUrl] = useState(initialData?.baseUrl ?? ""); - const [apiKey, setApiKey] = useState( - initialUsesRedactedApiKey ? REDACTED_API_KEY_DISPLAY : initialApiKey, + const initialApiKeys = Array.isArray(initialData?.apiKeys) + ? initialData.apiKeys.map((key) => key) + : initialData?.apiKey?.trim() + ? [initialData.apiKey] + : []; + // WebUI 永不下发明文 Key:脱敏快照里 apiKeys 为空、apiKeyConfigured=true, + // 用占位行表示"已有 N 个 Key 已配置";编辑即整体替换(无回归:WebUI 原本单 Key)。 + const initialUsesRedactedApiKey = + isGatewayWebui && initialApiKeys.length === 0 && initialData?.apiKeyConfigured === true; + const [apiKeys, setApiKeys] = useState( + initialUsesRedactedApiKey + ? [REDACTED_API_KEY_DISPLAY] + : initialApiKeys.length > 0 + ? initialApiKeys + : [""], ); + const configuredApiKeyCount = isGatewayWebui + ? initialData?.apiKeyCount ?? 0 + : initialApiKeys.filter((key) => key.trim()).length; const [customHeaders, setCustomHeaders] = useState(() => (initialData?.customHeaders ?? []).map((header) => ({ ...header })), ); @@ -475,8 +488,12 @@ function ProviderModal({ providerType, initialData, onSave, onClose }: ModalProp modelsRef.current = models; modelOrderRef.current = modelOrder; activeModelsRef.current = activeModels; - const apiKeyIsRedactedDisplay = initialUsesRedactedApiKey && apiKey === REDACTED_API_KEY_DISPLAY; - const apiKeyForRequest = apiKeyIsRedactedDisplay ? "" : apiKey.trim(); + const apiKeyIsRedactedDisplay = + initialUsesRedactedApiKey && apiKeys.length === 1 && apiKeys[0] === REDACTED_API_KEY_DISPLAY; + const realApiKeys = apiKeys + .map((key) => (key === REDACTED_API_KEY_DISPLAY ? "" : key.trim())) + .filter(Boolean); + const apiKeyForRequest = apiKeyIsRedactedDisplay ? "" : (realApiKeys[0] ?? ""); const canFetchModels = baseUrl.trim().length > 0 && apiKeyForRequest.length > 0; const persistedUsageQueryProviderId = getPersistedUsageQueryProviderId(initialData); const { confirm: requestUsageQueryConfirm, dialog: usageQueryConfirmDialog } = useConfirmDialog(); @@ -493,7 +510,7 @@ function ProviderModal({ providerType, initialData, onSave, onClose }: ModalProp // 变量实际生效值:查询专用覆盖优先,留空回退供应商自身配置(与 Rust // prepare_script_query 的解析顺序一致)。 const usageVariableBaseUrl = usageQuery.baseUrl.trim() || baseUrl.trim(); - const usageVariableApiKey = usageQuery.apiKey.trim() || apiKey.trim(); + const usageVariableApiKey = usageQuery.apiKey.trim() || apiKeyForRequest; // Token Plan 供应商:显式选择优先,否则按 Base URL 自动检测。 const activeCodingPlanProvider = usageQuery.codingPlanProvider || detectCodingPlanProvider(baseUrl); @@ -813,6 +830,16 @@ function ProviderModal({ providerType, initialData, onSave, onClose }: ModalProp setHeaderValidationSubmitted(false); } + function updateApiKey(index: number, value: string) { + setApiKeys((prev) => prev.map((key, keyIndex) => (keyIndex === index ? value : key))); + } + function removeApiKey(index: number) { + setApiKeys((prev) => prev.filter((_, keyIndex) => keyIndex !== index)); + } + function addApiKey() { + setApiKeys((prev) => [...prev, ""]); + } + function openHeaderSuggest(index: number) { const input = headerKeyRefs.current[index]; if (!input) return; @@ -897,14 +924,26 @@ function ProviderModal({ providerType, initialData, onSave, onClose }: ModalProp if (!confirmed) return; setCustomUsageQueryConfirmed(true); } - const nextApiKey = apiKeyIsRedactedDisplay ? "" : apiKey.trim(); + const trimmedApiKeys = apiKeys + .map((key) => (key === REDACTED_API_KEY_DISPLAY ? "" : key.trim())) + .filter(Boolean); + const dedupedApiKeys: string[] = []; + const seenApiKeys = new Set(); + for (const key of trimmedApiKeys) { + if (!seenApiKeys.has(key)) { + seenApiKeys.add(key); + dedupedApiKeys.push(key); + } + } + const nextApiKey = dedupedApiKeys[0] ?? ""; onSave({ name: name.trim(), type: providerType, baseUrl: baseUrl.trim(), apiKey: nextApiKey, + apiKeys: dedupedApiKeys, apiKeyConfigured: - nextApiKey.length > 0 || + dedupedApiKeys.length > 0 || apiKeyIsRedactedDisplay || (isGatewayWebui && initialData?.apiKeyConfigured === true), customHeaders, @@ -1209,32 +1248,111 @@ function ProviderModal({ providerType, initialData, onSave, onClose }: ModalProp
- -
- setApiKey(event.currentTarget.value)} - onFocus={(event) => { - if (apiKeyIsRedactedDisplay) event.currentTarget.select(); - }} - /> - -
+ {isGatewayWebui ? ( + <> + +
+ setApiKeys([event.currentTarget.value])} + onFocus={(event) => { + if (apiKeyIsRedactedDisplay) event.currentTarget.select(); + }} + /> + +
+ {apiKeyIsRedactedDisplay && configuredApiKeyCount > 0 ? ( +

+ {t("settings.providerApiKeysConfiguredCount").replace( + "{count}", + String(configuredApiKeyCount), + )} +

+ ) : null} + + ) : ( + <> +
+ + +
+
+ {apiKeys.map((key, index) => ( +
+ updateApiKey(index, event.currentTarget.value)} + onFocus={(event) => { + if (index === 0 && apiKeyIsRedactedDisplay) { + event.currentTarget.select(); + } + }} + /> + {apiKeys.length > 1 ? ( + + ) : null} +
+ ))} + +
+

+ {t("settings.providerApiKeyHint")} +

+ + )}
@@ -2803,6 +2921,7 @@ function providerFromCcs(item: CcsProviderImportItem, existingIds: Set): type: providerType, baseUrl: item.baseUrl, apiKey: item.apiKey, + apiKeys: item.apiKey.trim() ? [item.apiKey.trim()] : [], apiKeyConfigured: item.apiKey.trim().length > 0, models, activeModels: models.map((model) => model.id), @@ -2851,10 +2970,16 @@ function cherryProviderName(item: CherryProviderImportItem, allItems: CherryProv return `${item.name.trim()}(Cherry Studio · ${sourceId})`; } -// Re-syncing an existing provider must not silently revert an API key the -// user already configured in LiveAgent; like `name`, the existing key wins. -function cherryEffectiveApiKey(item: CherryProviderImportItem, existing?: CustomProvider) { - return existing?.apiKey?.trim() ? existing.apiKey : item.apiKey; +// Re-syncing an existing provider must not silently revert API keys the +// user already configured in LiveAgent; like `name`, the existing keys win. +function cherryEffectiveApiKeys(item: CherryProviderImportItem, existing?: CustomProvider): string[] { + if (Array.isArray(existing?.apiKeys) && existing.apiKeys.some((key) => key.trim())) { + return existing.apiKeys; + } + if (existing?.apiKey?.trim()) { + return [existing.apiKey.trim()]; + } + return item.apiKeys; } function providerFromCherry( @@ -2864,7 +2989,8 @@ function providerFromCherry( ): CustomProvider { const providerType = item.providerType; const models = existing?.models ?? []; - const apiKey = cherryEffectiveApiKey(item, existing); + const apiKeys = cherryEffectiveApiKeys(item, existing); + const apiKey = apiKeys[0] ?? ""; return { ...(existing ?? {}), id: cherryProviderId(item), @@ -2872,7 +2998,8 @@ function providerFromCherry( type: providerType, baseUrl: item.baseUrl, apiKey, - apiKeyConfigured: apiKey.trim().length > 0, + apiKeys, + apiKeyConfigured: apiKeys.length > 0, models, activeModels: existing?.activeModels ?? [], requestFormat: @@ -3888,7 +4015,7 @@ export function ProvidersSection( const fetchedModels = await fetchModelsFromApi( item.providerType, item.baseUrl, - cherryEffectiveApiKey(item, existingById.get(identity)), + cherryEffectiveApiKeys(item, existingById.get(identity))[0] ?? "", ); const models = fetchedModels.filter((model) => isLikelyCherryChatModel(model.id)); return { identity, models, fetched: true, failed: false }; diff --git a/crates/agent-gui/test/chat/agent-runner.test.mjs b/crates/agent-gui/test/chat/agent-runner.test.mjs index 5e690c9a4..cf2142a41 100644 --- a/crates/agent-gui/test/chat/agent-runner.test.mjs +++ b/crates/agent-gui/test/chat/agent-runner.test.mjs @@ -258,6 +258,19 @@ const llmMock = { headers: { Authorization: `Bearer ${runtime.apiKey}`, "x-liveagent-test": "1" }, }; }, + createProviderApiKeyFailover({ providerId, apiKeys, sessionId }) { + // 测试 runtime 多为单 Key(apiKey),仅对齐真实函数:单 Key 返回 attemptAuth、 + // 不启用故障转移;attemptAuth.headers 仅供 streamByApi 在 factory 里读取(测试 + // 用 streamSimpleByApi mock,不读 headers)。 + const key = Array.isArray(apiKeys) && apiKeys.length > 0 ? String(apiKeys[0]).trim() : ""; + if (!key) return { attemptAuth: undefined, failover: undefined }; + const headers = + providerId === "claude_code" ? { "x-api-key": key } : { Authorization: `Bearer ${key}` }; + return { + attemptAuth: { apiKey: key, headers }, + failover: undefined, + }; + }, createModelFromConfig(providerId, modelId, baseUrl) { const api = providerId === "claude_code" ? "anthropic-messages" : "openai-responses"; return { diff --git a/crates/agent-gui/test/settings/normalization.test.mjs b/crates/agent-gui/test/settings/normalization.test.mjs index 6396fc646..78eeb1003 100644 --- a/crates/agent-gui/test/settings/normalization.test.mjs +++ b/crates/agent-gui/test/settings/normalization.test.mjs @@ -789,7 +789,9 @@ test("gateway settings sync payload redacts provider api keys", () => { const payload = sync.buildGatewaySettingsSyncPayload(appSettings); assert.equal(payload.customProviders[0].apiKey, undefined); + assert.equal(payload.customProviders[0].apiKeys, undefined); assert.equal(payload.customProviders[0].apiKeyConfigured, true); + assert.equal(payload.customProviders[0].apiKeyCount, 1); assert.equal(payload.customProviders[0].nativeWebSearchEnabled, true); assert.deepEqual(payload.customSettings.conversationTitleModel, { customProviderId: "provider-1", @@ -847,8 +849,9 @@ test("gateway settings sync payload redacts provider api keys", () => { includeProviderApiKeyUpdates: true, }); assert.equal(updatePayload.customProviders[0].apiKey, undefined); + assert.equal(updatePayload.customProviders[0].apiKeyCount, 1); assert.deepEqual(updatePayload.providerApiKeyUpdates, { - "provider-1": "secret-key", + "provider-1": ["secret-key"], }); });