Skip to main content

infinity_provider_models_dev/
openai.rs

1//! `OpenAI` Chat Completions backend with SSE streaming.
2//!
3//! Covers every `OpenAI`-compatible endpoint: `OpenAI` itself plus `DeepSeek`,
4//! `Groq`, `Cerebras`, `xAI`, `Mistral`, `Gemini` (`OpenAI`-compat path), `Together`,
5//! `Fireworks`, `OpenRouter`, `NVIDIA NIM`, `Moonshot`/`Kimi`, `ZAI`, `MiniMax`, `Qwen`
6//! (`DashScope` compat), `HuggingFace` router, `Vercel` AI Gateway, `OpenCode` Zen,
7//! `Azure OpenAI`, `Ollama` / `LM Studio` / `vLLM`, and any custom
8//! `INFINITY_OPENAI_BASE_URL` proxy.
9
10use async_stream::stream;
11use futures_util::StreamExt;
12use infinity_provider_protocol::{
13    CompletionError, CompletionRequest, ErrorClass, FinalResponse, ModelStream, Reasoning,
14    ReasoningContent, StreamChunk, ToolCall, ToolCallDeltaContent, ToolDefinition, Usage,
15};
16use reqwest::Client;
17use serde_json::{Value, json};
18
19use crate::convert::{
20    classify_http_status, error_message_from_body, take_frames, to_openai_messages, to_openai_tools,
21};
22
23/// How long to wait for the endpoint to accept a request (connect, send,
24/// response headers). This deliberately covers only initiation: once a
25/// response stream is live it must never be cut off by a total deadline
26/// (long generations stream for many minutes).
27const REQUEST_INITIATION_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
28/// How long a live stream may go without yielding a chunk before it is
29/// treated as stalled. Chunks normally arrive every few seconds; thinking
30/// gaps on buffered backends can stretch much longer.
31const STREAM_IDLE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
32/// Upper bound for one model-list discovery probe.
33const DISCOVERY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
34
35#[derive(Debug)]
36struct AccumulatedToolCall {
37    id: String,
38    name: String,
39    args: String,
40    name_sent: bool,
41}
42
43/// How to reach and authenticate one `OpenAI`-compatible backend.
44pub struct Backend<'a> {
45    /// Shared HTTP client.
46    pub client: &'a Client,
47    /// Base URL (`.../v1`), without the trailing path.
48    pub base_url: &'a str,
49    /// API key; empty for trust-local endpoints.
50    pub api_key: &'a str,
51    /// Extra headers (e.g. `OpenRouter` attribution).
52    pub extra_headers: &'a [(String, String)],
53    /// Whether the model accepts image input.
54    pub supports_images: bool,
55}
56
57/// Stream one completion from an `OpenAI`-compatible endpoint.
58///
59/// # Errors
60///
61/// Returns [`CompletionError::provider`] when the endpoint rejects the
62/// request (with the provider's retry classification) and
63/// [`CompletionError::ResponseError`] when a live stream breaks mid-flight.
64/// Individual stream items may also yield errors.
65pub async fn invoke(
66    backend: &Backend<'_>,
67    upstream_model: &str,
68    default_max_tokens: Option<u64>,
69    request: CompletionRequest,
70) -> Result<ModelStream, CompletionError> {
71    let body = request_body(
72        upstream_model,
73        default_max_tokens,
74        &request,
75        backend.supports_images,
76    );
77
78    let url = format!(
79        "{}/chat/completions",
80        backend.base_url.trim_end_matches('/')
81    );
82    let mut req = backend
83        .client
84        .post(&url)
85        .header("Content-Type", "application/json")
86        .header("Accept", "text/event-stream")
87        .header("User-Agent", crate::USER_AGENT);
88    if !backend.api_key.is_empty() {
89        req = req.bearer_auth(backend.api_key);
90    }
91    for (k, v) in backend.extra_headers {
92        req = req.header(k.as_str(), v.as_str());
93    }
94    // Bound only initiation (connect, send, response headers). The body
95    // stream below has no total deadline; stalled streams trip the idle
96    // watchdog instead.
97    let resp = tokio::time::timeout(REQUEST_INITIATION_TIMEOUT, req.json(&body).send())
98        .await
99        .map_err(|_| {
100            CompletionError::provider(
101                ErrorClass::Transient,
102                format!("{}: request initiation timed out", backend.base_url),
103            )
104        })?
105        .map_err(transport_error)?;
106
107    if !resp.status().is_success() {
108        let status = resp.status().as_u16();
109        let text = resp.text().await.unwrap_or_default();
110        let class = classify_http_status(status, &text);
111        return Err(CompletionError::provider(
112            class,
113            format!(
114                "{} -> {status}: {}",
115                backend.base_url,
116                error_message_from_body(&text)
117            ),
118        ));
119    }
120
121    Ok(adapt_stream(resp.bytes_stream(), STREAM_IDLE_TIMEOUT))
122}
123
124/// Adapt a raw SSE byte stream into protocol chunks.
125///
126/// `idle_timeout` bounds silence between chunks (not the whole stream:
127/// long generations legitimately run for minutes). Transport failures,
128/// stalls, and terminal error frames close the stream *without* a `Final`
129/// so a dead stream never reads as a successful completion.
130fn adapt_stream<S, B, E>(mut byte_stream: S, idle_timeout: std::time::Duration) -> ModelStream
131where
132    S: futures_util::Stream<Item = Result<B, E>> + Unpin + Send + 'static,
133    B: AsRef<[u8]> + Send + 'static,
134    E: std::error::Error + Send + Sync + 'static,
135{
136    Box::pin(stream! {
137        let mut buf = String::new();
138        let mut pending: Vec<u8> = Vec::new();
139        let mut tools: Vec<AccumulatedToolCall> = Vec::new();
140        let mut usage: Option<Usage> = None;
141        let mut reasoning = String::new();
142        // Once the stream dies, nothing more can arrive: close without
143        // emitting tool calls, reasoning, or a Final that would read as
144        // success.
145        let mut failed = false;
146
147        'chunks: loop {
148            let chunk = tokio::time::timeout(idle_timeout, byte_stream.next()).await;
149            let chunk = match chunk {
150                Err(_) => {
151                    yield Err(CompletionError::ResponseError(
152                        "stream stalled: no chunks received".to_owned(),
153                    ));
154                    failed = true;
155                    break;
156                }
157                Ok(None) => break,
158                Ok(Some(chunk)) => chunk,
159            };
160            let bytes = match chunk {
161                Ok(b) => b,
162                Err(e) => {
163                    yield Err(CompletionError::ResponseError(format!("stream read failed: {e}")));
164                    failed = true;
165                    break;
166                }
167            };
168            crate::convert::push_bytes(&mut buf, &mut pending, bytes.as_ref());
169            // Process complete `data:` frames; take_frames keeps the tail.
170            for frame in take_frames(&mut buf) {
171                for line in frame.lines() {
172                    let line = line.trim();
173                    let Some(data) = line.strip_prefix("data:") else { continue };
174                    let data = data.trim();
175                    if data.is_empty() || data == "[DONE]" {
176                        continue;
177                    }
178                    let Ok(v) = serde_json::from_str::<Value>(data) else {
179                        tracing::debug!(
180                            "openai: skipping malformed frame: {}",
181                            data.chars().take(200).collect::<String>()
182                        );
183                        continue;
184                    };
185                    // Some gateways answer 200 and then report the failure
186                    // as a top-level error object mid-stream. Error frames
187                    // are terminal: stop reading after reporting one.
188                    if let Some(msg) = stream_error_message(&v) {
189                        yield Err(CompletionError::provider(
190                            classify_http_status(400, &msg),
191                            msg,
192                        ));
193                        failed = true;
194                        break 'chunks;
195                    }
196                    if let Some(u) = parse_usage(&v) {
197                        usage = Some(u);
198                    }
199                    for out in parse_choices(&v, &mut tools, &mut reasoning) {
200                        yield out;
201                    }
202                }
203            }
204        }
205        if !failed {
206            for tc in std::mem::take(&mut tools) {
207                if let Some(call) = finish_tool_call(tc) {
208                    yield Ok(call);
209                }
210            }
211            // The complete reasoning block is what gets recorded in
212            // history; deltas above were the UI-facing stream.
213            if !reasoning.is_empty() {
214                yield Ok(StreamChunk::Reasoning(Reasoning {
215                    id: None,
216                    content: vec![ReasoningContent::Text {
217                        text: std::mem::take(&mut reasoning),
218                        signature: None,
219                    }],
220                }));
221            }
222            yield Ok(StreamChunk::Final(FinalResponse { usage }));
223        }
224    })
225}
226
227/// A top-level `{"error": ...}` object in a streamed frame.
228fn stream_error_message(v: &Value) -> Option<String> {
229    match v.get("error") {
230        Some(Value::String(message)) if !message.is_empty() => Some(message.clone()),
231        Some(Value::Object(_)) => v
232            .pointer("/error/message")
233            .and_then(Value::as_str)
234            .filter(|message| !message.is_empty())
235            .map(str::to_owned),
236        _ => None,
237    }
238}
239
240fn request_body(
241    upstream_model: &str,
242    default_max_tokens: Option<u64>,
243    request: &CompletionRequest,
244    supports_images: bool,
245) -> Value {
246    let (messages, system) = to_openai_messages(
247        request.preamble.as_deref(),
248        &request.chat_history,
249        supports_images,
250    );
251    let mut map = serde_json::Map::new();
252    map.insert("model".to_owned(), json!(upstream_model));
253    map.insert(
254        "messages".to_owned(),
255        Value::Array(with_system(messages, system)),
256    );
257    map.insert("stream".to_owned(), Value::Bool(true));
258    map.insert("stream_options".to_owned(), json!({"include_usage": true}));
259    if let Some(max) = request.max_tokens.or(default_max_tokens) {
260        map.insert("max_tokens".to_owned(), json!(max));
261    }
262    if !request.tools.is_empty() {
263        map.insert(
264            "tools".to_owned(),
265            Value::Array(to_openai_tools(&request.tools)),
266        );
267    }
268    let mut body = Value::Object(map);
269    if let Some(extra) = request.additional_params.as_ref() {
270        merge_value(&mut body, extra);
271    }
272    body
273}
274
275fn with_system(mut messages: Vec<Value>, system: Option<String>) -> Vec<Value> {
276    if let Some(s) = system {
277        messages.insert(0, json!({"role": "system", "content": s}));
278    }
279    messages
280}
281
282fn merge_value(dst: &mut Value, src: &Value) {
283    match (dst, src) {
284        (Value::Object(d), Value::Object(s)) => {
285            for (k, v) in s {
286                merge_value(d.entry(k.clone()).or_insert(Value::Null), v);
287            }
288        }
289        (d, s) => *d = s.clone(),
290    }
291}
292
293fn parse_usage(v: &Value) -> Option<Usage> {
294    let u = v.get("usage")?;
295    Some(Usage {
296        input_tokens: u.get("prompt_tokens").and_then(Value::as_u64).unwrap_or(0),
297        output_tokens: u
298            .get("completion_tokens")
299            .and_then(Value::as_u64)
300            .unwrap_or(0),
301        total_tokens: u.get("total_tokens").and_then(Value::as_u64).unwrap_or(0),
302        cached_input_tokens: u
303            .pointer("/prompt_tokens_details/cached_tokens")
304            .and_then(Value::as_u64)
305            .unwrap_or(0),
306    })
307}
308
309/// Grow `tools` to cover `index` and return the slot for it.
310fn slot_for(
311    tools: &mut Vec<AccumulatedToolCall>,
312    index: usize,
313) -> Option<&mut AccumulatedToolCall> {
314    while tools.len() <= index {
315        tools.push(AccumulatedToolCall {
316            id: String::new(),
317            name: String::new(),
318            args: String::new(),
319            name_sent: false,
320        });
321    }
322    tools.get_mut(index)
323}
324
325fn parse_choices(
326    v: &Value,
327    tools: &mut Vec<AccumulatedToolCall>,
328    reasoning: &mut String,
329) -> Vec<Result<StreamChunk, CompletionError>> {
330    let choices = v
331        .get("choices")
332        .and_then(Value::as_array)
333        .cloned()
334        .unwrap_or_default();
335    let mut out = Vec::new();
336    for choice in &choices {
337        out.extend(parse_choice(choice, tools, reasoning));
338    }
339    out
340}
341
342fn parse_choice(
343    choice: &Value,
344    tools: &mut Vec<AccumulatedToolCall>,
345    reasoning: &mut String,
346) -> Vec<Result<StreamChunk, CompletionError>> {
347    if let Some(err) = choice.get("error") {
348        let msg = err
349            .get("message")
350            .and_then(Value::as_str)
351            .unwrap_or("stream error");
352        let code = err.get("code").and_then(Value::as_str).unwrap_or("");
353        return vec![Err(CompletionError::provider(
354            classify_http_status(400, &format!("{code} {msg}")),
355            msg.to_owned(),
356        ))];
357    }
358    let mut out = Vec::new();
359    let delta = choice.pointer("/delta").unwrap_or(&Value::Null).clone();
360    if let Some(text) = delta.get("content").and_then(Value::as_str) {
361        if !text.is_empty() {
362            out.push(Ok(StreamChunk::Text(text.to_owned())));
363        }
364    }
365    if let Some(fragment) = delta.get("reasoning_content").and_then(Value::as_str) {
366        if !fragment.is_empty() {
367            reasoning.push_str(fragment);
368            out.push(Ok(StreamChunk::ReasoningDelta {
369                id: None,
370                text: fragment.to_owned(),
371            }));
372        }
373    }
374    if let Some(calls) = delta.get("tool_calls").and_then(Value::as_array) {
375        accumulate_delta_tool_calls(calls, tools, &mut out);
376    }
377    // Some servers send the full tool call only in the final message.
378    if let Some(msg) = choice.get("message").and_then(Value::as_object) {
379        apply_full_message(msg, tools, &mut out);
380    }
381    out
382}
383
384fn accumulate_delta_tool_calls(
385    calls: &[Value],
386    tools: &mut Vec<AccumulatedToolCall>,
387    out: &mut Vec<Result<StreamChunk, CompletionError>>,
388) {
389    for call in calls {
390        let raw_index = call.get("index").and_then(Value::as_u64).unwrap_or(0);
391        let Ok(index) = usize::try_from(raw_index) else {
392            continue;
393        };
394        let Some(slot) = slot_for(tools, index) else {
395            continue;
396        };
397        if let Some(id) = call.get("id").and_then(Value::as_str) {
398            if !id.is_empty() {
399                id.clone_into(&mut slot.id);
400            }
401        }
402        let func = call.get("function").unwrap_or(&Value::Null);
403        if let Some(name) = func.get("name").and_then(Value::as_str) {
404            if !name.is_empty() {
405                name.clone_into(&mut slot.name);
406            }
407        }
408        if let Some(frag) = func.get("arguments").and_then(Value::as_str) {
409            slot.args.push_str(frag);
410            let id = if slot.id.is_empty() {
411                format!("tool-{index}")
412            } else {
413                slot.id.clone()
414            };
415            if !slot.name_sent && !slot.name.is_empty() {
416                slot.name_sent = true;
417                out.push(Ok(StreamChunk::ToolCallDelta {
418                    id: id.clone(),
419                    content: ToolCallDeltaContent::Name(slot.name.clone()),
420                }));
421            }
422            if !frag.is_empty() {
423                out.push(Ok(StreamChunk::ToolCallDelta {
424                    id,
425                    content: ToolCallDeltaContent::Delta(frag.to_owned()),
426                }));
427            }
428        }
429        if slot.id.is_empty() {
430            slot.id = format!("tool-{index}");
431        }
432    }
433}
434
435fn apply_full_message(
436    msg: &serde_json::Map<String, Value>,
437    tools: &mut Vec<AccumulatedToolCall>,
438    out: &mut Vec<Result<StreamChunk, CompletionError>>,
439) {
440    let empty = Vec::new();
441    let calls = msg
442        .get("tool_calls")
443        .and_then(Value::as_array)
444        .unwrap_or(&empty);
445    for (index, call) in calls.iter().enumerate() {
446        let id = call
447            .get("id")
448            .and_then(Value::as_str)
449            .unwrap_or("")
450            .to_owned();
451        let func = call.get("function").unwrap_or(&Value::Null);
452        let name = func
453            .get("name")
454            .and_then(Value::as_str)
455            .unwrap_or("")
456            .to_owned();
457        let args = func
458            .get("arguments")
459            .and_then(Value::as_str)
460            .unwrap_or("")
461            .to_owned();
462        if name.is_empty() {
463            continue;
464        }
465        let Some(slot) = slot_for(tools, index) else {
466            continue;
467        };
468        slot.id = if id.is_empty() {
469            format!("tool-{index}")
470        } else {
471            id
472        };
473        slot.name = name;
474        slot.args = args;
475        slot.name_sent = true;
476    }
477    if let Some(text) = msg.get("content").and_then(Value::as_str) {
478        if !text.is_empty() {
479            out.push(Ok(StreamChunk::Text(text.to_owned())));
480        }
481    }
482}
483
484/// Complete an accumulated tool call, or `None` when the slot never
485/// received a name or arguments (some servers emit empty placeholder
486/// entries that would otherwise surface as noise).
487fn finish_tool_call(tc: AccumulatedToolCall) -> Option<StreamChunk> {
488    if tc.name.is_empty() && tc.args.is_empty() {
489        return None;
490    }
491    let args: Value = if tc.args.trim().is_empty() {
492        json!({})
493    } else {
494        serde_json::from_str(&tc.args).unwrap_or_else(|_| json!({"_raw": tc.args}))
495    };
496    Some(StreamChunk::ToolCall(ToolCall::new(tc.id, tc.name, args)))
497}
498
499fn transport_error(e: reqwest::Error) -> CompletionError {
500    if e.is_timeout() || e.is_connect() || e.is_body() || e.is_decode() {
501        CompletionError::provider(ErrorClass::Transient, format!("transport error: {e}"))
502    } else if e.is_request() {
503        CompletionError::RequestError(Box::new(e))
504    } else {
505        CompletionError::provider(ErrorClass::Transient, format!("transport error: {e}"))
506    }
507}
508
509/// Extract model ids from an OpenAI-style `GET /models` body.
510///
511/// Accepts `{data: [{id, ...}]}` and bare `[{id, ...}]` shapes.
512#[must_use]
513pub fn discovered_ids(body: &Value) -> Vec<String> {
514    let items = body
515        .get("data")
516        .and_then(Value::as_array)
517        .or_else(|| body.as_array());
518    let Some(items) = items else {
519        return Vec::new();
520    };
521    items
522        .iter()
523        .filter_map(|m| m.get("id").and_then(Value::as_str))
524        .map(str::to_owned)
525        .filter(|id| !id.is_empty())
526        .collect()
527}
528
529/// Probe an OpenAI-compatible `GET {base}/models` endpoint.
530///
531/// Returns the advertised ids, or an empty list when the endpoint is down,
532/// refuses, or answers in an unknown shape. Never fails: discovery is a
533/// best-effort enrichment of the curated catalog.
534#[must_use]
535pub async fn discover_models(client: &Client, base_url: &str, api_key: &str) -> Vec<String> {
536    let url = format!("{}/models", base_url.trim_end_matches('/'));
537    let mut req = client
538        .get(&url)
539        .timeout(DISCOVERY_TIMEOUT)
540        .header("Accept", "application/json")
541        .header("User-Agent", crate::USER_AGENT);
542    if !api_key.is_empty() {
543        req = req.bearer_auth(api_key);
544    }
545    let resp = match req.send().await {
546        Ok(resp) => resp,
547        Err(e) => {
548            tracing::debug!("discovery: {url} unreachable: {e}");
549            return Vec::new();
550        }
551    };
552    if !resp.status().is_success() {
553        tracing::debug!("discovery: {url} -> {}", resp.status());
554        return Vec::new();
555    }
556    match resp.json::<Value>().await {
557        Ok(body) => discovered_ids(&body),
558        Err(e) => {
559            tracing::debug!("discovery: {url} returned invalid JSON: {e}");
560            Vec::new()
561        }
562    }
563}
564
565/// Build a non-streaming request body (used by tests).
566#[must_use]
567pub fn build_body(
568    upstream_model: &str,
569    max_tokens: Option<u64>,
570    tools: &[ToolDefinition],
571    messages: Vec<Value>,
572    system: Option<String>,
573) -> Value {
574    let mut map = serde_json::Map::new();
575    map.insert("model".to_owned(), json!(upstream_model));
576    map.insert(
577        "messages".to_owned(),
578        Value::Array(with_system(messages, system)),
579    );
580    map.insert("stream".to_owned(), Value::Bool(true));
581    if let Some(max) = max_tokens {
582        map.insert("max_tokens".to_owned(), json!(max));
583    }
584    if !tools.is_empty() {
585        map.insert("tools".to_owned(), Value::Array(to_openai_tools(tools)));
586    }
587    Value::Object(map)
588}
589
590#[cfg(test)]
591mod tests {
592    use super::*;
593    use serde_json::json;
594
595    #[test]
596    fn parses_openai_list_shape() {
597        let body = json!({"data": [{"id": "a"}, {"id": "b"}]});
598        assert_eq!(discovered_ids(&body), vec!["a".to_owned(), "b".to_owned()]);
599    }
600
601    #[test]
602    fn parses_bare_array_and_skips_bad_entries() {
603        let body = json!([{"id": "a"}, {"other": 1}, {"id": ""}]);
604        assert_eq!(discovered_ids(&body), vec!["a".to_owned()]);
605    }
606
607    #[test]
608    fn rejects_unknown_shapes() {
609        assert_eq!(discovered_ids(&json!({"models": []})), Vec::<String>::new());
610        assert_eq!(discovered_ids(&Value::Null), Vec::<String>::new());
611    }
612}
613
614#[cfg(test)]
615mod stream_tests {
616    use super::*;
617    use futures_util::stream;
618
619    fn frame(json: &str) -> Vec<u8> {
620        format!("data: {json}\n\n").into_bytes()
621    }
622
623    async fn collect<S, B, E>(
624        byte_stream: S,
625        idle_ms: u64,
626    ) -> Vec<Result<StreamChunk, CompletionError>>
627    where
628        S: futures_util::Stream<Item = Result<B, E>> + Unpin + Send + 'static,
629        B: AsRef<[u8]> + Send + 'static,
630        E: std::error::Error + Send + Sync + 'static,
631    {
632        adapt_stream(byte_stream, std::time::Duration::from_millis(idle_ms))
633            .collect()
634            .await
635    }
636
637    #[tokio::test]
638    async fn error_frame_terminates_without_final() {
639        let chunks: Vec<Result<Vec<u8>, std::io::Error>> = vec![
640            Ok(frame(r#"{"choices":[{"delta":{"content":"hi"}}]}"#)),
641            Ok(frame(
642                r#"{"error":{"message":"server overloaded","code":"overloaded"}}"#,
643            )),
644            Ok(frame(r#"{"choices":[{"delta":{"content":"unreached"}}]}"#)),
645        ];
646        let out = collect(stream::iter(chunks), 60_000).await;
647        assert!(matches!(out.first(), Some(Ok(StreamChunk::Text(t))) if t == "hi"));
648        assert_eq!(out.len(), 2, "error must close the stream: {out:?}");
649        match &out[1] {
650            Err(CompletionError::ProviderError { class, .. }) => {
651                assert_eq!(*class, ErrorClass::Transient);
652            }
653            other => panic!("expected classified provider error, got {other:?}"),
654        }
655    }
656
657    #[tokio::test]
658    async fn stalled_stream_errors_without_final() {
659        let pending = stream::pending::<Result<Vec<u8>, std::io::Error>>();
660        let out = collect(pending, 50).await;
661        assert_eq!(out.len(), 1, "stall must close the stream: {out:?}");
662        assert!(
663            matches!(&out[0], Err(CompletionError::ResponseError(_))),
664            "unexpected: {out:?}"
665        );
666    }
667
668    #[tokio::test]
669    async fn slow_drip_stream_completes() {
670        let chunks = stream::unfold(0_u32, |n| async move {
671            if n >= 3 {
672                return None;
673            }
674            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
675            let frame = frame(&format!(
676                r#"{{"choices":[{{"delta":{{"content":"{n}"}}}}]}}"#
677            ));
678            Some((Ok::<Vec<u8>, std::io::Error>(frame), n + 1))
679        });
680        let out = collect(Box::pin(chunks), 5_000).await;
681        assert!(
682            matches!(
683                &out[..],
684                [
685                    Ok(StreamChunk::Text(_)),
686                    Ok(StreamChunk::Text(_)),
687                    Ok(StreamChunk::Text(_)),
688                    Ok(StreamChunk::Final(_))
689                ]
690            ),
691            "unexpected: {out:?}"
692        );
693    }
694
695    #[tokio::test]
696    async fn tool_call_accumulates_name_and_args() {
697        let chunks: Vec<Result<Vec<u8>, std::io::Error>> = vec![
698            Ok(frame(
699                r#"{"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"get_weather","arguments":""}}]}}]}"#,
700            )),
701            Ok(frame(
702                r#"{"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"city\":"}}]}}]}"#,
703            )),
704            Ok(frame(
705                r#"{"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\"sf\"}"}}]}}]}"#,
706            )),
707            Ok(frame(r#"{"choices":[{"finish_reason":"tool_calls"}]}"#)),
708            Ok(b"data: [DONE]\n\n".to_vec()),
709        ];
710        let out = collect(stream::iter(chunks), 60_000).await;
711        let names: Vec<_> = out
712            .iter()
713            .filter_map(|item| match item {
714                Ok(StreamChunk::ToolCallDelta {
715                    content: ToolCallDeltaContent::Name(n),
716                    ..
717                }) => Some(n.clone()),
718                _ => None,
719            })
720            .collect();
721        assert_eq!(names, vec!["get_weather".to_owned()]);
722        assert!(
723            out.iter().any(|item| matches!(
724                item,
725                Ok(StreamChunk::ToolCall(call)) if call.function.name == "get_weather"
726            )),
727            "expected a complete tool call, got: {out:?}"
728        );
729        assert!(
730            out.iter()
731                .any(|item| matches!(item, Ok(StreamChunk::Final(_))))
732        );
733    }
734}