1#![doc = include_str!(concat!(env!("OUT_DIR"), "/docs/generic.md"))]
2
3use reqwest::header::{AUTHORIZATION, HeaderMap, HeaderName, HeaderValue};
4use serde::Serialize;
5use std::borrow::Cow;
6use std::future::Future;
7use std::time::Duration;
8use utils::variables::Vars;
9
10use crate::catalog::Provider;
11use crate::provider::{display_name, validate_reasoning};
12use crate::providers::anthropic::mappers::build_request as build_messages_request;
13use crate::providers::anthropic::streaming::decode_line;
14use crate::providers::http::{SseData, anthropic_code, http_client, post_sse, responses_code};
15pub use crate::providers::openai::chat::ChatRequestPolicy;
16use crate::providers::openai::chat::build_chat_request;
17use crate::providers::openai::chat::streaming::decode_sse_chunk;
18use crate::providers::openai::chat::types::CompatibleChatRequest;
19use crate::providers::openai::responses::mappers::build_request as build_responses_request;
20use crate::providers::openai::responses::streaming::decode_responses;
21use crate::providers::openai::wire::PromptCacheKeySource;
22use crate::providers::response_stream::{OpenedStream, error_stream, response_stream};
23use crate::tool_schema::normalize_for_moonshot;
24use crate::{
25 Context, LlmError, LlmModel, LlmResponseStream, ProviderAuthMode, ProviderConnectionConfig, Result,
26 StreamingModelProvider,
27};
28
29pub use crate::providers::openai::responses::mappers::ResponsesRequestPolicy;
30
31pub struct ProviderConfig {
32 pub provider: Provider,
33 pub api_base: Option<&'static str>,
34 pub api_base_env: Option<&'static str>,
35 pub default_model: &'static str,
36 pub api: Api,
37}
38
39pub enum Api {
40 ChatCompletions(ChatRequestPolicy),
41 Responses(ResponsesRequestPolicy),
42 Messages,
43}
44
45pub const ANTHROPIC: ProviderConfig = ProviderConfig {
46 provider: Provider::Anthropic,
47 api_base: Some("https://api.anthropic.com"),
48 api_base_env: None,
49 default_model: "claude-sonnet-4-5-20250929",
50 api: Api::Messages,
51};
52
53pub const OPENAI: ProviderConfig = ProviderConfig {
54 provider: Provider::Openai,
55 api_base: Some("https://api.openai.com/v1"),
56 api_base_env: None,
57 default_model: "gpt-4.1",
58 api: Api::Responses(ResponsesRequestPolicy::OPENAI),
59};
60
61pub const XIAOMI: ProviderConfig = ProviderConfig {
62 provider: Provider::Xiaomi,
63 api_base: Some("https://api.xiaomimimo.com/v1"),
64 api_base_env: None,
65 default_model: "mimo-v2.6-pro",
66 api: Api::Responses(ResponsesRequestPolicy::XIAOMI),
67};
68
69pub const DEEPSEEK: ProviderConfig = ProviderConfig {
70 provider: Provider::DeepSeek,
71 api_base: Some("https://api.deepseek.com"),
72 api_base_env: None,
73 default_model: "deepseek-v4-flash",
74 api: Api::ChatCompletions(ChatRequestPolicy::DEFAULT),
75};
76
77pub const MOONSHOT: ProviderConfig = ProviderConfig {
78 provider: Provider::Moonshot,
79 api_base: Some("https://api.moonshot.ai/v1"),
80 api_base_env: None,
81 default_model: "moonshot-v1-8k",
82 api: Api::ChatCompletions(ChatRequestPolicy {
83 tool_schema_transform: Some(normalize_for_moonshot),
84 ..ChatRequestPolicy::DEFAULT
85 }),
86};
87
88pub const ZAI: ProviderConfig = ProviderConfig {
89 provider: Provider::ZAi,
90 api_base: Some("https://api.z.ai/api/coding/paas/v4"),
91 api_base_env: None,
92 default_model: "GLM-4.6",
93 api: Api::ChatCompletions(ChatRequestPolicy::DEFAULT),
94};
95
96pub const AZURE_FOUNDRY: ProviderConfig = ProviderConfig {
97 provider: Provider::AzureFoundry,
98 api_base: None,
99 api_base_env: None,
100 default_model: "gpt-5.5",
101 api: Api::ChatCompletions(ChatRequestPolicy {
102 prompt_cache_key: PromptCacheKeySource::Prefix,
103 ..ChatRequestPolicy::DEFAULT
104 }),
105};
106
107pub const FIREWORKS: ProviderConfig = ProviderConfig {
108 provider: Provider::Fireworks,
109 api_base: Some("https://api.fireworks.ai/inference/v1"),
110 api_base_env: None,
111 default_model: "accounts/fireworks/models/glm-5p1",
112 api: Api::ChatCompletions(ChatRequestPolicy {
113 prompt_cache_key: PromptCacheKeySource::SessionAffinity,
114 ..ChatRequestPolicy::DEFAULT
115 }),
116};
117
118pub const GEMINI: ProviderConfig = ProviderConfig {
119 provider: Provider::Gemini,
120 api_base: Some("https://generativelanguage.googleapis.com/v1beta/openai"),
121 api_base_env: None,
122 default_model: "",
123 api: Api::ChatCompletions(ChatRequestPolicy::DEFAULT),
124};
125
126pub const OPENROUTER: ProviderConfig = ProviderConfig {
127 provider: Provider::OpenRouter,
128 api_base: Some("https://openrouter.ai/api/v1"),
129 api_base_env: None,
130 default_model: "",
131 api: Api::ChatCompletions(ChatRequestPolicy {
132 prompt_cache_key: PromptCacheKeySource::Prefix,
133 customize: Some(openrouter_request),
134 ..ChatRequestPolicy::DEFAULT
135 }),
136};
137
138pub const OLLAMA: ProviderConfig = ProviderConfig {
139 provider: Provider::Ollama,
140 api_base: Some("http://localhost:11434/v1"),
141 api_base_env: Some("OLLAMA_HOST"),
142 default_model: "",
143 api: Api::ChatCompletions(LOCAL_CHAT),
144};
145
146pub const LLAMACPP: ProviderConfig = ProviderConfig {
147 provider: Provider::LlamaCpp,
148 api_base: Some("http://localhost:8080/v1"),
149 api_base_env: Some("LLAMA_CPP_HOST"),
150 default_model: "",
151 api: Api::ChatCompletions(LOCAL_CHAT),
152};
153
154const LOCAL_CHAT: ChatRequestPolicy =
155 ChatRequestPolicy { tool_call_reasoning_placeholder: false, ..ChatRequestPolicy::DEFAULT };
156
157pub(crate) const BUILT_INS: &[&ProviderConfig] = &[
158 &ANTHROPIC,
159 &OPENAI,
160 &XIAOMI,
161 &DEEPSEEK,
162 &MOONSHOT,
163 &ZAI,
164 &AZURE_FOUNDRY,
165 &FIREWORKS,
166 &GEMINI,
167 &OPENROUTER,
168 &OLLAMA,
169 &LLAMACPP,
170];
171
172#[derive(Clone)]
174pub struct GenericProvider {
175 config: &'static ProviderConfig,
176 http: reqwest::Client,
177 api_base: String,
178 headers: HeaderMap,
179 model: String,
180 request_model: Option<String>,
181 idle_timeout: Duration,
182}
183
184impl GenericProvider {
185 pub fn from_env(config: &'static ProviderConfig) -> Result<Self> {
186 Self::from_env_with_connection(config, ProviderConnectionConfig::default())
187 }
188
189 pub fn from_env_with_connection(
190 config: &'static ProviderConfig,
191 connection: ProviderConnectionConfig,
192 ) -> Result<Self> {
193 Self::from_vars(config, connection, &Vars::new())
194 }
195
196 pub fn from_vars(
197 config: &'static ProviderConfig,
198 mut connection: ProviderConnectionConfig,
199 vars: &Vars,
200 ) -> Result<Self> {
201 let api_key = match (connection.auth_mode, config.provider.required_env_var()) {
202 (ProviderAuthMode::Default, Some(env_var)) => {
203 vars.get(env_var).ok_or_else(|| LlmError::MissingApiKey(env_var.to_string()))?.into_owned()
204 }
205 _ => String::new(),
206 };
207 connection.base_url = connection
208 .base_url
209 .or_else(|| config.api_base_env.and_then(|env_var| vars.get(env_var)).map(Cow::into_owned));
210 Self::new_with_connection(&api_key, config, connection)
211 }
212
213 pub fn new(api_key: &str, config: &'static ProviderConfig) -> Result<Self> {
214 Self::new_with_connection(api_key, config, ProviderConnectionConfig::default())
215 }
216
217 pub fn new_with_connection(
218 api_key: &str,
219 config: &'static ProviderConfig,
220 connection: ProviderConnectionConfig,
221 ) -> Result<Self> {
222 let api_base = connection
223 .base_url
224 .or_else(|| config.api_base.map(str::to_string))
225 .ok_or_else(|| LlmError::MissingProviderUrl { provider: config.provider.parser_name().to_string() })?;
226 let mut api_base = api_base.trim_end_matches('/').to_string();
227 if config.provider.is_local() && !api_base.ends_with("/v1") {
228 api_base.push_str("/v1");
229 }
230 Ok(Self {
231 config,
232 http: http_client(),
233 api_base,
234 headers: api_headers(&config.api, api_key, connection.auth_mode)?,
235 model: config.default_model.to_string(),
236 request_model: connection.request_model,
237 idle_timeout: connection.idle_timeout,
238 })
239 }
240
241 pub fn with_model(mut self, model: &str) -> Self {
242 self.model = model.to_string();
243 self
244 }
245}
246
247impl StreamingModelProvider for GenericProvider {
248 fn stream_response(&self, context: &Context) -> LlmResponseStream {
249 let identity = self.model();
250 let wire_model = self.request_model.as_deref().unwrap_or(&self.model);
251 let stream = validate_reasoning(context, identity.as_ref()).and_then(|()| match &self.config.api {
252 Api::ChatCompletions(policy) => self.stream_chat_completions(wire_model, context, policy),
253 Api::Responses(policy) => self.stream_responses(identity.as_ref(), wire_model, context, policy),
254 Api::Messages => self.stream_messages(wire_model, context),
255 });
256 stream.unwrap_or_else(error_stream)
257 }
258
259 fn display_name(&self) -> String {
260 display_name(self.config.provider, &self.model)
261 }
262
263 fn model(&self) -> Option<LlmModel> {
264 LlmModel::parse_for(self.config.provider, &self.model).ok()
265 }
266}
267
268impl GenericProvider {
269 fn stream_chat_completions(
270 &self,
271 wire_model: &str,
272 context: &Context,
273 policy: &ChatRequestPolicy,
274 ) -> Result<LlmResponseStream> {
275 let request = build_chat_request(wire_model, context, policy)?;
276 let opened = self.post("chat/completions", &request, responses_code);
277 Ok(response_stream(opened, |data, turn| decode_sse_chunk(&data, turn), self.idle_timeout))
278 }
279
280 fn stream_responses(
281 &self,
282 identity: Option<&LlmModel>,
283 wire_model: &str,
284 context: &Context,
285 policy: &ResponsesRequestPolicy,
286 ) -> Result<LlmResponseStream> {
287 let request = build_responses_request(identity, wire_model, context, policy)?;
288 Ok(response_stream(self.post("responses", &request, responses_code), decode_responses(), self.idle_timeout))
289 }
290
291 fn stream_messages(&self, wire_model: &str, context: &Context) -> Result<LlmResponseStream> {
292 let request = build_messages_request(wire_model, context)?;
293 let opened = self.post("v1/messages", &request, anthropic_code);
294 Ok(response_stream(opened, |data, turn| decode_line(&data, turn), self.idle_timeout))
295 }
296
297 fn post<T: Serialize>(
298 &self,
299 path: &str,
300 body: &T,
301 code: fn(&str) -> Option<String>,
302 ) -> impl Future<Output = Result<OpenedStream<SseData>>> + Send + use<T> {
303 post_sse(&self.http, &format!("{}/{path}", self.api_base), self.headers.clone(), body, code)
304 }
305}
306
307fn api_headers(api: &Api, api_key: &str, auth_mode: ProviderAuthMode) -> Result<HeaderMap> {
308 let mut headers = HeaderMap::new();
309 if let Api::Messages = api {
310 headers.insert("anthropic-version", HeaderValue::from_static("2023-06-01"));
311 }
312 if auth_mode == ProviderAuthMode::Default && !api_key.is_empty() {
313 let (name, value) = match api {
314 Api::ChatCompletions(_) | Api::Responses(_) => (AUTHORIZATION, format!("Bearer {api_key}")),
315 Api::Messages => (HeaderName::from_static("x-api-key"), api_key.to_string()),
316 };
317 let mut value = HeaderValue::from_str(&value)?;
318 value.set_sensitive(true);
319 headers.insert(name, value);
320 }
321 Ok(headers)
322}
323
324fn openrouter_request(request: &mut CompatibleChatRequest, context: &Context) {
325 request.extra.insert("usage".into(), serde_json::json!({ "include": true }));
326 request.extra.insert("cache_control".into(), serde_json::json!({ "type": "ephemeral" }));
327 if let Some(session_id) = context.session_affinity_key() {
328 request.extra.insert("session_id".into(), session_id.into());
329 }
330 if request.reasoning_effort == Some("none") {
331 request.reasoning_effort = None;
332 request.extra.insert("reasoning".into(), serde_json::json!({ "effort": "none" }));
333 }
334}
335
336#[cfg(test)]
337mod tests {
338 use futures::StreamExt;
339 use serde_json::json;
340
341 use super::*;
342 use crate::providers::test_capture_server::{CaptureServer, ProviderFixture, ResponseSpec, hello_context};
343 use crate::types::IsoString;
344 use crate::{
345 AssistantReasoning, ChatMessage, ContentBlock, EncryptedReasoningContent, LlmResponse, MessageId,
346 ModelSettings, ProviderErrorKind, ReasoningEffort, ToolCallError, ToolCallRequest, ToolCallResult,
347 ToolDefinition,
348 };
349
350 #[tokio::test]
351 async fn gemini_disables_reasoning_with_none() {
352 let model = reasoning_off_model(Provider::Gemini);
353 let mut fixture = ProviderFixture::builder(&GEMINI).model(&model.model_id()).build().await;
354 let mut context = Context::new(vec![ChatMessage::user("Hello")], vec![]);
355 context.set_reasoning_effort(ReasoningEffort::Disabled);
356
357 let (captured, responses) = fixture.run(&context).await;
358
359 assert_successful_stream(&responses);
360 assert_eq!(captured.body["reasoning_effort"], "none");
361 }
362
363 #[tokio::test]
364 async fn moonshot_normalizes_tool_schemas() {
365 let parameters = json!({
366 "$schema": "https://json-schema.org/draft/2020-12/schema",
367 "title": "MySchema",
368 "type": "object",
369 "properties": { "q": { "type": "string", "format": "uri" } }
370 });
371 let mut fixture = ProviderFixture::builder(&MOONSHOT).build().await;
372 let context =
373 Context::new(vec![ChatMessage::user("hi")], vec![ToolDefinition::new("search", "Search", parameters)]);
374
375 let (captured, responses) = fixture.run(&context).await;
376 let body = captured.body;
377
378 assert_successful_stream(&responses);
379 let params = &body["tools"][0]["function"]["parameters"];
380 assert!(params.get("$schema").is_none());
381 assert!(params.get("title").is_none());
382 assert!(params["properties"]["q"].get("format").is_none());
383 assert_eq!(params["type"], "object");
384 }
385
386 #[tokio::test]
387 async fn chat_completions_send_errors_and_summaries_as_user_notes() {
388 let mut fixture = ProviderFixture::builder(&DEEPSEEK).build().await;
389 let context = Context::new(
390 vec![
391 ChatMessage::Error {
392 message_id: MessageId::new(),
393 message: "boom".to_string(),
394 timestamp: IsoString::now(),
395 },
396 ChatMessage::Summary {
397 message_id: MessageId::new(),
398 content: "earlier work".to_string(),
399 timestamp: IsoString::now(),
400 messages_compacted: 2,
401 },
402 ],
403 vec![],
404 );
405
406 let (captured, responses) = fixture.run(&context).await;
407 let body = captured.body;
408
409 assert_successful_stream(&responses);
410 assert_eq!(body["messages"][0], json!({ "role": "user", "content": "Error: boom" }));
411 assert_eq!(
412 body["messages"][1],
413 json!({ "role": "user", "content": "[Previous conversation handoff]\n\nearlier work" })
414 );
415 }
416
417 #[tokio::test]
418 async fn openrouter_requests_usage_caching_and_session_routing() {
419 let mut fixture = ProviderFixture::builder(&OPENROUTER).model("anthropic/claude-haiku-4.5").build().await;
420 let mut context = Context::new(vec![ChatMessage::user("Hello")], vec![]);
421 context.set_prompt_cache_key(Some("prefix-abc".to_string()));
422 context.set_session_affinity_key(Some("conversation-abc".to_string()));
423 context.set_reasoning_effort(ReasoningEffort::High);
424
425 let (captured, responses) = fixture.run(&context).await;
426 let body = captured.body;
427
428 assert_successful_stream(&responses);
429 assert_eq!(body["model"], "anthropic/claude-haiku-4.5");
430 assert_eq!(body["prompt_cache_key"], "prefix-abc");
431 assert_eq!(body["session_id"], "conversation-abc");
432 assert_eq!(body["reasoning_effort"], "high");
433 assert_eq!(body["cache_control"]["type"], "ephemeral");
434 assert_eq!(body["usage"]["include"], true);
435 assert_eq!(fixture.provider.display_name(), "OpenRouter (anthropic/claude-haiku-4.5)");
436 }
437
438 #[tokio::test]
439 async fn openrouter_disables_reasoning_through_its_unified_reasoning_object() {
440 let model = reasoning_off_model(Provider::OpenRouter);
441 let mut fixture = ProviderFixture::builder(&OPENROUTER).model(&model.model_id()).build().await;
442 let mut context = Context::new(vec![ChatMessage::user("Hello")], vec![]);
443 context.set_reasoning_effort(ReasoningEffort::Disabled);
444
445 let (captured, responses) = fixture.run(&context).await;
446 let body = captured.body;
447
448 assert_successful_stream(&responses);
449 assert_eq!(body["reasoning"], json!({ "effort": "none" }));
450 assert!(body.get("reasoning_effort").is_none());
451 }
452
453 #[tokio::test]
454 async fn chat_rate_limit_rejection_is_a_retryable_provider_error() {
455 let body = r#"{"error":{"message":"Rate limit exceeded: free-models-per-day","code":429}}"#;
456 let error = rejected_chat_request(ResponseSpec::sse(body).with_status(429)).await;
457 let provider_error = error.provider().expect("a 429 must classify as a provider error");
458
459 assert_eq!(provider_error.kind, ProviderErrorKind::RateLimit);
460 assert_eq!(provider_error.http_status, Some(429));
461 assert_eq!(provider_error.code.as_deref(), Some("429"));
462 assert_eq!(provider_error.request_id.as_deref(), Some("request-123"));
463 assert!(provider_error.message.contains("free-models-per-day"));
464 assert!(error.is_retryable(), "429 must be retryable, got {error}");
465 }
466
467 #[tokio::test]
468 async fn chat_rejection_preserves_string_error_codes() {
469 let body = r#"{"error":{"code":"rate_limit_exceeded","message":"slow down"}}"#;
470 let error = rejected_chat_request(ResponseSpec::sse(body).with_status(429)).await;
471 let provider_error = error.provider().unwrap();
472
473 assert_eq!(provider_error.code.as_deref(), Some("rate_limit_exceeded"));
474 assert!(provider_error.message.contains("slow down"));
475 assert!(error.is_retryable());
476 }
477
478 #[tokio::test]
479 async fn chat_bad_request_is_not_retryable() {
480 let body = r#"{"error":{"message":"invalid request"}}"#;
481 let error = rejected_chat_request(ResponseSpec::sse(body).with_status(400)).await;
482
483 assert_eq!(error.provider().map(|provider| provider.kind), Some(ProviderErrorKind::Api));
484 assert!(!error.is_retryable());
485 }
486
487 #[tokio::test]
488 async fn chat_rejection_keeps_http_status_when_the_error_body_fails_to_read() {
489 let error = rejected_chat_request(ResponseSpec::sse("").with_status(429).interrupted()).await;
490 let provider_error = error.provider().unwrap();
491
492 assert!(provider_error.message.ends_with("Unknown error"), "{provider_error:?}");
493 assert_eq!(provider_error.http_status, Some(429));
494 assert_eq!(provider_error.request_id.as_deref(), Some("request-123"));
495 assert_eq!(provider_error.kind, ProviderErrorKind::RateLimit);
496 assert!(error.is_retryable());
497 }
498
499 #[tokio::test]
500 async fn chat_chunk_that_is_not_json_interrupts_the_stream() {
501 let responses = streamed_chat("data: invalid json\n\n").await;
502
503 assert!(matches!(responses[..], [Ok(LlmResponse::Start), Err(_)]), "{responses:?}");
504 assert_interrupted(&responses);
505 }
506
507 #[tokio::test]
508 async fn chat_stream_ends_at_done() {
509 let responses = streamed_chat(&format!("{CHAT_FIXTURE}data: invalid json\n\n")).await;
510
511 assert_successful_stream(&responses);
512 }
513
514 #[tokio::test]
515 async fn chat_stream_skips_keepalive_events() {
516 let responses = streamed_chat(&format!("event: keepalive\ndata: ping\n\n{CHAT_FIXTURE}")).await;
517
518 assert_successful_stream(&responses);
519 }
520
521 #[tokio::test]
522 async fn chat_stream_cut_off_mid_tool_call_is_interrupted() {
523 let tool_call_start = json!({ "choices": [{ "index": 0, "delta": { "tool_calls": [{
524 "index": 0, "id": "call_1", "type": "function",
525 "function": { "name": "bash", "arguments": r#"{"command":"# }
526 }] } }] });
527
528 let responses = streamed_chat(&format!("data: {tool_call_start}\n\n")).await;
529
530 assert!(
531 !responses.iter().any(|response| matches!(
532 response,
533 Ok(LlmResponse::ToolRequestComplete { .. } | LlmResponse::Done { .. })
534 )),
535 "{responses:?}"
536 );
537 assert_interrupted(&responses);
538 }
539
540 #[tokio::test]
541 async fn disabled_toggle_and_unknown_models_never_send_requests() {
542 let mut fixture = ProviderFixture::builder(&DEEPSEEK).build().await;
543 for model in ["deepseek-v4-flash", "unknown"] {
544 fixture.provider = fixture.provider.with_model(model);
545 let mut context = Context::new(vec![], vec![]);
546 context.set_reasoning_effort(ReasoningEffort::Disabled);
547 let responses = fixture.provider.stream_response(&context).collect::<Vec<_>>().await;
548 assert_eq!(responses.len(), 1, "{responses:?}");
549 let error = responses[0].as_ref().unwrap_err();
550 if model == "unknown" {
551 assert!(matches!(error, LlmError::ReasoningValidation(_)));
552 } else {
553 assert!(matches!(error, LlmError::UnsupportedDisableTransport { .. }));
554 }
555 assert!(!error.is_retryable());
556 }
557 }
558
559 #[tokio::test]
560 async fn sampling_settings_are_sent_as_configured() {
561 for config in [&DEEPSEEK, &OPENAI, &ANTHROPIC] {
562 let mut fixture = ProviderFixture::builder(config).build().await;
563 let mut context = hello_context();
564 context.set_model_settings(ModelSettings { temperature: Some(0.2), top_p: Some(0.9), max_tokens: None });
565
566 let (captured, responses) = fixture.run(&context).await;
567 let body = captured.body;
568
569 assert!(responses.iter().all(Result::is_ok), "{responses:?}");
570 assert_eq!(body["temperature"], 0.2, "{}", config.provider);
571 assert_eq!(body["top_p"], 0.9, "{}", config.provider);
572 }
573 }
574
575 #[tokio::test]
576 async fn every_api_validates_reasoning_before_sending() {
577 for config in [&DEEPSEEK, &OPENAI, &ANTHROPIC] {
578 let fixture = ProviderFixture::builder(config).model("unknown").build().await;
579 let mut context = hello_context();
580 context.set_reasoning_effort(ReasoningEffort::Disabled);
581
582 let responses = fixture.provider.stream_response(&context).collect::<Vec<_>>().await;
583
584 assert!(matches!(responses[..], [Err(LlmError::ReasoningValidation(_))]), "{responses:?}");
585 }
586 }
587
588 #[test]
589 fn display_name_names_models_outside_the_catalog() {
590 let provider = GenericProvider::new("key", &DEEPSEEK).unwrap().with_model("my-custom-model");
591 assert_eq!(provider.display_name(), "DeepSeek (my-custom-model)");
592
593 let provider = GenericProvider::new("key", &DEEPSEEK).unwrap().with_model("");
594 assert_eq!(provider.display_name(), "DeepSeek");
595 }
596
597 #[test]
598 fn azure_foundry_requires_a_configured_url() {
599 let Err(error) = GenericProvider::new("key", &AZURE_FOUNDRY) else {
600 panic!("Azure Foundry must require a URL");
601 };
602 assert!(matches!(error, LlmError::MissingProviderUrl { provider } if provider == "azure-foundry"));
603 }
604
605 #[tokio::test]
606 async fn request_model_routes_each_api_without_changing_catalog_identity() {
607 for (config, model, deployment, identity, display_name, auth_mode) in [
608 (
609 &AZURE_FOUNDRY,
610 "gpt-5.5",
611 "production-coding",
612 "azure-foundry:gpt-5.5",
613 "Microsoft Foundry (gpt-5.5)",
614 ProviderAuthMode::None,
615 ),
616 (
617 &XIAOMI,
618 "mimo-v2.6-pro",
619 "mimo-deployment",
620 "xiaomi:mimo-v2.6-pro",
621 "Xiaomi (mimo-v2.6-pro)",
622 ProviderAuthMode::None,
623 ),
624 (
625 &ANTHROPIC,
626 "claude-sonnet-4-5-20250929",
627 "claude-deployment",
628 "anthropic:claude-sonnet-4-5-20250929",
629 "Anthropic (claude-sonnet-4-5-20250929)",
630 ProviderAuthMode::Default,
631 ),
632 ] {
633 let mut fixture = ProviderFixture::builder(config)
634 .model(model)
635 .connection(ProviderConnectionConfig {
636 request_model: Some(deployment.into()),
637 auth_mode,
638 ..Default::default()
639 })
640 .build()
641 .await;
642
643 let (captured, responses) = fixture.run(&hello_context()).await;
644
645 assert_successful_stream(&responses);
646 assert_eq!(captured.body["model"], deployment);
647 assert_eq!(captured.body["stream"], true);
648 assert!(captured.headers.get("authorization").is_none());
649 assert_eq!(fixture.provider.model().unwrap().to_string(), identity);
650 assert_eq!(fixture.provider.display_name(), display_name);
651 match config.api {
652 Api::ChatCompletions(_) => {
653 assert_eq!(captured.path, "/chat/completions");
654 assert_eq!(captured.body["stream_options"]["include_usage"], true);
655 }
656 Api::Responses(_) => assert_eq!(captured.path, "/responses"),
657 Api::Messages => {
658 assert_eq!(captured.path, "/v1/messages");
659 assert_eq!(captured.headers["anthropic-version"], "2023-06-01");
660 assert_eq!(captured.headers["x-api-key"], "key");
661 }
662 }
663 }
664 }
665
666 #[tokio::test]
667 async fn default_auth_sends_the_api_key_as_a_bearer_token() {
668 let mut fixture = ProviderFixture::builder(&DEEPSEEK)
669 .api_key("secret")
670 .connection(ProviderConnectionConfig::default())
671 .build()
672 .await;
673 let (captured, responses) = fixture.run(&hello_context()).await;
674
675 assert_successful_stream(&responses);
676 assert_eq!(captured.headers["authorization"], "Bearer secret");
677 }
678
679 #[tokio::test]
680 async fn chat_providers_apply_their_declared_prompt_cache_policy() {
681 for (config, expected_key) in [
682 (&AZURE_FOUNDRY, Some("prefix-abc")),
683 (&FIREWORKS, Some("conversation-abc")),
684 (&DEEPSEEK, None),
685 (&MOONSHOT, None),
686 (&ZAI, None),
687 ] {
688 for keys_set in [true, false] {
689 let mut fixture = ProviderFixture::builder(config).build().await;
690 let mut context = Context::new(vec![ChatMessage::user("Hello")], vec![]);
691 if keys_set {
692 context.set_prompt_cache_key(Some("prefix-abc".to_string()));
693 context.set_session_affinity_key(Some("conversation-abc".to_string()));
694 }
695
696 let (captured, responses) = fixture.run(&context).await;
697
698 assert_successful_stream(&responses);
699 let sent_key = captured.body.get("prompt_cache_key").and_then(serde_json::Value::as_str);
700 assert_eq!(sent_key, expected_key.filter(|_| keys_set), "{} keys_set={keys_set}", config.provider);
701 assert!(captured.body.get("user").is_none());
702 assert!(captured.body.get("session_id").is_none());
703 }
704 }
705 }
706
707 #[tokio::test]
708 async fn local_providers_send_bare_chat_requests_under_v1() {
709 for (config, display_name) in [(&OLLAMA, "Ollama (test-model)"), (&LLAMACPP, "LlamaCpp (test-model)")] {
710 let mut server = CaptureServer::start_chat_completions().await;
711 let connection = ProviderConnectionConfig { base_url: Some(server.base_url.clone()), ..Default::default() };
712 let provider =
713 GenericProvider::from_env_with_connection(config, connection).unwrap().with_model("test-model");
714 let tool_call = ChatMessage::Assistant {
715 message_id: MessageId::new(),
716 content: String::new(),
717 reasoning: AssistantReasoning::default(),
718 timestamp: IsoString::now(),
719 tool_calls: vec![ToolCallRequest { id: "call_1".into(), name: "tool".into(), arguments: "{}".into() }],
720 };
721 let mut context = Context::new(vec![ChatMessage::user("Hello"), tool_call], vec![]);
722 context.set_prompt_cache_key(Some("prefix-abc".to_string()));
723 context.set_session_affinity_key(Some("conversation-abc".to_string()));
724
725 let responses = provider.stream_response(&context).collect::<Vec<_>>().await;
726 let captured = server.captured().await;
727
728 assert_successful_stream(&responses);
729 assert_eq!(captured.path, "/v1/chat/completions");
730 assert_eq!(captured.body["model"], "test-model");
731 assert!(captured.headers.get("authorization").is_none());
732 assert!(captured.body.get("prompt_cache_key").is_none());
733 assert!(captured.body["messages"][1].get("reasoning_content").is_none());
734 assert_eq!(provider.display_name(), display_name);
735 }
736 }
737
738 #[tokio::test]
739 async fn local_providers_send_requests_to_their_host_variable() {
740 for (config, host_var) in [(&OLLAMA, "OLLAMA_HOST"), (&LLAMACPP, "LLAMA_CPP_HOST")] {
741 let mut server = CaptureServer::start_chat_completions().await;
742 let vars = Vars::new().with_env_lookup(|_| None).with(host_var, server.base_url.clone());
743 let provider = GenericProvider::from_vars(config, ProviderConnectionConfig::default(), &vars)
744 .unwrap()
745 .with_model("test-model");
746
747 let responses = provider.stream_response(&hello_context()).collect::<Vec<_>>().await;
748
749 assert_successful_stream(&responses);
750 assert_eq!(server.captured().await.path, "/v1/chat/completions", "{host_var}");
751 }
752 }
753
754 #[tokio::test]
755 async fn connection_url_takes_precedence_over_the_host_variable() {
756 let mut server = CaptureServer::start_chat_completions().await;
757 let vars = Vars::new().with_env_lookup(|_| None).with("OLLAMA_HOST", "http://127.0.0.1:1");
758 let connection = ProviderConnectionConfig { base_url: Some(server.base_url.clone()), ..Default::default() };
759 let provider = GenericProvider::from_vars(&OLLAMA, connection, &vars).unwrap().with_model("test-model");
760
761 let responses = provider.stream_response(&hello_context()).collect::<Vec<_>>().await;
762
763 assert_successful_stream(&responses);
764 assert_eq!(server.captured().await.path, "/v1/chat/completions");
765 }
766
767 #[tokio::test]
768 async fn providers_honor_configured_idle_timeout() {
769 for (config, fixture) in [
770 (&OLLAMA, include_str!("../../tests/fixtures/openai/01_minimal.sse")),
771 (&ANTHROPIC, include_str!("../../tests/fixtures/anthropic/01_minimal.sse")),
772 ] {
773 let spec = ResponseSpec::sse(fixture).paced(Duration::from_mins(2));
774 let mut fixture = ProviderFixture::builder(config)
775 .response(spec)
776 .connection(ProviderConnectionConfig {
777 auth_mode: ProviderAuthMode::None,
778 idle_timeout: Duration::from_mins(1),
779 ..Default::default()
780 })
781 .build()
782 .await;
783
784 let responses =
785 fixture.server.collect_on_paused_clock(fixture.provider.stream_response(&hello_context())).await;
786
787 let error = responses.last().and_then(|response| response.as_ref().err()).and_then(LlmError::provider);
788 assert_eq!(error.map(|error| error.kind), Some(ProviderErrorKind::Timeout), "{responses:?}");
789 }
790 }
791
792 #[tokio::test]
793 async fn responses_send_user_input_without_unset_options() {
794 let body = openai_request_body("gpt-4.1", &hello_context()).await;
795
796 assert_eq!(body["model"], "gpt-4.1");
797 assert_eq!(
798 body["input"],
799 json!([{
800 "type": "message", "role": "user", "content": [{"type": "input_text", "text": "Hello"}]
801 }])
802 );
803 for field in ["instructions", "tools", "reasoning", "temperature", "top_p", "max_output_tokens"] {
804 assert!(body.get(field).is_none(), "{field}: {body}");
805 }
806 }
807
808 #[tokio::test]
809 async fn responses_lift_system_instructions_and_apply_model_settings() {
810 let mut context = Context::new(vec![ChatMessage::system("You are helpful."), ChatMessage::user("Hi")], vec![]);
811 context.set_model_settings(ModelSettings { temperature: Some(0.0), top_p: Some(0.5), max_tokens: Some(128) });
812
813 let body = openai_request_body("gpt-4.1", &context).await;
814
815 assert_eq!(body["instructions"], "You are helpful.");
816 assert_eq!(body["input"].as_array().unwrap().len(), 1);
817 assert_eq!(body["input"][0]["role"], "user");
818 assert_eq!(body["temperature"], 0.0);
819 assert_eq!(body["top_p"], 0.5);
820 assert_eq!(body["max_output_tokens"], 128);
821 }
822
823 #[tokio::test]
824 async fn responses_preserve_tool_conversations_and_definitions() {
825 let arguments = r#"{"path":"foo.rs"}"#;
826 let context = Context::new(
827 vec![
828 ChatMessage::user("Read foo.rs"),
829 ChatMessage::Assistant {
830 message_id: MessageId::new(),
831 content: "I'll read that file.".into(),
832 reasoning: AssistantReasoning::default(),
833 timestamp: IsoString::now(),
834 tool_calls: vec![ToolCallRequest {
835 id: "call_1".into(),
836 name: "read_file".into(),
837 arguments: arguments.into(),
838 }],
839 },
840 ChatMessage::ToolCallResult(Ok(ToolCallResult {
841 id: "call_1".into(),
842 name: "read_file".into(),
843 arguments: arguments.into(),
844 result: "fn main() {}".into(),
845 })),
846 ChatMessage::Assistant {
847 message_id: MessageId::new(),
848 content: "Here's the file content.".into(),
849 reasoning: AssistantReasoning::default(),
850 timestamp: IsoString::now(),
851 tool_calls: vec![],
852 },
853 ],
854 vec![ToolDefinition::new(
855 "read_file",
856 "Read a file from disk",
857 json!({
858 "type": "object", "properties": { "path": { "type": "string" } }
859 }),
860 )],
861 );
862
863 let body = openai_request_body("gpt-4.1", &context).await;
864
865 assert_eq!(
866 body["input"],
867 json!([
868 { "type": "message", "role": "user", "content": [{"type": "input_text", "text": "Read foo.rs"}] },
869 { "type": "message", "role": "assistant", "content": "I'll read that file." },
870 { "type": "function_call", "arguments": arguments, "call_id": "call_1", "name": "read_file" },
871 { "type": "function_call_output", "call_id": "call_1", "output": "fn main() {}" },
872 { "type": "message", "role": "assistant", "content": "Here's the file content." },
873 ])
874 );
875 let tools = body["tools"].as_array().unwrap();
876 assert_eq!(tools.len(), 1);
877 assert_eq!(tools[0]["type"], "function");
878 assert_eq!(tools[0]["name"], "read_file");
879 assert_eq!(tools[0]["description"], "Read a file from disk");
880 assert_eq!(tools[0]["parameters"]["properties"]["path"]["type"], "string");
881 assert_eq!(tools[0]["strict"], false);
882 }
883
884 #[tokio::test]
885 async fn responses_preserve_tool_calls_without_an_assistant_text_message() {
886 let context = Context::new(
887 vec![ChatMessage::Assistant {
888 message_id: MessageId::new(),
889 content: String::new(),
890 timestamp: IsoString::now(),
891 reasoning: AssistantReasoning::default(),
892 tool_calls: vec![ToolCallRequest {
893 id: "call_1".into(),
894 name: "search".into(),
895 arguments: "{}".into(),
896 }],
897 }],
898 vec![],
899 );
900
901 let body = openai_request_body("gpt-4.1", &context).await;
902
903 assert_eq!(
904 body["input"],
905 json!([{
906 "type": "function_call", "arguments": "{}", "call_id": "call_1", "name": "search"
907 }])
908 );
909 }
910
911 #[tokio::test]
912 async fn responses_preserve_tool_errors_and_summary_notes() {
913 let context = Context::new(
914 vec![
915 ChatMessage::ToolCallResult(Err(ToolCallError {
916 id: "call_2".into(),
917 name: "bash".into(),
918 arguments: Some("{}".into()),
919 error: "command failed".into(),
920 })),
921 ChatMessage::Summary {
922 message_id: MessageId::new(),
923 content: "User asked about Rust.".into(),
924 timestamp: IsoString::now(),
925 messages_compacted: 5,
926 },
927 ],
928 vec![],
929 );
930
931 let body = openai_request_body("gpt-4.1", &context).await;
932 let input = body["input"].as_array().unwrap();
933
934 assert_eq!(input.len(), 2);
935 assert_eq!(
936 input[0],
937 json!({ "type": "function_call_output", "call_id": "call_2", "output": "Error: command failed" })
938 );
939 assert_eq!(input[1]["type"], "message");
940 assert_eq!(input[1]["role"], "user");
941 let note = input[1]["content"].as_str().unwrap();
942 assert!(note.starts_with("[Previous conversation handoff]"));
943 assert!(note.contains("User asked about Rust."));
944 }
945
946 #[tokio::test]
947 async fn responses_replay_encrypted_reasoning_only_for_the_original_model() {
948 for source_model in [Some("openai:gpt-5.6"), Some("openai:gpt-5.5"), None] {
949 let context = Context::new(
950 vec![ChatMessage::Assistant {
951 message_id: MessageId::new(),
952 content: "thinking done".into(),
953 timestamp: IsoString::now(),
954 tool_calls: vec![],
955 reasoning: AssistantReasoning::from_parts(
956 "summary".into(),
957 source_model.map(|model| EncryptedReasoningContent {
958 id: "r_1".into(),
959 model: model.parse().unwrap(),
960 content: "encrypted-blob".into(),
961 }),
962 ),
963 }],
964 vec![],
965 );
966
967 let body = openai_request_body("gpt-5.6", &context).await;
968 let input = body["input"].as_array().unwrap();
969
970 assert_eq!(input[0], json!({ "type": "message", "role": "assistant", "content": "thinking done" }));
971 if source_model == Some("openai:gpt-5.6") {
972 assert_eq!(input.len(), 2);
973 assert_eq!(
974 input[1],
975 json!({ "type": "reasoning", "id": "r_1", "summary": [], "encrypted_content": "encrypted-blob" })
976 );
977 } else {
978 assert_eq!(input.len(), 1, "{source_model:?}: {input:?}");
979 }
980 }
981 }
982
983 #[tokio::test]
984 async fn responses_send_each_enabled_effort_with_an_automatic_summary() {
985 for effort in ReasoningEffort::all().iter().filter(|effort| effort.is_enabled()) {
986 let model = LlmModel::all()
987 .iter()
988 .find(|model| {
989 model.provider_enum() == Provider::Openai && model.effective_reasoning_levels().contains(effort)
990 })
991 .expect("OpenAI has a model supporting this effort");
992 let mut context = hello_context();
993 context.set_reasoning_effort(*effort);
994
995 let body = openai_request_body(&model.model_id(), &context).await;
996
997 assert_eq!(body["reasoning"], json!({ "effort": effort.as_str(), "summary": "auto" }));
998 }
999 }
1000
1001 #[tokio::test]
1002 async fn openai_distinguishes_default_disabled_and_low_effort() {
1003 for (effort, expected) in [
1004 (ReasoningEffort::Default, None),
1005 (ReasoningEffort::Disabled, Some("none")),
1006 (ReasoningEffort::Low, Some("low")),
1007 ] {
1008 let mut fixture = ProviderFixture::builder(&OPENAI).model("gpt-5.4").build().await;
1009 let mut context = Context::new(vec![ChatMessage::user("Hello")], vec![]);
1010 context.set_reasoning_effort(effort);
1011
1012 let (captured, responses) = fixture.run(&context).await;
1013 let body = captured.body;
1014
1015 assert!(responses.iter().all(Result::is_ok), "{responses:?}");
1016 assert_eq!(body["reasoning"]["effort"].as_str(), expected);
1017 if effort == ReasoningEffort::Disabled {
1018 assert!(body["reasoning"]["summary"].is_null());
1019 }
1020 }
1021 }
1022
1023 #[tokio::test]
1024 async fn openai_sends_max_effort_and_prompt_cache_key() {
1025 let mut fixture = ProviderFixture::builder(&OPENAI).model("gpt-5.6").build().await;
1026 let mut context = Context::new(vec![ChatMessage::user("Think harder")], vec![]);
1027 context.set_reasoning_effort(ReasoningEffort::Max);
1028 context.set_prompt_cache_key(Some("cache-key".to_string()));
1029
1030 let (captured, responses) = fixture.run(&context).await;
1031
1032 assert!(responses.iter().all(Result::is_ok), "{responses:?}");
1033 assert_eq!(captured.body["reasoning"]["effort"], "max");
1034 assert_eq!(captured.body["model"], "gpt-5.6");
1035 assert_eq!(captured.body["prompt_cache_key"], "cache-key");
1036 assert_eq!(captured.body["include"], json!(["reasoning.encrypted_content"]));
1037 assert_eq!(captured.body["stream"], true);
1038 assert_eq!(fixture.provider.display_name(), "OpenAI (gpt-5.6)");
1039 }
1040
1041 #[tokio::test]
1042 async fn responses_http_200_failed_server_error_is_retryable_with_request_id() {
1043 let spec = ResponseSpec::sse(include_str!("../../tests/fixtures/openai_responses/04_failed_server.sse"))
1044 .with_header("x-request-id", "req-openai-1");
1045 let mut fixture = ProviderFixture::builder(&OPENAI).response(spec).build().await;
1046 let context = Context::new(vec![ChatMessage::user("hi")], vec![]);
1047
1048 let (_, responses) = fixture.run(&context).await;
1049
1050 assert!(!responses.iter().any(|r| matches!(r, Ok(LlmResponse::Done { .. }))));
1051 let err = responses.iter().find_map(|r| r.as_ref().err()).expect("expected a failure");
1052 assert!(err.is_retryable(), "server_error must be retryable: {err:?}");
1053 let provider_error = err.provider().expect("expected provider error");
1054 assert_eq!(provider_error.kind, ProviderErrorKind::Server);
1055 assert_eq!(provider_error.http_status, Some(200));
1056 assert_eq!(provider_error.request_id.as_deref(), Some("req-openai-1"));
1057 assert_eq!(provider_error.code.as_deref(), Some("server_error"));
1058 }
1059
1060 #[tokio::test]
1061 async fn responses_surface_a_mapping_failure_as_the_only_item() {
1062 let connection = ProviderConnectionConfig { auth_mode: ProviderAuthMode::None, ..Default::default() };
1063 let provider = GenericProvider::from_env_with_connection(&OPENAI, connection).unwrap();
1064 let context = Context::new(
1065 vec![ChatMessage::User {
1066 message_id: MessageId::new(),
1067 content: vec![ContentBlock::Audio { data: "YXVkaW8=".to_string(), mime_type: "audio/wav".to_string() }],
1068 timestamp: IsoString::now(),
1069 }],
1070 vec![],
1071 );
1072
1073 let responses = provider.stream_response(&context).collect::<Vec<_>>().await;
1074
1075 assert_eq!(responses.len(), 1);
1076 assert!(matches!(responses[0], Err(LlmError::UnsupportedContent(_))), "{responses:?}");
1077 }
1078
1079 #[tokio::test]
1080 async fn xiaomi_omits_encrypted_reasoning_summaries_and_prompt_cache_key() {
1081 let mut fixture = ProviderFixture::builder(&XIAOMI).build().await;
1082 let mut context = Context::new(vec![ChatMessage::user("Hello")], vec![]);
1083 context.set_reasoning_effort(ReasoningEffort::High);
1084 context.set_prompt_cache_key(Some("cache-key".to_string()));
1085
1086 let (captured, responses) = fixture.run(&context).await;
1087
1088 assert!(responses.iter().all(Result::is_ok), "{responses:?}");
1089 assert_eq!(captured.path, "/responses");
1090 assert_eq!(captured.body["model"], "mimo-v2.6-pro");
1091 assert_eq!(captured.body["reasoning"], json!({ "effort": "high" }));
1092 assert!(captured.body.get("include").is_none());
1093 assert!(captured.body.get("prompt_cache_key").is_none());
1094 assert_eq!(fixture.provider.display_name(), "Xiaomi (mimo-v2.6-pro)");
1095 }
1096
1097 #[tokio::test]
1098 async fn xiaomi_replays_prior_reasoning_as_plain_text() {
1099 let mut fixture = ProviderFixture::builder(&XIAOMI).build().await;
1100 let context = Context::new(
1101 vec![
1102 ChatMessage::user("Hello"),
1103 ChatMessage::Assistant {
1104 message_id: MessageId::new(),
1105 content: "Hi".to_string(),
1106 reasoning: AssistantReasoning::from_parts("greeting the user".to_string(), None),
1107 timestamp: IsoString::now(),
1108 tool_calls: vec![],
1109 },
1110 ChatMessage::user("Again"),
1111 ],
1112 vec![],
1113 );
1114
1115 let (captured, responses) = fixture.run(&context).await;
1116
1117 assert!(responses.iter().all(Result::is_ok), "{responses:?}");
1118 let reasoning = captured.body["input"]
1119 .as_array()
1120 .unwrap()
1121 .iter()
1122 .find(|item| item["type"] == "reasoning")
1123 .expect("reasoning item should be replayed");
1124 assert_eq!(reasoning["content"], json!([{ "type": "reasoning_text", "text": "greeting the user" }]));
1125 assert!(reasoning.get("encrypted_content").is_none_or(serde_json::Value::is_null));
1126 }
1127
1128 #[tokio::test]
1129 async fn xiaomi_drops_null_from_optional_tool_parameters() {
1130 let mut fixture = ProviderFixture::builder(&XIAOMI).build().await;
1131 let tool = ToolDefinition::new(
1132 "bash",
1133 "Run a command",
1134 json!({
1135 "type": "object",
1136 "properties": {
1137 "command": { "type": "string" },
1138 "description": { "type": ["string", "null"] }
1139 },
1140 "required": ["command"]
1141 }),
1142 );
1143 let context = Context::new(vec![ChatMessage::user("Hello")], vec![tool]);
1144
1145 let (captured, responses) = fixture.run(&context).await;
1146
1147 assert!(responses.iter().all(Result::is_ok), "{responses:?}");
1148 assert_eq!(captured.body["tools"][0]["parameters"]["properties"]["description"], json!({ "type": "string" }));
1149 }
1150
1151 #[tokio::test]
1152 async fn anthropic_sends_its_version_and_api_key_unless_auth_is_none() {
1153 for (auth_mode, api_key) in [(ProviderAuthMode::Default, Some("secret")), (ProviderAuthMode::None, None)] {
1154 let mut fixture = ProviderFixture::builder(&ANTHROPIC)
1155 .api_key("secret")
1156 .connection(ProviderConnectionConfig { auth_mode, ..Default::default() })
1157 .build()
1158 .await;
1159 let (captured, responses) = fixture.run(&hello_context()).await;
1160
1161 assert_successful_stream(&responses);
1162 assert_eq!(captured.path, "/v1/messages");
1163 assert_eq!(captured.headers["anthropic-version"], "2023-06-01");
1164 assert_eq!(captured.headers.get("x-api-key").map(|key| key.to_str().unwrap()), api_key);
1165 assert!(captured.headers.get("authorization").is_none());
1166 }
1167 }
1168
1169 #[tokio::test]
1170 async fn anthropic_caches_the_system_prompt_and_sends_tools() {
1171 let mut fixture = ProviderFixture::builder(&ANTHROPIC).build().await;
1172 let context = Context::new(
1173 vec![ChatMessage::system("You are helpful"), ChatMessage::user("Hello")],
1174 vec![ToolDefinition::new("search", "Search", json!({ "type": "object" }))],
1175 );
1176
1177 let (captured, responses) = fixture.run(&context).await;
1178 let body = captured.body;
1179
1180 assert_successful_stream(&responses);
1181 assert_eq!(body["model"], "claude-sonnet-4-5-20250929");
1182 assert_eq!(body["max_tokens"], 16_384);
1183 assert_eq!(body["stream"], true);
1184 assert_eq!(body["cache_control"], json!({ "type": "ephemeral" }));
1185 assert_eq!(
1186 body["system"],
1187 json!([{ "type": "text", "text": "You are helpful", "cache_control": { "type": "ephemeral" } }])
1188 );
1189 assert_eq!(body["messages"].as_array().map(Vec::len), Some(1));
1190 assert_eq!(
1191 body["tools"],
1192 json!([{ "name": "search", "description": "Search", "input_schema": { "type": "object" } }])
1193 );
1194 assert_eq!(fixture.provider.display_name(), "Anthropic (claude-sonnet-4-5-20250929)");
1195 }
1196
1197 #[tokio::test]
1198 async fn anthropic_default_and_disabled_thinking_keep_sampling_and_token_limit() {
1199 for (effort, thinking) in
1200 [(ReasoningEffort::Default, None), (ReasoningEffort::Disabled, Some(json!({ "type": "disabled" })))]
1201 {
1202 let body = anthropic_request_body(effort).await;
1203
1204 assert_eq!(body.get("thinking"), thinking.as_ref());
1205 assert_eq!(body["max_tokens"], 500);
1206 assert_eq!(body["temperature"], 0.5);
1207 assert_eq!(body["top_p"], 0.5);
1208 }
1209 }
1210
1211 #[tokio::test]
1212 async fn anthropic_enabled_thinking_clears_sampling_and_raises_token_limit() {
1213 for (effort, budget_tokens) in
1214 [(ReasoningEffort::Low, 1024), (ReasoningEffort::High, 10240), (ReasoningEffort::Max, 32768)]
1215 {
1216 let body = anthropic_request_body(effort).await;
1217
1218 assert_eq!(body["thinking"], json!({ "type": "enabled", "budget_tokens": budget_tokens }));
1219 assert_eq!(body["max_tokens"], budget_tokens + 1024);
1220 assert!(body.get("temperature").is_none());
1221 assert!(body.get("top_p").is_none());
1222 }
1223 }
1224
1225 const CHAT_FIXTURE: &str = include_str!("../../tests/fixtures/openai/01_minimal.sse");
1226
1227 async fn streamed_chat(body: &str) -> Vec<Result<LlmResponse>> {
1228 let mut fixture = ProviderFixture::builder(&OPENROUTER).response(ResponseSpec::sse(body)).build().await;
1229
1230 let (_, responses) = fixture.run(&hello_context()).await;
1231 responses
1232 }
1233
1234 fn assert_successful_stream(responses: &[Result<LlmResponse>]) {
1235 assert!(responses.iter().all(Result::is_ok), "{responses:?}");
1236 assert!(responses.iter().any(|response| matches!(response, Ok(LlmResponse::Done { .. }))));
1237 }
1238
1239 fn assert_interrupted(responses: &[Result<LlmResponse>]) {
1240 let error = responses.last().and_then(|response| response.as_ref().err()).expect("the stream must fail");
1241 assert_eq!(error.provider().map(|provider| provider.kind), Some(ProviderErrorKind::StreamInterrupted));
1242 assert!(error.is_retryable());
1243 }
1244
1245 async fn openai_request_body(model: &str, context: &Context) -> serde_json::Value {
1246 let mut fixture = ProviderFixture::builder(&OPENAI).model(model).build().await;
1247
1248 let (captured, responses) = fixture.run(context).await;
1249
1250 assert_successful_stream(&responses);
1251 captured.body
1252 }
1253
1254 async fn anthropic_request_body(effort: ReasoningEffort) -> serde_json::Value {
1255 let mut fixture = ProviderFixture::builder(&ANTHROPIC)
1256 .model(&reasoning_off_model(Provider::Anthropic).model_id())
1257 .build()
1258 .await;
1259 let mut context = hello_context();
1260 context.set_reasoning_effort(effort);
1261 context.set_model_settings(ModelSettings { temperature: Some(0.5), top_p: Some(0.5), max_tokens: Some(500) });
1262
1263 let (captured, responses) = fixture.run(&context).await;
1264
1265 assert_successful_stream(&responses);
1266 captured.body
1267 }
1268
1269 fn reasoning_off_model(provider: Provider) -> &'static LlmModel {
1270 LlmModel::all()
1271 .iter()
1272 .find(|model| model.provider_enum() == provider && model.supports_reasoning_off())
1273 .expect("the catalog has a model that can turn reasoning off")
1274 }
1275
1276 async fn rejected_chat_request(spec: ResponseSpec) -> LlmError {
1277 let mut fixture = ProviderFixture::builder(&OPENROUTER)
1278 .response(spec.with_header("x-request-id", "request-123"))
1279 .build()
1280 .await;
1281
1282 let (_, mut events) = fixture.run(&hello_context()).await;
1283
1284 assert_eq!(events.len(), 1, "a rejected request must yield exactly one error: {events:?}");
1285 events.pop().unwrap().expect_err("request must fail")
1286 }
1287}