From e0a92509bf99ba73d685ce5e97738061092cbc71 Mon Sep 17 00:00:00 2001 From: Olivier Cervello Date: Fri, 12 Jun 2026 23:08:10 +0200 Subject: [PATCH] feat: surface tool_calls on ChatResponse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a `tool_calls: Option` field to `ChatResponse` carrying model-requested function calls in OpenAI shape — an array of `{ id, type: "function", function: { name, arguments } }` where `arguments` is the raw JSON string the model produced. - OpenAI-compatible providers pass `tool_calls` through unchanged from the assistant message. Empty / missing arrays normalize to `None` so callers can use `if let Some(_)` reliably. - Anthropic `tool_use` content blocks are mapped to the OpenAI shape (the block `id`/`name`/`input` become the call id/function name/arguments). - Gemini `functionCall` parts on the first candidate are mapped the same way, synthesizing `call_` ids since Gemini doesn't supply them. Tests cover all three providers plus the empty-array and missing-field cases. --- CHANGELOG.md | 4 + README.md | 1 + src/providers/anthropic.rs | 25 ++++++ src/providers/gemini.rs | 30 ++++++++ src/providers/openai_compat.rs | 8 ++ src/types.rs | 8 +- tests/integration_providers.rs | 137 +++++++++++++++++++++++++++++++++ 7 files changed, 212 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9937f5b..bd2cc40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Surface model-requested tool calls on `ChatResponse.tool_calls` in OpenAI shape — an array of `{ id, type: "function", function: { name, arguments } }` where `arguments` is the raw JSON string the model produced. OpenAI-compatible providers pass the field through unchanged; Anthropic `tool_use` content blocks and Gemini `functionCall` parts are normalized to the same shape so consumers can use a single dispatch path. Empty / missing arrays become `None`. + ## [0.2.0] - 2026-05-15 ### Breaking Changes diff --git a/README.md b/README.md index 7bbd406..edaea79 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ Minimal Rust SDK port of [LiteLLM](https://github.com/BerriAI/litellm) (library - **Unified client** for OpenAI-compatible, Anthropic, Gemini, and xAI providers - **Chat completions** with streaming (SSE) support +- **Tool calling** — model-requested function calls surfaced on `ChatResponse.tool_calls` in OpenAI shape (Anthropic `tool_use` and Gemini `functionCall` normalized through the same shape) - **Text embeddings** - **Image generation** (OpenAI DALL-E / GPT Image) - **Video generation** (Gemini Veo, OpenAI Sora) diff --git a/src/providers/anthropic.rs b/src/providers/anthropic.rs index 08842c4..52f4849 100644 --- a/src/providers/anthropic.rs +++ b/src/providers/anthropic.rs @@ -46,9 +46,11 @@ pub async fn chat(client: &Client, cfg: &ProviderConfig, req: ChatRequest) -> Re let (content, reasoning) = extract_text_and_reasoning(&parsed); let usage = parse_usage(&parsed); + let tool_calls = extract_anthropic_tool_calls(&parsed); Ok(ChatResponse { content, reasoning, + tool_calls, usage, response_id: parsed .get("id") @@ -59,6 +61,29 @@ pub async fn chat(client: &Client, cfg: &ProviderConfig, req: ChatRequest) -> Re }) } +/// Map Anthropic content blocks of type `tool_use` to the OpenAI tool_calls shape. +/// Anthropic returns `{type: "tool_use", id, name, input}` per call; OpenAI uses +/// `{id, type: "function", function: { name, arguments: }}`. +fn extract_anthropic_tool_calls(parsed: &Value) -> Option { + let blocks = parsed.get("content")?.as_array()?; + let mut calls: Vec = Vec::new(); + for b in blocks { + if b.get("type").and_then(|v| v.as_str()) != Some("tool_use") { + continue; + } + let id = b.get("id").cloned().unwrap_or(Value::Null); + let name = b.get("name").cloned().unwrap_or(Value::Null); + let input = b.get("input").cloned().unwrap_or(Value::Object(Default::default())); + let arguments = serde_json::to_string(&input).unwrap_or_else(|_| "{}".into()); + calls.push(serde_json::json!({ + "id": id, + "type": "function", + "function": { "name": name, "arguments": arguments }, + })); + } + if calls.is_empty() { None } else { Some(Value::Array(calls)) } +} + pub async fn chat_stream( client: &Client, cfg: &ProviderConfig, diff --git a/src/providers/gemini.rs b/src/providers/gemini.rs index 3b9c8b2..54195ed 100644 --- a/src/providers/gemini.rs +++ b/src/providers/gemini.rs @@ -89,9 +89,11 @@ pub async fn chat(client: &Client, cfg: &ProviderConfig, req: ChatRequest) -> Re } } + let tool_calls = extract_gemini_tool_calls(&resp); Ok(ChatResponse { content, reasoning: None, + tool_calls, usage, response_id: None, header_cost: None, @@ -99,6 +101,34 @@ pub async fn chat(client: &Client, cfg: &ProviderConfig, req: ChatRequest) -> Re }) } +/// Map Gemini `functionCall` parts to the OpenAI tool_calls shape. Gemini emits +/// `candidates[0].content.parts[].functionCall = { name, args }`; the OpenAI +/// shape is `{ id, type: "function", function: { name, arguments } }` where +/// `arguments` is a JSON string. Gemini doesn't give per-call ids, so we +/// synthesize `call_` to keep IDs stable within a response. +fn extract_gemini_tool_calls(resp: &serde_json::Value) -> Option { + let parts = resp + .get("candidates")? + .as_array()? + .first()? + .get("content")? + .get("parts")? + .as_array()?; + let mut calls: Vec = Vec::new(); + for (idx, part) in parts.iter().enumerate() { + let Some(fc) = part.get("functionCall") else { continue }; + let name = fc.get("name").cloned().unwrap_or(serde_json::Value::Null); + let args = fc.get("args").cloned().unwrap_or_else(|| serde_json::json!({})); + let arguments = serde_json::to_string(&args).unwrap_or_else(|_| "{}".into()); + calls.push(serde_json::json!({ + "id": format!("call_{idx}"), + "type": "function", + "function": { "name": name, "arguments": arguments }, + })); + } + if calls.is_empty() { None } else { Some(serde_json::Value::Array(calls)) } +} + /// Video generation options for configurable timeouts. #[derive(Debug, Clone)] pub struct VideoGenerationOptions { diff --git a/src/providers/openai_compat.rs b/src/providers/openai_compat.rs index 1c4df34..f67c890 100644 --- a/src/providers/openai_compat.rs +++ b/src/providers/openai_compat.rs @@ -38,6 +38,10 @@ struct OpenAIMessage { reasoning_content: Option, reasoning: Option, reasoning_details: Option, + /// Raw OpenAI-format tool_calls array, surfaced unchanged on `ChatResponse`. + /// Optional because non-tool-using replies omit it entirely. + #[serde(default)] + tool_calls: Option, } #[derive(Debug, Deserialize)] @@ -180,6 +184,9 @@ pub async fn chat(client: &Client, cfg: &ProviderConfig, req: ChatRequest) -> Re .and_then(|message| extract_text_value(message.content.as_ref())) .unwrap_or_default(); let reasoning = first_message.and_then(extract_reasoning); + let tool_calls = first_message + .and_then(|m| m.tool_calls.clone()) + .filter(|v| !matches!(v, Value::Null) && !matches!(v, Value::Array(a) if a.is_empty())); let header_cost = headers .get("x-litellm-response-cost") .and_then(|v| v.to_str().ok()) @@ -192,6 +199,7 @@ pub async fn chat(client: &Client, cfg: &ProviderConfig, req: ChatRequest) -> Re Ok(ChatResponse { content, reasoning, + tool_calls, usage, response_id: parsed.id, header_cost, diff --git a/src/types.rs b/src/types.rs index 6202c82..c885e1b 100644 --- a/src/types.rs +++ b/src/types.rs @@ -201,11 +201,17 @@ impl ChatRequest { } } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct ChatResponse { pub content: String, /// Provider reasoning, kept separate from answer content. pub reasoning: Option, + /// Tool calls requested by the model, normalized to OpenAI-compatible shape: + /// an array of `{ id, type: "function", function: { name, arguments } }` objects. + /// `arguments` is the raw JSON string the model produced — call sites usually + /// `serde_json::from_str` it before use. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tool_calls: Option, pub usage: Usage, pub response_id: Option, pub header_cost: Option, diff --git a/tests/integration_providers.rs b/tests/integration_providers.rs index fef834f..50cbcfb 100644 --- a/tests/integration_providers.rs +++ b/tests/integration_providers.rs @@ -89,6 +89,101 @@ mod openai_compat_tests { assert_eq!(resp.response_id, Some("chatcmpl-123".to_string())); } + /// Verifies that `tool_calls` on the assistant message is surfaced verbatim + /// on `ChatResponse` so agent loops can dispatch them. + #[tokio::test] + async fn chat_completion_surfaces_tool_calls() { + let mock_server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/chat/completions")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id": "chatcmpl-tool-1", + "choices": [{ + "message": { + "role": "assistant", + "content": null, + "tool_calls": [{ + "id": "call_abc123", + "type": "function", + "function": { + "name": "run_task", + "arguments": "{\"name\":\"nmap\",\"targets\":[\"example.com\"]}" + } + }] + } + }], + "usage": { "prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2 } + }))) + .mount(&mock_server) + .await; + let cfg = ProviderConfig { + base_url: Some(mock_server.uri()), + api_key: Some("test-key".to_string()), + ..Default::default() + }; + let resp = openai_compat::chat(&make_client(), &cfg, simple_chat_request("gpt-4")) + .await + .unwrap(); + let calls = resp.tool_calls.expect("tool_calls populated"); + let arr = calls.as_array().expect("array"); + assert_eq!(arr.len(), 1); + assert_eq!(arr[0]["id"], "call_abc123"); + assert_eq!(arr[0]["function"]["name"], "run_task"); + // arguments is a JSON string (OpenAI convention) — parse it. + let args: serde_json::Value = + serde_json::from_str(arr[0]["function"]["arguments"].as_str().unwrap()).unwrap(); + assert_eq!(args["name"], "nmap"); + assert_eq!(args["targets"][0], "example.com"); + } + + /// Empty / missing tool_calls stays as None so callers can `if let Some(_)`. + #[tokio::test] + async fn chat_completion_omits_tool_calls_when_absent() { + let mock_server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/chat/completions")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id": "chatcmpl-1", + "choices": [{ "message": { "role": "assistant", "content": "hi" } }], + "usage": { "prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2 } + }))) + .mount(&mock_server) + .await; + let cfg = ProviderConfig { + base_url: Some(mock_server.uri()), + api_key: Some("test-key".to_string()), + ..Default::default() + }; + let resp = openai_compat::chat(&make_client(), &cfg, simple_chat_request("gpt-4")) + .await + .unwrap(); + assert!(resp.tool_calls.is_none()); + } + + /// An empty `tool_calls: []` array is also treated as None. + #[tokio::test] + async fn chat_completion_treats_empty_tool_calls_as_none() { + let mock_server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/chat/completions")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id": "chatcmpl-2", + "choices": [{ "message": { "role": "assistant", "content": "hi", "tool_calls": [] } }], + "usage": { "prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2 } + }))) + .mount(&mock_server) + .await; + let cfg = ProviderConfig { + base_url: Some(mock_server.uri()), + api_key: Some("test-key".to_string()), + ..Default::default() + }; + let resp = openai_compat::chat(&make_client(), &cfg, simple_chat_request("gpt-4")) + .await + .unwrap(); + assert!(resp.tool_calls.is_none(), "empty array should be treated as None"); + } + /// Verifies reasoning_tokens extraction from completion_tokens_details. #[tokio::test] async fn chat_completion_extracts_reasoning_tokens() { @@ -777,6 +872,48 @@ mod anthropic_tests { assert_eq!(resp.response_id, Some("msg_123".to_string())); } + /// Anthropic `tool_use` content blocks map to OpenAI-shape tool_calls so + /// consumers can use a single dispatch path across providers. + #[tokio::test] + async fn chat_completion_maps_anthropic_tool_use_to_openai_tool_calls() { + let mock_server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/messages")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id": "msg_tool_1", + "content": [ + { "type": "text", "text": "I'll scan that for you." }, + { + "type": "tool_use", + "id": "toolu_abc", + "name": "run_task", + "input": { "name": "nmap", "targets": ["example.com"] } + } + ], + "usage": { "input_tokens": 5, "output_tokens": 5 } + }))) + .mount(&mock_server) + .await; + let cfg = ProviderConfig { + base_url: Some(mock_server.uri()), + api_key: Some("test-key".to_string()), + ..Default::default() + }; + let resp = anthropic::chat(&make_client(), &cfg, simple_chat_request("claude")) + .await + .unwrap(); + let calls = resp.tool_calls.expect("tool_calls"); + let arr = calls.as_array().unwrap(); + assert_eq!(arr.len(), 1); + assert_eq!(arr[0]["id"], "toolu_abc"); + assert_eq!(arr[0]["type"], "function"); + assert_eq!(arr[0]["function"]["name"], "run_task"); + let args: serde_json::Value = + serde_json::from_str(arr[0]["function"]["arguments"].as_str().unwrap()).unwrap(); + assert_eq!(args["name"], "nmap"); + assert_eq!(args["targets"][0], "example.com"); + } + #[tokio::test] async fn chat_completion_extracts_anthropic_thinking_blocks_as_reasoning() { let mock_server = MockServer::start().await;