Skip to main content

trusty_common/chat/
mod.rs

1//! Provider-agnostic streaming chat abstraction with tool-use support.
2//!
3//! Why: trusty-memory and trusty-search both want to support more than one
4//! upstream LLM (OpenRouter for cloud, Ollama / LM Studio for local). Rather
5//! than each crate re-implementing the dispatch, we expose a small
6//! [`ChatProvider`] trait plus concrete implementations and an auto-detector
7//! for a running local model server. The trait also surfaces OpenAI-style
8//! tool/function calling so downstream agents can let the model invoke tools
9//! (search, memory recall, shell, etc.).
10//!
11//! What: defines the [`ChatProvider`] trait, [`ToolDef`] / [`ToolCall`] /
12//! [`ChatEvent`] tool-use types, an [`OpenRouterProvider`] and an
13//! [`OllamaProvider`] that both speak OpenAI-compatible
14//! `/v1/chat/completions` with SSE streaming (including the streamed
15//! `tool_calls` shape), a [`BedrockProvider`] that uses the AWS Bedrock
16//! `Converse` API (behind the `bedrock` feature flag), and
17//! [`auto_detect_local_provider`] which probes `{base_url}/v1/models` with a
18//! 1-second timeout.
19//!
20//! Test: `cargo test -p trusty-common` covers default config values, the
21//! unreachable-server path of `auto_detect_local_provider`, SSE delta
22//! streaming, and accumulation of streamed tool-call fragments.
23
24mod openai_compat;
25
26#[cfg(feature = "bedrock")]
27mod bedrock_impl;
28#[cfg(not(feature = "bedrock"))]
29mod bedrock_stub;
30
31pub use openai_compat::{OllamaProvider, OpenRouterProvider, auto_detect_local_provider};
32
33#[cfg(feature = "bedrock")]
34pub use bedrock_impl::{
35    BedrockProvider, DEFAULT_BEDROCK_MODEL, DEFAULT_BEDROCK_REGION, ENV_REGION_AWS,
36    ENV_REGION_TRUSTY,
37};
38
39// Re-expose the bedrock_impl module as `bedrock_provider` so downstream
40// crates can access constants (e.g. `DEFAULT_BEDROCK_MODEL`) without needing
41// to depend on the bedrock feature themselves.
42#[cfg(feature = "bedrock")]
43pub mod bedrock_provider {
44    pub use super::bedrock_impl::*;
45}
46
47#[cfg(not(feature = "bedrock"))]
48pub use bedrock_stub::BedrockProvider;
49
50// Stub constant so code that references DEFAULT_BEDROCK_MODEL compiles without
51// the bedrock feature. Must stay in sync with bedrock_impl::DEFAULT_BEDROCK_MODEL.
52// Claude Sonnet 4.6 drops the date stamp and -v1:0 suffix (verified vs AWS docs).
53#[cfg(not(feature = "bedrock"))]
54pub const DEFAULT_BEDROCK_MODEL: &str = "us.anthropic.claude-sonnet-4-6";
55
56use crate::ChatMessage;
57use anyhow::Result;
58use async_trait::async_trait;
59use serde::{Deserialize, Serialize};
60use tokio::sync::mpsc::Sender;
61
62// ── Public re-exports so callers get the full surface from `chat::*` ──────────
63
64/// Configuration for a local OpenAI-compatible model server (Ollama, LM
65/// Studio, llama.cpp's server, etc.).
66///
67/// Why: callers want a single struct they can deserialize from config files
68/// and pass to [`auto_detect_local_provider`] without juggling defaults.
69/// What: holds an enable flag, the server's base URL (no trailing slash),
70/// and the default model to request. Defaults target Ollama's standard
71/// localhost binding.
72/// Test: `local_model_config_defaults` asserts the default values.
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct LocalModelConfig {
75    pub enabled: bool,
76    pub base_url: String,
77    pub model: String,
78}
79
80impl Default for LocalModelConfig {
81    fn default() -> Self {
82        Self {
83            enabled: true,
84            base_url: "http://localhost:11434".to_string(),
85            model: "qwen3:30b".to_string(),
86        }
87    }
88}
89
90// ─── Tool-use types ───────────────────────────────────────────────────────────
91
92/// JSON-Schema description of a callable tool, in OpenAI function-calling
93/// shape.
94///
95/// Why: downstream agents (trusty-memory, trusty-search) expose tools like
96/// `memory_recall` or `web_search` to the LLM. The OpenAI tool format is the
97/// de-facto common denominator across OpenRouter, Ollama, LM Studio, and
98/// most cloud providers.
99/// What: `name` and `description` are passed verbatim; `parameters` is a
100/// JSON Schema object (typically `{"type":"object","properties":{...}}`).
101/// Test: `tool_def_serializes_as_function` checks the wire shape.
102#[derive(Debug, Clone, Serialize, Deserialize)]
103pub struct ToolDef {
104    pub name: String,
105    pub description: String,
106    pub parameters: serde_json::Value,
107}
108
109/// A tool invocation the model wants the host to perform.
110///
111/// Why: the streaming chat API emits `tool_calls` in fragments — first an
112/// `id` + `function.name`, then a string of `function.arguments` deltas.
113/// We accumulate fragments and surface one fully-formed [`ToolCall`] per
114/// invocation to the caller.
115/// What: `id` is the upstream's call id (echoed back in subsequent
116/// `role:"tool"` messages); `name` is the function name; `arguments` is a
117/// JSON string (NOT a parsed value — many models emit malformed JSON and
118/// callers want the raw text for error reporting / repair).
119/// Test: `accumulates_streamed_tool_call_fragments`.
120#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
121pub struct ToolCall {
122    pub id: String,
123    pub name: String,
124    pub arguments: String,
125}
126
127/// Sampling knobs forwarded to an OpenAI-compatible provider.
128///
129/// Why (issue #3758): the streaming request wire previously sent only
130/// `model`/`messages`/`tools`, so a streamed reply's style, verbosity, and
131/// stopping behaviour were NOT equivalent to the blocking path's for the same
132/// turn — the caller silently got provider defaults instead of its configured
133/// temperature, token ceiling, and stop sequences. Carrying them in one struct
134/// (rather than three provider constructor arguments) keeps `new()` stable for
135/// existing callers and lets the set grow without churning call sites.
136/// What: every field is optional — `None`/empty means "omit from the request
137/// body and let the provider default apply", which is exactly the pre-#3758
138/// behaviour, so a caller that does not opt in is unaffected. `stop` maps to
139/// the OpenAI `stop` array (the direct-Anthropic dialect calls the same thing
140/// `stop_sequences`).
141/// Test: `sampling_params_serialize_into_request_body`,
142/// `default_sampling_omits_fields`.
143#[derive(Debug, Clone, Default, PartialEq)]
144pub struct SamplingParams {
145    /// Sampling temperature; `None` omits the field.
146    pub temperature: Option<f32>,
147    /// Maximum tokens to generate; `None` omits the field.
148    pub max_tokens: Option<u32>,
149    /// Stop sequences; empty omits the field.
150    pub stop: Vec<String>,
151}
152
153impl SamplingParams {
154    /// The stop sequences as a request-body-ready `Option<&[String]>`.
155    ///
156    /// Why: an EMPTY `stop` array is not the same as an absent one — some
157    /// OpenAI-compatible servers reject `"stop": []` outright, which is the
158    /// same trap [`ChatProvider`] already documents for an empty `tools`
159    /// array. Collapsing empty to `None` at the boundary keeps that decision
160    /// in one place instead of at each provider.
161    /// What: `None` when empty, `Some(slice)` otherwise.
162    /// Test: `default_sampling_omits_fields`.
163    pub fn stop_slice(&self) -> Option<&[String]> {
164        if self.stop.is_empty() {
165            None
166        } else {
167            Some(&self.stop)
168        }
169    }
170}
171
172/// Streaming chat event.
173///
174/// Why: replaces the previous "string-only" channel so callers can
175/// distinguish text deltas from tool invocations and from terminal
176/// success/error without parsing magic markers out of the text stream.
177/// What: `Delta` is a content chunk; `ToolCall` is a fully-accumulated tool
178/// invocation; `Done` signals the upstream stream terminated normally;
179/// `Error` carries a human-readable message for stream-mid failures (the
180/// provider also returns `Err` from `chat_stream`, but `Error` lets the
181/// caller display partial-stream failures inline).
182/// Test: `ollama_provider_streams_sse_deltas`.
183#[derive(Debug, Clone)]
184pub enum ChatEvent {
185    Delta(String),
186    ToolCall(ToolCall),
187    Done,
188    Error(String),
189}
190
191/// Streaming chat provider abstraction.
192///
193/// Why: downstream crates (trusty-memory, trusty-search) want to support
194/// multiple LLM backends without hard-coding which one to call. Providers
195/// expose a uniform streaming interface so the caller can swap them at
196/// runtime based on configuration / availability.
197/// What: implementors stream [`ChatEvent`]s into `tx`. Pass an empty
198/// `tools` vec to disable tool use entirely (the provider MUST then omit
199/// the `tools` field from the upstream request — some models error on an
200/// empty array). Returning `Ok(())` means the stream completed normally;
201/// the caller should also expect a final [`ChatEvent::Done`].
202/// Test: implementations are covered by their own unit tests in this
203/// module plus integration tests in downstream crates.
204#[async_trait]
205pub trait ChatProvider: Send + Sync {
206    /// Human-readable provider name (e.g. `"openrouter"`, `"ollama"`).
207    fn name(&self) -> &str;
208    /// Model identifier sent on every request.
209    fn model(&self) -> &str;
210    /// Stream chat events into `tx`. `tools` empty disables tool use.
211    async fn chat_stream(
212        &self,
213        messages: Vec<ChatMessage>,
214        tools: Vec<ToolDef>,
215        tx: Sender<ChatEvent>,
216    ) -> Result<()>;
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222
223    #[test]
224    fn local_model_config_defaults() {
225        let cfg = LocalModelConfig::default();
226        assert!(cfg.enabled);
227        assert_eq!(cfg.base_url, "http://localhost:11434");
228        assert_eq!(cfg.model, "qwen3:30b");
229    }
230
231    #[test]
232    fn local_model_config_deserializes_from_toml() {
233        let toml_src = r#"
234            enabled = true
235            base_url = "http://localhost:1234"
236            model = "qwen2.5-coder"
237        "#;
238        let cfg: LocalModelConfig = toml::from_str(toml_src).expect("parse TOML");
239        assert!(cfg.enabled);
240        assert_eq!(cfg.base_url, "http://localhost:1234");
241        assert_eq!(cfg.model, "qwen2.5-coder");
242    }
243}