Skip to main content

llm_universal/
openai.rs

1use std::collections::BTreeMap;
2
3use async_trait::async_trait;
4use serde_json::{json, Value};
5
6use crate::http::{secret_header, Http};
7use crate::stream::{self, parse_data, Accumulator, ChatStream, StreamEvent};
8use crate::{
9    ChatRequest, ChatResponse, EmbeddingProvider, Embeddings, ImageSource, LlmError, LlmProvider,
10    Message, ModelInfo, Part, Result, Role, StopReason, Usage,
11};
12
13/// Client for the OpenAI Chat Completions API (`POST {base_url}/chat/completions`).
14///
15/// The same wire format is served by many other providers; use the presets
16/// ([`azure`](Self::azure), [`bedrock`](Self::bedrock), [`groq`](Self::groq), [`mistral`](Self::mistral),
17/// [`deepseek`](Self::deepseek), [`xai`](Self::xai), [`openrouter`](Self::openrouter),
18/// [`together`](Self::together), [`fireworks`](Self::fireworks), [`perplexity`](Self::perplexity),
19/// [`ollama`](Self::ollama), [`lm_studio`](Self::lm_studio)) or [`with_base_url`](Self::with_base_url)
20/// for anything else (vLLM, LiteLLM, …).
21#[derive(Clone)]
22pub struct OpenAiProvider {
23    http: Http,
24    api_key: String,
25    base_url: String,
26    auth: Auth,
27    /// Set by a preset whose resource or region is invalid; returned by every call.
28    config_error: Option<String>,
29}
30
31/// Default OpenAI endpoint.
32const OPENAI_BASE_URL: &str = "https://api.openai.com/v1";
33
34/// How the key is sent.
35#[derive(Clone, Copy, Debug)]
36enum Auth {
37    /// `Authorization: Bearer <key>` (OpenAI and most compatible servers).
38    Bearer,
39    /// `api-key: <key>` (Azure OpenAI).
40    ApiKeyHeader,
41}
42
43impl std::fmt::Debug for OpenAiProvider {
44    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45        f.debug_struct("OpenAiProvider")
46            .field("api_key", &"<redacted>")
47            .field("base_url", &self.base_url)
48            .field("max_retries", &self.http.max_retries)
49            .finish()
50    }
51}
52
53impl OpenAiProvider {
54    pub fn new(api_key: impl Into<String>) -> Self {
55        Self {
56            http: Http::default(),
57            api_key: api_key.into(),
58            base_url: OPENAI_BASE_URL.to_string(),
59            auth: Auth::Bearer,
60            config_error: None,
61        }
62    }
63
64    /// A resource or region becomes part of the host name, so it must be a plain DNS label;
65    /// otherwise `"evil.com/"` would send the key to another host.
66    fn with_host_label(mut self, what: &str, value: &str) -> Self {
67        let valid = !value.is_empty()
68            && value.len() <= 63
69            && value.chars().all(|c| c.is_ascii_alphanumeric() || c == '-');
70        if !valid {
71            self.config_error = Some(format!(
72                "OpenAiProvider: {what} `{value}` may only contain a-z, A-Z, 0-9 and '-'"
73            ));
74        }
75        self
76    }
77
78    fn preset(api_key: impl Into<String>, base_url: &str) -> Self {
79        Self::new(api_key).with_base_url(base_url)
80    }
81
82    /// Azure OpenAI v1 API: `https://{resource}.openai.azure.com/openai/v1`, key in the `api-key` header.
83    /// Pass the deployment name as the model.
84    pub fn azure(resource: &str, api_key: impl Into<String>) -> Self {
85        let mut provider = Self::preset(
86            api_key,
87            &format!("https://{resource}.openai.azure.com/openai/v1"),
88        );
89        provider.auth = Auth::ApiKeyHeader;
90        provider.with_host_label("resource", resource)
91    }
92
93    /// Amazon Bedrock, OpenAI-compatible endpoint with a Bedrock API key:
94    /// `https://bedrock-runtime.{region}.amazonaws.com/openai/v1`. Use Bedrock model IDs.
95    pub fn bedrock(region: &str, api_key: impl Into<String>) -> Self {
96        Self::preset(
97            api_key,
98            &format!("https://bedrock-runtime.{region}.amazonaws.com/openai/v1"),
99        )
100        .with_host_label("region", region)
101    }
102
103    /// Groq: `https://api.groq.com/openai/v1`.
104    pub fn groq(api_key: impl Into<String>) -> Self {
105        Self::preset(api_key, "https://api.groq.com/openai/v1")
106    }
107
108    /// Mistral AI: `https://api.mistral.ai/v1`.
109    pub fn mistral(api_key: impl Into<String>) -> Self {
110        Self::preset(api_key, "https://api.mistral.ai/v1")
111    }
112
113    /// DeepSeek: `https://api.deepseek.com/v1`.
114    pub fn deepseek(api_key: impl Into<String>) -> Self {
115        Self::preset(api_key, "https://api.deepseek.com/v1")
116    }
117
118    /// xAI (Grok): `https://api.x.ai/v1`.
119    pub fn xai(api_key: impl Into<String>) -> Self {
120        Self::preset(api_key, "https://api.x.ai/v1")
121    }
122
123    /// OpenRouter: `https://openrouter.ai/api/v1`.
124    pub fn openrouter(api_key: impl Into<String>) -> Self {
125        Self::preset(api_key, "https://openrouter.ai/api/v1")
126    }
127
128    /// Together AI: `https://api.together.xyz/v1`.
129    pub fn together(api_key: impl Into<String>) -> Self {
130        Self::preset(api_key, "https://api.together.xyz/v1")
131    }
132
133    /// Fireworks AI: `https://api.fireworks.ai/inference/v1`.
134    pub fn fireworks(api_key: impl Into<String>) -> Self {
135        Self::preset(api_key, "https://api.fireworks.ai/inference/v1")
136    }
137
138    /// Perplexity: `https://api.perplexity.ai`.
139    pub fn perplexity(api_key: impl Into<String>) -> Self {
140        Self::preset(api_key, "https://api.perplexity.ai")
141    }
142
143    /// Ollama on this machine: `http://localhost:11434/v1`, no key.
144    pub fn ollama() -> Self {
145        Self::preset("", "http://localhost:11434/v1")
146    }
147
148    /// LM Studio on this machine: `http://localhost:1234/v1`, no key.
149    pub fn lm_studio() -> Self {
150        Self::preset("", "http://localhost:1234/v1")
151    }
152
153    /// Points the client at another server. The key may be empty if the server does not need one.
154    pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
155        self.base_url = base_url.into().trim_end_matches('/').to_string();
156        self
157    }
158
159    /// Uses your own `reqwest::Client`, for example to share a connection pool or set a proxy.
160    pub fn with_client(mut self, client: reqwest::Client) -> Self {
161        self.http.client = client;
162        self
163    }
164
165    /// How many times a failed request is retried (default [`DEFAULT_MAX_RETRIES`](crate::DEFAULT_MAX_RETRIES); 0 disables).
166    pub fn with_max_retries(mut self, max_retries: u32) -> Self {
167        self.http.max_retries = max_retries;
168        self
169    }
170
171    fn request(&self, method: reqwest::Method, path: &str) -> Result<reqwest::RequestBuilder> {
172        if let Some(error) = &self.config_error {
173            return Err(LlmError::InvalidArgument(error.clone()));
174        }
175        let mut builder = self
176            .http
177            .client
178            .request(method, format!("{}{path}", self.base_url));
179        if !self.api_key.is_empty() {
180            builder = match self.auth {
181                Auth::Bearer => builder.header(
182                    reqwest::header::AUTHORIZATION,
183                    secret_header("OpenAiProvider", &format!("Bearer {}", self.api_key))?,
184                ),
185                Auth::ApiKeyHeader => {
186                    builder.header("api-key", secret_header("OpenAiProvider", &self.api_key)?)
187                }
188            };
189        }
190        Ok(builder)
191    }
192
193    /// OpenAI itself and Azure take `max_completion_tokens`; other compatible servers
194    /// (Ollama, vLLM, Mistral, …) reliably understand the older `max_tokens`.
195    fn is_openai(&self) -> bool {
196        self.base_url == OPENAI_BASE_URL || matches!(self.auth, Auth::ApiKeyHeader)
197    }
198
199    /// Mistral rejects unknown fields such as `stream_options` and reports usage on its own.
200    fn stream_usage(&self) -> bool {
201        !self.base_url.starts_with("https://api.mistral.ai")
202    }
203
204    pub(crate) fn build_body(req: &ChatRequest, openai: bool) -> Value {
205        let mut messages = Vec::new();
206        if let Some(system) = &req.system {
207            messages.push(json!({ "role": "system", "content": system }));
208        }
209        for message in &req.messages {
210            messages.extend(to_openai_messages(message));
211        }
212
213        let mut body = json!({ "model": req.model, "messages": messages });
214
215        if !req.tools.is_empty() {
216            let tools: Vec<Value> = req
217                .tools
218                .iter()
219                .map(|t| {
220                    json!({
221                        "type": "function",
222                        "function": {
223                            "name": t.name,
224                            "description": t.description,
225                            "parameters": t.parameters,
226                        }
227                    })
228                })
229                .collect();
230            body["tools"] = json!(tools);
231        }
232        if let Some(max_tokens) = req.max_tokens {
233            let field = if openai {
234                "max_completion_tokens"
235            } else {
236                "max_tokens"
237            };
238            body[field] = json!(max_tokens);
239        }
240        if let Some(temperature) = req.temperature {
241            body["temperature"] = json!(temperature);
242        }
243        if let Some(format) = &req.response_format {
244            body["response_format"] = json!({
245                "type": "json_schema",
246                "json_schema": { "name": format.name, "schema": format.schema, "strict": true },
247            });
248        }
249        if let Some(effort) = req.reasoning {
250            body["reasoning_effort"] = json!(effort.as_str());
251        }
252        body
253    }
254}
255
256/// OpenAI-style `image_url` content part (also used by Cohere).
257pub(crate) fn image_url_part(source: &ImageSource) -> Value {
258    let url = match source {
259        ImageSource::Url { url } => url.clone(),
260        ImageSource::Base64 { media_type, data } => format!("data:{media_type};base64,{data}"),
261    };
262    json!({ "type": "image_url", "image_url": { "url": url } })
263}
264
265pub(crate) fn to_openai_messages(message: &Message) -> Vec<Value> {
266    if message.role == Role::Tool {
267        // One `tool` message per result.
268        return message
269            .parts
270            .iter()
271            .filter_map(|p| match p {
272                Part::ToolResult {
273                    call_id, content, ..
274                } => Some(json!({
275                    "role": "tool",
276                    "tool_call_id": call_id,
277                    "content": content,
278                })),
279                _ => None,
280            })
281            .collect();
282    }
283
284    let role = match message.role {
285        Role::System => "system",
286        Role::User => "user",
287        Role::Assistant | Role::Tool => "assistant",
288    };
289    let tool_calls: Vec<Value> = message
290        .parts
291        .iter()
292        .filter_map(|p| match p {
293            Part::ToolCall {
294                id,
295                name,
296                arguments,
297                ..
298            } => Some(json!({
299                "id": id,
300                "type": "function",
301                "function": { "name": name, "arguments": arguments_json(arguments) },
302            })),
303            _ => None,
304        })
305        .collect();
306
307    let content = if message.has_images() {
308        // Text and images as content parts, in order. Reasoning is not sent back.
309        let parts: Vec<Value> = message
310            .parts
311            .iter()
312            .filter_map(|p| match p {
313                Part::Text { text } if !text.is_empty() => {
314                    Some(json!({ "type": "text", "text": text }))
315                }
316                Part::Image { source } => Some(image_url_part(source)),
317                _ => None,
318            })
319            .collect();
320        json!(parts)
321    } else {
322        let text = message.text_content();
323        if text.is_empty() && !tool_calls.is_empty() {
324            Value::Null
325        } else {
326            json!(text)
327        }
328    };
329    let mut msg = json!({ "role": role, "content": content });
330    if !tool_calls.is_empty() {
331        msg["tool_calls"] = json!(tool_calls);
332    }
333    vec![msg]
334}
335
336/// Tool-call arguments as the JSON string OpenAI-style APIs expect. Arguments that were kept
337/// raw (invalid JSON) are sent back as they came, not encoded a second time.
338pub(crate) fn arguments_json(arguments: &Value) -> String {
339    match arguments {
340        Value::String(raw) => raw.clone(),
341        other => other.to_string(),
342    }
343}
344
345/// Parses an OpenAI-style `tool_calls` array (also used by Cohere).
346pub(crate) fn parse_tool_calls(calls: &Value) -> Vec<Part> {
347    let Some(calls) = calls.as_array() else {
348        return Vec::new();
349    };
350    calls
351        .iter()
352        .map(|call| Part::ToolCall {
353            id: call["id"].as_str().unwrap_or_default().to_string(),
354            name: call["function"]["name"]
355                .as_str()
356                .unwrap_or_default()
357                .to_string(),
358            arguments: parse_arguments(call["function"]["arguments"].as_str().unwrap_or_default()),
359            signature: None,
360        })
361        .collect()
362}
363
364/// Arguments are a JSON string. An empty string means a tool without arguments; invalid JSON
365/// (e.g. cut off by max tokens) is kept raw instead of failing the call.
366pub(crate) fn parse_arguments(raw: &str) -> Value {
367    if raw.trim().is_empty() {
368        json!({})
369    } else {
370        serde_json::from_str(raw).unwrap_or_else(|_| Value::String(raw.to_string()))
371    }
372}
373
374fn parse_response(v: &Value) -> Result<ChatResponse> {
375    let choice = v["choices"]
376        .get(0)
377        .ok_or_else(|| LlmError::InvalidResponse("missing field `choices[0]`".into()))?;
378    let message = &choice["message"];
379
380    let mut parts = Vec::new();
381    // Some compatible servers return reasoning: DeepSeek `reasoning_content`, OpenRouter `reasoning`.
382    let reasoning = message["reasoning_content"]
383        .as_str()
384        .or_else(|| message["reasoning"].as_str());
385    if let Some(text) = reasoning.filter(|t| !t.is_empty()) {
386        parts.push(Part::Reasoning {
387            text: text.to_string(),
388            raw: Value::Null,
389        });
390    }
391    if let Some(text) = message["content"].as_str().filter(|t| !t.is_empty()) {
392        parts.push(Part::text(text));
393    }
394    // A refusal arrives in its own `refusal` field while `content` is null.
395    let refusal = message["refusal"].as_str().filter(|t| !t.is_empty());
396    if let Some(text) = refusal {
397        parts.push(Part::text(text));
398    }
399    parts.extend(parse_tool_calls(&message["tool_calls"]));
400
401    let stop_reason = match choice["finish_reason"].as_str() {
402        _ if refusal.is_some() => StopReason::Refusal,
403        Some("stop") => StopReason::EndTurn,
404        Some("tool_calls" | "function_call") => StopReason::ToolUse,
405        Some("length") => StopReason::MaxTokens,
406        Some("content_filter") => StopReason::Refusal,
407        other => StopReason::Other(other.unwrap_or("unknown").to_string()),
408    };
409
410    Ok(ChatResponse {
411        model: v["model"].as_str().unwrap_or_default().to_string(),
412        message: Message::new(Role::Assistant, parts),
413        stop_reason,
414        usage: usage(&v["usage"], "prompt_tokens", "completion_tokens"),
415    })
416}
417
418pub(crate) fn usage(u: &Value, input: &str, output: &str) -> Usage {
419    let tokens = |field: &str| u32::try_from(u[field].as_u64().unwrap_or(0)).unwrap_or(u32::MAX);
420    Usage {
421        input_tokens: tokens(input),
422        output_tokens: tokens(output),
423    }
424}
425
426/// Builds the non-streaming response shape from `chat.completion.chunk` events.
427#[derive(Default)]
428struct OpenAiStream {
429    model: String,
430    content: String,
431    reasoning: String,
432    refusal: String,
433    /// Tool calls by `index`: (id, name, arguments).
434    calls: BTreeMap<u64, (String, String, String)>,
435    finish_reason: Option<String>,
436    usage: Value,
437    done: bool,
438}
439
440impl Accumulator for OpenAiStream {
441    fn on_event(&mut self, _event: &str, data: &str) -> Result<Vec<StreamEvent>> {
442        if data.trim() == "[DONE]" {
443            self.done = true;
444            return Ok(Vec::new());
445        }
446        let chunk = parse_data(data)?;
447        if chunk["error"].is_object() {
448            return Err(LlmError::Api(Box::new(crate::ApiError::from_stream_error(
449                &chunk["error"],
450            ))));
451        }
452        if let Some(model) = chunk["model"].as_str() {
453            self.model = model.to_string();
454        }
455        if chunk["usage"].is_object() {
456            self.usage = chunk["usage"].clone();
457        }
458        let mut out = Vec::new();
459        let Some(choice) = chunk["choices"].get(0) else {
460            return Ok(out);
461        };
462        let delta = &choice["delta"];
463        if let Some(text) = delta["reasoning_content"]
464            .as_str()
465            .or_else(|| delta["reasoning"].as_str())
466        {
467            if !text.is_empty() {
468                self.reasoning.push_str(text);
469                out.push(StreamEvent::ReasoningDelta(text.to_string()));
470            }
471        }
472        if let Some(text) = delta["content"].as_str().filter(|t| !t.is_empty()) {
473            self.content.push_str(text);
474            out.push(StreamEvent::TextDelta(text.to_string()));
475        }
476        if let Some(text) = delta["refusal"].as_str().filter(|t| !t.is_empty()) {
477            self.refusal.push_str(text);
478            out.push(StreamEvent::TextDelta(text.to_string()));
479        }
480        for call in delta["tool_calls"].as_array().into_iter().flatten() {
481            let index = call["index"].as_u64().unwrap_or(0);
482            let entry = self.calls.entry(index).or_default();
483            if let Some(id) = call["id"].as_str().filter(|s| !s.is_empty()) {
484                entry.0 = id.to_string();
485            }
486            // The name comes once; some servers repeat it in every chunk, so take only the first.
487            if let Some(name) = call["function"]["name"]
488                .as_str()
489                .filter(|s| !s.is_empty() && entry.1.is_empty())
490            {
491                entry.1 = name.to_string();
492                out.push(StreamEvent::ToolCallStart {
493                    id: entry.0.clone(),
494                    name: entry.1.clone(),
495                });
496            }
497            if let Some(args) = call["function"]["arguments"]
498                .as_str()
499                .filter(|s| !s.is_empty())
500            {
501                entry.2.push_str(args);
502                out.push(StreamEvent::ToolCallDelta {
503                    id: entry.0.clone(),
504                    arguments: args.to_string(),
505                });
506            }
507        }
508        if let Some(reason) = choice["finish_reason"].as_str() {
509            self.finish_reason = Some(reason.to_string());
510            self.done = true;
511        }
512        Ok(out)
513    }
514
515    fn finish(&mut self) -> Result<ChatResponse> {
516        if !self.done {
517            return Err(crate::stream::ended_early());
518        }
519        let tool_calls: Vec<Value> = self
520            .calls
521            .values()
522            .map(|(id, name, args)| json!({ "id": id, "function": { "name": name, "arguments": args } }))
523            .collect();
524        let mut message = json!({ "content": self.content, "tool_calls": tool_calls });
525        if !self.reasoning.is_empty() {
526            message["reasoning_content"] = json!(self.reasoning);
527        }
528        if !self.refusal.is_empty() {
529            message["refusal"] = json!(self.refusal);
530        }
531        parse_response(&json!({
532            "model": self.model,
533            "choices": [{ "message": message, "finish_reason": self.finish_reason }],
534            "usage": self.usage,
535        }))
536    }
537}
538
539#[async_trait]
540impl LlmProvider for OpenAiProvider {
541    async fn chat(&self, req: &ChatRequest) -> Result<ChatResponse> {
542        // OpenAI allows tool names up to 64 characters.
543        crate::validate(
544            "OpenAiProvider",
545            &self.api_key,
546            false,
547            &self.base_url,
548            64,
549            req,
550        )?;
551        let builder = self
552            .request(reqwest::Method::POST, "/chat/completions")?
553            .json(&Self::build_body(req, self.is_openai()));
554        parse_response(&self.http.json(builder).await?)
555    }
556
557    async fn chat_stream(&self, req: &ChatRequest) -> Result<ChatStream> {
558        crate::validate(
559            "OpenAiProvider",
560            &self.api_key,
561            false,
562            &self.base_url,
563            64,
564            req,
565        )?;
566        let mut body = Self::build_body(req, self.is_openai());
567        body["stream"] = json!(true);
568        if self.stream_usage() {
569            body["stream_options"] = json!({ "include_usage": true });
570        }
571        let builder = self
572            .request(reqwest::Method::POST, "/chat/completions")?
573            .json(&body);
574        let response = self.http.send(builder).await?;
575        Ok(stream::drive(response, Box::<OpenAiStream>::default()))
576    }
577
578    async fn list_models(&self) -> Result<Vec<ModelInfo>> {
579        crate::validate_endpoint("OpenAiProvider", &self.api_key, false, &self.base_url)?;
580        let v = self
581            .http
582            .json(self.request(reqwest::Method::GET, "/models")?)
583            .await?;
584        Ok(v["data"]
585            .as_array()
586            .into_iter()
587            .flatten()
588            .filter_map(|m| m["id"].as_str())
589            .map(|id| ModelInfo {
590                id: id.to_string(),
591                display_name: None,
592            })
593            .collect())
594    }
595}
596
597#[async_trait]
598impl EmbeddingProvider for OpenAiProvider {
599    async fn embed(&self, model: &str, inputs: &[&str]) -> Result<Embeddings> {
600        crate::validate_endpoint("OpenAiProvider", &self.api_key, false, &self.base_url)?;
601        validate_embed_args(model, inputs)?;
602        let builder = self
603            .request(reqwest::Method::POST, "/embeddings")?
604            .json(&json!({ "model": model, "input": inputs }));
605        let v = self.http.json(builder).await?;
606        let mut data: Vec<&Value> = v["data"].as_array().into_iter().flatten().collect();
607        // Results carry their input index; order by it.
608        data.sort_by_key(|d| d["index"].as_u64().unwrap_or(0));
609        Ok(Embeddings {
610            vectors: data.iter().map(|d| float_vec(&d["embedding"])).collect(),
611            usage: usage(&v["usage"], "prompt_tokens", "completion_tokens"),
612        })
613    }
614}
615
616pub(crate) fn validate_embed_args(model: &str, inputs: &[&str]) -> Result<()> {
617    if model.trim().is_empty() {
618        return Err(LlmError::InvalidArgument("model must not be empty".into()));
619    }
620    if inputs.is_empty() {
621        return Err(LlmError::InvalidArgument("inputs must not be empty".into()));
622    }
623    Ok(())
624}
625
626pub(crate) fn float_vec(v: &Value) -> Vec<f32> {
627    v.as_array()
628        .into_iter()
629        .flatten()
630        .filter_map(Value::as_f64)
631        .map(|x| x as f32)
632        .collect()
633}
634
635#[cfg(test)]
636mod tests {
637    use super::*;
638    use crate::{ReasoningEffort, Tool};
639
640    #[test]
641    fn builds_tool_request() {
642        let req = ChatRequest::new("gpt-4o")
643            .system("sys")
644            .max_tokens(100)
645            .tool(Tool::new("f", "d", json!({"type": "object"})))
646            .message(Message::user("hi"));
647        let body = OpenAiProvider::build_body(&req, true);
648        assert_eq!(body["messages"][0]["role"], "system");
649        assert_eq!(body["messages"][1]["content"], "hi");
650        assert_eq!(body["tools"][0]["function"]["name"], "f");
651        assert_eq!(body["max_completion_tokens"], 100);
652    }
653
654    #[test]
655    fn builds_images_json_schema_and_reasoning() {
656        let req = ChatRequest::new("gpt-4o")
657            .json_schema("answer", json!({"type": "object"}))
658            .reasoning(ReasoningEffort::High)
659            .message(Message::user_with_image(
660                "what is this?",
661                Part::image_base64("image/png", "AAAA"),
662            ));
663        let body = OpenAiProvider::build_body(&req, true);
664        let content = &body["messages"][0]["content"];
665        assert_eq!(content[0]["type"], "text");
666        assert_eq!(content[1]["image_url"]["url"], "data:image/png;base64,AAAA");
667        assert_eq!(body["response_format"]["json_schema"]["name"], "answer");
668        assert_eq!(body["reasoning_effort"], "high");
669    }
670
671    #[test]
672    fn parses_tool_call_response() {
673        let v = json!({
674            "model": "gpt-4o",
675            "choices": [{
676                "finish_reason": "tool_calls",
677                "message": {
678                    "content": null,
679                    "tool_calls": [{
680                        "id": "call_1",
681                        "type": "function",
682                        "function": { "name": "f", "arguments": "{\"x\":1}" }
683                    }]
684                }
685            }],
686            "usage": { "prompt_tokens": 5, "completion_tokens": 7 }
687        });
688        let resp = parse_response(&v).unwrap();
689        assert_eq!(resp.stop_reason, StopReason::ToolUse);
690        assert_eq!(resp.tool_calls()[0].arguments["x"], 1);
691        assert_eq!(resp.usage.output_tokens, 7);
692    }
693
694    #[test]
695    fn tolerates_empty_and_truncated_arguments() {
696        let v = json!({
697            "choices": [{
698                "finish_reason": "length",
699                "message": {
700                    "content": null,
701                    "tool_calls": [
702                        { "id": "a", "function": { "name": "f", "arguments": "" } },
703                        { "id": "b", "function": { "name": "f", "arguments": "{\"x\":" } }
704                    ]
705                }
706            }]
707        });
708        let resp = parse_response(&v).unwrap();
709        let calls = resp.tool_calls();
710        assert_eq!(calls[0].arguments, json!({}));
711        assert_eq!(calls[1].arguments, json!("{\"x\":"));
712        assert_eq!(resp.stop_reason, StopReason::MaxTokens);
713    }
714
715    #[test]
716    fn maps_refusal_field() {
717        let v = json!({
718            "choices": [{
719                "finish_reason": "stop",
720                "message": { "content": null, "refusal": "I can't help with that." }
721            }]
722        });
723        let resp = parse_response(&v).unwrap();
724        assert_eq!(resp.stop_reason, StopReason::Refusal);
725        assert_eq!(resp.text(), "I can't help with that.");
726    }
727
728    #[test]
729    fn stream_accumulates_text_and_tool_calls() {
730        let mut acc = OpenAiStream::default();
731        let events = [
732            r#"{"model":"gpt-4o","choices":[{"delta":{"content":"Hel"}}]}"#,
733            r#"{"choices":[{"delta":{"content":"lo"}}]}"#,
734            r#"{"choices":[{"delta":{"tool_calls":[{"index":0,"id":"c1","function":{"name":"f","arguments":"{\"x\""}}]}}]}"#,
735            r#"{"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":":1}"}}]},"finish_reason":"tool_calls"}]}"#,
736            r#"{"choices":[],"usage":{"prompt_tokens":3,"completion_tokens":4}}"#,
737            "[DONE]",
738        ];
739        let mut deltas = String::new();
740        for e in events {
741            for ev in acc.on_event("", e).unwrap() {
742                if let StreamEvent::TextDelta(t) = ev {
743                    deltas.push_str(&t);
744                }
745            }
746        }
747        let resp = acc.finish().unwrap();
748        assert_eq!(deltas, "Hello");
749        assert_eq!(resp.text(), "Hello");
750        assert_eq!(resp.tool_calls()[0].arguments["x"], 1);
751        assert_eq!(resp.stop_reason, StopReason::ToolUse);
752        assert_eq!(resp.usage.output_tokens, 4);
753    }
754
755    #[test]
756    fn debug_redacts_api_key() {
757        let debug = format!("{:?}", OpenAiProvider::new("sk-secret"));
758        assert!(!debug.contains("sk-secret"));
759    }
760}
761
762#[cfg(test)]
763mod regression_tests {
764    use super::*;
765
766    #[test]
767    fn raw_string_arguments_are_not_double_encoded() {
768        let msg = Message::new(
769            Role::Assistant,
770            vec![Part::ToolCall {
771                id: "c".into(),
772                name: "f".into(),
773                arguments: json!("{\"x\":"),
774                signature: None,
775            }],
776        );
777        let sent = to_openai_messages(&msg);
778        assert_eq!(sent[0]["tool_calls"][0]["function"]["arguments"], "{\"x\":");
779    }
780
781    #[test]
782    fn compatible_servers_get_max_tokens_and_openai_gets_max_completion_tokens() {
783        let req = ChatRequest::new("m")
784            .max_tokens(9)
785            .message(Message::user("x"));
786        assert_eq!(
787            OpenAiProvider::build_body(&req, true)["max_completion_tokens"],
788            9
789        );
790        assert_eq!(OpenAiProvider::build_body(&req, false)["max_tokens"], 9);
791        assert!(OpenAiProvider::new("k").is_openai());
792        assert!(OpenAiProvider::azure("res", "k").is_openai());
793        assert!(!OpenAiProvider::groq("k").is_openai());
794        assert!(!OpenAiProvider::mistral("k").stream_usage());
795    }
796
797    #[test]
798    fn repeated_tool_name_chunks_are_not_concatenated() {
799        let mut acc = OpenAiStream::default();
800        let chunk = r#"{"choices":[{"delta":{"tool_calls":[{"index":0,"id":"c","function":{"name":"f","arguments":"{}"}}]}}]}"#;
801        let first = acc.on_event("", chunk).unwrap();
802        let second = acc.on_event("", r#"{"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"name":"f"}}]},"finish_reason":"tool_calls"}]}"#).unwrap();
803        assert!(first
804            .iter()
805            .any(|e| matches!(e, StreamEvent::ToolCallStart { .. })));
806        assert!(!second
807            .iter()
808            .any(|e| matches!(e, StreamEvent::ToolCallStart { .. })));
809        assert_eq!(acc.finish().unwrap().tool_calls()[0].name, "f");
810    }
811}