mod builder;
mod decisions;
mod helpers;
mod stream;
mod tool_loop;
pub use builder::LLMBuilder;
pub use decisions::{collect_active_decisions, inject_decisions_into_system_prompt};
use helpers::{
build_assistant_tool_call_message, build_full_context_from_entries, build_messages,
extract_content, extract_tool_calls, prepend_tape_history, restore_last_user_content,
slice_entries_by_anchor, strip_image_blocks_for_persistence,
};
use std::fmt;
use serde_json::Value;
pub use crate::core::api_format::ApiFormat;
use crate::core::errors::{ConduitError, ErrorKind};
use crate::core::execution::{LLMCore, ProviderValue};
use crate::core::response_parser::TransportResponse;
use crate::tape::{AsyncTapeManager, AsyncTapeStoreAdapter, InMemoryTapeStore, TapeContext};
use crate::tools::context::ToolContext;
use crate::tools::executor::ToolExecutor;
use crate::tools::schema::ToolSet;
use tokio_util::sync::CancellationToken;
use uuid::Uuid;
pub const DEFAULT_MODEL: &str = "openai:gpt-4o-mini";
#[derive(Default)]
pub struct ChatRequest<'a> {
pub prompt: Option<&'a str>,
pub user_content: Option<Vec<Value>>,
pub system_prompt: Option<&'a str>,
pub model: Option<&'a str>,
pub provider: Option<&'a str>,
pub messages: Option<Vec<Value>>,
pub max_tokens: Option<u32>,
pub tools: Option<&'a ToolSet>,
pub tool_context: Option<&'a ToolContext>,
pub tape: Option<&'a str>,
pub tape_context: Option<&'a TapeContext>,
pub cancellation: Option<CancellationToken>,
pub context_window: Option<usize>,
pub max_tool_iterations: Option<usize>,
pub session_id: Option<&'a str>,
pub token_budget: Option<u64>,
pub tail_reminder: Option<String>,
pub text_sink: Option<tokio::sync::mpsc::Sender<StreamChunk>>,
}
#[derive(Debug)]
pub enum StreamChunk {
Text(String),
Reasoning(String),
}
pub struct LLM {
core: LLMCore,
tool_executor: ToolExecutor,
async_tape: AsyncTapeManager,
spill_dir: Option<std::path::PathBuf>,
pub(crate) context_window: Option<usize>,
}
impl LLM {
#[allow(clippy::too_many_arguments)]
pub fn new(
model: Option<&str>,
provider: Option<&str>,
fallback_models: Option<Vec<String>>,
max_retries: Option<u32>,
api_key: Option<String>,
api_key_map: Option<std::collections::HashMap<String, String>>,
api_base: Option<String>,
api_base_map: Option<std::collections::HashMap<String, String>>,
api_format: Option<ApiFormat>,
verbose: Option<u32>,
context: Option<TapeContext>,
) -> Result<Self, ConduitError> {
let verbose = verbose.unwrap_or(0);
if verbose > 2 {
return Err(ConduitError::new(
ErrorKind::InvalidInput,
"verbose must be 0, 1, or 2",
));
}
let max_retries = max_retries.unwrap_or(3);
let model_str = model.unwrap_or(DEFAULT_MODEL);
let (resolved_provider, resolved_model) =
LLMCore::resolve_model_provider(model_str, provider)?;
let api_key_config = match (api_key, api_key_map) {
(Some(key), _) => ProviderValue::Single(key),
(None, Some(map)) => ProviderValue::PerProvider(map),
(None, None) => ProviderValue::None,
};
let api_base_config = match (api_base, api_base_map) {
(Some(base), _) => ProviderValue::Single(base),
(None, Some(map)) => ProviderValue::PerProvider(map),
(None, None) => ProviderValue::None,
};
let api_format = api_format.unwrap_or_default();
let core = LLMCore::new(
resolved_provider,
resolved_model,
fallback_models.unwrap_or_default(),
max_retries,
api_key_config,
api_base_config,
api_format,
verbose,
);
let shared_tape_store = InMemoryTapeStore::new();
let async_store = AsyncTapeStoreAdapter::new(shared_tape_store);
let async_tape = AsyncTapeManager::new(Some(Box::new(async_store)), context);
Ok(Self {
core,
tool_executor: ToolExecutor::new(),
async_tape,
spill_dir: None,
context_window: None,
})
}
pub fn builder() -> LLMBuilder {
LLMBuilder::new()
}
pub fn model(&self) -> &str {
self.core.model()
}
pub fn provider(&self) -> &str {
self.core.provider()
}
pub fn fallback_models(&self) -> &[String] {
self.core.fallback_models()
}
pub fn tools(&self) -> &ToolExecutor {
&self.tool_executor
}
pub fn context_window(&self) -> Option<usize> {
self.context_window
}
pub fn set_context_window(&mut self, tokens: usize) {
self.context_window = Some(tokens);
}
pub fn set_context(&mut self, context: TapeContext) {
self.async_tape.set_default_context(context);
}
pub fn context(&self) -> Option<&TapeContext> {
Some(self.async_tape.default_context())
}
pub async fn append_tape_entry(
&self,
tape: &str,
entry: &crate::tape::TapeEntry,
) -> Result<(), ConduitError> {
self.async_tape.append_entry(tape, entry).await
}
pub async fn handoff_tape(
&self,
tape: &str,
name: &str,
state: Option<Value>,
meta: Value,
) -> Result<Vec<crate::tape::TapeEntry>, ConduitError> {
self.async_tape.handoff(tape, name, state, meta).await
}
pub fn session(&mut self, tape: impl Into<String>) -> crate::tape::TapeSession<'_> {
crate::tape::TapeSession::new(self, tape)
}
pub async fn chat_async(&mut self, req: ChatRequest<'_>) -> Result<String, ConduitError> {
let ChatRequest {
prompt,
user_content,
system_prompt,
model,
provider,
messages,
max_tokens,
tape,
session_id,
..
} = req;
let tape_messages = match tape {
Some(tape_name) => self.build_tape_messages(tape_name, None).await,
None => Vec::new(),
};
let mut msgs = build_messages(
prompt,
user_content.as_deref(),
system_prompt,
messages.as_deref(),
);
prepend_tape_history(&mut msgs, tape_messages);
let new_messages: Vec<Value> = msgs
.iter()
.filter(|m| m.get("role").and_then(|r| r.as_str()) == Some("user"))
.cloned()
.collect();
let response = self
.core
.run_chat(
msgs,
None,
model,
provider,
max_tokens,
false,
None,
Default::default(),
session_id,
|resp: TransportResponse| Ok(resp.payload),
)
.await?;
let content = extract_content(&response)?;
if let Some(tape_name) = tape {
let run_id = Uuid::new_v4().to_string();
if let Err(e) = self
.async_tape
.record_chat(
tape_name,
&run_id,
system_prompt,
None, &new_messages,
Some(&content),
None, None, None, None, Some(self.core.provider()),
Some(self.core.model()),
)
.await
{
tracing::error!(error = %e, tape = %tape_name, "failed to record chat transcript");
}
}
Ok(content)
}
}
impl fmt::Display for LLM {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "LLM({}:{})", self.core.provider(), self.core.model())
}
}
impl fmt::Debug for LLM {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("LLM")
.field("provider", &self.core.provider())
.field("model", &self.core.model())
.field("fallback_models", &self.core.fallback_models())
.field("max_retries", &self.core.max_retries())
.finish()
}
}
#[cfg(test)]
#[path = "tests.rs"]
mod tests;