mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 08:06:19 +00:00
fix(openai): observe reasoning effort updates across continuations
Resolve reasoning effort from configuration updates and preserve overrides in response-scoped session metadata without rewriting upstream requests. Cover HTTP and WebSocket continuations, new-chain resets, and baseline changes. Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -1015,7 +1015,7 @@ pub struct ProviderRequest {
|
||||
pub struct ProviderRequestObservation {
|
||||
/// 独立 Provider 端点声明的请求模型,仅用于观测,不参与文本模型目录路由
|
||||
pub requested_model: Option<PublicModelId>,
|
||||
/// 客户端原始请求中的推理强度
|
||||
/// Provider 从客户端请求与续接配置中解析的推理强度
|
||||
pub reasoning_effort: Option<String>,
|
||||
pub reasoning_preset: Option<String>,
|
||||
pub request_kind: Option<String>,
|
||||
|
||||
@@ -105,8 +105,10 @@ fn codex_identity_field(
|
||||
/// 从 OpenAI Responses 请求中提取的稳定 Codex 请求语义
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct CodexResponsesRequestSemantics {
|
||||
/// 客户端原始选择的推理强度
|
||||
/// 按最新配置更新与请求基线解析的推理强度
|
||||
pub reasoning_effort: Option<String>,
|
||||
/// 对话内持续生效的推理强度更新,供原生续接继承
|
||||
pub reasoning_effort_override: Option<String>,
|
||||
/// Codex turn metadata 中的请求类型
|
||||
pub request_kind: Option<String>,
|
||||
/// Codex turn metadata 中的子代理类型
|
||||
@@ -126,24 +128,31 @@ pub fn codex_responses_request_semantics(
|
||||
codex_responses_request_semantics_with_turn_metadata(
|
||||
body,
|
||||
non_empty_string(context.get("turn_metadata")),
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
/// 调用方已解析权威 turn metadata 时,从 Responses body 中提取 Codex 语义
|
||||
/// 结合权威 turn metadata 与原生续接配置,从 Responses body 中提取 Codex 语义
|
||||
#[must_use]
|
||||
pub fn codex_responses_request_semantics_with_turn_metadata(
|
||||
body: &Map<String, Value>,
|
||||
turn_metadata: Option<&str>,
|
||||
previous_reasoning_effort_override: Option<&str>,
|
||||
) -> CodexResponsesRequestSemantics {
|
||||
let reasoning_effort = body
|
||||
.get("reasoning")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|reasoning| reasoning.get("effort"))
|
||||
.and_then(|effort| {
|
||||
non_empty_string(Some(effort))
|
||||
// 配置更新不改变顶层基线,只有 previous_response_id 续接才继承上一响应的更新
|
||||
let reasoning_effort_override = input_items(body)
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|item| item.get("type").and_then(Value::as_str) == Some("configuration_update"))
|
||||
.map(|item| reasoning_effort(item.get("reasoning")))
|
||||
.unwrap_or_else(|| {
|
||||
previous_reasoning_effort_override
|
||||
.filter(|_| non_empty_string(body.get("previous_response_id")).is_some())
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| effort.as_u64().map(|value| value.to_string()))
|
||||
});
|
||||
let reasoning_effort = reasoning_effort_override
|
||||
.clone()
|
||||
.or_else(|| reasoning_effort(body.get("reasoning")));
|
||||
let parsed_turn_metadata = turn_metadata
|
||||
.or_else(|| request_turn_metadata(body))
|
||||
.and_then(|value| serde_json::from_str::<Value>(value).ok());
|
||||
@@ -168,6 +177,7 @@ pub fn codex_responses_request_semantics_with_turn_metadata(
|
||||
|
||||
CodexResponsesRequestSemantics {
|
||||
reasoning_effort,
|
||||
reasoning_effort_override,
|
||||
request_kind,
|
||||
subagent_kind,
|
||||
reasoning_preset,
|
||||
@@ -175,6 +185,13 @@ pub fn codex_responses_request_semantics_with_turn_metadata(
|
||||
}
|
||||
}
|
||||
|
||||
fn reasoning_effort(reasoning: Option<&Value>) -> Option<String> {
|
||||
let effort = reasoning?.get("effort")?;
|
||||
non_empty_string(Some(effort))
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| effort.as_u64().map(|value| value.to_string()))
|
||||
}
|
||||
|
||||
fn request_turn_metadata(body: &Map<String, Value>) -> Option<&str> {
|
||||
non_empty_string(body.get("turnMetadata"))
|
||||
.or_else(|| non_empty_string(body.get("turn_metadata")))
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//! 验证 Codex 会话身份优先级、轮次元数据与多代理模式解析
|
||||
|
||||
use gateway_protocol::openai::{
|
||||
codex_responses_request_semantics, codex_session_id, codex_thread_id,
|
||||
codex_responses_request_semantics, codex_responses_request_semantics_with_turn_metadata,
|
||||
codex_session_id, codex_thread_id,
|
||||
};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
@@ -24,6 +25,56 @@ fn reasoning_effort_should_observe_unsigned_numbers_without_changing_the_body()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reasoning_effort_should_follow_the_latest_configuration_update() {
|
||||
for (input, previous_id, inherited, expected) in [
|
||||
(json!([]), None, None, "medium"),
|
||||
(json!([]), Some("resp_previous"), Some("high"), "high"),
|
||||
(json!([]), None, Some("high"), "medium"),
|
||||
(
|
||||
json!([
|
||||
{"type":"configuration_update","reasoning":{"effort":"high"}},
|
||||
{"type":"message","role":"user","content":"first turn"},
|
||||
{"type":"configuration_update","reasoning":{"effort":"low"}},
|
||||
{"type":"message","role":"user","content":"next turn"}
|
||||
]),
|
||||
Some("resp_previous"),
|
||||
Some("max"),
|
||||
"low",
|
||||
),
|
||||
(
|
||||
json!([{"type":"configuration_update","reasoning":{"effort":64}}]),
|
||||
None,
|
||||
None,
|
||||
"64",
|
||||
),
|
||||
(
|
||||
json!([{"type":"configuration_update","reasoning":{"effort":" future-value "}}]),
|
||||
None,
|
||||
None,
|
||||
"future-value",
|
||||
),
|
||||
(
|
||||
json!([{"type":"message","role":"user","reasoning":{"effort":"high"},
|
||||
"content":"configuration_update: high"}]),
|
||||
None,
|
||||
None,
|
||||
"medium",
|
||||
),
|
||||
] {
|
||||
let body = object(json!({
|
||||
"reasoning":{"effort":"medium"},
|
||||
"input":input,
|
||||
"previous_response_id":previous_id,
|
||||
}));
|
||||
let original = body.clone();
|
||||
let semantics =
|
||||
codex_responses_request_semantics_with_turn_metadata(&body, None, inherited);
|
||||
assert_eq!(semantics.reasoning_effort.as_deref(), Some(expected));
|
||||
assert_eq!(body, original);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_session_should_prefer_the_connection_identity_over_body_metadata() {
|
||||
let body = object(
|
||||
@@ -147,7 +198,7 @@ fn codex_semantics_should_use_protocol_context_before_body_metadata() {
|
||||
|
||||
#[test]
|
||||
fn codex_semantics_should_detect_ultra_preset_from_proactive_multi_agent() {
|
||||
let body = object(json!({
|
||||
let mut body = object(json!({
|
||||
"model": "gpt-test",
|
||||
"reasoning": {"effort": "max"},
|
||||
"input": [
|
||||
@@ -167,6 +218,16 @@ fn codex_semantics_should_detect_ultra_preset_from_proactive_multi_agent() {
|
||||
assert_eq!(semantics.reasoning_effort.as_deref(), Some("max"));
|
||||
assert_eq!(semantics.reasoning_preset, Some("ultra"));
|
||||
assert!(!semantics.compact);
|
||||
|
||||
for (effort, expected_preset) in [("high", None), ("max", Some("ultra"))] {
|
||||
body["input"]
|
||||
.as_array_mut()
|
||||
.unwrap()
|
||||
.push(json!({"type":"configuration_update","reasoning":{"effort":effort}}));
|
||||
let semantics = codex_responses_request_semantics(&body, &Map::new());
|
||||
assert_eq!(semantics.reasoning_effort.as_deref(), Some(effort));
|
||||
assert_eq!(semantics.reasoning_preset, expected_preset);
|
||||
}
|
||||
}
|
||||
|
||||
fn object(value: Value) -> Map<String, Value> {
|
||||
|
||||
@@ -425,6 +425,8 @@ pub(super) struct OpenAiSessionState {
|
||||
pub(super) turn_state: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(super) client_turn_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(super) reasoning_effort_override: Option<String>,
|
||||
pub(super) continuation_scope: OpenAiContinuationScope,
|
||||
}
|
||||
|
||||
@@ -442,6 +444,7 @@ pub(super) struct OpenAiSessionCapture {
|
||||
pub(super) conversation_id: Option<String>,
|
||||
pub(super) turn_state: Option<String>,
|
||||
pub(super) client_turn_id: Option<String>,
|
||||
pub(super) reasoning_effort_override: Option<String>,
|
||||
pub(super) response_store: bool,
|
||||
pub(super) continuation_scope: Option<OpenAiContinuationScope>,
|
||||
}
|
||||
@@ -488,6 +491,7 @@ fn encode_openai_session_capture(
|
||||
conversation_id: capture.conversation_id.clone(),
|
||||
turn_state: capture.turn_state.clone(),
|
||||
client_turn_id: capture.client_turn_id.clone(),
|
||||
reasoning_effort_override: capture.reasoning_effort_override.clone(),
|
||||
continuation_scope,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -494,7 +494,12 @@ impl Provider for CodexProvider {
|
||||
let Ok(encoded) = encode_generate_request(request, "observability", None) else {
|
||||
return ProviderRequestObservation::default();
|
||||
};
|
||||
let semantics = encoded.semantics();
|
||||
let previous_session = decode_openai_session_state(request);
|
||||
let semantics = encoded.semantics(
|
||||
previous_session
|
||||
.as_ref()
|
||||
.and_then(|state| state.reasoning_effort_override.as_deref()),
|
||||
);
|
||||
let reasoning_effort = semantics.reasoning_effort.clone();
|
||||
let previous_response_id = encoded.previous_response_id();
|
||||
let continuation = ContinuationRequestObservation {
|
||||
@@ -1077,6 +1082,13 @@ impl CodexProvider {
|
||||
conversation_id: upstream_request.local_conversation_id.clone(),
|
||||
turn_state: upstream_request.turn_state.clone(),
|
||||
client_turn_id: upstream_request.client_turn_id.clone(),
|
||||
reasoning_effort_override: upstream_request
|
||||
.semantics(
|
||||
previous_session
|
||||
.as_ref()
|
||||
.and_then(|state| state.reasoning_effort_override.as_deref()),
|
||||
)
|
||||
.reasoning_effort_override,
|
||||
response_store,
|
||||
continuation_scope: None,
|
||||
});
|
||||
|
||||
@@ -432,7 +432,7 @@ pub(super) fn openai_response_request_summary(
|
||||
let body = request.body();
|
||||
let input = body.get("input");
|
||||
let tools = body.get("tools");
|
||||
let semantics = request.semantics();
|
||||
let semantics = request.semantics(None);
|
||||
json!({
|
||||
"model": request.model(),
|
||||
"stream": request.stream(),
|
||||
|
||||
@@ -23,6 +23,7 @@ pub(crate) fn follows_session_with_headers(
|
||||
gateway_protocol::openai::codex_responses_request_semantics_with_turn_metadata(
|
||||
body,
|
||||
metadata.or_else(|| context.get("turn_metadata").and_then(Value::as_str)),
|
||||
None,
|
||||
)
|
||||
.subagent_kind
|
||||
.is_some()
|
||||
|
||||
@@ -760,10 +760,14 @@ impl CodexResponsesRequest {
|
||||
}
|
||||
|
||||
/// 提取 Codex 请求类型、子代理类型、推理预设与压缩语义
|
||||
pub fn semantics(&self) -> CodexRequestSemantics {
|
||||
pub fn semantics(
|
||||
&self,
|
||||
previous_reasoning_effort_override: Option<&str>,
|
||||
) -> CodexRequestSemantics {
|
||||
let mut semantics = codex_responses_request_semantics_with_turn_metadata(
|
||||
self.body(),
|
||||
self.turn_metadata.as_deref(),
|
||||
previous_reasoning_effort_override,
|
||||
);
|
||||
// 官方 generate=false 只准备连接与上下文,不是模型推理
|
||||
// 预热分类参与用量筛选,不能仅凭客户端的 request_kind 提示排除真实推理
|
||||
@@ -782,7 +786,7 @@ impl CodexResponsesRequest {
|
||||
/// 它不拆分根会话的账号首选项;
|
||||
/// `thread_spawn` 仍用它派生独立 WebSocket/continuation transport identity
|
||||
pub fn subagent_kind(&self) -> Option<String> {
|
||||
self.semantics()
|
||||
self.semantics(None)
|
||||
.subagent_kind
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.or_else(|| {
|
||||
|
||||
@@ -10256,6 +10256,183 @@ fn request_observation_preserves_the_raw_reasoning_effort() {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reasoning_effort_observation_follows_configuration_updates_across_continuations() {
|
||||
fn completed(index: usize) -> Value {
|
||||
json!({"type":"response.completed","response":{
|
||||
"id":format!("resp_effort_{index}"),"model":"gpt-5.4","status":"completed",
|
||||
"output":[],"reasoning":{"effort":if index == 4 { "high" } else { "medium" }},
|
||||
"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}
|
||||
}})
|
||||
}
|
||||
|
||||
for use_websocket in [false, true] {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
create_account(&store, "acct_provider_contract").await;
|
||||
let http = MockServer::start().await;
|
||||
let count = std::sync::atomic::AtomicUsize::new(0);
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/codex/responses"))
|
||||
.respond_with(move |_: &wiremock::Request| {
|
||||
let index = count.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
ResponseTemplate::new(200)
|
||||
.insert_header("content-type", "text/event-stream")
|
||||
.set_body_string(format!("data: {}\n\n", completed(index)))
|
||||
})
|
||||
.mount(&http)
|
||||
.await;
|
||||
let (base_url, websocket_server) = if use_websocket {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let base_url = format!("http://{}", listener.local_addr().unwrap());
|
||||
let server = tokio::spawn(async move {
|
||||
let (socket, _) = listener.accept().await.unwrap();
|
||||
let mut websocket = accept_codex_test_websocket(socket).await;
|
||||
let mut frames = Vec::new();
|
||||
for index in 0..5 {
|
||||
let message = websocket.next().await.unwrap().unwrap();
|
||||
let frame: Value = serde_json::from_str(message.to_text().unwrap()).unwrap();
|
||||
assert_eq!(frame["type"], "response.create");
|
||||
frames.push(frame);
|
||||
websocket
|
||||
.send(Message::Text(completed(index).to_string().into()))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
frames
|
||||
});
|
||||
(base_url, Some(server))
|
||||
} else {
|
||||
(http.uri(), None)
|
||||
};
|
||||
let provider = provider_with_base_url(&store, base_url);
|
||||
let client_key = ClientApiKeyId::new("key_openai_contract").unwrap();
|
||||
let mut session_state = Some(
|
||||
ProviderSessionState::new(
|
||||
"openai",
|
||||
json!({
|
||||
"account_id":"acct_provider_contract",
|
||||
"conversation_id":"effort-observation",
|
||||
"continuation_scope":if use_websocket { "connection_local" } else { "persisted" },
|
||||
})
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.clone(),
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
let mut originals = Vec::new();
|
||||
for (index, (baseline, update, expected)) in [
|
||||
("medium", Some("high"), "high"),
|
||||
("medium", None, "high"),
|
||||
("medium", Some("low"), "low"),
|
||||
("medium", None, "medium"),
|
||||
("high", None, "high"),
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let mut input = Vec::new();
|
||||
if let Some(effort) = update {
|
||||
input.push(json!({"type":"configuration_update","reasoning":{"effort":effort}}));
|
||||
}
|
||||
input.push(json!({"type":"message","role":"user","content":"next turn"}));
|
||||
let mut body = json!({
|
||||
"model":"gpt-5.4","input":input,"reasoning":{"effort":baseline},
|
||||
"session_id":"effort-observation","store":!use_websocket,
|
||||
});
|
||||
let previous = (index > 0 && index != 3).then(|| format!("resp_effort_{}", index - 1));
|
||||
if let Some(previous) = &previous {
|
||||
body["previous_response_id"] = json!(previous);
|
||||
}
|
||||
let mut protocol_context =
|
||||
Map::from_iter([("use_websocket".to_owned(), json!(use_websocket))]);
|
||||
if use_websocket {
|
||||
protocol_context.insert(
|
||||
"downstream_websocket_connection_id".to_owned(),
|
||||
json!("ws_effort"),
|
||||
);
|
||||
}
|
||||
let mut generate = GenerateRequest::from_protocol_payload(
|
||||
ProtocolPayload::json_object("openai", body.as_object().unwrap().clone())
|
||||
.unwrap()
|
||||
.with_context(protocol_context),
|
||||
);
|
||||
if let Some(state) = session_state.take() {
|
||||
generate = generate.with_provider_session_state(state);
|
||||
}
|
||||
let operation = Operation::Generate(generate);
|
||||
let observation = provider.request_observation(&operation, &client_key);
|
||||
assert_eq!(observation.reasoning_effort.as_deref(), Some(expected));
|
||||
let binding = previous.as_ref().map(|previous| {
|
||||
ContinuationBinding::Pinned(NativeContinuationPin::new(
|
||||
PreviousResponseId::new(previous),
|
||||
PreviousResponseId::new(previous),
|
||||
client_key.clone(),
|
||||
ProviderKind::new("openai").unwrap(),
|
||||
ProviderAccountId::new("acct_provider_contract").unwrap(),
|
||||
))
|
||||
});
|
||||
let context = AttemptContext::new(
|
||||
RequestAttemptContext::new(
|
||||
ModelRequestId::new(format!("req_effort_{index}")).unwrap(),
|
||||
client_key.clone(),
|
||||
),
|
||||
NonZeroU32::new(1).unwrap(),
|
||||
SystemTime::now() + Duration::from_secs(30),
|
||||
account_policy(),
|
||||
AccountAttemptContext::new(BTreeSet::new(), None, None)
|
||||
.with_account_scope(contract_account_scope()),
|
||||
binding,
|
||||
CancellationToken::new(),
|
||||
)
|
||||
.with_continuation_attempt(if previous.is_some() {
|
||||
ContinuationAttempt::Native
|
||||
} else {
|
||||
ContinuationAttempt::None
|
||||
});
|
||||
let mut stream = Arc::clone(&provider)
|
||||
.execute(planned_request("openai", operation), context)
|
||||
.await
|
||||
.unwrap();
|
||||
while let Some(event) = timeout(Duration::from_secs(5), stream.next())
|
||||
.await
|
||||
.unwrap()
|
||||
{
|
||||
let event = event.unwrap_or_else(|error| {
|
||||
panic!("use_websocket={use_websocket}, turn={index}: {error:?}")
|
||||
});
|
||||
if let Some(update) = event.session_update() {
|
||||
session_state = Some(update.clone());
|
||||
}
|
||||
}
|
||||
assert!(session_state.is_some());
|
||||
originals.push(body);
|
||||
}
|
||||
let forwarded = if let Some(server) = websocket_server {
|
||||
timeout(Duration::from_secs(5), server)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
} else {
|
||||
http.received_requests()
|
||||
.await
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(captured_request_body)
|
||||
.collect()
|
||||
};
|
||||
assert_eq!(forwarded.len(), originals.len());
|
||||
for (forwarded, original) in forwarded.iter().zip(&originals) {
|
||||
assert_eq!(forwarded["input"], original["input"]);
|
||||
assert_eq!(forwarded["reasoning"], original["reasoning"]);
|
||||
assert_eq!(
|
||||
forwarded.get("previous_response_id"),
|
||||
original.get("previous_response_id")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn provider_compiles_catalog_presentation_for_codex_models() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
|
||||
@@ -775,7 +775,7 @@ fn observability_semantics_should_reuse_codex_turn_metadata() {
|
||||
|
||||
let semantics = encode_generate_request(&request, "observability", None)
|
||||
.expect("observability request should encode")
|
||||
.semantics();
|
||||
.semantics(None);
|
||||
|
||||
assert_eq!(semantics.request_kind.as_deref(), Some("compaction"));
|
||||
assert_eq!(semantics.subagent_kind.as_deref(), Some("review"));
|
||||
@@ -845,7 +845,7 @@ fn observability_semantics_should_use_the_transparent_openai_payload() {
|
||||
|
||||
let semantics = encode_generate_request(&request, "observability", None)
|
||||
.expect("transparent OpenAI request should encode")
|
||||
.semantics();
|
||||
.semantics(None);
|
||||
|
||||
assert_eq!(semantics.reasoning_preset, Some("ultra"));
|
||||
assert!(semantics.compact);
|
||||
|
||||
@@ -2118,6 +2118,10 @@ Dashboard 返回原始 `asOf` 及 `asOfDisplay`,作为当前数据的查询锚
|
||||
|
||||
### 记录范围与统计口径
|
||||
|
||||
OpenAI 推理强度优先取 `input` 中最后一条 `type: "configuration_update"` 项的 `reasoning.effort`,
|
||||
原生续接未再次更新时沿用已关联响应的配置,否则取请求顶层 `reasoning.effort`。
|
||||
该字段是请求观测,不改写上游请求;网关未持有的续接历史无法用于推断档位
|
||||
|
||||
请求健康可用性与服务成功率按 `成功 /(成功 + 服务失败 + 未完成)` 计算
|
||||
未完成保留独立计数,客户端取消与调用方错误不计入该分母,中间尝试失败不重复计为最终失败请求
|
||||
健康时间线以 15 分钟分桶,可用性不低于 95% 且有效样本不少于 10 个时显示稳定
|
||||
|
||||
Reference in New Issue
Block a user