Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,20 @@
DATABASE_URL=postgres://localhost/ironclaw
DATABASE_POOL_SIZE=10

# LLM Provider (NEAR AI)
# NEAR AI provides a unified interface to all models with user authentication
# Session token is stored in ~/.ironclaw/session.json and managed automatically.
# LLM Provider (NEAR AI - default)
# NEAR AI provides a unified interface to all models with user authentication.
# On first run, the agent will open a browser for OAuth authentication.
NEARAI_MODEL=claude-3-5-sonnet-20241022
NEARAI_BASE_URL=https://cloud-api.near.ai
Comment thread
lwlee2608 marked this conversation as resolved.
NEARAI_AUTH_URL=https://private.near.ai
# NEARAI_SESSION_PATH=~/.ironclaw/session.json # optional, default shown

# LLM Provider (OpenRouter - alternative)
# Set LLM_BACKEND=openrouter to use OpenRouter instead.
# Get your key at https://openrouter.ai/keys
# OPENROUTER_API_KEY=sk-or-...
# OPENROUTER_MODEL=anthropic/claude-sonnet-4 # optional, default shown

# Channel Configuration
# CLI is always enabled

Expand Down
38 changes: 33 additions & 5 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -175,9 +175,11 @@ impl DatabaseConfig {
/// 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.
/// Users can override with `LLM_BACKEND` env var to use other providers.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum LlmBackend {
/// OpenRouter -- OpenAI-compatible API with access to many models
OpenRouter,
/// NEAR AI proxy (default) -- session or API key auth
#[default]
NearAi,
Expand All @@ -196,13 +198,14 @@ impl std::str::FromStr for LlmBackend {

fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"openrouter" | "open_router" => Ok(Self::OpenRouter),
"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",
"invalid LLM backend '{}', expected one of: openrouter, nearai, openai, anthropic, ollama, openai_compatible",
s
)),
}
Expand All @@ -212,6 +215,7 @@ impl std::str::FromStr for LlmBackend {
impl std::fmt::Display for LlmBackend {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::OpenRouter => write!(f, "openrouter"),
Self::NearAi => write!(f, "nearai"),
Self::OpenAi => write!(f, "openai"),
Self::Anthropic => write!(f, "anthropic"),
Expand Down Expand Up @@ -242,6 +246,13 @@ pub struct OllamaConfig {
pub model: String,
}

/// Configuration for OpenRouter API.
#[derive(Debug, Clone)]
pub struct OpenRouterConfig {
pub api_key: SecretString,
pub model: String,
}

/// Configuration for any OpenAI-compatible endpoint.
#[derive(Debug, Clone)]
pub struct OpenAiCompatibleConfig {
Expand All @@ -252,14 +263,16 @@ pub struct OpenAiCompatibleConfig {

/// LLM provider configuration.
///
/// NEAR AI remains the default backend. Users can switch to other providers
/// by setting `LLM_BACKEND` (e.g. `openai`, `anthropic`, `ollama`).
/// NEAR AI is the default backend. Users can switch to other providers
/// by setting `LLM_BACKEND` (e.g. `openrouter`, `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,
/// OpenRouter config (populated when backend=openrouter)
pub openrouter: Option<OpenRouterConfig>,
/// Direct OpenAI config (populated when backend=openai)
pub openai: Option<OpenAiDirectConfig>,
/// Direct Anthropic config (populated when backend=anthropic)
Expand Down Expand Up @@ -323,7 +336,7 @@ impl LlmConfig {
message: e,
})?
} else {
LlmBackend::NearAi
LlmBackend::default()
};

// Always resolve NEAR AI config (used as fallback and for embeddings)
Expand Down Expand Up @@ -359,6 +372,20 @@ impl LlmConfig {
};

// Resolve provider-specific configs based on backend
let openrouter = if backend == LlmBackend::OpenRouter {
let api_key = optional_env("OPENROUTER_API_KEY")?
.map(SecretString::from)
.ok_or_else(|| ConfigError::MissingRequired {
key: "OPENROUTER_API_KEY".to_string(),
hint: "Set OPENROUTER_API_KEY when LLM_BACKEND=openrouter".to_string(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Since OpenRouter is now the default backend, this error message might be confusing for new users who haven't set LLM_BACKEND. The hint could be clarified to explain that OpenRouter is the default and requires an API key.

Suggested change
hint: "Set OPENROUTER_API_KEY when LLM_BACKEND=openrouter".to_string(),
hint: "Set OPENROUTER_API_KEY to use the OpenRouter backend (default). Alternatively, set LLM_BACKEND to use a different provider.".to_string(),

})?;
let model = optional_env("OPENROUTER_MODEL")?
.unwrap_or_else(|| "anthropic/claude-sonnet-4".to_string());
Comment thread
lwlee2608 marked this conversation as resolved.
Some(OpenRouterConfig { api_key, model })
} else {
None
};

let openai = if backend == LlmBackend::OpenAi {
let api_key = optional_env("OPENAI_API_KEY")?
.map(SecretString::from)
Expand Down Expand Up @@ -415,6 +442,7 @@ impl LlmConfig {
Ok(Self {
backend,
nearai,
openrouter,
openai,
anthropic,
ollama,
Expand Down
26 changes: 26 additions & 0 deletions src/llm/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
//!
//! Supports multiple backends:
//! - **NEAR AI** (default): Session-based or API key auth via NEAR AI proxy
//! - **OpenRouter**: Broad model access via OpenAI-compatible API
//! - **OpenAI**: Direct API access with your own key
//! - **Anthropic**: Direct API access with your own key
//! - **Ollama**: Local model inference
Expand Down Expand Up @@ -43,6 +44,7 @@ pub fn create_llm_provider(
session: Arc<SessionManager>,
) -> Result<Arc<dyn LlmProvider>, LlmError> {
match config.backend {
LlmBackend::OpenRouter => create_openrouter_provider(config),
LlmBackend::NearAi => create_nearai_provider(config, session),
LlmBackend::OpenAi => create_openai_provider(config),
LlmBackend::Anthropic => create_anthropic_provider(config),
Expand All @@ -51,6 +53,30 @@ pub fn create_llm_provider(
}
}

fn create_openrouter_provider(config: &LlmConfig) -> Result<Arc<dyn LlmProvider>, LlmError> {
let or = config
.openrouter
.as_ref()
.ok_or_else(|| LlmError::AuthFailed {
provider: "openrouter".to_string(),
})?;

use rig::providers::openai;

let client: openai::Client = openai::Client::builder()
.base_url("https://openrouter.ai/api/v1")
Comment thread
lwlee2608 marked this conversation as resolved.
.api_key(or.api_key.expose_secret())
.build()
.map_err(|e| LlmError::RequestFailed {
provider: "openrouter".to_string(),
reason: format!("Failed to create OpenRouter client: {}", e),
})?;

let model = client.completion_model(&or.model);
tracing::info!("Using OpenRouter API (model: {})", or.model);
Ok(Arc::new(RigAdapter::new(model, &or.model)))
}

fn create_nearai_provider(
config: &LlmConfig,
session: Arc<SessionManager>,
Expand Down
1 change: 1 addition & 0 deletions src/setup/wizard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,7 @@ impl SetupWizard {
api_mode: crate::config::NearAiApiMode::Responses,
api_key: None,
},
openrouter: None,
openai: None,
anthropic: None,
ollama: None,
Expand Down