diff --git a/Cargo.lock b/Cargo.lock index 688554336..5a7a0db68 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -550,6 +550,7 @@ dependencies = [ "tauri-build", "tauri-plugin-dialog", "tempfile", + "tokio", "toml_edit 0.22.27", ] diff --git a/apps/codex-plus-manager/src-tauri/Cargo.toml b/apps/codex-plus-manager/src-tauri/Cargo.toml index 0e8336fec..fb17f6cf2 100644 --- a/apps/codex-plus-manager/src-tauri/Cargo.toml +++ b/apps/codex-plus-manager/src-tauri/Cargo.toml @@ -31,6 +31,7 @@ serde.workspace = true serde_json.workspace = true tauri = { version = "2", features = ["protocol-asset", "custom-protocol", "tray-icon"] } tauri-plugin-dialog = "2" +tokio.workspace = true toml_edit.workspace = true [build-dependencies] diff --git a/apps/codex-plus-manager/src-tauri/src/gateway_commands.rs b/apps/codex-plus-manager/src-tauri/src/gateway_commands.rs index e82bf540d..e5242342a 100644 --- a/apps/codex-plus-manager/src-tauri/src/gateway_commands.rs +++ b/apps/codex-plus-manager/src-tauri/src/gateway_commands.rs @@ -75,6 +75,9 @@ pub struct GatewayPoolEntry { pub upstream_gone: Vec, /// 订阅账号的额度(ChatGPT 5 小时 / 每周、Copilot 本月高级请求);查不到或普通供应商为空。 pub quota: Option, + pub subscription_policy: + Option, + pub subscription_block_reason: Option, /// 有余额接口的厂商(DeepSeek、Kimi 等,#2793):卡片用「用量」页同一份余额,不当中转站识别。 pub balance_api: bool, /// new-api 访问令牌(打码,#2793);没配置为空。令牌原文不传给界面。 @@ -123,6 +126,27 @@ pub struct GatewaySourceEntry { pub name: String, } +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct NativeAuthAccount { + pub id: String, + pub name: String, + pub email: String, + pub workspace_id: String, + pub source: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct NativeAuthState { + pub managed: bool, + pub selection: Option, + pub actual_account_ref: Option, + pub actual_label: String, + pub accounts: Vec, + pub drift: Option, +} + #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct GatewayPayload { @@ -231,7 +255,9 @@ fn pool_entry( disable_tool_search: relay.disable_tool_search, upstream_new: upstream.0, upstream_gone: upstream.1, - quota: codex_plus_core::gateway::quota::get(&relay.id), + quota: codex_plus_core::gateway::quota::quota_for_relay(relay), + subscription_policy: None, + subscription_block_reason: None, balance_api: codex_plus_core::gateway::balance::has_balance_api(relay), newapi_access: None, } @@ -260,6 +286,8 @@ fn build_payload(settings: &BackendSettings, gateway: GatewaySettings) -> Gatewa .map(|provider| GatewayPoolEntry { keys: apply::provider_keys(&provider.relay), newapi_access: apply::provider_newapi_access(provider), + subscription_policy: provider.subscription_policy.clone(), + subscription_block_reason: codex_plus_core::gateway::quota::dispatch_decision(&gateway, &provider.relay).err().map(|blocked| blocked.message), model_windows: provider.model_windows.clone(), context_window: provider.context_window, key_rpm: provider.key_rpm, @@ -426,6 +454,117 @@ fn build_payload(settings: &BackendSettings, gateway: GatewaySettings) -> Gatewa } } +fn native_auth_state() -> anyhow::Result { + let settings = SettingsStore::default().load()?; + let home = codex_plus_core::relay_config::default_codex_home_dir(); + let live = match std::fs::read_to_string(home.join("auth.json")) { + Ok(contents) => contents, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(), + Err(error) => return Err(error.into()), + }; + let gateway = GatewayStore::default().load()?; + let has_native_login = codex_plus_core::official_login::official_login_from_auth_contents(&live).is_some(); + let mut accounts = Vec::new(); + let mut actual_account_ref = None; + let mut actual_label = if has_native_login { "ChatGPT".to_string() } else { String::new() }; + for provider in &gateway.providers { + if codex_plus_core::official_login::official_login_from_auth_contents( + &provider.relay.auth_contents, + ) + .is_none() + { + continue; + } + let account_id = codex_plus_core::official_login::chatgpt_account_id_from_auth_contents( + &provider.relay.auth_contents, + ); + let name = provider.relay.name.clone(); + if !live.trim().is_empty() + && codex_plus_core::native_auth::same_account(&live, &provider.relay.auth_contents) + && (actual_account_ref.is_none() + || codex_plus_core::native_auth::selection_account_ref(&settings) + == Some(provider.relay.id.as_str())) + { + actual_account_ref = Some(provider.relay.id.clone()); + actual_label = name.clone(); + } + accounts.push(NativeAuthAccount { + id: provider.relay.id.clone(), + name, + email: String::new(), + workspace_id: account_id, + source: provider.source.clone(), + }); + } + let drift = match settings.native_auth_selection.as_ref() { + Some(codex_plus_core::settings::NativeAuthSelection::None) if has_native_login => { + Some("当前 auth 仍有登录凭据".to_string()) + } + Some(codex_plus_core::settings::NativeAuthSelection::Account { account_ref }) + if actual_account_ref.as_deref() != Some(account_ref.as_str()) => + { + Some("当前 auth 与显式选择不一致".to_string()) + } + _ => None, + }; + Ok(NativeAuthState { + managed: settings.native_auth_selection.is_some(), + selection: settings.native_auth_selection, + actual_account_ref, + actual_label, + accounts, + drift, + }) +} + +#[tauri::command] +pub fn get_native_auth_state() -> CommandResult { + match native_auth_state() { + Ok(state) => ok("", state), + Err(error) => failed(&format!("读取原生登录状态失败:{error}"), NativeAuthState { + managed: false, + selection: None, + actual_account_ref: None, + actual_label: String::new(), + accounts: Vec::new(), + drift: Some(error.to_string()), + }), + } +} + +#[tauri::command] +pub fn set_native_auth_account(account_ref: Option) -> CommandResult { + let result = (|| -> anyhow::Result { + if let Some(account_ref) = account_ref.as_deref() { + let gateway = GatewayStore::default().load()?; + let provider = gateway + .provider(account_ref) + .context("所选 ChatGPT 账号不存在")?; + if codex_plus_core::official_login::official_login_from_auth_contents( + &provider.relay.auth_contents, + ) + .is_none() + { + anyhow::bail!("所选供应商没有可用的 ChatGPT 登录凭据"); + } + } + codex_plus_core::native_auth::set_native_auth_selection(account_ref.as_deref())?; + notify_settings_changed(); + native_auth_state() + })(); + match result { + Ok(state) => ok("原生登录选择已保存。", state), + Err(error) => failed(&format!("保存原生登录选择失败:{error}"), NativeAuthState { + managed: false, + selection: None, + actual_account_ref: None, + actual_label: String::new(), + accounts: Vec::new(), + drift: Some(error.to_string()), + }), + } +} + fn current_payload() -> Option { let settings = SettingsStore::default().load().unwrap_or_default(); let gateway = GatewayStore::default().load().ok()?; @@ -479,6 +618,7 @@ fn refresh_agents(result: anyhow::Result) -> anyhow::Result { /// 放到阻塞线程池里做,不占主线程。返回格式不变。 #[tauri::command] pub async fn load_gateway() -> CommandResult> { + refresh_quotas(); match tauri::async_runtime::spawn_blocking(load_gateway_blocking).await { Ok(result) => result, Err(error) => failed(&format!("读取网关状态失败:{error}"), None), @@ -1016,6 +1156,7 @@ pub fn update_gateway_provider( // 表单里可能改了模型列表:新模型按公开价格表补价格。 if result.is_ok() { autofill_prices(Some(id)); + refresh_quotas(); } respond("供应商已保存。", "保存供应商失败", result) } @@ -1428,17 +1569,26 @@ fn autofill_prices(only: Option) { /// 后台查一遍订阅账号的额度(刚登录的账号不用等下一轮)。 fn refresh_quotas() { tauri::async_runtime::spawn(async move { - let Ok(gateway) = GatewayStore::default().load() else { - return; - }; - for provider in gateway.providers.iter().filter(|provider| provider.enabled) { - if codex_plus_core::gateway::quota::supports(&provider.relay) { - let _ = codex_plus_core::gateway::quota::refresh(&provider.relay).await; - } - } + prefetch_quotas().await; }); } +async fn prefetch_quotas() { + if let Ok(gateway) = GatewayStore::default().load() { + codex_plus_core::gateway::quota::prefetch(&gateway).await; + } +} + +async fn prefetch_changed_quotas(before: &GatewaySettings) { + if let Ok(mut gateway) = GatewayStore::default().load() { + gateway.providers.retain(|provider| before.provider(&provider.relay.id) + .is_none_or(|old| old.relay.auth_contents != provider.relay.auth_contents)); + // 只等待此次导入/重登录的账号;其它账号留给后台,不拖住登录响应。 + let _ = tokio::time::timeout(Duration::from_secs(8), + codex_plus_core::gateway::quota::prefetch(&gateway)).await; + } +} + /// 手动刷新一个账号的额度。 #[tauri::command] pub async fn refresh_gateway_quota(id: String) -> CommandResult> { @@ -1508,6 +1658,12 @@ pub fn set_gateway_provider_enabled( id: String, enabled: bool, ) -> CommandResult> { + let result = refresh_agents(apply::set_provider_enabled( + &GatewayStore::default(), &id, enabled, + )); + if enabled && result.is_ok() { + refresh_quotas(); + } respond( if enabled { "已打开,接入的 Agent 能用它的模型了。" @@ -1515,11 +1671,7 @@ pub fn set_gateway_provider_enabled( "已关闭,它不再参与调度。" }, "切换供应商失败", - refresh_agents(apply::set_provider_enabled( - &GatewayStore::default(), - &id, - enabled, - )), + result, ) } @@ -1557,6 +1709,7 @@ async fn import_opencode(id: &str, skip_agents: Option<&[String]>) -> anyhow::Re model_prices: None, proxy: None, strip_image_generation_tool: None, + subscription_policy: None, strip_responses_image_detail: None, disable_tool_search: None, }; @@ -1590,6 +1743,7 @@ pub async fn import_gateway_providers( items: Vec, skip_agents: Option>, ) -> CommandResult> { + let before = GatewayStore::default().load().unwrap_or_default(); let skip = skip_agents.as_deref(); let mut added = 0usize; let mut skipped = 0usize; @@ -1637,6 +1791,7 @@ pub async fn import_gateway_providers( } } let _ = refresh_added(Ok(())); + prefetch_changed_quotas(&before).await; let mut message = format!("已导入 {added} 个供应商"); if !groups.is_empty() { message.push_str(&format!(",建好模型组「{}」", groups.join("」「"))); @@ -2206,7 +2361,7 @@ where } /// 登录命令统一的返回:成功刷新价格 / 额度 / Agent,取消时返回 `cancelled`。 -fn login_response(outcome: LoginOutcome) -> CommandResult> { +async fn login_response(outcome: LoginOutcome, before: &GatewaySettings) -> CommandResult> { match outcome { LoginOutcome::Cancelled => CommandResult { status: "cancelled".to_string(), @@ -2214,7 +2369,10 @@ fn login_response(outcome: LoginOutcome) -> CommandResult match refresh_added(result) { - Ok(message) => ok(&message, current_payload()), + Ok(message) => { + prefetch_changed_quotas(before).await; + ok(&message, current_payload()) + }, Err(error) => failed(&format!("登录失败:{error:#}"), current_payload()), }, } @@ -2283,6 +2441,7 @@ mod login_tests { pub async fn login_gateway_chatgpt( skip_agents: Option>, ) -> CommandResult> { + let before = GatewayStore::default().load().unwrap_or_default(); use codex_plus_core::gateway::accounts; let outcome = cancellable_login(async move { let account = accounts::chatgpt_login(open_in_browser).await?; @@ -2294,7 +2453,9 @@ pub async fn login_gateway_chatgpt( .providers .iter_mut() .find(|provider| { - accounts::same_chatgpt_login( + provider.source != "native:external" + && !codex_plus_core::official_login::is_native_owned_login(&provider.relay) + && accounts::same_chatgpt_login( &provider.relay.auth_contents, &account.auth_contents, ) @@ -2322,7 +2483,7 @@ pub async fn login_gateway_chatgpt( }) }) .await; - login_response(outcome) + login_response(outcome, &before).await } /// 账号类供应商(Copilot / SuperGrok)加进网关池;同一个账号(按 `identity_field`)已存在时只换凭据。 @@ -2398,12 +2559,13 @@ pub async fn finish_gateway_copilot_login( code: codex_plus_core::gateway::accounts::DeviceCode, skip_agents: Option>, ) -> CommandResult> { + let before = GatewayStore::default().load().unwrap_or_default(); let outcome = cancellable_login(async move { let relay = codex_plus_core::gateway::accounts::copilot_finish(&code).await?; add_account_provider(relay, "copilot", "login", skip_agents.as_deref()) }) .await; - login_response(outcome) + login_response(outcome, &before).await } /// 在网关里登录 SuperGrok:浏览器登录后加进网关池。与 Grok CLI 自己的登录互不影响。 @@ -2411,6 +2573,7 @@ pub async fn finish_gateway_copilot_login( pub async fn login_gateway_grok( skip_agents: Option>, ) -> CommandResult> { + let before = GatewayStore::default().load().unwrap_or_default(); use codex_plus_core::gateway::accounts; let outcome = cancellable_login(async move { let tokens = accounts::grok_login(open_in_browser).await?; @@ -2424,7 +2587,7 @@ pub async fn login_gateway_grok( ) }) .await; - login_response(outcome) + login_response(outcome, &before).await } /// 在网关里登录 ChatGPT API(OpenAI 的「用 ChatGPT 登录」):套餐额度直接用在 api.openai.com 上, @@ -2433,6 +2596,7 @@ pub async fn login_gateway_grok( pub async fn login_gateway_chatgpt_api( skip_agents: Option>, ) -> CommandResult> { + let before = GatewayStore::default().load().unwrap_or_default(); use codex_plus_core::gateway::accounts; let outcome = cancellable_login(async move { let state_dir = codex_plus_core::paths::default_app_state_dir(); @@ -2451,7 +2615,7 @@ pub async fn login_gateway_chatgpt_api( ) }) .await; - login_response(outcome) + login_response(outcome, &before).await } /// 私有接口订阅(Cursor、Kiro 等)登录第一步:按选的方式开始登录。要打开的网址在这里打开, @@ -2507,6 +2671,7 @@ pub async fn finish_gateway_subscription_login( start: codex_plus_core::gateway::subscriptions::LoginStart, skip_agents: Option>, ) -> CommandResult> { + let before = GatewayStore::default().load().unwrap_or_default(); use codex_plus_core::gateway::subscriptions; let outcome = cancellable_login(async move { let item = subscriptions::get(&kind) @@ -2525,7 +2690,7 @@ pub async fn finish_gateway_subscription_login( ) }) .await; - login_response(outcome) + login_response(outcome, &before).await } /// 清掉某个供应商的 Key 冷却与熔断状态。 diff --git a/apps/codex-plus-manager/src-tauri/src/lib.rs b/apps/codex-plus-manager/src-tauri/src/lib.rs index 2b01a2b4d..8ddaedb92 100644 --- a/apps/codex-plus-manager/src-tauri/src/lib.rs +++ b/apps/codex-plus-manager/src-tauri/src/lib.rs @@ -403,6 +403,8 @@ pub fn run() { gateway_commands::reset_gateway_key_health, gateway_commands::create_gateway_provider, gateway_commands::update_gateway_provider, + gateway_commands::get_native_auth_state, + gateway_commands::set_native_auth_account, gateway_commands::add_gateway_provider_key, gateway_commands::replace_gateway_provider_key, gateway_commands::delete_gateway_provider_key, diff --git a/apps/codex-plus-manager/src/App.tsx b/apps/codex-plus-manager/src/App.tsx index 0e029d623..1b1106b05 100644 --- a/apps/codex-plus-manager/src/App.tsx +++ b/apps/codex-plus-manager/src/App.tsx @@ -283,6 +283,7 @@ import { normalizeAggregateDefaultRelayId, } from "./aggregate-members"; import { relayAuthForLiveDraft, shouldBackfillRelayProfileBeforeSwitch } from "./relay-live-files"; +import { NativeAuthControl, SavedAuthAccountField } from "./NativeAuthControls"; import { relayBillingCacheKey, relayBillingProbeEligible, type RelayBillingProbe } from "./relay-billing"; import { RelayBillingLine, RelayBillingRetry, useRelayBillingProbe } from "./RelayBillingLine"; import { apiKeyPreview, createRelayApiKey, normalizeRelayApiKeys, relayProfileWithNormalizedApiKeys, relayProfileWithUpdatedApiKey, type RelayApiKey } from "./provider-api-keys"; @@ -622,6 +623,7 @@ export type RelayProfile = { relayMode: RelayMode; sessionProvider?: RelaySessionProvider; officialMixApiKey: boolean; + officialAccountRef?: string; hideOfficialUsageAlert: boolean; testModel: string; configContents: string; @@ -8075,6 +8077,7 @@ function RelayScreen({ )} /> {connection} + {!normalized.relayProfilesEnabled ? ( @@ -13339,7 +13342,7 @@ function RelayProfileEditor({ mode: "pureApi" as const, icon: KeyRound, title: t("纯 API"), - description: t("只使用 API 凭据,不依赖官方登录。"), + description: t("使用 API 凭据,原生登录账号独立选择。"), }, ]).map(({ mode, icon: Icon, title, description }) => ( + + + {error ?

{error}

: state?.drift ?

{state.drift} · {t("实际登录:")}{state.actualLabel || t("未登录")}

: null} + + ); +} + +/** 供应商绑定只保存引用;当前原生登录由上方独立控件选择。 */ +export function SavedAuthAccountField({ value, onChange }: { value: string; onChange: (value: string) => void }) { + const { state, loading, error, reload } = useSavedAuthAccounts(); + return ( +
+
+ + +
+ {error ?

{error}

: null} +
+ ); +} diff --git a/apps/codex-plus-manager/src/ProviderFormDialog.tsx b/apps/codex-plus-manager/src/ProviderFormDialog.tsx index dc1f4ff32..88f6d252c 100644 --- a/apps/codex-plus-manager/src/ProviderFormDialog.tsx +++ b/apps/codex-plus-manager/src/ProviderFormDialog.tsx @@ -12,6 +12,7 @@ import { presetHost, providerDraftFromEntry, providerDraftFromPreset, + proxyAddressError, validateProviderDraft, type GatewayPoolEntry, type GatewayPreset, @@ -35,6 +36,7 @@ import { import { windowSummary } from "./ProviderWindowsDialog"; import { filterModels, pickerOutcome, pickerRows, type PickerRow } from "./provider-sync-model"; import "./ProviderFormDialog.css"; +import { SubscriptionPolicyFields } from "./SubscriptionPolicyFields"; export type ProviderFormState = { mode: "preset"; preset: GatewayPreset } | { mode: "custom" } | { mode: "edit"; entry: GatewayPoolEntry }; @@ -66,8 +68,11 @@ export function ProviderFormDialog({ onEditWindows?: (models: string[]) => void; }) { const editing = form.mode === "edit"; + const subscriptionEditing = form.mode === "edit" && form.entry.account === "chatgpt"; const preset = form.mode === "preset" ? form.preset : null; - const [draft, setDraft] = useState(() => (form.mode === "edit" ? providerDraftFromEntry(form.entry) : providerDraftFromPreset(preset))); + const [draft, setDraft] = useState(() => { + return form.mode === "edit" ? providerDraftFromEntry(form.entry) : providerDraftFromPreset(preset); + }); // 价格在供应商池的详情里按模型编辑;新添加时按公开价格表给每个模型悄悄填上(查不到的不填)。 const autoPrice = useRef(!editing && !hasPrice(draft)); const [fetching, setFetching] = useState(false); @@ -75,12 +80,14 @@ export function ProviderFormDialog({ const [picker, setPicker] = useState(null); const [modelInput, setModelInput] = useState(""); const update = (patch: Partial) => setDraft((previous) => ({ ...previous, ...patch })); - const validation = validateProviderDraft(draft, editing); + const validation = subscriptionEditing + ? (!draft.name.trim() ? "名称不能为空" : proxyAddressError(draft.proxy)) + : validateProviderDraft(draft, editing); const keys = draftKeys(draft); const models = draftModels(draft); const chosenModel = defaultModel(draft); const firstKey = keys.map((key) => key.trim()).find(Boolean) ?? ""; - const canFetch = Boolean(draft.baseUrl.trim()) && (Boolean(firstKey) || editing) && !fetching; + const canFetch = (subscriptionEditing || (Boolean(draft.baseUrl.trim()) && (Boolean(firstKey) || editing))) && !fetching; // Esc 栈:「从上游获取」的勾选列表开着时 Esc 先收起列表。 useEscapeLayer(true, onCancel); @@ -126,7 +133,7 @@ export function ProviderFormDialog({ }; const title = form.mode === "edit" ? tf("编辑 {0}", [maskAccountName(form.entry.name)]) : preset ? tf("添加 {0}", [preset.name]) : t("添加自定义供应商"); - const subtitle = preset?.apiKeyUrl ? tf("在 {0} 获取 Key", [presetHost(preset.apiKeyUrl)]) : editing ? tf("现有 {0} 个 Key", [form.entry.keyCount]) : t("任意 OpenAI / Anthropic 兼容接口"); + const subtitle = subscriptionEditing ? t("ChatGPT 订阅账号") : preset?.apiKeyUrl ? tf("在 {0} 获取 Key", [presetHost(preset.apiKeyUrl)]) : editing ? tf("现有 {0} 个 Key", [form.entry.keyCount]) : t("任意 OpenAI / Anthropic 兼容接口"); // 编辑时已保存的 Key 只列打码后的样子,增删、替换在供应商池详情的「Key」一行里单独做(#2782); // 旧版后端没给 Key 列表时仍是「留空表示不改」的输入框。 const savedKeys = form.mode === "edit" ? form.entry.keys ?? [] : []; @@ -158,7 +165,7 @@ export function ProviderFormDialog({ {t("名称")} update({ name: event.currentTarget.value })} value={draft.name} /> -
+ {!subscriptionEditing ?
{t("协议")}
{PROTOCOLS.map((item) => ( @@ -167,8 +174,9 @@ export function ProviderFormDialog({ ))}
-
+
: null} + {!subscriptionEditing ? <>