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            input_tokens: 1_000,
444            output_tokens: 1_000,
445            cached_input_tokens: 0,
446            cache_creation_input_tokens: 0,
447        };
448        assert_eq!(
449            registry.estimate_cost_usd("openrouter", "openrouter/auto", &usage),
450            None
451        );
452        Ok(())
453    }
454
455    /// Mirrors the live feed: `google/gemini-2.5-pro` raises prompt, completion
456    /// and cache-read above 200K prompt tokens but leaves cache-write unstated,
457    /// while `openai/gpt-5.6-luna` restates every rate above 272K.
458    const OPENROUTER_OVERRIDES_FIXTURE: &str = r#"{
459      "data": [
460        {
461          "id": "google/gemini-2.5-pro",
462          "context_length": 1048576,
463          "pricing": {
464            "prompt": "0.00000125",
465            "completion": "0.00001",
466            "input_cache_read": "0.000000125",
467            "input_cache_write": "0.000000375",
468            "overrides": [
469              {
470                "min_prompt_tokens": 200000,
471                "prompt": "0.0000025",
472                "completion": "0.000015",
473                "input_cache_read": "0.00000025"
474              }
475            ]
476          }
477        },
478        {
479          "id": "openai/gpt-5.6-luna",
480          "context_length": 400000,
481          "pricing": {
482            "prompt": "0.000001",
483            "completion": "0.000006",
484            "input_cache_read": "0.0000001",
485            "input_cache_write": "0.00000125",
486            "overrides": [
487              {
488                "min_prompt_tokens": 272000,
489                "prompt": "0.000002",
490                "completion": "0.000009",
491                "input_cache_read": "0.0000002",
492                "input_cache_write": "0.0000025"
493              }
494            ]
495          }
496        }
497      ]
498    }"#;
499
500    #[test]
501    fn parse_openrouter_reads_long_context_overrides() -> Result<()> {
502        let entries = parse_openrouter(OPENROUTER_OVERRIDES_FIXTURE)?;
503
504        let gemini = find(&entries, "openrouter", "google/gemini-2.5-pro")?;
505        assert_eq!(gemini.pricing_tiers.len(), 1);
506        let band = gemini.pricing_tiers[0];
507        // `min_prompt_tokens` is already an inclusive lower bound.
508        assert_eq!(band.min_input_tokens, 200_000);
509        assert!(
510            (band.pricing.input.context("input")?.usd_per_million_tokens - 2.5).abs()
511                < f64::EPSILON
512        );
513        assert!(
514            (band
515                .pricing
516                .output
517                .context("output")?
518                .usd_per_million_tokens
519                - 15.0)
520                .abs()
521                < f64::EPSILON
522        );
523        // The override says nothing about cache-write, so the band keeps the
524        // base rate for it rather than dropping it or re-billing it at input.
525        assert!(
526            (band
527                .pricing
528                .cache_write
529                .context("cache_write inherited from base")?
530                .usd_per_million_tokens
531                - 0.375)
532                .abs()
533                < f64::EPSILON
534        );
535        Ok(())
536    }
537
538    #[tokio::test]
539    async fn long_context_route_bills_at_the_override_rate() -> Result<()> {
540        let registry = ModelRegistry::new();
541        registry
542            .refresh(&StaticSource(parse_openrouter(
543                OPENROUTER_OVERRIDES_FIXTURE,
544            )?))
545            .await?;
546
547        // Inside the base band: 100K in + 100K out = 0.1*1.25 + 0.1*10 = 1.125.
548        let short = Usage {
549            input_tokens: 100_000,
550            output_tokens: 100_000,
551            cached_input_tokens: 0,
552            cache_creation_input_tokens: 0,
553        };
554        let short_cost = registry
555            .estimate_cost_usd("openrouter", "google/gemini-2.5-pro", &short)
556            .context("cost estimate missing")?;
557        assert!(
558            (short_cost - 1.125).abs() < 1e-9,
559            "unexpected cost: {short_cost}"
560        );
561
562        // At the threshold — the bound is inclusive — and past it: the override
563        // rates apply to the whole call. 200K in + 100K out =
564        // 0.2*2.5 + 0.1*15 = 2.0, where the base rates would say 1.25.
565        for input_tokens in [200_000, 300_000] {
566            let long = Usage {
567                input_tokens,
568                output_tokens: 100_000,
569                cached_input_tokens: 0,
570                cache_creation_input_tokens: 0,
571            };
572            let cost = registry
573                .estimate_cost_usd("openrouter", "google/gemini-2.5-pro", &long)
574                .context("cost estimate missing")?;
575            let expected = (f64::from(input_tokens) / 1_000_000.0).mul_add(2.5, 1.5);
576            assert!(
577                (cost - expected).abs() < 1e-9,
578                "unexpected cost at {input_tokens}: {cost}"
579            );
580        }
581
582        // One token below the threshold still pays base rates.
583        let just_under = Usage {
584            input_tokens: 199_999,
585            output_tokens: 0,
586            cached_input_tokens: 0,
587            cache_creation_input_tokens: 0,
588        };
589        let under_cost = registry
590            .estimate_cost_usd("openrouter", "google/gemini-2.5-pro", &just_under)
591            .context("cost estimate missing")?;
592        assert!(
593            (under_cost - 0.249_998_75).abs() < 1e-9,
594            "unexpected cost: {under_cost}"
595        );
596        Ok(())
597    }
598
599    /// A summed usage is not a prompt size: the base band must price it, or a
600    /// thread of short calls gets re-billed at a long-context rate it never
601    /// paid. The tier machinery is shared, so this holds for both feeds.
602    #[tokio::test]
603    async fn aggregate_repricing_ignores_openrouter_overrides() -> Result<()> {
604        let registry = ModelRegistry::new();
605        registry
606            .refresh(&StaticSource(parse_openrouter(
607                OPENROUTER_OVERRIDES_FIXTURE,
608            )?))
609            .await?;
610
611        // Three 100K-prompt calls: 300K summed, no call near the 200K bound.
612        let aggregate = Usage {
613            input_tokens: 300_000,
614            output_tokens: 0,
615            cached_input_tokens: 0,
616            cache_creation_input_tokens: 0,
617        };
618        let base = registry
619            .estimate_dynamic_base_cost_usd("openrouter", "google/gemini-2.5-pro", &aggregate)
620            .context("cost estimate missing")?;
621        // Base: 0.3 * 1.25 = 0.375, not the override's 0.3 * 2.5 = 0.75.
622        assert!((base - 0.375).abs() < 1e-9, "unexpected cost: {base}");
623        Ok(())
624    }
625
626    /// `OpenRouter` applies overrides in source order (later wins per key). A
627    /// non-monotonic list — a later entry at a *lower* threshold — must fold so
628    /// that entry wins where both apply, not the highest-threshold entry.
629    #[tokio::test]
630    async fn non_monotonic_overrides_fold_later_wins() -> Result<()> {
631        // Later entry (min 100K, prompt 3) is listed after the higher-threshold
632        // entry (min 200K, prompt 5). Only a trimmed mirror produces this.
633        const NON_MONOTONIC_FIXTURE: &str = r#"{
634          "data": [
635            {
636              "id": "vendor/model",
637              "pricing": {
638                "prompt": "0.000001",
639                "completion": "0.000001",
640                "overrides": [
641                  { "min_prompt_tokens": 200000, "prompt": "0.000005", "completion": "0.000005" },
642                  { "min_prompt_tokens": 100000, "prompt": "0.000003", "completion": "0.000003" }
643                ]
644              }
645            }
646          ]
647        }"#;
648
649        let entries = parse_openrouter(NON_MONOTONIC_FIXTURE)?;
650        let model = find(&entries, "openrouter", "vendor/model")?;
651        // Two distinct thresholds → two tiers.
652        assert_eq!(model.pricing_tiers.len(), 2);
653
654        let registry = ModelRegistry::new();
655        registry.refresh(&StaticSource(entries)).await?;
656        let out = |n: u32| Usage {
657            input_tokens: n,
658            output_tokens: 0,
659            cached_input_tokens: 0,
660            cache_creation_input_tokens: 0,
661        };
662
663        // Below 100K: base $1/M.
664        let base = registry
665            .estimate_cost_usd("openrouter", "vendor/model", &out(50_000))
666            .context("cost estimate missing")?;
667        assert!((base - 0.05).abs() < 1e-9, "unexpected cost: {base}");
668
669        // 150K: only the min-100K entry applies → $3/M.
670        let mid = registry
671            .estimate_cost_usd("openrouter", "vendor/model", &out(150_000))
672            .context("cost estimate missing")?;
673        assert!((mid - 0.45).abs() < 1e-9, "unexpected cost: {mid}");
674
675        // 250K: both apply; source order folds the min-100K entry last, so it
676        // wins — $3/M, NOT the min-200K entry's $5/M that highest-threshold
677        // selection would have picked.
678        let high = registry
679            .estimate_cost_usd("openrouter", "vendor/model", &out(250_000))
680            .context("cost estimate missing")?;
681        assert!((high - 0.75).abs() < 1e-9, "unexpected cost: {high}");
682        Ok(())
683    }
684
685    /// A drifted override costs its own row's pricing, never the feed parse.
686    #[test]
687    fn parse_openrouter_survives_a_drifted_override() -> Result<()> {
688        const DRIFTED_FIXTURE: &str = r#"{
689          "data": [
690            {
691              "id": "vendor/healthy",
692              "pricing": { "prompt": "0.000001", "completion": "0.000002" }
693            },
694            {
695              "id": "vendor/no-threshold",
696              "pricing": {
697                "prompt": "0.000001",
698                "completion": "0.000002",
699                "overrides": [{ "prompt": "0.000009", "completion": "0.000009" }]
700              }
701            },
702            {
703              "id": "vendor/no-rates",
704              "pricing": {
705                "prompt": "0.000001",
706                "completion": "0.000002",
707                "overrides": [{ "min_prompt_tokens": 200000, "input_cache_read": "0.0000001" }]
708              }
709            }
710          ]
711        }"#;
712
713        let entries = parse_openrouter(DRIFTED_FIXTURE)?;
714        assert_eq!(
715            entries.len(),
716            3,
717            "the parse must not abort on a drifted row"
718        );
719
720        let healthy = find(&entries, "openrouter", "vendor/healthy")?;
721        assert!(healthy.pricing.is_some());
722        assert!(healthy.pricing_tiers.is_empty());
723
724        // An override with no threshold cannot be located, and one with no
725        // input/output rate cannot be billed: either way the base rates provably
726        // stop applying somewhere unknown, so the row carries no pricing at all.
727        for model in ["vendor/no-threshold", "vendor/no-rates"] {
728            let drifted = find(&entries, "openrouter", model)?;
729            assert!(drifted.pricing.is_none(), "{model} must drop its pricing");
730            assert!(drifted.pricing_tiers.is_empty());
731        }
732        Ok(())
733    }
734}