infinity_provider_models_dev/
openai.rs1use async_stream::stream;
11use futures_util::StreamExt;
12use infinity_provider_protocol::{
13 CompletionError, CompletionRequest, ErrorClass, FinalResponse, ModelStream, StreamChunk,
14 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, to_openai_messages, to_openai_tools,
21};
22
23const REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
24const DISCOVERY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
26
27#[derive(Debug)]
28struct AccumulatedToolCall {
29 id: String,
30 name: String,
31 args: String,
32 name_sent: bool,
33}
34
35pub async fn invoke(
44 client: &Client,
45 base_url: &str,
46 api_key: &str,
47 extra_headers: &[(String, String)],
48 upstream_model: &str,
49 default_max_tokens: Option<u64>,
50 request: CompletionRequest,
51) -> Result<ModelStream, CompletionError> {
52 let body = request_body(upstream_model, default_max_tokens, &request);
53
54 let url = format!("{}/chat/completions", base_url.trim_end_matches('/'));
55 let mut req = client
56 .post(&url)
57 .timeout(REQUEST_TIMEOUT)
58 .header("Content-Type", "application/json")
59 .header("Accept", "text/event-stream");
60 if !api_key.is_empty() {
61 req = req.bearer_auth(api_key);
62 }
63 for (k, v) in extra_headers {
64 req = req.header(k.as_str(), v.as_str());
65 }
66 let resp = req.json(&body).send().await.map_err(transport_error)?;
67
68 if !resp.status().is_success() {
69 let status = resp.status().as_u16();
70 let text = resp.text().await.unwrap_or_default();
71 let class = classify_http_status(status, &text);
72 return Err(CompletionError::provider(
73 class,
74 format!("{base_url} -> {status}: {}", error_message_from_body(&text)),
75 ));
76 }
77
78 let mut byte_stream = resp.bytes_stream();
79 let s = stream! {
80 let mut buf = String::new();
81 let mut tools: Vec<AccumulatedToolCall> = Vec::new();
82 let mut usage: Option<Usage> = None;
83
84 while let Some(chunk) = byte_stream.next().await {
85 let bytes = match chunk {
86 Ok(b) => b,
87 Err(e) => {
88 yield Err(CompletionError::ResponseError(format!("stream read failed: {e}")));
89 break;
90 }
91 };
92 buf.push_str(&String::from_utf8_lossy(&bytes));
93 while let Some(end) = buf.find("\n\n").or_else(|| buf.find("\r\n\r\n")) {
95 let frame_end = end.saturating_add(2);
96 let frame: String = buf.drain(..frame_end).collect();
97 buf = buf.trim_start_matches(['\n', '\r']).to_owned();
98 for line in frame.lines() {
99 let line = line.trim();
100 let Some(data) = line.strip_prefix("data:") else { continue };
101 let data = data.trim();
102 if data.is_empty() || data == "[DONE]" {
103 continue;
104 }
105 let Ok(v) = serde_json::from_str::<Value>(data) else { continue };
106 if let Some(u) = parse_usage(&v) {
107 usage = Some(u);
108 }
109 for out in parse_choices(&v, &mut tools) {
110 yield out;
111 }
112 }
113 }
114 }
115 for tc in std::mem::take(&mut tools) {
116 yield Ok(finish_tool_call(tc));
117 }
118 yield Ok(StreamChunk::Final(FinalResponse { usage }));
119 };
120 Ok(Box::pin(s))
121}
122
123fn request_body(
124 upstream_model: &str,
125 default_max_tokens: Option<u64>,
126 request: &CompletionRequest,
127) -> Value {
128 let (messages, system) =
129 to_openai_messages(request.preamble.as_deref(), &request.chat_history, true);
130 let mut map = serde_json::Map::new();
131 map.insert("model".to_owned(), json!(upstream_model));
132 map.insert(
133 "messages".to_owned(),
134 Value::Array(with_system(messages, system)),
135 );
136 map.insert("stream".to_owned(), Value::Bool(true));
137 map.insert("stream_options".to_owned(), json!({"include_usage": true}));
138 if let Some(max) = request.max_tokens.or(default_max_tokens) {
139 map.insert("max_tokens".to_owned(), json!(max));
140 }
141 if !request.tools.is_empty() {
142 map.insert(
143 "tools".to_owned(),
144 Value::Array(to_openai_tools(&request.tools)),
145 );
146 }
147 let mut body = Value::Object(map);
148 if let Some(extra) = request.additional_params.as_ref() {
149 merge_value(&mut body, extra);
150 }
151 body
152}
153
154fn with_system(mut messages: Vec<Value>, system: Option<String>) -> Vec<Value> {
155 if let Some(s) = system {
156 messages.insert(0, json!({"role": "system", "content": s}));
157 }
158 messages
159}
160
161fn merge_value(dst: &mut Value, src: &Value) {
162 match (dst, src) {
163 (Value::Object(d), Value::Object(s)) => {
164 for (k, v) in s {
165 merge_value(d.entry(k.clone()).or_insert(Value::Null), v);
166 }
167 }
168 (d, s) => *d = s.clone(),
169 }
170}
171
172fn parse_usage(v: &Value) -> Option<Usage> {
173 let u = v.get("usage")?;
174 Some(Usage {
175 input_tokens: u.get("prompt_tokens").and_then(Value::as_u64).unwrap_or(0),
176 output_tokens: u
177 .get("completion_tokens")
178 .and_then(Value::as_u64)
179 .unwrap_or(0),
180 total_tokens: u.get("total_tokens").and_then(Value::as_u64).unwrap_or(0),
181 cached_input_tokens: u
182 .pointer("/prompt_tokens_details/cached_tokens")
183 .and_then(Value::as_u64)
184 .unwrap_or(0),
185 })
186}
187
188fn slot_for(
190 tools: &mut Vec<AccumulatedToolCall>,
191 index: usize,
192) -> Option<&mut AccumulatedToolCall> {
193 while tools.len() <= index {
194 tools.push(AccumulatedToolCall {
195 id: String::new(),
196 name: String::new(),
197 args: String::new(),
198 name_sent: false,
199 });
200 }
201 tools.get_mut(index)
202}
203
204fn parse_choices(
205 v: &Value,
206 tools: &mut Vec<AccumulatedToolCall>,
207) -> Vec<Result<StreamChunk, CompletionError>> {
208 let choices = v
209 .get("choices")
210 .and_then(Value::as_array)
211 .cloned()
212 .unwrap_or_default();
213 let mut out = Vec::new();
214 for choice in &choices {
215 out.extend(parse_choice(choice, tools));
216 }
217 out
218}
219
220fn parse_choice(
221 choice: &Value,
222 tools: &mut Vec<AccumulatedToolCall>,
223) -> Vec<Result<StreamChunk, CompletionError>> {
224 if let Some(err) = choice.get("error") {
225 let msg = err
226 .get("message")
227 .and_then(Value::as_str)
228 .unwrap_or("stream error");
229 let code = err.get("code").and_then(Value::as_str).unwrap_or("");
230 return vec![Err(CompletionError::provider(
231 classify_http_status(400, &format!("{code} {msg}")),
232 msg.to_owned(),
233 ))];
234 }
235 let mut out = Vec::new();
236 let delta = choice.pointer("/delta").unwrap_or(&Value::Null).clone();
237 if let Some(text) = delta.get("content").and_then(Value::as_str) {
238 if !text.is_empty() {
239 out.push(Ok(StreamChunk::Text(text.to_owned())));
240 }
241 }
242 if let Some(reasoning) = delta.get("reasoning_content").and_then(Value::as_str) {
243 if !reasoning.is_empty() {
244 out.push(Ok(StreamChunk::ReasoningDelta {
245 id: None,
246 text: reasoning.to_owned(),
247 }));
248 }
249 }
250 if let Some(calls) = delta.get("tool_calls").and_then(Value::as_array) {
251 accumulate_delta_tool_calls(calls, tools, &mut out);
252 }
253 if let Some(msg) = choice.get("message").and_then(Value::as_object) {
255 apply_full_message(msg, tools, &mut out);
256 }
257 out
258}
259
260fn accumulate_delta_tool_calls(
261 calls: &[Value],
262 tools: &mut Vec<AccumulatedToolCall>,
263 out: &mut Vec<Result<StreamChunk, CompletionError>>,
264) {
265 for call in calls {
266 let raw_index = call.get("index").and_then(Value::as_u64).unwrap_or(0);
267 let Ok(index) = usize::try_from(raw_index) else {
268 continue;
269 };
270 let Some(slot) = slot_for(tools, index) else {
271 continue;
272 };
273 if let Some(id) = call.get("id").and_then(Value::as_str) {
274 if !id.is_empty() {
275 id.clone_into(&mut slot.id);
276 }
277 }
278 let func = call.get("function").unwrap_or(&Value::Null);
279 if let Some(name) = func.get("name").and_then(Value::as_str) {
280 if !name.is_empty() {
281 name.clone_into(&mut slot.name);
282 }
283 }
284 if let Some(frag) = func.get("arguments").and_then(Value::as_str) {
285 slot.args.push_str(frag);
286 let id = if slot.id.is_empty() {
287 format!("tool-{index}")
288 } else {
289 slot.id.clone()
290 };
291 if !slot.name_sent && !slot.name.is_empty() {
292 slot.name_sent = true;
293 out.push(Ok(StreamChunk::ToolCallDelta {
294 id: id.clone(),
295 content: ToolCallDeltaContent::Name(slot.name.clone()),
296 }));
297 }
298 if !frag.is_empty() {
299 out.push(Ok(StreamChunk::ToolCallDelta {
300 id,
301 content: ToolCallDeltaContent::Delta(frag.to_owned()),
302 }));
303 }
304 }
305 if slot.id.is_empty() {
306 slot.id = format!("tool-{index}");
307 }
308 }
309}
310
311fn apply_full_message(
312 msg: &serde_json::Map<String, Value>,
313 tools: &mut Vec<AccumulatedToolCall>,
314 out: &mut Vec<Result<StreamChunk, CompletionError>>,
315) {
316 let empty = Vec::new();
317 let calls = msg
318 .get("tool_calls")
319 .and_then(Value::as_array)
320 .unwrap_or(&empty);
321 for (index, call) in calls.iter().enumerate() {
322 let id = call
323 .get("id")
324 .and_then(Value::as_str)
325 .unwrap_or("")
326 .to_owned();
327 let func = call.get("function").unwrap_or(&Value::Null);
328 let name = func
329 .get("name")
330 .and_then(Value::as_str)
331 .unwrap_or("")
332 .to_owned();
333 let args = func
334 .get("arguments")
335 .and_then(Value::as_str)
336 .unwrap_or("")
337 .to_owned();
338 if name.is_empty() {
339 continue;
340 }
341 let Some(slot) = slot_for(tools, index) else {
342 continue;
343 };
344 slot.id = if id.is_empty() {
345 format!("tool-{index}")
346 } else {
347 id
348 };
349 slot.name = name;
350 slot.args = args;
351 slot.name_sent = true;
352 }
353 if let Some(text) = msg.get("content").and_then(Value::as_str) {
354 if !text.is_empty() {
355 out.push(Ok(StreamChunk::Text(text.to_owned())));
356 }
357 }
358}
359
360fn finish_tool_call(tc: AccumulatedToolCall) -> StreamChunk {
361 if tc.name.is_empty() && tc.args.is_empty() {
362 return StreamChunk::Text(String::new());
363 }
364 let args: Value = if tc.args.trim().is_empty() {
365 json!({})
366 } else {
367 serde_json::from_str(&tc.args).unwrap_or_else(|_| json!({"_raw": tc.args}))
368 };
369 StreamChunk::ToolCall(ToolCall::new(tc.id, tc.name, args))
370}
371
372fn transport_error(e: reqwest::Error) -> CompletionError {
373 if e.is_timeout() || e.is_connect() || e.is_body() || e.is_decode() {
374 CompletionError::provider(ErrorClass::Transient, format!("transport error: {e}"))
375 } else if e.is_request() {
376 CompletionError::RequestError(Box::new(e))
377 } else {
378 CompletionError::provider(ErrorClass::Transient, format!("transport error: {e}"))
379 }
380}
381
382#[must_use]
386pub fn discovered_ids(body: &Value) -> Vec<String> {
387 let items = body
388 .get("data")
389 .and_then(Value::as_array)
390 .or_else(|| body.as_array());
391 let Some(items) = items else {
392 return Vec::new();
393 };
394 items
395 .iter()
396 .filter_map(|m| m.get("id").and_then(Value::as_str))
397 .map(str::to_owned)
398 .filter(|id| !id.is_empty())
399 .collect()
400}
401
402#[must_use]
408pub async fn discover_models(client: &Client, base_url: &str, api_key: &str) -> Vec<String> {
409 let url = format!("{}/models", base_url.trim_end_matches('/'));
410 let mut req = client
411 .get(&url)
412 .timeout(DISCOVERY_TIMEOUT)
413 .header("Accept", "application/json");
414 if !api_key.is_empty() {
415 req = req.bearer_auth(api_key);
416 }
417 let resp = match req.send().await {
418 Ok(resp) => resp,
419 Err(e) => {
420 tracing::debug!("discovery: {url} unreachable: {e}");
421 return Vec::new();
422 }
423 };
424 if !resp.status().is_success() {
425 tracing::debug!("discovery: {url} -> {}", resp.status());
426 return Vec::new();
427 }
428 match resp.json::<Value>().await {
429 Ok(body) => discovered_ids(&body),
430 Err(e) => {
431 tracing::debug!("discovery: {url} returned invalid JSON: {e}");
432 Vec::new()
433 }
434 }
435}
436
437#[must_use]
439pub fn build_body(
440 upstream_model: &str,
441 max_tokens: Option<u64>,
442 tools: &[ToolDefinition],
443 messages: Vec<Value>,
444 system: Option<String>,
445) -> Value {
446 let mut map = serde_json::Map::new();
447 map.insert("model".to_owned(), json!(upstream_model));
448 map.insert(
449 "messages".to_owned(),
450 Value::Array(with_system(messages, system)),
451 );
452 map.insert("stream".to_owned(), Value::Bool(true));
453 if let Some(max) = max_tokens {
454 map.insert("max_tokens".to_owned(), json!(max));
455 }
456 if !tools.is_empty() {
457 map.insert("tools".to_owned(), Value::Array(to_openai_tools(tools)));
458 }
459 Value::Object(map)
460}
461
462#[cfg(test)]
463mod tests {
464 use super::*;
465 use serde_json::json;
466
467 #[test]
468 fn parses_openai_list_shape() {
469 let body = json!({"data": [{"id": "a"}, {"id": "b"}]});
470 assert_eq!(discovered_ids(&body), vec!["a".to_owned(), "b".to_owned()]);
471 }
472
473 #[test]
474 fn parses_bare_array_and_skips_bad_entries() {
475 let body = json!([{"id": "a"}, {"other": 1}, {"id": ""}]);
476 assert_eq!(discovered_ids(&body), vec!["a".to_owned()]);
477 }
478
479 #[test]
480 fn rejects_unknown_shapes() {
481 assert_eq!(discovered_ids(&json!({"models": []})), Vec::<String>::new());
482 assert_eq!(discovered_ids(&Value::Null), Vec::<String>::new());
483 }
484}