Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
25 changes: 25 additions & 0 deletions src/providers/anthropic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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: <json string> }}`.
fn extract_anthropic_tool_calls(parsed: &Value) -> Option<Value> {
let blocks = parsed.get("content")?.as_array()?;
let mut calls: Vec<Value> = 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,
Expand Down
30 changes: 30 additions & 0 deletions src/providers/gemini.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,16 +89,46 @@ 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,
raw: if debug { Some(resp) } else { None },
})
}

/// 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_<idx>` to keep IDs stable within a response.
fn extract_gemini_tool_calls(resp: &serde_json::Value) -> Option<serde_json::Value> {
let parts = resp
.get("candidates")?
.as_array()?
.first()?
.get("content")?
.get("parts")?
.as_array()?;
let mut calls: Vec<serde_json::Value> = 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 {
Expand Down
8 changes: 8 additions & 0 deletions src/providers/openai_compat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ struct OpenAIMessage {
reasoning_content: Option<String>,
reasoning: Option<String>,
reasoning_details: Option<Value>,
/// Raw OpenAI-format tool_calls array, surfaced unchanged on `ChatResponse`.
/// Optional because non-tool-using replies omit it entirely.
#[serde(default)]
tool_calls: Option<Value>,
}

#[derive(Debug, Deserialize)]
Expand Down Expand Up @@ -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())
Expand All @@ -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,
Expand Down
8 changes: 7 additions & 1 deletion src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Reasoning>,
/// 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<Value>,
pub usage: Usage,
pub response_id: Option<String>,
pub header_cost: Option<f64>,
Expand Down
137 changes: 137 additions & 0 deletions tests/integration_providers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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;
Expand Down