use serde_json::{Value, json};
use serial_test::serial;
use trusty_common::credentials::{KeyStore, MemoryKeyStore};
use trusty_common::inference::providers::{
anthropic, atlascloud, fireworks, openai, openrouter, together,
};
use trusty_common::inference::test_support::MockInferenceServer;
use trusty_common::inference::{
CacheControl, ChatMessage, ChatRequest, Configurator, FunctionDefinition, InferenceError,
ProviderId, ResolvedProvider, ToolChoice, ToolDefinition, register_default_factories,
};
fn clear_provider_env() {
for var in [
"OPENROUTER_API_KEY",
"ANTHROPIC_API_KEY",
"OPENAI_API_KEY",
"FIREWORKS_API_KEY",
"TOGETHER_API_KEY",
"ATLASCLOUD_API_KEY",
] {
unsafe { std::env::remove_var(var) };
}
}
fn text_response_body() -> Value {
json!({
"id": "gen-mock",
"model": "served/model",
"choices": [{
"message": {"role": "assistant", "content": "pong"},
"finish_reason": "stop"
}],
"usage": {"prompt_tokens": 7, "completion_tokens": 1, "total_tokens": 8}
})
}
#[tokio::test]
#[serial(dotenv_credential_env)]
async fn openrouter_translates_request_and_parses_response() {
clear_provider_env();
let server = MockInferenceServer::spawn(200, text_response_body())
.await
.expect("spawn mock");
let base = server.url().to_string();
let store = MemoryKeyStore::new();
store.set("openrouter", "sk-or-fake").unwrap();
let mut cfg = Configurator::new();
cfg.register(
ProviderId::OpenRouter,
Box::new(move |r: &ResolvedProvider| openrouter::build(r, &base)),
);
let adapter = cfg.build("openai/gpt-4o-mini", &store).expect("build");
assert_eq!(adapter.name(), "openrouter");
let req = ChatRequest::new("openai/gpt-4o-mini", vec![ChatMessage::user("ping")]);
let resp = adapter.chat(&req).await.expect("chat ok");
assert_eq!(resp.first_text().as_deref(), Some("pong"));
assert_eq!(resp.usage().total_tokens(), 8);
let sent = server.last_request().expect("captured request");
assert_eq!(sent.method, "POST");
assert_eq!(sent.path, "/chat/completions");
assert_eq!(sent.header("authorization"), Some("Bearer sk-or-fake")); assert_eq!(
sent.header("http-referer"),
Some("https://github.com/bobmatnyc/trusty-tools")
);
assert_eq!(sent.header("x-title"), Some("trusty-tools"));
let body = sent.body.expect("json body");
assert_eq!(body["model"], "openai/gpt-4o-mini");
assert_eq!(body["messages"][0]["content"], "ping");
assert_eq!(body["usage"], json!({"include": true}));
}
#[tokio::test]
#[serial(dotenv_credential_env)]
async fn openrouter_cache_accounting_and_passthrough() {
clear_provider_env();
let body = json!({
"id": "gen-cache",
"choices": [{"message": {"role": "assistant", "content": "ok"}, "finish_reason": "stop"}],
"usage": {
"prompt_tokens": 1200,
"completion_tokens": 40,
"cost": 0.0021,
"prompt_tokens_details": {"cached_tokens": 900, "cache_write_tokens": 300}
}
});
let server = MockInferenceServer::spawn(200, body).await.expect("spawn");
let base = server.url().to_string();
let store = MemoryKeyStore::new();
store.set("openrouter", "sk-or-fake").unwrap(); let mut cfg = Configurator::new();
cfg.register(
ProviderId::OpenRouter,
Box::new(move |r: &ResolvedProvider| openrouter::build(r, &base)),
);
let adapter = cfg
.build("anthropic/claude-sonnet-4-5", &store)
.expect("build");
let mut sys = ChatMessage::system("cache me");
sys.cache_control = Some(CacheControl::ephemeral());
let req = ChatRequest::new(
"anthropic/claude-sonnet-4-5",
vec![sys, ChatMessage::user("go")],
);
let resp = adapter.chat(&req).await.expect("chat ok");
let usage = resp.usage();
assert_eq!(usage.cache_read_tokens, 900);
assert_eq!(usage.cache_creation_tokens, 300);
assert_eq!(usage.cost_usd, Some(0.0021));
let sent = server.last_request().expect("captured");
let body = sent.body.expect("json");
assert_eq!(
body["messages"][0]["content"],
json!([{
"type": "text",
"text": "cache me",
"cache_control": {"type": "ephemeral"}
}])
);
}
#[tokio::test]
#[serial(dotenv_credential_env)]
async fn fireworks_tool_call_round_trip_no_usage_directive() {
clear_provider_env();
let body = json!({
"id": "gen-fw",
"choices": [{
"message": {
"role": "assistant",
"content": null,
"tool_calls": [{
"id": "call_1",
"type": "function",
"function": {"name": "get_weather", "arguments": "{\"loc\":\"SEA\"}"}
}]
},
"finish_reason": "tool_calls"
}],
"usage": {"prompt_tokens": 20, "completion_tokens": 8}
});
let server = MockInferenceServer::spawn(200, body).await.expect("spawn");
let base = server.url().to_string();
let store = MemoryKeyStore::new();
store.set("fireworks", "fw-fake").unwrap(); let mut cfg = Configurator::new();
cfg.register(
ProviderId::Fireworks,
Box::new(move |r: &ResolvedProvider| fireworks::build(r, &base)),
);
let adapter = cfg
.build("fireworks/llama-v3p1-8b-instruct", &store)
.expect("build");
assert_eq!(adapter.name(), "fireworks");
let mut req = ChatRequest::new(
"accounts/fireworks/models/llama-v3p1-8b-instruct",
vec![ChatMessage::user("weather in SEA?")],
);
req.tools = Some(vec![ToolDefinition::function(FunctionDefinition {
name: "get_weather".into(),
description: Some("look up weather".into()),
parameters: Some(json!({"type": "object"})),
cache_control: None,
})]);
req.tool_choice = Some(adapter.map_tool_choice(ToolChoice::Auto));
let resp = adapter.chat(&req).await.expect("chat ok");
let calls = resp.first_tool_calls();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].function.name, "get_weather");
let sent = server.last_request().expect("captured");
assert_eq!(sent.header("authorization"), Some("Bearer fw-fake")); let body = sent.body.expect("json");
assert_eq!(body["tools"][0]["function"]["name"], "get_weather");
assert_eq!(body["tool_choice"], "auto");
assert!(
body.get("usage").is_none() || body["usage"].is_null(),
"Fireworks must not send the detailed-usage directive: {body}"
);
}
#[tokio::test]
#[serial(dotenv_credential_env)]
async fn openai_direct_sends_bare_body() {
clear_provider_env();
let server = MockInferenceServer::spawn(200, text_response_body())
.await
.expect("spawn");
let base = server.url().to_string();
let store = MemoryKeyStore::new();
store.set("openai", "sk-openai-fake").unwrap(); let mut cfg = Configurator::new();
cfg.register(
ProviderId::OpenAI,
Box::new(move |r: &ResolvedProvider| openai::build(r, &base)),
);
let adapter = cfg.build("openai/gpt-4o-mini", &store).expect("build");
assert_eq!(adapter.name(), "openai");
let req = ChatRequest::new("gpt-4o-mini", vec![ChatMessage::user("ping")]);
let resp = adapter.chat(&req).await.expect("chat ok");
assert_eq!(resp.first_text().as_deref(), Some("pong"));
let sent = server.last_request().expect("captured");
assert_eq!(sent.header("authorization"), Some("Bearer sk-openai-fake")); assert!(sent.header("http-referer").is_none());
let body = sent.body.expect("json");
assert!(
body.get("usage").is_none() || body["usage"].is_null(),
"OpenAI-direct must not send the detailed-usage directive"
);
}
#[tokio::test]
#[serial(dotenv_credential_env)]
async fn together_tool_call_round_trip_no_usage_directive() {
clear_provider_env();
let body = json!({
"id": "gen-together",
"choices": [{
"message": {
"role": "assistant",
"content": null,
"tool_calls": [{
"id": "call_1",
"type": "function",
"function": {"name": "get_weather", "arguments": "{\"loc\":\"SEA\"}"}
}]
},
"finish_reason": "tool_calls"
}],
"usage": {"prompt_tokens": 20, "completion_tokens": 8}
});
let server = MockInferenceServer::spawn(200, body).await.expect("spawn");
let base = server.url().to_string();
let store = MemoryKeyStore::new();
store.set("together", "tgp_v1_fake").unwrap(); let mut cfg = Configurator::new();
cfg.register(
ProviderId::Together,
Box::new(move |r: &ResolvedProvider| together::build(r, &base)),
);
let adapter = cfg
.build("together/meta-llama/Llama-3.3-70B-Instruct-Turbo", &store)
.expect("build");
assert_eq!(adapter.name(), "together");
assert_eq!(adapter.capabilities().id, ProviderId::Together);
let mut req = ChatRequest::new(
"meta-llama/Llama-3.3-70B-Instruct-Turbo",
vec![ChatMessage::user("weather in SEA?")],
);
req.tools = Some(vec![ToolDefinition::function(FunctionDefinition {
name: "get_weather".into(),
description: Some("look up weather".into()),
parameters: Some(json!({"type": "object"})),
cache_control: None,
})]);
req.tool_choice = Some(adapter.map_tool_choice(ToolChoice::Auto));
let resp = adapter.chat(&req).await.expect("chat ok");
let calls = resp.first_tool_calls();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].function.name, "get_weather");
assert_eq!(resp.usage().total_tokens(), 28);
let sent = server.last_request().expect("captured");
assert_eq!(sent.method, "POST");
assert_eq!(sent.path, "/chat/completions");
assert_eq!(sent.header("authorization"), Some("Bearer tgp_v1_fake")); assert!(sent.header("http-referer").is_none());
let body = sent.body.expect("json");
assert_eq!(body["model"], "meta-llama/Llama-3.3-70B-Instruct-Turbo");
assert_eq!(body["tools"][0]["function"]["name"], "get_weather");
assert_eq!(body["tool_choice"], "auto");
assert!(
body.get("usage").is_none() || body["usage"].is_null(),
"Together must not send the detailed-usage directive: {body}"
);
}
#[tokio::test]
#[serial(dotenv_credential_env)]
async fn atlascloud_round_trip_no_usage_directive() {
clear_provider_env();
let server = MockInferenceServer::spawn(200, text_response_body())
.await
.expect("spawn");
let base = server.url().to_string();
let store = MemoryKeyStore::new();
store.set("atlascloud", "ac_fake").unwrap(); let mut cfg = Configurator::new();
cfg.register(
ProviderId::AtlasCloud,
Box::new(move |r: &ResolvedProvider| atlascloud::build(r, &base)),
);
let adapter = cfg
.build("atlascloud/openai/gpt-5.6-sol", &store)
.expect("build");
assert_eq!(adapter.name(), "atlascloud");
assert_eq!(adapter.capabilities().id, ProviderId::AtlasCloud);
let req = ChatRequest::new("openai/gpt-5.6-sol", vec![ChatMessage::user("ping")]);
let resp = adapter.chat(&req).await.expect("chat ok");
assert_eq!(resp.first_text().as_deref(), Some("pong"));
let sent = server.last_request().expect("captured");
assert_eq!(sent.method, "POST");
assert_eq!(sent.path, "/chat/completions");
assert_eq!(sent.header("authorization"), Some("Bearer ac_fake")); assert!(sent.header("http-referer").is_none());
let body = sent.body.expect("json");
assert_eq!(body["model"], "openai/gpt-5.6-sol");
assert!(
body.get("usage").is_none() || body["usage"].is_null(),
"AtlasCloud must not send the detailed-usage directive: {body}"
);
}
fn anthropic_text_response_body() -> Value {
json!({
"id": "msg_mock",
"type": "message",
"role": "assistant",
"model": "claude-sonnet-4-5",
"content": [{"type": "text", "text": "pong"}],
"stop_reason": "end_turn",
"usage": {"input_tokens": 12, "output_tokens": 3}
})
}
#[tokio::test]
#[serial(dotenv_credential_env)]
async fn anthropic_direct_translates_messages_api_request() {
clear_provider_env();
let server = MockInferenceServer::spawn(200, anthropic_text_response_body())
.await
.expect("spawn mock");
let base = server.url().to_string();
let store = MemoryKeyStore::new();
store.set("anthropic", "sk-ant-fake").unwrap(); let mut cfg = Configurator::new();
cfg.register(
ProviderId::Anthropic,
Box::new(move |r: &ResolvedProvider| anthropic::build(r, &base)),
);
let adapter = cfg
.build("anthropic/claude-sonnet-4-5", &store)
.expect("build");
assert_eq!(adapter.name(), "anthropic");
assert_eq!(adapter.capabilities().id, ProviderId::Anthropic);
let req = ChatRequest::new(
"anthropic/claude-sonnet-4-5",
vec![ChatMessage::system("be terse"), ChatMessage::user("ping")],
);
let resp = adapter.chat(&req).await.expect("chat ok");
assert_eq!(resp.first_text().as_deref(), Some("pong"));
assert_eq!(resp.usage().prompt_tokens, 12);
assert_eq!(resp.usage().completion_tokens, 3);
let sent = server.last_request().expect("captured request");
assert_eq!(sent.method, "POST");
assert_eq!(sent.path, "/messages");
assert_eq!(sent.header("x-api-key"), Some("sk-ant-fake")); assert_eq!(sent.header("anthropic-version"), Some("2023-06-01"));
assert!(sent.header("authorization").is_none());
let body = sent.body.expect("json body");
assert_eq!(body["model"], "claude-sonnet-4-5");
assert_eq!(body["system"], "be terse");
assert!(body["max_tokens"].is_number());
assert_eq!(body["messages"].as_array().unwrap().len(), 1);
assert_eq!(body["messages"][0]["role"], "user");
assert_eq!(body["messages"][0]["content"][0]["text"], "ping");
}
#[tokio::test]
#[serial(dotenv_credential_env)]
async fn anthropic_direct_parses_native_usage_and_tool_use() {
clear_provider_env();
let body = json!({
"id": "msg_tool",
"type": "message",
"role": "assistant",
"model": "claude-sonnet-4-5",
"content": [
{"type": "tool_use", "id": "toolu_1", "name": "get_weather",
"input": {"loc": "SEA"}}
],
"stop_reason": "tool_use",
"usage": {
"input_tokens": 1200,
"output_tokens": 40,
"cache_read_input_tokens": 900,
"cache_creation_input_tokens": 300
}
});
let server = MockInferenceServer::spawn(200, body).await.expect("spawn");
let base = server.url().to_string();
let store = MemoryKeyStore::new();
store.set("anthropic", "sk-ant-fake").unwrap(); let mut cfg = Configurator::new();
cfg.register(
ProviderId::Anthropic,
Box::new(move |r: &ResolvedProvider| anthropic::build(r, &base)),
);
let adapter = cfg
.build("anthropic/claude-sonnet-4-5", &store)
.expect("build");
let mut sys = ChatMessage::system("cache me");
sys.cache_control = Some(CacheControl::ephemeral());
let mut req = ChatRequest::new(
"anthropic/claude-sonnet-4-5",
vec![sys, ChatMessage::user("weather?")],
);
req.tools = Some(vec![ToolDefinition::function(FunctionDefinition {
name: "get_weather".into(),
description: Some("look up weather".into()),
parameters: Some(json!({"type": "object"})),
cache_control: None,
})]);
req.tool_choice = Some(adapter.map_tool_choice(ToolChoice::Auto));
let resp = adapter.chat(&req).await.expect("chat ok");
let usage = resp.usage();
assert_eq!(usage.prompt_tokens, 1200);
assert_eq!(usage.completion_tokens, 40);
assert_eq!(usage.cache_read_tokens, 900);
assert_eq!(usage.cache_creation_tokens, 300);
let calls = resp.first_tool_calls();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].function.name, "get_weather");
let sent = server.last_request().expect("captured");
let body = sent.body.expect("json");
assert_eq!(
body["system"],
json!([{
"type": "text",
"text": "cache me",
"cache_control": {"type": "ephemeral"}
}])
);
assert_eq!(body["tools"][0]["name"], "get_weather");
assert_eq!(body["tools"][0]["input_schema"]["type"], "object");
assert_eq!(body["tool_choice"], json!({"type": "auto"}));
}
#[tokio::test]
#[serial(dotenv_credential_env)]
async fn openai_direct_receives_unprefixed_model_id_on_the_wire() {
clear_provider_env();
let server = MockInferenceServer::spawn(200, text_response_body())
.await
.expect("spawn");
let base = server.url().to_string();
let store = MemoryKeyStore::new();
store.set("openai", "sk-openai-fake").unwrap(); let mut cfg = Configurator::new();
cfg.register(
ProviderId::OpenAI,
Box::new(move |r: &ResolvedProvider| openai::build(r, &base)),
);
let slug = "openai/gpt-4o-mini"; let adapter = cfg.build(slug, &store).expect("build");
assert_eq!(adapter.name(), "openai");
let req = ChatRequest::new(slug, vec![ChatMessage::user("ping")]);
adapter.chat(&req).await.expect("chat ok");
let body = server.last_request().expect("captured").body.expect("json");
assert_eq!(
body["model"], "gpt-4o-mini",
"OpenAI-direct must receive the unprefixed model id: {body}"
);
}
#[tokio::test]
#[serial(dotenv_credential_env)]
async fn openrouter_receives_the_full_slug_on_the_wire() {
clear_provider_env();
let server = MockInferenceServer::spawn(200, text_response_body())
.await
.expect("spawn");
let base = server.url().to_string();
let store = MemoryKeyStore::new();
store.set("openrouter", "sk-or-fake").unwrap(); let mut cfg = Configurator::new();
cfg.register(
ProviderId::OpenRouter,
Box::new(move |r: &ResolvedProvider| openrouter::build(r, &base)),
);
for slug in ["openai/gpt-4o-mini", "openrouter/auto"] {
let adapter = cfg.build(slug, &store).expect("build");
assert_eq!(adapter.name(), "openrouter");
let req = ChatRequest::new(slug, vec![ChatMessage::user("ping")]);
adapter.chat(&req).await.expect("chat ok");
let body = server.last_request().expect("captured").body.expect("json");
assert_eq!(
body["model"], slug,
"OpenRouter must transmit the FULL slug verbatim: {body}"
);
}
}
#[tokio::test]
#[serial(dotenv_credential_env)]
async fn together_direct_receives_unprefixed_model_id_on_the_wire() {
clear_provider_env();
let server = MockInferenceServer::spawn(200, text_response_body())
.await
.expect("spawn");
let base = server.url().to_string();
let store = MemoryKeyStore::new();
store.set("together", "tg-fake").unwrap(); let mut cfg = Configurator::new();
cfg.register(
ProviderId::Together,
Box::new(move |r: &ResolvedProvider| together::build(r, &base)),
);
let slug = "together/meta-llama/Llama-3.3-70B-Instruct-Turbo";
let adapter = cfg.build(slug, &store).expect("build");
assert_eq!(adapter.name(), "together");
let req = ChatRequest::new(slug, vec![ChatMessage::user("ping")]);
adapter.chat(&req).await.expect("chat ok");
let body = server.last_request().expect("captured").body.expect("json");
assert_eq!(
body["model"], "meta-llama/Llama-3.3-70B-Instruct-Turbo",
"Together must receive its catalog id with only the routing marker removed: {body}"
);
}
#[tokio::test]
#[serial(dotenv_credential_env)]
async fn http_429_maps_to_retryable_api_error() {
clear_provider_env();
let server = MockInferenceServer::spawn(429, json!({"error": "rate limited"}))
.await
.expect("spawn");
let base = server.url().to_string();
let store = MemoryKeyStore::new();
store.set("openrouter", "sk-or-fake").unwrap(); let mut cfg = Configurator::new();
cfg.register(
ProviderId::OpenRouter,
Box::new(move |r: &ResolvedProvider| openrouter::build(r, &base)),
);
let adapter = cfg.build("x/y", &store).expect("build");
let req = ChatRequest::new("x/y", vec![ChatMessage::user("hi")]);
let Err(err) = adapter.chat(&req).await else {
panic!("expected an API error");
};
assert!(matches!(err, InferenceError::Api { status: 429, .. }));
assert!(err.is_retryable());
assert!(!err.is_alarm());
}
#[test]
#[serial(dotenv_credential_env)]
fn default_factories_register_openai_dialect() {
clear_provider_env();
let store = MemoryKeyStore::new();
store.set("openrouter", "sk-or-fake").unwrap(); store.set("together", "tgp_v1_fake").unwrap(); store.set("atlascloud", "ac_fake").unwrap();
let mut cfg = Configurator::new();
register_default_factories(&mut cfg);
let adapter = cfg.build("some/model", &store).expect("build openrouter");
assert_eq!(adapter.name(), "openrouter");
let together = cfg
.build("together/meta-llama/Llama-3.3-70B-Instruct-Turbo", &store)
.expect("build together");
assert_eq!(together.name(), "together");
let atlascloud = cfg
.build("atlascloud/openai/gpt-5.6-sol", &store)
.expect("build atlascloud");
assert_eq!(atlascloud.name(), "atlascloud");
assert_eq!(atlascloud.capabilities().id, ProviderId::AtlasCloud);
let local = cfg.build("local/llama3.1", &store).expect("build local");
assert_eq!(local.name(), "local");
assert_eq!(local.capabilities().id, ProviderId::Local);
let ollama = cfg.build("ollama/qwen3:30b", &store).expect("build ollama");
assert_eq!(ollama.name(), "local");
let Err(err) = cfg.build("bedrock/us.anthropic.claude-sonnet-4-5", &store) else {
panic!("expected NoAdapterRegistered for Bedrock");
};
assert!(matches!(
err,
InferenceError::NoAdapterRegistered {
provider: ProviderId::Bedrock
}
));
}
#[test]
#[serial(dotenv_credential_env)]
fn default_factories_register_anthropic_direct() {
clear_provider_env();
let store = MemoryKeyStore::new();
store.set("anthropic", "sk-ant-fake").unwrap();
let mut cfg = Configurator::new();
register_default_factories(&mut cfg);
let anthropic = cfg
.build("anthropic/claude-sonnet-4-5", &store)
.expect("build anthropic");
assert_eq!(anthropic.name(), "anthropic");
assert_eq!(anthropic.capabilities().id, ProviderId::Anthropic);
assert_eq!(
anthropic.map_tool_choice(ToolChoice::Required),
serde_json::json!({"type": "any"})
);
}
const SSE_STREAM: &str = "\
data: {\"choices\":[{\"delta\":{\"content\":\"Hel\"}}]}\n\n\
data: {\"choices\":[{\"delta\":{\"content\":\"lo\"}}]}\n\n\
data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n\
data: {\"choices\":[],\"usage\":{\"prompt_tokens\":7,\"completion_tokens\":2}}\n\n\
data: [DONE]\n\n";
struct SseServer {
url: String,
shutdown: Option<tokio::sync::oneshot::Sender<()>>,
handle: tokio::task::JoinHandle<()>,
}
impl Drop for SseServer {
fn drop(&mut self) {
if let Some(tx) = self.shutdown.take() {
let _ = tx.send(());
}
self.handle.abort();
}
}
async fn spawn_sse_server(sse: &'static str) -> SseServer {
use axum::Router;
use axum::body::Body;
use axum::response::Response;
async fn handler(axum::extract::State(sse): axum::extract::State<&'static str>) -> Response {
let chunks: Vec<Result<Vec<u8>, std::convert::Infallible>> =
sse.as_bytes().chunks(8).map(|c| Ok(c.to_vec())).collect();
Response::builder()
.header("content-type", "text/event-stream")
.body(Body::from_stream(futures_util::stream::iter(chunks)))
.unwrap()
}
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let url = format!("http://{addr}");
let app = Router::new().fallback(handler).with_state(sse);
let (tx, rx) = tokio::sync::oneshot::channel::<()>();
let handle = tokio::spawn(async move {
let _ = axum::serve(listener, app)
.with_graceful_shutdown(async move {
let _ = rx.await;
})
.await;
});
SseServer {
url,
shutdown: Some(tx),
handle,
}
}
#[tokio::test]
#[serial(dotenv_credential_env)]
async fn openrouter_chat_stream_yields_incremental_deltas() {
use futures_util::StreamExt;
use trusty_common::inference::{ChatStreamEvent, StopReason};
clear_provider_env();
let server = spawn_sse_server(SSE_STREAM).await;
let base = server.url.clone();
let store = MemoryKeyStore::new();
store.set("openrouter", "sk-or-fake").unwrap(); let mut cfg = Configurator::new();
cfg.register(
ProviderId::OpenRouter,
Box::new(move |r: &ResolvedProvider| openrouter::build(r, &base)),
);
let adapter = cfg.build("openai/gpt-4o-mini", &store).expect("build");
let req = ChatRequest::new("openai/gpt-4o-mini", vec![ChatMessage::user("ping")]);
let stream = adapter
.chat_stream(&req)
.await
.expect("stream handshake ok");
let events: Vec<ChatStreamEvent> = stream.map(|r| r.expect("event ok")).collect().await;
let text: String = events
.iter()
.filter_map(|e| match e {
ChatStreamEvent::Delta(s) => Some(s.clone()),
_ => None,
})
.collect();
assert_eq!(text, "Hello");
match events.last().expect("terminal event") {
ChatStreamEvent::Done(done) => {
assert_eq!(done.usage.total_tokens(), 9);
assert_eq!(done.finish_reason, Some(StopReason::Stop));
}
other => panic!("expected terminal Done, got {other:?}"),
}
assert_eq!(
events
.iter()
.filter(|e| matches!(e, ChatStreamEvent::Done(_)))
.count(),
1,
"exactly one terminal event"
);
}
#[tokio::test]
#[serial(dotenv_credential_env)]
async fn chat_stream_surfaces_http_error_for_caller_retry() {
clear_provider_env();
let server = MockInferenceServer::spawn(400, json!({"error": {"message": "no stream"}}))
.await
.expect("spawn");
let base = server.url().to_string();
let store = MemoryKeyStore::new();
store.set("openrouter", "sk-or-fake").unwrap(); let mut cfg = Configurator::new();
cfg.register(
ProviderId::OpenRouter,
Box::new(move |r: &ResolvedProvider| openrouter::build(r, &base)),
);
let adapter = cfg.build("openai/gpt-4o-mini", &store).expect("build");
let req = ChatRequest::new("openai/gpt-4o-mini", vec![ChatMessage::user("ping")]);
match adapter.chat_stream(&req).await {
Err(InferenceError::Api { status: 400, .. }) => {}
Err(other) => panic!("expected retryable Api 400, got {other:?}"),
Ok(_) => panic!("must surface an error, not a degraded stream"),
}
let sent = server.last_request().expect("captured request");
assert_eq!(sent.body.expect("json body")["stream"], json!(true));
}
#[tokio::test]
#[serial(dotenv_credential_env)]
async fn chat_stream_degrades_when_body_not_sse() {
use futures_util::StreamExt;
use trusty_common::inference::ChatStreamEvent;
clear_provider_env();
let server = MockInferenceServer::spawn(200, text_response_body())
.await
.expect("spawn");
let base = server.url().to_string();
let store = MemoryKeyStore::new();
store.set("openrouter", "sk-or-fake").unwrap(); let mut cfg = Configurator::new();
cfg.register(
ProviderId::OpenRouter,
Box::new(move |r: &ResolvedProvider| openrouter::build(r, &base)),
);
let adapter = cfg.build("openai/gpt-4o-mini", &store).expect("build");
let req = ChatRequest::new("openai/gpt-4o-mini", vec![ChatMessage::user("ping")]);
let stream = adapter
.chat_stream(&req)
.await
.expect("non-SSE 200 must degrade to a buffered stream, not error");
let events: Vec<ChatStreamEvent> = stream.map(|r| r.expect("event ok")).collect().await;
let text: String = events
.iter()
.filter_map(|e| match e {
ChatStreamEvent::Delta(s) => Some(s.clone()),
_ => None,
})
.collect();
assert_eq!(text, "pong");
match events.last().expect("terminal event") {
ChatStreamEvent::Done(done) => assert_eq!(done.usage.total_tokens(), 8),
other => panic!("expected terminal Done, got {other:?}"),
}
}