llm: switch Skald to agent-loop Model clients; drop llm-client (phase 1, D13)
The LLM call path now runs on the agent-loop crate's clients and trait: - core-api: BuiltLlmClient.client is Arc<dyn agent_loop::model::Model>; chatbot.rs (ChatbotClient + wire types) deleted; APP_NAME re-exported from agent-loop - providers (openai/anthropic/ollama/openrouter/requesty/declared) build OpenAiModel/AnthropicModel/OllamaModel with the model's wire id - LoggingModel decorator (llm/logging.rs) replaces LoggingChatbotClient; per-request correlation (session/stack/user) travels in the new ModelRequest.log field, never sent to providers - llm_call/llm_loop/compactor speak Model::complete + ModelResponse; retriability via Model::is_retriable (structured status, B6 rule now the crate's default); payload persistence reads RawMeta off ModelResponse/ModelError - crates/llm-client and skald-core/src/chatbot deleted Full workspace test suite green (incl. 162 skald-core + 32 agent-loop).
This commit is contained in:
@@ -78,6 +78,6 @@ base64 = "0.22"
|
||||
sha2 = "0.10"
|
||||
notify = "8"
|
||||
honcho-client = { path = "../honcho-client" }
|
||||
llm-client = { path = "../llm-client" }
|
||||
agent-loop = { path = "../agent-loop" }
|
||||
core-api = { path = "../core-api" }
|
||||
mcp-client = { path = "../mcp-client" }
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
//! Transparent logging wrapper for any [`ChatbotClient`].
|
||||
//!
|
||||
//! [`LoggingChatbotClient`] intercepts every `chat_with_tools_raw` call, captures
|
||||
//! the raw HTTP request/response from the inner provider, persists a **metadata-only**
|
||||
//! row to `llm_requests` in `system.db` (fire-and-forget), then returns the raw data
|
||||
//! to the caller so it can write the **payload** to the user's own database.
|
||||
//!
|
||||
//! The split keeps conversation content (payloads) behind the user key while
|
||||
//! metadata (cost, tokens, timing) stays in the admin-readable registry.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde_json::Value;
|
||||
use sqlx::SqlitePool;
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::db::llm_requests;
|
||||
|
||||
use super::{ChatOptions, ChatResponse, ChatbotClient, LlmRawMeta, LlmTurn, Message, StreamDelta};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
pub struct LoggingChatbotClient {
|
||||
inner: Arc<dyn ChatbotClient>,
|
||||
pool: Arc<SqlitePool>,
|
||||
model_name: String,
|
||||
}
|
||||
|
||||
impl LoggingChatbotClient {
|
||||
pub fn new(
|
||||
inner: Arc<dyn ChatbotClient>,
|
||||
pool: Arc<SqlitePool>,
|
||||
model_name: impl Into<String>,
|
||||
) -> Self {
|
||||
Self { inner, pool, model_name: model_name.into() }
|
||||
}
|
||||
|
||||
/// Shared logging tail of both raw entry points: writes the metadata-only
|
||||
/// row to `system.db` (fire-and-forget), then passes the result through.
|
||||
async fn log_and_return(
|
||||
&self,
|
||||
options: &ChatOptions,
|
||||
duration: Duration,
|
||||
result: anyhow::Result<(LlmTurn, Option<LlmRawMeta>)>,
|
||||
) -> anyhow::Result<(LlmTurn, Option<LlmRawMeta>)> {
|
||||
let duration_ms = duration.as_millis() as i64;
|
||||
|
||||
let session_id = options.session_id;
|
||||
let stack_id = options.stack_id;
|
||||
let user_id = options.user_id.clone();
|
||||
let request_id = options.request_id.clone();
|
||||
let model_name = self.model_name.clone();
|
||||
let pool = Arc::clone(&self.pool);
|
||||
|
||||
match result {
|
||||
Ok((turn, meta)) => {
|
||||
let (input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens) = match &turn {
|
||||
LlmTurn::Message(r) => (r.input_tokens, r.output_tokens, r.cache_read_tokens, r.cache_creation_tokens),
|
||||
LlmTurn::ToolCalls { input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens, .. } =>
|
||||
(*input_tokens, *output_tokens, *cache_read_tokens, *cache_creation_tokens),
|
||||
};
|
||||
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = llm_requests::insert(&pool, llm_requests::LlmRequestRow {
|
||||
request_id,
|
||||
user_id,
|
||||
session_id,
|
||||
stack_id,
|
||||
model_name,
|
||||
error_text: None,
|
||||
input_tokens: input_tokens.map(|n| n as i64),
|
||||
output_tokens: output_tokens.map(|n| n as i64),
|
||||
duration_ms,
|
||||
cache_read_tokens: cache_read_tokens.map(|n| n as i64),
|
||||
cache_creation_tokens: cache_creation_tokens.map(|n| n as i64),
|
||||
}).await {
|
||||
warn!(error = %e, "llm_requests: failed to insert log row");
|
||||
}
|
||||
});
|
||||
|
||||
Ok((turn, meta))
|
||||
}
|
||||
|
||||
Err(e) => {
|
||||
let error_text = e.to_string();
|
||||
|
||||
tokio::spawn(async move {
|
||||
if let Err(log_err) = llm_requests::insert(&pool, llm_requests::LlmRequestRow {
|
||||
request_id,
|
||||
user_id,
|
||||
session_id,
|
||||
stack_id,
|
||||
model_name,
|
||||
error_text: Some(error_text),
|
||||
input_tokens: None,
|
||||
output_tokens: None,
|
||||
duration_ms,
|
||||
cache_read_tokens: None,
|
||||
cache_creation_tokens: None,
|
||||
}).await {
|
||||
warn!(error = %log_err, "llm_requests: failed to insert error log row");
|
||||
}
|
||||
});
|
||||
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ChatbotClient for LoggingChatbotClient {
|
||||
/// Passthrough — logging only applies to the tool-calling path.
|
||||
async fn chat(
|
||||
&self,
|
||||
messages: &[Message],
|
||||
options: &ChatOptions,
|
||||
) -> anyhow::Result<ChatResponse> {
|
||||
self.inner.chat(messages, options).await
|
||||
}
|
||||
|
||||
/// Passthrough that drops the raw meta. Used by callers that do not need
|
||||
/// payload capture (e.g. the compactor).
|
||||
async fn chat_with_tools(
|
||||
&self,
|
||||
messages: &[Value],
|
||||
tools: &[Value],
|
||||
options: &ChatOptions,
|
||||
) -> anyhow::Result<LlmTurn> {
|
||||
let (turn, _) = self.chat_with_tools_raw(messages, tools, options).await?;
|
||||
Ok(turn)
|
||||
}
|
||||
|
||||
/// Intercepts the call, delegates to `inner.chat_with_tools_raw` to capture
|
||||
/// HTTP wire data, writes a **metadata-only** row to `system.db`, then returns
|
||||
/// the raw data so the caller can persist payloads to the user's own database.
|
||||
async fn chat_with_tools_raw(
|
||||
&self,
|
||||
messages: &[Value],
|
||||
tools: &[Value],
|
||||
options: &ChatOptions,
|
||||
) -> anyhow::Result<(LlmTurn, Option<LlmRawMeta>)> {
|
||||
let start = Instant::now();
|
||||
let result = self.inner.chat_with_tools_raw(messages, tools, options).await;
|
||||
self.log_and_return(options, start.elapsed(), result).await
|
||||
}
|
||||
|
||||
/// Streaming twin of `chat_with_tools_raw`: forwards `delta_tx` untouched to
|
||||
/// the inner client (deltas are not logged — only the final turn is), then
|
||||
/// applies the same metadata logging. Without this override the trait
|
||||
/// default would silently fall back to the buffered call.
|
||||
async fn chat_with_tools_raw_streaming(
|
||||
&self,
|
||||
messages: &[Value],
|
||||
tools: &[Value],
|
||||
options: &ChatOptions,
|
||||
delta_tx: mpsc::Sender<StreamDelta>,
|
||||
) -> anyhow::Result<(LlmTurn, Option<LlmRawMeta>)> {
|
||||
let start = Instant::now();
|
||||
let result = self.inner.chat_with_tools_raw_streaming(messages, tools, options, delta_tx).await;
|
||||
self.log_and_return(options, start.elapsed(), result).await
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
pub mod logging;
|
||||
|
||||
// Re-export from the independent llm-client crate.
|
||||
pub use llm_client::{
|
||||
ChatOptions, ChatResponse, ChatbotClient, LlmError, LlmRawMeta, LlmTurn, Message, StreamDelta,
|
||||
ToolCall, anthropic, http_status, lm_studio, ollama, openai,
|
||||
};
|
||||
@@ -52,7 +52,6 @@ use tracing::{debug, info, warn};
|
||||
use core_api::{ConfigProperty, ConfigSet, PropertyType};
|
||||
|
||||
use crate::chat_event_bus::{ChatEventBus, CompactionEvent};
|
||||
use crate::chatbot::ChatOptions;
|
||||
use crate::config::CompactionConfig;
|
||||
use crate::config_store::GlobalConfigManager;
|
||||
use crate::db::{chat_history, chat_llm_tools, chat_summaries};
|
||||
@@ -353,25 +352,28 @@ impl ContextCompactor {
|
||||
json!({ "role": "user", "content": conversation_text }),
|
||||
];
|
||||
|
||||
let options = ChatOptions {
|
||||
let request = agent_loop::model::ModelRequest {
|
||||
messages: messages_payload,
|
||||
tools: Vec::new(),
|
||||
model: llm.model.clone(),
|
||||
max_tokens: None,
|
||||
temperature: Some(0.3),
|
||||
session_id: Some(session_id),
|
||||
stack_id: Some(stack_id),
|
||||
user_id: None,
|
||||
request_id: None,
|
||||
request_id: uuid::Uuid::new_v4().to_string(),
|
||||
conversation: agent_loop::ids::ConversationId::new(format!("session:{session_id}")),
|
||||
frame: agent_loop::ids::FrameId(stack_id),
|
||||
extras: serde_json::Value::Null,
|
||||
log: Some(json!({ "session_id": session_id, "stack_id": stack_id })),
|
||||
};
|
||||
|
||||
let turn = llm.client.chat_with_tools(&messages_payload, &[], &options).await
|
||||
let resp = llm.client.complete(&request, None).await
|
||||
.map_err(|e| {
|
||||
warn!(stack_id, error = %e, "compactor: LLM call failed");
|
||||
e
|
||||
})?;
|
||||
|
||||
let summary_text = match turn {
|
||||
crate::chatbot::LlmTurn::Message(resp) => resp.content,
|
||||
crate::chatbot::LlmTurn::ToolCalls { content, .. } => {
|
||||
let summary_text = match resp {
|
||||
agent_loop::model::ModelResponse::Message { content, .. } => content,
|
||||
agent_loop::model::ModelResponse::ToolCalls { content, .. } => {
|
||||
warn!(stack_id, "compactor: unexpected tool calls in summary response, using content");
|
||||
content
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! DB operations for the `llm_requests` table (metadata only).
|
||||
//!
|
||||
//! Every `chat_with_tools` call is logged here by the
|
||||
//! [`crate::chatbot::logging::LoggingChatbotClient`] wrapper.
|
||||
//! [`crate::llm::logging::LoggingModel`] decorator.
|
||||
//! Payloads (request/response bodies + headers) live in `llm_request_payloads`
|
||||
//! in the owner bucket (`{userid}.db`), correlated by `request_id`.
|
||||
//! Rows are retained for `llm.request_log.retention_days` days (default 14).
|
||||
|
||||
@@ -14,7 +14,6 @@ pub mod agents;
|
||||
pub mod approval;
|
||||
pub mod chat_event_bus;
|
||||
pub mod chat_hub;
|
||||
pub mod chatbot;
|
||||
pub mod clarification;
|
||||
pub mod command;
|
||||
pub mod compactor;
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
//! Transparent logging decorator for any [`agent_loop::model::Model`].
|
||||
//!
|
||||
//! [`LoggingModel`] intercepts every `complete` call, measures the duration,
|
||||
//! and persists a **metadata-only** row to `llm_requests` in `system.db`
|
||||
//! (fire-and-forget). Per-request correlation (session/stack/user id) travels
|
||||
//! in [`ModelRequest::log`], set by the caller; the payload (request/response
|
||||
//! bodies) is returned to the caller inside [`ModelResponse::raw`] /
|
||||
//! [`ModelError::raw`] so it can be written to the user's own database.
|
||||
//!
|
||||
//! The split keeps conversation content (payloads) behind the user key while
|
||||
//! metadata (cost, tokens, timing) stays in the admin-readable registry.
|
||||
//! (Successor of `chatbot::logging::LoggingChatbotClient`, blueprint D13.)
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use sqlx::SqlitePool;
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::warn;
|
||||
|
||||
use agent_loop::model::{Model, ModelError, ModelRequest, ModelResponse, StreamDelta};
|
||||
|
||||
use crate::db::llm_requests;
|
||||
|
||||
pub struct LoggingModel {
|
||||
inner: Arc<dyn Model>,
|
||||
pool: Arc<SqlitePool>,
|
||||
model_name: String,
|
||||
}
|
||||
|
||||
impl LoggingModel {
|
||||
pub fn new(inner: Arc<dyn Model>, pool: Arc<SqlitePool>, model_name: impl Into<String>) -> Self {
|
||||
Self { inner, pool, model_name: model_name.into() }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Model for LoggingModel {
|
||||
async fn complete(
|
||||
&self,
|
||||
req: &ModelRequest,
|
||||
deltas: Option<mpsc::Sender<StreamDelta>>,
|
||||
) -> Result<ModelResponse, ModelError> {
|
||||
let start = Instant::now();
|
||||
let result = self.inner.complete(req, deltas).await;
|
||||
let duration_ms = start.elapsed().as_millis() as i64;
|
||||
|
||||
// Per-request correlation set by the caller (llm_call / compactor).
|
||||
let log = req.log.clone().unwrap_or_default();
|
||||
let session_id = log["session_id"].as_i64();
|
||||
let stack_id = log["stack_id"].as_i64();
|
||||
let user_id = log["user_id"].as_str().map(str::to_string);
|
||||
let request_id = Some(req.request_id.clone());
|
||||
let model_name = self.model_name.clone();
|
||||
let pool = Arc::clone(&self.pool);
|
||||
|
||||
match &result {
|
||||
Ok(resp) => {
|
||||
let usage = resp.usage();
|
||||
let (input_tokens, output_tokens, cache_read, cache_write) = (
|
||||
usage.input_tokens.map(|n| n as i64),
|
||||
usage.output_tokens.map(|n| n as i64),
|
||||
usage.cache_read.map(|n| n as i64),
|
||||
usage.cache_write.map(|n| n as i64),
|
||||
);
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = llm_requests::insert(&pool, llm_requests::LlmRequestRow {
|
||||
request_id,
|
||||
user_id,
|
||||
session_id,
|
||||
stack_id,
|
||||
model_name,
|
||||
error_text: None,
|
||||
input_tokens,
|
||||
output_tokens,
|
||||
duration_ms,
|
||||
cache_read_tokens: cache_read,
|
||||
cache_creation_tokens: cache_write,
|
||||
}).await {
|
||||
warn!(error = %e, "llm_requests: failed to insert log row");
|
||||
}
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
let error_text = e.to_string();
|
||||
tokio::spawn(async move {
|
||||
if let Err(log_err) = llm_requests::insert(&pool, llm_requests::LlmRequestRow {
|
||||
request_id,
|
||||
user_id,
|
||||
session_id,
|
||||
stack_id,
|
||||
model_name,
|
||||
error_text: Some(error_text),
|
||||
input_tokens: None,
|
||||
output_tokens: None,
|
||||
duration_ms,
|
||||
cache_read_tokens: None,
|
||||
cache_creation_tokens: None,
|
||||
}).await {
|
||||
warn!(error = %log_err, "llm_requests: failed to insert error log row");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
fn is_retriable(&self, err: &ModelError) -> bool {
|
||||
self.inner.is_retriable(err)
|
||||
}
|
||||
}
|
||||
@@ -8,11 +8,11 @@ use sqlx::SqlitePool;
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::chatbot::ChatbotClient;
|
||||
use crate::chatbot::logging::LoggingChatbotClient;
|
||||
use agent_loop::model::Model;
|
||||
use core_api::provider::LlmStrength;
|
||||
use crate::provider::{ApiProvider, ProviderRegistry, ReasoningMode};
|
||||
|
||||
use super::logging::LoggingModel;
|
||||
use super::providers::RemoteLlmModelInfo;
|
||||
use super::{ClientStatus, LlmEntry, LlmModelInfo, LlmModelRecord, LlmProviderInfo, LlmProviderRecord};
|
||||
use super::db;
|
||||
@@ -512,8 +512,8 @@ fn build_entry(
|
||||
let prompt_cache = built.prompt_cache;
|
||||
let extra = model.extra_params.clone();
|
||||
|
||||
let client: Arc<dyn ChatbotClient> = match log_pool {
|
||||
Some(pool) => Arc::new(LoggingChatbotClient::new(inner, pool, &model.name)),
|
||||
let client: Arc<dyn Model> = match log_pool {
|
||||
Some(pool) => Arc::new(LoggingModel::new(inner, pool, &model.name)),
|
||||
None => inner,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
pub(crate) mod db;
|
||||
pub mod logging;
|
||||
pub mod manager;
|
||||
pub mod providers;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::chatbot::ChatbotClient;
|
||||
use agent_loop::model::Model;
|
||||
|
||||
use crate::provider::ServiceType;
|
||||
|
||||
pub use core_api::provider::{LlmProviderRecord, LlmModelRecord, LlmStrength, ReasoningMode};
|
||||
@@ -13,7 +15,7 @@ pub use manager::{LlmManager, sort_models_for_agent};
|
||||
/// A resolved, ready-to-use LLM client with its associated metadata.
|
||||
#[derive(Clone)]
|
||||
pub struct LlmEntry {
|
||||
pub client: Arc<dyn ChatbotClient>,
|
||||
pub client: Arc<dyn Model>,
|
||||
pub model: String,
|
||||
pub model_db_id: i64,
|
||||
pub strength: Option<LlmStrength>,
|
||||
|
||||
@@ -2,7 +2,7 @@ use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
|
||||
use crate::chatbot::anthropic::AnthropicClient;
|
||||
use agent_loop::models::AnthropicModel;
|
||||
use crate::llm::{LlmModelRecord, LlmProviderRecord};
|
||||
use crate::llm::providers::{RemoteLlmModelInfo, extra_with_reasoning};
|
||||
use crate::provider::{ApiProvider, BuiltLlmClient, ProviderField, ProviderUiMeta, ReasoningMode, ServiceType};
|
||||
@@ -111,7 +111,7 @@ impl ApiProvider for AnthropicProvider {
|
||||
// stays uncached, as before.
|
||||
let prompt_cache = model.capabilities.iter().any(|c| c == "tool_search");
|
||||
Ok(BuiltLlmClient {
|
||||
client: Arc::new(AnthropicClient::with_extra_body(key, extra)),
|
||||
client: Arc::new(AnthropicModel::with_extra_body(key, model.model_id.clone(), extra)),
|
||||
prompt_cache,
|
||||
})
|
||||
})())
|
||||
|
||||
@@ -18,7 +18,7 @@ use std::sync::Arc;
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::chatbot::openai::OpenAiClient;
|
||||
use agent_loop::models::OpenAiModel;
|
||||
use crate::llm::providers::{extra_with_reasoning, RemoteLlmModelInfo};
|
||||
use crate::llm::{LlmModelRecord, LlmProviderRecord};
|
||||
use crate::provider::{
|
||||
@@ -575,7 +575,7 @@ impl ApiProvider for DeclaredProvider {
|
||||
let extra = extra_with_reasoning(self, model);
|
||||
let prompt_cache = self.spec.prompt_cache;
|
||||
Ok(BuiltLlmClient {
|
||||
client: Arc::new(OpenAiClient::new(self.base_url(record), key, extra, prompt_cache)),
|
||||
client: Arc::new(OpenAiModel::with_options(self.base_url(record), key, model.model_id.clone(), extra, prompt_cache)),
|
||||
prompt_cache,
|
||||
})
|
||||
})())
|
||||
|
||||
@@ -15,7 +15,7 @@ use anyhow::{anyhow, Context, Result};
|
||||
|
||||
use core_api::provider::{ApiProvider, BuiltLlmClient, LlmModelRecord, LlmProviderRecord};
|
||||
|
||||
use crate::chatbot::openai::OpenAiClient;
|
||||
use agent_loop::models::OpenAiModel;
|
||||
|
||||
/// Computes the `extra_params` an OpenAI-compatible client should be built with,
|
||||
/// given a model's stored `extra_params` and its selected reasoning value. The
|
||||
@@ -75,7 +75,7 @@ pub(crate) async fn fetch_openai_models(
|
||||
.ok_or_else(|| anyhow!("unexpected {who} response shape"))
|
||||
}
|
||||
|
||||
/// Builds an `OpenAiClient` for an OpenAI-compatible provider: requires the
|
||||
/// Builds an `OpenAiModel` for an OpenAI-compatible provider: requires the
|
||||
/// provider record's `api_key` and merges the model's stored `extra_params`
|
||||
/// with the provider-translated reasoning fragment (see `extra_with_reasoning`).
|
||||
pub(crate) fn build_openai_llm(
|
||||
@@ -89,7 +89,7 @@ pub(crate) fn build_openai_llm(
|
||||
.with_context(|| format!("provider '{}': api_key required for {}", record.name, provider.type_id()))?;
|
||||
let extra = extra_with_reasoning(provider, model);
|
||||
Ok(BuiltLlmClient {
|
||||
client: Arc::new(OpenAiClient::new(base_url, key, extra, prompt_cache)),
|
||||
client: Arc::new(OpenAiModel::with_options(base_url, key, model.model_id.clone(), extra, prompt_cache)),
|
||||
prompt_cache,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ use std::sync::Arc;
|
||||
|
||||
use anyhow::{Result, anyhow};
|
||||
|
||||
use crate::chatbot::ollama::OllamaClient;
|
||||
use agent_loop::models::OllamaModel;
|
||||
use crate::llm::{LlmModelRecord, LlmProviderRecord};
|
||||
use crate::llm::providers::RemoteLlmModelInfo;
|
||||
use crate::provider::{ApiProvider, BuiltLlmClient, ProviderField, ProviderUiMeta, ServiceType};
|
||||
@@ -101,9 +101,9 @@ impl ApiProvider for OllamaProvider {
|
||||
Ok(Some(Self::parse_model_info(&resp, model_id)))
|
||||
}
|
||||
|
||||
fn build_llm(&self, record: &LlmProviderRecord, _model: &LlmModelRecord) -> Option<Result<BuiltLlmClient>> {
|
||||
fn build_llm(&self, record: &LlmProviderRecord, model: &LlmModelRecord) -> Option<Result<BuiltLlmClient>> {
|
||||
Some(Ok(BuiltLlmClient {
|
||||
client: Arc::new(OllamaClient::new(record.base_url.as_deref())),
|
||||
client: Arc::new(OllamaModel::new(record.base_url.as_deref(), model.model_id.clone())),
|
||||
prompt_cache: false,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -3,17 +3,21 @@
|
||||
//! Extracted from `run_agent_turn`: on a retriable error (5xx / network) it retries
|
||||
//! up to `MAX_LLM_ATTEMPTS` models in priority order, rebuilding the message list
|
||||
//! when the replacement model has a different `prompt_cache` setting, and emits
|
||||
//! `ModelFallback` / `LlmFailed` along the way.
|
||||
//! `ModelFallback` / `LlmFailed` along the way. The call itself goes through the
|
||||
//! `agent_loop::model::Model` trait (blueprint D13) — clients and protocols live
|
||||
//! in the `agent-loop` crate.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::Value;
|
||||
use serde_json::{Value, json};
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::{error, warn};
|
||||
|
||||
use crate::chatbot::{ChatOptions, LlmError, LlmTurn, StreamDelta};
|
||||
use agent_loop::ids::{ConversationId, FrameId};
|
||||
use agent_loop::model::{ModelRequest, ModelResponse, StreamDelta};
|
||||
|
||||
use crate::db::llm_request_payloads;
|
||||
use crate::events::{ServerEvent, TokenDeltaKind};
|
||||
use crate::llm::{LlmEntry, LlmStrength};
|
||||
@@ -24,8 +28,9 @@ use super::interface_tools::AgentRunConfig;
|
||||
|
||||
/// Outcome of one round's LLM call.
|
||||
pub(super) enum RoundLlm {
|
||||
/// The model responded (message or tool calls).
|
||||
Turn(LlmTurn),
|
||||
/// The model responded (message or tool calls). Boxed: `ModelResponse`
|
||||
/// dwarfs the other variants.
|
||||
Turn(Box<ModelResponse>),
|
||||
/// The turn was cancelled (`/stop`) while the request was in flight.
|
||||
Cancelled,
|
||||
/// All fallback attempts were exhausted, or an error is non-retriable.
|
||||
@@ -59,15 +64,6 @@ impl ChatSessionHandler {
|
||||
// a fallback across DTL modes must re-shape (deferred candidates or not).
|
||||
let cur_tool_defs = config.all_tool_defs(cur_llm.dtl);
|
||||
let request_id = uuid::Uuid::new_v4().to_string();
|
||||
let options = ChatOptions {
|
||||
model: cur_llm.model.clone(),
|
||||
max_tokens: None,
|
||||
temperature: None,
|
||||
session_id: Some(self.session_id),
|
||||
stack_id: Some(stack_id),
|
||||
user_id: Some(self.user_id.clone()),
|
||||
request_id: Some(request_id.clone()),
|
||||
};
|
||||
|
||||
// Tell the model, in read_file's description, which media formats it can
|
||||
// open directly — keyed on the model actually serving this attempt, so a
|
||||
@@ -80,6 +76,23 @@ impl ChatSessionHandler {
|
||||
// the fallback reassignment below. On cancel we drop the future
|
||||
// (aborting the request) and return immediately.
|
||||
let client = cur_llm.client.clone();
|
||||
let request = ModelRequest {
|
||||
messages: messages.clone(),
|
||||
tools: defs.to_vec(),
|
||||
model: cur_llm.model.clone(),
|
||||
max_tokens: None,
|
||||
temperature: None,
|
||||
request_id: request_id.clone(),
|
||||
conversation: ConversationId::new(format!("session:{}", self.session_id)),
|
||||
frame: FrameId(stack_id),
|
||||
extras: Value::Null,
|
||||
// Correlation for the LoggingModel decorator (never sent).
|
||||
log: Some(json!({
|
||||
"session_id": self.session_id,
|
||||
"stack_id": stack_id,
|
||||
"user_id": self.user_id,
|
||||
})),
|
||||
};
|
||||
// Streaming side-channel: providers that support SSE push deltas here;
|
||||
// the forwarder re-emits them as `TokenDelta` events on the turn bus.
|
||||
// Best-effort — the round's final events remain authoritative.
|
||||
@@ -87,7 +100,7 @@ impl ChatSessionHandler {
|
||||
let forwarder = spawn_delta_forwarder(delta_rx, em.sender());
|
||||
let call_result = tokio::select! {
|
||||
_ = token.cancelled() => return RoundLlm::Cancelled,
|
||||
r = client.chat_with_tools_raw_streaming(messages.as_slice(), defs, &options, delta_tx) => r,
|
||||
r = client.complete(&request, Some(delta_tx)) => r,
|
||||
};
|
||||
// The client's sender dropped with the completed future: the forwarder
|
||||
// drains any queued deltas and exits, so every `TokenDelta` precedes the
|
||||
@@ -95,39 +108,39 @@ impl ChatSessionHandler {
|
||||
forwarder.await.ok();
|
||||
|
||||
let e = match call_result {
|
||||
Ok((turn, meta)) => {
|
||||
Ok(resp) => {
|
||||
self.llm_manager.mark_success(cur_name).await;
|
||||
// Persist the payload (request/response bodies + headers) to the
|
||||
// user's own database. Fire-and-forget — a failed write must not
|
||||
// break the turn. The metadata row is already written by the
|
||||
// logging wrapper to system.db with the same request_id.
|
||||
if let Some(meta) = meta {
|
||||
// LoggingModel decorator to system.db with the same request_id.
|
||||
if let Some(meta) = resp.raw() {
|
||||
let pool = Arc::clone(&self.db);
|
||||
let rid = request_id.clone();
|
||||
let row = llm_request_payloads::PayloadRow {
|
||||
request_id: rid,
|
||||
request_json: meta.request_body.as_ref().map(|v| v.to_string()).unwrap_or_default(),
|
||||
request_headers: meta.request_headers.as_ref().map(|v| v.to_string()),
|
||||
response_json: meta.response_body.as_ref().map(|v| v.to_string()),
|
||||
response_headers: meta.response_headers.as_ref().map(|v| v.to_string()),
|
||||
};
|
||||
tokio::spawn(async move {
|
||||
let row = llm_request_payloads::PayloadRow {
|
||||
request_id: rid,
|
||||
request_json: meta.request_body.map(|v| v.to_string()).unwrap_or_default(),
|
||||
request_headers: meta.request_headers.map(|v| v.to_string()),
|
||||
response_json: meta.response_body.map(|v| v.to_string()),
|
||||
response_headers: meta.response_headers.map(|v| v.to_string()),
|
||||
};
|
||||
if let Err(e) = llm_request_payloads::insert(&pool, row).await {
|
||||
tracing::warn!(error = %e, "llm_request_payloads: failed to insert");
|
||||
}
|
||||
});
|
||||
}
|
||||
return RoundLlm::Turn(turn);
|
||||
return RoundLlm::Turn(Box::new(resp));
|
||||
}
|
||||
Err(e) => e,
|
||||
};
|
||||
|
||||
// Persist the payload even on failure so the debug log shows the request
|
||||
// that was rejected (e.g. a provider 400). Only the HTTP clients attach a
|
||||
// body (`LlmError::raw_meta`); a network/parse/cancel error carries none.
|
||||
// that was rejected (e.g. a provider 400). Only HTTP failures attach a
|
||||
// body (`ModelError::raw`); a network/parse/cancel error carries none.
|
||||
// Fire-and-forget, keyed on the same `request_id` as the metadata row the
|
||||
// logging wrapper wrote to system.db.
|
||||
if let Some(meta) = e.downcast_ref::<LlmError>().and_then(|le| le.raw_meta.as_ref()) {
|
||||
// LoggingModel decorator wrote to system.db.
|
||||
if let Some(meta) = e.raw.as_ref() {
|
||||
let row = llm_request_payloads::PayloadRow {
|
||||
request_id: request_id.clone(),
|
||||
request_json: meta.request_body.as_ref().map(|v| v.to_string()).unwrap_or_default(),
|
||||
@@ -147,10 +160,10 @@ impl ChatSessionHandler {
|
||||
self.llm_manager.mark_failure(cur_name, &e.to_string()).await;
|
||||
|
||||
let can_fallback = tried_this_round.len() < MAX_LLM_ATTEMPTS
|
||||
&& is_retriable_llm_error(&e);
|
||||
&& client.is_retriable(&e);
|
||||
if !can_fallback {
|
||||
em.llm_failed(tried_this_round.clone(), e.to_string()).await;
|
||||
return RoundLlm::Failed(e);
|
||||
return RoundLlm::Failed(e.into());
|
||||
}
|
||||
|
||||
let excluded: Vec<&str> = tried_this_round.iter().map(String::as_str).collect();
|
||||
@@ -179,7 +192,7 @@ impl ChatSessionHandler {
|
||||
}
|
||||
Err(_) => {
|
||||
em.llm_failed(tried_this_round.clone(), e.to_string()).await;
|
||||
return RoundLlm::Failed(e);
|
||||
return RoundLlm::Failed(e.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -206,21 +219,6 @@ fn spawn_delta_forwarder(
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether an LLM error is worth retrying on a different model.
|
||||
///
|
||||
/// Classifies on the real HTTP status ([`crate::chatbot::http_status`]), not a
|
||||
/// substring of the message — a model id or token count containing "404"/"401" no
|
||||
/// longer mis-classifies (bug B6). A non-HTTP failure (network, parse) has no status
|
||||
/// and is retriable, matching the previous default.
|
||||
fn is_retriable_llm_error(e: &anyhow::Error) -> bool {
|
||||
// Never retry these client errors — the request itself is unauthorized, not
|
||||
// found, or unprocessable. 400 is intentionally NOT listed: some providers
|
||||
// reject valid requests that others accept (e.g. DeepSeek requires a
|
||||
// reasoning_content echo, OpenAI does not), so retrying elsewhere can succeed.
|
||||
// 429 and 5xx stay retriable (a different model / provider may serve the call).
|
||||
!matches!(crate::chatbot::http_status(e), Some(401 | 403 | 404 | 422))
|
||||
}
|
||||
|
||||
fn first_line(s: &str) -> String {
|
||||
s.lines().next().unwrap_or(s).to_string()
|
||||
}
|
||||
@@ -246,38 +244,40 @@ fn media_annotated_tools(tool_defs: &[Value], capabilities: &[String]) -> Option
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::is_retriable_llm_error;
|
||||
use crate::chatbot::LlmError;
|
||||
use agent_loop::model::{Model, ModelError, ModelRequest, ModelResponse, StreamDelta};
|
||||
use async_trait::async_trait;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
fn http_err(status: u16, message: &str) -> anyhow::Error {
|
||||
LlmError { status: Some(status), message: message.to_string(), ..Default::default() }.into()
|
||||
struct Dummy;
|
||||
#[async_trait]
|
||||
impl agent_loop::model::Model for Dummy {
|
||||
async fn complete(
|
||||
&self,
|
||||
_req: &ModelRequest,
|
||||
_d: Option<mpsc::Sender<StreamDelta>>,
|
||||
) -> Result<ModelResponse, ModelError> {
|
||||
unreachable!()
|
||||
}
|
||||
}
|
||||
|
||||
/// Retriability classification lives on the `Model` trait default (the crate
|
||||
/// owns the protocols, blueprint D13): 401/403/404/422 don't retry,
|
||||
/// 400/429/5xx/network do. Classification keys on the structured status,
|
||||
/// never on the message string (bug B6 regression).
|
||||
#[test]
|
||||
fn client_errors_are_not_retried() {
|
||||
fn retriability_keys_on_structured_status() {
|
||||
let m = Dummy;
|
||||
for code in [401, 403, 404, 422] {
|
||||
assert!(!is_retriable_llm_error(&http_err(code, "nope")), "{code} must not retry");
|
||||
assert!(!m.is_retriable(&ModelError::new(Some(code), "nope")), "{code} must not retry");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_rate_limit_and_400_retry() {
|
||||
for code in [400, 429, 500, 502, 503] {
|
||||
assert!(is_retriable_llm_error(&http_err(code, "retry")), "{code} must retry");
|
||||
assert!(m.is_retriable(&ModelError::new(Some(code), "retry")), "{code} must retry");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_http_errors_retry() {
|
||||
assert!(is_retriable_llm_error(&anyhow::anyhow!("connection reset by peer")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_digits_in_the_message_do_not_mislead() {
|
||||
// Regression for B6: the old substring check read any "404"/"401" in the text
|
||||
// as a client error. A 500 whose body mentions "1401 tokens" / "code 404" must
|
||||
// still retry — classification keys on the structured status, not the string.
|
||||
let e = http_err(500, "provider error: too many (1401) tokens, see code 404 in docs");
|
||||
assert!(is_retriable_llm_error(&e));
|
||||
// A 500 whose body mentions "1401 tokens" / "code 404" must still retry.
|
||||
assert!(m.is_retriable(&ModelError::new(
|
||||
Some(500),
|
||||
"provider error: too many (1401) tokens, see code 404 in docs"
|
||||
)));
|
||||
assert!(m.is_retriable(&ModelError::new(None, "connection reset by peer")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ use tokio_util::sync::CancellationToken;
|
||||
use tracing::{debug, trace};
|
||||
|
||||
use crate::chat_event_bus::ToolCallEvent;
|
||||
use crate::chatbot::{LlmTurn, ToolCall};
|
||||
use agent_loop::model::{ModelResponse, ToolCall};
|
||||
use crate::db::{chat_history, chat_llm_tools};
|
||||
use crate::events::ServerEvent;
|
||||
use crate::tools::{
|
||||
@@ -139,36 +139,37 @@ impl ChatSessionHandler {
|
||||
RoundLlm::Failed(e) => return Err(e),
|
||||
};
|
||||
|
||||
match turn_result {
|
||||
LlmTurn::Message(resp) => {
|
||||
match *turn_result {
|
||||
ModelResponse::Message { content, reasoning, usage, .. } => {
|
||||
let message_id = chat_history::append(
|
||||
pool, stack_id, &chat_history::Role::Assistant, &resp.content, false,
|
||||
resp.reasoning_content.as_deref(),
|
||||
pool, stack_id, &chat_history::Role::Assistant, &content, false,
|
||||
reasoning.as_deref(),
|
||||
).await?;
|
||||
if let (Some(i), Some(o)) = (resp.input_tokens, resp.output_tokens) {
|
||||
chat_history::set_usage(pool, message_id, i, o, 0, resp.cost).await?;
|
||||
if let (Some(i), Some(o)) = (usage.input_tokens, usage.output_tokens) {
|
||||
chat_history::set_usage(pool, message_id, i, o, 0, usage.cost_usd).await?;
|
||||
}
|
||||
return Ok(TurnOutcome::Final {
|
||||
content: resp.content,
|
||||
content,
|
||||
message_id,
|
||||
input_tokens: resp.input_tokens,
|
||||
output_tokens: resp.output_tokens,
|
||||
truncated: resp.truncated,
|
||||
reasoning_content: resp.reasoning_content,
|
||||
input_tokens: usage.input_tokens,
|
||||
output_tokens: usage.output_tokens,
|
||||
truncated: usage.truncated,
|
||||
reasoning_content: reasoning,
|
||||
tool_calls: all_tool_calls,
|
||||
});
|
||||
}
|
||||
|
||||
LlmTurn::ToolCalls { content: assistant_text, calls, input_tokens, output_tokens, reasoning_content, cost, .. } => {
|
||||
ModelResponse::ToolCalls { content: assistant_text, calls, usage, reasoning, .. } => {
|
||||
let (input_tokens, output_tokens) = (usage.input_tokens, usage.output_tokens);
|
||||
let message_id = chat_history::append(
|
||||
pool, stack_id, &chat_history::Role::Assistant, &assistant_text, false,
|
||||
reasoning_content.as_deref(),
|
||||
reasoning.as_deref(),
|
||||
).await?;
|
||||
if let (Some(i), Some(o)) = (input_tokens, output_tokens) {
|
||||
chat_history::set_usage(pool, message_id, i, o, 0, cost).await?;
|
||||
chat_history::set_usage(pool, message_id, i, o, 0, usage.cost_usd).await?;
|
||||
}
|
||||
if !assistant_text.trim().is_empty() || input_tokens.is_some() {
|
||||
em.thinking(message_id, assistant_text, input_tokens, output_tokens, reasoning_content).await;
|
||||
em.thinking(message_id, assistant_text, input_tokens, output_tokens, reasoning).await;
|
||||
}
|
||||
|
||||
// A homogeneous batch of ≥2 synchronous sub-agent calls is fanned
|
||||
|
||||
Reference in New Issue
Block a user