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`,趋势线保留断点