{"repo": "caleb-terry/ai-usage-bar", "files": {"src-tauri/src/usage/types.rs": {"85ac4ce19fb54cad085e939dec384b6fe499f80e": {"t": 1786068337.8816507, "n": 24429, "h": "495b0b64cc1256e1", "c": "//! Unified usage model shared across all providers.\n//!\n//! Every provider normalizes its raw API response into a [`UsageSnapshot`]. The\n//! tray renderer, settings UI, and detail panel all consume this single shape so\n//! they never need provider-specific branching.\n\nuse chrono::{DateTime, Utc};\nuse serde::{Deserialize, Serialize};\n\n/// Identifies a usage provider.\n///\n/// Claude and Codex are subscription providers with OAuth session windows and\n/// (for those two) local cost logs + status pages. The remaining variants are\n/// API-key providers that report a purchased *credit balance* or spend, fetched\n/// from a single usage endpoint with a stored API key.\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]\n#[serde(rename_all = \"lowercase\")]\npub enum ProviderId {\n Claude,\n Codex,\n OpenRouter,\n ElevenLabs,\n Groq,\n Deepgram,\n #[serde(rename = \"zai\")]\n Zai,\n MiniMax,\n Gemini,\n Grok,\n DeepSeek,\n Moonshot,\n Mistral,\n Perplexity,\n}\n\n/// Broad behavior class for a provider, so generic code (settings, cost, status)\n/// can branch on capability rather than enumerating every variant.\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\npub enum ProviderKind {\n /// OAuth subscription provider with session/weekly windows (Claude, Codex).\n Subscription,\n /// API-key provider reporting a credit balance / spend.\n ApiKeyCredits,\n}\n\n/// Static, per-provider metadata. This table is the **single source of truth**\n/// for everything downstream code needs to know about a provider that isn't\n/// behavior: its wire id, display names, brand accent, and behavior class.\n///\n/// Adding a provider is one `ProviderId` variant plus one row here (and, for an\n/// API-key provider, its endpoint/auth row in `providers::credits` and its env\n/// var in `providers::api_key`). The TS side mirrors this exact table in\n/// `src/api.ts`; `provider_metadata_matches_typescript` guards against drift.\npub struct ProviderMeta {\n pub id: ProviderId,\n /// Lowercase wire id used in JSON, config keys, and the CLI.\n pub str: &'static str,\n /// Human-facing display name.\n pub label: &'static str,\n /// Short label for the compact tab chips in the panel.\n pub tab_label: &'static str,\n /// Brand accent (hex) driving the tab underline and hero card fill.\n pub accent: &'static str,\n pub kind: ProviderKind,\n /// Environment variable consulted for this provider's API key, if it's an\n /// API-key provider. `None` for subscription providers (CLI-authenticated).\n pub env_var: Option<&'static str>,\n /// statuspage.io status endpoint, when the provider publishes one we poll.\n pub status_url: Option<&'static str>,\n}\n\n/// The provider table. Order defines `ProviderId::ALL` and the UI's default\n/// provider ordering. Keep new providers grouped with their kind for clarity.\n///\n/// Columns: id, str, label, tab_label, accent, kind, env_var, status_url.\npub const PROVIDER_META: &[ProviderMeta] = &[\n meta(\n ProviderId::Claude,\n \"claude\",\n \"Claude Code\",\n \"Claude\",\n \"#d97757\",\n ProviderKind::Subscription,\n None,\n Some(\"https://status.claude.com/api/v2/status.json\"),\n ),\n meta(\n ProviderId::Codex,\n \"codex\",\n \"Codex\",\n \"Codex\",\n \"#10a37f\",\n ProviderKind::Subscription,\n None,\n Some(\"https://status.openai.com/api/v2/status.json\"),\n ),\n meta(\n ProviderId::OpenRouter,\n \"openrouter\",\n \"OpenRouter\",\n \"OpenRouter\",\n \"#6566f1\",\n ProviderKind::ApiKeyCredits,\n Some(\"OPENROUTER_API_KEY\"),\n None,\n ),\n meta(\n ProviderId::ElevenLabs,\n \"elevenlabs\",\n \"ElevenLabs\",\n \"11Labs\",\n \"#000000\",\n ProviderKind::ApiKeyCredits,\n Some(\"ELEVENLABS_API_KEY\"),\n None,\n ),\n meta(\n ProviderId::Groq,\n \"groq\",\n \"Groq\",\n \"Groq\",\n \"#f55036\",\n ProviderKind::ApiKeyCredits,\n Some(\"GROQ_API_KEY\"),\n Some(\"https://groqstatus.com/api/v2/status.json\"),\n ),\n meta(\n ProviderId::Deepgram,\n \"deepgram\",\n \"Deepgram\",\n \"Deepgram\",\n \"#13ef93\",\n ProviderKind::ApiKeyCredits,\n Some(\"DEEPGRAM_API_KEY\"),\n None,\n ),\n meta(\n ProviderId::Zai,\n \"zai\",\n \"z.ai\",\n \"z.ai\",\n \"#3b82f6\",\n ProviderKind::ApiKeyCredits,\n Some(\"ZAI_API_KEY\"),\n None,\n ),\n meta(\n ProviderId::MiniMax,\n \"minimax\",\n \"MiniMax\",\n \"MiniMax\",\n \"#ff4f4f\",\n ProviderKind::ApiKeyCredits,\n Some(\"MINIMAX_API_KEY\"),\n None,\n ),\n meta(\n ProviderId::Gemini,\n \"gemini\",\n \"Gemini\",\n \"Gemini\",\n \"#4285f4\",\n ProviderKind::ApiKeyCredits,\n Some(\"GEMINI_API_KEY\"),\n None,\n ),\n meta(\n ProviderId::Grok,\n \"grok\",\n \"Grok\",\n \"Grok\",\n \"#1a1a1a\",\n ProviderKind::ApiKeyCredits,\n Some(\"XAI_API_KEY\"),\n None,\n ),\n meta(\n ProviderId::DeepSeek,\n \"deepseek\",\n \"DeepSeek\",\n \"DeepSeek\",\n \"#4d6bfe\",\n ProviderKind::ApiKeyCredits,\n Some(\"DEEPSEEK_API_KEY\"),\n None,\n ),\n meta(\n ProviderId::Moonshot,\n \"moonshot\",\n \"Moonshot\",\n \"Moonshot\",\n \"#16191e\",\n ProviderKind::ApiKeyCredits,\n Some(\"MOONSHOT_API_KEY\"),\n None,\n ),\n meta(\n ProviderId::Mistral,\n \"mistral\",\n \"Mistral\",\n \"Mistral\",\n \"#fa520f\",\n ProviderKind::ApiKeyCredits,\n Some(\"MISTRAL_API_KEY\"),\n None,\n ),\n meta(\n ProviderId::Perplexity,\n \"perplexity\",\n \"Perplexity\",\n \"Perplexity\",\n \"#20808d\",\n ProviderKind::ApiKeyCredits,\n Some(\"PERPLEXITY_API_KEY\"),\n None,\n ),\n];\n\n/// `const fn` row constructor so `PROVIDER_META` stays a compile-time constant\n/// while reading as a flat table.\n#[allow(clippy::too_many_arguments)]\nconst fn meta(\n id: ProviderId,\n str: &'static str,\n label: &'static str,\n tab_label: &'static str,\n accent: &'static str,\n kind: ProviderKind,\n env_var: Option<&'static str>,\n status_url: Option<&'static str>,\n) -> ProviderMeta {\n ProviderMeta {\n id,\n str,\n label,\n tab_label,\n accent,\n kind,\n env_var,\n status_url,\n }\n}\n\nimpl ProviderId {\n /// Every provider, in table order. Built from `PROVIDER_META` so the list\n /// can never fall out of sync with the metadata.\n pub const ALL: [ProviderId; PROVIDER_META.len()] = {\n let mut out = [ProviderId::Claude; PROVIDER_META.len()];\n let mut i = 0;\n while i < PROVIDER_META.len() {\n out[i] = PROVIDER_META[i].id;\n i += 1;\n }\n out\n };\n\n /// This provider's metadata row. Infallible: every variant has a row, and\n /// `provider_meta_covers_all_variants` proves it.\n pub fn meta(self) -> &'static ProviderMeta {\n let mut i = 0;\n while i < PROVIDER_META.len() {\n if PROVIDER_META[i].id as u8 == self as u8 {\n return &PROVIDER_META[i];\n }\n i += 1;\n }\n // A missing row is a programmer error (a new ProviderId variant added\n // without its PROVIDER_META entry). Fail loudly rather than silently\n // returning the wrong provider's label/accent/env_var/status_url — a\n // wrong-credentials/wrong-status bug is far nastier to trace than a\n // panic. `provider_meta_covers_all_variants` makes this unreachable in\n // a tested build; the table is ≤14 entries, so the scan isn't hot.\n unreachable!(\"ProviderId missing from PROVIDER_META\")\n }\n\n pub fn as_str(self) -> &'static str {\n self.meta().str\n }\n\n /// Human-facing display name.\n pub fn label(self) -> &'static str {\n self.meta().label\n }\n\n /// Short label for the compact tab chips.\n pub fn tab_label(self) -> &'static str {\n self.meta().tab_label\n }\n\n /// Brand accent color (hex).\n pub fn accent(self) -> &'static str {\n self.meta().accent\n }\n\n pub fn kind(self) -> ProviderKind {\n self.meta().kind\n }\n\n /// Environment variable consulted for this provider's API key, if any.\n pub fn env_var(self) -> Option<&'static str> {\n self.meta().env_var\n }\n\n /// statuspage.io status endpoint for this provider, if one is published.\n pub fn status_url(self) -> Option<&'static str> {\n self.meta().status_url\n }\n}\n\n/// A single rate-limit window (e.g. the rolling 5-hour or 7-day window).\n#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]\npub struct WindowUsage {\n /// Percentage of the window consumed, 0.0..=100.0.\n pub utilization: f32,\n /// When this window resets, if the provider reports it.\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub reset_at: Option>,\n /// Short human label for the window, e.g. \"5h\" or \"Week\".\n pub label: String,\n}\n\nimpl WindowUsage {\n pub fn new(\n utilization: f32,\n label: impl Into,\n reset_at: Option>,\n ) -> Self {\n Self {\n utilization: utilization.clamp(0.0, 100.0),\n reset_at,\n label: label.into(),\n }\n }\n}\n\n/// How a provider's current state should be displayed.\n///\n/// Classification happens during normalization based on which fields the\n/// provider's API actually returned.\n#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]\n#[serde(tag = \"kind\", rename_all = \"snake_case\")]\npub enum DisplayMode {\n /// Subscription session windows present (5h + weekly).\n Session {\n primary: WindowUsage,\n secondary: Option,\n },\n /// Session windows unavailable but a spend cap is reported.\n SpendCap {\n utilization: f32,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n used_cents: Option,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n limit_cents: Option,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n reset_at: Option>,\n },\n /// API key valid, but the provider reports a purchased dollar balance rather\n /// than a utilization percentage (OpenRouter, DeepSeek, …). The balance is\n /// the meaningful figure; there is no percentage for the icon.\n CreditBalance { balance_cents: u64 },\n /// No valid credentials found for this provider.\n Unauthenticated,\n /// API key valid, but the provider exposes no balance or usage figure — the\n /// only signal is that the key authenticates (Groq, Gemini, …).\n ApiKeyOnly,\n}\n\nimpl DisplayMode {\n /// The single utilization figure used for auto provider-selection and\n /// threshold coloring: the most-constrained window/cap currently active.\n /// Returns `None` for states with no meaningful percentage.\n pub fn peak_utilization(&self) -> Option {\n match self {\n DisplayMode::Session { primary, secondary } => {\n let mut peak = primary.utilization;\n if let Some(s) = secondary {\n peak = peak.max(s.utilization);\n }\n Some(peak)\n }\n DisplayMode::SpendCap { utilization, .. } => Some(*utilization),\n DisplayMode::CreditBalance { .. }\n | DisplayMode::Unauthenticated\n | DisplayMode::ApiKeyOnly => None,\n }\n }\n\n /// Short tray-title string shown next to the macOS menu-bar glyph: the\n /// 5-hour session (or spend-cap) percentage honoring the used/remaining\n /// preference, a dollar balance, or a terse placeholder. The single\n /// implementation consumed by the tray and (for parity) any other caller.\n pub fn tray_title(&self, show_remaining: bool) -> String {\n match self {\n DisplayMode::Session { primary, .. } => {\n format!(\n \"{}%\",\n display_pct(primary.utilization, show_remaining).round() as i32\n )\n }\n DisplayMode::SpendCap { utilization, .. } => {\n format!(\n \"{}%\",\n display_pct(*utilization, show_remaining).round() as i32\n )\n }\n DisplayMode::CreditBalance { balance_cents } => format_usd_cents(*balance_cents),\n DisplayMode::Unauthenticated => \"—\".to_string(),\n DisplayMode::ApiKeyOnly => \"key\".to_string(),\n }\n }\n\n /// One-line body describing the mode (no provider/plan prefix), used in the\n /// tray menu header, tooltip, and the CLI. `used`/`left` follows the\n /// used/remaining preference.\n pub fn status_summary(&self, show_remaining: bool) -> String {\n let label = if show_remaining { \"left\" } else { \"used\" };\n match self {\n DisplayMode::Session { primary, secondary } => {\n let p = display_pct(primary.utilization, show_remaining).round() as i32;\n match secondary {\n Some(s) => format!(\n \"5h {p}% {label} · Wk {}% {label}\",\n display_pct(s.utilization, show_remaining).round() as i32\n ),\n None => format!(\"5h {p}% {label}\"),\n }\n }\n DisplayMode::SpendCap { utilization, .. } => {\n format!(\n \"Spend cap {}% {label}\",\n display_pct(*utilization, show_remaining).round() as i32\n )\n }\n DisplayMode::CreditBalance { balance_cents } => {\n format!(\"{} credits\", format_usd_cents(*balance_cents))\n }\n DisplayMode::Unauthenticated => \"Sign in required\".to_string(),\n DisplayMode::ApiKeyOnly => \"API key — no usage limits reported\".to_string(),\n }\n }\n}\n\n/// Apply the show-remaining preference to a raw utilization. The single\n/// implementation of this rule: `DisplayMode`'s presentation methods call it\n/// directly, and `Settings::display_pct` delegates to it (settings already\n/// depends on this module, so there's no cycle and no second copy to drift).\npub(crate) fn display_pct(utilization: f32, show_remaining: bool) -> f32 {\n if show_remaining {\n 100.0 - utilization\n } else {\n utilization\n }\n}\n\n/// Format a cent amount as `$12.34` / `$7,293` for menu-bar and menu display.\n/// `pub(crate)` so the icon renderer (baked-glyph path) formats balances\n/// identically to the tray title, rather than truncating to whole dollars.\n/// Locale: en-only (`,`/`.`); revisit if i18n grows beyond English.\npub(crate) fn format_usd_cents(cents: u64) -> String {\n let dollars = cents as f64 / 100.0;\n if dollars >= 1000.0 {\n // Thousands separator, no decimals, for compact menu-bar fit.\n let whole = dollars.round() as u64;\n let mut s = whole.to_string();\n let mut i = s.len();\n while i > 3 {\n i -= 3;\n s.insert(i, ',');\n }\n format!(\"${s}\")\n } else {\n format!(\"${dollars:.2}\")\n }\n}\n\n/// Provider-specific extras surfaced only in the detail panel (never the icon).\n#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]\npub struct DetailExtras {\n /// Purchased credit balance, in the provider's smallest currency unit.\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub credit_balance_cents: Option,\n /// Code-review weekly limit utilization, if reported (Codex).\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub code_review_utilization: Option,\n /// Number of available on-demand resets (Codex).\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub on_demand_resets: Option,\n /// Extra-usage spend against the monthly cap (Claude), in cents. Surfaced\n /// alongside the session bars so spend is visible without losing the\n /// windows; the icon never shows this.\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub extra_usage_used_cents: Option,\n /// Monthly extra-usage spend cap (Claude), in cents.\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub extra_usage_cap_cents: Option,\n}\n\n/// A fully normalized snapshot of one provider's usage at a point in time.\n#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]\npub struct UsageSnapshot {\n pub provider: ProviderId,\n /// e.g. \"pro\", \"max\", \"plus\", \"business\" — never hard-coded; from the API.\n pub plan_label: String,\n pub mode: DisplayMode,\n pub fetched_at: DateTime,\n /// True when serving cached data after a fetch error.\n pub stale: bool,\n #[serde(default)]\n pub extras: DetailExtras,\n}\n\nimpl UsageSnapshot {\n /// Construct an unauthenticated placeholder snapshot for a provider.\n pub fn unauthenticated(provider: ProviderId, fetched_at: DateTime) -> Self {\n Self {\n provider,\n plan_label: String::new(),\n mode: DisplayMode::Unauthenticated,\n fetched_at,\n stale: false,\n extras: DetailExtras::default(),\n }\n }\n\n pub fn peak_utilization(&self) -> Option {\n self.mode.peak_utilization()\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn window_clamps_utilization() {\n let w = WindowUsage::new(140.0, \"5h\", None);\n assert_eq!(w.utilization, 100.0);\n let w = WindowUsage::new(-5.0, \"5h\", None);\n assert_eq!(w.utilization, 0.0);\n }\n\n #[test]\n fn peak_picks_max_window() {\n let mode = DisplayMode::Session {\n primary: WindowUsage::new(42.0, \"5h\", None),\n secondary: Some(WindowUsage::new(81.0, \"Week\", None)),\n };\n assert_eq!(mode.peak_utilization(), Some(81.0));\n }\n\n #[test]\n fn peak_none_for_unauthenticated() {\n assert_eq!(DisplayMode::Unauthenticated.peak_utilization(), None);\n assert_eq!(DisplayMode::ApiKeyOnly.peak_utilization(), None);\n }\n\n #[test]\n fn tray_title_formats_each_mode() {\n let session = DisplayMode::Session {\n primary: WindowUsage::new(45.0, \"5h\", None),\n secondary: None,\n };\n assert_eq!(session.tray_title(false), \"45%\");\n assert_eq!(session.tray_title(true), \"55%\"); // remaining\n\n let bal = DisplayMode::CreditBalance {\n balance_cents: 1850,\n };\n assert_eq!(bal.tray_title(false), \"$18.50\");\n let big = DisplayMode::CreditBalance {\n balance_cents: 729_300,\n };\n assert_eq!(big.tray_title(false), \"$7,293\");\n\n assert_eq!(DisplayMode::Unauthenticated.tray_title(false), \"—\");\n assert_eq!(DisplayMode::ApiKeyOnly.tray_title(false), \"key\");\n }\n\n #[test]\n fn status_summary_formats_each_mode() {\n let session = DisplayMode::Session {\n primary: WindowUsage::new(42.0, \"5h\", None),\n secondary: Some(WindowUsage::new(18.0, \"Week\", None)),\n };\n assert_eq!(session.status_summary(false), \"5h 42% used · Wk 18% used\");\n assert_eq!(session.status_summary(true), \"5h 58% left · Wk 82% left\");\n\n let bal = DisplayMode::CreditBalance {\n balance_cents: 1850,\n };\n assert_eq!(bal.status_summary(false), \"$18.50 credits\");\n assert_eq!(\n DisplayMode::Unauthenticated.status_summary(false),\n \"Sign in required\"\n );\n }\n\n #[test]\n fn credit_balance_has_no_peak() {\n assert_eq!(\n DisplayMode::CreditBalance { balance_cents: 500 }.peak_utilization(),\n None\n );\n }\n\n #[test]\n fn provider_meta_covers_all_variants() {\n // Every ProviderId resolves to its own row (not the fallback). This\n // makes ProviderId::meta() infallible by construction.\n for id in ProviderId::ALL {\n assert_eq!(\n id.meta().id,\n id,\n \"{} resolved to the wrong row\",\n id.as_str()\n );\n }\n assert_eq!(PROVIDER_META.len(), ProviderId::ALL.len());\n }\n\n /// The TS side (`src/api.ts`) hand-mirrors this table. If a provider is\n /// added/edited in Rust but the TS literal drifts, this snapshot catches it:\n /// update `EXPECTED_TS_TABLE` and the TS file together. Each tuple is\n /// (str, label, tab_label, accent, is_api_key).\n #[test]\n fn provider_metadata_matches_typescript() {\n const EXPECTED_TS_TABLE: &[(&str, &str, &str, &str, bool)] = &[\n (\"claude\", \"Claude Code\", \"Claude\", \"#d97757\", false),\n (\"codex\", \"Codex\", \"Codex\", \"#10a37f\", false),\n (\"openrouter\", \"OpenRouter\", \"OpenRouter\", \"#6566f1\", true),\n (\"elevenlabs\", \"ElevenLabs\", \"11Labs\", \"#000000\", true),\n (\"groq\", \"Groq\", \"Groq\", \"#f55036\", true),\n (\"deepgram\", \"Deepgram\", \"Deepgram\", \"#13ef93\", true),\n (\"zai\", \"z.ai\", \"z.ai\", \"#3b82f6\", true),\n (\"minimax\", \"MiniMax\", \"MiniMax\", \"#ff4f4f\", true),\n (\"gemini\", \"Gemini\", \"Gemini\", \"#4285f4\", true),\n (\"grok\", \"Grok\", \"Grok\", \"#1a1a1a\", true),\n (\"deepseek\", \"DeepSeek\", \"DeepSeek\", \"#4d6bfe\", true),\n (\"moonshot\", \"Moonshot\", \"Moonshot\", \"#16191e\", true),\n (\"mistral\", \"Mistral\", \"Mistral\", \"#fa520f\", true),\n (\"perplexity\", \"Perplexity\", \"Perplexity\", \"#20808d\", true),\n ];\n let actual: Vec<_> = PROVIDER_META\n .iter()\n .map(|m| {\n (\n m.str,\n m.label,\n m.tab_label,\n m.accent,\n m.kind == ProviderKind::ApiKeyCredits,\n )\n })\n .collect();\n assert_eq!(actual, EXPECTED_TS_TABLE);\n }\n\n /// `env_var` and `status_url` aren't carried by the TS mirror, so the\n /// snapshot above can't guard them. Pin them here: a typo in an API-key env\n /// var (e.g. `XAI_API_KEY`) or a status endpoint would otherwise silently\n /// break credential loading / status polling with no failing test.\n #[test]\n fn provider_env_var_and_status_url_pinned() {\n const EXPECTED: &[(&str, Option<&str>, Option<&str>)] = &[\n // (str, env_var, status_url)\n (\n \"claude\",\n None,\n Some(\"https://status.claude.com/api/v2/status.json\"),\n ),\n (\n \"codex\",\n None,\n Some(\"https://status.openai.com/api/v2/status.json\"),\n ),\n (\"openrouter\", Some(\"OPENROUTER_API_KEY\"), None),\n (\"elevenlabs\", Some(\"ELEVENLABS_API_KEY\"), None),\n (\n \"groq\",\n Some(\"GROQ_API_KEY\"),\n Some(\"https://groqstatus.com/api/v2/status.json\"),\n ),\n (\"deepgram\", Some(\"DEEPGRAM_API_KEY\"), None),\n (\"zai\", Some(\"ZAI_API_KEY\"), None),\n (\"minimax\", Some(\"MINIMAX_API_KEY\"), None),\n (\"gemini\", Some(\"GEMINI_API_KEY\"), None),\n (\"grok\", Some(\"XAI_API_KEY\"), None),\n (\"deepseek\", Some(\"DEEPSEEK_API_KEY\"), None),\n (\"moonshot\", Some(\"MOONSHOT_API_KEY\"), None),\n (\"mistral\", Some(\"MISTRAL_API_KEY\"), None),\n (\"perplexity\", Some(\"PERPLEXITY_API_KEY\"), None),\n ];\n let actual: Vec<_> = PROVIDER_META\n .iter()\n .map(|m| (m.str, m.env_var, m.status_url))\n .collect();\n assert_eq!(actual, EXPECTED);\n // Every API-key provider must declare an env var; subscription providers\n // must not. This catches a kind/env_var mismatch independent of the\n // pinned list above.\n for m in PROVIDER_META {\n match m.kind {\n ProviderKind::ApiKeyCredits => {\n assert!(m.env_var.is_some(), \"{} missing env_var\", m.str)\n }\n ProviderKind::Subscription => {\n assert!(m.env_var.is_none(), \"{} should not have env_var\", m.str)\n }\n }\n }\n }\n}\n"}}}}