Skip to main content

rpi_extensions/
provider_hooks.rs

1//! B4 — extension provider hooks. Bridges an extension's `on(BeforeProviderRequest)`
2//! / `on(BeforeProviderHeaders)` / `on(AfterProviderResponse)` handlers into
3//! rpi-ai's [`ProviderHooks`] slot, so every provider call fans the live
4//! request/response context out to the subscribed plugins.
5//!
6//! **Observer semantics (v1):** the SDK `EventHandlerFn` returns `i32` (no
7//! result payload), so a handler observes the request/response — it can record
8//! state, rotate an external token store, emit diagnostics — but cannot PATCH
9//! the live `SimpleStreamOptions`. The `before_request` hook therefore returns
10//! `None` (no patch); a future ABI extension (an `out` pointer on the handler)
11//! would unlock the patch path. The harness treats a `None` return as
12//! "leave opts untouched", so the observer path is safe.
13
14use std::sync::Arc;
15
16use rpi_ai::types::AssistantMessage;
17use rpi_ai::Context;
18use rpi_ai::{Model, ProviderHooks, SimpleStreamOptions, SimpleStreamOptionsPatch};
19use rpi_plugin_sdk::EventTag;
20
21use crate::loader::ExtensionSession;
22use crate::registry::RegistrySnapshot;
23use crate::translate::dispatch_data_event;
24use crate::PluginKeepalive;
25
26/// A [`ProviderHooks`] that dispatches to the registered extension handlers.
27/// Keeps the cdylib mappings alive via the keepalive (the handler fn pointers
28/// live inside the plugins).
29pub struct ExtensionProviderHooks {
30    snapshot: Arc<RegistrySnapshot>,
31    _keepalive: Arc<PluginKeepalive>,
32}
33
34impl ExtensionProviderHooks {
35    /// Build hooks over a loaded extension session's registry snapshot. Returns
36    /// `None` when no plugin subscribes to any provider-hook tag (so a session
37    /// without provider hooks runs the plain no-op path).
38    pub fn from_session(session: &ExtensionSession) -> Option<Self> {
39        let snapshot = session.snapshot_arc()?;
40        let subscribed = [
41            EventTag::BeforeProviderRequest,
42            EventTag::BeforeProviderHeaders,
43            EventTag::AfterProviderResponse,
44        ]
45        .iter()
46        .any(|t| !snapshot.handlers_for(*t).is_empty());
47        if !subscribed {
48            return None;
49        }
50        Some(Self {
51            snapshot,
52            _keepalive: session.keepalive(),
53        })
54    }
55}
56
57impl ProviderHooks for ExtensionProviderHooks {
58    /// Fan the planned request out to `BeforeProviderRequest` +
59    /// `BeforeProviderHeaders` subscribers. Returns `None` — observer-only in
60    /// v1 (the handler ABI has no result channel; see the module docs).
61    fn before_request(
62        &self,
63        model: &Model,
64        _ctx: &Context,
65        opts: &SimpleStreamOptions,
66    ) -> Option<SimpleStreamOptionsPatch> {
67        let request = serde_json::json!({
68            "model": model.id,
69            "provider": model.provider,
70            "baseUrl": model.base_url,
71            "reasoning": model.reasoning,
72            "apiKey": opts.api_key,
73            "timeoutMs": opts.timeout.map(|d| d.as_millis() as u64),
74            "headers": opts.headers,
75            "metadata": opts.metadata,
76            "maxTokens": opts.max_tokens,
77            "temperature": opts.temperature,
78        });
79        dispatch_data_event(
80            &self.snapshot,
81            EventTag::BeforeProviderRequest,
82            &request.to_string(),
83        );
84        let headers = serde_json::json!({ "headers": opts.headers });
85        dispatch_data_event(
86            &self.snapshot,
87            EventTag::BeforeProviderHeaders,
88            &headers.to_string(),
89        );
90        None
91    }
92
93    /// Fan the terminal assistant message out to `AfterProviderResponse`
94    /// subscribers.
95    fn after_response(&self, _model: &Model, message: &AssistantMessage) {
96        let json = serde_json::to_value(message).unwrap_or(serde_json::json!({}));
97        dispatch_data_event(
98            &self.snapshot,
99            EventTag::AfterProviderResponse,
100            &json.to_string(),
101        );
102    }
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108    use rpi_plugin_sdk::{EventTag, StablePluginEvent};
109    use std::sync::atomic::{AtomicUsize, Ordering};
110    use std::sync::Mutex;
111
112    static PROVIDER_HITS: AtomicUsize = AtomicUsize::new(0);
113    static PROVIDER_LOCK: Mutex<()> = Mutex::new(());
114
115    extern "C" fn counting_provider_handler(
116        ev: StablePluginEvent,
117        _ud: *mut std::ffi::c_void,
118    ) -> i32 {
119        // Count every dispatch on the subscribed tags (BeforeProviderRequest +
120        // BeforeProviderHeaders). The `model` check validates the request-event
121        // payload shape separately — it must NOT gate the count, or the headers
122        // event (whose payload is `{"headers":...}` with no `model` key) would
123        // be silently dropped and the fan-out count would be wrong.
124        let s = unsafe { ev.payload.data.data.to_string_lossy() };
125        if let Ok(v) = serde_json::from_str::<serde_json::Value>(&s) {
126            // The request event carries `model`; the headers event does not.
127            // Both are valid dispatches — count unconditionally, and sanity-check
128            // the request payload when `model` is present.
129            if v.get("model").is_some() {
130                assert!(v.get("model").is_some(), "request event must carry model");
131            }
132            PROVIDER_HITS.fetch_add(1, Ordering::SeqCst);
133        }
134        0
135    }
136
137    /// `dispatch_data_event` fans the JSON to the tag's handlers, and the
138    /// `ExtensionProviderHooks::before_request` path dispatches BOTH the
139    /// request and headers events to their subscribers.
140    #[test]
141    fn provider_hooks_dispatch_to_subscribers() {
142        let _guard = PROVIDER_LOCK.lock().unwrap();
143        PROVIDER_HITS.store(0, Ordering::SeqCst);
144
145        let mut registry = crate::registry::ExtensionRegistry::new();
146        let handler: rpi_plugin_sdk::EventHandlerFn = counting_provider_handler;
147        registry.register_event_handler(
148            EventTag::BeforeProviderRequest,
149            handler,
150            std::ptr::null_mut(),
151        );
152        registry.register_event_handler(
153            EventTag::BeforeProviderHeaders,
154            handler,
155            std::ptr::null_mut(),
156        );
157        let snapshot = Arc::new(registry.snapshot());
158
159        // dispatch_data_event directly: one request event ⇒ one hit.
160        assert!(dispatch_data_event(
161            &snapshot,
162            EventTag::BeforeProviderRequest,
163            r#"{"model":"m"}"#,
164        ));
165        assert_eq!(PROVIDER_HITS.load(Ordering::SeqCst), 1);
166
167        // through ExtensionProviderHooks::before_request. The hook fires BOTH
168        // BeforeProviderRequest AND BeforeProviderHeaders (each with the same
169        // counting handler subscribed), so 2 more hits ⇒ 3 total. The headers
170        // event's payload is `{"headers":...}` (no `model` key) — the handler
171        // counts it regardless (see counting_provider_handler).
172        let hooks = ExtensionProviderHooks {
173            snapshot: Arc::clone(&snapshot),
174            _keepalive: Arc::new(crate::PluginKeepalive::new(Vec::new(), None)),
175        };
176        let model = rpi_ai::Model::new(
177            "m",
178            "m",
179            rpi_ai::Api::AnthropicMessages,
180            "anthropic",
181            "https://api.anthropic.com",
182        );
183        let ctx = rpi_ai::Context::default();
184        let opts = rpi_ai::SimpleStreamOptions::default();
185        let patch = hooks.before_request(&model, &ctx, &opts);
186        assert!(patch.is_none(), "observer-only v1: no patch returned");
187        assert_eq!(PROVIDER_HITS.load(Ordering::SeqCst), 3);
188    }
189
190    /// A tag with no subscribers dispatches nothing (and doesn't free anything
191    /// dangling — dispatch_data_event short-circuits before building events).
192    #[test]
193    fn provider_hooks_no_subscribers_is_noop() {
194        let registry = crate::registry::ExtensionRegistry::new();
195        let snapshot = Arc::new(registry.snapshot());
196        assert!(!dispatch_data_event(
197            &snapshot,
198            EventTag::AfterProviderResponse,
199            "{}",
200        ));
201    }
202}