fix: guard responses reasoning context
This commit is contained in:
@@ -62,6 +62,9 @@ pub(crate) struct ResponsesRequest {
|
||||
pub(crate) struct ReasoningConfig {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub effort: Option<String>,
|
||||
/// Reasoning encrypted_content は同一 user turn 内だけ再利用する。
|
||||
/// 古い turn の reasoning item は request input から除外する。
|
||||
pub context: &'static str,
|
||||
/// summary の出力制御。`"auto"` 固定で summary_text を受け取る。
|
||||
pub summary: &'static str,
|
||||
}
|
||||
@@ -193,6 +196,7 @@ impl OpenAIResponsesScheme {
|
||||
ReasoningControl::Effort(effort) => Some(effort.as_str().to_string()),
|
||||
ReasoningControl::BudgetTokens(_) => None,
|
||||
},
|
||||
context: "current_turn",
|
||||
summary: "auto",
|
||||
})
|
||||
.filter(|reasoning| reasoning.effort.is_some());
|
||||
@@ -236,8 +240,9 @@ impl OpenAIResponsesScheme {
|
||||
|
||||
/// `Item` 列を `input[]` に変換する。
|
||||
fn convert_items_to_input(items: &[Item]) -> Vec<InputItem> {
|
||||
let current_turn_start = current_turn_start_index(items);
|
||||
let mut out = Vec::with_capacity(items.len());
|
||||
for item in items {
|
||||
for (idx, item) in items.iter().enumerate() {
|
||||
match item {
|
||||
Item::Message { role, content, .. } => {
|
||||
let (role_str, text_variant): (&'static str, fn(String) -> InputContent) =
|
||||
@@ -294,6 +299,9 @@ fn convert_items_to_input(items: &[Item]) -> Vec<InputItem> {
|
||||
encrypted_content,
|
||||
..
|
||||
} => {
|
||||
if idx < current_turn_start {
|
||||
continue;
|
||||
}
|
||||
let summary_parts = summary
|
||||
.iter()
|
||||
.filter(|s| !s.is_empty())
|
||||
@@ -316,6 +324,26 @@ fn convert_items_to_input(items: &[Item]) -> Vec<InputItem> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Responses の `reasoning.context = "current_turn"` に合わせ、直近の
|
||||
/// user message 以降だけを current turn とみなす。ToolResult は Responses
|
||||
/// wire 上では user 側 item だが、新しい人間/外部入力ではなく function-call
|
||||
/// chain の継続なので turn reset には使わない。System/developer notes も
|
||||
/// 同一 turn 内の補助入力になり得るため reset しない。
|
||||
fn current_turn_start_index(items: &[Item]) -> usize {
|
||||
items
|
||||
.iter()
|
||||
.rposition(|item| {
|
||||
matches!(
|
||||
item,
|
||||
Item::Message {
|
||||
role: Role::User,
|
||||
..
|
||||
}
|
||||
)
|
||||
})
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn convert_tool(tool: &ToolDefinition) -> ResponseTool {
|
||||
ResponseTool {
|
||||
r#type: "function",
|
||||
@@ -477,6 +505,60 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_turn_reasoning_items_are_omitted_for_current_turn_context() {
|
||||
let scheme = OpenAIResponsesScheme::new();
|
||||
let old_reasoning = Item::reasoning("old").with_encrypted_content("OLD_ENC");
|
||||
let current_reasoning = Item::reasoning("current").with_encrypted_content("CURRENT_ENC");
|
||||
let req = Request::new()
|
||||
.user("old prompt")
|
||||
.item(old_reasoning)
|
||||
.assistant("old answer")
|
||||
.user("new prompt")
|
||||
.item(current_reasoning);
|
||||
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
|
||||
let encrypted: Vec<_> = body
|
||||
.input
|
||||
.iter()
|
||||
.filter_map(|item| match item {
|
||||
InputItem::Reasoning {
|
||||
encrypted_content, ..
|
||||
} => encrypted_content.as_deref(),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(encrypted, vec!["CURRENT_ENC"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_turn_reasoning_is_kept_across_function_call_loop() {
|
||||
let scheme = OpenAIResponsesScheme::new();
|
||||
let req = Request::new()
|
||||
.user("run tool")
|
||||
.item(Item::reasoning("plan").with_encrypted_content("ENC"))
|
||||
.item(Item::tool_call("c1", "tool", "{}"))
|
||||
.item(Item::tool_result("c1", "ok"));
|
||||
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
|
||||
assert!(matches!(body.input[1], InputItem::Reasoning { .. }));
|
||||
assert!(matches!(body.input[2], InputItem::FunctionCall { .. }));
|
||||
assert!(matches!(
|
||||
body.input[3],
|
||||
InputItem::FunctionCallOutput { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reasoning_request_uses_current_turn_context() {
|
||||
let scheme = OpenAIResponsesScheme::new();
|
||||
let mut req = Request::new().user("hi");
|
||||
req.config.reasoning = Some(ReasoningControl::Effort(ReasoningEffort::Medium));
|
||||
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
|
||||
let reasoning = body.reasoning.expect("reasoning should be set");
|
||||
assert_eq!(reasoning.context, "current_turn");
|
||||
let json = serde_json::to_value(reasoning).unwrap();
|
||||
assert_eq!(json["context"], "current_turn");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reasoning_summary_field_is_always_serialized() {
|
||||
// Responses API は reasoning item に `summary` を必須で要求する。
|
||||
@@ -508,6 +590,7 @@ mod tests {
|
||||
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
|
||||
let reasoning = body.reasoning.expect("reasoning should be set");
|
||||
assert_eq!(reasoning.effort.as_deref(), Some("high"));
|
||||
assert_eq!(reasoning.context, "current_turn");
|
||||
assert_eq!(reasoning.summary, "auto");
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ use futures::{Stream, StreamExt, TryStreamExt};
|
||||
use reqwest::header::{
|
||||
ACCEPT, CONTENT_ENCODING, CONTENT_TYPE, HeaderMap, HeaderName, HeaderValue, RETRY_AFTER,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use super::auth::{AuthProvider, AuthRequirement};
|
||||
use super::capability::ModelCapability;
|
||||
@@ -260,6 +260,60 @@ fn json_value_kind(value: &Value) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
fn request_body_shape_payload(body: &Value) -> Value {
|
||||
let mut map = Map::new();
|
||||
if let Some(input) = body.get("input").and_then(Value::as_array) {
|
||||
let items_json_bytes = serde_json::to_vec(input).map(|bytes| bytes.len()).ok();
|
||||
let mut reasoning_items = 0usize;
|
||||
let mut reasoning_encrypted_content_count = 0usize;
|
||||
let mut reasoning_encrypted_content_bytes = 0usize;
|
||||
for item in input {
|
||||
if item.get("type").and_then(Value::as_str) != Some("reasoning") {
|
||||
continue;
|
||||
}
|
||||
reasoning_items += 1;
|
||||
if let Some(encrypted) = item.get("encrypted_content").and_then(Value::as_str) {
|
||||
reasoning_encrypted_content_count += 1;
|
||||
reasoning_encrypted_content_bytes += encrypted.len();
|
||||
}
|
||||
}
|
||||
map.insert("items_len".to_string(), json!(input.len()));
|
||||
map.insert("items_json_bytes".to_string(), json!(items_json_bytes));
|
||||
map.insert("reasoning_items".to_string(), json!(reasoning_items));
|
||||
map.insert(
|
||||
"reasoning_encrypted_content_count".to_string(),
|
||||
json!(reasoning_encrypted_content_count),
|
||||
);
|
||||
map.insert(
|
||||
"reasoning_encrypted_content_bytes".to_string(),
|
||||
json!(reasoning_encrypted_content_bytes),
|
||||
);
|
||||
}
|
||||
let reasoning_context = body
|
||||
.get("reasoning")
|
||||
.and_then(|reasoning| reasoning.get("context"))
|
||||
.and_then(Value::as_str);
|
||||
map.insert("reasoning_context".to_string(), json!(reasoning_context));
|
||||
Value::Object(map)
|
||||
}
|
||||
|
||||
fn api_error_code(error: &ClientError) -> Option<&str> {
|
||||
match error {
|
||||
ClientError::Api { code, .. } => code.as_deref(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_context_length_exceeded(error: &ClientError) -> bool {
|
||||
match error {
|
||||
ClientError::Api { code, message, .. } => {
|
||||
code.as_deref() == Some("context_length_exceeded")
|
||||
|| message.contains("context_length_exceeded")
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
async fn response_with_timeout(
|
||||
future: impl std::future::Future<Output = Result<reqwest::Response, reqwest::Error>>,
|
||||
timeout: Duration,
|
||||
@@ -296,7 +350,11 @@ async fn classify_error_response(resp: reqwest::Response) -> ClientError {
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&text) {
|
||||
let error = json.get("error").unwrap_or(&json);
|
||||
let code = error.get("type").and_then(|v| v.as_str()).map(String::from);
|
||||
let code = error
|
||||
.get("code")
|
||||
.and_then(|v| v.as_str())
|
||||
.or_else(|| error.get("type").and_then(|v| v.as_str()))
|
||||
.map(String::from);
|
||||
let message = error
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
@@ -406,12 +464,14 @@ impl<S: Scheme + Clone + 'static> LlmClient for HttpTransport<S> {
|
||||
let body = self
|
||||
.scheme
|
||||
.build_request_body(&self.model_id, &request, &self.capability);
|
||||
let body_shape = request_body_shape_payload(&body);
|
||||
emit_transport_trace(
|
||||
&request,
|
||||
"transport_body_build_done",
|
||||
json!({
|
||||
"elapsed_ms": body_started.elapsed().as_millis() as u64,
|
||||
"body_kind": json_value_kind(&body),
|
||||
"request_shape": body_shape.clone(),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -438,6 +498,7 @@ impl<S: Scheme + Clone + 'static> LlmClient for HttpTransport<S> {
|
||||
"encoding": request_body.encoding(),
|
||||
"raw_json_bytes": request_body.raw_json_bytes(),
|
||||
"wire_bytes": request_body.wire_bytes(),
|
||||
"request_shape": body_shape.clone(),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -479,15 +540,23 @@ impl<S: Scheme + Clone + 'static> LlmClient for HttpTransport<S> {
|
||||
};
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status().as_u16();
|
||||
let retry_after_present = response.headers().get(RETRY_AFTER).is_some();
|
||||
let error = classify_error_response(response).await;
|
||||
let context_length_exceeded = is_context_length_exceeded(&error);
|
||||
emit_transport_trace(
|
||||
&request,
|
||||
"transport_http_status_error",
|
||||
json!({
|
||||
"status": response.status().as_u16(),
|
||||
"retry_after_present": response.headers().get(RETRY_AFTER).is_some(),
|
||||
"status": status,
|
||||
"retry_after_present": retry_after_present,
|
||||
"api_error_code": api_error_code(&error),
|
||||
"context_length_exceeded": context_length_exceeded,
|
||||
"provider_usage_absent": context_length_exceeded,
|
||||
"request_shape": body_shape.clone(),
|
||||
}),
|
||||
);
|
||||
return Err(classify_error_response(response).await);
|
||||
return Err(error);
|
||||
}
|
||||
|
||||
emit_transport_trace(
|
||||
@@ -611,6 +680,24 @@ mod tests {
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_body_shape_counts_reasoning_encrypted_content() {
|
||||
let payload = request_body_shape_payload(&json!({
|
||||
"reasoning": { "context": "current_turn" },
|
||||
"input": [
|
||||
{ "type": "message", "role": "user", "content": [] },
|
||||
{ "type": "reasoning", "encrypted_content": "abc", "summary": [] },
|
||||
{ "type": "reasoning", "encrypted_content": "defgh", "summary": [] }
|
||||
]
|
||||
}));
|
||||
assert_eq!(payload["items_len"], 3);
|
||||
assert_eq!(payload["reasoning_items"], 2);
|
||||
assert_eq!(payload["reasoning_encrypted_content_count"], 2);
|
||||
assert_eq!(payload["reasoning_encrypted_content_bytes"], 8);
|
||||
assert_eq!(payload["reasoning_context"], "current_turn");
|
||||
assert!(payload["items_json_bytes"].as_u64().unwrap() > 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn response_timeout_returns_retryable_lifecycle_timeout() {
|
||||
let err = response_with_timeout(
|
||||
|
||||
@@ -2029,12 +2029,31 @@ fn items_trace_payload(
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let mut reasoning_items = 0usize;
|
||||
let mut reasoning_encrypted_content_count = 0usize;
|
||||
let mut reasoning_encrypted_content_bytes = 0usize;
|
||||
for item in items {
|
||||
if let Item::Reasoning {
|
||||
encrypted_content, ..
|
||||
} = item
|
||||
{
|
||||
reasoning_items += 1;
|
||||
if let Some(encrypted) = encrypted_content {
|
||||
reasoning_encrypted_content_count += 1;
|
||||
reasoning_encrypted_content_bytes += encrypted.len();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
json!({
|
||||
"items_len": items.len(),
|
||||
"items_json_bytes": serde_json::to_vec(items).map(|bytes| bytes.len()).ok(),
|
||||
"tools_len": tools_len,
|
||||
"cache_anchor": cache_anchor,
|
||||
"cache_key_present": cache_key_present,
|
||||
"reasoning_items": reasoning_items,
|
||||
"reasoning_encrypted_content_count": reasoning_encrypted_content_count,
|
||||
"reasoning_encrypted_content_bytes": reasoning_encrypted_content_bytes,
|
||||
"last_item_kind": last.map(item_kind),
|
||||
"last_item_json_bytes": last.and_then(|item| serde_json::to_vec(item).ok().map(|bytes| bytes.len())),
|
||||
"last_tool_result": last_tool_result,
|
||||
|
||||
Reference in New Issue
Block a user