Skip to main content

nexus_core/provider/
openrouter.rs

1// Casts here are on bounded values: token counts, byte sizes, and
2// selection indices — never on unbounded input. JSON-derived indices in
3// provider/tools go through try_from instead.
4#![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";
29/// The general Zen catalog: free-tier and pay-per-token models, plus
30/// whatever a Go subscription adds. This is the default/fallback base.
31const OPENCODE_ZEN_BASE: &str = "https://opencode.ai/zen/v1";
32/// The flat-fee Go-subscription bundle — a *different* endpoint from Zen's
33/// general catalog, even though it's the same account key. Requests for a
34/// Go-bundled model must go here, not to Zen general, or they'd be billed
35/// per-token instead of covered by the flat $10/mo.
36const OPENCODE_GO_BASE: &str = "https://opencode.ai/zen/go/v1";
37/// Prefix on a Model id (from `list_models`) marking it as a flat-fee Go
38/// model rather than a general Zen one — stripped before it's ever sent to
39/// the API; only used to pick which of the two bases above to hit.
40const OPENCODE_GO_PREFIX: &str = "go:";
41
42/// The OpenAI-wire base URL used by a backend gateway route.
43pub 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
52/// Select the base URL and raw model id for a gateway request.
53pub 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
62/// Context windows for `OpenCode` Zen's general catalog (`/zen/v1/models`),
63/// keyed by raw model id. The Zen `/models` endpoint returns no context
64/// metadata (only `id/created/owned_by`), so without this table every `OpenCode`
65/// model would show no context size. Values mirror the models.dev catalog
66/// that opencode itself uses for these ids.
67const 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
131/// Context windows for the flat-fee Go bundle (`/zen/go/v1/models`), keyed by
132/// raw model id. Kept separate from `OPENCODE_ZEN_CONTEXT` because the *same
133/// id* can have a different window on each endpoint (e.g. qwen3.6-plus:
134/// 262k on Zen general vs 1M on Go; minimax-m3: 512k vs 1M).
135const 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];
162/// Hard cap on tool round-trips per response, so a model that keeps calling
163/// tools can't loop forever. The default for interactive chat; background
164/// jobs (e.g. deep-research searcher agents) pass their own smaller budget.
165pub const MAX_TOOL_ITERS: usize = 100;
166
167#[derive(Clone, Copy, Debug, Eq, PartialEq)]
168enum ProviderFlavor {
169    OpenRouter,
170    OpenAi,
171    OpenAiCodex,
172    /// `OpenCode` Go: a low-cost subscription bundling several open coding
173    /// models behind a fully OpenAI-compatible endpoint — no special
174    /// request/response handling needed, unlike Codex.
175    OpencodeGo,
176}
177
178#[derive(Clone)]
179pub struct OpenRouter {
180    client: reqwest::Client,
181    key: String,
182    flavor: ProviderFlavor,
183}
184
185/// Everything needed to generate a video: model, prompt, params, optional
186/// frame/reference images, and provider-specific options keyed by provider
187/// slug (e.g. `{"alibaba": {"parameters": {"video": "data:..."}}}`),
188/// passed through as `provider.options` in the request body.
189pub 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    /// The reasoning effort values this model accepts, in cycle order.
214    /// Empty = the model has no reasoning/thinking mode.
215    ///
216    /// A catalog-provided `reasoning.supported_efforts` list is authoritative
217    /// and wins for every backend that exposes it. Family rules are only
218    /// fallbacks for catalogs that report the `reasoning` parameter without
219    /// enumerating its accepted values.
220    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            // Codex and the Go bundle are reasoning-capable chat catalogs.
273            // Their catalog metadata wins above when present; this standard
274            // set is only for entries that expose no per-model detail.
275            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            // OpenRouter reports output_modalities in the catalog — use that.
299            Self::OpenRouter => None,
300            // Only dall-e models on OpenAI.
301            Self::OpenAi => Some(m.id.contains("dall-e")),
302            // Codex and Go don't support image generation.
303            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            // Ask OpenRouter for exact token accounting in the final chunk.
310            Self::OpenRouter => {
311                obj.insert("usage".into(), serde_json::json!({ "include": true }));
312            }
313            // OpenAI (and OpenCode Go, which mirrors OpenAI's API shape)
314            // put the equivalent switch under stream_options.
315            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    /// Add the `OpenCode` cache-lane key without sending this optional field to
326    /// providers that do not advertise the same OpenAI-compatible behavior.
327    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/// `OpenRouter` catalog pricing, in USD **per token** (stringly-typed in the
412/// API: gpt-5 reports `1.25e-06`, i.e. $1.25/M). Only the `OpenRouter` flavor
413/// reports it; others default to absent.
414#[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    /// Prices in USD per 1M tokens — the unit every cost calculation in this
428    /// codebase uses (see `Db::request_cost`) — or `None` when either base
429    /// token rate is absent or unparsable. Cache rates are optional: when a
430    /// catalog omits one, the regular prompt rate applies. The API reports
431    /// per-token values, so scale to per-1M once here.
432    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
457/// Whether the catalog entry accepts image input.
458fn 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
464/// Whether the catalog entry generates image output.
465fn entry_supports_image_gen(e: &ModelEntry) -> bool {
466    // The API's output_modalities field is authoritative when populated.
467    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    // Fallback: match known image-generation model name patterns.
474    // These are specific enough to avoid catching chat models from the same
475    // provider, unlike broad provider-prefix matching.
476    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    /// Video gen is OpenRouter-only (not OpenAI/Codex/OpenCode).
570    pub fn is_openrouter(&self) -> bool {
571        self.flavor == ProviderFlavor::OpenRouter
572    }
573
574    /// Generate a video via `OpenRouter`'s `/api/v1/videos` endpoint.
575    /// Submits the job, polls every 10s (up to 6 min), downloads on completion.
576    /// Returns `(mp4_bytes, cost_in_usd)`.
577    // Long by design (request assembly + polling).
578    #[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            // Codex and Go's bundled models are all chat/coding models, no embeddings.
762            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    /// Fetch the live model catalog. No hardcoded list except Codex, whose
782    /// subscription endpoint does not expose the normal `OpenAI` models catalog.
783    // Long by design (catalog merge).
784    #[allow(clippy::too_many_lines)]
785    pub async fn list_models(&self) -> Result<Vec<Model>> {
786        if self.flavor == ProviderFlavor::OpenAiCodex {
787            // Codex-only models — deliberately not merged with OpenRouter's
788            // catalog (switch backends with Ctrl+P to see that instead): a few hundred OpenRouter entries used to bury these
789            // 7 alphabetically, making it look like Codex had no models.
790            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            // Two distinct catalogs behind the same account key: Zen
872            // general (free + pay-per-token) and the flat-fee Go bundle.
873            // Fetch both and show them together — Go entries tagged so
874            // requests route (and bill) correctly; see `opencode_route`.
875            let (zen, go) = tokio::join!(
876                self.fetch_models_from(OPENCODE_ZEN_BASE),
877                self.fetch_models_from(OPENCODE_GO_BASE),
878            );
879            // Zen general is the primary catalog — a failure there is a
880            // real problem (bad key, network) and should surface. Go's
881            // bundle can legitimately 403 for an account without that
882            // subscription; treat it as "no Go models" rather than an error.
883            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                // A model covered by the flat-fee Go bundle is strictly
890                // better than its metered Zen-general twin — don't show both.
891                .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            // The general catalog is not authoritative for generation models.
903            // Merge OpenRouter's dedicated catalogs into the existing picker.
904            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    /// GET `{base}/models` and map the response into our `Model` type using
984    /// this instance's flavor for reasoning/image-support inference.
985    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        // The OpenCode Zen endpoints return no context metadata at all; fill
1028        // in the known windows per endpoint (the same id can differ between
1029        // the general catalog and the Go bundle — see the table comments).
1030        // An API-provided value always wins over the fallback table.
1031        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    /// Fallback context window for an `OpenCode` model id, keyed by endpoint
1041    /// (Zen general vs Go bundle). `None` for non-OpenCode bases and for ids
1042    /// not in the table.
1043    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    /// One-shot, non-streaming completion. Used for short utility calls like
1053    /// generating a session topic/slug. Returns the assistant's message text.
1054    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    /// One-shot completion with the provider's usage object when available.
1061    /// Keeping usage attached to the text lets background workflow stages use
1062    /// the same cache accounting as the interactive stream.
1063    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    /// One-shot completion with request parameters and usage accounting.
1073    pub async fn complete_with_params(
1074        &self,
1075        model: &str,
1076        messages: Vec<ChatMessage>,
1077        params: &ChatParams,
1078    ) -> Result<Completion> {
1079        // ChatGPT's Codex endpoint is stream-oriented: non-streaming requests
1080        // are rejected or can return no usable output. Utility jobs (titles,
1081        // memory, compaction, research metadata) still need a one-shot string,
1082        // so collect the same proven SSE path used by interactive chat.
1083        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) = &params.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    /// POST a completions body and pull the first choice's message text.
1121    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    /// POST a non-streaming completions body while retaining usage accounting.
1128    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    /// One-shot, non-streaming vision call: describe `image_data_url` with `model`.
1188    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    /// Generate an image via `OpenRouter`'s dedicated `/api/v1/images` endpoint.
1194    /// Returns `(decoded_bytes, file_extension)` — the caller should use the
1195    /// returned extension (e.g. `"png"`, `"jpg"`, `"webp"`) for the saved file.
1196    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            // DALL-E uses OpenAI's legacy pixel-size parameter.
1218            body["size"] = serde_json::json!(size);
1219        } else {
1220            // OpenRouter's image catalog uses normalized capabilities. The
1221            // old 1024x1024 size is not accepted by several newer models.
1222            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    /// Detect image MIME type from magic bytes.
1278    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    /// One-shot, non-streaming vision call: transcribe a scanned page image.
1319    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    /// Embed `inputs` with `model` (OpenAI-format /embeddings endpoint);
1324    /// returns one vector per input, in order.
1325    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    /// Start a streaming completion. Spawns a task that pushes tokens over the
1364    /// returned channel; the UI loop drains it alongside keypresses. If the
1365    /// model calls a tool, the task runs it via `toolbox` and continues the
1366    /// conversation, bounded by `max_tool_iters` round-trips.
1367    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    /// One chat round-trip plus tool iterations. Args mix request data
1393    /// (model/messages/params/tools) with execution plumbing (toolbox,
1394    /// budget, event sink) — a struct would group unrelated concerns.
1395    #[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        // Dedup state for tool results: (tool, arguments) → latest full
1407        // result. Seeded from the incoming history (which `build_history`
1408        // already deduped with the same rule), so a result that duplicates
1409        // anything the model has seen — this turn or earlier turns — is
1410        // compressed before it ever enters the context, and the wire
1411        // history stays byte-identical to what the next turn's replay will
1412        // rebuild (prompt-cache continuity).
1413        let mut seen_results = super::seed_tool_result_dedup(&messages);
1414        for iter in 0..=max_tool_iters {
1415            // Keep the tool schema stable while the loop grows. On the final
1416            // request, disable selection without removing the schemas or
1417            // appending a transient budget prompt; the next turn can still
1418            // reconstruct the same message prefix.
1419            match self
1420                .run_stream(
1421                    &model,
1422                    &messages,
1423                    &params,
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                    // Parallel calls the model issued in one response run
1445                    // concurrently when they're all read-only (searches,
1446                    // fetches, file/app reads) — network latency overlaps
1447                    // instead of stacking. Any mutating call keeps the batch
1448                    // sequential so writes to the same file can't race.
1449                    // Results are zipped back into call order either way.
1450                    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                        // A result byte-identical to an earlier call with
1480                        // the same tool and arguments (e.g. re-reading an
1481                        // unchanged file) is replaced by a one-line note so
1482                        // the duplicate never re-enters the context. The
1483                        // transcript above still carries the full result and
1484                        // the db stores it; `build_history` applies the same
1485                        // rule on replay, keeping the cache prefix intact.
1486                        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 the model supports images, load image references
1508                        // from the tool result and inject them as a user
1509                        // message so the vision model can see them on the
1510                        // next stream request.
1511                        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    /// One request/response over SSE. Doesn't send `Done` itself — the caller
1533    /// decides whether another tool round-trip follows.
1534    // Long by design (stream processing).
1535    #[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) = &params.prompt_cache_key {
1570            self.flavor.add_prompt_cache_key(obj, key);
1571        }
1572        if let Some(effort) = &params.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; // main phase over; the cost drain runs below
1618                    }
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                        // OpenCode Zen: models on the general route never
1637                        // report usage (every chunk carries `usage:null`);
1638                        // the trailing cost chunk is the only accounting.
1639                        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                // These two variants carry the actual HTTP response, but
1654                // their `Display` is close to useless ("Invalid header
1655                // value: \"\"" when Content-Type is simply absent — no
1656                // status, no body shown). Read the response through instead
1657                // so the real reason (an auth/entitlement error page, a
1658                // rate limit, etc.) reaches the user rather than a
1659                // header-parsing artifact.
1660                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        // The stream isn't finished at [DONE]: OpenCode sends the trailing
1679        // cost chunk ({"choices":[],"cost":"…"}) AFTER it, and for
1680        // no-usage (zen free) models that chunk is the entire accounting.
1681        // Drain it with a cap so a peer that keeps the connection open
1682        // after [DONE] can't hang the turn.
1683        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                    // StreamEnded, transport error, a second [DONE], or the
1703                    // 3-second cap — either way the accounting is complete.
1704                    _ => break,
1705                }
1706            }
1707        }
1708        // Surface exactly one accounting event for this provider request.
1709        // Keeping it at the request boundary prevents the app from treating
1710        // several partial/cost chunks as separate requests.
1711        if let Some(usage) = usage {
1712            let _ = tx.send(StreamEvent::Usage(usage));
1713        }
1714        // Trust accumulated tool calls, not finish_reason: some providers
1715        // stream tool_calls but finish with "stop" (or no finish chunk at
1716        // all); dropping the calls there kills the turn silently.
1717        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    /// For `OpenCode` Go: pick the base to hit and the raw model id to send,
1729    /// stripping the `go:` tag `list_models` adds to flat-fee Go models. A
1730    /// no-op (returns the flavor's default base, id unchanged) for every
1731    /// other flavor and for untagged (general Zen) `OpenCode` ids.
1732    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) = &params.prompt_cache_key {
1794            // The Codex Responses route uses the public Responses API cache
1795            // key field when it is available. Without it, GPT-5.6+ can route
1796            // identical prefixes to different cache workers and report no
1797            // reuse even though the full input is stable.
1798            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) = &params.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        // Not reqwest_eventsource here: it gates the whole response on a
1813        // strict Content-Type == "text/event-stream" check, and ChatGPT's
1814        // backend has been observed sending a genuine SSE body (starting
1815        // with a real "event: response.created" line) under a Content-Type
1816        // that check doesn't accept — the crate then reports the situation
1817        // as an opaque "invalid header value" and discards the (valid) body
1818        // entirely. Reading the response as a raw byte stream and splitting
1819        // on blank lines ourselves doesn't care what Content-Type says.
1820        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            // SSE events are separated by a blank line; a "data:" line's
1845            // content (possibly spread across several, joined by \n per the
1846            // spec — Codex never does this in practice, but handle it) is
1847            // the payload we care about.
1848            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
1895/// Cap an error response body shown to the user — a WAF/error page can be
1896/// arbitrarily large HTML, and the status line has no room for it anyway.
1897fn 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
1910/// Pull `(content, reasoning)` deltas out of one SSE data chunk. ``OpenRouter`` puts
1911/// thinking tokens in `delta.reasoning`, separate from the visible `delta.content`.
1912fn 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
1930/// Merge one SSE chunk's `delta.tool_calls` fragments into the running
1931/// per-call accumulator, keyed by the call's `index` (ids/names arrive once,
1932/// `arguments` streams in pieces that must be concatenated in order).
1933fn 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
1972/// Pull the `usage` object from an SSE chunk, if present. Returns `None`
1973/// when the chunk carries no real accounting: absent, `null`, or all-zero
1974/// usage objects (several providers echo `"usage":null` on every chunk and
1975/// report real numbers only in the final one — emitting a zero event would
1976/// log garbage rows and clobber the last cache hit rate).
1977fn 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
1982/// Parse a provider usage object shared by streaming and non-streaming calls.
1983fn 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    // OpenAI/OpenRouter report cache accounting in prompt_tokens_details
1993    // (Responses-style payloads call it input_tokens_details). Anthropic and
1994    // DeepSeek-compatible backends may expose flat fields instead. Take the
1995    // largest reported value so duplicated compatibility fields count once.
1996    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
2028/// Merge a usage-only event with earlier accounting from the same request.
2029/// `OpenCode` may send cost-only or partial/cumulative objects after token
2030/// usage; counts must never move backwards within one API request.
2031fn 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            // Streaming providers may expose cumulative accounting more than
2039            // once, and a trailing compatibility object can be partial. Keep
2040            // the largest count seen for this one API request instead of
2041            // allowing a later smaller object to make prompt history appear
2042            // to shrink.
2043            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
2056/// Parse an unsigned usage count from either JSON's numeric representation or
2057/// a provider's stringly-typed equivalent.
2058fn json_u64(value: &serde_json::Value) -> Option<u64> {
2059    value
2060        .as_u64()
2061        .or_else(|| value.as_str()?.parse::<u64>().ok())
2062}
2063
2064/// Parse either JSON's numeric representation or a provider's stringly-typed
2065/// equivalent, rejecting non-finite values before they reach `SQLite`.
2066fn 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
2073/// The trailing chunk of an `OpenCode` Zen/Go stream carries the request's
2074/// billed cost as a string (`"cost":"0.0012"`) with no usage object — the
2075/// only accounting those endpoints send for models that report no usage.
2076/// Other providers don't emit it, so this returns `None` for them.
2077fn 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            // A model may request several tools in parallel. Every subsequent
2102            // function_call_output must have a matching function_call item;
2103            // emitting only calls[0] makes Codex reject the other outputs.
2104            "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                        // Chat Completions permits visible assistant text next
2109                        // to tool calls. Keep it when translating the same
2110                        // history to Responses input; dropping it would make
2111                        // a later Codex request differ from the conversation
2112                        // the user saw.
2113                        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
2268/// Extract an SSE event block's `data:` payload — possibly spread across
2269/// several `data:` lines, joined by `\n` per the SSE spec (Codex never
2270/// actually does this, but it costs nothing to handle). Ignores any
2271/// `event:`/`id:`/comment lines in the block; we key off the JSON payload's
2272/// own `"type"` field instead of the SSE `event:` name.
2273fn 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
2423/// Request body for a one-shot image-understanding call: a text part with the
2424/// instruction plus the image as a data-URL content part (`OpenAI` vision shape).
2425/// Shared page-transcription instructions (``OpenRouter`` VLMs and local Ollama).
2426pub 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        // Page transcriptions run long; don't let a provider default clip them.
2439        "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
2469/// Validate basic video generation parameters against known model capabilities.
2470/// Returns `Ok` if the params look valid, or a descriptive error message if not.
2471/// This is a best-effort check — the API is the final authority.
2472pub 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// Long by design (model param table).
2545#[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    // Model-specific duration limits. Keys are model slugs (or prefixes).
2553    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 // unknown model, skip validation
2638        }
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
2661/// Scan a tool result string for `![...](file)` image references, load the
2662/// files from `files_dir`, resize large images to avoid hitting API size
2663/// limits, and return data URLs for the synthetic user message.
2664/// Returns `None` when no references found.
2665pub(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
2708/// Return a JPEG-compressed version of the image if it exceeds 1 MB, so the
2709/// API request doesn't get rejected for being too large. Keeps the original
2710/// if it's already small enough.
2711fn resize_for_api(bytes: &[u8], _files_dir: &std::path::Path) -> Vec<u8> {
2712    // ponytail: 1 MB threshold — large enough for readable text, small
2713    // enough to avoid 400s from provider byte limits.
2714    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    // One row per live catalog id — the table is the point.
2780    #[allow(clippy::too_many_lines)]
2781    fn opencode_context_fallback_covers_every_live_catalog_id() {
2782        // The live endpoints serve exactly these ids today; each must have a
2783        // known context window, or that model silently shows no context size
2784        // again. When opencode adds a model, add it to the table first.
2785        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        // Same id, different window on each endpoint — the tables must stay
2892        // separate or Go models get the wrong (smaller) limit.
2893        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        // Fallback is OpenCode-only: no table for other bases, unknown ids
2910        // yield nothing, and a go: tag is never part of the lookup.
2911        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        // Catalog metadata is authoritative, normalized into UI cycle order,
2947        // and can expose sparse sets plus explicit disable/max tiers.
2948        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        // Without an enumerated set, OpenRouter still uses the catalog's
2964        // supported-parameter gate and the conservative family fallback.
2965        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        // Direct OpenAI has no rich /models metadata, so known families use
2979        // concrete fallbacks rather than assigning every GPT-5 the same set.
2980        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        // Metadata also wins on OpenCode; absent metadata retains its broad
3002        // compatibility fallback because that catalog is not authoritative.
3003        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        // No embedding models on Go — feature stays disabled by default.
3022        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        // A "go:"-prefixed id is meaningless outside OpenCode Go — must not
3044        // be stripped or rerouted for another flavor.
3045        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); // 300 chars + the ellipsis marker
3086    }
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        // Generous output budget — page transcriptions are long.
3094        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        // OpenRouter/OpenAI style: cache reads and writes nested under
3220        // prompt_tokens_details, alongside the provider's exact request cost.
3221        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        // Anthropic-style: flat cache fields (OpenRouter passthrough).
3228        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        // OpenCode Zen reports cached input as flat prompt_cache_hit_tokens
3238        // (DeepSeek-style) plus the OpenAI-style nested field — captured from
3239        // the real zen/go/v1 finish chunk.
3240        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        // OpenCode Zen echoes `"usage":null` on every chunk; the app must
3249        // not emit (and log) a zero-usage event per chunk.
3250        let null_usage =
3251            r#"{"id":"x","choices":[{"index":0,"delta":{"content":null}}],"usage":null}"#;
3252        assert!(parse_usage(null_usage).is_none());
3253        // All-zero usage objects are equally uninformative.
3254        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        // The trailing OpenCode Zen chunk: `{"choices":[],"cost":"0"}`.
3262        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        // No cost field -> None (OpenRouter/OpenAI/Codex trailing chunks).
3268        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        // The API reports USD per token (gpt-5 → 1.25e-06 = $1.25/M); the
3387        // rest of the codebase prices in USD per 1M tokens, so parsing must
3388        // scale. Cache rates are preserved and free-tier entries stay zero.
3389        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}