Skip to main content

robonix_pilot/
vlm.rs

1// SPDX-License-Identifier: MulanPSL-2.0
2// Author: wheatfox <wheatfox17@icloud.com>
3//
4// Embedded OpenAI-compatible chat-completions client.
5// TODO: maybe we will support Google/Anthropic/etc. in the future :D
6use 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/// One message in an OpenAI Chat Completions conversation.
45/// Spec: <https://platform.openai.com/docs/api-reference/chat/create#chat/create-messages>
46///
47/// One struct, four roles (`system` / `user` / `assistant` / `tool`); each
48/// role uses a different subset of the optional fields. `skip_serializing_if`
49/// on every Option prunes irrelevant fields at serialization, so the wire
50/// JSON for each role only carries what OpenAI expects:
51///
52///   system    → role + content
53///   user      → role + content (+ optional `name` for multi-user)
54///   assistant → role + content (may be null when only tool_calls are emitted)
55///                              + optional tool_calls[]
56///   tool      → role + content + tool_call_id (must match an id in the
57///                                              preceding assistant.tool_calls)
58///
59/// We use a flat struct with optional fields rather than a tagged enum because
60/// the planner does a lot of generic `Vec<Message>` manipulation (trim, sanitize,
61/// sliding-window slicing) that's awkward to express through `match` on every
62/// access. Type-safety for "tool messages must have tool_call_id" is delegated
63/// to runtime checks (`history::sanitize_for_vlm`) and the OpenAI server's
64/// own validation.
65///
66/// `image_base64` is a robonix-side simplification, NOT part of the OpenAI
67/// wire format. Callers set it on a `user` message; `build_openai_messages`
68/// in this file repackages content + image into OpenAI's multimodal `content`
69/// array (`[{type:"text",...}, {type:"image_url",...}]`) at request time.
70#[derive(Serialize, Deserialize, Clone)]
71pub struct Message {
72    /// "system" / "user" / "assistant" / "tool". Determines which other
73    /// fields are meaningful; OpenAI rejects mismatched combinations.
74    pub role: String,
75
76    /// Optional sender name. Used by `user`/`assistant` for multi-user
77    /// disambiguation; rare in practice. Robonix doesn't set it today.
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub name: Option<String>,
80
81    /// Message text. Always present except on `assistant` messages whose
82    /// only output is tool calls (then None / null on the wire).
83    #[serde(skip_serializing_if = "Option::is_none")]
84    pub content: Option<String>,
85
86    /// Tool calls the LLM decided to make. Present only on `assistant`
87    /// messages. Each entry carries id + function.{name, arguments};
88    /// the corresponding `tool` message links back via `tool_call_id`.
89    #[serde(skip_serializing_if = "Option::is_none")]
90    pub tool_calls: Option<Vec<ToolCall>>,
91
92    /// Correlates a `tool` result back to the `assistant.tool_calls[].id`
93    /// that produced it. Required on `tool` messages; absent on others.
94    #[serde(skip_serializing_if = "Option::is_none")]
95    pub tool_call_id: Option<String>,
96
97    /// Inline image attached to a `user` message (base64-encoded JPEG
98    /// bytes). Robonix-only field; rewritten into OpenAI's multimodal
99    /// content array at serialize time by `build_openai_messages`.
100    #[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
185/// Item yielded by the chat completion stream. `planner.rs` matches on this
186/// enum to drive token streaming, tool dispatch, and finish handling.
187pub enum VlmStreamItem {
188    TextDelta(String),
189    ToolCall(ToolCall),
190    /// Stream complete. Finish reason ("stop" / "tool_calls" / "error") is
191    /// not surfaced to consumers yet — add a field here when the planner or
192    /// downstream PilotEvent grows a use for it.
193    Finish,
194}
195
196/// Direct HTTP client for an OpenAI-compatible chat-completions endpoint.
197/// Cheap to clone — `async_openai::Client` wraps a `reqwest::Client` (an
198/// `Arc<...>` internally). No mutex needed when sharing across tasks.
199#[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    /// Open a streaming chat completion. Yields:
218    ///   - `TextDelta` for every assistant content chunk
219    ///   - `ToolCall` once per accumulated function call (after the upstream
220    ///     finishes streaming all argument deltas)
221    ///   - one final `Finish`
222    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        // Walk the upstream chunk-by-chunk, accumulating tool-call deltas by
282        // index until the upstream finishes; then emit one ToolCall per index
283        // and a final Finish event. Use mpsc + spawn so we can return the
284        // boxed Stream while the polling runs in the background.
285        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; // surface to PilotEvent later if needed
353            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}