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 alias: String,
20 credential: Option<String>,
21 timeout_secs: u64,
22 max_tokens: Option<u32>,
23 extra_body: Option<serde_json::Value>,
24}
25
26pub(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#[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 #[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 #[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 #[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 pub fn with_timeout_secs(mut self, secs: u64) -> Self {
213 self.timeout_secs = secs;
214 self
215 }
216
217 pub fn with_max_tokens(mut self, max_tokens: Option<u32>) -> Self {
219 self.max_tokens = max_tokens;
220 self
221 }
222
223 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 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 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 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 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 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 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 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 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 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 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 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
997fn 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 #[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 #[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 #[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 #[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 #[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 #[test]
1904 fn convert_tools_preserves_skill_namespaced_names_with_double_underscore() {
1905 use zeroclaw_api::tool::ToolSpec;
1906
1907 let tools = vec![
1908 ToolSpec {
1910 name: "openrouter-spend__check_openrouter_spend".into(),
1911 description: "Skill tool".into(),
1912 parameters: serde_json::json!({"type": "object"}),
1913 },
1914 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 #[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}