diff --git a/crates/focus-providers/src/openai.rs b/crates/focus-providers/src/openai.rs index e938a0f..79f3f35 100644 --- a/crates/focus-providers/src/openai.rs +++ b/crates/focus-providers/src/openai.rs @@ -367,6 +367,29 @@ fn messages_to_responses(messages: &[Message]) -> JsonValue { items.push(item).ok(); } Message::Assistant(a) => { + // 推理内容必须回传为 reasoning item(thinking 模式 API 校验)。 + // Thinking must be replayed as a reasoning item (thinking-mode + // APIs validate this). + let reasoning: String = a + .content + .iter() + .filter_map(|c| match c { + ContentBlock::Thinking(t) => Some(t.thinking.clone()), + _ => None, + }) + .collect(); + if !reasoning.is_empty() { + let mut ri = JsonValue::obj(); + ri.insert("type", "reasoning".into()).ok(); + let mut part = JsonValue::obj(); + part.insert("type", "reasoning_text".into()).ok(); + part.insert("text", reasoning.into()).ok(); + let mut content = JsonValue::arr(); + content.push(part).ok(); + ri.insert("content", content).ok(); + ri.insert("summary", JsonValue::arr()).ok(); + items.push(ri).ok(); + } let mut item = JsonValue::obj(); item.insert("role", "assistant".into()).ok(); let mut content = JsonValue::arr(); @@ -694,6 +717,16 @@ impl OpenAiTurn { } } } + "reasoning" => { + // 推理条目:打开思考块,随后的 reasoning_text.delta 填充。 + // A reasoning item: open a thinking block; the + // following reasoning_text.delta events fill it. + for e in self.reducer.thinking_delta("") { + if !common::send(tx, e) { + return Ok(false); + } + } + } _ => {} } } @@ -724,6 +757,20 @@ impl OpenAiTurn { } Ok(true) } + "response.reasoning_text.delta" => { + // 推理增量(DeepSeek 等 thinking 模式)→ 思考块。 + // Reasoning deltas (thinking mode, e.g. DeepSeek) → thinking. + if let Some(delta) = json.get_str("delta") { + if !delta.is_empty() { + for e in self.reducer.thinking_delta(delta) { + if !common::send(tx, e) { + return Ok(false); + } + } + } + } + Ok(true) + } "response.completed" => { if let Some(resp) = json.get("response") { match resp.get_str("status").unwrap_or("completed") { diff --git a/crates/focus-providers/tests/openai_tests.rs b/crates/focus-providers/tests/openai_tests.rs index a196105..142cc86 100644 --- a/crates/focus-providers/tests/openai_tests.rs +++ b/crates/focus-providers/tests/openai_tests.rs @@ -540,3 +540,81 @@ fn chat_replays_both_reasoning_field_names() { assert_eq!(assistant.get_str("reasoning_content"), Some("思考内容")); assert_eq!(assistant.get_str("reasoning_text"), Some("思考内容")); } + +/// Responses API 的推理内容(reasoning_text.delta)必须被捕获为思考块(回归, +/// 依据真实端点日志:deepseek-v4-flash 走 Responses 协议)。 +/// Responses API reasoning (reasoning_text.delta) must be captured as a +/// thinking block (regression, based on a real endpoint log). +#[test] +fn responses_captures_reasoning() { + let mut mock = common::MockTransport::new(); + mock.push_body( + "event: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"type\":\"reasoning\",\"id\":\"r1\",\"status\":\"in_progress\",\"content\":[],\"summary\":[]},\"output_index\":0}\n\n\ + event: response.reasoning_text.delta\ndata: {\"type\":\"response.reasoning_text.delta\",\"content_index\":0,\"delta\":\"The user\",\"item_id\":\"r1\",\"output_index\":0}\n\n\ + event: response.reasoning_text.delta\ndata: {\"type\":\"response.reasoning_text.delta\",\"content_index\":0,\"delta\":\" asks about the project\",\"item_id\":\"r1\",\"output_index\":0}\n\n\ + event: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"type\":\"function_call\",\"id\":\"f1\",\"status\":\"in_progress\",\"arguments\":\"\",\"call_id\":\"call_x\",\"name\":\"shell\"},\"output_index\":1}\n\n\ + event: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"{\\\"command\\\":\\\"ls\\\"}\",\"item_id\":\"f1\",\"output_index\":1}\n\n\ + event: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"usage\":{\"input_tokens\":10,\"output_tokens\":20,\"output_tokens_details\":{\"reasoning_tokens\":5}}}}\n\n", + ); + let provider = provider(mock, OpenAiProtocol::Responses); + let events = common::collect(&provider, &request()); + + let msg = final_message(&events); + // 顺序:思考块 → 工具调用。 + // Order: thinking block → tool call. + assert_eq!(msg.content.len(), 2); + match &msg.content[0] { + ContentBlock::Thinking(t) => { + assert_eq!(t.thinking, "The user asks about the project"); + } + other => panic!("expected thinking block, got {:?}", other), + } + let tc = msg.content[1].as_tool_call().expect("tool call"); + assert_eq!(tc.name, "shell"); + assert_eq!(tc.arguments.get_str("command"), Some("ls")); +} + +/// 历史回传时,assistant 的思考必须以 reasoning item 回传。 +/// Replaying history must include the assistant thinking as a reasoning item. +#[test] +fn responses_replays_reasoning_item() { + let request = ProviderRequest { + model: "deepseek-v4-flash".into(), + system_prompt: "sys".into(), + messages: vec![ + Message::user_text("hi"), + Message::Assistant(AssistantMessage { + content: vec![ContentBlock::Thinking(ThinkingContent { + thinking: "思考内容".into(), + signature: None, + redacted: false, + })], + model: "deepseek-v4-flash".into(), + usage: Usage::default(), + stop_reason: StopReason::Stop, + error_message: None, + timestamp: 0, + }), + ], + tools: focus_json::JsonValue::arr(), + max_tokens: Some(512), + temperature: None, + }; + + let mut mock = common::MockTransport::new(); + mock.push_body("data: [DONE]\n\n"); + let provider = provider(mock.clone(), OpenAiProtocol::Responses); + let _ = common::collect(&provider, &request); + + let req = mock.last_request(); + let body: focus_json::JsonValue = + focus_json::parse(&String::from_utf8_lossy(&req.body)).unwrap(); + let input = body.get_arr("input").unwrap(); + // [0] = user,[1] = reasoning item,[2] = assistant item。 + // [0] = user, [1] = reasoning item, [2] = assistant item. + let reasoning = &input[1]; + assert_eq!(reasoning.get_str("type"), Some("reasoning")); + let content = reasoning.get_arr("content").unwrap(); + assert_eq!(content[0].get_str("type"), Some("reasoning_text")); + assert_eq!(content[0].get_str("text"), Some("思考内容")); +}