| /* | |
| * Licensed to the Apache Software Foundation (ASF) under one | |
| * or more contributor license agreements. See the NOTICE file | |
| * distributed with this work for additional information | |
| * regarding copyright ownership. The ASF licenses this file | |
| * to you under the Apache License, Version 2.0 (the | |
| * "License"); you may not use this file except in compliance | |
| * with the License. You may obtain a copy of the License at | |
| * | |
| * http://www.apache.org/licenses/LICENSE-2.0 | |
| * | |
| * Unless required by applicable law or agreed to in writing, | |
| * software distributed under the License is distributed on an | |
| * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY | |
| * KIND, either express or implied. See the License for the | |
| * specific language governing permissions and limitations | |
| * under the License. | |
| */ | |
| use crate::{Error, Result}; | |
| use maka_agent_facts::model::PlaintextReasoningReplay; | |
| use maka_agent_facts::model::PlaintextResponses; | |
| use maka_agent_facts::model::ToolInput; | |
| use maka_agent_facts::model::prompt::AssistantPart; | |
| use maka_agent_facts::model::prompt::ContentPart; | |
| use maka_agent_facts::model::prompt::FileData; | |
| use maka_agent_facts::model::prompt::Message; | |
| use maka_agent_facts::model::prompt::ToolOutput; | |
| use maka_agent_facts::tools::ToolDefinition; | |
| use serde::Serialize; | |
| use serde_json::{Value, json}; | |
| /// Protocol input only. Credential resolution and history ownership remain with Host. | |
| pub struct Request<'a> { | |
| pub language: maka_agent_facts::language::Language, | |
| pub model: &'a str, | |
| pub prompt: &'a [Message], | |
| pub tools: &'a [ToolDefinition], | |
| pub options: &'a Value, | |
| pub max_output_tokens: Option<u64>, | |
| pub plaintext: Option<PlaintextResponses>, | |
| } | |
| #[derive(Serialize)] | |
| #[serde(untagged)] | |
| enum Input { | |
| System { | |
| role: &'static str, | |
| content: String, | |
| }, | |
| Message { | |
| role: &'static str, | |
| content: Vec<Content>, | |
| }, | |
| Item(Item), | |
| } | |
| #[derive(Serialize)] | |
| #[serde(tag = "type", rename_all = "snake_case")] | |
| enum Content { | |
| InputText { text: String }, | |
| OutputText { text: String }, | |
| InputImage { image_url: String, detail: String }, | |
| InputAudio { audio_url: String }, | |
| InputFile { file_data: String, filename: String }, | |
| } | |
| #[derive(Serialize)] | |
| #[serde(tag = "type", rename_all = "snake_case")] | |
| enum Item { | |
| Reasoning { | |
| #[serde(skip_serializing_if = "Option::is_none")] | |
| id: Option<String>, | |
| summary: Vec<Value>, | |
| #[serde(skip_serializing_if = "Option::is_none")] | |
| encrypted_content: Option<String>, | |
| #[serde(skip_serializing_if = "Option::is_none")] | |
| content: Option<Vec<Value>>, | |
| }, | |
| FunctionCall { | |
| call_id: String, | |
| name: String, | |
| arguments: String, | |
| }, | |
| FunctionCallOutput { | |
| call_id: String, | |
| output: Value, | |
| }, | |
| CustomToolCall { | |
| call_id: String, | |
| name: String, | |
| input: String, | |
| }, | |
| CustomToolCallOutput { | |
| call_id: String, | |
| output: Value, | |
| }, | |
| #[serde(rename = "item_reference")] | |
| Reference { | |
| id: String, | |
| }, | |
| } | |
| #[derive(Serialize)] | |
| struct Function<'a> { | |
| r#type: &'static str, | |
| name: &'a str, | |
| description: &'a str, | |
| parameters: &'a Value, | |
| strict: bool, | |
| } | |
| pub fn wire_name(name: &str) -> &str { | |
| if name == "tool_search" { | |
| "maka_tool_search" | |
| } else { | |
| name | |
| } | |
| } | |
| pub fn local_name(name: &str) -> &str { | |
| if name == "maka_tool_search" { | |
| "tool_search" | |
| } else { | |
| name | |
| } | |
| } | |
| fn content(part: &ContentPart) -> Result<Content> { | |
| Ok(match part { | |
| ContentPart::Text { text, .. } => Content::InputText { text: text.clone() }, | |
| ContentPart::File { | |
| data: FileData::Data(data), | |
| media_type, | |
| provider_options, | |
| } => { | |
| if media_type.starts_with("image/") { | |
| Content::InputImage { | |
| image_url: format!("data:{media_type};base64,{data}"), | |
| detail: provider_options | |
| .as_ref() | |
| .and_then(|v| v["openai"]["imageDetail"].as_str()) | |
| .unwrap_or("auto") | |
| .into(), | |
| } | |
| } else if media_type.starts_with("audio/") { | |
| Content::InputAudio { | |
| audio_url: format!("data:{media_type};base64,{data}"), | |
| } | |
| } else if media_type == "application/pdf" { | |
| Content::InputFile { | |
| file_data: format!("data:{media_type};base64,{data}"), | |
| filename: "document.pdf".into(), | |
| } | |
| } else { | |
| return Err(Error::Invalid(format!( | |
| "Responses does not support file media type {media_type}" | |
| ))); | |
| } | |
| } | |
| }) | |
| } | |
| fn output(value: &ToolOutput) -> Result<Value> { | |
| Ok(match value { | |
| ToolOutput::Text(text) | ToolOutput::ErrorText(text) => Value::String(text.clone()), | |
| ToolOutput::Json(value) | ToolOutput::ErrorJson(value) => Value::String(value.to_string()), | |
| ToolOutput::Content(parts) => { | |
| serde_json::to_value(parts.iter().map(content).collect::<Result<Vec<_>>>()?) | |
| .map_err(|e| Error::Invalid(e.to_string()))? | |
| } | |
| }) | |
| } | |
| fn metadata(options: &Option<Value>, namespace: &str, key: &str) -> Option<String> { | |
| options.as_ref()?[namespace][key] | |
| .as_str() | |
| .map(str::to_owned) | |
| } | |
| impl Request<'_> { | |
| pub fn encode(&self) -> Result<Value> { | |
| let namespace = if self.plaintext.is_some() { | |
| "openResponses" | |
| } else { | |
| "openai" | |
| }; | |
| let empty = json!({}); | |
| let options = self.options.get(namespace).unwrap_or(&empty); | |
| if !options.is_object() { | |
| return Err(Error::Invalid("Responses options must be an object".into())); | |
| } | |
| let mut input = Vec::new(); | |
| for message in self.prompt { | |
| match message { | |
| Message::System { content, .. } => { | |
| input.push(Input::System { | |
| role: "system", | |
| content: content.clone(), | |
| }); | |
| } | |
| Message::User { content: parts, .. } => input.push(Input::Message { | |
| role: "user", | |
| content: parts.iter().map(content).collect::<Result<_>>()?, | |
| }), | |
| Message::Assistant { content: parts, .. } => { | |
| for part in parts { | |
| match part { | |
| AssistantPart::Text { text, .. } => input.push(Input::Message { | |
| role: "assistant", | |
| content: vec![Content::OutputText { text: text.clone() }], | |
| }), | |
| AssistantPart::Reasoning { | |
| text, | |
| provider_options, | |
| } => match self.plaintext { | |
| Some(contract) | |
| if contract.reasoning_replay | |
| == PlaintextReasoningReplay::PlaintextContent => | |
| { | |
| if !text.is_empty() { | |
| input.push(Input::Item(Item::Reasoning { | |
| id: None, | |
| summary: vec![], | |
| encrypted_content: None, | |
| content: Some(vec![ | |
| json!({"type":"reasoning_text","text":text}), | |
| ]), | |
| })); | |
| } | |
| } | |
| Some(_) => { | |
| let Some(options) = provider_options | |
| .as_ref() | |
| .and_then(|v| v.get("openResponses")) | |
| else { | |
| continue; | |
| }; | |
| let Some(summary) = options["reasoningSummary"].as_array() | |
| else { | |
| continue; | |
| }; | |
| input.push(Input::Item(Item::Reasoning { | |
| id: options["itemId"].as_str().map(str::to_owned), | |
| summary: summary.clone(), | |
| encrypted_content: None, | |
| content: None, | |
| })); | |
| } | |
| None => { | |
| let id = metadata(provider_options, "openai", "itemId"); | |
| let encrypted_content = metadata( | |
| provider_options, | |
| "openai", | |
| "reasoningEncryptedContent", | |
| ); | |
| if encrypted_content.is_some() { | |
| input.push(Input::Item(Item::Reasoning { | |
| id, | |
| summary: vec![], | |
| encrypted_content, | |
| content: None, | |
| })); | |
| } else if options["store"] != false | |
| && let Some(id) = id | |
| { | |
| input.push(Input::Item(Item::Reference { id })); | |
| } | |
| } | |
| }, | |
| AssistantPart::ToolCall { | |
| tool_call_id, | |
| tool_name, | |
| input: args, | |
| provider_executed, | |
| provider_options, | |
| } => { | |
| // A call ID may recur in another step; kind belongs to this call. | |
| let custom_source = match args { | |
| ToolInput::Json(Value::String(source)) | |
| if metadata(provider_options, "openai", "toolKind") | |
| .as_deref() | |
| == Some("custom") | |
| || metadata( | |
| provider_options, | |
| "openResponses", | |
| "toolKind", | |
| ) | |
| .as_deref() | |
| == Some("custom") => | |
| { | |
| Some(source) | |
| } | |
| _ => None, | |
| }; | |
| if *provider_executed == Some(true) { | |
| if options["store"] != false | |
| && let Some(id) = | |
| metadata(provider_options, namespace, "itemId") | |
| { | |
| input.push(Input::Item(Item::Reference { id })); | |
| } | |
| } else if self.plaintext.is_none() | |
| && let Some(source) = custom_source | |
| { | |
| input.push(Input::Item(Item::CustomToolCall { | |
| call_id: tool_call_id.clone(), | |
| name: wire_name(tool_name).into(), | |
| input: source.clone(), | |
| })); | |
| } else { | |
| input.push(Input::Item(Item::FunctionCall { | |
| call_id: tool_call_id.clone(), | |
| name: wire_name(tool_name).into(), | |
| arguments: match custom_source { | |
| Some(source) if tool_name == "exec" => { | |
| json!({"code":source}).to_string() | |
| } | |
| _ => match args { | |
| ToolInput::Json(value) => value.to_string(), | |
| ToolInput::InvalidJson { raw } => raw.clone(), | |
| }, | |
| }, | |
| })); | |
| } | |
| } | |
| AssistantPart::ToolResult { .. } => {} | |
| } | |
| } | |
| } | |
| Message::Tool { content, .. } => { | |
| for result in content { | |
| let custom = result.is_custom(); | |
| if result.is_notification() && (self.plaintext.is_some() || !custom) { | |
| if let ToolOutput::Text(text) = &result.output { | |
| input.push(Input::Message { | |
| role: "user", | |
| content: vec![Content::InputText { | |
| text: { | |
| use maka_agent_facts::language::Language; | |
| let id = &result.tool_call_id; | |
| match self.language { | |
| Language::En => { | |
| format!("[Notification from exec {id}]\n{text}") | |
| } | |
| Language::ZhCn => { | |
| format!("[来自 exec {id} 的通知]\n{text}") | |
| } | |
| Language::ZhTw => { | |
| format!("[來自 exec {id} 的通知]\n{text}") | |
| } | |
| } | |
| }, | |
| }], | |
| }); | |
| } | |
| continue; | |
| } | |
| input.push(Input::Item(if custom && self.plaintext.is_none() { | |
| Item::CustomToolCallOutput { | |
| call_id: result.tool_call_id.clone(), | |
| output: output(&result.output)?, | |
| } | |
| } else { | |
| Item::FunctionCallOutput { | |
| call_id: result.tool_call_id.clone(), | |
| output: output(&result.output)?, | |
| } | |
| })); | |
| } | |
| } | |
| } | |
| } | |
| let mut body = json!({"model":self.model,"input":input,"stream":true}); | |
| let object = body.as_object_mut().unwrap(); | |
| if let Some(maximum) = self.max_output_tokens { | |
| object.insert("max_output_tokens".into(), json!(maximum)); | |
| } | |
| for (source, target) in [ | |
| ("store", "store"), | |
| ("previousResponseId", "previous_response_id"), | |
| ("instructions", "instructions"), | |
| ("parallelToolCalls", "parallel_tool_calls"), | |
| ("user", "user"), | |
| ("metadata", "metadata"), | |
| ("serviceTier", "service_tier"), | |
| ("promptCacheKey", "prompt_cache_key"), | |
| ("promptCacheRetention", "prompt_cache_retention"), | |
| ("truncation", "truncation"), | |
| ("maxToolCalls", "max_tool_calls"), | |
| ("safetyIdentifier", "safety_identifier"), | |
| ] { | |
| if let Some(value) = options.get(source) { | |
| object.insert(target.into(), value.clone()); | |
| } | |
| } | |
| let mut reasoning = serde_json::Map::new(); | |
| for (source, target) in [ | |
| ("reasoningEffort", "effort"), | |
| ("reasoningSummary", "summary"), | |
| ] { | |
| if let Some(value) = options.get(source) { | |
| reasoning.insert(target.into(), value.clone()); | |
| } | |
| } | |
| if !reasoning.is_empty() { | |
| object.insert("reasoning".into(), Value::Object(reasoning)); | |
| } | |
| if let Some(verbosity) = options.get("textVerbosity") { | |
| object.insert("text".into(), json!({"verbosity":verbosity})); | |
| } | |
| let mut include = options | |
| .get("include") | |
| .and_then(Value::as_array) | |
| .cloned() | |
| .unwrap_or_default(); | |
| if self.plaintext.is_none() | |
| && options["store"] == false | |
| && !include.contains(&json!("reasoning.encrypted_content")) | |
| { | |
| include.push(json!("reasoning.encrypted_content")); | |
| } | |
| if !include.is_empty() { | |
| object.insert("include".into(), Value::Array(include)); | |
| } | |
| if !self.tools.is_empty() { | |
| let tools = self | |
| .tools | |
| .iter() | |
| .map(|tool| { | |
| if let Some(provider) = &tool.provider { | |
| let kind = provider.id.strip_prefix("openai.").ok_or_else(|| { | |
| Error::Invalid( | |
| "Responses provider tool belongs to another protocol".into(), | |
| ) | |
| })?; | |
| if !matches!(kind, "web_search" | "web_search_preview") { | |
| return Err(Error::Invalid(format!( | |
| "Unsupported provider-executed tool: openai.{kind}" | |
| ))); | |
| } | |
| let mut value = json!({"type":kind}); | |
| let args = provider.args.as_object().ok_or_else(|| { | |
| Error::Invalid("provider tool arguments must be an object".into()) | |
| })?; | |
| for (key, val) in args { | |
| let key = match key.as_str() { | |
| "searchContextSize" => "search_context_size", | |
| "userLocation" => "user_location", | |
| "externalWebAccess" => "external_web_access", | |
| other => other, | |
| }; | |
| if key == "type" { | |
| return Err(Error::Invalid( | |
| "provider tool cannot override its type".into(), | |
| )); | |
| } | |
| value[key] = val.clone(); | |
| } | |
| Ok(value) | |
| } else if self.plaintext.is_none() && let Some(format) = &tool.freeform { | |
| let mut format = serde_json::to_value(format).map_err(|e| Error::Invalid(e.to_string()))?; | |
| format["type"] = json!("grammar"); | |
| Ok(json!({"type":"custom", "name":wire_name(&tool.name), "description":tool.description, "format":format})) | |
| } else { | |
| serde_json::to_value(Function { | |
| r#type: "function", | |
| name: wire_name(&tool.name), | |
| description: &tool.description, | |
| parameters: &tool.input_schema, | |
| strict: false, | |
| }) | |
| .map_err(|e| Error::Invalid(e.to_string())) | |
| } | |
| }) | |
| .collect::<Result<Vec<_>>>()?; | |
| object.insert("tools".into(), json!(tools)); | |
| object.insert("tool_choice".into(), json!("auto")); | |
| } | |
| if self | |
| .plaintext | |
| .is_some_and(|contract| contract.compatibility.is_some()) | |
| { | |
| object.insert("store".into(), json!(false)); | |
| } | |
| Ok(body) | |
| } | |
| } | |
| #[cfg(test)] | |
| mod language_tests { | |
| use super::*; | |
| use maka_agent_facts::language::Language; | |
| #[test] | |
| fn notification_adaptation_keeps_worker_text_and_call_identity() { | |
| let messages = [Message::notification("task-原文", "Result: unchanged 結果")]; | |
| for (language, expected) in [ | |
| ( | |
| Language::ZhCn, | |
| "[来自 exec task-原文 的通知]\nResult: unchanged 結果", | |
| ), | |
| ( | |
| Language::ZhTw, | |
| "[來自 exec task-原文 的通知]\nResult: unchanged 結果", | |
| ), | |
| ] { | |
| let encoded = Request { | |
| language, | |
| model: "fixture", | |
| prompt: &messages, | |
| tools: &[], | |
| options: &json!({}), | |
| max_output_tokens: None, | |
| plaintext: None, | |
| } | |
| .encode() | |
| .unwrap(); | |
| assert_eq!(encoded["input"][0]["content"][0]["text"], expected); | |
| assert_eq!(encoded.get("language"), None); | |
| } | |
| } | |
| } |