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/// Token-usage tally surfaced at the end of a streamed response.
173///
174/// Why (issue #3767): Bedrock's `ConverseStream` reports usage only once, in
175/// a terminal `metadata` event — unlike the per-token deltas, it has no
176/// natural home in [`ChatEvent::Delta`]. Without a dedicated variant a
177/// streaming provider has no way to hand token counts back to the caller, so
178/// a downstream cost-reporting consumer would silently see zero usage for
179/// every streamed call. Kept as a small standalone struct (rather than
180/// reusing `trusty_common::inference::types::Usage`) so the `chat` module —
181/// which compiles unconditionally, unlike the `inference-client`-gated
182/// `types` module — never depends on a feature-gated type.
183/// What: four token buckets mirroring the shape every provider in this
184/// workspace already reports (prompt/completion split, plus the two
185/// prompt-cache buckets). The four fields are SEPARATE and ADDITIVE — not
186/// nested — matching both the Bedrock `TokenUsage` wire shape
187/// (`input_tokens`/`output_tokens`/`cache_read_input_tokens`/
188/// `cache_write_input_tokens` are independent fields, verified against
189/// `aws-sdk-bedrockruntime` 1.132.0) and this workspace's existing
190/// convention (`crate::perf::TokenUsage`,
191/// `trusty-agents/src/llm/anthropic_native/mod.rs`'s usage parsing, and
192/// `chat::bedrock_impl::parse_usage`'s non-streaming counterpart). A
193/// provider that has none of the cache fields simply leaves them zero.
194/// Test: `bedrock_stream_reports_usage_from_metadata_event` (bedrock_impl).
195#[derive(Debug, Clone, Copy, Default, PartialEq)]
196pub struct ChatUsage {
197    /// Prompt (input) tokens. Separate from `cache_read_tokens` — a
198    /// cost-accounting consumer that assumes this already includes cache
199    /// reads will double-count; one that assumes it excludes the full
200    /// prompt will undercount. Sum with `cache_read_tokens` for the total
201    /// prompt-side token count when that is what's needed.
202    pub prompt_tokens: u32,
203    /// Completion (output) tokens produced.
204    pub completion_tokens: u32,
205    /// Prompt tokens served from the provider's prompt cache — additive
206    /// with `prompt_tokens`, not a subset of it.
207    pub cache_read_tokens: u32,
208    /// Prompt tokens newly written into the prompt cache this turn.
209    pub cache_creation_tokens: u32,
210}
211
212/// Streaming chat event.
213///
214/// Why: replaces the previous "string-only" channel so callers can
215/// distinguish text deltas from tool invocations and from terminal
216/// success/error without parsing magic markers out of the text stream.
217/// What: `Delta` is a content chunk; `ToolCall` is a fully-accumulated tool
218/// invocation; `Usage` carries the call's token tally (emitted zero or one
219/// times, whenever the provider's wire format reports it — see
220/// [`ChatUsage`]); `Done` signals the upstream stream terminated normally;
221/// `Error` carries a human-readable message for stream-mid failures (the
222/// provider also returns `Err` from `chat_stream`, but `Error` lets the
223/// caller display partial-stream failures inline).
224/// Test: `ollama_provider_streams_sse_deltas`.
225///
226/// # Stability
227///
228/// `#[non_exhaustive]`: adding a variant here is otherwise a SemVer-breaking
229/// change for every downstream crate, because an exhaustive `match` over the
230/// old variant set stops compiling with E0004. That is not hypothetical — the
231/// `Usage` variant forced arm additions in five consumers at once
232/// (`trusty-agents`, `trusty-analyze`, `trusty-memory`, `trusty-mpm`,
233/// `trusty-search`) and is the direct cause of the 0.27.0 MINOR bump: a patch
234/// release would have re-resolved the already-published, arm-less consumer
235/// sources against the new variant and hard-failed `cargo install` (the same
236/// failure that forced `trusty-analyze` 0.7.3 to be yanked).
237///
238/// Downstream matches must therefore carry a wildcard arm. This attribute does
239/// NOT retroactively fix consumers published before it landed — it only stops
240/// the next variant addition from repeating the break.
241#[derive(Debug, Clone)]
242#[non_exhaustive]
243pub enum ChatEvent {
244    Delta(String),
245    ToolCall(ToolCall),
246    Usage(ChatUsage),
247    Done,
248    Error(String),
249}
250
251/// Streaming chat provider abstraction.
252///
253/// Why: downstream crates (trusty-memory, trusty-search) want to support
254/// multiple LLM backends without hard-coding which one to call. Providers
255/// expose a uniform streaming interface so the caller can swap them at
256/// runtime based on configuration / availability.
257/// What: implementors stream [`ChatEvent`]s into `tx`. Pass an empty
258/// `tools` vec to disable tool use entirely (the provider MUST then omit
259/// the `tools` field from the upstream request — some models error on an
260/// empty array). Returning `Ok(())` means the stream completed normally;
261/// the caller should also expect a final [`ChatEvent::Done`].
262/// Test: implementations are covered by their own unit tests in this
263/// module plus integration tests in downstream crates.
264#[async_trait]
265pub trait ChatProvider: Send + Sync {
266    /// Human-readable provider name (e.g. `"openrouter"`, `"ollama"`).
267    fn name(&self) -> &str;
268    /// Model identifier sent on every request.
269    fn model(&self) -> &str;
270    /// Stream chat events into `tx`. `tools` empty disables tool use.
271    async fn chat_stream(
272        &self,
273        messages: Vec<ChatMessage>,
274        tools: Vec<ToolDef>,
275        tx: Sender<ChatEvent>,
276    ) -> Result<()>;
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282
283    #[test]
284    fn local_model_config_defaults() {
285        let cfg = LocalModelConfig::default();
286        assert!(cfg.enabled);
287        assert_eq!(cfg.base_url, "http://localhost:11434");
288        assert_eq!(cfg.model, "qwen3:30b");
289    }
290
291    #[test]
292    fn local_model_config_deserializes_from_toml() {
293        let toml_src = r#"
294            enabled = true
295            base_url = "http://localhost:1234"
296            model = "qwen2.5-coder"
297        "#;
298        let cfg: LocalModelConfig = toml::from_str(toml_src).expect("parse TOML");
299        assert!(cfg.enabled);
300        assert_eq!(cfg.base_url, "http://localhost:1234");
301        assert_eq!(cfg.model, "qwen2.5-coder");
302    }
303}