Skip to main content

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}