Distinguish expired OAuth token status

This commit is contained in:
elky
2026-06-12 19:43:59 +08:00
parent 308cc88ef7
commit 68038c182b
16 changed files with 332 additions and 58 deletions
@@ -4,7 +4,9 @@ use std::sync::{
Arc, LazyLock, Arc, LazyLock,
}; };
use aether_admin::provider::pool as admin_provider_pool_pure; use aether_admin::provider::{
pool as admin_provider_pool_pure, status as admin_provider_status_pure,
};
use aether_data_contracts::repository::candidate_selection::{ use aether_data_contracts::repository::candidate_selection::{
StoredMinimalCandidateSelectionRow, StoredPoolKeyCandidateOrder, StoredMinimalCandidateSelectionRow, StoredPoolKeyCandidateOrder,
StoredPoolKeyCandidateRowsByKeyIdsQuery, StoredPoolKeyCandidateRowsQuery, StoredPoolKeyCandidateRowsByKeyIdsQuery, StoredPoolKeyCandidateRowsQuery,
@@ -1217,9 +1219,15 @@ fn pool_key_requires_reauth_for_scheduling(
.map(str::trim) .map(str::trim)
.unwrap_or_default(); .unwrap_or_default();
if !invalid_reason.is_empty() { if !invalid_reason.is_empty() {
if pool_oauth_reason_has_tag(invalid_reason, "[OAUTH_EXPIRED]") let account_state = admin_provider_status_pure::resolve_pool_account_state(
|| pool_oauth_reason_has_tag(invalid_reason, "[ACCOUNT_BLOCK]") None,
{ key.upstream_metadata.as_ref(),
Some(invalid_reason),
);
if account_state.blocked && !account_state.recoverable {
return true;
}
if pool_oauth_reason_has_tag(invalid_reason, "[ACCOUNT_BLOCK]") {
return true; return true;
} }
if pool_oauth_reason_has_tag(invalid_reason, "[REQUEST_FAILED]") { if pool_oauth_reason_has_tag(invalid_reason, "[REQUEST_FAILED]") {
@@ -1230,6 +1238,9 @@ fn pool_key_requires_reauth_for_scheduling(
.expires_at_unix_secs .expires_at_unix_secs
.is_none_or(|expires_at| expires_at == 0 || expires_at <= now_unix_secs); .is_none_or(|expires_at| expires_at == 0 || expires_at <= now_unix_secs);
} }
if pool_oauth_reason_has_tag(invalid_reason, "[OAUTH_EXPIRED]") {
return false;
}
return true; return true;
} }
@@ -3265,8 +3276,7 @@ mod tests {
let mut key_a_invalid = sample_codex_pool_key("provider-a", "key-a-invalid"); let mut key_a_invalid = sample_codex_pool_key("provider-a", "key-a-invalid");
key_a_invalid.oauth_invalid_at_unix_secs = Some(1_710_000_000); key_a_invalid.oauth_invalid_at_unix_secs = Some(1_710_000_000);
key_a_invalid.oauth_invalid_reason = key_a_invalid.oauth_invalid_reason = Some("[OAUTH_EXPIRED] token invalidated".to_string());
Some("[OAUTH_EXPIRED] Codex Token 无效或已过期 (401)".to_string());
let exhausted_status_snapshot = json!({ let exhausted_status_snapshot = json!({
"quota": { "quota": {
"provider_type": "codex", "provider_type": "codex",
@@ -3370,8 +3380,7 @@ mod tests {
let mut key_a_invalid = sample_codex_pool_key("provider-a", "key-a-invalid"); let mut key_a_invalid = sample_codex_pool_key("provider-a", "key-a-invalid");
key_a_invalid.oauth_invalid_at_unix_secs = Some(1_710_000_000); key_a_invalid.oauth_invalid_at_unix_secs = Some(1_710_000_000);
key_a_invalid.oauth_invalid_reason = key_a_invalid.oauth_invalid_reason = Some("[OAUTH_EXPIRED] token invalidated".to_string());
Some("[OAUTH_EXPIRED] Codex Token 无效或已过期 (401)".to_string());
key_a_invalid.status_snapshot = Some(json!({ key_a_invalid.status_snapshot = Some(json!({
"quota": { "quota": {
"provider_type": "codex", "provider_type": "codex",
@@ -3463,6 +3472,9 @@ mod tests {
key.oauth_invalid_reason = Some("[REQUEST_FAILED] 账号状态检查失败".to_string()); key.oauth_invalid_reason = Some("[REQUEST_FAILED] 账号状态检查失败".to_string());
key.oauth_invalid_at_unix_secs = Some(100); key.oauth_invalid_at_unix_secs = Some(100);
assert!(!pool_key_requires_reauth_for_scheduling(&key, 300)); assert!(!pool_key_requires_reauth_for_scheduling(&key, 300));
key.oauth_invalid_reason = Some("[OAUTH_EXPIRED] session expired".to_string());
assert!(!pool_key_requires_reauth_for_scheduling(&key, 300));
} }
#[test] #[test]
@@ -3471,6 +3483,10 @@ mod tests {
key.oauth_invalid_reason = Some("[ACCOUNT_BLOCK] account has been deactivated".to_string()); key.oauth_invalid_reason = Some("[ACCOUNT_BLOCK] account has been deactivated".to_string());
assert!(pool_key_requires_reauth_for_scheduling(&key, 100)); assert!(pool_key_requires_reauth_for_scheduling(&key, 100));
key.oauth_invalid_reason = Some("[OAUTH_EXPIRED] token invalidated".to_string());
key.oauth_invalid_at_unix_secs = None;
assert!(pool_key_requires_reauth_for_scheduling(&key, 100));
key.oauth_invalid_reason = Some("Kiro Token 无效或已过期".to_string()); key.oauth_invalid_reason = Some("Kiro Token 无效或已过期".to_string());
key.oauth_invalid_at_unix_secs = None; key.oauth_invalid_at_unix_secs = None;
assert!(pool_key_requires_reauth_for_scheduling(&key, 100)); assert!(pool_key_requires_reauth_for_scheduling(&key, 100));
@@ -21,7 +21,10 @@ fn oauth_invalid_reason_is_account_level_block(reason: Option<&str>) -> bool {
snapshot.blocked snapshot.blocked
&& !matches!( && !matches!(
snapshot.code.trim().to_ascii_lowercase().as_str(), snapshot.code.trim().to_ascii_lowercase().as_str(),
"oauth_token_invalid" | "oauth_expired" | "oauth_refresh_failed" "oauth_token_invalid"
| "oauth_token_expired"
| "oauth_expired"
| "oauth_refresh_failed"
) )
} }
@@ -13,6 +13,10 @@ pub(super) fn codex_looks_like_token_invalidated(message: Option<&str>) -> bool
admin_provider_quota_pure::codex_looks_like_token_invalidated(message) admin_provider_quota_pure::codex_looks_like_token_invalidated(message)
} }
pub(super) fn codex_looks_like_token_expired(message: Option<&str>) -> bool {
admin_provider_quota_pure::codex_looks_like_token_expired(message)
}
pub(super) fn codex_looks_like_workspace_deactivated(message: Option<&str>) -> bool { pub(super) fn codex_looks_like_workspace_deactivated(message: Option<&str>) -> bool {
admin_provider_quota_pure::codex_looks_like_workspace_deactivated(message) admin_provider_quota_pure::codex_looks_like_workspace_deactivated(message)
} }
@@ -3,7 +3,7 @@ mod parse;
mod plan; mod plan;
use self::invalid::{ use self::invalid::{
codex_build_invalid_state, codex_looks_like_token_invalidated, codex_build_invalid_state, codex_looks_like_token_expired, codex_looks_like_token_invalidated,
codex_looks_like_workspace_deactivated, codex_soft_request_failure_reason, codex_looks_like_workspace_deactivated, codex_soft_request_failure_reason,
codex_structured_invalid_reason, codex_structured_invalid_reason,
}; };
@@ -275,6 +275,7 @@ pub(crate) async fn refresh_codex_provider_quota_locally(
} }
403 => { 403 => {
let candidate_reason = if codex_looks_like_token_invalidated(err_msg.as_deref()) let candidate_reason = if codex_looks_like_token_invalidated(err_msg.as_deref())
|| codex_looks_like_token_expired(err_msg.as_deref())
{ {
codex_structured_invalid_reason(403, err_msg.as_deref()) codex_structured_invalid_reason(403, err_msg.as_deref())
} else { } else {
@@ -304,6 +304,7 @@ fn admin_pool_trimmed_string(value: Option<&Value>) -> Option<String> {
fn admin_pool_account_code_status_filter(code: &str) -> Option<&'static str> { fn admin_pool_account_code_status_filter(code: &str) -> Option<&'static str> {
match code.trim().to_ascii_lowercase().as_str() { match code.trim().to_ascii_lowercase().as_str() {
"oauth_token_invalid" => Some("invalid"), "oauth_token_invalid" => Some("invalid"),
"oauth_token_expired" => Some("expired"),
"account_banned" | "account_suspended" => Some("account_banned"), "account_banned" | "account_suspended" => Some("account_banned"),
"account_disabled" => Some("account_disabled"), "account_disabled" => Some("account_disabled"),
"workspace_deactivated" => Some("workspace_deactivated"), "workspace_deactivated" => Some("workspace_deactivated"),
@@ -265,14 +265,16 @@ fn build_provider_key_oauth_status_snapshot(key: &StoredProviderCatalogKey) -> V
if let Some(reason) = if let Some(reason) =
tagged_oauth_invalid_reason(invalid_reason.as_deref(), OAUTH_EXPIRED_PREFIX) tagged_oauth_invalid_reason(invalid_reason.as_deref(), OAUTH_EXPIRED_PREFIX)
{ {
let (code, label) =
admin_provider_status_pure::oauth_token_snapshot_status_parts(reason.as_str());
return json!({ return json!({
"code": "invalid", "code": code,
"label": "已失效", "label": label,
"reason": reason, "reason": reason,
"expires_at": expires_at_unix_secs, "expires_at": expires_at_unix_secs,
"invalid_at": invalid_at_unix_secs, "invalid_at": invalid_at_unix_secs,
"source": "oauth_invalid", "source": "oauth_invalid",
"requires_reauth": true, "requires_reauth": code == "invalid",
"expiring_soon": false, "expiring_soon": false,
}); });
} }
@@ -2031,7 +2031,7 @@ mod tests {
} }
#[tokio::test] #[tokio::test]
async fn oauth_invalidation_marks_codex_key_invalid() { async fn oauth_invalidation_marks_codex_key_expired() {
let state = codex_state(); let state = codex_state();
let plan = sample_codex_plan(); let plan = sample_codex_plan();
@@ -2069,7 +2069,7 @@ mod tests {
.and_then(|value| value.get("oauth")) .and_then(|value| value.get("oauth"))
.and_then(|value| value.get("code")) .and_then(|value| value.get("code"))
.and_then(Value::as_str), .and_then(Value::as_str),
Some("invalid") Some("expired")
); );
} }
@@ -376,9 +376,6 @@ fn oauth_invalid_reason_blocks_scheduling(
now_unix_secs: u64, now_unix_secs: u64,
) -> bool { ) -> bool {
let trimmed_reason = invalid_reason.trim(); let trimmed_reason = invalid_reason.trim();
if oauth_invalid_reason_has_tag(trimmed_reason, "[OAUTH_EXPIRED]") {
return true;
}
let account_state = admin_provider_status_pure::resolve_pool_account_state( let account_state = admin_provider_status_pure::resolve_pool_account_state(
Some(provider_type), Some(provider_type),
@@ -433,6 +430,7 @@ fn oauth_account_state_code_is_hard_block(code: &str) -> bool {
| "account_forbidden" | "account_forbidden"
| "account_blocked" | "account_blocked"
| "account_verification" | "account_verification"
| "oauth_token_invalid"
) )
} }
@@ -2064,7 +2064,7 @@ async fn keeps_codex_candidate_selectable_when_oauth_token_is_expired() {
let mut key = sample_key("key-codex", "provider-codex", Some(10)); let mut key = sample_key("key-codex", "provider-codex", Some(10));
key.auth_type = "oauth".to_string(); key.auth_type = "oauth".to_string();
key.oauth_invalid_at_unix_secs = Some(1_710_000_000); key.oauth_invalid_at_unix_secs = Some(1_710_000_000);
key.oauth_invalid_reason = Some("Codex Token 无效或已过期".to_string()); key.oauth_invalid_reason = Some("[OAUTH_EXPIRED] session expired".to_string());
key key
}], }],
)); ));
+9 -4
View File
@@ -171,7 +171,10 @@ fn oauth_invalid_reason_is_account_block(reason: Option<&str>) -> bool {
snapshot.blocked snapshot.blocked
&& !matches!( && !matches!(
snapshot.code.trim().to_ascii_lowercase().as_str(), snapshot.code.trim().to_ascii_lowercase().as_str(),
"oauth_token_invalid" | "oauth_expired" | "oauth_refresh_failed" "oauth_token_invalid"
| "oauth_token_expired"
| "oauth_expired"
| "oauth_refresh_failed"
) )
} }
@@ -356,14 +359,16 @@ fn build_oauth_status_snapshot_value(key: &StoredProviderCatalogKey) -> Value {
let invalid_reason = trimmed_reason(key.oauth_invalid_reason.as_deref()); let invalid_reason = trimmed_reason(key.oauth_invalid_reason.as_deref());
if let Some(reason) = tagged_reason(invalid_reason.as_deref(), OAUTH_EXPIRED_PREFIX) { if let Some(reason) = tagged_reason(invalid_reason.as_deref(), OAUTH_EXPIRED_PREFIX) {
let (code, label) =
aether_admin::provider::status::oauth_token_snapshot_status_parts(reason.as_str());
return json!({ return json!({
"code": "invalid", "code": code,
"label": "已失效", "label": label,
"reason": reason, "reason": reason,
"expires_at": expires_at_unix_secs, "expires_at": expires_at_unix_secs,
"invalid_at": invalid_at_unix_secs, "invalid_at": invalid_at_unix_secs,
"source": "oauth_invalid", "source": "oauth_invalid",
"requires_reauth": true, "requires_reauth": code == "invalid",
"expiring_soon": false, "expiring_soon": false,
}); });
} }
@@ -5403,7 +5403,7 @@ async fn gateway_refreshes_admin_provider_oauth_key_locally_with_trusted_admin_p
assert!(stored_key.oauth_invalid_at_unix_secs.is_some()); assert!(stored_key.oauth_invalid_at_unix_secs.is_some());
assert_eq!( assert_eq!(
stored_key.oauth_invalid_reason.as_deref(), stored_key.oauth_invalid_reason.as_deref(),
Some("[OAUTH_EXPIRED] Codex Token 无效或已过期 (401)") Some("[OAUTH_EXPIRED] Codex Token 已过期 (401)")
); );
} else if account_state_recheck_attempted } else if account_state_recheck_attempted
&& payload["account_state_recheck_error"] == "wham/usage API 返回状态码 403" && payload["account_state_recheck_error"] == "wham/usage API 返回状态码 403"
@@ -5449,7 +5449,40 @@ async fn gateway_refreshes_admin_provider_oauth_key_locally_with_trusted_admin_p
oauth_snapshot.get("expires_at"), oauth_snapshot.get("expires_at"),
auth_config.get("expires_at") auth_config.get("expires_at")
); );
if stored_key.oauth_invalid_reason.is_some() { if stored_key
.oauth_invalid_reason
.as_deref()
.is_some_and(|reason| reason.starts_with("[OAUTH_EXPIRED]"))
{
assert_eq!(
oauth_snapshot
.get("code")
.and_then(serde_json::Value::as_str),
Some("expired")
);
assert_eq!(
oauth_snapshot
.get("label")
.and_then(serde_json::Value::as_str),
Some("已过期")
);
assert_eq!(
oauth_snapshot.get("reason"),
Some(&json!("Codex Token 已过期 (401)"))
);
assert_eq!(
oauth_snapshot
.get("requires_reauth")
.and_then(serde_json::Value::as_bool),
Some(false)
);
assert_eq!(
oauth_snapshot
.get("expiring_soon")
.and_then(serde_json::Value::as_bool),
Some(false)
);
} else if stored_key.oauth_invalid_reason.is_some() {
assert_eq!( assert_eq!(
oauth_snapshot oauth_snapshot
.get("code") .get("code")
@@ -5463,8 +5496,11 @@ async fn gateway_refreshes_admin_provider_oauth_key_locally_with_trusted_admin_p
Some("已失效") Some("已失效")
); );
assert_eq!( assert_eq!(
oauth_snapshot.get("reason"), oauth_snapshot
Some(&json!("Codex Token 无效或已过期 (401)")) .get("reason")
.and_then(serde_json::Value::as_str)
.is_some_and(|reason| !reason.trim().is_empty()),
true
); );
assert_eq!( assert_eq!(
oauth_snapshot oauth_snapshot
@@ -3526,6 +3526,15 @@ async fn gateway_cleans_up_admin_pool_banned_keys_locally_with_trusted_admin_pri
); );
banned_key.name = "banned".to_string(); banned_key.name = "banned".to_string();
banned_key.oauth_invalid_reason = Some("account_banned".to_string()); banned_key.oauth_invalid_reason = Some("account_banned".to_string());
let mut oauth_invalidated_key = sample_key(
"key-openai-oauth-invalidated",
"provider-openai",
"openai:chat",
"sk-oauth-invalidated",
);
oauth_invalidated_key.name = "oauth-invalidated".to_string();
oauth_invalidated_key.oauth_invalid_reason =
Some("[OAUTH_EXPIRED] token invalidated".to_string());
let mut oauth_expired_key = sample_key( let mut oauth_expired_key = sample_key(
"key-openai-oauth-expired", "key-openai-oauth-expired",
"provider-openai", "provider-openai",
@@ -3533,7 +3542,7 @@ async fn gateway_cleans_up_admin_pool_banned_keys_locally_with_trusted_admin_pri
"sk-oauth-expired", "sk-oauth-expired",
); );
oauth_expired_key.name = "oauth-expired".to_string(); oauth_expired_key.name = "oauth-expired".to_string();
oauth_expired_key.oauth_invalid_reason = Some("[OAUTH_EXPIRED] token invalidated".to_string()); oauth_expired_key.oauth_invalid_reason = Some("[OAUTH_EXPIRED] session expired".to_string());
let mut healthy_key = sample_key( let mut healthy_key = sample_key(
"key-openai-healthy", "key-openai-healthy",
"provider-openai", "provider-openai",
@@ -3545,7 +3554,12 @@ async fn gateway_cleans_up_admin_pool_banned_keys_locally_with_trusted_admin_pri
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed( let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
vec![provider], vec![provider],
Vec::new(), Vec::new(),
vec![banned_key, oauth_expired_key, healthy_key], vec![
banned_key,
oauth_invalidated_key,
oauth_expired_key,
healthy_key,
],
)); ));
let (upstream_url, upstream_handle) = start_server(upstream).await; let (upstream_url, upstream_handle) = start_server(upstream).await;
@@ -3575,8 +3589,8 @@ async fn gateway_cleans_up_admin_pool_banned_keys_locally_with_trusted_admin_pri
assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.status(), StatusCode::OK);
let payload: serde_json::Value = response.json().await.expect("json body should parse"); let payload: serde_json::Value = response.json().await.expect("json body should parse");
assert_eq!(payload["affected"], 1); assert_eq!(payload["affected"], 2);
assert_eq!(payload["message"], "已清理 1 个异常账号"); assert_eq!(payload["message"], "已清理 2 个异常账号");
let remaining_keys = provider_catalog_repository let remaining_keys = provider_catalog_repository
.list_keys_by_provider_ids(&["provider-openai".to_string()]) .list_keys_by_provider_ids(&["provider-openai".to_string()])
@@ -3586,6 +3600,9 @@ async fn gateway_cleans_up_admin_pool_banned_keys_locally_with_trusted_admin_pri
assert!(remaining_keys assert!(remaining_keys
.iter() .iter()
.any(|key| key.id == "key-openai-oauth-expired")); .any(|key| key.id == "key-openai-oauth-expired"));
assert!(!remaining_keys
.iter()
.any(|key| key.id == "key-openai-oauth-invalidated"));
assert!(remaining_keys assert!(remaining_keys
.iter() .iter()
.any(|key| key.id == "key-openai-healthy")); .any(|key| key.id == "key-openai-healthy"));
+43 -8
View File
@@ -948,10 +948,29 @@ pub fn codex_build_invalid_state(
pub fn codex_looks_like_token_invalidated(message: Option<&str>) -> bool { pub fn codex_looks_like_token_invalidated(message: Option<&str>) -> bool {
let lowered = message.unwrap_or_default().trim().to_ascii_lowercase(); let lowered = message.unwrap_or_default().trim().to_ascii_lowercase();
lowered.contains("token invalid") lowered.contains("token_invalidated")
|| lowered.contains("authentication token has been invalidated")
|| lowered.contains("token has been invalidated")
|| lowered.contains("token invalidated") || lowered.contains("token invalidated")
|| lowered.contains("session has expired") || lowered.contains("invalidated")
|| lowered.contains("revoked")
|| lowered.contains("已撤销")
|| lowered.contains("被撤销")
|| lowered.contains("撤销")
|| lowered.contains("作废")
}
pub fn codex_looks_like_token_expired(message: Option<&str>) -> bool {
let lowered = message.unwrap_or_default().trim().to_ascii_lowercase();
lowered.contains("session has expired")
|| lowered.contains("session expired") || lowered.contains("session expired")
|| lowered.contains("access token expired")
|| lowered.contains("expired access token")
|| lowered.contains("token has expired")
|| lowered.contains("token expired")
|| lowered.contains("security token included in the request is expired")
|| lowered.contains("已过期")
|| lowered.contains("过期")
} }
fn codex_looks_like_account_deactivated(message: Option<&str>) -> bool { fn codex_looks_like_account_deactivated(message: Option<&str>) -> bool {
@@ -980,7 +999,15 @@ pub fn codex_structured_invalid_reason(status_code: u16, upstream_message: Optio
} }
if codex_looks_like_token_invalidated(Some(message)) { if codex_looks_like_token_invalidated(Some(message)) {
let detail = if message.is_empty() { let detail = if message.is_empty() {
"Codex Token 无效或已过期" "Codex Token 已失效"
} else {
message
};
return format!("{OAUTH_EXPIRED_PREFIX}{detail}");
}
if codex_looks_like_token_expired(Some(message)) {
let detail = if message.is_empty() {
"Codex Token 已过期"
} else { } else {
message message
}; };
@@ -988,7 +1015,7 @@ pub fn codex_structured_invalid_reason(status_code: u16, upstream_message: Optio
} }
if status_code == 401 { if status_code == 401 {
let detail = if message.is_empty() { let detail = if message.is_empty() {
"Codex Token 无效或已过期 (401)" "Codex Token 已过期 (401)"
} else { } else {
message message
}; };
@@ -1021,6 +1048,7 @@ pub fn codex_runtime_invalid_reason(
401 => Some(codex_structured_invalid_reason(401, upstream_message)), 401 => Some(codex_structured_invalid_reason(401, upstream_message)),
402 => Some(codex_structured_invalid_reason(402, upstream_message)), 402 => Some(codex_structured_invalid_reason(402, upstream_message)),
403 if codex_looks_like_token_invalidated(upstream_message) 403 if codex_looks_like_token_invalidated(upstream_message)
|| codex_looks_like_token_expired(upstream_message)
|| codex_looks_like_account_deactivated(upstream_message) => || codex_looks_like_account_deactivated(upstream_message) =>
{ {
Some(codex_structured_invalid_reason(403, upstream_message)) Some(codex_structured_invalid_reason(403, upstream_message))
@@ -1848,12 +1876,19 @@ mod tests {
} }
#[test] #[test]
fn auto_remove_structured_reason_keeps_oauth_expired_token_invalid() { fn auto_remove_structured_reason_removes_oauth_token_invalidated() {
assert!(!should_auto_remove_structured_reason(Some( assert!(should_auto_remove_structured_reason(Some(
"[OAUTH_EXPIRED] token invalidated" "[OAUTH_EXPIRED] token invalidated"
))); )));
} }
#[test]
fn auto_remove_structured_reason_keeps_oauth_token_expired() {
assert!(!should_auto_remove_structured_reason(Some(
"[OAUTH_EXPIRED] session expired"
)));
}
#[test] #[test]
fn auto_remove_refresh_failed_after_access_token_expiry() { fn auto_remove_refresh_failed_after_access_token_expiry() {
let mut key = StoredProviderCatalogKey::new( let mut key = StoredProviderCatalogKey::new(
@@ -1945,7 +1980,7 @@ mod tests {
} }
#[test] #[test]
fn oauth_token_invalid_is_not_auto_remove_proof_by_itself() { fn oauth_token_invalid_is_auto_remove_proof_by_itself() {
let mut key = StoredProviderCatalogKey::new( let mut key = StoredProviderCatalogKey::new(
"key-1".to_string(), "key-1".to_string(),
"provider-1".to_string(), "provider-1".to_string(),
@@ -1958,7 +1993,7 @@ mod tests {
key.expires_at_unix_secs = Some(1_000); key.expires_at_unix_secs = Some(1_000);
key.oauth_invalid_reason = Some("oauth_token_invalid".to_string()); key.oauth_invalid_reason = Some("oauth_token_invalid".to_string());
assert!(!super::should_auto_remove_oauth_invalid_key( assert!(super::should_auto_remove_oauth_invalid_key(
&key, &key,
Some("oauth_token_invalid"), Some("oauth_token_invalid"),
false, false,
+116 -11
View File
@@ -26,6 +26,10 @@ const ACCOUNT_BLOCK_REASON_KEYWORDS: &[&str] = &[
"账户访问被禁止", "账户访问被禁止",
"访问受限", "访问受限",
"账户访问受限", "账户访问受限",
"oauth_token_invalid",
"oauth_token_expired",
"token_invalidated",
"session expired",
"authentication token has been invalidated", "authentication token has been invalidated",
"token has been invalidated", "token has been invalidated",
"codex token 无效或已过期", "codex token 无效或已过期",
@@ -43,6 +47,7 @@ const AUTO_REMOVABLE_ACCOUNT_STATE_CODES: &[&str] = &[
"account_quarantined", "account_quarantined",
"workspace_deactivated", "workspace_deactivated",
"account_forbidden", "account_forbidden",
"oauth_token_invalid",
]; ];
#[derive(Debug, Clone, Default, PartialEq, Eq)] #[derive(Debug, Clone, Default, PartialEq, Eq)]
@@ -122,8 +127,79 @@ fn looks_like_account_verification(reason: &str) -> bool {
.any(|keyword| lowered.contains(keyword)) .any(|keyword| lowered.contains(keyword))
} }
pub fn oauth_token_reason_is_expired(reason: &str) -> bool {
let lowered = reason.trim().to_ascii_lowercase();
!lowered.is_empty()
&& [
"oauth_token_expired",
"session has expired",
"session expired",
"access token expired",
"expired access token",
"token expired",
"token has expired",
"security token included in the request is expired",
"已过期",
"过期",
]
.iter()
.any(|keyword| lowered.contains(keyword))
}
pub fn oauth_token_reason_is_hard_invalid(reason: &str) -> bool {
let lowered = reason.trim().to_ascii_lowercase();
if lowered.is_empty() {
return false;
}
if [
"oauth_token_invalid",
"token_invalidated",
"authentication token has been invalidated",
"token has been invalidated",
"token invalidated",
"invalidated",
"revoked",
"已撤销",
"被撤销",
"撤销",
"已作废",
"作废",
"已失效",
"token 失效",
"令牌失效",
]
.iter()
.any(|keyword| lowered.contains(keyword))
{
return true;
}
(lowered.contains("token 无效") || lowered.contains("令牌无效"))
&& !oauth_token_reason_is_expired(reason)
}
pub fn oauth_token_account_status_parts(reason: &str) -> (&'static str, &'static str) {
if oauth_token_reason_is_hard_invalid(reason) {
("oauth_token_invalid", "Token 失效")
} else {
("oauth_token_expired", "Token 过期")
}
}
pub fn oauth_token_snapshot_status_parts(reason: &str) -> (&'static str, &'static str) {
if oauth_token_reason_is_hard_invalid(reason) {
("invalid", "已失效")
} else {
("expired", "已过期")
}
}
fn classify_block_reason(reason: &str) -> (&'static str, &'static str) { fn classify_block_reason(reason: &str) -> (&'static str, &'static str) {
let lowered = reason.to_ascii_lowercase(); let lowered = reason.to_ascii_lowercase();
if oauth_token_reason_is_hard_invalid(reason) || oauth_token_reason_is_expired(reason) {
return oauth_token_account_status_parts(reason);
}
if [ if [
"authentication token has been invalidated", "authentication token has been invalidated",
"token has been invalidated", "token has been invalidated",
@@ -352,17 +428,18 @@ fn resolve_from_oauth_invalid_reason(reason: Option<&str>) -> Option<PoolAccount
} }
if let Some(cleaned) = tagged_reason(&text, "OAUTH_EXPIRED") { if let Some(cleaned) = tagged_reason(&text, "OAUTH_EXPIRED") {
let reason = if cleaned.is_empty() { let reason = if cleaned.is_empty() {
"OAuth Token 已过期且无法续期".to_string() "OAuth Token 已过期".to_string()
} else { } else {
cleaned cleaned
}; };
let (code, label) = oauth_token_account_status_parts(&reason);
return Some(PoolAccountState { return Some(PoolAccountState {
blocked: true, blocked: true,
code: Some("oauth_token_invalid".to_string()), code: Some(code.to_string()),
label: Some("Token 失效".to_string()), label: Some(label.to_string()),
reason: Some(reason), reason: Some(reason),
source: Some("oauth_invalid".to_string()), source: Some("oauth_invalid".to_string()),
recoverable: false, recoverable: code == "oauth_token_expired",
}); });
} }
if let Some(cleaned) = tagged_reason(&text, "REQUEST_FAILED") { if let Some(cleaned) = tagged_reason(&text, "REQUEST_FAILED") {
@@ -456,17 +533,18 @@ pub fn resolve_account_status_snapshot(
if let Some(cleaned) = tagged_reason(&text, "OAUTH_EXPIRED") { if let Some(cleaned) = tagged_reason(&text, "OAUTH_EXPIRED") {
let reason = if cleaned.is_empty() { let reason = if cleaned.is_empty() {
"OAuth Token 已过期且无法续期".to_string() "OAuth Token 已过期".to_string()
} else { } else {
cleaned cleaned
}; };
let (code, label) = oauth_token_account_status_parts(&reason);
return AccountStatusSnapshot { return AccountStatusSnapshot {
code: "oauth_token_invalid".to_string(), code: code.to_string(),
label: Some("Token 失效".to_string()), label: Some(label.to_string()),
reason: Some(reason), reason: Some(reason),
blocked: true, blocked: true,
source: Some("oauth_invalid".to_string()), source: Some("oauth_invalid".to_string()),
recoverable: false, recoverable: code == "oauth_token_expired",
}; };
} }
@@ -631,11 +709,11 @@ mod tests {
} }
#[test] #[test]
fn account_snapshot_marks_oauth_expired_as_token_invalid() { fn account_snapshot_marks_oauth_invalidated_as_token_invalid() {
let snapshot = resolve_account_status_snapshot( let snapshot = resolve_account_status_snapshot(
Some("codex"), Some("codex"),
None, None,
Some("[OAUTH_EXPIRED] Codex Token 无效或已过期 (401)"), Some("[OAUTH_EXPIRED] Your authentication token has been invalidated. Please try signing in again."),
); );
assert_eq!(snapshot.code, "oauth_token_invalid"); assert_eq!(snapshot.code, "oauth_token_invalid");
@@ -645,7 +723,21 @@ mod tests {
} }
#[test] #[test]
fn oauth_expired_state_is_not_auto_removed() { fn account_snapshot_marks_oauth_expired_as_token_expired() {
let snapshot = resolve_account_status_snapshot(
Some("codex"),
None,
Some("[OAUTH_EXPIRED] session expired"),
);
assert_eq!(snapshot.code, "oauth_token_expired");
assert_eq!(snapshot.label.as_deref(), Some("Token 过期"));
assert!(snapshot.blocked);
assert!(snapshot.recoverable);
}
#[test]
fn oauth_invalidated_state_is_auto_removed() {
let state = resolve_pool_account_state( let state = resolve_pool_account_state(
Some("codex"), Some("codex"),
None, None,
@@ -654,6 +746,19 @@ mod tests {
assert!(state.blocked); assert!(state.blocked);
assert_eq!(state.code.as_deref(), Some("oauth_token_invalid")); assert_eq!(state.code.as_deref(), Some("oauth_token_invalid"));
assert!(should_auto_remove_account_state(&state));
}
#[test]
fn oauth_expired_state_is_not_auto_removed() {
let state = resolve_pool_account_state(
Some("codex"),
None,
Some("[OAUTH_EXPIRED] session expired"),
);
assert!(state.blocked);
assert_eq!(state.code.as_deref(), Some("oauth_token_expired"));
assert!(!should_auto_remove_account_state(&state)); assert!(!should_auto_remove_account_state(&state));
} }
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { import {
classifyAccountBlockLabel,
cleanAccountBlockReason, cleanAccountBlockReason,
isAccountLevelBlockReason, isAccountLevelBlockReason,
isRefreshFailedReason, isRefreshFailedReason,
@@ -30,4 +31,9 @@ describe('accountBlock helpers', () => {
), ),
).toBe(false) ).toBe(false)
}) })
it('labels invalidated and expired oauth markers separately', () => {
expect(classifyAccountBlockLabel('[OAUTH_EXPIRED] token invalidated')).toBe('Token 失效')
expect(classifyAccountBlockLabel('[OAUTH_EXPIRED] session expired')).toBe('Token 过期')
})
}) })
+53 -8
View File
@@ -28,9 +28,33 @@ const KEYWORDS_DISABLED = [
] ]
const KEYWORDS_TOKEN_INVALID = [ const KEYWORDS_TOKEN_INVALID = [
'oauth_token_invalid',
'token_invalidated',
'authentication token has been invalidated', 'authentication token has been invalidated',
'token has been invalidated', 'token has been invalidated',
'codex token 无效或已过期', 'token invalidated',
'invalidated',
'revoked',
'已撤销',
'被撤销',
'撤销',
'作废',
'已失效',
'token 失效',
'令牌失效',
]
const KEYWORDS_TOKEN_EXPIRED = [
'oauth_token_expired',
'session has expired',
'session expired',
'access token expired',
'expired access token',
'token has expired',
'token expired',
'security token included in the request is expired',
'已过期',
'过期',
] ]
// 需要验证类 // 需要验证类
@@ -47,9 +71,29 @@ const ACCOUNT_BLOCK_REASON_KEYWORDS = [
...KEYWORDS_SUSPENDED, ...KEYWORDS_SUSPENDED,
...KEYWORDS_DISABLED, ...KEYWORDS_DISABLED,
...KEYWORDS_TOKEN_INVALID, ...KEYWORDS_TOKEN_INVALID,
...KEYWORDS_TOKEN_EXPIRED,
...KEYWORDS_VERIFICATION, ...KEYWORDS_VERIFICATION,
] ]
function normalizeOAuthReasonDetail(reason: string): string {
return reason
.replace(/^\[(ACCOUNT_BLOCK|OAUTH_EXPIRED)\]\s*/i, '')
.replace(/\s*\[REFRESH_FAILED\][\s\S]*$/i, '')
.trim()
}
function isHardTokenInvalidReason(reason: string): boolean {
const lowered = reason.toLowerCase()
if (KEYWORDS_TOKEN_INVALID.some(keyword => lowered.includes(keyword))) return true
return (lowered.includes('token 无效') || lowered.includes('令牌无效'))
&& !KEYWORDS_TOKEN_EXPIRED.some(keyword => lowered.includes(keyword))
}
function isTokenExpiredReason(reason: string): boolean {
const lowered = reason.toLowerCase()
return KEYWORDS_TOKEN_EXPIRED.some(keyword => lowered.includes(keyword))
}
export function isAccountLevelBlockReason(reason: string | null | undefined): boolean { export function isAccountLevelBlockReason(reason: string | null | undefined): boolean {
if (!reason) return false if (!reason) return false
const text = reason.trim() const text = reason.trim()
@@ -62,9 +106,13 @@ export function isAccountLevelBlockReason(reason: string | null | undefined): bo
} }
export function classifyAccountBlockLabel(reason: string): string { export function classifyAccountBlockLabel(reason: string): string {
if (reason.trim().startsWith('[OAUTH_EXPIRED]')) return 'Token 失效' const detail = normalizeOAuthReasonDetail(reason) || reason
const lowered = reason.toLowerCase() if (reason.trim().startsWith('[OAUTH_EXPIRED]')) {
if (KEYWORDS_TOKEN_INVALID.some(kw => lowered.includes(kw))) return 'Token 失效' return isHardTokenInvalidReason(detail) ? 'Token 失效' : 'Token 过期'
}
const lowered = detail.toLowerCase()
if (isHardTokenInvalidReason(detail)) return 'Token 失效'
if (isTokenExpiredReason(detail)) return 'Token 过期'
if (KEYWORDS_VERIFICATION.some(kw => lowered.includes(kw))) return '需要验证' if (KEYWORDS_VERIFICATION.some(kw => lowered.includes(kw))) return '需要验证'
if (lowered.includes('deactivated_workspace')) return '工作区停用' if (lowered.includes('deactivated_workspace')) return '工作区停用'
if (KEYWORDS_DISABLED.some(kw => lowered.includes(kw))) return '账号停用' if (KEYWORDS_DISABLED.some(kw => lowered.includes(kw))) return '账号停用'
@@ -73,10 +121,7 @@ export function classifyAccountBlockLabel(reason: string): string {
} }
export function cleanAccountBlockReason(reason: string): string { export function cleanAccountBlockReason(reason: string): string {
return reason return normalizeOAuthReasonDetail(reason)
.replace(/^\[(ACCOUNT_BLOCK|OAUTH_EXPIRED)\]\s*/i, '')
.replace(/\s*\[REFRESH_FAILED\][\s\S]*$/i, '')
.trim()
} }
export function isRefreshFailedReason(reason: string | null | undefined): boolean { export function isRefreshFailedReason(reason: string | null | undefined): boolean {