mirror of
https://github.com/storytold/craft-anything.git
synced 2026-11-05 20:47:45 +01:00
c4e3d79dda
Refs #374. A backend can set its own conversation budget (context_tokens in [[ai.backends]]); the conversation is fitted to it before each model attempt, including a fallback with a smaller window, counting history added since the last measured request.
828 lines
37 KiB
Rust
828 lines
37 KiB
Rust
//! AI backends, the agent loop and crews.
|
|
//!
|
|
//! The UI talks to this crate only through [`AiHub`] and [`Conversation`]: start a conversation,
|
|
//! send a message, then poll [`AgentEvent`]s every frame. All network and subprocess work happens
|
|
//! on background threads; nothing here ever blocks the caller (except the explicitly blocking
|
|
//! helpers `test_backend`, `list_models` and `check_reachability`, which the UI runs on a worker).
|
|
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
|
|
|
pub mod agent;
|
|
pub mod anthropic;
|
|
pub mod cli_agents;
|
|
pub mod config;
|
|
pub mod context;
|
|
pub mod detect;
|
|
pub mod failure_log;
|
|
pub mod http;
|
|
pub mod key_tools;
|
|
pub mod keys;
|
|
pub mod model;
|
|
pub mod openai;
|
|
pub mod price;
|
|
pub mod prompt;
|
|
pub mod rebuild;
|
|
pub mod ssh;
|
|
|
|
pub use config::{AiConfig, BackendEntry, BackendOptions, Price};
|
|
pub use keys::KeyStore;
|
|
pub use model::Effort;
|
|
|
|
use crate::agent::{ApiConversation, EngineHost, LoopConfig, ModelSlot};
|
|
use crate::model::{ChatModel, Delta, Msg, Stop, TurnRequest, TurnResult};
|
|
use craft_anything_engine::{Engine, Settings};
|
|
use craft_anything_evidence::{ErrorCode, ToolError};
|
|
use serde::{Deserialize, Serialize};
|
|
use std::path::PathBuf;
|
|
use std::sync::Arc;
|
|
use std::time::{Duration, Instant};
|
|
|
|
/// Kinds of backend. See `plan/05-ai-backends.md`.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum BackendKind {
|
|
Anthropic,
|
|
Openai,
|
|
Openrouter,
|
|
/// Ollama, LM Studio, llama.cpp, vLLM… (OpenAI-compatible HTTP).
|
|
OpenaiCompatible,
|
|
/// Claude Code, Codex, Gemini CLI, Hermes Agent, OpenCode… run as a subprocess with our MCP server.
|
|
AgentCli,
|
|
}
|
|
|
|
impl BackendKind {
|
|
pub fn title(self) -> &'static str {
|
|
match self {
|
|
BackendKind::Anthropic => "Anthropic (Claude API)",
|
|
BackendKind::Openai => "OpenAI",
|
|
BackendKind::Openrouter => "OpenRouter",
|
|
BackendKind::OpenaiCompatible => "Local / OpenAI-compatible server",
|
|
BackendKind::AgentCli => "Agent app (command line)",
|
|
}
|
|
}
|
|
|
|
/// Models offered in a dropdown before a live list is fetched.
|
|
pub fn suggested_models(self) -> &'static [&'static str] {
|
|
match self {
|
|
BackendKind::Anthropic => anthropic::MODELS,
|
|
BackendKind::Openai => &[openai::OPENAI_DEFAULT_MODEL],
|
|
BackendKind::Openrouter => openai::OPENROUTER_MODELS,
|
|
BackendKind::OpenaiCompatible => &[],
|
|
BackendKind::AgentCli => &["claude-code", "codex", "gemini-cli", "opencode", "antigravity", "hermes", "maki"],
|
|
}
|
|
}
|
|
|
|
pub fn needs_key(self) -> bool {
|
|
matches!(self, BackendKind::Anthropic | BackendKind::Openai | BackendKind::Openrouter)
|
|
}
|
|
}
|
|
|
|
/// One configured backend (stored in `settings.ai`; secrets live in the OS keychain).
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
pub struct BackendConfig {
|
|
/// User-chosen name, unique: "Claude", "Local Llama", "Codex on studio-mac".
|
|
pub name: String,
|
|
pub kind: BackendKind,
|
|
/// Model id (API backends) or agent id (`claude-code`, `codex`, `gemini-cli`, `antigravity`, `hermes`, `opencode`…).
|
|
pub model: String,
|
|
/// Base URL for API backends (defaults per kind).
|
|
#[serde(default)]
|
|
pub base_url: Option<String>,
|
|
/// SSH host alias for running an agent CLI or reaching a local-LLM server remotely.
|
|
#[serde(default)]
|
|
pub ssh_host: Option<String>,
|
|
#[serde(default = "yes")]
|
|
pub enabled: bool,
|
|
}
|
|
|
|
fn yes() -> bool {
|
|
true
|
|
}
|
|
|
|
/// The effort an AI thinks at, when it can be chosen (Settings → AI): the Claude API and Claude
|
|
/// Code, which take every level in [`Effort::ALL`]. `None` for the others, which don't use it.
|
|
pub fn effort_in_use(config: &BackendConfig, options: Option<&BackendOptions>) -> Option<Effort> {
|
|
let claude = match config.kind {
|
|
BackendKind::Anthropic => true,
|
|
BackendKind::AgentCli => config.model == "claude-code",
|
|
_ => false,
|
|
};
|
|
claude.then(|| prompt::choose_effort(options.and_then(|o| o.effort)))
|
|
}
|
|
|
|
/// The model an AI uses, when it can be chosen (Settings → AI, #123): an API's own, or the one
|
|
/// Claude Code is asked for. `None` for an agent app that picks its own.
|
|
pub fn model_in_use(config: &BackendConfig, options: Option<&BackendOptions>) -> Option<String> {
|
|
match config.kind {
|
|
BackendKind::AgentCli if config.model == "claude-code" => {
|
|
Some(options.and_then(|o| o.cli_model.clone()).unwrap_or_else(|| anthropic::DEFAULT_MODEL.to_string()))
|
|
}
|
|
BackendKind::AgentCli => None,
|
|
_ => Some(config.model.clone()),
|
|
}
|
|
}
|
|
|
|
/// The backend that builds when none is named: the Builder role's choice while it's on, else an
|
|
/// agent app, else an API backend. The same rule as [`AiConfig::backend_for`] for
|
|
/// [`Role::Builder`], for a client that holds only the list (the app decides "can it build?"
|
|
/// with it, so its warning and Start never disagree).
|
|
pub fn default_builder<'a>(backends: &'a [BackendConfig], chosen: Option<&str>) -> Option<&'a BackendConfig> {
|
|
let on = || backends.iter().filter(|b| b.enabled);
|
|
chosen
|
|
.and_then(|n| on().find(|b| b.name == n))
|
|
.or_else(|| on().find(|b| b.kind == BackendKind::AgentCli))
|
|
.or_else(|| on().find(|b| b.kind != BackendKind::AgentCli))
|
|
}
|
|
|
|
/// What a backend does in a crew.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum Role {
|
|
Lead,
|
|
Worker,
|
|
Reviewer,
|
|
Builder,
|
|
/// Takes the original game's files out after the study, in Deep mode (only when chosen).
|
|
Extractor,
|
|
}
|
|
|
|
/// Something that was found on this machine and can be added with one click.
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
pub struct Detected {
|
|
pub suggestion: BackendConfig,
|
|
/// Plain words: "Found Ollama running with 3 models", "Found ANTHROPIC_API_KEY".
|
|
pub reason: String,
|
|
/// True when a key is still needed before it works.
|
|
pub needs_key: bool,
|
|
}
|
|
|
|
/// Events the UI renders, in order.
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
#[serde(tag = "type", rename_all = "snake_case")]
|
|
pub enum AgentEvent {
|
|
/// Streaming text from the assistant (append to the current message).
|
|
TextDelta {
|
|
text: String,
|
|
},
|
|
/// A short note on what it's doing ("Looking at the functions that mention 'percent'…").
|
|
Status {
|
|
text: String,
|
|
},
|
|
/// A line a program printed outside the conversation (its stderr), for logs: never part of
|
|
/// the answer.
|
|
Output {
|
|
text: String,
|
|
},
|
|
ToolStarted {
|
|
call_id: String,
|
|
tool: String,
|
|
input: serde_json::Value,
|
|
},
|
|
ToolFinished {
|
|
call_id: String,
|
|
tool: String,
|
|
ok: bool,
|
|
summary: String,
|
|
evidence_id: Option<String>,
|
|
},
|
|
/// The agent wants to run a tool that needs the user's OK. Answer with [`Conversation::answer_consent`].
|
|
ConsentNeeded {
|
|
call_id: String,
|
|
tool: String,
|
|
title: String,
|
|
explanation: String,
|
|
},
|
|
/// One assistant turn finished.
|
|
TurnDone {
|
|
backend: String,
|
|
input_tokens: u64,
|
|
output_tokens: u64,
|
|
cost_usd: Option<f64>,
|
|
/// `cost_usd` is the agent's own guess at API prices, not what a key was charged: a
|
|
/// subscription may cover the whole of it.
|
|
#[serde(default)]
|
|
cost_is_estimate: bool,
|
|
},
|
|
Error {
|
|
message: String,
|
|
remediation: Option<String>,
|
|
/// A log with everything known about the failure, to open and inspect.
|
|
log: Option<std::path::PathBuf>,
|
|
},
|
|
}
|
|
|
|
/// A chat with the agent about the current session.
|
|
pub trait Conversation: Send {
|
|
fn send(&mut self, text: &str);
|
|
/// A saved session to continue later, for agents that have one (agent CLIs).
|
|
fn session_id(&self) -> Option<String> {
|
|
None
|
|
}
|
|
/// The saved session the last message was to continue isn't on this computer (the project
|
|
/// was started on another one, or in another folder): that message did nothing, and the next
|
|
/// one starts a new session, which knows nothing of the old.
|
|
fn session_lost(&self) -> bool {
|
|
false
|
|
}
|
|
/// Drain new events (non-blocking).
|
|
fn poll(&mut self) -> Vec<AgentEvent>;
|
|
fn answer_consent(&mut self, call_id: &str, allow: bool);
|
|
fn stop(&mut self);
|
|
fn is_busy(&self) -> bool;
|
|
}
|
|
|
|
/// A conversation tagged with its backend's name (compare mode).
|
|
pub type Labeled = (String, Box<dyn Conversation>);
|
|
|
|
/// Result of a backend test.
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
pub struct TestReport {
|
|
pub backend: String,
|
|
pub model: String,
|
|
pub latency_ms: u64,
|
|
pub reply: String,
|
|
/// Only the coding agent's `--version` ran (`reply` is its first line): it's installed, but
|
|
/// whether it's signed in, has a model and quota shows only when it gets a message (#48).
|
|
#[serde(default)]
|
|
pub version_only: bool,
|
|
}
|
|
|
|
/// Text kept in the journal for a user message (bounded).
|
|
pub(crate) fn journal_text(t: &str) -> String {
|
|
t.chars().take(4000).collect()
|
|
}
|
|
|
|
/// A model reached through an ssh tunnel; the tunnel lives as long as the model.
|
|
struct Tunneled {
|
|
inner: Box<dyn ChatModel>,
|
|
_tunnel: ssh::Tunnel,
|
|
}
|
|
|
|
impl ChatModel for Tunneled {
|
|
fn turn(&mut self, req: &TurnRequest<'_>, on_delta: &mut dyn FnMut(Delta), stop: &Stop) -> Result<TurnResult, ToolError> {
|
|
self.inner.turn(req, on_delta, stop)
|
|
}
|
|
|
|
fn model(&self) -> String {
|
|
self.inner.model()
|
|
}
|
|
}
|
|
|
|
/// The tools this layer adds to the engine's (rebuild control), for
|
|
/// [`craft_anything_engine::Engine::shared`].
|
|
pub fn tools() -> Vec<craft_anything_engine::ToolSpec> {
|
|
let mut v = rebuild::tools::specs();
|
|
v.extend(key_tools::specs());
|
|
v
|
|
}
|
|
|
|
/// Entry point for the UI and CLI.
|
|
pub struct AiHub {
|
|
pub engine: Arc<Engine>,
|
|
settings_path: Option<PathBuf>,
|
|
keys: KeyStore,
|
|
}
|
|
|
|
impl AiHub {
|
|
pub fn new(engine: Arc<Engine>) -> Self {
|
|
AiHub { engine, settings_path: None, keys: KeyStore::system() }
|
|
}
|
|
|
|
/// A hub with explicit settings file and key store (tests, portable installs).
|
|
pub fn with_stores(engine: Arc<Engine>, settings_path: PathBuf, keys: KeyStore) -> Self {
|
|
AiHub { engine, settings_path: Some(settings_path), keys }
|
|
}
|
|
|
|
fn settings_path(&self) -> PathBuf {
|
|
self.settings_path.clone().unwrap_or_else(Settings::path)
|
|
}
|
|
|
|
/// The current AI configuration. Never blocks on a running tool: if the session is busy the
|
|
/// saved settings file is read instead.
|
|
pub fn config(&self) -> AiConfig {
|
|
match self.engine.try_session() {
|
|
Some(s) => AiConfig::from_settings(&s.settings),
|
|
None => AiConfig::from_settings(&Settings::load_from(&self.settings_path())),
|
|
}
|
|
}
|
|
|
|
/// The configuration to change and save back. An [ai] section that can't be read is an error
|
|
/// here, never the empty default, so a change can't save over it (#261).
|
|
fn config_to_change(&self) -> Result<AiConfig, ToolError> {
|
|
match self.engine.try_session() {
|
|
Some(s) => AiConfig::from_table(&s.settings.ai),
|
|
None => AiConfig::from_table(&Settings::load_from(&self.settings_path()).ai),
|
|
}
|
|
}
|
|
|
|
/// Save the AI configuration into the settings file, then the session: a save that fails
|
|
/// changes nothing, so the app never shows a backend or role that isn't saved.
|
|
pub fn save_config(&self, cfg: &AiConfig) -> Result<(), ToolError> {
|
|
let table = cfg.to_table()?;
|
|
let mut s = self.engine.session();
|
|
let mut next = s.settings.clone();
|
|
next.ai = table;
|
|
next.save_to(&self.settings_path())?;
|
|
s.settings = next;
|
|
Ok(())
|
|
}
|
|
|
|
/// Configured backends.
|
|
pub fn backends(&self) -> Vec<BackendConfig> {
|
|
self.config().backends.into_iter().map(|b| b.config).collect()
|
|
}
|
|
|
|
/// Look for keys, local servers and agent CLIs (runs quickly; network probes are bounded).
|
|
/// Things already configured (by name) are left out.
|
|
pub fn detect(&self) -> Vec<Detected> {
|
|
let have: Vec<String> = self.backends().into_iter().map(|b| b.name).collect();
|
|
detect::detect_all().into_iter().filter(|d| !have.contains(&d.suggestion.name)).collect()
|
|
}
|
|
|
|
/// Host aliases from `~/.ssh/config`, for the "run on another machine" picker.
|
|
pub fn ssh_hosts(&self) -> Vec<String> {
|
|
ssh::ssh_hosts()
|
|
}
|
|
|
|
/// The backend the person chose for `role` in Settings → AI, while it's still enabled.
|
|
pub fn chosen(&self, role: Role) -> Option<String> {
|
|
let cfg = self.config();
|
|
cfg.roles.get(config::role_key(role)).filter(|n| cfg.get(n).is_some_and(|b| b.config.enabled)).cloned()
|
|
}
|
|
|
|
/// Choose the backend for `role`; `None` goes back to its default (the builder: an agent app,
|
|
/// else any AI backend; the extractor: none).
|
|
pub fn set_role(&self, role: Role, name: Option<&str>) -> Result<(), ToolError> {
|
|
let mut cfg = self.config_to_change()?;
|
|
let role = config::role_key(role).to_string();
|
|
match name {
|
|
Some(n) if cfg.get(n).is_none() => {
|
|
return Err(
|
|
ToolError::invalid(format!("No AI backend named {n:?}")).with_remediation("See `craft-anything-cli ai list`, or add one in Settings → AI.")
|
|
);
|
|
}
|
|
Some(n) => {
|
|
cfg.roles.insert(role, n.to_string());
|
|
}
|
|
None => {
|
|
cfg.roles.remove(&role);
|
|
}
|
|
}
|
|
self.save_config(&cfg)
|
|
}
|
|
|
|
/// Add (or replace) a backend, storing its key in the keychain when given.
|
|
pub fn add_backend(&self, cfg: BackendConfig, key: Option<String>) -> Result<(), ToolError> {
|
|
self.add_backend_with(BackendEntry { config: cfg, options: BackendOptions::default() }, key)
|
|
}
|
|
|
|
/// Add an OpenAI-compatible server by its address: Ollama or LM Studio on another computer, or
|
|
/// a hosted service with its key. It's added only when it answers with its models; without a
|
|
/// model, the first one it lists that can chat is used. Returns the model.
|
|
pub fn add_server(&self, name: &str, address: &str, model: Option<&str>, key: Option<String>) -> Result<String, ToolError> {
|
|
let name = name.trim();
|
|
let address = address.trim().trim_end_matches('/');
|
|
if name.is_empty() {
|
|
return Err(ToolError::invalid("A backend needs a name."));
|
|
}
|
|
if !(address.starts_with("http://") || address.starts_with("https://")) {
|
|
return Err(ToolError::invalid(format!("{address:?} isn't a web address."))
|
|
.with_remediation("Give the server's address, like http://192.168.1.20:11434/v1."));
|
|
}
|
|
let key = key.map(|k| k.trim().to_string()).filter(|k| !k.is_empty());
|
|
let models = openai::OpenAi::compatible(name, address, key.clone(), "").list_models(Duration::from_secs(10)).map_err(|e| {
|
|
let why = e.message.clone();
|
|
e.with_remediation(format!(
|
|
"Check the address (it usually ends in /v1), that the server is running and can be reached from this computer, and its key if it needs one. ({why})"
|
|
))
|
|
})?;
|
|
let model = match model.map(str::trim).filter(|m| !m.is_empty()) {
|
|
Some(m) => m.to_string(),
|
|
None => detect::chat_model(&models).ok_or_else(|| {
|
|
ToolError::new(ErrorCode::NotFound, format!("{address} has no models yet.")).with_remediation("Add a model on the server first, or name one.")
|
|
})?,
|
|
};
|
|
let config = BackendConfig {
|
|
name: name.to_string(),
|
|
kind: BackendKind::OpenaiCompatible,
|
|
model: model.clone(),
|
|
base_url: Some(address.to_string()),
|
|
ssh_host: None,
|
|
enabled: true,
|
|
};
|
|
self.add_backend(config, key)?;
|
|
Ok(model)
|
|
}
|
|
|
|
/// Like [`AiHub::add_backend`], with options.
|
|
pub fn add_backend_with(&self, entry: BackendEntry, key: Option<String>) -> Result<(), ToolError> {
|
|
if entry.config.name.trim().is_empty() {
|
|
return Err(ToolError::invalid("A backend needs a name."));
|
|
}
|
|
let mut cfg = self.config_to_change()?;
|
|
if let Some(k) = key.filter(|k| !k.trim().is_empty()) {
|
|
self.keys.set(&entry.config.name, k.trim())?;
|
|
}
|
|
cfg.upsert(entry);
|
|
self.save_config(&cfg)
|
|
}
|
|
|
|
/// Turn a backend on or off; it keeps its settings and key. Returns false when there was no
|
|
/// such backend.
|
|
pub fn set_enabled(&self, name: &str, on: bool) -> Result<bool, ToolError> {
|
|
let mut cfg = self.config_to_change()?;
|
|
let Some(b) = cfg.backends.iter_mut().find(|b| b.config.name == name) else { return Ok(false) };
|
|
b.config.enabled = on;
|
|
self.save_config(&cfg)?;
|
|
Ok(true)
|
|
}
|
|
|
|
/// The program an agent app runs (`None` or empty: found by its usual name, on PATH and in the
|
|
/// usual install folders). Returns false when there was no such backend.
|
|
pub fn set_command(&self, name: &str, command: Option<&str>) -> Result<bool, ToolError> {
|
|
let mut cfg = self.config_to_change()?;
|
|
let Some(b) = cfg.backends.iter_mut().find(|b| b.config.name == name) else { return Ok(false) };
|
|
b.options.command = command.map(str::trim).filter(|c| !c.is_empty()).map(str::to_string);
|
|
self.save_config(&cfg)?;
|
|
Ok(true)
|
|
}
|
|
|
|
/// Each backend's options (an agent app's program and model…), by name.
|
|
pub fn options(&self) -> std::collections::BTreeMap<String, BackendOptions> {
|
|
self.config().backends.into_iter().map(|b| (b.config.name, b.options)).collect()
|
|
}
|
|
|
|
/// The model a backend uses: an API's model, or the one Claude Code is asked for. Returns
|
|
/// false when there was no such backend.
|
|
pub fn set_model(&self, name: &str, model: &str) -> Result<bool, ToolError> {
|
|
let model = model.trim();
|
|
if model.is_empty() {
|
|
return Err(ToolError::invalid("Name a model."));
|
|
}
|
|
let mut cfg = self.config_to_change()?;
|
|
let Some(b) = cfg.backends.iter_mut().find(|b| b.config.name == name) else { return Ok(false) };
|
|
match b.config.kind {
|
|
BackendKind::AgentCli => b.options.cli_model = Some(model.to_string()),
|
|
_ => b.config.model = model.to_string(),
|
|
}
|
|
self.save_config(&cfg)?;
|
|
Ok(true)
|
|
}
|
|
|
|
/// How hard a backend thinks (`None`: the usual level again). Only the Claude API and Claude
|
|
/// Code take one ([`effort_in_use`]). Returns false when there was no such backend.
|
|
pub fn set_effort(&self, name: &str, effort: Option<Effort>) -> Result<bool, ToolError> {
|
|
let mut cfg = self.config_to_change()?;
|
|
let Some(b) = cfg.backends.iter_mut().find(|b| b.config.name == name) else { return Ok(false) };
|
|
if effort.is_some() && effort_in_use(&b.config, Some(&b.options)).is_none() {
|
|
return Err(ToolError::invalid(format!("{name} doesn't take an effort level.")).with_remediation("Effort is for the Claude API and Claude Code."));
|
|
}
|
|
b.options.effort = effort;
|
|
self.save_config(&cfg)?;
|
|
Ok(true)
|
|
}
|
|
|
|
/// The models a backend can be set to (#123): the API's own list, or Claude's models for Claude
|
|
/// Code. Empty for an agent app that picks its own. Blocking (bounded); run it off the UI thread.
|
|
pub fn model_choices(&self, name: &str) -> Result<Vec<String>, ToolError> {
|
|
let e = self.entry(name)?;
|
|
match (e.config.kind, model_in_use(&e.config, Some(&e.options))) {
|
|
(_, None) => Ok(Vec::new()),
|
|
(BackendKind::AgentCli, Some(_)) => Ok(anthropic::MODELS.iter().map(|m| m.to_string()).collect()),
|
|
_ => self.list_models(name),
|
|
}
|
|
}
|
|
|
|
/// Remove a backend and its stored key. Returns false when there was no such backend.
|
|
pub fn remove_backend(&self, name: &str) -> Result<bool, ToolError> {
|
|
let mut cfg = self.config_to_change()?;
|
|
let removed = cfg.remove(name);
|
|
if removed {
|
|
self.save_config(&cfg)?;
|
|
self.keys.delete(name)?;
|
|
}
|
|
Ok(removed)
|
|
}
|
|
|
|
/// Some built-in asset provider has a key, so builds can generate assets.
|
|
pub fn has_asset_keys(&self) -> bool {
|
|
let providers = craft_anything_engines::assets::AssetProviders::built_in();
|
|
providers.ids().into_iter().any(|id| self.has_asset_key(&providers, id))
|
|
}
|
|
|
|
/// Save an asset provider's key (keychain first). Returns where it went.
|
|
pub fn set_asset_key(&self, provider: &str, key: &str) -> Result<&'static str, ToolError> {
|
|
self.keys.set(&keys::asset_key_name(provider), key.trim())
|
|
}
|
|
|
|
pub fn remove_asset_key(&self, provider: &str) -> Result<(), ToolError> {
|
|
self.keys.delete(&keys::asset_key_name(provider))
|
|
}
|
|
|
|
/// The provider's key is stored or in its environment variable.
|
|
pub fn has_asset_key(&self, providers: &craft_anything_engines::assets::AssetProviders, provider: &str) -> bool {
|
|
providers.get(provider).is_some_and(|p| keys::asset_key(&self.keys, p.as_ref()).is_some())
|
|
}
|
|
|
|
/// True when a key is stored or in the environment (keys themselves are never returned).
|
|
pub fn has_key(&self, name: &str) -> bool {
|
|
self.config().get(name).is_some_and(|b| !b.config.kind.needs_key() || self.keys.resolve(name, b.config.kind).is_some())
|
|
}
|
|
|
|
fn entry(&self, name: &str) -> Result<BackendEntry, ToolError> {
|
|
self.config().get(name).cloned().ok_or_else(|| {
|
|
ToolError::invalid(format!("No AI backend named {name:?}")).with_remediation("See `craft-anything-cli ai list`, or add one in Settings → AI.")
|
|
})
|
|
}
|
|
|
|
fn key_for(&self, e: &BackendEntry) -> Result<String, ToolError> {
|
|
self.keys.resolve(&e.config.name, e.config.kind).ok_or_else(|| {
|
|
let var = keys::env_var(e.config.kind).unwrap_or("an API key");
|
|
ToolError::new(ErrorCode::BadSettings, format!("{} has no API key.", e.config.name))
|
|
.with_remediation(format!("Add the key in Settings → AI, or set {var}."))
|
|
})
|
|
}
|
|
|
|
/// Build the model for an API backend (not agent CLIs).
|
|
pub fn build_model(&self, e: &BackendEntry) -> Result<ModelSlot, ToolError> {
|
|
let c = &e.config;
|
|
let base = c.base_url.as_deref().filter(|b| !b.trim().is_empty());
|
|
let mut tunnel = None;
|
|
// Over ssh: forward a local port to the server as seen from the remote host.
|
|
let tunneled_base = match (&c.ssh_host, c.kind) {
|
|
(Some(host), BackendKind::OpenaiCompatible) if !host.trim().is_empty() => {
|
|
let b = base.unwrap_or("http://127.0.0.1:11434/v1");
|
|
let after = b.split("://").nth(1).unwrap_or(b);
|
|
let hostport = after.split('/').next().unwrap_or_default();
|
|
let path = after.get(hostport.len()..).unwrap_or("/v1");
|
|
let (rh, rp) = hostport.rsplit_once(':').unwrap_or((hostport, "80"));
|
|
let rp: u16 = rp.parse().map_err(|_| ToolError::invalid(format!("Bad port in {b}")))?;
|
|
let t = ssh::open_tunnel(host, rh, rp)?;
|
|
let url = format!("http://127.0.0.1:{}{path}", t.local_port);
|
|
tunnel = Some(t);
|
|
Some(url)
|
|
}
|
|
_ => None,
|
|
};
|
|
let base = tunneled_base.as_deref().or(base);
|
|
// A server on this computer or its network (Ollama, LM Studio, one reached over ssh) costs
|
|
// nothing and gets the small context budget; a remote OpenAI-compatible service has a
|
|
// large context and a bill, as any other cloud API (#374).
|
|
let local = c.kind == BackendKind::OpenaiCompatible && base.is_none_or(detect::on_this_network);
|
|
let model: Box<dyn ChatModel> = match c.kind {
|
|
BackendKind::Anthropic => {
|
|
let mut a = anthropic::Anthropic::new(base, self.key_for(e)?, &c.model);
|
|
a.fallbacks = e.options.fallbacks_enabled.unwrap_or(true);
|
|
Box::new(a)
|
|
}
|
|
BackendKind::Openai => Box::new(openai::OpenAi::official(base, self.key_for(e)?, &c.model)),
|
|
BackendKind::Openrouter => Box::new(openai::OpenAi::openrouter(base, self.key_for(e)?, &c.model)),
|
|
BackendKind::OpenaiCompatible => {
|
|
let mut o = openai::OpenAi::compatible(&c.name, base.unwrap_or("http://127.0.0.1:11434/v1"), self.keys.get(&c.name), &c.model);
|
|
o.react = e.options.react_fallback.unwrap_or(false);
|
|
Box::new(o)
|
|
}
|
|
BackendKind::AgentCli => return Err(ToolError::invalid(format!("{} is an agent app, not an API backend.", c.name))),
|
|
};
|
|
let model = match tunnel {
|
|
Some(t) => Box::new(Tunneled { inner: model, _tunnel: t }) as Box<dyn ChatModel>,
|
|
None => model,
|
|
};
|
|
Ok(ModelSlot {
|
|
name: c.name.clone(),
|
|
model,
|
|
local,
|
|
effort: e.options.effort,
|
|
max_turns: e.options.max_turns.unwrap_or(agent::DEFAULT_MAX_TURNS),
|
|
context_tokens: e.options.context_tokens.unwrap_or(0),
|
|
})
|
|
}
|
|
|
|
fn project_root(&self) -> Option<PathBuf> {
|
|
self.engine.try_session().and_then(|s| s.project.as_ref().map(|p| p.root.clone()))
|
|
}
|
|
|
|
/// Start a conversation with the lead backend (or the named one).
|
|
pub fn start(&self, backend: Option<&str>, beginner: bool) -> Result<Box<dyn Conversation>, ToolError> {
|
|
let cfg = self.config();
|
|
let first = match backend {
|
|
Some(n) => self.entry(n)?,
|
|
None => cfg
|
|
.backend_for(Role::Lead)
|
|
.cloned()
|
|
.ok_or_else(|| ToolError::unavailable("No AI is set up yet.").with_remediation("Add one in Settings → AI."))?,
|
|
};
|
|
if first.config.kind == BackendKind::AgentCli {
|
|
let spec = cli_agents::CliSpec::new(first.config.clone(), first.options.clone(), beginner, self.project_root());
|
|
let (deep, logs) = {
|
|
let session = self.engine.session();
|
|
(!session.clean_room(), failure_log::dir_in(&session.config_dir()))
|
|
};
|
|
return Ok(cli_agents::conversation(cli_agents::CliSpec { deep, logs: Some(logs), ..spec }));
|
|
}
|
|
let mut chain = vec![self.build_model(&first)?];
|
|
for name in cfg.chain_from(&first.config.name).into_iter().skip(1) {
|
|
if let Some(e) = cfg.get(&name).filter(|e| e.config.kind != BackendKind::AgentCli) {
|
|
match self.build_model(e) {
|
|
Ok(slot) => chain.push(slot),
|
|
Err(err) => log::info!("fallback {name} skipped: {}", err.message),
|
|
}
|
|
}
|
|
}
|
|
let host = Arc::new(EngineHost(self.engine.clone()));
|
|
let conv = ApiConversation::start(host, chain, LoopConfig { beginner, prices: cfg.prices.clone(), ..LoopConfig::default() })?;
|
|
Ok(Box::new(conv))
|
|
}
|
|
|
|
/// A builder conversation on an API backend: our agent loop with the Build toolset (files and
|
|
/// commands in the project) enabled from the start, and room for a long build. In Deep mode
|
|
/// every toolset is enabled, so it can look inside the original with all of them.
|
|
/// `transcript`: where the conversation is kept, and resumed from when it's there.
|
|
pub fn start_builder(&self, entry: &BackendEntry, beginner: bool, transcript: Option<PathBuf>) -> Result<ApiConversation, ToolError> {
|
|
use craft_anything_engine::Toolset;
|
|
let deep = !self.engine.session().clean_room();
|
|
let toolsets = if deep { self.engine.registry().toolsets() } else { vec![Toolset::Core, Toolset::Build] };
|
|
self.start_loop(entry, beginner, transcript, &toolsets)
|
|
}
|
|
|
|
/// Start the asset extractor: our agent loop with the Build tools and the game archive tools.
|
|
pub fn start_extractor(&self, entry: &BackendEntry, beginner: bool, transcript: Option<PathBuf>) -> Result<ApiConversation, ToolError> {
|
|
use craft_anything_engine::Toolset;
|
|
self.start_loop(entry, beginner, transcript, &[Toolset::Core, Toolset::Build, Toolset::Formats])
|
|
}
|
|
|
|
fn start_loop(
|
|
&self,
|
|
entry: &BackendEntry,
|
|
beginner: bool,
|
|
transcript: Option<PathBuf>,
|
|
toolsets: &[craft_anything_engine::Toolset],
|
|
) -> Result<ApiConversation, ToolError> {
|
|
let mut slot = self.build_model(entry)?;
|
|
slot.max_turns = slot.max_turns.max(agent::BUILDER_MAX_TURNS);
|
|
let host = Arc::new(EngineHost(self.engine.clone()));
|
|
ApiConversation::start_with(
|
|
host,
|
|
vec![slot],
|
|
LoopConfig { beginner, prices: self.config().prices.clone(), transcript, ..LoopConfig::default() },
|
|
toolsets.iter().copied().collect(),
|
|
)
|
|
}
|
|
|
|
/// Start a conversation with whichever backend fills `role` in the crew.
|
|
pub fn start_role(&self, role: Role, beginner: bool) -> Result<Box<dyn Conversation>, ToolError> {
|
|
let cfg = self.config();
|
|
let e = cfg.backend_for(role).ok_or_else(|| {
|
|
ToolError::unavailable(format!("No backend is set for the {} role.", config::role_key(role)))
|
|
.with_remediation("Choose one in Settings → AI → Roles.")
|
|
})?;
|
|
self.start(Some(&e.config.name), beginner)
|
|
}
|
|
|
|
/// Compare mode: the same question to two backends. Returns one conversation per backend,
|
|
/// tagged by name; the caller sends the same text to both and shows them side by side.
|
|
pub fn compare(&self, a: &str, b: &str, beginner: bool) -> Result<Vec<Labeled>, ToolError> {
|
|
if a == b {
|
|
return Err(ToolError::invalid("Pick two different backends to compare."));
|
|
}
|
|
Ok(vec![(a.to_string(), self.start(Some(a), beginner)?), (b.to_string(), self.start(Some(b), beginner)?)])
|
|
}
|
|
|
|
/// Live model list for a backend. Blocking (bounded); run it off the UI thread.
|
|
pub fn list_models(&self, name: &str) -> Result<Vec<String>, ToolError> {
|
|
let e = self.entry(name)?;
|
|
let base = e.config.base_url.as_deref().filter(|b| !b.trim().is_empty());
|
|
let t = Duration::from_secs(10);
|
|
match e.config.kind {
|
|
BackendKind::Anthropic => anthropic::Anthropic::new(base, self.key_for(&e)?, &e.config.model).list_models(t),
|
|
BackendKind::Openai => openai::OpenAi::official(base, self.key_for(&e)?, &e.config.model).list_models(t),
|
|
BackendKind::Openrouter => openai::OpenAi::openrouter(base, self.key_for(&e)?, &e.config.model).list_models(t),
|
|
BackendKind::OpenaiCompatible => {
|
|
if e.config.ssh_host.as_deref().is_some_and(|h| !h.trim().is_empty()) {
|
|
return Err(ToolError::unavailable("Listing models on a remote server isn't supported yet.").with_remediation("Type the model name."));
|
|
}
|
|
openai::OpenAi::compatible(&e.config.name, base.unwrap_or("http://127.0.0.1:11434/v1"), self.keys.get(&e.config.name), "").list_models(t)
|
|
}
|
|
BackendKind::AgentCli => Ok(BackendKind::AgentCli.suggested_models().iter().map(|s| s.to_string()).collect()),
|
|
}
|
|
}
|
|
|
|
/// Send a tiny request and time it; a coding agent only runs `--version`, so its report says
|
|
/// `version_only`. Blocking (bounded); run it off the UI thread.
|
|
pub fn test_backend(&self, name: &str) -> Result<TestReport, ToolError> {
|
|
let e = self.entry(name)?;
|
|
let start = Instant::now();
|
|
if e.config.kind == BackendKind::AgentCli {
|
|
let program = e.options.command.clone().unwrap_or_else(|| cli_agents::program_for(&e.config.model));
|
|
let p = match e.config.ssh_host.as_deref().filter(|h| !h.trim().is_empty()) {
|
|
Some(h) => craft_anything_platform::Process::new("ssh").args(ssh::ssh_args(h, &[program.clone(), "--version".into()])),
|
|
None => craft_anything_platform::Process::new(&program).arg("--version"),
|
|
};
|
|
let out = p.timeout(Duration::from_secs(15)).run_checked()?;
|
|
return Ok(TestReport {
|
|
backend: e.config.name.clone(),
|
|
model: e.config.model.clone(),
|
|
latency_ms: start.elapsed().as_millis() as u64,
|
|
reply: out.stdout_text().lines().next().unwrap_or_default().trim().to_string(),
|
|
version_only: true,
|
|
});
|
|
}
|
|
let mut slot = self.build_model(&e)?;
|
|
let stop = Stop::default();
|
|
let (tx, rx) = std::sync::mpsc::channel();
|
|
// A hard bound even if the server trickles bytes forever.
|
|
std::thread::Builder::new()
|
|
.name("ai-test".into())
|
|
.spawn(move || {
|
|
let msgs = req_parts();
|
|
let r = slot.model.turn(&req_owned(&msgs), &mut |_| {}, &stop).map(|r| (r, slot.model.model()));
|
|
let _ = tx.send(r);
|
|
})
|
|
.map_err(|e| ToolError::internal(e.to_string()))?;
|
|
let (r, model) =
|
|
rx.recv_timeout(Duration::from_secs(60)).map_err(|_| ToolError::new(ErrorCode::TimedOut, format!("{name} didn't answer within 60 s.")))??;
|
|
Ok(TestReport {
|
|
backend: e.config.name.clone(),
|
|
model: if r.model.is_empty() { model } else { r.model },
|
|
latency_ms: start.elapsed().as_millis() as u64,
|
|
reply: r.message.text().trim().to_string(),
|
|
version_only: false,
|
|
})
|
|
}
|
|
|
|
/// For `doctor`: is each enabled backend reachable? `(name, Ok(latency ms) | Err(reason))`.
|
|
/// Probes run in parallel; the whole check is bounded to about 12 s.
|
|
pub fn check_reachability(&self) -> Vec<(String, Result<u64, String>)> {
|
|
let entries: Vec<BackendEntry> = self.config().enabled().cloned().collect();
|
|
let (tx, rx) = std::sync::mpsc::channel();
|
|
for (i, e) in entries.iter().enumerate() {
|
|
let e = e.clone();
|
|
let key = self.keys.resolve(&e.config.name, e.config.kind);
|
|
let tx = tx.clone();
|
|
let _ = std::thread::Builder::new().name("ai-reach".into()).spawn(move || {
|
|
let _ = tx.send((i, reach(&e, key)));
|
|
});
|
|
}
|
|
drop(tx);
|
|
let deadline = Instant::now() + Duration::from_secs(12);
|
|
let mut results: Vec<Option<Result<u64, String>>> = vec![None; entries.len()];
|
|
while results.iter().any(Option::is_none) {
|
|
match rx.recv_timeout(deadline.saturating_duration_since(Instant::now())) {
|
|
Ok((i, r)) => {
|
|
if let Some(slot) = results.get_mut(i) {
|
|
*slot = Some(r);
|
|
}
|
|
}
|
|
Err(_) => break,
|
|
}
|
|
}
|
|
entries.into_iter().zip(results).map(|(e, r)| (e.config.name, r.unwrap_or_else(|| Err("no answer in time".into())))).collect()
|
|
}
|
|
}
|
|
|
|
fn req_parts() -> Vec<Msg> {
|
|
vec![Msg::user_text("Reply with the single word: ready")]
|
|
}
|
|
|
|
fn req_owned(msgs: &[Msg]) -> TurnRequest<'_> {
|
|
TurnRequest { system: "You are a connectivity check. Answer in one word.", messages: msgs, tools: &[], effort: Effort::High, max_tokens: 2048 }
|
|
}
|
|
|
|
fn reach(e: &BackendEntry, key: Option<String>) -> Result<u64, String> {
|
|
let start = Instant::now();
|
|
let t = Duration::from_secs(8);
|
|
let base = e.config.base_url.as_deref().filter(|b| !b.trim().is_empty());
|
|
let need =
|
|
|k: Option<String>| k.ok_or_else(|| format!("no API key (set it in Settings → AI or {})", keys::env_var(e.config.kind).unwrap_or("the environment")));
|
|
let r: Result<(), String> = match e.config.kind {
|
|
BackendKind::Anthropic => anthropic::Anthropic::new(base, need(key)?, &e.config.model).list_models(t).map(|_| ()).map_err(|x| x.message),
|
|
BackendKind::Openai => openai::OpenAi::official(base, need(key)?, &e.config.model).list_models(t).map(|_| ()).map_err(|x| x.message),
|
|
BackendKind::Openrouter => openai::OpenAi::openrouter(base, need(key)?, &e.config.model).list_models(t).map(|_| ()).map_err(|x| x.message),
|
|
BackendKind::OpenaiCompatible | BackendKind::AgentCli if e.config.ssh_host.as_deref().is_some_and(|h| !h.trim().is_empty()) => {
|
|
let host = e.config.ssh_host.clone().unwrap_or_default();
|
|
craft_anything_platform::Process::new("ssh")
|
|
.args(ssh::ssh_args(&host, &["true".to_string()]))
|
|
.timeout(Duration::from_secs(10))
|
|
.run_checked()
|
|
.map(|_| ())
|
|
.map_err(|x| x.message)
|
|
}
|
|
BackendKind::OpenaiCompatible => {
|
|
let b = base.unwrap_or("http://127.0.0.1:11434/v1");
|
|
if !detect::port_open(b, Duration::from_millis(500)) {
|
|
Err(format!("nothing is listening at {b}"))
|
|
} else {
|
|
// An open port isn't a model server: another program may hold it (#37).
|
|
openai::OpenAi::compatible(&e.config.name, b, key, &e.config.model).list_models(t).map(|_| ()).map_err(|x| x.message)
|
|
}
|
|
}
|
|
BackendKind::AgentCli => {
|
|
let program = e.options.command.clone().unwrap_or_else(|| cli_agents::program_for(&e.config.model));
|
|
craft_anything_platform::which(&program).map(|_| ()).ok_or_else(|| format!("{program} isn't on PATH"))
|
|
}
|
|
};
|
|
r.map(|_| start.elapsed().as_millis() as u64)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests;
|