Skip to main content

netget/llm/
ollama_client.rs

1//! LLM client supporting Ollama and OpenAI-compatible endpoints
2
3use std::collections::HashMap;
4
5use crate::llm::actions::ActionResponse;
6use crate::llm::circuit_breaker::{is_transport_failure, BreakerStatus, CircuitBreaker};
7use crate::logging::emit::Log;
8use anyhow::{Context, Result};
9use bytes::Bytes;
10#[cfg(not(target_arch = "wasm32"))]
11use ollama_rs::Ollama;
12use serde::{Deserialize, Serialize};
13use tokio::sync::mpsc;
14use tracing::{debug, error, info, trace, warn};
15
16/// Internal backend implementation for LLM communication
17#[derive(Clone)]
18enum LlmBackend {
19    /// Ollama HTTP API via ollama-rs.
20    ///
21    /// The second field is the HTTP client used for the two endpoints `ollama-rs` does not
22    /// cover (`/api/generate` with a raw body, and `/api/chat` with tools). Both used to call
23    /// `reqwest::Client::new()` **per request**, which is the defect the root CLAUDE.md
24    /// describes: building a client sets up the rustls stack and loads the platform root
25    /// store, on macOS through Security.framework, synchronously. Doing that on every LLM
26    /// call parks a runtime worker every time. It is built once, here, with the same
27    /// literal-IP resolver short-circuit as the `ollama-rs` client.
28    #[cfg(not(target_arch = "wasm32"))]
29    Ollama(Ollama, reqwest::Client),
30    /// OpenAI-compatible API via reqwest
31    #[cfg(not(target_arch = "wasm32"))]
32    OpenAI {
33        client: reqwest::Client,
34        base_url: String,
35        api_key: String,
36    },
37    /// No model: enqueue each request and await an answer from the calling MCP
38    /// agent (see `crate::llm::agent_queue`). MCP `--llm-agent` mode only.
39    Queue {
40        queue: std::sync::Arc<crate::llm::agent_queue::LlmRequestQueue>,
41        /// How long to wait for the agent's answer before erroring.
42        timeout: std::time::Duration,
43    },
44    /// No HTTP at all: each request is handed to whoever drains the bridge — the browser
45    /// page in the wasm build (WebLLM, a local Ollama it forwards to, or the person at the
46    /// keyboard) — and its answer is used as the model's. See `crate::llm::bridge`.
47    Bridge {
48        bridge: std::sync::Arc<crate::llm::bridge::LlmBridge>,
49        /// How long to wait for the host's answer before erroring.
50        timeout: std::time::Duration,
51    },
52}
53
54/// Strip markdown code fences (```json ... ``` or ``` ... ```) from text
55/// This helps handle cases where the LLM wraps JSON responses in markdown formatting
56/// Handles multiple trailing backticks (e.g., ```\n```)
57/// Pull the outermost JSON object out of a response that carries prose around
58/// it.
59///
60/// A **reasoning** model answers with its thinking first and the JSON after
61/// (`<reasoning>…</reasoning>\n{"actions": [...]}`), and prose that happens to
62/// contain an angle bracket or an unbalanced tag defeats the XML-reference
63/// stripping that runs before validation. The answer is then correct and
64/// rejected, which surfaces as "LLM failed to provide valid format after
65/// retry" — a hard failure over formatting, not content.
66///
67/// Braces are matched over `char_indices` so the slice lands on a character
68/// boundary: a model writing an em dash inside a string would otherwise
69/// truncate the object mid-character.
70fn extract_embedded_json_object(text: &str) -> Option<&str> {
71    let start = text.find('{')?;
72    let mut depth = 0usize;
73    let mut in_string = false;
74    let mut escaped = false;
75    for (offset, c) in text[start..].char_indices() {
76        if in_string {
77            match c {
78                _ if escaped => escaped = false,
79                '\\' => escaped = true,
80                '"' => in_string = false,
81                _ => {}
82            }
83            continue;
84        }
85        match c {
86            '"' => in_string = true,
87            '{' => depth += 1,
88            '}' => {
89                depth -= 1;
90                if depth == 0 {
91                    return Some(&text[start..=start + offset]);
92                }
93            }
94            _ => {}
95        }
96    }
97    None
98}
99
100fn strip_markdown_fences(text: &str) -> String {
101    let mut result = text.trim();
102
103    // Remove opening fence (```json or ```)
104    result = result
105        .strip_prefix("```json")
106        .or_else(|| result.strip_prefix("```"))
107        .unwrap_or(result)
108        .trim();
109
110    // Remove ALL trailing backticks (loop until no more)
111    // Handles cases like: {...}\n```\n```
112    while let Some(stripped) = result.strip_suffix("```") {
113        result = stripped.trim();
114    }
115
116    result.to_string()
117}
118
119// ============================================================================
120// Streaming (NDJSON) response handling
121// ============================================================================
122//
123// Both Ollama endpoints support `"stream": true`, in which case the HTTP body is
124// NDJSON — one JSON object per line, each carrying an incremental delta — ending
125// with a `{"done": true}` object that also holds the final token counts. When
126// `stream` is false (and, importantly, what the in-process test mock always
127// returns) the body is a SINGLE JSON object. The accumulator below handles BOTH
128// shapes: it feeds the body line by line, and a single-object body is simply the
129// degenerate case of "one line", so the mock harness keeps working unchanged.
130//
131// The `thinking` field (gemma/qwen "thinking" models, `message.thinking` for chat
132// and top-level `thinking` for generate) is the chain-of-thought. It is forwarded
133// to the status/log stream AS IT ARRIVES so the reasoning appears live in both the
134// TUI and the non-interactive stdout forwarder; `content` is only accumulated (it
135// is the actual answer — typically the JSON action body — and streaming it to the
136// TUI would just reproduce the response dump the logging split deliberately keeps
137// file-only).
138
139/// Which Ollama endpoint produced a streamed body — decides which JSON fields
140/// carry the content and the reasoning.
141#[derive(Debug, Clone, Copy, PartialEq, Eq)]
142pub enum OllamaResponseKind {
143    /// `/api/chat`: content at `message.content`, reasoning at `message.thinking`,
144    /// tool calls at `message.tool_calls`.
145    Chat,
146    /// `/api/generate`: content at `response`, reasoning at `thinking`.
147    Generate,
148}
149
150/// The complete result of accumulating a (possibly streamed) Ollama response.
151///
152/// After the last line is processed this holds the same full text the old
153/// single-shot path produced, so all downstream `ActionResponse` parsing is
154/// unchanged.
155#[derive(Debug, Clone, Default)]
156pub struct StreamAccumulation {
157    /// Full concatenated assistant content (the answer).
158    pub content: String,
159    /// Full concatenated chain-of-thought, if the model emitted any.
160    pub thinking: String,
161    /// `message.tool_calls` from the last chunk that carried a non-empty array.
162    pub tool_calls: Option<serde_json::Value>,
163    /// Prompt (input) token count from the final chunk, if reported.
164    pub prompt_eval_count: u64,
165    /// Completion (output) token count from the final chunk, if reported.
166    pub eval_count: u64,
167    /// An `{"error": "..."}` object seen anywhere in the body.
168    pub error: Option<String>,
169}
170
171/// Force a partial reasoning line onto the status stream once it reaches this many
172/// characters without a newline, so a model that streams a long unbroken thought
173/// still appears live rather than in one lump at the end.
174const REASONING_FLUSH_CHARS: usize = 160;
175
176/// TUI/stdout prefix marking a streamed reasoning line. Deliberately NOT one of
177/// the five `logging::emit::Log` level prefixes: reasoning is its own recognizable
178/// kind (like `[USER]`/`[SERVER]`), it renders inline in the TUI and the
179/// non-interactive forwarder prints it verbatim, and it is not mistaken for an
180/// action. It also stays clear of the `llm_log_prefix_guard` test, which only
181/// guards the five level prefixes.
182const REASONING_PREFIX: &str = "[REASONING] ";
183
184/// Coalesces reasoning deltas and forwards them to the status channel line by line.
185///
186/// Coalescing choice: flush on every newline, and additionally force-flush once the
187/// pending buffer reaches [`REASONING_FLUSH_CHARS`]. This yields roughly
188/// line-granular updates instead of one channel send per token — the maintainer
189/// wants to *see* the reasoning, and the status channel is unbounded with no
190/// backpressure, so per-token sends would be needless flooding.
191struct ReasoningForwarder<'a> {
192    status_tx: Option<&'a mpsc::UnboundedSender<String>>,
193    buf: String,
194}
195
196impl<'a> ReasoningForwarder<'a> {
197    fn new(status_tx: Option<&'a mpsc::UnboundedSender<String>>) -> Self {
198        Self {
199            status_tx,
200            buf: String::new(),
201        }
202    }
203
204    /// Append a reasoning delta and emit any complete/over-long lines.
205    fn push(&mut self, delta: &str) {
206        // No channel bound (e.g. unit tests without a receiver, or headless with no
207        // consumer): nothing to forward, and `content`/`thinking` accumulation is
208        // handled by the caller regardless.
209        if self.status_tx.is_none() || delta.is_empty() {
210            return;
211        }
212        self.buf.push_str(delta);
213
214        while let Some(pos) = self.buf.find('\n') {
215            let line: String = self.buf.drain(..=pos).collect();
216            self.emit(line.trim_end_matches(['\n', '\r']));
217        }
218
219        // Force-flush an over-long unbroken buffer, cutting on a char boundary.
220        while self.buf.chars().count() >= REASONING_FLUSH_CHARS {
221            let cut = self
222                .buf
223                .char_indices()
224                .nth(REASONING_FLUSH_CHARS)
225                .map(|(i, _)| i)
226                .unwrap_or(self.buf.len());
227            let seg: String = self.buf.drain(..cut).collect();
228            self.emit(&seg);
229        }
230    }
231
232    fn emit(&self, line: &str) {
233        let line = line.trim();
234        if line.is_empty() {
235            return;
236        }
237        if let Some(tx) = self.status_tx {
238            let _ = tx.send(format!("{}{}", REASONING_PREFIX, line));
239        }
240    }
241
242    /// Flush any buffered remainder (called once at end of stream).
243    fn flush(&mut self) {
244        let rest = std::mem::take(&mut self.buf);
245        self.emit(&rest);
246    }
247}
248
249/// Process a single NDJSON line: accumulate its deltas into `acc` and forward any
250/// reasoning to `fwd`. Blank lines and non-JSON keepalives are ignored.
251fn process_stream_line(
252    line: &str,
253    kind: OllamaResponseKind,
254    acc: &mut StreamAccumulation,
255    fwd: &mut ReasoningForwarder,
256) {
257    let line = line.trim();
258    if line.is_empty() {
259        return;
260    }
261    let v: serde_json::Value = match serde_json::from_str(line) {
262        Ok(v) => v,
263        Err(_) => return,
264    };
265
266    if let Some(err) = v.get("error").and_then(|e| e.as_str()) {
267        acc.error = Some(err.to_string());
268    }
269
270    let (content, thinking, tool_calls) = match kind {
271        OllamaResponseKind::Chat => {
272            let msg = v.get("message");
273            let tool_calls = msg
274                .and_then(|m| m.get("tool_calls"))
275                .filter(|tc| tc.as_array().map(|a| !a.is_empty()).unwrap_or(false))
276                .cloned();
277            (
278                msg.and_then(|m| m.get("content")).and_then(|c| c.as_str()),
279                msg.and_then(|m| m.get("thinking")).and_then(|c| c.as_str()),
280                tool_calls,
281            )
282        }
283        OllamaResponseKind::Generate => (
284            v.get("response").and_then(|c| c.as_str()),
285            v.get("thinking").and_then(|c| c.as_str()),
286            None,
287        ),
288    };
289
290    if let Some(c) = content {
291        acc.content.push_str(c);
292    }
293    if let Some(t) = thinking {
294        acc.thinking.push_str(t);
295        fwd.push(t);
296    }
297    if let Some(tc) = tool_calls {
298        acc.tool_calls = Some(tc);
299    }
300    if let Some(n) = v.get("prompt_eval_count").and_then(|x| x.as_u64()) {
301        acc.prompt_eval_count = n;
302    }
303    if let Some(n) = v.get("eval_count").and_then(|x| x.as_u64()) {
304        acc.eval_count = n;
305    }
306}
307
308/// Accumulate a complete Ollama response `body` (NDJSON stream OR single object)
309/// into a [`StreamAccumulation`], forwarding reasoning deltas to `status_tx` line
310/// by line exactly as the live streaming path does.
311///
312/// This is the synchronous core shared by the async transport reader and the unit
313/// tests: feeding a whole body here is equivalent to receiving it in one network
314/// chunk, so a test can assert both the accumulated text and the forwarded deltas
315/// without a live model.
316pub fn accumulate_ollama_stream(
317    body: &str,
318    kind: OllamaResponseKind,
319    status_tx: Option<&mpsc::UnboundedSender<String>>,
320) -> StreamAccumulation {
321    let mut acc = StreamAccumulation::default();
322    let mut fwd = ReasoningForwarder::new(status_tx);
323    for line in body.split('\n') {
324        process_stream_line(line, kind, &mut acc, &mut fwd);
325    }
326    fwd.flush();
327    acc
328}
329
330// --- OpenAI (`/v1/chat/completions`) streaming ---
331//
332// OpenAI's stream is Server-Sent Events: each event is a `data: {json}` line,
333// terminated by `data: [DONE]`. Unlike Ollama's NDJSON, tool calls arrive as
334// fragments (an `index`, then `id`/`function.name`/`function.arguments` streamed
335// piecewise), so they must be merged by index. The same processor also handles a
336// non-SSE single-object body (the test mock and any non-streaming backend), where
337// each field arrives whole — merging into an empty accumulator yields the value.
338
339#[derive(Default, Clone)]
340struct OpenAiToolCallAcc {
341    id: String,
342    name: String,
343    arguments: String,
344}
345
346/// Accumulated OpenAI chat result (from an SSE stream or a single object).
347#[derive(Default)]
348pub struct OpenAiStreamAcc {
349    pub content: String,
350    pub reasoning: String,
351    tool_calls: std::collections::BTreeMap<u64, OpenAiToolCallAcc>,
352    pub prompt_tokens: u64,
353    pub completion_tokens: u64,
354    pub total_tokens: u64,
355    pub error: Option<String>,
356}
357
358impl OpenAiStreamAcc {
359    /// Finalize the merged tool-call fragments into `ToolCall`s, dropping any that
360    /// never received a function name and defaulting unparseable arguments to `{}`.
361    pub fn into_tool_calls(&self) -> Vec<ToolCall> {
362        self.tool_calls
363            .values()
364            .filter(|t| !t.name.is_empty())
365            .map(|t| ToolCall {
366                id: t.id.clone(),
367                function_name: t.name.clone(),
368                arguments: serde_json::from_str(&t.arguments)
369                    .unwrap_or_else(|_| serde_json::json!({})),
370            })
371            .collect()
372    }
373
374    pub fn token_usage(&self) -> TokenUsage {
375        TokenUsage {
376            prompt_tokens: self.prompt_tokens,
377            completion_tokens: self.completion_tokens,
378            total_tokens: if self.total_tokens > 0 {
379                self.total_tokens
380            } else {
381                self.prompt_tokens + self.completion_tokens
382            },
383        }
384    }
385}
386
387fn apply_openai_usage(acc: &mut OpenAiStreamAcc, usage: &serde_json::Value) {
388    if let Some(n) = usage.get("prompt_tokens").and_then(|v| v.as_u64()) {
389        acc.prompt_tokens = n;
390    }
391    if let Some(n) = usage.get("completion_tokens").and_then(|v| v.as_u64()) {
392        acc.completion_tokens = n;
393    }
394    if let Some(n) = usage.get("total_tokens").and_then(|v| v.as_u64()) {
395        acc.total_tokens = n;
396    }
397}
398
399/// Merge one `choices[0].delta` (streaming) or `choices[0].message` (single
400/// object) node into the accumulator, forwarding any reasoning delta.
401fn merge_openai_choice(
402    acc: &mut OpenAiStreamAcc,
403    node: &serde_json::Value,
404    fwd: &mut ReasoningForwarder,
405) {
406    if let Some(c) = node.get("content").and_then(|v| v.as_str()) {
407        acc.content.push_str(c);
408    }
409    // OpenAI reasoning models expose chain-of-thought as `reasoning` or, on some
410    // OpenAI-compatible endpoints, `reasoning_content`.
411    for key in ["reasoning", "reasoning_content"] {
412        if let Some(r) = node.get(key).and_then(|v| v.as_str()) {
413            acc.reasoning.push_str(r);
414            fwd.push(r);
415        }
416    }
417    if let Some(tcs) = node.get("tool_calls").and_then(|v| v.as_array()) {
418        for (i, tc) in tcs.iter().enumerate() {
419            let idx = tc.get("index").and_then(|v| v.as_u64()).unwrap_or(i as u64);
420            let e = acc.tool_calls.entry(idx).or_default();
421            if let Some(id) = tc.get("id").and_then(|v| v.as_str()) {
422                if !id.is_empty() {
423                    e.id = id.to_string();
424                }
425            }
426            if let Some(f) = tc.get("function") {
427                if let Some(n) = f.get("name").and_then(|v| v.as_str()) {
428                    e.name.push_str(n);
429                }
430                if let Some(a) = f.get("arguments").and_then(|v| v.as_str()) {
431                    e.arguments.push_str(a);
432                }
433            }
434        }
435    }
436}
437
438/// Process one line of an OpenAI response. Returns `true` if the line was an SSE
439/// `data:` frame (so the caller knows the body is a real stream, not a single
440/// object). `[DONE]` and unparseable frames count as frames but add nothing.
441fn process_openai_sse_line(
442    line: &str,
443    acc: &mut OpenAiStreamAcc,
444    fwd: &mut ReasoningForwarder,
445) -> bool {
446    let line = line.trim();
447    let data = match line.strip_prefix("data:") {
448        Some(d) => d.trim(),
449        None => return false,
450    };
451    if data == "[DONE]" {
452        return true;
453    }
454    let v: serde_json::Value = match serde_json::from_str(data) {
455        Ok(v) => v,
456        Err(_) => return true,
457    };
458    if let Some(err) = v.pointer("/error/message").and_then(|e| e.as_str()) {
459        acc.error = Some(err.to_string());
460    }
461    if let Some(u) = v.get("usage").filter(|u| u.is_object()) {
462        apply_openai_usage(acc, u);
463    }
464    if let Some(delta) = v.pointer("/choices/0/delta") {
465        merge_openai_choice(acc, delta, fwd);
466    }
467    true
468}
469
470/// Accumulate a whole OpenAI body — an SSE stream OR a single completion object —
471/// forwarding reasoning line by line. The synchronous core shared by the async
472/// reader and unit tests.
473pub fn accumulate_openai_stream(
474    body: &str,
475    status_tx: Option<&mpsc::UnboundedSender<String>>,
476) -> OpenAiStreamAcc {
477    let mut acc = OpenAiStreamAcc::default();
478    let mut fwd = ReasoningForwarder::new(status_tx);
479    let mut saw_sse = false;
480    for line in body.split('\n') {
481        if process_openai_sse_line(line, &mut acc, &mut fwd) {
482            saw_sse = true;
483        }
484    }
485    // Non-SSE single object (mock / non-streaming backend): parse the whole body
486    // and merge choices[0].message, which carries every field whole.
487    if !saw_sse {
488        if let Ok(v) = serde_json::from_str::<serde_json::Value>(body.trim()) {
489            if let Some(err) = v.pointer("/error/message").and_then(|e| e.as_str()) {
490                acc.error = Some(err.to_string());
491            }
492            if let Some(u) = v.get("usage").filter(|u| u.is_object()) {
493                apply_openai_usage(&mut acc, u);
494            }
495            if let Some(msg) = v.pointer("/choices/0/message") {
496                merge_openai_choice(&mut acc, msg, &mut fwd);
497            }
498        }
499    }
500    fwd.flush();
501    acc
502}
503
504/// Message in a conversation with role (system/user/assistant/tool)
505#[derive(Debug, Clone, Serialize, Deserialize)]
506pub struct Message {
507    pub role: String,
508    pub content: String,
509    /// Tool call ID for tool result messages (role="tool")
510    #[serde(skip_serializing_if = "Option::is_none")]
511    pub tool_call_id: Option<String>,
512}
513
514/// Token usage statistics from an LLM response
515#[derive(Debug, Clone, Copy, Default)]
516pub struct TokenUsage {
517    /// Number of tokens in the prompt (input)
518    pub prompt_tokens: u64,
519    /// Number of tokens in the response (output)
520    pub completion_tokens: u64,
521    /// Total tokens (prompt + completion)
522    pub total_tokens: u64,
523}
524
525impl TokenUsage {
526    /// Create from ollama-rs GenerationResponse
527    #[cfg(not(target_arch = "wasm32"))]
528    pub fn from_response(response: &ollama_rs::generation::completion::GenerationResponse) -> Self {
529        let prompt_tokens = response.prompt_eval_count.unwrap_or(0) as u64;
530        let completion_tokens = response.eval_count.unwrap_or(0) as u64;
531
532        Self {
533            prompt_tokens,
534            completion_tokens,
535            total_tokens: prompt_tokens + completion_tokens,
536        }
537    }
538}
539
540/// Response from generate_with_format including token usage
541#[derive(Debug, Clone)]
542pub struct GenerateResponse {
543    /// The generated text
544    pub text: String,
545    /// Token usage statistics
546    pub token_usage: TokenUsage,
547}
548
549/// Request for chat_with_tools - structured messages with native tool definitions
550#[derive(Debug, Clone)]
551pub struct ChatRequest {
552    /// Conversation messages (system, user, assistant, tool)
553    pub messages: Vec<Message>,
554    /// Tool schemas in OpenAI/Ollama format (from ActionDefinition::to_tool_schema())
555    pub tools: Vec<serde_json::Value>,
556    /// Model name (e.g., "qwen3-coder:30b")
557    pub model: String,
558    /// The actions the prompt offers. Only the bridge backend sends these anywhere (see
559    /// `crate::llm::bridge::BridgeRequest::actions`); the HTTP backends ignore them.
560    pub offered_actions: Vec<crate::llm::actions::ActionDefinition>,
561}
562
563/// Response from chat_with_tools - may contain text and/or tool calls
564#[derive(Debug, Clone)]
565pub struct ChatResponse {
566    /// Text content of the response (may be None if only tool calls)
567    pub content: Option<String>,
568    /// Native tool calls requested by the LLM
569    pub tool_calls: Vec<ToolCall>,
570    /// Token usage statistics
571    pub token_usage: TokenUsage,
572}
573
574/// A native tool call from the LLM
575#[derive(Debug, Clone)]
576pub struct ToolCall {
577    /// Unique ID for this tool call (used to match tool results)
578    pub id: String,
579    /// Name of the function/tool to call
580    pub function_name: String,
581    /// Arguments as a JSON object
582    pub arguments: serde_json::Value,
583}
584
585impl Message {
586    /// Create a system message
587    pub fn system(content: impl Into<String>) -> Self {
588        Self {
589            role: "system".to_string(),
590            content: content.into(),
591            tool_call_id: None,
592        }
593    }
594
595    /// Create a user message
596    pub fn user(content: impl Into<String>) -> Self {
597        Self {
598            role: "user".to_string(),
599            content: content.into(),
600            tool_call_id: None,
601        }
602    }
603
604    /// Create an assistant message
605    pub fn assistant(content: impl Into<String>) -> Self {
606        Self {
607            role: "assistant".to_string(),
608            content: content.into(),
609            tool_call_id: None,
610        }
611    }
612
613    /// Create a tool message (legacy, without tool_call_id)
614    pub fn tool(content: impl Into<String>) -> Self {
615        Self {
616            role: "tool".to_string(),
617            content: content.into(),
618            tool_call_id: None,
619        }
620    }
621
622    /// Create a tool result message with tool_call_id for native tool calling
623    pub fn tool_result(tool_call_id: String, content: impl Into<String>) -> Self {
624        Self {
625            role: "tool".to_string(),
626            content: content.into(),
627            tool_call_id: Some(tool_call_id),
628        }
629    }
630}
631
632/// Structured response from the LLM
633#[derive(Debug, Clone, Deserialize, Serialize, Default)]
634pub struct LlmResponse {
635    /// Data to send over the connection (None = no output)
636    #[serde(default)]
637    pub output: Option<String>,
638
639    /// Whether to close this specific connection
640    #[serde(default)]
641    pub close_connection: bool,
642
643    /// Whether to wait for more data before responding
644    #[serde(default)]
645    pub wait_for_more: bool,
646
647    /// Whether to shut down the entire server
648    #[serde(default)]
649    pub shutdown_server: bool,
650
651    /// Optional log message for debugging
652    #[serde(default)]
653    pub log_message: Option<String>,
654
655    /// Update memory - completely replace existing memory
656    #[serde(default)]
657    pub set_memory: Option<String>,
658
659    /// Append to memory (added to end with newline separator)
660    #[serde(default)]
661    pub append_memory: Option<String>,
662}
663
664impl LlmResponse {
665    /// Parse from JSON string with fallback to legacy text format
666    #[allow(clippy::should_implement_trait)]
667    pub fn from_str(s: &str) -> Result<Self> {
668        let trimmed = s.trim();
669
670        // Try to parse as JSON first
671        if let Ok(response) = serde_json::from_str::<LlmResponse>(trimmed) {
672            return Ok(response);
673        }
674
675        // Fallback: handle legacy text responses
676        match trimmed {
677            "NO_RESPONSE" => Ok(Self::default()),
678            "CLOSE_CONNECTION" => Ok(Self {
679                close_connection: true,
680                ..Default::default()
681            }),
682            "WAIT_FOR_MORE" => Ok(Self {
683                wait_for_more: true,
684                ..Default::default()
685            }),
686            _ => {
687                // Treat as raw output text
688                Ok(Self {
689                    output: Some(trimmed.to_string()),
690                    ..Default::default()
691                })
692            }
693        }
694    }
695}
696
697/// Structured HTTP response from the LLM
698#[derive(Debug, Clone, Deserialize, Serialize)]
699pub struct HttpLlmResponse {
700    /// HTTP status code
701    pub status: u16,
702
703    /// Response headers
704    #[serde(default)]
705    pub headers: HashMap<String, String>,
706
707    /// Response body
708    pub body: String,
709
710    /// Optional log message for debugging
711    #[serde(default)]
712    pub log_message: Option<String>,
713
714    /// Update memory - completely replace existing memory
715    #[serde(default)]
716    pub set_memory: Option<String>,
717
718    /// Append to memory (added to end with newline separator)
719    #[serde(default)]
720    pub append_memory: Option<String>,
721}
722
723impl Default for HttpLlmResponse {
724    fn default() -> Self {
725        Self {
726            status: 200,
727            headers: HashMap::new(),
728            body: String::new(),
729            log_message: None,
730            set_memory: None,
731            append_memory: None,
732        }
733    }
734}
735
736impl std::str::FromStr for LlmResponse {
737    type Err = anyhow::Error;
738
739    fn from_str(s: &str) -> Result<Self, Self::Err> {
740        LlmResponse::from_str(s)
741    }
742}
743
744impl HttpLlmResponse {
745    /// Parse from JSON string
746    #[allow(clippy::should_implement_trait)]
747    pub fn from_str(s: &str) -> Result<Self> {
748        let trimmed = s.trim();
749        serde_json::from_str::<HttpLlmResponse>(trimmed)
750            .context("Failed to parse HTTP LLM response as JSON")
751    }
752
753    /// Convert to event HttpResponse
754    pub fn to_event_response(self) -> crate::events::types::HttpResponse {
755        crate::events::types::HttpResponse {
756            status: self.status,
757            headers: self.headers,
758            body: Bytes::from(self.body),
759        }
760    }
761}
762
763impl std::str::FromStr for HttpLlmResponse {
764    type Err = anyhow::Error;
765
766    fn from_str(s: &str) -> Result<Self, Self::Err> {
767        HttpLlmResponse::from_str(s)
768    }
769}
770
771/// Action types for command interpretation
772///
773/// WARNING: If you modify this enum, you MUST also update the corresponding
774/// The schema file is used for Ollama's structured output feature.
775#[derive(Debug, Clone, Deserialize, Serialize)]
776#[serde(tag = "type", rename_all = "snake_case")]
777pub enum CommandAction {
778    UpdateInstruction {
779        instruction: String,
780    },
781    OpenServer {
782        port: u16,
783        base_stack: String,
784        #[serde(default)]
785        send_first: bool,
786        #[serde(default)]
787        initial_memory: Option<String>,
788        /// The instruction prompt for handling network events
789        instruction: String,
790    },
791    CloseServer {
792        #[serde(default)]
793        server_id: Option<u32>,
794    },
795    OpenClient {
796        address: String,
797        base_stack: String,
798    },
799    CloseConnection {
800        #[serde(default)]
801        connection_id: Option<String>,
802    },
803    ShowMessage {
804        message: String,
805    },
806    ChangeModel {
807        model: String,
808    },
809}
810
811/// Structured response for command interpretation
812#[derive(Debug, Clone, Deserialize, Serialize)]
813pub struct CommandInterpretation {
814    /// List of actions to take
815    #[serde(default)]
816    pub actions: Vec<CommandAction>,
817
818    /// Optional message to display to user
819    #[serde(default)]
820    pub message: Option<String>,
821}
822
823impl CommandInterpretation {
824    /// Parse from JSON string
825    #[allow(clippy::should_implement_trait)]
826    pub fn from_str(s: &str) -> Result<Self> {
827        let trimmed = s.trim();
828        serde_json::from_str::<CommandInterpretation>(trimmed)
829            .context("Failed to parse command interpretation as JSON")
830    }
831}
832
833impl std::str::FromStr for CommandInterpretation {
834    type Err = anyhow::Error;
835
836    fn from_str(s: &str) -> Result<Self, Self::Err> {
837        CommandInterpretation::from_str(s)
838    }
839}
840
841/// Default per-request wall-clock bound for a backend call.
842///
843/// 300s, raised from 120s. 120 was not a safety margin, it was a coin flip: a locally
844/// served 31B reasoning model answering a realistic NetGet request — ~15k characters of
845/// system prompt after stripping, plus the 27 tool schemas a default build advertises —
846/// was measured at **79 seconds on an otherwise idle machine**. That is two thirds of the
847/// old budget spent on a *good* run, so anything else touching the same GPU (a second
848/// model loaded, a test suite, another netget) pushed it over, and the user saw
849/// `LLM request (attempt 1/2)` and then nothing for two minutes.
850///
851/// Reasoning models are what changed the arithmetic: the thinking block and the answer are
852/// generated serially, so latency scales with tool count and prompt size far more steeply
853/// than it did for a non-reasoning model of the same size.
854///
855/// This is a backstop against a hung backend, not a performance target — a request that
856/// legitimately needs four minutes should get four minutes, and one that is never coming
857/// back is equally dead at 120s or 300s. Override with `--llm-request-timeout`.
858pub const DEFAULT_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300);
859
860/// The bare host from anything callers hand us: `127.0.0.1`, `http://127.0.0.1`,
861/// `http://127.0.0.1:11434`, `http://127.0.0.1:11434/v1`, `http://[::1]:11434`.
862///
863/// Written out because the first version of this only stripped the scheme, so
864/// `http://127.0.0.1:54321` reduced to `127.0.0.1:54321` — which does not parse as an
865/// `IpAddr`, so the override silently did not apply and the very call it was meant to fix
866/// (`/api/tags`, on a 5-second budget) kept spending all five seconds in `getaddrinfo`. A
867/// short-circuit that quietly fails to engage is worse than none, because the symptom is
868/// unchanged; hence `host_of_extracts_the_bare_host` in tests/.
869pub fn host_of(url_or_host: &str) -> &str {
870    let after_scheme = url_or_host
871        .rsplit_once("//")
872        .map(|(_, h)| h)
873        .unwrap_or(url_or_host);
874    let authority = after_scheme.split('/').next().unwrap_or(after_scheme);
875    // IPv6 literals are bracketed, and their own colons must not be read as a port separator.
876    if let Some(rest) = authority.strip_prefix('[') {
877        return rest.split(']').next().unwrap_or(rest);
878    }
879    match authority.rsplit_once(':') {
880        Some((h, port)) if !port.is_empty() && port.chars().all(|c| c.is_ascii_digit()) => h,
881        _ => authority,
882    }
883}
884
885/// Add a resolver override when `host` is a **literal IP address**, so the request never
886/// consults the system name resolver.
887///
888/// Asking the resolver about `127.0.0.1` is never meaningful — a dotted quad already *is* the
889/// answer — but `reqwest` hands the URL host to its resolver unconditionally, and
890/// `hyper-util`'s `GaiResolver` does not special-case one. That becomes a real
891/// `getaddrinfo()` call, which on macOS goes through libinfo to mDNSResponder: a single
892/// system-wide daemon, and a serialisation point under concurrency. It was measured blocking
893/// for **8.25 seconds** with ~100 processes asking at once (see `src/server/doh/AGENTS.md`),
894/// which is long enough to expire a request timeout against a server that is up and idle.
895///
896/// A hostname is left alone: resolving `localhost` or a real name is the resolver's job, and
897/// `/etc/hosts` or split-horizon DNS may legitimately point it somewhere unexpected. Only the
898/// case where resolution has exactly one correct answer is short-circuited.
899#[cfg(not(target_arch = "wasm32"))]
900fn without_dns_for_literal_ip(
901    builder: reqwest::ClientBuilder,
902    host: &str,
903) -> reqwest::ClientBuilder {
904    let bare = host_of(host);
905    match bare.parse::<std::net::IpAddr>() {
906        // The port is irrelevant: hyper overwrites it with the one from the URL, so a single
907        // override serves every port this client is later pointed at.
908        Ok(ip) => builder.resolve(bare, std::net::SocketAddr::new(ip, 0)),
909        Err(_) => builder,
910    }
911}
912
913/// Whether `host` (anything [`host_of`] accepts) names this machine.
914///
915/// `127.0.0.0/8`, `::1` and the literal name `localhost`. Nothing else: a LAN address
916/// is somebody's network and its proxy configuration is their business.
917#[cfg(not(target_arch = "wasm32"))]
918pub fn is_loopback_endpoint(url_or_host: &str) -> bool {
919    let bare = host_of(url_or_host);
920    if bare.eq_ignore_ascii_case("localhost") {
921        return true;
922    }
923    bare.parse::<std::net::IpAddr>()
924        .map(|ip| ip.is_loopback())
925        .unwrap_or(false)
926}
927
928/// Skip the **system proxy probe** when the endpoint is on this machine.
929///
930/// This is the same shape of defect as [`without_dns_for_literal_ip`] and cost more.
931/// `reqwest`'s `system-proxy` feature is on by default, so every
932/// `Client::builder().build()` calls `hyper_util`'s `Matcher::from_system()`, which on
933/// macOS opens an `SCDynamicStore` session — a connection to **configd**, one
934/// system-wide daemon, exactly like `getaddrinfo`'s mDNSResponder. It is paid whether or
935/// not a proxy is configured, and it serialises across processes: measured here at
936/// **0.056 ms alone and 657 ms (p50, 726 ms max) with 100 processes building at once**,
937/// against 0.090 ms for the same build with `.no_proxy()` at the same concurrency. The
938/// e2e suite runs a netget process per test, so that is the suite's normal condition.
939///
940/// It is **not** a per-client cost, which is the trap: the first build in a process pays
941/// it and every later one costs ~0.3 ms, so caching clients per endpoint would save
942/// nothing. The TLS backend is irrelevant too — `use_rustls_tls()` measured the same
943/// 699 ms, and `tls_built_in_root_certs(false)` made no difference, because neither
944/// backend loads a root store at build time.
945///
946/// Gating on loopback is also the **correct** behaviour rather than merely the cheap one.
947/// `hyper_util`'s macOS reader takes `kSCPropNetProxiesHTTPEnable`/`Proxy`/`Port` and never
948/// reads `kSCPropNetProxiesExceptionsList`, so with a system proxy configured it would send
949/// a request for `http://127.0.0.1:11434` to that proxy — ignoring the exceptions list macOS
950/// itself ships, which excludes local addresses. A loopback request must never leave the
951/// machine.
952///
953/// A non-loopback endpoint keeps full system/env proxy behaviour: reaching a remote model
954/// through a corporate proxy is a real deployment, and this must not break it.
955#[cfg(not(target_arch = "wasm32"))]
956fn without_proxy_for_loopback(
957    builder: reqwest::ClientBuilder,
958    host: &str,
959) -> reqwest::ClientBuilder {
960    if is_loopback_endpoint(host) {
961        builder.no_proxy()
962    } else {
963        builder
964    }
965}
966
967/// Both endpoint short-circuits in one place, so no constructor can acquire one and miss
968/// the other. Every client NetGet builds from a configured URL goes through here.
969#[cfg(not(target_arch = "wasm32"))]
970pub(crate) fn configured_for_endpoint(
971    builder: reqwest::ClientBuilder,
972    base_url: &str,
973) -> reqwest::ClientBuilder {
974    without_proxy_for_loopback(without_dns_for_literal_ip(builder, base_url), base_url)
975}
976
977/// An HTTP client for a NetGet-configured endpoint, with no request timeout.
978///
979/// Use this instead of `reqwest::Client::new()` anywhere the URL comes from configuration
980/// (`--ollama-url`, `--openai-url`). It applies [`without_dns_for_literal_ip`] and
981/// [`without_proxy_for_loopback`], and building it once per endpoint rather than once per
982/// request is the other half of the same lesson — see the note on [`LlmBackend::Ollama`].
983#[cfg(not(target_arch = "wasm32"))]
984pub fn client_for_endpoint(base_url: &str) -> reqwest::Client {
985    configured_for_endpoint(reqwest::Client::builder(), base_url)
986        .build()
987        .unwrap_or_else(|_| reqwest::Client::new())
988}
989
990/// As [`client_for_endpoint`], bounded by `timeout`.
991#[cfg(not(target_arch = "wasm32"))]
992pub fn client_for_endpoint_with_timeout(
993    base_url: &str,
994    timeout: std::time::Duration,
995) -> reqwest::Client {
996    configured_for_endpoint(reqwest::Client::builder().timeout(timeout), base_url)
997        .build()
998        .unwrap_or_else(|_| reqwest::Client::new())
999}
1000
1001/// The HTTP client used for OpenAI-compatible backends, bounded by `timeout`.
1002///
1003/// One constructor so the reqwest-level bound and [`OllamaClient::request_timeout`] cannot
1004/// disagree; see [`OllamaClient::with_request_timeout`] for what that disagreement cost.
1005#[cfg(not(target_arch = "wasm32"))]
1006fn openai_http_client(timeout: std::time::Duration, base_url: &str) -> reqwest::Client {
1007    configured_for_endpoint(reqwest::Client::builder().timeout(timeout), base_url)
1008        .build()
1009        .expect("Failed to build HTTP client")
1010}
1011
1012/// The browser's `fetch()` does its own name resolution and offers no timeout knob, so on
1013/// wasm32 both constructors are a plain client. Nothing in the web build contacts an LLM
1014/// over HTTP anyway (see `LlmBackend::Bridge`); these exist for `model_selection`.
1015#[cfg(target_arch = "wasm32")]
1016pub fn client_for_endpoint(_base_url: &str) -> reqwest::Client {
1017    reqwest::Client::new()
1018}
1019
1020#[cfg(target_arch = "wasm32")]
1021pub fn client_for_endpoint_with_timeout(
1022    _base_url: &str,
1023    _timeout: std::time::Duration,
1024) -> reqwest::Client {
1025    reqwest::Client::new()
1026}
1027
1028/// Default completion-token budget for one backend call.
1029///
1030/// This is a **runaway guard**, not a working budget: it exists to stop a
1031/// model that has started generating without end, and should sit far above any
1032/// legitimate answer so it never truncates one. The wall-clock
1033/// `--llm-request-timeout` is the primary backstop; this is the secondary.
1034///
1035/// It used to be 2048, which is inside the range of ordinary answers. A
1036/// **reasoning** model spends the budget twice — once on its thinking block and
1037/// once on the answer — so a 27B model was observed emitting a full
1038/// `<reasoning>` section followed by a JSON action list truncated mid-object.
1039/// That surfaces as an unparseable (or empty) response, never as an obvious
1040/// limit, which is the worst way for a cap to fail. Override with
1041/// `--llm-max-tokens`.
1042pub const DEFAULT_MAX_TOKENS: u32 = 32768;
1043
1044/// Sampling overrides sent to the backend with every request.
1045///
1046/// Both fields are `None` unless the operator set them, and a `None` field is **absent from
1047/// the request body** — not sent as a default — so a run without `--llm-seed` or
1048/// `--llm-temperature` puts exactly the bytes on the wire it always did and the model's own
1049/// Modelfile decides. `tests/llm_sampling_options_test.rs` pins both halves.
1050///
1051/// Ollama reads them from the `options` object of `/api/generate` and `/api/chat`; the
1052/// OpenAI-compatible API takes them as top-level `seed` and `temperature`.
1053#[derive(Debug, Clone, Copy, Default, PartialEq)]
1054pub struct SamplingOptions {
1055    /// Sampler seed. With a fixed seed and an identical prompt, Ollama's sampler draws the
1056    /// same tokens; a prompt that differs (a new port, a new query id) still diverges.
1057    pub seed: Option<u64>,
1058    /// Sampling temperature. `0.0` is greedy decoding.
1059    pub temperature: Option<f32>,
1060}
1061
1062impl SamplingOptions {
1063    /// Whether any override is set.
1064    pub fn is_empty(&self) -> bool {
1065        self.seed.is_none() && self.temperature.is_none()
1066    }
1067
1068    /// Write the set fields into `target` (an Ollama `options` object or an OpenAI body).
1069    fn apply_to(&self, target: &mut serde_json::Value) {
1070        if let Some(seed) = self.seed {
1071            target["seed"] = serde_json::json!(seed);
1072        }
1073        if let Some(temperature) = self.temperature {
1074            target["temperature"] = serde_json::json!(temperature);
1075        }
1076    }
1077}
1078
1079/// LLM API client supporting Ollama and OpenAI-compatible backends
1080#[derive(Clone)]
1081pub struct OllamaClient {
1082    backend: LlmBackend,
1083    status_tx: Option<mpsc::UnboundedSender<String>>,
1084    mock_config_file: Option<std::path::PathBuf>,
1085    app_state: Option<crate::state::AppState>,
1086    /// Shared across clones, so every caller sees the same backend health.
1087    breaker: std::sync::Arc<CircuitBreaker>,
1088    /// Wall-clock bound on a single backend call.
1089    request_timeout: std::time::Duration,
1090    /// Completion-token budget for a single backend call.
1091    max_tokens: u32,
1092    /// Sampling overrides (`--llm-seed`, `--llm-temperature`). Empty by default, and
1093    /// an empty value adds nothing to any request body.
1094    sampling: SamplingOptions,
1095}
1096
1097impl OllamaClient {
1098    /// Create a new Ollama client
1099    #[cfg(not(target_arch = "wasm32"))]
1100    pub fn new(base_url: impl Into<String>) -> Self {
1101        let url_str = base_url.into();
1102
1103        // Parse the URL to extract host and port
1104        // URL format: "http://host:port" or "http://host" (default port 11434)
1105        let (host, port) = if let Some(port_start) = url_str.rfind(':') {
1106            // Check if this is a port (not the :// in http://)
1107            if port_start > 6 && !url_str[port_start..].contains('/') {
1108                // Extract port number
1109                if let Ok(port_num) = url_str[port_start + 1..].parse::<u16>() {
1110                    // Valid port found - split host and port
1111                    (&url_str[..port_start], port_num)
1112                } else {
1113                    // Invalid port - use whole URL as host with default port
1114                    (url_str.as_str(), 11434)
1115                }
1116            } else {
1117                // No port in URL - use default
1118                (url_str.as_str(), 11434)
1119            }
1120        } else {
1121            // No colon found - use default port
1122            (url_str.as_str(), 11434)
1123        };
1124
1125        // Built here rather than left to `Ollama::new`, which makes its own default client,
1126        // so a literal-IP host skips the system resolver and a loopback one skips the system
1127        // proxy probe. See `without_dns_for_literal_ip` and `without_proxy_for_loopback`.
1128        let http = configured_for_endpoint(reqwest::Client::builder(), host)
1129            .build()
1130            .expect("Failed to build Ollama HTTP client");
1131        let ollama = Ollama::new_with_client(host, port, http.clone());
1132        Self {
1133            backend: LlmBackend::Ollama(ollama, http),
1134            status_tx: None,
1135            mock_config_file: None,
1136            app_state: None,
1137            breaker: std::sync::Arc::new(CircuitBreaker::default()),
1138            request_timeout: DEFAULT_REQUEST_TIMEOUT,
1139            max_tokens: DEFAULT_MAX_TOKENS,
1140            sampling: SamplingOptions::default(),
1141        }
1142    }
1143
1144    /// Create a default client pointing to localhost
1145    ///
1146    /// `ollama-rs`'s own default points at `http://127.0.0.1:11434`, so the HTTP client for
1147    /// the two endpoints it does not cover is built for that endpoint rather than with
1148    /// `reqwest::Client::new()` — which would take the system resolver and the system proxy
1149    /// probe this module exists to avoid.
1150    #[allow(clippy::should_implement_trait)]
1151    #[cfg(not(target_arch = "wasm32"))]
1152    pub fn default() -> Self {
1153        let http = client_for_endpoint("http://127.0.0.1:11434");
1154        let ollama = Ollama::new_with_client("http://127.0.0.1", 11434, http.clone());
1155        Self {
1156            backend: LlmBackend::Ollama(ollama, http),
1157            status_tx: None,
1158            mock_config_file: None,
1159            app_state: None,
1160            breaker: std::sync::Arc::new(CircuitBreaker::default()),
1161            request_timeout: DEFAULT_REQUEST_TIMEOUT,
1162            max_tokens: DEFAULT_MAX_TOKENS,
1163            sampling: SamplingOptions::default(),
1164        }
1165    }
1166
1167    /// Create a new client for an OpenAI-compatible API endpoint
1168    #[cfg(not(target_arch = "wasm32"))]
1169    pub fn new_openai(base_url: impl Into<String>, api_key: impl Into<String>) -> Self {
1170        let base_url = base_url.into().trim_end_matches('/').to_string();
1171        let client = openai_http_client(DEFAULT_REQUEST_TIMEOUT, &base_url);
1172        Self {
1173            backend: LlmBackend::OpenAI {
1174                client,
1175                base_url,
1176                api_key: api_key.into(),
1177            },
1178            status_tx: None,
1179            mock_config_file: None,
1180            app_state: None,
1181            breaker: std::sync::Arc::new(CircuitBreaker::default()),
1182            request_timeout: DEFAULT_REQUEST_TIMEOUT,
1183            max_tokens: DEFAULT_MAX_TOKENS,
1184            sampling: SamplingOptions::default(),
1185        }
1186    }
1187
1188    /// Create a client that queues LLM requests for the calling MCP agent to answer.
1189    /// No model is contacted; each request waits up to `timeout` for an answer.
1190    pub fn new_queue(
1191        queue: std::sync::Arc<crate::llm::agent_queue::LlmRequestQueue>,
1192        timeout: std::time::Duration,
1193    ) -> Self {
1194        Self {
1195            backend: LlmBackend::Queue { queue, timeout },
1196            status_tx: None,
1197            mock_config_file: None,
1198            app_state: None,
1199            breaker: std::sync::Arc::new(CircuitBreaker::default()),
1200            request_timeout: DEFAULT_REQUEST_TIMEOUT,
1201            max_tokens: DEFAULT_MAX_TOKENS,
1202            sampling: SamplingOptions::default(),
1203        }
1204    }
1205
1206    /// Create a client whose every request is answered by whoever drains `bridge` — the
1207    /// browser page, in the wasm build. No model is contacted by NetGet itself.
1208    pub fn new_bridge(
1209        bridge: std::sync::Arc<crate::llm::bridge::LlmBridge>,
1210        timeout: std::time::Duration,
1211    ) -> Self {
1212        Self {
1213            backend: LlmBackend::Bridge { bridge, timeout },
1214            status_tx: None,
1215            mock_config_file: None,
1216            app_state: None,
1217            breaker: std::sync::Arc::new(CircuitBreaker::default()),
1218            request_timeout: DEFAULT_REQUEST_TIMEOUT,
1219            max_tokens: DEFAULT_MAX_TOKENS,
1220            sampling: SamplingOptions::default(),
1221        }
1222    }
1223
1224    /// Returns the backend type as a string ("ollama", "openai", "agent" or "bridge")
1225    pub fn backend_type(&self) -> &str {
1226        match &self.backend {
1227            #[cfg(not(target_arch = "wasm32"))]
1228            LlmBackend::Ollama(..) => "ollama",
1229            #[cfg(not(target_arch = "wasm32"))]
1230            LlmBackend::OpenAI { .. } => "openai",
1231            LlmBackend::Queue { .. } => "agent",
1232            LlmBackend::Bridge { .. } => "bridge",
1233        }
1234    }
1235
1236    /// Returns the base URL of the current backend
1237    pub fn backend_url(&self) -> String {
1238        match &self.backend {
1239            #[cfg(not(target_arch = "wasm32"))]
1240            LlmBackend::Ollama(ollama, _) => {
1241                // ollama-rs doesn't expose the URL directly, reconstruct from known state
1242                // The URL was parsed in new(), but we can't easily get it back.
1243                // For display purposes, use a best-effort approach.
1244                format!("{}", ollama.uri())
1245            }
1246            #[cfg(not(target_arch = "wasm32"))]
1247            LlmBackend::OpenAI { base_url, .. } => base_url.clone(),
1248            LlmBackend::Queue { .. } => "(calling agent)".to_string(),
1249            LlmBackend::Bridge { .. } => "(host bridge)".to_string(),
1250        }
1251    }
1252
1253    /// Set the status channel for sending trace logs to TUI
1254    pub fn with_status_tx(mut self, status_tx: mpsc::UnboundedSender<String>) -> Self {
1255        self.status_tx = Some(status_tx);
1256        self
1257    }
1258
1259    /// The TUI status channel this client narrates to, if any.
1260    ///
1261    /// Exposed so the event-logging path (`action_helper::call_llm`) can route
1262    /// templated event lifecycle lines to the same TUI stream the transport
1263    /// uses, instead of always passing `None` to the template renderer.
1264    pub fn status_tx(&self) -> Option<&mpsc::UnboundedSender<String>> {
1265        self.status_tx.as_ref()
1266    }
1267
1268    /// Dual-sink log facade bound to this client's status channel.
1269    ///
1270    /// The transport layer owns **wire facts** (model, sizes, tokens) which are
1271    /// `DEBUG`/`TRACE` and therefore file-only through the facade's defaults; it
1272    /// narrates to the TUI only for `WARN`/`ERROR`. The conversation layer is the
1273    /// one that narrates the round-trip to the TUI. This split is what removes the
1274    /// request/response double-logging.
1275    fn log(&self) -> Log<'_> {
1276        Log::new(self.status_tx.as_ref())
1277    }
1278
1279    /// Forward the reasoning a bridge host sent with its answer (`BridgeReply::reasoning`)
1280    /// the way a streamed Ollama `thinking` or OpenAI `reasoning` is forwarded: as
1281    /// `[REASONING]` lines on the status channel, plus the file-only summary and payload.
1282    /// The host has already shown it live on its own side; here it arrives whole, with the
1283    /// answer, because the bridge carries one reply per request.
1284    fn forward_host_reasoning(&self, reasoning: Option<&str>, private_payloads: bool) {
1285        if private_payloads {
1286            return;
1287        }
1288        let Some(reasoning) = reasoning.filter(|r| !r.trim().is_empty()) else {
1289            return;
1290        };
1291        let mut fwd = ReasoningForwarder::new(self.status_tx.as_ref());
1292        fwd.push(reasoning);
1293        fwd.flush();
1294        let log = self.log();
1295        log.debug(format!(
1296            "LLM reasoning: {} chars from the host",
1297            reasoning.len()
1298        ));
1299        log.payload("Full LLM reasoning", reasoning, usize::MAX);
1300    }
1301
1302    /// Read an Ollama HTTP response body — NDJSON stream or single object — into a
1303    /// [`StreamAccumulation`], forwarding reasoning deltas to this client's status
1304    /// channel as each line arrives.
1305    ///
1306    /// Uses `Response::chunk()` (no extra reqwest feature) and splits on `\n`, so
1307    /// the reasoning appears live rather than after the whole body is buffered. A
1308    /// non-streaming single-object body (the test mock, any `stream:false` backend)
1309    /// simply arrives as one trailing line with no newline and is handled the same.
1310    #[cfg(not(target_arch = "wasm32"))]
1311    async fn read_ollama_stream(
1312        &self,
1313        mut http_response: reqwest::Response,
1314        kind: OllamaResponseKind,
1315        private_payloads: bool,
1316    ) -> Result<StreamAccumulation> {
1317        let mut acc = StreamAccumulation::default();
1318        let mut fwd = ReasoningForwarder::new(if private_payloads {
1319            None
1320        } else {
1321            self.status_tx.as_ref()
1322        });
1323        let mut buf: Vec<u8> = Vec::new();
1324
1325        // Bound the whole body read by the same wall-clock budget as the request, so
1326        // a hung stream mid-body cannot block forever.
1327        let read = async {
1328            while let Some(chunk) = http_response
1329                .chunk()
1330                .await
1331                .context("reading Ollama streaming response body")?
1332            {
1333                buf.extend_from_slice(&chunk);
1334                // A '\n' byte never occurs inside a UTF-8 multibyte sequence, so each
1335                // drained line is a complete, valid-UTF-8 logical line.
1336                while let Some(pos) = buf.iter().position(|&b| b == b'\n') {
1337                    let line: Vec<u8> = buf.drain(..=pos).collect();
1338                    if let Ok(s) = std::str::from_utf8(&line) {
1339                        process_stream_line(s, kind, &mut acc, &mut fwd);
1340                    }
1341                }
1342            }
1343            // Trailing line without a newline — notably the single-object mock body.
1344            if !buf.is_empty() {
1345                if let Ok(s) = std::str::from_utf8(&buf) {
1346                    process_stream_line(s, kind, &mut acc, &mut fwd);
1347                }
1348            }
1349            fwd.flush();
1350            Ok::<(), anyhow::Error>(())
1351        };
1352
1353        tokio::time::timeout(self.request_timeout, read)
1354            .await
1355            .with_context(|| {
1356                format!(
1357                    "Ollama streaming response body timed out after {:?}",
1358                    self.request_timeout
1359                )
1360            })??;
1361
1362        // Wire facts: reasoning recorded to the FILE (DEBUG summary + TRACE payload),
1363        // never streamed to the TUI here — the TUI already received the live
1364        // [REASONING] lines. This is a new output, not a duplicate of the response log.
1365        if !acc.thinking.is_empty() {
1366            let log = self.log().with_payloads(!private_payloads);
1367            log.debug(format!(
1368                "LLM reasoning: {} chars streamed",
1369                acc.thinking.len()
1370            ));
1371            log.payload("Full LLM reasoning", &acc.thinking, usize::MAX);
1372        }
1373
1374        Ok(acc)
1375    }
1376
1377    /// Read an OpenAI `/v1/chat/completions` response — an SSE stream or a single
1378    /// completion object — into an [`OpenAiStreamAcc`], forwarding reasoning deltas
1379    /// to this client's status channel as each `data:` frame arrives.
1380    ///
1381    /// Like the Ollama reader it uses `Response::chunk()` and splits on `\n`, so
1382    /// reasoning appears live. It also buffers the raw body: if no SSE frame was
1383    /// seen (a non-streaming backend, or the single-object test mock), the whole
1384    /// body is parsed once as a completion object.
1385    #[cfg(not(target_arch = "wasm32"))]
1386    async fn read_openai_stream(
1387        &self,
1388        mut http_response: reqwest::Response,
1389        private_payloads: bool,
1390    ) -> Result<OpenAiStreamAcc> {
1391        let mut acc = OpenAiStreamAcc::default();
1392        let mut fwd = ReasoningForwarder::new(if private_payloads {
1393            None
1394        } else {
1395            self.status_tx.as_ref()
1396        });
1397        let mut buf: Vec<u8> = Vec::new();
1398        let mut raw = String::new();
1399        let mut saw_sse = false;
1400
1401        let read = async {
1402            while let Some(chunk) = http_response
1403                .chunk()
1404                .await
1405                .context("reading OpenAI streaming response body")?
1406            {
1407                buf.extend_from_slice(&chunk);
1408                while let Some(pos) = buf.iter().position(|&b| b == b'\n') {
1409                    let line: Vec<u8> = buf.drain(..=pos).collect();
1410                    if let Ok(s) = std::str::from_utf8(&line) {
1411                        raw.push_str(s);
1412                        if process_openai_sse_line(s, &mut acc, &mut fwd) {
1413                            saw_sse = true;
1414                        }
1415                    }
1416                }
1417            }
1418            if !buf.is_empty() {
1419                if let Ok(s) = std::str::from_utf8(&buf) {
1420                    raw.push_str(s);
1421                    if process_openai_sse_line(s, &mut acc, &mut fwd) {
1422                        saw_sse = true;
1423                    }
1424                }
1425            }
1426            Ok::<(), anyhow::Error>(())
1427        };
1428
1429        tokio::time::timeout(self.request_timeout, read)
1430            .await
1431            .with_context(|| {
1432                format!(
1433                    "OpenAI streaming response body timed out after {:?}",
1434                    self.request_timeout
1435                )
1436            })??;
1437
1438        // Non-SSE single object (mock / non-streaming backend): parse the whole
1439        // buffered body as a completion and merge choices[0].message.
1440        if !saw_sse {
1441            if let Ok(v) = serde_json::from_str::<serde_json::Value>(raw.trim()) {
1442                if let Some(err) = v.pointer("/error/message").and_then(|e| e.as_str()) {
1443                    acc.error = Some(err.to_string());
1444                }
1445                if let Some(u) = v.get("usage").filter(|u| u.is_object()) {
1446                    apply_openai_usage(&mut acc, u);
1447                }
1448                if let Some(msg) = v.pointer("/choices/0/message") {
1449                    merge_openai_choice(&mut acc, msg, &mut fwd);
1450                }
1451            }
1452        }
1453
1454        fwd.flush();
1455        Ok(acc)
1456    }
1457
1458    /// Set the mock configuration file path (for testing)
1459    pub fn with_mock_config_file(mut self, path: Option<std::path::PathBuf>) -> Self {
1460        self.mock_config_file = path;
1461        self
1462    }
1463
1464    /// Set the app state for token tracking
1465    pub fn with_app_state(mut self, state: crate::state::AppState) -> Self {
1466        self.app_state = Some(state);
1467        self
1468    }
1469
1470    /// Override the wall-clock bound on a single backend call
1471    /// (default [`DEFAULT_REQUEST_TIMEOUT`]).
1472    ///
1473    /// For the OpenAI backend this **rebuilds the HTTP client**, because reqwest enforces its
1474    /// own timeout underneath the `tokio::time::timeout` this field drives. That client was
1475    /// built with a hardcoded 120s, so whichever bound was shorter won — and raising
1476    /// `--llm-request-timeout` past 120s moved only the outer one. The flag appeared to work
1477    /// and the call still died at two minutes, reported as a transport failure rather than a
1478    /// timeout the user had asked for.
1479    pub fn with_request_timeout(mut self, timeout: std::time::Duration) -> Self {
1480        self.request_timeout = timeout;
1481        #[cfg(not(target_arch = "wasm32"))]
1482        {
1483            if let LlmBackend::OpenAI {
1484                client, base_url, ..
1485            } = &mut self.backend
1486            {
1487                *client = openai_http_client(timeout, base_url);
1488            }
1489        }
1490        self
1491    }
1492
1493    /// Override the completion-token budget for a single backend call
1494    /// (default [`DEFAULT_MAX_TOKENS`]). Reasoning models need more than the
1495    /// default, because their thinking block is spent from the same budget as
1496    /// the answer.
1497    pub fn with_max_tokens(mut self, max_tokens: u32) -> Self {
1498        self.max_tokens = max_tokens;
1499        self
1500    }
1501
1502    /// Set the sampling overrides sent with every request. See [`SamplingOptions`].
1503    pub fn with_sampling(mut self, sampling: SamplingOptions) -> Self {
1504        self.sampling = sampling;
1505        self
1506    }
1507
1508    /// The sampling overrides this client sends.
1509    pub fn sampling(&self) -> SamplingOptions {
1510        self.sampling
1511    }
1512
1513    /// Replace the circuit breaker (thresholds are per-breaker; see
1514    /// [`crate::llm::circuit_breaker`]).
1515    ///
1516    /// The breaker is shared by every clone of this client, so one backend outage is
1517    /// observed once rather than rediscovered by each connection.
1518    pub fn with_circuit_breaker(mut self, breaker: std::sync::Arc<CircuitBreaker>) -> Self {
1519        self.breaker = breaker;
1520        self
1521    }
1522
1523    /// The shared circuit breaker guarding this client's backend.
1524    pub fn circuit_breaker(&self) -> &std::sync::Arc<CircuitBreaker> {
1525        &self.breaker
1526    }
1527
1528    /// Snapshot of backend health — a server whose breaker has tripped should say so.
1529    pub fn circuit_breaker_status(&self) -> BreakerStatus {
1530        self.breaker.status()
1531    }
1532
1533    /// Whether the breaker guards this backend.
1534    ///
1535    /// The agent-queue backend is excluded: its "timeouts" mean the calling MCP agent has
1536    /// not answered yet, which is not a transport fault, and tripping on a slow human-driven
1537    /// agent would break the `--llm-agent` flow outright.
1538    fn breaker_applies(&self) -> bool {
1539        match self.backend {
1540            #[cfg(not(target_arch = "wasm32"))]
1541            LlmBackend::Ollama(..) | LlmBackend::OpenAI { .. } => true,
1542            // A queued or bridged request waits on an agent or a person, not a transport.
1543            LlmBackend::Queue { .. } | LlmBackend::Bridge { .. } => false,
1544        }
1545    }
1546
1547    /// Fail fast if the backend is known to be down.
1548    fn breaker_guard(&self) -> Result<Option<crate::llm::circuit_breaker::BreakerPermit<'_>>> {
1549        if !self.breaker_applies() {
1550            return Ok(None);
1551        }
1552        match self.breaker.acquire() {
1553            Ok(permit) => Ok(Some(permit)),
1554            Err(open) => {
1555                debug!("Short-circuiting LLM request: {}", open);
1556                self.log().warn(self.breaker.status().summary());
1557                Err(anyhow::Error::new(open))
1558            }
1559        }
1560    }
1561
1562    /// Feed the original outcome into its request permit, then hide private diagnostics.
1563    fn record_backend_outcome<T>(
1564        &self,
1565        permit: Option<crate::llm::circuit_breaker::BreakerPermit<'_>>,
1566        result: Result<T>,
1567        private_payloads: bool,
1568    ) -> Result<T> {
1569        let hide_error = |e: anyhow::Error| {
1570            if !private_payloads {
1571                e
1572            } else if is_transport_failure(&e) {
1573                anyhow::anyhow!(
1574                    "LLM connection failure for credential-bearing request; details hidden"
1575                )
1576            } else {
1577                anyhow::anyhow!("LLM backend rejected credential-bearing request; details hidden")
1578            }
1579        };
1580        let Some(permit) = permit else {
1581            return result.map_err(hide_error);
1582        };
1583
1584        match &result {
1585            Ok(_) => permit.record_success(),
1586            Err(e) if is_transport_failure(e) => {
1587                let summary = if private_payloads {
1588                    "LLM connection failure for credential-bearing request; details hidden"
1589                        .to_string()
1590                } else {
1591                    format!("{e:#}")
1592                };
1593                if permit.record_failure(&summary) {
1594                    self.log().error(self.breaker.status().summary());
1595                } else {
1596                    warn!(
1597                        "LLM transport failure {}/{} before the circuit breaker opens: {}",
1598                        self.breaker.status().consecutive_failures,
1599                        self.breaker.failure_threshold(),
1600                        crate::utils::truncate_for_log(&summary, 200)
1601                    );
1602                }
1603            }
1604            // The backend answered, it just answered with an error. Transport is fine.
1605            Err(_) => permit.record_success(),
1606        }
1607
1608        result.map_err(hide_error)
1609    }
1610
1611    /// Generate a completion from the model with optional JSON schema
1612    ///
1613    /// IMPORTANT: This method is crate-private. Use `action_helper::call_llm_with_actions()`
1614    /// instead for all LLM calls. The action helper provides a unified interface with
1615    /// proper prompt building, response parsing, and action execution.
1616    ///
1617    /// Only use this directly in:
1618    /// - action_helper module (the primary consumer)
1619    /// - handler for user input commands
1620    pub(crate) async fn generate(&self, model: &str, prompt: &str) -> Result<GenerateResponse> {
1621        self.generate_with_format(model, prompt, None).await
1622    }
1623
1624    /// Generate a completion with a specific JSON schema format
1625    ///
1626    /// IMPORTANT: This method is crate-private. Use `action_helper::call_llm_with_actions()`
1627    /// for network event handling, or the specialized methods like generate_command_interpretation()
1628    /// for user command interpretation.
1629    pub(crate) async fn generate_with_format(
1630        &self,
1631        model: &str,
1632        prompt: &str,
1633        format: Option<serde_json::Value>,
1634    ) -> Result<GenerateResponse> {
1635        self.generate_offering(model, prompt, format, &[], false)
1636            .await
1637    }
1638
1639    /// [`Self::generate_with_format`] for a prompt that offers actions. `offered` is the
1640    /// list the prompt describes; only the bridge backend passes it on (a person answering
1641    /// by hand gets a form built from it), and the HTTP backends send exactly what
1642    /// `generate_with_format` sends. `private_payloads` also covers credentials
1643    /// retained in earlier conversation turns when the offered list is narrowed.
1644    pub(crate) async fn generate_offering(
1645        &self,
1646        model: &str,
1647        prompt: &str,
1648        format: Option<serde_json::Value>,
1649        offered: &[crate::llm::actions::ActionDefinition],
1650        private_payloads: bool,
1651    ) -> Result<GenerateResponse> {
1652        self.generate_offering_with_event(model, prompt, format, offered, None, private_payloads)
1653            .await
1654    }
1655
1656    /// The event metadata is for bridge hosts only; native model requests are unchanged.
1657    pub(crate) async fn generate_offering_with_event(
1658        &self,
1659        model: &str,
1660        prompt: &str,
1661        format: Option<serde_json::Value>,
1662        offered: &[crate::llm::actions::ActionDefinition],
1663        event: Option<&crate::llm::bridge::BridgeEventContext>,
1664        private_payloads: bool,
1665    ) -> Result<GenerateResponse> {
1666        // Fail immediately if the backend is already known to be down, rather than paying
1667        // another full request timeout to rediscover it. See `crate::llm::circuit_breaker`.
1668        let private_payloads =
1669            private_payloads || crate::utils::redact::actions_have_credentials(offered);
1670        let permit = self.breaker_guard()?;
1671        let result = self
1672            .generate_with_format_inner(model, prompt, format, offered, event, private_payloads)
1673            .await;
1674        self.record_backend_outcome(permit, result, private_payloads)
1675    }
1676
1677    async fn generate_with_format_inner(
1678        &self,
1679        model: &str,
1680        prompt: &str,
1681        format: Option<serde_json::Value>,
1682        offered: &[crate::llm::actions::ActionDefinition],
1683        event: Option<&crate::llm::bridge::BridgeEventContext>,
1684        private_payloads: bool,
1685    ) -> Result<GenerateResponse> {
1686        // Transport owns wire facts: DEBUG summary + TRACE payload, both file-only.
1687        // The conversation layer is the one that narrates the round-trip to the TUI.
1688        let log = self.log().with_payloads(!private_payloads);
1689        log.debug(format!(
1690            "LLM request: model={}, prompt_len={} chars, format={}",
1691            model,
1692            prompt.len(),
1693            if format.is_some() { "JSON" } else { "text" }
1694        ));
1695        log.payload("Full LLM prompt", prompt, usize::MAX);
1696        if let Some(ref schema) = format {
1697            log.trace(format!(
1698                "JSON schema:\n{}",
1699                serde_json::to_string_pretty(schema).unwrap_or_else(|_| "invalid".to_string())
1700            ));
1701        }
1702
1703        // Dispatch to the appropriate backend
1704        let (response_text, token_usage) = match &self.backend {
1705            #[cfg(not(target_arch = "wasm32"))]
1706            LlmBackend::Ollama(ollama, http_client) => {
1707                // Streamed `/api/generate`: `stream: true` returns NDJSON deltas we
1708                // forward live (reasoning) while accumulating the full response. Called
1709                // via reqwest rather than ollama-rs because ollama-rs's high-level
1710                // `generate()` buffers the whole reply, defeating live streaming.
1711                let mut body = serde_json::json!({
1712                    "model": model,
1713                    "prompt": prompt,
1714                    "stream": true,
1715                    // num_predict allows longer responses (esp. binary protocol
1716                    // data, and reasoning models that think before answering).
1717                    "options": { "num_predict": self.max_tokens },
1718                });
1719                self.sampling.apply_to(&mut body["options"]);
1720                if format.is_some() {
1721                    body["format"] = serde_json::json!("json");
1722                }
1723
1724                let url = format!("{}/api/generate", ollama.url_str().trim_end_matches('/'));
1725                let http_client = http_client.clone();
1726
1727                let http_response = tokio::time::timeout(
1728                    self.request_timeout,
1729                    http_client.post(&url).json(&body).send(),
1730                )
1731                .await
1732                .with_context(|| format!("Ollama API call timed out after {:?}.\n   Please check:\n   1. Ollama is running (https://ollama.ai)\n   2. Model is loaded and ready\n   3. Use `/model` to list and select a model", self.request_timeout))?
1733                .map_err(|e| {
1734                    let error_str = e.to_string().to_lowercase();
1735                    if error_str.contains("connection") || error_str.contains("refused") || error_str.contains("connect") {
1736                        anyhow::anyhow!(
1737                            "✗  Cannot connect to Ollama.\n   Please ensure:\n   1. Ollama is running: https://ollama.ai\n   2. Ollama is listening on http://localhost:11434\n   3. Use `/model` command to list and select a model\n\n   Original error: {}", e
1738                        )
1739                    } else {
1740                        anyhow::anyhow!("✗  Ollama request failed: {}\n   Use `/model` to check available models", e)
1741                    }
1742                })?;
1743
1744                let status = http_response.status();
1745                if !status.is_success() {
1746                    let body =
1747                        crate::client::http_fetch::read_response_text(http_response, 1024 * 1024)
1748                            .await?;
1749                    let msg = serde_json::from_str::<serde_json::Value>(&body)
1750                        .ok()
1751                        .and_then(|v| {
1752                            v.get("error")
1753                                .and_then(|e| e.as_str())
1754                                .map(|s| s.to_string())
1755                        })
1756                        .unwrap_or(body);
1757                    if status.as_u16() == 404 || msg.to_lowercase().contains("not found") {
1758                        anyhow::bail!(
1759                            "✗  Model not found in Ollama.\n   Please:\n   1. Pull the model: ollama pull {}\n   2. Or use `/model` to select a different model\n\n   Original error: {}", model, msg
1760                        );
1761                    }
1762                    anyhow::bail!("✗  Ollama request failed ({}): {}\n   Use `/model` to check available models", status, msg);
1763                }
1764
1765                let acc = self
1766                    .read_ollama_stream(
1767                        http_response,
1768                        OllamaResponseKind::Generate,
1769                        private_payloads,
1770                    )
1771                    .await?;
1772                if let Some(err) = acc.error {
1773                    anyhow::bail!("✗  Ollama request failed: {}", err);
1774                }
1775
1776                let usage = TokenUsage {
1777                    prompt_tokens: acc.prompt_eval_count,
1778                    completion_tokens: acc.eval_count,
1779                    total_tokens: acc.prompt_eval_count + acc.eval_count,
1780                };
1781                (acc.content, usage)
1782            }
1783
1784            #[cfg(not(target_arch = "wasm32"))]
1785            LlmBackend::OpenAI {
1786                client,
1787                base_url,
1788                api_key,
1789            } => {
1790                let mut body = serde_json::json!({
1791                    "model": model,
1792                    "messages": [{ "role": "user", "content": prompt }],
1793                    "max_tokens": self.max_tokens,
1794                    // Stream so reasoning appears live; ask for usage in the final frame.
1795                    "stream": true,
1796                    "stream_options": { "include_usage": true },
1797                });
1798                self.sampling.apply_to(&mut body);
1799
1800                if format.is_some() {
1801                    body["response_format"] = serde_json::json!({ "type": "json_object" });
1802                }
1803
1804                let url = format!("{}/v1/chat/completions", base_url);
1805
1806                let http_response = tokio::time::timeout(
1807                    self.request_timeout,
1808                    client
1809                        .post(&url)
1810                        .header("Authorization", format!("Bearer {}", api_key))
1811                        .header("Content-Type", "application/json")
1812                        .json(&body)
1813                        .send(),
1814                )
1815                .await
1816                .with_context(|| {
1817                    format!("OpenAI API call timed out after {:?}", self.request_timeout)
1818                })?
1819                .context("OpenAI API request failed")?;
1820
1821                let status = http_response.status();
1822                if !status.is_success() {
1823                    // Error responses are a single JSON object, not an SSE stream.
1824                    let response_body: serde_json::Value =
1825                        crate::client::http_fetch::read_response_json(http_response, 1024 * 1024)
1826                            .await?;
1827                    let error_msg = response_body
1828                        .pointer("/error/message")
1829                        .and_then(|m| m.as_str())
1830                        .unwrap_or("Unknown error");
1831
1832                    match status.as_u16() {
1833                        401 => anyhow::bail!(
1834                            "✗  Authentication failed. Check your API key.\n   Error: {}",
1835                            error_msg
1836                        ),
1837                        404 => anyhow::bail!(
1838                            "✗  Model '{}' not found.\n   Error: {}",
1839                            model,
1840                            error_msg
1841                        ),
1842                        429 => anyhow::bail!(
1843                            "✗  Rate limited by API provider.\n   Error: {}",
1844                            error_msg
1845                        ),
1846                        _ => anyhow::bail!("✗  OpenAI API error ({}): {}", status, error_msg),
1847                    }
1848                }
1849
1850                let acc = self
1851                    .read_openai_stream(http_response, private_payloads)
1852                    .await?;
1853                if let Some(err) = &acc.error {
1854                    anyhow::bail!("✗  OpenAI API error: {}", err);
1855                }
1856                (acc.content.clone(), acc.token_usage())
1857            }
1858
1859            LlmBackend::Bridge { bridge, timeout } => {
1860                // No model here: the host answers. The prompt goes over as one user message,
1861                // the way the OpenAI arm sends it.
1862                let (id, rx) = bridge.submit_with_event(
1863                    crate::llm::bridge::BridgeRequestKind::Generate,
1864                    model.to_string(),
1865                    vec![Message::user(prompt)],
1866                    Vec::new(),
1867                    offered,
1868                    event.cloned(),
1869                );
1870                let reply = await_bridge_reply(id, rx, *timeout).await?;
1871                self.forward_host_reasoning(reply.reasoning.as_deref(), private_payloads);
1872                let usage = TokenUsage {
1873                    prompt_tokens: reply.prompt_tokens,
1874                    completion_tokens: reply.completion_tokens,
1875                    total_tokens: reply.prompt_tokens + reply.completion_tokens,
1876                };
1877                // A host that answered a prompt with native tool calls (a model that
1878                // prefers them) is folded into the action envelope the text path parses.
1879                let text = match reply.content {
1880                    Some(content) if !content.trim().is_empty() => content,
1881                    _ if !reply.tool_calls.is_empty() => {
1882                        let actions: Vec<serde_json::Value> = reply
1883                            .tool_calls
1884                            .into_iter()
1885                            .map(|call| {
1886                                let mut action = call.arguments;
1887                                if !action.is_object() {
1888                                    action = serde_json::json!({});
1889                                }
1890                                action["type"] = serde_json::Value::String(call.name);
1891                                action
1892                            })
1893                            .collect();
1894                        serde_json::json!({ "actions": actions }).to_string()
1895                    }
1896                    _ => String::new(),
1897                };
1898                (text, usage)
1899            }
1900
1901            LlmBackend::Queue { queue, timeout } => {
1902                // No model: enqueue the prompt and await the agent's action JSON.
1903                let (id, rx) =
1904                    queue.submit(model.to_string(), vec![Message::user(prompt)], Vec::new());
1905                let actions = match tokio::time::timeout(*timeout, rx).await {
1906                    Ok(Ok(actions)) => actions,
1907                    Ok(Err(_)) => {
1908                        queue.expire(id);
1909                        anyhow::bail!(
1910                            "agent-queue: LLM request #{} was dropped before it was answered",
1911                            id
1912                        );
1913                    }
1914                    Err(_) => {
1915                        queue.expire(id);
1916                        anyhow::bail!(
1917                            "agent-queue: LLM request #{} timed out after {:?} without an agent answer",
1918                            id,
1919                            timeout
1920                        );
1921                    }
1922                };
1923                let text = serde_json::json!({ "actions": actions }).to_string();
1924                (text, TokenUsage::default())
1925            }
1926        };
1927
1928        // Record tokens in app state if available (for /usage command)
1929        if let Some(ref state) = self.app_state {
1930            state
1931                .record_llm_tokens(token_usage.prompt_tokens, token_usage.completion_tokens)
1932                .await;
1933        }
1934
1935        // Wire fact: response size + tokens. DEBUG, file-only.
1936        log.debug(format!(
1937            "LLM response: response_len={} chars, tokens={}i/{}o/{}t",
1938            response_text.len(),
1939            token_usage.prompt_tokens,
1940            token_usage.completion_tokens,
1941            token_usage.total_tokens
1942        ));
1943
1944        // Check for empty response (model may be incompatible with JSON format).
1945        // This is a real failure the peer will feel, so it reaches the TUI (ERROR).
1946        if response_text.is_empty() || response_text.trim().is_empty() {
1947            let error_msg = format!(
1948                "Model '{}' returned empty response (used {} completion tokens).",
1949                model, token_usage.completion_tokens
1950            );
1951            log.error(&error_msg);
1952            return Err(anyhow::anyhow!(error_msg));
1953        }
1954
1955        // TRACE: full payload, file-only (pretty-printed JSON when possible).
1956        if let Ok(json) = serde_json::from_str::<serde_json::Value>(&response_text) {
1957            let pretty =
1958                serde_json::to_string_pretty(&json).unwrap_or_else(|_| response_text.clone());
1959            log.payload("Full LLM response (JSON)", &pretty, usize::MAX);
1960        } else {
1961            log.payload("Full LLM response (text)", &response_text, usize::MAX);
1962        }
1963
1964        Ok(GenerateResponse {
1965            text: response_text,
1966            token_usage,
1967        })
1968    }
1969
1970    /// Chat with native tool calling support
1971    ///
1972    /// Sends structured messages with tool definitions to the LLM backend.
1973    /// The LLM can respond with text content and/or tool calls.
1974    ///
1975    /// This is the preferred method for the chat completions migration.
1976    /// For Ollama: uses /api/chat with tools parameter
1977    /// For OpenAI: uses /v1/chat/completions with tools parameter
1978    ///
1979    /// # Arguments
1980    /// * `request` - Chat request with messages, tools, and model
1981    ///
1982    /// # Returns
1983    /// * `Ok(ChatResponse)` - Response with optional content and tool calls
1984    pub(crate) async fn chat_with_tools(
1985        &self,
1986        request: &ChatRequest,
1987        private_payloads: bool,
1988    ) -> Result<ChatResponse> {
1989        // See `generate_with_format`: fail fast while the backend is known to be down.
1990        let private_payloads = private_payloads
1991            || crate::utils::redact::actions_have_credentials(&request.offered_actions);
1992        let permit = self.breaker_guard()?;
1993        let result = self.chat_with_tools_inner(request, private_payloads).await;
1994        self.record_backend_outcome(permit, result, private_payloads)
1995    }
1996
1997    async fn chat_with_tools_inner(
1998        &self,
1999        request: &ChatRequest,
2000        private_payloads: bool,
2001    ) -> Result<ChatResponse> {
2002        // Transport wire facts: file-only through the facade defaults.
2003        let log = self.log().with_payloads(!private_payloads);
2004        log.debug(format!(
2005            "Chat request: model={}, messages={}, tools={}",
2006            request.model,
2007            request.messages.len(),
2008            request.tools.len()
2009        ));
2010
2011        log.trace(format!(
2012            "Chat messages: {:?}",
2013            request
2014                .messages
2015                .iter()
2016                .map(|m| {
2017                    format!(
2018                        "[{}] {}",
2019                        m.role,
2020                        crate::utils::truncate_for_log(&m.content, 100)
2021                    )
2022                })
2023                .collect::<Vec<_>>()
2024        ));
2025
2026        let chat_response = match &self.backend {
2027            #[cfg(not(target_arch = "wasm32"))]
2028            LlmBackend::Ollama(ollama, http_client) => {
2029                self.chat_with_tools_ollama(ollama, http_client, request, private_payloads)
2030                    .await?
2031            }
2032            #[cfg(not(target_arch = "wasm32"))]
2033            LlmBackend::OpenAI {
2034                client,
2035                base_url,
2036                api_key,
2037            } => {
2038                self.chat_with_tools_openai(client, base_url, api_key, request, private_payloads)
2039                    .await?
2040            }
2041            LlmBackend::Queue { queue, timeout } => {
2042                self.chat_with_tools_queue(queue.clone(), *timeout, request)
2043                    .await?
2044            }
2045            LlmBackend::Bridge { bridge, timeout } => {
2046                let (id, rx) = bridge.submit(
2047                    crate::llm::bridge::BridgeRequestKind::Chat,
2048                    request.model.clone(),
2049                    request.messages.clone(),
2050                    request.tools.clone(),
2051                    &request.offered_actions,
2052                );
2053                let reply = await_bridge_reply(id, rx, *timeout).await?;
2054                self.forward_host_reasoning(reply.reasoning.as_deref(), private_payloads);
2055                let tool_calls = reply
2056                    .tool_calls
2057                    .into_iter()
2058                    .enumerate()
2059                    .map(|(i, call)| ToolCall {
2060                        id: call.id.unwrap_or_else(|| format!("bridge-{}-{}", id, i)),
2061                        function_name: call.name,
2062                        arguments: call.arguments,
2063                    })
2064                    .collect();
2065                ChatResponse {
2066                    content: reply.content,
2067                    tool_calls,
2068                    token_usage: TokenUsage {
2069                        prompt_tokens: reply.prompt_tokens,
2070                        completion_tokens: reply.completion_tokens,
2071                        total_tokens: reply.prompt_tokens + reply.completion_tokens,
2072                    },
2073                }
2074            }
2075        };
2076
2077        // Record tokens in app state if available
2078        if let Some(ref state) = self.app_state {
2079            state
2080                .record_llm_tokens(
2081                    chat_response.token_usage.prompt_tokens,
2082                    chat_response.token_usage.completion_tokens,
2083                )
2084                .await;
2085        }
2086
2087        log.debug(format!(
2088            "Chat response: content_len={}, tool_calls={}, tokens={}i/{}o/{}t",
2089            chat_response.content.as_ref().map(|c| c.len()).unwrap_or(0),
2090            chat_response.tool_calls.len(),
2091            chat_response.token_usage.prompt_tokens,
2092            chat_response.token_usage.completion_tokens,
2093            chat_response.token_usage.total_tokens
2094        ));
2095
2096        Ok(chat_response)
2097    }
2098
2099    /// Agent-queue backend: enqueue the request and await the calling MCP agent's
2100    /// answer. The answer (a NetGet action JSON array) is wrapped as
2101    /// `{"actions":[...]}` in the response content — the same shape the test mock
2102    /// Ollama server produces — so `ActionResponse::from_str` parses it downstream.
2103    async fn chat_with_tools_queue(
2104        &self,
2105        queue: std::sync::Arc<crate::llm::agent_queue::LlmRequestQueue>,
2106        timeout: std::time::Duration,
2107        request: &ChatRequest,
2108    ) -> Result<ChatResponse> {
2109        let (id, rx) = queue.submit(
2110            request.model.clone(),
2111            request.messages.clone(),
2112            request.tools.clone(),
2113        );
2114        self.log().debug(format!(
2115            "agent-queue: enqueued LLM request #{} ({} tools), awaiting agent answer (timeout {:?})",
2116            id,
2117            request.tools.len(),
2118            timeout
2119        ));
2120
2121        match tokio::time::timeout(timeout, rx).await {
2122            Ok(Ok(actions)) => {
2123                // The native-tools pipeline (ConversationHandler) reads tool_calls, not
2124                // content: turn each answered action into a ToolCall whose function name
2125                // is the action `type` and whose arguments are the remaining fields.
2126                let tool_calls = actions
2127                    .into_iter()
2128                    .enumerate()
2129                    .map(|(i, mut action)| {
2130                        let function_name = action
2131                            .get("type")
2132                            .and_then(|v| v.as_str())
2133                            .unwrap_or_default()
2134                            .to_string();
2135                        if let Some(map) = action.as_object_mut() {
2136                            map.remove("type");
2137                        }
2138                        ToolCall {
2139                            id: format!("agent-{}", i),
2140                            function_name,
2141                            arguments: action,
2142                        }
2143                    })
2144                    .collect();
2145                Ok(ChatResponse {
2146                    content: None,
2147                    tool_calls,
2148                    token_usage: TokenUsage::default(),
2149                })
2150            }
2151            Ok(Err(_)) => {
2152                queue.expire(id);
2153                anyhow::bail!(
2154                    "agent-queue: LLM request #{} was dropped before it was answered",
2155                    id
2156                )
2157            }
2158            Err(_) => {
2159                queue.expire(id);
2160                anyhow::bail!(
2161                    "agent-queue: LLM request #{} timed out after {:?} without an agent answer",
2162                    id,
2163                    timeout
2164                )
2165            }
2166        }
2167    }
2168
2169    /// Ollama backend: /api/chat with tools
2170    #[cfg(not(target_arch = "wasm32"))]
2171    async fn chat_with_tools_ollama(
2172        &self,
2173        ollama: &Ollama,
2174        http_client: &reqwest::Client,
2175        request: &ChatRequest,
2176        private_payloads: bool,
2177    ) -> Result<ChatResponse> {
2178        // Build the request body manually since ollama-rs may not support tools natively
2179        let messages: Vec<serde_json::Value> = request
2180            .messages
2181            .iter()
2182            .map(|m| {
2183                let mut msg = serde_json::json!({
2184                    "role": m.role,
2185                    "content": m.content,
2186                });
2187                if let Some(ref tool_call_id) = m.tool_call_id {
2188                    // This message is a tool result - it's not a standard Ollama field,
2189                    // but some Ollama models support it. For now, embed in content.
2190                    msg["tool_call_id"] = serde_json::json!(tool_call_id);
2191                }
2192                msg
2193            })
2194            .collect();
2195
2196        let mut body = serde_json::json!({
2197            "model": request.model,
2198            "messages": messages,
2199            // Stream so reasoning (`message.thinking`) is forwarded live; the
2200            // accumulator reassembles the full content/tool_calls, and the test mock's
2201            // single-object body is handled as the one-line degenerate case.
2202            "stream": true,
2203        });
2204        // No `options` object at all unless an override is set: this path never sent one.
2205        if !self.sampling.is_empty() {
2206            body["options"] = serde_json::json!({});
2207            self.sampling.apply_to(&mut body["options"]);
2208        }
2209
2210        // Add tools if any
2211        if !request.tools.is_empty() {
2212            body["tools"] = serde_json::Value::Array(request.tools.clone());
2213        }
2214
2215        // ollama-rs doesn't expose a chat-with-tools API, so we call the endpoint via reqwest.
2216        // Preserve the client's full base URL (scheme, host, port, path prefix).
2217        let url = format!("{}/api/chat", ollama.url_str().trim_end_matches('/'));
2218
2219        let http_client = http_client.clone();
2220        let http_response = tokio::time::timeout(
2221            self.request_timeout,
2222            http_client.post(&url).json(&body).send(),
2223        )
2224        .await
2225        .with_context(|| {
2226            format!(
2227                "Ollama chat API call timed out after {:?}",
2228                self.request_timeout
2229            )
2230        })?
2231        .context("Ollama chat API request failed")?;
2232
2233        let status = http_response.status();
2234        if !status.is_success() {
2235            let body =
2236                crate::client::http_fetch::read_response_text(http_response, 1024 * 1024).await?;
2237            let error_msg = serde_json::from_str::<serde_json::Value>(&body)
2238                .ok()
2239                .and_then(|v| {
2240                    v.get("error")
2241                        .and_then(|e| e.as_str())
2242                        .map(|s| s.to_string())
2243                })
2244                .unwrap_or_else(|| "Unknown error".to_string());
2245            anyhow::bail!("Ollama chat API error ({}): {}", status, error_msg);
2246        }
2247
2248        let acc = self
2249            .read_ollama_stream(http_response, OllamaResponseKind::Chat, private_payloads)
2250            .await?;
2251        if let Some(err) = acc.error {
2252            anyhow::bail!("Ollama chat API error: {}", err);
2253        }
2254
2255        // Parse accumulated response
2256        let content = Some(acc.content).filter(|s| !s.is_empty());
2257
2258        let tool_calls = acc
2259            .tool_calls
2260            .as_ref()
2261            .and_then(|v| v.as_array())
2262            .map(|calls| {
2263                calls
2264                    .iter()
2265                    .enumerate()
2266                    .filter_map(|(i, tc)| {
2267                        let function = tc.get("function")?;
2268                        let name = function["name"].as_str()?.to_string();
2269                        let arguments = function.get("arguments").cloned().unwrap_or_default();
2270                        Some(ToolCall {
2271                            id: format!("call_{}", i),
2272                            function_name: name,
2273                            arguments,
2274                        })
2275                    })
2276                    .collect()
2277            })
2278            .unwrap_or_default();
2279
2280        let token_usage = TokenUsage {
2281            prompt_tokens: acc.prompt_eval_count,
2282            completion_tokens: acc.eval_count,
2283            total_tokens: acc.prompt_eval_count + acc.eval_count,
2284        };
2285
2286        Ok(ChatResponse {
2287            content,
2288            tool_calls,
2289            token_usage,
2290        })
2291    }
2292
2293    /// OpenAI backend: /v1/chat/completions with tools
2294    #[cfg(not(target_arch = "wasm32"))]
2295    async fn chat_with_tools_openai(
2296        &self,
2297        client: &reqwest::Client,
2298        base_url: &str,
2299        api_key: &str,
2300        request: &ChatRequest,
2301        private_payloads: bool,
2302    ) -> Result<ChatResponse> {
2303        let messages: Vec<serde_json::Value> = request
2304            .messages
2305            .iter()
2306            .map(|m| {
2307                if let Some(ref tool_call_id) = m.tool_call_id {
2308                    serde_json::json!({
2309                        "role": "tool",
2310                        "content": m.content,
2311                        "tool_call_id": tool_call_id,
2312                    })
2313                } else {
2314                    serde_json::json!({
2315                        "role": m.role,
2316                        "content": m.content,
2317                    })
2318                }
2319            })
2320            .collect();
2321
2322        let mut body = serde_json::json!({
2323            "model": request.model,
2324            "messages": messages,
2325            "max_tokens": 4096,
2326            // Stream so reasoning appears live; ask for usage in the final frame.
2327            "stream": true,
2328            "stream_options": { "include_usage": true },
2329        });
2330        self.sampling.apply_to(&mut body);
2331
2332        if !request.tools.is_empty() {
2333            body["tools"] = serde_json::Value::Array(request.tools.clone());
2334            body["tool_choice"] = serde_json::json!("auto");
2335        }
2336
2337        let url = format!("{}/v1/chat/completions", base_url);
2338
2339        let http_response = tokio::time::timeout(
2340            self.request_timeout,
2341            client
2342                .post(&url)
2343                .header("Authorization", format!("Bearer {}", api_key))
2344                .header("Content-Type", "application/json")
2345                .json(&body)
2346                .send(),
2347        )
2348        .await
2349        .context("OpenAI chat API call timed out after 120 seconds")?
2350        .context("OpenAI chat API request failed")?;
2351
2352        let status = http_response.status();
2353        if !status.is_success() {
2354            // Error responses are a single JSON object, not an SSE stream.
2355            let response_body: serde_json::Value =
2356                crate::client::http_fetch::read_response_json(http_response, 1024 * 1024).await?;
2357            let error_msg = response_body
2358                .pointer("/error/message")
2359                .and_then(|m| m.as_str())
2360                .unwrap_or("Unknown error");
2361            anyhow::bail!("OpenAI chat API error ({}): {}", status, error_msg);
2362        }
2363
2364        let acc = self
2365            .read_openai_stream(http_response, private_payloads)
2366            .await?;
2367        if let Some(err) = &acc.error {
2368            anyhow::bail!("OpenAI chat API error: {}", err);
2369        }
2370
2371        Ok(ChatResponse {
2372            content: Some(acc.content.clone()).filter(|s| !s.is_empty()),
2373            tool_calls: acc.into_tool_calls(),
2374            token_usage: acc.token_usage(),
2375        })
2376    }
2377
2378    /// Generate with automatic retry on parse errors (for legacy protocols)
2379    ///
2380    /// This is a simpler retry wrapper for protocols that don't use the action system.
2381    /// It retries if the response doesn't parse as ActionResponse.
2382    ///
2383    /// # Arguments
2384    /// * `model` - Model name
2385    /// * `prompt` - The prompt string
2386    /// * `expected_format` - Description of expected format for error message
2387    /// * `max_retries` - Maximum number of retries (0 = no retries, just one attempt)
2388    ///
2389    /// # Returns
2390    /// * `Ok(String)` - The LLM response (may be after retry)
2391    pub async fn generate_with_retry(
2392        &self,
2393        model: &str,
2394        prompt: &str,
2395        expected_format: &str,
2396        max_retries: usize,
2397    ) -> Result<String> {
2398        // Make prompt owned so we can update it for retries
2399        let mut current_prompt = prompt.to_string();
2400
2401        for attempt in 1..=max_retries + 1 {
2402            debug!("Generate attempt {}/{}", attempt, max_retries + 1);
2403            trace!(
2404                "Retry loop: attempt={}, prompt_len={}",
2405                attempt,
2406                current_prompt.len()
2407            );
2408
2409            // Generate response
2410            let generate_response = self.generate(model, &current_prompt).await?;
2411
2412            // Extract XML references BEFORE validating JSON format
2413            // This allows LLM to use <script001> tags without causing "trailing characters" errors
2414            let (json_only, _refs) =
2415                crate::llm::reference_parser::extract_references(&generate_response.text)
2416                    .unwrap_or_else(|_| {
2417                        (
2418                            generate_response.text.clone(),
2419                            std::collections::HashMap::new(),
2420                        )
2421                    });
2422
2423            // Strip markdown code fences if present (```json ... ``` or ``` ... ```)
2424            // LLMs sometimes wrap JSON in markdown formatting which causes parse errors
2425            let json_cleaned = strip_markdown_fences(&json_only);
2426
2427            // A reasoning model puts its thinking before the JSON, so accept an
2428            // answer that is embedded in prose rather than failing it for
2429            // formatting. Strict parsing is still tried first.
2430            let json_cleaned = match ActionResponse::from_str(&json_cleaned) {
2431                Ok(_) => json_cleaned,
2432                Err(_) => match extract_embedded_json_object(&json_cleaned) {
2433                    Some(embedded) if ActionResponse::from_str(embedded).is_ok() => {
2434                        debug!(
2435                            "Recovered the action JSON from a response that wrapped it in prose \
2436                             ({} chars of surrounding text)",
2437                            json_cleaned.len().saturating_sub(embedded.len())
2438                        );
2439                        embedded.to_string()
2440                    }
2441                    _ => json_cleaned,
2442                },
2443            };
2444
2445            // Try to parse cleaned JSON as ActionResponse to check format
2446            match ActionResponse::from_str(&json_cleaned) {
2447                // A tools-only answer parses, but this path has no tool loop —
2448                // `generate_with_retry` is the one-shot generate used by git,
2449                // mercurial and the event-case harness, so a tool call here is
2450                // a request the caller can never satisfy and returning it hands
2451                // them an empty action list. Retry with the correction below,
2452                // which tells the model to answer directly instead.
2453                Ok(parsed) if parsed.actions.is_empty() && !parsed.tools.is_empty() => {
2454                    warn!(
2455                        "Model asked to call {} tool(s) on a path that has none; \
2456                         retrying with a direct-answer correction",
2457                        parsed.tools.len()
2458                    );
2459                    if attempt > max_retries {
2460                        return Err(anyhow::anyhow!(
2461                            "LLM asked to call tools, which are not available on this \
2462                             path, and did not answer directly when asked again. \
2463                             Response began: {}",
2464                            crate::utils::truncate_for_log(&json_cleaned, 400)
2465                        ));
2466                    }
2467                    current_prompt = format!(
2468                        "{}\n\n---\n\nYour previous response asked to call a tool. Tools are \
2469                         not available for this request: answer directly instead, using only \
2470                         the actions listed above, and supply any value you would have asked \
2471                         a tool for yourself.\n\nRequired format: {}",
2472                        current_prompt, expected_format
2473                    );
2474                    continue;
2475                }
2476                Ok(_) => {
2477                    // Valid format!
2478                    if attempt > 1 {
2479                        info!("Retry successful on attempt {}", attempt);
2480                    }
2481                    trace!("Parse succeeded on attempt {}", attempt);
2482                    return Ok(generate_response.text);
2483                }
2484                Err(e) => {
2485                    if attempt <= max_retries {
2486                        // We have retries left
2487                        warn!("Parse error on attempt {}: {}", attempt, e);
2488                        warn!(
2489                            "Malformed response (after XML extraction and markdown stripping): {}",
2490                            crate::utils::truncate_for_log(&json_cleaned, 500)
2491                        );
2492                        trace!(
2493                            "Will retry with corrective feedback (attempt {}/{})",
2494                            attempt,
2495                            max_retries + 1
2496                        );
2497
2498                        current_prompt = format!(
2499                            "{}\n\n---\n\nYour previous response was invalid and could not be parsed.\n\nError: {}\n\nRequired format: {}\n\nPlease provide your response again in the correct format.",
2500                            current_prompt,
2501                            e,
2502                            expected_format
2503                        );
2504
2505                        info!(
2506                            "Retrying with corrective feedback (attempt {}/{})",
2507                            attempt + 1,
2508                            max_retries + 1
2509                        );
2510                        // Continue to next loop iteration with updated prompt
2511                    } else {
2512                        // No more retries
2513                        error!("Failed to get valid response after {} attempts", attempt);
2514                        trace!("Max retries exhausted, returning error");
2515                        // Carry a slice of what was actually returned: without
2516                        // it the operator sees only that parsing failed, and
2517                        // the difference between a truncated answer, a refusal
2518                        // and a model that ignored the format is invisible.
2519                        return Err(e).context(format!(
2520                            "LLM failed to provide valid format after retry. Response began: {}",
2521                            crate::utils::truncate_for_log(&json_cleaned, 400)
2522                        ));
2523                    }
2524                }
2525            }
2526        }
2527
2528        unreachable!("Loop should always return or error")
2529    }
2530
2531    /// Check if the LLM backend is available
2532    ///
2533    /// This is a probe, not a guard: it costs a round trip and races with the next real
2534    /// request. Callers wanting "do not attempt a doomed request" want the circuit breaker
2535    /// ([`Self::circuit_breaker_status`]), which is fed by the requests themselves.
2536    pub async fn is_available(&self) -> bool {
2537        self.list_models().await.is_ok()
2538    }
2539
2540    /// List available models from the backend
2541    pub async fn list_models(&self) -> Result<Vec<String>> {
2542        match &self.backend {
2543            LlmBackend::Bridge { bridge, .. } => Ok(bridge.models()),
2544            #[cfg(not(target_arch = "wasm32"))]
2545            LlmBackend::Ollama(ollama, _) => {
2546                // ollama-rs applies no timeout of its own, so an unreachable host that
2547                // silently drops packets would hang this call — and `is_available()` with it
2548                // — indefinitely.
2549                let models = tokio::time::timeout(self.request_timeout, ollama.list_local_models())
2550                    .await
2551                    .with_context(|| {
2552                        format!(
2553                            "Listing Ollama models timed out after {:?}",
2554                            self.request_timeout
2555                        )
2556                    })?
2557                    .map_err(|e| anyhow::anyhow!("Failed to list models: {}", e))?;
2558                Ok(models.into_iter().map(|m| m.name).collect())
2559            }
2560            #[cfg(not(target_arch = "wasm32"))]
2561            LlmBackend::OpenAI {
2562                client,
2563                base_url,
2564                api_key,
2565            } => {
2566                let url = format!("{}/v1/models", base_url);
2567                let response = client
2568                    .get(&url)
2569                    .header("Authorization", format!("Bearer {}", api_key))
2570                    .timeout(std::time::Duration::from_secs(5))
2571                    .send()
2572                    .await
2573                    .context("Failed to connect to OpenAI-compatible API")?;
2574
2575                if !response.status().is_success() {
2576                    anyhow::bail!("OpenAI API returned error status: {}", response.status());
2577                }
2578
2579                let body: serde_json::Value =
2580                    crate::client::http_fetch::read_response_json(response, 8 * 1024 * 1024)
2581                        .await
2582                        .context("Failed to parse model list response")?;
2583
2584                let models = body["data"]
2585                    .as_array()
2586                    .unwrap_or(&vec![])
2587                    .iter()
2588                    .filter_map(|m| m["id"].as_str().map(|s| s.to_string()))
2589                    .collect();
2590                Ok(models)
2591            }
2592            LlmBackend::Queue { .. } => Ok(vec!["agent".to_string()]),
2593        }
2594    }
2595}
2596
2597#[cfg(not(target_arch = "wasm32"))]
2598impl Default for OllamaClient {
2599    fn default() -> Self {
2600        OllamaClient::default()
2601    }
2602}
2603
2604/// Await the host's answer to a bridge request, bounded by `timeout`.
2605///
2606/// The three failure shapes are kept distinct in the message: the host said no (its own
2607/// text, which the page chose to show), the host dropped the request, and the host never
2608/// answered. Only the first is the model's doing.
2609async fn await_bridge_reply(
2610    id: u64,
2611    rx: tokio::sync::oneshot::Receiver<Result<crate::llm::bridge::BridgeReply, String>>,
2612    timeout: std::time::Duration,
2613) -> Result<crate::llm::bridge::BridgeReply> {
2614    match tokio::time::timeout(timeout, rx).await {
2615        Ok(Ok(Ok(reply))) => Ok(reply),
2616        Ok(Ok(Err(message))) => anyhow::bail!("✗  The model host reported an error: {}", message),
2617        Ok(Err(_)) => anyhow::bail!(
2618            "bridge: LLM request #{} was dropped before it was answered",
2619            id
2620        ),
2621        Err(_) => anyhow::bail!(
2622            "bridge: LLM request #{} timed out after {:?} without an answer",
2623            id,
2624            timeout
2625        ),
2626    }
2627}