From 5618d08d6d71f92f1807199d00c3c0caf143ed31 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sat, 10 Oct 2026 12:49:54 +0700 Subject: [PATCH 01/40] Pin provider registry values in a snapshot test --- rust/src/core/mod.rs | 2 + rust/src/core/provider_aliases_snapshot.txt | 241 ++++++++++++ rust/src/core/provider_registry_snapshot.txt | 356 ++++++++++++++++++ .../core/provider_registry_snapshot_tests.rs | 79 ++++ 4 files changed, 678 insertions(+) create mode 100644 rust/src/core/provider_aliases_snapshot.txt create mode 100644 rust/src/core/provider_registry_snapshot.txt create mode 100644 rust/src/core/provider_registry_snapshot_tests.rs diff --git a/rust/src/core/mod.rs b/rust/src/core/mod.rs index 8f0ae2bdcab..f6c3d1bec07 100755 --- a/rust/src/core/mod.rs +++ b/rust/src/core/mod.rs @@ -21,6 +21,8 @@ mod models_dev_targets; mod openai_dashboard; mod provider; mod provider_factory; +#[cfg(test)] +mod provider_registry_snapshot_tests; mod provider_state; mod quota_burndown; mod rate_window; diff --git a/rust/src/core/provider_aliases_snapshot.txt b/rust/src/core/provider_aliases_snapshot.txt new file mode 100644 index 00000000000..c0b840e248f --- /dev/null +++ b/rust/src/core/provider_aliases_snapshot.txt @@ -0,0 +1,241 @@ +codex => Codex +openai => Codex +claude => Claude +anthropic => Claude +pi => Pi +pi-mono => Pi +cursor => Cursor +factory => Factory +droid => Factory +gemini => Gemini +google => Gemini +antigravity => Antigravity +agy => Antigravity +copilot => Copilot +github => Copilot +zai => Zai +z.ai => Zai +minimax => MiniMax +kiro => Kiro +aws => Kiro +vertexai => VertexAI +vertex => VertexAI +vertex ai => VertexAI +augment => Augment +opencode => OpenCode +kimi => Kimi +moonshot => Kimi +kimik2 => KimiK2 +kimi-k2 => KimiK2 +kimi k2 => KimiK2 +k2 => KimiK2 +kimi k2 (removed) => KimiK2 +amp => Amp +sourcegraph => Amp +warp => Warp +warp-ai => Warp +warp-terminal => Warp +ollama => Ollama +azureopenai => AzureOpenAI +azure-openai => AzureOpenAI +azure openai => AzureOpenAI +t3chat => T3Chat +t3-chat => T3Chat +t3 chat => T3Chat +openrouter => OpenRouter +or => OpenRouter +jetbrains => JetBrains +jetbrains-ai => JetBrains +jetbrains ai => JetBrains +intellij => JetBrains +alibaba => Alibaba +tongyi => Alibaba +qianwen => Alibaba +alibabatokenplan => AlibabaTokenPlan +alibaba-token-plan => AlibabaTokenPlan +alibaba token plan => AlibabaTokenPlan +alibaba-token => AlibabaTokenPlan +bailian-token-plan => AlibabaTokenPlan +nanogpt => NanoGPT +nano-gpt => NanoGPT +infini => Infini +infini-ai => Infini +perplexity => Perplexity +pplx => Perplexity +abacus => Abacus +abacus ai => Abacus +abacus-ai => Abacus +mistral => Mistral +mistral-ai => Mistral +mistral ai => Mistral +opencodego => OpenCodeGo +opencode-go => OpenCodeGo +opencode go => OpenCodeGo +kilo => Kilo +bedrock => Bedrock +aws-bedrock => Bedrock +aws bedrock => Bedrock +codebuff => Codebuff +manicode => Codebuff +coderabbit => CodeRabbit +code-rabbit => CodeRabbit +code rabbit => CodeRabbit +deepseek => DeepSeek +deep-seek => DeepSeek +ds => DeepSeek +deepinfra => DeepInfra +deep-infra => DeepInfra +di => DeepInfra +aiand => AiAnd +ai& => AiAnd +ai-and => AiAnd +ai and => AiAnd +windsurf => Windsurf +codeium => Windsurf +manus => Manus +mimo => MiMo +xiaomi => MiMo +xiaomimimo => MiMo +xiaomi-mimo => MiMo +xiaomi mimo => MiMo +doubao => Doubao +ark => Doubao +volcengine => Doubao +commandcode => CommandCode +command-code => CommandCode +command code => CommandCode +stepfun => StepFun +step-fun => StepFun +step fun => StepFun +venice => Venice +openaiapi => OpenAIApi +openai-api => OpenAIApi +openai api => OpenAIApi +openai-balance => OpenAIApi +grok => Grok +supergrok => Grok +super-grok => Grok +elevenlabs => ElevenLabs +eleven-labs => ElevenLabs +11labs => ElevenLabs +deepgram => Deepgram +dg => Deepgram +groq => Groq +groqcloud => Groq +groq-cloud => Groq +groq cloud => Groq +huggingface => HuggingFace +hugging-face => HuggingFace +hugging face => HuggingFace +hf => HuggingFace +helmcode => Helmcode +nan-builders => Helmcode +nan builders => Helmcode +v0 => V0 +v0-dev => V0 +v0.dev => V0 +typesafe => TypeSafe +type-safe => TypeSafe +type safe => TypeSafe +llmproxy => LLMProxy +llm-proxy => LLMProxy +llm proxy => LLMProxy +chutes => Chutes +chutes-ai => Chutes +chutes ai => Chutes +litellm => LiteLLM +lite-llm => LiteLLM +lite llm => LiteLLM +poe => Poe +devin => Devin +zed => Zed +zed-ai => Zed +crossmodel => CrossModel +cross-model => CrossModel +cross model => CrossModel +crossmodel (removed) => CrossModel +qoder => Qoder +codebuddy => CodeBuddy +code-buddy => CodeBuddy +codebuddy-cn => CodeBuddy +codebuddycn => CodeBuddy +腾讯codebuddy => CodeBuddy +sakana => Sakana +sakana-ai => Sakana +sakana ai => Sakana +sub2api => Sub2Api +sub-2-api => Sub2Api +sub 2 api => Sub2Api +wayfinder => Wayfinder +zenmux => ZenMux +zen-mux => ZenMux +clinepass => ClinePass +cline-pass => ClinePass +cline => ClinePass +longcat => LongCat +long-cat => LongCat +lc => LongCat +neuralwatt => Neuralwatt +neural-watt => Neuralwatt +nw => Neuralwatt +neural => Neuralwatt +zoommate => ZoomMate +zoom-mate => ZoomMate +zoom mate => ZoomMate +qwen-cloud => QwenCloud +qwencloud => QwenCloud +qwen => QwenCloud +qwen-token-plan => QwenCloud +qwen cloud => QwenCloud +notion => Notion +notion-ai => Notion +notionai => Notion +notion ai => Notion +xai => Xai +x.ai => Xai +x-ai => Xai +fireworks => Fireworks +fireworks-ai => Fireworks +fw => Fireworks +atlascloud => AtlasCloud +atlas-cloud => AtlasCloud +atlas cloud => AtlasCloud +meta => Meta +metaspark => Meta +meta-spark => Meta +muse-spark => Meta +musespark => Meta +muse spark => Meta +meta muse spark => Meta +muse => Muse +muse-code => Muse +muse code => Muse +replicate => Replicate +r8 => Replicate +nous => Nous +nous-portal => Nous +nous portal => Nous +hermes => Nous +hyper => Hyper +charm-hyper => Hyper +charm hyper => Hyper +gitkraken => GitKraken +gitkraken-ai => GitKraken +gitkraken ai => GitKraken +bifrost => Bifrost +bifrost-gateway => Bifrost +bifrost gateway => Bifrost +aixy => Aixy +aixy-gateway => Aixy +aixy gateway => Aixy +llmman => LLMMan +devpass => DevPass +xkiro => XKiro +x-kiro => XKiro +raycast => Raycast +raycast-ai => Raycast +vercel => Vercel +vercel-ai-gateway => Vercel +vercel ai gateway => Vercel +ai-gateway => Vercel +ai gateway => Vercel diff --git a/rust/src/core/provider_registry_snapshot.txt b/rust/src/core/provider_registry_snapshot.txt new file mode 100644 index 00000000000..2e98e13759a --- /dev/null +++ b/rust/src/core/provider_registry_snapshot.txt @@ -0,0 +1,356 @@ +Codex cli=codex display="Codex" cookie=Some("chatgpt.com") color=#49A3B0 deprecated=false blocking=None + map=["codex", "openai"] + metadata=ProviderMetadata { id: Codex, display_name: "Codex", session_label: "Session", weekly_label: "Weekly", supports_opus: false, supports_credits: true, default_enabled: true, is_primary: true, dashboard_url: Some("https://chatgpt.com/codex/cloud/settings/analytics#usage"), status_page_url: Some("https://status.openai.com"), tertiary_label_key: None } + sources=[Auto, OAuth, Cli] oauth=true web=false cli=true +Claude cli=claude display="Claude" cookie=Some("claude.ai") color=#CC7C5E deprecated=false blocking=None + map=["anthropic", "claude"] + metadata=ProviderMetadata { id: Claude, display_name: "Claude", session_label: "Session (5h)", weekly_label: "Weekly", supports_opus: true, supports_credits: true, default_enabled: true, is_primary: true, dashboard_url: Some("https://claude.ai/settings/usage"), status_page_url: Some("https://status.claude.com/"), tertiary_label_key: None } + sources=[Auto, OAuth, Web, Cli] oauth=true web=true cli=true +Pi cli=pi display="Pi" cookie=None color=#7C3AED deprecated=false blocking=None + map=["pi"] + metadata=ProviderMetadata { id: Pi, display_name: "Pi", session_label: "Session", weekly_label: "Weekly", supports_opus: false, supports_credits: false, default_enabled: false, is_primary: false, dashboard_url: Some("https://github.com/badlogic/pi-mono"), status_page_url: None, tertiary_label_key: None } + sources=[Auto] oauth=false web=false cli=false +Cursor cli=cursor display="Cursor" cookie=Some("cursor.com") color=#F54E00 deprecated=false blocking=None + map=["cursor"] + metadata=ProviderMetadata { id: Cursor, display_name: "Cursor", session_label: "Plan", weekly_label: "Cursor", supports_opus: false, supports_credits: false, default_enabled: true, is_primary: false, dashboard_url: Some("https://cursor.com/dashboard/usage"), status_page_url: None, tertiary_label_key: None } + sources=[Auto, Web] oauth=false web=true cli=false +Factory cli=factory display="Factory" cookie=Some("app.factory.ai") color=#FF6B35 deprecated=false blocking=None + map=["factory"] + metadata=ProviderMetadata { id: Factory, display_name: "Droid", session_label: "Standard", weekly_label: "Premium", supports_opus: false, supports_credits: true, default_enabled: false, is_primary: false, dashboard_url: Some("https://app.factory.ai"), status_page_url: Some("https://status.factory.ai"), tertiary_label_key: None } + sources=[Auto, OAuth, Web] oauth=false web=true cli=true +Gemini cli=gemini display="Gemini" cookie=Some("aistudio.google.com") color=#AB87EA deprecated=false blocking=None + map=["gemini", "google"] + metadata=ProviderMetadata { id: Gemini, display_name: "Gemini", session_label: "Daily", weekly_label: "Daily", supports_opus: false, supports_credits: false, default_enabled: true, is_primary: false, dashboard_url: Some("https://aistudio.google.com"), status_page_url: Some("https://status.cloud.google.com"), tertiary_label_key: None } + sources=[Auto, Cli] oauth=false web=false cli=true +Antigravity cli=antigravity display="Antigravity" cookie=Some("antigravity.ai") color=#60BA7E deprecated=false blocking=None + map=["agy", "antigravity"] + metadata=ProviderMetadata { id: Antigravity, display_name: "Antigravity", session_label: "Claude", weekly_label: "Gemini Pro", supports_opus: true, supports_credits: false, default_enabled: false, is_primary: false, dashboard_url: None, status_page_url: None, tertiary_label_key: None } + sources=[Auto, Cli] oauth=false web=false cli=true +Copilot cli=copilot display="Copilot" cookie=None color=#A855F7 deprecated=false blocking=None + map=["copilot", "github"] + metadata=ProviderMetadata { id: Copilot, display_name: "GitHub Copilot", session_label: "Premium", weekly_label: "Chat", supports_opus: false, supports_credits: false, default_enabled: true, is_primary: false, dashboard_url: Some("https://github.com/settings/copilot"), status_page_url: Some("https://www.githubstatus.com/"), tertiary_label_key: None } + sources=[Auto, OAuth] oauth=true web=false cli=false +Zai cli=zai display="z.ai" cookie=None color=#E85A6A deprecated=false blocking=None + map=["zai"] + metadata=ProviderMetadata { id: Zai, display_name: "z.ai", session_label: "Tokens", weekly_label: "MCP", supports_opus: false, supports_credits: true, default_enabled: false, is_primary: false, dashboard_url: Some("https://z.ai/manage-apikey/coding-plan/personal/my-plan"), status_page_url: None, tertiary_label_key: None } + sources=[Auto, OAuth] oauth=false web=false cli=false +MiniMax cli=minimax display="MiniMax" cookie=Some("platform.minimax.io") color=#FE603C deprecated=false blocking=None + map=["minimax"] + metadata=ProviderMetadata { id: MiniMax, display_name: "MiniMax", session_label: "Usage", weekly_label: "Monthly", supports_opus: false, supports_credits: true, default_enabled: false, is_primary: false, dashboard_url: Some("https://platform.minimax.io/user-center/payment/coding-plan?cycle_type=3"), status_page_url: None, tertiary_label_key: None } + sources=[Auto, Web, Cli] oauth=false web=true cli=true +Kiro cli=kiro display="Kiro" cookie=Some("kiro.dev") color=#9046FF deprecated=false blocking=None + map=["aws", "kiro"] + metadata=ProviderMetadata { id: Kiro, display_name: "Kiro", session_label: "Session", weekly_label: "Monthly", supports_opus: false, supports_credits: true, default_enabled: false, is_primary: false, dashboard_url: Some("https://kiro.dev/account"), status_page_url: Some("https://health.aws.amazon.com"), tertiary_label_key: None } + sources=[Auto, Cli] oauth=false web=false cli=true +VertexAI cli=vertexai display="Vertex AI" cookie=None color=#4285F4 deprecated=false blocking=None + map=["vertex", "vertexai"] + metadata=ProviderMetadata { id: VertexAI, display_name: "Vertex AI", session_label: "Usage", weekly_label: "Monthly", supports_opus: false, supports_credits: true, default_enabled: false, is_primary: false, dashboard_url: Some("https://console.cloud.google.com/vertex-ai"), status_page_url: Some("https://status.cloud.google.com"), tertiary_label_key: None } + sources=[Auto, Web, Cli] oauth=false web=true cli=true +Augment cli=augment display="Augment" cookie=Some("app.augmentcode.com") color=#1AA049 deprecated=false blocking=None + map=["augment"] + metadata=ProviderMetadata { id: Augment, display_name: "Augment", session_label: "Session", weekly_label: "Monthly", supports_opus: false, supports_credits: true, default_enabled: false, is_primary: false, dashboard_url: Some("https://app.augmentcode.com/account"), status_page_url: Some("https://status.augmentcode.com"), tertiary_label_key: None } + sources=[Auto, Web, Cli] oauth=false web=true cli=true +OpenCode cli=opencode display="OpenCode" cookie=Some("opencode.ai") color=#3B82F6 deprecated=false blocking=None + map=["opencode"] + metadata=ProviderMetadata { id: OpenCode, display_name: "OpenCode", session_label: "5-hour", weekly_label: "Weekly", supports_opus: false, supports_credits: false, default_enabled: false, is_primary: false, dashboard_url: Some("https://opencode.ai"), status_page_url: None, tertiary_label_key: None } + sources=[Auto, Web] oauth=false web=true cli=false +Kimi cli=kimi display="Kimi" cookie=Some("kimi.moonshot.cn") color=#FE603C deprecated=false blocking=Some("kimi-monthly") + map=["kimi"] + metadata=ProviderMetadata { id: Kimi, display_name: "Kimi", session_label: "Weekly", weekly_label: "Rate Limit", supports_opus: false, supports_credits: false, default_enabled: false, is_primary: false, dashboard_url: Some("https://kimi.moonshot.cn"), status_page_url: None, tertiary_label_key: None } + sources=[Auto, Web, OAuth] oauth=true web=true cli=false +KimiK2 cli=kimik2 display="Kimi K2 (removed)" cookie=Some("platform.moonshot.cn") color=#4C00FF deprecated=true blocking=None + map=["kimik2"] + metadata=ProviderMetadata { id: KimiK2, display_name: "Moonshot / Kimi Open Platform", session_label: "Balance", weekly_label: "Cash", supports_opus: false, supports_credits: true, default_enabled: false, is_primary: false, dashboard_url: Some("https://platform.moonshot.ai/console/account"), status_page_url: None, tertiary_label_key: None } + sources=[Auto, Web] oauth=false web=true cli=false +Amp cli=amp display="Amp" cookie=Some("sourcegraph.com") color=#F34E3F deprecated=false blocking=None + map=["amp", "sourcegraph"] + metadata=ProviderMetadata { id: Amp, display_name: "Amp", session_label: "Usage", weekly_label: "Monthly", supports_opus: false, supports_credits: true, default_enabled: false, is_primary: false, dashboard_url: Some("https://ampcode.com/settings/usage"), status_page_url: Some("https://sourcegraphstatus.com"), tertiary_label_key: None } + sources=[Auto, Web, Cli] oauth=false web=true cli=true +Warp cli=warp display="Warp" cookie=None color=#6366F1 deprecated=false blocking=None + map=["warp", "warp-ai", "warp-terminal"] + metadata=ProviderMetadata { id: Warp, display_name: "Warp", session_label: "Credits", weekly_label: "Add-on credits", supports_opus: false, supports_credits: false, default_enabled: false, is_primary: false, dashboard_url: Some("https://docs.warp.dev/reference/cli/api-keys"), status_page_url: None, tertiary_label_key: None } + sources=[Auto, OAuth] oauth=false web=false cli=false +Ollama cli=ollama display="Ollama" cookie=Some("ollama.com") color=#8B95B0 deprecated=false blocking=None + map=["ollama"] + metadata=ProviderMetadata { id: Ollama, display_name: "Ollama", session_label: "Session", weekly_label: "Weekly", supports_opus: false, supports_credits: false, default_enabled: false, is_primary: false, dashboard_url: Some("https://ollama.com/settings"), status_page_url: None, tertiary_label_key: None } + sources=[Auto, Web] oauth=false web=true cli=false +AzureOpenAI cli=azureopenai display="Azure OpenAI" cookie=None color=#0078D4 deprecated=false blocking=None + map=["azureopenai"] + metadata=ProviderMetadata { id: AzureOpenAI, display_name: "Azure OpenAI", session_label: "Deployment", weekly_label: "Status", supports_opus: false, supports_credits: false, default_enabled: false, is_primary: false, dashboard_url: Some("https://ai.azure.com"), status_page_url: Some("https://status.azure.com"), tertiary_label_key: None } + sources=[Auto, Web] oauth=false web=false cli=false +T3Chat cli=t3chat display="T3 Chat" cookie=Some("t3.chat") color=#8B5CF6 deprecated=false blocking=None + map=["t3chat"] + metadata=ProviderMetadata { id: T3Chat, display_name: "T3 Chat", session_label: "Base", weekly_label: "Overage", supports_opus: false, supports_credits: false, default_enabled: false, is_primary: false, dashboard_url: Some("https://t3.chat/settings/customization"), status_page_url: None, tertiary_label_key: None } + sources=[Auto, Web] oauth=false web=true cli=false +OpenRouter cli=openrouter display="OpenRouter" cookie=None color=#6B7280 deprecated=false blocking=None + map=["openrouter", "or"] + metadata=ProviderMetadata { id: OpenRouter, display_name: "OpenRouter", session_label: "Credits", weekly_label: "API key limit", supports_opus: false, supports_credits: true, default_enabled: false, is_primary: false, dashboard_url: Some("https://openrouter.ai/activity"), status_page_url: Some("https://status.openrouter.ai"), tertiary_label_key: None } + sources=[Auto, OAuth] oauth=false web=false cli=false +JetBrains cli=jetbrains display="JetBrains AI" cookie=None color=#FF3399 deprecated=false blocking=None + map=["jetbrains"] + metadata=ProviderMetadata { id: JetBrains, display_name: "JetBrains AI", session_label: "Credits", weekly_label: "Monthly", supports_opus: false, supports_credits: true, default_enabled: false, is_primary: false, dashboard_url: Some("https://www.jetbrains.com/ai/"), status_page_url: None, tertiary_label_key: None } + sources=[Auto, Cli] oauth=false web=false cli=true +Alibaba cli=alibaba display="Alibaba" cookie=Some("modelstudio.console.alibabacloud.com") color=#FF6A00 deprecated=false blocking=None + map=["alibaba", "qianwen", "tongyi"] + metadata=ProviderMetadata { id: Alibaba, display_name: "Alibaba", session_label: "5-Hour", weekly_label: "Weekly", supports_opus: false, supports_credits: false, default_enabled: false, is_primary: false, dashboard_url: Some("https://modelstudio.console.alibabacloud.com"), status_page_url: None, tertiary_label_key: None } + sources=[Auto, Web] oauth=false web=true cli=false +AlibabaTokenPlan cli=alibabatokenplan display="Alibaba Token Plan" cookie=Some("bailian.console.aliyun.com") color=#FF6A00 deprecated=false blocking=None + map=["alibabatokenplan"] + metadata=ProviderMetadata { id: AlibabaTokenPlan, display_name: "Alibaba Token Plan", session_label: "Credits", weekly_label: "Usage", supports_opus: false, supports_credits: false, default_enabled: false, is_primary: false, dashboard_url: Some("https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/token-plan"), status_page_url: Some("https://status.aliyun.com"), tertiary_label_key: None } + sources=[Auto, Cli, Web] oauth=false web=true cli=true +NanoGPT cli=nanogpt display="NanoGPT" cookie=None color=#687FA1 deprecated=false blocking=None + map=["nanogpt"] + metadata=ProviderMetadata { id: NanoGPT, display_name: "NanoGPT", session_label: "Daily", weekly_label: "Monthly", supports_opus: false, supports_credits: false, default_enabled: false, is_primary: false, dashboard_url: Some("https://nano-gpt.com/usage"), status_page_url: None, tertiary_label_key: None } + sources=[Auto, OAuth] oauth=false web=false cli=false +Infini cli=infini display="Infini" cookie=None color=#687FA1 deprecated=false blocking=None + map=["infini", "infini-ai"] + metadata=ProviderMetadata { id: Infini, display_name: "Infini", session_label: "5-Hour", weekly_label: "7-Day", supports_opus: false, supports_credits: false, default_enabled: false, is_primary: false, dashboard_url: Some("https://cloud.infini-ai.com"), status_page_url: None, tertiary_label_key: None } + sources=[Auto, Web] oauth=false web=true cli=false +Perplexity cli=perplexity display="Perplexity" cookie=Some("perplexity.ai") color=#1FB8CD deprecated=false blocking=None + map=["perplexity", "pplx"] + metadata=ProviderMetadata { id: Perplexity, display_name: "Perplexity", session_label: "Credits", weekly_label: "Bonus credits", supports_opus: false, supports_credits: true, default_enabled: false, is_primary: false, dashboard_url: Some("https://www.perplexity.ai/account/usage"), status_page_url: None, tertiary_label_key: None } + sources=[Auto, Web] oauth=false web=true cli=false +Abacus cli=abacus display="Abacus AI" cookie=Some("apps.abacus.ai") color=#814EE8 deprecated=false blocking=None + map=["abacus", "abacus-ai"] + metadata=ProviderMetadata { id: Abacus, display_name: "Abacus AI", session_label: "Credits", weekly_label: "", supports_opus: false, supports_credits: true, default_enabled: false, is_primary: false, dashboard_url: Some("https://apps.abacus.ai/app/billing"), status_page_url: None, tertiary_label_key: None } + sources=[Auto, Web] oauth=false web=true cli=false +Mistral cli=mistral display="Mistral" cookie=Some("admin.mistral.ai") color=#FF5229 deprecated=false blocking=None + map=["mistral", "mistral-ai"] + metadata=ProviderMetadata { id: Mistral, display_name: "Mistral", session_label: "Monthly", weekly_label: "", supports_opus: false, supports_credits: true, default_enabled: false, is_primary: false, dashboard_url: Some("https://admin.mistral.ai/organization/usage"), status_page_url: Some("https://status.mistral.ai"), tertiary_label_key: None } + sources=[Auto, Web] oauth=false web=true cli=false +OpenCodeGo cli=opencodego display="OpenCode Go" cookie=Some("opencode.ai") color=#3B82F6 deprecated=false blocking=None + map=["opencode-go", "opencodego"] + metadata=ProviderMetadata { id: OpenCodeGo, display_name: "OpenCode Go", session_label: "5-hour", weekly_label: "Weekly", supports_opus: true, supports_credits: false, default_enabled: false, is_primary: false, dashboard_url: Some("https://opencode.ai"), status_page_url: None, tertiary_label_key: Some("ProviderMonthly") } + sources=[Auto, Web, Cli] oauth=false web=true cli=true +Kilo cli=kilo display="Kilo" cookie=None color=#5D87FF deprecated=false blocking=None + map=["kilo"] + metadata=ProviderMetadata { id: Kilo, display_name: "Kilo", session_label: "Credits", weekly_label: "Pass", supports_opus: false, supports_credits: true, default_enabled: false, is_primary: false, dashboard_url: Some("https://app.kilo.ai/usage"), status_page_url: None, tertiary_label_key: None } + sources=[Auto, OAuth] oauth=false web=false cli=false +Bedrock cli=bedrock display="AWS Bedrock" cookie=None color=#01A88D deprecated=false blocking=None + map=["aws bedrock", "aws-bedrock", "bedrock"] + metadata=ProviderMetadata { id: Bedrock, display_name: "AWS Bedrock", session_label: "Budget", weekly_label: "Cost", supports_opus: false, supports_credits: false, default_enabled: false, is_primary: false, dashboard_url: Some("https://console.aws.amazon.com/bedrock"), status_page_url: Some("https://health.aws.amazon.com/health/status"), tertiary_label_key: None } + sources=[Auto, OAuth] oauth=false web=false cli=false +Codebuff cli=codebuff display="Codebuff" cookie=None color=#00FF95 deprecated=false blocking=None + map=["codebuff", "manicode"] + metadata=ProviderMetadata { id: Codebuff, display_name: "Codebuff", session_label: "Credits", weekly_label: "Weekly", supports_opus: false, supports_credits: true, default_enabled: false, is_primary: false, dashboard_url: Some("https://www.codebuff.com/usage"), status_page_url: None, tertiary_label_key: None } + sources=[Auto, OAuth] oauth=false web=false cli=false +CodeRabbit cli=coderabbit display="CodeRabbit" cookie=None color=#FF5C35 deprecated=false blocking=None + map=["coderabbit"] + metadata=ProviderMetadata { id: CodeRabbit, display_name: "CodeRabbit", session_label: "Reviews", weekly_label: "Billing", supports_opus: false, supports_credits: false, default_enabled: false, is_primary: false, dashboard_url: Some("https://app.coderabbit.ai"), status_page_url: Some("https://status.coderabbit.ai"), tertiary_label_key: None } + sources=[Auto, Cli] oauth=false web=false cli=true +DeepSeek cli=deepseek display="DeepSeek" cookie=None color=#4D6BFE deprecated=false blocking=None + map=["deep-seek", "deepseek", "ds"] + metadata=ProviderMetadata { id: DeepSeek, display_name: "DeepSeek", session_label: "Balance", weekly_label: "Balance", supports_opus: false, supports_credits: true, default_enabled: false, is_primary: false, dashboard_url: Some("https://platform.deepseek.com/usage"), status_page_url: Some("https://status.deepseek.com"), tertiary_label_key: None } + sources=[Auto, OAuth] oauth=false web=false cli=false +DeepInfra cli=deepinfra display="DeepInfra" cookie=None color=#2A3275 deprecated=false blocking=None + map=["deep-infra", "deepinfra", "di"] + metadata=ProviderMetadata { id: DeepInfra, display_name: "DeepInfra", session_label: "Balance", weekly_label: "Balance", supports_opus: false, supports_credits: false, default_enabled: false, is_primary: false, dashboard_url: Some("https://deepinfra.com/dash"), status_page_url: Some("https://status.deepinfra.com"), tertiary_label_key: None } + sources=[Auto, OAuth] oauth=false web=false cli=false +AiAnd cli=aiand display="ai&" cookie=None color=#E25C2B deprecated=false blocking=None + map=["ai&", "ai-and", "aiand"] + metadata=ProviderMetadata { id: AiAnd, display_name: "ai&", session_label: "Spend", weekly_label: "Spend", supports_opus: false, supports_credits: false, default_enabled: false, is_primary: false, dashboard_url: Some("https://console.aiand.com"), status_page_url: None, tertiary_label_key: None } + sources=[Auto, OAuth] oauth=false web=false cli=false +Windsurf cli=windsurf display="Windsurf" cookie=None color=#22C55E deprecated=false blocking=None + map=["codeium", "windsurf"] + metadata=ProviderMetadata { id: Windsurf, display_name: "Windsurf", session_label: "Daily", weekly_label: "Weekly", supports_opus: false, supports_credits: false, default_enabled: false, is_primary: false, dashboard_url: Some("https://windsurf.com/subscription"), status_page_url: Some("https://status.windsurf.com"), tertiary_label_key: None } + sources=[Auto, Cli] oauth=false web=false cli=true +Manus cli=manus display="Manus" cookie=Some("manus.im") color=#34322D deprecated=false blocking=None + map=["manus"] + metadata=ProviderMetadata { id: Manus, display_name: "Manus", session_label: "Credits", weekly_label: "Refresh", supports_opus: false, supports_credits: true, default_enabled: false, is_primary: false, dashboard_url: Some("https://manus.im"), status_page_url: None, tertiary_label_key: None } + sources=[Auto, Web] oauth=false web=true cli=false +MiMo cli=mimo display="Xiaomi MiMo" cookie=Some("platform.xiaomimimo.com") color=#FF6900 deprecated=false blocking=None + map=["mimo", "xiaomi-mimo", "xiaomimimo"] + metadata=ProviderMetadata { id: MiMo, display_name: "Xiaomi MiMo", session_label: "Tokens", weekly_label: "Balance", supports_opus: false, supports_credits: true, default_enabled: false, is_primary: false, dashboard_url: Some("https://platform.xiaomimimo.com/#/console/balance"), status_page_url: None, tertiary_label_key: None } + sources=[Auto, Web] oauth=false web=true cli=false +Doubao cli=doubao display="Doubao" cookie=None color=#2563EB deprecated=false blocking=None + map=["ark", "doubao", "volcengine"] + metadata=ProviderMetadata { id: Doubao, display_name: "Doubao", session_label: "Requests", weekly_label: "Usage", supports_opus: false, supports_credits: false, default_enabled: false, is_primary: false, dashboard_url: Some("https://console.volcengine.com/ark/region:ark+cn-beijing/usage"), status_page_url: None, tertiary_label_key: None } + sources=[Auto, OAuth, Cli] oauth=false web=false cli=false +CommandCode cli=commandcode display="Command Code" cookie=Some("commandcode.ai") color=#8C4EDD deprecated=false blocking=None + map=["command-code", "commandcode"] + metadata=ProviderMetadata { id: CommandCode, display_name: "Command Code", session_label: "5-hour", weekly_label: "Weekly", supports_opus: false, supports_credits: true, default_enabled: false, is_primary: false, dashboard_url: Some("https://commandcode.ai"), status_page_url: None, tertiary_label_key: None } + sources=[Auto, Web] oauth=false web=true cli=false +StepFun cli=stepfun display="StepFun" cookie=None color=#999999 deprecated=false blocking=None + map=["step-fun", "stepfun"] + metadata=ProviderMetadata { id: StepFun, display_name: "StepFun", session_label: "5-hour", weekly_label: "Weekly", supports_opus: false, supports_credits: false, default_enabled: false, is_primary: false, dashboard_url: Some("https://platform.stepfun.com/dashboard"), status_page_url: None, tertiary_label_key: None } + sources=[Auto, OAuth] oauth=false web=false cli=false +Venice cli=venice display="Venice" cookie=Some("venice.ai") color=#3C8FDD deprecated=false blocking=None + map=["venice"] + metadata=ProviderMetadata { id: Venice, display_name: "Venice", session_label: "Balance", weekly_label: "DIEM", supports_opus: false, supports_credits: true, default_enabled: false, is_primary: false, dashboard_url: Some("https://venice.ai/settings/api"), status_page_url: None, tertiary_label_key: None } + sources=[Auto, OAuth, Web] oauth=false web=true cli=false +OpenAIApi cli=openaiapi display="OpenAI API" cookie=None color=#10A37F deprecated=false blocking=None + map=["openai-api", "openai-balance", "openaiapi"] + metadata=ProviderMetadata { id: OpenAIApi, display_name: "OpenAI", session_label: "Spend", weekly_label: "Requests", supports_opus: false, supports_credits: false, default_enabled: false, is_primary: false, dashboard_url: Some("https://platform.openai.com/usage"), status_page_url: Some("https://status.openai.com"), tertiary_label_key: None } + sources=[Auto, OAuth] oauth=false web=false cli=false +Grok cli=grok display="Grok" cookie=Some("grok.com") color=#111827 deprecated=false blocking=None + map=["grok", "supergrok"] + metadata=ProviderMetadata { id: Grok, display_name: "Grok", session_label: "Credits", weekly_label: "On-demand", supports_opus: false, supports_credits: false, default_enabled: false, is_primary: false, dashboard_url: Some("https://grok.com/?_s=usage"), status_page_url: Some("https://status.x.ai"), tertiary_label_key: None } + sources=[Auto, Cli, OAuth, Web] oauth=false web=true cli=true +ElevenLabs cli=elevenlabs display="ElevenLabs" cookie=None color=#111827 deprecated=false blocking=None + map=["11labs", "eleven-labs", "elevenlabs"] + metadata=ProviderMetadata { id: ElevenLabs, display_name: "ElevenLabs", session_label: "Credits", weekly_label: "Voices", supports_opus: false, supports_credits: true, default_enabled: false, is_primary: false, dashboard_url: Some("https://elevenlabs.io/app/settings/api-keys"), status_page_url: Some("https://status.elevenlabs.io"), tertiary_label_key: None } + sources=[Auto, OAuth] oauth=false web=false cli=false +Deepgram cli=deepgram display="Deepgram" cookie=None color=#13EF93 deprecated=false blocking=None + map=["deepgram", "dg"] + metadata=ProviderMetadata { id: Deepgram, display_name: "Deepgram", session_label: "Requests", weekly_label: "Usage", supports_opus: false, supports_credits: true, default_enabled: false, is_primary: false, dashboard_url: Some("https://console.deepgram.com/usage"), status_page_url: Some("https://status.deepgram.com"), tertiary_label_key: None } + sources=[Auto, OAuth] oauth=false web=false cli=false +Groq cli=groq display="Groq" cookie=None color=#F55036 deprecated=false blocking=None + map=["groq", "groq-cloud", "groqcloud"] + metadata=ProviderMetadata { id: Groq, display_name: "Groq", session_label: "Requests", weekly_label: "Tokens", supports_opus: false, supports_credits: true, default_enabled: false, is_primary: false, dashboard_url: Some("https://console.groq.com/settings/metrics"), status_page_url: Some("https://status.groq.com"), tertiary_label_key: None } + sources=[Auto, OAuth] oauth=false web=false cli=false +HuggingFace cli=huggingface display="Hugging Face" cookie=None color=#FFD21E deprecated=false blocking=None + map=["hf", "hugging-face", "huggingface"] + metadata=ProviderMetadata { id: HuggingFace, display_name: "Hugging Face", session_label: "Credits", weekly_label: "ZeroGPU", supports_opus: false, supports_credits: false, default_enabled: false, is_primary: false, dashboard_url: Some("https://huggingface.co/settings/billing"), status_page_url: Some("https://status.huggingface.co"), tertiary_label_key: None } + sources=[Auto, OAuth] oauth=false web=false cli=false +Helmcode cli=helmcode display="Helmcode" cookie=Some("helmcode.com") color=#4F46E5 deprecated=false blocking=None + map=["helmcode", "nan-builders"] + metadata=ProviderMetadata { id: Helmcode, display_name: "Helmcode", session_label: "Quota", weekly_label: "Quota", supports_opus: false, supports_credits: true, default_enabled: false, is_primary: false, dashboard_url: Some("https://cloud.helmcode.com/dashboard"), status_page_url: None, tertiary_label_key: None } + sources=[Auto, Web] oauth=false web=true cli=false +V0 cli=v0 display="v0" cookie=None color=#111827 deprecated=false blocking=None + map=["v0", "v0-dev"] + metadata=ProviderMetadata { id: V0, display_name: "v0", session_label: "Billing", weekly_label: "Rate limit", supports_opus: false, supports_credits: true, default_enabled: false, is_primary: false, dashboard_url: Some("https://v0.app/settings/billing"), status_page_url: Some("https://www.vercel-status.com/"), tertiary_label_key: None } + sources=[Auto, OAuth] oauth=false web=false cli=false +TypeSafe cli=typesafe display="TypeSafe" cookie=Some("typesafe.ai") color=#2563EB deprecated=false blocking=None + map=["type-safe", "typesafe"] + metadata=ProviderMetadata { id: TypeSafe, display_name: "TypeSafe", session_label: "Balance", weekly_label: "Spend", supports_opus: false, supports_credits: true, default_enabled: false, is_primary: false, dashboard_url: Some("https://console.typesafe.ai/settings/billing"), status_page_url: None, tertiary_label_key: None } + sources=[Auto, Web] oauth=false web=true cli=false +LLMProxy cli=llmproxy display="LLM Proxy" cookie=None color=#4F46E5 deprecated=false blocking=None + map=["llm-proxy", "llmproxy"] + metadata=ProviderMetadata { id: LLMProxy, display_name: "LLM Proxy", session_label: "Quota", weekly_label: "Requests", supports_opus: false, supports_credits: true, default_enabled: false, is_primary: false, dashboard_url: None, status_page_url: None, tertiary_label_key: None } + sources=[Auto, OAuth] oauth=false web=false cli=false +Chutes cli=chutes display="Chutes" cookie=None color=#FF5C35 deprecated=false blocking=None + map=["chutes", "chutes-ai"] + metadata=ProviderMetadata { id: Chutes, display_name: "Chutes", session_label: "4-hour quota", weekly_label: "Monthly quota", supports_opus: false, supports_credits: true, default_enabled: false, is_primary: false, dashboard_url: Some("https://chutes.ai"), status_page_url: None, tertiary_label_key: None } + sources=[Auto, OAuth] oauth=false web=false cli=false +LiteLLM cli=litellm display="LiteLLM" cookie=None color=#0EA5E9 deprecated=false blocking=None + map=["lite-llm", "litellm"] + metadata=ProviderMetadata { id: LiteLLM, display_name: "LiteLLM", session_label: "Personal budget", weekly_label: "Team budget", supports_opus: false, supports_credits: false, default_enabled: false, is_primary: false, dashboard_url: None, status_page_url: None, tertiary_label_key: None } + sources=[Auto, OAuth] oauth=false web=false cli=false +Poe cli=poe display="Poe" cookie=None color=#5D5FEF deprecated=false blocking=None + map=["poe"] + metadata=ProviderMetadata { id: Poe, display_name: "Poe", session_label: "Balance", weekly_label: "Points", supports_opus: false, supports_credits: true, default_enabled: false, is_primary: false, dashboard_url: Some("https://poe.com/settings/subscription"), status_page_url: None, tertiary_label_key: None } + sources=[Auto, OAuth] oauth=false web=false cli=false +Devin cli=devin display="Devin" cookie=None color=#317CFF deprecated=false blocking=None + map=["devin"] + metadata=ProviderMetadata { id: Devin, display_name: "Devin", session_label: "Daily", weekly_label: "Weekly", supports_opus: false, supports_credits: true, default_enabled: false, is_primary: false, dashboard_url: Some("https://app.devin.ai/settings/billing"), status_page_url: None, tertiary_label_key: None } + sources=[Auto, OAuth] oauth=false web=false cli=false +Zed cli=zed display="Zed" cookie=Some("zed.dev") color=#084CCF deprecated=false blocking=None + map=["zed", "zed-ai"] + metadata=ProviderMetadata { id: Zed, display_name: "Zed", session_label: "Edits", weekly_label: "Cycle", supports_opus: false, supports_credits: true, default_enabled: false, is_primary: false, dashboard_url: Some("https://zed.dev/account"), status_page_url: None, tertiary_label_key: None } + sources=[Auto, Web, OAuth] oauth=false web=true cli=false +CrossModel cli=crossmodel display="CrossModel (removed)" cookie=None color=#C084FC deprecated=true blocking=None + map=["cross-model", "crossmodel"] + metadata=ProviderMetadata { id: CrossModel, display_name: "CrossModel", session_label: "Daily cost", weekly_label: "Weekly cost", supports_opus: false, supports_credits: true, default_enabled: false, is_primary: false, dashboard_url: Some("https://crossmodel.ai"), status_page_url: None, tertiary_label_key: None } + sources=[Auto, OAuth] oauth=false web=false cli=false +Qoder cli=qoder display="Qoder" cookie=Some("qoder.com") color=#2563EB deprecated=false blocking=None + map=["qoder"] + metadata=ProviderMetadata { id: Qoder, display_name: "Qoder", session_label: "Credits", weekly_label: "Shared credits", supports_opus: false, supports_credits: true, default_enabled: false, is_primary: false, dashboard_url: Some("https://qoder.com/account/usage"), status_page_url: None, tertiary_label_key: None } + sources=[Auto, Web] oauth=false web=true cli=false +CodeBuddy cli=codebuddy display="CodeBuddy" cookie=Some("codebuddy.cn") color=#0052D9 deprecated=false blocking=None + map=["codebuddy"] + metadata=ProviderMetadata { id: CodeBuddy, display_name: "CodeBuddy", session_label: "Credits", weekly_label: "Packages", supports_opus: false, supports_credits: true, default_enabled: false, is_primary: false, dashboard_url: Some("https://www.codebuddy.cn/profile/plans-usage"), status_page_url: None, tertiary_label_key: None } + sources=[Auto, Web, Cli] oauth=false web=true cli=false +Sakana cli=sakana display="Sakana AI" cookie=Some("console.sakana.ai") color=#0EA5E9 deprecated=false blocking=None + map=["sakana", "sakana-ai"] + metadata=ProviderMetadata { id: Sakana, display_name: "Sakana AI", session_label: "5-hour", weekly_label: "Weekly", supports_opus: false, supports_credits: false, default_enabled: false, is_primary: false, dashboard_url: Some("https://console.sakana.ai/billing"), status_page_url: None, tertiary_label_key: None } + sources=[Auto, Web] oauth=false web=true cli=false +Sub2Api cli=sub2api display="sub2api" cookie=None color=#14B8A6 deprecated=false blocking=None + map=["sub-2-api", "sub2api"] + metadata=ProviderMetadata { id: Sub2Api, display_name: "sub2api", session_label: "Quota", weekly_label: "Weekly quota", supports_opus: false, supports_credits: false, default_enabled: false, is_primary: false, dashboard_url: None, status_page_url: None, tertiary_label_key: None } + sources=[Auto, OAuth] oauth=false web=false cli=false +Wayfinder cli=wayfinder display="Wayfinder" cookie=None color=#14B8A6 deprecated=false blocking=None + map=["wayfinder"] + metadata=ProviderMetadata { id: Wayfinder, display_name: "Wayfinder", session_label: "Gateway", weekly_label: "Savings", supports_opus: false, supports_credits: false, default_enabled: false, is_primary: false, dashboard_url: None, status_page_url: None, tertiary_label_key: None } + sources=[Auto] oauth=false web=false cli=false +ZenMux cli=zenmux display="ZenMux" cookie=None color=#6C5CE7 deprecated=false blocking=None + map=["zenmux"] + metadata=ProviderMetadata { id: ZenMux, display_name: "ZenMux", session_label: "5-hour quota", weekly_label: "Weekly quota", supports_opus: false, supports_credits: false, default_enabled: false, is_primary: false, dashboard_url: Some("https://zenmux.ai/platform/management"), status_page_url: None, tertiary_label_key: None } + sources=[Auto, OAuth] oauth=false web=false cli=false +ClinePass cli=clinepass display="ClinePass" cookie=None color=#5487C8 deprecated=false blocking=None + map=["clinepass"] + metadata=ProviderMetadata { id: ClinePass, display_name: "ClinePass", session_label: "5-hour", weekly_label: "Weekly", supports_opus: true, supports_credits: false, default_enabled: false, is_primary: false, dashboard_url: Some("https://app.cline.bot/dashboard/subscription?personal=true"), status_page_url: None, tertiary_label_key: None } + sources=[Auto, OAuth] oauth=false web=false cli=false +LongCat cli=longcat display="LongCat" cookie=Some("longcat.chat") color=#29E154 deprecated=false blocking=None + map=["longcat"] + metadata=ProviderMetadata { id: LongCat, display_name: "LongCat", session_label: "Quota", weekly_label: "Fuel Pack", supports_opus: false, supports_credits: false, default_enabled: false, is_primary: false, dashboard_url: Some("https://longcat.chat/platform/"), status_page_url: None, tertiary_label_key: None } + sources=[Auto, Web] oauth=false web=false cli=false +Neuralwatt cli=neuralwatt display="Neuralwatt" cookie=None color=#D55934 deprecated=false blocking=None + map=["neuralwatt"] + metadata=ProviderMetadata { id: Neuralwatt, display_name: "Neuralwatt", session_label: "Subscription", weekly_label: "Key allowance", supports_opus: false, supports_credits: false, default_enabled: false, is_primary: false, dashboard_url: Some("https://portal.neuralwatt.com/dashboard"), status_page_url: None, tertiary_label_key: None } + sources=[Auto, OAuth] oauth=false web=false cli=false +ZoomMate cli=zoommate display="ZoomMate" cookie=Some("zoommate.zoom.us") color=#0B5CFF deprecated=false blocking=None + map=["zoommate"] + metadata=ProviderMetadata { id: ZoomMate, display_name: "ZoomMate", session_label: "Credits", weekly_label: "Credits", supports_opus: false, supports_credits: true, default_enabled: false, is_primary: false, dashboard_url: Some("https://zoommate.zoom.us/#/?settings=credit-usage"), status_page_url: Some("https://www.zoomstatus.com/"), tertiary_label_key: None } + sources=[Auto, Web] oauth=false web=true cli=false +QwenCloud cli=qwen-cloud display="Qwen Cloud" cookie=Some("qwencloud.com") color=#615CED deprecated=false blocking=None + map=["qwen", "qwen-cloud", "qwen-token-plan", "qwencloud"] + metadata=ProviderMetadata { id: QwenCloud, display_name: "Qwen Cloud", session_label: "5-hour", weekly_label: "Weekly", supports_opus: false, supports_credits: false, default_enabled: false, is_primary: false, dashboard_url: Some("https://home.qwencloud.com/billing/subscription/token-plan-individual"), status_page_url: Some("https://status.alibabacloud.com"), tertiary_label_key: None } + sources=[Auto, Web] oauth=false web=true cli=false +Notion cli=notion display="Notion AI" cookie=Some("app.notion.com") color=#337EA9 deprecated=false blocking=None + map=["notion", "notion-ai", "notionai"] + metadata=ProviderMetadata { id: Notion, display_name: "Notion AI", session_label: "Rolling", weekly_label: "Monthly", supports_opus: false, supports_credits: false, default_enabled: false, is_primary: false, dashboard_url: Some("https://app.notion.com/"), status_page_url: Some("https://status.notion.so"), tertiary_label_key: None } + sources=[Auto, Web] oauth=false web=true cli=false +Xai cli=xai display="xAI" cookie=None color=#8E8E93 deprecated=false blocking=None + map=["x.ai", "xai"] + metadata=ProviderMetadata { id: Xai, display_name: "xAI", session_label: "Spend", weekly_label: "Spend", supports_opus: false, supports_credits: false, default_enabled: false, is_primary: false, dashboard_url: Some("https://console.x.ai"), status_page_url: Some("https://status.x.ai"), tertiary_label_key: None } + sources=[Auto, OAuth] oauth=false web=false cli=false +Fireworks cli=fireworks display="Fireworks" cookie=None color=#F25B1C deprecated=false blocking=None + map=["fireworks", "fireworks-ai", "fw"] + metadata=ProviderMetadata { id: Fireworks, display_name: "Fireworks", session_label: "Spend", weekly_label: "Spend", supports_opus: false, supports_credits: false, default_enabled: false, is_primary: false, dashboard_url: Some("https://app.fireworks.ai"), status_page_url: None, tertiary_label_key: None } + sources=[Auto, OAuth] oauth=false web=false cli=false +AtlasCloud cli=atlascloud display="Atlas Cloud" cookie=None color=#5975F5 deprecated=false blocking=None + map=["atlascloud"] + metadata=ProviderMetadata { id: AtlasCloud, display_name: "Atlas Cloud", session_label: "Balance", weekly_label: "Balance", supports_opus: false, supports_credits: false, default_enabled: false, is_primary: false, dashboard_url: Some("https://www.atlascloud.ai/console"), status_page_url: None, tertiary_label_key: None } + sources=[Auto, OAuth] oauth=false web=false cli=false +Meta cli=meta display="Meta" cookie=None color=#0467DF deprecated=false blocking=None + map=["meta", "meta muse spark", "meta-spark", "metaspark", "muse spark", "muse-spark", "musespark"] + metadata=ProviderMetadata { id: Meta, display_name: "Meta", session_label: "Status", weekly_label: "Models", supports_opus: false, supports_credits: false, default_enabled: false, is_primary: false, dashboard_url: Some("https://dev.meta.ai/docs"), status_page_url: None, tertiary_label_key: None } + sources=[Auto, OAuth] oauth=false web=false cli=false +Muse cli=muse display="Muse Code" cookie=Some("dev.meta.ai") color=#0668E1 deprecated=false blocking=None + map=["muse", "muse code", "muse-code"] + metadata=ProviderMetadata { id: Muse, display_name: "Muse Code", session_label: "5 hours", weekly_label: "Weekly", supports_opus: false, supports_credits: false, default_enabled: false, is_primary: false, dashboard_url: Some("https://dev.meta.ai"), status_page_url: None, tertiary_label_key: None } + sources=[Auto, OAuth] oauth=true web=false cli=false +Replicate cli=replicate display="Replicate" cookie=Some("replicate.com") color=#000000 deprecated=false blocking=None + map=["replicate"] + metadata=ProviderMetadata { id: Replicate, display_name: "Replicate", session_label: "Spend", weekly_label: "Spend", supports_opus: false, supports_credits: true, default_enabled: false, is_primary: false, dashboard_url: Some("https://replicate.com/account/billing"), status_page_url: None, tertiary_label_key: None } + sources=[Auto, Web] oauth=false web=true cli=false +Nous cli=nous display="Nous Portal" cookie=None color=#D6A55C deprecated=false blocking=None + map=["hermes", "nous", "nous portal", "nous-portal"] + metadata=ProviderMetadata { id: Nous, display_name: "Nous Portal", session_label: "Monthly credits", weekly_label: "Weekly", supports_opus: false, supports_credits: false, default_enabled: false, is_primary: false, dashboard_url: Some("https://portal.nousresearch.com/usage"), status_page_url: None, tertiary_label_key: None } + sources=[Auto, OAuth] oauth=true web=false cli=false +Hyper cli=hyper display="Charm Hyper" cookie=Some("hyper.charm.land") color=#FF60FF deprecated=false blocking=None + map=["hyper"] + metadata=ProviderMetadata { id: Hyper, display_name: "Charm Hyper", session_label: "Balance", weekly_label: "Balance", supports_opus: false, supports_credits: false, default_enabled: false, is_primary: false, dashboard_url: Some("https://hyper.charm.land"), status_page_url: None, tertiary_label_key: None } + sources=[Auto, Web, OAuth] oauth=false web=true cli=false +GitKraken cli=gitkraken display="GitKraken AI" cookie=None color=#179287 deprecated=false blocking=None + map=["gitkraken"] + metadata=ProviderMetadata { id: GitKraken, display_name: "GitKraken AI", session_label: "Personal", weekly_label: "Shared pool", supports_opus: false, supports_credits: false, default_enabled: false, is_primary: false, dashboard_url: Some("https://gitkraken.dev/account#ai-usage"), status_page_url: None, tertiary_label_key: None } + sources=[Auto, OAuth] oauth=false web=false cli=false +Bifrost cli=bifrost display="Bifrost" cookie=None color=#33C09E deprecated=false blocking=None + map=["bifrost"] + metadata=ProviderMetadata { id: Bifrost, display_name: "Bifrost", session_label: "Budget", weekly_label: "Spend", supports_opus: false, supports_credits: true, default_enabled: false, is_primary: false, dashboard_url: None, status_page_url: None, tertiary_label_key: None } + sources=[Auto, OAuth] oauth=false web=false cli=false +Aixy cli=aixy display="Aixy" cookie=None color=#123650 deprecated=false blocking=None + map=["aixy"] + metadata=ProviderMetadata { id: Aixy, display_name: "Aixy", session_label: "Budget", weekly_label: "Secondary budget", supports_opus: false, supports_credits: false, default_enabled: false, is_primary: false, dashboard_url: Some("https://dash.aixy-gateway.com"), status_page_url: None, tertiary_label_key: None } + sources=[Auto, OAuth] oauth=false web=false cli=false +LLMMan cli=llmman display="llmman" cookie=None color=#6CC5B0 deprecated=false blocking=None + map=["llmman"] + metadata=ProviderMetadata { id: LLMMan, display_name: "llmman", session_label: "Memory", weekly_label: "Models", supports_opus: false, supports_credits: false, default_enabled: false, is_primary: false, dashboard_url: Some("http://127.0.0.1:17434"), status_page_url: None, tertiary_label_key: None } + sources=[Auto, OAuth] oauth=false web=false cli=false +DevPass cli=devpass display="DevPass" cookie=None color=#2563EB deprecated=false blocking=None + map=["devpass"] + metadata=ProviderMetadata { id: DevPass, display_name: "DevPass", session_label: "Plan credits", weekly_label: "Premium weekly", supports_opus: false, supports_credits: false, default_enabled: false, is_primary: false, dashboard_url: Some("https://devpass.llmgateway.io/dashboard"), status_page_url: None, tertiary_label_key: None } + sources=[Auto, OAuth] oauth=false web=false cli=false +XKiro cli=xkiro display="xKiro" cookie=None color=#52C99B deprecated=false blocking=None + map=["x-kiro", "xkiro"] + metadata=ProviderMetadata { id: XKiro, display_name: "xKiro", session_label: "Daily free tokens", weekly_label: "Weekly", supports_opus: false, supports_credits: false, default_enabled: false, is_primary: false, dashboard_url: Some("https://xkiro.com"), status_page_url: None, tertiary_label_key: None } + sources=[Auto, OAuth] oauth=false web=false cli=false +Raycast cli=raycast display="Raycast" cookie=Some("www.raycast.com") color=#FF6363 deprecated=false blocking=None + map=["raycast"] + metadata=ProviderMetadata { id: Raycast, display_name: "Raycast", session_label: "Credits", weekly_label: "Plan", supports_opus: false, supports_credits: false, default_enabled: false, is_primary: false, dashboard_url: Some("https://www.raycast.com/settings"), status_page_url: None, tertiary_label_key: None } + sources=[Auto, Web] oauth=false web=true cli=false +Vercel cli=vercel display="Vercel AI Gateway" cookie=None color=#737373 deprecated=false blocking=None + map=["ai gateway", "ai-gateway", "vercel", "vercel ai gateway", "vercel-ai-gateway"] + metadata=ProviderMetadata { id: Vercel, display_name: "Vercel AI Gateway", session_label: "Balance", weekly_label: "Balance", supports_opus: false, supports_credits: false, default_enabled: false, is_primary: false, dashboard_url: Some("https://vercel.com/d?to=%2F%5Bteam%5D%2F%7E%2Fai-gateway"), status_page_url: None, tertiary_label_key: None } + sources=[Auto, OAuth] oauth=false web=false cli=false diff --git a/rust/src/core/provider_registry_snapshot_tests.rs b/rust/src/core/provider_registry_snapshot_tests.rs new file mode 100644 index 00000000000..1f0f0a0f1cf --- /dev/null +++ b/rust/src/core/provider_registry_snapshot_tests.rs @@ -0,0 +1,79 @@ +//! Pins every observable per-provider registry value: `all()` order, names, +//! cookie domains, brand colors, metadata, source support, and both alias +//! sets. Regenerate the dump with `CODEXBAR_UPDATE_REGISTRY_SNAPSHOT=1` only +//! for an intended change. + +use std::fmt::Write as _; + +use super::*; + +const REGISTRY_SNAPSHOT: &str = include_str!("provider_registry_snapshot.txt"); +const ALIASES_SNAPSHOT: &str = include_str!("provider_aliases_snapshot.txt"); + +fn registry_dump() -> String { + let map = cli_name_map(); + let mut out = String::new(); + for &id in ProviderId::all() { + let provider = instantiate_provider(id); + let mut map_keys: Vec<&str> = map + .iter() + .filter(|(_, mapped)| **mapped == id) + .map(|(key, _)| *key) + .collect(); + map_keys.sort_unstable(); + writeln!( + out, + "{id:?} cli={} display={:?} cookie={:?} color={} deprecated={} blocking={:?}", + id.cli_name(), + id.display_name(), + id.cookie_domain(), + brand_color(id), + id.is_deprecated(), + id.blocking_quota_window_id(), + ) + .unwrap(); + writeln!(out, " map={map_keys:?}").unwrap(); + writeln!(out, " metadata={:?}", provider.metadata()).unwrap(); + writeln!( + out, + " sources={:?} oauth={} web={} cli={}", + provider.available_sources(), + provider.supports_oauth(), + provider.supports_web(), + provider.supports_cli(), + ) + .unwrap(); + } + out +} + +#[test] +fn registry_matches_snapshot() { + let actual = registry_dump(); + if std::env::var_os("CODEXBAR_UPDATE_REGISTRY_SNAPSHOT").is_some() { + let path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/src/core/provider_registry_snapshot.txt" + ); + std::fs::write(path, &actual).unwrap(); + } + assert_eq!(actual, REGISTRY_SNAPSHOT.replace('\r', "")); +} + +fn alias_rows() -> Vec<(&'static str, &'static str)> { + ALIASES_SNAPSHOT + .lines() + .filter(|line| !line.is_empty()) + .map(|line| line.split_once(" => ").expect("alias row")) + .collect() +} + +#[test] +fn every_snapshot_alias_resolves_to_its_provider() { + let rows = alias_rows(); + assert_eq!(rows.len(), 241); + for (alias, id) in rows { + let resolved = ProviderId::from_cli_name(alias).map(|id| format!("{id:?}")); + assert_eq!(resolved.as_deref(), Some(id), "alias {alias:?}"); + } +} From a49f8e4920dc284d7793e1ba922b00af11e7df91 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:11:51 +0700 Subject: [PATCH 02/40] Move provider registry values into a spec table --- rust/src/core/provider.rs | 1842 ----------------- rust/src/core/provider/context.rs | 153 ++ rust/src/core/provider/error.rs | 142 ++ rust/src/core/provider/id.rs | 724 +++++++ rust/src/core/provider/mod.rs | 13 + rust/src/core/provider/spec.rs | 674 ++++++ rust/src/core/provider/traits.rs | 214 ++ .../transport_tests.rs} | 0 rust/src/providers/abacus/mod.rs | 24 +- rust/src/providers/aiand/mod.rs | 20 +- rust/src/providers/aixy/mod.rs | 22 +- rust/src/providers/alibaba/mod.rs | 28 +- rust/src/providers/alibabatokenplan/mod.rs | 30 +- rust/src/providers/antigravity/mod.rs | 28 +- rust/src/providers/atlascloud/mod.rs | 20 +- rust/src/providers/augment/mod.rs | 28 +- rust/src/providers/azureopenai.rs | 28 +- rust/src/providers/bedrock/mod.rs | 21 +- rust/src/providers/bifrost/mod.rs | 20 +- rust/src/providers/chutes/mod.rs | 22 +- rust/src/providers/claude/mod.rs | 20 +- rust/src/providers/clinepass/mod.rs | 22 +- rust/src/providers/codebuddy/mod.rs | 22 +- rust/src/providers/coderabbit/mod.rs | 26 +- rust/src/providers/codex/mod.rs | 20 +- rust/src/providers/commandcode/mod.rs | 20 +- rust/src/providers/copilot/mod.rs | 21 +- rust/src/providers/cursor/mod.rs | 22 +- rust/src/providers/deepinfra/mod.rs | 21 +- rust/src/providers/deepseek/mod.rs | 20 +- rust/src/providers/devin/mod.rs | 20 +- rust/src/providers/devpass/mod.rs | 20 +- rust/src/providers/doubao/mod.rs | 23 +- rust/src/providers/elevenlabs/mod.rs | 22 +- rust/src/providers/factory/mod.rs | 28 +- rust/src/providers/fireworks/mod.rs | 20 +- rust/src/providers/gemini/mod.rs | 22 +- rust/src/providers/gitkraken/mod.rs | 26 +- rust/src/providers/grok/mod.rs | 20 +- rust/src/providers/helmcode.rs | 19 +- rust/src/providers/huggingface/mod.rs | 20 +- rust/src/providers/hyper/mod.rs | 20 +- rust/src/providers/infini.rs | 22 +- rust/src/providers/jetbrains/mod.rs | 28 +- rust/src/providers/kimi/mod.rs | 28 +- rust/src/providers/kimik2/mod.rs | 26 +- rust/src/providers/kiro/mod.rs | 28 +- rust/src/providers/litellm/mod.rs | 21 +- rust/src/providers/llmman/endpoint.rs | 2 +- rust/src/providers/llmman/mod.rs | 23 +- rust/src/providers/llmproxy/mod.rs | 20 +- rust/src/providers/longcat/mod.rs | 22 +- rust/src/providers/manus/mod.rs | 20 +- rust/src/providers/manus/tests.rs | 1 - rust/src/providers/meta/mod.rs | 22 +- rust/src/providers/minimax/mod.rs | 28 +- rust/src/providers/mistral/mod.rs | 20 +- rust/src/providers/muse/mod.rs | 20 +- rust/src/providers/neuralwatt/mod.rs | 20 +- rust/src/providers/notion/mod.rs | 31 +- rust/src/providers/nous/mod.rs | 20 +- rust/src/providers/ollama/mod.rs | 26 +- rust/src/providers/openaiapi/mod.rs | 20 +- rust/src/providers/opencode/mod.rs | 22 +- rust/src/providers/opencodego/mod.rs | 20 +- rust/src/providers/openrouter/mod.rs | 26 +- rust/src/providers/openrouter/tests.rs | 2 +- rust/src/providers/perplexity/mod.rs | 22 +- rust/src/providers/pi.rs | 28 +- rust/src/providers/qoder/mod.rs | 22 +- rust/src/providers/qwencloud/mod.rs | 34 +- rust/src/providers/raycast/mod.rs | 23 +- rust/src/providers/replicate/mod.rs | 23 +- rust/src/providers/sakana/mod.rs | 24 +- rust/src/providers/stepfun/mod.rs | 22 +- rust/src/providers/sub2api/mod.rs | 20 +- rust/src/providers/t3chat.rs | 28 +- rust/src/providers/typesafe.rs | 21 +- rust/src/providers/v0.rs | 19 +- rust/src/providers/venice/mod.rs | 20 +- rust/src/providers/vercel/mod.rs | 20 +- rust/src/providers/vertexai/mod.rs | 28 +- rust/src/providers/warp/mod.rs | 28 +- rust/src/providers/windsurf/mod.rs | 28 +- rust/src/providers/xai/mod.rs | 20 +- rust/src/providers/xkiro/mod.rs | 21 +- rust/src/providers/zai/mod.rs | 26 +- rust/src/providers/zed/mod.rs | 20 +- rust/src/providers/zenmux/mod.rs | 21 +- rust/src/providers/zoommate/mod.rs | 22 +- 90 files changed, 2089 insertions(+), 3496 deletions(-) delete mode 100755 rust/src/core/provider.rs create mode 100644 rust/src/core/provider/context.rs create mode 100644 rust/src/core/provider/error.rs create mode 100755 rust/src/core/provider/id.rs create mode 100644 rust/src/core/provider/mod.rs create mode 100644 rust/src/core/provider/spec.rs create mode 100644 rust/src/core/provider/traits.rs rename rust/src/core/{provider_transport_tests.rs => provider/transport_tests.rs} (100%) diff --git a/rust/src/core/provider.rs b/rust/src/core/provider.rs deleted file mode 100755 index c228451efa8..00000000000 --- a/rust/src/core/provider.rs +++ /dev/null @@ -1,1842 +0,0 @@ -//! Provider trait - defines the interface all providers must implement - -use async_trait::async_trait; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use thiserror::Error; - -use super::ProviderFetchResult; -use super::last_good_owner::LastGoodOwner; -use super::provider_state::ProviderStateKind; - -/// Unique identifier for a provider -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum ProviderId { - Codex, - Claude, - Pi, - Cursor, - Factory, - Gemini, - Antigravity, - Copilot, - Zai, - MiniMax, - Kiro, - VertexAI, - Augment, - OpenCode, - Kimi, - KimiK2, - Amp, - Warp, - Ollama, - AzureOpenAI, - T3Chat, - OpenRouter, - JetBrains, - Alibaba, - AlibabaTokenPlan, - NanoGPT, - Infini, - Perplexity, - Abacus, - Mistral, - OpenCodeGo, - Kilo, - Bedrock, - Codebuff, - CodeRabbit, - DeepSeek, - DeepInfra, - AiAnd, - Windsurf, - Manus, - MiMo, - Doubao, - CommandCode, - StepFun, - Venice, - OpenAIApi, - Grok, - ElevenLabs, - Deepgram, - Groq, - HuggingFace, - Helmcode, - V0, - TypeSafe, - LLMProxy, - Chutes, - LiteLLM, - Poe, - Devin, - Zed, - CrossModel, - Qoder, - CodeBuddy, - Sakana, - Sub2Api, - Wayfinder, - ZenMux, - ClinePass, - LongCat, - Neuralwatt, - ZoomMate, - QwenCloud, - Notion, - Xai, - Fireworks, - AtlasCloud, - #[serde(alias = "metaspark")] - Meta, - Muse, - Replicate, - Nous, - Hyper, - GitKraken, - Bifrost, - Aixy, - LLMMan, - DevPass, - XKiro, - Raycast, - Vercel, -} - -impl ProviderId { - /// Get all provider IDs - pub fn all() -> &'static [ProviderId] { - &[ - ProviderId::Codex, - ProviderId::Claude, - ProviderId::Pi, - ProviderId::Cursor, - ProviderId::Factory, - ProviderId::Gemini, - ProviderId::Antigravity, - ProviderId::Copilot, - ProviderId::Zai, - ProviderId::MiniMax, - ProviderId::Kiro, - ProviderId::VertexAI, - ProviderId::Augment, - ProviderId::OpenCode, - ProviderId::Kimi, - ProviderId::KimiK2, - ProviderId::Amp, - ProviderId::Warp, - ProviderId::Ollama, - ProviderId::AzureOpenAI, - ProviderId::T3Chat, - ProviderId::OpenRouter, - ProviderId::JetBrains, - ProviderId::Alibaba, - ProviderId::AlibabaTokenPlan, - ProviderId::NanoGPT, - ProviderId::Infini, - ProviderId::Perplexity, - ProviderId::Abacus, - ProviderId::Mistral, - ProviderId::OpenCodeGo, - ProviderId::Kilo, - ProviderId::Bedrock, - ProviderId::Codebuff, - ProviderId::CodeRabbit, - ProviderId::DeepSeek, - ProviderId::DeepInfra, - ProviderId::AiAnd, - ProviderId::Windsurf, - ProviderId::Manus, - ProviderId::MiMo, - ProviderId::Doubao, - ProviderId::CommandCode, - ProviderId::StepFun, - ProviderId::Venice, - ProviderId::OpenAIApi, - ProviderId::Grok, - ProviderId::ElevenLabs, - ProviderId::Deepgram, - ProviderId::Groq, - ProviderId::HuggingFace, - ProviderId::Helmcode, - ProviderId::V0, - ProviderId::TypeSafe, - ProviderId::LLMProxy, - ProviderId::Chutes, - ProviderId::LiteLLM, - ProviderId::Poe, - ProviderId::Devin, - ProviderId::Zed, - ProviderId::CrossModel, - ProviderId::Qoder, - ProviderId::CodeBuddy, - ProviderId::Sakana, - ProviderId::Sub2Api, - ProviderId::Wayfinder, - ProviderId::ZenMux, - ProviderId::ClinePass, - ProviderId::LongCat, - ProviderId::Neuralwatt, - ProviderId::ZoomMate, - ProviderId::QwenCloud, - ProviderId::Notion, - ProviderId::Xai, - ProviderId::Fireworks, - ProviderId::AtlasCloud, - ProviderId::Meta, - ProviderId::Muse, - ProviderId::Replicate, - ProviderId::Nous, - ProviderId::Hyper, - ProviderId::GitKraken, - ProviderId::Bifrost, - ProviderId::Aixy, - ProviderId::LLMMan, - ProviderId::DevPass, - ProviderId::XKiro, - ProviderId::Raycast, - ProviderId::Vercel, - ] - } - - /// Get the CLI name for this provider - pub fn cli_name(&self) -> &'static str { - match self { - ProviderId::Codex => "codex", - ProviderId::Claude => "claude", - ProviderId::Pi => "pi", - ProviderId::Cursor => "cursor", - ProviderId::Factory => "factory", - ProviderId::Gemini => "gemini", - ProviderId::Antigravity => "antigravity", - ProviderId::Copilot => "copilot", - ProviderId::Zai => "zai", - ProviderId::MiniMax => "minimax", - ProviderId::Kiro => "kiro", - ProviderId::VertexAI => "vertexai", - ProviderId::Augment => "augment", - ProviderId::OpenCode => "opencode", - ProviderId::Kimi => "kimi", - ProviderId::KimiK2 => "kimik2", - ProviderId::Amp => "amp", - ProviderId::Warp => "warp", - ProviderId::Ollama => "ollama", - ProviderId::AzureOpenAI => "azureopenai", - ProviderId::T3Chat => "t3chat", - ProviderId::OpenRouter => "openrouter", - ProviderId::JetBrains => "jetbrains", - ProviderId::Alibaba => "alibaba", - ProviderId::AlibabaTokenPlan => "alibabatokenplan", - ProviderId::NanoGPT => "nanogpt", - ProviderId::Infini => "infini", - ProviderId::Perplexity => "perplexity", - ProviderId::Abacus => "abacus", - ProviderId::Mistral => "mistral", - ProviderId::OpenCodeGo => "opencodego", - ProviderId::Kilo => "kilo", - ProviderId::Bedrock => "bedrock", - ProviderId::Codebuff => "codebuff", - ProviderId::CodeRabbit => "coderabbit", - ProviderId::DeepSeek => "deepseek", - ProviderId::DeepInfra => "deepinfra", - ProviderId::Fireworks => "fireworks", - ProviderId::Meta => "meta", - ProviderId::Muse => "muse", - ProviderId::Nous => "nous", - ProviderId::Hyper => "hyper", - ProviderId::GitKraken => "gitkraken", - ProviderId::Bifrost => "bifrost", - ProviderId::Aixy => "aixy", - ProviderId::LLMMan => "llmman", - ProviderId::DevPass => "devpass", - ProviderId::XKiro => "xkiro", - ProviderId::Raycast => "raycast", - ProviderId::Vercel => "vercel", - ProviderId::AiAnd => "aiand", - ProviderId::Windsurf => "windsurf", - ProviderId::Manus => "manus", - ProviderId::MiMo => "mimo", - ProviderId::Doubao => "doubao", - ProviderId::CommandCode => "commandcode", - ProviderId::StepFun => "stepfun", - ProviderId::Venice => "venice", - ProviderId::OpenAIApi => "openaiapi", - ProviderId::Grok => "grok", - ProviderId::ElevenLabs => "elevenlabs", - ProviderId::Deepgram => "deepgram", - ProviderId::Groq => "groq", - ProviderId::HuggingFace => "huggingface", - ProviderId::Helmcode => "helmcode", - ProviderId::V0 => "v0", - ProviderId::TypeSafe => "typesafe", - ProviderId::LLMProxy => "llmproxy", - ProviderId::Chutes => "chutes", - ProviderId::LiteLLM => "litellm", - ProviderId::Poe => "poe", - ProviderId::Devin => "devin", - ProviderId::Zed => "zed", - ProviderId::CrossModel => "crossmodel", - ProviderId::Qoder => "qoder", - ProviderId::CodeBuddy => "codebuddy", - ProviderId::Sakana => "sakana", - ProviderId::Sub2Api => "sub2api", - ProviderId::Wayfinder => "wayfinder", - ProviderId::ZenMux => "zenmux", - ProviderId::ClinePass => "clinepass", - ProviderId::LongCat => "longcat", - ProviderId::Neuralwatt => "neuralwatt", - ProviderId::ZoomMate => "zoommate", - ProviderId::QwenCloud => "qwen-cloud", - ProviderId::Notion => "notion", - ProviderId::Xai => "xai", - ProviderId::Replicate => "replicate", - ProviderId::AtlasCloud => "atlascloud", - } - } - - /// Get the display name for this provider - pub fn display_name(&self) -> &'static str { - match self { - ProviderId::Codex => "Codex", - ProviderId::Claude => "Claude", - ProviderId::Pi => "Pi", - ProviderId::Cursor => "Cursor", - ProviderId::Factory => "Factory", - ProviderId::Gemini => "Gemini", - ProviderId::Antigravity => "Antigravity", - ProviderId::Copilot => "Copilot", - ProviderId::Zai => "z.ai", - ProviderId::MiniMax => "MiniMax", - ProviderId::Kiro => "Kiro", - ProviderId::VertexAI => "Vertex AI", - ProviderId::Augment => "Augment", - ProviderId::OpenCode => "OpenCode", - ProviderId::Kimi => "Kimi", - // Soft-removed (upstream #2254); still resolvable via CLI for legacy configs. - ProviderId::KimiK2 => "Kimi K2 (removed)", - ProviderId::Amp => "Amp", - ProviderId::Warp => "Warp", - ProviderId::Ollama => "Ollama", - ProviderId::AzureOpenAI => "Azure OpenAI", - ProviderId::T3Chat => "T3 Chat", - ProviderId::OpenRouter => "OpenRouter", - ProviderId::JetBrains => "JetBrains AI", - ProviderId::Alibaba => "Alibaba", - ProviderId::AlibabaTokenPlan => "Alibaba Token Plan", - ProviderId::NanoGPT => "NanoGPT", - ProviderId::Infini => "Infini", - ProviderId::Perplexity => "Perplexity", - ProviderId::Abacus => "Abacus AI", - ProviderId::Mistral => "Mistral", - ProviderId::OpenCodeGo => "OpenCode Go", - ProviderId::Kilo => "Kilo", - ProviderId::Bedrock => "AWS Bedrock", - ProviderId::Codebuff => "Codebuff", - ProviderId::CodeRabbit => "CodeRabbit", - ProviderId::DeepSeek => "DeepSeek", - ProviderId::DeepInfra => "DeepInfra", - ProviderId::Fireworks => "Fireworks", - ProviderId::Meta => "Meta", - ProviderId::Muse => "Muse Code", - ProviderId::Nous => "Nous Portal", - ProviderId::Hyper => "Charm Hyper", - ProviderId::GitKraken => "GitKraken AI", - ProviderId::Bifrost => "Bifrost", - ProviderId::Aixy => "Aixy", - ProviderId::LLMMan => "llmman", - ProviderId::DevPass => "DevPass", - ProviderId::XKiro => "xKiro", - ProviderId::Raycast => "Raycast", - ProviderId::Vercel => "Vercel AI Gateway", - ProviderId::AiAnd => "ai&", - ProviderId::Windsurf => "Windsurf", - ProviderId::Manus => "Manus", - ProviderId::MiMo => "Xiaomi MiMo", - ProviderId::Doubao => "Doubao", - ProviderId::CommandCode => "Command Code", - ProviderId::StepFun => "StepFun", - ProviderId::Venice => "Venice", - ProviderId::OpenAIApi => "OpenAI API", - ProviderId::Grok => "Grok", - ProviderId::ElevenLabs => "ElevenLabs", - ProviderId::Deepgram => "Deepgram", - ProviderId::Groq => "Groq", - ProviderId::HuggingFace => "Hugging Face", - ProviderId::Helmcode => "Helmcode", - ProviderId::V0 => "v0", - ProviderId::TypeSafe => "TypeSafe", - ProviderId::LLMProxy => "LLM Proxy", - ProviderId::Chutes => "Chutes", - ProviderId::LiteLLM => "LiteLLM", - ProviderId::Poe => "Poe", - ProviderId::Devin => "Devin", - ProviderId::Zed => "Zed", - // Soft-removed (upstream #2254); still resolvable via CLI for legacy configs. - ProviderId::CrossModel => "CrossModel (removed)", - ProviderId::Qoder => "Qoder", - ProviderId::CodeBuddy => "CodeBuddy", - ProviderId::Sakana => "Sakana AI", - ProviderId::Sub2Api => "sub2api", - ProviderId::Wayfinder => "Wayfinder", - ProviderId::ZenMux => "ZenMux", - ProviderId::ClinePass => "ClinePass", - ProviderId::LongCat => "LongCat", - ProviderId::Neuralwatt => "Neuralwatt", - ProviderId::ZoomMate => "ZoomMate", - ProviderId::QwenCloud => "Qwen Cloud", - ProviderId::Notion => "Notion AI", - ProviderId::Xai => "xAI", - ProviderId::Replicate => "Replicate", - ProviderId::AtlasCloud => "Atlas Cloud", - } - } - - /// Get the cookie domain for this provider. - /// Returns the domain used for cookie extraction, or None if the provider - /// doesn't use cookies for authentication. - pub fn cookie_domain(&self) -> Option<&'static str> { - match self { - ProviderId::Claude => Some("claude.ai"), - ProviderId::Cursor => Some("cursor.com"), - ProviderId::Factory => Some("app.factory.ai"), - ProviderId::Codex => Some("chatgpt.com"), - ProviderId::Pi => None, - ProviderId::Gemini => Some("aistudio.google.com"), - ProviderId::Kiro => Some("kiro.dev"), - ProviderId::Kimi => Some("kimi.moonshot.cn"), - ProviderId::KimiK2 => Some("platform.moonshot.cn"), - ProviderId::MiniMax => Some("platform.minimax.io"), - ProviderId::OpenCode => Some("opencode.ai"), - ProviderId::Augment => Some("app.augmentcode.com"), - ProviderId::Amp => Some("sourcegraph.com"), - ProviderId::Antigravity => Some("antigravity.ai"), - ProviderId::Alibaba => { - Some(crate::providers::AlibabaRegion::Singapore.primary_cookie_domain()) - } - ProviderId::AlibabaTokenPlan => Some("bailian.console.aliyun.com"), - ProviderId::Ollama => Some("ollama.com"), - ProviderId::T3Chat => Some("t3.chat"), - ProviderId::Perplexity => Some("perplexity.ai"), - ProviderId::Abacus => Some("apps.abacus.ai"), - ProviderId::Mistral => Some("admin.mistral.ai"), - ProviderId::OpenCodeGo => Some("opencode.ai"), - ProviderId::Manus => Some("manus.im"), - ProviderId::MiMo => Some("platform.xiaomimimo.com"), - ProviderId::CommandCode => Some("commandcode.ai"), - ProviderId::Grok => Some("grok.com"), - ProviderId::Venice => Some("venice.ai"), - ProviderId::Qoder => Some("qoder.com"), - ProviderId::CodeBuddy => Some("codebuddy.cn"), - ProviderId::Sakana => Some("console.sakana.ai"), - ProviderId::LongCat => Some("longcat.chat"), - ProviderId::Replicate => Some("replicate.com"), - ProviderId::AtlasCloud => None, - ProviderId::Raycast => Some("www.raycast.com"), - // Token-based providers (don't use cookies) - ProviderId::Copilot => None, - ProviderId::Zai => None, - ProviderId::VertexAI => None, - ProviderId::JetBrains => None, - ProviderId::Warp => None, - ProviderId::AzureOpenAI => None, - ProviderId::OpenRouter => None, - ProviderId::NanoGPT => None, - ProviderId::Infini => None, - ProviderId::Kilo => None, - ProviderId::Bedrock => None, - ProviderId::Codebuff => None, - ProviderId::CodeRabbit => None, - ProviderId::DeepSeek => None, - ProviderId::DeepInfra => None, - ProviderId::Fireworks => None, - ProviderId::Meta => None, - ProviderId::Muse => Some("dev.meta.ai"), - ProviderId::Nous => None, - ProviderId::Hyper => Some("hyper.charm.land"), - ProviderId::GitKraken => None, - ProviderId::Bifrost => None, - ProviderId::Aixy => None, - ProviderId::LLMMan => None, - ProviderId::DevPass => None, - ProviderId::XKiro => None, - ProviderId::Vercel => None, - ProviderId::AiAnd => None, - ProviderId::Windsurf => None, - ProviderId::Doubao => None, - ProviderId::StepFun => None, - ProviderId::OpenAIApi => None, - ProviderId::ElevenLabs => None, - ProviderId::Deepgram => None, - ProviderId::Groq => None, - ProviderId::HuggingFace => None, - ProviderId::Helmcode => Some("helmcode.com"), - ProviderId::TypeSafe => Some("typesafe.ai"), - ProviderId::V0 => None, - ProviderId::LLMProxy => None, - ProviderId::Chutes => None, - ProviderId::LiteLLM => None, - ProviderId::Poe => None, - ProviderId::Devin => None, - ProviderId::Zed => Some("zed.dev"), - ProviderId::CrossModel => None, - ProviderId::Sub2Api => None, - ProviderId::Wayfinder => None, - ProviderId::ZenMux => None, - ProviderId::ClinePass => None, - ProviderId::Neuralwatt => None, - ProviderId::ZoomMate => Some("zoommate.zoom.us"), - ProviderId::QwenCloud => Some("qwencloud.com"), - ProviderId::Notion => Some("app.notion.com"), - ProviderId::Xai => None, - } - } - - /// Id of the longer pool that blocks this provider's shorter windows once - /// exhausted (upstream 0.69.0 #4091). See [`super::BlockedWindows`]. - pub fn blocking_quota_window_id(&self) -> Option<&'static str> { - match self { - ProviderId::Kimi => Some(crate::providers::kimi::MONTHLY_WINDOW_ID), - _ => None, - } - } - - /// Parse from CLI name string - pub fn from_cli_name(name: &str) -> Option { - match name.to_lowercase().as_str() { - "codex" | "openai" => Some(ProviderId::Codex), - "claude" | "anthropic" => Some(ProviderId::Claude), - "pi" | "pi-mono" => Some(ProviderId::Pi), - "cursor" => Some(ProviderId::Cursor), - "factory" | "droid" => Some(ProviderId::Factory), - "gemini" | "google" => Some(ProviderId::Gemini), - "antigravity" | "agy" => Some(ProviderId::Antigravity), - "copilot" | "github" => Some(ProviderId::Copilot), - "zai" | "z.ai" => Some(ProviderId::Zai), - "minimax" => Some(ProviderId::MiniMax), - "kiro" | "aws" => Some(ProviderId::Kiro), - "vertexai" | "vertex" | "vertex ai" => Some(ProviderId::VertexAI), - "augment" => Some(ProviderId::Augment), - "opencode" => Some(ProviderId::OpenCode), - "kimi" | "moonshot" => Some(ProviderId::Kimi), - "kimik2" | "kimi-k2" | "kimi k2" | "k2" | "kimi k2 (removed)" => { - Some(ProviderId::KimiK2) - } - "amp" | "sourcegraph" => Some(ProviderId::Amp), - "warp" | "warp-ai" | "warp-terminal" => Some(ProviderId::Warp), - "ollama" => Some(ProviderId::Ollama), - "azureopenai" | "azure-openai" | "azure openai" => Some(ProviderId::AzureOpenAI), - "t3chat" | "t3-chat" | "t3 chat" => Some(ProviderId::T3Chat), - "openrouter" | "or" => Some(ProviderId::OpenRouter), - "jetbrains" | "jetbrains-ai" | "jetbrains ai" | "intellij" => { - Some(ProviderId::JetBrains) - } - "alibaba" | "tongyi" | "qianwen" => Some(ProviderId::Alibaba), - "alibabatokenplan" | "alibaba-token-plan" | "alibaba token plan" | "alibaba-token" - | "bailian-token-plan" => Some(ProviderId::AlibabaTokenPlan), - "nanogpt" | "nano-gpt" => Some(ProviderId::NanoGPT), - "infini" | "infini-ai" => Some(ProviderId::Infini), - "perplexity" | "pplx" => Some(ProviderId::Perplexity), - "abacus" | "abacus ai" | "abacus-ai" => Some(ProviderId::Abacus), - "mistral" | "mistral-ai" | "mistral ai" => Some(ProviderId::Mistral), - "opencodego" | "opencode-go" | "opencode go" => Some(ProviderId::OpenCodeGo), - "kilo" => Some(ProviderId::Kilo), - "bedrock" | "aws-bedrock" | "aws bedrock" => Some(ProviderId::Bedrock), - "codebuff" | "manicode" => Some(ProviderId::Codebuff), - "coderabbit" | "code-rabbit" | "code rabbit" => Some(ProviderId::CodeRabbit), - "deepseek" | "deep-seek" | "ds" => Some(ProviderId::DeepSeek), - "deepinfra" | "deep-infra" | "di" => Some(ProviderId::DeepInfra), - "fireworks" | "fireworks-ai" | "fw" => Some(ProviderId::Fireworks), - "muse" | "muse-code" | "muse code" => Some(ProviderId::Muse), - "nous" | "nous-portal" | "nous portal" | "hermes" => Some(ProviderId::Nous), - "hyper" | "charm-hyper" | "charm hyper" => Some(ProviderId::Hyper), - "gitkraken" | "gitkraken-ai" | "gitkraken ai" => Some(ProviderId::GitKraken), - "bifrost" | "bifrost-gateway" | "bifrost gateway" => Some(ProviderId::Bifrost), - "aixy" | "aixy-gateway" | "aixy gateway" => Some(ProviderId::Aixy), - "llmman" => Some(ProviderId::LLMMan), - "devpass" => Some(ProviderId::DevPass), - "xkiro" | "x-kiro" => Some(ProviderId::XKiro), - "vercel" | "vercel-ai-gateway" | "vercel ai gateway" | "ai-gateway" | "ai gateway" => { - Some(ProviderId::Vercel) - } - "meta" | "metaspark" | "meta-spark" | "muse-spark" | "musespark" | "muse spark" - | "meta muse spark" => Some(ProviderId::Meta), - "aiand" | "ai&" | "ai-and" | "ai and" => Some(ProviderId::AiAnd), - "windsurf" | "codeium" => Some(ProviderId::Windsurf), - "manus" => Some(ProviderId::Manus), - "mimo" | "xiaomi" | "xiaomimimo" | "xiaomi-mimo" | "xiaomi mimo" => { - Some(ProviderId::MiMo) - } - "doubao" | "ark" | "volcengine" => Some(ProviderId::Doubao), - "commandcode" | "command-code" | "command code" => Some(ProviderId::CommandCode), - "stepfun" | "step-fun" | "step fun" => Some(ProviderId::StepFun), - "venice" => Some(ProviderId::Venice), - "openaiapi" | "openai-api" | "openai api" | "openai-balance" => { - Some(ProviderId::OpenAIApi) - } - "grok" | "supergrok" | "super-grok" => Some(ProviderId::Grok), - "xai" | "x.ai" | "x-ai" => Some(ProviderId::Xai), - "elevenlabs" | "eleven-labs" | "11labs" => Some(ProviderId::ElevenLabs), - "deepgram" | "dg" => Some(ProviderId::Deepgram), - "groq" | "groqcloud" | "groq-cloud" | "groq cloud" => Some(ProviderId::Groq), - "huggingface" | "hugging-face" | "hugging face" | "hf" => Some(ProviderId::HuggingFace), - "helmcode" | "nan-builders" | "nan builders" => Some(ProviderId::Helmcode), - "v0" | "v0-dev" | "v0.dev" => Some(ProviderId::V0), - "typesafe" | "type-safe" | "type safe" => Some(ProviderId::TypeSafe), - "llmproxy" | "llm-proxy" | "llm proxy" => Some(ProviderId::LLMProxy), - "chutes" | "chutes-ai" | "chutes ai" => Some(ProviderId::Chutes), - "litellm" | "lite-llm" | "lite llm" => Some(ProviderId::LiteLLM), - "poe" => Some(ProviderId::Poe), - "devin" => Some(ProviderId::Devin), - "zed" | "zed-ai" => Some(ProviderId::Zed), - "crossmodel" | "cross-model" | "cross model" | "crossmodel (removed)" => { - Some(ProviderId::CrossModel) - } - "qoder" => Some(ProviderId::Qoder), - "codebuddy" | "code-buddy" | "codebuddy-cn" | "codebuddycn" | "腾讯codebuddy" => { - Some(ProviderId::CodeBuddy) - } - "sakana" | "sakana-ai" | "sakana ai" => Some(ProviderId::Sakana), - "sub2api" | "sub-2-api" | "sub 2 api" => Some(ProviderId::Sub2Api), - "wayfinder" => Some(ProviderId::Wayfinder), - "zenmux" | "zen-mux" => Some(ProviderId::ZenMux), - "clinepass" | "cline-pass" | "cline" => Some(ProviderId::ClinePass), - "longcat" | "long-cat" | "lc" => Some(ProviderId::LongCat), - "neuralwatt" | "neural-watt" | "nw" | "neural" => Some(ProviderId::Neuralwatt), - "qwen-cloud" | "qwencloud" | "qwen" | "qwen-token-plan" | "qwen cloud" => { - Some(ProviderId::QwenCloud) - } - "zoommate" | "zoom-mate" | "zoom mate" => Some(ProviderId::ZoomMate), - "notion" | "notion-ai" | "notionai" | "notion ai" => Some(ProviderId::Notion), - "replicate" | "r8" => Some(ProviderId::Replicate), - "atlascloud" | "atlas-cloud" | "atlas cloud" => Some(ProviderId::AtlasCloud), - "raycast" | "raycast-ai" => Some(ProviderId::Raycast), - _ => None, - } - } - - /// Soft-removed providers (upstream #2254: Kimi K2 + CrossModel). - /// - /// Modules and CLI resolution stay so existing configs and `--provider kimik2` - /// still work. Settings UI hides them unless already enabled in settings. - pub fn is_deprecated(&self) -> bool { - matches!(self, ProviderId::KimiK2 | ProviderId::CrossModel) - } -} - -impl std::fmt::Display for ProviderId { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.cli_name()) - } -} - -/// Data source mode for fetching usage -/// -/// Conventions for providers whose transport is not an OAuth flow: they -/// reuse `OAuth` as the persisted token/API lane (an API key, hub token, or -/// other credential), because the source enum is shared with the settings -/// UI. `Auto` may dispatch to that lane as well. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum SourceMode { - /// Automatically choose the best available source - #[default] - Auto, - /// Use OAuth API; also the token/API lane for non-OAuth providers - OAuth, - /// Use web API with browser cookies - Web, - /// Use CLI probe - Cli, -} - -impl SourceMode { - pub fn parse(s: &str) -> Option { - match s.to_lowercase().as_str() { - "auto" => Some(SourceMode::Auto), - "oauth" => Some(SourceMode::OAuth), - "web" => Some(SourceMode::Web), - "cli" => Some(SourceMode::Cli), - _ => None, - } - } -} - -/// Metadata about a provider -#[derive(Debug, Clone)] -pub struct ProviderMetadata { - pub id: ProviderId, - pub display_name: &'static str, - pub session_label: &'static str, - pub weekly_label: &'static str, - pub supports_opus: bool, - pub supports_credits: bool, - pub default_enabled: bool, - pub is_primary: bool, - pub dashboard_url: Option<&'static str>, - pub status_page_url: Option<&'static str>, - /// Locale key shown for the provider's tertiary metric lane in settings - /// pickers when the lane carries a semantic identity beyond "Tertiary" - /// (upstream F5). `None` renders the generic tertiary label. - pub tertiary_label_key: Option<&'static str>, -} - -/// Errors that can occur when fetching provider data -#[derive(Debug, Error)] -pub enum ProviderError { - #[error("Provider not installed: {0}")] - NotInstalled(String), - - #[error("Authentication required")] - AuthRequired, - - #[error("OAuth error: {0}")] - OAuth(String), - - #[error("Transient OAuth error: {0}")] - OAuthTransient(String), - - #[error("OAuth session expired: {0}")] - OAuthExpired(String), - - #[error("OAuth token revoked: {0}")] - OAuthRevoked(String), - - #[error("Parse error: {0}")] - Parse(String), - - #[error("Network error: {0}")] - Network(#[from] reqwest::Error), - - #[error("Timeout")] - Timeout, - - #[error("Source mode '{0:?}' not supported for this provider")] - UnsupportedSource(SourceMode), - - #[error("No cookies available for web API")] - NoCookies, - - /// Usage needs a provider web session that only a browser sign-in can - /// restore. `sign_in_url` is the page to open; CLI JSON error rows carry - /// it as `signInUrl` next to `errorKind: "browserSignInRequired"`. - #[error("{message}")] - BrowserSignInRequired { - message: String, - sign_in_url: String, - }, - - #[error("{0}")] - Other(String), - - /// A transport failure tagged with the session that produced it, so the - /// shell can retain a cached snapshot only for the same session. Build it - /// with [`ProviderError::with_failure_owner`]. - #[error("{source}")] - OwnedTransport { - owner: Option, - source: Box, - }, -} - -impl ProviderError { - /// Return true only for transport failures safe for last-good retention. - pub fn is_transport_failure(&self) -> bool { - match self { - ProviderError::Network(error) => matches!( - classify_reqwest_error(error), - ReqwestFailureClass::Timeout | ReqwestFailureClass::Connect - ), - ProviderError::Timeout => true, - ProviderError::OwnedTransport { source, .. } => source.is_transport_failure(), - _ => false, - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ReqwestFailureClass { - Timeout, - Connect, - Terminal, -} - -fn classify_reqwest_error(error: &reqwest::Error) -> ReqwestFailureClass { - // A response-body failure can also carry the timeout flag when the peer - // stalls while the body is being read. It is terminal for the snapshot, - // because retaining last-good data would hide a truncated response. - if error.is_body() || error.is_decode() { - return ReqwestFailureClass::Terminal; - } - if error.is_timeout() { - return ReqwestFailureClass::Timeout; - } - if !error.is_connect() { - return ReqwestFailureClass::Terminal; - } - - // A connect classification alone is too broad: it also covers protocol - // and TLS-handshake failures. Retain only a typed transient socket error. - if has_io_error_kind(error, std::io::ErrorKind::ConnectionRefused) { - return ReqwestFailureClass::Connect; - } - - ReqwestFailureClass::Terminal -} - -fn has_io_error_kind(error: &reqwest::Error, kind: std::io::ErrorKind) -> bool { - fn contains_kind( - source: Option<&(dyn std::error::Error + 'static)>, - kind: std::io::ErrorKind, - ) -> bool { - let Some(current) = source else { - return false; - }; - if let Some(io_error) = current.downcast_ref::() { - if io_error.kind() == kind { - return true; - } - let mut nested = io_error.get_ref(); - while let Some(inner) = nested { - let Some(inner_io) = inner.downcast_ref::() else { - break; - }; - if inner_io.kind() == kind { - return true; - } - nested = inner_io.get_ref(); - } - } - contains_kind(std::error::Error::source(current), kind) - } - - contains_kind(std::error::Error::source(error), kind) -} - -/// Context passed to provider fetch operations -#[derive(Debug, Clone)] -pub struct FetchContext { - /// Source mode to use - pub source_mode: SourceMode, - - /// Whether to include credits/cost data - pub include_credits: bool, - - /// Timeout for web operations in seconds - pub web_timeout: u64, - - /// Whether to enable verbose logging - pub verbose: bool, - - /// Manual cookie header (for testing) - pub manual_cookie_header: Option, - - /// The cookie source is manual and no cookie is stored, or (for providers - /// whose cookie source only scopes the session) cookies are off. The - /// provider decides what this means; Replicate fails closed instead of - /// importing a browser account the user did not select, and Charm Hyper - /// skips its session lane. - pub manual_cookie_missing: bool, - - /// API key for providers that require authentication - pub api_key: Option, - - /// Type of the explicitly selected labeled token account, if any. - pub token_account_kind: Option, - - /// A selected account is an identity boundary: providers must not retry - /// another ambient credential or account after its credential fails. - pub token_account_isolated: bool, - - /// Optional provider workspace/project scope from persisted settings. - pub workspace_id: Option, - - /// Optional Copilot seat AI-credit allowance supplied by the app settings. - /// The provider keeps the credit counter unknown when this is absent. - pub seat_credit_entitlement: Option, - - /// Optional provider API/web region from persisted settings. - pub api_region: Option, - - /// Optional provider gateway URL, used by local gateway-backed providers. - pub gateway_url: Option, - - /// When true, Auto mode prefers web before local (token-account scope, - /// manual cookie source, etc.). Workspace overrides are checked separately. - pub auto_prefer_web: bool, - - /// The user chose automatic browser cookie import for a provider whose - /// cookies only enrich its API usage (`Provider::cookies_only_enrich_usage`). - /// False for the default manual-without-cookie state and for Off, so those - /// never read a browser. - pub browser_cookie_import: bool, - - /// Foreground usage reads (`codexbar usage`, `codexbar serve`) set this so - /// providers join slow optional enrichment with the full optional-item - /// timeout budget measured from task start; background/UI polls keep the - /// short join grace instead (upstream 0.48.0 - /// `requiresOptionalUsageCompleteness`, #2583). - pub requires_optional_usage_completeness: bool, - - /// The user opted in to the requested provider's optional detail breakdown - /// (LiteLLM model activity, Claude workspace spend). Scoped to the provider - /// being fetched; false everywhere the setting is not consulted. - pub optional_details_enabled: bool, -} - -impl Default for FetchContext { - fn default() -> Self { - Self { - source_mode: SourceMode::Auto, - include_credits: true, - web_timeout: 60, - verbose: false, - manual_cookie_header: None, - manual_cookie_missing: false, - api_key: None, - token_account_kind: None, - token_account_isolated: false, - workspace_id: None, - seat_credit_entitlement: None, - api_region: None, - gateway_url: None, - auto_prefer_web: false, - browser_cookie_import: false, - requires_optional_usage_completeness: false, - optional_details_enabled: false, - } - } -} - -/// How the shell should treat a failed refresh when a prior good snapshot exists. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum LastGoodFailurePolicy { - Replace, - Preserve, - PreserveOnce, - PreserveOnceThenSurface, -} - -/// How the shell should treat a manual cookie source with no cookie present. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ManualEmptyCookiePolicy { - /// Remap to the shell's generic browser-cookie attempt. - Fallback, - /// Keep `SourceMode::Web` with no header so the provider fails closed - /// instead of importing a browser account the user did not select. - FailClosedWeb, -} - -/// Trait that all providers must implement -#[allow( - clippy::double_must_use, - reason = "async-trait marks its boxed futures #[must_use]" -)] -#[async_trait] -pub trait Provider: Send + Sync { - /// Get the provider's unique identifier - fn id(&self) -> ProviderId; - - /// Get provider metadata - fn metadata(&self) -> &ProviderMetadata; - - /// Fetch usage data from this provider - async fn fetch_usage(&self, ctx: &FetchContext) -> Result; - - /// Get the available source modes for this provider - fn available_sources(&self) -> Vec { - vec![SourceMode::Auto] - } - - /// Check if OAuth is supported - fn supports_oauth(&self) -> bool { - false - } - - /// Check if web API (cookies) is supported - fn supports_web(&self) -> bool { - false - } - - /// Check if CLI probe is supported - fn supports_cli(&self) -> bool { - false - } - - /// Detect the version of the CLI tool (if applicable) - fn detect_version(&self) -> Option { - None - } - - /// Whether an explicitly selected manual cookie outranks a token-account override. - fn manual_cookie_precedes_token_account(&self) -> bool { - false - } - - /// Whether a selected token account leaves an `Auto` usage source as `Auto`. - /// - /// The shell normally maps a token account with an environment override to - /// the OAuth (API-only) lane. A provider whose `Auto` source adds an - /// optional extra on top of the API credential, such as the Hugging Face - /// prepaid wallet, opts in so the account token does not silently drop it. - /// An explicitly chosen non-Auto usage source still maps to OAuth. - fn token_account_preserves_auto_source(&self) -> bool { - false - } - - /// How the shell treats a manual cookie source with no cookie present. - /// - /// `Fallback` lets the shell remap to its generic browser-cookie attempt. - /// `FailClosedWeb` keeps `SourceMode::Web` without any header, so the - /// provider fails closed instead of importing a browser account the user - /// did not select. - fn manual_empty_cookie_policy(&self) -> ManualEmptyCookiePolicy { - ManualEmptyCookiePolicy::Fallback - } - - /// Whether Automatic metric selection should prefer an exhausted quota lane. - fn automatic_metric_prioritizes_exhausted_window(&self) -> bool { - true - } - - /// Whether an explicit (non-Automatic) metric preference whose lane is - /// unavailable should still fall through to Automatic selection. Providers - /// with Automatic-only fallback lanes (seat credits) override this to - /// `false` so an explicit choice is never silently replaced by fallback - /// progress. - fn explicit_preference_falls_through_to_automatic(&self) -> bool { - true - } - - /// Whether Automatic metric selection is a dead end when the primary lane - /// is informational and no secondary lane exists. Providers with - /// Automatic-only fallback lanes (seat credits) or a named extra lane that - /// can be the only reported quota (Kimi's monthly pool) override this to - /// `false` so that lane can still fill in. - fn automatic_metric_missing_core_is_terminal(&self) -> bool { - true - } - - /// Whether Automatic metric selection shows the secondary lane whenever - /// neither core lane is exhausted, instead of the fuller lane. An - /// exhausted primary or secondary lane still wins, primary first. - fn automatic_metric_prefers_secondary_window(&self) -> bool { - false - } - - /// Id of the extra rate window that holds this provider's monthly plan - /// allowance. The `MonthlyPlan` menu bar metric selects that window - /// (upstream 0.70.0 #4072). `None` means the provider offers no Monthly - /// Plan metric. - fn monthly_plan_window_id(&self) -> Option<&'static str> { - None - } - - /// Label for the primary lane in the menu bar metric picker when the - /// lane is not a session window (upstream `menuBarLayoutPrimaryLabel`). - /// `None` keeps the generic session label. - fn menu_bar_primary_label(&self) -> Option<&'static str> { - None - } - - /// Whether cookies only enrich an API-backed result instead of being a - /// usage source of their own. The shell then keeps the configured usage - /// source, forwards a manual cookie as-is, and reads a browser only when - /// the cookie source is Automatic (`FetchContext::browser_cookie_import`); - /// the default manual state with no cookie never triggers a browser read. - fn cookies_only_enrich_usage(&self) -> bool { - false - } - - /// Whether browser-cookie discovery/recovery is owned by the provider. - fn owns_browser_cookie_resolution(&self) -> bool { - false - } - - /// Whether the web lane is used only when the usage source is explicitly - /// `web`. A cookie domain otherwise lets the shell turn Auto into Web - /// (manual cookie present or browser import), which would replace a - /// provider's default non-web credential. - fn web_is_opt_in(&self) -> bool { - false - } - - /// Whether the cookie source only scopes the browser session the - /// provider may use, leaving the selected usage source in charge of - /// routing. - /// - /// When true, the shell keeps the usage source for every cookie source: - /// `off` and a `manual` source without a stored cookie pass no header and - /// set [`FetchContext::manual_cookie_missing`], so the provider skips the - /// session and Auto can still use an API key. Otherwise the shell remaps - /// the source mode from the cookie source. - fn cookie_source_scopes_session_only(&self) -> bool { - false - } - - /// How the shell should treat a failed refresh when a prior good snapshot exists. - fn last_good_failure_policy(&self, _error: &str) -> LastGoodFailurePolicy { - LastGoodFailurePolicy::Replace - } - - /// Whether this provider can safely retain its last good snapshot on a - /// classified transport failure. - fn retains_last_good_on_transport_failure(&self) -> bool { - false - } - - /// Typed variant used before an error is sanitized for the frontend. - /// - /// Providers that need message-based distinctions can keep overriding the - /// string method. Transport retention is selected by the provider - /// capability and the typed error classification above. - fn last_good_failure_policy_for_error(&self, error: &ProviderError) -> LastGoodFailurePolicy { - if matches!(error, ProviderError::OAuthTransient(_)) { - return LastGoodFailurePolicy::Preserve; - } - if self.retains_last_good_on_transport_failure() && error.is_transport_failure() { - return LastGoodFailurePolicy::Preserve; - } - self.last_good_failure_policy(&error.to_string()) - } - - /// Presentation-safe availability state for a refresh error. The default - /// maps `ProviderError` variants, treating `NotInstalled` as a missing - /// credential (most providers raise it for a missing API key or auth - /// file). Override only when a variant carries provider-specific meaning - /// that differs — e.g. a local language-server probe or CLI/binary - /// presence check whose "not installed" means the runtime is simply - /// not running; prefer a message-contains guard when the provider also - /// raises credential-flavored `NotInstalled` errors. - fn error_state_kind(&self, error: &ProviderError) -> ProviderStateKind { - error.state_kind() - } -} - -/// Get the CLI name map for argument parsing -pub fn cli_name_map() -> HashMap<&'static str, ProviderId> { - let mut map = HashMap::new(); - for id in ProviderId::all() { - map.insert(id.cli_name(), *id); - } - // Add aliases - map.insert("openai", ProviderId::Codex); - map.insert("anthropic", ProviderId::Claude); - map.insert("codebuff", ProviderId::Codebuff); - map.insert("manicode", ProviderId::Codebuff); - map.insert("deep-seek", ProviderId::DeepSeek); - map.insert("ds", ProviderId::DeepSeek); - map.insert("deep-infra", ProviderId::DeepInfra); - map.insert("di", ProviderId::DeepInfra); - map.insert("fireworks-ai", ProviderId::Fireworks); - map.insert("fw", ProviderId::Fireworks); - map.insert("muse-code", ProviderId::Muse); - map.insert("muse code", ProviderId::Muse); - map.insert("nous-portal", ProviderId::Nous); - map.insert("nous portal", ProviderId::Nous); - map.insert("hermes", ProviderId::Nous); - map.insert("x-kiro", ProviderId::XKiro); - map.insert("vercel-ai-gateway", ProviderId::Vercel); - map.insert("vercel ai gateway", ProviderId::Vercel); - map.insert("ai-gateway", ProviderId::Vercel); - map.insert("ai gateway", ProviderId::Vercel); - map.insert("metaspark", ProviderId::Meta); - map.insert("meta-spark", ProviderId::Meta); - map.insert("muse-spark", ProviderId::Meta); - map.insert("musespark", ProviderId::Meta); - map.insert("muse spark", ProviderId::Meta); - map.insert("meta muse spark", ProviderId::Meta); - map.insert("ai&", ProviderId::AiAnd); - map.insert("ai-and", ProviderId::AiAnd); - map.insert("codeium", ProviderId::Windsurf); - map.insert("google", ProviderId::Gemini); - map.insert("agy", ProviderId::Antigravity); - map.insert("github", ProviderId::Copilot); - map.insert("aws", ProviderId::Kiro); - map.insert("vertex", ProviderId::VertexAI); - map.insert("sourcegraph", ProviderId::Amp); - map.insert("warp-ai", ProviderId::Warp); - map.insert("warp-terminal", ProviderId::Warp); - map.insert("or", ProviderId::OpenRouter); - map.insert("aws-bedrock", ProviderId::Bedrock); - map.insert("aws bedrock", ProviderId::Bedrock); - map.insert("tongyi", ProviderId::Alibaba); - map.insert("qianwen", ProviderId::Alibaba); - map.insert("qwen", ProviderId::QwenCloud); - map.insert("qwencloud", ProviderId::QwenCloud); - map.insert("qwen-token-plan", ProviderId::QwenCloud); - map.insert("infini-ai", ProviderId::Infini); - map.insert("pplx", ProviderId::Perplexity); - map.insert("abacus-ai", ProviderId::Abacus); - map.insert("mistral-ai", ProviderId::Mistral); - map.insert("opencode-go", ProviderId::OpenCodeGo); - map.insert("xiaomimimo", ProviderId::MiMo); - map.insert("xiaomi-mimo", ProviderId::MiMo); - map.insert("ark", ProviderId::Doubao); - map.insert("volcengine", ProviderId::Doubao); - map.insert("command-code", ProviderId::CommandCode); - map.insert("step-fun", ProviderId::StepFun); - map.insert("openai-api", ProviderId::OpenAIApi); - map.insert("openai-balance", ProviderId::OpenAIApi); - map.insert("xai", ProviderId::Xai); - map.insert("x.ai", ProviderId::Xai); - map.insert("supergrok", ProviderId::Grok); - map.insert("eleven-labs", ProviderId::ElevenLabs); - map.insert("11labs", ProviderId::ElevenLabs); - map.insert("dg", ProviderId::Deepgram); - map.insert("groqcloud", ProviderId::Groq); - map.insert("groq-cloud", ProviderId::Groq); - map.insert("hugging-face", ProviderId::HuggingFace); - map.insert("hf", ProviderId::HuggingFace); - map.insert("nan-builders", ProviderId::Helmcode); - map.insert("v0-dev", ProviderId::V0); - map.insert("type-safe", ProviderId::TypeSafe); - map.insert("chutes-ai", ProviderId::Chutes); - map.insert("lite-llm", ProviderId::LiteLLM); - map.insert("zed-ai", ProviderId::Zed); - map.insert("llm-proxy", ProviderId::LLMProxy); - map.insert("cross-model", ProviderId::CrossModel); - map.insert("sakana-ai", ProviderId::Sakana); - map.insert("sub-2-api", ProviderId::Sub2Api); - map.insert("notion-ai", ProviderId::Notion); - map.insert("notionai", ProviderId::Notion); - map -} - -/// The shipped brand color (hex) for a provider, mirroring the frontend -/// `PROVIDER_ICON_REGISTRY` in `providerIcons.ts` and the `--chart-` -/// tokens in `styles.css` (`providerIcons.test.ts` checks both). Used as the -/// default accent color before any per-provider override (#2972). -/// -/// Upstream 0.70.0 audited the palette (#4075, `docs/provider-palette.md`). -/// The 16 accents it adopted are pinned in the tests below; changing one -/// must not materially reduce contrast on white or `#222222`. -pub fn brand_color(id: ProviderId) -> &'static str { - match id { - ProviderId::Codex => "#49A3B0", - ProviderId::Claude => "#CC7C5E", - ProviderId::Pi => "#7C3AED", - ProviderId::Cursor => "#F54E00", - ProviderId::Factory => "#FF6B35", - ProviderId::Gemini => "#AB87EA", - ProviderId::Antigravity => "#60BA7E", - ProviderId::Copilot => "#A855F7", - ProviderId::Zai => "#E85A6A", - ProviderId::MiniMax => "#FE603C", - ProviderId::Kiro => "#9046FF", - ProviderId::VertexAI => "#4285F4", - ProviderId::Augment => "#1AA049", - ProviderId::OpenCode => "#3B82F6", - ProviderId::Kimi => "#FE603C", - ProviderId::KimiK2 => "#4C00FF", - ProviderId::Amp => "#F34E3F", - ProviderId::Warp => "#6366F1", - ProviderId::Ollama => "#8B95B0", - ProviderId::AzureOpenAI => "#0078D4", - ProviderId::T3Chat => "#8B5CF6", - ProviderId::OpenRouter => "#6B7280", - ProviderId::JetBrains => "#FF3399", - ProviderId::Alibaba => "#FF6A00", - ProviderId::AlibabaTokenPlan => "#FF6A00", - ProviderId::NanoGPT => "#687FA1", - ProviderId::Infini => "#687FA1", - ProviderId::Perplexity => "#1FB8CD", - ProviderId::Abacus => "#814EE8", - ProviderId::Mistral => "#FF5229", - ProviderId::OpenCodeGo => "#3B82F6", - ProviderId::Kilo => "#5D87FF", - ProviderId::Bedrock => "#01A88D", - ProviderId::Codebuff => "#00FF95", - ProviderId::CodeRabbit => "#FF5C35", - ProviderId::DeepSeek => "#4D6BFE", - ProviderId::DeepInfra => "#2A3275", - ProviderId::AiAnd => "#E25C2B", - ProviderId::Windsurf => "#22C55E", - ProviderId::Manus => "#34322D", - ProviderId::MiMo => "#FF6900", - ProviderId::Doubao => "#2563EB", - ProviderId::CommandCode => "#8C4EDD", - ProviderId::StepFun => "#999999", - ProviderId::Venice => "#3C8FDD", - ProviderId::OpenAIApi => "#10A37F", - ProviderId::Grok => "#111827", - ProviderId::ElevenLabs => "#111827", - ProviderId::Deepgram => "#13EF93", - ProviderId::Groq => "#F55036", - ProviderId::HuggingFace => "#FFD21E", - ProviderId::Helmcode => "#4F46E5", - ProviderId::V0 => "#111827", - ProviderId::TypeSafe => "#2563EB", - ProviderId::LLMProxy => "#4F46E5", - ProviderId::Chutes => "#FF5C35", - ProviderId::LiteLLM => "#0EA5E9", - ProviderId::Poe => "#5D5FEF", - ProviderId::Devin => "#317CFF", - ProviderId::Zed => "#084CCF", - ProviderId::CrossModel => "#C084FC", - ProviderId::Qoder => "#2563EB", - ProviderId::CodeBuddy => "#0052D9", - ProviderId::Sakana => "#0EA5E9", - ProviderId::Sub2Api => "#14B8A6", - ProviderId::Wayfinder => "#14B8A6", - ProviderId::ZenMux => "#6C5CE7", - ProviderId::ClinePass => "#5487C8", - ProviderId::LongCat => "#29E154", - ProviderId::Neuralwatt => "#D55934", - ProviderId::ZoomMate => "#0B5CFF", - ProviderId::QwenCloud => "#615CED", - ProviderId::Notion => "#337EA9", - ProviderId::Xai => "#8E8E93", - ProviderId::Fireworks => "#F25B1C", - ProviderId::Meta => "#0467DF", - ProviderId::Muse => "#0668E1", - ProviderId::Replicate => "#000000", - ProviderId::AtlasCloud => "#5975F5", - ProviderId::Nous => "#D6A55C", - ProviderId::Hyper => "#FF60FF", - ProviderId::GitKraken => "#179287", - ProviderId::Bifrost => "#33C09E", - ProviderId::Aixy => "#123650", - ProviderId::LLMMan => "#6CC5B0", - ProviderId::DevPass => "#2563EB", - ProviderId::XKiro => "#52C99B", - ProviderId::Raycast => "#FF6363", - // Upstream uses white; a mid neutral keeps contrast on light and dark surfaces. - ProviderId::Vercel => "#737373", - } -} - -#[cfg(test)] -#[path = "provider_transport_tests.rs"] -mod transport_tests; - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_provider_id_all() { - let all = ProviderId::all(); - assert_eq!(all.len(), 89); - assert!(all.contains(&ProviderId::Claude)); - assert!(all.contains(&ProviderId::Codex)); - assert!(all.contains(&ProviderId::Pi)); - assert!(all.contains(&ProviderId::Fireworks)); - assert!(all.contains(&ProviderId::Kimi)); - assert!(all.contains(&ProviderId::KimiK2)); - assert!(all.contains(&ProviderId::Amp)); - assert!(all.contains(&ProviderId::AzureOpenAI)); - assert!(all.contains(&ProviderId::T3Chat)); - assert!(all.contains(&ProviderId::JetBrains)); - assert!(all.contains(&ProviderId::AlibabaTokenPlan)); - assert!(all.contains(&ProviderId::NanoGPT)); - assert!(all.contains(&ProviderId::Infini)); - assert!(all.contains(&ProviderId::Bedrock)); - assert!(all.contains(&ProviderId::Codebuff)); - assert!(all.contains(&ProviderId::CodeRabbit)); - assert!(all.contains(&ProviderId::DeepSeek)); - assert!(all.contains(&ProviderId::DeepInfra)); - assert!(all.contains(&ProviderId::AiAnd)); - assert!(all.contains(&ProviderId::Windsurf)); - assert!(all.contains(&ProviderId::Manus)); - assert!(all.contains(&ProviderId::MiMo)); - assert!(all.contains(&ProviderId::Doubao)); - assert!(all.contains(&ProviderId::CommandCode)); - assert!(all.contains(&ProviderId::StepFun)); - assert!(all.contains(&ProviderId::Venice)); - assert!(all.contains(&ProviderId::OpenAIApi)); - assert!(all.contains(&ProviderId::Grok)); - assert!(all.contains(&ProviderId::ElevenLabs)); - assert!(all.contains(&ProviderId::Deepgram)); - assert!(all.contains(&ProviderId::Groq)); - assert!(all.contains(&ProviderId::HuggingFace)); - assert!(all.contains(&ProviderId::Helmcode)); - assert!(all.contains(&ProviderId::V0)); - assert!(all.contains(&ProviderId::TypeSafe)); - assert!(all.contains(&ProviderId::LLMProxy)); - assert!(all.contains(&ProviderId::Chutes)); - assert!(all.contains(&ProviderId::LiteLLM)); - assert!(all.contains(&ProviderId::Poe)); - assert!(all.contains(&ProviderId::Devin)); - assert!(all.contains(&ProviderId::Zed)); - assert!(all.contains(&ProviderId::CrossModel)); - assert!(all.contains(&ProviderId::Qoder)); - assert!(all.contains(&ProviderId::CodeBuddy)); - assert!(all.contains(&ProviderId::Sakana)); - assert!(all.contains(&ProviderId::Sub2Api)); - assert!(all.contains(&ProviderId::Wayfinder)); - assert!(all.contains(&ProviderId::ZenMux)); - assert!(all.contains(&ProviderId::ClinePass)); - assert!(all.contains(&ProviderId::LongCat)); - assert!(all.contains(&ProviderId::Neuralwatt)); - assert!(all.contains(&ProviderId::ZoomMate)); - assert!(all.contains(&ProviderId::QwenCloud)); - assert!(all.contains(&ProviderId::Notion)); - assert!(all.contains(&ProviderId::Xai)); - assert!(all.contains(&ProviderId::Meta)); - assert!(all.contains(&ProviderId::Replicate)); - assert!(all.contains(&ProviderId::Muse)); - assert!(all.contains(&ProviderId::Nous)); - assert!(all.contains(&ProviderId::AtlasCloud)); - assert!(all.contains(&ProviderId::Hyper)); - assert!(all.contains(&ProviderId::GitKraken)); - assert!(all.contains(&ProviderId::Bifrost)); - assert!(all.contains(&ProviderId::Aixy)); - assert!(all.contains(&ProviderId::LLMMan)); - assert!(all.contains(&ProviderId::DevPass)); - assert!(all.contains(&ProviderId::XKiro)); - assert!(all.contains(&ProviderId::Raycast)); - assert!(all.contains(&ProviderId::Vercel)); - } - - #[test] - fn deprecated_providers_are_kimik2_and_crossmodel() { - assert!(ProviderId::KimiK2.is_deprecated()); - assert!(ProviderId::CrossModel.is_deprecated()); - assert!(!ProviderId::Kimi.is_deprecated()); - assert!(!ProviderId::AiAnd.is_deprecated()); - assert!(ProviderId::KimiK2.display_name().contains("(removed)")); - assert!(ProviderId::CrossModel.display_name().contains("(removed)")); - assert_eq!( - ProviderId::from_cli_name("kimik2"), - Some(ProviderId::KimiK2) - ); - assert_eq!( - ProviderId::from_cli_name("crossmodel"), - Some(ProviderId::CrossModel) - ); - } - - #[test] - fn aiand_cli_aliases_resolve() { - assert_eq!(ProviderId::from_cli_name("aiand"), Some(ProviderId::AiAnd)); - assert_eq!(ProviderId::from_cli_name("ai&"), Some(ProviderId::AiAnd)); - assert_eq!(ProviderId::from_cli_name("ai-and"), Some(ProviderId::AiAnd)); - assert_eq!(ProviderId::AiAnd.cli_name(), "aiand"); - assert_eq!(ProviderId::AiAnd.display_name(), "ai&"); - } - - #[test] - fn test_provider_id_cli_name() { - assert_eq!(ProviderId::Claude.cli_name(), "claude"); - assert_eq!(ProviderId::Codex.cli_name(), "codex"); - assert_eq!(ProviderId::Factory.cli_name(), "factory"); - assert_eq!(ProviderId::Zai.cli_name(), "zai"); - assert_eq!(ProviderId::HuggingFace.cli_name(), "huggingface"); - assert_eq!(ProviderId::CodeRabbit.cli_name(), "coderabbit"); - } - - #[test] - fn test_provider_id_display_name() { - assert_eq!(ProviderId::Claude.display_name(), "Claude"); - assert_eq!(ProviderId::Factory.display_name(), "Factory"); - assert_eq!(ProviderId::Zai.display_name(), "z.ai"); - assert_eq!(ProviderId::HuggingFace.display_name(), "Hugging Face"); - assert_eq!(ProviderId::CodeRabbit.display_name(), "CodeRabbit"); - } - - #[test] - fn test_provider_id_from_cli_name() { - assert_eq!( - ProviderId::from_cli_name("claude"), - Some(ProviderId::Claude) - ); - assert_eq!( - ProviderId::from_cli_name("anthropic"), - Some(ProviderId::Claude) - ); - assert_eq!( - ProviderId::from_cli_name("CLAUDE"), - Some(ProviderId::Claude) - ); - assert_eq!(ProviderId::from_cli_name("codex"), Some(ProviderId::Codex)); - assert_eq!( - ProviderId::from_cli_name("hf"), - Some(ProviderId::HuggingFace) - ); - assert_eq!(ProviderId::from_cli_name("openai"), Some(ProviderId::Codex)); - assert_eq!( - ProviderId::from_cli_name("factory"), - Some(ProviderId::Factory) - ); - assert_eq!( - ProviderId::from_cli_name("agy"), - Some(ProviderId::Antigravity) - ); - assert_eq!(ProviderId::from_cli_name("zed"), Some(ProviderId::Zed)); - assert_eq!(ProviderId::from_cli_name("crof"), None); - assert_eq!(ProviderId::from_cli_name("unknown"), None); - assert_eq!( - ProviderId::from_cli_name("code-rabbit"), - Some(ProviderId::CodeRabbit) - ); - } - - #[test] - fn test_provider_id_from_display_name_aliases() { - for provider_id in ProviderId::all() { - assert_eq!( - ProviderId::from_cli_name(provider_id.display_name()), - Some(*provider_id), - "display name should round-trip for {}", - provider_id.display_name() - ); - } - } - - #[test] - fn test_provider_id_display() { - assert_eq!(format!("{}", ProviderId::Claude), "claude"); - assert_eq!(format!("{}", ProviderId::Codex), "codex"); - } - - #[test] - fn test_source_mode_from_str() { - assert_eq!(SourceMode::parse("auto"), Some(SourceMode::Auto)); - assert_eq!(SourceMode::parse("oauth"), Some(SourceMode::OAuth)); - assert_eq!(SourceMode::parse("web"), Some(SourceMode::Web)); - assert_eq!(SourceMode::parse("cli"), Some(SourceMode::Cli)); - assert_eq!(SourceMode::parse("AUTO"), Some(SourceMode::Auto)); - assert_eq!(SourceMode::parse("invalid"), None); - } - - #[test] - fn test_fetch_context_default() { - let ctx = FetchContext::default(); - assert_eq!(ctx.source_mode, SourceMode::Auto); - assert!(ctx.include_credits); - assert_eq!(ctx.web_timeout, 60); - assert!(!ctx.verbose); - assert!(ctx.manual_cookie_header.is_none()); - assert!(ctx.api_key.is_none()); - } - - #[test] - fn test_cli_name_map() { - let map = cli_name_map(); - assert_eq!(map.get("claude"), Some(&ProviderId::Claude)); - assert_eq!(map.get("anthropic"), Some(&ProviderId::Claude)); - assert_eq!(map.get("codex"), Some(&ProviderId::Codex)); - assert_eq!(map.get("openai"), Some(&ProviderId::Codex)); - assert_eq!(map.get("agy"), Some(&ProviderId::Antigravity)); - } - - #[test] - fn test_provider_id_cookie_domain() { - // Cookie-based providers - assert_eq!(ProviderId::Claude.cookie_domain(), Some("claude.ai")); - assert_eq!(ProviderId::Cursor.cookie_domain(), Some("cursor.com")); - assert_eq!(ProviderId::Factory.cookie_domain(), Some("app.factory.ai")); - assert_eq!(ProviderId::Codex.cookie_domain(), Some("chatgpt.com")); - assert_eq!( - ProviderId::Gemini.cookie_domain(), - Some("aistudio.google.com") - ); - assert_eq!(ProviderId::Kiro.cookie_domain(), Some("kiro.dev")); - assert_eq!(ProviderId::Zed.cookie_domain(), Some("zed.dev")); - assert_eq!(ProviderId::Kimi.cookie_domain(), Some("kimi.moonshot.cn")); - assert_eq!(ProviderId::OpenCode.cookie_domain(), Some("opencode.ai")); - assert_eq!(ProviderId::Venice.cookie_domain(), Some("venice.ai")); - - // Token-based providers (no cookies) - assert_eq!(ProviderId::Copilot.cookie_domain(), None); - assert_eq!(ProviderId::Zai.cookie_domain(), None); - assert_eq!(ProviderId::VertexAI.cookie_domain(), None); - assert_eq!(ProviderId::JetBrains.cookie_domain(), None); - assert_eq!(ProviderId::HuggingFace.cookie_domain(), None); - assert_eq!(ProviderId::CodeRabbit.cookie_domain(), None); - } - - #[test] - fn test_provider_id_alibaba() { - assert_eq!(ProviderId::Alibaba.cli_name(), "alibaba"); - assert_eq!(ProviderId::Alibaba.display_name(), "Alibaba"); - assert_eq!( - ProviderId::Alibaba.cookie_domain(), - Some("modelstudio.console.alibabacloud.com") - ); - assert_eq!( - ProviderId::from_cli_name("alibaba"), - Some(ProviderId::Alibaba) - ); - assert_eq!( - ProviderId::from_cli_name("tongyi"), - Some(ProviderId::Alibaba) - ); - assert_eq!( - ProviderId::from_cli_name("qianwen"), - Some(ProviderId::Alibaba) - ); - } - - #[test] - fn test_provider_id_qwen_cloud() { - assert_eq!(ProviderId::QwenCloud.cli_name(), "qwen-cloud"); - assert_eq!(ProviderId::QwenCloud.display_name(), "Qwen Cloud"); - assert_eq!(ProviderId::QwenCloud.cookie_domain(), Some("qwencloud.com")); - assert_eq!( - ProviderId::from_cli_name("qwen-cloud"), - Some(ProviderId::QwenCloud) - ); - assert_eq!( - ProviderId::from_cli_name("qwencloud"), - Some(ProviderId::QwenCloud) - ); - assert_eq!( - ProviderId::from_cli_name("qwen"), - Some(ProviderId::QwenCloud) - ); - assert_eq!( - ProviderId::from_cli_name("qwen-token-plan"), - Some(ProviderId::QwenCloud) - ); - assert_eq!( - ProviderId::from_cli_name("qwen cloud"), - Some(ProviderId::QwenCloud) - ); - // Bare "qwen" must not resolve to Alibaba Coding Plan. - assert_ne!(ProviderId::from_cli_name("qwen"), Some(ProviderId::Alibaba)); - } - - #[test] - fn test_provider_id_notion() { - assert_eq!(ProviderId::Notion.cli_name(), "notion"); - assert_eq!(ProviderId::Notion.display_name(), "Notion AI"); - assert_eq!(ProviderId::Notion.cookie_domain(), Some("app.notion.com")); - assert_eq!( - ProviderId::from_cli_name("notion"), - Some(ProviderId::Notion) - ); - assert_eq!( - ProviderId::from_cli_name("notion-ai"), - Some(ProviderId::Notion) - ); - assert_eq!( - ProviderId::from_cli_name("notionai"), - Some(ProviderId::Notion) - ); - assert_eq!( - ProviderId::from_cli_name("notion ai"), - Some(ProviderId::Notion) - ); - } - - #[test] - fn test_provider_id_meta() { - assert_eq!(ProviderId::Meta.cli_name(), "meta"); - assert_eq!(ProviderId::Meta.display_name(), "Meta"); - assert_eq!(ProviderId::Meta.cookie_domain(), None); - assert_eq!(ProviderId::from_cli_name("meta"), Some(ProviderId::Meta)); - assert_eq!( - ProviderId::from_cli_name("metaspark"), - Some(ProviderId::Meta) - ); - assert_eq!( - ProviderId::from_cli_name("meta-spark"), - Some(ProviderId::Meta) - ); - assert_eq!(ProviderId::from_cli_name("meta"), Some(ProviderId::Meta)); - assert_eq!( - ProviderId::from_cli_name("muse-spark"), - Some(ProviderId::Meta) - ); - assert_eq!( - ProviderId::from_cli_name("musespark"), - Some(ProviderId::Meta) - ); - // Display name round-trips (also covered by the generic alias test). - assert_eq!(ProviderId::from_cli_name("Meta"), Some(ProviderId::Meta)); - // Backwards-compat aliases still resolve. - assert_eq!( - ProviderId::from_cli_name("Meta Muse Spark"), - Some(ProviderId::Meta) - ); - assert_eq!(brand_color(ProviderId::Meta), "#0467DF"); - } - - #[test] - fn test_provider_id_muse() { - assert_eq!(ProviderId::Muse.cli_name(), "muse"); - assert_eq!(ProviderId::Muse.display_name(), "Muse Code"); - assert_eq!(ProviderId::Muse.cookie_domain(), Some("dev.meta.ai")); - assert_eq!(ProviderId::from_cli_name("muse"), Some(ProviderId::Muse)); - assert_eq!( - ProviderId::from_cli_name("muse-code"), - Some(ProviderId::Muse) - ); - assert_eq!(brand_color(ProviderId::Muse), "#0668E1"); - } - - // The "muse *" alias family spans two providers: bare `muse` / `muse code` - // are the Muse Code CLI, while `muse spark` belongs to Meta (Meta Muse - // Spark). Pin the boundary so a future alias edit cannot silently re-route - // either side (review finding: latent UX/triage trap). - #[test] - fn muse_alias_family_boundary() { - assert_eq!( - ProviderId::from_cli_name("muse code"), - Some(ProviderId::Muse) - ); - assert_eq!(ProviderId::from_cli_name("muse"), Some(ProviderId::Muse)); - assert_eq!( - ProviderId::from_cli_name("muse spark"), - Some(ProviderId::Meta) - ); - assert_eq!( - ProviderId::from_cli_name("muse-spark"), - Some(ProviderId::Meta) - ); - assert_eq!( - ProviderId::from_cli_name("musespark"), - Some(ProviderId::Meta) - ); - assert_eq!( - ProviderId::from_cli_name("meta muse spark"), - Some(ProviderId::Meta) - ); - } - - #[test] - fn test_provider_id_xai() { - assert_eq!(ProviderId::Xai.cli_name(), "xai"); - assert_eq!(ProviderId::Xai.display_name(), "xAI"); - assert_eq!(ProviderId::Xai.cookie_domain(), None); - assert_eq!(ProviderId::from_cli_name("xai"), Some(ProviderId::Xai)); - assert_eq!(ProviderId::from_cli_name("x.ai"), Some(ProviderId::Xai)); - assert_eq!(ProviderId::from_cli_name("x-ai"), Some(ProviderId::Xai)); - // Grok keeps consumer aliases; xai is the developer-platform provider. - assert_eq!(ProviderId::from_cli_name("grok"), Some(ProviderId::Grok)); - assert_eq!( - ProviderId::from_cli_name("supergrok"), - Some(ProviderId::Grok) - ); - } - - /// The 16 accents upstream 0.70.0 adopted in its palette audit (#4075), - /// each with the accent Windows shipped before this port. - const ADOPTED_ACCENTS: [(ProviderId, &str, &str); 16] = [ - (ProviderId::Abacus, "#7C3AED", "#814EE8"), - (ProviderId::Amp, "#DC2626", "#F34E3F"), - (ProviderId::Augment, "#6366F1", "#1AA049"), - (ProviderId::Bedrock, "#FF9900", "#01A88D"), - (ProviderId::ClinePass, "#61A3FA", "#5487C8"), - (ProviderId::Codebuff, "#44FF00", "#00FF95"), - (ProviderId::CommandCode, "#44FF00", "#8C4EDD"), - (ProviderId::Cursor, "#00BFA5", "#F54E00"), - (ProviderId::DeepSeek, "#527DF0", "#4D6BFE"), - (ProviderId::Devin, "#111827", "#317CFF"), - (ProviderId::Kiro, "#FF9900", "#9046FF"), - (ProviderId::LongCat, "#FFD100", "#29E154"), - (ProviderId::Mistral, "#FF500F", "#FF5229"), - (ProviderId::Neuralwatt, "#38D98C", "#D55934"), - (ProviderId::Sub2Api, "#2DC6D8", "#14B8A6"), - (ProviderId::Venice, "#111827", "#3C8FDD"), - ]; - - /// Audited providers whose accent upstream kept, where the Windows accent - /// already equals upstream's final value. Chutes, Deepgram, Doubao, Groq, - /// Kilo, LiteLLM, Perplexity, Qoder, Sakana, T3 Chat and Warp keep older - /// Windows accents that differ from upstream; aligning them is out of - /// scope for the 0.70.0 port. - const RETAINED_ACCENTS: [(ProviderId, &str); 7] = [ - (ProviderId::AiAnd, "#E25C2B"), - (ProviderId::Copilot, "#A855F7"), - (ProviderId::Fireworks, "#F25B1C"), - (ProviderId::JetBrains, "#FF3399"), - (ProviderId::Kimi, "#FE603C"), - (ProviderId::Notion, "#337EA9"), - (ProviderId::OpenCode, "#3B82F6"), - ]; - - /// WCAG relative luminance of a `#RRGGBB` color, as upstream's - /// `ProviderPaletteRegressionTests` computes it. - fn relative_luminance(hex: &str) -> f64 { - let digits = hex.strip_prefix('#').expect("hex color starts with #"); - assert_eq!(digits.len(), 6, "{hex} is not #RRGGBB"); - let linear = |offset: usize| { - let channel = - f64::from(u8::from_str_radix(&digits[offset..offset + 2], 16).expect("hex digits")) - / 255.0; - if channel <= 0.04045 { - channel / 12.92 - } else { - ((channel + 0.055) / 1.055).powf(2.4) - } - }; - 0.2126 * linear(0) + 0.7152 * linear(2) + 0.0722 * linear(4) - } - - fn contrast_ratio(color: &str, background: &str) -> f64 { - let foreground = relative_luminance(color); - let backdrop = relative_luminance(background); - (foreground.max(backdrop) + 0.05) / (foreground.min(backdrop) + 0.05) - } - - #[test] - fn contrast_ratio_matches_upstream_audit_values() { - // Rows of upstream docs/provider-palette.md, rounded to two decimals. - let rounded = |value: f64| (value * 100.0).round() / 100.0; - assert_eq!(rounded(contrast_ratio("#814EE8", "#FFFFFF")), 5.01); - assert_eq!(rounded(contrast_ratio("#814EE8", "#222222")), 3.17); - assert_eq!(rounded(contrast_ratio("#FF9900", "#FFFFFF")), 2.14); - assert_eq!(rounded(contrast_ratio("#01A88D", "#222222")), 5.29); - assert_eq!(rounded(contrast_ratio("#00FF95", "#FFFFFF")), 1.33); - assert_eq!(rounded(contrast_ratio("#FFFFFF", "#FFFFFF")), 1.0); - } - - #[test] - fn adopted_upstream_palette_accents_are_pinned() { - for (id, _, adopted) in ADOPTED_ACCENTS { - assert_eq!(brand_color(id), adopted, "{id:?}"); - } - for (id, retained) in RETAINED_ACCENTS { - assert_eq!(brand_color(id), retained, "{id:?}"); - } - } - - #[test] - fn adopted_palette_accents_do_not_materially_regress_contrast() { - // Upstream's controlled light and dark menu surfaces. A material - // regression falls below 3:1 while losing at least 0.5 of contrast - // against the accent Windows shipped before. - for (id, previous, _) in ADOPTED_ACCENTS { - for background in ["#FFFFFF", "#222222"] { - let current = contrast_ratio(brand_color(id), background); - let before = contrast_ratio(previous, background); - assert!( - current >= 3.0 || before - current < 0.5, - "{id:?} on {background}: {before:.2} -> {current:.2}" - ); - } - } - } - - #[test] - fn brand_colors_are_uppercase_hex() { - for id in ProviderId::all() { - let color = brand_color(*id); - assert!( - color.len() == 7 - && color.starts_with('#') - && color[1..] - .chars() - .all(|c| c.is_ascii_digit() || matches!(c, 'A'..='F')), - "{id:?} has {color}" - ); - } - } - - #[test] - fn test_provider_id_xkiro() { - assert_eq!(ProviderId::XKiro.cli_name(), "xkiro"); - assert_eq!(ProviderId::XKiro.display_name(), "xKiro"); - assert_eq!(ProviderId::XKiro.cookie_domain(), None); - assert_eq!(ProviderId::from_cli_name("xkiro"), Some(ProviderId::XKiro)); - assert_eq!(ProviderId::from_cli_name("x-kiro"), Some(ProviderId::XKiro)); - assert_eq!(cli_name_map().get("xkiro"), Some(&ProviderId::XKiro)); - assert_eq!(cli_name_map().get("x-kiro"), Some(&ProviderId::XKiro)); - // xKiro is a separate provider from Kiro and shares none of its aliases. - assert_eq!(ProviderId::from_cli_name("kiro"), Some(ProviderId::Kiro)); - assert_eq!(ProviderId::from_cli_name("aws"), Some(ProviderId::Kiro)); - } -} diff --git a/rust/src/core/provider/context.rs b/rust/src/core/provider/context.rs new file mode 100644 index 00000000000..b8cbdf89db1 --- /dev/null +++ b/rust/src/core/provider/context.rs @@ -0,0 +1,153 @@ +//! Fetch inputs: the requested [`SourceMode`] and the [`FetchContext`]. + +/// Data source mode for fetching usage +/// +/// Conventions for providers whose transport is not an OAuth flow: they +/// reuse `OAuth` as the persisted token/API lane (an API key, hub token, or +/// other credential), because the source enum is shared with the settings +/// UI. `Auto` may dispatch to that lane as well. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum SourceMode { + /// Automatically choose the best available source + #[default] + Auto, + /// Use OAuth API; also the token/API lane for non-OAuth providers + OAuth, + /// Use web API with browser cookies + Web, + /// Use CLI probe + Cli, +} + +impl SourceMode { + pub fn parse(s: &str) -> Option { + match s.to_lowercase().as_str() { + "auto" => Some(SourceMode::Auto), + "oauth" => Some(SourceMode::OAuth), + "web" => Some(SourceMode::Web), + "cli" => Some(SourceMode::Cli), + _ => None, + } + } +} + +/// Context passed to provider fetch operations +#[derive(Debug, Clone)] +pub struct FetchContext { + /// Source mode to use + pub source_mode: SourceMode, + + /// Whether to include credits/cost data + pub include_credits: bool, + + /// Timeout for web operations in seconds + pub web_timeout: u64, + + /// Whether to enable verbose logging + pub verbose: bool, + + /// Manual cookie header (for testing) + pub manual_cookie_header: Option, + + /// The cookie source is manual and no cookie is stored, or (for providers + /// whose cookie source only scopes the session) cookies are off. The + /// provider decides what this means; Replicate fails closed instead of + /// importing a browser account the user did not select, and Charm Hyper + /// skips its session lane. + pub manual_cookie_missing: bool, + + /// API key for providers that require authentication + pub api_key: Option, + + /// Type of the explicitly selected labeled token account, if any. + pub token_account_kind: Option, + + /// A selected account is an identity boundary: providers must not retry + /// another ambient credential or account after its credential fails. + pub token_account_isolated: bool, + + /// Optional provider workspace/project scope from persisted settings. + pub workspace_id: Option, + + /// Optional Copilot seat AI-credit allowance supplied by the app settings. + /// The provider keeps the credit counter unknown when this is absent. + pub seat_credit_entitlement: Option, + + /// Optional provider API/web region from persisted settings. + pub api_region: Option, + + /// Optional provider gateway URL, used by local gateway-backed providers. + pub gateway_url: Option, + + /// When true, Auto mode prefers web before local (token-account scope, + /// manual cookie source, etc.). Workspace overrides are checked separately. + pub auto_prefer_web: bool, + + /// The user chose automatic browser cookie import for a provider whose + /// cookies only enrich its API usage (`Provider::cookies_only_enrich_usage`). + /// False for the default manual-without-cookie state and for Off, so those + /// never read a browser. + pub browser_cookie_import: bool, + + /// Foreground usage reads (`codexbar usage`, `codexbar serve`) set this so + /// providers join slow optional enrichment with the full optional-item + /// timeout budget measured from task start; background/UI polls keep the + /// short join grace instead (upstream 0.48.0 + /// `requiresOptionalUsageCompleteness`, #2583). + pub requires_optional_usage_completeness: bool, + + /// The user opted in to the requested provider's optional detail breakdown + /// (LiteLLM model activity, Claude workspace spend). Scoped to the provider + /// being fetched; false everywhere the setting is not consulted. + pub optional_details_enabled: bool, +} + +impl Default for FetchContext { + fn default() -> Self { + Self { + source_mode: SourceMode::Auto, + include_credits: true, + web_timeout: 60, + verbose: false, + manual_cookie_header: None, + manual_cookie_missing: false, + api_key: None, + token_account_kind: None, + token_account_isolated: false, + workspace_id: None, + seat_credit_entitlement: None, + api_region: None, + gateway_url: None, + auto_prefer_web: false, + browser_cookie_import: false, + requires_optional_usage_completeness: false, + optional_details_enabled: false, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_source_mode_from_str() { + assert_eq!(SourceMode::parse("auto"), Some(SourceMode::Auto)); + assert_eq!(SourceMode::parse("oauth"), Some(SourceMode::OAuth)); + assert_eq!(SourceMode::parse("web"), Some(SourceMode::Web)); + assert_eq!(SourceMode::parse("cli"), Some(SourceMode::Cli)); + assert_eq!(SourceMode::parse("AUTO"), Some(SourceMode::Auto)); + assert_eq!(SourceMode::parse("invalid"), None); + } + + #[test] + fn test_fetch_context_default() { + let ctx = FetchContext::default(); + assert_eq!(ctx.source_mode, SourceMode::Auto); + assert!(ctx.include_credits); + assert_eq!(ctx.web_timeout, 60); + assert!(!ctx.verbose); + assert!(ctx.manual_cookie_header.is_none()); + assert!(ctx.api_key.is_none()); + } +} diff --git a/rust/src/core/provider/error.rs b/rust/src/core/provider/error.rs new file mode 100644 index 00000000000..b4653b9d295 --- /dev/null +++ b/rust/src/core/provider/error.rs @@ -0,0 +1,142 @@ +//! Provider fetch errors and their transport classification. + +use thiserror::Error; + +use super::SourceMode; +use crate::core::LastGoodOwner; + +/// Errors that can occur when fetching provider data +#[derive(Debug, Error)] +pub enum ProviderError { + #[error("Provider not installed: {0}")] + NotInstalled(String), + + #[error("Authentication required")] + AuthRequired, + + #[error("OAuth error: {0}")] + OAuth(String), + + #[error("Transient OAuth error: {0}")] + OAuthTransient(String), + + #[error("OAuth session expired: {0}")] + OAuthExpired(String), + + #[error("OAuth token revoked: {0}")] + OAuthRevoked(String), + + #[error("Parse error: {0}")] + Parse(String), + + #[error("Network error: {0}")] + Network(#[from] reqwest::Error), + + #[error("Timeout")] + Timeout, + + #[error("Source mode '{0:?}' not supported for this provider")] + UnsupportedSource(SourceMode), + + #[error("No cookies available for web API")] + NoCookies, + + /// Usage needs a provider web session that only a browser sign-in can + /// restore. `sign_in_url` is the page to open; CLI JSON error rows carry + /// it as `signInUrl` next to `errorKind: "browserSignInRequired"`. + #[error("{message}")] + BrowserSignInRequired { + message: String, + sign_in_url: String, + }, + + #[error("{0}")] + Other(String), + + /// A transport failure tagged with the session that produced it, so the + /// shell can retain a cached snapshot only for the same session. Build it + /// with [`ProviderError::with_failure_owner`]. + #[error("{source}")] + OwnedTransport { + owner: Option, + source: Box, + }, +} + +impl ProviderError { + /// Return true only for transport failures safe for last-good retention. + pub fn is_transport_failure(&self) -> bool { + match self { + ProviderError::Network(error) => matches!( + classify_reqwest_error(error), + ReqwestFailureClass::Timeout | ReqwestFailureClass::Connect + ), + ProviderError::Timeout => true, + ProviderError::OwnedTransport { source, .. } => source.is_transport_failure(), + _ => false, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ReqwestFailureClass { + Timeout, + Connect, + Terminal, +} + +fn classify_reqwest_error(error: &reqwest::Error) -> ReqwestFailureClass { + // A response-body failure can also carry the timeout flag when the peer + // stalls while the body is being read. It is terminal for the snapshot, + // because retaining last-good data would hide a truncated response. + if error.is_body() || error.is_decode() { + return ReqwestFailureClass::Terminal; + } + if error.is_timeout() { + return ReqwestFailureClass::Timeout; + } + if !error.is_connect() { + return ReqwestFailureClass::Terminal; + } + + // A connect classification alone is too broad: it also covers protocol + // and TLS-handshake failures. Retain only a typed transient socket error. + if has_io_error_kind(error, std::io::ErrorKind::ConnectionRefused) { + return ReqwestFailureClass::Connect; + } + + ReqwestFailureClass::Terminal +} + +fn has_io_error_kind(error: &reqwest::Error, kind: std::io::ErrorKind) -> bool { + fn contains_kind( + source: Option<&(dyn std::error::Error + 'static)>, + kind: std::io::ErrorKind, + ) -> bool { + let Some(current) = source else { + return false; + }; + if let Some(io_error) = current.downcast_ref::() { + if io_error.kind() == kind { + return true; + } + let mut nested = io_error.get_ref(); + while let Some(inner) = nested { + let Some(inner_io) = inner.downcast_ref::() else { + break; + }; + if inner_io.kind() == kind { + return true; + } + nested = inner_io.get_ref(); + } + } + contains_kind(std::error::Error::source(current), kind) + } + + contains_kind(std::error::Error::source(error), kind) +} + +#[cfg(test)] +#[path = "transport_tests.rs"] +mod transport_tests; diff --git a/rust/src/core/provider/id.rs b/rust/src/core/provider/id.rs new file mode 100755 index 00000000000..9c5ffc3bd49 --- /dev/null +++ b/rust/src/core/provider/id.rs @@ -0,0 +1,724 @@ +//! Provider identity: the [`ProviderId`] enum and its registry lookups. + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +use super::spec::{ALL, ProviderMetadata, ProviderSpec, SPECS}; + +/// Unique identifier for a provider +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ProviderId { + Codex, + Claude, + Pi, + Cursor, + Factory, + Gemini, + Antigravity, + Copilot, + Zai, + MiniMax, + Kiro, + VertexAI, + Augment, + OpenCode, + Kimi, + KimiK2, + Amp, + Warp, + Ollama, + AzureOpenAI, + T3Chat, + OpenRouter, + JetBrains, + Alibaba, + AlibabaTokenPlan, + NanoGPT, + Infini, + Perplexity, + Abacus, + Mistral, + OpenCodeGo, + Kilo, + Bedrock, + Codebuff, + CodeRabbit, + DeepSeek, + DeepInfra, + AiAnd, + Windsurf, + Manus, + MiMo, + Doubao, + CommandCode, + StepFun, + Venice, + OpenAIApi, + Grok, + ElevenLabs, + Deepgram, + Groq, + HuggingFace, + Helmcode, + V0, + TypeSafe, + LLMProxy, + Chutes, + LiteLLM, + Poe, + Devin, + Zed, + CrossModel, + Qoder, + CodeBuddy, + Sakana, + Sub2Api, + Wayfinder, + ZenMux, + ClinePass, + LongCat, + Neuralwatt, + ZoomMate, + QwenCloud, + Notion, + Xai, + Fireworks, + AtlasCloud, + #[serde(alias = "metaspark")] + Meta, + Muse, + Replicate, + Nous, + Hyper, + GitKraken, + Bifrost, + Aixy, + LLMMan, + DevPass, + XKiro, + Raycast, + Vercel, +} + +impl ProviderId { + /// Get all provider IDs + pub fn all() -> &'static [ProviderId] { + &ALL + } + + /// The registry row for this provider. + pub(crate) fn spec(self) -> &'static ProviderSpec { + &SPECS[self as usize] + } + + /// Static metadata for this provider. + pub fn metadata(self) -> &'static ProviderMetadata { + &self.spec().metadata + } + + /// Get the CLI name for this provider + pub fn cli_name(&self) -> &'static str { + self.spec().cli_name + } + + /// Get the display name for this provider + pub fn display_name(&self) -> &'static str { + self.spec().display_name + } + + /// Get the cookie domain for this provider. + /// Returns the domain used for cookie extraction, or None if the provider + /// doesn't use cookies for authentication. + pub fn cookie_domain(&self) -> Option<&'static str> { + self.spec().cookie_domain + } + + /// Id of the longer pool that blocks this provider's shorter windows once + /// exhausted (upstream 0.69.0 #4091). See [`crate::core::BlockedWindows`]. + pub fn blocking_quota_window_id(&self) -> Option<&'static str> { + match self { + ProviderId::Kimi => Some(crate::providers::kimi::MONTHLY_WINDOW_ID), + _ => None, + } + } + + /// Parse from CLI name string + pub fn from_cli_name(name: &str) -> Option { + let name = name.to_lowercase(); + SPECS + .iter() + .find(|spec| spec.accepts(&name)) + .map(|spec| spec.id) + } + + /// Soft-removed providers (upstream #2254: Kimi K2 + CrossModel). + /// + /// Modules and CLI resolution stay so existing configs and `--provider kimik2` + /// still work. Settings UI hides them unless already enabled in settings. + pub fn is_deprecated(&self) -> bool { + matches!(self, ProviderId::KimiK2 | ProviderId::CrossModel) + } +} + +impl std::fmt::Display for ProviderId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.cli_name()) + } +} + +/// Get the CLI name map for argument parsing +pub fn cli_name_map() -> HashMap<&'static str, ProviderId> { + let mut map = HashMap::new(); + for spec in &SPECS { + map.insert(spec.cli_name, spec.id); + for alias in spec.aliases { + map.insert(*alias, spec.id); + } + } + map +} + +/// The shipped brand color (hex) for a provider, mirroring the frontend +/// `PROVIDER_ICON_REGISTRY` in `providerIcons.ts` and the `--chart-` +/// tokens in `styles.css` (`providerIcons.test.ts` checks both). Used as the +/// default accent color before any per-provider override (#2972). +/// +/// Upstream 0.70.0 audited the palette (#4075, `docs/provider-palette.md`). +/// The 16 accents it adopted are pinned in the tests below; changing one +/// must not materially reduce contrast on white or `#222222`. +pub fn brand_color(id: ProviderId) -> &'static str { + id.spec().brand_color +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::instantiate_provider; + + #[test] + fn test_provider_id_all() { + let all = ProviderId::all(); + assert_eq!(all.len(), 89); + assert!(all.contains(&ProviderId::Claude)); + assert!(all.contains(&ProviderId::Codex)); + assert!(all.contains(&ProviderId::Pi)); + assert!(all.contains(&ProviderId::Fireworks)); + assert!(all.contains(&ProviderId::Kimi)); + assert!(all.contains(&ProviderId::KimiK2)); + assert!(all.contains(&ProviderId::Amp)); + assert!(all.contains(&ProviderId::AzureOpenAI)); + assert!(all.contains(&ProviderId::T3Chat)); + assert!(all.contains(&ProviderId::JetBrains)); + assert!(all.contains(&ProviderId::AlibabaTokenPlan)); + assert!(all.contains(&ProviderId::NanoGPT)); + assert!(all.contains(&ProviderId::Infini)); + assert!(all.contains(&ProviderId::Bedrock)); + assert!(all.contains(&ProviderId::Codebuff)); + assert!(all.contains(&ProviderId::CodeRabbit)); + assert!(all.contains(&ProviderId::DeepSeek)); + assert!(all.contains(&ProviderId::DeepInfra)); + assert!(all.contains(&ProviderId::AiAnd)); + assert!(all.contains(&ProviderId::Windsurf)); + assert!(all.contains(&ProviderId::Manus)); + assert!(all.contains(&ProviderId::MiMo)); + assert!(all.contains(&ProviderId::Doubao)); + assert!(all.contains(&ProviderId::CommandCode)); + assert!(all.contains(&ProviderId::StepFun)); + assert!(all.contains(&ProviderId::Venice)); + assert!(all.contains(&ProviderId::OpenAIApi)); + assert!(all.contains(&ProviderId::Grok)); + assert!(all.contains(&ProviderId::ElevenLabs)); + assert!(all.contains(&ProviderId::Deepgram)); + assert!(all.contains(&ProviderId::Groq)); + assert!(all.contains(&ProviderId::HuggingFace)); + assert!(all.contains(&ProviderId::Helmcode)); + assert!(all.contains(&ProviderId::V0)); + assert!(all.contains(&ProviderId::TypeSafe)); + assert!(all.contains(&ProviderId::LLMProxy)); + assert!(all.contains(&ProviderId::Chutes)); + assert!(all.contains(&ProviderId::LiteLLM)); + assert!(all.contains(&ProviderId::Poe)); + assert!(all.contains(&ProviderId::Devin)); + assert!(all.contains(&ProviderId::Zed)); + assert!(all.contains(&ProviderId::CrossModel)); + assert!(all.contains(&ProviderId::Qoder)); + assert!(all.contains(&ProviderId::CodeBuddy)); + assert!(all.contains(&ProviderId::Sakana)); + assert!(all.contains(&ProviderId::Sub2Api)); + assert!(all.contains(&ProviderId::Wayfinder)); + assert!(all.contains(&ProviderId::ZenMux)); + assert!(all.contains(&ProviderId::ClinePass)); + assert!(all.contains(&ProviderId::LongCat)); + assert!(all.contains(&ProviderId::Neuralwatt)); + assert!(all.contains(&ProviderId::ZoomMate)); + assert!(all.contains(&ProviderId::QwenCloud)); + assert!(all.contains(&ProviderId::Notion)); + assert!(all.contains(&ProviderId::Xai)); + assert!(all.contains(&ProviderId::Meta)); + assert!(all.contains(&ProviderId::Replicate)); + assert!(all.contains(&ProviderId::Muse)); + assert!(all.contains(&ProviderId::Nous)); + assert!(all.contains(&ProviderId::AtlasCloud)); + assert!(all.contains(&ProviderId::Hyper)); + assert!(all.contains(&ProviderId::GitKraken)); + assert!(all.contains(&ProviderId::Bifrost)); + assert!(all.contains(&ProviderId::Aixy)); + assert!(all.contains(&ProviderId::LLMMan)); + assert!(all.contains(&ProviderId::DevPass)); + assert!(all.contains(&ProviderId::XKiro)); + assert!(all.contains(&ProviderId::Raycast)); + assert!(all.contains(&ProviderId::Vercel)); + } + + #[test] + fn deprecated_providers_are_kimik2_and_crossmodel() { + assert!(ProviderId::KimiK2.is_deprecated()); + assert!(ProviderId::CrossModel.is_deprecated()); + assert!(!ProviderId::Kimi.is_deprecated()); + assert!(!ProviderId::AiAnd.is_deprecated()); + assert!(ProviderId::KimiK2.display_name().contains("(removed)")); + assert!(ProviderId::CrossModel.display_name().contains("(removed)")); + assert_eq!( + ProviderId::from_cli_name("kimik2"), + Some(ProviderId::KimiK2) + ); + assert_eq!( + ProviderId::from_cli_name("crossmodel"), + Some(ProviderId::CrossModel) + ); + } + + #[test] + fn aiand_cli_aliases_resolve() { + assert_eq!(ProviderId::from_cli_name("aiand"), Some(ProviderId::AiAnd)); + assert_eq!(ProviderId::from_cli_name("ai&"), Some(ProviderId::AiAnd)); + assert_eq!(ProviderId::from_cli_name("ai-and"), Some(ProviderId::AiAnd)); + assert_eq!(ProviderId::AiAnd.cli_name(), "aiand"); + assert_eq!(ProviderId::AiAnd.display_name(), "ai&"); + } + + #[test] + fn test_provider_id_cli_name() { + assert_eq!(ProviderId::Claude.cli_name(), "claude"); + assert_eq!(ProviderId::Codex.cli_name(), "codex"); + assert_eq!(ProviderId::Factory.cli_name(), "factory"); + assert_eq!(ProviderId::Zai.cli_name(), "zai"); + assert_eq!(ProviderId::HuggingFace.cli_name(), "huggingface"); + assert_eq!(ProviderId::CodeRabbit.cli_name(), "coderabbit"); + } + + #[test] + fn test_provider_id_display_name() { + assert_eq!(ProviderId::Claude.display_name(), "Claude"); + assert_eq!(ProviderId::Factory.display_name(), "Factory"); + assert_eq!(ProviderId::Zai.display_name(), "z.ai"); + assert_eq!(ProviderId::HuggingFace.display_name(), "Hugging Face"); + assert_eq!(ProviderId::CodeRabbit.display_name(), "CodeRabbit"); + } + + #[test] + fn test_provider_id_from_cli_name() { + assert_eq!( + ProviderId::from_cli_name("claude"), + Some(ProviderId::Claude) + ); + assert_eq!( + ProviderId::from_cli_name("anthropic"), + Some(ProviderId::Claude) + ); + assert_eq!( + ProviderId::from_cli_name("CLAUDE"), + Some(ProviderId::Claude) + ); + assert_eq!(ProviderId::from_cli_name("codex"), Some(ProviderId::Codex)); + assert_eq!( + ProviderId::from_cli_name("hf"), + Some(ProviderId::HuggingFace) + ); + assert_eq!(ProviderId::from_cli_name("openai"), Some(ProviderId::Codex)); + assert_eq!( + ProviderId::from_cli_name("factory"), + Some(ProviderId::Factory) + ); + assert_eq!( + ProviderId::from_cli_name("agy"), + Some(ProviderId::Antigravity) + ); + assert_eq!(ProviderId::from_cli_name("zed"), Some(ProviderId::Zed)); + assert_eq!(ProviderId::from_cli_name("crof"), None); + assert_eq!(ProviderId::from_cli_name("unknown"), None); + assert_eq!( + ProviderId::from_cli_name("code-rabbit"), + Some(ProviderId::CodeRabbit) + ); + } + + #[test] + fn test_provider_id_from_display_name_aliases() { + for provider_id in ProviderId::all() { + assert_eq!( + ProviderId::from_cli_name(provider_id.display_name()), + Some(*provider_id), + "display name should round-trip for {}", + provider_id.display_name() + ); + } + } + + #[test] + fn test_provider_id_display() { + assert_eq!(format!("{}", ProviderId::Claude), "claude"); + assert_eq!(format!("{}", ProviderId::Codex), "codex"); + } + + #[test] + fn test_cli_name_map() { + let map = cli_name_map(); + assert_eq!(map.get("claude"), Some(&ProviderId::Claude)); + assert_eq!(map.get("anthropic"), Some(&ProviderId::Claude)); + assert_eq!(map.get("codex"), Some(&ProviderId::Codex)); + assert_eq!(map.get("openai"), Some(&ProviderId::Codex)); + assert_eq!(map.get("agy"), Some(&ProviderId::Antigravity)); + } + + #[test] + fn test_provider_id_cookie_domain() { + // Cookie-based providers + assert_eq!(ProviderId::Claude.cookie_domain(), Some("claude.ai")); + assert_eq!(ProviderId::Cursor.cookie_domain(), Some("cursor.com")); + assert_eq!(ProviderId::Factory.cookie_domain(), Some("app.factory.ai")); + assert_eq!(ProviderId::Codex.cookie_domain(), Some("chatgpt.com")); + assert_eq!( + ProviderId::Gemini.cookie_domain(), + Some("aistudio.google.com") + ); + assert_eq!(ProviderId::Kiro.cookie_domain(), Some("kiro.dev")); + assert_eq!(ProviderId::Zed.cookie_domain(), Some("zed.dev")); + assert_eq!(ProviderId::Kimi.cookie_domain(), Some("kimi.moonshot.cn")); + assert_eq!(ProviderId::OpenCode.cookie_domain(), Some("opencode.ai")); + assert_eq!(ProviderId::Venice.cookie_domain(), Some("venice.ai")); + + // Token-based providers (no cookies) + assert_eq!(ProviderId::Copilot.cookie_domain(), None); + assert_eq!(ProviderId::Zai.cookie_domain(), None); + assert_eq!(ProviderId::VertexAI.cookie_domain(), None); + assert_eq!(ProviderId::JetBrains.cookie_domain(), None); + assert_eq!(ProviderId::HuggingFace.cookie_domain(), None); + assert_eq!(ProviderId::CodeRabbit.cookie_domain(), None); + } + + #[test] + fn test_provider_id_alibaba() { + assert_eq!(ProviderId::Alibaba.cli_name(), "alibaba"); + assert_eq!(ProviderId::Alibaba.display_name(), "Alibaba"); + assert_eq!( + ProviderId::Alibaba.cookie_domain(), + Some("modelstudio.console.alibabacloud.com") + ); + assert_eq!( + ProviderId::from_cli_name("alibaba"), + Some(ProviderId::Alibaba) + ); + assert_eq!( + ProviderId::from_cli_name("tongyi"), + Some(ProviderId::Alibaba) + ); + assert_eq!( + ProviderId::from_cli_name("qianwen"), + Some(ProviderId::Alibaba) + ); + } + + #[test] + fn test_provider_id_qwen_cloud() { + assert_eq!(ProviderId::QwenCloud.cli_name(), "qwen-cloud"); + assert_eq!(ProviderId::QwenCloud.display_name(), "Qwen Cloud"); + assert_eq!(ProviderId::QwenCloud.cookie_domain(), Some("qwencloud.com")); + assert_eq!( + ProviderId::from_cli_name("qwen-cloud"), + Some(ProviderId::QwenCloud) + ); + assert_eq!( + ProviderId::from_cli_name("qwencloud"), + Some(ProviderId::QwenCloud) + ); + assert_eq!( + ProviderId::from_cli_name("qwen"), + Some(ProviderId::QwenCloud) + ); + assert_eq!( + ProviderId::from_cli_name("qwen-token-plan"), + Some(ProviderId::QwenCloud) + ); + assert_eq!( + ProviderId::from_cli_name("qwen cloud"), + Some(ProviderId::QwenCloud) + ); + // Bare "qwen" must not resolve to Alibaba Coding Plan. + assert_ne!(ProviderId::from_cli_name("qwen"), Some(ProviderId::Alibaba)); + } + + #[test] + fn test_provider_id_notion() { + assert_eq!(ProviderId::Notion.cli_name(), "notion"); + assert_eq!(ProviderId::Notion.display_name(), "Notion AI"); + assert_eq!(ProviderId::Notion.cookie_domain(), Some("app.notion.com")); + assert_eq!( + ProviderId::from_cli_name("notion"), + Some(ProviderId::Notion) + ); + assert_eq!( + ProviderId::from_cli_name("notion-ai"), + Some(ProviderId::Notion) + ); + assert_eq!( + ProviderId::from_cli_name("notionai"), + Some(ProviderId::Notion) + ); + assert_eq!( + ProviderId::from_cli_name("notion ai"), + Some(ProviderId::Notion) + ); + } + + #[test] + fn test_provider_id_meta() { + assert_eq!(ProviderId::Meta.cli_name(), "meta"); + assert_eq!(ProviderId::Meta.display_name(), "Meta"); + assert_eq!(ProviderId::Meta.cookie_domain(), None); + assert_eq!(ProviderId::from_cli_name("meta"), Some(ProviderId::Meta)); + assert_eq!( + ProviderId::from_cli_name("metaspark"), + Some(ProviderId::Meta) + ); + assert_eq!( + ProviderId::from_cli_name("meta-spark"), + Some(ProviderId::Meta) + ); + assert_eq!(ProviderId::from_cli_name("meta"), Some(ProviderId::Meta)); + assert_eq!( + ProviderId::from_cli_name("muse-spark"), + Some(ProviderId::Meta) + ); + assert_eq!( + ProviderId::from_cli_name("musespark"), + Some(ProviderId::Meta) + ); + // Display name round-trips (also covered by the generic alias test). + assert_eq!(ProviderId::from_cli_name("Meta"), Some(ProviderId::Meta)); + // Backwards-compat aliases still resolve. + assert_eq!( + ProviderId::from_cli_name("Meta Muse Spark"), + Some(ProviderId::Meta) + ); + assert_eq!(brand_color(ProviderId::Meta), "#0467DF"); + } + + #[test] + fn test_provider_id_muse() { + assert_eq!(ProviderId::Muse.cli_name(), "muse"); + assert_eq!(ProviderId::Muse.display_name(), "Muse Code"); + assert_eq!(ProviderId::Muse.cookie_domain(), Some("dev.meta.ai")); + assert_eq!(ProviderId::from_cli_name("muse"), Some(ProviderId::Muse)); + assert_eq!( + ProviderId::from_cli_name("muse-code"), + Some(ProviderId::Muse) + ); + assert_eq!(brand_color(ProviderId::Muse), "#0668E1"); + } + + // The "muse *" alias family spans two providers: bare `muse` / `muse code` + // are the Muse Code CLI, while `muse spark` belongs to Meta (Meta Muse + // Spark). Pin the boundary so a future alias edit cannot silently re-route + // either side (review finding: latent UX/triage trap). + #[test] + fn muse_alias_family_boundary() { + assert_eq!( + ProviderId::from_cli_name("muse code"), + Some(ProviderId::Muse) + ); + assert_eq!(ProviderId::from_cli_name("muse"), Some(ProviderId::Muse)); + assert_eq!( + ProviderId::from_cli_name("muse spark"), + Some(ProviderId::Meta) + ); + assert_eq!( + ProviderId::from_cli_name("muse-spark"), + Some(ProviderId::Meta) + ); + assert_eq!( + ProviderId::from_cli_name("musespark"), + Some(ProviderId::Meta) + ); + assert_eq!( + ProviderId::from_cli_name("meta muse spark"), + Some(ProviderId::Meta) + ); + } + + #[test] + fn test_provider_id_xai() { + assert_eq!(ProviderId::Xai.cli_name(), "xai"); + assert_eq!(ProviderId::Xai.display_name(), "xAI"); + assert_eq!(ProviderId::Xai.cookie_domain(), None); + assert_eq!(ProviderId::from_cli_name("xai"), Some(ProviderId::Xai)); + assert_eq!(ProviderId::from_cli_name("x.ai"), Some(ProviderId::Xai)); + assert_eq!(ProviderId::from_cli_name("x-ai"), Some(ProviderId::Xai)); + // Grok keeps consumer aliases; xai is the developer-platform provider. + assert_eq!(ProviderId::from_cli_name("grok"), Some(ProviderId::Grok)); + assert_eq!( + ProviderId::from_cli_name("supergrok"), + Some(ProviderId::Grok) + ); + } + + /// The 16 accents upstream 0.70.0 adopted in its palette audit (#4075), + /// each with the accent Windows shipped before this port. + const ADOPTED_ACCENTS: [(ProviderId, &str, &str); 16] = [ + (ProviderId::Abacus, "#7C3AED", "#814EE8"), + (ProviderId::Amp, "#DC2626", "#F34E3F"), + (ProviderId::Augment, "#6366F1", "#1AA049"), + (ProviderId::Bedrock, "#FF9900", "#01A88D"), + (ProviderId::ClinePass, "#61A3FA", "#5487C8"), + (ProviderId::Codebuff, "#44FF00", "#00FF95"), + (ProviderId::CommandCode, "#44FF00", "#8C4EDD"), + (ProviderId::Cursor, "#00BFA5", "#F54E00"), + (ProviderId::DeepSeek, "#527DF0", "#4D6BFE"), + (ProviderId::Devin, "#111827", "#317CFF"), + (ProviderId::Kiro, "#FF9900", "#9046FF"), + (ProviderId::LongCat, "#FFD100", "#29E154"), + (ProviderId::Mistral, "#FF500F", "#FF5229"), + (ProviderId::Neuralwatt, "#38D98C", "#D55934"), + (ProviderId::Sub2Api, "#2DC6D8", "#14B8A6"), + (ProviderId::Venice, "#111827", "#3C8FDD"), + ]; + + /// Audited providers whose accent upstream kept, where the Windows accent + /// already equals upstream's final value. Chutes, Deepgram, Doubao, Groq, + /// Kilo, LiteLLM, Perplexity, Qoder, Sakana, T3 Chat and Warp keep older + /// Windows accents that differ from upstream; aligning them is out of + /// scope for the 0.70.0 port. + const RETAINED_ACCENTS: [(ProviderId, &str); 7] = [ + (ProviderId::AiAnd, "#E25C2B"), + (ProviderId::Copilot, "#A855F7"), + (ProviderId::Fireworks, "#F25B1C"), + (ProviderId::JetBrains, "#FF3399"), + (ProviderId::Kimi, "#FE603C"), + (ProviderId::Notion, "#337EA9"), + (ProviderId::OpenCode, "#3B82F6"), + ]; + + /// WCAG relative luminance of a `#RRGGBB` color, as upstream's + /// `ProviderPaletteRegressionTests` computes it. + fn relative_luminance(hex: &str) -> f64 { + let digits = hex.strip_prefix('#').expect("hex color starts with #"); + assert_eq!(digits.len(), 6, "{hex} is not #RRGGBB"); + let linear = |offset: usize| { + let channel = + f64::from(u8::from_str_radix(&digits[offset..offset + 2], 16).expect("hex digits")) + / 255.0; + if channel <= 0.04045 { + channel / 12.92 + } else { + ((channel + 0.055) / 1.055).powf(2.4) + } + }; + 0.2126 * linear(0) + 0.7152 * linear(2) + 0.0722 * linear(4) + } + + fn contrast_ratio(color: &str, background: &str) -> f64 { + let foreground = relative_luminance(color); + let backdrop = relative_luminance(background); + (foreground.max(backdrop) + 0.05) / (foreground.min(backdrop) + 0.05) + } + + #[test] + fn contrast_ratio_matches_upstream_audit_values() { + // Rows of upstream docs/provider-palette.md, rounded to two decimals. + let rounded = |value: f64| (value * 100.0).round() / 100.0; + assert_eq!(rounded(contrast_ratio("#814EE8", "#FFFFFF")), 5.01); + assert_eq!(rounded(contrast_ratio("#814EE8", "#222222")), 3.17); + assert_eq!(rounded(contrast_ratio("#FF9900", "#FFFFFF")), 2.14); + assert_eq!(rounded(contrast_ratio("#01A88D", "#222222")), 5.29); + assert_eq!(rounded(contrast_ratio("#00FF95", "#FFFFFF")), 1.33); + assert_eq!(rounded(contrast_ratio("#FFFFFF", "#FFFFFF")), 1.0); + } + + #[test] + fn adopted_upstream_palette_accents_are_pinned() { + for (id, _, adopted) in ADOPTED_ACCENTS { + assert_eq!(brand_color(id), adopted, "{id:?}"); + } + for (id, retained) in RETAINED_ACCENTS { + assert_eq!(brand_color(id), retained, "{id:?}"); + } + } + + #[test] + fn adopted_palette_accents_do_not_materially_regress_contrast() { + // Upstream's controlled light and dark menu surfaces. A material + // regression falls below 3:1 while losing at least 0.5 of contrast + // against the accent Windows shipped before. + for (id, previous, _) in ADOPTED_ACCENTS { + for background in ["#FFFFFF", "#222222"] { + let current = contrast_ratio(brand_color(id), background); + let before = contrast_ratio(previous, background); + assert!( + current >= 3.0 || before - current < 0.5, + "{id:?} on {background}: {before:.2} -> {current:.2}" + ); + } + } + } + + #[test] + fn brand_colors_are_uppercase_hex() { + for id in ProviderId::all() { + let color = brand_color(*id); + assert!( + color.len() == 7 + && color.starts_with('#') + && color[1..] + .chars() + .all(|c| c.is_ascii_digit() || matches!(c, 'A'..='F')), + "{id:?} has {color}" + ); + } + } + + #[test] + fn test_provider_id_xkiro() { + assert_eq!(ProviderId::XKiro.cli_name(), "xkiro"); + assert_eq!(ProviderId::XKiro.display_name(), "xKiro"); + assert_eq!(ProviderId::XKiro.cookie_domain(), None); + assert_eq!(ProviderId::from_cli_name("xkiro"), Some(ProviderId::XKiro)); + assert_eq!(ProviderId::from_cli_name("x-kiro"), Some(ProviderId::XKiro)); + assert_eq!(cli_name_map().get("xkiro"), Some(&ProviderId::XKiro)); + assert_eq!(cli_name_map().get("x-kiro"), Some(&ProviderId::XKiro)); + // xKiro is a separate provider from Kiro and shares none of its aliases. + assert_eq!(ProviderId::from_cli_name("kiro"), Some(ProviderId::Kiro)); + assert_eq!(ProviderId::from_cli_name("aws"), Some(ProviderId::Kiro)); + } + + #[test] + fn alibaba_cookie_domain_matches_its_default_region() { + assert_eq!( + ProviderId::Alibaba.cookie_domain(), + Some(crate::providers::AlibabaRegion::Singapore.primary_cookie_domain()) + ); + } + + #[test] + fn instantiated_provider_metadata_matches_the_registry() { + for &id in ProviderId::all() { + let provider = instantiate_provider(id); + assert_eq!(provider.id(), id); + assert_eq!( + format!("{:?}", provider.metadata()), + format!("{:?}", id.metadata()), + "{id:?}" + ); + } + } +} diff --git a/rust/src/core/provider/mod.rs b/rust/src/core/provider/mod.rs new file mode 100644 index 00000000000..a141efadd6d --- /dev/null +++ b/rust/src/core/provider/mod.rs @@ -0,0 +1,13 @@ +//! Provider trait - defines the interface all providers must implement + +mod context; +mod error; +mod id; +mod spec; +mod traits; + +pub use context::*; +pub use error::*; +pub use id::*; +pub use spec::ProviderMetadata; +pub use traits::*; diff --git a/rust/src/core/provider/spec.rs b/rust/src/core/provider/spec.rs new file mode 100644 index 00000000000..042be467cc9 --- /dev/null +++ b/rust/src/core/provider/spec.rs @@ -0,0 +1,674 @@ +//! The provider registry: one row per [`ProviderId`], in declaration order, +//! holding its names, cookie domain, brand color, metadata and CLI aliases. + +use super::ProviderId; +use super::ProviderId as P; + +/// Metadata about a provider +#[derive(Debug, Clone)] +pub struct ProviderMetadata { + pub id: ProviderId, + pub display_name: &'static str, + pub session_label: &'static str, + pub weekly_label: &'static str, + pub supports_opus: bool, + pub supports_credits: bool, + pub default_enabled: bool, + pub is_primary: bool, + pub dashboard_url: Option<&'static str>, + pub status_page_url: Option<&'static str>, + /// Locale key shown for the provider's tertiary metric lane in settings + /// pickers when the lane carries a semantic identity beyond "Tertiary" + /// (upstream F5). `None` renders the generic tertiary label. + pub tertiary_label_key: Option<&'static str>, +} + +/// Static registry row for one provider. +#[derive(Debug)] +pub(crate) struct ProviderSpec { + pub(crate) id: ProviderId, + pub(crate) cli_name: &'static str, + pub(crate) display_name: &'static str, + pub(crate) cookie_domain: Option<&'static str>, + pub(crate) brand_color: &'static str, + pub(crate) metadata: ProviderMetadata, + /// Accepted by `from_cli_name` and listed in `cli_name_map`. + pub(crate) aliases: &'static [&'static str], + /// Accepted by `from_cli_name` only. + pub(crate) parse_aliases: &'static [&'static str], +} + +impl ProviderSpec { + /// Whether an already-lowercased CLI argument names this provider. + pub(crate) fn accepts(&self, name: &str) -> bool { + self.cli_name == name || self.aliases.contains(&name) || self.parse_aliases.contains(&name) + } + + const fn labels(mut self, session: &'static str, weekly: &'static str) -> Self { + self.metadata.session_label = session; + self.metadata.weekly_label = weekly; + self + } + + const fn cookie(mut self, domain: &'static str) -> Self { + self.cookie_domain = Some(domain); + self + } + + /// Metadata display name, where it differs from [`ProviderId::display_name`]. + const fn metadata_name(mut self, name: &'static str) -> Self { + self.metadata.display_name = name; + self + } + + const fn opus(mut self) -> Self { + self.metadata.supports_opus = true; + self + } + + const fn credits(mut self) -> Self { + self.metadata.supports_credits = true; + self + } + + const fn default_enabled(mut self) -> Self { + self.metadata.default_enabled = true; + self + } + + const fn primary(mut self) -> Self { + self.metadata.is_primary = true; + self + } + + const fn dashboard(mut self, url: &'static str) -> Self { + self.metadata.dashboard_url = Some(url); + self + } + + const fn status(mut self, url: &'static str) -> Self { + self.metadata.status_page_url = Some(url); + self + } + + const fn tertiary(mut self, key: &'static str) -> Self { + self.metadata.tertiary_label_key = Some(key); + self + } + + const fn aliases(mut self, aliases: &'static [&'static str]) -> Self { + self.aliases = aliases; + self + } + + const fn parse_aliases(mut self, aliases: &'static [&'static str]) -> Self { + self.parse_aliases = aliases; + self + } +} + +const fn spec( + id: ProviderId, + cli_name: &'static str, + display_name: &'static str, + brand_color: &'static str, +) -> ProviderSpec { + ProviderSpec { + id, + cli_name, + display_name, + cookie_domain: None, + brand_color, + metadata: ProviderMetadata { + id, + display_name, + session_label: "", + weekly_label: "", + supports_opus: false, + supports_credits: false, + default_enabled: false, + is_primary: false, + dashboard_url: None, + status_page_url: None, + tertiary_label_key: None, + }, + aliases: &[], + parse_aliases: &[], + } +} + +const COUNT: usize = 89; + +pub(crate) static ALL: [ProviderId; COUNT] = { + // Indexing SPECS by discriminant relies on rows following declaration order. + assert!(P::Vercel as usize + 1 == COUNT); + let mut ids = [P::Codex; COUNT]; + let mut i = 0; + while i < COUNT { + assert!(SPECS[i].id as usize == i); + ids[i] = SPECS[i].id; + i += 1; + } + ids +}; + +pub(crate) static SPECS: [ProviderSpec; COUNT] = [ + spec(P::Codex, "codex", "Codex", "#49A3B0") + .labels("Session", "Weekly") + .cookie("chatgpt.com") + .credits() + .default_enabled() + .primary() + .dashboard("https://chatgpt.com/codex/cloud/settings/analytics#usage") + .status("https://status.openai.com") + .aliases(&["openai"]), + spec(P::Claude, "claude", "Claude", "#CC7C5E") + .labels("Session (5h)", "Weekly") + .cookie("claude.ai") + .opus() + .credits() + .default_enabled() + .primary() + .dashboard("https://claude.ai/settings/usage") + .status("https://status.claude.com/") + .aliases(&["anthropic"]), + spec(P::Pi, "pi", "Pi", "#7C3AED") + .labels("Session", "Weekly") + .dashboard("https://github.com/badlogic/pi-mono") + .parse_aliases(&["pi-mono"]), + // Upstream #2338: Cursor has no account credit balance to advertise. + spec(P::Cursor, "cursor", "Cursor", "#F54E00") + .labels("Plan", "Cursor") + .cookie("cursor.com") + .default_enabled() + .dashboard("https://cursor.com/dashboard/usage"), + spec(P::Factory, "factory", "Factory", "#FF6B35") + .labels("Standard", "Premium") + .cookie("app.factory.ai") + .metadata_name("Droid") + .credits() + .dashboard("https://app.factory.ai") + .status("https://status.factory.ai") + .parse_aliases(&["droid"]), + spec(P::Gemini, "gemini", "Gemini", "#AB87EA") + .labels("Daily", "Daily") + .cookie("aistudio.google.com") + .default_enabled() + .dashboard("https://aistudio.google.com") + .status("https://status.cloud.google.com") + .aliases(&["google"]), + spec(P::Antigravity, "antigravity", "Antigravity", "#60BA7E") + .labels("Claude", "Gemini Pro") + .cookie("antigravity.ai") + .opus() + .aliases(&["agy"]), + spec(P::Copilot, "copilot", "Copilot", "#A855F7") + .labels("Premium", "Chat") + .metadata_name("GitHub Copilot") + .default_enabled() + .dashboard("https://github.com/settings/copilot") + .status("https://www.githubstatus.com/") + .aliases(&["github"]), + spec(P::Zai, "zai", "z.ai", "#E85A6A") + .labels("Tokens", "MCP") + .credits() + .dashboard("https://z.ai/manage-apikey/coding-plan/personal/my-plan") + .parse_aliases(&["z.ai"]), + spec(P::MiniMax, "minimax", "MiniMax", "#FE603C") + .labels("Usage", "Monthly") + .cookie("platform.minimax.io") + .credits() + .dashboard("https://platform.minimax.io/user-center/payment/coding-plan?cycle_type=3"), + spec(P::Kiro, "kiro", "Kiro", "#9046FF") + .labels("Session", "Monthly") + .cookie("kiro.dev") + .credits() + .dashboard("https://kiro.dev/account") + .status("https://health.aws.amazon.com") + .aliases(&["aws"]), + spec(P::VertexAI, "vertexai", "Vertex AI", "#4285F4") + .labels("Usage", "Monthly") + .credits() + .dashboard("https://console.cloud.google.com/vertex-ai") + .status("https://status.cloud.google.com") + .aliases(&["vertex"]) + .parse_aliases(&["vertex ai"]), + spec(P::Augment, "augment", "Augment", "#1AA049") + .labels("Session", "Monthly") + .cookie("app.augmentcode.com") + .credits() + .dashboard("https://app.augmentcode.com/account") + .status("https://status.augmentcode.com"), + spec(P::OpenCode, "opencode", "OpenCode", "#3B82F6") + .labels("5-hour", "Weekly") + .cookie("opencode.ai") + .dashboard("https://opencode.ai"), + spec(P::Kimi, "kimi", "Kimi", "#FE603C") + .labels("Weekly", "Rate Limit") + .cookie("kimi.moonshot.cn") + .dashboard("https://kimi.moonshot.cn") + .parse_aliases(&["moonshot"]), + // Soft-removed (upstream #2254); still resolvable via CLI for legacy configs. + spec(P::KimiK2, "kimik2", "Kimi K2 (removed)", "#4C00FF") + .labels("Balance", "Cash") + .cookie("platform.moonshot.cn") + .metadata_name("Moonshot / Kimi Open Platform") + .credits() + .dashboard("https://platform.moonshot.ai/console/account") + .parse_aliases(&["kimi-k2", "kimi k2", "k2", "kimi k2 (removed)"]), + spec(P::Amp, "amp", "Amp", "#F34E3F") + .labels("Usage", "Monthly") + .cookie("sourcegraph.com") + .credits() + .dashboard("https://ampcode.com/settings/usage") + .status("https://sourcegraphstatus.com") + .aliases(&["sourcegraph"]), + spec(P::Warp, "warp", "Warp", "#6366F1") + .labels("Credits", "Add-on credits") + .dashboard("https://docs.warp.dev/reference/cli/api-keys") + .aliases(&["warp-ai", "warp-terminal"]), + spec(P::Ollama, "ollama", "Ollama", "#8B95B0") + .labels("Session", "Weekly") + .cookie("ollama.com") + .dashboard("https://ollama.com/settings"), + spec(P::AzureOpenAI, "azureopenai", "Azure OpenAI", "#0078D4") + .labels("Deployment", "Status") + .dashboard("https://ai.azure.com") + .status("https://status.azure.com") + .parse_aliases(&["azure-openai", "azure openai"]), + spec(P::T3Chat, "t3chat", "T3 Chat", "#8B5CF6") + .labels("Base", "Overage") + .cookie("t3.chat") + .dashboard("https://t3.chat/settings/customization") + .parse_aliases(&["t3-chat", "t3 chat"]), + spec(P::OpenRouter, "openrouter", "OpenRouter", "#6B7280") + .labels("Credits", "API key limit") + .credits() + .dashboard("https://openrouter.ai/activity") + .status("https://status.openrouter.ai") + .aliases(&["or"]), + spec(P::JetBrains, "jetbrains", "JetBrains AI", "#FF3399") + .labels("Credits", "Monthly") + .credits() + .dashboard("https://www.jetbrains.com/ai/") + .parse_aliases(&["jetbrains-ai", "jetbrains ai", "intellij"]), + spec(P::Alibaba, "alibaba", "Alibaba", "#FF6A00") + .labels("5-Hour", "Weekly") + .cookie("modelstudio.console.alibabacloud.com") + .dashboard("https://modelstudio.console.alibabacloud.com") + .aliases(&["tongyi", "qianwen"]), + spec( + P::AlibabaTokenPlan, + "alibabatokenplan", + "Alibaba Token Plan", + "#FF6A00", + ) + .labels("Credits", "Usage") + .cookie("bailian.console.aliyun.com") + .dashboard( + "https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/token-plan", + ) + .status("https://status.aliyun.com") + .parse_aliases(&[ + "alibaba-token-plan", + "alibaba token plan", + "alibaba-token", + "bailian-token-plan", + ]), + spec(P::NanoGPT, "nanogpt", "NanoGPT", "#687FA1") + .labels("Daily", "Monthly") + .dashboard("https://nano-gpt.com/usage") + .parse_aliases(&["nano-gpt"]), + spec(P::Infini, "infini", "Infini", "#687FA1") + .labels("5-Hour", "7-Day") + .dashboard("https://cloud.infini-ai.com") + .aliases(&["infini-ai"]), + spec(P::Perplexity, "perplexity", "Perplexity", "#1FB8CD") + .labels("Credits", "Bonus credits") + .cookie("perplexity.ai") + .credits() + .dashboard("https://www.perplexity.ai/account/usage") + .aliases(&["pplx"]), + spec(P::Abacus, "abacus", "Abacus AI", "#814EE8") + .labels(crate::providers::abacus::CREDITS_LABEL, "") + .cookie("apps.abacus.ai") + .credits() + .dashboard("https://apps.abacus.ai/app/billing") + .aliases(&["abacus-ai"]) + .parse_aliases(&["abacus ai"]), + spec(P::Mistral, "mistral", "Mistral", "#FF5229") + .labels("Monthly", "") + .cookie("admin.mistral.ai") + .credits() + .dashboard("https://admin.mistral.ai/organization/usage") + .status("https://status.mistral.ai") + .aliases(&["mistral-ai"]) + .parse_aliases(&["mistral ai"]), + spec(P::OpenCodeGo, "opencodego", "OpenCode Go", "#3B82F6") + .labels("5-hour", "Weekly") + .cookie("opencode.ai") + .opus() + .dashboard("https://opencode.ai") + .tertiary("ProviderMonthly") + .aliases(&["opencode-go"]) + .parse_aliases(&["opencode go"]), + spec(P::Kilo, "kilo", "Kilo", "#5D87FF") + .labels("Credits", "Pass") + .credits() + .dashboard("https://app.kilo.ai/usage"), + spec(P::Bedrock, "bedrock", "AWS Bedrock", "#01A88D") + .labels("Budget", "Cost") + .dashboard("https://console.aws.amazon.com/bedrock") + .status("https://health.aws.amazon.com/health/status") + .aliases(&["aws-bedrock", "aws bedrock"]), + spec(P::Codebuff, "codebuff", "Codebuff", "#00FF95") + .labels("Credits", "Weekly") + .credits() + .dashboard("https://www.codebuff.com/usage") + .aliases(&["manicode"]), + spec(P::CodeRabbit, "coderabbit", "CodeRabbit", "#FF5C35") + .labels("Reviews", "Billing") + .dashboard("https://app.coderabbit.ai") + .status("https://status.coderabbit.ai") + .parse_aliases(&["code-rabbit", "code rabbit"]), + spec(P::DeepSeek, "deepseek", "DeepSeek", "#4D6BFE") + .labels("Balance", "Balance") + .credits() + .dashboard("https://platform.deepseek.com/usage") + .status("https://status.deepseek.com") + .aliases(&["deep-seek", "ds"]), + // Upstream marks supportsCredits=false; balance is shown via primary window text. + spec(P::DeepInfra, "deepinfra", "DeepInfra", "#2A3275") + .labels("Balance", "Balance") + .dashboard("https://deepinfra.com/dash") + .status("https://status.deepinfra.com") + .aliases(&["deep-infra", "di"]), + spec(P::AiAnd, "aiand", "ai&", "#E25C2B") + .labels("Spend", "Spend") + .dashboard("https://console.aiand.com") + .aliases(&["ai&", "ai-and"]) + .parse_aliases(&["ai and"]), + spec(P::Windsurf, "windsurf", "Windsurf", "#22C55E") + .labels("Daily", "Weekly") + .dashboard("https://windsurf.com/subscription") + .status("https://status.windsurf.com") + .aliases(&["codeium"]), + spec(P::Manus, "manus", "Manus", "#34322D") + .labels("Credits", "Refresh") + .cookie("manus.im") + .credits() + .dashboard("https://manus.im"), + spec(P::MiMo, "mimo", "Xiaomi MiMo", "#FF6900") + .labels("Tokens", "Balance") + .cookie("platform.xiaomimimo.com") + .credits() + .dashboard("https://platform.xiaomimimo.com/#/console/balance") + .aliases(&["xiaomimimo", "xiaomi-mimo"]) + .parse_aliases(&["xiaomi", "xiaomi mimo"]), + spec(P::Doubao, "doubao", "Doubao", "#2563EB") + .labels("Requests", "Usage") + .dashboard("https://console.volcengine.com/ark/region:ark+cn-beijing/usage") + .aliases(&["ark", "volcengine"]), + spec(P::CommandCode, "commandcode", "Command Code", "#8C4EDD") + .labels("5-hour", "Weekly") + .cookie("commandcode.ai") + .credits() + .dashboard("https://commandcode.ai") + .aliases(&["command-code"]) + .parse_aliases(&["command code"]), + spec(P::StepFun, "stepfun", "StepFun", "#999999") + .labels("5-hour", "Weekly") + .dashboard("https://platform.stepfun.com/dashboard") + .aliases(&["step-fun"]) + .parse_aliases(&["step fun"]), + spec(P::Venice, "venice", "Venice", "#3C8FDD") + .labels("Balance", "DIEM") + .cookie("venice.ai") + .credits() + .dashboard("https://venice.ai/settings/api"), + spec(P::OpenAIApi, "openaiapi", "OpenAI API", "#10A37F") + .labels("Spend", "Requests") + .metadata_name("OpenAI") + .dashboard("https://platform.openai.com/usage") + .status("https://status.openai.com") + .aliases(&["openai-api", "openai-balance"]) + .parse_aliases(&["openai api"]), + spec(P::Grok, "grok", "Grok", "#111827") + .labels("Credits", "On-demand") + .cookie("grok.com") + .dashboard("https://grok.com/?_s=usage") + .status("https://status.x.ai") + .aliases(&["supergrok"]) + .parse_aliases(&["super-grok"]), + spec(P::ElevenLabs, "elevenlabs", "ElevenLabs", "#111827") + .labels("Credits", "Voices") + .credits() + .dashboard("https://elevenlabs.io/app/settings/api-keys") + .status("https://status.elevenlabs.io") + .aliases(&["eleven-labs", "11labs"]), + spec(P::Deepgram, "deepgram", "Deepgram", "#13EF93") + .labels("Requests", "Usage") + .credits() + .dashboard("https://console.deepgram.com/usage") + .status("https://status.deepgram.com") + .aliases(&["dg"]), + spec(P::Groq, "groq", "Groq", "#F55036") + .labels("Requests", "Tokens") + .credits() + .dashboard("https://console.groq.com/settings/metrics") + .status("https://status.groq.com") + .aliases(&["groqcloud", "groq-cloud"]) + .parse_aliases(&["groq cloud"]), + spec(P::HuggingFace, "huggingface", "Hugging Face", "#FFD21E") + .labels("Credits", "ZeroGPU") + .dashboard("https://huggingface.co/settings/billing") + .status("https://status.huggingface.co") + .aliases(&["hugging-face", "hf"]) + .parse_aliases(&["hugging face"]), + spec(P::Helmcode, "helmcode", "Helmcode", "#4F46E5") + .labels("Quota", "Quota") + .cookie("helmcode.com") + .credits() + .dashboard("https://cloud.helmcode.com/dashboard") + .aliases(&["nan-builders"]) + .parse_aliases(&["nan builders"]), + spec(P::V0, "v0", "v0", "#111827") + .labels("Billing", "Rate limit") + .credits() + .dashboard("https://v0.app/settings/billing") + .status("https://www.vercel-status.com/") + .aliases(&["v0-dev"]) + .parse_aliases(&["v0.dev"]), + spec(P::TypeSafe, "typesafe", "TypeSafe", "#2563EB") + .labels("Balance", "Spend") + .cookie("typesafe.ai") + .credits() + .dashboard(crate::providers::typesafe::BILLING_URL) + .aliases(&["type-safe"]) + .parse_aliases(&["type safe"]), + spec(P::LLMProxy, "llmproxy", "LLM Proxy", "#4F46E5") + .labels("Quota", "Requests") + .credits() + .aliases(&["llm-proxy"]) + .parse_aliases(&["llm proxy"]), + spec(P::Chutes, "chutes", "Chutes", "#FF5C35") + .labels("4-hour quota", "Monthly quota") + .credits() + .dashboard("https://chutes.ai") + .aliases(&["chutes-ai"]) + .parse_aliases(&["chutes ai"]), + spec(P::LiteLLM, "litellm", "LiteLLM", "#0EA5E9") + .labels("Personal budget", "Team budget") + .aliases(&["lite-llm"]) + .parse_aliases(&["lite llm"]), + spec(P::Poe, "poe", "Poe", "#5D5FEF") + .labels("Balance", "Points") + .credits() + .dashboard("https://poe.com/settings/subscription"), + spec(P::Devin, "devin", "Devin", "#317CFF") + .labels("Daily", "Weekly") + .credits() + .dashboard("https://app.devin.ai/settings/billing"), + spec(P::Zed, "zed", "Zed", "#084CCF") + .labels("Edits", "Cycle") + .cookie("zed.dev") + .credits() + .dashboard("https://zed.dev/account") + .aliases(&["zed-ai"]), + // Soft-removed (upstream #2254); still resolvable via CLI for legacy configs. + spec( + P::CrossModel, + "crossmodel", + "CrossModel (removed)", + "#C084FC", + ) + .labels("Daily cost", "Weekly cost") + .metadata_name("CrossModel") + .credits() + .dashboard("https://crossmodel.ai") + .aliases(&["cross-model"]) + .parse_aliases(&["cross model", "crossmodel (removed)"]), + spec(P::Qoder, "qoder", "Qoder", "#2563EB") + .labels("Credits", "Shared credits") + .cookie("qoder.com") + .credits() + .dashboard("https://qoder.com/account/usage"), + spec(P::CodeBuddy, "codebuddy", "CodeBuddy", "#0052D9") + .labels("Credits", "Packages") + .cookie("codebuddy.cn") + .credits() + .dashboard("https://www.codebuddy.cn/profile/plans-usage") + .parse_aliases(&["code-buddy", "codebuddy-cn", "codebuddycn", "腾讯codebuddy"]), + spec(P::Sakana, "sakana", "Sakana AI", "#0EA5E9") + .labels("5-hour", "Weekly") + .cookie("console.sakana.ai") + .dashboard(crate::providers::sakana::BILLING_URL) + .aliases(&["sakana-ai"]) + .parse_aliases(&["sakana ai"]), + spec(P::Sub2Api, "sub2api", "sub2api", "#14B8A6") + .labels("Quota", "Weekly quota") + .aliases(&["sub-2-api"]) + .parse_aliases(&["sub 2 api"]), + spec(P::Wayfinder, "wayfinder", "Wayfinder", "#14B8A6").labels("Gateway", "Savings"), + spec(P::ZenMux, "zenmux", "ZenMux", "#6C5CE7") + .labels("5-hour quota", "Weekly quota") + .dashboard("https://zenmux.ai/platform/management") + .parse_aliases(&["zen-mux"]), + spec(P::ClinePass, "clinepass", "ClinePass", "#5487C8") + .labels("5-hour", "Weekly") + .opus() + .dashboard("https://app.cline.bot/dashboard/subscription?personal=true") + .parse_aliases(&["cline-pass", "cline"]), + spec(P::LongCat, "longcat", "LongCat", "#29E154") + .labels("Quota", "Fuel Pack") + .cookie("longcat.chat") + .dashboard("https://longcat.chat/platform/") + .parse_aliases(&["long-cat", "lc"]), + spec(P::Neuralwatt, "neuralwatt", "Neuralwatt", "#D55934") + .labels("Subscription", "Key allowance") + .dashboard("https://portal.neuralwatt.com/dashboard") + .parse_aliases(&["neural-watt", "nw", "neural"]), + spec(P::ZoomMate, "zoommate", "ZoomMate", "#0B5CFF") + .labels("Credits", "Credits") + .cookie("zoommate.zoom.us") + .credits() + .dashboard("https://zoommate.zoom.us/#/?settings=credit-usage") + .status("https://www.zoomstatus.com/") + .parse_aliases(&["zoom-mate", "zoom mate"]), + spec(P::QwenCloud, "qwen-cloud", "Qwen Cloud", "#615CED") + .labels("5-hour", "Weekly") + .cookie("qwencloud.com") + .dashboard(crate::providers::qwencloud::DASHBOARD_URL) + .status("https://status.alibabacloud.com") + .aliases(&["qwencloud", "qwen", "qwen-token-plan"]) + .parse_aliases(&["qwen cloud"]), + spec(P::Notion, "notion", "Notion AI", "#337EA9") + .labels("Rolling", "Monthly") + .cookie("app.notion.com") + .dashboard(crate::providers::notion::DASHBOARD_URL) + .status("https://status.notion.so") + .aliases(&["notion-ai", "notionai"]) + .parse_aliases(&["notion ai"]), + spec(P::Xai, "xai", "xAI", "#8E8E93") + .labels("Spend", "Spend") + .dashboard("https://console.x.ai") + .status("https://status.x.ai") + .aliases(&["x.ai"]) + .parse_aliases(&["x-ai"]), + spec(P::Fireworks, "fireworks", "Fireworks", "#F25B1C") + .labels("Spend", "Spend") + .dashboard("https://app.fireworks.ai") + .aliases(&["fireworks-ai", "fw"]), + spec(P::AtlasCloud, "atlascloud", "Atlas Cloud", "#5975F5") + .labels("Balance", "Balance") + .dashboard(crate::providers::atlascloud::DASHBOARD_URL) + .parse_aliases(&["atlas-cloud", "atlas cloud"]), + spec(P::Meta, "meta", "Meta", "#0467DF") + .labels("Status", "Models") + .dashboard("https://dev.meta.ai/docs") + .aliases(&[ + "metaspark", + "meta-spark", + "muse-spark", + "musespark", + "muse spark", + "meta muse spark", + ]), + spec(P::Muse, "muse", "Muse Code", "#0668E1") + .labels("5 hours", "Weekly") + .cookie("dev.meta.ai") + .dashboard("https://dev.meta.ai") + .aliases(&["muse-code", "muse code"]), + spec(P::Replicate, "replicate", "Replicate", "#000000") + .labels("Spend", "Spend") + .cookie("replicate.com") + .credits() + .dashboard(crate::providers::replicate::BILLING_URL) + .parse_aliases(&["r8"]), + spec(P::Nous, "nous", "Nous Portal", "#D6A55C") + .labels("Monthly credits", "Weekly") + .dashboard("https://portal.nousresearch.com/usage") + .aliases(&["nous-portal", "nous portal", "hermes"]), + spec(P::Hyper, "hyper", "Charm Hyper", "#FF60FF") + .labels("Balance", "Balance") + .cookie("hyper.charm.land") + .dashboard("https://hyper.charm.land") + .parse_aliases(&["charm-hyper", "charm hyper"]), + spec(P::GitKraken, "gitkraken", "GitKraken AI", "#179287") + .labels("Personal", "Shared pool") + .dashboard("https://gitkraken.dev/account#ai-usage") + .parse_aliases(&["gitkraken-ai", "gitkraken ai"]), + spec(P::Bifrost, "bifrost", "Bifrost", "#33C09E") + .labels("Budget", "Spend") + .credits() + .parse_aliases(&["bifrost-gateway", "bifrost gateway"]), + spec(P::Aixy, "aixy", "Aixy", "#123650") + .labels("Budget", "Secondary budget") + .dashboard("https://dash.aixy-gateway.com") + .parse_aliases(&["aixy-gateway", "aixy gateway"]), + spec(P::LLMMan, "llmman", "llmman", "#6CC5B0") + .labels("Memory", "Models") + .dashboard(crate::providers::llmman::DEFAULT_BASE_URL), + spec(P::DevPass, "devpass", "DevPass", "#2563EB") + .labels("Plan credits", "Premium weekly") + .dashboard("https://devpass.llmgateway.io/dashboard"), + spec(P::XKiro, "xkiro", "xKiro", "#52C99B") + .labels("Daily free tokens", "Weekly") + .dashboard("https://xkiro.com") + .aliases(&["x-kiro"]), + spec(P::Raycast, "raycast", "Raycast", "#FF6363") + .labels("Credits", "Plan") + .cookie("www.raycast.com") + .dashboard(crate::providers::raycast::SETTINGS_URL) + .parse_aliases(&["raycast-ai"]), + // Upstream uses white; a mid neutral keeps contrast on light and dark surfaces. + spec(P::Vercel, "vercel", "Vercel AI Gateway", "#737373") + .labels("Balance", "Balance") + .dashboard("https://vercel.com/d?to=%2F%5Bteam%5D%2F%7E%2Fai-gateway") + .aliases(&[ + "vercel-ai-gateway", + "vercel ai gateway", + "ai-gateway", + "ai gateway", + ]), +]; diff --git a/rust/src/core/provider/traits.rs b/rust/src/core/provider/traits.rs new file mode 100644 index 00000000000..a8a62bf0275 --- /dev/null +++ b/rust/src/core/provider/traits.rs @@ -0,0 +1,214 @@ +//! The [`Provider`] trait and the shell policies a provider can select. + +use async_trait::async_trait; + +use super::{FetchContext, ProviderError, ProviderId, ProviderMetadata, SourceMode}; +use crate::core::ProviderFetchResult; +use crate::core::ProviderStateKind; + +/// How the shell should treat a failed refresh when a prior good snapshot exists. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LastGoodFailurePolicy { + Replace, + Preserve, + PreserveOnce, + PreserveOnceThenSurface, +} + +/// How the shell should treat a manual cookie source with no cookie present. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ManualEmptyCookiePolicy { + /// Remap to the shell's generic browser-cookie attempt. + Fallback, + /// Keep `SourceMode::Web` with no header so the provider fails closed + /// instead of importing a browser account the user did not select. + FailClosedWeb, +} + +/// Trait that all providers must implement +#[allow( + clippy::double_must_use, + reason = "async-trait marks its boxed futures #[must_use]" +)] +#[async_trait] +pub trait Provider: Send + Sync { + /// Get the provider's unique identifier + fn id(&self) -> ProviderId; + + /// Get provider metadata + fn metadata(&self) -> &ProviderMetadata { + self.id().metadata() + } + + /// Fetch usage data from this provider + async fn fetch_usage(&self, ctx: &FetchContext) -> Result; + + /// Get the available source modes for this provider + fn available_sources(&self) -> Vec { + vec![SourceMode::Auto] + } + + /// Check if OAuth is supported + fn supports_oauth(&self) -> bool { + false + } + + /// Check if web API (cookies) is supported + fn supports_web(&self) -> bool { + false + } + + /// Check if CLI probe is supported + fn supports_cli(&self) -> bool { + false + } + + /// Detect the version of the CLI tool (if applicable) + fn detect_version(&self) -> Option { + None + } + + /// Whether an explicitly selected manual cookie outranks a token-account override. + fn manual_cookie_precedes_token_account(&self) -> bool { + false + } + + /// Whether a selected token account leaves an `Auto` usage source as `Auto`. + /// + /// The shell normally maps a token account with an environment override to + /// the OAuth (API-only) lane. A provider whose `Auto` source adds an + /// optional extra on top of the API credential, such as the Hugging Face + /// prepaid wallet, opts in so the account token does not silently drop it. + /// An explicitly chosen non-Auto usage source still maps to OAuth. + fn token_account_preserves_auto_source(&self) -> bool { + false + } + + /// How the shell treats a manual cookie source with no cookie present. + /// + /// `Fallback` lets the shell remap to its generic browser-cookie attempt. + /// `FailClosedWeb` keeps `SourceMode::Web` without any header, so the + /// provider fails closed instead of importing a browser account the user + /// did not select. + fn manual_empty_cookie_policy(&self) -> ManualEmptyCookiePolicy { + ManualEmptyCookiePolicy::Fallback + } + + /// Whether Automatic metric selection should prefer an exhausted quota lane. + fn automatic_metric_prioritizes_exhausted_window(&self) -> bool { + true + } + + /// Whether an explicit (non-Automatic) metric preference whose lane is + /// unavailable should still fall through to Automatic selection. Providers + /// with Automatic-only fallback lanes (seat credits) override this to + /// `false` so an explicit choice is never silently replaced by fallback + /// progress. + fn explicit_preference_falls_through_to_automatic(&self) -> bool { + true + } + + /// Whether Automatic metric selection is a dead end when the primary lane + /// is informational and no secondary lane exists. Providers with + /// Automatic-only fallback lanes (seat credits) or a named extra lane that + /// can be the only reported quota (Kimi's monthly pool) override this to + /// `false` so that lane can still fill in. + fn automatic_metric_missing_core_is_terminal(&self) -> bool { + true + } + + /// Whether Automatic metric selection shows the secondary lane whenever + /// neither core lane is exhausted, instead of the fuller lane. An + /// exhausted primary or secondary lane still wins, primary first. + fn automatic_metric_prefers_secondary_window(&self) -> bool { + false + } + + /// Id of the extra rate window that holds this provider's monthly plan + /// allowance. The `MonthlyPlan` menu bar metric selects that window + /// (upstream 0.70.0 #4072). `None` means the provider offers no Monthly + /// Plan metric. + fn monthly_plan_window_id(&self) -> Option<&'static str> { + None + } + + /// Label for the primary lane in the menu bar metric picker when the + /// lane is not a session window (upstream `menuBarLayoutPrimaryLabel`). + /// `None` keeps the generic session label. + fn menu_bar_primary_label(&self) -> Option<&'static str> { + None + } + + /// Whether cookies only enrich an API-backed result instead of being a + /// usage source of their own. The shell then keeps the configured usage + /// source, forwards a manual cookie as-is, and reads a browser only when + /// the cookie source is Automatic (`FetchContext::browser_cookie_import`); + /// the default manual state with no cookie never triggers a browser read. + fn cookies_only_enrich_usage(&self) -> bool { + false + } + + /// Whether browser-cookie discovery/recovery is owned by the provider. + fn owns_browser_cookie_resolution(&self) -> bool { + false + } + + /// Whether the web lane is used only when the usage source is explicitly + /// `web`. A cookie domain otherwise lets the shell turn Auto into Web + /// (manual cookie present or browser import), which would replace a + /// provider's default non-web credential. + fn web_is_opt_in(&self) -> bool { + false + } + + /// Whether the cookie source only scopes the browser session the + /// provider may use, leaving the selected usage source in charge of + /// routing. + /// + /// When true, the shell keeps the usage source for every cookie source: + /// `off` and a `manual` source without a stored cookie pass no header and + /// set [`FetchContext::manual_cookie_missing`], so the provider skips the + /// session and Auto can still use an API key. Otherwise the shell remaps + /// the source mode from the cookie source. + fn cookie_source_scopes_session_only(&self) -> bool { + false + } + + /// How the shell should treat a failed refresh when a prior good snapshot exists. + fn last_good_failure_policy(&self, _error: &str) -> LastGoodFailurePolicy { + LastGoodFailurePolicy::Replace + } + + /// Whether this provider can safely retain its last good snapshot on a + /// classified transport failure. + fn retains_last_good_on_transport_failure(&self) -> bool { + false + } + + /// Typed variant used before an error is sanitized for the frontend. + /// + /// Providers that need message-based distinctions can keep overriding the + /// string method. Transport retention is selected by the provider + /// capability and the typed error classification above. + fn last_good_failure_policy_for_error(&self, error: &ProviderError) -> LastGoodFailurePolicy { + if matches!(error, ProviderError::OAuthTransient(_)) { + return LastGoodFailurePolicy::Preserve; + } + if self.retains_last_good_on_transport_failure() && error.is_transport_failure() { + return LastGoodFailurePolicy::Preserve; + } + self.last_good_failure_policy(&error.to_string()) + } + + /// Presentation-safe availability state for a refresh error. The default + /// maps `ProviderError` variants, treating `NotInstalled` as a missing + /// credential (most providers raise it for a missing API key or auth + /// file). Override only when a variant carries provider-specific meaning + /// that differs — e.g. a local language-server probe or CLI/binary + /// presence check whose "not installed" means the runtime is simply + /// not running; prefer a message-contains guard when the provider also + /// raises credential-flavored `NotInstalled` errors. + fn error_state_kind(&self, error: &ProviderError) -> ProviderStateKind { + error.state_kind() + } +} diff --git a/rust/src/core/provider_transport_tests.rs b/rust/src/core/provider/transport_tests.rs similarity index 100% rename from rust/src/core/provider_transport_tests.rs rename to rust/src/core/provider/transport_tests.rs diff --git a/rust/src/providers/abacus/mod.rs b/rust/src/providers/abacus/mod.rs index 43fcb572c71..16337457df6 100644 --- a/rust/src/providers/abacus/mod.rs +++ b/rust/src/providers/abacus/mod.rs @@ -14,8 +14,8 @@ use serde::{Deserialize, Deserializer}; use serde_json::Value; use crate::core::{ - FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, ProviderMetadata, - ProviderStateKind, RateWindow, SourceMode, UsageSnapshot, + FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, ProviderStateKind, + RateWindow, SourceMode, UsageSnapshot, }; #[cfg(test)] @@ -26,7 +26,7 @@ const COMPUTE_PATH: &str = "/api/_getOrganizationComputePoints"; const BILLING_PATH: &str = "/api/_getBillingInfo"; /// Parent domain of `apps.abacus.ai`; one browser query covers both hosts. const COOKIE_DOMAIN: &str = "abacus.ai"; -const CREDITS_LABEL: &str = "Credits"; +pub(crate) const CREDITS_LABEL: &str = "Credits"; const FALLBACK_MONTHLY_WINDOW_MINUTES: u32 = 30 * 24 * 60; const MAX_COOKIE_CANDIDATES: u32 = 5; const MAX_BODY_BYTES: usize = 1024 * 1024; @@ -114,7 +114,6 @@ fn lenient_finite_number<'de, D: Deserializer<'de>>( } pub struct AbacusProvider { - metadata: ProviderMetadata, client: Client, origin: String, } @@ -126,19 +125,6 @@ impl AbacusProvider { fn with_origin(origin: &str) -> Self { Self { - metadata: ProviderMetadata { - id: ProviderId::Abacus, - display_name: "Abacus AI", - session_label: CREDITS_LABEL, - weekly_label: "", - supports_opus: false, - supports_credits: true, - default_enabled: false, - is_primary: false, - dashboard_url: Some("https://apps.abacus.ai/app/billing"), - status_page_url: None, - tertiary_label_key: None, - }, // Every request sets its own timeout; the client has none so a // configured web timeout above 30 s is honored. client: crate::core::credentialed_http_client_builder() @@ -486,10 +472,6 @@ impl Provider for AbacusProvider { ProviderId::Abacus } - fn metadata(&self) -> &ProviderMetadata { - &self.metadata - } - async fn fetch_usage(&self, ctx: &FetchContext) -> Result { tracing::debug!("Fetching Abacus AI usage"); diff --git a/rust/src/providers/aiand/mod.rs b/rust/src/providers/aiand/mod.rs index 005e45a78c0..bf91d5fa8c5 100644 --- a/rust/src/providers/aiand/mod.rs +++ b/rust/src/providers/aiand/mod.rs @@ -10,7 +10,7 @@ use serde::Deserialize; use crate::core::{ CostSnapshot, FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, - ProviderMetadata, RateWindow, SourceMode, UsageSnapshot, + RateWindow, SourceMode, UsageSnapshot, }; const LOGS_URL: &str = "https://api.aiand.com/logs"; @@ -126,26 +126,12 @@ impl AiAndSnapshot { } pub struct AiAndProvider { - metadata: ProviderMetadata, client: Client, } impl AiAndProvider { pub fn new() -> Self { Self { - metadata: ProviderMetadata { - id: ProviderId::AiAnd, - display_name: "ai&", - session_label: "Spend", - weekly_label: "Spend", - supports_opus: false, - supports_credits: false, - default_enabled: false, - is_primary: false, - dashboard_url: Some("https://console.aiand.com"), - status_page_url: None, - tertiary_label_key: None, - }, client: crate::core::credentialed_http_client_builder() .timeout(std::time::Duration::from_secs(15)) .build() @@ -265,10 +251,6 @@ impl Provider for AiAndProvider { ProviderId::AiAnd } - fn metadata(&self) -> &ProviderMetadata { - &self.metadata - } - async fn fetch_usage(&self, ctx: &FetchContext) -> Result { match ctx.source_mode { SourceMode::Auto | SourceMode::OAuth => self.fetch_usage_api(ctx).await, diff --git a/rust/src/providers/aixy/mod.rs b/rust/src/providers/aixy/mod.rs index 76d965b9a56..154adb8ed2e 100644 --- a/rust/src/providers/aixy/mod.rs +++ b/rust/src/providers/aixy/mod.rs @@ -16,40 +16,24 @@ use async_trait::async_trait; use reqwest::{Client, StatusCode, Url}; use crate::core::{ - FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, ProviderMetadata, - SourceMode, + FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, SourceMode, }; const CREDENTIAL_TARGET: &str = "codexbar-aixy"; const API_KEY_ENV: &str = "AIXY_API_KEY"; const BASE_URL_ENV: &str = "AIXY_BASE_URL"; const DEFAULT_BASE_URL: &str = "https://api.aixy-gateway.com"; -const DASHBOARD_URL: &str = "https://dash.aixy-gateway.com"; const USAGE_PATH: &str = "/v1/usage"; const REQUEST_TIMEOUT: Duration = Duration::from_secs(15); const MAX_RESPONSE_BYTES: usize = 2 * 1024 * 1024; pub struct AixyProvider { - metadata: ProviderMetadata, client: Option, } impl AixyProvider { pub fn new() -> Self { Self { - metadata: ProviderMetadata { - id: ProviderId::Aixy, - display_name: "Aixy", - session_label: "Budget", - weekly_label: "Secondary budget", - supports_opus: false, - supports_credits: false, - default_enabled: false, - is_primary: false, - dashboard_url: Some(DASHBOARD_URL), - status_page_url: None, - tertiary_label_key: None, - }, client: crate::core::credentialed_http_client_builder() .timeout(REQUEST_TIMEOUT) // The Bearer credential must never follow a gateway redirect @@ -116,10 +100,6 @@ impl Provider for AixyProvider { ProviderId::Aixy } - fn metadata(&self) -> &ProviderMetadata { - &self.metadata - } - async fn fetch_usage(&self, ctx: &FetchContext) -> Result { match ctx.source_mode { SourceMode::Auto | SourceMode::OAuth => self.fetch_api(ctx).await, diff --git a/rust/src/providers/alibaba/mod.rs b/rust/src/providers/alibaba/mod.rs index 1f8d7d5f4cc..1f0c6fdd9c9 100644 --- a/rust/src/providers/alibaba/mod.rs +++ b/rust/src/providers/alibaba/mod.rs @@ -12,8 +12,8 @@ mod sec_token; use async_trait::async_trait; use crate::core::{ - FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, ProviderMetadata, - SourceMode, UsageSnapshot, + FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, SourceMode, + UsageSnapshot, }; use crate::providers::browser_cookie_header; @@ -27,27 +27,11 @@ use self::sec_token::{ const USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) \ AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36"; -pub struct AlibabaProvider { - metadata: ProviderMetadata, -} +pub struct AlibabaProvider {} impl AlibabaProvider { pub fn new() -> Self { - Self { - metadata: ProviderMetadata { - id: ProviderId::Alibaba, - display_name: "Alibaba", - session_label: "5-Hour", - weekly_label: "Weekly", - supports_opus: false, - supports_credits: false, - default_enabled: false, - is_primary: false, - dashboard_url: Some("https://modelstudio.console.alibabacloud.com"), - status_page_url: None, - tertiary_label_key: None, - }, - } + Self {} } /// Resolve the [`AlibabaRegion`] from a settings value. @@ -264,10 +248,6 @@ impl Provider for AlibabaProvider { ProviderId::Alibaba } - fn metadata(&self) -> &ProviderMetadata { - &self.metadata - } - async fn fetch_usage(&self, ctx: &FetchContext) -> Result { tracing::debug!(region = ?ctx.api_region, "Fetching Alibaba Coding Plan usage"); match ctx.source_mode { diff --git a/rust/src/providers/alibabatokenplan/mod.rs b/rust/src/providers/alibabatokenplan/mod.rs index 8d5e4e3663f..a3fac1e14bc 100644 --- a/rust/src/providers/alibabatokenplan/mod.rs +++ b/rust/src/providers/alibabatokenplan/mod.rs @@ -21,15 +21,13 @@ use regex_lite::Regex; use serde_json::Value; use crate::core::{ - FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, ProviderMetadata, - RateWindow, SourceMode, UsageSnapshot, + FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, RateWindow, SourceMode, + UsageSnapshot, }; use crate::providers::browser_cookie_header; use region::AlibabaTokenPlanRegion as Region; -const DEFAULT_DASHBOARD_URL: &str = - "https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/token-plan"; pub(super) const USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36"; pub(super) const LANGUAGE: &str = "en-US"; pub(super) const PERSONAL_CONSOLE_PRODUCT: &str = "sfm_bailian"; @@ -44,9 +42,7 @@ const WEEKLY_MINUTES: u32 = 7 * 24 * 60; const LEGACY_MINUTES: u32 = 30 * 24 * 60; const MONTHLY_MINUTES: u32 = 30 * 24 * 60; -pub struct AlibabaTokenPlanProvider { - metadata: ProviderMetadata, -} +pub struct AlibabaTokenPlanProvider {} #[derive(Debug, Clone, PartialEq)] pub(super) struct TokenPlanSnapshot { @@ -68,21 +64,7 @@ pub(super) struct TokenPlanSnapshot { impl AlibabaTokenPlanProvider { pub fn new() -> Self { - Self { - metadata: ProviderMetadata { - id: ProviderId::AlibabaTokenPlan, - display_name: "Alibaba Token Plan", - session_label: "Credits", - weekly_label: "Usage", - supports_opus: false, - supports_credits: false, - default_enabled: false, - is_primary: false, - dashboard_url: Some(DEFAULT_DASHBOARD_URL), - status_page_url: Some("https://status.aliyun.com"), - tertiary_label_key: None, - }, - } + Self {} } fn resolve_region(ctx: &FetchContext) -> Region { @@ -398,10 +380,6 @@ impl Provider for AlibabaTokenPlanProvider { ProviderId::AlibabaTokenPlan } - fn metadata(&self) -> &ProviderMetadata { - &self.metadata - } - async fn fetch_usage(&self, ctx: &FetchContext) -> Result { match ctx.source_mode { SourceMode::Auto if ctx.auto_prefer_web => match self.fetch_via_web(ctx).await { diff --git a/rust/src/providers/antigravity/mod.rs b/rust/src/providers/antigravity/mod.rs index 8626ec77cff..50041ffa5d5 100755 --- a/rust/src/providers/antigravity/mod.rs +++ b/rust/src/providers/antigravity/mod.rs @@ -39,8 +39,8 @@ use std::time::Duration; use std::time::Instant; use crate::core::{ - FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, ProviderMetadata, - RateWindow, SourceMode, UsageSnapshot, + FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, RateWindow, SourceMode, + UsageSnapshot, }; const AGY_NOT_FOUND_MESSAGE: &str = @@ -72,9 +72,7 @@ impl From for ProviderError { } /// Antigravity provider -pub struct AntigravityProvider { - metadata: ProviderMetadata, -} +pub struct AntigravityProvider {} #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum AntigravityStrategyId { @@ -142,21 +140,7 @@ fn is_agy_cli_command(command_line: &str) -> bool { impl AntigravityProvider { pub fn new() -> Self { - Self { - metadata: ProviderMetadata { - id: ProviderId::Antigravity, - display_name: "Antigravity", - session_label: "Claude", - weekly_label: "Gemini Pro", - supports_opus: true, - supports_credits: false, - default_enabled: false, - is_primary: false, - dashboard_url: None, - status_page_url: None, - tertiary_label_key: None, - }, - } + Self {} } /// Detect running Antigravity language server and extract connection info @@ -899,10 +883,6 @@ impl Provider for AntigravityProvider { ProviderId::Antigravity } - fn metadata(&self) -> &ProviderMetadata { - &self.metadata - } - async fn fetch_usage(&self, ctx: &FetchContext) -> Result { // `oauth` is not supported (no remote API path is ported yet); surface it // explicitly instead of silently probing locally. Both `auto` and `cli` diff --git a/rust/src/providers/atlascloud/mod.rs b/rust/src/providers/atlascloud/mod.rs index 68cd687199b..5ba699b4428 100644 --- a/rust/src/providers/atlascloud/mod.rs +++ b/rust/src/providers/atlascloud/mod.rs @@ -7,7 +7,7 @@ use std::time::Duration; use crate::core::{ CostSnapshot, FetchContext, Provider, ProviderDisplayDetail, ProviderError, - ProviderFetchResult, ProviderId, ProviderMetadata, RateWindow, SourceMode, UsageSnapshot, + ProviderFetchResult, ProviderId, RateWindow, SourceMode, UsageSnapshot, }; use crate::providers::{BoundedBodyError, read_bounded_response}; @@ -20,7 +20,6 @@ const MAX_RESPONSE_BYTES: usize = 1024 * 1024; const REQUEST_TIMEOUT: Duration = Duration::from_secs(10); pub struct AtlasCloudProvider { - metadata: ProviderMetadata, client: Client, balance_url: String, } @@ -37,19 +36,6 @@ impl AtlasCloudProvider { fn with_client(balance_url: impl Into, client: Client) -> Self { Self { - metadata: ProviderMetadata { - id: ProviderId::AtlasCloud, - display_name: "Atlas Cloud", - session_label: "Balance", - weekly_label: "Balance", - supports_opus: false, - supports_credits: false, - default_enabled: false, - is_primary: false, - dashboard_url: Some(DASHBOARD_URL), - status_page_url: None, - tertiary_label_key: None, - }, client, balance_url: balance_url.into(), } @@ -132,10 +118,6 @@ impl Provider for AtlasCloudProvider { ProviderId::AtlasCloud } - fn metadata(&self) -> &ProviderMetadata { - &self.metadata - } - async fn fetch_usage(&self, ctx: &FetchContext) -> Result { match ctx.source_mode { SourceMode::Auto | SourceMode::OAuth => self.fetch_balance(ctx).await, diff --git a/rust/src/providers/augment/mod.rs b/rust/src/providers/augment/mod.rs index 761c247d315..05aa6dfcab5 100755 --- a/rust/src/providers/augment/mod.rs +++ b/rust/src/providers/augment/mod.rs @@ -21,32 +21,16 @@ use tokio::process::Command; use tokio::time::timeout; use crate::core::{ - FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, ProviderMetadata, - RateWindow, SourceMode, UsageSnapshot, + FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, RateWindow, SourceMode, + UsageSnapshot, }; /// Augment provider -pub struct AugmentProvider { - metadata: ProviderMetadata, -} +pub struct AugmentProvider {} impl AugmentProvider { pub fn new() -> Self { - Self { - metadata: ProviderMetadata { - id: ProviderId::Augment, - display_name: "Augment", - session_label: "Session", - weekly_label: "Monthly", - supports_opus: false, - supports_credits: true, - default_enabled: false, - is_primary: false, - dashboard_url: Some("https://app.augmentcode.com/account"), - status_page_url: Some("https://status.augmentcode.com"), - tertiary_label_key: None, - }, - } + Self {} } /// Get Augment config directory @@ -288,10 +272,6 @@ impl Provider for AugmentProvider { ProviderId::Augment } - fn metadata(&self) -> &ProviderMetadata { - &self.metadata - } - async fn fetch_usage(&self, ctx: &FetchContext) -> Result { tracing::debug!("Fetching Augment usage"); diff --git a/rust/src/providers/azureopenai.rs b/rust/src/providers/azureopenai.rs index 617355d145c..f7fc2e3513c 100644 --- a/rust/src/providers/azureopenai.rs +++ b/rust/src/providers/azureopenai.rs @@ -7,16 +7,14 @@ use reqwest::Url; use serde::Deserialize; use crate::core::{ - FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, ProviderMetadata, - RateWindow, SourceMode, UsageSnapshot, + FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, RateWindow, SourceMode, + UsageSnapshot, }; use crate::settings::ApiKeys; const DEFAULT_API_VERSION: &str = "2024-10-21"; -pub struct AzureOpenAIProvider { - metadata: ProviderMetadata, -} +pub struct AzureOpenAIProvider {} #[derive(Debug, Clone)] struct AzureOpenAIConfig { @@ -33,21 +31,7 @@ struct ChatCompletionResponse { impl AzureOpenAIProvider { pub fn new() -> Self { - Self { - metadata: ProviderMetadata { - id: ProviderId::AzureOpenAI, - display_name: "Azure OpenAI", - session_label: "Deployment", - weekly_label: "Status", - supports_opus: false, - supports_credits: false, - default_enabled: false, - is_primary: false, - dashboard_url: Some("https://ai.azure.com"), - status_page_url: Some("https://status.azure.com"), - tertiary_label_key: None, - }, - } + Self {} } async fn fetch_via_api(&self, ctx: &FetchContext) -> Result { @@ -331,10 +315,6 @@ impl Provider for AzureOpenAIProvider { ProviderId::AzureOpenAI } - fn metadata(&self) -> &ProviderMetadata { - &self.metadata - } - async fn fetch_usage(&self, ctx: &FetchContext) -> Result { match ctx.source_mode { SourceMode::Auto | SourceMode::Web => { diff --git a/rust/src/providers/bedrock/mod.rs b/rust/src/providers/bedrock/mod.rs index 7739a5fd34c..17c02d870dd 100644 --- a/rust/src/providers/bedrock/mod.rs +++ b/rust/src/providers/bedrock/mod.rs @@ -9,8 +9,7 @@ use serde_json::{Value, json}; use crate::core::{ CostDailyPoint, CostSnapshot, FetchContext, Provider, ProviderError, ProviderFetchResult, - ProviderId, ProviderMetadata, RateWindow, SourceMode, UsageSnapshot, hex, hmac_sha256, - sha256_hex, + ProviderId, RateWindow, SourceMode, UsageSnapshot, hex, hmac_sha256, sha256_hex, }; mod daily; @@ -104,26 +103,12 @@ fn parse_claude_activity(value: &Value) -> BedrockClaudeActivity { } pub struct BedrockProvider { - metadata: ProviderMetadata, client: Client, } impl BedrockProvider { pub fn new() -> Self { Self { - metadata: ProviderMetadata { - id: ProviderId::Bedrock, - display_name: "AWS Bedrock", - session_label: "Budget", - weekly_label: "Cost", - supports_opus: false, - supports_credits: false, - default_enabled: false, - is_primary: false, - dashboard_url: Some("https://console.aws.amazon.com/bedrock"), - status_page_url: Some("https://health.aws.amazon.com/health/status"), - tertiary_label_key: None, - }, client: crate::core::credentialed_http_client_builder() .timeout(std::time::Duration::from_secs(15)) .build() @@ -558,10 +543,6 @@ impl Provider for BedrockProvider { ProviderId::Bedrock } - fn metadata(&self) -> &ProviderMetadata { - &self.metadata - } - async fn fetch_usage(&self, ctx: &FetchContext) -> Result { match ctx.source_mode { SourceMode::Auto | SourceMode::OAuth => self.fetch_via_api(ctx).await, diff --git a/rust/src/providers/bifrost/mod.rs b/rust/src/providers/bifrost/mod.rs index 91f277c70ff..1039560282e 100644 --- a/rust/src/providers/bifrost/mod.rs +++ b/rust/src/providers/bifrost/mod.rs @@ -11,7 +11,7 @@ use std::time::Duration; use crate::core::{ CostSnapshot, FetchContext, NamedRateWindow, Provider, ProviderDisplayDetail, ProviderError, - ProviderFetchResult, ProviderId, ProviderMetadata, RateWindow, SourceMode, UsageSnapshot, + ProviderFetchResult, ProviderId, RateWindow, SourceMode, UsageSnapshot, }; const CREDENTIAL_TARGET: &str = "codexbar-bifrost"; @@ -24,7 +24,6 @@ const QUOTA_PATH: &str = "/api/governance/virtual-keys/quota"; mod model_labels; pub struct BifrostProvider { - metadata: ProviderMetadata, client: Option, } @@ -57,19 +56,6 @@ struct ResetTiming { impl BifrostProvider { pub fn new() -> Self { Self { - metadata: ProviderMetadata { - id: ProviderId::Bifrost, - display_name: "Bifrost", - session_label: "Budget", - weekly_label: "Spend", - supports_opus: false, - supports_credits: true, - default_enabled: false, - is_primary: false, - dashboard_url: None, - status_page_url: None, - tertiary_label_key: None, - }, client: crate::core::credentialed_http_client_builder() .timeout(REQUEST_TIMEOUT) // The virtual-key credential uses a custom header; never @@ -167,10 +153,6 @@ impl Provider for BifrostProvider { ProviderId::Bifrost } - fn metadata(&self) -> &ProviderMetadata { - &self.metadata - } - async fn fetch_usage(&self, ctx: &FetchContext) -> Result { match ctx.source_mode { SourceMode::Auto | SourceMode::OAuth => self.fetch_api(ctx).await, diff --git a/rust/src/providers/chutes/mod.rs b/rust/src/providers/chutes/mod.rs index c2b1d45f92d..cc826946727 100644 --- a/rust/src/providers/chutes/mod.rs +++ b/rust/src/providers/chutes/mod.rs @@ -4,8 +4,8 @@ use reqwest::{Client, StatusCode, Url}; use serde_json::{Map, Value, json}; use crate::core::{ - FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, ProviderMetadata, - SourceMode, UsageSnapshot, + FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, SourceMode, + UsageSnapshot, }; mod parse; @@ -18,26 +18,12 @@ const CREDENTIAL_TARGET: &str = "codexbar-chutes"; const DEFAULT_API_URL: &str = "https://api.chutes.ai"; pub struct ChutesProvider { - metadata: ProviderMetadata, client: Client, } impl ChutesProvider { pub fn new() -> Self { Self { - metadata: ProviderMetadata { - id: ProviderId::Chutes, - display_name: "Chutes", - session_label: "4-hour quota", - weekly_label: "Monthly quota", - supports_opus: false, - supports_credits: true, - default_enabled: false, - is_primary: false, - dashboard_url: Some("https://chutes.ai"), - status_page_url: None, - tertiary_label_key: None, - }, client: crate::core::credentialed_http_client_builder() .timeout(std::time::Duration::from_secs(15)) .build() @@ -58,10 +44,6 @@ impl Provider for ChutesProvider { ProviderId::Chutes } - fn metadata(&self) -> &ProviderMetadata { - &self.metadata - } - async fn fetch_usage(&self, ctx: &FetchContext) -> Result { match ctx.source_mode { SourceMode::Auto | SourceMode::OAuth => { diff --git a/rust/src/providers/claude/mod.rs b/rust/src/providers/claude/mod.rs index d546d2101d4..c16242fe7cd 100755 --- a/rust/src/providers/claude/mod.rs +++ b/rust/src/providers/claude/mod.rs @@ -28,7 +28,7 @@ use std::time::{Duration, Instant}; use crate::cli::tty_runner::{TtyCommandOptions, TtyCommandRunner}; use crate::core::{ FetchContext, LastGoodFailurePolicy, Provider, ProviderError, ProviderFetchResult, ProviderId, - ProviderMetadata, RateWindow, SourceMode, UsageSnapshot, + RateWindow, SourceMode, UsageSnapshot, }; use admin_api::ClaudeAdminApiFetcher; @@ -140,7 +140,6 @@ pub fn auto_resume_identity() -> Option { /// Claude provider implementation pub struct ClaudeProvider { - metadata: ProviderMetadata, web_fetcher: ClaudeWebApiFetcher, oauth_fetcher: ClaudeOAuthFetcher, admin_fetcher: ClaudeAdminApiFetcher, @@ -149,19 +148,6 @@ pub struct ClaudeProvider { impl ClaudeProvider { pub fn new() -> Self { Self { - metadata: ProviderMetadata { - id: ProviderId::Claude, - display_name: "Claude", - session_label: "Session (5h)", - weekly_label: "Weekly", - supports_opus: true, - supports_credits: true, - default_enabled: true, - is_primary: true, - dashboard_url: Some("https://claude.ai/settings/usage"), - status_page_url: Some("https://status.claude.com/"), - tertiary_label_key: None, - }, web_fetcher: ClaudeWebApiFetcher::new(), oauth_fetcher: ClaudeOAuthFetcher::new(), admin_fetcher: ClaudeAdminApiFetcher::new(), @@ -829,10 +815,6 @@ impl Provider for ClaudeProvider { ProviderId::Claude } - fn metadata(&self) -> &ProviderMetadata { - &self.metadata - } - fn retains_last_good_on_transport_failure(&self) -> bool { true } diff --git a/rust/src/providers/clinepass/mod.rs b/rust/src/providers/clinepass/mod.rs index f4d21b3e88b..9cdb248a4e5 100644 --- a/rust/src/providers/clinepass/mod.rs +++ b/rust/src/providers/clinepass/mod.rs @@ -14,8 +14,8 @@ use reqwest::Client; use serde::Deserialize; use crate::core::{ - FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, ProviderMetadata, - ProviderStateKind, RateWindow, SourceMode, UsageSnapshot, + FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, ProviderStateKind, + RateWindow, SourceMode, UsageSnapshot, }; use session::FileCredential; @@ -76,26 +76,12 @@ struct LimitEntry { } pub struct ClinePassProvider { - metadata: ProviderMetadata, client: Client, } impl ClinePassProvider { pub fn new() -> Self { Self { - metadata: ProviderMetadata { - id: ProviderId::ClinePass, - display_name: "ClinePass", - session_label: "5-hour", - weekly_label: "Weekly", - supports_opus: true, - supports_credits: false, - default_enabled: false, - is_primary: false, - dashboard_url: Some("https://app.cline.bot/dashboard/subscription?personal=true"), - status_page_url: None, - tertiary_label_key: None, - }, client: crate::core::credentialed_http_client_builder() .timeout(std::time::Duration::from_secs(15)) .build() @@ -116,10 +102,6 @@ impl Provider for ClinePassProvider { ProviderId::ClinePass } - fn metadata(&self) -> &ProviderMetadata { - &self.metadata - } - async fn fetch_usage(&self, ctx: &FetchContext) -> Result { match ctx.source_mode { SourceMode::Auto | SourceMode::OAuth => { diff --git a/rust/src/providers/codebuddy/mod.rs b/rust/src/providers/codebuddy/mod.rs index 3fc13226152..b02b4503279 100644 --- a/rust/src/providers/codebuddy/mod.rs +++ b/rust/src/providers/codebuddy/mod.rs @@ -30,8 +30,8 @@ use sha2::{Digest, Sha256}; use std::path::{Path, PathBuf}; use crate::core::{ - FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, ProviderMetadata, - RateWindow, SourceMode, UsageSnapshot, + FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, RateWindow, SourceMode, + UsageSnapshot, }; const CN_API: &str = "https://www.codebuddy.cn/billing/meter/get-user-resource"; @@ -64,7 +64,6 @@ const MAX_RESPONSE_BYTES: u64 = 8 * 1024 * 1024; const MAX_CACHE_BYTES: u64 = 1024 * 1024; pub struct CodeBuddyProvider { - metadata: ProviderMetadata, client: Client, /// Billing API endpoint; override via `CB_API_URL` (validated). api_url: String, @@ -73,19 +72,6 @@ pub struct CodeBuddyProvider { impl CodeBuddyProvider { pub fn new() -> Self { Self { - metadata: ProviderMetadata { - id: ProviderId::CodeBuddy, - display_name: "CodeBuddy", - session_label: "Credits", - weekly_label: "Packages", - supports_opus: false, - supports_credits: true, - default_enabled: false, - is_primary: false, - dashboard_url: Some("https://www.codebuddy.cn/profile/plans-usage"), - status_page_url: None, - tertiary_label_key: None, - }, client: crate::core::credentialed_http_client_builder() .timeout(std::time::Duration::from_secs(20)) .build() @@ -772,10 +758,6 @@ impl Provider for CodeBuddyProvider { ProviderId::CodeBuddy } - fn metadata(&self) -> &ProviderMetadata { - &self.metadata - } - async fn fetch_usage(&self, ctx: &FetchContext) -> Result { match ctx.source_mode { SourceMode::Auto | SourceMode::Web => match self.resolve_cookie(ctx) { diff --git a/rust/src/providers/coderabbit/mod.rs b/rust/src/providers/coderabbit/mod.rs index 383e468851a..ccc75730e9d 100644 --- a/rust/src/providers/coderabbit/mod.rs +++ b/rust/src/providers/coderabbit/mod.rs @@ -14,7 +14,7 @@ use tokio::process::Command; use crate::core::{ FetchContext, Provider, ProviderDisplayDetail, ProviderError, ProviderFetchResult, ProviderId, - ProviderMetadata, RateWindow, SourceMode, UsageSnapshot, + RateWindow, SourceMode, UsageSnapshot, }; const CLI_TIMEOUT: Duration = Duration::from_secs(15); @@ -48,27 +48,11 @@ struct CliOutput { stderr: Vec, } -pub struct CodeRabbitProvider { - metadata: ProviderMetadata, -} +pub struct CodeRabbitProvider {} impl CodeRabbitProvider { pub fn new() -> Self { - Self { - metadata: ProviderMetadata { - id: ProviderId::CodeRabbit, - display_name: "CodeRabbit", - session_label: "Reviews", - weekly_label: "Billing", - supports_opus: false, - supports_credits: false, - default_enabled: false, - is_primary: false, - dashboard_url: Some("https://app.coderabbit.ai"), - status_page_url: Some("https://status.coderabbit.ai"), - tertiary_label_key: None, - }, - } + Self {} } } @@ -84,10 +68,6 @@ impl Provider for CodeRabbitProvider { ProviderId::CodeRabbit } - fn metadata(&self) -> &ProviderMetadata { - &self.metadata - } - async fn fetch_usage(&self, ctx: &FetchContext) -> Result { match ctx.source_mode { SourceMode::Auto | SourceMode::Cli => { diff --git a/rust/src/providers/codex/mod.rs b/rust/src/providers/codex/mod.rs index 1792711f94e..40031543de8 100755 --- a/rust/src/providers/codex/mod.rs +++ b/rust/src/providers/codex/mod.rs @@ -13,7 +13,7 @@ use async_trait::async_trait; use std::os::windows::process::CommandExt; use crate::core::{ - FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, ProviderMetadata, + FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, ResetCreditsObservation, SourceMode, }; @@ -21,26 +21,12 @@ pub use api::CodexApi; /// Codex provider for fetching AI usage limits pub struct CodexProvider { - metadata: ProviderMetadata, api: CodexApi, } impl CodexProvider { pub fn new() -> Self { Self { - metadata: ProviderMetadata { - id: ProviderId::Codex, - display_name: "Codex", - session_label: "Session", - weekly_label: "Weekly", - supports_opus: false, - supports_credits: true, - default_enabled: true, - is_primary: true, - dashboard_url: Some("https://chatgpt.com/codex/cloud/settings/analytics#usage"), - status_page_url: Some("https://status.openai.com"), - tertiary_label_key: None, - }, api: CodexApi::new(), } } @@ -121,10 +107,6 @@ impl Provider for CodexProvider { ProviderId::Codex } - fn metadata(&self) -> &ProviderMetadata { - &self.metadata - } - fn retains_last_good_on_transport_failure(&self) -> bool { true } diff --git a/rust/src/providers/commandcode/mod.rs b/rust/src/providers/commandcode/mod.rs index b8b8590ecce..623676d7753 100644 --- a/rust/src/providers/commandcode/mod.rs +++ b/rust/src/providers/commandcode/mod.rs @@ -13,7 +13,7 @@ use std::sync::{LazyLock, Mutex}; use crate::core::{ CostSnapshot, FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, - ProviderMetadata, RateWindow, SourceMode, UsageSnapshot, + RateWindow, SourceMode, UsageSnapshot, }; use plan_cache::CommandCodePlanCache; @@ -103,26 +103,12 @@ fn find_plan(plan_id: &str) -> Option<&'static CommandCodePlan> { } pub struct CommandCodeProvider { - metadata: ProviderMetadata, client: Client, } impl CommandCodeProvider { pub fn new() -> Self { Self { - metadata: ProviderMetadata { - id: ProviderId::CommandCode, - display_name: "Command Code", - session_label: "5-hour", - weekly_label: "Weekly", - supports_opus: false, - supports_credits: true, - default_enabled: false, - is_primary: false, - dashboard_url: Some("https://commandcode.ai"), - status_page_url: None, - tertiary_label_key: None, - }, client: crate::core::credentialed_http_client_builder() .timeout(std::time::Duration::from_secs(15)) .build() @@ -615,10 +601,6 @@ impl Provider for CommandCodeProvider { ProviderId::CommandCode } - fn metadata(&self) -> &ProviderMetadata { - &self.metadata - } - async fn fetch_usage(&self, ctx: &FetchContext) -> Result { match ctx.source_mode { SourceMode::Auto | SourceMode::Web => { diff --git a/rust/src/providers/copilot/mod.rs b/rust/src/providers/copilot/mod.rs index 1e7983ce0dd..f5408ddcd6c 100755 --- a/rust/src/providers/copilot/mod.rs +++ b/rust/src/providers/copilot/mod.rs @@ -8,34 +8,19 @@ pub mod device_flow; use async_trait::async_trait; use crate::core::{ - FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, ProviderMetadata, - SourceMode, + FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, SourceMode, }; pub use api::{CopilotApi, SEAT_CREDIT_WINDOW_ID}; /// GitHub Copilot provider for fetching AI usage limits pub struct CopilotProvider { - metadata: ProviderMetadata, api: CopilotApi, } impl CopilotProvider { pub fn new() -> Self { Self { - metadata: ProviderMetadata { - id: ProviderId::Copilot, - display_name: "GitHub Copilot", - session_label: "Premium", - weekly_label: "Chat", - supports_opus: false, - supports_credits: false, - default_enabled: true, - is_primary: false, - dashboard_url: Some("https://github.com/settings/copilot"), - status_page_url: Some("https://www.githubstatus.com/"), - tertiary_label_key: None, - }, api: CopilotApi::new(), } } @@ -65,10 +50,6 @@ impl Provider for CopilotProvider { ProviderId::Copilot } - fn metadata(&self) -> &ProviderMetadata { - &self.metadata - } - async fn fetch_usage(&self, ctx: &FetchContext) -> Result { tracing::debug!("Fetching GitHub Copilot usage via GitHub OAuth"); diff --git a/rust/src/providers/cursor/mod.rs b/rust/src/providers/cursor/mod.rs index ec5401f9afe..5fd1aa5e24e 100755 --- a/rust/src/providers/cursor/mod.rs +++ b/rust/src/providers/cursor/mod.rs @@ -16,7 +16,7 @@ use async_trait::async_trait; use crate::core::{ CostSnapshot, FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, - ProviderMetadata, RateWindow, SourceMode, UsageSnapshot, + RateWindow, SourceMode, UsageSnapshot, }; pub use api::CursorApi; @@ -25,7 +25,6 @@ use token_cost::TokenCostError; /// Cursor provider for fetching AI usage limits pub struct CursorProvider { - metadata: ProviderMetadata, api: CursorApi, /// Per-credential back-off for forbidden cost requests. The store is /// process-wide because the shell builds a fresh provider per refresh. @@ -37,20 +36,6 @@ pub struct CursorProvider { impl CursorProvider { pub fn new() -> Self { Self { - metadata: ProviderMetadata { - id: ProviderId::Cursor, - display_name: "Cursor", - session_label: "Plan", - weekly_label: "Cursor", - supports_opus: false, - // Upstream #2338: Cursor has no account credit balance to advertise. - supports_credits: false, - default_enabled: true, - is_primary: false, - dashboard_url: Some("https://cursor.com/dashboard/usage"), - status_page_url: None, - tertiary_label_key: None, - }, api: CursorApi::new(), cost_cooldown: CostCooldown::shared(), clock: Arc::new(Instant::now), @@ -235,7 +220,6 @@ impl CursorProvider { api: CursorApi::with_base_url(base_url), cost_cooldown, clock, - ..Self::new() } } } @@ -256,10 +240,6 @@ impl Provider for CursorProvider { ProviderId::Cursor } - fn metadata(&self) -> &ProviderMetadata { - &self.metadata - } - fn retains_last_good_on_transport_failure(&self) -> bool { true } diff --git a/rust/src/providers/deepinfra/mod.rs b/rust/src/providers/deepinfra/mod.rs index 4528c9076ec..07397727d28 100644 --- a/rust/src/providers/deepinfra/mod.rs +++ b/rust/src/providers/deepinfra/mod.rs @@ -21,7 +21,7 @@ use serde::Deserialize; use crate::core::{ CostSnapshot, FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, - ProviderMetadata, RateWindow, SourceMode, UsageSnapshot, + RateWindow, SourceMode, UsageSnapshot, }; const CHECKLIST_URL: &str = "https://api.deepinfra.com/payment/checklist?compute_owed=true"; @@ -165,27 +165,12 @@ impl DeepInfraSnapshot { } pub struct DeepInfraProvider { - metadata: ProviderMetadata, client: Client, } impl DeepInfraProvider { pub fn new() -> Self { Self { - metadata: ProviderMetadata { - id: ProviderId::DeepInfra, - display_name: "DeepInfra", - session_label: "Balance", - weekly_label: "Balance", - supports_opus: false, - // Upstream marks supportsCredits=false; balance is shown via primary window text. - supports_credits: false, - default_enabled: false, - is_primary: false, - dashboard_url: Some("https://deepinfra.com/dash"), - status_page_url: Some("https://status.deepinfra.com"), - tertiary_label_key: None, - }, client: crate::core::credentialed_http_client_builder() .timeout(REQUEST_TIMEOUT) .build() @@ -365,10 +350,6 @@ impl Provider for DeepInfraProvider { ProviderId::DeepInfra } - fn metadata(&self) -> &ProviderMetadata { - &self.metadata - } - async fn fetch_usage(&self, ctx: &FetchContext) -> Result { match ctx.source_mode { SourceMode::Auto | SourceMode::OAuth => self.fetch_usage_api(ctx).await, diff --git a/rust/src/providers/deepseek/mod.rs b/rust/src/providers/deepseek/mod.rs index 1a684a2071f..537058c951b 100644 --- a/rust/src/providers/deepseek/mod.rs +++ b/rust/src/providers/deepseek/mod.rs @@ -18,7 +18,7 @@ mod session_resolver; use crate::core::{ CostSnapshot, FetchContext, LastGoodFailurePolicy, Provider, ProviderError, - ProviderFetchResult, ProviderId, ProviderMetadata, RateWindow, SourceMode, UsageSnapshot, + ProviderFetchResult, ProviderId, RateWindow, SourceMode, UsageSnapshot, }; const DEEPSEEK_API_BASE: &str = "https://api.deepseek.com"; @@ -161,26 +161,12 @@ struct DeepSeekUsageSummary { } pub struct DeepSeekProvider { - metadata: ProviderMetadata, client: Client, } impl DeepSeekProvider { pub fn new() -> Self { Self { - metadata: ProviderMetadata { - id: ProviderId::DeepSeek, - display_name: "DeepSeek", - session_label: "Balance", - weekly_label: "Balance", - supports_opus: false, - supports_credits: true, - default_enabled: false, - is_primary: false, - dashboard_url: Some("https://platform.deepseek.com/usage"), - status_page_url: Some("https://status.deepseek.com"), - tertiary_label_key: None, - }, client: crate::core::credentialed_http_client_builder() .timeout(std::time::Duration::from_secs(30)) .build() @@ -563,10 +549,6 @@ impl Provider for DeepSeekProvider { ProviderId::DeepSeek } - fn metadata(&self) -> &ProviderMetadata { - &self.metadata - } - async fn fetch_usage(&self, ctx: &FetchContext) -> Result { match ctx.source_mode { SourceMode::Auto => match Self::get_api_key(ctx.api_key.as_deref()) { diff --git a/rust/src/providers/devin/mod.rs b/rust/src/providers/devin/mod.rs index 42152ff9e60..79eeedf51c8 100644 --- a/rust/src/providers/devin/mod.rs +++ b/rust/src/providers/devin/mod.rs @@ -4,7 +4,7 @@ use serde_json::Value; use crate::core::{ CostSnapshot, FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, - ProviderMetadata, RateWindow, SourceMode, UsageSnapshot, + RateWindow, SourceMode, UsageSnapshot, }; const CREDENTIAL_TARGET: &str = "codexbar-devin"; @@ -15,26 +15,12 @@ const MISSING_ORGANIZATION_CONFIG_MESSAGE: &str = "Devin organization not found; const MISSING_TOKEN_MESSAGE: &str = "Devin Bearer token not found. In app.devin.ai open Developer Tools > Network, reload Usage & Limits, select a successful billing/quota/usage request and copy its Authorization value (a leading 'Bearer ' is accepted) into the Devin token field in Preferences, or set DEVIN_BEARER_TOKEN / DEVIN_AUTHORIZATION."; pub struct DevinProvider { - metadata: ProviderMetadata, client: Client, } impl DevinProvider { pub fn new() -> Self { Self { - metadata: ProviderMetadata { - id: ProviderId::Devin, - display_name: "Devin", - session_label: "Daily", - weekly_label: "Weekly", - supports_opus: false, - supports_credits: true, - default_enabled: false, - is_primary: false, - dashboard_url: Some("https://app.devin.ai/settings/billing"), - status_page_url: None, - tertiary_label_key: None, - }, client: crate::core::credentialed_http_client_builder() .timeout(std::time::Duration::from_secs(15)) .build() @@ -55,10 +41,6 @@ impl Provider for DevinProvider { ProviderId::Devin } - fn metadata(&self) -> &ProviderMetadata { - &self.metadata - } - async fn fetch_usage(&self, ctx: &FetchContext) -> Result { match ctx.source_mode { SourceMode::Auto | SourceMode::OAuth => { diff --git a/rust/src/providers/devpass/mod.rs b/rust/src/providers/devpass/mod.rs index b8b0874c03a..1cdaf8faca2 100644 --- a/rust/src/providers/devpass/mod.rs +++ b/rust/src/providers/devpass/mod.rs @@ -23,7 +23,7 @@ use reqwest::{Client, StatusCode}; use crate::core::{ FetchContext, Provider, ProviderDisplayDetail, ProviderError, ProviderFetchResult, ProviderId, - ProviderMetadata, RateWindow, SourceMode, UsageSnapshot, + RateWindow, SourceMode, UsageSnapshot, }; use crate::providers::{BoundedBodyError, read_bounded_response}; use wire::{DevPlan, KeyData, Money, PlanFields}; @@ -37,26 +37,12 @@ const MAX_BODY_BYTES: usize = 1024 * 1024; const PREMIUM_WINDOW_MINUTES: u32 = 7 * 24 * 60; pub struct DevPassProvider { - metadata: ProviderMetadata, client: Client, } impl DevPassProvider { pub fn new() -> Self { Self { - metadata: ProviderMetadata { - id: ProviderId::DevPass, - display_name: "DevPass", - session_label: "Plan credits", - weekly_label: "Premium weekly", - supports_opus: false, - supports_credits: false, - default_enabled: false, - is_primary: false, - dashboard_url: Some("https://devpass.llmgateway.io/dashboard"), - status_page_url: None, - tertiary_label_key: None, - }, client: crate::core::credentialed_http_client_builder() .redirect(reqwest::redirect::Policy::none()) .timeout(REQUEST_TIMEOUT) @@ -78,10 +64,6 @@ impl Provider for DevPassProvider { ProviderId::DevPass } - fn metadata(&self) -> &ProviderMetadata { - &self.metadata - } - async fn fetch_usage(&self, ctx: &FetchContext) -> Result { match ctx.source_mode { SourceMode::Auto | SourceMode::OAuth => { diff --git a/rust/src/providers/doubao/mod.rs b/rust/src/providers/doubao/mod.rs index 15b5d5d8206..01ffc98234b 100644 --- a/rust/src/providers/doubao/mod.rs +++ b/rust/src/providers/doubao/mod.rs @@ -14,8 +14,7 @@ use std::time::Duration; use crate::core::{ FetchContext, IconLane, NamedRateWindow, Provider, ProviderError, ProviderFetchResult, - ProviderId, ProviderMetadata, RateWindow, SourceMode, UsageSnapshot, hex, hmac_sha256, - sha256_hex, + ProviderId, RateWindow, SourceMode, UsageSnapshot, hex, hmac_sha256, sha256_hex, }; const DOUBAO_API_URL: &str = "https://ark.cn-beijing.volces.com/api/coding/v3/chat/completions"; @@ -29,28 +28,12 @@ const PROBE_MODELS: &[&str] = &[ ]; pub struct DoubaoProvider { - metadata: ProviderMetadata, client: Client, } impl DoubaoProvider { pub fn new() -> Self { Self { - metadata: ProviderMetadata { - id: ProviderId::Doubao, - display_name: "Doubao", - session_label: "Requests", - weekly_label: "Usage", - supports_opus: false, - supports_credits: false, - default_enabled: false, - is_primary: false, - dashboard_url: Some( - "https://console.volcengine.com/ark/region:ark+cn-beijing/usage", - ), - status_page_url: None, - tertiary_label_key: None, - }, client: crate::core::credentialed_http_client_builder() .timeout(std::time::Duration::from_secs(15)) .build() @@ -910,10 +893,6 @@ impl Provider for DoubaoProvider { ProviderId::Doubao } - fn metadata(&self) -> &ProviderMetadata { - &self.metadata - } - async fn fetch_usage(&self, ctx: &FetchContext) -> Result { if ctx.token_account_isolated && ctx.token_account_kind == Some(crate::core::TokenAccountKind::ApiKey) diff --git a/rust/src/providers/elevenlabs/mod.rs b/rust/src/providers/elevenlabs/mod.rs index b37f120eb50..d19ff80894f 100644 --- a/rust/src/providers/elevenlabs/mod.rs +++ b/rust/src/providers/elevenlabs/mod.rs @@ -8,8 +8,8 @@ use reqwest::{Client, Url}; use serde::Deserialize; use crate::core::{ - FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, ProviderMetadata, - RateWindow, SourceMode, UsageSnapshot, + FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, RateWindow, SourceMode, + UsageSnapshot, }; const ELEVENLABS_API_BASE_URL: &str = "https://api.elevenlabs.io"; @@ -44,26 +44,12 @@ struct ElevenLabsApiErrorDetail { } pub struct ElevenLabsProvider { - metadata: ProviderMetadata, client: Client, } impl ElevenLabsProvider { pub fn new() -> Self { Self { - metadata: ProviderMetadata { - id: ProviderId::ElevenLabs, - display_name: "ElevenLabs", - session_label: "Credits", - weekly_label: "Voices", - supports_opus: false, - supports_credits: true, - default_enabled: false, - is_primary: false, - dashboard_url: Some("https://elevenlabs.io/app/settings/api-keys"), - status_page_url: Some("https://status.elevenlabs.io"), - tertiary_label_key: None, - }, client: crate::core::credentialed_http_client_builder() .timeout(std::time::Duration::from_secs(15)) .build() @@ -167,10 +153,6 @@ impl Provider for ElevenLabsProvider { ProviderId::ElevenLabs } - fn metadata(&self) -> &ProviderMetadata { - &self.metadata - } - async fn fetch_usage(&self, ctx: &FetchContext) -> Result { match ctx.source_mode { SourceMode::Auto | SourceMode::OAuth => { diff --git a/rust/src/providers/factory/mod.rs b/rust/src/providers/factory/mod.rs index f10eb85c336..b3065c09452 100755 --- a/rust/src/providers/factory/mod.rs +++ b/rust/src/providers/factory/mod.rs @@ -15,8 +15,8 @@ use chrono::{Duration, Utc}; use serde::Deserialize; use crate::core::{ - FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, ProviderMetadata, - RateWindow, SourceMode, UsageSnapshot, + FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, RateWindow, SourceMode, + UsageSnapshot, }; use crate::providers::browser_cookie_header; use crate::settings::ApiKeys; @@ -370,27 +370,11 @@ pub(crate) fn factory_api_error_is_recoverable(error: &ProviderError) -> bool { // ── Provider ───────────────────────────────────────────────────────── /// Droid (Factory) provider -pub struct FactoryProvider { - metadata: ProviderMetadata, -} +pub struct FactoryProvider {} impl FactoryProvider { pub fn new() -> Self { - Self { - metadata: ProviderMetadata { - id: ProviderId::Factory, - display_name: "Droid", - session_label: "Standard", - weekly_label: "Premium", - supports_opus: false, - supports_credits: true, - default_enabled: false, - is_primary: false, - dashboard_url: Some("https://app.factory.ai"), - status_page_url: Some("https://status.factory.ai"), - tertiary_label_key: None, - }, - } + Self {} } fn get_cookies(&self, ctx: &FetchContext) -> Result { @@ -755,10 +739,6 @@ impl Provider for FactoryProvider { ProviderId::Factory } - fn metadata(&self) -> &ProviderMetadata { - &self.metadata - } - async fn fetch_usage(&self, ctx: &FetchContext) -> Result { tracing::debug!("Fetching Droid (Factory) usage"); diff --git a/rust/src/providers/fireworks/mod.rs b/rust/src/providers/fireworks/mod.rs index 0286a5a9c4a..c5f660fda07 100644 --- a/rust/src/providers/fireworks/mod.rs +++ b/rust/src/providers/fireworks/mod.rs @@ -15,7 +15,7 @@ use std::collections::BTreeSet; use crate::core::{ CostSnapshot, FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, - ProviderMetadata, RateWindow, SourceMode, UsageSnapshot, + RateWindow, SourceMode, UsageSnapshot, }; const BILLING_SUMMARY_URL: &str = "https://api.fireworks.ai/v1/accounts"; @@ -181,26 +181,12 @@ fn format_money(value: f64) -> String { } pub struct FireworksProvider { - metadata: ProviderMetadata, client: Client, } impl FireworksProvider { pub fn new() -> Self { Self { - metadata: ProviderMetadata { - id: ProviderId::Fireworks, - display_name: "Fireworks", - session_label: "Spend", - weekly_label: "Spend", - supports_opus: false, - supports_credits: false, - default_enabled: false, - is_primary: false, - dashboard_url: Some("https://app.fireworks.ai"), - status_page_url: None, - tertiary_label_key: None, - }, client: crate::core::credentialed_http_client_builder() .timeout(std::time::Duration::from_secs(15)) .build() @@ -464,10 +450,6 @@ impl Provider for FireworksProvider { ProviderId::Fireworks } - fn metadata(&self) -> &ProviderMetadata { - &self.metadata - } - async fn fetch_usage(&self, ctx: &FetchContext) -> Result { match ctx.source_mode { SourceMode::Auto | SourceMode::OAuth => self.fetch_usage_api(ctx).await, diff --git a/rust/src/providers/gemini/mod.rs b/rust/src/providers/gemini/mod.rs index aea2c3574cc..67663a85a94 100755 --- a/rust/src/providers/gemini/mod.rs +++ b/rust/src/providers/gemini/mod.rs @@ -8,34 +8,20 @@ mod api; use async_trait::async_trait; use crate::core::{ - FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, ProviderMetadata, - SourceMode, UsageSnapshot, + FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, SourceMode, + UsageSnapshot, }; pub use api::GeminiApi; /// Gemini provider for fetching AI usage limits pub struct GeminiProvider { - metadata: ProviderMetadata, api: GeminiApi, } impl GeminiProvider { pub fn new() -> Self { Self { - metadata: ProviderMetadata { - id: ProviderId::Gemini, - display_name: "Gemini", - session_label: "Daily", - weekly_label: "Daily", - supports_opus: false, - supports_credits: false, - default_enabled: true, - is_primary: false, - dashboard_url: Some("https://aistudio.google.com"), - status_page_url: Some("https://status.cloud.google.com"), - tertiary_label_key: None, - }, api: GeminiApi::new(), } } @@ -53,10 +39,6 @@ impl Provider for GeminiProvider { ProviderId::Gemini } - fn metadata(&self) -> &ProviderMetadata { - &self.metadata - } - async fn fetch_usage(&self, ctx: &FetchContext) -> Result { tracing::debug!("Fetching Gemini usage via API"); diff --git a/rust/src/providers/gitkraken/mod.rs b/rust/src/providers/gitkraken/mod.rs index c736239edee..452834c3d5b 100644 --- a/rust/src/providers/gitkraken/mod.rs +++ b/rust/src/providers/gitkraken/mod.rs @@ -12,7 +12,7 @@ use std::time::Duration; use crate::core::{ FetchContext, Provider, ProviderDisplayDetail, ProviderError, ProviderFetchResult, ProviderId, - ProviderMetadata, RateWindow, SourceMode, UsageSnapshot, + RateWindow, SourceMode, UsageSnapshot, }; use crate::providers::{BoundedBodyError, read_bounded_response}; @@ -39,27 +39,11 @@ struct GitKrakenUsage { } /// GitKraken AI provider. -pub struct GitKrakenProvider { - metadata: ProviderMetadata, -} +pub struct GitKrakenProvider {} impl GitKrakenProvider { pub fn new() -> Self { - Self { - metadata: ProviderMetadata { - id: ProviderId::GitKraken, - display_name: "GitKraken AI", - session_label: "Personal", - weekly_label: "Shared pool", - supports_opus: false, - supports_credits: false, - default_enabled: false, - is_primary: false, - dashboard_url: Some("https://gitkraken.dev/account#ai-usage"), - status_page_url: None, - tertiary_label_key: None, - }, - } + Self {} } async fn fetch(&self, ctx: &FetchContext) -> Result { @@ -128,10 +112,6 @@ impl Provider for GitKrakenProvider { ProviderId::GitKraken } - fn metadata(&self) -> &ProviderMetadata { - &self.metadata - } - async fn fetch_usage(&self, ctx: &FetchContext) -> Result { match ctx.source_mode { SourceMode::Auto | SourceMode::OAuth => self.fetch(ctx).await, diff --git a/rust/src/providers/grok/mod.rs b/rust/src/providers/grok/mod.rs index abe5fb8d1c0..c9b77246d30 100644 --- a/rust/src/providers/grok/mod.rs +++ b/rust/src/providers/grok/mod.rs @@ -22,7 +22,7 @@ use std::os::windows::process::CommandExt; use crate::core::{ FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, ProviderInventoryItem, - ProviderMetadata, RateWindow, SourceMode, UsageSnapshot, + RateWindow, SourceMode, UsageSnapshot, }; use self::accounts::{GrokAuthKind, ParsedGrokAuthFile}; @@ -38,7 +38,6 @@ const RESET_CREDITS_TIMEOUT: Duration = Duration::from_secs(2); const RESET_CREDITS_JOIN_GRACE: Duration = Duration::from_millis(250); pub struct GrokProvider { - metadata: ProviderMetadata, client: Client, billing_endpoint: String, credits_proxy_endpoint: String, @@ -47,19 +46,6 @@ pub struct GrokProvider { impl GrokProvider { pub fn new() -> Self { Self { - metadata: ProviderMetadata { - id: ProviderId::Grok, - display_name: "Grok", - session_label: "Credits", - weekly_label: "On-demand", - supports_opus: false, - supports_credits: false, - default_enabled: false, - is_primary: false, - dashboard_url: Some("https://grok.com/?_s=usage"), - status_page_url: Some("https://status.x.ai"), - tertiary_label_key: None, - }, client: crate::core::credentialed_http_client_builder() .timeout(std::time::Duration::from_secs(15)) .build() @@ -565,10 +551,6 @@ impl Provider for GrokProvider { ProviderId::Grok } - fn metadata(&self) -> &ProviderMetadata { - &self.metadata - } - async fn fetch_usage(&self, ctx: &FetchContext) -> Result { match ctx.source_mode { SourceMode::Auto => self.fetch_auto(ctx).await, diff --git a/rust/src/providers/helmcode.rs b/rust/src/providers/helmcode.rs index 5f3461d7ccc..7acf64500df 100644 --- a/rust/src/providers/helmcode.rs +++ b/rust/src/providers/helmcode.rs @@ -8,7 +8,7 @@ use std::time::Duration; use crate::core::{ CostSnapshot, FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, - ProviderMetadata, RateWindow, SourceMode, UsageSnapshot, + RateWindow, SourceMode, UsageSnapshot, }; use crate::providers::{BoundedBodyError, read_bounded_response}; @@ -53,7 +53,6 @@ struct ModelQuota { } pub struct HelmcodeProvider { - metadata: ProviderMetadata, client: Client, #[cfg(test)] api_base_override: Option, @@ -62,19 +61,6 @@ pub struct HelmcodeProvider { impl HelmcodeProvider { pub fn new() -> Self { Self { - metadata: ProviderMetadata { - id: ProviderId::Helmcode, - display_name: "Helmcode", - session_label: "Quota", - weekly_label: "Quota", - supports_opus: false, - supports_credits: true, - default_enabled: false, - is_primary: false, - dashboard_url: Some("https://cloud.helmcode.com/dashboard"), - status_page_url: None, - tertiary_label_key: None, - }, client: crate::core::credentialed_http_client_builder() .timeout(Duration::from_secs(10)) .redirect(reqwest::redirect::Policy::none()) @@ -262,9 +248,6 @@ impl Provider for HelmcodeProvider { fn id(&self) -> ProviderId { ProviderId::Helmcode } - fn metadata(&self) -> &ProviderMetadata { - &self.metadata - } async fn fetch_usage(&self, ctx: &FetchContext) -> Result { match ctx.source_mode { SourceMode::Auto | SourceMode::Web => self.fetch_web(ctx).await, diff --git a/rust/src/providers/huggingface/mod.rs b/rust/src/providers/huggingface/mod.rs index beb959eb04f..dea594d69c0 100644 --- a/rust/src/providers/huggingface/mod.rs +++ b/rust/src/providers/huggingface/mod.rs @@ -15,7 +15,7 @@ use std::time::Duration; use super::{BoundedBodyError, read_bounded_response}; use crate::core::{ CostSnapshot, FetchContext, Provider, ProviderDisplayDetail, ProviderError, - ProviderFetchResult, ProviderId, ProviderMetadata, RateWindow, SourceMode, UsageSnapshot, + ProviderFetchResult, ProviderId, RateWindow, SourceMode, UsageSnapshot, }; mod identity_cache; @@ -136,26 +136,12 @@ impl TokenEnvironment { } pub struct HuggingFaceProvider { - metadata: ProviderMetadata, client: Client, } impl HuggingFaceProvider { pub fn new() -> Self { Self { - metadata: ProviderMetadata { - id: ProviderId::HuggingFace, - display_name: "Hugging Face", - session_label: "Credits", - weekly_label: "ZeroGPU", - supports_opus: false, - supports_credits: false, - default_enabled: false, - is_primary: false, - dashboard_url: Some("https://huggingface.co/settings/billing"), - status_page_url: Some("https://status.huggingface.co"), - tertiary_label_key: None, - }, client: crate::core::credentialed_http_client_builder() .timeout(PRIMARY_TIMEOUT) .build() @@ -307,10 +293,6 @@ impl Provider for HuggingFaceProvider { ProviderId::HuggingFace } - fn metadata(&self) -> &ProviderMetadata { - &self.metadata - } - async fn fetch_usage(&self, ctx: &FetchContext) -> Result { match ctx.source_mode { // The shared source enum uses OAuth as the persisted token/API diff --git a/rust/src/providers/hyper/mod.rs b/rust/src/providers/hyper/mod.rs index 21da8301123..75648bc499f 100644 --- a/rust/src/providers/hyper/mod.rs +++ b/rust/src/providers/hyper/mod.rs @@ -16,7 +16,7 @@ use std::time::Duration; use crate::core::{ FetchContext, Provider, ProviderDisplayDetail, ProviderError, ProviderFetchResult, ProviderId, - ProviderMetadata, ProviderStateKind, RateWindow, SourceMode, UsageSnapshot, + ProviderStateKind, RateWindow, SourceMode, UsageSnapshot, }; use crate::providers::{BoundedBodyError, format, read_bounded_response}; @@ -39,7 +39,6 @@ const INVALID_JSON: &str = "Charm Hyper credits response is not valid JSON."; const INVALID_BALANCE: &str = "Charm Hyper balance must be a non-negative number."; pub struct HyperProvider { - metadata: ProviderMetadata, client: Client, credits_url: String, } @@ -83,19 +82,6 @@ impl HyperProvider { fn with_client(credits_url: impl Into, client: Client) -> Self { Self { - metadata: ProviderMetadata { - id: ProviderId::Hyper, - display_name: "Charm Hyper", - session_label: "Balance", - weekly_label: "Balance", - supports_opus: false, - supports_credits: false, - default_enabled: false, - is_primary: false, - dashboard_url: Some("https://hyper.charm.land"), - status_page_url: None, - tertiary_label_key: None, - }, client, credits_url: credits_url.into(), } @@ -212,10 +198,6 @@ impl Provider for HyperProvider { ProviderId::Hyper } - fn metadata(&self) -> &ProviderMetadata { - &self.metadata - } - async fn fetch_usage(&self, ctx: &FetchContext) -> Result { // This repository's SourceMode::OAuth is the documented API-key lane // for non-OAuth providers; upstream calls this source "api". Web never diff --git a/rust/src/providers/infini.rs b/rust/src/providers/infini.rs index 766d5d87983..3ab97b36b5b 100644 --- a/rust/src/providers/infini.rs +++ b/rust/src/providers/infini.rs @@ -6,8 +6,8 @@ use serde::{Deserialize, Serialize}; use thiserror::Error; use crate::core::{ - FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, ProviderMetadata, - RateWindow, SourceMode, UsageSnapshot, + FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, RateWindow, SourceMode, + UsageSnapshot, }; /// Infini AI Coding Plan 用量数据 @@ -285,7 +285,6 @@ mod tests { /// Infini AI Provider 实现 pub struct InfiniProvider { - metadata: ProviderMetadata, client: InfiniClient, } @@ -293,19 +292,6 @@ impl InfiniProvider { /// 创建新的 InfiniProvider pub fn new(api_key: String) -> Self { Self { - metadata: ProviderMetadata { - id: ProviderId::Infini, - display_name: "Infini", - session_label: "5-Hour", - weekly_label: "7-Day", - supports_opus: false, - supports_credits: false, - default_enabled: false, - is_primary: false, - dashboard_url: Some("https://cloud.infini-ai.com"), - status_page_url: None, - tertiary_label_key: None, - }, client: InfiniClient::new(api_key), } } @@ -334,10 +320,6 @@ impl Provider for InfiniProvider { ProviderId::Infini } - fn metadata(&self) -> &ProviderMetadata { - &self.metadata - } - async fn fetch_usage(&self, ctx: &FetchContext) -> Result { tracing::debug!("Fetching Infini usage"); diff --git a/rust/src/providers/jetbrains/mod.rs b/rust/src/providers/jetbrains/mod.rs index 9ebf6d16f3a..4ab041cade9 100755 --- a/rust/src/providers/jetbrains/mod.rs +++ b/rust/src/providers/jetbrains/mod.rs @@ -12,32 +12,16 @@ use async_trait::async_trait; use std::path::PathBuf; use crate::core::{ - FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, ProviderMetadata, - RateWindow, SourceMode, UsageSnapshot, + FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, RateWindow, SourceMode, + UsageSnapshot, }; /// JetBrains AI provider -pub struct JetBrainsProvider { - metadata: ProviderMetadata, -} +pub struct JetBrainsProvider {} impl JetBrainsProvider { pub fn new() -> Self { - Self { - metadata: ProviderMetadata { - id: ProviderId::JetBrains, - display_name: "JetBrains AI", - session_label: "Credits", - weekly_label: "Monthly", - supports_opus: false, - supports_credits: true, - default_enabled: false, - is_primary: false, - dashboard_url: Some("https://www.jetbrains.com/ai/"), - status_page_url: None, - tertiary_label_key: None, - }, - } + Self {} } /// Get JetBrains config directory @@ -232,10 +216,6 @@ impl Provider for JetBrainsProvider { ProviderId::JetBrains } - fn metadata(&self) -> &ProviderMetadata { - &self.metadata - } - async fn fetch_usage(&self, ctx: &FetchContext) -> Result { tracing::debug!("Fetching JetBrains AI usage"); diff --git a/rust/src/providers/kimi/mod.rs b/rust/src/providers/kimi/mod.rs index 56749a57cec..e30c27f4e84 100755 --- a/rust/src/providers/kimi/mod.rs +++ b/rust/src/providers/kimi/mod.rs @@ -38,8 +38,8 @@ use serde::Deserialize; use std::time::{SystemTime, UNIX_EPOCH}; use crate::core::{ - FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, ProviderMetadata, - RateWindow, SourceMode, UsageSnapshot, + FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, RateWindow, SourceMode, + UsageSnapshot, }; /// Extra-window id of the monthly membership pool (`Total usage`). @@ -243,27 +243,11 @@ struct KimiWindow { } /// Kimi AI provider -pub struct KimiProvider { - metadata: ProviderMetadata, -} +pub struct KimiProvider {} impl KimiProvider { pub fn new() -> Self { - Self { - metadata: ProviderMetadata { - id: ProviderId::Kimi, - display_name: "Kimi", - session_label: "Weekly", - weekly_label: "Rate Limit", - supports_opus: false, - supports_credits: false, - default_enabled: false, - is_primary: false, - dashboard_url: Some("https://kimi.moonshot.cn"), - status_page_url: None, - tertiary_label_key: None, - }, - } + Self {} } fn auth_token_from_cookie_headers( @@ -347,10 +331,6 @@ impl Provider for KimiProvider { ProviderId::Kimi } - fn metadata(&self) -> &ProviderMetadata { - &self.metadata - } - async fn fetch_usage(&self, ctx: &FetchContext) -> Result { tracing::debug!("Fetching Kimi usage"); let region = KimiRegion::from_settings(ctx.api_region.as_deref()); diff --git a/rust/src/providers/kimik2/mod.rs b/rust/src/providers/kimik2/mod.rs index 2814431c2f2..2a117522d02 100755 --- a/rust/src/providers/kimik2/mod.rs +++ b/rust/src/providers/kimik2/mod.rs @@ -8,7 +8,7 @@ use std::collections::HashMap; use crate::core::{ CostSnapshot, FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, - ProviderMetadata, RateWindow, SourceMode, UsageSnapshot, + RateWindow, SourceMode, UsageSnapshot, }; const KIMIK2_API_BASE_INTERNATIONAL: &str = "https://api.moonshot.ai"; @@ -141,27 +141,11 @@ fn api_key_for_region(env: &HashMap, region: MoonshotRegion) -> } /// Kimi K2 provider (API-based credits) -pub struct KimiK2Provider { - metadata: ProviderMetadata, -} +pub struct KimiK2Provider {} impl KimiK2Provider { pub fn new() -> Self { - Self { - metadata: ProviderMetadata { - id: ProviderId::KimiK2, - display_name: "Moonshot / Kimi Open Platform", - session_label: "Balance", - weekly_label: "Cash", - supports_opus: false, - supports_credits: true, - default_enabled: false, - is_primary: false, - dashboard_url: Some("https://platform.moonshot.ai/console/account"), - status_page_url: None, - tertiary_label_key: None, - }, - } + Self {} } /// Region-bound API key resolution (upstream 0.48.0 #2621): @@ -393,10 +377,6 @@ impl Provider for KimiK2Provider { ProviderId::KimiK2 } - fn metadata(&self) -> &ProviderMetadata { - &self.metadata - } - async fn fetch_usage(&self, ctx: &FetchContext) -> Result { tracing::debug!("Fetching Kimi K2 usage"); diff --git a/rust/src/providers/kiro/mod.rs b/rust/src/providers/kiro/mod.rs index 73193b3f855..8130aa05f9d 100755 --- a/rust/src/providers/kiro/mod.rs +++ b/rust/src/providers/kiro/mod.rs @@ -25,14 +25,12 @@ use std::process::Stdio; use tokio::process::Command; use crate::core::{ - FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, ProviderMetadata, - RateWindow, SourceMode, UsageSnapshot, + FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, RateWindow, SourceMode, + UsageSnapshot, }; /// Kiro provider (AWS AI assistant) -pub struct KiroProvider { - metadata: ProviderMetadata, -} +pub struct KiroProvider {} struct KiroCliUsage { plan_name: String, matched_new_format: bool, @@ -50,21 +48,7 @@ struct KiroCliUsage { impl KiroProvider { pub fn new() -> Self { - Self { - metadata: ProviderMetadata { - id: ProviderId::Kiro, - display_name: "Kiro", - session_label: "Session", - weekly_label: "Monthly", - supports_opus: false, - supports_credits: true, - default_enabled: false, - is_primary: false, - dashboard_url: Some("https://kiro.dev/account"), - status_page_url: Some("https://health.aws.amazon.com"), - tertiary_label_key: None, - }, - } + Self {} } /// Get Kiro config directory @@ -466,10 +450,6 @@ impl Provider for KiroProvider { ProviderId::Kiro } - fn metadata(&self) -> &ProviderMetadata { - &self.metadata - } - async fn fetch_usage(&self, ctx: &FetchContext) -> Result { tracing::debug!("Fetching Kiro usage"); diff --git a/rust/src/providers/litellm/mod.rs b/rust/src/providers/litellm/mod.rs index df3826e5aec..1b6baa1609a 100644 --- a/rust/src/providers/litellm/mod.rs +++ b/rust/src/providers/litellm/mod.rs @@ -4,8 +4,7 @@ use reqwest::{Client, Response, StatusCode, Url}; use serde::de::DeserializeOwned; use crate::core::{ - FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, ProviderMetadata, - SourceMode, + FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, SourceMode, }; use crate::providers::{BoundedBodyError, read_bounded_response}; @@ -27,26 +26,12 @@ const CREDENTIAL_TARGET: &str = "codexbar-litellm"; const MAX_RESPONSE_BYTES: usize = 1024 * 1024; pub struct LiteLLMProvider { - metadata: ProviderMetadata, client: Client, } impl LiteLLMProvider { pub fn new() -> Self { Self { - metadata: ProviderMetadata { - id: ProviderId::LiteLLM, - display_name: "LiteLLM", - session_label: "Personal budget", - weekly_label: "Team budget", - supports_opus: false, - supports_credits: false, - default_enabled: false, - is_primary: false, - dashboard_url: None, - status_page_url: None, - tertiary_label_key: None, - }, client: crate::core::credentialed_http_client_builder() .timeout(std::time::Duration::from_secs(15)) .build() @@ -67,10 +52,6 @@ impl Provider for LiteLLMProvider { ProviderId::LiteLLM } - fn metadata(&self) -> &ProviderMetadata { - &self.metadata - } - /// Upstream's LiteLLM menu bar resolver: the team budget is enforced for /// the key, so Automatic shows it unless a budget is already exhausted. fn automatic_metric_prefers_secondary_window(&self) -> bool { diff --git a/rust/src/providers/llmman/endpoint.rs b/rust/src/providers/llmman/endpoint.rs index 161989f60f3..83489449990 100644 --- a/rust/src/providers/llmman/endpoint.rs +++ b/rust/src/providers/llmman/endpoint.rs @@ -8,7 +8,7 @@ use std::net::{Ipv4Addr, Ipv6Addr}; use reqwest::Url; -pub(super) const DEFAULT_BASE_URL: &str = "http://127.0.0.1:17434"; +pub(crate) const DEFAULT_BASE_URL: &str = "http://127.0.0.1:17434"; const DEFAULT_PORT: u16 = 17434; const POLICY_MESSAGE: &str = "llmman base URL must use HTTPS, or HTTP for localhost and private-network hosts, without embedded credentials, query, or fragment."; diff --git a/rust/src/providers/llmman/mod.rs b/rust/src/providers/llmman/mod.rs index c79f5a825a7..cb1f845d7b2 100644 --- a/rust/src/providers/llmman/mod.rs +++ b/rust/src/providers/llmman/mod.rs @@ -18,12 +18,13 @@ use serde::Deserialize; use crate::core::{ FetchContext, Provider, ProviderDisplayDetail, ProviderError, ProviderFetchResult, ProviderId, - ProviderMetadata, ProviderStateKind, RateWindow, SourceMode, UsageSnapshot, + ProviderStateKind, RateWindow, SourceMode, UsageSnapshot, }; use crate::providers::{BoundedBodyError, read_bounded_response}; +pub(crate) use endpoint::DEFAULT_BASE_URL; +use endpoint::request_base; pub(crate) use endpoint::validated_llmman_base_url; -use endpoint::{DEFAULT_BASE_URL, request_base}; const CREDENTIAL_TARGET: &str = "codexbar-llmman"; const REQUEST_TIMEOUT: Duration = Duration::from_secs(5); @@ -34,26 +35,12 @@ const MAX_SAFE_BYTES: u64 = (1 << 53) - 1; const NOT_REACHABLE_PREFIX: &str = "llmman is not reachable at "; pub struct LLMManProvider { - metadata: ProviderMetadata, client: Client, } impl LLMManProvider { pub fn new() -> Self { Self { - metadata: ProviderMetadata { - id: ProviderId::LLMMan, - display_name: "llmman", - session_label: "Memory", - weekly_label: "Models", - supports_opus: false, - supports_credits: false, - default_enabled: false, - is_primary: false, - dashboard_url: Some(DEFAULT_BASE_URL), - status_page_url: None, - tertiary_label_key: None, - }, client: crate::core::credentialed_http_client_builder() .build() .unwrap_or_else(|_| Client::new()), @@ -73,10 +60,6 @@ impl Provider for LLMManProvider { ProviderId::LLMMan } - fn metadata(&self) -> &ProviderMetadata { - &self.metadata - } - async fn fetch_usage(&self, ctx: &FetchContext) -> Result { match ctx.source_mode { SourceMode::Auto | SourceMode::OAuth => { diff --git a/rust/src/providers/llmproxy/mod.rs b/rust/src/providers/llmproxy/mod.rs index f428ff6d19c..eb30905d0db 100644 --- a/rust/src/providers/llmproxy/mod.rs +++ b/rust/src/providers/llmproxy/mod.rs @@ -10,7 +10,7 @@ use std::collections::HashMap; use crate::core::{ CostSnapshot, FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, - ProviderMetadata, RateWindow, SourceMode, UsageSnapshot, + RateWindow, SourceMode, UsageSnapshot, }; const LLM_PROXY_CREDENTIAL_TARGET: &str = "codexbar-llmproxy"; @@ -96,26 +96,12 @@ struct LLMProxySummary { } pub struct LLMProxyProvider { - metadata: ProviderMetadata, client: Client, } impl LLMProxyProvider { pub fn new() -> Self { Self { - metadata: ProviderMetadata { - id: ProviderId::LLMProxy, - display_name: "LLM Proxy", - session_label: "Quota", - weekly_label: "Requests", - supports_opus: false, - supports_credits: true, - default_enabled: false, - is_primary: false, - dashboard_url: None, - status_page_url: None, - tertiary_label_key: None, - }, client: crate::core::credentialed_http_client_builder() .timeout(std::time::Duration::from_secs(15)) .build() @@ -172,10 +158,6 @@ impl Provider for LLMProxyProvider { ProviderId::LLMProxy } - fn metadata(&self) -> &ProviderMetadata { - &self.metadata - } - async fn fetch_usage(&self, ctx: &FetchContext) -> Result { match ctx.source_mode { SourceMode::Auto | SourceMode::OAuth => { diff --git a/rust/src/providers/longcat/mod.rs b/rust/src/providers/longcat/mod.rs index 1bbd7776089..abb111141a1 100644 --- a/rust/src/providers/longcat/mod.rs +++ b/rust/src/providers/longcat/mod.rs @@ -8,8 +8,8 @@ use reqwest::Client; use serde_json::Value; use crate::core::{ - FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, ProviderMetadata, - RateWindow, SourceMode, UsageSnapshot, + FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, RateWindow, SourceMode, + UsageSnapshot, }; const HOST: &str = "https://longcat.chat"; @@ -20,26 +20,12 @@ const PENDING_FUEL: &str = "/api/lc-platform/v1/pending-fuel-packages"; const TOKEN_PACKS_SUMMARY: &str = "/api/pay/quota/metering/token-packs/summary"; pub struct LongCatProvider { - metadata: ProviderMetadata, client: Client, } impl LongCatProvider { pub fn new() -> Self { Self { - metadata: ProviderMetadata { - id: ProviderId::LongCat, - display_name: "LongCat", - session_label: "Quota", - weekly_label: "Fuel Pack", - supports_opus: false, - supports_credits: false, - default_enabled: false, - is_primary: false, - dashboard_url: Some("https://longcat.chat/platform/"), - status_page_url: None, - tertiary_label_key: None, - }, // Isolated cookie-free client — auth is only the explicit Cookie header. client: crate::core::credentialed_http_client_builder() .cookie_store(false) @@ -112,10 +98,6 @@ impl Provider for LongCatProvider { ProviderId::LongCat } - fn metadata(&self) -> &ProviderMetadata { - &self.metadata - } - async fn fetch_usage(&self, ctx: &FetchContext) -> Result { match ctx.source_mode { SourceMode::Auto | SourceMode::Web => { diff --git a/rust/src/providers/manus/mod.rs b/rust/src/providers/manus/mod.rs index 59a5fdc9753..6fafdf049a5 100644 --- a/rust/src/providers/manus/mod.rs +++ b/rust/src/providers/manus/mod.rs @@ -13,7 +13,7 @@ use serde_json::Value; use crate::core::{ FetchContext, ManualEmptyCookiePolicy, Provider, ProviderError, ProviderFetchResult, - ProviderId, ProviderMetadata, RateWindow, SourceMode, UsageSnapshot, + ProviderId, RateWindow, SourceMode, UsageSnapshot, }; const MANUS_CREDITS_URL: &str = "https://api.manus.im/user.v1.UserService/GetAvailableCredits"; @@ -117,7 +117,6 @@ struct Attempts { } pub struct ManusProvider { - metadata: ProviderMetadata, client: Client, credits_url: String, } @@ -125,19 +124,6 @@ pub struct ManusProvider { impl ManusProvider { pub fn new() -> Self { Self { - metadata: ProviderMetadata { - id: ProviderId::Manus, - display_name: "Manus", - session_label: "Credits", - weekly_label: "Refresh", - supports_opus: false, - supports_credits: true, - default_enabled: false, - is_primary: false, - dashboard_url: Some("https://manus.im"), - status_page_url: None, - tertiary_label_key: None, - }, client: crate::core::credentialed_http_client_builder() .timeout(std::time::Duration::from_secs(15)) .build() @@ -397,10 +383,6 @@ impl Provider for ManusProvider { ProviderId::Manus } - fn metadata(&self) -> &ProviderMetadata { - &self.metadata - } - async fn fetch_usage(&self, ctx: &FetchContext) -> Result { match ctx.source_mode { SourceMode::Auto | SourceMode::Web => { diff --git a/rust/src/providers/manus/tests.rs b/rust/src/providers/manus/tests.rs index 939c11ea541..d358136c927 100644 --- a/rust/src/providers/manus/tests.rs +++ b/rust/src/providers/manus/tests.rs @@ -11,7 +11,6 @@ fn provider_for(server: &ServerGuard) -> ManusProvider { .build() .expect("the test client should build"), credits_url: format!("{}{CREDITS_PATH}", server.url()), - ..ManusProvider::new() } } diff --git a/rust/src/providers/meta/mod.rs b/rust/src/providers/meta/mod.rs index be02405a163..e17c383bade 100644 --- a/rust/src/providers/meta/mod.rs +++ b/rust/src/providers/meta/mod.rs @@ -11,8 +11,8 @@ use reqwest::{Client, Url}; use serde::Deserialize; use crate::core::{ - FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, ProviderMetadata, - RateWindow, SourceMode, UsageSnapshot, + FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, RateWindow, SourceMode, + UsageSnapshot, }; const META_API_BASE: &str = "https://api.meta.ai/v1"; @@ -33,26 +33,12 @@ struct ModelEntry { } pub struct MetaProvider { - metadata: ProviderMetadata, client: Client, } impl MetaProvider { pub fn new() -> Self { Self { - metadata: ProviderMetadata { - id: ProviderId::Meta, - display_name: "Meta", - session_label: "Status", - weekly_label: "Models", - supports_opus: false, - supports_credits: false, - default_enabled: false, - is_primary: false, - dashboard_url: Some("https://dev.meta.ai/docs"), - status_page_url: None, - tertiary_label_key: None, - }, client: crate::core::credentialed_http_client_builder() .timeout(std::time::Duration::from_secs(15)) .build() @@ -102,10 +88,6 @@ impl Provider for MetaProvider { ProviderId::Meta } - fn metadata(&self) -> &ProviderMetadata { - &self.metadata - } - async fn fetch_usage(&self, ctx: &FetchContext) -> Result { match ctx.source_mode { SourceMode::Auto | SourceMode::OAuth => { diff --git a/rust/src/providers/minimax/mod.rs b/rust/src/providers/minimax/mod.rs index 8a1dd03a236..9707055d570 100755 --- a/rust/src/providers/minimax/mod.rs +++ b/rust/src/providers/minimax/mod.rs @@ -24,7 +24,7 @@ use std::path::PathBuf; use crate::core::{ CostSnapshot, FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, - ProviderMetadata, RateWindow, SourceMode, UsageSnapshot, + RateWindow, SourceMode, UsageSnapshot, }; const CODING_PLAN_PATH: &str = "/user-center/payment/coding-plan"; @@ -176,29 +176,11 @@ impl MiniMaxRegion { } /// MiniMax provider -pub struct MiniMaxProvider { - metadata: ProviderMetadata, -} +pub struct MiniMaxProvider {} impl MiniMaxProvider { pub fn new() -> Self { - Self { - metadata: ProviderMetadata { - id: ProviderId::MiniMax, - display_name: "MiniMax", - session_label: "Usage", - weekly_label: "Monthly", - supports_opus: false, - supports_credits: true, - default_enabled: false, - is_primary: false, - dashboard_url: Some( - "https://platform.minimax.io/user-center/payment/coding-plan?cycle_type=3", - ), - status_page_url: None, - tertiary_label_key: None, - }, - } + Self {} } pub fn region_from_settings(value: Option<&str>) -> MiniMaxRegion { @@ -1026,10 +1008,6 @@ impl Provider for MiniMaxProvider { ProviderId::MiniMax } - fn metadata(&self) -> &ProviderMetadata { - &self.metadata - } - async fn fetch_usage(&self, ctx: &FetchContext) -> Result { tracing::debug!("Fetching MiniMax usage"); let region = MiniMaxRegion::from_settings_value(ctx.api_region.as_deref()); diff --git a/rust/src/providers/mistral/mod.rs b/rust/src/providers/mistral/mod.rs index ac123dc57e0..9be117bf3e7 100644 --- a/rust/src/providers/mistral/mod.rs +++ b/rust/src/providers/mistral/mod.rs @@ -18,7 +18,7 @@ use subscription::{SubscriptionBudget, SubscriptionBudgets}; use crate::core::{ CostSnapshot, FetchContext, NamedRateWindow, Provider, ProviderError, ProviderFetchResult, - ProviderId, ProviderMetadata, RateWindow, SourceMode, UsageSnapshot, + ProviderId, RateWindow, SourceMode, UsageSnapshot, }; const BASE_URL: &str = "https://admin.mistral.ai"; @@ -120,7 +120,6 @@ struct MistralUsageSummary { } pub struct MistralProvider { - metadata: ProviderMetadata, client: Client, } @@ -178,19 +177,6 @@ impl TokenCounts { impl MistralProvider { pub fn new() -> Self { Self { - metadata: ProviderMetadata { - id: ProviderId::Mistral, - display_name: "Mistral", - session_label: "Monthly", - weekly_label: "", - supports_opus: false, - supports_credits: true, - default_enabled: false, - is_primary: false, - dashboard_url: Some("https://admin.mistral.ai/organization/usage"), - status_page_url: Some("https://status.mistral.ai"), - tertiary_label_key: None, - }, client: crate::core::credentialed_http_client_builder() .timeout(CLIENT_TIMEOUT) .build() @@ -528,10 +514,6 @@ impl Provider for MistralProvider { ProviderId::Mistral } - fn metadata(&self) -> &ProviderMetadata { - &self.metadata - } - async fn fetch_usage(&self, ctx: &FetchContext) -> Result { match ctx.source_mode { SourceMode::Auto | SourceMode::Web => { diff --git a/rust/src/providers/muse/mod.rs b/rust/src/providers/muse/mod.rs index bda9bb4b626..0d458535507 100644 --- a/rust/src/providers/muse/mod.rs +++ b/rust/src/providers/muse/mod.rs @@ -20,7 +20,7 @@ use tokio::time::{Duration, timeout}; use crate::core::{ FetchContext, Provider, ProviderDisplayDetail, ProviderError, ProviderFetchResult, ProviderId, - ProviderMetadata, RateWindow, SourceMode, UsageSnapshot, + RateWindow, SourceMode, UsageSnapshot, }; const MUSE_USAGE_URL: &str = "https://api.meta.ai/muse-code/key"; @@ -52,26 +52,12 @@ struct MuseMetaCredentials { } pub struct MuseProvider { - metadata: ProviderMetadata, client: Client, } impl MuseProvider { pub fn new() -> Self { Self { - metadata: ProviderMetadata { - id: ProviderId::Muse, - display_name: "Muse Code", - session_label: "5 hours", - weekly_label: "Weekly", - supports_opus: false, - supports_credits: false, - default_enabled: false, - is_primary: false, - dashboard_url: Some("https://dev.meta.ai"), - status_page_url: None, - tertiary_label_key: None, - }, client: crate::core::credentialed_http_client_builder() .timeout(REQUEST_TIMEOUT) .build() @@ -147,10 +133,6 @@ impl Provider for MuseProvider { ProviderId::Muse } - fn metadata(&self) -> &ProviderMetadata { - &self.metadata - } - async fn fetch_usage(&self, ctx: &FetchContext) -> Result { match ctx.source_mode { SourceMode::Auto | SourceMode::OAuth => self.fetch_api(ctx).await, diff --git a/rust/src/providers/neuralwatt/mod.rs b/rust/src/providers/neuralwatt/mod.rs index 72b55077909..7ce148d4b5f 100644 --- a/rust/src/providers/neuralwatt/mod.rs +++ b/rust/src/providers/neuralwatt/mod.rs @@ -16,7 +16,7 @@ use serde::Deserialize; use crate::core::{ CostSnapshot, FetchContext, NamedRateWindow, Provider, ProviderError, ProviderFetchResult, - ProviderId, ProviderMetadata, RateWindow, SourceMode, SubscriptionMetadata, UsageSnapshot, + ProviderId, RateWindow, SourceMode, SubscriptionMetadata, UsageSnapshot, }; const DEFAULT_API_BASE: &str = "https://api.neuralwatt.com"; @@ -124,26 +124,12 @@ struct KeyAllowance { } pub struct NeuralwattProvider { - metadata: ProviderMetadata, client: Client, } impl NeuralwattProvider { pub fn new() -> Self { Self { - metadata: ProviderMetadata { - id: ProviderId::Neuralwatt, - display_name: "Neuralwatt", - session_label: "Subscription", - weekly_label: "Key allowance", - supports_opus: false, - supports_credits: false, - default_enabled: false, - is_primary: false, - dashboard_url: Some("https://portal.neuralwatt.com/dashboard"), - status_page_url: None, - tertiary_label_key: None, - }, client: crate::core::credentialed_http_client_builder() .timeout(std::time::Duration::from_secs(15)) .build() @@ -194,10 +180,6 @@ impl Provider for NeuralwattProvider { ProviderId::Neuralwatt } - fn metadata(&self) -> &ProviderMetadata { - &self.metadata - } - async fn fetch_usage(&self, ctx: &FetchContext) -> Result { match ctx.source_mode { SourceMode::Auto | SourceMode::OAuth => { diff --git a/rust/src/providers/notion/mod.rs b/rust/src/providers/notion/mod.rs index c917e3b3991..c2906f57e01 100644 --- a/rust/src/providers/notion/mod.rs +++ b/rust/src/providers/notion/mod.rs @@ -14,16 +14,15 @@ use reqwest::Client; use serde_json::Value; use crate::core::{ - FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, ProviderMetadata, - RateWindow, SourceMode, UsageSnapshot, + FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, RateWindow, SourceMode, + UsageSnapshot, }; use crate::providers::browser_cookie_header; const BASE_URL: &str = "https://app.notion.com"; const GET_SPACES_URL: &str = "https://app.notion.com/api/v3/getSpaces"; const RATE_LIMIT_URL: &str = "https://app.notion.com/api/v3/getCreditRateLimitStatus"; -const DASHBOARD_URL: &str = "https://app.notion.com/"; -const STATUS_PAGE_URL: &str = "https://status.notion.so"; +pub(crate) const DASHBOARD_URL: &str = "https://app.notion.com/"; const SESSION_COOKIE_NAME: &str = "token_v2"; const USER_AGENT: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) \ AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36"; @@ -41,9 +40,7 @@ const COOKIE_DOMAINS: &[&str] = &[ /// with the real cycle length ending at `resets_at` (upstream `ProviderPaceCapability`). const MONTHLY_WINDOW_SENTINEL_MINUTES: u32 = 30 * 24 * 60; -pub struct NotionProvider { - metadata: ProviderMetadata, -} +pub struct NotionProvider {} #[derive(Debug, Clone)] struct NotionWorkspace { @@ -131,21 +128,7 @@ impl CreditRateLimitStatus { impl NotionProvider { pub fn new() -> Self { - Self { - metadata: ProviderMetadata { - id: ProviderId::Notion, - display_name: "Notion AI", - session_label: "Rolling", - weekly_label: "Monthly", - supports_opus: false, - supports_credits: false, - default_enabled: false, - is_primary: false, - dashboard_url: Some(DASHBOARD_URL), - status_page_url: Some(STATUS_PAGE_URL), - tertiary_label_key: None, - }, - } + Self {} } fn resolve_cookie_header(ctx: &FetchContext) -> Result { @@ -262,10 +245,6 @@ impl Provider for NotionProvider { ProviderId::Notion } - fn metadata(&self) -> &ProviderMetadata { - &self.metadata - } - async fn fetch_usage(&self, ctx: &FetchContext) -> Result { tracing::debug!("Fetching Notion AI usage"); match ctx.source_mode { diff --git a/rust/src/providers/nous/mod.rs b/rust/src/providers/nous/mod.rs index 8315c95061c..f5fb3494294 100644 --- a/rust/src/providers/nous/mod.rs +++ b/rust/src/providers/nous/mod.rs @@ -14,7 +14,7 @@ use tokio::time::{Duration, timeout}; use crate::core::{ FetchContext, Provider, ProviderDisplayDetail, ProviderError, ProviderFetchResult, ProviderId, - ProviderMetadata, RateWindow, SourceMode, SubscriptionMetadata, UsageSnapshot, + RateWindow, SourceMode, SubscriptionMetadata, UsageSnapshot, }; const PORTAL_ACCOUNT_PATH: &str = "api/oauth/account"; @@ -24,26 +24,12 @@ const REQUEST_TIMEOUT: Duration = Duration::from_secs(15); mod credentials; pub struct NousProvider { - metadata: ProviderMetadata, client: Client, } impl NousProvider { pub fn new() -> Self { Self { - metadata: ProviderMetadata { - id: ProviderId::Nous, - display_name: "Nous Portal", - session_label: "Monthly credits", - weekly_label: "Weekly", - supports_opus: false, - supports_credits: false, - default_enabled: false, - is_primary: false, - dashboard_url: Some("https://portal.nousresearch.com/usage"), - status_page_url: None, - tertiary_label_key: None, - }, client: crate::core::credentialed_http_client_builder() .timeout(REQUEST_TIMEOUT) .build() @@ -102,10 +88,6 @@ impl Provider for NousProvider { ProviderId::Nous } - fn metadata(&self) -> &ProviderMetadata { - &self.metadata - } - async fn fetch_usage(&self, ctx: &FetchContext) -> Result { match ctx.source_mode { SourceMode::Auto | SourceMode::OAuth => self.fetch_api(ctx.api_key.as_deref()).await, diff --git a/rust/src/providers/ollama/mod.rs b/rust/src/providers/ollama/mod.rs index d042f5ca403..65c15ae87a6 100755 --- a/rust/src/providers/ollama/mod.rs +++ b/rust/src/providers/ollama/mod.rs @@ -15,7 +15,7 @@ use serde::Deserialize; use crate::core::{ FetchContext, ManualEmptyCookiePolicy, Provider, ProviderError, ProviderFetchResult, - ProviderId, ProviderMetadata, RateWindow, SourceMode, UsageSnapshot, + ProviderId, RateWindow, SourceMode, UsageSnapshot, }; use crate::settings::ApiKeys; @@ -27,9 +27,7 @@ const OLLAMA_MONTHLY_WINDOW_MINUTES: u32 = 30 * 24 * 60; const OLLAMA_MONTHLY_USAGE_LABEL: &str = "Monthly usage"; /// Ollama provider -pub struct OllamaProvider { - metadata: ProviderMetadata, -} +pub struct OllamaProvider {} #[derive(Debug, Clone, PartialEq)] struct UsageBlock { @@ -41,21 +39,7 @@ struct UsageBlock { impl OllamaProvider { pub fn new() -> Self { - Self { - metadata: ProviderMetadata { - id: ProviderId::Ollama, - display_name: "Ollama", - session_label: "Session", - weekly_label: "Weekly", - supports_opus: false, - supports_credits: false, - default_enabled: false, - is_primary: false, - dashboard_url: Some("https://ollama.com/settings"), - status_page_url: None, - tertiary_label_key: None, - }, - } + Self {} } /// Fetch usage by scraping ollama.com/settings @@ -372,10 +356,6 @@ impl Provider for OllamaProvider { ProviderId::Ollama } - fn metadata(&self) -> &ProviderMetadata { - &self.metadata - } - async fn fetch_usage(&self, ctx: &FetchContext) -> Result { tracing::debug!("Fetching Ollama usage"); diff --git a/rust/src/providers/openaiapi/mod.rs b/rust/src/providers/openaiapi/mod.rs index 4d73b4dada6..cb5e6806d99 100644 --- a/rust/src/providers/openaiapi/mod.rs +++ b/rust/src/providers/openaiapi/mod.rs @@ -31,7 +31,7 @@ mod history; use crate::core::{ CostSnapshot, FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, - ProviderMetadata, RateWindow, SourceMode, UsageSnapshot, + RateWindow, SourceMode, UsageSnapshot, }; const OPENAI_CREDIT_GRANTS_URL: &str = "https://api.openai.com/v1/dashboard/billing/credit_grants"; @@ -181,7 +181,6 @@ fn allows_legacy_balance_fallback(project_id: Option<&str>, is_admin: bool) -> b } pub struct OpenAIApiProvider { - metadata: ProviderMetadata, client: Client, endpoints: Endpoints, retry: RetryPolicy, @@ -190,19 +189,6 @@ pub struct OpenAIApiProvider { impl OpenAIApiProvider { pub fn new() -> Self { Self { - metadata: ProviderMetadata { - id: ProviderId::OpenAIApi, - display_name: "OpenAI", - session_label: "Spend", - weekly_label: "Requests", - supports_opus: false, - supports_credits: false, - default_enabled: false, - is_primary: false, - dashboard_url: Some("https://platform.openai.com/usage"), - status_page_url: Some("https://status.openai.com"), - tertiary_label_key: None, - }, client: crate::core::credentialed_http_client_builder() .timeout(REQUEST_TIMEOUT) .build() @@ -672,10 +658,6 @@ impl Provider for OpenAIApiProvider { ProviderId::OpenAIApi } - fn metadata(&self) -> &ProviderMetadata { - &self.metadata - } - async fn fetch_usage(&self, ctx: &FetchContext) -> Result { match ctx.source_mode { SourceMode::Auto | SourceMode::OAuth => { diff --git a/rust/src/providers/opencode/mod.rs b/rust/src/providers/opencode/mod.rs index 63c205e8025..ce5f63b97e1 100755 --- a/rust/src/providers/opencode/mod.rs +++ b/rust/src/providers/opencode/mod.rs @@ -20,8 +20,8 @@ use serde_json::Value; use uuid::Uuid; use crate::core::{ - FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, ProviderMetadata, - RateWindow, SourceMode, UsageSnapshot, + FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, RateWindow, SourceMode, + UsageSnapshot, }; pub(super) const BASE_URL: &str = "https://opencode.ai"; @@ -33,26 +33,12 @@ const SUBSCRIPTION_SERVER_ID: &str = "7abeebee372f304e050aaaf92be863f4a86490e382f8c79db68fd94040d691b4"; /// OpenCode provider pub struct OpenCodeProvider { - metadata: ProviderMetadata, pub(super) client: Client, } impl OpenCodeProvider { pub fn new() -> Self { Self { - metadata: ProviderMetadata { - id: ProviderId::OpenCode, - display_name: "OpenCode", - session_label: "5-hour", - weekly_label: "Weekly", - supports_opus: false, - supports_credits: false, - default_enabled: false, - is_primary: false, - dashboard_url: Some("https://opencode.ai"), - status_page_url: None, - tertiary_label_key: None, - }, client: crate::core::credentialed_http_client_builder() .timeout(std::time::Duration::from_secs(30)) .build() @@ -609,10 +595,6 @@ impl Provider for OpenCodeProvider { ProviderId::OpenCode } - fn metadata(&self) -> &ProviderMetadata { - &self.metadata - } - async fn fetch_usage(&self, ctx: &FetchContext) -> Result { tracing::debug!("Fetching OpenCode usage"); diff --git a/rust/src/providers/opencodego/mod.rs b/rust/src/providers/opencodego/mod.rs index 5186531729c..b81bc05a0ad 100644 --- a/rust/src/providers/opencodego/mod.rs +++ b/rust/src/providers/opencodego/mod.rs @@ -22,7 +22,7 @@ use transport::{HttpWebTransport, WebTransport}; use crate::core::{ CostSnapshot, FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, - ProviderMetadata, RateWindow, SourceMode, UsageSnapshot, + RateWindow, SourceMode, UsageSnapshot, }; const BASE_URL: &str = "https://opencode.ai"; @@ -39,7 +39,6 @@ const ZEN_BALANCE_START_DELAY: Duration = Duration::from_millis(25); const ZEN_BALANCE_JOIN_GRACE: Duration = Duration::from_millis(250); pub struct OpenCodeGoProvider { - metadata: ProviderMetadata, client: Client, } @@ -116,19 +115,6 @@ impl WebCookieSession { impl OpenCodeGoProvider { pub fn new() -> Self { Self { - metadata: ProviderMetadata { - id: ProviderId::OpenCodeGo, - display_name: "OpenCode Go", - session_label: "5-hour", - weekly_label: "Weekly", - supports_opus: true, - supports_credits: false, - default_enabled: false, - is_primary: false, - dashboard_url: Some("https://opencode.ai"), - status_page_url: None, - tertiary_label_key: Some("ProviderMonthly"), - }, client: crate::core::credentialed_http_client_builder() .timeout(std::time::Duration::from_secs(30)) .build() @@ -471,10 +457,6 @@ impl Provider for OpenCodeGoProvider { ProviderId::OpenCodeGo } - fn metadata(&self) -> &ProviderMetadata { - &self.metadata - } - async fn fetch_usage(&self, ctx: &FetchContext) -> Result { tracing::debug!("Fetching OpenCode Go usage"); diff --git a/rust/src/providers/openrouter/mod.rs b/rust/src/providers/openrouter/mod.rs index ab6b917ced3..89fbbbba228 100755 --- a/rust/src/providers/openrouter/mod.rs +++ b/rust/src/providers/openrouter/mod.rs @@ -17,7 +17,7 @@ use serde_json::Value; use crate::core::{ CostSnapshot, FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, - ProviderMetadata, RateWindow, SourceMode, UsageSnapshot, + RateWindow, SourceMode, UsageSnapshot, }; use activity::ActivityReport; use diagnostics::{Degraded, Observations}; @@ -140,9 +140,7 @@ impl KeyData { } /// OpenRouter provider -pub struct OpenRouterProvider { - metadata: ProviderMetadata, -} +pub struct OpenRouterProvider {} /// Usage value for quota math when the server does not report remaining: the /// field matching the declared reset window when known, otherwise cumulative @@ -164,21 +162,7 @@ fn quota_fallback_usage(key_data: &KeyData) -> Option { impl OpenRouterProvider { pub fn new() -> Self { - Self { - metadata: ProviderMetadata { - id: ProviderId::OpenRouter, - display_name: "OpenRouter", - session_label: "Credits", - weekly_label: "API key limit", - supports_opus: false, - supports_credits: true, - default_enabled: false, - is_primary: false, - dashboard_url: Some("https://openrouter.ai/activity"), - status_page_url: Some("https://status.openrouter.ai"), - tertiary_label_key: None, - }, - } + Self {} } /// Get API token from ctx, Windows Credential Manager, or env @@ -572,10 +556,6 @@ impl Provider for OpenRouterProvider { ProviderId::OpenRouter } - fn metadata(&self) -> &ProviderMetadata { - &self.metadata - } - async fn fetch_usage(&self, ctx: &FetchContext) -> Result { tracing::debug!("Fetching OpenRouter usage"); diff --git a/rust/src/providers/openrouter/tests.rs b/rust/src/providers/openrouter/tests.rs index 87c945c6df4..4c41d4dffd8 100644 --- a/rust/src/providers/openrouter/tests.rs +++ b/rust/src/providers/openrouter/tests.rs @@ -82,7 +82,7 @@ fn key_quota_percent(key_data: KeyData) -> Option { #[test] fn key_limit_copy_stays_distinct_from_account_balance() { let provider = OpenRouterProvider::new(); - assert_eq!(provider.metadata.weekly_label, "API key limit"); + assert_eq!(provider.metadata().weekly_label, "API key limit"); let credits = CreditsData { total_credits: 5.0, diff --git a/rust/src/providers/perplexity/mod.rs b/rust/src/providers/perplexity/mod.rs index 36f422c8a07..6b6a1580ad1 100644 --- a/rust/src/providers/perplexity/mod.rs +++ b/rust/src/providers/perplexity/mod.rs @@ -18,8 +18,8 @@ use reqwest::{Client, StatusCode}; use serde::Deserialize; use crate::core::{ - FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, ProviderMetadata, - RateWindow, SourceMode, UsageSnapshot, + FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, RateWindow, SourceMode, + UsageSnapshot, }; use cookies::request_cookies; @@ -65,7 +65,6 @@ struct CreditGrant { } pub struct PerplexityProvider { - metadata: ProviderMetadata, client: Client, credits_url: String, } @@ -73,19 +72,6 @@ pub struct PerplexityProvider { impl PerplexityProvider { pub fn new() -> Self { Self { - metadata: ProviderMetadata { - id: ProviderId::Perplexity, - display_name: "Perplexity", - session_label: "Credits", - weekly_label: "Bonus credits", - supports_opus: false, - supports_credits: true, - default_enabled: false, - is_primary: false, - dashboard_url: Some("https://www.perplexity.ai/account/usage"), - status_page_url: None, - tertiary_label_key: None, - }, client: crate::core::credentialed_http_client_builder() .timeout(std::time::Duration::from_secs(30)) .build() @@ -360,10 +346,6 @@ impl Provider for PerplexityProvider { ProviderId::Perplexity } - fn metadata(&self) -> &ProviderMetadata { - &self.metadata - } - async fn fetch_usage(&self, ctx: &FetchContext) -> Result { tracing::debug!("Fetching Perplexity usage"); diff --git a/rust/src/providers/pi.rs b/rust/src/providers/pi.rs index 862f069abc4..6a3ea52fb9d 100644 --- a/rust/src/providers/pi.rs +++ b/rust/src/providers/pi.rs @@ -8,31 +8,15 @@ use async_trait::async_trait; use crate::core::{ - FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, ProviderMetadata, - RateWindow, SourceMode, UsageSnapshot, + FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, RateWindow, SourceMode, + UsageSnapshot, }; -pub struct PiProvider { - metadata: ProviderMetadata, -} +pub struct PiProvider {} impl PiProvider { pub fn new() -> Self { - Self { - metadata: ProviderMetadata { - id: ProviderId::Pi, - display_name: "Pi", - session_label: "Session", - weekly_label: "Weekly", - supports_opus: false, - supports_credits: false, - default_enabled: false, - is_primary: false, - dashboard_url: Some("https://github.com/badlogic/pi-mono"), - status_page_url: None, - tertiary_label_key: None, - }, - } + Self {} } } @@ -48,10 +32,6 @@ impl Provider for PiProvider { ProviderId::Pi } - fn metadata(&self) -> &ProviderMetadata { - &self.metadata - } - async fn fetch_usage(&self, ctx: &FetchContext) -> Result { if ctx.source_mode != SourceMode::Auto { return Err(ProviderError::UnsupportedSource(ctx.source_mode)); diff --git a/rust/src/providers/qoder/mod.rs b/rust/src/providers/qoder/mod.rs index ed91b7c4723..1c4fe884f39 100644 --- a/rust/src/providers/qoder/mod.rs +++ b/rust/src/providers/qoder/mod.rs @@ -14,8 +14,8 @@ use serde_json::Value; use std::time::Duration; use crate::core::{ - FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, ProviderMetadata, - RateWindow, SourceMode, UsageSnapshot, + FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, RateWindow, SourceMode, + UsageSnapshot, }; const BX_VERSION: &str = "2.5.35"; @@ -25,26 +25,12 @@ const USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/ use routing::QoderSite; pub struct QoderProvider { - metadata: ProviderMetadata, client: Client, } impl QoderProvider { pub fn new() -> Self { Self { - metadata: ProviderMetadata { - id: ProviderId::Qoder, - display_name: "Qoder", - session_label: "Credits", - weekly_label: "Shared credits", - supports_opus: false, - supports_credits: true, - default_enabled: false, - is_primary: false, - dashboard_url: Some("https://qoder.com/account/usage"), - status_page_url: None, - tertiary_label_key: None, - }, client: crate::core::credentialed_http_client_builder() .timeout(REQUEST_TIMEOUT) .build() @@ -509,10 +495,6 @@ impl Provider for QoderProvider { ProviderId::Qoder } - fn metadata(&self) -> &ProviderMetadata { - &self.metadata - } - async fn fetch_usage(&self, ctx: &FetchContext) -> Result { match ctx.source_mode { SourceMode::Auto | SourceMode::Web => self.fetch_usage_web(ctx).await, diff --git a/rust/src/providers/qwencloud/mod.rs b/rust/src/providers/qwencloud/mod.rs index d9f2abeb844..d392a64f0ce 100644 --- a/rust/src/providers/qwencloud/mod.rs +++ b/rust/src/providers/qwencloud/mod.rs @@ -14,15 +14,15 @@ use serde_json::{Map, Value, json}; use uuid::Uuid; use crate::core::{ - FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, ProviderMetadata, - RateWindow, SourceMode, UsageSnapshot, + FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, RateWindow, SourceMode, + UsageSnapshot, }; use crate::providers::browser_cookie_header; const GATEWAY_BASE_URL: &str = "https://home.qwencloud.com"; const DATA_GATEWAY_BASE_URL: &str = "https://cs-data.qwencloud.com"; -const DASHBOARD_URL: &str = "https://home.qwencloud.com/billing/subscription/token-plan-individual"; -const STATUS_PAGE_URL: &str = "https://status.alibabacloud.com"; +pub(crate) const DASHBOARD_URL: &str = + "https://home.qwencloud.com/billing/subscription/token-plan-individual"; const PRODUCT_CODE: &str = "sfm_tokenplansolo_public_intl"; const CONSOLE_PRODUCT: &str = "sfm_bailian"; const CONSOLE_ACTION: &str = "IntlBroadScopeAspnGateway"; @@ -52,9 +52,7 @@ const WEEKLY_MINUTES: u32 = 7 * 24 * 60; const LEGACY_MINUTES: u32 = 30 * 24 * 60; const MONTHLY_MINUTES: u32 = 30 * 24 * 60; -pub struct QwenCloudProvider { - metadata: ProviderMetadata, -} +pub struct QwenCloudProvider {} #[derive(Debug, Clone, PartialEq)] struct QwenCloudSnapshot { @@ -91,21 +89,7 @@ const USAGE_WINDOW_KEYS: &[&str] = &[ impl QwenCloudProvider { pub fn new() -> Self { - Self { - metadata: ProviderMetadata { - id: ProviderId::QwenCloud, - display_name: "Qwen Cloud", - session_label: "5-hour", - weekly_label: "Weekly", - supports_opus: false, - supports_credits: false, - default_enabled: false, - is_primary: false, - dashboard_url: Some(DASHBOARD_URL), - status_page_url: Some(STATUS_PAGE_URL), - tertiary_label_key: None, - }, - } + Self {} } async fn fetch_via_web(&self, ctx: &FetchContext) -> Result { @@ -395,7 +379,7 @@ impl QwenCloudProvider { let (primary, secondary, mut primary_label, monthly_extra) = match (five_hour.or(legacy), weekly) { (Some(primary), secondary) => (primary, secondary, None, monthly), - (None, Some(weekly)) => (weekly, None, Some(self.metadata.weekly_label), monthly), + (None, Some(weekly)) => (weekly, None, Some(self.metadata().weekly_label), monthly), (None, None) => match monthly { Some(monthly) => (monthly, None, None, None), None => { @@ -438,10 +422,6 @@ impl Provider for QwenCloudProvider { ProviderId::QwenCloud } - fn metadata(&self) -> &ProviderMetadata { - &self.metadata - } - async fn fetch_usage(&self, ctx: &FetchContext) -> Result { match ctx.source_mode { SourceMode::Auto | SourceMode::Web => { diff --git a/rust/src/providers/raycast/mod.rs b/rust/src/providers/raycast/mod.rs index 8cd578462a9..e3ca4e66410 100644 --- a/rust/src/providers/raycast/mod.rs +++ b/rust/src/providers/raycast/mod.rs @@ -19,12 +19,12 @@ use std::time::Duration; use crate::browser::detection::BrowserType; use crate::core::{ FetchContext, ManualEmptyCookiePolicy, Provider, ProviderError, ProviderFetchResult, - ProviderId, ProviderMetadata, ProviderStateKind, SourceMode, + ProviderId, ProviderStateKind, SourceMode, }; use crate::providers::{BoundedBodyError, read_bounded_response}; const ORIGIN: &str = "https://www.raycast.com"; -const SETTINGS_URL: &str = "https://www.raycast.com/settings"; +pub(crate) const SETTINGS_URL: &str = "https://www.raycast.com/settings"; const CREDITS_PATH: &str = "/frontend_api/current_user/ai_credits"; const USER_AGENT: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36"; const MAX_RESPONSE_BYTES: usize = 1024 * 1024; @@ -38,7 +38,6 @@ const COOKIES_DISABLED: &str = "Raycast cookies are disabled. Set the cookie source to Auto or Manual to read credits."; pub struct RaycastProvider { - metadata: ProviderMetadata, client: Client, origin: String, } @@ -46,19 +45,6 @@ pub struct RaycastProvider { impl RaycastProvider { pub fn new() -> Self { Self { - metadata: ProviderMetadata { - id: ProviderId::Raycast, - display_name: "Raycast", - session_label: "Credits", - weekly_label: "Plan", - supports_opus: false, - supports_credits: false, - default_enabled: false, - is_primary: false, - dashboard_url: Some(SETTINGS_URL), - status_page_url: None, - tertiary_label_key: None, - }, client: crate::core::credentialed_http_client_builder() .build() .unwrap_or_else(|_| Client::new()), @@ -74,7 +60,6 @@ impl RaycastProvider { .build() .expect("the test client should build"), origin: origin.to_string(), - ..Self::new() } } @@ -185,10 +170,6 @@ impl Provider for RaycastProvider { ProviderId::Raycast } - fn metadata(&self) -> &ProviderMetadata { - &self.metadata - } - async fn fetch_usage(&self, ctx: &FetchContext) -> Result { match ctx.source_mode { SourceMode::Auto | SourceMode::Web => self.fetch_web(ctx).await, diff --git a/rust/src/providers/replicate/mod.rs b/rust/src/providers/replicate/mod.rs index 1bcebe3aa08..4a503a54f25 100644 --- a/rust/src/providers/replicate/mod.rs +++ b/rust/src/providers/replicate/mod.rs @@ -18,11 +18,10 @@ use tokio::time::timeout; use crate::core::{ CostSnapshot, FetchContext, ManualEmptyCookiePolicy, Provider, ProviderDisplayDetail, - ProviderError, ProviderFetchResult, ProviderId, ProviderMetadata, RateWindow, SourceMode, - UsageSnapshot, + ProviderError, ProviderFetchResult, ProviderId, RateWindow, SourceMode, UsageSnapshot, }; -const BILLING_URL: &str = "https://replicate.com/account/billing"; +pub(crate) const BILLING_URL: &str = "https://replicate.com/account/billing"; const REPLICATE_ORIGIN: &str = "https://replicate.com"; const REQUEST_TIMEOUT: Duration = Duration::from_secs(8); const OPTIONAL_CREDIT_TIMEOUT: Duration = Duration::from_secs(2); @@ -56,26 +55,12 @@ struct InvoiceSpend { } pub struct ReplicateProvider { - metadata: ProviderMetadata, client: Client, } impl ReplicateProvider { pub fn new() -> Self { Self { - metadata: ProviderMetadata { - id: ProviderId::Replicate, - display_name: "Replicate", - session_label: "Spend", - weekly_label: "Spend", - supports_opus: false, - supports_credits: true, - default_enabled: false, - is_primary: false, - dashboard_url: Some(BILLING_URL), - status_page_url: None, - tertiary_label_key: None, - }, client: crate::core::credentialed_http_client_builder() .timeout(REQUEST_TIMEOUT) .build() @@ -219,10 +204,6 @@ impl Provider for ReplicateProvider { ProviderId::Replicate } - fn metadata(&self) -> &ProviderMetadata { - &self.metadata - } - async fn fetch_usage(&self, ctx: &FetchContext) -> Result { match ctx.source_mode { SourceMode::Auto | SourceMode::Web => self.fetch_with_cookie_source(ctx).await, diff --git a/rust/src/providers/sakana/mod.rs b/rust/src/providers/sakana/mod.rs index 58d4ef0d64f..552ca818b75 100644 --- a/rust/src/providers/sakana/mod.rs +++ b/rust/src/providers/sakana/mod.rs @@ -12,16 +12,15 @@ use regex_lite::Regex; use reqwest::{Client, RequestBuilder, Url}; use crate::core::{ - FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, ProviderMetadata, - RateWindow, SourceMode, UsageSnapshot, + FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, RateWindow, SourceMode, + UsageSnapshot, }; -const BILLING_URL: &str = "https://console.sakana.ai/billing"; +pub(crate) const BILLING_URL: &str = "https://console.sakana.ai/billing"; const PAYG_QUERY: &str = "tab=payAsYouGo"; const USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"; pub struct SakanaProvider { - metadata: ProviderMetadata, client: Client, billing_url: Url, } @@ -29,19 +28,6 @@ pub struct SakanaProvider { impl SakanaProvider { pub fn new() -> Self { Self { - metadata: ProviderMetadata { - id: ProviderId::Sakana, - display_name: "Sakana AI", - session_label: "5-hour", - weekly_label: "Weekly", - supports_opus: false, - supports_credits: false, - default_enabled: false, - is_primary: false, - dashboard_url: Some(BILLING_URL), - status_page_url: None, - tertiary_label_key: None, - }, client: crate::core::credentialed_http_client_builder() .timeout(std::time::Duration::from_secs(15)) .build() @@ -238,10 +224,6 @@ impl Provider for SakanaProvider { ProviderId::Sakana } - fn metadata(&self) -> &ProviderMetadata { - &self.metadata - } - async fn fetch_usage(&self, ctx: &FetchContext) -> Result { match ctx.source_mode { SourceMode::Auto | SourceMode::Web => { diff --git a/rust/src/providers/stepfun/mod.rs b/rust/src/providers/stepfun/mod.rs index b35f184016b..8b4657750cb 100644 --- a/rust/src/providers/stepfun/mod.rs +++ b/rust/src/providers/stepfun/mod.rs @@ -11,8 +11,8 @@ use reqwest::Client; use serde::Deserialize; use crate::core::{ - FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, ProviderMetadata, - RateWindow, SourceMode, UsageSnapshot, + FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, RateWindow, SourceMode, + UsageSnapshot, }; const STEPFUN_RATE_LIMIT_URL: &str = @@ -174,26 +174,12 @@ struct StepFunTokenPair { } pub struct StepFunProvider { - metadata: ProviderMetadata, client: Client, } impl StepFunProvider { pub fn new() -> Self { Self { - metadata: ProviderMetadata { - id: ProviderId::StepFun, - display_name: "StepFun", - session_label: "5-hour", - weekly_label: "Weekly", - supports_opus: false, - supports_credits: false, - default_enabled: false, - is_primary: false, - dashboard_url: Some("https://platform.stepfun.com/dashboard"), - status_page_url: None, - tertiary_label_key: None, - }, client: crate::core::credentialed_http_client_builder() .timeout(std::time::Duration::from_secs(15)) .build() @@ -517,10 +503,6 @@ impl Provider for StepFunProvider { ProviderId::StepFun } - fn metadata(&self) -> &ProviderMetadata { - &self.metadata - } - async fn fetch_usage(&self, ctx: &FetchContext) -> Result { match ctx.source_mode { SourceMode::Auto | SourceMode::OAuth => { diff --git a/rust/src/providers/sub2api/mod.rs b/rust/src/providers/sub2api/mod.rs index 569b0ff33f7..4016f0f6252 100644 --- a/rust/src/providers/sub2api/mod.rs +++ b/rust/src/providers/sub2api/mod.rs @@ -10,7 +10,7 @@ use serde::Deserialize; use crate::core::{ CostSnapshot, FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, - ProviderMetadata, RateWindow, SourceMode, UsageSnapshot, + RateWindow, SourceMode, UsageSnapshot, }; const CREDENTIAL_TARGET: &str = "codexbar-sub2api"; @@ -18,7 +18,6 @@ const API_KEY_ENV: &str = "SUB2API_API_KEY"; const BASE_URL_ENV: &str = "SUB2API_BASE_URL"; pub struct Sub2ApiProvider { - metadata: ProviderMetadata, client: Client, } @@ -148,19 +147,6 @@ struct TotalsResponse { impl Sub2ApiProvider { pub fn new() -> Self { Self { - metadata: ProviderMetadata { - id: ProviderId::Sub2Api, - display_name: "sub2api", - session_label: "Quota", - weekly_label: "Weekly quota", - supports_opus: false, - supports_credits: false, - default_enabled: false, - is_primary: false, - dashboard_url: None, - status_page_url: None, - tertiary_label_key: None, - }, client: crate::core::credentialed_http_client_builder() .timeout(std::time::Duration::from_secs(15)) .build() @@ -215,10 +201,6 @@ impl Provider for Sub2ApiProvider { ProviderId::Sub2Api } - fn metadata(&self) -> &ProviderMetadata { - &self.metadata - } - async fn fetch_usage(&self, ctx: &FetchContext) -> Result { match ctx.source_mode { SourceMode::Auto | SourceMode::OAuth => { diff --git a/rust/src/providers/t3chat.rs b/rust/src/providers/t3chat.rs index a8697cc1bf6..86ba3490090 100644 --- a/rust/src/providers/t3chat.rs +++ b/rust/src/providers/t3chat.rs @@ -9,8 +9,8 @@ use regex_lite::Regex; use serde::Deserialize; use crate::core::{ - FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, ProviderMetadata, - RateWindow, SourceMode, UsageSnapshot, + FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, RateWindow, SourceMode, + UsageSnapshot, }; use crate::providers::browser_cookie_header; @@ -20,9 +20,7 @@ const CUSTOMER_DATA_INPUT: &str = r#"{"0":{"json":{"sessionId":null},"meta":{"values":{"sessionId":["undefined"]}}}}"#; const COOKIE_DOMAINS: [&str; 2] = ["t3.chat", "www.t3.chat"]; -pub struct T3ChatProvider { - metadata: ProviderMetadata, -} +pub struct T3ChatProvider {} #[derive(Debug, Clone)] struct T3RequestContext { @@ -52,21 +50,7 @@ struct T3Subscription { impl T3ChatProvider { pub fn new() -> Self { - Self { - metadata: ProviderMetadata { - id: ProviderId::T3Chat, - display_name: "T3 Chat", - session_label: "Base", - weekly_label: "Overage", - supports_opus: false, - supports_credits: false, - default_enabled: false, - is_primary: false, - dashboard_url: Some("https://t3.chat/settings/customization"), - status_page_url: None, - tertiary_label_key: None, - }, - } + Self {} } async fn fetch_via_web(&self, ctx: &FetchContext) -> Result { @@ -381,10 +365,6 @@ impl Provider for T3ChatProvider { ProviderId::T3Chat } - fn metadata(&self) -> &ProviderMetadata { - &self.metadata - } - async fn fetch_usage(&self, ctx: &FetchContext) -> Result { match ctx.source_mode { SourceMode::Auto | SourceMode::Web => { diff --git a/rust/src/providers/typesafe.rs b/rust/src/providers/typesafe.rs index 67136d6013c..6ddb7c7af47 100644 --- a/rust/src/providers/typesafe.rs +++ b/rust/src/providers/typesafe.rs @@ -10,10 +10,10 @@ use std::time::Duration; use super::{BoundedBodyError, read_bounded_response}; use crate::core::{ CostSnapshot, FetchContext, Provider, ProviderDisplayDetail, ProviderError, - ProviderFetchResult, ProviderId, ProviderMetadata, RateWindow, SourceMode, UsageSnapshot, + ProviderFetchResult, ProviderId, RateWindow, SourceMode, UsageSnapshot, }; -const BILLING_URL: &str = "https://console.typesafe.ai/settings/billing"; +pub(crate) const BILLING_URL: &str = "https://console.typesafe.ai/settings/billing"; const ORIGIN: &str = "https://console.typesafe.ai"; const MAX_CHUNKS: usize = 60; const CHUNK_SCAN_CONCURRENCY: usize = 6; @@ -37,26 +37,12 @@ struct Billing { } pub struct TypeSafeProvider { - metadata: ProviderMetadata, client: Client, } impl TypeSafeProvider { pub fn new() -> Self { Self { - metadata: ProviderMetadata { - id: ProviderId::TypeSafe, - display_name: "TypeSafe", - session_label: "Balance", - weekly_label: "Spend", - supports_opus: false, - supports_credits: true, - default_enabled: false, - is_primary: false, - dashboard_url: Some(BILLING_URL), - status_page_url: None, - tertiary_label_key: None, - }, client: crate::core::credentialed_http_client_builder() .redirect(Policy::none()) .timeout(REQUEST_TIMEOUT) @@ -173,9 +159,6 @@ impl Provider for TypeSafeProvider { fn id(&self) -> ProviderId { ProviderId::TypeSafe } - fn metadata(&self) -> &ProviderMetadata { - &self.metadata - } async fn fetch_usage(&self, ctx: &FetchContext) -> Result { match ctx.source_mode { SourceMode::Auto | SourceMode::Web => self.fetch_web(ctx).await, diff --git a/rust/src/providers/v0.rs b/rust/src/providers/v0.rs index e7866fd0002..3014aa78b68 100644 --- a/rust/src/providers/v0.rs +++ b/rust/src/providers/v0.rs @@ -8,7 +8,7 @@ use std::time::Duration; use crate::core::{ FetchContext, Provider, ProviderDisplayDetail, ProviderError, ProviderFetchResult, ProviderId, - ProviderMetadata, RateWindow, SourceMode, UsageSnapshot, + RateWindow, SourceMode, UsageSnapshot, }; use crate::providers::{BoundedBodyError, read_bounded_response}; @@ -33,26 +33,12 @@ struct Billing { } pub struct V0Provider { - metadata: ProviderMetadata, client: Client, } impl V0Provider { pub fn new() -> Self { Self { - metadata: ProviderMetadata { - id: ProviderId::V0, - display_name: "v0", - session_label: "Billing", - weekly_label: "Rate limit", - supports_opus: false, - supports_credits: true, - default_enabled: false, - is_primary: false, - dashboard_url: Some("https://v0.app/settings/billing"), - status_page_url: Some("https://www.vercel-status.com/"), - tertiary_label_key: None, - }, client: crate::core::credentialed_http_client_builder() .timeout(Duration::from_secs(15)) .build() @@ -119,9 +105,6 @@ impl Provider for V0Provider { fn id(&self) -> ProviderId { ProviderId::V0 } - fn metadata(&self) -> &ProviderMetadata { - &self.metadata - } async fn fetch_usage(&self, ctx: &FetchContext) -> Result { match ctx.source_mode { diff --git a/rust/src/providers/venice/mod.rs b/rust/src/providers/venice/mod.rs index 6a49d16ea7c..9e6f4b530f7 100644 --- a/rust/src/providers/venice/mod.rs +++ b/rust/src/providers/venice/mod.rs @@ -12,7 +12,7 @@ use std::future::Future; use crate::core::{ FetchContext, Provider, ProviderDisplayDetail, ProviderError, ProviderFetchResult, ProviderId, - ProviderMetadata, RateWindow, SourceMode, UsageSnapshot, + RateWindow, SourceMode, UsageSnapshot, }; const VENICE_BALANCE_URL: &str = "https://api.venice.ai/api/v1/billing/balance"; @@ -71,7 +71,6 @@ fn session_token_from_body(body: &[u8]) -> Result { } pub struct VeniceProvider { - metadata: ProviderMetadata, client: Client, } @@ -103,19 +102,6 @@ impl VeniceWebFailure { impl VeniceProvider { pub fn new() -> Self { Self { - metadata: ProviderMetadata { - id: ProviderId::Venice, - display_name: "Venice", - session_label: "Balance", - weekly_label: "DIEM", - supports_opus: false, - supports_credits: true, - default_enabled: false, - is_primary: false, - dashboard_url: Some("https://venice.ai/settings/api"), - status_page_url: None, - tertiary_label_key: None, - }, client: crate::core::credentialed_http_client_builder() .timeout(std::time::Duration::from_secs(15)) .build() @@ -295,10 +281,6 @@ impl Provider for VeniceProvider { ProviderId::Venice } - fn metadata(&self) -> &ProviderMetadata { - &self.metadata - } - async fn fetch_usage(&self, ctx: &FetchContext) -> Result { match ctx.source_mode { SourceMode::Auto | SourceMode::OAuth => { diff --git a/rust/src/providers/vercel/mod.rs b/rust/src/providers/vercel/mod.rs index b509a8c72e3..f76a8e72c43 100644 --- a/rust/src/providers/vercel/mod.rs +++ b/rust/src/providers/vercel/mod.rs @@ -13,7 +13,7 @@ use std::time::Duration; use crate::core::{ CostSnapshot, FetchContext, Provider, ProviderDisplayDetail, ProviderError, - ProviderFetchResult, ProviderId, ProviderMetadata, RateWindow, SourceMode, UsageSnapshot, + ProviderFetchResult, ProviderId, RateWindow, SourceMode, UsageSnapshot, }; use crate::providers::{BoundedBodyError, read_bounded_response}; @@ -25,7 +25,6 @@ const REQUEST_TIMEOUT: Duration = Duration::from_secs(15); const SECTION_LABEL: &str = "Team credits"; pub struct VercelProvider { - metadata: ProviderMetadata, client: Client, credits_url: String, } @@ -42,19 +41,6 @@ impl VercelProvider { fn with_client(credits_url: impl Into, client: Client) -> Self { Self { - metadata: ProviderMetadata { - id: ProviderId::Vercel, - display_name: "Vercel AI Gateway", - session_label: "Balance", - weekly_label: "Balance", - supports_opus: false, - supports_credits: false, - default_enabled: false, - is_primary: false, - dashboard_url: Some("https://vercel.com/d?to=%2F%5Bteam%5D%2F%7E%2Fai-gateway"), - status_page_url: None, - tertiary_label_key: None, - }, client, credits_url: credits_url.into(), } @@ -99,10 +85,6 @@ impl Provider for VercelProvider { ProviderId::Vercel } - fn metadata(&self) -> &ProviderMetadata { - &self.metadata - } - async fn fetch_usage(&self, ctx: &FetchContext) -> Result { match ctx.source_mode { SourceMode::Auto | SourceMode::OAuth => self.fetch_credits(ctx).await, diff --git a/rust/src/providers/vertexai/mod.rs b/rust/src/providers/vertexai/mod.rs index fc6855717d0..81404ea68e0 100755 --- a/rust/src/providers/vertexai/mod.rs +++ b/rust/src/providers/vertexai/mod.rs @@ -16,32 +16,16 @@ use async_trait::async_trait; use std::path::PathBuf; use crate::core::{ - FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, ProviderMetadata, - RateWindow, SourceMode, UsageSnapshot, + FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, RateWindow, SourceMode, + UsageSnapshot, }; /// Vertex AI provider -pub struct VertexAIProvider { - metadata: ProviderMetadata, -} +pub struct VertexAIProvider {} impl VertexAIProvider { pub fn new() -> Self { - Self { - metadata: ProviderMetadata { - id: ProviderId::VertexAI, - display_name: "Vertex AI", - session_label: "Usage", - weekly_label: "Monthly", - supports_opus: false, - supports_credits: true, - default_enabled: false, - is_primary: false, - dashboard_url: Some("https://console.cloud.google.com/vertex-ai"), - status_page_url: Some("https://status.cloud.google.com"), - tertiary_label_key: None, - }, - } + Self {} } /// Get Google Cloud credentials path @@ -321,10 +305,6 @@ impl Provider for VertexAIProvider { ProviderId::VertexAI } - fn metadata(&self) -> &ProviderMetadata { - &self.metadata - } - fn retains_last_good_on_transport_failure(&self) -> bool { true } diff --git a/rust/src/providers/warp/mod.rs b/rust/src/providers/warp/mod.rs index ce94d0bbd4c..9264af82f1f 100755 --- a/rust/src/providers/warp/mod.rs +++ b/rust/src/providers/warp/mod.rs @@ -9,8 +9,8 @@ use serde::Deserialize; use serde_json::json; use crate::core::{ - FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, ProviderMetadata, - RateWindow, SourceMode, UsageSnapshot, + FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, RateWindow, SourceMode, + UsageSnapshot, }; /// Warp GraphQL API endpoint @@ -116,27 +116,11 @@ struct BonusGrantsInfo { } /// Warp provider -pub struct WarpProvider { - metadata: ProviderMetadata, -} +pub struct WarpProvider {} impl WarpProvider { pub fn new() -> Self { - Self { - metadata: ProviderMetadata { - id: ProviderId::Warp, - display_name: "Warp", - session_label: "Credits", - weekly_label: "Add-on credits", - supports_opus: false, - supports_credits: false, - default_enabled: false, - is_primary: false, - dashboard_url: Some("https://docs.warp.dev/reference/cli/api-keys"), - status_page_url: None, - tertiary_label_key: None, - }, - } + Self {} } /// Get API token from ctx, Windows Credential Manager, or env @@ -406,10 +390,6 @@ impl Provider for WarpProvider { ProviderId::Warp } - fn metadata(&self) -> &ProviderMetadata { - &self.metadata - } - async fn fetch_usage(&self, ctx: &FetchContext) -> Result { tracing::debug!("Fetching Warp usage"); diff --git a/rust/src/providers/windsurf/mod.rs b/rust/src/providers/windsurf/mod.rs index 79b15c0f32d..60140561e8d 100644 --- a/rust/src/providers/windsurf/mod.rs +++ b/rust/src/providers/windsurf/mod.rs @@ -11,8 +11,8 @@ use serde::Deserialize; use std::path::PathBuf; use crate::core::{ - FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, ProviderMetadata, - RateWindow, SourceMode, UsageSnapshot, + FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, RateWindow, SourceMode, + UsageSnapshot, }; const STATE_KEY: &str = "windsurf.settings.cachedPlanInfo"; @@ -49,27 +49,11 @@ struct QuotaUsage { weekly_reset_at_unix: Option, } -pub struct WindsurfProvider { - metadata: ProviderMetadata, -} +pub struct WindsurfProvider {} impl WindsurfProvider { pub fn new() -> Self { - Self { - metadata: ProviderMetadata { - id: ProviderId::Windsurf, - display_name: "Windsurf", - session_label: "Daily", - weekly_label: "Weekly", - supports_opus: false, - supports_credits: false, - default_enabled: false, - is_primary: false, - dashboard_url: Some("https://windsurf.com/subscription"), - status_page_url: Some("https://status.windsurf.com"), - tertiary_label_key: None, - }, - } + Self {} } fn default_db_path() -> Option { @@ -200,10 +184,6 @@ impl Provider for WindsurfProvider { ProviderId::Windsurf } - fn metadata(&self) -> &ProviderMetadata { - &self.metadata - } - async fn fetch_usage(&self, ctx: &FetchContext) -> Result { match ctx.source_mode { SourceMode::Auto | SourceMode::Cli => { diff --git a/rust/src/providers/xai/mod.rs b/rust/src/providers/xai/mod.rs index 951279095a5..417f51a0221 100644 --- a/rust/src/providers/xai/mod.rs +++ b/rust/src/providers/xai/mod.rs @@ -17,7 +17,7 @@ use serde_json::Value; use crate::core::{ CostDailyPoint, CostSnapshot, FetchContext, Provider, ProviderError, ProviderFetchResult, - ProviderId, ProviderMetadata, RateWindow, SourceMode, UsageSnapshot, + ProviderId, RateWindow, SourceMode, UsageSnapshot, }; const BASE_URL: &str = "https://management-api.x.ai"; @@ -169,26 +169,12 @@ fn format_balance_line(balance_usd: f64) -> String { } pub struct XaiProvider { - metadata: ProviderMetadata, client: Client, } impl XaiProvider { pub fn new() -> Self { Self { - metadata: ProviderMetadata { - id: ProviderId::Xai, - display_name: "xAI", - session_label: "Spend", - weekly_label: "Spend", - supports_opus: false, - supports_credits: false, - default_enabled: false, - is_primary: false, - dashboard_url: Some("https://console.x.ai"), - status_page_url: Some("https://status.x.ai"), - tertiary_label_key: None, - }, client: crate::core::credentialed_http_client_builder() .timeout(std::time::Duration::from_secs(REQUEST_TIMEOUT_SECS)) .build() @@ -340,10 +326,6 @@ impl Provider for XaiProvider { ProviderId::Xai } - fn metadata(&self) -> &ProviderMetadata { - &self.metadata - } - async fn fetch_usage(&self, ctx: &FetchContext) -> Result { match ctx.source_mode { SourceMode::Auto | SourceMode::OAuth => self.fetch_usage_api(ctx).await, diff --git a/rust/src/providers/xkiro/mod.rs b/rust/src/providers/xkiro/mod.rs index 38b3a0787be..671a81ebcfc 100644 --- a/rust/src/providers/xkiro/mod.rs +++ b/rust/src/providers/xkiro/mod.rs @@ -17,12 +17,11 @@ use serde_json::{Map, Value}; use crate::core::{ FetchContext, Provider, ProviderDisplayDetail, ProviderError, ProviderFetchResult, ProviderId, - ProviderMetadata, RateWindow, SourceMode, UsageSnapshot, + RateWindow, SourceMode, UsageSnapshot, }; use crate::providers::{BoundedBodyError, read_bounded_response}; const USAGE_URL: &str = "https://api.xkiro.com/v1/usage"; -const DASHBOARD_URL: &str = "https://xkiro.com"; const CREDENTIAL_TARGET: &str = "codexbar-xkiro"; const ENV_KEYS: &[&str] = &["XKIRO_API_KEY"]; const REQUEST_TIMEOUT_SECS: u64 = 15; @@ -45,7 +44,6 @@ struct FreeTokenUsage { } pub struct XKiroProvider { - metadata: ProviderMetadata, client: Client, usage_url: String, } @@ -53,19 +51,6 @@ pub struct XKiroProvider { impl XKiroProvider { pub fn new() -> Self { Self { - metadata: ProviderMetadata { - id: ProviderId::XKiro, - display_name: "xKiro", - session_label: "Daily free tokens", - weekly_label: "Weekly", - supports_opus: false, - supports_credits: false, - default_enabled: false, - is_primary: false, - dashboard_url: Some(DASHBOARD_URL), - status_page_url: None, - tertiary_label_key: None, - }, client: crate::core::credentialed_http_client_builder() .timeout(std::time::Duration::from_secs(REQUEST_TIMEOUT_SECS)) .build() @@ -122,10 +107,6 @@ impl Provider for XKiroProvider { ProviderId::XKiro } - fn metadata(&self) -> &ProviderMetadata { - &self.metadata - } - async fn fetch_usage(&self, ctx: &FetchContext) -> Result { match ctx.source_mode { SourceMode::Auto | SourceMode::OAuth => { diff --git a/rust/src/providers/zai/mod.rs b/rust/src/providers/zai/mod.rs index 6d423d3559d..740f61e7375 100755 --- a/rust/src/providers/zai/mod.rs +++ b/rust/src/providers/zai/mod.rs @@ -27,7 +27,7 @@ use serde::Deserialize; use crate::core::{ FetchContext, Provider, ProviderDisplayDetail, ProviderError, ProviderFetchResult, ProviderId, - ProviderMetadata, RateWindow, SourceMode, UsageSnapshot, + RateWindow, SourceMode, UsageSnapshot, }; use reset_plausibility::is_plausible_five_hour_reset; @@ -195,9 +195,7 @@ fn unavailable_quota_detail(has_token_limits: bool) -> Option Self { - Self { - metadata: ProviderMetadata { - id: ProviderId::Zai, - display_name: "z.ai", - session_label: "Tokens", - weekly_label: "MCP", - supports_opus: false, - supports_credits: true, - default_enabled: false, - is_primary: false, - dashboard_url: Some("https://z.ai/manage-apikey/coding-plan/personal/my-plan"), - status_page_url: None, - tertiary_label_key: None, - }, - } + Self {} } /// Effective API region (upstream 0.48.0): an explicit settings value @@ -678,10 +662,6 @@ impl Provider for ZaiProvider { ProviderId::Zai } - fn metadata(&self) -> &ProviderMetadata { - &self.metadata - } - async fn fetch_usage(&self, ctx: &FetchContext) -> Result { tracing::debug!("Fetching z.ai usage"); diff --git a/rust/src/providers/zed/mod.rs b/rust/src/providers/zed/mod.rs index 9416a7eacc3..e8a6dcb7a81 100644 --- a/rust/src/providers/zed/mod.rs +++ b/rust/src/providers/zed/mod.rs @@ -7,7 +7,7 @@ use reqwest::{Client, StatusCode}; use crate::core::{ FetchContext, ManualEmptyCookiePolicy, Provider, ProviderError, ProviderFetchResult, - ProviderId, ProviderMetadata, ProviderStateKind, SourceMode, + ProviderId, ProviderStateKind, SourceMode, }; use crate::providers::{BoundedBodyError, read_bounded_response}; @@ -24,7 +24,6 @@ const COOKIES_DISABLED: &str = "Enable Zed browser cookies or paste a Cookie header to read token spend."; pub struct ZedProvider { - metadata: ProviderMetadata, client: Client, billing_url: String, } @@ -42,19 +41,6 @@ impl ZedProvider { fn with_client(billing_url: impl Into, client: Client) -> Self { Self { - metadata: ProviderMetadata { - id: ProviderId::Zed, - display_name: "Zed", - session_label: "Edits", - weekly_label: "Cycle", - supports_opus: false, - supports_credits: true, - default_enabled: false, - is_primary: false, - dashboard_url: Some("https://zed.dev/account"), - status_page_url: None, - tertiary_label_key: None, - }, client, billing_url: billing_url.into(), } @@ -100,10 +86,6 @@ impl Provider for ZedProvider { ProviderId::Zed } - fn metadata(&self) -> &ProviderMetadata { - &self.metadata - } - async fn fetch_usage(&self, ctx: &FetchContext) -> Result { match ctx.source_mode { // The editor credential is the default lane. Browser billing is diff --git a/rust/src/providers/zenmux/mod.rs b/rust/src/providers/zenmux/mod.rs index 575812a50f9..e831e435653 100644 --- a/rust/src/providers/zenmux/mod.rs +++ b/rust/src/providers/zenmux/mod.rs @@ -11,8 +11,7 @@ use serde::Deserialize; use crate::core::{ CostSnapshot, FetchContext, Provider, ProviderDisplayDetail, ProviderError, - ProviderFetchResult, ProviderId, ProviderMetadata, RateWindow, SourceMode, - SubscriptionMetadata, UsageSnapshot, + ProviderFetchResult, ProviderId, RateWindow, SourceMode, SubscriptionMetadata, UsageSnapshot, }; const MANAGEMENT_BASE: &str = "https://zenmux.ai/api/v1/management"; @@ -67,26 +66,12 @@ struct BalanceData { } pub struct ZenMuxProvider { - metadata: ProviderMetadata, client: Client, } impl ZenMuxProvider { pub fn new() -> Self { Self { - metadata: ProviderMetadata { - id: ProviderId::ZenMux, - display_name: "ZenMux", - session_label: "5-hour quota", - weekly_label: "Weekly quota", - supports_opus: false, - supports_credits: false, - default_enabled: false, - is_primary: false, - dashboard_url: Some("https://zenmux.ai/platform/management"), - status_page_url: None, - tertiary_label_key: None, - }, client: crate::core::credentialed_http_client_builder() .timeout(std::time::Duration::from_secs(15)) .build() @@ -134,10 +119,6 @@ impl Provider for ZenMuxProvider { ProviderId::ZenMux } - fn metadata(&self) -> &ProviderMetadata { - &self.metadata - } - async fn fetch_usage(&self, ctx: &FetchContext) -> Result { match ctx.source_mode { SourceMode::Auto | SourceMode::OAuth => { diff --git a/rust/src/providers/zoommate/mod.rs b/rust/src/providers/zoommate/mod.rs index a3c10f08ec3..ca1c5d286bc 100644 --- a/rust/src/providers/zoommate/mod.rs +++ b/rust/src/providers/zoommate/mod.rs @@ -20,8 +20,8 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; use crate::browser::cookies::Cookie; use crate::core::curl_capture; use crate::core::{ - FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, ProviderMetadata, - RateWindow, SourceMode, UsageSnapshot, + FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, RateWindow, SourceMode, + UsageSnapshot, }; use crate::providers::browser_cookies_for_domain; @@ -170,26 +170,12 @@ fn cache_invalidate(key: &str) { // ── Provider ───────────────────────────────────────────────────────────────── pub struct ZoomMateProvider { - metadata: ProviderMetadata, client: Client, } impl ZoomMateProvider { pub fn new() -> Self { Self { - metadata: ProviderMetadata { - id: ProviderId::ZoomMate, - display_name: "ZoomMate", - session_label: "Credits", - weekly_label: "Credits", - supports_opus: false, - supports_credits: true, - default_enabled: false, - is_primary: false, - dashboard_url: Some("https://zoommate.zoom.us/#/?settings=credit-usage"), - status_page_url: Some("https://www.zoomstatus.com/"), - tertiary_label_key: None, - }, client: crate::core::credentialed_http_client_builder() .timeout(Duration::from_secs(15)) .build() @@ -466,10 +452,6 @@ impl Provider for ZoomMateProvider { ProviderId::ZoomMate } - fn metadata(&self) -> &ProviderMetadata { - &self.metadata - } - async fn fetch_usage(&self, ctx: &FetchContext) -> Result { match ctx.source_mode { SourceMode::Auto | SourceMode::Web => { From ee5f639c82e993365f0c16bfd027ecac6bca796b Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:20:44 +0700 Subject: [PATCH 03/40] Derive Default for stateless provider structs --- rust/src/providers/alibaba/mod.rs | 11 +++-------- rust/src/providers/alibabatokenplan/mod.rs | 11 +++-------- rust/src/providers/antigravity/mod.rs | 11 +++-------- rust/src/providers/augment/mod.rs | 11 +++-------- rust/src/providers/azureopenai.rs | 11 +++-------- rust/src/providers/coderabbit/mod.rs | 11 +++-------- rust/src/providers/factory/mod.rs | 11 +++-------- rust/src/providers/gitkraken/mod.rs | 11 +++-------- rust/src/providers/jetbrains/mod.rs | 11 +++-------- rust/src/providers/kimi/mod.rs | 11 +++-------- rust/src/providers/kimik2/mod.rs | 11 +++-------- rust/src/providers/kiro/mod.rs | 11 +++-------- rust/src/providers/minimax/mod.rs | 11 +++-------- rust/src/providers/notion/mod.rs | 11 +++-------- rust/src/providers/ollama/mod.rs | 11 +++-------- rust/src/providers/openrouter/mod.rs | 11 +++-------- rust/src/providers/pi.rs | 11 +++-------- rust/src/providers/qwencloud/mod.rs | 11 +++-------- rust/src/providers/t3chat.rs | 11 +++-------- rust/src/providers/vertexai/mod.rs | 11 +++-------- rust/src/providers/warp/mod.rs | 11 +++-------- rust/src/providers/windsurf/mod.rs | 11 +++-------- rust/src/providers/zai/mod.rs | 11 +++-------- 23 files changed, 69 insertions(+), 184 deletions(-) diff --git a/rust/src/providers/alibaba/mod.rs b/rust/src/providers/alibaba/mod.rs index 1f0c6fdd9c9..9d93e166ad4 100644 --- a/rust/src/providers/alibaba/mod.rs +++ b/rust/src/providers/alibaba/mod.rs @@ -27,11 +27,12 @@ use self::sec_token::{ const USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) \ AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36"; -pub struct AlibabaProvider {} +#[derive(Default)] +pub struct AlibabaProvider; impl AlibabaProvider { pub fn new() -> Self { - Self {} + Self } /// Resolve the [`AlibabaRegion`] from a settings value. @@ -236,12 +237,6 @@ impl AlibabaProvider { } } -impl Default for AlibabaProvider { - fn default() -> Self { - Self::new() - } -} - #[async_trait] impl Provider for AlibabaProvider { fn id(&self) -> ProviderId { diff --git a/rust/src/providers/alibabatokenplan/mod.rs b/rust/src/providers/alibabatokenplan/mod.rs index a3fac1e14bc..d8c813db69b 100644 --- a/rust/src/providers/alibabatokenplan/mod.rs +++ b/rust/src/providers/alibabatokenplan/mod.rs @@ -42,7 +42,8 @@ const WEEKLY_MINUTES: u32 = 7 * 24 * 60; const LEGACY_MINUTES: u32 = 30 * 24 * 60; const MONTHLY_MINUTES: u32 = 30 * 24 * 60; -pub struct AlibabaTokenPlanProvider {} +#[derive(Default)] +pub struct AlibabaTokenPlanProvider; #[derive(Debug, Clone, PartialEq)] pub(super) struct TokenPlanSnapshot { @@ -64,7 +65,7 @@ pub(super) struct TokenPlanSnapshot { impl AlibabaTokenPlanProvider { pub fn new() -> Self { - Self {} + Self } fn resolve_region(ctx: &FetchContext) -> Region { @@ -368,12 +369,6 @@ impl AlibabaTokenPlanProvider { } } -impl Default for AlibabaTokenPlanProvider { - fn default() -> Self { - Self::new() - } -} - #[async_trait] impl Provider for AlibabaTokenPlanProvider { fn id(&self) -> ProviderId { diff --git a/rust/src/providers/antigravity/mod.rs b/rust/src/providers/antigravity/mod.rs index 50041ffa5d5..e0a9546e625 100755 --- a/rust/src/providers/antigravity/mod.rs +++ b/rust/src/providers/antigravity/mod.rs @@ -72,7 +72,8 @@ impl From for ProviderError { } /// Antigravity provider -pub struct AntigravityProvider {} +#[derive(Default)] +pub struct AntigravityProvider; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum AntigravityStrategyId { @@ -140,7 +141,7 @@ fn is_agy_cli_command(command_line: &str) -> bool { impl AntigravityProvider { pub fn new() -> Self { - Self {} + Self } /// Detect running Antigravity language server and extract connection info @@ -867,12 +868,6 @@ impl AntigravityProvider { } } -impl Default for AntigravityProvider { - fn default() -> Self { - Self::new() - } -} - #[async_trait] impl Provider for AntigravityProvider { fn automatic_metric_prioritizes_exhausted_window(&self) -> bool { diff --git a/rust/src/providers/augment/mod.rs b/rust/src/providers/augment/mod.rs index 05aa6dfcab5..98a783b4928 100755 --- a/rust/src/providers/augment/mod.rs +++ b/rust/src/providers/augment/mod.rs @@ -26,11 +26,12 @@ use crate::core::{ }; /// Augment provider -pub struct AugmentProvider {} +#[derive(Default)] +pub struct AugmentProvider; impl AugmentProvider { pub fn new() -> Self { - Self {} + Self } /// Get Augment config directory @@ -260,12 +261,6 @@ impl AugmentProvider { } } -impl Default for AugmentProvider { - fn default() -> Self { - Self::new() - } -} - #[async_trait] impl Provider for AugmentProvider { fn id(&self) -> ProviderId { diff --git a/rust/src/providers/azureopenai.rs b/rust/src/providers/azureopenai.rs index f7fc2e3513c..cbe5c4e5a2b 100644 --- a/rust/src/providers/azureopenai.rs +++ b/rust/src/providers/azureopenai.rs @@ -14,7 +14,8 @@ use crate::settings::ApiKeys; const DEFAULT_API_VERSION: &str = "2024-10-21"; -pub struct AzureOpenAIProvider {} +#[derive(Default)] +pub struct AzureOpenAIProvider; #[derive(Debug, Clone)] struct AzureOpenAIConfig { @@ -31,7 +32,7 @@ struct ChatCompletionResponse { impl AzureOpenAIProvider { pub fn new() -> Self { - Self {} + Self } async fn fetch_via_api(&self, ctx: &FetchContext) -> Result { @@ -303,12 +304,6 @@ fn clean_string(value: &str) -> Option { (!value.is_empty()).then_some(value) } -impl Default for AzureOpenAIProvider { - fn default() -> Self { - Self::new() - } -} - #[async_trait] impl Provider for AzureOpenAIProvider { fn id(&self) -> ProviderId { diff --git a/rust/src/providers/coderabbit/mod.rs b/rust/src/providers/coderabbit/mod.rs index ccc75730e9d..54fbba5012e 100644 --- a/rust/src/providers/coderabbit/mod.rs +++ b/rust/src/providers/coderabbit/mod.rs @@ -48,17 +48,12 @@ struct CliOutput { stderr: Vec, } -pub struct CodeRabbitProvider {} +#[derive(Default)] +pub struct CodeRabbitProvider; impl CodeRabbitProvider { pub fn new() -> Self { - Self {} - } -} - -impl Default for CodeRabbitProvider { - fn default() -> Self { - Self::new() + Self } } diff --git a/rust/src/providers/factory/mod.rs b/rust/src/providers/factory/mod.rs index b3065c09452..42ad842c5aa 100755 --- a/rust/src/providers/factory/mod.rs +++ b/rust/src/providers/factory/mod.rs @@ -370,11 +370,12 @@ pub(crate) fn factory_api_error_is_recoverable(error: &ProviderError) -> bool { // ── Provider ───────────────────────────────────────────────────────── /// Droid (Factory) provider -pub struct FactoryProvider {} +#[derive(Default)] +pub struct FactoryProvider; impl FactoryProvider { pub fn new() -> Self { - Self {} + Self } fn get_cookies(&self, ctx: &FetchContext) -> Result { @@ -727,12 +728,6 @@ fn snapshot_from_billing_limits( usage.with_login_method(login) } -impl Default for FactoryProvider { - fn default() -> Self { - Self::new() - } -} - #[async_trait] impl Provider for FactoryProvider { fn id(&self) -> ProviderId { diff --git a/rust/src/providers/gitkraken/mod.rs b/rust/src/providers/gitkraken/mod.rs index 452834c3d5b..f2c170eec40 100644 --- a/rust/src/providers/gitkraken/mod.rs +++ b/rust/src/providers/gitkraken/mod.rs @@ -39,11 +39,12 @@ struct GitKrakenUsage { } /// GitKraken AI provider. -pub struct GitKrakenProvider {} +#[derive(Default)] +pub struct GitKrakenProvider; impl GitKrakenProvider { pub fn new() -> Self { - Self {} + Self } async fn fetch(&self, ctx: &FetchContext) -> Result { @@ -100,12 +101,6 @@ impl GitKrakenProvider { } } -impl Default for GitKrakenProvider { - fn default() -> Self { - Self::new() - } -} - #[async_trait] impl Provider for GitKrakenProvider { fn id(&self) -> ProviderId { diff --git a/rust/src/providers/jetbrains/mod.rs b/rust/src/providers/jetbrains/mod.rs index 4ab041cade9..ca9358df748 100755 --- a/rust/src/providers/jetbrains/mod.rs +++ b/rust/src/providers/jetbrains/mod.rs @@ -17,11 +17,12 @@ use crate::core::{ }; /// JetBrains AI provider -pub struct JetBrainsProvider {} +#[derive(Default)] +pub struct JetBrainsProvider; impl JetBrainsProvider { pub fn new() -> Self { - Self {} + Self } /// Get JetBrains config directory @@ -204,12 +205,6 @@ impl JetBrainsQuota { } } -impl Default for JetBrainsProvider { - fn default() -> Self { - Self::new() - } -} - #[async_trait] impl Provider for JetBrainsProvider { fn id(&self) -> ProviderId { diff --git a/rust/src/providers/kimi/mod.rs b/rust/src/providers/kimi/mod.rs index e30c27f4e84..0336e9abcc5 100755 --- a/rust/src/providers/kimi/mod.rs +++ b/rust/src/providers/kimi/mod.rs @@ -243,11 +243,12 @@ struct KimiWindow { } /// Kimi AI provider -pub struct KimiProvider {} +#[derive(Default)] +pub struct KimiProvider; impl KimiProvider { pub fn new() -> Self { - Self {} + Self } fn auth_token_from_cookie_headers( @@ -319,12 +320,6 @@ impl KimiProvider { } } -impl Default for KimiProvider { - fn default() -> Self { - Self::new() - } -} - #[async_trait] impl Provider for KimiProvider { fn id(&self) -> ProviderId { diff --git a/rust/src/providers/kimik2/mod.rs b/rust/src/providers/kimik2/mod.rs index 2a117522d02..82789f5b630 100755 --- a/rust/src/providers/kimik2/mod.rs +++ b/rust/src/providers/kimik2/mod.rs @@ -141,11 +141,12 @@ fn api_key_for_region(env: &HashMap, region: MoonshotRegion) -> } /// Kimi K2 provider (API-based credits) -pub struct KimiK2Provider {} +#[derive(Default)] +pub struct KimiK2Provider; impl KimiK2Provider { pub fn new() -> Self { - Self {} + Self } /// Region-bound API key resolution (upstream 0.48.0 #2621): @@ -365,12 +366,6 @@ impl KimiK2Provider { } } -impl Default for KimiK2Provider { - fn default() -> Self { - Self::new() - } -} - #[async_trait] impl Provider for KimiK2Provider { fn id(&self) -> ProviderId { diff --git a/rust/src/providers/kiro/mod.rs b/rust/src/providers/kiro/mod.rs index 8130aa05f9d..ed137550fea 100755 --- a/rust/src/providers/kiro/mod.rs +++ b/rust/src/providers/kiro/mod.rs @@ -30,7 +30,8 @@ use crate::core::{ }; /// Kiro provider (AWS AI assistant) -pub struct KiroProvider {} +#[derive(Default)] +pub struct KiroProvider; struct KiroCliUsage { plan_name: String, matched_new_format: bool, @@ -48,7 +49,7 @@ struct KiroCliUsage { impl KiroProvider { pub fn new() -> Self { - Self {} + Self } /// Get Kiro config directory @@ -438,12 +439,6 @@ impl KiroProvider { } } -impl Default for KiroProvider { - fn default() -> Self { - Self::new() - } -} - #[async_trait] impl Provider for KiroProvider { fn id(&self) -> ProviderId { diff --git a/rust/src/providers/minimax/mod.rs b/rust/src/providers/minimax/mod.rs index 9707055d570..bed99700f44 100755 --- a/rust/src/providers/minimax/mod.rs +++ b/rust/src/providers/minimax/mod.rs @@ -176,11 +176,12 @@ impl MiniMaxRegion { } /// MiniMax provider -pub struct MiniMaxProvider {} +#[derive(Default)] +pub struct MiniMaxProvider; impl MiniMaxProvider { pub fn new() -> Self { - Self {} + Self } pub fn region_from_settings(value: Option<&str>) -> MiniMaxRegion { @@ -992,12 +993,6 @@ fn format_count(value: i64) -> String { out.chars().rev().collect() } -impl Default for MiniMaxProvider { - fn default() -> Self { - Self::new() - } -} - #[async_trait] impl Provider for MiniMaxProvider { fn automatic_metric_prioritizes_exhausted_window(&self) -> bool { diff --git a/rust/src/providers/notion/mod.rs b/rust/src/providers/notion/mod.rs index c2906f57e01..00004b3f92e 100644 --- a/rust/src/providers/notion/mod.rs +++ b/rust/src/providers/notion/mod.rs @@ -40,7 +40,8 @@ const COOKIE_DOMAINS: &[&str] = &[ /// with the real cycle length ending at `resets_at` (upstream `ProviderPaceCapability`). const MONTHLY_WINDOW_SENTINEL_MINUTES: u32 = 30 * 24 * 60; -pub struct NotionProvider {} +#[derive(Default)] +pub struct NotionProvider; #[derive(Debug, Clone)] struct NotionWorkspace { @@ -128,7 +129,7 @@ impl CreditRateLimitStatus { impl NotionProvider { pub fn new() -> Self { - Self {} + Self } fn resolve_cookie_header(ctx: &FetchContext) -> Result { @@ -233,12 +234,6 @@ impl NotionProvider { } } -impl Default for NotionProvider { - fn default() -> Self { - Self::new() - } -} - #[async_trait] impl Provider for NotionProvider { fn id(&self) -> ProviderId { diff --git a/rust/src/providers/ollama/mod.rs b/rust/src/providers/ollama/mod.rs index 65c15ae87a6..bce597e2b44 100755 --- a/rust/src/providers/ollama/mod.rs +++ b/rust/src/providers/ollama/mod.rs @@ -27,7 +27,8 @@ const OLLAMA_MONTHLY_WINDOW_MINUTES: u32 = 30 * 24 * 60; const OLLAMA_MONTHLY_USAGE_LABEL: &str = "Monthly usage"; /// Ollama provider -pub struct OllamaProvider {} +#[derive(Default)] +pub struct OllamaProvider; #[derive(Debug, Clone, PartialEq)] struct UsageBlock { @@ -39,7 +40,7 @@ struct UsageBlock { impl OllamaProvider { pub fn new() -> Self { - Self {} + Self } /// Fetch usage by scraping ollama.com/settings @@ -344,12 +345,6 @@ impl OllamaProvider { } } -impl Default for OllamaProvider { - fn default() -> Self { - Self::new() - } -} - #[async_trait] impl Provider for OllamaProvider { fn id(&self) -> ProviderId { diff --git a/rust/src/providers/openrouter/mod.rs b/rust/src/providers/openrouter/mod.rs index 89fbbbba228..fd37ab1e570 100755 --- a/rust/src/providers/openrouter/mod.rs +++ b/rust/src/providers/openrouter/mod.rs @@ -140,7 +140,8 @@ impl KeyData { } /// OpenRouter provider -pub struct OpenRouterProvider {} +#[derive(Default)] +pub struct OpenRouterProvider; /// Usage value for quota math when the server does not report remaining: the /// field matching the declared reset window when known, otherwise cumulative @@ -162,7 +163,7 @@ fn quota_fallback_usage(key_data: &KeyData) -> Option { impl OpenRouterProvider { pub fn new() -> Self { - Self {} + Self } /// Get API token from ctx, Windows Credential Manager, or env @@ -544,12 +545,6 @@ impl OpenRouterProvider { } } -impl Default for OpenRouterProvider { - fn default() -> Self { - Self::new() - } -} - #[async_trait] impl Provider for OpenRouterProvider { fn id(&self) -> ProviderId { diff --git a/rust/src/providers/pi.rs b/rust/src/providers/pi.rs index 6a3ea52fb9d..960e8c439e5 100644 --- a/rust/src/providers/pi.rs +++ b/rust/src/providers/pi.rs @@ -12,17 +12,12 @@ use crate::core::{ UsageSnapshot, }; -pub struct PiProvider {} +#[derive(Default)] +pub struct PiProvider; impl PiProvider { pub fn new() -> Self { - Self {} - } -} - -impl Default for PiProvider { - fn default() -> Self { - Self::new() + Self } } diff --git a/rust/src/providers/qwencloud/mod.rs b/rust/src/providers/qwencloud/mod.rs index d392a64f0ce..9d2f7b24bb6 100644 --- a/rust/src/providers/qwencloud/mod.rs +++ b/rust/src/providers/qwencloud/mod.rs @@ -52,7 +52,8 @@ const WEEKLY_MINUTES: u32 = 7 * 24 * 60; const LEGACY_MINUTES: u32 = 30 * 24 * 60; const MONTHLY_MINUTES: u32 = 30 * 24 * 60; -pub struct QwenCloudProvider {} +#[derive(Default)] +pub struct QwenCloudProvider; #[derive(Debug, Clone, PartialEq)] struct QwenCloudSnapshot { @@ -89,7 +90,7 @@ const USAGE_WINDOW_KEYS: &[&str] = &[ impl QwenCloudProvider { pub fn new() -> Self { - Self {} + Self } async fn fetch_via_web(&self, ctx: &FetchContext) -> Result { @@ -410,12 +411,6 @@ impl QwenCloudProvider { } } -impl Default for QwenCloudProvider { - fn default() -> Self { - Self::new() - } -} - #[async_trait] impl Provider for QwenCloudProvider { fn id(&self) -> ProviderId { diff --git a/rust/src/providers/t3chat.rs b/rust/src/providers/t3chat.rs index 86ba3490090..0a8ddc28390 100644 --- a/rust/src/providers/t3chat.rs +++ b/rust/src/providers/t3chat.rs @@ -20,7 +20,8 @@ const CUSTOMER_DATA_INPUT: &str = r#"{"0":{"json":{"sessionId":null},"meta":{"values":{"sessionId":["undefined"]}}}}"#; const COOKIE_DOMAINS: [&str; 2] = ["t3.chat", "www.t3.chat"]; -pub struct T3ChatProvider {} +#[derive(Default)] +pub struct T3ChatProvider; #[derive(Debug, Clone)] struct T3RequestContext { @@ -50,7 +51,7 @@ struct T3Subscription { impl T3ChatProvider { pub fn new() -> Self { - Self {} + Self } async fn fetch_via_web(&self, ctx: &FetchContext) -> Result { @@ -353,12 +354,6 @@ fn plan_name(customer: &T3CustomerData) -> Option { ) } -impl Default for T3ChatProvider { - fn default() -> Self { - Self::new() - } -} - #[async_trait] impl Provider for T3ChatProvider { fn id(&self) -> ProviderId { diff --git a/rust/src/providers/vertexai/mod.rs b/rust/src/providers/vertexai/mod.rs index 81404ea68e0..45466905a6d 100755 --- a/rust/src/providers/vertexai/mod.rs +++ b/rust/src/providers/vertexai/mod.rs @@ -21,11 +21,12 @@ use crate::core::{ }; /// Vertex AI provider -pub struct VertexAIProvider {} +#[derive(Default)] +pub struct VertexAIProvider; impl VertexAIProvider { pub fn new() -> Self { - Self {} + Self } /// Get Google Cloud credentials path @@ -293,12 +294,6 @@ fn parse_access_token_response(body: &[u8]) -> Result { .ok_or_else(|| ProviderError::Parse("No access_token in response".to_string())) } -impl Default for VertexAIProvider { - fn default() -> Self { - Self::new() - } -} - #[async_trait] impl Provider for VertexAIProvider { fn id(&self) -> ProviderId { diff --git a/rust/src/providers/warp/mod.rs b/rust/src/providers/warp/mod.rs index 9264af82f1f..a1bc1d643d9 100755 --- a/rust/src/providers/warp/mod.rs +++ b/rust/src/providers/warp/mod.rs @@ -116,11 +116,12 @@ struct BonusGrantsInfo { } /// Warp provider -pub struct WarpProvider {} +#[derive(Default)] +pub struct WarpProvider; impl WarpProvider { pub fn new() -> Self { - Self {} + Self } /// Get API token from ctx, Windows Credential Manager, or env @@ -378,12 +379,6 @@ impl WarpProvider { } } -impl Default for WarpProvider { - fn default() -> Self { - Self::new() - } -} - #[async_trait] impl Provider for WarpProvider { fn id(&self) -> ProviderId { diff --git a/rust/src/providers/windsurf/mod.rs b/rust/src/providers/windsurf/mod.rs index 60140561e8d..6852cc75098 100644 --- a/rust/src/providers/windsurf/mod.rs +++ b/rust/src/providers/windsurf/mod.rs @@ -49,11 +49,12 @@ struct QuotaUsage { weekly_reset_at_unix: Option, } -pub struct WindsurfProvider {} +#[derive(Default)] +pub struct WindsurfProvider; impl WindsurfProvider { pub fn new() -> Self { - Self {} + Self } fn default_db_path() -> Option { @@ -172,12 +173,6 @@ impl WindsurfProvider { } } -impl Default for WindsurfProvider { - fn default() -> Self { - Self::new() - } -} - #[async_trait] impl Provider for WindsurfProvider { fn id(&self) -> ProviderId { diff --git a/rust/src/providers/zai/mod.rs b/rust/src/providers/zai/mod.rs index 740f61e7375..c113411a520 100755 --- a/rust/src/providers/zai/mod.rs +++ b/rust/src/providers/zai/mod.rs @@ -195,7 +195,8 @@ fn unavailable_quota_detail(has_token_limits: bool) -> Option Self { - Self {} + Self } /// Effective API region (upstream 0.48.0): an explicit settings value @@ -646,12 +647,6 @@ fn authorization_header(token: &str) -> String { } } -impl Default for ZaiProvider { - fn default() -> Self { - Self::new() - } -} - #[async_trait] impl Provider for ZaiProvider { fn automatic_metric_prioritizes_exhausted_window(&self) -> bool { From 109eb0b47a757dcf10c835e2a45d296f4794795a Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:28:39 +0700 Subject: [PATCH 04/40] Derive supports_web and supports_cli from available sources --- rust/src/core/provider/traits.rs | 10 ++++++---- rust/src/providers/abacus/mod.rs | 8 -------- rust/src/providers/alibaba/mod.rs | 8 -------- rust/src/providers/alibabatokenplan/mod.rs | 8 -------- rust/src/providers/antigravity/mod.rs | 4 ---- rust/src/providers/augment/mod.rs | 8 -------- rust/src/providers/azureopenai.rs | 5 +++++ rust/src/providers/claude/mod.rs | 8 -------- rust/src/providers/codebuddy/mod.rs | 5 +++-- rust/src/providers/coderabbit/mod.rs | 4 ---- rust/src/providers/codex/mod.rs | 4 ---- rust/src/providers/commandcode/mod.rs | 4 ---- rust/src/providers/cursor/mod.rs | 4 ---- rust/src/providers/doubao/mod.rs | 5 +++++ rust/src/providers/factory/mod.rs | 4 ---- rust/src/providers/gemini/mod.rs | 4 ---- rust/src/providers/grok/mod.rs | 8 -------- rust/src/providers/hyper/mod.rs | 4 ---- rust/src/providers/infini.rs | 8 -------- rust/src/providers/jetbrains/mod.rs | 8 -------- rust/src/providers/kimi/mod.rs | 8 -------- rust/src/providers/kimik2/mod.rs | 8 -------- rust/src/providers/kiro/mod.rs | 8 -------- rust/src/providers/longcat/mod.rs | 5 +++++ rust/src/providers/manus/mod.rs | 4 ---- rust/src/providers/minimax/mod.rs | 8 -------- rust/src/providers/mistral/mod.rs | 4 ---- rust/src/providers/notion/mod.rs | 8 -------- rust/src/providers/ollama/mod.rs | 8 -------- rust/src/providers/opencode/mod.rs | 8 -------- rust/src/providers/opencodego/mod.rs | 8 -------- rust/src/providers/openrouter/mod.rs | 8 -------- rust/src/providers/perplexity/mod.rs | 8 -------- rust/src/providers/qoder/mod.rs | 4 ---- rust/src/providers/qwencloud/mod.rs | 4 ---- rust/src/providers/raycast/mod.rs | 4 ---- rust/src/providers/replicate/mod.rs | 4 ---- rust/src/providers/sakana/mod.rs | 4 ---- rust/src/providers/t3chat.rs | 4 ---- rust/src/providers/venice/mod.rs | 4 ---- rust/src/providers/vertexai/mod.rs | 8 -------- rust/src/providers/warp/mod.rs | 8 -------- rust/src/providers/windsurf/mod.rs | 4 ---- rust/src/providers/zai/mod.rs | 8 -------- rust/src/providers/zed/mod.rs | 4 ---- rust/src/providers/zoommate/mod.rs | 4 ---- 46 files changed, 24 insertions(+), 254 deletions(-) diff --git a/rust/src/core/provider/traits.rs b/rust/src/core/provider/traits.rs index a8a62bf0275..0b2874de600 100644 --- a/rust/src/core/provider/traits.rs +++ b/rust/src/core/provider/traits.rs @@ -53,14 +53,16 @@ pub trait Provider: Send + Sync { false } - /// Check if web API (cookies) is supported + /// Check if web API (cookies) is supported. Defaults to whether + /// [`Self::available_sources`] lists [`SourceMode::Web`]. fn supports_web(&self) -> bool { - false + self.available_sources().contains(&SourceMode::Web) } - /// Check if CLI probe is supported + /// Check if CLI probe is supported. Defaults to whether + /// [`Self::available_sources`] lists [`SourceMode::Cli`]. fn supports_cli(&self) -> bool { - false + self.available_sources().contains(&SourceMode::Cli) } /// Detect the version of the CLI tool (if applicable) diff --git a/rust/src/providers/abacus/mod.rs b/rust/src/providers/abacus/mod.rs index 16337457df6..4c8924a633d 100644 --- a/rust/src/providers/abacus/mod.rs +++ b/rust/src/providers/abacus/mod.rs @@ -497,12 +497,4 @@ impl Provider for AbacusProvider { fn available_sources(&self) -> Vec { vec![SourceMode::Auto, SourceMode::Web] } - - fn supports_web(&self) -> bool { - true - } - - fn supports_cli(&self) -> bool { - false - } } diff --git a/rust/src/providers/alibaba/mod.rs b/rust/src/providers/alibaba/mod.rs index 9d93e166ad4..87a103b0f31 100644 --- a/rust/src/providers/alibaba/mod.rs +++ b/rust/src/providers/alibaba/mod.rs @@ -258,14 +258,6 @@ impl Provider for AlibabaProvider { fn available_sources(&self) -> Vec { vec![SourceMode::Auto, SourceMode::Web] } - - fn supports_web(&self) -> bool { - true - } - - fn supports_cli(&self) -> bool { - false - } } #[cfg(test)] diff --git a/rust/src/providers/alibabatokenplan/mod.rs b/rust/src/providers/alibabatokenplan/mod.rs index d8c813db69b..7e096fd7e4e 100644 --- a/rust/src/providers/alibabatokenplan/mod.rs +++ b/rust/src/providers/alibabatokenplan/mod.rs @@ -415,14 +415,6 @@ impl Provider for AlibabaTokenPlanProvider { _ => error.state_kind(), } } - - fn supports_web(&self) -> bool { - true - } - - fn supports_cli(&self) -> bool { - true - } } pub(super) fn throw_if_error_payload(value: &Value) -> Result<(), ProviderError> { diff --git a/rust/src/providers/antigravity/mod.rs b/rust/src/providers/antigravity/mod.rs index e0a9546e625..8c6c3fb5776 100755 --- a/rust/src/providers/antigravity/mod.rs +++ b/rust/src/providers/antigravity/mod.rs @@ -898,10 +898,6 @@ impl Provider for AntigravityProvider { vec![SourceMode::Auto, SourceMode::Cli] } - fn supports_cli(&self) -> bool { - true - } - /// Antigravity's `NotInstalled` reports the local language-server probe /// finding nothing to talk to — a runtime that is not running, not a /// credential problem — so it surfaces as an offline runtime. diff --git a/rust/src/providers/augment/mod.rs b/rust/src/providers/augment/mod.rs index 98a783b4928..7562eb9dd78 100755 --- a/rust/src/providers/augment/mod.rs +++ b/rust/src/providers/augment/mod.rs @@ -293,14 +293,6 @@ impl Provider for AugmentProvider { fn available_sources(&self) -> Vec { vec![SourceMode::Auto, SourceMode::Web, SourceMode::Cli] } - - fn supports_web(&self) -> bool { - true - } - - fn supports_cli(&self) -> bool { - true - } /// Augment's CLI probes raise `NotInstalled` when the CLI binary or /// config root is absent ("Augment CLI not found. Install from ...", /// "Augment not found. Install from ...") — an installation gap, not a diff --git a/rust/src/providers/azureopenai.rs b/rust/src/providers/azureopenai.rs index cbe5c4e5a2b..25e5e7d8698 100644 --- a/rust/src/providers/azureopenai.rs +++ b/rust/src/providers/azureopenai.rs @@ -325,6 +325,11 @@ impl Provider for AzureOpenAIProvider { fn available_sources(&self) -> Vec { vec![SourceMode::Auto, SourceMode::Web] } + + /// Stays `false` although `SourceMode::Web` is listed (existing behavior). + fn supports_web(&self) -> bool { + false + } } #[cfg(test)] diff --git a/rust/src/providers/claude/mod.rs b/rust/src/providers/claude/mod.rs index c16242fe7cd..b5fa7abe3df 100755 --- a/rust/src/providers/claude/mod.rs +++ b/rust/src/providers/claude/mod.rs @@ -854,14 +854,6 @@ impl Provider for ClaudeProvider { true } - fn supports_web(&self) -> bool { - true - } - - fn supports_cli(&self) -> bool { - true - } - fn owns_browser_cookie_resolution(&self) -> bool { true } diff --git a/rust/src/providers/codebuddy/mod.rs b/rust/src/providers/codebuddy/mod.rs index b02b4503279..e8a09f0c15e 100644 --- a/rust/src/providers/codebuddy/mod.rs +++ b/rust/src/providers/codebuddy/mod.rs @@ -794,8 +794,9 @@ impl Provider for CodeBuddyProvider { vec![SourceMode::Auto, SourceMode::Web, SourceMode::Cli] } - fn supports_web(&self) -> bool { - true + /// Stays `false` although `SourceMode::Cli` is listed (existing behavior). + fn supports_cli(&self) -> bool { + false } } diff --git a/rust/src/providers/coderabbit/mod.rs b/rust/src/providers/coderabbit/mod.rs index 54fbba5012e..7b6691eda21 100644 --- a/rust/src/providers/coderabbit/mod.rs +++ b/rust/src/providers/coderabbit/mod.rs @@ -101,10 +101,6 @@ impl Provider for CodeRabbitProvider { fn available_sources(&self) -> Vec { vec![SourceMode::Auto, SourceMode::Cli] } - - fn supports_cli(&self) -> bool { - true - } } fn configured_program() -> String { diff --git a/rust/src/providers/codex/mod.rs b/rust/src/providers/codex/mod.rs index 40031543de8..b9b0c3edb06 100755 --- a/rust/src/providers/codex/mod.rs +++ b/rust/src/providers/codex/mod.rs @@ -154,10 +154,6 @@ impl Provider for CodexProvider { true } - fn supports_cli(&self) -> bool { - true - } - fn detect_version(&self) -> Option { detect_codex_version() } diff --git a/rust/src/providers/commandcode/mod.rs b/rust/src/providers/commandcode/mod.rs index 623676d7753..9220e323567 100644 --- a/rust/src/providers/commandcode/mod.rs +++ b/rust/src/providers/commandcode/mod.rs @@ -618,10 +618,6 @@ impl Provider for CommandCodeProvider { fn available_sources(&self) -> Vec { vec![SourceMode::Auto, SourceMode::Web] } - - fn supports_web(&self) -> bool { - true - } } #[cfg(test)] diff --git a/rust/src/providers/cursor/mod.rs b/rust/src/providers/cursor/mod.rs index 5fd1aa5e24e..443b15a52dd 100755 --- a/rust/src/providers/cursor/mod.rs +++ b/rust/src/providers/cursor/mod.rs @@ -294,10 +294,6 @@ impl Provider for CursorProvider { fn available_sources(&self) -> Vec { vec![SourceMode::Auto, SourceMode::Web] } - - fn supports_web(&self) -> bool { - true - } } #[cfg(test)] diff --git a/rust/src/providers/doubao/mod.rs b/rust/src/providers/doubao/mod.rs index 01ffc98234b..84bb2f6ab81 100644 --- a/rust/src/providers/doubao/mod.rs +++ b/rust/src/providers/doubao/mod.rs @@ -943,6 +943,11 @@ impl Provider for DoubaoProvider { vec![SourceMode::Auto, SourceMode::OAuth, SourceMode::Cli] } + /// Stays `false` although `SourceMode::Cli` is listed (existing behavior). + fn supports_cli(&self) -> bool { + false + } + /// Doubao's arkcli probe raises `NotInstalled` when the `arkcli` /// binary is missing from PATH/`ARKCLI_PATH` ("arkcli was not found. /// Install arkcli, ...") — an installation gap, not a credential diff --git a/rust/src/providers/factory/mod.rs b/rust/src/providers/factory/mod.rs index 42ad842c5aa..fdb027f7b9d 100755 --- a/rust/src/providers/factory/mod.rs +++ b/rust/src/providers/factory/mod.rs @@ -754,10 +754,6 @@ impl Provider for FactoryProvider { vec![SourceMode::Auto, SourceMode::OAuth, SourceMode::Web] } - fn supports_web(&self) -> bool { - true - } - fn supports_cli(&self) -> bool { // Shell maps cookie-source "off" to Cli; Factory treats Cli as API-only. true diff --git a/rust/src/providers/gemini/mod.rs b/rust/src/providers/gemini/mod.rs index 67663a85a94..0554652f8c4 100755 --- a/rust/src/providers/gemini/mod.rs +++ b/rust/src/providers/gemini/mod.rs @@ -65,8 +65,4 @@ impl Provider for GeminiProvider { fn available_sources(&self) -> Vec { vec![SourceMode::Auto, SourceMode::Cli] } - - fn supports_cli(&self) -> bool { - true - } } diff --git a/rust/src/providers/grok/mod.rs b/rust/src/providers/grok/mod.rs index c9b77246d30..9cc60a3f18d 100644 --- a/rust/src/providers/grok/mod.rs +++ b/rust/src/providers/grok/mod.rs @@ -591,14 +591,6 @@ impl Provider for GrokProvider { ] } - fn supports_web(&self) -> bool { - true - } - - fn supports_cli(&self) -> bool { - true - } - fn detect_version(&self) -> Option { Self::detect_cli_version() } diff --git a/rust/src/providers/hyper/mod.rs b/rust/src/providers/hyper/mod.rs index 75648bc499f..5a5a61f60e7 100644 --- a/rust/src/providers/hyper/mod.rs +++ b/rust/src/providers/hyper/mod.rs @@ -218,10 +218,6 @@ impl Provider for HyperProvider { vec![SourceMode::Auto, SourceMode::Web, SourceMode::OAuth] } - fn supports_web(&self) -> bool { - true - } - fn cookie_source_scopes_session_only(&self) -> bool { true } diff --git a/rust/src/providers/infini.rs b/rust/src/providers/infini.rs index 3ab97b36b5b..ce6d32cdc20 100644 --- a/rust/src/providers/infini.rs +++ b/rust/src/providers/infini.rs @@ -379,14 +379,6 @@ impl Provider for InfiniProvider { fn available_sources(&self) -> Vec { vec![SourceMode::Auto, SourceMode::Web] } - - fn supports_web(&self) -> bool { - true - } - - fn supports_cli(&self) -> bool { - false - } } impl std::fmt::Display for PlanType { diff --git a/rust/src/providers/jetbrains/mod.rs b/rust/src/providers/jetbrains/mod.rs index ca9358df748..4889cb83724 100755 --- a/rust/src/providers/jetbrains/mod.rs +++ b/rust/src/providers/jetbrains/mod.rs @@ -229,14 +229,6 @@ impl Provider for JetBrainsProvider { fn available_sources(&self) -> Vec { vec![SourceMode::Auto, SourceMode::Cli] } - - fn supports_web(&self) -> bool { - false - } - - fn supports_cli(&self) -> bool { - true - } /// JetBrains' local IDE probe raises `NotInstalled` when the AI /// Assistant plugin is not found in any IDE configuration — an /// installation gap, not a credential problem — so it surfaces as an diff --git a/rust/src/providers/kimi/mod.rs b/rust/src/providers/kimi/mod.rs index 0336e9abcc5..8af68d6ad9d 100755 --- a/rust/src/providers/kimi/mod.rs +++ b/rust/src/providers/kimi/mod.rs @@ -397,14 +397,6 @@ impl Provider for KimiProvider { vec![SourceMode::Auto, SourceMode::Web, SourceMode::OAuth] } - fn supports_web(&self) -> bool { - true - } - - fn supports_cli(&self) -> bool { - false - } - fn supports_oauth(&self) -> bool { true } diff --git a/rust/src/providers/kimik2/mod.rs b/rust/src/providers/kimik2/mod.rs index 82789f5b630..5fb19af7caa 100755 --- a/rust/src/providers/kimik2/mod.rs +++ b/rust/src/providers/kimik2/mod.rs @@ -387,14 +387,6 @@ impl Provider for KimiK2Provider { fn available_sources(&self) -> Vec { vec![SourceMode::Auto, SourceMode::Web] } - - fn supports_web(&self) -> bool { - true - } - - fn supports_cli(&self) -> bool { - false - } } #[cfg(test)] diff --git a/rust/src/providers/kiro/mod.rs b/rust/src/providers/kiro/mod.rs index ed137550fea..6fbdb83ee9e 100755 --- a/rust/src/providers/kiro/mod.rs +++ b/rust/src/providers/kiro/mod.rs @@ -469,14 +469,6 @@ impl Provider for KiroProvider { fn available_sources(&self) -> Vec { vec![SourceMode::Auto, SourceMode::Cli] } - - fn supports_web(&self) -> bool { - false - } - - fn supports_cli(&self) -> bool { - true - } /// Kiro's CLI probes raise `NotInstalled` when the `kiro-cli` binary is /// missing ("kiro-cli not found. Install from https://kiro.dev") — an /// installation gap, not a credential problem — so it surfaces as an diff --git a/rust/src/providers/longcat/mod.rs b/rust/src/providers/longcat/mod.rs index abb111141a1..8e7a224e0a6 100644 --- a/rust/src/providers/longcat/mod.rs +++ b/rust/src/providers/longcat/mod.rs @@ -150,6 +150,11 @@ impl Provider for LongCatProvider { fn available_sources(&self) -> Vec { vec![SourceMode::Auto, SourceMode::Web] } + + /// Stays `false` although `SourceMode::Web` is listed (existing behavior). + fn supports_web(&self) -> bool { + false + } } fn normalize_cookie_header(raw: &str) -> Option { diff --git a/rust/src/providers/manus/mod.rs b/rust/src/providers/manus/mod.rs index 6fafdf049a5..bf4d50b9750 100644 --- a/rust/src/providers/manus/mod.rs +++ b/rust/src/providers/manus/mod.rs @@ -415,10 +415,6 @@ impl Provider for ManusProvider { vec![SourceMode::Auto, SourceMode::Web] } - fn supports_web(&self) -> bool { - true - } - /// The provider iterates every browser session itself, so the shell must /// not pre-resolve a single merged header for it. fn owns_browser_cookie_resolution(&self) -> bool { diff --git a/rust/src/providers/minimax/mod.rs b/rust/src/providers/minimax/mod.rs index bed99700f44..77c3d462fa4 100755 --- a/rust/src/providers/minimax/mod.rs +++ b/rust/src/providers/minimax/mod.rs @@ -1044,14 +1044,6 @@ impl Provider for MiniMaxProvider { fn available_sources(&self) -> Vec { vec![SourceMode::Auto, SourceMode::Web, SourceMode::Cli] } - - fn supports_web(&self) -> bool { - true - } - - fn supports_cli(&self) -> bool { - true - } } #[cfg(test)] diff --git a/rust/src/providers/mistral/mod.rs b/rust/src/providers/mistral/mod.rs index 9be117bf3e7..c0e40d531a0 100644 --- a/rust/src/providers/mistral/mod.rs +++ b/rust/src/providers/mistral/mod.rs @@ -542,10 +542,6 @@ impl Provider for MistralProvider { vec![SourceMode::Auto, SourceMode::Web] } - fn supports_web(&self) -> bool { - true - } - fn monthly_plan_window_id(&self) -> Option<&'static str> { Some(MONTHLY_PLAN_WINDOW_ID) } diff --git a/rust/src/providers/notion/mod.rs b/rust/src/providers/notion/mod.rs index 00004b3f92e..5fae3438437 100644 --- a/rust/src/providers/notion/mod.rs +++ b/rust/src/providers/notion/mod.rs @@ -256,14 +256,6 @@ impl Provider for NotionProvider { fn available_sources(&self) -> Vec { vec![SourceMode::Auto, SourceMode::Web] } - - fn supports_web(&self) -> bool { - true - } - - fn supports_cli(&self) -> bool { - false - } } fn build_usage_snapshot( diff --git a/rust/src/providers/ollama/mod.rs b/rust/src/providers/ollama/mod.rs index bce597e2b44..b912869692e 100755 --- a/rust/src/providers/ollama/mod.rs +++ b/rust/src/providers/ollama/mod.rs @@ -380,14 +380,6 @@ impl Provider for OllamaProvider { vec![SourceMode::Auto, SourceMode::Web] } - fn supports_web(&self) -> bool { - true - } - - fn supports_cli(&self) -> bool { - false - } - fn retains_last_good_on_transport_failure(&self) -> bool { true } diff --git a/rust/src/providers/opencode/mod.rs b/rust/src/providers/opencode/mod.rs index ce5f63b97e1..faac59aa295 100755 --- a/rust/src/providers/opencode/mod.rs +++ b/rust/src/providers/opencode/mod.rs @@ -613,14 +613,6 @@ impl Provider for OpenCodeProvider { fn available_sources(&self) -> Vec { vec![SourceMode::Auto, SourceMode::Web] } - - fn supports_web(&self) -> bool { - true - } - - fn supports_cli(&self) -> bool { - false - } } #[cfg(test)] diff --git a/rust/src/providers/opencodego/mod.rs b/rust/src/providers/opencodego/mod.rs index b81bc05a0ad..531d64c02d6 100644 --- a/rust/src/providers/opencodego/mod.rs +++ b/rust/src/providers/opencodego/mod.rs @@ -520,14 +520,6 @@ impl Provider for OpenCodeGoProvider { fn available_sources(&self) -> Vec { vec![SourceMode::Auto, SourceMode::Web, SourceMode::Cli] } - - fn supports_web(&self) -> bool { - true - } - - fn supports_cli(&self) -> bool { - true - } } /// Keep a selected API-key account on its own identity. Explicit web and local diff --git a/rust/src/providers/openrouter/mod.rs b/rust/src/providers/openrouter/mod.rs index fd37ab1e570..8f0bf132bd5 100755 --- a/rust/src/providers/openrouter/mod.rs +++ b/rust/src/providers/openrouter/mod.rs @@ -565,12 +565,4 @@ impl Provider for OpenRouterProvider { fn available_sources(&self) -> Vec { vec![SourceMode::Auto, SourceMode::OAuth] } - - fn supports_web(&self) -> bool { - false - } - - fn supports_cli(&self) -> bool { - false - } } diff --git a/rust/src/providers/perplexity/mod.rs b/rust/src/providers/perplexity/mod.rs index 6b6a1580ad1..51500775942 100644 --- a/rust/src/providers/perplexity/mod.rs +++ b/rust/src/providers/perplexity/mod.rs @@ -363,14 +363,6 @@ impl Provider for PerplexityProvider { vec![SourceMode::Auto, SourceMode::Web] } - fn supports_web(&self) -> bool { - true - } - - fn supports_cli(&self) -> bool { - false - } - /// Browser sessions are tried one by one inside the provider, so the shell /// must not pre-merge a single browser cookie header for Auto. fn owns_browser_cookie_resolution(&self) -> bool { diff --git a/rust/src/providers/qoder/mod.rs b/rust/src/providers/qoder/mod.rs index 1c4fe884f39..e9dd2b26179 100644 --- a/rust/src/providers/qoder/mod.rs +++ b/rust/src/providers/qoder/mod.rs @@ -507,10 +507,6 @@ impl Provider for QoderProvider { fn available_sources(&self) -> Vec { vec![SourceMode::Auto, SourceMode::Web] } - - fn supports_web(&self) -> bool { - true - } } #[cfg(test)] diff --git a/rust/src/providers/qwencloud/mod.rs b/rust/src/providers/qwencloud/mod.rs index 9d2f7b24bb6..8f3b52e39bd 100644 --- a/rust/src/providers/qwencloud/mod.rs +++ b/rust/src/providers/qwencloud/mod.rs @@ -432,10 +432,6 @@ impl Provider for QwenCloudProvider { fn available_sources(&self) -> Vec { vec![SourceMode::Auto, SourceMode::Web] } - - fn supports_web(&self) -> bool { - true - } } fn api_url(api: &str) -> String { diff --git a/rust/src/providers/raycast/mod.rs b/rust/src/providers/raycast/mod.rs index e3ca4e66410..bd8a6953677 100644 --- a/rust/src/providers/raycast/mod.rs +++ b/rust/src/providers/raycast/mod.rs @@ -184,10 +184,6 @@ impl Provider for RaycastProvider { vec![SourceMode::Auto, SourceMode::Web] } - fn supports_web(&self) -> bool { - true - } - fn manual_cookie_precedes_token_account(&self) -> bool { true } diff --git a/rust/src/providers/replicate/mod.rs b/rust/src/providers/replicate/mod.rs index 4a503a54f25..7a4a07220cb 100644 --- a/rust/src/providers/replicate/mod.rs +++ b/rust/src/providers/replicate/mod.rs @@ -215,10 +215,6 @@ impl Provider for ReplicateProvider { vec![SourceMode::Auto, SourceMode::Web] } - fn supports_web(&self) -> bool { - true - } - fn manual_cookie_precedes_token_account(&self) -> bool { true } diff --git a/rust/src/providers/sakana/mod.rs b/rust/src/providers/sakana/mod.rs index 552ca818b75..a6e0bfb7166 100644 --- a/rust/src/providers/sakana/mod.rs +++ b/rust/src/providers/sakana/mod.rs @@ -242,10 +242,6 @@ impl Provider for SakanaProvider { fn available_sources(&self) -> Vec { vec![SourceMode::Auto, SourceMode::Web] } - - fn supports_web(&self) -> bool { - true - } } #[cfg(test)] diff --git a/rust/src/providers/t3chat.rs b/rust/src/providers/t3chat.rs index 0a8ddc28390..2e8e478b153 100644 --- a/rust/src/providers/t3chat.rs +++ b/rust/src/providers/t3chat.rs @@ -375,10 +375,6 @@ impl Provider for T3ChatProvider { fn available_sources(&self) -> Vec { vec![SourceMode::Auto, SourceMode::Web] } - - fn supports_web(&self) -> bool { - true - } } #[cfg(test)] diff --git a/rust/src/providers/venice/mod.rs b/rust/src/providers/venice/mod.rs index 9e6f4b530f7..a460528188b 100644 --- a/rust/src/providers/venice/mod.rs +++ b/rust/src/providers/venice/mod.rs @@ -299,10 +299,6 @@ impl Provider for VeniceProvider { vec![SourceMode::Auto, SourceMode::OAuth, SourceMode::Web] } - fn supports_web(&self) -> bool { - true - } - fn owns_browser_cookie_resolution(&self) -> bool { true } diff --git a/rust/src/providers/vertexai/mod.rs b/rust/src/providers/vertexai/mod.rs index 45466905a6d..67212b3d562 100755 --- a/rust/src/providers/vertexai/mod.rs +++ b/rust/src/providers/vertexai/mod.rs @@ -332,14 +332,6 @@ impl Provider for VertexAIProvider { fn available_sources(&self) -> Vec { vec![SourceMode::Auto, SourceMode::Web, SourceMode::Cli] } - - fn supports_web(&self) -> bool { - true - } - - fn supports_cli(&self) -> bool { - true - } } #[cfg(test)] diff --git a/rust/src/providers/warp/mod.rs b/rust/src/providers/warp/mod.rs index a1bc1d643d9..3e157827919 100755 --- a/rust/src/providers/warp/mod.rs +++ b/rust/src/providers/warp/mod.rs @@ -402,14 +402,6 @@ impl Provider for WarpProvider { fn available_sources(&self) -> Vec { vec![SourceMode::Auto, SourceMode::OAuth] } - - fn supports_web(&self) -> bool { - false - } - - fn supports_cli(&self) -> bool { - false - } } #[cfg(test)] diff --git a/rust/src/providers/windsurf/mod.rs b/rust/src/providers/windsurf/mod.rs index 6852cc75098..a958381c115 100644 --- a/rust/src/providers/windsurf/mod.rs +++ b/rust/src/providers/windsurf/mod.rs @@ -202,10 +202,6 @@ impl Provider for WindsurfProvider { fn available_sources(&self) -> Vec { vec![SourceMode::Auto, SourceMode::Cli] } - - fn supports_cli(&self) -> bool { - true - } } fn decode_json_value(value: SqlValue) -> Option { diff --git a/rust/src/providers/zai/mod.rs b/rust/src/providers/zai/mod.rs index c113411a520..c3b5d22d321 100755 --- a/rust/src/providers/zai/mod.rs +++ b/rust/src/providers/zai/mod.rs @@ -673,14 +673,6 @@ impl Provider for ZaiProvider { fn available_sources(&self) -> Vec { vec![SourceMode::Auto, SourceMode::OAuth] } - - fn supports_web(&self) -> bool { - false - } - - fn supports_cli(&self) -> bool { - false - } } #[cfg(test)] diff --git a/rust/src/providers/zed/mod.rs b/rust/src/providers/zed/mod.rs index e8a6dcb7a81..600f9393f6f 100644 --- a/rust/src/providers/zed/mod.rs +++ b/rust/src/providers/zed/mod.rs @@ -102,10 +102,6 @@ impl Provider for ZedProvider { vec![SourceMode::Auto, SourceMode::Web, SourceMode::OAuth] } - fn supports_web(&self) -> bool { - true - } - fn web_is_opt_in(&self) -> bool { true } diff --git a/rust/src/providers/zoommate/mod.rs b/rust/src/providers/zoommate/mod.rs index ca1c5d286bc..d5093112fea 100644 --- a/rust/src/providers/zoommate/mod.rs +++ b/rust/src/providers/zoommate/mod.rs @@ -467,10 +467,6 @@ impl Provider for ZoomMateProvider { fn available_sources(&self) -> Vec { vec![SourceMode::Auto, SourceMode::Web] } - - fn supports_web(&self) -> bool { - true - } } // ── Pure helpers ───────────────────────────────────────────────────────────── From 9384213616f193dd2d739cb9be65fcf7a6f52a25 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:41:40 +0700 Subject: [PATCH 05/40] Share identical provider number and format helpers --- rust/src/providers/aixy/present.rs | 23 +++++---- rust/src/providers/atlascloud/mod.rs | 14 +----- rust/src/providers/atlascloud/tests.rs | 2 +- rust/src/providers/bifrost/mod.rs | 24 ++++++--- rust/src/providers/elevenlabs/mod.rs | 17 ++----- rust/src/providers/fireworks/mod.rs | 7 +-- rust/src/providers/format.rs | 60 ++++++++++++++++++++++- rust/src/providers/grok/accounts.rs | 11 +---- rust/src/providers/huggingface/mod.rs | 19 +++---- rust/src/providers/json.rs | 41 ++++++++++++++++ rust/src/providers/kimi/code_api.rs | 3 +- rust/src/providers/kimi/mod.rs | 22 +++------ rust/src/providers/kimik2/mod.rs | 22 +++------ rust/src/providers/llmproxy/mod.rs | 27 ++++------ rust/src/providers/minimax/coding_plan.rs | 11 +++-- rust/src/providers/minimax/mod.rs | 13 ++--- rust/src/providers/mod.rs | 1 + rust/src/providers/neuralwatt/mod.rs | 15 +++--- rust/src/providers/openaiapi/mod.rs | 12 +---- rust/src/providers/openaiapi/tests.rs | 7 ++- rust/src/providers/sakana/payg.rs | 10 ++-- rust/src/providers/vercel/mod.rs | 16 ++---- rust/src/providers/vercel/tests.rs | 10 ++-- rust/src/providers/xkiro/mod.rs | 22 ++------- rust/src/providers/zenmux/mod.rs | 41 ++++++---------- 25 files changed, 229 insertions(+), 221 deletions(-) create mode 100644 rust/src/providers/json.rs diff --git a/rust/src/providers/aixy/present.rs b/rust/src/providers/aixy/present.rs index 87f5669478a..aef15a90613 100644 --- a/rust/src/providers/aixy/present.rs +++ b/rust/src/providers/aixy/present.rs @@ -7,6 +7,7 @@ use crate::core::{ CostSnapshot, NamedRateWindow, ProviderDisplayDetail, ProviderFetchResult, RateWindow, UsageSnapshot, }; +use crate::providers::format; const COST_PERIOD: &str = "Last 7 days · attributed"; const LOGIN_METHOD: &str = "API key"; @@ -67,7 +68,7 @@ pub(super) fn build_result(usage: KeyUsage) -> ProviderFetchResult { fn rate_window(budget: &Budget) -> RateWindow { let balance = match (budget.known, budget.remaining) { - (true, Some(remaining)) => format!("{} remaining", usd(remaining)), + (true, Some(remaining)) => format!("{} remaining", format::usd_plain(remaining)), _ => "Unavailable".to_owned(), }; RateWindow::with_details( @@ -111,14 +112,22 @@ fn budget_row((index, budget): (usize, &Budget)) -> Option Vec> { "Attributed spend (last 7 days)", totals .spend_usd - .map_or_else(|| "Unavailable".to_owned(), usd), + .map_or_else(|| "Unavailable".to_owned(), format::usd_plain), ) .and_then(|row| row.with_section_title("Last 7 days · this key")), ProviderDisplayDetail::new( @@ -163,10 +172,6 @@ fn totals_rows(totals: &Totals) -> Vec> { ] } -fn usd(value: f64) -> String { - format!("${value:.2}") -} - /// Integer with thousands separators. fn count(value: u64) -> String { let digits = value.to_string(); diff --git a/rust/src/providers/atlascloud/mod.rs b/rust/src/providers/atlascloud/mod.rs index 5ba699b4428..c2972d9ea77 100644 --- a/rust/src/providers/atlascloud/mod.rs +++ b/rust/src/providers/atlascloud/mod.rs @@ -9,7 +9,7 @@ use crate::core::{ CostSnapshot, FetchContext, Provider, ProviderDisplayDetail, ProviderError, ProviderFetchResult, ProviderId, RateWindow, SourceMode, UsageSnapshot, }; -use crate::providers::{BoundedBodyError, read_bounded_response}; +use crate::providers::{BoundedBodyError, format, read_bounded_response}; const BALANCE_URL: &str = "https://api.atlascloud.ai/public/v1/balance"; const CREDENTIAL_TARGET: &str = "codexbar-atlascloud"; @@ -89,23 +89,13 @@ fn balance_result(balance: f64) -> ProviderFetchResult { let detail = ProviderDisplayDetail::new( "atlascloud-available", "Available balance", - format_usd(balance), + format::usd_signed(balance), ); ProviderFetchResult::new(usage, "api") .with_cost(cost) .with_display_detail(detail) } -/// `$95.50` / `-$1.25`; a negative that rounds to zero shows no sign. -fn format_usd(amount: f64) -> String { - let magnitude = format!("{:.2}", amount.abs()); - if amount < 0.0 && magnitude != "0.00" { - format!("-${magnitude}") - } else { - format!("${magnitude}") - } -} - impl Default for AtlasCloudProvider { fn default() -> Self { Self::new() diff --git a/rust/src/providers/atlascloud/tests.rs b/rust/src/providers/atlascloud/tests.rs index 85879c11ae5..39bc51dbbed 100644 --- a/rust/src/providers/atlascloud/tests.rs +++ b/rust/src/providers/atlascloud/tests.rs @@ -29,7 +29,7 @@ fn formats_signed_usd_like_upstream() { (-0.001, "$0.00"), (1234.5678, "$1234.57"), ] { - assert_eq!(format_usd(amount), expected); + assert_eq!(format::usd_signed(amount), expected); } } diff --git a/rust/src/providers/bifrost/mod.rs b/rust/src/providers/bifrost/mod.rs index 1039560282e..aeb771bf826 100644 --- a/rust/src/providers/bifrost/mod.rs +++ b/rust/src/providers/bifrost/mod.rs @@ -13,6 +13,7 @@ use crate::core::{ CostSnapshot, FetchContext, NamedRateWindow, Provider, ProviderDisplayDetail, ProviderError, ProviderFetchResult, ProviderId, RateWindow, SourceMode, UsageSnapshot, }; +use crate::providers::format; const CREDENTIAL_TARGET: &str = "codexbar-bifrost"; const API_KEY_ENV: &str = "BIFROST_API_KEY"; @@ -475,9 +476,13 @@ fn result_from_usage(usage: ParsedUsage) -> ProviderFetchResult { .join(" · "), ); let value = if budget.limit > 0.0 { - format!("{} / {}", usd(budget.used), usd(budget.limit)) + format!( + "{} / {}", + format::usd_plain(budget.used), + format::usd_plain(budget.limit) + ) } else { - usd(budget.used) + format::usd_plain(budget.used) }; let mut detail = ProviderDisplayDetail::new(format!("bifrost-budget-detail-{index}"), title, value); @@ -639,7 +644,10 @@ fn model_details(models: &[Value]) -> Vec> { .collect::>() .join(" · "), ); - let value = row.cost.map(usd).unwrap_or_else(|| "—".into()); + let value = row + .cost + .map(format::usd_plain) + .unwrap_or_else(|| "—".into()); let detail = ProviderDisplayDetail::new(format!("bifrost-model-{index}"), label, value); match row.tokens { Some(tokens) => detail.and_then(|detail| { @@ -670,7 +678,11 @@ fn budget_description(budget: &Budget) -> String { if let Some(label) = budget.reset.label { parts.push(label.into()); } - parts.push(format!("{} / {}", usd(budget.used), usd(budget.limit))); + parts.push(format!( + "{} / {}", + format::usd_plain(budget.used), + format::usd_plain(budget.limit) + )); bounded(&parts.join(" · ")) } @@ -835,10 +847,6 @@ fn percent(used: f64, limit: f64) -> f64 { 0.0 } } - -fn usd(value: f64) -> String { - format!("${value:.2}") -} fn bounded(value: &str) -> String { value.chars().take(120).collect() } diff --git a/rust/src/providers/elevenlabs/mod.rs b/rust/src/providers/elevenlabs/mod.rs index d19ff80894f..0bb00a95e03 100644 --- a/rust/src/providers/elevenlabs/mod.rs +++ b/rust/src/providers/elevenlabs/mod.rs @@ -11,6 +11,7 @@ use crate::core::{ FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, RateWindow, SourceMode, UsageSnapshot, }; +use crate::providers::format; const ELEVENLABS_API_BASE_URL: &str = "https://api.elevenlabs.io"; const ELEVENLABS_API_URL_ENV: &str = "ELEVENLABS_API_URL"; @@ -187,8 +188,8 @@ fn snapshot_from_subscription(subscription: &ElevenLabsSubscriptionResponse) -> let mut primary = RateWindow::new(used_percent); primary.reset_description = Some(format!( "{} / {} credits", - format_count(subscription.character_count), - format_count(subscription.character_limit) + format::count(subscription.character_count), + format::count(subscription.character_limit) )); primary.resets_at = subscription .next_character_count_reset_unix @@ -275,18 +276,6 @@ fn title_case_tier(tier: &str) -> String { title } -fn format_count(value: u64) -> String { - let raw = value.to_string(); - let mut out = String::with_capacity(raw.len() + raw.len() / 3); - for (idx, ch) in raw.chars().rev().enumerate() { - if idx > 0 && idx % 3 == 0 { - out.push(','); - } - out.push(ch); - } - out.chars().rev().collect() -} - fn resolve_api_key( explicit: Option<&str>, credential_target: &str, diff --git a/rust/src/providers/fireworks/mod.rs b/rust/src/providers/fireworks/mod.rs index c5f660fda07..173c2424cab 100644 --- a/rust/src/providers/fireworks/mod.rs +++ b/rust/src/providers/fireworks/mod.rs @@ -17,6 +17,7 @@ use crate::core::{ CostSnapshot, FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, RateWindow, SourceMode, UsageSnapshot, }; +use crate::providers::format; const BILLING_SUMMARY_URL: &str = "https://api.fireworks.ai/v1/accounts"; const CREDENTIAL_TARGET: &str = "codexbar-fireworks"; @@ -159,7 +160,7 @@ impl FireworksSummary { let spend_text = self .last_30_days_spend .zip(self.currency_code.as_deref()) - .map(|(spend, _)| format_money(spend)); + .map(|(spend, _)| format::usd_plain(spend)); let mut primary = RateWindow::new(0.0); primary.reset_description = spend_text.clone(); let mut snapshot = UsageSnapshot::new(primary); @@ -176,10 +177,6 @@ impl FireworksSummary { } } -fn format_money(value: f64) -> String { - format!("${value:.2}") -} - pub struct FireworksProvider { client: Client, } diff --git a/rust/src/providers/format.rs b/rust/src/providers/format.rs index 864027f0bf2..14e8ba0b0dc 100644 --- a/rust/src/providers/format.rs +++ b/rust/src/providers/format.rs @@ -2,7 +2,8 @@ //! //! These mirror the helpers in the upstream provider plugin prelude //! (`ctx.format.number`, `ctx.format.usd`) and JavaScript's -//! `Number.prototype.toFixed`, so ported rows render the same strings. +//! `Number.prototype.toFixed`, so ported rows render the same strings, plus +//! the small dollar and count formats several native providers share. /// `ctx.format.number(value, { maximumFractionDigits })`: fixed to /// `max_fraction_digits`, trailing fractional zeros trimmed, and the integer @@ -18,6 +19,37 @@ pub(crate) fn usd(value: f64) -> String { format!("{sign}{}", format_number(value.abs(), 2, 2)) } +/// A dollar amount with two fractional digits and no grouping, as +/// `format!("${value:.2}")` prints it (`$1234.50`). +pub(crate) fn usd_plain(value: f64) -> String { + format!("${value:.2}") +} + +/// `$95.50` / `-$1.25` with no grouping; a negative that rounds to zero +/// shows no sign. +pub(crate) fn usd_signed(value: f64) -> String { + let magnitude = format!("{:.2}", value.abs()); + if value < 0.0 && magnitude != "0.00" { + format!("-${magnitude}") + } else { + format!("${magnitude}") + } +} + +/// An integer count with comma digit groups (`1,234,567`). +pub(crate) fn count(value: u64) -> String { + group_thousands(&value.to_string()) +} + +/// Whole values without decimals, anything else with two (`6182`, `57.20`). +pub(crate) fn whole_or_two_decimals(value: f64) -> String { + if (value - value.round()).abs() < f64::EPSILON { + format!("{:.0}", value) + } else { + format!("{:.2}", value) + } +} + /// JavaScript `Number.prototype.toFixed(digits)`. /// /// Rust's formatter rounds an exact binary tie to even; JavaScript picks the @@ -103,7 +135,7 @@ fn js_string(value: f64) -> String { #[cfg(test)] mod tests { - use super::{number, to_fixed, usd}; + use super::{count, number, to_fixed, usd, usd_plain, usd_signed, whole_or_two_decimals}; #[test] fn to_fixed_rounds_exact_ties_away_from_zero_like_javascript() { @@ -172,4 +204,28 @@ mod tests { assert_eq!(usd(-1.0), "-$1.00"); assert_eq!(usd(1_000_000.0), "$1,000,000.00"); } + + #[test] + fn usd_plain_and_signed_keep_two_digits_without_grouping() { + assert_eq!(usd_plain(1_234.5), "$1234.50"); + assert_eq!(usd_plain(-1.0), "$-1.00"); + assert_eq!(usd_signed(1_234.5), "$1234.50"); + assert_eq!(usd_signed(-1.25), "-$1.25"); + assert_eq!(usd_signed(-0.001), "$0.00"); + } + + #[test] + fn count_groups_digits_by_three() { + assert_eq!(count(0), "0"); + assert_eq!(count(999), "999"); + assert_eq!(count(1_000), "1,000"); + assert_eq!(count(1_234_567), "1,234,567"); + assert_eq!(count(u64::MAX), "18,446,744,073,709,551,615"); + } + + #[test] + fn whole_or_two_decimals_drops_zero_cents_only() { + assert_eq!(whole_or_two_decimals(6182.0), "6182"); + assert_eq!(whole_or_two_decimals(57.2), "57.20"); + } } diff --git a/rust/src/providers/grok/accounts.rs b/rust/src/providers/grok/accounts.rs index acdb13db6fa..1523064ffc3 100644 --- a/rust/src/providers/grok/accounts.rs +++ b/rust/src/providers/grok/accounts.rs @@ -16,6 +16,8 @@ use uuid::Uuid; use crate::atomic_file::replace_staged; use crate::secure_file; +use super::text_field; + pub use login::{begin_login, cancel_login, cleanup_abandoned_logins, login}; pub use orca::select_account as select_orca_account; @@ -413,15 +415,6 @@ fn required_string<'a>(object: &'a Value, key: &str) -> io::Result<&'a str> { .ok_or_else(|| io::Error::other(format!("Grok login is missing {key}. Sign in again."))) } -fn text_field(value: &Value, key: &str) -> Option { - value - .get(key) - .and_then(Value::as_str) - .map(str::trim) - .filter(|s| !s.is_empty()) - .map(ToOwned::to_owned) -} - fn identity_id(auth: &Value) -> io::Result { let entry = auth_file(auth)? .select_account() diff --git a/rust/src/providers/huggingface/mod.rs b/rust/src/providers/huggingface/mod.rs index dea594d69c0..55ec04cd2ae 100644 --- a/rust/src/providers/huggingface/mod.rs +++ b/rust/src/providers/huggingface/mod.rs @@ -21,6 +21,7 @@ use crate::core::{ mod identity_cache; mod wallet; +use crate::providers::format; use identity_cache::{get_or_fetch_identity, process_identity_cache}; use wallet::{WalletCandidate, fetch_matching_wallet_balance, parse_wallet_balance}; @@ -506,27 +507,31 @@ fn build_result( ( "billable-usage", "Billable inference usage", - format_usd(billing.billable_usd), + format::usd_plain(billing.billable_usd), ), ( "gross-inference-usage", "Gross inference usage", - format_usd(billing.used_usd), + format::usd_plain(billing.used_usd), ), ( "included-inference-amount", "Included inference amount", - format_usd(billing.included_usd), + format::usd_plain(billing.included_usd), ), ]; if let Some(limit) = billing.limit_usd { - details.push(("spending-limit", "Spending limit", format_usd(limit))); + details.push(("spending-limit", "Spending limit", format::usd_plain(limit))); } if let Some(requests) = billing.requests { details.push(("inference-requests", "Requests", requests.to_string())); } if let Some(balance) = balance { - details.push(("prepaid-balance", "Prepaid balance", format_usd(balance))); + details.push(( + "prepaid-balance", + "Prepaid balance", + format::usd_plain(balance), + )); } let mut rows: Vec> = details @@ -573,10 +578,6 @@ fn build_result( result } -fn format_usd(value: f64) -> String { - format!("${value:.2}") -} - fn classify_optional_status(status: StatusCode) -> ProviderError { match status { StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => ProviderError::AuthRequired, diff --git a/rust/src/providers/json.rs b/rust/src/providers/json.rs new file mode 100644 index 00000000000..adba497cfd2 --- /dev/null +++ b/rust/src/providers/json.rs @@ -0,0 +1,41 @@ +//! JSON number readers shared by providers. + +use serde_json::Value; + +/// A JSON number, or a string holding one once trimmed and stripped of +/// thousands commas. Strings that parse to a non-finite value pass through. +pub(crate) fn lenient_f64(value: Option<&Value>) -> Option { + match value? { + Value::Number(number) => number.as_f64(), + Value::String(text) => text.trim().replace(',', "").parse().ok(), + _ => None, + } +} + +/// [`lenient_f64`], keeping finite values only. +pub(crate) fn lenient_finite_f64(value: &Value) -> Option { + lenient_f64(Some(value)).filter(|value| value.is_finite()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn lenient_f64_reads_numbers_and_numeric_strings() { + assert_eq!(lenient_f64(Some(&json!(12.5))), Some(12.5)); + assert_eq!(lenient_f64(Some(&json!(" 1,234.5 "))), Some(1234.5)); + assert_eq!(lenient_f64(Some(&json!("abc"))), None); + assert_eq!(lenient_f64(Some(&json!(true))), None); + assert_eq!(lenient_f64(None), None); + assert!(lenient_f64(Some(&json!("NaN"))).is_some_and(f64::is_nan)); + } + + #[test] + fn lenient_finite_f64_drops_non_finite_strings() { + assert_eq!(lenient_finite_f64(&json!("7")), Some(7.0)); + assert_eq!(lenient_finite_f64(&json!("inf")), None); + assert_eq!(lenient_finite_f64(&json!("NaN")), None); + } +} diff --git a/rust/src/providers/kimi/code_api.rs b/rust/src/providers/kimi/code_api.rs index fdb32885ae9..624f35fa725 100644 --- a/rust/src/providers/kimi/code_api.rs +++ b/rust/src/providers/kimi/code_api.rs @@ -13,6 +13,7 @@ use super::{ kimi_window_minutes, }; use super::{ratio_pool, web}; +use crate::providers::json; const KIMI_CODE_API_KEY_ENV: &str = "KIMI_CODE_API_KEY"; const KIMI_CODE_BASE_URL_ENV: &str = "KIMI_CODE_BASE_URL"; @@ -332,7 +333,7 @@ fn read_kimi_code_device_id(home: &Path) -> Option { } fn is_kimi_code_credential_fresh(expires_at: Option, now_unix: f64) -> bool { - let Some(expires) = super::value_as_f64(expires_at.as_ref()) else { + let Some(expires) = json::lenient_f64(expires_at.as_ref()) else { return false; }; if !expires.is_finite() { diff --git a/rust/src/providers/kimi/mod.rs b/rust/src/providers/kimi/mod.rs index 8af68d6ad9d..4e6c27c2929 100755 --- a/rust/src/providers/kimi/mod.rs +++ b/rust/src/providers/kimi/mod.rs @@ -41,6 +41,7 @@ use crate::core::{ FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, RateWindow, SourceMode, UsageSnapshot, }; +use crate::providers::json; /// Extra-window id of the monthly membership pool (`Total usage`). pub const MONTHLY_WINDOW_ID: &str = "kimi-monthly"; @@ -92,7 +93,7 @@ impl KimiRatioPool { /// Explicit zero is a known quota value; missing or malformed ratios stay /// unknown rather than being rendered as 0% used. fn rate_window(&self, window_minutes: u32) -> Option { - let ratio = value_as_f64(self.used_ratio.as_ref())?; + let ratio = json::lenient_f64(self.used_ratio.as_ref())?; if !ratio.is_finite() || ratio < 0.0 { return None; } @@ -289,12 +290,12 @@ impl KimiProvider { detail: &KimiUsageDetail, window_minutes: Option, ) -> Result { - let limit = value_as_f64(detail.limit.as_ref()) + let limit = json::lenient_f64(detail.limit.as_ref()) .filter(|limit| *limit > 0.0) .ok_or_else(|| ProviderError::Parse("Kimi usage limit missing".into()))?; let used = match ( - value_as_f64(detail.used.as_ref()), - value_as_f64(detail.remaining.as_ref()), + json::lenient_f64(detail.used.as_ref()), + json::lenient_f64(detail.remaining.as_ref()), ) { (Some(used), _) => used, (None, Some(remaining)) => (limit - remaining).max(0.0), @@ -445,7 +446,7 @@ fn apply_subscription_windows( && matches!(balance.feature.as_deref(), None | Some("FEATURE_OMNI")) && matches!(balance.balance_type.as_deref(), None | Some("SUBSCRIPTION")) && let Some(ratio) = - value_as_f64(balance.amount_used_ratio.as_ref()).filter(|value| value.is_finite()) + json::lenient_f64(balance.amount_used_ratio.as_ref()).filter(|value| value.is_finite()) { // Verified monthly sentinel (#2431 / #2566). usage = usage.with_extra_rate_window( @@ -462,7 +463,8 @@ fn apply_subscription_windows( if let Some(limit) = subscription.ratelimit_code7d.as_ref() && limit.enabled.unwrap_or(true) - && let Some(ratio) = value_as_f64(limit.ratio.as_ref()).filter(|value| value.is_finite()) + && let Some(ratio) = + json::lenient_f64(limit.ratio.as_ref()).filter(|value| value.is_finite()) { // Upstream 0.49.0 #2741: the membership 7-day Code ratio and the // FEATURE_CODING weekly detail report the same quota through two @@ -526,14 +528,6 @@ async fn kimi_web_post( .map_err(ProviderError::from) } -fn value_as_f64(value: Option<&serde_json::Value>) -> Option { - match value? { - serde_json::Value::Number(number) => number.as_f64(), - serde_json::Value::String(text) => text.trim().replace(',', "").parse().ok(), - _ => None, - } -} - fn parse_kimi_timestamp(value: &serde_json::Value) -> Option> { match value { serde_json::Value::String(text) => parse_kimi_timestamp_str(text), diff --git a/rust/src/providers/kimik2/mod.rs b/rust/src/providers/kimik2/mod.rs index 5fb19af7caa..01b7a8644c7 100755 --- a/rust/src/providers/kimik2/mod.rs +++ b/rust/src/providers/kimik2/mod.rs @@ -10,6 +10,7 @@ use crate::core::{ CostSnapshot, FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, RateWindow, SourceMode, UsageSnapshot, }; +use crate::providers::json; const KIMIK2_API_BASE_INTERNATIONAL: &str = "https://api.moonshot.ai"; const KIMIK2_API_BASE_CHINA: &str = "https://api.moonshot.cn"; @@ -292,21 +293,21 @@ impl KimiK2Provider { let available_balance = data .get("available_balance") .or_else(|| data.get("balance")) - .and_then(finite_json_f64) + .and_then(json::lenient_finite_f64) .unwrap_or(0.0); // Total credits (used + available) let total_credits = data .get("total_balance") .or_else(|| data.get("total")) - .and_then(finite_json_f64) + .and_then(json::lenient_finite_f64) .unwrap_or(available_balance.max(0.0)); // Used credits let used_credits = data .get("used_balance") .or_else(|| data.get("used")) - .and_then(finite_json_f64) + .and_then(json::lenient_finite_f64) .unwrap_or(total_credits - available_balance); // Calculate percentage used @@ -317,8 +318,10 @@ impl KimiK2Provider { }; // Cash balance (if any) - let voucher_balance = data.get("voucher_balance").and_then(finite_json_f64); - let cash_balance = data.get("cash_balance").and_then(finite_json_f64); + let voucher_balance = data + .get("voucher_balance") + .and_then(json::lenient_finite_f64); + let cash_balance = data.get("cash_balance").and_then(json::lenient_finite_f64); // Create primary rate window (credits used) let mut primary = RateWindow::new(used_percent); @@ -337,15 +340,6 @@ impl KimiK2Provider { )); } - fn finite_json_f64(value: &serde_json::Value) -> Option { - match value { - serde_json::Value::Number(number) => number.as_f64(), - serde_json::Value::String(text) => text.trim().replace(',', "").parse().ok(), - _ => None, - } - .filter(|value: &f64| value.is_finite()) - } - let mut usage = UsageSnapshot::new(primary).with_login_method(login_method); // Add secondary window for cash balance if available diff --git a/rust/src/providers/llmproxy/mod.rs b/rust/src/providers/llmproxy/mod.rs index eb30905d0db..4da48689bbe 100644 --- a/rust/src/providers/llmproxy/mod.rs +++ b/rust/src/providers/llmproxy/mod.rs @@ -12,6 +12,7 @@ use crate::core::{ CostSnapshot, FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, RateWindow, SourceMode, UsageSnapshot, }; +use crate::providers::format; const LLM_PROXY_CREDENTIAL_TARGET: &str = "codexbar-llmproxy"; const LLM_PROXY_BASE_URL_ENV: &str = "LLM_PROXY_BASE_URL"; @@ -360,10 +361,12 @@ fn snapshot_from_summary(summary: &LLMProxySummary) -> UsageSnapshot { // Totals and provider rows are counts, not quotas. Informational rows keep // them from rendering as empty bars with a "Resets" prefix. - let secondary = - RateWindow::informational(format!("{} requests", format_count(summary.total_requests))); + let secondary = RateWindow::informational(format!( + "{} requests", + format::count(summary.total_requests) + )); let tertiary = - RateWindow::informational(format!("{} tokens", format_count(summary.total_tokens))); + RateWindow::informational(format!("{} tokens", format::count(summary.total_tokens))); let mut snapshot = UsageSnapshot::new(primary) .with_secondary(secondary) @@ -376,8 +379,8 @@ fn snapshot_from_summary(summary: &LLMProxySummary) -> UsageSnapshot { for provider in summary.top_providers.iter().take(3) { let mut parts = vec![ - format!("{} req", format_count(provider.requests)), - format!("{} tok", format_count(provider.tokens)), + format!("{} req", format::count(provider.requests)), + format!("{} tok", format::count(provider.tokens)), ]; if let Some(cost) = provider.approximate_cost_usd { parts.push(format_usd(cost)); @@ -410,7 +413,7 @@ fn format_usd(value: f64) -> String { )] let cents = (value.abs() * 100.0).round() as u64; let sign = if value < 0.0 && cents > 0 { "-" } else { "" }; - format!("{sign}${}.{:02}", format_count(cents / 100), cents % 100) + format!("{sign}${}.{:02}", format::count(cents / 100), cents % 100) } fn token_total(tokens: Option<&TokenStats>) -> u64 { @@ -439,18 +442,6 @@ fn parse_date(raw: Option<&str>) -> Option> { .map(|dt| dt.with_timezone(&Utc)) } -fn format_count(value: u64) -> String { - let raw = value.to_string(); - let mut out = String::with_capacity(raw.len() + raw.len() / 3); - for (idx, ch) in raw.chars().rev().enumerate() { - if idx > 0 && idx % 3 == 0 { - out.push(','); - } - out.push(ch); - } - out.chars().rev().collect() -} - fn resolve_api_key( explicit: Option<&str>, credential_target: &str, diff --git a/rust/src/providers/minimax/coding_plan.rs b/rust/src/providers/minimax/coding_plan.rs index fc3f2906e5c..25694854c75 100644 --- a/rust/src/providers/minimax/coding_plan.rs +++ b/rust/src/providers/minimax/coding_plan.rs @@ -11,7 +11,8 @@ use serde_json::Value; use crate::core::ProviderError; -use super::{scalar_string, value_f64, value_i64}; +use super::{scalar_string, value_i64}; +use crate::providers::json; /// Parsed result of a coding-plan fetch (page JSON, remains API, or HTML scrape). #[derive(Debug, Clone)] @@ -407,7 +408,7 @@ fn parse_multi_service(json: &Value) -> Option> { if limit <= 0 { continue; } - let percent = match value_f64(item.get("percent")) { + let percent = match json::lenient_f64(item.get("percent")) { Some(p) => p, None => (usage as f64 / limit as f64) * 100.0, }; @@ -524,7 +525,7 @@ fn parse_plan_name_from_data(data: &Value) -> Option { "current_interval_usage_count", "currentIntervalUsageCount", )), - value_f64(get_field( + json::lenient_f64(get_field( e, "current_interval_remaining_percent", "currentIntervalRemainingPercent", @@ -611,7 +612,7 @@ fn parse_remains( "current_interval_usage_count", "currentIntervalUsageCount", )), - value_f64(get_field( + json::lenient_f64(get_field( entry, "current_interval_remaining_percent", "currentIntervalRemainingPercent", @@ -646,7 +647,7 @@ fn parse_remains( "current_weekly_usage_count", "currentWeeklyUsageCount", )), - value_f64(get_field( + json::lenient_f64(get_field( entry, "current_weekly_remaining_percent", "currentWeeklyRemainingPercent", diff --git a/rust/src/providers/minimax/mod.rs b/rust/src/providers/minimax/mod.rs index 77c3d462fa4..9967e5df9f6 100755 --- a/rust/src/providers/minimax/mod.rs +++ b/rust/src/providers/minimax/mod.rs @@ -26,6 +26,7 @@ use crate::core::{ CostSnapshot, FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, RateWindow, SourceMode, UsageSnapshot, }; +use crate::providers::json; const CODING_PLAN_PATH: &str = "/user-center/payment/coding-plan"; const CODING_PLAN_QUERY: &str = "cycle_type=3"; @@ -923,8 +924,8 @@ fn record_token_count(record: &MiniMaxBillingRecord) -> i64 { } fn record_cash(record: &MiniMaxBillingRecord) -> Option { - value_f64(record.consume_cash_after_voucher.as_ref()) - .or_else(|| value_f64(record.consume_cash.as_ref())) + json::lenient_f64(record.consume_cash_after_voucher.as_ref()) + .or_else(|| json::lenient_f64(record.consume_cash.as_ref())) } fn record_date(record: &MiniMaxBillingRecord) -> Option> { @@ -964,14 +965,6 @@ fn value_i64(value: Option<&serde_json::Value>) -> Option { } } -fn value_f64(value: Option<&serde_json::Value>) -> Option { - match value? { - serde_json::Value::Number(number) => number.as_f64(), - serde_json::Value::String(text) => text.trim().replace(',', "").parse().ok(), - _ => None, - } -} - fn scalar_string(value: Option<&serde_json::Value>) -> Option { match value? { serde_json::Value::String(text) => Some(text.clone()), diff --git a/rust/src/providers/mod.rs b/rust/src/providers/mod.rs index 78f57c45a67..ad0213a293b 100755 --- a/rust/src/providers/mod.rs +++ b/rust/src/providers/mod.rs @@ -50,6 +50,7 @@ pub mod huggingface; pub mod hyper; pub mod infini; pub mod jetbrains; +pub(crate) mod json; pub mod kilo; pub mod kimi; pub mod kimik2; diff --git a/rust/src/providers/neuralwatt/mod.rs b/rust/src/providers/neuralwatt/mod.rs index 7ce148d4b5f..68052eb6163 100644 --- a/rust/src/providers/neuralwatt/mod.rs +++ b/rust/src/providers/neuralwatt/mod.rs @@ -18,6 +18,7 @@ use crate::core::{ CostSnapshot, FetchContext, NamedRateWindow, Provider, ProviderError, ProviderFetchResult, ProviderId, RateWindow, SourceMode, SubscriptionMetadata, UsageSnapshot, }; +use crate::providers::format; const DEFAULT_API_BASE: &str = "https://api.neuralwatt.com"; const CREDENTIAL_TARGET: &str = "codexbar-neuralwatt"; @@ -338,14 +339,6 @@ fn parse_failure(message: &str) -> ProviderError { ProviderError::Parse(format!("Failed to parse Neuralwatt response: {message}")) } -fn format_kwh(value: f64) -> String { - if (value - value.round()).abs() < f64::EPSILON { - format!("{:.0}", value) - } else { - format!("{:.2}", value) - } -} - fn subscription_window(sub: &Subscription) -> Result, ProviderError> { let start = optional_iso(sub.current_period_start.as_deref(), "current_period_start")?; let end = optional_iso(sub.current_period_end.as_deref(), "current_period_end")?; @@ -375,7 +368,11 @@ fn subscription_window(sub: &Subscription) -> Result, Provide } w.resets_at = Some(end); } - w.reset_description = Some(format!("{} / {} kWh", format_kwh(used), format_kwh(total))); + w.reset_description = Some(format!( + "{} / {} kWh", + format::whole_or_two_decimals(used), + format::whole_or_two_decimals(total) + )); Ok(Some(w)) } diff --git a/rust/src/providers/openaiapi/mod.rs b/rust/src/providers/openaiapi/mod.rs index cb5e6806d99..f86e4772223 100644 --- a/rust/src/providers/openaiapi/mod.rs +++ b/rust/src/providers/openaiapi/mod.rs @@ -33,6 +33,7 @@ use crate::core::{ CostSnapshot, FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, RateWindow, SourceMode, UsageSnapshot, }; +use crate::providers::json; const OPENAI_CREDIT_GRANTS_URL: &str = "https://api.openai.com/v1/dashboard/billing/credit_grants"; const OPENAI_ORG_COSTS_URL: &str = "https://api.openai.com/v1/organization/costs"; @@ -553,7 +554,7 @@ fn cost_amount(result: &CostResult) -> Result { match &amount.value { serde_json::Value::Null => Ok(0.0), serde_json::Value::String(text) if text.trim().is_empty() => Ok(0.0), - value => number_value(value).ok_or_else(|| { + value => json::lenient_finite_f64(value).ok_or_else(|| { ProviderError::Parse("OpenAI API costs amount must be numeric".to_string()) }), } @@ -637,15 +638,6 @@ fn response_error_detail(body: &str) -> String { trimmed.chars().take(500).collect() } -fn number_value(value: &serde_json::Value) -> Option { - let value = match value { - serde_json::Value::Number(number) => number.as_f64(), - serde_json::Value::String(text) => text.trim().replace(',', "").parse().ok(), - _ => None, - }?; - value.is_finite().then_some(value) -} - impl Default for OpenAIApiProvider { fn default() -> Self { Self::new() diff --git a/rust/src/providers/openaiapi/tests.rs b/rust/src/providers/openaiapi/tests.rs index f6b4c4cad68..c45e0275976 100644 --- a/rust/src/providers/openaiapi/tests.rs +++ b/rust/src/providers/openaiapi/tests.rs @@ -206,8 +206,11 @@ fn openai_admin_usage_sums_costs_and_ranks_line_items() { #[test] fn openai_admin_usage_rejects_nonfinite_cost_amounts() { - assert_eq!(number_value(&serde_json::json!("NaN")), None); - assert_eq!(number_value(&serde_json::json!("Infinity")), None); + assert_eq!(json::lenient_finite_f64(&serde_json::json!("NaN")), None); + assert_eq!( + json::lenient_finite_f64(&serde_json::json!("Infinity")), + None + ); for value in ["NaN", "Infinity", "-Infinity", "1e309", "-1e309"] { let costs = [cost_bucket(serde_json::json!(value))]; let error = result_from_admin_usage(&costs, &[], fixed_now(0), None) diff --git a/rust/src/providers/sakana/payg.rs b/rust/src/providers/sakana/payg.rs index 8b4a39ada33..cd09b3263ad 100644 --- a/rust/src/providers/sakana/payg.rs +++ b/rust/src/providers/sakana/payg.rs @@ -14,7 +14,7 @@ use reqwest::{Client, Url}; use tokio::task::JoinHandle; use crate::core::{ProviderDisplayDetail, is_same_origin}; -use crate::providers::read_bounded_response; +use crate::providers::{format, read_bounded_response}; /// Per-request bound for the optional GET (the primary keeps the client's 15 s). const REQUEST_TIMEOUT: Duration = Duration::from_secs(5); @@ -57,10 +57,10 @@ impl PaygBalance { rows.extend(ProviderDisplayDetail::new( "payg-balance", "Balance", - format_usd(self.balance), + format::usd_plain(self.balance), )); if let Some(total) = self.usage_total { - let row = ProviderDisplayDetail::new("payg-usage", "Usage", format_usd(total)); + let row = ProviderDisplayDetail::new("payg-usage", "Usage", format::usd_plain(total)); rows.extend(match self.period.as_deref() { Some(period) => row.and_then(|row| row.with_secondary_value(period)), None => row, @@ -100,10 +100,6 @@ fn capture_period(html: &str) -> Option { (!collapsed.is_empty()).then(|| collapsed.chars().take(MAX_PERIOD_CHARS).collect()) } -fn format_usd(value: f64) -> String { - format!("${value:.2}") -} - /// One optional GET: never errors, never retries. The final URL must stay on /// the billing origin so a redirect cannot feed foreign markup into the parser. async fn fetch( diff --git a/rust/src/providers/vercel/mod.rs b/rust/src/providers/vercel/mod.rs index f76a8e72c43..ba0f9fd0919 100644 --- a/rust/src/providers/vercel/mod.rs +++ b/rust/src/providers/vercel/mod.rs @@ -15,7 +15,7 @@ use crate::core::{ CostSnapshot, FetchContext, Provider, ProviderDisplayDetail, ProviderError, ProviderFetchResult, ProviderId, RateWindow, SourceMode, UsageSnapshot, }; -use crate::providers::{BoundedBodyError, read_bounded_response}; +use crate::providers::{BoundedBodyError, format, read_bounded_response}; const CREDITS_URL: &str = "https://ai-gateway.vercel.sh/v1/credits"; const CREDENTIAL_TARGET: &str = "codexbar-vercel"; @@ -166,16 +166,6 @@ fn parse_amount(value: &str) -> Option { .filter(|amount| amount.is_finite()) } -/// `$95.50`, `-$1.25`, `$0.00`; a negative that rounds to zero carries no sign. -fn format_usd(value: f64) -> String { - let cents = format!("{:.2}", value.abs()); - if value < 0.0 && cents != "0.00" { - format!("-${cents}") - } else { - format!("${cents}") - } -} - fn build_result(credits: Credits) -> ProviderFetchResult { let usage = UsageSnapshot::new(RateWindow::informational(SECTION_LABEL)).with_login_method("API"); @@ -190,12 +180,12 @@ fn build_result(credits: Credits) -> ProviderFetchResult { .with_display_detail(ProviderDisplayDetail::new( "vercel-balance", "Available balance", - format_usd(credits.balance), + format::usd_signed(credits.balance), )) .with_display_detail(ProviderDisplayDetail::new( "vercel-lifetime-spend", "Lifetime spend", - format_usd(credits.total_used), + format::usd_signed(credits.total_used), )) } diff --git a/rust/src/providers/vercel/tests.rs b/rust/src/providers/vercel/tests.rs index 780aa0b1385..0f31fcc25a4 100644 --- a/rust/src/providers/vercel/tests.rs +++ b/rust/src/providers/vercel/tests.rs @@ -77,11 +77,11 @@ fn ignores_unknown_fields_without_inventing_them() { #[test] fn formats_usd_with_sign_before_the_symbol() { - assert_eq!(format_usd(95.5), "$95.50"); - assert_eq!(format_usd(-1.25), "-$1.25"); - assert_eq!(format_usd(0.0), "$0.00"); - assert_eq!(format_usd(-0.001), "$0.00"); - assert_eq!(format_usd(4.5), "$4.50"); + assert_eq!(format::usd_signed(95.5), "$95.50"); + assert_eq!(format::usd_signed(-1.25), "-$1.25"); + assert_eq!(format::usd_signed(0.0), "$0.00"); + assert_eq!(format::usd_signed(-0.001), "$0.00"); + assert_eq!(format::usd_signed(4.5), "$4.50"); } #[test] diff --git a/rust/src/providers/xkiro/mod.rs b/rust/src/providers/xkiro/mod.rs index 671a81ebcfc..7ed0ae4bd70 100644 --- a/rust/src/providers/xkiro/mod.rs +++ b/rust/src/providers/xkiro/mod.rs @@ -19,7 +19,7 @@ use crate::core::{ FetchContext, Provider, ProviderDisplayDetail, ProviderError, ProviderFetchResult, ProviderId, RateWindow, SourceMode, UsageSnapshot, }; -use crate::providers::{BoundedBodyError, read_bounded_response}; +use crate::providers::{BoundedBodyError, format, read_bounded_response}; const USAGE_URL: &str = "https://api.xkiro.com/v1/usage"; const CREDENTIAL_TARGET: &str = "codexbar-xkiro"; @@ -241,18 +241,6 @@ fn next_utc_midnight(now: DateTime) -> DateTime { + Duration::days(1) } -fn format_count(value: u64) -> String { - let raw = value.to_string(); - let mut out = String::with_capacity(raw.len() + raw.len() / 3); - for (index, digit) in raw.chars().rev().enumerate() { - if index > 0 && index % 3 == 0 { - out.push(','); - } - out.push(digit); - } - out.chars().rev().collect() -} - /// Percent of the daily allowance used. A zero allowance is exhausted. fn used_percent(used: u64, limit: u64) -> f64 { if limit == 0 { @@ -272,7 +260,7 @@ fn primary_window(usage: &FreeTokenUsage, now: DateTime) -> RateWindow { ), // Missing counters stay unknown: no percentage is invented. _ => RateWindow::informational(if let Some(remaining) = usage.remaining { - format!("{} tokens remaining", format_count(remaining)) + format!("{} tokens remaining", format::count(remaining)) } else if usage.uncapped { "No daily cap reported".to_string() } else { @@ -291,7 +279,7 @@ fn result_from_usage(usage: &FreeTokenUsage, now: DateTime) -> ProviderFetc } let allowance = match usage.limit_per_day { - Some(limit) => Some(format_count(limit)), + Some(limit) => Some(format::count(limit)), None if usage.uncapped => Some("No cap reported".to_string()), None => None, }; @@ -299,13 +287,13 @@ fn result_from_usage(usage: &FreeTokenUsage, now: DateTime) -> ProviderFetc ( "tokens-used-today", "Tokens used today", - usage.used_today.map(format_count), + usage.used_today.map(format::count), ), ("daily-allowance", "Daily allowance", allowance), ( "tokens-remaining", "Tokens remaining", - usage.remaining.map(format_count), + usage.remaining.map(format::count), ), ("daily-reset", "Daily reset", Some("00:00 UTC".to_string())), ]; diff --git a/rust/src/providers/zenmux/mod.rs b/rust/src/providers/zenmux/mod.rs index e831e435653..9dc7ce1692e 100644 --- a/rust/src/providers/zenmux/mod.rs +++ b/rust/src/providers/zenmux/mod.rs @@ -13,6 +13,7 @@ use crate::core::{ CostSnapshot, FetchContext, Provider, ProviderDisplayDetail, ProviderError, ProviderFetchResult, ProviderId, RateWindow, SourceMode, SubscriptionMetadata, UsageSnapshot, }; +use crate::providers::format; const MANAGEMENT_BASE: &str = "https://zenmux.ai/api/v1/management"; const CREDENTIAL_TARGET: &str = "codexbar-zenmux"; @@ -200,20 +201,12 @@ fn quota_window(q: &QuotaInfo, minutes: u32) -> RateWindow { w.resets_at = parse_iso(q.resets_at.as_deref()); w.reset_description = Some(format!( "{} / {} flows", - format_amount(q.used_flows), - format_amount(q.max_flows) + format::whole_or_two_decimals(q.used_flows), + format::whole_or_two_decimals(q.max_flows) )); w } -fn format_amount(value: f64) -> String { - if (value - value.round()).abs() < f64::EPSILON { - format!("{:.0}", value) - } else { - format!("{:.2}", value) - } -} - fn snapshot_from_subscription(value: &serde_json::Value) -> Result { let env: SubscriptionEnvelope = serde_json::from_value(value.clone()) .map_err(|e| ProviderError::Parse(format!("Failed to parse ZenMux subscription: {e}")))?; @@ -298,20 +291,11 @@ fn payg_from_balance( let row = ProviderDisplayDetail::new( "payg_balance", "Pay-as-you-go balance", - format_signed_usd(env.data.total_credits), + format::usd_signed(env.data.total_credits), ); Ok((cost, row)) } -fn format_signed_usd(value: f64) -> String { - let magnitude = format!("{:.2}", value.abs()); - if value < 0.0 && magnitude != "0.00" { - format!("-${magnitude}") - } else { - format!("${magnitude}") - } -} - fn capitalize(s: &str) -> String { let mut c = s.chars(); match c.next() { @@ -536,13 +520,16 @@ mod tests { #[test] fn flow_labels_use_two_decimals_or_integers() { - assert_eq!(format_amount(57.2), "57.20"); - assert_eq!(format_amount(6182.0), "6182"); - assert_eq!(format_amount(0.125), "0.12"); - assert_eq!(format_amount(1.375), "1.38"); - assert_eq!(format_amount(-0.125), "-0.12"); - assert_eq!(format_amount(-0.0), "-0"); - assert_eq!(format_amount(1e21), "1000000000000000000000"); + assert_eq!(format::whole_or_two_decimals(57.2), "57.20"); + assert_eq!(format::whole_or_two_decimals(6182.0), "6182"); + assert_eq!(format::whole_or_two_decimals(0.125), "0.12"); + assert_eq!(format::whole_or_two_decimals(1.375), "1.38"); + assert_eq!(format::whole_or_two_decimals(-0.125), "-0.12"); + assert_eq!(format::whole_or_two_decimals(-0.0), "-0"); + assert_eq!( + format::whole_or_two_decimals(1e21), + "1000000000000000000000" + ); } #[test] From c1934333d6b1c5d73090a173000af6f9ab6685c9 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:51:11 +0700 Subject: [PATCH 06/40] Share cookie-header prefix and pair steps; reuse core curl detection --- rust/src/providers/alibabatokenplan/mod.rs | 10 +--- rust/src/providers/codebuddy/mod.rs | 19 ++------ rust/src/providers/commandcode/mod.rs | 15 ++---- rust/src/providers/longcat/mod.rs | 11 ++--- rust/src/providers/mod.rs | 55 ++++++++++++++++++---- rust/src/providers/ollama/cookies.rs | 19 ++------ rust/src/providers/qoder/mod.rs | 20 ++------ rust/src/providers/qwencloud/mod.rs | 10 +--- rust/src/providers/replicate/mod.rs | 9 +--- rust/src/providers/sakana/mod.rs | 19 +------- rust/src/providers/t3chat.rs | 10 +--- 11 files changed, 72 insertions(+), 125 deletions(-) diff --git a/rust/src/providers/alibabatokenplan/mod.rs b/rust/src/providers/alibabatokenplan/mod.rs index 7e096fd7e4e..fb21b27ec6b 100644 --- a/rust/src/providers/alibabatokenplan/mod.rs +++ b/rust/src/providers/alibabatokenplan/mod.rs @@ -24,7 +24,7 @@ use crate::core::{ FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, RateWindow, SourceMode, UsageSnapshot, }; -use crate::providers::browser_cookie_header; +use crate::providers::{browser_cookie_header, strip_cookie_prefix}; use region::AlibabaTokenPlanRegion as Region; @@ -488,13 +488,7 @@ fn find_failing_success_frame(value: &Value) -> Option<&Value> { } fn normalize_cookie_header(raw: &str) -> Option { - let mut header = raw.trim(); - if header - .get(.."cookie:".len()) - .is_some_and(|prefix| prefix.eq_ignore_ascii_case("cookie:")) - { - header = header["cookie:".len()..].trim(); - } + let header = strip_cookie_prefix(raw.trim()); (!header.is_empty() && header.contains('=')).then(|| header.to_string()) } diff --git a/rust/src/providers/codebuddy/mod.rs b/rust/src/providers/codebuddy/mod.rs index e8a09f0c15e..ef1d0406949 100644 --- a/rust/src/providers/codebuddy/mod.rs +++ b/rust/src/providers/codebuddy/mod.rs @@ -33,6 +33,7 @@ use crate::core::{ FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, RateWindow, SourceMode, UsageSnapshot, }; +use crate::providers::{normalize_cookie_pairs, strip_cookie_prefix}; const CN_API: &str = "https://www.codebuddy.cn/billing/meter/get-user-resource"; const CN_ORIGIN: &str = "https://www.codebuddy.cn"; @@ -414,22 +415,8 @@ fn read_cookie_file() -> Option { fn normalize_cookie_header(raw: &str) -> Option { // Strip BOM, caret-escapes from Windows "Copy as cURL", and Cookie: prefix. - let mut header = raw.trim().trim_start_matches('\u{feff}').replace('^', ""); - header = header.trim().to_string(); - let lower = header.to_ascii_lowercase(); - if lower.starts_with("cookie:") { - header = header["cookie:".len()..].trim().to_string(); - } - let pairs = header - .split(';') - .filter_map(|chunk| { - let (name, value) = chunk.trim().split_once('=')?; - let name = name.trim(); - let value = value.trim(); - (!name.is_empty() && !value.is_empty()).then(|| format!("{name}={value}")) - }) - .collect::>(); - (!pairs.is_empty()).then(|| pairs.join("; ")) + let header = raw.trim().trim_start_matches('\u{feff}').replace('^', ""); + normalize_cookie_pairs(strip_cookie_prefix(header.trim())) } /// First 8 bytes of SHA-256(cookie), hex — 16 chars, no cookie material. diff --git a/rust/src/providers/commandcode/mod.rs b/rust/src/providers/commandcode/mod.rs index 9220e323567..05a090d08fb 100644 --- a/rust/src/providers/commandcode/mod.rs +++ b/rust/src/providers/commandcode/mod.rs @@ -13,8 +13,9 @@ use std::sync::{LazyLock, Mutex}; use crate::core::{ CostSnapshot, FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, - RateWindow, SourceMode, UsageSnapshot, + RateWindow, SourceMode, UsageSnapshot, looks_like_curl_capture, }; +use crate::providers::strip_cookie_prefix; use plan_cache::CommandCodePlanCache; const COMMAND_CODE_API_BASE: &str = "https://api.commandcode.ai"; @@ -202,12 +203,7 @@ fn normalize_cookie_header(raw: &str) -> Option { } else if looks_like_curl_capture(header) { return None; } - if header - .get(.."cookie:".len()) - .is_some_and(|prefix| prefix.eq_ignore_ascii_case("cookie:")) - { - header = header["cookie:".len()..].trim(); - } + header = strip_cookie_prefix(header); if header.is_empty() { return None; } @@ -268,11 +264,6 @@ fn cookie_header_from_curl(raw: &str) -> Option { }) } -fn looks_like_curl_capture(raw: &str) -> bool { - let lower = raw.trim_start().to_ascii_lowercase(); - lower.starts_with("curl ") || lower.starts_with("curl.exe ") -} - fn split_header(field: &str) -> Option<(&str, &str)> { let colon = field.find(':')?; Some((field[..colon].trim(), field[colon + 1..].trim())) diff --git a/rust/src/providers/longcat/mod.rs b/rust/src/providers/longcat/mod.rs index 8e7a224e0a6..a37d2eb3d7e 100644 --- a/rust/src/providers/longcat/mod.rs +++ b/rust/src/providers/longcat/mod.rs @@ -11,6 +11,7 @@ use crate::core::{ FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, RateWindow, SourceMode, UsageSnapshot, }; +use crate::providers::strip_cookie_prefix; const HOST: &str = "https://longcat.chat"; const USER_CURRENT: &str = "/api/v1/user-current"; @@ -158,14 +159,8 @@ impl Provider for LongCatProvider { } fn normalize_cookie_header(raw: &str) -> Option { - let mut header = raw.trim().to_string(); - if header - .get(.."cookie:".len()) - .is_some_and(|p| p.eq_ignore_ascii_case("cookie:")) - { - header = header["cookie:".len()..].trim().to_string(); - } - (!header.is_empty()).then_some(header) + let header = strip_cookie_prefix(raw.trim()); + (!header.is_empty()).then(|| header.to_string()) } fn envelope_code(value: &Value) -> Result, ProviderError> { diff --git a/rust/src/providers/mod.rs b/rust/src/providers/mod.rs index ad0213a293b..cca5e48f76f 100755 --- a/rust/src/providers/mod.rs +++ b/rust/src/providers/mod.rs @@ -306,16 +306,35 @@ pub(crate) fn cookie_values<'a>(cookie_header: &'a str, name: &str) -> Vec<&'a s /// Empty values and control characters are rejected before the value reaches an /// HTTP client. pub(crate) fn normalize_cookie_header(raw: &str) -> Option { - let trimmed = raw.trim(); - let value = trimmed - .get(.."cookie:".len()) - .filter(|prefix| prefix.eq_ignore_ascii_case("cookie:")) - .map_or(trimmed, |_| &trimmed["cookie:".len()..]) - .trim(); - + let value = strip_cookie_prefix(raw.trim()); (!value.is_empty() && !value.chars().any(char::is_control)).then(|| value.to_string()) } +/// Drops a leading, case-insensitive `Cookie:` label and the whitespace after +/// it. Input without the label is returned unchanged. +pub(crate) fn strip_cookie_prefix(header: &str) -> &str { + match header.get(.."cookie:".len()) { + Some(prefix) if prefix.eq_ignore_ascii_case("cookie:") => header["cookie:".len()..].trim(), + _ => header, + } +} + +/// Rebuilds a cookie header from its `name=value` pairs, trimming both sides +/// and dropping chunks with no `=`, an empty name or an empty value. `None` +/// when no pair remains. +pub(crate) fn normalize_cookie_pairs(header: &str) -> Option { + let pairs = header + .split(';') + .filter_map(|chunk| { + let (name, value) = chunk.trim().split_once('=')?; + let name = name.trim(); + let value = value.trim(); + (!name.is_empty() && !value.is_empty()).then(|| format!("{name}={value}")) + }) + .collect::>(); + (!pairs.is_empty()).then(|| pairs.join("; ")) +} + pub(crate) fn browser_cookies_for_domain( domain: &str, ) -> Result, crate::core::ProviderError> { @@ -521,7 +540,10 @@ pub(crate) fn extract_renewal(text: &str) -> Option Option { return None; } - if header - .get(.."cookie:".len()) - .is_some_and(|prefix| prefix.eq_ignore_ascii_case("cookie:")) - { - header = header["cookie:".len()..].trim(); - } + header = strip_cookie_prefix(header); if header.is_empty() { return None; @@ -62,16 +58,7 @@ pub(super) fn normalize_cookie_header(input: &str) -> Option { let cleaned = header .split(';') .map(str::trim) - .map(|segment| { - if segment - .get(.."cookie:".len()) - .is_some_and(|prefix| prefix.eq_ignore_ascii_case("cookie:")) - { - segment["cookie:".len()..].trim().to_string() - } else { - segment.to_string() - } - }) + .map(|segment| strip_cookie_prefix(segment).to_string()) .filter(|segment| !segment.is_empty()) .collect::>() .join("; "); diff --git a/rust/src/providers/qoder/mod.rs b/rust/src/providers/qoder/mod.rs index e9dd2b26179..fce64939863 100644 --- a/rust/src/providers/qoder/mod.rs +++ b/rust/src/providers/qoder/mod.rs @@ -17,6 +17,7 @@ use crate::core::{ FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, RateWindow, SourceMode, UsageSnapshot, }; +use crate::providers::{normalize_cookie_pairs, strip_cookie_prefix}; const BX_VERSION: &str = "2.5.35"; const REQUEST_TIMEOUT: Duration = Duration::from_secs(15); @@ -191,26 +192,11 @@ impl Default for QoderProvider { } fn normalize_cookie_header(raw: &str) -> Option { - let mut header = raw.trim(); + let header = raw.trim(); if header.chars().any(char::is_control) { return None; } - if header - .get(.."cookie:".len()) - .is_some_and(|prefix| prefix.eq_ignore_ascii_case("cookie:")) - { - header = header["cookie:".len()..].trim(); - } - let pairs = header - .split(';') - .filter_map(|chunk| { - let (name, value) = chunk.trim().split_once('=')?; - let name = name.trim(); - let value = value.trim(); - (!name.is_empty() && !value.is_empty()).then(|| format!("{name}={value}")) - }) - .collect::>(); - (!pairs.is_empty()).then(|| pairs.join("; ")) + normalize_cookie_pairs(strip_cookie_prefix(header)) } fn snapshot_from_payload( diff --git a/rust/src/providers/qwencloud/mod.rs b/rust/src/providers/qwencloud/mod.rs index 8f3b52e39bd..702ce930dbd 100644 --- a/rust/src/providers/qwencloud/mod.rs +++ b/rust/src/providers/qwencloud/mod.rs @@ -17,7 +17,7 @@ use crate::core::{ FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, RateWindow, SourceMode, UsageSnapshot, }; -use crate::providers::browser_cookie_header; +use crate::providers::{browser_cookie_header, strip_cookie_prefix}; const GATEWAY_BASE_URL: &str = "https://home.qwencloud.com"; const DATA_GATEWAY_BASE_URL: &str = "https://cs-data.qwencloud.com"; @@ -658,13 +658,7 @@ fn throw_if_error_payload(value: &Value) -> Result<(), ProviderError> { } fn normalize_cookie_header(raw: &str) -> Option { - let mut header = raw.trim(); - if header - .get(.."cookie:".len()) - .is_some_and(|prefix| prefix.eq_ignore_ascii_case("cookie:")) - { - header = header["cookie:".len()..].trim(); - } + let mut header = strip_cookie_prefix(raw.trim()); if (header.starts_with('"') && header.ends_with('"')) || (header.starts_with('\'') && header.ends_with('\'')) { diff --git a/rust/src/providers/replicate/mod.rs b/rust/src/providers/replicate/mod.rs index 7a4a07220cb..29eefeefdca 100644 --- a/rust/src/providers/replicate/mod.rs +++ b/rust/src/providers/replicate/mod.rs @@ -20,6 +20,7 @@ use crate::core::{ CostSnapshot, FetchContext, ManualEmptyCookiePolicy, Provider, ProviderDisplayDetail, ProviderError, ProviderFetchResult, ProviderId, RateWindow, SourceMode, UsageSnapshot, }; +use crate::providers::strip_cookie_prefix; pub(crate) const BILLING_URL: &str = "https://replicate.com/account/billing"; const REPLICATE_ORIGIN: &str = "https://replicate.com"; @@ -528,13 +529,7 @@ fn result_from_billing( } fn normalize_cookie_header(raw: &str) -> Option { - let mut value = raw.trim(); - if value - .get(.."cookie:".len()) - .is_some_and(|prefix| prefix.eq_ignore_ascii_case("cookie:")) - { - value = value["cookie:".len()..].trim(); - } + let value = strip_cookie_prefix(raw.trim()); let mut pairs = Vec::new(); for part in value.split(';') { let part = part.trim(); diff --git a/rust/src/providers/sakana/mod.rs b/rust/src/providers/sakana/mod.rs index a6e0bfb7166..42746ff1471 100644 --- a/rust/src/providers/sakana/mod.rs +++ b/rust/src/providers/sakana/mod.rs @@ -15,6 +15,7 @@ use crate::core::{ FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, RateWindow, SourceMode, UsageSnapshot, }; +use crate::providers::{normalize_cookie_pairs, strip_cookie_prefix}; pub(crate) const BILLING_URL: &str = "https://console.sakana.ai/billing"; const PAYG_QUERY: &str = "tab=payAsYouGo"; @@ -114,23 +115,7 @@ impl Default for SakanaProvider { } fn normalize_cookie_header(raw: &str) -> Option { - let mut header = raw.trim(); - if header - .get(.."cookie:".len()) - .is_some_and(|prefix| prefix.eq_ignore_ascii_case("cookie:")) - { - header = header["cookie:".len()..].trim(); - } - let pairs = header - .split(';') - .filter_map(|chunk| { - let (name, value) = chunk.trim().split_once('=')?; - let name = name.trim(); - let value = value.trim(); - (!name.is_empty() && !value.is_empty()).then(|| format!("{name}={value}")) - }) - .collect::>(); - (!pairs.is_empty()).then(|| pairs.join("; ")) + normalize_cookie_pairs(strip_cookie_prefix(raw.trim())) } fn looks_signed_out(text: &str) -> bool { diff --git a/rust/src/providers/t3chat.rs b/rust/src/providers/t3chat.rs index 2e8e478b153..9d0ca57b2fe 100644 --- a/rust/src/providers/t3chat.rs +++ b/rust/src/providers/t3chat.rs @@ -12,7 +12,7 @@ use crate::core::{ FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, RateWindow, SourceMode, UsageSnapshot, }; -use crate::providers::browser_cookie_header; +use crate::providers::{browser_cookie_header, strip_cookie_prefix}; const BASE_URL: &str = "https://t3.chat"; const CUSTOMER_DATA_URL: &str = "https://t3.chat/api/trpc/getCustomerData"; @@ -261,13 +261,7 @@ impl T3ChatProvider { } fn normalize_cookie_header(raw: &str) -> Option { - let mut header = raw.trim(); - if header - .get(.."cookie:".len()) - .is_some_and(|prefix| prefix.eq_ignore_ascii_case("cookie:")) - { - header = header["cookie:".len()..].trim(); - } + let header = strip_cookie_prefix(raw.trim()); (!header.is_empty() && header.contains('=')).then(|| header.to_string()) } From 7bfe6de220e8954cbf269f7fa68e5c49988befce Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:01:34 +0700 Subject: [PATCH 07/40] Share provider mock helpers in tests --- rust/src/providers/abacus/tests.rs | 66 ++++++------- rust/src/providers/aixy/tests.rs | 37 ++++--- rust/src/providers/deepinfra/mod.rs | 23 +++-- rust/src/providers/devpass/tests.rs | 31 +++--- .../src/providers/grok/credits_proxy_tests.rs | 74 ++++---------- .../src/providers/grok/product_usage_tests.rs | 81 ++++++---------- rust/src/providers/helmcode.rs | 97 ++++++------------- rust/src/providers/hyper/tests.rs | 7 +- rust/src/providers/llmman/tests.rs | 51 +++------- rust/src/providers/mod.rs | 2 + rust/src/providers/neuralwatt/tests.rs | 23 +---- rust/src/providers/ollama/mod.rs | 28 +++--- rust/src/providers/openaiapi/tests.rs | 35 ++----- rust/src/providers/perplexity/tests.rs | 22 ++--- rust/src/providers/raycast/tests.rs | 79 ++++++++------- rust/src/providers/test_support.rs | 67 +++++++++++++ rust/src/providers/xkiro/tests.rs | 15 +-- rust/src/providers/zed/tests.rs | 36 +++---- 18 files changed, 329 insertions(+), 445 deletions(-) create mode 100644 rust/src/providers/test_support.rs diff --git a/rust/src/providers/abacus/tests.rs b/rust/src/providers/abacus/tests.rs index 6bbf77c33b6..fea5eaecb56 100644 --- a/rust/src/providers/abacus/tests.rs +++ b/rust/src/providers/abacus/tests.rs @@ -4,6 +4,7 @@ use mockito::Matcher; use tokio::time::Instant; use super::*; +use crate::providers::test_support::mock_response; // Wire fixtures copied from upstream `TestsPlugin/AbacusPluginTests.swift` (v0.68.0). const POINTS: &str = @@ -396,17 +397,22 @@ async fn billing_failures_keep_credits_and_fallback_window() { ]; for (name, status, body) in cases { let mut server = mockito::Server::new_async().await; - server - .mock("GET", "/api/_getOrganizationComputePoints") - .with_body(POINTS) - .create_async() - .await; - server - .mock("POST", "/api/_getBillingInfo") - .with_status(status.into()) - .with_body(body) - .create_async() - .await; + mock_response( + &mut server, + "GET", + "/api/_getOrganizationComputePoints", + 200, + POINTS, + ) + .await; + mock_response( + &mut server, + "POST", + "/api/_getBillingInfo", + status.into(), + body, + ) + .await; let provider = AbacusProvider::with_origin(&server.url()); let usage = provider @@ -451,17 +457,15 @@ async fn required_failures_are_classified() { ]; for (status, body, check) in cases { let mut server = mockito::Server::new_async().await; - server - .mock("GET", "/api/_getOrganizationComputePoints") - .with_status(status.into()) - .with_body(body) - .create_async() - .await; - server - .mock("POST", "/api/_getBillingInfo") - .with_body(BILLING) - .create_async() - .await; + mock_response( + &mut server, + "GET", + "/api/_getOrganizationComputePoints", + status.into(), + body, + ) + .await; + mock_response(&mut server, "POST", "/api/_getBillingInfo", 200, BILLING).await; let provider = AbacusProvider::with_origin(&server.url()); let error = provider .fetch_with_cookies("sessionid=fixture", Duration::from_secs(2)) @@ -499,11 +503,7 @@ async fn failed_candidates_advance_to_the_next_session() { let mut server = mockito::Server::new_async().await; let stale = credits_for(&mut server, "session=stale", stale_status, stale_body).await; let fresh = credits_for(&mut server, "session=fresh", 200, POINTS).await; - server - .mock("POST", "/api/_getBillingInfo") - .with_body(BILLING) - .create_async() - .await; + mock_response(&mut server, "POST", "/api/_getBillingInfo", 200, BILLING).await; let provider = AbacusProvider::with_origin(&server.url()); let usage = provider @@ -527,11 +527,7 @@ async fn exhausted_candidates_report_the_last_error() { let mut server = mockito::Server::new_async().await; let first = credits_for(&mut server, "session=one", 500, "boom").await; let second = credits_for(&mut server, "session=two", 401, "").await; - server - .mock("POST", "/api/_getBillingInfo") - .with_body(BILLING) - .create_async() - .await; + mock_response(&mut server, "POST", "/api/_getBillingInfo", 200, BILLING).await; let provider = AbacusProvider::with_origin(&server.url()); let error = provider @@ -574,11 +570,7 @@ async fn manual_cookie_is_exclusive_and_errors_propagate() { .expect(1) .create_async() .await; - server - .mock("POST", "/api/_getBillingInfo") - .with_body(BILLING) - .create_async() - .await; + mock_response(&mut server, "POST", "/api/_getBillingInfo", 200, BILLING).await; let provider = AbacusProvider::with_origin(&server.url()); let error = provider diff --git a/rust/src/providers/aixy/tests.rs b/rust/src/providers/aixy/tests.rs index f0b6f6ecb9d..e293c8c0ac4 100644 --- a/rust/src/providers/aixy/tests.rs +++ b/rust/src/providers/aixy/tests.rs @@ -1,5 +1,6 @@ use super::*; use crate::core::ProviderDisplayDetail; +use crate::providers::test_support::{mock_response, mock_response_expect}; use mockito::Matcher; use serde_json::{Value, json}; @@ -438,12 +439,14 @@ async fn statuses_are_classified_without_echoing_the_body() { (418, "failed (HTTP 418"), ] { let mut server = mockito::Server::new_async().await; - let _mock = server - .mock("GET", "/v1/usage") - .with_status(status) - .with_body("private upstream body") - .create_async() - .await; + let _mock = mock_response( + &mut server, + "GET", + "/v1/usage", + status, + "private upstream body", + ) + .await; let provider = AixyProvider::new(); let error = provider @@ -463,12 +466,14 @@ async fn statuses_are_classified_without_echoing_the_body() { #[tokio::test] async fn malformed_bodies_fail_without_echoing_them() { let mut server = mockito::Server::new_async().await; - let _mock = server - .mock("GET", "/v1/usage") - .with_status(200) - .with_body("private upstream body") - .create_async() - .await; + let _mock = mock_response( + &mut server, + "GET", + "/v1/usage", + 200, + "private upstream body", + ) + .await; let error = AixyProvider::new() .fetch_usage(&context(&server.url())) .await @@ -488,13 +493,7 @@ async fn does_not_forward_the_key_through_gateway_redirects() { .expect(1) .create_async() .await; - let target = server - .mock("GET", "/redirected") - .with_status(200) - .with_body(FIXTURE) - .expect(0) - .create_async() - .await; + let target = mock_response_expect(&mut server, "GET", "/redirected", 200, FIXTURE, 0).await; let error = AixyProvider::new() .fetch_usage(&context(&server.url())) diff --git a/rust/src/providers/deepinfra/mod.rs b/rust/src/providers/deepinfra/mod.rs index 07397727d28..c0345802fae 100644 --- a/rust/src/providers/deepinfra/mod.rs +++ b/rust/src/providers/deepinfra/mod.rs @@ -396,6 +396,7 @@ fn parse_snapshot_for_testing( #[cfg(test)] mod tests { use super::*; + use crate::providers::test_support::{mock_response_expect, mock_status_expect}; use std::sync::{ Arc, atomic::{AtomicUsize, Ordering}, @@ -594,13 +595,15 @@ mod tests { .expect(1) .create_async() .await; - let ok = server - .mock("GET", "/payment/checklist") - .with_status(200) - .with_body(checklist_json(-5.0, 1.0, None, false, None)) - .expect(1) - .create_async() - .await; + let ok = mock_response_expect( + &mut server, + "GET", + "/payment/checklist", + 200, + checklist_json(-5.0, 1.0, None, false, None), + 1, + ) + .await; let url = format!("{}/payment/checklist", server.url()); let checklist = DeepInfraProvider::new() @@ -692,11 +695,7 @@ mod tests { #[tokio::test] async fn expired_budget_fails_without_sending_a_request() { let mut server = mockito::Server::new_async().await; - let mock = server - .mock("GET", "/payment/checklist") - .expect(0) - .create_async() - .await; + let mock = mock_status_expect(&mut server, "GET", "/payment/checklist", 200, 0).await; let url = format!("{}/payment/checklist", server.url()); let error = DeepInfraProvider::new() diff --git a/rust/src/providers/devpass/tests.rs b/rust/src/providers/devpass/tests.rs index 3f7147eea06..932c2808236 100644 --- a/rust/src/providers/devpass/tests.rs +++ b/rust/src/providers/devpass/tests.rs @@ -1,4 +1,5 @@ use super::*; +use crate::providers::test_support::{mock_response, mock_response_expect}; use chrono::{TimeZone, Utc}; use serde_json::{Value, json}; @@ -378,12 +379,7 @@ async fn fetch_never_echoes_an_error_body() { (503, "Other"), ] { let mut server = mockito::Server::new_async().await; - server - .mock("GET", "/v1/key") - .with_status(status) - .with_body("private-response") - .create_async() - .await; + mock_response(&mut server, "GET", "/v1/key", status, "private-response").await; let error = fetch_key(&DevPassProvider::new().client, &key_url(&server), "k") .await @@ -403,13 +399,8 @@ async fn fetch_never_echoes_an_error_body() { #[tokio::test] async fn fetch_does_not_follow_redirects_with_the_key() { let mut target = mockito::Server::new_async().await; - let leaked = target - .mock("GET", "/v1/key") - .with_status(200) - .with_body(body(&fixture())) - .expect(0) - .create_async() - .await; + let leaked = + mock_response_expect(&mut target, "GET", "/v1/key", 200, body(&fixture()), 0).await; let mut origin = mockito::Server::new_async().await; origin .mock("GET", "/v1/key") @@ -429,12 +420,14 @@ async fn fetch_does_not_follow_redirects_with_the_key() { #[tokio::test] async fn fetch_rejects_an_oversized_body() { let mut server = mockito::Server::new_async().await; - server - .mock("GET", "/v1/key") - .with_status(200) - .with_body(vec![b' '; MAX_BODY_BYTES + 1]) - .create_async() - .await; + mock_response( + &mut server, + "GET", + "/v1/key", + 200, + vec![b' '; MAX_BODY_BYTES + 1], + ) + .await; let error = fetch_key(&DevPassProvider::new().client, &key_url(&server), "k") .await diff --git a/rust/src/providers/grok/credits_proxy_tests.rs b/rust/src/providers/grok/credits_proxy_tests.rs index 1623ec0e772..1fbe6d8d8ec 100644 --- a/rust/src/providers/grok/credits_proxy_tests.rs +++ b/rust/src/providers/grok/credits_proxy_tests.rs @@ -1,6 +1,9 @@ use super::credits_proxy::{BearerBilling, parse_credits_response}; use super::tests::billing_response_with_percent; use super::*; +use crate::providers::test_support::{ + mock_response, mock_response_expect, mock_status, mock_status_expect, +}; /// Upstream live capture shape (`GrokCreditsProxyFetcherTests`), including /// fields the port does not read. @@ -187,12 +190,7 @@ async fn bearer_billing_sends_the_cli_proxy_request_and_skips_grpc() { .expect(1) .create_async() .await; - let grpc = server - .mock("POST", "/billing") - .with_status(200) - .expect(0) - .create_async() - .await; + let grpc = mock_status_expect(&mut server, "POST", "/billing", 200, 0).await; let bearer = provider_for(&server) .fetch_bearer_billing(&GrokCredentials::from_bearer("token-123")) @@ -212,11 +210,7 @@ async fn bearer_billing_sends_the_cli_proxy_request_and_skips_grpc() { async fn proxy_failure_falls_back_to_grpc_billing() { for status in [401, 500] { let mut server = mockito::Server::new_async().await; - let proxy = server - .mock("GET", "/credits") - .with_status(status) - .create_async() - .await; + let proxy = mock_status(&mut server, "GET", "/credits", status).await; let grpc = server .mock("POST", "/billing") .match_header("authorization", "Bearer token-123") @@ -240,19 +234,16 @@ async fn proxy_failure_falls_back_to_grpc_billing() { #[tokio::test] async fn period_only_answer_adopts_the_grpc_percent_and_keeps_proxy_metadata() { let mut server = mockito::Server::new_async().await; - server - .mock("GET", "/credits") - .with_status(200) - .with_body(PERIOD_ONLY_BODY) - .create_async() - .await; - let grpc = server - .mock("POST", "/billing") - .with_status(200) - .with_body(billing_response_with_percent(90.0)) - .expect(1) - .create_async() - .await; + mock_response(&mut server, "GET", "/credits", 200, PERIOD_ONLY_BODY).await; + let grpc = mock_response_expect( + &mut server, + "POST", + "/billing", + 200, + billing_response_with_percent(90.0), + 1, + ) + .await; let bearer = provider_for(&server) .fetch_bearer_billing(&GrokCredentials::from_bearer("token-123")) @@ -271,18 +262,8 @@ async fn period_only_answer_stays_unknown_when_grpc_has_no_percent_or_fails() { // An empty gRPC-web frame carries no percent; a 401 is a failed retry. for (status, body) in [(200, vec![0, 0, 0, 0, 0]), (401, Vec::new())] { let mut server = mockito::Server::new_async().await; - server - .mock("GET", "/credits") - .with_status(200) - .with_body(PERIOD_ONLY_BODY) - .create_async() - .await; - let grpc = server - .mock("POST", "/billing") - .with_status(status) - .with_body(body) - .create_async() - .await; + mock_response(&mut server, "GET", "/credits", 200, PERIOD_ONLY_BODY).await; + let grpc = mock_response(&mut server, "POST", "/billing", status, body).await; let bearer = provider_for(&server) .fetch_bearer_billing(&GrokCredentials::from_bearer("token-123")) @@ -299,16 +280,8 @@ async fn period_only_answer_stays_unknown_when_grpc_has_no_percent_or_fails() { #[tokio::test] async fn expired_credentials_are_never_sent() { let mut server = mockito::Server::new_async().await; - let proxy = server - .mock("GET", "/credits") - .expect(0) - .create_async() - .await; - let grpc = server - .mock("POST", "/billing") - .expect(0) - .create_async() - .await; + let proxy = mock_status_expect(&mut server, "GET", "/credits", 200, 0).await; + let grpc = mock_status_expect(&mut server, "POST", "/billing", 200, 0).await; let mut credentials = GrokCredentials::from_bearer("stale-token"); credentials.expires_at = Some(Utc::now() - chrono::Duration::minutes(1)); @@ -329,14 +302,7 @@ async fn expired_credentials_are_never_sent() { #[tokio::test] async fn credits_plan_labels_oauth_results_without_settings_lookup() { let mut server = mockito::Server::new_async().await; - server - .mock("GET", "/credits") - .with_status(200) - .with_body( - r#"{"config":{"creditUsagePercent":40,"billingPeriodEnd":"2026-08-13T00:00:00Z"},"subscriptionTier":"SUPERGROK_HEAVY"}"#, - ) - .create_async() - .await; + mock_response(&mut server, "GET", "/credits", 200, r#"{"config":{"creditUsagePercent":40,"billingPeriodEnd":"2026-08-13T00:00:00Z"},"subscriptionTier":"SUPERGROK_HEAVY"}"#,).await; let context = FetchContext { include_credits: false, ..FetchContext::default() diff --git a/rust/src/providers/grok/product_usage_tests.rs b/rust/src/providers/grok/product_usage_tests.rs index 6f7ee4dbb03..d47268b6cc3 100644 --- a/rust/src/providers/grok/product_usage_tests.rs +++ b/rust/src/providers/grok/product_usage_tests.rs @@ -2,6 +2,7 @@ use super::credits_proxy::{BearerBilling, parse_credits_response}; use super::product_usage::{GrokProductUsage, display_details}; use super::tests::billing_response_with_percent; use super::*; +use crate::providers::test_support::{mock_response, mock_status}; /// Upstream live capture (`LiveMultiProductCreditsPayload.p6`). const LIVE_MULTI_PRODUCT_BODY: &str = r#"{"config":{"currentPeriod":{"type":"USAGE_PERIOD_TYPE_WEEKLY","start":"2026-09-20T18:42:45.537749+00:00","end":"2026-09-27T18:42:45.537749+00:00"},"creditUsagePercent":6.0,"onDemandCap":{"val":0},"onDemandUsed":{"val":0},"productUsage":[{"product":"GrokChat","usagePercent":4.0},{"product":"GrokBuild","usagePercent":2.0}],"isUnifiedBillingUser":true,"prepaidBalance":{"val":0},"topUpMethod":"TOP_UP_METHOD_SAVED_PAYMENT_METHOD","billingPeriodStart":"2026-09-20T18:42:45.537749+00:00","billingPeriodEnd":"2026-09-27T18:42:45.537749+00:00"}}"#; @@ -251,12 +252,7 @@ fn context_without_credits() -> FetchContext { #[tokio::test] async fn bearer_result_shows_the_breakdown_as_plain_rows_under_one_bar() { let mut server = mockito::Server::new_async().await; - server - .mock("GET", "/credits") - .with_status(200) - .with_body(LIVE_MULTI_PRODUCT_BODY) - .create_async() - .await; + mock_response(&mut server, "GET", "/credits", 200, LIVE_MULTI_PRODUCT_BODY).await; let result = provider_for(&server) .fetch_with_auth( @@ -287,12 +283,7 @@ async fn bearer_result_shows_the_breakdown_as_plain_rows_under_one_bar() { #[tokio::test] async fn reset_credit_enrichment_preserves_the_breakdown() { let mut server = mockito::Server::new_async().await; - server - .mock("GET", "/credits") - .with_status(200) - .with_body(LIVE_MULTI_PRODUCT_BODY) - .create_async() - .await; + mock_response(&mut server, "GET", "/credits", 200, LIVE_MULTI_PRODUCT_BODY).await; let result = provider_for(&server) .fetch_with_auth( &GrokCredentials::from_bearer("token-123"), @@ -315,20 +306,15 @@ async fn reset_credit_enrichment_preserves_the_breakdown() { #[tokio::test] async fn grpc_percent_never_borrows_the_proxy_products() { let mut server = mockito::Server::new_async().await; - server - .mock("GET", "/credits") - .with_status(200) - .with_body( - r#"{"config":{"currentPeriod":{"start":"2026-08-06T00:00:00Z","end":"2026-08-13T00:00:00Z"},"productUsage":[{"product":"GrokBuild","usagePercent":12}]}}"#, - ) - .create_async() - .await; - server - .mock("POST", "/billing") - .with_status(200) - .with_body(billing_response_with_percent(12.0)) - .create_async() - .await; + mock_response(&mut server, "GET", "/credits", 200, r#"{"config":{"currentPeriod":{"start":"2026-08-06T00:00:00Z","end":"2026-08-13T00:00:00Z"},"productUsage":[{"product":"GrokBuild","usagePercent":12}]}}"#,).await; + mock_response( + &mut server, + "POST", + "/billing", + 200, + billing_response_with_percent(12.0), + ) + .await; let result = provider_for(&server) .fetch_with_auth( @@ -379,20 +365,15 @@ fn live_breakdown() -> Vec<(String, String)> { #[tokio::test] async fn grpc_breakdown_is_adopted_with_the_grpc_percent_on_a_period_only_proxy_answer() { let mut server = mockito::Server::new_async().await; - server - .mock("GET", "/credits") - .with_status(200) - .with_body( - r#"{"config":{"currentPeriod":{"start":"2026-08-06T00:00:00Z","end":"2026-08-13T00:00:00Z"},"productUsage":[{"product":"GrokBuild","usagePercent":12}]}}"#, - ) - .create_async() - .await; - server - .mock("POST", "/billing") - .with_status(200) - .with_body(super::billing::web_product_usage_tests::live_frame()) - .create_async() - .await; + mock_response(&mut server, "GET", "/credits", 200, r#"{"config":{"currentPeriod":{"start":"2026-08-06T00:00:00Z","end":"2026-08-13T00:00:00Z"},"productUsage":[{"product":"GrokBuild","usagePercent":12}]}}"#,).await; + mock_response( + &mut server, + "POST", + "/billing", + 200, + super::billing::web_product_usage_tests::live_frame(), + ) + .await; let result = provider_for(&server) .fetch_with_auth( @@ -410,17 +391,15 @@ async fn grpc_breakdown_is_adopted_with_the_grpc_percent_on_a_period_only_proxy_ #[tokio::test] async fn grpc_only_bearer_fallback_shows_the_breakdown() { let mut server = mockito::Server::new_async().await; - server - .mock("GET", "/credits") - .with_status(500) - .create_async() - .await; - server - .mock("POST", "/billing") - .with_status(200) - .with_body(super::billing::web_product_usage_tests::live_frame()) - .create_async() - .await; + mock_status(&mut server, "GET", "/credits", 500).await; + mock_response( + &mut server, + "POST", + "/billing", + 200, + super::billing::web_product_usage_tests::live_frame(), + ) + .await; let result = provider_for(&server) .fetch_with_auth( diff --git a/rust/src/providers/helmcode.rs b/rust/src/providers/helmcode.rs index 7acf64500df..4b13e4121d8 100644 --- a/rust/src/providers/helmcode.rs +++ b/rust/src/providers/helmcode.rs @@ -437,6 +437,7 @@ fn parse_failure(field: impl AsRef) -> ProviderError { #[cfg(test)] mod tests { use super::*; + use crate::providers::test_support::{mock_response, mock_response_expect, mock_status}; use serde_json::json; const QUOTA_GOLDEN: &str = r#"{"periodStart":"2026-09-01","models":[ @@ -479,31 +480,28 @@ mod tests { workspace_id: Option<&str>, ) -> Result { let mut server = mockito::Server::new_async().await; - let quota = server - .mock("GET", "/api/usage/quota") - .with_status(200) - .with_body(QUOTA_GOLDEN) - .create_async() - .await; - let billing = server - .mock("GET", "/api/billing") - .with_status(billing_status) - .with_body(billing_body) - .create_async() - .await; - let credits = server - .mock("GET", "/api/billing/credits") - .with_status(credits_status) - .with_body(credits_body) - .expect( - if workspace_id.is_some_and(|id| id.eq_ignore_ascii_case("nanBuilders")) { - 0 - } else { - 1 - }, - ) - .create_async() - .await; + let quota = mock_response(&mut server, "GET", "/api/usage/quota", 200, QUOTA_GOLDEN).await; + let billing = mock_response( + &mut server, + "GET", + "/api/billing", + billing_status, + billing_body, + ) + .await; + let credits = mock_response_expect( + &mut server, + "GET", + "/api/billing/credits", + credits_status, + credits_body, + if workspace_id.is_some_and(|id| id.eq_ignore_ascii_case("nanBuilders")) { + 0 + } else { + 1 + }, + ) + .await; let result = provider_at(&server.url()) .fetch_usage(&fetch_context(workspace_id)) @@ -768,24 +766,9 @@ mod tests { {"model":"helm-a","cap":1000,"tokensUsed":2000,"creditTokens":20} ]}"#; let mut server = mockito::Server::new_async().await; - let quota = server - .mock("GET", "/api/usage/quota") - .with_status(200) - .with_body(quota_body) - .create_async() - .await; - let billing = server - .mock("GET", "/api/billing") - .with_status(200) - .with_body("{}") - .create_async() - .await; - let credits = server - .mock("GET", "/api/billing/credits") - .with_status(200) - .with_body("{}") - .create_async() - .await; + let quota = mock_response(&mut server, "GET", "/api/usage/quota", 200, quota_body).await; + let billing = mock_response(&mut server, "GET", "/api/billing", 200, "{}").await; + let credits = mock_response(&mut server, "GET", "/api/billing/credits", 200, "{}").await; let result = provider_at(&server.url()) .fetch_tenant(Tenant::Helmcode, "session=test-cookie") .await @@ -875,17 +858,9 @@ mod tests { async fn quota_auth_responses_still_require_authentication() { for status in [401, 403, 302] { let mut server = mockito::Server::new_async().await; - let quota = server - .mock("GET", "/api/usage/quota") - .with_status(status) - .create_async() - .await; - let billing = server - .mock("GET", "/api/billing") - .with_status(200) - .with_body(BILLING_FREE) - .create_async() - .await; + let quota = mock_status(&mut server, "GET", "/api/usage/quota", status).await; + let billing = + mock_response(&mut server, "GET", "/api/billing", 200, BILLING_FREE).await; let result = provider_at(&server.url()) .fetch_usage(&fetch_context(None)) .await; @@ -901,18 +876,8 @@ mod tests { #[tokio::test] async fn quota_schema_drift_remains_a_parse_error_when_optional_billing_fails() { let mut server = mockito::Server::new_async().await; - let quota = server - .mock("GET", "/api/usage/quota") - .with_status(200) - .with_body(r#"{"periodStart":"2026-09-01T00:00:00Z","models":[{"model":"helm-model-a","limit":1000000,"consumed":250000}]}"#) - .create_async() - .await; - let billing = server - .mock("GET", "/api/billing") - .with_status(503) - .with_body("bad JSON") - .create_async() - .await; + let quota = mock_response(&mut server, "GET", "/api/usage/quota", 200, r#"{"periodStart":"2026-09-01T00:00:00Z","models":[{"model":"helm-model-a","limit":1000000,"consumed":250000}]}"#).await; + let billing = mock_response(&mut server, "GET", "/api/billing", 503, "bad JSON").await; let result = provider_at(&server.url()) .fetch_usage(&fetch_context(None)) .await; diff --git a/rust/src/providers/hyper/tests.rs b/rust/src/providers/hyper/tests.rs index 5ad8667b88a..217a9e7f683 100644 --- a/rust/src/providers/hyper/tests.rs +++ b/rust/src/providers/hyper/tests.rs @@ -3,6 +3,7 @@ //! missing cookie, so no test reads the keyring or a real browser. use super::*; +use crate::providers::test_support::mock_status_expect; use mockito::{Matcher, Mock, Server, ServerGuard}; const KEY: &str = "fixture-key"; @@ -70,11 +71,7 @@ async fn api_mock(server: &mut ServerGuard, status: usize, body: &str, hits: usi /// Fails the test if the provider sends any request at all. async fn no_request_mock(server: &mut ServerGuard) -> Mock { - server - .mock("GET", Matcher::Any) - .expect(0) - .create_async() - .await + mock_status_expect(server, "GET", Matcher::Any, 200, 0).await } fn closed_port_url() -> String { diff --git a/rust/src/providers/llmman/tests.rs b/rust/src/providers/llmman/tests.rs index 617437fdc61..29a08b35352 100644 --- a/rust/src/providers/llmman/tests.rs +++ b/rust/src/providers/llmman/tests.rs @@ -1,4 +1,5 @@ use super::*; +use crate::providers::test_support::{mock_response, mock_status}; use mockito::Matcher; const NODE_BODY: &str = r#"{"memory":40000000000,"loaded":{"fixture/small:Q4_K_M":2000000000,"fixture/large:Q4_K_M":8000000000},"stored":{"fixture/small:Q4_K_M":2000000000,"fixture/large:Q4_K_M":8000000000,"fixture/idle":500000000}}"#; @@ -278,11 +279,7 @@ async fn open_daemon_is_queried_without_an_authorization_header() { .with_body(NODE_BODY) .create_async() .await; - server - .mock("GET", "/api/version") - .with_status(404) - .create_async() - .await; + mock_status(&mut server, "GET", "/api/version", 404).await; let result = fetch_daemon(&client(), &server.url(), None).await.unwrap(); @@ -300,16 +297,8 @@ async fn open_daemon_is_queried_without_an_authorization_header() { #[tokio::test] async fn a_v1_base_is_dropped_from_request_paths() { let mut server = mockito::Server::new_async().await; - let node = server - .mock("GET", "/llmman/node") - .with_body(NODE_BODY) - .create_async() - .await; - server - .mock("GET", "/api/version") - .with_body(VERSION_BODY) - .create_async() - .await; + let node = mock_response(&mut server, "GET", "/llmman/node", 200, NODE_BODY).await; + mock_response(&mut server, "GET", "/api/version", 200, VERSION_BODY).await; fetch_daemon(&client(), &format!("{}/v1", server.url()), None) .await @@ -328,16 +317,8 @@ async fn unusable_version_replies_are_ignored() { r#"{"pid":1}"#, ] { let mut server = mockito::Server::new_async().await; - server - .mock("GET", "/llmman/node") - .with_body(NODE_BODY) - .create_async() - .await; - server - .mock("GET", "/api/version") - .with_body(reply) - .create_async() - .await; + mock_response(&mut server, "GET", "/llmman/node", 200, NODE_BODY).await; + mock_response(&mut server, "GET", "/api/version", 200, reply).await; let result = fetch_daemon(&client(), &server.url(), None).await.unwrap(); assert!( @@ -352,12 +333,14 @@ async fn unusable_version_replies_are_ignored() { async fn node_status_error(status: usize, key: Option<&str>) -> ProviderError { let mut server = mockito::Server::new_async().await; - server - .mock("GET", "/llmman/node") - .with_status(status) - .with_body("sk-echoed-secret") - .create_async() - .await; + mock_response( + &mut server, + "GET", + "/llmman/node", + status, + "sk-echoed-secret", + ) + .await; fetch_daemon(&client(), &server.url(), key) .await .unwrap_err() @@ -402,11 +385,7 @@ async fn other_statuses_map_to_friendly_errors() { #[tokio::test] async fn unrecognized_body_is_a_parse_error() { let mut server = mockito::Server::new_async().await; - server - .mock("GET", "/llmman/node") - .with_body("not json") - .create_async() - .await; + mock_response(&mut server, "GET", "/llmman/node", 200, "not json").await; let error = fetch_daemon(&client(), &server.url(), None) .await .unwrap_err(); diff --git a/rust/src/providers/mod.rs b/rust/src/providers/mod.rs index cca5e48f76f..df60771f4ea 100755 --- a/rust/src/providers/mod.rs +++ b/rust/src/providers/mod.rs @@ -86,6 +86,8 @@ pub mod sakana; pub mod stepfun; pub mod sub2api; pub mod t3chat; +#[cfg(test)] +pub(crate) mod test_support; pub mod typesafe; pub mod v0; pub mod venice; diff --git a/rust/src/providers/neuralwatt/tests.rs b/rust/src/providers/neuralwatt/tests.rs index 4da2e6fc18a..96365e7d139 100644 --- a/rust/src/providers/neuralwatt/tests.rs +++ b/rust/src/providers/neuralwatt/tests.rs @@ -8,6 +8,7 @@ use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpListener; use super::*; +use crate::providers::test_support::{mock_response_expect, mock_status_expect}; const FIXTURES: [&str; 10] = [ r#"{ @@ -390,13 +391,7 @@ async fn transient_status_recovers_on_the_single_retry() { .expect(1) .create_async() .await; - let second = server - .mock("GET", "/v1/quota") - .with_status(200) - .with_body(FIXTURES[0]) - .expect(1) - .create_async() - .await; + let second = mock_response_expect(&mut server, "GET", "/v1/quota", 200, FIXTURES[0], 1).await; let body = fetch_quota(&test_client(), &mock_url(&server), "k", Duration::ZERO) .await @@ -411,12 +406,7 @@ async fn transient_status_recovers_on_the_single_retry() { async fn auth_and_client_errors_are_never_retried() { for (status, auth) in [(401, true), (403, true), (404, false), (400, false)] { let mut server = mockito::Server::new_async().await; - let mock = server - .mock("GET", "/v1/quota") - .with_status(status) - .expect(1) - .create_async() - .await; + let mock = mock_status_expect(&mut server, "GET", "/v1/quota", status, 1).await; let error = fetch_quota(&test_client(), &mock_url(&server), "k", Duration::ZERO) .await @@ -440,12 +430,7 @@ async fn auth_and_client_errors_are_never_retried() { #[tokio::test] async fn retry_sleep_is_dropped_with_the_fetch_future() { let mut server = mockito::Server::new_async().await; - let mock = server - .mock("GET", "/v1/quota") - .with_status(503) - .expect(1) - .create_async() - .await; + let mock = mock_status_expect(&mut server, "GET", "/v1/quota", 503, 1).await; // Without `Retry-After` the retry waits the 30 s default; cancelling the // future (as the refresh timeout does) must end the wait immediately. diff --git a/rust/src/providers/ollama/mod.rs b/rust/src/providers/ollama/mod.rs index b912869692e..9a192c7c2a3 100755 --- a/rust/src/providers/ollama/mod.rs +++ b/rust/src/providers/ollama/mod.rs @@ -543,6 +543,7 @@ fn ollama_api_key_error() -> ProviderError { mod tests { use super::*; use crate::core::LastGoodFailurePolicy; + use crate::providers::test_support::{mock_response, mock_status, mock_status_expect}; #[tokio::test] async fn settings_fetch_follows_same_origin_redirects() { @@ -553,12 +554,14 @@ mod tests { .with_header("location", "/settings/account") .create_async() .await; - let second = server - .mock("GET", "/settings/account") - .with_status(200) - .with_body("usage") - .create_async() - .await; + let second = mock_response( + &mut server, + "GET", + "/settings/account", + 200, + "usage", + ) + .await; let client = reqwest::Client::builder() .redirect(reqwest::redirect::Policy::none()) .build() @@ -672,17 +675,8 @@ mod tests { #[tokio::test] async fn rejects_unproven_validation_responses_before_catalog_fetch() { let mut server = mockito::Server::new_async().await; - let validation = server - .mock("POST", "/api/web_search") - .with_status(422) - .create_async() - .await; - let catalog = server - .mock("GET", "/api/tags") - .expect(0) - .with_status(200) - .create_async() - .await; + let validation = mock_status(&mut server, "POST", "/api/web_search", 422).await; + let catalog = mock_status_expect(&mut server, "GET", "/api/tags", 200, 0).await; let client = reqwest::Client::new(); let error = OllamaProvider::fetch_usage_api_at( diff --git a/rust/src/providers/openaiapi/tests.rs b/rust/src/providers/openaiapi/tests.rs index c45e0275976..83ff7db32de 100644 --- a/rust/src/providers/openaiapi/tests.rs +++ b/rust/src/providers/openaiapi/tests.rs @@ -1,4 +1,5 @@ use super::*; +use crate::providers::test_support::{mock_response_expect, mock_status}; use mockito::{Matcher, Server, ServerGuard}; const COSTS_PATH: &str = "/v1/organization/costs"; @@ -737,12 +738,7 @@ async fn mock_admin_status(server: &mut ServerGuard, status: usize) -> mockito:: async fn openai_unscoped_key_falls_back_to_balance_on_admin_auth_failure() { let mut server = Server::new_async().await; let _costs = mock_admin_status(&mut server, 403).await; - let grants = server - .mock("GET", GRANTS_PATH) - .with_body(GRANTS_BODY) - .expect(1) - .create_async() - .await; + let grants = mock_response_expect(&mut server, "GET", GRANTS_PATH, 200, GRANTS_BODY, 1).await; let result = provider(&server) .fetch_admin_or_balance(&admin_credential(), None, fixed_now(3_600)) @@ -763,12 +759,7 @@ async fn openai_unscoped_key_falls_back_to_balance_on_admin_auth_failure() { async fn openai_unscoped_key_falls_back_to_balance_during_admin_outage() { let mut server = Server::new_async().await; let costs = mock_admin_status(&mut server, 500).await.expect(2); - let grants = server - .mock("GET", GRANTS_PATH) - .with_body(GRANTS_BODY) - .expect(1) - .create_async() - .await; + let grants = mock_response_expect(&mut server, "GET", GRANTS_PATH, 200, GRANTS_BODY, 1).await; let result = provider(&server) .fetch_admin_or_balance(&admin_credential(), None, fixed_now(3_600)) @@ -784,12 +775,7 @@ async fn openai_unscoped_key_falls_back_to_balance_during_admin_outage() { async fn openai_project_scoped_admin_key_never_uses_the_unfiltered_balance() { let mut server = Server::new_async().await; let _costs = mock_admin_status(&mut server, 403).await; - let grants = server - .mock("GET", GRANTS_PATH) - .with_body(GRANTS_BODY) - .expect(0) - .create_async() - .await; + let grants = mock_response_expect(&mut server, "GET", GRANTS_PATH, 200, GRANTS_BODY, 0).await; let error = provider(&server) .fetch_admin_or_balance(&admin_credential(), Some("proj_abc"), fixed_now(3_600)) @@ -804,12 +790,7 @@ async fn openai_project_scoped_admin_key_never_uses_the_unfiltered_balance() { async fn openai_project_scoped_plain_key_keeps_the_balance_fallback() { let mut server = Server::new_async().await; let _costs = mock_admin_status(&mut server, 403).await; - let grants = server - .mock("GET", GRANTS_PATH) - .with_body(GRANTS_BODY) - .expect(1) - .create_async() - .await; + let grants = mock_response_expect(&mut server, "GET", GRANTS_PATH, 200, GRANTS_BODY, 1).await; let credential = ApiCredential { key: "sk-plain".to_string(), is_admin: false, @@ -828,11 +809,7 @@ async fn openai_project_scoped_plain_key_keeps_the_balance_fallback() { async fn openai_balance_failure_keeps_the_admin_outage_error() { let mut server = Server::new_async().await; let _costs = mock_admin_status(&mut server, 500).await; - let _grants = server - .mock("GET", GRANTS_PATH) - .with_status(404) - .create_async() - .await; + let _grants = mock_status(&mut server, "GET", GRANTS_PATH, 404).await; let error = provider(&server) .fetch_admin_or_balance(&admin_credential(), None, fixed_now(3_600)) diff --git a/rust/src/providers/perplexity/tests.rs b/rust/src/providers/perplexity/tests.rs index f784873d8ee..2c14f18a710 100644 --- a/rust/src/providers/perplexity/tests.rs +++ b/rust/src/providers/perplexity/tests.rs @@ -3,6 +3,7 @@ use serde_json::json; use super::cookies::{SESSION_COOKIE_NAMES, request_cookies}; use super::*; +use crate::providers::test_support::{mock_response, mock_status_expect}; // Fixture epochs: 2030-01-01T00:00:00Z renewal, promo expiries around it. const NOW: i64 = 1_750_000_000; // 2025-06-15T15:06:40Z @@ -509,12 +510,7 @@ async fn no_usable_cookie_reports_missing_cookies_without_a_request() { #[tokio::test] async fn server_errors_are_terminal_and_do_not_try_more_cookies() { let mut server = mockito::Server::new_async().await; - let mock = server - .mock("GET", "/rest/billing/credits") - .with_status(500) - .expect(1) - .create_async() - .await; + let mock = mock_status_expect(&mut server, "GET", "/rest/billing/credits", 500, 1).await; let provider = provider_for(&server); let error = provider @@ -529,12 +525,14 @@ async fn server_errors_are_terminal_and_do_not_try_more_cookies() { #[tokio::test] async fn invalid_payloads_are_parse_failures_with_a_friendly_reason() { let mut server = mockito::Server::new_async().await; - let mock = server - .mock("GET", "/rest/billing/credits") - .with_status(200) - .with_body(r#"{"balance_cents": 1}"#) - .create_async() - .await; + let mock = mock_response( + &mut server, + "GET", + "/rest/billing/credits", + 200, + r#"{"balance_cents": 1}"#, + ) + .await; let provider = provider_for(&server); let error = provider diff --git a/rust/src/providers/raycast/tests.rs b/rust/src/providers/raycast/tests.rs index 618988d3fef..41e32169895 100644 --- a/rust/src/providers/raycast/tests.rs +++ b/rust/src/providers/raycast/tests.rs @@ -1,5 +1,6 @@ use super::*; use crate::browser::cookies::Cookie; +use crate::providers::test_support::{mock_response_expect, mock_status_expect}; use chrono::TimeZone; use parse::parse_credits; @@ -530,12 +531,14 @@ async fn rejected_candidates_advance_within_the_same_refresh() { #[tokio::test] async fn all_candidates_rejected_means_the_session_expired() { let mut server = mockito::Server::new_async().await; - let mock = server - .mock("GET", "/frontend_api/current_user/ai_credits") - .with_status(401) - .expect(2) - .create_async() - .await; + let mock = mock_status_expect( + &mut server, + "GET", + "/frontend_api/current_user/ai_credits", + 401, + 2, + ) + .await; let provider = RaycastProvider::with_origin(&server.url()); let error = provider @@ -554,12 +557,14 @@ async fn all_candidates_rejected_means_the_session_expired() { #[tokio::test] async fn manual_stops_after_its_single_rejected_candidate() { let mut server = mockito::Server::new_async().await; - let mock = server - .mock("GET", "/frontend_api/current_user/ai_credits") - .with_status(401) - .expect(1) - .create_async() - .await; + let mock = mock_status_expect( + &mut server, + "GET", + "/frontend_api/current_user/ai_credits", + 401, + 1, + ) + .await; let provider = RaycastProvider::with_origin(&server.url()); let error = provider @@ -577,11 +582,7 @@ async fn manual_stops_after_its_single_rejected_candidate() { #[tokio::test] async fn no_candidates_is_a_missing_credential_without_a_request() { let mut server = mockito::Server::new_async().await; - let mock = server - .mock("GET", mockito::Matcher::Any) - .expect(0) - .create_async() - .await; + let mock = mock_status_expect(&mut server, "GET", mockito::Matcher::Any, 200, 0).await; let provider = RaycastProvider::with_origin(&server.url()); let error = provider @@ -604,11 +605,7 @@ async fn no_candidates_is_a_missing_credential_without_a_request() { #[tokio::test] async fn empty_manual_selection_fails_closed_without_browser_access() { let mut server = mockito::Server::new_async().await; - let mock = server - .mock("GET", mockito::Matcher::Any) - .expect(0) - .create_async() - .await; + let mock = mock_status_expect(&mut server, "GET", mockito::Matcher::Any, 200, 0).await; let provider = RaycastProvider::with_origin(&server.url()); let mut fetch_context = ctx(SourceMode::Web, None); @@ -625,11 +622,7 @@ async fn empty_manual_selection_fails_closed_without_browser_access() { #[tokio::test] async fn off_makes_no_request_and_no_cookie_access() { let mut server = mockito::Server::new_async().await; - let mock = server - .mock("GET", mockito::Matcher::Any) - .expect(0) - .create_async() - .await; + let mock = mock_status_expect(&mut server, "GET", mockito::Matcher::Any, 200, 0).await; let provider = RaycastProvider::with_origin(&server.url()); // The shell maps the Off cookie source to Cli, and a stored header must @@ -657,13 +650,15 @@ async fn non_authentication_failures_stop_retries_and_keep_the_session() { (204, "Raycast credits API returned HTTP 204"), ] { let mut server = mockito::Server::new_async().await; - let mock = server - .mock("GET", "/frontend_api/current_user/ai_credits") - .with_status(status) - .with_body("invalid JSON") - .expect(1) - .create_async() - .await; + let mock = mock_response_expect( + &mut server, + "GET", + "/frontend_api/current_user/ai_credits", + status, + "invalid JSON", + 1, + ) + .await; let provider = RaycastProvider::with_origin(&server.url()); let error = provider @@ -685,13 +680,15 @@ async fn non_authentication_failures_stop_retries_and_keep_the_session() { #[tokio::test] async fn malformed_success_body_is_a_parse_failure_that_stops_retries() { let mut server = mockito::Server::new_async().await; - let mock = server - .mock("GET", "/frontend_api/current_user/ai_credits") - .with_status(200) - .with_body("invalid JSON") - .expect(1) - .create_async() - .await; + let mock = mock_response_expect( + &mut server, + "GET", + "/frontend_api/current_user/ai_credits", + 200, + "invalid JSON", + 1, + ) + .await; let provider = RaycastProvider::with_origin(&server.url()); let error = provider diff --git a/rust/src/providers/test_support.rs b/rust/src/providers/test_support.rs new file mode 100644 index 00000000000..10af744fd7b --- /dev/null +++ b/rust/src/providers/test_support.rs @@ -0,0 +1,67 @@ +//! Mock-server helpers shared by provider tests. + +use mockito::{Matcher, Mock, ServerGuard}; + +/// A mock answering `method path` with `status` and `body`. +pub(crate) async fn mock_response( + server: &mut ServerGuard, + method: &str, + path: impl Into, + status: usize, + body: impl AsRef<[u8]>, +) -> Mock { + server + .mock(method, path) + .with_status(status) + .with_body(body) + .create_async() + .await +} + +/// [`mock_response`] that expects exactly `hits` requests. +pub(crate) async fn mock_response_expect( + server: &mut ServerGuard, + method: &str, + path: impl Into, + status: usize, + body: impl AsRef<[u8]>, + hits: usize, +) -> Mock { + server + .mock(method, path) + .with_status(status) + .with_body(body) + .expect(hits) + .create_async() + .await +} + +/// A mock answering `method path` with `status` and an empty body. +pub(crate) async fn mock_status( + server: &mut ServerGuard, + method: &str, + path: impl Into, + status: usize, +) -> Mock { + server + .mock(method, path) + .with_status(status) + .create_async() + .await +} + +/// [`mock_status`] that expects exactly `hits` requests. +pub(crate) async fn mock_status_expect( + server: &mut ServerGuard, + method: &str, + path: impl Into, + status: usize, + hits: usize, +) -> Mock { + server + .mock(method, path) + .with_status(status) + .expect(hits) + .create_async() + .await +} diff --git a/rust/src/providers/xkiro/tests.rs b/rust/src/providers/xkiro/tests.rs index 2c02dbe838f..35e1ec76c3c 100644 --- a/rust/src/providers/xkiro/tests.rs +++ b/rust/src/providers/xkiro/tests.rs @@ -1,4 +1,5 @@ use super::*; +use crate::providers::test_support::mock_response; use chrono::TimeZone; /// https://docs.xkiro.com/api/usage/ pay-as-you-go example, synthetic identity @@ -225,12 +226,14 @@ async fn http_failures_are_classified_without_echoing_the_body() { #[tokio::test] async fn oversized_responses_are_rejected_as_unrecognized() { let mut server = mockito::Server::new_async().await; - server - .mock("GET", "/v1/usage") - .with_status(200) - .with_body("x".repeat(MAX_RESPONSE_BYTES + 1)) - .create_async() - .await; + mock_response( + &mut server, + "GET", + "/v1/usage", + 200, + "x".repeat(MAX_RESPONSE_BYTES + 1), + ) + .await; let error = provider_for(&server) .fetch_api(API_KEY, now()) diff --git a/rust/src/providers/zed/tests.rs b/rust/src/providers/zed/tests.rs index ed3173c68aa..57ede617bef 100644 --- a/rust/src/providers/zed/tests.rs +++ b/rust/src/providers/zed/tests.rs @@ -3,6 +3,7 @@ use reqwest::redirect::Policy; use super::snapshot::{editor_result, web_result}; use super::*; +use crate::providers::test_support::{mock_response_expect, mock_status, mock_status_expect}; // Fixtures mirror upstream v0.65.0 `ZedPluginTests.swift` / `ZedStatusProbeTests.swift`. const BILLING: &str = r#"{"plan":"zed_pro","current_usage":{ @@ -359,13 +360,15 @@ async fn web_source_maps_expired_and_failing_responses_without_editor_fallback() (404, "Zed cloud API returned HTTP 404."), ] { let mut server = mockito::Server::new_async().await; - let mock = server - .mock("GET", "/frontend/billing/usage") - .with_status(status) - .with_body("login") - .expect(1) - .create_async() - .await; + let mock = mock_response_expect( + &mut server, + "GET", + "/frontend/billing/usage", + status, + "login", + 1, + ) + .await; let ctx = FetchContext { source_mode: SourceMode::Web, manual_cookie_header: Some("zed.session=fixture-session".into()), @@ -414,11 +417,7 @@ async fn web_source_without_a_usable_cookie_fails_before_any_request() { }, ] { let mut server = mockito::Server::new_async().await; - let mock = server - .mock("GET", mockito::Matcher::Any) - .expect(0) - .create_async() - .await; + let mock = mock_status_expect(&mut server, "GET", mockito::Matcher::Any, 200, 0).await; let error = test_provider(&server.url()) .fetch_usage(&ctx) .await @@ -441,11 +440,8 @@ async fn auto_and_api_sources_use_the_editor_credential_and_never_the_browser() .with_body(editor_body("zed_pro", 10, r#"{"limited":20}"#, false)) .create_async() .await; - let billing = server - .mock("GET", "/frontend/billing/usage") - .expect(0) - .create_async() - .await; + let billing = + mock_status_expect(&mut server, "GET", "/frontend/billing/usage", 200, 0).await; let ctx = FetchContext { source_mode, api_key: Some("4242 fixture-token".into()), @@ -468,11 +464,7 @@ async fn auto_and_api_sources_use_the_editor_credential_and_never_the_browser() #[tokio::test] async fn editor_lane_keeps_auth_required_for_rejected_credentials() { let mut server = mockito::Server::new_async().await; - let _mock = server - .mock("GET", "/client/users/me") - .with_status(401) - .create_async() - .await; + let _mock = mock_status(&mut server, "GET", "/client/users/me", 401).await; let ctx = FetchContext { source_mode: SourceMode::Auto, api_key: Some("4242 stale".into()), From 733d69a66538fad1f59d99fbd144383acc1c5878 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:42:37 +0700 Subject: [PATCH 08/40] Point brand-color parity test and docs at the provider spec table --- AGENTS.md | 2 +- .../components/providers/providerIcons.test.ts | 18 +++++++----------- docs/PORTING.md | 2 +- docs/PROVIDERS.md | 4 ++-- rust/src/core/provider/mod.rs | 2 +- 5 files changed, 12 insertions(+), 16 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 55165e9171d..4409b2fc963 100755 --- a/AGENTS.md +++ b/AGENTS.md @@ -96,7 +96,7 @@ pnpm run tauri:build - `apps/desktop-tauri/src/lib/tauri.ts` — frontend invoke bridge - `apps/desktop-tauri/src/types/bridge.ts` — DTOs + `SettingsTabId` - `rust/src/core/provider_factory.rs` — sole provider factory -- `rust/src/core/provider.rs` — `ProviderId` + `Provider` trait +- `rust/src/core/provider/` — `ProviderId` (`id.rs`), spec table (`spec.rs`), `Provider` trait (`traits.rs`) - `apps/desktop-tauri/src-tauri/src/commands/providers.rs` — refresh engine - `apps/desktop-tauri/src-tauri/src/tray_bridge.rs` — tray icon and menu - `apps/desktop-tauri/src-tauri/src/floatbar/window.rs` — float bar window builder diff --git a/apps/desktop-tauri/src/components/providers/providerIcons.test.ts b/apps/desktop-tauri/src/components/providers/providerIcons.test.ts index 332d358c258..993a75c4f33 100644 --- a/apps/desktop-tauri/src/components/providers/providerIcons.test.ts +++ b/apps/desktop-tauri/src/components/providers/providerIcons.test.ts @@ -8,20 +8,16 @@ import { PROVIDER_ICON_REGISTRY } from "./providerIcons"; const REPO_ROOT = import.meta.dirname + "/../../../../../"; /** - * The `ProviderId::X => "#RRGGBB"` rows of the Rust `brand_color` table in - * `rust/src/core/provider.rs`, keyed by the lowercased variant name (which - * is the registry id for every provider). + * The `spec(P::X, "cli", "Display", "#RRGGBB")` rows of the Rust provider spec + * table in `rust/src/core/provider/spec.rs`, keyed by the lowercased variant + * name (which is the registry id for every provider). */ function rustBrandColors(): Map { - // CI checks out with CRLF line endings; normalize so the "\n}\n" search below matches. - const source = readFileSync(REPO_ROOT + "rust/src/core/provider.rs", "utf8").replace(/\r\n/g, "\n"); - const start = source.indexOf("pub fn brand_color(id: ProviderId)"); - const end = source.indexOf("\n}\n", start); + const source = readFileSync(REPO_ROOT + "rust/src/core/provider/spec.rs", "utf8"); const rows = new Map(); - if (start < 0 || end < 0) return rows; - for (const match of source - .slice(start, end) - .matchAll(/ProviderId::(\w+) => "(#[0-9A-Fa-f]{6})"/g)) { + for (const match of source.matchAll( + /spec\(\s*P::(\w+),\s*"[^"]*",\s*"[^"]*",\s*"(#[0-9A-Fa-f]{6})"/g, + )) { rows.set(match[1].toLowerCase(), match[2].toLowerCase()); } return rows; diff --git a/docs/PORTING.md b/docs/PORTING.md index 83a08e46c36..aa42240723b 100644 --- a/docs/PORTING.md +++ b/docs/PORTING.md @@ -130,7 +130,7 @@ Hosted CI and review granularity are intentionally different: New or materially changed provider — touch the full registration path: 1. Provider module — `rust/src/providers//` (parse, auth, `fetch_usage`) -2. `ProviderId` — `rust/src/core/provider.rs` (`cli_name`, display, cookie domain, …) +2. `ProviderId` — `rust/src/core/provider/id.rs`, plus its row in `rust/src/core/provider/spec.rs` (`cli_name`, display, cookie domain, …) 3. Factory arm — `rust/src/core/provider_factory.rs` (exhaustive match) 4. Token accounts / multi-account plumbing if the provider uses it 5. Frontend catalog — `providerIcons` / `providerCatalog` (and any settings diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index 0cf80274a7d..f9a7cb9c9f1 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -11,7 +11,7 @@ All shells and the CLI construct providers through: codexbar::core::instantiate_provider → rust/src/core/provider_factory.rs ``` -`ProviderId` lives in `rust/src/core/provider.rs`. The factory match is **exhaustive** (missing arm = compile error). Tests ensure every id instantiates. +`ProviderId` lives in `rust/src/core/provider/id.rs`; its names, aliases, brand color and metadata are one row in `rust/src/core/provider/spec.rs`. The factory match is **exhaustive** (missing arm = compile error). Tests ensure every id instantiates. **Never** duplicate provider factories in the Tauri shell or ad-hoc commands. @@ -243,7 +243,7 @@ The provider calls `GET /key/info`, then `GET /user/info?user_id=…` for a user Upstream `docs/providers.md` is a large auto-strategy matrix (60+ providers) for the macOS app. Use it as **inspiration** when porting a provider. For runtime truth on Windows: -1. `rust/src/core/provider.rs` (`ProviderId`) +1. `rust/src/core/provider/id.rs` (`ProviderId`) and `rust/src/core/provider/spec.rs` (its spec row) 2. `rust/src/providers//` 3. `codexbar usage -p -v` / desktop provider detail errors diff --git a/rust/src/core/provider/mod.rs b/rust/src/core/provider/mod.rs index a141efadd6d..93a584f4987 100644 --- a/rust/src/core/provider/mod.rs +++ b/rust/src/core/provider/mod.rs @@ -1,4 +1,4 @@ -//! Provider trait - defines the interface all providers must implement +//! Provider identity (`ProviderId` + spec table), fetch context, errors and the `Provider` trait mod context; mod error; From 3481c49702bb7b805cfba670243887c38658c9df Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:18:57 +0700 Subject: [PATCH 09/40] Delete dead OpenCode scraper module --- rust/src/providers/opencode/mod.rs | 8 - rust/src/providers/opencode/scraper.rs | 700 ------------------------- 2 files changed, 708 deletions(-) delete mode 100755 rust/src/providers/opencode/scraper.rs diff --git a/rust/src/providers/opencode/mod.rs b/rust/src/providers/opencode/mod.rs index faac59aa295..82e9d2398ae 100755 --- a/rust/src/providers/opencode/mod.rs +++ b/rust/src/providers/opencode/mod.rs @@ -4,14 +4,6 @@ //! Uses browser cookies for authentication pub mod billing; -pub mod scraper; - -// Re-exports for advanced scraping -#[allow( - unused_imports, - reason = "imports needed for future OpenCode provider wiring" -)] -pub use scraper::{OpenCodeError, OpenCodeUsageFetcher, OpenCodeUsageSnapshot}; use async_trait::async_trait; use chrono::{DateTime, Utc}; diff --git a/rust/src/providers/opencode/scraper.rs b/rust/src/providers/opencode/scraper.rs deleted file mode 100755 index 5260d505fb3..00000000000 --- a/rust/src/providers/opencode/scraper.rs +++ /dev/null @@ -1,700 +0,0 @@ -//! OpenCode Advanced Web Scraping -//! -//! Multi-stage fetcher for opencode.ai with: -//! - Workspace ID resolution -//! - Subscription info fetching via custom X-Server-Id headers -//! - Robust JSON parsing with multiple fallback strategies - -use chrono::{DateTime, Duration, Utc}; -use regex_lite::Regex; -use serde::{Deserialize, Serialize}; -use std::sync::OnceLock; - -/// OpenCode server IDs for API endpoints -const WORKSPACES_SERVER_ID: &str = - "def39973159c7f0483d8793a822b8dbb10d067e12c65455fcb4608459ba0234f"; -const SUBSCRIPTION_SERVER_ID: &str = - "7abeebee372f304e050aaaf92be863f4a86490e382f8c79db68fd94040d691b4"; -const BASE_URL: &str = "https://opencode.ai"; -const SERVER_URL: &str = "https://opencode.ai/_server"; - -/// Keys to look for when parsing percent values -const PERCENT_KEYS: &[&str] = &[ - "usagePercent", - "usedPercent", - "percentUsed", - "percent", - "usage_percent", - "used_percent", - "utilization", - "utilizationPercent", - "utilization_percent", - // Note: raw "usage" is often a token count — handled via used/limit only. -]; - -/// Keys to look for when parsing reset time in seconds -const RESET_IN_KEYS: &[&str] = &[ - "resetInSec", - "resetInSeconds", - "resetSeconds", - "reset_sec", - "reset_in_sec", - "resetsInSec", - "resetsInSeconds", - "resetIn", - "resetSec", -]; - -/// Keys to look for when parsing reset timestamp -const RESET_AT_KEYS: &[&str] = &[ - "resetAt", - "resetsAt", - "reset_at", - "resets_at", - "nextReset", - "next_reset", - "renewAt", - "renew_at", -]; - -/// OpenCode usage snapshot -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct OpenCodeUsageSnapshot { - /// Rolling window usage percent (5-hour window) - pub rolling_usage_percent: f64, - /// Weekly usage percent - pub weekly_usage_percent: f64, - /// Rolling window reset in seconds - pub rolling_reset_in_sec: i64, - /// Weekly reset in seconds - pub weekly_reset_in_sec: i64, - /// Account renewal date when exposed by OpenCode. - pub renews_at: Option>, - /// When this snapshot was captured - pub updated_at: DateTime, -} - -impl OpenCodeUsageSnapshot { - /// Get rolling window reset time - pub fn rolling_resets_at(&self) -> DateTime { - self.updated_at + Duration::seconds(self.rolling_reset_in_sec) - } - - /// Get weekly reset time - pub fn weekly_resets_at(&self) -> DateTime { - self.updated_at + Duration::seconds(self.weekly_reset_in_sec) - } -} - -/// OpenCode usage fetcher errors -#[derive(Debug, Clone)] -pub enum OpenCodeError { - /// Session cookie is invalid or expired - InvalidCredentials, - /// Network error - NetworkError(String), - /// API error - ApiError(String), - /// Parse error - ParseFailed(String), -} - -impl std::fmt::Display for OpenCodeError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - OpenCodeError::InvalidCredentials => { - write!(f, "OpenCode session cookie is invalid or expired") - } - OpenCodeError::NetworkError(msg) => write!(f, "OpenCode network error: {}", msg), - OpenCodeError::ApiError(msg) => write!(f, "OpenCode API error: {}", msg), - OpenCodeError::ParseFailed(msg) => write!(f, "OpenCode parse error: {}", msg), - } - } -} - -impl std::error::Error for OpenCodeError {} - -/// Server request configuration -struct ServerRequest { - server_id: String, - args: Option>, - method: String, - referer: String, -} - -/// OpenCode usage fetcher -pub struct OpenCodeUsageFetcher; - -impl OpenCodeUsageFetcher { - /// Fetch usage data from OpenCode - pub async fn fetch_usage( - cookie_header: &str, - timeout_secs: u64, - workspace_id_override: Option<&str>, - ) -> Result { - let now = Utc::now(); - - // Get or resolve workspace ID - let workspace_id = if let Some(override_id) = workspace_id_override { - Self::normalize_workspace_id(override_id) - .ok_or_else(|| OpenCodeError::ParseFailed("Invalid workspace ID".to_string()))? - } else { - Self::fetch_workspace_id(cookie_header, timeout_secs).await? - }; - - // Fetch subscription info - let subscription_text = - Self::fetch_subscription_info(&workspace_id, cookie_header, timeout_secs).await?; - - // Parse subscription - Self::parse_subscription(&subscription_text, now) - } - - /// Fetch workspace ID from OpenCode - async fn fetch_workspace_id( - cookie_header: &str, - timeout_secs: u64, - ) -> Result { - let request = ServerRequest { - server_id: WORKSPACES_SERVER_ID.to_string(), - args: None, - method: "GET".to_string(), - referer: BASE_URL.to_string(), - }; - - let text = Self::fetch_server_text(&request, cookie_header, timeout_secs).await?; - - if Self::looks_signed_out(&text) { - return Err(OpenCodeError::InvalidCredentials); - } - - // Try parsing workspace IDs - let mut ids = Self::parse_workspace_ids(&text); - if ids.is_empty() { - ids = Self::parse_workspace_ids_from_json(&text); - } - - if ids.is_empty() { - // Retry with POST - let retry_request = ServerRequest { - server_id: WORKSPACES_SERVER_ID.to_string(), - args: Some(vec![]), - method: "POST".to_string(), - referer: BASE_URL.to_string(), - }; - - let fallback = - Self::fetch_server_text(&retry_request, cookie_header, timeout_secs).await?; - - if Self::looks_signed_out(&fallback) { - return Err(OpenCodeError::InvalidCredentials); - } - - ids = Self::parse_workspace_ids(&fallback); - if ids.is_empty() { - ids = Self::parse_workspace_ids_from_json(&fallback); - } - - if ids.is_empty() { - return Err(OpenCodeError::ParseFailed( - "Missing workspace id".to_string(), - )); - } - } - - Ok(ids.into_iter().next().unwrap()) - } - - /// Fetch subscription info for a workspace - async fn fetch_subscription_info( - workspace_id: &str, - cookie_header: &str, - timeout_secs: u64, - ) -> Result { - let referer = format!("{}/workspace/{}/billing", BASE_URL, workspace_id); - let request = ServerRequest { - server_id: SUBSCRIPTION_SERVER_ID.to_string(), - args: Some(vec![serde_json::Value::String(workspace_id.to_string())]), - method: "GET".to_string(), - referer, - }; - - let text = Self::fetch_server_text(&request, cookie_header, timeout_secs).await?; - - if Self::looks_signed_out(&text) { - return Err(OpenCodeError::InvalidCredentials); - } - - // Check if we got valid data, otherwise retry with POST - if Self::parse_subscription_json(&text, Utc::now()).is_none() - && Self::extract_double( - r#"rollingUsage[^}]*?usagePercent\s*:\s*([0-9]+(?:\.[0-9]+)?)"#, - &text, - ) - .is_none() - { - let retry_request = ServerRequest { - server_id: SUBSCRIPTION_SERVER_ID.to_string(), - args: Some(vec![serde_json::Value::String(workspace_id.to_string())]), - method: "POST".to_string(), - referer: format!("{}/workspace/{}/billing", BASE_URL, workspace_id), - }; - - let fallback = - Self::fetch_server_text(&retry_request, cookie_header, timeout_secs).await?; - - if Self::looks_signed_out(&fallback) { - return Err(OpenCodeError::InvalidCredentials); - } - - return Ok(fallback); - } - - Ok(text) - } - - /// Normalize a workspace ID from various formats - pub fn normalize_workspace_id(raw: &str) -> Option { - let trimmed = raw.trim(); - - // Already a valid workspace ID - if trimmed.starts_with("wrk_") && trimmed.len() > 4 { - return Some(trimmed.to_string()); - } - - // Try to parse as URL - if let Ok(url) = reqwest::Url::parse(trimmed) { - let parts: Vec<&str> = url.path_segments().map(|s| s.collect()).unwrap_or_default(); - if let Some(idx) = parts.iter().position(|&p| p == "workspace") - && parts.len() > idx + 1 - { - let candidate = parts[idx + 1]; - if candidate.starts_with("wrk_") && candidate.len() > 4 { - return Some(candidate.to_string()); - } - } - } - - // Try regex extraction - static WRK_REGEX: OnceLock = OnceLock::new(); - let regex = WRK_REGEX.get_or_init(|| Regex::new(r"wrk_[A-Za-z0-9]+").unwrap()); - - regex.find(trimmed).map(|m| m.as_str().to_string()) - } - - /// Fetch server text with custom headers - async fn fetch_server_text( - request: &ServerRequest, - cookie_header: &str, - timeout_secs: u64, - ) -> Result { - let url = Self::build_server_url(&request.server_id, &request.args, &request.method); - - let client = crate::core::credentialed_http_client_builder() - .timeout(std::time::Duration::from_secs(timeout_secs)) - .build() - .map_err(|e| OpenCodeError::NetworkError(e.to_string()))?; - - let mut req = if request.method == "GET" { - client.get(&url) - } else { - client.post(&url) - }; - - req = req - .header("Cookie", cookie_header) - .header("X-Server-Id", &request.server_id) - .header("X-Server-Instance", format!("server-fn:{}", uuid::Uuid::new_v4())) - .header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36") - .header("Origin", BASE_URL) - .header("Referer", &request.referer) - .header("Accept", "text/javascript, application/json;q=0.9, */*;q=0.8"); - - if request.method != "GET" - && let Some(args) = &request.args - { - req = req.header("Content-Type", "application/json").json(args); - } - - let response = req - .send() - .await - .map_err(|e| OpenCodeError::NetworkError(e.to_string()))?; - - let status = response.status(); - let text = response - .text() - .await - .map_err(|e| OpenCodeError::NetworkError(e.to_string()))?; - - if !status.is_success() { - if Self::looks_signed_out(&text) { - return Err(OpenCodeError::InvalidCredentials); - } - if status.as_u16() == 401 || status.as_u16() == 403 { - return Err(OpenCodeError::InvalidCredentials); - } - return Err(OpenCodeError::ApiError(format!("HTTP {}", status))); - } - - Ok(text) - } - - /// Build server request URL - fn build_server_url( - server_id: &str, - args: &Option>, - method: &str, - ) -> String { - if method != "GET" { - return SERVER_URL.to_string(); - } - - let mut url = format!("{}?id={}", SERVER_URL, server_id); - if let Some(args) = args - && !args.is_empty() - && let Ok(json_str) = serde_json::to_string(args) - { - url.push_str(&format!("&args={}", Self::url_encode(&json_str))); - } - url - } - - /// URL encode a string for query parameters - fn url_encode(s: &str) -> String { - let mut result = String::with_capacity(s.len() * 3); - for c in s.chars() { - match c { - 'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' | '.' | '~' => { - result.push(c); - } - _ => { - for b in c.to_string().as_bytes() { - result.push_str(&format!("%{:02X}", b)); - } - } - } - } - result - } - - /// Parse subscription text into snapshot - pub fn parse_subscription( - text: &str, - now: DateTime, - ) -> Result { - // Try JSON parsing first - if let Some(snapshot) = Self::parse_subscription_json(text, now) { - return Ok(snapshot); - } - - // Fall back to regex parsing - let rolling_percent = Self::extract_double( - r#"rollingUsage[^}]*?usagePercent\s*:\s*([0-9]+(?:\.[0-9]+)?)"#, - text, - ); - let rolling_reset = - Self::extract_int(r#"rollingUsage[^}]*?resetInSec\s*:\s*([0-9]+)"#, text); - let weekly_percent = Self::extract_double( - r#"weeklyUsage[^}]*?usagePercent\s*:\s*([0-9]+(?:\.[0-9]+)?)"#, - text, - ); - let weekly_reset = Self::extract_int(r#"weeklyUsage[^}]*?resetInSec\s*:\s*([0-9]+)"#, text); - - match (rolling_percent, rolling_reset, weekly_percent, weekly_reset) { - (Some(rp), Some(rr), Some(wp), Some(wr)) => Ok(OpenCodeUsageSnapshot { - rolling_usage_percent: rp, - weekly_usage_percent: wp, - rolling_reset_in_sec: rr, - weekly_reset_in_sec: wr, - renews_at: crate::providers::extract_renewal(text), - updated_at: now, - }), - _ => Err(OpenCodeError::ParseFailed( - "Missing usage fields".to_string(), - )), - } - } - - /// Parse subscription JSON - fn parse_subscription_json(text: &str, now: DateTime) -> Option { - let json: serde_json::Value = serde_json::from_str(text).ok()?; - let renews_at = Self::find_datetime(&json, &["renewAt", "renew_at"]); - - // Try direct parsing - if let Some(mut snapshot) = Self::parse_usage_dict(&json, now) { - snapshot.renews_at = renews_at; - return Some(snapshot); - } - - // Try nested keys - for key in ["data", "result", "usage", "billing", "payload"] { - if let Some(nested) = json.get(key) - && let Some(mut snapshot) = Self::parse_usage_dict(nested, now) - { - snapshot.renews_at = snapshot.renews_at.or(renews_at); - return Some(snapshot); - } - } - - None - } - - /// Parse usage from a JSON object - fn parse_usage_dict( - json: &serde_json::Value, - now: DateTime, - ) -> Option { - let rolling_keys = ["rollingUsage", "rolling", "rolling_usage"]; - let weekly_keys = ["weeklyUsage", "weekly", "weekly_usage"]; - - let rolling = rolling_keys.iter().find_map(|k| json.get(k)); - let weekly = weekly_keys.iter().find_map(|k| json.get(k)); - - if let (Some(rolling), Some(weekly)) = (rolling, weekly) { - let rolling_window = Self::parse_window(rolling, now)?; - let weekly_window = Self::parse_window(weekly, now)?; - - return Some(OpenCodeUsageSnapshot { - rolling_usage_percent: rolling_window.0, - weekly_usage_percent: weekly_window.0, - rolling_reset_in_sec: rolling_window.1, - weekly_reset_in_sec: weekly_window.1, - renews_at: Self::find_datetime(json, &["renewAt", "renew_at"]), - updated_at: now, - }); - } - - None - } - - /// Parse a window object into (percent, reset_in_sec) - fn parse_window(json: &serde_json::Value, _now: DateTime) -> Option<(f64, i64)> { - // Direct percent field (may be 0..1 fraction or 0..100). - let direct = PERCENT_KEYS - .iter() - .find_map(|k| json.get(k).and_then(|v| v.as_f64())); - let percent_is_direct = direct.is_some(); - - let percent = direct.or_else(|| { - // Computed used/limit is already 0..100 — never apply fraction rescale (#2331). - let used = ["used", "usage", "consumed", "count", "usedTokens"] - .iter() - .find_map(|k| json.get(*k).and_then(|v| v.as_f64())); - let limit = ["limit", "total", "allowance"] - .iter() - .find_map(|k| json.get(*k).and_then(|v| v.as_f64())); - match (used, limit) { - (Some(u), Some(l)) if l > 0.0 => Some((u / l) * 100.0), - _ => None, - } - })?; - - let reset_in = RESET_IN_KEYS - .iter() - .find_map(|k| json.get(k).and_then(|v| v.as_i64())) - .unwrap_or(0); - - let normalized_percent = if percent_is_direct && (0.0..=1.0).contains(&percent) { - percent * 100.0 - } else { - percent.clamp(0.0, 100.0) - }; - Some((normalized_percent.clamp(0.0, 100.0), reset_in.max(0))) - } - - fn find_datetime(json: &serde_json::Value, keys: &[&str]) -> Option> { - for key in keys { - if let Some(value) = json.get(*key) - && let Some(parsed) = Self::date_from_value(value) - { - return Some(parsed); - } - } - - if let Some(obj) = json.as_object() { - for value in obj.values() { - if let Some(parsed) = Self::find_datetime(value, keys) { - return Some(parsed); - } - } - } - None - } - - fn date_from_value(value: &serde_json::Value) -> Option> { - if let Some(number) = value.as_i64() { - return Self::date_from_timestamp(number as f64); - } - if let Some(number) = value.as_f64() { - return Self::date_from_timestamp(number); - } - let text = value.as_str()?.trim(); - if text.is_empty() { - return None; - } - if let Ok(number) = text.parse::() { - return Self::date_from_timestamp(number); - } - DateTime::parse_from_rfc3339(text) - .ok() - .map(|dt| dt.with_timezone(&Utc)) - } - - fn date_from_timestamp(number: f64) -> Option> { - if !number.is_finite() || number <= 0.0 { - return None; - } - let seconds = if number > 10_000_000_000.0 { - number / 1000.0 - } else { - number - }; - #[allow( - clippy::cast_possible_truncation, - reason = "timestamps are normalized to whole seconds (ms input divided by 1000) before the cast" - )] - DateTime::::from_timestamp(seconds as i64, 0) - } - - /// Parse workspace IDs from text using regex - fn parse_workspace_ids(text: &str) -> Vec { - // Try JSON parsing first - let json_ids = Self::parse_workspace_ids_from_json(text); - if !json_ids.is_empty() { - return json_ids; - } - - // Fall back to regex for JavaScript object notation (unquoted keys) - static WRK_ID_REGEX: OnceLock = OnceLock::new(); - let regex = WRK_ID_REGEX.get_or_init(|| { - Regex::new(r#""id"\s*:\s*"(wrk_[^"]+)"|id\s*:\s*"(wrk_[^"]+)""#).unwrap() - }); - - regex - .captures_iter(text) - .filter_map(|cap| { - cap.get(1) - .or_else(|| cap.get(2)) - .map(|m| m.as_str().to_string()) - }) - .collect() - } - - /// Parse workspace IDs from JSON - fn parse_workspace_ids_from_json(text: &str) -> Vec { - let json: serde_json::Value = match serde_json::from_str(text) { - Ok(v) => v, - Err(_) => return vec![], - }; - - let mut results = Vec::new(); - Self::collect_workspace_ids(&json, &mut results); - results - } - - /// Recursively collect workspace IDs from JSON - fn collect_workspace_ids(value: &serde_json::Value, out: &mut Vec) { - match value { - serde_json::Value::String(s) if s.starts_with("wrk_") && !out.contains(s) => { - out.push(s.clone()); - } - serde_json::Value::Array(arr) => { - for item in arr { - Self::collect_workspace_ids(item, out); - } - } - serde_json::Value::Object(obj) => { - for v in obj.values() { - Self::collect_workspace_ids(v, out); - } - } - _ => {} - } - } - - /// Check if response indicates signed out - fn looks_signed_out(text: &str) -> bool { - let lower = text.to_lowercase(); - lower.contains("login") || lower.contains("sign in") || lower.contains("auth/authorize") - } - - /// Extract a double from text using regex - fn extract_double(pattern: &str, text: &str) -> Option { - let regex = Regex::new(pattern).ok()?; - let captures = regex.captures(text)?; - let value_str = captures.get(1)?.as_str(); - value_str.parse().ok() - } - - /// Extract an int from text using regex - fn extract_int(pattern: &str, text: &str) -> Option { - let regex = Regex::new(pattern).ok()?; - let captures = regex.captures(text)?; - let value_str = captures.get(1)?.as_str(); - value_str.parse().ok() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_normalize_workspace_id_direct() { - assert_eq!( - OpenCodeUsageFetcher::normalize_workspace_id("wrk_abc123"), - Some("wrk_abc123".to_string()) - ); - } - - #[test] - fn test_normalize_workspace_id_url() { - assert_eq!( - OpenCodeUsageFetcher::normalize_workspace_id( - "https://opencode.ai/workspace/wrk_xyz789/billing" - ), - Some("wrk_xyz789".to_string()) - ); - } - - #[test] - fn test_normalize_workspace_id_invalid() { - assert_eq!( - OpenCodeUsageFetcher::normalize_workspace_id("invalid"), - None - ); - } - - #[test] - fn test_parse_workspace_ids() { - let text = r#"{"workspaces":[{"id":"wrk_abc123"},{"id":"wrk_def456"}]}"#; - let ids = OpenCodeUsageFetcher::parse_workspace_ids(text); - assert_eq!(ids, vec!["wrk_abc123", "wrk_def456"]); - } - - #[test] - fn test_looks_signed_out() { - assert!(OpenCodeUsageFetcher::looks_signed_out( - "Please login to continue" - )); - assert!(OpenCodeUsageFetcher::looks_signed_out("Sign in required")); - assert!(!OpenCodeUsageFetcher::looks_signed_out( - "Welcome to OpenCode" - )); - } - - #[test] - fn test_parse_subscription_json() { - let json = r#"{ - "rollingUsage": {"usagePercent": 45.5, "resetInSec": 3600}, - "weeklyUsage": {"usagePercent": 20.0, "resetInSec": 604800} - }"#; - - let snapshot = OpenCodeUsageFetcher::parse_subscription(json, Utc::now()).unwrap(); - assert!((snapshot.rolling_usage_percent - 45.5).abs() < 0.01); - assert!((snapshot.weekly_usage_percent - 20.0).abs() < 0.01); - assert_eq!(snapshot.rolling_reset_in_sec, 3600); - assert_eq!(snapshot.weekly_reset_in_sec, 604800); - } -} From e974e9bd7d955b5de0a5115b37cb8a1080712aa4 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:20:16 +0700 Subject: [PATCH 10/40] Delete dead MiniMax localStorage importer --- rust/src/providers/minimax/local_storage.rs | 458 -------------------- rust/src/providers/minimax/mod.rs | 8 - 2 files changed, 466 deletions(-) delete mode 100755 rust/src/providers/minimax/local_storage.rs diff --git a/rust/src/providers/minimax/local_storage.rs b/rust/src/providers/minimax/local_storage.rs deleted file mode 100755 index 3225be47e1b..00000000000 --- a/rust/src/providers/minimax/local_storage.rs +++ /dev/null @@ -1,458 +0,0 @@ -//! MiniMax LocalStorage Importer -//! -//! Extracts session data from Chromium browser storage for the MiniMax platform. -//! Storage directories come from `browser::storage_discovery` (every installed Chromium-family -//! browser and profile: Local Storage, then Session Storage, then MiniMax IndexedDB). - -use crate::browser::leveldb::local_storage::{ - LocalStorageEntry, read_local_storage_entries_for_origins, -}; -use crate::browser::leveldb::{self, Entry}; -use crate::browser::storage_discovery::{self, StorageCandidate, StorageKind}; -use serde::{Deserialize, Serialize}; -use std::path::Path; - -/// Session data extracted from MiniMax localStorage -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct MiniMaxSession { - pub access_token: Option, - pub user_id: Option, - pub group_id: Option, - pub email: Option, - pub phone: Option, - pub plan_type: Option, - pub source_label: String, -} - -/// Error type for localStorage import -#[derive(Debug)] -pub enum ImportError { - BrowserNotFound, - StorageNotFound, - ParseError(String), - AccessDenied(String), -} - -impl std::fmt::Display for ImportError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - ImportError::BrowserNotFound => write!(f, "No supported browser found"), - ImportError::StorageNotFound => write!(f, "localStorage data not found"), - ImportError::ParseError(e) => write!(f, "Parse error: {}", e), - ImportError::AccessDenied(e) => write!(f, "Access denied: {}", e), - } - } -} - -impl std::error::Error for ImportError {} - -/// Origin prefixes of the MiniMax IndexedDB databases (`__`). -const INDEXED_DB_ORIGIN_PREFIXES: &[&str] = &[ - "https_platform.minimax.io_", - "https_www.minimax.io_", - "https_minimax.io_", - "https_platform.minimaxi.com_", - "https_minimaxi.com_", - "https_www.minimaxi.com_", -]; - -const MINIMAX_LOCAL_STORAGE_ORIGINS: &[&str] = &[ - "https://platform.minimax.io", - "https://www.minimax.io", - "https://minimax.io", - "https://platform.minimaxi.com", - "https://www.minimaxi.com", - "https://minimaxi.com", -]; - -const MINIMAX_SESSION_PATTERNS: &[&str] = &[ - "minimax_user", - "minimax_session", - "platform.minimaxi.com", - "mm_token", - "mm_user_info", -]; - -/// Stores tried in order; a later store is read only when earlier ones yield no session. -const STORAGE_ORDER: [StorageKind; 3] = [ - StorageKind::LocalStorage, - StorageKind::SessionStorage, - StorageKind::IndexedDb { - origin_prefixes: INDEXED_DB_ORIGIN_PREFIXES, - }, -]; - -/// MiniMax localStorage importer -pub struct MiniMaxLocalStorageImporter; - -impl MiniMaxLocalStorageImporter { - /// Import a MiniMax session from Chromium browser storage. - /// - /// Visits every installed Chromium-family browser and profile: Local Storage first, then - /// Session Storage, then MiniMax-origin IndexedDB when the earlier stores yield nothing. - pub fn import_session() -> Result { - Self::import_with(storage_discovery::discover) - } - - fn import_with( - discover: impl Fn(StorageKind) -> Vec, - ) -> Result { - let mut found_any_store = false; - let mut last_error = None; - for kind in STORAGE_ORDER { - for candidate in discover(kind) { - found_any_store = true; - match Self::extract_from_path(&candidate.path, &candidate.label, kind) { - Ok(session) => return Ok(session), - Err(ImportError::StorageNotFound) => {} - Err(error) => last_error = Some(error), - } - } - } - - Err(if found_any_store { - last_error.unwrap_or(ImportError::StorageNotFound) - } else { - ImportError::BrowserNotFound - }) - } - - /// Extract a MiniMax session from one decoded Chromium storage directory. - fn extract_from_path( - path: &Path, - source_label: &str, - kind: StorageKind, - ) -> Result { - let minimax_data = match kind { - StorageKind::LocalStorage => { - let entries = - read_local_storage_entries_for_origins(path, MINIMAX_LOCAL_STORAGE_ORIGINS) - .map_err(|error| ImportError::AccessDenied(error.to_string()))?; - entries.iter().find_map(Self::extract_local_storage_json) - } - StorageKind::SessionStorage | StorageKind::IndexedDb { .. } => { - let entries = leveldb::read_entries(path) - .map_err(|error| ImportError::AccessDenied(error.to_string()))?; - entries.iter().find_map(Self::extract_minimax_entry) - } - }; - - match minimax_data { - Some(json) => Self::parse_session_from_json(&json, source_label), - None => Err(ImportError::StorageNotFound), - } - } - - fn extract_local_storage_json(entry: &LocalStorageEntry) -> Option { - if MINIMAX_SESSION_PATTERNS - .iter() - .any(|pattern| entry.key.contains(pattern)) - { - return serde_json::from_str(&entry.value) - .ok() - .or_else(|| Self::extract_minimax_json(entry.value.as_bytes())); - } - - Self::extract_minimax_json(entry.value.as_bytes()) - } - - fn extract_minimax_entry(entry: &Entry) -> Option { - let key = String::from_utf8_lossy(&entry.key); - let value = String::from_utf8_lossy(&entry.value); - if MINIMAX_SESSION_PATTERNS - .iter() - .any(|pattern| key.contains(pattern)) - { - return serde_json::from_str(&value) - .ok() - .or_else(|| Self::extract_minimax_json(&entry.value)); - } - - Self::extract_minimax_json(&entry.value).or_else(|| Self::extract_minimax_json(&entry.key)) - } - - /// Find MiniMax JSON embedded in a decoded LevelDB key or value. - fn extract_minimax_json(data: &[u8]) -> Option { - let content = String::from_utf8_lossy(data); - for pattern in MINIMAX_SESSION_PATTERNS { - if let Some(parsed) = Self::extract_json_after_pattern(&content, pattern) { - return Some(parsed); - } - } - - None - } - - fn extract_json_after_pattern(content: &str, pattern: &str) -> Option { - let json_start = Self::json_start_after_pattern(content, pattern)?; - let remaining = &content[json_start..]; - let end_idx = Self::matching_json_object_end(remaining)?; - serde_json::from_str(&remaining[..end_idx]).ok() - } - - fn json_start_after_pattern(content: &str, pattern: &str) -> Option { - let start_idx = content.find(pattern)?; - content[start_idx..] - .find('{') - .map(|offset| start_idx + offset) - } - - fn matching_json_object_end(content: &str) -> Option { - let mut depth = 0; - let mut in_string = false; - let mut escaped = false; - for (i, c) in content.char_indices() { - if in_string { - match c { - '\\' if !escaped => escaped = true, - '"' if !escaped => in_string = false, - _ => escaped = false, - } - continue; - } - match c { - '"' => in_string = true, - '{' => depth += 1, - '}' => { - depth -= 1; - if depth == 0 { - return Some(i + 1); - } - } - _ => {} - } - } - None - } - - /// Parse session from extracted JSON - fn parse_session_from_json( - json: &serde_json::Value, - source_label: &str, - ) -> Result { - let access_token = json - .get("access_token") - .or_else(|| json.get("token")) - .or_else(|| json.get("mm_token")) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - - let user_id = json - .get("user_id") - .or_else(|| json.get("userId")) - .or_else(|| json.get("id")) - .and_then(|v| { - v.as_str() - .map(|s| s.to_string()) - .or_else(|| v.as_i64().map(|n| n.to_string())) - }); - - let group_id = json - .get("group_id") - .or_else(|| json.get("groupId")) - .and_then(|v| { - v.as_str() - .map(|s| s.to_string()) - .or_else(|| v.as_i64().map(|n| n.to_string())) - }); - - let email = json - .get("email") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - - let phone = json - .get("phone") - .or_else(|| json.get("mobile")) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - - let plan_type = json - .get("plan_type") - .or_else(|| json.get("planType")) - .or_else(|| json.get("plan")) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - - // Need at least access_token or user_id - if access_token.is_none() && user_id.is_none() { - return Err(ImportError::ParseError( - "No valid session data found".to_string(), - )); - } - - Ok(MiniMaxSession { - access_token, - user_id, - group_id, - email, - phone, - plan_type, - source_label: source_label.to_string(), - }) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_parse_session_json() { - let json = serde_json::json!({ - "access_token": "test_token", - "user_id": "12345", - "group_id": "67890", - "email": "test@example.com" - }); - - let result = MiniMaxLocalStorageImporter::parse_session_from_json(&json, "Chrome"); - assert!(result.is_ok()); - - let session = result.unwrap(); - assert_eq!(session.access_token, Some("test_token".to_string())); - assert_eq!(session.user_id, Some("12345".to_string())); - } - - #[test] - fn test_parse_session_empty() { - let json = serde_json::json!({ - "foo": "bar" - }); - - let result = MiniMaxLocalStorageImporter::parse_session_from_json(&json, "Chrome"); - assert!(result.is_err()); - } - - fn candidate(label: &str, path: &Path) -> StorageCandidate { - StorageCandidate { - label: label.to_string(), - path: path.to_path_buf(), - } - } - - fn write_store(dir: &Path, token: Option<&str>) -> std::path::PathBuf { - std::fs::create_dir_all(dir).unwrap(); - let (key, value, local_key, local_value) = match token { - Some(token) => { - let json = format!(r#"{{"access_token":"{token}","user_id":"1"}}"#); - let mut local_value = vec![1]; - local_value.extend_from_slice(json.as_bytes()); - ( - b"minimax_user".to_vec(), - json.as_bytes().to_vec(), - b"_https://platform.minimax.io\0\x01minimax_user".to_vec(), - local_value, - ) - } - None => ( - b"unrelated".to_vec(), - b"unrelated".to_vec(), - b"_https://platform.minimax.io\0\x01unrelated".to_vec(), - b"\x01unrelated".to_vec(), - ), - }; - - let mut batch = Vec::new(); - batch.extend_from_slice(&1u64.to_le_bytes()); - batch.extend_from_slice(&2u32.to_le_bytes()); - for (key, value) in [(key, value), (local_key, local_value)] { - batch.push(1); // put - append_varint(&mut batch, key.len()); - batch.extend_from_slice(&key); - append_varint(&mut batch, value.len()); - batch.extend_from_slice(&value); - } - let mut log = vec![0; 4]; // the best-effort reader does not verify checksums - log.extend_from_slice(&u16::try_from(batch.len()).unwrap().to_le_bytes()); - log.push(1); // full physical record - log.extend_from_slice(&batch); - std::fs::write(dir.join("000003.log"), log).unwrap(); - dir.to_path_buf() - } - - fn append_varint(output: &mut Vec, mut value: usize) { - while value >= 0x80 { - output.push(u8::try_from(value & 0x7f).unwrap() | 0x80); - value >>= 7; - } - output.push(u8::try_from(value).unwrap()); - } - fn discover_from( - local: Vec, - session: Vec, - indexed: Vec, - ) -> impl Fn(StorageKind) -> Vec { - move |kind| match kind { - StorageKind::LocalStorage => local.clone(), - StorageKind::SessionStorage => session.clone(), - StorageKind::IndexedDb { origin_prefixes } => { - assert_eq!(origin_prefixes, INDEXED_DB_ORIGIN_PREFIXES); - indexed.clone() - } - } - } - - #[test] - fn import_prefers_local_storage_over_later_stores() { - let root = tempfile::tempdir().unwrap(); - let local = write_store(&root.path().join("local"), Some("from-local")); - let session = write_store(&root.path().join("session"), Some("from-session")); - - let found = MiniMaxLocalStorageImporter::import_with(discover_from( - vec![candidate("Chrome Default", &local)], - vec![candidate("Chrome Default (Session Storage)", &session)], - Vec::new(), - )) - .unwrap(); - - assert_eq!(found.access_token.as_deref(), Some("from-local")); - assert_eq!(found.source_label, "Chrome Default"); - } - - #[test] - fn import_falls_back_to_session_then_indexed_db_when_earlier_stores_are_empty() { - let root = tempfile::tempdir().unwrap(); - let local = write_store(&root.path().join("local"), None); - let session = write_store(&root.path().join("session"), None); - let indexed = write_store(&root.path().join("indexed"), Some("from-indexed")); - - let found = MiniMaxLocalStorageImporter::import_with(discover_from( - vec![candidate("Edge Default", &local)], - vec![candidate("Edge Default (Session Storage)", &session)], - vec![candidate("Edge Default (IndexedDB)", &indexed)], - )) - .unwrap(); - assert_eq!(found.access_token.as_deref(), Some("from-indexed")); - assert_eq!(found.source_label, "Edge Default (IndexedDB)"); - - let session_token = write_store(&root.path().join("session2"), Some("from-session")); - let found = MiniMaxLocalStorageImporter::import_with(discover_from( - vec![candidate("Edge Default", &local)], - vec![candidate("Edge Default (Session Storage)", &session_token)], - vec![candidate("Edge Default (IndexedDB)", &indexed)], - )) - .unwrap(); - assert_eq!(found.access_token.as_deref(), Some("from-session")); - } - - #[test] - fn import_distinguishes_no_browser_from_no_session() { - let root = tempfile::tempdir().unwrap(); - let empty = write_store(&root.path().join("empty"), None); - - let none = MiniMaxLocalStorageImporter::import_with(discover_from( - Vec::new(), - Vec::new(), - Vec::new(), - )); - assert!(matches!(none, Err(ImportError::BrowserNotFound))); - - let no_session = MiniMaxLocalStorageImporter::import_with(discover_from( - vec![candidate("Brave Default", &empty)], - Vec::new(), - Vec::new(), - )); - assert!(matches!(no_session, Err(ImportError::StorageNotFound))); - } -} diff --git a/rust/src/providers/minimax/mod.rs b/rust/src/providers/minimax/mod.rs index 9967e5df9f6..7704468f2b6 100755 --- a/rust/src/providers/minimax/mod.rs +++ b/rust/src/providers/minimax/mod.rs @@ -5,17 +5,9 @@ mod coding_plan; mod coding_plan_html; -mod local_storage; mod remains_api; mod token_plan; -// Re-exports for local storage import -#[allow( - unused_imports, - reason = "imports needed for future MiniMax provider wiring" -)] -pub use local_storage::{ImportError, MiniMaxLocalStorageImporter, MiniMaxSession}; - use async_trait::async_trait; use chrono::{DateTime, Duration, TimeZone, Utc}; use serde::Deserialize; From 0f76a5fef4fdb5f467a65510bdbed2221b22c549 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:25:57 +0700 Subject: [PATCH 11/40] Share the OpenCode server-function request builder --- rust/src/providers/opencode/billing.rs | 29 ++---- rust/src/providers/opencode/mod.rs | 125 +++++++++++++++++++------ 2 files changed, 102 insertions(+), 52 deletions(-) diff --git a/rust/src/providers/opencode/billing.rs b/rust/src/providers/opencode/billing.rs index 2ac874dcb07..c5a4e4420dc 100644 --- a/rust/src/providers/opencode/billing.rs +++ b/rust/src/providers/opencode/billing.rs @@ -6,11 +6,10 @@ //! parsing, the HTTP fallback, and the presentation mapping. use serde_json::Value; -use uuid::Uuid; use crate::core::{ProviderError, RateWindow, UsageSnapshot}; -use super::{BASE_URL, OpenCodeProvider, SERVER_URL}; +use super::OpenCodeProvider; /// Customer/billing server function carrying the monthly spend fields /// pay-as-you-go workspaces bill against (upstream 0.49.5 #2504/#2697). @@ -139,28 +138,12 @@ impl OpenCodeProvider { cookie_header: &str, ) -> Result, ProviderError> { let referer = format!("https://opencode.ai/workspace/{workspace_id}"); - let args = serde_json::json!([workspace_id]); - let encoded_args = Self::url_encode(&args.to_string()); - let url = format!( - "{}?id={}&args={}", - SERVER_URL, BILLING_SERVER_ID, encoded_args - ); - let response = self - .client - .get(&url) - .header("Cookie", cookie_header) - .header("X-Server-Id", BILLING_SERVER_ID) - .header("X-Server-Instance", format!("server-fn:{}", Uuid::new_v4())) - .header( - "User-Agent", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", - ) - .header("Origin", BASE_URL) - .header("Referer", referer) - .header( - "Accept", - "text/javascript, application/json;q=0.9, */*;q=0.8", + .server_fn_get( + BILLING_SERVER_ID, + Some(workspace_id), + cookie_header, + &referer, ) .send() .await; diff --git a/rust/src/providers/opencode/mod.rs b/rust/src/providers/opencode/mod.rs index 82e9d2398ae..e7a4bd1f7d8 100755 --- a/rust/src/providers/opencode/mod.rs +++ b/rust/src/providers/opencode/mod.rs @@ -69,26 +69,42 @@ impl OpenCodeProvider { self.parse_subscription(&subscription) } - /// Fetch workspace ID from server - async fn fetch_workspace_id(&self, cookie_header: &str) -> Result { - let url = format!("{}?id={}", SERVER_URL, WORKSPACES_SERVER_ID); - - let response = self - .client + /// GET a SolidStart server function, optionally with a one-element + /// `[workspace_id]` argument list. + pub(super) fn server_fn_get( + &self, + server_id: &str, + workspace_arg: Option<&str>, + cookie_header: &str, + referer: &str, + ) -> reqwest::RequestBuilder { + let mut url = format!("{}?id={}", SERVER_URL, server_id); + if let Some(workspace_id) = workspace_arg { + let args = serde_json::json!([workspace_id]); + url.push_str("&args="); + url.push_str(&Self::url_encode(&args.to_string())); + } + self.client .get(&url) .header("Cookie", cookie_header) - .header("X-Server-Id", WORKSPACES_SERVER_ID) + .header("X-Server-Id", server_id) .header("X-Server-Instance", format!("server-fn:{}", Uuid::new_v4())) .header( "User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", ) .header("Origin", BASE_URL) - .header("Referer", BASE_URL) + .header("Referer", referer) .header( "Accept", "text/javascript, application/json;q=0.9, */*;q=0.8", ) + } + + /// Fetch workspace ID from server + async fn fetch_workspace_id(&self, cookie_header: &str) -> Result { + let response = self + .server_fn_get(WORKSPACES_SERVER_ID, None, cookie_header, BASE_URL) .send() .await?; @@ -125,28 +141,12 @@ impl OpenCodeProvider { cookie_header: &str, ) -> Result { let referer = format!("https://opencode.ai/workspace/{}/billing", workspace_id); - let args = serde_json::json!([workspace_id]); - let encoded_args = Self::url_encode(&args.to_string()); - let url = format!( - "{}?id={}&args={}", - SERVER_URL, SUBSCRIPTION_SERVER_ID, encoded_args - ); - let response = self - .client - .get(&url) - .header("Cookie", cookie_header) - .header("X-Server-Id", SUBSCRIPTION_SERVER_ID) - .header("X-Server-Instance", format!("server-fn:{}", Uuid::new_v4())) - .header( - "User-Agent", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", - ) - .header("Origin", BASE_URL) - .header("Referer", referer) - .header( - "Accept", - "text/javascript, application/json;q=0.9, */*;q=0.8", + .server_fn_get( + SUBSCRIPTION_SERVER_ID, + Some(workspace_id), + cookie_header, + &referer, ) .send() .await?; @@ -611,6 +611,73 @@ impl Provider for OpenCodeProvider { mod tests { use super::*; + fn request_headers(request: &reqwest::Request) -> Vec<(String, String)> { + request + .headers() + .iter() + .map(|(name, value)| (name.to_string(), value.to_str().unwrap().to_string())) + .collect() + } + + #[test] + fn server_fn_get_pins_url_and_header_order_for_each_call_site() { + let provider = OpenCodeProvider::new(); + let cases = [ + (WORKSPACES_SERVER_ID, None, BASE_URL, ""), + ( + SUBSCRIPTION_SERVER_ID, + Some("wrk_a b"), + "https://opencode.ai/workspace/wrk_a b/billing", + "&args=%5B%22wrk_a%20b%22%5D", + ), + ( + billing::BILLING_SERVER_ID, + Some("wrk_a b"), + "https://opencode.ai/workspace/wrk_a b", + "&args=%5B%22wrk_a%20b%22%5D", + ), + ]; + for (server_id, workspace_arg, referer, args) in cases { + let request = provider + .server_fn_get(server_id, workspace_arg, "auth=synthetic", referer) + .build() + .unwrap(); + assert_eq!(request.method(), reqwest::Method::GET); + assert_eq!( + request.url().as_str(), + format!("{SERVER_URL}?id={server_id}{args}") + ); + let headers = request_headers(&request); + let names: Vec<_> = headers.iter().map(|(name, _)| name.as_str()).collect(); + assert_eq!( + names, + [ + "cookie", + "x-server-id", + "x-server-instance", + "user-agent", + "origin", + "referer", + "accept" + ] + ); + assert_eq!(headers[0].1, "auth=synthetic"); + assert_eq!(headers[1].1, server_id); + let instance = headers[2].1.strip_prefix("server-fn:").unwrap(); + assert!(Uuid::parse_str(instance).is_ok()); + assert_eq!( + headers[3].1, + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" + ); + assert_eq!(headers[4].1, BASE_URL); + assert_eq!(headers[5].1, referer); + assert_eq!( + headers[6].1, + "text/javascript, application/json;q=0.9, */*;q=0.8" + ); + } + } + #[test] fn parses_json_renewal_window() { let provider = OpenCodeProvider::new(); From c8927f62c4d9f87751aa259e9d2039bab267996b Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:29:47 +0700 Subject: [PATCH 12/40] Table-drive OpenRouter key-meter and OpenCode parser tests --- rust/src/providers/opencode/mod.rs | 172 ++++++++++++----------- rust/src/providers/openrouter/mod.rs | 4 - rust/src/providers/openrouter/tests.rs | 181 +++++++++++-------------- 3 files changed, 159 insertions(+), 198 deletions(-) diff --git a/rust/src/providers/opencode/mod.rs b/rust/src/providers/opencode/mod.rs index e7a4bd1f7d8..b5b19796993 100755 --- a/rust/src/providers/opencode/mod.rs +++ b/rust/src/providers/opencode/mod.rs @@ -709,29 +709,91 @@ mod tests { } #[test] - fn parses_weekly_only_json_without_rolling() { + fn parse_usage_json_window_shapes() { let provider = OpenCodeProvider::new(); let now = DateTime::::from_timestamp(1_700_000_000, 0).unwrap(); - let payload = serde_json::json!({ - "weeklyUsage": { "usagePercent": 76.0, "resetInSec": 86400 } - }); - - let snap = provider.parse_usage_json(&payload, now).expect("snapshot"); - assert!((snap.primary.used_percent - 76.0).abs() < f64::EPSILON); - assert!(snap.secondary.is_none()); - } - - #[test] - fn parses_rolling_only_json_without_weekly() { - let provider = OpenCodeProvider::new(); - let now = DateTime::::from_timestamp(1_700_000_000, 0).unwrap(); - let payload = serde_json::json!({ - "rollingUsage": { "usagePercent": 12.5, "resetInSec": 600 } - }); - - let snap = provider.parse_usage_json(&payload, now).expect("snapshot"); - assert!((snap.primary.used_percent - 12.5).abs() < f64::EPSILON); - assert!(snap.secondary.is_none()); + let cases = [ + ( + "weekly only, without rolling", + serde_json::json!({ + "weeklyUsage": { "usagePercent": 76.0, "resetInSec": 86400 } + }), + 76.0, + None, + f64::EPSILON, + ), + ( + "rolling only, without weekly", + serde_json::json!({ + "rollingUsage": { "usagePercent": 12.5, "resetInSec": 600 } + }), + 12.5, + None, + f64::EPSILON, + ), + ( + "nested data.subscription shape", + serde_json::json!({ + "data": { + "subscription": { + "rollingUsage": { "usedPercent": 18.5, "resetInSeconds": 1200 }, + "weeklyUsage": { "percentUsed": 42, "resetsInSec": 86400 } + } + } + }), + 18.5, + Some(42.0), + f64::EPSILON, + ), + // The 0.25 week fraction scales to 25%. + ( + "fiveHour and week aliases", + serde_json::json!({ + "fiveHourUsage": { "pct": 11, "resetInSec": 300 }, + "weekUsage": { "value": 0.25, "resetInSec": 7200 } + }), + 11.0, + Some(25.0), + f64::EPSILON, + ), + // Upstream #2331: used=1, limit=100 is 1%, not 100% (false exhausted). + ( + "sub-one-percent computed used/limit is not rescaled to 100", + serde_json::json!({ + "rollingUsage": { "used": 1, "limit": 100, "resetInSec": 600 }, + "weeklyUsage": { "used": 1, "limit": 200, "resetInSec": 86400 } + }), + 1.0, + Some(0.5), + 0.001, + ), + ( + "direct fractional usagePercent still scales", + serde_json::json!({ + "rollingUsage": { "usagePercent": 0.25, "resetInSec": 600 } + }), + 25.0, + None, + 0.001, + ), + ]; + for (name, payload, primary, secondary, tolerance) in cases { + let snap = provider.parse_usage_json(&payload, now).expect(name); + assert!( + (snap.primary.used_percent - primary).abs() < tolerance, + "{name}: primary {}", + snap.primary.used_percent + ); + match (snap.secondary.as_ref(), secondary) { + (None, None) => {} + (Some(window), Some(expected)) => assert!( + (window.used_percent - expected).abs() < tolerance, + "{name}: secondary {}", + window.used_percent + ), + (actual, expected) => panic!("{name}: secondary {actual:?} vs {expected:?}"), + } + } } #[test] @@ -752,74 +814,6 @@ mod tests { assert!((snap.primary.used_percent - 55.0).abs() < f64::EPSILON); } - #[test] - fn parses_nested_data_subscription_shape() { - let provider = OpenCodeProvider::new(); - let now = DateTime::::from_timestamp(1_700_000_000, 0).unwrap(); - let payload = serde_json::json!({ - "data": { - "subscription": { - "rollingUsage": { "usedPercent": 18.5, "resetInSeconds": 1200 }, - "weeklyUsage": { "percentUsed": 42, "resetsInSec": 86400 } - } - } - }); - let snap = provider - .parse_usage_json(&payload, now) - .expect("nested snapshot"); - assert!((snap.primary.used_percent - 18.5).abs() < f64::EPSILON); - assert!((snap.secondary.as_ref().unwrap().used_percent - 42.0).abs() < f64::EPSILON); - } - - #[test] - fn parses_five_hour_and_week_aliases() { - let provider = OpenCodeProvider::new(); - let now = DateTime::::from_timestamp(1_700_000_000, 0).unwrap(); - let payload = serde_json::json!({ - "fiveHourUsage": { "pct": 11, "resetInSec": 300 }, - "weekUsage": { "value": 0.25, "resetInSec": 7200 } - }); - let snap = provider - .parse_usage_json(&payload, now) - .expect("alias snapshot"); - assert!((snap.primary.used_percent - 11.0).abs() < f64::EPSILON); - // 0.25 fraction scales to 25% - assert!((snap.secondary.as_ref().unwrap().used_percent - 25.0).abs() < f64::EPSILON); - } - - #[test] - fn sub_one_percent_computed_used_limit_is_not_rescaled_to_100() { - // Upstream #2331: used=1, limit=100 → 1%, not 100% (false exhausted). - let provider = OpenCodeProvider::new(); - let now = DateTime::::from_timestamp(1_700_000_000, 0).unwrap(); - let payload = serde_json::json!({ - "rollingUsage": { "used": 1, "limit": 100, "resetInSec": 600 }, - "weeklyUsage": { "used": 1, "limit": 200, "resetInSec": 86400 } - }); - let snap = provider.parse_usage_json(&payload, now).expect("snapshot"); - assert!( - (snap.primary.used_percent - 1.0).abs() < 0.001, - "primary {}", - snap.primary.used_percent - ); - assert!( - (snap.secondary.as_ref().unwrap().used_percent - 0.5).abs() < 0.001, - "secondary {}", - snap.secondary.as_ref().unwrap().used_percent - ); - } - - #[test] - fn direct_fractional_usage_percent_still_scales() { - let provider = OpenCodeProvider::new(); - let now = DateTime::::from_timestamp(1_700_000_000, 0).unwrap(); - let payload = serde_json::json!({ - "rollingUsage": { "usagePercent": 0.25, "resetInSec": 600 } - }); - let snap = provider.parse_usage_json(&payload, now).expect("snapshot"); - assert!((snap.primary.used_percent - 25.0).abs() < 0.001); - } - #[test] fn parse_subscription_accepts_realistic_server_fn_body() { let provider = OpenCodeProvider::new(); diff --git a/rust/src/providers/openrouter/mod.rs b/rust/src/providers/openrouter/mod.rs index 8f0bf132bd5..78c4410166c 100755 --- a/rust/src/providers/openrouter/mod.rs +++ b/rust/src/providers/openrouter/mod.rs @@ -506,10 +506,6 @@ impl OpenRouterProvider { /// reads 0%. Without it, fall back to the period usage matching the /// declared reset window, then cumulative usage; with no usable source the /// meter stays hidden. - fn add_key_quota(usage: &mut UsageSnapshot, key_data: &KeyData) { - Self::add_key_quota_with_suffix(usage, key_data, "Spending cap, not balance"); - } - fn add_key_quota_with_suffix(usage: &mut UsageSnapshot, key_data: &KeyData, suffix: &str) { let Some(key_window) = Self::key_quota_window(key_data, suffix) else { return; diff --git a/rust/src/providers/openrouter/tests.rs b/rust/src/providers/openrouter/tests.rs index 4c41d4dffd8..c6cb00f290a 100644 --- a/rust/src/providers/openrouter/tests.rs +++ b/rust/src/providers/openrouter/tests.rs @@ -73,9 +73,11 @@ fn key_data( } } +const KEY_CAP_SUFFIX: &str = "Spending cap, not balance"; + fn key_quota_percent(key_data: KeyData) -> Option { let mut usage = UsageSnapshot::new(RateWindow::new(0.0)); - OpenRouterProvider::add_key_quota(&mut usage, &key_data); + OpenRouterProvider::add_key_quota_with_suffix(&mut usage, &key_data, KEY_CAP_SUFFIX); usage.secondary.map(|window| window.used_percent) } @@ -89,7 +91,7 @@ fn key_limit_copy_stays_distinct_from_account_balance() { total_usage: 3.1, }; let mut usage = OpenRouterProvider::build_credits_usage(&credits); - OpenRouterProvider::add_key_quota( + OpenRouterProvider::add_key_quota_with_suffix( &mut usage, &key_data( Some(30.0), @@ -100,6 +102,7 @@ fn key_limit_copy_stays_distinct_from_account_balance() { None, Some(0.0), ), + KEY_CAP_SUFFIX, ); assert_eq!(usage.login_method.as_deref(), Some("$1.90 balance")); let key = usage.secondary.expect("key spending cap"); @@ -267,114 +270,82 @@ fn activity_cost_wins_while_uncapped_key_spend_windows_remain() { } #[test] -fn server_remaining_replaces_lifetime_usage_for_meter() { - // limit 50, server says 12.50 left this period → 75% used, even though - // cumulative lifetime usage would imply a different ratio. - let pct = key_quota_percent(key_data( - Some(50.0), - Some(12.5), - None, - Some(40.0), - None, - None, - None, - )); - assert_eq!(pct, Some(75.0)); -} - -#[test] -fn negative_server_remaining_reads_exhausted() { - // Upstream: "treat negative remaining as exhausted quota". - let pct = key_quota_percent(key_data( - Some(50.0), - Some(-3.0), - None, - Some(10.0), - None, - None, - None, - )); - assert_eq!(pct, Some(100.0)); -} - -#[test] -fn above_limit_server_remaining_reads_zero() { - // Inclusive [0, keyLimit] clamp: a server remaining above the - // configured limit renders 0% used, not a suppressed meter. - let pct = key_quota_percent(key_data( - Some(50.0), - Some(75.0), - None, - Some(10.0), - None, - None, - None, - )); - assert_eq!(pct, Some(0.0)); -} - -#[test] -fn reset_window_usage_is_the_preferred_fallback() { - // No remaining: `limit_reset: "monthly"` picks usage_monthly (25/50). - let pct = key_quota_percent(key_data( - Some(50.0), - None, - Some("monthly"), - Some(40.0), - Some(1.0), - Some(2.0), - Some(25.0), - )); - assert_eq!(pct, Some(50.0)); - // Case-insensitive reset label. - let pct = key_quota_percent(key_data( - Some(50.0), - None, - Some("WEEKLY"), - Some(40.0), - Some(1.0), - Some(2.0), - Some(25.0), - )); - assert_eq!(pct, Some(4.0)); -} - -#[test] -fn cumulative_usage_is_the_last_fallback() { - let pct = key_quota_percent(key_data( - Some(50.0), - None, - None, - Some(20.0), - Some(1.0), - None, - None, - )); - assert_eq!(pct, Some(40.0)); -} - -#[test] -fn no_usable_quota_source_hides_the_meter() { - assert_eq!( - key_quota_percent(key_data(Some(50.0), None, None, None, None, None, None)), - None +fn key_meter_prefers_server_remaining_then_reset_window_then_cumulative_usage() { + type Case = ( + &'static str, + (Option, Option, Option<&'static str>, Option), + (Option, Option, Option), + Option, ); - assert_eq!( - key_quota_percent(key_data( + let cases: [Case; 9] = [ + // limit 50, server says 12.50 left this period: 75% used, even though + // cumulative lifetime usage would imply a different ratio. + ( + "server remaining replaces lifetime usage", + (Some(50.0), Some(12.5), None, Some(40.0)), + (None, None, None), + Some(75.0), + ), + // Upstream: "treat negative remaining as exhausted quota". + ( + "negative server remaining reads exhausted", + (Some(50.0), Some(-3.0), None, Some(10.0)), + (None, None, None), + Some(100.0), + ), + // Inclusive [0, keyLimit] clamp: a server remaining above the + // configured limit renders 0% used, not a suppressed meter. + ( + "above-limit server remaining reads zero", + (Some(50.0), Some(75.0), None, Some(10.0)), + (None, None, None), Some(0.0), - Some(5.0), + ), + // No remaining: `limit_reset: "monthly"` picks usage_monthly (25/50). + ( + "reset window usage is the preferred fallback", + (Some(50.0), None, Some("monthly"), Some(40.0)), + (Some(1.0), Some(2.0), Some(25.0)), + Some(50.0), + ), + // Case-insensitive reset label. + ( + "reset window label is case-insensitive", + (Some(50.0), None, Some("WEEKLY"), Some(40.0)), + (Some(1.0), Some(2.0), Some(25.0)), + Some(4.0), + ), + ( + "cumulative usage is the last fallback", + (Some(50.0), None, None, Some(20.0)), + (Some(1.0), None, None), + Some(40.0), + ), + ( + "no usable quota source hides the meter", + (Some(50.0), None, None, None), + (None, None, None), None, - Some(1.0), + ), + ( + "zero limit hides the meter", + (Some(0.0), Some(5.0), None, Some(1.0)), + (None, None, None), None, + ), + ( + "missing limit hides the meter", + (None, Some(5.0), None, Some(1.0)), + (None, None, None), None, - None - )), - None - ); - assert_eq!( - key_quota_percent(key_data(None, Some(5.0), None, Some(1.0), None, None, None)), - None - ); + ), + ]; + for (name, (limit, remaining, reset, usage), (daily, weekly, monthly), expected) in cases { + let pct = key_quota_percent(key_data( + limit, remaining, reset, usage, daily, weekly, monthly, + )); + assert_eq!(pct, expected, "{name}"); + } } #[test] From 101d979740cc0825140236f2c4160a4581773e99 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:33:25 +0700 Subject: [PATCH 13/40] Share the OpenRouter bearer JSON GET and spend-window table --- rust/src/providers/openrouter/mod.rs | 128 ++++++++++++------------- rust/src/providers/openrouter/tests.rs | 104 ++++++++++++++++++++ 2 files changed, 165 insertions(+), 67 deletions(-) diff --git a/rust/src/providers/openrouter/mod.rs b/rust/src/providers/openrouter/mod.rs index 78c4410166c..6bf7621dd23 100755 --- a/rust/src/providers/openrouter/mod.rs +++ b/rust/src/providers/openrouter/mod.rs @@ -296,26 +296,48 @@ impl OpenRouterProvider { management_key: &str, date: Option<&str>, ) -> Result { - let mut request = client - .get(OPENROUTER_ACTIVITY_URL) - .header("Authorization", format!("Bearer {management_key}")) - .header("Accept", "application/json"); + let mut request = client.get(OPENROUTER_ACTIVITY_URL); if let Some(date) = date { request = request.query(&[("date", date)]); } - let response = request.send().await?; + Self::get_json( + request, + management_key, + "Activity", + AUTH_REJECTED, + Some(diagnostics::ACTIVITY_KEY_REQUIRED), + ) + .await + } + + /// Bearer-authenticated JSON GET. Statuses in `auth_statuses` are typed as + /// auth failures; a 403 shows `forbidden_reason` when one is given. + async fn get_json( + request: reqwest::RequestBuilder, + api_key: &str, + label: &str, + auth_statuses: &[reqwest::StatusCode], + forbidden_reason: Option<&str>, + ) -> Result { + let response = request + .header("Authorization", format!("Bearer {api_key}")) + .header("Accept", "application/json") + .send() + .await?; let status = response.status(); - if status == reqwest::StatusCode::FORBIDDEN { - return Err(Degraded::http("Activity", status, AUTH_REJECTED) - .with_reason(diagnostics::ACTIVITY_KEY_REQUIRED)); - } if !status.is_success() { - return Err(Degraded::http("Activity", status, AUTH_REJECTED)); + let degraded = Degraded::http(label, status, auth_statuses); + return Err(match forbidden_reason { + Some(reason) if status == reqwest::StatusCode::FORBIDDEN => { + degraded.with_reason(reason) + } + _ => degraded, + }); } response - .json::() + .json::() .await - .map_err(|error| Degraded::body("Activity", error)) + .map_err(|error| Degraded::body(label, error)) } fn build_client() -> Result { @@ -330,25 +352,14 @@ impl OpenRouterProvider { api_key: &str, ) -> Result { let credits_url = format!("{}/credits", OPENROUTER_API_BASE); - let resp = client - .get(&credits_url) - .header("Authorization", format!("Bearer {}", api_key)) - .header("Accept", "application/json") - .send() - .await?; - - if !resp.status().is_success() { - return Err(Degraded::http( - "credits", - resp.status(), - &[reqwest::StatusCode::UNAUTHORIZED], - )); - } - - let response = resp - .json::() - .await - .map_err(|error| Degraded::body("credits", error))?; + let response: CreditsResponse = Self::get_json( + client.get(&credits_url), + api_key, + "credits", + &[reqwest::StatusCode::UNAUTHORIZED], + None, + ) + .await?; response.data.validate().map_err(Degraded::invalid)?; Ok(response) } @@ -430,21 +441,8 @@ impl OpenRouterProvider { async fn fetch_key_data(client: &reqwest::Client, api_key: &str) -> Result { let key_url = format!("{}/key", OPENROUTER_API_BASE); - let key_resp = client - .get(&key_url) - .header("Authorization", format!("Bearer {}", api_key)) - .header("Accept", "application/json") - .send() - .await?; - - if !key_resp.status().is_success() { - return Err(Degraded::http("key", key_resp.status(), AUTH_REJECTED)); - } - - let response = key_resp - .json::() - .await - .map_err(|error| Degraded::body("key", error))?; + let response: KeyResponse = + Self::get_json(client.get(&key_url), api_key, "key", AUTH_REJECTED, None).await?; response.data.validate().map_err(Degraded::invalid)?; Ok(response.data) } @@ -455,27 +453,23 @@ impl OpenRouterProvider { } fn add_spend_windows(usage: &mut UsageSnapshot, key_data: &KeyData) { - Self::add_spend_window( - usage, - key_data.usage_daily, - "daily-spend", - "Daily spend", - "today", - ); - Self::add_spend_window( - usage, - key_data.usage_weekly, - "weekly-spend", - "Weekly spend", - "this week", - ); - Self::add_spend_window( - usage, - key_data.usage_monthly, - "monthly-spend", - "Monthly spend", - "this month", - ); + for (value, id, label, period) in [ + (key_data.usage_daily, "daily-spend", "Daily spend", "today"), + ( + key_data.usage_weekly, + "weekly-spend", + "Weekly spend", + "this week", + ), + ( + key_data.usage_monthly, + "monthly-spend", + "Monthly spend", + "this month", + ), + ] { + Self::add_spend_window(usage, value, id, label, period); + } } fn key_quota_metrics(key_data: &KeyData) -> Option<(f64, f64, f64)> { diff --git a/rust/src/providers/openrouter/tests.rs b/rust/src/providers/openrouter/tests.rs index c6cb00f290a..bfd6807e07e 100644 --- a/rust/src/providers/openrouter/tests.rs +++ b/rust/src/providers/openrouter/tests.rs @@ -531,3 +531,107 @@ fn missing_key_message_explains_primary_and_management_fields() { "Enter a regular API key or a Management API key in the API key field, or set OPENROUTER_API_KEY. In Settings, the optional Management API key field does not replace it." ); } + +// ── Shared JSON GET: status typing and safe reasons per call site ── + +#[tokio::test] +async fn get_json_keeps_each_call_sites_auth_set_and_reason() { + use reqwest::StatusCode; + + let mut server = mockito::Server::new_async().await; + let client = reqwest::Client::new(); + let credits_auth = &[StatusCode::UNAUTHORIZED][..]; + // (label, auth statuses, forbidden reason, HTTP status, body, + // expected error, expected reason) + let cases: [(&str, &[StatusCode], Option<&str>, usize, &str, &str, &str); 7] = [ + ("credits", credits_auth, None, 200, r#"{"ok":1}"#, "", ""), + ( + "credits", + credits_auth, + None, + 401, + "", + "auth", + "Request returned HTTP 401", + ), + ( + "credits", + credits_auth, + None, + 403, + "", + "OpenRouter credits request returned HTTP 403 Forbidden", + "Request returned HTTP 403", + ), + ( + "key", + AUTH_REJECTED, + None, + 403, + "", + "auth", + "Request returned HTTP 403", + ), + ( + "key", + AUTH_REJECTED, + None, + 200, + "not json", + "OpenRouter key response was invalid:", + "Response was invalid", + ), + ( + "Activity", + AUTH_REJECTED, + Some(ACTIVITY_KEY_REQUIRED), + 403, + "", + "auth", + ACTIVITY_KEY_REQUIRED, + ), + ( + "Activity", + AUTH_REJECTED, + Some(ACTIVITY_KEY_REQUIRED), + 500, + "", + "OpenRouter Activity request returned HTTP 500 Internal Server Error", + "Request returned HTTP 500", + ), + ]; + for (index, (label, auth, forbidden, status, body, error, reason)) in + cases.into_iter().enumerate() + { + let path = format!("/case-{index}"); + let mock = server + .mock("GET", path.as_str()) + .match_header("authorization", "Bearer sk-or-synthetic") + .match_header("accept", "application/json") + .with_status(status) + .with_body(body) + .create_async() + .await; + let request = client.get(format!("{}{path}", server.url())); + let result: Result = + OpenRouterProvider::get_json(request, "sk-or-synthetic", label, auth, forbidden).await; + mock.assert_async().await; + match result { + Ok(value) => { + assert_eq!(error, "", "{label} {status}"); + assert_eq!(value, serde_json::json!({ "ok": 1 })); + } + Err(degraded) => { + assert_eq!(degraded.reason, reason, "{label} {status}"); + match degraded.error { + ProviderError::AuthRequired => assert_eq!(error, "auth", "{label} {status}"), + ProviderError::Other(message) => assert_eq!(message, error), + ProviderError::Parse(message) => { + assert!(message.starts_with(error), "{message}") + } + other => panic!("{label} {status}: unexpected {other:?}"), + } + } + } + } +} From 8a5703b89d2e26ed9a4f05b805fd3799ce93602e Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:41:17 +0700 Subject: [PATCH 14/40] Share OpenRouter and OpenCode Go error constructors --- rust/src/providers/opencodego/console.rs | 133 ++++++++++++++++------ rust/src/providers/opencodego/local.rs | 62 ++++++---- rust/src/providers/openrouter/activity.rs | 129 +++++++++++++-------- 3 files changed, 215 insertions(+), 109 deletions(-) diff --git a/rust/src/providers/opencodego/console.rs b/rust/src/providers/opencodego/console.rs index 6a1d0daa34e..31a28dbc13b 100644 --- a/rust/src/providers/opencodego/console.rs +++ b/rust/src/providers/opencodego/console.rs @@ -64,7 +64,7 @@ pub(super) async fn fetch_workspace_id( parse_workspace_ids(&text) .into_iter() .next() - .ok_or_else(|| ProviderError::Parse("Missing OpenCode Console workspace ID".to_string())) + .ok_or_else(|| missing("workspace ID")) } pub(super) async fn fetch_usage( @@ -145,26 +145,21 @@ fn parse_workspace_ids(text: &str) -> Vec { } fn parse_usage(text: &str, _now: DateTime) -> Result { - let root: Value = serde_json::from_str(text) - .map_err(|_| ProviderError::Parse("Invalid OpenCode Console usage payload".to_string()))?; + let root: Value = serde_json::from_str(text).map_err(|_| invalid("usage payload"))?; if root.is_null() || root.get("access").is_some_and(Value::is_null) { return Ok(ConsoleUsage::NoSubscription); } let access = root .get("access") .and_then(Value::as_object) - .ok_or_else(|| { - ProviderError::Parse("Invalid OpenCode Console usage payload".to_string()) - })?; + .ok_or_else(|| invalid("usage payload"))?; let meters = access .get("meters") .and_then(Value::as_object) - .ok_or_else(|| { - ProviderError::Parse("Invalid OpenCode Console usage payload".to_string()) - })?; - let rolling = meters.get("fiveHour").ok_or_else(|| { - ProviderError::Parse("Invalid OpenCode Console usage payload".to_string()) - })?; + .ok_or_else(|| invalid("usage payload"))?; + let rolling = meters + .get("fiveHour") + .ok_or_else(|| invalid("usage payload"))?; let ends_at = access.get("endsAt").and_then(date_value); let primary = meter_window(rolling, Some(300), None)?; let mut snapshot = UsageSnapshot::new(primary).with_login_method("OpenCode Go"); @@ -192,14 +187,12 @@ fn meter_window( window_minutes: Option, reset_override: Option>, ) -> Result { - let object = meter - .as_object() - .ok_or_else(|| ProviderError::Parse("Invalid OpenCode Console usage meter".to_string()))?; - let used = numeric_value(object.get("usedMicroCents")) - .ok_or_else(|| ProviderError::Parse("Missing OpenCode Console usage amount".to_string()))?; + let object = meter.as_object().ok_or_else(|| invalid("usage meter"))?; + let used = + numeric_value(object.get("usedMicroCents")).ok_or_else(|| missing("usage amount"))?; let limit = numeric_value(object.get("limitMicroCents")) .filter(|value| *value > 0.0) - .ok_or_else(|| ProviderError::Parse("Missing OpenCode Console usage limit".to_string()))?; + .ok_or_else(|| missing("usage limit"))?; let resets_at = reset_override.or_else(|| object.get("resetsAt").and_then(date_value)); Ok(RateWindow::with_details( ((used / limit) * 100.0).clamp(0.0, 100.0), @@ -210,47 +203,43 @@ fn meter_window( } fn parse_balance(text: &str) -> Result, ProviderError> { - let root: Value = serde_json::from_str(text).map_err(|_| { - ProviderError::Parse("Invalid OpenCode Console billing payload".to_string()) - })?; + let root: Value = serde_json::from_str(text).map_err(|_| invalid("billing payload"))?; let billing_mode = root .get("billingMode") .and_then(Value::as_str) .filter(|mode| matches!(*mode, "prepaid" | "legacy" | "seat" | "credit")) - .ok_or_else(|| { - ProviderError::Parse("Invalid OpenCode Console billing payload".to_string()) - })?; + .ok_or_else(|| invalid("billing payload"))?; let mode = root .get("mode") .and_then(Value::as_str) .filter(|mode| matches!(*mode, "pay-as-you-go" | "invoiceable")) - .ok_or_else(|| { - ProviderError::Parse("Invalid OpenCode Console billing payload".to_string()) - })?; + .ok_or_else(|| invalid("billing payload"))?; if billing_mode != "prepaid" || mode != "pay-as-you-go" { return Ok(None); } let raw = root .get("balanceMicroCents") .and_then(Value::as_str) - .ok_or_else(|| ProviderError::Parse("Missing OpenCode Console balance".to_string()))?; + .ok_or_else(|| missing("balance"))?; let digits = raw.strip_prefix('-').unwrap_or(raw); if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_digit()) { - return Err(ProviderError::Parse( - "Invalid OpenCode Console balance".to_string(), - )); + return Err(invalid("balance")); } - let balance = raw - .parse::() - .map_err(|_| ProviderError::Parse("Invalid OpenCode Console balance".to_string()))?; + let balance = raw.parse::().map_err(|_| invalid("balance"))?; if !balance.is_finite() { - return Err(ProviderError::Parse( - "Invalid OpenCode Console balance".to_string(), - )); + return Err(invalid("balance")); } Ok(Some(balance / BILLING_SCALE)) } +fn invalid(what: &str) -> ProviderError { + ProviderError::Parse(format!("Invalid OpenCode Console {what}")) +} + +fn missing(what: &str) -> ProviderError { + ProviderError::Parse(format!("Missing OpenCode Console {what}")) +} + fn numeric_value(value: Option<&Value>) -> Option { match value? { Value::Number(number) => number.as_f64().filter(|value| value.is_finite()), @@ -296,6 +285,76 @@ mod tests { ) } + #[test] + fn console_parse_errors_keep_their_messages() { + let usage_cases = [ + ("not json", "Invalid OpenCode Console usage payload"), + (r#"{"access":5}"#, "Invalid OpenCode Console usage payload"), + (r#"{"access":{}}"#, "Invalid OpenCode Console usage payload"), + ( + r#"{"access":{"meters":{}}}"#, + "Invalid OpenCode Console usage payload", + ), + ( + r#"{"access":{"meters":{"fiveHour":5}}}"#, + "Invalid OpenCode Console usage meter", + ), + ( + r#"{"access":{"meters":{"fiveHour":{}}}}"#, + "Missing OpenCode Console usage amount", + ), + ( + r#"{"access":{"meters":{"fiveHour":{"usedMicroCents":"1"}}}}"#, + "Missing OpenCode Console usage limit", + ), + ]; + for (text, expected) in usage_cases { + let error = parse_usage(text, now()).err().expect(text); + assert!( + matches!(&error, ProviderError::Parse(message) if message == expected), + "{text}: {error:?}" + ); + } + + let huge = "9".repeat(400); + let balance_cases = [ + ( + "not json".to_string(), + "Invalid OpenCode Console billing payload", + ), + ( + r#"{"billingMode":"other","mode":"pay-as-you-go"}"#.to_string(), + "Invalid OpenCode Console billing payload", + ), + ( + r#"{"billingMode":"prepaid"}"#.to_string(), + "Invalid OpenCode Console billing payload", + ), + ( + r#"{"billingMode":"prepaid","mode":"pay-as-you-go"}"#.to_string(), + "Missing OpenCode Console balance", + ), + ( + r#"{"billingMode":"prepaid","mode":"pay-as-you-go","balanceMicroCents":"12a"}"# + .to_string(), + "Invalid OpenCode Console balance", + ), + ( + format!( + r#"{{"billingMode":"prepaid","mode":"pay-as-you-go","balanceMicroCents":"{huge}"}}"# + ), + "Invalid OpenCode Console balance", + ), + ]; + for (text, expected) in balance_cases { + let error = parse_balance(&text).unwrap_err(); + assert!( + matches!(&error, ProviderError::Parse(message) if message == expected), + "{text}: {error:?}" + ); + } + } + #[test] fn parses_console_workspace_ids_and_urls() { assert_eq!( diff --git a/rust/src/providers/opencodego/local.rs b/rust/src/providers/opencodego/local.rs index abb36ff9559..757b4e85499 100644 --- a/rust/src/providers/opencodego/local.rs +++ b/rust/src/providers/opencodego/local.rs @@ -164,11 +164,7 @@ pub fn fetch_local_usage(now: DateTime) -> Result last_err = Some(e), } } - Err(last_err.unwrap_or_else(|| { - ProviderError::NotInstalled( - "OpenCode Go not detected. Log in with OpenCode Go or use it locally first.".into(), - ) - })) + Err(last_err.unwrap_or_else(not_detected)) } pub fn fetch_from_paths( @@ -183,17 +179,13 @@ pub fn fetch_from_paths( "OpenCode Go local usage history is unavailable: database not found".into(), ) } else { - ProviderError::NotInstalled( - "OpenCode Go not detected. Log in with OpenCode Go or use it locally first.".into(), - ) + not_detected() }); } let rows = read_rows(db_path)?; if !has_auth && rows.is_empty() { - return Err(ProviderError::NotInstalled( - "OpenCode Go not detected. Log in with OpenCode Go or use it locally first.".into(), - )); + return Err(not_detected()); } if rows.is_empty() { return Err(ProviderError::Other( @@ -204,6 +196,16 @@ pub fn fetch_from_paths( Ok(snapshot_from_rows(&rows, now)) } +fn not_detected() -> ProviderError { + ProviderError::NotInstalled( + "OpenCode Go not detected. Log in with OpenCode Go or use it locally first.".into(), + ) +} + +fn sqlite_err(e: rusqlite::Error) -> ProviderError { + ProviderError::Other(format!("SQLite error reading OpenCode Go usage: {e}")) +} + fn has_auth_key(path: &Path) -> bool { let Ok(text) = std::fs::read_to_string(path) else { return false; @@ -221,9 +223,7 @@ fn has_auth_key(path: &Path) -> bool { fn read_rows(db_path: &Path) -> Result, ProviderError> { let conn = open_readonly_connection(db_path)?; conn.busy_timeout(std::time::Duration::from_millis(250)) - .map_err(|e| { - ProviderError::Other(format!("SQLite error reading OpenCode Go usage: {e}")) - })?; + .map_err(sqlite_err)?; let sql = if has_table(&conn, "part") { MESSAGE_AND_PART_USAGE_SQL @@ -231,9 +231,7 @@ fn read_rows(db_path: &Path) -> Result, ProviderError> { MESSAGE_USAGE_SQL }; - let mut stmt = conn.prepare(sql).map_err(|e| { - ProviderError::Other(format!("SQLite error reading OpenCode Go usage: {e}")) - })?; + let mut stmt = conn.prepare(sql).map_err(sqlite_err)?; let rows = stmt .query_map([], |row| { Ok(UsageRow { @@ -253,15 +251,11 @@ fn read_rows(db_path: &Path) -> Result, ProviderError> { .and_then(|json| RowTokens::parse(&json)), }) }) - .map_err(|e| { - ProviderError::Other(format!("SQLite error reading OpenCode Go usage: {e}")) - })?; + .map_err(sqlite_err)?; let mut out = Vec::new(); for row in rows { - let row = row.map_err(|e| { - ProviderError::Other(format!("SQLite error reading OpenCode Go usage: {e}")) - })?; + let row = row.map_err(sqlite_err)?; if row.created_ms > 0 && row.cost.is_finite() && row.cost >= 0.0 { out.push(row); } @@ -273,7 +267,7 @@ fn read_rows(db_path: &Path) -> Result, ProviderError> { /// WAL-mode databases (upstream #2544). fn open_readonly_connection(db_path: &Path) -> Result { crate::core::open_readonly_sqlite_connection(db_path, std::time::Duration::from_millis(250)) - .map_err(|e| ProviderError::Other(format!("SQLite error reading OpenCode Go usage: {e}"))) + .map_err(sqlite_err) } fn has_table(conn: &Connection, name: &str) -> bool { @@ -771,6 +765,20 @@ mod tests { .timestamp_millis() } + #[test] + fn unreadable_database_reports_the_sqlite_error_prefix() { + let db = temp_db_path("garbage"); + std::fs::write(&db, b"not a sqlite database, just synthetic bytes").unwrap(); + let err = read_rows(&db).unwrap_err(); + // Best-effort teardown; the temp file may already be gone. + let _removed = std::fs::remove_file(&db); + assert!( + matches!(&err, ProviderError::Other(message) + if message.starts_with("SQLite error reading OpenCode Go usage: ")), + "{err:?}" + ); + } + #[test] fn not_detected_without_db_or_auth() { let dir = std::env::temp_dir().join(format!( @@ -785,7 +793,11 @@ mod tests { let auth = dir.join("auth.json"); let db = dir.join("opencode.db"); let err = fetch_from_paths(&auth, &db, Utc::now()).unwrap_err(); - assert!(matches!(err, ProviderError::NotInstalled(_))); + assert!(matches!( + &err, + ProviderError::NotInstalled(message) + if message == "OpenCode Go not detected. Log in with OpenCode Go or use it locally first." + )); // Best-effort teardown; temp dir may already be gone. let _removed_dir = std::fs::remove_dir_all(&dir); } diff --git a/rust/src/providers/openrouter/activity.rs b/rust/src/providers/openrouter/activity.rs index d164320cf88..3d98bf93e62 100644 --- a/rust/src/providers/openrouter/activity.rs +++ b/rust/src/providers/openrouter/activity.rs @@ -71,35 +71,21 @@ pub(super) fn parse_activity_cost( )); } for (index, row) in rows.iter().enumerate() { - let object = row.as_object().ok_or_else(|| { - ProviderError::Parse(format!( - "OpenRouter activity.data[{index}] must be an object" - )) - })?; + let object = row + .as_object() + .ok_or_else(|| row_err(index, " must be an object"))?; let raw_day = object .get("date") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) - .ok_or_else(|| { - ProviderError::Parse(format!( - "OpenRouter activity.data[{index}].date is missing" - )) - })?; - let day = normalize_activity_day(raw_day).ok_or_else(|| { - ProviderError::Parse(format!( - "OpenRouter activity.data[{index}].date must be YYYY-MM-DD or YYYY-MM-DD HH:MM:SS" - )) - })?; - let parsed_day = chrono::NaiveDate::parse_from_str(day, "%Y-%m-%d").map_err(|_| { - ProviderError::Parse(format!( - "OpenRouter activity.data[{index}].date must be a real calendar date" - )) - })?; + .ok_or_else(|| row_err(index, ".date is missing"))?; + let day = normalize_activity_day(raw_day) + .ok_or_else(|| row_err(index, ".date must be YYYY-MM-DD or YYYY-MM-DD HH:MM:SS"))?; + let parsed_day = chrono::NaiveDate::parse_from_str(day, "%Y-%m-%d") + .map_err(|_| row_err(index, ".date must be a real calendar date"))?; if parsed_day > latest_completed { - return Err(ProviderError::Parse(format!( - "OpenRouter activity.data[{index}].date must be a completed UTC day" - ))); + return Err(row_err(index, ".date must be a completed UTC day")); } if parsed_day < cutoff { continue; @@ -111,9 +97,7 @@ pub(super) fn parse_activity_cost( .map(str::trim) .unwrap_or(""); if model.len() > 64 { - return Err(ProviderError::Parse(format!( - "OpenRouter activity.data[{index}].model exceeds 64 characters" - ))); + return Err(row_err(index, ".model exceeds 64 characters")); } let prompt = nonnegative_integer(object.get("prompt_tokens"), index, "prompt_tokens")?; let completion = @@ -126,9 +110,7 @@ pub(super) fn parse_activity_cost( .checked_add(completion) .is_none_or(|total| total > MAX_SAFE_INTEGER) { - return Err(ProviderError::Parse(format!( - "OpenRouter activity.data[{index}] token total overflowed" - ))); + return Err(row_err(index, " token total overflowed")); } let requests = nonnegative_integer(object.get("requests"), index, "requests")?; let metered = nonnegative_number(object.get("usage"), index, "usage")?; @@ -249,25 +231,24 @@ fn normalize_activity_day(raw: &str) -> Option<&str> { Some(&raw[..10]) } +fn row_err(index: usize, detail: &str) -> ProviderError { + ProviderError::Parse(format!("OpenRouter activity.data[{index}]{detail}")) +} + fn nonnegative_integer( value: Option<&Value>, index: usize, field: &str, ) -> Result { - let value = value.ok_or_else(|| { - ProviderError::Parse(format!( - "OpenRouter activity.data[{index}].{field} is missing" - )) - })?; - let value = value.as_u64().ok_or_else(|| { - ProviderError::Parse(format!( - "OpenRouter activity.data[{index}].{field} must be a nonnegative integer" - )) - })?; + let value = value.ok_or_else(|| row_err(index, &format!(".{field} is missing")))?; + let value = value + .as_u64() + .ok_or_else(|| row_err(index, &format!(".{field} must be a nonnegative integer")))?; if value > MAX_SAFE_INTEGER { - return Err(ProviderError::Parse(format!( - "OpenRouter activity.data[{index}].{field} must be a nonnegative safe integer" - ))); + return Err(row_err( + index, + &format!(".{field} must be a nonnegative safe integer"), + )); } Ok(value) } @@ -280,11 +261,7 @@ fn nonnegative_number( value .and_then(Value::as_f64) .filter(|value| value.is_finite() && *value >= 0.0) - .ok_or_else(|| { - ProviderError::Parse(format!( - "OpenRouter activity.data[{index}].{field} must be finite and nonnegative" - )) - }) + .ok_or_else(|| row_err(index, &format!(".{field} must be finite and nonnegative"))) } #[cfg(test)] @@ -297,6 +274,64 @@ mod tests { .with_timezone(&Utc) } + #[test] + fn activity_row_errors_name_the_row_index_and_field() { + let long_model = "m".repeat(65); + let cases = [ + (serde_json::json!(5), " must be an object"), + (serde_json::json!({}), ".date is missing"), + ( + serde_json::json!({"date":"2026/08/21"}), + ".date must be YYYY-MM-DD or YYYY-MM-DD HH:MM:SS", + ), + ( + serde_json::json!({"date":"2026-02-31"}), + ".date must be a real calendar date", + ), + ( + serde_json::json!({"date":"2026-08-22"}), + ".date must be a completed UTC day", + ), + ( + serde_json::json!({"date":"2026-08-21","model":long_model}), + ".model exceeds 64 characters", + ), + ( + serde_json::json!({"date":"2026-08-21"}), + ".prompt_tokens is missing", + ), + ( + serde_json::json!({"date":"2026-08-21","prompt_tokens":-1}), + ".prompt_tokens must be a nonnegative integer", + ), + ( + serde_json::json!({"date":"2026-08-21","prompt_tokens":MAX_SAFE_INTEGER + 1}), + ".prompt_tokens must be a nonnegative safe integer", + ), + ( + serde_json::json!({"date":"2026-08-21","prompt_tokens":MAX_SAFE_INTEGER, + "completion_tokens":1}), + " token total overflowed", + ), + ( + serde_json::json!({"date":"2026-08-21","prompt_tokens":1,"completion_tokens":1, + "requests":1,"usage":-1.0}), + ".usage must be finite and nonnegative", + ), + ]; + for (row, detail) in cases { + let valid = serde_json::json!({"date":"2026-08-21","model":"m","prompt_tokens":1, + "completion_tokens":1,"requests":1,"usage":0.1}); + let payload = serde_json::json!({ "data": [valid, row] }); + let error = parse_activity_cost(&[payload], now()).unwrap_err(); + assert!( + matches!(&error, ProviderError::Parse(message) + if *message == format!("OpenRouter activity.data[1]{detail}")), + "{detail}: {error:?}" + ); + } + } + #[test] fn aggregates_metered_and_byok_spend_without_double_counting_latest_completed_day() { let history = serde_json::json!({"data":[ From 7aea5310405b79456958b3ca3d921005c526897d Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:48:22 +0700 Subject: [PATCH 15/40] Share the OpenCode Go snapshot builder --- rust/src/providers/opencodego/legacy.rs | 47 ++++++++---------- rust/src/providers/opencodego/local.rs | 58 ++++++++++++++-------- rust/src/providers/opencodego/mod.rs | 40 +++++++++------ rust/src/providers/opencodego/usage_api.rs | 50 ++++++++++--------- 4 files changed, 111 insertions(+), 84 deletions(-) diff --git a/rust/src/providers/opencodego/legacy.rs b/rust/src/providers/opencodego/legacy.rs index c876c1b8c5a..691db6b6008 100644 --- a/rust/src/providers/opencodego/legacy.rs +++ b/rust/src/providers/opencodego/legacy.rs @@ -200,30 +200,8 @@ fn parse_usage_text(text: &str) -> Result { let weekly = extract_window(text, &["weeklyUsage", "weekly_usage", "weekly"]); let monthly = extract_window(text, &["monthlyUsage", "monthly_usage", "monthly"]); - let primary = RateWindow::with_details( - rolling.0, - Some(300), - Some(now + chrono::Duration::seconds(rolling.1)), - None, - ); - let mut snapshot = UsageSnapshot::new(primary).with_login_method("OpenCode Go"); - if let Some((percent, reset)) = weekly { - snapshot = snapshot.with_secondary(RateWindow::with_details( - percent, - Some(10080), - Some(now + chrono::Duration::seconds(reset)), - None, - )); - } - if let Some((percent, reset)) = monthly { - let resets_at = now + chrono::Duration::seconds(reset); - snapshot = snapshot.with_tertiary(RateWindow::with_details( - percent, - RateWindow::monthly_window_minutes(Some(resets_at)).or(Some(43200)), - Some(resets_at), - None, - )); - } + let at = |(percent, reset): (f64, i64)| (percent, now + chrono::Duration::seconds(reset)); + let mut snapshot = super::go_snapshot(at(rolling), weekly.map(at), monthly.map(at)); if let Some(renews_at) = super::super::extract_renewal(text) { snapshot = snapshot.with_extra_rate_window( "renewal", @@ -410,10 +388,27 @@ mod tests { weeklyUsage: { usagePercent: 13, resetInSec: 86400 } monthlyUsage: { usagePercent: 7, resetInSec: 2592000 } "#; + let before = Utc::now(); let snapshot = parse_usage_text(text).unwrap(); + let after = Utc::now(); assert!((snapshot.primary.used_percent - 42.5).abs() < 0.001); - assert!((snapshot.secondary.unwrap().used_percent - 13.0).abs() < 0.001); - assert!((snapshot.tertiary.unwrap().used_percent - 7.0).abs() < 0.001); + assert!((snapshot.secondary.as_ref().unwrap().used_percent - 13.0).abs() < 0.001); + assert!((snapshot.tertiary.as_ref().unwrap().used_percent - 7.0).abs() < 0.001); + assert_eq!(snapshot.login_method.as_deref(), Some("OpenCode Go")); + let windows = [ + (&snapshot.primary, 3600, Some(300)), + (snapshot.secondary.as_ref().unwrap(), 86400, Some(10080)), + (snapshot.tertiary.as_ref().unwrap(), 2592000, None), + ]; + for (window, reset_in, minutes) in windows { + let resets_at = window.resets_at.unwrap(); + assert!(resets_at >= before + chrono::Duration::seconds(reset_in)); + assert!(resets_at <= after + chrono::Duration::seconds(reset_in)); + let expected = minutes.or(RateWindow::monthly_window_minutes(Some(resets_at))); + assert_eq!(window.window_minutes, expected); + assert!(window.reset_description.is_none()); + } + assert!(snapshot.extra_rate_windows.is_empty()); } #[test] diff --git a/rust/src/providers/opencodego/local.rs b/rust/src/providers/opencodego/local.rs index 757b4e85499..31ddf162103 100644 --- a/rust/src/providers/opencodego/local.rs +++ b/rust/src/providers/opencodego/local.rs @@ -9,7 +9,7 @@ use rusqlite::Connection; use std::path::{Path, PathBuf}; use super::tokens::{RowTokens, TokenSums}; -use crate::core::{ProviderError, ProviderFetchResult, RateWindow, UsageSnapshot}; +use crate::core::{ProviderError, ProviderFetchResult}; const FIVE_HOURS_MS: i64 = 5 * 60 * 60 * 1000; const WEEK_MS: i64 = 7 * 24 * 60 * 60 * 1000; @@ -100,26 +100,12 @@ pub struct LocalUsageSnapshot { impl LocalUsageSnapshot { pub fn to_fetch_result(&self) -> ProviderFetchResult { let now = Utc::now(); - let primary = RateWindow::with_details( - self.rolling_usage_percent, - Some(300), - Some(now + Duration::seconds(self.rolling_reset_in_sec)), - None, + let at = |percent: f64, reset: i64| (percent, now + Duration::seconds(reset)); + let snap = super::go_snapshot( + at(self.rolling_usage_percent, self.rolling_reset_in_sec), + Some(at(self.weekly_usage_percent, self.weekly_reset_in_sec)), + Some(at(self.monthly_usage_percent, self.monthly_reset_in_sec)), ); - let mut snap = UsageSnapshot::new(primary).with_login_method("OpenCode Go"); - snap = snap.with_secondary(RateWindow::with_details( - self.weekly_usage_percent, - Some(10080), - Some(now + Duration::seconds(self.weekly_reset_in_sec)), - None, - )); - let monthly_reset = now + Duration::seconds(self.monthly_reset_in_sec); - snap = snap.with_tertiary(RateWindow::with_details( - self.monthly_usage_percent, - RateWindow::monthly_window_minutes(Some(monthly_reset)).or(Some(43200)), - Some(monthly_reset), - None, - )); // Upstream 0.51 (#2982): local SQLite quota reconstruction is useful // but it is not server-confirmed authority. Keep that distinction in // the data contract so CLI/React can present it without guessing. @@ -632,6 +618,7 @@ mod tokens_tests; #[cfg(test)] mod tests { use super::*; + use crate::core::RateWindow; use chrono::Weekday; use std::time::{SystemTime, UNIX_EPOCH}; @@ -659,6 +646,37 @@ mod tests { assert!(result.usage.tertiary.as_ref().unwrap().resets_at.is_some()); } + #[test] + fn local_fetch_result_pins_window_minutes_and_reset_offsets() { + let before = Utc::now(); + let result = LocalUsageSnapshot { + rolling_usage_percent: 12.0, + weekly_usage_percent: 23.0, + monthly_usage_percent: 34.0, + rolling_reset_in_sec: 300, + weekly_reset_in_sec: 1_000, + monthly_reset_in_sec: 2_000, + } + .to_fetch_result(); + let after = Utc::now(); + let usage = &result.usage; + assert_eq!(usage.login_method.as_deref(), Some("OpenCode Go")); + let windows = [ + (&usage.primary, 300, Some(300)), + (usage.secondary.as_ref().unwrap(), 1_000, Some(10080)), + (usage.tertiary.as_ref().unwrap(), 2_000, None), + ]; + for (window, reset_in, minutes) in windows { + let resets_at = window.resets_at.unwrap(); + assert!(resets_at >= before + Duration::seconds(reset_in)); + assert!(resets_at <= after + Duration::seconds(reset_in)); + let expected = minutes.or(RateWindow::monthly_window_minutes(Some(resets_at))); + assert_eq!(window.window_minutes, expected); + assert!(window.reset_description.is_none()); + } + assert!(usage.extra_rate_windows.is_empty()); + } + fn temp_db_path(label: &str) -> PathBuf { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) diff --git a/rust/src/providers/opencodego/mod.rs b/rust/src/providers/opencodego/mod.rs index 531d64c02d6..e03fd8dda0f 100644 --- a/rust/src/providers/opencodego/mod.rs +++ b/rust/src/providers/opencodego/mod.rs @@ -328,20 +328,8 @@ impl OpenCodeGoProvider { } Err(console_error) if cookies.can_recover_with_legacy(&console_error) => { zen_task.abort(); - let session = match transport.resolve_legacy_session(&cookies).await { - Ok(session) => session, - Err(error) => { - return cookies.select_legacy_result(console_error, Err(error)); - } - }; - return Self::fetch_legacy_with_session( - ctx, - &cookies, - console_error, - transport, - session, - ) - .await; + return Self::fetch_legacy_with_cookies(ctx, &cookies, console_error, transport) + .await; } Err(error) => { zen_task.abort(); @@ -445,6 +433,30 @@ impl OpenCodeGoProvider { } } +/// `(used percent, reset time)` of one OpenCode Go window. +type GoWindow = (f64, chrono::DateTime); + +/// Rolling 5h, weekly and calendar-month windows in the shared Go snapshot shape. +fn go_snapshot( + rolling: GoWindow, + weekly: Option, + monthly: Option, +) -> UsageSnapshot { + let window = |(percent, resets_at): GoWindow, minutes| { + RateWindow::with_details(percent, minutes, Some(resets_at), None) + }; + let mut snapshot = + UsageSnapshot::new(window(rolling, Some(300))).with_login_method("OpenCode Go"); + if let Some(weekly) = weekly { + snapshot = snapshot.with_secondary(window(weekly, Some(10080))); + } + if let Some(monthly) = monthly { + let minutes = RateWindow::monthly_window_minutes(Some(monthly.1)).or(Some(43200)); + snapshot = snapshot.with_tertiary(window(monthly, minutes)); + } + snapshot +} + impl Default for OpenCodeGoProvider { fn default() -> Self { Self::new() diff --git a/rust/src/providers/opencodego/usage_api.rs b/rust/src/providers/opencodego/usage_api.rs index 13427919d9f..283c1ee6dd1 100644 --- a/rust/src/providers/opencodego/usage_api.rs +++ b/rust/src/providers/opencodego/usage_api.rs @@ -1,7 +1,7 @@ use chrono::{DateTime, Utc}; use reqwest::Client; -use crate::core::{FetchContext, ProviderError, ProviderFetchResult, RateWindow, UsageSnapshot}; +use crate::core::{FetchContext, ProviderError, ProviderFetchResult, UsageSnapshot}; const USAGE_API_URL: &str = "https://opencode.ai/zen/go/v1/usage"; @@ -71,30 +71,11 @@ fn parse_usage_text(text: &str, now: DateTime) -> Result = windows + .iter() + .map(|w| (w.window_minutes, w.resets_at.map(|r| r.to_rfc3339()))) + .collect(); + assert_eq!( + shape, + [ + (Some(300), Some("2026-08-12T02:00:00+00:00".to_string())), + (Some(10080), Some("2026-08-18T00:00:00+00:00".to_string())), + // Aug 1 to Sep 1 is a 31-day calendar month. + (Some(44640), Some("2026-09-01T00:00:00+00:00".to_string())), + ] + ); + assert!(windows.iter().all(|w| w.reset_description.is_none())); + assert!(snapshot.extra_rate_windows.is_empty()); } #[test] From 0c24a44a96dfa41b88d24ca633581f55c7fb0151 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:52:00 +0700 Subject: [PATCH 16/40] Extract OpenCode Go local window and month helpers --- rust/src/providers/opencodego/local.rs | 179 ++++++++++++++++++------- 1 file changed, 132 insertions(+), 47 deletions(-) diff --git a/rust/src/providers/opencodego/local.rs b/rust/src/providers/opencodego/local.rs index 31ddf162103..2f690c7ad55 100644 --- a/rust/src/providers/opencodego/local.rs +++ b/rust/src/providers/opencodego/local.rs @@ -368,6 +368,27 @@ fn local_today_from_utc(now: DateTime) -> NaiveDate { Local.from_utc_datetime(&now.naive_utc()).date_naive() } +/// Local midnight that opens a `days`-long window ending today. +fn window_since_ms(now: DateTime, days: u32) -> i64 { + let clamped = crate::cost_reporting_period::clamp_window_days(days); + let since = local_today_from_utc(now) - Duration::days(clamped as i64 - 1); + Local + .from_local_datetime(&since.and_hms_opt(0, 0, 0).unwrap_or_default()) + .single() + .map(|dt| dt.timestamp_millis()) + .unwrap_or(0) +} + +/// Trimmed model id, with blanks bucketed as `UNKNOWN_MODEL_NAME`. +fn model_name(raw: &str) -> &str { + let trimmed = raw.trim(); + if trimmed.is_empty() { + UNKNOWN_MODEL_NAME + } else { + trimmed + } +} + /// Group rows into `(day, model)` cost buckets (upstream #2649). /// /// Rows outside the `[since, now]` window are dropped; model ids are trimmed and @@ -378,14 +399,7 @@ pub fn daily_model_costs( now: DateTime, history_days: u32, ) -> Vec { - let clamped = crate::cost_reporting_period::clamp_window_days(history_days); - let today = local_today_from_utc(now); - let since = today - Duration::days(clamped as i64 - 1); - let since_ms = Local - .from_local_datetime(&since.and_hms_opt(0, 0, 0).unwrap_or_default()) - .single() - .map(|dt| dt.timestamp_millis()) - .unwrap_or(0); + let since_ms = window_since_ms(now, history_days); let now_ms = now.timestamp_millis(); let mut by_day_model: std::collections::BTreeMap< @@ -399,12 +413,7 @@ pub fn daily_model_costs( let Some(key) = day_key_local(row.created_ms) else { continue; }; - let trimmed = row.model.trim(); - let model = if trimmed.is_empty() { - UNKNOWN_MODEL_NAME - } else { - trimmed - }; + let model = model_name(&row.model); let entry = by_day_model.entry(key).or_default(); let bucket = entry.entry(model.to_string()).or_default(); bucket.0 += row.cost; @@ -433,14 +442,7 @@ pub fn model_cost_summary_from_rows( now: DateTime, days: u32, ) -> ModelCostSummary { - let clamped = crate::cost_reporting_period::clamp_window_days(days); - let today = local_today_from_utc(now); - let since = today - Duration::days(clamped as i64 - 1); - let since_ms = Local - .from_local_datetime(&since.and_hms_opt(0, 0, 0).unwrap_or_default()) - .single() - .map(|dt| dt.timestamp_millis()) - .unwrap_or(0); + let since_ms = window_since_ms(now, days); let now_ms = now.timestamp_millis(); let mut total = 0.0; @@ -457,12 +459,7 @@ pub fn model_cost_summary_from_rows( } total += row.cost; request_count = request_count.saturating_add(row.request_count); - let trimmed = row.model.trim(); - let model = if trimmed.is_empty() { - UNKNOWN_MODEL_NAME - } else { - trimmed - }; + let model = model_name(&row.model); *by_model.entry(model.to_string()).or_insert(0.0) += row.cost; tokens.add(row.tokens.as_ref()); by_model_tokens @@ -542,15 +539,11 @@ fn month_bounds_ms(now: DateTime, anchor_ms: Option) -> (i64, i64) { .and_hms_opt(0, 0, 0) .unwrap_or_default(); let start_dt = Utc.from_utc_datetime(&start); - let end_dt = if now.month() == 12 { - Utc.with_ymd_and_hms(now.year() + 1, 1, 1, 0, 0, 0) - .single() - .unwrap_or(start_dt) - } else { - Utc.with_ymd_and_hms(now.year(), now.month() + 1, 1, 0, 0, 0) - .single() - .unwrap_or(start_dt) - }; + let (end_year, end_month) = next_month(now.year(), now.month()); + let end_dt = Utc + .with_ymd_and_hms(end_year, end_month, 1, 0, 0, 0) + .single() + .unwrap_or(start_dt); return (start_dt.timestamp_millis(), end_dt.timestamp_millis()); }; @@ -567,13 +560,17 @@ fn month_bounds_ms(now: DateTime, anchor_ms: Option) -> (i64, i64) { } start = anchored_month(year, month, &anchor); } - let (end_year, end_month) = if month == 12 { + let (end_year, end_month) = next_month(year, month); + let end = anchored_month(end_year, end_month, &anchor); + (start.timestamp_millis(), end.timestamp_millis()) +} + +fn next_month(year: i32, month: u32) -> (i32, u32) { + if month == 12 { (year + 1, 1) } else { (year, month + 1) - }; - let end = anchored_month(end_year, end_month, &anchor); - (start.timestamp_millis(), end.timestamp_millis()) + } } fn anchored_month(year: i32, month: u32, anchor: &DateTime) -> DateTime { @@ -592,12 +589,8 @@ fn anchored_month(year: i32, month: u32, anchor: &DateTime) -> DateTime = daily.iter().map(|b| b.model.as_str()).collect(); + let mut sorted = models.clone(); + sorted.sort_unstable(); + assert_eq!(sorted, ["kept", UNKNOWN_MODEL_NAME]); + let summary = model_cost_summary_from_rows(&rows, now, 2); + assert_eq!(summary.request_count, 2); + assert!((summary.total_cost_usd - 2.0).abs() < 1e-9); + let mut keys: Vec<_> = summary.by_model.keys().map(String::as_str).collect(); + keys.sort_unstable(); + assert_eq!(keys, ["kept", UNKNOWN_MODEL_NAME]); + } + #[test] fn iso_week_starts_monday_utc() { // 2026-03-18 is a Wednesday; week start should be 2026-03-16 00:00 UTC. From 26cb94bb5290f09cc513ef43061c4134550fea31 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:53:39 +0700 Subject: [PATCH 17/40] Move OpenCode Go local reader tests into local/tests.rs --- rust/src/providers/opencodego/local.rs | 769 +------------------ rust/src/providers/opencodego/local/tests.rs | 766 ++++++++++++++++++ 2 files changed, 767 insertions(+), 768 deletions(-) create mode 100644 rust/src/providers/opencodego/local/tests.rs diff --git a/rust/src/providers/opencodego/local.rs b/rust/src/providers/opencodego/local.rs index 2f690c7ad55..d48c9eb1400 100644 --- a/rust/src/providers/opencodego/local.rs +++ b/rust/src/providers/opencodego/local.rs @@ -609,771 +609,4 @@ fn anchored_month(year: i32, month: u32, anchor: &DateTime) -> DateTime= before + Duration::seconds(reset_in)); - assert!(resets_at <= after + Duration::seconds(reset_in)); - let expected = minutes.or(RateWindow::monthly_window_minutes(Some(resets_at))); - assert_eq!(window.window_minutes, expected); - assert!(window.reset_description.is_none()); - } - assert!(usage.extra_rate_windows.is_empty()); - } - - fn temp_db_path(label: &str) -> PathBuf { - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_nanos()) - .unwrap_or(0); - std::env::temp_dir().join(format!("opencodego-local-{label}-{nanos}.db")) - } - - fn write_message_db(path: &Path, rows: &[(i64, f64)]) { - let conn = Connection::open(path).unwrap(); - conn.execute_batch( - "CREATE TABLE message ( - id TEXT PRIMARY KEY, - data TEXT, - time_created INTEGER - );", - ) - .unwrap(); - for (i, (created_ms, cost)) in rows.iter().enumerate() { - let data = format!( - r#"{{"providerID":"opencode-go","role":"assistant","cost":{cost},"time":{{"created":{created_ms}}}}}"# - ); - conn.execute( - "INSERT INTO message (id, data, time_created) VALUES (?1, ?2, ?3)", - rusqlite::params![format!("m{i}"), data, created_ms], - ) - .unwrap(); - } - } - - /// Build a message-only DB with optional per-row `modelID` (upstream #2649 fixtures). - fn write_message_db_with_model(path: &Path, rows: &[(i64, f64, Option<&str>)]) { - let conn = Connection::open(path).unwrap(); - conn.execute_batch( - "CREATE TABLE message ( - id TEXT PRIMARY KEY, - data TEXT, - time_created INTEGER - );", - ) - .unwrap(); - for (i, (created_ms, cost, model)) in rows.iter().enumerate() { - let model_json = match model { - Some(m) => format!(r#","modelID":"{m}""#), - None => String::new(), - }; - let data = format!( - r#"{{"providerID":"opencode-go","role":"assistant","cost":{cost}{model_json},"time":{{"created":{created_ms}}}}}"# - ); - conn.execute( - "INSERT INTO message (id, data, time_created) VALUES (?1, ?2, ?3)", - rusqlite::params![format!("m{i}"), data, created_ms], - ) - .unwrap(); - } - } - - /// Insert one assistant message and return its id, optionally with a modelID. - fn insert_message( - conn: &Connection, - id: &str, - created_ms: i64, - cost: Option, - model: Option<&str>, - ) { - let cost_json = match cost { - Some(c) => format!(r#","cost":{c}"#), - None => String::new(), - }; - let model_json = match model { - Some(m) => format!(r#","modelID":"{m}""#), - None => String::new(), - }; - let data = format!( - r#"{{"providerID":"opencode-go","role":"assistant"{cost_json}{model_json},"time":{{"created":{created_ms}}}}}"# - ); - conn.execute( - "INSERT INTO message (id, data, time_created) VALUES (?1, ?2, ?3)", - rusqlite::params![id, data, created_ms], - ) - .unwrap(); - } - - /// Insert a step-finish part carrying a cost, attached to `message_id`. - fn insert_step_finish_part( - conn: &Connection, - id: &str, - message_id: &str, - created_ms: i64, - cost: f64, - ) { - let data = - format!(r#"{{"type":"step-finish","cost":{cost},"time":{{"created":{created_ms}}}}}"#); - conn.execute( - "INSERT INTO part (id, message_id, data, time_created) VALUES (?1, ?2, ?3, ?4)", - rusqlite::params![id, message_id, data, created_ms], - ) - .unwrap(); - } - - fn iso_ms(iso: &str) -> i64 { - chrono::DateTime::parse_from_rfc3339(iso) - .unwrap() - .timestamp_millis() - } - - #[test] - fn unreadable_database_reports_the_sqlite_error_prefix() { - let db = temp_db_path("garbage"); - std::fs::write(&db, b"not a sqlite database, just synthetic bytes").unwrap(); - let err = read_rows(&db).unwrap_err(); - // Best-effort teardown; the temp file may already be gone. - let _removed = std::fs::remove_file(&db); - assert!( - matches!(&err, ProviderError::Other(message) - if message.starts_with("SQLite error reading OpenCode Go usage: ")), - "{err:?}" - ); - } - - #[test] - fn not_detected_without_db_or_auth() { - let dir = std::env::temp_dir().join(format!( - "opencodego-missing-{}", - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_nanos()) - .unwrap_or(0) - )); - // Best-effort fixture setup; a failure surfaces as the fetch error below. - let _created = std::fs::create_dir_all(&dir); - let auth = dir.join("auth.json"); - let db = dir.join("opencode.db"); - let err = fetch_from_paths(&auth, &db, Utc::now()).unwrap_err(); - assert!(matches!( - &err, - ProviderError::NotInstalled(message) - if message == "OpenCode Go not detected. Log in with OpenCode Go or use it locally first." - )); - // Best-effort teardown; temp dir may already be gone. - let _removed_dir = std::fs::remove_dir_all(&dir); - } - - #[test] - fn sums_session_weekly_monthly_costs() { - let db = temp_db_path("sums"); - let now = Utc.with_ymd_and_hms(2026, 3, 18, 12, 0, 0).unwrap(); // Wednesday - let now_ms = now.timestamp_millis(); - // $6 in the rolling 5h window → 50% of $12 - // $15 in ISO week → 50% of $30 - // $30 in anchored month → 50% of $60 - let session_ms = now_ms - 60_000; - let week_ms = start_of_utc_iso_week_ms(now) + 3_600_000; - let month_anchor_ms = now_ms - 10 * 24 * 60 * 60 * 1000; - write_message_db( - &db, - &[ - (session_ms, 6.0), - (week_ms, 9.0), // plus session = 15 in week if session also in week - (month_anchor_ms, 15.0), - ], - ); - - // auth present so empty-rows path is not used; auth not required when rows exist - let auth = db.with_extension("auth.json"); - // Fixture write; failure would make fetch_from_paths return an error. - let _written = std::fs::write(&auth, r#"{"opencode-go":{"key":"test-key"}}"#); - - let snap = fetch_from_paths(&auth, &db, now).unwrap(); - assert!((snap.rolling_usage_percent - 50.0).abs() < 0.05, "{snap:?}"); - // session 6 + week-only 9 = 15 → 50% - assert!((snap.weekly_usage_percent - 50.0).abs() < 0.05, "{snap:?}"); - // session 6 + week 9 + month 15 = 30 → 50% - assert!((snap.monthly_usage_percent - 50.0).abs() < 0.05, "{snap:?}"); - - // Best-effort teardown; leftover temp files are harmless. - let _removed_db = std::fs::remove_file(&db); - let _removed_auth = std::fs::remove_file(&auth); - } - - #[test] - fn prefers_step_finish_parts_when_present() { - let db = temp_db_path("parts"); - let conn = Connection::open(&db).unwrap(); - conn.execute_batch( - "CREATE TABLE message (id TEXT PRIMARY KEY, data TEXT, time_created INTEGER); - CREATE TABLE part (id TEXT PRIMARY KEY, message_id TEXT, data TEXT, time_created INTEGER);", - ) - .unwrap(); - let now = Utc.with_ymd_and_hms(2026, 3, 18, 12, 0, 0).unwrap(); - let created = now.timestamp_millis() - 1_000; - // Message cost would be $12 (100%), but step-finish parts sum to $3 (25%). - conn.execute( - "INSERT INTO message (id, data, time_created) VALUES (?1, ?2, ?3)", - rusqlite::params![ - "m1", - format!( - r#"{{"providerID":"opencode-go","role":"assistant","cost":12,"time":{{"created":{created}}}}}"# - ), - created - ], - ) - .unwrap(); - conn.execute( - "INSERT INTO part (id, message_id, data, time_created) VALUES (?1, ?2, ?3, ?4)", - rusqlite::params![ - "p1", - "m1", - format!(r#"{{"type":"step-finish","cost":3,"time":{{"created":{created}}}}}"#), - created - ], - ) - .unwrap(); - drop(conn); - - let auth = db.with_extension("auth.json"); - // Fixture write; a failure would surface in fetch_from_paths. - let _written_auth = std::fs::write(&auth, r#"{"opencode-go":{"key":"k"}}"#); - let snap = fetch_from_paths(&auth, &db, now).unwrap(); - assert!( - (snap.rolling_usage_percent - 25.0).abs() < 0.05, - "expected step-finish cost only, got {snap:?}" - ); - // Best-effort teardown; leftover temp files are harmless. - let _removed_db = std::fs::remove_file(&db); - let _removed_auth = std::fs::remove_file(&auth); - } - - #[test] - fn percent_rounds_to_one_decimal() { - assert!((percent(1.0, 12.0) - 8.3).abs() < 0.05); - assert_eq!(percent(0.0, 12.0), 0.0); - assert_eq!(percent(f64::NAN, 12.0), 0.0); - } - - #[test] - fn month_bounds_follow_calendar_or_anchor_day() { - let at = |iso: &str| { - DateTime::parse_from_rfc3339(iso) - .unwrap() - .with_timezone(&Utc) - }; - let cases = [ - // (now, anchor, expected start, expected end) - ( - "2026-12-15T12:00:00Z", - None, - "2026-12-01T00:00:00Z", - "2027-01-01T00:00:00Z", - ), - ( - "2026-03-18T12:00:00Z", - None, - "2026-03-01T00:00:00Z", - "2026-04-01T00:00:00Z", - ), - // Day 31 clamps to Feb 28, which is after now, so the window steps back. - ( - "2026-02-20T10:00:00Z", - Some("2026-01-31T08:30:00Z"), - "2026-01-31T08:30:00Z", - "2026-02-28T08:30:00Z", - ), - ( - "2026-12-20T00:00:00Z", - Some("2026-03-15T00:00:00Z"), - "2026-12-15T00:00:00Z", - "2027-01-15T00:00:00Z", - ), - ( - "2026-01-10T00:00:00Z", - Some("2025-11-15T06:00:00Z"), - "2025-12-15T06:00:00Z", - "2026-01-15T06:00:00Z", - ), - ( - "2026-11-10T12:00:00Z", - Some("2026-08-31T00:00:00Z"), - "2026-10-31T00:00:00Z", - "2026-11-30T00:00:00Z", - ), - ]; - for (now, anchor, start, end) in cases { - let bounds = month_bounds_ms(at(now), anchor.map(|a| at(a).timestamp_millis())); - assert_eq!( - bounds, - (at(start).timestamp_millis(), at(end).timestamp_millis()), - "{now} {anchor:?}" - ); - } - } - - #[test] - fn daily_and_summary_windows_share_the_local_midnight_cutoff() { - let now = a14_now(); - let since = local_today_from_utc(now) - Duration::days(1); - let since_ms = Local - .from_local_datetime(&since.and_hms_opt(0, 0, 0).unwrap()) - .single() - .unwrap() - .timestamp_millis(); - let row = |created_ms: i64, model: &str| UsageRow { - created_ms, - cost: 1.0, - request_count: 1, - model: model.to_string(), - tokens: None, - }; - let rows = [ - row(since_ms - 1, "dropped"), - row(since_ms, " kept "), - row(now.timestamp_millis(), ""), - row(now.timestamp_millis() + 1, "future"), - ]; - let daily = daily_model_costs(&rows, now, 2); - let models: Vec<_> = daily.iter().map(|b| b.model.as_str()).collect(); - let mut sorted = models.clone(); - sorted.sort_unstable(); - assert_eq!(sorted, ["kept", UNKNOWN_MODEL_NAME]); - let summary = model_cost_summary_from_rows(&rows, now, 2); - assert_eq!(summary.request_count, 2); - assert!((summary.total_cost_usd - 2.0).abs() < 1e-9); - let mut keys: Vec<_> = summary.by_model.keys().map(String::as_str).collect(); - keys.sort_unstable(); - assert_eq!(keys, ["kept", UNKNOWN_MODEL_NAME]); - } - - #[test] - fn iso_week_starts_monday_utc() { - // 2026-03-18 is a Wednesday; week start should be 2026-03-16 00:00 UTC. - let wed = Utc.with_ymd_and_hms(2026, 3, 18, 15, 0, 0).unwrap(); - let start = start_of_utc_iso_week_ms(wed); - let expected = Utc - .with_ymd_and_hms(2026, 3, 16, 0, 0, 0) - .unwrap() - .timestamp_millis(); - assert_eq!(start, expected); - assert_eq!(wed.weekday(), Weekday::Wed); - } - - #[test] - fn idle_wal_mode_read_creates_no_sidecars() { - let dir = tempfile::tempdir().unwrap(); - let db = dir.path().join("opencode.db"); - let auth = dir.path().join("auth.json"); - std::fs::write(&auth, r#"{"opencode-go":{"key":"k"}}"#).unwrap(); - - // Build a WAL-mode DB, insert a row, leave journal_mode=WAL, then drop - // any writer-created sidecars so the main file is an idle WAL header. - { - let conn = Connection::open(&db).unwrap(); - conn.execute_batch("PRAGMA journal_mode=WAL;").unwrap(); - conn.execute_batch( - "CREATE TABLE message ( - id TEXT PRIMARY KEY, - data TEXT, - time_created INTEGER - );", - ) - .unwrap(); - let now = Utc::now(); - let created = now.timestamp_millis() - 1_000; - let data = format!( - r#"{{"providerID":"opencode-go","role":"assistant","cost":3,"time":{{"created":{created}}}}}"# - ); - conn.execute( - "INSERT INTO message (id, data, time_created) VALUES ('m1', ?1, ?2)", - rusqlite::params![data, created], - ) - .unwrap(); - // Truncate empties WAL content before close; journal_mode stays WAL. - // Best-effort checkpoint; a truncated WAL is just tidier. - let _checkpointed = conn.query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |_| Ok(())); - drop(conn); - } - - // Ensure idle-WAL case: header says WAL, no live sidecars. - let wal = crate::core::sqlite_sidecar_path(&db, "-wal"); - let shm = crate::core::sqlite_sidecar_path(&db, "-shm"); - // Prefer rename-away over delete if OS still holds handles. - for side in [&wal, &shm] { - if side.exists() { - let parked = side.with_extension("parked"); - // Best-effort parking; a locked sidecar just stays put. - let _parked = std::fs::rename(side, parked); - } - } - assert!(!wal.exists(), "precondition: no -wal"); - assert!(!shm.exists(), "precondition: no -shm"); - - let snap = fetch_from_paths(&auth, &db, Utc::now()).expect("read idle WAL db"); - assert!(snap.rolling_usage_percent > 0.0, "{snap:?}"); - - assert!( - !wal.exists() && !shm.exists(), - "reader must not create -wal/-shm sidecars" - ); - } - - // ---- A14: per-model daily cost breakdown (upstream #2649) ------------- - - fn a14_now() -> DateTime { - Utc.timestamp_opt(1_772_798_400, 0).unwrap() - } - - fn a14_now_afternoon() -> DateTime { - Utc.timestamp_opt(1_772_798_400 + 4 * 3600, 0).unwrap() - } - - #[test] - fn daily_entries_group_cost_by_model_within_a_day() { - let dir = tempfile::tempdir().unwrap(); - let db = dir.path().join("opencode.db"); - let now = a14_now_afternoon(); - write_message_db_with_model( - &db, - &[ - ( - iso_ms("2026-03-06T11:00:00.000Z"), - 3.0, - Some("claude-sonnet-4-5"), - ), - ( - iso_ms("2026-03-06T12:00:00.000Z"), - 2.0, - Some("gpt-5.1-codex"), - ), - ( - iso_ms("2026-03-06T13:00:00.000Z"), - 1.0, - Some("claude-sonnet-4-5"), - ), - ], - ); - let rows = read_rows(&db).unwrap(); - let buckets = daily_model_costs(&rows, now, 30); - - // Day key is local-calendar; assert on tz-independent model aggregation. - let total: f64 = buckets.iter().map(|b| b.cost).sum(); - assert!((total - 6.0).abs() < 1e-6, "total {total}"); - assert_eq!(buckets.iter().map(|b| b.request_count).sum::(), 3); - let by_model: std::collections::HashMap<&str, f64> = - buckets.iter().map(|b| (b.model.as_str(), b.cost)).collect(); - assert!((by_model["claude-sonnet-4-5"] - 4.0).abs() < 1e-6); - assert!((by_model["gpt-5.1-codex"] - 2.0).abs() < 1e-6); - } - - #[test] - fn step_finish_parts_inherit_their_model_from_the_parent_message() { - let dir = tempfile::tempdir().unwrap(); - let db = dir.path().join("opencode.db"); - let conn = Connection::open(&db).unwrap(); - conn.execute_batch( - "CREATE TABLE message (id TEXT PRIMARY KEY, data TEXT, time_created INTEGER); - CREATE TABLE part (id TEXT PRIMARY KEY, message_id TEXT, data TEXT, time_created INTEGER);", - ) - .unwrap(); - let created = iso_ms("2026-03-06T11:00:00.000Z"); - insert_message(&conn, "m1", created, None, Some("grok-code-fast-1")); - insert_step_finish_part(&conn, "p1", "m1", created, 3.0); - drop(conn); - - let rows = read_rows(&db).unwrap(); - let buckets = daily_model_costs(&rows, a14_now(), 30); - assert_eq!(buckets.len(), 1, "{buckets:?}"); - assert_eq!(buckets[0].model, "grok-code-fast-1"); - assert!((buckets[0].cost - 3.0).abs() < 1e-6); - } - - #[test] - fn messages_without_a_model_fall_back_to_the_unknown_bucket() { - let dir = tempfile::tempdir().unwrap(); - let db = dir.path().join("opencode.db"); - write_message_db_with_model(&db, &[(iso_ms("2026-03-06T11:00:00.000Z"), 4.0, None)]); - let rows = read_rows(&db).unwrap(); - let buckets = daily_model_costs(&rows, a14_now(), 30); - assert_eq!(buckets.len(), 1, "{buckets:?}"); - assert_eq!(buckets[0].model, UNKNOWN_MODEL_NAME); - assert!((buckets[0].cost - 4.0).abs() < 1e-6); - } - - #[test] - fn whitespace_only_model_ids_fall_back_to_the_unknown_bucket() { - let dir = tempfile::tempdir().unwrap(); - let db = dir.path().join("opencode.db"); - write_message_db_with_model( - &db, - &[(iso_ms("2026-03-06T11:00:00.000Z"), 5.0, Some(" "))], - ); - let rows = read_rows(&db).unwrap(); - let buckets = daily_model_costs(&rows, a14_now(), 30); - assert_eq!(buckets.len(), 1, "{buckets:?}"); - assert_eq!(buckets[0].model, UNKNOWN_MODEL_NAME); - assert!((buckets[0].cost - 5.0).abs() < 1e-6); - } - - #[test] - fn model_ids_with_incidental_whitespace_merge_with_the_trimmed_bucket() { - let dir = tempfile::tempdir().unwrap(); - let db = dir.path().join("opencode.db"); - write_message_db_with_model( - &db, - &[ - ( - iso_ms("2026-03-06T11:00:00.000Z"), - 2.0, - Some("claude-sonnet-4-5"), - ), - ( - iso_ms("2026-03-06T12:00:00.000Z"), - 3.0, - Some(" claude-sonnet-4-5 "), - ), - ], - ); - let rows = read_rows(&db).unwrap(); - let buckets = daily_model_costs(&rows, a14_now_afternoon(), 30); - assert_eq!(buckets.len(), 1, "{buckets:?}"); - assert_eq!(buckets[0].model, "claude-sonnet-4-5"); - assert!((buckets[0].cost - 5.0).abs() < 1e-6); - assert_eq!(buckets[0].request_count, 2); - } - - #[test] - fn multiple_days_bucket_separately_and_sort_deterministically() { - let dir = tempfile::tempdir().unwrap(); - let db = dir.path().join("opencode.db"); - write_message_db_with_model( - &db, - &[ - (iso_ms("2026-03-05T11:00:00.000Z"), 1.0, Some("a")), - (iso_ms("2026-03-06T11:00:00.000Z"), 2.0, Some("b")), - (iso_ms("2026-03-07T11:00:00.000Z"), 3.0, Some("a")), - ], - ); - let rows = read_rows(&db).unwrap(); - let buckets = daily_model_costs(&rows, a14_now_afternoon(), 30); - assert!( - buckets - .windows(2) - .all(|w| (w[0].day_key.as_str(), w[0].model.as_str()) - <= (w[1].day_key.as_str(), w[1].model.as_str())), - "not sorted: {buckets:?}" - ); - assert!( - buckets - .iter() - .map(|b| b.day_key.as_str()) - .collect::>() - .len() - >= 2 - ); - } - - #[test] - fn zero_cost_rows_are_kept_and_aggregated() { - let dir = tempfile::tempdir().unwrap(); - let db = dir.path().join("opencode.db"); - write_message_db_with_model( - &db, - &[ - (iso_ms("2026-03-06T11:00:00.000Z"), 0.0, Some("a")), - (iso_ms("2026-03-06T12:00:00.000Z"), 4.0, Some("a")), - ], - ); - let rows = read_rows(&db).unwrap(); - let buckets = daily_model_costs(&rows, a14_now_afternoon(), 30); - assert_eq!(buckets.len(), 1, "{buckets:?}"); - assert!((buckets[0].cost - 4.0).abs() < 1e-6); - assert_eq!(buckets[0].request_count, 2); - } - - #[test] - fn malformed_rows_are_dropped() { - let dir = tempfile::tempdir().unwrap(); - let db = dir.path().join("opencode.db"); - let conn = Connection::open(&db).unwrap(); - conn.execute_batch( - "CREATE TABLE message (id TEXT PRIMARY KEY, data TEXT, time_created INTEGER);", - ) - .unwrap(); - conn.execute( - "INSERT INTO message (id, data, time_created) VALUES (?1, ?2, ?3)", - rusqlite::params![ - "m1", - r#"{"providerID":"opencode-go","role":"user","cost":9,"time":{"created":1772798400000}}"#, - 1772798400000i64 - ], - ) - .unwrap(); - conn.execute( - "INSERT INTO message (id, data, time_created) VALUES (?1, ?2, ?3)", - rusqlite::params![ - "m2", - r#"{"providerID":"opencode-go","role":"assistant","cost":null,"modelID":"x","time":{"created":1772798400000}}"#, - 1772798400000i64 - ], - ) - .unwrap(); - conn.execute( - "INSERT INTO message (id, data, time_created) VALUES (?1, ?2, ?3)", - rusqlite::params![ - "m3", - r#"{"providerID":"opencode-go","role":"assistant","cost":7,"modelID":"good","time":{"created":1772798400000}}"#, - 1772798400000i64 - ], - ) - .unwrap(); - drop(conn); - - let rows = read_rows(&db).unwrap(); - assert_eq!(rows.len(), 1, "only the valid assistant+cost row survives"); - assert_eq!(rows[0].model, "good"); - } - - #[test] - fn rows_outside_history_window_are_dropped() { - let dir = tempfile::tempdir().unwrap(); - let db = dir.path().join("opencode.db"); - let far_past = iso_ms("2025-01-01T00:00:00.000Z"); - let recent = a14_now().timestamp_millis(); - write_message_db_with_model( - &db, - &[(far_past, 1.0, Some("old")), (recent, 2.0, Some("new"))], - ); - let rows = read_rows(&db).unwrap(); - let buckets = daily_model_costs(&rows, a14_now(), 1); - let models: Vec<&str> = buckets.iter().map(|b| b.model.as_str()).collect(); - assert!( - !models.contains(&"old"), - "old row should be outside the 1-day window: {buckets:?}" - ); - } - - #[test] - fn day_boundary_keys_by_local_calendar_day() { - let dir = tempfile::tempdir().unwrap(); - let db = dir.path().join("opencode.db"); - let just_after_utc_midnight = iso_ms("2026-03-06T00:30:00.000Z"); - write_message_db_with_model(&db, &[(just_after_utc_midnight, 1.5, Some("edge"))]); - let rows = read_rows(&db).unwrap(); - let buckets = daily_model_costs(&rows, a14_now_afternoon(), 30); - assert_eq!(buckets.len(), 1, "{buckets:?}"); - assert!( - NaiveDate::parse_from_str(&buckets[0].day_key, "%Y-%m-%d").is_ok(), - "day_key not yyyy-MM-dd: {}", - buckets[0].day_key - ); - } - - #[test] - fn model_cost_summary_aggregates_total_and_by_model() { - let dir = tempfile::tempdir().unwrap(); - let db = dir.path().join("opencode.db"); - write_message_db_with_model( - &db, - &[ - (iso_ms("2026-03-06T11:00:00.000Z"), 3.0, Some("a")), - (iso_ms("2026-03-06T12:00:00.000Z"), 1.0, Some("b")), - (iso_ms("2026-03-06T13:00:00.000Z"), 2.0, None), - ], - ); - let rows = read_rows(&db).unwrap(); - let summary = model_cost_summary_from_rows(&rows, a14_now_afternoon(), 30); - assert!((summary.total_cost_usd - 6.0).abs() < 1e-6, "{summary:?}"); - assert_eq!(summary.request_count, 3); - assert!((summary.by_model["a"] - 3.0).abs() < 1e-6); - assert!((summary.by_model["b"] - 1.0).abs() < 1e-6); - assert!((summary.by_model[UNKNOWN_MODEL_NAME] - 2.0).abs() < 1e-6); - assert!(summary.period_start.is_some() && summary.period_end.is_some()); - } - - #[test] - fn daily_series_sums_models_per_day_via_pure_aggregation() { - let dir = tempfile::tempdir().unwrap(); - let db = dir.path().join("opencode.db"); - write_message_db_with_model( - &db, - &[ - (iso_ms("2026-03-06T11:00:00.000Z"), 3.0, Some("a")), - (iso_ms("2026-03-06T12:00:00.000Z"), 2.0, Some("b")), - ], - ); - let rows = read_rows(&db).unwrap(); - let buckets = daily_model_costs(&rows, a14_now_afternoon(), 30); - let mut by_day: std::collections::BTreeMap = std::collections::BTreeMap::new(); - for b in &buckets { - *by_day.entry(b.day_key.clone()).or_insert(0.0) += b.cost; - } - let series: Vec<(String, f64)> = by_day.into_iter().collect(); - assert_eq!(series.len(), 1, "{series:?}"); - assert!((series[0].1 - 5.0).abs() < 1e-6); - } - - #[test] - fn daily_aggregation_is_independent_of_zen_wait() { - let dir = tempfile::tempdir().unwrap(); - let db = dir.path().join("opencode.db"); - write_message_db_with_model(&db, &[(iso_ms("2026-03-06T11:00:00.000Z"), 2.0, Some("a"))]); - let rows = read_rows(&db).unwrap(); - let now = a14_now(); - let b1 = daily_model_costs(&rows, now, 30); - let b2 = daily_model_costs(&rows, now, 30); - assert_eq!(b1, b2, "pure aggregation must be deterministic"); - assert_eq!(b1.len(), 1); - } -} +mod tests; diff --git a/rust/src/providers/opencodego/local/tests.rs b/rust/src/providers/opencodego/local/tests.rs new file mode 100644 index 00000000000..001fe653cd7 --- /dev/null +++ b/rust/src/providers/opencodego/local/tests.rs @@ -0,0 +1,766 @@ +use super::*; +use crate::core::RateWindow; +use chrono::Weekday; +use std::time::{SystemTime, UNIX_EPOCH}; + +#[test] +fn local_fetch_result_keeps_estimate_source_and_reset_windows() { + let result = LocalUsageSnapshot { + rolling_usage_percent: 12.0, + weekly_usage_percent: 23.0, + monthly_usage_percent: 34.0, + rolling_reset_in_sec: 300, + weekly_reset_in_sec: 1_000, + monthly_reset_in_sec: 2_000, + } + .to_fetch_result(); + + assert_eq!( + result.source_label, + super::super::LOCAL_ESTIMATE_SOURCE_LABEL + ); + assert_eq!(result.usage.primary.used_percent, 12.0); + assert_eq!(result.usage.secondary.as_ref().unwrap().used_percent, 23.0); + assert_eq!(result.usage.tertiary.as_ref().unwrap().used_percent, 34.0); + assert!(result.usage.primary.resets_at.is_some()); + assert!(result.usage.secondary.as_ref().unwrap().resets_at.is_some()); + assert!(result.usage.tertiary.as_ref().unwrap().resets_at.is_some()); +} + +#[test] +fn local_fetch_result_pins_window_minutes_and_reset_offsets() { + let before = Utc::now(); + let result = LocalUsageSnapshot { + rolling_usage_percent: 12.0, + weekly_usage_percent: 23.0, + monthly_usage_percent: 34.0, + rolling_reset_in_sec: 300, + weekly_reset_in_sec: 1_000, + monthly_reset_in_sec: 2_000, + } + .to_fetch_result(); + let after = Utc::now(); + let usage = &result.usage; + assert_eq!(usage.login_method.as_deref(), Some("OpenCode Go")); + let windows = [ + (&usage.primary, 300, Some(300)), + (usage.secondary.as_ref().unwrap(), 1_000, Some(10080)), + (usage.tertiary.as_ref().unwrap(), 2_000, None), + ]; + for (window, reset_in, minutes) in windows { + let resets_at = window.resets_at.unwrap(); + assert!(resets_at >= before + Duration::seconds(reset_in)); + assert!(resets_at <= after + Duration::seconds(reset_in)); + let expected = minutes.or(RateWindow::monthly_window_minutes(Some(resets_at))); + assert_eq!(window.window_minutes, expected); + assert!(window.reset_description.is_none()); + } + assert!(usage.extra_rate_windows.is_empty()); +} + +fn temp_db_path(label: &str) -> PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + std::env::temp_dir().join(format!("opencodego-local-{label}-{nanos}.db")) +} + +fn write_message_db(path: &Path, rows: &[(i64, f64)]) { + let conn = Connection::open(path).unwrap(); + conn.execute_batch( + "CREATE TABLE message ( + id TEXT PRIMARY KEY, + data TEXT, + time_created INTEGER + );", + ) + .unwrap(); + for (i, (created_ms, cost)) in rows.iter().enumerate() { + let data = format!( + r#"{{"providerID":"opencode-go","role":"assistant","cost":{cost},"time":{{"created":{created_ms}}}}}"# + ); + conn.execute( + "INSERT INTO message (id, data, time_created) VALUES (?1, ?2, ?3)", + rusqlite::params![format!("m{i}"), data, created_ms], + ) + .unwrap(); + } +} + +/// Build a message-only DB with optional per-row `modelID` (upstream #2649 fixtures). +fn write_message_db_with_model(path: &Path, rows: &[(i64, f64, Option<&str>)]) { + let conn = Connection::open(path).unwrap(); + conn.execute_batch( + "CREATE TABLE message ( + id TEXT PRIMARY KEY, + data TEXT, + time_created INTEGER + );", + ) + .unwrap(); + for (i, (created_ms, cost, model)) in rows.iter().enumerate() { + let model_json = match model { + Some(m) => format!(r#","modelID":"{m}""#), + None => String::new(), + }; + let data = format!( + r#"{{"providerID":"opencode-go","role":"assistant","cost":{cost}{model_json},"time":{{"created":{created_ms}}}}}"# + ); + conn.execute( + "INSERT INTO message (id, data, time_created) VALUES (?1, ?2, ?3)", + rusqlite::params![format!("m{i}"), data, created_ms], + ) + .unwrap(); + } +} + +/// Insert one assistant message and return its id, optionally with a modelID. +fn insert_message( + conn: &Connection, + id: &str, + created_ms: i64, + cost: Option, + model: Option<&str>, +) { + let cost_json = match cost { + Some(c) => format!(r#","cost":{c}"#), + None => String::new(), + }; + let model_json = match model { + Some(m) => format!(r#","modelID":"{m}""#), + None => String::new(), + }; + let data = format!( + r#"{{"providerID":"opencode-go","role":"assistant"{cost_json}{model_json},"time":{{"created":{created_ms}}}}}"# + ); + conn.execute( + "INSERT INTO message (id, data, time_created) VALUES (?1, ?2, ?3)", + rusqlite::params![id, data, created_ms], + ) + .unwrap(); +} + +/// Insert a step-finish part carrying a cost, attached to `message_id`. +fn insert_step_finish_part( + conn: &Connection, + id: &str, + message_id: &str, + created_ms: i64, + cost: f64, +) { + let data = + format!(r#"{{"type":"step-finish","cost":{cost},"time":{{"created":{created_ms}}}}}"#); + conn.execute( + "INSERT INTO part (id, message_id, data, time_created) VALUES (?1, ?2, ?3, ?4)", + rusqlite::params![id, message_id, data, created_ms], + ) + .unwrap(); +} + +fn iso_ms(iso: &str) -> i64 { + chrono::DateTime::parse_from_rfc3339(iso) + .unwrap() + .timestamp_millis() +} + +#[test] +fn unreadable_database_reports_the_sqlite_error_prefix() { + let db = temp_db_path("garbage"); + std::fs::write(&db, b"not a sqlite database, just synthetic bytes").unwrap(); + let err = read_rows(&db).unwrap_err(); + // Best-effort teardown; the temp file may already be gone. + let _removed = std::fs::remove_file(&db); + assert!( + matches!(&err, ProviderError::Other(message) + if message.starts_with("SQLite error reading OpenCode Go usage: ")), + "{err:?}" + ); +} + +#[test] +fn not_detected_without_db_or_auth() { + let dir = std::env::temp_dir().join(format!( + "opencodego-missing-{}", + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + )); + // Best-effort fixture setup; a failure surfaces as the fetch error below. + let _created = std::fs::create_dir_all(&dir); + let auth = dir.join("auth.json"); + let db = dir.join("opencode.db"); + let err = fetch_from_paths(&auth, &db, Utc::now()).unwrap_err(); + assert!(matches!( + &err, + ProviderError::NotInstalled(message) + if message == "OpenCode Go not detected. Log in with OpenCode Go or use it locally first." + )); + // Best-effort teardown; temp dir may already be gone. + let _removed_dir = std::fs::remove_dir_all(&dir); +} + +#[test] +fn sums_session_weekly_monthly_costs() { + let db = temp_db_path("sums"); + let now = Utc.with_ymd_and_hms(2026, 3, 18, 12, 0, 0).unwrap(); // Wednesday + let now_ms = now.timestamp_millis(); + // $6 in the rolling 5h window → 50% of $12 + // $15 in ISO week → 50% of $30 + // $30 in anchored month → 50% of $60 + let session_ms = now_ms - 60_000; + let week_ms = start_of_utc_iso_week_ms(now) + 3_600_000; + let month_anchor_ms = now_ms - 10 * 24 * 60 * 60 * 1000; + write_message_db( + &db, + &[ + (session_ms, 6.0), + (week_ms, 9.0), // plus session = 15 in week if session also in week + (month_anchor_ms, 15.0), + ], + ); + + // auth present so empty-rows path is not used; auth not required when rows exist + let auth = db.with_extension("auth.json"); + // Fixture write; failure would make fetch_from_paths return an error. + let _written = std::fs::write(&auth, r#"{"opencode-go":{"key":"test-key"}}"#); + + let snap = fetch_from_paths(&auth, &db, now).unwrap(); + assert!((snap.rolling_usage_percent - 50.0).abs() < 0.05, "{snap:?}"); + // session 6 + week-only 9 = 15 → 50% + assert!((snap.weekly_usage_percent - 50.0).abs() < 0.05, "{snap:?}"); + // session 6 + week 9 + month 15 = 30 → 50% + assert!((snap.monthly_usage_percent - 50.0).abs() < 0.05, "{snap:?}"); + + // Best-effort teardown; leftover temp files are harmless. + let _removed_db = std::fs::remove_file(&db); + let _removed_auth = std::fs::remove_file(&auth); +} + +#[test] +fn prefers_step_finish_parts_when_present() { + let db = temp_db_path("parts"); + let conn = Connection::open(&db).unwrap(); + conn.execute_batch( + "CREATE TABLE message (id TEXT PRIMARY KEY, data TEXT, time_created INTEGER); + CREATE TABLE part (id TEXT PRIMARY KEY, message_id TEXT, data TEXT, time_created INTEGER);", + ) + .unwrap(); + let now = Utc.with_ymd_and_hms(2026, 3, 18, 12, 0, 0).unwrap(); + let created = now.timestamp_millis() - 1_000; + // Message cost would be $12 (100%), but step-finish parts sum to $3 (25%). + conn.execute( + "INSERT INTO message (id, data, time_created) VALUES (?1, ?2, ?3)", + rusqlite::params![ + "m1", + format!( + r#"{{"providerID":"opencode-go","role":"assistant","cost":12,"time":{{"created":{created}}}}}"# + ), + created + ], + ) + .unwrap(); + conn.execute( + "INSERT INTO part (id, message_id, data, time_created) VALUES (?1, ?2, ?3, ?4)", + rusqlite::params![ + "p1", + "m1", + format!(r#"{{"type":"step-finish","cost":3,"time":{{"created":{created}}}}}"#), + created + ], + ) + .unwrap(); + drop(conn); + + let auth = db.with_extension("auth.json"); + // Fixture write; a failure would surface in fetch_from_paths. + let _written_auth = std::fs::write(&auth, r#"{"opencode-go":{"key":"k"}}"#); + let snap = fetch_from_paths(&auth, &db, now).unwrap(); + assert!( + (snap.rolling_usage_percent - 25.0).abs() < 0.05, + "expected step-finish cost only, got {snap:?}" + ); + // Best-effort teardown; leftover temp files are harmless. + let _removed_db = std::fs::remove_file(&db); + let _removed_auth = std::fs::remove_file(&auth); +} + +#[test] +fn percent_rounds_to_one_decimal() { + assert!((percent(1.0, 12.0) - 8.3).abs() < 0.05); + assert_eq!(percent(0.0, 12.0), 0.0); + assert_eq!(percent(f64::NAN, 12.0), 0.0); +} + +#[test] +fn month_bounds_follow_calendar_or_anchor_day() { + let at = |iso: &str| { + DateTime::parse_from_rfc3339(iso) + .unwrap() + .with_timezone(&Utc) + }; + let cases = [ + // (now, anchor, expected start, expected end) + ( + "2026-12-15T12:00:00Z", + None, + "2026-12-01T00:00:00Z", + "2027-01-01T00:00:00Z", + ), + ( + "2026-03-18T12:00:00Z", + None, + "2026-03-01T00:00:00Z", + "2026-04-01T00:00:00Z", + ), + // Day 31 clamps to Feb 28, which is after now, so the window steps back. + ( + "2026-02-20T10:00:00Z", + Some("2026-01-31T08:30:00Z"), + "2026-01-31T08:30:00Z", + "2026-02-28T08:30:00Z", + ), + ( + "2026-12-20T00:00:00Z", + Some("2026-03-15T00:00:00Z"), + "2026-12-15T00:00:00Z", + "2027-01-15T00:00:00Z", + ), + ( + "2026-01-10T00:00:00Z", + Some("2025-11-15T06:00:00Z"), + "2025-12-15T06:00:00Z", + "2026-01-15T06:00:00Z", + ), + ( + "2026-11-10T12:00:00Z", + Some("2026-08-31T00:00:00Z"), + "2026-10-31T00:00:00Z", + "2026-11-30T00:00:00Z", + ), + ]; + for (now, anchor, start, end) in cases { + let bounds = month_bounds_ms(at(now), anchor.map(|a| at(a).timestamp_millis())); + assert_eq!( + bounds, + (at(start).timestamp_millis(), at(end).timestamp_millis()), + "{now} {anchor:?}" + ); + } +} + +#[test] +fn daily_and_summary_windows_share_the_local_midnight_cutoff() { + let now = a14_now(); + let since = local_today_from_utc(now) - Duration::days(1); + let since_ms = Local + .from_local_datetime(&since.and_hms_opt(0, 0, 0).unwrap()) + .single() + .unwrap() + .timestamp_millis(); + let row = |created_ms: i64, model: &str| UsageRow { + created_ms, + cost: 1.0, + request_count: 1, + model: model.to_string(), + tokens: None, + }; + let rows = [ + row(since_ms - 1, "dropped"), + row(since_ms, " kept "), + row(now.timestamp_millis(), ""), + row(now.timestamp_millis() + 1, "future"), + ]; + let daily = daily_model_costs(&rows, now, 2); + let models: Vec<_> = daily.iter().map(|b| b.model.as_str()).collect(); + let mut sorted = models.clone(); + sorted.sort_unstable(); + assert_eq!(sorted, ["kept", UNKNOWN_MODEL_NAME]); + let summary = model_cost_summary_from_rows(&rows, now, 2); + assert_eq!(summary.request_count, 2); + assert!((summary.total_cost_usd - 2.0).abs() < 1e-9); + let mut keys: Vec<_> = summary.by_model.keys().map(String::as_str).collect(); + keys.sort_unstable(); + assert_eq!(keys, ["kept", UNKNOWN_MODEL_NAME]); +} + +#[test] +fn iso_week_starts_monday_utc() { + // 2026-03-18 is a Wednesday; week start should be 2026-03-16 00:00 UTC. + let wed = Utc.with_ymd_and_hms(2026, 3, 18, 15, 0, 0).unwrap(); + let start = start_of_utc_iso_week_ms(wed); + let expected = Utc + .with_ymd_and_hms(2026, 3, 16, 0, 0, 0) + .unwrap() + .timestamp_millis(); + assert_eq!(start, expected); + assert_eq!(wed.weekday(), Weekday::Wed); +} + +#[test] +fn idle_wal_mode_read_creates_no_sidecars() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("opencode.db"); + let auth = dir.path().join("auth.json"); + std::fs::write(&auth, r#"{"opencode-go":{"key":"k"}}"#).unwrap(); + + // Build a WAL-mode DB, insert a row, leave journal_mode=WAL, then drop + // any writer-created sidecars so the main file is an idle WAL header. + { + let conn = Connection::open(&db).unwrap(); + conn.execute_batch("PRAGMA journal_mode=WAL;").unwrap(); + conn.execute_batch( + "CREATE TABLE message ( + id TEXT PRIMARY KEY, + data TEXT, + time_created INTEGER + );", + ) + .unwrap(); + let now = Utc::now(); + let created = now.timestamp_millis() - 1_000; + let data = format!( + r#"{{"providerID":"opencode-go","role":"assistant","cost":3,"time":{{"created":{created}}}}}"# + ); + conn.execute( + "INSERT INTO message (id, data, time_created) VALUES ('m1', ?1, ?2)", + rusqlite::params![data, created], + ) + .unwrap(); + // Truncate empties WAL content before close; journal_mode stays WAL. + // Best-effort checkpoint; a truncated WAL is just tidier. + let _checkpointed = conn.query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |_| Ok(())); + drop(conn); + } + + // Ensure idle-WAL case: header says WAL, no live sidecars. + let wal = crate::core::sqlite_sidecar_path(&db, "-wal"); + let shm = crate::core::sqlite_sidecar_path(&db, "-shm"); + // Prefer rename-away over delete if OS still holds handles. + for side in [&wal, &shm] { + if side.exists() { + let parked = side.with_extension("parked"); + // Best-effort parking; a locked sidecar just stays put. + let _parked = std::fs::rename(side, parked); + } + } + assert!(!wal.exists(), "precondition: no -wal"); + assert!(!shm.exists(), "precondition: no -shm"); + + let snap = fetch_from_paths(&auth, &db, Utc::now()).expect("read idle WAL db"); + assert!(snap.rolling_usage_percent > 0.0, "{snap:?}"); + + assert!( + !wal.exists() && !shm.exists(), + "reader must not create -wal/-shm sidecars" + ); +} + +// ---- A14: per-model daily cost breakdown (upstream #2649) ------------- + +fn a14_now() -> DateTime { + Utc.timestamp_opt(1_772_798_400, 0).unwrap() +} + +fn a14_now_afternoon() -> DateTime { + Utc.timestamp_opt(1_772_798_400 + 4 * 3600, 0).unwrap() +} + +#[test] +fn daily_entries_group_cost_by_model_within_a_day() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("opencode.db"); + let now = a14_now_afternoon(); + write_message_db_with_model( + &db, + &[ + ( + iso_ms("2026-03-06T11:00:00.000Z"), + 3.0, + Some("claude-sonnet-4-5"), + ), + ( + iso_ms("2026-03-06T12:00:00.000Z"), + 2.0, + Some("gpt-5.1-codex"), + ), + ( + iso_ms("2026-03-06T13:00:00.000Z"), + 1.0, + Some("claude-sonnet-4-5"), + ), + ], + ); + let rows = read_rows(&db).unwrap(); + let buckets = daily_model_costs(&rows, now, 30); + + // Day key is local-calendar; assert on tz-independent model aggregation. + let total: f64 = buckets.iter().map(|b| b.cost).sum(); + assert!((total - 6.0).abs() < 1e-6, "total {total}"); + assert_eq!(buckets.iter().map(|b| b.request_count).sum::(), 3); + let by_model: std::collections::HashMap<&str, f64> = + buckets.iter().map(|b| (b.model.as_str(), b.cost)).collect(); + assert!((by_model["claude-sonnet-4-5"] - 4.0).abs() < 1e-6); + assert!((by_model["gpt-5.1-codex"] - 2.0).abs() < 1e-6); +} + +#[test] +fn step_finish_parts_inherit_their_model_from_the_parent_message() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("opencode.db"); + let conn = Connection::open(&db).unwrap(); + conn.execute_batch( + "CREATE TABLE message (id TEXT PRIMARY KEY, data TEXT, time_created INTEGER); + CREATE TABLE part (id TEXT PRIMARY KEY, message_id TEXT, data TEXT, time_created INTEGER);", + ) + .unwrap(); + let created = iso_ms("2026-03-06T11:00:00.000Z"); + insert_message(&conn, "m1", created, None, Some("grok-code-fast-1")); + insert_step_finish_part(&conn, "p1", "m1", created, 3.0); + drop(conn); + + let rows = read_rows(&db).unwrap(); + let buckets = daily_model_costs(&rows, a14_now(), 30); + assert_eq!(buckets.len(), 1, "{buckets:?}"); + assert_eq!(buckets[0].model, "grok-code-fast-1"); + assert!((buckets[0].cost - 3.0).abs() < 1e-6); +} + +#[test] +fn messages_without_a_model_fall_back_to_the_unknown_bucket() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("opencode.db"); + write_message_db_with_model(&db, &[(iso_ms("2026-03-06T11:00:00.000Z"), 4.0, None)]); + let rows = read_rows(&db).unwrap(); + let buckets = daily_model_costs(&rows, a14_now(), 30); + assert_eq!(buckets.len(), 1, "{buckets:?}"); + assert_eq!(buckets[0].model, UNKNOWN_MODEL_NAME); + assert!((buckets[0].cost - 4.0).abs() < 1e-6); +} + +#[test] +fn whitespace_only_model_ids_fall_back_to_the_unknown_bucket() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("opencode.db"); + write_message_db_with_model( + &db, + &[(iso_ms("2026-03-06T11:00:00.000Z"), 5.0, Some(" "))], + ); + let rows = read_rows(&db).unwrap(); + let buckets = daily_model_costs(&rows, a14_now(), 30); + assert_eq!(buckets.len(), 1, "{buckets:?}"); + assert_eq!(buckets[0].model, UNKNOWN_MODEL_NAME); + assert!((buckets[0].cost - 5.0).abs() < 1e-6); +} + +#[test] +fn model_ids_with_incidental_whitespace_merge_with_the_trimmed_bucket() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("opencode.db"); + write_message_db_with_model( + &db, + &[ + ( + iso_ms("2026-03-06T11:00:00.000Z"), + 2.0, + Some("claude-sonnet-4-5"), + ), + ( + iso_ms("2026-03-06T12:00:00.000Z"), + 3.0, + Some(" claude-sonnet-4-5 "), + ), + ], + ); + let rows = read_rows(&db).unwrap(); + let buckets = daily_model_costs(&rows, a14_now_afternoon(), 30); + assert_eq!(buckets.len(), 1, "{buckets:?}"); + assert_eq!(buckets[0].model, "claude-sonnet-4-5"); + assert!((buckets[0].cost - 5.0).abs() < 1e-6); + assert_eq!(buckets[0].request_count, 2); +} + +#[test] +fn multiple_days_bucket_separately_and_sort_deterministically() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("opencode.db"); + write_message_db_with_model( + &db, + &[ + (iso_ms("2026-03-05T11:00:00.000Z"), 1.0, Some("a")), + (iso_ms("2026-03-06T11:00:00.000Z"), 2.0, Some("b")), + (iso_ms("2026-03-07T11:00:00.000Z"), 3.0, Some("a")), + ], + ); + let rows = read_rows(&db).unwrap(); + let buckets = daily_model_costs(&rows, a14_now_afternoon(), 30); + assert!( + buckets + .windows(2) + .all(|w| (w[0].day_key.as_str(), w[0].model.as_str()) + <= (w[1].day_key.as_str(), w[1].model.as_str())), + "not sorted: {buckets:?}" + ); + assert!( + buckets + .iter() + .map(|b| b.day_key.as_str()) + .collect::>() + .len() + >= 2 + ); +} + +#[test] +fn zero_cost_rows_are_kept_and_aggregated() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("opencode.db"); + write_message_db_with_model( + &db, + &[ + (iso_ms("2026-03-06T11:00:00.000Z"), 0.0, Some("a")), + (iso_ms("2026-03-06T12:00:00.000Z"), 4.0, Some("a")), + ], + ); + let rows = read_rows(&db).unwrap(); + let buckets = daily_model_costs(&rows, a14_now_afternoon(), 30); + assert_eq!(buckets.len(), 1, "{buckets:?}"); + assert!((buckets[0].cost - 4.0).abs() < 1e-6); + assert_eq!(buckets[0].request_count, 2); +} + +#[test] +fn malformed_rows_are_dropped() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("opencode.db"); + let conn = Connection::open(&db).unwrap(); + conn.execute_batch( + "CREATE TABLE message (id TEXT PRIMARY KEY, data TEXT, time_created INTEGER);", + ) + .unwrap(); + conn.execute( + "INSERT INTO message (id, data, time_created) VALUES (?1, ?2, ?3)", + rusqlite::params![ + "m1", + r#"{"providerID":"opencode-go","role":"user","cost":9,"time":{"created":1772798400000}}"#, + 1772798400000i64 + ], + ) + .unwrap(); + conn.execute( + "INSERT INTO message (id, data, time_created) VALUES (?1, ?2, ?3)", + rusqlite::params![ + "m2", + r#"{"providerID":"opencode-go","role":"assistant","cost":null,"modelID":"x","time":{"created":1772798400000}}"#, + 1772798400000i64 + ], + ) + .unwrap(); + conn.execute( + "INSERT INTO message (id, data, time_created) VALUES (?1, ?2, ?3)", + rusqlite::params![ + "m3", + r#"{"providerID":"opencode-go","role":"assistant","cost":7,"modelID":"good","time":{"created":1772798400000}}"#, + 1772798400000i64 + ], + ) + .unwrap(); + drop(conn); + + let rows = read_rows(&db).unwrap(); + assert_eq!(rows.len(), 1, "only the valid assistant+cost row survives"); + assert_eq!(rows[0].model, "good"); +} + +#[test] +fn rows_outside_history_window_are_dropped() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("opencode.db"); + let far_past = iso_ms("2025-01-01T00:00:00.000Z"); + let recent = a14_now().timestamp_millis(); + write_message_db_with_model( + &db, + &[(far_past, 1.0, Some("old")), (recent, 2.0, Some("new"))], + ); + let rows = read_rows(&db).unwrap(); + let buckets = daily_model_costs(&rows, a14_now(), 1); + let models: Vec<&str> = buckets.iter().map(|b| b.model.as_str()).collect(); + assert!( + !models.contains(&"old"), + "old row should be outside the 1-day window: {buckets:?}" + ); +} + +#[test] +fn day_boundary_keys_by_local_calendar_day() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("opencode.db"); + let just_after_utc_midnight = iso_ms("2026-03-06T00:30:00.000Z"); + write_message_db_with_model(&db, &[(just_after_utc_midnight, 1.5, Some("edge"))]); + let rows = read_rows(&db).unwrap(); + let buckets = daily_model_costs(&rows, a14_now_afternoon(), 30); + assert_eq!(buckets.len(), 1, "{buckets:?}"); + assert!( + NaiveDate::parse_from_str(&buckets[0].day_key, "%Y-%m-%d").is_ok(), + "day_key not yyyy-MM-dd: {}", + buckets[0].day_key + ); +} + +#[test] +fn model_cost_summary_aggregates_total_and_by_model() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("opencode.db"); + write_message_db_with_model( + &db, + &[ + (iso_ms("2026-03-06T11:00:00.000Z"), 3.0, Some("a")), + (iso_ms("2026-03-06T12:00:00.000Z"), 1.0, Some("b")), + (iso_ms("2026-03-06T13:00:00.000Z"), 2.0, None), + ], + ); + let rows = read_rows(&db).unwrap(); + let summary = model_cost_summary_from_rows(&rows, a14_now_afternoon(), 30); + assert!((summary.total_cost_usd - 6.0).abs() < 1e-6, "{summary:?}"); + assert_eq!(summary.request_count, 3); + assert!((summary.by_model["a"] - 3.0).abs() < 1e-6); + assert!((summary.by_model["b"] - 1.0).abs() < 1e-6); + assert!((summary.by_model[UNKNOWN_MODEL_NAME] - 2.0).abs() < 1e-6); + assert!(summary.period_start.is_some() && summary.period_end.is_some()); +} + +#[test] +fn daily_series_sums_models_per_day_via_pure_aggregation() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("opencode.db"); + write_message_db_with_model( + &db, + &[ + (iso_ms("2026-03-06T11:00:00.000Z"), 3.0, Some("a")), + (iso_ms("2026-03-06T12:00:00.000Z"), 2.0, Some("b")), + ], + ); + let rows = read_rows(&db).unwrap(); + let buckets = daily_model_costs(&rows, a14_now_afternoon(), 30); + let mut by_day: std::collections::BTreeMap = std::collections::BTreeMap::new(); + for b in &buckets { + *by_day.entry(b.day_key.clone()).or_insert(0.0) += b.cost; + } + let series: Vec<(String, f64)> = by_day.into_iter().collect(); + assert_eq!(series.len(), 1, "{series:?}"); + assert!((series[0].1 - 5.0).abs() < 1e-6); +} + +#[test] +fn daily_aggregation_is_independent_of_zen_wait() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("opencode.db"); + write_message_db_with_model(&db, &[(iso_ms("2026-03-06T11:00:00.000Z"), 2.0, Some("a"))]); + let rows = read_rows(&db).unwrap(); + let now = a14_now(); + let b1 = daily_model_costs(&rows, now, 30); + let b2 = daily_model_costs(&rows, now, 30); + assert_eq!(b1, b2, "pure aggregation must be deterministic"); + assert_eq!(b1.len(), 1); +} From 200592da599903704fedafe5388afc4f3e56bb5a Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:55:57 +0700 Subject: [PATCH 18/40] Share OpenCode Go local test fixtures --- rust/src/providers/opencodego/local.rs | 2 + .../src/providers/opencodego/local/test_db.rs | 83 +++++++++ rust/src/providers/opencodego/local/tests.rs | 174 +++--------------- .../opencodego/local/tokens_tests.rs | 103 +++++------ 4 files changed, 151 insertions(+), 211 deletions(-) create mode 100644 rust/src/providers/opencodego/local/test_db.rs diff --git a/rust/src/providers/opencodego/local.rs b/rust/src/providers/opencodego/local.rs index d48c9eb1400..f8464232063 100644 --- a/rust/src/providers/opencodego/local.rs +++ b/rust/src/providers/opencodego/local.rs @@ -605,6 +605,8 @@ fn anchored_month(year: i32, month: u32, anchor: &DateTime) -> DateTime i64 { + chrono::DateTime::parse_from_rfc3339(iso) + .unwrap() + .timestamp_millis() +} + +pub(super) fn open_db(path: &Path, with_part_table: bool) -> Connection { + let conn = Connection::open(path).unwrap(); + conn.execute_batch( + "CREATE TABLE message (id TEXT PRIMARY KEY, data TEXT, time_created INTEGER);", + ) + .unwrap(); + if with_part_table { + conn.execute_batch( + "CREATE TABLE part (id TEXT PRIMARY KEY, message_id TEXT, data TEXT, time_created INTEGER);", + ) + .unwrap(); + } + conn +} + +/// Insert one assistant message. `tokens` is spliced in as raw JSON so +/// malformed shapes can be exercised. +pub(super) fn insert_message( + conn: &Connection, + id: &str, + created_ms: i64, + cost: Option, + model: Option<&str>, + tokens: Option<&str>, +) { + let cost_json = cost.map_or_else(String::new, |c| format!(r#","cost":{c}"#)); + let model_json = model.map_or_else(String::new, |m| format!(r#","modelID":"{m}""#)); + let tokens_json = tokens.map_or_else(String::new, |t| format!(r#","tokens":{t}"#)); + let data = format!( + r#"{{"providerID":"opencode-go","role":"assistant"{cost_json}{model_json}{tokens_json},"time":{{"created":{created_ms}}}}}"# + ); + conn.execute( + "INSERT INTO message (id, data, time_created) VALUES (?1, ?2, ?3)", + rusqlite::params![id, data, created_ms], + ) + .unwrap(); +} + +/// Insert a step-finish part carrying a cost, attached to `message_id`. +pub(super) fn insert_step_finish( + conn: &Connection, + id: &str, + message_id: &str, + created_ms: i64, + cost: f64, + tokens: Option<&str>, +) { + let tokens_json = tokens.map_or_else(String::new, |t| format!(r#","tokens":{t}"#)); + let data = format!( + r#"{{"type":"step-finish","cost":{cost}{tokens_json},"time":{{"created":{created_ms}}}}}"# + ); + conn.execute( + "INSERT INTO part (id, message_id, data, time_created) VALUES (?1, ?2, ?3, ?4)", + rusqlite::params![id, message_id, data, created_ms], + ) + .unwrap(); +} + +/// Message-only DB with one assistant row per `(created_ms, cost, modelID)`. +pub(super) fn write_message_db(path: &Path, rows: &[(i64, f64, Option<&str>)]) { + let conn = open_db(path, false); + for (i, (created_ms, cost, model)) in rows.iter().enumerate() { + insert_message( + &conn, + &format!("m{i}"), + *created_ms, + Some(*cost), + *model, + None, + ); + } +} diff --git a/rust/src/providers/opencodego/local/tests.rs b/rust/src/providers/opencodego/local/tests.rs index 001fe653cd7..b606312ff3a 100644 --- a/rust/src/providers/opencodego/local/tests.rs +++ b/rust/src/providers/opencodego/local/tests.rs @@ -1,3 +1,4 @@ +use super::test_db::{insert_message, insert_step_finish, iso_ms, open_db, write_message_db}; use super::*; use crate::core::RateWindow; use chrono::Weekday; @@ -66,104 +67,6 @@ fn temp_db_path(label: &str) -> PathBuf { std::env::temp_dir().join(format!("opencodego-local-{label}-{nanos}.db")) } -fn write_message_db(path: &Path, rows: &[(i64, f64)]) { - let conn = Connection::open(path).unwrap(); - conn.execute_batch( - "CREATE TABLE message ( - id TEXT PRIMARY KEY, - data TEXT, - time_created INTEGER - );", - ) - .unwrap(); - for (i, (created_ms, cost)) in rows.iter().enumerate() { - let data = format!( - r#"{{"providerID":"opencode-go","role":"assistant","cost":{cost},"time":{{"created":{created_ms}}}}}"# - ); - conn.execute( - "INSERT INTO message (id, data, time_created) VALUES (?1, ?2, ?3)", - rusqlite::params![format!("m{i}"), data, created_ms], - ) - .unwrap(); - } -} - -/// Build a message-only DB with optional per-row `modelID` (upstream #2649 fixtures). -fn write_message_db_with_model(path: &Path, rows: &[(i64, f64, Option<&str>)]) { - let conn = Connection::open(path).unwrap(); - conn.execute_batch( - "CREATE TABLE message ( - id TEXT PRIMARY KEY, - data TEXT, - time_created INTEGER - );", - ) - .unwrap(); - for (i, (created_ms, cost, model)) in rows.iter().enumerate() { - let model_json = match model { - Some(m) => format!(r#","modelID":"{m}""#), - None => String::new(), - }; - let data = format!( - r#"{{"providerID":"opencode-go","role":"assistant","cost":{cost}{model_json},"time":{{"created":{created_ms}}}}}"# - ); - conn.execute( - "INSERT INTO message (id, data, time_created) VALUES (?1, ?2, ?3)", - rusqlite::params![format!("m{i}"), data, created_ms], - ) - .unwrap(); - } -} - -/// Insert one assistant message and return its id, optionally with a modelID. -fn insert_message( - conn: &Connection, - id: &str, - created_ms: i64, - cost: Option, - model: Option<&str>, -) { - let cost_json = match cost { - Some(c) => format!(r#","cost":{c}"#), - None => String::new(), - }; - let model_json = match model { - Some(m) => format!(r#","modelID":"{m}""#), - None => String::new(), - }; - let data = format!( - r#"{{"providerID":"opencode-go","role":"assistant"{cost_json}{model_json},"time":{{"created":{created_ms}}}}}"# - ); - conn.execute( - "INSERT INTO message (id, data, time_created) VALUES (?1, ?2, ?3)", - rusqlite::params![id, data, created_ms], - ) - .unwrap(); -} - -/// Insert a step-finish part carrying a cost, attached to `message_id`. -fn insert_step_finish_part( - conn: &Connection, - id: &str, - message_id: &str, - created_ms: i64, - cost: f64, -) { - let data = - format!(r#"{{"type":"step-finish","cost":{cost},"time":{{"created":{created_ms}}}}}"#); - conn.execute( - "INSERT INTO part (id, message_id, data, time_created) VALUES (?1, ?2, ?3, ?4)", - rusqlite::params![id, message_id, data, created_ms], - ) - .unwrap(); -} - -fn iso_ms(iso: &str) -> i64 { - chrono::DateTime::parse_from_rfc3339(iso) - .unwrap() - .timestamp_millis() -} - #[test] fn unreadable_database_reports_the_sqlite_error_prefix() { let db = temp_db_path("garbage"); @@ -215,9 +118,9 @@ fn sums_session_weekly_monthly_costs() { write_message_db( &db, &[ - (session_ms, 6.0), - (week_ms, 9.0), // plus session = 15 in week if session also in week - (month_anchor_ms, 15.0), + (session_ms, 6.0, None), + (week_ms, 9.0, None), // plus session = 15 in week if session also in week + (month_anchor_ms, 15.0, None), ], ); @@ -241,36 +144,12 @@ fn sums_session_weekly_monthly_costs() { #[test] fn prefers_step_finish_parts_when_present() { let db = temp_db_path("parts"); - let conn = Connection::open(&db).unwrap(); - conn.execute_batch( - "CREATE TABLE message (id TEXT PRIMARY KEY, data TEXT, time_created INTEGER); - CREATE TABLE part (id TEXT PRIMARY KEY, message_id TEXT, data TEXT, time_created INTEGER);", - ) - .unwrap(); + let conn = open_db(&db, true); let now = Utc.with_ymd_and_hms(2026, 3, 18, 12, 0, 0).unwrap(); let created = now.timestamp_millis() - 1_000; // Message cost would be $12 (100%), but step-finish parts sum to $3 (25%). - conn.execute( - "INSERT INTO message (id, data, time_created) VALUES (?1, ?2, ?3)", - rusqlite::params![ - "m1", - format!( - r#"{{"providerID":"opencode-go","role":"assistant","cost":12,"time":{{"created":{created}}}}}"# - ), - created - ], - ) - .unwrap(); - conn.execute( - "INSERT INTO part (id, message_id, data, time_created) VALUES (?1, ?2, ?3, ?4)", - rusqlite::params![ - "p1", - "m1", - format!(r#"{{"type":"step-finish","cost":3,"time":{{"created":{created}}}}}"#), - created - ], - ) - .unwrap(); + insert_message(&conn, "m1", created, Some(12.0), None, None); + insert_step_finish(&conn, "p1", "m1", created, 3.0, None); drop(conn); let auth = db.with_extension("auth.json"); @@ -472,7 +351,7 @@ fn daily_entries_group_cost_by_model_within_a_day() { let dir = tempfile::tempdir().unwrap(); let db = dir.path().join("opencode.db"); let now = a14_now_afternoon(); - write_message_db_with_model( + write_message_db( &db, &[ ( @@ -509,15 +388,10 @@ fn daily_entries_group_cost_by_model_within_a_day() { fn step_finish_parts_inherit_their_model_from_the_parent_message() { let dir = tempfile::tempdir().unwrap(); let db = dir.path().join("opencode.db"); - let conn = Connection::open(&db).unwrap(); - conn.execute_batch( - "CREATE TABLE message (id TEXT PRIMARY KEY, data TEXT, time_created INTEGER); - CREATE TABLE part (id TEXT PRIMARY KEY, message_id TEXT, data TEXT, time_created INTEGER);", - ) - .unwrap(); + let conn = open_db(&db, true); let created = iso_ms("2026-03-06T11:00:00.000Z"); - insert_message(&conn, "m1", created, None, Some("grok-code-fast-1")); - insert_step_finish_part(&conn, "p1", "m1", created, 3.0); + insert_message(&conn, "m1", created, None, Some("grok-code-fast-1"), None); + insert_step_finish(&conn, "p1", "m1", created, 3.0, None); drop(conn); let rows = read_rows(&db).unwrap(); @@ -531,7 +405,7 @@ fn step_finish_parts_inherit_their_model_from_the_parent_message() { fn messages_without_a_model_fall_back_to_the_unknown_bucket() { let dir = tempfile::tempdir().unwrap(); let db = dir.path().join("opencode.db"); - write_message_db_with_model(&db, &[(iso_ms("2026-03-06T11:00:00.000Z"), 4.0, None)]); + write_message_db(&db, &[(iso_ms("2026-03-06T11:00:00.000Z"), 4.0, None)]); let rows = read_rows(&db).unwrap(); let buckets = daily_model_costs(&rows, a14_now(), 30); assert_eq!(buckets.len(), 1, "{buckets:?}"); @@ -543,7 +417,7 @@ fn messages_without_a_model_fall_back_to_the_unknown_bucket() { fn whitespace_only_model_ids_fall_back_to_the_unknown_bucket() { let dir = tempfile::tempdir().unwrap(); let db = dir.path().join("opencode.db"); - write_message_db_with_model( + write_message_db( &db, &[(iso_ms("2026-03-06T11:00:00.000Z"), 5.0, Some(" "))], ); @@ -558,7 +432,7 @@ fn whitespace_only_model_ids_fall_back_to_the_unknown_bucket() { fn model_ids_with_incidental_whitespace_merge_with_the_trimmed_bucket() { let dir = tempfile::tempdir().unwrap(); let db = dir.path().join("opencode.db"); - write_message_db_with_model( + write_message_db( &db, &[ ( @@ -585,7 +459,7 @@ fn model_ids_with_incidental_whitespace_merge_with_the_trimmed_bucket() { fn multiple_days_bucket_separately_and_sort_deterministically() { let dir = tempfile::tempdir().unwrap(); let db = dir.path().join("opencode.db"); - write_message_db_with_model( + write_message_db( &db, &[ (iso_ms("2026-03-05T11:00:00.000Z"), 1.0, Some("a")), @@ -616,7 +490,7 @@ fn multiple_days_bucket_separately_and_sort_deterministically() { fn zero_cost_rows_are_kept_and_aggregated() { let dir = tempfile::tempdir().unwrap(); let db = dir.path().join("opencode.db"); - write_message_db_with_model( + write_message_db( &db, &[ (iso_ms("2026-03-06T11:00:00.000Z"), 0.0, Some("a")), @@ -634,11 +508,7 @@ fn zero_cost_rows_are_kept_and_aggregated() { fn malformed_rows_are_dropped() { let dir = tempfile::tempdir().unwrap(); let db = dir.path().join("opencode.db"); - let conn = Connection::open(&db).unwrap(); - conn.execute_batch( - "CREATE TABLE message (id TEXT PRIMARY KEY, data TEXT, time_created INTEGER);", - ) - .unwrap(); + let conn = open_db(&db, false); conn.execute( "INSERT INTO message (id, data, time_created) VALUES (?1, ?2, ?3)", rusqlite::params![ @@ -679,7 +549,7 @@ fn rows_outside_history_window_are_dropped() { let db = dir.path().join("opencode.db"); let far_past = iso_ms("2025-01-01T00:00:00.000Z"); let recent = a14_now().timestamp_millis(); - write_message_db_with_model( + write_message_db( &db, &[(far_past, 1.0, Some("old")), (recent, 2.0, Some("new"))], ); @@ -697,7 +567,7 @@ fn day_boundary_keys_by_local_calendar_day() { let dir = tempfile::tempdir().unwrap(); let db = dir.path().join("opencode.db"); let just_after_utc_midnight = iso_ms("2026-03-06T00:30:00.000Z"); - write_message_db_with_model(&db, &[(just_after_utc_midnight, 1.5, Some("edge"))]); + write_message_db(&db, &[(just_after_utc_midnight, 1.5, Some("edge"))]); let rows = read_rows(&db).unwrap(); let buckets = daily_model_costs(&rows, a14_now_afternoon(), 30); assert_eq!(buckets.len(), 1, "{buckets:?}"); @@ -712,7 +582,7 @@ fn day_boundary_keys_by_local_calendar_day() { fn model_cost_summary_aggregates_total_and_by_model() { let dir = tempfile::tempdir().unwrap(); let db = dir.path().join("opencode.db"); - write_message_db_with_model( + write_message_db( &db, &[ (iso_ms("2026-03-06T11:00:00.000Z"), 3.0, Some("a")), @@ -734,7 +604,7 @@ fn model_cost_summary_aggregates_total_and_by_model() { fn daily_series_sums_models_per_day_via_pure_aggregation() { let dir = tempfile::tempdir().unwrap(); let db = dir.path().join("opencode.db"); - write_message_db_with_model( + write_message_db( &db, &[ (iso_ms("2026-03-06T11:00:00.000Z"), 3.0, Some("a")), @@ -756,7 +626,7 @@ fn daily_series_sums_models_per_day_via_pure_aggregation() { fn daily_aggregation_is_independent_of_zen_wait() { let dir = tempfile::tempdir().unwrap(); let db = dir.path().join("opencode.db"); - write_message_db_with_model(&db, &[(iso_ms("2026-03-06T11:00:00.000Z"), 2.0, Some("a"))]); + write_message_db(&db, &[(iso_ms("2026-03-06T11:00:00.000Z"), 2.0, Some("a"))]); let rows = read_rows(&db).unwrap(); let now = a14_now(); let b1 = daily_model_costs(&rows, now, 30); diff --git a/rust/src/providers/opencodego/local/tokens_tests.rs b/rust/src/providers/opencodego/local/tokens_tests.rs index c64f449ae2f..b789acfd931 100644 --- a/rust/src/providers/opencodego/local/tokens_tests.rs +++ b/rust/src/providers/opencodego/local/tokens_tests.rs @@ -1,5 +1,6 @@ //! Recorded-token coverage for the OpenCode Go local reader (upstream 0.67.0). +use super::test_db::{insert_message, insert_step_finish, iso_ms, open_db}; use super::*; const FULL: &str = @@ -7,55 +8,10 @@ const FULL: &str = /// Older OpenCode rows record no `total`; the five components sum to 20. const NO_TOTAL: &str = r#"{"input":10,"output":5,"reasoning":1,"cache":{"read":4,"write":0}}"#; -fn iso_ms(iso: &str) -> i64 { - chrono::DateTime::parse_from_rfc3339(iso) - .unwrap() - .timestamp_millis() -} - fn now() -> DateTime { Utc.with_ymd_and_hms(2026, 3, 8, 0, 0, 0).unwrap() } -fn open_db(path: &Path, with_part_table: bool) -> Connection { - let conn = Connection::open(path).unwrap(); - conn.execute_batch( - "CREATE TABLE message (id TEXT PRIMARY KEY, data TEXT, time_created INTEGER);", - ) - .unwrap(); - if with_part_table { - conn.execute_batch( - "CREATE TABLE part (id TEXT PRIMARY KEY, message_id TEXT, data TEXT, time_created INTEGER);", - ) - .unwrap(); - } - conn -} - -/// `tokens` is spliced in as raw JSON so malformed shapes can be exercised. -fn insert_message(conn: &Connection, id: &str, created_ms: i64, model: &str, tokens: Option<&str>) { - let tokens_json = tokens.map_or_else(String::new, |t| format!(r#","tokens":{t}"#)); - let data = format!( - r#"{{"providerID":"opencode-go","role":"assistant","cost":1.5,"modelID":"{model}"{tokens_json},"time":{{"created":{created_ms}}}}}"# - ); - conn.execute( - "INSERT INTO message (id, data, time_created) VALUES (?1, ?2, ?3)", - rusqlite::params![id, data, created_ms], - ) - .unwrap(); -} - -fn insert_step_finish(conn: &Connection, id: &str, message_id: &str, ms: i64, tokens: &str) { - let data = format!( - r#"{{"type":"step-finish","cost":0.5,"tokens":{tokens},"time":{{"created":{ms}}}}}"# - ); - conn.execute( - "INSERT INTO part (id, message_id, data, time_created) VALUES (?1, ?2, ?3, ?4)", - rusqlite::params![id, message_id, data, ms], - ) - .unwrap(); -} - fn parse(json: &str) -> Option { RowTokens::parse(json) } @@ -147,8 +103,15 @@ fn daily_entries_carry_message_token_counts_per_day_and_model() { iso_ms("2026-03-06T11:00:00.000Z"), iso_ms("2026-03-06T11:30:00.000Z"), ); - insert_message(&conn, "m1", first, "kimi-k2", Some(FULL)); - insert_message(&conn, "m2", second, "kimi-k2", Some(NO_TOTAL)); + insert_message(&conn, "m1", first, Some(1.5), Some("kimi-k2"), Some(FULL)); + insert_message( + &conn, + "m2", + second, + Some(1.5), + Some("kimi-k2"), + Some(NO_TOTAL), + ); drop(conn); let rows = read_rows(&db).unwrap(); @@ -173,14 +136,21 @@ fn step_finish_tokens_replace_the_parent_message_tokens() { let conn = open_db(&db, true); let created = iso_ms("2026-03-06T11:00:00.000Z"); // The parent message records the session total; the steps record each call. - insert_message(&conn, "m1", created, "kimi-k2", Some(FULL)); + insert_message(&conn, "m1", created, Some(1.5), Some("kimi-k2"), Some(FULL)); let step_a = r#"{"total":30,"input":10,"output":5,"reasoning":5,"cache":{"read":10,"write":0}}"#; let step_b = r#"{"total":20,"input":8,"output":2,"reasoning":0,"cache":{"read":10,"write":0}}"#; - insert_step_finish(&conn, "p1", "m1", created, step_a); - insert_step_finish(&conn, "p2", "m1", created, step_b); + insert_step_finish(&conn, "p1", "m1", created, 0.5, Some(step_a)); + insert_step_finish(&conn, "p2", "m1", created, 0.5, Some(step_b)); // A message with no step-finish parts keeps using its own tokens. - insert_message(&conn, "m2", created, "kimi-k2", Some(NO_TOTAL)); + insert_message( + &conn, + "m2", + created, + Some(1.5), + Some("kimi-k2"), + Some(NO_TOTAL), + ); drop(conn); let rows = read_rows(&db).unwrap(); @@ -198,10 +168,17 @@ fn rows_without_usable_tokens_leave_the_day_incomplete_not_zero() { let db = dir.path().join("opencode.db"); let conn = open_db(&db, false); let created = iso_ms("2026-03-06T11:00:00.000Z"); - insert_message(&conn, "m1", created, "kimi-k2", Some(FULL)); - insert_message(&conn, "m2", created, "kimi-k2", None); - insert_message(&conn, "m3", created, "kimi-k2", Some("5")); - insert_message(&conn, "m4", created, "kimi-k2", Some(r#"{"total":-4}"#)); + insert_message(&conn, "m1", created, Some(1.5), Some("kimi-k2"), Some(FULL)); + insert_message(&conn, "m2", created, Some(1.5), Some("kimi-k2"), None); + insert_message(&conn, "m3", created, Some(1.5), Some("kimi-k2"), Some("5")); + insert_message( + &conn, + "m4", + created, + Some(1.5), + Some("kimi-k2"), + Some(r#"{"total":-4}"#), + ); drop(conn); let rows = read_rows(&db).unwrap(); @@ -223,14 +200,22 @@ fn model_summary_carries_per_model_tokens_and_marks_partial_models() { let db = dir.path().join("opencode.db"); let conn = open_db(&db, false); let created = iso_ms("2026-03-06T11:00:00.000Z"); - insert_message(&conn, "m1", created, "kimi-k2", Some(FULL)); - insert_message(&conn, "m2", created, "kimi-k2", Some(NO_TOTAL)); - insert_message(&conn, "m3", created, "glm-5", None); + insert_message(&conn, "m1", created, Some(1.5), Some("kimi-k2"), Some(FULL)); + insert_message( + &conn, + "m2", + created, + Some(1.5), + Some("kimi-k2"), + Some(NO_TOTAL), + ); + insert_message(&conn, "m3", created, Some(1.5), Some("glm-5"), None); insert_message( &conn, "m4", created, - "mimo", + Some(1.5), + Some("mimo"), Some(r#"{"total":9,"input":4}"#), ); drop(conn); From 47115d70e38024bf3c544aec03c31416625c8d07 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:03:08 +0700 Subject: [PATCH 19/40] Share Kimi clock, integer and client helpers (PBG-36) --- rust/src/providers/kimi/code_api.rs | 5 +-- rust/src/providers/kimi/desktop_token.rs | 14 +------- rust/src/providers/kimi/mod.rs | 43 ++++++++++++++++++------ rust/src/providers/kimi/ratio_pool.rs | 35 ++++++++++++------- rust/src/providers/kimi/web.rs | 2 +- 5 files changed, 58 insertions(+), 41 deletions(-) diff --git a/rust/src/providers/kimi/code_api.rs b/rust/src/providers/kimi/code_api.rs index 624f35fa725..3cda29ee0fb 100644 --- a/rust/src/providers/kimi/code_api.rs +++ b/rust/src/providers/kimi/code_api.rs @@ -56,10 +56,7 @@ pub(crate) async fn fetch_via_code_api( let api_key = code_api_key(api_key_override.or(ctx.api_key.as_deref()))?; let base_url = code_api_base_url(region)?; let endpoint = code_api_usage_endpoint(&base_url)?; - let client = crate::core::credentialed_http_client_builder() - .timeout(std::time::Duration::from_secs(30)) - .build() - .map_err(|e| ProviderError::Other(e.to_string()))?; + let client = web::client()?; let mut request = client .get(endpoint) diff --git a/rust/src/providers/kimi/desktop_token.rs b/rust/src/providers/kimi/desktop_token.rs index ac40f793ba2..f0ab4a1e1a6 100644 --- a/rust/src/providers/kimi/desktop_token.rs +++ b/rust/src/providers/kimi/desktop_token.rs @@ -20,7 +20,6 @@ use base64::Engine; use std::path::{Path, PathBuf}; -use std::time::{SystemTime, UNIX_EPOCH}; pub struct KimiDesktopAuthToken; @@ -44,10 +43,6 @@ impl KimiDesktopAuthToken { /// Most recently accessed `kimi-auth` token from the signed-in Kimi /// Desktop session, or `None` when the app/database/cookie is absent or /// unreadable. Production entry point. - pub fn load() -> Option { - Self::load_for_region(super::KimiRegion::China) - } - pub fn load_for_region(region: super::KimiRegion) -> Option { let data_root = dirs::data_dir()?; Self::load_from_region(&data_root, region) @@ -101,7 +96,7 @@ impl KimiDesktopAuthToken { }) .ok() .and_then(|row| decode_cookie_value(row, aes_key)) - .filter(|token| !is_expired_jwt(token, unix_now_secs())) + .filter(|token| !is_expired_jwt(token, super::unix_now_secs())) } } @@ -133,13 +128,6 @@ fn is_expired_jwt(token: &str, now_unix: f64) -> bool { expiry.is_finite() && expiry <= now_unix } -fn unix_now_secs() -> f64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_secs_f64()) - .unwrap_or(0.0) -} - /// Decode a `(value, encrypted_value)` pair: plaintext first, AES-256-GCM /// fallback (upstream reads `value` only; Windows Chromium rows are usually /// encrypted). diff --git a/rust/src/providers/kimi/mod.rs b/rust/src/providers/kimi/mod.rs index 4e6c27c2929..aeaaea56b0d 100755 --- a/rust/src/providers/kimi/mod.rs +++ b/rust/src/providers/kimi/mod.rs @@ -594,19 +594,23 @@ fn ascii_header_value(raw: &str) -> String { } } -fn format_usage_amount(value: f64) -> String { - if value.is_finite() +/// `value` as an i64 when it is finite, integral and inside the i64 range. +fn exact_i64(value: f64) -> Option { + // The strict upper bound excludes 2^63, which is representable as f64 + // but has no exact i64 representation. + let integral = value.is_finite() && value.fract() == 0.0 && value >= i64::MIN as f64 - && value < i64::MAX as f64 - { - // The strict upper bound excludes 2^63, which is representable as f64 - // but has no exact i64 representation. - #[allow( - clippy::cast_possible_truncation, - reason = "finite integral value is bounded to the i64 range above" - )] - let integral = value as i64; + && value < i64::MAX as f64; + #[allow( + clippy::cast_possible_truncation, + reason = "finite integral value is bounded to the i64 range above" + )] + integral.then_some(value as i64) +} + +fn format_usage_amount(value: f64) -> String { + if let Some(integral) = exact_i64(value) { format!("{integral}") } else if value.is_finite() && value.fract() == 0.0 { // Preserve a large integral value without saturating it to i64::MAX. @@ -870,6 +874,23 @@ mod tests { assert!(cleaned_owned(" ").is_none()); } + #[test] + fn usage_amount_formats_integral_fractional_and_non_finite_values() { + let cases = [ + (42.0, "42"), + (-0.0, "0"), + (1.5, "1.50"), + (-2_f64.powi(63), "-9223372036854775808"), + (2_f64.powi(63), "9223372036854775808"), + (1e20, "100000000000000000000"), + (f64::NAN, "NaN"), + (f64::INFINITY, "inf"), + ]; + for (value, expected) in cases { + assert_eq!(format_usage_amount(value), expected, "{value}"); + } + } + #[test] fn oversized_integral_usage_amount_is_not_saturated_to_i64_max() { let value = 2_f64.powi(63); diff --git a/rust/src/providers/kimi/ratio_pool.rs b/rust/src/providers/kimi/ratio_pool.rs index 941f394765a..ad4e39b024c 100644 --- a/rust/src/providers/kimi/ratio_pool.rs +++ b/rust/src/providers/kimi/ratio_pool.rs @@ -57,18 +57,9 @@ fn usage_counts(detail: &KimiUsageDetail) -> Option { /// strings do not. fn integer_counter(value: Option<&serde_json::Value>) -> Option { match value? { - serde_json::Value::Number(number) => number.as_i64().or_else(|| { - let value = number.as_f64()?; - let integral = value.is_finite() - && value.fract() == 0.0 - && value >= i64::MIN as f64 - && value < i64::MAX as f64; - #[allow( - clippy::cast_possible_truncation, - reason = "finite integral value is bounded to the i64 range above" - )] - integral.then_some(value as i64) - }), + serde_json::Value::Number(number) => number + .as_i64() + .or_else(|| number.as_f64().and_then(super::exact_i64)), serde_json::Value::String(text) => text.parse().ok(), _ => None, } @@ -163,6 +154,26 @@ mod tests { use chrono::{DateTime, Utc}; use serde_json::{Value, json}; + #[test] + fn integer_counter_accepts_exact_integers_only() { + let cases = [ + (json!(3), Some(3)), + (json!(3.0), Some(3)), + (json!(3.5), None), + (json!(-9.223_372_036_854_775_808e18), Some(i64::MIN)), + (json!(9.223_372_036_854_775_808e18), None), + (json!(u64::MAX), None), + (json!("12"), Some(12)), + (json!(" 12"), None), + (json!("1,000"), None), + (json!(null), None), + ]; + for (value, expected) in cases { + assert_eq!(super::integer_counter(Some(&value)), expected, "{value}"); + } + assert_eq!(super::integer_counter(None), None); + } + fn try_parse(value: Value) -> Result { let response: KimiCodeApiUsageResponse = serde_json::from_value(value).expect("fixture parses"); diff --git a/rust/src/providers/kimi/web.rs b/rust/src/providers/kimi/web.rs index c6c1b8f59f0..542825febe6 100644 --- a/rust/src/providers/kimi/web.rs +++ b/rust/src/providers/kimi/web.rs @@ -330,7 +330,7 @@ fn selected_account_auth_token(cookie_header: Option<&str>) -> Result Result { +pub(super) fn client() -> Result { crate::core::credentialed_http_client_builder() .timeout(std::time::Duration::from_secs(30)) .build() From 82d75e699ef5864c69db6534afe6436c6598971c Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:09:11 +0700 Subject: [PATCH 20/40] Settle Kimi web token attempts in one place (PBG-35) --- rust/src/providers/kimi/web.rs | 219 ++++++++++++++++++++++++--------- 1 file changed, 159 insertions(+), 60 deletions(-) diff --git a/rust/src/providers/kimi/web.rs b/rust/src/providers/kimi/web.rs index 542825febe6..02d6976b93f 100644 --- a/rust/src/providers/kimi/web.rs +++ b/rust/src/providers/kimi/web.rs @@ -53,6 +53,13 @@ pub(super) struct WebFetchFailure { } impl WebFetchFailure { + fn before_token(error: ProviderError) -> Self { + Self { + error, + had_token: false, + } + } + fn after_token(error: ProviderError) -> Self { Self { error, @@ -61,6 +68,18 @@ impl WebFetchFailure { } } +/// One automatic token attempt. `None` means the server rejected the token +/// and the chain moves on; anything else ends it. +fn settle_attempt( + result: Result, +) -> Option> { + match result { + Ok(usage) => Some(Ok(usage)), + Err(ProviderError::AuthRequired) => None, + Err(error) => Some(Err(WebFetchFailure::after_token(error))), + } +} + /// Web auth token chain for both the web fetch and the Code-API enrichment /// (upstream `KimiWebEnrichmentTokenResolver.resolve`): /// 1. Manual cookie header (its `kimi-auth`/auth cookie), source-independent. @@ -194,24 +213,9 @@ pub(super) async fn fetch_web_session_isolated( // its own session cookie and never fall back to another ambient credential // or browser account (upstream per-provider token account routing). if account_isolated { - let token = match selected_account_auth_token(cookie_header) { - Ok(token) => token, - Err(error) => { - return Err(WebFetchFailure { - error, - had_token: false, - }); - } - }; - let http = match client() { - Ok(http) => http, - Err(error) => { - return Err(WebFetchFailure { - error, - had_token: false, - }); - } - }; + let token = + selected_account_auth_token(cookie_header).map_err(WebFetchFailure::before_token)?; + let http = client().map_err(WebFetchFailure::before_token)?; return fetch_via_web_token(&http, &token, region) .await .map_err(WebFetchFailure::after_token); @@ -267,50 +271,41 @@ where } if !browser_import_allowed(input.cookie_source) { - return Err(WebFetchFailure { - error: browser_import_error(input.cookie_source), - had_token: false, - }); + return Err(WebFetchFailure::before_token(browser_import_error( + input.cookie_source, + ))); } // Read and try the desktop session first. Browser cookies are intentionally // read only after the server rejects this automatic session, so a healthy // desktop account never causes another credential store to be touched. let desktop_token = (input.desktop_token)(input.region); - if let Some(token) = desktop_token.clone() { - match fetch(token).await { - Ok(usage) => return Ok(usage), - Err(ProviderError::AuthRequired) => {} - Err(error) => return Err(WebFetchFailure::after_token(error)), - } + if let Some(token) = desktop_token.clone() + && let Some(outcome) = settle_attempt(fetch(token).await) + { + return outcome; } let browser_token = (input.browser_token)(input.region).filter(|token| desktop_token.as_ref() != Some(token)); - if let Some(token) = browser_token.clone() { - match fetch(token).await { - Ok(usage) => return Ok(usage), - Err(ProviderError::AuthRequired) => {} - Err(error) => return Err(WebFetchFailure::after_token(error)), - } + if let Some(token) = browser_token.clone() + && let Some(outcome) = settle_attempt(fetch(token).await) + { + return outcome; } // Local-storage tokens are read last, only after every cookie source // was rejected. - let mut seen = std::collections::HashSet::new(); - if let Some(token) = desktop_token.clone() { - seen.insert(token); - } - if let Some(token) = browser_token.clone() { - seen.insert(token); - } + let mut seen: std::collections::HashSet = desktop_token + .iter() + .chain(&browser_token) + .cloned() + .collect(); for candidate in resolve_web_tokens(input).into_iter().filter(|candidate| { candidate.source == WebTokenSource::LocalStorage && seen.insert(candidate.token.clone()) }) { - match fetch(candidate.token).await { - Ok(usage) => return Ok(usage), - Err(ProviderError::AuthRequired) => {} - Err(error) => return Err(WebFetchFailure::after_token(error)), + if let Some(outcome) = settle_attempt(fetch(candidate.token).await) { + return outcome; } } @@ -381,14 +376,17 @@ async fn fetch_subscription_details( // completed statistics response when the plan endpoint is slow or absent. let stats = tokio::time::timeout( SUBSCRIPTION_ENRICHMENT_TIMEOUT, - fetch_subscription_for_enrichment(client, token, region), + fetch_subscription_for_enrichment_result(client, token, region), ); let plan = tokio::time::timeout( SUBSCRIPTION_ENRICHMENT_TIMEOUT, fetch_subscription_plan(client, token, region), ); let (stats, plan) = tokio::join!(stats, plan); - (stats.ok().flatten(), plan.ok().flatten()) + ( + stats.ok().and_then(Result::ok).flatten(), + plan.ok().flatten(), + ) } pub(super) fn snapshot_from_web_usage_response( @@ -444,19 +442,6 @@ pub(super) async fn fetch_subscription_plan( } } -// Kept for `code_api`: resolve the subscription stats snapshot with a web -// token; any failure means "no enrichment", never an error. -pub(super) async fn fetch_subscription_for_enrichment( - client: &Client, - token: &str, - region: KimiRegion, -) -> Option { - fetch_subscription_for_enrichment_result(client, token, region) - .await - .ok() - .flatten() -} - pub(super) async fn fetch_subscription_for_enrichment_result( client: &Client, token: &str, @@ -648,6 +633,120 @@ mod tests { Vec::new() } + thread_local! { + static READS: std::cell::Cell<[u32; 3]> = const { std::cell::Cell::new([0; 3]) }; + } + + fn count_read(source: usize) { + READS.with(|reads| { + let mut counts = reads.get(); + counts[source] += 1; + reads.set(counts); + }); + } + + /// Desktop, browser and local-storage reads since the last call. + fn take_reads() -> [u32; 3] { + READS.with(|reads| reads.replace([0; 3])) + } + + fn counted_desktop(_: KimiRegion) -> Option { + count_read(0); + Some("desktop-token".to_string()) + } + + fn counted_browser(_: KimiRegion) -> Option { + count_read(1); + Some("browser-token".to_string()) + } + + fn counted_no_desktop(_: KimiRegion) -> Option { + count_read(0); + None + } + + fn counted_no_browser(_: KimiRegion) -> Option { + count_read(1); + None + } + + fn counted_local_storage(_: KimiRegion) -> Vec { + count_read(2); + vec!["browser-token".to_string(), "storage-token".to_string()] + } + + fn accept_storage_only(token: &str) -> Result { + if token == "storage-token" { + Ok(usage(40.0)) + } else { + Err(ProviderError::AuthRequired) + } + } + + #[tokio::test] + async fn rejected_local_storage_only_token_reports_none_tried() { + take_reads(); + let (result, sent) = run_chain( + WebTokenInput { + local_storage_tokens: counted_local_storage, + ..input(None, "auto", counted_no_desktop, counted_no_browser) + }, + reject_all, + ) + .await; + let failure = result.expect_err("local-storage token rejected"); + // Kept quirk: a rejected local-storage-only token reads as "no token". + assert!(!failure.had_token); + assert!(matches!(failure.error, ProviderError::AuthRequired)); + assert_eq!(sent, ["browser-token", "storage-token"]); + assert_eq!(take_reads(), [2, 2, 2]); + } + + #[tokio::test] + async fn local_storage_tokens_follow_rejected_cookie_tokens() { + take_reads(); + let (result, sent) = run_chain( + WebTokenInput { + local_storage_tokens: counted_local_storage, + ..input(None, "auto", counted_desktop, counted_browser) + }, + accept_storage_only, + ) + .await; + assert_eq!( + result.expect("storage token accepted").primary.used_percent, + 40.0 + ); + assert_eq!(sent, ["desktop-token", "browser-token", "storage-token"]); + assert_eq!(take_reads(), [2, 2, 1]); + + let (result, sent) = run_chain( + WebTokenInput { + local_storage_tokens: counted_local_storage, + ..input(None, "auto", counted_desktop, counted_browser) + }, + reject_all, + ) + .await; + assert!(result.expect_err("every token rejected").had_token); + assert_eq!(sent, ["desktop-token", "browser-token", "storage-token"]); + assert_eq!(take_reads(), [2, 2, 1]); + + let (result, sent) = run_chain( + WebTokenInput { + local_storage_tokens: counted_local_storage, + ..input(None, "auto", counted_no_desktop, counted_no_browser) + }, + server_error, + ) + .await; + let failure = result.expect_err("server error"); + assert!(failure.had_token); + assert!(matches!(failure.error, ProviderError::Other(message) if message.contains("500"))); + assert_eq!(sent, ["browser-token"]); + assert_eq!(take_reads(), [2, 2, 1]); + } + fn static_local_storage(_: KimiRegion) -> Vec { vec!["browser-token".to_string(), "storage-token".to_string()] } From b3282b02eb3b5a273befcc8b51f68624e2f3c973 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:12:14 +0700 Subject: [PATCH 21/40] Share Kimi web snapshot and rate-limit test helpers (PBG-64) --- rust/src/providers/kimi/mod.rs | 170 +++++++++++--------------- rust/src/providers/kimi/ratio_pool.rs | 42 +++---- 2 files changed, 85 insertions(+), 127 deletions(-) diff --git a/rust/src/providers/kimi/mod.rs b/rust/src/providers/kimi/mod.rs index aeaaea56b0d..3f140a2c0d4 100755 --- a/rust/src/providers/kimi/mod.rs +++ b/rust/src/providers/kimi/mod.rs @@ -623,7 +623,21 @@ fn format_usage_amount(value: f64) -> String { #[cfg(test)] mod tests { use super::*; - use serde_json::json; + use serde_json::{Value, json}; + + fn web_snapshot(usage: Value, subscription: Value) -> UsageSnapshot { + let usage: KimiWebUsageResponse = serde_json::from_value(usage).unwrap(); + let subscription: KimiSubscriptionStatsResponse = + serde_json::from_value(subscription).unwrap(); + web::snapshot_from_web_usage_response(usage, Some(subscription)).unwrap() + } + + fn has_extra(snapshot: &UsageSnapshot, id: &str) -> bool { + snapshot + .extra_rate_windows + .iter() + .any(|window| window.id == id) + } #[test] fn auth_token_search_skips_unrelated_cookie_headers() { @@ -720,31 +734,29 @@ mod tests { #[test] fn parses_web_usage_with_subscription_windows() { - let usage: KimiWebUsageResponse = serde_json::from_value(json!({ - "usages": [{ - "scope": "FEATURE_CODING", - "detail": { "limit": "2048", "used": "375", "resetTime": "2026-01-09T15:23:13Z" }, - "limits": [{ - "window": { "duration": 300, "timeUnit": "TIME_UNIT_MINUTE" }, - "detail": { "limit": "100", "used": "25" } + let snapshot = web_snapshot( + json!({ + "usages": [{ + "scope": "FEATURE_CODING", + "detail": { "limit": "2048", "used": "375", "resetTime": "2026-01-09T15:23:13Z" }, + "limits": [{ + "window": { "duration": 300, "timeUnit": "TIME_UNIT_MINUTE" }, + "detail": { "limit": "100", "used": "25" } + }] }] - }] - })) - .unwrap(); - let subscription: KimiSubscriptionStatsResponse = serde_json::from_value(json!({ - "subscriptionBalance": { - "amountUsedRatio": 0.7716, - "expireTime": "2026-07-23T00:00:00Z" - }, - "ratelimitCode7d": { - "ratio": 0.0946, - "enabled": true, - "resetTime": "2026-07-13T15:28:00Z" - } - })) - .unwrap(); - - let snapshot = web::snapshot_from_web_usage_response(usage, Some(subscription)).unwrap(); + }), + json!({ + "subscriptionBalance": { + "amountUsedRatio": 0.7716, + "expireTime": "2026-07-23T00:00:00Z" + }, + "ratelimitCode7d": { + "ratio": 0.0946, + "enabled": true, + "resetTime": "2026-07-13T15:28:00Z" + } + }), + ); assert!((snapshot.primary.used_percent - 18.310546875).abs() < f64::EPSILON); assert_eq!( @@ -774,29 +786,22 @@ mod tests { fn feature_scoped_balance_is_not_the_total_usage_lane() { // Upstream 0.49.0 #2741: only the omni/subscription pool maps to the // "Total usage" lane; feature-scoped balances must not. - let usage: KimiWebUsageResponse = serde_json::from_value(json!({ - "usages": [{ - "scope": "FEATURE_CODING", - "detail": { "limit": "2048", "used": "375" } - }] - })) - .unwrap(); - let subscription: KimiSubscriptionStatsResponse = serde_json::from_value(json!({ - "subscriptionBalance": { - "amountUsedRatio": 0.5, - "feature": "FEATURE_CODING", - "type": "SUBSCRIPTION" - } - })) - .unwrap(); - - let snapshot = web::snapshot_from_web_usage_response(usage, Some(subscription)).unwrap(); - assert!( - snapshot - .extra_rate_windows - .iter() - .all(|window| window.id != "kimi-monthly") + let snapshot = web_snapshot( + json!({ + "usages": [{ + "scope": "FEATURE_CODING", + "detail": { "limit": "2048", "used": "375" } + }] + }), + json!({ + "subscriptionBalance": { + "amountUsedRatio": 0.5, + "feature": "FEATURE_CODING", + "type": "SUBSCRIPTION" + } + }), ); + assert!(!has_extra(&snapshot, "kimi-monthly")); } #[test] @@ -804,7 +809,7 @@ mod tests { // Upstream 0.49.0 #2741: when the membership Code 7-day ratio and the // primary weekly window agree (percent within 1 point, resets within // 5 minutes, weekly counter reliable), the extra row is suppressed. - let usage: KimiWebUsageResponse = serde_json::from_value(json!({ + let usage = json!({ "usages": [{ "scope": "FEATURE_CODING", "detail": { @@ -813,58 +818,27 @@ mod tests { "resetTime": "2026-08-13T15:28:00Z" } }] - })) - .unwrap(); - let subscription_matching: KimiSubscriptionStatsResponse = serde_json::from_value(json!({ - "ratelimitCode7d": { - "ratio": 0.421, - "enabled": true, - "resetTime": "2026-08-13T15:30:00Z" - } - })) - .unwrap(); - - let snapshot = - web::snapshot_from_web_usage_response(usage, Some(subscription_matching)).unwrap(); + }); + let code_7d = |ratio: f64| { + json!({ + "ratelimitCode7d": { + "ratio": ratio, + "enabled": true, + "resetTime": "2026-08-13T15:30:00Z" + } + }) + }; + let snapshot = web_snapshot(usage.clone(), code_7d(0.421)); assert!((snapshot.primary.used_percent - 42.0).abs() < f64::EPSILON); assert!( - snapshot - .extra_rate_windows - .iter() - .all(|window| window.id != "kimi-code-7d"), + !has_extra(&snapshot, "kimi-code-7d"), "matching Code 7-day row should be suppressed" ); // Diverging ratio (or missing reset evidence) keeps the row. - let subscription_diverging: KimiSubscriptionStatsResponse = serde_json::from_value(json!({ - "ratelimitCode7d": { - "ratio": 0.9, - "enabled": true, - "resetTime": "2026-08-13T15:30:00Z" - } - })) - .unwrap(); - let usage_diverging: KimiWebUsageResponse = serde_json::from_value(json!({ - "usages": [{ - "scope": "FEATURE_CODING", - "detail": { - "limit": "1000", - "used": "420", - "resetTime": "2026-08-13T15:28:00Z" - } - }] - })) - .unwrap(); - let snapshot = - web::snapshot_from_web_usage_response(usage_diverging, Some(subscription_diverging)) - .unwrap(); - assert!( - snapshot - .extra_rate_windows - .iter() - .any(|window| window.id == "kimi-code-7d") - ); + let snapshot = web_snapshot(usage, code_7d(0.9)); + assert!(has_extra(&snapshot, "kimi-code-7d")); } #[test] @@ -881,6 +855,7 @@ mod tests { (-0.0, "0"), (1.5, "1.50"), (-2_f64.powi(63), "-9223372036854775808"), + // Not saturated to i64::MAX. (2_f64.powi(63), "9223372036854775808"), (1e20, "100000000000000000000"), (f64::NAN, "NaN"), @@ -891,15 +866,6 @@ mod tests { } } - #[test] - fn oversized_integral_usage_amount_is_not_saturated_to_i64_max() { - let value = 2_f64.powi(63); - let formatted = format_usage_amount(value); - - assert!(formatted.starts_with("9223372036854775808")); - assert_ne!(formatted, i64::MAX.to_string()); - } - #[test] fn overflowing_window_units_are_omitted() { let window = KimiWindow { diff --git a/rust/src/providers/kimi/ratio_pool.rs b/rust/src/providers/kimi/ratio_pool.rs index ad4e39b024c..8b535dd21f4 100644 --- a/rust/src/providers/kimi/ratio_pool.rs +++ b/rust/src/providers/kimi/ratio_pool.rs @@ -149,7 +149,7 @@ mod tests { use super::super::code_api::{MISSING_WEEKLY_DESCRIPTION, snapshot_from_code_api_response}; use super::super::{ KimiCodeApiUsageResponse, KimiSubscriptionStatsResponse, MONTHLY_WINDOW_ID, ProviderError, - UsageSnapshot, apply_subscription_windows, + RateWindow, UsageSnapshot, apply_subscription_windows, }; use chrono::{DateTime, Utc}; use serde_json::{Value, json}; @@ -208,6 +208,14 @@ mod tests { ); } + /// The 5-hour rate-limit lane, asserting its percent and duration. + fn assert_rate_limit(snapshot: &UsageSnapshot, percent: f64) -> &RateWindow { + let rate_limit = snapshot.secondary.as_ref().expect("rate-limit lane"); + assert_eq!(rate_limit.used_percent, percent); + assert_eq!(rate_limit.window_minutes, Some(300)); + rate_limit + } + fn rate_limit_percent(snapshot: &UsageSnapshot) -> f64 { snapshot .secondary @@ -267,9 +275,7 @@ mod tests { } })); assert_weekly_absent(&snapshot); - let rate_limit = snapshot.secondary.as_ref().expect("rate-limit lane"); - assert_eq!(rate_limit.used_percent, 0.0); - assert_eq!(rate_limit.window_minutes, Some(300)); + let rate_limit = assert_rate_limit(&snapshot, 0.0); assert_eq!(rate_limit.resets_at, at("2026-09-16T20:15:44Z")); assert_eq!(rate_limit.reset_description, None); assert!(snapshot.tertiary.is_none()); @@ -298,9 +304,7 @@ mod tests { assert_eq!(snapshot.primary.used_percent, 12.5); assert_eq!(snapshot.primary.window_minutes, Some(10_080)); assert_eq!(snapshot.primary.resets_at, at("2026-09-20T00:00:00Z")); - let rate_limit = snapshot.secondary.as_ref().expect("rate-limit lane"); - assert_eq!(rate_limit.used_percent, 62.5); - assert_eq!(rate_limit.window_minutes, Some(300)); + assert_rate_limit(&snapshot, 62.5); assert!(snapshot.extra_rate_windows.is_empty()); } @@ -326,9 +330,7 @@ mod tests { }] })); assert_weekly_absent(&snapshot); - let rate_limit = snapshot.secondary.as_ref().expect("rate-limit lane"); - assert_eq!(rate_limit.used_percent, 25.0); - assert_eq!(rate_limit.window_minutes, Some(300)); + let rate_limit = assert_rate_limit(&snapshot, 25.0); assert_eq!( rate_limit.reset_description.as_deref(), Some("25/100 credits") @@ -507,9 +509,7 @@ mod tests { snapshot.primary.resets_at, at("2026-09-19T16:45:59.449979Z") ); - let rate_limit = snapshot.secondary.as_ref().expect("rate-limit lane"); - assert_eq!(rate_limit.used_percent, 1.0); - assert_eq!(rate_limit.window_minutes, Some(300)); + let rate_limit = assert_rate_limit(&snapshot, 1.0); assert_eq!(rate_limit.resets_at, at("2026-09-19T14:45:59.449979Z")); assert!(snapshot.tertiary.is_none()); assert!(snapshot.extra_rate_windows.is_empty()); @@ -575,9 +575,7 @@ mod tests { response["limits"][0]["window"]["duration"] = json!(120); let snapshot = parse(response); assert_eq!(snapshot.primary.used_percent, 19.0); - let rate_limit = snapshot.secondary.as_ref().expect("rate-limit lane"); - assert_eq!(rate_limit.used_percent, 0.0); - assert_eq!(rate_limit.window_minutes, Some(300)); + assert_rate_limit(&snapshot, 0.0); } // Upstream `usageCounts`: an invalid `used` falls back to a valid @@ -618,9 +616,7 @@ mod tests { let snapshot = parse(response); assert_eq!(snapshot.primary.used_percent, 0.0); assert_eq!(snapshot.primary.window_minutes, Some(10_080)); - let rate_limit = snapshot.secondary.as_ref().expect("rate-limit lane"); - assert_eq!(rate_limit.used_percent, 0.0); - assert_eq!(rate_limit.window_minutes, Some(300)); + assert_rate_limit(&snapshot, 0.0); } } @@ -661,9 +657,7 @@ mod tests { #[test] fn missing_legacy_window_does_not_override_zero_session_ratio() { let snapshot = zero_session_ratio_with_legacy_window(None); - let rate_limit = snapshot.secondary.as_ref().expect("rate-limit lane"); - assert_eq!(rate_limit.window_minutes, Some(300)); - assert_eq!(rate_limit.used_percent, 0.0); + assert_rate_limit(&snapshot, 0.0); } #[test] @@ -672,8 +666,6 @@ mod tests { "duration": 300, "timeUnit": "TIME_UNIT_FORTNIGHT" }))); - let rate_limit = snapshot.secondary.as_ref().expect("rate-limit lane"); - assert_eq!(rate_limit.window_minutes, Some(300)); - assert_eq!(rate_limit.used_percent, 0.0); + assert_rate_limit(&snapshot, 0.0); } } From 55674d577096f1464887f9b16b93df0b3bf1d2eb Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:20:09 +0700 Subject: [PATCH 22/40] Share the MiniMax HTTP client, console GET and status mapping (PBG-08) --- rust/src/providers/minimax/http_tests.rs | 96 +++++++++++ rust/src/providers/minimax/mod.rs | 184 ++++++++++------------ rust/src/providers/minimax/remains_api.rs | 61 ++++--- rust/src/providers/minimax/token_plan.rs | 73 ++++++--- 4 files changed, 267 insertions(+), 147 deletions(-) create mode 100644 rust/src/providers/minimax/http_tests.rs diff --git a/rust/src/providers/minimax/http_tests.rs b/rust/src/providers/minimax/http_tests.rs new file mode 100644 index 00000000000..bdced7e1fb7 --- /dev/null +++ b/rust/src/providers/minimax/http_tests.rs @@ -0,0 +1,96 @@ +//! Wire-level pins for the MiniMax console and remains GET requests. + +use super::*; + +pub(super) const JSON_ACCEPT: &str = "application/json, text/plain, */*"; + +/// Console GET with the cookie, JSON accept, XHR and browser headers. +pub(super) fn console_mock(server: &mut mockito::Server, path: &str) -> mockito::Mock { + let base = MiniMaxRegion::Global.base_url(); + server + .mock("GET", path) + .match_header("cookie", "session=fixture") + .match_header("accept", JSON_ACCEPT) + .match_header("x-requested-with", "XMLHttpRequest") + .match_header("user-agent", MiniMaxProvider::WEB_USER_AGENT) + .match_header("accept-language", "en-US,en;q=0.9") + .match_header("origin", base) + .match_header( + "referer", + format!("{base}/user-center/payment/coding-plan").as_str(), + ) +} + +#[tokio::test] +async fn cookie_remains_request_maps_statuses() { + let cases: [(usize, Option<&str>); 5] = [ + (401, None), + (403, None), + ( + 404, + Some("Parse:MiniMax remains returned status 404 Not Found"), + ), + ( + 405, + Some("Parse:MiniMax remains returned status 405 Method Not Allowed"), + ), + ( + 500, + Some("Other:MiniMax remains returned status 500 Internal Server Error"), + ), + ]; + for (status, expected) in cases { + let mut server = mockito::Server::new_async().await; + let mock = console_mock(&mut server, "/remains") + .with_status(status) + .expect(1) + .create_async() + .await; + let result = MiniMaxProvider::new() + .fetch_remains_once( + "session=fixture", + &format!("{}/remains", server.url()), + MiniMaxRegion::Global, + Utc::now(), + ) + .await; + mock.assert_async().await; + assert_status_error(result.map(|_| ()), expected, status); + } +} + +#[tokio::test] +async fn cookie_remains_request_parses_a_json_body() { + let mut server = mockito::Server::new_async().await; + let mock = console_mock(&mut server, "/remains") + .with_header("content-type", "application/json") + .with_body(r#"{"base_resp":{"status_code":2062,"status_msg":"no plan"}}"#) + .expect(1) + .create_async() + .await; + let result = MiniMaxProvider::new() + .fetch_remains_once( + "session=fixture", + &format!("{}/remains", server.url()), + MiniMaxRegion::Global, + Utc::now(), + ) + .await; + mock.assert_async().await; + assert!(result.is_err()); +} + +/// `None` expects AuthRequired; otherwise `":"`. +pub(super) fn assert_status_error( + result: Result<(), ProviderError>, + expected: Option<&str>, + status: usize, +) { + let actual = match result { + Err(ProviderError::AuthRequired) => None, + Err(ProviderError::Parse(message)) => Some(format!("Parse:{message}")), + Err(ProviderError::Other(message)) => Some(format!("Other:{message}")), + other => panic!("status {status}: unexpected {other:?}"), + }; + assert_eq!(actual.as_deref(), expected, "status {status}"); +} diff --git a/rust/src/providers/minimax/mod.rs b/rust/src/providers/minimax/mod.rs index 7704468f2b6..9e4a2bf5494 100755 --- a/rust/src/providers/minimax/mod.rs +++ b/rust/src/providers/minimax/mod.rs @@ -22,6 +22,67 @@ use crate::providers::json; const CODING_PLAN_PATH: &str = "/user-center/payment/coding-plan"; const CODING_PLAN_QUERY: &str = "cycle_type=3"; +const JSON_ACCEPT: &str = "application/json, text/plain, */*"; + +fn http_client() -> Result { + crate::core::credentialed_http_client_builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .map_err(|e| ProviderError::Other(e.to_string())) +} + +fn is_auth_status(status: reqwest::StatusCode) -> bool { + status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN +} + +/// 401/403 read as AuthRequired and other failures as +/// "MiniMax {what} returned status {status}". With `not_found_is_parse`, +/// 404/405 read as Parse so the caller tries its next URL. +fn check_status( + status: reqwest::StatusCode, + what: &str, + not_found_is_parse: bool, +) -> Result<(), ProviderError> { + if is_auth_status(status) { + return Err(ProviderError::AuthRequired); + } + if status.is_success() { + return Ok(()); + } + let message = format!("MiniMax {what} returned status {status}"); + if not_found_is_parse + && (status == reqwest::StatusCode::NOT_FOUND + || status == reqwest::StatusCode::METHOD_NOT_ALLOWED) + { + return Err(ProviderError::Parse(message)); + } + Err(ProviderError::Other(message)) +} + +/// Browser-shaped console GET: cookie, `accept`, the optional XHR marker, +/// then the Chrome UA, language, origin and coding-plan referer. +fn console_get( + client: &reqwest::Client, + url: &str, + cookie_header: &str, + region: MiniMaxRegion, + accept: &str, + xhr: bool, +) -> reqwest::RequestBuilder { + let base = region.base_url(); + let mut request = client + .get(url) + .header("Cookie", cookie_header) + .header("Accept", accept); + if xhr { + request = request.header("X-Requested-With", "XMLHttpRequest"); + } + request + .header("User-Agent", MiniMaxProvider::WEB_USER_AGENT) + .header("Accept-Language", "en-US,en;q=0.9") + .header("Origin", base) + .header("Referer", format!("{base}/user-center/payment/coding-plan")) +} #[derive(Debug, Deserialize)] struct MiniMaxBillingHistoryPayload { @@ -291,10 +352,7 @@ impl MiniMaxProvider { api_key: &str, region: MiniMaxRegion, ) -> Result { - let client = crate::core::credentialed_http_client_builder() - .timeout(std::time::Duration::from_secs(30)) - .build() - .map_err(|e| ProviderError::Other(e.to_string()))?; + let client = http_client()?; let base_url = region.api_base_url(); let resp = client @@ -306,19 +364,7 @@ impl MiniMaxProvider { .header("MM-API-Source", "CodexBar") .send() .await?; - - if resp.status() == reqwest::StatusCode::UNAUTHORIZED - || resp.status() == reqwest::StatusCode::FORBIDDEN - { - return Err(ProviderError::AuthRequired); - } - - if !resp.status().is_success() { - return Err(ProviderError::Other(format!( - "MiniMax API returned status {}", - resp.status() - ))); - } + check_status(resp.status(), "API", false)?; let json: serde_json::Value = resp .json() @@ -390,31 +436,17 @@ impl MiniMaxProvider { cookie_header: &str, region: MiniMaxRegion, ) -> Result { - let client = crate::core::credentialed_http_client_builder() - .timeout(std::time::Duration::from_secs(30)) - .build() - .map_err(|e| ProviderError::Other(e.to_string()))?; + let client = http_client()?; let response = client .get(region.billing_history_url()) .query(&[("page", "1"), ("limit", "100"), ("aggregate", "false")]) .header("Cookie", cookie_header) - .header("Accept", "application/json, text/plain, */*") + .header("Accept", JSON_ACCEPT) .header("X-Requested-With", "XMLHttpRequest") .send() .await?; - - if response.status() == reqwest::StatusCode::UNAUTHORIZED - || response.status() == reqwest::StatusCode::FORBIDDEN - { - return Err(ProviderError::AuthRequired); - } - if !response.status().is_success() { - return Err(ProviderError::Other(format!( - "MiniMax billing returned status {}", - response.status() - ))); - } + check_status(response.status(), "billing", false)?; let json: serde_json::Value = response .json() @@ -434,39 +466,19 @@ impl MiniMaxProvider { cookie_header: &str, region: MiniMaxRegion, ) -> Result { - let client = crate::core::credentialed_http_client_builder() - .timeout(std::time::Duration::from_secs(30)) - .build() - .map_err(|e| ProviderError::Other(e.to_string()))?; - - let coding_url = region.coding_plan_url(); - let base = region.base_url(); - let response = client - .get(&coding_url) - .header("Cookie", cookie_header) - .header( - "Accept", - "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", - ) - .header("User-Agent", Self::WEB_USER_AGENT) - .header("Accept-Language", "en-US,en;q=0.9") - .header("Origin", base) - .header( - "Referer", - &format!("{base}/user-center/payment/coding-plan"), - ) - .send() - .await?; - - let status = response.status(); - if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { - return Err(ProviderError::AuthRequired); - } - if !status.is_success() { - return Err(ProviderError::Other(format!( - "MiniMax coding plan returned status {status}" - ))); - } + let client = http_client()?; + + let response = console_get( + &client, + ®ion.coding_plan_url(), + cookie_header, + region, + "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + false, + ) + .send() + .await?; + check_status(response.status(), "coding plan", false)?; let content_type = response .headers() @@ -555,41 +567,12 @@ impl MiniMaxProvider { region: MiniMaxRegion, now: DateTime, ) -> Result { - let client = crate::core::credentialed_http_client_builder() - .timeout(std::time::Duration::from_secs(30)) - .build() - .map_err(|e| ProviderError::Other(e.to_string()))?; + let client = http_client()?; - let base = region.base_url(); - let response = client - .get(url) - .header("Cookie", cookie_header) - .header("Accept", "application/json, text/plain, */*") - .header("X-Requested-With", "XMLHttpRequest") - .header("User-Agent", Self::WEB_USER_AGENT) - .header("Accept-Language", "en-US,en;q=0.9") - .header("Origin", base) - .header( - "Referer", - &format!("{base}/user-center/payment/coding-plan"), - ) + let response = console_get(&client, url, cookie_header, region, JSON_ACCEPT, true) .send() .await?; - - let status = response.status(); - if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { - return Err(ProviderError::AuthRequired); - } - if !status.is_success() { - let msg = format!("MiniMax remains returned status {status}"); - // 404/405 → try next URL; other → stop - if status == reqwest::StatusCode::NOT_FOUND - || status == reqwest::StatusCode::METHOD_NOT_ALLOWED - { - return Err(ProviderError::Parse(msg)); - } - return Err(ProviderError::Other(msg)); - } + check_status(response.status(), "remains", true)?; let content_type = response .headers() @@ -1031,6 +1014,9 @@ impl Provider for MiniMaxProvider { } } +#[cfg(test)] +mod http_tests; + #[cfg(test)] mod tests { use super::*; diff --git a/rust/src/providers/minimax/remains_api.rs b/rust/src/providers/minimax/remains_api.rs index f1608fe9404..8ae1944d031 100644 --- a/rust/src/providers/minimax/remains_api.rs +++ b/rust/src/providers/minimax/remains_api.rs @@ -7,7 +7,7 @@ use chrono::Utc; use crate::core::{FetchContext, ProviderError, ProviderFetchResult}; -use super::{MiniMaxRegion, coding_plan, coding_plan_html}; +use super::{JSON_ACCEPT, MiniMaxRegion, check_status, coding_plan, coding_plan_html, http_client}; fn resolve_plain_api_key(explicit: Option<&str>, environment: Option<&str>) -> Option { explicit @@ -46,31 +46,13 @@ async fn fetch_remains_once_via_api_key( api_key: &str, url: &str, ) -> Result { - let client = crate::core::credentialed_http_client_builder() - .timeout(std::time::Duration::from_secs(30)) - .build() - .map_err(|e| ProviderError::Other(e.to_string()))?; - - let response = client + let response = http_client()? .get(url) .header("Authorization", format!("Bearer {api_key}")) - .header("Accept", "application/json, text/plain, */*") + .header("Accept", JSON_ACCEPT) .send() .await?; - - let status = response.status(); - if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { - return Err(ProviderError::AuthRequired); - } - if !status.is_success() { - let message = format!("MiniMax remains (api key) returned status {status}"); - if status == reqwest::StatusCode::NOT_FOUND - || status == reqwest::StatusCode::METHOD_NOT_ALLOWED - { - return Err(ProviderError::Parse(message)); - } - return Err(ProviderError::Other(message)); - } + check_status(response.status(), "remains (api key)", true)?; let json: serde_json::Value = response .json() @@ -81,7 +63,40 @@ async fn fetch_remains_once_via_api_key( #[cfg(test)] mod tests { - use super::resolve_plain_api_key; + use super::super::http_tests::{JSON_ACCEPT, assert_status_error}; + use super::{fetch_remains_once_via_api_key, resolve_plain_api_key}; + + #[tokio::test] + async fn api_key_remains_request_maps_statuses() { + let cases: [(usize, Option<&str>); 4] = [ + (401, None), + (403, None), + ( + 404, + Some("Parse:MiniMax remains (api key) returned status 404 Not Found"), + ), + ( + 503, + Some("Other:MiniMax remains (api key) returned status 503 Service Unavailable"), + ), + ]; + for (status, expected) in cases { + let mut server = mockito::Server::new_async().await; + let mock = server + .mock("GET", "/remains") + .match_header("authorization", "Bearer fixture-key") + .match_header("accept", JSON_ACCEPT) + .with_status(status) + .expect(1) + .create_async() + .await; + let result = + fetch_remains_once_via_api_key("fixture-key", &format!("{}/remains", server.url())) + .await; + mock.assert_async().await; + assert_status_error(result.map(|_| ()), expected, status); + } + } #[test] fn plain_api_key_prefers_explicit_then_environment_without_mutating_process_env() { diff --git a/rust/src/providers/minimax/token_plan.rs b/rust/src/providers/minimax/token_plan.rs index c7d3ba8ea03..113bc3cd7c2 100644 --- a/rust/src/providers/minimax/token_plan.rs +++ b/rust/src/providers/minimax/token_plan.rs @@ -10,8 +10,9 @@ use chrono::{DateTime, Utc}; use crate::core::{ProviderError, ProviderFetchResult, RateWindow, UsageSnapshot}; use super::{ - MiniMaxProvider, MiniMaxRegion, attach_billing_summary, coding_plan, coding_plan_html, - format_count, scalar_string, value_i64, + JSON_ACCEPT, MiniMaxProvider, MiniMaxRegion, attach_billing_summary, coding_plan, + coding_plan_html, console_get, format_count, http_client, is_auth_status, scalar_string, + value_i64, }; /// Token Plan console endpoints (www host; issue #254): the charge-API @@ -82,33 +83,23 @@ pub(crate) async fn fetch_token_plan_with_cookie( Ok(result) } -/// Shared GET envelope for the token-plan console endpoints — copied from -/// fetch_remains_once. Network errors propagate; callers map statuses. +/// Shared GET envelope for the token-plan console endpoints (the remains +/// request shape). Network errors propagate; callers map statuses. async fn token_plan_get( cookie_header: &str, url: &str, region: MiniMaxRegion, ) -> Result { - let client = crate::core::credentialed_http_client_builder() - .timeout(std::time::Duration::from_secs(30)) - .build() - .map_err(|e| ProviderError::Other(e.to_string()))?; - - let base = region.base_url(); - Ok(client - .get(url) - .header("Cookie", cookie_header) - .header("Accept", "application/json, text/plain, */*") - .header("X-Requested-With", "XMLHttpRequest") - .header("User-Agent", MiniMaxProvider::WEB_USER_AGENT) - .header("Accept-Language", "en-US,en;q=0.9") - .header("Origin", base) - .header( - "Referer", - &format!("{base}/user-center/payment/coding-plan"), - ) - .send() - .await?) + Ok(console_get( + &http_client()?, + url, + cookie_header, + region, + JSON_ACCEPT, + true, + ) + .send() + .await?) } /// (a) `charge/token_plan/usage`. 401/403 → AuthRequired; any other @@ -123,7 +114,7 @@ async fn fetch_token_plan_usage_once( let response = token_plan_get(cookie_header, &url, region).await?; let status = response.status(); - if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { + if is_auth_status(status) { return Err(ProviderError::AuthRequired); } if !status.is_success() { @@ -289,9 +280,41 @@ fn assemble_token_plan_result( #[cfg(test)] mod tests { + use super::super::http_tests::console_mock; use super::*; use chrono::TimeZone; + #[tokio::test] + async fn token_plan_backend_request_sends_console_headers() { + let mut server = mockito::Server::new_async().await; + let ok = console_mock(&mut server, "/summary") + .with_body(r#"{"ok":true}"#) + .expect(1) + .create_async() + .await; + let failed = console_mock(&mut server, "/credit") + .with_status(500) + .expect(1) + .create_async() + .await; + let summary = fetch_token_plan_backend_json( + "session=fixture", + &format!("{}/summary", server.url()), + MiniMaxRegion::Global, + ) + .await; + let credit = fetch_token_plan_backend_json( + "session=fixture", + &format!("{}/credit", server.url()), + MiniMaxRegion::Global, + ) + .await; + ok.assert_async().await; + failed.assert_async().await; + assert_eq!(summary, Some(serde_json::json!({ "ok": true }))); + assert_eq!(credit, None); + } + fn fixed_now() -> DateTime { // 2026-08-03T12:00:00Z = 1,785,758,400 unix seconds. Utc.timestamp_opt(1_785_758_400, 0).unwrap() From df5e3ba630b519c4e9a374332543a5fe3be9c1d5 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:24:37 +0700 Subject: [PATCH 23/40] Share MiniMax row windows and the billing window table (PBG-37) --- .../src/providers/minimax/coding_plan_html.rs | 284 ++++++++++++++---- rust/src/providers/minimax/mod.rs | 177 +++++++---- 2 files changed, 335 insertions(+), 126 deletions(-) diff --git a/rust/src/providers/minimax/coding_plan_html.rs b/rust/src/providers/minimax/coding_plan_html.rs index a2fafdb909e..211ae787ce6 100644 --- a/rust/src/providers/minimax/coding_plan_html.rs +++ b/rust/src/providers/minimax/coding_plan_html.rs @@ -9,31 +9,45 @@ use serde_json::Value; use crate::core::{ProviderError, RateWindow, UsageSnapshot}; -use super::coding_plan::{MiniMaxCodingPlanSnapshot, parse_coding_plan_value}; -#[cfg(test)] -use super::coding_plan::{RemainsRow, ServiceRow}; +use super::coding_plan::{ + MiniMaxCodingPlanSnapshot, RemainsRow, ServiceRow, parse_coding_plan_value, +}; + +impl ServiceRow { + fn rate_window(&self) -> RateWindow { + RateWindow::with_details( + self.percent, + None, + self.resets_at, + self.reset_description.clone(), + ) + } +} + +impl RemainsRow { + fn rate_window(&self) -> RateWindow { + RateWindow::with_details( + self.percent, + self.window_minutes, + self.resets_at, + self.reset_description.clone(), + ) + } +} /// Recursively walk the JSON object tree for the first object having /// `model_remains` or `modelRemains` (upstream `findCodingPlanPayload`). fn find_coding_plan_payload(obj: &Value) -> Option<&Value> { - if let Value::Object(map) = obj { - if map.contains_key("model_remains") || map.contains_key("modelRemains") { - return Some(obj); - } - for value in map.values() { - if let Some(matched) = find_coding_plan_payload(value) { - return Some(matched); - } - } - } - if let Value::Array(arr) = obj { - for value in arr { - if let Some(matched) = find_coding_plan_payload(value) { - return Some(matched); - } + match obj { + Value::Object(map) + if map.contains_key("model_remains") || map.contains_key("modelRemains") => + { + Some(obj) } + Value::Object(map) => map.values().find_map(find_coding_plan_payload), + Value::Array(arr) => arr.iter().find_map(find_coding_plan_payload), + _ => None, } - None } /// Extract `__NEXT_DATA__` JSON from HTML (upstream `nextDataJSONData`). @@ -361,37 +375,19 @@ pub(super) fn to_usage_snapshot( if rows.is_empty() { return Err(ProviderError::Parse("Missing coding plan data.".into())); } - let first = &rows[0]; - let primary = RateWindow::with_details( - first.percent, - None, - first.resets_at, - first.reset_description.clone(), - ); - let mut usage = UsageSnapshot::new(primary); + let mut usage = UsageSnapshot::new(rows[0].rate_window()); // Secondary = second row if present - if rows.len() >= 2 { - let second = &rows[1]; - usage = usage.with_secondary(RateWindow::with_details( - second.percent, - None, - second.resets_at, - second.reset_description.clone(), - )); + if let Some(second) = rows.get(1) { + usage = usage.with_secondary(second.rate_window()); } // Extra windows for remaining rows - for row in rows.iter().skip(if rows.len() >= 2 { 2 } else { 1 }) { + for row in rows.iter().skip(2) { usage = usage.with_extra_rate_window( &row.service_type, format!("{} · {}", row.service_type, row.window_type), - RateWindow::with_details( - row.percent, - None, - row.resets_at, - row.reset_description.clone(), - ), + row.rate_window(), ); } @@ -422,22 +418,11 @@ pub(super) fn to_usage_snapshot( .or_else(|| rows.first()) .ok_or_else(|| ProviderError::Parse("Missing coding plan data.".into()))?; - let primary = RateWindow::with_details( - primary_row.percent, - primary_row.window_minutes, - primary_row.resets_at, - primary_row.reset_description.clone(), - ); - let mut usage = UsageSnapshot::new(primary); + let mut usage = UsageSnapshot::new(primary_row.rate_window()); // Secondary = first qualifying weekly row if let Some(weekly) = rows.iter().find(|r| r.is_weekly) { - usage = usage.with_secondary(RateWindow::with_details( - weekly.percent, - weekly.window_minutes, - weekly.resets_at, - weekly.reset_description.clone(), - )); + usage = usage.with_secondary(weekly.rate_window()); } // Every row as an extra window @@ -448,16 +433,7 @@ pub(super) fn to_usage_snapshot( row.service_type.clone() }; let title = format!("{} · {}", row.service_type, row.window_type); - usage = usage.with_extra_rate_window( - id, - title, - RateWindow::with_details( - row.percent, - row.window_minutes, - row.resets_at, - row.reset_description.clone(), - ), - ); + usage = usage.with_extra_rate_window(id, title, row.rate_window()); } if let Some(pn) = plan_name { @@ -645,6 +621,184 @@ mod tests { assert_eq!(usage.login_method.as_deref(), Some("Text Generation Pro")); } + type WindowRow = ( + String, + String, + f64, + Option, + Option>, + Option, + ); + + fn window_row(id: &str, title: &str, window: &RateWindow) -> WindowRow { + ( + id.to_string(), + title.to_string(), + window.used_percent, + window.window_minutes, + window.resets_at, + window.reset_description.clone(), + ) + } + + fn extra_rows(usage: &UsageSnapshot) -> Vec { + usage + .extra_rate_windows + .iter() + .map(|named| window_row(&named.id, &named.title, &named.window)) + .collect() + } + + fn service_row(service_type: &str, window_type: &str, percent: f64) -> ServiceRow { + ServiceRow { + service_type: service_type.to_string(), + window_type: window_type.to_string(), + time_range: String::new(), + percent, + resets_at: Some(now() + Duration::hours(1)), + reset_description: Some(format!("{service_type} reset")), + } + } + + #[test] + fn to_usage_snapshot_services_maps_every_row_window() { + let single = MiniMaxCodingPlanSnapshot::Services(vec![service_row("Text", "Today", 5.0)]); + let usage = to_usage_snapshot(&single, now()).unwrap(); + assert!(usage.secondary.is_none()); + assert!(usage.extra_rate_windows.is_empty()); + assert_eq!(usage.login_method, None); + + let rows = vec![ + service_row("Text", "Today", 10.0), + service_row("Speech Max", "Week", 20.0), + service_row("Video", "Month", 30.0), + ]; + let snapshot = MiniMaxCodingPlanSnapshot::Services(rows); + let usage = to_usage_snapshot(&snapshot, now()).unwrap(); + let reset = Some(now() + Duration::hours(1)); + assert_eq!( + window_row("", "", &usage.primary), + window_row( + "", + "", + &RateWindow::with_details(10.0, None, reset, Some("Text reset".into())) + ) + ); + assert_eq!( + window_row("", "", usage.secondary.as_ref().unwrap()), + window_row( + "", + "", + &RateWindow::with_details(20.0, None, reset, Some("Speech Max reset".into())) + ) + ); + assert_eq!( + extra_rows(&usage), + vec![( + "Video".to_string(), + "Video · Month".to_string(), + 30.0, + None, + reset, + Some("Video reset".to_string()), + )] + ); + assert_eq!(usage.login_method.as_deref(), Some("Speech Max")); + } + + #[test] + fn to_usage_snapshot_remains_maps_every_row_window() { + let row = |window_type: &str, percent: f64, minutes: u32, unlimited: bool, weekly: bool| { + RemainsRow { + service_type: "general".to_string(), + model_name: "General".to_string(), + window_type: window_type.to_string(), + percent, + window_minutes: Some(minutes), + resets_at: Some(now() + Duration::minutes(i64::from(minutes))), + reset_description: Some(format!("{window_type} reset")), + is_unlimited: unlimited, + is_weekly: weekly, + } + }; + let snapshot = MiniMaxCodingPlanSnapshot::Remains { + plan_name: None, + rows: vec![ + row("Unlimited", 0.0, 60, true, false), + row("Today", 40.0, 1440, false, false), + row("Weekly", 70.0, 10080, false, true), + ], + }; + let usage = to_usage_snapshot(&snapshot, now()).unwrap(); + let at = |minutes: i64| Some(now() + Duration::minutes(minutes)); + assert_eq!( + window_row("", "", &usage.primary), + ( + "".into(), + "".into(), + 40.0, + Some(1440), + at(1440), + Some("Today reset".into()) + ) + ); + assert_eq!( + window_row("", "", usage.secondary.as_ref().unwrap()), + ( + "".into(), + "".into(), + 70.0, + Some(10080), + at(10080), + Some("Weekly reset".into()) + ) + ); + assert_eq!( + extra_rows(&usage), + vec![ + ( + "general".to_string(), + "general · Unlimited".to_string(), + 0.0, + Some(60), + at(60), + Some("Unlimited reset".to_string()), + ), + ( + "general".to_string(), + "general · Today".to_string(), + 40.0, + Some(1440), + at(1440), + Some("Today reset".to_string()), + ), + ( + "general:weekly".to_string(), + "general · Weekly".to_string(), + 70.0, + Some(10080), + at(10080), + Some("Weekly reset".to_string()), + ), + ] + ); + assert_eq!(usage.login_method, None); + } + + #[test] + fn coding_plan_payload_search_finds_the_first_nested_object() { + let json = serde_json::json!({ + "a": [1, {"b": {"modelRemains": [], "tag": "camel"}}], + "z": {"model_remains": [], "tag": "snake"} + }); + let found = find_coding_plan_payload(&json).unwrap(); + assert_eq!(found["tag"], "camel"); + let direct = serde_json::json!([{"model_remains": [], "tag": "array"}]); + assert_eq!(find_coding_plan_payload(&direct).unwrap()["tag"], "array"); + assert!(find_coding_plan_payload(&serde_json::json!({"a": [1, "x"]})).is_none()); + assert!(find_coding_plan_payload(&serde_json::json!("model_remains")).is_none()); + } + #[test] fn oversized_html_duration_is_omitted() { assert_eq!(minutes_from_duration(f64::MAX, "hours"), None); diff --git a/rust/src/providers/minimax/mod.rs b/rust/src/providers/minimax/mod.rs index 9e4a2bf5494..352945d03f8 100755 --- a/rust/src/providers/minimax/mod.rs +++ b/rust/src/providers/minimax/mod.rs @@ -774,78 +774,54 @@ fn billing_record_succeeded(record: &MiniMaxBillingRecord) -> bool { } } -fn result_from_billing_summary( - summary: MiniMaxBillingSummary, - source_label: &str, -) -> ProviderFetchResult { - let usage = UsageSnapshot::new(RateWindow::with_details( - 0.0, - None, - None, - Some(format!( - "{} tokens today", - format_count(summary.today_tokens) - )), - )) - .with_secondary(RateWindow::with_details( - 0.0, - None, - None, - Some(format!( - "{} tokens over last 30 days", - format_count(summary.last_30_days_tokens) - )), - )) - .with_login_method("MiniMax billing"); - attach_billing_summary(ProviderFetchResult::new(usage, source_label), summary) -} - fn attach_billing_summary( mut result: ProviderFetchResult, summary: MiniMaxBillingSummary, ) -> ProviderFetchResult { - result.usage = result.usage.with_extra_rate_window( - "billing-tokens-today", - "Tokens today", - RateWindow::with_details(0.0, None, None, Some(format_count(summary.today_tokens))), - ); - result.usage = result.usage.with_extra_rate_window( - "billing-tokens-30d", - "Tokens (30 days)", - RateWindow::with_details( - 0.0, - None, - None, - Some(format_count(summary.last_30_days_tokens)), + let mut rows = vec![ + ( + "billing-tokens-today".to_string(), + "Tokens today".to_string(), + format_count(summary.today_tokens), + ), + ( + "billing-tokens-30d".to_string(), + "Tokens (30 days)".to_string(), + format_count(summary.last_30_days_tokens), ), - ); + ]; if let Some(cash) = summary.today_cash { - result.usage = result.usage.with_extra_rate_window( - "billing-cash-today", - "Spend today", - RateWindow::with_details(0.0, None, None, Some(format!("${cash:.2}"))), - ); + rows.push(( + "billing-cash-today".to_string(), + "Spend today".to_string(), + format!("${cash:.2}"), + )); } if let Some(cash) = summary.last_30_days_cash { - result.usage = result.usage.with_extra_rate_window( - "billing-cash-30d", - "Spend (30 days)", - RateWindow::with_details(0.0, None, None, Some(format!("${cash:.2}"))), - ); + rows.push(( + "billing-cash-30d".to_string(), + "Spend (30 days)".to_string(), + format!("${cash:.2}"), + )); result.cost = Some(CostSnapshot::new(cash, "USD", "Last 30 days")); } - for (idx, item) in summary.top_methods.iter().enumerate() { - result.usage = result.usage.with_extra_rate_window( - format!("billing-method-{idx}"), - format!("Method: {}", item.name), - RateWindow::with_details(0.0, None, None, Some(breakdown_description(item))), - ); + for (kind, label, items) in [ + ("method", "Method", &summary.top_methods), + ("model", "Model", &summary.top_models), + ] { + for (idx, item) in items.iter().enumerate() { + rows.push(( + format!("billing-{kind}-{idx}"), + format!("{label}: {}", item.name), + breakdown_description(item), + )); + } } - for (idx, item) in summary.top_models.iter().enumerate() { + for (id, title, detail) in rows { result.usage = result.usage.with_extra_rate_window( - format!("billing-model-{idx}"), - format!("Model: {}", item.name), - RateWindow::with_details(0.0, None, None, Some(breakdown_description(item))), + id, + title, + RateWindow::with_details(0.0, None, None, Some(detail)), ); } result @@ -1131,7 +1107,10 @@ mod tests { }] }); let summary = parse_billing_summary(&json).unwrap(); - let result = result_from_billing_summary(summary, "web-billing"); + let result = attach_billing_summary( + ProviderFetchResult::new(UsageSnapshot::new(RateWindow::new(0.0)), "web-billing"), + summary, + ); assert!(result.cost.is_some()); assert!( result @@ -1142,6 +1121,82 @@ mod tests { ); } + #[test] + fn billing_summary_windows_follow_a_fixed_order() { + let breakdown = |name: &str, tokens: i64, cash: Option| MiniMaxBillingBreakdown { + name: name.to_string(), + tokens, + cash, + }; + let summary = MiniMaxBillingSummary { + today_tokens: 1234, + last_30_days_tokens: 56789, + today_cash: Some(0.5), + last_30_days_cash: Some(12.345), + top_methods: vec![ + breakdown("chat", 5000, Some(1.0)), + breakdown("audio", 10, None), + ], + top_models: vec![breakdown("abab6.5", 7000, None)], + }; + let base = || ProviderFetchResult::new(UsageSnapshot::new(RateWindow::new(1.0)), "web"); + let windows = |result: &ProviderFetchResult| -> Vec<(String, String, Option)> { + result + .usage + .extra_rate_windows + .iter() + .map(|named| { + assert_eq!(named.window.used_percent, 0.0); + assert_eq!(named.window.window_minutes, None); + assert_eq!(named.window.resets_at, None); + ( + named.id.clone(), + named.title.clone(), + named.window.reset_description.clone(), + ) + }) + .collect() + }; + let row = |id: &str, title: &str, detail: &str| { + (id.to_string(), title.to_string(), Some(detail.to_string())) + }; + + let result = attach_billing_summary(base(), summary.clone()); + assert_eq!( + windows(&result), + vec![ + row("billing-tokens-today", "Tokens today", "1,234"), + row("billing-tokens-30d", "Tokens (30 days)", "56,789"), + row("billing-cash-today", "Spend today", "$0.50"), + row("billing-cash-30d", "Spend (30 days)", "$12.35"), + row("billing-method-0", "Method: chat", "5,000 tokens / $1.00"), + row("billing-method-1", "Method: audio", "10 tokens"), + row("billing-model-0", "Model: abab6.5", "7,000 tokens"), + ] + ); + let cost = result.cost.expect("30-day spend sets cost"); + assert_eq!(cost.used, 12.345); + assert_eq!(cost.currency_code, "USD"); + assert_eq!(result.usage.primary.used_percent, 1.0); + + let no_cash = MiniMaxBillingSummary { + today_cash: None, + last_30_days_cash: None, + top_methods: Vec::new(), + top_models: Vec::new(), + ..summary + }; + let result = attach_billing_summary(base(), no_cash); + assert_eq!( + windows(&result), + vec![ + row("billing-tokens-today", "Tokens today", "1,234"), + row("billing-tokens-30d", "Tokens (30 days)", "56,789"), + ] + ); + assert!(result.cost.is_none()); + } + #[test] fn parses_plan_title_from_coding_plan_fields() { let provider = MiniMaxProvider::new(); From a4f7826d96e7f53abde398ffa40166e3e0c4aacb Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:31:49 +0700 Subject: [PATCH 24/40] Table-drive MiniMax coding-plan lanes, reset text and clock rollover (PBG-13) --- rust/src/providers/minimax/coding_plan.rs | 414 +++++++++++++----- .../src/providers/minimax/coding_plan_html.rs | 28 +- 2 files changed, 323 insertions(+), 119 deletions(-) diff --git a/rust/src/providers/minimax/coding_plan.rs b/rust/src/providers/minimax/coding_plan.rs index 25694854c75..f0e143a9419 100644 --- a/rust/src/providers/minimax/coding_plan.rs +++ b/rust/src/providers/minimax/coding_plan.rs @@ -65,12 +65,98 @@ pub(super) struct RemainsRow { // for non-weekly rows, IANA timezone names, host/env URL overrides // (`MiniMaxSettingsReader`). +/// MiniMax console times are China Standard Time. +const CN_TZ: FixedOffset = FixedOffset::east_opt(8 * 3600).unwrap(); + /// Look up a value trying snake_case then camelCase (upstream normalizes the /// camelCase aliases onto the snake_case keys). fn get_field<'a>(obj: &'a Value, snake: &str, camel: &str) -> Option<&'a Value> { obj.get(snake).or_else(|| obj.get(camel)) } +/// Snake/camel key pairs for one quota lane of a `model_remains[]` entry. +struct LaneKeys { + total: [&'static str; 2], + usage: [&'static str; 2], + remaining_percent: [&'static str; 2], + status: [&'static str; 2], + start: [&'static str; 2], + end: [&'static str; 2], + remains: [&'static str; 2], +} + +const INTERVAL_LANE: LaneKeys = LaneKeys { + total: ["current_interval_total_count", "currentIntervalTotalCount"], + usage: ["current_interval_usage_count", "currentIntervalUsageCount"], + remaining_percent: [ + "current_interval_remaining_percent", + "currentIntervalRemainingPercent", + ], + status: ["current_interval_status", "currentIntervalStatus"], + start: ["start_time", "startTime"], + end: ["end_time", "endTime"], + remains: ["remains_time", "remainsTime"], +}; + +const WEEKLY_LANE: LaneKeys = LaneKeys { + total: ["current_weekly_total_count", "currentWeeklyTotalCount"], + usage: ["current_weekly_usage_count", "currentWeeklyUsageCount"], + remaining_percent: [ + "current_weekly_remaining_percent", + "currentWeeklyRemainingPercent", + ], + status: ["current_weekly_status", "currentWeeklyStatus"], + start: ["weekly_start_time", "weeklyStartTime"], + end: ["weekly_end_time", "weeklyEndTime"], + remains: ["weekly_remains_time", "weeklyRemainsTime"], +}; + +/// Raw lane fields. MiniMax's `usage_count` is the remaining count. +#[derive(Clone, Copy)] +struct LaneFields { + total: Option, + remaining: Option, + remaining_percent: Option, + status: Option, + start: Option, + end: Option, + remains: Option, +} + +impl LaneKeys { + fn read(&self, entry: &Value) -> LaneFields { + let field = |[snake, camel]: [&str; 2]| get_field(entry, snake, camel); + LaneFields { + total: value_i64(field(self.total)), + remaining: value_i64(field(self.usage)), + remaining_percent: json::lenient_f64(field(self.remaining_percent)), + status: value_i64(field(self.status)), + start: value_i64(field(self.start)), + end: value_i64(field(self.end)), + remains: value_i64(field(self.remains)), + } + } +} + +/// The next `time` today in `tz`, or a day from now once it has passed. +pub(super) fn next_time_today( + time: chrono::NaiveTime, + tz: FixedOffset, + now: DateTime, +) -> Option> { + let dt = now + .date_naive() + .and_time(time) + .and_local_timezone(tz) + .single()?; + let candidate = dt.with_timezone(&Utc); + Some(if candidate < now { + now + Duration::days(1) + } else { + candidate + }) +} + /// Decode an epoch integer that may be in milliseconds, seconds, or unparseable. fn date_from_epoch(value: Option) -> Option> { let raw = value?; @@ -247,7 +333,7 @@ fn time_range_string(start: Option>, end: Option>) - let (Some(start), Some(end)) = (start, end) else { return "N/A".to_string(); }; - let tz = FixedOffset::east_opt(8 * 3600).unwrap(); + let tz = CN_TZ; format!( "{}-{}(UTC+8)", start.with_timezone(&tz).format("%H:%M"), @@ -262,7 +348,7 @@ fn weekly_time_range_string( ) -> Option { let start = start?; let end = end?; - let tz = FixedOffset::east_opt(8 * 3600).unwrap(); + let tz = CN_TZ; Some(format!( "{} - {}(UTC+8)", start.with_timezone(&tz).format("%m/%d %H:%M"), @@ -282,25 +368,15 @@ fn reset_description( { let interval = (resets - now).num_seconds(); if interval < 60 { - return format!("Resets in {} seconds", interval); - } else if interval < 3600 { - let minutes = interval / 60; - return format!( - "Resets in {} minute{}", - minutes, - if minutes == 1 { "" } else { "s" } - ); - } else if interval < 86400 { - let hours = interval / 3600; - return format!( - "Resets in {} hour{}", - hours, - if hours == 1 { "" } else { "s" } - ); - } else { - let days = interval / 86400; - return format!("Resets in {} day{}", days, if days == 1 { "" } else { "s" }); + return format!("Resets in {interval} seconds"); } + let (unit_seconds, unit) = [(86_400, "day"), (3_600, "hour")] + .into_iter() + .find(|(unit_seconds, _)| interval >= *unit_seconds) + .unwrap_or((60, "minute")); + let count = interval / unit_seconds; + let plural = if count == 1 { "" } else { "s" }; + return format!("Resets in {count} {unit}{plural}"); } format!("{window_type}: {time_range}") } @@ -308,23 +384,22 @@ fn reset_description( /// Build a `RemainsRow` from the raw interval/weekly fields (upstream /// `makeServiceUsage`). Returns `None` when the row is a placeholder that /// should be skipped. -#[allow( - clippy::too_many_arguments, - reason = "signature mirrors the flat upstream makeServiceUsage payload fields one-to-one" -)] fn make_remains_row( service_type: &str, window_type_override: Option<&str>, - total: Option, - remaining: Option, - remaining_percent: Option, - status: Option, - start: Option, - end: Option, - remains_time: Option, + lane: &LaneFields, now: DateTime, is_weekly: bool, ) -> Option { + let LaneFields { + total, + remaining, + remaining_percent, + status, + start, + end, + remains: remains_time, + } = *lane; if is_unavailable_quota_placeholder( service_type, window_type_override, @@ -434,7 +509,7 @@ fn parse_resets_at_from_time_range( now: DateTime, ) -> Option> { let lower = window_type.trim().to_lowercase(); - let tz = FixedOffset::east_opt(8 * 3600).unwrap(); + let tz = CN_TZ; if lower == "today" { let parts: Vec<&str> = time_range.splitn(2, '-').collect(); @@ -459,13 +534,7 @@ fn parse_resets_at_from_time_range( re.replace_all(end_part, "").trim().to_string() }; let time = chrono::NaiveTime::parse_from_str(&end_clean, "%H:%M").ok()?; - let today = now.date_naive(); - let dt = today.and_time(time).and_local_timezone(tz).single()?; - let mut candidate = dt.with_timezone(&Utc); - if candidate < now { - candidate = now + Duration::days(1); - } - return Some(candidate); + return next_time_today(time, tz, now); } None @@ -506,30 +575,15 @@ fn parse_plan_name_from_data(data: &Value) -> Option { let name = scalar_string(get_field(e, "model_name", "modelName")) .map(|n| n.trim().to_lowercase()) .unwrap_or_default(); + let lane = INTERVAL_LANE.read(e); name == "video" && is_unavailable_quota_placeholder( "Text to Video", None, - value_i64(get_field( - e, - "current_interval_status", - "currentIntervalStatus", - )), - value_i64(get_field( - e, - "current_interval_total_count", - "currentIntervalTotalCount", - )), - value_i64(get_field( - e, - "current_interval_usage_count", - "currentIntervalUsageCount", - )), - json::lenient_f64(get_field( - e, - "current_interval_remaining_percent", - "currentIntervalRemainingPercent", - )), + lane.status, + lane.total, + lane.remaining, + lane.remaining_percent, ) }); if has_text_generation && has_unavailable_video { @@ -599,35 +653,9 @@ fn parse_remains( let service_type = map_model_name_to_service_type(&model_name); // Interval row - if let Some(mut row) = make_remains_row( - &service_type, - None, - value_i64(get_field( - entry, - "current_interval_total_count", - "currentIntervalTotalCount", - )), - value_i64(get_field( - entry, - "current_interval_usage_count", - "currentIntervalUsageCount", - )), - json::lenient_f64(get_field( - entry, - "current_interval_remaining_percent", - "currentIntervalRemainingPercent", - )), - value_i64(get_field( - entry, - "current_interval_status", - "currentIntervalStatus", - )), - value_i64(get_field(entry, "start_time", "startTime")), - value_i64(get_field(entry, "end_time", "endTime")), - value_i64(get_field(entry, "remains_time", "remainsTime")), - now, - false, - ) { + if let Some(mut row) = + make_remains_row(&service_type, None, &INTERVAL_LANE.read(entry), now, false) + { row.model_name = model_name.clone(); rows.push(row); } @@ -637,29 +665,7 @@ fn parse_remains( && let Some(mut row) = make_remains_row( &service_type, Some("Weekly"), - value_i64(get_field( - entry, - "current_weekly_total_count", - "currentWeeklyTotalCount", - )), - value_i64(get_field( - entry, - "current_weekly_usage_count", - "currentWeeklyUsageCount", - )), - json::lenient_f64(get_field( - entry, - "current_weekly_remaining_percent", - "currentWeeklyRemainingPercent", - )), - value_i64(get_field( - entry, - "current_weekly_status", - "currentWeeklyStatus", - )), - value_i64(get_field(entry, "weekly_start_time", "weeklyStartTime")), - value_i64(get_field(entry, "weekly_end_time", "weeklyEndTime")), - value_i64(get_field(entry, "weekly_remains_time", "weeklyRemainsTime")), + &WEEKLY_LANE.read(entry), now, true, ) @@ -733,6 +739,192 @@ mod tests { Utc.with_ymd_and_hms(2026, 8, 3, 12, 0, 0).unwrap() } + #[test] + fn reset_description_buckets_by_largest_whole_unit() { + let cases: [(Option, &str); 14] = [ + (None, "Today: 00:00-24:00"), + (Some(-10), "Today: 00:00-24:00"), + (Some(0), "Today: 00:00-24:00"), + (Some(1), "Resets in 1 seconds"), + (Some(59), "Resets in 59 seconds"), + (Some(60), "Resets in 1 minute"), + (Some(119), "Resets in 1 minute"), + (Some(120), "Resets in 2 minutes"), + (Some(3_599), "Resets in 59 minutes"), + (Some(3_600), "Resets in 1 hour"), + (Some(7_200), "Resets in 2 hours"), + (Some(86_399), "Resets in 23 hours"), + (Some(86_400), "Resets in 1 day"), + (Some(172_800), "Resets in 2 days"), + ]; + for (offset, expected) in cases { + let resets = offset.map(|seconds| now() + Duration::seconds(seconds)); + assert_eq!( + reset_description("Today", "00:00-24:00", now(), resets), + expected, + "offset {offset:?}" + ); + } + } + + #[test] + fn time_range_resets_read_utc_plus_8_end_times() { + let at = |y, mo, d, h, mi| Some(Utc.with_ymd_and_hms(y, mo, d, h, mi, 0).unwrap()); + let tomorrow = Some(now() + Duration::days(1)); + let cases = [ + ( + "2026/08/03 00:00 - 2026/08/04 00:00", + "Today", + at(2026, 8, 3, 16, 0), + ), + ("2026/08/03 00:00", "today", None), + ("garbage - later", "Today", None), + ("10:00-23:00(UTC+8)", "5 hours", at(2026, 8, 3, 15, 0)), + ("10:00 - 23:30", "2h", at(2026, 8, 3, 15, 30)), + ("08:00-19:00(UTC+8)", "5 hours", tomorrow), + ("10:00-23:00", "Month", at(2026, 8, 3, 15, 0)), + ("10:00", "5 hours", None), + ("10:00-25:99", "5 hours", None), + ("10:00-23:00", "Weekly", None), + ]; + for (range, window, expected) in cases { + assert_eq!( + parse_resets_at_from_time_range(range, window, now()), + expected, + "{range} / {window}" + ); + } + } + + #[test] + fn time_range_labels_render_in_utc_plus_8() { + let start = Some(Utc.with_ymd_and_hms(2026, 8, 3, 0, 0, 0).unwrap()); + let end = Some(Utc.with_ymd_and_hms(2026, 8, 10, 5, 30, 0).unwrap()); + assert_eq!(time_range_string(start, end), "08:00-13:30(UTC+8)"); + assert_eq!(time_range_string(start, None), "N/A"); + assert_eq!( + weekly_time_range_string(start, end).as_deref(), + Some("08/03 08:00 - 08/10 13:30(UTC+8)") + ); + assert_eq!(weekly_time_range_string(None, end), None); + } + + fn camel_keys(value: &Value) -> Value { + match value { + Value::Object(map) => Value::Object( + map.iter() + .map(|(key, value)| { + let mut camel = String::new(); + let mut upper = false; + for ch in key.chars() { + if ch == '_' { + upper = true; + } else if upper { + camel.extend(ch.to_uppercase()); + upper = false; + } else { + camel.push(ch); + } + } + (camel, camel_keys(value)) + }) + .collect(), + ), + Value::Array(items) => Value::Array(items.iter().map(camel_keys).collect()), + other => other.clone(), + } + } + + #[test] + fn remains_lanes_read_every_snake_and_camel_key() { + let snake = serde_json::json!({ + "model_remains": [{ + "model_name": "General", + "current_interval_total_count": 200, + "current_interval_usage_count": 50, + "current_interval_status": 0, + "start_time": 1785754800, + "end_time": 1785758340, + "remains_time": 600, + "current_weekly_total_count": 1000, + "current_weekly_usage_count": 100, + "current_weekly_remaining_percent": 40, + "current_weekly_status": 0, + "weekly_start_time": 1785585600, + "weekly_end_time": 1786190400, + "weekly_remains_time": 30 + }] + }); + let camel = camel_keys(&snake); + assert!(camel["modelRemains"][0].get("weeklyRemainsTime").is_some()); + + let rows_for = |json: &Value| { + let MiniMaxCodingPlanSnapshot::Remains { plan_name, rows } = + parse_coding_plan_value(json, now()).unwrap() + else { + panic!("expected Remains"); + }; + assert_eq!(plan_name, None); + rows + }; + let rows = rows_for(&snake); + assert_eq!(format!("{rows:?}"), format!("{:?}", rows_for(&camel))); + assert_eq!(rows.len(), 2); + + let interval = &rows[0]; + assert_eq!(interval.model_name, "General"); + assert_eq!(interval.service_type, "general"); + assert_eq!(interval.window_type, "Custom"); + // usage_count is the remaining count: (200 - 50) / 200. + assert_eq!(interval.percent, 75.0); + assert_eq!(interval.window_minutes, Some(59)); + assert_eq!(interval.resets_at, Some(now() + Duration::seconds(600))); + assert_eq!( + interval.reset_description.as_deref(), + Some("Resets in 10 minutes") + ); + assert!(!interval.is_weekly); + + let weekly = &rows[1]; + assert_eq!(weekly.window_type, "Weekly"); + assert_eq!(weekly.percent, 60.0); + assert_eq!(weekly.window_minutes, Some(10080)); + assert_eq!(weekly.resets_at, Some(now() + Duration::days(5))); + assert_eq!( + weekly.reset_description.as_deref(), + Some("Resets in 5 days") + ); + assert!(weekly.is_weekly); + } + + #[test] + fn plan_name_infers_plus_from_an_unavailable_video_lane() { + let entries = |video_remaining_percent: i64| { + serde_json::json!({ + "model_remains": [ + {"model_name": "MiniMax-M2", "current_interval_total_count": 10, + "current_interval_usage_count": 5}, + {"model_name": " Video ", "current_interval_status": 3, + "current_interval_total_count": 0, "current_interval_usage_count": 0, + "current_interval_remaining_percent": video_remaining_percent} + ] + }) + }; + for (remaining, expected) in [(100, Some("Plus")), (50, None)] { + let snake = entries(remaining); + assert_eq!( + parse_plan_name_from_data(&snake).as_deref(), + expected, + "snake {remaining}" + ); + assert_eq!( + parse_plan_name_from_data(&camel_keys(&snake)).as_deref(), + expected, + "camel {remaining}" + ); + } + } + #[test] fn parses_remains_json_snake_case() { let json = serde_json::json!({ diff --git a/rust/src/providers/minimax/coding_plan_html.rs b/rust/src/providers/minimax/coding_plan_html.rs index 211ae787ce6..7c3df0e90b4 100644 --- a/rust/src/providers/minimax/coding_plan_html.rs +++ b/rust/src/providers/minimax/coding_plan_html.rs @@ -10,7 +10,7 @@ use serde_json::Value; use crate::core::{ProviderError, RateWindow, UsageSnapshot}; use super::coding_plan::{ - MiniMaxCodingPlanSnapshot, RemainsRow, ServiceRow, parse_coding_plan_value, + MiniMaxCodingPlanSnapshot, RemainsRow, ServiceRow, next_time_today, parse_coding_plan_value, }; impl ServiceRow { @@ -295,13 +295,7 @@ fn date_for_time(time: &str, tz_hint: Option<&str>, now: DateTime) -> Optio None => FixedOffset::east_opt(0).unwrap(), }; let time_only = chrono::NaiveTime::parse_from_str(time.trim(), "%H:%M").ok()?; - let today = now.date_naive(); - let dt = today.and_time(time_only).and_local_timezone(tz).single()?; - let mut candidate = dt.with_timezone(&Utc); - if candidate < now { - candidate = now + Duration::days(1); - } - Some(candidate) + next_time_today(time_only, tz, now) } /// Parse a `UTC±H[:MM]`/`GMT±H[:MM]` hint into a `FixedOffset`. Non-numeric hints @@ -486,6 +480,24 @@ mod tests { assert_eq!(reset, now() + Duration::seconds(2 * 86_400)); } + #[test] + fn reset_clock_times_roll_forward_with_the_zone_hint() { + let at = |d, h, mi| Some(Utc.with_ymd_and_hms(2026, 8, d, h, mi, 0).unwrap()); + let tomorrow = Some(now() + Duration::days(1)); + let cases = [ + ("Resets at 15:30", at(3, 15, 30)), + ("Resets at 11:59", tomorrow), + ("Reset at 13:00 (UTC+8)", tomorrow), + ("resets at 23:00 (GMT-5:30)", at(4, 4, 30)), + ("Resets at 20:00 (PST)", at(3, 20, 0)), + ("Resets at 20:00 (UTC+0130)", at(3, 18, 30)), + ("Resets at 25:00", None), + ]; + for (text, expected) in cases { + assert_eq!(parse_resets_at_from_text(text, now()), expected, "{text}"); + } + } + #[test] fn text_without_a_reset_count_is_unmatched() { assert_eq!(parse_resets_at_from_text("no reset info", now()), None); From 1e5a7da074eade42da9ebdb49dcc9f0d464e1538 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:34:37 +0700 Subject: [PATCH 25/40] Share MiniMax billing-record and base_resp test tables (PBG-64) --- rust/src/providers/minimax/coding_plan.rs | 55 +++++++-------- rust/src/providers/minimax/mod.rs | 83 +++++++---------------- 2 files changed, 47 insertions(+), 91 deletions(-) diff --git a/rust/src/providers/minimax/coding_plan.rs b/rust/src/providers/minimax/coding_plan.rs index f0e143a9419..fc3a3bbf05f 100644 --- a/rust/src/providers/minimax/coding_plan.rs +++ b/rust/src/providers/minimax/coding_plan.rs @@ -1057,39 +1057,30 @@ mod tests { } #[test] - fn base_resp_1004_is_auth_required() { - let json = serde_json::json!({ - "data": { - "base_resp": { "status_code": 1004 }, - "model_remains": [] - } - }); - let err = parse_coding_plan_value(&json, now()).unwrap_err(); - assert!(matches!(err, ProviderError::AuthRequired)); - } - - #[test] - fn base_resp_status_msg_login_is_auth_required() { - let json = serde_json::json!({ - "data": { - "base_resp": { "status_code": 2000, "status_msg": "please log in" }, - "model_remains": [] - } - }); - let err = parse_coding_plan_value(&json, now()).unwrap_err(); - assert!(matches!(err, ProviderError::AuthRequired)); - } - - #[test] - fn base_resp_other_status_is_other_error() { - let json = serde_json::json!({ - "data": { - "base_resp": { "status_code": 2000, "status_msg": "quota sync failed" }, - "model_remains": [] + fn base_resp_status_maps_to_errors() { + // `None` expects AuthRequired; `Some` expects Other with that message. + let cases = [ + (serde_json::json!({ "status_code": 1004 }), None), + ( + serde_json::json!({ "status_code": 2000, "status_msg": "please log in" }), + None, + ), + ( + serde_json::json!({ "status_code": 2000, "status_msg": "quota sync failed" }), + Some("quota sync failed"), + ), + ]; + for (base_resp, expected) in cases { + let json = serde_json::json!({ + "data": { "base_resp": base_resp, "model_remains": [] } + }); + let err = parse_coding_plan_value(&json, now()).unwrap_err(); + match (expected, err) { + (None, ProviderError::AuthRequired) => {} + (Some(message), ProviderError::Other(actual)) => assert_eq!(actual, message), + (expected, err) => panic!("{base_resp}: expected {expected:?}, got {err:?}"), } - }); - let err = parse_coding_plan_value(&json, now()).unwrap_err(); - assert!(matches!(err, ProviderError::Other(_))); + } } #[test] diff --git a/rust/src/providers/minimax/mod.rs b/rust/src/providers/minimax/mod.rs index 352945d03f8..078cb52a488 100755 --- a/rust/src/providers/minimax/mod.rs +++ b/rust/src/providers/minimax/mod.rs @@ -1054,36 +1054,38 @@ mod tests { ); } + /// A dated billing record with every other field absent. + fn record(ymd: &str, method: &str, model: &str) -> MiniMaxBillingRecord { + MiniMaxBillingRecord { + consume_token: None, + consume_input_token: None, + consume_output_token: None, + consume_cash: None, + consume_cash_after_voucher: None, + created_at: None, + ymd: Some(ymd.to_string()), + consume_time: None, + method: Some(method.to_string()), + model: Some(model.to_string()), + result: None, + status: None, + } + } + #[test] fn aggregates_billing_history_records() { let records = vec![ MiniMaxBillingRecord { consume_token: Some(serde_json::json!(1200)), - consume_input_token: None, - consume_output_token: None, consume_cash: Some(serde_json::json!("0.42")), - consume_cash_after_voucher: None, - created_at: None, - ymd: Some("2026-12-16".to_string()), - consume_time: None, - method: Some("chat".to_string()), - model: Some("abab6.5".to_string()), result: Some(serde_json::json!("SUCCESS")), - status: None, + ..record("2026-12-16", "chat", "abab6.5") }, MiniMaxBillingRecord { - consume_token: None, consume_input_token: Some(serde_json::json!(300)), consume_output_token: Some(serde_json::json!(500)), - consume_cash: None, consume_cash_after_voucher: Some(serde_json::json!(0.21)), - created_at: None, - ymd: Some("2026-12-15".to_string()), - consume_time: None, - method: Some("completion".to_string()), - model: Some("abab6.5".to_string()), - result: None, - status: None, + ..record("2026-12-15", "completion", "abab6.5") }, ]; let now = Utc.with_ymd_and_hms(2026, 12, 16, 12, 0, 0).unwrap(); @@ -1233,59 +1235,22 @@ mod tests { let records = vec![ MiniMaxBillingRecord { consume_token: Some(serde_json::json!(1000)), - consume_input_token: None, - consume_output_token: None, - consume_cash: None, - consume_cash_after_voucher: None, - created_at: None, - ymd: Some("2026-05-17".to_string()), - consume_time: None, - method: Some("chat".to_string()), - model: Some("MiniMax-M1".to_string()), result: Some(serde_json::json!("SUCCESS")), - status: None, + ..record("2026-05-17", "chat", "MiniMax-M1") }, MiniMaxBillingRecord { consume_token: Some(serde_json::json!(2000)), - consume_input_token: None, - consume_output_token: None, - consume_cash: None, - consume_cash_after_voucher: None, - created_at: None, - ymd: Some("2026-05-17".to_string()), - consume_time: None, - method: Some("chat".to_string()), - model: Some("MiniMax-M1".to_string()), result: Some(serde_json::json!("FAILED")), - status: None, + ..record("2026-05-17", "chat", "MiniMax-M1") }, MiniMaxBillingRecord { consume_token: Some(serde_json::json!(3000)), - consume_input_token: None, - consume_output_token: None, - consume_cash: None, - consume_cash_after_voucher: None, - created_at: None, - ymd: Some("2026-05-17".to_string()), - consume_time: None, - method: Some("audio".to_string()), - model: Some("speech".to_string()), - result: None, - status: None, + ..record("2026-05-17", "audio", "speech") }, MiniMaxBillingRecord { consume_token: Some(serde_json::json!(4000)), - consume_input_token: None, - consume_output_token: None, - consume_cash: None, - consume_cash_after_voucher: None, - created_at: None, - ymd: Some("2026-05-17".to_string()), - consume_time: None, - method: Some("video".to_string()), - model: Some("video".to_string()), - result: None, status: Some(serde_json::json!(0)), + ..record("2026-05-17", "video", "video") }, ]; let now = Utc.with_ymd_and_hms(2026, 5, 17, 12, 0, 0).unwrap(); From cc822efa044b678767e5a01aacee63813ece8622 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:36:21 +0700 Subject: [PATCH 26/40] Move MiniMax provider tests into minimax/tests.rs --- rust/src/providers/minimax/mod.rs | 271 +--------------------------- rust/src/providers/minimax/tests.rs | 268 +++++++++++++++++++++++++++ 2 files changed, 269 insertions(+), 270 deletions(-) create mode 100644 rust/src/providers/minimax/tests.rs diff --git a/rust/src/providers/minimax/mod.rs b/rust/src/providers/minimax/mod.rs index 078cb52a488..d103bf7e8f8 100755 --- a/rust/src/providers/minimax/mod.rs +++ b/rust/src/providers/minimax/mod.rs @@ -994,273 +994,4 @@ impl Provider for MiniMaxProvider { mod http_tests; #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn minimax_region_defaults_to_global_io_urls() { - let region = MiniMaxRegion::from_settings_value(None); - assert_eq!(region, MiniMaxRegion::Global); - assert_eq!(region.settings_value(), "global"); - assert_eq!(region.cookie_domain(), "platform.minimax.io"); - assert_eq!( - region.coding_plan_url(), - "https://platform.minimax.io/user-center/payment/coding-plan?cycle_type=3" - ); - assert_eq!( - MiniMaxProvider::dashboard_url_for_region(None), - "https://platform.minimax.io/user-center/payment/coding-plan?cycle_type=3" - ); - } - - #[test] - fn minimax_region_accepts_legacy_china_value() { - for value in ["cn", "china", "china-mainland", "china_mainland"] { - let region = MiniMaxRegion::from_settings_value(Some(value)); - assert_eq!(region, MiniMaxRegion::ChinaMainland); - assert_eq!(region.settings_value(), "cn"); - assert_eq!(region.cookie_domain(), "platform.minimaxi.com"); - assert_eq!( - region.coding_plan_url(), - "https://platform.minimaxi.com/user-center/payment/coding-plan?cycle_type=3" - ); - } - } - - #[test] - fn minimax_region_cookie_search_domains_and_www_base_url() { - let global = MiniMaxRegion::Global; - assert_eq!(global.www_base_url(), "https://www.minimax.io"); - assert_eq!(global.cookie_search_domains(), ["minimax.io"]); - assert_eq!( - global.coding_plan_remains_url(), - "https://platform.minimax.io/v1/api/openplatform/coding_plan/remains" - ); - assert_eq!( - global.www_remains_url(), - "https://www.minimax.io/v1/api/openplatform/coding_plan/remains" - ); - - let cn = MiniMaxRegion::ChinaMainland; - assert_eq!(cn.www_base_url(), "https://www.minimaxi.com"); - assert_eq!(cn.cookie_search_domains(), ["minimaxi.com"]); - assert_eq!( - cn.coding_plan_remains_url(), - "https://platform.minimaxi.com/v1/api/openplatform/coding_plan/remains" - ); - assert_eq!( - cn.www_remains_url(), - "https://www.minimaxi.com/v1/api/openplatform/coding_plan/remains" - ); - } - - /// A dated billing record with every other field absent. - fn record(ymd: &str, method: &str, model: &str) -> MiniMaxBillingRecord { - MiniMaxBillingRecord { - consume_token: None, - consume_input_token: None, - consume_output_token: None, - consume_cash: None, - consume_cash_after_voucher: None, - created_at: None, - ymd: Some(ymd.to_string()), - consume_time: None, - method: Some(method.to_string()), - model: Some(model.to_string()), - result: None, - status: None, - } - } - - #[test] - fn aggregates_billing_history_records() { - let records = vec![ - MiniMaxBillingRecord { - consume_token: Some(serde_json::json!(1200)), - consume_cash: Some(serde_json::json!("0.42")), - result: Some(serde_json::json!("SUCCESS")), - ..record("2026-12-16", "chat", "abab6.5") - }, - MiniMaxBillingRecord { - consume_input_token: Some(serde_json::json!(300)), - consume_output_token: Some(serde_json::json!(500)), - consume_cash_after_voucher: Some(serde_json::json!(0.21)), - ..record("2026-12-15", "completion", "abab6.5") - }, - ]; - let now = Utc.with_ymd_and_hms(2026, 12, 16, 12, 0, 0).unwrap(); - let summary = aggregate_billing(&records, now); - assert_eq!(summary.last_30_days_tokens, 2000); - assert!((summary.last_30_days_cash.unwrap() - 0.63).abs() < 0.001); - assert_eq!(summary.top_models[0].name, "abab6.5"); - assert_eq!(summary.top_models[0].tokens, 2000); - } - - #[test] - fn parses_billing_payload_into_result_extras() { - let json = serde_json::json!({ - "base_resp": { "status_code": 0 }, - "charge_records": [{ - "consume_token": "2500", - "consume_cash_after_voucher": "1.25", - "ymd": Utc::now().format("%Y-%m-%d").to_string(), - "method": "chat", - "model": "abab6.5" - }] - }); - let summary = parse_billing_summary(&json).unwrap(); - let result = attach_billing_summary( - ProviderFetchResult::new(UsageSnapshot::new(RateWindow::new(0.0)), "web-billing"), - summary, - ); - assert!(result.cost.is_some()); - assert!( - result - .usage - .extra_rate_windows - .iter() - .any(|window| window.id == "billing-tokens-30d") - ); - } - - #[test] - fn billing_summary_windows_follow_a_fixed_order() { - let breakdown = |name: &str, tokens: i64, cash: Option| MiniMaxBillingBreakdown { - name: name.to_string(), - tokens, - cash, - }; - let summary = MiniMaxBillingSummary { - today_tokens: 1234, - last_30_days_tokens: 56789, - today_cash: Some(0.5), - last_30_days_cash: Some(12.345), - top_methods: vec![ - breakdown("chat", 5000, Some(1.0)), - breakdown("audio", 10, None), - ], - top_models: vec![breakdown("abab6.5", 7000, None)], - }; - let base = || ProviderFetchResult::new(UsageSnapshot::new(RateWindow::new(1.0)), "web"); - let windows = |result: &ProviderFetchResult| -> Vec<(String, String, Option)> { - result - .usage - .extra_rate_windows - .iter() - .map(|named| { - assert_eq!(named.window.used_percent, 0.0); - assert_eq!(named.window.window_minutes, None); - assert_eq!(named.window.resets_at, None); - ( - named.id.clone(), - named.title.clone(), - named.window.reset_description.clone(), - ) - }) - .collect() - }; - let row = |id: &str, title: &str, detail: &str| { - (id.to_string(), title.to_string(), Some(detail.to_string())) - }; - - let result = attach_billing_summary(base(), summary.clone()); - assert_eq!( - windows(&result), - vec![ - row("billing-tokens-today", "Tokens today", "1,234"), - row("billing-tokens-30d", "Tokens (30 days)", "56,789"), - row("billing-cash-today", "Spend today", "$0.50"), - row("billing-cash-30d", "Spend (30 days)", "$12.35"), - row("billing-method-0", "Method: chat", "5,000 tokens / $1.00"), - row("billing-method-1", "Method: audio", "10 tokens"), - row("billing-model-0", "Model: abab6.5", "7,000 tokens"), - ] - ); - let cost = result.cost.expect("30-day spend sets cost"); - assert_eq!(cost.used, 12.345); - assert_eq!(cost.currency_code, "USD"); - assert_eq!(result.usage.primary.used_percent, 1.0); - - let no_cash = MiniMaxBillingSummary { - today_cash: None, - last_30_days_cash: None, - top_methods: Vec::new(), - top_models: Vec::new(), - ..summary - }; - let result = attach_billing_summary(base(), no_cash); - assert_eq!( - windows(&result), - vec![ - row("billing-tokens-today", "Tokens today", "1,234"), - row("billing-tokens-30d", "Tokens (30 days)", "56,789"), - ] - ); - assert!(result.cost.is_none()); - } - - #[test] - fn parses_plan_title_from_coding_plan_fields() { - let provider = MiniMaxProvider::new(); - for (field, expected) in [ - ("plan_name", "MiniMax Star"), - ("current_plan_title", "Coding Plan Pro"), - ("current_subscribe_title", "Max"), - ("combo_title", "Combo Star"), - ] { - let snapshot = provider - .parse_usage_response(&serde_json::json!({ - "base_resp": { "status_code": 0 }, - field: expected, - "used_amount": 0, - "total_quota": 100 - })) - .unwrap(); - assert_eq!(snapshot.login_method.as_deref(), Some(expected)); - } - - let snapshot = provider - .parse_usage_response(&serde_json::json!({ - "base_resp": { "status_code": 0 }, - "current_combo_card": { "title": "Card Title" }, - "used_amount": 0, - "total_quota": 100 - })) - .unwrap(); - assert_eq!(snapshot.login_method.as_deref(), Some("Card Title")); - } - - #[test] - fn filters_failed_billing_records() { - let records = vec![ - MiniMaxBillingRecord { - consume_token: Some(serde_json::json!(1000)), - result: Some(serde_json::json!("SUCCESS")), - ..record("2026-05-17", "chat", "MiniMax-M1") - }, - MiniMaxBillingRecord { - consume_token: Some(serde_json::json!(2000)), - result: Some(serde_json::json!("FAILED")), - ..record("2026-05-17", "chat", "MiniMax-M1") - }, - MiniMaxBillingRecord { - consume_token: Some(serde_json::json!(3000)), - ..record("2026-05-17", "audio", "speech") - }, - MiniMaxBillingRecord { - consume_token: Some(serde_json::json!(4000)), - status: Some(serde_json::json!(0)), - ..record("2026-05-17", "video", "video") - }, - ]; - let now = Utc.with_ymd_and_hms(2026, 5, 17, 12, 0, 0).unwrap(); - let summary = aggregate_billing(&records, now); - assert_eq!(summary.today_tokens, 4000); - assert_eq!(summary.last_30_days_tokens, 4000); - assert_eq!(summary.top_methods.len(), 2); - assert_eq!(summary.top_methods[0].name, "audio"); - assert_eq!(summary.top_methods[0].tokens, 3000); - assert_eq!(summary.top_methods[1].name, "chat"); - assert_eq!(summary.top_methods[1].tokens, 1000); - } -} +mod tests; diff --git a/rust/src/providers/minimax/tests.rs b/rust/src/providers/minimax/tests.rs new file mode 100644 index 00000000000..66cc1f9bb48 --- /dev/null +++ b/rust/src/providers/minimax/tests.rs @@ -0,0 +1,268 @@ +use super::*; + +#[test] +fn minimax_region_defaults_to_global_io_urls() { + let region = MiniMaxRegion::from_settings_value(None); + assert_eq!(region, MiniMaxRegion::Global); + assert_eq!(region.settings_value(), "global"); + assert_eq!(region.cookie_domain(), "platform.minimax.io"); + assert_eq!( + region.coding_plan_url(), + "https://platform.minimax.io/user-center/payment/coding-plan?cycle_type=3" + ); + assert_eq!( + MiniMaxProvider::dashboard_url_for_region(None), + "https://platform.minimax.io/user-center/payment/coding-plan?cycle_type=3" + ); +} + +#[test] +fn minimax_region_accepts_legacy_china_value() { + for value in ["cn", "china", "china-mainland", "china_mainland"] { + let region = MiniMaxRegion::from_settings_value(Some(value)); + assert_eq!(region, MiniMaxRegion::ChinaMainland); + assert_eq!(region.settings_value(), "cn"); + assert_eq!(region.cookie_domain(), "platform.minimaxi.com"); + assert_eq!( + region.coding_plan_url(), + "https://platform.minimaxi.com/user-center/payment/coding-plan?cycle_type=3" + ); + } +} + +#[test] +fn minimax_region_cookie_search_domains_and_www_base_url() { + let global = MiniMaxRegion::Global; + assert_eq!(global.www_base_url(), "https://www.minimax.io"); + assert_eq!(global.cookie_search_domains(), ["minimax.io"]); + assert_eq!( + global.coding_plan_remains_url(), + "https://platform.minimax.io/v1/api/openplatform/coding_plan/remains" + ); + assert_eq!( + global.www_remains_url(), + "https://www.minimax.io/v1/api/openplatform/coding_plan/remains" + ); + + let cn = MiniMaxRegion::ChinaMainland; + assert_eq!(cn.www_base_url(), "https://www.minimaxi.com"); + assert_eq!(cn.cookie_search_domains(), ["minimaxi.com"]); + assert_eq!( + cn.coding_plan_remains_url(), + "https://platform.minimaxi.com/v1/api/openplatform/coding_plan/remains" + ); + assert_eq!( + cn.www_remains_url(), + "https://www.minimaxi.com/v1/api/openplatform/coding_plan/remains" + ); +} + +/// A dated billing record with every other field absent. +fn record(ymd: &str, method: &str, model: &str) -> MiniMaxBillingRecord { + MiniMaxBillingRecord { + consume_token: None, + consume_input_token: None, + consume_output_token: None, + consume_cash: None, + consume_cash_after_voucher: None, + created_at: None, + ymd: Some(ymd.to_string()), + consume_time: None, + method: Some(method.to_string()), + model: Some(model.to_string()), + result: None, + status: None, + } +} + +#[test] +fn aggregates_billing_history_records() { + let records = vec![ + MiniMaxBillingRecord { + consume_token: Some(serde_json::json!(1200)), + consume_cash: Some(serde_json::json!("0.42")), + result: Some(serde_json::json!("SUCCESS")), + ..record("2026-12-16", "chat", "abab6.5") + }, + MiniMaxBillingRecord { + consume_input_token: Some(serde_json::json!(300)), + consume_output_token: Some(serde_json::json!(500)), + consume_cash_after_voucher: Some(serde_json::json!(0.21)), + ..record("2026-12-15", "completion", "abab6.5") + }, + ]; + let now = Utc.with_ymd_and_hms(2026, 12, 16, 12, 0, 0).unwrap(); + let summary = aggregate_billing(&records, now); + assert_eq!(summary.last_30_days_tokens, 2000); + assert!((summary.last_30_days_cash.unwrap() - 0.63).abs() < 0.001); + assert_eq!(summary.top_models[0].name, "abab6.5"); + assert_eq!(summary.top_models[0].tokens, 2000); +} + +#[test] +fn parses_billing_payload_into_result_extras() { + let json = serde_json::json!({ + "base_resp": { "status_code": 0 }, + "charge_records": [{ + "consume_token": "2500", + "consume_cash_after_voucher": "1.25", + "ymd": Utc::now().format("%Y-%m-%d").to_string(), + "method": "chat", + "model": "abab6.5" + }] + }); + let summary = parse_billing_summary(&json).unwrap(); + let result = attach_billing_summary( + ProviderFetchResult::new(UsageSnapshot::new(RateWindow::new(0.0)), "web-billing"), + summary, + ); + assert!(result.cost.is_some()); + assert!( + result + .usage + .extra_rate_windows + .iter() + .any(|window| window.id == "billing-tokens-30d") + ); +} + +#[test] +fn billing_summary_windows_follow_a_fixed_order() { + let breakdown = |name: &str, tokens: i64, cash: Option| MiniMaxBillingBreakdown { + name: name.to_string(), + tokens, + cash, + }; + let summary = MiniMaxBillingSummary { + today_tokens: 1234, + last_30_days_tokens: 56789, + today_cash: Some(0.5), + last_30_days_cash: Some(12.345), + top_methods: vec![ + breakdown("chat", 5000, Some(1.0)), + breakdown("audio", 10, None), + ], + top_models: vec![breakdown("abab6.5", 7000, None)], + }; + let base = || ProviderFetchResult::new(UsageSnapshot::new(RateWindow::new(1.0)), "web"); + let windows = |result: &ProviderFetchResult| -> Vec<(String, String, Option)> { + result + .usage + .extra_rate_windows + .iter() + .map(|named| { + assert_eq!(named.window.used_percent, 0.0); + assert_eq!(named.window.window_minutes, None); + assert_eq!(named.window.resets_at, None); + ( + named.id.clone(), + named.title.clone(), + named.window.reset_description.clone(), + ) + }) + .collect() + }; + let row = |id: &str, title: &str, detail: &str| { + (id.to_string(), title.to_string(), Some(detail.to_string())) + }; + + let result = attach_billing_summary(base(), summary.clone()); + assert_eq!( + windows(&result), + vec![ + row("billing-tokens-today", "Tokens today", "1,234"), + row("billing-tokens-30d", "Tokens (30 days)", "56,789"), + row("billing-cash-today", "Spend today", "$0.50"), + row("billing-cash-30d", "Spend (30 days)", "$12.35"), + row("billing-method-0", "Method: chat", "5,000 tokens / $1.00"), + row("billing-method-1", "Method: audio", "10 tokens"), + row("billing-model-0", "Model: abab6.5", "7,000 tokens"), + ] + ); + let cost = result.cost.expect("30-day spend sets cost"); + assert_eq!(cost.used, 12.345); + assert_eq!(cost.currency_code, "USD"); + assert_eq!(result.usage.primary.used_percent, 1.0); + + let no_cash = MiniMaxBillingSummary { + today_cash: None, + last_30_days_cash: None, + top_methods: Vec::new(), + top_models: Vec::new(), + ..summary + }; + let result = attach_billing_summary(base(), no_cash); + assert_eq!( + windows(&result), + vec![ + row("billing-tokens-today", "Tokens today", "1,234"), + row("billing-tokens-30d", "Tokens (30 days)", "56,789"), + ] + ); + assert!(result.cost.is_none()); +} + +#[test] +fn parses_plan_title_from_coding_plan_fields() { + let provider = MiniMaxProvider::new(); + for (field, expected) in [ + ("plan_name", "MiniMax Star"), + ("current_plan_title", "Coding Plan Pro"), + ("current_subscribe_title", "Max"), + ("combo_title", "Combo Star"), + ] { + let snapshot = provider + .parse_usage_response(&serde_json::json!({ + "base_resp": { "status_code": 0 }, + field: expected, + "used_amount": 0, + "total_quota": 100 + })) + .unwrap(); + assert_eq!(snapshot.login_method.as_deref(), Some(expected)); + } + + let snapshot = provider + .parse_usage_response(&serde_json::json!({ + "base_resp": { "status_code": 0 }, + "current_combo_card": { "title": "Card Title" }, + "used_amount": 0, + "total_quota": 100 + })) + .unwrap(); + assert_eq!(snapshot.login_method.as_deref(), Some("Card Title")); +} + +#[test] +fn filters_failed_billing_records() { + let records = vec![ + MiniMaxBillingRecord { + consume_token: Some(serde_json::json!(1000)), + result: Some(serde_json::json!("SUCCESS")), + ..record("2026-05-17", "chat", "MiniMax-M1") + }, + MiniMaxBillingRecord { + consume_token: Some(serde_json::json!(2000)), + result: Some(serde_json::json!("FAILED")), + ..record("2026-05-17", "chat", "MiniMax-M1") + }, + MiniMaxBillingRecord { + consume_token: Some(serde_json::json!(3000)), + ..record("2026-05-17", "audio", "speech") + }, + MiniMaxBillingRecord { + consume_token: Some(serde_json::json!(4000)), + status: Some(serde_json::json!(0)), + ..record("2026-05-17", "video", "video") + }, + ]; + let now = Utc.with_ymd_and_hms(2026, 5, 17, 12, 0, 0).unwrap(); + let summary = aggregate_billing(&records, now); + assert_eq!(summary.today_tokens, 4000); + assert_eq!(summary.last_30_days_tokens, 4000); + assert_eq!(summary.top_methods.len(), 2); + assert_eq!(summary.top_methods[0].name, "audio"); + assert_eq!(summary.top_methods[0].tokens, 3000); + assert_eq!(summary.top_methods[1].name, "chat"); + assert_eq!(summary.top_methods[1].tokens, 1000); +} From f265576a02abf691b1da8a8ab3642956ee0a8876 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:44:21 +0700 Subject: [PATCH 27/40] Move MiniMax billing summary into minimax/billing.rs --- rust/src/providers/minimax/billing.rs | 278 ++++++++++++++++++++++++++ rust/src/providers/minimax/mod.rs | 278 +------------------------- rust/src/providers/minimax/tests.rs | 2 + 3 files changed, 285 insertions(+), 273 deletions(-) create mode 100644 rust/src/providers/minimax/billing.rs diff --git a/rust/src/providers/minimax/billing.rs b/rust/src/providers/minimax/billing.rs new file mode 100644 index 00000000000..5098c2c6b93 --- /dev/null +++ b/rust/src/providers/minimax/billing.rs @@ -0,0 +1,278 @@ +//! MiniMax billing-history summary: parse, aggregate and attach as extra windows. + +use chrono::{DateTime, Duration, TimeZone, Utc}; +use serde::Deserialize; +use std::collections::HashMap; + +use super::{format_count, scalar_string, value_i64}; +use crate::core::{CostSnapshot, ProviderError, ProviderFetchResult, RateWindow}; +use crate::providers::json; + +#[derive(Debug, Deserialize)] +struct MiniMaxBillingHistoryPayload { + #[serde(default)] + base_resp: Option, + #[serde(default)] + charge_records: Vec, +} + +#[derive(Debug, Deserialize)] +struct MiniMaxBaseResponse { + status_code: Option, + status_msg: Option, +} + +#[derive(Debug, Deserialize)] +pub(super) struct MiniMaxBillingRecord { + pub(super) consume_token: Option, + pub(super) consume_input_token: Option, + pub(super) consume_output_token: Option, + pub(super) consume_cash: Option, + pub(super) consume_cash_after_voucher: Option, + pub(super) created_at: Option, + pub(super) ymd: Option, + pub(super) consume_time: Option, + pub(super) method: Option, + pub(super) model: Option, + pub(super) result: Option, + pub(super) status: Option, +} + +#[derive(Debug, Clone)] +pub(super) struct MiniMaxBillingSummary { + pub(super) today_tokens: i64, + pub(super) last_30_days_tokens: i64, + pub(super) today_cash: Option, + pub(super) last_30_days_cash: Option, + pub(super) top_methods: Vec, + pub(super) top_models: Vec, +} + +#[derive(Debug, Clone)] +pub(super) struct MiniMaxBillingBreakdown { + pub(super) name: String, + pub(super) tokens: i64, + pub(super) cash: Option, +} + +pub(super) fn parse_billing_summary( + json: &serde_json::Value, +) -> Result { + let payload: MiniMaxBillingHistoryPayload = serde_json::from_value(json.clone()) + .map_err(|e| ProviderError::Parse(format!("Failed to parse MiniMax billing: {e}")))?; + if let Some(base) = payload.base_resp + && let Some(status) = base.status_code + && status != 0 + { + return Err(ProviderError::Other( + base.status_msg + .unwrap_or_else(|| format!("MiniMax billing status {status}")), + )); + } + if payload.charge_records.is_empty() { + return Err(ProviderError::Parse( + "MiniMax billing records not present".to_string(), + )); + } + Ok(aggregate_billing(&payload.charge_records, Utc::now())) +} + +pub(super) fn aggregate_billing( + records: &[MiniMaxBillingRecord], + now: DateTime, +) -> MiniMaxBillingSummary { + let today_start = now.date_naive().and_hms_opt(0, 0, 0).unwrap().and_utc(); + let window_start = today_start - Duration::days(29); + let mut today_tokens = 0; + let mut last_30_days_tokens = 0; + let mut today_cash = 0.0; + let mut today_has_cash = false; + let mut last_30_days_cash = 0.0; + let mut last_30_has_cash = false; + let mut method_totals: HashMap = HashMap::new(); + let mut model_totals: HashMap = HashMap::new(); + + for record in records { + if !billing_record_succeeded(record) { + continue; + } + let Some(date) = record_date(record) else { + continue; + }; + if date < window_start || date > now { + continue; + } + let tokens = record_token_count(record); + let cash = record_cash(record); + last_30_days_tokens += tokens; + if let Some(cash) = cash { + last_30_days_cash += cash; + last_30_has_cash = true; + } + if date >= today_start { + today_tokens += tokens; + if let Some(cash) = cash { + today_cash += cash; + today_has_cash = true; + } + } + add_breakdown(&mut method_totals, record.method.as_deref(), tokens, cash); + add_breakdown(&mut model_totals, record.model.as_deref(), tokens, cash); + } + + MiniMaxBillingSummary { + today_tokens, + last_30_days_tokens, + today_cash: today_has_cash.then_some(today_cash), + last_30_days_cash: last_30_has_cash.then_some(last_30_days_cash), + top_methods: top_breakdowns(method_totals), + top_models: top_breakdowns(model_totals), + } +} + +fn billing_record_succeeded(record: &MiniMaxBillingRecord) -> bool { + let status = + scalar_string(record.result.as_ref()).or_else(|| scalar_string(record.status.as_ref())); + match status + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + None => true, + Some(value) => value.eq_ignore_ascii_case("success"), + } +} + +pub(super) fn attach_billing_summary( + mut result: ProviderFetchResult, + summary: MiniMaxBillingSummary, +) -> ProviderFetchResult { + let mut rows = vec![ + ( + "billing-tokens-today".to_string(), + "Tokens today".to_string(), + format_count(summary.today_tokens), + ), + ( + "billing-tokens-30d".to_string(), + "Tokens (30 days)".to_string(), + format_count(summary.last_30_days_tokens), + ), + ]; + if let Some(cash) = summary.today_cash { + rows.push(( + "billing-cash-today".to_string(), + "Spend today".to_string(), + format!("${cash:.2}"), + )); + } + if let Some(cash) = summary.last_30_days_cash { + rows.push(( + "billing-cash-30d".to_string(), + "Spend (30 days)".to_string(), + format!("${cash:.2}"), + )); + result.cost = Some(CostSnapshot::new(cash, "USD", "Last 30 days")); + } + for (kind, label, items) in [ + ("method", "Method", &summary.top_methods), + ("model", "Model", &summary.top_models), + ] { + for (idx, item) in items.iter().enumerate() { + rows.push(( + format!("billing-{kind}-{idx}"), + format!("{label}: {}", item.name), + breakdown_description(item), + )); + } + } + for (id, title, detail) in rows { + result.usage = result.usage.with_extra_rate_window( + id, + title, + RateWindow::with_details(0.0, None, None, Some(detail)), + ); + } + result +} + +fn add_breakdown( + totals: &mut HashMap, + raw_name: Option<&str>, + tokens: i64, + cash: Option, +) { + let Some(name) = raw_name.map(str::trim).filter(|s| !s.is_empty()) else { + return; + }; + let total = totals.entry(name.to_string()).or_default(); + total.0 += tokens; + if let Some(cash) = cash { + total.1 += cash; + total.2 = true; + } +} + +fn top_breakdowns(totals: HashMap) -> Vec { + let mut items: Vec<_> = totals + .into_iter() + .map(|(name, (tokens, cash, has_cash))| MiniMaxBillingBreakdown { + name, + tokens, + cash: has_cash.then_some(cash), + }) + .collect(); + items.sort_by(|a, b| b.tokens.cmp(&a.tokens).then_with(|| a.name.cmp(&b.name))); + items.truncate(3); + items +} + +fn breakdown_description(item: &MiniMaxBillingBreakdown) -> String { + match item.cash { + Some(cash) => format!("{} tokens / ${cash:.2}", format_count(item.tokens)), + None => format!("{} tokens", format_count(item.tokens)), + } +} + +fn record_token_count(record: &MiniMaxBillingRecord) -> i64 { + let direct = value_i64(record.consume_token.as_ref()).unwrap_or(0); + if direct > 0 { + return direct; + } + value_i64(record.consume_input_token.as_ref()).unwrap_or(0) + + value_i64(record.consume_output_token.as_ref()).unwrap_or(0) +} + +fn record_cash(record: &MiniMaxBillingRecord) -> Option { + json::lenient_f64(record.consume_cash_after_voucher.as_ref()) + .or_else(|| json::lenient_f64(record.consume_cash.as_ref())) +} + +fn record_date(record: &MiniMaxBillingRecord) -> Option> { + if let Some(created_at) = value_i64(record.created_at.as_ref()) { + let seconds = if created_at > 1_000_000_000_000 { + created_at / 1000 + } else { + created_at + }; + return Utc.timestamp_opt(seconds, 0).single(); + } + if let Some(ymd) = record.ymd.as_deref() { + for format in ["%Y-%m-%d", "%Y%m%d", "%Y/%m/%d"] { + if let Ok(date) = chrono::NaiveDate::parse_from_str(ymd.trim(), format) { + return Some(date.and_hms_opt(0, 0, 0)?.and_utc()); + } + } + } + if let Some(text) = record.consume_time.as_deref() { + for format in ["%Y-%m-%d %H:%M:%S", "%Y/%m/%d %H:%M:%S"] { + if let Ok(date) = chrono::NaiveDateTime::parse_from_str(text.trim(), format) { + return Some(date.and_utc()); + } + } + if let Ok(date) = DateTime::parse_from_rfc3339(text.trim()) { + return Some(date.with_timezone(&Utc)); + } + } + None +} diff --git a/rust/src/providers/minimax/mod.rs b/rust/src/providers/minimax/mod.rs index d103bf7e8f8..097e6be2b32 100755 --- a/rust/src/providers/minimax/mod.rs +++ b/rust/src/providers/minimax/mod.rs @@ -3,22 +3,21 @@ //! Fetches usage data from MiniMax AI API //! MiniMax stores API keys locally or in environment +mod billing; mod coding_plan; mod coding_plan_html; mod remains_api; mod token_plan; use async_trait::async_trait; -use chrono::{DateTime, Duration, TimeZone, Utc}; -use serde::Deserialize; -use std::collections::HashMap; +use chrono::{DateTime, Utc}; use std::path::PathBuf; use crate::core::{ - CostSnapshot, FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, - RateWindow, SourceMode, UsageSnapshot, + FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, RateWindow, SourceMode, + UsageSnapshot, }; -use crate::providers::json; +use billing::{MiniMaxBillingSummary, attach_billing_summary, parse_billing_summary}; const CODING_PLAN_PATH: &str = "/user-center/payment/coding-plan"; const CODING_PLAN_QUERY: &str = "cycle_type=3"; @@ -84,53 +83,6 @@ fn console_get( .header("Referer", format!("{base}/user-center/payment/coding-plan")) } -#[derive(Debug, Deserialize)] -struct MiniMaxBillingHistoryPayload { - #[serde(default)] - base_resp: Option, - #[serde(default)] - charge_records: Vec, -} - -#[derive(Debug, Deserialize)] -struct MiniMaxBaseResponse { - status_code: Option, - status_msg: Option, -} - -#[derive(Debug, Deserialize)] -struct MiniMaxBillingRecord { - consume_token: Option, - consume_input_token: Option, - consume_output_token: Option, - consume_cash: Option, - consume_cash_after_voucher: Option, - created_at: Option, - ymd: Option, - consume_time: Option, - method: Option, - model: Option, - result: Option, - status: Option, -} - -#[derive(Debug, Clone)] -struct MiniMaxBillingSummary { - today_tokens: i64, - last_30_days_tokens: i64, - today_cash: Option, - last_30_days_cash: Option, - top_methods: Vec, - top_models: Vec, -} - -#[derive(Debug, Clone)] -struct MiniMaxBillingBreakdown { - name: String, - tokens: i64, - cash: Option, -} - /// MiniMax API region #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum MiniMaxRegion { @@ -688,226 +640,6 @@ impl MiniMaxProvider { } } -fn parse_billing_summary(json: &serde_json::Value) -> Result { - let payload: MiniMaxBillingHistoryPayload = serde_json::from_value(json.clone()) - .map_err(|e| ProviderError::Parse(format!("Failed to parse MiniMax billing: {e}")))?; - if let Some(base) = payload.base_resp - && let Some(status) = base.status_code - && status != 0 - { - return Err(ProviderError::Other( - base.status_msg - .unwrap_or_else(|| format!("MiniMax billing status {status}")), - )); - } - if payload.charge_records.is_empty() { - return Err(ProviderError::Parse( - "MiniMax billing records not present".to_string(), - )); - } - Ok(aggregate_billing(&payload.charge_records, Utc::now())) -} - -fn aggregate_billing( - records: &[MiniMaxBillingRecord], - now: DateTime, -) -> MiniMaxBillingSummary { - let today_start = now.date_naive().and_hms_opt(0, 0, 0).unwrap().and_utc(); - let window_start = today_start - Duration::days(29); - let mut today_tokens = 0; - let mut last_30_days_tokens = 0; - let mut today_cash = 0.0; - let mut today_has_cash = false; - let mut last_30_days_cash = 0.0; - let mut last_30_has_cash = false; - let mut method_totals: HashMap = HashMap::new(); - let mut model_totals: HashMap = HashMap::new(); - - for record in records { - if !billing_record_succeeded(record) { - continue; - } - let Some(date) = record_date(record) else { - continue; - }; - if date < window_start || date > now { - continue; - } - let tokens = record_token_count(record); - let cash = record_cash(record); - last_30_days_tokens += tokens; - if let Some(cash) = cash { - last_30_days_cash += cash; - last_30_has_cash = true; - } - if date >= today_start { - today_tokens += tokens; - if let Some(cash) = cash { - today_cash += cash; - today_has_cash = true; - } - } - add_breakdown(&mut method_totals, record.method.as_deref(), tokens, cash); - add_breakdown(&mut model_totals, record.model.as_deref(), tokens, cash); - } - - MiniMaxBillingSummary { - today_tokens, - last_30_days_tokens, - today_cash: today_has_cash.then_some(today_cash), - last_30_days_cash: last_30_has_cash.then_some(last_30_days_cash), - top_methods: top_breakdowns(method_totals), - top_models: top_breakdowns(model_totals), - } -} - -fn billing_record_succeeded(record: &MiniMaxBillingRecord) -> bool { - let status = - scalar_string(record.result.as_ref()).or_else(|| scalar_string(record.status.as_ref())); - match status - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - { - None => true, - Some(value) => value.eq_ignore_ascii_case("success"), - } -} - -fn attach_billing_summary( - mut result: ProviderFetchResult, - summary: MiniMaxBillingSummary, -) -> ProviderFetchResult { - let mut rows = vec![ - ( - "billing-tokens-today".to_string(), - "Tokens today".to_string(), - format_count(summary.today_tokens), - ), - ( - "billing-tokens-30d".to_string(), - "Tokens (30 days)".to_string(), - format_count(summary.last_30_days_tokens), - ), - ]; - if let Some(cash) = summary.today_cash { - rows.push(( - "billing-cash-today".to_string(), - "Spend today".to_string(), - format!("${cash:.2}"), - )); - } - if let Some(cash) = summary.last_30_days_cash { - rows.push(( - "billing-cash-30d".to_string(), - "Spend (30 days)".to_string(), - format!("${cash:.2}"), - )); - result.cost = Some(CostSnapshot::new(cash, "USD", "Last 30 days")); - } - for (kind, label, items) in [ - ("method", "Method", &summary.top_methods), - ("model", "Model", &summary.top_models), - ] { - for (idx, item) in items.iter().enumerate() { - rows.push(( - format!("billing-{kind}-{idx}"), - format!("{label}: {}", item.name), - breakdown_description(item), - )); - } - } - for (id, title, detail) in rows { - result.usage = result.usage.with_extra_rate_window( - id, - title, - RateWindow::with_details(0.0, None, None, Some(detail)), - ); - } - result -} - -fn add_breakdown( - totals: &mut HashMap, - raw_name: Option<&str>, - tokens: i64, - cash: Option, -) { - let Some(name) = raw_name.map(str::trim).filter(|s| !s.is_empty()) else { - return; - }; - let total = totals.entry(name.to_string()).or_default(); - total.0 += tokens; - if let Some(cash) = cash { - total.1 += cash; - total.2 = true; - } -} - -fn top_breakdowns(totals: HashMap) -> Vec { - let mut items: Vec<_> = totals - .into_iter() - .map(|(name, (tokens, cash, has_cash))| MiniMaxBillingBreakdown { - name, - tokens, - cash: has_cash.then_some(cash), - }) - .collect(); - items.sort_by(|a, b| b.tokens.cmp(&a.tokens).then_with(|| a.name.cmp(&b.name))); - items.truncate(3); - items -} - -fn breakdown_description(item: &MiniMaxBillingBreakdown) -> String { - match item.cash { - Some(cash) => format!("{} tokens / ${cash:.2}", format_count(item.tokens)), - None => format!("{} tokens", format_count(item.tokens)), - } -} - -fn record_token_count(record: &MiniMaxBillingRecord) -> i64 { - let direct = value_i64(record.consume_token.as_ref()).unwrap_or(0); - if direct > 0 { - return direct; - } - value_i64(record.consume_input_token.as_ref()).unwrap_or(0) - + value_i64(record.consume_output_token.as_ref()).unwrap_or(0) -} - -fn record_cash(record: &MiniMaxBillingRecord) -> Option { - json::lenient_f64(record.consume_cash_after_voucher.as_ref()) - .or_else(|| json::lenient_f64(record.consume_cash.as_ref())) -} - -fn record_date(record: &MiniMaxBillingRecord) -> Option> { - if let Some(created_at) = value_i64(record.created_at.as_ref()) { - let seconds = if created_at > 1_000_000_000_000 { - created_at / 1000 - } else { - created_at - }; - return Utc.timestamp_opt(seconds, 0).single(); - } - if let Some(ymd) = record.ymd.as_deref() { - for format in ["%Y-%m-%d", "%Y%m%d", "%Y/%m/%d"] { - if let Ok(date) = chrono::NaiveDate::parse_from_str(ymd.trim(), format) { - return Some(date.and_hms_opt(0, 0, 0)?.and_utc()); - } - } - } - if let Some(text) = record.consume_time.as_deref() { - for format in ["%Y-%m-%d %H:%M:%S", "%Y/%m/%d %H:%M:%S"] { - if let Ok(date) = chrono::NaiveDateTime::parse_from_str(text.trim(), format) { - return Some(date.and_utc()); - } - } - if let Ok(date) = DateTime::parse_from_rfc3339(text.trim()) { - return Some(date.with_timezone(&Utc)); - } - } - None -} - fn value_i64(value: Option<&serde_json::Value>) -> Option { match value? { serde_json::Value::Number(number) => number.as_i64(), diff --git a/rust/src/providers/minimax/tests.rs b/rust/src/providers/minimax/tests.rs index 66cc1f9bb48..3c74846d67b 100644 --- a/rust/src/providers/minimax/tests.rs +++ b/rust/src/providers/minimax/tests.rs @@ -1,4 +1,6 @@ +use super::billing::*; use super::*; +use chrono::TimeZone; #[test] fn minimax_region_defaults_to_global_io_urls() { From d7d72f22c078ce7706f3e4d7b259b87bf763cedf Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:48:40 +0700 Subject: [PATCH 28/40] Satisfy clippy in the Kimi and OpenRouter table tests --- rust/src/providers/kimi/ratio_pool.rs | 4 ++-- rust/src/providers/openrouter/tests.rs | 11 ++++++++++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/rust/src/providers/kimi/ratio_pool.rs b/rust/src/providers/kimi/ratio_pool.rs index 8b535dd21f4..e9c3f9d43e7 100644 --- a/rust/src/providers/kimi/ratio_pool.rs +++ b/rust/src/providers/kimi/ratio_pool.rs @@ -160,8 +160,8 @@ mod tests { (json!(3), Some(3)), (json!(3.0), Some(3)), (json!(3.5), None), - (json!(-9.223_372_036_854_775_808e18), Some(i64::MIN)), - (json!(9.223_372_036_854_775_808e18), None), + (json!(i64::MIN as f64), Some(i64::MIN)), + (json!(-(i64::MIN as f64)), None), (json!(u64::MAX), None), (json!("12"), Some(12)), (json!(" 12"), None), diff --git a/rust/src/providers/openrouter/tests.rs b/rust/src/providers/openrouter/tests.rs index bfd6807e07e..e264a2d508a 100644 --- a/rust/src/providers/openrouter/tests.rs +++ b/rust/src/providers/openrouter/tests.rs @@ -543,7 +543,16 @@ async fn get_json_keeps_each_call_sites_auth_set_and_reason() { let credits_auth = &[StatusCode::UNAUTHORIZED][..]; // (label, auth statuses, forbidden reason, HTTP status, body, // expected error, expected reason) - let cases: [(&str, &[StatusCode], Option<&str>, usize, &str, &str, &str); 7] = [ + type Case<'a> = ( + &'a str, + &'a [StatusCode], + Option<&'a str>, + usize, + &'a str, + &'a str, + &'a str, + ); + let cases: [Case<'_>; 7] = [ ("credits", credits_auth, None, 200, r#"{"ok":1}"#, "", ""), ( "credits", From 91468aa88c2461625e7a0fc653a173f82e5c39c3 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:56:18 +0700 Subject: [PATCH 29/40] Pin Antigravity SQLite schema inspection and row byte charging --- .../antigravity/local_sqlite_tests.rs | 198 ++++++++++++++++++ 1 file changed, 198 insertions(+) diff --git a/rust/src/providers/antigravity/local_sqlite_tests.rs b/rust/src/providers/antigravity/local_sqlite_tests.rs index a8a93a11034..1181e8dcbfe 100644 --- a/rust/src/providers/antigravity/local_sqlite_tests.rs +++ b/rust/src/providers/antigravity/local_sqlite_tests.rs @@ -465,3 +465,201 @@ fn routine_read_offers_background_pricing_only_for_unpriced_history() { assert_eq!(unknown.total_usd(), None); assert!(background_pricing_refresh(&unknown).is_some()); } + +fn inspect_schema( + table: &str, + conn: &Connection, + budget: &mut Budget, +) -> rusqlite::Result { + match table { + "gen_metadata" => supported_schema(conn, budget), + _ => supported_steps_schema(conn, budget), + } +} + +#[test] +fn schema_inspection_requires_one_stored_table_with_the_reader_columns() { + use SchemaInspection::{Incomplete, Supported, Unsupported}; + for (table, column) in [("gen_metadata", "data"), ("steps", "metadata")] { + let upper = (table.to_ascii_uppercase(), column.to_ascii_uppercase()); + let unrelated = (0..=MAX_SCHEMA_ENTRIES) + .map(|index| format!("CREATE TABLE unrelated_{index}(value TEXT);")) + .collect::(); + let cases = [ + ( + format!("CREATE TABLE {table}(idx INTEGER, {column} BLOB);"), + false, + Supported, + ), + ( + format!("CREATE TABLE {}(IDX INTEGER, {} BLOB);", upper.0, upper.1), + false, + Supported, + ), + ( + format!("CREATE TABLE {table}(idx INTEGER);"), + false, + Unsupported, + ), + ( + format!("CREATE VIEW {table} AS SELECT 1 AS idx, x'00' AS {column};"), + false, + Unsupported, + ), + ( + format!("CREATE TABLE other(idx INTEGER, {column} BLOB);"), + false, + Unsupported, + ), + (String::new(), false, Unsupported), + ( + format!( + "CREATE TABLE {table}(idx INTEGER, {column} BLOB, derived TEXT GENERATED ALWAYS AS (idx || 'x') VIRTUAL);" + ), + false, + Unsupported, + ), + ( + format!("CREATE TABLE {table}(idx INTEGER, {column} BLOB);"), + true, + Incomplete, + ), + (unrelated, false, Incomplete), + ]; + for (ddl, expired, expected) in cases { + let conn = Connection::open_in_memory().unwrap(); + conn.execute_batch(&ddl).unwrap(); + let mut budget = if expired { + Budget::with_deadline(Instant::now()) + } else { + Budget::new() + }; + assert_eq!( + inspect_schema(table, &conn, &mut budget).unwrap(), + expected, + "{table}: {ddl:.80}" + ); + } + } +} + +/// (complete, exhausted, budget rows, budget bytes, database bytes) after one read. +type RowCharge = (bool, bool, usize, usize, usize); +/// (rows as (idx, SQL value), preset (rows, bytes, database bytes), expected charge). +type RowCase<'a> = (&'a [(i64, &'a str)], (usize, usize, usize), RowCharge); + +fn charge_rows(steps: bool, rows: &[(i64, &str)], preset: (usize, usize, usize)) -> RowCharge { + let (table, column) = if steps { + ("steps", "metadata") + } else { + ("gen_metadata", "data") + }; + let conn = Connection::open_in_memory().unwrap(); + conn.execute( + &format!("CREATE TABLE {table}(idx INTEGER, {column} BLOB)"), + [], + ) + .unwrap(); + for (idx, value) in rows { + conn.execute(&format!("INSERT INTO {table} VALUES (?1, {value})"), [idx]) + .unwrap(); + } + let (preset_rows, preset_bytes, preset_database_bytes) = preset; + let mut budget = Budget::new(); + budget.rows = preset_rows; + budget.bytes = preset_bytes; + let (complete, database_bytes) = if steps { + let mut database_bytes = preset_database_bytes; + let scan = + read_step_timestamps(&conn, &HashMap::new(), &mut budget, &mut database_bytes).unwrap(); + (scan.complete, database_bytes) + } else { + assert_eq!( + preset_database_bytes, 0, + "the generation reader starts at zero" + ); + let parsed = read_generation_rows(&conn, "session", &mut budget).unwrap(); + (parsed.complete, parsed.database_bytes) + }; + ( + complete, + budget.exhausted, + budget.rows, + budget.bytes, + database_bytes, + ) +} + +#[test] +fn row_readers_skip_unusable_rows_before_charging_bytes() { + let oversized = format!("zeroblob({})", MAX_BLOB_BYTES + 1); + let near_full = MAX_TOTAL_BYTES - 5; + for steps in [false, true] { + let cases: [RowCase<'_>; 8] = [ + ( + &[(-1, "zeroblob(10)")], + (0, MAX_TOTAL_BYTES, 0), + (false, false, 1, MAX_TOTAL_BYTES, 0), + ), + ( + &[(0, "NULL")], + (0, MAX_TOTAL_BYTES, 0), + (false, false, 1, MAX_TOTAL_BYTES, 0), + ), + ( + &[(0, "'text'")], + (0, MAX_TOTAL_BYTES, 0), + (false, false, 1, MAX_TOTAL_BYTES, 0), + ), + (&[(0, "zeroblob(0)")], (0, 0, 0), (false, false, 1, 0, 0)), + ( + &[(0, oversized.as_str())], + (0, 0, 0), + (false, false, 1, MAX_BLOB_BYTES + 1, MAX_BLOB_BYTES + 1), + ), + ( + &[(0, "zeroblob(10)")], + (0, near_full, 0), + (false, true, 1, near_full, 10), + ), + ( + &[(0, "zeroblob(4)"), (1, "zeroblob(10)")], + (0, MAX_TOTAL_BYTES - 9, 0), + (false, true, 2, MAX_TOTAL_BYTES - 5, 14), + ), + ( + &[(0, "zeroblob(10)")], + (MAX_ROWS, 0, 0), + (false, true, MAX_ROWS + 1, 0, 0), + ), + ]; + for (rows, preset, expected) in cases { + assert_eq!( + charge_rows(steps, rows, preset), + expected, + "steps={steps} {rows:?} {preset:?}" + ); + } + } + // The steps reader carries the database total from the generation scan. + let full = MAX_DATABASE_BYTES - 5; + assert_eq!( + charge_rows(true, &[(0, "zeroblob(10)")], (0, 0, full)), + (false, true, 1, 0, full) + ); + assert_eq!( + charge_rows(true, &[(0, "zeroblob(5)")], (0, 0, full)), + (false, false, 1, 5, MAX_DATABASE_BYTES) + ); + + let valid = valid_turn_blob(100, 1_785_700_000); + let hex = valid + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + let literal = format!("x'{hex}'"); + assert_eq!( + charge_rows(false, &[(0, literal.as_str())], (0, 0, 0)), + (true, false, 1, valid.len(), valid.len()) + ); +} From ef7c32bad4ce4f450146fb7f4ea24de380a135d7 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:03:14 +0700 Subject: [PATCH 30/40] Share Antigravity SQLite row checks and schema inspector (PBG-17) --- .../src/providers/antigravity/local_sqlite.rs | 214 +++++++----------- .../antigravity/local_sqlite_tests.rs | 15 +- 2 files changed, 91 insertions(+), 138 deletions(-) diff --git a/rust/src/providers/antigravity/local_sqlite.rs b/rust/src/providers/antigravity/local_sqlite.rs index e820538cb2f..e89a41728ca 100644 --- a/rust/src/providers/antigravity/local_sqlite.rs +++ b/rust/src/providers/antigravity/local_sqlite.rs @@ -46,6 +46,27 @@ enum DatabaseScan { Unsupported, } +impl DatabaseScan { + fn partial(events: Vec) -> Self { + Self::Supported { + events, + complete: false, + } + } +} + +/// Outcome of the per-row size checks shared by both table readers. +enum CheckedBlob<'r> { + Blob { + idx: i64, + bytes: &'r [u8], + }, + /// Unusable row: skip it and mark the read incomplete. + Invalid, + /// A byte limit ran out (already recorded on the budget): stop the read. + Exhausted, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum SchemaInspection { Supported, @@ -406,21 +427,15 @@ fn discover_databases(roots: &[PathBuf], budget: &mut Budget) -> (Vec, fn read_database(path: &Path, budget: &mut Budget) -> rusqlite::Result { if !budget.check() { - return Ok(DatabaseScan::Supported { - events: Vec::new(), - complete: false, - }); + return Ok(DatabaseScan::partial(Vec::new())); } let mut conn = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY)?; let tx = conn.transaction_with_behavior(TransactionBehavior::Deferred)?; - match supported_schema(&tx, budget)? { + match table_schema(&tx, budget, "gen_metadata", &["idx", "data"])? { SchemaInspection::Supported => {} SchemaInspection::Unsupported => return Ok(DatabaseScan::Unsupported), SchemaInspection::Incomplete => { - return Ok(DatabaseScan::Supported { - events: Vec::new(), - complete: false, - }); + return Ok(DatabaseScan::partial(Vec::new())); } } @@ -439,20 +454,14 @@ fn read_database(path: &Path, budget: &mut Budget) -> rusqlite::Result rusqlite::Result scan, Err(_) => { - return Ok(DatabaseScan::Supported { - events: rows.events, - complete: false, - }); + return Ok(DatabaseScan::partial(rows.events)); } }; if !step_scan.complete { - return Ok(DatabaseScan::Supported { - events: rows.events, - complete: false, - }); + return Ok(DatabaseScan::partial(rows.events)); } if !embedded_timestamps_agree(&rows.occurrences, &step_scan, &rows.bot_id_uses) { - return Ok(DatabaseScan::Supported { - events: rows.events, - complete: false, - }); + return Ok(DatabaseScan::partial(rows.events)); } let resolved = resolve_step_timestamps( @@ -551,46 +551,17 @@ fn read_generation_rows( break; } - let idx: i64 = match row.get(0) { - Ok(value) if value >= 0 => value, - _ => { + let (idx, blob) = match checked_blob(row, &mut database_bytes, budget)? { + CheckedBlob::Blob { idx, bytes } => (idx, bytes), + CheckedBlob::Invalid => { complete = false; continue; } - }; - let declared: Option = row.get(1).ok(); - let Some(declared) = declared.and_then(|value| usize::try_from(value).ok()) else { - complete = false; - continue; - }; - database_bytes = match database_bytes.checked_add(declared) { - Some(value) if value <= MAX_DATABASE_BYTES => value, - _ => { + CheckedBlob::Exhausted => { complete = false; - budget.exhausted = true; break; } }; - budget.bytes = match budget.bytes.checked_add(declared) { - Some(value) if value <= MAX_TOTAL_BYTES => value, - _ => { - complete = false; - budget.exhausted = true; - break; - } - }; - if declared == 0 || declared > MAX_BLOB_BYTES { - complete = false; - continue; - } - - let blob = match row.get_ref(2)? { - ValueRef::Blob(bytes) if bytes.len() == declared => bytes, - _ => { - complete = false; - continue; - } - }; let Some(turn) = parse_turn(blob) else { complete = false; continue; @@ -684,46 +655,17 @@ fn read_step_timestamps( break; } - let idx: i64 = match row.get(0) { - Ok(value) if value >= 0 => value, - _ => { + let (idx, blob) = match checked_blob(row, database_bytes, budget)? { + CheckedBlob::Blob { idx, bytes } => (idx, bytes), + CheckedBlob::Invalid => { rows_are_valid = false; continue; } - }; - let declared: Option = row.get(1).ok(); - let Some(declared) = declared.and_then(|value| usize::try_from(value).ok()) else { - rows_are_valid = false; - continue; - }; - *database_bytes = match (*database_bytes).checked_add(declared) { - Some(value) if value <= MAX_DATABASE_BYTES => value, - _ => { - complete = false; - budget.exhausted = true; - break; - } - }; - budget.bytes = match budget.bytes.checked_add(declared) { - Some(value) if value <= MAX_TOTAL_BYTES => value, - _ => { + CheckedBlob::Exhausted => { complete = false; - budget.exhausted = true; break; } }; - if declared == 0 || declared > MAX_BLOB_BYTES { - rows_are_valid = false; - continue; - } - - let blob = match row.get_ref(2)? { - ValueRef::Blob(bytes) if bytes.len() == declared => bytes, - _ => { - rows_are_valid = false; - continue; - } - }; let Some(metadata) = parse_step_metadata(blob) else { rows_are_valid = false; continue; @@ -763,44 +705,54 @@ fn read_step_timestamps( }) } -fn supported_schema(conn: &Connection, budget: &mut Budget) -> rusqlite::Result { - let mut statement = - conn.prepare("SELECT name, type, rootpage FROM main.sqlite_master LIMIT ?1")?; - let mut rows = statement.query([i64::try_from(MAX_SCHEMA_ENTRIES + 1).unwrap_or(i64::MAX)])?; - let mut found = false; - let mut schema_entries = 0usize; - while let Some(row) = rows.next()? { - if !budget.check() { - return Ok(SchemaInspection::Incomplete); - } - schema_entries += 1; - if schema_entries > MAX_SCHEMA_ENTRIES { - return Ok(SchemaInspection::Incomplete); - } - let name: String = row.get(0)?; - let kind: String = row.get(1)?; - if !budget.charge_schema_text(&name) || !budget.charge_schema_text(&kind) { - return Ok(SchemaInspection::Incomplete); - } - if !name.eq_ignore_ascii_case("gen_metadata") { - continue; - } - let rootpage: i64 = row.get(2)?; - if kind != "table" || rootpage <= 0 || found { - return Ok(SchemaInspection::Unsupported); - } - found = true; - } - if !found { - return Ok(SchemaInspection::Unsupported); +/// Size-check one `(idx, declared length, blob)` row. Rows with a bad index or +/// length are skipped before any bytes are charged; out-of-range lengths are +/// charged first, then rejected. +fn checked_blob<'r>( + row: &'r rusqlite::Row<'_>, + database_bytes: &mut usize, + budget: &mut Budget, +) -> rusqlite::Result> { + let idx: i64 = match row.get(0) { + Ok(value) if value >= 0 => value, + _ => return Ok(CheckedBlob::Invalid), + }; + let declared: Option = row.get(1).ok(); + let Some(declared) = declared.and_then(|value| usize::try_from(value).ok()) else { + return Ok(CheckedBlob::Invalid); + }; + let Some(next_database) = database_bytes + .checked_add(declared) + .filter(|value| *value <= MAX_DATABASE_BYTES) + else { + budget.exhausted = true; + return Ok(CheckedBlob::Exhausted); + }; + *database_bytes = next_database; + let Some(next_total) = budget + .bytes + .checked_add(declared) + .filter(|value| *value <= MAX_TOTAL_BYTES) + else { + budget.exhausted = true; + return Ok(CheckedBlob::Exhausted); + }; + budget.bytes = next_total; + if declared == 0 || declared > MAX_BLOB_BYTES { + return Ok(CheckedBlob::Invalid); } - - has_stored_columns(conn, "gen_metadata", &["idx", "data"], budget) + Ok(match row.get_ref(2)? { + ValueRef::Blob(bytes) if bytes.len() == declared => CheckedBlob::Blob { idx, bytes }, + _ => CheckedBlob::Invalid, + }) } -fn supported_steps_schema( +/// Exactly one real `table` in `main` with the `required` stored columns. +fn table_schema( conn: &Connection, budget: &mut Budget, + table: &str, + required: &[&str], ) -> rusqlite::Result { let mut statement = conn.prepare("SELECT name, type, rootpage FROM main.sqlite_master LIMIT ?1")?; @@ -820,7 +772,7 @@ fn supported_steps_schema( if !budget.charge_schema_text(&name) || !budget.charge_schema_text(&kind) { return Ok(SchemaInspection::Incomplete); } - if !name.eq_ignore_ascii_case("steps") { + if !name.eq_ignore_ascii_case(table) { continue; } let rootpage: i64 = row.get(2)?; @@ -833,7 +785,7 @@ fn supported_steps_schema( return Ok(SchemaInspection::Unsupported); } - has_stored_columns(conn, "steps", &["idx", "metadata"], budget) + has_stored_columns(conn, table, required, budget) } fn has_stored_columns( diff --git a/rust/src/providers/antigravity/local_sqlite_tests.rs b/rust/src/providers/antigravity/local_sqlite_tests.rs index 1181e8dcbfe..ca38f522efb 100644 --- a/rust/src/providers/antigravity/local_sqlite_tests.rs +++ b/rust/src/providers/antigravity/local_sqlite_tests.rs @@ -245,7 +245,7 @@ fn extra_columns_and_without_rowid_schema_is_supported() { .unwrap(); let mut budget = Budget::new(); assert_eq!( - supported_schema(&conn, &mut budget).unwrap(), + table_schema(&conn, &mut budget, "gen_metadata", &["idx", "data"]).unwrap(), SchemaInspection::Supported ); } @@ -260,7 +260,7 @@ fn generated_columns_are_rejected() { .unwrap(); let mut budget = Budget::new(); assert_eq!( - supported_schema(&conn, &mut budget).unwrap(), + table_schema(&conn, &mut budget, "gen_metadata", &["idx", "data"]).unwrap(), SchemaInspection::Unsupported ); } @@ -274,7 +274,7 @@ fn schema_entry_budget_is_incomplete_not_foreign() { } let mut budget = Budget::new(); assert_eq!( - supported_schema(&conn, &mut budget).unwrap(), + table_schema(&conn, &mut budget, "gen_metadata", &["idx", "data"]).unwrap(), SchemaInspection::Incomplete ); } @@ -471,10 +471,11 @@ fn inspect_schema( conn: &Connection, budget: &mut Budget, ) -> rusqlite::Result { - match table { - "gen_metadata" => supported_schema(conn, budget), - _ => supported_steps_schema(conn, budget), - } + let required: &[&str] = match table { + "gen_metadata" => &["idx", "data"], + _ => &["idx", "metadata"], + }; + table_schema(conn, budget, table, required) } #[test] From ff5bcf26616016762acb193d43e4ead253e0cd7d Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:07:11 +0700 Subject: [PATCH 31/40] Pin Antigravity IDE flag capture and port precedence --- rust/src/providers/antigravity/tests.rs | 32 +++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/rust/src/providers/antigravity/tests.rs b/rust/src/providers/antigravity/tests.rs index e248151f6d5..51f5ed018a3 100644 --- a/rust/src/providers/antigravity/tests.rs +++ b/rust/src/providers/antigravity/tests.rs @@ -111,6 +111,38 @@ fn parses_equals_form_args() { assert_eq!(process.source, ProcessSource::Ide); } +#[test] +fn ide_flags_capture_extension_token_and_prefer_the_extension_port() { + // (command line, extension token, port) + let cases = [ + ( + "1 ls.exe --extension_server_csrf_token ext-tok --csrf_token main --extension_server_port 54123 --https_server_port 61999", + Some("ext-tok"), + Some(54123), + ), + ( + "1 ls.exe --csrf_token=main --extension_server_port=abc --https_server_port=61999", + None, + Some(61999), + ), + ( + "1 ls.exe --csrf_token main --extension_server_port 70000", + None, + None, + ), + ]; + for (line, ext_token, port) in cases { + let process = AntigravityProvider::parse_process_info(line).expect(line); + assert_eq!(process.csrf_token, "main", "{line}"); + assert_eq!( + process.extension_server_csrf_token.as_deref(), + ext_token, + "{line}" + ); + assert_eq!(process.extension_port, port, "{line}"); + } +} + fn make_response(models: Vec<(&str, f64)>) -> UserStatusResponse { let json = serde_json::json!({ "userStatus": { From 1af81118d2f6f5e99d04061a7c641130e5601eff Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:10:42 +0700 Subject: [PATCH 32/40] Share Antigravity loopback client and user-status request (PBG-18) --- rust/src/providers/antigravity/mod.rs | 163 +++++++++++------------- rust/src/providers/antigravity/tests.rs | 43 +++++++ 2 files changed, 116 insertions(+), 90 deletions(-) diff --git a/rust/src/providers/antigravity/mod.rs b/rust/src/providers/antigravity/mod.rs index 8c6c3fb5776..e668ec988ef 100755 --- a/rust/src/providers/antigravity/mod.rs +++ b/rust/src/providers/antigravity/mod.rs @@ -106,6 +106,59 @@ fn flag_re(flag: &str) -> Regex { Regex::new(&format!("--{f}(?:\\s+|\\s*=\\s*)(\\S+)", f = flag)).expect("valid flag pattern") } +fn flag_value<'a>(re: &Regex, line: &'a str) -> Option<&'a str> { + re.captures(line).and_then(|c| c.get(1)).map(|m| m.as_str()) +} + +/// Client for the loopback language server. +/// +/// SECURITY: TLS verification is disabled because the local language server +/// uses a self-signed certificate; this client only talks to 127.0.0.1. It also +/// bypasses the app-wide outbound proxy. +fn loopback_client(timeout: std::time::Duration) -> Result { + crate::core::credentialed_http_client_builder() + .no_proxy() + .timeout(timeout) + .danger_accept_invalid_certs(true) + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|e| ProviderError::Other(e.to_string())) +} + +fn user_status_body() -> serde_json::Value { + serde_json::json!({ + "metadata": { + "ideName": "antigravity", + "extensionName": "antigravity", + "ideVersion": "unknown", + "locale": "en" + } + }) +} + +/// A Connect-protocol JSON POST to the local language server. +fn local_post( + client: &reqwest::Client, + url: &str, + body: &serde_json::Value, + timeout: std::time::Duration, +) -> reqwest::RequestBuilder { + client + .post(url) + .header("Content-Type", "application/json") + .header("Connect-Protocol-Version", "1") + .timeout(timeout) + .json(body) +} + +async fn response_bytes(response: reqwest::Response) -> Result, LiveFailure> { + response + .bytes() + .await + .map(|bytes| bytes.to_vec()) + .map_err(|e| LiveFailure::request("Failed to read response", &e)) +} + /// The kind of local Antigravity process a `ProcessInfo` was derived from. /// /// The desktop IDE/app language server authenticates local requests with a @@ -200,25 +253,12 @@ impl AntigravityProvider { None => (None, line), }; - let csrf_token = csrf_re - .captures(line) - .and_then(|c| c.get(1)) - .map(|m| m.as_str().to_string()); - - let ext_csrf_token = ext_csrf_re - .captures(line) - .and_then(|c| c.get(1)) - .map(|m| m.as_str().to_string()); - - let port = port_re - .captures(line) - .and_then(|c| c.get(1)) - .and_then(|m| m.as_str().parse::().ok()) + let csrf_token = flag_value(&csrf_re, line).map(str::to_string); + let ext_csrf_token = flag_value(&ext_csrf_re, line).map(str::to_string); + let port = flag_value(&port_re, line) + .and_then(|value| value.parse::().ok()) .or_else(|| { - https_port_re - .captures(line) - .and_then(|c| c.get(1)) - .and_then(|m| m.as_str().parse::().ok()) + flag_value(&https_port_re, line).and_then(|value| value.parse::().ok()) }); // Desktop IDE/app language server: requires --csrf_token. @@ -258,20 +298,9 @@ impl AntigravityProvider { // real gRPC/Connect API port is not guaranteed to be within a small window above it. // Mirror the macOS/Linux probe (which uses `lsof`) by enumerating the language-server // process's own listening ports first, then fall back to a heuristic window above the - // extension port and a few historically-seen ports. - // - // SECURITY: TLS verification is disabled because the local language server uses a - // self-signed certificate. This is scoped to 127.0.0.1 only; we confirm a port by - // checking that it answers the expected gRPC endpoint. - // The language server is a local loopback endpoint. Do not route it - // through the app-wide outbound proxy. - let client = crate::core::credentialed_http_client_builder() - .no_proxy() - .timeout(std::time::Duration::from_secs(2)) - .danger_accept_invalid_certs(true) - .redirect(reqwest::redirect::Policy::none()) - .build() - .map_err(|e| ProviderError::Other(e.to_string()))?; + // extension port and a few historically-seen ports. A port is confirmed only when it + // answers the expected gRPC endpoint. + let client = loopback_client(std::time::Duration::from_secs(2))?; // Ordered candidate ports: the process's real listening ports first (Windows // equivalent of `lsof`), then the heuristic window above the extension port, then a @@ -383,14 +412,7 @@ impl AntigravityProvider { process_info: &ProcessInfo, api_port: u16, ) -> Result { - // SECURITY: TLS verification disabled only for this loopback language server. - let client = crate::core::credentialed_http_client_builder() - .no_proxy() - .timeout(std::time::Duration::from_secs(8)) - .danger_accept_invalid_certs(true) - .redirect(reqwest::redirect::Policy::none()) - .build() - .map_err(|e| ProviderError::Other(e.to_string()))?; + let client = loopback_client(std::time::Duration::from_secs(8))?; let quota_body = serde_json::json!({ "forceRefresh": true }); match Self::fetch_local_payload( @@ -407,20 +429,12 @@ impl AntigravityProvider { Ok(mut snapshot) => { // Identity is best-effort enrichment and must not displace a // successful quota-summary result. - let identity_body = serde_json::json!({ - "metadata": { - "ideName": "antigravity", - "extensionName": "antigravity", - "ideVersion": "unknown", - "locale": "en" - } - }); if let Ok(identity_bytes) = Self::fetch_local_payload( &client, process_info, api_port, GET_USER_STATUS_PATH, - &identity_body, + &user_status_body(), std::time::Duration::from_secs(1), ) .await @@ -442,20 +456,12 @@ impl AntigravityProvider { ), } - let body = serde_json::json!({ - "metadata": { - "ideName": "antigravity", - "extensionName": "antigravity", - "ideVersion": "unknown", - "locale": "en" - } - }); let bytes = Self::fetch_local_payload( &client, process_info, api_port, GET_USER_STATUS_PATH, - &body, + &user_status_body(), std::time::Duration::from_secs(8), ) .await?; @@ -507,13 +513,7 @@ impl AntigravityProvider { let Some(binary) = cli_resolution::locate_agy_binary()? else { return Ok(ManagedAgyOutcome::Missing); }; - let probe_client = crate::core::credentialed_http_client_builder() - .no_proxy() - .timeout(AGY_PROBE_TIMEOUT) - .danger_accept_invalid_certs(true) - .redirect(reqwest::redirect::Policy::none()) - .build() - .map_err(|error| ProviderError::Other(error.to_string()))?; + let probe_client = loopback_client(AGY_PROBE_TIMEOUT)?; let config = ManagedProcessConfig { program: binary, args: Vec::new(), @@ -797,12 +797,7 @@ impl AntigravityProvider { .extension_server_csrf_token .as_deref() .unwrap_or(&process_info.csrf_token); - let mut request = client - .post(&url) - .header("Content-Type", "application/json") - .header("Connect-Protocol-Version", "1") - .timeout(timeout) - .json(body); + let mut request = local_post(client, &url, body, timeout); if requires_csrf { request = request.header("X-Codeium-Csrf-Token", csrf_token); } @@ -811,42 +806,30 @@ impl AntigravityProvider { .await .map_err(|e| LiveFailure::request("API request failed", &e))?; if response.status().is_success() { - return response - .bytes() - .await - .map(|bytes| bytes.to_vec()) - .map_err(|e| LiveFailure::request("Failed to read response", &e)); + return response_bytes(response).await; } let status = response.status(); let text = response.text().await.unwrap_or_default(); if requires_csrf && process_info.extension_server_csrf_token.is_some() { - let retry = client - .post(&url) - .header("Content-Type", "application/json") - .header("Connect-Protocol-Version", "1") + let retry = local_post(client, &url, body, timeout) .header("X-Codeium-Csrf-Token", &process_info.csrf_token) - .timeout(timeout) - .json(body) .send() .await; if let Ok(retry) = retry && retry.status().is_success() { - return retry - .bytes() - .await - .map(|bytes| bytes.to_vec()) - .map_err(|e| LiveFailure::request("Failed to read response", &e)); + return response_bytes(retry).await; } } + let lower = text.to_ascii_lowercase(); if process_info.source == ProcessSource::Cli && (status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN - || text.to_ascii_lowercase().contains("not logged") - || text.to_ascii_lowercase().contains("login method") - || text.to_ascii_lowercase().contains("keyring")) + || ["not logged", "login method", "keyring"] + .iter() + .any(|needle| lower.contains(needle))) { return Err(ProviderError::AuthRequired.into()); } diff --git a/rust/src/providers/antigravity/tests.rs b/rust/src/providers/antigravity/tests.rs index 51f5ed018a3..9d002fa0b97 100644 --- a/rust/src/providers/antigravity/tests.rs +++ b/rust/src/providers/antigravity/tests.rs @@ -111,6 +111,49 @@ fn parses_equals_form_args() { assert_eq!(process.source, ProcessSource::Ide); } +#[test] +fn local_post_sends_connect_json_with_the_request_timeout() { + let client = reqwest::Client::new(); + let request = local_post( + &client, + "https://127.0.0.1:1/exa.language_server_pb.LanguageServerService/GetUserStatus", + &user_status_body(), + std::time::Duration::from_secs(4), + ) + .header("X-Codeium-Csrf-Token", "tok") + .build() + .unwrap(); + + assert_eq!(request.method(), reqwest::Method::POST); + let headers: Vec<_> = request + .headers() + .iter() + .map(|(name, value)| (name.as_str(), value.to_str().unwrap())) + .collect(); + assert_eq!( + headers, + [ + ("content-type", "application/json"), + ("connect-protocol-version", "1"), + ("x-codeium-csrf-token", "tok"), + ] + ); + assert_eq!(request.timeout(), Some(&std::time::Duration::from_secs(4))); + let body: serde_json::Value = + serde_json::from_slice(request.body().unwrap().as_bytes().unwrap()).unwrap(); + assert_eq!( + body, + serde_json::json!({ + "metadata": { + "ideName": "antigravity", + "extensionName": "antigravity", + "ideVersion": "unknown", + "locale": "en" + } + }) + ); +} + #[test] fn ide_flags_capture_extension_token_and_prefer_the_extension_port() { // (command line, extension token, port) From 38518c1961f9a4cd5befe1ac7e26cecc6dae8cb8 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:15:09 +0700 Subject: [PATCH 33/40] Pin Antigravity group family titles and model window slugs --- .../providers/antigravity/quota_summary.rs | 35 ++++++++++++++++++ rust/src/providers/antigravity/tests.rs | 36 +++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/rust/src/providers/antigravity/quota_summary.rs b/rust/src/providers/antigravity/quota_summary.rs index 079f74a0294..3b7c8066e90 100644 --- a/rust/src/providers/antigravity/quota_summary.rs +++ b/rust/src/providers/antigravity/quota_summary.rs @@ -603,6 +603,41 @@ mod tests { ); } + #[test] + fn group_rank_title_and_scope_follow_the_group_family() { + // (displayName, name, rank, title, scope) + let cases = [ + (Some("Gemini Pro"), None, 0, "Gemini", "gemini"), + (None, Some("claude & GPT"), 1, "Claude/GPT", "claude-gpt"), + ( + Some("OpenAI gpt-oss"), + Some("Gemini"), + 1, + "Claude/GPT", + "claude-gpt", + ), + (Some(" "), Some("Gemini"), 2, "Quota", "quota"), + ( + Some(" Third Party! "), + None, + 2, + "Third Party!", + "third-party", + ), + (None, None, 2, "Quota", "quota"), + ]; + for (display_name, name, rank, title, scope) in cases { + let group: QuotaSummaryGroup = serde_json::from_value(serde_json::json!({ + "displayName": display_name, + "name": name, + })) + .unwrap(); + assert_eq!(group_rank(&group), rank, "{display_name:?} {name:?}"); + assert_eq!(group_title(&group), title, "{display_name:?} {name:?}"); + assert_eq!(group_scope(&group), scope, "{display_name:?} {name:?}"); + } + } + fn single_bucket_snapshot(bucket_id: &str, display_name: &str, window: &str) -> UsageSnapshot { let data = format!( r#"{{"groups":[{{"displayName":"Gemini Models","buckets":[ diff --git a/rust/src/providers/antigravity/tests.rs b/rust/src/providers/antigravity/tests.rs index 9d002fa0b97..828b5424347 100644 --- a/rust/src/providers/antigravity/tests.rs +++ b/rust/src/providers/antigravity/tests.rs @@ -111,6 +111,42 @@ fn parses_equals_form_args() { assert_eq!(process.source, ProcessSource::Ide); } +#[test] +fn unselected_model_window_ids_are_lowercase_ascii_slugs() { + let response: UserStatusResponse = serde_json::from_value(serde_json::json!({ + "userStatus": { + "cascadeModelConfigData": { + "clientModelConfigs": [ + {"label": "Claude Sonnet", "modelId": "claude-sonnet", "quotaInfo": {"remainingFraction": 0.5}}, + {"label": "Other One", "modelId": " GPT-OSS 120B (Medium) ", "quotaInfo": {"remainingFraction": 0.5}}, + {"label": "Other Two", "modelId": "!!!", "quotaInfo": {"remainingFraction": 0.5}}, + {"label": "Other Three", "id": "Mod\u{e8}le_A", "quotaInfo": {"remainingFraction": 0.5}}, + {"label": "Plain Label", "quotaInfo": {"remainingFraction": 0.5}} + ] + } + } + })) + .unwrap(); + let snap = AntigravityProvider::new() + .parse_user_status(response) + .unwrap(); + let mut ids: Vec<_> = snap + .extra_rate_windows + .iter() + .map(|window| window.id.as_str()) + .collect(); + ids.sort_unstable(); + assert_eq!( + ids, + [ + "model-gpt-oss-120b--medium", + "model-mod-le-a", + "model-plain-label", + "model-unknown", + ] + ); +} + #[test] fn local_post_sends_connect_json_with_the_request_timeout() { let client = reqwest::Client::new(); From efd9893ecf67ac64b92ed095f4c0ddd136c9d4af Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:22:05 +0700 Subject: [PATCH 34/40] Share Antigravity group family, slug, version probe and proto checks (PBG-46) --- .../src/providers/antigravity/cli_fallback.rs | 21 ++++---- .../providers/antigravity/legacy_status.rs | 18 +++---- .../src/providers/antigravity/local_bot_id.rs | 29 +++++------ .../providers/antigravity/local_history.rs | 10 +--- rust/src/providers/antigravity/local_proto.rs | 41 ++++++--------- .../antigravity/local_sessions_reader.rs | 2 +- rust/src/providers/antigravity/mod.rs | 15 +----- .../providers/antigravity/quota_summary.rs | 51 +++++++------------ rust/src/providers/antigravity/tests.rs | 43 +++++++--------- 9 files changed, 88 insertions(+), 142 deletions(-) diff --git a/rust/src/providers/antigravity/cli_fallback.rs b/rust/src/providers/antigravity/cli_fallback.rs index c4474bc8ca5..f2fbdaca80d 100644 --- a/rust/src/providers/antigravity/cli_fallback.rs +++ b/rust/src/providers/antigravity/cli_fallback.rs @@ -144,13 +144,15 @@ pub(super) async fn managed_spawn_is_csrf_gated(binary: Option) -> bool let Some(binary) = binary else { return false; }; - let Ok(version) = - run_cli_command(&binary, &VERSION_ARGS, VERSION_TIMEOUT, VERSION_TOO_LARGE).await - else { - return false; - }; - let version = String::from_utf8_lossy(&version); - is_csrf_gated_version(version.trim()) + agy_version(&binary, VERSION_TIMEOUT) + .await + .is_ok_and(|version| is_csrf_gated_version(&version)) +} + +/// `agy --version` output, trimmed. +async fn agy_version(binary: &Path, timeout: Duration) -> Result { + let version = run_cli_command(binary, &VERSION_ARGS, timeout, VERSION_TOO_LARGE).await?; + Ok(String::from_utf8_lossy(&version).trim().to_string()) } async fn fetch_print_usage(binary: &Path) -> Result { @@ -161,10 +163,7 @@ async fn fetch_print_usage_with_version_timeout( binary: &Path, version_timeout: Duration, ) -> Result { - let version = - run_cli_command(binary, &VERSION_ARGS, version_timeout, VERSION_TOO_LARGE).await?; - let version = String::from_utf8_lossy(&version); - if !is_supported_version(version.trim()) { + if !is_supported_version(&agy_version(binary, version_timeout).await?) { return Err(ProviderError::Parse( "Antigravity CLI usage reports require agy 1.1.11 or later".into(), ) diff --git a/rust/src/providers/antigravity/legacy_status.rs b/rust/src/providers/antigravity/legacy_status.rs index 852561a3995..a93366f1292 100644 --- a/rust/src/providers/antigravity/legacy_status.rs +++ b/rust/src/providers/antigravity/legacy_status.rs @@ -13,10 +13,6 @@ pub(crate) struct UserStatusResponse { #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub(crate) struct UserStatus { - #[allow( - dead_code, - reason = "field mirrors the Antigravity API user payload; deserialized for round-trip fidelity but not read yet" - )] email: Option, plan_status: Option, user_tier: Option, @@ -224,9 +220,14 @@ fn model_window_id(config: &ModelConfig) -> String { .as_deref() .or(config.id.as_deref()) .unwrap_or_else(|| model_label(config)); - let raw = canonical_model_id(raw); - let slug = raw - .chars() + let slug = slug(canonical_model_id(raw)); + format!("model-{}", if slug.is_empty() { "unknown" } else { &slug }) +} + +/// Lowercase ASCII alphanumerics; every other character becomes `-`, and +/// leading or trailing dashes are dropped. +pub(super) fn slug(raw: &str) -> String { + raw.chars() .map(|ch| { if ch.is_ascii_alphanumeric() { ch.to_ascii_lowercase() @@ -236,8 +237,7 @@ fn model_window_id(config: &ModelConfig) -> String { }) .collect::() .trim_matches('-') - .to_string(); - format!("model-{}", if slug.is_empty() { "unknown" } else { &slug }) + .to_string() } fn rate_window_from_quota(quota: &QuotaInfo) -> RateWindow { diff --git a/rust/src/providers/antigravity/local_bot_id.rs b/rust/src/providers/antigravity/local_bot_id.rs index ac732ee84b9..d9d9c26da2d 100644 --- a/rust/src/providers/antigravity/local_bot_id.rs +++ b/rust/src/providers/antigravity/local_bot_id.rs @@ -116,14 +116,7 @@ pub(super) fn append_recovered_events( continue; } if bot_id_uses.get(bot_id) == Some(&1) { - let mut turn = pending.turn.clone(); - turn.timestamp_ms = Some(exact.timestamp_ms); - events.push(Event { - session: session.to_string(), - row: pending.row, - turn, - total: pending.total, - }); + events.push(recovered_event(session, pending, exact.timestamp_ms)); recovered += 1; continue; } @@ -141,16 +134,20 @@ pub(super) fn append_recovered_events( let Some(timestamp_ms) = timestamps.get(*offset) else { continue; }; - let mut turn = pending.turn.clone(); - turn.timestamp_ms = Some(*timestamp_ms); - events.push(Event { - session: session.to_string(), - row: pending.row, - turn, - total: pending.total, - }); + events.push(recovered_event(session, pending, *timestamp_ms)); recovered += 1; } events.sort_by_key(|event| event.row); recovered } + +fn recovered_event(session: &str, pending: &PendingTimestampRow, timestamp_ms: i64) -> Event { + let mut turn = pending.turn.clone(); + turn.timestamp_ms = Some(timestamp_ms); + Event { + session: session.to_string(), + row: pending.row, + turn, + total: pending.total, + } +} diff --git a/rust/src/providers/antigravity/local_history.rs b/rust/src/providers/antigravity/local_history.rs index 5581c656182..3bcc6e712d4 100644 --- a/rust/src/providers/antigravity/local_history.rs +++ b/rust/src/providers/antigravity/local_history.rs @@ -9,13 +9,6 @@ use chrono::{DateTime, Utc}; use crate::spend_contract::{LocalHistoryCoverage, LocalTokenHistorySummary}; -fn clean_env_path(value: Option<&str>) -> Option { - value - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(PathBuf::from) -} - fn configured_database_roots(home: &Path) -> [PathBuf; 3] { let gemini_cli_home = std::env::var("GEMINI_CLI_HOME").ok(); configured_database_roots_from_values(home, gemini_cli_home.as_deref()) @@ -25,7 +18,8 @@ fn configured_database_roots_from_values( home: &Path, gemini_cli_home: Option<&str>, ) -> [PathBuf; 3] { - let gemini_base = clean_env_path(gemini_cli_home).unwrap_or_else(|| home.join(".gemini")); + let gemini_base = + local_sessions::clean_env_path(gemini_cli_home).unwrap_or_else(|| home.join(".gemini")); local_sqlite::database_roots(&gemini_base) } diff --git a/rust/src/providers/antigravity/local_proto.rs b/rust/src/providers/antigravity/local_proto.rs index 851b80a2fa5..251f692681e 100644 --- a/rust/src/providers/antigravity/local_proto.rs +++ b/rust/src/providers/antigravity/local_proto.rs @@ -127,11 +127,6 @@ fn identity_text(field: Field<'_>) -> Option> { Some((!value.trim().is_empty()).then(|| value.to_string())) } -fn auxiliary_text(field: Field<'_>) -> Result, ()> { - let value = std::str::from_utf8(message(field).ok_or(())?).map_err(|_| ())?; - Ok((!value.trim().is_empty()).then(|| value.to_string())) -} - #[derive(Debug, Default)] struct AuxiliaryIdentifier { value: Option, @@ -150,12 +145,9 @@ impl AuxiliaryIdentifier { if !self.valid { return; } - match auxiliary_text(field) { - Ok(value) => self.value = value, - Err(()) => { - self.value = None; - self.valid = false; - } + match identity_text(field) { + Some(value) => self.value = value, + None => self.invalidate(), } } @@ -165,11 +157,20 @@ impl AuxiliaryIdentifier { } } +/// Positive and no later than 9999-12-31T23:59:59Z. +fn valid_seconds(seconds: u64) -> bool { + seconds != 0 && seconds <= 253_402_300_799 +} + +fn valid_nanos(nanos: u64) -> bool { + nanos <= 999_999_999 +} + fn timestamp_millis(seconds: Option, nanos: u64) -> Option> { let Some(seconds) = seconds else { return Some(None); }; - if seconds == 0 || seconds > 253_402_300_799 || nanos > 999_999_999 { + if !valid_seconds(seconds) || !valid_nanos(nanos) { return None; } let seconds = i64::try_from(seconds).ok()?; @@ -302,20 +303,8 @@ fn parse_generation(bytes: &[u8], seconds: &mut Option, nanos: &mut u64) -> fn parse_timestamp_field(bytes: &[u8], seconds: &mut Option, nanos: &mut u64) -> Option<()> { fields(bytes, |stamp| { match stamp.number { - 1 => { - let value = integer(stamp)?; - if value == 0 || value > 253_402_300_799 { - return None; - } - *seconds = Some(value); - } - 2 => { - let value = integer(stamp)?; - if value > 999_999_999 { - return None; - } - *nanos = value; - } + 1 => *seconds = Some(integer(stamp).filter(|value| valid_seconds(*value))?), + 2 => *nanos = integer(stamp).filter(|value| valid_nanos(*value))?, _ => {} } Some(()) diff --git a/rust/src/providers/antigravity/local_sessions_reader.rs b/rust/src/providers/antigravity/local_sessions_reader.rs index 6b494f628ef..c9a82ee2961 100644 --- a/rust/src/providers/antigravity/local_sessions_reader.rs +++ b/rust/src/providers/antigravity/local_sessions_reader.rs @@ -37,7 +37,7 @@ pub(super) fn configured_tokscale_sessions(home: &Path) -> PathBuf { tokscale_sessions_from_values(home, tokscale.as_deref()) } -fn clean_env_path(value: Option<&str>) -> Option { +pub(super) fn clean_env_path(value: Option<&str>) -> Option { value .map(str::trim) .filter(|value| !value.is_empty()) diff --git a/rust/src/providers/antigravity/mod.rs b/rust/src/providers/antigravity/mod.rs index e668ec988ef..011dfd2f94f 100755 --- a/rust/src/providers/antigravity/mod.rs +++ b/rust/src/providers/antigravity/mod.rs @@ -17,7 +17,7 @@ mod local_step_resolver; mod offline_reason; mod quota_summary; -use legacy_status::{UserStatus, UserStatusResponse}; +use legacy_status::UserStatusResponse; use offline_reason::LiveFailure; #[cfg(windows)] @@ -467,7 +467,7 @@ impl AntigravityProvider { .await?; let response: UserStatusResponse = serde_json::from_slice(&bytes) .map_err(|e| ProviderError::Parse(format!("Failed to parse response: {e}")))?; - let usage = self.parse_user_status(response)?; + let usage = legacy_status::parse_user_status(response)?; Ok(Self::fetch_result(usage, AntigravityStrategyId::Local)) } @@ -838,17 +838,6 @@ impl AntigravityProvider { ProviderError::Other(format!("API error {status}: {text}")), )) } - - fn resolve_plan_name(status: &UserStatus) -> Option { - legacy_status::resolve_plan_name(status) - } - - fn parse_user_status( - &self, - response: UserStatusResponse, - ) -> Result { - legacy_status::parse_user_status(response) - } } #[async_trait] diff --git a/rust/src/providers/antigravity/quota_summary.rs b/rust/src/providers/antigravity/quota_summary.rs index 3b7c8066e90..b23ad03a228 100644 --- a/rust/src/providers/antigravity/quota_summary.rs +++ b/rust/src/providers/antigravity/quota_summary.rs @@ -2,6 +2,7 @@ use chrono::{DateTime, TimeZone, Utc}; use serde::Deserialize; use std::collections::{HashMap, HashSet}; +use super::legacy_status::slug; use crate::core::{NamedRateWindow, ProviderError, RateWindow, UsageSnapshot}; const WINDOW_ID_PREFIX: &str = "antigravity-quota-summary-"; @@ -287,49 +288,35 @@ fn most_constrained_named(windows: &[NamedRateWindow], minutes: u32) -> Option<& }) } -fn group_rank(group: &QuotaSummaryGroup) -> u8 { - let title = group - .display_name - .as_deref() - .or(group.name.as_deref()) - .unwrap_or_default() - .trim() - .to_ascii_lowercase(); - if title.contains("gemini") { +/// The group's trimmed name and its rank: 0 for Gemini, 1 for Claude/GPT, +/// 2 for everything else. +fn group_family(group: &QuotaSummaryGroup) -> (Option<&str>, u8) { + let title = non_empty(group.display_name.as_deref().or(group.name.as_deref())); + let lower = title.unwrap_or_default().to_ascii_lowercase(); + let rank = if lower.contains("gemini") { 0 - } else if title.contains("claude") || title.contains("gpt") { + } else if lower.contains("claude") || lower.contains("gpt") { 1 } else { 2 - } + }; + (title, rank) +} + +fn group_rank(group: &QuotaSummaryGroup) -> u8 { + group_family(group).1 } fn group_title(group: &QuotaSummaryGroup) -> String { - let title = - non_empty(group.display_name.as_deref().or(group.name.as_deref())).unwrap_or("Quota"); - let lower = title.to_ascii_lowercase(); - if lower.contains("gemini") { - "Gemini".into() - } else if lower.contains("claude") || lower.contains("gpt") { - "Claude/GPT".into() - } else { - title.to_string() + match group_family(group) { + (_, 0) => "Gemini".into(), + (_, 1) => "Claude/GPT".into(), + (title, _) => title.unwrap_or("Quota").to_string(), } } fn group_scope(group: &QuotaSummaryGroup) -> String { - group_title(group) - .chars() - .map(|character| { - if character.is_ascii_alphanumeric() { - character.to_ascii_lowercase() - } else { - '-' - } - }) - .collect::() - .trim_matches('-') - .to_string() + slug(&group_title(group)) } fn bucket_kind(bucket: &QuotaSummaryBucket) -> BucketKind { diff --git a/rust/src/providers/antigravity/tests.rs b/rust/src/providers/antigravity/tests.rs index 828b5424347..5befc48f5b7 100644 --- a/rust/src/providers/antigravity/tests.rs +++ b/rust/src/providers/antigravity/tests.rs @@ -1,4 +1,7 @@ -use super::legacy_status::{ModelFamily, canonical_model_id, classify_model}; +use super::legacy_status::{ + ModelFamily, UserStatus, canonical_model_id, classify_model, parse_user_status, + resolve_plan_name, +}; use super::*; use std::sync::{ Arc, @@ -127,9 +130,7 @@ fn unselected_model_window_ids_are_lowercase_ascii_slugs() { } })) .unwrap(); - let snap = AntigravityProvider::new() - .parse_user_status(response) - .unwrap(); + let snap = parse_user_status(response).unwrap(); let mut ids: Vec<_> = snap .extra_rate_windows .iter() @@ -267,7 +268,7 @@ fn antigravity_extra_windows_preserve_usage_known() { } }); let resp: UserStatusResponse = serde_json::from_value(json).unwrap(); - let snap = AntigravityProvider::new().parse_user_status(resp).unwrap(); + let snap = parse_user_status(resp).unwrap(); let claude = snap .extra_rate_windows .iter() @@ -284,8 +285,7 @@ fn test_parse_user_status_standard() { ("Gemini 2.5 Pro Low", 0.5), ("Gemini 2.5 Flash", 0.9), ]); - let provider = AntigravityProvider::new(); - let snap = provider.parse_user_status(resp).unwrap(); + let snap = parse_user_status(resp).unwrap(); assert!((snap.primary.used_percent - 20.0).abs() < 0.1); let sec = snap.secondary.unwrap(); @@ -303,8 +303,7 @@ fn antigravity_extra_windows_preserve_all_unselected_configs() { ("Mistral Large", 0.6), ("Qwen Max", 0.6), ]); - let provider = AntigravityProvider::new(); - let snap = provider.parse_user_status(resp).unwrap(); + let snap = parse_user_status(resp).unwrap(); assert_eq!(snap.extra_rate_windows.len(), 3); assert!( @@ -331,8 +330,7 @@ fn test_parse_user_status_thinking_skipped() { ("Claude 3.5 Sonnet", 0.7), ("Gemini 2.5 Flash", 0.5), ]); - let provider = AntigravityProvider::new(); - let snap = provider.parse_user_status(resp).unwrap(); + let snap = parse_user_status(resp).unwrap(); assert!((snap.primary.used_percent - 30.0).abs() < 0.1); } @@ -340,8 +338,7 @@ fn test_parse_user_status_thinking_skipped() { #[test] fn test_parse_user_status_fallback_first() { let resp = make_response(vec![("GPT-4o", 0.4), ("Mistral Large", 0.6)]); - let provider = AntigravityProvider::new(); - let snap = provider.parse_user_status(resp).unwrap(); + let snap = parse_user_status(resp).unwrap(); assert!((snap.primary.used_percent - 60.0).abs() < 0.1); assert!(snap.secondary.is_none()); @@ -358,8 +355,7 @@ fn test_noisy_models_do_not_drive_summary_windows() { ("Gemini 2.5 Pro Low", 0.6), ("Gemini 2.5 Flash", 0.7), ]); - let provider = AntigravityProvider::new(); - let snap = provider.parse_user_status(resp).unwrap(); + let snap = parse_user_status(resp).unwrap(); assert!((snap.primary.used_percent - 20.0).abs() < 0.1); assert!((snap.secondary.unwrap().used_percent - 40.0).abs() < 0.1); @@ -570,8 +566,7 @@ fn multiple_unselected_models_with_same_reading_remain_visible() { ("Mistral Large", 0.8), ("Qwen Max", 0.8), ]); - let provider = AntigravityProvider::new(); - let snap = provider.parse_user_status(resp).unwrap(); + let snap = parse_user_status(resp).unwrap(); assert_eq!( snap.extra_rate_windows.len(), 2, @@ -586,8 +581,7 @@ fn models_in_distinct_quota_buckets_keep_separate_lanes() { ("Claude 4 Sonnet", 0.7), ("Gemini 2.5 Pro Low", 0.5), ]); - let provider = AntigravityProvider::new(); - let snap = provider.parse_user_status(resp).unwrap(); + let snap = parse_user_status(resp).unwrap(); assert_eq!(snap.extra_rate_windows.len(), 1); } @@ -888,7 +882,7 @@ fn user_tier_resolves_google_ai_ultra_plan_name() { } }); let resp: UserStatusResponse = serde_json::from_value(json).unwrap(); - let snap = AntigravityProvider::new().parse_user_status(resp).unwrap(); + let snap = parse_user_status(resp).unwrap(); assert_eq!(snap.login_method.as_deref(), Some("Google AI Ultra")); assert_eq!(snap.account_email.as_deref(), Some("user@example.com")); } @@ -914,7 +908,7 @@ fn user_status_falls_back_to_plan_status_when_user_tier_is_absent() { } }); let resp: UserStatusResponse = serde_json::from_value(json).unwrap(); - let snap = AntigravityProvider::new().parse_user_status(resp).unwrap(); + let snap = parse_user_status(resp).unwrap(); assert_eq!(snap.login_method.as_deref(), Some("Pro")); } @@ -929,7 +923,7 @@ fn plan_name_fallback_skips_blank_user_tier_name() { .unwrap(); assert_eq!( - AntigravityProvider::resolve_plan_name(&status).as_deref(), + resolve_plan_name(&status).as_deref(), Some("Google AI Ultra") ); } @@ -946,8 +940,5 @@ fn plan_name_fallback_skips_blank_display_name() { })) .unwrap(); - assert_eq!( - AntigravityProvider::resolve_plan_name(&status).as_deref(), - Some("Pro") - ); + assert_eq!(resolve_plan_name(&status).as_deref(), Some("Pro")); } From 7d24f7a9e1359ded1e58c2eb4d713738b3322554 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:27:19 +0700 Subject: [PATCH 35/40] Share Antigravity protobuf test fixtures (PBG-57) --- rust/src/providers/antigravity/local_proto.rs | 30 ++++++++++++------- .../local_sqlite_synthetic_tests.rs | 29 +----------------- .../antigravity/local_sqlite_tests.rs | 29 +----------------- 3 files changed, 21 insertions(+), 67 deletions(-) diff --git a/rust/src/providers/antigravity/local_proto.rs b/rust/src/providers/antigravity/local_proto.rs index 251f692681e..cffe7893efd 100644 --- a/rust/src/providers/antigravity/local_proto.rs +++ b/rust/src/providers/antigravity/local_proto.rs @@ -311,9 +311,9 @@ fn parse_timestamp_field(bytes: &[u8], seconds: &mut Option, nanos: &mut u6 }) } +/// Protobuf encoders for building test blobs. #[cfg(test)] -mod tests { - use super::*; +pub(super) mod test_fixtures { fn varint(mut value: u64) -> Vec { let mut bytes = Vec::new(); loop { @@ -328,17 +328,25 @@ mod tests { } } } - fn field_varint(number: u64, value: u64) -> Vec { - let mut b = varint(number << 3); - b.extend(varint(value)); - b + + pub(crate) fn field_varint(number: u64, value: u64) -> Vec { + let mut bytes = varint(number << 3); + bytes.extend(varint(value)); + bytes } - fn field_bytes(number: u64, value: &[u8]) -> Vec { - let mut b = varint((number << 3) | 2); - b.extend(varint(value.len() as u64)); - b.extend(value); - b + + pub(crate) fn field_bytes(number: u64, value: &[u8]) -> Vec { + let mut bytes = varint((number << 3) | 2); + bytes.extend(varint(value.len() as u64)); + bytes.extend(value); + bytes } +} + +#[cfg(test)] +mod tests { + use super::test_fixtures::{field_bytes, field_varint}; + use super::*; #[test] fn decodes_generation_usage_and_timestamp() { let mut usage = Vec::new(); diff --git a/rust/src/providers/antigravity/local_sqlite_synthetic_tests.rs b/rust/src/providers/antigravity/local_sqlite_synthetic_tests.rs index 6259052d82f..334dc5e4ee9 100644 --- a/rust/src/providers/antigravity/local_sqlite_synthetic_tests.rs +++ b/rust/src/providers/antigravity/local_sqlite_synthetic_tests.rs @@ -6,6 +6,7 @@ use rusqlite::{Connection, params}; use tempfile::TempDir; use super::*; +use crate::providers::antigravity::local_proto::test_fixtures::{field_bytes, field_varint}; const NOW_SECONDS: u64 = 1_800_000_000; @@ -15,34 +16,6 @@ fn now() -> DateTime { .unwrap() } -fn varint(mut value: u64) -> Vec { - let mut bytes = Vec::new(); - loop { - let mut byte = (value & 0x7f) as u8; - value >>= 7; - if value != 0 { - byte |= 0x80; - } - bytes.push(byte); - if value == 0 { - return bytes; - } - } -} - -fn field_varint(number: u64, value: u64) -> Vec { - let mut bytes = varint(number << 3); - bytes.extend(varint(value)); - bytes -} - -fn field_bytes(number: u64, value: &[u8]) -> Vec { - let mut bytes = varint((number << 3) | 2); - bytes.extend(varint(value.len() as u64)); - bytes.extend(value); - bytes -} - fn turn_blob(step_uuid: Option<&str>, input: u64, timestamp: Option) -> Vec { turn_blob_with_bot_id(step_uuid, input, timestamp, None) } diff --git a/rust/src/providers/antigravity/local_sqlite_tests.rs b/rust/src/providers/antigravity/local_sqlite_tests.rs index ca38f522efb..af3efb28089 100644 --- a/rust/src/providers/antigravity/local_sqlite_tests.rs +++ b/rust/src/providers/antigravity/local_sqlite_tests.rs @@ -1,34 +1,7 @@ use super::*; +use crate::providers::antigravity::local_proto::test_fixtures::{field_bytes, field_varint}; use rusqlite::params; -fn varint(mut value: u64) -> Vec { - let mut bytes = Vec::new(); - loop { - let mut byte = (value & 0x7f) as u8; - value >>= 7; - if value != 0 { - byte |= 0x80; - } - bytes.push(byte); - if value == 0 { - return bytes; - } - } -} - -fn field_varint(number: u64, value: u64) -> Vec { - let mut bytes = varint(number << 3); - bytes.extend(varint(value)); - bytes -} - -fn field_bytes(number: u64, value: &[u8]) -> Vec { - let mut bytes = varint((number << 3) | 2); - bytes.extend(varint(value.len() as u64)); - bytes.extend(value); - bytes -} - fn valid_turn_blob(input: u64, timestamp_seconds: u64) -> Vec { valid_turn_blob_with_model(input, timestamp_seconds, None) } From 14315535ac3d73ab730aad23c28335dfd0b7c734 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:33:16 +0700 Subject: [PATCH 36/40] Share Antigravity SQLite test database helpers (PBG-58) --- .../local_sqlite_synthetic_tests.rs | 181 +++++----------- .../antigravity/local_sqlite_tests.rs | 205 +++++++----------- 2 files changed, 133 insertions(+), 253 deletions(-) diff --git a/rust/src/providers/antigravity/local_sqlite_synthetic_tests.rs b/rust/src/providers/antigravity/local_sqlite_synthetic_tests.rs index 334dc5e4ee9..71dcd1ada27 100644 --- a/rust/src/providers/antigravity/local_sqlite_synthetic_tests.rs +++ b/rust/src/providers/antigravity/local_sqlite_synthetic_tests.rs @@ -118,60 +118,60 @@ fn database( path } -fn summary(dir: &TempDir) -> LocalSessionSummary { +/// Build one session database in a fresh directory, scan it, and check the +/// coverage and token total. +fn expect_summary( + session: &str, + generation_rows: &[(i64, Vec)], + step_rows: Option>, + coverage: LocalHistoryCoverage, + total_tokens: u64, +) -> LocalSessionSummary { + let dir = tempfile::tempdir().unwrap(); + database(&dir, session, generation_rows, step_rows); let root = dir.path().join("conversations"); let SQLiteScan::Summary(summary) = summarize(&[root], now(), 30) else { panic!("database should be attempted"); }; + assert_eq!(summary.coverage, coverage, "{session}"); + assert_eq!(summary.total_tokens, total_tokens, "{session}"); summary } #[test] fn newer_step_timestamp_recovery_preserves_legacy_and_new_totals() { - let dir = tempfile::tempdir().unwrap(); let uuid = "new-step"; - database( - &dir, + let summary = expect_summary( "mixed", &[ (0, turn_blob(None, 100, Some(NOW_SECONDS - 120))), (1, turn_blob(Some(uuid), 200, None)), ], Some(&[(10, Some(step_metadata(Some(uuid), Some(NOW_SECONDS - 60))))]), + LocalHistoryCoverage::Complete, + 496, ); - - let summary = summary(&dir); - - assert_eq!(summary.coverage, LocalHistoryCoverage::Complete); - assert_eq!(summary.total_tokens, 496); assert_eq!(summary.session_count, 1); } #[test] fn absent_steps_table_withholds_newer_rows_but_keeps_embedded_totals() { - let dir = tempfile::tempdir().unwrap(); - database( - &dir, + expect_summary( "missing-table", &[ (0, turn_blob(None, 100, Some(NOW_SECONDS - 120))), (1, turn_blob(Some("missing-table"), 200, None)), ], None, + LocalHistoryCoverage::Partial, + 198, ); - - let summary = summary(&dir); - - assert_eq!(summary.coverage, LocalHistoryCoverage::Partial); - assert_eq!(summary.total_tokens, 198); } #[test] fn reused_step_uuid_follows_stored_idx_order() { - let dir = tempfile::tempdir().unwrap(); let uuid = "reused-step"; - database( - &dir, + expect_summary( "ordered", &[ (0, turn_blob(Some(uuid), 100, None)), @@ -181,32 +181,24 @@ fn reused_step_uuid_follows_stored_idx_order() { (20, Some(step_metadata(Some(uuid), Some(NOW_SECONDS - 60)))), (10, Some(step_metadata(Some(uuid), Some(NOW_SECONDS - 120)))), ]), + LocalHistoryCoverage::Complete, + 496, ); - - let summary = summary(&dir); - - assert_eq!(summary.coverage, LocalHistoryCoverage::Complete); - assert_eq!(summary.total_tokens, 496); } #[test] fn missing_lowest_step_timestamp_withholds_pending_tokens() { - let dir = tempfile::tempdir().unwrap(); let uuid = "missing-lowest"; - database( - &dir, + expect_summary( "missing", &[(0, turn_blob(Some(uuid), 100, None))], Some(&[ (10, Some(step_metadata(Some(uuid), None))), (20, Some(step_metadata(Some(uuid), Some(NOW_SECONDS - 60)))), ]), + LocalHistoryCoverage::Partial, + 0, ); - - let summary = summary(&dir); - - assert_eq!(summary.coverage, LocalHistoryCoverage::Partial); - assert_eq!(summary.total_tokens, 0); } #[test] @@ -230,35 +222,25 @@ fn duplicate_or_malformed_step_rows_fail_closed() { vec![(10, Some(malformed_step_metadata("malformed")))], ), ] { - let dir = tempfile::tempdir().unwrap(); - database( - &dir, + expect_summary( session, &[(0, turn_blob(Some(session), 100, None))], Some(&step_rows), + LocalHistoryCoverage::Partial, + 0, ); - - let summary = summary(&dir); - - assert_eq!(summary.coverage, LocalHistoryCoverage::Partial, "{session}"); - assert_eq!(summary.total_tokens, 0, "{session}"); } } #[test] fn null_step_rows_fail_closed() { - let dir = tempfile::tempdir().unwrap(); - database( - &dir, + expect_summary( "null-step", &[(0, turn_blob(Some("null-step"), 100, None))], Some(&[(10, None)]), + LocalHistoryCoverage::Partial, + 0, ); - - let summary = summary(&dir); - - assert_eq!(summary.coverage, LocalHistoryCoverage::Partial); - assert_eq!(summary.total_tokens, 0); } #[test] @@ -280,22 +262,13 @@ fn unidentified_step_rows_do_not_invalidate_timestamp_recovery() { ], ), ] { - let dir = tempfile::tempdir().unwrap(); - database( - &dir, + expect_summary( session, &[(0, turn_blob(Some(uuid), 100, None))], Some(&step_rows), - ); - - let summary = summary(&dir); - - assert_eq!( - summary.coverage, LocalHistoryCoverage::Complete, - "{session}" + 198, ); - assert_eq!(summary.total_tokens, 198, "{session}"); } } @@ -304,7 +277,6 @@ fn exact_bot_id_recovery_survives_an_unidentified_step_row() { let uuid = "exact-with-unidentified"; let bot_id = "exact-bot"; for unidentified_first in [false, true] { - let dir = tempfile::tempdir().unwrap(); let identified = ( 20, Some(step_metadata_with_bot_id( @@ -319,29 +291,23 @@ fn exact_bot_id_recovery_survives_an_unidentified_step_row() { } else { vec![identified, unidentified] }; - database( - &dir, + expect_summary( "exact-with-unidentified", &[( 0, turn_blob_with_bot_id(Some(uuid), 100, None, Some(bot_id)), )], Some(&step_rows), + LocalHistoryCoverage::Complete, + 198, ); - - let summary = summary(&dir); - - assert_eq!(summary.coverage, LocalHistoryCoverage::Complete); - assert_eq!(summary.total_tokens, 198); } } #[test] fn embedded_and_step_timestamps_must_agree() { - let dir = tempfile::tempdir().unwrap(); let uuid = "conflicting"; - database( - &dir, + expect_summary( "conflict", &[ (0, turn_blob(Some(uuid), 100, Some(NOW_SECONDS - 120))), @@ -351,20 +317,15 @@ fn embedded_and_step_timestamps_must_agree() { (10, Some(step_metadata(Some(uuid), Some(NOW_SECONDS - 60)))), (20, Some(step_metadata(Some(uuid), Some(NOW_SECONDS - 30)))), ]), + LocalHistoryCoverage::Partial, + 198, ); - - let summary = summary(&dir); - - assert_eq!(summary.coverage, LocalHistoryCoverage::Partial); - assert_eq!(summary.total_tokens, 198); } #[test] fn unique_bot_ids_recover_each_turn_despite_auxiliary_step_rows() { - let dir = tempfile::tempdir().unwrap(); let uuid = "per-turn"; - database( - &dir, + expect_summary( "bot-correlation", &[ ( @@ -402,19 +363,15 @@ fn unique_bot_ids_recover_each_turn_despite_auxiliary_step_rows() { )), ), ]), + LocalHistoryCoverage::Complete, + 496, ); - - let summary = summary(&dir); - - assert_eq!(summary.coverage, LocalHistoryCoverage::Complete); - assert_eq!(summary.total_tokens, 496); } #[test] fn reused_step_uuid_recovers_distinct_timestamps_with_an_unidentified_row() { let uuid = "reused-with-unidentified"; for unidentified_position in 0..=2 { - let dir = tempfile::tempdir().unwrap(); let mut step_rows = vec![ (10, Some(step_metadata(Some(uuid), Some(NOW_SECONDS - 120)))), (20, Some(step_metadata(Some(uuid), Some(NOW_SECONDS - 60)))), @@ -423,29 +380,23 @@ fn reused_step_uuid_recovers_distinct_timestamps_with_an_unidentified_row() { unidentified_position, (15, Some(step_metadata(None, Some(NOW_SECONDS - 300)))), ); - database( - &dir, + expect_summary( "reused-with-unidentified", &[ (0, turn_blob(Some(uuid), 100, None)), (1, turn_blob(Some(uuid), 200, None)), ], Some(&step_rows), + LocalHistoryCoverage::Complete, + 496, ); - - let summary = summary(&dir); - - assert_eq!(summary.coverage, LocalHistoryCoverage::Complete); - assert_eq!(summary.total_tokens, 496); } } #[test] fn reused_step_uuid_with_one_identified_row_and_an_unidentified_row_stays_partial() { - let dir = tempfile::tempdir().unwrap(); let uuid = "single-identified-with-unidentified"; - database( - &dir, + expect_summary( "single-identified-with-unidentified", &[ (0, turn_blob(Some(uuid), 100, None)), @@ -455,12 +406,9 @@ fn reused_step_uuid_with_one_identified_row_and_an_unidentified_row_stays_partia (10, Some(step_metadata(Some(uuid), Some(NOW_SECONDS - 120)))), (20, Some(step_metadata(None, Some(NOW_SECONDS - 60)))), ]), + LocalHistoryCoverage::Partial, + 0, ); - - let summary = summary(&dir); - - assert_eq!(summary.coverage, LocalHistoryCoverage::Partial); - assert_eq!(summary.total_tokens, 0); } #[test] @@ -469,7 +417,6 @@ fn unidentified_duplicate_bot_id_withholds_exact_and_positional_recovery() { let bot_id = "shared-bot"; for unidentified_first in [false, true] { for unidentified_timestamp in [Some(NOW_SECONDS - 60), None] { - let dir = tempfile::tempdir().unwrap(); let identified = ( 10, Some(step_metadata_with_bot_id( @@ -491,30 +438,24 @@ fn unidentified_duplicate_bot_id_withholds_exact_and_positional_recovery() { } else { vec![identified, unidentified] }; - database( - &dir, + expect_summary( "duplicate-bot-with-unidentified", &[( 0, turn_blob_with_bot_id(Some(uuid), 100, None, Some(bot_id)), )], Some(&step_rows), + LocalHistoryCoverage::Partial, + 0, ); - - let summary = summary(&dir); - - assert_eq!(summary.coverage, LocalHistoryCoverage::Partial); - assert_eq!(summary.total_tokens, 0); } } } #[test] fn conflicting_bot_id_evidence_is_withheld() { - let dir = tempfile::tempdir().unwrap(); let uuid = "ambiguous-bot"; - database( - &dir, + expect_summary( "ambiguous-bot", &[( (0), @@ -538,19 +479,14 @@ fn conflicting_bot_id_evidence_is_withheld() { )), ), ]), + LocalHistoryCoverage::Partial, + 0, ); - - let summary = summary(&dir); - - assert_eq!(summary.coverage, LocalHistoryCoverage::Partial); - assert_eq!(summary.total_tokens, 0); } #[test] fn cross_step_bot_id_evidence_is_withheld() { - let dir = tempfile::tempdir().unwrap(); - database( - &dir, + expect_summary( "cross-step-bot", &[( (0), @@ -574,10 +510,7 @@ fn cross_step_bot_id_evidence_is_withheld() { )), ), ]), + LocalHistoryCoverage::Partial, + 0, ); - - let summary = summary(&dir); - - assert_eq!(summary.coverage, LocalHistoryCoverage::Partial); - assert_eq!(summary.total_tokens, 0); } diff --git a/rust/src/providers/antigravity/local_sqlite_tests.rs b/rust/src/providers/antigravity/local_sqlite_tests.rs index af3efb28089..ffdc79b747f 100644 --- a/rust/src/providers/antigravity/local_sqlite_tests.rs +++ b/rust/src/providers/antigravity/local_sqlite_tests.rs @@ -31,6 +31,38 @@ fn zero_token_turn_blob(timestamp_seconds: u64, model: &str) -> Vec { field_bytes(1, &chat) } +/// The CLI conversations directory under `dir`. +fn cli_root(dir: &tempfile::TempDir) -> PathBuf { + dir.path().join(".gemini/antigravity-cli/conversations") +} + +/// Open `root/name`, creating `root`, with an empty `gen_metadata` table. +fn database_at(root: &Path, name: &str) -> Connection { + fs::create_dir_all(root).unwrap(); + let conn = Connection::open(root.join(name)).unwrap(); + conn.execute("CREATE TABLE gen_metadata(idx INTEGER, data BLOB)", []) + .unwrap(); + conn +} + +fn insert_row(conn: &Connection, idx: i64, data: impl rusqlite::ToSql) { + conn.execute( + "INSERT INTO gen_metadata(idx, data) VALUES(?1, ?2)", + params![idx, data], + ) + .unwrap(); +} + +/// Scan the default `.gemini` roots under `dir`. +fn expect_summary(dir: &tempfile::TempDir) -> LocalSessionSummary { + let SQLiteScan::Summary(summary) = + summarize(&database_roots(&dir.path().join(".gemini")), Utc::now(), 30) + else { + panic!("supported database should produce coverage"); + }; + summary +} + #[test] fn missing_databases_falls_through() { let dir = tempfile::tempdir().unwrap(); @@ -43,7 +75,7 @@ fn missing_databases_falls_through() { #[test] fn foreign_database_is_non_authoritative() { let dir = tempfile::tempdir().unwrap(); - let root = dir.path().join(".gemini/antigravity-cli/conversations"); + let root = cli_root(&dir); fs::create_dir_all(&root).unwrap(); let conn = Connection::open(root.join("one.db")).unwrap(); conn.execute("CREATE TABLE wrong(idx INTEGER, data BLOB)", []) @@ -58,17 +90,8 @@ fn foreign_database_is_non_authoritative() { #[test] fn empty_supported_database_is_confirmed_zero() { let dir = tempfile::tempdir().unwrap(); - let root = dir.path().join(".gemini/antigravity-cli/conversations"); - fs::create_dir_all(&root).unwrap(); - let conn = Connection::open(root.join("one.db")).unwrap(); - conn.execute("CREATE TABLE gen_metadata(idx INTEGER, data BLOB)", []) - .unwrap(); - drop(conn); - let SQLiteScan::Summary(summary) = - summarize(&database_roots(&dir.path().join(".gemini")), Utc::now(), 30) - else { - panic!("supported database should produce coverage"); - }; + drop(database_at(&cli_root(&dir), "one.db")); + let summary = expect_summary(&dir); assert_eq!(summary.coverage, LocalHistoryCoverage::Complete); assert_eq!(summary.total_tokens, 0); assert_eq!(summary.session_count, 0); @@ -79,19 +102,11 @@ fn same_named_databases_in_separate_roots_keep_distinct_rows_and_sessions() { let dir = tempfile::tempdir().unwrap(); let first_root = dir.path().join("first"); let second_root = dir.path().join("second"); - fs::create_dir_all(&first_root).unwrap(); - fs::create_dir_all(&second_root).unwrap(); let timestamp = u64::try_from(Utc::now().timestamp()).unwrap(); for (root, input) in [(&first_root, 100_u64), (&second_root, 200_u64)] { - let conn = Connection::open(root.join("session.db")).unwrap(); - conn.execute("CREATE TABLE gen_metadata(idx INTEGER, data BLOB)", []) - .unwrap(); - conn.execute( - "INSERT INTO gen_metadata(idx, data) VALUES(1, ?1)", - [valid_turn_blob(input, timestamp)], - ) - .unwrap(); + let conn = database_at(root, "session.db"); + insert_row(&conn, 1, valid_turn_blob(input, timestamp)); } let SQLiteScan::Summary(summary) = summarize(&[first_root, second_root], Utc::now(), 30) else { @@ -106,34 +121,18 @@ fn same_named_databases_in_separate_roots_keep_distinct_rows_and_sessions() { #[test] fn zero_token_unknown_model_does_not_poison_priced_history() { let dir = tempfile::tempdir().unwrap(); - let root = dir.path().join(".gemini/antigravity-cli/conversations"); - fs::create_dir_all(&root).unwrap(); + let root = cli_root(&dir); let now = Utc::now(); let timestamp = u64::try_from(now.timestamp()).unwrap(); - let priced = Connection::open(root.join("priced.db")).unwrap(); - priced - .execute("CREATE TABLE gen_metadata(idx INTEGER, data BLOB)", []) - .unwrap(); - priced - .execute( - "INSERT INTO gen_metadata(idx, data) VALUES(1, ?1)", - [valid_turn_blob_with_model( - 100, - timestamp, - Some("claude-sonnet-4-6"), - )], - ) - .unwrap(); - - let zero = Connection::open(root.join("zero.db")).unwrap(); - zero.execute("CREATE TABLE gen_metadata(idx INTEGER, data BLOB)", []) - .unwrap(); - zero.execute( - "INSERT INTO gen_metadata(idx, data) VALUES(1, ?1)", - [zero_token_turn_blob(timestamp, "unknown-model")], - ) - .unwrap(); + let priced = database_at(&root, "priced.db"); + insert_row( + &priced, + 1, + valid_turn_blob_with_model(100, timestamp, Some("claude-sonnet-4-6")), + ); + let zero = database_at(&root, "zero.db"); + insert_row(&zero, 1, zero_token_turn_blob(timestamp, "unknown-model")); let SQLiteScan::Summary(summary) = summarize(&[root], now, 30) else { panic!("supported databases should produce coverage"); @@ -159,23 +158,10 @@ fn zero_token_unknown_model_does_not_poison_priced_history() { #[test] fn non_blob_rows_make_coverage_partial() { let dir = tempfile::tempdir().unwrap(); - let root = dir.path().join(".gemini/antigravity-cli/conversations"); - fs::create_dir_all(&root).unwrap(); - let conn = Connection::open(root.join("one.db")).unwrap(); - conn.execute("CREATE TABLE gen_metadata(idx INTEGER, data BLOB)", []) - .unwrap(); - conn.execute( - "INSERT INTO gen_metadata(idx,data) VALUES(?1,?2)", - params![1_i64, "not-a-blob"], - ) - .unwrap(); + let conn = database_at(&cli_root(&dir), "one.db"); + insert_row(&conn, 1, "not-a-blob"); drop(conn); - let SQLiteScan::Summary(summary) = - summarize(&database_roots(&dir.path().join(".gemini")), Utc::now(), 30) - else { - panic!("supported database should produce coverage"); - }; - assert_eq!(summary.coverage, LocalHistoryCoverage::Partial); + assert_eq!(expect_summary(&dir).coverage, LocalHistoryCoverage::Partial); } #[test] fn discovery_allows_exactly_500_databases_but_marks_501_partial() { @@ -255,29 +241,13 @@ fn schema_entry_budget_is_incomplete_not_foreign() { #[test] fn undecodable_row_beside_valid_rows_yields_a_lower_bound() { let dir = tempfile::tempdir().unwrap(); - let root = dir.path().join(".gemini/antigravity-cli/conversations"); - fs::create_dir_all(&root).unwrap(); let timestamp = u64::try_from(Utc::now().timestamp()).unwrap(); - let conn = Connection::open(root.join("one.db")).unwrap(); - conn.execute("CREATE TABLE gen_metadata(idx INTEGER, data BLOB)", []) - .unwrap(); - conn.execute( - "INSERT INTO gen_metadata(idx, data) VALUES(1, ?1)", - [valid_turn_blob(100, timestamp)], - ) - .unwrap(); - conn.execute( - "INSERT INTO gen_metadata(idx, data) VALUES(2, ?1)", - params!["not-a-blob"], - ) - .unwrap(); + let conn = database_at(&cli_root(&dir), "one.db"); + insert_row(&conn, 1, valid_turn_blob(100, timestamp)); + insert_row(&conn, 2, "not-a-blob"); drop(conn); - let SQLiteScan::Summary(summary) = - summarize(&database_roots(&dir.path().join(".gemini")), Utc::now(), 30) - else { - panic!("supported database should produce coverage"); - }; + let summary = expect_summary(&dir); assert_eq!(summary.coverage, LocalHistoryCoverage::Partial); assert!(summary.total_tokens > 0); @@ -289,26 +259,14 @@ fn undecodable_row_beside_valid_rows_yields_a_lower_bound() { #[test] fn contradicting_rows_for_one_index_are_withheld_not_published() { let dir = tempfile::tempdir().unwrap(); - let root = dir.path().join(".gemini/antigravity-cli/conversations"); - fs::create_dir_all(&root).unwrap(); let timestamp = u64::try_from(Utc::now().timestamp()).unwrap(); - let conn = Connection::open(root.join("one.db")).unwrap(); - conn.execute("CREATE TABLE gen_metadata(idx INTEGER, data BLOB)", []) - .unwrap(); + let conn = database_at(&cli_root(&dir), "one.db"); for input in [100_u64, 900_u64] { - conn.execute( - "INSERT INTO gen_metadata(idx, data) VALUES(1, ?1)", - [valid_turn_blob(input, timestamp)], - ) - .unwrap(); + insert_row(&conn, 1, valid_turn_blob(input, timestamp)); } drop(conn); - let SQLiteScan::Summary(summary) = - summarize(&database_roots(&dir.path().join(".gemini")), Utc::now(), 30) - else { - panic!("supported database should produce coverage"); - }; + let summary = expect_summary(&dir); assert_eq!(summary.coverage, LocalHistoryCoverage::Partial); assert_eq!(summary.total_tokens, 0); @@ -321,22 +279,19 @@ fn list_price_uses_prompt_plus_input_and_output_plus_reasoning() { // Upstream AntigravityLocalReaderTests: a known model gets a list-price estimate, a routing // variant prices from its base model, and an unknown model stays unpriced. let dir = tempfile::tempdir().unwrap(); - let root = dir.path().join(".gemini/antigravity-cli/conversations"); - fs::create_dir_all(&root).unwrap(); + let root = cli_root(&dir); let now = Utc::now(); let timestamp = u64::try_from(now.timestamp()).unwrap(); for (name, model) in [ ("direct.db", "claude-sonnet-4-6"), ("routed.db", "claude-sonnet-4-6-Thinking"), ] { - let conn = Connection::open(root.join(name)).unwrap(); - conn.execute("CREATE TABLE gen_metadata(idx INTEGER, data BLOB)", []) - .unwrap(); - conn.execute( - "INSERT INTO gen_metadata(idx, data) VALUES(1, ?1)", - [valid_turn_blob_with_model(100, timestamp, Some(model))], - ) - .unwrap(); + let conn = database_at(&root, name); + insert_row( + &conn, + 1, + valid_turn_blob_with_model(100, timestamp, Some(model)), + ); } let SQLiteScan::Summary(summary) = summarize(std::slice::from_ref(&root), now, 30) else { @@ -351,18 +306,12 @@ fn list_price_uses_prompt_plus_input_and_output_plus_reasoning() { assert_eq!(summary.cost_estimate.coverage.unpriced, 0); assert_eq!(summary.total_usd(), Some(per_request * 2.0)); - let conn = Connection::open(root.join("unknown.db")).unwrap(); - conn.execute("CREATE TABLE gen_metadata(idx INTEGER, data BLOB)", []) - .unwrap(); - conn.execute( - "INSERT INTO gen_metadata(idx, data) VALUES(1, ?1)", - [valid_turn_blob_with_model( - 100, - timestamp, - Some("fixture-unpriced"), - )], - ) - .unwrap(); + let conn = database_at(&root, "unknown.db"); + insert_row( + &conn, + 1, + valid_turn_blob_with_model(100, timestamp, Some("fixture-unpriced")), + ); drop(conn); let SQLiteScan::Summary(summary) = summarize(std::slice::from_ref(&root), now, 30) else { @@ -399,18 +348,16 @@ fn routine_read_offers_background_pricing_only_for_unpriced_history() { // rows; `Some(Some(model))`: one recorded request for `model`. let routine_read = |database: Option>| { let dir = tempfile::tempdir().unwrap(); - let root = dir.path().join(".gemini/antigravity-cli/conversations"); + let root = cli_root(&dir); fs::create_dir_all(&root).unwrap(); if let Some(model) = database { - let conn = Connection::open(root.join("one.db")).unwrap(); - conn.execute("CREATE TABLE gen_metadata(idx INTEGER, data BLOB)", []) - .unwrap(); + let conn = database_at(&root, "one.db"); if let Some(model) = model { - conn.execute( - "INSERT INTO gen_metadata(idx, data) VALUES(1, ?1)", - [valid_turn_blob_with_model(100, timestamp, Some(model))], - ) - .unwrap(); + insert_row( + &conn, + 1, + valid_turn_blob_with_model(100, timestamp, Some(model)), + ); } } match summarize(&[root], now, 30) { From 8c1d7ecd43fc458d53862f742531cdd57a873473 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:46:24 +0700 Subject: [PATCH 37/40] Table-drive Antigravity process, user-status and fallback tests (PBG-59) --- rust/src/providers/antigravity/tests.rs | 841 +++++++++++------------- 1 file changed, 393 insertions(+), 448 deletions(-) diff --git a/rust/src/providers/antigravity/tests.rs b/rust/src/providers/antigravity/tests.rs index 5befc48f5b7..206387211b0 100644 --- a/rust/src/providers/antigravity/tests.rs +++ b/rust/src/providers/antigravity/tests.rs @@ -30,24 +30,22 @@ fn explicit_quota_summary_cadence_label_survives_normalization() { #[test] fn test_classify_model_families() { - assert_eq!(classify_model("Claude 3.5 Sonnet"), ModelFamily::Claude); - assert_eq!(classify_model("claude-4-opus"), ModelFamily::Claude); - assert_eq!( - classify_model("Claude Thinking"), - ModelFamily::ClaudeThinking - ); - assert_eq!( - classify_model("claude-3.5-sonnet-thinking"), - ModelFamily::ClaudeThinking - ); - assert_eq!(classify_model("Gemini 2.5 Pro Low"), ModelFamily::GeminiPro); - assert_eq!(classify_model("gemini-pro-low"), ModelFamily::GeminiPro); - assert_eq!(classify_model("Pro Low Latency"), ModelFamily::GeminiPro); - assert_eq!(classify_model("Gemini 2.5 Flash"), ModelFamily::GeminiFlash); - assert_eq!(classify_model("gemini-flash"), ModelFamily::GeminiFlash); - assert_eq!(classify_model("Flash Model"), ModelFamily::GeminiFlash); - assert_eq!(classify_model("GPT-4o"), ModelFamily::Other); - assert_eq!(classify_model("unknown-model"), ModelFamily::Other); + for (label, family) in [ + ("Claude 3.5 Sonnet", ModelFamily::Claude), + ("claude-4-opus", ModelFamily::Claude), + ("Claude Thinking", ModelFamily::ClaudeThinking), + ("claude-3.5-sonnet-thinking", ModelFamily::ClaudeThinking), + ("Gemini 2.5 Pro Low", ModelFamily::GeminiPro), + ("gemini-pro-low", ModelFamily::GeminiPro), + ("Pro Low Latency", ModelFamily::GeminiPro), + ("Gemini 2.5 Flash", ModelFamily::GeminiFlash), + ("gemini-flash", ModelFamily::GeminiFlash), + ("Flash Model", ModelFamily::GeminiFlash), + ("GPT-4o", ModelFamily::Other), + ("unknown-model", ModelFamily::Other), + ] { + assert_eq!(classify_model(label), family, "{label}"); + } } #[test] @@ -63,57 +61,6 @@ fn retired_flash_ids_collapse_to_current_wire_id() { assert_eq!(canonical_model_id("gemini-3.7-flash"), "gemini-3.7-flash"); } -#[test] -fn parses_current_language_server_process() { - let output = r"4242 C:\Users\test\AppData\Local\Programs\Antigravity\resources\bin\language_server.exe --csrf_token 11111111-2222-3333-4444-555555555555 --extension_server_port 54123"; - - let process = AntigravityProvider::parse_process_info(output).expect("process info"); - - assert_eq!(process.pid, Some(4242)); - assert_eq!(process.extension_port, Some(54123)); - assert_eq!(process.csrf_token, "11111111-2222-3333-4444-555555555555"); - assert_eq!(process.source, ProcessSource::Ide); -} - -#[test] -fn parses_language_server_without_extension_server_port() { - let output = "34564\tC:\\Users\\test\\AppData\\Local\\Programs\\Antigravity\\resources\\bin\\language_server.exe --standalone --override_ide_name antigravity --subclient_type hub --override_ide_version 2.0.11 --https_server_port 0 --csrf_token 68dda2fb-6b26-40c0-aeef-b9a628615714 --app_data_dir antigravity"; - - let process = - AntigravityProvider::parse_process_info(output).expect("process info should be detected"); - - assert_eq!(process.pid, Some(34564)); - assert_eq!(process.extension_port, Some(0)); - assert_eq!(process.csrf_token, "68dda2fb-6b26-40c0-aeef-b9a628615714"); - assert_eq!(process.source, ProcessSource::Ide); -} - -#[test] -fn parses_language_server_without_any_port_arg() { - let output = "34564\tC:\\Users\\test\\AppData\\Local\\Programs\\Antigravity\\resources\\bin\\language_server.exe --standalone --csrf_token aabbccdd-1122-3344-5566-778899001122 --app_data_dir antigravity"; - - let process = - AntigravityProvider::parse_process_info(output).expect("process info should be detected"); - - assert_eq!(process.pid, Some(34564)); - assert_eq!(process.extension_port, None); - assert_eq!(process.csrf_token, "aabbccdd-1122-3344-5566-778899001122"); - assert_eq!(process.source, ProcessSource::Ide); -} - -#[test] -fn parses_equals_form_args() { - let output = "34564\tC:\\Users\\test\\AppData\\Local\\Programs\\Antigravity\\resources\\bin\\language_server.exe --csrf_token=68dda2fb-6b26-40c0-aeef-b9a628615714 --https_server_port=61999"; - - let process = - AntigravityProvider::parse_process_info(output).expect("process info should be detected"); - - assert_eq!(process.pid, Some(34564)); - assert_eq!(process.extension_port, Some(61999)); - assert_eq!(process.csrf_token, "68dda2fb-6b26-40c0-aeef-b9a628615714"); - assert_eq!(process.source, ProcessSource::Ide); -} - #[test] fn unselected_model_window_ids_are_lowercase_ascii_slugs() { let response: UserStatusResponse = serde_json::from_value(serde_json::json!({ @@ -278,93 +225,135 @@ fn antigravity_extra_windows_preserve_usage_known() { assert_eq!(claude.window.used_percent, 0.0); } -#[test] -fn test_parse_user_status_standard() { - let resp = make_response(vec![ - ("Claude 3.5 Sonnet", 0.8), - ("Gemini 2.5 Pro Low", 0.5), - ("Gemini 2.5 Flash", 0.9), - ]); - let snap = parse_user_status(resp).unwrap(); - - assert!((snap.primary.used_percent - 20.0).abs() < 0.1); - let sec = snap.secondary.unwrap(); - assert!((sec.used_percent - 50.0).abs() < 0.1); - let ter = snap.model_specific.unwrap(); - assert!((ter.used_percent - 10.0).abs() < 0.1); - assert!(snap.extra_rate_windows.is_empty()); -} +/// (models, primary, secondary, model-specific used %, sorted extra titles) +type WindowCase<'a> = ( + &'a [(&'a str, f64)], + f64, + Option, + Option, + &'a [&'a str], +); #[test] -fn antigravity_extra_windows_preserve_all_unselected_configs() { - let resp = make_response(vec![ - ("Claude 4 Sonnet", 0.8), - ("GPT-4o", 0.8), - ("Mistral Large", 0.6), - ("Qwen Max", 0.6), - ]); - let snap = parse_user_status(resp).unwrap(); - - assert_eq!(snap.extra_rate_windows.len(), 3); - assert!( - snap.extra_rate_windows - .iter() - .any(|window| window.title == "GPT-4o") - ); - assert!( - snap.extra_rate_windows - .iter() - .any(|window| window.title == "Mistral Large") - ); - assert!( - snap.extra_rate_windows - .iter() - .any(|window| window.title == "Qwen Max") - ); -} - -#[test] -fn test_parse_user_status_thinking_skipped() { - let resp = make_response(vec![ - ("Claude Thinking", 0.6), - ("Claude 3.5 Sonnet", 0.7), - ("Gemini 2.5 Flash", 0.5), - ]); - let snap = parse_user_status(resp).unwrap(); - - assert!((snap.primary.used_percent - 30.0).abs() < 0.1); -} - -#[test] -fn test_parse_user_status_fallback_first() { - let resp = make_response(vec![("GPT-4o", 0.4), ("Mistral Large", 0.6)]); - let snap = parse_user_status(resp).unwrap(); - - assert!((snap.primary.used_percent - 60.0).abs() < 0.1); - assert!(snap.secondary.is_none()); - assert!(snap.model_specific.is_none()); -} - -#[test] -fn test_noisy_models_do_not_drive_summary_windows() { - let resp = make_response(vec![ - ("Gemini 2.5 Flash Image", 0.01), - ("Gemini 2.5 Pro Lite", 0.02), - ("Gemini autocomplete internal", 0.03), - ("Claude 4 Sonnet", 0.8), - ("Gemini 2.5 Pro Low", 0.6), - ("Gemini 2.5 Flash", 0.7), - ]); - let snap = parse_user_status(resp).unwrap(); - - assert!((snap.primary.used_percent - 20.0).abs() < 0.1); - assert!((snap.secondary.unwrap().used_percent - 40.0).abs() < 0.1); - assert!((snap.model_specific.unwrap().used_percent - 30.0).abs() < 0.1); - assert!( - snap.extra_rate_windows +fn parse_user_status_selects_summary_windows_and_keeps_the_rest() { + let cases: [WindowCase<'_>; 7] = [ + ( + &[ + ("Claude 3.5 Sonnet", 0.8), + ("Gemini 2.5 Pro Low", 0.5), + ("Gemini 2.5 Flash", 0.9), + ], + 20.0, + Some(50.0), + Some(10.0), + &[], + ), + // Upstream 0.50.1 #2963: every unselected config stays visible. + ( + &[ + ("Claude 4 Sonnet", 0.8), + ("GPT-4o", 0.8), + ("Mistral Large", 0.6), + ("Qwen Max", 0.6), + ], + 20.0, + None, + None, + &["GPT-4o", "Mistral Large", "Qwen Max"], + ), + // Thinking variants never drive the Claude window. + ( + &[ + ("Claude Thinking", 0.6), + ("Claude 3.5 Sonnet", 0.7), + ("Gemini 2.5 Flash", 0.5), + ], + 30.0, + None, + Some(50.0), + &["Claude Thinking"], + ), + // Without a known family, the first model is primary. + ( + &[("GPT-4o", 0.4), ("Mistral Large", 0.6)], + 60.0, + None, + None, + &["Mistral Large"], + ), + // Noisy Gemini variants stay visible but do not drive summary windows. + ( + &[ + ("Gemini 2.5 Flash Image", 0.01), + ("Gemini 2.5 Pro Lite", 0.02), + ("Gemini autocomplete internal", 0.03), + ("Claude 4 Sonnet", 0.8), + ("Gemini 2.5 Pro Low", 0.6), + ("Gemini 2.5 Flash", 0.7), + ], + 20.0, + Some(40.0), + Some(30.0), + &[ + "Gemini 2.5 Flash Image", + "Gemini 2.5 Pro Lite", + "Gemini autocomplete internal", + ], + ), + // Equal readings are not a pool identity: both unselected configs stay + // visible even though the canonical Claude and Gemini configs are selected. + ( + &[ + ("Claude 4 Sonnet", 0.8), + ("Gemini 2.5 Pro Low", 0.5), + ("Mistral Large", 0.8), + ("Qwen Max", 0.8), + ], + 20.0, + Some(50.0), + None, + &["Mistral Large", "Qwen Max"], + ), + // Models in distinct quota buckets keep separate lanes. + ( + &[ + ("Claude 3.5 Sonnet", 0.8), + ("Claude 4 Sonnet", 0.7), + ("Gemini 2.5 Pro Low", 0.5), + ], + 30.0, + Some(50.0), + None, + &["Claude 3.5 Sonnet"], + ), + ]; + let near = |actual: Option, expected: Option| match (actual, expected) { + (Some(actual), Some(expected)) => (actual - expected).abs() < 0.1, + (actual, expected) => actual.is_none() && expected.is_none(), + }; + for (models, primary, secondary, model_specific, extras) in cases { + let snap = parse_user_status(make_response(models.to_vec())).unwrap(); + let mut titles: Vec<_> = snap + .extra_rate_windows .iter() - .any(|window| window.title == "Gemini 2.5 Flash Image") - ); + .map(|window| window.title.as_str()) + .collect(); + titles.sort_unstable(); + let actual = ( + snap.primary.used_percent, + snap.secondary.as_ref().map(|window| window.used_percent), + snap.model_specific + .as_ref() + .map(|window| window.used_percent), + ); + assert!( + near(Some(actual.0), Some(primary)) + && near(actual.1, secondary) + && near(actual.2, model_specific), + "{models:?}: {actual:?}" + ); + assert_eq!(titles, extras, "{models:?}"); + } } #[test] @@ -425,164 +414,149 @@ fn managed_auth_required_surfaces_instead_of_offline() { // ── agy CLI process matching ─────────────────────────────────────── #[test] -fn detects_agy_exe_cli_process_with_empty_csrf() { - // agy.exe hosts the language server in-process with no --csrf_token. - let output = - "7777\tC:\\Users\\test\\AppData\\Local\\agy\\bin\\agy.exe session --model gemini-2.5-pro"; - - let process = AntigravityProvider::parse_process_info(output) - .expect("agy CLI process should be detected"); - - assert_eq!(process.pid, Some(7777)); - assert_eq!(process.source, ProcessSource::Cli); - assert_eq!(process.csrf_token, ""); - assert!( - process.csrf_token.is_empty(), - "agy CLI requires no CSRF token" - ); - assert_eq!(process.extension_server_csrf_token, None); - assert_eq!(process.extension_port, None); -} - -#[test] -fn detects_quoted_agy_exe_cli_process() { - // Windows CIM quotes an executable path that contains path separators. - let output = "7777\t\"C:\\Users\\user\\AppData\\Local\\agy\\bin\\agy.exe\" --model gemini-3.7-flash-high"; - - let process = AntigravityProvider::parse_process_info(output) - .expect("quoted agy CLI process should be detected"); - - assert_eq!(process.pid, Some(7777)); - assert_eq!(process.source, ProcessSource::Cli); - assert!(process.csrf_token.is_empty()); -} - -#[test] -fn detects_bare_agy_command() { - // The CLI may appear under the bare `agy` name (no .exe suffix). - let output = "8888\tagy serve"; - - let process = AntigravityProvider::parse_process_info(output) - .expect("bare agy command should be detected"); - - assert_eq!(process.pid, Some(8888)); - assert_eq!(process.source, ProcessSource::Cli); - assert!(process.csrf_token.is_empty()); -} - -#[test] -fn detects_antigravity_cli_command() { - // Upstream also matches antigravity-cli / antigravity_cli. - let output = "9999\t/opt/homebrew/bin/antigravity-cli status"; - - let process = AntigravityProvider::parse_process_info(output) - .expect("antigravity-cli command should be detected"); - - assert_eq!(process.pid, Some(9999)); - assert_eq!(process.source, ProcessSource::Cli); - assert!(process.csrf_token.is_empty()); -} - -#[test] -fn ide_match_preferred_over_agy_cli_when_both_running() { - // When the desktop IDE server and the agy CLI are both running, the - // CSRF-protected IDE match wins (mirrors upstream process-kind precedence). - let output = "4242\tC:\\Antigravity\\language_server.exe --csrf_token deadbeef-aaaa-bbbb-cccc-dddddddddddd --extension_server_port 54123\n\ - 7777\tC:\\Users\\test\\AppData\\Local\\agy\\bin\\agy.exe session"; - - let process = - AntigravityProvider::parse_process_info(output).expect("a process should be detected"); - - assert_eq!(process.pid, Some(4242)); - assert_eq!(process.source, ProcessSource::Ide); - assert_eq!(process.csrf_token, "deadbeef-aaaa-bbbb-cccc-dddddddddddd"); -} - -#[test] -fn agy_cli_matches_when_only_cli_running() { - // No --csrf_token anywhere: only the agy CLI line should match. - let output = "7777\tC:\\Users\\test\\AppData\\Local\\agy\\bin\\agy.exe"; - - let process = AntigravityProvider::parse_process_info(output) - .expect("agy CLI should be detected when it is the only match"); - - assert_eq!(process.source, ProcessSource::Cli); - assert!(process.csrf_token.is_empty()); -} - -#[test] -fn non_antigravity_process_without_csrf_is_not_matched() { - // An unrelated tokenless process must not be mistaken for the agy CLI. - let output = "1234\tC:\\Windows\\System32\\notepad.exe"; - - let process = AntigravityProvider::parse_process_info(output); - - assert!(process.is_none(), "unrelated process must not match"); -} - -#[test] -fn is_agy_cli_command_matches_known_names() { - assert!(is_agy_cli_command("agy serve")); - assert!(is_agy_cli_command( - "C:\\Users\\test\\AppData\\Local\\agy\\bin\\agy.exe session" - )); - assert!(is_agy_cli_command( - "C:\\Users\\user\\AppData\\Local\\agy\\bin\\AGY.EXE --model gemini-3.7-flash-high" - )); - assert!(is_agy_cli_command( - "\"C:\\Users\\user\\AppData\\Local\\agy\\bin\\agy.exe\" --model gemini-3.7-flash-high" - )); - assert!(is_agy_cli_command("/usr/local/bin/antigravity-cli status")); - assert!(is_agy_cli_command("/opt/antigravity_cli run")); - assert!(is_agy_cli_command("\"C:\\Tools\\antigravity-cli\" status")); - assert!(is_agy_cli_command("\"C:\\Tools\\antigravity_cli\" run")); -} - -#[test] -fn is_agy_cli_command_rejects_unrelated_names() { - // A leading path separator prevents `notantigravity-cli` from matching. - assert!(!is_agy_cli_command( - "notagy.exe --model gemini-3.7-flash-high" - )); - assert!(!is_agy_cli_command( - "C:\\Tools\\someagy.exe --model gemini-3.7-flash-high" - )); - assert!(!is_agy_cli_command("notantigravity-cli status")); - assert!(!is_agy_cli_command("C:\\Tools\\notantigravity-cli status")); - assert!(!is_agy_cli_command("C:\\Windows\\System32\\notepad.exe")); - assert!(!is_agy_cli_command("language_server.exe --csrf_token abc")); - assert!(!is_agy_cli_command("")); -} - -// ── Upstream 0.50.1 #2963: preserve unselected quota configs ──────────────── - -#[test] -fn multiple_unselected_models_with_same_reading_remain_visible() { - // Equal readings are not a pool identity. Both unselected configs remain - // visible even though the canonical Claude and Gemini configs are selected. - let resp = make_response(vec![ - ("Claude 4 Sonnet", 0.8), - ("Gemini 2.5 Pro Low", 0.5), - ("Mistral Large", 0.8), - ("Qwen Max", 0.8), - ]); - let snap = parse_user_status(resp).unwrap(); - assert_eq!( - snap.extra_rate_windows.len(), - 2, - "distinct unselected configs with equal readings remain visible" - ); +fn parse_process_info_matches_ide_servers_and_the_agy_cli() { + // (output, expected (pid, source, csrf token, extension-server csrf token, port)) + let cases = [ + ( + r"4242 C:\Users\test\AppData\Local\Programs\Antigravity\resources\bin\language_server.exe --csrf_token 11111111-2222-3333-4444-555555555555 --extension_server_port 54123", + Some(( + Some(4242), + ProcessSource::Ide, + "11111111-2222-3333-4444-555555555555", + None, + Some(54123), + )), + ), + // No --extension_server_port: the https port is used. + ( + "34564\tC:\\Users\\test\\AppData\\Local\\Programs\\Antigravity\\resources\\bin\\language_server.exe --standalone --override_ide_name antigravity --subclient_type hub --override_ide_version 2.0.11 --https_server_port 0 --csrf_token 68dda2fb-6b26-40c0-aeef-b9a628615714 --app_data_dir antigravity", + Some(( + Some(34564), + ProcessSource::Ide, + "68dda2fb-6b26-40c0-aeef-b9a628615714", + None, + Some(0), + )), + ), + ( + "34564\tC:\\Users\\test\\AppData\\Local\\Programs\\Antigravity\\resources\\bin\\language_server.exe --standalone --csrf_token aabbccdd-1122-3344-5566-778899001122 --app_data_dir antigravity", + Some(( + Some(34564), + ProcessSource::Ide, + "aabbccdd-1122-3344-5566-778899001122", + None, + None, + )), + ), + // `--flag=value` form. + ( + "34564\tC:\\Users\\test\\AppData\\Local\\Programs\\Antigravity\\resources\\bin\\language_server.exe --csrf_token=68dda2fb-6b26-40c0-aeef-b9a628615714 --https_server_port=61999", + Some(( + Some(34564), + ProcessSource::Ide, + "68dda2fb-6b26-40c0-aeef-b9a628615714", + None, + Some(61999), + )), + ), + // agy.exe hosts the language server in-process with no --csrf_token. + ( + "7777\tC:\\Users\\test\\AppData\\Local\\agy\\bin\\agy.exe session --model gemini-2.5-pro", + Some((Some(7777), ProcessSource::Cli, "", None, None)), + ), + // Windows CIM quotes an executable path that contains path separators. + ( + "7777\t\"C:\\Users\\user\\AppData\\Local\\agy\\bin\\agy.exe\" --model gemini-3.7-flash-high", + Some((Some(7777), ProcessSource::Cli, "", None, None)), + ), + // The CLI may appear under the bare `agy` name (no .exe suffix). + ( + "8888\tagy serve", + Some((Some(8888), ProcessSource::Cli, "", None, None)), + ), + // Upstream also matches antigravity-cli / antigravity_cli. + ( + "9999\t/opt/homebrew/bin/antigravity-cli status", + Some((Some(9999), ProcessSource::Cli, "", None, None)), + ), + // When the desktop IDE server and the agy CLI are both running, the + // CSRF-protected IDE match wins (mirrors upstream process-kind precedence). + ( + "4242\tC:\\Antigravity\\language_server.exe --csrf_token deadbeef-aaaa-bbbb-cccc-dddddddddddd --extension_server_port 54123\n\ + 7777\tC:\\Users\\test\\AppData\\Local\\agy\\bin\\agy.exe session", + Some(( + Some(4242), + ProcessSource::Ide, + "deadbeef-aaaa-bbbb-cccc-dddddddddddd", + None, + Some(54123), + )), + ), + // No --csrf_token anywhere: only the agy CLI line matches. + ( + "7777\tC:\\Users\\test\\AppData\\Local\\agy\\bin\\agy.exe", + Some((Some(7777), ProcessSource::Cli, "", None, None)), + ), + // An unrelated tokenless process must not be mistaken for the agy CLI. + ("1234\tC:\\Windows\\System32\\notepad.exe", None), + ]; + for (output, expected) in cases { + let actual = AntigravityProvider::parse_process_info(output).map(|process| { + ( + process.pid, + process.source, + process.csrf_token, + process.extension_server_csrf_token, + process.extension_port, + ) + }); + let expected = expected.map(|(pid, source, csrf, extension_csrf, port)| { + ( + pid, + source, + csrf.to_string(), + extension_csrf.map(str::to_string), + port, + ) + }); + assert_eq!(actual, expected, "{output}"); + } } #[test] -fn models_in_distinct_quota_buckets_keep_separate_lanes() { - let resp = make_response(vec![ - ("Claude 3.5 Sonnet", 0.8), - ("Claude 4 Sonnet", 0.7), - ("Gemini 2.5 Pro Low", 0.5), - ]); - let snap = parse_user_status(resp).unwrap(); - assert_eq!(snap.extra_rate_windows.len(), 1); +fn is_agy_cli_command_matches_only_cli_names() { + for (command, expected) in [ + ("agy serve", true), + ( + "C:\\Users\\test\\AppData\\Local\\agy\\bin\\agy.exe session", + true, + ), + ( + "C:\\Users\\user\\AppData\\Local\\agy\\bin\\AGY.EXE --model gemini-3.7-flash-high", + true, + ), + ( + "\"C:\\Users\\user\\AppData\\Local\\agy\\bin\\agy.exe\" --model gemini-3.7-flash-high", + true, + ), + ("/usr/local/bin/antigravity-cli status", true), + ("/opt/antigravity_cli run", true), + ("\"C:\\Tools\\antigravity-cli\" status", true), + ("\"C:\\Tools\\antigravity_cli\" run", true), + // A leading path separator prevents `notantigravity-cli` from matching. + ("notagy.exe --model gemini-3.7-flash-high", false), + ( + "C:\\Tools\\someagy.exe --model gemini-3.7-flash-high", + false, + ), + ("notantigravity-cli status", false), + ("C:\\Tools\\notantigravity-cli status", false), + ("C:\\Windows\\System32\\notepad.exe", false), + ("language_server.exe --csrf_token abc", false), + ("", false), + ] { + assert_eq!(is_agy_cli_command(command), expected, "{command}"); + } } #[test] @@ -695,15 +669,39 @@ fn non_auth_failure_without_history_surfaces_error() { assert!(matches!(resolved, Err(ProviderError::Other(_)))); } +fn local_result() -> ProviderFetchResult { + ProviderFetchResult::new(UsageSnapshot::new(RateWindow::new(10.0)), "local") +} + +/// Resolve `local` with the given offline history and a CLI fallback that +/// yields `cli`. Also reports whether the CLI fallback ran. +async fn run_fallback( + local: Result, LiveFailure>, + cli: Result, LiveFailure>, + offline: Option, +) -> (Result, bool) { + let ran = Arc::new(AtomicBool::new(false)); + let marker = Arc::clone(&ran); + let result = AntigravityProvider::new() + .resolve_runtime_fallback_with_offline( + local, + move || async move { + marker.store(true, Ordering::SeqCst); + cli + }, + offline, + ) + .await; + (result, ran.load(Ordering::SeqCst)) +} + #[tokio::test] async fn local_probe_success_does_not_run_structured_cli_fallback() { let provider = AntigravityProvider::new(); let fallback_called = Arc::new(AtomicBool::new(false)); let marker = Arc::clone(&fallback_called); - let local = ProviderFetchResult::new(UsageSnapshot::new(RateWindow::new(10.0)), "local"); - let result = provider - .resolve_runtime_fallback(Ok(Some(local)), move || async move { + .resolve_runtime_fallback(Ok(Some(local_result())), move || async move { marker.store(true, Ordering::SeqCst); Ok(Some(structured_cli_result())) }) @@ -717,28 +715,19 @@ async fn local_probe_success_does_not_run_structured_cli_fallback() { #[tokio::test] async fn local_probe_success_wins_over_an_invalid_cli_override() { - let provider = AntigravityProvider::new(); - let fallback_called = Arc::new(AtomicBool::new(false)); - let marker = Arc::clone(&fallback_called); - let local = ProviderFetchResult::new(UsageSnapshot::new(RateWindow::new(10.0)), "local"); - - let result = provider - .resolve_runtime_fallback_with_offline( - Ok(Some(local)), - move || async move { - marker.store(true, Ordering::SeqCst); - Err(LiveFailure::from(ProviderError::NotInstalled( - "ANTIGRAVITY_CLI_PATH is set but unusable".to_string(), - ))) - }, - Some(offline_result()), - ) - .await - .expect("successful local desktop probe must remain authoritative"); + let (result, fallback_ran) = run_fallback( + Ok(Some(local_result())), + Err(LiveFailure::from(ProviderError::NotInstalled( + "ANTIGRAVITY_CLI_PATH is set but unusable".to_string(), + ))), + Some(offline_result()), + ) + .await; + let result = result.expect("successful local desktop probe must remain authoritative"); assert_eq!(result.source_label, "local"); assert_eq!(result.usage.primary.used_percent, 10.0); - assert!(!fallback_called.load(Ordering::SeqCst)); + assert!(!fallback_ran); } #[tokio::test] @@ -781,35 +770,29 @@ async fn unauthenticated_local_and_unavailable_cli_paths_remain_auth_required() #[tokio::test] async fn malformed_structured_cli_json_from_fallback_is_a_parse_error() { - let result = AntigravityProvider::new() - .resolve_runtime_fallback_with_offline( - Err(ProviderError::AuthRequired.into()), - || async { - let error = quota_summary::parse_cli_usage_report(br#"{"status":"SUCCESS""#) - .expect_err("malformed JSON must fail parsing"); - Err(LiveFailure::from(error)) - }, - None, - ) - .await; + let error = quota_summary::parse_cli_usage_report(br#"{"status":"SUCCESS""#) + .expect_err("malformed JSON must fail parsing"); + let (result, _) = run_fallback( + Err(ProviderError::AuthRequired.into()), + Err(LiveFailure::from(error)), + None, + ) + .await; assert!(matches!(result, Err(ProviderError::Parse(_)))); } #[tokio::test] async fn cli_fallback_error_prefers_offline_history() { - let result = AntigravityProvider::new() - .resolve_runtime_fallback_with_offline( - Err(ProviderError::AuthRequired.into()), - || async { - Err(LiveFailure::from(ProviderError::Parse( - "Antigravity CLI usage report: malformed JSON".to_string(), - ))) - }, - Some(offline_result()), - ) - .await - .expect("offline history should survive a failed CLI probe"); + let (result, _) = run_fallback( + Err(ProviderError::AuthRequired.into()), + Err(LiveFailure::from(ProviderError::Parse( + "Antigravity CLI usage report: malformed JSON".to_string(), + ))), + Some(offline_result()), + ) + .await; + let result = result.expect("offline history should survive a failed CLI probe"); assert_eq!(result.source_label, "offline"); assert_eq!(result.usage.login_method.as_deref(), Some("offline")); @@ -817,18 +800,15 @@ async fn cli_fallback_error_prefers_offline_history() { #[tokio::test] async fn unusable_cli_override_prefers_offline_history() { - let result = AntigravityProvider::new() - .resolve_runtime_fallback_with_offline( - Err(ProviderError::AuthRequired.into()), - || async { - Err(LiveFailure::from(ProviderError::NotInstalled( - "ANTIGRAVITY_CLI_PATH is set but does not point to a usable agy file".into(), - ))) - }, - Some(offline_result()), - ) - .await - .expect("offline history should survive an unusable CLI override"); + let (result, _) = run_fallback( + Err(ProviderError::AuthRequired.into()), + Err(LiveFailure::from(ProviderError::NotInstalled( + "ANTIGRAVITY_CLI_PATH is set but does not point to a usable agy file".into(), + ))), + Some(offline_result()), + ) + .await; + let result = result.expect("offline history should survive an unusable CLI override"); assert_eq!(result.source_label, "offline"); assert_eq!(result.usage.login_method.as_deref(), Some("offline")); @@ -836,17 +816,14 @@ async fn unusable_cli_override_prefers_offline_history() { #[tokio::test] async fn unusable_cli_override_without_history_reports_the_override() { - let result = AntigravityProvider::new() - .resolve_runtime_fallback_with_offline( - Err(ProviderError::Other("local API unavailable".to_string()).into()), - || async { - Err(LiveFailure::from(ProviderError::NotInstalled( - "ANTIGRAVITY_CLI_PATH is set but does not point to a usable agy file".into(), - ))) - }, - None, - ) - .await; + let (result, _) = run_fallback( + Err(ProviderError::Other("local API unavailable".to_string()).into()), + Err(LiveFailure::from(ProviderError::NotInstalled( + "ANTIGRAVITY_CLI_PATH is set but does not point to a usable agy file".into(), + ))), + None, + ) + .await; match result { Err(ProviderError::NotInstalled(message)) => { @@ -857,88 +834,56 @@ async fn unusable_cli_override_without_history_reports_the_override() { } #[test] -fn user_tier_resolves_google_ai_ultra_plan_name() { - let json = serde_json::json!({ - "userStatus": { - "email": "user@example.com", - "planStatus": { - "planInfo": { - "planName": "Pro" - } - }, - "userTier": { +fn user_tier_name_wins_over_the_plan_status_name() { + // (userTier, login method) + for (user_tier, plan) in [ + ( + Some(serde_json::json!({ "id": "g1-ultra-tier", "name": "Google AI Ultra", "description": "Google AI Ultra" - }, - "cascadeModelConfigData": { - "clientModelConfigs": [ - { - "label": "Gemini 2.5 Pro", - "quotaInfo": {"remainingFraction": 0.8} - } - ] - } - } - }); - let resp: UserStatusResponse = serde_json::from_value(json).unwrap(); - let snap = parse_user_status(resp).unwrap(); - assert_eq!(snap.login_method.as_deref(), Some("Google AI Ultra")); - assert_eq!(snap.account_email.as_deref(), Some("user@example.com")); -} - -#[test] -fn user_status_falls_back_to_plan_status_when_user_tier_is_absent() { - let json = serde_json::json!({ - "userStatus": { + })), + "Google AI Ultra", + ), + (None, "Pro"), + ] { + let mut status = serde_json::json!({ "email": "user@example.com", - "planStatus": { - "planInfo": { - "planName": "Pro" - } - }, + "planStatus": {"planInfo": {"planName": "Pro"}}, "cascadeModelConfigData": { "clientModelConfigs": [ - { - "label": "Gemini 2.5 Pro", - "quotaInfo": {"remainingFraction": 0.8} - } + {"label": "Gemini 2.5 Pro", "quotaInfo": {"remainingFraction": 0.8}} ] } + }); + if let Some(user_tier) = user_tier { + status["userTier"] = user_tier; } - }); - let resp: UserStatusResponse = serde_json::from_value(json).unwrap(); - let snap = parse_user_status(resp).unwrap(); - assert_eq!(snap.login_method.as_deref(), Some("Pro")); -} - -#[test] -fn plan_name_fallback_skips_blank_user_tier_name() { - let status: UserStatus = serde_json::from_value(serde_json::json!({ - "userTier": { - "name": " ", - "description": " Google AI Ultra " - } - })) - .unwrap(); - - assert_eq!( - resolve_plan_name(&status).as_deref(), - Some("Google AI Ultra") - ); + let resp: UserStatusResponse = + serde_json::from_value(serde_json::json!({ "userStatus": status })).unwrap(); + let snap = parse_user_status(resp).unwrap(); + assert_eq!(snap.login_method.as_deref(), Some(plan)); + assert_eq!(snap.account_email.as_deref(), Some("user@example.com")); + } } #[test] -fn plan_name_fallback_skips_blank_display_name() { - let status: UserStatus = serde_json::from_value(serde_json::json!({ - "planStatus": { - "planInfo": { - "planDisplayName": " ", - "planName": " Pro " - } - } - })) - .unwrap(); - - assert_eq!(resolve_plan_name(&status).as_deref(), Some("Pro")); +fn plan_name_fallback_skips_blank_names() { + for (status, plan) in [ + ( + serde_json::json!({ + "userTier": {"name": " ", "description": " Google AI Ultra "} + }), + "Google AI Ultra", + ), + ( + serde_json::json!({ + "planStatus": {"planInfo": {"planDisplayName": " ", "planName": " Pro "}} + }), + "Pro", + ), + ] { + let status: UserStatus = serde_json::from_value(status).unwrap(); + assert_eq!(resolve_plan_name(&status).as_deref(), Some(plan)); + } } From 720d78b26f7b5b25c1228f58021e4f29479b3620 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:53:17 +0700 Subject: [PATCH 38/40] Share Antigravity session-reader, step and offline-reason test helpers (PBG-60) --- .../antigravity/local_sessions_reader.rs | 93 +++++--------- .../antigravity/local_step_resolver.rs | 57 +++------ .../antigravity/offline_reason_tests.rs | 121 ++++++++---------- rust/src/providers/antigravity/tests.rs | 2 +- 4 files changed, 107 insertions(+), 166 deletions(-) diff --git a/rust/src/providers/antigravity/local_sessions_reader.rs b/rust/src/providers/antigravity/local_sessions_reader.rs index c9a82ee2961..487ede93829 100644 --- a/rust/src/providers/antigravity/local_sessions_reader.rs +++ b/rust/src/providers/antigravity/local_sessions_reader.rs @@ -413,6 +413,18 @@ fn has_valid_token_fields(value: &Value) -> bool { mod tests { use super::*; + fn now() -> DateTime { + Utc.timestamp_millis_opt(1787576400000).single().unwrap() + } + + /// Summarize one session file holding `text` over the last 7 days. + fn summarize_lines(text: impl AsRef<[u8]>) -> LocalTokenHistorySummary { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("session-a.jsonl"); + fs::write(&path, text).unwrap(); + summarize_paths(&[path], now(), 7, false) + } + #[test] fn tokscale_discovery_bounds_entries_and_retained_paths() { let dir = tempfile::tempdir().unwrap(); @@ -452,9 +464,8 @@ mod tests { ), ) .unwrap(); - let now = Utc.timestamp_millis_opt(1787576400000).single().unwrap(); - let summary = summarize_paths(&[known, unknown], now, 7, false); + let summary = summarize_paths(&[known, unknown], now(), 7, false); assert_eq!(summary.cost_estimate.coverage.estimated, 1); assert_eq!(summary.cost_estimate.coverage.unpriced, 1); @@ -463,15 +474,11 @@ mod tests { } #[test] fn summarizes_tokscale_jsonl_and_deduplicates_response_ids() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("session-a.jsonl"); - fs::write(&path, concat!( + let summary = summarize_lines(concat!( "{\"type\":\"session_meta\",\"modelId\":\"test-model-antigravity-a\"}\n", "{\"type\":\"usage\",\"responseId\":\"r1\",\"timestamp\":1787572800000,\"input\":100,\"output\":20,\"cacheRead\":10,\"cacheWrite\":5}\n", "{\"type\":\"usage\",\"response_id\":\"r1\",\"timestamp\":1787572800000,\"input\":100,\"output\":20}\n" - )).unwrap(); - let now = Utc.timestamp_millis_opt(1787576400000).single().unwrap(); - let summary = summarize_paths(&[path], now, 7, false); + )); assert_eq!(summary.total_tokens, 135); assert_eq!(summary.session_count, 1); assert_eq!(summary.coverage, LocalHistoryCoverage::Complete); @@ -486,35 +493,24 @@ mod tests { b"{\"type\":\"usage\",\"timestamp\":1787572800000,\"input\":10}\n", ) .unwrap(); - let now = Utc.timestamp_millis_opt(1787576400000).single().unwrap(); - let truncated = summarize_paths(std::slice::from_ref(&path), now, 7, true); + let truncated = summarize_paths(std::slice::from_ref(&path), now(), 7, true); assert_eq!(truncated.coverage, LocalHistoryCoverage::Partial); - let missing = summarize_paths(&[dir.path().join("missing.jsonl")], now, 7, false); + let missing = summarize_paths(&[dir.path().join("missing.jsonl")], now(), 7, false); assert_eq!(missing.coverage, LocalHistoryCoverage::Partial); } #[test] fn excludes_usage_outside_requested_window() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("session-a.jsonl"); - fs::write( - &path, - concat!( - "{\"type\":\"usage\",\"timestamp\":1787572800000,\"input\":10,\"output\":5}\n", - "{\"type\":\"usage\",\"timestamp\":1784894400000,\"input\":99,\"output\":99}\n" - ), - ) - .unwrap(); - let now = Utc.timestamp_millis_opt(1787576400000).single().unwrap(); - let summary = summarize_paths(&[path], now, 7, false); + let summary = summarize_lines(concat!( + "{\"type\":\"usage\",\"timestamp\":1787572800000,\"input\":10,\"output\":5}\n", + "{\"type\":\"usage\",\"timestamp\":1784894400000,\"input\":99,\"output\":99}\n" + )); assert_eq!(summary.total_tokens, 15); assert_eq!(summary.session_count, 1); } #[test] fn oversized_jsonl_line_marks_coverage_partial_and_next_row_is_counted() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("session-a.jsonl"); let mut text = format!( r#"{{"type":"usage","padding":"{}"}}"#, "x".repeat(MAX_JSONL_LINE_BYTES + 32) @@ -522,9 +518,7 @@ mod tests { text.push('\n'); text.push_str(r#"{"type":"usage","timestamp":1787572800000,"input":10,"output":5}"#); text.push('\n'); - fs::write(&path, text).unwrap(); - let now = Utc.timestamp_millis_opt(1787576400000).single().unwrap(); - let summary = summarize_paths(&[path], now, 7, false); + let summary = summarize_lines(text); assert_eq!(summary.total_tokens, 15); assert_eq!(summary.session_count, 1); assert_eq!(summary.coverage, LocalHistoryCoverage::Partial); @@ -532,21 +526,12 @@ mod tests { #[test] fn malformed_jsonl_record_marks_coverage_partial_and_next_row_is_counted() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("session-a.jsonl"); - fs::write( - &path, - concat!( - "{malformed json}\n", - "{\"type\":\"usage\",\"timestamp\":1787572800000,\"input\":\"invalid\"}\n", - "{\"type\":\"usage\",\"input\":10}\n", - "{\"type\":\"usage\",\"timestamp\":1787572800000,\"input\":10,\"output\":5}\n" - ), - ) - .unwrap(); - let now = Utc.timestamp_millis_opt(1787576400000).single().unwrap(); - - let summary = summarize_paths(&[path], now, 7, false); + let summary = summarize_lines(concat!( + "{malformed json}\n", + "{\"type\":\"usage\",\"timestamp\":1787572800000,\"input\":\"invalid\"}\n", + "{\"type\":\"usage\",\"input\":10}\n", + "{\"type\":\"usage\",\"timestamp\":1787572800000,\"input\":10,\"output\":5}\n" + )); assert_eq!(summary.total_tokens, 15); assert_eq!(summary.coverage, LocalHistoryCoverage::Partial); @@ -567,10 +552,9 @@ mod tests { "{\"type\":\"usage\",\"timestamp\":1787572800000,\"input\":20}\n", ) .unwrap(); - let now = Utc.timestamp_millis_opt(1787576400000).single().unwrap(); let summary = - summarize_paths_with_budget(&[first_path, second_path], now, 7, false, first.len()); + summarize_paths_with_budget(&[first_path, second_path], now(), 7, false, first.len()); // A hard budget stop is withheld: no total is published from it. assert_eq!(summary.total_tokens, 0); @@ -581,20 +565,11 @@ mod tests { #[test] fn token_sum_overflow_marks_coverage_partial_without_saturation() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("session-a.jsonl"); - fs::write( - &path, - concat!( - "{\"type\":\"usage\",\"timestamp\":1787572800000,\"input\":18446744073709551615,\"output\":1}\n", - "{\"type\":\"usage\",\"timestamp\":1787572800000,\"input\":18446744073709551615}\n", - "{\"type\":\"usage\",\"timestamp\":1787572800000,\"input\":1}\n" - ), - ) - .unwrap(); - let now = Utc.timestamp_millis_opt(1787576400000).single().unwrap(); - - let summary = summarize_paths(&[path], now, 7, false); + let summary = summarize_lines(concat!( + "{\"type\":\"usage\",\"timestamp\":1787572800000,\"input\":18446744073709551615,\"output\":1}\n", + "{\"type\":\"usage\",\"timestamp\":1787572800000,\"input\":18446744073709551615}\n", + "{\"type\":\"usage\",\"timestamp\":1787572800000,\"input\":1}\n" + )); assert_eq!(summary.total_tokens, u64::MAX); assert_eq!(summary.session_count, 1); diff --git a/rust/src/providers/antigravity/local_step_resolver.rs b/rust/src/providers/antigravity/local_step_resolver.rs index 52b49fd72f8..c2b54dad48f 100644 --- a/rust/src/providers/antigravity/local_step_resolver.rs +++ b/rust/src/providers/antigravity/local_step_resolver.rs @@ -96,41 +96,30 @@ fn has_duplicate_rows(rows: impl Iterator) -> bool { mod tests { use super::*; - fn occurrences(rows: &[(i64, Option)]) -> Vec { - rows.iter() - .map(|&(row, timestamp_ms)| StepOccurrence { - row, - timestamp_ms, - bot_id: None, - }) - .collect() - } - - fn timestamps(rows: &[(i64, Option)]) -> Vec { - rows.iter() + /// `(row, timestamp)` pairs without bot ids, keyed under one `step`. + fn step(rows: &[(i64, Option)]) -> HashMap> { + let rows = rows + .iter() .map(|&(row, timestamp_ms)| StepOccurrence { row, timestamp_ms, bot_id: None, }) - .collect() + .collect(); + HashMap::from([("step".to_string(), rows)]) } #[test] fn orders_rows_and_repeats_one_shared_timestamp() { - let occurrences = - HashMap::from([("step".to_string(), occurrences(&[(0, None), (1, None)]))]); - let timestamp_map = HashMap::from([( - "step".to_string(), - timestamps(&[(20, Some(200)), (10, Some(100))]), - )]); + let occurrences = step(&[(0, None), (1, None)]); + let timestamp_map = step(&[(20, Some(200)), (10, Some(100))]); assert_eq!( resolve_step_timestamps(×tamp_map, &occurrences, &HashSet::new(), false)["step"], vec![100, 200] ); - let timestamps = HashMap::from([("step".to_string(), timestamps(&[(10, Some(100))]))]); + let timestamps = step(&[(10, Some(100))]); assert_eq!( resolve_step_timestamps(×tamps, &occurrences, &HashSet::new(), false)["step"], vec![100, 100] @@ -139,13 +128,11 @@ mod tests { #[test] fn withholds_missing_duplicate_and_conflicting_evidence() { - let occurrences = - HashMap::from([("step".to_string(), occurrences(&[(0, None), (1, None)]))]); - for timestamps in [ - timestamps(&[(10, None), (20, Some(200))]), - timestamps(&[(10, Some(100)), (10, Some(200))]), + let occurrences = step(&[(0, None), (1, None)]); + for evidence in [ + step(&[(10, None), (20, Some(200))]), + step(&[(10, Some(100)), (10, Some(200))]), ] { - let evidence = HashMap::from([("step".to_string(), timestamps)]); assert!( !resolve_step_timestamps(&evidence, &occurrences, &HashSet::new(), false) .contains_key("step") @@ -155,14 +142,8 @@ mod tests { #[test] fn embedded_timestamp_must_agree_with_aligned_step() { - let occurrences = HashMap::from([( - "step".to_string(), - occurrences(&[(0, Some(100)), (1, None)]), - )]); - let timestamps = HashMap::from([( - "step".to_string(), - timestamps(&[(10, Some(101)), (20, Some(200))]), - )]); + let occurrences = step(&[(0, Some(100)), (1, None)]); + let timestamps = step(&[(10, Some(101)), (20, Some(200))]); assert!( !resolve_step_timestamps(×tamps, &occurrences, &HashSet::new(), false) @@ -172,9 +153,8 @@ mod tests { #[test] fn unidentified_rows_do_not_reuse_one_timestamp_for_repeated_occurrences() { - let occurrences = - HashMap::from([("step".to_string(), occurrences(&[(0, None), (1, None)]))]); - let timestamps = HashMap::from([("step".to_string(), timestamps(&[(10, Some(100))]))]); + let occurrences = step(&[(0, None), (1, None)]); + let timestamps = step(&[(10, Some(100))]); assert!( !resolve_step_timestamps(×tamps, &occurrences, &HashSet::new(), true) @@ -184,8 +164,7 @@ mod tests { #[test] fn ambiguous_bot_timestamp_keeps_its_positional_slot() { - let occurrences = - HashMap::from([("step".to_string(), occurrences(&[(0, None), (1, None)]))]); + let occurrences = step(&[(0, None), (1, None)]); let timestamps = HashMap::from([( "step".to_string(), vec![ diff --git a/rust/src/providers/antigravity/offline_reason_tests.rs b/rust/src/providers/antigravity/offline_reason_tests.rs index 00e12c91965..c9b2d5cf2fa 100644 --- a/rust/src/providers/antigravity/offline_reason_tests.rs +++ b/rust/src/providers/antigravity/offline_reason_tests.rs @@ -3,7 +3,7 @@ use super::super::AntigravityProvider; use super::super::cli_print_failure::ExitReason; -use super::super::tests::offline_result; +use super::super::tests::{offline_result, run_fallback}; use super::*; use crate::core::{ProviderFetchResult, RateWindow, UsageSnapshot}; @@ -18,6 +18,14 @@ fn offline_detail_value(result: &ProviderFetchResult) -> String { details[0].value().to_string() } +/// Resolve `failure` against offline history and return its explanation. +fn offline_detail_for(failure: impl Into) -> String { + let resolved = AntigravityProvider::resolve_probe_failure(failure, Some(offline_result())) + .expect("offline history is preserved"); + assert_eq!(resolved.source_label, "offline"); + offline_detail_value(&resolved) +} + #[test] fn offline_fallback_explains_each_typed_failure() { let other = |message: &str| ProviderError::Other(message.to_string()); @@ -62,11 +70,8 @@ fn offline_fallback_explains_each_typed_failure() { (ProviderError::NoCookies.into(), HINT), ]; for (failure, reason) in cases { - let resolved = AntigravityProvider::resolve_probe_failure(failure, Some(offline_result())) - .expect("offline history is preserved"); - assert_eq!(resolved.source_label, "offline"); assert_eq!( - offline_detail_value(&resolved), + offline_detail_for(failure), format!("{OFFLINE_DETAIL_PREFIX} {reason}") ); } @@ -74,29 +79,19 @@ fn offline_fallback_explains_each_typed_failure() { #[test] fn offline_explanation_keeps_only_the_http_status() { - let body = AntigravityProvider::resolve_probe_failure( - LiveFailure::http_status( - 500, - ProviderError::Other( - r#"API error 500 Internal Server Error: {"secret":"token-value"}"#.to_string(), - ), + let body = offline_detail_for(LiveFailure::http_status( + 500, + ProviderError::Other( + r#"API error 500 Internal Server Error: {"secret":"token-value"}"#.to_string(), ), - Some(offline_result()), - ) - .expect("offline history is preserved"); - let body = offline_detail_value(&body); + )); assert!(body.contains("HTTP 500"), "{body}"); assert!(!body.contains("token-value"), "{body}"); - let expired = AntigravityProvider::resolve_probe_failure( - LiveFailure::http_status( - 403, - ProviderError::Other(r#"API error 403 Forbidden: {"detail":"private"}"#.to_string()), - ), - Some(offline_result()), - ) - .expect("offline history is preserved"); - let expired = offline_detail_value(&expired); + let expired = offline_detail_for(LiveFailure::http_status( + 403, + ProviderError::Other(r#"API error 403 Forbidden: {"detail":"private"}"#.to_string()), + )); assert!(expired.contains("session expired"), "{expired}"); assert!(!expired.contains("private"), "{expired}"); } @@ -118,19 +113,16 @@ fn http_failure_without_history_surfaces_the_original_error() { #[tokio::test] async fn offline_fallback_explains_the_masked_cli_failure() { - let result = AntigravityProvider::new() - .resolve_runtime_fallback_with_offline( - Err(LiveFailure::not_running()), - || async { - Err(LiveFailure::cli_report(CliPrintFailure::Exited { - code: 1, - reason: ExitReason::EligibilityNetwork, - })) - }, - Some(offline_result()), - ) - .await - .expect("offline history should survive a failed CLI probe"); + let (result, _) = run_fallback( + Err(LiveFailure::not_running()), + Err(LiveFailure::cli_report(CliPrintFailure::Exited { + code: 1, + reason: ExitReason::EligibilityNetwork, + })), + Some(offline_result()), + ) + .await; + let result = result.expect("offline history should survive a failed CLI probe"); assert_eq!(result.source_label, "offline"); assert_eq!( offline_detail_value(&result), @@ -140,17 +132,16 @@ async fn offline_fallback_explains_the_masked_cli_failure() { #[tokio::test] async fn unavailable_cli_keeps_the_local_failure_reason() { - let result = AntigravityProvider::new() - .resolve_runtime_fallback_with_offline( - Err(LiveFailure::http_status( - 500, - ProviderError::Other("API error 500 Internal Server Error: busy".to_string()), - )), - || async { Ok(None) }, - Some(offline_result()), - ) - .await - .expect("offline history is preserved"); + let (result, _) = run_fallback( + Err(LiveFailure::http_status( + 500, + ProviderError::Other("API error 500 Internal Server Error: busy".to_string()), + )), + Ok(None), + Some(offline_result()), + ) + .await; + let result = result.expect("offline history is preserved"); assert_eq!( offline_detail_value(&result), format!("{OFFLINE_DETAIL_PREFIX} the usage request failed (HTTP 500)") @@ -240,9 +231,7 @@ fn offline_explanation_never_echoes_error_text() { ProviderError::OAuth(leaky), ]; for error in errors { - let resolved = AntigravityProvider::resolve_probe_failure(error, Some(offline_result())) - .expect("offline history is preserved"); - let value = offline_detail_value(&resolved); + let value = offline_detail_for(error); for secret in secrets { assert!(!value.contains(secret), "{secret} leaked into {value}"); } @@ -266,28 +255,26 @@ fn auth_required_adds_no_offline_explanation() { #[tokio::test] async fn successful_live_fallback_carries_no_offline_explanation() { let live = ProviderFetchResult::new(UsageSnapshot::new(RateWindow::new(10.0)), "cli"); - let result = AntigravityProvider::new() - .resolve_runtime_fallback_with_offline( - Err(ProviderError::Timeout.into()), - || async { Ok(Some(live)) }, - Some(offline_result()), - ) - .await - .expect("live CLI fallback wins over offline history"); + let (result, _) = run_fallback( + Err(ProviderError::Timeout.into()), + Ok(Some(live)), + Some(offline_result()), + ) + .await; + let result = result.expect("live CLI fallback wins over offline history"); assert_eq!(result.source_label, "cli"); assert!(result.display_details().is_empty()); } #[tokio::test] async fn failed_cli_fallback_offline_result_explains_failure() { - let result = AntigravityProvider::new() - .resolve_runtime_fallback_with_offline( - Err(ProviderError::AuthRequired.into()), - || async { Err(LiveFailure::from(ProviderError::Timeout)) }, - Some(offline_result()), - ) - .await - .expect("offline history should survive a failed CLI probe"); + let (result, _) = run_fallback( + Err(ProviderError::AuthRequired.into()), + Err(LiveFailure::from(ProviderError::Timeout)), + Some(offline_result()), + ) + .await; + let result = result.expect("offline history should survive a failed CLI probe"); assert_eq!( offline_detail_value(&result), format!("{OFFLINE_DETAIL_PREFIX} Antigravity quota request timed out.") diff --git a/rust/src/providers/antigravity/tests.rs b/rust/src/providers/antigravity/tests.rs index 206387211b0..1dfa8a4f34d 100644 --- a/rust/src/providers/antigravity/tests.rs +++ b/rust/src/providers/antigravity/tests.rs @@ -675,7 +675,7 @@ fn local_result() -> ProviderFetchResult { /// Resolve `local` with the given offline history and a CLI fallback that /// yields `cli`. Also reports whether the CLI fallback ran. -async fn run_fallback( +pub(super) async fn run_fallback( local: Result, LiveFailure>, cli: Result, LiveFailure>, offline: Option, From dce376a2f4befb6480f88b644fca3d21ab09c38c Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:36:13 +0700 Subject: [PATCH 39/40] Revert "Delete dead MiniMax localStorage importer" This reverts commit e974e9bd7d955b5de0a5115b37cb8a1080712aa4. --- rust/src/providers/minimax/local_storage.rs | 458 ++++++++++++++++++++ rust/src/providers/minimax/mod.rs | 8 + 2 files changed, 466 insertions(+) create mode 100755 rust/src/providers/minimax/local_storage.rs diff --git a/rust/src/providers/minimax/local_storage.rs b/rust/src/providers/minimax/local_storage.rs new file mode 100755 index 00000000000..3225be47e1b --- /dev/null +++ b/rust/src/providers/minimax/local_storage.rs @@ -0,0 +1,458 @@ +//! MiniMax LocalStorage Importer +//! +//! Extracts session data from Chromium browser storage for the MiniMax platform. +//! Storage directories come from `browser::storage_discovery` (every installed Chromium-family +//! browser and profile: Local Storage, then Session Storage, then MiniMax IndexedDB). + +use crate::browser::leveldb::local_storage::{ + LocalStorageEntry, read_local_storage_entries_for_origins, +}; +use crate::browser::leveldb::{self, Entry}; +use crate::browser::storage_discovery::{self, StorageCandidate, StorageKind}; +use serde::{Deserialize, Serialize}; +use std::path::Path; + +/// Session data extracted from MiniMax localStorage +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MiniMaxSession { + pub access_token: Option, + pub user_id: Option, + pub group_id: Option, + pub email: Option, + pub phone: Option, + pub plan_type: Option, + pub source_label: String, +} + +/// Error type for localStorage import +#[derive(Debug)] +pub enum ImportError { + BrowserNotFound, + StorageNotFound, + ParseError(String), + AccessDenied(String), +} + +impl std::fmt::Display for ImportError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ImportError::BrowserNotFound => write!(f, "No supported browser found"), + ImportError::StorageNotFound => write!(f, "localStorage data not found"), + ImportError::ParseError(e) => write!(f, "Parse error: {}", e), + ImportError::AccessDenied(e) => write!(f, "Access denied: {}", e), + } + } +} + +impl std::error::Error for ImportError {} + +/// Origin prefixes of the MiniMax IndexedDB databases (`__`). +const INDEXED_DB_ORIGIN_PREFIXES: &[&str] = &[ + "https_platform.minimax.io_", + "https_www.minimax.io_", + "https_minimax.io_", + "https_platform.minimaxi.com_", + "https_minimaxi.com_", + "https_www.minimaxi.com_", +]; + +const MINIMAX_LOCAL_STORAGE_ORIGINS: &[&str] = &[ + "https://platform.minimax.io", + "https://www.minimax.io", + "https://minimax.io", + "https://platform.minimaxi.com", + "https://www.minimaxi.com", + "https://minimaxi.com", +]; + +const MINIMAX_SESSION_PATTERNS: &[&str] = &[ + "minimax_user", + "minimax_session", + "platform.minimaxi.com", + "mm_token", + "mm_user_info", +]; + +/// Stores tried in order; a later store is read only when earlier ones yield no session. +const STORAGE_ORDER: [StorageKind; 3] = [ + StorageKind::LocalStorage, + StorageKind::SessionStorage, + StorageKind::IndexedDb { + origin_prefixes: INDEXED_DB_ORIGIN_PREFIXES, + }, +]; + +/// MiniMax localStorage importer +pub struct MiniMaxLocalStorageImporter; + +impl MiniMaxLocalStorageImporter { + /// Import a MiniMax session from Chromium browser storage. + /// + /// Visits every installed Chromium-family browser and profile: Local Storage first, then + /// Session Storage, then MiniMax-origin IndexedDB when the earlier stores yield nothing. + pub fn import_session() -> Result { + Self::import_with(storage_discovery::discover) + } + + fn import_with( + discover: impl Fn(StorageKind) -> Vec, + ) -> Result { + let mut found_any_store = false; + let mut last_error = None; + for kind in STORAGE_ORDER { + for candidate in discover(kind) { + found_any_store = true; + match Self::extract_from_path(&candidate.path, &candidate.label, kind) { + Ok(session) => return Ok(session), + Err(ImportError::StorageNotFound) => {} + Err(error) => last_error = Some(error), + } + } + } + + Err(if found_any_store { + last_error.unwrap_or(ImportError::StorageNotFound) + } else { + ImportError::BrowserNotFound + }) + } + + /// Extract a MiniMax session from one decoded Chromium storage directory. + fn extract_from_path( + path: &Path, + source_label: &str, + kind: StorageKind, + ) -> Result { + let minimax_data = match kind { + StorageKind::LocalStorage => { + let entries = + read_local_storage_entries_for_origins(path, MINIMAX_LOCAL_STORAGE_ORIGINS) + .map_err(|error| ImportError::AccessDenied(error.to_string()))?; + entries.iter().find_map(Self::extract_local_storage_json) + } + StorageKind::SessionStorage | StorageKind::IndexedDb { .. } => { + let entries = leveldb::read_entries(path) + .map_err(|error| ImportError::AccessDenied(error.to_string()))?; + entries.iter().find_map(Self::extract_minimax_entry) + } + }; + + match minimax_data { + Some(json) => Self::parse_session_from_json(&json, source_label), + None => Err(ImportError::StorageNotFound), + } + } + + fn extract_local_storage_json(entry: &LocalStorageEntry) -> Option { + if MINIMAX_SESSION_PATTERNS + .iter() + .any(|pattern| entry.key.contains(pattern)) + { + return serde_json::from_str(&entry.value) + .ok() + .or_else(|| Self::extract_minimax_json(entry.value.as_bytes())); + } + + Self::extract_minimax_json(entry.value.as_bytes()) + } + + fn extract_minimax_entry(entry: &Entry) -> Option { + let key = String::from_utf8_lossy(&entry.key); + let value = String::from_utf8_lossy(&entry.value); + if MINIMAX_SESSION_PATTERNS + .iter() + .any(|pattern| key.contains(pattern)) + { + return serde_json::from_str(&value) + .ok() + .or_else(|| Self::extract_minimax_json(&entry.value)); + } + + Self::extract_minimax_json(&entry.value).or_else(|| Self::extract_minimax_json(&entry.key)) + } + + /// Find MiniMax JSON embedded in a decoded LevelDB key or value. + fn extract_minimax_json(data: &[u8]) -> Option { + let content = String::from_utf8_lossy(data); + for pattern in MINIMAX_SESSION_PATTERNS { + if let Some(parsed) = Self::extract_json_after_pattern(&content, pattern) { + return Some(parsed); + } + } + + None + } + + fn extract_json_after_pattern(content: &str, pattern: &str) -> Option { + let json_start = Self::json_start_after_pattern(content, pattern)?; + let remaining = &content[json_start..]; + let end_idx = Self::matching_json_object_end(remaining)?; + serde_json::from_str(&remaining[..end_idx]).ok() + } + + fn json_start_after_pattern(content: &str, pattern: &str) -> Option { + let start_idx = content.find(pattern)?; + content[start_idx..] + .find('{') + .map(|offset| start_idx + offset) + } + + fn matching_json_object_end(content: &str) -> Option { + let mut depth = 0; + let mut in_string = false; + let mut escaped = false; + for (i, c) in content.char_indices() { + if in_string { + match c { + '\\' if !escaped => escaped = true, + '"' if !escaped => in_string = false, + _ => escaped = false, + } + continue; + } + match c { + '"' => in_string = true, + '{' => depth += 1, + '}' => { + depth -= 1; + if depth == 0 { + return Some(i + 1); + } + } + _ => {} + } + } + None + } + + /// Parse session from extracted JSON + fn parse_session_from_json( + json: &serde_json::Value, + source_label: &str, + ) -> Result { + let access_token = json + .get("access_token") + .or_else(|| json.get("token")) + .or_else(|| json.get("mm_token")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + let user_id = json + .get("user_id") + .or_else(|| json.get("userId")) + .or_else(|| json.get("id")) + .and_then(|v| { + v.as_str() + .map(|s| s.to_string()) + .or_else(|| v.as_i64().map(|n| n.to_string())) + }); + + let group_id = json + .get("group_id") + .or_else(|| json.get("groupId")) + .and_then(|v| { + v.as_str() + .map(|s| s.to_string()) + .or_else(|| v.as_i64().map(|n| n.to_string())) + }); + + let email = json + .get("email") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + let phone = json + .get("phone") + .or_else(|| json.get("mobile")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + let plan_type = json + .get("plan_type") + .or_else(|| json.get("planType")) + .or_else(|| json.get("plan")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + // Need at least access_token or user_id + if access_token.is_none() && user_id.is_none() { + return Err(ImportError::ParseError( + "No valid session data found".to_string(), + )); + } + + Ok(MiniMaxSession { + access_token, + user_id, + group_id, + email, + phone, + plan_type, + source_label: source_label.to_string(), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_session_json() { + let json = serde_json::json!({ + "access_token": "test_token", + "user_id": "12345", + "group_id": "67890", + "email": "test@example.com" + }); + + let result = MiniMaxLocalStorageImporter::parse_session_from_json(&json, "Chrome"); + assert!(result.is_ok()); + + let session = result.unwrap(); + assert_eq!(session.access_token, Some("test_token".to_string())); + assert_eq!(session.user_id, Some("12345".to_string())); + } + + #[test] + fn test_parse_session_empty() { + let json = serde_json::json!({ + "foo": "bar" + }); + + let result = MiniMaxLocalStorageImporter::parse_session_from_json(&json, "Chrome"); + assert!(result.is_err()); + } + + fn candidate(label: &str, path: &Path) -> StorageCandidate { + StorageCandidate { + label: label.to_string(), + path: path.to_path_buf(), + } + } + + fn write_store(dir: &Path, token: Option<&str>) -> std::path::PathBuf { + std::fs::create_dir_all(dir).unwrap(); + let (key, value, local_key, local_value) = match token { + Some(token) => { + let json = format!(r#"{{"access_token":"{token}","user_id":"1"}}"#); + let mut local_value = vec![1]; + local_value.extend_from_slice(json.as_bytes()); + ( + b"minimax_user".to_vec(), + json.as_bytes().to_vec(), + b"_https://platform.minimax.io\0\x01minimax_user".to_vec(), + local_value, + ) + } + None => ( + b"unrelated".to_vec(), + b"unrelated".to_vec(), + b"_https://platform.minimax.io\0\x01unrelated".to_vec(), + b"\x01unrelated".to_vec(), + ), + }; + + let mut batch = Vec::new(); + batch.extend_from_slice(&1u64.to_le_bytes()); + batch.extend_from_slice(&2u32.to_le_bytes()); + for (key, value) in [(key, value), (local_key, local_value)] { + batch.push(1); // put + append_varint(&mut batch, key.len()); + batch.extend_from_slice(&key); + append_varint(&mut batch, value.len()); + batch.extend_from_slice(&value); + } + let mut log = vec![0; 4]; // the best-effort reader does not verify checksums + log.extend_from_slice(&u16::try_from(batch.len()).unwrap().to_le_bytes()); + log.push(1); // full physical record + log.extend_from_slice(&batch); + std::fs::write(dir.join("000003.log"), log).unwrap(); + dir.to_path_buf() + } + + fn append_varint(output: &mut Vec, mut value: usize) { + while value >= 0x80 { + output.push(u8::try_from(value & 0x7f).unwrap() | 0x80); + value >>= 7; + } + output.push(u8::try_from(value).unwrap()); + } + fn discover_from( + local: Vec, + session: Vec, + indexed: Vec, + ) -> impl Fn(StorageKind) -> Vec { + move |kind| match kind { + StorageKind::LocalStorage => local.clone(), + StorageKind::SessionStorage => session.clone(), + StorageKind::IndexedDb { origin_prefixes } => { + assert_eq!(origin_prefixes, INDEXED_DB_ORIGIN_PREFIXES); + indexed.clone() + } + } + } + + #[test] + fn import_prefers_local_storage_over_later_stores() { + let root = tempfile::tempdir().unwrap(); + let local = write_store(&root.path().join("local"), Some("from-local")); + let session = write_store(&root.path().join("session"), Some("from-session")); + + let found = MiniMaxLocalStorageImporter::import_with(discover_from( + vec![candidate("Chrome Default", &local)], + vec![candidate("Chrome Default (Session Storage)", &session)], + Vec::new(), + )) + .unwrap(); + + assert_eq!(found.access_token.as_deref(), Some("from-local")); + assert_eq!(found.source_label, "Chrome Default"); + } + + #[test] + fn import_falls_back_to_session_then_indexed_db_when_earlier_stores_are_empty() { + let root = tempfile::tempdir().unwrap(); + let local = write_store(&root.path().join("local"), None); + let session = write_store(&root.path().join("session"), None); + let indexed = write_store(&root.path().join("indexed"), Some("from-indexed")); + + let found = MiniMaxLocalStorageImporter::import_with(discover_from( + vec![candidate("Edge Default", &local)], + vec![candidate("Edge Default (Session Storage)", &session)], + vec![candidate("Edge Default (IndexedDB)", &indexed)], + )) + .unwrap(); + assert_eq!(found.access_token.as_deref(), Some("from-indexed")); + assert_eq!(found.source_label, "Edge Default (IndexedDB)"); + + let session_token = write_store(&root.path().join("session2"), Some("from-session")); + let found = MiniMaxLocalStorageImporter::import_with(discover_from( + vec![candidate("Edge Default", &local)], + vec![candidate("Edge Default (Session Storage)", &session_token)], + vec![candidate("Edge Default (IndexedDB)", &indexed)], + )) + .unwrap(); + assert_eq!(found.access_token.as_deref(), Some("from-session")); + } + + #[test] + fn import_distinguishes_no_browser_from_no_session() { + let root = tempfile::tempdir().unwrap(); + let empty = write_store(&root.path().join("empty"), None); + + let none = MiniMaxLocalStorageImporter::import_with(discover_from( + Vec::new(), + Vec::new(), + Vec::new(), + )); + assert!(matches!(none, Err(ImportError::BrowserNotFound))); + + let no_session = MiniMaxLocalStorageImporter::import_with(discover_from( + vec![candidate("Brave Default", &empty)], + Vec::new(), + Vec::new(), + )); + assert!(matches!(no_session, Err(ImportError::StorageNotFound))); + } +} diff --git a/rust/src/providers/minimax/mod.rs b/rust/src/providers/minimax/mod.rs index 097e6be2b32..1356206a5e0 100755 --- a/rust/src/providers/minimax/mod.rs +++ b/rust/src/providers/minimax/mod.rs @@ -6,9 +6,17 @@ mod billing; mod coding_plan; mod coding_plan_html; +mod local_storage; mod remains_api; mod token_plan; +// Re-exports for local storage import +#[allow( + unused_imports, + reason = "imports needed for future MiniMax provider wiring" +)] +pub use local_storage::{ImportError, MiniMaxLocalStorageImporter, MiniMaxSession}; + use async_trait::async_trait; use chrono::{DateTime, Utc}; use std::path::PathBuf; From b0fe1653dce26c992b4859a2df7012812e52f476 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:41:40 +0700 Subject: [PATCH 40/40] Pin MiniMax coding-plan page headers and the remains no-plan error --- rust/src/providers/minimax/http_tests.rs | 47 ++++++++++++++++++++++-- rust/src/providers/minimax/mod.rs | 3 +- 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/rust/src/providers/minimax/http_tests.rs b/rust/src/providers/minimax/http_tests.rs index bdced7e1fb7..785148a74af 100644 --- a/rust/src/providers/minimax/http_tests.rs +++ b/rust/src/providers/minimax/http_tests.rs @@ -6,12 +6,22 @@ pub(super) const JSON_ACCEPT: &str = "application/json, text/plain, */*"; /// Console GET with the cookie, JSON accept, XHR and browser headers. pub(super) fn console_mock(server: &mut mockito::Server, path: &str) -> mockito::Mock { + browser_mock(server, path, JSON_ACCEPT, "XMLHttpRequest".into()) +} + +/// Console GET with the cookie, `accept`, `xhr` marker and browser headers. +fn browser_mock( + server: &mut mockito::Server, + path: &str, + accept: &str, + xhr: mockito::Matcher, +) -> mockito::Mock { let base = MiniMaxRegion::Global.base_url(); server .mock("GET", path) .match_header("cookie", "session=fixture") - .match_header("accept", JSON_ACCEPT) - .match_header("x-requested-with", "XMLHttpRequest") + .match_header("accept", accept) + .match_header("x-requested-with", xhr) .match_header("user-agent", MiniMaxProvider::WEB_USER_AGENT) .match_header("accept-language", "en-US,en;q=0.9") .match_header("origin", base) @@ -77,7 +87,38 @@ async fn cookie_remains_request_parses_a_json_body() { ) .await; mock.assert_async().await; - assert!(result.is_err()); + match result { + Err(ProviderError::Other(message)) => assert_eq!(message, "no plan"), + other => panic!("expected Other(\"no plan\"), got {other:?}"), + } +} + +#[tokio::test] +async fn coding_plan_page_request_sends_html_accept_without_the_xhr_marker() { + let mut server = mockito::Server::new_async().await; + let mock = browser_mock( + &mut server, + "/coding-plan", + "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + mockito::Matcher::Missing, + ) + .expect(1) + .create_async() + .await; + let client = http_client().expect("client"); + let response = console_get( + &client, + &format!("{}/coding-plan", server.url()), + "session=fixture", + MiniMaxRegion::Global, + HTML_ACCEPT, + false, + ) + .send() + .await + .expect("send"); + mock.assert_async().await; + assert_eq!(response.status(), reqwest::StatusCode::OK); } /// `None` expects AuthRequired; otherwise `":"`. diff --git a/rust/src/providers/minimax/mod.rs b/rust/src/providers/minimax/mod.rs index 1356206a5e0..668f13f867c 100755 --- a/rust/src/providers/minimax/mod.rs +++ b/rust/src/providers/minimax/mod.rs @@ -30,6 +30,7 @@ use billing::{MiniMaxBillingSummary, attach_billing_summary, parse_billing_summa const CODING_PLAN_PATH: &str = "/user-center/payment/coding-plan"; const CODING_PLAN_QUERY: &str = "cycle_type=3"; const JSON_ACCEPT: &str = "application/json, text/plain, */*"; +const HTML_ACCEPT: &str = "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"; fn http_client() -> Result { crate::core::credentialed_http_client_builder() @@ -433,7 +434,7 @@ impl MiniMaxProvider { ®ion.coding_plan_url(), cookie_header, region, - "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + HTML_ACCEPT, false, ) .send()