From 594bb0291c3fc3fe0152bdc19a9511bfad6028df Mon Sep 17 00:00:00 2001 From: Nicolas Dreno Date: Mon, 20 Jul 2026 23:48:21 +0200 Subject: [PATCH] feat(ai-proxy): translate tool use for the Anthropic provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Anthropic translation path dropped tools entirely in both directions, so Codex/Claude tool calling was non-functional (OpenAI/Ollama passthrough was unaffected): - Request: `tools`/`tool_choice` were never forwarded, so the model never saw the tools and could not call them. - Response: `tool_use` blocks were ignored and only the first text block kept, yet `finish_reason` was set to `tool_calls` with no `tool_calls` array — a malformed tool-calling turn. Add a shared `protocols::tools` module and wire it into both Chat Completions and Responses: - map `tools` (`function.parameters` / flat `parameters` -> `input_schema`) and `tool_choice` (`required` -> `any`, named-tool forms -> `{type:tool}`, `parallel_tool_calls:false` -> `disable_parallel_tool_use`); - translate assistant `tool_calls` and `role:"tool"` history to `tool_use` / `tool_result` blocks (consecutive results merged into one user turn); - translate Anthropic `tool_use` responses back to OpenAI `tool_calls` (Chat Completions) / `function_call` (Responses), with `content: null` on a tool-only turn. Codex freeform `custom` tools (apply_patch), `local_shell`, and hosted server tools have no Anthropic representation. On the Responses path they now return 400 `custom_tools_not_supported_for_provider` instead of being dropped silently (fail visible, mirroring the dropped-reasoning handling). Full custom/freeform mapping and true SSE tool-call streaming remain deferred (ADR-0024 / ADR-0030 §2). Tests: 16 new unit tests (tool schema + tool_choice mapping, history translation, tool_calls emission, custom-tool rejection). Verified end-to-end against a WireMock Anthropic /v1/messages upstream: outbound request carries `tools`/`input_schema`, response comes back as `tool_calls`, and a Responses custom tool returns 400. - protocols/tools.rs: shared OpenAI <-> Anthropic tool mapping - protocols/chat_completion.rs: message + tool translation both ways - protocols/responses.rs: forward tools, reject custom tools with 400 - docs (dispatchers guide) + CHANGELOG --- CHANGELOG.md | 1 + docs/guide/dispatchers.md | 9 + .../ai-proxy/src/protocols/chat_completion.rs | 338 ++++++++++++++++-- plugins/ai-proxy/src/protocols/mod.rs | 1 + plugins/ai-proxy/src/protocols/responses.rs | 84 ++++- plugins/ai-proxy/src/protocols/tools.rs | 246 +++++++++++++ 6 files changed, 646 insertions(+), 33 deletions(-) create mode 100644 plugins/ai-proxy/src/protocols/tools.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 48c4206..05aa68f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **plugins/ai-proxy**: tool-use translation for the Anthropic provider on both Chat Completions and the Responses API. Previously the Anthropic translation dropped the client's `tools`/`tool_choice` entirely (the model never saw the tools, so it could never call them) and ignored `tool_use` blocks in the response (a tool-calling turn came back malformed, with `finish_reason: "tool_calls"` but no `tool_calls`). Now: `tools` and `tool_choice` are mapped to Anthropic's `tools`/`tool_choice` (`parameters` → `input_schema`, `"required"` → `any`, `parallel_tool_calls: false` → `disable_parallel_tool_use`); assistant `tool_calls` and `role:"tool"` history messages are translated to `tool_use`/`tool_result` blocks; and Anthropic `tool_use` responses are translated back to OpenAI `tool_calls` / Responses `function_call`. Codex freeform `custom` tools (e.g. `apply_patch`), `local_shell`, and hosted server tools have no Anthropic representation and are now rejected on the Responses path with `400 custom_tools_not_supported_for_provider` instead of being dropped silently. Shared mapping lives in a new `protocols::tools` module. OpenAI/Ollama remain passthrough. - **plugins/ai-proxy**: configurable credential attachment via a new `auth` field on targets, routes, and the flat config. `auth` is orthogonal to `provider` (which selects the wire protocol): `bearer` → `Authorization: Bearer`, `api_key` → `x-api-key`, `{ header: "Name" }` → an arbitrary credential header, `{ query: "param" }` → key in the query string. When omitted it defaults to the provider's convention (bearer for OpenAI/Ollama, `x-api-key` for Anthropic), so existing configs are unchanged. This lets OpenAI-compatible endpoints with non-standard credential headers (e.g. Brave AI Grounding's `X-Subscription-Token`, Azure OpenAI's `api-key`) be configured without a dedicated provider type. Internally, the three previously hardcoded auth call sites (OpenAI transport, Anthropic transport, `/v1/models` aggregator) now share a single `apply_auth` implementation. ## [0.8.1] - 2026-07-15 diff --git a/docs/guide/dispatchers.md b/docs/guide/dispatchers.md index 35c7734..cbb8346 100644 --- a/docs/guide/dispatchers.md +++ b/docs/guide/dispatchers.md @@ -913,6 +913,15 @@ OpenAI-compatible providers (OpenAI, Ollama) stream natively via `host_http_stre Anthropic streaming is buffered: the dispatcher waits for the full response and returns it non-streamed (a warning is logged). True SSE translation is deferred per ADR-0024 / ADR-0030 §2. +#### Tool use (Anthropic translation) + +For OpenAI/Ollama the tool surface is passthrough. For the **Anthropic** provider the dispatcher translates tools in both directions on Chat Completions and Responses: + +- **Request:** the client's `tools` are mapped to Anthropic's `tools` (`function.parameters` / flat `parameters` → `input_schema`), and `tool_choice` is mapped (`"auto"`/`"none"` → same, `"required"` → `any`, `{function:{name}}`/`{name}` → `{type:"tool", name}`); `parallel_tool_calls: false` becomes `disable_parallel_tool_use: true`. +- **Response:** Anthropic `tool_use` blocks become OpenAI `tool_calls` (Chat Completions) / `function_call` items (Responses); the assistant `content` is `null` on a tool-only turn, and `finish_reason` is `tool_calls`. +- **History:** an assistant `tool_calls` message → `tool_use` blocks, and `role:"tool"` messages → `tool_result` blocks (consecutive results merged into one user turn). +- **Unsupported tool types:** Codex freeform `custom` tools (e.g. `apply_patch`), `local_shell`, and hosted server tools have no Anthropic representation. On the Responses path they are rejected with `400 custom_tools_not_supported_for_provider` rather than dropped silently. Route those to OpenAI. + #### Responses API specifics `POST /v1/responses` is **stateless-only** (ADR-0030 §2): diff --git a/plugins/ai-proxy/src/protocols/chat_completion.rs b/plugins/ai-proxy/src/protocols/chat_completion.rs index 1aa2516..1d6efc9 100644 --- a/plugins/ai-proxy/src/protocols/chat_completion.rs +++ b/plugins/ai-proxy/src/protocols/chat_completion.rs @@ -10,11 +10,14 @@ use crate::host; use crate::{AiProxy, TargetConfig}; use barbacane_plugin_sdk::prelude::*; use serde::Serialize; +use serde_json::{json, Value}; + +use super::tools; #[derive(Serialize)] pub(crate) struct AnthropicRequest { pub model: String, - pub messages: Vec, + pub messages: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub system: Option, pub max_tokens: u32, @@ -24,6 +27,12 @@ pub(crate) struct AnthropicRequest { pub top_p: Option, #[serde(skip_serializing_if = "Option::is_none")] pub stream: Option, + /// Tools translated from the client's `tools` array (ADR-0024 tool-use + /// gap). Absent when the client sent none. + #[serde(skip_serializing_if = "Option::is_none")] + pub tools: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_choice: Option, } /// Per-protocol handler invoked by [`crate::dispatch`] after the orchestration @@ -68,26 +77,14 @@ pub(crate) fn translate_to_anthropic( default_max_tokens: Option, ) -> Result { let raw = body.as_deref().unwrap_or(b"{}"); - let openai: serde_json::Value = + let openai: Value = serde_json::from_slice(raw).map_err(|e| format!("invalid request body: {}", e))?; - let messages = openai["messages"] + let msgs = openai["messages"] .as_array() .ok_or("missing or invalid messages array")?; - // Split system messages out; Anthropic takes them as a top-level field - let mut system_parts: Vec<&str> = Vec::new(); - let mut chat_messages: Vec = Vec::new(); - - for msg in messages { - if msg["role"].as_str() == Some("system") { - if let Some(content) = msg["content"].as_str() { - system_parts.push(content); - } - } else { - chat_messages.push(msg.clone()); - } - } + let (messages, system_parts) = translate_chat_messages(msgs); let max_tokens = openai["max_tokens"] .as_u64() @@ -97,7 +94,7 @@ pub(crate) fn translate_to_anthropic( let anthropic = AnthropicRequest { model: client_model.to_string(), - messages: chat_messages, + messages, system: if system_parts.is_empty() { None } else { @@ -107,23 +104,186 @@ pub(crate) fn translate_to_anthropic( temperature: openai["temperature"].as_f64(), top_p: openai["top_p"].as_f64(), stream: if stream { Some(true) } else { None }, + tools: tools::chat_tools_to_anthropic(&openai), + tool_choice: tools::tool_choice_to_anthropic( + openai.get("tool_choice"), + openai.get("parallel_tool_calls").and_then(|v| v.as_bool()), + ), }; serde_json::to_string(&anthropic).map_err(|e| e.to_string()) } +/// Translate the OpenAI `messages` array into Anthropic `messages` + +/// hoisted `system` parts. Beyond splitting system out, this maps the tool-use +/// wire shapes Anthropic needs (the ADR-0024 gap): an assistant `tool_calls` +/// array becomes `tool_use` content blocks, and each `role:"tool"` message +/// becomes a `tool_result` block. Consecutive tool messages are merged into a +/// single user turn, as Anthropic expects all results for one assistant turn +/// grouped together. +fn translate_chat_messages(messages: &[Value]) -> (Vec, Vec) { + let mut out: Vec = Vec::with_capacity(messages.len()); + let mut system_parts: Vec = Vec::new(); + let mut pending_tool_results: Vec = Vec::new(); + + let flush = |pending: &mut Vec, out: &mut Vec| { + if !pending.is_empty() { + out.push(json!({ "role": "user", "content": std::mem::take(pending) })); + } + }; + + for msg in messages { + let role = msg["role"].as_str().unwrap_or("user"); + if role != "tool" { + flush(&mut pending_tool_results, &mut out); + } + + match role { + "system" => collect_text_into(&msg["content"], &mut system_parts), + "tool" => { + // OpenAI `role:"tool"` → Anthropic `tool_result` block. The + // `tool_call_id` maps to Anthropic's `tool_use_id`. + pending_tool_results.push(json!({ + "type": "tool_result", + "tool_use_id": msg.get("tool_call_id").cloned().unwrap_or(Value::Null), + "content": stringify_tool_content(&msg["content"]), + })); + } + "assistant" => { + let mut blocks: Vec = Vec::new(); + if let Some(text) = msg["content"].as_str() { + if !text.is_empty() { + blocks.push(json!({ "type": "text", "text": text })); + } + } else if let Some(parts) = msg["content"].as_array() { + for p in parts { + if let Some(t) = p.get("text").and_then(|v| v.as_str()) { + blocks.push(json!({ "type": "text", "text": t })); + } + } + } + if let Some(calls) = msg["tool_calls"].as_array() { + for c in calls { + let input = c["function"]["arguments"] + .as_str() + .and_then(|s| serde_json::from_str::(s).ok()) + .unwrap_or_else(|| json!({})); + blocks.push(json!({ + "type": "tool_use", + "id": c.get("id").cloned().unwrap_or(Value::Null), + "name": c["function"].get("name").cloned().unwrap_or(Value::Null), + "input": input, + })); + } + } + // Skip an assistant turn with neither text nor tool calls — + // Anthropic rejects an empty content array. + if !blocks.is_empty() { + out.push(json!({ "role": "assistant", "content": blocks })); + } + } + // "user" and any unknown role pass through as a user turn. + _ => out.push(json!({ "role": "user", "content": normalize_user_content(&msg["content"]) })), + } + } + flush(&mut pending_tool_results, &mut out); + (out, system_parts) +} + +/// Append text from an OpenAI `content` field (string or array-of-parts) to +/// `parts`. Used to hoist `system` messages into Anthropic's `system` field. +fn collect_text_into(content: &Value, parts: &mut Vec) { + match content { + Value::String(s) => parts.push(s.clone()), + Value::Array(items) => { + for item in items { + if let Some(t) = item.get("text").and_then(|v| v.as_str()) { + parts.push(t.to_string()); + } + } + } + _ => {} + } +} + +/// Normalize an OpenAI user `content` into an Anthropic-acceptable content +/// value: a plain string passes through; an array of parts maps `text` and +/// `image_url` parts to Anthropic content blocks. +fn normalize_user_content(content: &Value) -> Value { + match content { + Value::String(_) => content.clone(), + Value::Array(parts) => { + let blocks: Vec = parts + .iter() + .filter_map(|p| match p.get("type").and_then(|v| v.as_str()) { + Some("text") | None => p + .get("text") + .and_then(|v| v.as_str()) + .map(|t| json!({ "type": "text", "text": t })), + Some("image_url") => p + .get("image_url") + .and_then(|u| u.get("url")) + .and_then(|v| v.as_str()) + .map(|url| json!({ "type": "image", "source": { "type": "url", "url": url } })), + _ => None, + }) + .collect(); + Value::Array(blocks) + } + Value::Null => Value::String(String::new()), + other => other.clone(), + } +} + +/// Coerce an OpenAI tool-message `content` into the string Anthropic's +/// `tool_result` block expects. Non-string content is JSON-serialized. +fn stringify_tool_content(content: &Value) -> Value { + match content { + Value::String(_) => content.clone(), + Value::Null => Value::String(String::new()), + other => Value::String(other.to_string()), + } +} + /// Translate an Anthropic Messages API response body to OpenAI chat completion format. /// Pinned to Anthropic API version 2024-10-22 (ADR-0024). pub(crate) fn translate_from_anthropic(body: &str) -> Result { - let anthropic: serde_json::Value = + let anthropic: Value = serde_json::from_str(body).map_err(|e| format!("invalid Anthropic response: {}", e))?; - // Extract text content from the first text block - let content_text = anthropic["content"] - .as_array() - .and_then(|arr| arr.iter().find(|c| c["type"].as_str() == Some("text"))) - .and_then(|c| c["text"].as_str()) - .unwrap_or(""); + // Walk every content block: concatenate text blocks and turn each + // `tool_use` block into an OpenAI `tool_calls` entry. The previous + // implementation kept only the first text block and dropped tool calls + // entirely, so a tool-calling turn was returned malformed. + let mut content_text = String::new(); + let mut tool_calls: Vec = Vec::new(); + if let Some(blocks) = anthropic["content"].as_array() { + for block in blocks { + match block["type"].as_str() { + Some("text") => { + if let Some(t) = block["text"].as_str() { + content_text.push_str(t); + } + } + Some("tool_use") => { + // OpenAI carries the arguments as a JSON *string*. + let arguments = block + .get("input") + .map(|v| v.to_string()) + .unwrap_or_else(|| "{}".to_string()); + tool_calls.push(json!({ + "id": block.get("id").cloned().unwrap_or(Value::Null), + "type": "function", + "function": { + "name": block.get("name").cloned().unwrap_or(Value::Null), + "arguments": arguments, + }, + })); + } + _ => {} + } + } + } let input_tokens = anthropic["usage"]["input_tokens"].as_u64().unwrap_or(0); let output_tokens = anthropic["usage"]["output_tokens"].as_u64().unwrap_or(0); @@ -133,19 +293,32 @@ pub(crate) fn translate_from_anthropic(body: &str) -> Result { Some("end_turn") => "stop", Some("max_tokens") => "length", Some("tool_use") => "tool_calls", + _ if !tool_calls.is_empty() => "tool_calls", _ => "stop", }; - let openai = serde_json::json!({ + // OpenAI convention: `content` is null when the turn is only tool calls. + let mut message = serde_json::Map::new(); + message.insert("role".to_string(), json!("assistant")); + message.insert( + "content".to_string(), + if content_text.is_empty() && !tool_calls.is_empty() { + Value::Null + } else { + Value::String(content_text) + }, + ); + if !tool_calls.is_empty() { + message.insert("tool_calls".to_string(), Value::Array(tool_calls)); + } + + let openai = json!({ "id": anthropic["id"], "object": "chat.completion", "model": anthropic["model"], "choices": [{ "index": 0, - "message": { - "role": "assistant", - "content": content_text - }, + "message": Value::Object(message), "finish_reason": finish_reason }], "usage": { @@ -157,3 +330,110 @@ pub(crate) fn translate_from_anthropic(body: &str) -> Result { serde_json::to_string(&openai).map_err(|e| e.to_string()) } + +#[cfg(test)] +mod tests { + use super::*; + + fn to_anthropic(body: &str) -> Value { + let out = translate_to_anthropic(&Some(body.as_bytes().to_vec()), "claude-sonnet-4-6", false, Some(1024)) + .expect("translate_to_anthropic"); + serde_json::from_str(&out).unwrap() + } + + #[test] + fn to_anthropic_forwards_tools_and_tool_choice() { + let body = to_anthropic( + r#"{ + "messages":[{"role":"user","content":"weather?"}], + "tools":[{"type":"function","function":{"name":"get_weather","description":"d","parameters":{"type":"object","properties":{"city":{"type":"string"}}}}}], + "tool_choice":{"type":"function","function":{"name":"get_weather"}}, + "parallel_tool_calls": false + }"#, + ); + assert_eq!(body["tools"][0]["name"], "get_weather"); + assert_eq!(body["tools"][0]["input_schema"]["properties"]["city"]["type"], "string"); + assert_eq!(body["tool_choice"]["type"], "tool"); + assert_eq!(body["tool_choice"]["name"], "get_weather"); + assert_eq!(body["tool_choice"]["disable_parallel_tool_use"], true); + } + + #[test] + fn to_anthropic_translates_assistant_tool_calls_and_tool_result() { + let body = to_anthropic( + r#"{ + "messages":[ + {"role":"user","content":"weather in Paris?"}, + {"role":"assistant","content":null,"tool_calls":[ + {"id":"call_1","type":"function","function":{"name":"get_weather","arguments":"{\"city\":\"Paris\"}"}} + ]}, + {"role":"tool","tool_call_id":"call_1","content":"18C"} + ] + }"#, + ); + let messages = body["messages"].as_array().unwrap(); + // user, assistant(tool_use), user(tool_result) + assert_eq!(messages.len(), 3); + let tool_use = &messages[1]["content"][0]; + assert_eq!(tool_use["type"], "tool_use"); + assert_eq!(tool_use["id"], "call_1"); + assert_eq!(tool_use["name"], "get_weather"); + assert_eq!(tool_use["input"]["city"], "Paris"); + let tool_result = &messages[2]["content"][0]; + assert_eq!(messages[2]["role"], "user"); + assert_eq!(tool_result["type"], "tool_result"); + assert_eq!(tool_result["tool_use_id"], "call_1"); + assert_eq!(tool_result["content"], "18C"); + } + + #[test] + fn to_anthropic_merges_consecutive_tool_results() { + let body = to_anthropic( + r#"{"messages":[ + {"role":"assistant","content":null,"tool_calls":[ + {"id":"a","type":"function","function":{"name":"f","arguments":"{}"}}, + {"id":"b","type":"function","function":{"name":"g","arguments":"{}"}} + ]}, + {"role":"tool","tool_call_id":"a","content":"ra"}, + {"role":"tool","tool_call_id":"b","content":"rb"} + ]}"#, + ); + let messages = body["messages"].as_array().unwrap(); + // assistant, then a single user turn holding both tool_results. + assert_eq!(messages.len(), 2); + assert_eq!(messages[1]["content"].as_array().unwrap().len(), 2); + } + + #[test] + fn from_anthropic_emits_tool_calls_with_stringified_args() { + let out = translate_from_anthropic( + r#"{"id":"msg_1","model":"claude","stop_reason":"tool_use", + "content":[{"type":"tool_use","id":"tu_1","name":"get_weather","input":{"city":"Paris"}}], + "usage":{"input_tokens":5,"output_tokens":3}}"#, + ) + .unwrap(); + let v: Value = serde_json::from_str(&out).unwrap(); + let msg = &v["choices"][0]["message"]; + assert!(msg["content"].is_null(), "content is null on a tool-only turn"); + let call = &msg["tool_calls"][0]; + assert_eq!(call["id"], "tu_1"); + assert_eq!(call["type"], "function"); + assert_eq!(call["function"]["name"], "get_weather"); + // arguments is a JSON *string*, not an object. + assert_eq!(call["function"]["arguments"], "{\"city\":\"Paris\"}"); + assert_eq!(v["choices"][0]["finish_reason"], "tool_calls"); + } + + #[test] + fn from_anthropic_plain_text_unchanged() { + let out = translate_from_anthropic( + r#"{"id":"m","model":"c","stop_reason":"end_turn", + "content":[{"type":"text","text":"hi"}],"usage":{"input_tokens":1,"output_tokens":1}}"#, + ) + .unwrap(); + let v: Value = serde_json::from_str(&out).unwrap(); + assert_eq!(v["choices"][0]["message"]["content"], "hi"); + assert!(v["choices"][0]["message"].get("tool_calls").is_none()); + assert_eq!(v["choices"][0]["finish_reason"], "stop"); + } +} diff --git a/plugins/ai-proxy/src/protocols/mod.rs b/plugins/ai-proxy/src/protocols/mod.rs index e5fdce5..ae784ce 100644 --- a/plugins/ai-proxy/src/protocols/mod.rs +++ b/plugins/ai-proxy/src/protocols/mod.rs @@ -8,3 +8,4 @@ pub mod chat_completion; pub mod models; pub mod responses; +pub mod tools; diff --git a/plugins/ai-proxy/src/protocols/responses.rs b/plugins/ai-proxy/src/protocols/responses.rs index fdc3c16..98a763f 100644 --- a/plugins/ai-proxy/src/protocols/responses.rs +++ b/plugins/ai-proxy/src/protocols/responses.rs @@ -20,6 +20,7 @@ //! path (mirrors ADR-0024 Chat Completions until true SSE translation lands). //! The OpenAI passthrough streams normally via `host_http_stream`. +use crate::protocols::tools; use crate::providers::openai::{openai_base_headers, openai_url}; use crate::{ error_response, host, host_http_stream, http_call, AiProxy, HttpRequest, Provider, Response, @@ -107,8 +108,29 @@ pub(crate) fn handle( .map(|v| v == "true") .unwrap_or(true); - let translation = - ResponsesToAnthropic::translate(&body, client_model, streaming, plugin.max_tokens)?; + // Tool declarations must reach Anthropic or it can never call + // them. Codex freeform tools (`apply_patch`), `local_shell`, and + // hosted tools have no Anthropic representation — reject with a + // precise 400 rather than dropping them silently. + let anthropic_tools = match tools::responses_tools_to_anthropic(&body) { + Ok(t) => t, + Err(unsupported) => { + return Ok(custom_tools_not_supported_response(&unsupported.tool_type)) + } + }; + let anthropic_tool_choice = tools::tool_choice_to_anthropic( + body.get("tool_choice"), + body.get("parallel_tool_calls").and_then(|v| v.as_bool()), + ); + + let translation = ResponsesToAnthropic::translate( + &body, + client_model, + streaming, + plugin.max_tokens, + anthropic_tools, + anthropic_tool_choice, + )?; // Buffered Anthropic call — true SSE translation deferred per // ADR-0030 §2; mirror the Chat Completions buffering behavior. @@ -271,6 +293,8 @@ impl ResponsesToAnthropic { client_model: &str, stream: bool, default_max_tokens: Option, + anthropic_tools: Option, + anthropic_tool_choice: Option, ) -> Result { let input_items = responses .get("input") @@ -410,6 +434,12 @@ impl ResponsesToAnthropic { if let Some(t) = responses.get("top_p").cloned() { anthropic.insert("top_p".to_string(), t); } + if let Some(t) = anthropic_tools { + anthropic.insert("tools".to_string(), t); + } + if let Some(tc) = anthropic_tool_choice { + anthropic.insert("tool_choice".to_string(), tc); + } if stream { anthropic.insert("stream".to_string(), serde_json::Value::Bool(true)); } @@ -694,6 +724,24 @@ fn previous_response_id_not_supported_response() -> Response { .into_response() } +fn custom_tools_not_supported_response(tool_type: &str) -> Response { + ProblemDetails::new( + 400, + "urn:barbacane:error:custom_tools_not_supported_for_provider", + "Bad Request", + ) + .detail(format!( + "ai-proxy: tool type {:?} has no Anthropic Messages representation \ + (Codex freeform `custom` tools such as `apply_patch`, \ + `local_shell`, and hosted server tools). Only `function` tools \ + translate to the Anthropic provider; route these to OpenAI, or \ + drop the tool.", + tool_type + )) + .with("code", "custom_tools_not_supported_for_provider") + .into_response() +} + fn responses_not_supported_for_provider_response(provider: Provider) -> Response { ProblemDetails::new( 400, @@ -793,8 +841,36 @@ mod tests { fn translate_in(json: &str) -> ResponsesToAnthropic { let v: serde_json::Value = serde_json::from_str(json).unwrap(); - ResponsesToAnthropic::translate(&v, "claude-sonnet-4-6", false, Some(1024)) - .expect("translate") + // Mirror the handle() path: map tools/tool_choice before translating. + let anthropic_tools = tools::responses_tools_to_anthropic(&v).expect("tools"); + let anthropic_tool_choice = tools::tool_choice_to_anthropic( + v.get("tool_choice"), + v.get("parallel_tool_calls").and_then(|x| x.as_bool()), + ); + ResponsesToAnthropic::translate( + &v, + "claude-sonnet-4-6", + false, + Some(1024), + anthropic_tools, + anthropic_tool_choice, + ) + .expect("translate") + } + + #[test] + fn translate_in_forwards_function_tools_and_choice() { + let res = translate_in( + r#"{ + "input":[{"type":"input_text","role":"user","content":"hi"}], + "tools":[{"type":"function","name":"get_time","parameters":{"type":"object"}}], + "tool_choice":{"type":"function","name":"get_time"} + }"#, + ); + let body: serde_json::Value = serde_json::from_str(&res.body).unwrap(); + assert_eq!(body["tools"][0]["name"], "get_time"); + assert_eq!(body["tools"][0]["input_schema"], serde_json::json!({"type":"object"})); + assert_eq!(body["tool_choice"], serde_json::json!({"type":"tool","name":"get_time"})); } #[test] diff --git a/plugins/ai-proxy/src/protocols/tools.rs b/plugins/ai-proxy/src/protocols/tools.rs new file mode 100644 index 0000000..eca94e6 --- /dev/null +++ b/plugins/ai-proxy/src/protocols/tools.rs @@ -0,0 +1,246 @@ +//! Shared OpenAI ↔ Anthropic tool-schema translation, used by both the Chat +//! Completions and Responses protocol adapters (ADR-0024 / ADR-0030). +//! +//! `Provider` selects the wire protocol; this module handles the *tool* +//! surface that both OpenAI-shaped protocols expose and that must be mapped +//! onto Anthropic's Messages `tools` / `tool_choice` fields. Without this the +//! Anthropic upstream never learns the client's tools exist, so it can never +//! call them (see the gap ADR-0024/0030 left open). + +use serde_json::{json, Value}; + +/// A tool type the Anthropic Messages API has no representation for — Codex +/// freeform `custom` tools (e.g. `apply_patch`), `local_shell`, or a hosted +/// server tool. Carries the offending `type` so the caller can surface a +/// precise 400 instead of silently dropping the tool. +#[derive(Debug)] +pub(crate) struct UnsupportedTool { + pub tool_type: String, +} + +/// Build one Anthropic tool object from a name / description / JSON-schema +/// `parameters` triple. Anthropic requires `input_schema`; default to an empty +/// object schema when the client omitted `parameters`. +fn anthropic_tool(name: &Value, description: Option, parameters: Option) -> Value { + let mut obj = serde_json::Map::new(); + obj.insert("name".to_string(), name.clone()); + if let Some(d) = description { + obj.insert("description".to_string(), d); + } + obj.insert( + "input_schema".to_string(), + parameters.unwrap_or_else(|| json!({ "type": "object" })), + ); + Value::Object(obj) +} + +/// Translate a Chat Completions `tools` array +/// (`[{type:"function", function:{name, description, parameters}}]`) into an +/// Anthropic `tools` array. Chat Completions only defines function tools; a +/// non-function entry is forward-compatibly skipped rather than rejected. +/// Returns `None` when there are no usable tools. +pub(crate) fn chat_tools_to_anthropic(openai: &Value) -> Option { + let arr = openai.get("tools")?.as_array()?; + let tools: Vec = arr + .iter() + .filter_map(|t| { + let f = t.get("function")?; + let name = f.get("name")?; + Some(anthropic_tool( + name, + f.get("description").cloned(), + f.get("parameters").cloned(), + )) + }) + .collect(); + (!tools.is_empty()).then_some(Value::Array(tools)) +} + +/// Translate a Responses `tools` array (flat +/// `[{type:"function", name, description, parameters}]`) into Anthropic tools. +/// Rejects tool types Anthropic can't represent (`custom` freeform such as +/// Codex `apply_patch`, `local_shell`, hosted server tools) with an +/// [`UnsupportedTool`] so the dispatcher returns a precise 400 rather than +/// dropping the tool and leaving the model unable to call it. +pub(crate) fn responses_tools_to_anthropic( + responses: &Value, +) -> Result, UnsupportedTool> { + let arr = match responses.get("tools").and_then(|v| v.as_array()) { + Some(a) => a, + None => return Ok(None), + }; + let mut out = Vec::with_capacity(arr.len()); + for t in arr { + // Responses defaults an entry with no `type` to a function tool. + let ty = t.get("type").and_then(|v| v.as_str()).unwrap_or("function"); + if ty != "function" { + return Err(UnsupportedTool { + tool_type: ty.to_string(), + }); + } + let Some(name) = t.get("name") else { continue }; + out.push(anthropic_tool( + name, + t.get("description").cloned(), + t.get("parameters").cloned(), + )); + } + Ok((!out.is_empty()).then_some(Value::Array(out))) +} + +/// Map an OpenAI `tool_choice` (the shape is shared across Chat Completions and +/// Responses) plus the `parallel_tool_calls` flag into an Anthropic +/// `tool_choice` object. Returns `None` when the client left the choice +/// implicit and parallel calls aren't disabled — let Anthropic apply its +/// default rather than forcing one. +pub(crate) fn tool_choice_to_anthropic( + tool_choice: Option<&Value>, + parallel_tool_calls: Option, +) -> Option { + let mut base = match tool_choice { + Some(Value::String(s)) => match s.as_str() { + "auto" => Some(json!({ "type": "auto" })), + "none" => Some(json!({ "type": "none" })), + // OpenAI "required" == Anthropic "any" (must call some tool). + "required" | "any" => Some(json!({ "type": "any" })), + _ => None, + }, + // Named-tool form. Chat Completions nests the name under `function`; + // Responses puts it at the top level. + Some(Value::Object(o)) => o + .get("function") + .and_then(|f| f.get("name")) + .or_else(|| o.get("name")) + .cloned() + .map(|name| json!({ "type": "tool", "name": name })), + _ => None, + }; + + if parallel_tool_calls == Some(false) { + let mut choice = base.unwrap_or_else(|| json!({ "type": "auto" })); + if let Some(obj) = choice.as_object_mut() { + obj.insert("disable_parallel_tool_use".to_string(), json!(true)); + } + base = Some(choice); + } + base +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn chat_tools_map_function_shape_to_input_schema() { + let openai = json!({ + "tools": [{ + "type": "function", + "function": { + "name": "get_weather", + "description": "Get weather", + "parameters": { "type": "object", "properties": { "city": { "type": "string" } } } + } + }] + }); + let tools = chat_tools_to_anthropic(&openai).expect("tools"); + let t = &tools.as_array().unwrap()[0]; + assert_eq!(t["name"], "get_weather"); + assert_eq!(t["description"], "Get weather"); + assert_eq!(t["input_schema"]["properties"]["city"]["type"], "string"); + assert!(t.get("function").is_none(), "must be flattened for Anthropic"); + } + + #[test] + fn chat_tools_default_input_schema_when_parameters_missing() { + let openai = json!({ "tools": [{ "type": "function", "function": { "name": "ping" } }] }); + let tools = chat_tools_to_anthropic(&openai).unwrap(); + assert_eq!(tools[0]["input_schema"], json!({ "type": "object" })); + } + + #[test] + fn chat_tools_none_when_absent() { + assert!(chat_tools_to_anthropic(&json!({})).is_none()); + assert!(chat_tools_to_anthropic(&json!({ "tools": [] })).is_none()); + } + + #[test] + fn responses_tools_map_flat_function_shape() { + let responses = json!({ + "tools": [{ "type": "function", "name": "get_time", "parameters": { "type": "object" } }] + }); + let tools = responses_tools_to_anthropic(&responses).unwrap().unwrap(); + assert_eq!(tools[0]["name"], "get_time"); + assert_eq!(tools[0]["input_schema"], json!({ "type": "object" })); + } + + #[test] + fn responses_tools_reject_custom_freeform() { + // Codex apply_patch is a custom/freeform tool — Anthropic can't + // represent it, so we reject explicitly instead of dropping it. + let responses = json!({ "tools": [{ "type": "custom", "name": "apply_patch" }] }); + let err = responses_tools_to_anthropic(&responses).unwrap_err(); + assert_eq!(err.tool_type, "custom"); + } + + #[test] + fn responses_tools_reject_local_shell() { + let responses = json!({ "tools": [{ "type": "local_shell" }] }); + assert_eq!( + responses_tools_to_anthropic(&responses).unwrap_err().tool_type, + "local_shell" + ); + } + + #[test] + fn tool_choice_string_forms() { + assert_eq!( + tool_choice_to_anthropic(Some(&json!("auto")), None), + Some(json!({ "type": "auto" })) + ); + assert_eq!( + tool_choice_to_anthropic(Some(&json!("required")), None), + Some(json!({ "type": "any" })) + ); + assert_eq!( + tool_choice_to_anthropic(Some(&json!("none")), None), + Some(json!({ "type": "none" })) + ); + } + + #[test] + fn tool_choice_named_forms_both_protocols() { + // Chat Completions nesting + assert_eq!( + tool_choice_to_anthropic( + Some(&json!({ "type": "function", "function": { "name": "f" } })), + None + ), + Some(json!({ "type": "tool", "name": "f" })) + ); + // Responses flat naming + assert_eq!( + tool_choice_to_anthropic(Some(&json!({ "type": "function", "name": "f" })), None), + Some(json!({ "type": "tool", "name": "f" })) + ); + } + + #[test] + fn tool_choice_parallel_disabled_adds_flag() { + // No explicit choice, parallel disabled → default auto + flag. + assert_eq!( + tool_choice_to_anthropic(None, Some(false)), + Some(json!({ "type": "auto", "disable_parallel_tool_use": true })) + ); + // Explicit choice keeps its type and gains the flag. + assert_eq!( + tool_choice_to_anthropic(Some(&json!("required")), Some(false)), + Some(json!({ "type": "any", "disable_parallel_tool_use": true })) + ); + } + + #[test] + fn tool_choice_none_when_implicit() { + assert!(tool_choice_to_anthropic(None, None).is_none()); + assert!(tool_choice_to_anthropic(None, Some(true)).is_none()); + } +}