From c0fdc1bbff4e98250347f5e5314ce0ddf1eef3b6 Mon Sep 17 00:00:00 2001 From: Zyy <0x@ainz.cc> Date: Wed, 11 Nov 2026 15:12:02 +0800 Subject: [PATCH] fix(openai): record explicit Anthropic reasoning effort (#680) Observe top-level output_config.effort before the OpenAI encoder rejects Messages payloads. Reuse the protocol parser without inferring an effort from thinking budgets or changing upstream conversion behavior. Cover absent and explicit effort values, native Messages forwarding, and Messages-to-Responses conversion. Document the observation contract. Co-authored-by: Zyy Co-authored-by: Codex --- .../gateway-protocol/src/anthropic/mod.rs | 1 + .../src/anthropic/thinking.rs | 16 +++-- .../providers/openai/src/provider/mod.rs | 10 +++ .../tests/provider/contract/messages.rs | 61 ++++++++++++++++++- docs/api.md | 3 + 5 files changed, 85 insertions(+), 6 deletions(-) diff --git a/backend/crates/gateway-protocol/src/anthropic/mod.rs b/backend/crates/gateway-protocol/src/anthropic/mod.rs index fcd611635..a2f98d68b 100644 --- a/backend/crates/gateway-protocol/src/anthropic/mod.rs +++ b/backend/crates/gateway-protocol/src/anthropic/mod.rs @@ -24,6 +24,7 @@ pub use stream::{ MessagesAccumulator, MessagesToResponsesStream, ResponsesToMessagesStream, message_to_stream_events, }; +pub use thinking::messages_request_effort; pub use usage::MessageUsage; /// Messages 载荷的协议标识 diff --git a/backend/crates/gateway-protocol/src/anthropic/thinking.rs b/backend/crates/gateway-protocol/src/anthropic/thinking.rs index f9f5b8f09..0299e6913 100644 --- a/backend/crates/gateway-protocol/src/anthropic/thinking.rs +++ b/backend/crates/gateway-protocol/src/anthropic/thinking.rs @@ -1,16 +1,22 @@ -//! 按目标模型能力映射推理等级与预算;未知兼容上游沿用 CPA 手动预算 +//! 提取 Messages 显式 effort,并按目标模型能力映射推理等级与预算 use serde_json::{Map, Value, json}; use super::MessagesModelCapabilities; -pub(super) fn to_responses(request: &Map) -> Value { - let configured = request +/// 返回客户端显式设置的 effort,预算与默认档位由转换器单独处理 +#[must_use] +pub fn messages_request_effort(request: &Map) -> Option<&str> { + request .get("output_config") .and_then(|value| value.get("effort")) .and_then(Value::as_str) - .map(|value| value.trim().to_ascii_lowercase()) - .filter(|value| !value.is_empty()); + .map(str::trim) + .filter(|value| !value.is_empty()) +} + +pub(super) fn to_responses(request: &Map) -> Value { + let configured = messages_request_effort(request).map(str::to_ascii_lowercase); let thinking = request.get("thinking"); let effort = match thinking .and_then(|value| value.get("type")) diff --git a/backend/crates/providers/openai/src/provider/mod.rs b/backend/crates/providers/openai/src/provider/mod.rs index dd5860553..199c7beff 100644 --- a/backend/crates/providers/openai/src/provider/mod.rs +++ b/backend/crates/providers/openai/src/provider/mod.rs @@ -504,6 +504,16 @@ impl Provider for CodexProvider { ..Default::default() }; }; + let payload = request.protocol_payload(); + if payload.protocol() == gateway_protocol::anthropic::ANTHROPIC_PROTOCOL { + return ProviderRequestObservation { + reasoning_effort: gateway_protocol::anthropic::messages_request_effort( + payload.body(), + ) + .map(str::to_owned), + ..Default::default() + }; + } let Ok(encoded) = encode_generate_request(request, "observability", None) else { return ProviderRequestObservation::default(); }; diff --git a/backend/crates/providers/openai/tests/provider/contract/messages.rs b/backend/crates/providers/openai/tests/provider/contract/messages.rs index 0e196db44..cbfcc7085 100644 --- a/backend/crates/providers/openai/tests/provider/contract/messages.rs +++ b/backend/crates/providers/openai/tests/provider/contract/messages.rs @@ -29,6 +29,7 @@ async fn seed(store: &Arc, server: &MockServer, protocol: Ap fn messages_operation(stream: bool) -> Operation { let body = json!({ "model":"gpt-5.4", "max_tokens":128, "stream":stream, + "thinking":{"type":"adaptive"}, "output_config":{"effort":"xhigh"}, "messages":[ {"role":"assistant","content":[{"type":"thinking","thinking":"prior thought","signature":"real-native-signature"},{"type":"text","text":"before"}]}, {"role":"user","content":"hello"}, @@ -51,6 +52,51 @@ fn messages_operation(stream: bool) -> Operation { )) } +#[test] +fn messages_request_observation_preserves_explicit_effort_without_inventing_defaults() { + let store = Arc::new(MemoryAccountStore::default()); + let provider = provider(&store); + let client = ClientApiKeyId::new("key_messages_observation").unwrap(); + for (options, expected) in [ + (json!({"output_config":{"effort":"xhigh"}}), Some("xhigh")), + ( + json!({"thinking":{"type":"adaptive"},"output_config":{"effort":"low"}}), + Some("low"), + ), + ( + json!({"thinking":{"type":"disabled"},"output_config":{"effort":"high"}}), + Some("high"), + ), + ( + json!({"output_config":{"effort":" Future-Level "}}), + Some("Future-Level"), + ), + (json!({"output_config":{"effort":" "}}), None), + (json!({"output_config":{"effort":null}}), None), + (json!({"output_config":{"effort":42}}), None), + (json!({"thinking":{"type":"adaptive"}}), None), + ( + json!({"thinking":{"type":"enabled","budget_tokens":32768}}), + None, + ), + (json!({}), None), + ] { + let mut body = json!({"model":"gpt-5.4","max_tokens":128,"messages":[{"role":"user","content":"hello"}]}); + body.as_object_mut() + .unwrap() + .extend(options.as_object().unwrap().clone()); + let operation = Operation::Generate(GenerateRequest::from_protocol_payload( + ProtocolPayload::json_object("anthropic", body.as_object().unwrap().clone()).unwrap(), + )); + let observation = provider.request_observation(&operation, &client); + assert_eq!( + observation.reasoning_effort.as_deref(), + expected, + "{options}" + ); + } +} + #[tokio::test] async fn final_usage_before_truncation_keeps_only_explicitly_priceable_cost() { for (priced, long_cache, fast) in [ @@ -174,7 +220,18 @@ async fn native_messages_preserve_json_sse_headers_and_real_signatures() { .await; let operation = messages_operation(streaming); let original = operation.middleware_body().unwrap(); - let mut stream = provider(&store) + let provider = provider(&store); + assert_eq!( + provider + .request_observation( + &operation, + &ClientApiKeyId::new("key_messages_native").unwrap() + ) + .reasoning_effort + .as_deref(), + Some("xhigh") + ); + let mut stream = provider .execute( planned_request("openai", operation), context("req_messages_native", CancellationToken::new()), @@ -290,6 +347,8 @@ async fn messages_and_responses_translate_with_target_header_boundaries() { assert!(!headers.contains_key(name)); } } else { + let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); + assert_eq!(body["reasoning"]["effort"], "xhigh"); for name in [ "anthropic-version", "anthropic-beta", diff --git a/docs/api.md b/docs/api.md index 92a439447..32647b232 100644 --- a/docs/api.md +++ b/docs/api.md @@ -2841,6 +2841,9 @@ OpenAI 推理强度优先取 `input` 中最后一条 `type: "configuration_updat 原生续接未再次更新时沿用已关联响应的配置,否则取请求顶层 `reasoning.effort`。 该字段是请求观测,不改写上游请求;网关未持有的续接历史无法用于推断档位 +Messages 推理强度取请求顶层 `output_config.effort` 的非空字符串,保留客户端设置的档位。 +未显式设置时保持未知,不根据 `thinking.budget_tokens` 或模型默认值推断;该记录不代表上游最终执行的档位 + 首页趋势、请求健康与热点诊断统一口径:错误为服务失败与未完成之和,成功率为 `成功 /(成功 + 错误)` 已恢复记录、客户端取消与调用方错误不计入,进行中不参与比例;调用方错误仅指失败终态的 4xx,不含取消请求的 499 无有效样本时,成功率与错误率返回 `null`,趋势线保留断点