Skip to main content

zeroclaw_providers/
openrouter.rs

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