Skip to main content

rpi_extensions/
provider.rs

1//! B5c — `PluggableProvider`: the host-side `Provider` impl wrapping a plugin's
2//! `ProviderRequestFn`.
3//!
4//! A plugin's [`ProviderRequestFn`](rpi_plugin_sdk::ProviderRequestFn) is a
5//! **synchronous one-shot**: given a borrowed JSON request envelope it writes a
6//! **plugin-owned** `out` [`StbString`] (a full assistant-message JSON). It
7//! cannot drive a chunked `stream_simple` without blocking, so the v1
8//! `PluggableProvider` emits the full response as a **single terminal `Done`
9//! chunk** — a documented divergence from pi's async `streamSimple`
10//! (`custom-provider.md`). The ffi call runs on `spawn_blocking` (via a captured
11//! [`Handle`](tokio::runtime::Handle) — same foreign-thread fix as the
12//! `runtime_action` bridge), so the async `stream_simple` never blocks a runtime
13//! worker.
14//!
15//! ## Request/response envelope
16//!
17//! - **Request** (borrowed `StbStringRef` handed to `request_fn`): JSON
18//!   `{ model, context, options }`, where `model`/`context` are the serde
19//!   serializations of [`Model`](rpi_ai::Model)/[`Context`](rpi_ai::Context)
20//!   and `options` is a hand-built JSON of the [`SimpleStreamOptions`] fields
21//!   (that type is not `Serialize`; we mirror the field set
22//!   [`ExtensionProviderHooks`](crate::ExtensionProviderHooks) dispatches).
23//! - **Response** (the plugin-owned `out` `StbString`): an
24//!   [`AssistantMessage`] JSON. The host reclaims `out` via the plugin's
25//!   `plugin_free_string` (traveled with the registration), parses the JSON, and
26//!   pushes it as one `Done` (or `Error` on nonzero rc / parse failure).
27//!
28//! ## Lifetime + staleness
29//!
30//! `PluggableProvider` mirrors [`ExtensionProviderHooks`]: it holds an
31//! `Arc<RegistrySnapshot>` (the staleness flag is shared — a `/reload`
32//! invalidate marks it stale) + an `Arc<PluginKeepalive>` (keeps the cdylib
33//! mapped so `request_fn` stays callable) + the [`RegisteredProvider`] record +
34//! a `Handle` captured at build time (the host is on the runtime when it
35//! constructs providers from the session).
36
37use std::sync::Arc;
38
39use rpi_ai::event_stream::create_assistant_message_event_stream;
40use rpi_ai::types::{AssistantMessage, DoneReason, ErrorReason};
41use rpi_ai::{
42    AssistantMessageEvent, AssistantMessageEventStream, AssistantMessageEventStreamProducer,
43    Context, Model, Provider, SimpleStreamOptions,
44};
45use tokio::runtime::Handle;
46
47use crate::loader::ExtensionSession;
48use crate::registry::{RegisteredProvider, RegistrySnapshot};
49use crate::PluginKeepalive;
50
51/// A `Provider` backed by a plugin's sync `ProviderRequestFn`.
52///
53/// See the module docs for the v1 one-shot streaming model + the request/response
54/// envelope. Built from a loaded [`ExtensionSession`]; one `PluggableProvider`
55/// per [`RegisteredProvider`] in the session's snapshot.
56pub struct PluggableProvider {
57    /// The registration record (id/base_url/api_style + the fn pointers). Cloned
58    /// from the snapshot; the fn pointers are valid for the keepalive's lifetime.
59    record: RegisteredProvider,
60    /// Shared staleness flag (a `/reload` invalidate flows through here).
61    snapshot: Arc<RegistrySnapshot>,
62    /// Keeps the cdylib mapped so `request_fn` stays callable.
63    _keepalive: Arc<PluginKeepalive>,
64    /// Captured at build time — `spawn_blocking` works from the async context.
65    runtime: Handle,
66}
67
68impl PluggableProvider {
69    fn new(
70        record: RegisteredProvider,
71        snapshot: Arc<RegistrySnapshot>,
72        keepalive: Arc<PluginKeepalive>,
73        runtime: Handle,
74    ) -> Arc<Self> {
75        Arc::new(Self {
76            record,
77            snapshot,
78            _keepalive: keepalive,
79            runtime,
80        })
81    }
82
83    /// Build one [`PluggableProvider`] per registered provider in the session's
84    /// snapshot, as `Arc<dyn Provider>`. Returns an empty vec when no plugin
85    /// registered a provider (so a session without provider plugins injects
86    /// nothing). The `Handle` MUST be captured from a thread running the target
87    /// runtime (pi-cli builds providers on the async main thread, same as the
88    /// `ActionBridge`).
89    pub fn from_session(session: &ExtensionSession, runtime: Handle) -> Vec<Arc<dyn Provider>> {
90        let Some(snapshot) = session.snapshot_arc() else {
91            return Vec::new();
92        };
93        let keepalive = session.keepalive();
94        snapshot
95            .providers()
96            .iter()
97            .cloned()
98            .map(|record| {
99                Self::new(
100                    record,
101                    Arc::clone(&snapshot),
102                    Arc::clone(&keepalive),
103                    runtime.clone(),
104                ) as Arc<dyn Provider>
105            })
106            .collect()
107    }
108}
109
110#[async_trait::async_trait]
111impl Provider for PluggableProvider {
112    fn id(&self) -> &str {
113        &self.record.provider_id
114    }
115
116    fn models(&self) -> &[Model] {
117        // v1: the registrar carries no model list (the SDK `ProviderRequestFn`
118        // signature has no models channel — a plugin resolves models itself).
119        // The host injects this provider into `AgentHarnessOptions.models`, so
120        // the harness finds it by id when a catalog model's `provider` field
121        // matches; whether such a model exists is a catalog/config concern.
122        // Returning empty here means the provider advertises no models of its
123        // own (mirrors a provider that only serves foreign-configured models).
124        &[]
125    }
126
127    async fn stream_simple(
128        &self,
129        model: &Model,
130        ctx: &Context,
131        opts: &SimpleStreamOptions,
132    ) -> AssistantMessageEventStream {
133        let (mut prod, stream) = create_assistant_message_event_stream();
134        let snapshot = Arc::clone(&self.snapshot);
135        // into_owned_record copies the fn pointers + owned strings; the
136        // `user_data` raw pointer is carried (plugin-owned, valid for the
137        // keepalive lifetime).
138        let record = self.record.clone();
139        let runtime = self.runtime.clone();
140        let model = model.clone();
141        let ctx = Arc::new(ctx.clone());
142        // `opts` is not `Clone`-free of lifetime — capture the fields the
143        // request envelope needs as owned values so the spawned task is
144        // `'static`. `SimpleStreamOptions` IS `Clone` (verified, provider.rs:27),
145        // so a single clone carries every field into the task.
146        let opts = opts.clone();
147
148        // `spawn` (not `spawn_blocking`) for the producer task — the producer
149        // must be async so it can `await` the blocking ffi call. Inside, we
150        // `spawn_blocking` the sync `request_fn` (the ffi call is blocking by
151        // contract). This keeps the runtime worker free while the plugin runs.
152        tokio::spawn(async move {
153            let message =
154                drive_plugin_provider(&snapshot, &record, runtime, &model, &ctx, &opts).await;
155            push_terminal(&mut prod, message);
156            // `prod` drops here, closing the mpsc sender so the consumer's
157            // `next()` returns `None` after the terminal event.
158        });
159
160        stream
161    }
162}
163
164/// Run the sync `ProviderRequestFn` on `spawn_blocking`, read its plugin-owned
165/// `out` JSON, reclaim it via `plugin_free_string`, parse to an
166/// [`AssistantMessage`]. Returns an error terminal message on any failure
167/// (staleness, nonzero rc, parse error, runtime shutdown).
168async fn drive_plugin_provider(
169    snapshot: &Arc<RegistrySnapshot>,
170    record: &RegisteredProvider,
171    runtime: Handle,
172    model: &Model,
173    ctx: &Context,
174    opts: &SimpleStreamOptions,
175) -> AssistantMessage {
176    // Staleness: a `/reload`d session must not drive its old providers.
177    if !snapshot.is_active() {
178        return AssistantMessage::terminal(
179            model.api.clone(),
180            record.provider_id.clone(),
181            model.id.clone(),
182            rpi_ai::types::StopReason::Error,
183            "extensions provider registry is stale (session swapped/reloaded)",
184            0,
185        );
186    }
187
188    // Build the request envelope JSON. `Model`/`Context` are Serialize;
189    // `SimpleStreamOptions` is not, so the `options` object is hand-built
190    // (mirrors `ExtensionProviderHooks::before_request`).
191    let request = serde_json::json!({
192        "model": serde_json::to_value(model).unwrap_or(serde_json::Value::Null),
193        "context": serde_json::to_value(ctx).unwrap_or(serde_json::Value::Null),
194        "options": options_json(opts),
195    });
196    let request_json = request.to_string();
197
198    // The fn pointers + user_data are `Copy`-able (fn ptrs) or plain raw (ud);
199    // clone what spawn_blocking needs. `record` is `Clone` (owned strings).
200    let record = record.clone();
201    let provider_id = record.provider_id.clone();
202    let api = model.api.clone();
203    let model_id = model.id.clone();
204
205    // `spawn_blocking` the sync ffi call — `request_fn` is blocking by contract.
206    // The join handle is awaited so a panic in the plugin (caught by
207    // `catch_unwind` below) surfaces as an error, not a silent hang.
208    let join = runtime.spawn_blocking(move || run_provider_request(&record, &request_json));
209    let outcome = match join.await {
210        Ok(inner) => inner,
211        Err(join_err) => {
212            // The blocking task panicked before returning (catch_unwind inside
213            // caught a plugin panic and aborted, OR tokio cancelled). Either way
214            // no response.
215            return AssistantMessage::terminal(
216                api,
217                provider_id,
218                model_id,
219                rpi_ai::types::StopReason::Error,
220                format!("plugin provider task failed: {join_err}"),
221                0,
222            );
223        }
224    };
225
226    match outcome {
227        ProviderOutcome::Ok(message) => message,
228        ProviderOutcome::Err(msg) => AssistantMessage::terminal(
229            api,
230            provider_id,
231            model_id,
232            rpi_ai::types::StopReason::Error,
233            msg,
234            0,
235        ),
236    }
237}
238
239/// The result of one sync `request_fn` call: either a parsed assistant message
240/// or an error string. Computed inside `spawn_blocking`.
241enum ProviderOutcome {
242    Ok(AssistantMessage),
243    Err(String),
244}
245
246/// The inner sync driver (runs on the blocking pool). Calls `request_fn`,
247/// recovers the plugin-owned `out` `StbString`, reclaims it via
248/// `plugin_free_string`, parses to an [`AssistantMessage`]. Wrapped in
249/// `catch_unwind` so a plugin panic cannot unwind across FFI (⇒ error outcome).
250fn run_provider_request(record: &RegisteredProvider, request_json: &str) -> ProviderOutcome {
251    let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
252        run_provider_request_inner(record, request_json)
253    }));
254    match outcome {
255        Ok(inner) => inner,
256        Err(_) => {
257            tracing::error!(
258                "plugin provider {} panicked — refusing to unwind across FFI",
259                record.provider_id
260            );
261            ProviderOutcome::Err(format!(
262                "plugin provider {} panicked during request",
263                record.provider_id
264            ))
265        }
266    }
267}
268
269fn run_provider_request_inner(record: &RegisteredProvider, request_json: &str) -> ProviderOutcome {
270    // Borrowed request envelope: the plugin must NOT free it (StbStringRef is a
271    // borrow — the SDK contract).
272    let req_ref = StbStringRef::from_str(request_json);
273
274    // Plugin-owned `out` — `request_fn` writes a StbString into `*out` (plugin
275    // allocation, reclaimed via `plugin_free_string`). Start empty.
276    let mut out = StbString::empty();
277    let rc = (record.request_fn)(req_ref, &mut out as *mut StbString, record.user_data);
278    if rc != 0 {
279        // Nonzero rc: the plugin signaled failure. Reclaim whatever it wrote
280        // (may be empty) before returning.
281        out.free_with(Some(record.plugin_free_string));
282        return ProviderOutcome::Err(format!(
283            "plugin provider {} returned error code {rc}",
284            record.provider_id
285        ));
286    }
287
288    // Read the plugin-owned JSON (borrow `out` for the parse) then reclaim it.
289    let response_text = out.to_string_lossy();
290    out.free_with(Some(record.plugin_free_string));
291
292    // Parse the response as an AssistantMessage. A plugin that returns a partial
293    // or malformed message surfaces a parse error (not a crash) — the host
294    // emits it as an `Error` terminal so the run terminates cleanly.
295    match serde_json::from_str::<AssistantMessage>(&response_text) {
296        Ok(message) => {
297            // Stamp the provider/model identity the host expects if the plugin
298            // omitted them (a plugin may return only `content`/`stopReason`).
299            // We do NOT overwrite fields the plugin set — only fill defaults on
300            // a message whose provider/model don't match this provider.
301            let mut message = message;
302            if message.provider.is_empty() {
303                message.provider = record.provider_id.clone();
304            }
305            if message.model.is_empty() {
306                message.model = String::new(); // host stamps model id upstream; leave as-is
307            }
308            ProviderOutcome::Ok(message)
309        }
310        Err(err) => ProviderOutcome::Err(format!(
311            "plugin provider {} returned unparseable response: {err}",
312            record.provider_id
313        )),
314    }
315}
316
317/// Push the terminal event for `message`. A `StopReason::Error`/`Aborted`
318/// message → `Error`; otherwise → `Done` (mapping the stop reason onto
319/// [`DoneReason`]). This is the single chunk v1 emits (one-shot streaming).
320fn push_terminal(prod: &mut AssistantMessageEventStreamProducer, message: AssistantMessage) {
321    use rpi_ai::types::StopReason;
322    match message.stop_reason {
323        StopReason::Error | StopReason::Aborted => {
324            prod.push(AssistantMessageEvent::Error {
325                reason: ErrorReason::Error,
326                error: message,
327            });
328        }
329        other => {
330            let reason = match other {
331                StopReason::Stop => DoneReason::Stop,
332                StopReason::Length => DoneReason::Length,
333                StopReason::ToolUse => DoneReason::ToolUse,
334                StopReason::Deferred => DoneReason::Deferred,
335                // Pending/unknown settle to Stop (the plugin should have set a
336                // terminal reason; if not, the response is still complete).
337                _ => DoneReason::Stop,
338            };
339            prod.push(AssistantMessageEvent::Done { reason, message });
340        }
341    }
342}
343
344/// Build the `options` JSON for the request envelope. `SimpleStreamOptions` is
345/// not `Serialize`, so we hand-build the field set (mirrors
346/// `ExtensionProviderHooks::before_request`). Sensitive fields (`api_key`) are
347/// included — the plugin is trusted host-side code (a cdylib the user installed
348/// under `--extensions-dir`); this matches pi's `registerProvider` handing the
349/// full options to the plugin's `streamSimple`.
350fn options_json(opts: &SimpleStreamOptions) -> serde_json::Value {
351    serde_json::json!({
352        "apiKey": opts.api_key,
353        "timeoutMs": opts.timeout.map(|d| d.as_millis() as u64),
354        "maxRetries": opts.max_retries,
355        "maxRetryDelayMs": opts.max_retry_delay.map(|d| d.as_millis() as u64),
356        "headers": opts.headers,
357        "metadata": opts.metadata,
358        "cacheRetention": format!("{:?}", opts.cache_retention),
359        "sessionId": opts.session_id,
360        "reasoning": opts.reasoning.map(|r| format!("{r:?}")),
361        "maxTokens": opts.max_tokens,
362        "temperature": opts.temperature,
363    })
364}
365
366use rpi_plugin_sdk::{StbString, StbStringRef};
367
368#[cfg(test)]
369mod tests {
370    use super::*;
371    use crate::registry::ExtensionRegistry;
372
373    /// A stub `ProviderRequestFn` that writes a minimal assistant-message JSON
374    /// into `out`. Mirrors the shape a real plugin's request fn returns.
375    extern "C" fn ok_request_fn(
376        _req: StbStringRef,
377        out: *mut StbString,
378        _ud: *mut std::ffi::c_void,
379    ) -> i32 {
380        let json = r#"{"role":"assistant","content":[{"type":"text","text":"hi"}],"api":"faux","provider":"pluggy","model":"m","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0,"total":0.0}},"stopReason":"stop","timestamp":0}"#;
381        unsafe {
382            *out = StbString::from_string(json.to_string());
383        }
384        0
385    }
386
387    /// A free fn matching the stub's allocation (`StbString::from_string` ⇒ a
388    /// `Box<[u8]>` reclaimed by reconstructing the slice).
389    extern "C" fn stub_free(s: StbString) {
390        if s.is_empty() || s.ptr.is_null() {
391            return;
392        }
393        unsafe {
394            let slice = std::slice::from_raw_parts(s.ptr as *const u8, s.len);
395            let _ = Box::from_raw(slice as *const [u8] as *mut [u8]);
396        }
397    }
398
399    /// `run_provider_request_inner` round-trips a nonzero-rc plugin response to
400    /// `Err`, and a valid-message response to `Ok` (with provider stamped from
401    /// the record when the plugin omits it).
402    #[test]
403    fn request_fn_round_trip_ok_and_err() {
404        let mut registry = ExtensionRegistry::new();
405        registry.register_provider(RegisteredProvider {
406            provider_id: "pluggy".to_string(),
407            base_url: "https://example".to_string(),
408            api_style: "anthropic-messages".to_string(),
409            request_fn: ok_request_fn,
410            plugin_free_string: stub_free,
411            user_data: std::ptr::null_mut(),
412        });
413        let snap = Arc::new(registry.snapshot());
414        let record = snap.providers()[0].clone();
415
416        // Valid response → Ok(message).
417        let outcome = run_provider_request(&record, r#"{"model":"m"}"#);
418        match outcome {
419            ProviderOutcome::Ok(m) => {
420                assert_eq!(m.provider, "pluggy");
421                assert_eq!(m.stop_reason, rpi_ai::types::StopReason::Stop);
422                assert_eq!(m.content.len(), 1);
423            }
424            _ => panic!("expected Ok"),
425        }
426
427        // A request fn that returns nonzero rc ⇒ Err (registered separately).
428        extern "C" fn err_request_fn(
429            _req: StbStringRef,
430            _out: *mut StbString,
431            _ud: *mut std::ffi::c_void,
432        ) -> i32 {
433            7
434        }
435        let err_record = RegisteredProvider {
436            provider_id: "pluggy".to_string(),
437            base_url: "https://example".to_string(),
438            api_style: "anthropic-messages".to_string(),
439            request_fn: err_request_fn,
440            plugin_free_string: stub_free,
441            user_data: std::ptr::null_mut(),
442        };
443        let outcome = run_provider_request(&err_record, r#"{"model":"m"}"#);
444        assert!(matches!(outcome, ProviderOutcome::Err(_)));
445    }
446}