use std::collections::HashMap;
use crate::llm::actions::ActionResponse;
use crate::llm::circuit_breaker::{is_transport_failure, BreakerStatus, CircuitBreaker};
use crate::logging::emit::Log;
use anyhow::{Context, Result};
use bytes::Bytes;
#[cfg(not(target_arch = "wasm32"))]
use ollama_rs::Ollama;
use serde::{Deserialize, Serialize};
use tokio::sync::mpsc;
use tracing::{debug, error, info, trace, warn};
#[derive(Clone)]
enum LlmBackend {
#[cfg(not(target_arch = "wasm32"))]
Ollama(Ollama, reqwest::Client),
#[cfg(not(target_arch = "wasm32"))]
OpenAI {
client: reqwest::Client,
base_url: String,
api_key: String,
},
Queue {
queue: std::sync::Arc<crate::llm::agent_queue::LlmRequestQueue>,
timeout: std::time::Duration,
},
Bridge {
bridge: std::sync::Arc<crate::llm::bridge::LlmBridge>,
timeout: std::time::Duration,
},
}
fn extract_embedded_json_object(text: &str) -> Option<&str> {
let start = text.find('{')?;
let mut depth = 0usize;
let mut in_string = false;
let mut escaped = false;
for (offset, c) in text[start..].char_indices() {
if in_string {
match c {
_ if escaped => escaped = false,
'\\' => escaped = true,
'"' => in_string = false,
_ => {}
}
continue;
}
match c {
'"' => in_string = true,
'{' => depth += 1,
'}' => {
depth -= 1;
if depth == 0 {
return Some(&text[start..=start + offset]);
}
}
_ => {}
}
}
None
}
fn strip_markdown_fences(text: &str) -> String {
let mut result = text.trim();
result = result
.strip_prefix("```json")
.or_else(|| result.strip_prefix("```"))
.unwrap_or(result)
.trim();
while let Some(stripped) = result.strip_suffix("```") {
result = stripped.trim();
}
result.to_string()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OllamaResponseKind {
Chat,
Generate,
}
#[derive(Debug, Clone, Default)]
pub struct StreamAccumulation {
pub content: String,
pub thinking: String,
pub tool_calls: Option<serde_json::Value>,
pub prompt_eval_count: u64,
pub eval_count: u64,
pub error: Option<String>,
}
const REASONING_FLUSH_CHARS: usize = 160;
const REASONING_PREFIX: &str = "[REASONING] ";
struct ReasoningForwarder<'a> {
status_tx: Option<&'a mpsc::UnboundedSender<String>>,
buf: String,
}
impl<'a> ReasoningForwarder<'a> {
fn new(status_tx: Option<&'a mpsc::UnboundedSender<String>>) -> Self {
Self {
status_tx,
buf: String::new(),
}
}
fn push(&mut self, delta: &str) {
if self.status_tx.is_none() || delta.is_empty() {
return;
}
self.buf.push_str(delta);
while let Some(pos) = self.buf.find('\n') {
let line: String = self.buf.drain(..=pos).collect();
self.emit(line.trim_end_matches(['\n', '\r']));
}
while self.buf.chars().count() >= REASONING_FLUSH_CHARS {
let cut = self
.buf
.char_indices()
.nth(REASONING_FLUSH_CHARS)
.map(|(i, _)| i)
.unwrap_or(self.buf.len());
let seg: String = self.buf.drain(..cut).collect();
self.emit(&seg);
}
}
fn emit(&self, line: &str) {
let line = line.trim();
if line.is_empty() {
return;
}
if let Some(tx) = self.status_tx {
let _ = tx.send(format!("{}{}", REASONING_PREFIX, line));
}
}
fn flush(&mut self) {
let rest = std::mem::take(&mut self.buf);
self.emit(&rest);
}
}
fn process_stream_line(
line: &str,
kind: OllamaResponseKind,
acc: &mut StreamAccumulation,
fwd: &mut ReasoningForwarder,
) {
let line = line.trim();
if line.is_empty() {
return;
}
let v: serde_json::Value = match serde_json::from_str(line) {
Ok(v) => v,
Err(_) => return,
};
if let Some(err) = v.get("error").and_then(|e| e.as_str()) {
acc.error = Some(err.to_string());
}
let (content, thinking, tool_calls) = match kind {
OllamaResponseKind::Chat => {
let msg = v.get("message");
let tool_calls = msg
.and_then(|m| m.get("tool_calls"))
.filter(|tc| tc.as_array().map(|a| !a.is_empty()).unwrap_or(false))
.cloned();
(
msg.and_then(|m| m.get("content")).and_then(|c| c.as_str()),
msg.and_then(|m| m.get("thinking")).and_then(|c| c.as_str()),
tool_calls,
)
}
OllamaResponseKind::Generate => (
v.get("response").and_then(|c| c.as_str()),
v.get("thinking").and_then(|c| c.as_str()),
None,
),
};
if let Some(c) = content {
acc.content.push_str(c);
}
if let Some(t) = thinking {
acc.thinking.push_str(t);
fwd.push(t);
}
if let Some(tc) = tool_calls {
acc.tool_calls = Some(tc);
}
if let Some(n) = v.get("prompt_eval_count").and_then(|x| x.as_u64()) {
acc.prompt_eval_count = n;
}
if let Some(n) = v.get("eval_count").and_then(|x| x.as_u64()) {
acc.eval_count = n;
}
}
pub fn accumulate_ollama_stream(
body: &str,
kind: OllamaResponseKind,
status_tx: Option<&mpsc::UnboundedSender<String>>,
) -> StreamAccumulation {
let mut acc = StreamAccumulation::default();
let mut fwd = ReasoningForwarder::new(status_tx);
for line in body.split('\n') {
process_stream_line(line, kind, &mut acc, &mut fwd);
}
fwd.flush();
acc
}
#[derive(Default, Clone)]
struct OpenAiToolCallAcc {
id: String,
name: String,
arguments: String,
}
#[derive(Default)]
pub struct OpenAiStreamAcc {
pub content: String,
pub reasoning: String,
tool_calls: std::collections::BTreeMap<u64, OpenAiToolCallAcc>,
pub prompt_tokens: u64,
pub completion_tokens: u64,
pub total_tokens: u64,
pub error: Option<String>,
}
impl OpenAiStreamAcc {
pub fn into_tool_calls(&self) -> Vec<ToolCall> {
self.tool_calls
.values()
.filter(|t| !t.name.is_empty())
.map(|t| ToolCall {
id: t.id.clone(),
function_name: t.name.clone(),
arguments: serde_json::from_str(&t.arguments)
.unwrap_or_else(|_| serde_json::json!({})),
})
.collect()
}
pub fn token_usage(&self) -> TokenUsage {
TokenUsage {
prompt_tokens: self.prompt_tokens,
completion_tokens: self.completion_tokens,
total_tokens: if self.total_tokens > 0 {
self.total_tokens
} else {
self.prompt_tokens + self.completion_tokens
},
}
}
}
fn apply_openai_usage(acc: &mut OpenAiStreamAcc, usage: &serde_json::Value) {
if let Some(n) = usage.get("prompt_tokens").and_then(|v| v.as_u64()) {
acc.prompt_tokens = n;
}
if let Some(n) = usage.get("completion_tokens").and_then(|v| v.as_u64()) {
acc.completion_tokens = n;
}
if let Some(n) = usage.get("total_tokens").and_then(|v| v.as_u64()) {
acc.total_tokens = n;
}
}
fn merge_openai_choice(
acc: &mut OpenAiStreamAcc,
node: &serde_json::Value,
fwd: &mut ReasoningForwarder,
) {
if let Some(c) = node.get("content").and_then(|v| v.as_str()) {
acc.content.push_str(c);
}
for key in ["reasoning", "reasoning_content"] {
if let Some(r) = node.get(key).and_then(|v| v.as_str()) {
acc.reasoning.push_str(r);
fwd.push(r);
}
}
if let Some(tcs) = node.get("tool_calls").and_then(|v| v.as_array()) {
for (i, tc) in tcs.iter().enumerate() {
let idx = tc.get("index").and_then(|v| v.as_u64()).unwrap_or(i as u64);
let e = acc.tool_calls.entry(idx).or_default();
if let Some(id) = tc.get("id").and_then(|v| v.as_str()) {
if !id.is_empty() {
e.id = id.to_string();
}
}
if let Some(f) = tc.get("function") {
if let Some(n) = f.get("name").and_then(|v| v.as_str()) {
e.name.push_str(n);
}
if let Some(a) = f.get("arguments").and_then(|v| v.as_str()) {
e.arguments.push_str(a);
}
}
}
}
}
fn process_openai_sse_line(
line: &str,
acc: &mut OpenAiStreamAcc,
fwd: &mut ReasoningForwarder,
) -> bool {
let line = line.trim();
let data = match line.strip_prefix("data:") {
Some(d) => d.trim(),
None => return false,
};
if data == "[DONE]" {
return true;
}
let v: serde_json::Value = match serde_json::from_str(data) {
Ok(v) => v,
Err(_) => return true,
};
if let Some(err) = v.pointer("/error/message").and_then(|e| e.as_str()) {
acc.error = Some(err.to_string());
}
if let Some(u) = v.get("usage").filter(|u| u.is_object()) {
apply_openai_usage(acc, u);
}
if let Some(delta) = v.pointer("/choices/0/delta") {
merge_openai_choice(acc, delta, fwd);
}
true
}
pub fn accumulate_openai_stream(
body: &str,
status_tx: Option<&mpsc::UnboundedSender<String>>,
) -> OpenAiStreamAcc {
let mut acc = OpenAiStreamAcc::default();
let mut fwd = ReasoningForwarder::new(status_tx);
let mut saw_sse = false;
for line in body.split('\n') {
if process_openai_sse_line(line, &mut acc, &mut fwd) {
saw_sse = true;
}
}
if !saw_sse {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(body.trim()) {
if let Some(err) = v.pointer("/error/message").and_then(|e| e.as_str()) {
acc.error = Some(err.to_string());
}
if let Some(u) = v.get("usage").filter(|u| u.is_object()) {
apply_openai_usage(&mut acc, u);
}
if let Some(msg) = v.pointer("/choices/0/message") {
merge_openai_choice(&mut acc, msg, &mut fwd);
}
}
}
fwd.flush();
acc
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Message {
pub role: String,
pub content: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_call_id: Option<String>,
}
#[derive(Debug, Clone, Copy, Default)]
pub struct TokenUsage {
pub prompt_tokens: u64,
pub completion_tokens: u64,
pub total_tokens: u64,
}
impl TokenUsage {
#[cfg(not(target_arch = "wasm32"))]
pub fn from_response(response: &ollama_rs::generation::completion::GenerationResponse) -> Self {
let prompt_tokens = response.prompt_eval_count.unwrap_or(0) as u64;
let completion_tokens = response.eval_count.unwrap_or(0) as u64;
Self {
prompt_tokens,
completion_tokens,
total_tokens: prompt_tokens + completion_tokens,
}
}
}
#[derive(Debug, Clone)]
pub struct GenerateResponse {
pub text: String,
pub token_usage: TokenUsage,
}
#[derive(Debug, Clone)]
pub struct ChatRequest {
pub messages: Vec<Message>,
pub tools: Vec<serde_json::Value>,
pub model: String,
pub offered_actions: Vec<crate::llm::actions::ActionDefinition>,
}
#[derive(Debug, Clone)]
pub struct ChatResponse {
pub content: Option<String>,
pub tool_calls: Vec<ToolCall>,
pub token_usage: TokenUsage,
}
#[derive(Debug, Clone)]
pub struct ToolCall {
pub id: String,
pub function_name: String,
pub arguments: serde_json::Value,
}
impl Message {
pub fn system(content: impl Into<String>) -> Self {
Self {
role: "system".to_string(),
content: content.into(),
tool_call_id: None,
}
}
pub fn user(content: impl Into<String>) -> Self {
Self {
role: "user".to_string(),
content: content.into(),
tool_call_id: None,
}
}
pub fn assistant(content: impl Into<String>) -> Self {
Self {
role: "assistant".to_string(),
content: content.into(),
tool_call_id: None,
}
}
pub fn tool(content: impl Into<String>) -> Self {
Self {
role: "tool".to_string(),
content: content.into(),
tool_call_id: None,
}
}
pub fn tool_result(tool_call_id: String, content: impl Into<String>) -> Self {
Self {
role: "tool".to_string(),
content: content.into(),
tool_call_id: Some(tool_call_id),
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct LlmResponse {
#[serde(default)]
pub output: Option<String>,
#[serde(default)]
pub close_connection: bool,
#[serde(default)]
pub wait_for_more: bool,
#[serde(default)]
pub shutdown_server: bool,
#[serde(default)]
pub log_message: Option<String>,
#[serde(default)]
pub set_memory: Option<String>,
#[serde(default)]
pub append_memory: Option<String>,
}
impl LlmResponse {
#[allow(clippy::should_implement_trait)]
pub fn from_str(s: &str) -> Result<Self> {
let trimmed = s.trim();
if let Ok(response) = serde_json::from_str::<LlmResponse>(trimmed) {
return Ok(response);
}
match trimmed {
"NO_RESPONSE" => Ok(Self::default()),
"CLOSE_CONNECTION" => Ok(Self {
close_connection: true,
..Default::default()
}),
"WAIT_FOR_MORE" => Ok(Self {
wait_for_more: true,
..Default::default()
}),
_ => {
Ok(Self {
output: Some(trimmed.to_string()),
..Default::default()
})
}
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct HttpLlmResponse {
pub status: u16,
#[serde(default)]
pub headers: HashMap<String, String>,
pub body: String,
#[serde(default)]
pub log_message: Option<String>,
#[serde(default)]
pub set_memory: Option<String>,
#[serde(default)]
pub append_memory: Option<String>,
}
impl Default for HttpLlmResponse {
fn default() -> Self {
Self {
status: 200,
headers: HashMap::new(),
body: String::new(),
log_message: None,
set_memory: None,
append_memory: None,
}
}
}
impl std::str::FromStr for LlmResponse {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
LlmResponse::from_str(s)
}
}
impl HttpLlmResponse {
#[allow(clippy::should_implement_trait)]
pub fn from_str(s: &str) -> Result<Self> {
let trimmed = s.trim();
serde_json::from_str::<HttpLlmResponse>(trimmed)
.context("Failed to parse HTTP LLM response as JSON")
}
pub fn to_event_response(self) -> crate::events::types::HttpResponse {
crate::events::types::HttpResponse {
status: self.status,
headers: self.headers,
body: Bytes::from(self.body),
}
}
}
impl std::str::FromStr for HttpLlmResponse {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
HttpLlmResponse::from_str(s)
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum CommandAction {
UpdateInstruction {
instruction: String,
},
OpenServer {
port: u16,
base_stack: String,
#[serde(default)]
send_first: bool,
#[serde(default)]
initial_memory: Option<String>,
instruction: String,
},
CloseServer {
#[serde(default)]
server_id: Option<u32>,
},
OpenClient {
address: String,
base_stack: String,
},
CloseConnection {
#[serde(default)]
connection_id: Option<String>,
},
ShowMessage {
message: String,
},
ChangeModel {
model: String,
},
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct CommandInterpretation {
#[serde(default)]
pub actions: Vec<CommandAction>,
#[serde(default)]
pub message: Option<String>,
}
impl CommandInterpretation {
#[allow(clippy::should_implement_trait)]
pub fn from_str(s: &str) -> Result<Self> {
let trimmed = s.trim();
serde_json::from_str::<CommandInterpretation>(trimmed)
.context("Failed to parse command interpretation as JSON")
}
}
impl std::str::FromStr for CommandInterpretation {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
CommandInterpretation::from_str(s)
}
}
pub const DEFAULT_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300);
pub fn host_of(url_or_host: &str) -> &str {
let after_scheme = url_or_host
.rsplit_once("//")
.map(|(_, h)| h)
.unwrap_or(url_or_host);
let authority = after_scheme.split('/').next().unwrap_or(after_scheme);
if let Some(rest) = authority.strip_prefix('[') {
return rest.split(']').next().unwrap_or(rest);
}
match authority.rsplit_once(':') {
Some((h, port)) if !port.is_empty() && port.chars().all(|c| c.is_ascii_digit()) => h,
_ => authority,
}
}
#[cfg(not(target_arch = "wasm32"))]
fn without_dns_for_literal_ip(
builder: reqwest::ClientBuilder,
host: &str,
) -> reqwest::ClientBuilder {
let bare = host_of(host);
match bare.parse::<std::net::IpAddr>() {
Ok(ip) => builder.resolve(bare, std::net::SocketAddr::new(ip, 0)),
Err(_) => builder,
}
}
#[cfg(not(target_arch = "wasm32"))]
pub fn is_loopback_endpoint(url_or_host: &str) -> bool {
let bare = host_of(url_or_host);
if bare.eq_ignore_ascii_case("localhost") {
return true;
}
bare.parse::<std::net::IpAddr>()
.map(|ip| ip.is_loopback())
.unwrap_or(false)
}
#[cfg(not(target_arch = "wasm32"))]
fn without_proxy_for_loopback(
builder: reqwest::ClientBuilder,
host: &str,
) -> reqwest::ClientBuilder {
if is_loopback_endpoint(host) {
builder.no_proxy()
} else {
builder
}
}
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn configured_for_endpoint(
builder: reqwest::ClientBuilder,
base_url: &str,
) -> reqwest::ClientBuilder {
without_proxy_for_loopback(without_dns_for_literal_ip(builder, base_url), base_url)
}
#[cfg(not(target_arch = "wasm32"))]
pub fn client_for_endpoint(base_url: &str) -> reqwest::Client {
configured_for_endpoint(reqwest::Client::builder(), base_url)
.build()
.unwrap_or_else(|_| reqwest::Client::new())
}
#[cfg(not(target_arch = "wasm32"))]
pub fn client_for_endpoint_with_timeout(
base_url: &str,
timeout: std::time::Duration,
) -> reqwest::Client {
configured_for_endpoint(reqwest::Client::builder().timeout(timeout), base_url)
.build()
.unwrap_or_else(|_| reqwest::Client::new())
}
#[cfg(not(target_arch = "wasm32"))]
fn openai_http_client(timeout: std::time::Duration, base_url: &str) -> reqwest::Client {
configured_for_endpoint(reqwest::Client::builder().timeout(timeout), base_url)
.build()
.expect("Failed to build HTTP client")
}
#[cfg(target_arch = "wasm32")]
pub fn client_for_endpoint(_base_url: &str) -> reqwest::Client {
reqwest::Client::new()
}
#[cfg(target_arch = "wasm32")]
pub fn client_for_endpoint_with_timeout(
_base_url: &str,
_timeout: std::time::Duration,
) -> reqwest::Client {
reqwest::Client::new()
}
pub const DEFAULT_MAX_TOKENS: u32 = 32768;
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct SamplingOptions {
pub seed: Option<u64>,
pub temperature: Option<f32>,
}
impl SamplingOptions {
pub fn is_empty(&self) -> bool {
self.seed.is_none() && self.temperature.is_none()
}
fn apply_to(&self, target: &mut serde_json::Value) {
if let Some(seed) = self.seed {
target["seed"] = serde_json::json!(seed);
}
if let Some(temperature) = self.temperature {
target["temperature"] = serde_json::json!(temperature);
}
}
}
#[derive(Clone)]
pub struct OllamaClient {
backend: LlmBackend,
status_tx: Option<mpsc::UnboundedSender<String>>,
mock_config_file: Option<std::path::PathBuf>,
app_state: Option<crate::state::AppState>,
breaker: std::sync::Arc<CircuitBreaker>,
request_timeout: std::time::Duration,
max_tokens: u32,
sampling: SamplingOptions,
}
impl OllamaClient {
#[cfg(not(target_arch = "wasm32"))]
pub fn new(base_url: impl Into<String>) -> Self {
let url_str = base_url.into();
let (host, port) = if let Some(port_start) = url_str.rfind(':') {
if port_start > 6 && !url_str[port_start..].contains('/') {
if let Ok(port_num) = url_str[port_start + 1..].parse::<u16>() {
(&url_str[..port_start], port_num)
} else {
(url_str.as_str(), 11434)
}
} else {
(url_str.as_str(), 11434)
}
} else {
(url_str.as_str(), 11434)
};
let http = configured_for_endpoint(reqwest::Client::builder(), host)
.build()
.expect("Failed to build Ollama HTTP client");
let ollama = Ollama::new_with_client(host, port, http.clone());
Self {
backend: LlmBackend::Ollama(ollama, http),
status_tx: None,
mock_config_file: None,
app_state: None,
breaker: std::sync::Arc::new(CircuitBreaker::default()),
request_timeout: DEFAULT_REQUEST_TIMEOUT,
max_tokens: DEFAULT_MAX_TOKENS,
sampling: SamplingOptions::default(),
}
}
#[allow(clippy::should_implement_trait)]
#[cfg(not(target_arch = "wasm32"))]
pub fn default() -> Self {
let http = client_for_endpoint("http://127.0.0.1:11434");
let ollama = Ollama::new_with_client("http://127.0.0.1", 11434, http.clone());
Self {
backend: LlmBackend::Ollama(ollama, http),
status_tx: None,
mock_config_file: None,
app_state: None,
breaker: std::sync::Arc::new(CircuitBreaker::default()),
request_timeout: DEFAULT_REQUEST_TIMEOUT,
max_tokens: DEFAULT_MAX_TOKENS,
sampling: SamplingOptions::default(),
}
}
#[cfg(not(target_arch = "wasm32"))]
pub fn new_openai(base_url: impl Into<String>, api_key: impl Into<String>) -> Self {
let base_url = base_url.into().trim_end_matches('/').to_string();
let client = openai_http_client(DEFAULT_REQUEST_TIMEOUT, &base_url);
Self {
backend: LlmBackend::OpenAI {
client,
base_url,
api_key: api_key.into(),
},
status_tx: None,
mock_config_file: None,
app_state: None,
breaker: std::sync::Arc::new(CircuitBreaker::default()),
request_timeout: DEFAULT_REQUEST_TIMEOUT,
max_tokens: DEFAULT_MAX_TOKENS,
sampling: SamplingOptions::default(),
}
}
pub fn new_queue(
queue: std::sync::Arc<crate::llm::agent_queue::LlmRequestQueue>,
timeout: std::time::Duration,
) -> Self {
Self {
backend: LlmBackend::Queue { queue, timeout },
status_tx: None,
mock_config_file: None,
app_state: None,
breaker: std::sync::Arc::new(CircuitBreaker::default()),
request_timeout: DEFAULT_REQUEST_TIMEOUT,
max_tokens: DEFAULT_MAX_TOKENS,
sampling: SamplingOptions::default(),
}
}
pub fn new_bridge(
bridge: std::sync::Arc<crate::llm::bridge::LlmBridge>,
timeout: std::time::Duration,
) -> Self {
Self {
backend: LlmBackend::Bridge { bridge, timeout },
status_tx: None,
mock_config_file: None,
app_state: None,
breaker: std::sync::Arc::new(CircuitBreaker::default()),
request_timeout: DEFAULT_REQUEST_TIMEOUT,
max_tokens: DEFAULT_MAX_TOKENS,
sampling: SamplingOptions::default(),
}
}
pub fn backend_type(&self) -> &str {
match &self.backend {
#[cfg(not(target_arch = "wasm32"))]
LlmBackend::Ollama(..) => "ollama",
#[cfg(not(target_arch = "wasm32"))]
LlmBackend::OpenAI { .. } => "openai",
LlmBackend::Queue { .. } => "agent",
LlmBackend::Bridge { .. } => "bridge",
}
}
pub fn backend_url(&self) -> String {
match &self.backend {
#[cfg(not(target_arch = "wasm32"))]
LlmBackend::Ollama(ollama, _) => {
format!("{}", ollama.uri())
}
#[cfg(not(target_arch = "wasm32"))]
LlmBackend::OpenAI { base_url, .. } => base_url.clone(),
LlmBackend::Queue { .. } => "(calling agent)".to_string(),
LlmBackend::Bridge { .. } => "(host bridge)".to_string(),
}
}
pub fn with_status_tx(mut self, status_tx: mpsc::UnboundedSender<String>) -> Self {
self.status_tx = Some(status_tx);
self
}
pub fn status_tx(&self) -> Option<&mpsc::UnboundedSender<String>> {
self.status_tx.as_ref()
}
fn log(&self) -> Log<'_> {
Log::new(self.status_tx.as_ref())
}
fn forward_host_reasoning(&self, reasoning: Option<&str>, private_payloads: bool) {
if private_payloads {
return;
}
let Some(reasoning) = reasoning.filter(|r| !r.trim().is_empty()) else {
return;
};
let mut fwd = ReasoningForwarder::new(self.status_tx.as_ref());
fwd.push(reasoning);
fwd.flush();
let log = self.log();
log.debug(format!(
"LLM reasoning: {} chars from the host",
reasoning.len()
));
log.payload("Full LLM reasoning", reasoning, usize::MAX);
}
#[cfg(not(target_arch = "wasm32"))]
async fn read_ollama_stream(
&self,
mut http_response: reqwest::Response,
kind: OllamaResponseKind,
private_payloads: bool,
) -> Result<StreamAccumulation> {
let mut acc = StreamAccumulation::default();
let mut fwd = ReasoningForwarder::new(if private_payloads {
None
} else {
self.status_tx.as_ref()
});
let mut buf: Vec<u8> = Vec::new();
let read = async {
while let Some(chunk) = http_response
.chunk()
.await
.context("reading Ollama streaming response body")?
{
buf.extend_from_slice(&chunk);
while let Some(pos) = buf.iter().position(|&b| b == b'\n') {
let line: Vec<u8> = buf.drain(..=pos).collect();
if let Ok(s) = std::str::from_utf8(&line) {
process_stream_line(s, kind, &mut acc, &mut fwd);
}
}
}
if !buf.is_empty() {
if let Ok(s) = std::str::from_utf8(&buf) {
process_stream_line(s, kind, &mut acc, &mut fwd);
}
}
fwd.flush();
Ok::<(), anyhow::Error>(())
};
tokio::time::timeout(self.request_timeout, read)
.await
.with_context(|| {
format!(
"Ollama streaming response body timed out after {:?}",
self.request_timeout
)
})??;
if !acc.thinking.is_empty() {
let log = self.log().with_payloads(!private_payloads);
log.debug(format!(
"LLM reasoning: {} chars streamed",
acc.thinking.len()
));
log.payload("Full LLM reasoning", &acc.thinking, usize::MAX);
}
Ok(acc)
}
#[cfg(not(target_arch = "wasm32"))]
async fn read_openai_stream(
&self,
mut http_response: reqwest::Response,
private_payloads: bool,
) -> Result<OpenAiStreamAcc> {
let mut acc = OpenAiStreamAcc::default();
let mut fwd = ReasoningForwarder::new(if private_payloads {
None
} else {
self.status_tx.as_ref()
});
let mut buf: Vec<u8> = Vec::new();
let mut raw = String::new();
let mut saw_sse = false;
let read = async {
while let Some(chunk) = http_response
.chunk()
.await
.context("reading OpenAI streaming response body")?
{
buf.extend_from_slice(&chunk);
while let Some(pos) = buf.iter().position(|&b| b == b'\n') {
let line: Vec<u8> = buf.drain(..=pos).collect();
if let Ok(s) = std::str::from_utf8(&line) {
raw.push_str(s);
if process_openai_sse_line(s, &mut acc, &mut fwd) {
saw_sse = true;
}
}
}
}
if !buf.is_empty() {
if let Ok(s) = std::str::from_utf8(&buf) {
raw.push_str(s);
if process_openai_sse_line(s, &mut acc, &mut fwd) {
saw_sse = true;
}
}
}
Ok::<(), anyhow::Error>(())
};
tokio::time::timeout(self.request_timeout, read)
.await
.with_context(|| {
format!(
"OpenAI streaming response body timed out after {:?}",
self.request_timeout
)
})??;
if !saw_sse {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(raw.trim()) {
if let Some(err) = v.pointer("/error/message").and_then(|e| e.as_str()) {
acc.error = Some(err.to_string());
}
if let Some(u) = v.get("usage").filter(|u| u.is_object()) {
apply_openai_usage(&mut acc, u);
}
if let Some(msg) = v.pointer("/choices/0/message") {
merge_openai_choice(&mut acc, msg, &mut fwd);
}
}
}
fwd.flush();
Ok(acc)
}
pub fn with_mock_config_file(mut self, path: Option<std::path::PathBuf>) -> Self {
self.mock_config_file = path;
self
}
pub fn with_app_state(mut self, state: crate::state::AppState) -> Self {
self.app_state = Some(state);
self
}
pub fn with_request_timeout(mut self, timeout: std::time::Duration) -> Self {
self.request_timeout = timeout;
#[cfg(not(target_arch = "wasm32"))]
{
if let LlmBackend::OpenAI {
client, base_url, ..
} = &mut self.backend
{
*client = openai_http_client(timeout, base_url);
}
}
self
}
pub fn with_max_tokens(mut self, max_tokens: u32) -> Self {
self.max_tokens = max_tokens;
self
}
pub fn with_sampling(mut self, sampling: SamplingOptions) -> Self {
self.sampling = sampling;
self
}
pub fn sampling(&self) -> SamplingOptions {
self.sampling
}
pub fn with_circuit_breaker(mut self, breaker: std::sync::Arc<CircuitBreaker>) -> Self {
self.breaker = breaker;
self
}
pub fn circuit_breaker(&self) -> &std::sync::Arc<CircuitBreaker> {
&self.breaker
}
pub fn circuit_breaker_status(&self) -> BreakerStatus {
self.breaker.status()
}
fn breaker_applies(&self) -> bool {
match self.backend {
#[cfg(not(target_arch = "wasm32"))]
LlmBackend::Ollama(..) | LlmBackend::OpenAI { .. } => true,
LlmBackend::Queue { .. } | LlmBackend::Bridge { .. } => false,
}
}
fn breaker_guard(&self) -> Result<Option<crate::llm::circuit_breaker::BreakerPermit<'_>>> {
if !self.breaker_applies() {
return Ok(None);
}
match self.breaker.acquire() {
Ok(permit) => Ok(Some(permit)),
Err(open) => {
debug!("Short-circuiting LLM request: {}", open);
self.log().warn(self.breaker.status().summary());
Err(anyhow::Error::new(open))
}
}
}
fn record_backend_outcome<T>(
&self,
permit: Option<crate::llm::circuit_breaker::BreakerPermit<'_>>,
result: Result<T>,
private_payloads: bool,
) -> Result<T> {
let hide_error = |e: anyhow::Error| {
if !private_payloads {
e
} else if is_transport_failure(&e) {
anyhow::anyhow!(
"LLM connection failure for credential-bearing request; details hidden"
)
} else {
anyhow::anyhow!("LLM backend rejected credential-bearing request; details hidden")
}
};
let Some(permit) = permit else {
return result.map_err(hide_error);
};
match &result {
Ok(_) => permit.record_success(),
Err(e) if is_transport_failure(e) => {
let summary = if private_payloads {
"LLM connection failure for credential-bearing request; details hidden"
.to_string()
} else {
format!("{e:#}")
};
if permit.record_failure(&summary) {
self.log().error(self.breaker.status().summary());
} else {
warn!(
"LLM transport failure {}/{} before the circuit breaker opens: {}",
self.breaker.status().consecutive_failures,
self.breaker.failure_threshold(),
crate::utils::truncate_for_log(&summary, 200)
);
}
}
Err(_) => permit.record_success(),
}
result.map_err(hide_error)
}
pub(crate) async fn generate(&self, model: &str, prompt: &str) -> Result<GenerateResponse> {
self.generate_with_format(model, prompt, None).await
}
pub(crate) async fn generate_with_format(
&self,
model: &str,
prompt: &str,
format: Option<serde_json::Value>,
) -> Result<GenerateResponse> {
self.generate_offering(model, prompt, format, &[], false)
.await
}
pub(crate) async fn generate_offering(
&self,
model: &str,
prompt: &str,
format: Option<serde_json::Value>,
offered: &[crate::llm::actions::ActionDefinition],
private_payloads: bool,
) -> Result<GenerateResponse> {
self.generate_offering_with_event(model, prompt, format, offered, None, private_payloads)
.await
}
pub(crate) async fn generate_offering_with_event(
&self,
model: &str,
prompt: &str,
format: Option<serde_json::Value>,
offered: &[crate::llm::actions::ActionDefinition],
event: Option<&crate::llm::bridge::BridgeEventContext>,
private_payloads: bool,
) -> Result<GenerateResponse> {
let private_payloads =
private_payloads || crate::utils::redact::actions_have_credentials(offered);
let permit = self.breaker_guard()?;
let result = self
.generate_with_format_inner(model, prompt, format, offered, event, private_payloads)
.await;
self.record_backend_outcome(permit, result, private_payloads)
}
async fn generate_with_format_inner(
&self,
model: &str,
prompt: &str,
format: Option<serde_json::Value>,
offered: &[crate::llm::actions::ActionDefinition],
event: Option<&crate::llm::bridge::BridgeEventContext>,
private_payloads: bool,
) -> Result<GenerateResponse> {
let log = self.log().with_payloads(!private_payloads);
log.debug(format!(
"LLM request: model={}, prompt_len={} chars, format={}",
model,
prompt.len(),
if format.is_some() { "JSON" } else { "text" }
));
log.payload("Full LLM prompt", prompt, usize::MAX);
if let Some(ref schema) = format {
log.trace(format!(
"JSON schema:\n{}",
serde_json::to_string_pretty(schema).unwrap_or_else(|_| "invalid".to_string())
));
}
let (response_text, token_usage) = match &self.backend {
#[cfg(not(target_arch = "wasm32"))]
LlmBackend::Ollama(ollama, http_client) => {
let mut body = serde_json::json!({
"model": model,
"prompt": prompt,
"stream": true,
"options": { "num_predict": self.max_tokens },
});
self.sampling.apply_to(&mut body["options"]);
if format.is_some() {
body["format"] = serde_json::json!("json");
}
let url = format!("{}/api/generate", ollama.url_str().trim_end_matches('/'));
let http_client = http_client.clone();
let http_response = tokio::time::timeout(
self.request_timeout,
http_client.post(&url).json(&body).send(),
)
.await
.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))?
.map_err(|e| {
let error_str = e.to_string().to_lowercase();
if error_str.contains("connection") || error_str.contains("refused") || error_str.contains("connect") {
anyhow::anyhow!(
"✗ 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
)
} else {
anyhow::anyhow!("✗ Ollama request failed: {}\n Use `/model` to check available models", e)
}
})?;
let status = http_response.status();
if !status.is_success() {
let body =
crate::client::http_fetch::read_response_text(http_response, 1024 * 1024)
.await?;
let msg = serde_json::from_str::<serde_json::Value>(&body)
.ok()
.and_then(|v| {
v.get("error")
.and_then(|e| e.as_str())
.map(|s| s.to_string())
})
.unwrap_or(body);
if status.as_u16() == 404 || msg.to_lowercase().contains("not found") {
anyhow::bail!(
"✗ 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
);
}
anyhow::bail!("✗ Ollama request failed ({}): {}\n Use `/model` to check available models", status, msg);
}
let acc = self
.read_ollama_stream(
http_response,
OllamaResponseKind::Generate,
private_payloads,
)
.await?;
if let Some(err) = acc.error {
anyhow::bail!("✗ Ollama request failed: {}", err);
}
let usage = TokenUsage {
prompt_tokens: acc.prompt_eval_count,
completion_tokens: acc.eval_count,
total_tokens: acc.prompt_eval_count + acc.eval_count,
};
(acc.content, usage)
}
#[cfg(not(target_arch = "wasm32"))]
LlmBackend::OpenAI {
client,
base_url,
api_key,
} => {
let mut body = serde_json::json!({
"model": model,
"messages": [{ "role": "user", "content": prompt }],
"max_tokens": self.max_tokens,
"stream": true,
"stream_options": { "include_usage": true },
});
self.sampling.apply_to(&mut body);
if format.is_some() {
body["response_format"] = serde_json::json!({ "type": "json_object" });
}
let url = format!("{}/v1/chat/completions", base_url);
let http_response = tokio::time::timeout(
self.request_timeout,
client
.post(&url)
.header("Authorization", format!("Bearer {}", api_key))
.header("Content-Type", "application/json")
.json(&body)
.send(),
)
.await
.with_context(|| {
format!("OpenAI API call timed out after {:?}", self.request_timeout)
})?
.context("OpenAI API request failed")?;
let status = http_response.status();
if !status.is_success() {
let response_body: serde_json::Value =
crate::client::http_fetch::read_response_json(http_response, 1024 * 1024)
.await?;
let error_msg = response_body
.pointer("/error/message")
.and_then(|m| m.as_str())
.unwrap_or("Unknown error");
match status.as_u16() {
401 => anyhow::bail!(
"✗ Authentication failed. Check your API key.\n Error: {}",
error_msg
),
404 => anyhow::bail!(
"✗ Model '{}' not found.\n Error: {}",
model,
error_msg
),
429 => anyhow::bail!(
"✗ Rate limited by API provider.\n Error: {}",
error_msg
),
_ => anyhow::bail!("✗ OpenAI API error ({}): {}", status, error_msg),
}
}
let acc = self
.read_openai_stream(http_response, private_payloads)
.await?;
if let Some(err) = &acc.error {
anyhow::bail!("✗ OpenAI API error: {}", err);
}
(acc.content.clone(), acc.token_usage())
}
LlmBackend::Bridge { bridge, timeout } => {
let (id, rx) = bridge.submit_with_event(
crate::llm::bridge::BridgeRequestKind::Generate,
model.to_string(),
vec![Message::user(prompt)],
Vec::new(),
offered,
event.cloned(),
);
let reply = await_bridge_reply(id, rx, *timeout).await?;
self.forward_host_reasoning(reply.reasoning.as_deref(), private_payloads);
let usage = TokenUsage {
prompt_tokens: reply.prompt_tokens,
completion_tokens: reply.completion_tokens,
total_tokens: reply.prompt_tokens + reply.completion_tokens,
};
let text = match reply.content {
Some(content) if !content.trim().is_empty() => content,
_ if !reply.tool_calls.is_empty() => {
let actions: Vec<serde_json::Value> = reply
.tool_calls
.into_iter()
.map(|call| {
let mut action = call.arguments;
if !action.is_object() {
action = serde_json::json!({});
}
action["type"] = serde_json::Value::String(call.name);
action
})
.collect();
serde_json::json!({ "actions": actions }).to_string()
}
_ => String::new(),
};
(text, usage)
}
LlmBackend::Queue { queue, timeout } => {
let (id, rx) =
queue.submit(model.to_string(), vec![Message::user(prompt)], Vec::new());
let actions = match tokio::time::timeout(*timeout, rx).await {
Ok(Ok(actions)) => actions,
Ok(Err(_)) => {
queue.expire(id);
anyhow::bail!(
"agent-queue: LLM request #{} was dropped before it was answered",
id
);
}
Err(_) => {
queue.expire(id);
anyhow::bail!(
"agent-queue: LLM request #{} timed out after {:?} without an agent answer",
id,
timeout
);
}
};
let text = serde_json::json!({ "actions": actions }).to_string();
(text, TokenUsage::default())
}
};
if let Some(ref state) = self.app_state {
state
.record_llm_tokens(token_usage.prompt_tokens, token_usage.completion_tokens)
.await;
}
log.debug(format!(
"LLM response: response_len={} chars, tokens={}i/{}o/{}t",
response_text.len(),
token_usage.prompt_tokens,
token_usage.completion_tokens,
token_usage.total_tokens
));
if response_text.is_empty() || response_text.trim().is_empty() {
let error_msg = format!(
"Model '{}' returned empty response (used {} completion tokens).",
model, token_usage.completion_tokens
);
log.error(&error_msg);
return Err(anyhow::anyhow!(error_msg));
}
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&response_text) {
let pretty =
serde_json::to_string_pretty(&json).unwrap_or_else(|_| response_text.clone());
log.payload("Full LLM response (JSON)", &pretty, usize::MAX);
} else {
log.payload("Full LLM response (text)", &response_text, usize::MAX);
}
Ok(GenerateResponse {
text: response_text,
token_usage,
})
}
pub(crate) async fn chat_with_tools(
&self,
request: &ChatRequest,
private_payloads: bool,
) -> Result<ChatResponse> {
let private_payloads = private_payloads
|| crate::utils::redact::actions_have_credentials(&request.offered_actions);
let permit = self.breaker_guard()?;
let result = self.chat_with_tools_inner(request, private_payloads).await;
self.record_backend_outcome(permit, result, private_payloads)
}
async fn chat_with_tools_inner(
&self,
request: &ChatRequest,
private_payloads: bool,
) -> Result<ChatResponse> {
let log = self.log().with_payloads(!private_payloads);
log.debug(format!(
"Chat request: model={}, messages={}, tools={}",
request.model,
request.messages.len(),
request.tools.len()
));
log.trace(format!(
"Chat messages: {:?}",
request
.messages
.iter()
.map(|m| {
format!(
"[{}] {}",
m.role,
crate::utils::truncate_for_log(&m.content, 100)
)
})
.collect::<Vec<_>>()
));
let chat_response = match &self.backend {
#[cfg(not(target_arch = "wasm32"))]
LlmBackend::Ollama(ollama, http_client) => {
self.chat_with_tools_ollama(ollama, http_client, request, private_payloads)
.await?
}
#[cfg(not(target_arch = "wasm32"))]
LlmBackend::OpenAI {
client,
base_url,
api_key,
} => {
self.chat_with_tools_openai(client, base_url, api_key, request, private_payloads)
.await?
}
LlmBackend::Queue { queue, timeout } => {
self.chat_with_tools_queue(queue.clone(), *timeout, request)
.await?
}
LlmBackend::Bridge { bridge, timeout } => {
let (id, rx) = bridge.submit(
crate::llm::bridge::BridgeRequestKind::Chat,
request.model.clone(),
request.messages.clone(),
request.tools.clone(),
&request.offered_actions,
);
let reply = await_bridge_reply(id, rx, *timeout).await?;
self.forward_host_reasoning(reply.reasoning.as_deref(), private_payloads);
let tool_calls = reply
.tool_calls
.into_iter()
.enumerate()
.map(|(i, call)| ToolCall {
id: call.id.unwrap_or_else(|| format!("bridge-{}-{}", id, i)),
function_name: call.name,
arguments: call.arguments,
})
.collect();
ChatResponse {
content: reply.content,
tool_calls,
token_usage: TokenUsage {
prompt_tokens: reply.prompt_tokens,
completion_tokens: reply.completion_tokens,
total_tokens: reply.prompt_tokens + reply.completion_tokens,
},
}
}
};
if let Some(ref state) = self.app_state {
state
.record_llm_tokens(
chat_response.token_usage.prompt_tokens,
chat_response.token_usage.completion_tokens,
)
.await;
}
log.debug(format!(
"Chat response: content_len={}, tool_calls={}, tokens={}i/{}o/{}t",
chat_response.content.as_ref().map(|c| c.len()).unwrap_or(0),
chat_response.tool_calls.len(),
chat_response.token_usage.prompt_tokens,
chat_response.token_usage.completion_tokens,
chat_response.token_usage.total_tokens
));
Ok(chat_response)
}
async fn chat_with_tools_queue(
&self,
queue: std::sync::Arc<crate::llm::agent_queue::LlmRequestQueue>,
timeout: std::time::Duration,
request: &ChatRequest,
) -> Result<ChatResponse> {
let (id, rx) = queue.submit(
request.model.clone(),
request.messages.clone(),
request.tools.clone(),
);
self.log().debug(format!(
"agent-queue: enqueued LLM request #{} ({} tools), awaiting agent answer (timeout {:?})",
id,
request.tools.len(),
timeout
));
match tokio::time::timeout(timeout, rx).await {
Ok(Ok(actions)) => {
let tool_calls = actions
.into_iter()
.enumerate()
.map(|(i, mut action)| {
let function_name = action
.get("type")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
if let Some(map) = action.as_object_mut() {
map.remove("type");
}
ToolCall {
id: format!("agent-{}", i),
function_name,
arguments: action,
}
})
.collect();
Ok(ChatResponse {
content: None,
tool_calls,
token_usage: TokenUsage::default(),
})
}
Ok(Err(_)) => {
queue.expire(id);
anyhow::bail!(
"agent-queue: LLM request #{} was dropped before it was answered",
id
)
}
Err(_) => {
queue.expire(id);
anyhow::bail!(
"agent-queue: LLM request #{} timed out after {:?} without an agent answer",
id,
timeout
)
}
}
}
#[cfg(not(target_arch = "wasm32"))]
async fn chat_with_tools_ollama(
&self,
ollama: &Ollama,
http_client: &reqwest::Client,
request: &ChatRequest,
private_payloads: bool,
) -> Result<ChatResponse> {
let messages: Vec<serde_json::Value> = request
.messages
.iter()
.map(|m| {
let mut msg = serde_json::json!({
"role": m.role,
"content": m.content,
});
if let Some(ref tool_call_id) = m.tool_call_id {
msg["tool_call_id"] = serde_json::json!(tool_call_id);
}
msg
})
.collect();
let mut body = serde_json::json!({
"model": request.model,
"messages": messages,
"stream": true,
});
if !self.sampling.is_empty() {
body["options"] = serde_json::json!({});
self.sampling.apply_to(&mut body["options"]);
}
if !request.tools.is_empty() {
body["tools"] = serde_json::Value::Array(request.tools.clone());
}
let url = format!("{}/api/chat", ollama.url_str().trim_end_matches('/'));
let http_client = http_client.clone();
let http_response = tokio::time::timeout(
self.request_timeout,
http_client.post(&url).json(&body).send(),
)
.await
.with_context(|| {
format!(
"Ollama chat API call timed out after {:?}",
self.request_timeout
)
})?
.context("Ollama chat API request failed")?;
let status = http_response.status();
if !status.is_success() {
let body =
crate::client::http_fetch::read_response_text(http_response, 1024 * 1024).await?;
let error_msg = serde_json::from_str::<serde_json::Value>(&body)
.ok()
.and_then(|v| {
v.get("error")
.and_then(|e| e.as_str())
.map(|s| s.to_string())
})
.unwrap_or_else(|| "Unknown error".to_string());
anyhow::bail!("Ollama chat API error ({}): {}", status, error_msg);
}
let acc = self
.read_ollama_stream(http_response, OllamaResponseKind::Chat, private_payloads)
.await?;
if let Some(err) = acc.error {
anyhow::bail!("Ollama chat API error: {}", err);
}
let content = Some(acc.content).filter(|s| !s.is_empty());
let tool_calls = acc
.tool_calls
.as_ref()
.and_then(|v| v.as_array())
.map(|calls| {
calls
.iter()
.enumerate()
.filter_map(|(i, tc)| {
let function = tc.get("function")?;
let name = function["name"].as_str()?.to_string();
let arguments = function.get("arguments").cloned().unwrap_or_default();
Some(ToolCall {
id: format!("call_{}", i),
function_name: name,
arguments,
})
})
.collect()
})
.unwrap_or_default();
let token_usage = TokenUsage {
prompt_tokens: acc.prompt_eval_count,
completion_tokens: acc.eval_count,
total_tokens: acc.prompt_eval_count + acc.eval_count,
};
Ok(ChatResponse {
content,
tool_calls,
token_usage,
})
}
#[cfg(not(target_arch = "wasm32"))]
async fn chat_with_tools_openai(
&self,
client: &reqwest::Client,
base_url: &str,
api_key: &str,
request: &ChatRequest,
private_payloads: bool,
) -> Result<ChatResponse> {
let messages: Vec<serde_json::Value> = request
.messages
.iter()
.map(|m| {
if let Some(ref tool_call_id) = m.tool_call_id {
serde_json::json!({
"role": "tool",
"content": m.content,
"tool_call_id": tool_call_id,
})
} else {
serde_json::json!({
"role": m.role,
"content": m.content,
})
}
})
.collect();
let mut body = serde_json::json!({
"model": request.model,
"messages": messages,
"max_tokens": 4096,
"stream": true,
"stream_options": { "include_usage": true },
});
self.sampling.apply_to(&mut body);
if !request.tools.is_empty() {
body["tools"] = serde_json::Value::Array(request.tools.clone());
body["tool_choice"] = serde_json::json!("auto");
}
let url = format!("{}/v1/chat/completions", base_url);
let http_response = tokio::time::timeout(
self.request_timeout,
client
.post(&url)
.header("Authorization", format!("Bearer {}", api_key))
.header("Content-Type", "application/json")
.json(&body)
.send(),
)
.await
.context("OpenAI chat API call timed out after 120 seconds")?
.context("OpenAI chat API request failed")?;
let status = http_response.status();
if !status.is_success() {
let response_body: serde_json::Value =
crate::client::http_fetch::read_response_json(http_response, 1024 * 1024).await?;
let error_msg = response_body
.pointer("/error/message")
.and_then(|m| m.as_str())
.unwrap_or("Unknown error");
anyhow::bail!("OpenAI chat API error ({}): {}", status, error_msg);
}
let acc = self
.read_openai_stream(http_response, private_payloads)
.await?;
if let Some(err) = &acc.error {
anyhow::bail!("OpenAI chat API error: {}", err);
}
Ok(ChatResponse {
content: Some(acc.content.clone()).filter(|s| !s.is_empty()),
tool_calls: acc.into_tool_calls(),
token_usage: acc.token_usage(),
})
}
pub async fn generate_with_retry(
&self,
model: &str,
prompt: &str,
expected_format: &str,
max_retries: usize,
) -> Result<String> {
let mut current_prompt = prompt.to_string();
for attempt in 1..=max_retries + 1 {
debug!("Generate attempt {}/{}", attempt, max_retries + 1);
trace!(
"Retry loop: attempt={}, prompt_len={}",
attempt,
current_prompt.len()
);
let generate_response = self.generate(model, ¤t_prompt).await?;
let (json_only, _refs) =
crate::llm::reference_parser::extract_references(&generate_response.text)
.unwrap_or_else(|_| {
(
generate_response.text.clone(),
std::collections::HashMap::new(),
)
});
let json_cleaned = strip_markdown_fences(&json_only);
let json_cleaned = match ActionResponse::from_str(&json_cleaned) {
Ok(_) => json_cleaned,
Err(_) => match extract_embedded_json_object(&json_cleaned) {
Some(embedded) if ActionResponse::from_str(embedded).is_ok() => {
debug!(
"Recovered the action JSON from a response that wrapped it in prose \
({} chars of surrounding text)",
json_cleaned.len().saturating_sub(embedded.len())
);
embedded.to_string()
}
_ => json_cleaned,
},
};
match ActionResponse::from_str(&json_cleaned) {
Ok(parsed) if parsed.actions.is_empty() && !parsed.tools.is_empty() => {
warn!(
"Model asked to call {} tool(s) on a path that has none; \
retrying with a direct-answer correction",
parsed.tools.len()
);
if attempt > max_retries {
return Err(anyhow::anyhow!(
"LLM asked to call tools, which are not available on this \
path, and did not answer directly when asked again. \
Response began: {}",
crate::utils::truncate_for_log(&json_cleaned, 400)
));
}
current_prompt = format!(
"{}\n\n---\n\nYour previous response asked to call a tool. Tools are \
not available for this request: answer directly instead, using only \
the actions listed above, and supply any value you would have asked \
a tool for yourself.\n\nRequired format: {}",
current_prompt, expected_format
);
continue;
}
Ok(_) => {
if attempt > 1 {
info!("Retry successful on attempt {}", attempt);
}
trace!("Parse succeeded on attempt {}", attempt);
return Ok(generate_response.text);
}
Err(e) => {
if attempt <= max_retries {
warn!("Parse error on attempt {}: {}", attempt, e);
warn!(
"Malformed response (after XML extraction and markdown stripping): {}",
crate::utils::truncate_for_log(&json_cleaned, 500)
);
trace!(
"Will retry with corrective feedback (attempt {}/{})",
attempt,
max_retries + 1
);
current_prompt = format!(
"{}\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.",
current_prompt,
e,
expected_format
);
info!(
"Retrying with corrective feedback (attempt {}/{})",
attempt + 1,
max_retries + 1
);
} else {
error!("Failed to get valid response after {} attempts", attempt);
trace!("Max retries exhausted, returning error");
return Err(e).context(format!(
"LLM failed to provide valid format after retry. Response began: {}",
crate::utils::truncate_for_log(&json_cleaned, 400)
));
}
}
}
}
unreachable!("Loop should always return or error")
}
pub async fn is_available(&self) -> bool {
self.list_models().await.is_ok()
}
pub async fn list_models(&self) -> Result<Vec<String>> {
match &self.backend {
LlmBackend::Bridge { bridge, .. } => Ok(bridge.models()),
#[cfg(not(target_arch = "wasm32"))]
LlmBackend::Ollama(ollama, _) => {
let models = tokio::time::timeout(self.request_timeout, ollama.list_local_models())
.await
.with_context(|| {
format!(
"Listing Ollama models timed out after {:?}",
self.request_timeout
)
})?
.map_err(|e| anyhow::anyhow!("Failed to list models: {}", e))?;
Ok(models.into_iter().map(|m| m.name).collect())
}
#[cfg(not(target_arch = "wasm32"))]
LlmBackend::OpenAI {
client,
base_url,
api_key,
} => {
let url = format!("{}/v1/models", base_url);
let response = client
.get(&url)
.header("Authorization", format!("Bearer {}", api_key))
.timeout(std::time::Duration::from_secs(5))
.send()
.await
.context("Failed to connect to OpenAI-compatible API")?;
if !response.status().is_success() {
anyhow::bail!("OpenAI API returned error status: {}", response.status());
}
let body: serde_json::Value =
crate::client::http_fetch::read_response_json(response, 8 * 1024 * 1024)
.await
.context("Failed to parse model list response")?;
let models = body["data"]
.as_array()
.unwrap_or(&vec![])
.iter()
.filter_map(|m| m["id"].as_str().map(|s| s.to_string()))
.collect();
Ok(models)
}
LlmBackend::Queue { .. } => Ok(vec!["agent".to_string()]),
}
}
}
#[cfg(not(target_arch = "wasm32"))]
impl Default for OllamaClient {
fn default() -> Self {
OllamaClient::default()
}
}
async fn await_bridge_reply(
id: u64,
rx: tokio::sync::oneshot::Receiver<Result<crate::llm::bridge::BridgeReply, String>>,
timeout: std::time::Duration,
) -> Result<crate::llm::bridge::BridgeReply> {
match tokio::time::timeout(timeout, rx).await {
Ok(Ok(Ok(reply))) => Ok(reply),
Ok(Ok(Err(message))) => anyhow::bail!("✗ The model host reported an error: {}", message),
Ok(Err(_)) => anyhow::bail!(
"bridge: LLM request #{} was dropped before it was answered",
id
),
Err(_) => anyhow::bail!(
"bridge: LLM request #{} timed out after {:?} without an answer",
id,
timeout
),
}
}