diff --git a/apps/codex-plus-manager/src/App.tsx b/apps/codex-plus-manager/src/App.tsx index 10834322d..b6cac5328 100644 --- a/apps/codex-plus-manager/src/App.tsx +++ b/apps/codex-plus-manager/src/App.tsx @@ -174,6 +174,7 @@ import { normalizeAggregateDefaultRelayId, } from "./aggregate-members"; import { relayAuthForLiveDraft, shouldBackfillRelayProfileBeforeSwitch } from "./relay-live-files"; +import { createRelayApiKey, normalizeRelayApiKeys, relayProfileWithNormalizedApiKeys, type RelayApiKey } from "./provider-api-keys"; import { relayHeadersValidationMessage, serializeRelayHeaders } from "./relay-headers"; import { sessionProviderForProtocol } from "./relay-session"; import { usePopoverLayer } from "./use-popover-layer"; @@ -421,6 +422,8 @@ export type RelayProfile = { baseUrl: string; upstreamBaseUrl: string; apiKey: string; + apiKeys?: RelayApiKey[]; + activeApiKeyId?: string; protocol: RelayProtocol; relayMode: RelayMode; sessionProvider?: RelaySessionProvider; @@ -8874,6 +8877,11 @@ function RelayProfileEditor({ const updateDraft = (patch: Partial) => { onProfileChange(applyRelayProfilePatchToFiles(profile, patch, { allowGenerateFiles: isNew })); }; + const normalizedApiKeys = normalizeRelayApiKeys(profile); + const updateApiKeys = (apiKeys: RelayApiKey[], activeApiKeyId = normalizedApiKeys.activeApiKeyId) => { + const selected = apiKeys.find((entry) => entry.id === activeApiKeyId) || apiKeys[0]; + updateDraft({ apiKeys, activeApiKeyId: selected?.id || "", apiKey: selected?.apiKey || "" }); + }; const selectEditorMode = (mode: RelayEditorMode) => { if (mode === "official") { updateDraft({ relayMode: "official", officialMixApiKey: false }); @@ -9318,13 +9326,58 @@ function RelayProfileEditor({ placeholder={t("填写供应商的 Base URL")} /> - - updateDraft({ apiKey: event.currentTarget.value })} - placeholder={t("输入供应商的 API Key")} - /> + +
+ {normalizedApiKeys.apiKeys.map((entry) => { + const active = entry.id === normalizedApiKeys.activeApiKeyId; + return ( +
+ + updateApiKeys(normalizedApiKeys.apiKeys.map((key) => + key.id === entry.id ? { ...key, name: event.currentTarget.value } : key))} + /> + updateApiKeys(normalizedApiKeys.apiKeys.map((key) => + key.id === entry.id ? { ...key, apiKey: event.currentTarget.value } : key))} + placeholder={t("输入供应商的 API Key")} + /> + +
+ ); + })} + +
EN_PLAIN["中文"]. export const EN_PLAIN: Record = { + "设为当前 Key": "Select this key", + "Key 名称": "Key name", + "删除 Key": "Delete key", + "添加 Key": "Add key", "本地服务器状态": "Local server status", "调试连接状态": "Debug connection status", "当前配置无需本地服务器": "The current configuration does not require a local server", @@ -1444,6 +1448,7 @@ export const EN_PLAIN: Record = { // Interpolated strings: tf("前缀 {0}", [x]) -> EN_TEMPLATE["前缀 {0}"] with {0} filled. export const EN_TEMPLATE: Record = { + "{0} 的 API Key": "API key for {0}", "{0} 个插件": "{0} plugins", "{0} / {1} 个插件": "{0} / {1} plugins", "查看插件 {0} 的详情": "View details for plugin {0}", diff --git a/apps/codex-plus-manager/src/named-key-editor.test.ts b/apps/codex-plus-manager/src/named-key-editor.test.ts new file mode 100644 index 000000000..92a2d5da3 --- /dev/null +++ b/apps/codex-plus-manager/src/named-key-editor.test.ts @@ -0,0 +1,31 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import ts from "typescript"; +import { normalizeRelayApiKeys, relayProfileWithNormalizedApiKeys } from "./provider-api-keys.ts"; + +test("编辑命名 Key 同步选择、列表和既有 API 字段", async () => { + const source = await readFile(new URL("./App.tsx", import.meta.url), "utf8"); + const file = ts.createSourceFile("App.tsx", source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); + let code = ""; + const visit = (node: ts.Node) => { + if (ts.isVariableDeclaration(node) && node.name.getText(file) === "updateApiKeys") { + code = node.initializer!.getText(file); + } + ts.forEachChild(node, visit); + }; + visit(file); + assert.ok(code); + const profile = { apiKey: "a", apiKeys: [{ id: "a", name: "A", apiKey: "a" }, { id: "b", name: "B", apiKey: "b" }], activeApiKeyId: "a" }; + const normalized = normalizeRelayApiKeys(profile); + let patch: unknown; + const compiled = ts.transpileModule(`const update = ${code};`, { + compilerOptions: { target: ts.ScriptTarget.ES2022 }, + }).outputText; + const update = new Function("normalizedApiKeys", "updateDraft", `${compiled}; return update;`)( + normalized, (value: unknown) => { patch = value; }, + ); + update(normalized.apiKeys, "b"); + assert.deepEqual(patch, { apiKeys: normalized.apiKeys, activeApiKeyId: "b", apiKey: "b" }); + assert.equal(relayProfileWithNormalizedApiKeys({ ...profile, activeApiKeyId: "b" } as never).apiKey, "b"); +}); diff --git a/apps/codex-plus-manager/src/provider-api-keys.test.ts b/apps/codex-plus-manager/src/provider-api-keys.test.ts new file mode 100644 index 000000000..aa6f5189c --- /dev/null +++ b/apps/codex-plus-manager/src/provider-api-keys.test.ts @@ -0,0 +1,49 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { normalizeRelayApiKeys, relayProfileWithNormalizedApiKeys } from "./provider-api-keys.ts"; +import type { RelayProfile } from "./App.tsx"; + +test("旧单 Key 自动迁移为默认命名 Key", () => { + assert.deepEqual(normalizeRelayApiKeys({ apiKey: "sk-legacy" }), { + apiKeys: [{ id: "default", name: "默认", apiKey: "sk-legacy" }], + activeApiKeyId: "default", + }); +}); + +test("空配置也提供一个可直接编辑的默认 Key", () => { + assert.deepEqual(normalizeRelayApiKeys({ apiKey: "" }), { + apiKeys: [{ id: "default", name: "默认", apiKey: "" }], + activeApiKeyId: "default", + }); +}); + +test("命名 Key 去重并回退到首个有效选项", () => { + const normalized = normalizeRelayApiKeys({ + apiKey: "old", + activeApiKeyId: "missing", + apiKeys: [ + { id: "same", name: "主账号", apiKey: "sk-a" }, + { id: "same", name: "", apiKey: "sk-b" }, + ], + }); + assert.deepEqual(normalized, { + apiKeys: [ + { id: "same", name: "主账号", apiKey: "sk-a" }, + { id: "same-2", name: "Key 2", apiKey: "sk-b" }, + ], + activeApiKeyId: "same", + }); +}); + +test("当前命名 Key 始终决定运行时 apiKey", () => { + const profile = { + apiKey: "stale", + activeApiKeyId: "backup", + apiKeys: [ + { id: "main", name: "主账号", apiKey: "sk-main" }, + { id: "backup", name: "备用", apiKey: "sk-backup" }, + ], + } as RelayProfile; + assert.equal(relayProfileWithNormalizedApiKeys(profile).apiKey, "sk-backup"); +}); diff --git a/apps/codex-plus-manager/src/provider-api-keys.ts b/apps/codex-plus-manager/src/provider-api-keys.ts new file mode 100644 index 000000000..0a064dbf4 --- /dev/null +++ b/apps/codex-plus-manager/src/provider-api-keys.ts @@ -0,0 +1,64 @@ +import type { RelayProfile } from "./App"; + +export type RelayApiKey = { id: string; name: string; apiKey: string }; + +const DEFAULT_KEY_ID = "default"; + +function uniqueKeyId(base: string, used: Set): string { + let id = base; + let suffix = 2; + while (used.has(id)) { + id = `${base}-${suffix}`; + suffix += 1; + } + return id; +} + +export function normalizeRelayApiKeys(profile: Pick): { + apiKeys: RelayApiKey[]; + activeApiKeyId: string; +} { + const used = new Set(); + const apiKeys = (Array.isArray(profile.apiKeys) ? profile.apiKeys : []).map((entry, index) => { + const requestedId = String(entry?.id || "").trim() || `key-${index + 1}`; + const id = uniqueKeyId(requestedId, used); + used.add(id); + return { + id, + name: String(entry?.name || "").trim() || `Key ${index + 1}`, + apiKey: String(entry?.apiKey || ""), + }; + }); + if (!apiKeys.length) { + apiKeys.push({ id: DEFAULT_KEY_ID, name: "默认", apiKey: profile.apiKey || "" }); + } + const requestedActiveId = String(profile.activeApiKeyId || "").trim(); + const activeApiKeyId = apiKeys.some((entry) => entry.id === requestedActiveId) + ? requestedActiveId + : apiKeys[0]?.id || ""; + return { apiKeys, activeApiKeyId }; +} + +export function selectedRelayApiKey(profile: Pick): RelayApiKey | null { + const normalized = normalizeRelayApiKeys(profile); + return normalized.apiKeys.find((entry) => entry.id === normalized.activeApiKeyId) || null; +} + +export function relayProfileWithNormalizedApiKeys(profile: RelayProfile): RelayProfile { + const normalized = normalizeRelayApiKeys(profile); + const selected = normalized.apiKeys.find((entry) => entry.id === normalized.activeApiKeyId); + return { + ...profile, + ...normalized, + apiKey: selected?.apiKey ?? profile.apiKey, + }; +} + +export function createRelayApiKey(apiKeys: RelayApiKey[]): RelayApiKey { + const used = new Set(apiKeys.map((entry) => entry.id)); + return { + id: uniqueKeyId(`key-${Date.now().toString(36)}`, used), + name: `Key ${apiKeys.length + 1}`, + apiKey: "", + }; +} diff --git a/apps/codex-plus-manager/src/styles.css b/apps/codex-plus-manager/src/styles.css index 904200b2c..d116b3c20 100644 --- a/apps/codex-plus-manager/src/styles.css +++ b/apps/codex-plus-manager/src/styles.css @@ -1877,6 +1877,60 @@ body { grid-column: auto; } +.relay-field-key { + grid-column: 1 / -1; +} + +.relay-api-key-list { + display: grid; + gap: 8px; +} + +.relay-api-key-row { + display: grid; + grid-template-columns: 36px minmax(120px, 0.55fr) minmax(220px, 1.45fr) 36px; + align-items: center; + gap: 8px; + min-width: 0; + padding: 6px; + border: 1px solid hsl(var(--border) / 0.8); + border-radius: var(--radius-md); + background: hsl(var(--surface-raised) / 0.35); +} + +.relay-api-key-row.active { + border-color: hsl(var(--brand-accent) / 0.55); + background: hsl(var(--brand-accent) / 0.07); +} + +.relay-api-key-select { + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border: 0; + border-radius: var(--radius-md); + background: transparent; + color: hsl(var(--muted-foreground)); + cursor: pointer; +} + +.relay-api-key-select:hover, +.relay-api-key-select:focus-visible { + background: hsl(var(--surface-raised)); + color: hsl(var(--foreground)); + outline: none; +} + +.relay-api-key-row.active .relay-api-key-select { + color: hsl(var(--brand-accent)); +} + +.relay-api-key-add { + justify-self: start; +} + /* 折叠式高级区:整块描边,标题行自带箭头,收起时用一行提示说明里面有什么 */ .relay-advanced-block { grid-column: 1 / -1; @@ -7003,6 +7057,14 @@ select { flex-direction: column; } + .relay-api-key-row { + grid-template-columns: 36px minmax(0, 1fr) 36px; + } + + .relay-api-key-row > input[type="password"] { + grid-column: 2 / 4; + } + .relay-model-list-tools { justify-content: flex-start; } diff --git a/crates/codex-plus-core/src/ccs_import.rs b/crates/codex-plus-core/src/ccs_import.rs index cc5b78b41..e9175035b 100644 --- a/crates/codex-plus-core/src/ccs_import.rs +++ b/crates/codex-plus-core/src/ccs_import.rs @@ -146,6 +146,12 @@ pub fn relay_profile_from_ccs( base_url: provider.base_url.clone(), upstream_base_url: provider.base_url.clone(), api_key: provider.api_key.clone(), + api_keys: vec![crate::settings::RelayApiKey { + id: "default".to_string(), + name: "默认".to_string(), + api_key: provider.api_key.clone(), + }], + active_api_key_id: "default".to_string(), protocol: provider.protocol, relay_mode: RelayMode::PureApi, official_mix_api_key: false, diff --git a/crates/codex-plus-core/src/model_catalog.rs b/crates/codex-plus-core/src/model_catalog.rs index 7853f9b89..605370810 100644 --- a/crates/codex-plus-core/src/model_catalog.rs +++ b/crates/codex-plus-core/src/model_catalog.rs @@ -799,7 +799,7 @@ pub async fn fetch_relay_profile_model_ids( } else { profile.upstream_base_url.trim().to_string() }, - api_key: profile.api_key.trim().to_string(), + api_key: crate::relay_config::relay_profile_api_key(profile), headers: profile.custom_headers.clone(), }; if source.base_url.is_empty() { diff --git a/crates/codex-plus-core/src/provider_import.rs b/crates/codex-plus-core/src/provider_import.rs index a1560bf84..d4d7082f6 100644 --- a/crates/codex-plus-core/src/provider_import.rs +++ b/crates/codex-plus-core/src/provider_import.rs @@ -187,6 +187,12 @@ fn relay_profile_from_request( base_url: request.base_url.clone(), upstream_base_url: request.base_url.clone(), api_key: request.api_key.clone(), + api_keys: vec![crate::settings::RelayApiKey { + id: "default".to_string(), + name: "默认".to_string(), + api_key: request.api_key.clone(), + }], + active_api_key_id: "default".to_string(), protocol: relay_protocol(&request.wire_api), relay_mode: relay_mode(&request.relay_mode), official_mix_api_key: false, diff --git a/crates/codex-plus-core/src/relay_config.rs b/crates/codex-plus-core/src/relay_config.rs index dbf0f2cfa..317daeb75 100644 --- a/crates/codex-plus-core/src/relay_config.rs +++ b/crates/codex-plus-core/src/relay_config.rs @@ -1297,6 +1297,10 @@ fn backfill_profile_from_text( "实时配置没有可采纳的供应商 API Key;已保留原快照,请显式导入完整 URL 与凭据。" ); } + profile.api_keys = vec![crate::settings::RelayApiKey { + id: "live-import".into(), name: "导入".into(), api_key: live_key.clone(), + }]; + profile.active_api_key_id = "live-import".into(); restore_profile_credentials_after_backfill(profile, live_auth, &live_key, live_auth)?; } else { restore_profile_credentials_after_backfill( @@ -4397,6 +4401,9 @@ pub fn relay_profile_api_key(profile: &RelayProfile) -> String { if profile.relay_mode == crate::settings::RelayMode::Aggregate { return "codex-plus-aggregate".to_string(); } + if let Some(selected) = profile.selected_api_key() { + return selected.api_key.trim().to_string(); + } if profile.relay_mode == crate::settings::RelayMode::Official { return experimental_bearer_token_from_config(&profile.config_contents) .ok() @@ -4565,6 +4572,47 @@ fn complete_relay_profile_config_with_proxy_port( } pub fn normalize_relay_profile_for_storage(profile: &mut RelayProfile) -> anyhow::Result<()> { + if profile.api_keys.is_empty() + && !profile.uses_no_auth() + && (profile.relay_mode != crate::settings::RelayMode::Official || profile.official_mix_api_key) + { + let key = relay_profile_api_key(profile); + if !key.is_empty() { + profile.api_keys.push(crate::settings::RelayApiKey { + id: "default".into(), name: "默认".into(), api_key: key, + }); + } + } + let mut seen_api_key_ids = HashSet::new(); + profile.api_keys = profile + .api_keys + .drain(..) + .filter_map(|mut entry| { + entry.id = entry.id.trim().to_string(); + entry.name = entry.name.trim().to_string(); + if entry.id.is_empty() || !seen_api_key_ids.insert(entry.id.clone()) { + return None; + } + if entry.name.is_empty() { + entry.name = "未命名 Key".to_string(); + } + Some(entry) + }) + .collect(); + if !profile + .api_keys + .iter() + .any(|entry| entry.id == profile.active_api_key_id) + { + profile.active_api_key_id = profile + .api_keys + .first() + .map(|entry| entry.id.clone()) + .unwrap_or_default(); + } + if let Some(selected) = profile.selected_api_key() { + profile.api_key = selected.api_key.clone(); + } profile.no_auth = profile.relay_mode == crate::settings::RelayMode::PureApi && profile.no_auth; if profile.no_auth { profile.api_key.clear(); diff --git a/crates/codex-plus-core/src/routes.rs b/crates/codex-plus-core/src/routes.rs index a88d403d9..b0b105c2a 100644 --- a/crates/codex-plus-core/src/routes.rs +++ b/crates/codex-plus-core/src/routes.rs @@ -739,6 +739,23 @@ fn settings_payload_value( ); settings.dictation = crate::dictation::effective_settings(&settings); let mut value = serde_json::to_value(settings)?; + fn remove_named_keys(value: &mut Value) { + match value { + Value::Object(object) => { + object.remove("apiKeys"); + for child in object.values_mut() { + remove_named_keys(child); + } + } + Value::Array(array) => { + for child in array { + remove_named_keys(child); + } + } + _ => {} + } + } + remove_named_keys(&mut value); if let Some(object) = value.as_object_mut() { object.remove("codexAppStepwiseApiKey"); if let Some(dictation) = object.get_mut("dictation").and_then(Value::as_object_mut) { @@ -791,6 +808,17 @@ mod dictation_privacy_tests { assert_eq!(result["dictation"]["apiKeyConfigured"], true); assert!(!result.to_string().contains("fake-asr-private-key")); } + + #[test] + fn settings_bridge_never_returns_named_key_lists() { + let mut settings = BackendSettings::default(); + settings.relay_profiles[0].api_keys = vec![crate::settings::RelayApiKey { + id: "backup".into(), name: "备用".into(), api_key: "fake-backup-secret".into(), + }]; + let result = settings_payload_value(settings, String::new()).unwrap(); + assert!(result["relayProfiles"][0].get("apiKeys").is_none()); + assert!(!result.to_string().contains("fake-backup-secret")); + } #[test] fn dictation_settings_bridge_applies_enhancement_switch_without_mutating_settings() { let mut settings = BackendSettings::default(); diff --git a/crates/codex-plus-core/src/settings.rs b/crates/codex-plus-core/src/settings.rs index a964d7ebe..76441ed52 100644 --- a/crates/codex-plus-core/src/settings.rs +++ b/crates/codex-plus-core/src/settings.rs @@ -32,6 +32,10 @@ pub struct RelayProfile { deserialize_with = "deserialize_profile_api_key" )] pub api_key: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub api_keys: Vec, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub active_api_key_id: String, #[serde(default)] pub protocol: RelayProtocol, #[serde(rename = "relayMode", default)] @@ -130,6 +134,15 @@ pub struct RelayProfile { pub web_search_history_compat: bool, } +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RelayApiKey { + pub id: String, + pub name: String, + #[serde(default)] + pub api_key: String, +} + #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "camelCase")] pub struct RelayModelRoute { @@ -239,6 +252,8 @@ impl Default for RelayProfile { base_url: default_relay_base_url(), upstream_base_url: String::new(), api_key: String::new(), + api_keys: Vec::new(), + active_api_key_id: String::new(), protocol: RelayProtocol::Responses, relay_mode: RelayMode::Official, official_mix_api_key: false, @@ -271,6 +286,11 @@ impl Default for RelayProfile { } impl RelayProfile { + pub fn selected_api_key(&self) -> Option<&RelayApiKey> { + self.api_keys.iter().find(|entry| entry.id == self.active_api_key_id) + .or_else(|| self.api_keys.first()) + } + pub fn uses_no_auth(&self) -> bool { self.relay_mode == RelayMode::PureApi && self.no_auth } @@ -846,6 +866,8 @@ impl BackendSettings { self.relay_base_url.clone() }, api_key: self.relay_api_key.clone(), + api_keys: Vec::new(), + active_api_key_id: String::new(), protocol: RelayProtocol::Responses, relay_mode: RelayMode::MixedApi, official_mix_api_key: true, @@ -903,6 +925,8 @@ impl BackendSettings { self.relay_base_url.clone() }, api_key: self.relay_api_key.clone(), + api_keys: Vec::new(), + active_api_key_id: String::new(), protocol: RelayProtocol::Responses, relay_mode: RelayMode::Official, official_mix_api_key: false, diff --git a/crates/codex-plus-core/tests/launcher.rs b/crates/codex-plus-core/tests/launcher.rs index e235fa8ee..d808aeafd 100644 --- a/crates/codex-plus-core/tests/launcher.rs +++ b/crates/codex-plus-core/tests/launcher.rs @@ -1885,6 +1885,8 @@ async fn launch_starts_helper_when_chat_protocol_proxy_is_enabled() { base_url: "https://chat-only.example.test/v1".to_string(), upstream_base_url: "https://chat-only.example.test/v1".to_string(), api_key: "sk-test".to_string(), + api_keys: Vec::new(), + active_api_key_id: String::new(), protocol: RelayProtocol::ChatCompletions, relay_mode: codex_plus_core::settings::RelayMode::MixedApi, official_mix_api_key: false, diff --git a/crates/codex-plus-core/tests/named_api_keys.rs b/crates/codex-plus-core/tests/named_api_keys.rs new file mode 100644 index 000000000..e5f0683cb --- /dev/null +++ b/crates/codex-plus-core/tests/named_api_keys.rs @@ -0,0 +1,88 @@ +use codex_plus_core::relay_config::{normalize_relay_profile_for_storage, relay_profile_api_key}; +use codex_plus_core::settings::{ + BackendSettings, RelayApiKey, RelayMode, RelayProfile, SettingsStore, +}; +use serde_json::json; + +fn profile() -> RelayProfile { + RelayProfile { + id: "provider".into(), + relay_mode: RelayMode::PureApi, + base_url: "https://provider.example/v1".into(), + api_key: "legacy-key".into(), + ..Default::default() + } +} + +#[test] +fn legacy_key_migrates_and_selected_key_reaches_both_auth_locations() { + let mut profile = profile(); + normalize_relay_profile_for_storage(&mut profile).unwrap(); + assert_eq!(profile.api_keys[0].api_key, "legacy-key"); + profile.api_keys.push(RelayApiKey { + id: "backup".into(), + name: "备用".into(), + api_key: "backup-key".into(), + }); + profile.active_api_key_id = "backup".into(); + normalize_relay_profile_for_storage(&mut profile).unwrap(); + assert_eq!(relay_profile_api_key(&profile), "backup-key"); + let auth: serde_json::Value = serde_json::from_str(&profile.auth_contents).unwrap(); + assert_eq!(auth["OPENAI_API_KEY"], "backup-key"); + assert!( + profile + .config_contents + .contains("experimental_bearer_token = \"backup-key\"") + ); +} + +#[test] +fn named_keys_survive_settings_save_load_and_tool_shard_sync() { + let temp = tempfile::tempdir().unwrap(); + let store = SettingsStore::new(temp.path().join("settings.json")); + let mut profile = profile(); + profile.api_keys = vec![ + RelayApiKey { + id: "main".into(), + name: "主账号".into(), + api_key: "main-key".into(), + }, + RelayApiKey { + id: "backup".into(), + name: "备用".into(), + api_key: "backup-key".into(), + }, + ]; + profile.active_api_key_id = "backup".into(); + store + .save(&BackendSettings { + active_relay_id: profile.id.clone(), + relay_profiles: vec![profile], + ..Default::default() + }) + .unwrap(); + let loaded = store.load().unwrap(); + assert_eq!(loaded.relay_profiles[0].api_keys.len(), 2); + assert_eq!(loaded.active_relay_profile().active_api_key_id, "backup"); + assert_eq!( + relay_profile_api_key(&loaded.active_relay_profile()), + "backup-key" + ); + let raw: serde_json::Value = + serde_json::from_slice(&std::fs::read(temp.path().join("settings.json")).unwrap()).unwrap(); + assert_eq!(raw["relayProfiles"][0]["activeApiKeyId"], "backup"); +} + +#[test] +fn missing_selection_falls_back_and_empty_current_key_does_not_borrow_another_key() { + let mut profile = profile(); + profile.api_keys = serde_json::from_value(json!([ + {"id":"main","name":"主账号","apiKey":""}, + {"id":"backup","name":"备用","apiKey":"backup-key"} + ])) + .unwrap(); + profile.active_api_key_id = "missing".into(); + normalize_relay_profile_for_storage(&mut profile).unwrap(); + assert_eq!(profile.active_api_key_id, "main"); + assert_eq!(relay_profile_api_key(&profile), ""); +} diff --git a/docs/named-api-keys.md b/docs/named-api-keys.md new file mode 100644 index 000000000..400aa75a6 --- /dev/null +++ b/docs/named-api-keys.md @@ -0,0 +1,13 @@ +# 命名 API Key + +普通 API 供应商可保存多个命名 Key。`apiKeys` 保存 ID、名称和密钥, +`activeApiKeyId` 指定当前项;当前项同步到现有 API 配置和认证文件。 +旧单 Key 配置自动成为“默认”项,原来的供应商选择方式保持不变。 + +在供应商详情选择当前项后,点击“保存供应商”。正在使用的供应商沿用 +既有保存与重启确认流程;未启用的供应商仅保存快照。聚合供应商继续由成员定义。 +添加备用项不会自动切换,最后一项不能通过列表删除。 + +显式采纳其他 live 供应商身份时,命名 Key 随该身份重新导入。 +注入页的设置桥接剥除整个 `apiKeys` 列表,不向用户脚本下发备用密钥。 +磁盘凭据沿用上游现有存储方式。