Files
solidcraft-upstream/crates/ui-egui/src/agent_chat/api_client.rs
T

778 lines
36 KiB
Rust

//! The API backend (desktop only): a model behind an OpenAI-compatible chat completions endpoint
//! (Ollama, LM Studio, OpenAI, any URL the user gives). SolidCraft runs the tool loop itself, with
//! the same tools the ACP agents get: it starts the same MCP server (`solidcraft-cli mcp
//! --connect` to the chat's own endpoint) and is its client, so every tool call passes the same
//! gate, limits and folder checks. The key, when there is one, comes from the system keychain for
//! this connection only.
//!
//! The loop runs on a thread of its own: a model request can take a while.
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{Receiver, Sender, channel};
use std::time::Duration;
use serde_json::{Value, json};
use super::acp::ModelInfo;
use super::backend::{ChatBackend, Event, HttpFn, HttpRequest, PromptPart, StartContext, ToolStatus};
use super::process::{AgentProcess, FromAgent, ProcessHandle};
/// Model requests for one message, at most (each answer with tool calls is one).
pub const MAX_REQUESTS_PER_TURN: usize = 24;
/// Longest tool result handed back to the model.
const MAX_TOOL_TEXT: usize = 20_000;
/// Messages kept in the conversation (the system note stays).
const MAX_HISTORY: usize = 240;
/// How long one model request may take.
const REQUEST_TIMEOUT_S: u64 = 300;
/// How long one tool call may take (it may wait for the user's Allow).
const TOOL_TIMEOUT: Duration = Duration::from_secs(330);
/// How often a waiting request or tool call looks at the cancel flag.
const CANCEL_POLL: Duration = Duration::from_millis(40);
/// Tools the model is not offered.
const HIDDEN_TOOLS: &[&str] = &["crash_reports"];
pub struct ApiConfig {
pub name: String,
pub base_url: String,
pub model: String,
pub key: Option<String>,
pub http: HttpFn,
}
enum Cmd {
Prompt(Vec<PromptPart>),
NewChat,
SetModel(String),
}
pub struct ApiBackend {
cmds: Option<Sender<Cmd>>,
events: Receiver<Event>,
cancel: Arc<AtomicBool>,
mcp: Option<ProcessHandle>,
}
impl ApiBackend {
pub fn start(cfg: ApiConfig, ctx: StartContext) -> Result<ApiBackend, String> {
if cfg.key.is_some() && !super::agents::key_safe_for(&cfg.base_url) {
return Err(crate::i18n::tr("ui.ai_key_needs_https").to_string());
}
// The same MCP server an ACP agent would get.
let mcp = match &ctx.bridge {
Some(b) => {
let argv = vec![b.cli.to_string_lossy().into_owned(), "mcp".into(), "--connect".into(), format!("127.0.0.1:{}", b.port)];
Some(AgentProcess::spawn(&argv, &ctx.cwd, &["SOLIDCRAFT_CONTROL_TOKEN"], || {})?)
}
None => None,
};
let handle = mcp.as_ref().map(AgentProcess::handle);
let (cmd_tx, cmd_rx) = channel::<Cmd>();
let (ev_tx, events) = channel::<Event>();
let cancel = Arc::new(AtomicBool::new(false));
let stop = cancel.clone();
let wake: Arc<dyn Fn() + Send + Sync> = Arc::from(ctx.wake);
std::thread::spawn(move || {
let mut w = Worker { cfg, mcp, next: 0, tools: Vec::new(), history: Vec::new(), events: ev_tx, wake, cancel: stop };
w.run(cmd_rx);
});
Ok(ApiBackend { cmds: Some(cmd_tx), events, cancel, mcp: handle })
}
}
impl ChatBackend for ApiBackend {
fn poll(&mut self) -> Vec<Event> {
self.events.try_iter().take(512).collect()
}
fn prompt(&mut self, parts: &[PromptPart]) {
self.cancel.store(false, Ordering::Relaxed);
if let Some(c) = &self.cmds {
let _ = c.send(Cmd::Prompt(parts.to_vec()));
}
}
fn cancel(&mut self) {
self.cancel.store(true, Ordering::Relaxed);
}
fn set_model(&mut self, model: &str) {
if let Some(c) = &self.cmds {
let _ = c.send(Cmd::SetModel(model.to_string()));
}
}
fn new_session(&mut self) {
// A running turn is cancelled; the worker resets the history once it has ended.
self.cancel.store(true, Ordering::Relaxed);
if let Some(c) = &self.cmds {
let _ = c.send(Cmd::NewChat);
}
}
fn answer_permission(&mut self, _key: u64, _option: Option<&str>) {}
fn authenticate(&mut self, _method: &str) {}
fn pid(&self) -> Option<u32> {
self.mcp.as_ref().and_then(ProcessHandle::pid)
}
fn shutdown(&mut self, wait: bool) {
self.cancel.store(true, Ordering::Relaxed);
self.cmds = None;
if let Some(h) = self.mcp.take() {
h.shutdown(wait);
}
}
}
impl Drop for ApiBackend {
fn drop(&mut self) {
self.shutdown(false);
}
}
struct Worker {
cfg: ApiConfig,
mcp: Option<AgentProcess>,
next: u64,
/// The MCP tools as OpenAI function tools.
tools: Vec<Value>,
history: Vec<Value>,
events: Sender<Event>,
wake: Arc<dyn Fn() + Send + Sync>,
cancel: Arc<AtomicBool>,
}
impl Worker {
fn emit(&self, e: Event) {
let _ = self.events.send(e);
(self.wake)();
}
fn run(&mut self, cmds: Receiver<Cmd>) {
if let Err(e) = self.start_mcp() {
self.emit(Event::Error(e));
}
self.history.push(json!({"role": "system", "content": super::acp::SYSTEM_NOTE}));
self.emit(Event::Ready { agent: format!("{} · {}", self.cfg.name, self.cfg.model), images: true });
// The endpoint's models for the picker; an endpoint that does not list them shows none.
if let Ok(list) = model_list(&self.cfg.http, &self.cfg.base_url, self.cfg.key.as_deref()) {
let mut available = list;
if !self.cfg.model.is_empty() && !available.iter().any(|m| m.id == self.cfg.model) {
available.insert(0, ModelInfo { id: self.cfg.model.clone(), name: self.cfg.model.clone(), description: String::new() });
}
if !available.is_empty() {
self.emit(Event::Models { current: self.cfg.model.clone(), available });
}
}
for cmd in cmds {
match cmd {
Cmd::Prompt(parts) => self.turn(parts),
Cmd::SetModel(m) => self.cfg.model = m,
Cmd::NewChat => {
self.history.truncate(1);
self.cancel.store(false, Ordering::Relaxed);
self.emit(Event::Ready { agent: format!("{} · {}", self.cfg.name, self.cfg.model), images: true });
}
}
}
if let Some(mut m) = self.mcp.take() {
m.shutdown(false);
}
}
/// One MCP request to the server; its `result`.
fn mcp_call(&mut self, method: &str, params: Value) -> Result<Value, String> {
let Some(m) = self.mcp.as_ref() else { return Err("no OneCAD tools in this session".into()) };
self.next += 1;
let id = self.next;
m.send(&json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}));
let deadline = std::time::Instant::now() + TOOL_TIMEOUT;
loop {
// Wait in short slices so Stop and Send now do not wait for a slow tool: its late
// answer is skipped later (its id no longer matches).
if self.cancel.load(Ordering::Relaxed) {
return Err("cancelled by the user".into());
}
let left = deadline.saturating_duration_since(std::time::Instant::now()).min(CANCEL_POLL);
match m.from_agent.recv_timeout(left) {
Ok(FromAgent::Line(l)) => {
let Ok(v) = serde_json::from_str::<Value>(l.trim()) else { continue };
if v.get("id").and_then(Value::as_u64) != Some(id) {
continue;
}
if let Some(e) = v.get("error") {
return Err(e.get("message").and_then(Value::as_str).unwrap_or("error").to_string());
}
return Ok(v.get("result").cloned().unwrap_or(Value::Null));
}
Ok(FromAgent::Exited(_)) => return Err("the OneCAD tools stopped".into()),
Err(std::sync::mpsc::RecvTimeoutError::Timeout) if std::time::Instant::now() < deadline => {}
Err(_) => return Err("the OneCAD tools did not answer".into()),
}
}
}
fn start_mcp(&mut self) -> Result<(), String> {
if self.mcp.is_none() {
return Ok(());
}
self.mcp_call(
"initialize",
json!({"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "solidcraft-agent-chat", "version": env!("CARGO_PKG_VERSION")}}),
)?;
if let Some(m) = &self.mcp {
m.send(&json!({"jsonrpc": "2.0", "method": "notifications/initialized"}));
}
let list = self.mcp_call("tools/list", json!({}))?;
self.tools = openai_tools(&list);
Ok(())
}
fn turn(&mut self, parts: Vec<PromptPart>) {
self.history.push(user_message(&parts));
// Keep the system note; drop the oldest messages, never leaving a reply first.
while self.history.len() > MAX_HISTORY {
self.history.remove(1);
}
while self.history.len() > 2 && self.history.get(1).is_some_and(|m| m["role"] != "user") {
self.history.remove(1);
}
for _ in 0..MAX_REQUESTS_PER_TURN {
// The history as of here is complete (every tool call answered): a cancelled request
// goes back to it, so no half-finished round is left to confuse the next message.
let sound = self.history.len();
if self.cancel.load(Ordering::Relaxed) {
self.emit(Event::TurnEnded { reason: "cancelled".into() });
return;
}
let reply = match self.request() {
Ok(_) if self.cancel.load(Ordering::Relaxed) => {
self.history.truncate(sound);
self.emit(Event::TurnEnded { reason: "cancelled".into() });
return;
}
Ok(r) => r,
Err(_) if self.cancel.load(Ordering::Relaxed) => {
self.history.truncate(sound);
self.emit(Event::TurnEnded { reason: "cancelled".into() });
return;
}
Err(e) => {
self.emit(Event::Error(e));
self.emit(Event::TurnEnded { reason: "error".into() });
return;
}
};
let msg = reply.pointer("/choices/0/message").cloned().unwrap_or(Value::Null);
if let Some(text) = msg.get("content").and_then(Value::as_str).filter(|t| !t.is_empty()) {
self.emit(Event::Text { thought: false, text: text.to_string() });
}
let calls: Vec<Value> = msg.get("tool_calls").and_then(Value::as_array).cloned().unwrap_or_default();
let mut kept = json!({"role": "assistant", "content": msg.get("content").cloned().unwrap_or(Value::Null)});
if !calls.is_empty() {
kept["tool_calls"] = Value::Array(calls.clone());
}
self.history.push(kept);
if calls.is_empty() {
self.emit(Event::TurnEnded { reason: String::new() });
return;
}
for (i, c) in calls.iter().enumerate().take(32) {
let id = c.get("id").and_then(Value::as_str).map_or_else(|| format!("call-{i}"), str::to_string);
let name = c.pointer("/function/name").and_then(Value::as_str).unwrap_or("").to_string();
let parsed = tool_arguments(c.pointer("/function/arguments"));
let args = parsed.clone().unwrap_or_else(|_| json!({}));
let title = tool_title(&name, &args);
let tool_id = format!("{id}-{}", self.next);
self.emit(Event::Tool {
id: tool_id.clone(),
title: Some(title),
kind: Some(String::new()),
status: Some(ToolStatus::InProgress),
output: None,
});
let (text, failed) = if self.cancel.load(Ordering::Relaxed) {
("cancelled by the user".to_string(), true)
} else if let Err(e) = &parsed {
// A call whose arguments do not parse is answered, never run with `{}`.
(format!("Not run: {e}"), true)
} else if HIDDEN_TOOLS.contains(&name.as_str())
|| !self.tools.iter().any(|t| t.pointer("/function/name").and_then(Value::as_str) == Some(name.as_str()))
{
(format!("unknown tool `{name}`"), true)
} else {
match self.mcp_call("tools/call", json!({"name": name, "arguments": args})) {
Ok(r) => tool_text(&r),
Err(e) => (e, true),
}
};
let shown: String = text.chars().take(2000).collect();
self.emit(Event::Tool {
id: tool_id,
title: None,
kind: None,
status: Some(if failed { ToolStatus::Failed } else { ToolStatus::Completed }),
output: Some(shown),
});
self.history.push(json!({"role": "tool", "tool_call_id": id, "content": text}));
}
}
self.emit(Event::Text { thought: false, text: format!("\n({})", crate::i18n::tr("ui.ai_request_limit")) });
self.emit(Event::TurnEnded { reason: "max_turn_requests".into() });
}
fn request(&self) -> Result<Value, String> {
let mut body = json!({"model": self.cfg.model, "messages": self.history});
if !self.tools.is_empty() {
body["tools"] = Value::Array(self.tools.clone());
}
let mut headers = vec![("Content-Type".to_string(), "application/json".to_string())];
if let Some(k) = &self.cfg.key {
headers.push(("Authorization".into(), format!("Bearer {k}")));
}
let req = HttpRequest {
method: "POST",
url: format!("{}/chat/completions", self.cfg.base_url),
headers,
body: Some(body.to_string().into_bytes()),
timeout_s: REQUEST_TIMEOUT_S,
};
// The request runs on a thread of its own so that Stop does not wait for the model: a
// cancelled one is abandoned (the host's own timeout ends it) and its answer dropped.
let (tx, rx) = channel();
let http = self.cfg.http.clone();
std::thread::spawn(move || {
let _ = tx.send(http(&req));
});
let (status, bytes) = loop {
if self.cancel.load(Ordering::Relaxed) {
return Err("cancelled by the user".into());
}
match rx.recv_timeout(CANCEL_POLL) {
Ok(r) => break r?,
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
Err(_) => return Err("the request ended without an answer".into()),
}
};
let v: Value = serde_json::from_slice(&bytes).unwrap_or(Value::Null);
if status != 200 {
return Err(http_error(status, &v));
}
Ok(v)
}
}
/// An endpoint's refusal as people read it.
pub fn http_error(status: u16, body: &Value) -> String {
let detail =
body.pointer("/error/message").or_else(|| body.get("error")).and_then(Value::as_str).unwrap_or("").chars().take(300).collect::<String>();
let what = match status {
401 | 403 => crate::i18n::tr("ui.ai_http_key"),
404 => crate::i18n::tr("ui.ai_http_not_found"),
429 => crate::i18n::tr("ui.ai_http_busy"),
_ => "",
};
format!(
"HTTP {status}{}{}",
if what.is_empty() { String::new() } else { format!(": {what}") },
if detail.is_empty() { String::new() } else { format!(" ({detail})") }
)
}
/// A tool call's arguments as an object: the JSON text a model sends (or an object, for servers
/// that send one), or why they cannot be used. Missing or empty arguments are an empty object.
fn tool_arguments(raw: Option<&Value>) -> Result<Value, String> {
let text = match raw {
None | Some(Value::Null) => return Ok(json!({})),
Some(v @ Value::Object(_)) => return Ok(v.clone()),
Some(Value::String(s)) => s.trim(),
Some(_) => return Err("the tool arguments must be a JSON object".into()),
};
if text.is_empty() {
return Ok(json!({}));
}
match serde_json::from_str::<Value>(text) {
Ok(v @ Value::Object(_)) => Ok(v),
Ok(_) => Err("the tool arguments must be a JSON object".into()),
Err(e) => Err(format!("the tool arguments were not valid JSON ({e}); send the call again with a JSON object")),
}
}
/// The message for what the user typed (and the view, as a data URL).
fn user_message(parts: &[PromptPart]) -> Value {
let images = parts.iter().any(|p| matches!(p, PromptPart::Image { .. }));
if !images {
let text: Vec<&str> = parts.iter().filter_map(|p| if let PromptPart::Text(t) = p { Some(t.as_str()) } else { None }).collect();
return json!({"role": "user", "content": text.join("\n")});
}
let content: Vec<Value> = parts
.iter()
.map(|p| match p {
PromptPart::Text(t) => json!({"type": "text", "text": t}),
PromptPart::Image { mime, base64 } => json!({"type": "image_url", "image_url": {"url": format!("data:{mime};base64,{base64}")}}),
})
.collect();
json!({"role": "user", "content": content})
}
/// MCP `tools/list` as OpenAI function tools (the hidden ones left out).
pub fn openai_tools(list: &Value) -> Vec<Value> {
list.get("tools")
.and_then(Value::as_array)
.map(|a| {
a.iter()
.take(128)
.filter_map(|t| {
let name = t.get("name")?.as_str()?;
if HIDDEN_TOOLS.contains(&name) {
return None;
}
let params = t.get("inputSchema").cloned().filter(Value::is_object).unwrap_or(json!({"type": "object", "properties": {}}));
Some(json!({"type": "function", "function": {
"name": name,
"description": t.get("description").and_then(Value::as_str).unwrap_or(""),
"parameters": params,
}}))
})
.collect()
})
.unwrap_or_default()
}
/// A tool call's result as text for the model, and whether it failed.
fn tool_text(r: &Value) -> (String, bool) {
let failed = r.get("isError").and_then(Value::as_bool).unwrap_or(false);
let parts: Vec<String> = r
.get("content")
.and_then(Value::as_array)
.map(|a| {
a.iter()
.take(16)
.map(|c| match c.get("type").and_then(Value::as_str) {
Some("text") => c.get("text").and_then(Value::as_str).unwrap_or("").to_string(),
Some("image") => "[a picture of the view]".to_string(),
_ => String::new(),
})
.collect()
})
.unwrap_or_default();
let mut text = parts.join("\n");
if text.len() > MAX_TOOL_TEXT {
let mut cut = MAX_TOOL_TEXT;
while !text.is_char_boundary(cut) {
cut -= 1;
}
text.truncate(cut);
text.push_str("\n…(cut)");
}
(text, failed)
}
/// What a tool call card says: the tool and, for `execute` and `batch`, the commands.
fn tool_title(name: &str, args: &Value) -> String {
match name {
"execute" => format!("execute: {}", args.get("command").and_then(Value::as_str).unwrap_or("?")),
"batch" => {
let ids: Vec<&str> = args
.get("commands")
.and_then(Value::as_array)
.map(|c| c.iter().filter_map(|s| s.get("command").and_then(Value::as_str)).take(4).collect())
.unwrap_or_default();
format!("batch: {}", ids.join(", "))
}
_ => name.to_string(),
}
}
/// `GET {base_url}/models`: the model ids, in the order the endpoint lists them.
fn model_ids(http: &HttpFn, base_url: &str, headers: Vec<(String, String)>) -> Result<Vec<String>, String> {
let req = HttpRequest { method: "GET", url: format!("{base_url}/models"), headers, body: None, timeout_s: 15 };
let (status, bytes) = http(&req)?;
let v: Value = serde_json::from_slice(&bytes).unwrap_or(Value::Null);
if status != 200 {
return Err(http_error(status, &v));
}
Ok(v.get("data")
.and_then(Value::as_array)
.map(|a| a.iter().take(500).filter_map(|m| m.get("id").and_then(Value::as_str).map(String::from)).collect())
.unwrap_or_default())
}
/// The endpoint's models for the picker.
pub fn model_list(http: &HttpFn, base_url: &str, key: Option<&str>) -> Result<Vec<ModelInfo>, String> {
let mut headers = Vec::new();
if let Some(k) = key {
if !super::agents::key_safe_for(base_url) {
return Err(crate::i18n::tr("ui.ai_key_needs_https").to_string());
}
headers.push(("Authorization".to_string(), format!("Bearer {k}")));
}
Ok(model_ids(http, base_url, headers)?.into_iter().map(|id| ModelInfo { name: id.clone(), id, description: String::new() }).collect())
}
/// Ask an endpoint for its models (Test connection): how many, and whether `model` is one.
pub fn test_endpoint(http: &HttpFn, base_url: &str, key: Option<&str>, model: &str) -> Result<String, String> {
let mut headers = Vec::new();
if let Some(k) = key {
if !super::agents::key_safe_for(base_url) {
return Err(crate::i18n::tr("ui.ai_key_needs_https").to_string());
}
headers.push(("Authorization".to_string(), format!("Bearer {k}")));
}
let ids = model_ids(http, base_url, headers)?;
let ids: Vec<&str> = ids.iter().map(String::as_str).collect();
let found = model.is_empty() || ids.contains(&model);
Ok(crate::i18n::format(if found { "ui.ai_test_ok" } else { "ui.ai_test_no_model" }, &[("count", &ids.len().to_string()), ("model", model)]))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mcp_tools_become_function_tools_without_crash_reports() {
let list = json!({"tools": [
{"name": "batch", "description": "Run commands", "inputSchema": {"type": "object", "properties": {"commands": {"type": "array"}}}},
{"name": "crash_reports", "description": "x", "inputSchema": {"type": "object"}},
{"name": "measure"},
]});
let t = openai_tools(&list);
assert_eq!(t.len(), 2);
assert_eq!(t[0]["function"]["name"], "batch");
assert_eq!(t[1]["function"]["parameters"]["type"], "object");
}
#[test]
fn pictures_go_as_data_urls_and_text_alone_as_a_string() {
assert_eq!(user_message(&[PromptPart::Text("hi".into())])["content"], "hi");
let m = user_message(&[PromptPart::Text("hi".into()), PromptPart::Image { mime: "image/png".into(), base64: "AA==".into() }]);
assert_eq!(m["content"][1]["image_url"]["url"], "data:image/png;base64,AA==");
}
/// A scripted endpoint: one answer with a tool call, then a plain answer. The loop runs the
/// tool (here without SolidCraft tools: an unknown tool fails) and reports the turn's end.
#[test]
fn the_tool_loop_runs_calls_and_ends_the_turn() {
let answers = std::sync::Mutex::new(vec![
json!({"choices": [{"message": {"role": "assistant", "content": "Done."}}]}),
json!({"choices": [{"message": {"role": "assistant", "content": "Measuring.", "tool_calls": [{"id": "c1", "type": "function", "function": {"name": "measure", "arguments": "{}"}}]}}]}),
]);
let seen = Arc::new(std::sync::Mutex::new(Vec::<Value>::new()));
let log = seen.clone();
let http: HttpFn = Arc::new(move |r: &HttpRequest| {
if r.method == "GET" {
return Ok((200, b"{\"data\":[]}".to_vec()));
}
log.lock().unwrap().push(serde_json::from_slice(r.body.as_deref().unwrap_or(b"null")).unwrap());
let a = answers.lock().unwrap().pop().unwrap_or(json!({}));
Ok((200, a.to_string().into_bytes()))
});
let cfg = ApiConfig { name: "Test".into(), base_url: "http://localhost:1/v1".into(), model: "m".into(), key: None, http };
let ctx = StartContext { cwd: std::env::temp_dir(), bridge: None, tools: None, wake: Box::new(|| {}) };
let mut b = ApiBackend::start(cfg, ctx).unwrap();
b.prompt(&[PromptPart::Text("measure it".into())]);
let mut events = Vec::new();
let start = std::time::Instant::now();
while !events.iter().any(|e| matches!(e, Event::TurnEnded { .. })) && start.elapsed() < Duration::from_secs(10) {
events.extend(b.poll());
std::thread::sleep(Duration::from_millis(10));
}
assert!(matches!(events.first(), Some(Event::Ready { .. })), "{events:?}");
assert!(events.iter().any(|e| matches!(e, Event::Tool { status: Some(ToolStatus::Failed), .. })), "{events:?}");
assert!(events.iter().any(|e| matches!(e, Event::Text { text, .. } if text == "Done.")), "{events:?}");
assert!(matches!(events.last(), Some(Event::TurnEnded { reason }) if reason.is_empty()), "{events:?}");
// The second request carried the tool's answer.
let sent = seen.lock().unwrap();
assert_eq!(sent.len(), 2);
assert_eq!(sent[1]["messages"].as_array().unwrap().last().unwrap()["role"], "tool");
b.shutdown(true);
}
/// Cancel while a model request is in flight ends the turn at once (the request is
/// abandoned, its late answer dropped), leaves no half-finished round in the history and
/// the next message goes through.
#[test]
fn cancelling_a_request_ends_the_turn_and_the_next_message_still_works() {
let (release_tx, release_rx) = channel::<()>();
let release_rx = std::sync::Mutex::new(release_rx);
let seen = Arc::new(std::sync::Mutex::new(Vec::<Value>::new()));
let log = seen.clone();
let http: HttpFn = Arc::new(move |r: &HttpRequest| {
if r.method == "GET" {
return Ok((200, b"{\"data\":[]}".to_vec()));
}
let n = {
let mut l = log.lock().unwrap();
l.push(serde_json::from_slice(r.body.as_deref().unwrap_or(b"null")).unwrap());
l.len()
};
if n == 1 {
// The first request hangs until the test lets it go, then answers late.
let _ = release_rx.lock().unwrap().recv_timeout(Duration::from_secs(20));
return Ok((200, json!({"choices": [{"message": {"role": "assistant", "content": "Too late."}}]}).to_string().into_bytes()));
}
Ok((200, json!({"choices": [{"message": {"role": "assistant", "content": "Steered."}}]}).to_string().into_bytes()))
});
let cfg = ApiConfig { name: "Test".into(), base_url: "http://localhost:1/v1".into(), model: "m".into(), key: None, http };
let ctx = StartContext { cwd: std::env::temp_dir(), bridge: None, tools: None, wake: Box::new(|| {}) };
let mut b = ApiBackend::start(cfg, ctx).unwrap();
let wait = |b: &mut ApiBackend, events: &mut Vec<Event>| {
let start = std::time::Instant::now();
while !events.iter().any(|e| matches!(e, Event::TurnEnded { .. })) && start.elapsed() < Duration::from_secs(10) {
events.extend(b.poll());
std::thread::sleep(Duration::from_millis(10));
}
};
b.prompt(&[PromptPart::Text("long job".into())]);
let start = std::time::Instant::now();
while seen.lock().unwrap().is_empty() && start.elapsed() < Duration::from_secs(10) {
std::thread::sleep(Duration::from_millis(10));
}
b.cancel();
let mut events = Vec::new();
wait(&mut b, &mut events);
assert!(start.elapsed() < Duration::from_secs(8), "the turn ended while the request still hung");
assert!(matches!(events.last(), Some(Event::TurnEnded { reason }) if reason == "cancelled"), "{events:?}");
assert!(!events.iter().any(|e| matches!(e, Event::Text { .. } | Event::Error(_))), "{events:?}");
// The next message is answered, and the late answer of the first never shows up.
b.prompt(&[PromptPart::Text("[interrupted] do this instead".into())]);
let mut events = Vec::new();
wait(&mut b, &mut events);
let _ = release_tx.send(());
assert!(events.iter().any(|e| matches!(e, Event::Text { text, .. } if text == "Steered.")), "{events:?}");
assert!(matches!(events.last(), Some(Event::TurnEnded { reason }) if reason.is_empty()), "{events:?}");
let sent = seen.lock().unwrap();
let roles: Vec<&str> = sent[1]["messages"].as_array().unwrap().iter().map(|m| m["role"].as_str().unwrap_or("")).collect();
assert_eq!(roles, ["system", "user", "user"], "no reply or tool call of the cancelled round is left");
b.shutdown(true);
}
/// New chat resets the model's history to the system note: the next request carries nothing
/// of the conversation before it.
#[test]
fn a_new_chat_resets_the_history_to_the_system_note() {
let seen = Arc::new(std::sync::Mutex::new(Vec::<Value>::new()));
let log = seen.clone();
let http: HttpFn = Arc::new(move |r: &HttpRequest| {
if r.method == "GET" {
return Ok((200, b"{\"data\":[]}".to_vec()));
}
log.lock().unwrap().push(serde_json::from_slice(r.body.as_deref().unwrap_or(b"null")).unwrap());
Ok((200, json!({"choices": [{"message": {"role": "assistant", "content": "ok"}}]}).to_string().into_bytes()))
});
let cfg = ApiConfig { name: "Test".into(), base_url: "http://localhost:1/v1".into(), model: "m".into(), key: None, http };
let ctx = StartContext { cwd: std::env::temp_dir(), bridge: None, tools: None, wake: Box::new(|| {}) };
let mut b = ApiBackend::start(cfg, ctx).unwrap();
let run = |b: &mut ApiBackend, f: &dyn Fn(&mut ApiBackend), want: usize| {
f(b);
let mut events = Vec::new();
let start = std::time::Instant::now();
while events.iter().filter(|e| matches!(e, Event::TurnEnded { .. } | Event::Ready { .. })).count() < want
&& start.elapsed() < Duration::from_secs(10)
{
events.extend(b.poll());
std::thread::sleep(Duration::from_millis(10));
}
};
run(&mut b, &|b| b.prompt(&[PromptPart::Text("one".into())]), 2);
run(&mut b, &|b| b.new_session(), 1);
run(&mut b, &|b| b.prompt(&[PromptPart::Text("two".into())]), 1);
let sent = seen.lock().unwrap();
let roles: Vec<&str> = sent[1]["messages"].as_array().unwrap().iter().map(|m| m["role"].as_str().unwrap_or("")).collect();
assert_eq!(roles, ["system", "user"]);
assert_eq!(sent[1]["messages"][1]["content"], "two");
b.shutdown(true);
}
/// The endpoint's `/models` fills the picker (with the current model first if it is not
/// listed), and choosing one changes the model of the next request.
#[test]
fn the_endpoint_lists_models_and_a_choice_changes_the_next_request() {
let seen = Arc::new(std::sync::Mutex::new(Vec::<Value>::new()));
let log = seen.clone();
let http: HttpFn = Arc::new(move |r: &HttpRequest| {
if r.method == "GET" {
assert!(r.url.ends_with("/models"));
return Ok((200, json!({"data": [{"id": "big"}, {"id": "small"}]}).to_string().into_bytes()));
}
log.lock().unwrap().push(serde_json::from_slice(r.body.as_deref().unwrap_or(b"null")).unwrap());
Ok((200, json!({"choices": [{"message": {"role": "assistant", "content": "ok"}}]}).to_string().into_bytes()))
});
let cfg = ApiConfig { name: "Test".into(), base_url: "http://localhost:1/v1".into(), model: "local".into(), key: None, http };
let ctx = StartContext { cwd: std::env::temp_dir(), bridge: None, tools: None, wake: Box::new(|| {}) };
let mut b = ApiBackend::start(cfg, ctx).unwrap();
let mut events = Vec::new();
let start = std::time::Instant::now();
while !events.iter().any(|e| matches!(e, Event::Models { .. })) && start.elapsed() < Duration::from_secs(10) {
events.extend(b.poll());
std::thread::sleep(Duration::from_millis(10));
}
let Some(Event::Models { current, available }) = events.iter().find(|e| matches!(e, Event::Models { .. })) else { panic!("{events:?}") };
assert_eq!(current, "local");
assert_eq!(available.iter().map(|m| m.id.as_str()).collect::<Vec<_>>(), ["local", "big", "small"]);
b.set_model("big");
b.prompt(&[PromptPart::Text("hi".into())]);
let start = std::time::Instant::now();
while seen.lock().unwrap().is_empty() && start.elapsed() < Duration::from_secs(10) {
std::thread::sleep(Duration::from_millis(10));
}
assert_eq!(seen.lock().unwrap()[0]["model"], "big");
b.shutdown(true);
}
#[test]
fn tool_arguments_are_an_object_or_an_error() {
assert_eq!(tool_arguments(None), Ok(json!({})));
assert_eq!(tool_arguments(Some(&json!(""))), Ok(json!({})));
assert_eq!(tool_arguments(Some(&json!("{\"a\": 1}"))), Ok(json!({"a": 1})));
assert_eq!(tool_arguments(Some(&json!({"a": 1}))), Ok(json!({"a": 1})));
for bad in [json!("{command: solid.box"), json!("[1]"), json!("7"), json!(7), json!([1])] {
assert!(tool_arguments(Some(&bad)).is_err(), "{bad}");
}
}
/// A call with arguments that are not JSON is answered with the reason, not run with `{}`.
#[test]
fn a_call_with_broken_arguments_is_not_run() {
let answers = std::sync::Mutex::new(vec![
json!({"choices": [{"message": {"role": "assistant", "content": "Sorry."}}]}),
json!({"choices": [{"message": {"role": "assistant", "content": "", "tool_calls": [{"id": "c1", "type": "function", "function": {"name": "batch", "arguments": "{commands: [oops"}}]}}]}),
]);
let seen = Arc::new(std::sync::Mutex::new(Vec::<Value>::new()));
let log = seen.clone();
let http: HttpFn = Arc::new(move |r: &HttpRequest| {
if r.method == "GET" {
return Ok((200, b"{\"data\":[]}".to_vec()));
}
log.lock().unwrap().push(serde_json::from_slice(r.body.as_deref().unwrap_or(b"null")).unwrap());
Ok((200, answers.lock().unwrap().pop().unwrap_or(json!({})).to_string().into_bytes()))
});
let cfg = ApiConfig { name: "Test".into(), base_url: "http://localhost:1/v1".into(), model: "m".into(), key: None, http };
let ctx = StartContext { cwd: std::env::temp_dir(), bridge: None, tools: None, wake: Box::new(|| {}) };
let mut b = ApiBackend::start(cfg, ctx).unwrap();
b.prompt(&[PromptPart::Text("go".into())]);
let mut events = Vec::new();
let start = std::time::Instant::now();
while !events.iter().any(|e| matches!(e, Event::TurnEnded { .. })) && start.elapsed() < Duration::from_secs(10) {
events.extend(b.poll());
std::thread::sleep(Duration::from_millis(10));
}
let failed = events.iter().find_map(|e| match e {
Event::Tool { status: Some(ToolStatus::Failed), output: Some(o), .. } => Some(o.clone()),
_ => None,
});
assert!(failed.is_some_and(|o| o.contains("Not run") && o.contains("not valid JSON")), "{events:?}");
// The model was told, in the next request.
let sent = seen.lock().unwrap();
let last = sent[1]["messages"].as_array().unwrap().last().unwrap().clone();
assert!(last["content"].as_str().unwrap_or("").contains("not valid JSON"), "{last}");
b.shutdown(true);
}
#[test]
fn keys_never_go_over_plain_http_to_another_computer() {
let http: HttpFn = Arc::new(|_: &HttpRequest| Ok((200, b"{}".to_vec())));
assert!(test_endpoint(&http, "http://10.0.0.2:11434/v1", Some("k"), "m").is_err());
let cfg = ApiConfig { name: "x".into(), base_url: "http://10.0.0.2/v1".into(), model: "m".into(), key: Some("k".into()), http };
let ctx = StartContext { cwd: std::env::temp_dir(), bridge: None, tools: None, wake: Box::new(|| {}) };
assert!(ApiBackend::start(cfg, ctx).is_err());
}
}