1#![allow(
5 clippy::cast_possible_truncation,
6 clippy::cast_possible_wrap,
7 clippy::cast_precision_loss,
8 clippy::cast_sign_loss
9)]
10use std::collections::BTreeMap;
11use std::sync::Arc;
12
13use anyhow::{Context, Result};
14use base64::Engine;
15use futures_util::StreamExt;
16use reqwest_eventsource::{Event, EventSource};
17use serde::Deserialize;
18use tokio::sync::mpsc;
19
20use super::{
21 ChatMessage, ChatParams, Completion, Model, ModelPricing, ReasoningEffort, StreamEvent,
22 ToolCall, ToolDef, Usage,
23};
24use crate::tools::ToolExecutor;
25
26const OPENROUTER_BASE: &str = "https://openrouter.ai/api/v1";
27const OPENAI_BASE: &str = "https://api.openai.com/v1";
28const CODEX_BASE: &str = "https://chatgpt.com/backend-api";
29const OPENCODE_ZEN_BASE: &str = "https://opencode.ai/zen/v1";
32const OPENCODE_GO_BASE: &str = "https://opencode.ai/zen/go/v1";
37const OPENCODE_GO_PREFIX: &str = "go:";
41
42pub fn base_url(tag: crate::provider::BackendTag) -> &'static str {
44 match tag {
45 crate::provider::BackendTag::OpenRouter => OPENROUTER_BASE,
46 crate::provider::BackendTag::OpenAi => OPENAI_BASE,
47 crate::provider::BackendTag::OpencodeGo => OPENCODE_ZEN_BASE,
48 crate::provider::BackendTag::Codex => CODEX_BASE,
49 }
50}
51
52pub fn gateway_route(tag: crate::provider::BackendTag, model: &str) -> (&'static str, String) {
54 if tag == crate::provider::BackendTag::OpencodeGo
55 && let Some(raw) = model.strip_prefix(OPENCODE_GO_PREFIX)
56 {
57 return (OPENCODE_GO_BASE, raw.to_string());
58 }
59 (base_url(tag), model.to_string())
60}
61
62const OPENCODE_ZEN_CONTEXT: &[(&str, u64)] = &[
68 ("big-pickle", 200_000),
69 ("claude-fable-5", 1_000_000),
70 ("claude-haiku-4-5", 200_000),
71 ("claude-opus-4-1", 200_000),
72 ("claude-opus-4-5", 200_000),
73 ("claude-opus-4-6", 1_000_000),
74 ("claude-opus-4-7", 1_000_000),
75 ("claude-opus-4-8", 1_000_000),
76 ("claude-opus-5", 1_000_000),
77 ("claude-sonnet-4", 1_000_000),
78 ("claude-sonnet-4-5", 1_000_000),
79 ("claude-sonnet-4-6", 1_000_000),
80 ("claude-sonnet-5", 1_000_000),
81 ("deepseek-v4-flash", 1_000_000),
82 ("deepseek-v4-flash-free", 200_000),
83 ("deepseek-v4-pro", 1_000_000),
84 ("gemini-3-flash", 1_048_576),
85 ("gemini-3.1-pro", 1_048_576),
86 ("gemini-3.5-flash", 1_048_576),
87 ("gemini-3.5-flash-lite", 1_048_576),
88 ("gemini-3.6-flash", 1_048_576),
89 ("glm-5", 204_800),
90 ("glm-5.1", 204_800),
91 ("glm-5.2", 1_000_000),
92 ("gpt-5", 400_000),
93 ("gpt-5-codex", 400_000),
94 ("gpt-5-nano", 400_000),
95 ("gpt-5.1", 400_000),
96 ("gpt-5.1-codex", 400_000),
97 ("gpt-5.1-codex-max", 400_000),
98 ("gpt-5.1-codex-mini", 400_000),
99 ("gpt-5.2", 400_000),
100 ("gpt-5.2-codex", 400_000),
101 ("gpt-5.3-codex", 400_000),
102 ("gpt-5.3-codex-spark", 128_000),
103 ("gpt-5.4", 1_050_000),
104 ("gpt-5.4-mini", 400_000),
105 ("gpt-5.4-nano", 400_000),
106 ("gpt-5.4-pro", 1_050_000),
107 ("gpt-5.5", 1_050_000),
108 ("gpt-5.5-pro", 1_050_000),
109 ("gpt-5.6-luna", 1_050_000),
110 ("gpt-5.6-sol", 1_050_000),
111 ("gpt-5.6-terra", 1_050_000),
112 ("grok-4.5", 500_000),
113 ("grok-build-0.1", 256_000),
114 ("kimi-k2.5", 262_144),
115 ("kimi-k2.6", 262_144),
116 ("kimi-k2.7-code", 262_144),
117 ("kimi-k3", 1_048_576),
118 ("laguna-s-2.1-free", 256_000),
119 ("ling-3.0-flash-free", 262_144),
120 ("longcat-2.0-free", 1_000_000),
121 ("minimax-m2.5", 204_800),
122 ("minimax-m2.7", 204_800),
123 ("minimax-m3", 512_000),
124 ("mimo-v2.5-free", 200_000),
125 ("nemotron-3-ultra-free", 1_000_000),
126 ("north-mini-code-free", 256_000),
127 ("qwen3.5-plus", 262_144),
128 ("qwen3.6-plus", 262_144),
129];
130
131const OPENCODE_GO_CONTEXT: &[(&str, u64)] = &[
136 ("deepseek-v4-flash", 1_000_000),
137 ("deepseek-v4-pro", 1_000_000),
138 ("glm-5", 202_752),
139 ("glm-5.1", 202_752),
140 ("glm-5.2", 1_000_000),
141 ("gpt-5.6-luna", 1_050_000),
142 ("grok-4.5", 500_000),
143 ("hy3", 256_000),
144 ("hy3-preview", 256_000),
145 ("kimi-k2.5", 262_144),
146 ("kimi-k2.6", 262_144),
147 ("kimi-k2.7-code", 262_144),
148 ("kimi-k3", 1_048_576),
149 ("mimo-v2-omni", 262_144),
150 ("mimo-v2-pro", 1_048_576),
151 ("mimo-v2.5", 1_000_000),
152 ("mimo-v2.5-pro", 1_048_576),
153 ("minimax-m2.5", 204_800),
154 ("minimax-m2.7", 204_800),
155 ("minimax-m3", 1_000_000),
156 ("qwen3.5-plus", 262_144),
157 ("qwen3.6-plus", 1_000_000),
158 ("qwen3.7-max", 1_000_000),
159 ("qwen3.7-plus", 1_000_000),
160 ("qwen3.8-max", 1_000_000),
161];
162pub const MAX_TOOL_ITERS: usize = 100;
166
167#[derive(Clone, Copy, Debug, Eq, PartialEq)]
168enum ProviderFlavor {
169 OpenRouter,
170 OpenAi,
171 OpenAiCodex,
172 OpencodeGo,
176}
177
178#[derive(Clone)]
179pub struct OpenRouter {
180 client: reqwest::Client,
181 key: String,
182 flavor: ProviderFlavor,
183}
184
185pub struct VideoRequest {
190 pub model: String,
191 pub prompt: String,
192 pub duration: u32,
193 pub resolution: String,
194 pub aspect_ratio: String,
195 pub generate_audio: bool,
196 pub first_frame: Option<Vec<u8>>,
197 pub last_frame: Option<Vec<u8>>,
198 pub input_references: Vec<Vec<u8>>,
199 pub seed: Option<i32>,
200 pub provider_options: Option<serde_json::Value>,
201}
202
203impl ProviderFlavor {
204 const fn base(self) -> &'static str {
205 match self {
206 Self::OpenRouter => OPENROUTER_BASE,
207 Self::OpenAi => OPENAI_BASE,
208 Self::OpenAiCodex => CODEX_BASE,
209 Self::OpencodeGo => OPENCODE_ZEN_BASE,
210 }
211 }
212
213 fn reasoning_efforts(self, m: &ModelEntry) -> Vec<ReasoningEffort> {
221 use ReasoningEffort as E;
222 if let Some(supported) = m
223 .reasoning
224 .as_ref()
225 .map(|r| r.supported_efforts.as_slice())
226 .filter(|values| !values.is_empty())
227 {
228 return E::CYCLE_ORDER
229 .iter()
230 .copied()
231 .filter(|effort| supported.iter().any(|value| value == effort.as_str()))
232 .collect();
233 }
234 match self {
235 Self::OpenRouter => {
236 if !m.supported_parameters.iter().any(|p| p == "reasoning") {
237 return Vec::new();
238 }
239 if m.id.starts_with("anthropic/") {
240 E::WITH_MINIMAL.to_vec()
241 } else {
242 E::STANDARD.to_vec()
243 }
244 }
245 Self::OpenAi => {
246 let id = m.id.as_str();
247 if id == "gpt-5-pro" || id.starts_with("gpt-5-pro-") {
248 E::HIGH_ONLY.to_vec()
249 } else if id == "gpt-5"
250 || id.starts_with("gpt-5-20")
251 || id.starts_with("gpt-5-mini")
252 || id.starts_with("gpt-5-nano")
253 {
254 E::WITH_MINIMAL.to_vec()
255 } else if ["gpt-5.2", "gpt-5.3", "gpt-5.4", "gpt-5.5"]
256 .iter()
257 .any(|prefix| id.starts_with(prefix))
258 {
259 E::WITH_XHIGH_AND_NONE.to_vec()
260 } else if id.starts_with("gpt-5.6") {
261 E::WITH_MAX_XHIGH_AND_NONE.to_vec()
262 } else if id
263 .strip_prefix('o')
264 .is_some_and(|rest| rest.chars().next().is_some_and(|c| c.is_ascii_digit()))
265 || id.starts_with("gpt-5")
266 {
267 E::STANDARD.to_vec()
268 } else {
269 Vec::new()
270 }
271 }
272 Self::OpenAiCodex | Self::OpencodeGo => E::STANDARD.to_vec(),
276 }
277 }
278
279 fn supports_images(self, m: &ModelEntry) -> Option<bool> {
280 match self {
281 Self::OpenRouter | Self::OpencodeGo => None,
282 Self::OpenAi => {
283 let id = m.id.as_str();
284 Some(
285 id.contains("gpt-4o")
286 || id.contains("gpt-4.1")
287 || id.starts_with("gpt-5")
288 || id.starts_with("o3")
289 || id.starts_with("o4"),
290 )
291 }
292 Self::OpenAiCodex => Some(true),
293 }
294 }
295
296 fn supports_image_generation(self, m: &ModelEntry) -> Option<bool> {
297 match self {
298 Self::OpenRouter => None,
300 Self::OpenAi => Some(m.id.contains("dall-e")),
302 Self::OpenAiCodex | Self::OpencodeGo => Some(false),
304 }
305 }
306
307 fn add_stream_usage(self, obj: &mut serde_json::Map<String, serde_json::Value>) {
308 match self {
309 Self::OpenRouter => {
311 obj.insert("usage".into(), serde_json::json!({ "include": true }));
312 }
313 Self::OpenAi | Self::OpencodeGo => {
316 obj.insert(
317 "stream_options".into(),
318 serde_json::json!({ "include_usage": true }),
319 );
320 }
321 Self::OpenAiCodex => {}
322 }
323 }
324
325 fn add_prompt_cache_key(self, obj: &mut serde_json::Map<String, serde_json::Value>, key: &str) {
328 if self == Self::OpencodeGo {
329 obj.insert("prompt_cache_key".into(), serde_json::json!(key));
330 }
331 }
332
333 fn add_reasoning_effort(
334 self,
335 obj: &mut serde_json::Map<String, serde_json::Value>,
336 effort: &str,
337 ) {
338 match self {
339 Self::OpenRouter => {
340 obj.insert("reasoning".into(), serde_json::json!({ "effort": effort }));
341 }
342 Self::OpenAi | Self::OpencodeGo => {
343 obj.insert("reasoning_effort".into(), serde_json::json!(effort));
344 }
345 Self::OpenAiCodex => {
346 obj.insert(
347 "reasoning".into(),
348 serde_json::json!({ "effort": effort, "summary": "auto" }),
349 );
350 }
351 }
352 }
353}
354
355fn looks_like_openrouter_key(key: &str) -> bool {
356 key.trim_start().starts_with("sk-or-")
357}
358
359fn looks_like_codex_token(key: &str) -> bool {
360 crate::config::codex_account_id(key).is_ok()
361}
362
363#[derive(Deserialize)]
364struct ModelsResponse {
365 data: Vec<ModelEntry>,
366}
367
368#[derive(Deserialize)]
369struct ImageModelsResponse {
370 data: Vec<ImageModelEntry>,
371}
372
373#[derive(Deserialize)]
374struct ImageModelEntry {
375 id: String,
376 #[serde(default)]
377 name: Option<String>,
378 #[serde(default)]
379 architecture: Option<Architecture>,
380}
381
382#[derive(Deserialize)]
383struct VideoModelsResponse {
384 data: Vec<VideoModelEntry>,
385}
386
387#[derive(Deserialize)]
388struct VideoModelEntry {
389 id: String,
390 #[serde(default)]
391 name: Option<String>,
392}
393
394#[derive(Deserialize, Clone)]
395struct ModelEntry {
396 id: String,
397 #[serde(default)]
398 name: Option<String>,
399 #[serde(default)]
400 supported_parameters: Vec<String>,
401 #[serde(default)]
402 reasoning: Option<ModelReasoningEntry>,
403 #[serde(default)]
404 context_length: Option<u64>,
405 #[serde(default)]
406 architecture: Option<Architecture>,
407 #[serde(default)]
408 pricing: Option<CatalogPricing>,
409}
410
411#[derive(Deserialize, Clone)]
415struct CatalogPricing {
416 #[serde(default)]
417 prompt: Option<String>,
418 #[serde(default)]
419 completion: Option<String>,
420 #[serde(default)]
421 input_cache_read: Option<String>,
422 #[serde(default)]
423 input_cache_write: Option<String>,
424}
425
426impl CatalogPricing {
427 fn usd_per_million(&self) -> Option<ModelPricing> {
433 let parse = |value: Option<&str>| value?.parse::<f64>().ok().map(|v| v * 1e6);
434 Some(ModelPricing {
435 prompt: parse(self.prompt.as_deref())?,
436 completion: parse(self.completion.as_deref())?,
437 cache_read: parse(self.input_cache_read.as_deref()),
438 cache_write: parse(self.input_cache_write.as_deref()),
439 })
440 }
441}
442
443#[derive(Deserialize, Clone)]
444struct ModelReasoningEntry {
445 #[serde(default)]
446 supported_efforts: Vec<String>,
447}
448
449#[derive(Deserialize, Clone)]
450struct Architecture {
451 #[serde(default)]
452 input_modalities: Vec<String>,
453 #[serde(default)]
454 output_modalities: Vec<String>,
455}
456
457fn entry_supports_images(e: &ModelEntry) -> bool {
459 e.architecture
460 .as_ref()
461 .is_some_and(|a| a.input_modalities.iter().any(|m| m == "image"))
462}
463
464fn entry_supports_image_gen(e: &ModelEntry) -> bool {
466 if e.architecture
468 .as_ref()
469 .is_some_and(|a| a.output_modalities.iter().any(|m| m == "image"))
470 {
471 return true;
472 }
473 let id = &e.id;
477 id.contains("/flux")
478 || id.contains("dall-e")
479 || id.contains("/stable-diffusion")
480 || id == "recraft-20b"
481 || id.starts_with("recraft-v")
482 || id.contains("/imagen")
483 || id.contains("/pixart")
484 || id.contains("/playground-v")
485 || id == "luma-photon"
486 || id.starts_with("luma/")
487 || id.starts_with("ideogram/")
488 || id.contains("/sdxl")
489 || id.contains("hyper-sd")
490}
491
492fn entry_supports_video_gen(e: &ModelEntry) -> bool {
493 e.architecture
494 .as_ref()
495 .is_some_and(|a| a.output_modalities.iter().any(|m| m == "video"))
496}
497
498fn merge_generation_models(models: &mut Vec<Model>, additions: Vec<Model>) {
499 for addition in additions {
500 if let Some(existing) = models
501 .iter_mut()
502 .find(|m| m.backend == addition.backend && m.id == addition.id)
503 {
504 existing.supports_images |= addition.supports_images;
505 existing.supports_image_generation |= addition.supports_image_generation;
506 existing.supports_video_generation |= addition.supports_video_generation;
507 if existing.name == existing.id && addition.name != addition.id {
508 existing.name = addition.name;
509 }
510 } else {
511 models.push(addition);
512 }
513 }
514 models.sort_by(|a, b| a.id.cmp(&b.id));
515}
516
517impl OpenRouter {
518 pub fn from_key_auto(key: String) -> Self {
519 if looks_like_openrouter_key(&key) {
520 Self::openrouter_flavor(key)
521 } else if looks_like_codex_token(&key) {
522 Self::openai_codex(key)
523 } else {
524 Self::openai(key)
525 }
526 }
527
528 pub fn openrouter_flavor(key: String) -> Self {
529 Self {
530 client: reqwest::Client::new(),
531 key,
532 flavor: ProviderFlavor::OpenRouter,
533 }
534 }
535
536 pub fn openai(key: String) -> Self {
537 Self {
538 client: reqwest::Client::new(),
539 key,
540 flavor: ProviderFlavor::OpenAi,
541 }
542 }
543
544 pub fn opencode_go(key: String) -> Self {
545 Self {
546 client: reqwest::Client::new(),
547 key,
548 flavor: ProviderFlavor::OpencodeGo,
549 }
550 }
551
552 pub fn openai_codex(key: String) -> Self {
553 Self {
554 client: reqwest::Client::new(),
555 key,
556 flavor: ProviderFlavor::OpenAiCodex,
557 }
558 }
559
560 pub const fn backend_tag(&self) -> crate::provider::BackendTag {
561 match self.flavor {
562 ProviderFlavor::OpenRouter => crate::provider::BackendTag::OpenRouter,
563 ProviderFlavor::OpenAi => crate::provider::BackendTag::OpenAi,
564 ProviderFlavor::OpenAiCodex => crate::provider::BackendTag::Codex,
565 ProviderFlavor::OpencodeGo => crate::provider::BackendTag::OpencodeGo,
566 }
567 }
568
569 pub fn is_openrouter(&self) -> bool {
571 self.flavor == ProviderFlavor::OpenRouter
572 }
573
574 #[allow(clippy::too_many_lines)]
579 pub async fn generate_video(&self, req: VideoRequest) -> Result<(Vec<u8>, f64)> {
580 let VideoRequest {
581 model,
582 prompt,
583 duration,
584 resolution,
585 aspect_ratio,
586 generate_audio,
587 first_frame,
588 last_frame,
589 input_references,
590 seed,
591 provider_options,
592 } = req;
593 if !self.is_openrouter() {
594 anyhow::bail!("video generation only available on the OpenRouter backend");
595 }
596 let (base, model_id) = self.opencode_route(&model);
597 let (duration, resolution, aspect_ratio) =
598 normalize_video_params(&model_id, duration, &resolution, &aspect_ratio);
599 check_video_params(&model_id, duration, &resolution, &aspect_ratio)?;
600
601 let mut body = serde_json::json!({
602 "model": model_id,
603 "prompt": prompt,
604 "duration": duration,
605 "resolution": resolution,
606 "aspect_ratio": aspect_ratio,
607 "generate_audio": generate_audio,
608 });
609
610 let mut frames = Vec::new();
611 if let Some(data) = first_frame {
612 let b64 = base64::engine::general_purpose::STANDARD.encode(&data);
613 let mime = Self::detect_image_mime(&data);
614 frames.push(serde_json::json!({
615 "type": "image_url",
616 "image_url": { "url": format!("data:{mime};base64,{b64}") },
617 "frame_type": "first_frame",
618 }));
619 }
620 if let Some(data) = last_frame {
621 let b64 = base64::engine::general_purpose::STANDARD.encode(&data);
622 let mime = Self::detect_image_mime(&data);
623 frames.push(serde_json::json!({
624 "type": "image_url",
625 "image_url": { "url": format!("data:{mime};base64,{b64}") },
626 "frame_type": "last_frame",
627 }));
628 }
629 if !frames.is_empty() {
630 body["frame_images"] = serde_json::Value::Array(frames);
631 }
632
633 if !input_references.is_empty() {
634 let refs: Vec<serde_json::Value> = input_references
635 .iter()
636 .map(|data| {
637 let b64 = base64::engine::general_purpose::STANDARD.encode(data);
638 let mime = Self::detect_image_mime(data);
639 serde_json::json!({
640 "type": "image_url",
641 "image_url": { "url": format!("data:{mime};base64,{b64}") }
642 })
643 })
644 .collect();
645 body["input_references"] = serde_json::Value::Array(refs);
646 }
647
648 if let Some(s) = seed {
649 body["seed"] = serde_json::json!(s);
650 }
651
652 if let Some(po) = provider_options {
653 body["provider"] = serde_json::json!({ "options": po });
654 }
655
656 let raw_resp = self
657 .client
658 .post(format!("{base}/videos"))
659 .bearer_auth(&self.key)
660 .json(&body)
661 .send()
662 .await
663 .context("video generation request")?;
664 let resp: serde_json::Value = if raw_resp.status().is_success() {
665 raw_resp
666 .json::<serde_json::Value>()
667 .await
668 .context("parsing video submission response")?
669 } else {
670 let status = raw_resp.status();
671 let body_text = raw_resp.text().await.unwrap_or_default();
672 anyhow::bail!("video generation submission failed (HTTP {status}): {body_text}");
673 };
674
675 let job_id = resp
676 .get("id")
677 .and_then(|i| i.as_str())
678 .context("no job id in video generation response")?
679 .to_string();
680
681 let poll_url = format!("{base}/videos/{job_id}");
682 for _attempt in 0..36 {
683 tokio::time::sleep(std::time::Duration::from_secs(10)).await;
684
685 let status = self
686 .client
687 .get(&poll_url)
688 .bearer_auth(&self.key)
689 .send()
690 .await
691 .context("video status poll")?
692 .error_for_status()
693 .context("video status poll failed")?
694 .json::<serde_json::Value>()
695 .await
696 .context("parsing video status response")?;
697
698 match status.get("status").and_then(|s| s.as_str()) {
699 Some("completed") => {
700 let cost = status
701 .pointer("/usage/cost")
702 .and_then(serde_json::Value::as_f64)
703 .unwrap_or(0.0);
704
705 tokio::time::sleep(std::time::Duration::from_secs(2)).await;
706
707 let content = self
708 .client
709 .get(format!("{base}/videos/{job_id}/content?index=0"))
710 .bearer_auth(&self.key)
711 .send()
712 .await
713 .context("video download request")?
714 .error_for_status()
715 .context("video download failed")?
716 .bytes()
717 .await
718 .context("reading video content")?;
719
720 return Ok((content.to_vec(), cost));
721 }
722 Some("failed") => {
723 let err = status
724 .get("error")
725 .and_then(|e| e.as_str())
726 .unwrap_or("unknown error");
727 anyhow::bail!("video generation failed: {err}");
728 }
729 Some("cancelled" | "expired") => {
730 anyhow::bail!("video generation was cancelled or expired");
731 }
732 _ => {}
733 }
734 }
735
736 anyhow::bail!("video generation timed out after 6 minutes");
737 }
738
739 pub const fn default_utility_model(&self) -> &'static str {
740 match self.flavor {
741 ProviderFlavor::OpenRouter => "google/gemini-2.5-flash-lite",
742 ProviderFlavor::OpenAi => "gpt-4.1-mini",
743 ProviderFlavor::OpenAiCodex => "gpt-5.4-mini",
744 ProviderFlavor::OpencodeGo => "deepseek-v4-flash",
745 }
746 }
747
748 pub const fn default_research_model(&self) -> &'static str {
749 match self.flavor {
750 ProviderFlavor::OpenRouter => "google/gemini-2.5-flash",
751 ProviderFlavor::OpenAi => "gpt-4.1",
752 ProviderFlavor::OpenAiCodex => "gpt-5.5",
753 ProviderFlavor::OpencodeGo => "kimi-k2.7-code",
754 }
755 }
756
757 pub const fn default_embedding_model(&self) -> &'static str {
758 match self.flavor {
759 ProviderFlavor::OpenRouter => "openai/text-embedding-3-small",
760 ProviderFlavor::OpenAi => "text-embedding-3-small",
761 ProviderFlavor::OpenAiCodex | ProviderFlavor::OpencodeGo => "",
763 }
764 }
765
766 pub const fn default_video_gen_model(&self) -> &'static str {
767 match self.flavor {
768 ProviderFlavor::OpenRouter => "google/veo-3.1",
769 _ => "",
770 }
771 }
772
773 pub const fn default_image_gen_model(&self) -> &'static str {
774 match self.flavor {
775 ProviderFlavor::OpenRouter => "openai/gpt-image-2",
776 ProviderFlavor::OpenAi => "dall-e-3",
777 ProviderFlavor::OpenAiCodex | ProviderFlavor::OpencodeGo => "",
778 }
779 }
780
781 #[allow(clippy::too_many_lines)]
785 pub async fn list_models(&self) -> Result<Vec<Model>> {
786 if self.flavor == ProviderFlavor::OpenAiCodex {
787 return Ok(vec![
791 Model {
792 id: "gpt-5.3-codex-spark".into(),
793 name: "GPT-5.3 Codex Spark".into(),
794 reasoning_efforts: ReasoningEffort::STANDARD.to_vec(),
795 context_length: Some(128_000),
796 supports_images: false,
797 supports_image_generation: false,
798 supports_video_generation: false,
799 backend: crate::provider::BackendTag::Codex,
800 pricing: None,
801 },
802 Model {
803 id: "gpt-5.4".into(),
804 name: "GPT-5.4".into(),
805 reasoning_efforts: ReasoningEffort::WITH_XHIGH_AND_NONE.to_vec(),
806 context_length: Some(272_000),
807 supports_images: true,
808 supports_image_generation: false,
809 supports_video_generation: false,
810 backend: crate::provider::BackendTag::Codex,
811 pricing: None,
812 },
813 Model {
814 id: "gpt-5.4-mini".into(),
815 name: "GPT-5.4 mini".into(),
816 reasoning_efforts: ReasoningEffort::WITH_XHIGH_AND_NONE.to_vec(),
817 context_length: Some(272_000),
818 supports_images: true,
819 supports_image_generation: false,
820 supports_video_generation: false,
821 backend: crate::provider::BackendTag::Codex,
822 pricing: None,
823 },
824 Model {
825 id: "gpt-5.5".into(),
826 name: "GPT-5.5".into(),
827 reasoning_efforts: ReasoningEffort::WITH_XHIGH_AND_NONE.to_vec(),
828 context_length: Some(272_000),
829 supports_images: true,
830 supports_image_generation: false,
831 supports_video_generation: false,
832 backend: crate::provider::BackendTag::Codex,
833 pricing: None,
834 },
835 Model {
836 id: "gpt-5.6-sol".into(),
837 name: "GPT-5.6 Sol".into(),
838 reasoning_efforts: ReasoningEffort::WITH_MAX_XHIGH_AND_NONE.to_vec(),
839 context_length: Some(1_000_000),
840 supports_images: true,
841 supports_image_generation: false,
842 supports_video_generation: false,
843 backend: crate::provider::BackendTag::Codex,
844 pricing: None,
845 },
846 Model {
847 id: "gpt-5.6-terra".into(),
848 name: "GPT-5.6 Terra".into(),
849 reasoning_efforts: ReasoningEffort::WITH_MAX_XHIGH_AND_NONE.to_vec(),
850 context_length: Some(1_000_000),
851 supports_images: true,
852 supports_image_generation: false,
853 supports_video_generation: false,
854 backend: crate::provider::BackendTag::Codex,
855 pricing: None,
856 },
857 Model {
858 id: "gpt-5.6-luna".into(),
859 name: "GPT-5.6 Luna".into(),
860 reasoning_efforts: ReasoningEffort::WITH_MAX_XHIGH_AND_NONE.to_vec(),
861 context_length: Some(1_000_000),
862 supports_images: true,
863 supports_image_generation: false,
864 supports_video_generation: false,
865 backend: crate::provider::BackendTag::Codex,
866 pricing: None,
867 },
868 ]);
869 }
870 if self.flavor == ProviderFlavor::OpencodeGo {
871 let (zen, go) = tokio::join!(
876 self.fetch_models_from(OPENCODE_ZEN_BASE),
877 self.fetch_models_from(OPENCODE_GO_BASE),
878 );
879 let zen = zen?;
884 let go = go.unwrap_or_default();
885 let go_ids: std::collections::HashSet<&str> =
886 go.iter().map(|m| m.id.as_str()).collect();
887 let mut models: Vec<Model> = zen
888 .into_iter()
889 .filter(|m| !go_ids.contains(m.id.as_str()))
892 .collect();
893 models.extend(go.into_iter().map(|mut m| {
894 m.id = format!("{OPENCODE_GO_PREFIX}{}", m.id);
895 m
896 }));
897 models.sort_by(|a, b| a.id.cmp(&b.id));
898 return Ok(models);
899 }
900 let mut models = self.fetch_models_from(self.flavor.base()).await?;
901 if self.flavor == ProviderFlavor::OpenRouter {
902 let (images, videos) =
905 tokio::join!(self.fetch_image_models(), self.fetch_video_models(),);
906 merge_generation_models(&mut models, images.unwrap_or_default());
907 merge_generation_models(&mut models, videos.unwrap_or_default());
908 }
909 Ok(models)
910 }
911
912 async fn fetch_image_models(&self) -> Result<Vec<Model>> {
913 let response = self
914 .client
915 .get(format!("{OPENROUTER_BASE}/images/models"))
916 .bearer_auth(&self.key)
917 .send()
918 .await
919 .context("requesting image model list")?
920 .error_for_status()
921 .context("image model list request failed")?
922 .json::<ImageModelsResponse>()
923 .await
924 .context("parsing image model list")?;
925
926 Ok(response
927 .data
928 .into_iter()
929 .map(|m| {
930 let id = m.id;
931 Model {
932 name: m.name.unwrap_or_else(|| id.clone()),
933 reasoning_efforts: Vec::new(),
934 context_length: None,
935 supports_images: m
936 .architecture
937 .as_ref()
938 .is_some_and(|a| a.input_modalities.iter().any(|v| v == "image")),
939 supports_image_generation: true,
940 supports_video_generation: false,
941 backend: crate::provider::BackendTag::OpenRouter,
942 id,
943 pricing: None,
944 }
945 })
946 .collect())
947 }
948
949 async fn fetch_video_models(&self) -> Result<Vec<Model>> {
950 let response = self
951 .client
952 .get(format!("{OPENROUTER_BASE}/videos/models"))
953 .bearer_auth(&self.key)
954 .send()
955 .await
956 .context("requesting video model list")?
957 .error_for_status()
958 .context("video model list request failed")?
959 .json::<VideoModelsResponse>()
960 .await
961 .context("parsing video model list")?;
962
963 Ok(response
964 .data
965 .into_iter()
966 .map(|m| {
967 let id = m.id;
968 Model {
969 name: m.name.unwrap_or_else(|| id.clone()),
970 reasoning_efforts: Vec::new(),
971 context_length: None,
972 supports_images: false,
973 supports_image_generation: false,
974 supports_video_generation: true,
975 backend: crate::provider::BackendTag::OpenRouter,
976 id,
977 pricing: None,
978 }
979 })
980 .collect())
981 }
982
983 async fn fetch_models_from(&self, base: &str) -> Result<Vec<Model>> {
986 let resp = self
987 .client
988 .get(format!("{base}/models"))
989 .bearer_auth(&self.key)
990 .send()
991 .await
992 .context("requesting model list")?
993 .error_for_status()
994 .context("model list request failed")?
995 .json::<ModelsResponse>()
996 .await
997 .context("parsing model list")?;
998
999 let mut models: Vec<Model> = resp
1000 .data
1001 .into_iter()
1002 .map(|m| {
1003 let reasoning_efforts = self.flavor.reasoning_efforts(&m);
1004 let supports_images = self
1005 .flavor
1006 .supports_images(&m)
1007 .unwrap_or_else(|| entry_supports_images(&m));
1008 let supports_image_generation = self
1009 .flavor
1010 .supports_image_generation(&m)
1011 .unwrap_or_else(|| entry_supports_image_gen(&m));
1012 let supports_video_generation =
1013 self.flavor == ProviderFlavor::OpenRouter && entry_supports_video_gen(&m);
1014 Model {
1015 name: m.name.unwrap_or_else(|| m.id.clone()),
1016 reasoning_efforts,
1017 context_length: m.context_length,
1018 id: m.id,
1019 supports_images,
1020 supports_image_generation,
1021 supports_video_generation,
1022 backend: self.backend_tag(),
1023 pricing: m.pricing.and_then(|p| p.usd_per_million()),
1024 }
1025 })
1026 .collect();
1027 for m in &mut models {
1032 if m.context_length.is_none() {
1033 m.context_length = Self::opencode_context_fallback(base, &m.id);
1034 }
1035 }
1036 models.sort_by(|a, b| a.id.cmp(&b.id));
1037 Ok(models)
1038 }
1039
1040 fn opencode_context_fallback(base: &str, id: &str) -> Option<u64> {
1044 let table = match base {
1045 OPENCODE_ZEN_BASE => OPENCODE_ZEN_CONTEXT,
1046 OPENCODE_GO_BASE => OPENCODE_GO_CONTEXT,
1047 _ => return None,
1048 };
1049 table.iter().find(|(tid, _)| *tid == id).map(|(_, c)| *c)
1050 }
1051
1052 pub async fn complete(&self, model: &str, messages: Vec<ChatMessage>) -> Result<String> {
1055 self.complete_with_usage(model, messages)
1056 .await
1057 .map(|completion| completion.text)
1058 }
1059
1060 pub async fn complete_with_usage(
1064 &self,
1065 model: &str,
1066 messages: Vec<ChatMessage>,
1067 ) -> Result<Completion> {
1068 self.complete_with_params(model, messages, &ChatParams::default())
1069 .await
1070 }
1071
1072 pub async fn complete_with_params(
1074 &self,
1075 model: &str,
1076 messages: Vec<ChatMessage>,
1077 params: &ChatParams,
1078 ) -> Result<Completion> {
1079 if self.flavor == ProviderFlavor::OpenAiCodex {
1084 let (tx, mut rx) = mpsc::unbounded_channel();
1085 let finish = self
1086 .run_codex_stream(model, &messages, params, &[], false, &tx)
1087 .await?;
1088 drop(tx);
1089
1090 let mut text = String::new();
1091 let mut error = None;
1092 let mut usage = None;
1093 while let Ok(event) = rx.try_recv() {
1094 match event {
1095 StreamEvent::Token(token) => text.push_str(&token),
1096 StreamEvent::Error(message) => error = Some(message),
1097 StreamEvent::Usage(next) => merge_usage(&mut usage, next),
1098 _ => {}
1099 }
1100 }
1101 if matches!(finish, Finish::Errored) {
1102 anyhow::bail!(error.unwrap_or_else(|| "Codex completion failed".to_string()));
1103 }
1104 return Ok(Completion { text, usage });
1105 }
1106
1107 let mut body = serde_json::json!({
1108 "model": model,
1109 "messages": messages,
1110 "stream": false,
1111 });
1112 if let Some(key) = ¶ms.prompt_cache_key
1113 && let Some(obj) = body.as_object_mut()
1114 {
1115 self.flavor.add_prompt_cache_key(obj, key);
1116 }
1117 self.post_completion_with_usage(body).await
1118 }
1119
1120 async fn post_completion(&self, body: serde_json::Value) -> Result<String> {
1122 self.post_completion_with_usage(body)
1123 .await
1124 .map(|completion| completion.text)
1125 }
1126
1127 async fn post_completion_with_usage(&self, body: serde_json::Value) -> Result<Completion> {
1129 if let Some(delegate) = self.openrouter_delegate_for_body(&body) {
1130 return Box::pin(delegate.post_completion_with_usage(body)).await;
1131 }
1132 if self.flavor == ProviderFlavor::OpenAiCodex {
1133 let body = chat_body_to_codex_body(&body, false, &[]);
1134 let v = self
1135 .client
1136 .post(format!("{}/codex/responses", self.flavor.base()))
1137 .headers(self.codex_headers(false)?)
1138 .json(&body)
1139 .send()
1140 .await
1141 .context("Codex completion request")?
1142 .error_for_status()
1143 .context("Codex completion failed")?
1144 .json::<serde_json::Value>()
1145 .await
1146 .context("parsing Codex completion")?;
1147 return Ok(Completion {
1148 text: codex_response_text(&v),
1149 usage: v.get("usage").and_then(parse_usage_value),
1150 });
1151 }
1152 let mut body = body;
1153 let base = if let Some(model) = body.get("model").and_then(|m| m.as_str()) {
1154 let (base, real_model) = self.opencode_route(model);
1155 if let Some(obj) = body.as_object_mut() {
1156 obj.insert("model".into(), serde_json::json!(real_model));
1157 }
1158 base
1159 } else {
1160 self.flavor.base()
1161 };
1162 let v = self
1163 .client
1164 .post(format!("{base}/chat/completions"))
1165 .bearer_auth(&self.key)
1166 .json(&body)
1167 .send()
1168 .await
1169 .context("completion request")?
1170 .error_for_status()
1171 .context("completion failed")?
1172 .json::<serde_json::Value>()
1173 .await
1174 .context("parsing completion")?;
1175 Ok(Completion {
1176 text: v
1177 .get("choices")
1178 .and_then(|c| c.get(0))
1179 .and_then(|c| c.get("message"))
1180 .and_then(|m| m.get("content"))
1181 .and_then(wire_text)
1182 .unwrap_or_default(),
1183 usage: v.get("usage").and_then(parse_usage_value),
1184 })
1185 }
1186
1187 pub async fn describe_image(&self, model: &str, image_data_url: &str) -> Result<String> {
1189 self.post_completion(vision_body(model, image_data_url))
1190 .await
1191 }
1192
1193 pub async fn generate_image(
1197 &self,
1198 model: &str,
1199 prompt: &str,
1200 size: &str,
1201 image_data: Option<&[u8]>,
1202 ) -> Result<(Vec<u8>, String)> {
1203 if let Some(delegate) = self.openrouter_delegate_for_model(model) {
1204 return Box::pin(delegate.generate_image(model, prompt, size, image_data)).await;
1205 }
1206 if self.flavor == ProviderFlavor::OpenAiCodex {
1207 anyhow::bail!("image generation not supported on Codex");
1208 }
1209 let (base, model) = self.opencode_route(model);
1210
1211 let mut body = serde_json::json!({
1212 "model": model,
1213 "prompt": prompt,
1214 "n": 1,
1215 });
1216 if self.flavor == ProviderFlavor::OpenAi {
1217 body["size"] = serde_json::json!(size);
1219 } else {
1220 body["aspect_ratio"] = serde_json::json!(Self::image_aspect_ratio(size));
1223 if model.starts_with("google/gemini-3") {
1224 body["resolution"] = serde_json::json!(Self::image_resolution(size));
1225 }
1226 if model.starts_with("openai/gpt-image") || model.starts_with("openai/gpt-5-image") {
1227 body["quality"] = serde_json::json!("high");
1228 }
1229 }
1230 if let Some(img) = image_data {
1231 let b64 = base64::engine::general_purpose::STANDARD.encode(img);
1232 let mime = Self::detect_image_mime(img);
1233 body["input_references"] = serde_json::json!([{
1234 "type": "image_url",
1235 "image_url": { "url": format!("data:{mime};base64,{b64}") }
1236 }]);
1237 }
1238 let v = self
1239 .client
1240 .post(format!("{base}/images"))
1241 .bearer_auth(&self.key)
1242 .json(&body)
1243 .send()
1244 .await
1245 .context("image generation request")?
1246 .error_for_status()
1247 .context("image generation failed")?
1248 .json::<serde_json::Value>()
1249 .await
1250 .context("parsing image generation response")?;
1251 let data = v
1252 .get("data")
1253 .and_then(|d| d.as_array())
1254 .and_then(|a| a.first())
1255 .context("no image data in response")?;
1256 let b64 = data
1257 .get("b64_json")
1258 .and_then(|b| b.as_str())
1259 .context("no b64_json field")?;
1260 let media_type = data
1261 .get("media_type")
1262 .and_then(|m| m.as_str())
1263 .unwrap_or("image/png");
1264 let ext = match media_type {
1265 "image/jpeg" => "jpg",
1266 "image/webp" => "webp",
1267 "image/gif" => "gif",
1268 "image/svg+xml" => "svg",
1269 _ => "png",
1270 };
1271 let bytes = base64::engine::general_purpose::STANDARD
1272 .decode(b64)
1273 .context("base64 decode")?;
1274 Ok((bytes, ext.to_string()))
1275 }
1276
1277 fn detect_image_mime(data: &[u8]) -> &'static str {
1279 if data.len() < 4 {
1280 return "image/png";
1281 }
1282 if data[0] == 0x89 && data[1] == b'P' && data[2] == b'N' && data[3] == b'G' {
1283 "image/png"
1284 } else if data[0] == 0xFF && data[1] == 0xD8 {
1285 "image/jpeg"
1286 } else if data[0] == b'G' && data[1] == b'I' && data[2] == b'F' {
1287 "image/gif"
1288 } else if data[0] == b'R' && data[1] == b'I' && data[2] == b'F' && data[3] == b'F' {
1289 "image/webp"
1290 } else {
1291 "image/png"
1292 }
1293 }
1294
1295 fn image_aspect_ratio(size: &str) -> &'static str {
1296 match size {
1297 "1024x1792" => "9:16",
1298 "1792x1024" => "16:9",
1299 _ => "1:1",
1300 }
1301 }
1302
1303 fn image_resolution(size: &str) -> &'static str {
1304 let max_dimension = size
1305 .split('x')
1306 .filter_map(|value| value.parse::<u32>().ok())
1307 .max()
1308 .unwrap_or(1024);
1309 if max_dimension >= 3000 {
1310 "4K"
1311 } else if max_dimension >= 1800 {
1312 "2K"
1313 } else {
1314 "1K"
1315 }
1316 }
1317
1318 pub async fn ocr_page(&self, model: &str, image_data_url: &str) -> Result<String> {
1320 self.post_completion(ocr_body(model, image_data_url)).await
1321 }
1322
1323 pub async fn embed(&self, model: &str, inputs: Vec<String>) -> Result<Vec<Vec<f32>>> {
1326 if let Some(delegate) = self.openrouter_delegate_for_model(model) {
1327 return Box::pin(delegate.embed(model, inputs)).await;
1328 }
1329 let (base, model) = self.opencode_route(model);
1330 let v = self
1331 .client
1332 .post(format!("{base}/embeddings"))
1333 .bearer_auth(&self.key)
1334 .json(&serde_json::json!({ "model": model, "input": inputs }))
1335 .send()
1336 .await
1337 .context("embeddings request")?
1338 .error_for_status()
1339 .context("embeddings failed")?
1340 .json::<serde_json::Value>()
1341 .await
1342 .context("parsing embeddings")?;
1343 let data = v
1344 .get("data")
1345 .and_then(|d| d.as_array())
1346 .context("embeddings response has no data")?;
1347 let mut out = Vec::with_capacity(data.len());
1348 for item in data {
1349 let emb = item
1350 .get("embedding")
1351 .and_then(|e| e.as_array())
1352 .context("embeddings item has no vector")?;
1353 out.push(
1354 emb.iter()
1355 .filter_map(serde_json::Value::as_f64)
1356 .map(|f| f as f32)
1357 .collect(),
1358 );
1359 }
1360 Ok(out)
1361 }
1362
1363 pub fn stream_chat(
1368 &self,
1369 model: String,
1370 messages: Vec<ChatMessage>,
1371 params: ChatParams,
1372 tools: Vec<ToolDef>,
1373 toolbox: Arc<dyn ToolExecutor>,
1374 max_tool_iters: usize,
1375 ) -> (
1376 mpsc::UnboundedReceiver<StreamEvent>,
1377 tokio::task::AbortHandle,
1378 ) {
1379 let (tx, rx) = mpsc::unbounded_channel();
1380 let this = self.clone();
1381 let task = tokio::spawn(async move {
1382 if let Err(e) = this
1383 .run_chat_loop(model, messages, params, tools, toolbox, max_tool_iters, &tx)
1384 .await
1385 {
1386 let _ = tx.send(StreamEvent::Error(e.to_string()));
1387 }
1388 });
1389 (rx, task.abort_handle())
1390 }
1391
1392 #[allow(clippy::too_many_arguments)]
1396 async fn run_chat_loop(
1397 &self,
1398 model: String,
1399 mut messages: Vec<ChatMessage>,
1400 params: ChatParams,
1401 tools: Vec<ToolDef>,
1402 toolbox: Arc<dyn ToolExecutor>,
1403 max_tool_iters: usize,
1404 tx: &mpsc::UnboundedSender<StreamEvent>,
1405 ) -> Result<()> {
1406 let mut seen_results = super::seed_tool_result_dedup(&messages);
1414 for iter in 0..=max_tool_iters {
1415 match self
1420 .run_stream(
1421 &model,
1422 &messages,
1423 ¶ms,
1424 &tools,
1425 iter == max_tool_iters,
1426 tx,
1427 )
1428 .await?
1429 {
1430 Finish::Errored => return Ok(()),
1431 Finish::Done => {
1432 let _ = tx.send(StreamEvent::Done);
1433 return Ok(());
1434 }
1435 Finish::ToolCalls(calls, content, reasoning) => {
1436 messages.push(ChatMessage {
1437 role: "assistant".to_string(),
1438 content: content.clone(),
1439 reasoning_content: reasoning.clone(),
1440 tool_calls: Some(calls.clone()),
1441 tool_call_id: None,
1442 images: Vec::new(),
1443 });
1444 let results: Vec<(String, String)> = if calls.len() > 1
1451 && calls
1452 .iter()
1453 .all(|call| toolbox.is_read_only(&call.name, &call.arguments))
1454 {
1455 futures_util::future::join_all(calls.iter().map(|call| {
1456 let toolbox = toolbox.clone();
1457 async move { toolbox.run(&call.name, &call.arguments).await }
1458 }))
1459 .await
1460 } else {
1461 let mut results = Vec::with_capacity(calls.len());
1462 for call in &calls {
1463 results.push(toolbox.run(&call.name, &call.arguments).await);
1464 }
1465 results
1466 };
1467 for (index, (call, (result, status))) in calls.iter().zip(results).enumerate() {
1468 let _ = tx.send(StreamEvent::Status("Running tool…".to_string()));
1469 let _ = tx.send(StreamEvent::Status(status));
1470 let _ = tx.send(StreamEvent::ToolCall {
1471 id: call.id.clone(),
1472 reasoning: (index == 0).then(|| reasoning.clone()).flatten(),
1473 assistant_content: (index == 0 && !content.is_empty())
1474 .then(|| content.clone()),
1475 name: call.name.clone(),
1476 arguments: call.arguments.clone(),
1477 result: result.clone(),
1478 });
1479 let key = (call.name.clone(), call.arguments.clone());
1487 let content = match seen_results.get(&key) {
1488 Some(prev) if *prev == result => {
1489 crate::tools::tool_result_unchanged_note(
1490 &call.name,
1491 &call.arguments,
1492 )
1493 }
1494 _ => {
1495 seen_results.insert(key, result.clone());
1496 result.clone()
1497 }
1498 };
1499 messages.push(ChatMessage {
1500 role: "tool".to_string(),
1501 content,
1502 reasoning_content: None,
1503 tool_calls: None,
1504 tool_call_id: Some(call.id.clone()),
1505 images: Vec::new(),
1506 });
1507 if toolbox.supports_images()
1512 && let Some(files_dir) = toolbox.space_files_dir()
1513 && let Some(imgs) = extract_tool_images(&result, &files_dir)
1514 {
1515 messages.push(ChatMessage {
1516 role: "user".to_string(),
1517 content: imgs.description,
1518 reasoning_content: None,
1519 tool_calls: None,
1520 tool_call_id: None,
1521 images: imgs.urls,
1522 });
1523 }
1524 }
1525 }
1526 }
1527 }
1528 let _ = tx.send(StreamEvent::Done);
1529 Ok(())
1530 }
1531
1532 #[allow(clippy::too_many_lines)]
1536 async fn run_stream(
1537 &self,
1538 model: &str,
1539 messages: &[ChatMessage],
1540 params: &ChatParams,
1541 tools: &[ToolDef],
1542 disable_tool_choice: bool,
1543 tx: &mpsc::UnboundedSender<StreamEvent>,
1544 ) -> Result<Finish> {
1545 if let Some(delegate) = self.openrouter_delegate_for_model(model) {
1546 return Box::pin(delegate.run_stream(
1547 model,
1548 messages,
1549 params,
1550 tools,
1551 disable_tool_choice,
1552 tx,
1553 ))
1554 .await;
1555 }
1556 if self.flavor == ProviderFlavor::OpenAiCodex {
1557 return self
1558 .run_codex_stream(model, messages, params, tools, disable_tool_choice, tx)
1559 .await;
1560 }
1561 let (base, model) = self.opencode_route(model);
1562 let mut body = serde_json::json!({
1563 "model": model,
1564 "messages": messages,
1565 "stream": true,
1566 });
1567 let obj = body.as_object_mut().expect("body is a json object");
1568 self.flavor.add_stream_usage(obj);
1569 if let Some(key) = ¶ms.prompt_cache_key {
1570 self.flavor.add_prompt_cache_key(obj, key);
1571 }
1572 if let Some(effort) = ¶ms.reasoning_effort {
1573 self.flavor.add_reasoning_effort(obj, effort);
1574 }
1575 if let Some(t) = params.temperature {
1576 obj.insert("temperature".into(), serde_json::json!(t));
1577 }
1578 if let Some(p) = params.top_p {
1579 obj.insert("top_p".into(), serde_json::json!(p));
1580 }
1581 if let Some(m) = params.max_tokens {
1582 obj.insert("max_tokens".into(), serde_json::json!(m));
1583 }
1584 if !tools.is_empty() {
1585 let wire: Vec<serde_json::Value> = tools
1586 .iter()
1587 .map(|t| {
1588 serde_json::json!({
1589 "type": "function",
1590 "function": { "name": t.name, "description": t.description, "parameters": t.parameters },
1591 })
1592 })
1593 .collect();
1594 obj.insert("tools".into(), serde_json::json!(wire));
1595 if disable_tool_choice {
1596 obj.insert("tool_choice".into(), serde_json::json!("none"));
1597 }
1598 }
1599 let request = self
1600 .client
1601 .post(format!("{base}/chat/completions"))
1602 .bearer_auth(&self.key)
1603 .json(&body);
1604
1605 let mut es = EventSource::new(request).context("opening SSE stream")?;
1606 let mut tool_calls: BTreeMap<usize, ToolCall> = BTreeMap::new();
1607 let mut content_acc = String::new();
1608 let mut reasoning_acc = String::new();
1609 let mut usage = None;
1610 let mut done = false;
1611 while let Some(event) = es.next().await {
1612 match event {
1613 Ok(Event::Open) => {}
1614 Ok(Event::Message(msg)) => {
1615 if msg.data == "[DONE]" {
1616 done = true;
1617 break; }
1619 let (content, reasoning) = parse_delta(&msg.data);
1620 if let Some(r) = reasoning
1621 && !r.is_empty()
1622 {
1623 reasoning_acc.push_str(&r);
1624 let _ = tx.send(StreamEvent::Reasoning(r));
1625 }
1626 if let Some(token) = content
1627 && !token.is_empty()
1628 {
1629 content_acc.push_str(&token);
1630 let _ = tx.send(StreamEvent::Token(token));
1631 }
1632 accumulate_tool_calls(&mut tool_calls, &msg.data);
1633 if let Some(next) = parse_usage(&msg.data) {
1634 merge_usage(&mut usage, next);
1635 } else if let Some(cost) = parse_stream_cost(&msg.data) {
1636 merge_usage(
1640 &mut usage,
1641 Usage {
1642 prompt_tokens: 0,
1643 completion_tokens: 0,
1644 total_tokens: 0,
1645 cache_read_tokens: 0,
1646 cache_creation_tokens: 0,
1647 cost: Some(cost),
1648 },
1649 );
1650 }
1651 }
1652 Err(reqwest_eventsource::Error::StreamEnded) => break,
1653 Err(
1661 reqwest_eventsource::Error::InvalidStatusCode(_, response)
1662 | reqwest_eventsource::Error::InvalidContentType(_, response),
1663 ) => {
1664 let status = response.status();
1665 let body = response.text().await.unwrap_or_default();
1666 let msg = format!("request failed ({status}): {}", truncate_error_body(&body));
1667 let _ = tx.send(StreamEvent::Error(msg));
1668 es.close();
1669 return Ok(Finish::Errored);
1670 }
1671 Err(e) => {
1672 let _ = tx.send(StreamEvent::Error(e.to_string()));
1673 es.close();
1674 return Ok(Finish::Errored);
1675 }
1676 }
1677 }
1678 if done {
1684 loop {
1685 let next = tokio::time::timeout(std::time::Duration::from_secs(3), es.next()).await;
1686 match next {
1687 Ok(Some(Ok(Event::Message(msg)))) if msg.data != "[DONE]" => {
1688 if let Some(cost) = parse_stream_cost(&msg.data) {
1689 merge_usage(
1690 &mut usage,
1691 Usage {
1692 prompt_tokens: 0,
1693 completion_tokens: 0,
1694 total_tokens: 0,
1695 cache_read_tokens: 0,
1696 cache_creation_tokens: 0,
1697 cost: Some(cost),
1698 },
1699 );
1700 }
1701 }
1702 _ => break,
1705 }
1706 }
1707 }
1708 if let Some(usage) = usage {
1712 let _ = tx.send(StreamEvent::Usage(usage));
1713 }
1714 if tool_calls.is_empty() {
1718 Ok(Finish::Done)
1719 } else {
1720 Ok(Finish::ToolCalls(
1721 tool_calls.into_values().collect(),
1722 content_acc,
1723 (!reasoning_acc.is_empty()).then_some(reasoning_acc),
1724 ))
1725 }
1726 }
1727
1728 fn opencode_route(&self, model: &str) -> (&'static str, String) {
1733 if self.flavor == ProviderFlavor::OpencodeGo
1734 && let Some(stripped) = model.strip_prefix(OPENCODE_GO_PREFIX)
1735 {
1736 return (OPENCODE_GO_BASE, stripped.to_string());
1737 }
1738 (self.flavor.base(), model.to_string())
1739 }
1740
1741 fn openrouter_delegate_for_model(&self, model: &str) -> Option<Self> {
1742 if self.flavor == ProviderFlavor::OpenAiCodex && model.contains('/') {
1743 crate::config::load_openrouter_key_only().map(Self::openrouter_flavor)
1744 } else {
1745 None
1746 }
1747 }
1748
1749 fn openrouter_delegate_for_body(&self, body: &serde_json::Value) -> Option<Self> {
1750 let model = body.get("model").and_then(|m| m.as_str())?;
1751 self.openrouter_delegate_for_model(model)
1752 }
1753
1754 fn codex_headers(&self, sse: bool) -> Result<reqwest::header::HeaderMap> {
1755 let account_id = crate::config::codex_account_id(&self.key)?;
1756 let mut h = reqwest::header::HeaderMap::new();
1757 h.insert(
1758 reqwest::header::AUTHORIZATION,
1759 format!("Bearer {}", self.key).parse()?,
1760 );
1761 h.insert("chatgpt-account-id", account_id.parse()?);
1762 h.insert("originator", "nexus-chat".parse()?);
1763 h.insert(reqwest::header::USER_AGENT, "nexus-chat".parse()?);
1764 h.insert("OpenAI-Beta", "responses=experimental".parse()?);
1765 h.insert(reqwest::header::CONTENT_TYPE, "application/json".parse()?);
1766 if sse {
1767 h.insert(reqwest::header::ACCEPT, "text/event-stream".parse()?);
1768 }
1769 Ok(h)
1770 }
1771
1772 async fn run_codex_stream(
1773 &self,
1774 model: &str,
1775 messages: &[ChatMessage],
1776 params: &ChatParams,
1777 tools: &[ToolDef],
1778 disable_tool_choice: bool,
1779 tx: &mpsc::UnboundedSender<StreamEvent>,
1780 ) -> Result<Finish> {
1781 let mut body = serde_json::json!({
1782 "model": model,
1783 "store": false,
1784 "stream": true,
1785 "instructions": codex_instructions(messages),
1786 "input": codex_input(messages),
1787 "text": { "verbosity": "low" },
1788 "include": ["reasoning.encrypted_content"],
1789 "tool_choice": if disable_tool_choice { "none" } else { "auto" },
1790 "parallel_tool_calls": true,
1791 });
1792 let obj = body.as_object_mut().unwrap();
1793 if let Some(key) = ¶ms.prompt_cache_key {
1794 obj.insert("prompt_cache_key".into(), serde_json::json!(key));
1799 }
1800 if let Some(t) = params.temperature {
1801 obj.insert("temperature".into(), serde_json::json!(t));
1802 }
1803 if let Some(effort) = ¶ms.reasoning_effort {
1804 obj.insert(
1805 "reasoning".into(),
1806 serde_json::json!({ "effort": effort, "summary": "auto" }),
1807 );
1808 }
1809 if !tools.is_empty() {
1810 obj.insert("tools".into(), serde_json::json!(codex_tools(tools)));
1811 }
1812 let response = self
1821 .client
1822 .post(format!("{}/codex/responses", self.flavor.base()))
1823 .headers(self.codex_headers(true)?)
1824 .json(&body)
1825 .send()
1826 .await
1827 .context("sending Codex request")?;
1828 let status = response.status();
1829 if !status.is_success() {
1830 let text = response.text().await.unwrap_or_default();
1831 let msg = format!("request failed ({status}): {}", truncate_error_body(&text));
1832 let _ = tx.send(StreamEvent::Error(msg));
1833 return Ok(Finish::Errored);
1834 }
1835
1836 let mut tool_calls: BTreeMap<usize, ToolCall> = BTreeMap::new();
1837 let mut content_acc = String::new();
1838 let mut usage = None;
1839 let mut buf = String::new();
1840 let mut stream = response.bytes_stream();
1841 'stream: while let Some(chunk) = stream.next().await {
1842 let chunk = chunk.context("reading Codex stream")?;
1843 buf.push_str(&String::from_utf8_lossy(&chunk));
1844 while let Some(end) = buf.find("\n\n") {
1849 let event_block: String = buf.drain(..end + 2).collect();
1850 let data = sse_event_data(&event_block);
1851 if data.is_empty() {
1852 continue;
1853 }
1854 if data == "[DONE]" {
1855 break 'stream;
1856 }
1857 if let Some(token) = codex_text_delta(&data)
1858 && !token.is_empty()
1859 {
1860 content_acc.push_str(&token);
1861 let _ = tx.send(StreamEvent::Token(token));
1862 }
1863 if let Some(r) = codex_reasoning_delta(&data)
1864 && !r.is_empty()
1865 {
1866 let _ = tx.send(StreamEvent::Reasoning(r));
1867 }
1868 accumulate_codex_tool_calls(&mut tool_calls, &data);
1869 if let Some(next) = codex_usage(&data) {
1870 merge_usage(&mut usage, next);
1871 }
1872 }
1873 }
1874 if let Some(usage) = usage {
1875 let _ = tx.send(StreamEvent::Usage(usage));
1876 }
1877 if tool_calls.is_empty() {
1878 Ok(Finish::Done)
1879 } else {
1880 Ok(Finish::ToolCalls(
1881 tool_calls.into_values().collect(),
1882 content_acc,
1883 None,
1884 ))
1885 }
1886 }
1887}
1888
1889enum Finish {
1890 Done,
1891 ToolCalls(Vec<ToolCall>, String, Option<String>),
1892 Errored,
1893}
1894
1895fn truncate_error_body(body: &str) -> String {
1898 const MAX: usize = 300;
1899 let trimmed = body.trim();
1900 if trimmed.is_empty() {
1901 return "(empty body)".to_string();
1902 }
1903 let mut truncated: String = trimmed.chars().take(MAX).collect();
1904 if trimmed.chars().count() > MAX {
1905 truncated.push('…');
1906 }
1907 truncated
1908}
1909
1910fn parse_delta(data: &str) -> (Option<String>, Option<String>) {
1913 let Ok(v) = serde_json::from_str::<serde_json::Value>(data) else {
1914 return (None, None);
1915 };
1916 let Some(delta) = v
1917 .get("choices")
1918 .and_then(|c| c.get(0))
1919 .and_then(|c| c.get("delta"))
1920 else {
1921 return (None, None);
1922 };
1923 let field = |name: &str| delta.get(name).and_then(|x| x.as_str()).map(str::to_string);
1924 (
1925 field("content"),
1926 field("reasoning_content").or_else(|| field("reasoning")),
1927 )
1928}
1929
1930fn accumulate_tool_calls(acc: &mut BTreeMap<usize, ToolCall>, data: &str) {
1934 let Ok(v) = serde_json::from_str::<serde_json::Value>(data) else {
1935 return;
1936 };
1937 let Some(calls) = v
1938 .get("choices")
1939 .and_then(|c| c.get(0))
1940 .and_then(|c| c.get("delta"))
1941 .and_then(|d| d.get("tool_calls"))
1942 .and_then(|t| t.as_array())
1943 else {
1944 return;
1945 };
1946 for call in calls {
1947 let idx = usize::try_from(
1948 call.get("index")
1949 .and_then(serde_json::Value::as_u64)
1950 .unwrap_or(0),
1951 )
1952 .unwrap_or(0);
1953 let entry = acc.entry(idx).or_default();
1954 if let Some(id) = call.get("id").and_then(|i| i.as_str())
1955 && !id.is_empty()
1956 {
1957 entry.id = id.to_string();
1958 }
1959 if let Some(func) = call.get("function") {
1960 if let Some(name) = func.get("name").and_then(|n| n.as_str())
1961 && !name.is_empty()
1962 {
1963 entry.name = name.to_string();
1964 }
1965 if let Some(args) = func.get("arguments").and_then(|a| a.as_str()) {
1966 entry.arguments.push_str(args);
1967 }
1968 }
1969 }
1970}
1971
1972fn parse_usage(data: &str) -> Option<Usage> {
1978 let v: serde_json::Value = serde_json::from_str(data).ok()?;
1979 v.get("usage").and_then(parse_usage_value)
1980}
1981
1982fn parse_usage_value(value: &serde_json::Value) -> Option<Usage> {
1984 let u = value.as_object()?;
1985 let get = |k: &str| u.get(k).and_then(json_u64).unwrap_or(0);
1986 let detail = |group: &str, field: &str| {
1987 u.get(group)
1988 .and_then(|d| d.get(field))
1989 .and_then(json_u64)
1990 .unwrap_or(0)
1991 };
1992 let cached = [
1997 detail("prompt_tokens_details", "cached_tokens"),
1998 detail("input_tokens_details", "cached_tokens"),
1999 get("cache_read_input_tokens"),
2000 get("prompt_cache_hit_tokens"),
2001 get("prompt_cache_read_tokens"),
2002 get("cache_read_tokens"),
2003 ]
2004 .into_iter()
2005 .max()
2006 .unwrap_or(0);
2007 let cache_creation = [
2008 detail("prompt_tokens_details", "cache_write_tokens"),
2009 detail("input_tokens_details", "cache_write_tokens"),
2010 get("cache_creation_input_tokens"),
2011 ]
2012 .into_iter()
2013 .max()
2014 .unwrap_or(0);
2015 let cost = u.get("cost").and_then(json_f64);
2016 let usage = Usage {
2017 prompt_tokens: get("prompt_tokens"),
2018 completion_tokens: get("completion_tokens"),
2019 total_tokens: get("total_tokens"),
2020 cache_read_tokens: cached,
2021 cache_creation_tokens: cache_creation,
2022 cost,
2023 };
2024 (usage.prompt_tokens > 0 || usage.completion_tokens > 0 || usage.cost.is_some())
2025 .then_some(usage)
2026}
2027
2028fn merge_usage(slot: &mut Option<Usage>, next: Usage) {
2032 *slot = Some(match slot.take() {
2033 Some(previous) if next.prompt_tokens + next.completion_tokens == 0 => Usage {
2034 cost: next.cost.or(previous.cost),
2035 ..previous
2036 },
2037 Some(previous) => Usage {
2038 prompt_tokens: previous.prompt_tokens.max(next.prompt_tokens),
2044 completion_tokens: previous.completion_tokens.max(next.completion_tokens),
2045 total_tokens: previous.total_tokens.max(next.total_tokens),
2046 cache_read_tokens: previous.cache_read_tokens.max(next.cache_read_tokens),
2047 cache_creation_tokens: previous
2048 .cache_creation_tokens
2049 .max(next.cache_creation_tokens),
2050 cost: next.cost.or(previous.cost),
2051 },
2052 None => next,
2053 });
2054}
2055
2056fn json_u64(value: &serde_json::Value) -> Option<u64> {
2059 value
2060 .as_u64()
2061 .or_else(|| value.as_str()?.parse::<u64>().ok())
2062}
2063
2064fn json_f64(value: &serde_json::Value) -> Option<f64> {
2067 value
2068 .as_f64()
2069 .or_else(|| value.as_str()?.parse::<f64>().ok())
2070 .filter(|n| n.is_finite())
2071}
2072
2073fn parse_stream_cost(data: &str) -> Option<f64> {
2078 let v: serde_json::Value = serde_json::from_str(data).ok()?;
2079 v.get("cost").and_then(json_f64)
2080}
2081
2082fn codex_instructions(messages: &[ChatMessage]) -> String {
2083 let s = messages
2084 .iter()
2085 .filter(|m| m.role == "system")
2086 .map(|m| m.content.as_str())
2087 .collect::<Vec<_>>()
2088 .join("\n\n");
2089 if s.is_empty() {
2090 "You are a helpful assistant.".to_string()
2091 } else {
2092 s
2093 }
2094}
2095
2096fn codex_input(messages: &[ChatMessage]) -> Vec<serde_json::Value> {
2097 messages
2098 .iter()
2099 .filter(|m| m.role != "system")
2100 .flat_map(|m| match m.role.as_str() {
2101 "assistant" => match m.tool_calls.as_ref().filter(|calls| !calls.is_empty()) {
2105 Some(calls) => {
2106 let mut items = Vec::with_capacity(calls.len() + usize::from(!m.content.is_empty()));
2107 if !m.content.is_empty() {
2108 items.push(serde_json::json!({
2114 "role": "assistant",
2115 "content": [{ "type": "output_text", "text": m.content, "annotations": [] }],
2116 }));
2117 }
2118 items.extend(calls.iter().map(|call| {
2119 serde_json::json!({
2120 "type": "function_call",
2121 "call_id": call.id,
2122 "name": call.name,
2123 "arguments": call.arguments,
2124 })
2125 }));
2126 items
2127 }
2128 None => vec![serde_json::json!({
2129 "role": "assistant",
2130 "content": [{ "type": "output_text", "text": m.content, "annotations": [] }],
2131 })],
2132 },
2133 "tool" => vec![serde_json::json!({
2134 "type": "function_call_output",
2135 "call_id": m.tool_call_id.as_deref().unwrap_or_default(),
2136 "output": m.content,
2137 })],
2138 _ => {
2139 let mut content = Vec::new();
2140 if !m.content.is_empty() {
2141 content.push(serde_json::json!({ "type": "input_text", "text": m.content }));
2142 }
2143 for image_url in &m.images {
2144 content.push(serde_json::json!({ "type": "input_image", "detail": "auto", "image_url": image_url }));
2145 }
2146 vec![serde_json::json!({ "role": "user", "content": content })]
2147 }
2148 })
2149 .collect()
2150}
2151
2152fn codex_tools(tools: &[ToolDef]) -> Vec<serde_json::Value> {
2153 tools
2154 .iter()
2155 .map(|t| {
2156 serde_json::json!({
2157 "type": "function",
2158 "name": t.name,
2159 "description": t.description,
2160 "parameters": t.parameters,
2161 "strict": false,
2162 })
2163 })
2164 .collect()
2165}
2166
2167fn chat_body_to_codex_body(
2168 body: &serde_json::Value,
2169 stream: bool,
2170 tools: &[ToolDef],
2171) -> serde_json::Value {
2172 let model = body
2173 .get("model")
2174 .and_then(|v| v.as_str())
2175 .unwrap_or("gpt-5.1-codex-mini");
2176 let messages = body
2177 .get("messages")
2178 .and_then(|m| m.as_array())
2179 .cloned()
2180 .unwrap_or_default();
2181 let instructions = messages
2182 .iter()
2183 .filter(|m| m.get("role").and_then(|r| r.as_str()) == Some("system"))
2184 .filter_map(|m| wire_text(m.get("content")?))
2185 .collect::<Vec<_>>()
2186 .join("\n\n");
2187 let input = messages
2188 .iter()
2189 .filter(|m| m.get("role").and_then(|r| r.as_str()) != Some("system"))
2190 .map(|m| {
2191 let role = m.get("role").and_then(|r| r.as_str()).unwrap_or("user");
2192 if role == "assistant" {
2193 return serde_json::json!({
2194 "role": "assistant",
2195 "content": [{ "type": "output_text", "text": wire_text(m.get("content").unwrap_or(&serde_json::Value::Null)).unwrap_or_default(), "annotations": [] }],
2196 });
2197 }
2198 let mut content = Vec::new();
2199 match m.get("content") {
2200 Some(serde_json::Value::String(s)) => content.push(serde_json::json!({ "type": "input_text", "text": s })),
2201 Some(serde_json::Value::Array(parts)) => {
2202 for p in parts {
2203 match p.get("type").and_then(|t| t.as_str()) {
2204 Some("text") => content.push(serde_json::json!({ "type": "input_text", "text": p.get("text").and_then(|t| t.as_str()).unwrap_or_default() })),
2205 Some("image_url") => content.push(serde_json::json!({ "type": "input_image", "detail": "auto", "image_url": p.get("image_url").and_then(|i| i.get("url")).and_then(|u| u.as_str()).unwrap_or_default() })),
2206 _ => {}
2207 }
2208 }
2209 }
2210 _ => {}
2211 }
2212 serde_json::json!({ "role": "user", "content": content })
2213 })
2214 .collect::<Vec<_>>();
2215 let mut out = serde_json::json!({
2216 "model": model,
2217 "store": false,
2218 "stream": stream,
2219 "instructions": if instructions.is_empty() { "You are a helpful assistant." } else { &instructions },
2220 "input": input,
2221 "text": { "verbosity": "low" },
2222 });
2223 if !tools.is_empty() {
2224 out.as_object_mut()
2225 .unwrap()
2226 .insert("tools".into(), serde_json::json!(codex_tools(tools)));
2227 }
2228 out
2229}
2230
2231fn wire_text(v: &serde_json::Value) -> Option<String> {
2232 match v {
2233 serde_json::Value::String(s) => Some(s.clone()),
2234 serde_json::Value::Array(parts) => Some(
2235 parts
2236 .iter()
2237 .filter_map(|p| p.get("text").and_then(|t| t.as_str()))
2238 .collect::<Vec<_>>()
2239 .join("\n"),
2240 ),
2241 _ => None,
2242 }
2243}
2244
2245fn codex_response_text(v: &serde_json::Value) -> String {
2246 if let Some(s) = v.get("output_text").and_then(|v| v.as_str()) {
2247 return s.to_string();
2248 }
2249 v.get("output")
2250 .and_then(|o| o.as_array())
2251 .into_iter()
2252 .flatten()
2253 .flat_map(|item| {
2254 item.get("content")
2255 .and_then(|c| c.as_array())
2256 .into_iter()
2257 .flatten()
2258 })
2259 .filter_map(|c| {
2260 c.get("text")
2261 .or_else(|| c.get("refusal"))
2262 .and_then(|t| t.as_str())
2263 })
2264 .collect::<Vec<_>>()
2265 .join("")
2266}
2267
2268fn sse_event_data(event_block: &str) -> String {
2274 event_block
2275 .lines()
2276 .filter_map(|l| l.strip_prefix("data:"))
2277 .map(str::trim_start)
2278 .collect::<Vec<_>>()
2279 .join("\n")
2280}
2281
2282fn codex_text_delta(data: &str) -> Option<String> {
2283 let v: serde_json::Value = serde_json::from_str(data).ok()?;
2284 match v.get("type")?.as_str()? {
2285 "response.output_text.delta" | "response.refusal.delta" => {
2286 v.get("delta")?.as_str().map(str::to_string)
2287 }
2288 _ => None,
2289 }
2290}
2291
2292fn codex_reasoning_delta(data: &str) -> Option<String> {
2293 let v: serde_json::Value = serde_json::from_str(data).ok()?;
2294 match v.get("type")?.as_str()? {
2295 "response.reasoning_summary_text.delta" | "response.reasoning_text.delta" => {
2296 v.get("delta")?.as_str().map(str::to_string)
2297 }
2298 _ => None,
2299 }
2300}
2301
2302fn accumulate_codex_tool_calls(acc: &mut BTreeMap<usize, ToolCall>, data: &str) {
2303 let Ok(v) = serde_json::from_str::<serde_json::Value>(data) else {
2304 return;
2305 };
2306 match v.get("type").and_then(|t| t.as_str()) {
2307 Some("response.output_item.added") => {
2308 let Some(item) = v.get("item") else { return };
2309 if item.get("type").and_then(|t| t.as_str()) != Some("function_call") {
2310 return;
2311 }
2312 let idx = usize::try_from(
2313 v.get("output_index")
2314 .and_then(serde_json::Value::as_u64)
2315 .unwrap_or(0),
2316 )
2317 .unwrap_or(0);
2318 let entry = acc.entry(idx).or_default();
2319 entry.id = item
2320 .get("call_id")
2321 .and_then(|s| s.as_str())
2322 .unwrap_or_default()
2323 .to_string();
2324 entry.name = item
2325 .get("name")
2326 .and_then(|s| s.as_str())
2327 .unwrap_or_default()
2328 .to_string();
2329 entry.arguments.push_str(
2330 item.get("arguments")
2331 .and_then(|s| s.as_str())
2332 .unwrap_or_default(),
2333 );
2334 }
2335 Some("response.function_call_arguments.delta") => {
2336 let idx = usize::try_from(
2337 v.get("output_index")
2338 .and_then(serde_json::Value::as_u64)
2339 .unwrap_or(0),
2340 )
2341 .unwrap_or(0);
2342 acc.entry(idx)
2343 .or_default()
2344 .arguments
2345 .push_str(v.get("delta").and_then(|s| s.as_str()).unwrap_or_default());
2346 }
2347 Some("response.function_call_arguments.done") => {
2348 let idx = usize::try_from(
2349 v.get("output_index")
2350 .and_then(serde_json::Value::as_u64)
2351 .unwrap_or(0),
2352 )
2353 .unwrap_or(0);
2354 if let Some(args) = v.get("arguments").and_then(|s| s.as_str()) {
2355 acc.entry(idx).or_default().arguments = args.to_string();
2356 }
2357 }
2358 Some("response.output_item.done") => {
2359 let Some(item) = v.get("item") else { return };
2360 if item.get("type").and_then(|t| t.as_str()) != Some("function_call") {
2361 return;
2362 }
2363 let idx = usize::try_from(
2364 v.get("output_index")
2365 .and_then(serde_json::Value::as_u64)
2366 .unwrap_or(0),
2367 )
2368 .unwrap_or(0);
2369 let entry = acc.entry(idx).or_default();
2370 entry.id = item
2371 .get("call_id")
2372 .and_then(|s| s.as_str())
2373 .unwrap_or(&entry.id)
2374 .to_string();
2375 entry.name = item
2376 .get("name")
2377 .and_then(|s| s.as_str())
2378 .unwrap_or(&entry.name)
2379 .to_string();
2380 entry.arguments = item
2381 .get("arguments")
2382 .and_then(|s| s.as_str())
2383 .unwrap_or(&entry.arguments)
2384 .to_string();
2385 }
2386 _ => {}
2387 }
2388}
2389
2390fn codex_usage(data: &str) -> Option<Usage> {
2391 let v: serde_json::Value = serde_json::from_str(data).ok()?;
2392 let response = v.get("response")?;
2393 let u = response.get("usage")?;
2394 let prompt_tokens = u
2395 .get("input_tokens")
2396 .and_then(serde_json::Value::as_u64)
2397 .unwrap_or(0);
2398 let completion_tokens = u
2399 .get("output_tokens")
2400 .and_then(serde_json::Value::as_u64)
2401 .unwrap_or(0);
2402 Some(Usage {
2403 prompt_tokens,
2404 completion_tokens,
2405 total_tokens: u
2406 .get("total_tokens")
2407 .and_then(serde_json::Value::as_u64)
2408 .unwrap_or(prompt_tokens + completion_tokens),
2409 cache_read_tokens: u
2410 .get("input_tokens_details")
2411 .and_then(|d| d.get("cached_tokens"))
2412 .and_then(json_u64)
2413 .unwrap_or(0),
2414 cache_creation_tokens: u
2415 .get("input_tokens_details")
2416 .and_then(|d| d.get("cache_write_tokens"))
2417 .and_then(json_u64)
2418 .unwrap_or(0),
2419 cost: None,
2420 })
2421}
2422
2423pub const OCR_PROMPT: &str = "Transcribe this scanned page to plain text, faithfully and completely. \
2427 Output ONLY the transcription — no commentary, no markdown fences. \
2428 Preserve the natural reading order; vertical Japanese text reads in \
2429 columns from right to left. Transcribe the body text only: skip \
2430 furigana/ruby annotations (the small kana printed above or beside \
2431 kanji). Render tables as plain text rows. If the page contains no \
2432 text, output nothing.";
2433
2434fn ocr_body(model: &str, image_data_url: &str) -> serde_json::Value {
2435 serde_json::json!({
2436 "model": model,
2437 "stream": false,
2438 "max_tokens": 8000,
2440 "messages": [{
2441 "role": "user",
2442 "content": [
2443 { "type": "text", "text": OCR_PROMPT },
2444 { "type": "image_url", "image_url": { "url": image_data_url } },
2445 ],
2446 }],
2447 })
2448}
2449
2450fn vision_body(model: &str, image_data_url: &str) -> serde_json::Value {
2451 serde_json::json!({
2452 "model": model,
2453 "stream": false,
2454 "messages": [{
2455 "role": "user",
2456 "content": [
2457 { "type": "text",
2458 "text": "Describe this image so another AI model can reason about it without seeing it. \
2459 Cover: what it is (screenshot, chart, photo, diagram…), overall layout and structure, \
2460 the key entities and how they relate, ALL visible text verbatim (preserve code, \
2461 tables, and labels as markdown), and any notable visual details (colors, states, \
2462 highlights, errors). Be thorough but do not speculate beyond what is visible." },
2463 { "type": "image_url", "image_url": { "url": image_data_url } },
2464 ],
2465 }],
2466 })
2467}
2468
2469pub fn normalize_video_params(
2473 model: &str,
2474 duration: u32,
2475 resolution: &str,
2476 aspect_ratio: &str,
2477) -> (u32, String, String) {
2478 let mut duration = duration;
2479 let mut resolution = resolution.to_string();
2480 let mut aspect_ratio = aspect_ratio.to_string();
2481
2482 if model.starts_with("minimax/hailuo-3") {
2483 resolution = "2K".to_string();
2484 } else if model.starts_with("minimax/hailuo-2.3") {
2485 resolution = "1080p".to_string();
2486 aspect_ratio = "16:9".to_string();
2487 } else if model.starts_with("runway/gen-4.5") {
2488 resolution = "720p".to_string();
2489 if !matches!(aspect_ratio.as_str(), "16:9" | "9:16") {
2490 aspect_ratio = "16:9".to_string();
2491 }
2492 } else if model.starts_with("kwaivgi/kling-v3.0") || model.starts_with("kwaivgi/kling-video-o1")
2493 {
2494 resolution = "720p".to_string();
2495 if !matches!(aspect_ratio.as_str(), "16:9" | "9:16" | "1:1") {
2496 aspect_ratio = "16:9".to_string();
2497 }
2498 } else if model.starts_with("x-ai/grok-imagine-video") {
2499 if !matches!(resolution.as_str(), "480p" | "720p" | "1080p") {
2500 resolution = "720p".to_string();
2501 }
2502 } else if model.starts_with("openai/sora-2-pro") {
2503 if !matches!(duration, 4 | 8 | 12 | 16 | 20) {
2504 duration = nearest_duration(duration, &[4, 8, 12, 16, 20]);
2505 }
2506 if !matches!(resolution.as_str(), "720p" | "1080p") {
2507 resolution = "1080p".to_string();
2508 }
2509 if !matches!(aspect_ratio.as_str(), "16:9" | "9:16") {
2510 aspect_ratio = "16:9".to_string();
2511 }
2512 } else if model.starts_with("alibaba/wan-2.6") {
2513 if !matches!(duration, 5 | 10) {
2514 duration = nearest_duration(duration, &[5, 10]);
2515 }
2516 if !matches!(resolution.as_str(), "720p" | "1080p") {
2517 resolution = "1080p".to_string();
2518 }
2519 if !matches!(aspect_ratio.as_str(), "16:9" | "9:16") {
2520 aspect_ratio = "16:9".to_string();
2521 }
2522 } else if model.starts_with("google/veo-3.1") {
2523 if !matches!(duration, 4 | 6 | 8) {
2524 duration = nearest_duration(duration, &[4, 6, 8]);
2525 }
2526 if !matches!(resolution.as_str(), "720p" | "1080p" | "4K") {
2527 resolution = "1080p".to_string();
2528 }
2529 if !matches!(aspect_ratio.as_str(), "16:9" | "9:16") {
2530 aspect_ratio = "16:9".to_string();
2531 }
2532 }
2533
2534 (duration, resolution, aspect_ratio)
2535}
2536
2537fn nearest_duration(requested: u32, allowed: &[u32]) -> u32 {
2538 *allowed
2539 .iter()
2540 .min_by_key(|candidate| candidate.abs_diff(requested))
2541 .unwrap_or(&requested)
2542}
2543
2544#[allow(clippy::too_many_lines)]
2546fn check_video_params(
2547 model: &str,
2548 duration: u32,
2549 resolution: &str,
2550 aspect_ratio: &str,
2551) -> anyhow::Result<()> {
2552 struct Cap {
2554 max_dur: u32,
2555 res_ok: bool,
2556 ar_ok: bool,
2557 }
2558 let caps = |model: &str| -> Option<Cap> {
2559 if model.starts_with("google/veo-3.1-lite")
2560 || model.starts_with("google/veo-3.1-fast")
2561 || model.starts_with("google/veo-3.1")
2562 {
2563 Some(Cap {
2564 max_dur: 8,
2565 res_ok: matches!(resolution, "720p" | "1080p" | "4K"),
2566 ar_ok: matches!(aspect_ratio, "16:9" | "9:16"),
2567 })
2568 } else if model.starts_with("alibaba/wan-2.7") {
2569 Some(Cap {
2570 max_dur: 10,
2571 res_ok: matches!(resolution, "720p" | "1080p"),
2572 ar_ok: true,
2573 })
2574 } else if model.starts_with("bytedance/seedance-2.0") {
2575 Some(Cap {
2576 max_dur: 15,
2577 res_ok: true,
2578 ar_ok: true,
2579 })
2580 } else if model.starts_with("kwaivgi/kling-v3.0")
2581 || model.starts_with("kwaivgi/kling-video-o1")
2582 {
2583 Some(Cap {
2584 max_dur: 15,
2585 res_ok: resolution == "720p",
2586 ar_ok: matches!(aspect_ratio, "16:9" | "9:16" | "1:1"),
2587 })
2588 } else if model.starts_with("openai/sora-2-pro") {
2589 Some(Cap {
2590 max_dur: 20,
2591 res_ok: matches!(resolution, "720p" | "1080p"),
2592 ar_ok: matches!(aspect_ratio, "16:9" | "9:16"),
2593 })
2594 } else if model.starts_with("runway/gen-4.5") {
2595 Some(Cap {
2596 max_dur: 10,
2597 res_ok: resolution == "720p",
2598 ar_ok: matches!(aspect_ratio, "16:9" | "9:16"),
2599 })
2600 } else if model.starts_with("x-ai/grok-imagine-video-1.5") {
2601 Some(Cap {
2602 max_dur: 15,
2603 res_ok: matches!(resolution, "480p" | "720p" | "1080p"),
2604 ar_ok: true,
2605 })
2606 } else if model.starts_with("minimax/hailuo-3") {
2607 Some(Cap {
2608 max_dur: 15,
2609 res_ok: resolution == "2K",
2610 ar_ok: true,
2611 })
2612 } else if model.starts_with("bytedance/seedance-1-5-pro") {
2613 Some(Cap {
2614 max_dur: 12,
2615 res_ok: matches!(resolution, "480p" | "720p" | "1080p"),
2616 ar_ok: true,
2617 })
2618 } else if model.starts_with("minimax/hailuo-2.3") {
2619 Some(Cap {
2620 max_dur: 10,
2621 res_ok: resolution == "1080p",
2622 ar_ok: aspect_ratio == "16:9",
2623 })
2624 } else if model.starts_with("alibaba/happyhorse") {
2625 Some(Cap {
2626 max_dur: 15,
2627 res_ok: matches!(resolution, "720p" | "1080p"),
2628 ar_ok: true,
2629 })
2630 } else if model.starts_with("x-ai/grok-imagine-video") {
2631 Some(Cap {
2632 max_dur: 15,
2633 res_ok: matches!(resolution, "480p" | "720p"),
2634 ar_ok: true,
2635 })
2636 } else {
2637 None }
2639 };
2640 if let Some(c) = caps(model) {
2641 if duration > c.max_dur {
2642 anyhow::bail!(
2643 "model {model} does not support duration {duration}s — max {max}s. Change the model in /config.",
2644 max = c.max_dur
2645 );
2646 }
2647 if !c.res_ok {
2648 anyhow::bail!(
2649 "model {model} may not support resolution {resolution}. Change the model in /config."
2650 );
2651 }
2652 if !c.ar_ok {
2653 anyhow::bail!(
2654 "model {model} may not support aspect ratio {aspect_ratio}. Change the model in /config."
2655 );
2656 }
2657 }
2658 Ok(())
2659}
2660
2661pub(crate) fn extract_tool_images(
2666 result: &str,
2667 files_dir: &std::path::Path,
2668) -> Option<ImagesForTool> {
2669 use base64::Engine;
2670 let mut urls: Vec<String> = Vec::new();
2671 let mut descs: Vec<String> = Vec::new();
2672 let mut rest = result;
2673 while let Some(start) = rest.find("![") {
2674 if let Some(end) = rest[start..].find(')') {
2675 let inner = &rest[start + 2..start + end];
2676 if let Some((desc, file)) = inner.split_once("](") {
2677 let path = files_dir.join(file);
2678 if let Ok(bytes) = std::fs::read(&path) {
2679 let resized = resize_for_api(&bytes, files_dir);
2680 let b64 = base64::engine::general_purpose::STANDARD.encode(&resized);
2681 let ext = if resized.len() < bytes.len() {
2682 "jpg"
2683 } else {
2684 "png"
2685 };
2686 let mime = match ext {
2687 "jpg" | "jpeg" => "image/jpeg",
2688 "gif" => "image/gif",
2689 "webp" => "image/webp",
2690 _ => "image/png",
2691 };
2692 urls.push(format!("data:{mime};base64,{b64}"));
2693 descs.push(desc.to_string());
2694 }
2695 }
2696 rest = &rest[start + end + 1..];
2697 } else {
2698 break;
2699 }
2700 }
2701 if urls.is_empty() {
2702 return None;
2703 }
2704 let description = format!("The tool returned this image: {}", descs.join(", "));
2705 Some(ImagesForTool { urls, description })
2706}
2707
2708fn resize_for_api(bytes: &[u8], _files_dir: &std::path::Path) -> Vec<u8> {
2712 if bytes.len() < 1_000_000 {
2715 return bytes.to_vec();
2716 }
2717 if let Ok(img) = image::load_from_memory(bytes) {
2718 let (w, h) = (img.width(), img.height());
2719 let max_dim = 1024u32;
2720 if w <= max_dim && h <= max_dim {
2721 return bytes.to_vec();
2722 }
2723 let (nw, nh) = if w > h {
2724 (max_dim, (h * max_dim / w).max(1))
2725 } else {
2726 ((w * max_dim / h).max(1), max_dim)
2727 };
2728 let small = img.resize(nw, nh, image::imageops::FilterType::Lanczos3);
2729 let mut out = Vec::new();
2730 if small
2731 .write_to(
2732 &mut std::io::Cursor::new(&mut out),
2733 image::ImageFormat::Jpeg,
2734 )
2735 .is_ok()
2736 {
2737 return out;
2738 }
2739 }
2740 bytes.to_vec()
2741}
2742
2743pub(crate) struct ImagesForTool {
2744 pub(crate) urls: Vec<String>,
2745 pub(crate) description: String,
2746}
2747
2748#[cfg(test)]
2749mod tests {
2750 use super::*;
2751
2752 #[tokio::test]
2753 async fn codex_catalog_matches_current_chatgpt_models() {
2754 let models = OpenRouter::openai_codex("token".into())
2755 .list_models()
2756 .await
2757 .unwrap();
2758 let ids: Vec<&str> = models.iter().map(|model| model.id.as_str()).collect();
2759
2760 assert_eq!(
2761 ids,
2762 [
2763 "gpt-5.3-codex-spark",
2764 "gpt-5.4",
2765 "gpt-5.4-mini",
2766 "gpt-5.5",
2767 "gpt-5.6-sol",
2768 "gpt-5.6-terra",
2769 "gpt-5.6-luna",
2770 ]
2771 );
2772 assert!(models.iter().all(|model| {
2773 model.backend == crate::provider::BackendTag::Codex
2774 && !model.reasoning_efforts.is_empty()
2775 }));
2776 }
2777
2778 #[test]
2779 #[allow(clippy::too_many_lines)]
2781 fn opencode_context_fallback_covers_every_live_catalog_id() {
2782 let zen_ids = [
2786 "big-pickle",
2787 "claude-fable-5",
2788 "claude-opus-5",
2789 "claude-opus-4-8",
2790 "claude-opus-4-7",
2791 "claude-opus-4-6",
2792 "claude-opus-4-5",
2793 "claude-opus-4-1",
2794 "claude-sonnet-5",
2795 "claude-sonnet-4-6",
2796 "claude-sonnet-4-5",
2797 "claude-sonnet-4",
2798 "claude-haiku-4-5",
2799 "gemini-3.6-flash",
2800 "gemini-3.5-flash-lite",
2801 "gemini-3.5-flash",
2802 "gemini-3.1-pro",
2803 "gemini-3-flash",
2804 "gpt-5.6-sol",
2805 "gpt-5.6-terra",
2806 "gpt-5.6-luna",
2807 "gpt-5.5",
2808 "gpt-5.5-pro",
2809 "gpt-5.4",
2810 "gpt-5.4-pro",
2811 "gpt-5.4-mini",
2812 "gpt-5.4-nano",
2813 "gpt-5.3-codex-spark",
2814 "gpt-5.3-codex",
2815 "gpt-5.2",
2816 "gpt-5.2-codex",
2817 "gpt-5.1",
2818 "gpt-5.1-codex-max",
2819 "gpt-5.1-codex",
2820 "gpt-5.1-codex-mini",
2821 "gpt-5",
2822 "gpt-5-codex",
2823 "gpt-5-nano",
2824 "grok-build-0.1",
2825 "grok-4.5",
2826 "deepseek-v4-pro",
2827 "deepseek-v4-flash",
2828 "glm-5.2",
2829 "glm-5.1",
2830 "glm-5",
2831 "minimax-m3",
2832 "minimax-m2.7",
2833 "minimax-m2.5",
2834 "kimi-k3",
2835 "kimi-k2.7-code",
2836 "kimi-k2.6",
2837 "kimi-k2.5",
2838 "qwen3.6-plus",
2839 "qwen3.5-plus",
2840 "deepseek-v4-flash-free",
2841 "mimo-v2.5-free",
2842 "ling-3.0-flash-free",
2843 "nemotron-3-ultra-free",
2844 "north-mini-code-free",
2845 "laguna-s-2.1-free",
2846 "longcat-2.0-free",
2847 ];
2848 let go_ids = [
2849 "minimax-m3",
2850 "minimax-m2.7",
2851 "minimax-m2.5",
2852 "kimi-k3",
2853 "kimi-k2.7-code",
2854 "kimi-k2.6",
2855 "kimi-k2.5",
2856 "glm-5.2",
2857 "glm-5.1",
2858 "glm-5",
2859 "deepseek-v4-pro",
2860 "deepseek-v4-flash",
2861 "qwen3.7-max",
2862 "qwen3.8-max",
2863 "qwen3.7-plus",
2864 "qwen3.6-plus",
2865 "qwen3.5-plus",
2866 "mimo-v2-pro",
2867 "mimo-v2-omni",
2868 "mimo-v2.5-pro",
2869 "mimo-v2.5",
2870 "hy3",
2871 "hy3-preview",
2872 "gpt-5.6-luna",
2873 "grok-4.5",
2874 ];
2875 for id in zen_ids {
2876 assert!(
2877 OpenRouter::opencode_context_fallback(OPENCODE_ZEN_BASE, id).is_some(),
2878 "no context window for Zen general model {id}"
2879 );
2880 }
2881 for id in go_ids {
2882 assert!(
2883 OpenRouter::opencode_context_fallback(OPENCODE_GO_BASE, id).is_some(),
2884 "no context window for Go bundle model {id}"
2885 );
2886 }
2887 }
2888
2889 #[test]
2890 fn opencode_context_fallback_differs_per_endpoint_and_ignores_other_flavors() {
2891 assert_eq!(
2894 OpenRouter::opencode_context_fallback(OPENCODE_ZEN_BASE, "qwen3.6-plus"),
2895 Some(262_144)
2896 );
2897 assert_eq!(
2898 OpenRouter::opencode_context_fallback(OPENCODE_GO_BASE, "qwen3.6-plus"),
2899 Some(1_000_000)
2900 );
2901 assert_eq!(
2902 OpenRouter::opencode_context_fallback(OPENCODE_ZEN_BASE, "minimax-m3"),
2903 Some(512_000)
2904 );
2905 assert_eq!(
2906 OpenRouter::opencode_context_fallback(OPENCODE_GO_BASE, "minimax-m3"),
2907 Some(1_000_000)
2908 );
2909 assert_eq!(
2912 OpenRouter::opencode_context_fallback(OPENROUTER_BASE, "deepseek-v4-pro"),
2913 None
2914 );
2915 assert_eq!(
2916 OpenRouter::opencode_context_fallback(OPENCODE_ZEN_BASE, "nope"),
2917 None
2918 );
2919 assert_eq!(
2920 OpenRouter::opencode_context_fallback(OPENCODE_GO_BASE, "go:hy3"),
2921 None
2922 );
2923 }
2924
2925 #[test]
2926 fn reasoning_efforts_prefer_catalog_metadata_then_use_backend_fallbacks() {
2927 let entry = |id: &str, parameters: &[&str], efforts: &[&str]| ModelEntry {
2928 id: id.to_string(),
2929 name: None,
2930 supported_parameters: parameters
2931 .iter()
2932 .map(std::string::ToString::to_string)
2933 .collect(),
2934 reasoning: (!efforts.is_empty()).then(|| ModelReasoningEntry {
2935 supported_efforts: efforts
2936 .iter()
2937 .map(std::string::ToString::to_string)
2938 .collect(),
2939 }),
2940 context_length: None,
2941 architecture: None,
2942 pricing: None,
2943 };
2944
2945 let or = OpenRouter::openrouter_flavor("k".into());
2946 let sparse = entry("google/gemini", &[], &["high", "minimal"]);
2949 assert_eq!(
2950 or.flavor.reasoning_efforts(&sparse),
2951 vec![ReasoningEffort::Minimal, ReasoningEffort::High]
2952 );
2953 let full = entry(
2954 "openai/gpt-next",
2955 &["reasoning"],
2956 &["max", "xhigh", "high", "medium", "low", "none"],
2957 );
2958 assert_eq!(
2959 or.flavor.reasoning_efforts(&full),
2960 ReasoningEffort::WITH_MAX_XHIGH_AND_NONE.to_vec()
2961 );
2962
2963 let claude = entry("anthropic/claude-sonnet-4.5", &["reasoning"], &[]);
2966 assert_eq!(
2967 or.flavor.reasoning_efforts(&claude),
2968 ReasoningEffort::WITH_MINIMAL.to_vec()
2969 );
2970 let claude_no_param = entry("anthropic/claude-sonnet-4.5", &[], &[]);
2971 assert!(or.flavor.reasoning_efforts(&claude_no_param).is_empty());
2972 let generic = entry("deepseek/reasoner", &["reasoning"], &[]);
2973 assert_eq!(
2974 or.flavor.reasoning_efforts(&generic),
2975 ReasoningEffort::STANDARD.to_vec()
2976 );
2977
2978 let oa = OpenRouter::openai("k".into());
2981 assert_eq!(
2982 oa.flavor.reasoning_efforts(&entry("gpt-5", &[], &[])),
2983 ReasoningEffort::WITH_MINIMAL.to_vec()
2984 );
2985 assert_eq!(
2986 oa.flavor.reasoning_efforts(&entry("gpt-5-pro", &[], &[])),
2987 ReasoningEffort::HIGH_ONLY.to_vec()
2988 );
2989 assert_eq!(
2990 oa.flavor.reasoning_efforts(&entry("gpt-5.4", &[], &[])),
2991 ReasoningEffort::WITH_XHIGH_AND_NONE.to_vec()
2992 );
2993 let o3 = entry("o3", &[], &[]);
2994 assert_eq!(
2995 oa.flavor.reasoning_efforts(&o3),
2996 ReasoningEffort::STANDARD.to_vec()
2997 );
2998 let gpt41 = entry("gpt-4.1", &[], &[]);
2999 assert!(oa.flavor.reasoning_efforts(&gpt41).is_empty());
3000
3001 let go = OpenRouter::opencode_go("k".into());
3004 assert_eq!(
3005 go.flavor.reasoning_efforts(&sparse),
3006 vec![ReasoningEffort::Minimal, ReasoningEffort::High]
3007 );
3008 assert_eq!(
3009 go.flavor.reasoning_efforts(&gpt41),
3010 ReasoningEffort::STANDARD.to_vec()
3011 );
3012 }
3013
3014 #[test]
3015 fn opencode_go_reports_its_own_backend_base_and_defaults() {
3016 let p = OpenRouter::opencode_go("k".into());
3017 assert_eq!(p.backend_tag().display_name(), "OpenCode Go");
3018 assert_eq!(p.flavor.base(), "https://opencode.ai/zen/v1");
3019 assert!(!p.default_utility_model().is_empty());
3020 assert!(!p.default_research_model().is_empty());
3021 assert_eq!(p.default_embedding_model(), "");
3023 }
3024
3025 #[test]
3026 fn opencode_route_sends_go_tagged_models_to_the_go_base_untagged() {
3027 let p = OpenRouter::opencode_go("k".into());
3028 let (base, model) = p.opencode_route("go:deepseek-v4-pro");
3029 assert_eq!(base, "https://opencode.ai/zen/go/v1");
3030 assert_eq!(model, "deepseek-v4-pro");
3031 }
3032
3033 #[test]
3034 fn opencode_route_sends_untagged_models_to_zen_general() {
3035 let p = OpenRouter::opencode_go("k".into());
3036 let (base, model) = p.opencode_route("deepseek-v4-flash-free");
3037 assert_eq!(base, "https://opencode.ai/zen/v1");
3038 assert_eq!(model, "deepseek-v4-flash-free");
3039 }
3040
3041 #[test]
3042 fn opencode_route_is_a_no_op_for_other_flavors() {
3043 let p = OpenRouter::openrouter_flavor("k".into());
3046 let (base, model) = p.opencode_route("go:whatever");
3047 assert_eq!(base, "https://openrouter.ai/api/v1");
3048 assert_eq!(model, "go:whatever");
3049 }
3050
3051 #[test]
3052 fn sse_event_data_joins_multiple_data_lines() {
3053 let block = "event: response.created\ndata: {\"a\":1}\ndata: more\n\n";
3054 assert_eq!(sse_event_data(block), "{\"a\":1}\nmore");
3055 }
3056
3057 #[test]
3058 fn sse_event_data_ignores_non_data_lines() {
3059 let block = "id: 5\nevent: ping\n\n";
3060 assert_eq!(sse_event_data(block), "");
3061 }
3062
3063 #[test]
3064 fn sse_event_data_handles_done_sentinel() {
3065 let block = "event: done\ndata: [DONE]\n\n";
3066 assert_eq!(sse_event_data(block), "[DONE]");
3067 }
3068
3069 #[test]
3070 fn truncate_error_body_reports_empty_body_explicitly() {
3071 assert_eq!(truncate_error_body(""), "(empty body)");
3072 assert_eq!(truncate_error_body(" \n "), "(empty body)");
3073 }
3074
3075 #[test]
3076 fn truncate_error_body_passes_short_text_through() {
3077 assert_eq!(truncate_error_body(" access denied "), "access denied");
3078 }
3079
3080 #[test]
3081 fn truncate_error_body_caps_long_text_with_ellipsis() {
3082 let long = "x".repeat(1000);
3083 let out = truncate_error_body(&long);
3084 assert!(out.ends_with('…'));
3085 assert_eq!(out.chars().count(), 301); }
3087
3088 #[test]
3089 fn ocr_body_has_prompt_image_and_token_budget() {
3090 let body = ocr_body("google/gemini-2.5-flash-lite", "data:image/png;base64,AAAA");
3091 assert_eq!(body["model"], "google/gemini-2.5-flash-lite");
3092 assert_eq!(body["stream"], false);
3093 assert!(body["max_tokens"].as_u64().unwrap() >= 8000);
3095 let content = &body["messages"][0]["content"];
3096 let prompt = content[0]["text"].as_str().unwrap();
3097 assert!(
3098 prompt.contains("furigana"),
3099 "prompt must say to skip furigana"
3100 );
3101 assert!(
3102 prompt.contains("right to left"),
3103 "prompt must cover vertical text"
3104 );
3105 assert_eq!(content[1]["image_url"]["url"], "data:image/png;base64,AAAA");
3106 }
3107
3108 #[test]
3109 fn vision_body_has_image_url_content_part() {
3110 let body = vision_body("google/gemini-2.5-flash-lite", "data:image/png;base64,AAAA");
3111 assert_eq!(body["model"], "google/gemini-2.5-flash-lite");
3112 assert_eq!(body["stream"], false);
3113 let content = &body["messages"][0]["content"];
3114 assert_eq!(content[0]["type"], "text");
3115 assert!(
3116 content[0]["text"]
3117 .as_str()
3118 .unwrap()
3119 .to_lowercase()
3120 .contains("describe this image")
3121 );
3122 assert_eq!(content[1]["type"], "image_url");
3123 assert_eq!(content[1]["image_url"]["url"], "data:image/png;base64,AAAA");
3124 }
3125
3126 #[test]
3127 fn opencode_cache_key_is_scoped_to_its_wire_flavor() {
3128 let mut opencode = serde_json::Map::new();
3129 ProviderFlavor::OpencodeGo.add_prompt_cache_key(&mut opencode, "session-1");
3130 assert_eq!(
3131 opencode.get("prompt_cache_key"),
3132 Some(&serde_json::json!("session-1"))
3133 );
3134
3135 let mut openai = serde_json::Map::new();
3136 ProviderFlavor::OpenAi.add_prompt_cache_key(&mut openai, "session-1");
3137 assert!(openai.is_empty());
3138 }
3139
3140 #[test]
3141 fn extracts_content_delta() {
3142 let data = r#"{"choices":[{"delta":{"content":"Hel"}}]}"#;
3143 let (content, reasoning) = parse_delta(data);
3144 assert_eq!(content.as_deref(), Some("Hel"));
3145 assert_eq!(reasoning, None);
3146 }
3147
3148 #[test]
3149 fn extracts_reasoning_delta() {
3150 let data = r#"{"choices":[{"delta":{"reasoning":"Let me think"}}]}"#;
3151 let (content, reasoning) = parse_delta(data);
3152 assert_eq!(content, None);
3153 assert_eq!(reasoning.as_deref(), Some("Let me think"));
3154 }
3155
3156 #[test]
3157 fn extracts_reasoning_content_delta() {
3158 let data = r#"{"choices":[{"delta":{"reasoning_content":"Keep this on replay"}}]}"#;
3159 let (content, reasoning) = parse_delta(data);
3160 assert_eq!(content, None);
3161 assert_eq!(reasoning.as_deref(), Some("Keep this on replay"));
3162 }
3163
3164 #[test]
3165 fn empty_delta_yields_none() {
3166 let data = r#"{"choices":[{"delta":{"role":"assistant"}}]}"#;
3167 assert_eq!(parse_delta(data), (None, None));
3168 }
3169
3170 #[test]
3171 fn junk_yields_none() {
3172 assert_eq!(parse_delta("not json"), (None, None));
3173 }
3174
3175 #[test]
3176 fn parses_usage() {
3177 let data = r#"{"choices":[],"usage":{"prompt_tokens":120,"completion_tokens":40,"total_tokens":160}}"#;
3178 let u = parse_usage(data).unwrap();
3179 assert_eq!(u.prompt_tokens, 120);
3180 assert_eq!(u.completion_tokens, 40);
3181 assert_eq!(u.total_tokens, 160);
3182 assert_eq!(u.cache_read_tokens, 0);
3183 assert_eq!(u.cache_creation_tokens, 0);
3184 assert!(parse_usage(r#"{"choices":[{"delta":{"content":"hi"}}]}"#).is_none());
3185 }
3186
3187 #[test]
3188 fn merging_usage_never_reduces_counts_within_a_request() {
3189 let mut slot = Some(Usage {
3190 prompt_tokens: 120,
3191 completion_tokens: 40,
3192 total_tokens: 160,
3193 cache_read_tokens: 100,
3194 cache_creation_tokens: 5,
3195 cost: None,
3196 });
3197 merge_usage(
3198 &mut slot,
3199 Usage {
3200 prompt_tokens: 80,
3201 completion_tokens: 20,
3202 total_tokens: 100,
3203 cache_read_tokens: 60,
3204 cache_creation_tokens: 2,
3205 cost: Some(0.01),
3206 },
3207 );
3208 let u = slot.unwrap();
3209 assert_eq!(u.prompt_tokens, 120);
3210 assert_eq!(u.completion_tokens, 40);
3211 assert_eq!(u.total_tokens, 160);
3212 assert_eq!(u.cache_read_tokens, 100);
3213 assert_eq!(u.cache_creation_tokens, 5);
3214 assert_eq!(u.cost, Some(0.01));
3215 }
3216
3217 #[test]
3218 fn parses_usage_cache_tokens_both_styles() {
3219 let openai = r#"{"usage":{"prompt_tokens":100,"prompt_tokens_details":{"cached_tokens":70,"cache_write_tokens":20},"completion_tokens":10,"total_tokens":110,"cost":0.00024}}"#;
3222 let u = parse_usage(openai).unwrap();
3223 assert_eq!(u.cache_read_tokens, 70);
3224 assert_eq!(u.cache_creation_tokens, 20);
3225 assert_eq!(u.cache_hit_rate(), Some(0.7));
3226 assert_eq!(u.cost, Some(0.00024));
3227 let anthropic = r#"{"usage":{"prompt_tokens":100,"cache_read_input_tokens":40,"cache_creation_input_tokens":10,"completion_tokens":10,"total_tokens":110}}"#;
3229 let u = parse_usage(anthropic).unwrap();
3230 assert_eq!(u.cache_read_tokens, 40);
3231 assert_eq!(u.cache_creation_tokens, 10);
3232 assert_eq!(u.cache_hit_rate(), Some(0.4));
3233 }
3234
3235 #[test]
3236 fn deepseek_style_cache_hit_tokens_parsed() {
3237 let data = r#"{"choices":[],"usage":{"prompt_tokens":6,"completion_tokens":28,"total_tokens":34,"prompt_cache_hit_tokens":2,"prompt_cache_miss_tokens":4,"prompt_tokens_details":{"cached_tokens":1},"completion_tokens_details":{"reasoning_tokens":26}}}"#;
3241 let u = parse_usage(data).unwrap();
3242 assert_eq!(u.cache_read_tokens, 2);
3243 assert_eq!(u.cache_hit_rate(), Some(2.0 / 6.0));
3244 }
3245
3246 #[test]
3247 fn null_or_zero_usage_is_not_an_event() {
3248 let null_usage =
3251 r#"{"id":"x","choices":[{"index":0,"delta":{"content":null}}],"usage":null}"#;
3252 assert!(parse_usage(null_usage).is_none());
3253 let zero =
3255 r#"{"choices":[],"usage":{"prompt_tokens":0,"completion_tokens":0,"total_tokens":0}}"#;
3256 assert!(parse_usage(zero).is_none());
3257 }
3258
3259 #[test]
3260 fn stream_cost_parses_string_dollars() {
3261 assert_eq!(parse_stream_cost(r#"{"choices":[],"cost":"0"}"#), Some(0.0));
3263 assert_eq!(
3264 parse_stream_cost(r#"{"choices":[],"cost":"0.0012"}"#),
3265 Some(0.0012)
3266 );
3267 assert!(parse_stream_cost(r#"{"choices":[]}"#).is_none());
3269 assert!(parse_stream_cost(r#"{"choices":[],"usage":{"prompt_tokens":1}}"#).is_none());
3270 }
3271
3272 #[test]
3273 fn codex_usage_reads_cached_input_and_cache_writes() {
3274 let data = r#"{"response":{"usage":{"input_tokens":50,"output_tokens":5,"input_tokens_details":{"cached_tokens":30,"cache_write_tokens":12}}}}"#;
3275 let u = codex_usage(data).unwrap();
3276 assert_eq!(u.prompt_tokens, 50);
3277 assert_eq!(u.cache_read_tokens, 30);
3278 assert_eq!(u.cache_creation_tokens, 12);
3279 assert_eq!(u.cache_hit_rate(), Some(0.6));
3280 }
3281
3282 #[test]
3283 fn accumulates_tool_call_fragments_across_chunks() {
3284 let mut acc = BTreeMap::new();
3285 accumulate_tool_calls(
3286 &mut acc,
3287 r#"{"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"web_search","arguments":""}}]}}]}"#,
3288 );
3289 accumulate_tool_calls(
3290 &mut acc,
3291 r#"{"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"query\""}}]}}]}"#,
3292 );
3293 accumulate_tool_calls(
3294 &mut acc,
3295 r#"{"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":":\"rust\"}"}}]}}]}"#,
3296 );
3297 let call = acc.get(&0).unwrap();
3298 assert_eq!(call.id, "call_1");
3299 assert_eq!(call.name, "web_search");
3300 assert_eq!(call.arguments, r#"{"query":"rust"}"#);
3301 }
3302
3303 #[test]
3304 fn accumulates_multiple_parallel_tool_calls_by_index() {
3305 let mut acc = BTreeMap::new();
3306 accumulate_tool_calls(
3307 &mut acc,
3308 r#"{"choices":[{"delta":{"tool_calls":[
3309 {"index":0,"id":"a","function":{"name":"skill","arguments":"{}"}},
3310 {"index":1,"id":"b","function":{"name":"web_search","arguments":"{}"}}
3311 ]}}]}"#,
3312 );
3313 assert_eq!(acc.len(), 2);
3314 assert_eq!(acc[&0].name, "skill");
3315 assert_eq!(acc[&1].name, "web_search");
3316 }
3317
3318 #[test]
3319 fn codex_input_preserves_assistant_content_with_parallel_function_calls() {
3320 let messages = vec![
3321 ChatMessage {
3322 role: "assistant".into(),
3323 content: "I will check both sources.".into(),
3324 tool_calls: Some(vec![
3325 ToolCall {
3326 id: "call_a".into(),
3327 name: "first".into(),
3328 arguments: "{}".into(),
3329 },
3330 ToolCall {
3331 id: "call_b".into(),
3332 name: "second".into(),
3333 arguments: r#"{"value":2}"#.into(),
3334 },
3335 ]),
3336 ..Default::default()
3337 },
3338 ChatMessage {
3339 role: "tool".into(),
3340 content: "first result".into(),
3341 tool_call_id: Some("call_a".into()),
3342 ..Default::default()
3343 },
3344 ChatMessage {
3345 role: "tool".into(),
3346 content: "second result".into(),
3347 tool_call_id: Some("call_b".into()),
3348 ..Default::default()
3349 },
3350 ];
3351
3352 let input = codex_input(&messages);
3353 assert_eq!(input.len(), 5);
3354 assert_eq!(input[0]["role"], "assistant");
3355 assert_eq!(input[0]["content"][0]["text"], "I will check both sources.");
3356 assert_eq!(input[1]["type"], "function_call");
3357 assert_eq!(input[1]["call_id"], "call_a");
3358 assert_eq!(input[2]["type"], "function_call");
3359 assert_eq!(input[2]["call_id"], "call_b");
3360 assert_eq!(input[3]["type"], "function_call_output");
3361 assert_eq!(input[3]["call_id"], "call_a");
3362 assert_eq!(input[4]["type"], "function_call_output");
3363 assert_eq!(input[4]["call_id"], "call_b");
3364 }
3365
3366 #[test]
3367 fn request_body_omits_tools_key_when_empty() {
3368 let body = serde_json::json!({ "model": "m", "messages": Vec::<ChatMessage>::new(), "stream": true });
3369 assert!(body.get("tools").is_none());
3370 }
3371
3372 #[test]
3373 fn parses_input_modalities_into_supports_images() {
3374 let json = r#"{"data":[
3375 {"id":"a/vision","architecture":{"input_modalities":["text","image"]}},
3376 {"id":"b/text","architecture":{"input_modalities":["text"]}},
3377 {"id":"c/legacy"}
3378 ]}"#;
3379 let resp: ModelsResponse = serde_json::from_str(json).unwrap();
3380 let flags: Vec<bool> = resp.data.iter().map(entry_supports_images).collect();
3381 assert_eq!(flags, vec![true, false, false]);
3382 }
3383
3384 #[test]
3385 fn catalog_pricing_scales_per_token_to_per_million() {
3386 let p = CatalogPricing {
3390 prompt: Some("8e-08".into()),
3391 completion: Some("1.8e-07".into()),
3392 input_cache_read: Some("1.6e-08".into()),
3393 input_cache_write: Some("1e-07".into()),
3394 };
3395 let scaled = p.usd_per_million().unwrap();
3396 assert!((scaled.prompt - 0.08).abs() < 1e-12);
3397 assert!((scaled.completion - 0.18).abs() < 1e-12);
3398 assert!((scaled.cache_read.unwrap() - 0.016).abs() < 1e-12);
3399 assert!((scaled.cache_write.unwrap() - 0.1).abs() < 1e-12);
3400 let free = CatalogPricing {
3401 prompt: Some("0".into()),
3402 completion: Some("0".into()),
3403 input_cache_read: None,
3404 input_cache_write: None,
3405 };
3406 assert_eq!(
3407 free.usd_per_million(),
3408 Some(ModelPricing {
3409 prompt: 0.0,
3410 completion: 0.0,
3411 cache_read: None,
3412 cache_write: None,
3413 })
3414 );
3415 let missing = CatalogPricing {
3416 prompt: None,
3417 completion: None,
3418 input_cache_read: None,
3419 input_cache_write: None,
3420 };
3421 assert_eq!(missing.usd_per_million(), None);
3422 }
3423
3424 #[test]
3425 fn defaults_use_current_quality_generation_models() {
3426 let provider = OpenRouter::openrouter_flavor("key".into());
3427 assert_eq!(provider.default_image_gen_model(), "openai/gpt-image-2");
3428 assert_eq!(provider.default_video_gen_model(), "google/veo-3.1");
3429 }
3430
3431 #[test]
3432 fn image_size_maps_to_normalized_generation_capabilities() {
3433 assert_eq!(OpenRouter::image_aspect_ratio("1024x1024"), "1:1");
3434 assert_eq!(OpenRouter::image_aspect_ratio("1024x1792"), "9:16");
3435 assert_eq!(OpenRouter::image_aspect_ratio("1792x1024"), "16:9");
3436 assert_eq!(OpenRouter::image_resolution("1792x1024"), "1K");
3437 assert_eq!(OpenRouter::image_resolution("2048x2048"), "2K");
3438 }
3439
3440 #[test]
3441 fn normalizes_model_specific_video_defaults() {
3442 let (_, resolution, aspect_ratio) =
3443 normalize_video_params("minimax/hailuo-3", 8, "720p", "16:9");
3444 assert_eq!(resolution, "2K");
3445 assert_eq!(aspect_ratio, "16:9");
3446
3447 let (duration, resolution, _) =
3448 normalize_video_params("openai/sora-2-pro", 6, "720p", "16:9");
3449 assert_eq!(duration, 4);
3450 assert_eq!(resolution, "720p");
3451 }
3452}