diff --git a/backend/crates/gateway-api/src/admin/wire.rs b/backend/crates/gateway-api/src/admin/wire.rs index 87b05b689..b34c07ef0 100644 --- a/backend/crates/gateway-api/src/admin/wire.rs +++ b/backend/crates/gateway-api/src/admin/wire.rs @@ -331,7 +331,7 @@ impl AdminError { pub(crate) fn map_admin_service_error(error: gateway_admin::model::AdminError) -> AdminError { use gateway_admin::model::AdminErrorKind; - match error.kind() { + let mut response = match error.kind() { AdminErrorKind::Invalid => AdminError::bad_request(error.message()), AdminErrorKind::Unauthorized => AdminError::admin_session_required(), AdminErrorKind::NotFound => AdminError::not_found(error.message()), @@ -341,7 +341,19 @@ pub(crate) fn map_admin_service_error(error: gateway_admin::model::AdminError) - AdminErrorKind::UpstreamResultUnknown => AdminError::upstream_result_unknown(), AdminErrorKind::Unavailable => AdminError::service_unavailable(), AdminErrorKind::Internal => AdminError::internal(), + }; + // Admin owner 已负责公开文案,不能在 HTTP 边界再次丢掉安全的业务原因。 + // 认证与未知内部异常仍使用固定提示,不放行底层诊断或任意 500 消息。 + if matches!( + error.kind(), + AdminErrorKind::BadGateway + | AdminErrorKind::UpstreamResultUnknown + | AdminErrorKind::Unavailable + ) && !error.message().trim().is_empty() + { + response.body.message = error.message().to_owned(); } + response } impl IntoResponse for AdminError { diff --git a/backend/crates/gateway-api/tests/admin/errors.rs b/backend/crates/gateway-api/tests/admin/errors.rs index 39c39abc5..b21b45851 100644 --- a/backend/crates/gateway-api/tests/admin/errors.rs +++ b/backend/crates/gateway-api/tests/admin/errors.rs @@ -318,3 +318,187 @@ async fn admin_auth_failures_should_use_stable_chinese_contracts() { ] ); } + +mod provider { + use std::time::SystemTime; + + use chrono::Utc; + use gateway_admin::{ + model::{ + Revision, + accounts::{AccountPageItem, AccountRecord}, + }, + ports::provider::{ProviderAdminError, ProviderAdminErrorKind as Kind}, + }; + use gateway_core::{ + account::{ + AccountStatusFacts, AccountWeight, CredentialState, QuotaState, resolve_account_status, + }, + routing::ProviderKind, + }; + use tower_http::request_id::{MakeRequestUuid, PropagateRequestIdLayer, SetRequestIdLayer}; + + use super::*; + + #[tokio::test] + async fn provider_public_errors_survive_import_and_refresh_http_handlers() { + let fixture = AdminTestFixture::new().await; + fixture.auth.insert_session("valid-session"); + for provider in ["openai", "xai"] { + *fixture.account.lock().unwrap() = Some(account(provider)); + for (kind, status, code, message) in [ + (Kind::Invalid, 400, 40001, "刷新令牌已被使用,请重新授权"), + ( + Kind::Conflict, + 409, + 40901, + "令牌刷新繁忙,请等待当前刷新完成后重试", + ), + ( + Kind::BadGateway, + 502, + 50201, + "OpenAI 返回的 Codex PAT 身份资料不完整或格式无效,请稍后重试", + ), + ( + Kind::Unavailable, + 503, + 50301, + "暂时无法向 OpenAI 验证 Codex PAT,请稍后重试", + ), + ( + Kind::Ambiguous, + 502, + 50202, + "令牌刷新结果未知,请先核对账号状态,不要立即重复刷新", + ), + ] { + *fixture.provider_error.lock().unwrap() = Some( + ProviderAdminError::new(kind) + .with_public_message(message) + .with_message("raw-upstream-secret-marker"), + ); + for (path, body) in [ + ( + "/api/admin/accounts/refresh", + json!({"accountId": "acct_error_test"}), + ), + ( + "/api/admin/accounts/import", + json!({"provider": provider, "data": {"accessToken": "at-synthetic-test"}}), + ), + ] { + let response = send(&fixture, path, body).await; + assert_eq!(response.headers()["x-request-id"], "req_admin_errors"); + let actual = response_json(response).await; + assert_eq!( + actual, + ( + StatusCode::from_u16(status).unwrap(), + "application/json".to_owned(), + json!({"code": code, "message": message, "data": null}), + ) + ); + assert!(!actual.2.to_string().contains("raw-upstream-secret-marker")); + assert_eq!(fixture.auth.audit_count(), 0); + } + } + } + } + + #[tokio::test] + async fn provider_diagnostics_without_public_messages_still_use_safe_fallbacks() { + let fixture = AdminTestFixture::new().await; + fixture.auth.insert_session("valid-session"); + for (kind, status, code, message) in [ + (Kind::BadGateway, 502, 50201, "上游服务请求失败"), + (Kind::Unavailable, 503, 50301, "Provider 服务暂不可用"), + ( + Kind::Ambiguous, + 502, + 50202, + "上游执行结果未知,请刷新状态后再决定是否重试", + ), + (Kind::Internal, 500, 50001, "服务内部错误"), + ] { + let error = ProviderAdminError::new(kind).with_message("private-provider-diagnostics"); + // 即使 Provider 错标了公开文案,未知内部异常仍不得通过 500 信封下发。 + *fixture.provider_error.lock().unwrap() = Some(if kind == Kind::Internal { + error.with_public_message("internal-detail-must-stay-hidden") + } else { + error + }); + let response = send( + &fixture, + "/api/admin/accounts/import", + json!({"provider": "openai", "data": {}}), + ) + .await; + let (actual_status, _, body) = response_json(response).await; + assert_eq!(actual_status.as_u16(), status); + assert_eq!( + body, + json!({"code": code, "message": message, "data": null}) + ); + } + } + + async fn send(fixture: &AdminTestFixture, path: &str, body: Value) -> axum::response::Response { + let mut request = request(Method::POST, path, Body::from(body.to_string())); + request + .headers_mut() + .insert(header::COOKIE, SESSION_COOKIE.parse().unwrap()); + request + .headers_mut() + .insert(header::CONTENT_TYPE, "application/json".parse().unwrap()); + let name = header::HeaderName::from_static("x-request-id"); + app(fixture.state()) + .layer(PropagateRequestIdLayer::new(name.clone())) + .layer(SetRequestIdLayer::new(name, MakeRequestUuid)) + .oneshot(request) + .await + .unwrap() + } + + fn account(provider: &str) -> AccountPageItem { + let now = Utc::now(); + let facts = AccountStatusFacts { + enabled: true, + credential_state: CredentialState::Ready, + access_token_expires_at: None, + quota: QuotaState::unknown(), + rate_limited_until: None, + last_error_reason: None, + last_error_message: None, + }; + AccountPageItem { + account: AccountRecord { + id: "acct_error_test".to_owned(), + provider_kind: ProviderKind::new(provider).unwrap(), + groups: Vec::new(), + name: "synthetic account".to_owned(), + email: None, + upstream_user_id: None, + upstream_account_id: None, + plan_type: None, + authentication_kind: "oauth".to_owned(), + credential_revision: Revision::new(1).unwrap(), + has_refresh_token: true, + access_token_expires_at: None, + next_refresh_at: None, + enabled: facts.enabled, + concurrency_limit: None, + weight: AccountWeight::default(), + outbound_proxy: None, + credential_state: facts.credential_state, + credential_observed_at: now, + quota: facts.quota, + last_error_reason: None, + last_error_message: None, + created_at: now, + updated_at: now, + }, + projection: resolve_account_status(&facts, SystemTime::now()), + } + } +} diff --git a/backend/crates/gateway-api/tests/admin/mod.rs b/backend/crates/gateway-api/tests/admin/mod.rs index 039fe9516..c42bf1b98 100644 --- a/backend/crates/gateway-api/tests/admin/mod.rs +++ b/backend/crates/gateway-api/tests/admin/mod.rs @@ -96,6 +96,8 @@ pub(super) struct AdminTestFixture { pub ops_errors: Arc>>, pub dashboard_observation: Arc>>, pub dashboard_summary_range: Arc>>, + pub provider_error: Arc>>, + pub account: Arc>>, } impl AdminTestFixture { @@ -115,6 +117,8 @@ impl AdminTestFixture { let ops_errors = Arc::new(Mutex::new(Vec::new())); let dashboard_observation = Arc::new(Mutex::new(None)); let dashboard_summary_range = Arc::new(Mutex::new(None)); + let provider_error = Arc::new(Mutex::new(None)); + let account = Arc::new(Mutex::new(None)); let unused = Arc::new(UnusedStore { usage_records: Arc::clone(&usage_records), usage_detail: Arc::clone(&usage_detail), @@ -122,6 +126,7 @@ impl AdminTestFixture { ops_errors: Arc::clone(&ops_errors), dashboard_observation: Arc::clone(&dashboard_observation), dashboard_summary_range: Arc::clone(&dashboard_summary_range), + account: Arc::clone(&account), }); let stores = AdminStorePorts::new( AdminAccountStorePorts::new( @@ -137,8 +142,8 @@ impl AdminTestFixture { gateway_admin::ports::backup::BackupStorePorts::disabled(), ); let providers: Vec> = vec![ - Arc::new(UnusedProvider::new("openai")), - Arc::new(UnusedProvider::new("xai")), + Arc::new(UnusedProvider::new("openai", Arc::clone(&provider_error))), + Arc::new(UnusedProvider::new("xai", Arc::clone(&provider_error))), ]; let bundle = gateway_admin::initialize( AdminConfig { @@ -165,6 +170,8 @@ impl AdminTestFixture { ops_errors, dashboard_observation, dashboard_summary_range, + provider_error, + account, } } @@ -702,6 +709,7 @@ struct UnusedStore { ops_errors: Arc>>, dashboard_observation: Arc>>, dashboard_summary_range: Arc>>, + account: Arc>>, } #[async_trait] @@ -716,9 +724,12 @@ impl AccountStore for UnusedStore { async fn load_account( &self, - _: &str, + id: &str, _: AccountRuntimeSnapshot, ) -> AdminStoreResult> { + if let Some(account) = self.account.lock().expect("account").as_ref() { + return Ok((account.account.id == id).then(|| account.clone())); + } Err(unavailable("account")) } @@ -927,12 +938,14 @@ impl ObservabilityStore for UnusedStore { struct UnusedProvider { kind: ProviderKind, + error: Arc>>, } impl UnusedProvider { - fn new(kind: &str) -> Self { + fn new(kind: &str, error: Arc>>) -> Self { Self { kind: ProviderKind::new(kind).expect("provider kind"), + error, } } } @@ -973,7 +986,12 @@ impl ProviderAdmin for UnusedProvider { &self, _: PrepareCredentialImport, ) -> Result { - Err(unsupported_provider()) + Err(self + .error + .lock() + .expect("provider error") + .clone() + .unwrap_or_else(unsupported_provider)) } async fn start_authorization( @@ -1001,7 +1019,12 @@ impl ProviderAdmin for UnusedProvider { &self, _: PrepareCredentialRefresh, ) -> Result { - Err(unsupported_provider()) + Err(self + .error + .lock() + .expect("provider error") + .clone() + .unwrap_or_else(unsupported_provider)) } async fn quota( diff --git a/backend/crates/providers/openai/src/admin.rs b/backend/crates/providers/openai/src/admin.rs index 0a57d313f..33099bc68 100644 --- a/backend/crates/providers/openai/src/admin.rs +++ b/backend/crates/providers/openai/src/admin.rs @@ -408,7 +408,8 @@ impl ProviderAdmin for OpenAiAdminProvider { .await .map_err(map_store_error)?; if !account_matches_record(¤t.account, &command.account) { - return Err(provider_admin_error(ProviderAdminErrorKind::Conflict)); + return Err(provider_admin_error(ProviderAdminErrorKind::Conflict) + .with_public_message("账号凭据已被更新,请刷新账号列表后重试")); } let mut secret = rotation_secret(command.provider_material)?; if secret.id_token.is_none() { @@ -440,7 +441,8 @@ impl ProviderAdmin for OpenAiAdminProvider { .await .map_err(map_store_error)?; if !account_matches_record(¤t.account, &command.account) { - return Err(provider_admin_error(ProviderAdminErrorKind::Conflict)); + return Err(provider_admin_error(ProviderAdminErrorKind::Conflict) + .with_public_message("账号凭据已被更新,请刷新账号列表后重试")); } let prepared = self .credentials @@ -1313,52 +1315,80 @@ fn map_store_error(error: gateway_core::error::StoreError) -> ProviderAdminError fn map_credential_admin_error(error: CodexCredentialAdminError) -> ProviderAdminError { use crate::credential::token_client::PersonalAccessTokenError; use CodexCredentialAdminError as Error; + use ProviderAdminErrorKind as Kind; let upstream_message = error.upstream_message().map(ToOwned::to_owned); - let public_message = match &error { - Error::PersonalAccessToken(error) => Some(match error { - PersonalAccessTokenError::InvalidToken => { - "Codex PAT 格式无效:应为 at- 开头的完整令牌,不能包含空白或控制字符" - } - PersonalAccessTokenError::Rejected => { - "OpenAI 拒绝了 Codex PAT:令牌可能无效、已过期、已撤销或没有访问权限" - } - PersonalAccessTokenError::Unavailable => "暂时无法向 OpenAI 验证 Codex PAT,请稍后重试", - PersonalAccessTokenError::InvalidResponse => { - "OpenAI 返回的 Codex PAT 身份资料不完整或格式无效,请稍后重试" - } - }), - _ => None, - }; - let kind = match error { - Error::PersonalAccessToken( - PersonalAccessTokenError::InvalidToken | PersonalAccessTokenError::Rejected, - ) - | Error::InvalidInput - | Error::InvalidCredential - | Error::MissingRefreshToken - | Error::RefreshRejected { .. } - | Error::AccountBanned { .. } => ProviderAdminErrorKind::Invalid, - Error::NotFound => ProviderAdminErrorKind::NotFound, - Error::RefreshLeaseUnavailable | Error::RefreshAmbiguous { .. } => { - ProviderAdminErrorKind::Conflict + let (kind, public_message) = match error { + Error::PersonalAccessToken(error) => match error { + PersonalAccessTokenError::InvalidToken => ( + Kind::Invalid, + "Codex PAT 格式无效:应为 at- 开头的完整令牌,不能包含空白或控制字符", + ), + PersonalAccessTokenError::Rejected => ( + Kind::Invalid, + "OpenAI 拒绝了 Codex PAT:令牌可能无效、已过期、已撤销或没有访问权限", + ), + PersonalAccessTokenError::Unavailable => ( + Kind::Unavailable, + "暂时无法向 OpenAI 验证 Codex PAT,请稍后重试", + ), + PersonalAccessTokenError::InvalidResponse => ( + Kind::BadGateway, + "OpenAI 返回的 Codex PAT 身份资料不完整或格式无效,请稍后重试", + ), + }, + Error::InvalidInput => (Kind::Invalid, "OpenAI 账号输入格式不合法,请检查导入内容"), + Error::InvalidCredential => ( + Kind::Invalid, + "OpenAI 凭据不完整或格式无效,请检查凭据或重新授权", + ), + Error::MissingRefreshToken => (Kind::Invalid, "账号没有刷新令牌,请重新授权"), + Error::RefreshRejected { code, .. } => ( + Kind::Invalid, + refresh_rejection_message(code.as_deref()).unwrap_or("刷新令牌已失效,请重新授权"), + ), + Error::AccountBanned { .. } => (Kind::Invalid, "OpenAI 账号已被停用,请检查账号状态"), + Error::NotFound => (Kind::NotFound, "OpenAI 账号不存在,请刷新账号列表"), + Error::RefreshLeaseUnavailable => { + (Kind::Conflict, "令牌刷新繁忙,请等待当前刷新完成后重试") } - Error::PersonalAccessToken(PersonalAccessTokenError::InvalidResponse) => { - ProviderAdminErrorKind::BadGateway - } - Error::PersonalAccessToken(PersonalAccessTokenError::Unavailable) - | Error::RefreshUnavailable => ProviderAdminErrorKind::Unavailable, - }; - let error = provider_admin_error(kind); - let error = match public_message { - Some(message) => error.with_public_message(message), - None => error, + Error::RefreshUnavailable => ( + Kind::Unavailable, + "令牌刷新服务暂不可用,请检查出站连接与依赖服务", + ), + Error::RefreshUpstream { status, code, .. } => ( + Kind::BadGateway, + refresh_rejection_message(code.as_deref()).unwrap_or(match status { + 401 | 403 => "OpenAI 拒绝了令牌刷新,请检查账号授权状态", + 429 => "OpenAI 令牌刷新请求被限流,请稍后重试", + 500..=599 => "OpenAI 令牌刷新服务异常,请稍后重试", + _ => "OpenAI 未能完成令牌刷新,请检查账号授权与上游服务状态", + }), + ), + Error::RefreshAmbiguous { .. } => ( + Kind::Ambiguous, + "令牌刷新结果未知,请先核对账号状态,不要立即重复刷新", + ), }; + let error = provider_admin_error(kind).with_public_message(public_message); match upstream_message { Some(message) => error.with_message(message), None => error, } } +fn refresh_rejection_message(code: Option<&str>) -> Option<&'static str> { + // 与官方 Codex 的失败原因对齐;只解释管理提示,不改动 Worker 的终态/退避判定。 + match code.map(str::to_ascii_lowercase).as_deref() { + Some("refresh_token_expired") => Some("刷新令牌已过期,请重新授权"), + Some("refresh_token_reused") => Some("刷新令牌已被使用,请重新授权"), + Some("refresh_token_invalidated") => Some("刷新令牌已被撤销,请重新授权"), + // token_expired 也用于 RT 校验失败,不据此断言具体到期原因。 + Some("token_expired") => Some("刷新令牌不可用,请重新授权"), + Some("invalid_grant") => Some("刷新令牌无效或已失效,请重新授权"), + _ => None, + } +} + const fn credential_admin_error_code(error: &CodexCredentialAdminError) -> &'static str { match error { CodexCredentialAdminError::PersonalAccessToken(_) => { @@ -1372,6 +1402,7 @@ const fn credential_admin_error_code(error: &CodexCredentialAdminError) -> &'sta CodexCredentialAdminError::RefreshRejected { .. } => "refresh_rejected", CodexCredentialAdminError::AccountBanned { .. } => "account_banned", CodexCredentialAdminError::RefreshUnavailable => "refresh_unavailable", + CodexCredentialAdminError::RefreshUpstream { .. } => "refresh_upstream_failed", CodexCredentialAdminError::RefreshAmbiguous { .. } => "refresh_ambiguous", } } @@ -1400,17 +1431,31 @@ fn log_import_failure(stage: &'static str, error: &'static str) { fn map_oauth_error(error: CodexOAuthAdminError) -> ProviderAdminError { use CodexOAuthAdminError as Error; - provider_admin_error(match error { - Error::InvalidInput - | Error::CallbackRejected - | Error::TokenRejected - | Error::Credential => ProviderAdminErrorKind::Invalid, - Error::NotFound | Error::FlowExpired => ProviderAdminErrorKind::NotFound, - Error::Conflict | Error::Ambiguous => ProviderAdminErrorKind::Conflict, - Error::UpstreamUnavailable | Error::StorageUnavailable => { - ProviderAdminErrorKind::Unavailable - } - }) + use ProviderAdminErrorKind as Kind; + let (kind, message) = match error { + Error::InvalidInput => (Kind::Invalid, "OpenAI 授权参数不合法,请重新发起授权"), + Error::CallbackRejected => (Kind::Invalid, "OpenAI 授权回调校验失败,请重新发起授权"), + Error::TokenRejected => (Kind::Invalid, "OpenAI 拒绝了授权码,请重新发起授权"), + Error::Credential => (Kind::Invalid, "OpenAI 授权返回的凭据不完整或无效"), + Error::NotFound | Error::FlowExpired => ( + Kind::NotFound, + "OpenAI 授权流程不存在或已过期,请重新发起授权", + ), + Error::Conflict => ( + Kind::Conflict, + "OpenAI 授权流程正在处理或已被更新,请先检查授权状态", + ), + Error::Ambiguous => ( + Kind::Ambiguous, + "OpenAI 授权结果未知,请先核对账号状态,不要立即重复提交", + ), + Error::UpstreamUnavailable => ( + Kind::Unavailable, + "OpenAI 授权服务暂不可用,请检查出站连接后重试", + ), + Error::StorageUnavailable => (Kind::Unavailable, "OpenAI 授权状态存储暂不可用,请稍后重试"), + }; + provider_admin_error(kind).with_public_message(message) } const fn oauth_error_code(error: &CodexOAuthAdminError) -> &'static str { @@ -1430,15 +1475,24 @@ const fn oauth_error_code(error: &CodexOAuthAdminError) -> &'static str { fn map_quota_error(error: CodexCredentialQuotaError) -> ProviderAdminError { use CodexCredentialQuotaError as Error; - provider_admin_error(match error { - Error::InvalidCredentialData => ProviderAdminErrorKind::Invalid, - Error::NotFound => ProviderAdminErrorKind::NotFound, - Error::RevisionConflict => ProviderAdminErrorKind::Conflict, - Error::CredentialRefreshRequired - | Error::Repository(_) - | Error::Store { .. } - | Error::Upstream { .. } => ProviderAdminErrorKind::Unavailable, - }) + use ProviderAdminErrorKind as Kind; + let (kind, message) = match error { + Error::InvalidCredentialData => (Kind::Invalid, "OpenAI 额度查询凭据无效,请检查账号授权"), + Error::NotFound => (Kind::NotFound, "OpenAI 额度查询账号不存在"), + Error::RevisionConflict => (Kind::Conflict, "账号凭据已被更新,请刷新账号列表后重试"), + Error::CredentialRefreshRequired => ( + Kind::Unavailable, + "OpenAI 额度查询需要有效凭据,请刷新令牌后重试", + ), + Error::Repository(_) | Error::Store { .. } => { + (Kind::Unavailable, "OpenAI 额度查询的依赖服务暂不可用") + } + Error::Upstream { .. } => ( + Kind::Unavailable, + "OpenAI 额度查询失败,请检查出站连接与上游服务", + ), + }; + provider_admin_error(kind).with_public_message(message) } fn map_profile_statistics_error(error: CodexProfileStatisticsError) -> ProviderAdminError { @@ -1451,7 +1505,8 @@ fn map_profile_statistics_error(error: CodexProfileStatisticsError) -> ProviderA provider_admin_error(ProviderAdminErrorKind::Unavailable) } Error::CredentialRefreshRequired { upstream_body } => { - let mut error = provider_admin_error(ProviderAdminErrorKind::CredentialRefreshRequired); + let mut error = provider_admin_error(ProviderAdminErrorKind::CredentialRefreshRequired) + .with_public_message("OpenAI 资料查询需要有效凭据,请刷新令牌后重试"); if let Some(body) = upstream_body { error = error.with_message(format!( "OpenAI profile-statistics upstream returned HTTP 401: {}", @@ -1468,10 +1523,12 @@ fn map_profile_statistics_error(error: CodexProfileStatisticsError) -> ProviderA let retry_after = retry_after_seconds .map(|seconds| format!("; retry-after={seconds}s")) .unwrap_or_default(); - provider_admin_error(ProviderAdminErrorKind::BadGateway).with_message(format!( - "OpenAI profile-statistics upstream returned HTTP {status}{retry_after}: {}", - bounded_upstream_body(&body) - )) + provider_admin_error(ProviderAdminErrorKind::BadGateway) + .with_public_message("OpenAI 资料查询失败,请检查账号授权与上游服务") + .with_message(format!( + "OpenAI profile-statistics upstream returned HTTP {status}{retry_after}: {}", + bounded_upstream_body(&body) + )) } } } @@ -1543,10 +1600,17 @@ fn bounded_upstream_body(body: &str) -> String { fn map_catalog_error(error: CodexCredentialCatalogError) -> ProviderAdminError { use CodexCredentialCatalogError as Error; - provider_admin_error(match error { - Error::InvalidCredentialData | Error::InvalidEtag => ProviderAdminErrorKind::Invalid, - Error::NoEligibleCredential => ProviderAdminErrorKind::NotFound, - Error::ConcurrentUpdate => ProviderAdminErrorKind::Conflict, - Error::Upstream { .. } | Error::Cache => ProviderAdminErrorKind::Unavailable, - }) + use ProviderAdminErrorKind as Kind; + let (kind, message) = match error { + Error::InvalidCredentialData => (Kind::Invalid, "OpenAI 模型查询凭据无效,请检查账号授权"), + Error::InvalidEtag => (Kind::Invalid, "OpenAI 模型目录版本标识无效,请重新查询"), + Error::NoEligibleCredential => (Kind::NotFound, "没有可用于查询 OpenAI 模型的账号"), + Error::ConcurrentUpdate => (Kind::Conflict, "OpenAI 模型目录正在更新,请稍后重试"), + Error::Upstream { .. } => ( + Kind::Unavailable, + "OpenAI 模型查询失败,请检查出站连接与上游服务", + ), + Error::Cache => (Kind::Unavailable, "OpenAI 模型目录缓存暂不可用"), + }; + provider_admin_error(kind).with_public_message(message) } diff --git a/backend/crates/providers/openai/src/credential/admin.rs b/backend/crates/providers/openai/src/credential/admin.rs index 0ac823dc4..a41f686ec 100644 --- a/backend/crates/providers/openai/src/credential/admin.rs +++ b/backend/crates/providers/openai/src/credential/admin.rs @@ -339,11 +339,20 @@ pub enum CodexCredentialAdminError { #[error("Codex refresh lease is unavailable")] RefreshLeaseUnavailable, #[error("Codex refresh token was rejected")] - RefreshRejected { message: Option }, + RefreshRejected { + code: Option, + message: Option, + }, #[error("Codex account is banned")] AccountBanned { message: Option }, #[error("Codex refresh service is unavailable")] RefreshUnavailable, + #[error("Codex refresh upstream returned HTTP {status}")] + RefreshUpstream { + status: u16, + code: Option, + message: Option, + }, #[error("Codex refresh send state is ambiguous")] RefreshAmbiguous { message: Option }, } @@ -352,8 +361,9 @@ impl CodexCredentialAdminError { #[must_use] pub fn upstream_message(&self) -> Option<&str> { match self { - Self::RefreshRejected { message } + Self::RefreshRejected { message, .. } | Self::AccountBanned { message } + | Self::RefreshUpstream { message, .. } | Self::RefreshAmbiguous { message } => message.as_deref(), Self::PersonalAccessToken(_) | Self::InvalidInput @@ -1020,16 +1030,29 @@ fn china_rfc3339(value: DateTime) -> String { fn map_refresh_failure(error: RefreshFailure) -> CodexCredentialAdminError { match error { - RefreshFailure::InvalidGrant { message, .. } => { - CodexCredentialAdminError::RefreshRejected { message } + RefreshFailure::InvalidGrant { message, upstream } => { + CodexCredentialAdminError::RefreshRejected { + code: upstream + .as_ref() + .and_then(|failure| failure.code()) + .map(str::to_owned), + message, + } } RefreshFailure::Banned { message, .. } => { CodexCredentialAdminError::AccountBanned { message } } RefreshFailure::RetryableTransport { .. } => CodexCredentialAdminError::RefreshUnavailable, - RefreshFailure::Transport { message, .. } => { - CodexCredentialAdminError::RefreshAmbiguous { message } - } + // Worker 的 Transport 分类还承担 401 退避;管理提示只按已收到的响应事实细分, + // 不改变后台刷新策略,也不把明确失败响应误报为租约冲突或执行结果未知。 + RefreshFailure::Transport { message, upstream } => match upstream { + Some(upstream) => CodexCredentialAdminError::RefreshUpstream { + status: upstream.status(), + code: upstream.code().map(str::to_owned), + message, + }, + None => CodexCredentialAdminError::RefreshAmbiguous { message }, + }, } } diff --git a/backend/crates/providers/openai/src/credential/oauth.rs b/backend/crates/providers/openai/src/credential/oauth.rs index dec168eae..23755dd55 100644 --- a/backend/crates/providers/openai/src/credential/oauth.rs +++ b/backend/crates/providers/openai/src/credential/oauth.rs @@ -398,7 +398,7 @@ impl AuthorizationCommitGuard for CodexOAuthAuthorizationCommitGuard { Ok(()) } else { Err(AdminError::unavailable( - "OpenAI OAuth pending claim could not be released", + "OpenAI 授权处理状态暂时无法释放,请先检查授权状态", )) } } @@ -897,7 +897,7 @@ fn map_authorization_settlement_error(error: CodexOAuthPendingStoreError) -> Adm AdminError::conflict("OpenAI OAuth pending claim conflicts with current state") } CodexOAuthPendingStoreError::Unavailable => { - AdminError::unavailable("OpenAI OAuth pending claim store is unavailable") + AdminError::unavailable("OpenAI 授权状态存储暂不可用,请稍后重试") } } } diff --git a/backend/crates/providers/openai/tests/admin.rs b/backend/crates/providers/openai/tests/admin.rs index ed4fdbe14..2bd838dfe 100644 --- a/backend/crates/providers/openai/tests/admin.rs +++ b/backend/crates/providers/openai/tests/admin.rs @@ -1603,3 +1603,315 @@ impl OAuthPendingFlowPort for TestOAuthPending { }) } } + +mod errors { + use gateway_admin::ports::provider::ProviderAdminErrorKind as Kind; + use gateway_core::provider_ports::{ + ProviderLeaseAcquisition, ProviderLeasePort, ProviderLeaseRequest, ProviderSchedulingState, + }; + + use super::*; + + #[tokio::test] + async fn manual_refresh_preserves_banned_evidence_without_promoting_401_to_terminal() { + for (status, kind, message) in [ + (400, Kind::Invalid, "OpenAI 账号已被停用,请检查账号状态"), + ( + 401, + Kind::BadGateway, + "OpenAI 拒绝了令牌刷新,请检查账号授权状态", + ), + ] { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/oauth/token")) + .respond_with(ResponseTemplate::new(status).set_body_json(json!({ + "error": { + "message": "account has been deactivated: raw-secret-marker" + } + }))) + .expect(1) + .mount(&server) + .await; + let (bundle, store, _config) = refresh_fixture(&server, false, true).await; + let before = store.account("acct_refresh_error").unwrap(); + let error = bundle + .admin_provider() + .prepare_refresh(PrepareCredentialRefresh { + account: account_record(&before), + }) + .await + .unwrap_err(); + assert_eq!(error.kind(), kind); + assert_eq!(error.public_message(), Some(message)); + assert!(!format!("{error:?} {error}").contains("raw-secret-marker")); + assert_eq!(store.account("acct_refresh_error").unwrap(), before); + } + } + + #[tokio::test] + async fn manual_refresh_reports_known_upstream_failures_without_changing_account_state() { + for (status, code, expected_kind, expected_message) in [ + ( + 401, + "refresh_token_reused", + Kind::BadGateway, + "刷新令牌已被使用,请重新授权", + ), + ( + 401, + "refresh_token_expired", + Kind::BadGateway, + "刷新令牌已过期,请重新授权", + ), + ( + 401, + "refresh_token_invalidated", + Kind::BadGateway, + "刷新令牌已被撤销,请重新授权", + ), + ( + 401, + "token_expired", + Kind::BadGateway, + "刷新令牌不可用,请重新授权", + ), + ( + 401, + "unknown", + Kind::BadGateway, + "OpenAI 拒绝了令牌刷新,请检查账号授权状态", + ), + ( + 400, + "refresh_token_reused", + Kind::Invalid, + "刷新令牌已被使用,请重新授权", + ), + ( + 400, + "refresh_token_expired", + Kind::Invalid, + "刷新令牌已过期,请重新授权", + ), + ( + 400, + "refresh_token_invalidated", + Kind::Invalid, + "刷新令牌已被撤销,请重新授权", + ), + ( + 400, + "INVALID_GRANT", + Kind::BadGateway, + "刷新令牌无效或已失效,请重新授权", + ), + ( + 429, + "unknown", + Kind::BadGateway, + "OpenAI 令牌刷新请求被限流,请稍后重试", + ), + ( + 503, + "unknown", + Kind::BadGateway, + "OpenAI 令牌刷新服务异常,请稍后重试", + ), + ] { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/oauth/token")) + .respond_with(ResponseTemplate::new(status).set_body_json(json!({ + "error": {"code": code, "message": "raw-secret-marker"} + }))) + .expect(1) + .mount(&server) + .await; + let (bundle, store, _config) = refresh_fixture(&server, false, true).await; + let before = store.account("acct_refresh_error").unwrap(); + let error = bundle + .admin_provider() + .prepare_refresh(PrepareCredentialRefresh { + account: account_record(&before), + }) + .await + .expect_err("upstream rejection"); + assert_eq!(error.kind(), expected_kind, "HTTP {status} {code}"); + assert_eq!(error.public_message(), Some(expected_message)); + assert!(!format!("{error:?} {error}").contains("raw-secret-marker")); + assert_eq!(store.account("acct_refresh_error").unwrap(), before); + } + } + + #[tokio::test] + async fn manual_refresh_distinguishes_busy_missing_token_and_stale_account_before_exchange() { + for (busy, has_token, stale, kind, message) in [ + ( + true, + true, + false, + Kind::Conflict, + "令牌刷新繁忙,请等待当前刷新完成后重试", + ), + ( + false, + false, + false, + Kind::Invalid, + "账号没有刷新令牌,请重新授权", + ), + ( + false, + true, + true, + Kind::Conflict, + "账号凭据已被更新,请刷新账号列表后重试", + ), + ] { + let server = MockServer::start().await; + let (bundle, store, _config) = refresh_fixture(&server, busy, has_token).await; + let before = store.account("acct_refresh_error").unwrap(); + let mut account = account_record(&before); + if stale { + account.upstream_user_id = Some("previous-user".to_owned()); + } + let error = bundle + .admin_provider() + .prepare_refresh(PrepareCredentialRefresh { account }) + .await + .expect_err("local refresh failure"); + assert_eq!(error.kind(), kind); + assert_eq!(error.public_message(), Some(message)); + assert!(server.received_requests().await.unwrap().is_empty()); + assert_eq!(store.account("acct_refresh_error").unwrap(), before); + } + } + + #[tokio::test] + async fn manual_refresh_invalid_success_and_unclassified_transport_stay_conservative() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/oauth/token")) + .respond_with( + ResponseTemplate::new(200).set_body_string("invalid-success-secret-marker"), + ) + .expect(1) + .mount(&server) + .await; + let (bundle, store, mut config) = refresh_fixture(&server, false, true).await; + let before = store.account("acct_refresh_error").unwrap(); + let command = || PrepareCredentialRefresh { + account: account_record(&before), + }; + let error = bundle + .admin_provider() + .prepare_refresh(command()) + .await + .unwrap_err(); + assert_eq!(error.kind(), Kind::Ambiguous); + assert_eq!( + error.public_message(), + Some("令牌刷新结果未知,请先核对账号状态,不要立即重复刷新") + ); + assert!(!format!("{error:?}").contains("invalid-success-secret-marker")); + + config.config.auth.oauth_token_endpoint = "http://127.0.0.1:0/oauth/token".to_owned(); + let bundle = + provider_openai::initialize(config.config, refresh_ports(store.clone(), false)) + .await + .unwrap(); + let error = bundle + .admin_provider() + .prepare_refresh(command()) + .await + .unwrap_err(); + // 既有 transport 策略未认定此错误为安全重试,本次不能因展示更详细而放宽重试边界。 + assert_eq!(error.kind(), Kind::Ambiguous); + assert_eq!( + error.public_message(), + Some("令牌刷新结果未知,请先核对账号状态,不要立即重复刷新") + ); + assert_eq!(store.account("acct_refresh_error").unwrap(), before); + } + + async fn refresh_fixture( + server: &MockServer, + busy: bool, + has_token: bool, + ) -> ( + provider_openai::ProviderBundle, + Arc, + TestOpenAiConfig, + ) { + let store = Arc::new(MemoryAccountStore::default()); + let mut credential = secret("synthetic-refresh-access"); + if !has_token { + credential.refresh_token = None; + } + store + .seed_oauth_credential(ImportCodexOAuthCredential { + account_id: "acct_refresh_error".to_owned(), + name: "refresh error test".to_owned(), + secret: credential, + verified_account: profile("synthetic-refresh-user"), + next_refresh_at: None, + enabled: true, + }) + .await; + let mut config = valid_config(); + config.config.auth.oauth_token_endpoint = format!("{}/oauth/token", server.uri()); + let bundle = + provider_openai::initialize(config.config.clone(), refresh_ports(store.clone(), busy)) + .await + .unwrap(); + (bundle, store, config) + } + + fn refresh_ports(store: Arc, busy: bool) -> ProviderStorePorts { + ProviderStorePorts::new( + store, + Arc::new(RefreshLeases { busy }), + Arc::new(MemorySessionAffinity::default()), + Arc::new(MemorySessionExclusions::default()), + Arc::new(TestCatalogCache::default()), + Arc::new(TestArtifactProfiles), + Arc::new(TestCredentialState), + Arc::new(TestCooldown), + Arc::new(TestRuntimePolicy), + Arc::new(TestOAuthPending::default()), + ) + } + + struct RefreshLeases { + busy: bool, + } + + impl ProviderLeasePort for RefreshLeases { + fn load_state<'a>( + &'a self, + _: &'a ClientApiKeyId, + _: &'a ProviderKind, + _: &'a [ProviderAccountId], + ) -> BoxFuture<'a, Result> { + panic!("manual refresh does not use scheduling leases") + } + + fn try_acquire( + &self, + request: ProviderLeaseRequest, + ) -> BoxFuture<'_, Result> { + assert!(matches!( + request, + ProviderLeaseRequest::Refresh(_) | ProviderLeaseRequest::RefreshCapacity(_) + )); + Box::pin(async move { + Ok(if self.busy { + ProviderLeaseAcquisition::Busy { retry_after: None } + } else { + ProviderLeaseAcquisition::Acquired(Box::new(())) + }) + }) + } + } +} diff --git a/backend/crates/providers/xai/src/admin.rs b/backend/crates/providers/xai/src/admin.rs index 54d7fdce3..b324526b5 100644 --- a/backend/crates/providers/xai/src/admin.rs +++ b/backend/crates/providers/xai/src/admin.rs @@ -124,7 +124,7 @@ impl AuthorizationCommitGuard for XaiAuthorizationCommitGuard { OAuthPendingReleaseOutcome::NotFound | OAuthPendingReleaseOutcome::OwnerMismatch | OAuthPendingReleaseOutcome::ClaimMismatch => Err(AdminError::unavailable( - "xAI OAuth pending claim could not be released", + "xAI 授权处理状态暂时无法释放,请先检查授权状态", )), } } @@ -611,7 +611,8 @@ impl ProviderAdmin for XaiAdminProvider { .await .map_err(map_store_error)?; if !account_matches_record(¤t.account, &command.account) { - return Err(provider_error(ProviderAdminErrorKind::Conflict)); + return Err(provider_error(ProviderAdminErrorKind::Conflict) + .with_public_message("账号凭据已被更新,请刷新账号列表后重试")); } let rotation: RotationDocument = serde_json::from_value(Value::Object( command.provider_material.into_provider_data().into_inner(), @@ -664,7 +665,8 @@ impl ProviderAdmin for XaiAdminProvider { .await .map_err(map_store_error)?; if !account_matches_record(¤t.account, &command.account) { - return Err(provider_error(ProviderAdminErrorKind::Conflict)); + return Err(provider_error(ProviderAdminErrorKind::Conflict) + .with_public_message("账号凭据已被更新,请刷新账号列表后重试")); } let prepared = self .refresh @@ -1347,15 +1349,28 @@ fn build_connection_test_operation( } fn map_failure_class(class: FailureClass) -> ProviderAdminError { - provider_error(match class { - FailureClass::Transient => ProviderAdminErrorKind::Unavailable, - FailureClass::Ambiguous => ProviderAdminErrorKind::Conflict, - FailureClass::CredentialPermanent - | FailureClass::ConfigurationPermanent - | FailureClass::UserActionRequired - | FailureClass::Security => ProviderAdminErrorKind::Invalid, - FailureClass::Unsupported => ProviderAdminErrorKind::Unsupported, - }) + use ProviderAdminErrorKind as Kind; + let (kind, message) = match class { + FailureClass::Transient => ( + Kind::Unavailable, + "xAI 授权服务暂不可用,请检查出站连接后重试", + ), + FailureClass::Ambiguous => ( + Kind::Ambiguous, + "xAI 授权结果未知,请先核对账号状态,不要立即重复提交", + ), + FailureClass::CredentialPermanent => (Kind::Invalid, "xAI 拒绝了当前凭据,请检查账号授权"), + FailureClass::ConfigurationPermanent => { + (Kind::Invalid, "xAI 授权配置无效,请检查客户端与授权配置") + } + FailureClass::UserActionRequired => (Kind::Invalid, "xAI 授权需要用户确认,请重新完成授权"), + FailureClass::Security => ( + Kind::Invalid, + "xAI 授权安全校验失败,请检查授权来源与账号信息", + ), + FailureClass::Unsupported => (Kind::Unsupported, "xAI 不支持当前授权操作"), + }; + provider_error(kind).with_public_message(message) } fn map_oauth_error(error: OAuthError) -> ProviderAdminError { @@ -1379,7 +1394,7 @@ fn map_pending_claim_settlement_error(error: ProviderStoreError) -> AdminError { AdminError::conflict("xAI OAuth pending claim conflicts with current state") } ProviderStoreErrorKind::Unavailable => { - AdminError::unavailable("xAI OAuth pending claim store is unavailable") + AdminError::unavailable("xAI 授权状态存储暂不可用,请稍后重试") } } } @@ -1411,41 +1426,71 @@ fn map_repository_error(error: GrokCredentialRepositoryError) -> ProviderAdminEr } fn map_refresh_error(error: GrokCredentialRefreshError) -> ProviderAdminError { + use crate::GrokRefreshFailure as Failure; use GrokCredentialRefreshError as Error; - match error { - Error::Repository(error) => map_repository_error(error), - Error::Lease(error) => map_provider_store_error(error), - Error::LeaseBusy => provider_error(ProviderAdminErrorKind::Conflict), - Error::InvalidRefreshResponse => provider_error(ProviderAdminErrorKind::Invalid), - Error::Preparation => provider_error(ProviderAdminErrorKind::Unavailable), - Error::ManualFailure(failure) => map_failure_class(match failure { - crate::GrokRefreshFailure::Transient => FailureClass::Transient, - crate::GrokRefreshFailure::Ambiguous => FailureClass::Ambiguous, - crate::GrokRefreshFailure::InvalidGrant - | crate::GrokRefreshFailure::Banned - | crate::GrokRefreshFailure::Rejected => FailureClass::CredentialPermanent, - }), - } + use ProviderAdminErrorKind as Kind; + let (kind, message) = match error { + Error::Repository(error) => return map_repository_error(error), + Error::Lease(error) => return map_provider_store_error(error), + Error::LeaseBusy => (Kind::Conflict, "令牌刷新繁忙,请等待当前刷新完成后重试"), + Error::InvalidRefreshResponse => ( + Kind::Ambiguous, + "xAI 返回的刷新凭据无效,请先核对账号状态,不要立即重复刷新", + ), + Error::Preparation => (Kind::Unavailable, "xAI 令牌刷新服务尚未就绪,请稍后重试"), + Error::ManualFailure(failure) => match failure { + Failure::Transient => ( + Kind::Unavailable, + "xAI 令牌刷新连接失败,请检查出站连接后重试", + ), + Failure::Ambiguous => ( + Kind::Ambiguous, + "令牌刷新结果未知,请先核对账号状态,不要立即重复刷新", + ), + Failure::InvalidGrant => (Kind::Invalid, "刷新令牌已失效,请重新授权"), + Failure::Banned => (Kind::Invalid, "xAI 账号已被停用,请检查账号状态"), + Failure::Rejected => ( + Kind::BadGateway, + "xAI 未能完成令牌刷新,请检查授权配置与上游服务", + ), + }, + }; + provider_error(kind).with_public_message(message) } fn map_quota_error(error: GrokQuotaError) -> ProviderAdminError { use GrokQuotaError as Error; - provider_error(match error { - Error::AccountUnavailable => ProviderAdminErrorKind::NotFound, - Error::StaleCredentialSnapshot => ProviderAdminErrorKind::Conflict, - Error::InvalidData => ProviderAdminErrorKind::Invalid, - Error::Upstream | Error::Store => ProviderAdminErrorKind::Unavailable, - }) + use ProviderAdminErrorKind as Kind; + let (kind, message) = match error { + Error::AccountUnavailable => (Kind::NotFound, "没有可用于查询 xAI 额度的账号"), + Error::StaleCredentialSnapshot => { + (Kind::Conflict, "账号凭据已被更新,请刷新账号列表后重试") + } + Error::InvalidData => (Kind::Invalid, "xAI 额度数据无效,请检查账号授权"), + Error::Upstream => ( + Kind::Unavailable, + "xAI 额度查询失败,请检查出站连接与上游服务", + ), + Error::Store => (Kind::Unavailable, "xAI 额度查询的依赖服务暂不可用"), + }; + provider_error(kind).with_public_message(message) } fn map_catalog_error(error: GrokCredentialCatalogError) -> ProviderAdminError { use GrokCredentialCatalogError as Error; - provider_error(match error { - Error::InvalidCredentialData | Error::ConflictingModelFacts => { - ProviderAdminErrorKind::Invalid + use ProviderAdminErrorKind as Kind; + let (kind, message) = match error { + Error::InvalidCredentialData => (Kind::Invalid, "xAI 模型查询凭据无效,请检查账号授权"), + Error::ConflictingModelFacts => (Kind::Invalid, "xAI 模型目录信息不一致,请重新查询"), + Error::NoEligibleCredential => (Kind::NotFound, "没有可用于查询 xAI 模型的账号"), + Error::StaleCredentialSnapshot => { + (Kind::Conflict, "账号凭据已被更新,请刷新账号列表后重试") } - Error::NoEligibleCredential => ProviderAdminErrorKind::NotFound, - Error::StaleCredentialSnapshot => ProviderAdminErrorKind::Conflict, - Error::Upstream | Error::Cache | Error::Store => ProviderAdminErrorKind::Unavailable, - }) + Error::Upstream => ( + Kind::Unavailable, + "xAI 模型查询失败,请检查出站连接与上游服务", + ), + Error::Cache | Error::Store => (Kind::Unavailable, "xAI 模型查询的依赖服务暂不可用"), + }; + provider_error(kind).with_public_message(message) } diff --git a/backend/crates/providers/xai/tests/admin.rs b/backend/crates/providers/xai/tests/admin.rs index e4ae5dc9d..569a73653 100644 --- a/backend/crates/providers/xai/tests/admin.rs +++ b/backend/crates/providers/xai/tests/admin.rs @@ -1016,3 +1016,167 @@ impl OAuthPendingFlowPort for TestOAuthPending { }) } } + +mod errors { + use gateway_admin::ports::provider::ProviderAdminErrorKind as Kind; + use gateway_core::account::OutboundProxy; + + use super::*; + + #[tokio::test] + async fn manual_refresh_keeps_expired_and_changed_credentials_distinct_without_network() { + for expired in [true, false] { + let store = MemoryProviderAccountStore::shared(); + let mut input = create_input("refresh_error", "synthetic-subject"); + if expired { + let now = Utc::now(); + input.account.access_token_expires_at = now + chrono::Duration::seconds(1); + input.account.refresh_token_expires_at = Some(now + chrono::Duration::seconds(2)); + } + seed_input(&store, &input).await.unwrap(); + if expired { + // 新导入不接受已过期凭据;等待合法存入的短期凭据自然过期,验证历史账号刷新。 + let remaining = input.account.refresh_token_expires_at.unwrap() - Utc::now(); + tokio::time::sleep( + remaining.to_std().unwrap_or_default() + Duration::from_millis(10), + ) + .await; + } + let before = store.account(&input.account_id).unwrap(); + let mut account = account_record(&before); + if !expired { + account.upstream_user_id = Some("previous-subject".to_owned()); + } + let bundle = provider_xai::initialize( + xai_config(), + provider_ports_with(store.clone(), Arc::new(TestOAuthPending::default())), + ) + .await + .unwrap(); + let error = bundle + .admin_provider() + .prepare_refresh(PrepareCredentialRefresh { account }) + .await + .unwrap_err(); + assert_eq!( + error.kind(), + if expired { + Kind::Invalid + } else { + Kind::Conflict + } + ); + assert_eq!( + error.public_message(), + Some(if expired { + "刷新令牌已失效,请重新授权" + } else { + "账号凭据已被更新,请刷新账号列表后重试" + }) + ); + assert_eq!(store.account(&input.account_id).unwrap(), before); + } + } + + #[tokio::test] + async fn manual_refresh_reports_busy_without_calling_the_account_proxy() { + let store = MemoryProviderAccountStore::shared(); + let input = create_input("refresh_busy", "synthetic-subject"); + seed_input(&store, &input).await.unwrap(); + // 账号使用出站代理时不提前准备直连 discovery;占用失败不得触发任何代理请求。 + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let proxy = + OutboundProxy::parse(&format!("http://{}", listener.local_addr().unwrap())).unwrap(); + store.set_outbound_proxy(&input.account_id, Some(proxy)); + let before = store.account(&input.account_id).unwrap(); + let mut account = account_record(&before); + account.outbound_proxy = before.outbound_proxy().cloned(); + let ports = ProviderStorePorts::new( + store.clone(), + Arc::new(BusyRefreshLeases), + Arc::new(TestSessionAffinity), + Arc::new(TestSessionExclusions), + Arc::new(TestCatalogCache::default()), + Arc::new(TestArtifactProfiles), + Arc::new(TestCredentialState), + Arc::new(TestCooldown), + Arc::new(TestRuntimePolicy), + Arc::new(TestOAuthPending::default()), + ); + let bundle = provider_xai::initialize(xai_config(), ports).await.unwrap(); + let error = bundle + .admin_provider() + .prepare_refresh(PrepareCredentialRefresh { account }) + .await + .unwrap_err(); + assert_eq!(error.kind(), Kind::Conflict); + assert_eq!( + error.public_message(), + Some("令牌刷新繁忙,请等待当前刷新完成后重试") + ); + assert_eq!( + listener.accept().unwrap_err().kind(), + std::io::ErrorKind::WouldBlock + ); + assert_eq!(store.account(&input.account_id).unwrap(), before); + } + + #[tokio::test] + async fn manual_refresh_unavailable_proxy_does_not_expose_proxy_credentials() { + let store = MemoryProviderAccountStore::shared(); + let input = create_input("refresh_proxy", "synthetic-subject"); + seed_input(&store, &input).await.unwrap(); + let proxy = + OutboundProxy::parse("http://synthetic-user:proxy-secret-marker@127.0.0.1:1").unwrap(); + store.set_outbound_proxy(&input.account_id, Some(proxy)); + let before = store.account(&input.account_id).unwrap(); + let mut account = account_record(&before); + account.outbound_proxy = before.outbound_proxy().cloned(); + let bundle = provider_xai::initialize( + xai_config(), + provider_ports_with(store.clone(), Arc::new(TestOAuthPending::default())), + ) + .await + .unwrap(); + let error = bundle + .admin_provider() + .prepare_refresh(PrepareCredentialRefresh { account }) + .await + .unwrap_err(); + assert_eq!(error.kind(), Kind::Unavailable); + assert_eq!( + error.public_message(), + Some("xAI 令牌刷新连接失败,请检查出站连接后重试") + ); + assert!(!format!("{error:?} {error}").contains("proxy-secret-marker")); + assert_eq!(store.account(&input.account_id).unwrap(), before); + } + + struct BusyRefreshLeases; + + impl ProviderLeasePort for BusyRefreshLeases { + fn load_state<'a>( + &'a self, + _: &'a ClientApiKeyId, + _: &'a ProviderKind, + _: &'a [ProviderAccountId], + ) -> BoxFuture< + 'a, + Result, + > { + panic!("manual refresh does not use scheduling state") + } + + fn try_acquire( + &self, + request: ProviderLeaseRequest, + ) -> BoxFuture<'_, Result> { + assert!(matches!( + request, + ProviderLeaseRequest::RefreshCapacity(_) | ProviderLeaseRequest::Refresh(_) + )); + Box::pin(async { Ok(ProviderLeaseAcquisition::Busy { retry_after: None }) }) + } + } +} diff --git a/docs/api.md b/docs/api.md index a62081df4..d12b60f6a 100644 --- a/docs/api.md +++ b/docs/api.md @@ -99,6 +99,21 @@ body 不进入这个通用信封。稳定业务码如下: 未知 `/api/admin/*` 路径使用 `40401`,不会落入 SPA;已存在路径使用错误 method 时返回 `405`、 `40001`,并保留标准 `Allow` header。request ID 继续通过配置的响应 header 返回。 +Provider 管理适配使用静态 `public_message` 提供可操作的具体原因,Admin 用例完成安全消息选择后, +API 对 `50201`、`50202` 和 `50301` 也保留该消息,不再用固定错误覆盖;缺少安全消息时仍回退到通用提示。 +认证错误和未知内部错误继续使用固定文案,不公开 Provider 内部 message、原始响应或凭据。 + +手动刷新令牌时,容量/账号租约占用和账号快照冲突仍为 `40901`,但分别提示等待或刷新账号列表。 +OpenAI 已收到的刷新失败响应不再统一归为资源冲突:原先落入宽泛 Transport 分类的明确拒绝使用 +`50201`,按已解析的错误码区分令牌过期、已使用、已撤销和 `invalid_grant`;刷新接口返回 +`token_expired` 时提示“刷新令牌不可用,请重新授权”,不据此断言具体失效原因。无法确认刷新结果时使用 +`50202`,提示先核对账号状态、不要立即重复刷新。缺少刷新令牌及原有明确凭据无效分支仍为 `40001`。 +上游 `401` 不代表管理员会话失效,也不会触发管理端重新登录。上述变化只修正管理错误的分类和展示, +不改变后台自动刷新、401 恢复退避或账号终态策略;客户端不得仅因状态码从 `409` 改为 `502` 自动重发刷新。 +xAI 手动刷新返回无效的新凭据时,从 `40001` 改为 `50202`,因为上游可能已经轮换了旧 RT; +未能完成刷新、但没有明确凭据永久失效证据的 `Rejected` 从 `40001` 改为 `50201`,不再一概提示凭据无效。 +Codex PAT 验证服务不可用和身份响应无效分别通过 `50301`、`50201` 保留原有具体提示。 + ### 管理写入一致性 管理写入不要求客户端提供全局配置版本。会改变路由快照或安全配置的写入由后端在事务内推进 diff --git a/docs/architecture.md b/docs/architecture.md index 952632e8a..7f18c4ca0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -249,6 +249,13 @@ Proxy changes advance the runtime configuration revision without invalidating in 3. **原始上游诊断**:只在连接测试、运维错误详情等明确诊断界面展示已经捕获的原始字段,不翻译、不补造, 也不塞入普通 Admin 错误信封。原始 body 不进入 Debug、普通日志或持久化错误消息。 +Provider 管理适配在仍持有结构化失败事实时选择静态 `public_message`;Admin 将其转为可安全展示的 +`AdminError.message`,API 保留其中的 502/503 具体原因,无公开消息时才使用通用回退。认证与未知 +内部异常仍保持固定文案,不能把任意 Provider/Store 的 message 直接放行。 +管理提示与 Worker 的处置分类是不同职责:例如 OpenAI 刷新已收到 401 时,可以提示已解析的令牌 +拒绝原因,但不因此改变现有有界恢复退避和账号终态判定。刷新繁忙、已知上游失败与结果未知分别映射为 +409、50201、50202;结果未知不能触发自动重放一次性凭据,Vue 也不重新解析上游错误码或正文。 + 连接测试的 `gateway` / `provider` / `upstream` 来源以及 `not_sent` / `sent` / `ambiguous` 发送状态由 Core 在 仍持有完整执行错误时一次判定;Vue 只能根据稳定字段生成摘要,不能匹配英文错误句子反推来源。