diff --git a/backend/crates/gateway-admin/src/use_case/accounts.rs b/backend/crates/gateway-admin/src/use_case/accounts.rs index 9b538cf9d..88af1f916 100644 --- a/backend/crates/gateway-admin/src/use_case/accounts.rs +++ b/backend/crates/gateway-admin/src/use_case/accounts.rs @@ -565,11 +565,7 @@ impl AccountsService for DefaultAccountsService { let upstream_body = error .upstream_response() .map(|response| String::from_utf8_lossy(response.body()).into_owned()); - let message = upstream_body - .as_deref() - .filter(|body| !body.is_empty()) - .unwrap_or_else(|| error.client_message()) - .to_owned(); + let message = error.client_message().to_owned(); vec![AccountConnectionTestEvent::Failed { message, provider_error_code: error.client_error_code().map(ToOwned::to_owned), diff --git a/backend/crates/gateway-core/src/engine/credential.rs b/backend/crates/gateway-core/src/engine/credential.rs index 9c0f10eb4..09408c7fc 100644 --- a/backend/crates/gateway-core/src/engine/credential.rs +++ b/backend/crates/gateway-core/src/engine/credential.rs @@ -1119,7 +1119,8 @@ pub struct AccountStateChange { pub observed_at: SystemTime, /// 受控错误原因;恢复 Ready 时必须清空。 pub error_reason: Option, - /// 已脱敏的上游诊断信息;恢复 Ready 时必须清空。 + /// 供管理端展示的错误消息;结构化上游失败应保留原始 message,不能写入整个正文。 + /// 恢复 Ready 时必须清空。 pub message: Option, } diff --git a/backend/crates/providers/openai/src/credential/admin.rs b/backend/crates/providers/openai/src/credential/admin.rs index 32020dfa5..b3c51c791 100644 --- a/backend/crates/providers/openai/src/credential/admin.rs +++ b/backend/crates/providers/openai/src/credential/admin.rs @@ -1133,10 +1133,10 @@ fn china_rfc3339(value: DateTime) -> String { .to_rfc3339() } -const fn map_refresh_failure(error: RefreshFailure) -> CodexCredentialAdminError { +fn map_refresh_failure(error: RefreshFailure) -> CodexCredentialAdminError { match error { - RefreshFailure::InvalidGrant => CodexCredentialAdminError::RefreshRejected, - RefreshFailure::Banned => CodexCredentialAdminError::AccountBanned, + RefreshFailure::InvalidGrant { .. } => CodexCredentialAdminError::RefreshRejected, + RefreshFailure::Banned { .. } => CodexCredentialAdminError::AccountBanned, RefreshFailure::RetryableTransport => CodexCredentialAdminError::RefreshUnavailable, RefreshFailure::Transport => CodexCredentialAdminError::RefreshAmbiguous, } diff --git a/backend/crates/providers/openai/src/credential/refresh.rs b/backend/crates/providers/openai/src/credential/refresh.rs index cf0f74048..72bffd088 100644 --- a/backend/crates/providers/openai/src/credential/refresh.rs +++ b/backend/crates/providers/openai/src/credential/refresh.rs @@ -258,6 +258,7 @@ impl CodexCredentialRefreshService { CredentialState::Expired, AccountErrorReason::CredentialExpired, "refresh_recovery_window_exhausted", + None, CodexCredentialRefreshOutcome::Invalidated { account_id }, ) .await; @@ -293,22 +294,24 @@ impl CodexCredentialRefreshService { .ok_or(CodexCredentialRefreshError::InvalidRefreshResponse)?; match self.refresher.refresh(refresh_token.expose_secret()).await { Ok(tokens) => self.persist_success(due, tokens).await, - Err(RefreshFailure::InvalidGrant) => { + Err(RefreshFailure::InvalidGrant { message }) => { self.persist_terminal( &due.account, CredentialState::Expired, AccountErrorReason::CredentialExpired, "refresh_invalid_grant", + message, CodexCredentialRefreshOutcome::Invalidated { account_id }, ) .await } - Err(RefreshFailure::Banned) => { + Err(RefreshFailure::Banned { message }) => { self.persist_terminal( &due.account, CredentialState::Banned, AccountErrorReason::AccountBanned, "account_banned", + message, CodexCredentialRefreshOutcome::Banned { account_id }, ) .await @@ -484,6 +487,7 @@ impl CodexCredentialRefreshService { credential_state: CredentialState, error_reason: AccountErrorReason, reason: &'static str, + message: Option, outcome: CodexCredentialRefreshOutcome, ) -> Result { match self.repository.load_runtime_credential(account).await { @@ -502,7 +506,7 @@ impl CodexCredentialRefreshService { credential_state, SystemTime::now(), Some(error_reason), - Some(reason.to_owned()), + Some(message.unwrap_or_else(|| reason.to_owned())), ) .await { diff --git a/backend/crates/providers/openai/src/credential/token_client.rs b/backend/crates/providers/openai/src/credential/token_client.rs index 93c372b9f..3520f264b 100644 --- a/backend/crates/providers/openai/src/credential/token_client.rs +++ b/backend/crates/providers/openai/src/credential/token_client.rs @@ -51,12 +51,12 @@ impl fmt::Debug for TokenPair { } /// Codex token 刷新的稳定失败分类。 -#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] pub enum RefreshFailure { #[error("refresh token is invalid or expired")] - InvalidGrant, + InvalidGrant { message: Option }, #[error("account is banned")] - Banned, + Banned { message: Option }, #[error("refresh transport failed before server processing")] RetryableTransport, #[error("refresh transport failed after possible server processing")] @@ -186,6 +186,45 @@ struct RefreshTokenResponse { expires_in: Option, } +#[derive(Deserialize)] +struct RefreshErrorResponse { + error: Option, + code: Option, +} + +#[derive(Deserialize)] +#[serde(untagged)] +enum RefreshError { + Details(RefreshErrorDetails), + Code(String), +} + +#[derive(Deserialize)] +struct RefreshErrorDetails { + code: Option, + message: Option, +} + +impl RefreshErrorResponse { + fn code(&self) -> Option<&str> { + match self.error.as_ref() { + Some(RefreshError::Details(error)) => error.code.as_deref(), + Some(RefreshError::Code(code)) => Some(code.as_str()), + None => None, + } + .or(self.code.as_deref()) + } + + fn message(&self) -> Option { + match self.error.as_ref() { + Some(RefreshError::Details(error)) => error.message.as_deref(), + Some(RefreshError::Code(_)) | None => None, + } + .filter(|message| !message.trim().is_empty()) + .map(ToOwned::to_owned) + } +} + #[derive(Deserialize)] struct AuthorizationCodeResponse { access_token: String, @@ -317,18 +356,32 @@ fn classify_refresh_failure(status: StatusCode, body: &[u8]) -> RefreshFailure { if status.is_server_error() || status == StatusCode::TOO_MANY_REQUESTS { return RefreshFailure::Transport; } - // 4xx 正文才是权威 OAuth 错误响应,据其区分真过期/封禁与其余瞬态。 - let lower = String::from_utf8_lossy(body).to_ascii_lowercase(); - if lower.contains("account has been deactivated") { - return RefreshFailure::Banned; + // 官方刷新错误的消息与错误码分别位于 `error.message`、`error.code`; + // `error` 字符串与顶层 `code` 仅用于兼容官方客户端自身的错误码提取契约。 + let Ok(error) = serde_json::from_slice::(body) else { + return RefreshFailure::Transport; + }; + let message = error.message(); + if message.as_deref().is_some_and(|message| { + message + .to_ascii_lowercase() + .contains("account has been deactivated") + }) { + return RefreshFailure::Banned { message }; } - if lower.contains("invalid_grant") - || lower.contains("invalid_token") - || lower.contains("access_denied") - || lower.contains("refresh_token_expired") - || lower.contains("refresh_token_reused") - { - return RefreshFailure::InvalidGrant; + let normalized_code = error.code().map(str::to_ascii_lowercase); + if matches!( + normalized_code.as_deref(), + Some( + "invalid_grant" + | "invalid_token" + | "access_denied" + | "refresh_token_expired" + | "refresh_token_reused" + | "refresh_token_invalidated" + ) + ) { + return RefreshFailure::InvalidGrant { message }; } RefreshFailure::Transport } diff --git a/backend/crates/providers/openai/tests/credential/refresh.rs b/backend/crates/providers/openai/tests/credential/refresh.rs index acd0a6512..7d5beeaee 100644 --- a/backend/crates/providers/openai/tests/credential/refresh.rs +++ b/backend/crates/providers/openai/tests/credential/refresh.rs @@ -82,6 +82,17 @@ impl TokenRefresher for SingleUseRefresher { } } +struct FailingRefresher { + failure: RefreshFailure, +} + +#[async_trait] +impl TokenRefresher for FailingRefresher { + async fn refresh(&self, _: &str) -> Result { + Err(self.failure.clone()) + } +} + struct RefreshLeases; impl ProviderLeasePort for RefreshLeases { @@ -267,6 +278,50 @@ async fn scheduled_refresh_uses_the_current_margin_without_persisting_a_normal_s ); } +#[tokio::test] +async fn scheduled_refresh_persists_the_original_upstream_error_message() { + let store = Arc::new(MemoryAccountStore::default()); + let policy = MutableRuntimePolicy::new(Duration::from_secs(5 * 60)); + let upstream_message = "Refresh token has already been used."; + let service = CodexCredentialRefreshService::new( + store.repository(), + Arc::new(FailingRefresher { + failure: RefreshFailure::InvalidGrant { + message: Some(upstream_message.to_owned()), + }, + }), + Arc::new(RefreshLeases), + Arc::new(RefreshCredentialState), + policy, + ); + let account_id = "acct_invalid_refresh_token"; + seed_refreshable_account( + &store, + account_id, + SystemTime::now() + .checked_add(Duration::from_secs(120)) + .expect("test expiry"), + None, + ) + .await; + + let outcomes = service.refresh_due().await.expect("refresh cycle"); + + assert!(matches!( + outcomes.as_slice(), + [CodexCredentialRefreshOutcome::Invalidated { + account_id: invalidated_account_id, + }] if invalidated_account_id == account_id + )); + assert_eq!( + store + .account(account_id) + .expect("invalidated account") + .last_error_message(), + Some(upstream_message) + ); +} + #[tokio::test] async fn scheduled_refresh_persists_a_returned_id_token() { let store = Arc::new(MemoryAccountStore::default()); diff --git a/backend/crates/providers/openai/tests/credential/token_client.rs b/backend/crates/providers/openai/tests/credential/token_client.rs index 60a90e2d4..77b488bf4 100644 --- a/backend/crates/providers/openai/tests/credential/token_client.rs +++ b/backend/crates/providers/openai/tests/credential/token_client.rs @@ -273,24 +273,46 @@ async fn authorization_code_exchange_requires_id_token_and_json_response() { #[tokio::test] async fn refresh_token_reuse_should_be_classified_as_invalid_grant() { + let upstream_message = "Your refresh token has already been used to generate a new access token. Please try signing in again."; let failure = refresh_failure( 400, - r#"{"error":"invalid_grant","error_description":"refresh_token_reused"}"#, + r#"{"error":{"message":"Your refresh token has already been used to generate a new access token. Please try signing in again.","type":"invalid_request_error","param":null,"code":"refresh_token_reused"}}"#, ) .await; - assert_eq!(failure, RefreshFailure::InvalidGrant); + assert_eq!( + failure, + RefreshFailure::InvalidGrant { + message: Some(upstream_message.to_owned()) + } + ); +} + +#[tokio::test] +async fn oauth_error_description_should_not_be_used_as_the_upstream_message() { + let failure = refresh_failure( + 400, + r#"{"error":"invalid_grant","error_description":"not the official refresh error message field"}"#, + ) + .await; + + assert_eq!(failure, RefreshFailure::InvalidGrant { message: None }); } #[tokio::test] async fn deactivated_account_should_be_classified_as_banned() { let failure = refresh_failure( 403, - r#"{"error":"access_denied","error_description":"account has been deactivated"}"#, + r#"{"error":{"code":"access_denied","message":"account has been deactivated"}}"#, ) .await; - assert_eq!(failure, RefreshFailure::Banned); + assert_eq!( + failure, + RefreshFailure::Banned { + message: Some("account has been deactivated".to_owned()) + } + ); } #[tokio::test] @@ -328,7 +350,7 @@ async fn server_error_or_rate_limit_must_stay_transient_even_with_oauth_error_bo for status in [500, 502, 503, 429] { let failure = refresh_failure( status, - r#"{"error":"invalid_grant","error_description":"refresh_token_expired"}"#, + r#"{"error":{"code":"refresh_token_expired","message":"Refresh token expired."}}"#, ) .await; assert_eq!( diff --git a/frontend/src/views/accounts/composables/useAccountConnectionTest.ts b/frontend/src/views/accounts/composables/useAccountConnectionTest.ts index 4b3d7b8a5..95048f67d 100644 --- a/frontend/src/views/accounts/composables/useAccountConnectionTest.ts +++ b/frontend/src/views/accounts/composables/useAccountConnectionTest.ts @@ -42,26 +42,13 @@ interface ConnectionTestEvent { model?: string payload?: ConnectionTestRequestPayload success?: boolean - upstreamBody?: string - upstreamStatus?: number error?: string } function connectionTestErrorText(event: ConnectionTestEvent) { - if (typeof event.upstreamBody === 'string' && event.upstreamBody.length > 0) - return event.upstreamBody - if (Number.isInteger(event.upstreamStatus)) - return `HTTP ${event.upstreamStatus}` - return event.error || '测试连接失败' -} - -function connectionTestFailureLabel(event: ConnectionTestEvent) { - const hasUpstreamResponse - = (typeof event.upstreamBody === 'string' && event.upstreamBody.length > 0) - || Number.isInteger(event.upstreamStatus) - return hasUpstreamResponse - ? '上游返回错误' - : '未能向上游发起请求' + if (typeof event.error === 'string' && event.error.trim().length > 0) + return event.error + return '测试连接失败' } export function useAccountConnectionTest(options: { reload: () => Promise }) { @@ -284,11 +271,7 @@ export function useAccountConnectionTest(options: { reload: () => Promise