pub(crate) mod sse;
use agentyk_core::driver::{ChatRequest, ChatResponse, DeltaSink, ModelSpec};
use agentyk_core::error::{Error, LlmErrorKind, Result};
use futures_util::StreamExt;
use serde_json::Value;
use sse::SseDecoder;
pub(crate) trait StreamAccumulator: Default {
fn apply(&mut self, data: &str) -> Result<Option<String>>;
fn is_terminator(_data: &str) -> bool {
false
}
fn text(&self) -> &str;
fn finish(self) -> ChatResponse;
}
pub(crate) fn client() -> reqwest::Client {
reqwest::Client::builder()
.use_rustls_tls()
.tls_built_in_webpki_certs(true)
.tls_built_in_native_certs(true)
.build()
.unwrap_or_default()
}
pub(crate) fn decode<T: serde::de::DeserializeOwned>(label: &str, body: &str) -> Result<T> {
serde_json::from_str(body).map_err(|e| {
Error::driver(
LlmErrorKind::Unknown,
format!("{label} response did not match the expected shape: {e}"),
)
})
}
pub(crate) trait HttpProvider: Send + Sync {
type Accumulator: StreamAccumulator;
fn label(&self) -> &str;
fn default_base_url(&self) -> &str;
fn endpoint(&self) -> &str;
fn authorize(
&self,
builder: reqwest::RequestBuilder,
model: &ModelSpec,
) -> Result<reqwest::RequestBuilder>;
fn build_body(&self, request: &ChatRequest) -> Value;
fn enable_streaming(&self, body: &mut Value);
fn parse_response(&self, body: &str) -> Result<ChatResponse>;
fn classify_status(&self, status: reqwest::StatusCode) -> LlmErrorKind {
classify_status(status)
}
}
pub(crate) fn classify_status(status: reqwest::StatusCode) -> LlmErrorKind {
match status.as_u16() {
401 | 403 => LlmErrorKind::Authentication,
429 => LlmErrorKind::RateLimited,
503 => LlmErrorKind::Overloaded,
400 | 404 | 422 => LlmErrorKind::InvalidRequest,
500..=599 => LlmErrorKind::ServerError,
400..=499 => LlmErrorKind::InvalidRequest,
_ => LlmErrorKind::Unknown,
}
}
fn network_error(label: &str, context: &str, error: &reqwest::Error) -> Error {
let kind = if error.is_timeout() {
LlmErrorKind::Timeout
} else {
LlmErrorKind::Network
};
Error::driver(kind, format!("{label} {context}: {error}"))
}
fn endpoint_url<P: HttpProvider>(provider: &P, model: &ModelSpec) -> String {
let base = model
.base_url
.as_deref()
.unwrap_or_else(|| provider.default_base_url());
format!("{}{}", base.trim_end_matches('/'), provider.endpoint())
}
async fn send<P: HttpProvider>(
provider: &P,
client: &reqwest::Client,
model: &ModelSpec,
body: &Value,
) -> Result<reqwest::Response> {
let builder = client.post(endpoint_url(provider, model)).json(body);
let response = provider
.authorize(builder, model)?
.send()
.await
.map_err(|e| network_error(provider.label(), "request failed", &e))?;
let status = response.status();
if !status.is_success() {
let payload: Value = response.json().await.unwrap_or(Value::Null);
return Err(Error::driver(
provider.classify_status(status),
format!("{} http {status}: {payload}", provider.label()),
));
}
Ok(response)
}
pub(crate) async fn complete<P: HttpProvider>(
provider: &P,
client: &reqwest::Client,
request: ChatRequest,
) -> Result<ChatResponse> {
let body = provider.build_body(&request);
let response = send(provider, client, &request.model, &body).await?;
let text = response
.text()
.await
.map_err(|e| network_error(provider.label(), "response body could not be read", &e))?;
provider.parse_response(&text)
}
pub(crate) async fn complete_streaming<P: HttpProvider>(
provider: &P,
client: &reqwest::Client,
request: ChatRequest,
sink: &mut dyn DeltaSink,
) -> Result<ChatResponse> {
let mut body = provider.build_body(&request);
provider.enable_streaming(&mut body);
let response = send(provider, client, &request.model, &body).await?;
let mut bytes = response.bytes_stream();
let mut decoder = SseDecoder::new();
let mut accumulator = P::Accumulator::default();
while let Some(chunk) = bytes.next().await {
let chunk = chunk.map_err(|e| network_error(provider.label(), "stream read failed", &e))?;
pump(
&mut accumulator,
&mut decoder,
&String::from_utf8_lossy(&chunk),
sink,
)
.await?;
}
Ok(accumulator.finish())
}
async fn pump<A: StreamAccumulator>(
accumulator: &mut A,
decoder: &mut SseDecoder,
chunk: &str,
sink: &mut dyn DeltaSink,
) -> Result<()> {
for payload in decoder.push(chunk) {
if A::is_terminator(&payload) {
continue;
}
if let Some(delta) = accumulator.apply(&payload)? {
sink.delta(&delta, accumulator.text()).await?;
}
}
Ok(())
}
#[cfg(test)]
pub(crate) async fn drive_stream<A: StreamAccumulator>(
chunks: &[&str],
sink: &mut dyn DeltaSink,
) -> Result<ChatResponse> {
let mut decoder = SseDecoder::new();
let mut accumulator = A::default();
for chunk in chunks {
pump(&mut accumulator, &mut decoder, chunk, sink).await?;
}
Ok(accumulator.finish())
}
#[cfg(test)]
#[derive(Default)]
pub(crate) struct RecordingSink {
pub(crate) deltas: Vec<String>,
pub(crate) accumulated: Vec<String>,
}
#[cfg(test)]
#[async_trait::async_trait]
impl DeltaSink for RecordingSink {
async fn delta(&mut self, delta: &str, accumulated: &str) -> Result<()> {
self.deltas.push(delta.to_string());
self.accumulated.push(accumulated.to_string());
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_default_classification_splits_retryable_from_terminal() {
use reqwest::StatusCode;
assert_eq!(
classify_status(StatusCode::UNAUTHORIZED),
LlmErrorKind::Authentication
);
assert_eq!(
classify_status(StatusCode::TOO_MANY_REQUESTS),
LlmErrorKind::RateLimited
);
assert_eq!(
classify_status(StatusCode::SERVICE_UNAVAILABLE),
LlmErrorKind::Overloaded
);
assert_eq!(
classify_status(StatusCode::BAD_REQUEST),
LlmErrorKind::InvalidRequest
);
assert_eq!(
classify_status(StatusCode::INTERNAL_SERVER_ERROR),
LlmErrorKind::ServerError
);
assert!(classify_status(StatusCode::TOO_MANY_REQUESTS).is_retryable());
assert!(!classify_status(StatusCode::UNAUTHORIZED).is_retryable());
}
}