1use 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#[derive(Clone)]
18enum LlmBackend {
19 #[cfg(not(target_arch = "wasm32"))]
29 Ollama(Ollama, reqwest::Client),
30 #[cfg(not(target_arch = "wasm32"))]
32 OpenAI {
33 client: reqwest::Client,
34 base_url: String,
35 api_key: String,
36 },
37 Queue {
40 queue: std::sync::Arc<crate::llm::agent_queue::LlmRequestQueue>,
41 timeout: std::time::Duration,
43 },
44 Bridge {
48 bridge: std::sync::Arc<crate::llm::bridge::LlmBridge>,
49 timeout: std::time::Duration,
51 },
52}
53
54fn 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 result = result
105 .strip_prefix("```json")
106 .or_else(|| result.strip_prefix("```"))
107 .unwrap_or(result)
108 .trim();
109
110 while let Some(stripped) = result.strip_suffix("```") {
113 result = stripped.trim();
114 }
115
116 result.to_string()
117}
118
119#[derive(Debug, Clone, Copy, PartialEq, Eq)]
142pub enum OllamaResponseKind {
143 Chat,
146 Generate,
148}
149
150#[derive(Debug, Clone, Default)]
156pub struct StreamAccumulation {
157 pub content: String,
159 pub thinking: String,
161 pub tool_calls: Option<serde_json::Value>,
163 pub prompt_eval_count: u64,
165 pub eval_count: u64,
167 pub error: Option<String>,
169}
170
171const REASONING_FLUSH_CHARS: usize = 160;
175
176const REASONING_PREFIX: &str = "[REASONING] ";
183
184struct 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 fn push(&mut self, delta: &str) {
206 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 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 fn flush(&mut self) {
244 let rest = std::mem::take(&mut self.buf);
245 self.emit(&rest);
246 }
247}
248
249fn 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
308pub 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#[derive(Default, Clone)]
340struct OpenAiToolCallAcc {
341 id: String,
342 name: String,
343 arguments: String,
344}
345
346#[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 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
399fn 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 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
438fn 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
470pub 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
506pub struct Message {
507 pub role: String,
508 pub content: String,
509 #[serde(skip_serializing_if = "Option::is_none")]
511 pub tool_call_id: Option<String>,
512}
513
514#[derive(Debug, Clone, Copy, Default)]
516pub struct TokenUsage {
517 pub prompt_tokens: u64,
519 pub completion_tokens: u64,
521 pub total_tokens: u64,
523}
524
525impl TokenUsage {
526 #[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#[derive(Debug, Clone)]
542pub struct GenerateResponse {
543 pub text: String,
545 pub token_usage: TokenUsage,
547}
548
549#[derive(Debug, Clone)]
551pub struct ChatRequest {
552 pub messages: Vec<Message>,
554 pub tools: Vec<serde_json::Value>,
556 pub model: String,
558 pub offered_actions: Vec<crate::llm::actions::ActionDefinition>,
561}
562
563#[derive(Debug, Clone)]
565pub struct ChatResponse {
566 pub content: Option<String>,
568 pub tool_calls: Vec<ToolCall>,
570 pub token_usage: TokenUsage,
572}
573
574#[derive(Debug, Clone)]
576pub struct ToolCall {
577 pub id: String,
579 pub function_name: String,
581 pub arguments: serde_json::Value,
583}
584
585impl Message {
586 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 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 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 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 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#[derive(Debug, Clone, Deserialize, Serialize, Default)]
634pub struct LlmResponse {
635 #[serde(default)]
637 pub output: Option<String>,
638
639 #[serde(default)]
641 pub close_connection: bool,
642
643 #[serde(default)]
645 pub wait_for_more: bool,
646
647 #[serde(default)]
649 pub shutdown_server: bool,
650
651 #[serde(default)]
653 pub log_message: Option<String>,
654
655 #[serde(default)]
657 pub set_memory: Option<String>,
658
659 #[serde(default)]
661 pub append_memory: Option<String>,
662}
663
664impl LlmResponse {
665 #[allow(clippy::should_implement_trait)]
667 pub fn from_str(s: &str) -> Result<Self> {
668 let trimmed = s.trim();
669
670 if let Ok(response) = serde_json::from_str::<LlmResponse>(trimmed) {
672 return Ok(response);
673 }
674
675 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 Ok(Self {
689 output: Some(trimmed.to_string()),
690 ..Default::default()
691 })
692 }
693 }
694 }
695}
696
697#[derive(Debug, Clone, Deserialize, Serialize)]
699pub struct HttpLlmResponse {
700 pub status: u16,
702
703 #[serde(default)]
705 pub headers: HashMap<String, String>,
706
707 pub body: String,
709
710 #[serde(default)]
712 pub log_message: Option<String>,
713
714 #[serde(default)]
716 pub set_memory: Option<String>,
717
718 #[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 #[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 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#[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 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#[derive(Debug, Clone, Deserialize, Serialize)]
813pub struct CommandInterpretation {
814 #[serde(default)]
816 pub actions: Vec<CommandAction>,
817
818 #[serde(default)]
820 pub message: Option<String>,
821}
822
823impl CommandInterpretation {
824 #[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
841pub const DEFAULT_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300);
859
860pub 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 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#[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 Ok(ip) => builder.resolve(bare, std::net::SocketAddr::new(ip, 0)),
909 Err(_) => builder,
910 }
911}
912
913#[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#[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#[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#[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#[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#[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#[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
1028pub const DEFAULT_MAX_TOKENS: u32 = 32768;
1043
1044#[derive(Debug, Clone, Copy, Default, PartialEq)]
1054pub struct SamplingOptions {
1055 pub seed: Option<u64>,
1058 pub temperature: Option<f32>,
1060}
1061
1062impl SamplingOptions {
1063 pub fn is_empty(&self) -> bool {
1065 self.seed.is_none() && self.temperature.is_none()
1066 }
1067
1068 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#[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 breaker: std::sync::Arc<CircuitBreaker>,
1088 request_timeout: std::time::Duration,
1090 max_tokens: u32,
1092 sampling: SamplingOptions,
1095}
1096
1097impl OllamaClient {
1098 #[cfg(not(target_arch = "wasm32"))]
1100 pub fn new(base_url: impl Into<String>) -> Self {
1101 let url_str = base_url.into();
1102
1103 let (host, port) = if let Some(port_start) = url_str.rfind(':') {
1106 if port_start > 6 && !url_str[port_start..].contains('/') {
1108 if let Ok(port_num) = url_str[port_start + 1..].parse::<u16>() {
1110 (&url_str[..port_start], port_num)
1112 } else {
1113 (url_str.as_str(), 11434)
1115 }
1116 } else {
1117 (url_str.as_str(), 11434)
1119 }
1120 } else {
1121 (url_str.as_str(), 11434)
1123 };
1124
1125 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 #[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 #[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 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 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 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 pub fn backend_url(&self) -> String {
1238 match &self.backend {
1239 #[cfg(not(target_arch = "wasm32"))]
1240 LlmBackend::Ollama(ollama, _) => {
1241 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 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 pub fn status_tx(&self) -> Option<&mpsc::UnboundedSender<String>> {
1265 self.status_tx.as_ref()
1266 }
1267
1268 fn log(&self) -> Log<'_> {
1276 Log::new(self.status_tx.as_ref())
1277 }
1278
1279 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 #[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 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 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 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 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 #[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 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 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 pub fn with_app_state(mut self, state: crate::state::AppState) -> Self {
1466 self.app_state = Some(state);
1467 self
1468 }
1469
1470 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 pub fn with_max_tokens(mut self, max_tokens: u32) -> Self {
1498 self.max_tokens = max_tokens;
1499 self
1500 }
1501
1502 pub fn with_sampling(mut self, sampling: SamplingOptions) -> Self {
1504 self.sampling = sampling;
1505 self
1506 }
1507
1508 pub fn sampling(&self) -> SamplingOptions {
1510 self.sampling
1511 }
1512
1513 pub fn with_circuit_breaker(mut self, breaker: std::sync::Arc<CircuitBreaker>) -> Self {
1519 self.breaker = breaker;
1520 self
1521 }
1522
1523 pub fn circuit_breaker(&self) -> &std::sync::Arc<CircuitBreaker> {
1525 &self.breaker
1526 }
1527
1528 pub fn circuit_breaker_status(&self) -> BreakerStatus {
1530 self.breaker.status()
1531 }
1532
1533 fn breaker_applies(&self) -> bool {
1539 match self.backend {
1540 #[cfg(not(target_arch = "wasm32"))]
1541 LlmBackend::Ollama(..) | LlmBackend::OpenAI { .. } => true,
1542 LlmBackend::Queue { .. } | LlmBackend::Bridge { .. } => false,
1544 }
1545 }
1546
1547 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 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 Err(_) => permit.record_success(),
1606 }
1607
1608 result.map_err(hide_error)
1609 }
1610
1611 pub(crate) async fn generate(&self, model: &str, prompt: &str) -> Result<GenerateResponse> {
1621 self.generate_with_format(model, prompt, None).await
1622 }
1623
1624 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 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 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 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 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 let (response_text, token_usage) = match &self.backend {
1705 #[cfg(not(target_arch = "wasm32"))]
1706 LlmBackend::Ollama(ollama, http_client) => {
1707 let mut body = serde_json::json!({
1712 "model": model,
1713 "prompt": prompt,
1714 "stream": true,
1715 "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": 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 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 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 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 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 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 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 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 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 pub(crate) async fn chat_with_tools(
1985 &self,
1986 request: &ChatRequest,
1987 private_payloads: bool,
1988 ) -> Result<ChatResponse> {
1989 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 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 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 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 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 #[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 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 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": true,
2203 });
2204 if !self.sampling.is_empty() {
2206 body["options"] = serde_json::json!({});
2207 self.sampling.apply_to(&mut body["options"]);
2208 }
2209
2210 if !request.tools.is_empty() {
2212 body["tools"] = serde_json::Value::Array(request.tools.clone());
2213 }
2214
2215 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 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 #[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": 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 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 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 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 let generate_response = self.generate(model, ¤t_prompt).await?;
2411
2412 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 let json_cleaned = strip_markdown_fences(&json_only);
2426
2427 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 match ActionResponse::from_str(&json_cleaned) {
2447 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 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 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 } else {
2512 error!("Failed to get valid response after {} attempts", attempt);
2514 trace!("Max retries exhausted, returning error");
2515 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 pub async fn is_available(&self) -> bool {
2537 self.list_models().await.is_ok()
2538 }
2539
2540 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 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
2604async 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}