use std::collections::HashMap;
use std::fmt::Write as _;
use std::time::{Duration, Instant};
use serde::Deserialize;
use serde_json::json;
use crate::bench_openrouter::classify::{
CacheClassification, cache_hold_result, classify_round, expected_cached_for_round,
verify_pinned,
};
use crate::bench_openrouter::discovery::EndpointInfo;
use crate::util::UnwrapPoison;
pub(crate) const RUN_DEADLINE_MINS: u64 = 55;
const CHAT_COMPLETIONS_URL: &str = "https://openrouter.ai/api/v1/chat/completions";
const REQUEST_TIMEOUT_SECS: u64 = 120;
const MAX_ATTEMPTS: u32 = 3;
const RETRY_SLEEP_MIN_MS: u64 = 5_000;
const RETRY_SLEEP_MAX_MS: u64 = 60_000;
const RESPONSE_CACHE_RETRY_DELAY_SECS: u64 = 2;
const ROUND_MAX_TOKENS: u32 = 128;
const LENGTH_RETRY_MAX_TOKENS: u32 = 1024;
const FILLER_WORDS: &[&str] = &[
"alpha", "bravo", "charlie", "delta", "echo", "foxtrot", "golf", "hotel", "india", "juliet",
"kilo", "lima", "mike", "november", "oscar", "papa",
];
#[derive(Clone)]
pub(crate) struct BasePrompt {
pub system: String,
pub user: String,
}
#[must_use]
pub(crate) fn build_base_prompt(system: &str, nonce: &str, filler: &str) -> BasePrompt {
BasePrompt {
system: system.to_string(),
user: format!(
"{filler}\n\nStep 0: call fast_tool with step 0 and nothing else.\n[bench-run {nonce}]"
),
}
}
#[must_use]
pub(crate) fn generate_filler(prefix_chars: usize) -> String {
let mut out = String::new();
let mut i = 0usize;
while out.len() < prefix_chars {
if i > 0 {
out.push('\n');
}
let _ = write!(out, "{:06} {}", i, FILLER_WORDS[i % FILLER_WORDS.len()]);
i += 1;
}
out
}
pub(crate) struct ToolFrame {
pub id: String,
pub step: u64,
pub reasoning: Option<serde_json::Value>,
}
#[must_use]
pub(crate) fn build_messages(base: &BasePrompt, frames: &[ToolFrame]) -> Vec<serde_json::Value> {
let mut messages = vec![
json!({"role": "system", "content": base.system}),
json!({"role": "user", "content": base.user}),
];
for frame in frames {
let mut assistant = json!({
"role": "assistant",
"content": null,
"tool_calls": [{
"id": frame.id,
"type": "function",
"function": {
"name": "fast_tool",
"arguments": format!("{{\"step\":{}}}", frame.step),
},
}],
});
if let Some(reasoning) = &frame.reasoning
&& let Some(obj) = reasoning.as_object()
{
for (key, value) in obj {
assistant[key] = value.clone();
}
}
messages.push(assistant);
messages.push(json!({
"role": "tool",
"tool_call_id": frame.id,
"content": format!(
"step {} acknowledged; proceed to step {}",
frame.step,
frame.step + 1
),
}));
}
messages
}
#[must_use]
pub(crate) fn build_request_body(
model: &str,
base: &BasePrompt,
frames: &[ToolFrame],
tag: &str,
reasoning_effort: Option<&str>,
max_tokens: u32,
) -> serde_json::Value {
let mut body = json!({
"model": model,
"messages": build_messages(base, frames),
"max_tokens": max_tokens,
"tools": [{
"type": "function",
"function": {
"name": "fast_tool",
"description": "Report the current benchmark step.",
"parameters": {
"type": "object",
"properties": {"step": {"type": "integer"}},
"required": ["step"],
},
},
}],
"provider": {"order": [tag], "allow_fallbacks": false},
});
if let Some(effort) = reasoning_effort {
body["reasoning_effort"] = json!(effort);
}
body
}
#[allow(dead_code)]
#[must_use]
pub(crate) fn canonical_messages_json(messages: &[serde_json::Value]) -> String {
serde_json::to_string(messages).expect("serializing a message array cannot fail")
}
#[derive(Debug, Clone, Default, Deserialize)]
pub(crate) struct PromptTokensDetails {
#[serde(default)]
pub cached_tokens: Option<u64>,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub(crate) struct RawUsage {
#[serde(default)]
pub prompt_tokens: Option<u64>,
#[serde(default)]
pub prompt_tokens_details: Option<PromptTokensDetails>,
#[serde(default)]
pub prompt_cache_hit_tokens: Option<u64>,
#[serde(default)]
pub cost: Option<f64>,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub(crate) struct RawEnvelope {
#[serde(default)]
pub provider: Option<String>,
#[serde(default)]
pub usage: Option<RawUsage>,
#[serde(default)]
pub choices: Vec<RawChoice>,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub(crate) struct RawChoice {
#[serde(default)]
pub finish_reason: Option<String>,
#[serde(default)]
pub message: RawMessage,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub(crate) struct RawMessage {
#[serde(default)]
pub tool_calls: Option<Vec<RawToolCall>>,
#[serde(default)]
pub reasoning_content: Option<String>,
#[serde(default)]
pub reasoning: Option<String>,
#[serde(default)]
pub reasoning_details: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub(crate) struct RawToolCall {
#[serde(default)]
pub id: Option<String>,
#[serde(default)]
pub function: Option<RawToolCallFunction>,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub(crate) struct RawToolCallFunction {
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub arguments: Option<String>,
}
pub(crate) struct RoundOutcome {
pub envelope: Option<RawEnvelope>,
pub error_class: Option<String>,
pub response_cache_hit: bool,
pub tool_call_step: Option<u64>,
pub tool_call_id: Option<String>,
pub reasoning: Option<serde_json::Value>,
}
#[must_use]
fn retry_sleep_ms(status: reqwest::StatusCode, retry_after: Option<u64>) -> u64 {
if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
retry_after
.unwrap_or(RETRY_SLEEP_MIN_MS)
.clamp(RETRY_SLEEP_MIN_MS, RETRY_SLEEP_MAX_MS)
} else {
RETRY_SLEEP_MIN_MS
}
}
#[must_use]
fn classify_http_error(status: reqwest::StatusCode) -> Option<&'static str> {
match status.as_u16() {
401 => Some("auth"),
402 => Some("quota"),
429 | 500..=599 => None,
_ => Some("http_4xx"),
}
}
pub(crate) async fn send_round(
client: &reqwest::Client,
key: &str,
body: &serde_json::Value,
) -> RoundOutcome {
let mut attempts = 0u32;
let mut http_status = 0u16;
let mut envelope: Option<RawEnvelope> = None;
let mut error_class: Option<&'static str> = None;
let mut response_cache_hit = false;
while attempts < MAX_ATTEMPTS {
attempts += 1;
tracing::debug!(attempt = attempts, "bench round send");
let resp = client
.post(CHAT_COMPLETIONS_URL)
.bearer_auth(key)
.json(body)
.timeout(Duration::from_secs(REQUEST_TIMEOUT_SECS))
.send()
.await;
let resp = match resp {
Ok(r) => r,
Err(e) => {
error_class = Some(if e.is_timeout() {
"timeout"
} else {
"transport"
});
if attempts < MAX_ATTEMPTS {
tokio::time::sleep(Duration::from_millis(RETRY_SLEEP_MIN_MS)).await;
}
continue;
}
};
let status = resp.status();
http_status = status.as_u16();
if status.is_success() {
let cache_status = resp
.headers()
.get("x-openrouter-cache-status")
.and_then(|v| v.to_str().ok())
.map(str::to_string);
response_cache_hit = cache_status
.as_deref()
.is_some_and(|s| s.to_ascii_lowercase().contains("hit"));
match resp.json::<RawEnvelope>().await {
Ok(env) => envelope = Some(env),
Err(_) => error_class = Some("parse"),
}
break;
}
if let Some(class) = classify_http_error(status) {
error_class = Some(class);
break; }
let retry_after = crate::util::error::retry_after_header(resp.headers());
if attempts < MAX_ATTEMPTS {
tokio::time::sleep(Duration::from_millis(retry_sleep_ms(status, retry_after))).await;
}
}
if error_class.is_none() && envelope.is_none() && http_status != 0 {
error_class = Some(if http_status == 429 {
"rate_limited"
} else {
"http_5xx"
});
}
let (tool_call_step, tool_call_id, reasoning) = extract_tool_call(envelope.as_ref());
RoundOutcome {
envelope,
error_class: error_class.map(str::to_string),
response_cache_hit,
tool_call_step,
tool_call_id,
reasoning,
}
}
#[must_use]
fn extract_tool_call(
envelope: Option<&RawEnvelope>,
) -> (Option<u64>, Option<String>, Option<serde_json::Value>) {
let Some(choice) = envelope.and_then(|e| e.choices.first()) else {
return (None, None, None);
};
let Some(calls) = &choice.message.tool_calls else {
return (None, None, None);
};
let Some(call) = calls.iter().find(|c| {
c.function
.as_ref()
.is_some_and(|f| f.name.as_deref() == Some("fast_tool"))
}) else {
return (None, None, None);
};
let step = call
.function
.as_ref()
.and_then(|f| f.arguments.as_deref())
.and_then(|args| serde_json::from_str::<serde_json::Value>(args).ok())
.and_then(|v| v.get("step").and_then(serde_json::Value::as_u64));
(step, call.id.clone(), combine_reasoning(&choice.message))
}
#[must_use]
fn combine_reasoning(msg: &RawMessage) -> Option<serde_json::Value> {
let mut obj = serde_json::Map::new();
if let Some(v) = &msg.reasoning_content {
obj.insert("reasoning_content".to_string(), json!(v));
}
if let Some(v) = &msg.reasoning {
obj.insert("reasoning".to_string(), json!(v));
}
if let Some(v) = &msg.reasoning_details {
obj.insert("reasoning_details".to_string(), v.clone());
}
if obj.is_empty() {
None
} else {
Some(serde_json::Value::Object(obj))
}
}
#[derive(Debug, Clone, Default)]
pub(crate) struct RoundUsage {
pub prompt_tokens: Option<u64>,
pub cached_tokens: Option<u64>,
pub cost: Option<f64>,
}
#[must_use]
fn raw_cached(u: &RawUsage) -> Option<u64> {
u.prompt_tokens_details
.as_ref()
.and_then(|d| d.cached_tokens)
.or(u.prompt_cache_hit_tokens)
}
#[must_use]
fn cached_tokens_of(envelope: Option<&RawEnvelope>) -> u64 {
envelope
.and_then(|e| e.usage.as_ref())
.and_then(raw_cached)
.unwrap_or(0)
}
#[must_use]
fn usage_from(envelope: Option<&RawEnvelope>) -> RoundUsage {
let Some(u) = envelope.and_then(|e| e.usage.as_ref()) else {
return RoundUsage::default();
};
RoundUsage {
prompt_tokens: u.prompt_tokens,
cached_tokens: raw_cached(u),
cost: u.cost,
}
}
pub(crate) struct RunBudget {
pub cap_usd: f64,
pub per_provider_guard: f64,
pub total_spent: std::sync::Mutex<f64>,
pub provider_spent: std::sync::Mutex<HashMap<String, f64>>,
}
impl RunBudget {
#[must_use]
pub(crate) fn new(cap_usd: f64, per_provider_guard: f64) -> Self {
Self {
cap_usd,
per_provider_guard,
total_spent: std::sync::Mutex::new(0.0),
provider_spent: std::sync::Mutex::new(HashMap::new()),
}
}
#[must_use]
pub(crate) fn record(&self, tag: &str, billed: f64) -> (bool, bool) {
let mut total = self.total_spent.lock().unwrap_poison();
*total += billed;
let mut providers = self.provider_spent.lock().unwrap_poison();
let spent = providers.entry(tag.to_string()).or_insert(0.0);
*spent += billed;
(*spent > self.per_provider_guard, *total > self.cap_usd)
}
#[must_use]
pub(crate) fn total(&self) -> f64 {
*self.total_spent.lock().unwrap_poison()
}
}
pub(crate) struct RunContext {
pub budget: RunBudget,
pub abort: tokio_util::sync::CancellationToken,
pub abort_reason: std::sync::Mutex<Option<String>>,
pub deadline: Instant,
}
pub(crate) struct ProviderRun {
pub tag: String,
pub cache_hold_bucket: String,
}
impl ProviderRun {
#[must_use]
fn not_measured(tag: String) -> Self {
Self {
tag,
cache_hold_bucket: "not measured".to_string(),
}
}
}
#[expect(
clippy::too_many_arguments, // the full protocol inputs; bundled would obscure the call site
clippy::too_many_lines, // one long sequential protocol implementation
clippy::cast_precision_loss, // gap seconds (u64) → f64 for the nominal-gap bookkeeping
clippy::ignored_unit_patterns // spec'd select! arms bind the unit sleep future with _
)]
pub(crate) async fn run_provider(
client: &reqwest::Client,
key: &str,
endpoint: &EndpointInfo,
model: &str,
base: &BasePrompt,
reasoning_effort: Option<&str>,
ladder_secs: &[u64],
context: &RunContext,
run_started: Instant,
) -> ProviderRun {
let tag = endpoint.tag.clone();
let w_body = build_request_body(model, base, &[], &tag, reasoning_effort, ROUND_MAX_TOKENS);
let outcome_w1 = send_round(client, key, &w_body).await;
if let Some(class) = outcome_w1.error_class.as_deref() {
if class == "auth" || class == "quota" {
let reason = format!("warmup failed ({class}); provider '{tag}'");
context.abort.cancel();
*context.abort_reason.lock().unwrap_poison() = Some(reason.clone());
tracing::debug!(tag = %tag, class, "provider aborted during warmup");
return ProviderRun::not_measured(tag);
}
tracing::debug!(tag = %tag, class, "provider failed warmup; skipping ladder");
return ProviderRun::not_measured(tag);
}
let outcome_w2 = send_round(client, key, &w_body).await;
let w2_cached = cached_tokens_of(outcome_w2.envelope.as_ref());
let w2_is_response_cache = outcome_w2.response_cache_hit;
let base_cached = if w2_is_response_cache { 0 } else { w2_cached };
let w2_prompt_tokens = outcome_w2
.envelope
.as_ref()
.and_then(|e| e.usage.as_ref())
.and_then(|u| u.prompt_tokens);
let mut cached_observations: Vec<u64> = if w2_is_response_cache {
Vec::new()
} else {
vec![w2_cached]
};
let w_cost = usage_from(outcome_w1.envelope.as_ref()).cost.unwrap_or(0.0)
+ usage_from(outcome_w2.envelope.as_ref()).cost.unwrap_or(0.0);
let (over_guard, over_cap) = context.budget.record(&tag, w_cost);
if over_cap {
let total = context.budget.total();
let reason = format!(
"spend cap exceeded (${total:.4} > ${:.4})",
context.budget.cap_usd
);
context.abort.cancel();
*context.abort_reason.lock().unwrap_poison() = Some(reason.clone());
return ProviderRun::not_measured(tag);
}
if over_guard {
return ProviderRun::not_measured(tag);
}
if let Some(class) = outcome_w2.error_class.as_deref() {
if class == "auth" || class == "quota" {
let reason = format!("warmup failed ({class}); provider '{tag}'");
context.abort.cancel();
*context.abort_reason.lock().unwrap_poison() = Some(reason.clone());
tracing::debug!(tag = %tag, class, "provider aborted during warmup 2");
return ProviderRun::not_measured(tag);
}
tracing::debug!(tag = %tag, class, "provider failed warmup 2; skipping ladder");
return ProviderRun::not_measured(tag);
}
let rounds = ladder_secs.len() + 1;
let mut frames: Vec<ToolFrame> = Vec::new();
let mut classifications: Vec<CacheClassification> = Vec::new();
let mut nominal_gaps: Vec<f64> = Vec::new();
let mut round0_prompt_tokens: Option<u64> = None;
for r in 0..rounds {
if context.abort.is_cancelled() {
break;
}
if Instant::now() >= context.deadline {
break;
}
let next_step = u64::try_from(r).expect("ladder round index fits u64");
let body = build_request_body(
model,
base,
&frames,
&tag,
reasoning_effort,
ROUND_MAX_TOKENS,
);
let nominal_gap_secs = if r == 0 {
Some(0.0)
} else {
Some(ladder_secs[r - 1] as f64)
};
let mut outcome = send_round(client, key, &body).await;
let mut round_billed = usage_from(outcome.envelope.as_ref()).cost.unwrap_or(0.0);
let mut invalid_reason: Option<String> = None;
if outcome.envelope.is_none() {
invalid_reason = Some(
outcome
.error_class
.clone()
.unwrap_or_else(|| "http_error".to_string()),
);
}
if invalid_reason.is_none() && outcome.response_cache_hit {
tokio::time::sleep(Duration::from_secs(RESPONSE_CACHE_RETRY_DELAY_SECS)).await;
outcome = send_round(client, key, &body).await;
round_billed += usage_from(outcome.envelope.as_ref()).cost.unwrap_or(0.0);
if outcome.response_cache_hit {
invalid_reason = Some("response_cache".to_string());
} else if outcome.envelope.is_none() {
invalid_reason = Some(
outcome
.error_class
.clone()
.unwrap_or_else(|| "http_error".to_string()),
);
}
}
if invalid_reason.is_none() {
let serving = outcome
.envelope
.as_ref()
.and_then(|e| e.provider.as_deref());
if serving.is_some() && !verify_pinned(serving, endpoint) {
outcome = send_round(client, key, &body).await;
round_billed += usage_from(outcome.envelope.as_ref()).cost.unwrap_or(0.0);
if outcome.envelope.is_none() {
invalid_reason = Some(
outcome
.error_class
.clone()
.unwrap_or_else(|| "http_error".to_string()),
);
} else {
let serving2 = outcome
.envelope
.as_ref()
.and_then(|e| e.provider.as_deref());
let v2 = verify_pinned(serving2, endpoint);
if serving2.is_some() && !v2 {
invalid_reason = Some("pin_drift".to_string());
}
}
}
}
if invalid_reason.is_none() {
match outcome.tool_call_step {
Some(step) if step == next_step => {}
Some(_) => invalid_reason = Some("tool_call_mismatch".to_string()),
None => {
let finish = outcome
.envelope
.as_ref()
.and_then(|e| e.choices.first())
.and_then(|c| c.finish_reason.as_deref());
if finish == Some("length") {
let retry_body = build_request_body(
model,
base,
&frames,
&tag,
reasoning_effort,
LENGTH_RETRY_MAX_TOKENS,
);
outcome = send_round(client, key, &retry_body).await;
round_billed += usage_from(outcome.envelope.as_ref()).cost.unwrap_or(0.0);
if outcome.envelope.is_none() {
invalid_reason = Some(
outcome
.error_class
.clone()
.unwrap_or_else(|| "http_error".to_string()),
);
} else if outcome.tool_call_step != Some(next_step) {
invalid_reason = Some("no_tool_call".to_string());
}
} else {
invalid_reason = Some("no_tool_call".to_string());
}
}
}
}
let tool_call_id = outcome
.tool_call_id
.clone()
.unwrap_or_else(|| format!("call_{r}"));
let reasoning = outcome.reasoning.clone();
let usage = usage_from(outcome.envelope.as_ref());
if !outcome.response_cache_hit {
cached_observations.push(usage.cached_tokens.unwrap_or(0));
}
if r == 0 {
round0_prompt_tokens = usage.prompt_tokens;
}
let base_pt = round0_prompt_tokens.or(w2_prompt_tokens).unwrap_or(0);
let expected =
expected_cached_for_round(base_cached, usage.prompt_tokens.unwrap_or(0), base_pt);
let classification = if invalid_reason.is_some() {
CacheClassification::Invalid
} else {
classify_round(usage.cached_tokens.unwrap_or(0), expected)
};
let classification_str = classification.as_str();
classifications.push(classification);
nominal_gaps.push(nominal_gap_secs.unwrap_or(0.0));
let (over_guard, over_cap) = context.budget.record(&tag, round_billed);
if over_guard {
break;
}
if over_cap {
let total = context.budget.total();
let reason = format!(
"spend cap exceeded (${total:.4} > ${:.4})",
context.budget.cap_usd
);
context.abort.cancel();
*context.abort_reason.lock().unwrap_poison() = Some(reason.clone());
break;
}
if invalid_reason.is_none() {
frames.push(ToolFrame {
id: tool_call_id,
step: next_step,
reasoning,
});
}
eprintln!(
"bench [{tag}] rung {r} gap={}s {}",
nominal_gap_secs.unwrap_or(0.0),
invalid_reason.as_deref().unwrap_or(classification_str),
);
if r < ladder_secs.len() {
tokio::select! {
_ = tokio::time::sleep(Duration::from_secs(ladder_secs[r])) => {}
_ = tokio::time::sleep_until(tokio::time::Instant::from_std(context.deadline)) => {
break;
}
_ = context.abort.cancelled() => {
break;
}
}
}
}
let any_cached = cached_observations.iter().any(|&c| c > 0);
let cache_hold_bucket = cache_hold_result(any_cached, &classifications, &nominal_gaps);
tracing::debug!(
tag = %tag,
elapsed_ms = run_started.elapsed().as_millis(),
cache_hold_bucket = %cache_hold_bucket,
"provider run finished"
);
ProviderRun {
tag,
cache_hold_bucket,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn base() -> BasePrompt {
BasePrompt {
system: "sys".to_string(),
user: "user text".to_string(),
}
}
#[test]
fn build_messages_shape_and_reasoning_spread() {
let frames = vec![
ToolFrame {
id: "call_0".to_string(),
step: 0,
reasoning: None,
},
ToolFrame {
id: "call_1".to_string(),
step: 1,
reasoning: Some(json!({
"reasoning_content": "rc",
"reasoning": "r",
"reasoning_details": {"x": 1},
})),
},
];
let msgs = build_messages(&base(), &frames);
assert_eq!(msgs.len(), 6);
assert_eq!(msgs[0], json!({"role": "system", "content": "sys"}));
assert_eq!(msgs[1], json!({"role": "user", "content": "user text"}));
assert_eq!(msgs[2]["role"], "assistant");
assert_eq!(msgs[2]["tool_calls"][0]["id"], "call_0");
assert_eq!(msgs[2]["tool_calls"][0]["type"], "function");
assert_eq!(
msgs[2]["tool_calls"][0]["function"]["arguments"],
"{\"step\":0}"
);
assert!(msgs[2].get("reasoning_content").is_none());
assert_eq!(msgs[3]["role"], "tool");
assert_eq!(msgs[3]["tool_call_id"], "call_0");
assert_eq!(msgs[3]["content"], "step 0 acknowledged; proceed to step 1");
assert_eq!(msgs[4]["role"], "assistant");
assert_eq!(msgs[4]["reasoning_content"], "rc");
assert_eq!(msgs[4]["reasoning"], "r");
assert_eq!(msgs[4]["reasoning_details"]["x"], 1);
assert_eq!(
msgs[4]["tool_calls"][0]["function"]["arguments"],
"{\"step\":1}"
);
assert_eq!(msgs[5]["content"], "step 1 acknowledged; proceed to step 2");
}
#[test]
fn build_request_body_pins_and_omits_effort_and_tool_choice() {
let body = build_request_body("acme/model-1", &base(), &[], "acme/fp8", None, 128);
assert_eq!(body["model"], "acme/model-1");
assert_eq!(body["max_tokens"], 128);
assert!(body.get("reasoning_effort").is_none());
assert!(
body.get("tool_choice").is_none(),
"tool_choice must be omitted (providers default to auto)"
);
assert_eq!(
body["provider"],
json!({"order": ["acme/fp8"], "allow_fallbacks": false})
);
assert_eq!(body["tools"][0]["function"]["name"], "fast_tool");
let with_effort = build_request_body("m", &base(), &[], "t", Some("low"), 256);
assert_eq!(with_effort["reasoning_effort"], "low");
assert!(with_effort.get("tool_choice").is_none());
}
#[test]
fn generate_filler_deterministic_and_sized() {
let a = generate_filler(4000);
let b = generate_filler(4000);
assert_eq!(a, b);
assert!(a.len() >= 4000, "filler too short: {}", a.len());
assert!(a.len() <= 4400, "filler overshoot: {}", a.len());
assert_eq!(a.lines().next(), Some("000000 alpha"));
assert!(a.contains('\n'));
}
#[test]
fn raw_envelope_parse_fixture() {
const FIXTURE: &str = r#"{
"id": "gen-abc123",
"created": 1755000000,
"model": "acme/model-1",
"provider": "Acme Cloud",
"system_fingerprint": "fp-1",
"service_tier": "default",
"is_byok": false,
"openrouter_metadata": {"elapsed": 42},
"usage": {
"prompt_tokens": 16000,
"completion_tokens": 12,
"total_tokens": 16012,
"prompt_tokens_details": {"cached_tokens": 15800, "cache_write_tokens": 200},
"completion_tokens_details": {"reasoning_tokens": 4},
"prompt_cache_hit_tokens": 15800,
"prompt_cache_miss_tokens": 200,
"cost": 0.0001234,
"cost_details": {"prompt": 0.0001, "completion": 0.0000234}
},
"choices": [{
"finish_reason": "tool_calls",
"message": {
"content": null,
"tool_calls": [{
"id": "call_0",
"type": "function",
"function": {"name": "fast_tool", "arguments": "{\"step\":0}"}
}],
"reasoning_content": "thinking",
"reasoning": "more",
"reasoning_details": {"tokens": 4}
}
}]
}"#;
let env: RawEnvelope = serde_json::from_str(FIXTURE).expect("fixture parses");
assert_eq!(env.provider.as_deref(), Some("Acme Cloud"));
let usage = env.usage.as_ref().expect("usage");
assert_eq!(usage.cost, Some(0.000_123_4));
let details = usage.prompt_tokens_details.as_ref().expect("details");
assert_eq!(details.cached_tokens, Some(15800));
let choice = &env.choices[0];
assert_eq!(choice.finish_reason.as_deref(), Some("tool_calls"));
let call = choice.message.tool_calls.as_ref().expect("tool_calls")[0].clone();
assert_eq!(
call.function.as_ref().and_then(|f| f.name.as_deref()),
Some("fast_tool")
);
assert_eq!(
call.function.as_ref().and_then(|f| f.arguments.as_deref()),
Some("{\"step\":0}")
);
let (step, id, reasoning) = extract_tool_call(Some(&env));
assert_eq!(step, Some(0));
assert_eq!(id.as_deref(), Some("call_0"));
let reasoning = reasoning.expect("reasoning combined");
assert_eq!(reasoning["reasoning_content"], "thinking");
assert_eq!(reasoning["reasoning"], "more");
assert_eq!(reasoning["reasoning_details"]["tokens"], 4);
}
#[test]
fn retry_sleep_clamp_boundaries() {
let s429 = reqwest::StatusCode::TOO_MANY_REQUESTS;
assert_eq!(retry_sleep_ms(s429, None), 5000);
assert_eq!(retry_sleep_ms(s429, Some(1000)), 5000);
assert_eq!(retry_sleep_ms(s429, Some(30_000)), 30_000);
assert_eq!(retry_sleep_ms(s429, Some(120_000)), 60_000);
assert_eq!(
retry_sleep_ms(reqwest::StatusCode::INTERNAL_SERVER_ERROR, None),
5000
);
}
#[test]
fn classify_http_error_mapping() {
assert_eq!(
classify_http_error(reqwest::StatusCode::UNAUTHORIZED),
Some("auth")
);
assert_eq!(
classify_http_error(reqwest::StatusCode::PAYMENT_REQUIRED),
Some("quota")
);
assert_eq!(
classify_http_error(reqwest::StatusCode::TOO_MANY_REQUESTS),
None
);
assert_eq!(
classify_http_error(reqwest::StatusCode::INTERNAL_SERVER_ERROR),
None
);
assert_eq!(
classify_http_error(reqwest::StatusCode::BAD_REQUEST),
Some("http_4xx")
);
}
#[test]
fn canonical_messages_json_deterministic() {
let msgs = build_messages(&base(), &[]);
let a = canonical_messages_json(&msgs);
let b = canonical_messages_json(&msgs);
assert_eq!(a, b);
assert!(a.contains("\"content\":\"sys\""));
assert!(a.contains("\"role\":\"system\""));
let body = build_request_body("m", &base(), &[], "t", None, 128);
assert_eq!(
canonical_messages_json(body["messages"].as_array().expect("messages")),
a
);
}
}