rpi_ai/provider.rs
1//! Mirrors `packages/ai/src/models.ts::Provider` — the trait every concrete
2//! provider (faux, anthropic, …) implements. The agent loop never touches a
3//! provider's wire format directly; it calls `stream_simple` and consumes the
4//! returned [`AssistantMessageEventStream`].
5
6use crate::event_stream::AssistantMessageEventStream;
7use crate::model::Model;
8use crate::types::{AssistantMessage, Context, DeferredHandle, ThinkingBudgets, ThinkingLevel};
9use std::collections::BTreeMap;
10use std::time::Duration;
11use tokio_util::sync::CancellationToken;
12
13/// Default deadline for one LLM API request, including its streamed response.
14pub const DEFAULT_LLM_API_TIMEOUT: Duration = Duration::from_secs(600);
15
16/// Cache-retention hint passed to a provider. Mirrors TS `CacheRetention`.
17/// `Long` requests 1h ephemeral cache control on Anthropic (subject to compat);
18/// `Short` requests the default ephemeral; `None` disables cache control.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
20pub enum CacheRetention {
21 None,
22 #[default]
23 Short,
24 Long,
25}
26
27/// Options for a "simple" (non-agent) stream call — what compaction / smoke
28/// tests / bare provider usage pass. The agent loop wraps this with its richer
29/// equivalent when it drives a real turn (M2).
30#[derive(Debug, Clone)]
31pub struct SimpleStreamOptions {
32 /// API key. A provider may fall back to an env var when `None` (M3 anthropic
33 /// reads `ANTHROPIC_API_KEY`); faux ignores it.
34 pub api_key: Option<String>,
35 /// Request timeout. Defaults to 600 seconds; `None` uses the same provider
36 /// fallback and can be used by patches to clear a caller override.
37 pub timeout: Option<Duration>,
38 /// Max retry attempts on retryable errors (408/409/429/>=500). `None` = 0.
39 pub max_retries: Option<u32>,
40 /// Cap on the total backoff between retries. `None` = 60s.
41 pub max_retry_delay: Option<Duration>,
42 /// Extra HTTP headers to send (merged over provider defaults).
43 pub headers: Option<BTreeMap<String, String>>,
44 /// Free-form provider metadata (request id, trace id, …). Anthropic reads
45 /// `user_id` for abuse tracking; faux ignores it.
46 pub metadata: Option<BTreeMap<String, String>>,
47 /// Cache-retention hint for prompt caching.
48 pub cache_retention: CacheRetention,
49 /// Optional session id (faux uses it to simulate prompt-cache hits).
50 pub session_id: Option<String>,
51 /// Cancellation token. Providers must poll this between chunks and emit an
52 /// `Error { reason: Aborted }` terminal event when cancelled. Defaults to a
53 /// fresh un-cancelled token so `Default`-constructed options never abort.
54 pub signal: CancellationToken,
55 /// Extended-thinking level. `None`/`Off` → thinking disabled (provider may
56 /// still emit `thinking: { type: "disabled" }` when the model reasons).
57 /// Mirrors TS `SimpleStreamOptions.reasoning`.
58 pub reasoning: Option<ThinkingLevel>,
59 /// Optional caller override for the requested output-token ceiling. `None`
60 /// lets the provider fit the thinking budget inside `Model::max_tokens`.
61 /// Mirrors TS `SimpleStreamOptions.maxTokens`.
62 pub max_tokens: Option<u64>,
63 /// Sampling temperature. Only applied when the model supports it AND
64 /// thinking is disabled (Anthropic rejects temperature under thinking).
65 /// Mirrors TS `StreamOptions.temperature`.
66 pub temperature: Option<f64>,
67 /// Per-level reasoning token budgets for budget-based thinking models.
68 /// Faux ignores this; anthropic uses it in `adjust_max_tokens_for_thinking`.
69 pub thinking_budgets: Option<ThinkingBudgets>,
70}
71
72impl Default for SimpleStreamOptions {
73 fn default() -> Self {
74 Self {
75 api_key: None,
76 timeout: Some(DEFAULT_LLM_API_TIMEOUT),
77 max_retries: None,
78 max_retry_delay: None,
79 headers: None,
80 metadata: None,
81 cache_retention: CacheRetention::default(),
82 session_id: None,
83 signal: CancellationToken::new(),
84 reasoning: None,
85 max_tokens: None,
86 temperature: None,
87 thinking_budgets: None,
88 }
89 }
90}
91
92impl SimpleStreamOptions {
93 pub fn new() -> Self {
94 Self::default()
95 }
96
97 pub fn with_api_key(mut self, key: impl Into<String>) -> Self {
98 self.api_key = Some(key.into());
99 self
100 }
101
102 pub fn with_signal(mut self, signal: CancellationToken) -> Self {
103 self.signal = signal;
104 self
105 }
106
107 /// Resolve the effective reasoning level, defaulting to `Off` when unset.
108 pub fn reasoning_level(&self) -> ThinkingLevel {
109 self.reasoning.unwrap_or(ThinkingLevel::Off)
110 }
111
112 /// Resolve the effective request timeout, including the provider fallback.
113 pub fn request_timeout(&self) -> Duration {
114 self.timeout.unwrap_or(DEFAULT_LLM_API_TIMEOUT)
115 }
116}
117
118#[cfg(test)]
119mod tests {
120 use super::{SimpleStreamOptions, DEFAULT_LLM_API_TIMEOUT};
121 use std::time::Duration;
122
123 #[test]
124 fn llm_api_timeout_defaults_to_ten_minutes() {
125 let options = SimpleStreamOptions::default();
126 assert_eq!(options.timeout, Some(Duration::from_secs(600)));
127 assert_eq!(options.request_timeout(), DEFAULT_LLM_API_TIMEOUT);
128 }
129
130 #[test]
131 fn llm_api_timeout_allows_override_and_none_uses_provider_fallback() {
132 let mut options = SimpleStreamOptions::default();
133 options.timeout = Some(Duration::from_secs(15));
134 assert_eq!(options.request_timeout(), Duration::from_secs(15));
135
136 options.timeout = None;
137 assert_eq!(options.request_timeout(), Duration::from_secs(600));
138 }
139}
140
141/// The trait seam. The arrow `pi-agent → pi-ai` means the agent loop depends on
142/// this trait alone — provider crates are pluggable behind it.
143///
144/// `stream_simple` returns synchronously (no `await`): it creates an
145/// [`AssistantMessageEventStream`], spawns the producer task internally, and
146/// hands back the consumer. Failures surface as `Error` events on the stream,
147/// never as panics or `Err` from this method.
148#[async_trait::async_trait]
149pub trait Provider: Send + Sync {
150 /// Stable provider id (e.g. `"anthropic"`, `"faux"`).
151 fn id(&self) -> &str;
152
153 /// Models this provider serves.
154 fn models(&self) -> &[Model];
155
156 /// Stream a single assistant turn against `ctx` on `model`. Returns the
157 /// consumer end of an event stream; the producer task is already running.
158 async fn stream_simple(
159 &self,
160 model: &Model,
161 ctx: &Context,
162 opts: &SimpleStreamOptions,
163 ) -> AssistantMessageEventStream;
164
165 /// This provider's deferred-response capability, if it has one.
166 ///
167 /// Defaulted rather than required, and returned as an optional sidecar:
168 /// only providers whose API actually has a long-poll continuation can
169 /// answer, and the rest should not carry a stub. This is the same reasoning
170 /// that put [`ProviderHooks`] in its own trait, except a default body also
171 /// spares every existing implementation.
172 fn deferred(&self) -> Option<&dyn DeferredProvider> {
173 None
174 }
175}
176
177/// Continue a response the provider deferred (a long-poll or batch API).
178///
179/// Mirrors the optional `streamDeferred` / `cancelDeferred` on upstream's models
180/// interface. Same contract as [`Provider::stream_simple`]: returns the consumer
181/// end of a stream and reports failures as `Error` events rather than `Err`, so
182/// the harness handles both kinds of provider call uniformly.
183#[async_trait::async_trait]
184pub trait DeferredProvider: Send + Sync {
185 /// Poll a handle this provider previously issued: either the finished
186 /// assistant message, or another `Deferred` handle to poll again.
187 async fn stream_deferred(
188 &self,
189 model: &Model,
190 handle: &DeferredHandle,
191 opts: &SimpleStreamOptions,
192 ) -> AssistantMessageEventStream;
193
194 /// Best-effort remote cancellation. Defaulting to `Ok` is right because most
195 /// long-poll APIs cannot be cancelled, and a caller must never be blocked by
196 /// that.
197 async fn cancel_deferred(
198 &self,
199 _model: &Model,
200 _handle: &DeferredHandle,
201 ) -> Result<(), String> {
202 Ok(())
203 }
204}
205
206// ---------------------------------------------------------------------------
207// ProviderHooks (B4) — a per-call sidecar hook fired inside the harness
208// `StreamFn` closure BEFORE each `stream_simple` call.
209//
210// Why a sidecar trait (not an extension of `Provider`): `Provider` is not
211// sealed (verified), so extending it directly is viable — but that would force
212// EVERY `Provider` impl to stub no-op hooks, even faux/anthropic. A sidecar
213// trait keeps `Provider` lean and lets only the host (harness) supply hooks;
214// the harness captures `Option<Arc<dyn ProviderHooks>>` into its `StreamFn`
215// closure and applies the patch per call. This mirrors pi's `beforeRequest`
216// per-call semantics, NOT a run-once config-build patch (applying once per run
217// is not equivalent — e.g. a hook that injects a per-request id or rotates a
218// header must run before every provider call).
219//
220// The patch type is `SimpleStreamOptionsPatch` (against `SimpleStreamOptions`,
221// the type actually passed to `stream_simple`) — NOT the harness-side
222// `AgentHarnessStreamOptionsPatch` (which targets `AgentHarnessStreamOptions`,
223// the pinned subset, and lives in rpi-harness): defining it here keeps rpi-ai
224// free of any rpi-harness dep (cycle-free) and targets the live options the
225// provider sees. The harness's existing `AgentHarnessStreamOptionsPatch` stays
226// for harness config, a separate concern.
227// ---------------------------------------------------------------------------
228
229/// A per-call provider hook. `before_request` may patch the live
230/// [`SimpleStreamOptions`] (e.g. inject/rotate headers, set metadata, override
231/// timeout) before the harness calls `stream_simple`; `after_response` observes
232/// the terminal assistant message after the stream resolves. Both are
233/// `Option`-returning: `None` = no change / no observation.
234///
235/// Implementations MUST be infallible at the API boundary — a hook error is
236/// swallowed (the run continues with the unpatched options), mirroring the
237/// "hooks must not throw" contract from pi and the harness's other hooks
238/// (`before_tool_call` etc.).
239pub trait ProviderHooks: Send + Sync {
240 /// Fired before each `stream_simple` call, with a snapshot of the model +
241 /// context + opts the harness is about to pass. Return a patch that the
242 /// harness applies to a clone of `ctx` before calling the provider; return
243 /// `None` to leave `ctx` untouched.
244 ///
245 /// This is the seam pi calls `transform_context`
246 /// (`harness/execution/assistant.ts:142`), and it fires on **every** provider
247 /// request of a run — the same cadence as [`Self::before_request`], and the
248 /// reason a plugin can change the prompt for one turn without the change
249 /// sticking to the session. It runs before `before_request` so a prompt the
250 /// hook installs is what that request carries.
251 ///
252 /// Unlike `before_request` this cannot patch `opts`; the two are separate so
253 /// a caller can reason about "what the model sees" independently of "how the
254 /// request is sent".
255 ///
256 /// Must not retain the borrow. Runs on the blocking-bridge thread inside the
257 /// harness `StreamFn` closure.
258 fn transform_context(&self, _model: &Model, _ctx: &Context) -> Option<ContextPatch> {
259 None
260 }
261
262 /// Fired before each `stream_simple` call, with a snapshot of the model +
263 /// context + opts the harness is about to pass. Return a patch that the
264 /// harness applies to a clone of `opts` before calling the provider; return
265 /// `None` to leave `opts` untouched.
266 ///
267 /// The hook receives a reference to the planned `opts`, not ownership — it
268 /// must not retain the borrow (the closure clones before `await`). Runs on
269 /// the blocking-bridge thread inside the harness `StreamFn` closure.
270 fn before_request(
271 &self,
272 _model: &Model,
273 _ctx: &Context,
274 _opts: &SimpleStreamOptions,
275 ) -> Option<SimpleStreamOptionsPatch> {
276 None
277 }
278
279 /// Fired after the stream resolves with the terminal assistant message.
280 /// `message` is the final `AssistantMessage` the provider produced (the same
281 /// one the loop emits as `MessageEnd`). `None` lets the default no-op impl
282 /// apply. Runs on the blocking-bridge thread.
283 fn after_response(&self, _model: &Model, _message: &AssistantMessage) {}
284}
285
286/// A no-op default so the harness can install a `ProviderHooks` slot cheaply
287/// when none is supplied (uniform code path vs `Option<Arc<dyn>>`).
288#[derive(Default)]
289pub struct NoopProviderHooks;
290impl ProviderHooks for NoopProviderHooks {}
291
292/// A patch against the model-facing [`Context`] returned by
293/// [`ProviderHooks::transform_context`].
294///
295/// Mirrors the `systemPrompt` half of pi's `transform_context` result
296/// (`harness/agent-harness.ts:445`): the hook may replace the system prompt the
297/// request carries. pi also allows replacing `messages` there; rpi deliberately
298/// does not (yet) — see `ProviderHooks::transform_context`.
299///
300/// `None` inside means "leave as-is", so an empty patch is a no-op and a caller
301/// that only wants to observe can return `None` from the hook entirely.
302#[derive(Debug, Clone, Default, PartialEq, Eq)]
303pub struct ContextPatch {
304 /// Replacement system prompt for this request. `Some` installs it verbatim
305 /// (**replacement, not append** — a plugin that wants to append reads
306 /// `ctx.system_prompt` and concatenates, exactly as pi requires).
307 pub system_prompt: Option<String>,
308}
309
310impl ContextPatch {
311 /// Apply this patch to `ctx` in place.
312 pub fn apply(&self, ctx: &mut Context) {
313 if let Some(prompt) = &self.system_prompt {
314 ctx.system_prompt = Some(prompt.clone());
315 }
316 }
317}
318
319/// A patch against [`SimpleStreamOptions`] returned by [`ProviderHooks::before_request`].
320/// Mirrors TS `beforeRequest`'s per-request overrides but targets the live
321/// `SimpleStreamOptions` (the type `stream_simple` receives), not the harness
322/// pinned subset. `Option<Option<T>>` semantics: outer `None` = "leave this
323/// field"; inner `Some(None)` on the headers/metadata maps = "delete this key".
324///
325/// This is *distinct* from the harness-side `AgentHarnessStreamOptionsPatch`
326/// (which lives in rpi-harness and patches `AgentHarnessStreamOptions`). Both
327/// exist; this one is the rpi-ai seam so provider hooks can be defined without
328/// a rpi-harness cycle.
329#[derive(Debug, Clone, Default)]
330pub struct SimpleStreamOptionsPatch {
331 pub timeout: Option<Option<Duration>>,
332 pub max_retries: Option<Option<u32>>,
333 pub max_retry_delay: Option<Option<Duration>>,
334 /// `None` = leave; `Some(map)` = merge, inner `None` deletes a key.
335 pub headers: Option<BTreeMap<String, Option<String>>>,
336 /// `None` = leave; `Some(map)` = merge, inner `None` deletes a key.
337 pub metadata: Option<BTreeMap<String, Option<String>>>,
338 pub cache_retention: Option<Option<CacheRetention>>,
339 pub max_tokens: Option<Option<u64>>,
340 pub temperature: Option<Option<f64>>,
341 pub session_id: Option<Option<String>>,
342}
343
344impl SimpleStreamOptionsPatch {
345 /// Apply this patch to `opts` in place. Mirrors the harness patch semantics:
346 /// outer `None` = leave the field; an explicit value replaces it; `None`
347 /// inside a map entry deletes that key.
348 pub fn apply(&self, opts: &mut SimpleStreamOptions) {
349 if let Some(v) = self.timeout {
350 opts.timeout = v;
351 }
352 if let Some(v) = self.max_retries {
353 opts.max_retries = v;
354 }
355 if let Some(v) = self.max_retry_delay {
356 opts.max_retry_delay = v;
357 }
358 if let Some(patch) = &self.headers {
359 let mut map = opts.headers.take().unwrap_or_default();
360 for (k, v) in patch {
361 match v {
362 Some(val) => map.insert(k.clone(), val.clone()),
363 None => map.remove(k),
364 };
365 }
366 opts.headers = if map.is_empty() { None } else { Some(map) };
367 }
368 if let Some(patch) = &self.metadata {
369 let mut map = opts.metadata.take().unwrap_or_default();
370 for (k, v) in patch {
371 match v {
372 Some(val) => map.insert(k.clone(), val.clone()),
373 None => map.remove(k),
374 };
375 }
376 opts.metadata = if map.is_empty() { None } else { Some(map) };
377 }
378 if let Some(v) = self.cache_retention {
379 opts.cache_retention = v.unwrap_or_default();
380 }
381 if let Some(v) = self.max_tokens {
382 opts.max_tokens = v;
383 }
384 if let Some(v) = self.temperature {
385 opts.temperature = v;
386 }
387 if let Some(v) = &self.session_id {
388 opts.session_id = v.clone();
389 }
390 }
391}