use crate::chat::request::ChatRequest;
use crate::chat::response::ChatResponse;
use crate::chat::stream::{Event, EventStream};
use crate::model::{ModelCapabilities, ModelId};
use crate::Result;
use async_trait::async_trait;
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum Access {
Ready,
Denied {
reason: String,
},
Unknown {
why: String,
},
}
impl Access {
pub fn is_ready(&self) -> bool {
matches!(self, Access::Ready)
}
pub fn is_denied(&self) -> bool {
matches!(self, Access::Denied { .. })
}
pub fn is_unknown(&self) -> bool {
matches!(self, Access::Unknown { .. })
}
pub fn denied(reason: impl Into<String>) -> Access {
Access::Denied {
reason: reason.into(),
}
}
pub fn unknown(why: impl Into<String>) -> Access {
Access::Unknown { why: why.into() }
}
pub fn as_str(&self) -> &'static str {
match self {
Access::Ready => "ready",
Access::Denied { .. } => "denied",
Access::Unknown { .. } => "unknown",
}
}
pub fn detail(&self) -> Option<&str> {
match self {
Access::Ready => None,
Access::Denied { reason } => Some(reason),
Access::Unknown { why } => Some(why),
}
}
}
impl std::fmt::Display for Access {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self.detail() {
None => f.write_str(self.as_str()),
Some(detail) => write!(f, "{}: {detail}", self.as_str()),
}
}
}
#[async_trait]
pub trait Provider: Send + Sync {
fn id(&self) -> &str;
fn capabilities(&self, model: &ModelId) -> Option<ModelCapabilities>;
async fn chat(&self, request: ChatRequest) -> Result<ChatResponse>;
async fn stream(&self, request: ChatRequest) -> Result<EventStream<'_>> {
Ok(replay_stream(&self.chat(request).await?))
}
async fn catalogue(&self) -> Result<Vec<ModelId>> {
Err(crate::Error::Unsupported(format!(
"{} has no model catalogue",
self.id()
)))
}
async fn validate(&self, _model: &ModelId) -> Access {
Access::unknown(format!("{} has no free way to be asked", self.id()))
}
}
pub(crate) fn replay_stream(reply: &ChatResponse) -> EventStream<'static> {
let mut events = vec![Event::Started {
model: reply.model.clone(),
}];
events.extend(replay(reply));
events.push(Event::Stopped {
reason: reply.stop_reason,
details: reply.stop_details.clone(),
});
events.push(Event::Metered(reply.usage));
Box::pin(Replayed {
events: events.into_iter(),
})
}
fn replay(reply: &ChatResponse) -> Vec<Event> {
let mut events = Vec::new();
for block in &reply.message.content {
match block {
crate::chat::message::ContentBlock::Text(text) => {
events.push(Event::TextDelta(text.clone()));
}
crate::chat::message::ContentBlock::Thinking { text, signature } => {
events.push(Event::ThinkingDelta(text.clone()));
if let Some(signature) = signature {
events.push(Event::ThinkingSignature(signature.clone()));
}
}
crate::chat::message::ContentBlock::ToolUse { id, name, input } => {
events.push(Event::ToolUseStarted {
id: id.clone(),
name: name.clone(),
});
events.push(Event::ToolArgumentsDelta(input.to_string()));
}
crate::chat::message::ContentBlock::Opaque { kind, raw } => {
events.push(Event::Opaque {
kind: kind.clone(),
raw: raw.clone(),
});
}
crate::chat::message::ContentBlock::ToolResult { .. }
| crate::chat::message::ContentBlock::Image { .. } => {}
}
}
events
}
struct Replayed {
events: std::vec::IntoIter<Event>,
}
impl futures_core::Stream for Replayed {
type Item = Result<Event>;
fn poll_next(
mut self: std::pin::Pin<&mut Self>,
_cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Option<Self::Item>> {
std::task::Poll::Ready(self.events.next().map(Ok))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::chat::request::ChatRequest;
use crate::chat::response::ChatResponse;
struct Bare;
#[async_trait]
impl Provider for Bare {
fn id(&self) -> &str {
"bare"
}
fn capabilities(&self, _model: &ModelId) -> Option<ModelCapabilities> {
None
}
async fn chat(&self, _request: ChatRequest) -> Result<ChatResponse> {
Err(crate::Error::Unsupported("not in this test".into()))
}
}
#[tokio::test]
async fn a_provider_with_nothing_to_ask_answers_unknown_rather_than_denied() {
let access = Bare.validate(&"any-model".into()).await;
assert!(access.is_unknown(), "{access:?}");
assert!(!access.is_denied());
assert_eq!(access.detail().map(|d| d.contains("bare")), Some(true));
}
#[test]
fn denied_is_not_merely_the_absence_of_ready() {
let unknown = Access::unknown("the network was down");
assert!(!unknown.is_ready());
assert!(!unknown.is_denied());
assert!(unknown.is_unknown());
let denied = Access::denied("the key was rejected");
assert!(!denied.is_ready());
assert!(denied.is_denied());
assert!(!denied.is_unknown());
}
#[test]
fn ready_carries_no_detail_and_the_other_two_do() {
assert_eq!(Access::Ready.detail(), None);
assert_eq!(
Access::denied("no entitlement").detail(),
Some("no entitlement")
);
assert_eq!(Access::unknown("timed out").detail(), Some("timed out"));
}
#[test]
fn every_answer_has_one_spelling() {
assert_eq!(Access::Ready.as_str(), "ready");
assert_eq!(Access::denied("x").as_str(), "denied");
assert_eq!(Access::unknown("x").as_str(), "unknown");
}
#[test]
fn what_was_said_reaches_the_line_somebody_reads() {
assert_eq!(Access::Ready.to_string(), "ready");
assert_eq!(
Access::denied("the key was rejected").to_string(),
"denied: the key was rejected"
);
assert_eq!(
Access::unknown("503 from the vendor").to_string(),
"unknown: 503 from the vendor"
);
}
use crate::chat::message::{ContentBlock, Message, Role, StopReason};
use crate::chat::stream::Transcript;
use crate::cost::usage::Usage;
fn rich_reply() -> ChatResponse {
ChatResponse::new(
Message {
role: Role::Assistant,
content: vec![
ContentBlock::Thinking {
text: "working".into(),
signature: Some("sig".into()),
},
ContentBlock::Text("Four.".into()),
ContentBlock::ToolUse {
id: "call_1".into(),
name: "search".into(),
input: serde_json::json!({ "q": "rust" }),
},
ContentBlock::Opaque {
kind: "redacted_thinking".into(),
raw: serde_json::json!({ "type": "redacted_thinking", "data": "x" }),
},
],
},
StopReason::ToolUse,
Usage::absent().with_input(10).with_output(7),
"m".into(),
)
}
#[test]
fn a_reply_broken_into_events_folds_back_into_itself() {
let reply = rich_reply();
let mut transcript = Transcript::new("m");
transcript.push(Event::Started {
model: reply.model.clone(),
});
for event in replay(&reply) {
transcript.push(event);
}
transcript.push(Event::Stopped {
reason: reply.stop_reason,
details: reply.stop_details.clone(),
});
transcript.push(Event::Metered(reply.usage));
let assembled = transcript.finish();
assert_eq!(assembled.message, reply.message);
assert_eq!(assembled.stop_reason, reply.stop_reason);
assert_eq!(assembled.usage, reply.usage);
assert_eq!(assembled.model, reply.model);
}
#[test]
fn a_tool_result_never_becomes_an_event() {
let reply = ChatResponse::new(
Message {
role: Role::Assistant,
content: vec![ContentBlock::ToolResult {
tool_use_id: "call_1".into(),
content: "42".into(),
is_error: false,
}],
},
StopReason::EndTurn,
Usage::absent(),
"m".into(),
);
assert!(replay(&reply).is_empty());
}
}