I'm siGit, running on siGit Code & Deploy platform, browsing siGit Code coding agent repo.

1 //! Inference backend abstraction.
2 //!
3 //! The agent loop only needs to send a turn (optionally with tools) and return
4 //! tool results. This module defines that seam as the `InferenceBackend` trait
5 //! plus a few neutral types, with two implementations:
6 //!
7 //! - `LocalBackend` runs on-device through the `onde` crate (`ChatEngine`).
8 //! - `OpenAiBackend` talks to any OpenAI-compatible HTTP endpoint, configured by
9 //! `base_url`, `api_key`, and `model`.
10 //!
11 //! The trait exposes neither `onde` nor OpenAI types, so the loop does not depend
12 //! on a specific backend.
13 //!
14 //! The seam is consumed by both surfaces: the interactive client (`#[cfg(unix)]`,
15 //! see `run_interactive` in `main.rs` and `mod tui` in `chat.rs`) and the ACP
16 //! server's prompt loop. Some items are still reached only through the
17 //! Unix-only interactive paths, so the dead-code lint stays suppressed on
18 //! non-Unix targets only — Unix builds keep full coverage.
19 #![cfg_attr(not(unix), allow(dead_code))]
20
21 use std::sync::Arc;
22 use std::sync::atomic::{AtomicBool, Ordering};
23
24 use async_trait::async_trait;
25 use onde::inference::{ChatEngine, ChatMessage, ChatRole, EngineStatus, ToolDefinition};
26 use serde::Deserialize;
27 use tokio::sync::Mutex;
28
29 // ── Neutral types ───────────────────────────────────────────────────────────────
30
31 /// A tool the model may call, in a provider-neutral form. `parameters_schema` is
32 /// a JSON Schema encoded as a string (matching how siGit Code already declares
33 /// tools).
34 #[derive(Debug, Clone)]
35 pub struct ToolSpec {
36 pub name: String,
37 pub description: String,
38 pub parameters_schema: String,
39 }
40
41 /// A tool call requested by the model.
42 #[derive(Debug, Clone)]
43 pub struct ToolCall {
44 pub id: String,
45 pub name: String,
46 /// Arguments as a JSON-encoded string.
47 pub arguments: String,
48 }
49
50 /// The output of executing one tool call, fed back to the model.
51 #[derive(Debug, Clone)]
52 pub struct ToolResult {
53 pub tool_call_id: String,
54 pub content: String,
55 }
56
57 /// The result of one assistant turn: free text and/or tool calls.
58 #[derive(Debug, Clone, Default)]
59 pub struct TurnResult {
60 pub text: String,
61 pub tool_calls: Vec<ToolCall>,
62 /// Why the model stopped. Only the last round of a turn decides how the
63 /// turn is reported; a round that goes on to run tools is not the end.
64 pub finish: FinishReason,
65 /// The context in use once this reply is in, as the endpoint counted it:
66 /// prompt plus completion tokens. `None` when nothing was reported, which
67 /// is always the case on-device.
68 pub context_tokens: Option<u64>,
69 }
70
71 /// Why the model stopped generating, reduced to what the agent loop acts on.
72 /// Endpoints report it as `finish_reason`; without it a reply cut off at the
73 /// token limit is indistinguishable from one the model finished.
74 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
75 pub enum FinishReason {
76 /// The model finished on its own: `stop`, `tool_calls`, or no reason given.
77 #[default]
78 Complete,
79 /// The output ran into the token limit, so the reply is cut short.
80 Length,
81 /// The endpoint withheld the reply instead of answering.
82 ContentFilter,
83 /// The harness cut the reply off because it had degenerated into
84 /// repetition (see `repetition`).
85 Repetition,
86 }
87
88 impl FinishReason {
89 /// Map a wire `finish_reason`. OpenAI's spellings are `length` and
90 /// `content_filter`; gateways in front of other providers sometimes pass
91 /// the upstream's own word through, so those are accepted too.
92 fn from_wire(reason: Option<&str>) -> Self {
93 match reason {
94 Some("length" | "max_tokens") => Self::Length,
95 Some("content_filter" | "refusal") => Self::ContentFilter,
96 _ => Self::Complete,
97 }
98 }
99 }
100
101 /// Backend errors are plain strings. Callers map them to ACP errors.
102 pub type BackendError = String;
103
104 /// Rough context budget for a conversation, in estimated tokens (see
105 /// [`estimate_tokens`]). When a snapshot exceeds this, the agent loops compact
106 /// history before the next tool round.
107 pub const DEFAULT_CONTEXT_TOKEN_BUDGET: usize = 24_000;
108
109 /// How many trailing messages survive a compaction verbatim (the rest are
110 /// folded into the summary).
111 pub const COMPACT_KEEP_LAST: usize = 6;
112
113 /// The summarization request sent to the model when compacting history.
114 const SUMMARIZE_PROMPT: &str = "Summarize this coding session so far: decisions made, \
115 files touched, current state, open items. Be concise and factual.";
116
117 /// Largest transcript (in estimated tokens, see [`estimate_tokens`]) sent to
118 /// the model for summarization in one go.
119 ///
120 /// Compaction runs because the conversation has outgrown the context budget,
121 /// so shipping the whole transcript verbatim asks the endpoint to process
122 /// exactly the payload that is already too big — on siGit Code Cloud that
123 /// request sits in inference until the gateway gives up (504), which is the
124 /// failure behind issue #125. Capping the transcript keeps the summarization
125 /// round inside the window the model actually has; the oldest and newest
126 /// exchanges carry the intent and the current state, while the truncated
127 /// middle is the part a summary can afford to lose.
128 const SUMMARY_TRANSCRIPT_TOKEN_CAP: usize = 12_000;
129
130 /// Trim `transcript` to about `cap` estimated tokens by cutting whole lines
131 /// from the middle, keeping the opening and closing exchanges.
132 ///
133 /// A middle cut preserves the two ends that matter most to a summary: how the
134 /// session started (the original request) and where it stands now. Cutting by
135 /// whole lines keeps individual messages intact. A transcript already under
136 /// the cap is returned unchanged.
137 ///
138 /// Line boundaries are best-effort. A head with no newline in its half is
139 /// dropped and the tail keeps its own half. A tail with no newline in its half
140 /// (a single huge message, or a giant final tool result) can't be cut on a
141 /// line, so the newest `cap` worth of text is kept instead and the head goes:
142 /// the newest exchanges are the ones the summary can least afford to lose.
143 fn truncate_transcript_middle(transcript: &str, cap: usize) -> String {
144 let budget = cap * 4; // estimate_tokens is chars / 4; invert it.
145 if transcript.len() <= budget {
146 return transcript.to_string();
147 }
148
149 let total = transcript.len();
150 // Each side gets half the budget, so the two together stay inside the cap.
151 // Walk each half to a line boundary. A head with no boundary in reach
152 // contributes nothing; a tail with none takes the hard cut below.
153 let half = budget / 2;
154 let head_end = {
155 let mut cut = half.min(total);
156 while cut < total && !transcript.is_char_boundary(cut) {
157 cut += 1;
158 }
159 cut = cut.min(total);
160 if transcript[..cut].contains('\n') {
161 transcript[..cut].rfind('\n').unwrap() + 1
162 } else {
163 0
164 }
165 };
166 let tail_start = {
167 let mut cut = total.saturating_sub(half);
168 while cut < total && !transcript.is_char_boundary(cut) {
169 cut += 1;
170 }
171 if transcript[cut..].contains('\n') {
172 transcript[cut..]
173 .find('\n')
174 .map(|offset| cut + offset + 1)
175 .unwrap()
176 } else {
177 total
178 }
179 };
180
181 if tail_start == total {
182 // The closing exchange has no line boundary in reach (a transcript of
183 // one huge message, or a giant newline-less tool result at the end):
184 // keep the newest budget. A head-only cut would invert the contract
185 // while the placeholder claims older messages were dropped.
186 let mut start = total.saturating_sub(budget);
187 while start > 0 && !transcript.is_char_boundary(start) {
188 start -= 1;
189 }
190 return format!(
191 "{}\n[…older messages omitted…]",
192 transcript[start..].trim_start()
193 );
194 }
195
196 if head_end >= tail_start {
197 // No line boundary in reach anywhere: keep the newest budget.
198 let mut start = total.saturating_sub(budget);
199 while start > 0 && !transcript.is_char_boundary(start) {
200 start -= 1;
201 }
202 return format!(
203 "{}\n[…older messages omitted…]",
204 transcript[start..].trim_start()
205 );
206 }
207
208 let omitted_chars = transcript[head_end..tail_start].chars().count();
209 format!(
210 "{}\n[…{} characters of older messages omitted…]\n{}",
211 transcript[..head_end].trim_end(),
212 omitted_chars,
213 transcript[tail_start..].trim_start(),
214 )
215 }
216
217 /// Render a history snapshot as a plain-text transcript, with tool calls and
218 /// tool results spelled out as prose rather than left in their wire shapes.
219 ///
220 /// Compaction summarizes a conversation that is, by definition, thick with
221 /// `tool_calls` and `role: "tool"` messages — but the summarization round asks
222 /// for a plain answer and so sends no `tools` array. Forwarding the raw shapes
223 /// in that request produces tool blocks with no schema to validate against,
224 /// which strict endpoints reject outright: Anthropic answers 400, so every
225 /// compaction of a session that had ever run a tool failed, permanently, no
226 /// matter how small the history was. Flattening to text keeps everything the
227 /// summary actually needs and drops the shapes that only make sense alongside
228 /// a tool schema. It also sidesteps orphaned `tool_call_id`s and role-
229 /// alternation rules, neither of which a transcript can violate.
230 fn transcript_for_summary(history: &[serde_json::Value]) -> String {
231 let mut lines: Vec<String> = Vec::new();
232 for message in history {
233 let role = message["role"].as_str().unwrap_or("user");
234 // The system prompt is carried over verbatim, so it needn't be summarized.
235 if role == "system" {
236 continue;
237 }
238
239 let mut parts: Vec<String> = Vec::new();
240 let text = message_text(message);
241 if !text.trim().is_empty() {
242 parts.push(text);
243 }
244 // A summary cannot carry a picture, but it should say one was there.
245 match message_image_count(message) {
246 0 => {}
247 1 => parts.push("[attached an image]".to_string()),
248 count => parts.push(format!("[attached {count} images]")),
249 }
250 for call in message["tool_calls"].as_array().into_iter().flatten() {
251 parts.push(format!(
252 "called {}({})",
253 call["function"]["name"].as_str().unwrap_or("tool"),
254 call["function"]["arguments"].as_str().unwrap_or_default(),
255 ));
256 }
257 if parts.is_empty() {
258 continue;
259 }
260
261 let label = if role == "tool" { "tool result" } else { role };
262 lines.push(format!("{label}: {}", parts.join("\n")));
263 }
264 lines.join("\n\n")
265 }
266
267 /// Crude token estimate for a history snapshot: serialized characters / 4.
268 /// Deliberately model-agnostic — it only needs to be in the right ballpark to
269 /// decide when compaction is worth an extra inference round.
270 pub fn estimate_tokens(history: &[serde_json::Value]) -> usize {
271 let mut chars = 0;
272 let mut images = 0;
273 for message in history {
274 let image_count = message_image_count(message);
275 if image_count == 0 {
276 chars += message.to_string().chars().count();
277 } else {
278 // An image travels as base64, megabytes of it, but a model bills
279 // it by resolution. Counting the payload as text would put every
280 // session with one screenshot permanently over budget.
281 chars += message_text(message).chars().count();
282 images += image_count;
283 }
284 }
285 chars / 4 + images * IMAGE_TOKEN_ESTIMATE
286 }
287
288 /// What one attached image is assumed to cost. Providers land between a few
289 /// hundred and a couple of thousand tokens depending on resolution; like the
290 /// rest of [`estimate_tokens`] this only has to be in the right ballpark.
291 const IMAGE_TOKEN_ESTIMATE: usize = 1_000;
292
293 /// The text of a history message. `content` is a plain string for almost every
294 /// message; a user message that carries an image uses OpenAI's content-part
295 /// array instead, and its text parts are joined here.
296 pub fn message_text(message: &serde_json::Value) -> String {
297 match &message["content"] {
298 serde_json::Value::String(text) => text.clone(),
299 serde_json::Value::Array(parts) => parts
300 .iter()
301 .filter_map(|part| part["text"].as_str())
302 .collect::<Vec<_>>()
303 .join("\n"),
304 _ => String::new(),
305 }
306 }
307
308 /// How many content parts of `part_type` a history message carries.
309 fn message_part_count(message: &serde_json::Value, part_type: &str) -> usize {
310 message["content"]
311 .as_array()
312 .map(|parts| {
313 parts
314 .iter()
315 .filter(|part| part["type"] == part_type)
316 .count()
317 })
318 .unwrap_or(0)
319 }
320
321 /// How many images a history message carries as `image_url` content parts.
322 pub fn message_image_count(message: &serde_json::Value) -> usize {
323 message_part_count(message, "image_url")
324 }
325
326 /// How many audio clips a history message carries as `input_audio` content
327 /// parts.
328 pub fn message_audio_count(message: &serde_json::Value) -> usize {
329 message_part_count(message, "input_audio")
330 }
331
332 /// One streamed fragment, tagged by whether it is visible prose or the model's
333 /// reasoning.
334 ///
335 /// Reasoning reaches a backend two ways: inline `<think>…</think>` tags inside
336 /// the ordinary content stream (local models, and some OpenAI-compatible
337 /// endpoints), or a separate `reasoning_content` delta field (DeepSeek-style
338 /// endpoints). Inline tags can't be classified at the source — a tag may span
339 /// chunk boundaries — so they travel as [`TokenChunk::Visible`] and are split
340 /// out downstream by `StreamedReply`. The `reasoning_content` field *is*
341 /// unambiguous at the source, so the backend tags it [`TokenChunk::Reasoning`]
342 /// directly.
343 #[derive(Debug, Clone, PartialEq, Eq)]
344 pub enum TokenChunk {
345 /// Content-stream text. May still contain inline `<think>` tags that a
346 /// downstream consumer splits into reasoning and prose.
347 Visible(String),
348 /// Text from a dedicated reasoning channel, already known to be reasoning.
349 Reasoning(String),
350 }
351
352 /// A sink for streaming assistant fragments to the UI as they are produced.
353 ///
354 /// When a caller passes `Some(sink)`, a streaming-capable backend forwards each
355 /// fragment through it as the model emits it, tagged [`TokenChunk::Visible`] or
356 /// [`TokenChunk::Reasoning`]; the returned [`TurnResult`] still carries the
357 /// fully assembled (visible) text and any tool calls. When the sink is `None`,
358 /// the backend runs in non-streaming mode. Unbounded so the inference task
359 /// never blocks on a slow consumer.
360 pub type TokenSink = tokio::sync::mpsc::UnboundedSender<TokenChunk>;
361
362 /// An image attached to a user message: base64 data and its media type, as an
363 /// ACP client sends it.
364 #[derive(Debug, Clone, PartialEq, Eq)]
365 pub struct ImageInput {
366 pub mime_type: String,
367 pub data: String,
368 }
369
370 /// An audio clip attached to a user message: base64 data and its media type,
371 /// as an ACP client sends it.
372 #[derive(Debug, Clone, PartialEq, Eq)]
373 pub struct AudioInput {
374 pub mime_type: String,
375 pub data: String,
376 }
377
378 impl AudioInput {
379 /// The `format` OpenAI's `input_audio` part expects (`wav`, `mp3`, ...),
380 /// from the MIME type an ACP client sends (`audio/wav`, `audio/mpeg`, ...).
381 fn format(&self) -> String {
382 let subtype = self
383 .mime_type
384 .split(';')
385 .next()
386 .unwrap_or_default()
387 .trim()
388 .to_lowercase();
389 let subtype = subtype.strip_prefix("audio/").unwrap_or(&subtype);
390 match subtype.strip_prefix("x-").unwrap_or(subtype) {
391 "mpeg" | "mp3" => "mp3".to_string(),
392 "wave" | "wav" | "vnd.wave" => "wav".to_string(),
393 other => other.to_string(),
394 }
395 }
396 }
397
398 /// What a model that cannot read images is shown in place of one.
399 const IMAGE_OMITTED_NOTE: &str = "[image omitted: this model cannot read images]";
400
401 /// What a model that cannot take audio is shown in place of a clip.
402 const AUDIO_OMITTED_NOTE: &str = "[audio omitted: this model cannot take audio]";
403
404 /// A user message in history form. Plain text keeps the string `content` every
405 /// other message uses; with attachments it becomes OpenAI's content-part
406 /// array, text first, each image as a base64 `data:` URL and each audio clip
407 /// as an `input_audio` part.
408 fn user_message(text: &str, images: &[ImageInput], audio: &[AudioInput]) -> serde_json::Value {
409 if images.is_empty() && audio.is_empty() {
410 return serde_json::json!({ "role": "user", "content": text });
411 }
412 let mut parts = Vec::with_capacity(images.len() + audio.len() + 1);
413 if !text.is_empty() {
414 parts.push(serde_json::json!({ "type": "text", "text": text }));
415 }
416 for image in images {
417 parts.push(serde_json::json!({
418 "type": "image_url",
419 "image_url": {
420 "url": format!("data:{};base64,{}", image.mime_type, image.data),
421 },
422 }));
423 }
424 for clip in audio {
425 parts.push(serde_json::json!({
426 "type": "input_audio",
427 "input_audio": { "data": clip.data, "format": clip.format() },
428 }));
429 }
430 serde_json::json!({ "role": "user", "content": parts })
431 }
432
433 /// `history` with every content part of `part_type` replaced by `note`, for a
434 /// model that cannot take that kind of attachment. A message left with only
435 /// text is flattened back to a plain string.
436 ///
437 /// History itself keeps the attachments. A thread can move to a model that
438 /// reads them (or back to one), so what was attached is not thrown away just
439 /// because the model active right now cannot use it.
440 fn without_parts(
441 history: &[serde_json::Value],
442 part_type: &str,
443 note: &str,
444 ) -> Vec<serde_json::Value> {
445 history
446 .iter()
447 .map(|message| {
448 let Some(parts) = message["content"].as_array() else {
449 return message.clone();
450 };
451 if !parts.iter().any(|part| part["type"] == part_type) {
452 return message.clone();
453 }
454 let mut kept: Vec<serde_json::Value> = Vec::with_capacity(parts.len());
455 let mut notes = Vec::new();
456 for part in parts {
457 if part["type"] == part_type {
458 notes.push(serde_json::json!({ "type": "text", "text": note }));
459 } else {
460 kept.push(part.clone());
461 }
462 }
463 kept.extend(notes);
464 let mut stripped = message.clone();
465 stripped["content"] = if kept.iter().all(|part| part["type"] == "text") {
466 let text = kept
467 .iter()
468 .filter_map(|part| part["text"].as_str())
469 .filter(|text| !text.is_empty())
470 .collect::<Vec<_>>()
471 .join("\n");
472 serde_json::Value::String(text)
473 } else {
474 serde_json::Value::Array(kept)
475 };
476 stripped
477 })
478 .collect()
479 }
480
481 /// `history` as a model that cannot read images has to receive it, with a
482 /// note where each image was.
483 fn without_images(history: &[serde_json::Value]) -> Vec<serde_json::Value> {
484 without_parts(history, "image_url", IMAGE_OMITTED_NOTE)
485 }
486
487 /// `history` as a model that cannot take audio has to receive it, with a note
488 /// where each clip was.
489 fn without_audio(history: &[serde_json::Value]) -> Vec<serde_json::Value> {
490 without_parts(history, "input_audio", AUDIO_OMITTED_NOTE)
491 }
492
493 // ── The trait ───────────────────────────────────────────────────────────────────
494
495 /// A swappable inference backend driving siGit Code's agent loop.
496 #[async_trait]
497 pub trait InferenceBackend: Send + Sync {
498 /// Start an assistant turn from a new user message, offering `tools`.
499 ///
500 /// If `sink` is `Some`, text is streamed through it as it is generated. A
501 /// backend may decline to stream a given round (for example, on-device
502 /// inference cannot stream while it is still deciding whether to call a
503 /// tool); in that case the text is delivered only via the returned result.
504 async fn send_message_with_tools(
505 &self,
506 text: &str,
507 tools: &[ToolSpec],
508 sink: Option<&TokenSink>,
509 ) -> Result<TurnResult, BackendError>;
510
511 /// Continue the turn by returning tool results. `allow_tool_calls` controls
512 /// whether `tools` are offered again; the complete catalog remains available
513 /// so a disabled round can recognize and suppress tool-shaped model output.
514 /// `sink` streams assistant text when set.
515 async fn send_tool_results(
516 &self,
517 results: Vec<ToolResult>,
518 tools: &[ToolSpec],
519 allow_tool_calls: bool,
520 sink: Option<&TokenSink>,
521 ) -> Result<TurnResult, BackendError>;
522
523 /// Record tool results in the conversation history *without* asking the
524 /// model to continue the turn. Used when a turn is abandoned mid-round
525 /// (the user cancelled at the permission gate): by then the assistant
526 /// message carrying the tool calls is already in history, and leaving them
527 /// unanswered makes strict OpenAI-compatible endpoints reject every later
528 /// request in the session.
529 async fn record_cancelled_tool_results(&self, results: Vec<ToolResult>);
530
531 /// Start a turn from a user message that carries images or audio.
532 ///
533 /// The default drops the attachments and sends the text, which is right
534 /// for a backend that cannot read them. Callers check
535 /// [`Self::accepts_images`] and [`Self::accepts_audio`] first so the user
536 /// can be told, instead of the attachment vanishing.
537 async fn send_message_with_attachments(
538 &self,
539 text: &str,
540 images: &[ImageInput],
541 audio: &[AudioInput],
542 tools: &[ToolSpec],
543 sink: Option<&TokenSink>,
544 ) -> Result<TurnResult, BackendError> {
545 let _ = (images, audio);
546 self.send_message_with_tools(text, tools, sink).await
547 }
548
549 /// Whether the model behind this backend reads images. On-device models do
550 /// not; a remote one answers from its model id.
551 fn accepts_images(&self) -> bool {
552 false
553 }
554
555 /// Whether the model behind this backend takes audio. On-device models do
556 /// not; a remote one answers from its model id.
557 fn accepts_audio(&self) -> bool {
558 false
559 }
560
561 /// Whether inference runs over the network (a configured provider) rather
562 /// than on-device. Drives UI labelling so the displayed model can't claim a
563 /// local model while requests actually go to the cloud.
564 fn is_remote(&self) -> bool;
565
566 /// A backend for the same endpoint and model with an empty conversation,
567 /// or `None` when the conversation cannot be separated from the backend
568 /// (on-device, the engine holds it). An editor keeps several threads open
569 /// in one process; this is what lets each of them own its history instead
570 /// of taking turns on a shared one.
571 fn fresh(&self) -> Option<Arc<dyn InferenceBackend>> {
572 None
573 }
574
575 /// A serializable snapshot of the conversation history, one JSON object per
576 /// message (`{"role": ..., "content": ...}` at minimum). The snapshot is
577 /// what the session store persists; it includes any seeded system message
578 /// so [`InferenceBackend::restore_history`] can replace state wholesale.
579 async fn history_snapshot(&self) -> Vec<serde_json::Value>;
580
581 /// Replace the conversation history with a previously saved snapshot.
582 /// Backends that cannot represent every entry (e.g. on-device history has
583 /// no tool-call structure) flatten what they can and drop the rest.
584 async fn restore_history(&self, history: Vec<serde_json::Value>);
585
586 /// Shrink the conversation history: summarize everything so far with one
587 /// extra (non-streaming) inference round, then rebuild history as
588 /// `[system message, summary, last keep_last non-system messages]`. When
589 /// the summarization round itself fails, a backend should fall back to a
590 /// deterministic shrink (dropping the oldest messages) rather than leave
591 /// an over-budget session stuck; an `Err` here means even that fallback
592 /// could not fit the history in the budget.
593 async fn compact_history(&self, keep_last: usize) -> Result<(), BackendError>;
594 }
595
596 // ── Local backend (onde ChatEngine) ──────────────────────────────────────────────
597
598 /// On-device inference. A thin adapter over `onde::ChatEngine`.
599 pub struct LocalBackend {
600 engine: Arc<ChatEngine>,
601 /// History restored before an on-device model is loaded. Onde ignores
602 /// `push_history` while unloaded, so keep the snapshot here until the
603 /// engine can accept it instead of silently dropping a loaded session.
604 pending_history: Mutex<Option<Vec<serde_json::Value>>>,
605 }
606
607 impl LocalBackend {
608 pub fn new(engine: Arc<ChatEngine>) -> Self {
609 Self {
610 engine,
611 pending_history: Mutex::new(None),
612 }
613 }
614
615 async fn apply_pending_history(&self) -> Result<(), BackendError> {
616 if self.pending_history.lock().await.is_none() {
617 return Ok(());
618 }
619 let status = self.engine.info().await.status;
620 if status != EngineStatus::Ready {
621 return Err(format!(
622 "on-device model is not ready to restore session history (status: {status})"
623 ));
624 }
625 let Some(history) = self.pending_history.lock().await.take() else {
626 return Ok(());
627 };
628
629 self.engine.clear_history().await;
630 for entry in history {
631 let role = entry["role"].as_str().unwrap_or("");
632 // The on-device engine keeps text only, so an image attached
633 // while a cloud model was active stays behind here.
634 let content = message_text(&entry);
635 // Tool-call-only assistant entries and empty tool results carry no
636 // text a plain chat history can replay; drop them.
637 if content.is_empty() && role != "user" && role != "system" {
638 continue;
639 }
640 let message = match role {
641 "system" => ChatMessage::system(content),
642 "user" => ChatMessage::user(content),
643 "assistant" => ChatMessage::assistant(content),
644 // Tool results flatten to plain text (MVP; acceptable loss).
645 "tool" => ChatMessage::user(format!("[tool result]\n{content}")),
646 _ => continue,
647 };
648 self.engine.push_history(message).await;
649 }
650 Ok(())
651 }
652 }
653
654 fn to_onde_tools(tools: &[ToolSpec]) -> Vec<ToolDefinition> {
655 tools
656 .iter()
657 .map(|tool| ToolDefinition {
658 name: tool.name.clone(),
659 description: tool.description.clone(),
660 parameters_schema: tool.parameters_schema.clone(),
661 })
662 .collect()
663 }
664
665 #[async_trait]
666 impl InferenceBackend for LocalBackend {
667 async fn send_message_with_tools(
668 &self,
669 text: &str,
670 tools: &[ToolSpec],
671 sink: Option<&TokenSink>,
672 ) -> Result<TurnResult, BackendError> {
673 self.apply_pending_history().await?;
674 // onde's tool-aware path is non-streaming: it has to buffer the whole
675 // reply to detect tool calls. We can only stream when no tools are on
676 // offer (a plain answer), which is exactly the tools-disabled case.
677 if let Some(sink) = sink
678 && tools.is_empty()
679 {
680 let rx = self
681 .engine
682 .stream_message(text)
683 .await
684 .map_err(|error| error.to_string())?;
685 return drain_onde_stream(rx, sink).await;
686 }
687
688 let onde_tools = to_onde_tools(tools);
689 let result = self
690 .engine
691 .send_message_with_tools(text, &onde_tools)
692 .await
693 .map_err(|error| error.to_string())?;
694 Ok(onde_result_to_turn(result))
695 }
696
697 async fn send_tool_results(
698 &self,
699 results: Vec<ToolResult>,
700 tools: &[ToolSpec],
701 allow_tool_calls: bool,
702 sink: Option<&TokenSink>,
703 ) -> Result<TurnResult, BackendError> {
704 self.apply_pending_history().await?;
705 let onde_results: Vec<onde::inference::ToolResult> = results
706 .into_iter()
707 .map(|result| onde::inference::ToolResult {
708 tool_call_id: result.tool_call_id,
709 content: result.content,
710 })
711 .collect();
712
713 // A forced-text round is the only round onde can stream, since no
714 // further tool calls are parsed.
715 if let Some(sink) = sink
716 && !allow_tool_calls
717 {
718 let rx = self
719 .engine
720 .stream_tool_results(onde_results, None)
721 .await
722 .map_err(|error| error.to_string())?;
723 return drain_onde_stream(rx, sink).await;
724 }
725
726 let onde_tools = allow_tool_calls.then(|| to_onde_tools(tools));
727 let result = self
728 .engine
729 .send_tool_results(onde_results, onde_tools.as_deref())
730 .await
731 .map_err(|error| error.to_string())?;
732 Ok(onde_result_to_turn(result))
733 }
734
735 async fn record_cancelled_tool_results(&self, _results: Vec<ToolResult>) {
736 // onde's public API cannot append tool-result history entries without
737 // running another inference round, so the dangling tool call stays in
738 // its history. The chat template replays it as-is, which local models
739 // tolerate — worst case the model re-issues the call next turn.
740 }
741
742 fn is_remote(&self) -> bool {
743 false
744 }
745
746 async fn history_snapshot(&self) -> Vec<serde_json::Value> {
747 if let Some(history) = self.pending_history.lock().await.as_ref() {
748 return history.clone();
749 }
750 // onde's `history()` already flattens tool entries: assistant tool
751 // calls become plain assistant text and tool results are omitted, so
752 // the snapshot is lossy for tool-heavy turns (acceptable in this MVP).
753 self.engine
754 .history()
755 .await
756 .iter()
757 .map(|message| {
758 serde_json::json!({
759 "role": message.role.to_string(),
760 "content": message.content,
761 })
762 })
763 .collect()
764 }
765
766 async fn restore_history(&self, history: Vec<serde_json::Value>) {
767 *self.pending_history.lock().await = Some(history);
768 if self.engine.info().await.status == EngineStatus::Ready {
769 // Ready was just observed, so failure here can only mean a status
770 // transition; keep the pending copy for the next inference call.
771 let _ = self.apply_pending_history().await;
772 }
773 }
774
775 async fn compact_history(&self, keep_last: usize) -> Result<(), BackendError> {
776 self.apply_pending_history().await?;
777 let snapshot = self.engine.history().await;
778
779 // Leading system messages carry session context; keep them all.
780 let system: Vec<ChatMessage> = snapshot
781 .iter()
782 .take_while(|message| message.role == ChatRole::System)
783 .cloned()
784 .collect();
785 let non_system: Vec<ChatMessage> = snapshot
786 .into_iter()
787 .filter(|message| message.role != ChatRole::System)
788 .collect();
789 let tail_start = non_system.len().saturating_sub(keep_last);
790 let tail: Vec<ChatMessage> = non_system[tail_start..].to_vec();
791
792 // One plain (tool-free) inference round produces the summary. Note the
793 // cap only bounds this request's own payload: the engine still replays
794 // its full (over-budget) history underneath it, so an already
795 // over-budget local session usually fails here and lands in the
796 // fallback below — the cap is about not making the doomed round worse.
797 let transcript = truncate_transcript_middle(
798 &chat_messages_transcript(&non_system),
799 SUMMARY_TRANSCRIPT_TOKEN_CAP,
800 );
801 let result = self
802 .engine
803 .send_message(format!("{transcript}\n\n{SUMMARIZE_PROMPT}"))
804 .await
805 .map_err(|error| error.to_string());
806
807 let rebuild: Vec<ChatMessage> = match result {
808 Ok(result) => {
809 // Local models may reason in <think> blocks; keep only the visible part.
810 let (_think, summary) = crate::chat::strip_think_blocks(&result.text);
811 let mut rebuilt = system;
812 rebuilt.push(ChatMessage::user(format!(
813 "[Conversation summary]\n{summary}"
814 )));
815 rebuilt.extend(tail);
816 rebuilt
817 }
818 Err(error) => {
819 log::warn!(
820 "local summarization round failed ({error}); falling back to plain truncation"
821 );
822 // Same deterministic shrink as the remote path, on the plain
823 // chat shapes the engine holds (its history has no tool-call
824 // structure, so there are no orphaned tool results to guard).
825 match truncate_chat_messages_to_budget(system, tail) {
826 Some(rebuilt) => rebuilt,
827 // The system messages alone overflow the window: unrecoverable.
828 None => return Err(error),
829 }
830 }
831 };
832
833 self.engine.clear_history().await;
834 for message in rebuild {
835 self.engine.push_history(message).await;
836 }
837 Ok(())
838 }
839 }
840
841 /// The local counterpart of `truncate_history_to_budget`: system messages, the
842 /// truncation placeholder, and the newest of `tail` that fit the budget.
843 ///
844 /// The tail may drain to empty. A newest message that overflows the window on
845 /// its own (a giant tool result, the very thing that triggers compaction) is
846 /// dropped rather than failing the session, so `None` only means the system
847 /// messages alone don't fit.
848 fn truncate_chat_messages_to_budget(
849 system: Vec<ChatMessage>,
850 mut tail: Vec<ChatMessage>,
851 ) -> Option<Vec<ChatMessage>> {
852 let budget = DEFAULT_CONTEXT_TOKEN_BUDGET * 4; // estimate_tokens is chars / 4.
853 let fixed_chars: usize = system
854 .iter()
855 .map(|message| message.content.len())
856 .sum::<usize>()
857 + TRUNCATION_PLACEHOLDER.len();
858 if fixed_chars > budget {
859 return None;
860 }
861
862 let mut tail_chars: usize = tail.iter().map(|message| message.content.len()).sum();
863 let mut drop = 0;
864 while fixed_chars + tail_chars > budget {
865 tail_chars -= tail[drop].content.len();
866 drop += 1;
867 }
868 tail.drain(..drop);
869
870 let mut rebuilt = system;
871 rebuilt.push(ChatMessage::user(TRUNCATION_PLACEHOLDER.to_string()));
872 rebuilt.extend(tail);
873 Some(rebuilt)
874 }
875
876 /// Flatten `onde` chat messages into the transcript shape summarization
877 /// expects (the local engine's history has no tool-call structure, so this is
878 /// a plain role/content rendering).
879 fn chat_messages_transcript(history: &[ChatMessage]) -> String {
880 history
881 .iter()
882 .filter(|message| message.role != ChatRole::System && !message.content.trim().is_empty())
883 .map(|message| format!("{}: {}", message.role, message.content))
884 .collect::<Vec<_>>()
885 .join("\n\n")
886 }
887
888 /// Drain an onde streaming receiver, forwarding each token to `sink` and
889 /// assembling the full text. onde reports stream failures as a final chunk whose
890 /// `finish_reason` is `"error: …"`; surface those as a backend error.
891 async fn drain_onde_stream(
892 mut rx: tokio::sync::mpsc::Receiver<onde::inference::StreamChunk>,
893 sink: &TokenSink,
894 ) -> Result<TurnResult, BackendError> {
895 let mut text = String::new();
896 let mut finish = FinishReason::default();
897 while let Some(chunk) = rx.recv().await {
898 if !chunk.delta.is_empty() {
899 text.push_str(&chunk.delta);
900 // The receiver is the UI; if it's gone the turn is being cancelled,
901 // so stop assembling rather than spinning the model to completion.
902 // onde reports reasoning as inline `<think>` tags in `delta`, so it
903 // is visible content that a downstream consumer splits out.
904 if sink.send(TokenChunk::Visible(chunk.delta)).is_err() {
905 break;
906 }
907 }
908 if chunk.done {
909 if let Some(reason) = chunk.finish_reason.as_deref()
910 && let Some(message) = reason.strip_prefix("error: ")
911 {
912 return Err(message.to_string());
913 }
914 finish = FinishReason::from_wire(chunk.finish_reason.as_deref());
915 break;
916 }
917 }
918 Ok(TurnResult {
919 text,
920 tool_calls: Vec::new(),
921 finish,
922 context_tokens: None,
923 })
924 }
925
926 /// Convert an `onde` tool-aware result into the neutral [`TurnResult`].
927 fn onde_result_to_turn(result: onde::inference::ToolAwareResult) -> TurnResult {
928 TurnResult {
929 finish: FinishReason::from_wire(Some(&result.finish_reason)),
930 context_tokens: None,
931 text: result.text,
932 tool_calls: result
933 .tool_calls
934 .into_iter()
935 .map(|call| ToolCall {
936 id: call.id,
937 name: call.function_name,
938 arguments: call.arguments,
939 })
940 .collect(),
941 }
942 }
943
944 // ── OpenAI-compatible backend ─────────────────────────────────────────────────────
945
946 /// Inference against any OpenAI-compatible Chat Completions endpoint.
947 ///
948 /// Conversation state is held client-side and replayed on every request, so the
949 /// endpoint can be stateless. Standard OpenAI function-calling is used end to
950 /// end (`tools`, `choices[].message.tool_calls`, `role: "tool"` follow-ups).
951 pub struct OpenAiBackend {
952 base_url: String,
953 api_key: String,
954 /// Read the bearer from the stored session on each request instead of
955 /// using `api_key`; see `bearer`.
956 session_token: bool,
957 model: String,
958 http: reqwest::Client,
959 /// The full message list sent on each request (system + turns + tool results).
960 history: Mutex<Vec<serde_json::Value>>,
961 /// Whether `model` reads images (see `provider::model_accepts_images`).
962 /// When it does not, images already in `history` are left out of each
963 /// request rather than sent to an endpoint that would refuse them.
964 accepts_images: bool,
965 /// Whether `model` takes audio (see `provider::model_accepts_audio`), with
966 /// the same effect on audio already in `history`.
967 accepts_audio: bool,
968 /// Set once the endpoint has refused `stream_options`, so streamed
969 /// requests stop asking it for token usage.
970 stream_usage_refused: Arc<AtomicBool>,
971 }
972
973 impl OpenAiBackend {
974 /// Build a backend for `{base_url, api_key, model}`, seeding the optional
975 /// system prompt. `base_url` should include the API root (e.g. ending in
976 /// `/v1`); the chat path is appended.
977 pub fn new(
978 base_url: impl Into<String>,
979 api_key: impl Into<String>,
980 model: impl Into<String>,
981 system_prompt: Option<String>,
982 ) -> Self {
983 let mut history = Vec::new();
984 if let Some(prompt) = system_prompt {
985 history.push(serde_json::json!({ "role": "system", "content": prompt }));
986 }
987 let model = model.into();
988 Self {
989 base_url: base_url.into(),
990 api_key: api_key.into(),
991 session_token: false,
992 accepts_images: crate::provider::model_accepts_images(&model),
993 accepts_audio: crate::provider::model_accepts_audio(&model),
994 model,
995 http: reqwest::Client::new(),
996 history: Mutex::new(history),
997 stream_usage_refused: Arc::default(),
998 }
999 }
1000
1001 /// Have the backend send the stored siGit Code Cloud session token as its
1002 /// bearer, read on every request. A token captured at build time goes
1003 /// stale when the user signs in as someone else; `api_key` is then only
1004 /// the fallback for a signed-out request.
1005 pub fn with_session_token(mut self, on: bool) -> Self {
1006 self.session_token = on;
1007 self
1008 }
1009
1010 /// The bearer for the next request.
1011 fn bearer(&self) -> String {
1012 if self.session_token
1013 && let Some(token) = crate::credentials::load_token()
1014 {
1015 return token;
1016 }
1017 self.api_key.clone()
1018 }
1019
1020 fn tools_json(tools: &[ToolSpec]) -> Vec<serde_json::Value> {
1021 tools
1022 .iter()
1023 .map(|tool| {
1024 // parameters_schema is a JSON string; parse it, defaulting to an
1025 // empty object schema if malformed.
1026 let parameters: serde_json::Value = serde_json::from_str(&tool.parameters_schema)
1027 .unwrap_or_else(|_| serde_json::json!({ "type": "object", "properties": {} }));
1028 serde_json::json!({
1029 "type": "function",
1030 "function": {
1031 "name": tool.name,
1032 "description": tool.description,
1033 "parameters": parameters,
1034 }
1035 })
1036 })
1037 .collect()
1038 }
1039
1040 /// POST the current history and apply the assistant reply to history.
1041 /// `tools` is always the known catalog, while `allow_tool_calls` determines
1042 /// whether it is advertised to the model and whether returned calls may run.
1043 /// Streams via SSE when `sink` is set; otherwise reads a single JSON response.
1044 ///
1045 /// A reply whose only attempt at a tool call was a block that couldn't be
1046 /// parsed gets one retry: the model is told the call didn't run and asked
1047 /// to make it again through the structured interface. Without that the
1048 /// turn ends on whatever prose preceded the broken block, and the call is
1049 /// silently lost.
1050 async fn complete(
1051 &self,
1052 tools: &[ToolSpec],
1053 allow_tool_calls: bool,
1054 sink: Option<&TokenSink>,
1055 ) -> Result<TurnResult, BackendError> {
1056 let (result, malformed) = self.request(tools, allow_tool_calls, sink).await?;
1057 let retry_note = if !allow_tool_calls || !result.tool_calls.is_empty() {
1058 None
1059 } else if malformed > 0 {
1060 log::warn!(
1061 "dropped {malformed} unparseable inline tool call(s); asking the model to retry"
1062 );
1063 Some(MALFORMED_TOOL_CALL_RETRY)
1064 } else if result.finish == FinishReason::Repetition {
1065 log::warn!("reply was cut off as a repetition loop; asking the model to retry");
1066 Some(REPETITION_RETRY)
1067 } else {
1068 None
1069 };
1070 let Some(retry_note) = retry_note else {
1071 return Ok(result);
1072 };
1073
1074 self.history.lock().await.push(serde_json::json!({
1075 "role": "user",
1076 "content": retry_note,
1077 }));
1078 if let Some(sink) = sink
1079 && !result.text.trim().is_empty()
1080 {
1081 // The retry's text follows what already streamed; keep it from
1082 // running on into the previous sentence.
1083 let _ = sink.send(TokenChunk::Visible("\n\n".to_string()));
1084 }
1085 let (retry, retry_malformed) = self.request(tools, allow_tool_calls, sink).await?;
1086 if retry_malformed > 0 && retry.tool_calls.is_empty() {
1087 log::warn!(
1088 "the retry also wrote {retry_malformed} unparseable inline tool call(s); \
1089 ending the turn without them"
1090 );
1091 }
1092 Ok(TurnResult {
1093 text: join_reply_text(&result.text, &retry.text),
1094 tool_calls: retry.tool_calls,
1095 finish: retry.finish,
1096 context_tokens: retry.context_tokens,
1097 })
1098 }
1099
1100 /// One chat-completion round trip. Returns the turn plus how many inline
1101 /// tool-call blocks were dropped because they couldn't be parsed.
1102 async fn request(
1103 &self,
1104 tools: &[ToolSpec],
1105 allow_tool_calls: bool,
1106 sink: Option<&TokenSink>,
1107 ) -> Result<(TurnResult, usize), BackendError> {
1108 let url = format!("{}/chat/completions", self.base_url.trim_end_matches('/'));
1109 let streaming = sink.is_some();
1110
1111 let messages = {
1112 let history = self.history.lock().await;
1113 let messages = if self.accepts_images {
1114 history.clone()
1115 } else {
1116 without_images(&history)
1117 };
1118 if self.accepts_audio {
1119 messages
1120 } else {
1121 without_audio(&messages)
1122 }
1123 };
1124 let mut body = serde_json::json!({
1125 "model": self.model,
1126 "messages": messages,
1127 "stream": streaming,
1128 });
1129 // A stream reports no token usage unless asked to. It comes in a last
1130 // chunk with no choices, which feeds the client's context meter.
1131 let ask_for_usage = streaming && !self.stream_usage_refused.load(Ordering::Relaxed);
1132 if ask_for_usage {
1133 body["stream_options"] = serde_json::json!({ "include_usage": true });
1134 }
1135 if allow_tool_calls && !tools.is_empty() {
1136 body["tools"] = serde_json::Value::Array(Self::tools_json(tools));
1137 // OpenAI specifies `auto` as the default when tools are present,
1138 // but not every OpenAI-compatible gateway implements that default.
1139 // Sending it explicitly is important for the cloud path: otherwise
1140 // a model can describe the action it intends to take as text and
1141 // end the turn without returning a tool call at all.
1142 body["tool_choice"] = serde_json::Value::String("auto".to_string());
1143 }
1144
1145 let mut response = self
1146 .http
1147 .post(&url)
1148 .bearer_auth(self.bearer())
1149 .json(&body)
1150 .send()
1151 .await
1152 .map_err(|error| format!("request to {url} failed: {error}"))?;
1153
1154 if !response.status().is_success() {
1155 let status = response.status();
1156 let error_body = response.text().await.unwrap_or_default();
1157 // Usage is a nicety; an endpoint that does not know the option is
1158 // asked again without it, and never asked for it again.
1159 if !(ask_for_usage && refuses_stream_options(status, &error_body)) {
1160 return Err(describe_api_error(status, &error_body));
1161 }
1162 log::info!("{url} refused stream_options; streaming without token usage");
1163 self.stream_usage_refused.store(true, Ordering::Relaxed);
1164 if let Some(fields) = body.as_object_mut() {
1165 fields.remove("stream_options");
1166 }
1167 response = self
1168 .http
1169 .post(&url)
1170 .bearer_auth(self.bearer())
1171 .json(&body)
1172 .send()
1173 .await
1174 .map_err(|error| format!("request to {url} failed: {error}"))?;
1175 if !response.status().is_success() {
1176 let status = response.status();
1177 let error_body = response.text().await.unwrap_or_default();
1178 return Err(describe_api_error(status, &error_body));
1179 }
1180 }
1181
1182 if let Some(sink) = sink {
1183 self.consume_stream(response, sink, tools, allow_tool_calls)
1184 .await
1185 } else {
1186 self.consume_json(response, tools, allow_tool_calls).await
1187 }
1188 }
1189
1190 /// One self-contained summarization round: summarize `transcript` without
1191 /// touching the live history.
1192 ///
1193 /// Unlike [`complete`], which replays and mutates `self.history`, this
1194 /// sends its own one-off message list. Compaction only rewrites history
1195 /// once it has a summary in hand, so a failed round leaves the session
1196 /// exactly as it was — and the caller can fall back to plain truncation
1197 /// instead of failing the session (issue #125).
1198 async fn summarize(
1199 &self,
1200 system: &[serde_json::Value],
1201 transcript: &str,
1202 ) -> Result<String, BackendError> {
1203 let url = format!("{}/chat/completions", self.base_url.trim_end_matches('/'));
1204 let mut messages = Vec::with_capacity(system.len() + 1);
1205 messages.extend(system.iter().cloned());
1206 messages.push(serde_json::json!({
1207 "role": "user",
1208 "content": format!("{transcript}\n\n{SUMMARIZE_PROMPT}"),
1209 }));
1210 let body = serde_json::json!({
1211 "model": self.model,
1212 "messages": messages,
1213 "stream": false,
1214 });
1215
1216 let response = self
1217 .http
1218 .post(&url)
1219 .bearer_auth(self.bearer())
1220 .json(&body)
1221 .send()
1222 .await
1223 .map_err(|error| format!("request to {url} failed: {error}"))?;
1224
1225 if !response.status().is_success() {
1226 let status = response.status();
1227 let body = response.text().await.unwrap_or_default();
1228 return Err(describe_api_error(status, &body));
1229 }
1230
1231 let parsed: ChatCompletion = response
1232 .json()
1233 .await
1234 .map_err(|error| format!("response parse error: {error}"))?;
1235 parsed
1236 .choices
1237 .into_iter()
1238 .next()
1239 .and_then(|choice| choice.message.content)
1240 .filter(|text| !text.trim().is_empty())
1241 .ok_or_else(|| "endpoint returned no summary".to_string())
1242 }
1243
1244 /// Parse a single non-streaming chat-completion response.
1245 async fn consume_json(
1246 &self,
1247 response: reqwest::Response,
1248 tools: &[ToolSpec],
1249 allow_tool_calls: bool,
1250 ) -> Result<(TurnResult, usize), BackendError> {
1251 let parsed: ChatCompletion = response
1252 .json()
1253 .await
1254 .map_err(|error| format!("response parse error: {error}"))?;
1255 let context_tokens = parsed.usage.as_ref().map(Usage::context_tokens);
1256
1257 let choice = parsed
1258 .choices
1259 .into_iter()
1260 .next()
1261 .ok_or_else(|| "endpoint returned no choices".to_string())?;
1262 let finish = FinishReason::from_wire(choice.finish_reason.as_deref());
1263 let mut message = choice.message;
1264
1265 let text = message.content.clone().unwrap_or_default();
1266 let mut tool_calls: Vec<ToolCall> = message
1267 .tool_calls
1268 .iter()
1269 .flatten()
1270 .map(|call| ToolCall {
1271 id: call.id.clone(),
1272 name: call.function.name.clone(),
1273 arguments: call.function.arguments.clone(),
1274 })
1275 .collect();
1276
1277 let mut extracted = crate::inline_tool_calls::extract(&text, tools);
1278 // Markup the model made up in the harness's shape goes too, from the
1279 // reply and the history alike. See `harness_markup`.
1280 extracted.text = crate::harness_markup::strip(&extracted.text);
1281 let malformed = extracted.malformed;
1282 if malformed > 0 {
1283 log::warn!("dropped {malformed} unparseable inline tool call(s) from the reply");
1284 }
1285
1286 if !allow_tool_calls {
1287 let recovered = extracted.calls;
1288 let text = extracted.text;
1289 let suppressed = tool_calls.len() + recovered.len();
1290 if suppressed > 0 {
1291 let names = tool_calls
1292 .iter()
1293 .map(|call| call.name.as_str())
1294 .chain(recovered.iter().map(|call| call.name.as_str()))
1295 .collect::<Vec<_>>()
1296 .join(", ");
1297 log::warn!(
1298 "suppressed {suppressed} tool call(s) from a forced-text response: {names}"
1299 );
1300 }
1301 self.history
1302 .lock()
1303 .await
1304 .push(streamed_assistant_history(&text, &[]));
1305 return Ok((
1306 TurnResult {
1307 text,
1308 tool_calls: Vec::new(),
1309 finish,
1310 context_tokens,
1311 },
1312 malformed,
1313 ));
1314 }
1315
1316 // Some models write a tool call out as literal `<tool_call>` text
1317 // instead of using the structured field (see `inline_tool_calls`).
1318 // Recover it, or the turn ends with the tag rendered as prose and
1319 // whatever the model meant to do is dropped.
1320 if tool_calls.is_empty() && (malformed > 0 || !extracted.calls.is_empty()) {
1321 let cleaned = extracted.text;
1322 let recovered = extracted.calls;
1323 if !recovered.is_empty() {
1324 log::warn!(
1325 "recovered {} tool call(s) the model emitted as text instead of a structured call",
1326 recovered.len()
1327 );
1328 let tool_calls: Vec<ToolCall> = recovered
1329 .into_iter()
1330 .enumerate()
1331 .map(|(index, call)| ToolCall {
1332 id: format!("call_recovered_{index}"),
1333 name: call.name,
1334 arguments: call.arguments,
1335 })
1336 .collect();
1337 // Record the recovered shape, not the raw tag: the tool
1338 // results that follow have to answer an assistant message
1339 // that actually carries these calls, or the next request is
1340 // rejected for orphaned tool results.
1341 self.history
1342 .lock()
1343 .await
1344 .push(streamed_assistant_history(&cleaned, &tool_calls));
1345 return Ok((
1346 TurnResult {
1347 text: cleaned,
1348 tool_calls,
1349 finish,
1350 context_tokens,
1351 },
1352 malformed,
1353 ));
1354 }
1355 // Nothing recovered, so what was found is a broken block. Keep it
1356 // out of history as well as the reply; see `inline_tool_calls` for
1357 // why it poisons later turns.
1358 self.push_reply_without_calls(&cleaned).await;
1359 return Ok((
1360 TurnResult {
1361 text: cleaned,
1362 tool_calls,
1363 finish,
1364 context_tokens,
1365 },
1366 malformed,
1367 ));
1368 } else if malformed > 0 || !extracted.calls.is_empty() {
1369 // Structured calls arrived with inline blocks beside them. As on
1370 // the streaming path, inline calls that parse run alongside the
1371 // structured ones, broken blocks are dropped, and neither kind of
1372 // block is left in the reply or the history.
1373 let offset = tool_calls.len();
1374 tool_calls.extend(
1375 extracted
1376 .calls
1377 .into_iter()
1378 .enumerate()
1379 .map(|(index, call)| ToolCall {
1380 id: format!("call_recovered_{}", offset + index),
1381 name: call.name,
1382 arguments: call.arguments,
1383 }),
1384 );
1385 self.history
1386 .lock()
1387 .await
1388 .push(streamed_assistant_history(&extracted.text, &tool_calls));
1389 return Ok((
1390 TurnResult {
1391 text: extracted.text,
1392 tool_calls,
1393 finish,
1394 context_tokens,
1395 },
1396 malformed,
1397 ));
1398 }
1399
1400 // Record the assistant turn so later tool results have context. No
1401 // inline block was found, so the only change `extracted.text` can
1402 // carry is the stripped markup.
1403 let text = extracted.text;
1404 if message.content.is_some() {
1405 message.content = Some(text.clone());
1406 }
1407 self.history.lock().await.push(message.into_history_value());
1408
1409 Ok((
1410 TurnResult {
1411 text,
1412 tool_calls,
1413 finish,
1414 context_tokens,
1415 },
1416 0,
1417 ))
1418 }
1419
1420 /// Record a text-only assistant reply. An empty one is skipped: strict
1421 /// endpoints reject an assistant message with neither content nor tool
1422 /// calls, and there is nothing in it worth replaying.
1423 async fn push_reply_without_calls(&self, text: &str) {
1424 if !text.is_empty() {
1425 self.history
1426 .lock()
1427 .await
1428 .push(streamed_assistant_history(text, &[]));
1429 }
1430 }
1431
1432 /// Consume an OpenAI Server-Sent Events stream, forwarding content deltas to
1433 /// `sink` and reassembling any tool calls (which arrive fragmented across
1434 /// chunks, keyed by `index`).
1435 async fn consume_stream(
1436 &self,
1437 response: reqwest::Response,
1438 sink: &TokenSink,
1439 tools: &[ToolSpec],
1440 allow_tool_calls: bool,
1441 ) -> Result<(TurnResult, usize), BackendError> {
1442 use futures::StreamExt;
1443
1444 let mut stream = response.bytes_stream();
1445 // Newlines are ASCII, so splitting raw bytes on `\n` never bisects a
1446 // multibyte UTF-8 sequence; we only lossily decode whole lines.
1447 let mut buffer: Vec<u8> = Vec::new();
1448 let mut text = String::new();
1449 let mut tool_accum: Vec<StreamingToolCall> = Vec::new();
1450 let mut done = false;
1451 // Arrives on a late chunk, usually one with an empty delta.
1452 let mut finish = FinishReason::default();
1453 // Arrives on the last chunk, which has no choices, when asked for.
1454 let mut context_tokens = None;
1455 // Recovers a tool call the model wrote as literal `<tool_call>` text
1456 // instead of a structured delta. Scanning here (rather than after the
1457 // stream) keeps the tag off the UI: content goes straight to `sink` as
1458 // it arrives, so by the time a whole turn is assembled the tag has
1459 // already been rendered. See `inline_tool_calls`.
1460 let mut scanner = crate::inline_tool_calls::StreamScanner::new(tools);
1461 // What it lets through then loses any markup the model made up in the
1462 // harness's shape, like a `<system_warning>` block. See
1463 // `harness_markup`.
1464 let mut markup = crate::harness_markup::MarkupFilter::new();
1465 let mut recovered: Vec<ToolCall> = Vec::new();
1466 let mut malformed = 0usize;
1467 // Stops a reply that falls into a repetition loop. See `repetition`.
1468 let mut guard = crate::repetition::RepetitionGuard::new();
1469 let mut degenerate = false;
1470
1471 while let Some(item) = stream.next().await {
1472 let bytes = item.map_err(|error| format!("stream read error: {error}"))?;
1473 buffer.extend_from_slice(&bytes);
1474
1475 while let Some(pos) = buffer.iter().position(|&b| b == b'\n') {
1476 let line: Vec<u8> = buffer.drain(..=pos).collect();
1477 let line = String::from_utf8_lossy(&line);
1478 let line = line.trim();
1479
1480 let Some(data) = line.strip_prefix("data:") else {
1481 continue;
1482 };
1483 let data = data.trim();
1484 if data == "[DONE]" {
1485 done = true;
1486 break;
1487 }
1488 if data.is_empty() {
1489 continue;
1490 }
1491
1492 // An endpoint that fails after the status line is already sent
1493 // reports it in the stream instead, as an ordinary `data:`
1494 // frame holding an error envelope. siGit Code Cloud does this
1495 // when the upstream fails mid-turn. It has no `choices`, so
1496 // without this it parses as an empty chunk and is skipped, and
1497 // the turn ends looking like the model simply said nothing.
1498 if let Some(message) = api_error_message(data) {
1499 return Err(message);
1500 }
1501
1502 let chunk: StreamCompletion = match serde_json::from_str(data) {
1503 Ok(chunk) => chunk,
1504 // Skip keep-alive comments and anything we can't parse rather
1505 // than aborting a turn over one malformed frame.
1506 Err(_) => continue,
1507 };
1508
1509 if let Some(usage) = &chunk.usage {
1510 context_tokens = Some(usage.context_tokens());
1511 }
1512 let Some(choice) = chunk.choices.into_iter().next() else {
1513 continue;
1514 };
1515 if let Some(reason) = choice.finish_reason.as_deref() {
1516 finish = FinishReason::from_wire(Some(reason));
1517 }
1518 // Reasoning streamed in its own field is tagged at the source;
1519 // it bypasses the inline-tag scanner and is never kept in the
1520 // assistant message text.
1521 if let Some(reasoning) = choice.delta.reasoning_content
1522 && !reasoning.is_empty()
1523 && sink.send(TokenChunk::Reasoning(reasoning)).is_err()
1524 {
1525 // Consumer dropped (turn cancelled).
1526 done = true;
1527 break;
1528 }
1529 if let Some(content) = choice.delta.content
1530 && !content.is_empty()
1531 {
1532 let mut cancelled = false;
1533 for event in scanner.push(&content) {
1534 match event {
1535 crate::inline_tool_calls::ScanEvent::Text(chunk) => {
1536 let chunk = markup.push(&chunk);
1537 if chunk.is_empty() {
1538 continue;
1539 }
1540 text.push_str(&chunk);
1541 let looping = guard.push(&chunk);
1542 if sink.send(TokenChunk::Visible(chunk)).is_err() {
1543 // Consumer dropped (turn cancelled).
1544 cancelled = true;
1545 break;
1546 }
1547 if looping {
1548 degenerate = true;
1549 break;
1550 }
1551 }
1552 crate::inline_tool_calls::ScanEvent::ToolCall(call) => {
1553 if allow_tool_calls {
1554 log::warn!(
1555 "recovered tool call '{}' the model emitted as text instead of a structured call",
1556 call.name
1557 );
1558 }
1559 recovered.push(ToolCall {
1560 id: format!("call_recovered_{}", recovered.len()),
1561 name: call.name,
1562 arguments: call.arguments,
1563 });
1564 }
1565 crate::inline_tool_calls::ScanEvent::Malformed(block) => {
1566 log_malformed_block(&block);
1567 malformed += 1;
1568 }
1569 }
1570 }
1571 if cancelled || degenerate {
1572 done = true;
1573 break;
1574 }
1575 }
1576 for delta in choice.delta.tool_calls.into_iter().flatten() {
1577 let index = delta.index.unwrap_or(0) as usize;
1578 if tool_accum.len() <= index {
1579 tool_accum.resize_with(index + 1, StreamingToolCall::default);
1580 }
1581 let slot = &mut tool_accum[index];
1582 if let Some(id) = delta.id {
1583 slot.id = id;
1584 }
1585 if let Some(function) = delta.function {
1586 if let Some(name) = function.name {
1587 slot.name = name;
1588 }
1589 if let Some(arguments) = function.arguments {
1590 slot.arguments.push_str(&arguments);
1591 }
1592 }
1593 }
1594 }
1595
1596 if done {
1597 break;
1598 }
1599 }
1600
1601 // Flush what was held back. A partial marker is just text; a
1602 // tool-call block that never closed is dropped like any other
1603 // unparseable one.
1604 let mut leftover = String::new();
1605 if degenerate {
1606 // Dropping the stream below closes the connection, so the endpoint
1607 // stops generating. Nothing held back is worth flushing.
1608 let keep = guard.loop_start().unwrap_or(text.len()).min(text.len());
1609 log::warn!(
1610 "reply degenerated into repetition; cut {} of {} bytes",
1611 text.len() - keep,
1612 text.len()
1613 );
1614 text.truncate(keep);
1615 text.truncate(text.trim_end().len());
1616 finish = FinishReason::Repetition;
1617 let _ = sink.send(TokenChunk::Visible(REPETITION_NOTICE.to_string()));
1618 } else {
1619 match scanner.finish() {
1620 Some(crate::inline_tool_calls::ScanEvent::Text(rest)) => {
1621 leftover = markup.push(&rest);
1622 }
1623 Some(crate::inline_tool_calls::ScanEvent::Malformed(block)) => {
1624 log_malformed_block(&block);
1625 malformed += 1;
1626 }
1627 Some(crate::inline_tool_calls::ScanEvent::ToolCall(_)) | None => {}
1628 }
1629 leftover.push_str(&markup.finish());
1630 if !leftover.is_empty() {
1631 text.push_str(&leftover);
1632 let _ = sink.send(TokenChunk::Visible(leftover));
1633 }
1634 }
1635
1636 let mut tool_calls: Vec<ToolCall> = tool_accum
1637 .iter()
1638 .filter(|call| !call.name.is_empty())
1639 .enumerate()
1640 .map(|(index, call)| ToolCall {
1641 id: if call.id.is_empty() {
1642 format!("call_{index}")
1643 } else {
1644 call.id.clone()
1645 },
1646 name: call.name.clone(),
1647 arguments: call.arguments.clone(),
1648 })
1649 .collect();
1650 tool_calls.extend(recovered);
1651
1652 if !allow_tool_calls && !tool_calls.is_empty() {
1653 let names = tool_calls
1654 .iter()
1655 .map(|call| call.name.as_str())
1656 .collect::<Vec<_>>()
1657 .join(", ");
1658 log::warn!(
1659 "suppressed {} tool call(s) from a forced-text response: {names}",
1660 tool_calls.len()
1661 );
1662 tool_calls.clear();
1663 }
1664
1665 // Record the assistant turn so later tool results have context.
1666 if (malformed > 0 || degenerate) && tool_calls.is_empty() {
1667 self.push_reply_without_calls(&text).await;
1668 } else {
1669 self.history
1670 .lock()
1671 .await
1672 .push(streamed_assistant_history(&text, &tool_calls));
1673 }
1674
1675 Ok((
1676 TurnResult {
1677 text,
1678 tool_calls,
1679 finish,
1680 context_tokens,
1681 },
1682 malformed,
1683 ))
1684 }
1685 }
1686
1687 /// Sent as a user turn after a reply whose tool call couldn't be parsed.
1688 const MALFORMED_TOOL_CALL_RETRY: &str = "[siGit Code] Your last reply tried to call a tool by \
1689 writing the call out as text, and it could not be parsed, so nothing ran and the user did \
1690 not see it. Do not write tool calls or tool results as text. Make the call again using the \
1691 tool-calling interface, or answer in plain prose if no tool is needed.";
1692
1693 /// Sent as a user turn after a reply was cut off as a repetition loop.
1694 const REPETITION_RETRY: &str = "[siGit Code] Your last reply started repeating the same few \
1695 words and was cut off. Do not continue it. Make the next tool call you need, or answer \
1696 briefly in plain prose.";
1697
1698 /// Shown to the user, who has already watched the loop stream. Not kept in
1699 /// history.
1700 const REPETITION_NOTICE: &str =
1701 "\n\n[siGit Code stopped this reply because the model started repeating itself.]";
1702
1703 fn log_malformed_block(block: &str) {
1704 log::warn!(
1705 "dropped an unparseable inline tool call ({} chars): {}",
1706 block.len(),
1707 block.chars().take(200).collect::<String>()
1708 );
1709 }
1710
1711 /// Join the visible text of a reply and its retry the way the stream showed
1712 /// them: as separate paragraphs.
1713 fn join_reply_text(first: &str, retry: &str) -> String {
1714 match (first.trim().is_empty(), retry.trim().is_empty()) {
1715 (true, _) => retry.to_string(),
1716 (false, true) => first.to_string(),
1717 (false, false) => format!("{first}\n\n{retry}"),
1718 }
1719 }
1720
1721 /// One tool call being reassembled from streamed deltas.
1722 #[derive(Default)]
1723 struct StreamingToolCall {
1724 id: String,
1725 name: String,
1726 arguments: String,
1727 }
1728
1729 /// Rebuild the assistant message for replay in history after a streamed turn,
1730 /// preserving any tool calls so the follow-up request is well-formed. Mirrors
1731 /// [`ResponseMessage::into_history_value`] for the non-streaming path.
1732 fn streamed_assistant_history(text: &str, tool_calls: &[ToolCall]) -> serde_json::Value {
1733 let mut message = serde_json::json!({ "role": "assistant" });
1734 message["content"] = if text.is_empty() {
1735 serde_json::Value::Null
1736 } else {
1737 serde_json::Value::String(text.to_string())
1738 };
1739 if !tool_calls.is_empty() {
1740 message["tool_calls"] = serde_json::json!(
1741 tool_calls
1742 .iter()
1743 .map(|call| serde_json::json!({
1744 "id": call.id,
1745 "type": "function",
1746 "function": {
1747 "name": call.name,
1748 "arguments": call.arguments,
1749 }
1750 }))
1751 .collect::<Vec<_>>()
1752 );
1753 }
1754 message
1755 }
1756
1757 #[async_trait]
1758 impl InferenceBackend for OpenAiBackend {
1759 async fn send_message_with_tools(
1760 &self,
1761 text: &str,
1762 tools: &[ToolSpec],
1763 sink: Option<&TokenSink>,
1764 ) -> Result<TurnResult, BackendError> {
1765 self.history
1766 .lock()
1767 .await
1768 .push(serde_json::json!({ "role": "user", "content": text }));
1769 self.complete(tools, true, sink).await
1770 }
1771
1772 async fn send_tool_results(
1773 &self,
1774 results: Vec<ToolResult>,
1775 tools: &[ToolSpec],
1776 allow_tool_calls: bool,
1777 sink: Option<&TokenSink>,
1778 ) -> Result<TurnResult, BackendError> {
1779 {
1780 let mut history = self.history.lock().await;
1781 for result in results {
1782 history.push(serde_json::json!({
1783 "role": "tool",
1784 "tool_call_id": result.tool_call_id,
1785 "content": result.content,
1786 }));
1787 }
1788 }
1789 self.complete(tools, allow_tool_calls, sink).await
1790 }
1791
1792 async fn record_cancelled_tool_results(&self, results: Vec<ToolResult>) {
1793 let mut history = self.history.lock().await;
1794 for result in results {
1795 // Cancellation races the in-flight continuation request. That
1796 // request appends its tool results before awaiting HTTP, so cleanup
1797 // must be safe whether cancellation won just before or just after
1798 // the append.
1799 let matching_call = history.iter().rposition(|message| {
1800 message["role"] == "assistant"
1801 && message["tool_calls"].as_array().is_some_and(|calls| {
1802 calls.iter().any(|call| call["id"] == result.tool_call_id)
1803 })
1804 });
1805 let already_recorded = matching_call.is_some_and(|call_index| {
1806 history[call_index + 1..].iter().any(|message| {
1807 message["role"] == "tool" && message["tool_call_id"] == result.tool_call_id
1808 })
1809 });
1810 if already_recorded {
1811 continue;
1812 }
1813 history.push(serde_json::json!({
1814 "role": "tool",
1815 "tool_call_id": result.tool_call_id,
1816 "content": result.content,
1817 }));
1818 }
1819 }
1820
1821 async fn send_message_with_attachments(
1822 &self,
1823 text: &str,
1824 images: &[ImageInput],
1825 audio: &[AudioInput],
1826 tools: &[ToolSpec],
1827 sink: Option<&TokenSink>,
1828 ) -> Result<TurnResult, BackendError> {
1829 self.history
1830 .lock()
1831 .await
1832 .push(user_message(text, images, audio));
1833 self.complete(tools, true, sink).await
1834 }
1835
1836 fn accepts_images(&self) -> bool {
1837 self.accepts_images
1838 }
1839
1840 fn accepts_audio(&self) -> bool {
1841 self.accepts_audio
1842 }
1843
1844 fn is_remote(&self) -> bool {
1845 true
1846 }
1847
1848 fn fresh(&self) -> Option<Arc<dyn InferenceBackend>> {
1849 Some(Arc::new(Self {
1850 base_url: self.base_url.clone(),
1851 api_key: self.api_key.clone(),
1852 session_token: self.session_token,
1853 model: self.model.clone(),
1854 // The client is a handle on one connection pool; sharing it keeps
1855 // connections to the endpoint warm across sessions.
1856 http: self.http.clone(),
1857 history: Mutex::new(Vec::new()),
1858 accepts_images: self.accepts_images,
1859 accepts_audio: self.accepts_audio,
1860 // It is the same endpoint, so what it refused it refuses here.
1861 stream_usage_refused: Arc::clone(&self.stream_usage_refused),
1862 }))
1863 }
1864
1865 async fn history_snapshot(&self) -> Vec<serde_json::Value> {
1866 self.history.lock().await.clone()
1867 }
1868
1869 async fn restore_history(&self, history: Vec<serde_json::Value>) {
1870 // The snapshot includes the seeded system message, so a wholesale
1871 // replacement restores exactly what was saved.
1872 *self.history.lock().await = history;
1873 }
1874
1875 async fn compact_history(&self, keep_last: usize) -> Result<(), BackendError> {
1876 let snapshot: Vec<serde_json::Value> = self.history.lock().await.clone();
1877
1878 // Leading system messages carry session context (the seeded prompt,
1879 // pushed context); keep them all, like the summarization-success path.
1880 let system: Vec<serde_json::Value> = snapshot
1881 .iter()
1882 .take_while(|message| message["role"] == "system")
1883 .cloned()
1884 .collect();
1885
1886 let non_system: Vec<serde_json::Value> = snapshot
1887 .iter()
1888 .filter(|message| message["role"] != "system")
1889 .cloned()
1890 .collect();
1891 let tail_start = non_system.len().saturating_sub(keep_last);
1892 let mut tail = non_system[tail_start..].to_vec();
1893 // Drop leading tool results whose assistant tool-call message was
1894 // summarized away — strict endpoints reject orphaned `role: "tool"`
1895 // entries on the very next request.
1896 while tail
1897 .first()
1898 .is_some_and(|message| message["role"] == "tool")
1899 {
1900 tail.remove(0);
1901 }
1902
1903 // Ask the endpoint for a summary of the conversation so far, through a
1904 // direct completion call. The request carries the conversation as a
1905 // flattened transcript in a single user message rather than the live
1906 // history: this round offers no tools, and a tool-shaped history sent
1907 // without a tool schema is rejected upstream (see
1908 // `transcript_for_summary`). The transcript is capped so the request
1909 // cannot be as large as the over-budget conversation that triggered
1910 // compaction (see `SUMMARY_TRANSCRIPT_TOKEN_CAP`).
1911 let transcript = truncate_transcript_middle(
1912 &transcript_for_summary(&snapshot),
1913 SUMMARY_TRANSCRIPT_TOKEN_CAP,
1914 );
1915 let summary = match self.summarize(&system, &transcript).await {
1916 Ok(summary) => summary,
1917 Err(error) => {
1918 log::warn!(
1919 "summarization round failed ({error}); falling back to plain truncation"
1920 );
1921 // Compaction must not fail the session: fold the overflow into
1922 // a placeholder and drop the oldest messages until the rebuild
1923 // fits the budget. Needs no endpoint, so it cannot fail the way
1924 // the inference round just did (issue #125).
1925 let rebuilt = truncate_history_to_budget(&system, tail.clone()).ok_or(error)?;
1926 *self.history.lock().await = rebuilt;
1927 return Ok(());
1928 }
1929 };
1930
1931 let mut rebuilt = Vec::with_capacity(system.len() + tail.len() + 1);
1932 rebuilt.extend(system);
1933 rebuilt.push(serde_json::json!({
1934 "role": "user",
1935 "content": format!("[Conversation summary]\n{summary}"),
1936 }));
1937 rebuilt.extend(tail);
1938 *self.history.lock().await = rebuilt;
1939 Ok(())
1940 }
1941 }
1942
1943 /// The placeholder message replacing dropped history when compaction falls
1944 /// back to plain truncation (the model reads it, so it explains the gap).
1945 const TRUNCATION_PLACEHOLDER: &str = "[Older conversation omitted: the session outgrew \
1946 the context window and the summarization service was unavailable. Earlier \
1947 messages were dropped, so answers may lack that context.]";
1948
1949 /// Last-resort compaction when the summarization round cannot run: rebuild the
1950 /// history as `[system, placeholder, newest messages that fit the budget]`.
1951 ///
1952 /// Returns `None` when even the smallest rebuild exceeds the budget (a
1953 /// pathological case — e.g. one message larger than the whole window); the
1954 /// caller then reports the original summarization error and leaves history
1955 /// untouched.
1956 fn truncate_history_to_budget(
1957 system: &[serde_json::Value],
1958 mut tail: Vec<serde_json::Value>,
1959 ) -> Option<Vec<serde_json::Value>> {
1960 /// A `role: "tool"` entry only makes sense behind the assistant message
1961 /// that requested it; dropping that message strands it.
1962 fn drop_orphaned_tool_results(tail: &mut Vec<serde_json::Value>) {
1963 while tail
1964 .first()
1965 .is_some_and(|message| message["role"] == "tool")
1966 {
1967 tail.remove(0);
1968 }
1969 }
1970
1971 // A caller splitting mid-round can hand in a tail that starts with tool
1972 // results whose call message already fell outside the kept window.
1973 drop_orphaned_tool_results(&mut tail);
1974
1975 loop {
1976 let mut candidate = Vec::with_capacity(tail.len() + system.len() + 1);
1977 candidate.extend(system.iter().cloned());
1978 candidate.push(serde_json::json!({
1979 "role": "user",
1980 "content": TRUNCATION_PLACEHOLDER,
1981 }));
1982 candidate.extend(tail.iter().cloned());
1983 if estimate_tokens(&candidate) <= DEFAULT_CONTEXT_TOKEN_BUDGET {
1984 return Some(candidate);
1985 }
1986
1987 // Nothing left to drop: even the minimal rebuild (system + placeholder,
1988 // no conversation) overflows — the session is genuinely unrecoverable.
1989 if tail.is_empty() {
1990 return None;
1991 }
1992 if tail[0]["role"] == "assistant"
1993 && tail[0]["tool_calls"]
1994 .as_array()
1995 .is_some_and(|calls| !calls.is_empty())
1996 && tail.get(1).is_some_and(|message| message["role"] == "tool")
1997 {
1998 // Dropping an assistant tool-call message would orphan its
1999 // results; drop the whole round (call + results) together.
2000 let mut end = 1;
2001 while tail
2002 .get(end)
2003 .is_some_and(|message| message["role"] == "tool")
2004 {
2005 end += 1;
2006 }
2007 tail.drain(..end);
2008 } else {
2009 tail.remove(0);
2010 drop_orphaned_tool_results(&mut tail);
2011 }
2012 }
2013 }
2014
2015 // ── OpenAI error shape ────────────────────────────────────────────────────────
2016
2017 /// The OpenAI error envelope: `{"error": {"message": ..., "type": ...}}`.
2018 ///
2019 /// Every endpoint siGit talks to speaks it, including siGit Code Cloud, whose
2020 /// messages are written for the person reading them ("Monthly siGit Code Cloud
2021 /// allowance reached..."). Worth unwrapping rather than pasting the raw body
2022 /// into the editor's error banner.
2023 #[derive(Debug, Deserialize)]
2024 struct ApiErrorEnvelope {
2025 error: ApiErrorBody,
2026 }
2027
2028 #[derive(Debug, Deserialize)]
2029 struct ApiErrorBody {
2030 #[serde(default)]
2031 message: Option<String>,
2032 }
2033
2034 /// How much of an unparseable error body to keep. An endpoint behind a proxy
2035 /// can answer with a full HTML page, and the whole thing ends up in the
2036 /// editor's error banner.
2037 const ERROR_BODY_LIMIT: usize = 500;
2038
2039 /// Turn an error response into something worth showing a person.
2040 ///
2041 /// The endpoint's own message wins when there is one: it is written for the
2042 /// user, and the status code repeats what it already says. Anything else falls
2043 /// back to the status plus whatever the body held, which is all there is to go
2044 /// on — except an HTML page, which is a proxy or Rails error page and tells
2045 /// the person nothing the status doesn't (pasting it into chat is the dump
2046 /// seen in issue #125's screenshots).
2047 fn describe_api_error(status: reqwest::StatusCode, body: &str) -> String {
2048 if let Some(message) = api_error_message(body) {
2049 return message;
2050 }
2051
2052 let body = body.trim();
2053 if body.is_empty() || is_probable_html(body) {
2054 return format!("endpoint returned {status}");
2055 }
2056
2057 let mut detail = body;
2058 if detail.len() > ERROR_BODY_LIMIT {
2059 let mut cut = ERROR_BODY_LIMIT;
2060 while !detail.is_char_boundary(cut) {
2061 cut -= 1;
2062 }
2063 detail = &detail[..cut];
2064 return format!("endpoint returned {status}: {detail}…");
2065 }
2066 format!("endpoint returned {status}: {detail}")
2067 }
2068
2069 /// The human-readable message out of an OpenAI error envelope, if the body is
2070 /// one and carries a non-empty message.
2071 fn api_error_message(body: &str) -> Option<String> {
2072 let envelope: ApiErrorEnvelope = serde_json::from_str(body).ok()?;
2073 let message = envelope.error.message?;
2074 let message = message.trim();
2075 (!message.is_empty()).then(|| message.to_string())
2076 }
2077
2078 /// Whether `body` looks like an HTML page rather than prose: a doctype or an
2079 /// opening tag in the first chunk. Gateways and Rails apps answer failures
2080 /// with full pages; none of that markup is worth showing.
2081 fn is_probable_html(body: &str) -> bool {
2082 let head: String = body.chars().take(512).collect();
2083 let head = head.trim_start().to_ascii_lowercase();
2084 head.starts_with("<!doctype") || head.starts_with("<html") || head.starts_with("<head")
2085 }
2086
2087 // ── OpenAI response shapes ────────────────────────────────────────────────────────
2088
2089 #[derive(Debug, Deserialize)]
2090 struct ChatCompletion {
2091 #[serde(default)]
2092 choices: Vec<CompletionChoice>,
2093 #[serde(default)]
2094 usage: Option<Usage>,
2095 }
2096
2097 /// Token usage as an OpenAI-compatible endpoint reports it.
2098 #[derive(Debug, Deserialize)]
2099 struct Usage {
2100 #[serde(default)]
2101 prompt_tokens: u64,
2102 #[serde(default)]
2103 completion_tokens: u64,
2104 #[serde(default)]
2105 total_tokens: Option<u64>,
2106 }
2107
2108 impl Usage {
2109 /// The context in use once the reply is in.
2110 fn context_tokens(&self) -> u64 {
2111 self.total_tokens
2112 .unwrap_or(self.prompt_tokens + self.completion_tokens)
2113 }
2114 }
2115
2116 /// Whether a failed request was turned down for carrying `stream_options`,
2117 /// which some OpenAI-compatible servers predate and reject as unknown.
2118 fn refuses_stream_options(status: reqwest::StatusCode, body: &str) -> bool {
2119 matches!(status.as_u16(), 400 | 422) && body.contains("stream_options")
2120 }
2121
2122 #[derive(Debug, Deserialize)]
2123 struct CompletionChoice {
2124 message: ResponseMessage,
2125 #[serde(default)]
2126 finish_reason: Option<String>,
2127 }
2128
2129 #[derive(Debug, Deserialize)]
2130 struct ResponseMessage {
2131 #[serde(default)]
2132 content: Option<String>,
2133 #[serde(default)]
2134 tool_calls: Option<Vec<ResponseToolCall>>,
2135 }
2136
2137 impl ResponseMessage {
2138 /// Reconstruct the assistant message for replay in history, preserving any
2139 /// tool calls so the follow-up request is well-formed.
2140 fn into_history_value(self) -> serde_json::Value {
2141 let mut message = serde_json::json!({ "role": "assistant" });
2142 message["content"] = match self.content {
2143 Some(text) => serde_json::Value::String(text),
2144 None => serde_json::Value::Null,
2145 };
2146 if let Some(tool_calls) = self.tool_calls {
2147 message["tool_calls"] = serde_json::json!(
2148 tool_calls
2149 .into_iter()
2150 .map(|call| serde_json::json!({
2151 "id": call.id,
2152 "type": "function",
2153 "function": {
2154 "name": call.function.name,
2155 "arguments": call.function.arguments,
2156 }
2157 }))
2158 .collect::<Vec<_>>()
2159 );
2160 }
2161 message
2162 }
2163 }
2164
2165 #[derive(Debug, Deserialize)]
2166 struct ResponseToolCall {
2167 id: String,
2168 function: ResponseFunction,
2169 }
2170
2171 #[derive(Debug, Deserialize)]
2172 struct ResponseFunction {
2173 name: String,
2174 #[serde(default)]
2175 arguments: String,
2176 }
2177
2178 // ── OpenAI streaming (SSE) chunk shapes ─────────────────────────────────────────
2179
2180 #[derive(Debug, Deserialize)]
2181 struct StreamCompletion {
2182 #[serde(default)]
2183 choices: Vec<StreamChoice>,
2184 #[serde(default)]
2185 usage: Option<Usage>,
2186 }
2187
2188 #[derive(Debug, Deserialize)]
2189 struct StreamChoice {
2190 #[serde(default)]
2191 delta: StreamDelta,
2192 #[serde(default)]
2193 finish_reason: Option<String>,
2194 }
2195
2196 #[derive(Debug, Default, Deserialize)]
2197 struct StreamDelta {
2198 #[serde(default)]
2199 content: Option<String>,
2200 // Some endpoints stream model reasoning in a dedicated field rather than
2201 // inline `<think>` tags. It is unambiguously reasoning at the source, so it
2202 // travels as `TokenChunk::Reasoning` and never enters the content scanner.
2203 #[serde(default, alias = "reasoning")]
2204 reasoning_content: Option<String>,
2205 #[serde(default)]
2206 tool_calls: Option<Vec<StreamToolCallDelta>>,
2207 }
2208
2209 #[derive(Debug, Deserialize)]
2210 struct StreamToolCallDelta {
2211 #[serde(default)]
2212 index: Option<u32>,
2213 #[serde(default)]
2214 id: Option<String>,
2215 #[serde(default)]
2216 function: Option<StreamFunctionDelta>,
2217 }
2218
2219 #[derive(Debug, Deserialize)]
2220 struct StreamFunctionDelta {
2221 #[serde(default)]
2222 name: Option<String>,
2223 #[serde(default)]
2224 arguments: Option<String>,
2225 }
2226
2227 // ── Carrying a conversation across a model switch ────────────────────────────
2228
2229 /// The part of a history snapshot that survives a model switch: every
2230 /// non-system message, with half-finished tool plumbing repaired.
2231 ///
2232 /// System messages are dropped because the backend being switched *to* seeds
2233 /// its own — a different model's prompt, or a freshly pushed session-context
2234 /// message. A switch can land mid-turn, after the assistant asked for a tool
2235 /// but before the results came back; a tool call with no matching result (or a
2236 /// result with no matching call) makes strict OpenAI-compatible endpoints
2237 /// reject every later request in the session, so those halves are stripped
2238 /// instead of carried.
2239 pub fn carryover_history(snapshot: Vec<serde_json::Value>) -> Vec<serde_json::Value> {
2240 let answered: std::collections::HashSet<String> = snapshot
2241 .iter()
2242 .filter(|message| message["role"] == "tool")
2243 .filter_map(|message| message["tool_call_id"].as_str().map(str::to_string))
2244 .collect();
2245
2246 let mut carried: Vec<serde_json::Value> = Vec::with_capacity(snapshot.len());
2247 let mut called: std::collections::HashSet<String> = std::collections::HashSet::new();
2248
2249 for mut message in snapshot {
2250 match message["role"].as_str().unwrap_or_default() {
2251 "system" => continue,
2252 "assistant" => {
2253 if let Some(calls) = message["tool_calls"].as_array() {
2254 let kept: Vec<serde_json::Value> = calls
2255 .iter()
2256 .filter(|call| call["id"].as_str().is_some_and(|id| answered.contains(id)))
2257 .cloned()
2258 .collect();
2259 if kept.is_empty() {
2260 // Only unanswered requests: keep whatever text came
2261 // with them, drop the message if there was none.
2262 if let Some(object) = message.as_object_mut() {
2263 object.remove("tool_calls");
2264 }
2265 if message_text(&message).is_empty() {
2266 continue;
2267 }
2268 } else {
2269 for call in &kept {
2270 called.insert(call["id"].as_str().unwrap_or_default().to_string());
2271 }
2272 message["tool_calls"] = serde_json::Value::Array(kept);
2273 }
2274 }
2275 carried.push(message);
2276 }
2277 "tool" => {
2278 let answers_a_kept_call = message["tool_call_id"]
2279 .as_str()
2280 .is_some_and(|id| called.contains(id));
2281 if answers_a_kept_call {
2282 carried.push(message);
2283 }
2284 }
2285 _ => carried.push(message),
2286 }
2287 }
2288
2289 carried
2290 }
2291
2292 /// Take a cancelled prompt's user message back out of the live history.
2293 ///
2294 /// The client drops a cancelled prompt from its thread, so the model must not
2295 /// keep answering it. Only the first inference round can cancel with the user
2296 /// message as the last entry; later rounds end on tool or assistant output.
2297 /// A prompt that fails with an error is not taken back: the client keeps it,
2298 /// so the history keeps it too and a model switch carries it over.
2299 pub async fn forget_trailing_user_message(backend: &dyn InferenceBackend) {
2300 let mut history = backend.history_snapshot().await;
2301 if history
2302 .last()
2303 .is_some_and(|message| message["role"] == "user")
2304 {
2305 history.pop();
2306 backend.restore_history(history).await;
2307 }
2308 }
2309
2310 /// Replay `carried` (from [`carryover_history`]) into `backend`, on top of the
2311 /// system messages `backend` seeded for itself. Used when a model switch
2312 /// installs a new backend — or reloads the on-device engine, which wipes its
2313 /// history — so the thread continues under the new model instead of restarting.
2314 pub async fn adopt_carryover(backend: &dyn InferenceBackend, carried: Vec<serde_json::Value>) {
2315 if carried.is_empty() {
2316 return;
2317 }
2318 let mut rebuilt: Vec<serde_json::Value> = backend
2319 .history_snapshot()
2320 .await
2321 .into_iter()
2322 .take_while(|message| message["role"] == "system")
2323 .collect();
2324 rebuilt.extend(carried);
2325 backend.restore_history(rebuilt).await;
2326 }
2327
2328 #[cfg(test)]
2329 mod tests {
2330 use super::*;
2331
2332 /// The case from the issue: the endpoint says something a person can act
2333 /// on, and the status code and JSON around it are noise.
2334 #[test]
2335 fn an_endpoints_own_message_is_what_the_user_sees() {
2336 let body = r#"{"error":{"message":"Monthly siGit Code Cloud allowance reached. It resets at the start of your next billing period.","type":"server_error"}}"#;
2337
2338 let described = describe_api_error(reqwest::StatusCode::TOO_MANY_REQUESTS, body);
2339
2340 assert_eq!(
2341 described,
2342 "Monthly siGit Code Cloud allowance reached. \
2343 It resets at the start of your next billing period."
2344 );
2345 }
2346
2347 #[test]
2348 fn a_body_that_is_not_an_error_envelope_keeps_the_status() {
2349 let described = describe_api_error(reqwest::StatusCode::BAD_GATEWAY, "upstream said no");
2350
2351 assert!(described.contains("502"), "{described}");
2352 assert!(described.contains("upstream said no"), "{described}");
2353 }
2354
2355 /// The issue-#125 screenshots: a gateway's HTML error page pasted into
2356 /// chat tells the person nothing the status doesn't already say.
2357 #[test]
2358 fn an_html_error_page_is_not_pasted_into_the_message() {
2359 let page = "<!doctype html>\n<html lang=\"en\">\n<head>\n\
2360 <title>We're sorry, but something went wrong (500 Internal Server Error)</title>\n\
2361 </head>\n<body>…</body>\n</html>";
2362
2363 let described = describe_api_error(reqwest::StatusCode::GATEWAY_TIMEOUT, page);
2364
2365 assert_eq!(described, "endpoint returned 504 Gateway Timeout");
2366 }
2367
2368 #[test]
2369 fn an_empty_body_still_says_something() {
2370 let described = describe_api_error(reqwest::StatusCode::INTERNAL_SERVER_ERROR, " ");
2371
2372 assert_eq!(described, "endpoint returned 500 Internal Server Error");
2373 }
2374
2375 #[test]
2376 fn an_oversized_body_is_cut_down() {
2377 let body = "x".repeat(ERROR_BODY_LIMIT * 3);
2378
2379 let described = describe_api_error(reqwest::StatusCode::BAD_GATEWAY, &body);
2380
2381 assert!(described.len() < ERROR_BODY_LIMIT + 100, "{described}");
2382 assert!(described.ends_with('…'), "{described}");
2383 }
2384
2385 /// An envelope with nothing useful in it must not shadow the status, which
2386 /// would leave the user with an empty error.
2387 #[test]
2388 fn an_envelope_with_a_blank_message_falls_back() {
2389 let described = describe_api_error(
2390 reqwest::StatusCode::FORBIDDEN,
2391 r#"{"error":{"message":" "}}"#,
2392 );
2393
2394 assert!(described.contains("403"), "{described}");
2395 }
2396
2397 #[test]
2398 fn tools_json_wraps_function_schema() {
2399 let tools = vec![ToolSpec {
2400 name: "read_file".to_string(),
2401 description: "Read a file".to_string(),
2402 parameters_schema: r#"{"type":"object","properties":{"path":{"type":"string"}}}"#
2403 .to_string(),
2404 }];
2405 let json = OpenAiBackend::tools_json(&tools);
2406 assert_eq!(json[0]["type"], "function");
2407 assert_eq!(json[0]["function"]["name"], "read_file");
2408 assert_eq!(
2409 json[0]["function"]["parameters"]["properties"]["path"]["type"],
2410 "string"
2411 );
2412 }
2413
2414 #[test]
2415 fn malformed_schema_falls_back_to_empty_object() {
2416 let tools = vec![ToolSpec {
2417 name: "x".to_string(),
2418 description: String::new(),
2419 parameters_schema: "not json".to_string(),
2420 }];
2421 let json = OpenAiBackend::tools_json(&tools);
2422 assert_eq!(json[0]["function"]["parameters"]["type"], "object");
2423 }
2424
2425 #[test]
2426 fn streamed_assistant_history_omits_empty_tool_calls() {
2427 let value = streamed_assistant_history("hello", &[]);
2428 assert_eq!(value["role"], "assistant");
2429 assert_eq!(value["content"], "hello");
2430 assert!(value.get("tool_calls").is_none());
2431 }
2432
2433 #[test]
2434 fn streamed_assistant_history_preserves_tool_calls() {
2435 let calls = vec![ToolCall {
2436 id: "call_0".to_string(),
2437 name: "read_file".to_string(),
2438 arguments: r#"{"path":"a.rs"}"#.to_string(),
2439 }];
2440 let value = streamed_assistant_history("", &calls);
2441 assert!(value["content"].is_null());
2442 assert_eq!(value["tool_calls"][0]["id"], "call_0");
2443 assert_eq!(value["tool_calls"][0]["type"], "function");
2444 assert_eq!(value["tool_calls"][0]["function"]["name"], "read_file");
2445 assert_eq!(
2446 value["tool_calls"][0]["function"]["arguments"],
2447 r#"{"path":"a.rs"}"#
2448 );
2449 }
2450
2451 #[tokio::test]
2452 async fn cancelled_tool_results_close_out_history() {
2453 let backend = OpenAiBackend::new("http://localhost", "", "test-model", None);
2454 backend
2455 .history
2456 .lock()
2457 .await
2458 .push(streamed_assistant_history(
2459 "",
2460 &[ToolCall {
2461 id: "call_9".to_string(),
2462 name: "run_command".to_string(),
2463 arguments: r#"{"command":"ls"}"#.to_string(),
2464 }],
2465 ));
2466
2467 backend
2468 .record_cancelled_tool_results(vec![ToolResult {
2469 tool_call_id: "call_9".to_string(),
2470 content: "cancelled by the user".to_string(),
2471 }])
2472 .await;
2473
2474 let history = backend.history.lock().await;
2475 let last = history.last().unwrap();
2476 assert_eq!(last["role"], "tool");
2477 assert_eq!(last["tool_call_id"], "call_9");
2478 assert_eq!(last["content"], "cancelled by the user");
2479
2480 drop(history);
2481 backend
2482 .record_cancelled_tool_results(vec![ToolResult {
2483 tool_call_id: "call_9".to_string(),
2484 content: "duplicate cleanup".to_string(),
2485 }])
2486 .await;
2487 let history = backend.history.lock().await;
2488 assert_eq!(
2489 history
2490 .iter()
2491 .filter(|message| message["tool_call_id"] == "call_9")
2492 .count(),
2493 1
2494 );
2495 }
2496
2497 #[test]
2498 fn carryover_drops_system_messages_and_keeps_the_turns() {
2499 let snapshot = vec![
2500 serde_json::json!({ "role": "system", "content": "old prompt" }),
2501 serde_json::json!({ "role": "user", "content": "hello" }),
2502 serde_json::json!({ "role": "assistant", "content": "hi" }),
2503 ];
2504
2505 let carried = carryover_history(snapshot);
2506
2507 assert_eq!(carried.len(), 2);
2508 assert_eq!(carried[0]["role"], "user");
2509 assert_eq!(carried[1]["content"], "hi");
2510 }
2511
2512 #[test]
2513 fn carryover_keeps_a_trailing_user_message_from_a_failed_turn() {
2514 // The client keeps a prompt whose inference errored, so a switch must
2515 // not drop it (issue #124).
2516 let carried = carryover_history(vec![
2517 serde_json::json!({ "role": "system", "content": "prompt" }),
2518 serde_json::json!({ "role": "user", "content": "completed question" }),
2519 serde_json::json!({ "role": "assistant", "content": "completed answer" }),
2520 serde_json::json!({ "role": "user", "content": "failed question" }),
2521 ]);
2522
2523 assert_eq!(carried.len(), 3, "{carried:#?}");
2524 assert_eq!(carried.last().unwrap()["content"], "failed question");
2525 }
2526
2527 #[tokio::test]
2528 async fn forget_trailing_user_message_drops_only_a_cancelled_prompt() {
2529 let backend = OpenAiBackend::new("http://localhost", "", "m", None);
2530 backend
2531 .restore_history(vec![
2532 serde_json::json!({ "role": "user", "content": "completed question" }),
2533 serde_json::json!({ "role": "assistant", "content": "completed answer" }),
2534 serde_json::json!({ "role": "user", "content": "cancelled question" }),
2535 ])
2536 .await;
2537
2538 forget_trailing_user_message(&backend).await;
2539
2540 let history = backend.history_snapshot().await;
2541 assert_eq!(history.len(), 2, "{history:#?}");
2542 assert_eq!(history[1]["content"], "completed answer");
2543
2544 forget_trailing_user_message(&backend).await;
2545 assert_eq!(backend.history_snapshot().await.len(), 2);
2546 }
2547
2548 #[test]
2549 fn carryover_strips_a_tool_call_that_never_got_a_result() {
2550 // Switching mid-turn: the assistant asked for a tool, nothing answered.
2551 let snapshot = vec![
2552 serde_json::json!({ "role": "user", "content": "read a.rs" }),
2553 streamed_assistant_history(
2554 "on it",
2555 &[ToolCall {
2556 id: "call_1".to_string(),
2557 name: "read_file".to_string(),
2558 arguments: "{}".to_string(),
2559 }],
2560 ),
2561 ];
2562
2563 let carried = carryover_history(snapshot);
2564
2565 assert_eq!(carried.len(), 2);
2566 assert_eq!(carried[1]["content"], "on it");
2567 assert!(
2568 carried[1].get("tool_calls").is_none(),
2569 "an unanswered tool call must not survive the switch"
2570 );
2571 }
2572
2573 #[test]
2574 fn carryover_drops_a_textless_unanswered_tool_call_and_its_late_result() {
2575 let snapshot = vec![
2576 streamed_assistant_history(
2577 "",
2578 &[ToolCall {
2579 id: "call_1".to_string(),
2580 name: "read_file".to_string(),
2581 arguments: "{}".to_string(),
2582 }],
2583 ),
2584 // An orphan: its assistant message is gone with the line above.
2585 serde_json::json!({
2586 "role": "tool",
2587 "tool_call_id": "call_other",
2588 "content": "file contents",
2589 }),
2590 ];
2591
2592 assert!(carryover_history(snapshot).is_empty());
2593 }
2594
2595 #[test]
2596 fn carryover_keeps_an_answered_tool_call_paired_with_its_result() {
2597 let snapshot = vec![
2598 streamed_assistant_history(
2599 "",
2600 &[ToolCall {
2601 id: "call_1".to_string(),
2602 name: "read_file".to_string(),
2603 arguments: "{}".to_string(),
2604 }],
2605 ),
2606 serde_json::json!({
2607 "role": "tool",
2608 "tool_call_id": "call_1",
2609 "content": "file contents",
2610 }),
2611 ];
2612
2613 let carried = carryover_history(snapshot);
2614
2615 assert_eq!(carried.len(), 2);
2616 assert_eq!(carried[0]["tool_calls"][0]["id"], "call_1");
2617 assert_eq!(carried[1]["tool_call_id"], "call_1");
2618 }
2619
2620 #[tokio::test]
2621 async fn adopt_carryover_replays_the_thread_under_the_new_system_prompt() {
2622 let new_backend =
2623 OpenAiBackend::new("http://localhost", "", "m", Some("new prompt".into()));
2624
2625 let carried = carryover_history(vec![
2626 serde_json::json!({ "role": "system", "content": "old prompt" }),
2627 serde_json::json!({ "role": "user", "content": "hello" }),
2628 serde_json::json!({ "role": "assistant", "content": "hi" }),
2629 ]);
2630 adopt_carryover(&new_backend, carried).await;
2631
2632 let history = new_backend.history_snapshot().await;
2633 assert_eq!(history.len(), 3);
2634 assert_eq!(history[0]["role"], "system");
2635 assert_eq!(history[0]["content"], "new prompt");
2636 assert_eq!(history[1]["content"], "hello");
2637 assert_eq!(history[2]["content"], "hi");
2638 }
2639
2640 #[tokio::test]
2641 async fn adopt_carryover_leaves_a_fresh_backend_alone() {
2642 let new_backend =
2643 OpenAiBackend::new("http://localhost", "", "m", Some("new prompt".into()));
2644
2645 adopt_carryover(&new_backend, Vec::new()).await;
2646
2647 let history = new_backend.history_snapshot().await;
2648 assert_eq!(history.len(), 1);
2649 assert_eq!(history[0]["content"], "new prompt");
2650 }
2651
2652 fn user_with_image(text: &str, payload_chars: usize) -> serde_json::Value {
2653 serde_json::json!({
2654 "role": "user",
2655 "content": [
2656 { "type": "text", "text": text },
2657 { "type": "image_url", "image_url": {
2658 "url": format!("data:image/png;base64,{}", "A".repeat(payload_chars)),
2659 }},
2660 ],
2661 })
2662 }
2663
2664 #[test]
2665 fn user_message_uses_content_parts_only_when_there_is_an_image() {
2666 assert_eq!(
2667 user_message("hello", &[], &[]),
2668 serde_json::json!({ "role": "user", "content": "hello" })
2669 );
2670
2671 let image = ImageInput {
2672 mime_type: "image/png".to_string(),
2673 data: "AAAA".to_string(),
2674 };
2675 assert_eq!(
2676 user_message("what is this?", std::slice::from_ref(&image), &[]),
2677 serde_json::json!({
2678 "role": "user",
2679 "content": [
2680 { "type": "text", "text": "what is this?" },
2681 { "type": "image_url", "image_url": { "url": "data:image/png;base64,AAAA" } },
2682 ],
2683 })
2684 );
2685 // An image with no text sends no empty text part.
2686 let only_image = user_message("", &[image], &[]);
2687 assert_eq!(only_image["content"].as_array().unwrap().len(), 1);
2688 }
2689
2690 #[test]
2691 fn audio_is_sent_as_an_input_audio_part() {
2692 let clip = AudioInput {
2693 mime_type: "audio/mpeg".to_string(),
2694 data: "BBBB".to_string(),
2695 };
2696 assert_eq!(
2697 user_message("transcribe this", &[], &[clip]),
2698 serde_json::json!({
2699 "role": "user",
2700 "content": [
2701 { "type": "text", "text": "transcribe this" },
2702 { "type": "input_audio", "input_audio": { "data": "BBBB", "format": "mp3" } },
2703 ],
2704 })
2705 );
2706 }
2707
2708 #[test]
2709 fn audio_format_follows_the_mime_type() {
2710 let format = |mime: &str| {
2711 AudioInput {
2712 mime_type: mime.to_string(),
2713 data: String::new(),
2714 }
2715 .format()
2716 };
2717 assert_eq!(format("audio/wav"), "wav");
2718 assert_eq!(format("audio/x-wav"), "wav");
2719 assert_eq!(format("audio/wave"), "wav");
2720 assert_eq!(format("audio/mpeg"), "mp3");
2721 assert_eq!(format("Audio/MP3; codecs=mp3"), "mp3");
2722 assert_eq!(format("audio/flac"), "flac");
2723 }
2724
2725 #[test]
2726 fn a_model_without_audio_gets_a_note_and_keeps_the_image() {
2727 let history = vec![serde_json::json!({
2728 "role": "user",
2729 "content": [
2730 { "type": "text", "text": "what is this?" },
2731 { "type": "image_url", "image_url": { "url": "data:image/png;base64,AAAA" } },
2732 { "type": "input_audio", "input_audio": { "data": "BBBB", "format": "wav" } },
2733 ],
2734 })];
2735
2736 let sent = without_audio(&history);
2737 assert_eq!(
2738 sent[0]["content"],
2739 serde_json::json!([
2740 { "type": "text", "text": "what is this?" },
2741 { "type": "image_url", "image_url": { "url": "data:image/png;base64,AAAA" } },
2742 { "type": "text", "text": AUDIO_OMITTED_NOTE },
2743 ])
2744 );
2745 // With neither, the message is plain text again.
2746 assert_eq!(
2747 without_images(&sent)[0]["content"],
2748 format!("what is this?\n{AUDIO_OMITTED_NOTE}\n{IMAGE_OMITTED_NOTE}")
2749 );
2750 // History keeps the clip for a model that can take it later.
2751 assert_eq!(message_audio_count(&history[0]), 1);
2752 }
2753
2754 #[test]
2755 fn a_text_only_model_is_sent_a_note_in_place_of_each_image() {
2756 let history = vec![
2757 serde_json::json!({ "role": "system", "content": "prompt" }),
2758 user_with_image("what is this?", 16),
2759 serde_json::json!({ "role": "assistant", "content": "a cat" }),
2760 ];
2761 let sent = without_images(&history);
2762
2763 assert_eq!(sent[0], history[0]);
2764 assert_eq!(sent[2], history[2]);
2765 assert_eq!(
2766 sent[1]["content"],
2767 format!("what is this?\n{IMAGE_OMITTED_NOTE}")
2768 );
2769 // History keeps the image for a model that can read it later.
2770 assert_eq!(message_image_count(&history[1]), 1);
2771 }
2772
2773 #[test]
2774 fn a_remote_backend_reads_images_by_model_id() {
2775 let own_endpoint = OpenAiBackend::new("http://localhost", "", "gpt-4o-mini", None);
2776 assert!(own_endpoint.accepts_images());
2777 let text_tier = OpenAiBackend::new("http://localhost", "", "onde-nova", None);
2778 assert!(!text_tier.accepts_images());
2779 let image_tier = OpenAiBackend::new("http://localhost", "", "onde-large", None);
2780 assert!(image_tier.accepts_images());
2781 }
2782
2783 #[test]
2784 fn a_remote_backend_takes_audio_only_on_the_users_own_endpoint() {
2785 let own_endpoint = OpenAiBackend::new("http://localhost", "", "gpt-4o-audio", None);
2786 assert!(own_endpoint.accepts_audio());
2787 let cloud_tier = OpenAiBackend::new("http://localhost", "", "onde-large", None);
2788 assert!(!cloud_tier.accepts_audio());
2789 }
2790
2791 #[test]
2792 fn message_text_reads_both_content_shapes() {
2793 assert_eq!(
2794 message_text(&serde_json::json!({ "role": "user", "content": "hello" })),
2795 "hello"
2796 );
2797 let with_image = user_with_image("what is this?", 16);
2798 assert_eq!(message_text(&with_image), "what is this?");
2799 assert_eq!(message_image_count(&with_image), 1);
2800 // A tool-call-only assistant message has no content at all.
2801 let no_content = serde_json::json!({ "role": "assistant", "content": null });
2802 assert_eq!(message_text(&no_content), "");
2803 assert_eq!(message_image_count(&no_content), 0);
2804 }
2805
2806 #[test]
2807 fn estimate_tokens_does_not_count_an_image_payload_as_text() {
2808 // Two megabytes of base64 would read as half a million tokens.
2809 let history = vec![user_with_image("what is this?", 2_000_000)];
2810 let estimate = estimate_tokens(&history);
2811 assert!(
2812 (IMAGE_TOKEN_ESTIMATE..IMAGE_TOKEN_ESTIMATE + 100).contains(&estimate),
2813 "{estimate}"
2814 );
2815 }
2816
2817 #[test]
2818 fn summary_transcript_keeps_the_text_of_a_message_with_an_image() {
2819 let transcript = transcript_for_summary(&[user_with_image("what is this?", 64)]);
2820 assert!(transcript.contains("what is this?"), "{transcript}");
2821 assert!(transcript.contains("[attached an image]"), "{transcript}");
2822 assert!(!transcript.contains("base64"), "{transcript}");
2823 }
2824
2825 #[test]
2826 fn carryover_keeps_a_user_message_that_carries_an_image() {
2827 let carried = carryover_history(vec![
2828 serde_json::json!({ "role": "system", "content": "prompt" }),
2829 user_with_image("what is this?", 16),
2830 serde_json::json!({ "role": "assistant", "content": "a cat" }),
2831 ]);
2832 assert_eq!(carried.len(), 2);
2833 assert_eq!(message_image_count(&carried[0]), 1);
2834 }
2835
2836 #[test]
2837 fn estimate_tokens_scales_with_serialized_size() {
2838 assert_eq!(estimate_tokens(&[]), 0);
2839
2840 let short = vec![serde_json::json!({ "role": "user", "content": "hi" })];
2841 let long = vec![serde_json::json!({ "role": "user", "content": "x".repeat(4_000) })];
2842 let short_estimate = estimate_tokens(&short);
2843 let long_estimate = estimate_tokens(&long);
2844
2845 assert!(short_estimate > 0, "non-empty history estimates > 0 tokens");
2846 assert!(long_estimate > short_estimate, "longer history costs more");
2847 // 4,000 content chars / 4 ≈ 1,000 tokens, plus a little JSON framing.
2848 assert!((1_000..1_100).contains(&long_estimate), "{long_estimate}");
2849 }
2850
2851 #[tokio::test]
2852 async fn openai_snapshot_restore_round_trips_exactly() {
2853 let backend = OpenAiBackend::new("http://localhost", "", "m", Some("be helpful".into()));
2854 {
2855 let mut history = backend.history.lock().await;
2856 history.push(serde_json::json!({ "role": "user", "content": "hello" }));
2857 history.push(streamed_assistant_history(
2858 "",
2859 &[ToolCall {
2860 id: "call_1".to_string(),
2861 name: "read_file".to_string(),
2862 arguments: r#"{"path":"a.rs"}"#.to_string(),
2863 }],
2864 ));
2865 history.push(serde_json::json!({
2866 "role": "tool", "tool_call_id": "call_1", "content": "fn main() {}",
2867 }));
2868 history.push(serde_json::json!({ "role": "assistant", "content": "done" }));
2869 }
2870 let snapshot = backend.history_snapshot().await;
2871 assert_eq!(
2872 snapshot[0]["role"], "system",
2873 "snapshot keeps the system message"
2874 );
2875
2876 // Restoring into a backend seeded with a *different* system prompt must
2877 // replace everything, including that seed.
2878 let restored = OpenAiBackend::new("http://localhost", "", "m", Some("other seed".into()));
2879 restored.restore_history(snapshot.clone()).await;
2880 assert_eq!(restored.history_snapshot().await, snapshot);
2881 }
2882
2883 #[tokio::test]
2884 async fn local_restore_replaces_engine_history_and_system_context() {
2885 let engine = Arc::new(ChatEngine::new());
2886 let backend = LocalBackend::new(Arc::clone(&engine));
2887 backend
2888 .restore_history(vec![
2889 serde_json::json!({ "role": "system", "content": "thread A context" }),
2890 serde_json::json!({ "role": "user", "content": "thread A question" }),
2891 ])
2892 .await;
2893
2894 backend
2895 .restore_history(vec![
2896 serde_json::json!({ "role": "system", "content": "thread B context" }),
2897 serde_json::json!({ "role": "user", "content": "thread B question" }),
2898 ])
2899 .await;
2900
2901 let restored = backend.history_snapshot().await;
2902 assert_eq!(restored.len(), 2, "{restored:#?}");
2903 assert_eq!(restored[0]["content"], "thread B context");
2904 assert_eq!(restored[1]["content"], "thread B question");
2905 assert!(
2906 restored.iter().all(|message| !message["content"]
2907 .as_str()
2908 .unwrap_or_default()
2909 .contains("thread A")),
2910 "restore_history must replace, not append to, the local engine: {restored:#?}"
2911 );
2912
2913 let error = backend
2914 .send_message_with_tools("must not run", &[], None)
2915 .await
2916 .unwrap_err();
2917 assert!(error.contains("not ready"), "{error}");
2918 assert_eq!(backend.history_snapshot().await, restored);
2919 }
2920
2921 /// Minimal scripted OpenAI-compatible endpoint: accepts one HTTP request on
2922 /// a std listener and answers with a fixed non-streaming completion. The
2923 /// receiver yields the request body the backend actually put on the wire.
2924 fn spawn_completion_stub(
2925 summary: &str,
2926 ) -> (std::net::SocketAddr, std::sync::mpsc::Receiver<String>) {
2927 spawn_message_stub(serde_json::json!({ "role": "assistant", "content": summary }))
2928 }
2929
2930 /// Like [`spawn_completion_stub`], answering with an arbitrary message.
2931 fn spawn_message_stub(
2932 message: serde_json::Value,
2933 ) -> (std::net::SocketAddr, std::sync::mpsc::Receiver<String>) {
2934 spawn_choice_stub(serde_json::json!({ "message": message }))
2935 }
2936
2937 /// Like [`spawn_message_stub`], answering with a whole choice, so a test
2938 /// can set what sits beside the message.
2939 fn spawn_choice_stub(
2940 choice: serde_json::Value,
2941 ) -> (std::net::SocketAddr, std::sync::mpsc::Receiver<String>) {
2942 use std::io::{Read, Write};
2943
2944 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2945 let addr = listener.local_addr().unwrap();
2946 let (sender, receiver) = std::sync::mpsc::channel();
2947 let body = serde_json::json!({ "choices": [choice] }).to_string();
2948 std::thread::spawn(move || {
2949 let (mut stream, _) = listener.accept().unwrap();
2950 // Read until the full request (headers + content-length body) is in.
2951 let mut request = Vec::new();
2952 let mut chunk = [0u8; 4096];
2953 loop {
2954 let n = stream.read(&mut chunk).unwrap_or(0);
2955 if n == 0 {
2956 break;
2957 }
2958 request.extend_from_slice(&chunk[..n]);
2959 if let Some(headers_end) =
2960 request.windows(4).position(|window| window == b"\r\n\r\n")
2961 {
2962 let headers = String::from_utf8_lossy(&request[..headers_end]);
2963 let content_length = headers
2964 .lines()
2965 .find_map(|line| {
2966 line.to_ascii_lowercase()
2967 .strip_prefix("content-length:")
2968 .map(|value| value.trim().parse::<usize>().unwrap_or(0))
2969 })
2970 .unwrap_or(0);
2971 if request.len() >= headers_end + 4 + content_length {
2972 let _ = sender.send(
2973 String::from_utf8_lossy(&request[headers_end + 4..]).into_owned(),
2974 );
2975 break;
2976 }
2977 }
2978 }
2979 let response = format!(
2980 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\n\
2981 content-length: {}\r\nconnection: close\r\n\r\n{}",
2982 body.len(),
2983 body
2984 );
2985 let _ = stream.write_all(response.as_bytes());
2986 });
2987 (addr, receiver)
2988 }
2989
2990 /// Scripted SSE endpoint: one response per connection, in order. The
2991 /// receiver yields each request body.
2992 fn spawn_sse_sequence_stub(
2993 replies: Vec<Vec<String>>,
2994 ) -> (std::net::SocketAddr, std::sync::mpsc::Receiver<String>) {
2995 use std::io::{Read, Write};
2996
2997 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2998 let addr = listener.local_addr().unwrap();
2999 let (sender, receiver) = std::sync::mpsc::channel();
3000 std::thread::spawn(move || {
3001 for deltas in replies {
3002 let (mut stream, _) = listener.accept().unwrap();
3003 let mut request = Vec::new();
3004 let mut buf = [0u8; 4096];
3005 loop {
3006 let n = stream.read(&mut buf).unwrap_or(0);
3007 if n == 0 {
3008 break;
3009 }
3010 request.extend_from_slice(&buf[..n]);
3011 if let Some(end) = request.windows(4).position(|w| w == b"\r\n\r\n") {
3012 let headers = String::from_utf8_lossy(&request[..end]).to_ascii_lowercase();
3013 let length = headers
3014 .lines()
3015 .find_map(|l| l.strip_prefix("content-length:")?.trim().parse().ok())
3016 .unwrap_or(0usize);
3017 if request.len() >= end + 4 + length {
3018 let _ = sender
3019 .send(String::from_utf8_lossy(&request[end + 4..]).into_owned());
3020 break;
3021 }
3022 }
3023 }
3024 let mut body = String::new();
3025 for delta in deltas {
3026 let frame = serde_json::json!({
3027 "choices": [{ "delta": { "content": delta } }]
3028 });
3029 body.push_str(&format!("data: {frame}\n\n"));
3030 }
3031 body.push_str("data: [DONE]\n\n");
3032 let response = format!(
3033 "HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\n\
3034 content-length: {}\r\nconnection: close\r\n\r\n{body}",
3035 body.len()
3036 );
3037 let _ = stream.write_all(response.as_bytes());
3038 }
3039 });
3040 (addr, receiver)
3041 }
3042
3043 /// Issue #123: a streamed reply that degenerates into a repetition loop is
3044 /// cut off, history keeps only what came before the loop, and the model
3045 /// gets one retry.
3046 #[tokio::test]
3047 async fn a_repetition_loop_is_cut_and_retried_once() {
3048 let intro = "Let me look at the commits. ";
3049 let looped: Vec<String> = std::iter::once(intro.to_string())
3050 .chain(
3051 crate::repetition::issue_loop_text()
3052 .as_bytes()
3053 .chunks(40)
3054 .map(|c| String::from_utf8_lossy(c).into_owned()),
3055 )
3056 .collect();
3057 let (addr, requests) =
3058 spawn_sse_sequence_stub(vec![looped, vec!["Here is the answer.".to_string()]]);
3059 let backend =
3060 OpenAiBackend::new(format!("http://{addr}/v1"), "test-key", "test-model", None);
3061 let (sink, mut tokens) = tokio::sync::mpsc::unbounded_channel();
3062 let tools = vec![ToolSpec {
3063 name: "run_command".to_string(),
3064 description: "Run a command".to_string(),
3065 parameters_schema: r#"{"type":"object","properties":{}}"#.to_string(),
3066 }];
3067
3068 let result = backend
3069 .send_message_with_tools("what changed", &tools, Some(&sink))
3070 .await
3071 .unwrap();
3072
3073 assert_eq!(result.finish, FinishReason::Complete);
3074 assert!(result.text.starts_with(intro.trim_end()), "{}", result.text);
3075 assert!(result.text.ends_with("Here is the answer."));
3076 assert!(
3077 result.text.len() < 600,
3078 "junk kept: {} bytes",
3079 result.text.len()
3080 );
3081
3082 let first = requests.recv().unwrap();
3083 assert!(!first.contains(REPETITION_RETRY));
3084 let second = requests.recv().unwrap();
3085 assert!(second.contains(REPETITION_RETRY));
3086
3087 let history = backend.history_snapshot().await;
3088 let kept = history
3089 .iter()
3090 .find(|m| m["role"] == "assistant")
3091 .and_then(|m| m["content"].as_str())
3092 .unwrap();
3093 assert!(
3094 kept.len() < 600,
3095 "history kept the loop: {} bytes",
3096 kept.len()
3097 );
3098
3099 drop(sink);
3100 let mut shown = String::new();
3101 while let Ok(chunk) = tokens.try_recv() {
3102 if let TokenChunk::Visible(text) = chunk {
3103 shown.push_str(&text);
3104 }
3105 }
3106 assert!(shown.contains("stopped this reply"), "{shown}");
3107 }
3108
3109 /// Serves `responses` in order, one per connection, as `(status,
3110 /// content type, body)`, and hands back each request body.
3111 fn spawn_scripted_stub(
3112 responses: Vec<(u16, &'static str, String)>,
3113 ) -> (std::net::SocketAddr, std::sync::mpsc::Receiver<String>) {
3114 use std::io::{Read, Write};
3115
3116 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
3117 let addr = listener.local_addr().unwrap();
3118 let (sender, receiver) = std::sync::mpsc::channel();
3119 std::thread::spawn(move || {
3120 for (status, content_type, body) in responses {
3121 let Ok((mut stream, _)) = listener.accept() else {
3122 return;
3123 };
3124 let mut request = Vec::new();
3125 let mut chunk = [0u8; 4096];
3126 loop {
3127 let n = stream.read(&mut chunk).unwrap_or(0);
3128 if n == 0 {
3129 break;
3130 }
3131 request.extend_from_slice(&chunk[..n]);
3132 if let Some(headers_end) =
3133 request.windows(4).position(|window| window == b"\r\n\r\n")
3134 {
3135 let headers = String::from_utf8_lossy(&request[..headers_end]);
3136 let content_length = headers
3137 .lines()
3138 .find_map(|line| {
3139 line.to_ascii_lowercase()
3140 .strip_prefix("content-length:")
3141 .map(|value| value.trim().parse::<usize>().unwrap_or(0))
3142 })
3143 .unwrap_or(0);
3144 if request.len() >= headers_end + 4 + content_length {
3145 let _ = sender.send(
3146 String::from_utf8_lossy(&request[headers_end + 4..]).into_owned(),
3147 );
3148 break;
3149 }
3150 }
3151 }
3152 let response = format!(
3153 "HTTP/1.1 {status} X\r\ncontent-type: {content_type}\r\n\
3154 content-length: {}\r\nconnection: close\r\n\r\n{}",
3155 body.len(),
3156 body
3157 );
3158 let _ = stream.write_all(response.as_bytes());
3159 }
3160 });
3161 (addr, receiver)
3162 }
3163
3164 fn sse(events: &[serde_json::Value]) -> String {
3165 let mut body: String = events
3166 .iter()
3167 .map(|event| format!("data: {event}\n\n"))
3168 .collect();
3169 body.push_str("data: [DONE]\n\n");
3170 body
3171 }
3172
3173 /// A stream asks for token usage and reads it off the last chunk, the
3174 /// one with no choices, for the client's context meter (issue #194).
3175 #[tokio::test]
3176 async fn a_stream_reports_the_usage_it_was_asked_for() {
3177 let (addr, requests) = spawn_scripted_stub(vec![(
3178 200,
3179 "text/event-stream",
3180 sse(&[
3181 serde_json::json!({"choices": [{"delta": {"content": "Hi."}}]}),
3182 serde_json::json!({
3183 "choices": [],
3184 "usage": {"prompt_tokens": 1200, "completion_tokens": 34, "total_tokens": 1234},
3185 }),
3186 ]),
3187 )]);
3188 let backend =
3189 OpenAiBackend::new(format!("http://{addr}/v1"), "test-key", "test-model", None);
3190 let (sink, _chunks) = tokio::sync::mpsc::unbounded_channel();
3191
3192 let result = backend
3193 .send_message_with_tools("hello", &[], Some(&sink))
3194 .await
3195 .unwrap();
3196
3197 assert_eq!(result.text, "Hi.");
3198 assert_eq!(result.context_tokens, Some(1234));
3199 let request: serde_json::Value = serde_json::from_str(&requests.recv().unwrap()).unwrap();
3200 assert_eq!(request["stream_options"]["include_usage"], true);
3201 }
3202
3203 #[tokio::test]
3204 async fn a_json_reply_reports_its_usage() {
3205 let body = serde_json::json!({
3206 "choices": [{"message": {"role": "assistant", "content": "Hi."}}],
3207 "usage": {"prompt_tokens": 90, "completion_tokens": 10},
3208 })
3209 .to_string();
3210 let (addr, requests) = spawn_scripted_stub(vec![(200, "application/json", body)]);
3211 let backend =
3212 OpenAiBackend::new(format!("http://{addr}/v1"), "test-key", "test-model", None);
3213
3214 let result = backend
3215 .send_message_with_tools("hello", &[], None)
3216 .await
3217 .unwrap();
3218
3219 // No total given: prompt plus completion.
3220 assert_eq!(result.context_tokens, Some(100));
3221 let request: serde_json::Value = serde_json::from_str(&requests.recv().unwrap()).unwrap();
3222 assert!(request.get("stream_options").is_none(), "{request}");
3223 }
3224
3225 /// An endpoint that predates `stream_options` is asked again without it,
3226 /// and not asked for usage again after that.
3227 #[tokio::test]
3228 async fn an_endpoint_that_refuses_stream_options_still_streams() {
3229 let refusal = serde_json::json!({
3230 "error": {"message": "Unrecognized request argument supplied: stream_options"},
3231 })
3232 .to_string();
3233 let reply = sse(&[serde_json::json!({"choices": [{"delta": {"content": "Hi."}}]})]);
3234 let (addr, requests) = spawn_scripted_stub(vec![
3235 (400, "application/json", refusal),
3236 (200, "text/event-stream", reply.clone()),
3237 (200, "text/event-stream", reply),
3238 ]);
3239 let backend =
3240 OpenAiBackend::new(format!("http://{addr}/v1"), "test-key", "test-model", None);
3241 let (sink, _chunks) = tokio::sync::mpsc::unbounded_channel();
3242
3243 let first = backend
3244 .send_message_with_tools("hello", &[], Some(&sink))
3245 .await
3246 .unwrap();
3247 assert_eq!(first.text, "Hi.");
3248 assert_eq!(first.context_tokens, None);
3249 let second = backend
3250 .send_message_with_tools("again", &[], Some(&sink))
3251 .await
3252 .unwrap();
3253 assert_eq!(second.text, "Hi.");
3254
3255 let bodies: Vec<serde_json::Value> = requests
3256 .try_iter()
3257 .map(|body| serde_json::from_str(&body).unwrap())
3258 .collect();
3259 assert_eq!(bodies.len(), 3);
3260 assert!(bodies[0].get("stream_options").is_some());
3261 assert!(bodies[1].get("stream_options").is_none());
3262 assert!(bodies[2].get("stream_options").is_none());
3263 }
3264
3265 #[test]
3266 fn finish_reasons_map_from_the_wire() {
3267 for reason in [None, Some("stop"), Some("tool_calls"), Some("unheard-of")] {
3268 assert_eq!(FinishReason::from_wire(reason), FinishReason::Complete);
3269 }
3270 assert_eq!(
3271 FinishReason::from_wire(Some("length")),
3272 FinishReason::Length
3273 );
3274 assert_eq!(
3275 FinishReason::from_wire(Some("content_filter")),
3276 FinishReason::ContentFilter
3277 );
3278 }
3279
3280 /// The non-streaming path (headless runs, subagents) reads the reason off
3281 /// the choice; a reply cut off at the token limit must not look finished.
3282 #[tokio::test]
3283 async fn a_truncated_json_reply_reports_length() {
3284 let (addr, _requests) = spawn_choice_stub(serde_json::json!({
3285 "message": { "role": "assistant", "content": "The answer is" },
3286 "finish_reason": "length",
3287 }));
3288 let backend =
3289 OpenAiBackend::new(format!("http://{addr}/v1"), "test-key", "test-model", None);
3290
3291 let result = backend
3292 .send_message_with_tools("explain", &[], None)
3293 .await
3294 .unwrap();
3295
3296 assert_eq!(result.text, "The answer is");
3297 assert_eq!(result.finish, FinishReason::Length);
3298 }
3299
3300 /// A structured call can come back with a broken inline block in the same
3301 /// message. The call runs; the block reaches neither the reply nor the
3302 /// history, where the model would read back a call that never ran.
3303 #[tokio::test]
3304 async fn a_broken_inline_block_beside_a_structured_call_is_dropped() {
3305 let (addr, _requests) = spawn_message_stub(serde_json::json!({
3306 "role": "assistant",
3307 "content": "Checking.<tool_call>run_command CheckStatus=nope</tool_call>",
3308 "tool_calls": [{
3309 "id": "call_1",
3310 "type": "function",
3311 "function": { "name": "run_command", "arguments": "{\"command\":\"pwd\"}" },
3312 }],
3313 }));
3314 let backend =
3315 OpenAiBackend::new(format!("http://{addr}/v1"), "test-key", "test-model", None);
3316 let tools = vec![ToolSpec {
3317 name: "run_command".to_string(),
3318 description: "Run a command".to_string(),
3319 parameters_schema: r#"{"type":"object","properties":{"command":{"type":"string"}}}"#
3320 .to_string(),
3321 }];
3322
3323 let result = backend
3324 .send_message_with_tools("where am I", &tools, None)
3325 .await
3326 .unwrap();
3327
3328 assert_eq!(result.text, "Checking.");
3329 assert_eq!(result.tool_calls.len(), 1);
3330 assert_eq!(result.tool_calls[0].id, "call_1");
3331 let history = backend.history_snapshot().await;
3332 let reply = history.last().unwrap();
3333 assert_eq!(reply["content"], "Checking.");
3334 assert_eq!(reply["tool_calls"][0]["id"], "call_1");
3335 }
3336
3337 /// A structured call plus a well-formed inline block: both run, and the
3338 /// inline markup leaves the reply and history, as on the streaming path.
3339 #[tokio::test]
3340 async fn a_well_formed_inline_block_beside_a_structured_call_also_runs() {
3341 let (addr, _requests) = spawn_message_stub(serde_json::json!({
3342 "role": "assistant",
3343 "content": "Checking.<tool_call>run_command<arg_key>command</arg_key><arg_value>ls</arg_value></tool_call>",
3344 "tool_calls": [{
3345 "id": "call_1",
3346 "type": "function",
3347 "function": { "name": "run_command", "arguments": "{\"command\":\"pwd\"}" },
3348 }],
3349 }));
3350 let backend =
3351 OpenAiBackend::new(format!("http://{addr}/v1"), "test-key", "test-model", None);
3352 let tools = vec![ToolSpec {
3353 name: "run_command".to_string(),
3354 description: "Run a command".to_string(),
3355 parameters_schema: r#"{"type":"object","properties":{"command":{"type":"string"}}}"#
3356 .to_string(),
3357 }];
3358
3359 let result = backend
3360 .send_message_with_tools("where am I", &tools, None)
3361 .await
3362 .unwrap();
3363
3364 assert_eq!(result.text, "Checking.");
3365 let ids: Vec<&str> = result.tool_calls.iter().map(|c| c.id.as_str()).collect();
3366 assert_eq!(ids, ["call_1", "call_recovered_1"]);
3367 assert_eq!(result.tool_calls[1].arguments, r#"{"command":"ls"}"#);
3368 let history = backend.history_snapshot().await;
3369 let reply = history.last().unwrap();
3370 assert_eq!(reply["content"], "Checking.");
3371 assert_eq!(reply["tool_calls"].as_array().unwrap().len(), 2);
3372 }
3373
3374 #[tokio::test]
3375 async fn compact_history_rebuilds_system_summary_and_tail() {
3376 let (addr, _requests) = spawn_completion_stub("We refactored backend.rs; tests pass.");
3377 let backend = OpenAiBackend::new(
3378 format!("http://{addr}/v1"),
3379 "test-key",
3380 "test-model",
3381 Some("be helpful".into()),
3382 );
3383 {
3384 let mut history = backend.history.lock().await;
3385 for i in 0..5 {
3386 let role = if i % 2 == 0 { "user" } else { "assistant" };
3387 history.push(serde_json::json!({
3388 "role": role, "content": format!("message {i}"),
3389 }));
3390 }
3391 }
3392
3393 backend.compact_history(2).await.unwrap();
3394
3395 let history = backend.history_snapshot().await;
3396 assert_eq!(history.len(), 4, "system + summary + last 2: {history:?}");
3397 assert_eq!(history[0]["role"], "system");
3398 assert_eq!(history[0]["content"], "be helpful");
3399 assert_eq!(history[1]["role"], "user");
3400 let summary_text = history[1]["content"].as_str().unwrap();
3401 assert!(summary_text.starts_with("[Conversation summary]\n"));
3402 assert!(summary_text.contains("We refactored backend.rs; tests pass."));
3403 assert_eq!(
3404 history[2],
3405 serde_json::json!({ "role": "assistant", "content": "message 3" })
3406 );
3407 assert_eq!(
3408 history[3],
3409 serde_json::json!({ "role": "user", "content": "message 4" })
3410 );
3411 }
3412
3413 /// Compacting a tool-heavy session must not put tool shapes on the wire.
3414 /// The summarization round offers no `tools`, and endpoints reject tool
3415 /// calls and tool results that arrive without a schema — which used to make
3416 /// compaction fail forever in any session that had run a single tool.
3417 #[tokio::test]
3418 async fn compact_history_sends_no_tool_artifacts() {
3419 let (addr, requests) = spawn_completion_stub("Ran git status on main.");
3420 let backend = OpenAiBackend::new(
3421 format!("http://{addr}/v1"),
3422 "test-key",
3423 "test-model",
3424 Some("be helpful".into()),
3425 );
3426 {
3427 let mut history = backend.history.lock().await;
3428 history.push(serde_json::json!({ "role": "user", "content": "check the repo" }));
3429 history.push(serde_json::json!({
3430 "role": "assistant",
3431 "content": null,
3432 "tool_calls": [{
3433 "id": "call_1",
3434 "type": "function",
3435 "function": { "name": "run_command", "arguments": "{\"command\":\"git status\"}" },
3436 }],
3437 }));
3438 history.push(serde_json::json!({
3439 "role": "tool",
3440 "tool_call_id": "call_1",
3441 "content": "on branch main",
3442 }));
3443 }
3444
3445 backend.compact_history(2).await.unwrap();
3446
3447 let body: serde_json::Value =
3448 serde_json::from_str(&requests.recv().unwrap()).expect("request body is JSON");
3449 assert!(
3450 body.get("tools").is_none(),
3451 "summarization offers no tools: {body}"
3452 );
3453 for message in body["messages"].as_array().unwrap() {
3454 assert!(
3455 message.get("tool_calls").is_none(),
3456 "no tool_calls may be sent without a schema: {message}"
3457 );
3458 assert_ne!(
3459 message["role"], "tool",
3460 "no tool results may be sent without a schema: {message}"
3461 );
3462 }
3463
3464 // The tool round still has to survive into the summary request as prose,
3465 // or the summary loses the work the session actually did.
3466 let transcript = body["messages"].as_array().unwrap().last().unwrap()["content"]
3467 .as_str()
3468 .unwrap();
3469 assert!(transcript.contains("called run_command({\"command\":\"git status\"})"));
3470 assert!(transcript.contains("tool result: on branch main"));
3471 }
3472
3473 /// Compaction only fails when even the truncation fallback cannot fit the
3474 /// history in the budget — here the newest message alone is bigger than
3475 /// the whole window.
3476 #[tokio::test]
3477 async fn compact_history_failure_leaves_history_intact() {
3478 // No listener at this address, so summarization fails; the system
3479 // prompt alone is bigger than the window, so even the minimal
3480 // fallback rebuild cannot fit — the genuinely unrecoverable case.
3481 let backend = OpenAiBackend::new(
3482 "http://127.0.0.1:9",
3483 "",
3484 "test-model",
3485 Some("s".repeat(DEFAULT_CONTEXT_TOKEN_BUDGET * 4 + 1000)),
3486 );
3487 backend
3488 .history
3489 .lock()
3490 .await
3491 .push(serde_json::json!({ "role": "user", "content": "hello" }));
3492 let before = backend.history_snapshot().await;
3493
3494 assert!(backend.compact_history(2).await.is_err());
3495 assert_eq!(backend.history_snapshot().await, before);
3496 }
3497
3498 /// The issue-#125 path: the summarization endpoint is down, and the
3499 /// session must still shrink rather than die with "start a new thread".
3500 #[tokio::test]
3501 async fn failed_summarization_falls_back_to_truncation() {
3502 // Nothing listens here, so the summarization round always fails.
3503 let backend =
3504 OpenAiBackend::new("http://127.0.0.1:9", "", "test-model", Some("sys".into()));
3505 {
3506 let mut history = backend.history.lock().await;
3507 for i in 0..20 {
3508 history.push(serde_json::json!({
3509 "role": "user",
3510 "content": format!("message {i}: {}", "padding ".repeat(200)),
3511 }));
3512 }
3513 }
3514
3515 backend.compact_history(4).await.unwrap();
3516
3517 let history = backend.history_snapshot().await;
3518 assert_eq!(history[0]["role"], "system", "{history:?}");
3519 assert_eq!(history[1]["content"], TRUNCATION_PLACEHOLDER, "{history:?}");
3520 // The newest messages survive; the oldest were dropped to fit.
3521 assert_eq!(
3522 history.last().unwrap()["content"]
3523 .as_str()
3524 .unwrap()
3525 .split(':')
3526 .next()
3527 .unwrap(),
3528 "message 19",
3529 "{history:?}"
3530 );
3531 assert!(
3532 estimate_tokens(&history) <= DEFAULT_CONTEXT_TOKEN_BUDGET,
3533 "fallback result must fit the budget: ~{} tokens",
3534 estimate_tokens(&history)
3535 );
3536 }
3537
3538 /// A history small enough to begin with keeps its whole tail: the
3539 /// fallback drops nothing beyond what `keep_last` already folded away.
3540 #[tokio::test]
3541 async fn truncation_fallback_keeps_the_newest_messages_that_fit() {
3542 let system = vec![serde_json::json!({ "role": "system", "content": "sys" })];
3543 let tail: Vec<serde_json::Value> = (0..4)
3544 .map(|i| serde_json::json!({ "role": "user", "content": format!("m{i}") }))
3545 .collect();
3546
3547 let rebuilt = truncate_history_to_budget(&system, tail).unwrap();
3548
3549 assert_eq!(rebuilt[0]["role"], "system");
3550 assert_eq!(rebuilt[1]["content"], TRUNCATION_PLACEHOLDER);
3551 assert_eq!(rebuilt.len(), 6, "{rebuilt:?}");
3552 assert_eq!(rebuilt[2]["content"], "m0");
3553 assert_eq!(rebuilt[5]["content"], "m3");
3554 }
3555
3556 /// A multi-system-message session keeps every leading system message in
3557 /// the fallback, exactly like the summarization-success path.
3558 #[test]
3559 fn truncation_fallback_keeps_all_leading_system_messages() {
3560 let system = vec![
3561 serde_json::json!({ "role": "system", "content": "seed prompt" }),
3562 serde_json::json!({ "role": "system", "content": "session context" }),
3563 ];
3564 let tail = vec![serde_json::json!({ "role": "user", "content": "hi" })];
3565
3566 let rebuilt = truncate_history_to_budget(&system, tail).unwrap();
3567
3568 assert_eq!(
3569 rebuilt[0]["content"], "seed prompt",
3570 "first system message kept: {rebuilt:?}"
3571 );
3572 assert_eq!(
3573 rebuilt[1]["content"], "session context",
3574 "second system message kept: {rebuilt:?}"
3575 );
3576 }
3577
3578 #[test]
3579 fn truncation_fallback_drops_orphaned_tool_results_with_their_call() {
3580 // A caller that split mid-round hands in a tail starting with tool
3581 // results whose assistant call message already fell outside the kept
3582 // window; those orphans must go before anything else, or strict
3583 // endpoints reject the next request.
3584 let tail = vec![
3585 serde_json::json!({ "role": "tool", "tool_call_id": "call_1", "content": "ok" }),
3586 serde_json::json!({ "role": "user", "content": "thanks" }),
3587 serde_json::json!({ "role": "assistant", "content": "done" }),
3588 ];
3589
3590 let rebuilt = truncate_history_to_budget(&[], tail).unwrap();
3591
3592 let roles: Vec<&str> = rebuilt.iter().filter_map(|m| m["role"].as_str()).collect();
3593 assert_eq!(
3594 roles,
3595 ["user", "user", "assistant"],
3596 "placeholder + the surviving exchange, orphan gone: {rebuilt:?}"
3597 );
3598 }
3599
3600 /// Review follow-up: a tail that drains to empty (one oversized tool
3601 /// round) must still test the minimal rebuild — system + placeholder fits,
3602 /// so the session is recoverable, not "unfixable".
3603 #[test]
3604 fn truncation_fallback_recovers_when_the_whole_tail_was_one_oversized_round() {
3605 let system = vec![serde_json::json!({ "role": "system", "content": "sys" })];
3606 let tail = vec![
3607 serde_json::json!({
3608 "role": "assistant",
3609 "content": null,
3610 "tool_calls": [{
3611 "id": "call_1", "type": "function",
3612 "function": { "name": "read_file", "arguments": "{}" },
3613 }],
3614 }),
3615 serde_json::json!({
3616 "role": "tool",
3617 "tool_call_id": "call_1",
3618 "content": "y".repeat(DEFAULT_CONTEXT_TOKEN_BUDGET * 4 + 1000),
3619 }),
3620 ];
3621
3622 let rebuilt = truncate_history_to_budget(&system, tail).unwrap();
3623
3624 assert_eq!(rebuilt.len(), 2, "{rebuilt:?}");
3625 assert_eq!(rebuilt[0]["role"], "system");
3626 assert_eq!(rebuilt[1]["content"], TRUNCATION_PLACEHOLDER);
3627 }
3628
3629 #[test]
3630 fn truncation_fallback_reports_when_nothing_fits() {
3631 // Even the minimal rebuild (system + placeholder, no conversation)
3632 // overflows: the system messages alone are bigger than the window.
3633 let system = vec![serde_json::json!({
3634 "role": "system",
3635 "content": "s".repeat(DEFAULT_CONTEXT_TOKEN_BUDGET * 4 + 1000),
3636 })];
3637
3638 assert!(truncate_history_to_budget(&system, Vec::new()).is_none());
3639 }
3640
3641 /// Review follow-up: the local fallback recovers from a newest message
3642 /// that overflows the window on its own, the same way the remote one does.
3643 #[test]
3644 fn local_truncation_fallback_drops_an_oversized_final_message() {
3645 let system = vec![ChatMessage::system("sys")];
3646 let tail = vec![
3647 ChatMessage::user("hi"),
3648 ChatMessage::user("y".repeat(DEFAULT_CONTEXT_TOKEN_BUDGET * 4 + 1000)),
3649 ];
3650
3651 let rebuilt = truncate_chat_messages_to_budget(system, tail).unwrap();
3652
3653 assert_eq!(rebuilt.len(), 2, "{rebuilt:?}");
3654 assert_eq!(rebuilt[0].content, "sys");
3655 assert_eq!(rebuilt[1].content, TRUNCATION_PLACEHOLDER);
3656 }
3657
3658 #[test]
3659 fn local_truncation_fallback_keeps_the_newest_messages_that_fit() {
3660 let big = DEFAULT_CONTEXT_TOKEN_BUDGET * 4 / 2;
3661 let tail = vec![
3662 ChatMessage::user("a".repeat(big)),
3663 ChatMessage::user("b".repeat(big)),
3664 ChatMessage::user("newest"),
3665 ];
3666
3667 let rebuilt = truncate_chat_messages_to_budget(Vec::new(), tail).unwrap();
3668
3669 assert_eq!(rebuilt.len(), 3, "oldest dropped, rest kept");
3670 assert_eq!(rebuilt[0].content, TRUNCATION_PLACEHOLDER);
3671 assert!(rebuilt[1].content.starts_with('b'));
3672 assert_eq!(rebuilt[2].content, "newest");
3673 }
3674
3675 #[test]
3676 fn local_truncation_fallback_reports_when_the_system_messages_overflow() {
3677 let system = vec![ChatMessage::system(
3678 "s".repeat(DEFAULT_CONTEXT_TOKEN_BUDGET * 4 + 1000),
3679 )];
3680
3681 assert!(truncate_chat_messages_to_budget(system, Vec::new()).is_none());
3682 }
3683
3684 /// Review follow-up: head and tail share the budget, so the trimmed
3685 /// transcript stays near the cap instead of reaching twice it.
3686 #[test]
3687 fn truncate_transcript_middle_stays_within_the_cap() {
3688 let lines: Vec<String> = (0..2_000)
3689 .map(|i| format!("user: message {i} with some padding text"))
3690 .collect();
3691 let transcript = lines.join("\n");
3692
3693 let trimmed = truncate_transcript_middle(&transcript, 200); // ~800 chars
3694
3695 assert!(trimmed.contains("message 0 "), "opening kept: {trimmed}");
3696 assert!(trimmed.contains("message 1999"), "closing kept: {trimmed}");
3697 assert!(
3698 trimmed.len() <= 800 + 80,
3699 "cap plus the omission marker, got {} chars",
3700 trimmed.len()
3701 );
3702 }
3703
3704 #[test]
3705 fn truncate_transcript_middle_keeps_short_transcripts_verbatim() {
3706 let transcript = "user: hello\n\nassistant: hi";
3707
3708 assert_eq!(truncate_transcript_middle(transcript, 1_000), transcript);
3709 }
3710
3711 /// Review follow-up: a huge final message with no newline in the tail
3712 /// half must not be silently dropped — the closing exchange is what the
3713 /// summary most needs. The head gives up its share instead.
3714 #[test]
3715 fn truncate_transcript_middle_keeps_a_newlineless_final_message() {
3716 let mut lines: Vec<String> = (0..100).map(|i| format!("user: message {i}")).collect();
3717 lines.push(format!(
3718 "user: {}current state: pending review",
3719 "noise ".repeat(600)
3720 ));
3721 let transcript = lines.join("\n");
3722
3723 let trimmed = truncate_transcript_middle(&transcript, 400); // ~1600 chars
3724
3725 assert!(
3726 trimmed.contains("current state: pending review"),
3727 "the closing exchange survives: {trimmed}"
3728 );
3729 assert!(
3730 trimmed.contains("older messages omitted"),
3731 "the gap is marked: {trimmed}"
3732 );
3733 assert!(
3734 !trimmed.contains("message 0"),
3735 "the head gives up its share to the unbreakable tail: {trimmed}"
3736 );
3737 }
3738
3739 /// Review follow-up: no newline anywhere in reach — keep the newest
3740 /// budget, not the oldest (which would invert the contract while the
3741 /// placeholder claims older messages were dropped).
3742 #[test]
3743 fn truncate_transcript_middle_without_any_newline_keeps_the_newest() {
3744 let transcript = format!("old decisions {} current state", "x".repeat(4_000));
3745
3746 let trimmed = truncate_transcript_middle(&transcript, 200); // ~800 chars
3747
3748 assert!(
3749 trimmed.contains("current state"),
3750 "the newest content survives: {trimmed}"
3751 );
3752 assert!(
3753 !trimmed.contains("old decisions"),
3754 "the oldest goes when only one side can survive: {trimmed}"
3755 );
3756 }
3757
3758 #[test]
3759 fn truncate_transcript_middle_cuts_the_middle_not_the_ends() {
3760 let mut lines: Vec<String> = Vec::new();
3761 for i in 0..200 {
3762 lines.push(format!("user: message {i} with some padding text"));
3763 }
3764 let transcript = lines.join("\n");
3765
3766 let trimmed = truncate_transcript_middle(&transcript, 200); // ~800 chars
3767
3768 assert!(trimmed.contains("message 0"), "opening kept: {trimmed}");
3769 assert!(trimmed.contains("message 199"), "closing kept: {trimmed}");
3770 assert!(
3771 trimmed.contains("older messages omitted"),
3772 "gap is marked: {trimmed}"
3773 );
3774 assert!(
3775 trimmed.len() <= transcript.len(),
3776 "never grows the transcript"
3777 );
3778 }
3779
3780 #[test]
3781 fn assistant_message_with_tool_calls_round_trips() {
3782 let message = ResponseMessage {
3783 content: None,
3784 tool_calls: Some(vec![ResponseToolCall {
3785 id: "call_1".to_string(),
3786 function: ResponseFunction {
3787 name: "read_file".to_string(),
3788 arguments: r#"{"path":"a.rs"}"#.to_string(),
3789 },
3790 }]),
3791 };
3792 let value = message.into_history_value();
3793 assert_eq!(value["role"], "assistant");
3794 assert!(value["content"].is_null());
3795 assert_eq!(value["tool_calls"][0]["id"], "call_1");
3796 assert_eq!(value["tool_calls"][0]["type"], "function");
3797 assert_eq!(value["tool_calls"][0]["function"]["name"], "read_file");
3798 }
3799 }