Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions crates/agent-gateway/web/src/i18n/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1396,6 +1396,10 @@ export const translations: Record<Locale, Record<string, string>> = {
"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": "关闭",
Expand Down Expand Up @@ -3601,6 +3605,12 @@ export const translations: Record<Locale, Record<string, string>> = {
"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",
Expand Down
16 changes: 13 additions & 3 deletions crates/agent-gateway/web/src/lib/settings/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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[];
Expand Down Expand Up @@ -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 {
Expand All @@ -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 } : {}),
Expand Down
22 changes: 22 additions & 0 deletions crates/agent-gateway/web/src/lib/settings/normalize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];
Expand Down
87 changes: 73 additions & 14 deletions crates/agent-gateway/web/src/lib/settings/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
workspaceProjectPathKey,
} from "./index";

export type GatewayProviderApiKeyUpdates = Record<string, string>;
export type GatewayProviderApiKeyUpdates = Record<string, string[]>;
export type GatewayProviderUsageQuerySecretUpdates = Record<
string,
{
Expand Down Expand Up @@ -42,8 +42,12 @@ export type GatewaySshSyncPatch = {
after: string[];
};
};
export type GatewaySettingsSyncProvider = Omit<AppSettings["customProviders"][number], "apiKey"> & {
export type GatewaySettingsSyncProvider = Omit<
AppSettings["customProviders"][number],
"apiKey" | "apiKeys"
> & {
apiKeyConfigured?: boolean;
apiKeyCount?: number;
};
export type GatewaySettingsSyncCustomSettings = Partial<AppSettings["customSettings"]>;

Expand Down Expand Up @@ -97,7 +101,20 @@ function asObject(value: unknown): Record<string, unknown> {
}

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"] = {
Expand Down Expand Up @@ -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),
};
});
}
Expand All @@ -162,8 +180,10 @@ export function redactCustomProvidersForWebStorage(
return customProviders.map((provider) => ({
...provider,
apiKey: "",
apiKeys: [],
usageQuery: redactUsageQueryConfig(provider.usageQuery),
apiKeyConfigured: apiKeyConfiguredForProvider(provider),
apiKeyCount: apiKeyCountForProvider(provider),
}));
}

Expand Down Expand Up @@ -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<string>();
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;
}
Expand Down Expand Up @@ -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<string>();
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;
}
Expand Down Expand Up @@ -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(
Expand All @@ -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 } : {}),
Expand Down
Loading
Loading