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 <noreply@openai.com>
This commit is contained in:
Zyy
2026-11-02 11:36:58 +08:00
co-authored by Codex
parent f9b5b5ff77
commit 7b05b14a2b
5 changed files with 197 additions and 17 deletions
@@ -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<String> {
(!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,
@@ -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};
@@ -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 [
+3 -1
View File
@@ -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": [] }` 清除限制。
+2 -1
View File
@@ -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 的受管上游适配器不继承这两项原生限制