llm-universal 0.2.0

One async client for Anthropic, OpenAI, Gemini, Cohere, Azure OpenAI and OpenAI-compatible APIs (Groq, Mistral, DeepSeek, xAI, OpenRouter, Ollama), with tool calling and SDK-style errors
Documentation
use std::collections::HashMap;

use async_trait::async_trait;
use serde_json::{json, Value};

use crate::{
    send_json, ChatRequest, ChatResponse, LlmError, LlmProvider, Message, Part, Result, Role,
    StopReason, Usage,
};

/// Prefix of tool-call ids this crate generates when Gemini returns a call without an id.
/// Such ids are never sent back to the API.
const LOCAL_ID_PREFIX: &str = "llm-universal-call-";

/// Client for the Google Gemini API
/// (`POST {base_url}/models/{model}:generateContent`, key in `x-goog-api-key`).
#[derive(Clone)]
pub struct GeminiProvider {
    client: reqwest::Client,
    api_key: String,
    base_url: String,
}

impl std::fmt::Debug for GeminiProvider {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("GeminiProvider")
            .field("api_key", &"<redacted>")
            .field("base_url", &self.base_url)
            .finish()
    }
}

impl GeminiProvider {
    pub fn new(api_key: impl Into<String>) -> Self {
        Self {
            client: crate::http_client(),
            api_key: api_key.into(),
            base_url: "https://generativelanguage.googleapis.com/v1beta".to_string(),
        }
    }

    pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
        self.base_url = base_url.into().trim_end_matches('/').to_string();
        self
    }

    /// Uses your own `reqwest::Client`, for example to share a connection pool or set a proxy.
    pub fn with_client(mut self, client: reqwest::Client) -> Self {
        self.client = client;
        self
    }

    fn build_body(req: &ChatRequest) -> Value {
        // Gemini needs the function name in `functionResponse`; recover it from the call.
        let names: HashMap<&str, &str> = req
            .messages
            .iter()
            .flat_map(|m| &m.parts)
            .filter_map(|p| match p {
                Part::ToolCall { id, name, .. } => Some((id.as_str(), name.as_str())),
                _ => None,
            })
            .collect();

        let mut system_parts: Vec<Value> =
            req.system.iter().map(|s| json!({ "text": s })).collect();
        let mut contents = Vec::new();
        for message in &req.messages {
            if message.role == Role::System {
                system_parts.push(json!({ "text": message.text_content() }));
                continue;
            }
            let role = if message.role == Role::Assistant {
                "model"
            } else {
                "user"
            };
            let parts: Vec<Value> = message
                .parts
                .iter()
                .filter_map(|p| to_gemini_part(p, &names))
                .collect();
            contents.push(json!({ "role": role, "parts": parts }));
        }

        let mut body = json!({ "contents": contents });
        if !system_parts.is_empty() {
            body["systemInstruction"] = json!({ "parts": system_parts });
        }
        if !req.tools.is_empty() {
            let declarations: Vec<Value> = req
                .tools
                .iter()
                .map(|t| {
                    json!({
                        "name": t.name,
                        "description": t.description,
                        "parameters": t.parameters,
                    })
                })
                .collect();
            body["tools"] = json!([{ "functionDeclarations": declarations }]);
        }
        let mut config = serde_json::Map::new();
        if let Some(max_tokens) = req.max_tokens {
            config.insert("maxOutputTokens".into(), json!(max_tokens));
        }
        if let Some(temperature) = req.temperature {
            config.insert("temperature".into(), json!(temperature));
        }
        if !config.is_empty() {
            body["generationConfig"] = Value::Object(config);
        }
        body
    }
}

fn to_gemini_part(part: &Part, names: &HashMap<&str, &str>) -> Option<Value> {
    let sendable_id =
        |id: &str| (!id.is_empty() && !id.starts_with(LOCAL_ID_PREFIX)).then(|| id.to_string());
    match part {
        Part::Text { text } if text.is_empty() => None,
        Part::Text { text } => Some(json!({ "text": text })),
        Part::ToolCall {
            id,
            name,
            arguments,
        } => {
            let mut call = json!({ "name": name, "args": arguments });
            if let Some(id) = sendable_id(id) {
                call["id"] = json!(id);
            }
            Some(json!({ "functionCall": call }))
        }
        Part::ToolResult {
            call_id,
            content,
            is_error,
        } => {
            // `response` must be an object: use the content if it is a JSON object,
            // otherwise wrap it the way Google's examples do.
            let response = match serde_json::from_str::<Value>(content) {
                Ok(v @ Value::Object(_)) if !is_error => v,
                _ if *is_error => json!({ "error": content }),
                _ => json!({ "result": content }),
            };
            let mut result = json!({
                "name": names.get(call_id.as_str()).copied().unwrap_or_default(),
                "response": response,
            });
            if let Some(id) = sendable_id(call_id) {
                result["id"] = json!(id);
            }
            Some(json!({ "functionResponse": result }))
        }
    }
}

fn parse_response(v: &Value, requested_model: &str) -> Result<ChatResponse> {
    let tokens = |field: &str| {
        u32::try_from(v["usageMetadata"][field].as_u64().unwrap_or(0)).unwrap_or(u32::MAX)
    };
    let usage = Usage {
        input_tokens: tokens("promptTokenCount"),
        output_tokens: tokens("candidatesTokenCount"),
    };
    let model = v["modelVersion"]
        .as_str()
        .unwrap_or(requested_model)
        .to_string();

    let Some(candidate) = v["candidates"].get(0) else {
        // The prompt itself was blocked: no candidates, only `promptFeedback.blockReason`.
        if let Some(reason) = v["promptFeedback"]["blockReason"].as_str() {
            let text = format!("prompt blocked: {reason}");
            return Ok(ChatResponse {
                model,
                message: Message::new(Role::Assistant, vec![Part::Text { text }]),
                stop_reason: StopReason::Refusal,
                usage,
            });
        }
        return Err(LlmError::InvalidResponse(
            "missing field `candidates[0]`".into(),
        ));
    };

    let mut parts = Vec::new();
    let mut calls = 0usize;
    for part in candidate["content"]["parts"]
        .as_array()
        .into_iter()
        .flatten()
    {
        // Thought summaries (`thought: true`) are not part of the answer.
        if part["thought"].as_bool() == Some(true) {
            continue;
        }
        if let Some(text) = part["text"].as_str() {
            if !text.is_empty() {
                parts.push(Part::Text {
                    text: text.to_string(),
                });
            }
        } else if part["functionCall"].is_object() {
            let call = &part["functionCall"];
            let id = match call["id"].as_str() {
                Some(id) if !id.is_empty() => id.to_string(),
                _ => format!("{LOCAL_ID_PREFIX}{calls}"),
            };
            calls += 1;
            parts.push(Part::ToolCall {
                id,
                name: call["name"].as_str().unwrap_or_default().to_string(),
                arguments: if call["args"].is_null() {
                    json!({})
                } else {
                    call["args"].clone()
                },
            });
        }
    }

    let stop_reason = match candidate["finishReason"].as_str() {
        // Gemini reports STOP for function calls as well.
        Some("STOP") if calls > 0 => StopReason::ToolUse,
        Some("STOP") => StopReason::EndTurn,
        Some("MAX_TOKENS") => StopReason::MaxTokens,
        Some(
            "SAFETY"
            | "RECITATION"
            | "BLOCKLIST"
            | "PROHIBITED_CONTENT"
            | "SPII"
            | "IMAGE_SAFETY"
            | "IMAGE_PROHIBITED_CONTENT"
            | "IMAGE_RECITATION",
        ) => StopReason::Refusal,
        None if calls > 0 => StopReason::ToolUse,
        other => StopReason::Other(other.unwrap_or("unknown").to_string()),
    };

    Ok(ChatResponse {
        model,
        message: Message::new(Role::Assistant, parts),
        stop_reason,
        usage,
    })
}

/// The model goes into the URL path, so only plain model-name characters are allowed.
fn checked_model(model: &str) -> Result<&str> {
    let model = model.strip_prefix("models/").unwrap_or(model);
    if model
        .chars()
        .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
    {
        Ok(model)
    } else {
        Err(LlmError::InvalidArgument(format!(
            "GeminiProvider: model `{model}` may only contain a-z, A-Z, 0-9, '-', '_' and '.'"
        )))
    }
}

#[async_trait]
impl LlmProvider for GeminiProvider {
    async fn chat(&self, req: &ChatRequest) -> Result<ChatResponse> {
        crate::validate(
            "GeminiProvider",
            &self.api_key,
            true,
            &self.base_url,
            64,
            req,
        )?;
        let model = checked_model(&req.model)?;
        let url = format!("{}/models/{model}:generateContent", self.base_url);
        let builder = self
            .client
            .post(url)
            .header(
                "x-goog-api-key",
                crate::secret_header("GeminiProvider", &self.api_key)?,
            )
            .json(&Self::build_body(req));
        let body = send_json(builder).await?;
        parse_response(&body, model)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::Tool;

    #[test]
    fn builds_request_with_tools_and_results() {
        let req = ChatRequest::new("gemini-2.5-flash")
            .system("sys")
            .max_tokens(100)
            .tool(Tool::new("get_weather", "d", json!({"type": "object"})))
            .message(Message::user("hi"))
            .message(Message::new(
                Role::Assistant,
                vec![Part::ToolCall {
                    id: format!("{LOCAL_ID_PREFIX}0"),
                    name: "get_weather".into(),
                    arguments: json!({"city": "Berlin"}),
                }],
            ))
            .message(Message::tool_result(
                format!("{LOCAL_ID_PREFIX}0"),
                "18C",
                false,
            ));
        let body = GeminiProvider::build_body(&req);
        assert_eq!(body["systemInstruction"]["parts"][0]["text"], "sys");
        assert_eq!(body["generationConfig"]["maxOutputTokens"], 100);
        assert_eq!(
            body["tools"][0]["functionDeclarations"][0]["name"],
            "get_weather"
        );
        assert_eq!(body["contents"][1]["role"], "model");
        let call = &body["contents"][1]["parts"][0]["functionCall"];
        assert_eq!(call["args"]["city"], "Berlin");
        assert!(call.get("id").is_none(), "local ids are not sent");
        let result = &body["contents"][2]["parts"][0]["functionResponse"];
        assert_eq!(result["name"], "get_weather");
        assert_eq!(result["response"]["result"], "18C");
    }

    #[test]
    fn parses_function_call_as_tool_use() {
        let v = json!({
            "candidates": [{
                "content": { "role": "model", "parts": [
                    { "text": "thinking...", "thought": true },
                    { "functionCall": { "name": "get_weather", "args": { "city": "Berlin" } } }
                ]},
                "finishReason": "STOP"
            }],
            "usageMetadata": { "promptTokenCount": 4, "candidatesTokenCount": 6 },
            "modelVersion": "gemini-2.5-flash"
        });
        let resp = parse_response(&v, "m").unwrap();
        assert_eq!(resp.stop_reason, StopReason::ToolUse);
        assert_eq!(resp.text(), "");
        let calls = resp.tool_calls();
        assert_eq!(calls[0].1, "get_weather");
        assert!(calls[0].0.starts_with(LOCAL_ID_PREFIX));
        assert_eq!(resp.usage.output_tokens, 6);
        assert_eq!(resp.model, "gemini-2.5-flash");
    }

    #[test]
    fn blocked_prompt_is_a_refusal() {
        let v = json!({ "promptFeedback": { "blockReason": "SAFETY" } });
        let resp = parse_response(&v, "m").unwrap();
        assert_eq!(resp.stop_reason, StopReason::Refusal);
        assert_eq!(resp.text(), "prompt blocked: SAFETY");
    }

    #[test]
    fn rejects_model_that_would_change_the_url() {
        for model in ["../files", "gemini?key=x", "a/b", "gemini pro"] {
            assert!(checked_model(model).is_err(), "{model}");
        }
        assert_eq!(
            checked_model("models/gemini-2.5-flash").unwrap(),
            "gemini-2.5-flash"
        );
    }
}