mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-26 07:30:11 +00:00
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 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
45f547c711
commit
bb228f6315
@@ -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());
|
||||
|
||||
+184
-20
@@ -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<Self, Self::Err> {
|
||||
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<SecretString>,
|
||||
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<OpenAiDirectConfig>,
|
||||
/// Direct Anthropic config (populated when backend=anthropic)
|
||||
pub anthropic: Option<AnthropicDirectConfig>,
|
||||
/// Ollama config (populated when backend=ollama)
|
||||
pub ollama: Option<OllamaConfig>,
|
||||
/// OpenAI-compatible config (populated when backend=openai_compatible)
|
||||
pub openai_compatible: Option<OpenAiCompatibleConfig>,
|
||||
}
|
||||
|
||||
/// API mode for NEAR AI.
|
||||
@@ -224,37 +316,109 @@ pub struct NearAiConfig {
|
||||
|
||||
impl LlmConfig {
|
||||
fn resolve(settings: &Settings) -> Result<Self, ConfigError> {
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
+132
-8
@@ -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<SessionManager>,
|
||||
) -> Result<Arc<dyn LlmProvider>, 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<SessionManager>,
|
||||
) -> Result<Arc<dyn LlmProvider>, 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<Arc<dyn LlmProvider>, 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<Arc<dyn LlmProvider>, 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<Arc<dyn LlmProvider>, 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<Arc<dyn LlmProvider>, 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)))
|
||||
}
|
||||
|
||||
@@ -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<dyn LlmProvider>` 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<M: CompletionModel> {
|
||||
model: M,
|
||||
model_name: String,
|
||||
input_cost: Decimal,
|
||||
output_cost: Decimal,
|
||||
}
|
||||
|
||||
impl<M: CompletionModel> RigAdapter<M> {
|
||||
/// Create a new adapter wrapping the given rig-core model.
|
||||
pub fn new(model: M, model_name: impl Into<String>) -> 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<String>, Vec<RigMessage>) {
|
||||
let mut preamble: Option<String> = 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<AssistantContent> = 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<RigToolDefinition> {
|
||||
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<RigToolChoice> {
|
||||
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<AssistantContent>,
|
||||
_usage: &RigUsage,
|
||||
) -> (Option<String>, Vec<IronToolCall>, FinishReason) {
|
||||
let mut text_parts: Vec<String> = Vec::new();
|
||||
let mut tool_calls: Vec<IronToolCall> = 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<String>,
|
||||
mut history: Vec<RigMessage>,
|
||||
tools: Vec<RigToolDefinition>,
|
||||
tool_choice: Option<RigToolChoice>,
|
||||
temperature: Option<f32>,
|
||||
max_tokens: Option<u32>,
|
||||
) -> Result<RigRequest, LlmError> {
|
||||
// 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<M> LlmProvider for RigAdapter<M>
|
||||
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<CompletionResponse, LlmError> {
|
||||
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<ToolCompletionResponse, LlmError> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
+5
-3
@@ -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 {
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user