1use 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
23const REQUEST_INITIATION_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
28const STREAM_IDLE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
32const 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
43pub struct Backend<'a> {
45 pub client: &'a Client,
47 pub base_url: &'a str,
49 pub api_key: &'a str,
51 pub extra_headers: &'a [(String, String)],
53 pub supports_images: bool,
55}
56
57pub 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 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
124fn 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 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 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 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 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
227fn 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
309fn 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 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
484fn 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#[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#[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#[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}