diff --git a/crates/tw-dialect/src/anthropic/request.rs b/crates/tw-dialect/src/anthropic/request.rs index fcefeeb5..be1af89a 100644 --- a/crates/tw-dialect/src/anthropic/request.rs +++ b/crates/tw-dialect/src/anthropic/request.rs @@ -430,6 +430,7 @@ pub fn encode_request(r: &Request, t: &Target, dropped: &mut Dropped) -> Value { (r.seed.is_some(), Feature::Seed), (r.presence_penalty.is_some(), Feature::PresencePenalty), (r.frequency_penalty.is_some(), Feature::FrequencyPenalty), + (r.verbosity.is_some(), Feature::Verbosity), ] { if present { dropped.feature(f); diff --git a/crates/tw-dialect/src/bedrock/request.rs b/crates/tw-dialect/src/bedrock/request.rs index 91c0f8bb..4ed91fd1 100644 --- a/crates/tw-dialect/src/bedrock/request.rs +++ b/crates/tw-dialect/src/bedrock/request.rs @@ -221,6 +221,9 @@ pub fn encode_request(r: &Request, t: &Target, dropped: &mut Dropped) -> Value { if r.format.is_some() { dropped.feature(Feature::Format); } + if r.verbosity.is_some() { + dropped.feature(Feature::Verbosity); + } Value::Object(out) } diff --git a/crates/tw-dialect/src/caller.rs b/crates/tw-dialect/src/caller.rs index d07dae91..d8c9dcba 100644 --- a/crates/tw-dialect/src/caller.rs +++ b/crates/tw-dialect/src/caller.rs @@ -322,7 +322,7 @@ fn chat(c: &mut Collect, v: &Value) { /// 见 `responses::request::decode_request`:`input` 是一个字符串时整个是用户的话; /// 是数组时看 `message`(`system`、`developer`、`assistant` 之外的角色,没写的算 -/// `user`)和两种工具结果 +/// `user`)、别的代理发来的 `agent_message` 和两种工具结果 fn responses(c: &mut Collect, v: &Value) { let Some(input) = v.get("input") else { return; @@ -351,6 +351,18 @@ fn responses_item(c: &mut Collect, item: &Value) { c.key("content", |c| responses_content(c, content, false)); } } + // 只认 `input_text`:`encrypted_content` 只有 OpenAI 读得懂,解码时丢掉了 + "agent_message" => { + if let Some(Value::Array(parts)) = item.get("content") { + c.key("content", |c| { + for (i, p) in parts.iter().enumerate() { + if str_of(p, "type") == Some("input_text") { + c.index(i, |c| c.field(p, "text", false)); + } + } + }); + } + } "function_call_output" | "custom_tool_call_output" => { if let Some(output) = item.get("output") { c.key("output", |c| responses_content(c, output, true)); diff --git a/crates/tw-dialect/src/chat/request.rs b/crates/tw-dialect/src/chat/request.rs index 2868e284..ad52a725 100644 --- a/crates/tw-dialect/src/chat/request.rs +++ b/crates/tw-dialect/src/chat/request.rs @@ -176,6 +176,13 @@ pub fn decode_request( _ => None, }; + if has(v, "verbosity") { + r.verbosity = str_of(v, "verbosity").and_then(Verbosity::parse); + if r.verbosity.is_none() { + dropped.path("verbosity"); + } + } + if u64_of(v, "n").is_some_and(|n| n > 1) { dropped.path("n"); } @@ -194,7 +201,6 @@ pub fn decode_request( "prediction", "audio", "web_search_options", - "verbosity", "moderation", "functions", "function_call", @@ -397,6 +403,14 @@ pub fn encode_request(r: &Request, t: &Target, dropped: &mut Dropped) -> Value { None => {} } + match r.verbosity { + Some(x) if Verbosity::understood_by(&r.model) => { + out.insert("verbosity".into(), json!(x.as_str())); + } + Some(_) => dropped.feature(Feature::Verbosity), + None => {} + } + match &r.format { Some(Format::JsonObject) => { out.insert("response_format".into(), json!({ "type": "json_object" })); diff --git a/crates/tw-dialect/src/convert.rs b/crates/tw-dialect/src/convert.rs index 793b36e1..c9acce47 100644 --- a/crates/tw-dialect/src/convert.rs +++ b/crates/tw-dialect/src/convert.rs @@ -246,6 +246,11 @@ impl Session { self.shape.namespaced.get(name) } + /// 这个工具是 Responses 客户端自己执行的工具搜索:调用写回 `tool_search_call` + pub(crate) fn is_tool_search(&self, name: &str) -> bool { + self.shape.tool_search.as_deref() == Some(name) + } + /// 上游的整包响应 → 客户端的整包响应。上游返回的不是 JSON 时是 `None` pub fn response(&self, body: &[u8]) -> Option> { let v: Value = serde_json::from_slice(body).ok()?; @@ -367,6 +372,7 @@ impl Session { namespaced: HashMap::new(), include_usage: false, gemini_sse: true, + tool_search: None, }, } } diff --git a/crates/tw-dialect/src/gemini/request.rs b/crates/tw-dialect/src/gemini/request.rs index 24d0fb75..d1ac9cb3 100644 --- a/crates/tw-dialect/src/gemini/request.rs +++ b/crates/tw-dialect/src/gemini/request.rs @@ -472,6 +472,9 @@ pub fn encode_request(r: &Request, _t: &Target, dropped: &mut Dropped) -> Value Some(_) => dropped.feature(Feature::Reasoning), None => {} } + if r.verbosity.is_some() { + dropped.feature(Feature::Verbosity); + } if !g.is_empty() { out.insert("generationConfig".into(), Value::Object(g)); } diff --git a/crates/tw-dialect/src/ir.rs b/crates/tw-dialect/src/ir.rs index 3dc6f3e5..9499b634 100644 --- a/crates/tw-dialect/src/ir.rs +++ b/crates/tw-dialect/src/ir.rs @@ -81,6 +81,8 @@ pub struct Request { pub frequency_penalty: Option, pub reasoning: Option, pub format: Option, + /// 回答写多写少(OpenAI GPT-5 系列的 `verbosity`) + pub verbosity: Option, pub stream: bool, /// 提示缓存的断点,按出现顺序 pub cache: Vec, @@ -147,6 +149,9 @@ pub struct ClientShape { pub include_usage: bool, /// Gemini 客户端要 SSE(`alt=sse`);否则流是一个逐步写出的 JSON 数组 pub gemini_sse: bool, + /// Responses 客户端自己执行的工具搜索(Codex 的 `tool_search`,`execution: client`) + /// 转成的函数工具叫什么。上游调用它时,写回去的是 `tool_search_call`,不是函数调用 + pub tool_search: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -339,6 +344,47 @@ pub enum Effort { Max, } +/// 回答写多写少。**只有 OpenAI 的 GPT-5 系列认**:Chat 的 `verbosity`、Responses 的 +/// `text.verbosity`。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Verbosity { + Low, + Medium, + High, +} + +impl Verbosity { + pub fn parse(s: &str) -> Option { + Some(match s { + "low" => Verbosity::Low, + "medium" => Verbosity::Medium, + "high" => Verbosity::High, + _ => return None, + }) + } + + pub fn as_str(self) -> &'static str { + match self { + Verbosity::Low => "low", + Verbosity::Medium => "medium", + Verbosity::High => "high", + } + } + + /// 上游的这个模型认不认 `verbosity`:OpenAI 的 GPT-5 及以后(`gpt-5.4`、`openai/gpt-5`)。 + /// + /// **按模型名判断,不按格式**:说 Chat 格式的绝大多数是别家的模型,不认的参数有的 + /// 忽略、有的直接 400;GPT-4.1 这种 OpenAI 自己的老模型也是 400 + pub fn understood_by(model: &str) -> bool { + let model = model.to_ascii_lowercase(); + let Some((_, rest)) = model.split_once("gpt-") else { + return false; + }; + let major: String = rest.chars().take_while(char::is_ascii_digit).collect(); + major.parse::().is_ok_and(|m| m >= 5) + } +} + #[derive(Debug, Clone, PartialEq)] pub enum Format { JsonObject, @@ -585,6 +631,8 @@ pub enum Feature { FreeformFormat, /// 提示缓存的断点,目标模型不认 Cache, + /// 回答写多写少,目标模型不认(见 [`Verbosity::understood_by`]) + Verbosity, } impl Feature { @@ -643,6 +691,8 @@ impl Feature { (FreeformFormat, _) => "tools.custom.format", (Cache, Bedrock) => "cachePoint", (Cache, _) => "cache_control", + (Feature::Verbosity, Responses) => "text.verbosity", + (Feature::Verbosity, _) => "verbosity", } } } diff --git a/crates/tw-dialect/src/responses/request.rs b/crates/tw-dialect/src/responses/request.rs index 9284a609..227150d0 100644 --- a/crates/tw-dialect/src/responses/request.rs +++ b/crates/tw-dialect/src/responses/request.rs @@ -1,4 +1,31 @@ //! OpenAI Responses 请求 ⇄ 中间表示。 +//! +//! # 输入项转给别家时怎么办 +//! +//! 照 Codex 往 `input` 里放的每一种项写(`codex-rs/protocol/src/models.rs` 的 +//! `ResponseItem`)。**丢弃只在别家确实没有对应物、模型也不缺什么的时候**,丢了的记进 +//! [`Dropped`]: +//! +//! | 输入项 | 转成 | +//! |---|---| +//! | `additional_tools` | 里面的工具和顶层 `tools` 一样解码。Responses Lite 只在这里声明工具,顶层没有 `tools`;对话里可以有好几个(增量声明) | +//! | `message` | `system`、`developer` 并进系统提示,别的是一轮对话。`phase`(commentary、final_answer)别家没有,文字照留 | +//! | `agent_message` | 别的代理发来的话(发信人和任务名写在正文里),当用户的一轮。只有 OpenAI 读得懂的 `encrypted_content` 记丢弃 | +//! | `reasoning` | 推理,签名照规矩带着 | +//! | `function_call`、`custom_tool_call` 和各自的 `_output` | 工具调用和结果 | +//! | `local_shell_call` | 老历史里的命令调用:叫 `local_shell` 的工具调用,参数是它的 `action`。结果是同一个 `call_id` 的 `function_call_output`,丢了调用、留着结果,Anthropic 会拒绝整个请求 | +//! | `tool_search_call`、`tool_search_output` | 一次 `tool_search` 调用和结果。搜到的工具从此可以调用,加进工具列表 | +//! | `configuration_update` | 对话中途改的推理强度。最后一个说了算,盖过顶层的 `reasoning.effort` —— Codex 为了保住提示缓存,顶层一直写开头那一档 | +//! | `web_search_call`、`image_generation_call` | 丢弃并记下:OpenAI 服务端工具的执行记录,搜到的、画出的写在后面的回答里 | +//! | `compaction`、`context_compaction`、`compaction_trigger`、`item_reference` | 拒绝:内容在 OpenAI 服务端,或者是只有 OpenAI 读得懂的密文 | +//! +//! 顶层字段:`text.verbosity` 写给认它的模型([`Verbosity::understood_by`]),别处记丢弃; +//! `service_tier` 别家没有同样的档位,记丢弃。`store`、`include`(转换写出的推理项总是带着 +//! `encrypted_content`)、`prompt_cache_key`、`client_metadata`、`stream_options`、 +//! `reasoning.context` 是给 OpenAI 服务端的存储和传输参数,模型看不到,不记 —— +//! `client_metadata` 里是 Codex 的会话信息,本来就不该交给别家。 + +use std::collections::HashMap; use serde_json::{Map, Value, json}; @@ -7,6 +34,21 @@ use crate::think; // ───────────────────────────────────────────────────────── 解码 +/// Codex 的默认 namespace。Responses Lite 把顶层的函数和自由格式工具都装在它里面,而 +/// Codex 认 `functions` 里的 `shell` 和不带 namespace 的 `shell` 是同一个工具 +/// (`codex-rs/protocol/src/tool_name.rs`),所以展开时不加前缀 +const DEFAULT_NAMESPACE: &str = "functions"; + +/// Codex 的工具搜索(`{"type": "tool_search", "execution": "client"}`)没有名字,转给别家时 +/// 写成叫这个名字的函数工具 +pub const TOOL_SEARCH: &str = "tool_search"; + +/// `local_shell_call` 转成的工具调用叫什么。Codex 已经不再声明这个工具,它只出现在老历史里 +const LOCAL_SHELL: &str = "local_shell"; + +/// 别家工具名的长度上限:OpenAI Chat、Gemini、Bedrock 都是 64 +const NAME_LIMIT: usize = 64; + /// 客户端发来的 Responses 请求 → 中间表示。 /// /// **依赖 OpenAI 服务端状态的请求直接拒绝**(`previous_response_id`、`conversation`、 @@ -54,10 +96,15 @@ pub fn decode_request( r.system.push(i.to_string()); } + let mut cx = Ctx { + dropped, + shape, + tools: ToolSet::default(), + effort: None, + }; for t in arr_of(v, "tools") { - decode_tool(t, None, dropped, shape, &mut r.tools); + cx.tool(t, None, "tools"); } - match v.get("input") { Some(Value::String(s)) if !s.is_empty() => r.messages.push(Message { role: Role::User, @@ -65,11 +112,27 @@ pub fn decode_request( }), Some(Value::Array(items)) => { for item in items { - decode_item(item, dropped, &mut r)?; + cx.item(item, &mut r)?; } } _ => {} } + let Ctx { + dropped, + tools, + effort, + .. + } = cx; + r.tools = tools.tools; + // namespace 的说明(MCP 服务器的使用说明就写在这里):别家的工具没有 namespace, + // 写进系统提示,模型照样看得到 + for (ns, note) in tools.notes { + r.system.push(if ns == DEFAULT_NAMESPACE { + note + } else { + format!("Tools whose names start with {ns} belong to the {ns} namespace:\n{note}") + }); + } r.tool_choice = match v.get("tool_choice") { Some(Value::String(s)) => match s.as_str() { @@ -79,9 +142,8 @@ pub fn decode_request( _ => None, }, Some(o @ Value::Object(_)) => match str_of(o, "type") { - Some("function" | "custom") => { - str_of(o, "name").map(|n| ToolChoice::Named(n.to_string())) - } + Some("function" | "custom") => str_of(o, "name") + .map(|n| ToolChoice::Named(flat_tool_name(str_of(o, "namespace"), n))), Some("allowed_tools") => { dropped.path("tool_choice.allowed_tools"); match str_of(o, "mode") { @@ -106,6 +168,23 @@ pub fn decode_request( summary: has(re, "summary") || has(re, "generate_summary"), }); } + // 对话中途改过推理强度:以最后一次为准 + if let Some(e) = effort { + match think::parse_openai(&e) { + Some(effort) => { + let re = r.reasoning.get_or_insert(Reasoning { + enabled: true, + effort: None, + budget: None, + summary: false, + }); + re.enabled = effort.is_some(); + re.effort = effort; + } + // 模型自己定义的强度,别家没有对应 + None => dropped.path("input.configuration_update.reasoning.effort"), + } + } if let Some(text) = v.get("text") { r.format = match text.get("format").and_then(|f| str_of(f, "type")) { @@ -121,7 +200,10 @@ pub fn decode_request( _ => None, }; if has(text, "verbosity") { - dropped.path("text.verbosity"); + r.verbosity = str_of(text, "verbosity").and_then(Verbosity::parse); + if r.verbosity.is_none() { + dropped.path("text.verbosity"); + } } } @@ -135,175 +217,421 @@ pub fn decode_request( dropped.path(k); } } + // `priority`、`flex` 是 OpenAI 的计费和排队档位,别家没有同样的东西 + if str_of(v, "service_tier").is_some_and(|t| !matches!(t, "auto" | "default")) { + dropped.path("service_tier"); + } Ok(r) } -/// namespace 里的工具展开成 `namespace__名字`,写响应时再拆回来 -fn flat_name(namespace: Option<&str>, name: &str) -> String { - match namespace { - Some(ns) if !ns.is_empty() => format!("{ns}__{name}"), - _ => name.to_string(), +/// namespace 里的工具展开成一个名字,写响应时再按 [`ClientShape::namespaced`] 拆回来。 +/// +/// - 默认 namespace(`functions`)不加前缀 +/// - 别的照 Codex 自己拼名字的写法:分界处已经有 `_` 的直接接上 +/// (`mcp__codex_apps__calendar` + `_create_event`),否则中间加 `__`(`mcp_fs__read`) +/// - **超过 64 个字符的截短,末尾换成哈希**:别家的工具名都限 64 个字符,namespace 再加 +/// 名字很容易超,超了整个请求被拒。哈希按 namespace 和名字算,同一个工具每次都是同一个名字 +/// +/// 工具定义和历史里的调用用的是同一个函数,所以对得上。会话记录读 Responses 请求时也用它, +/// 和转给别家时的名字一样 +pub fn flat_tool_name(namespace: Option<&str>, name: &str) -> String { + let ns = match namespace { + Some(ns) if !ns.is_empty() && ns != DEFAULT_NAMESPACE => ns, + _ => return name.to_string(), + }; + let flat = if ns.ends_with('_') || name.starts_with('_') { + format!("{ns}{name}") + } else { + format!("{ns}__{name}") + }; + if flat.len() <= NAME_LIMIT { + return flat; } + let suffix = format!("_{:012x}", fnv1a(ns, name) & 0xffff_ffff_ffff); + let mut cut = NAME_LIMIT - suffix.len(); + while !flat.is_char_boundary(cut) { + cut -= 1; + } + format!("{}{suffix}", &flat[..cut]) } -fn decode_tool( - t: &Value, - namespace: Option<&str>, - dropped: &mut Dropped, - shape: &mut ClientShape, - tools: &mut Vec, -) { - let name = str_of(t, "name").unwrap_or_default(); - let flat = flat_name(namespace, name); - let kind = match str_of(t, "type") { - Some("function") => ToolKind::Function { - schema: t - .get("parameters") - .filter(|p| !p.is_null()) - .cloned() - .unwrap_or_else(|| json!({ "type": "object", "properties": {} })), - strict: t.get("strict").and_then(Value::as_bool), - }, - Some("custom") => ToolKind::Freeform { - format: t.get("format").cloned(), - }, - Some("namespace") if namespace.is_none() => { - for inner in arr_of(t, "tools") { - decode_tool(inner, Some(name), dropped, shape, tools); +/// FNV-1a,64 位。不用标准库的哈希:它不保证换个版本还是同一个值 +fn fnv1a(ns: &str, name: &str) -> u64 { + let mut h: u64 = 0xcbf2_9ce4_8422_2325; + for b in ns.bytes().chain([0]).chain(name.bytes()) { + h ^= u64::from(b); + h = h.wrapping_mul(0x0000_0100_0000_01b3); + } + h +} + +/// 请求里定义成自由格式的工具,展开后的名字:顶层 `tools`、`additional_tools` 和 +/// `tool_search_output` 里的都算。 +/// +/// 会话记录读上游的回答时要它:别家上游把自由格式工具的原文包在 `{"input": …}` 里, +/// 按这份名单拆回来 +pub fn freeform_tools(v: &Value) -> Vec { + fn walk(t: &Value, namespace: Option<&str>, out: &mut Vec) { + match str_of(t, "type") { + Some("custom") => out.push(flat_tool_name( + namespace, + str_of(t, "name").unwrap_or_default(), + )), + Some("namespace") if namespace.is_none() => { + let ns = str_of(t, "name"); + for inner in arr_of(t, "tools") { + walk(inner, ns, out); + } } - return; + _ => {} } - // 托管工具(web_search、file_search、shell、apply_patch、mcp……)只有 OpenAI 能执行 - other => { - dropped.path(format!("tools.{}", other.unwrap_or("unknown"))); - return; - } - }; - if let Some(ns) = namespace { - shape - .namespaced - .insert(flat.clone(), (ns.to_string(), name.to_string())); - } - tools.push(Tool { - name: flat, - description: str_of(t, "description").map(str::to_string), - kind, + } + let mut out = Vec::new(); + let declared = arr_of(v, "input").iter().filter(|i| { + matches!( + str_of(i, "type"), + Some("additional_tools" | "tool_search_output") + ) }); + for t in arr_of(v, "tools") + .iter() + .chain(declared.flat_map(|i| arr_of(i, "tools"))) + { + walk(t, None, &mut out); + } + out } -fn decode_item(item: &Value, dropped: &mut Dropped, r: &mut Request) -> Result<(), Rejection> { - let kind = str_of(item, "type").unwrap_or("message"); - let push = |r: &mut Request, role, part| { - r.messages.push(Message { - role, - parts: vec![part], - }) - }; - match kind { - "message" => { - let content = item.get("content").unwrap_or(&Value::Null); - match str_of(item, "role").unwrap_or("user") { - "system" | "developer" => { - let t = text_of(content); - if !t.is_empty() { - r.system.push(t); - } - } - role => { - let role = if role == "assistant" { - Role::Assistant - } else { - Role::User - }; - let parts = content_parts(content, "input.content", dropped); - r.messages.push(Message { role, parts }); - } +/// 解码出的工具。 +/// +/// **同名的后来者替换先前的,位置不变**:顶层 `tools` 在前,`input` 里的 `additional_tools`、 +/// `tool_search_output` 按出现顺序在后。Codex 的增量声明就是这么说的(「重新定义的工具以 +/// 最新的定义为准」);位置不变,工具列表的开头就尽量稳定 —— 提示缓存从工具列表算起 +#[derive(Default)] +struct ToolSet { + tools: Vec, + at: HashMap, + /// namespace 的说明,按第一次出现的顺序;后来的替换先前的 + notes: Vec<(String, String)>, +} + +impl ToolSet { + fn add(&mut self, t: Tool) { + match self.at.get(&t.name) { + Some(&i) => self.tools[i] = t, + None => { + self.at.insert(t.name.clone(), self.tools.len()); + self.tools.push(t); } } - "function_call" | "custom_tool_call" => { - let name = flat_name( - str_of(item, "namespace"), - str_of(item, "name").unwrap_or_default(), - ); - let input = if kind == "custom_tool_call" { - ToolInput::Text(str_of(item, "input").unwrap_or_default().to_string()) - } else { - ToolInput::from_json_text(str_of(item, "arguments").unwrap_or_default()) - }; - push( - r, - Role::Assistant, - Part::ToolCall(ToolCall { - id: str_of(item, "call_id").unwrap_or_default().to_string(), - name, - input, - }), - ); + } + + fn note(&mut self, namespace: &str, note: &str) { + match self.notes.iter_mut().find(|(ns, _)| ns == namespace) { + Some((_, n)) => *n = note.to_string(), + None => self.notes.push((namespace.to_string(), note.to_string())), } - "function_call_output" | "custom_tool_call_output" => { - let content = match item.get("output") { - Some(Value::String(s)) if !s.is_empty() => vec![Part::Text(s.clone())], - Some(o @ Value::Array(_)) => { - content_parts(o, &format!("input.{kind}.output"), dropped) - .into_iter() - .filter(|p| matches!(p, Part::Text(_) | Part::Image(_))) - .collect() + } +} + +/// 解码时一路带着的东西 +struct Ctx<'a> { + dropped: &'a mut Dropped, + shape: &'a mut ClientShape, + tools: ToolSet, + /// 最后一个 `configuration_update` 里的推理强度 + effort: Option, +} + +impl Ctx<'_> { + /// 一个工具定义。返回加进去的工具名,namespace 展开成里面的每一个。`at` 是它在客户端 + /// 请求里的位置,记丢弃用 + fn tool(&mut self, t: &Value, namespace: Option<&str>, at: &str) -> Vec { + let name = str_of(t, "name").unwrap_or_default(); + let (flat, kind) = match str_of(t, "type") { + Some("function") => (flat_tool_name(namespace, name), function_kind(t)), + Some("custom") => ( + flat_tool_name(namespace, name), + ToolKind::Freeform { + format: t.get("format").cloned(), + }, + ), + Some("namespace") if namespace.is_none() => { + // Codex 给没写说明的 namespace 填的是这句套话,不值得写进系统提示 + let filler = format!("Tools in the {name} namespace."); + if let Some(note) = str_of(t, "description") + .map(str::trim) + .filter(|d| !d.is_empty() && *d != filler) + { + self.tools.note(name, note); } - _ => Vec::new(), - }; - push( - r, - Role::User, - Part::ToolResult(ToolResult { - id: str_of(item, "call_id").unwrap_or_default().to_string(), - content, - is_error: false, - }), - ); + return arr_of(t, "tools") + .iter() + .flat_map(|inner| self.tool(inner, Some(name), at)) + .collect(); + } + // 客户端自己执行的工具搜索:上游调用它时写回 `tool_search_call`,由客户端去搜 + Some("tool_search") + if namespace.is_none() && str_of(t, "execution") == Some("client") => + { + self.shape.tool_search = Some(TOOL_SEARCH.to_string()); + (TOOL_SEARCH.to_string(), function_kind(t)) + } + // 托管工具(web_search、file_search、OpenAI 执行的 tool_search、shell、mcp……) + // 只有 OpenAI 能执行 + other => { + self.dropped + .path(format!("{at}.{}", other.unwrap_or("unknown"))); + return Vec::new(); + } + }; + if let Some(ns) = namespace { + self.shape + .namespaced + .insert(flat.clone(), (ns.to_string(), name.to_string())); } - "reasoning" => { - let texts = |key: &str| { - arr_of(item, key) + self.tools.add(Tool { + name: flat.clone(), + description: str_of(t, "description").map(str::to_string), + kind, + }); + vec![flat] + } + + fn item(&mut self, item: &Value, r: &mut Request) -> Result<(), Rejection> { + let kind = str_of(item, "type").unwrap_or("message"); + let push = |r: &mut Request, role, part| { + r.messages.push(Message { + role, + parts: vec![part], + }) + }; + match kind { + "message" => { + let content = item.get("content").unwrap_or(&Value::Null); + match str_of(item, "role").unwrap_or("user") { + "system" | "developer" => { + let t = text_of(content); + if !t.is_empty() { + r.system.push(t); + } + } + role => { + let role = if role == "assistant" { + Role::Assistant + } else { + Role::User + }; + let parts = content_parts(content, "input.content", self.dropped); + r.messages.push(Message { role, parts }); + } + } + } + "additional_tools" => { + for t in arr_of(item, "tools") { + self.tool(t, None, "input.additional_tools.tools"); + } + } + "agent_message" => { + let mut parts = Vec::new(); + for p in arr_of(item, "content") { + match str_of(p, "type").unwrap_or("") { + "input_text" => { + if let Some(t) = str_of(p, "text").filter(|t| !t.is_empty()) { + parts.push(Part::Text(t.to_string())); + } + } + other => self + .dropped + .path(format!("input.agent_message.content.{other}")), + } + } + if !parts.is_empty() { + r.messages.push(Message { + role: Role::User, + parts, + }); + } + } + "function_call" | "custom_tool_call" => { + let name = flat_tool_name( + str_of(item, "namespace"), + str_of(item, "name").unwrap_or_default(), + ); + let input = if kind == "custom_tool_call" { + ToolInput::Text(str_of(item, "input").unwrap_or_default().to_string()) + } else { + ToolInput::from_json_text(str_of(item, "arguments").unwrap_or_default()) + }; + push( + r, + Role::Assistant, + Part::ToolCall(ToolCall { + id: str_of(item, "call_id").unwrap_or_default().to_string(), + name, + input, + }), + ); + } + // 结果是同一个 call_id 的 function_call_output。没有 call_id 的(更早的写法) + // 配不上结果,留着反而是一个没有结果的调用 + "local_shell_call" => match str_of(item, "call_id") { + Some(id) => push( + r, + Role::Assistant, + Part::ToolCall(ToolCall { + id: id.to_string(), + name: LOCAL_SHELL.to_string(), + input: ToolInput::Json( + item.get("action") + .filter(|a| a.is_object()) + .cloned() + .unwrap_or_else(|| json!({})), + ), + }), + ), + None => self.dropped.path("input.local_shell_call"), + }, + "function_call_output" | "custom_tool_call_output" => { + let content = match item.get("output") { + Some(Value::String(s)) if !s.is_empty() => vec![Part::Text(s.clone())], + Some(o @ Value::Array(_)) => { + content_parts(o, &format!("input.{kind}.output"), self.dropped) + .into_iter() + .filter(|p| matches!(p, Part::Text(_) | Part::Image(_))) + .collect() + } + _ => Vec::new(), + }; + push( + r, + Role::User, + Part::ToolResult(ToolResult { + id: str_of(item, "call_id").unwrap_or_default().to_string(), + content, + is_error: false, + }), + ); + } + "tool_search_call" => match str_of(item, "call_id") { + Some(id) => push( + r, + Role::Assistant, + Part::ToolCall(ToolCall { + id: id.to_string(), + name: TOOL_SEARCH.to_string(), + input: match item.get("arguments") { + Some(Value::String(s)) => ToolInput::from_json_text(s), + Some(a @ Value::Object(_)) => ToolInput::Json(a.clone()), + _ => ToolInput::Json(json!({})), + }, + }), + ), + None => self.dropped.path("input.tool_search_call"), + }, + // 搜到的工具从此可以调用:加进工具列表。结果里写上它们在上游那边叫什么 + "tool_search_output" => { + let names: Vec = arr_of(item, "tools") .iter() - .filter_map(|x| str_of(x, "text")) - .collect::>() - .join("\n\n") - }; - let text = match texts("content") { - t if t.is_empty() => texts("summary"), - t => t, - }; - let signature = str_of(item, "encrypted_content") - .filter(|e| !e.is_empty()) - .and_then(|enc| { - if enc.starts_with(CARRIED) { - Signature::read(enc, Vendor::OpenAi) + .flat_map(|t| self.tool(t, None, "input.tool_search_output.tools")) + .collect(); + if let Some(id) = str_of(item, "call_id") { + let text = if names.is_empty() { + "No matching tools were found.".to_string() } else { - let id = str_of(item, "id").unwrap_or_default(); - Some(Signature::new(Vendor::OpenAi, format!("{id}:{enc}"))) - } - }); - push( - r, - Role::Assistant, - Part::Thinking(Thinking { text, signature }), - ); - } - "item_reference" => { - return Err(Rejection( - "An item_reference in input points at something kept on OpenAI's servers, so the request cannot be converted for an upstream of another format." - .into(), - )); - } - "compaction" => { - return Err(Rejection( - "A compaction in input is an encrypted, compacted conversation only OpenAI can read, so the request cannot be converted for an upstream of another format." - .into(), - )); + format!("These tools are now available: {}", names.join(", ")) + }; + push( + r, + Role::User, + Part::ToolResult(ToolResult { + id: id.to_string(), + content: vec![Part::Text(text)], + is_error: false, + }), + ); + } + } + "configuration_update" => { + match item.get("reasoning").and_then(|re| str_of(re, "effort")) { + Some(e) => self.effort = Some(e.to_string()), + None => self.dropped.path("input.configuration_update"), + } + } + "reasoning" => { + let texts = |key: &str| { + arr_of(item, key) + .iter() + .filter_map(|x| str_of(x, "text")) + .collect::>() + .join("\n\n") + }; + let text = match texts("content") { + t if t.is_empty() => texts("summary"), + t => t, + }; + let signature = str_of(item, "encrypted_content") + .filter(|e| !e.is_empty()) + .and_then(|enc| { + if enc.starts_with(CARRIED) { + Signature::read(enc, Vendor::OpenAi) + } else { + let id = str_of(item, "id").unwrap_or_default(); + Some(Signature::new(Vendor::OpenAi, format!("{id}:{enc}"))) + } + }); + push( + r, + Role::Assistant, + Part::Thinking(Thinking { text, signature }), + ); + } + "item_reference" => { + return Err(Rejection( + "An item_reference in input points at something kept on OpenAI's servers, so the request cannot be converted for an upstream of another format." + .into(), + )); + } + "compaction" => { + return Err(Rejection( + "A compaction in input is an encrypted, compacted conversation only OpenAI can read, so the request cannot be converted for an upstream of another format." + .into(), + )); + } + "context_compaction" => { + if str_of(item, "encrypted_content").is_some_and(|e| !e.is_empty()) { + return Err(Rejection( + "A context_compaction in input is an encrypted, compacted conversation only OpenAI can read, so the request cannot be converted for an upstream of another format." + .into(), + )); + } + self.dropped.path("input.context_compaction"); + } + // 别家上游答不出 Codex 要的那个加密的 compaction 项:转过去只会白答一轮, + // Codex 再报「没有收到 compaction」 + "compaction_trigger" => { + return Err(Rejection( + "A compaction_trigger in input asks OpenAI's servers to compact the conversation into an encrypted item only OpenAI can read, so the request cannot be converted for an upstream of another format." + .into(), + )); + } + // web_search_call、image_generation_call……:服务端工具的执行记录 + other => self.dropped.path(format!("input.{other}")), } - other => dropped.path(format!("input.{other}")), + Ok(()) + } +} + +/// 函数工具(还有客户端执行的工具搜索)的参数定义 +fn function_kind(t: &Value) -> ToolKind { + ToolKind::Function { + schema: t + .get("parameters") + .filter(|p| !p.is_null()) + .cloned() + .unwrap_or_else(|| json!({ "type": "object", "properties": {} })), + strict: t.get("strict").and_then(Value::as_bool), } - Ok(()) } fn content_parts(content: &Value, prefix: &str, dropped: &mut Dropped) -> Vec { @@ -557,12 +885,10 @@ pub fn encode_request(r: &Request, _t: &Target, dropped: &mut Dropped) -> Value None => {} } + let mut text = Map::new(); match &r.format { Some(Format::JsonObject) => { - out.insert( - "text".into(), - json!({ "format": { "type": "json_object" } }), - ); + text.insert("format".into(), json!({ "type": "json_object" })); } Some(Format::JsonSchema { name, @@ -577,10 +903,20 @@ pub fn encode_request(r: &Request, _t: &Target, dropped: &mut Dropped) -> Value if let Some(s) = strict { f["strict"] = json!(s); } - out.insert("text".into(), json!({ "format": f })); + text.insert("format".into(), f); } None => {} } + match r.verbosity { + Some(x) if Verbosity::understood_by(&r.model) => { + text.insert("verbosity".into(), json!(x.as_str())); + } + Some(_) => dropped.feature(Feature::Verbosity), + None => {} + } + if !text.is_empty() { + out.insert("text".into(), Value::Object(text)); + } // 不让 OpenAI 保存这次对话:转换过来的请求本来就带着完整的上下文 out.insert("store".into(), json!(false)); @@ -672,7 +1008,15 @@ mod tests { #[test] fn a_codex_request_decodes_into_turns_tools_and_reasoning() { let (r, dropped, shape) = decode(CODEX).unwrap(); - assert_eq!(r.system, ["You are Codex.", "sandbox: workspace-write"]); + // namespace 的说明进系统提示 + assert_eq!( + r.system, + [ + "You are Codex.", + "sandbox: workspace-write", + "Tools whose names start with mcp_fs belong to the mcp_fs namespace:\nfiles" + ] + ); let Part::Thinking(th) = &r.messages[1].parts[0] else { panic!("{:?}", r.messages[1]); }; @@ -702,6 +1046,9 @@ mod tests { r#"{"model":"m","previous_response_id":"resp_1","input":"hi"}"#, r#"{"model":"m","input":[{"type":"item_reference","id":"msg_1"}]}"#, r#"{"model":"m","input":[{"type":"compaction","encrypted_content":"x"}]}"#, + r#"{"model":"m","input":[{"type":"context_compaction","encrypted_content":"x"}]}"#, + // Codex 的 Responses Lite 要压缩前文时发的:要的是一个只有 OpenAI 写得出的加密项 + r#"{"model":"m","input":[{"type":"message","role":"user","content":"hi"},{"type":"compaction_trigger"}]}"#, r#"{"model":"m","background":true,"input":"hi"}"#, ] { let e = decode(body).unwrap_err(); @@ -709,6 +1056,255 @@ mod tests { } // null 不算用了 assert!(decode(r#"{"model":"m","previous_response_id":null,"input":"hi"}"#).is_ok()); + // 没有密文的 context_compaction 只是一个记号 + let (_, dropped, _) = + decode(r#"{"model":"m","input":[{"type":"context_compaction"},{"role":"user","content":"hi"}]}"#) + .unwrap(); + assert_eq!(dropped, ["input.context_compaction"]); + } + + /// Codex 的 Responses Lite(`use_responses_lite`):顶层没有 `tools`,所有工具装在 + /// `input` 开头的 `additional_tools` 里,函数和自由格式工具在 `functions` 这个 + /// namespace 里(`codex-rs/core/src/client.rs`、`codex-rs/tools/src/tool_spec.rs`) + const LITE: &str = r#"{ + "model": "gpt-5.4", + "stream": true, + "input": [ + {"id": "at_1", "type": "additional_tools", "role": "developer", "tools": [ + {"type": "namespace", "name": "functions", "description": "", "tools": [ + {"type": "function", "name": "exec_command", "description": "Runs a command.", "strict": false, + "parameters": {"type": "object", "properties": {"cmd": {"type": "string"}}, "required": ["cmd"], "additionalProperties": false}}, + {"type": "custom", "name": "apply_patch", "description": "Edit files.", + "format": {"type": "grammar", "syntax": "lark", "definition": "start: begin_patch hunk+ end_patch"}} + ]}, + {"type": "namespace", "name": "mcp__codex_apps__calendar", "description": "Plan events.", "tools": [ + {"type": "function", "name": "_create_event", "description": "Create a calendar event.", "strict": false, + "parameters": {"type": "object", "properties": {"title": {"type": "string"}}}} + ]}, + {"type": "namespace", "name": "web", "description": "Tools in the web namespace.", "tools": [ + {"type": "function", "name": "run", "description": "Search the web.", "strict": false, "parameters": {"type": "object"}} + ]}, + {"type": "tool_search", "execution": "client", "description": "Search deferred tools.", + "parameters": {"type": "object", "properties": {"query": {"type": "string"}, "limit": {"type": "number"}}, "required": ["query"]}} + ]}, + {"id": "msg_1", "type": "message", "role": "developer", "content": [{"type": "input_text", "text": "You are Codex."}], + "internal_chat_message_metadata_passthrough": {"content_item_kinds": ["model.base_instructions"]}}, + {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "fix the test"}]}, + {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "Looking."}], "phase": "commentary"}, + {"type": "function_call", "name": "exec_command", "namespace": "functions", "arguments": "{\"cmd\":\"ls\"}", "call_id": "call_1"}, + {"type": "function_call_output", "call_id": "call_1", "output": "src"}, + {"type": "local_shell_call", "id": "lsh_1", "call_id": "call_2", "status": "completed", + "action": {"type": "exec", "command": ["echo", "hi"], "timeout_ms": null, "working_directory": null, "env": null, "user": null}}, + {"type": "function_call_output", "call_id": "call_2", "output": "hi"}, + {"type": "tool_search_call", "call_id": "call_3", "execution": "client", "status": "completed", "arguments": {"query": "drive", "limit": 8}}, + {"type": "tool_search_output", "call_id": "call_3", "status": "completed", "execution": "client", "tools": [ + {"type": "namespace", "name": "mcp__codex_apps__drive", "description": "Files in Drive.", "tools": [ + {"type": "function", "name": "_search", "description": "Search files.", "strict": false, "defer_loading": true, "parameters": {"type": "object"}} + ]} + ]}, + {"type": "configuration_update", "reasoning": {"effort": "high"}}, + {"type": "agent_message", "author": "/root", "recipient": "/root/worker", "content": [ + {"type": "input_text", "text": "Message Type: MESSAGE\nPayload:\nrun it"}, + {"type": "encrypted_content", "encrypted_content": "gAAA"} + ]}, + {"type": "web_search_call", "id": "ws_1", "status": "completed", "action": {"type": "search", "query": "x"}} + ], + "tool_choice": "auto", + "parallel_tool_calls": false, + "reasoning": {"effort": "medium", "summary": "auto", "context": "all_turns"}, + "store": false, + "include": ["reasoning.encrypted_content"], + "prompt_cache_key": "019a", + "text": {"verbosity": "low"}, + "client_metadata": {"x-codex-turn-metadata": "{}"} + }"#; + + #[test] + fn a_responses_lite_request_keeps_the_tools_it_declares_in_input() { + let (r, dropped, shape) = decode(LITE).unwrap(); + let names: Vec<&str> = r.tools.iter().map(|t| t.name.as_str()).collect(); + assert_eq!( + names, + [ + "exec_command", + "apply_patch", + "mcp__codex_apps__calendar_create_event", + "web__run", + "tool_search", + // tool_search 搜到的,从此可以调用 + "mcp__codex_apps__drive_search", + ] + ); + assert!(matches!(r.tools[1].kind, ToolKind::Freeform { .. })); + assert_eq!( + shape.namespaced.get("exec_command"), + Some(&("functions".to_string(), "exec_command".to_string())) + ); + assert_eq!( + shape + .namespaced + .get("mcp__codex_apps__calendar_create_event"), + Some(&( + "mcp__codex_apps__calendar".to_string(), + "_create_event".to_string() + )) + ); + assert_eq!(shape.tool_search.as_deref(), Some("tool_search")); + // MCP 服务器的说明进系统提示;Codex 自己填的套话不进 + assert_eq!( + r.system, + [ + "You are Codex.", + "Tools whose names start with mcp__codex_apps__calendar belong to the mcp__codex_apps__calendar namespace:\nPlan events.", + "Tools whose names start with mcp__codex_apps__drive belong to the mcp__codex_apps__drive namespace:\nFiles in Drive." + ] + ); + // 对话中途改成了 high:盖过顶层开头那一档 + assert_eq!(r.reasoning.as_ref().unwrap().effort, Some(Effort::High)); + assert_eq!(r.verbosity, Some(Verbosity::Low)); + assert_eq!( + dropped, + [ + "input.agent_message.content.encrypted_content", + "input.web_search_call" + ] + ); + + let parts: Vec<&Part> = r.messages.iter().flat_map(|m| &m.parts).collect(); + // 历史里默认 namespace 的调用和工具同名 + assert!( + matches!(parts[2], Part::ToolCall(c) if c.name == "exec_command" && c.input == ToolInput::Json(json!({"cmd": "ls"}))) + ); + // local_shell_call 和它的结果成对留着 + let Part::ToolCall(shell) = parts[4] else { + panic!("{:?}", parts[4]); + }; + assert_eq!( + (shell.id.as_str(), shell.name.as_str()), + ("call_2", "local_shell") + ); + let ToolInput::Json(action) = &shell.input else { + panic!("{shell:?}"); + }; + assert_eq!(action["command"], json!(["echo", "hi"])); + assert!( + matches!(parts[5], Part::ToolResult(res) if res.id == "call_2" && res.text() == "hi") + ); + // 工具搜索的调用和结果 + assert!( + matches!(parts[6], Part::ToolCall(c) if c.name == "tool_search" && c.input == ToolInput::Json(json!({"query": "drive", "limit": 8}))) + ); + assert!( + matches!(parts[7], Part::ToolResult(res) if res.id == "call_3" && res.text() == "These tools are now available: mcp__codex_apps__drive_search") + ); + // 别的代理发来的话是用户的一轮 + let last = r.messages.last().unwrap(); + assert_eq!(last.role, Role::User); + assert_eq!( + last.parts, + [Part::Text("Message Type: MESSAGE\nPayload:\nrun it".into())] + ); + } + + #[test] + fn a_later_declaration_replaces_an_earlier_one_in_place() { + // 顶层 tools 和 additional_tools 一起来,还有 Codex 的增量声明:同名的以最后一次为准, + // 位置是第一次出现的位置 + let (r, dropped, _) = decode( + r#"{"model": "m", + "tools": [ + {"type": "function", "name": "shell", "description": "old", "parameters": {"type": "object"}}, + {"type": "function", "name": "plan", "parameters": {"type": "object"}} + ], + "input": [ + {"type": "additional_tools", "role": "developer", "tools": [ + {"type": "namespace", "name": "functions", "tools": [ + {"type": "function", "name": "shell", "description": "new", "parameters": {"type": "object"}}, + {"type": "custom", "name": "apply_patch"} + ]}, + {"type": "web_search"} + ]}, + {"role": "user", "content": "hi"}, + {"type": "additional_tools", "role": "developer", "tools": [ + {"type": "namespace", "name": "functions", "tools": [ + {"type": "custom", "name": "plan", "description": "now freeform"} + ]} + ]} + ]}"#, + ) + .unwrap(); + let tools: Vec<(&str, Option<&str>, bool)> = r + .tools + .iter() + .map(|t| { + ( + t.name.as_str(), + t.description.as_deref(), + matches!(t.kind, ToolKind::Freeform { .. }), + ) + }) + .collect(); + assert_eq!( + tools, + [ + ("shell", Some("new"), false), + ("plan", Some("now freeform"), true), + ("apply_patch", None, true), + ] + ); + assert_eq!(dropped, ["input.additional_tools.tools.web_search"]); + } + + #[test] + fn a_long_namespaced_name_is_cut_to_64_with_a_stable_hash() { + assert_eq!(flat_tool_name(None, "shell"), "shell"); + assert_eq!(flat_tool_name(Some("functions"), "shell"), "shell"); + assert_eq!(flat_tool_name(Some("mcp_fs"), "read"), "mcp_fs__read"); + assert_eq!( + flat_tool_name(Some("mcp__codex_apps__calendar"), "_create_event"), + "mcp__codex_apps__calendar_create_event" + ); + let ns = "mcp__a_rather_long_server_name_from_some_connector"; + let a = flat_tool_name(Some(ns), "create_a_very_descriptive_thing"); + assert_eq!(a.len(), 64); + assert!(a.starts_with(ns), "{a}"); + // 同一个工具每次同一个名字,不同的工具不撞 + assert_eq!( + a, + flat_tool_name(Some(ns), "create_a_very_descriptive_thing") + ); + assert_ne!( + a, + flat_tool_name(Some(ns), "create_a_very_descriptive_thinG") + ); + } + + #[test] + fn freeform_tools_are_found_wherever_they_are_declared() { + let v: Value = serde_json::from_str(LITE).unwrap(); + assert_eq!(freeform_tools(&v), ["apply_patch"]); + } + + #[test] + fn verbosity_goes_only_to_models_that_understand_it() { + let (mut r, _, _) = decode(LITE).unwrap(); + let (v, dropped) = encode(&r, Dialect::Responses); + assert_eq!(v["text"], json!({"verbosity": "low"})); + assert!(!dropped.contains(&"text.verbosity".to_string())); + r.model = "gpt-4.1".into(); + let (v, dropped) = encode(&r, Dialect::Responses); + assert!(v.get("text").is_none()); + assert!(dropped.contains(&"text.verbosity".to_string())); + for (model, ok) in [ + ("gpt-5", true), + ("gpt-5.4-mini", true), + ("openai/gpt-6-luna", true), + ("gpt-4o", false), + ("gpt-oss-120b", false), + ("claude-opus-4-7", false), + ] { + assert_eq!(Verbosity::understood_by(model), ok, "{model}"); + } } #[test] @@ -733,7 +1329,7 @@ mod tests { assert_eq!(items[1]["encrypted_content"], "gAAAAB"); assert_eq!( v["instructions"], - "You are Codex.\n\nsandbox: workspace-write" + "You are Codex.\n\nsandbox: workspace-write\n\nTools whose names start with mcp_fs belong to the mcp_fs namespace:\nfiles" ); assert_eq!(v["tools"][0]["strict"], false); assert_eq!(v["tools"][1]["type"], "custom"); diff --git a/crates/tw-dialect/src/responses/response.rs b/crates/tw-dialect/src/responses/response.rs index 1200547d..c1f81f2b 100644 --- a/crates/tw-dialect/src/responses/response.rs +++ b/crates/tw-dialect/src/responses/response.rs @@ -164,6 +164,15 @@ pub(crate) fn item(b: &Block, id: &str, done: bool, s: &Session) -> Value { } o } + // 客户端自己执行的工具搜索:参数是对象,不是 JSON 文本 + Block::ToolCall(c) if s.is_tool_search(&c.name) => json!({ + "id": id, + "type": "tool_search_call", + "call_id": c.id, + "execution": "client", + "status": status, + "arguments": if done { c.input.to_object() } else { json!({}) }, + }), Block::ToolCall(c) => { let (name, namespace) = match s.namespaced(&c.name) { Some((ns, n)) => (n.as_str(), Some(ns.as_str())), @@ -198,6 +207,7 @@ pub(crate) fn item_id(b: &Block, s: &Session) -> String { new_id(match b { Block::Text(_) => "msg_", Block::Thinking(_) => "rs_", + Block::ToolCall(c) if s.is_tool_search(&c.name) => "tsc_", Block::ToolCall(c) if s.is_freeform(&c.name) => "ctc_", Block::ToolCall(_) => "fc_", }) diff --git a/crates/tw-dialect/src/responses/stream.rs b/crates/tw-dialect/src/responses/stream.rs index 53894012..46a4deac 100644 --- a/crates/tw-dialect/src/responses/stream.rs +++ b/crates/tw-dialect/src/responses/stream.rs @@ -492,6 +492,8 @@ impl Writer { } (Block::Thinking(th), Delta::Signature(s)) => th.signature = Some(s.clone()), (Block::ToolCall(c), Delta::ToolInput(d)) => { + // `tool_search_call` 没有参数的增量事件:参数整个在 output_item.done 里 + let quiet = self.session.is_tool_search(&c.name); let kind = match &mut c.input { ToolInput::Text(t) => { t.push_str(d); @@ -506,11 +508,13 @@ impl Writer { "response.function_call_arguments.delta" } }; - self.emit( - kind, - json!({ "item_id": id, "output_index": oi, "delta": d }), - out, - ); + if !quiet { + self.emit( + kind, + json!({ "item_id": id, "output_index": oi, "delta": d }), + out, + ); + } } _ => {} } @@ -563,6 +567,7 @@ impl Writer { ); } Block::Thinking(_) => {} + Block::ToolCall(c) if self.session.is_tool_search(&c.name) => {} Block::ToolCall(c) => match &c.input { ToolInput::Text(t) => self.emit( "response.custom_tool_call_input.done", diff --git a/crates/tw-dialect/tests/caller.rs b/crates/tw-dialect/tests/caller.rs index 6e176a1f..1cff6408 100644 --- a/crates/tw-dialect/tests/caller.rs +++ b/crates/tw-dialect/tests/caller.rs @@ -209,6 +209,26 @@ fn responses() { {"type": "input_image", "image_url": "data:image/png;base64,«x4»"}, ]}, {"type": "reasoning", "summary": [{"type": "summary_text", "text": "«a2»"}]}, + // Codex 的 Responses Lite:工具声明在 input 里,namespace 的说明进系统提示 + {"type": "additional_tools", "role": "developer", "tools": [ + {"type": "namespace", "name": "mcp__x", "description": "«s4»", "tools": [ + {"type": "function", "name": "h", "description": "«x6»", "parameters": {}}, + ]}, + {"type": "tool_search", "execution": "client", "description": "«x7»", "parameters": {}}, + ]}, + {"type": "local_shell_call", "call_id": "c3", "status": "completed", + "action": {"type": "exec", "command": ["echo", "«x8»"]}}, + {"type": "function_call_output", "call_id": "c3", "output": "«t3»"}, + {"type": "tool_search_call", "call_id": "c4", "execution": "client", "arguments": {"query": "«x9»"}}, + {"type": "tool_search_output", "call_id": "c4", "status": "completed", "execution": "client", "tools": [ + {"type": "function", "name": "k", "description": "«x10»", "parameters": {}}, + ]}, + // 别的代理发来的话:读得懂的那段是调用方的话 + {"type": "agent_message", "author": "/root", "recipient": "/root/w", "content": [ + {"type": "input_text", "text": "«u6»"}, + {"type": "encrypted_content", "encrypted_content": "«x11»"}, + ]}, + {"type": "configuration_update", "reasoning": {"effort": "high"}}, ], "tools": [ {"type": "function", "name": "f", "description": "«x5»", "parameters": {}}, @@ -218,8 +238,8 @@ fn responses() { check( Dialect::Responses, v, - &["u1", "u2", "u3", "u4", "u5"], - &["t1", "t2"], + &["u1", "u2", "u3", "u4", "u5", "u6"], + &["t1", "t2", "t3"], ); } diff --git a/crates/tw-dialect/tests/codex_lite.rs b/crates/tw-dialect/tests/codex_lite.rs new file mode 100644 index 00000000..851d8829 --- /dev/null +++ b/crates/tw-dialect/tests/codex_lite.rs @@ -0,0 +1,614 @@ +//! Codex 的 Responses Lite 请求转给别家格式的上游,走一个来回。 +//! +//! Responses Lite(Codex 的 `use_responses_lite`)的请求顶层没有 `tools`:所有工具装在 +//! `input` 开头的 `additional_tools` 项里,函数和自由格式工具又装在 `functions` 这个 +//! namespace 里(`codex-rs/core/src/client.rs` 的 `build_responses_request`、 +//! `codex-rs/tools/src/tool_spec.rs` 的 `create_tools_json_for_responses_lite`)。这一项 +//! 以前整个被丢掉:转给别家的请求一个工具都没有,Codex 回答「没有挂载读写和执行命令的工具」。 +//! +//! 请求照 Codex 的 serde 类型写(`codex-rs/protocol/src/models.rs` 的 `ResponseItem`)。 +//! 检查: +//! +//! - 发给 Anthropic、Chat、Gemini、Bedrock 的请求里工具一个不少,历史里的调用和结果成对 +//! - 上游调用这些工具时,Codex 收到的调用用的是它自己的写法:namespace 加名字、 +//! `function_call` 还是 `custom_tool_call`、工具搜索是 `tool_search_call` —— 整包、流式、 +//! 「上游给流、客户端要整包」三条路都一样 +//! - 同格式直通一个字节都不改 + +// 整个请求写成一个 `json!`,嵌套得深 +#![recursion_limit = "512"] + +use serde_json::{Value, json}; +use tw_dialect::convert::{Session, decode, strip_carried}; +use tw_dialect::frame::Decoder; +use tw_dialect::ir::*; +use tw_dialect::{anthropic, bedrock, chat, gemini}; + +const UPSTREAMS: [Dialect; 4] = [ + Dialect::Anthropic, + Dialect::Chat, + Dialect::Gemini, + Dialect::Bedrock, +]; + +const PATCH: &str = "*** Begin Patch\n*** Add File: a.txt\n+hi\n*** End Patch\n"; + +/// Codex 0.1xx 用 Responses Lite 时发的那种请求:第二轮,带着上一轮的命令调用、一次老式的 +/// `local_shell_call`、一次工具搜索和对话中途改过的推理强度 +fn lite_request() -> Value { + json!({ + "model": "gpt-5.4", + "stream": true, + "input": [ + {"id": "at_0b9e", "type": "additional_tools", "role": "developer", "tools": [ + {"type": "namespace", "name": "functions", "description": "", "tools": [ + {"type": "function", "name": "exec_command", "description": "Runs a command in a PTY.", "strict": false, + "parameters": {"type": "object", "properties": { + "cmd": {"type": "string", "description": "Shell command to execute."}, + "workdir": {"type": "string"} + }, "required": ["cmd"], "additionalProperties": false}}, + {"type": "function", "name": "write_stdin", "description": "Writes characters to an existing session.", "strict": false, + "parameters": {"type": "object", "properties": {"session_id": {"type": "number"}, "chars": {"type": "string"}}, + "required": ["session_id"], "additionalProperties": false}}, + {"type": "custom", "name": "apply_patch", "description": "Use the `apply_patch` tool to edit files.", + "format": {"type": "grammar", "syntax": "lark", "definition": "start: begin_patch hunk+ end_patch\nbegin_patch: \"*** Begin Patch\" LF"}}, + {"type": "function", "name": "update_plan", "description": "Updates the task plan.", "strict": false, + "parameters": {"type": "object", "properties": {"plan": {"type": "array", "items": {"type": "object"}}}, + "required": ["plan"], "additionalProperties": false}} + ]}, + {"type": "namespace", "name": "mcp__codex_apps__calendar", "description": "Plan events.", "tools": [ + {"type": "function", "name": "_create_event", "description": "Create a calendar event.", "strict": false, + "parameters": {"type": "object", "properties": {"title": {"type": "string"}}, "required": ["title"]}} + ]}, + {"type": "namespace", "name": "web", "description": "Tools in the web namespace.", "tools": [ + {"type": "function", "name": "run", "description": "Search the web.", "strict": false, + "parameters": {"type": "object", "properties": {"query": {"type": "string"}}}} + ]}, + {"type": "tool_search", "execution": "client", "description": "Search deferred tools.", + "parameters": {"type": "object", "properties": { + "query": {"type": "string", "description": "Search query for deferred tools."}, + "limit": {"type": "number", "description": "Maximum number of tools to return. Defaults to 8."} + }, "required": ["query"], "additionalProperties": false}} + ]}, + {"id": "msg_5c1d", "type": "message", "role": "developer", + "content": [{"type": "input_text", "text": "You are Codex, a coding agent."}], + "internal_chat_message_metadata_passthrough": {"content_item_kinds": ["model.base_instructions"]}}, + {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "\n /repo\n"}]}, + {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "Fix the failing test."}]}, + {"type": "reasoning", "id": "rs_1", "summary": [{"type": "summary_text", "text": "**Running the tests**"}], "encrypted_content": "gAAAAB"}, + {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "Running the tests first."}], "phase": "commentary"}, + {"type": "function_call", "name": "exec_command", "namespace": "functions", "arguments": "{\"cmd\":\"cargo test\"}", "call_id": "call_1"}, + {"type": "function_call_output", "call_id": "call_1", "output": "test result: FAILED"}, + {"type": "custom_tool_call", "status": "completed", "call_id": "call_2", "name": "apply_patch", "namespace": "functions", "input": PATCH}, + {"type": "custom_tool_call_output", "call_id": "call_2", "output": "Success. Updated the following files:\nA a.txt"}, + {"type": "local_shell_call", "id": "lsh_1", "call_id": "call_3", "status": "completed", + "action": {"type": "exec", "command": ["cargo", "test"], "timeout_ms": 60000, "working_directory": "/repo", "env": null, "user": null}}, + {"type": "function_call_output", "call_id": "call_3", "output": "test result: ok"}, + {"type": "tool_search_call", "call_id": "call_4", "execution": "client", "status": "completed", "arguments": {"query": "drive files", "limit": 8}}, + {"type": "tool_search_output", "call_id": "call_4", "status": "completed", "execution": "client", "tools": [ + {"type": "namespace", "name": "mcp__codex_apps__drive", "description": "Files in Drive.", "tools": [ + {"type": "function", "name": "_search", "description": "Search files.", "strict": false, "defer_loading": true, + "parameters": {"type": "object", "properties": {"q": {"type": "string"}}}} + ]} + ]}, + {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "The test passes now."}], "phase": "final_answer"}, + {"type": "configuration_update", "reasoning": {"effort": "high"}}, + {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "Now add a calendar event for the release."}]} + ], + "tool_choice": "auto", + "parallel_tool_calls": false, + "reasoning": {"effort": "medium", "summary": "auto", "context": "all_turns"}, + "store": false, + "include": ["reasoning.encrypted_content"], + "prompt_cache_key": "019a5e0c-3d1b-7f00-8000-000000000001", + "text": {"verbosity": "low"}, + "client_metadata": {"x-codex-turn-metadata": "{\"turn_id\":\"t1\"}"} + }) +} + +/// 每个工具在别家那边叫什么 +const TOOLS: [&str; 8] = [ + "exec_command", + "write_stdin", + "apply_patch", + "update_plan", + "mcp__codex_apps__calendar_create_event", + "web__run", + "tool_search", + "mcp__codex_apps__drive_search", +]; + +fn target(d: Dialect) -> Target { + Target { + dialect: d, + official: false, + default_max_tokens: 8192, + } +} + +/// 网关的做法:解码一次,改成上游的模型名,再按上游的格式编码 +fn prepared(upstream: Dialect, model: &str) -> tw_dialect::convert::Prepared { + let mut d = decode(Dialect::Responses, &lite_request(), "/v1/responses", None).unwrap(); + d.request.model = model.to_string(); + d.encode(&target(upstream)) +} + +/// 用上游格式自己的解码器把发出去的请求读回来 +fn read_back(upstream: Dialect, body: &[u8]) -> Request { + let v: Value = serde_json::from_slice(body).unwrap(); + let mut d = Dropped::new(upstream); + let mut shape = ClientShape::default(); + match upstream { + Dialect::Anthropic => anthropic::decode_request(&v, &mut d), + Dialect::Chat => chat::decode_request(&v, &mut d, &mut shape), + Dialect::Gemini => gemini::decode_request(&v, "m", true, &mut d), + Dialect::Bedrock => bedrock::decode_request(&v, "m", true, &mut d), + Dialect::Responses => unreachable!(), + } + .unwrap() +} + +#[test] +fn every_tool_codex_declares_in_input_reaches_the_upstream() { + for upstream in UPSTREAMS { + let model = match upstream { + Dialect::Anthropic => "claude-opus-4-7", + Dialect::Chat => "deepseek-chat", + Dialect::Gemini => "gemini-2.5-pro", + _ => "us.anthropic.claude-sonnet-4-5-20250929-v1:0", + }; + let p = prepared(upstream, model); + // 丢的只是别家确实没有的:OpenAI 加密的推理(只有 OpenAI 读得懂)、apply_patch 的 + // 语法约束(只有 OpenAI 执行)、回答写多写少,Gemini 和 Bedrock 还有「一次只调一个 + // 工具」。工具声明一项不丢 + let mut want = vec!["input.reasoning", "tools.custom.format"]; + if matches!(upstream, Dialect::Gemini | Dialect::Bedrock) { + want.push("parallel_tool_calls"); + } + want.push("text.verbosity"); + assert_eq!(p.dropped, want, "{upstream:?}"); + + let r = read_back(upstream, &p.body); + let names: Vec<&str> = r.tools.iter().map(|t| t.name.as_str()).collect(); + assert_eq!(names, TOOLS, "{upstream:?}"); + // 自由格式的 apply_patch 在只有 JSON 工具的格式里声明成 `{"input": string}` + let patch = r.tools.iter().find(|t| t.name == "apply_patch").unwrap(); + assert!( + matches!(&patch.kind, ToolKind::Function { schema, .. } if schema["properties"]["input"]["type"] == "string"), + "{upstream:?}: {patch:?}" + ); + // 系统提示:基础指令、MCP 服务器的说明 + let system = r.system.join("\n"); + assert!(system.contains("You are Codex"), "{upstream:?}"); + assert!(system.contains("Plan events."), "{upstream:?}"); + assert!(system.contains("Files in Drive."), "{upstream:?}"); + + // 历史里的四次调用(含老式的 local_shell_call 和工具搜索)都在,每次都有结果 + let calls: Vec<&str> = r + .messages + .iter() + .flat_map(|m| &m.parts) + .filter_map(|p| match p { + Part::ToolCall(c) => Some(c.name.as_str()), + _ => None, + }) + .collect(); + assert_eq!( + calls, + ["exec_command", "apply_patch", "local_shell", "tool_search"], + "{upstream:?}" + ); + let results: Vec = r + .messages + .iter() + .flat_map(|m| &m.parts) + .filter_map(|p| match p { + Part::ToolResult(res) => Some(res.text()), + _ => None, + }) + .collect(); + assert_eq!(results.len(), 4, "{upstream:?}: {results:?}"); + assert!(results[2].contains("test result: ok"), "{upstream:?}"); + assert!( + results[3].contains("mcp__codex_apps__drive_search"), + "{upstream:?}" + ); + } +} + +#[test] +fn the_effort_changed_mid_conversation_is_the_one_sent() { + // 顶层写的是 medium(Codex 为了保住提示缓存,一直写开头那一档),对话中途改成了 high + let p = prepared(Dialect::Chat, "gpt-5.4"); + let v: Value = serde_json::from_slice(&p.body).unwrap(); + assert_eq!(v["reasoning_effort"], "high"); + // GPT-5 认 verbosity:照写,不算丢 + assert_eq!(v["verbosity"], "low"); + assert_eq!(p.dropped, ["input.reasoning", "tools.custom.format"]); + let p = prepared(Dialect::Chat, "deepseek-chat"); + let v: Value = serde_json::from_slice(&p.body).unwrap(); + assert!(v.get("verbosity").is_none()); + assert_eq!( + p.dropped, + ["input.reasoning", "tools.custom.format", "text.verbosity"] + ); +} + +#[test] +fn passing_straight_through_to_openai_changes_nothing() { + let body = lite_request().to_string(); + assert_eq!(strip_carried(Dialect::Responses, body.as_bytes()), None); +} + +// ───────────────────────────────────────────────────────── 上游调用工具 + +fn named(event: &str, data: Value) -> String { + format!("event: {event}\ndata: {data}\n\n") +} + +fn data(v: Value) -> String { + format!("data: {v}\n\n") +} + +/// 上游的整包回答:一段话,再调用四种工具 +fn upstream_response(d: Dialect) -> Value { + let wrapped = json!({ "input": PATCH }); + match d { + Dialect::Anthropic => json!({ + "id": "msg_up", "type": "message", "role": "assistant", "model": "claude-opus-4-7", + "content": [ + {"type": "text", "text": "On it."}, + {"type": "tool_use", "id": "toolu_1", "name": "exec_command", "input": {"cmd": "cargo test"}}, + {"type": "tool_use", "id": "toolu_2", "name": "apply_patch", "input": wrapped}, + {"type": "tool_use", "id": "toolu_3", "name": "mcp__codex_apps__calendar_create_event", "input": {"title": "release"}}, + {"type": "tool_use", "id": "toolu_4", "name": "tool_search", "input": {"query": "drive"}} + ], + "stop_reason": "tool_use", + "usage": {"input_tokens": 10, "output_tokens": 5} + }), + Dialect::Chat => json!({ + "id": "chatcmpl-up", "object": "chat.completion", "model": "deepseek-chat", + "choices": [{"index": 0, "finish_reason": "tool_calls", "message": { + "role": "assistant", "content": "On it.", + "tool_calls": [ + {"id": "call_a", "type": "function", "function": {"name": "exec_command", "arguments": "{\"cmd\":\"cargo test\"}"}}, + {"id": "call_b", "type": "function", "function": {"name": "apply_patch", "arguments": wrapped.to_string()}}, + {"id": "call_c", "type": "function", "function": {"name": "mcp__codex_apps__calendar_create_event", "arguments": "{\"title\":\"release\"}"}}, + {"id": "call_d", "type": "function", "function": {"name": "tool_search", "arguments": "{\"query\":\"drive\"}"}} + ] + }}], + "usage": {"prompt_tokens": 10, "completion_tokens": 5} + }), + Dialect::Gemini => json!({ + "candidates": [{"content": {"role": "model", "parts": [ + {"text": "On it."}, + {"functionCall": {"name": "exec_command", "args": {"cmd": "cargo test"}}}, + {"functionCall": {"name": "apply_patch", "args": wrapped}}, + {"functionCall": {"name": "mcp__codex_apps__calendar_create_event", "args": {"title": "release"}}}, + {"functionCall": {"name": "tool_search", "args": {"query": "drive"}}} + ]}, "finishReason": "STOP"}], + "usageMetadata": {"promptTokenCount": 10, "candidatesTokenCount": 5}, + "modelVersion": "gemini-2.5-pro" + }), + Dialect::Bedrock => json!({ + "output": {"message": {"role": "assistant", "content": [ + {"text": "On it."}, + {"toolUse": {"toolUseId": "tooluse_1", "name": "exec_command", "input": {"cmd": "cargo test"}}}, + {"toolUse": {"toolUseId": "tooluse_2", "name": "apply_patch", "input": wrapped}}, + {"toolUse": {"toolUseId": "tooluse_3", "name": "mcp__codex_apps__calendar_create_event", "input": {"title": "release"}}}, + {"toolUse": {"toolUseId": "tooluse_4", "name": "tool_search", "input": {"query": "drive"}}} + ]}}, + "stopReason": "tool_use", + "usage": {"inputTokens": 10, "outputTokens": 5, "totalTokens": 15} + }), + Dialect::Responses => unreachable!(), + } +} + +/// 同样的回答写成上游的流,参数分几片发 +fn upstream_stream(d: Dialect) -> String { + let wrapped = json!({ "input": PATCH }).to_string(); + let (w1, w2) = wrapped.split_at(12); + let calls: [(&str, &str, &str); 4] = [ + ("exec_command", "{\"cmd\":", "\"cargo test\"}"), + ("apply_patch", w1, w2), + ( + "mcp__codex_apps__calendar_create_event", + "{\"title\":", + "\"release\"}", + ), + ("tool_search", "{\"query\":", "\"drive\"}"), + ]; + match d { + Dialect::Anthropic => { + let mut s = vec![ + named( + "message_start", + json!({"type": "message_start", "message": {"id": "msg_up", "model": "claude-opus-4-7", "usage": {"input_tokens": 10, "output_tokens": 1}}}), + ), + named( + "content_block_start", + json!({"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}), + ), + named( + "content_block_delta", + json!({"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "On it."}}), + ), + named( + "content_block_stop", + json!({"type": "content_block_stop", "index": 0}), + ), + ]; + for (i, (name, a, b)) in calls.iter().enumerate() { + let index = i + 1; + s.push(named("content_block_start", json!({"type": "content_block_start", "index": index, "content_block": {"type": "tool_use", "id": format!("toolu_{index}"), "name": name, "input": {}}}))); + for part in [a, b] { + s.push(named("content_block_delta", json!({"type": "content_block_delta", "index": index, "delta": {"type": "input_json_delta", "partial_json": part}}))); + } + s.push(named( + "content_block_stop", + json!({"type": "content_block_stop", "index": index}), + )); + } + s.push(named("message_delta", json!({"type": "message_delta", "delta": {"stop_reason": "tool_use"}, "usage": {"output_tokens": 5}}))); + s.push(named("message_stop", json!({"type": "message_stop"}))); + s.concat() + } + Dialect::Chat => { + let chunk = |delta: Value, finish: Value| { + data( + json!({"id": "chatcmpl-up", "object": "chat.completion.chunk", "model": "deepseek-chat", + "choices": [{"index": 0, "delta": delta, "finish_reason": finish}]}), + ) + }; + let mut s = vec![ + chunk(json!({"role": "assistant", "content": ""}), Value::Null), + chunk(json!({"content": "On it."}), Value::Null), + ]; + for (i, (name, a, b)) in calls.iter().enumerate() { + s.push(chunk(json!({"tool_calls": [{"index": i, "id": format!("call_{i}"), "type": "function", "function": {"name": name, "arguments": a}}]}), Value::Null)); + s.push(chunk( + json!({"tool_calls": [{"index": i, "function": {"arguments": b}}]}), + Value::Null, + )); + } + s.push(chunk(json!({}), json!("tool_calls"))); + s.push(data(json!({"id": "chatcmpl-up", "object": "chat.completion.chunk", "model": "deepseek-chat", "choices": [], + "usage": {"prompt_tokens": 10, "completion_tokens": 5}}))); + s.push("data: [DONE]\n\n".to_string()); + s.concat() + } + // Gemini 的函数调用不分片:一次给完整的参数 + Dialect::Gemini => { + let v = upstream_response(Dialect::Gemini); + let parts = v["candidates"][0]["content"]["parts"].as_array().unwrap(); + let mut s: Vec = parts[..parts.len() - 1] + .iter() + .map(|p| data(json!({"candidates": [{"content": {"role": "model", "parts": [p]}}], "modelVersion": "gemini-2.5-pro"}))) + .collect(); + s.push(data(json!({"candidates": [{"content": {"role": "model", "parts": [parts.last().unwrap()]}, "finishReason": "STOP"}], + "usageMetadata": {"promptTokenCount": 10, "candidatesTokenCount": 5}}))); + s.concat() + } + Dialect::Bedrock => { + let mut s = vec![ + named("messageStart", json!({"role": "assistant"})), + named( + "contentBlockDelta", + json!({"contentBlockIndex": 0, "delta": {"text": "On it."}}), + ), + named("contentBlockStop", json!({"contentBlockIndex": 0})), + ]; + for (i, (name, a, b)) in calls.iter().enumerate() { + let index = i + 1; + s.push(named("contentBlockStart", json!({"contentBlockIndex": index, "start": {"toolUse": {"toolUseId": format!("tooluse_{index}"), "name": name}}}))); + for part in [a, b] { + s.push(named( + "contentBlockDelta", + json!({"contentBlockIndex": index, "delta": {"toolUse": {"input": part}}}), + )); + } + s.push(named( + "contentBlockStop", + json!({"contentBlockIndex": index}), + )); + } + s.push(named("messageStop", json!({"stopReason": "tool_use"}))); + s.push(named( + "metadata", + json!({"usage": {"inputTokens": 10, "outputTokens": 5, "totalTokens": 15}}), + )); + s.concat() + } + Dialect::Responses => unreachable!(), + } +} + +/// Codex 的 `ResponseItem` 里这几种调用必须有的字段(`codex-rs/protocol/src/models.rs`)。 +/// 少一个,Codex 解析 `output_item` 失败,这次调用就像没发生过 +fn assert_codex_can_read(item: &Value, at: &str) { + let has_str = |k: &str| item[k].is_string(); + match item["type"].as_str() { + Some("function_call") => assert!( + has_str("name") && has_str("arguments") && has_str("call_id"), + "{at}: {item}" + ), + Some("custom_tool_call") => assert!( + has_str("name") && has_str("input") && has_str("call_id"), + "{at}: {item}" + ), + Some("tool_search_call") => assert!( + has_str("execution") && !item["arguments"].is_null() && has_str("call_id"), + "{at}: {item}" + ), + Some("message" | "reasoning") => {} + other => panic!("{at}: unexpected item {other:?}: {item}"), + } +} + +/// 调用写回 Codex 的样子:(类型, namespace, 名字, 参数) +fn calls_of(items: &[Value], at: &str) -> Vec<(String, Option, String, Value)> { + items + .iter() + .inspect(|i| assert_codex_can_read(i, at)) + .filter(|i| i["type"] != "message" && i["type"] != "reasoning") + .map(|i| { + let kind = i["type"].as_str().unwrap().to_string(); + let args = match kind.as_str() { + "function_call" => serde_json::from_str(i["arguments"].as_str().unwrap()).unwrap(), + "custom_tool_call" => i["input"].clone(), + _ => i["arguments"].clone(), + }; + ( + kind, + i["namespace"].as_str().map(str::to_string), + i["name"].as_str().unwrap_or_default().to_string(), + args, + ) + }) + .collect() +} + +fn check(items: &[Value], at: &str) { + let ns = |s: &str| Some(s.to_string()); + assert_eq!( + calls_of(items, at), + [ + ( + "function_call".to_string(), + ns("functions"), + "exec_command".to_string(), + json!({"cmd": "cargo test"}) + ), + ( + "custom_tool_call".to_string(), + ns("functions"), + "apply_patch".to_string(), + json!(PATCH) + ), + ( + "function_call".to_string(), + ns("mcp__codex_apps__calendar"), + "_create_event".to_string(), + json!({"title": "release"}) + ), + ( + "tool_search_call".to_string(), + None, + String::new(), + json!({"query": "drive"}) + ), + ], + "{at}" + ); + let search = items + .iter() + .find(|i| i["type"] == "tool_search_call") + .unwrap(); + assert_eq!(search["execution"], "client", "{at}"); + assert!( + search["id"].as_str().unwrap().starts_with("tsc_"), + "{at}: {search}" + ); +} + +fn session(upstream: Dialect, stream: bool) -> Session { + let mut body = lite_request(); + body["stream"] = json!(stream); + let mut d = decode(Dialect::Responses, &body, "/v1/responses", None).unwrap(); + d.request.model = "up-model".into(); + d.encode(&target(upstream)).session +} + +#[test] +fn a_whole_answer_calls_the_tools_the_way_codex_names_them() { + for upstream in UPSTREAMS { + let s = session(upstream, false); + let out = s + .response(upstream_response(upstream).to_string().as_bytes()) + .unwrap(); + let v: Value = serde_json::from_slice(&out).unwrap(); + check( + v["output"].as_array().unwrap(), + &format!("{upstream:?} 整包"), + ); + assert_eq!(v["status"], "completed"); + } +} + +#[test] +fn a_streamed_answer_calls_the_tools_the_way_codex_names_them() { + for upstream in UPSTREAMS { + let at = format!("{upstream:?} 流式"); + let s = session(upstream, true); + let mut c = s.stream(); + let mut out = Vec::new(); + for piece in upstream_stream(upstream).as_bytes().chunks(7) { + out.extend(c.process(piece)); + } + out.extend(c.finish()); + let mut dec = Decoder::default(); + let frames = dec.feed(&out); + let events: Vec<(String, Value)> = frames + .iter() + .map(|f| { + ( + f.event.clone().unwrap_or_default(), + serde_json::from_str(&f.data).unwrap(), + ) + }) + .collect(); + // Codex 记历史用的是 output_item.done 里的完整项 + let done: Vec = events + .iter() + .filter(|(k, _)| k == "response.output_item.done") + .map(|(_, v)| v["item"].clone()) + .collect(); + check(&done, &at); + // 刚开始的项也要读得懂:Codex 用它显示「正在调用」 + for (_, v) in events + .iter() + .filter(|(k, _)| k == "response.output_item.added") + { + assert_codex_can_read(&v["item"], &at); + } + // 工具搜索没有参数的增量事件 + let search_id = done + .iter() + .find(|i| i["type"] == "tool_search_call") + .unwrap()["id"] + .clone(); + assert!( + !events + .iter() + .any(|(k, v)| k.ends_with(".delta") && v["item_id"] == search_id), + "{at}" + ); + // 自由格式工具的原文完整地出现在它自己的事件里 + assert!( + events + .iter() + .any(|(k, v)| k == "response.custom_tool_call_input.done" && v["input"] == PATCH), + "{at}" + ); + let (last, body) = events.last().unwrap(); + assert_eq!(last, "response.completed", "{at}"); + check(body["response"]["output"].as_array().unwrap(), &at); + } +} + +#[test] +fn a_stream_collected_into_a_whole_answer_calls_the_tools_the_same_way() { + for upstream in UPSTREAMS { + let s = session(upstream, false); + let mut c = s.collector(); + for piece in upstream_stream(upstream).as_bytes().chunks(7) { + c.process(piece); + } + let v: Value = serde_json::from_slice(&c.finish().unwrap()).unwrap(); + check( + v["output"].as_array().unwrap(), + &format!("{upstream:?} 收集"), + ); + } +} diff --git a/crates/tw-gateway/tests/conversion.rs b/crates/tw-gateway/tests/conversion.rs index 2fb03ddf..ee11731b 100644 --- a/crates/tw-gateway/tests/conversion.rs +++ b/crates/tw-gateway/tests/conversion.rs @@ -566,3 +566,137 @@ async fn a_dangerous_call_is_cut_in_a_converted_gemini_json_array_stream() { // 转换出来的数组由转换器收尾,一样是完整的 assert_array_ends_in_error(&body, "我来装一下依赖。"); } + +/// Codex 用 Responses Lite 时的请求:顶层没有 `tools`,工具全在 `input` 开头的 +/// `additional_tools` 里(`functions` 这个 namespace 装着函数和自由格式工具) +fn codex_lite_request() -> Value { + json!({ + "model": "claude-opus-4-7", + "stream": true, + "input": [ + {"id": "at_1", "type": "additional_tools", "role": "developer", "tools": [ + {"type": "namespace", "name": "functions", "description": "", "tools": [ + {"type": "function", "name": "exec_command", "description": "Runs a command.", "strict": false, + "parameters": {"type": "object", "properties": {"cmd": {"type": "string"}}, "required": ["cmd"]}}, + {"type": "custom", "name": "apply_patch", "description": "Edit files.", + "format": {"type": "grammar", "syntax": "lark", "definition": "start: x"}} + ]}, + {"type": "namespace", "name": "mcp__codex_apps__calendar", "description": "Plan events.", "tools": [ + {"type": "function", "name": "_create_event", "description": "Create an event.", "strict": false, + "parameters": {"type": "object"}} + ]} + ]}, + {"type": "message", "role": "developer", "content": [{"type": "input_text", "text": "You are Codex."}]}, + {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "list the files"}]} + ], + "tool_choice": "auto", + "parallel_tool_calls": false, + "reasoning": {"effort": "medium", "summary": "auto", "context": "all_turns"}, + "store": false, + "include": ["reasoning.encrypted_content"], + "prompt_cache_key": "019a", + "text": {"verbosity": "low"} + }) +} + +#[tokio::test] +async fn a_codex_responses_lite_request_reaches_claude_with_every_tool() { + // 上游调用 Codex 默认 namespace 里的两个工具:一个函数、一个自由格式 + let stream = [ + "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_1\",\"model\":\"claude-opus-4-7\",\"usage\":{\"input_tokens\":30,\"output_tokens\":1}}}\n\n", + "event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"toolu_1\",\"name\":\"exec_command\",\"input\":{}}}\n\n", + "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"cmd\\\":\\\"ls\\\"}\"}}\n\n", + "event: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\n", + "event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"tool_use\",\"id\":\"toolu_2\",\"name\":\"apply_patch\",\"input\":{}}}\n\n", + "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"input\\\":\\\"*** Begin Patch\\\"}\"}}\n\n", + "event: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":1}\n\n", + "event: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\"},\"usage\":{\"output_tokens\":12}}\n\n", + "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n", + ] + .concat(); + let (up, seen) = upstream(200, "text/event-stream", stream).await; + let (gw, mut rx) = gateway(provider(up, Protocol::Anthropic), SecurityMode::Observe).await; + let (status, ct, body) = post( + gw, + "/v1/responses", + &[("authorization", "Bearer tw-k")], + codex_lite_request(), + ) + .await; + assert_eq!(status, 200, "{body}"); + assert_eq!(ct, "text/event-stream"); + + // 发给 Claude 的请求里工具一个不少 + let sent: Value = serde_json::from_slice(&seen.lock().unwrap().body).unwrap(); + let names: Vec<&str> = sent["tools"] + .as_array() + .unwrap_or_else(|| panic!("没有工具:{sent}")) + .iter() + .map(|t| t["name"].as_str().unwrap()) + .collect(); + assert_eq!( + names, + [ + "exec_command", + "apply_patch", + "mcp__codex_apps__calendar_create_event" + ] + ); + + // Codex 收到的调用用的是它自己的写法 + let done: Vec = data_frames(&body) + .into_iter() + .filter(|f| f["type"] == "response.output_item.done") + .map(|f| f["item"].clone()) + .collect(); + assert_eq!(done.len(), 2, "{body}"); + assert_eq!(done[0]["type"], "function_call"); + assert_eq!(done[0]["namespace"], "functions"); + assert_eq!(done[0]["name"], "exec_command"); + assert_eq!(done[0]["arguments"], "{\"cmd\":\"ls\"}"); + assert_eq!(done[1]["type"], "custom_tool_call"); + assert_eq!(done[1]["namespace"], "functions"); + assert_eq!(done[1]["name"], "apply_patch"); + assert_eq!(done[1]["input"], "*** Begin Patch"); + + // 说出来的丢弃字段里没有工具声明 + let mut dropped = None; + while let Ok(Ok(ev)) = tokio::time::timeout(Duration::from_secs(3), rx.recv()).await { + if let tw_api::Event::Translated { dropped: d, .. } = ev { + dropped = Some(d); + break; + } + } + assert_eq!( + dropped.expect("没发翻译事件"), + ["tools.custom.format", "text.verbosity"] + ); +} + +#[tokio::test] +async fn a_codex_responses_lite_request_to_openai_goes_byte_for_byte() { + let reply = concat!( + "event: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_1\",\"status\":\"completed\",", + "\"output\":[],\"usage\":{\"input_tokens\":5,\"output_tokens\":1}}}\n\n", + ); + let (up, seen) = upstream(200, "text/event-stream", reply.into()).await; + let (gw, _) = gateway( + provider(up, Protocol::OpenaiResponses), + SecurityMode::Observe, + ) + .await; + let sent = codex_lite_request(); + let (status, _, body) = post( + gw, + "/v1/responses", + &[("authorization", "Bearer tw-k")], + sent.clone(), + ) + .await; + assert_eq!(status, 200, "{body}"); + assert_eq!( + seen.lock().unwrap().body, + sent.to_string().into_bytes(), + "同格式直通改了请求体" + ); +} diff --git a/crates/tw-store/src/transcript/mod.rs b/crates/tw-store/src/transcript/mod.rs index 0ad55314..5b207879 100644 --- a/crates/tw-store/src/transcript/mod.rs +++ b/crates/tw-store/src/transcript/mod.rs @@ -1778,6 +1778,44 @@ mod tests { assert_eq!(t.turns[0].output, [call("toolu_1", "apply_patch", patch)]); } + /// Codex 的 Responses Lite:工具声明在 `input` 的 `additional_tools` 里,自由格式工具在 + /// `functions` 这个 namespace 里。声明不算对话;转给别家时 `apply_patch` 还叫这个名字, + /// 包着的原文照样拆回来 + #[test] + fn a_responses_lite_request_reads_its_tools_from_input() { + let mut d = Disk::new(); + let patch = "*** Begin Patch\n*** End Patch"; + d.put( + "/v1/responses", + Some( + json!({"model": "claude-sonnet-4-5", "input": [ + {"type": "additional_tools", "role": "developer", "tools": [ + {"type": "namespace", "name": "functions", "tools": [ + {"type": "custom", "name": "apply_patch"}, + {"type": "function", "name": "exec_command", "parameters": {}} + ]} + ]}, + {"type": "message", "role": "developer", "content": "You are Codex."}, + {"type": "message", "role": "user", "content": "改一下"} + ]}) + .to_string() + .as_bytes(), + ), + Some(&anthropic_stream(&[json!({"type": "tool_use", "id": "toolu_1", + "name": "apply_patch", "input": {"input": patch}})])), + |r| { + r.translated = Some( + json!({"provider": "anthropic", "from": "openai-responses", "to": "anthropic", "dropped": []}) + .to_string(), + ) + }, + ); + let t = d.transcript(); + assert_eq!(t.system.as_deref(), Some("You are Codex.")); + assert_eq!(t.turns[0].input, [msg(R::User, vec![text("改一下")])]); + assert_eq!(t.turns[0].output, [call("toolu_1", "apply_patch", patch)]); + } + /// Bedrock 的二进制帧在网关进门时转成了 SSE;整包的 Converse 也读得出来 #[test] fn a_bedrock_answer_is_read_streamed_or_whole() { diff --git a/crates/tw-store/src/transcript/read.rs b/crates/tw-store/src/transcript/read.rs index 5f88faf4..a34607f4 100644 --- a/crates/tw-store/src/transcript/read.rs +++ b/crates/tw-store/src/transcript/read.rs @@ -481,6 +481,10 @@ fn responses(v: &Value) -> Body<'_> { // 开头连着的 system、developer 消息算系统提示,和 Chat 一样 let mut leading = true; for it in input { + // 工具声明(Responses Lite 写在 input 里),和顶层的 `tools` 一样不算对话 + if str_of(it, "type") == Some("additional_tools") { + continue; + } if leading && str_of(it, "type").unwrap_or("message") == "message" && matches!(str_of(it, "role"), Some("system" | "developer")) @@ -494,34 +498,22 @@ fn responses(v: &Value) -> Body<'_> { } _ => {} } - let mut freeform = Vec::new(); - for t in arr_of(v, "tools") { - match str_of(t, "type") { - Some("custom") => freeform.extend(str_of(t, "name").map(str::to_string)), - Some("namespace") => { - let ns = str_of(t, "name"); - freeform.extend( - arr_of(t, "tools") - .iter() - .filter(|x| str_of(x, "type") == Some("custom")) - .filter_map(|x| str_of(x, "name")) - .map(|n| flat_name(ns, n).into_owned()), - ); - } - _ => {} - } - } Body { system, items, - freeform, + // 顶层 `tools`、`additional_tools`、`tool_search_output` 里的都算,名字和转给别家时一样 + freeform: tw_dialect::responses::request::freeform_tools(v), } } -/// namespace 里的工具展开成 `namespace__名字`,和转换给别家时的名字一样 +/// namespace 里的工具展开成一个名字,和转换给别家时的名字一样 +/// ([`tw_dialect::responses::request::flat_tool_name`]) fn flat_name<'a>(namespace: Option<&str>, name: &'a str) -> Cow<'a, str> { match namespace { - Some(ns) if !ns.is_empty() => Cow::Owned(format!("{ns}__{name}")), + Some(ns) if !ns.is_empty() => Cow::Owned(tw_dialect::responses::request::flat_tool_name( + Some(ns), + name, + )), _ => Cow::Borrowed(name), } }