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