use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
const MAX_IMAGE_BYTES: usize = 9 * 1024 * 1024;
const MAX_MEDIA_BYTES: usize = 25 * 1024 * 1024;
const MAX_TTS_INPUT: usize = 4096;
const MAX_CHAT_IMAGE_BYTES: usize = 10 * 1024 * 1024;
pub const KNOWN_VOICES: &[&str] = &[
"alloy", "ash", "ballad", "coral", "echo", "fable", "onyx", "nova", "sage", "shimmer", "verse",
];
#[derive(Clone, Debug)]
pub struct ToolSpec {
pub name: String,
pub description: String,
pub parameters: serde_json::Value,
}
impl ToolSpec {
pub fn new(
name: impl Into<String>,
description: impl Into<String>,
parameters: serde_json::Value,
) -> Self {
Self {
name: name.into(),
description: description.into(),
parameters,
}
}
}
#[derive(Clone, Debug)]
pub struct ToolCall {
pub id: String,
pub name: String,
pub arguments: String,
}
#[derive(Debug, Default)]
pub struct ChatOutcome {
pub text: String,
pub tool_calls: Vec<ToolCall>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProbeVerdict {
Live,
Canned,
Unstable,
NotSupported,
}
#[derive(Debug)]
pub struct ProbeReport {
pub verdict: ProbeVerdict,
pub evidence: Vec<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ChatMessage {
pub role: String,
pub content: String,
#[serde(skip)]
image: Option<(Vec<u8>, String)>,
#[serde(skip)]
tool_call_id: Option<String>,
#[serde(skip)]
tool_calls: Option<Vec<ToolCall>>,
}
impl ChatMessage {
pub fn system(content: impl Into<String>) -> Self {
Self {
role: "system".to_owned(),
content: content.into(),
image: None,
tool_call_id: None,
tool_calls: None,
}
}
pub fn user(content: impl Into<String>) -> Self {
Self {
role: "user".to_owned(),
content: content.into(),
image: None,
tool_call_id: None,
tool_calls: None,
}
}
pub fn assistant(content: impl Into<String>) -> Self {
Self {
role: "assistant".to_owned(),
content: content.into(),
image: None,
tool_call_id: None,
tool_calls: None,
}
}
pub fn tool_result(call_id: impl Into<String>, content: impl Into<String>) -> Self {
Self {
role: "tool".to_owned(),
content: content.into(),
image: None,
tool_call_id: Some(call_id.into()),
tool_calls: None,
}
}
pub fn assistant_tool_calls(calls: &[ToolCall]) -> Self {
Self {
role: "assistant".to_owned(),
content: String::new(),
image: None,
tool_call_id: None,
tool_calls: Some(calls.to_vec()),
}
}
pub fn with_image(mut self, bytes: Vec<u8>, mime: &str) -> Self {
if bytes.len() <= MAX_CHAT_IMAGE_BYTES {
self.image = Some((bytes, mime.to_owned()));
}
self
}
}
#[derive(Clone, Debug)]
pub struct DiscoveredModel {
pub id: String,
pub caps: ModelCaps,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct ModelCaps(pub u8);
impl ModelCaps {
pub const IMAGE: u8 = 1;
pub const VIDEO: u8 = 2;
pub const AUDIO: u8 = 4;
pub fn image(self) -> bool {
self.0 & Self::IMAGE != 0
}
pub fn video(self) -> bool {
self.0 & Self::VIDEO != 0
}
pub fn audio(self) -> bool {
self.0 & Self::AUDIO != 0
}
pub fn is_text(self) -> bool {
self.0 == 0
}
}
const IMAGE_MODEL_MARKERS: &[&str] = &[
"dall-e",
"gpt-image",
"stable-diffusion",
"sdxl",
"flux",
"imagen",
"qwen-image",
"midjourney",
];
const VIDEO_MODEL_MARKERS: &[&str] = &[
"sora", "veo", "wan-", "wanx", "wan2", "video", "runway", "kling", "t2v", "i2v", "r2v",
];
const AUDIO_MODEL_MARKERS: &[&str] = &["tts", "audio", "bark", "musicgen", "suno"];
pub fn caps_from_name(model: &str) -> ModelCaps {
let low = model.to_ascii_lowercase();
let mut mask = 0u8;
if IMAGE_MODEL_MARKERS.iter().any(|k| low.contains(k)) {
mask |= ModelCaps::IMAGE;
}
if VIDEO_MODEL_MARKERS.iter().any(|k| low.contains(k)) {
mask |= ModelCaps::VIDEO;
}
if AUDIO_MODEL_MARKERS.iter().any(|k| low.contains(k)) {
mask |= ModelCaps::AUDIO;
}
ModelCaps(mask)
}
#[derive(Debug)]
pub enum GenError {
NotSupported,
Other(String),
}
impl std::fmt::Display for GenError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NotSupported => write!(f, "not supported by this host"),
Self::Other(msg) => write!(f, "{msg}"),
}
}
}
impl std::error::Error for GenError {}
const MAX_ERR_REASON: usize = 120;
fn net_err(e: reqwest::Error) -> GenError {
let reason = if e.is_timeout() {
"timed out".to_owned()
} else if e.is_connect() {
let mut cause: &dyn std::error::Error = &e;
while let Some(src) = cause.source() {
cause = src;
}
cause.to_string()
} else {
e.without_url().to_string()
};
GenError::Other(format!(
"host unreachable: {}",
clip_reason(&reason, MAX_ERR_REASON)
))
}
fn clip_reason(reason: &str, cap: usize) -> String {
if reason.chars().count() <= cap {
return reason.to_owned();
}
let cut: String = reason.chars().take(cap.saturating_sub(3)).collect();
format!("{cut}...")
}
pub fn is_cert_error(e: &GenError) -> bool {
let msg = e.to_string().to_lowercase();
msg.contains("certificate") || msg.contains("unknownissuer")
}
const TLS_CACHE_CAP: usize = 256;
fn tls_mode() -> &'static Mutex<HashMap<String, bool>> {
static TLS_MODE: OnceLock<Mutex<HashMap<String, bool>>> = OnceLock::new();
TLS_MODE.get_or_init(|| Mutex::new(HashMap::new()))
}
fn cached_insecure(base: &str) -> Option<bool> {
tls_mode().lock().ok()?.get(base).copied()
}
fn remember_insecure(base: &str, insecure: bool) {
if let Ok(mut map) = tls_mode().lock() {
if map.len() >= TLS_CACHE_CAP && !map.contains_key(base) {
map.clear();
}
map.insert(base.to_owned(), insecure);
}
}
pub struct GenClient {
base_url: String,
api_key: Option<String>,
insecure: bool,
auto_tls: bool,
}
impl GenClient {
pub fn new(base_url: impl Into<String>, api_key: Option<String>) -> Self {
Self {
base_url: base_url.into(),
api_key: api_key.filter(|k| !k.is_empty()),
insecure: false,
auto_tls: true,
}
}
pub fn insecure(mut self, yes: bool) -> Self {
self.insecure = yes;
self
}
pub fn auto_tls(mut self, yes: bool) -> Self {
self.auto_tls = yes;
self
}
fn auth(&self, req: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
match &self.api_key {
Some(key) => req.bearer_auth(key),
None => req,
}
}
fn http_mode(&self, timeout_secs: u64, insecure: bool) -> Result<reqwest::Client, GenError> {
let mut builder =
reqwest::Client::builder().timeout(std::time::Duration::from_secs(timeout_secs));
if insecure {
builder = builder.danger_accept_invalid_certs(true);
}
builder
.build()
.map_err(|e| GenError::Other(format!("client: {e}")))
}
fn effective_insecure(&self) -> bool {
if self.insecure {
return true;
}
self.auto_tls && cached_insecure(&self.base_url).unwrap_or(false)
}
async fn send_with_tls_fallback(
&self,
timeout_secs: u64,
build: impl Fn(&reqwest::Client) -> reqwest::RequestBuilder,
) -> Result<(reqwest::Response, reqwest::Client), GenError> {
let insecure = self.effective_insecure();
let client = self.http_mode(timeout_secs, insecure)?;
let first_err = match build(&client).send().await {
Ok(resp) => return Ok((resp, client)),
Err(e) => net_err(e),
};
if !self.auto_tls {
return Err(first_err);
}
if !insecure && is_cert_error(&first_err) {
let client = self.http_mode(timeout_secs, true)?;
return match build(&client).send().await {
Ok(resp) => {
remember_insecure(&self.base_url, true);
Ok((resp, client))
}
Err(_) => Err(first_err),
};
}
if insecure && !self.insecure {
let client = self.http_mode(timeout_secs, false)?;
return match build(&client).send().await {
Ok(resp) => {
remember_insecure(&self.base_url, false);
Ok((resp, client))
}
Err(e) => Err(net_err(e)),
};
}
Err(first_err)
}
pub async fn chat(&self, model: &str, messages: &[ChatMessage]) -> Result<String, GenError> {
let outcome = self.chat_tools(model, messages, &[]).await?;
if outcome.text.is_empty() {
return Err(GenError::Other("empty response".to_owned()));
}
Ok(outcome.text)
}
pub async fn chat_tools(
&self,
model: &str,
messages: &[ChatMessage],
tools: &[ToolSpec],
) -> Result<ChatOutcome, GenError> {
#[derive(Serialize)]
struct Req<'a> {
model: &'a str,
messages: Vec<WireMessage<'a>>,
#[serde(skip_serializing_if = "Option::is_none")]
tools: Option<Vec<WireTool<'a>>>,
}
let url = format!("{}/chat/completions", v1_url(&self.base_url));
let req = Req {
model,
messages: messages.iter().map(to_wire).collect(),
tools: wire_tools(tools),
};
let (resp, _) = self
.send_with_tls_fallback(120, |client| self.auth(client.post(&url).json(&req)))
.await?;
let status = resp.status();
if !status.is_success() {
return Err(chat_http_error(status, resp, !tools.is_empty()).await);
}
let parsed: ChatResp = resp
.json()
.await
.map_err(|_| GenError::Other("bad response from host".to_owned()))?;
let outcome = parsed
.into_outcome()
.ok_or_else(|| GenError::Other("empty response".to_owned()))?;
if outcome.text.is_empty() && outcome.tool_calls.is_empty() {
return Err(GenError::Other("empty response".to_owned()));
}
Ok(outcome)
}
pub async fn chat_stream<F>(
&self,
model: &str,
messages: &[ChatMessage],
on_delta: F,
) -> Result<String, GenError>
where
F: FnMut(&str) + Send,
{
let outcome = self
.chat_stream_tools(model, messages, &[], on_delta)
.await?;
if outcome.text.is_empty() {
return Err(GenError::Other("empty response".to_owned()));
}
Ok(outcome.text)
}
pub async fn chat_stream_tools<F>(
&self,
model: &str,
messages: &[ChatMessage],
tools: &[ToolSpec],
mut on_delta: F,
) -> Result<ChatOutcome, GenError>
where
F: FnMut(&str) + Send,
{
#[derive(Serialize)]
struct Req<'a> {
model: &'a str,
messages: Vec<WireMessage<'a>>,
stream: bool,
#[serde(skip_serializing_if = "Option::is_none")]
tools: Option<Vec<WireTool<'a>>>,
}
let url = format!("{}/chat/completions", v1_url(&self.base_url));
let req = Req {
model,
messages: messages.iter().map(to_wire).collect(),
stream: true,
tools: wire_tools(tools),
};
let (resp, _) = self
.send_with_tls_fallback(600, |client| self.auth(client.post(&url).json(&req)))
.await?;
let status = resp.status();
if !status.is_success() {
return Err(chat_http_error(status, resp, !tools.is_empty()).await);
}
let is_sse = resp
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.map(|v| v.contains("text/event-stream"))
.unwrap_or(false);
if !is_sse {
let parsed: ChatResp = resp
.json()
.await
.map_err(|_| GenError::Other("bad response from host".to_owned()))?;
let outcome = parsed
.into_outcome()
.ok_or_else(|| GenError::Other("empty response".to_owned()))?;
if outcome.text.is_empty() && outcome.tool_calls.is_empty() {
return Err(GenError::Other("empty response".to_owned()));
}
if !outcome.text.is_empty() {
on_delta(&outcome.text);
}
return Ok(outcome);
}
let mut resp = resp;
let mut buf = SseBuffer::default();
let mut text = String::new();
let mut acc = ToolCallAccumulator::default();
let mut done = false;
let mut saw_sse = false;
let mut raw = Vec::new();
while !done {
let chunk = tokio::time::timeout(std::time::Duration::from_secs(180), resp.chunk())
.await
.map_err(|_| GenError::Other("stream stalled".to_owned()))?
.map_err(net_err)?;
let Some(chunk) = chunk else { break };
if raw.len() < 2 * 1024 * 1024 {
raw.extend_from_slice(&chunk);
}
let mut grew = false;
for event in buf.feed(&chunk) {
saw_sse = true;
match event {
SseData::Delta(d) => {
text.push_str(&d);
grew = true;
}
SseData::ToolDelta(parts) => acc.feed(parts),
SseData::Done => done = true,
}
}
if grew {
on_delta(&text);
}
}
let tool_calls = acc.finish();
if text.is_empty() && tool_calls.is_empty() {
if !saw_sse {
if let Ok(parsed) = serde_json::from_slice::<ChatResp>(&raw) {
if let Some(outcome) = parsed.into_outcome() {
if !outcome.text.is_empty() || !outcome.tool_calls.is_empty() {
if !outcome.text.is_empty() {
on_delta(&outcome.text);
}
return Ok(outcome);
}
}
}
}
let head = String::from_utf8_lossy(&raw);
let head = head.trim();
return Err(GenError::Other(if head.is_empty() {
"empty response".to_owned()
} else {
format!("empty response (host sent: {})", clip_reason(head, 160))
}));
}
Ok(ChatOutcome { text, tool_calls })
}
pub async fn list_models(&self) -> Result<Vec<DiscoveredModel>, GenError> {
#[derive(Deserialize)]
struct ModelsResp {
#[serde(default)]
data: Vec<ModelEntry>,
}
#[derive(Deserialize)]
struct ModelEntry {
id: String,
#[serde(default)]
architecture: Option<Architecture>,
#[serde(default)]
mode: Option<String>,
}
#[derive(Deserialize)]
struct Architecture {
#[serde(default)]
output_modalities: Vec<String>,
}
let url = format!("{}/models", v1_url(&self.base_url));
let (resp, _) = self
.send_with_tls_fallback(10, |client| self.auth(client.get(&url)))
.await?;
let status = resp.status();
if matches!(status.as_u16(), 400 | 404 | 405) {
return Err(GenError::NotSupported);
}
if !status.is_success() {
return Err(GenError::Other(format!(
"host returned HTTP {}",
status.as_u16()
)));
}
let parsed: ModelsResp = resp
.json()
.await
.map_err(|_| GenError::Other("bad response from host".to_owned()))?;
let mut models: Vec<DiscoveredModel> = parsed
.data
.into_iter()
.map(|m| {
let caps =
caps_from_entry(m.architecture.map(|a| a.output_modalities), m.mode, &m.id);
DiscoveredModel { id: m.id, caps }
})
.collect();
models.sort_by(|a, b| a.id.cmp(&b.id));
models.truncate(50);
Ok(models)
}
pub async fn image(&self, model: &str, prompt: &str) -> Result<Vec<u8>, GenError> {
use base64::Engine as _;
#[derive(Serialize)]
struct Req<'a> {
model: &'a str,
prompt: &'a str,
n: u8,
size: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
response_format: Option<&'a str>,
}
#[derive(Deserialize)]
struct Resp {
#[serde(default)]
data: Vec<Item>,
}
#[derive(Deserialize)]
struct Item {
#[serde(default)]
b64_json: Option<String>,
#[serde(default)]
url: Option<String>,
}
let url = format!("{}/images/generations", v1_url(&self.base_url));
let req = Req {
model,
prompt,
n: 1,
size: "1024x1024",
response_format: Some("b64_json"),
};
let (mut resp, client) = self
.send_with_tls_fallback(90, |client| self.auth(client.post(&url).json(&req)))
.await?;
if resp.status().as_u16() == 400 {
let body = resp.text().await.unwrap_or_default();
if body.to_lowercase().contains("response_format") {
resp = self
.auth(client.post(&url).json(&Req {
model,
prompt,
n: 1,
size: "1024x1024",
response_format: None,
}))
.send()
.await
.map_err(net_err)?;
} else {
return Err(GenError::NotSupported);
}
}
let status = resp.status();
if matches!(status.as_u16(), 400 | 404 | 405) {
return Err(GenError::NotSupported);
}
if !status.is_success() {
return Err(GenError::Other(format!(
"host returned HTTP {}",
status.as_u16()
)));
}
let parsed: Resp = resp
.json()
.await
.map_err(|_| GenError::Other("bad response from host".to_owned()))?;
let Some(item) = parsed.data.into_iter().next() else {
return Err(GenError::Other("empty response".to_owned()));
};
if let Some(b64) = item.b64_json {
let bytes = base64::engine::general_purpose::STANDARD
.decode(b64.trim())
.map_err(|_| GenError::Other("bad image data".to_owned()))?;
if bytes.is_empty() {
return Err(GenError::Other("empty image".to_owned()));
}
if bytes.len() > MAX_IMAGE_BYTES {
return Err(GenError::Other("image too large".to_owned()));
}
return Ok(bytes);
}
if let Some(link) = item.url {
return download_image(&client, &link).await;
}
Err(GenError::Other("empty response".to_owned()))
}
pub async fn video(&self, model: &str, prompt: &str) -> Result<Vec<u8>, GenError> {
#[derive(Serialize)]
struct Req<'a> {
model: &'a str,
prompt: &'a str,
seconds: &'a str,
size: &'a str,
}
#[derive(Deserialize)]
struct Job {
id: String,
#[serde(default)]
status: String,
#[serde(default)]
error: Option<JobError>,
}
#[derive(Deserialize)]
struct JobError {
#[serde(default)]
message: String,
}
let base = v1_url(&self.base_url);
let req = Req {
model,
prompt,
seconds: "4",
size: "1280x720",
};
let (resp, client) = self
.send_with_tls_fallback(30, |client| {
self.auth(client.post(format!("{base}/videos")).json(&req))
})
.await?;
let status = resp.status();
if matches!(status.as_u16(), 400 | 404 | 405) {
return Err(GenError::NotSupported);
}
if !status.is_success() {
return Err(GenError::Other(format!(
"host returned HTTP {}",
status.as_u16()
)));
}
let job: Job = resp
.json()
.await
.map_err(|_| GenError::Other("bad response from host".to_owned()))?;
let job_id = job.id;
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5 * 60);
loop {
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
if std::time::Instant::now() > deadline {
return Err(GenError::Other("video generation timed out".to_owned()));
}
let resp = self
.auth(client.get(format!("{base}/videos/{job_id}")))
.send()
.await
.map_err(net_err)?;
if !resp.status().is_success() {
return Err(GenError::Other(format!(
"host returned HTTP {}",
resp.status().as_u16()
)));
}
let job: Job = resp
.json()
.await
.map_err(|_| GenError::Other("bad response from host".to_owned()))?;
match job.status.as_str() {
"completed" => break,
"failed" | "cancelled" => {
let msg = job
.error
.map(|e| e.message)
.filter(|m| !m.is_empty())
.unwrap_or_else(|| "video generation failed".to_owned());
return Err(GenError::Other(msg));
}
_ => {}
}
}
let resp = self
.auth(client.get(format!("{base}/videos/{job_id}/content")))
.send()
.await
.map_err(net_err)?;
if !resp.status().is_success() {
return Err(GenError::Other(format!(
"video download HTTP {}",
resp.status().as_u16()
)));
}
read_capped(resp, MAX_MEDIA_BYTES, "video").await
}
pub async fn speech(
&self,
model: &str,
text: &str,
voice: Option<&str>,
) -> Result<Vec<u8>, GenError> {
#[derive(Serialize)]
struct Req<'a> {
model: &'a str,
input: &'a str,
voice: &'a str,
response_format: &'a str,
}
let input: String = text.chars().take(MAX_TTS_INPUT).collect();
let base = v1_url(&self.base_url);
let req = Req {
model,
input: &input,
voice: voice.unwrap_or("alloy"),
response_format: "mp3",
};
let (resp, _) = self
.send_with_tls_fallback(90, |client| {
self.auth(client.post(format!("{base}/audio/speech")).json(&req))
})
.await?;
let status = resp.status();
if matches!(status.as_u16(), 400 | 404 | 405) {
return Err(GenError::NotSupported);
}
if !status.is_success() {
return Err(GenError::Other(format!(
"host returned HTTP {}",
status.as_u16()
)));
}
read_capped(resp, MAX_MEDIA_BYTES, "audio").await
}
pub async fn probe_chat(&self, model: &str) -> Result<ProbeReport, GenError> {
const PROMPT_A: &str = "Reply with exactly the word: apple";
const PROMPT_B: &str = "Reply with exactly and only the single word: orange";
let a = self.chat_raw(model, PROMPT_A).await?;
let b = self.chat_raw(model, PROMPT_B).await?;
match resolve_pair(a, b)? {
PairOutcome::Report(report) => Ok(report),
PairOutcome::Both(sa, sb) => {
let now = unix_now();
let (verdict, evidence) = judge_chat(&sa, &sb, now);
Ok(ProbeReport { verdict, evidence })
}
}
}
pub async fn probe_speech(&self, model: &str) -> Result<ProbeReport, GenError> {
const SHORT: &str = "a";
const LONG: &str = "The quick brown fox jumps over the lazy dog by the river bank.";
let a = self.speech_raw(model, SHORT).await?;
let b = self.speech_raw(model, LONG).await?;
match resolve_pair(a, b)? {
PairOutcome::Report(report) => Ok(report),
PairOutcome::Both(short, long) => {
let (verdict, evidence) = judge_speech(&short, &long);
Ok(ProbeReport { verdict, evidence })
}
}
}
pub async fn probe_image_cheap(&self, model: &str) -> Result<ProbeReport, GenError> {
#[derive(Serialize)]
struct Req<'a> {
model: &'a str,
prompt: &'a str,
n: u8,
size: &'a str,
}
let url = format!("{}/images/generations", v1_url(&self.base_url));
let req = Req {
model,
prompt: "x",
n: 1,
size: "1x1",
};
let (resp, _) = self
.send_with_tls_fallback(30, |client| self.auth(client.post(&url).json(&req)))
.await?;
let status = resp.status().as_u16();
let body = if status == 400 {
resp.text().await.unwrap_or_default()
} else {
String::new()
};
let (verdict, evidence) = judge_image(status, &body)?;
Ok(ProbeReport { verdict, evidence })
}
async fn chat_raw(&self, model: &str, prompt: &str) -> Result<ProbeCall<RawSample>, GenError> {
#[derive(Serialize)]
struct Req<'a> {
model: &'a str,
messages: Vec<WireMessage<'a>>,
max_tokens: u32,
temperature: f32,
}
let url = format!("{}/chat/completions", v1_url(&self.base_url));
let msg = ChatMessage::user(prompt);
let req = Req {
model,
messages: vec![to_wire(&msg)],
max_tokens: 8,
temperature: 1.0,
};
let (resp, _) = self
.send_with_tls_fallback(30, |client| self.auth(client.post(&url).json(&req)))
.await?;
let status = resp.status().as_u16();
if !(200..300).contains(&status) {
return Ok(ProbeCall::Http(status));
}
let parsed: ChatResp = resp
.json()
.await
.map_err(|_| GenError::Other("bad response from host".to_owned()))?;
let id = parsed.id.clone();
let created = parsed.created;
let usage_prompt_tokens = parsed.usage.as_ref().and_then(|u| u.prompt_tokens);
let text = parsed.into_outcome().map(|o| o.text).unwrap_or_default();
Ok(ProbeCall::Ok(RawSample {
text,
id,
created,
usage_prompt_tokens,
prompt_len: prompt.len(),
}))
}
async fn speech_raw(&self, model: &str, input: &str) -> Result<ProbeCall<Vec<u8>>, GenError> {
#[derive(Serialize)]
struct Req<'a> {
model: &'a str,
input: &'a str,
voice: &'a str,
response_format: &'a str,
}
let base = v1_url(&self.base_url);
let req = Req {
model,
input,
voice: "alloy",
response_format: "mp3",
};
let (resp, _) = self
.send_with_tls_fallback(60, |client| {
self.auth(client.post(format!("{base}/audio/speech")).json(&req))
})
.await?;
let status = resp.status().as_u16();
if !(200..300).contains(&status) {
return Ok(ProbeCall::Http(status));
}
let bytes = read_capped(resp, MAX_MEDIA_BYTES, "audio").await?;
Ok(ProbeCall::Ok(bytes))
}
}
enum ProbeCall<T> {
Ok(T),
Http(u16),
}
enum PairOutcome<T> {
Report(ProbeReport),
Both(T, T),
}
fn resolve_pair<T>(a: ProbeCall<T>, b: ProbeCall<T>) -> Result<PairOutcome<T>, GenError> {
let report = |verdict, clue: &str| {
Ok(PairOutcome::Report(ProbeReport {
verdict,
evidence: vec![clue.to_owned()],
}))
};
match (a, b) {
(ProbeCall::Ok(sa), ProbeCall::Ok(sb)) => Ok(PairOutcome::Both(sa, sb)),
(ProbeCall::Http(s), ProbeCall::Http(t)) if s >= 500 && t >= 500 => Err(GenError::Other(
format!("host returned HTTP {s} and HTTP {t}"),
)),
(ProbeCall::Http(s), _) | (_, ProbeCall::Http(s)) if matches!(s, 400 | 404 | 405) => {
report(
ProbeVerdict::NotSupported,
&format!("host returned HTTP {s}"),
)
}
(ProbeCall::Http(s), ProbeCall::Ok(_)) | (ProbeCall::Ok(_), ProbeCall::Http(s))
if s >= 500 =>
{
report(
ProbeVerdict::Unstable,
&format!("alternates between {s} and success"),
)
}
(ProbeCall::Http(s), _) | (_, ProbeCall::Http(s)) => {
Err(GenError::Other(format!("host returned HTTP {s}")))
}
}
}
#[derive(Debug)]
struct RawSample {
text: String,
id: Option<String>,
created: Option<i64>,
usage_prompt_tokens: Option<u64>,
prompt_len: usize,
}
const STALE_CREATED_SECS: i64 = 86_400;
fn judge_chat(a: &RawSample, b: &RawSample, now: i64) -> (ProbeVerdict, Vec<String>) {
let mut strong = 0usize;
let mut weak = 0usize;
let mut evidence = Vec::new();
match (&a.id, &b.id) {
(Some(x), Some(y)) if !x.is_empty() && x == y => {
strong += 1;
evidence.push("same response id on both calls".to_owned());
}
(Some(x), Some(y)) if x != y => evidence.push("response ids differ".to_owned()),
_ => {}
}
if !a.text.is_empty() && a.text == b.text {
strong += 1;
evidence.push("same answer to different prompts".to_owned());
} else if a.text != b.text {
evidence.push("answers differ".to_owned());
}
let stale = [a.created, b.created]
.iter()
.flatten()
.any(|c| (now - c).abs() > STALE_CREATED_SECS);
if stale {
strong += 1;
evidence.push("created timestamp is over a day off".to_owned());
} else if a.created.is_some() || b.created.is_some() {
evidence.push("fresh created timestamp".to_owned());
}
if let (Some(x), Some(y)) = (a.usage_prompt_tokens, b.usage_prompt_tokens) {
if x == y && a.prompt_len != b.prompt_len {
weak += 1;
evidence.push("same prompt_tokens for prompts of different length".to_owned());
}
}
let verdict = if strong >= 2 || (strong == 1 && weak >= 1) {
ProbeVerdict::Canned
} else {
ProbeVerdict::Live
};
(verdict, evidence)
}
const SPEECH_SCALE_MIN: f64 = 1.5;
fn judge_speech(short: &[u8], long: &[u8]) -> (ProbeVerdict, Vec<String>) {
if short == long {
return (
ProbeVerdict::Canned,
vec!["identical audio for inputs of different length".to_owned()],
);
}
let scale = long.len() as f64 / short.len().max(1) as f64;
if scale > SPEECH_SCALE_MIN {
(
ProbeVerdict::Live,
vec![format!(
"audio size scales with input ({} vs {} bytes)",
short.len(),
long.len()
)],
)
} else {
(
ProbeVerdict::Canned,
vec![format!(
"audio size barely changes for a 60x longer input ({} vs {} bytes)",
short.len(),
long.len()
)],
)
}
}
fn judge_image(status: u16, body_400: &str) -> Result<(ProbeVerdict, Vec<String>), GenError> {
if (200..300).contains(&status) {
return Ok((
ProbeVerdict::Canned,
vec!["accepted an invalid size parameter".to_owned()],
));
}
if status == 400 {
let low = body_400.to_ascii_lowercase();
if low.contains("size") || low.contains("invalid") {
return Ok((
ProbeVerdict::Live,
vec!["backend validates the size parameter".to_owned()],
));
}
return Ok((
ProbeVerdict::NotSupported,
vec!["host returned HTTP 400 without mentioning size".to_owned()],
));
}
if matches!(status, 404 | 405) {
return Ok((
ProbeVerdict::NotSupported,
vec![format!("host returned HTTP {status}")],
));
}
Err(GenError::Other(format!("host returned HTTP {status}")))
}
fn unix_now() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
fn v1_url(base: &str) -> String {
let base = base.trim_end_matches('/');
let base = base.trim_end_matches("/chat/completions");
if base.ends_with("/v1") {
base.to_owned()
} else {
format!("{base}/v1")
}
}
#[derive(Debug, PartialEq, Eq)]
enum SseData {
Delta(String),
ToolDelta(Vec<ToolCallDelta>),
Done,
}
#[derive(Debug, PartialEq, Eq, Deserialize)]
struct ToolCallDelta {
#[serde(default)]
index: usize,
#[serde(default)]
id: Option<String>,
#[serde(default)]
function: Option<ToolFnDelta>,
}
#[derive(Debug, PartialEq, Eq, Deserialize)]
struct ToolFnDelta {
#[serde(default)]
name: Option<String>,
#[serde(default)]
arguments: Option<String>,
}
#[derive(Default)]
struct ToolCallAccumulator {
calls: Vec<ToolCall>,
}
impl ToolCallAccumulator {
fn feed(&mut self, parts: Vec<ToolCallDelta>) {
for part in parts {
while self.calls.len() <= part.index {
self.calls.push(ToolCall {
id: String::new(),
name: String::new(),
arguments: String::new(),
});
}
let call = &mut self.calls[part.index];
if let Some(id) = part.id {
call.id = id;
}
if let Some(f) = part.function {
if let Some(name) = f.name {
call.name = name;
}
if let Some(args) = f.arguments {
call.arguments.push_str(&args);
}
}
}
}
fn finish(self) -> Vec<ToolCall> {
self.calls
.into_iter()
.filter(|c| !c.name.is_empty())
.collect()
}
}
#[derive(Default)]
struct SseBuffer {
buf: String,
}
impl SseBuffer {
fn feed(&mut self, chunk: &[u8]) -> Vec<SseData> {
self.buf.push_str(&String::from_utf8_lossy(chunk));
let mut events = Vec::new();
while let Some(pos) = self.buf.find('\n') {
let line: String = self.buf.drain(..=pos).collect();
if let Some(event) = parse_sse_line(line.trim_end()) {
events.push(event);
}
}
events
}
}
fn parse_sse_line(line: &str) -> Option<SseData> {
#[derive(Deserialize)]
struct Chunk {
#[serde(default)]
choices: Vec<ChunkChoice>,
}
#[derive(Deserialize)]
struct ChunkChoice {
#[serde(default)]
delta: Delta,
}
#[derive(Deserialize, Default)]
struct Delta {
#[serde(default)]
content: Option<ChatRespContent>,
#[serde(default)]
reasoning_content: Option<String>,
#[serde(default)]
tool_calls: Option<Vec<ToolCallDelta>>,
}
let data = line.strip_prefix("data:")?.trim();
if data == "[DONE]" {
return Some(SseData::Done);
}
let chunk: Chunk = serde_json::from_str(data).ok()?;
let delta = chunk.choices.into_iter().next()?.delta;
if let Some(calls) = delta.tool_calls {
if !calls.is_empty() {
return Some(SseData::ToolDelta(calls));
}
}
let mut content = delta.content.map(|c| c.into_text()).unwrap_or_default();
if content.is_empty() {
content = delta.reasoning_content.unwrap_or_default();
}
if content.is_empty() {
return None;
}
Some(SseData::Delta(content))
}
#[derive(Deserialize)]
struct ChatResp {
#[serde(default)]
id: Option<String>,
#[serde(default)]
created: Option<i64>,
#[serde(default)]
usage: Option<ChatUsage>,
#[serde(default)]
choices: Vec<ChatRespChoice>,
}
#[derive(Deserialize)]
struct ChatUsage {
#[serde(default)]
prompt_tokens: Option<u64>,
}
#[derive(Deserialize)]
struct ChatRespChoice {
message: ChatRespMessage,
}
#[derive(Deserialize)]
struct ChatRespMessage {
#[serde(default)]
content: Option<ChatRespContent>,
#[serde(default)]
reasoning_content: Option<String>,
#[serde(default)]
tool_calls: Vec<ChatRespToolCall>,
}
#[derive(Deserialize)]
#[serde(untagged)]
enum ChatRespContent {
Text(String),
Parts(Vec<ChatRespPart>),
}
#[derive(Deserialize)]
struct ChatRespPart {
#[serde(default)]
text: Option<String>,
}
impl ChatRespContent {
fn into_text(self) -> String {
match self {
Self::Text(t) => t,
Self::Parts(parts) => parts
.into_iter()
.filter_map(|p| p.text)
.collect::<Vec<_>>()
.join(""),
}
}
}
#[derive(Deserialize)]
struct ChatRespToolCall {
#[serde(default)]
id: String,
function: ChatRespFn,
}
#[derive(Deserialize)]
struct ChatRespFn {
name: String,
#[serde(default)]
arguments: String,
}
impl ChatResp {
fn into_outcome(self) -> Option<ChatOutcome> {
let msg = self.choices.into_iter().next()?.message;
let mut text = msg.content.map(|c| c.into_text()).unwrap_or_default();
if text.is_empty() {
if let Some(r) = msg.reasoning_content {
text = r;
}
}
Some(ChatOutcome {
text,
tool_calls: msg
.tool_calls
.into_iter()
.map(|c| ToolCall {
id: c.id,
name: c.function.name,
arguments: c.function.arguments,
})
.collect(),
})
}
}
async fn chat_http_error(
status: reqwest::StatusCode,
resp: reqwest::Response,
sent_tools: bool,
) -> GenError {
if sent_tools && status.as_u16() == 400 {
let body = resp.text().await.unwrap_or_default().to_ascii_lowercase();
if body.contains("tool") || body.contains("function") {
return GenError::NotSupported;
}
}
GenError::Other(format!("host returned HTTP {}", status.as_u16()))
}
#[derive(Serialize)]
struct WireTool<'a> {
#[serde(rename = "type")]
kind: &'static str,
function: WireToolFn<'a>,
}
#[derive(Serialize)]
struct WireToolFn<'a> {
name: &'a str,
description: &'a str,
parameters: &'a serde_json::Value,
}
fn wire_tools(tools: &[ToolSpec]) -> Option<Vec<WireTool<'_>>> {
if tools.is_empty() {
return None;
}
Some(
tools
.iter()
.map(|t| WireTool {
kind: "function",
function: WireToolFn {
name: &t.name,
description: &t.description,
parameters: &t.parameters,
},
})
.collect(),
)
}
#[derive(Serialize)]
struct WireMessage<'a> {
role: &'a str,
content: WireContent<'a>,
#[serde(skip_serializing_if = "Option::is_none")]
tool_call_id: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
tool_calls: Option<Vec<WireToolCall<'a>>>,
}
#[derive(Serialize)]
#[serde(untagged)]
enum WireContent<'a> {
Text(&'a str),
Parts(Vec<WirePart<'a>>),
Null,
}
#[derive(Serialize)]
#[serde(tag = "type")]
enum WirePart<'a> {
#[serde(rename = "text")]
Text { text: &'a str },
#[serde(rename = "image_url")]
ImageUrl { image_url: WireImageUrl },
}
#[derive(Serialize)]
struct WireImageUrl {
url: String,
}
#[derive(Serialize)]
struct WireToolCall<'a> {
id: &'a str,
#[serde(rename = "type")]
kind: &'static str,
function: WireCallFn<'a>,
}
#[derive(Serialize)]
struct WireCallFn<'a> {
name: &'a str,
arguments: &'a str,
}
fn to_wire(msg: &ChatMessage) -> WireMessage<'_> {
use base64::Engine as _;
let tool_calls = msg.tool_calls.as_ref().map(|calls| {
calls
.iter()
.map(|c| WireToolCall {
id: &c.id,
kind: "function",
function: WireCallFn {
name: &c.name,
arguments: &c.arguments,
},
})
.collect()
});
let content = match &msg.image {
Some((bytes, mime)) => {
let b64 = base64::engine::general_purpose::STANDARD.encode(bytes);
WireContent::Parts(vec![
WirePart::Text { text: &msg.content },
WirePart::ImageUrl {
image_url: WireImageUrl {
url: format!("data:{mime};base64,{b64}"),
},
},
])
}
None if tool_calls.is_some() && msg.content.is_empty() => WireContent::Null,
None => WireContent::Text(&msg.content),
};
WireMessage {
role: &msg.role,
content,
tool_call_id: msg.tool_call_id.as_deref(),
tool_calls,
}
}
fn caps_from_entry(modalities: Option<Vec<String>>, mode: Option<String>, id: &str) -> ModelCaps {
if let Some(mods) = modalities {
if !mods.is_empty() {
let mut mask = 0u8;
for m in &mods {
match m.as_str() {
"image" => mask |= ModelCaps::IMAGE,
"video" => mask |= ModelCaps::VIDEO,
"audio" => mask |= ModelCaps::AUDIO,
_ => {}
}
}
return ModelCaps(mask);
}
}
if let Some(mode) = mode.as_deref() {
return match mode {
"image_generation" | "image_edit" => ModelCaps(ModelCaps::IMAGE),
"video_generation" => ModelCaps(ModelCaps::VIDEO),
"audio_speech" | "audio_transcription" | "tts" => ModelCaps(ModelCaps::AUDIO),
"chat" | "completion" | "embedding" | "embeddings" | "rerank" | "moderation" => {
ModelCaps::default()
}
_ => caps_from_name(id),
};
}
caps_from_name(id)
}
async fn download_image(client: &reqwest::Client, url: &str) -> Result<Vec<u8>, GenError> {
let resp = client
.get(url)
.send()
.await
.map_err(|_| GenError::Other("image download failed".to_owned()))?;
if !resp.status().is_success() {
return Err(GenError::Other(format!(
"image download HTTP {}",
resp.status().as_u16()
)));
}
read_capped(resp, MAX_IMAGE_BYTES, "image").await
}
async fn read_capped(resp: reqwest::Response, cap: usize, what: &str) -> Result<Vec<u8>, GenError> {
let mut resp = resp;
let mut bytes = Vec::new();
while let Some(chunk) = resp
.chunk()
.await
.map_err(|_| GenError::Other(format!("{what} download failed")))?
{
if bytes.len() + chunk.len() > cap {
return Err(GenError::Other(format!("{what} too large")));
}
bytes.extend_from_slice(&chunk);
}
if bytes.is_empty() {
return Err(GenError::Other(format!("empty {what}")));
}
Ok(bytes)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn v1_url_normalises() {
assert_eq!(v1_url("http://x:11434"), "http://x:11434/v1");
assert_eq!(v1_url("http://x:11434/"), "http://x:11434/v1");
assert_eq!(v1_url("http://x/v1/"), "http://x/v1");
assert_eq!(v1_url("http://x/v1/chat/completions"), "http://x/v1");
}
#[test]
fn caps_heuristic() {
assert!(caps_from_name("qwen-image-plus").image());
assert!(caps_from_name("DALL-E-3").image());
assert!(caps_from_name("sora-2").video());
assert!(caps_from_name("gpt-4o-mini-tts").audio());
assert!(caps_from_name("llama3").is_text());
}
#[test]
fn metadata_beats_heuristic() {
let caps = caps_from_entry(Some(vec!["text".into(), "image".into()]), None, "gemini");
assert!(caps.image());
let caps = caps_from_entry(None, Some("image_generation".into()), "my-model");
assert!(caps.image());
let caps = caps_from_entry(None, Some("chat".into()), "flux-chat");
assert!(caps.is_text());
}
#[test]
fn chat_message_constructors() {
let m = ChatMessage::system("rules");
assert_eq!(m.role, "system");
assert_eq!(m.content, "rules");
let m = ChatMessage::user(String::from("hi"));
assert_eq!(m.role, "user");
assert_eq!(m.content, "hi");
let m = ChatMessage::assistant("ok");
assert_eq!(m.role, "assistant");
assert_eq!(m.content, "ok");
}
#[test]
fn empty_api_key_means_none() {
let c = GenClient::new("http://x", Some(String::new()));
assert!(c.api_key.is_none());
let c = GenClient::new("http://x", Some("sk-1".into()));
assert_eq!(c.api_key.as_deref(), Some("sk-1"));
}
#[test]
fn insecure_flag_is_stored() {
let c = GenClient::new("http://x", None);
assert!(!c.insecure);
let c = c.insecure(true);
assert!(c.insecure);
let c = c.insecure(false);
assert!(!c.insecure);
}
#[test]
fn auto_tls_on_by_default_and_toggleable() {
let c = GenClient::new("http://x", None);
assert!(c.auto_tls);
let c = c.auto_tls(false);
assert!(!c.auto_tls);
let c = c.auto_tls(true);
assert!(c.auto_tls);
}
#[test]
fn cert_error_detection() {
assert!(is_cert_error(&GenError::Other(
"host unreachable: invalid peer certificate: UnknownIssuer".into()
)));
assert!(is_cert_error(&GenError::Other(
"Certificate verify failed".into()
)));
assert!(!is_cert_error(&GenError::Other(
"host unreachable: connection refused".into()
)));
assert!(!is_cert_error(&GenError::NotSupported));
}
#[test]
fn tls_cache_set_get_and_overflow_clear() {
remember_insecure("https://tls-test-host-a", true);
assert_eq!(cached_insecure("https://tls-test-host-a"), Some(true));
remember_insecure("https://tls-test-host-a", false);
assert_eq!(cached_insecure("https://tls-test-host-a"), Some(false));
assert_eq!(cached_insecure("https://tls-test-never-seen"), None);
for i in 0..TLS_CACHE_CAP {
remember_insecure(&format!("https://tls-test-fill-{i}"), true);
}
remember_insecure("https://tls-test-overflow", true);
assert_eq!(cached_insecure("https://tls-test-overflow"), Some(true));
assert_eq!(cached_insecure("https://tls-test-fill-0"), None);
let len = tls_mode().lock().unwrap().len();
assert!(len <= TLS_CACHE_CAP, "cache grew past the cap: {len}");
}
#[test]
fn effective_mode_explicit_insecure_wins() {
let c = GenClient::new("https://tls-test-pin", None).insecure(true);
remember_insecure("https://tls-test-pin", false);
assert!(c.effective_insecure());
let c = GenClient::new("https://tls-test-fresh-host", None);
assert!(!c.effective_insecure());
let c = GenClient::new("https://tls-test-pin2", None).auto_tls(false);
remember_insecure("https://tls-test-pin2", true);
assert!(!c.effective_insecure());
}
#[test]
fn clip_reason_caps_length() {
assert_eq!(clip_reason("short", 120), "short");
let long = "x".repeat(200);
let clipped = clip_reason(&long, 120);
assert_eq!(clipped.chars().count(), 120);
assert!(clipped.ends_with("..."));
}
#[tokio::test]
async fn net_err_keeps_the_cause() {
let e = reqwest::Client::new()
.get("http://127.0.0.1:1/")
.send()
.await
.expect_err("connect must fail");
let msg = net_err(e).to_string();
assert!(msg.starts_with("host unreachable: "), "got: {msg}");
assert!(msg.len() > "host unreachable: ".len(), "got: {msg}");
}
#[test]
fn with_image_stores_bytes_and_mime() {
let m = ChatMessage::user("look").with_image(vec![1, 2, 3], "image/png");
let (bytes, mime) = m.image.as_ref().expect("image kept");
assert_eq!(bytes, &[1, 2, 3]);
assert_eq!(mime, "image/png");
}
#[test]
fn oversized_image_is_dropped() {
let big = vec![0u8; MAX_CHAT_IMAGE_BYTES + 1];
let m = ChatMessage::user("look").with_image(big, "image/png");
assert!(m.image.is_none());
}
#[test]
fn wire_message_with_image_uses_content_parts() {
let m = ChatMessage::user("what is this").with_image(vec![255, 216], "image/jpeg");
let json = serde_json::to_string(&to_wire(&m)).unwrap();
assert!(json.contains("\"image_url\""));
assert!(json.contains("data:image/jpeg;base64,"));
assert!(json.contains("\"what is this\""));
}
#[test]
fn wire_message_without_image_keeps_string_content() {
let m = ChatMessage::user("hi");
let json = serde_json::to_string(&to_wire(&m)).unwrap();
assert_eq!(json, "{\"role\":\"user\",\"content\":\"hi\"}");
}
#[test]
fn image_is_not_serialized_with_the_message() {
let m = ChatMessage::user("hi").with_image(vec![1], "image/png");
let json = serde_json::to_string(&m).unwrap();
assert!(!json.contains("image"));
}
#[test]
fn sse_line_with_content() {
let line = r#"data: {"choices":[{"delta":{"content":"Hi"}}]}"#;
assert_eq!(parse_sse_line(line), Some(SseData::Delta("Hi".into())));
}
#[test]
fn sse_done_terminator() {
assert_eq!(parse_sse_line("data: [DONE]"), Some(SseData::Done));
}
#[test]
fn sse_empty_or_missing_delta_is_skipped() {
let line = r#"data: {"choices":[{"delta":{"role":"assistant"}}]}"#;
assert_eq!(parse_sse_line(line), None);
let line = r#"data: {"choices":[{"delta":{"content":""}}]}"#;
assert_eq!(parse_sse_line(line), None);
assert_eq!(parse_sse_line(": keep-alive"), None);
assert_eq!(parse_sse_line(""), None);
}
#[test]
fn sse_buffer_reassembles_split_line() {
let mut buf = SseBuffer::default();
let events = buf.feed(b"data: {\"choices\":[{\"delta\":{\"co");
assert!(events.is_empty());
let events = buf.feed(b"ntent\":\"hello\"}}]}\n");
assert_eq!(events, vec![SseData::Delta("hello".into())]);
}
#[test]
fn sse_buffer_multiple_lines_in_one_chunk() {
let mut buf = SseBuffer::default();
let chunk = concat!(
"data: {\"choices\":[{\"delta\":{\"content\":\"a\"}}]}\n",
"\n",
"data: {\"choices\":[{\"delta\":{\"content\":\"b\"}}]}\n",
"\n",
"data: [DONE]\n",
);
let events = buf.feed(chunk.as_bytes());
assert_eq!(
events,
vec![
SseData::Delta("a".into()),
SseData::Delta("b".into()),
SseData::Done,
]
);
}
#[test]
fn sse_buffer_handles_crlf() {
let mut buf = SseBuffer::default();
let events = buf.feed(b"data: {\"choices\":[{\"delta\":{\"content\":\"x\"}}]}\r\n");
assert_eq!(events, vec![SseData::Delta("x".into())]);
}
#[test]
fn sse_tool_call_delta_parsed() {
let line = r#"data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"get_time","arguments":""}}]}}]}"#;
let Some(SseData::ToolDelta(parts)) = parse_sse_line(line) else {
panic!("expected tool delta");
};
assert_eq!(parts.len(), 1);
assert_eq!(parts[0].index, 0);
assert_eq!(parts[0].id.as_deref(), Some("call_1"));
assert_eq!(
parts[0].function.as_ref().unwrap().name.as_deref(),
Some("get_time")
);
}
#[test]
fn tool_call_accumulates_arguments_across_deltas() {
let mut acc = ToolCallAccumulator::default();
for line in [
r#"data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"weather","arguments":""}}]}}]}"#,
r#"data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"city\":"}}]}}]}"#,
r#"data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\"Oslo\"}"}}]}}]}"#,
] {
let Some(SseData::ToolDelta(parts)) = parse_sse_line(line) else {
panic!("expected tool delta");
};
acc.feed(parts);
}
let calls = acc.finish();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].id, "call_1");
assert_eq!(calls[0].name, "weather");
assert_eq!(calls[0].arguments, r#"{"city":"Oslo"}"#);
}
#[test]
fn two_parallel_tool_calls_by_index() {
let mut acc = ToolCallAccumulator::default();
acc.feed(vec![ToolCallDelta {
index: 0,
id: Some("call_a".into()),
function: Some(ToolFnDelta {
name: Some("first".into()),
arguments: Some("{\"a\":".into()),
}),
}]);
acc.feed(vec![ToolCallDelta {
index: 1,
id: Some("call_b".into()),
function: Some(ToolFnDelta {
name: Some("second".into()),
arguments: Some("{}".into()),
}),
}]);
acc.feed(vec![ToolCallDelta {
index: 0,
id: None,
function: Some(ToolFnDelta {
name: None,
arguments: Some("1}".into()),
}),
}]);
let calls = acc.finish();
assert_eq!(calls.len(), 2);
assert_eq!(calls[0].name, "first");
assert_eq!(calls[0].arguments, "{\"a\":1}");
assert_eq!(calls[1].id, "call_b");
assert_eq!(calls[1].name, "second");
assert_eq!(calls[1].arguments, "{}");
}
#[test]
fn wire_tool_result_message() {
let m = ChatMessage::tool_result("call_1", "42");
let json = serde_json::to_string(&to_wire(&m)).unwrap();
assert_eq!(
json,
"{\"role\":\"tool\",\"content\":\"42\",\"tool_call_id\":\"call_1\"}"
);
}
#[test]
fn wire_assistant_tool_calls_message() {
let m = ChatMessage::assistant_tool_calls(&[ToolCall {
id: "call_1".into(),
name: "weather".into(),
arguments: "{\"city\":\"Oslo\"}".into(),
}]);
let json = serde_json::to_string(&to_wire(&m)).unwrap();
assert!(json.contains("\"role\":\"assistant\""));
assert!(json.contains("\"content\":null"));
assert!(json.contains("\"id\":\"call_1\""));
assert!(json.contains("\"type\":\"function\""));
assert!(json.contains("\"name\":\"weather\""));
assert!(json.contains("\"arguments\":\"{\\\"city\\\":\\\"Oslo\\\"}\""));
assert!(!json.contains("tool_call_id"));
}
#[test]
fn wire_tools_omitted_when_empty() {
assert!(wire_tools(&[]).is_none());
let spec = ToolSpec::new("f", "does f", serde_json::json!({"type": "object"}));
let wired = wire_tools(std::slice::from_ref(&spec)).unwrap();
let json = serde_json::to_string(&wired).unwrap();
assert_eq!(
json,
"[{\"type\":\"function\",\"function\":{\"name\":\"f\",\"description\":\"does f\",\"parameters\":{\"type\":\"object\"}}}]"
);
}
#[test]
fn chat_resp_with_tool_calls() {
let body = r#"{"choices":[{"message":{"content":null,"tool_calls":[
{"id":"call_1","type":"function","function":{"name":"f","arguments":"{}"}}
]},"finish_reason":"tool_calls"}]}"#;
let resp: ChatResp = serde_json::from_str(body).unwrap();
let outcome = resp.into_outcome().unwrap();
assert!(outcome.text.is_empty());
assert_eq!(outcome.tool_calls.len(), 1);
assert_eq!(outcome.tool_calls[0].id, "call_1");
assert_eq!(outcome.tool_calls[0].name, "f");
assert_eq!(outcome.tool_calls[0].arguments, "{}");
}
#[test]
fn chat_resp_text_only() {
let body = r#"{"choices":[{"message":{"content":"hi"}}]}"#;
let resp: ChatResp = serde_json::from_str(body).unwrap();
let outcome = resp.into_outcome().unwrap();
assert_eq!(outcome.text, "hi");
assert!(outcome.tool_calls.is_empty());
}
#[test]
fn chat_resp_content_parts() {
let body = r#"{"choices":[{"message":{"content":[{"type":"text","text":"hel"},{"type":"text","text":"lo"}]}}]}"#;
let resp: ChatResp = serde_json::from_str(body).unwrap();
assert_eq!(resp.into_outcome().unwrap().text, "hello");
}
#[test]
fn chat_resp_reasoning_content_fallback() {
let body = r#"{"choices":[{"message":{"content":"","reasoning_content":"thought out"}}]}"#;
let resp: ChatResp = serde_json::from_str(body).unwrap();
assert_eq!(resp.into_outcome().unwrap().text, "thought out");
}
#[test]
fn sse_delta_content_parts_and_reasoning() {
let parts = parse_sse_line(
r#"data: {"choices":[{"delta":{"content":[{"type":"text","text":"x"}]}}]}"#,
);
assert_eq!(parts, Some(SseData::Delta("x".to_owned())));
let reasoning = parse_sse_line(
r#"data: {"choices":[{"delta":{"content":"","reasoning_content":"y"}}]}"#,
);
assert_eq!(reasoning, Some(SseData::Delta("y".to_owned())));
}
fn sample(text: &str, id: &str, created: i64, tokens: u64, prompt_len: usize) -> RawSample {
RawSample {
text: text.to_owned(),
id: Some(id.to_owned()),
created: Some(created),
usage_prompt_tokens: Some(tokens),
prompt_len,
}
}
#[test]
fn probe_chat_canned_by_id_and_text() {
let now = 1_800_000_000;
let a = sample("banana", "chatcmpl-fixed", now, 12, 34);
let b = sample("banana", "chatcmpl-fixed", now, 14, 52);
let (verdict, evidence) = judge_chat(&a, &b, now);
assert_eq!(verdict, ProbeVerdict::Canned);
assert!(evidence.iter().any(|e| e.contains("same response id")));
assert!(evidence.iter().any(|e| e.contains("same answer")));
}
#[test]
fn probe_chat_canned_by_stale_created_plus_usage() {
let now = 1_800_000_000;
let a = sample("apple", "id-1", now - 90 * 86_400, 10, 34);
let b = sample("orange", "id-2", now - 90 * 86_400, 10, 52);
let (verdict, evidence) = judge_chat(&a, &b, now);
assert_eq!(verdict, ProbeVerdict::Canned);
assert!(evidence.iter().any(|e| e.contains("over a day off")));
assert!(evidence.iter().any(|e| e.contains("prompt_tokens")));
}
#[test]
fn probe_chat_live() {
let now = 1_800_000_000;
let a = sample("apple", "id-1", now - 1, 10, 34);
let b = sample("orange", "id-2", now, 14, 52);
let (verdict, evidence) = judge_chat(&a, &b, now);
assert_eq!(verdict, ProbeVerdict::Live);
assert!(evidence.iter().any(|e| e.contains("answers differ")));
assert!(evidence.iter().any(|e| e.contains("fresh created")));
}
#[test]
fn probe_chat_one_sign_is_not_enough() {
let now = 1_800_000_000;
let a = sample("ok", "id-1", now, 10, 34);
let b = sample("ok", "id-2", now, 14, 52);
let (verdict, _) = judge_chat(&a, &b, now);
assert_eq!(verdict, ProbeVerdict::Live);
}
#[test]
fn probe_pair_unstable_and_not_supported() {
let out = resolve_pair(ProbeCall::Http(502), ProbeCall::Ok(())).unwrap();
let PairOutcome::Report(report) = out else {
panic!("expected a report");
};
assert_eq!(report.verdict, ProbeVerdict::Unstable);
assert!(report.evidence[0].contains("alternates"));
let out = resolve_pair::<()>(ProbeCall::Http(500), ProbeCall::Http(404)).unwrap();
let PairOutcome::Report(report) = out else {
panic!("expected a report");
};
assert_eq!(report.verdict, ProbeVerdict::NotSupported);
assert!(resolve_pair::<()>(ProbeCall::Http(502), ProbeCall::Http(503)).is_err());
}
#[test]
fn probe_speech_judging() {
let (verdict, _) = judge_speech(&[1, 2, 3], &[1, 2, 3]);
assert_eq!(verdict, ProbeVerdict::Canned);
let (verdict, _) = judge_speech(&vec![0u8; 1000], &vec![0u8; 1100]);
assert_eq!(verdict, ProbeVerdict::Canned);
let (verdict, evidence) = judge_speech(&vec![0u8; 1000], &vec![0u8; 9000]);
assert_eq!(verdict, ProbeVerdict::Live);
assert!(evidence[0].contains("scales"));
}
#[test]
fn probe_image_judging() {
let (verdict, _) = judge_image(400, r#"{"error":{"message":"invalid size '1x1'"}}"#)
.expect("verdict expected");
assert_eq!(verdict, ProbeVerdict::Live);
let (verdict, _) = judge_image(200, "").expect("verdict expected");
assert_eq!(verdict, ProbeVerdict::Canned);
let (verdict, _) = judge_image(404, "").expect("verdict expected");
assert_eq!(verdict, ProbeVerdict::NotSupported);
let (verdict, _) = judge_image(400, "model quota exceeded").expect("verdict expected");
assert_eq!(verdict, ProbeVerdict::NotSupported);
assert!(judge_image(500, "").is_err());
}
}