1use crate::config::VlmConfig;
7use anyhow::{Context, Result, bail};
8use async_openai::types::chat::{
9 ChatCompletionMessageToolCall, ChatCompletionMessageToolCalls,
10 ChatCompletionRequestAssistantMessageArgs, ChatCompletionRequestMessage,
11 ChatCompletionRequestMessageContentPartImage, ChatCompletionRequestMessageContentPartText,
12 ChatCompletionRequestSystemMessageArgs, ChatCompletionRequestToolMessageArgs,
13 ChatCompletionRequestUserMessageArgs, ChatCompletionRequestUserMessageContent,
14 ChatCompletionRequestUserMessageContentPart, ChatCompletionTool, ChatCompletionTools,
15 CreateChatCompletionRequestArgs, FunctionCall, FunctionObject, FunctionObjectArgs, ImageDetail,
16 ImageUrl, ResponseFormat,
17};
18use futures_util::stream::{Stream, StreamExt};
19use serde::{Deserialize, Serialize};
20use serde_json::Value;
21use std::collections::BTreeMap;
22use std::pin::Pin;
23use std::time::Duration;
24
25const MAX_OPEN_RETRIES: usize = 3;
26
27fn open_retry_delay(
28 status: reqwest::StatusCode,
29 retry_after: Option<&str>,
30 retry_index: usize,
31) -> Option<Duration> {
32 if retry_index >= MAX_OPEN_RETRIES
33 || !(status == reqwest::StatusCode::TOO_MANY_REQUESTS || status.is_server_error())
34 {
35 return None;
36 }
37 let server_seconds = retry_after
38 .and_then(|value| value.trim().parse::<u64>().ok())
39 .map(|seconds| seconds.clamp(1, 10));
40 let seconds = server_seconds.unwrap_or_else(|| 1_u64 << retry_index.min(3));
41 Some(Duration::from_secs(seconds))
42}
43
44#[derive(Serialize, Deserialize, Clone)]
71pub struct Message {
72 pub role: String,
75
76 #[serde(skip_serializing_if = "Option::is_none")]
79 pub name: Option<String>,
80
81 #[serde(skip_serializing_if = "Option::is_none")]
84 pub content: Option<String>,
85
86 #[serde(skip_serializing_if = "Option::is_none")]
90 pub tool_calls: Option<Vec<ToolCall>>,
91
92 #[serde(skip_serializing_if = "Option::is_none")]
95 pub tool_call_id: Option<String>,
96
97 #[serde(skip_serializing_if = "Option::is_none")]
101 pub image_base64: Option<String>,
102}
103
104#[derive(Serialize, Deserialize, Clone)]
105pub struct ToolCall {
106 pub id: String,
107 #[serde(rename = "type")]
108 pub kind: String,
109 pub function: FnCall,
110}
111
112#[derive(Serialize, Deserialize, Clone)]
113pub struct FnCall {
114 pub name: String,
115 pub arguments: String,
116}
117
118#[derive(Serialize, Clone)]
119pub struct ToolDef {
120 #[serde(rename = "type")]
121 kind: String,
122 function: FnDef,
123}
124
125#[derive(Serialize, Clone)]
126struct FnDef {
127 name: String,
128 description: String,
129 parameters: Value,
130}
131
132impl Message {
133 pub fn system(content: &str) -> Self {
134 Self {
135 role: "system".into(),
136 name: None,
137 content: Some(content.into()),
138 tool_calls: None,
139 tool_call_id: None,
140 image_base64: None,
141 }
142 }
143 pub fn user(content: &str) -> Self {
144 Self {
145 role: "user".into(),
146 name: None,
147 content: Some(content.into()),
148 tool_calls: None,
149 tool_call_id: None,
150 image_base64: None,
151 }
152 }
153 pub fn user_with_image(content: &str, image_base64: String) -> Self {
154 Self {
155 role: "user".into(),
156 name: None,
157 content: Some(content.into()),
158 tool_calls: None,
159 tool_call_id: None,
160 image_base64: Some(image_base64),
161 }
162 }
163 pub fn assistant(content: &str) -> Self {
164 Self {
165 role: "assistant".into(),
166 name: None,
167 content: Some(content.into()),
168 tool_calls: None,
169 tool_call_id: None,
170 image_base64: None,
171 }
172 }
173 pub fn tool_result(id: &str, content: &str) -> Self {
174 Self {
175 role: "tool".into(),
176 name: None,
177 content: Some(content.into()),
178 tool_calls: None,
179 tool_call_id: Some(id.into()),
180 image_base64: None,
181 }
182 }
183}
184
185pub enum VlmStreamItem {
188 TextDelta(String),
189 ToolCall(ToolCall),
190 Finish,
194}
195
196#[derive(Clone)]
200pub struct VlmClient {
201 inner: reqwest::Client,
202 api_base: String,
203 api_key: String,
204 model: String,
205}
206
207impl VlmClient {
208 pub fn new(cfg: &VlmConfig) -> Self {
209 Self {
210 inner: reqwest::Client::new(),
211 api_base: cfg.upstream.trim_end_matches('/').to_string(),
212 api_key: cfg.api_key.clone(),
213 model: cfg.model.clone(),
214 }
215 }
216
217 pub async fn chat_stream(
223 &self,
224 messages: &[Message],
225 tools: &[ToolDef],
226 ) -> Result<Pin<Box<dyn Stream<Item = Result<VlmStreamItem>> + Send>>> {
227 let oai_messages = build_openai_messages(messages)?;
228 let oai_tools = build_openai_tools(tools)?;
229
230 let mut req_builder = CreateChatCompletionRequestArgs::default();
231 req_builder
232 .model(&self.model)
233 .messages(oai_messages)
234 .stream(true)
235 .response_format(ResponseFormat::JsonObject);
236 if !oai_tools.is_empty() {
237 req_builder.tools(oai_tools);
238 }
239 let request = req_builder
240 .build()
241 .context("build chat completion request")?;
242
243 let url = format!("{}/chat/completions", self.api_base);
244 let mut retry_index = 0;
245 let response = loop {
246 let response = self
247 .inner
248 .post(&url)
249 .bearer_auth(&self.api_key)
250 .header(reqwest::header::ACCEPT, "text/event-stream")
251 .header(reqwest::header::CONTENT_TYPE, "application/json")
252 .json(&request)
253 .send()
254 .await
255 .context("open VLM chat stream")?;
256 let status = response.status();
257 if status.is_success() {
258 break response;
259 }
260 let retry_after = response
261 .headers()
262 .get(reqwest::header::RETRY_AFTER)
263 .and_then(|value| value.to_str().ok())
264 .map(str::to_string);
265 let text = response.text().await.unwrap_or_default();
266 if let Some(delay) = open_retry_delay(status, retry_after.as_deref(), retry_index) {
267 robonix_scribe::warn!(
268 "[pilot/vlm] open stream HTTP {status}; retry {}/{} in {:.1}s",
269 retry_index + 1,
270 MAX_OPEN_RETRIES,
271 delay.as_secs_f64()
272 );
273 tokio::time::sleep(delay).await;
274 retry_index += 1;
275 continue;
276 }
277 bail!("open VLM chat stream: HTTP {status}: {text}");
278 };
279 let mut upstream = response.bytes_stream();
280
281 let (tx, rx) = tokio::sync::mpsc::channel::<Result<VlmStreamItem>>(64);
286 tokio::spawn(async move {
287 let mut tc_acc: BTreeMap<u32, AccumulatedToolCall> = BTreeMap::new();
288 let mut finish = "stop".to_string();
289 let mut buf = String::new();
290 let mut done = false;
291 while !done {
292 let Some(chunk) = upstream.next().await else {
293 break;
294 };
295 match chunk {
296 Ok(bytes) => {
297 buf.push_str(&String::from_utf8_lossy(&bytes));
298 while let Some(pos) = buf.find('\n') {
299 let line: String = buf.drain(..=pos).collect();
300 match process_stream_line(
301 line.trim_end(),
302 &mut tc_acc,
303 &mut finish,
304 &tx,
305 )
306 .await
307 {
308 Ok(true) => {
309 done = true;
310 break;
311 }
312 Ok(false) => {}
313 Err(e) => {
314 let _ = tx.send(Err(e)).await;
315 return;
316 }
317 }
318 }
319 }
320 Err(e) => {
321 let _ = tx
322 .send(Err(anyhow::anyhow!("VLM stream chunk error: {e}")))
323 .await;
324 return;
325 }
326 }
327 }
328 if !buf.trim().is_empty()
329 && let Err(e) =
330 process_stream_line(buf.trim_end(), &mut tc_acc, &mut finish, &tx).await
331 {
332 let _ = tx.send(Err(e)).await;
333 return;
334 }
335
336 for (_, tc) in tc_acc {
337 if tc.id.is_empty() && tc.name.is_empty() {
338 continue;
339 }
340 let item = VlmStreamItem::ToolCall(ToolCall {
341 id: tc.id,
342 kind: "function".to_string(),
343 function: FnCall {
344 name: tc.name,
345 arguments: tc.arguments,
346 },
347 });
348 if tx.send(Ok(item)).await.is_err() {
349 return;
350 }
351 }
352 let _ = finish; let _ = tx.send(Ok(VlmStreamItem::Finish)).await;
354 });
355
356 Ok(Box::pin(tokio_stream::wrappers::ReceiverStream::new(rx)))
357 }
358}
359
360#[cfg(test)]
361mod tests {
362 use super::{MAX_OPEN_RETRIES, open_retry_delay};
363 use std::time::Duration;
364
365 #[test]
366 fn transient_open_errors_use_bounded_backoff() {
367 assert_eq!(
368 open_retry_delay(reqwest::StatusCode::TOO_MANY_REQUESTS, None, 0),
369 Some(Duration::from_secs(1))
370 );
371 assert_eq!(
372 open_retry_delay(reqwest::StatusCode::SERVICE_UNAVAILABLE, Some("7"), 1),
373 Some(Duration::from_secs(7))
374 );
375 assert_eq!(
376 open_retry_delay(reqwest::StatusCode::BAD_REQUEST, None, 0),
377 None
378 );
379 assert_eq!(
380 open_retry_delay(
381 reqwest::StatusCode::TOO_MANY_REQUESTS,
382 None,
383 MAX_OPEN_RETRIES
384 ),
385 None
386 );
387 }
388}
389
390#[derive(Default)]
391struct AccumulatedToolCall {
392 id: String,
393 name: String,
394 arguments: String,
395}
396
397async fn process_stream_line(
398 line: &str,
399 tc_acc: &mut BTreeMap<u32, AccumulatedToolCall>,
400 finish: &mut String,
401 tx: &tokio::sync::mpsc::Sender<Result<VlmStreamItem>>,
402) -> Result<bool> {
403 let line = line.trim();
404 if line.is_empty() || line.starts_with(':') {
405 return Ok(false);
406 }
407 let Some(data) = line.strip_prefix("data:") else {
408 return Ok(false);
409 };
410 let data = data.trim();
411 if data == "[DONE]" {
412 return Ok(true);
413 }
414
415 let v: Value = serde_json::from_str(data)
416 .with_context(|| format!("deserialize VLM stream chunk: {data}"))?;
417 let Some(choice) = v
418 .get("choices")
419 .and_then(Value::as_array)
420 .and_then(|choices| choices.first())
421 else {
422 return Ok(false);
423 };
424
425 if let Some(content) = choice
426 .get("delta")
427 .and_then(|delta| delta.get("content"))
428 .and_then(Value::as_str)
429 && !content.is_empty()
430 && tx
431 .send(Ok(VlmStreamItem::TextDelta(content.to_string())))
432 .await
433 .is_err()
434 {
435 return Ok(true);
436 }
437 if let Some(tc_chunks) = choice
438 .get("delta")
439 .and_then(|delta| delta.get("tool_calls"))
440 .and_then(Value::as_array)
441 {
442 for tc in tc_chunks {
443 let index = tc.get("index").and_then(Value::as_u64).unwrap_or(0) as u32;
444 let entry = tc_acc.entry(index).or_default();
445 if let Some(id) = tc.get("id").and_then(Value::as_str) {
446 entry.id = id.to_string();
447 }
448 if let Some(func) = tc.get("function") {
449 if let Some(name) = func.get("name").and_then(Value::as_str) {
450 entry.name.push_str(name);
451 }
452 if let Some(args) = func.get("arguments").and_then(Value::as_str) {
453 entry.arguments.push_str(args);
454 }
455 }
456 }
457 }
458 if let Some(fr) = choice.get("finish_reason").and_then(Value::as_str) {
459 *finish = fr.to_string();
460 }
461 Ok(false)
462}
463
464fn build_openai_messages(messages: &[Message]) -> Result<Vec<ChatCompletionRequestMessage>> {
465 let mut out = Vec::with_capacity(messages.len());
466 for m in messages {
467 let msg = match m.role.as_str() {
468 "system" => ChatCompletionRequestSystemMessageArgs::default()
469 .content(m.content.clone().unwrap_or_default())
470 .build()?
471 .into(),
472 "user" => {
473 if let Some(image) = &m.image_base64 {
474 let text = m.content.clone().unwrap_or_default();
475 let mut parts: Vec<ChatCompletionRequestUserMessageContentPart> = Vec::new();
476 if !text.is_empty() {
477 parts.push(ChatCompletionRequestUserMessageContentPart::Text(
478 ChatCompletionRequestMessageContentPartText { text },
479 ));
480 }
481 let url = format!("data:image/jpeg;base64,{image}");
482 parts.push(ChatCompletionRequestUserMessageContentPart::ImageUrl(
483 ChatCompletionRequestMessageContentPartImage {
484 image_url: ImageUrl {
485 url,
486 detail: Some(ImageDetail::Auto),
487 },
488 },
489 ));
490 ChatCompletionRequestUserMessageArgs::default()
491 .content(ChatCompletionRequestUserMessageContent::Array(parts))
492 .build()?
493 .into()
494 } else {
495 ChatCompletionRequestUserMessageArgs::default()
496 .content(m.content.clone().unwrap_or_default())
497 .build()?
498 .into()
499 }
500 }
501 "assistant" => {
502 let mut b = ChatCompletionRequestAssistantMessageArgs::default();
503 if let Some(c) = &m.content
504 && !c.is_empty()
505 {
506 b.content(c.clone());
507 }
508 if let Some(tcs) = &m.tool_calls {
509 let oai_tcs: Vec<ChatCompletionMessageToolCalls> = tcs
510 .iter()
511 .map(|tc| {
512 ChatCompletionMessageToolCalls::Function(
513 ChatCompletionMessageToolCall {
514 id: tc.id.clone(),
515 function: FunctionCall {
516 name: tc.function.name.clone(),
517 arguments: tc.function.arguments.clone(),
518 },
519 },
520 )
521 })
522 .collect();
523 b.tool_calls(oai_tcs);
524 }
525 b.build()?.into()
526 }
527 "tool" => {
528 let id = m.tool_call_id.clone().unwrap_or_default();
529 ChatCompletionRequestToolMessageArgs::default()
530 .tool_call_id(id)
531 .content(m.content.clone().unwrap_or_default())
532 .build()?
533 .into()
534 }
535 other => anyhow::bail!("unknown message role '{other}'"),
536 };
537 out.push(msg);
538 }
539 Ok(out)
540}
541
542fn build_openai_tools(tools: &[ToolDef]) -> Result<Vec<ChatCompletionTools>> {
543 tools
544 .iter()
545 .map(|t| -> Result<ChatCompletionTools> {
546 let func: FunctionObject = FunctionObjectArgs::default()
547 .name(&t.function.name)
548 .description(&t.function.description)
549 .parameters(t.function.parameters.clone())
550 .build()?;
551 Ok(ChatCompletionTools::Function(ChatCompletionTool {
552 function: func,
553 }))
554 })
555 .collect()
556}