1use std::sync::{Arc, Mutex};
15
16use rpi_ai::types::{AssistantMessage, Content, Message, UserContent};
17use rpi_ai::Context;
18use rpi_ai::{ContextPatch, Model, ProviderHooks, SimpleStreamOptions, SimpleStreamOptionsPatch};
19use rpi_plugin_sdk::EventTag;
20
21use crate::loader::ExtensionSession;
22use crate::prompt_transform::emit_before_agent_start;
23use crate::registry::RegistrySnapshot;
24use crate::translate::dispatch_data_event;
25use crate::PluginKeepalive;
26
27pub struct ExtensionProviderHooks {
31 snapshot: Arc<RegistrySnapshot>,
32 _keepalive: Arc<PluginKeepalive>,
33 last_before_agent_start: Mutex<Option<i64>>,
37 last_forced_prompt: Mutex<Option<(i64, Option<String>)>>,
49}
50
51impl ExtensionProviderHooks {
52 pub fn from_session(session: &ExtensionSession) -> Option<Self> {
68 let snapshot = session.snapshot_arc()?;
69 let observes_events = [
70 EventTag::BeforeProviderRequest,
71 EventTag::BeforeProviderHeaders,
72 EventTag::AfterProviderResponse,
73 EventTag::BeforeAgentStart,
74 ]
75 .iter()
76 .any(|t| !snapshot.handlers_for(*t).is_empty());
77 let transforms_prompt = !snapshot.before_agent_start().is_empty();
78 if !observes_events && !transforms_prompt {
79 return None;
80 }
81 Some(Self {
82 snapshot,
83 _keepalive: session.keepalive(),
84 last_before_agent_start: Mutex::new(None),
85 last_forced_prompt: Mutex::new(None),
86 })
87 }
88}
89
90impl ProviderHooks for ExtensionProviderHooks {
91 fn transform_context(&self, _model: &Model, ctx: &Context) -> Option<ContextPatch> {
100 if self.snapshot.before_agent_start().is_empty() {
101 return None;
102 }
103 let (timestamp, prompt, image_count) = latest_user_prompt(&ctx.messages)?;
108
109 let mut cache = self.last_forced_prompt.lock().unwrap();
110 if cache.as_ref().map(|(ts, _)| *ts) != Some(timestamp) {
111 let event = before_agent_start_event(&prompt, image_count, ctx);
112 let forced = emit_before_agent_start(
113 ctx.system_prompt.as_deref().unwrap_or_default(),
114 &event.to_string(),
115 &self.snapshot,
116 );
117 *cache = Some((timestamp, forced));
118 }
119 cache
120 .as_ref()
121 .and_then(|(_, forced)| forced.clone())
122 .map(|system_prompt| ContextPatch {
123 system_prompt: Some(system_prompt),
124 })
125 }
126
127 fn before_request(
133 &self,
134 model: &Model,
135 ctx: &Context,
136 opts: &SimpleStreamOptions,
137 ) -> Option<SimpleStreamOptionsPatch> {
138 if let Some((timestamp, prompt, image_count)) = latest_user_prompt(&ctx.messages) {
146 let mut last = self.last_before_agent_start.lock().unwrap();
147 if *last != Some(timestamp) {
148 *last = Some(timestamp);
149 drop(last);
150 let event = before_agent_start_event(&prompt, image_count, ctx);
151 dispatch_data_event(
157 &self.snapshot,
158 EventTag::BeforeAgentStart,
159 &event.to_string(),
160 );
161 }
162 }
163
164 let request = serde_json::json!({
165 "model": model.id,
166 "provider": model.provider,
167 "baseUrl": model.base_url,
168 "reasoning": model.reasoning,
169 "apiKey": opts.api_key,
170 "timeoutMs": opts.timeout.map(|d| d.as_millis() as u64),
171 "headers": opts.headers,
172 "metadata": opts.metadata,
173 "maxTokens": opts.max_tokens,
174 "temperature": opts.temperature,
175 "messages": ctx.messages,
180 });
181 dispatch_data_event(
182 &self.snapshot,
183 EventTag::BeforeProviderRequest,
184 &request.to_string(),
185 );
186 let headers = serde_json::json!({ "headers": opts.headers });
187 dispatch_data_event(
188 &self.snapshot,
189 EventTag::BeforeProviderHeaders,
190 &headers.to_string(),
191 );
192 None
193 }
194
195 fn after_response(&self, _model: &Model, message: &AssistantMessage) {
198 let json = serde_json::to_value(message).unwrap_or(serde_json::json!({}));
199 dispatch_data_event(
200 &self.snapshot,
201 EventTag::AfterProviderResponse,
202 &json.to_string(),
203 );
204 }
205}
206
207fn before_agent_start_event(prompt: &str, image_count: usize, ctx: &Context) -> serde_json::Value {
222 serde_json::json!({
223 "prompt": prompt,
224 "imageCount": image_count,
225 "systemPrompt": ctx.system_prompt,
226 "sessionId": std::env::var(rpi_plugin_sdk::SESSION_ID_ENV).ok(),
232 "host": host_identity(),
238 })
239}
240
241fn host_identity() -> serde_json::Value {
249 serde_json::json!({
250 "name": rpi_plugin_sdk::HOST_NAME,
251 "version": env!("CARGO_PKG_VERSION"),
252 })
253}
254
255fn latest_user_prompt(messages: &[Message]) -> Option<(i64, String, usize)> {
258 messages.iter().rev().find_map(|message| match message {
259 Message::User(user) => Some((
260 user.timestamp,
261 user_content_text(&user.content),
262 user_content_image_count(&user.content),
263 )),
264 _ => None,
265 })
266}
267
268fn user_content_text(content: &UserContent) -> String {
269 match content {
270 UserContent::Text(text) => text.clone(),
271 UserContent::Blocks(blocks) => blocks
272 .iter()
273 .filter_map(|block| match block {
274 Content::Text(text) => Some(text.text.as_str()),
275 _ => None,
276 })
277 .collect::<Vec<_>>()
278 .join("\n"),
279 }
280}
281
282fn user_content_image_count(content: &UserContent) -> usize {
283 match content {
284 UserContent::Text(_) => 0,
285 UserContent::Blocks(blocks) => blocks
286 .iter()
287 .filter(|block| matches!(block, Content::Image(_)))
288 .count(),
289 }
290}
291
292#[cfg(test)]
293mod tests {
294 use super::*;
295 use rpi_plugin_sdk::{EventTag, StablePluginEvent};
296 use std::sync::atomic::{AtomicUsize, Ordering};
297 use std::sync::Mutex;
298
299 static PROVIDER_HITS: AtomicUsize = AtomicUsize::new(0);
300 static PROVIDER_LOCK: Mutex<()> = Mutex::new(());
301 static CAPTURED: Mutex<Vec<(EventTag, String)>> = Mutex::new(Vec::new());
302
303 extern "C" fn capture_handler(ev: StablePluginEvent, _ud: *mut std::ffi::c_void) -> i32 {
304 let payload = unsafe { ev.payload.data.data.to_string_lossy() };
305 CAPTURED.lock().unwrap().push((ev.tag, payload));
306 0
307 }
308
309 extern "C" fn counting_provider_handler(
310 ev: StablePluginEvent,
311 _ud: *mut std::ffi::c_void,
312 ) -> i32 {
313 let s = unsafe { ev.payload.data.data.to_string_lossy() };
319 if let Ok(v) = serde_json::from_str::<serde_json::Value>(&s) {
320 if v.get("model").is_some() {
324 assert!(v.get("model").is_some(), "request event must carry model");
325 }
326 PROVIDER_HITS.fetch_add(1, Ordering::SeqCst);
327 }
328 0
329 }
330
331 #[test]
335 fn before_request_emits_before_agent_start_once_and_forwards_messages() {
336 let _guard = PROVIDER_LOCK.lock().unwrap();
337 CAPTURED.lock().unwrap().clear();
338
339 let mut registry = crate::registry::ExtensionRegistry::new();
340 registry.register_event_handler(
341 "test-plugin".to_string(),
342 EventTag::BeforeAgentStart,
343 capture_handler,
344 std::ptr::null_mut(),
345 );
346 registry.register_event_handler(
347 "test-plugin".to_string(),
348 EventTag::BeforeProviderRequest,
349 capture_handler,
350 std::ptr::null_mut(),
351 );
352 let snapshot = Arc::new(registry.snapshot());
353 let hooks = ExtensionProviderHooks {
354 snapshot: Arc::clone(&snapshot),
355 _keepalive: Arc::new(crate::PluginKeepalive::new(Vec::new(), None)),
356 last_before_agent_start: Mutex::new(None),
357 last_forced_prompt: Mutex::new(None),
358 };
359 let model = rpi_ai::Model::new(
360 "m",
361 "m",
362 rpi_ai::Api::AnthropicMessages,
363 "anthropic",
364 "https://api.anthropic.com",
365 );
366 let ctx = rpi_ai::Context::new(vec![rpi_ai::types::Message::User(
367 rpi_ai::types::UserMessage::new(
368 rpi_ai::types::UserContent::Text("hello".to_string()),
369 42,
370 ),
371 )]);
372 let opts = rpi_ai::SimpleStreamOptions::default();
373
374 hooks.before_request(&model, &ctx, &opts);
377 hooks.before_request(&model, &ctx, &opts);
378
379 let captured = CAPTURED.lock().unwrap().clone();
380 let agent_starts: Vec<_> = captured
381 .iter()
382 .filter(|(tag, _)| matches!(tag, EventTag::BeforeAgentStart))
383 .collect();
384 assert_eq!(
385 agent_starts.len(),
386 1,
387 "before_agent_start must fire once per prompt: {captured:?}"
388 );
389 let start_payload: serde_json::Value =
390 serde_json::from_str(&agent_starts[0].1).expect("before_agent_start payload is JSON");
391 assert_eq!(start_payload["prompt"], "hello");
392 assert_eq!(start_payload["imageCount"], 0);
393 assert_eq!(
397 start_payload.get("sessionId").map(|v| v.is_null()),
398 Some(std::env::var(rpi_plugin_sdk::SESSION_ID_ENV).is_err()),
399 "sessionId must mirror the host's session id env var: {}",
400 agent_starts[0].1
401 );
402 assert_eq!(start_payload["host"]["name"], rpi_plugin_sdk::HOST_NAME);
405 assert_eq!(
406 start_payload["host"]["version"],
407 env!("CARGO_PKG_VERSION"),
408 "the host reports the version of the host, not of the plugin SDK"
409 );
410
411 let requests: Vec<_> = captured
412 .iter()
413 .filter(|(tag, _)| matches!(tag, EventTag::BeforeProviderRequest))
414 .collect();
415 assert_eq!(requests.len(), 2, "every provider call is observed");
416 assert!(
417 requests[0].1.contains("\"messages\""),
418 "request payload must forward messages: {}",
419 requests[0].1
420 );
421 }
422
423 #[test]
427 fn provider_hooks_dispatch_to_subscribers() {
428 let _guard = PROVIDER_LOCK.lock().unwrap();
429 PROVIDER_HITS.store(0, Ordering::SeqCst);
430
431 let mut registry = crate::registry::ExtensionRegistry::new();
432 let handler: rpi_plugin_sdk::EventHandlerFn = counting_provider_handler;
433 registry.register_event_handler(
434 "test-plugin".to_string(),
435 EventTag::BeforeProviderRequest,
436 handler,
437 std::ptr::null_mut(),
438 );
439 registry.register_event_handler(
440 "test-plugin".to_string(),
441 EventTag::BeforeProviderHeaders,
442 handler,
443 std::ptr::null_mut(),
444 );
445 let snapshot = Arc::new(registry.snapshot());
446
447 assert!(dispatch_data_event(
449 &snapshot,
450 EventTag::BeforeProviderRequest,
451 r#"{"model":"m"}"#,
452 ));
453 assert_eq!(PROVIDER_HITS.load(Ordering::SeqCst), 1);
454
455 let hooks = ExtensionProviderHooks {
461 snapshot: Arc::clone(&snapshot),
462 _keepalive: Arc::new(crate::PluginKeepalive::new(Vec::new(), None)),
463 last_before_agent_start: Mutex::new(None),
464 last_forced_prompt: Mutex::new(None),
465 };
466 let model = rpi_ai::Model::new(
467 "m",
468 "m",
469 rpi_ai::Api::AnthropicMessages,
470 "anthropic",
471 "https://api.anthropic.com",
472 );
473 let ctx = rpi_ai::Context::default();
474 let opts = rpi_ai::SimpleStreamOptions::default();
475 let patch = hooks.before_request(&model, &ctx, &opts);
476 assert!(patch.is_none(), "observer-only v1: no patch returned");
477 assert_eq!(PROVIDER_HITS.load(Ordering::SeqCst), 3);
478 }
479
480 #[test]
483 fn provider_hooks_no_subscribers_is_noop() {
484 let registry = crate::registry::ExtensionRegistry::new();
485 let snapshot = Arc::new(registry.snapshot());
486 assert!(!dispatch_data_event(
487 &snapshot,
488 EventTag::AfterProviderResponse,
489 "{}",
490 ));
491 }
492
493 extern "C" fn append_marker(
503 event_json: rpi_plugin_sdk::StbStringRef,
504 out: *mut rpi_plugin_sdk::StbString,
505 user_data: *mut std::ffi::c_void,
506 ) -> i32 {
507 let envelope = unsafe { event_json.as_str() };
510 let marker = unsafe { &*(user_data as *const &'static str) };
511 let current = serde_json::from_str::<serde_json::Value>(envelope)
512 .ok()
513 .and_then(|v| v.get("systemPrompt")?.as_str().map(str::to_string))
514 .unwrap_or_default();
515 let json = serde_json::json!({ "systemPrompt": format!("{current}{marker}") });
516 unsafe {
517 *out = rpi_plugin_sdk::StbString::from_string(json.to_string());
518 }
519 0
520 }
521
522 extern "C" fn declines(
524 _event_json: rpi_plugin_sdk::StbStringRef,
525 _out: *mut rpi_plugin_sdk::StbString,
526 _user_data: *mut std::ffi::c_void,
527 ) -> i32 {
528 0
529 }
530
531 extern "C" fn fails(
533 _event_json: rpi_plugin_sdk::StbStringRef,
534 _out: *mut rpi_plugin_sdk::StbString,
535 _user_data: *mut std::ffi::c_void,
536 ) -> i32 {
537 7
538 }
539
540 fn hooks_with(
542 handlers: Vec<crate::registry::BeforeAgentStartHandler>,
543 ) -> ExtensionProviderHooks {
544 let mut registry = crate::registry::ExtensionRegistry::new();
545 for h in handlers {
546 registry.register_before_agent_start(h.handler, h.plugin_free_string, h.user_data);
547 }
548 ExtensionProviderHooks {
549 snapshot: Arc::new(registry.snapshot()),
550 _keepalive: Arc::new(crate::PluginKeepalive::new(Vec::new(), None)),
551 last_before_agent_start: Mutex::new(None),
552 last_forced_prompt: Mutex::new(None),
553 }
554 }
555
556 #[test]
560 fn prompt_transformers_chain_in_registration_order() {
561 let a: &'static str = " A";
562 let b: &'static str = " B";
563 let leak = |s: &'static str| Box::leak(Box::new(s)) as *mut &'static str as *mut _;
564 let hooks = hooks_with(vec![
565 crate::registry::BeforeAgentStartHandler {
566 handler: append_marker,
567 plugin_free_string: noop_free,
568 user_data: leak(a),
569 },
570 crate::registry::BeforeAgentStartHandler {
571 handler: append_marker,
572 plugin_free_string: noop_free,
573 user_data: leak(b),
574 },
575 ]);
576
577 let model = rpi_ai::Model::new(
578 "m",
579 "m",
580 rpi_ai::Api::AnthropicMessages,
581 "anthropic",
582 "https://api.anthropic.com",
583 );
584 let mut ctx = rpi_ai::Context::new(vec![rpi_ai::types::Message::User(
585 rpi_ai::types::UserMessage::new(
586 rpi_ai::types::UserContent::Text("hello".to_string()),
587 1,
588 ),
589 )]);
590 ctx.system_prompt = Some("base".to_string());
591
592 let patch = hooks
593 .transform_context(&model, &ctx)
594 .expect("both handlers changed the prompt");
595 assert_eq!(patch.system_prompt.as_deref(), Some("base A B"));
598 }
599
600 #[test]
604 fn a_failing_transformer_is_skipped_and_the_chain_continues() {
605 let after: &'static str = " AFTER";
606 let leaked = Box::leak(Box::new(after)) as *mut &'static str as *mut _;
607 let hooks = hooks_with(vec![
608 crate::registry::BeforeAgentStartHandler {
609 handler: fails,
610 plugin_free_string: noop_free,
611 user_data: std::ptr::null_mut(),
612 },
613 crate::registry::BeforeAgentStartHandler {
614 handler: append_marker,
615 plugin_free_string: noop_free,
616 user_data: leaked,
617 },
618 ]);
619
620 let model = rpi_ai::Model::new(
621 "m",
622 "m",
623 rpi_ai::Api::AnthropicMessages,
624 "anthropic",
625 "https://api.anthropic.com",
626 );
627 let mut ctx = rpi_ai::Context::new(vec![rpi_ai::types::Message::User(
628 rpi_ai::types::UserMessage::new(
629 rpi_ai::types::UserContent::Text("hello".to_string()),
630 1,
631 ),
632 )]);
633 ctx.system_prompt = Some("base".to_string());
634
635 let patch = hooks.transform_context(&model, &ctx).expect("changed");
636 assert_eq!(
637 patch.system_prompt.as_deref(),
638 Some("base AFTER"),
639 "the failing handler must contribute nothing and not stop the next one"
640 );
641 }
642
643 #[test]
647 fn declining_transformers_leave_the_prompt_untouched() {
648 let hooks = hooks_with(vec![crate::registry::BeforeAgentStartHandler {
649 handler: declines,
650 plugin_free_string: noop_free,
651 user_data: std::ptr::null_mut(),
652 }]);
653 let model = rpi_ai::Model::new(
654 "m",
655 "m",
656 rpi_ai::Api::AnthropicMessages,
657 "anthropic",
658 "https://api.anthropic.com",
659 );
660 let ctx = rpi_ai::Context::new(vec![rpi_ai::types::Message::User(
661 rpi_ai::types::UserMessage::new(
662 rpi_ai::types::UserContent::Text("hello".to_string()),
663 1,
664 ),
665 )]);
666 assert!(
667 hooks.transform_context(&model, &ctx).is_none(),
668 "no handler changed the prompt ⇒ no patch ⇒ harness leaves it alone"
669 );
670 }
671
672 #[test]
675 fn no_transformers_means_no_patch() {
676 let hooks = hooks_with(vec![]);
677 let model = rpi_ai::Model::new(
678 "m",
679 "m",
680 rpi_ai::Api::AnthropicMessages,
681 "anthropic",
682 "https://api.anthropic.com",
683 );
684 let ctx = rpi_ai::Context::default();
685 assert!(hooks.transform_context(&model, &ctx).is_none());
686 }
687
688 extern "C" fn noop_free(_s: rpi_plugin_sdk::StbString) {}
692
693 #[test]
702 fn the_observer_sees_the_prompt_after_the_transform() {
703 let marker: &'static str = " MARK";
704 let leaked = Box::leak(Box::new(marker)) as *mut &'static str as *mut _;
705 let _guard = PROVIDER_LOCK.lock().unwrap();
706 CAPTURED.lock().unwrap().clear();
707
708 let mut registry = crate::registry::ExtensionRegistry::new();
709 registry.register_event_handler(
710 "test-plugin".to_string(),
711 EventTag::BeforeAgentStart,
712 capture_handler,
713 std::ptr::null_mut(),
714 );
715 registry.register_before_agent_start(append_marker, noop_free, leaked);
716 let hooks = ExtensionProviderHooks {
717 snapshot: Arc::new(registry.snapshot()),
718 _keepalive: Arc::new(crate::PluginKeepalive::new(Vec::new(), None)),
719 last_before_agent_start: Mutex::new(None),
720 last_forced_prompt: Mutex::new(None),
721 };
722
723 let model = rpi_ai::Model::new(
724 "m",
725 "m",
726 rpi_ai::Api::AnthropicMessages,
727 "anthropic",
728 "https://api.anthropic.com",
729 );
730 let mut ctx = rpi_ai::Context::new(vec![rpi_ai::types::Message::User(
731 rpi_ai::types::UserMessage::new(
732 rpi_ai::types::UserContent::Text("hello".to_string()),
733 1,
734 ),
735 )]);
736 ctx.system_prompt = Some("base".to_string());
737
738 let patch = hooks
741 .transform_context(&model, &ctx)
742 .expect("transformer changed the prompt");
743 patch.apply(&mut ctx);
744 hooks.before_request(&model, &ctx, &rpi_ai::SimpleStreamOptions::default());
745
746 let captured = CAPTURED.lock().unwrap().clone();
747 let start = captured
748 .iter()
749 .find(|(tag, _)| matches!(tag, EventTag::BeforeAgentStart))
750 .expect("BeforeAgentStart was dispatched");
751 let payload: serde_json::Value = serde_json::from_str(&start.1).expect("JSON");
752 assert_eq!(
753 payload["systemPrompt"], "base MARK",
754 "observers must be shown the prompt the provider will actually get"
755 );
756 }
757}