Skip to main content

agent_sdk_providers/model_catalog/
openrouter.rs

1use anyhow::{Context, Result};
2use async_trait::async_trait;
3
4use super::{
5    CatalogEntry, ModelCatalogSource, OPENROUTER_URL, PricingTier, build_feed_client,
6    merge_band_over_base,
7};
8use crate::model_capabilities::{PricePoint, Pricing};
9
10#[derive(serde::Deserialize)]
11struct OpenRouterPricing {
12    #[serde(default)]
13    prompt: Option<String>,
14    #[serde(default)]
15    completion: Option<String>,
16    #[serde(default)]
17    input_cache_read: Option<String>,
18    /// The default (5-minute) cache-write rate. The feed also publishes
19    /// `input_cache_write_1h` for Anthropic's extended TTL, which the SDK does
20    /// not request.
21    #[serde(default)]
22    input_cache_write: Option<String>,
23    /// Reasoning-token rate, when the route prices reasoning apart from
24    /// completion. Billed as the output band's rate via `max(output, reasoning)`.
25    #[serde(default)]
26    internal_reasoning: Option<String>,
27    /// Long-context price bands: from `min_prompt_tokens` upwards the route
28    /// bills at these rates instead. Gemini 2.5 Pro doubles its input rate
29    /// above 200K tokens this way, GPT-5.x above 272K.
30    #[serde(default)]
31    overrides: Vec<OpenRouterPricingOverride>,
32}
33
34/// Every field is optional: a drifted override must cost its own row's pricing,
35/// never the parse of the whole feed body. See [`tiers_from_openrouter_pricing`].
36#[derive(serde::Deserialize)]
37struct OpenRouterPricingOverride {
38    #[serde(default)]
39    min_prompt_tokens: Option<u32>,
40    #[serde(default)]
41    prompt: Option<String>,
42    #[serde(default)]
43    completion: Option<String>,
44    #[serde(default)]
45    input_cache_read: Option<String>,
46    #[serde(default)]
47    input_cache_write: Option<String>,
48    /// No live override restates a reasoning rate, but an override is
49    /// structurally a pricing subset, so it is read symmetrically with the base
50    /// row rather than silently inheriting the base reasoning rate.
51    #[serde(default)]
52    internal_reasoning: Option<String>,
53}
54
55#[derive(serde::Deserialize)]
56struct OpenRouterTopProvider {
57    #[serde(default)]
58    max_completion_tokens: Option<u32>,
59}
60
61#[derive(serde::Deserialize)]
62struct OpenRouterModel {
63    id: String,
64    #[serde(default)]
65    context_length: Option<u32>,
66    #[serde(default)]
67    pricing: Option<OpenRouterPricing>,
68    #[serde(default)]
69    top_provider: Option<OpenRouterTopProvider>,
70}
71
72#[derive(serde::Deserialize)]
73struct OpenRouterResponse {
74    #[serde(default)]
75    data: Vec<OpenRouterModel>,
76}
77
78/// The provider name every `OpenRouter` row is filed under: the route is what
79/// the caller pays, so the route is what the row is keyed by.
80const OPENROUTER_PROVIDER: &str = "openrouter";
81
82fn openrouter_price_per_million(value: &str) -> Option<PricePoint> {
83    let per_token: f64 = value.trim().parse().ok()?;
84    if !per_token.is_finite() || per_token <= 0.0 {
85        return None;
86    }
87    Some(PricePoint::new(per_token * 1_000_000.0))
88}
89
90/// Parse the `OpenRouter` `/models` body into catalog entries.
91///
92/// Every row is filed under the route that serves it — provider `openrouter`,
93/// model id the full slug (`anthropic/claude-opus-4.8`) — never under the
94/// vendor half of the slug. The prices in this feed are `OpenRouter`'s own,
95/// and they are not the vendor's: splitting `openai/gpt-4o` into
96/// `("openai", "gpt-4o")` would file a router's rate under the exact key a
97/// *direct* `OpenAI` call resolves to, so a direct call would be priced at the
98/// router's rate. A caller that routes through `OpenRouter` reaches these rows
99/// by asking for the route key; a caller that does not, never sees them.
100///
101/// This matches how models.dev files the same rows (under its `openrouter`
102/// service key, slug intact).
103///
104/// # Errors
105///
106/// Returns an error if the body is not valid `OpenRouter` JSON.
107pub fn parse_openrouter(json: &str) -> Result<Vec<CatalogEntry>> {
108    let parsed: OpenRouterResponse =
109        serde_json::from_str(json).context("failed to parse OpenRouter models response")?;
110    Ok(parsed
111        .data
112        .into_iter()
113        .map(|model| {
114            let base = model.pricing.as_ref().and_then(base_pricing);
115            // An un-interpretable override drops the row's pricing entirely:
116            // its base rates stop applying somewhere the parser cannot locate,
117            // so keeping them would under-price every long-context call.
118            let tiers = model.pricing.as_ref().map_or_else(
119                || Some(Vec::new()),
120                |p| tiers_from_openrouter_pricing(p, base),
121            );
122            let (pricing, pricing_tiers) =
123                tiers.map_or_else(|| (None, Vec::new()), |tiers| (base, tiers));
124            let max_output_tokens = model.top_provider.and_then(|tp| tp.max_completion_tokens);
125            CatalogEntry {
126                provider: OPENROUTER_PROVIDER.to_owned(),
127                model_id: model.id,
128                context_window: model.context_length,
129                max_output_tokens,
130                pricing,
131                pricing_tiers,
132                supports_thinking: None,
133            }
134        })
135        .collect())
136}
137
138/// The route's rates for a call inside the base band.
139fn base_pricing(pricing: &OpenRouterPricing) -> Option<Pricing> {
140    pricing_from_rates(
141        pricing.prompt.as_deref(),
142        pricing.completion.as_deref(),
143        pricing.input_cache_read.as_deref(),
144        pricing.input_cache_write.as_deref(),
145        pricing.internal_reasoning.as_deref(),
146    )
147}
148
149fn pricing_from_rates(
150    prompt: Option<&str>,
151    completion: Option<&str>,
152    input_cache_read: Option<&str>,
153    input_cache_write: Option<&str>,
154    internal_reasoning: Option<&str>,
155) -> Option<Pricing> {
156    let input = prompt.and_then(openrouter_price_per_million);
157    let output = completion.and_then(openrouter_price_per_million);
158    let cached_input = input_cache_read.and_then(openrouter_price_per_million);
159    let cache_write = input_cache_write.and_then(openrouter_price_per_million);
160    let reasoning = internal_reasoning.and_then(openrouter_price_per_million);
161
162    if input.is_none()
163        && output.is_none()
164        && cached_input.is_none()
165        && cache_write.is_none()
166        && reasoning.is_none()
167    {
168        return None;
169    }
170    Some(Pricing {
171        input,
172        output,
173        cached_input,
174        cache_write,
175        reasoning,
176        notes: None,
177    })
178}
179
180/// The route's long-context bands, ascending.
181///
182/// `None` — "do not price this route from this feed at all" — when an override
183/// cannot be interpreted: no `min_prompt_tokens` to locate the band, or no
184/// input/output rate to bill it with. An override exists precisely because the
185/// base rates stop applying above its threshold, so one that cannot be read
186/// cannot be ignored: keeping the base rates would price every long-context
187/// call at a fraction of its true cost.
188///
189/// An override that restates only *some* rates (six routes raise input, output
190/// and cache-read above the threshold while leaving cache-write unstated) keeps
191/// its base value for the rest — see [`merge_band_over_base`].
192///
193/// # Threshold and precedence
194///
195/// The bound is inclusive: `OpenRouter`'s provider docs state a tier "applies
196/// when input tokens meet or exceed the `min_context` value", so
197/// `min_prompt_tokens` maps directly to the inclusive
198/// [`PricingTier::min_input_tokens`] with no offset.
199///
200/// The docs define pricing only for the base band plus a single override tier;
201/// how *multiple* overrides compose is undocumented. `OpenRouter` applies the
202/// override list in source order, so this reads "later entry wins per price
203/// key": for a call, every override whose threshold the call meets is folded
204/// over the base in list order, a later entry's stated rates overriding an
205/// earlier one's. That fold is precomputed per distinct threshold here, so the
206/// highest-threshold [`PricingTier`] a call reaches already carries the
207/// resolved rates — matching what `applicable_pricing` selects. Ascending
208/// lists (every live route) are unaffected; only a non-monotonic list (which
209/// only a trimmed mirror could produce) changes, and there the source-order
210/// reading is the conservative one.
211fn tiers_from_openrouter_pricing(
212    pricing: &OpenRouterPricing,
213    base: Option<Pricing>,
214) -> Option<Vec<PricingTier>> {
215    let mut bands: Vec<(u32, Pricing)> = Vec::with_capacity(pricing.overrides.len());
216    for band in &pricing.overrides {
217        // An override that restates a reasoning rate uses its own; otherwise
218        // the base row's carries over through `merge_band_over_base`.
219        let rates = pricing_from_rates(
220            band.prompt.as_deref(),
221            band.completion.as_deref(),
222            band.input_cache_read.as_deref(),
223            band.input_cache_write.as_deref(),
224            band.internal_reasoning.as_deref(),
225        )?;
226        // A band that bills only a cache component, with no input or output
227        // rate of its own, is not a price band this parser can stand behind.
228        if rates.input.is_none() || rates.output.is_none() {
229            return None;
230        }
231        // `min_prompt_tokens` is the smallest prompt the band applies to, so
232        // the bound is already inclusive.
233        bands.push((band.min_prompt_tokens?, rates));
234    }
235
236    let mut thresholds: Vec<u32> = bands.iter().map(|(threshold, _)| *threshold).collect();
237    thresholds.sort_unstable();
238    thresholds.dedup();
239
240    Some(
241        thresholds
242            .into_iter()
243            .filter_map(|threshold| {
244                // Fold every override the call would satisfy at this threshold,
245                // in source order, each overriding the last per price key.
246                let mut pricing = base;
247                for (min, rates) in &bands {
248                    if *min <= threshold {
249                        pricing = Some(merge_band_over_base(pricing, *rates));
250                    }
251                }
252                pricing.map(|pricing| PricingTier {
253                    min_input_tokens: threshold,
254                    pricing,
255                })
256            })
257            .collect(),
258    )
259}
260
261/// An alternative public feed: <https://openrouter.ai/api/v1/models> (no key).
262pub struct OpenRouterSource {
263    client: reqwest::Client,
264    url: String,
265}
266
267impl Default for OpenRouterSource {
268    fn default() -> Self {
269        let client = match build_feed_client() {
270            Ok(c) => c,
271            Err(e) => {
272                log::warn!("model-catalog feed client build failed, using default client: {e}");
273                reqwest::Client::new()
274            }
275        };
276        Self {
277            client,
278            url: OPENROUTER_URL.to_owned(),
279        }
280    }
281}
282
283impl OpenRouterSource {
284    /// Create a source pointing at the canonical `OpenRouter` models endpoint.
285    ///
286    /// # Errors
287    ///
288    /// Returns an error if the feed HTTP client cannot be constructed.
289    pub fn new() -> Result<Self> {
290        Ok(Self {
291            client: build_feed_client()?,
292            url: OPENROUTER_URL.to_owned(),
293        })
294    }
295
296    /// Override the feed URL (e.g. for a mirror or a local test server).
297    #[must_use]
298    pub fn with_url(mut self, url: impl Into<String>) -> Self {
299        self.url = url.into();
300        self
301    }
302}
303
304#[async_trait]
305impl ModelCatalogSource for OpenRouterSource {
306    async fn fetch(&self) -> Result<Vec<CatalogEntry>> {
307        let body = self
308            .client
309            .get(&self.url)
310            .send()
311            .await
312            .context("OpenRouter request failed")?
313            .error_for_status()
314            .context("OpenRouter returned an error status")?
315            .text()
316            .await
317            .context("failed to read OpenRouter body")?;
318        parse_openrouter(&body)
319    }
320}
321
322#[cfg(test)]
323mod tests {
324    use super::super::ModelRegistry;
325    use super::*;
326    use agent_sdk_foundation::llm::Usage;
327
328    struct StaticSource(Vec<CatalogEntry>);
329
330    #[async_trait]
331    impl ModelCatalogSource for StaticSource {
332        async fn fetch(&self) -> Result<Vec<CatalogEntry>> {
333            Ok(self.0.clone())
334        }
335    }
336
337    const OPENROUTER_FIXTURE: &str = r#"{
338      "data": [
339        {
340          "id": "anthropic/claude-opus-4.8",
341          "name": "Anthropic: Claude Opus 4.8",
342          "context_length": 1000000,
343          "pricing": {
344            "prompt": "0.000005",
345            "completion": "0.000025",
346            "input_cache_read": "0.0000005"
347          },
348          "top_provider": { "max_completion_tokens": 128000 }
349        },
350        {
351          "id": "google/gemini-2.5-pro",
352          "name": "Google: Gemini 2.5 Pro",
353          "context_length": 1048576,
354          "pricing": { "prompt": "0.00000125", "completion": "0.00001" },
355          "top_provider": { "max_completion_tokens": 65536 }
356        }
357      ]
358    }"#;
359
360    const OPENROUTER_SENTINEL_FIXTURE: &str = r#"{
361      "data": [
362        {
363          "id": "openrouter/auto",
364          "name": "Auto Router",
365          "context_length": 2000000,
366          "pricing": {
367            "prompt": "-1",
368            "completion": "-1",
369            "input_cache_read": "-1"
370          }
371        }
372      ]
373    }"#;
374
375    fn find<'a>(
376        entries: &'a [CatalogEntry],
377        provider: &str,
378        model: &str,
379    ) -> Result<&'a CatalogEntry> {
380        entries
381            .iter()
382            .find(|e| e.provider == provider && e.model_id == model)
383            .with_context(|| format!("missing {provider}/{model}"))
384    }
385
386    #[test]
387    fn parse_openrouter_converts_per_token_to_per_million_and_keys_rows_by_route() -> Result<()> {
388        let entries = parse_openrouter(OPENROUTER_FIXTURE)?;
389        assert_eq!(entries.len(), 2);
390
391        // Rows are filed under the route, slug intact — never under the vendor
392        // half, which is where a DIRECT call to that vendor would look and
393        // would then find this router's price.
394        assert!(entries.iter().all(|e| e.provider == "openrouter"));
395        assert!(
396            !entries
397                .iter()
398                .any(|e| e.provider == "anthropic" || e.provider == "gemini"),
399        );
400
401        let opus = find(&entries, "openrouter", "anthropic/claude-opus-4.8")?;
402        assert_eq!(opus.context_window, Some(1_000_000));
403        assert_eq!(opus.max_output_tokens, Some(128_000));
404        let pricing = opus.pricing.context("opus pricing missing")?;
405        // 0.000005 USD/token * 1e6 = 5.0 USD/M.
406        assert!(
407            (pricing.input.context("input")?.usd_per_million_tokens - 5.0).abs() < f64::EPSILON
408        );
409        assert!(
410            (pricing.output.context("output")?.usd_per_million_tokens - 25.0).abs() < f64::EPSILON
411        );
412        assert!(
413            (pricing
414                .cached_input
415                .context("cache")?
416                .usd_per_million_tokens
417                - 0.5)
418                .abs()
419                < f64::EPSILON
420        );
421
422        let gemini = find(&entries, "openrouter", "google/gemini-2.5-pro")?;
423        assert_eq!(gemini.context_window, Some(1_048_576));
424        Ok(())
425    }
426
427    #[tokio::test]
428    async fn parse_openrouter_treats_minus_one_sentinel_prices_as_absent() -> Result<()> {
429        let entries = parse_openrouter(OPENROUTER_SENTINEL_FIXTURE)?;
430        assert_eq!(entries.len(), 1);
431
432        let auto = find(&entries, "openrouter", "openrouter/auto")?;
433        assert!(
434            auto.pricing.is_none(),
435            "sentinel `-1` prices must yield None pricing, got {:?}",
436            auto.pricing
437        );
438        assert_eq!(auto.context_window, Some(2_000_000));
439
440        let registry = ModelRegistry::new();
441        registry.refresh(&StaticSource(entries)).await?;
442        let usage = Usage {
443            served_speed: None,
444            input_tokens: 1_000,
445            output_tokens: 1_000,
446            cached_input_tokens: 0,
447            cache_creation_input_tokens: 0,
448        };
449        assert_eq!(
450            registry.estimate_cost_usd("openrouter", "openrouter/auto", &usage),
451            None
452        );
453        Ok(())
454    }
455
456    /// Mirrors the live feed: `google/gemini-2.5-pro` raises prompt, completion
457    /// and cache-read above 200K prompt tokens but leaves cache-write unstated,
458    /// while `openai/gpt-5.6-luna` restates every rate above 272K.
459    const OPENROUTER_OVERRIDES_FIXTURE: &str = r#"{
460      "data": [
461        {
462          "id": "google/gemini-2.5-pro",
463          "context_length": 1048576,
464          "pricing": {
465            "prompt": "0.00000125",
466            "completion": "0.00001",
467            "input_cache_read": "0.000000125",
468            "input_cache_write": "0.000000375",
469            "overrides": [
470              {
471                "min_prompt_tokens": 200000,
472                "prompt": "0.0000025",
473                "completion": "0.000015",
474                "input_cache_read": "0.00000025"
475              }
476            ]
477          }
478        },
479        {
480          "id": "openai/gpt-5.6-luna",
481          "context_length": 400000,
482          "pricing": {
483            "prompt": "0.000001",
484            "completion": "0.000006",
485            "input_cache_read": "0.0000001",
486            "input_cache_write": "0.00000125",
487            "overrides": [
488              {
489                "min_prompt_tokens": 272000,
490                "prompt": "0.000002",
491                "completion": "0.000009",
492                "input_cache_read": "0.0000002",
493                "input_cache_write": "0.0000025"
494              }
495            ]
496          }
497        }
498      ]
499    }"#;
500
501    #[test]
502    fn parse_openrouter_reads_long_context_overrides() -> Result<()> {
503        let entries = parse_openrouter(OPENROUTER_OVERRIDES_FIXTURE)?;
504
505        let gemini = find(&entries, "openrouter", "google/gemini-2.5-pro")?;
506        assert_eq!(gemini.pricing_tiers.len(), 1);
507        let band = gemini.pricing_tiers[0];
508        // `min_prompt_tokens` is already an inclusive lower bound.
509        assert_eq!(band.min_input_tokens, 200_000);
510        assert!(
511            (band.pricing.input.context("input")?.usd_per_million_tokens - 2.5).abs()
512                < f64::EPSILON
513        );
514        assert!(
515            (band
516                .pricing
517                .output
518                .context("output")?
519                .usd_per_million_tokens
520                - 15.0)
521                .abs()
522                < f64::EPSILON
523        );
524        // The override says nothing about cache-write, so the band keeps the
525        // base rate for it rather than dropping it or re-billing it at input.
526        assert!(
527            (band
528                .pricing
529                .cache_write
530                .context("cache_write inherited from base")?
531                .usd_per_million_tokens
532                - 0.375)
533                .abs()
534                < f64::EPSILON
535        );
536        Ok(())
537    }
538
539    #[tokio::test]
540    async fn long_context_route_bills_at_the_override_rate() -> Result<()> {
541        let registry = ModelRegistry::new();
542        registry
543            .refresh(&StaticSource(parse_openrouter(
544                OPENROUTER_OVERRIDES_FIXTURE,
545            )?))
546            .await?;
547
548        // Inside the base band: 100K in + 100K out = 0.1*1.25 + 0.1*10 = 1.125.
549        let short = Usage {
550            served_speed: None,
551            input_tokens: 100_000,
552            output_tokens: 100_000,
553            cached_input_tokens: 0,
554            cache_creation_input_tokens: 0,
555        };
556        let short_cost = registry
557            .estimate_cost_usd("openrouter", "google/gemini-2.5-pro", &short)
558            .context("cost estimate missing")?;
559        assert!(
560            (short_cost - 1.125).abs() < 1e-9,
561            "unexpected cost: {short_cost}"
562        );
563
564        // At the threshold — the bound is inclusive — and past it: the override
565        // rates apply to the whole call. 200K in + 100K out =
566        // 0.2*2.5 + 0.1*15 = 2.0, where the base rates would say 1.25.
567        for input_tokens in [200_000, 300_000] {
568            let long = Usage {
569                served_speed: None,
570                input_tokens,
571                output_tokens: 100_000,
572                cached_input_tokens: 0,
573                cache_creation_input_tokens: 0,
574            };
575            let cost = registry
576                .estimate_cost_usd("openrouter", "google/gemini-2.5-pro", &long)
577                .context("cost estimate missing")?;
578            let expected = (f64::from(input_tokens) / 1_000_000.0).mul_add(2.5, 1.5);
579            assert!(
580                (cost - expected).abs() < 1e-9,
581                "unexpected cost at {input_tokens}: {cost}"
582            );
583        }
584
585        // One token below the threshold still pays base rates.
586        let just_under = Usage {
587            served_speed: None,
588            input_tokens: 199_999,
589            output_tokens: 0,
590            cached_input_tokens: 0,
591            cache_creation_input_tokens: 0,
592        };
593        let under_cost = registry
594            .estimate_cost_usd("openrouter", "google/gemini-2.5-pro", &just_under)
595            .context("cost estimate missing")?;
596        assert!(
597            (under_cost - 0.249_998_75).abs() < 1e-9,
598            "unexpected cost: {under_cost}"
599        );
600        Ok(())
601    }
602
603    /// A summed usage is not a prompt size: the base band must price it, or a
604    /// thread of short calls gets re-billed at a long-context rate it never
605    /// paid. The tier machinery is shared, so this holds for both feeds.
606    #[tokio::test]
607    async fn aggregate_repricing_ignores_openrouter_overrides() -> Result<()> {
608        let registry = ModelRegistry::new();
609        registry
610            .refresh(&StaticSource(parse_openrouter(
611                OPENROUTER_OVERRIDES_FIXTURE,
612            )?))
613            .await?;
614
615        // Three 100K-prompt calls: 300K summed, no call near the 200K bound.
616        let aggregate = Usage {
617            served_speed: None,
618            input_tokens: 300_000,
619            output_tokens: 0,
620            cached_input_tokens: 0,
621            cache_creation_input_tokens: 0,
622        };
623        let base = registry
624            .estimate_dynamic_base_cost_usd("openrouter", "google/gemini-2.5-pro", &aggregate)
625            .context("cost estimate missing")?;
626        // Base: 0.3 * 1.25 = 0.375, not the override's 0.3 * 2.5 = 0.75.
627        assert!((base - 0.375).abs() < 1e-9, "unexpected cost: {base}");
628        Ok(())
629    }
630
631    /// `OpenRouter` applies overrides in source order (later wins per key). A
632    /// non-monotonic list — a later entry at a *lower* threshold — must fold so
633    /// that entry wins where both apply, not the highest-threshold entry.
634    #[tokio::test]
635    async fn non_monotonic_overrides_fold_later_wins() -> Result<()> {
636        // Later entry (min 100K, prompt 3) is listed after the higher-threshold
637        // entry (min 200K, prompt 5). Only a trimmed mirror produces this.
638        const NON_MONOTONIC_FIXTURE: &str = r#"{
639          "data": [
640            {
641              "id": "vendor/model",
642              "pricing": {
643                "prompt": "0.000001",
644                "completion": "0.000001",
645                "overrides": [
646                  { "min_prompt_tokens": 200000, "prompt": "0.000005", "completion": "0.000005" },
647                  { "min_prompt_tokens": 100000, "prompt": "0.000003", "completion": "0.000003" }
648                ]
649              }
650            }
651          ]
652        }"#;
653
654        let entries = parse_openrouter(NON_MONOTONIC_FIXTURE)?;
655        let model = find(&entries, "openrouter", "vendor/model")?;
656        // Two distinct thresholds → two tiers.
657        assert_eq!(model.pricing_tiers.len(), 2);
658
659        let registry = ModelRegistry::new();
660        registry.refresh(&StaticSource(entries)).await?;
661        let out = |n: u32| Usage {
662            served_speed: None,
663            input_tokens: n,
664            output_tokens: 0,
665            cached_input_tokens: 0,
666            cache_creation_input_tokens: 0,
667        };
668
669        // Below 100K: base $1/M.
670        let base = registry
671            .estimate_cost_usd("openrouter", "vendor/model", &out(50_000))
672            .context("cost estimate missing")?;
673        assert!((base - 0.05).abs() < 1e-9, "unexpected cost: {base}");
674
675        // 150K: only the min-100K entry applies → $3/M.
676        let mid = registry
677            .estimate_cost_usd("openrouter", "vendor/model", &out(150_000))
678            .context("cost estimate missing")?;
679        assert!((mid - 0.45).abs() < 1e-9, "unexpected cost: {mid}");
680
681        // 250K: both apply; source order folds the min-100K entry last, so it
682        // wins — $3/M, NOT the min-200K entry's $5/M that highest-threshold
683        // selection would have picked.
684        let high = registry
685            .estimate_cost_usd("openrouter", "vendor/model", &out(250_000))
686            .context("cost estimate missing")?;
687        assert!((high - 0.75).abs() < 1e-9, "unexpected cost: {high}");
688        Ok(())
689    }
690
691    /// A drifted override costs its own row's pricing, never the feed parse.
692    #[test]
693    fn parse_openrouter_survives_a_drifted_override() -> Result<()> {
694        const DRIFTED_FIXTURE: &str = r#"{
695          "data": [
696            {
697              "id": "vendor/healthy",
698              "pricing": { "prompt": "0.000001", "completion": "0.000002" }
699            },
700            {
701              "id": "vendor/no-threshold",
702              "pricing": {
703                "prompt": "0.000001",
704                "completion": "0.000002",
705                "overrides": [{ "prompt": "0.000009", "completion": "0.000009" }]
706              }
707            },
708            {
709              "id": "vendor/no-rates",
710              "pricing": {
711                "prompt": "0.000001",
712                "completion": "0.000002",
713                "overrides": [{ "min_prompt_tokens": 200000, "input_cache_read": "0.0000001" }]
714              }
715            }
716          ]
717        }"#;
718
719        let entries = parse_openrouter(DRIFTED_FIXTURE)?;
720        assert_eq!(
721            entries.len(),
722            3,
723            "the parse must not abort on a drifted row"
724        );
725
726        let healthy = find(&entries, "openrouter", "vendor/healthy")?;
727        assert!(healthy.pricing.is_some());
728        assert!(healthy.pricing_tiers.is_empty());
729
730        // An override with no threshold cannot be located, and one with no
731        // input/output rate cannot be billed: either way the base rates provably
732        // stop applying somewhere unknown, so the row carries no pricing at all.
733        for model in ["vendor/no-threshold", "vendor/no-rates"] {
734            let drifted = find(&entries, "openrouter", model)?;
735            assert!(drifted.pricing.is_none(), "{model} must drop its pricing");
736            assert!(drifted.pricing_tiers.is_empty());
737        }
738        Ok(())
739    }
740}