Skip to main content

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}