refactor: remove Responses API, consolidate to Chat Completions (#272)

* fix: strip reasoning from LLM responses and persist assistant messages reliably

- Filter out `type: "reasoning"` output items from NEAR AI Responses API
  parsing so chain-of-thought never reaches the UI (nearai.rs)
- Rewrite clean_response with regex-based tag stripping that is
  code-aware (preserves tags inside fenced blocks and inline backticks),
  supports 9+ tag names (think, thought, reasoning, reflection, etc.),
  handles <final> extraction, pipe-delimited tags, and case/whitespace
  tolerance (reasoning.rs)
- Add Reasoning::complete() helper so all non-agentic LLM call sites
  (summarize, suggest, heartbeat, compaction) get automatic response
  cleaning; thread SafetyLayer through to those callers
- Change persist_turn from fire-and-forget tokio::spawn to awaited async
  so both user and assistant messages are written before returning,
  preventing data loss on shutdown/restart
- Pass input_count through seed_response_chain so response chaining
  delta calculation is accurate after thread hydration on restart
- Make NearAiResponse.usage optional and preserve response_id in alt
  response path for chaining continuity
- Persist session token to DB during onboarding wizard so runtime
  loads it without legacy-key fallback; suppress spurious warning on
  fresh installs
- Fix dev tool double-registration when builder already registers them
- Load dotenv/ironclaw env for doctor and status subcommands
- Reduce startup log noise (demote info→debug for skills, remove
  redundant info lines)

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* Nudge to not loop over tools continuesly

* refactor: remove Responses API, consolidate NEAR AI to Chat Completions only

The Responses API provider (nearai.rs, 1278 lines) added significant complexity
(response chaining state machine, delta message calculation, previous_response_id
persistence) for marginal benefit. This consolidates to the Chat Completions API
only, upgrading NearAiChatProvider with dual auth (session token + API key) and
401 retry for session token renewal.

- Delete src/llm/nearai.rs (Responses API provider)
- Upgrade nearai_chat.rs with SessionManager, dual auth, flexible list_models
- Remove response_id from CompletionResponse and ToolCompletionResponse
- Remove seed_response_chain/get_response_chain_id from LlmProvider trait
- Remove response chain persistence from agent (thread_ops, session)
- Remove NearAiApiMode enum and NEARAI_API_MODE config
- Clean up all wrapper providers (retry, circuit_breaker, failover, cache)
- Update documentation (CLAUDE.md, .env.example)

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* feat: runtime log level control via gateway UI and URL parameter

Add server-side log level switching using tracing_subscriber::reload::Layer
so the EnvFilter can be swapped at runtime without restarting. Expose via
GET/PUT /api/logs/level endpoints, a "Server: LEVEL" dropdown in the logs
toolbar, and a ?log_level=debug URL parameter for one-click activation.

Also applies cargo fmt to pre-existing files (llm/, tests/).

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
This commit is contained in:
Illia Polosukhin
2026-02-20 20:43:32 +00:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 7df356c109
commit 448383cfb0
38 changed files with 1331 additions and 1785 deletions
+8 -10
View File
@@ -6,21 +6,19 @@ DATABASE_POOL_SIZE=10
# LLM_BACKEND=nearai # default # LLM_BACKEND=nearai # default
# Possible values: nearai, ollama, openai_compatible, openai, anthropic, tinfoil # Possible values: nearai, ollama, openai_compatible, openai, anthropic, tinfoil
# === NEAR AI Chat (Responses API, session token auth) === # === NEAR AI (Chat Completions API) ===
# Default mode. Uses browser OAuth (GitHub/Google) on first run. # Two auth modes:
# Session token stored in ~/.ironclaw/session.json automatically. # 1. Session token (default): Uses browser OAuth (GitHub/Google) on first run.
# For hosting providers: set NEARAI_SESSION_TOKEN env var directly. # Session token stored in ~/.ironclaw/session.json automatically.
# Base URL defaults to https://private.near.ai
# 2. API key: Set NEARAI_API_KEY to use API key auth from cloud.near.ai.
# Base URL defaults to https://cloud-api.near.ai
NEARAI_MODEL=zai-org/GLM-5-FP8 NEARAI_MODEL=zai-org/GLM-5-FP8
NEARAI_BASE_URL=https://private.near.ai NEARAI_BASE_URL=https://private.near.ai
NEARAI_AUTH_URL=https://private.near.ai NEARAI_AUTH_URL=https://private.near.ai
# NEARAI_SESSION_TOKEN=sess_... # hosting providers: set this # NEARAI_SESSION_TOKEN=sess_... # hosting providers: set this
# NEARAI_SESSION_PATH=~/.ironclaw/session.json # optional, default shown # NEARAI_SESSION_PATH=~/.ironclaw/session.json # optional, default shown
# NEARAI_API_KEY=... # API key from cloud.near.ai
# === NEAR AI Cloud (Chat Completions API, API key auth) ===
# Auto-selected when NEARAI_API_KEY is set. Get a key from cloud.near.ai.
# NEARAI_API_KEY=...
# NEARAI_BASE_URL=https://cloud-api.near.ai # default for cloud mode
# NEARAI_API_MODE=chat_completions # auto-detected from API key
# Local LLM Providers (Ollama, LM Studio, vLLM, LiteLLM) # Local LLM Providers (Ollama, LM Studio, vLLM, LiteLLM)
+7 -11
View File
@@ -120,8 +120,7 @@ src/
├── llm/ # LLM integration (multi-provider) ├── llm/ # LLM integration (multi-provider)
│ ├── mod.rs # Provider factory, LlmBackend enum │ ├── mod.rs # Provider factory, LlmBackend enum
│ ├── provider.rs # LlmProvider trait, message types │ ├── provider.rs # LlmProvider trait, message types
│ ├── nearai.rs # NEAR AI Responses API provider │ ├── nearai_chat.rs # NEAR AI Chat Completions provider (session token + API key auth)
│ ├── nearai_chat.rs # NEAR AI Chat Completions fallback
│ ├── reasoning.rs # Planning, tool selection, evaluation │ ├── reasoning.rs # Planning, tool selection, evaluation
│ ├── session.rs # Session token management with auto-renewal │ ├── session.rs # Session token management with auto-renewal
│ ├── circuit_breaker.rs # Circuit breaker for provider failures │ ├── circuit_breaker.rs # Circuit breaker for provider failures
@@ -339,13 +338,12 @@ LIBSQL_PATH=~/.ironclaw/ironclaw.db # libSQL local path (default)
# LIBSQL_AUTH_TOKEN=xxx # Required with LIBSQL_URL # LIBSQL_AUTH_TOKEN=xxx # Required with LIBSQL_URL
# NEAR AI (when LLM_BACKEND=nearai, the default) # NEAR AI (when LLM_BACKEND=nearai, the default)
# Two modes: "NEAR AI Chat" (session token) or "NEAR AI Cloud" (API key) # Two auth modes: session token (default) or API key
# NEAR AI Chat (Responses API, default): # Session token auth (default): uses browser OAuth on first run
NEARAI_SESSION_TOKEN=sess_... # session token for chat-api NEARAI_SESSION_TOKEN=sess_... # hosting providers: set this
NEARAI_BASE_URL=https://private.near.ai NEARAI_BASE_URL=https://private.near.ai
# NEAR AI Cloud (Chat Completions API, auto-selected when API key is set): # API key auth: set NEARAI_API_KEY, base URL defaults to cloud-api.near.ai
# NEARAI_API_KEY=... # API key from cloud.near.ai # NEARAI_API_KEY=... # API key from cloud.near.ai
# NEARAI_BASE_URL=https://cloud-api.near.ai
NEARAI_MODEL=claude-3-5-sonnet-20241022 NEARAI_MODEL=claude-3-5-sonnet-20241022
# Agent settings # Agent settings
@@ -408,11 +406,9 @@ TINFOIL_MODEL=kimi-k2-5 # Default model
IronClaw supports multiple LLM backends via the `LLM_BACKEND` env var: `nearai` (default), `openai`, `anthropic`, `ollama`, `openai_compatible`, and `tinfoil`. IronClaw supports multiple LLM backends via the `LLM_BACKEND` env var: `nearai` (default), `openai`, `anthropic`, `ollama`, `openai_compatible`, and `tinfoil`.
**NEAR AI Chat** -- Uses the NEAR AI Responses API (`https://private.near.ai/v1/responses`). Authenticates with session tokens (`sess_xxx`) obtained via browser OAuth (GitHub/Google). Supports response chaining (delta-only follow-up messages) for efficient multi-turn conversations. This is the default mode when no `NEARAI_API_KEY` is set. Set `NEARAI_SESSION_TOKEN` env var for hosting providers that inject tokens via environment. Configure with `NEARAI_BASE_URL` (default: `https://private.near.ai`). **NEAR AI** -- Uses the Chat Completions API with dual auth support. Session token auth (default): authenticates with session tokens (`sess_xxx`) obtained via browser OAuth (GitHub/Google), base URL defaults to `https://private.near.ai`. API key auth: set `NEARAI_API_KEY` (from `cloud.near.ai`), base URL defaults to `https://cloud-api.near.ai`. Both modes use the same Chat Completions endpoint. Tool messages are flattened to plain text for compatibility. Set `NEARAI_SESSION_TOKEN` env var for hosting providers that inject tokens via environment.
**NEAR AI Cloud** -- Uses the OpenAI-compatible Chat Completions API (`https://cloud-api.near.ai/v1/chat/completions`). Authenticates with API keys from `cloud.near.ai`. Auto-selected when `NEARAI_API_KEY` is set (or explicitly via `NEARAI_API_MODE=chat_completions`). Tool messages are flattened to plain text for compatibility. Configure with `NEARAI_API_KEY` and `NEARAI_BASE_URL` (default: `https://cloud-api.near.ai`). **Tinfoil** -- Private inference via `https://inference.tinfoil.sh/v1`. Runs models inside hardware-attested TEEs so neither Tinfoil nor the cloud provider can see prompts or responses. Uses the OpenAI-compatible Chat Completions API. Configure with `TINFOIL_API_KEY` and `TINFOIL_MODEL` (default: `kimi-k2-5`).
**Tinfoil** -- Private inference via `https://inference.tinfoil.sh/v1`. Runs models inside hardware-attested TEEs so neither Tinfoil nor the cloud provider can see prompts or responses. Uses the OpenAI-compatible Chat Completions API only (not the Responses API, so tool calls are adapted to chat format). Configure with `TINFOIL_API_KEY` and `TINFOIL_MODEL` (default: `kimi-k2-5`).
## Database ## Database
-2
View File
@@ -182,7 +182,6 @@ mod tests {
input_tokens: 100, input_tokens: 100,
output_tokens: 50, output_tokens: 50,
finish_reason: FinishReason::Stop, finish_reason: FinishReason::Stop,
response_id: None,
}) })
} }
@@ -196,7 +195,6 @@ mod tests {
input_tokens: 200, input_tokens: 200,
output_tokens: 100, output_tokens: 100,
finish_reason: FinishReason::Stop, finish_reason: FinishReason::Stop,
response_id: None,
}) })
} }
} }
+1 -4
View File
@@ -358,10 +358,6 @@ impl Agent {
} }
}); });
tracing::info!(
"Heartbeat enabled with {}s interval",
hb_config.interval_secs
);
let hygiene = self let hygiene = self
.hygiene_config .hygiene_config
.as_ref() .as_ref()
@@ -373,6 +369,7 @@ impl Agent {
hygiene, hygiene,
workspace.clone(), workspace.clone(),
self.cheap_llm().clone(), self.cheap_llm().clone(),
self.safety().clone(),
Some(notify_tx), Some(notify_tx),
)) ))
} else { } else {
+10 -7
View File
@@ -13,7 +13,7 @@ use crate::agent::submission::SubmissionResult;
use crate::agent::{Agent, MessageIntent}; use crate::agent::{Agent, MessageIntent};
use crate::channels::{IncomingMessage, StatusUpdate}; use crate::channels::{IncomingMessage, StatusUpdate};
use crate::error::Error; use crate::error::Error;
use crate::llm::ChatMessage; use crate::llm::{ChatMessage, Reasoning};
impl Agent { impl Agent {
/// Handle job-related intents without turn tracking. /// Handle job-related intents without turn tracking.
@@ -235,6 +235,7 @@ impl Agent {
crate::workspace::hygiene::HygieneConfig::default(), crate::workspace::hygiene::HygieneConfig::default(),
workspace.clone(), workspace.clone(),
self.llm().clone(), self.llm().clone(),
self.safety().clone(),
); );
match runner.check_heartbeat().await { match runner.check_heartbeat().await {
@@ -295,10 +296,11 @@ impl Agent {
.with_max_tokens(512) .with_max_tokens(512)
.with_temperature(0.3); .with_temperature(0.3);
match self.llm().complete(request).await { let reasoning = Reasoning::new(self.llm().clone(), self.safety().clone());
Ok(response) => Ok(SubmissionResult::response(format!( match reasoning.complete(request).await {
Ok((text, _usage)) => Ok(SubmissionResult::response(format!(
"Thread Summary:\n\n{}", "Thread Summary:\n\n{}",
response.content.trim() text.trim()
))), ))),
Err(e) => Ok(SubmissionResult::error(format!("Summarize failed: {}", e))), Err(e) => Ok(SubmissionResult::error(format!("Summarize failed: {}", e))),
} }
@@ -342,10 +344,11 @@ impl Agent {
.with_max_tokens(512) .with_max_tokens(512)
.with_temperature(0.5); .with_temperature(0.5);
match self.llm().complete(request).await { let reasoning = Reasoning::new(self.llm().clone(), self.safety().clone());
Ok(response) => Ok(SubmissionResult::response(format!( match reasoning.complete(request).await {
Ok((text, _usage)) => Ok(SubmissionResult::response(format!(
"Suggested Next Steps:\n\n{}", "Suggested Next Steps:\n\n{}",
response.content.trim() text.trim()
))), ))),
Err(e) => Ok(SubmissionResult::error(format!("Suggest failed: {}", e))), Err(e) => Ok(SubmissionResult::error(format!("Suggest failed: {}", e))),
} }
+8 -5
View File
@@ -12,7 +12,8 @@ use chrono::Utc;
use crate::agent::context_monitor::{CompactionStrategy, ContextBreakdown}; use crate::agent::context_monitor::{CompactionStrategy, ContextBreakdown};
use crate::agent::session::Thread; use crate::agent::session::Thread;
use crate::error::Error; use crate::error::Error;
use crate::llm::{ChatMessage, CompletionRequest, LlmProvider}; use crate::llm::{ChatMessage, CompletionRequest, LlmProvider, Reasoning};
use crate::safety::SafetyLayer;
use crate::workspace::Workspace; use crate::workspace::Workspace;
/// Result of a compaction operation. /// Result of a compaction operation.
@@ -33,12 +34,13 @@ pub struct CompactionResult {
/// Compacts conversation context to stay within limits. /// Compacts conversation context to stay within limits.
pub struct ContextCompactor { pub struct ContextCompactor {
llm: Arc<dyn LlmProvider>, llm: Arc<dyn LlmProvider>,
safety: Arc<SafetyLayer>,
} }
impl ContextCompactor { impl ContextCompactor {
/// Create a new context compactor. /// Create a new context compactor.
pub fn new(llm: Arc<dyn LlmProvider>) -> Self { pub fn new(llm: Arc<dyn LlmProvider>, safety: Arc<SafetyLayer>) -> Self {
Self { llm } Self { llm, safety }
} }
/// Compact a thread's context using the given strategy. /// Compact a thread's context using the given strategy.
@@ -231,8 +233,9 @@ Be brief but capture all important details. Use bullet points."#,
.with_max_tokens(1024) .with_max_tokens(1024)
.with_temperature(0.3); .with_temperature(0.3);
let response = self.llm.complete(request).await?; let reasoning = Reasoning::new(self.llm.clone(), self.safety.clone());
Ok(response.content) let (text, _) = reasoning.complete(request).await?;
Ok(text)
} }
/// Write a summary to the workspace daily log. /// Write a summary to the workspace daily log.
+32 -5
View File
@@ -109,10 +109,17 @@ impl Agent {
let job_ctx = JobContext::with_user(&message.user_id, "chat", "Interactive chat session"); let job_ctx = JobContext::with_user(&message.user_id, "chat", "Interactive chat session");
const MAX_TOOL_ITERATIONS: usize = 10; const MAX_TOOL_ITERATIONS: usize = 10;
// Force a text-only response on the last iteration to guarantee termination
// instead of hard-erroring. The penultimate iteration also gets a nudge
// message so the LLM knows it should wrap up.
const FORCE_TEXT_AT: usize = MAX_TOOL_ITERATIONS;
const NUDGE_AT: usize = MAX_TOOL_ITERATIONS - 1;
let mut iteration = 0; let mut iteration = 0;
loop { loop {
iteration += 1; iteration += 1;
if iteration > MAX_TOOL_ITERATIONS { // Hard ceiling one past the forced-text iteration (should never be reached
// since FORCE_TEXT_AT guarantees a text response, but kept as a safety net).
if iteration > MAX_TOOL_ITERATIONS + 1 {
return Err(crate::error::LlmError::InvalidResponse { return Err(crate::error::LlmError::InvalidResponse {
provider: "agent".to_string(), provider: "agent".to_string(),
reason: format!("Exceeded maximum tool iterations ({})", MAX_TOOL_ITERATIONS), reason: format!("Exceeded maximum tool iterations ({})", MAX_TOOL_ITERATIONS),
@@ -143,6 +150,19 @@ impl Agent {
.into()); .into());
} }
// Inject a nudge message when approaching the iteration limit so the
// LLM is aware it should produce a final answer on the next turn.
if iteration == NUDGE_AT {
context_messages.push(ChatMessage::system(
"You are approaching the tool call limit. \
Provide your best final answer on the next response \
using the information you have gathered so far. \
Do not call any more tools.",
));
}
let force_text = iteration >= FORCE_TEXT_AT;
// Refresh tool definitions each iteration so newly built tools become visible // Refresh tool definitions each iteration so newly built tools become visible
let tool_defs = self.tools().tool_definitions().await; let tool_defs = self.tools().tool_definitions().await;
@@ -162,8 +182,9 @@ impl Agent {
tool_defs tool_defs
}; };
// Call LLM with current context // Call LLM with current context; force_text drops tools to guarantee a
let context = ReasoningContext::new() // text response on the final iteration.
let mut context = ReasoningContext::new()
.with_messages(context_messages.clone()) .with_messages(context_messages.clone())
.with_tools(tool_defs) .with_tools(tool_defs)
.with_metadata({ .with_metadata({
@@ -171,6 +192,14 @@ impl Agent {
m.insert("thread_id".to_string(), thread_id.to_string()); m.insert("thread_id".to_string(), thread_id.to_string());
m m
}); });
context.force_text = force_text;
if force_text {
tracing::info!(
iteration,
"Forcing text-only response (iteration limit reached)"
);
}
let output = reasoning.respond_with_tools(&context).await?; let output = reasoning.respond_with_tools(&context).await?;
@@ -799,7 +828,6 @@ mod tests {
input_tokens: 0, input_tokens: 0,
output_tokens: 0, output_tokens: 0,
finish_reason: FinishReason::Stop, finish_reason: FinishReason::Stop,
response_id: None,
}) })
} }
@@ -813,7 +841,6 @@ mod tests {
input_tokens: 0, input_tokens: 0,
output_tokens: 0, output_tokens: 0,
finish_reason: FinishReason::Stop, finish_reason: FinishReason::Stop,
response_id: None,
}) })
} }
} }
+11 -13
View File
@@ -29,7 +29,8 @@ use std::time::Duration;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use crate::channels::OutgoingResponse; use crate::channels::OutgoingResponse;
use crate::llm::{ChatMessage, CompletionRequest, FinishReason, LlmProvider}; use crate::llm::{ChatMessage, CompletionRequest, LlmProvider, Reasoning};
use crate::safety::SafetyLayer;
use crate::workspace::Workspace; use crate::workspace::Workspace;
use crate::workspace::hygiene::HygieneConfig; use crate::workspace::hygiene::HygieneConfig;
@@ -100,6 +101,7 @@ pub struct HeartbeatRunner {
hygiene_config: HygieneConfig, hygiene_config: HygieneConfig,
workspace: Arc<Workspace>, workspace: Arc<Workspace>,
llm: Arc<dyn LlmProvider>, llm: Arc<dyn LlmProvider>,
safety: Arc<SafetyLayer>,
response_tx: Option<mpsc::Sender<OutgoingResponse>>, response_tx: Option<mpsc::Sender<OutgoingResponse>>,
consecutive_failures: u32, consecutive_failures: u32,
} }
@@ -111,12 +113,14 @@ impl HeartbeatRunner {
hygiene_config: HygieneConfig, hygiene_config: HygieneConfig,
workspace: Arc<Workspace>, workspace: Arc<Workspace>,
llm: Arc<dyn LlmProvider>, llm: Arc<dyn LlmProvider>,
safety: Arc<SafetyLayer>,
) -> Self { ) -> Self {
Self { Self {
config, config,
hygiene_config, hygiene_config,
workspace, workspace,
llm, llm,
safety,
response_tx: None, response_tx: None,
consecutive_failures: 0, consecutive_failures: 0,
} }
@@ -258,25 +262,18 @@ impl HeartbeatRunner {
.with_max_tokens(max_tokens) .with_max_tokens(max_tokens)
.with_temperature(0.3); .with_temperature(0.3);
let response = match self.llm.complete(request).await { let reasoning = Reasoning::new(self.llm.clone(), self.safety.clone());
let (content, _usage) = match reasoning.complete(request).await {
Ok(r) => r, Ok(r) => r,
Err(e) => return HeartbeatResult::Failed(format!("LLM call failed: {}", e)), Err(e) => return HeartbeatResult::Failed(format!("LLM call failed: {}", e)),
}; };
let content = response.content.trim(); let content = content.trim();
// Guard against empty content. Reasoning models (e.g. GLM-4.7) may // Guard against empty content. Reasoning models (e.g. GLM-4.7) may
// burn all output tokens on chain-of-thought and return content: null. // burn all output tokens on chain-of-thought and return content: null.
if content.is_empty() { if content.is_empty() {
return if response.finish_reason == FinishReason::Length { return HeartbeatResult::Failed("LLM returned empty content.".to_string());
HeartbeatResult::Failed(
"LLM response was truncated (finish_reason=length) with no content. \
The model may have exhausted its token budget on reasoning."
.to_string(),
)
} else {
HeartbeatResult::Failed("LLM returned empty content.".to_string())
};
} }
// Check if nothing needs attention // Check if nothing needs attention
@@ -355,9 +352,10 @@ pub fn spawn_heartbeat(
hygiene_config: HygieneConfig, hygiene_config: HygieneConfig,
workspace: Arc<Workspace>, workspace: Arc<Workspace>,
llm: Arc<dyn LlmProvider>, llm: Arc<dyn LlmProvider>,
safety: Arc<SafetyLayer>,
response_tx: Option<mpsc::Sender<OutgoingResponse>>, response_tx: Option<mpsc::Sender<OutgoingResponse>>,
) -> tokio::task::JoinHandle<()> { ) -> tokio::task::JoinHandle<()> {
let mut runner = HeartbeatRunner::new(config, hygiene_config, workspace, llm); let mut runner = HeartbeatRunner::new(config, hygiene_config, workspace, llm, safety);
if let Some(tx) = response_tx { if let Some(tx) = response_tx {
runner = runner.with_response_channel(tx); runner = runner.with_response_channel(tx);
} }
-8
View File
@@ -185,10 +185,6 @@ pub struct Thread {
/// Pending auth token request (thread is in auth mode). /// Pending auth token request (thread is in auth mode).
#[serde(default)] #[serde(default)]
pub pending_auth: Option<PendingAuth>, pub pending_auth: Option<PendingAuth>,
/// Last NEAR AI response ID for response chaining. Persisted to DB
/// metadata so we can resume chaining across restarts.
#[serde(default)]
pub last_response_id: Option<String>,
} }
impl Thread { impl Thread {
@@ -205,7 +201,6 @@ impl Thread {
metadata: serde_json::Value::Null, metadata: serde_json::Value::Null,
pending_approval: None, pending_approval: None,
pending_auth: None, pending_auth: None,
last_response_id: None,
} }
} }
@@ -222,7 +217,6 @@ impl Thread {
metadata: serde_json::Value::Null, metadata: serde_json::Value::Null,
pending_approval: None, pending_approval: None,
pending_auth: None, pending_auth: None,
last_response_id: None,
} }
} }
@@ -863,7 +857,6 @@ mod tests {
thread.start_turn("hello"); thread.start_turn("hello");
thread.complete_turn("world"); thread.complete_turn("world");
thread.last_response_id = Some("resp_abc123".to_string());
let json = serde_json::to_string(&thread).unwrap(); let json = serde_json::to_string(&thread).unwrap();
let restored: Thread = serde_json::from_str(&json).unwrap(); let restored: Thread = serde_json::from_str(&json).unwrap();
@@ -873,7 +866,6 @@ mod tests {
assert_eq!(restored.turns.len(), 1); assert_eq!(restored.turns.len(), 1);
assert_eq!(restored.turns[0].user_input, "hello"); assert_eq!(restored.turns[0].user_input, "hello");
assert_eq!(restored.turns[0].response, Some("world".to_string())); assert_eq!(restored.turns[0].response, Some("world".to_string()));
assert_eq!(restored.last_response_id, Some("resp_abc123".to_string()));
} }
#[test] #[test]
+39 -90
View File
@@ -87,20 +87,6 @@ impl Agent {
thread.restore_from_messages(chat_messages); thread.restore_from_messages(chat_messages);
} }
// Restore response chain from conversation metadata
if let Some(store) = self.store()
&& let Ok(Some(metadata)) = store.get_conversation_metadata(thread_uuid).await
&& let Some(rid) = metadata
.get("last_response_id")
.and_then(|v| v.as_str())
.map(String::from)
{
thread.last_response_id = Some(rid.clone());
self.llm()
.seed_response_chain(&thread_uuid.to_string(), rid);
tracing::debug!("Restored response chain for thread {}", thread_uuid);
}
// Insert into session and register with session manager // Insert into session and register with session manager
{ {
let mut sess = session.lock().await; let mut sess = session.lock().await;
@@ -228,7 +214,7 @@ impl Agent {
) )
.await; .await;
let compactor = ContextCompactor::new(self.llm().clone()); let compactor = ContextCompactor::new(self.llm().clone(), self.safety().clone());
if let Err(e) = compactor if let Err(e) = compactor
.compact(thread, strategy, self.workspace().map(|w| w.as_ref())) .compact(thread, strategy, self.workspace().map(|w| w.as_ref()))
.await .await
@@ -325,7 +311,6 @@ impl Agent {
}; };
thread.complete_turn(&response); thread.complete_turn(&response);
self.persist_response_chain(thread);
let _ = self let _ = self
.channels .channels
.send_status( .send_status(
@@ -335,8 +320,10 @@ impl Agent {
) )
.await; .await;
// Fire-and-forget: persist turn to DB // Persist turn to DB before returning so the write
self.persist_turn(thread_id, &message.user_id, content, Some(&response)); // completes even if the process shuts down right after.
self.persist_turn(thread_id, &message.user_id, content, Some(&response))
.await;
Ok(SubmissionResult::response(response)) Ok(SubmissionResult::response(response))
} }
@@ -366,15 +353,16 @@ impl Agent {
thread.fail_turn(e.to_string()); thread.fail_turn(e.to_string());
// Persist the user message even on failure // Persist the user message even on failure
self.persist_turn(thread_id, &message.user_id, content, None); self.persist_turn(thread_id, &message.user_id, content, None)
.await;
Ok(SubmissionResult::error(e.to_string())) Ok(SubmissionResult::error(e.to_string()))
} }
} }
} }
/// Fire-and-forget: persist a turn (user message + optional assistant response) to the DB. /// Persist a turn (user message + optional assistant response) to the DB.
pub(super) fn persist_turn( pub(super) async fn persist_turn(
&self, &self,
thread_id: Uuid, thread_id: Uuid,
user_id: &str, user_id: &str,
@@ -386,70 +374,29 @@ impl Agent {
None => return, None => return,
}; };
let user_id = user_id.to_string(); if let Err(e) = store
let user_input = user_input.to_string(); .ensure_conversation(thread_id, "gateway", user_id, None)
let response = response.map(String::from); .await
{
tracing::warn!("Failed to ensure conversation {}: {}", thread_id, e);
return;
}
tokio::spawn(async move { if let Err(e) = store
if let Err(e) = store .add_conversation_message(thread_id, "user", user_input)
.ensure_conversation(thread_id, "gateway", &user_id, None) .await
{
tracing::warn!("Failed to persist user message: {}", e);
return;
}
if let Some(resp) = response
&& let Err(e) = store
.add_conversation_message(thread_id, "assistant", resp)
.await .await
{ {
tracing::warn!("Failed to ensure conversation {}: {}", thread_id, e); tracing::warn!("Failed to persist assistant message: {}", e);
return; }
}
if let Err(e) = store
.add_conversation_message(thread_id, "user", &user_input)
.await
{
tracing::warn!("Failed to persist user message: {}", e);
return;
}
if let Some(ref resp) = response
&& let Err(e) = store
.add_conversation_message(thread_id, "assistant", resp)
.await
{
tracing::warn!("Failed to persist assistant message: {}", e);
}
});
}
/// Sync the provider's response chain ID to the thread and DB metadata.
///
/// Call after a successful agentic loop to persist the latest
/// `previous_response_id` so chaining survives restarts.
pub(super) fn persist_response_chain(&self, thread: &mut crate::agent::session::Thread) {
let tid = thread.id.to_string();
let response_id = match self.llm().get_response_chain_id(&tid) {
Some(rid) => rid,
None => return,
};
// Update in-memory thread
thread.last_response_id = Some(response_id.clone());
// Fire-and-forget DB write
let store = match self.store() {
Some(s) => Arc::clone(s),
None => return,
};
let thread_id = thread.id;
tokio::spawn(async move {
let val = serde_json::json!(response_id);
if let Err(e) = store
.update_conversation_metadata_field(thread_id, "last_response_id", &val)
.await
{
tracing::warn!(
"Failed to persist response chain for thread {}: {}",
thread_id,
e
);
}
});
} }
pub(super) async fn process_undo( pub(super) async fn process_undo(
@@ -562,7 +509,7 @@ impl Agent {
crate::agent::context_monitor::CompactionStrategy::Summarize { keep_recent: 5 }, crate::agent::context_monitor::CompactionStrategy::Summarize { keep_recent: 5 },
); );
let compactor = ContextCompactor::new(self.llm().clone()); let compactor = ContextCompactor::new(self.llm().clone(), self.safety().clone());
match compactor match compactor
.compact(thread, strategy, self.workspace().map(|w| w.as_ref())) .compact(thread, strategy, self.workspace().map(|w| w.as_ref()))
.await .await
@@ -1072,9 +1019,9 @@ impl Agent {
let user_input = thread.last_turn().map(|t| t.user_input.clone()); let user_input = thread.last_turn().map(|t| t.user_input.clone());
thread.complete_turn(&response); thread.complete_turn(&response);
if let Some(input) = user_input { if let Some(input) = user_input {
self.persist_turn(thread_id, &message.user_id, &input, Some(&response)); self.persist_turn(thread_id, &message.user_id, &input, Some(&response))
.await;
} }
self.persist_response_chain(thread);
let _ = self let _ = self
.channels .channels
.send_status( .send_status(
@@ -1112,7 +1059,8 @@ impl Agent {
let user_input = thread.last_turn().map(|t| t.user_input.clone()); let user_input = thread.last_turn().map(|t| t.user_input.clone());
thread.fail_turn(e.to_string()); thread.fail_turn(e.to_string());
if let Some(input) = user_input { if let Some(input) = user_input {
self.persist_turn(thread_id, &message.user_id, &input, None); self.persist_turn(thread_id, &message.user_id, &input, None)
.await;
} }
Ok(SubmissionResult::error(e.to_string())) Ok(SubmissionResult::error(e.to_string()))
} }
@@ -1131,7 +1079,8 @@ impl Agent {
thread.clear_pending_approval(); thread.clear_pending_approval();
thread.complete_turn(&rejection); thread.complete_turn(&rejection);
if let Some(input) = user_input { if let Some(input) = user_input {
self.persist_turn(thread_id, &message.user_id, &input, Some(&rejection)); self.persist_turn(thread_id, &message.user_id, &input, Some(&rejection))
.await;
} }
} }
} }
@@ -1171,9 +1120,9 @@ impl Agent {
thread.enter_auth_mode(ext_name.clone()); thread.enter_auth_mode(ext_name.clone());
thread.complete_turn(&instructions); thread.complete_turn(&instructions);
if let Some(input) = user_input { if let Some(input) = user_input {
self.persist_turn(thread_id, &message.user_id, &input, Some(&instructions)); self.persist_turn(thread_id, &message.user_id, &input, Some(&instructions))
.await;
} }
self.persist_response_chain(thread);
} }
} }
let _ = self let _ = self
+5 -9
View File
@@ -396,7 +396,6 @@ impl AppBuilder {
let tools = Arc::new(ToolRegistry::new()); let tools = Arc::new(ToolRegistry::new());
tools.register_builtin_tools(); tools.register_builtin_tools();
tracing::info!("Registered {} built-in tools", tools.count());
// Create embeddings provider if configured // Create embeddings provider if configured
let embeddings: Option<Arc<dyn EmbeddingProvider>> = if self.config.embeddings.enabled { let embeddings: Option<Arc<dyn EmbeddingProvider>> = if self.config.embeddings.enabled {
@@ -681,12 +680,12 @@ impl AppBuilder {
None None
}; };
// Register dev tools if local tools are enabled // register_builder_tool() already calls register_dev_tools() internally,
if self.config.agent.allow_local_tools { // so only register them here when the builder didn't already do it.
let builder_registered_dev_tools = self.config.builder.enabled
&& (self.config.agent.allow_local_tools || !self.config.sandbox.enabled);
if self.config.agent.allow_local_tools && !builder_registered_dev_tools {
tools.register_dev_tools(); tools.register_dev_tools();
tracing::info!(
"Local tools enabled (allow_local_tools=true), dev tools registered directly"
);
} }
Ok((mcp_session_manager, wasm_tool_runtime, extension_manager)) Ok((mcp_session_manager, wasm_tool_runtime, extension_manager))
@@ -709,9 +708,6 @@ impl AppBuilder {
// Seed workspace and backfill embeddings // Seed workspace and backfill embeddings
if let Some(ref ws) = workspace { if let Some(ref ws) = workspace {
match ws.seed_if_empty().await { match ws.seed_if_empty().await {
Ok(count) if count > 0 => {
tracing::info!("Workspace seeded with {} core files", count);
}
Ok(_) => {} Ok(_) => {}
Err(e) => { Err(e) => {
tracing::warn!("Failed to seed workspace: {}", e); tracing::warn!("Failed to seed workspace: {}", e);
+112 -1
View File
@@ -22,7 +22,9 @@ use std::sync::{Arc, Mutex};
use serde::Serialize; use serde::Serialize;
use tokio::sync::broadcast; use tokio::sync::broadcast;
use tracing::field::{Field, Visit}; use tracing::field::{Field, Visit};
use tracing_subscriber::Layer; use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
use tracing_subscriber::{EnvFilter, Layer, reload};
use crate::safety::LeakDetector; use crate::safety::LeakDetector;
@@ -102,6 +104,115 @@ impl Default for LogBroadcaster {
} }
} }
/// Handle for changing the tracing `EnvFilter` at runtime.
///
/// Wraps a `reload::Handle` so the gateway can switch between log levels
/// (e.g. `ironclaw=debug`) without restarting the process.
pub struct LogLevelHandle {
handle: reload::Handle<EnvFilter, tracing_subscriber::Registry>,
current_level: Mutex<String>,
base_filter: String,
}
impl LogLevelHandle {
pub fn new(
handle: reload::Handle<EnvFilter, tracing_subscriber::Registry>,
initial_level: String,
base_filter: String,
) -> Self {
Self {
handle,
current_level: Mutex::new(initial_level),
base_filter,
}
}
/// Change the `ironclaw=<level>` directive at runtime.
///
/// `level` must be one of: trace, debug, info, warn, error.
pub fn set_level(&self, level: &str) -> Result<(), String> {
const VALID: &[&str] = &["trace", "debug", "info", "warn", "error"];
let level = level.to_lowercase();
if !VALID.contains(&level.as_str()) {
return Err(format!(
"invalid level '{}', must be one of: {}",
level,
VALID.join(", ")
));
}
let filter_str = if self.base_filter.is_empty() {
format!("ironclaw={}", level)
} else {
format!("ironclaw={},{}", level, self.base_filter)
};
let new_filter = EnvFilter::new(&filter_str);
self.handle
.reload(new_filter)
.map_err(|e| format!("failed to reload filter: {}", e))?;
if let Ok(mut current) = self.current_level.lock() {
*current = level;
}
Ok(())
}
/// Returns the current ironclaw log level (e.g. "info", "debug").
pub fn current_level(&self) -> String {
self.current_level
.lock()
.map(|l| l.clone())
.unwrap_or_else(|_| "info".to_string())
}
}
/// Initialise the tracing subscriber with a reloadable `EnvFilter`.
///
/// Returns the `LogLevelHandle` so callers can swap the filter at runtime.
/// The fmt layer and `WebLogLayer` are attached alongside the reloadable filter.
pub fn init_tracing(log_broadcaster: Arc<LogBroadcaster>) -> Arc<LogLevelHandle> {
let raw_filter =
std::env::var("RUST_LOG").unwrap_or_else(|_| "ironclaw=info,tower_http=warn".to_string());
// Split into the ironclaw directive and "everything else" (base_filter).
let mut ironclaw_level = String::from("info");
let mut base_parts: Vec<&str> = Vec::new();
for part in raw_filter.split(',') {
let trimmed = part.trim();
if trimmed.starts_with("ironclaw=") {
if let Some(lvl) = trimmed.strip_prefix("ironclaw=") {
ironclaw_level = lvl.to_string();
}
} else if !trimmed.is_empty() {
base_parts.push(trimmed);
}
}
let base_filter = base_parts.join(",");
let env_filter = EnvFilter::new(&raw_filter);
let (reload_layer, reload_handle) = reload::Layer::new(env_filter);
let handle = Arc::new(LogLevelHandle::new(
reload_handle,
ironclaw_level,
base_filter,
));
tracing_subscriber::registry()
.with(reload_layer)
.with(
tracing_subscriber::fmt::layer()
.with_target(false)
.with_writer(crate::tracing_fmt::TruncatingStderr::default()),
)
.with(WebLogLayer::new(log_broadcaster))
.init();
handle
}
/// Visitor that extracts the `message` field and all extra key-value /// Visitor that extracts the `message` field and all extra key-value
/// fields from a tracing event. /// fields from a tracing event.
/// ///
+9 -1
View File
@@ -41,7 +41,7 @@ use crate::skills::registry::SkillRegistry;
use crate::tools::ToolRegistry; use crate::tools::ToolRegistry;
use crate::workspace::Workspace; use crate::workspace::Workspace;
use self::log_layer::LogBroadcaster; use self::log_layer::{LogBroadcaster, LogLevelHandle};
use self::server::GatewayState; use self::server::GatewayState;
use self::sse::SseManager; use self::sse::SseManager;
@@ -76,6 +76,7 @@ impl GatewayChannel {
workspace: None, workspace: None,
session_manager: None, session_manager: None,
log_broadcaster: None, log_broadcaster: None,
log_level_handle: None,
extension_manager: None, extension_manager: None,
tool_registry: None, tool_registry: None,
store: None, store: None,
@@ -105,6 +106,7 @@ impl GatewayChannel {
workspace: self.state.workspace.clone(), workspace: self.state.workspace.clone(),
session_manager: self.state.session_manager.clone(), session_manager: self.state.session_manager.clone(),
log_broadcaster: self.state.log_broadcaster.clone(), log_broadcaster: self.state.log_broadcaster.clone(),
log_level_handle: self.state.log_level_handle.clone(),
extension_manager: self.state.extension_manager.clone(), extension_manager: self.state.extension_manager.clone(),
tool_registry: self.state.tool_registry.clone(), tool_registry: self.state.tool_registry.clone(),
store: self.state.store.clone(), store: self.state.store.clone(),
@@ -140,6 +142,12 @@ impl GatewayChannel {
self self
} }
/// Inject the log level handle for runtime log level control.
pub fn with_log_level_handle(mut self, h: Arc<LogLevelHandle>) -> Self {
self.rebuild_state(|s| s.log_level_handle = Some(h));
self
}
/// Inject the extension manager for the extensions API. /// Inject the extension manager for the extensions API.
pub fn with_extension_manager(mut self, em: Arc<ExtensionManager>) -> Self { pub fn with_extension_manager(mut self, em: Arc<ExtensionManager>) -> Self {
self.rebuild_state(|s| s.extension_manager = Some(em)); self.rebuild_state(|s| s.extension_manager = Some(em));
+39
View File
@@ -122,6 +122,8 @@ pub struct GatewayState {
pub session_manager: Option<Arc<SessionManager>>, pub session_manager: Option<Arc<SessionManager>>,
/// Log broadcaster for the logs SSE endpoint. /// Log broadcaster for the logs SSE endpoint.
pub log_broadcaster: Option<Arc<LogBroadcaster>>, pub log_broadcaster: Option<Arc<LogBroadcaster>>,
/// Handle for changing the tracing log level at runtime.
pub log_level_handle: Option<Arc<crate::channels::web::log_layer::LogLevelHandle>>,
/// Extension manager for extension management API. /// Extension manager for extension management API.
pub extension_manager: Option<Arc<ExtensionManager>>, pub extension_manager: Option<Arc<ExtensionManager>>,
/// Tool registry for listing registered tools. /// Tool registry for listing registered tools.
@@ -204,6 +206,11 @@ pub async fn start_server(
.route("/api/jobs/{id}/files/read", get(job_files_read_handler)) .route("/api/jobs/{id}/files/read", get(job_files_read_handler))
// Logs // Logs
.route("/api/logs/events", get(logs_events_handler)) .route("/api/logs/events", get(logs_events_handler))
.route("/api/logs/level", get(logs_level_get_handler))
.route(
"/api/logs/level",
axum::routing::put(logs_level_set_handler),
)
// Extensions // Extensions
.route("/api/extensions", get(extensions_list_handler)) .route("/api/extensions", get(extensions_list_handler))
.route("/api/extensions/tools", get(extensions_tools_handler)) .route("/api/extensions/tools", get(extensions_tools_handler))
@@ -1620,6 +1627,38 @@ async fn logs_events_handler(
)) ))
} }
async fn logs_level_get_handler(
State(state): State<Arc<GatewayState>>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let handle = state.log_level_handle.as_ref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"Log level control not available".to_string(),
))?;
Ok(Json(serde_json::json!({ "level": handle.current_level() })))
}
async fn logs_level_set_handler(
State(state): State<Arc<GatewayState>>,
Json(body): Json<serde_json::Value>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let handle = state.log_level_handle.as_ref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"Log level control not available".to_string(),
))?;
let level = body
.get("level")
.and_then(|v| v.as_str())
.ok_or((StatusCode::BAD_REQUEST, "missing 'level' field".to_string()))?;
handle
.set_level(level)
.map_err(|e| (StatusCode::BAD_REQUEST, e))?;
tracing::info!("Log level changed to '{}'", handle.current_level());
Ok(Json(serde_json::json!({ "level": handle.current_level() })))
}
// --- Extension handlers --- // --- Extension handlers ---
async fn extensions_list_handler( async fn extensions_list_handler(
+33 -1
View File
@@ -29,9 +29,11 @@ function authenticate() {
sessionStorage.setItem('ironclaw_token', token); sessionStorage.setItem('ironclaw_token', token);
document.getElementById('auth-screen').style.display = 'none'; document.getElementById('auth-screen').style.display = 'none';
document.getElementById('app').style.display = 'flex'; document.getElementById('app').style.display = 'flex';
// Strip token from URL so it's not visible in the address bar // Strip token and log_level from URL so they're not visible in the address bar
const cleaned = new URL(window.location); const cleaned = new URL(window.location);
const urlLogLevel = cleaned.searchParams.get('log_level');
cleaned.searchParams.delete('token'); cleaned.searchParams.delete('token');
cleaned.searchParams.delete('log_level');
window.history.replaceState({}, '', cleaned.pathname + cleaned.search); window.history.replaceState({}, '', cleaned.pathname + cleaned.search);
connectSSE(); connectSSE();
connectLogSSE(); connectLogSSE();
@@ -39,6 +41,12 @@ function authenticate() {
loadThreads(); loadThreads();
loadMemoryTree(); loadMemoryTree();
loadJobs(); loadJobs();
// Apply URL log_level param if present, otherwise just sync the dropdown
if (urlLogLevel) {
setServerLogLevel(urlLogLevel);
} else {
loadServerLogLevel();
}
}) })
.catch(() => { .catch(() => {
sessionStorage.removeItem('ironclaw_token'); sessionStorage.removeItem('ironclaw_token');
@@ -1167,6 +1175,30 @@ function applyLogFilters() {
} }
} }
// --- Server-side log level control ---
function setServerLogLevel(level) {
apiFetch('/api/logs/level', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ level: level }),
})
.then(r => r.json())
.then(data => {
document.getElementById('logs-server-level').value = data.level;
})
.catch(err => console.error('Failed to set server log level:', err));
}
function loadServerLogLevel() {
apiFetch('/api/logs/level')
.then(r => r.json())
.then(data => {
document.getElementById('logs-server-level').value = data.level;
})
.catch(() => {}); // ignore if not available
}
// --- Extensions --- // --- Extensions ---
function loadExtensions() { function loadExtensions() {
+6
View File
@@ -127,6 +127,12 @@
<div class="tab-panel" id="tab-logs"> <div class="tab-panel" id="tab-logs">
<div class="logs-container"> <div class="logs-container">
<div class="logs-toolbar"> <div class="logs-toolbar">
<select id="logs-server-level" onchange="setServerLogLevel(this.value)" title="Server-side log level (changes what the server emits)">
<option value="error">Server: ERROR</option>
<option value="warn">Server: WARN</option>
<option value="info" selected>Server: INFO</option>
<option value="debug">Server: DEBUG</option>
</select>
<select id="logs-level-filter"> <select id="logs-level-filter">
<option value="all">All Levels</option> <option value="all">All Levels</option>
<option value="ERROR">Error</option> <option value="ERROR">Error</option>
+1
View File
@@ -477,6 +477,7 @@ mod tests {
workspace: None, workspace: None,
session_manager: None, session_manager: None,
log_broadcaster: None, log_broadcaster: None,
log_level_handle: None,
extension_manager: None, extension_manager: None,
tool_registry: None, tool_registry: None,
store: None, store: None,
+4 -49
View File
@@ -121,37 +121,7 @@ pub struct LlmConfig {
pub tinfoil: Option<TinfoilConfig>, pub tinfoil: Option<TinfoilConfig>,
} }
/// API mode for NEAR AI. /// NEAR AI configuration.
///
/// - `Responses` = **NEAR AI Chat** (`private.near.ai`, session token auth)
/// - `ChatCompletions` = **NEAR AI Cloud** (`cloud-api.near.ai`, API key auth)
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum NearAiApiMode {
/// NEAR AI Chat: Responses API with session token auth
#[default]
Responses,
/// NEAR AI Cloud: Chat Completions API with API key auth
ChatCompletions,
}
impl std::str::FromStr for NearAiApiMode {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"responses" | "response" => Ok(Self::Responses),
"chat_completions" | "chatcompletions" | "chat" | "completions" => {
Ok(Self::ChatCompletions)
}
_ => Err(format!(
"invalid API mode '{}', expected 'responses' or 'chat_completions'",
s
)),
}
}
}
/// NEAR AI configuration (shared by Chat and Cloud modes).
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct NearAiConfig { pub struct NearAiConfig {
/// Model to use (e.g., "claude-3-5-sonnet-20241022", "gpt-4o") /// Model to use (e.g., "claude-3-5-sonnet-20241022", "gpt-4o")
@@ -160,16 +130,13 @@ pub struct NearAiConfig {
/// Falls back to the main model if not set. /// Falls back to the main model if not set.
pub cheap_model: Option<String>, pub cheap_model: Option<String>,
/// Base URL for the NEAR AI API. /// Base URL for the NEAR AI API.
/// Chat mode default: `https://private.near.ai` /// Default: `https://private.near.ai` (session token) or `https://cloud-api.near.ai` (API key)
/// Cloud mode default: `https://cloud-api.near.ai`
pub base_url: String, pub base_url: String,
/// Base URL for auth/refresh endpoints (default: https://private.near.ai) /// Base URL for auth/refresh endpoints (default: https://private.near.ai)
pub auth_base_url: String, pub auth_base_url: String,
/// Path to session file (default: ~/.ironclaw/session.json) /// Path to session file (default: ~/.ironclaw/session.json)
pub session_path: PathBuf, pub session_path: PathBuf,
/// API mode: NEAR AI Chat (Responses) or NEAR AI Cloud (ChatCompletions) /// API key for NEAR AI Cloud. When set, uses API key auth; otherwise uses session token auth.
pub api_mode: NearAiApiMode,
/// API key for NEAR AI Cloud (required for ChatCompletions mode)
pub api_key: Option<SecretString>, pub api_key: Option<SecretString>,
/// Optional fallback model for failover (default: None). /// Optional fallback model for failover (default: None).
/// When set, a secondary provider is created with this model and wrapped /// When set, a secondary provider is created with this model and wrapped
@@ -229,17 +196,6 @@ impl LlmConfig {
// Resolve NEAR AI config only when backend is NearAi (or when explicitly configured) // Resolve NEAR AI config only when backend is NearAi (or when explicitly configured)
let nearai_api_key = optional_env("NEARAI_API_KEY")?.map(SecretString::from); 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 nearai_api_key.is_some() {
NearAiApiMode::ChatCompletions
} else {
NearAiApiMode::Responses
};
let nearai = NearAiConfig { let nearai = NearAiConfig {
model: optional_env("NEARAI_MODEL")? model: optional_env("NEARAI_MODEL")?
.or_else(|| settings.selected_model.clone()) .or_else(|| settings.selected_model.clone())
@@ -249,7 +205,7 @@ impl LlmConfig {
}), }),
cheap_model: optional_env("NEARAI_CHEAP_MODEL")?, cheap_model: optional_env("NEARAI_CHEAP_MODEL")?,
base_url: optional_env("NEARAI_BASE_URL")?.unwrap_or_else(|| { base_url: optional_env("NEARAI_BASE_URL")?.unwrap_or_else(|| {
if api_mode == NearAiApiMode::ChatCompletions { if nearai_api_key.is_some() {
"https://cloud-api.near.ai".to_string() "https://cloud-api.near.ai".to_string()
} else { } else {
"https://private.near.ai".to_string() "https://private.near.ai".to_string()
@@ -260,7 +216,6 @@ impl LlmConfig {
session_path: optional_env("NEARAI_SESSION_PATH")? session_path: optional_env("NEARAI_SESSION_PATH")?
.map(PathBuf::from) .map(PathBuf::from)
.unwrap_or_else(default_session_path), .unwrap_or_else(default_session_path),
api_mode,
api_key: nearai_api_key, api_key: nearai_api_key,
fallback_model: optional_env("NEARAI_FALLBACK_MODEL")?, fallback_model: optional_env("NEARAI_FALLBACK_MODEL")?,
max_retries: parse_optional_env("NEARAI_MAX_RETRIES", 3)?, max_retries: parse_optional_env("NEARAI_MAX_RETRIES", 3)?,
+1 -1
View File
@@ -37,7 +37,7 @@ pub use self::embeddings::EmbeddingsConfig;
pub use self::heartbeat::HeartbeatConfig; pub use self::heartbeat::HeartbeatConfig;
pub use self::hygiene::HygieneConfig; pub use self::hygiene::HygieneConfig;
pub use self::llm::{ pub use self::llm::{
AnthropicDirectConfig, LlmBackend, LlmConfig, NearAiApiMode, NearAiConfig, OllamaConfig, AnthropicDirectConfig, LlmBackend, LlmConfig, NearAiConfig, OllamaConfig,
OpenAiCompatibleConfig, OpenAiDirectConfig, TinfoilConfig, OpenAiCompatibleConfig, OpenAiDirectConfig, TinfoilConfig,
}; };
pub use self::routines::RoutineConfig; pub use self::routines::RoutineConfig;
-8
View File
@@ -296,14 +296,6 @@ impl LlmProvider for CircuitBreakerProvider {
self.inner.set_model(model) self.inner.set_model(model)
} }
fn seed_response_chain(&self, thread_id: &str, response_id: String) {
self.inner.seed_response_chain(thread_id, response_id)
}
fn get_response_chain_id(&self, thread_id: &str) -> Option<String> {
self.inner.get_response_chain_id(thread_id)
}
fn calculate_cost(&self, input_tokens: u32, output_tokens: u32) -> Decimal { fn calculate_cost(&self, input_tokens: u32, output_tokens: u32) -> Decimal {
self.inner.calculate_cost(input_tokens, output_tokens) self.inner.calculate_cost(input_tokens, output_tokens)
} }
-13
View File
@@ -359,15 +359,6 @@ impl LlmProvider for FailoverProvider {
.await .await
} }
fn seed_response_chain(&self, thread_id: &str, response_id: String) {
self.providers[self.last_used.load(Ordering::Relaxed)]
.seed_response_chain(thread_id, response_id);
}
fn get_response_chain_id(&self, thread_id: &str) -> Option<String> {
self.providers[self.last_used.load(Ordering::Relaxed)].get_response_chain_id(thread_id)
}
fn calculate_cost(&self, input_tokens: u32, output_tokens: u32) -> Decimal { fn calculate_cost(&self, input_tokens: u32, output_tokens: u32) -> Decimal {
self.providers[self.last_used.load(Ordering::Relaxed)] self.providers[self.last_used.load(Ordering::Relaxed)]
.calculate_cost(input_tokens, output_tokens) .calculate_cost(input_tokens, output_tokens)
@@ -413,7 +404,6 @@ mod tests {
input_tokens: 10, input_tokens: 10,
output_tokens: 5, output_tokens: 5,
finish_reason: FinishReason::Stop, finish_reason: FinishReason::Stop,
response_id: None,
}))), }))),
tool_complete_result: Mutex::new(Some(Ok(ToolCompletionResponse { tool_complete_result: Mutex::new(Some(Ok(ToolCompletionResponse {
content: Some(content.to_string()), content: Some(content.to_string()),
@@ -421,7 +411,6 @@ mod tests {
input_tokens: 10, input_tokens: 10,
output_tokens: 5, output_tokens: 5,
finish_reason: FinishReason::Stop, finish_reason: FinishReason::Stop,
response_id: None,
}))), }))),
} }
} }
@@ -803,7 +792,6 @@ mod tests {
input_tokens: 10, input_tokens: 10,
output_tokens: 5, output_tokens: 5,
finish_reason: FinishReason::Stop, finish_reason: FinishReason::Stop,
response_id: None,
}) })
} }
@@ -829,7 +817,6 @@ mod tests {
input_tokens: 10, input_tokens: 10,
output_tokens: 5, output_tokens: 5,
finish_reason: FinishReason::Stop, finish_reason: FinishReason::Stop,
response_id: None,
}) })
} }
+21 -34
View File
@@ -1,7 +1,7 @@
//! LLM integration for the agent. //! LLM integration for the agent.
//! //!
//! Supports multiple backends: //! Supports multiple backends:
//! - **NEAR AI** (default): Session-based or API key auth via NEAR AI proxy //! - **NEAR AI** (default): Session token or API key auth via Chat Completions API
//! - **OpenAI**: Direct API access with your own key //! - **OpenAI**: Direct API access with your own key
//! - **Anthropic**: Direct API access with your own key //! - **Anthropic**: Direct API access with your own key
//! - **Ollama**: Local model inference //! - **Ollama**: Local model inference
@@ -10,7 +10,6 @@
pub mod circuit_breaker; pub mod circuit_breaker;
pub mod costs; pub mod costs;
pub mod failover; pub mod failover;
mod nearai;
mod nearai_chat; mod nearai_chat;
mod provider; mod provider;
mod reasoning; mod reasoning;
@@ -21,8 +20,7 @@ pub mod session;
pub use circuit_breaker::{CircuitBreakerConfig, CircuitBreakerProvider}; pub use circuit_breaker::{CircuitBreakerConfig, CircuitBreakerProvider};
pub use failover::{CooldownConfig, FailoverProvider}; pub use failover::{CooldownConfig, FailoverProvider};
pub use nearai::{ModelInfo, NearAiProvider}; pub use nearai_chat::{ModelInfo, NearAiChatProvider};
pub use nearai_chat::NearAiChatProvider;
pub use provider::{ pub use provider::{
ChatMessage, CompletionRequest, CompletionResponse, FinishReason, LlmProvider, ModelMetadata, ChatMessage, CompletionRequest, CompletionResponse, FinishReason, LlmProvider, ModelMetadata,
Role, ToolCall, ToolCompletionRequest, ToolCompletionResponse, ToolDefinition, ToolResult, Role, ToolCall, ToolCompletionRequest, ToolCompletionResponse, ToolDefinition, ToolResult,
@@ -41,7 +39,7 @@ use std::sync::Arc;
use rig::client::CompletionClient; use rig::client::CompletionClient;
use secrecy::ExposeSecret; use secrecy::ExposeSecret;
use crate::config::{LlmBackend, LlmConfig, NearAiApiMode, NearAiConfig}; use crate::config::{LlmBackend, LlmConfig, NearAiConfig};
use crate::error::LlmError; use crate::error::LlmError;
/// Create an LLM provider based on configuration. /// Create an LLM provider based on configuration.
@@ -71,24 +69,18 @@ pub fn create_llm_provider_with_config(
config: &NearAiConfig, config: &NearAiConfig,
session: Arc<SessionManager>, session: Arc<SessionManager>,
) -> Result<Arc<dyn LlmProvider>, LlmError> { ) -> Result<Arc<dyn LlmProvider>, LlmError> {
match config.api_mode { let auth_mode = if config.api_key.is_some() {
NearAiApiMode::Responses => { "API key"
tracing::info!( } else {
model = %config.model, "session token"
base_url = %config.base_url, };
"Using NEAR AI Chat (Responses API, session token auth)" tracing::info!(
); model = %config.model,
Ok(Arc::new(NearAiProvider::new(config.clone(), session)?)) base_url = %config.base_url,
} auth = auth_mode,
NearAiApiMode::ChatCompletions => { "Using NEAR AI (Chat Completions API)"
tracing::info!( );
model = %config.model, Ok(Arc::new(NearAiChatProvider::new(config.clone(), session)?))
base_url = %config.base_url,
"Using NEAR AI Cloud (Chat Completions API, API key auth)"
);
Ok(Arc::new(NearAiChatProvider::new(config.clone())?))
}
}
} }
fn create_openai_provider(config: &LlmConfig) -> Result<Arc<dyn LlmProvider>, LlmError> { fn create_openai_provider(config: &LlmConfig) -> Result<Arc<dyn LlmProvider>, LlmError> {
@@ -254,7 +246,7 @@ fn create_openai_compatible_provider(config: &LlmConfig) -> Result<Arc<dyn LlmPr
/// Create a cheap/fast LLM provider for lightweight tasks (heartbeat, routing, evaluation). /// Create a cheap/fast LLM provider for lightweight tasks (heartbeat, routing, evaluation).
/// ///
/// Uses `NEARAI_CHEAP_MODEL` if set, otherwise falls back to the main provider. /// Uses `NEARAI_CHEAP_MODEL` if set, otherwise falls back to the main provider.
/// Currently only supports NEAR AI backends (Responses and ChatCompletions modes). /// Currently only supports NEAR AI backend.
pub fn create_cheap_llm_provider( pub fn create_cheap_llm_provider(
config: &LlmConfig, config: &LlmConfig,
session: Arc<SessionManager>, session: Arc<SessionManager>,
@@ -275,20 +267,16 @@ pub fn create_cheap_llm_provider(
let mut cheap_config = config.nearai.clone(); let mut cheap_config = config.nearai.clone();
cheap_config.model = cheap_model.clone(); cheap_config.model = cheap_model.clone();
tracing::info!("Cheap LLM provider: {}", cheap_model); Ok(Some(Arc::new(NearAiChatProvider::new(
cheap_config,
match cheap_config.api_mode { session,
NearAiApiMode::Responses => Ok(Some(Arc::new(NearAiProvider::new(cheap_config, session)?))), )?)))
NearAiApiMode::ChatCompletions => {
Ok(Some(Arc::new(NearAiChatProvider::new(cheap_config)?)))
}
}
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::config::{LlmBackend, NearAiApiMode, NearAiConfig}; use crate::config::{LlmBackend, NearAiConfig};
use std::path::PathBuf; use std::path::PathBuf;
fn test_nearai_config() -> NearAiConfig { fn test_nearai_config() -> NearAiConfig {
@@ -298,7 +286,6 @@ mod tests {
base_url: "https://api.near.ai".to_string(), base_url: "https://api.near.ai".to_string(),
auth_base_url: "https://private.near.ai".to_string(), auth_base_url: "https://private.near.ai".to_string(),
session_path: PathBuf::from("/tmp/test-session.json"), session_path: PathBuf::from("/tmp/test-session.json"),
api_mode: NearAiApiMode::Responses,
api_key: None, api_key: None,
fallback_model: None, fallback_model: None,
max_retries: 3, max_retries: 3,
-1205
View File
File diff suppressed because it is too large Load Diff
+199 -61
View File
@@ -1,8 +1,12 @@
//! NEAR AI Cloud provider implementation (Chat Completions API). //! NEAR AI provider implementation (Chat Completions API).
//! //!
//! This provider uses the NEAR AI Cloud API (`cloud-api.near.ai`) which //! This provider uses the OpenAI-compatible Chat Completions endpoint with
//! exposes an OpenAI-compatible chat completions endpoint with API key //! dual auth support:
//! authentication. //! - **API key auth**: When `NEARAI_API_KEY` is set, uses Bearer API key
//! - **Session token auth**: Otherwise, uses `SessionManager` for Bearer session token
//! with automatic renewal on 401 errors
use std::sync::Arc;
use async_trait::async_trait; use async_trait::async_trait;
use reqwest::Client; use reqwest::Client;
@@ -14,38 +18,51 @@ use serde::{Deserialize, Serialize};
use crate::config::NearAiConfig; use crate::config::NearAiConfig;
use crate::error::LlmError; use crate::error::LlmError;
use crate::llm::provider::{ use crate::llm::provider::{
ChatMessage, CompletionRequest, CompletionResponse, FinishReason, LlmProvider, ModelMetadata, ChatMessage, CompletionRequest, CompletionResponse, FinishReason, LlmProvider, Role, ToolCall,
Role, ToolCall, ToolCompletionRequest, ToolCompletionResponse, ToolCompletionRequest, ToolCompletionResponse,
}; };
use crate::llm::session::SessionManager;
/// NEAR AI Cloud provider (Chat Completions API, API key auth). /// Information about an available model from NEAR AI API.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModelInfo {
/// Model identifier.
#[serde(alias = "id", alias = "model")]
pub name: String,
/// Optional provider name.
#[serde(default)]
pub provider: Option<String>,
}
/// NEAR AI provider (Chat Completions API, dual auth).
pub struct NearAiChatProvider { pub struct NearAiChatProvider {
client: Client, client: Client,
config: NearAiConfig, config: NearAiConfig,
/// Session manager for session token auth (used when no API key is set).
session: Arc<SessionManager>,
active_model: std::sync::RwLock<String>, active_model: std::sync::RwLock<String>,
flatten_tool_messages: bool, flatten_tool_messages: bool,
} }
impl NearAiChatProvider { impl NearAiChatProvider {
/// Create a new NEAR AI Cloud provider with API key auth. /// Create a new NEAR AI Chat Completions provider.
///
/// Auth mode is determined by `config.api_key`:
/// - If set, uses Bearer API key auth
/// - If not set, uses session token auth via `SessionManager`
/// ///
/// By default this enables tool-message flattening for compatibility with /// By default this enables tool-message flattening for compatibility with
/// providers that reject `role: "tool"` messages. /// providers that reject `role: "tool"` messages.
pub fn new(config: NearAiConfig) -> Result<Self, LlmError> { pub fn new(config: NearAiConfig, session: Arc<SessionManager>) -> Result<Self, LlmError> {
Self::new_with_flatten(config, true) Self::new_with_flatten(config, session, true)
} }
/// Create a chat completions provider with configurable tool-message flattening. /// Create a chat completions provider with configurable tool-message flattening.
pub fn new_with_flatten( pub fn new_with_flatten(
config: NearAiConfig, config: NearAiConfig,
session: Arc<SessionManager>,
flatten_tool_messages: bool, flatten_tool_messages: bool,
) -> Result<Self, LlmError> { ) -> Result<Self, LlmError> {
if config.api_key.is_none() {
return Err(LlmError::AuthFailed {
provider: "nearai_chat".to_string(),
});
}
let client = Client::builder() let client = Client::builder()
.timeout(std::time::Duration::from_secs(120)) .timeout(std::time::Duration::from_secs(120))
.build() .build()
@@ -58,6 +75,7 @@ impl NearAiChatProvider {
Ok(Self { Ok(Self {
client, client,
config, config,
session,
active_model, active_model,
flatten_tool_messages, flatten_tool_messages,
}) })
@@ -74,23 +92,50 @@ impl NearAiChatProvider {
} }
} }
fn api_key(&self) -> String { /// Returns true if using API key auth, false if session token auth.
self.config fn uses_api_key(&self) -> bool {
.api_key self.config.api_key.is_some()
.as_ref() }
.map(|k| k.expose_secret().to_string())
.unwrap_or_default() /// Resolve the Bearer token for the current auth mode.
async fn resolve_bearer_token(&self) -> Result<String, LlmError> {
if let Some(ref api_key) = self.config.api_key {
Ok(api_key.expose_secret().to_string())
} else {
let token = self.session.get_token().await?;
Ok(token.expose_secret().to_string())
}
} }
/// Send a single request to the chat completions API. /// Send a single request to the chat completions API.
/// ///
/// Does not retry internally — retries are handled by the external /// For session token auth, handles 401 by calling `session.handle_auth_failure()`
/// and retrying once.
///
/// Does not retry on other errors — retries are handled by the external
/// `RetryProvider` wrapper in the composition chain. /// `RetryProvider` wrapper in the composition chain.
async fn send_request<T: Serialize, R: for<'de> Deserialize<'de>>( async fn send_request<T: Serialize, R: for<'de> Deserialize<'de>>(
&self, &self,
body: &T, body: &T,
) -> Result<R, LlmError> {
match self.send_request_inner(body).await {
Ok(result) => Ok(result),
Err(LlmError::SessionExpired { .. }) if !self.uses_api_key() => {
// Session expired, attempt renewal and retry once
self.session.handle_auth_failure().await?;
self.send_request_inner(body).await
}
Err(e) => Err(e),
}
}
/// Inner request implementation (single attempt).
async fn send_request_inner<T: Serialize, R: for<'de> Deserialize<'de>>(
&self,
body: &T,
) -> Result<R, LlmError> { ) -> Result<R, LlmError> {
let url = self.api_url("chat/completions"); let url = self.api_url("chat/completions");
let token = self.resolve_bearer_token().await?;
tracing::debug!("Sending request to NEAR AI Chat: {}", url); tracing::debug!("Sending request to NEAR AI Chat: {}", url);
@@ -103,7 +148,7 @@ impl NearAiChatProvider {
let response = self let response = self
.client .client
.post(&url) .post(&url)
.header("Authorization", format!("Bearer {}", self.api_key())) .header("Authorization", format!("Bearer {}", token))
.header("Content-Type", "application/json") .header("Content-Type", "application/json")
.json(body) .json(body)
.send() .send()
@@ -126,6 +171,17 @@ impl NearAiChatProvider {
let status_code = status.as_u16(); let status_code = status.as_u16();
if status_code == 401 { if status_code == 401 {
// For session token auth, distinguish session expired from plain auth failure
if !self.uses_api_key() {
let lower = response_text.to_lowercase();
let is_session_expired = lower.contains("session")
&& (lower.contains("expired") || lower.contains("invalid"));
if is_session_expired {
return Err(LlmError::SessionExpired {
provider: "nearai_chat".to_string(),
});
}
}
return Err(LlmError::AuthFailed { return Err(LlmError::AuthFailed {
provider: "nearai_chat".to_string(), provider: "nearai_chat".to_string(),
}); });
@@ -154,14 +210,31 @@ impl NearAiChatProvider {
}) })
} }
/// Fetch available models with full metadata from the `/v1/models` endpoint. /// Fetch available models from the NEAR AI API.
async fn fetch_models(&self) -> Result<Vec<ApiModelEntry>, LlmError> { ///
/// Handles session renewal on 401 (same pattern as `send_request`).
/// Supports multiple response formats: `{models: [...]}`, `{data: [...]}`, and plain array.
pub async fn list_models_full(&self) -> Result<Vec<ModelInfo>, LlmError> {
match self.list_models_inner().await {
Ok(models) => Ok(models),
Err(LlmError::SessionExpired { .. }) if !self.uses_api_key() => {
self.session.handle_auth_failure().await?;
self.list_models_inner().await
}
Err(e) => Err(e),
}
}
async fn list_models_inner(&self) -> Result<Vec<ModelInfo>, LlmError> {
let url = self.api_url("models"); let url = self.api_url("models");
let token = self.resolve_bearer_token().await?;
tracing::debug!("Fetching models from: {}", url);
let response = self let response = self
.client .client
.get(&url) .get(&url)
.header("Authorization", format!("Bearer {}", self.api_key())) .header("Authorization", format!("Bearer {}", token))
.send() .send()
.await .await
.map_err(|e| LlmError::RequestFailed { .map_err(|e| LlmError::RequestFailed {
@@ -176,6 +249,11 @@ impl NearAiChatProvider {
})?; })?;
if !status.is_success() { if !status.is_success() {
if status.as_u16() == 401 && !self.uses_api_key() {
return Err(LlmError::SessionExpired {
provider: "nearai_chat".to_string(),
});
}
let truncated = crate::agent::truncate_for_preview(&response_text, 512); let truncated = crate::agent::truncate_for_preview(&response_text, 512);
return Err(LlmError::RequestFailed { return Err(LlmError::RequestFailed {
provider: "nearai_chat".to_string(), provider: "nearai_chat".to_string(),
@@ -183,29 +261,97 @@ impl NearAiChatProvider {
}); });
} }
// Flexible model entry parsing -- handle various field names
#[derive(Deserialize)] #[derive(Deserialize)]
struct ModelsResponse { struct ModelMetadataInner {
data: Vec<ApiModelEntry>, #[serde(default)]
name: Option<String>,
#[serde(default, alias = "modelName", alias = "model_name")]
model_name: Option<String>,
} }
let resp: ModelsResponse = #[derive(Deserialize)]
serde_json::from_str(&response_text).map_err(|e| LlmError::InvalidResponse { struct ModelEntry {
provider: "nearai_chat".to_string(), #[serde(default)]
reason: format!("JSON parse error: {}", e), name: Option<String>,
})?; #[serde(default)]
id: Option<String>,
#[serde(default)]
model: Option<String>,
#[serde(default, alias = "modelName", alias = "model_name")]
model_name: Option<String>,
#[serde(default, alias = "modelId", alias = "model_id")]
model_id: Option<String>,
#[serde(default)]
metadata: Option<ModelMetadataInner>,
}
Ok(resp.data) impl ModelEntry {
fn get_name(&self) -> Option<String> {
self.name
.clone()
.or_else(|| self.id.clone())
.or_else(|| self.model.clone())
.or_else(|| self.model_name.clone())
.or_else(|| self.model_id.clone())
.or_else(|| self.metadata.as_ref().and_then(|m| m.name.clone()))
.or_else(|| self.metadata.as_ref().and_then(|m| m.model_name.clone()))
}
}
#[derive(Deserialize)]
struct ModelsResponse {
#[serde(default)]
models: Option<Vec<ModelEntry>>,
#[serde(default)]
data: Option<Vec<ModelEntry>>,
}
// Try {models: [...]} or {data: [...]} format
if let Ok(resp) = serde_json::from_str::<ModelsResponse>(&response_text)
&& let Some(entries) = resp.models.or(resp.data)
{
let models: Vec<ModelInfo> = entries
.into_iter()
.filter_map(|e| {
e.get_name().map(|name| ModelInfo {
name,
provider: None,
})
})
.collect();
if !models.is_empty() {
return Ok(models);
}
}
// Try direct array format
if let Ok(entries) = serde_json::from_str::<Vec<ModelEntry>>(&response_text) {
let models: Vec<ModelInfo> = entries
.into_iter()
.filter_map(|e| {
e.get_name().map(|name| ModelInfo {
name,
provider: None,
})
})
.collect();
if !models.is_empty() {
return Ok(models);
}
}
// Couldn't find model names in response
Err(LlmError::InvalidResponse {
provider: "nearai_chat".to_string(),
reason: format!(
"No model names found in response: {}",
&response_text[..response_text.len().min(300)]
),
})
} }
} }
/// Model entry as returned by the `/v1/models` API.
#[derive(Debug, Deserialize)]
struct ApiModelEntry {
id: String,
#[serde(default)]
context_length: Option<u32>,
}
#[async_trait] #[async_trait]
impl LlmProvider for NearAiChatProvider { impl LlmProvider for NearAiChatProvider {
async fn complete(&self, req: CompletionRequest) -> Result<CompletionResponse, LlmError> { async fn complete(&self, req: CompletionRequest) -> Result<CompletionResponse, LlmError> {
@@ -252,7 +398,6 @@ impl LlmProvider for NearAiChatProvider {
finish_reason, finish_reason,
input_tokens, input_tokens,
output_tokens, output_tokens,
response_id: None,
}) })
} }
@@ -347,7 +492,6 @@ impl LlmProvider for NearAiChatProvider {
finish_reason, finish_reason,
input_tokens, input_tokens,
output_tokens, output_tokens,
response_id: None,
}) })
} }
@@ -361,18 +505,8 @@ impl LlmProvider for NearAiChatProvider {
} }
async fn list_models(&self) -> Result<Vec<String>, LlmError> { async fn list_models(&self) -> Result<Vec<String>, LlmError> {
let models = self.fetch_models().await?; let models = self.list_models_full().await?;
Ok(models.into_iter().map(|m| m.id).collect()) Ok(models.into_iter().map(|m| m.name).collect())
}
async fn model_metadata(&self) -> Result<ModelMetadata, LlmError> {
let active = self.active_model_name();
let models = self.fetch_models().await?;
let current = models.iter().find(|m| m.id == active);
Ok(ModelMetadata {
id: active,
context_length: current.and_then(|m| m.context_length),
})
} }
fn active_model_name(&self) -> String { fn active_model_name(&self) -> String {
@@ -613,6 +747,7 @@ fn parse_usage(usage: Option<&ChatCompletionUsage>) -> (u32, u32) {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::llm::session::SessionConfig;
fn test_nearai_config(base_url: &str) -> NearAiConfig { fn test_nearai_config(base_url: &str) -> NearAiConfig {
NearAiConfig { NearAiConfig {
@@ -620,7 +755,6 @@ mod tests {
base_url: base_url.to_string(), base_url: base_url.to_string(),
auth_base_url: "https://private.near.ai".to_string(), auth_base_url: "https://private.near.ai".to_string(),
session_path: std::path::PathBuf::from("/tmp/session.json"), session_path: std::path::PathBuf::from("/tmp/session.json"),
api_mode: crate::config::NearAiApiMode::ChatCompletions,
api_key: Some(secrecy::SecretString::from("test-key".to_string())), api_key: Some(secrecy::SecretString::from("test-key".to_string())),
cheap_model: None, cheap_model: None,
fallback_model: None, fallback_model: None,
@@ -635,18 +769,22 @@ mod tests {
} }
} }
fn test_session() -> Arc<SessionManager> {
Arc::new(SessionManager::new(SessionConfig::default()))
}
#[test] #[test]
fn test_api_url_with_base_without_v1() { fn test_api_url_with_base_without_v1() {
let mut cfg = test_nearai_config("http://127.0.0.1:8318"); let mut cfg = test_nearai_config("http://127.0.0.1:8318");
let provider = NearAiChatProvider::new(cfg.clone()).expect("provider"); let provider = NearAiChatProvider::new(cfg.clone(), test_session()).expect("provider");
assert_eq!( assert_eq!(
provider.api_url("chat/completions"), provider.api_url("chat/completions"),
"http://127.0.0.1:8318/v1/chat/completions" "http://127.0.0.1:8318/v1/chat/completions"
); );
cfg.base_url = "http://127.0.0.1:8318/".to_string(); cfg.base_url = "http://127.0.0.1:8318/".to_string();
let provider = NearAiChatProvider::new(cfg).expect("provider"); let provider = NearAiChatProvider::new(cfg, test_session()).expect("provider");
assert_eq!( assert_eq!(
provider.api_url("/chat/completions"), provider.api_url("/chat/completions"),
"http://127.0.0.1:8318/v1/chat/completions" "http://127.0.0.1:8318/v1/chat/completions"
@@ -657,7 +795,7 @@ mod tests {
fn test_api_url_with_base_already_v1() { fn test_api_url_with_base_already_v1() {
let cfg = test_nearai_config("http://127.0.0.1:8318/v1"); let cfg = test_nearai_config("http://127.0.0.1:8318/v1");
let provider = NearAiChatProvider::new(cfg).expect("provider"); let provider = NearAiChatProvider::new(cfg, test_session()).expect("provider");
assert_eq!( assert_eq!(
provider.api_url("chat/completions"), provider.api_url("chat/completions"),
"http://127.0.0.1:8318/v1/chat/completions" "http://127.0.0.1:8318/v1/chat/completions"
-18
View File
@@ -153,8 +153,6 @@ pub struct CompletionResponse {
pub input_tokens: u32, pub input_tokens: u32,
pub output_tokens: u32, pub output_tokens: u32,
pub finish_reason: FinishReason, pub finish_reason: FinishReason,
/// Provider-specific response ID (e.g. for NEAR AI response chaining).
pub response_id: Option<String>,
} }
/// Why the completion finished. /// Why the completion finished.
@@ -256,8 +254,6 @@ pub struct ToolCompletionResponse {
pub input_tokens: u32, pub input_tokens: u32,
pub output_tokens: u32, pub output_tokens: u32,
pub finish_reason: FinishReason, pub finish_reason: FinishReason,
/// Provider-specific response ID (e.g. for NEAR AI response chaining).
pub response_id: Option<String>,
} }
/// Metadata about a model returned by the provider's API. /// Metadata about a model returned by the provider's API.
@@ -327,20 +323,6 @@ pub trait LlmProvider: Send + Sync {
}) })
} }
/// Seed a response chain for a thread (e.g. restoring from DB).
///
/// Providers that support response chaining (e.g. NEAR AI `previous_response_id`)
/// store this so subsequent calls send only delta messages.
fn seed_response_chain(&self, _thread_id: &str, _response_id: String) {}
/// Get the last response chain ID for a thread.
///
/// Returns `None` if the provider doesn't support chaining or has no
/// stored state for this thread.
fn get_response_chain_id(&self, _thread_id: &str) -> Option<String> {
None
}
/// Calculate cost for a completion. /// Calculate cost for a completion.
fn calculate_cost(&self, input_tokens: u32, output_tokens: u32) -> Decimal { fn calculate_cost(&self, input_tokens: u32, output_tokens: u32) -> Decimal {
let (input_cost, output_cost) = self.cost_per_token(); let (input_cost, output_cost) = self.cost_per_token();
+687 -159
View File
File diff suppressed because it is too large Load Diff
-8
View File
@@ -228,14 +228,6 @@ impl LlmProvider for CachedProvider {
fn set_model(&self, model: &str) -> Result<(), LlmError> { fn set_model(&self, model: &str) -> Result<(), LlmError> {
self.inner.set_model(model) self.inner.set_model(model)
} }
fn seed_response_chain(&self, thread_id: &str, response_id: String) {
self.inner.seed_response_chain(thread_id, response_id);
}
fn get_response_chain_id(&self, thread_id: &str) -> Option<String> {
self.inner.get_response_chain_id(thread_id)
}
} }
#[cfg(test)] #[cfg(test)]
-8
View File
@@ -210,14 +210,6 @@ impl LlmProvider for RetryProvider {
self.inner.set_model(model) self.inner.set_model(model)
} }
fn seed_response_chain(&self, thread_id: &str, response_id: String) {
self.inner.seed_response_chain(thread_id, response_id)
}
fn get_response_chain_id(&self, thread_id: &str) -> Option<String> {
self.inner.get_response_chain_id(thread_id)
}
fn calculate_cost(&self, input_tokens: u32, output_tokens: u32) -> Decimal { fn calculate_cost(&self, input_tokens: u32, output_tokens: u32) -> Decimal {
self.inner.calculate_cost(input_tokens, output_tokens) self.inner.calculate_cost(input_tokens, output_tokens)
} }
-2
View File
@@ -445,7 +445,6 @@ where
input_tokens: saturate_u32(response.usage.input_tokens), input_tokens: saturate_u32(response.usage.input_tokens),
output_tokens: saturate_u32(response.usage.output_tokens), output_tokens: saturate_u32(response.usage.output_tokens),
finish_reason: finish, finish_reason: finish,
response_id: None,
}) })
} }
@@ -511,7 +510,6 @@ where
input_tokens: saturate_u32(response.usage.input_tokens), input_tokens: saturate_u32(response.usage.input_tokens),
output_tokens: saturate_u32(response.usage.output_tokens), output_tokens: saturate_u32(response.usage.output_tokens),
finish_reason: finish, finish_reason: finish,
response_id: None,
}) })
} }
+19 -9
View File
@@ -513,20 +513,30 @@ impl SessionManager {
})? { })? {
value value
} else { } else {
tracing::warn!( // Try the legacy key. Only warn if it actually exists (real
"nearai.session_token missing; falling back to legacy nearai.session for backwards compatibility" // backwards-compat migration). When neither key is present
); // (fresh install), just return the "No session in DB" error.
store let legacy = store
.get_setting(&user_id, "nearai.session") .get_setting(&user_id, "nearai.session")
.await .await
.map_err(|e| LlmError::SessionRenewalFailed { .map_err(|e| LlmError::SessionRenewalFailed {
provider: "nearai".to_string(), provider: "nearai".to_string(),
reason: format!("DB query failed: {}", e), reason: format!("DB query failed: {}", e),
})? })?;
.ok_or(LlmError::SessionRenewalFailed { match legacy {
provider: "nearai".to_string(), Some(value) => {
reason: "No session in DB".to_string(), tracing::warn!(
})? "nearai.session_token missing; falling back to legacy nearai.session for backwards compatibility"
);
value
}
None => {
return Err(LlmError::SessionRenewalFailed {
provider: "nearai".to_string(),
reason: "No session in DB".to_string(),
});
}
}
}; };
let session: SessionData = let session: SessionData =
+18 -31
View File
@@ -3,7 +3,7 @@
use std::sync::Arc; use std::sync::Arc;
use clap::Parser; use clap::Parser;
use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitExt}; use tracing_subscriber::EnvFilter;
use ironclaw::{ use ironclaw::{
agent::{Agent, AgentDeps, SessionManager}, agent::{Agent, AgentDeps, SessionManager},
@@ -14,7 +14,7 @@ use ironclaw::{
RegisteredEndpoint, SharedWasmChannel, WasmChannelLoader, WasmChannelRouter, RegisteredEndpoint, SharedWasmChannel, WasmChannelLoader, WasmChannelRouter,
WasmChannelRuntime, WasmChannelRuntimeConfig, create_wasm_channel_router, WasmChannelRuntime, WasmChannelRuntimeConfig, create_wasm_channel_router,
}, },
web::log_layer::{LogBroadcaster, WebLogLayer}, web::log_layer::LogBroadcaster,
}, },
cli::{ cli::{
Cli, Command, run_mcp_command, run_pairing_command, run_service_command, Cli, Command, run_mcp_command, run_pairing_command, run_service_command,
@@ -201,6 +201,9 @@ async fn main() -> anyhow::Result<()> {
) )
.init(); .init();
let _ = dotenvy::dotenv();
ironclaw::bootstrap::load_ironclaw_env();
return ironclaw::cli::run_doctor_command().await; return ironclaw::cli::run_doctor_command().await;
} }
Some(Command::Status) => { Some(Command::Status) => {
@@ -210,6 +213,9 @@ async fn main() -> anyhow::Result<()> {
) )
.init(); .init();
let _ = dotenvy::dotenv();
ironclaw::bootstrap::load_ironclaw_env();
return run_status_command().await; return run_status_command().await;
} }
Some(Command::Worker { Some(Command::Worker {
@@ -360,23 +366,14 @@ async fn main() -> anyhow::Result<()> {
}; };
let session = create_session_manager(session_config).await; let session = create_session_manager(session_config).await;
// Initialize tracing
let env_filter = EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new("ironclaw=info,tower_http=warn"));
// Create log broadcaster before tracing init so the WebLogLayer can capture all events. // Create log broadcaster before tracing init so the WebLogLayer can capture all events.
// This gets wired to the gateway's /api/logs/events SSE endpoint later. // This gets wired to the gateway's /api/logs/events SSE endpoint later.
let log_broadcaster = Arc::new(LogBroadcaster::new()); let log_broadcaster = Arc::new(LogBroadcaster::new());
tracing_subscriber::registry() // Initialize tracing with a reloadable EnvFilter so the gateway can switch
.with(env_filter) // log levels (e.g. ironclaw=debug) at runtime without restarting.
.with( let log_level_handle =
tracing_subscriber::fmt::layer() ironclaw::channels::web::log_layer::init_tracing(Arc::clone(&log_broadcaster));
.with_target(false)
.with_writer(ironclaw::tracing_fmt::TruncatingStderr::default()),
)
.with(WebLogLayer::new(Arc::clone(&log_broadcaster)))
.init();
// Create CLI channel // Create CLI channel
let repl_channel = if let Some(ref msg) = cli.message { let repl_channel = if let Some(ref msg) = cli.message {
@@ -730,7 +727,6 @@ async fn main() -> anyhow::Result<()> {
// Initialize tool registry // Initialize tool registry
let tools = Arc::new(ToolRegistry::new()); let tools = Arc::new(ToolRegistry::new());
tools.register_builtin_tools(); tools.register_builtin_tools();
tracing::info!("Registered {} built-in tools", tools.count());
// Create embeddings provider if configured // Create embeddings provider if configured
let embeddings: Option<Arc<dyn EmbeddingProvider>> = if config.embeddings.enabled { let embeddings: Option<Arc<dyn EmbeddingProvider>> = if config.embeddings.enabled {
@@ -1024,11 +1020,12 @@ async fn main() -> anyhow::Result<()> {
// Set up orchestrator for sandboxed job execution // Set up orchestrator for sandboxed job execution
// When allow_local_tools is false (default), the LLM uses create_job for FS/shell work. // When allow_local_tools is false (default), the LLM uses create_job for FS/shell work.
// When allow_local_tools is true, dev tools are also registered directly (current behavior). // When allow_local_tools is true, dev tools are also registered directly (current behavior).
if config.agent.allow_local_tools { // register_builder_tool() already calls register_dev_tools() internally,
// so only register them here when the builder didn't already do it.
let builder_registered_dev_tools =
config.builder.enabled && (config.agent.allow_local_tools || !config.sandbox.enabled);
if config.agent.allow_local_tools && !builder_registered_dev_tools {
tools.register_dev_tools(); tools.register_dev_tools();
tracing::info!(
"Local tools enabled (allow_local_tools=true), dev tools registered directly"
);
} }
// Shared state for job events (used by both orchestrator and web gateway) // Shared state for job events (used by both orchestrator and web gateway)
@@ -1079,7 +1076,6 @@ async fn main() -> anyhow::Result<()> {
} }
}); });
tracing::info!("Orchestrator API started on :50051, sandbox delegation enabled");
if config.claude_code.enabled { if config.claude_code.enabled {
tracing::info!( tracing::info!(
"Claude Code sandbox mode available (model: {}, max_turns: {})", "Claude Code sandbox mode available (model: {}, max_turns: {})",
@@ -1333,9 +1329,6 @@ async fn main() -> anyhow::Result<()> {
// Seed workspace with core identity files on first boot // Seed workspace with core identity files on first boot
if let Some(ref ws) = workspace { if let Some(ref ws) = workspace {
match ws.seed_if_empty().await { match ws.seed_if_empty().await {
Ok(count) if count > 0 => {
tracing::info!("Workspace seeded with {} core files", count);
}
Ok(_) => {} Ok(_) => {}
Err(e) => { Err(e) => {
tracing::warn!("Failed to seed workspace: {}", e); tracing::warn!("Failed to seed workspace: {}", e);
@@ -1426,6 +1419,7 @@ async fn main() -> anyhow::Result<()> {
} }
gw = gw.with_session_manager(Arc::clone(&session_manager)); gw = gw.with_session_manager(Arc::clone(&session_manager));
gw = gw.with_log_broadcaster(Arc::clone(&log_broadcaster)); gw = gw.with_log_broadcaster(Arc::clone(&log_broadcaster));
gw = gw.with_log_level_handle(Arc::clone(&log_level_handle));
gw = gw.with_tool_registry(Arc::clone(&tools)); gw = gw.with_tool_registry(Arc::clone(&tools));
if let Some(ref ext_mgr) = extension_manager { if let Some(ref ext_mgr) = extension_manager {
gw = gw.with_extension_manager(Arc::clone(ext_mgr)); gw = gw.with_extension_manager(Arc::clone(ext_mgr));
@@ -1464,11 +1458,6 @@ async fn main() -> anyhow::Result<()> {
gw.auth_token() gw.auth_token()
)); ));
tracing::info!(
"Web gateway enabled on {}:{}",
gw_config.host,
gw_config.port
);
tracing::info!("Web UI: http://{}:{}/", gw_config.host, gw_config.port); tracing::info!("Web UI: http://{}:{}/", gw_config.host, gw_config.port);
channel_names.push("gateway".to_string()); channel_names.push("gateway".to_string());
@@ -1511,8 +1500,6 @@ async fn main() -> anyhow::Result<()> {
Some(session_manager), Some(session_manager),
); );
tracing::info!("Agent initialized, starting main loop...");
// Print boot screen for interactive CLI mode (not single-message mode). // Print boot screen for interactive CLI mode (not single-message mode).
if config.channels.cli.enabled && cli.message.is_none() { if config.channels.cli.enabled && cli.message.is_none() {
let boot_info = ironclaw::boot_screen::BootInfo { let boot_info = ironclaw::boot_screen::BootInfo {
+54 -1
View File
@@ -826,6 +826,12 @@ impl SetupWizard {
self.session_manager = Some(session); self.session_manager = Some(session);
// Persist session token to the database so the runtime can load it
// via `attach_store()` → `load_session_from_db()` without the
// backwards-compat fallback. The session manager saved to disk but
// doesn't have a DB store attached during onboarding.
self.persist_session_to_db().await;
// If the user chose the API key path, NEARAI_API_KEY is now set // If the user chose the API key path, NEARAI_API_KEY is now set
// in the environment. Persist it to the encrypted secrets store // in the environment. Persist it to the encrypted secrets store
// so inject_llm_keys_from_secrets() can load it on future runs. // so inject_llm_keys_from_secrets() can load it on future runs.
@@ -1160,7 +1166,6 @@ impl SetupWizard {
base_url, base_url,
auth_base_url, auth_base_url,
session_path: crate::llm::session::default_session_path(), session_path: crate::llm::session::default_session_path(),
api_mode: crate::config::NearAiApiMode::Responses,
api_key: None, api_key: None,
fallback_model: None, fallback_model: None,
max_retries: 3, max_retries: 3,
@@ -1861,6 +1866,54 @@ impl SetupWizard {
Ok(()) Ok(())
} }
/// Persist the NEAR AI session token to the database.
///
/// The session manager writes to disk during `ensure_authenticated()` but
/// doesn't have a DB store attached during onboarding. This reads the
/// session file from disk and stores it under the `nearai.session_token`
/// key so the runtime's `attach_store()` finds it without fallback.
///
/// Best-effort: silently ignores errors (no DB connection yet, no
/// session file, etc.).
async fn persist_session_to_db(&self) {
let session_path = crate::llm::session::default_session_path();
let data = match std::fs::read_to_string(&session_path) {
Ok(d) if !d.trim().is_empty() => d,
_ => return,
};
let value: serde_json::Value = match serde_json::from_str(&data) {
Ok(v) => v,
Err(_) => return,
};
#[cfg(feature = "postgres")]
if let Some(ref pool) = self.db_pool {
let store = crate::history::Store::from_pool(pool.clone());
if let Err(e) = store
.set_setting("default", "nearai.session_token", &value)
.await
{
tracing::debug!("Could not persist session token to postgres: {}", e);
} else {
tracing::debug!("Session token persisted to database");
return;
}
}
#[cfg(feature = "libsql")]
if let Some(ref backend) = self.db_backend {
use crate::db::SettingsStore as _;
if let Err(e) = backend
.set_setting("default", "nearai.session_token", &value)
.await
{
tracing::debug!("Could not persist session token to libsql: {}", e);
} else {
tracing::debug!("Session token persisted to database");
}
}
}
/// Persist settings to DB and bootstrap .env after each step. /// Persist settings to DB and bootstrap .env after each step.
/// ///
/// Silently ignores errors (e.g., DB not connected yet before step 1 /// Silently ignores errors (e.g., DB not connected yet before step 1
+1 -1
View File
@@ -197,7 +197,7 @@ impl SkillRegistry {
let source = make_source(path.clone()); let source = make_source(path.clone());
match self.load_skill_md(&skill_md, trust, source).await { match self.load_skill_md(&skill_md, trust, source).await {
Ok((name, skill)) => { Ok((name, skill)) => {
tracing::info!("Loaded skill: {}", name); tracing::debug!("Loaded skill: {}", name);
results.push((name, skill)); results.push((name, skill));
} }
Err(e) => { Err(e) => {
-2
View File
@@ -168,7 +168,6 @@ impl LlmProvider for StubLlm {
input_tokens: 10, input_tokens: 10,
output_tokens: 5, output_tokens: 5,
finish_reason: FinishReason::Stop, finish_reason: FinishReason::Stop,
response_id: None,
}) })
} }
@@ -186,7 +185,6 @@ impl LlmProvider for StubLlm {
input_tokens: 10, input_tokens: 10,
output_tokens: 5, output_tokens: 5,
finish_reason: FinishReason::Stop, finish_reason: FinishReason::Stop,
response_id: None,
}) })
} }
} }
-2
View File
@@ -227,7 +227,6 @@ impl WorkerHttpClient {
input_tokens: proxy_resp.input_tokens, input_tokens: proxy_resp.input_tokens,
output_tokens: proxy_resp.output_tokens, output_tokens: proxy_resp.output_tokens,
finish_reason: parse_finish_reason(&proxy_resp.finish_reason), finish_reason: parse_finish_reason(&proxy_resp.finish_reason),
response_id: None,
}) })
} }
@@ -255,7 +254,6 @@ impl WorkerHttpClient {
input_tokens: proxy_resp.input_tokens, input_tokens: proxy_resp.input_tokens,
output_tokens: proxy_resp.output_tokens, output_tokens: proxy_resp.output_tokens,
finish_reason: parse_finish_reason(&proxy_resp.finish_reason), finish_reason: parse_finish_reason(&proxy_resp.finish_reason),
response_id: None,
}) })
} }
+3 -1
View File
@@ -15,6 +15,7 @@ use ironclaw::{
config::Config, config::Config,
history::Store, history::Store,
llm::{SessionConfig, create_llm_provider, create_session_manager}, llm::{SessionConfig, create_llm_provider, create_session_manager},
safety::SafetyLayer,
workspace::Workspace, workspace::Workspace,
}; };
@@ -96,7 +97,8 @@ async fn test_heartbeat_end_to_end() {
let hb_config = ironclaw::agent::HeartbeatConfig::default(); let hb_config = ironclaw::agent::HeartbeatConfig::default();
let hygiene_config = ironclaw::workspace::hygiene::HygieneConfig::default(); let hygiene_config = ironclaw::workspace::hygiene::HygieneConfig::default();
let runner = HeartbeatRunner::new(hb_config, hygiene_config, workspace, llm); let safety = Arc::new(SafetyLayer::new(&config.safety));
let runner = HeartbeatRunner::new(hb_config, hygiene_config, workspace, llm, safety);
let result = runner.check_heartbeat().await; let result = runner.check_heartbeat().await;
+2 -5
View File
@@ -71,7 +71,6 @@ impl LlmProvider for MockLlmProvider {
input_tokens: 10, input_tokens: 10,
output_tokens: 5, output_tokens: 5,
finish_reason: FinishReason::Stop, finish_reason: FinishReason::Stop,
response_id: None,
}) })
} }
@@ -97,7 +96,6 @@ impl LlmProvider for MockLlmProvider {
input_tokens: 15, input_tokens: 15,
output_tokens: 8, output_tokens: 8,
finish_reason: FinishReason::ToolUse, finish_reason: FinishReason::ToolUse,
response_id: None,
}) })
} else { } else {
Ok(ToolCompletionResponse { Ok(ToolCompletionResponse {
@@ -106,7 +104,6 @@ impl LlmProvider for MockLlmProvider {
input_tokens: 10, input_tokens: 10,
output_tokens: 4, output_tokens: 4,
finish_reason: FinishReason::Stop, finish_reason: FinishReason::Stop,
response_id: None,
}) })
} }
} }
@@ -145,7 +142,6 @@ impl LlmProvider for FixedModelProvider {
input_tokens: 10, input_tokens: 10,
output_tokens: 5, output_tokens: 5,
finish_reason: FinishReason::Stop, finish_reason: FinishReason::Stop,
response_id: None,
}) })
} }
@@ -159,7 +155,6 @@ impl LlmProvider for FixedModelProvider {
input_tokens: 10, input_tokens: 10,
output_tokens: 5, output_tokens: 5,
finish_reason: FinishReason::Stop, finish_reason: FinishReason::Stop,
response_id: None,
}) })
} }
@@ -190,6 +185,7 @@ async fn start_test_server_with_provider(
workspace: None, workspace: None,
session_manager: None, session_manager: None,
log_broadcaster: None, log_broadcaster: None,
log_level_handle: None,
extension_manager: None, extension_manager: None,
tool_registry: None, tool_registry: None,
store: None, store: None,
@@ -674,6 +670,7 @@ async fn test_no_llm_provider_returns_503() {
workspace: None, workspace: None,
session_manager: None, session_manager: None,
log_broadcaster: None, log_broadcaster: None,
log_level_handle: None,
extension_manager: None, extension_manager: None,
tool_registry: None, tool_registry: None,
store: None, store: None,
+1
View File
@@ -43,6 +43,7 @@ async fn start_test_server() -> (
workspace: None, workspace: None,
session_manager: None, session_manager: None,
log_broadcaster: None, log_broadcaster: None,
log_level_handle: None,
extension_manager: None, extension_manager: None,
tool_registry: None, tool_registry: None,
store: None, store: None,