use std::sync::{Arc, Mutex};
use rpi_ai::types::{AssistantMessage, Content, Message, UserContent};
use rpi_ai::Context;
use rpi_ai::{Model, ProviderHooks, SimpleStreamOptions, SimpleStreamOptionsPatch};
use rpi_plugin_sdk::EventTag;
use crate::loader::ExtensionSession;
use crate::registry::RegistrySnapshot;
use crate::translate::dispatch_data_event;
use crate::PluginKeepalive;
pub struct ExtensionProviderHooks {
snapshot: Arc<RegistrySnapshot>,
_keepalive: Arc<PluginKeepalive>,
last_before_agent_start: Mutex<Option<i64>>,
}
impl ExtensionProviderHooks {
pub fn from_session(session: &ExtensionSession) -> Option<Self> {
let snapshot = session.snapshot_arc()?;
let subscribed = [
EventTag::BeforeProviderRequest,
EventTag::BeforeProviderHeaders,
EventTag::AfterProviderResponse,
EventTag::BeforeAgentStart,
]
.iter()
.any(|t| !snapshot.handlers_for(*t).is_empty());
if !subscribed {
return None;
}
Some(Self {
snapshot,
_keepalive: session.keepalive(),
last_before_agent_start: Mutex::new(None),
})
}
}
impl ProviderHooks for ExtensionProviderHooks {
fn before_request(
&self,
model: &Model,
ctx: &Context,
opts: &SimpleStreamOptions,
) -> Option<SimpleStreamOptionsPatch> {
if let Some((timestamp, prompt, image_count)) = latest_user_prompt(&ctx.messages) {
let mut last = self.last_before_agent_start.lock().unwrap();
if *last != Some(timestamp) {
*last = Some(timestamp);
drop(last);
let event = serde_json::json!({
"prompt": prompt,
"imageCount": image_count,
"sessionId": std::env::var(rpi_plugin_sdk::SESSION_ID_ENV).ok(),
"host": host_identity(),
});
dispatch_data_event(
&self.snapshot,
EventTag::BeforeAgentStart,
&event.to_string(),
);
}
}
let request = serde_json::json!({
"model": model.id,
"provider": model.provider,
"baseUrl": model.base_url,
"reasoning": model.reasoning,
"apiKey": opts.api_key,
"timeoutMs": opts.timeout.map(|d| d.as_millis() as u64),
"headers": opts.headers,
"metadata": opts.metadata,
"maxTokens": opts.max_tokens,
"temperature": opts.temperature,
"messages": ctx.messages,
});
dispatch_data_event(
&self.snapshot,
EventTag::BeforeProviderRequest,
&request.to_string(),
);
let headers = serde_json::json!({ "headers": opts.headers });
dispatch_data_event(
&self.snapshot,
EventTag::BeforeProviderHeaders,
&headers.to_string(),
);
None
}
fn after_response(&self, _model: &Model, message: &AssistantMessage) {
let json = serde_json::to_value(message).unwrap_or(serde_json::json!({}));
dispatch_data_event(
&self.snapshot,
EventTag::AfterProviderResponse,
&json.to_string(),
);
}
}
fn host_identity() -> serde_json::Value {
serde_json::json!({
"name": rpi_plugin_sdk::HOST_NAME,
"version": env!("CARGO_PKG_VERSION"),
})
}
fn latest_user_prompt(messages: &[Message]) -> Option<(i64, String, usize)> {
messages.iter().rev().find_map(|message| match message {
Message::User(user) => Some((
user.timestamp,
user_content_text(&user.content),
user_content_image_count(&user.content),
)),
_ => None,
})
}
fn user_content_text(content: &UserContent) -> String {
match content {
UserContent::Text(text) => text.clone(),
UserContent::Blocks(blocks) => blocks
.iter()
.filter_map(|block| match block {
Content::Text(text) => Some(text.text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join("\n"),
}
}
fn user_content_image_count(content: &UserContent) -> usize {
match content {
UserContent::Text(_) => 0,
UserContent::Blocks(blocks) => blocks
.iter()
.filter(|block| matches!(block, Content::Image(_)))
.count(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use rpi_plugin_sdk::{EventTag, StablePluginEvent};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Mutex;
static PROVIDER_HITS: AtomicUsize = AtomicUsize::new(0);
static PROVIDER_LOCK: Mutex<()> = Mutex::new(());
static CAPTURED: Mutex<Vec<(EventTag, String)>> = Mutex::new(Vec::new());
extern "C" fn capture_handler(ev: StablePluginEvent, _ud: *mut std::ffi::c_void) -> i32 {
let payload = unsafe { ev.payload.data.data.to_string_lossy() };
CAPTURED.lock().unwrap().push((ev.tag, payload));
0
}
extern "C" fn counting_provider_handler(
ev: StablePluginEvent,
_ud: *mut std::ffi::c_void,
) -> i32 {
let s = unsafe { ev.payload.data.data.to_string_lossy() };
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&s) {
if v.get("model").is_some() {
assert!(v.get("model").is_some(), "request event must carry model");
}
PROVIDER_HITS.fetch_add(1, Ordering::SeqCst);
}
0
}
#[test]
fn before_request_emits_before_agent_start_once_and_forwards_messages() {
let _guard = PROVIDER_LOCK.lock().unwrap();
CAPTURED.lock().unwrap().clear();
let mut registry = crate::registry::ExtensionRegistry::new();
registry.register_event_handler(
"test-plugin".to_string(),
EventTag::BeforeAgentStart,
capture_handler,
std::ptr::null_mut(),
);
registry.register_event_handler(
"test-plugin".to_string(),
EventTag::BeforeProviderRequest,
capture_handler,
std::ptr::null_mut(),
);
let snapshot = Arc::new(registry.snapshot());
let hooks = ExtensionProviderHooks {
snapshot: Arc::clone(&snapshot),
_keepalive: Arc::new(crate::PluginKeepalive::new(Vec::new(), None)),
last_before_agent_start: Mutex::new(None),
};
let model = rpi_ai::Model::new(
"m",
"m",
rpi_ai::Api::AnthropicMessages,
"anthropic",
"https://api.anthropic.com",
);
let ctx = rpi_ai::Context::new(vec![rpi_ai::types::Message::User(
rpi_ai::types::UserMessage::new(
rpi_ai::types::UserContent::Text("hello".to_string()),
42,
),
)]);
let opts = rpi_ai::SimpleStreamOptions::default();
hooks.before_request(&model, &ctx, &opts);
hooks.before_request(&model, &ctx, &opts);
let captured = CAPTURED.lock().unwrap().clone();
let agent_starts: Vec<_> = captured
.iter()
.filter(|(tag, _)| matches!(tag, EventTag::BeforeAgentStart))
.collect();
assert_eq!(
agent_starts.len(),
1,
"before_agent_start must fire once per prompt: {captured:?}"
);
let start_payload: serde_json::Value =
serde_json::from_str(&agent_starts[0].1).expect("before_agent_start payload is JSON");
assert_eq!(start_payload["prompt"], "hello");
assert_eq!(start_payload["imageCount"], 0);
assert_eq!(
start_payload.get("sessionId").map(|v| v.is_null()),
Some(std::env::var(rpi_plugin_sdk::SESSION_ID_ENV).is_err()),
"sessionId must mirror the host's session id env var: {}",
agent_starts[0].1
);
assert_eq!(start_payload["host"]["name"], rpi_plugin_sdk::HOST_NAME);
assert_eq!(
start_payload["host"]["version"],
env!("CARGO_PKG_VERSION"),
"the host reports the version of the host, not of the plugin SDK"
);
let requests: Vec<_> = captured
.iter()
.filter(|(tag, _)| matches!(tag, EventTag::BeforeProviderRequest))
.collect();
assert_eq!(requests.len(), 2, "every provider call is observed");
assert!(
requests[0].1.contains("\"messages\""),
"request payload must forward messages: {}",
requests[0].1
);
}
#[test]
fn provider_hooks_dispatch_to_subscribers() {
let _guard = PROVIDER_LOCK.lock().unwrap();
PROVIDER_HITS.store(0, Ordering::SeqCst);
let mut registry = crate::registry::ExtensionRegistry::new();
let handler: rpi_plugin_sdk::EventHandlerFn = counting_provider_handler;
registry.register_event_handler(
"test-plugin".to_string(),
EventTag::BeforeProviderRequest,
handler,
std::ptr::null_mut(),
);
registry.register_event_handler(
"test-plugin".to_string(),
EventTag::BeforeProviderHeaders,
handler,
std::ptr::null_mut(),
);
let snapshot = Arc::new(registry.snapshot());
assert!(dispatch_data_event(
&snapshot,
EventTag::BeforeProviderRequest,
r#"{"model":"m"}"#,
));
assert_eq!(PROVIDER_HITS.load(Ordering::SeqCst), 1);
let hooks = ExtensionProviderHooks {
snapshot: Arc::clone(&snapshot),
_keepalive: Arc::new(crate::PluginKeepalive::new(Vec::new(), None)),
last_before_agent_start: Mutex::new(None),
};
let model = rpi_ai::Model::new(
"m",
"m",
rpi_ai::Api::AnthropicMessages,
"anthropic",
"https://api.anthropic.com",
);
let ctx = rpi_ai::Context::default();
let opts = rpi_ai::SimpleStreamOptions::default();
let patch = hooks.before_request(&model, &ctx, &opts);
assert!(patch.is_none(), "observer-only v1: no patch returned");
assert_eq!(PROVIDER_HITS.load(Ordering::SeqCst), 3);
}
#[test]
fn provider_hooks_no_subscribers_is_noop() {
let registry = crate::registry::ExtensionRegistry::new();
let snapshot = Arc::new(registry.snapshot());
assert!(!dispatch_data_event(
&snapshot,
EventTag::AfterProviderResponse,
"{}",
));
}
}