trusty_common/chat/openai_compat/providers.rs
1//! Concrete OpenAI-compatible chat provider implementations.
2//!
3//! Why: OpenRouter (cloud) and Ollama (local) both use the same SSE pump but
4//! differ in auth, URL, and timeout configuration. Keeping both concrete
5//! providers in one file makes the symmetry obvious and lets us share the
6//! `build_consolidation_prompt`-style helpers without re-importing.
7//! What: `OpenRouterProvider`, `OllamaProvider`, and
8//! `auto_detect_local_provider` — the public surface that callers import from
9//! the `openai_compat` module.
10//! Test: `openrouter_provider_reports_metadata`,
11//! `ollama_provider_reports_metadata`, `ollama_provider_streams_sse_deltas`,
12//! `ollama_provider_emits_tool_call`,
13//! `auto_detect_returns_none_on_unreachable`,
14//! `auto_detect_returns_some_on_200`.
15
16use super::sse_pump::pump_openai_sse;
17use super::wire::tools_wire;
18use crate::ChatMessage;
19use crate::chat::{ChatEvent, ChatProvider, SamplingParams, ToolDef};
20use anyhow::{Context, Result, anyhow};
21use async_trait::async_trait;
22use tokio::sync::mpsc::Sender;
23
24const LOCAL_PROBE_TIMEOUT_SECS: u64 = 1;
25const LOCAL_REQUEST_TIMEOUT_SECS: u64 = 120;
26const OPENROUTER_URL: &str = "https://openrouter.ai/api/v1/chat/completions";
27const OPENROUTER_CONNECT_TIMEOUT_SECS: u64 = 10;
28const OPENROUTER_REQUEST_TIMEOUT_SECS: u64 = 120;
29const HTTP_REFERER: &str = "https://github.com/bobmatnyc/trusty-common";
30const X_TITLE: &str = "trusty-common";
31
32/// Cloud chat provider backed by OpenRouter.
33///
34/// Why: lets callers pick OpenRouter or a local model uniformly through
35/// the [`ChatProvider`] trait.
36/// What: stores an API key and model id; POSTs OpenAI-compatible streaming
37/// chat completions with bearer auth and trusty-common branding headers.
38/// Test: shape covered by `openrouter_provider_reports_metadata`; the
39/// streaming and tool-call paths are covered by integration tests in
40/// downstream crates plus the SSE-pump unit tests in this module.
41pub struct OpenRouterProvider {
42 pub api_key: String,
43 pub model: String,
44 /// #3758: sampling knobs forwarded on the streaming request so a streamed
45 /// turn matches the blocking path's style/verbosity/stopping. Default
46 /// (all-absent) reproduces the pre-#3758 body exactly.
47 pub sampling: SamplingParams,
48}
49
50impl OpenRouterProvider {
51 /// Construct a provider from an API key and model id.
52 ///
53 /// Why: keeps callers from poking the public fields directly so the
54 /// struct can grow optional knobs without breaking call sites.
55 /// What: stores both fields verbatim.
56 /// Test: trivially exercised by `openrouter_provider_reports_metadata`.
57 pub fn new(api_key: impl Into<String>, model: impl Into<String>) -> Self {
58 Self {
59 api_key: api_key.into(),
60 model: model.into(),
61 sampling: SamplingParams::default(),
62 }
63 }
64
65 /// Attach sampling parameters to every request this provider sends.
66 ///
67 /// Why (#3758): callers that also have a blocking path must be able to send
68 /// the SAME temperature / token ceiling / stop sequences on the streaming
69 /// path, without `new()` growing three arguments that every existing call
70 /// site would have to spell out.
71 /// What: consuming builder; returns `self` with `sampling` replaced.
72 /// Test: `sampling_params_serialize_into_request_body`.
73 pub fn with_sampling(mut self, sampling: SamplingParams) -> Self {
74 self.sampling = sampling;
75 self
76 }
77}
78
79#[async_trait]
80impl ChatProvider for OpenRouterProvider {
81 fn name(&self) -> &str {
82 "openrouter"
83 }
84
85 fn model(&self) -> &str {
86 &self.model
87 }
88
89 async fn chat_stream(
90 &self,
91 messages: Vec<ChatMessage>,
92 tools: Vec<ToolDef>,
93 tx: Sender<ChatEvent>,
94 ) -> Result<()> {
95 if self.api_key.is_empty() {
96 return Err(anyhow!("openrouter api key is empty"));
97 }
98 let client = reqwest::Client::builder()
99 .connect_timeout(std::time::Duration::from_secs(
100 OPENROUTER_CONNECT_TIMEOUT_SECS,
101 ))
102 .timeout(std::time::Duration::from_secs(
103 OPENROUTER_REQUEST_TIMEOUT_SECS,
104 ))
105 .build()
106 .context("build reqwest client for OpenRouterProvider::chat_stream")?;
107
108 let tw = tools_wire(&tools);
109 let body = super::wire::ChatRequestWire {
110 model: &self.model,
111 messages: &messages,
112 stream: true,
113 tools: tw,
114 // #3758: forward the same sampling knobs the blocking path sends.
115 temperature: self.sampling.temperature,
116 max_tokens: self.sampling.max_tokens,
117 stop: self.sampling.stop_slice(),
118 };
119 let resp = client
120 .post(OPENROUTER_URL)
121 .bearer_auth(&self.api_key)
122 .header("HTTP-Referer", HTTP_REFERER)
123 .header("X-Title", X_TITLE)
124 .json(&body)
125 .send()
126 .await
127 .context("POST openrouter chat completions (stream)")?;
128
129 let status = resp.status();
130 if !status.is_success() {
131 let text = resp.text().await.unwrap_or_default();
132 return Err(anyhow!("openrouter HTTP {status}: {text}"));
133 }
134
135 pump_openai_sse(resp, tx).await
136 }
137}
138
139/// Local chat provider for OpenAI-compatible servers (Ollama, LM Studio,
140/// llama.cpp's `server`, vLLM, etc.).
141///
142/// Why: developers increasingly run a local model server during dev to avoid
143/// API costs and latency. The OpenAI-compatible `/v1/chat/completions`
144/// endpoint with SSE streaming is the de-facto common denominator.
145/// What: stores the server's base URL and the model id to request.
146/// `chat_stream` POSTs `{model, messages, tools?, stream: true}` and parses
147/// SSE `data:` frames identically to the OpenRouter path.
148/// Test: shape covered by `ollama_provider_reports_metadata`; streaming and
149/// tool-call accumulation by `ollama_provider_streams_sse_deltas` and
150/// `accumulates_streamed_tool_call_fragments`.
151pub struct OllamaProvider {
152 pub base_url: String,
153 pub model: String,
154 /// #3758: see [`OpenRouterProvider::sampling`] — kept in lock-step so both
155 /// OpenAI-compatible providers send the same request shape.
156 pub sampling: SamplingParams,
157}
158
159impl OllamaProvider {
160 /// Construct a provider from a base URL and model id.
161 ///
162 /// Why: parallel to [`OpenRouterProvider::new`] so callers see a
163 /// consistent shape across providers.
164 /// What: stores both fields verbatim; the base URL should NOT have a
165 /// trailing slash — the implementation appends `/v1/chat/completions`.
166 /// Test: covered by `ollama_provider_reports_metadata`.
167 pub fn new(base_url: impl Into<String>, model: impl Into<String>) -> Self {
168 Self {
169 base_url: base_url.into(),
170 model: model.into(),
171 sampling: SamplingParams::default(),
172 }
173 }
174
175 /// Attach sampling parameters to every request this provider sends.
176 ///
177 /// Why: parallel to [`OpenRouterProvider::with_sampling`] (#3758) so both
178 /// providers stay in lock-step.
179 /// What: consuming builder; returns `self` with `sampling` replaced.
180 /// Test: `sampling_params_serialize_into_request_body`.
181 pub fn with_sampling(mut self, sampling: SamplingParams) -> Self {
182 self.sampling = sampling;
183 self
184 }
185}
186
187#[async_trait]
188impl ChatProvider for OllamaProvider {
189 fn name(&self) -> &str {
190 "ollama"
191 }
192
193 fn model(&self) -> &str {
194 &self.model
195 }
196
197 async fn chat_stream(
198 &self,
199 messages: Vec<ChatMessage>,
200 tools: Vec<ToolDef>,
201 tx: Sender<ChatEvent>,
202 ) -> Result<()> {
203 let client = reqwest::Client::builder()
204 .connect_timeout(std::time::Duration::from_secs(LOCAL_PROBE_TIMEOUT_SECS))
205 .timeout(std::time::Duration::from_secs(LOCAL_REQUEST_TIMEOUT_SECS))
206 .build()
207 .context("build reqwest client for OllamaProvider::chat_stream")?;
208
209 let url = format!(
210 "{}/v1/chat/completions",
211 self.base_url.trim_end_matches('/')
212 );
213 let tw = tools_wire(&tools);
214 let body = super::wire::ChatRequestWire {
215 model: &self.model,
216 messages: &messages,
217 stream: true,
218 tools: tw,
219 // #3758: forward the same sampling knobs the blocking path sends.
220 temperature: self.sampling.temperature,
221 max_tokens: self.sampling.max_tokens,
222 stop: self.sampling.stop_slice(),
223 };
224 let resp = client
225 .post(&url)
226 .json(&body)
227 .send()
228 .await
229 .with_context(|| format!("POST {url}"))?;
230
231 let status = resp.status();
232 if !status.is_success() {
233 let text = resp.text().await.unwrap_or_default();
234 return Err(anyhow!("local chat HTTP {status}: {text}"));
235 }
236
237 pump_openai_sse(resp, tx).await
238 }
239}
240
241/// Probe a local model server and return an [`OllamaProvider`] if reachable.
242///
243/// Why: at startup, downstream daemons want to know whether a local model
244/// server is running before falling back to a cloud provider. The OpenAI
245/// `/v1/models` endpoint is a cheap, side-effect-free liveness check that
246/// Ollama, LM Studio, and llama.cpp's server all implement.
247/// What: GETs `{base_url}/v1/models` with a 1-second total timeout. Returns
248/// `Some(OllamaProvider { base_url, model: "" })` on any 2xx response.
249/// Returns `None` on network errors, timeouts, or non-2xx status. Never
250/// returns an error — the caller treats absence as "no local provider
251/// available" and is responsible for setting the model id afterwards (e.g.
252/// from [`super::LocalModelConfig::model`]).
253/// Test: `auto_detect_returns_none_on_unreachable` points at a closed port
254/// and asserts `None` within the 1-second budget;
255/// `auto_detect_returns_some_on_200` spins up an in-process server and
256/// asserts a provider is returned.
257pub async fn auto_detect_local_provider(base_url: &str) -> Option<OllamaProvider> {
258 let client = reqwest::Client::builder()
259 .connect_timeout(std::time::Duration::from_secs(LOCAL_PROBE_TIMEOUT_SECS))
260 .timeout(std::time::Duration::from_secs(LOCAL_PROBE_TIMEOUT_SECS))
261 .build()
262 .ok()?;
263
264 let url = format!("{}/v1/models", base_url.trim_end_matches('/'));
265 match client.get(&url).send().await {
266 Ok(resp) if resp.status().is_success() => {
267 Some(OllamaProvider::new(base_url.to_string(), String::new()))
268 }
269 _ => None,
270 }
271}