blob: db12df9527e666f8e4deb1fe960c31a445e7b0a9 [file]
/*
* 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);
}
}
}