From a33d71e5794b7a1294e04ffb9bedbbda4afba53d Mon Sep 17 00:00:00 2001 From: Yuimi-chaya Date: Thu, 5 Nov 2026 11:25:07 +0800 Subject: [PATCH 1/6] feat(auth): separate native login from gateway subscription quota --- Cargo.lock | 1 + apps/codex-plus-manager/src-tauri/Cargo.toml | 1 + .../src-tauri/src/gateway_commands.rs | 205 +++- apps/codex-plus-manager/src-tauri/src/lib.rs | 2 + apps/codex-plus-manager/src/App.tsx | 8 +- apps/codex-plus-manager/src/GatewayPage.css | 24 + apps/codex-plus-manager/src/GatewayPage.tsx | 4 +- .../src/GatewayProviders.tsx | 34 +- .../src/NativeAuthControls.css | 113 ++ .../src/NativeAuthControls.tsx | 116 ++ .../src/ProviderFormDialog.tsx | 17 +- .../src/ProviderTestDialog.tsx | 13 +- .../src/SubscriptionPolicyFields.tsx | 61 ++ apps/codex-plus-manager/src/gateway-model.ts | 57 +- .../src/gateway-ui-review.test.ts | 2 +- apps/codex-plus-manager/src/gateway-ui.tsx | 26 +- .../src/group-sticky-quota.test.ts | 6 +- apps/codex-plus-manager/src/i18n-en.ts | 16 + .../src/native-auth-model.test.ts | 19 + .../src/native-auth-model.ts | 31 + .../src/provider-test-dialog.test.ts | 58 +- .../src/provider-test.test.ts | 3 +- apps/codex-plus-manager/src/provider-test.ts | 4 +- .../src/subscription-policy-fields.test.ts | 88 ++ .../src/subscription-policy.test.ts | 120 +++ .../src/subscription-policy.ts | 53 + crates/codex-plus-core/src/ccs_import.rs | 1 + .../codex-plus-core/src/gateway/accounts.rs | 58 +- crates/codex-plus-core/src/gateway/apply.rs | 184 +++- crates/codex-plus-core/src/gateway/balance.rs | 1 + .../src/gateway/chatgpt_backend.rs | 33 +- .../src/gateway/disconnect_preview.rs | 1 + crates/codex-plus-core/src/gateway/images.rs | 1 + .../codex-plus-core/src/gateway/model_sync.rs | 2 + .../codex-plus-core/src/gateway/pipeline.rs | 34 +- crates/codex-plus-core/src/gateway/probe.rs | 51 + crates/codex-plus-core/src/gateway/quota.rs | 991 ++++++++++++++++-- crates/codex-plus-core/src/gateway/route.rs | 39 +- crates/codex-plus-core/src/gateway/store.rs | 80 ++ crates/codex-plus-core/src/gateway/usage.rs | 1 + crates/codex-plus-core/src/lib.rs | 1 + crates/codex-plus-core/src/model_catalog.rs | 8 +- crates/codex-plus-core/src/native_auth.rs | 719 +++++++++++++ crates/codex-plus-core/src/official_login.rs | 402 ++++--- crates/codex-plus-core/src/provider_import.rs | 1 + crates/codex-plus-core/src/relay_config.rs | 47 +- crates/codex-plus-core/src/settings.rs | 23 +- .../tests/all/extension_manager_data.rs | 2 + .../tests/all/gateway_agents.rs | 1 + .../tests/all/gateway_apply.rs | 22 +- .../tests/all/gateway_breaker.rs | 1 + .../tests/all/gateway_codex_identity.rs | 1 + .../tests/all/gateway_diagnostics.rs | 1 + .../tests/all/gateway_key_limits.rs | 1 + .../tests/all/gateway_meta_sync.rs | 1 + .../tests/all/gateway_own_models.rs | 1 + .../tests/all/gateway_provider_probe.rs | 79 ++ .../tests/all/gateway_rate_limit.rs | 1 + .../tests/all/gateway_restore.rs | 1 + crates/codex-plus-core/tests/all/launcher.rs | 1 + .../codex-plus-core/tests/all/relay_config.rs | 11 +- crates/codex-plus-core/tests/gateway_e2e.rs | 1 + 62 files changed, 3543 insertions(+), 342 deletions(-) create mode 100644 apps/codex-plus-manager/src/NativeAuthControls.css create mode 100644 apps/codex-plus-manager/src/NativeAuthControls.tsx create mode 100644 apps/codex-plus-manager/src/SubscriptionPolicyFields.tsx create mode 100644 apps/codex-plus-manager/src/native-auth-model.test.ts create mode 100644 apps/codex-plus-manager/src/native-auth-model.ts create mode 100644 apps/codex-plus-manager/src/subscription-policy-fields.test.ts create mode 100644 apps/codex-plus-manager/src/subscription-policy.test.ts create mode 100644 apps/codex-plus-manager/src/subscription-policy.ts create mode 100644 crates/codex-plus-core/src/native_auth.rs diff --git a/Cargo.lock b/Cargo.lock index 6885543364..5a7a0db68c 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 0e8336fec6..fb17f6cf2b 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 e82bf540da..9e6b2a6948 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(); + codex_plus_core::native_auth::preserve_live_auth(&home)?; + 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 mut accounts = Vec::new(); + let mut actual_account_ref = None; + let mut actual_label = 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 actual_account_ref.is_some() => { + 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?; @@ -2322,7 +2481,7 @@ pub async fn login_gateway_chatgpt( }) }) .await; - login_response(outcome) + login_response(outcome, &before).await } /// 账号类供应商(Copilot / SuperGrok)加进网关池;同一个账号(按 `identity_field`)已存在时只换凭据。 @@ -2398,12 +2557,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 +2571,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 +2585,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 +2594,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 +2613,7 @@ pub async fn login_gateway_chatgpt_api( ) }) .await; - login_response(outcome) + login_response(outcome, &before).await } /// 私有接口订阅(Cursor、Kiro 等)登录第一步:按选的方式开始登录。要打开的网址在这里打开, @@ -2507,6 +2669,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 +2688,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 2b01a2b4df..8ddaedb925 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 0e029d6236..1b1106b05a 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 dc1f4ff32e..e88a850d19 100644 --- a/apps/codex-plus-manager/src/ProviderFormDialog.tsx +++ b/apps/codex-plus-manager/src/ProviderFormDialog.tsx @@ -35,6 +35,8 @@ import { import { windowSummary } from "./ProviderWindowsDialog"; import { filterModels, pickerOutcome, pickerRows, type PickerRow } from "./provider-sync-model"; import "./ProviderFormDialog.css"; +import { SubscriptionPolicyFields } from "./SubscriptionPolicyFields"; +import { normalizeSubscriptionPolicy } from "./subscription-policy"; export type ProviderFormState = { mode: "preset"; preset: GatewayPreset } | { mode: "custom" } | { mode: "edit"; entry: GatewayPoolEntry }; @@ -66,8 +68,12 @@ 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(() => { + const initial = form.mode === "edit" ? providerDraftFromEntry(form.entry) : providerDraftFromPreset(preset); + return subscriptionEditing ? { ...initial, subscriptionPolicy: normalizeSubscriptionPolicy(initial.subscriptionPolicy) } : initial; + }); // 价格在供应商池的详情里按模型编辑;新添加时按公开价格表给每个模型悄悄填上(查不到的不填)。 const autoPrice = useRef(!editing && !hasPrice(draft)); const [fetching, setFetching] = useState(false); @@ -75,7 +81,7 @@ 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 ? null : validateProviderDraft(draft, editing); const keys = draftKeys(draft); const models = draftModels(draft); const chosenModel = defaultModel(draft); @@ -126,7 +132,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("网关订阅消费授权") : 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 ?? [] : []; @@ -151,6 +157,9 @@ export function ProviderFormDialog({
+ {subscriptionEditing ? ( + update({ subscriptionPolicy })} quota={form.mode === "edit" ? form.entry.quota : null} value={draft.subscriptionPolicy} /> + ) : <>

{t("连接")}

@@ -305,7 +314,7 @@ export function ProviderFormDialog({
) : null} - + }
{agentPicks ?
{agentPicks}
: null} diff --git a/apps/codex-plus-manager/src/ProviderTestDialog.tsx b/apps/codex-plus-manager/src/ProviderTestDialog.tsx index 6cf2967e36..7e88e919c3 100644 --- a/apps/codex-plus-manager/src/ProviderTestDialog.tsx +++ b/apps/codex-plus-manager/src/ProviderTestDialog.tsx @@ -10,7 +10,7 @@ import { CheckCircle2, Loader2, X, XCircle, Zap } from "lucide-react"; import { Button } from "@/components/ui/button"; import { t, tf } from "./i18n"; import { AppSelect } from "./AppSelect"; -import { maskAccountName, type GatewayPoolEntry } from "./gateway-model"; +import { maskAccountName, maskAccountText, subscriptionBlockReason, type GatewayPoolEntry } from "./gateway-model"; import { Avatar } from "./gateway-ui"; import { useEscapeLayer } from "./use-escape-layer"; import { formatProbeMs, probeHints, probeWireLabel, type ProviderProbeResult } from "./provider-test"; @@ -28,6 +28,7 @@ export function ProviderTestDialog({ lockModel = false, onStart, onResult, + onEditSubscription, onClose, }: { entry: GatewayPoolEntry; @@ -37,8 +38,10 @@ export function ProviderTestDialog({ lockModel?: boolean; onStart?: () => void; onResult?: (result: ProviderProbeResult) => void; + onEditSubscription?: () => void; onClose: () => void; }) { + const subscriptionBlocked = subscriptionBlockReason(entry); const imageModels = (entry.imageModels ?? []).map((model) => model.toLowerCase()); const models = entry.models.filter((model) => !imageModels.includes(model.toLowerCase())); if (initialModel && !models.includes(initialModel)) models.unshift(initialModel); @@ -70,7 +73,7 @@ export function ProviderTestDialog({ }, []); const start = async () => { - if (!model || runningRef.current) return; + if (!model || runningRef.current || subscriptionBlockReason(entry)) return; const current = ++seq.current; runningRef.current = true; onStart?.(); @@ -144,6 +147,7 @@ export function ProviderTestDialog({ ) : null}

{t("按这家的协议、代理和 Key 发送,让模型只回一个词;最多等 60 秒。")}

+ {subscriptionBlocked ?

{t(maskAccountText(subscriptionBlocked))}

: null} {running ? ( @@ -199,12 +203,15 @@ export function ProviderTestDialog({