diff --git a/.gitignore b/.gitignore index 42c56894..752ceb34 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ venv/ build/ dist/ target/ +node_modules/ apps/codex-plus-manager/src-tauri/gen/ .codex_asar_extract/ diff --git a/apps/codex-plus-launcher/src/main.rs b/apps/codex-plus-launcher/src/main.rs index 059397e5..bce0a613 100644 --- a/apps/codex-plus-launcher/src/main.rs +++ b/apps/codex-plus-launcher/src/main.rs @@ -80,9 +80,7 @@ fn acquire_single_instance_guard_with_retry( } fn try_acquire_single_instance_guard() -> std::io::Result { - codex_plus_core::ports::acquire_loopback_port_guard( - codex_plus_core::ports::LAUNCHER_GUARD_PORT, - ) + codex_plus_core::ports::acquire_loopback_port_guard(codex_plus_core::ports::LAUNCHER_GUARD_PORT) } fn should_recover_stale_launcher(debug_port: u16) -> bool { diff --git a/apps/codex-plus-manager/src-tauri/src/commands.rs b/apps/codex-plus-manager/src-tauri/src/commands.rs index 305ac322..f8aa9b59 100644 --- a/apps/codex-plus-manager/src-tauri/src/commands.rs +++ b/apps/codex-plus-manager/src-tauri/src/commands.rs @@ -713,10 +713,14 @@ fn strip_common_config_text_fallback(config_contents: &str, common_config: &str) let mut kept = Vec::new(); let mut skipping_table = false; + let mut in_root_section = true; + let mut removed_root_keys = std::collections::HashSet::new(); + let source_root_keys = toml_root_keys_before_first_table(config_contents); for line in config_contents.lines() { let trimmed = line.trim(); if trimmed.starts_with('[') && trimmed.ends_with(']') { + in_root_section = false; let header = trimmed.to_string(); skipping_table = common.table_headers.contains(&header); if skipping_table { @@ -728,9 +732,21 @@ fn strip_common_config_text_fallback(config_contents: &str, common_config: &str) continue; } - if let Some(key) = toml_key_from_line(trimmed) { + if in_root_section && let Some(key) = toml_key_from_line(trimmed) { if common.root_keys.contains(key) { - continue; + let is_duplicate_common_key = removed_root_keys.contains(key) + || source_root_keys.contains(key) + || common.table_headers.contains("[features]") + || common + .table_headers + .contains("[marketplaces.openai-bundled]") + || common + .table_headers + .contains("[plugins.\"superpowers@openai-curated\"]"); + if is_duplicate_common_key { + removed_root_keys.insert(key.to_string()); + continue; + } } } @@ -740,6 +756,20 @@ fn strip_common_config_text_fallback(config_contents: &str, common_config: &str) ensure_text_newline(kept.join("\n").trim_end()) } +fn toml_root_keys_before_first_table(config_contents: &str) -> std::collections::HashSet { + let mut keys = std::collections::HashSet::new(); + for line in config_contents.lines() { + let trimmed = line.trim(); + if trimmed.starts_with('[') && trimmed.ends_with(']') { + break; + } + if let Some(key) = toml_key_from_line(trimmed) { + keys.insert(key.to_string()); + } + } + keys +} + struct CommonConfigAnchors { root_keys: std::collections::HashSet, table_headers: std::collections::HashSet, @@ -1534,6 +1564,9 @@ pub fn apply_relay_injection() -> CommandResult { } let relay = settings.active_relay_profile(); log_relay_apply_request("manager.apply_relay_injection", &settings, &relay); + if settings.active_aggregate_relay_profile().is_some() { + return apply_aggregate_relay_injection_to_home(&home); + } if relay_has_complete_files(&relay) { return match codex_plus_core::relay_config::apply_relay_profile_to_home_with_switch_rules( &home, @@ -1625,6 +1658,33 @@ pub fn apply_relay_injection() -> CommandResult { } } +fn apply_aggregate_relay_injection_to_home(home: &Path) -> CommandResult { + match codex_plus_core::relay_config::apply_relay_config_to_home_with_protocol( + home, + &codex_plus_core::protocol_proxy::local_responses_proxy_base_url( + codex_plus_core::protocol_proxy::DEFAULT_PROTOCOL_PROXY_PORT, + ), + "codex-plus-aggregate", + codex_plus_core::settings::RelayProtocol::Responses, + codex_plus_core::protocol_proxy::DEFAULT_PROTOCOL_PROXY_PORT, + ) { + Ok(result) => { + let status = codex_plus_core::relay_config::relay_status_from_home(home); + ok( + "聚合供应商配置已写入,真实请求会由本地代理按策略轮转。", + relay_payload(status, result.backup_path), + ) + } + Err(error) => { + let status = codex_plus_core::relay_config::relay_status_from_home(home); + failed( + &format!("写入聚合供应商配置失败:{error}"), + relay_payload(status, None), + ) + } + } +} + #[tauri::command] pub fn apply_pure_api_injection() -> CommandResult { let home = codex_plus_core::relay_config::default_codex_home_dir(); @@ -2358,6 +2418,20 @@ mod tests { assert!(text.contains("hasBearerToken")); } + #[test] + fn aggregate_relay_injection_writes_local_proxy_without_chatgpt_auth() { + let temp = tempfile::tempdir().unwrap(); + + let result = apply_aggregate_relay_injection_to_home(temp.path()); + let config = std::fs::read_to_string(temp.path().join("config.toml")).unwrap(); + + assert_eq!(result.status, "ok"); + assert!(result.payload.configured); + assert!(!result.payload.authenticated); + assert!(config.contains(r#"base_url = "http://127.0.0.1:57321/v1""#)); + assert!(config.contains(r#"experimental_bearer_token = "codex-plus-aggregate""#)); + } + #[test] fn relay_files_payload_reads_config_and_auth_contents() { let temp = tempfile::tempdir().unwrap(); @@ -2429,7 +2503,6 @@ mod tests { relay_common_config_contents: "[mcp_servers.context7]\ncommand = \"npx\"\n".to_string(), relay_profiles: vec![RelayProfile { use_common_config: false, - relay_mode: codex_plus_core::settings::RelayMode::PureApi, config_contents: "model = \"gpt-5\"\n\n[mcp_servers.context7]\ncommand = \"npx\"\n" .to_string(), ..RelayProfile::default() @@ -2477,10 +2550,16 @@ mod tests { let normalized = normalize_settings_before_save(settings); + let auth_json: serde_json::Value = + serde_json::from_str(&normalized.relay_profiles[0].auth_contents).unwrap(); assert_eq!( - serde_json::from_str::(&normalized.relay_profiles[0].auth_contents) - .unwrap(), - serde_json::json!({"auth_mode":"chatgpt","tokens":{"access_token":"edited"}}) + auth_json, + serde_json::json!({ + "auth_mode": "chatgpt", + "tokens": { + "access_token": "edited" + } + }) ); assert!(normalized.relay_profiles[0].config_contents.is_empty()); } @@ -2527,7 +2606,6 @@ enabled = true .to_string(), relay_profiles: vec![RelayProfile { use_common_config: true, - relay_mode: codex_plus_core::settings::RelayMode::PureApi, config_contents: r#"model = "gpt-5" model_reasoning_effort = "high" @@ -2564,7 +2642,6 @@ last_updated = "2026-05-25T11:52:46Z" .to_string(), relay_profiles: vec![RelayProfile { use_common_config: true, - relay_mode: codex_plus_core::settings::RelayMode::PureApi, config_contents: r#"model = "gpt-5" model_reasoning_effort = "high" diff --git a/apps/codex-plus-manager/src-tauri/tests/windows_subsystem.rs b/apps/codex-plus-manager/src-tauri/tests/windows_subsystem.rs index 6c9369ca..f25b04b8 100644 --- a/apps/codex-plus-manager/src-tauri/tests/windows_subsystem.rs +++ b/apps/codex-plus-manager/src-tauri/tests/windows_subsystem.rs @@ -156,6 +156,12 @@ fn relay_settings_keeps_profile_config_and_auth_files_isolated() { assert!(app_tsx.contains("relayProfileSwitchValidation(selectedBeforeSave)")); assert!(app_tsx.contains("缺少独立 config.toml")); assert!(app_tsx.contains("const command = relayProfileSwitchCommand(selectedAfterSave)")); + assert!(app_tsx.contains("function relayProfileSwitchCommand")); + assert!(app_tsx.contains("return \"apply_pure_api_injection\"")); + assert!(app_tsx.contains("return \"apply_relay_injection\"")); + assert!(app_tsx.contains("const createNewAggregateProfile = () =>")); + assert!(app_tsx.contains("onClick={createNewAggregateProfile}")); + assert!(app_tsx.contains("已打开聚合供应商详情")); assert!(!commands_rs.contains("缺少独立 auth.json")); assert!(commands_rs.contains("backfill_relay_profile_from_live")); assert!(commands_rs.contains("apply_relay_profile_to_home_with_switch_rules")); diff --git a/apps/codex-plus-manager/src/App.tsx b/apps/codex-plus-manager/src/App.tsx index baf1c41d..f2684abc 100644 --- a/apps/codex-plus-manager/src/App.tsx +++ b/apps/codex-plus-manager/src/App.tsx @@ -116,6 +116,8 @@ type BackendSettings = { relayBaseUrl: string; relayApiKey: string; relayProfiles: RelayProfile[]; + aggregateRelayProfiles: AggregateRelayProfile[]; + activeAggregateRelayId: string; relayCommonConfigContents: string; relayContextConfigContents: string; activeRelayId: string; @@ -149,6 +151,27 @@ type RelayProfile = { autoCompactLimit: string; modelList: string; userAgent: string; + aggregate?: RelayAggregateConfig | null; +}; + +type RelayAggregateStrategy = "failover" | "conversationRoundRobin" | "requestRoundRobin" | "weightedRoundRobin"; +type RelayAggregateMember = { + profileId: string; + weight: number; +}; +type RelayAggregateConfig = { + strategy: RelayAggregateStrategy; + members: RelayAggregateMember[]; +}; +type AggregateRelayMember = { + relayId: string; + weight: number; +}; +type AggregateRelayProfile = { + id: string; + name: string; + strategy: RelayAggregateStrategy; + members: AggregateRelayMember[]; }; type RelayContextSelection = { @@ -175,7 +198,7 @@ type CodexContextEntries = { }; type RelayProtocol = "responses" | "chatCompletions"; -type RelayMode = "official" | "mixedApi" | "pureApi"; +type RelayMode = "official" | "mixedApi" | "pureApi" | "aggregate"; const PROTOCOL_PROXY_BASE_URL = "http://127.0.0.1:57321/v1"; const CHAT_UPSTREAM_BASE_URL_KEY = "codex_plus_chat_base_url"; const SCRIPT_MARKET_REPOSITORY_URL = "https://github.com/BigPizzaV3/CodexPlusPlusScriptMarket"; @@ -486,6 +509,8 @@ const defaultSettings: BackendSettings = { relayCommonConfigContents: "", relayContextConfigContents: "", activeRelayId: "default", + aggregateRelayProfiles: [], + activeAggregateRelayId: "", relayTestModel: "gpt-5.4-mini", cliWrapperEnabled: false, cliWrapperBaseUrl: "", @@ -1690,6 +1715,18 @@ function RelayScreen({ onFormChange(next); await actions.saveSettingsValue(next, true, preserveLinkedProfiles); }; + const createNewAggregateProfile = () => { + const draft = createAggregateRelayProfile(normalized); + setDetailProfileId(null); + setNewProfileDraft(draft); + if (!normalizeAggregateConfig(draft.aggregate, aggregateMemberCandidates(normalized, draft.id)).members.length) { + void actions.showMessage( + "添加聚合供应商", + "已打开聚合供应商详情;请先添加或完善至少 1 个普通 API 供应商的 Base URL / Key,再勾选为成员。", + "failed", + ); + } + }; const editRelayProfile = async (profileId: string) => { let nextSettings = normalized; const profile = normalized.relayProfiles.find((item) => item.id === profileId); @@ -1782,6 +1819,13 @@ function RelayScreen({ 添加供应商 + - + {isAggregateRelayProfile(draft) ? null : ( + )} ); } @@ -2729,8 +2781,19 @@ function RelayProfileEditor({ onSwitch: () => void; actions: Actions; }) { - const showApiFields = profile.relayMode !== "official" || profile.officialMixApiKey; const [showAdvanced, setShowAdvanced] = useState(false); + if (isAggregateRelayProfile(profile)) { + return ( + + ); + } + + const showApiFields = profile.relayMode !== "official" || profile.officialMixApiKey; const updateDraft = (patch: Partial) => { onProfileChange(applyRelayProfilePatchToFiles(profile, patch, { allowGenerateFiles: isNew })); }; @@ -2936,6 +2999,146 @@ function RelayProfileEditor({ ); } +function AggregateRelayProfileEditor({ + profile, + form, + isNew = false, + onProfileChange, +}: { + profile: RelayProfile; + form: BackendSettings; + isNew?: boolean; + onProfileChange: (value: RelayProfile) => void; +}) { + const candidates = aggregateMemberCandidates(form, profile.id); + const aggregate = normalizeAggregateConfig(profile.aggregate, candidates); + const memberIds = new Set(aggregate.members.map((member) => member.profileId)); + const updateAggregate = (nextAggregate: RelayAggregateConfig) => { + onProfileChange(normalizeAggregateRelayProfile({ ...profile, aggregate: nextAggregate }, form)); + }; + const toggleMember = (profileId: string, checked: boolean) => { + const members = checked + ? [...aggregate.members, { profileId, weight: 1 }] + : aggregate.members.filter((member) => member.profileId !== profileId); + updateAggregate({ ...aggregate, members }); + }; + const updateWeight = (profileId: string, weight: number) => { + updateAggregate({ + ...aggregate, + members: aggregate.members.map((member) => + member.profileId === profileId ? { ...member, weight: clampAggregateWeight(weight) } : member, + ), + }); + }; + const totalWeight = aggregate.members.reduce((total, member) => total + clampAggregateWeight(member.weight), 0); + + return ( +
+
+
+ {profile.name || "未命名聚合供应商"} + {isNew ? "选择已有供应商作为成员,保存后写入 settings payload" : "聚合配置只引用已有供应商,不复制 Key 和配置文件"} +
+ 聚合 +
+
+ + onProfileChange({ ...profile, name: event.currentTarget.value })} + placeholder="例如 主力聚合池" + /> + + + onProfileChange({ ...profile, testModel: event.currentTarget.value })} + placeholder={`留空使用默认:${form.relayTestModel || defaultSettings.relayTestModel}`} + /> + + + + +
+
+ {aggregateStrategyOptions.map((option) => ( + + ))} +
+
+
+
+ 成员供应商 + 只能勾选已填写 Base URL / Key 的 API 供应商,聚合供应商不会作为成员。 +
+ {aggregate.members.length} / {candidates.length} +
+ {candidates.length ? ( +
+ {candidates.map((candidate) => { + const member = aggregate.members.find((item) => item.profileId === candidate.id); + const checked = memberIds.has(candidate.id); + return ( + + ); + })} +
+ ) : ( +
先添加至少 1 个已填写 Base URL / Key 的 API 供应商,再创建聚合供应商。
+ )} +
+
+ + + + +
+
+ + {aggregateStrategyHelp(aggregate.strategy)} +
+
+ ); +} + function RelayContextManager({ form, liveEntries, @@ -4185,6 +4388,9 @@ function healthItems(overview: OverviewResult | null) { } function normalizeSettings(settings: BackendSettings): BackendSettings { + const backendAggregates = new Map( + (settings.aggregateRelayProfiles ?? []).map((aggregate) => [aggregate.id, aggregate] as const), + ); const splitCommon = splitContextConfigText(settings.relayCommonConfigContents || ""); const relayCommonConfigContents = splitCommon.common; const relayContextConfigContents = joinTomlSectionsRootFirst([ @@ -4198,7 +4404,9 @@ function normalizeSettings(settings: BackendSettings): BackendSettings { }); const profiles = settings.relayProfiles?.length - ? settings.relayProfiles.map((profile) => normalizeRelayProfile(profile, defaultContextSelection)) + ? settings.relayProfiles.map((profile) => + normalizeRelayProfile(hydrateAggregateRelayProfile(profile, backendAggregates.get(profile.id)), defaultContextSelection), + ) : [ { id: settings.activeRelayId || "default", @@ -4248,6 +4456,33 @@ function inputToCodexExtraArgs(value: string) { function normalizeRelayProfile(profile: RelayProfile, defaultContextSelection = emptyContextSelection()): RelayProfile { const legacyMixedApi = profile.relayMode === "mixedApi"; + if (profile.relayMode === "aggregate" || profile.aggregate) { + return normalizeAggregateRelayProfile( + { + ...profile, + linkedCcsProviderId: "", + model: profile.model || "", + baseUrl: "", + upstreamBaseUrl: "", + apiKey: "", + protocol: "responses", + relayMode: "aggregate", + officialMixApiKey: false, + testModel: profile.testModel || "", + configContents: "", + authContents: "", + useCommonConfig: profile.useCommonConfig !== false, + contextSelection: profile.contextSelectionInitialized + ? normalizeContextSelection(profile.contextSelection) + : normalizeContextSelection(undefined, defaultContextSelection), + contextSelectionInitialized: true, + contextWindow: "", + autoCompactLimit: "", + modelList: "", + }, + null, + ); + } let normalized: RelayProfile = { ...profile, linkedCcsProviderId: profile.linkedCcsProviderId || "", @@ -4270,10 +4505,27 @@ function normalizeRelayProfile(profile: RelayProfile, defaultContextSelection = autoCompactLimit: profile.autoCompactLimit || "", modelList: profile.modelList || "", userAgent: profile.userAgent || "", + aggregate: null, }; return deriveRelayProfileFromFiles(normalized); } +function hydrateAggregateRelayProfile(profile: RelayProfile, aggregate: AggregateRelayProfile | undefined): RelayProfile { + if (!aggregate) return profile; + return { + ...profile, + name: profile.name || aggregate.name, + relayMode: "aggregate", + aggregate: { + strategy: aggregate.strategy, + members: aggregate.members.map((member) => ({ + profileId: member.relayId, + weight: clampAggregateWeight(member.weight), + })), + }, + }; +} + function activeRelayProfile(settings: BackendSettings): RelayProfile { return ( settings.relayProfiles.find((profile) => profile.id === settings.activeRelayId) || @@ -4287,6 +4539,7 @@ function relayProtocolLabel(protocol: RelayProtocol): string { } function normalizeRelayMode(mode: RelayMode | undefined): RelayMode { + if (mode === "aggregate") return mode; if (mode === "pureApi") return mode; return "official"; } @@ -4310,16 +4563,24 @@ function normalizeContextSelection( } function relayModeLabel(mode: RelayMode): string { + if (mode === "aggregate") return "聚合供应商"; if (mode === "pureApi") return "纯 API"; return "官方登录"; } function relayProfileConfigBrief(profile: RelayProfile): string { + if (isAggregateRelayProfile(profile)) { + const aggregate = normalizeAggregateConfig(profile.aggregate, []); + return `${aggregateStrategyLabel(aggregate.strategy)} · ${aggregate.members.length} 个成员`; + } if (profile.relayMode === "official") return profile.officialMixApiKey ? "混入 API Key" : "不写 API 文件"; return profile.baseUrl || "未填写 URL"; } function relayProfileModeHelp(profile: RelayProfile): string { + if (isAggregateRelayProfile(profile)) { + return "聚合供应商只保存成员和策略配置,成员来自已有 API 供应商;切为当前后会通过本地协议代理轮转请求。"; + } if (profile.relayMode === "official") { if (profile.officialMixApiKey) { return "此供应商会保留官方登录模式,并把请求混入当前 API Key;页面增强仍使用兼容模式。"; @@ -4333,6 +4594,10 @@ function relayProfileModeHelp(profile: RelayProfile): string { } function relayProfileReadinessText(profile: RelayProfile, relay: RelayResult | null): string { + if (isAggregateRelayProfile(profile)) { + const aggregate = normalizeAggregateConfig(profile.aggregate, []); + return `聚合供应商已配置为${aggregateStrategyLabel(aggregate.strategy)},包含 ${aggregate.members.length} 个成员;真实对话会走本地代理轮转。`; + } if (profile.relayMode === "official") { if (profile.officialMixApiKey) { const hasApiFields = profile.baseUrl.trim() && profile.apiKey.trim(); @@ -4352,6 +4617,7 @@ function relayProfileReadinessText(profile: RelayProfile, relay: RelayResult | n } function relayProfileSwitchCommand(profile: RelayProfile): "clear_relay_injection" | "apply_relay_injection" | "apply_pure_api_injection" { + if (isAggregateRelayProfile(profile)) return "apply_relay_injection"; if (profile.relayMode === "pureApi") return "apply_pure_api_injection"; if (profile.relayMode === "official" && !profile.officialMixApiKey) return "clear_relay_injection"; if (profile.configContents.trim()) return "apply_relay_injection"; @@ -4359,12 +4625,16 @@ function relayProfileSwitchCommand(profile: RelayProfile): "clear_relay_injectio } function relayProfileModeSwitchedText(profile: RelayProfile): string { + if (isAggregateRelayProfile(profile)) return "已切换到聚合供应商;真实对话会按所选策略轮转成员。"; if (profile.relayMode === "pureApi") return "已按此供应商切换到纯 API;页面增强已设为完整增强。"; if (profile.officialMixApiKey) return "已按此供应商使用官方登录,并混入 API Key;页面增强已设为兼容增强。"; return "已按此供应商切回官方登录;页面增强已设为兼容增强。"; } function withGeneratedRelayFiles(profile: RelayProfile): RelayProfile { + if (isAggregateRelayProfile(profile)) { + return { ...profile, configContents: "", authContents: "", aggregate: normalizeAggregateConfig(profile.aggregate, []) }; + } if (profile.relayMode === "official") { return { ...profile, @@ -4420,6 +4690,9 @@ function buildOfficialRelayAuthJson(contents: string): string { } function deriveRelayProfileFromFiles(profile: RelayProfile): RelayProfile { + if (isAggregateRelayProfile(profile)) { + return normalizeAggregateRelayProfile(profile, null); + } const configContents = profile.configContents || ""; const authContents = profile.relayMode === "official" ? buildOfficialRelayAuthJson(profile.authContents || "") : profile.authContents || ""; const configBaseUrl = codexBaseUrlFromConfig(configContents); @@ -4448,6 +4721,9 @@ function applyRelayProfilePatchToFiles( options: { allowGenerateFiles?: boolean } = {}, ): RelayProfile { let next: RelayProfile = { ...profile, ...patch }; + if (isAggregateRelayProfile(next)) { + return normalizeAggregateRelayProfile(next, null); + } const shouldHaveFiles = next.relayMode !== "official" || next.officialMixApiKey || next.configContents.trim() || next.authContents.trim(); const needsAuthFile = next.relayMode === "pureApi"; @@ -4708,6 +4984,9 @@ function removeTomlSectionKey(contents: string, sectionName: string, key: string } function relayProfileSwitchValidation(profile: RelayProfile): string | null { + if (isAggregateRelayProfile(profile)) { + return aggregateRelayProfileValidation(profile); + } if (profile.relayMode === "official" && !profile.officialMixApiKey) return null; if (!profile.configContents.trim()) { return `供应商「${profile.name || profile.id}」缺少独立 config.toml,已停止切换,避免继续显示上一套配置文件。请先在该供应商详情里保存 config.toml。`; @@ -4732,17 +5011,39 @@ function tomlString(value: string): string { } function syncLegacyRelayFields(settings: BackendSettings): BackendSettings { - const relayProfiles = settings.relayProfiles.map(deriveRelayProfileFromFiles); + const relayProfiles = settings.relayProfiles.map((profile) => + isAggregateRelayProfile(profile) ? normalizeAggregateRelayProfile(profile, { ...settings, relayProfiles: settings.relayProfiles }) : deriveRelayProfileFromFiles(profile), + ); const active = activeRelayProfile({ ...settings, relayProfiles }); + const aggregateRelayProfiles = normalizeAggregateProfilesFromRelayProfiles(relayProfiles); + const activeAggregateRelayId = isAggregateRelayProfile(active) ? active.id : ""; return { ...settings, relayProfiles, activeRelayId: active.id, - relayBaseUrl: active.baseUrl, + relayBaseUrl: isAggregateRelayProfile(active) ? PROTOCOL_PROXY_BASE_URL : active.baseUrl, relayApiKey: active.apiKey, + aggregateRelayProfiles, + activeAggregateRelayId, }; } +function normalizeAggregateProfilesFromRelayProfiles(profiles: RelayProfile[]): AggregateRelayProfile[] { + const candidates = profiles.filter((profile) => !isAggregateRelayProfile(profile)); + return profiles.filter(isAggregateRelayProfile).map((profile) => { + const aggregate = normalizeAggregateConfig(profile.aggregate, candidates); + return { + id: profile.id, + name: profile.name || "聚合供应商", + strategy: aggregate.strategy, + members: aggregate.members.map((member) => ({ + relayId: member.profileId, + weight: clampAggregateWeight(member.weight), + })), + }; + }); +} + function mergeLiveLinkedRelayProfiles(settings: BackendSettings, liveSettings: BackendSettings): BackendSettings { const liveLinkedById = new Map( liveSettings.relayProfiles @@ -4765,6 +5066,14 @@ function mergeLiveLinkedRelayProfiles(settings: BackendSettings, liveSettings: B } function updateRelayProfile(settings: BackendSettings, id: string, patch: Partial): BackendSettings { + if (patch.relayMode === "aggregate" || patch.aggregate) { + return syncLegacyRelayFields({ + ...settings, + relayProfiles: settings.relayProfiles.map((profile) => + profile.id === id ? normalizeAggregateRelayProfile({ ...profile, ...patch }, settings) : profile, + ), + }); + } return syncLegacyRelayFields({ ...settings, relayProfiles: settings.relayProfiles.map((profile) => { @@ -4802,10 +5111,47 @@ function createRelayProfile(settings: BackendSettings): RelayProfile { return withGeneratedRelayFiles(next); } -function addRelayProfile(settings: BackendSettings, profile: RelayProfile): BackendSettings { - const nextWithFiles = deriveRelayProfileFromFiles( - profile.configContents.trim() || profile.authContents.trim() ? profile : withGeneratedRelayFiles(profile), +function createAggregateRelayProfile(settings: BackendSettings): RelayProfile { + const id = `aggregate-${Date.now().toString(36)}`; + const contextSelection = contextSelectionForAllEntries(settings); + const candidates = aggregateMemberCandidates(settings, id); + return normalizeAggregateRelayProfile( + { + id, + linkedCcsProviderId: "", + name: `聚合供应商 ${settings.relayProfiles.filter(isAggregateRelayProfile).length + 1}`, + model: "", + baseUrl: "", + upstreamBaseUrl: "", + apiKey: "", + protocol: "responses", + relayMode: "aggregate", + officialMixApiKey: false, + testModel: "", + configContents: "", + authContents: "", + useCommonConfig: true, + contextSelection, + contextSelectionInitialized: true, + contextWindow: "", + autoCompactLimit: "", + modelList: "", + userAgent: "", + aggregate: { + strategy: "failover", + members: candidates.slice(0, 1).map((profile) => ({ profileId: profile.id, weight: 1 })), + }, + }, + settings, ); +} + +function addRelayProfile(settings: BackendSettings, profile: RelayProfile): BackendSettings { + const nextWithFiles = isAggregateRelayProfile(profile) + ? normalizeAggregateRelayProfile(profile, settings) + : deriveRelayProfileFromFiles( + profile.configContents.trim() || profile.authContents.trim() ? profile : withGeneratedRelayFiles(profile), + ); const activeId = settings.relayProfiles.some((item) => item.id === settings.activeRelayId) ? settings.activeRelayId : activeRelayProfile(settings).id; @@ -4826,8 +5172,9 @@ function duplicateRelayProfile(settings: BackendSettings, id: string): BackendSe linkedCcsProviderId: "", name: `${source.name || "未命名供应商"} 副本`, }; + const normalizedNext = isAggregateRelayProfile(next) ? normalizeAggregateRelayProfile(next, settings) : next; const relayProfiles = [...settings.relayProfiles]; - relayProfiles.splice(sourceIndex >= 0 ? sourceIndex + 1 : relayProfiles.length, 0, next); + relayProfiles.splice(sourceIndex >= 0 ? sourceIndex + 1 : relayProfiles.length, 0, normalizedNext); return syncLegacyRelayFields({ ...settings, relayProfiles, @@ -4850,13 +5197,123 @@ function reorderRelayProfiles(settings: BackendSettings, sourceId: string, targe function removeRelayProfile(settings: BackendSettings, id: string): BackendSettings { const profiles = settings.relayProfiles.filter((profile) => profile.id !== id); + const scrubbedProfiles = profiles.map((profile) => + isAggregateRelayProfile(profile) + ? normalizeAggregateRelayProfile( + { + ...profile, + aggregate: { + ...normalizeAggregateConfig(profile.aggregate, []), + members: normalizeAggregateConfig(profile.aggregate, []).members.filter((member) => member.profileId !== id), + }, + }, + { ...settings, relayProfiles: profiles }, + ) + : profile, + ); return syncLegacyRelayFields({ ...settings, - relayProfiles: profiles.length ? profiles : defaultSettings.relayProfiles, - activeRelayId: settings.activeRelayId === id ? profiles[0]?.id || "default" : settings.activeRelayId, + relayProfiles: scrubbedProfiles.length ? scrubbedProfiles : defaultSettings.relayProfiles, + activeRelayId: settings.activeRelayId === id ? scrubbedProfiles[0]?.id || "default" : settings.activeRelayId, }); } +const aggregateStrategyOptions: Array<{ value: RelayAggregateStrategy; label: string; description: string }> = [ + { + value: "failover", + label: "失败切换", + description: "按成员顺序请求,失败后切到下一个供应商。", + }, + { + value: "conversationRoundRobin", + label: "按对话轮转", + description: "同一对话保持一个成员,不同对话依次分配。", + }, + { + value: "requestRoundRobin", + label: "按请求轮转", + description: "每次请求按成员顺序切换,适合均匀摊请求量。", + }, + { + value: "weightedRoundRobin", + label: "权重轮转", + description: "按成员权重分配请求,权重越高承担越多。", + }, +]; + +function isAggregateRelayProfile(profile: Pick): boolean { + return profile.relayMode === "aggregate" || !!profile.aggregate; +} + +function normalizeAggregateRelayProfile(profile: RelayProfile, settings: BackendSettings | null): RelayProfile { + const candidates = settings ? aggregateMemberCandidates(settings, profile.id) : []; + const aggregate = normalizeAggregateConfig(profile.aggregate, candidates); + return { + ...profile, + linkedCcsProviderId: "", + baseUrl: "", + upstreamBaseUrl: "", + apiKey: "", + protocol: "responses", + relayMode: "aggregate", + officialMixApiKey: false, + configContents: "", + authContents: "", + aggregate, + }; +} + +function normalizeAggregateConfig( + aggregate: RelayAggregateConfig | null | undefined, + candidates: RelayProfile[], +): RelayAggregateConfig { + const candidateIds = new Set(candidates.map((profile) => profile.id)); + const seen = new Set(); + const strategy: RelayAggregateStrategy = + aggregate?.strategy && aggregateStrategyOptions.some((option) => option.value === aggregate.strategy) + ? aggregate.strategy + : "failover"; + const members = (aggregate?.members ?? []) + .filter((member) => member.profileId && !seen.has(member.profileId)) + .filter((member) => !candidateIds.size || candidateIds.has(member.profileId)) + .map((member) => { + seen.add(member.profileId); + return { profileId: member.profileId, weight: clampAggregateWeight(member.weight) }; + }); + return { strategy, members }; +} + +function aggregateMemberCandidates(settings: BackendSettings, aggregateId: string): RelayProfile[] { + return settings.relayProfiles.filter( + (profile) => profile.id !== aggregateId && !isAggregateRelayProfile(profile) && isApiRelayProfile(profile), + ); +} + +function isApiRelayProfile(profile: RelayProfile): boolean { + return Boolean(profile.baseUrl.trim() && profile.apiKey.trim()); +} + +function clampAggregateWeight(value: number): number { + if (!Number.isFinite(value)) return 1; + return Math.max(1, Math.min(999, Math.round(value))); +} + +function aggregateStrategyLabel(strategy: RelayAggregateStrategy): string { + return aggregateStrategyOptions.find((option) => option.value === strategy)?.label ?? "失败切换"; +} + +function aggregateStrategyHelp(strategy: RelayAggregateStrategy): string { + if (strategy === "failover") return "失败切换会保留成员顺序,优先使用第一个可用供应商。"; + if (strategy === "conversationRoundRobin") return "按对话轮转会让同一对话尽量保持固定成员,降低上下文漂移。"; + if (strategy === "requestRoundRobin") return "按请求轮转会逐请求切换成员,适合供应商能力接近的场景。"; + return "权重轮转会读取每个成员的权重值,权重越高的成员获得更多请求。"; +} + +function aggregateRelayProfileValidation(profile: RelayProfile): string | null { + const aggregate = normalizeAggregateConfig(profile.aggregate, []); + return aggregate.members.length >= 1 ? null : "聚合供应商至少需要勾选 1 个已填写 Base URL / Key 的 API 供应商。"; +} + function numberOrDefault(value: string, fallback: number) { const parsed = Number.parseInt(value, 10); return Number.isFinite(parsed) ? parsed : fallback; diff --git a/apps/codex-plus-manager/src/styles.css b/apps/codex-plus-manager/src/styles.css index bcc2444c..1635e96b 100644 --- a/apps/codex-plus-manager/src/styles.css +++ b/apps/codex-plus-manager/src/styles.css @@ -499,6 +499,7 @@ body { .relay-add-row { display: flex; justify-content: flex-end; + gap: 8px; margin-bottom: 10px; } @@ -679,7 +680,7 @@ body { position: sticky; top: 0; z-index: 20; - margin: 0; + margin: -16px -20px 0; padding: 10px 20px; background: hsl(var(--background)); border-bottom: 1px solid hsl(var(--border)); @@ -713,6 +714,11 @@ body { margin: 0 20px 24px; } +.aggregate-editor { + display: grid; + gap: 12px; +} + .relay-editor-head, .relay-file-head { display: flex; @@ -797,6 +803,10 @@ body { animation-delay: 95ms; } +.aggregate-fields { + margin-top: 0; +} + .relay-field-name { grid-column: 1; } @@ -1254,6 +1264,86 @@ body { line-height: 1.45; } +.aggregate-strategy-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; +} + +.aggregate-strategy-option { + min-height: 118px; +} + +.aggregate-members { + display: grid; + gap: 10px; + border: 1px solid hsl(var(--border)); + border-radius: 8px; + background: hsl(var(--secondary) / 0.22); + padding: 12px; +} + +.aggregate-members-head { + display: flex; + gap: 12px; + align-items: center; + justify-content: space-between; +} + +.aggregate-members-head > div, +.aggregate-member-summary { + display: grid; + gap: 3px; + min-width: 0; +} + +.aggregate-members-head span, +.aggregate-member-summary small, +.aggregate-weight-box > span { + color: hsl(var(--muted-foreground)); + font-size: 12px; + line-height: 1.35; +} + +.aggregate-member-list { + display: grid; + gap: 8px; +} + +.aggregate-member-row { + display: grid; + grid-template-columns: 18px minmax(0, 1fr) 118px; + gap: 10px; + align-items: center; + border: 1px solid hsl(var(--border)); + border-radius: 8px; + background: hsl(var(--background)); + padding: 10px; +} + +.aggregate-member-row.selected { + border-color: hsl(var(--primary) / 0.68); + background: hsl(var(--primary) / 0.1); +} + +.aggregate-member-row input[type="checkbox"] { + width: 16px; + height: 16px; +} + +.aggregate-weight-box { + display: grid; + gap: 4px; +} + +.aggregate-weight-box input { + min-height: 34px; +} + +.aggregate-preview { + margin-top: 0; +} + .metric-list div, .table-row { display: grid; diff --git a/crates/codex-plus-core/src/cli_wrapper.rs b/crates/codex-plus-core/src/cli_wrapper.rs index 19b12330..5fa89c1d 100644 --- a/crates/codex-plus-core/src/cli_wrapper.rs +++ b/crates/codex-plus-core/src/cli_wrapper.rs @@ -151,9 +151,9 @@ pub fn wrapper_dir() -> PathBuf { } pub fn wrapper_dir_from_roaming(roaming: &Path) -> PathBuf { - let roaming_text = roaming.as_os_str().to_string_lossy(); - if roaming_text.contains('\\') && !roaming_text.contains('/') { - return PathBuf::from(format!("{roaming_text}\\Codex++")); + let raw = roaming.as_os_str().to_string_lossy(); + if raw.contains('\\') && (!raw.contains('/') || cfg!(not(windows))) { + return PathBuf::from(format!("{}\\Codex++", raw.trim_end_matches(['\\', '/']))); } roaming.join("Codex++") } diff --git a/crates/codex-plus-core/src/launcher.rs b/crates/codex-plus-core/src/launcher.rs index 3d652a63..e711fc97 100644 --- a/crates/codex-plus-core/src/launcher.rs +++ b/crates/codex-plus-core/src/launcher.rs @@ -267,7 +267,7 @@ where } fn relay_protocol_proxy_enabled(settings: &BackendSettings) -> bool { - settings.active_relay_profile().protocol == crate::settings::RelayProtocol::ChatCompletions + settings.active_relay_uses_protocol_proxy() } pub trait IntoLaunchHooks { @@ -845,6 +845,26 @@ async fn handle_protocol_proxy_connection( if upstream.is_stream { write_http_stream_headers(stream, "200 OK", "text/event-stream; charset=utf-8").await?; + if upstream.wire_api == crate::protocol_proxy::UpstreamWireApi::Responses { + let mut bytes_stream = upstream.response.bytes_stream(); + while let Some(chunk) = bytes_stream.next().await { + if let Ok(bytes) = chunk { + stream.write_all(&bytes).await?; + } else { + break; + } + } + log_helper_response( + "helper.protocol_proxy_stream_ok", + method, + path, + "200 OK", + remote_addr_text, + ); + stream.shutdown().await?; + return Ok(()); + } + let mut converter = request_json .as_ref() .map(crate::protocol_proxy::ChatSseToResponsesConverter::with_request) @@ -892,6 +912,29 @@ async fn handle_protocol_proxy_connection( } let upstream_body = upstream.response.bytes().await?; + if upstream.wire_api == crate::protocol_proxy::UpstreamWireApi::Responses { + write_http_response( + stream, + "200 OK", + if upstream.content_type.is_empty() { + "application/json; charset=utf-8" + } else { + &upstream.content_type + }, + &upstream_body, + ) + .await?; + log_helper_response( + "helper.protocol_proxy_ok", + method, + path, + "200 OK", + remote_addr_text, + ); + stream.shutdown().await?; + return Ok(()); + } + let chat_json: serde_json::Value = serde_json::from_slice(&upstream_body)?; let response_json = if let Some(request_json) = request_json.as_ref() { crate::protocol_proxy::chat_completion_to_response_with_request(chat_json, request_json)? diff --git a/crates/codex-plus-core/src/lib.rs b/crates/codex-plus-core/src/lib.rs index 4087af5b..43a4cc5f 100644 --- a/crates/codex-plus-core/src/lib.rs +++ b/crates/codex-plus-core/src/lib.rs @@ -16,6 +16,7 @@ pub mod ports; pub mod protocol_proxy; pub mod proxy; pub mod relay_config; +pub mod relay_rotation; pub mod routes; pub mod script_market; pub mod settings; diff --git a/crates/codex-plus-core/src/model_catalog.rs b/crates/codex-plus-core/src/model_catalog.rs index 49041945..84e79637 100644 --- a/crates/codex-plus-core/src/model_catalog.rs +++ b/crates/codex-plus-core/src/model_catalog.rs @@ -66,11 +66,7 @@ pub async fn read_codex_model_catalog() -> Value { fn relay_profile_model_catalog_value(home: &Path, profile: &RelayProfile) -> Value { let models = relay_profile_model_ids(profile); let model = profile.model.trim().to_string(); - let default_model = if models.iter().any(|item| item == &model) { - model.clone() - } else { - models.first().cloned().unwrap_or_default() - }; + let default_model = models.first().cloned().unwrap_or_default(); let provider_name = if profile.name.trim().is_empty() { profile.id.trim() } else { @@ -102,8 +98,10 @@ fn relay_profile_model_catalog_value(home: &Path, profile: &RelayProfile) -> Val fn relay_profile_model_ids(profile: &RelayProfile) -> Vec { unique_strings( - std::iter::once(profile.model.as_str()) - .chain(profile.model_list.split(['\r', '\n', ','])) + profile + .model_list + .split(['\r', '\n', ',']) + .chain(std::iter::once(profile.model.as_str())) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToString::to_string) diff --git a/crates/codex-plus-core/src/protocol_proxy.rs b/crates/codex-plus-core/src/protocol_proxy.rs index 23510d01..8fe1c04f 100644 --- a/crates/codex-plus-core/src/protocol_proxy.rs +++ b/crates/codex-plus-core/src/protocol_proxy.rs @@ -4,12 +4,18 @@ use std::collections::BTreeMap; use std::collections::BTreeSet; +use std::time::Duration; +use anyhow::Context; use serde_json::{Value, json}; +use crate::relay_rotation::{RotationContext, RotationEvent}; use crate::settings::{RelayProtocol, SettingsStore}; pub const DEFAULT_PROTOCOL_PROXY_PORT: u16 = 57321; +const UPSTREAM_CONNECT_TIMEOUT: Duration = Duration::from_secs(5); +const UPSTREAM_HEADER_TIMEOUT: Duration = Duration::from_secs(30); +const UPSTREAM_STREAM_HEADER_TIMEOUT: Duration = Duration::from_secs(120); const THINK_OPEN_TAG: &str = ""; const THINK_CLOSE_TAG: &str = ""; const EXTRA_CHAT_PASSTHROUGH_FIELDS: &[&str] = &[ @@ -280,9 +286,16 @@ pub struct UpstreamProxyResponse { pub status_code: u16, pub content_type: String, pub is_stream: bool, + pub wire_api: UpstreamWireApi, pub response: reqwest::Response, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] +pub enum UpstreamWireApi { + Responses, + ChatCompletions, +} + impl UpstreamProxyResponse { pub fn status(&self) -> String { http_status_line(self.status_code) @@ -293,6 +306,46 @@ impl UpstreamProxyResponse { } } +pub fn upstream_header_timeout() -> Duration { + UPSTREAM_HEADER_TIMEOUT +} + +pub fn upstream_stream_header_timeout() -> Duration { + UPSTREAM_STREAM_HEADER_TIMEOUT +} + +pub fn upstream_http_client() -> anyhow::Result { + reqwest::Client::builder() + .connect_timeout(UPSTREAM_CONNECT_TIMEOUT) + .user_agent("CodexPlusPlus/ProtocolProxy") + .build() + .context("failed to build upstream HTTP client") +} + +pub async fn send_upstream_request( + request: reqwest::RequestBuilder, +) -> anyhow::Result { + send_upstream_request_with_header_timeout(request, UPSTREAM_HEADER_TIMEOUT).await +} + +pub async fn send_upstream_request_for_responses( + request: reqwest::RequestBuilder, + is_stream: bool, +) -> anyhow::Result { + let timeout = response_header_timeout(is_stream); + send_upstream_request_with_header_timeout(request, timeout).await +} + +pub async fn send_upstream_request_with_header_timeout( + request: reqwest::RequestBuilder, + timeout: Duration, +) -> anyhow::Result { + tokio::time::timeout(timeout, request.send()) + .await + .with_context(|| format!("上游请求超过 {} 秒未返回响应头", timeout.as_secs()))? + .context("上游请求失败") +} + pub struct ChatSseToResponsesConverter { buffer: String, utf8_remainder: Vec, @@ -425,66 +478,165 @@ pub fn is_models_proxy_path(path: &str) -> bool { pub async fn open_responses_proxy_request(body: &str) -> anyhow::Result { let settings = SettingsStore::default().load().unwrap_or_default(); - let relay = settings.active_relay_profile(); - if relay.protocol != RelayProtocol::ChatCompletions { - anyhow::bail!("当前中转未启用 Chat Completions 协议代理"); - } - if relay.base_url.trim().is_empty() { - anyhow::bail!("Chat Completions 上游 Base URL 不能为空"); - } - if relay.api_key.trim().is_empty() { - anyhow::bail!("Chat Completions 上游 Key 不能为空"); - } + open_responses_proxy_request_with_settings(body, settings).await +} +pub async fn open_responses_proxy_request_with_settings( + body: &str, + settings: crate::settings::BackendSettings, +) -> anyhow::Result { let request_json: Value = serde_json::from_str(body)?; let is_stream = request_json .get("stream") .and_then(Value::as_bool) .unwrap_or(false); - let chat_request = responses_to_chat_completions(request_json.clone())?; - let client = crate::http_client::proxied_client(&relay.user_agent)?; - let upstream = client - .post(chat_completions_url(&relay.base_url)) - .bearer_auth(relay.api_key.trim()) - .header(reqwest::header::CONTENT_TYPE, "application/json") - .json(&chat_request) - .send() - .await?; - let status_code = upstream.status().as_u16(); - let content_type = upstream - .headers() - .get(reqwest::header::CONTENT_TYPE) - .and_then(|value| value.to_str().ok()) - .unwrap_or("") - .to_string(); - - Ok(UpstreamProxyResponse { - status_code, - is_stream: is_stream || content_type.contains("text/event-stream"), - content_type, - response: upstream, - }) + let context = RotationContext { + conversation_id: conversation_id_from_responses_request(&request_json), + }; + let relay = crate::relay_rotation::select_relay_for_request(&settings, context)?; + let mut relays = vec![relay.clone()]; + relays.extend(crate::relay_rotation::fallback_relays_after( + &settings, &relay.id, + )?); + let relay_count = relays.len(); + for (attempt, relay) in relays.into_iter().enumerate() { + validate_upstream(&relay)?; + let (endpoint, upstream_body, wire_api) = + upstream_request_parts(&relay, request_json.clone())?; + let has_more_candidates = attempt + 1 < relay_count; + let header_timeout = response_header_timeout(is_stream); + let _ = crate::diagnostic_log::append_diagnostic_log( + "protocol_proxy.upstream_request", + json!({ + "relayId": relay.id, + "relayName": relay.name, + "endpoint": endpoint, + "wireApi": wire_api, + "stream": is_stream, + "attempt": attempt + 1, + "candidateCount": relay_count, + "headerTimeoutSeconds": header_timeout.as_secs() + }), + ); + let upstream = match send_upstream_request_for_responses( + upstream_request_builder( + crate::http_client::proxied_client(&relay.user_agent)?, + &endpoint, + relay.api_key.trim(), + is_stream, + &upstream_body, + ), + is_stream, + ) + .await + { + Ok(upstream) => upstream, + Err(error) => { + let _ = crate::diagnostic_log::append_diagnostic_log( + "protocol_proxy.upstream_request_failed", + json!({ + "relayId": relay.id, + "relayName": relay.name, + "endpoint": endpoint, + "wireApi": wire_api, + "stream": is_stream, + "attempt": attempt + 1, + "candidateCount": relay_count, + "headerTimeoutSeconds": header_timeout.as_secs(), + "willFailover": has_more_candidates, + "error": error.to_string() + }), + ); + crate::relay_rotation::record_relay_request_failure(&settings); + if has_more_candidates { + continue; + } + return Err(error).with_context(|| { + format!( + "供应商「{}」请求上游失败,endpoint: {}", + relay.name, endpoint + ) + }); + } + }; + let status_code = upstream.status().as_u16(); + let _ = crate::diagnostic_log::append_diagnostic_log( + "protocol_proxy.upstream_response", + json!({ + "relayId": relay.id, + "relayName": relay.name, + "endpoint": endpoint, + "wireApi": wire_api, + "stream": is_stream, + "statusCode": status_code, + "attempt": attempt + 1, + "candidateCount": relay_count, + "headerTimeoutSeconds": header_timeout.as_secs(), + "willFailover": has_more_candidates && !(200..300).contains(&status_code) + }), + ); + crate::relay_rotation::record_relay_request_event( + &settings, + if (200..300).contains(&status_code) { + RotationEvent::Success + } else { + RotationEvent::Failure + }, + ); + let content_type = upstream + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .unwrap_or("") + .to_string(); + if (200..300).contains(&status_code) || !has_more_candidates { + return Ok(UpstreamProxyResponse { + status_code, + is_stream: is_stream || content_type.contains("text/event-stream"), + content_type, + wire_api, + response: upstream, + }); + } + let _ = crate::diagnostic_log::append_diagnostic_log( + "protocol_proxy.upstream_failover", + json!({ + "relayId": relay.id, + "relayName": relay.name, + "endpoint": endpoint, + "wireApi": wire_api, + "stream": is_stream, + "statusCode": status_code, + "attempt": attempt + 1, + "candidateCount": relay_count, + "headerTimeoutSeconds": header_timeout.as_secs() + }), + ); + } + anyhow::bail!("未找到可用的聚合供应商成员") } pub async fn open_models_proxy_request() -> anyhow::Result { let settings = SettingsStore::default().load().unwrap_or_default(); - let relay = settings.active_relay_profile(); - if relay.protocol != RelayProtocol::ChatCompletions { - anyhow::bail!("当前中转未启用 Chat Completions 协议代理"); - } - if relay.base_url.trim().is_empty() { - anyhow::bail!("Chat Completions 上游 Base URL 不能为空"); - } - if relay.api_key.trim().is_empty() { - anyhow::bail!("Chat Completions 上游 Key 不能为空"); - } + let relay = crate::relay_rotation::select_relay_for_probe(&settings)?; + validate_upstream(&relay)?; - let client = crate::http_client::proxied_client(&relay.user_agent)?; - let upstream = client - .get(models_url(&relay.base_url)) - .bearer_auth(relay.api_key.trim()) - .send() - .await?; + let endpoint = models_url(&relay.base_url); + let _ = crate::diagnostic_log::append_diagnostic_log( + "protocol_proxy.models_request", + json!({ + "relayId": relay.id, + "relayName": relay.name, + "endpoint": endpoint, + "wireApi": UpstreamWireApi::Responses + }), + ); + let upstream = send_upstream_request( + crate::http_client::proxied_client(&relay.user_agent)? + .get(endpoint) + .bearer_auth(relay.api_key.trim()), + ) + .await?; let status_code = upstream.status().as_u16(); let content_type = upstream .headers() @@ -497,6 +649,7 @@ pub async fn open_models_proxy_request() -> anyhow::Result Duration { + if is_stream { + UPSTREAM_STREAM_HEADER_TIMEOUT + } else { + UPSTREAM_HEADER_TIMEOUT + } +} + +fn upstream_request_parts( + relay: &crate::settings::RelayProfile, + request_json: Value, +) -> anyhow::Result<(String, Value, UpstreamWireApi)> { + match relay.protocol { + RelayProtocol::Responses => Ok(( + responses_url(&relay.base_url), + request_json, + UpstreamWireApi::Responses, + )), + RelayProtocol::ChatCompletions => Ok(( + chat_completions_url(&relay.base_url), + responses_to_chat_completions(request_json)?, + UpstreamWireApi::ChatCompletions, + )), + } +} + +fn upstream_request_builder( + client: reqwest::Client, + endpoint: &str, + api_key: &str, + is_stream: bool, + upstream_body: &Value, +) -> reqwest::RequestBuilder { + let mut builder = client + .post(endpoint) + .bearer_auth(api_key) + .header(reqwest::header::CONTENT_TYPE, "application/json"); + if is_stream { + builder = builder + .header(reqwest::header::ACCEPT, "text/event-stream") + .header(reqwest::header::CACHE_CONTROL, "no-cache"); + } + builder.json(upstream_body) +} + +fn validate_upstream(relay: &crate::settings::RelayProfile) -> anyhow::Result<()> { + if relay.base_url.trim().is_empty() { + anyhow::bail!("上游 Base URL 不能为空"); + } + if relay.api_key.trim().is_empty() { + anyhow::bail!("上游 Key 不能为空"); + } + Ok(()) +} + +fn conversation_id_from_responses_request(body: &Value) -> Option { + for key in ["conversation", "conversation_id", "previous_response_id"] { + if let Some(value) = body.get(key).and_then(Value::as_str) { + let value = value.trim(); + if !value.is_empty() { + return Some(value.to_string()); + } + } + } + None +} + pub async fn handle_responses_proxy_request(body: &str) -> anyhow::Result { let request_json: Value = serde_json::from_str(body)?; let upstream = open_responses_proxy_request(body).await?; let status_code = upstream.status_code; let upstream_content_type = upstream.content_type.clone(); let is_stream = upstream.is_stream; + let wire_api = upstream.wire_api; let upstream_body = upstream.response.bytes().await?; if !(200..300).contains(&status_code) { @@ -562,6 +784,18 @@ pub async fn handle_responses_proxy_request(body: &str) -> anyhow::Result String { url } +pub fn responses_url(base_url: &str) -> String { + let skip_version_prefix = base_url.trim().ends_with('#'); + let base = base_url.trim().trim_end_matches('#').trim_end_matches('/'); + if base.to_ascii_lowercase().ends_with("/responses") { + return base.to_string(); + } + let origin_only = base + .split_once("://") + .map_or(!base.contains('/'), |(_, rest)| !rest.contains('/')); + let mut url = if skip_version_prefix || has_version_suffix(base) || !origin_only { + format!("{base}/responses") + } else { + format!("{base}/v1/responses") + }; + while url.contains("/v1/v1") { + url = url.replace("/v1/v1", "/v1"); + } + url +} + pub fn models_url(base_url: &str) -> String { let skip_version_prefix = base_url.trim().ends_with('#'); let mut base = base_url diff --git a/crates/codex-plus-core/src/relay_config.rs b/crates/codex-plus-core/src/relay_config.rs index 6f368eee..30f7f3af 100644 --- a/crates/codex-plus-core/src/relay_config.rs +++ b/crates/codex-plus-core/src/relay_config.rs @@ -1563,12 +1563,26 @@ fn complete_relay_profile_config(profile: &RelayProfile) -> anyhow::Result anyhow::Result<()> { if profile.relay_mode == crate::settings::RelayMode::Official && !profile.official_mix_api_key { - profile.config_contents.clear(); + let has_api_config = !profile.base_url.trim().is_empty() + || !profile.api_key.trim().is_empty() + || codex_auth_api_key(&profile.auth_contents).is_some() + || config_has_model_provider(profile.config_contents.as_str()); + if has_api_config { + profile.config_contents.clear(); + } + if !profile.model_list.trim().is_empty() { + profile.model_list = merge_model_into_model_list(&profile.model, &profile.model_list); + } profile.model.clear(); profile.base_url.clear(); profile.upstream_base_url.clear(); profile.api_key.clear(); - profile.auth_contents = remove_openai_api_key_from_auth_contents(&profile.auth_contents)?; + if auth_contents_looks_like_chatgpt_auth(&profile.auth_contents) { + profile.auth_contents = + remove_openai_api_key_from_auth_contents(&profile.auth_contents)?; + } else { + profile.auth_contents.clear(); + } return Ok(()); } let source_base_url = relay_profile_base_url(profile); @@ -1591,6 +1605,7 @@ pub fn normalize_relay_profile_for_storage(profile: &mut RelayProfile) -> anyhow profile.auth_contents = remove_openai_api_key_from_auth_contents(&profile.auth_contents)?; } profile.model = relay_profile_model(profile); + profile.model_list = merge_model_into_model_list(&profile.model, &profile.model_list); profile.upstream_base_url = source_base_url.clone(); profile.base_url = source_base_url; profile.api_key = relay_profile_api_key(profile); @@ -1613,6 +1628,48 @@ fn remove_openai_api_key_from_auth_contents(auth_contents: &str) -> anyhow::Resu Ok(format!("{}\n", serde_json::to_string_pretty(&value)?)) } +fn merge_model_into_model_list(model: &str, model_list: &str) -> String { + let model = model.trim(); + let mut models = Vec::new(); + if !model.is_empty() { + models.push(model.to_string()); + } + for item in model_list.split(['\r', '\n', ',']).map(str::trim) { + if !item.is_empty() && !models.iter().any(|existing| existing == item) { + models.push(item.to_string()); + } + } + models.join("\n") +} + +fn config_has_model_provider(config_contents: &str) -> bool { + parse_toml_document(config_contents) + .ok() + .and_then(|doc| { + doc.get("model_provider") + .and_then(Item::as_str) + .map(str::to_string) + }) + .map(|value| !value.trim().is_empty()) + .unwrap_or(false) +} + +fn auth_contents_looks_like_chatgpt_auth(contents: &str) -> bool { + let Ok(value) = serde_json::from_str::(contents) else { + return false; + }; + let is_chatgpt = value + .get("auth_mode") + .and_then(Value::as_str) + .map(|mode| mode.eq_ignore_ascii_case("chatgpt")) + .unwrap_or(false); + is_chatgpt + && value + .get("tokens") + .map(tokens_have_login_secret) + .unwrap_or(false) +} + fn provider_string_from_config(config_contents: &str, key: &str) -> Option { let doc = parse_toml_document(config_contents).ok()?; let active = active_provider_id(&doc); diff --git a/crates/codex-plus-core/src/relay_rotation.rs b/crates/codex-plus-core/src/relay_rotation.rs new file mode 100644 index 00000000..3dbc98d4 --- /dev/null +++ b/crates/codex-plus-core/src/relay_rotation.rs @@ -0,0 +1,335 @@ +/** + * @description 聚合供应商轮转选择器,负责按失败、对话、请求和权重策略选择已有中转配置。 + * @author Albert_Luo + * @email 480199976@qq.com + * @date 2026-05-27 00:00 + */ +use std::collections::HashMap; +use std::sync::{Mutex, OnceLock}; + +use crate::settings::{ + AggregateRelayProfile, AggregateRelayStrategy, BackendSettings, RelayProfile, +}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SelectionError { + NoActiveAggregate, + EmptyAggregateMembers { + aggregate_id: String, + }, + UnknownMemberRelay { + aggregate_id: String, + relay_id: String, + }, + InvalidMemberRelay { + aggregate_id: String, + relay_id: String, + }, +} + +impl std::fmt::Display for SelectionError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + SelectionError::NoActiveAggregate => write!(formatter, "未找到当前聚合供应商"), + SelectionError::EmptyAggregateMembers { aggregate_id } => { + write!(formatter, "聚合供应商「{aggregate_id}」没有成员") + } + SelectionError::UnknownMemberRelay { + aggregate_id, + relay_id, + } => write!( + formatter, + "聚合供应商「{aggregate_id}」引用了不存在的供应商「{relay_id}」" + ), + SelectionError::InvalidMemberRelay { + aggregate_id, + relay_id, + } => write!( + formatter, + "聚合供应商「{aggregate_id}」成员「{relay_id}」缺少 API Base URL 或 Key" + ), + } + } +} + +impl std::error::Error for SelectionError {} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct RotationContext { + pub conversation_id: Option, +} + +impl RotationContext { + pub fn for_conversation(conversation_id: impl Into) -> Self { + Self { + conversation_id: Some(conversation_id.into()), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RotationEvent { + Success, + Failure, +} + +#[derive(Debug, Clone)] +pub struct RelayRotationSelector { + aggregate: AggregateRelayProfile, + failover_index: usize, + request_index: usize, + weighted_index: usize, + conversation_assignments: HashMap, +} + +static GLOBAL_SELECTOR: OnceLock>> = OnceLock::new(); + +impl RelayRotationSelector { + pub fn from_settings(settings: &BackendSettings) -> Result { + let aggregate = active_aggregate(settings)?.clone(); + validate_aggregate_members(settings, &aggregate)?; + Ok(Self { + aggregate, + failover_index: 0, + request_index: 0, + weighted_index: 0, + conversation_assignments: HashMap::new(), + }) + } + + pub fn select( + &mut self, + settings: &BackendSettings, + context: RotationContext, + ) -> Result { + validate_aggregate_members(settings, &self.aggregate)?; + let relay_id = match self.aggregate.strategy { + AggregateRelayStrategy::Failover => self.member_id_at(self.failover_index), + AggregateRelayStrategy::ConversationRoundRobin => { + self.select_for_conversation(context.conversation_id) + } + AggregateRelayStrategy::RequestRoundRobin => self.select_next_request(), + AggregateRelayStrategy::WeightedRoundRobin => self.select_next_weighted(), + }; + relay_profile_by_id(settings, &relay_id).ok_or_else(|| SelectionError::UnknownMemberRelay { + aggregate_id: self.aggregate.id.clone(), + relay_id, + }) + } + + pub fn peek(&self, settings: &BackendSettings) -> Result { + validate_aggregate_members(settings, &self.aggregate)?; + let relay_id = match self.aggregate.strategy { + AggregateRelayStrategy::Failover => self.member_id_at(self.failover_index), + AggregateRelayStrategy::ConversationRoundRobin + | AggregateRelayStrategy::RequestRoundRobin => self.member_id_at(self.request_index), + AggregateRelayStrategy::WeightedRoundRobin => { + let schedule = self.weighted_schedule(); + schedule[self.weighted_index % schedule.len()].clone() + } + }; + relay_profile_by_id(settings, &relay_id).ok_or_else(|| SelectionError::UnknownMemberRelay { + aggregate_id: self.aggregate.id.clone(), + relay_id, + }) + } + + pub fn record_event(&mut self, event: RotationEvent) { + if event == RotationEvent::Failure + && self.aggregate.strategy == AggregateRelayStrategy::Failover + && !self.aggregate.members.is_empty() + { + self.failover_index = (self.failover_index + 1) % self.aggregate.members.len(); + } + } + + fn select_for_conversation(&mut self, conversation_id: Option) -> String { + let Some(conversation_id) = conversation_id else { + return self.select_next_request(); + }; + if let Some(relay_id) = self.conversation_assignments.get(&conversation_id) { + return relay_id.clone(); + } + + let relay_id = self.select_next_request(); + self.conversation_assignments + .insert(conversation_id, relay_id.clone()); + relay_id + } + + fn select_next_request(&mut self) -> String { + let relay_id = self.member_id_at(self.request_index); + self.request_index = (self.request_index + 1) % self.aggregate.members.len(); + relay_id + } + + fn select_next_weighted(&mut self) -> String { + let schedule = self.weighted_schedule(); + let relay_id = schedule[self.weighted_index % schedule.len()].clone(); + self.weighted_index = (self.weighted_index + 1) % schedule.len(); + relay_id + } + + fn weighted_schedule(&self) -> Vec { + self.aggregate + .members + .iter() + .flat_map(|member| { + std::iter::repeat_n(member.relay_id.clone(), member.weight.max(1) as usize) + }) + .collect() + } + + fn member_id_at(&self, index: usize) -> String { + self.aggregate.members[index % self.aggregate.members.len()] + .relay_id + .clone() + } +} + +pub fn select_relay_for_request( + settings: &BackendSettings, + context: RotationContext, +) -> Result { + let Some(active_aggregate) = settings.active_aggregate_relay_profile() else { + clear_global_selector(); + return Ok(settings.active_relay_profile()); + }; + + let lock = GLOBAL_SELECTOR.get_or_init(|| Mutex::new(None)); + let mut guard = lock.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); + let needs_new_selector = guard + .as_ref() + .map(|selector| selector.aggregate != active_aggregate) + .unwrap_or(true); + if needs_new_selector { + *guard = Some(RelayRotationSelector::from_settings(settings)?); + } + guard + .as_mut() + .expect("selector initialized") + .select(settings, context) +} + +pub fn select_relay_for_probe(settings: &BackendSettings) -> Result { + let Some(active_aggregate) = settings.active_aggregate_relay_profile() else { + clear_global_selector(); + return Ok(settings.active_relay_profile()); + }; + + let lock = GLOBAL_SELECTOR.get_or_init(|| Mutex::new(None)); + let mut guard = lock.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); + let needs_new_selector = guard + .as_ref() + .map(|selector| selector.aggregate != active_aggregate) + .unwrap_or(true); + if needs_new_selector { + *guard = Some(RelayRotationSelector::from_settings(settings)?); + } + guard.as_ref().expect("selector initialized").peek(settings) +} + +pub fn fallback_relays_after( + settings: &BackendSettings, + relay_id: &str, +) -> Result, SelectionError> { + let Some(active_aggregate) = settings.active_aggregate_relay_profile() else { + return Ok(Vec::new()); + }; + validate_aggregate_members(settings, &active_aggregate)?; + let start_index = active_aggregate + .members + .iter() + .position(|member| member.relay_id == relay_id) + .map(|index| index + 1) + .unwrap_or(0); + (0..active_aggregate.members.len().saturating_sub(1)) + .map(|offset| { + let index = (start_index + offset) % active_aggregate.members.len(); + &active_aggregate.members[index] + }) + .map(|member| { + relay_profile_by_id(settings, &member.relay_id).ok_or_else(|| { + SelectionError::UnknownMemberRelay { + aggregate_id: active_aggregate.id.clone(), + relay_id: member.relay_id.clone(), + } + }) + }) + .collect() +} + +pub fn record_relay_request_event(settings: &BackendSettings, event: RotationEvent) { + if settings.active_aggregate_relay_profile().is_none() { + clear_global_selector(); + return; + } + let lock = GLOBAL_SELECTOR.get_or_init(|| Mutex::new(None)); + let mut guard = lock.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); + if let Some(selector) = guard.as_mut() { + selector.record_event(event); + } +} + +pub fn record_relay_request_failure(settings: &BackendSettings) { + record_relay_request_event(settings, RotationEvent::Failure); +} + +fn active_aggregate(settings: &BackendSettings) -> Result<&AggregateRelayProfile, SelectionError> { + let active_id = settings + .active_aggregate_relay_profile() + .map(|aggregate| aggregate.id) + .ok_or(SelectionError::NoActiveAggregate)?; + + settings + .aggregate_relay_profiles + .iter() + .find(|aggregate| aggregate.id == active_id) + .ok_or(SelectionError::NoActiveAggregate) +} + +fn validate_aggregate_members( + settings: &BackendSettings, + aggregate: &AggregateRelayProfile, +) -> Result<(), SelectionError> { + if aggregate.members.is_empty() { + return Err(SelectionError::EmptyAggregateMembers { + aggregate_id: aggregate.id.clone(), + }); + } + + let relay_by_id = settings + .relay_profiles + .iter() + .map(|profile| (profile.id.as_str(), profile)) + .collect::>(); + for member in &aggregate.members { + let Some(relay) = relay_by_id.get(member.relay_id.as_str()) else { + return Err(SelectionError::UnknownMemberRelay { + aggregate_id: aggregate.id.clone(), + relay_id: member.relay_id.clone(), + }); + }; + if relay.base_url.trim().is_empty() || relay.api_key.trim().is_empty() { + return Err(SelectionError::InvalidMemberRelay { + aggregate_id: aggregate.id.clone(), + relay_id: member.relay_id.clone(), + }); + } + } + Ok(()) +} + +fn clear_global_selector() { + let lock = GLOBAL_SELECTOR.get_or_init(|| Mutex::new(None)); + let mut guard = lock.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); + *guard = None; +} + +fn relay_profile_by_id(settings: &BackendSettings, relay_id: &str) -> Option { + settings + .relay_profiles + .iter() + .find(|profile| profile.id == relay_id) + .cloned() +} diff --git a/crates/codex-plus-core/src/settings.rs b/crates/codex-plus-core/src/settings.rs index b33c1868..433f26f4 100644 --- a/crates/codex-plus-core/src/settings.rs +++ b/crates/codex-plus-core/src/settings.rs @@ -49,7 +49,11 @@ pub struct RelayProfile { pub base_url: String, #[serde(rename = "upstreamBaseUrl", default)] pub upstream_base_url: String, - #[serde(default, skip_serializing, deserialize_with = "deserialize_profile_api_key")] + #[serde( + default, + skip_serializing, + deserialize_with = "deserialize_profile_api_key" + )] pub api_key: String, #[serde(default)] pub protocol: RelayProtocol, @@ -85,6 +89,36 @@ pub struct RelayProfile { pub user_agent: String, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum AggregateRelayStrategy { + #[default] + Failover, + ConversationRoundRobin, + RequestRoundRobin, + WeightedRoundRobin, +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AggregateRelayMember { + #[serde(rename = "relayId")] + pub relay_id: String, + #[serde(default = "default_aggregate_member_weight")] + pub weight: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AggregateRelayProfile { + pub id: String, + pub name: String, + #[serde(default)] + pub strategy: AggregateRelayStrategy, + #[serde(default)] + pub members: Vec, +} + impl Default for RelayProfile { fn default() -> Self { Self { @@ -136,6 +170,7 @@ pub enum RelayMode { #[default] MixedApi, PureApi, + Aggregate, } #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] @@ -194,6 +229,10 @@ pub struct BackendSettings { pub relay_context_config_contents: String, #[serde(rename = "activeRelayId", default = "default_active_relay_id")] pub active_relay_id: String, + #[serde(rename = "aggregateRelayProfiles", default)] + pub aggregate_relay_profiles: Vec, + #[serde(rename = "activeAggregateRelayId", default)] + pub active_aggregate_relay_id: String, #[serde(rename = "relayTestModel", default = "default_relay_test_model")] pub relay_test_model: String, #[serde(rename = "cliWrapperEnabled", default)] @@ -240,6 +279,8 @@ impl Default for BackendSettings { relay_common_config_contents: String::new(), relay_context_config_contents: String::new(), active_relay_id: default_active_relay_id(), + aggregate_relay_profiles: Vec::new(), + active_aggregate_relay_id: String::new(), relay_test_model: default_relay_test_model(), cli_wrapper_enabled: false, cli_wrapper_base_url: String::new(), @@ -333,6 +374,36 @@ impl BackendSettings { user_agent: String::new(), } } + + pub fn active_aggregate_relay_profile(&self) -> Option { + let active_relay = self + .relay_profiles + .iter() + .find(|profile| profile.id == self.active_relay_id)?; + if active_relay.relay_mode != RelayMode::Aggregate { + return None; + } + + let active_aggregate_id = if self.active_aggregate_relay_id.trim().is_empty() { + active_relay.id.as_str() + } else { + self.active_aggregate_relay_id.trim() + }; + + if active_aggregate_id != active_relay.id { + return None; + } + + self.aggregate_relay_profiles + .iter() + .find(|profile| profile.id == active_aggregate_id) + .cloned() + } + + pub fn active_relay_uses_protocol_proxy(&self) -> bool { + self.active_aggregate_relay_profile().is_some() + || self.active_relay_profile().protocol == RelayProtocol::ChatCompletions + } } pub fn default_api_key_env() -> String { @@ -359,6 +430,10 @@ pub fn default_relay_profiles() -> Vec { vec![RelayProfile::default()] } +pub fn default_aggregate_member_weight() -> u32 { + 1 +} + pub fn empty_as_default_api_key_env<'de, D>(deserializer: D) -> Result where D: serde::Deserializer<'de>, @@ -558,6 +633,21 @@ fn merge_known_setting_fields(target: &mut Map, source: &Map= Duration::from_secs(30)); + assert!(upstream_header_timeout() <= Duration::from_secs(60)); + assert!(upstream_stream_header_timeout() >= Duration::from_secs(120)); +} + +#[tokio::test] +async fn upstream_request_returns_when_provider_accepts_but_never_sends_headers() { + let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)) + .await + .unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let Ok((_stream, _addr)) = listener.accept().await else { + return; + }; + tokio::time::sleep(Duration::from_secs(2)).await; + }); + + let started = Instant::now(); + let result = send_upstream_request_with_header_timeout( + upstream_http_client() + .unwrap() + .get(format!("http://{addr}/v1/models")), + Duration::from_millis(100), + ) + .await; + + assert!(result.is_err()); + assert!(started.elapsed() < Duration::from_secs(1)); + server.abort(); +} + +#[tokio::test] +async fn aggregate_proxy_fails_over_to_next_member_in_same_request() { + let first = tokio::net::TcpListener::bind(("127.0.0.1", 0)) + .await + .unwrap(); + let first_addr = first.local_addr().unwrap(); + let second = tokio::net::TcpListener::bind(("127.0.0.1", 0)) + .await + .unwrap(); + let second_addr = second.local_addr().unwrap(); + let first_server = tokio::spawn(respond_once( + first, + "HTTP/1.1 500 Internal Server Error\r\ncontent-length: 11\r\ncontent-type: application/json\r\n\r\n{\"error\":1}", + )); + let second_server = tokio::spawn(respond_once( + second, + "HTTP/1.1 200 OK\r\ncontent-length: 35\r\ncontent-type: application/json\r\n\r\n{\"id\":\"resp_1\",\"object\":\"response\"}", + )); + let settings = aggregate_proxy_settings( + "failover", + format!("http://{first_addr}/v1"), + format!("http://{second_addr}/v1"), + ); + + let result = open_responses_proxy_request_with_settings( + r#"{"model":"gpt-5-mini","input":"hi","stream":false}"#, + settings, + ) + .await + .unwrap(); + let body = result.response.bytes().await.unwrap(); + + assert_eq!(result.status_code, 200); + assert_eq!(body.as_ref(), br#"{"id":"resp_1","object":"response"}"#); + first_server.await.unwrap(); + second_server.await.unwrap(); +} + +#[tokio::test] +async fn aggregate_stream_request_sends_sse_accept_header() { + let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)) + .await + .unwrap(); + let addr = listener.local_addr().unwrap(); + let fallback = tokio::net::TcpListener::bind(("127.0.0.1", 0)) + .await + .unwrap(); + let fallback_addr = fallback.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut buffer = [0; 4096]; + let read = stream.read(&mut buffer).await.unwrap(); + let request = String::from_utf8_lossy(&buffer[..read]).to_string(); + stream + .write_all( + b"HTTP/1.1 200 OK\r\ncontent-length: 14\r\ncontent-type: text/event-stream\r\n\r\ndata: [DONE]\n\n", + ) + .await + .unwrap(); + request + }); + let fallback_server = tokio::spawn(respond_once( + fallback, + "HTTP/1.1 200 OK\r\ncontent-length: 14\r\ncontent-type: text/event-stream\r\n\r\ndata: [DONE]\n\n", + )); + let settings = aggregate_proxy_settings( + "stream", + format!("http://{addr}/v1"), + format!("http://{fallback_addr}/v1"), + ); + + let result = open_responses_proxy_request_with_settings( + r#"{"model":"gpt-5-mini","input":"hi","stream":true}"#, + settings, + ) + .await + .unwrap(); + let request = server.await.unwrap(); + + assert_eq!(result.status_code, 200); + assert!(result.is_stream); + assert!( + request + .to_ascii_lowercase() + .contains("accept: text/event-stream") + ); + fallback_server.abort(); +} + +async fn respond_once(listener: tokio::net::TcpListener, response: &'static str) { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut buffer = [0; 1024]; + let _ = stream.read(&mut buffer).await.unwrap(); + stream.write_all(response.as_bytes()).await.unwrap(); +} + +fn aggregate_proxy_settings( + id_suffix: &str, + first_base_url: String, + second_base_url: String, +) -> BackendSettings { + let first_id = format!("proxy-{id_suffix}-a"); + let second_id = format!("proxy-{id_suffix}-b"); + let aggregate_id = format!("proxy-{id_suffix}-agg"); + BackendSettings { + relay_profiles: vec![ + RelayProfile { + id: first_id.clone(), + name: "first".to_string(), + base_url: first_base_url, + api_key: "sk-first".to_string(), + ..RelayProfile::default() + }, + RelayProfile { + id: second_id.clone(), + name: "second".to_string(), + base_url: second_base_url, + api_key: "sk-second".to_string(), + ..RelayProfile::default() + }, + RelayProfile { + id: aggregate_id.clone(), + name: "aggregate".to_string(), + relay_mode: RelayMode::Aggregate, + ..RelayProfile::default() + }, + ], + active_relay_id: aggregate_id.clone(), + active_aggregate_relay_id: aggregate_id.clone(), + aggregate_relay_profiles: vec![AggregateRelayProfile { + id: aggregate_id, + name: "aggregate".to_string(), + strategy: AggregateRelayStrategy::RequestRoundRobin, + members: vec![ + AggregateRelayMember { + relay_id: first_id, + weight: 1, + }, + AggregateRelayMember { + relay_id: second_id, + weight: 1, + }, + ], + }], + ..BackendSettings::default() + } +} diff --git a/crates/codex-plus-core/tests/relay_config.rs b/crates/codex-plus-core/tests/relay_config.rs index 487e4860..048373aa 100644 --- a/crates/codex-plus-core/tests/relay_config.rs +++ b/crates/codex-plus-core/tests/relay_config.rs @@ -1929,7 +1929,8 @@ requires_openai_auth = true experimental_bearer_token = "22222222222222222222222222222222222" "# .to_string(), - auth_contents: r#"{"auth_mode":"chatgpt","tokens":{"access_token":"official"}}"#.to_string(), + auth_contents: r#"{"auth_mode":"chatgpt","tokens":{"access_token":"official"}}"# + .to_string(), ..RelayProfile::default() }; let mut common = String::new(); @@ -1940,9 +1941,11 @@ experimental_bearer_token = "22222222222222222222222222222222222" assert_eq!(profile.relay_mode, RelayMode::Official); assert!(profile.official_mix_api_key); assert_eq!(profile.api_key, "333333333333333333333"); - assert!(profile - .config_contents - .contains(r#"experimental_bearer_token = "333333333333333333333""#)); + assert!( + profile + .config_contents + .contains(r#"experimental_bearer_token = "333333333333333333333""#) + ); assert!(!profile.auth_contents.contains("OPENAI_API_KEY")); } diff --git a/crates/codex-plus-core/tests/relay_rotation.rs b/crates/codex-plus-core/tests/relay_rotation.rs new file mode 100644 index 00000000..1c3904db --- /dev/null +++ b/crates/codex-plus-core/tests/relay_rotation.rs @@ -0,0 +1,409 @@ +use codex_plus_core::relay_rotation::{ + RelayRotationSelector, RotationContext, RotationEvent, SelectionError, fallback_relays_after, + record_relay_request_failure, select_relay_for_probe, select_relay_for_request, +}; +use codex_plus_core::settings::{ + AggregateRelayMember, AggregateRelayProfile, AggregateRelayStrategy, BackendSettings, + RelayMode, RelayProfile, +}; +use std::sync::{Mutex, MutexGuard, OnceLock}; + +fn global_selector_test_lock() -> MutexGuard<'static, ()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +fn profile(id: &str) -> RelayProfile { + RelayProfile { + id: id.to_string(), + name: id.to_string(), + base_url: format!("https://{id}.example/v1"), + api_key: format!("sk-{id}"), + ..RelayProfile::default() + } +} + +fn aggregate(strategy: AggregateRelayStrategy) -> AggregateRelayProfile { + AggregateRelayProfile { + id: "agg".to_string(), + name: "聚合".to_string(), + strategy, + members: vec![ + AggregateRelayMember { + relay_id: "relay-a".to_string(), + weight: 1, + }, + AggregateRelayMember { + relay_id: "relay-b".to_string(), + weight: 2, + }, + AggregateRelayMember { + relay_id: "relay-c".to_string(), + weight: 1, + }, + ], + } +} + +fn aggregate_with_id(id: &str, strategy: AggregateRelayStrategy) -> AggregateRelayProfile { + AggregateRelayProfile { + id: id.to_string(), + name: "聚合".to_string(), + strategy, + members: vec![ + AggregateRelayMember { + relay_id: "relay-a".to_string(), + weight: 1, + }, + AggregateRelayMember { + relay_id: "relay-b".to_string(), + weight: 2, + }, + ], + } +} + +fn settings(strategy: AggregateRelayStrategy) -> BackendSettings { + BackendSettings { + relay_profiles: vec![ + profile("relay-a"), + profile("relay-b"), + profile("relay-c"), + RelayProfile { + id: "agg".to_string(), + name: "聚合".to_string(), + relay_mode: RelayMode::Aggregate, + ..RelayProfile::default() + }, + ], + aggregate_relay_profiles: vec![aggregate(strategy)], + active_relay_id: "agg".to_string(), + active_aggregate_relay_id: "agg".to_string(), + ..BackendSettings::default() + } +} + +#[test] +fn failover_keeps_current_provider_until_failure_then_moves_to_next_member() { + let settings = settings(AggregateRelayStrategy::Failover); + let mut selector = RelayRotationSelector::from_settings(&settings).unwrap(); + + let first = selector + .select(&settings, RotationContext::for_conversation("chat-1")) + .unwrap(); + selector.record_event(RotationEvent::Success); + let second = selector + .select(&settings, RotationContext::for_conversation("chat-1")) + .unwrap(); + selector.record_event(RotationEvent::Failure); + let third = selector + .select(&settings, RotationContext::for_conversation("chat-1")) + .unwrap(); + + assert_eq!(first.id, "relay-a"); + assert_eq!(second.id, "relay-a"); + assert_eq!(third.id, "relay-b"); +} + +#[test] +fn conversation_rotation_sticks_each_conversation_to_a_stable_member() { + let settings = settings(AggregateRelayStrategy::ConversationRoundRobin); + let mut selector = RelayRotationSelector::from_settings(&settings).unwrap(); + + let chat_a_first = selector + .select(&settings, RotationContext::for_conversation("chat-a")) + .unwrap(); + let chat_a_second = selector + .select(&settings, RotationContext::for_conversation("chat-a")) + .unwrap(); + let chat_b_first = selector + .select(&settings, RotationContext::for_conversation("chat-b")) + .unwrap(); + + assert_eq!(chat_a_first.id, "relay-a"); + assert_eq!(chat_a_second.id, "relay-a"); + assert_eq!(chat_b_first.id, "relay-b"); +} + +#[test] +fn request_rotation_advances_on_every_request() { + let settings = settings(AggregateRelayStrategy::RequestRoundRobin); + let mut selector = RelayRotationSelector::from_settings(&settings).unwrap(); + + let selected = (0..5) + .map(|_| { + selector + .select(&settings, RotationContext::default()) + .unwrap() + .id + }) + .collect::>(); + + assert_eq!( + selected, + vec!["relay-a", "relay-b", "relay-c", "relay-a", "relay-b"] + ); +} + +#[test] +fn weighted_rotation_repeats_members_by_configured_weight() { + let settings = settings(AggregateRelayStrategy::WeightedRoundRobin); + let mut selector = RelayRotationSelector::from_settings(&settings).unwrap(); + + let selected = (0..6) + .map(|_| { + selector + .select(&settings, RotationContext::default()) + .unwrap() + .id + }) + .collect::>(); + + assert_eq!( + selected, + vec![ + "relay-a", "relay-b", "relay-b", "relay-c", "relay-a", "relay-b" + ] + ); +} + +#[test] +fn aggregate_members_must_reference_existing_relay_profiles() { + let mut settings = settings(AggregateRelayStrategy::RequestRoundRobin); + settings.aggregate_relay_profiles[0] + .members + .push(AggregateRelayMember { + relay_id: "missing-relay".to_string(), + weight: 1, + }); + + let error = RelayRotationSelector::from_settings(&settings).unwrap_err(); + + assert_eq!( + error, + SelectionError::UnknownMemberRelay { + aggregate_id: "agg".to_string(), + relay_id: "missing-relay".to_string() + } + ); +} + +#[test] +fn aggregate_with_one_member_is_allowed_without_rotation() { + let mut settings = settings(AggregateRelayStrategy::RequestRoundRobin); + settings.aggregate_relay_profiles[0].members.truncate(1); + + let mut selector = RelayRotationSelector::from_settings(&settings).unwrap(); + let first = selector + .select(&settings, RotationContext::default()) + .unwrap(); + let second = selector + .select(&settings, RotationContext::default()) + .unwrap(); + + assert_eq!(first.id, "relay-a"); + assert_eq!(second.id, "relay-a"); +} + +#[test] +fn aggregate_members_must_be_api_capable_relay_profiles() { + let mut settings = settings(AggregateRelayStrategy::WeightedRoundRobin); + settings.relay_profiles.push(RelayProfile { + id: "official-login".to_string(), + name: "官方登录".to_string(), + base_url: String::new(), + api_key: String::new(), + ..RelayProfile::default() + }); + settings.aggregate_relay_profiles[0] + .members + .push(AggregateRelayMember { + relay_id: "official-login".to_string(), + weight: 1, + }); + + let error = RelayRotationSelector::from_settings(&settings).unwrap_err(); + + assert_eq!( + error, + SelectionError::InvalidMemberRelay { + aggregate_id: "agg".to_string(), + relay_id: "official-login".to_string() + } + ); +} + +#[test] +fn select_relay_for_request_uses_active_relay_id_as_aggregate_source_of_truth() { + let _guard = global_selector_test_lock(); + let mut settings = settings(AggregateRelayStrategy::WeightedRoundRobin); + settings.active_relay_id = "agg".to_string(); + settings.active_aggregate_relay_id.clear(); + + let selected = select_relay_for_request(&settings, RotationContext::default()).unwrap(); + + assert_eq!(selected.id, "relay-a"); +} + +#[test] +fn select_relay_for_request_ignores_stale_active_aggregate_id_for_regular_relay() { + let _guard = global_selector_test_lock(); + let mut settings = settings(AggregateRelayStrategy::WeightedRoundRobin); + settings.active_relay_id = "relay-b".to_string(); + settings.active_aggregate_relay_id = "agg".to_string(); + + let selected = select_relay_for_request(&settings, RotationContext::default()).unwrap(); + + assert_eq!(selected.id, "relay-b"); +} + +#[test] +fn select_relay_for_request_resets_rotation_after_switching_to_regular_relay() { + let _guard = global_selector_test_lock(); + let mut settings = settings(AggregateRelayStrategy::RequestRoundRobin); + settings.active_relay_id = "agg".to_string(); + + let first = select_relay_for_request(&settings, RotationContext::default()).unwrap(); + let mut regular_settings = settings.clone(); + regular_settings.active_relay_id = "relay-c".to_string(); + regular_settings.active_aggregate_relay_id.clear(); + let regular = select_relay_for_request(®ular_settings, RotationContext::default()).unwrap(); + let after_reselect = select_relay_for_request(&settings, RotationContext::default()).unwrap(); + + assert_eq!(first.id, "relay-a"); + assert_eq!(regular.id, "relay-c"); + assert_eq!(after_reselect.id, "relay-a"); +} + +#[test] +fn record_relay_request_failure_advances_global_failover_selector() { + let _guard = global_selector_test_lock(); + let aggregate_id = "agg-global-failure"; + let settings = BackendSettings { + relay_profiles: vec![ + profile("relay-a"), + profile("relay-b"), + RelayProfile { + id: aggregate_id.to_string(), + name: "聚合".to_string(), + relay_mode: RelayMode::Aggregate, + ..RelayProfile::default() + }, + ], + aggregate_relay_profiles: vec![aggregate_with_id( + aggregate_id, + AggregateRelayStrategy::Failover, + )], + active_relay_id: aggregate_id.to_string(), + active_aggregate_relay_id: aggregate_id.to_string(), + ..BackendSettings::default() + }; + + let first = select_relay_for_request(&settings, RotationContext::default()).unwrap(); + record_relay_request_failure(&settings); + let second = select_relay_for_request(&settings, RotationContext::default()).unwrap(); + + assert_eq!(first.id, "relay-a"); + assert_eq!(second.id, "relay-b"); +} + +#[test] +fn select_relay_for_probe_does_not_advance_request_rotation() { + let _guard = global_selector_test_lock(); + let aggregate_id = "agg-probe"; + let settings = BackendSettings { + relay_profiles: vec![ + profile("relay-a"), + profile("relay-b"), + RelayProfile { + id: aggregate_id.to_string(), + name: "聚合".to_string(), + relay_mode: RelayMode::Aggregate, + ..RelayProfile::default() + }, + ], + aggregate_relay_profiles: vec![aggregate_with_id( + aggregate_id, + AggregateRelayStrategy::RequestRoundRobin, + )], + active_relay_id: aggregate_id.to_string(), + active_aggregate_relay_id: aggregate_id.to_string(), + ..BackendSettings::default() + }; + + let first_probe = select_relay_for_probe(&settings).unwrap(); + let second_probe = select_relay_for_probe(&settings).unwrap(); + let first_request = select_relay_for_request(&settings, RotationContext::default()).unwrap(); + let second_request = select_relay_for_request(&settings, RotationContext::default()).unwrap(); + + assert_eq!(first_probe.id, "relay-a"); + assert_eq!(second_probe.id, "relay-a"); + assert_eq!(first_request.id, "relay-a"); + assert_eq!(second_request.id, "relay-b"); +} + +#[test] +fn fallback_relays_after_returns_remaining_aggregate_members_after_current_then_wraps() { + let settings = settings(AggregateRelayStrategy::RequestRoundRobin); + + let fallbacks = fallback_relays_after(&settings, "relay-b").unwrap(); + + assert_eq!( + fallbacks + .iter() + .map(|profile| profile.id.as_str()) + .collect::>(), + vec!["relay-c", "relay-a"] + ); +} + +#[test] +fn fallback_relays_after_regular_relay_returns_empty_candidates() { + let mut settings = settings(AggregateRelayStrategy::RequestRoundRobin); + settings.active_relay_id = "relay-a".to_string(); + + let fallbacks = fallback_relays_after(&settings, "relay-a").unwrap(); + + assert!(fallbacks.is_empty()); +} + +#[test] +fn select_relay_for_request_rebuilds_selector_when_active_aggregate_changes() { + let _guard = global_selector_test_lock(); + let aggregate_id = "agg-refresh"; + let mut settings = BackendSettings { + relay_profiles: vec![ + profile("relay-a"), + profile("relay-b"), + RelayProfile { + id: aggregate_id.to_string(), + name: "聚合".to_string(), + relay_mode: RelayMode::Aggregate, + ..RelayProfile::default() + }, + ], + aggregate_relay_profiles: vec![aggregate_with_id( + aggregate_id, + AggregateRelayStrategy::Failover, + )], + active_relay_id: aggregate_id.to_string(), + active_aggregate_relay_id: aggregate_id.to_string(), + ..BackendSettings::default() + }; + + let first = select_relay_for_request(&settings, RotationContext::default()).unwrap(); + settings.aggregate_relay_profiles[0].strategy = AggregateRelayStrategy::WeightedRoundRobin; + + let selected = (0..3) + .map(|_| { + select_relay_for_request(&settings, RotationContext::default()) + .unwrap() + .id + }) + .collect::>(); + + assert_eq!(first.id, "relay-a"); + assert_eq!(selected, vec!["relay-a", "relay-b", "relay-b"]); +} diff --git a/crates/codex-plus-core/tests/watcher.rs b/crates/codex-plus-core/tests/watcher.rs index ccad1173..716707dc 100644 --- a/crates/codex-plus-core/tests/watcher.rs +++ b/crates/codex-plus-core/tests/watcher.rs @@ -1,8 +1,7 @@ use codex_plus_core::watcher::{ build_spawn_launcher_command, build_watcher_install_plan, cdp_listening, codex_process_ids, disable_watcher_at, enable_watcher_at, filter_killable_launcher_processes, - should_recover_stale_launcher, - watcher_disabled_flag, + should_recover_stale_launcher, watcher_disabled_flag, }; #[test] diff --git "a/docs/plan/20260527160751519_\350\201\232\345\220\210API\344\276\233\345\272\224\345\225\206\350\275\256\350\275\254\345\256\236\346\226\275\350\256\241\345\210\222.md" "b/docs/plan/20260527160751519_\350\201\232\345\220\210API\344\276\233\345\272\224\345\225\206\350\275\256\350\275\254\345\256\236\346\226\275\350\256\241\345\210\222.md" new file mode 100644 index 00000000..60389e23 --- /dev/null +++ "b/docs/plan/20260527160751519_\350\201\232\345\220\210API\344\276\233\345\272\224\345\225\206\350\275\256\350\275\254\345\256\236\346\226\275\350\256\241\345\210\222.md" @@ -0,0 +1,210 @@ +# 聚合 API 供应商轮转实施计划 + +> 生成时间:2026-05-27 16:07:51 +> 工作区:`/Users/albertluo/workSpace/albertLuo/createSomethingNew/CodexPlusPlus` +> 角色:Agent-4(验证与交付) +> 范围:API 供应商新增“自动聚合 API 轮转”对话;支持四种策略;成员从已有供应商勾选。 +> 约束:本文档仅准备验证清单与交付计划,不修改业务代码。 + +## 1. 子 Agent 开工模板 + +- 目标:为聚合 API 供应商轮转功能准备验证清单和文档落盘。 +- 输入:API 供应商新增自动聚合 API 轮转对话,四种策略,成员从已有供应商勾选。 +- 输出:`docs/plan/20260527160751519_聚合API供应商轮转实施计划.md`。 +- 验收标准:文档落盘;列出建议执行的最小验证命令。 +- 负责文件:仅新增 `docs/plan/20260527160751519_聚合API供应商轮转实施计划.md`。 +- 预计耗时:5-10 分钟。 +- 心跳频率:60 秒。 + +## 2. 功能验收范围 + +### 2.1 产品行为 + +- 在“供应商配置”页提供“自动聚合 API 轮转”创建入口。 +- 对话内可以从已有供应商列表勾选成员,不允许输入游离于现有列表之外的成员。 +- 至少勾选 2 个成员后才允许保存聚合供应商。 +- 支持四种轮转策略,并在保存后可被配置持久化、重新加载和编辑。 +- 聚合供应商应能出现在供应商列表中,并能被正常切换为当前供应商。 +- 删除或修改成员供应商时,聚合供应商需要给出明确状态:成员缺失、成员不可用或需要重新选择。 + +### 2.2 四种策略建议 + +实现按用户确认的四种策略验收: + +1. 失败切换:优先使用第一个成员,失败、限流或上游错误后切到下一个成员。 +2. 按对话轮转:每个新对话分配一个成员,同一对话保持固定成员。 +3. 按请求轮转:每次请求按成员顺序切换,适合能力接近的供应商池。 +4. 权重轮转:按成员权重分配请求,权重越高承担越多请求。 + +字段名固定为 `failover`、`conversationRoundRobin`、`requestRoundRobin`、`weightedRoundRobin`,需要在 UI、持久化配置和调用路径上保持一致。 + +## 3. 建议任务拆分 + +### Task 1:数据模型与持久化检查 + +负责人:Agent-2 / Agent-BE-Rust +负责目录: + +- `crates/codex-plus-core/src` +- `apps/codex-plus-manager/src-tauri/src` + +验收标准: + +- 聚合供应商字段不会破坏已有单供应商配置反序列化。 +- 老配置缺少聚合字段时仍能正常加载。 +- 保存后重新打开应用,聚合供应商成员、策略、排序、权重仍保留。 + +### Task 2:前端对话与成员勾选 + +负责人:Agent-3 / Agent-FE-React +负责文件: + +- `apps/codex-plus-manager/src/App.tsx` +- `apps/codex-plus-manager/src/styles.css` + +验收标准: + +- 创建入口可见,文案明确。 +- 对话只展示已有供应商作为候选成员。 +- 当前正在编辑的聚合供应商不能勾选自身。 +- 不足 2 个成员、没有策略、成员重复时保存按钮不可用或保存失败提示明确。 +- 移动端和桌面宽度下文本不溢出、按钮不遮挡。 + +### Task 3:轮转选择逻辑 + +负责人:Agent-2 / Agent-BE-Rust +负责目录: + +- `crates/codex-plus-core/src` + +验收标准: + +- 失败切换、按对话轮转、按请求轮转、权重轮转四种策略均有确定性单元测试。 +- 单个成员不可用时,不影响其他可用成员继续工作。 +- 所有成员不可用时返回明确错误,不静默回落到错误供应商。 +- 不泄露成员供应商 API Key 到日志、错误提示或前端通知。 + +### Task 4:端到端冒烟 + +负责人:Agent-4 +负责范围: + +- 前端类型检查 +- Rust 单元/集成测试 +- Tauri 构建前检查 +- 手工 UI 冒烟清单 + +验收标准: + +- 最小验证命令通过,或明确记录失败原因与阻塞点。 +- 手工路径覆盖:创建聚合供应商、选择四种策略、保存、重载、切换、删除成员供应商后的提示。 + +## 4. 只读检查到的可用命令 + +### 4.1 前端管理端 + +`apps/codex-plus-manager/package.json` 暴露命令: + +```bash +cd apps/codex-plus-manager && npm run check +cd apps/codex-plus-manager && npm run vite:build +cd apps/codex-plus-manager && npm run build +cd apps/codex-plus-manager && npm run dev +``` + +说明: + +- `npm run check`:TypeScript 静态检查,适合作为最小前端验证。 +- `npm run vite:build`:前端产物构建,适合 UI 改动后的构建验证。 +- `npm run build`:包含 `cargo build -p codex-plus-launcher --release && tauri build`,成本较高,建议放在合并前或发版前。 +- `npm run dev`:本地 Tauri 开发调试。 + +### 4.2 Rust workspace + +根目录存在 `Cargo.toml` workspace,成员包括: + +- `crates/codex-plus-core` +- `crates/codex-plus-data` +- `apps/codex-plus-launcher` +- `apps/codex-plus-manager/src-tauri` + +建议命令: + +```bash +cargo test -p codex-plus-core +cargo test -p codex-plus-data +cargo test -p codex-plus-manager +cargo test --workspace +cargo build --workspace +``` + +说明: + +- `cargo test -p codex-plus-core`:优先覆盖供应商配置、协议代理、轮转逻辑。 +- `cargo test -p codex-plus-manager`:优先覆盖 Tauri command 层与配置读写桥接。 +- `cargo test --workspace`:全量 Rust 回归,耗时更高。 +- `cargo build --workspace`:合并前构建兜底。 + +## 5. 最小验证命令 + +优先建议实现 Agent 完成后执行以下最小集合: + +```bash +cd /Users/albertluo/workSpace/albertLuo/createSomethingNew/CodexPlusPlus +cargo test -p codex-plus-core +cargo test -p codex-plus-manager +cd apps/codex-plus-manager && npm run check +``` + +若涉及样式、布局或对话交互,追加: + +```bash +cd /Users/albertluo/workSpace/albertLuo/createSomethingNew/CodexPlusPlus/apps/codex-plus-manager +npm run vite:build +``` + +若准备合并或发版,追加: + +```bash +cd /Users/albertluo/workSpace/albertLuo/createSomethingNew/CodexPlusPlus +cargo test --workspace +cargo build --workspace +cd apps/codex-plus-manager && npm run build +``` + +## 6. 手工冒烟清单 + +- 打开供应商配置页,确认“自动聚合 API 轮转”入口存在。 +- 已有 0 个或 1 个普通供应商时,创建聚合供应商应提示成员不足。 +- 已有 2 个以上普通供应商时,勾选成员后可保存。 +- 分别选择四种策略保存,重新打开详情后策略保持不变。 +- 调整成员顺序后保存,按请求轮转策略按新顺序展示或生效。 +- 权重轮转策略下,权重为空、0、非数字、极大值都有明确校验。 +- 删除一个被聚合引用的普通供应商后,聚合供应商展示成员缺失或不可用状态。 +- 切换到聚合供应商后,配置预览不应显示错误成员的 API Key。 +- 失败提示不包含完整 API Key,只允许展示脱敏前缀或供应商名称。 + +## 7. 风险与回滚 + +### 7.1 主要风险 + +- 配置兼容风险:新增聚合字段可能影响旧版本配置加载。 +- 调用路径风险:聚合供应商如果被当作普通供应商写入 `config.toml`,可能导致 base URL 或 API Key 为空。 +- 状态一致性风险:成员供应商删除、重命名、拖动排序后,聚合引用可能失效。 +- 安全风险:轮转失败日志可能打印成员 API Key。 +- UI 风险:供应商列表和对话都集中在 `App.tsx`,改动容易影响现有供应商新增、编辑、切换流程。 + +### 7.2 回滚方案 + +- 前端入口回滚:隐藏或移除“自动聚合 API 轮转”入口,保留已有普通供应商流程。 +- 数据兼容回滚:读取配置时忽略聚合供应商字段,不删除用户已有普通供应商。 +- 调用路径回滚:切换供应商时若检测到聚合类型不可用,阻止写入 `config.toml` 并提示用户选择普通供应商。 +- 测试回滚:保留新增单元测试中对旧配置兼容的用例,避免回滚后引入老配置不可读问题。 + +## 8. 交付模板 + +- 结果摘要:已完成聚合 API 供应商轮转功能验证计划落盘;未改业务代码。 +- 改动点:新增 `docs/plan/20260527160751519_聚合API供应商轮转实施计划.md`。 +- 验证:只读检查 `apps/codex-plus-manager/package.json` 与 workspace `Cargo.toml`,整理出最小验证命令。 +- 风险与回滚:见本文档第 7 节。 +- 请求关闭:Agent-4 文档与验证清单任务已完成,请 Agent-0 回收关闭。