From bb228f63159b14e38ab9e1cc16431871c406243c Mon Sep 17 00:00:00 2001 From: Zaki Manian Date: Wed, 11 Feb 2026 16:37:51 -0800 Subject: [PATCH] feat: Add multi-provider LLM support via rig-core adapter (#36) Add support for OpenAI, Anthropic, Ollama, and OpenAI-compatible endpoints alongside the existing NEAR AI backend. Users can now bring their own API keys via environment variables (LLM_BACKEND, OPENAI_API_KEY, ANTHROPIC_API_KEY, etc.) while NEAR AI remains the default. Co-authored-by: Claude Opus 4.6 --- Cargo.lock | 338 +++++++++++++++++++++++++++++- Cargo.toml | 3 + src/agent/agent_loop.rs | 2 +- src/config.rs | 204 ++++++++++++++++-- src/llm/costs.rs | 124 +++++++++++ src/llm/mod.rs | 140 ++++++++++++- src/llm/rig_adapter.rs | 451 ++++++++++++++++++++++++++++++++++++++++ src/main.rs | 8 +- src/setup/wizard.rs | 5 + 9 files changed, 1239 insertions(+), 36 deletions(-) create mode 100644 src/llm/costs.rs create mode 100644 src/llm/rig_adapter.rs diff --git a/Cargo.lock b/Cargo.lock index 0fe4dc8b..c9006498 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -182,6 +182,12 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "as-any" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0f477b951e452a0b6b4a10b53ccd569042d1d01729b519e02074a9c0958a063" + [[package]] name = "async-broadcast" version = "0.7.2" @@ -307,6 +313,28 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "async-task" version = "4.7.1" @@ -820,6 +848,16 @@ dependencies = [ "crossterm 0.29.0", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -1479,6 +1517,17 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "eventsource-stream" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74fef4569247a5f429d9156b9d0a2599914385dd189c539334c625d8099d90ab" +dependencies = [ + "futures-core", + "nom", + "pin-project-lite", +] + [[package]] name = "fallible-iterator" version = "0.2.0" @@ -1537,6 +1586,21 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -1647,6 +1711,12 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +[[package]] +name = "futures-timer" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" + [[package]] name = "futures-util" version = "0.3.31" @@ -1745,6 +1815,12 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + [[package]] name = "h2" version = "0.4.13" @@ -1944,6 +2020,22 @@ dependencies = [ "webpki-roots", ] +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -1962,9 +2054,11 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -2219,12 +2313,13 @@ dependencies = [ "refinery", "regex", "reqwest", + "rig-core", "rust_decimal", "rust_decimal_macros", "rustyline", "secrecy", "secret-service", - "security-framework", + "security-framework 3.5.1", "serde", "serde_json", "sha2", @@ -2545,6 +2640,32 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "nanoid" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ffa00dec017b5b1a8b7cf5e2c008bfda1aa7e0697ac1508b491fdf2622fb4d8" +dependencies = [ + "rand 0.8.5", +] + +[[package]] +name = "native-tls" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe 0.1.6", + "openssl-sys", + "schannel", + "security-framework 2.11.1", + "security-framework-sys", + "tempfile", +] + [[package]] name = "nibble_vec" version = "0.1.0" @@ -2737,18 +2858,71 @@ dependencies = [ "pathdiff", ] +[[package]] +name = "openssl" +version = "0.10.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + [[package]] name = "openssl-probe" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "openssl-sys" +version = "0.9.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "option-ext" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-float" +version = "5.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4779c6901a562440c3786d08192c6fbda7c1c2060edd10006b05ee35d10f2d" +dependencies = [ + "num-traits", +] + [[package]] name = "ordered-stream" version = "0.2.0" @@ -2860,6 +3034,26 @@ dependencies = [ "siphasher", ] +[[package]] +name = "pin-project" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -3392,16 +3586,22 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64 0.22.1", "bytes", + "encoding_rs", "futures-core", "futures-util", + "h2", "http", "http-body", "http-body-util", "hyper", "hyper-rustls", + "hyper-tls", "hyper-util", "js-sys", "log", + "mime", + "mime_guess", + "native-tls", "percent-encoding", "pin-project-lite", "quinn", @@ -3412,6 +3612,7 @@ dependencies = [ "serde_urlencoded", "sync_wrapper", "tokio", + "tokio-native-tls", "tokio-rustls", "tokio-util", "tower", @@ -3425,6 +3626,38 @@ dependencies = [ "webpki-roots", ] +[[package]] +name = "rig-core" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f7a3f0c7c00eaced15a68ee16e1bd6bb709ff598d11b9aedac8b628217dc09" +dependencies = [ + "as-any", + "async-stream", + "base64 0.22.1", + "bytes", + "eventsource-stream", + "fastrand", + "futures", + "futures-timer", + "glob", + "http", + "mime", + "mime_guess", + "nanoid", + "ordered-float", + "pin-project-lite", + "reqwest", + "schemars 1.2.1", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tracing", + "tracing-futures", + "url", +] + [[package]] name = "ring" version = "0.17.14" @@ -3572,10 +3805,10 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" dependencies = [ - "openssl-probe", + "openssl-probe 0.2.1", "rustls-pki-types", "schannel", - "security-framework", + "security-framework 3.5.1", ] [[package]] @@ -3692,10 +3925,23 @@ checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" dependencies = [ "dyn-clone", "ref-cast", + "schemars_derive", "serde", "serde_json", ] +[[package]] +name = "schemars_derive" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.114", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -3737,6 +3983,19 @@ dependencies = [ "zbus", ] +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.10.0", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + [[package]] name = "security-framework" version = "3.5.1" @@ -3744,7 +4003,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" dependencies = [ "bitflags 2.10.0", - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -3800,6 +4059,17 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "serde_json" version = "1.0.149" @@ -4113,6 +4383,27 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.10.0", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "system-interface" version = "0.27.3" @@ -4351,6 +4642,16 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + [[package]] name = "tokio-postgres" version = "0.7.16" @@ -4613,6 +4914,18 @@ dependencies = [ "valuable", ] +[[package]] +name = "tracing-futures" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97d095ae15e245a057c8e8451bab9b3ee1e1f68e9ba2b4fbc18d0ac5237835f2" +dependencies = [ + "futures", + "futures-task", + "pin-project", + "tracing", +] + [[package]] name = "tracing-log" version = "0.2.0" @@ -4840,6 +5153,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" @@ -5514,6 +5833,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + [[package]] name = "windows-result" version = "0.4.1" diff --git a/Cargo.toml b/Cargo.toml index 26a449fd..c8e3a4f4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -93,6 +93,9 @@ sha2 = "0.10" blake3 = "1" rand = "0.8" +# Multi-provider LLM support +rig-core = "0.30" + # Docker sandbox bollard = "0.18" diff --git a/src/agent/agent_loop.rs b/src/agent/agent_loop.rs index 462535e9..0a1d2b09 100644 --- a/src/agent/agent_loop.rs +++ b/src/agent/agent_loop.rs @@ -1049,7 +1049,7 @@ impl Agent { iteration += 1; if iteration > MAX_TOOL_ITERATIONS { return Err(crate::error::LlmError::InvalidResponse { - provider: "nearai".to_string(), + provider: "agent".to_string(), reason: format!("Exceeded maximum tool iterations ({})", MAX_TOOL_ITERATIONS), } .into()); diff --git a/src/config.rs b/src/config.rs index a1f93a8d..20ef580e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -172,10 +172,102 @@ impl DatabaseConfig { } } -/// LLM provider configuration (NEAR AI only). +/// Which LLM backend to use. +/// +/// Defaults to `NearAi` to keep IronClaw close to the NEAR ecosystem. +/// Users can override with `LLM_BACKEND` env var to use their own API keys. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum LlmBackend { + /// NEAR AI proxy (default) -- session or API key auth + #[default] + NearAi, + /// Direct OpenAI API + OpenAi, + /// Direct Anthropic API + Anthropic, + /// Local Ollama instance + Ollama, + /// Any OpenAI-compatible endpoint (e.g. vLLM, LiteLLM, Together) + OpenAiCompatible, +} + +impl std::str::FromStr for LlmBackend { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "nearai" | "near_ai" | "near" => Ok(Self::NearAi), + "openai" | "open_ai" => Ok(Self::OpenAi), + "anthropic" | "claude" => Ok(Self::Anthropic), + "ollama" => Ok(Self::Ollama), + "openai_compatible" | "openai-compatible" | "compatible" => Ok(Self::OpenAiCompatible), + _ => Err(format!( + "invalid LLM backend '{}', expected one of: nearai, openai, anthropic, ollama, openai_compatible", + s + )), + } + } +} + +impl std::fmt::Display for LlmBackend { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::NearAi => write!(f, "nearai"), + Self::OpenAi => write!(f, "openai"), + Self::Anthropic => write!(f, "anthropic"), + Self::Ollama => write!(f, "ollama"), + Self::OpenAiCompatible => write!(f, "openai_compatible"), + } + } +} + +/// Configuration for direct OpenAI API access. +#[derive(Debug, Clone)] +pub struct OpenAiDirectConfig { + pub api_key: SecretString, + pub model: String, +} + +/// Configuration for direct Anthropic API access. +#[derive(Debug, Clone)] +pub struct AnthropicDirectConfig { + pub api_key: SecretString, + pub model: String, +} + +/// Configuration for local Ollama. +#[derive(Debug, Clone)] +pub struct OllamaConfig { + pub base_url: String, + pub model: String, +} + +/// Configuration for any OpenAI-compatible endpoint. +#[derive(Debug, Clone)] +pub struct OpenAiCompatibleConfig { + pub base_url: String, + pub api_key: Option, + pub model: String, +} + +/// LLM provider configuration. +/// +/// NEAR AI remains the default backend. Users can switch to other providers +/// by setting `LLM_BACKEND` (e.g. `openai`, `anthropic`, `ollama`). #[derive(Debug, Clone)] pub struct LlmConfig { + /// Which backend to use (default: NearAi) + pub backend: LlmBackend, + /// NEAR AI config (always populated for NEAR AI embeddings, etc.) pub nearai: NearAiConfig, + /// Direct OpenAI config (populated when backend=openai) + pub openai: Option, + /// Direct Anthropic config (populated when backend=anthropic) + pub anthropic: Option, + /// Ollama config (populated when backend=ollama) + pub ollama: Option, + /// OpenAI-compatible config (populated when backend=openai_compatible) + pub openai_compatible: Option, } /// API mode for NEAR AI. @@ -224,37 +316,109 @@ pub struct NearAiConfig { impl LlmConfig { fn resolve(settings: &Settings) -> Result { - let api_key = optional_env("NEARAI_API_KEY")?.map(SecretString::from); + // Determine backend (default: NearAi) + let backend: LlmBackend = if let Some(b) = optional_env("LLM_BACKEND")? { + b.parse().map_err(|e| ConfigError::InvalidValue { + key: "LLM_BACKEND".to_string(), + message: e, + })? + } else { + LlmBackend::NearAi + }; + + // Always resolve NEAR AI config (used as fallback and for embeddings) + let nearai_api_key = optional_env("NEARAI_API_KEY")?.map(SecretString::from); let api_mode = if let Some(mode_str) = optional_env("NEARAI_API_MODE")? { mode_str.parse().map_err(|e| ConfigError::InvalidValue { key: "NEARAI_API_MODE".to_string(), message: e, })? - } else if api_key.is_some() { + } else if nearai_api_key.is_some() { NearAiApiMode::ChatCompletions } else { NearAiApiMode::Responses }; - Ok(Self { - nearai: NearAiConfig { - model: optional_env("NEARAI_MODEL")? - .or_else(|| settings.selected_model.clone()) - .unwrap_or_else(|| { - "fireworks::accounts/fireworks/models/llama4-maverick-instruct-basic" - .to_string() - }), - base_url: optional_env("NEARAI_BASE_URL")? - .unwrap_or_else(|| "https://cloud-api.near.ai".to_string()), - auth_base_url: optional_env("NEARAI_AUTH_URL")? - .unwrap_or_else(|| "https://private.near.ai".to_string()), - session_path: optional_env("NEARAI_SESSION_PATH")? - .map(PathBuf::from) - .unwrap_or_else(default_session_path), - api_mode, + let nearai = NearAiConfig { + model: optional_env("NEARAI_MODEL")? + .or_else(|| settings.selected_model.clone()) + .unwrap_or_else(|| { + "fireworks::accounts/fireworks/models/llama4-maverick-instruct-basic" + .to_string() + }), + base_url: optional_env("NEARAI_BASE_URL")? + .unwrap_or_else(|| "https://cloud-api.near.ai".to_string()), + auth_base_url: optional_env("NEARAI_AUTH_URL")? + .unwrap_or_else(|| "https://private.near.ai".to_string()), + session_path: optional_env("NEARAI_SESSION_PATH")? + .map(PathBuf::from) + .unwrap_or_else(default_session_path), + api_mode, + api_key: nearai_api_key, + }; + + // Resolve provider-specific configs based on backend + let openai = if backend == LlmBackend::OpenAi { + let api_key = optional_env("OPENAI_API_KEY")? + .map(SecretString::from) + .ok_or_else(|| ConfigError::MissingRequired { + key: "OPENAI_API_KEY".to_string(), + hint: "Set OPENAI_API_KEY when LLM_BACKEND=openai".to_string(), + })?; + let model = optional_env("OPENAI_MODEL")?.unwrap_or_else(|| "gpt-4o".to_string()); + Some(OpenAiDirectConfig { api_key, model }) + } else { + None + }; + + let anthropic = if backend == LlmBackend::Anthropic { + let api_key = optional_env("ANTHROPIC_API_KEY")? + .map(SecretString::from) + .ok_or_else(|| ConfigError::MissingRequired { + key: "ANTHROPIC_API_KEY".to_string(), + hint: "Set ANTHROPIC_API_KEY when LLM_BACKEND=anthropic".to_string(), + })?; + let model = optional_env("ANTHROPIC_MODEL")? + .unwrap_or_else(|| "claude-sonnet-4-20250514".to_string()); + Some(AnthropicDirectConfig { api_key, model }) + } else { + None + }; + + let ollama = if backend == LlmBackend::Ollama { + let base_url = optional_env("OLLAMA_BASE_URL")? + .unwrap_or_else(|| "http://localhost:11434".to_string()); + let model = optional_env("OLLAMA_MODEL")?.unwrap_or_else(|| "llama3".to_string()); + Some(OllamaConfig { base_url, model }) + } else { + None + }; + + let openai_compatible = if backend == LlmBackend::OpenAiCompatible { + let base_url = + optional_env("LLM_BASE_URL")?.ok_or_else(|| ConfigError::MissingRequired { + key: "LLM_BASE_URL".to_string(), + hint: "Set LLM_BASE_URL when LLM_BACKEND=openai_compatible".to_string(), + })?; + let api_key = optional_env("LLM_API_KEY")?.map(SecretString::from); + let model = optional_env("LLM_MODEL")?.unwrap_or_else(|| "default".to_string()); + Some(OpenAiCompatibleConfig { + base_url, api_key, - }, + model, + }) + } else { + None + }; + + Ok(Self { + backend, + nearai, + openai, + anthropic, + ollama, + openai_compatible, }) } } diff --git a/src/llm/costs.rs b/src/llm/costs.rs new file mode 100644 index 00000000..89c5b529 --- /dev/null +++ b/src/llm/costs.rs @@ -0,0 +1,124 @@ +//! Per-model cost lookup table for multi-provider LLM support. +//! +//! Returns (input_cost_per_token, output_cost_per_token) as Decimal pairs. +//! Ollama and other local models return zero cost. + +use rust_decimal::Decimal; +use rust_decimal_macros::dec; + +/// Look up known per-token costs for a model by its identifier. +/// +/// Returns `Some((input_cost, output_cost))` for known models, `None` otherwise. +pub fn model_cost(model_id: &str) -> Option<(Decimal, Decimal)> { + // Normalize: strip provider prefixes (e.g., "openai/gpt-4o" -> "gpt-4o") + let id = model_id + .rsplit_once('/') + .map(|(_, name)| name) + .unwrap_or(model_id); + + match id { + // OpenAI models -- prices per token (USD) + "gpt-4o" | "gpt-4o-2024-11-20" | "gpt-4o-2024-08-06" => { + Some((dec!(0.0000025), dec!(0.00001))) + } + "gpt-4o-mini" | "gpt-4o-mini-2024-07-18" => Some((dec!(0.00000015), dec!(0.0000006))), + "gpt-4-turbo" | "gpt-4-turbo-2024-04-09" => Some((dec!(0.00001), dec!(0.00003))), + "gpt-4" | "gpt-4-0613" => Some((dec!(0.00003), dec!(0.00006))), + "gpt-3.5-turbo" | "gpt-3.5-turbo-0125" => Some((dec!(0.0000005), dec!(0.0000015))), + "o1" | "o1-2024-12-17" => Some((dec!(0.000015), dec!(0.00006))), + "o1-mini" | "o1-mini-2024-09-12" => Some((dec!(0.000003), dec!(0.000012))), + "o3-mini" | "o3-mini-2025-01-31" => Some((dec!(0.0000011), dec!(0.0000044))), + + // Anthropic models + "claude-3-5-sonnet-20241022" | "claude-3-5-sonnet-latest" | "claude-sonnet-4-20250514" => { + Some((dec!(0.000003), dec!(0.000015))) + } + "claude-3-5-haiku-20241022" | "claude-3-5-haiku-latest" => { + Some((dec!(0.0000008), dec!(0.000004))) + } + "claude-3-opus-20240229" | "claude-3-opus-latest" | "claude-opus-4-20250514" => { + Some((dec!(0.000015), dec!(0.000075))) + } + "claude-3-haiku-20240307" => Some((dec!(0.00000025), dec!(0.00000125))), + + // Ollama / local models -- free + _ if is_local_model(id) => Some((Decimal::ZERO, Decimal::ZERO)), + + _ => None, + } +} + +/// Default cost for unknown models. +pub fn default_cost() -> (Decimal, Decimal) { + // Conservative estimate: roughly GPT-4o pricing + (dec!(0.0000025), dec!(0.00001)) +} + +/// Heuristic to detect local/self-hosted models (Ollama, llama.cpp, etc.). +fn is_local_model(model_id: &str) -> bool { + let lower = model_id.to_lowercase(); + lower.starts_with("llama") + || lower.starts_with("mistral") + || lower.starts_with("mixtral") + || lower.starts_with("phi") + || lower.starts_with("gemma") + || lower.starts_with("qwen") + || lower.starts_with("codellama") + || lower.starts_with("deepseek") + || lower.starts_with("starcoder") + || lower.starts_with("vicuna") + || lower.starts_with("yi") + || lower.contains(":latest") + || lower.contains(":instruct") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_known_model_costs() { + let (input, output) = model_cost("gpt-4o").unwrap(); + assert!(input > Decimal::ZERO); + assert!(output > input); + } + + #[test] + fn test_claude_costs() { + let (input, output) = model_cost("claude-3-5-sonnet-20241022").unwrap(); + assert!(input > Decimal::ZERO); + assert!(output > input); + } + + #[test] + fn test_local_model_free() { + let (input, output) = model_cost("llama3").unwrap(); + assert_eq!(input, Decimal::ZERO); + assert_eq!(output, Decimal::ZERO); + } + + #[test] + fn test_ollama_tagged_model_free() { + let (input, output) = model_cost("mistral:latest").unwrap(); + assert_eq!(input, Decimal::ZERO); + assert_eq!(output, Decimal::ZERO); + } + + #[test] + fn test_unknown_model_returns_none() { + assert!(model_cost("some-totally-unknown-model-xyz").is_none()); + } + + #[test] + fn test_default_cost_nonzero() { + let (input, output) = default_cost(); + assert!(input > Decimal::ZERO); + assert!(output > Decimal::ZERO); + } + + #[test] + fn test_provider_prefix_stripped() { + // "openai/gpt-4o" should resolve to same as "gpt-4o" + assert_eq!(model_cost("openai/gpt-4o"), model_cost("gpt-4o")); + } +} diff --git a/src/llm/mod.rs b/src/llm/mod.rs index ee6063ad..1a3b7a47 100644 --- a/src/llm/mod.rs +++ b/src/llm/mod.rs @@ -1,13 +1,18 @@ //! LLM integration for the agent. //! -//! Supports two API modes: -//! - **Responses API** (chat-api): Session-based auth, uses `/v1/responses` endpoint -//! - **Chat Completions API** (cloud-api): API key auth, uses `/v1/chat/completions` endpoint +//! Supports multiple backends: +//! - **NEAR AI** (default): Session-based or API key auth via NEAR AI proxy +//! - **OpenAI**: Direct API access with your own key +//! - **Anthropic**: Direct API access with your own key +//! - **Ollama**: Local model inference +//! - **OpenAI-compatible**: Any endpoint that speaks the OpenAI API +mod costs; mod nearai; mod nearai_chat; mod provider; mod reasoning; +mod rig_adapter; pub mod session; pub use nearai::{ModelInfo, NearAiProvider}; @@ -17,32 +22,151 @@ pub use provider::{ Role, ToolCall, ToolCompletionRequest, ToolCompletionResponse, ToolDefinition, ToolResult, }; pub use reasoning::{ActionPlan, Reasoning, ReasoningContext, RespondResult, ToolSelection}; +pub use rig_adapter::RigAdapter; pub use session::{SessionConfig, SessionManager, create_session_manager}; use std::sync::Arc; -use crate::config::{LlmConfig, NearAiApiMode}; +use rig::client::CompletionClient; +use secrecy::ExposeSecret; + +use crate::config::{LlmBackend, LlmConfig, NearAiApiMode}; use crate::error::LlmError; /// Create an LLM provider based on configuration. /// -/// - For `Responses` mode: Requires a session manager for authentication -/// - For `ChatCompletions` mode: Uses API key from config (session not needed) +/// - `NearAi` backend: Uses session manager for authentication (Responses API) +/// or API key (Chat Completions API) +/// - Other backends: Use rig-core adapter with provider-specific clients pub fn create_llm_provider( config: &LlmConfig, session: Arc, +) -> Result, LlmError> { + match config.backend { + LlmBackend::NearAi => create_nearai_provider(config, session), + LlmBackend::OpenAi => create_openai_provider(config), + LlmBackend::Anthropic => create_anthropic_provider(config), + LlmBackend::Ollama => create_ollama_provider(config), + LlmBackend::OpenAiCompatible => create_openai_compatible_provider(config), + } +} + +fn create_nearai_provider( + config: &LlmConfig, + session: Arc, ) -> Result, LlmError> { match config.nearai.api_mode { NearAiApiMode::Responses => { - tracing::info!("Using Responses API (chat-api) with session auth"); + tracing::info!("Using NEAR AI Responses API (chat-api) with session auth"); Ok(Arc::new(NearAiProvider::new( config.nearai.clone(), session, ))) } NearAiApiMode::ChatCompletions => { - tracing::info!("Using Chat Completions API (cloud-api) with API key auth"); + tracing::info!("Using NEAR AI Chat Completions API (cloud-api) with API key auth"); Ok(Arc::new(NearAiChatProvider::new(config.nearai.clone())?)) } } } + +fn create_openai_provider(config: &LlmConfig) -> Result, LlmError> { + let oai = config.openai.as_ref().ok_or_else(|| LlmError::AuthFailed { + provider: "openai".to_string(), + })?; + + use rig::providers::openai; + + let client: openai::Client = + openai::Client::new(oai.api_key.expose_secret()).map_err(|e| LlmError::RequestFailed { + provider: "openai".to_string(), + reason: format!("Failed to create OpenAI client: {}", e), + })?; + + let model = client.completion_model(&oai.model); + tracing::info!("Using OpenAI direct API (model: {})", oai.model); + Ok(Arc::new(RigAdapter::new(model, &oai.model))) +} + +fn create_anthropic_provider(config: &LlmConfig) -> Result, LlmError> { + let anth = config + .anthropic + .as_ref() + .ok_or_else(|| LlmError::AuthFailed { + provider: "anthropic".to_string(), + })?; + + use rig::providers::anthropic; + + let client: anthropic::Client = + anthropic::Client::new(anth.api_key.expose_secret()).map_err(|e| { + LlmError::RequestFailed { + provider: "anthropic".to_string(), + reason: format!("Failed to create Anthropic client: {}", e), + } + })?; + + let model = client.completion_model(&anth.model); + tracing::info!("Using Anthropic direct API (model: {})", anth.model); + Ok(Arc::new(RigAdapter::new(model, &anth.model))) +} + +fn create_ollama_provider(config: &LlmConfig) -> Result, LlmError> { + let oll = config.ollama.as_ref().ok_or_else(|| LlmError::AuthFailed { + provider: "ollama".to_string(), + })?; + + use rig::client::Nothing; + use rig::providers::ollama; + + let client: ollama::Client = ollama::Client::builder() + .base_url(&oll.base_url) + .api_key(Nothing) + .build() + .map_err(|e| LlmError::RequestFailed { + provider: "ollama".to_string(), + reason: format!("Failed to create Ollama client: {}", e), + })?; + + let model = client.completion_model(&oll.model); + tracing::info!( + "Using Ollama (base_url: {}, model: {})", + oll.base_url, + oll.model + ); + Ok(Arc::new(RigAdapter::new(model, &oll.model))) +} + +fn create_openai_compatible_provider(config: &LlmConfig) -> Result, LlmError> { + let compat = config + .openai_compatible + .as_ref() + .ok_or_else(|| LlmError::AuthFailed { + provider: "openai_compatible".to_string(), + })?; + + use rig::providers::openai; + + let api_key = compat + .api_key + .as_ref() + .map(|k| k.expose_secret().to_string()) + .unwrap_or_else(|| "no-key".to_string()); + + let client: openai::Client = openai::Client::builder() + .base_url(&compat.base_url) + .api_key(api_key) + .build() + .map_err(|e| LlmError::RequestFailed { + provider: "openai_compatible".to_string(), + reason: format!("Failed to create OpenAI-compatible client: {}", e), + })?; + + let model = client.completion_model(&compat.model); + tracing::info!( + "Using OpenAI-compatible endpoint (base_url: {}, model: {})", + compat.base_url, + compat.model + ); + Ok(Arc::new(RigAdapter::new(model, &compat.model))) +} diff --git a/src/llm/rig_adapter.rs b/src/llm/rig_adapter.rs new file mode 100644 index 00000000..642cc895 --- /dev/null +++ b/src/llm/rig_adapter.rs @@ -0,0 +1,451 @@ +//! Generic adapter that bridges rig-core's `CompletionModel` trait to IronClaw's `LlmProvider`. +//! +//! This lets us use any rig-core provider (OpenAI, Anthropic, Ollama, etc.) as an +//! `Arc` without changing any of the agent, reasoning, or tool code. + +use async_trait::async_trait; +use rig::OneOrMany; +use rig::completion::{ + AssistantContent, CompletionModel, CompletionRequest as RigRequest, + ToolDefinition as RigToolDefinition, Usage as RigUsage, +}; +use rig::message::{ + Message as RigMessage, ToolChoice as RigToolChoice, ToolFunction, ToolResult as RigToolResult, + ToolResultContent, UserContent, +}; +use rust_decimal::Decimal; +use serde::Serialize; +use serde::de::DeserializeOwned; + +use crate::error::LlmError; +use crate::llm::costs; +use crate::llm::provider::{ + ChatMessage, CompletionRequest, CompletionResponse, FinishReason, LlmProvider, + ToolCall as IronToolCall, ToolCompletionRequest, ToolCompletionResponse, + ToolDefinition as IronToolDefinition, +}; + +/// Adapter that wraps a rig-core `CompletionModel` and implements `LlmProvider`. +pub struct RigAdapter { + model: M, + model_name: String, + input_cost: Decimal, + output_cost: Decimal, +} + +impl RigAdapter { + /// Create a new adapter wrapping the given rig-core model. + pub fn new(model: M, model_name: impl Into) -> Self { + let name = model_name.into(); + let (input_cost, output_cost) = + costs::model_cost(&name).unwrap_or_else(costs::default_cost); + Self { + model, + model_name: name, + input_cost, + output_cost, + } + } +} + +// -- Type conversion helpers -- + +/// Convert IronClaw messages to rig-core format. +/// +/// Returns `(preamble, chat_history)` where preamble is extracted from +/// any System message and chat_history contains the rest. +fn convert_messages(messages: &[ChatMessage]) -> (Option, Vec) { + let mut preamble: Option = None; + let mut history = Vec::new(); + + for msg in messages { + match msg.role { + crate::llm::Role::System => { + // Concatenate system messages into preamble + match preamble { + Some(ref mut p) => { + p.push('\n'); + p.push_str(&msg.content); + } + None => preamble = Some(msg.content.clone()), + } + } + crate::llm::Role::User => { + history.push(RigMessage::user(&msg.content)); + } + crate::llm::Role::Assistant => { + if let Some(ref tool_calls) = msg.tool_calls { + // Assistant message with tool calls + let mut contents: Vec = Vec::new(); + if !msg.content.is_empty() { + contents.push(AssistantContent::text(&msg.content)); + } + for tc in tool_calls { + contents.push(AssistantContent::ToolCall(rig::message::ToolCall::new( + tc.id.clone(), + ToolFunction::new(tc.name.clone(), tc.arguments.clone()), + ))); + } + if let Ok(many) = OneOrMany::many(contents) { + history.push(RigMessage::Assistant { + id: None, + content: many, + }); + } else { + // Shouldn't happen but fall back to text + history.push(RigMessage::assistant(&msg.content)); + } + } else { + history.push(RigMessage::assistant(&msg.content)); + } + } + crate::llm::Role::Tool => { + // Tool result message: wrap as User { ToolResult } + let tool_id = msg.tool_call_id.clone().unwrap_or_default(); + history.push(RigMessage::User { + content: OneOrMany::one(UserContent::ToolResult(RigToolResult { + id: tool_id, + call_id: None, + content: OneOrMany::one(ToolResultContent::text(&msg.content)), + })), + }); + } + } + } + + (preamble, history) +} + +/// Convert IronClaw tool definitions to rig-core format. +fn convert_tools(tools: &[IronToolDefinition]) -> Vec { + tools + .iter() + .map(|t| RigToolDefinition { + name: t.name.clone(), + description: t.description.clone(), + parameters: t.parameters.clone(), + }) + .collect() +} + +/// Convert IronClaw tool_choice string to rig-core ToolChoice. +fn convert_tool_choice(choice: Option<&str>) -> Option { + match choice.map(|s| s.to_lowercase()).as_deref() { + Some("auto") => Some(RigToolChoice::Auto), + Some("required") => Some(RigToolChoice::Required), + Some("none") => Some(RigToolChoice::None), + _ => None, + } +} + +/// Extract text and tool calls from a rig-core completion response. +fn extract_response( + choice: &OneOrMany, + _usage: &RigUsage, +) -> (Option, Vec, FinishReason) { + let mut text_parts: Vec = Vec::new(); + let mut tool_calls: Vec = Vec::new(); + + for content in choice.iter() { + match content { + AssistantContent::Text(t) => { + if !t.text.is_empty() { + text_parts.push(t.text.clone()); + } + } + AssistantContent::ToolCall(tc) => { + tool_calls.push(IronToolCall { + id: tc.id.clone(), + name: tc.function.name.clone(), + arguments: tc.function.arguments.clone(), + }); + } + // Reasoning and Image variants are not mapped to IronClaw types + _ => {} + } + } + + let text = if text_parts.is_empty() { + None + } else { + Some(text_parts.join("")) + }; + + let finish = if !tool_calls.is_empty() { + FinishReason::ToolUse + } else { + FinishReason::Stop + }; + + (text, tool_calls, finish) +} + +/// Saturate u64 to u32 for token counts. +fn saturate_u32(val: u64) -> u32 { + val.min(u32::MAX as u64) as u32 +} + +/// Build a rig-core CompletionRequest from our internal types. +fn build_rig_request( + preamble: Option, + mut history: Vec, + tools: Vec, + tool_choice: Option, + temperature: Option, + max_tokens: Option, +) -> Result { + // rig-core requires at least one message in chat_history + if history.is_empty() { + history.push(RigMessage::user("Hello")); + } + + let chat_history = OneOrMany::many(history).map_err(|e| LlmError::RequestFailed { + provider: "rig".to_string(), + reason: format!("Failed to build chat history: {}", e), + })?; + + Ok(RigRequest { + preamble, + chat_history, + documents: Vec::new(), + tools, + temperature: temperature.map(|t| t as f64), + max_tokens: max_tokens.map(|t| t as u64), + tool_choice, + additional_params: None, + }) +} + +#[async_trait] +impl LlmProvider for RigAdapter +where + M: CompletionModel + Send + Sync + 'static, + M::Response: Send + Sync + Serialize + DeserializeOwned, +{ + fn model_name(&self) -> &str { + &self.model_name + } + + fn cost_per_token(&self) -> (Decimal, Decimal) { + (self.input_cost, self.output_cost) + } + + async fn complete(&self, request: CompletionRequest) -> Result { + let (preamble, history) = convert_messages(&request.messages); + + let rig_req = build_rig_request( + preamble, + history, + Vec::new(), + None, + request.temperature, + request.max_tokens, + )?; + + let response = + self.model + .completion(rig_req) + .await + .map_err(|e| LlmError::RequestFailed { + provider: self.model_name.clone(), + reason: e.to_string(), + })?; + + let (text, _tool_calls, finish) = extract_response(&response.choice, &response.usage); + + Ok(CompletionResponse { + content: text.unwrap_or_default(), + input_tokens: saturate_u32(response.usage.input_tokens), + output_tokens: saturate_u32(response.usage.output_tokens), + finish_reason: finish, + response_id: None, + }) + } + + async fn complete_with_tools( + &self, + request: ToolCompletionRequest, + ) -> Result { + let (preamble, history) = convert_messages(&request.messages); + let tools = convert_tools(&request.tools); + let tool_choice = convert_tool_choice(request.tool_choice.as_deref()); + + let rig_req = build_rig_request( + preamble, + history, + tools, + tool_choice, + request.temperature, + request.max_tokens, + )?; + + let response = + self.model + .completion(rig_req) + .await + .map_err(|e| LlmError::RequestFailed { + provider: self.model_name.clone(), + reason: e.to_string(), + })?; + + let (text, tool_calls, finish) = extract_response(&response.choice, &response.usage); + + Ok(ToolCompletionResponse { + content: text, + tool_calls, + input_tokens: saturate_u32(response.usage.input_tokens), + output_tokens: saturate_u32(response.usage.output_tokens), + finish_reason: finish, + response_id: None, + }) + } + + fn active_model_name(&self) -> String { + self.model_name.clone() + } + + fn set_model(&self, _model: &str) -> Result<(), LlmError> { + // rig-core models are baked at construction time. + // Switching requires creating a new adapter. + Err(LlmError::RequestFailed { + provider: self.model_name.clone(), + reason: "Runtime model switching not supported for rig-core providers. \ + Restart with a different model configured." + .to_string(), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_convert_messages_system_to_preamble() { + let messages = vec![ + ChatMessage::system("You are a helpful assistant."), + ChatMessage::user("Hello"), + ]; + let (preamble, history) = convert_messages(&messages); + assert_eq!(preamble, Some("You are a helpful assistant.".to_string())); + assert_eq!(history.len(), 1); + } + + #[test] + fn test_convert_messages_multiple_systems_concatenated() { + let messages = vec![ + ChatMessage::system("System 1"), + ChatMessage::system("System 2"), + ChatMessage::user("Hi"), + ]; + let (preamble, history) = convert_messages(&messages); + assert_eq!(preamble, Some("System 1\nSystem 2".to_string())); + assert_eq!(history.len(), 1); + } + + #[test] + fn test_convert_messages_tool_result() { + let messages = vec![ChatMessage::tool_result( + "call_123", + "search", + "result text", + )]; + let (preamble, history) = convert_messages(&messages); + assert!(preamble.is_none()); + assert_eq!(history.len(), 1); + // Tool results become User messages in rig-core + match &history[0] { + RigMessage::User { .. } => {} + other => panic!("Expected User message, got: {:?}", other), + } + } + + #[test] + fn test_convert_messages_assistant_with_tool_calls() { + let tc = IronToolCall { + id: "call_1".to_string(), + name: "search".to_string(), + arguments: serde_json::json!({"query": "test"}), + }; + let msg = ChatMessage::assistant_with_tool_calls(Some("thinking".to_string()), vec![tc]); + let messages = vec![msg]; + let (_preamble, history) = convert_messages(&messages); + assert_eq!(history.len(), 1); + match &history[0] { + RigMessage::Assistant { content, .. } => { + // Should have both text and tool call + assert!(content.iter().count() >= 2); + } + other => panic!("Expected Assistant message, got: {:?}", other), + } + } + + #[test] + fn test_convert_tools() { + let tools = vec![IronToolDefinition { + name: "search".to_string(), + description: "Search the web".to_string(), + parameters: serde_json::json!({ + "type": "object", + "properties": { + "query": {"type": "string"} + } + }), + }]; + let rig_tools = convert_tools(&tools); + assert_eq!(rig_tools.len(), 1); + assert_eq!(rig_tools[0].name, "search"); + assert_eq!(rig_tools[0].description, "Search the web"); + } + + #[test] + fn test_convert_tool_choice() { + assert!(matches!( + convert_tool_choice(Some("auto")), + Some(RigToolChoice::Auto) + )); + assert!(matches!( + convert_tool_choice(Some("required")), + Some(RigToolChoice::Required) + )); + assert!(matches!( + convert_tool_choice(Some("none")), + Some(RigToolChoice::None) + )); + assert!(matches!( + convert_tool_choice(Some("AUTO")), + Some(RigToolChoice::Auto) + )); + assert!(convert_tool_choice(None).is_none()); + assert!(convert_tool_choice(Some("unknown")).is_none()); + } + + #[test] + fn test_extract_response_text_only() { + let content = OneOrMany::one(AssistantContent::text("Hello world")); + let usage = RigUsage::new(); + let (text, calls, finish) = extract_response(&content, &usage); + assert_eq!(text, Some("Hello world".to_string())); + assert!(calls.is_empty()); + assert_eq!(finish, FinishReason::Stop); + } + + #[test] + fn test_extract_response_tool_call() { + let tc = AssistantContent::tool_call("call_1", "search", serde_json::json!({"q": "test"})); + let content = OneOrMany::one(tc); + let usage = RigUsage::new(); + let (text, calls, finish) = extract_response(&content, &usage); + assert!(text.is_none()); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].name, "search"); + assert_eq!(finish, FinishReason::ToolUse); + } + + #[test] + fn test_saturate_u32() { + assert_eq!(saturate_u32(100), 100); + assert_eq!(saturate_u32(u64::MAX), u32::MAX); + assert_eq!(saturate_u32(u32::MAX as u64), u32::MAX); + } +} diff --git a/src/main.rs b/src/main.rs index de53c90a..8dcd69ed 100644 --- a/src/main.rs +++ b/src/main.rs @@ -274,8 +274,10 @@ async fn main() -> anyhow::Result<()> { }; let session = create_session_manager(session_config).await; - // Ensure we're authenticated before proceeding (may trigger login flow) - session.ensure_authenticated().await?; + // Ensure we're authenticated before proceeding (only needed for NEAR AI backend) + if config.llm.backend == ironclaw::config::LlmBackend::NearAi { + session.ensure_authenticated().await?; + } // Initialize tracing let env_filter = EnvFilter::try_from_default_env() @@ -302,7 +304,7 @@ async fn main() -> anyhow::Result<()> { tracing::info!("Starting IronClaw..."); tracing::info!("Loaded configuration for agent: {}", config.agent.name); - tracing::info!("NEAR AI session authenticated"); + tracing::info!("LLM backend: {}", config.llm.backend); // Initialize database store (optional for testing) let store = if cli.no_db { diff --git a/src/setup/wizard.rs b/src/setup/wizard.rs index db447369..021938c5 100644 --- a/src/setup/wizard.rs +++ b/src/setup/wizard.rs @@ -455,6 +455,7 @@ impl SetupWizard { .unwrap_or_else(|_| "https://private.near.ai".to_string()); let config = LlmConfig { + backend: crate::config::LlmBackend::NearAi, nearai: crate::config::NearAiConfig { model: "dummy".to_string(), base_url, @@ -463,6 +464,10 @@ impl SetupWizard { api_mode: crate::config::NearAiApiMode::Responses, api_key: None, }, + openai: None, + anthropic: None, + ollama: None, + openai_compatible: None, }; match create_llm_provider(&config, Arc::clone(session)) {