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}