Skip to content

Commit 72623c9

Browse files
ilblackdragondesamtralizedclaude
authored
feat: direct api key and cheap model (#116)
* feat: Support direct API key auth and cheap model routing Allow using IronClaw with any OpenAI-compatible API provider (e.g. Anthropic Claude) via API key, without requiring NEAR AI session auth. Changes: - Skip session authentication in chat_completions mode (API key auth) - Skip first-run onboard check when NEARAI_API_KEY is configured - Add `cheap_model` config field (NEARAI_CHEAP_MODEL env var) for a secondary lightweight model used for heartbeat, routing, evaluation - Add `create_cheap_llm_provider()` factory in llm module - Add `cheap_llm` to AgentDeps with fallback to main model - Route heartbeat through cheap model to reduce costs - Fix wizard compilation for new config field Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: address PR #20 review feedback - Check API key presence (not api_mode) for auth skip (ilblackdragon) - Add Settings::load() call in check_onboard_needed (ilblackdragon) - Warn and ignore cheap_model for non-NearAi backends (ilblackdragon) - Add unit tests for create_cheap_llm_provider (ilblackdragon) - Minor formatting cleanup in cheap provider match arm Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Samuel Barbosa <sambarbosaa@gmail.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 6895adb commit 72623c9

5 files changed

Lines changed: 143 additions & 5 deletions

File tree

src/agent/agent_loop.rs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,9 @@ enum AgenticLoopResult {
6767
pub struct AgentDeps {
6868
pub store: Option<Arc<dyn Database>>,
6969
pub llm: Arc<dyn LlmProvider>,
70+
/// Cheap/fast LLM for lightweight tasks (heartbeat, routing, evaluation).
71+
/// Falls back to the main `llm` if None.
72+
pub cheap_llm: Option<Arc<dyn LlmProvider>>,
7073
pub safety: Arc<SafetyLayer>,
7174
pub tools: Arc<ToolRegistry>,
7275
pub workspace: Option<Arc<Workspace>>,
@@ -138,6 +141,11 @@ impl Agent {
138141
&self.deps.llm
139142
}
140143

144+
/// Get the cheap/fast LLM provider, falling back to the main one.
145+
fn cheap_llm(&self) -> &Arc<dyn LlmProvider> {
146+
self.deps.cheap_llm.as_ref().unwrap_or(&self.deps.llm)
147+
}
148+
141149
fn safety(&self) -> &Arc<SafetyLayer> {
142150
&self.deps.safety
143151
}
@@ -301,7 +309,7 @@ impl Agent {
301309
Some(spawn_heartbeat(
302310
config,
303311
workspace.clone(),
304-
self.llm().clone(),
312+
self.cheap_llm().clone(),
305313
Some(notify_tx),
306314
))
307315
} else {

src/config.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,9 @@ impl std::str::FromStr for NearAiApiMode {
388388
pub struct NearAiConfig {
389389
/// Model to use (e.g., "claude-3-5-sonnet-20241022", "gpt-4o")
390390
pub model: String,
391+
/// Cheap/fast model for lightweight tasks (heartbeat, routing, evaluation).
392+
/// Falls back to the main model if not set.
393+
pub cheap_model: Option<String>,
391394
/// Base URL for the NEAR AI API (default: https://api.near.ai)
392395
pub base_url: String,
393396
/// Base URL for auth/refresh endpoints (default: https://private.near.ai)
@@ -454,6 +457,7 @@ impl LlmConfig {
454457
"fireworks::accounts/fireworks/models/llama4-maverick-instruct-basic"
455458
.to_string()
456459
}),
460+
cheap_model: optional_env("NEARAI_CHEAP_MODEL")?,
457461
base_url: optional_env("NEARAI_BASE_URL")?
458462
.unwrap_or_else(|| "https://cloud-api.near.ai".to_string()),
459463
auth_base_url: optional_env("NEARAI_AUTH_URL")?

src/llm/mod.rs

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,3 +183,106 @@ fn create_openai_compatible_provider(config: &LlmConfig) -> Result<Arc<dyn LlmPr
183183
);
184184
Ok(Arc::new(RigAdapter::new(model, &compat.model)))
185185
}
186+
187+
/// Create a cheap/fast LLM provider for lightweight tasks (heartbeat, routing, evaluation).
188+
///
189+
/// Uses `NEARAI_CHEAP_MODEL` if set, otherwise falls back to the main provider.
190+
/// Currently only supports NEAR AI backends (Responses and ChatCompletions modes).
191+
pub fn create_cheap_llm_provider(
192+
config: &LlmConfig,
193+
session: Arc<SessionManager>,
194+
) -> Result<Option<Arc<dyn LlmProvider>>, LlmError> {
195+
let Some(ref cheap_model) = config.nearai.cheap_model else {
196+
return Ok(None);
197+
};
198+
199+
if config.backend != LlmBackend::NearAi {
200+
tracing::warn!(
201+
"NEARAI_CHEAP_MODEL is set but LLM_BACKEND is {:?}, not NearAi. \
202+
Cheap model setting will be ignored.",
203+
config.backend
204+
);
205+
return Ok(None);
206+
}
207+
208+
let mut cheap_config = config.nearai.clone();
209+
cheap_config.model = cheap_model.clone();
210+
211+
tracing::info!("Cheap LLM provider: {}", cheap_model);
212+
213+
match cheap_config.api_mode {
214+
NearAiApiMode::Responses => Ok(Some(Arc::new(NearAiProvider::new(cheap_config, session)))),
215+
NearAiApiMode::ChatCompletions => {
216+
Ok(Some(Arc::new(NearAiChatProvider::new(cheap_config)?)))
217+
}
218+
}
219+
}
220+
221+
#[cfg(test)]
222+
mod tests {
223+
use super::*;
224+
use crate::config::{LlmBackend, NearAiApiMode, NearAiConfig};
225+
use std::path::PathBuf;
226+
227+
fn test_nearai_config() -> NearAiConfig {
228+
NearAiConfig {
229+
model: "test-model".to_string(),
230+
cheap_model: None,
231+
base_url: "https://api.near.ai".to_string(),
232+
auth_base_url: "https://private.near.ai".to_string(),
233+
session_path: PathBuf::from("/tmp/test-session.json"),
234+
api_mode: NearAiApiMode::Responses,
235+
api_key: None,
236+
fallback_model: None,
237+
max_retries: 3,
238+
}
239+
}
240+
241+
fn test_llm_config() -> LlmConfig {
242+
LlmConfig {
243+
backend: LlmBackend::NearAi,
244+
nearai: test_nearai_config(),
245+
openai: None,
246+
anthropic: None,
247+
ollama: None,
248+
openai_compatible: None,
249+
}
250+
}
251+
252+
#[test]
253+
fn test_create_cheap_llm_provider_returns_none_when_not_configured() {
254+
let config = test_llm_config();
255+
let session = Arc::new(SessionManager::new(SessionConfig::default()));
256+
257+
let result = create_cheap_llm_provider(&config, session);
258+
assert!(result.is_ok());
259+
assert!(result.unwrap().is_none());
260+
}
261+
262+
#[test]
263+
fn test_create_cheap_llm_provider_creates_provider_when_configured() {
264+
let mut config = test_llm_config();
265+
config.nearai.cheap_model = Some("cheap-test-model".to_string());
266+
267+
let session = Arc::new(SessionManager::new(SessionConfig::default()));
268+
let result = create_cheap_llm_provider(&config, session);
269+
270+
assert!(result.is_ok());
271+
let provider = result.unwrap();
272+
assert!(provider.is_some());
273+
assert_eq!(provider.unwrap().model_name(), "cheap-test-model");
274+
}
275+
276+
#[test]
277+
fn test_create_cheap_llm_provider_ignored_for_non_nearai_backend() {
278+
let mut config = test_llm_config();
279+
config.backend = LlmBackend::OpenAi;
280+
config.nearai.cheap_model = Some("cheap-test-model".to_string());
281+
282+
let session = Arc::new(SessionManager::new(SessionConfig::default()));
283+
let result = create_cheap_llm_provider(&config, session);
284+
285+
assert!(result.is_ok());
286+
assert!(result.unwrap().is_none());
287+
}
288+
}

src/main.rs

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,8 @@ use ironclaw::{
2323
context::ContextManager,
2424
extensions::ExtensionManager,
2525
llm::{
26-
FailoverProvider, LlmProvider, SessionConfig, create_llm_provider,
27-
create_llm_provider_with_config, create_session_manager,
26+
FailoverProvider, LlmProvider, SessionConfig, create_cheap_llm_provider,
27+
create_llm_provider, create_llm_provider_with_config, create_session_manager,
2828
},
2929
orchestrator::{
3030
ContainerJobConfig, ContainerJobManager, OrchestratorApi, TokenStore,
@@ -307,8 +307,11 @@ async fn main() -> anyhow::Result<()> {
307307
};
308308
let session = create_session_manager(session_config).await;
309309

310-
// Ensure we're authenticated before proceeding (only needed for NEAR AI backend)
311-
if config.llm.backend == ironclaw::config::LlmBackend::NearAi {
310+
// Session-based auth is only needed for NEAR AI backend without an API key.
311+
// ChatCompletions mode with an API key skips session auth entirely.
312+
if config.llm.backend == ironclaw::config::LlmBackend::NearAi
313+
&& config.llm.nearai.api_key.is_none()
314+
{
312315
session.ensure_authenticated().await?;
313316
}
314317

@@ -534,6 +537,12 @@ async fn main() -> anyhow::Result<()> {
534537
llm
535538
};
536539

540+
// Initialize cheap LLM provider for lightweight tasks (heartbeat, evaluation)
541+
let cheap_llm = create_cheap_llm_provider(&config.llm, session.clone())?;
542+
if let Some(ref cheap) = cheap_llm {
543+
tracing::info!("Cheap LLM provider initialized: {}", cheap.model_name());
544+
}
545+
537546
// Initialize safety layer
538547
let safety = Arc::new(SafetyLayer::new(&config.safety));
539548
tracing::info!("Safety layer initialized");
@@ -1185,6 +1194,7 @@ async fn main() -> anyhow::Result<()> {
11851194
let deps = AgentDeps {
11861195
store: db,
11871196
llm,
1197+
cheap_llm,
11881198
safety,
11891199
tools,
11901200
workspace,
@@ -1229,6 +1239,18 @@ fn check_onboard_needed() -> Option<&'static str> {
12291239
return Some("Database not configured");
12301240
}
12311241

1242+
// First run (onboarding never completed and no session).
1243+
// Reads NEARAI_API_KEY env var directly because this function runs
1244+
// before Config is loaded -- Config::from_env() may fail without a
1245+
// database URL, which is what triggers onboarding in the first place.
1246+
if std::env::var("NEARAI_API_KEY").is_err() {
1247+
let settings = ironclaw::settings::Settings::load();
1248+
let session_path = ironclaw::llm::session::default_session_path();
1249+
if !settings.onboard_completed && !session_path.exists() {
1250+
return Some("First run");
1251+
}
1252+
}
1253+
12321254
None
12331255
}
12341256

src/setup/wizard.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1014,6 +1014,7 @@ impl SetupWizard {
10141014
backend: crate::config::LlmBackend::NearAi,
10151015
nearai: crate::config::NearAiConfig {
10161016
model: "dummy".to_string(),
1017+
cheap_model: None,
10171018
base_url,
10181019
auth_base_url,
10191020
session_path: crate::llm::session::default_session_path(),

0 commit comments

Comments
 (0)