use futures_util::future::BoxFuture;
use indexmap::IndexMap;
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
use yapi_types::event::AssistantMessageEvent;
use yapi_types::message::{
AssistantMessage, ContentBlock, Message, StopReason, ThinkingLevel, Usage,
};
use yapi_types::model::Model;
pub use yapi_types::settings::Transport;
use crate::credentials::{ProviderEnv, provider_env_value};
pub trait Provider: Send + Sync {
fn api(&self) -> &str;
fn stream(&self, request: Request) -> EventStream;
}
#[derive(Clone, Debug)]
pub struct Request {
pub model: Model,
pub messages: Vec<Message>,
pub options: StreamOptions,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CacheRetention {
None,
Short,
Long,
}
impl CacheRetention {
pub fn as_str(self) -> &'static str {
match self {
CacheRetention::None => "none",
CacheRetention::Short => "short",
CacheRetention::Long => "long",
}
}
pub fn parse(name: &str) -> Option<CacheRetention> {
[
CacheRetention::None,
CacheRetention::Short,
CacheRetention::Long,
]
.into_iter()
.find(|retention| retention.as_str() == name)
}
}
#[derive(Clone, Debug, Default)]
pub struct StreamOptions {
pub api_key: Option<String>,
pub headers: IndexMap<String, Option<String>>,
pub reasoning: Option<ThinkingLevel>,
pub max_tokens: Option<u64>,
pub temperature: Option<f64>,
pub session_id: Option<String>,
pub transport: Option<Transport>,
pub websocket_connect_timeout_ms: Option<u64>,
pub timeout_ms: Option<f64>,
pub cache_retention: Option<CacheRetention>,
pub thinking_budgets: ThinkingBudgets,
pub max_retries: u32,
pub max_retry_delay_ms: Option<u64>,
pub cancel: CancellationToken,
pub env: Option<ProviderEnv>,
pub hooks: RequestHooks,
}
pub type RequestHeaders = IndexMap<String, Option<String>>;
pub type Hook<A, R> = std::sync::Arc<dyn Fn(A) -> BoxFuture<'static, R> + Send + Sync>;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ProviderResponse {
pub status: u16,
pub headers: IndexMap<String, String>,
}
#[derive(Clone, Default)]
pub struct RequestHooks {
pub payload: Option<Hook<serde_json::Value, serde_json::Value>>,
pub headers: Option<Hook<RequestHeaders, RequestHeaders>>,
pub response: Option<Hook<ProviderResponse, ()>>,
pub stream_event: Option<Hook<serde_json::Value, ()>>,
}
impl RequestHooks {
pub async fn payload(&self, payload: serde_json::Value) -> serde_json::Value {
match &self.payload {
Some(hook) => hook(payload).await,
None => payload,
}
}
pub async fn prepare(request: &mut Request) {
if let Some(hook) = request.options.hooks.headers.clone() {
request.options.headers = hook(std::mem::take(&mut request.options.headers)).await;
}
}
pub async fn response(&self, response: &reqwest::Response) {
if let Some(hook) = &self.response {
let headers = response
.headers()
.iter()
.filter_map(|(name, value)| {
Some((name.as_str().to_owned(), value.to_str().ok()?.to_owned()))
})
.collect();
hook(ProviderResponse {
status: response.status().as_u16(),
headers,
})
.await;
}
}
pub async fn stream_event(&self, data: &serde_json::Value) {
if let Some(hook) = &self.stream_event {
hook(data.clone()).await;
}
}
}
impl std::fmt::Debug for RequestHooks {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RequestHooks")
.field("payload", &self.payload.is_some())
.field("headers", &self.headers.is_some())
.field("response", &self.response.is_some())
.field("stream_event", &self.stream_event.is_some())
.finish()
}
}
impl StreamOptions {
pub fn resolved_cache_retention(&self) -> CacheRetention {
self.requested_cache_retention()
.unwrap_or(CacheRetention::Short)
}
pub fn requested_cache_retention(&self) -> Option<CacheRetention> {
self.cache_retention.or_else(|| {
(provider_env_value("PI_CACHE_RETENTION", self.env.as_ref()).as_deref() == Some("long"))
.then_some(CacheRetention::Long)
})
}
pub fn has_header(&self, name: &str) -> bool {
self.headers.iter().any(|(key, value)| {
key.eq_ignore_ascii_case(name)
&& value
.as_deref()
.is_some_and(|value| !value.trim().is_empty())
})
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct ThinkingBudgets {
pub minimal: Option<u64>,
pub low: Option<u64>,
pub medium: Option<u64>,
pub high: Option<u64>,
}
#[derive(Clone, Debug, PartialEq)]
pub enum StreamEvent {
Start(AssistantMessage),
Update {
event: AssistantMessageEvent,
usage: Usage,
},
Done(AssistantMessage),
Error(AssistantMessage),
}
#[derive(Debug)]
pub struct EventStream {
receiver: mpsc::UnboundedReceiver<StreamEvent>,
}
impl EventStream {
pub fn channel() -> (EventSender, EventStream) {
let (sender, receiver) = mpsc::unbounded_channel();
(EventSender { sender }, EventStream { receiver })
}
pub async fn next(&mut self) -> Option<StreamEvent> {
self.receiver.recv().await
}
pub async fn result(mut self) -> Option<AssistantMessage> {
while let Some(event) = self.next().await {
if let StreamEvent::Done(message) | StreamEvent::Error(message) = event {
return Some(message);
}
}
None
}
}
#[derive(Clone, Debug)]
pub struct EventSender {
sender: mpsc::UnboundedSender<StreamEvent>,
}
impl EventSender {
pub fn send(&self, event: StreamEvent) {
let _ = self.sender.send(event);
}
pub fn update(&self, message: &AssistantMessage, event: AssistantMessageEvent) {
self.send(StreamEvent::Update {
event,
usage: message.usage.clone(),
});
}
pub(crate) fn start(&self, output: &mut AssistantMessage, block: ContentBlock) -> usize {
let content_index = output.content.len();
let event = match &block {
ContentBlock::Text(_) => Some(AssistantMessageEvent::TextStart { content_index }),
ContentBlock::Thinking(_) => {
Some(AssistantMessageEvent::ThinkingStart { content_index })
}
ContentBlock::ToolCall(call) => Some(AssistantMessageEvent::ToolcallStart {
content_index,
id: call.id.clone(),
tool_name: call.name.clone(),
}),
ContentBlock::Image(_) => None,
};
output.content.push(block);
if let Some(event) = event {
self.update(output, event);
}
content_index
}
pub(crate) fn delta(&self, output: &mut AssistantMessage, content_index: usize, delta: &str) {
let event = match output.content.get_mut(content_index) {
Some(ContentBlock::Text(text)) => {
text.text.push_str(delta);
AssistantMessageEvent::TextDelta {
content_index,
delta: delta.to_owned(),
}
}
Some(ContentBlock::Thinking(thinking)) => {
thinking.thinking.push_str(delta);
AssistantMessageEvent::ThinkingDelta {
content_index,
delta: delta.to_owned(),
}
}
Some(ContentBlock::ToolCall(_)) => AssistantMessageEvent::ToolcallDelta {
content_index,
delta: delta.to_owned(),
},
Some(ContentBlock::Image(_)) | None => return,
};
self.update(output, event);
}
pub(crate) fn end(&self, output: &AssistantMessage, content_index: usize) {
let event = match output.content.get(content_index) {
Some(ContentBlock::Text(text)) => AssistantMessageEvent::TextEnd {
content_index,
content: text.text.clone(),
},
Some(ContentBlock::Thinking(thinking)) => AssistantMessageEvent::ThinkingEnd {
content_index,
content: thinking.thinking.clone(),
},
Some(ContentBlock::ToolCall(call)) => AssistantMessageEvent::ToolcallEnd {
content_index,
tool_call: call.clone(),
},
Some(ContentBlock::Image(_)) | None => return,
};
self.update(output, event);
}
}
pub fn new_output(model: &Model, now_ms: u64) -> AssistantMessage {
AssistantMessage {
content: Vec::new(),
api: model.api.clone(),
provider: model.provider.clone(),
model: model.id.clone(),
provider_thinking_level: None,
usage: Usage {
total_tokens: Some(0),
..Usage::default()
},
stop_reason: StopReason::Pending,
timestamp: now_ms,
response_id: None,
response_model: None,
raw_stop_reason: None,
deferred: None,
error_message: None,
diagnostics: None,
end_turn: None,
thinking_level: None,
}
}
pub fn send_error(
sender: &EventSender,
mut output: AssistantMessage,
cancel: &CancellationToken,
message: String,
) {
output.stop_reason = if cancel.is_cancelled() {
StopReason::Aborted
} else {
StopReason::Error
};
output.error_message = Some(message);
sender.send(StreamEvent::Error(output));
}
pub(crate) fn check_complete(
output: &AssistantMessage,
cancel: &CancellationToken,
pending: &str,
) -> Result<(), String> {
if cancel.is_cancelled() {
return Err(crate::http::ABORTED_DURING_STREAM.to_owned());
}
match output.stop_reason {
StopReason::Pending => Err(pending.to_owned()),
StopReason::Aborted | StopReason::Error => Err(output
.error_message
.clone()
.filter(|message| !message.is_empty())
.unwrap_or_else(|| "An unknown error occurred".to_owned())),
_ => Ok(()),
}
}
pub use yapi_types::time::now_ms;