Skip to main content

zeroclaw_providers/
openrouter.rs

1use crate::compatible::sse_bytes_to_events;
2use crate::multimodal;
3use crate::traits::{
4    ChatMessage, ChatRequest as ProviderChatRequest, ChatResponse as ProviderChatResponse,
5    ModelProvider, ProviderCapabilities, StreamError, StreamEvent, StreamOptions, StreamResult,
6    TokenUsage, ToolCall as ProviderToolCall,
7};
8use async_trait::async_trait;
9use futures_util::StreamExt as _;
10use futures_util::stream;
11use reqwest::Client;
12use serde::de::DeserializeOwned;
13use serde::{Deserialize, Serialize};
14use zeroclaw_api::tool::ToolSpec;
15
16pub struct OpenRouterModelProvider {
17    /// `[model_providers.<family>.<alias>]` config-key alias.
18    alias: String,
19    credential: Option<String>,
20    timeout_secs: u64,
21    max_tokens: Option<u32>,
22    extra_body: Option<serde_json::Value>,
23}
24
25/// OpenRouter's public aggregator endpoint.
26pub(crate) const BASE_URL: &str = "https://openrouter.ai/api/v1";
27const OPENROUTER_CONNECT_TIMEOUT_SECS: u64 = 10;
28
29#[derive(Debug, Serialize)]
30struct ChatRequest {
31    model: String,
32    messages: Vec<Message>,
33    temperature: f64,
34    #[serde(skip_serializing_if = "Option::is_none")]
35    max_tokens: Option<u32>,
36}
37
38#[derive(Debug, Serialize)]
39struct Message {
40    role: String,
41    content: MessageContent,
42}
43
44#[derive(Debug, Serialize)]
45#[serde(untagged)]
46enum MessageContent {
47    Text(String),
48    Parts(Vec<MessagePart>),
49}
50
51/// RAII guard that aborts a spawned tokio task when dropped.
52///
53/// Used by `stream_chat` to bind the SSE-forwarding task's lifetime to the
54/// returned stream. When a caller cancels the stream (timeout, user abort,
55/// client disconnect), the guard is dropped together with the stream state
56/// and the in-flight HTTP request is cancelled so it stops consuming
57/// bandwidth and connection-pool slots. `AbortHandle::abort` is a no-op
58/// after the task has finished naturally, so the happy path is unaffected.
59struct AbortOnDrop(tokio::task::AbortHandle);
60
61impl Drop for AbortOnDrop {
62    fn drop(&mut self) {
63        self.0.abort();
64    }
65}
66
67/// Marker placed on a content block to opt it into OpenRouter prompt caching.
68///
69/// Currently only `{"type": "ephemeral"}` is defined. OpenRouter forwards this
70/// field to upstream providers that support prompt caching (Anthropic,
71/// DeepSeek, Qwen). Providers without caching ignore the marker.
72#[derive(Debug, Serialize)]
73struct CacheControl {
74    #[serde(rename = "type")]
75    cache_type: String,
76}
77
78#[derive(Debug, Serialize)]
79#[serde(tag = "type", rename_all = "snake_case")]
80enum MessagePart {
81    Text {
82        text: String,
83        #[serde(skip_serializing_if = "Option::is_none")]
84        cache_control: Option<CacheControl>,
85    },
86    ImageUrl {
87        image_url: ImageUrlPart,
88    },
89}
90
91#[derive(Debug, Serialize)]
92struct ImageUrlPart {
93    url: String,
94}
95
96#[derive(Debug, Deserialize)]
97struct ApiChatResponse {
98    choices: Vec<Choice>,
99}
100
101#[derive(Debug, Deserialize)]
102struct Choice {
103    message: ResponseMessage,
104}
105
106#[derive(Debug, Deserialize)]
107struct ResponseMessage {
108    content: String,
109}
110
111#[derive(Debug, Serialize)]
112struct NativeChatRequest {
113    model: String,
114    messages: Vec<NativeMessage>,
115    temperature: f64,
116    #[serde(skip_serializing_if = "Option::is_none")]
117    tools: Option<Vec<NativeToolSpec>>,
118    #[serde(skip_serializing_if = "Option::is_none")]
119    tool_choice: Option<String>,
120    #[serde(skip_serializing_if = "Option::is_none")]
121    max_tokens: Option<u32>,
122    #[serde(skip_serializing_if = "Option::is_none")]
123    stream: Option<bool>,
124}
125
126#[derive(Debug, Serialize)]
127struct NativeMessage {
128    role: String,
129    #[serde(skip_serializing_if = "Option::is_none")]
130    content: Option<MessageContent>,
131    #[serde(skip_serializing_if = "Option::is_none")]
132    tool_call_id: Option<String>,
133    #[serde(skip_serializing_if = "Option::is_none")]
134    tool_calls: Option<Vec<NativeToolCall>>,
135    /// Raw reasoning content from thinking models; pass-through for model_providers
136    /// that require it in assistant tool-call history messages.
137    #[serde(skip_serializing_if = "Option::is_none")]
138    reasoning_content: Option<String>,
139}
140
141#[derive(Debug, Serialize)]
142struct NativeToolSpec {
143    #[serde(rename = "type")]
144    kind: String,
145    function: NativeToolFunctionSpec,
146}
147
148#[derive(Debug, Serialize)]
149struct NativeToolFunctionSpec {
150    name: String,
151    description: String,
152    parameters: serde_json::Value,
153}
154
155#[derive(Debug, Serialize, Deserialize)]
156struct NativeToolCall {
157    #[serde(skip_serializing_if = "Option::is_none")]
158    id: Option<String>,
159    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
160    kind: Option<String>,
161    function: NativeFunctionCall,
162}
163
164#[derive(Debug, Serialize, Deserialize)]
165struct NativeFunctionCall {
166    name: String,
167    arguments: String,
168}
169
170#[derive(Debug, Deserialize)]
171struct NativeChatResponse {
172    choices: Vec<NativeChoice>,
173    #[serde(default)]
174    usage: Option<UsageInfo>,
175}
176
177#[derive(Debug, Deserialize)]
178struct UsageInfo {
179    #[serde(default)]
180    prompt_tokens: Option<u64>,
181    #[serde(default)]
182    completion_tokens: Option<u64>,
183    /// Per-category prompt-token breakdown. Only present when the upstream
184    /// provider returns cached-token accounting. Absent for providers that
185    /// do not support prompt caching.
186    #[serde(default)]
187    prompt_tokens_details: Option<PromptTokensDetails>,
188}
189
190#[derive(Debug, Deserialize)]
191struct PromptTokensDetails {
192    #[serde(default)]
193    cached_tokens: Option<u64>,
194}
195
196#[derive(Debug, Deserialize)]
197struct NativeChoice {
198    message: NativeResponseMessage,
199}
200
201#[derive(Debug, Deserialize)]
202struct NativeResponseMessage {
203    #[serde(default)]
204    content: Option<String>,
205    /// Reasoning/thinking models may return output in `reasoning_content`.
206    #[serde(default)]
207    reasoning_content: Option<String>,
208    #[serde(default)]
209    tool_calls: Option<Vec<NativeToolCall>>,
210}
211
212impl OpenRouterModelProvider {
213    pub fn new(alias: &str, credential: Option<&str>, timeout_secs: Option<u64>) -> Self {
214        Self {
215            alias: alias.to_string(),
216            credential: credential.map(ToString::to_string),
217            timeout_secs: timeout_secs
218                .filter(|secs| *secs > 0)
219                .unwrap_or(zeroclaw_api::model_provider::BASELINE_TIMEOUT_SECS),
220            max_tokens: None,
221            extra_body: None,
222        }
223    }
224    /// Override the HTTP request timeout for LLM API calls.
225    pub fn with_timeout_secs(mut self, secs: u64) -> Self {
226        self.timeout_secs = secs;
227        self
228    }
229
230    /// Set the maximum output tokens for API requests.
231    pub fn with_max_tokens(mut self, max_tokens: Option<u32>) -> Self {
232        self.max_tokens = max_tokens;
233        self
234    }
235
236    /// Set extra JSON parameters to merge into every API request body.
237    /// Keys in `extra` are inserted at the top level of the serialized request,
238    /// overriding any existing keys with the same name.
239    pub fn with_extra_body(mut self, extra: serde_json::Value) -> Self {
240        self.extra_body = Some(extra);
241        self
242    }
243
244    fn convert_tools(tools: Option<&[ToolSpec]>) -> Option<Vec<NativeToolSpec>> {
245        let items = tools?;
246        if items.is_empty() {
247            return None;
248        }
249        let valid: Vec<NativeToolSpec> = items
250            .iter()
251            .filter(|tool| is_valid_openai_tool_name(&tool.name))
252            .map(|tool| NativeToolSpec {
253                kind: "function".to_string(),
254                function: NativeToolFunctionSpec {
255                    name: tool.name.clone(),
256                    description: tool.description.clone(),
257                    parameters: tool.parameters.clone(),
258                },
259            })
260            .collect();
261        if valid.is_empty() { None } else { Some(valid) }
262    }
263
264    fn convert_messages(messages: &[ChatMessage]) -> Vec<NativeMessage> {
265        messages
266            .iter()
267            .map(|m| {
268                if m.role == "assistant"
269                    && let Ok(value) = serde_json::from_str::<serde_json::Value>(&m.content)
270                    && let Some(tool_calls_value) = value.get("tool_calls")
271                    && let Ok(parsed_calls) =
272                        serde_json::from_value::<Vec<ProviderToolCall>>(tool_calls_value.clone())
273                {
274                    let tool_calls = parsed_calls
275                        .into_iter()
276                        .map(|tc| NativeToolCall {
277                            id: Some(tc.id),
278                            kind: Some("function".to_string()),
279                            function: NativeFunctionCall {
280                                name: tc.name,
281                                arguments: tc.arguments,
282                            },
283                        })
284                        .collect::<Vec<_>>();
285                    let content = value
286                        .get("content")
287                        .and_then(serde_json::Value::as_str)
288                        .map(|value| MessageContent::Text(value.to_string()));
289                    let reasoning_content = value
290                        .get("reasoning_content")
291                        .and_then(serde_json::Value::as_str)
292                        .map(ToString::to_string);
293                    return NativeMessage {
294                        role: "assistant".to_string(),
295                        content,
296                        tool_call_id: None,
297                        tool_calls: Some(tool_calls),
298                        reasoning_content,
299                    };
300                }
301
302                if m.role == "tool"
303                    && let Ok(value) = serde_json::from_str::<serde_json::Value>(&m.content)
304                {
305                    let tool_call_id = value
306                        .get("tool_call_id")
307                        .and_then(serde_json::Value::as_str)
308                        .map(ToString::to_string);
309                    let content = value
310                        .get("content")
311                        .and_then(serde_json::Value::as_str)
312                        .map(|value| MessageContent::Text(value.to_string()))
313                        .or_else(|| Some(MessageContent::Text(m.content.clone())));
314                    return NativeMessage {
315                        role: "tool".to_string(),
316                        content,
317                        tool_call_id,
318                        tool_calls: None,
319                        reasoning_content: None,
320                    };
321                }
322
323                NativeMessage {
324                    role: m.role.clone(),
325                    content: Some(Self::to_message_content(&m.role, &m.content)),
326                    tool_call_id: None,
327                    tool_calls: None,
328                    reasoning_content: None,
329                }
330            })
331            .collect()
332    }
333
334    fn to_message_content(role: &str, content: &str) -> MessageContent {
335        if role == "system" {
336            // Serialize system messages as a single-text-part array so we can
337            // attach `cache_control: {"type": "ephemeral"}`. OpenRouter forwards
338            // this marker to upstream providers that support prompt caching
339            // (Anthropic, DeepSeek, Qwen); providers without caching ignore
340            // the field. The wire shape is identical to a plain-string system
341            // message for ignoring providers, so this is safe across the
342            // provider fleet.
343            return MessageContent::Parts(vec![MessagePart::Text {
344                text: content.to_string(),
345                cache_control: Some(CacheControl {
346                    cache_type: "ephemeral".to_string(),
347                }),
348            }]);
349        }
350        if role != "user" {
351            return MessageContent::Text(content.to_string());
352        }
353
354        let (cleaned_text, image_refs) = multimodal::parse_image_markers(content);
355        if image_refs.is_empty() {
356            return MessageContent::Text(content.to_string());
357        }
358
359        let mut parts = Vec::with_capacity(image_refs.len() + 1);
360        let trimmed_text = cleaned_text.trim();
361        if !trimmed_text.is_empty() {
362            parts.push(MessagePart::Text {
363                text: trimmed_text.to_string(),
364                cache_control: None,
365            });
366        }
367
368        for image_ref in image_refs {
369            parts.push(MessagePart::ImageUrl {
370                image_url: ImageUrlPart { url: image_ref },
371            });
372        }
373
374        MessageContent::Parts(parts)
375    }
376
377    fn parse_native_response(message: NativeResponseMessage) -> ProviderChatResponse {
378        let reasoning_content = message.reasoning_content.clone();
379        let tool_calls = message
380            .tool_calls
381            .unwrap_or_default()
382            .into_iter()
383            .map(|tc| ProviderToolCall {
384                id: tc.id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
385                name: tc.function.name,
386                arguments: tc.function.arguments,
387                extra_content: None,
388            })
389            .collect::<Vec<_>>();
390
391        ProviderChatResponse {
392            text: message.content,
393            tool_calls,
394            usage: None,
395            reasoning_content,
396        }
397    }
398
399    fn compact_sanitized_body_snippet(body: &str) -> String {
400        super::sanitize_api_error(body)
401            .split_whitespace()
402            .collect::<Vec<_>>()
403            .join(" ")
404    }
405
406    async fn read_response_body(
407        provider_name: &str,
408        response: reqwest::Response,
409    ) -> anyhow::Result<String> {
410        response.text().await.map_err(|error| {
411            let sanitized = super::format_error_chain(&error);
412            ::zeroclaw_log::record!(
413                ERROR,
414                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
415                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
416                    .with_attrs(::serde_json::json!({
417                        "model_provider": provider_name,
418                        "body": &sanitized,
419                    })),
420                "openrouter: transport error reading response body"
421            );
422            anyhow::Error::msg(format!(
423                "{provider_name} transport error while reading response body: {sanitized}"
424            ))
425        })
426    }
427
428    fn parse_response_body<T: DeserializeOwned>(
429        provider_name: &str,
430        body: &str,
431        kind: &str,
432    ) -> anyhow::Result<T> {
433        serde_json::from_str::<T>(body).map_err(|error| {
434            let snippet = Self::compact_sanitized_body_snippet(body);
435            ::zeroclaw_log::record!(
436                ERROR,
437                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
438                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
439                    .with_attrs(::serde_json::json!({
440                        "model_provider": provider_name,
441                        "kind": kind,
442                        "body": &snippet,
443                        "error": format!("{}", error),
444                    })),
445                "openrouter: unexpected response payload"
446            );
447            anyhow::Error::msg(format!(
448                "{provider_name} API returned an unexpected {kind} payload: {error}; body={snippet}"
449            ))
450        })
451    }
452
453    /// Serialize `request` to JSON, merge `self.extra_body` keys at the top
454    /// level (extra_body wins on conflicts), and return the merged Value.
455    fn merge_extra_body<T: Serialize>(&self, request: &T) -> anyhow::Result<serde_json::Value> {
456        let Some(extra) = &self.extra_body else {
457            return Ok(serde_json::to_value(request)?);
458        };
459        let overrides = extra.as_object().ok_or_else(|| {
460            ::zeroclaw_log::record!(
461                WARN,
462                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
463                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
464                    .with_attrs(::serde_json::json!({"provider_extra": extra})),
465                "openrouter: provider_extra must be a JSON object"
466            );
467            anyhow::Error::msg(format!(
468                "provider_extra must be a JSON object, got: {extra}"
469            ))
470        })?;
471        let mut value = serde_json::to_value(request)?;
472        if let Some(base) = value.as_object_mut() {
473            for (k, v) in overrides {
474                base.insert(k.clone(), v.clone());
475            }
476        }
477        Ok(value)
478    }
479
480    fn http_client(&self) -> Client {
481        zeroclaw_config::schema::build_runtime_proxy_client_with_timeouts(
482            "model_provider.openrouter",
483            self.timeout_secs,
484            OPENROUTER_CONNECT_TIMEOUT_SECS,
485        )
486    }
487}
488
489#[async_trait]
490impl ModelProvider for OpenRouterModelProvider {
491    // ── ModelProvider-family defaults ──
492    fn default_base_url(&self) -> Option<&str> {
493        Some(BASE_URL)
494    }
495
496    fn capabilities(&self) -> ProviderCapabilities {
497        ProviderCapabilities {
498            native_tool_calling: true,
499            vision: true,
500            prompt_caching: false,
501            extended_thinking: false,
502        }
503    }
504
505    async fn warmup(&self) -> anyhow::Result<()> {
506        // Hit a lightweight endpoint to establish TLS + HTTP/2 connection pool.
507        // This prevents the first real chat request from timing out on cold start.
508        if let Some(credential) = self.credential.as_ref() {
509            self.http_client()
510                .get("https://openrouter.ai/api/v1/auth/key")
511                .header("Authorization", format!("Bearer {credential}"))
512                .send()
513                .await?
514                .error_for_status()?;
515        }
516        Ok(())
517    }
518
519    async fn list_models(&self) -> anyhow::Result<Vec<String>> {
520        // OpenRouter's /models endpoint is public — no credential required.
521        // Returns ~300 models across every model_provider OpenRouter proxies.
522        let response = self
523            .http_client()
524            .get("https://openrouter.ai/api/v1/models")
525            .send()
526            .await?
527            .error_for_status()?;
528
529        #[derive(Deserialize)]
530        struct Resp {
531            data: Vec<Entry>,
532        }
533        #[derive(Deserialize)]
534        struct Entry {
535            id: String,
536        }
537
538        let body: Resp = response.json().await?;
539        let mut ids: Vec<String> = body.data.into_iter().map(|e| e.id).collect();
540        ids.sort();
541        Ok(ids)
542    }
543
544    async fn chat_with_system(
545        &self,
546        system_prompt: Option<&str>,
547        message: &str,
548        model: &str,
549        temperature: Option<f64>,
550    ) -> anyhow::Result<String> {
551        let credential = self.credential.as_ref().ok_or_else(|| {
552            ::zeroclaw_log::record!(
553                ERROR,
554                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
555                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
556                    .with_attrs(::serde_json::json!({"missing": "credentials"})),
557                "openrouter: API key not configured"
558            );
559            anyhow::Error::msg(
560                "OpenRouter API key not set. Run `zeroclaw onboard` or set OPENROUTER_API_KEY env var.",
561            )
562        })?;
563
564        let temperature = temperature.unwrap_or(self.default_temperature());
565
566        let mut messages = Vec::new();
567
568        if let Some(sys) = system_prompt {
569            messages.push(Message {
570                role: "system".to_string(),
571                content: MessageContent::Text(sys.to_string()),
572            });
573        }
574
575        messages.push(Message {
576            role: "user".to_string(),
577            content: Self::to_message_content("user", message),
578        });
579
580        let request = ChatRequest {
581            model: model.to_string(),
582            messages,
583            temperature,
584            max_tokens: self.max_tokens,
585        };
586
587        let body = self.merge_extra_body(&request)?;
588        let response = self
589            .http_client()
590            .post("https://openrouter.ai/api/v1/chat/completions")
591            .header("Authorization", format!("Bearer {credential}"))
592            .header("HTTP-Referer", "https://github.com/zeroclaw-labs/zeroclaw")
593            .header("X-Title", "ZeroClaw")
594            .json(&body)
595            .send()
596            .await?;
597
598        if !response.status().is_success() {
599            return Err(super::api_error("OpenRouter", response).await);
600        }
601
602        let resp_body = Self::read_response_body("OpenRouter", response).await?;
603        let chat_response = Self::parse_response_body::<ApiChatResponse>(
604            "OpenRouter",
605            &resp_body,
606            "chat-completions",
607        )?;
608
609        chat_response
610            .choices
611            .into_iter()
612            .next()
613            .map(|c| c.message.content)
614            .ok_or_else(|| {
615                ::zeroclaw_log::record!(
616                    ERROR,
617                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
618                        .with_outcome(::zeroclaw_log::EventOutcome::Failure),
619                    "openrouter: empty choices in response"
620                );
621                anyhow::Error::msg("No response from OpenRouter")
622            })
623    }
624
625    async fn chat_with_history(
626        &self,
627        messages: &[ChatMessage],
628        model: &str,
629        temperature: Option<f64>,
630    ) -> anyhow::Result<String> {
631        let credential = self.credential.as_ref().ok_or_else(|| {
632            ::zeroclaw_log::record!(
633                ERROR,
634                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
635                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
636                    .with_attrs(::serde_json::json!({"missing": "credentials"})),
637                "openrouter: API key not configured"
638            );
639            anyhow::Error::msg(
640                "OpenRouter API key not set. Run `zeroclaw onboard` or set OPENROUTER_API_KEY env var.",
641            )
642        })?;
643
644        let temperature = temperature.unwrap_or(self.default_temperature());
645
646        let api_messages: Vec<Message> = messages
647            .iter()
648            .map(|m| Message {
649                role: m.role.clone(),
650                content: Self::to_message_content(&m.role, &m.content),
651            })
652            .collect();
653
654        let request = ChatRequest {
655            model: model.to_string(),
656            messages: api_messages,
657            temperature,
658            max_tokens: self.max_tokens,
659        };
660
661        let body = self.merge_extra_body(&request)?;
662        let response = self
663            .http_client()
664            .post("https://openrouter.ai/api/v1/chat/completions")
665            .header("Authorization", format!("Bearer {credential}"))
666            .header("HTTP-Referer", "https://github.com/zeroclaw-labs/zeroclaw")
667            .header("X-Title", "ZeroClaw")
668            .json(&body)
669            .send()
670            .await?;
671
672        if !response.status().is_success() {
673            return Err(super::api_error("OpenRouter", response).await);
674        }
675
676        let resp_body = Self::read_response_body("OpenRouter", response).await?;
677        let chat_response = Self::parse_response_body::<ApiChatResponse>(
678            "OpenRouter",
679            &resp_body,
680            "chat-completions",
681        )?;
682
683        chat_response
684            .choices
685            .into_iter()
686            .next()
687            .map(|c| c.message.content)
688            .ok_or_else(|| {
689                ::zeroclaw_log::record!(
690                    ERROR,
691                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
692                        .with_outcome(::zeroclaw_log::EventOutcome::Failure),
693                    "openrouter: empty choices in response"
694                );
695                anyhow::Error::msg("No response from OpenRouter")
696            })
697    }
698
699    async fn chat(
700        &self,
701        request: ProviderChatRequest<'_>,
702        model: &str,
703        temperature: Option<f64>,
704    ) -> anyhow::Result<ProviderChatResponse> {
705        let credential = self.credential.as_ref().ok_or_else(|| {
706            ::zeroclaw_log::record!(
707                ERROR,
708                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
709                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
710                    .with_attrs(::serde_json::json!({"missing": "credentials"})),
711                "openrouter: API key not configured"
712            );
713            anyhow::Error::msg(
714                "OpenRouter API key not set. Run `zeroclaw onboard` or set OPENROUTER_API_KEY env var.",
715            )
716        })?;
717
718        let temperature = temperature.unwrap_or(self.default_temperature());
719
720        let tools = Self::convert_tools(request.tools);
721        let native_request = NativeChatRequest {
722            model: model.to_string(),
723            messages: Self::convert_messages(request.messages),
724            temperature,
725            tool_choice: tools.as_ref().map(|_| "auto".to_string()),
726            tools,
727            max_tokens: self.max_tokens,
728            stream: None,
729        };
730
731        let body = self.merge_extra_body(&native_request)?;
732        let response = self
733            .http_client()
734            .post("https://openrouter.ai/api/v1/chat/completions")
735            .header("Authorization", format!("Bearer {credential}"))
736            .header("HTTP-Referer", "https://github.com/zeroclaw-labs/zeroclaw")
737            .header("X-Title", "ZeroClaw")
738            .json(&body)
739            .send()
740            .await?;
741
742        if !response.status().is_success() {
743            return Err(super::api_error("OpenRouter", response).await);
744        }
745
746        let resp_body = Self::read_response_body("OpenRouter", response).await?;
747        let native_response = Self::parse_response_body::<NativeChatResponse>(
748            "OpenRouter",
749            &resp_body,
750            "native chat",
751        )?;
752        // OpenRouter surfaces cached-token accounting via
753        // `usage.prompt_tokens_details.cached_tokens` when the upstream
754        // provider supports prompt caching. For providers without caching
755        // the field is absent and we report `None`.
756        let usage = native_response.usage.map(|u| TokenUsage {
757            input_tokens: u.prompt_tokens,
758            output_tokens: u.completion_tokens,
759            cached_input_tokens: u.prompt_tokens_details.and_then(|d| d.cached_tokens),
760        });
761        let message = native_response
762            .choices
763            .into_iter()
764            .next()
765            .map(|c| c.message)
766            .ok_or_else(|| {
767                ::zeroclaw_log::record!(
768                    ERROR,
769                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
770                        .with_outcome(::zeroclaw_log::EventOutcome::Failure),
771                    "openrouter: empty choices in response"
772                );
773                anyhow::Error::msg("No response from OpenRouter")
774            })?;
775        let mut result = Self::parse_native_response(message);
776        result.usage = usage;
777        Ok(result)
778    }
779
780    fn supports_native_tools(&self) -> bool {
781        true
782    }
783
784    fn supports_streaming(&self) -> bool {
785        true
786    }
787
788    fn supports_streaming_tool_events(&self) -> bool {
789        true
790    }
791
792    fn stream_chat(
793        &self,
794        request: ProviderChatRequest<'_>,
795        model: &str,
796        temperature: Option<f64>,
797        options: StreamOptions,
798    ) -> stream::BoxStream<'static, StreamResult<StreamEvent>> {
799        if !options.enabled {
800            return stream::once(async { Ok(StreamEvent::Final) }).boxed();
801        }
802
803        let credential = match self.credential.as_ref() {
804            Some(c) => c.clone(),
805            None => {
806                return stream::once(async {
807                    Err(StreamError::ModelProvider(
808                        "OpenRouter API key not set. Run `zeroclaw onboard` or set OPENROUTER_API_KEY env var.".to_string(),
809                    ))
810                })
811                .boxed();
812            }
813        };
814
815        let temperature = temperature.unwrap_or(self.default_temperature());
816
817        let tools = Self::convert_tools(request.tools);
818        let native_request = NativeChatRequest {
819            model: model.to_string(),
820            messages: Self::convert_messages(request.messages),
821            temperature,
822            tool_choice: tools.as_ref().map(|_| "auto".to_string()),
823            tools,
824            max_tokens: self.max_tokens,
825            stream: Some(true),
826        };
827
828        let payload = match serde_json::to_value(&native_request) {
829            Ok(v) => v,
830            Err(e) => {
831                return stream::once(async move { Err(StreamError::Json(e)) }).boxed();
832            }
833        };
834
835        let client = self.http_client();
836        let count_tokens = options.count_tokens;
837
838        let (tx, rx) = tokio::sync::mpsc::channel::<StreamResult<StreamEvent>>(100);
839
840        let handle = tokio::spawn(async move {
841            let response = match client
842                .post("https://openrouter.ai/api/v1/chat/completions")
843                .header("Authorization", format!("Bearer {credential}"))
844                .header("HTTP-Referer", "https://github.com/zeroclaw-labs/zeroclaw")
845                .header("X-Title", "ZeroClaw")
846                .header("Accept", "text/event-stream")
847                .json(&payload)
848                .send()
849                .await
850            {
851                Ok(r) => r,
852                Err(e) => {
853                    let _ = tx
854                        .send(Err(StreamError::Http(super::format_error_chain(&e))))
855                        .await;
856                    return;
857                }
858            };
859
860            if !response.status().is_success() {
861                let status = response.status();
862                let error = response
863                    .text()
864                    .await
865                    .unwrap_or_else(|_| format!("HTTP error: {status}"));
866                let _ = tx
867                    .send(Err(StreamError::ModelProvider(format!(
868                        "{status}: {error}"
869                    ))))
870                    .await;
871                return;
872            }
873
874            let mut event_stream = sse_bytes_to_events(response, count_tokens);
875            while let Some(event) = event_stream.next().await {
876                if tx.send(event).await.is_err() {
877                    break;
878                }
879            }
880        });
881
882        // Bind the task's lifetime to the returned stream so dropping the
883        // stream cancels the in-flight HTTP request. Without this guard the
884        // spawned task keeps reading the response body to completion after
885        // the consumer is gone, holding a connection-pool slot and
886        // consuming OpenRouter quota for a request the caller no longer
887        // wants. `AbortHandle::abort` is a no-op if the task has already
888        // finished, so the happy path is unaffected.
889        let guard = AbortOnDrop(handle.abort_handle());
890
891        stream::unfold((rx, guard), |(mut rx, guard)| async move {
892            rx.recv().await.map(|event| (event, (rx, guard)))
893        })
894        .boxed()
895    }
896
897    async fn chat_with_tools(
898        &self,
899        messages: &[ChatMessage],
900        tools: &[serde_json::Value],
901        model: &str,
902        temperature: Option<f64>,
903    ) -> anyhow::Result<ProviderChatResponse> {
904        let credential = self.credential.as_ref().ok_or_else(|| {
905            ::zeroclaw_log::record!(
906                ERROR,
907                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
908                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
909                    .with_attrs(::serde_json::json!({"missing": "credentials"})),
910                "openrouter: API key not configured"
911            );
912            anyhow::Error::msg(
913                "OpenRouter API key not set. Run `zeroclaw onboard` or set OPENROUTER_API_KEY env var.",
914            )
915        })?;
916
917        let temperature = temperature.unwrap_or(self.default_temperature());
918
919        // Convert tool JSON values to NativeToolSpec
920        let native_tools: Option<Vec<NativeToolSpec>> = if tools.is_empty() {
921            None
922        } else {
923            let specs: Vec<NativeToolSpec> = tools
924                .iter()
925                .filter_map(|t| {
926                    let func = t.get("function")?;
927                    Some(NativeToolSpec {
928                        kind: "function".to_string(),
929                        function: NativeToolFunctionSpec {
930                            name: func.get("name")?.as_str()?.to_string(),
931                            description: func
932                                .get("description")
933                                .and_then(|d| d.as_str())
934                                .unwrap_or("")
935                                .to_string(),
936                            parameters: func
937                                .get("parameters")
938                                .cloned()
939                                .unwrap_or(serde_json::json!({})),
940                        },
941                    })
942                })
943                .collect();
944            if specs.is_empty() { None } else { Some(specs) }
945        };
946
947        // Convert ChatMessage to NativeMessage, preserving structured assistant/tool entries
948        // when history contains native tool-call metadata.
949        let native_messages = Self::convert_messages(messages);
950
951        let native_request = NativeChatRequest {
952            model: model.to_string(),
953            messages: native_messages,
954            temperature,
955            tool_choice: native_tools.as_ref().map(|_| "auto".to_string()),
956            tools: native_tools,
957            max_tokens: self.max_tokens,
958            stream: None,
959        };
960
961        let body = self.merge_extra_body(&native_request)?;
962        let response = self
963            .http_client()
964            .post("https://openrouter.ai/api/v1/chat/completions")
965            .header("Authorization", format!("Bearer {credential}"))
966            .header("HTTP-Referer", "https://github.com/zeroclaw-labs/zeroclaw")
967            .header("X-Title", "ZeroClaw")
968            .json(&body)
969            .send()
970            .await?;
971
972        if !response.status().is_success() {
973            return Err(super::api_error("OpenRouter", response).await);
974        }
975
976        let resp_body = Self::read_response_body("OpenRouter", response).await?;
977        let native_response = Self::parse_response_body::<NativeChatResponse>(
978            "OpenRouter",
979            &resp_body,
980            "native chat",
981        )?;
982        // OpenRouter surfaces cached-token accounting via
983        // `usage.prompt_tokens_details.cached_tokens` when the upstream
984        // provider supports prompt caching. For providers without caching
985        // the field is absent and we report `None`.
986        let usage = native_response.usage.map(|u| TokenUsage {
987            input_tokens: u.prompt_tokens,
988            output_tokens: u.completion_tokens,
989            cached_input_tokens: u.prompt_tokens_details.and_then(|d| d.cached_tokens),
990        });
991        let message = native_response
992            .choices
993            .into_iter()
994            .next()
995            .map(|c| c.message)
996            .ok_or_else(|| {
997                ::zeroclaw_log::record!(
998                    ERROR,
999                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
1000                        .with_outcome(::zeroclaw_log::EventOutcome::Failure),
1001                    "openrouter: empty choices in response"
1002                );
1003                anyhow::Error::msg("No response from OpenRouter")
1004            })?;
1005        let mut result = Self::parse_native_response(message);
1006        result.usage = usage;
1007        Ok(result)
1008    }
1009}
1010
1011/// Check if a tool name is valid for OpenAI-compatible APIs.
1012/// Must match `^[a-zA-Z0-9_-]{1,64}$`.
1013fn is_valid_openai_tool_name(name: &str) -> bool {
1014    !name.is_empty()
1015        && name.len() <= 64
1016        && name
1017            .bytes()
1018            .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
1019}
1020
1021impl ::zeroclaw_api::attribution::Attributable for OpenRouterModelProvider {
1022    fn role(&self) -> ::zeroclaw_api::attribution::Role {
1023        ::zeroclaw_api::attribution::Role::Provider(
1024            ::zeroclaw_api::attribution::ProviderKind::Model(
1025                ::zeroclaw_api::attribution::ModelProviderKind::OpenRouter,
1026            ),
1027        )
1028    }
1029    fn alias(&self) -> &str {
1030        &self.alias
1031    }
1032}
1033
1034#[cfg(test)]
1035mod tests {
1036    use super::*;
1037    use crate::traits::{ChatMessage, ModelProvider};
1038
1039    #[test]
1040    fn capabilities_report_vision_support() {
1041        let model_provider =
1042            OpenRouterModelProvider::new("test", Some("openrouter-test-credential"), None);
1043        let caps = <OpenRouterModelProvider as ModelProvider>::capabilities(&model_provider);
1044        assert!(caps.native_tool_calling);
1045        assert!(caps.vision);
1046    }
1047
1048    #[test]
1049    fn supports_streaming_returns_true() {
1050        let model_provider =
1051            OpenRouterModelProvider::new("test", Some("openrouter-test-credential"), None);
1052        assert!(model_provider.supports_streaming());
1053    }
1054
1055    #[test]
1056    fn supports_streaming_tool_events_returns_true() {
1057        let model_provider =
1058            OpenRouterModelProvider::new("test", Some("openrouter-test-credential"), None);
1059        assert!(model_provider.supports_streaming_tool_events());
1060    }
1061
1062    #[tokio::test]
1063    async fn stream_chat_without_key_returns_error_event() {
1064        use crate::traits::{ChatMessage, ChatRequest};
1065        use futures_util::StreamExt as _;
1066
1067        let model_provider = OpenRouterModelProvider::new("test", None, None);
1068        let messages = vec![ChatMessage {
1069            role: "user".into(),
1070            content: "hello".into(),
1071        }];
1072        let request = ChatRequest {
1073            messages: &messages,
1074            tools: None,
1075            thinking: None,
1076        };
1077
1078        let mut stream = model_provider.stream_chat(
1079            request,
1080            "anthropic/claude-haiku-4-5",
1081            Some(0.0),
1082            crate::traits::StreamOptions {
1083                enabled: true,
1084                count_tokens: false,
1085            },
1086        );
1087
1088        let first = stream
1089            .next()
1090            .await
1091            .expect("stream should yield at least one event");
1092        assert!(first.is_err(), "expected error without API key");
1093        let err = first.unwrap_err();
1094        let msg = err.to_string();
1095        assert!(
1096            msg.contains("API key not set"),
1097            "error should mention API key: {msg}"
1098        );
1099    }
1100
1101    #[tokio::test]
1102    async fn stream_chat_disabled_options_returns_final() {
1103        use crate::traits::{ChatMessage, ChatRequest, StreamEvent};
1104        use futures_util::StreamExt as _;
1105
1106        let model_provider = OpenRouterModelProvider::new("test", Some("key"), None);
1107        let messages = vec![ChatMessage {
1108            role: "user".into(),
1109            content: "hello".into(),
1110        }];
1111        let request = ChatRequest {
1112            messages: &messages,
1113            tools: None,
1114            thinking: None,
1115        };
1116
1117        let mut stream = model_provider.stream_chat(
1118            request,
1119            "anthropic/claude-haiku-4-5",
1120            Some(0.0),
1121            crate::traits::StreamOptions {
1122                enabled: false,
1123                count_tokens: false,
1124            },
1125        );
1126
1127        let first = stream
1128            .next()
1129            .await
1130            .expect("stream should yield Final immediately");
1131        assert!(matches!(first, Ok(StreamEvent::Final)));
1132    }
1133
1134    #[test]
1135    fn native_chat_request_serializes_stream_true() {
1136        let req = NativeChatRequest {
1137            model: "anthropic/claude-haiku-4-5".into(),
1138            messages: vec![],
1139            temperature: 0.0,
1140            tools: None,
1141            tool_choice: None,
1142            max_tokens: None,
1143            stream: Some(true),
1144        };
1145        let json = serde_json::to_string(&req).unwrap();
1146        assert!(json.contains("\"stream\":true"));
1147    }
1148
1149    #[test]
1150    fn native_chat_request_omits_stream_when_none() {
1151        let req = NativeChatRequest {
1152            model: "anthropic/claude-haiku-4-5".into(),
1153            messages: vec![],
1154            temperature: 0.0,
1155            tools: None,
1156            tool_choice: None,
1157            max_tokens: None,
1158            stream: None,
1159        };
1160        let json = serde_json::to_string(&req).unwrap();
1161        assert!(!json.contains("stream"));
1162    }
1163
1164    #[test]
1165    fn creates_with_key() {
1166        let model_provider =
1167            OpenRouterModelProvider::new("test", Some("openrouter-test-credential"), None);
1168        assert_eq!(
1169            model_provider.credential.as_deref(),
1170            Some("openrouter-test-credential")
1171        );
1172    }
1173
1174    #[test]
1175    fn creates_without_key() {
1176        let model_provider = OpenRouterModelProvider::new("test", None, None);
1177        assert!(model_provider.credential.is_none());
1178    }
1179
1180    #[test]
1181    fn uses_configured_timeout_when_provided() {
1182        let model_provider =
1183            OpenRouterModelProvider::new("test", Some("openrouter-test-credential"), Some(1200));
1184        assert_eq!(model_provider.timeout_secs, 1200);
1185    }
1186
1187    #[test]
1188    fn falls_back_to_default_timeout_for_zero() {
1189        let model_provider =
1190            OpenRouterModelProvider::new("test", Some("openrouter-test-credential"), Some(0));
1191        assert_eq!(
1192            model_provider.timeout_secs,
1193            zeroclaw_api::model_provider::BASELINE_TIMEOUT_SECS
1194        );
1195    }
1196
1197    #[tokio::test]
1198    async fn warmup_without_key_is_noop() {
1199        let model_provider = OpenRouterModelProvider::new("test", None, None);
1200        let result = model_provider.warmup().await;
1201        assert!(result.is_ok());
1202    }
1203
1204    #[tokio::test]
1205    async fn chat_with_system_fails_without_key() {
1206        let model_provider = OpenRouterModelProvider::new("test", None, None);
1207        let result = model_provider
1208            .chat_with_system(Some("system"), "hello", "openai/gpt-4o", Some(0.2))
1209            .await;
1210
1211        assert!(result.is_err());
1212        assert!(result.unwrap_err().to_string().contains("API key not set"));
1213    }
1214
1215    #[tokio::test]
1216    async fn chat_with_history_fails_without_key() {
1217        let model_provider = OpenRouterModelProvider::new("test", None, None);
1218        let messages = vec![
1219            ChatMessage {
1220                role: "system".into(),
1221                content: "be concise".into(),
1222            },
1223            ChatMessage {
1224                role: "user".into(),
1225                content: "hello".into(),
1226            },
1227        ];
1228
1229        let result = model_provider
1230            .chat_with_history(&messages, "anthropic/claude-sonnet-4", Some(0.7))
1231            .await;
1232
1233        assert!(result.is_err());
1234        assert!(result.unwrap_err().to_string().contains("API key not set"));
1235    }
1236
1237    #[test]
1238    fn chat_request_serializes_with_system_and_user() {
1239        let request = ChatRequest {
1240            model: "anthropic/claude-sonnet-4".into(),
1241            messages: vec![
1242                Message {
1243                    role: "system".into(),
1244                    content: MessageContent::Text("You are helpful".into()),
1245                },
1246                Message {
1247                    role: "user".into(),
1248                    content: MessageContent::Text("Summarize this".into()),
1249                },
1250            ],
1251            temperature: 0.5,
1252            max_tokens: None,
1253        };
1254
1255        let json = serde_json::to_string(&request).unwrap();
1256
1257        assert!(json.contains("anthropic/claude-sonnet-4"));
1258        assert!(json.contains("\"role\":\"system\""));
1259        assert!(json.contains("\"role\":\"user\""));
1260        assert!(json.contains("\"temperature\":0.5"));
1261    }
1262
1263    #[test]
1264    fn chat_request_serializes_history_messages() {
1265        let messages = [
1266            ChatMessage {
1267                role: "assistant".into(),
1268                content: "Previous answer".into(),
1269            },
1270            ChatMessage {
1271                role: "user".into(),
1272                content: "Follow-up".into(),
1273            },
1274        ];
1275
1276        let request = ChatRequest {
1277            model: "google/gemini-2.5-pro".into(),
1278            messages: messages
1279                .iter()
1280                .map(|msg| Message {
1281                    role: msg.role.clone(),
1282                    content: MessageContent::Text(msg.content.clone()),
1283                })
1284                .collect(),
1285            temperature: 0.0,
1286            max_tokens: None,
1287        };
1288
1289        let json = serde_json::to_string(&request).unwrap();
1290        assert!(json.contains("\"role\":\"assistant\""));
1291        assert!(json.contains("\"role\":\"user\""));
1292        assert!(json.contains("google/gemini-2.5-pro"));
1293    }
1294
1295    #[test]
1296    fn response_deserializes_single_choice() {
1297        let json = r#"{"choices":[{"message":{"content":"Hi from OpenRouter"}}]}"#;
1298
1299        let response: ApiChatResponse = serde_json::from_str(json).unwrap();
1300
1301        assert_eq!(response.choices.len(), 1);
1302        assert_eq!(response.choices[0].message.content, "Hi from OpenRouter");
1303    }
1304
1305    #[test]
1306    fn response_deserializes_empty_choices() {
1307        let json = r#"{"choices":[]}"#;
1308
1309        let response: ApiChatResponse = serde_json::from_str(json).unwrap();
1310
1311        assert!(response.choices.is_empty());
1312    }
1313
1314    #[test]
1315    fn parse_chat_response_body_reports_sanitized_snippet() {
1316        let body = r#"{"choices":"invalid","api_key":"sk-test-secret-value"}"#;
1317        let err = OpenRouterModelProvider::parse_response_body::<ApiChatResponse>(
1318            "OpenRouter",
1319            body,
1320            "chat-completions",
1321        )
1322        .expect_err("payload should fail");
1323        let msg = err.to_string();
1324
1325        assert!(msg.contains("OpenRouter API returned an unexpected chat-completions payload"));
1326        assert!(msg.contains("body="));
1327        assert!(msg.contains("[REDACTED]"));
1328        assert!(!msg.contains("sk-test-secret-value"));
1329    }
1330
1331    #[test]
1332    fn parse_native_response_body_reports_sanitized_snippet() {
1333        let body = r#"{"choices":123,"api_key":"sk-another-secret"}"#;
1334        let err = OpenRouterModelProvider::parse_response_body::<NativeChatResponse>(
1335            "OpenRouter",
1336            body,
1337            "native chat",
1338        )
1339        .expect_err("payload should fail");
1340        let msg = err.to_string();
1341
1342        assert!(msg.contains("OpenRouter API returned an unexpected native chat payload"));
1343        assert!(msg.contains("body="));
1344        assert!(msg.contains("[REDACTED]"));
1345        assert!(!msg.contains("sk-another-secret"));
1346    }
1347
1348    #[tokio::test]
1349    async fn chat_with_tools_fails_without_key() {
1350        let model_provider = OpenRouterModelProvider::new("test", None, None);
1351        let messages = vec![ChatMessage {
1352            role: "user".into(),
1353            content: "What is the date?".into(),
1354        }];
1355        let tools = vec![serde_json::json!({
1356            "type": "function",
1357            "function": {
1358                "name": "shell",
1359                "description": "Run a shell command",
1360                "parameters": {"type": "object", "properties": {"command": {"type": "string"}}}
1361            }
1362        })];
1363
1364        let result = model_provider
1365            .chat_with_tools(&messages, &tools, "deepseek/deepseek-chat", Some(0.5))
1366            .await;
1367
1368        assert!(result.is_err());
1369        assert!(result.unwrap_err().to_string().contains("API key not set"));
1370    }
1371
1372    #[test]
1373    fn native_response_deserializes_with_tool_calls() {
1374        let json = r#"{
1375            "choices":[{
1376                "message":{
1377                    "content":null,
1378                    "tool_calls":[
1379                        {"id":"call_123","type":"function","function":{"name":"get_price","arguments":"{\"symbol\":\"BTC\"}"}}
1380                    ]
1381                }
1382            }]
1383        }"#;
1384
1385        let response: NativeChatResponse = serde_json::from_str(json).unwrap();
1386
1387        assert_eq!(response.choices.len(), 1);
1388        let message = &response.choices[0].message;
1389        assert!(message.content.is_none());
1390        let tool_calls = message.tool_calls.as_ref().unwrap();
1391        assert_eq!(tool_calls.len(), 1);
1392        assert_eq!(tool_calls[0].id.as_deref(), Some("call_123"));
1393        assert_eq!(tool_calls[0].function.name, "get_price");
1394        assert_eq!(tool_calls[0].function.arguments, "{\"symbol\":\"BTC\"}");
1395    }
1396
1397    #[test]
1398    fn native_response_deserializes_with_text_and_tool_calls() {
1399        let json = r#"{
1400            "choices":[{
1401                "message":{
1402                    "content":"I'll get that for you.",
1403                    "tool_calls":[
1404                        {"id":"call_456","type":"function","function":{"name":"shell","arguments":"{\"command\":\"date\"}"}}
1405                    ]
1406                }
1407            }]
1408        }"#;
1409
1410        let response: NativeChatResponse = serde_json::from_str(json).unwrap();
1411
1412        assert_eq!(response.choices.len(), 1);
1413        let message = &response.choices[0].message;
1414        assert_eq!(message.content.as_deref(), Some("I'll get that for you."));
1415        let tool_calls = message.tool_calls.as_ref().unwrap();
1416        assert_eq!(tool_calls.len(), 1);
1417        assert_eq!(tool_calls[0].function.name, "shell");
1418    }
1419
1420    #[test]
1421    fn parse_native_response_converts_to_chat_response() {
1422        let message = NativeResponseMessage {
1423            content: Some("Here you go.".into()),
1424            reasoning_content: None,
1425            tool_calls: Some(vec![NativeToolCall {
1426                id: Some("call_789".into()),
1427                kind: Some("function".into()),
1428                function: NativeFunctionCall {
1429                    name: "file_read".into(),
1430                    arguments: r#"{"path":"test.txt"}"#.into(),
1431                },
1432            }]),
1433        };
1434
1435        let response = OpenRouterModelProvider::parse_native_response(message);
1436
1437        assert_eq!(response.text.as_deref(), Some("Here you go."));
1438        assert_eq!(response.tool_calls.len(), 1);
1439        assert_eq!(response.tool_calls[0].id, "call_789");
1440        assert_eq!(response.tool_calls[0].name, "file_read");
1441    }
1442
1443    #[test]
1444    fn convert_messages_parses_assistant_tool_call_payload() {
1445        let messages = vec![ChatMessage {
1446            role: "assistant".into(),
1447            content: r#"{"content":"Using tool","tool_calls":[{"id":"call_abc","name":"shell","arguments":"{\"command\":\"pwd\"}"}]}"#
1448                .into(),
1449        }];
1450
1451        let converted = OpenRouterModelProvider::convert_messages(&messages);
1452        assert_eq!(converted.len(), 1);
1453        assert_eq!(converted[0].role, "assistant");
1454        assert_eq!(
1455            converted[0]
1456                .content
1457                .as_ref()
1458                .and_then(|content| match content {
1459                    MessageContent::Text(value) => Some(value.as_str()),
1460                    MessageContent::Parts(_) => None,
1461                }),
1462            Some("Using tool")
1463        );
1464
1465        let tool_calls = converted[0].tool_calls.as_ref().unwrap();
1466        assert_eq!(tool_calls.len(), 1);
1467        assert_eq!(tool_calls[0].id.as_deref(), Some("call_abc"));
1468        assert_eq!(tool_calls[0].function.name, "shell");
1469        assert_eq!(tool_calls[0].function.arguments, r#"{"command":"pwd"}"#);
1470    }
1471
1472    #[test]
1473    fn convert_messages_parses_tool_result_payload() {
1474        let messages = vec![ChatMessage {
1475            role: "tool".into(),
1476            content: r#"{"tool_call_id":"call_xyz","content":"done"}"#.into(),
1477        }];
1478
1479        let converted = OpenRouterModelProvider::convert_messages(&messages);
1480        assert_eq!(converted.len(), 1);
1481        assert_eq!(converted[0].role, "tool");
1482        assert_eq!(converted[0].tool_call_id.as_deref(), Some("call_xyz"));
1483        assert_eq!(
1484            converted[0]
1485                .content
1486                .as_ref()
1487                .and_then(|content| match content {
1488                    MessageContent::Text(value) => Some(value.as_str()),
1489                    MessageContent::Parts(_) => None,
1490                }),
1491            Some("done")
1492        );
1493        assert!(converted[0].tool_calls.is_none());
1494    }
1495
1496    #[test]
1497    fn to_message_content_converts_image_markers_to_openai_parts() {
1498        let content = "Describe this\n\n[IMAGE:data:image/png;base64,abcd]";
1499        let value =
1500            serde_json::to_value(OpenRouterModelProvider::to_message_content("user", content))
1501                .unwrap();
1502        let parts = value
1503            .as_array()
1504            .expect("multimodal content should be an array");
1505        assert_eq!(parts.len(), 2);
1506        assert_eq!(parts[0]["type"], "text");
1507        assert_eq!(parts[0]["text"], "Describe this");
1508        assert_eq!(parts[1]["type"], "image_url");
1509        assert_eq!(parts[1]["image_url"]["url"], "data:image/png;base64,abcd");
1510    }
1511
1512    #[test]
1513    fn native_response_parses_usage() {
1514        let json = r#"{
1515            "choices": [{"message": {"content": "Hello"}}],
1516            "usage": {"prompt_tokens": 42, "completion_tokens": 15}
1517        }"#;
1518        let resp: NativeChatResponse = serde_json::from_str(json).unwrap();
1519        let usage = resp.usage.unwrap();
1520        assert_eq!(usage.prompt_tokens, Some(42));
1521        assert_eq!(usage.completion_tokens, Some(15));
1522    }
1523
1524    #[test]
1525    fn native_response_parses_without_usage() {
1526        let json = r#"{"choices": [{"message": {"content": "Hello"}}]}"#;
1527        let resp: NativeChatResponse = serde_json::from_str(json).unwrap();
1528        assert!(resp.usage.is_none());
1529    }
1530
1531    // ═══════════════════════════════════════════════════════════════════════
1532    // prompt caching: request-side serialization
1533    // ═══════════════════════════════════════════════════════════════════════
1534
1535    #[test]
1536    fn system_message_serializes_as_content_block_with_cache_control() {
1537        let content = OpenRouterModelProvider::to_message_content("system", "You are helpful.");
1538        let json = serde_json::to_value(&content).unwrap();
1539        let parts = json.as_array().expect("system content should be an array");
1540        assert_eq!(parts.len(), 1);
1541        assert_eq!(parts[0]["type"], "text");
1542        assert_eq!(parts[0]["text"], "You are helpful.");
1543        assert_eq!(parts[0]["cache_control"]["type"], "ephemeral");
1544    }
1545
1546    #[test]
1547    fn user_message_without_images_serializes_as_plain_string() {
1548        let content = OpenRouterModelProvider::to_message_content("user", "Hello");
1549        let json = serde_json::to_value(&content).unwrap();
1550        assert!(json.is_string(), "user content should be a plain string");
1551        assert_eq!(json.as_str().unwrap(), "Hello");
1552    }
1553
1554    #[test]
1555    fn assistant_message_serializes_as_plain_string() {
1556        let content = OpenRouterModelProvider::to_message_content("assistant", "Hi there.");
1557        let json = serde_json::to_value(&content).unwrap();
1558        assert!(
1559            json.is_string(),
1560            "assistant content should be a plain string"
1561        );
1562        assert_eq!(json.as_str().unwrap(), "Hi there.");
1563    }
1564
1565    #[test]
1566    fn tool_message_serializes_as_plain_string() {
1567        let content = OpenRouterModelProvider::to_message_content(
1568            "tool",
1569            r#"{"tool_call_id":"call_1","content":"ok"}"#,
1570        );
1571        let json = serde_json::to_value(&content).unwrap();
1572        assert!(json.is_string(), "tool content should be a plain string");
1573    }
1574
1575    #[test]
1576    fn cache_control_absent_on_user_image_text_part() {
1577        let content = OpenRouterModelProvider::to_message_content(
1578            "user",
1579            "Describe this\n\n[IMAGE:data:image/png;base64,abcd]",
1580        );
1581        let json = serde_json::to_value(&content).unwrap();
1582        let parts = json
1583            .as_array()
1584            .expect("multimodal content should be an array");
1585        let text_part = &parts[0];
1586        assert_eq!(text_part["type"], "text");
1587        assert!(
1588            text_part.get("cache_control").is_none(),
1589            "cache_control should not appear on user image text parts (got {:?})",
1590            text_part.get("cache_control")
1591        );
1592    }
1593
1594    #[test]
1595    fn full_native_request_serializes_system_as_blocks_user_as_string() {
1596        let messages = vec![
1597            ChatMessage {
1598                role: "system".into(),
1599                content: "Be helpful".into(),
1600            },
1601            ChatMessage {
1602                role: "user".into(),
1603                content: "Hi".into(),
1604            },
1605        ];
1606        let native = OpenRouterModelProvider::convert_messages(&messages);
1607        assert_eq!(native.len(), 2);
1608
1609        let sys_json = serde_json::to_value(&native[0].content).unwrap();
1610        let sys_parts = sys_json.as_array().expect("system content should be array");
1611        assert_eq!(sys_parts[0]["cache_control"]["type"], "ephemeral");
1612        assert_eq!(sys_parts[0]["text"], "Be helpful");
1613
1614        let user_json = serde_json::to_value(&native[1].content).unwrap();
1615        assert!(user_json.is_string(), "user content should be a string");
1616    }
1617
1618    // ═══════════════════════════════════════════════════════════════════════
1619    // prompt caching: response-side deserialization and token mapping
1620    // ═══════════════════════════════════════════════════════════════════════
1621
1622    #[test]
1623    fn usage_info_deserializes_prompt_tokens_details() {
1624        let json = r#"{
1625            "prompt_tokens": 25000,
1626            "completion_tokens": 500,
1627            "prompt_tokens_details": {"cached_tokens": 20000}
1628        }"#;
1629        let usage: UsageInfo = serde_json::from_str(json).unwrap();
1630        assert_eq!(usage.prompt_tokens, Some(25000));
1631        assert_eq!(usage.completion_tokens, Some(500));
1632        let details = usage
1633            .prompt_tokens_details
1634            .expect("prompt_tokens_details should deserialize");
1635        assert_eq!(details.cached_tokens, Some(20000));
1636    }
1637
1638    #[test]
1639    fn usage_info_deserializes_without_prompt_tokens_details() {
1640        let json = r#"{"prompt_tokens": 100, "completion_tokens": 50}"#;
1641        let usage: UsageInfo = serde_json::from_str(json).unwrap();
1642        assert!(
1643            usage.prompt_tokens_details.is_none(),
1644            "absent field should deserialize to None (backward compat with providers without caching)"
1645        );
1646    }
1647
1648    #[test]
1649    fn usage_info_deserializes_empty_prompt_tokens_details() {
1650        let json = r#"{
1651            "prompt_tokens": 100,
1652            "completion_tokens": 50,
1653            "prompt_tokens_details": {}
1654        }"#;
1655        let usage: UsageInfo = serde_json::from_str(json).unwrap();
1656        let details = usage.prompt_tokens_details.unwrap();
1657        assert!(details.cached_tokens.is_none());
1658    }
1659
1660    #[test]
1661    fn usage_info_deserializes_zero_cached_tokens_as_some_zero() {
1662        let json = r#"{
1663            "prompt_tokens": 100,
1664            "completion_tokens": 50,
1665            "prompt_tokens_details": {"cached_tokens": 0}
1666        }"#;
1667        let usage: UsageInfo = serde_json::from_str(json).unwrap();
1668        let details = usage.prompt_tokens_details.unwrap();
1669        assert_eq!(details.cached_tokens, Some(0));
1670    }
1671
1672    #[test]
1673    fn native_response_maps_cached_tokens_into_token_usage() {
1674        let json = r#"{
1675            "choices": [{"message": {"content": "Hello"}}],
1676            "usage": {
1677                "prompt_tokens": 25000,
1678                "completion_tokens": 500,
1679                "prompt_tokens_details": {"cached_tokens": 15000}
1680            }
1681        }"#;
1682        let resp: NativeChatResponse = serde_json::from_str(json).unwrap();
1683        let usage = resp
1684            .usage
1685            .map(|u| TokenUsage {
1686                input_tokens: u.prompt_tokens,
1687                output_tokens: u.completion_tokens,
1688                cached_input_tokens: u.prompt_tokens_details.and_then(|d| d.cached_tokens),
1689            })
1690            .expect("usage should be Some");
1691        assert_eq!(usage.input_tokens, Some(25000));
1692        assert_eq!(usage.output_tokens, Some(500));
1693        assert_eq!(usage.cached_input_tokens, Some(15000));
1694    }
1695
1696    #[test]
1697    fn native_response_maps_none_when_prompt_tokens_details_absent() {
1698        let json = r#"{
1699            "choices": [{"message": {"content": "Hello"}}],
1700            "usage": {"prompt_tokens": 100, "completion_tokens": 50}
1701        }"#;
1702        let resp: NativeChatResponse = serde_json::from_str(json).unwrap();
1703        let usage = resp
1704            .usage
1705            .map(|u| TokenUsage {
1706                input_tokens: u.prompt_tokens,
1707                output_tokens: u.completion_tokens,
1708                cached_input_tokens: u.prompt_tokens_details.and_then(|d| d.cached_tokens),
1709            })
1710            .expect("usage should be Some");
1711        assert!(
1712            usage.cached_input_tokens.is_none(),
1713            "absent details should map to None (providers without caching are unaffected)"
1714        );
1715    }
1716
1717    // ═══════════════════════════════════════════════════════════════════════
1718    // reasoning_content pass-through tests
1719    // ═══════════════════════════════════════════════════════════════════════
1720
1721    #[test]
1722    fn parse_native_response_captures_reasoning_content() {
1723        let message = NativeResponseMessage {
1724            content: Some("answer".into()),
1725            reasoning_content: Some("thinking step".into()),
1726            tool_calls: Some(vec![NativeToolCall {
1727                id: Some("call_1".into()),
1728                kind: Some("function".into()),
1729                function: NativeFunctionCall {
1730                    name: "shell".into(),
1731                    arguments: "{}".into(),
1732                },
1733            }]),
1734        };
1735        let parsed = OpenRouterModelProvider::parse_native_response(message);
1736        assert_eq!(parsed.reasoning_content.as_deref(), Some("thinking step"));
1737        assert_eq!(parsed.tool_calls.len(), 1);
1738    }
1739
1740    #[test]
1741    fn parse_native_response_none_reasoning_content_for_normal_model() {
1742        let message = NativeResponseMessage {
1743            content: Some("hello".into()),
1744            reasoning_content: None,
1745            tool_calls: None,
1746        };
1747        let parsed = OpenRouterModelProvider::parse_native_response(message);
1748        assert!(parsed.reasoning_content.is_none());
1749    }
1750
1751    #[test]
1752    fn native_response_deserializes_reasoning_content() {
1753        let json = r#"{
1754            "choices":[{
1755                "message":{
1756                    "content":"answer",
1757                    "reasoning_content":"deep thought",
1758                    "tool_calls":[
1759                        {"id":"call_r1","type":"function","function":{"name":"shell","arguments":"{}"}}
1760                    ]
1761                }
1762            }]
1763        }"#;
1764        let resp: NativeChatResponse = serde_json::from_str(json).unwrap();
1765        let message = &resp.choices[0].message;
1766        assert_eq!(message.reasoning_content.as_deref(), Some("deep thought"));
1767    }
1768
1769    #[test]
1770    fn convert_messages_round_trips_reasoning_content() {
1771        let history_json = serde_json::json!({
1772            "content": "I will check",
1773            "tool_calls": [{
1774                "id": "tc_1",
1775                "name": "shell",
1776                "arguments": "{}"
1777            }],
1778            "reasoning_content": "Let me think..."
1779        });
1780
1781        let messages = vec![ChatMessage {
1782            role: "assistant".into(),
1783            content: history_json.to_string(),
1784        }];
1785        let native = OpenRouterModelProvider::convert_messages(&messages);
1786        assert_eq!(native.len(), 1);
1787        assert_eq!(
1788            native[0].reasoning_content.as_deref(),
1789            Some("Let me think...")
1790        );
1791    }
1792
1793    #[test]
1794    fn convert_messages_no_reasoning_content_when_absent() {
1795        let history_json = serde_json::json!({
1796            "content": "I will check",
1797            "tool_calls": [{
1798                "id": "tc_1",
1799                "name": "shell",
1800                "arguments": "{}"
1801            }]
1802        });
1803
1804        let messages = vec![ChatMessage {
1805            role: "assistant".into(),
1806            content: history_json.to_string(),
1807        }];
1808        let native = OpenRouterModelProvider::convert_messages(&messages);
1809        assert_eq!(native.len(), 1);
1810        assert!(native[0].reasoning_content.is_none());
1811    }
1812
1813    #[test]
1814    fn native_message_omits_reasoning_content_when_none() {
1815        let msg = NativeMessage {
1816            role: "assistant".to_string(),
1817            content: Some(MessageContent::Text("hi".into())),
1818            tool_call_id: None,
1819            tool_calls: None,
1820            reasoning_content: None,
1821        };
1822        let json = serde_json::to_string(&msg).unwrap();
1823        assert!(!json.contains("reasoning_content"));
1824    }
1825
1826    #[test]
1827    fn native_message_includes_reasoning_content_when_some() {
1828        let msg = NativeMessage {
1829            role: "assistant".to_string(),
1830            content: Some(MessageContent::Text("hi".into())),
1831            tool_call_id: None,
1832            tool_calls: None,
1833            reasoning_content: Some("thinking...".to_string()),
1834        };
1835        let json = serde_json::to_string(&msg).unwrap();
1836        assert!(json.contains("reasoning_content"));
1837        assert!(json.contains("thinking..."));
1838    }
1839
1840    // ═══════════════════════════════════════════════════════════════════════
1841    // timeout_secs configuration tests
1842    // ═══════════════════════════════════════════════════════════════════════
1843
1844    #[test]
1845    fn default_timeout_is_120() {
1846        let model_provider = OpenRouterModelProvider::new("test", Some("key"), None);
1847        assert_eq!(model_provider.timeout_secs, 120);
1848    }
1849
1850    #[test]
1851    fn with_timeout_secs_overrides_default() {
1852        let model_provider =
1853            OpenRouterModelProvider::new("test", Some("key"), None).with_timeout_secs(300);
1854        assert_eq!(model_provider.timeout_secs, 300);
1855    }
1856
1857    // ═══════════════════════════════════════════════════════════════════════
1858    // tool name validation tests
1859    // ═══════════════════════════════════════════════════════════════════════
1860
1861    #[test]
1862    fn valid_openai_tool_names() {
1863        assert!(is_valid_openai_tool_name("shell"));
1864        assert!(is_valid_openai_tool_name("file_read"));
1865        assert!(is_valid_openai_tool_name("web-search"));
1866        assert!(is_valid_openai_tool_name("Tool123"));
1867        assert!(is_valid_openai_tool_name("a"));
1868    }
1869
1870    #[test]
1871    fn invalid_openai_tool_names() {
1872        assert!(!is_valid_openai_tool_name(""));
1873        assert!(!is_valid_openai_tool_name("mcp:server.tool"));
1874        assert!(!is_valid_openai_tool_name("node.js"));
1875        assert!(!is_valid_openai_tool_name("tool name"));
1876        assert!(!is_valid_openai_tool_name(
1877            "this_tool_name_is_way_too_long_and_exceeds_the_sixty_four_character_limit_xxxxx"
1878        ));
1879    }
1880
1881    #[test]
1882    fn convert_tools_skips_invalid_names() {
1883        use zeroclaw_api::tool::ToolSpec;
1884
1885        let tools = vec![
1886            ToolSpec {
1887                name: "valid_tool".into(),
1888                description: "A valid tool".into(),
1889                parameters: serde_json::json!({"type": "object"}),
1890            },
1891            ToolSpec {
1892                name: "mcp:server.bad".into(),
1893                description: "Invalid name".into(),
1894                parameters: serde_json::json!({"type": "object"}),
1895            },
1896            ToolSpec {
1897                name: "another-valid".into(),
1898                description: "Also valid".into(),
1899                parameters: serde_json::json!({"type": "object"}),
1900            },
1901        ];
1902
1903        let result = OpenRouterModelProvider::convert_tools(Some(&tools)).unwrap();
1904        assert_eq!(result.len(), 2);
1905        assert_eq!(result[0].function.name, "valid_tool");
1906        assert_eq!(result[1].function.name, "another-valid");
1907    }
1908
1909    /// Regression: skill tools used to be registered with a `.` separator
1910    /// (`{skill}.{tool}`), e.g. `openrouter-spend.check_openrouter_spend`.
1911    /// That format silently failed `is_valid_openai_tool_name` and got
1912    /// dropped from the function-call spec list sent to OpenAI-compatible
1913    /// providers, while still appearing in the system prompt — leaving the
1914    /// LLM hallucinating "unknown tool" errors. Skill tools now use the
1915    /// `__` separator (matching the MCP `<server>__<tool>` convention),
1916    /// which passes the validator and survives `convert_tools`.
1917    #[test]
1918    fn convert_tools_preserves_skill_namespaced_names_with_double_underscore() {
1919        use zeroclaw_api::tool::ToolSpec;
1920
1921        let tools = vec![
1922            // New format — must pass through.
1923            ToolSpec {
1924                name: "openrouter-spend__check_openrouter_spend".into(),
1925                description: "Skill tool".into(),
1926                parameters: serde_json::json!({"type": "object"}),
1927            },
1928            // Old format — must still be rejected so the regression stays caught.
1929            ToolSpec {
1930                name: "openrouter-spend.check_openrouter_spend".into(),
1931                description: "Skill tool with legacy dotted name".into(),
1932                parameters: serde_json::json!({"type": "object"}),
1933            },
1934        ];
1935
1936        let result = OpenRouterModelProvider::convert_tools(Some(&tools)).unwrap();
1937        assert_eq!(
1938            result.len(),
1939            1,
1940            "only the __ form should survive convert_tools"
1941        );
1942        assert_eq!(
1943            result[0].function.name,
1944            "openrouter-spend__check_openrouter_spend"
1945        );
1946    }
1947
1948    #[test]
1949    fn convert_tools_returns_none_when_all_invalid() {
1950        use zeroclaw_api::tool::ToolSpec;
1951
1952        let tools = vec![ToolSpec {
1953            name: "mcp:bad.name".into(),
1954            description: "Invalid".into(),
1955            parameters: serde_json::json!({"type": "object"}),
1956        }];
1957
1958        assert!(OpenRouterModelProvider::convert_tools(Some(&tools)).is_none());
1959    }
1960
1961    #[test]
1962    fn with_extra_body_sets_value() {
1963        let extra = serde_json::json!({"model_provider": {"only": ["Anthropic"]}});
1964        let model_provider =
1965            OpenRouterModelProvider::new("test", Some("key"), None).with_extra_body(extra.clone());
1966        assert_eq!(model_provider.extra_body, Some(extra));
1967    }
1968
1969    #[test]
1970    fn extra_body_none_produces_unchanged_request() {
1971        let model_provider = OpenRouterModelProvider::new("test", Some("key"), None);
1972        let request = ChatRequest {
1973            model: "test-model".into(),
1974            messages: vec![],
1975            temperature: 0.5,
1976            max_tokens: None,
1977        };
1978
1979        let base = serde_json::to_value(&request).unwrap();
1980        let merged = model_provider.merge_extra_body(&request).unwrap();
1981        assert_eq!(base, merged);
1982    }
1983
1984    #[test]
1985    fn extra_body_empty_object_produces_unchanged_request() {
1986        let model_provider = OpenRouterModelProvider::new("test", Some("key"), None)
1987            .with_extra_body(serde_json::json!({}));
1988        let request = ChatRequest {
1989            model: "test-model".into(),
1990            messages: vec![],
1991            temperature: 0.5,
1992            max_tokens: None,
1993        };
1994
1995        let base = serde_json::to_value(&request).unwrap();
1996        let merged = model_provider.merge_extra_body(&request).unwrap();
1997        assert_eq!(base, merged);
1998    }
1999
2000    #[test]
2001    fn extra_body_adds_new_top_level_keys() {
2002        let model_provider = OpenRouterModelProvider::new("test", Some("key"), None)
2003            .with_extra_body(serde_json::json!({"model_provider": {"only": ["Anthropic"]}}));
2004        let request = ChatRequest {
2005            model: "test-model".into(),
2006            messages: vec![],
2007            temperature: 0.5,
2008            max_tokens: None,
2009        };
2010
2011        let merged = model_provider.merge_extra_body(&request).unwrap();
2012        let obj = merged.as_object().unwrap();
2013        assert_eq!(
2014            obj.get("model_provider").unwrap(),
2015            &serde_json::json!({"only": ["Anthropic"]})
2016        );
2017        assert_eq!(obj.get("model").unwrap(), "test-model");
2018        assert_eq!(obj.get("temperature").unwrap(), 0.5);
2019    }
2020
2021    #[test]
2022    fn extra_body_overrides_existing_keys() {
2023        let model_provider = OpenRouterModelProvider::new("test", Some("key"), None)
2024            .with_extra_body(serde_json::json!({"temperature": 0.9}));
2025        let request = ChatRequest {
2026            model: "test-model".into(),
2027            messages: vec![],
2028            temperature: 0.5,
2029            max_tokens: None,
2030        };
2031
2032        let merged = model_provider.merge_extra_body(&request).unwrap();
2033        let obj = merged.as_object().unwrap();
2034        assert_eq!(obj.get("temperature").unwrap(), 0.9);
2035    }
2036
2037    #[test]
2038    fn extra_body_merges_at_top_level_not_nested() {
2039        let model_provider = OpenRouterModelProvider::new("test", Some("key"), None)
2040            .with_extra_body(serde_json::json!({"transforms": ["middle-out"]}));
2041        let request = ChatRequest {
2042            model: "test-model".into(),
2043            messages: vec![],
2044            temperature: 0.5,
2045            max_tokens: None,
2046        };
2047
2048        let merged = model_provider.merge_extra_body(&request).unwrap();
2049        let obj = merged.as_object().unwrap();
2050        assert_eq!(
2051            obj.get("transforms").unwrap(),
2052            &serde_json::json!(["middle-out"])
2053        );
2054        assert!(obj.get("extra_body").is_none());
2055    }
2056
2057    #[test]
2058    fn extra_body_with_nested_provider_routing() {
2059        let model_provider = OpenRouterModelProvider::new("test", Some("key"), None).with_extra_body(
2060            serde_json::json!({"model_provider": {"only": ["Anthropic"], "allow_fallbacks": false}}),
2061        );
2062        let request = NativeChatRequest {
2063            model: "anthropic/claude-sonnet-4".into(),
2064            messages: vec![],
2065            temperature: 0.7,
2066            tools: None,
2067            tool_choice: None,
2068            max_tokens: None,
2069            stream: None,
2070        };
2071
2072        let merged = model_provider.merge_extra_body(&request).unwrap();
2073        let obj = merged.as_object().unwrap();
2074        let prov = obj.get("model_provider").unwrap();
2075        assert_eq!(prov["only"], serde_json::json!(["Anthropic"]));
2076        assert_eq!(prov["allow_fallbacks"], false);
2077    }
2078
2079    /// Regression for #5822.
2080    ///
2081    /// `AbortOnDrop` must cancel the bound tokio task when it is dropped.
2082    /// This guards the `stream_chat` invariant that a dropped stream stops
2083    /// the in-flight SSE-forwarding task instead of letting it run to
2084    /// completion.
2085    #[tokio::test]
2086    async fn abort_on_drop_cancels_long_running_task() {
2087        use std::sync::Arc;
2088        use std::sync::atomic::{AtomicBool, Ordering};
2089        use tokio::time::{Duration, timeout};
2090
2091        let finished = Arc::new(AtomicBool::new(false));
2092        let finished_clone = Arc::clone(&finished);
2093
2094        let handle = tokio::spawn(async move {
2095            tokio::time::sleep(Duration::from_secs(30)).await;
2096            finished_clone.store(true, Ordering::SeqCst);
2097        });
2098        let raw_handle = handle.abort_handle();
2099        let guard = AbortOnDrop(handle.abort_handle());
2100
2101        assert!(!raw_handle.is_finished());
2102
2103        drop(guard);
2104
2105        let cancelled = timeout(Duration::from_secs(2), async {
2106            loop {
2107                if raw_handle.is_finished() {
2108                    return;
2109                }
2110                tokio::time::sleep(Duration::from_millis(10)).await;
2111            }
2112        })
2113        .await;
2114
2115        assert!(
2116            cancelled.is_ok(),
2117            "task should be aborted within 2 s of AbortOnDrop being dropped"
2118        );
2119        assert!(
2120            !finished.load(Ordering::SeqCst),
2121            "cancelled task must not have run its completion side effect"
2122        );
2123    }
2124}