Skip to main content

llm/providers/
generic.rs

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