use std::ffi::c_void;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Condvar, Mutex};
use std::time::Duration;
use rpi_ai::event_stream::create_assistant_message_event_stream;
use rpi_ai::types::{AssistantMessage, Content, DoneReason, ErrorReason, ToolCall, ToolCallType};
use rpi_ai::{
AssistantMessageEvent, AssistantMessageEventStream, AssistantMessageEventStreamProducer,
Context, Model, Provider, SimpleStreamOptions,
};
use rpi_plugin_sdk::callbacks::OwnedString;
use rpi_plugin_sdk::{StbString, StbStringRef};
use tokio::runtime::Handle;
use crate::loader::ExtensionSession;
use crate::registry::{RegisteredProvider, RegistrySnapshot};
use crate::PluginKeepalive;
#[cfg(test)]
mod lifetime_tests;
#[cfg(test)]
mod stream_tests;
struct ProviderRequestLifetime {
_keepalive: Arc<PluginKeepalive>,
_background: Option<rpi_agent::tool_execution_scope::BackgroundToolGuard>,
}
impl ProviderRequestLifetime {
fn new(keepalive: Arc<PluginKeepalive>) -> Self {
Self {
_keepalive: keepalive,
_background: rpi_agent::tool_execution_scope::track_background_tool(),
}
}
}
pub struct PluggableProvider {
record: RegisteredProvider,
snapshot: Arc<RegistrySnapshot>,
_keepalive: Arc<PluginKeepalive>,
runtime: Handle,
models: Vec<Model>,
}
impl PluggableProvider {
fn new(
record: RegisteredProvider,
snapshot: Arc<RegistrySnapshot>,
keepalive: Arc<PluginKeepalive>,
runtime: Handle,
) -> Arc<Self> {
let models = snapshot
.provider_manifest(&record.provider_id)
.map(|manifest| manifest.models.clone())
.unwrap_or_default();
Arc::new(Self {
record,
snapshot,
_keepalive: keepalive,
runtime,
models,
})
}
pub fn from_session(session: &ExtensionSession, runtime: Handle) -> Vec<Arc<dyn Provider>> {
let Some(snapshot) = session.snapshot_arc() else {
return Vec::new();
};
let keepalive = session.keepalive();
snapshot
.providers()
.iter()
.cloned()
.map(|record| {
Self::new(
record,
Arc::clone(&snapshot),
Arc::clone(&keepalive),
runtime.clone(),
) as Arc<dyn Provider>
})
.collect()
}
}
#[async_trait::async_trait]
impl Provider for PluggableProvider {
fn id(&self) -> &str {
&self.record.provider_id
}
fn models(&self) -> &[Model] {
&self.models
}
async fn stream_simple(
&self,
model: &Model,
ctx: &Context,
opts: &SimpleStreamOptions,
) -> AssistantMessageEventStream {
let (mut prod, stream) = create_assistant_message_event_stream();
let snapshot = Arc::clone(&self.snapshot);
let record = self.record.clone();
let runtime = self.runtime.clone();
let lifetime = ProviderRequestLifetime::new(Arc::clone(&self._keepalive));
let model = model.clone();
let ctx = Arc::new(ctx.clone());
let opts = opts.clone();
tokio::spawn(async move {
if let Some(stream_fn) = record.stream_fn {
let message = drive_streaming_plugin_provider(
&snapshot, &record, stream_fn, runtime, lifetime, &model, &ctx, &opts,
&mut prod,
)
.await;
push_terminal(&mut prod, message);
} else {
let message = drive_plugin_provider(
&snapshot, &record, runtime, lifetime, &model, &ctx, &opts,
)
.await;
push_terminal(&mut prod, message);
}
});
stream
}
}
async fn drive_plugin_provider(
snapshot: &Arc<RegistrySnapshot>,
record: &RegisteredProvider,
runtime: Handle,
lifetime: ProviderRequestLifetime,
model: &Model,
ctx: &Context,
opts: &SimpleStreamOptions,
) -> AssistantMessage {
if !snapshot.is_active() {
return AssistantMessage::terminal(
model.api.clone(),
record.provider_id.clone(),
model.id.clone(),
rpi_ai::types::StopReason::Error,
"extensions provider registry is stale (session swapped/reloaded)",
0,
);
}
let request = serde_json::json!({
"model": serde_json::to_value(model).unwrap_or(serde_json::Value::Null),
"context": serde_json::to_value(ctx).unwrap_or(serde_json::Value::Null),
"options": options_json(opts),
});
let request_json = request.to_string();
let record = record.clone();
let provider_id = record.provider_id.clone();
let api = model.api.clone();
let model_id = model.id.clone();
let request_model_id = model_id.clone();
let join = runtime.spawn_blocking(move || {
let _lifetime = lifetime;
run_provider_request(&record, &request_json, &request_model_id)
});
let outcome = match join.await {
Ok(inner) => inner,
Err(join_err) => {
return AssistantMessage::terminal(
api,
provider_id,
model_id,
rpi_ai::types::StopReason::Error,
format!("plugin provider task failed: {join_err}"),
0,
);
}
};
match outcome {
ProviderOutcome::Ok(message) => message,
ProviderOutcome::Err(msg) => AssistantMessage::terminal(
api,
provider_id,
model_id,
rpi_ai::types::StopReason::Error,
msg,
0,
),
}
}
enum ProviderOutcome {
Ok(AssistantMessage),
Err(String),
}
fn run_provider_request(
record: &RegisteredProvider,
request_json: &str,
model_id: &str,
) -> ProviderOutcome {
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
run_provider_request_inner(record, request_json, model_id)
}));
match outcome {
Ok(inner) => inner,
Err(_) => {
tracing::error!(
"plugin provider {} panicked — refusing to unwind across FFI",
record.provider_id
);
ProviderOutcome::Err(format!(
"plugin provider {} panicked during request",
record.provider_id
))
}
}
}
fn run_provider_request_inner(
record: &RegisteredProvider,
request_json: &str,
model_id: &str,
) -> ProviderOutcome {
let req_ref = StbStringRef::from_str(request_json);
let mut out = StbString::empty();
let rc = (record.request_fn)(req_ref, &mut out as *mut StbString, record.user_data);
let out = unsafe {
rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(record.plugin_free_string))
};
if rc != 0 {
return ProviderOutcome::Err(format!(
"plugin provider {} returned error code {rc}",
record.provider_id
));
}
let response_text = out.to_string_lossy();
match serde_json::from_str::<AssistantMessage>(&response_text) {
Ok(message) => {
let mut message = message;
if message.provider.is_empty() {
message.provider = record.provider_id.clone();
}
if message.model.is_empty() {
message.model = model_id.to_string();
}
ProviderOutcome::Ok(message)
}
Err(err) => ProviderOutcome::Err(format!(
"plugin provider {} returned unparseable response: {err}",
record.provider_id
)),
}
}
fn push_terminal(prod: &mut AssistantMessageEventStreamProducer, message: AssistantMessage) {
use rpi_ai::types::StopReason;
match message.stop_reason {
StopReason::Error | StopReason::Aborted => {
let reason = if message.stop_reason == StopReason::Aborted {
ErrorReason::Aborted
} else {
ErrorReason::Error
};
prod.push(AssistantMessageEvent::Error {
reason,
error: message,
});
}
other => {
let reason = match other {
StopReason::Stop => DoneReason::Stop,
StopReason::Length => DoneReason::Length,
StopReason::ToolUse => DoneReason::ToolUse,
StopReason::Deferred => DoneReason::Deferred,
_ => DoneReason::Stop,
};
prod.push(AssistantMessageEvent::Done { reason, message });
}
}
}
fn options_json(opts: &SimpleStreamOptions) -> serde_json::Value {
serde_json::json!({
"apiKey": opts.api_key,
"timeoutMs": opts.timeout.map(|d| d.as_millis() as u64),
"maxRetries": opts.max_retries,
"maxRetryDelayMs": opts.max_retry_delay.map(|d| d.as_millis() as u64),
"headers": opts.headers,
"metadata": opts.metadata,
"cacheRetention": format!("{:?}", opts.cache_retention),
"sessionId": opts.session_id,
"reasoning": opts.reasoning.map(|r| format!("{r:?}")),
"maxTokens": opts.max_tokens,
"temperature": opts.temperature,
"thinkingBudgets": opts.thinking_budgets,
})
}
#[allow(clippy::too_many_arguments)]
async fn drive_streaming_plugin_provider(
snapshot: &Arc<RegistrySnapshot>,
record: &RegisteredProvider,
stream_fn: rpi_plugin_sdk::ProviderStreamRequestFn,
runtime: Handle,
lifetime: ProviderRequestLifetime,
model: &Model,
ctx: &Context,
opts: &SimpleStreamOptions,
prod: &mut AssistantMessageEventStreamProducer,
) -> AssistantMessage {
if !snapshot.is_active() {
return AssistantMessage::terminal(
model.api.clone(),
record.provider_id.clone(),
model.id.clone(),
rpi_ai::types::StopReason::Error,
"extensions provider registry is stale (session swapped/reloaded)",
0,
);
}
let cancel = opts.signal.clone();
let request_json = request_envelope_json(model, ctx, opts);
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<StreamChunk>();
let control = Arc::new(ProviderControlState::default());
let bridge = StreamEmitBridge {
tx,
plugin_free_string: record.plugin_free_string,
control: Some(Arc::clone(&control)),
};
let owner = Arc::new(ProviderControlOwner {
state: Arc::clone(&control),
});
let record_owned = record.clone();
let snapshot_owned = Arc::clone(snapshot);
let owner_owned = Arc::clone(&owner);
let api = model.api.clone();
let provider_id = record.provider_id.clone();
let model_id = model.id.clone();
let request_model_id = model_id.clone();
let join = runtime.spawn_blocking(move || {
let _lifetime = lifetime;
run_streaming_provider_request(
&snapshot_owned,
&record_owned,
stream_fn,
&bridge,
&owner_owned,
&request_json,
&request_model_id,
)
});
let mut mapper = StreamMapper::new(
model.api.clone(),
record.provider_id.clone(),
model.id.clone(),
);
let mut cancelled_mid_stream = false;
loop {
tokio::select! {
biased;
_ = cancel.cancelled() => {
control.cancel();
cancelled_mid_stream = true;
break;
}
chunk = rx.recv() => match chunk {
Some(chunk) => {
if let Some(event) = mapper.apply(chunk) {
prod.push(event);
}
while let Some(deferred) = mapper.take_deferred() {
if let Some(event) = mapper.apply(deferred) {
prod.push(event);
}
}
}
None => break,
},
}
}
rx.close();
let mut message = if cancelled_mid_stream {
mapper.aborted_message()
} else {
tokio::select! {
biased;
_ = cancel.cancelled() => {
control.cancel();
mapper.aborted_message()
}
outcome = join => match outcome {
Ok(message) => message,
Err(join_err) => AssistantMessage::terminal(
api,
provider_id,
model_id,
rpi_ai::types::StopReason::Error,
format!("plugin provider task failed: {join_err}"),
0,
),
},
}
};
if message.provider.is_empty() {
message.provider = record.provider_id.clone();
}
if message.model.is_empty() {
message.model = model.id.clone();
}
for event in mapper.chunks_then_message(&message) {
prod.push(event);
}
message
}
type StreamingProviderOutcome = AssistantMessage;
#[derive(Default)]
struct ProviderControlState {
cancelled: AtomicBool,
wait_lock: Mutex<()>,
wake: Condvar,
}
impl ProviderControlState {
fn is_cancelled(&self) -> bool {
self.cancelled.load(Ordering::SeqCst)
}
fn cancel(&self) {
let _guard = self.wait_lock.lock().unwrap_or_else(|err| err.into_inner());
self.cancelled.store(true, Ordering::SeqCst);
self.wake.notify_all();
}
fn wait(&self, timeout_ms: u32) -> bool {
if self.is_cancelled() {
return true;
}
if timeout_ms == 0 {
return false;
}
let guard = self.wait_lock.lock().unwrap_or_else(|err| err.into_inner());
let _wait = self
.wake
.wait_timeout_while(guard, Duration::from_millis(u64::from(timeout_ms)), |_| {
!self.is_cancelled()
})
.unwrap_or_else(|err| err.into_inner());
self.is_cancelled()
}
}
struct ProviderControlOwner {
state: Arc<ProviderControlState>,
}
extern "C" fn control_is_cancelled(user_data: *mut c_void, chunk_kind: u32) -> i32 {
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
if user_data.is_null() {
tracing::error!("provider control callback missing its owner");
return 0;
}
let owner = unsafe { &*(user_data as *const ProviderControlOwner) };
if chunk_kind == rpi_plugin_sdk::ProviderChunkKind::Structural as u32 {
return 0;
}
i32::from(owner.state.is_cancelled())
}));
outcome.unwrap_or(0)
}
extern "C" fn control_wait_cancelled_ms(user_data: *mut c_void, timeout_ms: u32) -> i32 {
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
if user_data.is_null() {
tracing::error!("provider control callback missing its owner");
return 0;
}
let owner = unsafe { &*(user_data as *const ProviderControlOwner) };
i32::from(owner.state.wait(timeout_ms))
}));
outcome.unwrap_or(0)
}
struct StreamEmitBridge {
tx: tokio::sync::mpsc::UnboundedSender<StreamChunk>,
plugin_free_string: rpi_plugin_sdk::FreeStringFn,
control: Option<Arc<ProviderControlState>>,
}
type StreamChunk = serde_json::Value;
extern "C" fn stream_emit_trampoline(event_json: StbString, user_data: *mut c_void) -> i32 {
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
if user_data.is_null() {
tracing::error!("plugin provider emit callback missing its context");
return 2;
}
let bridge = unsafe { &*(user_data as *const StreamEmitBridge) };
let owned = unsafe { OwnedString::from_raw(event_json, Some(bridge.plugin_free_string)) };
let text = owned.to_string_lossy();
match serde_json::from_str::<serde_json::Value>(&text) {
Ok(chunk) if chunk.is_object() => {
let is_content = matches!(
chunk.get("type").and_then(serde_json::Value::as_str),
Some("text_delta") | Some("thinking_delta") | Some("toolcall_delta")
);
if bridge
.control
.as_ref()
.is_some_and(|state| state.is_cancelled())
{
return i32::from(is_content);
}
if bridge.tx.send(chunk).is_err() {
return 1;
}
0
}
Ok(_) => {
tracing::warn!("plugin provider emitted a non-object chunk — dropped");
1
}
Err(error) => {
tracing::warn!(%error, "plugin provider emitted unparseable JSON — dropped");
1
}
}
}));
match outcome {
Ok(rc) => rc,
Err(_) => {
tracing::error!("plugin provider emit callback panicked — chunk dropped");
2
}
}
}
fn run_streaming_provider_request(
snapshot: &Arc<RegistrySnapshot>,
record: &RegisteredProvider,
stream_fn: rpi_plugin_sdk::ProviderStreamRequestFn,
bridge: &StreamEmitBridge,
owner: &ProviderControlOwner,
request_json: &str,
model_id: &str,
) -> StreamingProviderOutcome {
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
if !snapshot.is_active() {
return Err("extensions provider registry is stale".to_string());
}
let req_ref = StbStringRef::from_str(request_json);
let mut out = StbString::empty();
let relay = bridge as *const StreamEmitBridge as *mut std::ffi::c_void;
let control = rpi_plugin_sdk::ProviderStreamControl {
is_cancelled: control_is_cancelled,
wait_cancelled_ms: control_wait_cancelled_ms,
user_data: owner as *const ProviderControlOwner as *mut std::ffi::c_void,
};
let rc = stream_fn(
req_ref,
Some(stream_emit_trampoline),
relay,
&control as *const rpi_plugin_sdk::ProviderStreamControl,
None,
std::ptr::null_mut(),
&mut out as *mut StbString,
record.user_data,
);
let out = unsafe {
rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(record.plugin_free_string))
};
if rc != 0 {
if bridge
.control
.as_ref()
.is_some_and(|state| state.is_cancelled())
{
return Err(CANCELLED_MARKER.to_string());
}
return Err(format!(
"plugin provider {} streaming request returned error code {rc}",
record.provider_id
));
}
let response_text = out.to_string_lossy();
serde_json::from_str::<AssistantMessage>(&response_text).map_err(|err| {
format!(
"plugin provider {} returned unparseable response: {err}",
record.provider_id
)
})
}));
match outcome {
Ok(Ok(mut message)) => {
if message.provider.is_empty() {
message.provider = record.provider_id.clone();
}
if message.model.is_empty() {
message.model = model_id.to_string();
}
message
}
Ok(Err(msg)) if msg == CANCELLED_MARKER => cancelled_message(record),
Ok(Err(msg)) => error_message(record, &msg),
Err(_) => {
tracing::error!(
"plugin provider {} panicked — refusing to unwind across FFI",
record.provider_id
);
error_message(
record,
&format!(
"plugin provider {} panicked during request",
record.provider_id
),
)
}
}
}
const CANCELLED_MARKER: &str = "\u{0}rpi:cancelled";
fn cancelled_message(record: &RegisteredProvider) -> AssistantMessage {
AssistantMessage::terminal(
api_from_style(&record.api_style),
record.provider_id.clone(),
String::new(),
rpi_ai::types::StopReason::Aborted,
"plugin provider request was cancelled",
0,
)
}
fn error_message(record: &RegisteredProvider, message: &str) -> AssistantMessage {
AssistantMessage::terminal(
api_from_style(&record.api_style),
record.provider_id.clone(),
String::new(),
rpi_ai::types::StopReason::Error,
message.to_string(),
0,
)
}
fn api_from_style(style: &str) -> rpi_ai::types::Api {
serde_json::from_value::<rpi_ai::types::Api>(serde_json::Value::String(style.to_string()))
.unwrap_or_else(|_| rpi_ai::types::Api::Other(style.to_string()))
}
fn request_envelope_json(model: &Model, ctx: &Context, opts: &SimpleStreamOptions) -> String {
serde_json::json!({
"model": serde_json::to_value(model).unwrap_or(serde_json::Value::Null),
"context": serde_json::to_value(ctx).unwrap_or(serde_json::Value::Null),
"options": options_json(opts),
})
.to_string()
}
struct StreamMapper {
output: AssistantMessage,
started: bool,
terminal: bool,
tool_json: std::collections::BTreeMap<usize, String>,
deferred: Option<serde_json::Value>,
final_message: Option<AssistantMessage>,
}
impl StreamMapper {
fn new(api: rpi_ai::types::Api, provider: String, model: String) -> Self {
Self {
output: AssistantMessage::empty(api, provider, model, 0),
started: false,
terminal: false,
tool_json: std::collections::BTreeMap::new(),
deferred: None,
final_message: None,
}
}
fn take_deferred(&mut self) -> Option<serde_json::Value> {
self.deferred.take()
}
fn ensure_started(&mut self) -> Option<AssistantMessageEvent> {
if self.started {
return None;
}
self.started = true;
Some(AssistantMessageEvent::Start {
partial: Arc::new(self.output.clone()),
})
}
fn partial(&self) -> Arc<AssistantMessage> {
Arc::new(self.output.clone())
}
fn aborted_message(&self) -> AssistantMessage {
let mut message = self.output.clone();
message.stop_reason = rpi_ai::types::StopReason::Aborted;
message.error_message = Some("plugin provider request was cancelled".into());
message
}
fn apply_partial(&mut self, chunk: &serde_json::Value) -> bool {
match chunk
.get("partial")
.cloned()
.map(serde_json::from_value::<AssistantMessage>)
{
Some(Ok(message)) => {
self.output = message;
true
}
Some(Err(error)) => {
tracing::warn!(%error, "ignoring malformed plugin provider partial snapshot");
false
}
None => false,
}
}
fn apply(&mut self, chunk: serde_json::Value) -> Option<AssistantMessageEvent> {
if self.terminal {
tracing::debug!("dropping a plugin provider chunk that follows a terminal event");
return None;
}
let mut chunk = chunk;
let kind = chunk.get("type").and_then(serde_json::Value::as_str)?;
if !self.started {
self.started = true;
self.deferred = Some(std::mem::replace(&mut chunk, serde_json::Value::Null));
return Some(AssistantMessageEvent::Start {
partial: self.partial(),
});
}
match kind {
"start" => None,
"usage" => {
if let Some(usage) = chunk.get("usage") {
if let Ok(parsed) =
serde_json::from_value::<rpi_ai::types::Usage>(usage.clone())
{
self.output.usage = parsed;
}
}
None
}
"text_start" => {
let index = self.content_index(&chunk);
self.open_block(index, Content::text(String::new()));
Some(AssistantMessageEvent::TextStart {
content_index: index,
partial: self.partial(),
})
}
"text_delta" => {
let index = self.content_index(&chunk);
let delta = chunk_string(&chunk, "delta");
if !self.apply_partial(&chunk) {
let block = self.ensure_text_block(index);
block.text.push_str(&delta);
}
Some(AssistantMessageEvent::TextDelta {
content_index: index,
delta,
partial: self.partial(),
})
}
"text_end" => {
let index = self.content_index(&chunk);
let block = self.ensure_text_block(index);
let content = if chunk.get("content").is_some() {
chunk_string(&chunk, "content")
} else {
block.text.clone()
};
block.text = content.clone();
Some(AssistantMessageEvent::TextEnd {
content_index: index,
content,
partial: self.partial(),
})
}
"thinking_start" => {
let index = self.content_index(&chunk);
self.open_block(index, Content::thinking(String::new()));
Some(AssistantMessageEvent::ThinkingStart {
content_index: index,
partial: self.partial(),
})
}
"thinking_delta" => {
let index = self.content_index(&chunk);
let delta = chunk_string(&chunk, "delta");
if !self.apply_partial(&chunk) {
let block = self.ensure_thinking_block(index);
block.thinking.push_str(&delta);
}
Some(AssistantMessageEvent::ThinkingDelta {
content_index: index,
delta,
partial: self.partial(),
})
}
"thinking_end" => {
let index = self.content_index(&chunk);
let block = self.ensure_thinking_block(index);
let content = if chunk.get("content").is_some() {
chunk_string(&chunk, "content")
} else {
block.thinking.clone()
};
block.thinking = content.clone();
let redacted = chunk
.get("redacted")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
block.redacted = redacted;
Some(AssistantMessageEvent::ThinkingEnd {
content_index: index,
content,
partial: self.partial(),
})
}
"toolcall_start" => {
let index = self.content_index(&chunk);
let id = chunk_string(&chunk, "id");
let name = chunk_string(&chunk, "name");
let arguments = chunk
.get("arguments")
.cloned()
.filter(serde_json::Value::is_object)
.unwrap_or_else(|| serde_json::Value::Object(serde_json::Map::new()));
self.tool_json.insert(index, String::new());
self.open_block(index, Content::tool_call(id, name, arguments));
Some(AssistantMessageEvent::ToolCallStart {
content_index: index,
partial: self.partial(),
})
}
"toolcall_delta" => {
let index = self.content_index(&chunk);
let delta = chunk_string(&chunk, "delta");
let accumulated = self.tool_json.entry(index).or_default();
accumulated.push_str(&delta);
let parsed = rpi_ai::json_parse::parse_streaming_json(Some(accumulated));
if !self.apply_partial(&chunk) {
match self.output.content.get_mut(index) {
Some(Content::ToolCall(call)) => call.arguments = parsed,
_ => {
let id = chunk_string(&chunk, "id");
let name = chunk_string(&chunk, "name");
self.ensure_tool_call_block(index, id, name);
if let Some(Content::ToolCall(call)) =
self.output.content.get_mut(index)
{
call.arguments = parsed;
}
}
}
}
Some(AssistantMessageEvent::ToolCallDelta {
content_index: index,
delta,
partial: self.partial(),
})
}
"toolcall_end" => {
let index = self.content_index(&chunk);
let id = chunk_string(&chunk, "id");
let name = chunk_string(&chunk, "name");
let arguments = chunk
.get("arguments")
.cloned()
.filter(serde_json::Value::is_object)
.or_else(|| match self.output.content.get(index) {
Some(Content::ToolCall(call)) => Some(call.arguments.clone()),
_ => None,
})
.unwrap_or_else(|| serde_json::Value::Object(serde_json::Map::new()));
self.ensure_tool_call_block(index, id.clone(), name.clone());
let call = match self.output.content.get_mut(index) {
Some(Content::ToolCall(call)) => {
if !id.is_empty() {
call.id = id.clone();
}
if !name.is_empty() {
call.name = name.clone();
}
call.arguments = arguments.clone();
call.clone()
}
_ => ToolCall {
kind: ToolCallType,
id: id.clone(),
name: name.clone(),
arguments: arguments.clone(),
thought_signature: None,
namespace: None,
},
};
self.tool_json.remove(&index);
Some(AssistantMessageEvent::ToolCallEnd {
content_index: index,
tool_call: call,
partial: self.partial(),
})
}
other => {
tracing::debug!(
chunk_type = other,
"ignoring an unknown plugin provider chunk"
);
None
}
}
}
fn content_index(&self, chunk: &serde_json::Value) -> usize {
chunk
.get("contentIndex")
.and_then(serde_json::Value::as_u64)
.map(|n| n as usize)
.unwrap_or(0)
}
fn open_block(&mut self, index: usize, content: Content) {
while self.output.content.len() <= index {
self.output.content.push(Content::text(String::new()));
}
self.output.content[index] = content;
}
fn ensure_text_block(&mut self, index: usize) -> &mut rpi_ai::types::TextContent {
self.ensure_block(index, || Content::text(String::new()));
match self.output.content.get_mut(index) {
Some(Content::Text(block)) => block,
other => unreachable!("ensure_text_block left a non-text block: {other:?}"),
}
}
fn ensure_thinking_block(&mut self, index: usize) -> &mut rpi_ai::types::ThinkingContent {
self.ensure_block(index, || Content::thinking(String::new()));
match self.output.content.get_mut(index) {
Some(Content::Thinking(block)) => block,
other => unreachable!("ensure_thinking_block left a non-thinking block: {other:?}"),
}
}
fn ensure_tool_call_block(&mut self, index: usize, id: String, name: String) {
self.ensure_block(index, || {
Content::tool_call(
id.clone(),
name.clone(),
serde_json::Value::Object(serde_json::Map::new()),
)
});
}
fn ensure_block(&mut self, index: usize, make: impl FnOnce() -> Content) {
if self.output.content.len() <= index {
tracing::warn!(
content_index = index,
"plugin provider sent a block delta without its start chunk — synthesizing the block"
);
while self.output.content.len() < index {
self.output.content.push(Content::text(String::new()));
}
self.output.content.push(make());
}
}
fn chunks_then_message(&mut self, message: &AssistantMessage) -> Vec<AssistantMessageEvent> {
let mut events = Vec::new();
if let Some(start) = self.ensure_started() {
events.push(start);
}
self.terminal = true;
self.final_message = Some(message.clone());
events
}
#[cfg(test)]
fn accumulated(&self) -> &AssistantMessage {
&self.output
}
}
fn chunk_string(chunk: &serde_json::Value, field: &str) -> String {
chunk
.get(field)
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::registry::ExtensionRegistry;
rpi_plugin_sdk::string_handler! {
fn ok_request_fn(_req, out, _ud) {
let json = r#"{"role":"assistant","content":[{"type":"text","text":"hi"}],"api":"faux","provider":"pluggy","model":"","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}"#;
i32::from(!out.write(json.to_string()))
} }
use rpi_plugin_sdk::callbacks::free_string as stub_free;
mod cancel_provider {
use super::*;
use rpi_plugin_sdk::{ProviderStreamCompletionFn, ProviderStreamControl};
use std::sync::atomic::AtomicUsize;
pub static EMITTED: AtomicUsize = AtomicUsize::new(0);
pub static OBSERVED: AtomicUsize = AtomicUsize::new(0);
pub extern "C" fn request_v2(
_req: StbStringRef,
emit: Option<rpi_plugin_sdk::ProviderStreamEmitFn>,
emit_user_data: *mut c_void,
control: *const ProviderStreamControl,
_completion: Option<ProviderStreamCompletionFn>,
_completion_user_data: *mut c_void,
out: *mut StbString,
_ud: *mut c_void,
) -> i32 {
let control = unsafe { *control };
if let Some(emit) = emit {
let start = r#"{"type":"text_start","contentIndex":0}"#;
let _ = emit(StbString::from_string(start.to_string()), emit_user_data);
for i in 0..50 {
if (control.wait_cancelled_ms)(control.user_data, 20) != 0 {
OBSERVED.fetch_add(1, Ordering::SeqCst);
return 1;
}
let chunk =
format!(r#"{{"type":"text_delta","contentIndex":0,"delta":"{i},"}}"#);
if emit(StbString::from_string(chunk), emit_user_data) != 0 {
OBSERVED.fetch_add(1, Ordering::SeqCst);
EMITTED.fetch_add(1, Ordering::SeqCst);
return 1;
}
EMITTED.fetch_add(1, Ordering::SeqCst);
}
}
let json = r#"{"role":"assistant","content":[{"type":"text","text":"completed"}],"api":"faux","provider":"cancel-probe","model":"m","usage":{"input":1,"output":1,"cacheRead":0,"cacheWrite":0,"totalTokens":2,"cost":{"input":0.0,"output":0.0,"cacheRead":0.0,"cacheWrite":0.0,"total":0.0}},"stopReason":"stop","timestamp":0}"#;
unsafe {
*out = StbString::from_string(json.to_string());
}
0
}
}
#[test]
fn request_fn_round_trip_ok_and_err() {
let mut registry = ExtensionRegistry::new();
registry.register_provider(RegisteredProvider {
provider_id: "pluggy".to_string(),
base_url: "https://example".to_string(),
api_style: "anthropic-messages".to_string(),
request_fn: ok_request_fn,
stream_fn: None,
plugin_free_string: stub_free,
user_data: std::ptr::null_mut(),
});
let snap = Arc::new(registry.snapshot());
let record = snap.providers()[0].clone();
let outcome = run_provider_request(&record, r#"{"model":"m"}"#, "m");
match outcome {
ProviderOutcome::Ok(m) => {
assert_eq!(m.provider, "pluggy");
assert_eq!(m.model, "m");
assert_eq!(m.stop_reason, rpi_ai::types::StopReason::Stop);
assert_eq!(m.content.len(), 1);
}
_ => panic!("expected Ok"),
}
extern "C" fn err_request_fn(
_req: StbStringRef,
_out: *mut StbString,
_ud: *mut std::ffi::c_void,
) -> i32 {
7
}
let err_record = RegisteredProvider {
provider_id: "pluggy".to_string(),
base_url: "https://example".to_string(),
api_style: "anthropic-messages".to_string(),
request_fn: err_request_fn,
stream_fn: None,
plugin_free_string: stub_free,
user_data: std::ptr::null_mut(),
};
let outcome = run_provider_request(&err_record, r#"{"model":"m"}"#, "m");
assert!(matches!(outcome, ProviderOutcome::Err(_)));
}
#[test]
fn stream_chunks_map_to_ordered_events() {
let mut mapper = StreamMapper::new(rpi_ai::types::Api::Faux, "pluggy".into(), "m".into());
let chunks = [
serde_json::json!({"type":"start"}),
serde_json::json!({"type":"text_start","contentIndex":0}),
serde_json::json!({"type":"text_delta","contentIndex":0,"delta":"hel"}),
serde_json::json!({"type":"text_delta","contentIndex":0,"delta":"lo"}),
serde_json::json!({"type":"text_end","contentIndex":0}),
];
let mut tags = Vec::new();
for chunk in chunks {
if let Some(event) = mapper.apply(chunk) {
tags.push(event.type_tag());
}
while let Some(deferred) = mapper.take_deferred() {
if let Some(event) = mapper.apply(deferred) {
tags.push(event.type_tag());
}
}
}
assert_eq!(
tags,
vec![
"start",
"text_start",
"text_delta",
"text_delta",
"text_end"
],
"chunks must map to an ordered, non-terminal event sequence"
);
assert_eq!(
mapper.accumulated().content[0],
rpi_ai::types::Content::text("hello"),
"deltas accumulate into the block they name"
);
}
#[test]
fn an_aborted_message_terminates_with_aborted_not_error() {
let (mut prod, mut stream) = create_assistant_message_event_stream();
let record = RegisteredProvider {
provider_id: "pluggy".to_string(),
base_url: "https://example".to_string(),
api_style: "faux".to_string(),
request_fn: ok_request_fn,
stream_fn: None,
plugin_free_string: stub_free,
user_data: std::ptr::null_mut(),
};
push_terminal(&mut prod, cancelled_message(&record));
let event = futures::executor::block_on(stream.next()).expect("a terminal event");
match event {
AssistantMessageEvent::Error { reason, error } => {
assert_eq!(reason, ErrorReason::Aborted);
assert_eq!(error.stop_reason, rpi_ai::types::StopReason::Aborted);
}
other => panic!("expected Aborted Error, got {other:?}"),
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_cancelled_v2_request_terminates_aborted_and_tells_the_plugin() {
cancel_provider::EMITTED.store(0, Ordering::SeqCst);
cancel_provider::OBSERVED.store(0, Ordering::SeqCst);
let record = RegisteredProvider {
provider_id: "cancel-probe".to_string(),
base_url: "https://example".to_string(),
api_style: "faux".to_string(),
request_fn: ok_request_fn,
stream_fn: Some(cancel_provider::request_v2),
plugin_free_string: stub_free,
user_data: std::ptr::null_mut(),
};
let mut registry = ExtensionRegistry::new();
registry.register_streaming_provider(record.clone());
let snapshot = Arc::new(registry.snapshot());
let model = Model::new(
"m",
"M",
rpi_ai::types::Api::Faux,
"cancel-probe",
"https://example",
);
let ctx = Context::new(Vec::new());
let signal = tokio_util::sync::CancellationToken::new();
let opts = SimpleStreamOptions {
signal: signal.clone(),
..SimpleStreamOptions::default()
};
let (mut prod, mut stream) = create_assistant_message_event_stream();
tokio::spawn(async move {
let stream_fn = record.stream_fn.expect("a streaming fn");
let message = drive_streaming_plugin_provider(
&snapshot,
&record,
stream_fn,
Handle::current(),
ProviderRequestLifetime::new(PluginKeepalive::empty()),
&model,
&ctx,
&opts,
&mut prod,
)
.await;
push_terminal(&mut prod, message);
});
let mut aborted = false;
let mut saw_delta = false;
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20);
while let Some(event) = stream.next().await {
match &event {
AssistantMessageEvent::TextDelta { .. } if !saw_delta => {
saw_delta = true;
signal.cancel();
}
AssistantMessageEvent::Error { reason, .. } => {
aborted = *reason == ErrorReason::Aborted;
}
_ => {}
}
assert!(
std::time::Instant::now() < deadline,
"a cancelled request must terminate promptly"
);
}
assert!(saw_delta, "the provider must have started producing");
assert!(aborted, "the terminal must be Aborted");
tokio::time::timeout(Duration::from_secs(2), async {
while cancel_provider::OBSERVED.load(Ordering::SeqCst) == 0 {
tokio::time::sleep(Duration::from_millis(1)).await;
}
})
.await
.expect("the plugin observes cancellation eventually");
assert_eq!(
cancel_provider::OBSERVED.load(Ordering::SeqCst),
1,
"the plugin must have observed the cancellation through its control surface"
);
assert!(
cancel_provider::EMITTED.load(Ordering::SeqCst) < 50,
"the plugin must stop before its 50-delta budget"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_cancelled_request_stops_forwarding_content_even_if_the_plugin_pushes() {
let owner = Arc::new(ProviderControlOwner {
state: Arc::new(ProviderControlState::default()),
});
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<StreamChunk>();
let bridge = StreamEmitBridge {
tx,
plugin_free_string: stub_free,
control: Some(Arc::clone(&owner.state)),
};
let relay = &bridge as *const StreamEmitBridge as *mut c_void;
let delta = r#"{"type":"text_delta","contentIndex":0,"delta":"a"}"#;
assert_eq!(
stream_emit_trampoline(StbString::from_string(delta.to_string()), relay),
0
);
assert!(rx.try_recv().is_ok(), "an un-cancelled chunk is delivered");
owner.state.cancel();
assert_eq!(
stream_emit_trampoline(StbString::from_string(delta.to_string()), relay),
1,
"a content chunk is refused once the run is cancelled"
);
let end = r#"{"type":"text_end","contentIndex":0,"content":"a"}"#;
assert_eq!(
stream_emit_trampoline(StbString::from_string(end.to_string()), relay),
0,
"a structural chunk stays admissible while winding down"
);
assert!(
rx.try_recv().is_err(),
"cleanup must not produce post-terminal events"
);
}
#[test]
fn the_control_surface_reports_cancellation_for_content_only() {
let owner = Arc::new(ProviderControlOwner {
state: Arc::new(ProviderControlState::default()),
});
let user_data = Arc::as_ptr(&owner) as *mut c_void;
let content = rpi_plugin_sdk::ProviderChunkKind::Content as u32;
let structural = rpi_plugin_sdk::ProviderChunkKind::Structural as u32;
assert_eq!(control_is_cancelled(user_data, content), 0);
assert_eq!(control_wait_cancelled_ms(user_data, 0), 0);
owner.state.cancel();
assert_eq!(
control_is_cancelled(user_data, content),
1,
"content chunks must report cancelled"
);
assert_eq!(
control_is_cancelled(user_data, structural),
0,
"structural chunks stay admissible while a cancelled request winds down"
);
let started = std::time::Instant::now();
assert_eq!(control_wait_cancelled_ms(user_data, 60_000), 1);
assert!(
started.elapsed() < std::time::Duration::from_secs(5),
"an already-cancelled wait must not sleep out its timeout"
);
assert_eq!(control_is_cancelled(std::ptr::null_mut(), content), 0);
assert_eq!(control_wait_cancelled_ms(std::ptr::null_mut(), 0), 0);
}
#[test]
fn a_delta_before_start_still_opens_the_stream() {
let mut mapper = StreamMapper::new(rpi_ai::types::Api::Faux, "pluggy".into(), "m".into());
let first = mapper
.apply(serde_json::json!({"type":"text_delta","contentIndex":0,"delta":"x"}))
.expect("a start event");
assert_eq!(first.type_tag(), "start");
let deferred = mapper.take_deferred().expect("the parked delta");
let second = mapper.apply(deferred).expect("the delta event");
assert_eq!(second.type_tag(), "text_delta");
}
#[test]
fn tool_call_deltas_accumulate_json() {
let mut mapper = StreamMapper::new(rpi_ai::types::Api::Faux, "pluggy".into(), "m".into());
let mut tags = Vec::new();
for chunk in [
serde_json::json!({"type":"toolcall_start","contentIndex":0,"id":"c1","name":"echo"}),
serde_json::json!({"type":"toolcall_delta","contentIndex":0,"delta":"{\"text\":"}),
serde_json::json!({"type":"toolcall_delta","contentIndex":0,"delta":"\"hi\"}"}),
] {
if let Some(event) = mapper.apply(chunk) {
tags.push(event.type_tag());
}
while let Some(deferred) = mapper.take_deferred() {
if let Some(event) = mapper.apply(deferred) {
tags.push(event.type_tag());
}
}
}
assert_eq!(
tags,
vec![
"start",
"toolcall_start",
"toolcall_delta",
"toolcall_delta"
]
);
let event = mapper
.apply(
serde_json::json!({"type":"toolcall_end","contentIndex":0,"id":"c1","name":"echo"}),
)
.expect("tool call end");
let AssistantMessageEvent::ToolCallEnd { tool_call, .. } = event else {
panic!("expected ToolCallEnd");
};
assert_eq!(tool_call.name, "echo");
assert_eq!(tool_call.arguments["text"], "hi");
}
}