From 7b05b14a2bf29b56ddf4a9e889205c94b5c3db43 Mon Sep 17 00:00:00 2001 From: Zyy Date: Mon, 2 Nov 2026 11:32:33 +0800 Subject: [PATCH] fix(openai): narrow model rejection cooldown detection Require the official model_not_found code and supported status codes for account-model cooldowns. Treat unsupported_value as a terminal request error and preserve account eligibility across HTTP, SSE, and WebSocket. Co-authored-by: Codex --- .../openai/src/transport/diagnostics/mod.rs | 27 ++-- .../contract/model_account_eligibility.rs | 127 ++++++++++++++++++ .../openai/tests/transport/diagnostics/mod.rs | 53 ++++++++ docs/api.md | 4 +- docs/architecture.md | 3 +- 5 files changed, 197 insertions(+), 17 deletions(-) diff --git a/backend/crates/providers/openai/src/transport/diagnostics/mod.rs b/backend/crates/providers/openai/src/transport/diagnostics/mod.rs index 925b2f921..0ab654855 100644 --- a/backend/crates/providers/openai/src/transport/diagnostics/mod.rs +++ b/backend/crates/providers/openai/src/transport/diagnostics/mod.rs @@ -482,15 +482,22 @@ fn classify_upstream_failure( }; } + // 参数值不支持属于请求错误,不能因消息提及模型而推断账号无模型权限 // 官方 Codex 将流内 invalid_prompt 归为请求错误,不依赖 HTTP 状态码 - if matches!(source, UpstreamFailureSource::SseFailure) && code == "invalid_prompt" { + if code == "unsupported_value" + || (matches!(source, UpstreamFailureSource::SseFailure) && code == "invalid_prompt") + { return CodexFailureCategory::InvalidRequest; } - if [code.as_str(), message.as_str(), body.as_str()] - .into_iter() - .any(is_model_unsupported) - { + // 对齐官方 Codex 的模型拒绝合同,消息与正文不提供账号模型资格证据 + if matches!( + (fields.code.as_deref(), status), + ( + Some("model_not_found"), + None | Some(StatusCode::BAD_REQUEST | StatusCode::FORBIDDEN | StatusCode::NOT_FOUND) + ) + ) { return CodexFailureCategory::ModelUnsupported; } let identity_signals = [ @@ -594,16 +601,6 @@ fn non_empty_owned(value: &str) -> Option { (!value.is_empty()).then(|| value.to_owned()) } -fn is_model_unsupported(value: &str) -> bool { - value.contains("model_not_supported") - || value.contains("model_not_available") - || (value.contains("model") - && (value.contains("not supported") - || value.contains("not available") - || value.contains("not_supported") - || value.contains("not_available"))) -} - fn is_identity_verification_required(value: &str) -> bool { matches!( value, diff --git a/backend/crates/providers/openai/tests/provider/contract/model_account_eligibility.rs b/backend/crates/providers/openai/tests/provider/contract/model_account_eligibility.rs index ccac63c98..cfff837c2 100644 --- a/backend/crates/providers/openai/tests/provider/contract/model_account_eligibility.rs +++ b/backend/crates/providers/openai/tests/provider/contract/model_account_eligibility.rs @@ -453,6 +453,133 @@ async fn missing_catalog_unknown_plan_and_rotated_api_credentials_preserve_upstr } } +#[tokio::test] +async fn unsupported_reasoning_value_preserves_account_eligibility_across_transports() { + for transport in ["http", "sse", "websocket"] { + let store = Arc::new(MemoryAccountStore::default()); + create_account(&store, "acct_provider_contract").await; + let cooldowns = Arc::new(MemoryCooldownPort::new()); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let (provider, pool) = provider_with_cooldowns( + &store, + format!("http://{}", listener.local_addr().unwrap()), + cooldowns.clone(), + ); + let upstream_error = json!({ + "code": "unsupported_value", + "type": "invalid_request_error", + "param": "reasoning.effort", + "message": "Unsupported value: 'none' is not supported with the 'gpt-6-astra' model. Supported values are: 'low', 'medium', 'high', 'xhigh', and 'max'." + }); + let expected_message = upstream_error["message"].as_str().unwrap().to_owned(); + let server = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.unwrap(); + if transport == "websocket" { + let mut socket = accept_codex_test_websocket(socket).await; + socket.next().await.unwrap().unwrap(); + socket + .send(Message::Text( + json!({ + "type": "error", "error": upstream_error, "status": 400 + }) + .to_string() + .into(), + )) + .await + .unwrap(); + } else { + read_http_request(&mut socket).await; + let (status, content_type, body) = if transport == "sse" { + let event = json!({"type":"response.failed","response":{ + "id":"resp_bad_effort","status":"failed","output":[],"error":upstream_error + }}); + ( + "200 OK", + "text/event-stream", + gateway_protocol::openai::sse::encode_sse_event( + "response.failed", + &event.to_string(), + ), + ) + } else { + ( + "400 Bad Request", + "application/json", + json!({"error":upstream_error}).to_string(), + ) + }; + socket.write_all(format!( + "HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ).as_bytes()).await.unwrap(); + } + // 合法请求仍应使用同一个账号,不能被上一请求的参数拒绝隔离 + let (mut socket, _) = listener.accept().await.unwrap(); + read_http_request(&mut socket).await; + socket.write_all(format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{CAPTURE_COMPLETED_SSE}", + CAPTURE_COMPLETED_SSE.len() + ).as_bytes()).await.unwrap(); + }); + let payload = ProtocolPayload::json_object( + "openai", + json!({ + "model":"gpt-6-astra","input":"hello","reasoning":{"effort":"none"} + }) + .as_object() + .unwrap() + .clone(), + ) + .unwrap() + .with_context(Map::from_iter([( + "use_websocket".to_owned(), + json!(transport == "websocket"), + )])); + let mut stream = provider + .clone() + .execute( + planned_request_for_model( + "openai", + Operation::Generate(GenerateRequest::from_protocol_payload(payload)), + "gpt-6-astra", + ), + context("req_unsupported_effort", CancellationToken::new()), + ) + .await + .unwrap(); + let (_, error) = super::precommit::stream_failure(&mut stream).await; + assert_eq!( + error.kind(), + ProviderErrorKind::InvalidRequest, + "{transport}" + ); + assert!(error.retry_is_prohibited(), "{transport}"); + assert!(!error.replay_is_safe(), "{transport}"); + let visible = error.client_visible_upstream_error().unwrap(); + assert_eq!(visible.code(), Some("unsupported_value")); + assert_eq!(visible.message(), expected_message); + drop(stream); + assert!(cooldowns.scoped.lock().unwrap().is_empty(), "{transport}"); + let mut recovered = provider + .clone() + .execute( + planned_request_for_model("openai", operation("gpt-6-astra"), "gpt-6-astra"), + context("req_valid_effort", CancellationToken::new()), + ) + .await + .unwrap(); + assert_eq!( + recovered.metadata().provider_account_id().as_str(), + "acct_provider_contract" + ); + while let Some(event) = recovered.next().await { + event.unwrap(); + } + server.await.unwrap(); + pool.shutdown().await; + } +} + #[tokio::test] async fn upstream_model_rejection_cools_only_that_model_across_requests_and_transports() { use gateway_core::provider_ports::{ProviderCooldownScope, ProviderScopedCooldown}; diff --git a/backend/crates/providers/openai/tests/transport/diagnostics/mod.rs b/backend/crates/providers/openai/tests/transport/diagnostics/mod.rs index fa9f5b919..d51ade456 100644 --- a/backend/crates/providers/openai/tests/transport/diagnostics/mod.rs +++ b/backend/crates/providers/openai/tests/transport/diagnostics/mod.rs @@ -22,6 +22,59 @@ fn classify(status: u16, body: &str) -> CodexFailureCategory { .category() } +#[test] +fn model_rejection_requires_the_official_code_and_status() { + for status in [400, 403, 404] { + assert_eq!( + classify( + status, + r#"{"error":{"code":"model_not_found","message":"Access to this deployment ended."}}"# + ), + CodexFailureCategory::ModelUnsupported + ); + } + for (status, body, expected) in [ + ( + 400, + r#"{"error":{"code":"unsupported_value","type":"invalid_request_error","param":"reasoning.effort","message":"Unsupported value: 'none' is not supported with the 'gpt-6-astra' model. Supported values are: 'low', 'medium', 'high', 'xhigh', and 'max'."}}"#, + CodexFailureCategory::InvalidRequest, + ), + ( + 400, + r#"{"error":{"message":"The model is not supported."}}"#, + CodexFailureCategory::InvalidRequest, + ), + ( + 400, + r#"{"error":{"code":"unknown_error","message":"model_not_supported"}}"#, + CodexFailureCategory::InvalidRequest, + ), + ( + 400, + r#"{"error":{"code":"unknown_error"},"detail":"model_not_available"}"#, + CodexFailureCategory::InvalidRequest, + ), + ( + 500, + r#"{"error":{"code":"model_not_found","message":"The model is not available."}}"#, + CodexFailureCategory::Unavailable, + ), + ] { + let failure = CodexUpstreamFailure::from_response( + reqwest::StatusCode::from_u16(status).unwrap(), + body, + None, + &CodexUpstreamDiagnostics::default(), + None, + &[], + &[], + CodexUpstreamSendPhase::AfterPayload, + ); + assert_eq!(failure.category(), expected, "{body}"); + assert!(!failure.replay_is_safe(), "{body}"); + } +} + #[test] fn flex_capacity_rejection_should_be_terminal_even_before_payload_send() { for phase in [ diff --git a/docs/api.md b/docs/api.md index 90774de75..7fd65ae12 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1177,7 +1177,9 @@ Pro 使用 `all` 时也能参与 luna 调度。套餐名称不自动生成或修 OAuth 和 xAI 按已知套餐匹配目录,API Key 按账号与凭据版本隔离目录,不借用其他账号的模型集合。 OpenAI 使用最近 30 分钟内同步的目录,xAI 使用未过期的套餐目录缓存;未知套餐或无有效目录时交由上游验证,仍执行 `modelAccess` -原生 OpenAI Responses 上游明确拒绝某个账号使用该模型后,该账号与模型组合冷却 30 分钟;其他模型仍可使用该账号。 +原生 OpenAI Responses 上游返回 `error.code=model_not_found`,且状态为 400/403/404 或流内错误未提供状态时, +该账号与模型组合冷却 30 分钟;其他模型仍可使用该账号。仅消息提及模型不支持不触发冷却; +`unsupported_value` 属于请求参数错误,原样交回客户端,不触发换号或模型冷却。 冷却按凭据版本隔离,不修改 `modelAccess`,也不解除原生续写的固定账号约束。OpenAI 的受管上游适配器不应用原生目录限制和模型拒绝冷却 单账号更新或批量更新省略 `modelAccess` 时保留原值,显式提交 `{ "mode": "all", "models": [] }` 清除限制。 diff --git a/docs/architecture.md b/docs/architecture.md index 62b398043..b6374f8e3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -739,7 +739,8 @@ OpenAI Provider 的账号资格模块统一解释请求类型、认证与套餐 OpenAI 使用未满 30 分钟的进程目录快照,xAI 按套餐去重读取 TTL 目录缓存,选号不请求上游目录。 未知套餐、缺失或过期目录保留上游验证,不从其他账号或套餐借用目录资格 -原生 OpenAI Responses 明确返回模型不支持时,通过既有模型级冷却端口记录账号、凭据版本与上游模型,30 分钟内不再选用该组合。 +原生 OpenAI Responses 按[官方 Codex 0.166.0](https://github.com/openai/codex/blob/6ff1e1ca3b30fc8b914720681fcf5fe62e95f856/codex-rs/codex-api/src/error.rs#L73)的结构化 `model_not_found` 错误码与状态合同识别模型拒绝, +不从消息或任意正文推断模型权限;通过既有模型级冷却端口记录账号、凭据版本与上游模型,30 分钟内不再选用该组合。 该反馈不冻结整个账号,不改写管理员模型政策,也不占用 WS 传输恢复预算;重试仍遵守交付边界与原生续写固定账号约束。 管理员诊断旁路目录资格与模型拒绝冷却,OpenAI 的受管上游适配器不继承这两项原生限制