//! OpenAI Responses API リクエスト body 生成 //! //! Chat Completions の `messages` と違い、Responses は `input[]` の //! item 配列で reasoning / function_call / function_call_output が //! first-class。`Item` を素に近い形で `input[]` に投影できる。 use serde::{Serialize, Serializer}; use serde_json::Value; use crate::{ llm_client::{ Request, capability::{ModelCapability, ReasoningControl, ReasoningSupport}, types::{ContentPart, Item, Role, ToolDefinition, image_data_url, parse_tool_arguments}, }, tool::Attachment, }; use super::OpenAIResponsesScheme; #[derive(Debug, Serialize)] #[serde(untagged)] pub(crate) enum FunctionCallOutputBody { Text(String), ContentItems(Vec), } #[derive(Debug, Serialize)] #[serde(tag = "type", rename_all = "snake_case")] pub(crate) enum FunctionCallOutputContentItem { InputText { text: String }, InputImage { image_url: String }, } /// `/v1/responses` のリクエスト body。 #[derive(Debug, Serialize)] pub(crate) struct ResponsesRequest { pub model: String, /// システムプロンプト相当。`input[]` とは別フィールド。 #[serde(skip_serializing_if = "Option::is_none")] pub instructions: Option, pub input: Vec, #[serde(skip_serializing_if = "Vec::is_empty")] pub tools: Vec, /// 常時 `"auto"` を送る。scheme 固定値。 pub tool_choice: &'static str, /// 常時 `true` を送る。scheme 固定値。 pub parallel_tool_calls: bool, #[serde(skip_serializing_if = "Option::is_none")] pub reasoning: Option, /// ZDR / stateless 運用では `false`。 pub store: bool, /// 常時 `true`。 pub stream: bool, /// `["reasoning.encrypted_content"]` 等。 #[serde(skip_serializing_if = "Vec::is_empty")] pub include: Vec<&'static str>, /// 公式 OpenAI Responses API では受理されるが、互換 backend によっては /// 400 で弾く。scheme の `send_max_output_tokens` が `false` のときは /// `None` のまま送る (skip_serializing_if で除外)。 #[serde(skip_serializing_if = "Option::is_none")] pub max_output_tokens: Option, /// 公式 OpenAI Responses API では受理されるが、互換 backend によっては /// `temperature` / `top_p` を 400 で弾く。scheme の /// `send_sampling_params` が `false` のときは `None` のまま送る。 #[serde(skip_serializing_if = "Option::is_none")] pub temperature: Option, #[serde(skip_serializing_if = "Option::is_none")] pub top_p: Option, /// 会話単位の安定キー。明示キーを必要とする backend では、 /// 呼び出し側が安定した conversation identifier を渡す。 /// `Request::cache_key` が `None` のときはキー自体を送らない。 #[serde(skip_serializing_if = "Option::is_none")] pub prompt_cache_key: Option, } /// reasoning 制御。 #[derive(Debug, Serialize)] pub(crate) struct ReasoningConfig { #[serde(skip_serializing_if = "Option::is_none")] pub effort: Option, /// summary の出力制御。`"auto"` 固定で summary_text を受け取る。 pub summary: &'static str, } /// `input[]` の 1 要素。 /// /// Responses API の item 型を素に近い形で投影する。未対応 type は /// 無視(reasoning 送信時に `content: []` の場合は `None` として弾く)。 #[derive(Debug, Serialize)] #[serde(tag = "type", rename_all = "snake_case")] pub(crate) enum InputItem { /// 会話メッセージ。user / assistant / developer のいずれか。 /// `Role::System` items は `developer` として投影する。OpenAI /// Responses 互換 backend の一部は `role: "system"` を拒否するため、 /// system 相当の挿入には `role: "developer"` を使う。 Message { role: &'static str, content: Vec, }, /// 過去の function tool 呼び出し(assistant 側)。 FunctionCall { call_id: String, name: String, /// JSON 文字列(object でなくても正規化済み)。 arguments: String, }, /// function tool の結果(user 側)。 FunctionCallOutput { call_id: String, output: FunctionCallOutputBody, }, /// reasoning item。`encrypted_content` があれば必ず添える。 Reasoning { #[serde(skip_serializing_if = "Option::is_none")] id: Option, /// Responses API は reasoning item に `summary` フィールドを必須で /// 要求する(中身が空でも `[]` として送る必要がある)。GPT-5 など /// summary を返さないモデル + reasoning effort 指定なしのターンでは /// summary text が一切付かないので、ここを skip すると 400 /// "Missing required parameter: 'input[N].summary'" で弾かれる。 summary: Vec, #[serde(skip_serializing_if = "Vec::is_empty")] content: Vec, #[serde(skip_serializing_if = "Option::is_none")] encrypted_content: Option, }, } /// メッセージ content_part。role で input/output を使い分ける。 #[derive(Debug, Serialize)] #[serde(tag = "type", rename_all = "snake_case")] pub(crate) enum InputContent { /// user / developer 側のテキスト InputText { text: String }, /// user 側の画像 /// assistant 側のテキスト OutputText { text: String }, } #[derive(Debug, Serialize)] #[serde(tag = "type", rename_all = "snake_case")] pub(crate) enum ReasoningSummaryPart { SummaryText { text: String }, } #[derive(Debug, Serialize)] #[serde(tag = "type", rename_all = "snake_case")] pub(crate) enum ReasoningContentPart { ReasoningText { text: String }, } /// Responses 用 tool 定義。Chat と違い function キーでネストせず /// トップレベルに `name` / `parameters` が載る。 #[derive(Debug, Serialize)] pub(crate) struct ResponseTool { #[serde(rename = "type")] pub r#type: &'static str, pub name: String, #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, /// OpenAI Responses API は `type:"object"` のパラメータスキーマに /// `properties` が存在することを要求する。schemars は引数なし struct /// から `properties` を含まない最小スキーマを出すので、serialize /// 時に空オブジェクトを補う。 #[serde(serialize_with = "serialize_parameters")] pub parameters: Value, /// Structured output モード制御。デフォルト false。 pub strict: bool, } fn serialize_parameters(value: &Value, s: S) -> Result { if let Some(obj) = value.as_object() && obj.get("type").and_then(Value::as_str) == Some("object") && !obj.contains_key("properties") { let mut patched = obj.clone(); patched.insert("properties".to_string(), Value::Object(Default::default())); return Value::Object(patched).serialize(s); } value.serialize(s) } impl OpenAIResponsesScheme { /// `Request` から wire 形式の body を組み立てる。 pub(crate) fn build_request( &self, model: &str, request: &Request, capability: &ModelCapability, ) -> ResponsesRequest { let input = convert_items_to_input(&request.items, capability.vision); let tools = request.tools.iter().map(convert_tool).collect(); // Reasoning 投影: capability が Effort / Both をサポートし、かつ // request 側で effort が指定されているときだけ reasoning を付ける。 let supports_effort = matches!( capability.reasoning, Some(ReasoningSupport::Effort | ReasoningSupport::Both), ); let reasoning = request .config .reasoning .as_ref() .filter(|_| supports_effort) .map(|effort| ReasoningConfig { effort: match effort { ReasoningControl::Effort(effort) => Some(effort.as_str().to_string()), ReasoningControl::BudgetTokens(_) => None, }, summary: "auto", }) .filter(|reasoning| reasoning.effort.is_some()); let include: Vec<&'static str> = if self.include_encrypted_content { vec!["reasoning.encrypted_content"] } else { Vec::new() }; ResponsesRequest { model: model.to_string(), instructions: request.system_prompt.clone(), input, tools, tool_choice: "auto", parallel_tool_calls: true, reasoning, store: self.store, stream: true, include, max_output_tokens: if self.send_max_output_tokens { request.config.max_tokens } else { None }, temperature: if self.send_sampling_params { request.config.temperature } else { None }, top_p: if self.send_sampling_params { request.config.top_p } else { None }, prompt_cache_key: request.cache_key.clone(), } } } /// `Item` 列を `input[]` に変換する。 fn convert_items_to_input(items: &[Item], supports_images: bool) -> Vec { let mut out = Vec::with_capacity(items.len()); for item in items { match item { Item::Message { role, content, .. } => { let (role_str, text_variant): (&'static str, fn(String) -> InputContent) = match role { Role::User => ("user", |t| InputContent::InputText { text: t }), Role::Assistant => ("assistant", |t| InputContent::OutputText { text: t }), Role::System => ("developer", |t| InputContent::InputText { text: t }), }; let parts: Vec = content .iter() .map(|part| match part { ContentPart::Text { text } => text_variant(text.clone()), ContentPart::Refusal { refusal } => text_variant(refusal.clone()), }) .collect(); out.push(InputItem::Message { role: role_str, content: parts, }); } Item::ToolCall { call_id, name, arguments, .. } => { // 非 object / 旧形式の "null" を "{}" に正規化。 let normalized = parse_tool_arguments(arguments).to_string(); out.push(InputItem::FunctionCall { call_id: call_id.clone(), name: name.clone(), arguments: normalized, }); } Item::ToolResult { call_id, summary, content, attachments, .. } => { let text = match content { Some(c) => format!("{summary}\n{c}"), None => summary.clone(), }; let output = if attachments.is_empty() { FunctionCallOutputBody::Text(text) } else if supports_images { let mut parts = vec![FunctionCallOutputContentItem::InputText { text }]; parts.extend(attachments.iter().map(|attachment| { let Attachment::Image(image) = attachment; FunctionCallOutputContentItem::InputImage { image_url: image_data_url(image.mime_type(), image.data()), } })); FunctionCallOutputBody::ContentItems(parts) } else { FunctionCallOutputBody::Text(format!( "{text}\n[{} image attachment(s) omitted: model does not support images]", attachments.len() )) }; out.push(InputItem::FunctionCallOutput { call_id: call_id.clone(), output, }); } Item::Reasoning { id, text, summary, encrypted_content, .. } => { let summary_parts = summary .iter() .filter(|s| !s.is_empty()) .map(|s| ReasoningSummaryPart::SummaryText { text: s.clone() }) .collect(); let content_parts = if text.is_empty() { Vec::new() } else { vec![ReasoningContentPart::ReasoningText { text: text.clone() }] }; out.push(InputItem::Reasoning { id: id.clone(), summary: summary_parts, content: content_parts, encrypted_content: encrypted_content.clone(), }); } } } out } fn convert_tool(tool: &ToolDefinition) -> ResponseTool { ResponseTool { r#type: "function", name: tool.name.clone(), description: tool.description.clone(), parameters: tool.input_schema.clone(), strict: false, } } #[cfg(test)] mod tests { use super::*; use crate::llm_client::capability::{ CacheStrategy, ModelCapability, ReasoningControl, ReasoningEffort, ReasoningSupport, StructuredOutput, ToolCallingSupport, }; fn cap_with_reasoning() -> ModelCapability { ModelCapability { tool_calling: ToolCallingSupport::Parallel, structured_output: StructuredOutput::JsonSchema, reasoning: Some(ReasoningSupport::Effort), vision: true, prompt_caching: CacheStrategy::Auto, } } fn cap_no_reasoning() -> ModelCapability { ModelCapability { reasoning: None, ..cap_with_reasoning() } } #[test] fn scheme_defaults_to_stateless_zdr() { let s = OpenAIResponsesScheme::new(); assert!(!s.store); assert!(s.include_encrypted_content); } #[test] fn includes_encrypted_content_when_enabled() { let scheme = OpenAIResponsesScheme::new(); let req = Request::new().user("hi"); let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning()); assert_eq!(body.include, vec!["reasoning.encrypted_content"]); assert!(!body.store); assert!(body.stream); } #[test] fn instructions_from_system_prompt() { let scheme = OpenAIResponsesScheme::new(); let req = Request::new().system("be terse").user("hi"); let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning()); assert_eq!(body.instructions.as_deref(), Some("be terse")); assert_eq!(body.input.len(), 1); } #[test] fn tool_choice_and_parallel_are_fixed() { let scheme = OpenAIResponsesScheme::new(); let req = Request::new().user("hi"); let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning()); assert_eq!(body.tool_choice, "auto"); assert!(body.parallel_tool_calls); } #[test] fn user_message_uses_input_text() { let scheme = OpenAIResponsesScheme::new(); let req = Request::new().user("hi"); let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning()); match &body.input[0] { InputItem::Message { role, content } => { assert_eq!(*role, "user"); assert_eq!(content.len(), 1); assert!(matches!(&content[0], InputContent::InputText { text } if text == "hi")); } _ => panic!("expected message"), } } #[test] fn system_role_item_is_projected_as_developer() { // Some compatible backends reject `role: "system"` in input[]. // Project in-conversation system notes as `role: "developer"` so // both official and compatible backends can accept them. let scheme = OpenAIResponsesScheme::new(); let req = Request::new() .user("hi") .item(Item::system_message("[notify] hello")); let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning()); match &body.input[1] { InputItem::Message { role, content } => { assert_eq!(*role, "developer"); assert!( matches!(&content[0], InputContent::InputText { text } if text == "[notify] hello"), ); } _ => panic!("expected message"), } } #[test] fn assistant_message_uses_output_text() { let scheme = OpenAIResponsesScheme::new(); let req = Request::new().user("hi").assistant("hello"); let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning()); match &body.input[1] { InputItem::Message { role, content } => { assert_eq!(*role, "assistant"); assert!( matches!(&content[0], InputContent::OutputText { text } if text == "hello") ); } _ => panic!("expected message"), } } #[test] fn tool_call_and_result_become_function_items() { let scheme = OpenAIResponsesScheme::new(); let req = Request::new() .user("run") .item(Item::tool_call("c1", "t", r#"{"a":1}"#)) .item(Item::tool_result("c1", "ok")); let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning()); assert!(matches!(body.input[1], InputItem::FunctionCall { .. })); assert!(matches!( body.input[2], InputItem::FunctionCallOutput { .. } )); } #[test] fn reasoning_item_round_trips_encrypted_content() { let scheme = OpenAIResponsesScheme::new(); let item = Item::reasoning("inner") .with_reasoning_summary(vec!["s1".into()]) .with_encrypted_content("ENC"); let req = Request::new().user("hi").item(item); let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning()); match &body.input[1] { InputItem::Reasoning { summary, content, encrypted_content, .. } => { assert_eq!(summary.len(), 1); assert_eq!(content.len(), 1); assert_eq!(encrypted_content.as_deref(), Some("ENC")); } _ => panic!("expected reasoning"), } } #[test] fn persisted_reasoning_items_are_preserved_across_user_turns() { 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!["OLD_ENC", "CURRENT_ENC"]); } #[test] fn 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_summary_field_is_always_serialized() { // Responses API は reasoning item に `summary` を必須で要求する。 // summary が空でも wire 上に `summary: []` として残らないと、 // backend によっては missing required parameter として拒否される。 // reasoning effort 未指定のターンでは summary text が付かないことが // あるため、空のままでも skip しないこと。 let scheme = OpenAIResponsesScheme::new(); let item = Item::reasoning("").with_encrypted_content("ENC"); let req = Request::new().user("hi").item(item); let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning()); let json = serde_json::to_value(&body).unwrap(); let reasoning_item = &json["input"][1]; assert_eq!(reasoning_item["type"], "reasoning"); assert!( reasoning_item.get("summary").is_some(), "summary key must be present even when empty, got: {reasoning_item}" ); assert_eq!(reasoning_item["summary"], serde_json::json!([])); } #[test] fn reasoning_effort_projected_when_supported() { let scheme = OpenAIResponsesScheme::new(); let mut req = Request::new().user("hi"); req.config.reasoning = Some(ReasoningControl::Effort(ReasoningEffort::High)); 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.summary, "auto"); let json = serde_json::to_value(reasoning).unwrap(); assert!( json.get("context").is_none(), "reasoning.context must not be serialized, got: {json}" ); } #[test] fn reasoning_omitted_when_unsupported() { let scheme = OpenAIResponsesScheme::new(); let mut req = Request::new().user("hi"); req.config.reasoning = Some(ReasoningControl::Effort(ReasoningEffort::High)); let body = scheme.build_request("gpt-4o", &req, &cap_no_reasoning()); assert!(body.reasoning.is_none()); } #[test] fn max_output_tokens_passed_through_by_default() { let scheme = OpenAIResponsesScheme::new(); let req = Request::new().user("hi").max_tokens(100); let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning()); assert_eq!(body.max_output_tokens, Some(100)); } #[test] fn max_output_tokens_dropped_when_send_disabled() { let scheme = OpenAIResponsesScheme::new().with_send_max_output_tokens(false); let req = Request::new().user("hi").max_tokens(100); let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning()); assert_eq!(body.max_output_tokens, None); let json = serde_json::to_value(&body).unwrap(); assert!( json.get("max_output_tokens").is_none(), "max_output_tokens key must not appear in serialised body, got: {json}" ); } #[test] fn sampling_params_passed_through_by_default() { let scheme = OpenAIResponsesScheme::new(); let req = Request::new().user("hi").temperature(0.4).top_p(0.9); let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning()); assert_eq!(body.temperature, Some(0.4)); assert_eq!(body.top_p, Some(0.9)); } #[test] fn sampling_params_dropped_when_send_disabled() { let scheme = OpenAIResponsesScheme::new().with_send_sampling_params(false); let req = Request::new().user("hi").temperature(0.4).top_p(0.9); let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning()); assert_eq!(body.temperature, None); assert_eq!(body.top_p, None); let json = serde_json::to_value(&body).unwrap(); assert!( json.get("temperature").is_none() && json.get("top_p").is_none(), "temperature/top_p keys must not appear in serialised body, got: {json}" ); } #[test] fn prompt_cache_key_passed_through_when_set() { let scheme = OpenAIResponsesScheme::new(); let req = Request::new().user("hi").cache_key("session-abc"); let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning()); assert_eq!(body.prompt_cache_key.as_deref(), Some("session-abc")); let json = serde_json::to_value(&body).unwrap(); assert_eq!(json["prompt_cache_key"], "session-abc"); } #[test] fn prompt_cache_key_omitted_when_none() { let scheme = OpenAIResponsesScheme::new(); let req = Request::new().user("hi"); let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning()); assert!(body.prompt_cache_key.is_none()); let json = serde_json::to_value(&body).unwrap(); assert!( json.get("prompt_cache_key").is_none(), "prompt_cache_key key must not appear in serialised body, got: {json}" ); } #[test] fn tool_schema_without_properties_is_normalized() { // schemars は引数なし struct から `type:"object"` だけのスキーマを // 吐く。OpenAI Responses は `properties` 欠落を 400 で拒否するので // 送る直前に空オブジェクトを補うのを確認。 let scheme = OpenAIResponsesScheme::new(); let raw_schema = serde_json::json!({ "type": "object" }); let req = Request::new().tool( ToolDefinition::new("empty") .description("no args") .input_schema(raw_schema), ); let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning()); let json = serde_json::to_value(&body).unwrap(); assert_eq!(json["tools"][0]["parameters"]["type"], "object"); assert!( json["tools"][0]["parameters"]["properties"].is_object(), "properties must be present as an object, got: {}", json["tools"][0]["parameters"] ); } #[test] fn tool_schema_with_properties_is_untouched() { let scheme = OpenAIResponsesScheme::new(); let raw_schema = serde_json::json!({ "type": "object", "properties": { "path": { "type": "string" } }, "required": ["path"] }); let req = Request::new().tool( ToolDefinition::new("t") .description("d") .input_schema(raw_schema.clone()), ); let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning()); let json = serde_json::to_value(&body).unwrap(); assert_eq!(json["tools"][0]["parameters"], raw_schema); } #[test] fn serialized_body_has_expected_shape() { // wire 形式が崩れていないかのスモークテスト let scheme = OpenAIResponsesScheme::new(); let req = Request::new() .system("sys") .user("hi") .tool(ToolDefinition::new("t").description("d")); let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning()); let json = serde_json::to_value(&body).unwrap(); assert_eq!(json["model"], "gpt-5"); assert_eq!(json["instructions"], "sys"); assert_eq!(json["tool_choice"], "auto"); assert_eq!(json["parallel_tool_calls"], true); assert_eq!(json["store"], false); assert_eq!(json["stream"], true); assert_eq!(json["include"][0], "reasoning.encrypted_content"); assert_eq!(json["tools"][0]["type"], "function"); assert_eq!(json["tools"][0]["name"], "t"); } #[test] fn durable_tool_image_uses_function_call_output_content_items() { let scheme = OpenAIResponsesScheme::new(); let image = std::sync::Arc::<[u8]>::from(&b"\x89PNG\r\n\x1a\nbody"[..]); let item = Item::tool_result_item_with_attachments( "call_image", "Attached image", None, false, vec![crate::tool::Attachment::Image( crate::tool::ImageAttachment::new("image/png", image), )], ); let persisted = serde_json::to_string(&item).unwrap(); let restored: Item = serde_json::from_str(&persisted).unwrap(); let req = Request::new() .item(Item::tool_call( "call_image", "ViewImage", r#"{"path":"a.png"}"#, )) .item(restored); let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning()); let json = serde_json::to_value(&body).unwrap(); assert_eq!(json["input"][1]["type"], "function_call_output"); assert_eq!(json["input"].as_array().unwrap().len(), 2); assert_eq!(json["input"][1]["output"][0]["type"], "input_text"); assert_eq!(json["input"][1]["output"][1]["type"], "input_image"); assert!( json["input"][1]["output"][1]["image_url"] .as_str() .unwrap() .starts_with("data:image/png;base64,") ); let rebuilt = serde_json::to_value(scheme.build_request("gpt-5", &req, &cap_with_reasoning())) .unwrap(); assert_eq!(rebuilt["input"], json["input"]); let mut no_vision = cap_with_reasoning(); no_vision.vision = false; let disabled = serde_json::to_string(&scheme.build_request("gpt-5", &req, &no_vision)).unwrap(); assert!(!disabled.contains("data:image")); } }