mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 16:06:08 +00:00
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:
@@ -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
@@ -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": [] }` 清除限制。
|
||||
|
||||
@@ -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 的受管上游适配器不继承这两项原生限制
|
||||
|
||||
|
||||
Reference in New Issue
Block a user