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 <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-11-11 15:12:02 +08:00
committed by GitHub
co-authored by Zyy Codex
parent 343c579017
commit c0fdc1bbff
5 changed files with 85 additions and 6 deletions
@@ -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 载荷的协议标识
@@ -1,16 +1,22 @@
//! 按目标模型能力映射推理等级与预算;未知兼容上游沿用 CPA 手动预算
//! 提取 Messages 显式 effort,并按目标模型能力映射推理等级与预算
use serde_json::{Map, Value, json};
use super::MessagesModelCapabilities;
pub(super) fn to_responses(request: &Map<String, Value>) -> Value {
let configured = request
/// 返回客户端显式设置的 effort,预算与默认档位由转换器单独处理
#[must_use]
pub fn messages_request_effort(request: &Map<String, Value>) -> 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<String, Value>) -> 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"))
@@ -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();
};
@@ -29,6 +29,7 @@ async fn seed(store: &Arc<MemoryAccountStore>, 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",
+3
View File
@@ -2841,6 +2841,9 @@ OpenAI 推理强度优先取 `input` 中最后一条 `type: "configuration_updat
原生续接未再次更新时沿用已关联响应的配置,否则取请求顶层 `reasoning.effort`。
该字段是请求观测,不改写上游请求;网关未持有的续接历史无法用于推断档位
Messages 推理强度取请求顶层 `output_config.effort` 的非空字符串,保留客户端设置的档位。
未显式设置时保持未知,不根据 `thinking.budget_tokens` 或模型默认值推断;该记录不代表上游最终执行的档位
首页趋势、请求健康与热点诊断统一口径:错误为服务失败与未完成之和,成功率为 `成功 /(成功 + 错误)`
已恢复记录、客户端取消与调用方错误不计入,进行中不参与比例;调用方错误仅指失败终态的 4xx,不含取消请求的 499
无有效样本时,成功率与错误率返回 `null`,趋势线保留断点