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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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 c2adfcef0a42172c659ff706c023fca417a2a628 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:43:52 +0700 Subject: [PATCH 09/44] Delete dead typed Codex build_result path --- rust/src/providers/codex/api.rs | 226 -------------------------------- 1 file changed, 226 deletions(-) diff --git a/rust/src/providers/codex/api.rs b/rust/src/providers/codex/api.rs index 5baf943126f..a38848b017d 100755 --- a/rust/src/providers/codex/api.rs +++ b/rust/src/providers/codex/api.rs @@ -978,82 +978,6 @@ impl CodexApi { Some(CostSnapshot::new(balance, "USD", "Credits")) } - - fn build_result( - &self, - response: UsageResponse, - ) -> Result<(UsageSnapshot, Option), ProviderError> { - let (primary, secondary) = normalize_named_windows( - response - .rate_limit - .as_ref() - .and_then(|rate_limit| rate_limit.primary_window.as_ref()) - .map(rate_window_from_snapshot), - response - .rate_limit - .as_ref() - .and_then(|rate_limit| rate_limit.secondary_window.as_ref()) - .map(rate_window_from_snapshot), - ); - - // Extract code review rate window - let code_review = response - .rate_limit - .as_ref() - .and_then(|rate_limit| rate_limit.code_review_window.as_ref()) - .map(rate_window_from_snapshot); - - // Build usage snapshot - let login_method = response.plan_type.as_ref().map(|pt| match pt.as_str() { - "guest" => "Guest".to_string(), - "free" => "ChatGPT Free".to_string(), - "go" => "ChatGPT Go".to_string(), - "plus" => "ChatGPT Plus".to_string(), - "pro" => "ChatGPT Pro".to_string(), - "team" => "ChatGPT Team".to_string(), - "business" => "ChatGPT Business".to_string(), - "enterprise" => "ChatGPT Enterprise".to_string(), - "education" | "edu" => "ChatGPT Education".to_string(), - other => format!("ChatGPT {}", capitalize(other)), - }); - - let mut usage = UsageSnapshot::new(primary); - if let Some(sec) = secondary { - usage = usage.with_secondary(sec); - } - if let Some(cr) = code_review { - usage = usage.with_code_review(cr); - } - if let Some(method) = login_method { - usage = usage.with_login_method(method); - } - - // Build cost snapshot if credits are present - let credit_limit = response.individual_limit.as_ref().or_else(|| { - response - .rate_limit - .as_ref() - .and_then(|rate_limit| rate_limit.individual_limit.as_ref()) - }); - let cost = response.credits.as_ref().and_then(|credits| { - if credits.has_credits() { - let balance = credits.balance.unwrap_or(0.0); - if credits.unlimited() { - None // Unlimited credits, no need to show - } else if let Some(limit) = - credit_limit.and_then(|limit| limit.to_cost_snapshot(balance)) - { - Some(limit) - } else { - Some(CostSnapshot::new(balance, "USD", "Credits")) - } - } else { - None - } - }); - - Ok((usage, cost)) - } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -1168,20 +1092,6 @@ fn normalize_array_windows( ) } -fn rate_window_from_snapshot(window: &WindowSnapshot) -> RateWindow { - let reset_at = timestamp_to_datetime(window.reset_at); - let used_percent = f64::from(window.used_percent); - RateWindow::with_details( - used_percent, - window - .limit_window_seconds - .and_then(|seconds| u32::try_from(seconds / 60).ok()), - reset_at, - format_reset_countdown(reset_at), - ) - .with_usage_known(valid_used_percent(Some(used_percent))) -} - fn format_plan_type(plan_type: &str) -> String { match plan_type { "guest" => "Guest".to_string(), @@ -1235,48 +1145,6 @@ struct CachedCodexCredentials { credentials: CodexCredentials, } -#[derive(Debug, Deserialize)] -struct UsageResponse { - plan_type: Option, - rate_limit: Option, - credits: Option, - #[serde(default, alias = "individualLimit")] - individual_limit: Option, -} - -#[derive(Debug, Deserialize)] -struct RateLimitDetails { - primary_window: Option, - secondary_window: Option, - code_review_window: Option, - #[serde(default, alias = "individualLimit")] - individual_limit: Option, -} - -#[derive(Debug, Deserialize)] -struct WindowSnapshot { - used_percent: i32, - reset_at: Option, - limit_window_seconds: Option, -} - -#[derive(Debug, Deserialize)] -struct CreditDetails { - has_credits: Option, - unlimited: Option, - balance: Option, -} - -#[derive(Debug, Deserialize)] -struct SpendControlLimitSnapshot { - limit: Option, - used: Option, - #[serde(default, alias = "remainingPercent")] - remaining_percent: Option, - #[serde(default, alias = "resetsAt")] - resets_at: Option, -} - #[derive(Debug, Clone, Deserialize)] pub(super) struct ResetCredit { #[serde(default)] @@ -1351,46 +1219,8 @@ fn apply_reset_credits_window( usage } -impl CreditDetails { - // Helper to safely check has_credits - fn has_credits(&self) -> bool { - self.has_credits.unwrap_or(false) - } - - fn unlimited(&self) -> bool { - self.unlimited.unwrap_or(false) - } -} - -impl SpendControlLimitSnapshot { - fn to_cost_snapshot(&self, balance: f64) -> Option { - let limit = self - .limit - .filter(|limit| limit.is_finite() && *limit >= 0.0)?; - let used = self - .used - .filter(|used| used.is_finite() && *used >= 0.0) - .or_else(|| { - self.remaining_percent - .filter(|pct| pct.is_finite() && *pct >= 0.0) - .map(|remaining| limit * (1.0 - (remaining / 100.0))) - }) - .unwrap_or_else(|| (limit - balance).max(0.0)); - let mut cost = - CostSnapshot::new(used.clamp(0.0, limit), "USD", "Monthly credits").with_limit(limit); - if let Some(resets_at) = timestamp_to_datetime(self.resets_at) { - cost = cost.with_resets_at(resets_at); - } - Some(cost) - } -} - // --- Helper functions --- -fn timestamp_to_datetime(timestamp: Option) -> Option> { - timestamp.and_then(|ts| Utc.timestamp_opt(ts, 0).single()) -} - /// Parse the native `exp` claim from an access-token JWT. Opaque or malformed /// tokens return `None` and are handled by the read-only usage request. fn parse_access_token_expiry(token: &str) -> Option> { @@ -2347,62 +2177,6 @@ mod tests { assert!(usage.extra_rate_windows.is_empty()); } - #[test] - fn maps_top_level_individual_credit_limit_to_cost_snapshot() { - let api = CodexApi::new(); - let (_, cost) = api - .build_result(UsageResponse { - plan_type: None, - rate_limit: None, - credits: Some(CreditDetails { - has_credits: Some(true), - unlimited: Some(false), - balance: Some(7.5), - }), - individual_limit: Some(SpendControlLimitSnapshot { - limit: Some(20.0), - used: Some(12.5), - remaining_percent: None, - resets_at: Some(1783036800), - }), - }) - .expect("codex result"); - let cost = cost.expect("cost"); - assert_eq!(cost.used, 12.5); - assert_eq!(cost.limit, Some(20.0)); - assert!(cost.resets_at.is_some()); - } - - #[test] - fn maps_nested_individual_credit_limit_to_cost_snapshot() { - let api = CodexApi::new(); - let (_, cost) = api - .build_result(UsageResponse { - plan_type: None, - rate_limit: Some(RateLimitDetails { - primary_window: None, - secondary_window: None, - code_review_window: None, - individual_limit: Some(SpendControlLimitSnapshot { - limit: Some(100.0), - used: None, - remaining_percent: Some(60.0), - resets_at: None, - }), - }), - credits: Some(CreditDetails { - has_credits: Some(true), - unlimited: Some(false), - balance: Some(60.0), - }), - individual_limit: None, - }) - .expect("codex result"); - let cost = cost.expect("cost"); - assert_eq!(cost.used, 40.0); - assert_eq!(cost.limit, Some(100.0)); - } - fn win(minutes: u32, used: f64) -> RateWindow { RateWindow::with_details(used, Some(minutes), None, None) } From 63fdd2281b536da488e69f61ab6dd5b05e16f355 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:43:53 +0700 Subject: [PATCH 10/44] Share the Codex reset observation store reader --- .../src/providers/codex/reset_observations.rs | 102 ++++++++++++------ 1 file changed, 69 insertions(+), 33 deletions(-) diff --git a/rust/src/providers/codex/reset_observations.rs b/rust/src/providers/codex/reset_observations.rs index 0902e25f1a5..cab1b4e4d8a 100644 --- a/rust/src/providers/codex/reset_observations.rs +++ b/rust/src/providers/codex/reset_observations.rs @@ -69,17 +69,6 @@ pub struct CodexResetObservationMergeResult { pub changed: bool, } -/// Path of the store under the shared configuration root. -pub fn default_store_path() -> Result { - let root = dirs::config_dir().ok_or_else(|| { - CodexResetObservationError::Read(std::io::Error::new( - std::io::ErrorKind::NotFound, - "configuration directory not found", - )) - })?; - Ok(root.join("CodexBar").join(STORE_RELATIVE_PATH)) -} - /// Path of the store relative to an explicit config root (tests, proof homes). pub fn store_path(config_root: &Path) -> PathBuf { config_root.join(STORE_RELATIVE_PATH) @@ -93,16 +82,20 @@ fn validate_scope(account_scope: &str) -> Result<(), CodexResetObservationError> } } -/// Read the observations recorded for `account_scope`. -pub fn load_reset_observations( - config_root: &Path, - account_scope: &str, -) -> Result, CodexResetObservationError> { - validate_scope(account_scope)?; - let path = store_path(config_root); - let raw = match secure_file::read_string(&path) { +/// A missing store reads as empty. Only the merge path also treats a blank +/// file as empty; `load_reset_observations` reports it as a decode error. +fn read_store( + path: &Path, + blank_is_empty: bool, +) -> Result { + let raw = match secure_file::read_string(path) { + Ok(raw) if blank_is_empty && raw.trim().is_empty() => { + return Ok(CodexResetObservationStore::default()); + } Ok(raw) => raw, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(CodexResetObservationStore::default()); + } Err(error) => return Err(CodexResetObservationError::Read(error)), }; let store: CodexResetObservationStore = @@ -112,6 +105,16 @@ pub fn load_reset_observations( store.version, )); } + Ok(store) +} + +/// Read the observations recorded for `account_scope`. +pub fn load_reset_observations( + config_root: &Path, + account_scope: &str, +) -> Result, CodexResetObservationError> { + validate_scope(account_scope)?; + let store = read_store(&store_path(config_root), false)?; Ok(store .accounts .get(account_scope) @@ -163,19 +166,7 @@ pub fn merge_and_persist_reset_observation( } let path = store_path(config_root); - let mut store = match secure_file::read_string(&path) { - Ok(raw) if raw.trim().is_empty() => CodexResetObservationStore::default(), - Ok(raw) => serde_json::from_str(&raw).map_err(CodexResetObservationError::Deserialize)?, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - CodexResetObservationStore::default() - } - Err(error) => return Err(CodexResetObservationError::Read(error)), - }; - if store.version != STORE_VERSION { - return Err(CodexResetObservationError::UnsupportedVersion( - store.version, - )); - } + let mut store = read_store(&path, true)?; let (changed, observations) = { let rows = store.accounts.entry(account_scope.to_string()).or_default(); let changed = merge_reset_observations( @@ -312,6 +303,51 @@ mod tests { )); } + #[test] + fn blank_store_fails_load_but_merges_as_empty() { + let root = temp_root("blank"); + let path = store_path(root.path()); + std::fs::create_dir_all(path.parent().unwrap()).expect("mkdir"); + secure_file::write_string( + &path, " +", + ) + .expect("write"); + assert!(matches!( + load_reset_observations(root.path(), CODEX_ACCOUNT_SCOPE), + Err(CodexResetObservationError::Deserialize(_)) + )); + let merged = merge_and_persist_reset_observation( + root.path(), + CODEX_ACCOUNT_SCOPE, + at(4_000), + at(1_000), + ) + .expect("merge over blank store"); + assert!(merged.changed); + assert_eq!( + load_reset_observations(root.path(), CODEX_ACCOUNT_SCOPE).expect("load"), + merged.observations + ); + } + + #[test] + fn unsupported_version_blocks_merge() { + let root = temp_root("merge-version"); + let path = store_path(root.path()); + std::fs::create_dir_all(path.parent().unwrap()).expect("mkdir"); + secure_file::write_string(&path, r#"{"version": 99, "accounts": {}}"#).expect("write"); + assert!(matches!( + merge_and_persist_reset_observation( + root.path(), + CODEX_ACCOUNT_SCOPE, + at(4_000), + at(1_000) + ), + Err(CodexResetObservationError::UnsupportedVersion(99)) + )); + } + #[test] fn persisted_file_is_secure_wrapped() { let root = temp_root("secure"); From 1e7f0033af5846e8baea343236c8343bd684b530 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:48:37 +0700 Subject: [PATCH 11/44] Pin Codex named-window routing --- rust/src/providers/codex/api.rs | 44 +++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/rust/src/providers/codex/api.rs b/rust/src/providers/codex/api.rs index a38848b017d..1f2af0a3d60 100755 --- a/rust/src/providers/codex/api.rs +++ b/rust/src/providers/codex/api.rs @@ -2181,6 +2181,50 @@ mod tests { RateWindow::with_details(used, Some(minutes), None, None) } + #[test] + fn normalize_named_windows_routes_by_role() { + // Primary is reported as -1.0 for the "No active 5h session" placeholder. + let s = |used| win(300, used); + let w = |used| win(10_080, used); + let u = |used| win(999, used); + let m = |used| win(43_200, used); + let rows: Vec<(Option, Option, f64, Option)> = vec![ + (None, None, -1.0, None), + (Some(w(1.0)), None, -1.0, Some(1.0)), + (Some(s(1.0)), None, 1.0, None), + (Some(u(1.0)), None, 1.0, None), + (Some(m(1.0)), None, 1.0, None), + (None, Some(w(2.0)), -1.0, Some(2.0)), + (None, Some(s(2.0)), 2.0, None), + (None, Some(u(2.0)), 2.0, None), + (Some(w(1.0)), Some(s(2.0)), 2.0, Some(1.0)), + (Some(w(1.0)), Some(u(2.0)), -1.0, Some(1.0)), + (Some(u(1.0)), Some(s(2.0)), 2.0, Some(1.0)), + (Some(s(1.0)), Some(w(2.0)), 1.0, Some(2.0)), + (Some(u(1.0)), Some(w(2.0)), 1.0, Some(2.0)), + (Some(s(1.0)), Some(s(2.0)), 1.0, Some(2.0)), + (Some(w(1.0)), Some(w(2.0)), 1.0, Some(2.0)), + (Some(u(1.0)), Some(u(2.0)), 1.0, Some(2.0)), + (Some(m(1.0)), Some(w(2.0)), 1.0, Some(2.0)), + ]; + for (index, (primary, secondary, want_primary, want_secondary)) in + rows.into_iter().enumerate() + { + let (got_primary, got_secondary) = normalize_named_windows(primary, secondary); + let got_primary = if got_primary.is_informational { + -1.0 + } else { + got_primary.used_percent + }; + assert_eq!(got_primary, want_primary, "row {index} primary"); + assert_eq!( + got_secondary.map(|window| window.used_percent), + want_secondary, + "row {index} secondary" + ); + } + } + #[test] fn f5_normalize_array_routes_session_weekly_monthly_to_lanes() { // 5h session + weekly + monthly → (session, weekly, monthly, None) From c340721547dabd051e5929929a5199bfda831321 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:51:30 +0700 Subject: [PATCH 12/44] Collapse Codex API request and cache layers --- rust/src/providers/codex/api.rs | 257 +++++++++++------------ rust/src/providers/codex/subscription.rs | 30 ++- 2 files changed, 134 insertions(+), 153 deletions(-) diff --git a/rust/src/providers/codex/api.rs b/rust/src/providers/codex/api.rs index 1f2af0a3d60..30cfd1f1c2a 100755 --- a/rust/src/providers/codex/api.rs +++ b/rust/src/providers/codex/api.rs @@ -6,7 +6,6 @@ use super::{pat, weekly_reset}; use crate::core::{ CostSnapshot, NamedRateWindow, ProviderError, RateWindow, RateWindowCadence, UsageSnapshot, }; -use crate::providers::openai::OpenAISubscriptionFetchResult; use base64::Engine; use chrono::{DateTime, TimeZone, Utc}; use serde::Deserialize; @@ -42,6 +41,13 @@ struct ResetCreditsCache { confirmation_failure_at: Option, } +impl ResetCreditsCache { + fn confirmation_failed_recently(&self) -> bool { + self.confirmation_failure_at + .is_some_and(|failed| failed.elapsed() < RESET_CREDITS_CACHE_TTL) + } +} + /// Codex API client pub struct CodexApi { client: reqwest::Client, @@ -110,14 +116,14 @@ impl CodexApi { { usage = usage.with_login_method(format_plan_type(&plan_type)); } - let usage = self - .enrich_subscription_metadata( - &self.resolve_base_url(), - &token, - account_id.as_deref(), - usage, - ) - .await; + let usage = subscription::enrich_subscription_metadata( + self, + &self.resolve_base_url(), + &token, + account_id.as_deref(), + usage, + ) + .await; Ok((usage, cost, account_identity)) } @@ -198,22 +204,22 @@ impl CodexApi { %error, "Codex weekly reset confirmation failed; preserving first successful usage" ); - let result = Self::preserve_after_confirmation_failure( - &state, - first_usage, - first_cost, - ); + let usage = weekly_reset::preserve_weekly(&state, first_usage); weekly_reset::save(&scope, &state); - let (usage, cost) = result; - let usage = self - .enrich_subscription_metadata( - &base_url, - &creds.access_token, - creds.account_id.as_deref(), - usage, - ) - .await; - return Ok((usage, cost, creds.account_id.clone(), displayed_credits)); + let usage = subscription::enrich_subscription_metadata( + self, + &base_url, + &creds.access_token, + creds.account_id.as_deref(), + usage, + ) + .await; + return Ok(( + usage, + first_cost, + creds.account_id.clone(), + displayed_credits, + )); } }; let confirmation_credits = if initial_credits.is_some() { @@ -256,51 +262,36 @@ impl CodexApi { } } }; - let usage = self - .enrich_subscription_metadata( - &base_url, - &creds.access_token, - creds.account_id.as_deref(), - usage, - ) - .await; + let usage = subscription::enrich_subscription_metadata( + self, + &base_url, + &creds.access_token, + creds.account_id.as_deref(), + usage, + ) + .await; let usage = apply_reset_credits_window(usage, displayed_credits.as_ref()); Ok((usage, cost, creds.account_id.clone(), displayed_credits)) } - /// Subscription metadata is optional enrichment. Usage remains usable when - /// the endpoint is unavailable, malformed, unauthorized, or points at a - /// custom backend. A successful empty cancellation response is the only - /// result allowed to clear dates on the fresh snapshot. - async fn enrich_subscription_metadata( + /// Bearer GET shared by the ChatGPT backend endpoints; an empty account id + /// sends no `ChatGPT-Account-Id` header. + pub(super) fn authed_get( &self, - base_url: &str, + url: &str, access_token: &str, account_id: Option<&str>, - usage: UsageSnapshot, - ) -> UsageSnapshot { - subscription::enrich_subscription_metadata(self, base_url, access_token, account_id, usage) - .await - } - - async fn fetch_subscription_metadata( - &self, - base_url: &str, - access_token: &str, - account_id: Option<&str>, - ) -> OpenAISubscriptionFetchResult { - subscription::fetch_subscription_metadata(self, base_url, access_token, account_id).await - } - - fn preserve_after_confirmation_failure( - state: &weekly_reset::AccountState, - first_usage: UsageSnapshot, - first_cost: Option, - ) -> (UsageSnapshot, Option) { - ( - weekly_reset::preserve_weekly(state, first_usage), - first_cost, - ) + ) -> reqwest::RequestBuilder { + let request = self + .client + .get(url) + .header("Authorization", format!("Bearer {access_token}")) + .header("User-Agent", "CodexBar") + .header("Accept", "application/json"); + match account_id.filter(|id| !id.is_empty()) { + Some(account_id) => request.header("ChatGPT-Account-Id", account_id), + None => request, + } } async fn fetch_usage_once( @@ -309,19 +300,11 @@ impl CodexApi { base_url: &str, ) -> Result<(UsageSnapshot, Option, Option), ProviderError> { let url = format!("{}{}", base_url, USAGE_PATH); - let mut request = self - .client - .get(&url) - .header("Authorization", format!("Bearer {}", creds.access_token)) - .header("User-Agent", "CodexBar") - .header("Accept", "application/json") - .timeout(std::time::Duration::from_secs(30)); - if let Some(account_id) = &creds.account_id - && !account_id.is_empty() - { - request = request.header("ChatGPT-Account-Id", account_id); - } - let response = request.send().await?; + let response = self + .authed_get(&url, &creds.access_token, creds.account_id.as_deref()) + .timeout(std::time::Duration::from_secs(30)) + .send() + .await?; if !response.status().is_success() { return Err(super::authenticated_http_error(response, "Codex API").await); } @@ -413,27 +396,14 @@ impl CodexApi { let slot = self.reset_credits_cache_slot(creds, base_url); let mut cache = slot.lock().await; cache.value.as_ref()?; - if cache - .confirmation_failure_at - .is_some_and(|failed| failed.elapsed() < RESET_CREDITS_CACHE_TTL) - { + if cache.confirmation_failed_recently() { return None; } if cache.loaded_at.is_some_and(|loaded| loaded >= started) { return cache.value.clone(); } - let fresh = self - .fetch_rate_limit_reset_credits(creds, base_url) + self.refetch_reset_credits_into(&mut cache, creds, base_url) .await - .ok(); - if let Some(value) = fresh.as_ref() { - cache.value = Some(value.clone()); - cache.loaded_at = Some(Instant::now()); - cache.confirmation_failure_at = None; - } else { - cache.confirmation_failure_at = Some(Instant::now()); - } - fresh } async fn fetch_rate_limit_reset_credits_fresh( @@ -443,12 +413,21 @@ impl CodexApi { ) -> Option { let slot = self.reset_credits_cache_slot(creds, base_url); let mut cache = slot.lock().await; - if cache - .confirmation_failure_at - .is_some_and(|failed| failed.elapsed() < RESET_CREDITS_CACHE_TTL) - { + if cache.confirmation_failed_recently() { return None; } + self.refetch_reset_credits_into(&mut cache, creds, base_url) + .await + } + + /// A failed read keeps the cached inventory but blocks further + /// confirmation reads for one cache TTL. + async fn refetch_reset_credits_into( + &self, + cache: &mut ResetCreditsCache, + creds: &CodexCredentials, + base_url: &str, + ) -> Option { let fresh = self .fetch_rate_limit_reset_credits(creds, base_url) .await @@ -468,18 +447,14 @@ impl CodexApi { creds: &CodexCredentials, base_url: &str, ) -> Result { - let mut request = self - .client - .get(format!("{}{}", base_url, RESET_CREDITS_PATH)) - .header("Authorization", format!("Bearer {}", creds.access_token)) - .header("User-Agent", "CodexBar") - .header("Accept", "application/json"); - if let Some(account_id) = &creds.account_id - && !account_id.is_empty() - { - request = request.header("ChatGPT-Account-Id", account_id); - } - let response = request.send().await?; + let response = self + .authed_get( + &format!("{}{}", base_url, RESET_CREDITS_PATH), + &creds.access_token, + creds.account_id.as_deref(), + ) + .send() + .await?; if !response.status().is_success() { return Err(super::authenticated_http_error(response, "Codex reset credits").await); } @@ -829,23 +804,7 @@ impl CodexApi { .collect::>(); let (primary, secondary, monthly, code_review) = normalize_array_windows(windows); // F5 (upstream 0.48.0): route monthly to its own tertiary lane. - let mut usage = UsageSnapshot::new(primary); - if let Some(sec) = secondary { - usage = usage.with_secondary(sec); - } - if let Some(mo) = monthly { - usage = usage.with_tertiary(mo); - } - if let Some(cr) = code_review { - usage = usage.with_model_specific(cr); - } - return ( - usage.primary, - usage.secondary, - usage.tertiary, - usage.model_specific, - false, - ); + return (primary, secondary, monthly, code_review, false); } // Try direct fields @@ -1008,14 +967,7 @@ fn normalize_named_windows( ) -> (RateWindow, Option) { match (primary, secondary) { (None, None) => (RateWindow::no_active_session(), None), - (Some(window), None) => { - if codex_window_role(&window) == CodexWindowRole::Weekly { - (RateWindow::no_active_session(), Some(window)) - } else { - (window, None) - } - } - (None, Some(window)) => { + (Some(window), None) | (None, Some(window)) => { if codex_window_role(&window) == CodexWindowRole::Weekly { (RateWindow::no_active_session(), Some(window)) } else { @@ -1029,15 +981,12 @@ fn normalize_named_windows( (RateWindow::no_active_session(), Some(primary)) } (CodexWindowRole::Unknown, CodexWindowRole::Session) => (secondary, Some(primary)), - (CodexWindowRole::Session, CodexWindowRole::Weekly) - | (CodexWindowRole::Unknown, CodexWindowRole::Weekly) => (primary, Some(secondary)), _ => (primary, Some(secondary)), } } } } -/// Normalize an array of Codex windows without relying on the API's ordering. /// Normalize an array of Codex windows without relying on the API's ordering. /// /// Returns (session, weekly, monthly, code_review). F5 (upstream 0.48.0): @@ -2225,6 +2174,44 @@ mod tests { } } + #[tokio::test] + async fn authed_get_sends_account_header_only_when_non_empty() { + let mut server = mockito::Server::new_async().await; + let with_account = server + .mock("GET", "/with") + .match_header("authorization", "Bearer tok") + .match_header("user-agent", "CodexBar") + .match_header("accept", "application/json") + .match_header("chatgpt-account-id", "acct-1") + .with_status(200) + .create_async() + .await; + let without_account = server + .mock("GET", "/without") + .match_header("authorization", "Bearer tok") + .match_header("chatgpt-account-id", mockito::Matcher::Missing) + .expect(2) + .with_status(200) + .create_async() + .await; + let api = CodexApi::new(); + for (path, account_id) in [ + ("/with", Some("acct-1")), + ("/without", Some("")), + ("/without", None), + ] { + let status = api + .authed_get(&format!("{}{path}", server.url()), "tok", account_id) + .send() + .await + .expect("send") + .status(); + assert_eq!(status.as_u16(), 200, "{path} {account_id:?}"); + } + with_account.assert_async().await; + without_account.assert_async().await; + } + #[test] fn f5_normalize_array_routes_session_weekly_monthly_to_lanes() { // 5h session + weekly + monthly → (session, weekly, monthly, None) @@ -2275,7 +2262,7 @@ mod tests { let state = weekly_reset::AccountState::default(); let first = UsageSnapshot::new(RateWindow::new(10.0)).with_secondary(RateWindow::new(0.5)); let cost = Some(CostSnapshot::new(3.25, "USD", "Monthly")); - let (usage, kept_cost) = CodexApi::preserve_after_confirmation_failure(&state, first, cost); + let (usage, kept_cost) = (weekly_reset::preserve_weekly(&state, first), cost); assert!((usage.secondary.expect("weekly").used_percent - 0.5).abs() < f64::EPSILON); assert_eq!(kept_cost.expect("cost").used, 3.25); } diff --git a/rust/src/providers/codex/subscription.rs b/rust/src/providers/codex/subscription.rs index 8a127ac445c..175cdd90a3f 100644 --- a/rust/src/providers/codex/subscription.rs +++ b/rust/src/providers/codex/subscription.rs @@ -7,6 +7,10 @@ use super::CodexApi; const SUBSCRIPTION_PATH: &str = "/subscriptions"; +/// Subscription metadata is optional enrichment. Usage remains usable when +/// the endpoint is unavailable, malformed, unauthorized, or points at a +/// custom backend. A successful empty cancellation response is the only +/// result allowed to clear dates on the fresh snapshot. pub(super) async fn enrich_subscription_metadata( api: &CodexApi, base_url: &str, @@ -17,16 +21,13 @@ pub(super) async fn enrich_subscription_metadata( if !crate::settings::Settings::load().codex_openai_web_extras() { return usage; } - match api - .fetch_subscription_metadata(base_url, access_token, account_id) - .await - { + match fetch_subscription_metadata(api, base_url, access_token, account_id).await { OpenAISubscriptionFetchResult::Success(metadata) => usage.with_subscription(metadata), OpenAISubscriptionFetchResult::Unavailable => usage, } } -pub(super) async fn fetch_subscription_metadata( +async fn fetch_subscription_metadata( api: &CodexApi, base_url: &str, access_token: &str, @@ -42,22 +43,15 @@ pub(super) async fn fetch_subscription_metadata( return OpenAISubscriptionFetchResult::Unavailable; } - let mut request = api - .client - .get(format!( - "{}{}", - base_url.trim_end_matches('/'), - SUBSCRIPTION_PATH - )) - .header("Authorization", format!("Bearer {access_token}")) - .header("User-Agent", "CodexBar") - .header("Accept", "application/json") + let request = api + .authed_get( + &format!("{}{}", base_url.trim_end_matches('/'), SUBSCRIPTION_PATH), + access_token, + account_id, + ) .header("Cache-Control", "no-cache, no-store, max-age=0") .header("Pragma", "no-cache") .timeout(Duration::from_secs(8)); - if let Some(account_id) = account_id.filter(|id| !id.is_empty()) { - request = request.header("ChatGPT-Account-Id", account_id); - } let Ok(response) = request.send().await else { return OpenAISubscriptionFetchResult::Unavailable; }; From 65db4ccac8547d2b710cc8dcd9e76bb687a17c6e Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:53:29 +0700 Subject: [PATCH 13/44] Pin every Codex weekly-reset diagnostic code --- .../src/providers/codex/weekly_reset/tests.rs | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/rust/src/providers/codex/weekly_reset/tests.rs b/rust/src/providers/codex/weekly_reset/tests.rs index 6848b8ff835..f506291d5f7 100644 --- a/rust/src/providers/codex/weekly_reset/tests.rs +++ b/rust/src/providers/codex/weekly_reset/tests.rs @@ -11,6 +11,21 @@ fn reset_diagnostic_codes_are_fixed_and_redacted() { ResetDiagnosticReason::ExpiredCandidate.code(), ResetDiagnosticReason::ChangedCreditInventory.code(), ResetDiagnosticReason::StoreRequested.code(), + ResetDiagnosticReason::MissingPreviousSnapshot.code(), + ResetDiagnosticReason::MissingWeeklyWindow.code(), + ResetDiagnosticReason::ResetThresholdMismatch.code(), + ResetDiagnosticReason::InvalidResetBoundary.code(), + ResetDiagnosticReason::InconsistentResetBoundary.code(), + ResetDiagnosticReason::UnsupportedResetBoundary.code(), + ResetDiagnosticReason::PlanMismatch.code(), + ResetDiagnosticReason::PlanChanged.code(), + ResetDiagnosticReason::MissingCreditInventory.code(), + ResetDiagnosticReason::EvidenceVersionMismatch.code(), + ResetDiagnosticReason::FutureCandidate.code(), + ResetDiagnosticReason::StaleObservation.code(), + ResetDiagnosticReason::MinimumDelay.code(), + ResetDiagnosticReason::ConfirmedObservation.code(), + ResetDiagnosticReason::StoreUnavailable.code(), ]; assert_eq!( codes, @@ -20,6 +35,21 @@ fn reset_diagnostic_codes_are_fixed_and_redacted() { "expiredCandidate", "changedCreditInventory", "storeRequested", + "missingPreviousSnapshot", + "missingWeeklyWindow", + "resetThresholdMismatch", + "invalidResetBoundary", + "inconsistentResetBoundary", + "unsupportedResetBoundary", + "planMismatch", + "planChanged", + "missingCreditInventory", + "evidenceVersionMismatch", + "futureCandidate", + "staleObservation", + "minimumDelay", + "confirmedObservation", + "storeUnavailable", ] ); assert!(codes.iter().all(|code| { From 37b7f8b8d6cf264b489609e318f46ccb35f7b2f5 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:54:35 +0700 Subject: [PATCH 14/44] Fold Codex weekly-reset rejections into one admission check --- rust/src/providers/codex/weekly_reset.rs | 186 +++++++----------- .../codex/weekly_reset/diagnostics.rs | 84 ++++---- 2 files changed, 110 insertions(+), 160 deletions(-) diff --git a/rust/src/providers/codex/weekly_reset.rs b/rust/src/providers/codex/weekly_reset.rs index d6acaa7292c..077352c2fe8 100644 --- a/rust/src/providers/codex/weekly_reset.rs +++ b/rust/src/providers/codex/weekly_reset.rs @@ -132,54 +132,43 @@ pub(super) fn scope_key(account_id: Option<&str>, auth_path: &Path) -> String { digest.iter().map(|byte| format!("{byte:02x}")).collect() } +/// An unreadable, malformed or other-version state file reads as absent. +fn read_state_file(path: &Path) -> Option { + crate::secure_file::read_string(path) + .ok() + .and_then(|raw| serde_json::from_str::(&raw).ok()) + .filter(|file| file.version == STATE_VERSION) +} + pub(super) fn load(scope: &str) -> AccountState { - let Some(path) = state_path() else { - return AccountState::default(); - }; - let Ok(raw) = crate::secure_file::read_string(&path) else { - return AccountState::default(); - }; - let Ok(file) = serde_json::from_str::(&raw) else { - return AccountState::default(); - }; - if file.version != STATE_VERSION { - return AccountState::default(); - } - file.accounts.get(scope).cloned().unwrap_or_default() + state_path() + .and_then(|path| read_state_file(&path)) + .and_then(|file| file.accounts.get(scope).cloned()) + .unwrap_or_default() } pub(super) fn save(scope: &str, state: &AccountState) { - let Some(path) = state_path() else { + let skipped = || { log_reset_diagnostic( "candidatePersistence", "skipped", ResetDiagnosticReason::StoreUnavailable, ); - return; }; - let mut file = crate::secure_file::read_string(&path) - .ok() - .and_then(|raw| serde_json::from_str::(&raw).ok()) - .filter(|file| file.version == STATE_VERSION) - .unwrap_or_else(|| StateFile { - version: STATE_VERSION, - accounts: HashMap::new(), - }); - file.accounts.insert(scope.to_string(), state.clone()); - let Some(parent) = path.parent() else { - log_reset_diagnostic( - "candidatePersistence", - "skipped", - ResetDiagnosticReason::StoreUnavailable, - ); + let Some(path) = state_path() else { + skipped(); return; }; - if std::fs::create_dir_all(parent).is_err() { - log_reset_diagnostic( - "candidatePersistence", - "skipped", - ResetDiagnosticReason::StoreUnavailable, - ); + let mut file = read_state_file(&path).unwrap_or_else(|| StateFile { + version: STATE_VERSION, + accounts: HashMap::new(), + }); + file.accounts.insert(scope.to_string(), state.clone()); + if path + .parent() + .is_none_or(|parent| std::fs::create_dir_all(parent).is_err()) + { + skipped(); return; } if let Ok(raw) = serde_json::to_string_pretty(&file) { @@ -413,91 +402,67 @@ fn maybe_store_delayed_candidate( exact_oauth: bool, observed_at: DateTime, ) { + match delayed_candidate_admission( + state, + initial, + confirmation, + confirmation_inventory, + exact_oauth, + observed_at, + ) { + Ok(candidate) => { + state.candidate = Some(candidate); + log_reset_diagnostic( + "candidateCreation", + "created", + ResetDiagnosticReason::CandidateCreated, + ); + } + Err(reason) => log_reset_diagnostic("candidateCreation", "rejected", reason), + } +} + +/// The checks run in a fixed order; the first failure is the logged reason. +fn delayed_candidate_admission( + state: &AccountState, + initial: &UsageSnapshot, + confirmation: &UsageSnapshot, + confirmation_inventory: Option<&CreditInventory>, + exact_oauth: bool, + observed_at: DateTime, +) -> Result { + use ResetDiagnosticReason as Reason; if !exact_oauth { - log_reset_diagnostic( - "candidateCreation", - "rejected", - ResetDiagnosticReason::SourceNotExactOAuth, - ); - return; + return Err(Reason::SourceNotExactOAuth); } if !plans_match(state.plan.as_deref(), initial, confirmation) { - log_reset_diagnostic( - "candidateCreation", - "rejected", - ResetDiagnosticReason::PlanMismatch, - ); - return; + return Err(Reason::PlanMismatch); } - let Some(previous_weekly) = state.published_weekly.as_ref() else { - log_reset_diagnostic( - "candidateCreation", - "rejected", - ResetDiagnosticReason::MissingPreviousSnapshot, - ); - return; - }; - let Some(initial_weekly) = weekly(initial) else { - log_reset_diagnostic( - "candidateCreation", - "rejected", - ResetDiagnosticReason::MissingWeeklyWindow, - ); - return; - }; - let Some(confirmation_weekly) = weekly(confirmation) else { - log_reset_diagnostic( - "candidateCreation", - "rejected", - ResetDiagnosticReason::MissingWeeklyWindow, - ); - return; - }; - let Some(previous_inventory) = state.credit_inventory.as_ref() else { - log_reset_diagnostic( - "candidateCreation", - "rejected", - ResetDiagnosticReason::MissingCreditInventory, - ); - return; - }; - let Some(confirmation_inventory) = confirmation_inventory else { - log_reset_diagnostic( - "candidateCreation", - "rejected", - ResetDiagnosticReason::MissingCreditInventory, - ); - return; - }; + let previous_weekly = state + .published_weekly + .as_ref() + .ok_or(Reason::MissingPreviousSnapshot)?; + let initial_weekly = weekly(initial).ok_or(Reason::MissingWeeklyWindow)?; + let confirmation_weekly = weekly(confirmation).ok_or(Reason::MissingWeeklyWindow)?; + let previous_inventory = state + .credit_inventory + .as_ref() + .ok_or(Reason::MissingCreditInventory)?; + let confirmation_inventory = confirmation_inventory.ok_or(Reason::MissingCreditInventory)?; if previous_inventory.available_count == 0 || previous_inventory != confirmation_inventory { - log_reset_diagnostic( - "candidateCreation", - "rejected", - ResetDiagnosticReason::ChangedCreditInventory, - ); - return; + return Err(Reason::ChangedCreditInventory); } if !supported_delayed_boundary(previous_weekly, initial_weekly) || !supported_delayed_boundary(previous_weekly, confirmation_weekly) { - log_reset_diagnostic( - "candidateCreation", - "rejected", - ResetDiagnosticReason::UnsupportedResetBoundary, - ); - return; + return Err(Reason::UnsupportedResetBoundary); } if boundary_distance_seconds(initial_weekly, confirmation_weekly).abs() >= RESET_TOLERANCE_SECONDS { - log_reset_diagnostic( - "candidateCreation", - "rejected", - ResetDiagnosticReason::InconsistentResetBoundary, - ); - return; + return Err(Reason::InconsistentResetBoundary); } - state.candidate = Some(DelayedCandidate { + Ok(DelayedCandidate { evidence_version: EVIDENCE_VERSION, first_observed_at: initial.updated_at, created_at: observed_at, @@ -505,12 +470,7 @@ fn maybe_store_delayed_candidate( weekly: confirmation_weekly.clone(), plan: confirmation.login_method.clone(), inventory: confirmation_inventory.clone(), - }); - log_reset_diagnostic( - "candidateCreation", - "created", - ResetDiagnosticReason::CandidateCreated, - ); + }) } fn delayed_candidate_decision( diff --git a/rust/src/providers/codex/weekly_reset/diagnostics.rs b/rust/src/providers/codex/weekly_reset/diagnostics.rs index c870b60a288..fec1c17999e 100644 --- a/rust/src/providers/codex/weekly_reset/diagnostics.rs +++ b/rust/src/providers/codex/weekly_reset/diagnostics.rs @@ -1,52 +1,42 @@ -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) enum ResetDiagnosticReason { - CandidateCreated, - SourceNotExactOAuth, - MissingPreviousSnapshot, - MissingWeeklyWindow, - ResetThresholdMismatch, - InvalidResetBoundary, - InconsistentResetBoundary, - UnsupportedResetBoundary, - PlanMismatch, - PlanChanged, - MissingCreditInventory, - ChangedCreditInventory, - EvidenceVersionMismatch, - FutureCandidate, - ExpiredCandidate, - StaleObservation, - MinimumDelay, - ConfirmedObservation, - StoreUnavailable, - StoreRequested, -} +/// Each reason maps to one fixed, redacted log code. +macro_rules! reset_diagnostic_reasons { + ($($variant:ident => $code:literal,)*) => { + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub(super) enum ResetDiagnosticReason { + $($variant,)* + } -impl ResetDiagnosticReason { - pub(super) const fn code(self) -> &'static str { - match self { - Self::CandidateCreated => "candidateCreated", - Self::SourceNotExactOAuth => "sourceNotExactOAuth", - Self::MissingPreviousSnapshot => "missingPreviousSnapshot", - Self::MissingWeeklyWindow => "missingWeeklyWindow", - Self::ResetThresholdMismatch => "resetThresholdMismatch", - Self::InvalidResetBoundary => "invalidResetBoundary", - Self::InconsistentResetBoundary => "inconsistentResetBoundary", - Self::UnsupportedResetBoundary => "unsupportedResetBoundary", - Self::PlanMismatch => "planMismatch", - Self::PlanChanged => "planChanged", - Self::MissingCreditInventory => "missingCreditInventory", - Self::ChangedCreditInventory => "changedCreditInventory", - Self::EvidenceVersionMismatch => "evidenceVersionMismatch", - Self::FutureCandidate => "futureCandidate", - Self::ExpiredCandidate => "expiredCandidate", - Self::StaleObservation => "staleObservation", - Self::MinimumDelay => "minimumDelay", - Self::ConfirmedObservation => "confirmedObservation", - Self::StoreUnavailable => "storeUnavailable", - Self::StoreRequested => "storeRequested", + impl ResetDiagnosticReason { + pub(super) const fn code(self) -> &'static str { + match self { + $(Self::$variant => $code,)* + } + } } - } + }; +} + +reset_diagnostic_reasons! { + CandidateCreated => "candidateCreated", + SourceNotExactOAuth => "sourceNotExactOAuth", + MissingPreviousSnapshot => "missingPreviousSnapshot", + MissingWeeklyWindow => "missingWeeklyWindow", + ResetThresholdMismatch => "resetThresholdMismatch", + InvalidResetBoundary => "invalidResetBoundary", + InconsistentResetBoundary => "inconsistentResetBoundary", + UnsupportedResetBoundary => "unsupportedResetBoundary", + PlanMismatch => "planMismatch", + PlanChanged => "planChanged", + MissingCreditInventory => "missingCreditInventory", + ChangedCreditInventory => "changedCreditInventory", + EvidenceVersionMismatch => "evidenceVersionMismatch", + FutureCandidate => "futureCandidate", + ExpiredCandidate => "expiredCandidate", + StaleObservation => "staleObservation", + MinimumDelay => "minimumDelay", + ConfirmedObservation => "confirmedObservation", + StoreUnavailable => "storeUnavailable", + StoreRequested => "storeRequested", } pub(super) fn log_reset_diagnostic( From de87ae1117ffd19d189c2bae1aa053220879b84e Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:57:35 +0700 Subject: [PATCH 15/44] Share Codex API test fixtures --- rust/src/providers/codex/api.rs | 294 ++++++++++---------------------- 1 file changed, 86 insertions(+), 208 deletions(-) diff --git a/rust/src/providers/codex/api.rs b/rust/src/providers/codex/api.rs index 30cfd1f1c2a..29ddf36dbfb 100755 --- a/rust/src/providers/codex/api.rs +++ b/rust/src/providers/codex/api.rs @@ -1373,6 +1373,7 @@ mod credential_retry_tests; #[cfg(test)] mod tests { use super::*; + use crate::providers::test_support::{mock_response, mock_response_expect, mock_status_expect}; use serde_json::json; #[test] @@ -1432,98 +1433,37 @@ mod tests { #[test] fn next_expiry_picks_soonest_available() { - let now = DateTime::parse_from_rfc3339("2026-07-01T00:00:00Z") - .unwrap() - .with_timezone(&Utc); + let now = utc("2026-07-01T00:00:00Z"); let credits = vec![ - ResetCredit { - id: None, - reset_type: None, - status: Some("available".into()), - expires_at: Some("2026-07-10T00:00:00Z".into()), - }, - ResetCredit { - id: None, - reset_type: None, - status: Some("available".into()), - expires_at: Some("2026-07-05T00:00:00Z".into()), - }, - ResetCredit { - id: None, - reset_type: None, - status: Some("available".into()), - expires_at: Some("2026-07-20T00:00:00Z".into()), - }, + credit(Some("available"), "2026-07-10T00:00:00Z"), + credit(Some("available"), "2026-07-05T00:00:00Z"), + credit(Some("available"), "2026-07-20T00:00:00Z"), ]; let expiry = next_available_reset_credit_expiry(&credits, now).expect("expiry"); - assert_eq!( - expiry, - DateTime::parse_from_rfc3339("2026-07-05T00:00:00Z") - .unwrap() - .with_timezone(&Utc) - ); + assert_eq!(expiry, utc("2026-07-05T00:00:00Z")); } #[test] fn next_expiry_skips_past_and_non_available() { - let now = DateTime::parse_from_rfc3339("2026-07-01T00:00:00Z") - .unwrap() - .with_timezone(&Utc); + let now = utc("2026-07-01T00:00:00Z"); let credits = vec![ - ResetCredit { - id: None, - reset_type: None, - status: Some("available".into()), - expires_at: Some("2026-06-01T00:00:00Z".into()), - }, - ResetCredit { - id: None, - reset_type: None, - status: Some("used".into()), - expires_at: Some("2026-07-03T00:00:00Z".into()), - }, - ResetCredit { - id: None, - reset_type: None, - status: Some("AVAILABLE".into()), - expires_at: Some("2026-07-08T00:00:00Z".into()), - }, - ResetCredit { - id: None, - reset_type: None, - status: None, - expires_at: Some("2026-07-09T00:00:00Z".into()), - }, + credit(Some("available"), "2026-06-01T00:00:00Z"), + credit(Some("used"), "2026-07-03T00:00:00Z"), + credit(Some("AVAILABLE"), "2026-07-08T00:00:00Z"), + credit(None, "2026-07-09T00:00:00Z"), ]; let expiry = next_available_reset_credit_expiry(&credits, now).expect("expiry"); - assert_eq!( - expiry, - DateTime::parse_from_rfc3339("2026-07-08T00:00:00Z") - .unwrap() - .with_timezone(&Utc) - ); + assert_eq!(expiry, utc("2026-07-08T00:00:00Z")); } #[test] fn reset_credits_window_sets_informational_and_expiry() { - let now = DateTime::parse_from_rfc3339("2026-07-01T00:00:00Z") - .unwrap() - .with_timezone(&Utc); + let now = utc("2026-07-01T00:00:00Z"); let reset = ResetCredits { available_count: 2, credits: vec![ - ResetCredit { - id: None, - reset_type: None, - status: Some("available".into()), - expires_at: Some("2026-07-15T12:00:00Z".into()), - }, - ResetCredit { - id: None, - reset_type: None, - status: Some("available".into()), - expires_at: Some("2026-07-10T12:00:00Z".into()), - }, + credit(Some("available"), "2026-07-15T12:00:00Z"), + credit(Some("available"), "2026-07-10T12:00:00Z"), ], }; let window = reset_credits_rate_window(&reset, now); @@ -1532,21 +1472,12 @@ mod tests { window.reset_description.as_deref(), Some("2 reset credits available") ); - assert_eq!( - window.resets_at, - Some( - DateTime::parse_from_rfc3339("2026-07-10T12:00:00Z") - .unwrap() - .with_timezone(&Utc) - ) - ); + assert_eq!(window.resets_at, Some(utc("2026-07-10T12:00:00Z"))); } #[test] fn reset_credits_window_count_only_without_expiry() { - let now = DateTime::parse_from_rfc3339("2026-07-01T00:00:00Z") - .unwrap() - .with_timezone(&Utc); + let now = utc("2026-07-01T00:00:00Z"); let reset = ResetCredits { available_count: 1, credits: vec![], @@ -1560,6 +1491,55 @@ mod tests { assert!(window.resets_at.is_none()); } + const PLUS_USAGE: &str = r#"{"plan_type":"plus","rate_limit":{"primary_window":{"used_percent":10,"limit_window_seconds":18000}}}"#; + + fn utc(value: &str) -> DateTime { + DateTime::parse_from_rfc3339(value) + .unwrap() + .with_timezone(&Utc) + } + + fn credit(status: Option<&str>, expires_at: &str) -> ResetCredit { + ResetCredit { + id: None, + reset_type: None, + status: status.map(str::to_string), + expires_at: Some(expires_at.to_string()), + } + } + + /// An unsigned JWT whose payload carries only `exp` (seconds since the epoch). + fn jwt_with_exp(exp: i64) -> String { + let payload = + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(format!(r#"{{"exp":{exp}}}"#)); + format!("header.{payload}.signature") + } + + fn external_creds(access_token: &str, last_refresh: Option>) -> CodexCredentials { + CodexCredentials { + access_token: access_token.to_string(), + account_id: None, + is_external_oauth: true, + access_token_expires_at: None, + last_refresh, + } + } + + async fn mock_reset_credits( + server: &mut mockito::ServerGuard, + available_count: u32, + ) -> mockito::Mock { + mock_response_expect( + server, + "GET", + RESET_CREDITS_PATH, + 200, + format!(r#"{{"available_count":{available_count},"credits":[]}}"#), + 1, + ) + .await + } + fn write_codex_home(base_url: &str) -> tempfile::TempDir { let dir = tempfile::tempdir().expect("temp codex home"); std::fs::write( @@ -1578,12 +1558,7 @@ mod tests { #[tokio::test] async fn reset_credit_cache_single_flight_and_unknown_are_ten_minute_observations() { let mut server = mockito::Server::new_async().await; - let request = server - .mock("GET", "/wham/rate-limit-reset-credits") - .expect(1) - .with_status(503) - .create_async() - .await; + let request = mock_status_expect(&mut server, "GET", RESET_CREDITS_PATH, 503, 1).await; let home = write_codex_home(&server.url()); let api = CodexApi::new().with_codex_home(home.path()); let creds = api.load_credentials().await.unwrap(); @@ -1682,14 +1657,7 @@ mod tests { // makes its own observation instead of reading the other's. let mut server = mockito::Server::new_async().await; let base = server.url(); - let first = server - .mock("GET", "/wham/rate-limit-reset-credits") - .expect(1) - .with_status(200) - .with_header("content-type", "application/json") - .with_body(r#"{"available_count":2,"credits":[]}"#) - .create_async() - .await; + let first = mock_reset_credits(&mut server, 2).await; let first_home = write_codex_home(&base); let first_api = CodexApi::new().with_codex_home(first_home.path()); let first_creds = first_api.load_credentials().await.unwrap(); @@ -1701,12 +1669,7 @@ mod tests { first.assert_async().await; first.remove_async().await; - let second = server - .mock("GET", "/wham/rate-limit-reset-credits") - .expect(1) - .with_status(503) - .create_async() - .await; + let second = mock_status_expect(&mut server, "GET", RESET_CREDITS_PATH, 503, 1).await; let second_home = write_codex_home(&base); let second_api = CodexApi::new().with_codex_home(second_home.path()); let second_creds = second_api.load_credentials().await.unwrap(); @@ -1724,14 +1687,7 @@ mod tests { #[tokio::test] async fn suspicious_weekly_reset_uses_independent_credit_observations() { let mut server = mockito::Server::new_async().await; - let cached_response = server - .mock("GET", "/wham/rate-limit-reset-credits") - .expect(1) - .with_status(200) - .with_header("content-type", "application/json") - .with_body(r#"{"available_count":2,"credits":[]}"#) - .create_async() - .await; + let cached_response = mock_reset_credits(&mut server, 2).await; let home = write_codex_home(&server.url()); let api = CodexApi::new().with_codex_home(home.path()); let creds = api.load_credentials().await.unwrap(); @@ -1743,14 +1699,7 @@ mod tests { cached_response.remove_async().await; let started = Instant::now(); - let initial_response = server - .mock("GET", "/wham/rate-limit-reset-credits") - .expect(1) - .with_status(200) - .with_header("content-type", "application/json") - .with_body(r#"{"available_count":1,"credits":[]}"#) - .create_async() - .await; + let initial_response = mock_reset_credits(&mut server, 1).await; let initial = api .fresh_reset_credits_for_confirmation(&creds, &base, started) .await @@ -1759,14 +1708,7 @@ mod tests { initial_response.assert_async().await; initial_response.remove_async().await; - let confirmation_response = server - .mock("GET", "/wham/rate-limit-reset-credits") - .expect(1) - .with_status(200) - .with_header("content-type", "application/json") - .with_body(r#"{"available_count":0,"credits":[]}"#) - .create_async() - .await; + let confirmation_response = mock_reset_credits(&mut server, 0).await; let confirmation = api .fetch_rate_limit_reset_credits_fresh(&creds, &base) .await @@ -1778,14 +1720,7 @@ mod tests { #[tokio::test] async fn pending_delayed_candidate_revalidates_with_a_fresh_credit_observation() { let mut server = mockito::Server::new_async().await; - let candidate_observation = server - .mock("GET", "/wham/rate-limit-reset-credits") - .expect(1) - .with_status(200) - .with_header("content-type", "application/json") - .with_body(r#"{"available_count":2,"credits":[]}"#) - .create_async() - .await; + let candidate_observation = mock_reset_credits(&mut server, 2).await; let home = write_codex_home(&server.url()); let api = CodexApi::new().with_codex_home(home.path()); let creds = api.load_credentials().await.unwrap(); @@ -1799,14 +1734,7 @@ mod tests { // A later refresh: the ten-minute cache still holds the observation // that created the candidate. let started = Instant::now(); - let changed = server - .mock("GET", "/wham/rate-limit-reset-credits") - .expect(1) - .with_status(200) - .with_header("content-type", "application/json") - .with_body(r#"{"available_count":1,"credits":[]}"#) - .create_async() - .await; + let changed = mock_reset_credits(&mut server, 1).await; let without_candidate = api .initial_reset_credits(&creds, &base, started, cached.clone(), false) .await; @@ -1835,9 +1763,7 @@ mod tests { .match_header("authorization", "Bearer test-token") .with_status(200) .with_header("content-type", "application/json") - .with_body( - r#"{"plan_type":"plus","rate_limit":{"primary_window":{"used_percent":10,"limit_window_seconds":18000}}}"#, - ) + .with_body(PLUS_USAGE) .create_async() .await; @@ -1874,9 +1800,7 @@ mod tests { extra.window.reset_description.as_deref(), Some("2 reset credits available") ); - let expected = DateTime::parse_from_rfc3339(&soonest) - .unwrap() - .with_timezone(&Utc); + let expected = utc(&soonest); assert_eq!(extra.window.resets_at, Some(expected)); } @@ -1918,9 +1842,7 @@ mod tests { .match_header("authorization", "Bearer opaque-token") .with_status(200) .with_header("content-type", "application/json") - .with_body( - r#"{"plan_type":"plus","rate_limit":{"primary_window":{"used_percent":10,"limit_window_seconds":18000}}}"#, - ) + .with_body(PLUS_USAGE) .create_async() .await; let reset_mock = server @@ -1959,23 +1881,9 @@ mod tests { async fn fetch_usage_skips_reset_credits_when_available_count_zero() { let mut server = mockito::Server::new_async().await; - let usage_mock = server - .mock("GET", "/wham/usage") - .with_status(200) - .with_header("content-type", "application/json") - .with_body( - r#"{"plan_type":"plus","rate_limit":{"primary_window":{"used_percent":10,"limit_window_seconds":18000}}}"#, - ) - .create_async() - .await; + let usage_mock = mock_response(&mut server, "GET", USAGE_PATH, 200, PLUS_USAGE).await; - let reset_mock = server - .mock("GET", "/wham/rate-limit-reset-credits") - .with_status(200) - .with_header("content-type", "application/json") - .with_body(r#"{"available_count":0,"credits":[]}"#) - .create_async() - .await; + let reset_mock = mock_reset_credits(&mut server, 0).await; let home = write_codex_home(&server.url()); let api = CodexApi::new().with_codex_home(home.path()); @@ -2309,13 +2217,7 @@ mod tests { #[test] fn external_oauth_gate_fails_closed_without_last_refresh() { - let creds = CodexCredentials { - access_token: "access".to_string(), - account_id: None, - is_external_oauth: true, - access_token_expires_at: None, - last_refresh: None, - }; + let creds = external_creds("access", None); let err = CodexApi::enforce_external_oauth_gate(&creds) .expect_err("external OAuth without provenance must fail closed"); assert!(matches!(err, ProviderError::AuthRequired)); @@ -2324,26 +2226,14 @@ mod tests { #[test] fn external_oauth_gate_ignores_old_last_refresh_for_opaque_token() { let old = Utc::now() - chrono::Duration::days(10); - let creds = CodexCredentials { - access_token: "access".to_string(), - account_id: None, - is_external_oauth: true, - access_token_expires_at: None, - last_refresh: Some(old), - }; + let creds = external_creds("access", Some(old)); assert!(CodexApi::enforce_external_oauth_gate(&creds).is_ok()); } #[test] fn external_oauth_gate_allows_refresh_provenance() { let fresh = Utc::now() - chrono::Duration::hours(1); - let creds = CodexCredentials { - access_token: "access".to_string(), - account_id: None, - is_external_oauth: true, - access_token_expires_at: None, - last_refresh: Some(fresh), - }; + let creds = external_creds("access", Some(fresh)); assert!(CodexApi::enforce_external_oauth_gate(&creds).is_ok()); } @@ -2351,9 +2241,7 @@ mod tests { fn external_oauth_gate_uses_future_jwt_expiry_over_old_last_refresh() { let now = Utc::now(); let future = now + chrono::Duration::hours(2); - let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD - .encode(format!(r#"{{"exp":{}}}"#, future.timestamp())); - let token = format!("header.{payload}.signature"); + let token = jwt_with_exp(future.timestamp()); let json = format!( r#"{{"tokens":{{"access_token":"{token}","refresh_token":"refresh"}},"last_refresh":"2026-01-01T00:00:00Z"}}"# ); @@ -2366,9 +2254,7 @@ mod tests { #[test] fn external_oauth_gate_rejects_expired_jwt() { let expired = Utc::now() - chrono::Duration::minutes(1); - let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD - .encode(format!(r#"{{"exp":{}}}"#, expired.timestamp())); - let token = format!("header.{payload}.signature"); + let token = jwt_with_exp(expired.timestamp()); let json = format!( r#"{{"tokens":{{"access_token":"{token}","refresh_token":"refresh"}},"last_refresh":"{}"}}"#, Utc::now().to_rfc3339() @@ -2382,9 +2268,7 @@ mod tests { #[test] fn external_oauth_gate_requires_cli_refresh_when_jwt_is_near_expiry() { let soon = Utc::now() + chrono::Duration::minutes(2); - let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD - .encode(format!(r#"{{"exp":{}}}"#, soon.timestamp())); - let token = format!("header.{payload}.signature"); + let token = jwt_with_exp(soon.timestamp()); let json = format!( r#"{{"tokens":{{"access_token":"{token}","refresh_token":"refresh"}},"last_refresh":"{}"}}"#, Utc::now().to_rfc3339() @@ -2397,13 +2281,7 @@ mod tests { #[test] fn external_oauth_gate_allows_missing_last_refresh_when_opted_in() { - let creds = CodexCredentials { - access_token: "opaque-token".to_string(), - account_id: None, - is_external_oauth: true, - access_token_expires_at: None, - last_refresh: None, - }; + let creds = external_creds("opaque-token", None); assert!(CodexApi::enforce_external_oauth_gate_at(&creds, true, Utc::now()).is_ok()); } From 0c76b42a8ea13d97c462edbb396f1d2dab108943 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sat, 10 Oct 2026 16:01:26 +0700 Subject: [PATCH 16/44] Name the Codex named-window test row type --- rust/src/providers/codex/api.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/rust/src/providers/codex/api.rs b/rust/src/providers/codex/api.rs index 29ddf36dbfb..185ba2c53a0 100755 --- a/rust/src/providers/codex/api.rs +++ b/rust/src/providers/codex/api.rs @@ -2045,7 +2045,8 @@ mod tests { let w = |used| win(10_080, used); let u = |used| win(999, used); let m = |used| win(43_200, used); - let rows: Vec<(Option, Option, f64, Option)> = vec![ + type Row = (Option, Option, f64, Option); + let rows: Vec = vec![ (None, None, -1.0, None), (Some(w(1.0)), None, -1.0, Some(1.0)), (Some(s(1.0)), None, 1.0, None), From 65e2af1ce82360e6066dcc506f2fb10c65bc8c5b Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sat, 10 Oct 2026 16:04:29 +0700 Subject: [PATCH 17/44] Share Codex weekly-reset test helpers --- .../src/providers/codex/weekly_reset/tests.rs | 97 ++++++++----------- .../codex/weekly_reset/tests/plan_change.rs | 62 ++---------- 2 files changed, 50 insertions(+), 109 deletions(-) diff --git a/rust/src/providers/codex/weekly_reset/tests.rs b/rust/src/providers/codex/weekly_reset/tests.rs index f506291d5f7..5058638c910 100644 --- a/rust/src/providers/codex/weekly_reset/tests.rs +++ b/rust/src/providers/codex/weekly_reset/tests.rs @@ -75,6 +75,37 @@ fn snapshot(used: f64, reset_days: i64, captured_minutes: i64) -> UsageSnapshot snapshot } +/// Exact-OAuth confirmation at `now()` where both fetches saw the same inventory. +fn confirm( + state: &mut AccountState, + initial: &UsageSnapshot, + confirmation: &UsageSnapshot, + inventory: Option<&CreditInventory>, +) -> ConfirmationDecision { + confirmation_decision( + state, + initial, + inventory, + confirmation, + inventory, + true, + now(), + ) +} + +/// A candidate admitted at `now()` against the `credit-a` inventory. +fn pending_candidate(weekly: RateWindow, plan: &str) -> DelayedCandidate { + DelayedCandidate { + evidence_version: EVIDENCE_VERSION, + first_observed_at: now(), + created_at: now(), + snapshot_updated_at: now(), + weekly, + plan: Some(plan.to_string()), + inventory: inventory("credit-a"), + } +} + fn inventory(id: &str) -> CreditInventory { CreditInventory { available_count: 1, @@ -150,15 +181,7 @@ fn early_low_usage_requires_confirmation_without_spending_credit() { ); let confirmation = snapshot(0.0, 9, 2); assert_eq!( - confirmation_decision( - &mut state, - &initial, - Some(&inv), - &confirmation, - Some(&inv), - true, - now(), - ), + confirm(&mut state, &initial, &confirmation, Some(&inv)), ConfirmationDecision::Preserve ); assert!(state.candidate.is_some()); @@ -172,15 +195,7 @@ fn delayed_candidate_publishes_after_sixty_seconds_and_expires_after_thirty_minu let confirmation = snapshot(0.0, 9, 2); let inv = inventory("credit-a"); assert_eq!( - confirmation_decision( - &mut state, - &initial, - Some(&inv), - &confirmation, - Some(&inv), - true, - now(), - ), + confirm(&mut state, &initial, &confirmation, Some(&inv)), ConfirmationDecision::Preserve ); let current = snapshot(0.0, 9, 3); @@ -223,15 +238,10 @@ fn delayed_candidate_publishes_after_sixty_seconds_and_expires_after_thirty_minu #[test] fn credits_only_refresh_retains_candidate_and_account_scope_hashes_differ() { let mut state = baseline(); - state.candidate = Some(DelayedCandidate { - evidence_version: EVIDENCE_VERSION, - first_observed_at: now(), - created_at: now(), - snapshot_updated_at: now(), - weekly: snapshot(0.0, 9, 1).secondary.unwrap(), - plan: Some("ChatGPT Pro".to_string()), - inventory: inventory("credit-a"), - }); + state.candidate = Some(pending_candidate( + snapshot(0.0, 9, 1).secondary.unwrap(), + "ChatGPT Pro", + )); let mut credits_only = UsageSnapshot::new(RateWindow::new(20.0)); credits_only.updated_at = now() + chrono::Duration::minutes(1); // A credits-only refresh has no weekly window and may omit both plan and @@ -260,15 +270,10 @@ fn credits_only_refresh_retains_candidate_and_account_scope_hashes_differ() { #[test] fn credits_only_refresh_candidate_survives_state_reload_until_full_usage() { let mut state = baseline(); - state.candidate = Some(DelayedCandidate { - evidence_version: EVIDENCE_VERSION, - first_observed_at: now(), - created_at: now(), - snapshot_updated_at: now(), - weekly: snapshot(0.0, 9, 1).secondary.unwrap(), - plan: Some("ChatGPT Pro".to_string()), - inventory: inventory("credit-a"), - }); + state.candidate = Some(pending_candidate( + snapshot(0.0, 9, 1).secondary.unwrap(), + "ChatGPT Pro", + )); let candidate_before = serde_json::to_value(&state.candidate).unwrap(); let mut credits_only = UsageSnapshot::new(RateWindow::new(20.0)); credits_only.updated_at = now() + chrono::Duration::minutes(1); @@ -337,15 +342,7 @@ fn consumed_credit_allows_immediate_confirmation() { credits: Vec::new(), }; assert_eq!( - confirmation_decision( - &mut state, - &initial, - Some(&consumed), - &confirmation, - Some(&consumed), - true, - now(), - ), + confirm(&mut state, &initial, &confirmation, Some(&consumed)), ConfirmationDecision::Publish ); } @@ -383,15 +380,7 @@ fn rolling_state() -> AccountState { let initial = rolling_snapshot(0.0, 7 * 24 * 60, 1, WEEK_SECONDS - 1); let confirmation = rolling_snapshot(0.0, 7 * 24 * 60, 2, WEEK_SECONDS - 2); assert_eq!( - confirmation_decision( - &mut state, - &initial, - Some(&inv), - &confirmation, - Some(&inv), - true, - now(), - ), + confirm(&mut state, &initial, &confirmation, Some(&inv)), ConfirmationDecision::Preserve ); assert!(state.candidate.is_some()); diff --git a/rust/src/providers/codex/weekly_reset/tests/plan_change.rs b/rust/src/providers/codex/weekly_reset/tests/plan_change.rs index 2a22edb68ef..ba8f45809fc 100644 --- a/rust/src/providers/codex/weekly_reset/tests/plan_change.rs +++ b/rust/src/providers/codex/weekly_reset/tests/plan_change.rs @@ -36,15 +36,7 @@ fn plan_upgrade_starts_a_new_baseline_and_publishes_the_new_plan() { assert!(state.credit_inventory.is_none()); assert!(state.candidate.is_none()); assert_eq!( - confirmation_decision( - &mut state, - &initial, - Some(&inv), - &confirmation, - Some(&inv), - true, - now(), - ), + confirm(&mut state, &initial, &confirmation, Some(&inv)), ConfirmationDecision::Publish ); } @@ -63,15 +55,7 @@ fn same_plan_near_zero_reading_keeps_the_previous_weekly_pinned() { ); assert!(state.published_weekly.is_some()); assert_eq!( - confirmation_decision( - &mut state, - &initial, - Some(&inv), - &confirmation, - Some(&inv), - true, - now(), - ), + confirm(&mut state, &initial, &confirmation, Some(&inv)), ConfirmationDecision::Preserve ); } @@ -93,15 +77,7 @@ fn plan_upgrade_does_not_pin_the_previous_plan_weekly_window() { #[test] fn plan_change_discards_a_pending_candidate() { let mut state = plus_baseline(); - state.candidate = Some(DelayedCandidate { - evidence_version: EVIDENCE_VERSION, - first_observed_at: now(), - created_at: now(), - snapshot_updated_at: now(), - weekly: RateWindow::new(0.0), - plan: Some("ChatGPT Plus".to_string()), - inventory: inventory("credit-a"), - }); + state.candidate = Some(pending_candidate(RateWindow::new(0.0), "ChatGPT Plus")); let current = plan_snapshot(Some("ChatGPT Pro"), 5.0, 10); assert_eq!( initial_decision(&mut state, ¤t, None, true, now()), @@ -181,15 +157,7 @@ fn near_zero_confirmation_must_report_the_initial_plan() { let initial = plan_snapshot(Some("ChatGPT Pro"), 0.0, 10); let confirmation = plan_snapshot(confirmation_plan, 0.0, 11); assert_eq!( - confirmation_decision( - &mut state, - &initial, - Some(&inv), - &confirmation, - Some(&inv), - true, - now(), - ), + confirm(&mut state, &initial, &confirmation, Some(&inv)), ConfirmationDecision::Preserve, "{confirmation_plan:?} baseline {has_baseline}" ); @@ -204,7 +172,7 @@ fn nonzero_confirmation_can_publish_its_own_plan() { let initial = plan_snapshot(Some("ChatGPT Pro"), 0.0, 10); let confirmation = plan_snapshot(Some("ChatGPT Plus"), 5.0, 11); assert_eq!( - confirmation_decision(&mut state, &initial, None, &confirmation, None, true, now()), + confirm(&mut state, &initial, &confirmation, None), ConfirmationDecision::Publish ); } @@ -253,15 +221,7 @@ fn new_plan_replaces_the_previous_plan_quota_baseline() { InitialDecision::Publish => current.clone(), InitialDecision::RequiresConfirmation => { assert_eq!( - confirmation_decision( - &mut state, - ¤t, - Some(&inv), - &confirmation, - Some(&inv), - true, - now(), - ), + confirm(&mut state, ¤t, &confirmation, Some(&inv)), ConfirmationDecision::Publish, "{used}% confirmation" ); @@ -299,15 +259,7 @@ fn same_or_unknown_plan_cannot_discard_previous_quota_evidence() { "{plan:?}" ); assert_eq!( - confirmation_decision( - &mut state, - ¤t, - Some(&inv), - ¤t, - Some(&inv), - true, - now(), - ), + confirm(&mut state, ¤t, ¤t, Some(&inv)), ConfirmationDecision::Preserve, "{plan:?}" ); From dbb5cb68647c16256e18fc05866b0ebe4704c324 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sat, 10 Oct 2026 16:06:48 +0700 Subject: [PATCH 18/44] Share the Copilot request tail and quota slots --- rust/src/providers/copilot/api.rs | 144 ++++++++++++------------------ 1 file changed, 57 insertions(+), 87 deletions(-) diff --git a/rust/src/providers/copilot/api.rs b/rust/src/providers/copilot/api.rs index 9b97b0d7b55..16b9694fb4d 100755 --- a/rust/src/providers/copilot/api.rs +++ b/rust/src/providers/copilot/api.rs @@ -64,35 +64,17 @@ impl CopilotApi { github_host: Option<&str>, seat_credit_entitlement: Option, ) -> Result { - let api_url = copilot_usage_url(github_host); - let response = self + let request = self .client - .get(api_url) + .get(copilot_usage_url(github_host)) .header("Authorization", format!("token {}", token.trim())) .header("Accept", "application/json") .header("Editor-Version", "vscode/1.96.2") .header("Editor-Plugin-Version", "copilot-chat/0.26.7") .header("User-Agent", "GitHubCopilotChat/0.26.7") - .header("X-Github-Api-Version", "2025-04-01") - .send() - .await - .map_err(|e| ProviderError::Other(format!("Request failed: {}", e)))?; - - if response.status() == 401 || response.status() == 403 { - return Err(ProviderError::AuthRequired); - } - - if !response.status().is_success() { - return Err(ProviderError::Other(format!( - "GitHub Copilot usage endpoint returned {}", - response.status() - ))); - } - - let usage_response: CopilotUsageResponse = response - .json() - .await - .map_err(|e| ProviderError::Parse(e.to_string()))?; + .header("X-Github-Api-Version", "2025-04-01"); + let usage_response: CopilotUsageResponse = + send_json(request, "GitHub Copilot usage endpoint").await?; snapshot_from_response_with_seat_entitlement(usage_response, seat_credit_entitlement) } @@ -103,33 +85,14 @@ impl CopilotApi { token: &str, github_host: Option<&str>, ) -> Result { - let url = github_api_url(github_host, GITHUB_USER_PATH); - let response = self + let request = self .client - .get(url) + .get(github_api_url(github_host, GITHUB_USER_PATH)) .header("Authorization", format!("token {}", token.trim())) .header("Accept", "application/vnd.github+json") .header("User-Agent", "Win-CodexBar") - .header("X-GitHub-Api-Version", "2022-11-28") - .send() - .await - .map_err(|e| ProviderError::Other(format!("Request failed: {}", e)))?; - - if response.status() == 401 || response.status() == 403 { - return Err(ProviderError::AuthRequired); - } - - if !response.status().is_success() { - return Err(ProviderError::Other(format!( - "GitHub identity endpoint returned {}", - response.status() - ))); - } - - response - .json() - .await - .map_err(|e| ProviderError::Parse(e.to_string())) + .header("X-GitHub-Api-Version", "2022-11-28"); + send_json(request, "GitHub identity endpoint").await } /// Resolve the Copilot OAuth token from settings/legacy API key, GitHub @@ -237,6 +200,30 @@ impl Default for CopilotApi { } } +/// 401/403 mean the token is rejected; any other non-success names `endpoint`. +async fn send_json( + request: reqwest::RequestBuilder, + endpoint: &str, +) -> Result { + let response = request + .send() + .await + .map_err(|e| ProviderError::Other(format!("Request failed: {}", e)))?; + let status = response.status(); + if status == 401 || status == 403 { + return Err(ProviderError::AuthRequired); + } + if !status.is_success() { + return Err(ProviderError::Other(format!( + "{endpoint} returned {status}" + ))); + } + response + .json() + .await + .map_err(|e| ProviderError::Parse(e.to_string())) +} + // --- API Response Types --- #[derive(Debug, Deserialize)] @@ -521,10 +508,7 @@ impl CopilotUsageResponse { fn credits_used_counter(&self) -> Option { let mut chat: Option = None; let mut first: Option = None; - for (key, value) in &self.quota_snapshots.entries { - let Ok(snapshot) = serde_json::from_value::(value.clone()) else { - continue; - }; + for (key, snapshot) in self.snapshots() { let Some(credits) = snapshot.credits_used else { continue; }; @@ -534,64 +518,50 @@ impl CopilotUsageResponse { match classify_quota_kind(key, snapshot.quota_id.as_deref().unwrap_or_default()) { CopilotQuotaKind::Premium => return Some(credits), CopilotQuotaKind::Chat => { - if chat.is_none() { - chat = Some(credits); - } + chat.get_or_insert(credits); } _ => { - if first.is_none() { - first = Some(credits); - } + first.get_or_insert(credits); } } } chat.or(first) } + /// Quota snapshot entries that decode; malformed entries are skipped. + fn snapshots(&self) -> impl Iterator { + self.quota_snapshots + .entries + .iter() + .filter_map(|(key, value)| { + serde_json::from_value::(value.clone()) + .ok() + .map(|snapshot| (key, snapshot)) + }) + } + fn usable_quotas(&self, reset: Option>) -> UsableQuotas { let mut quotas = UsableQuotas::default(); - for (key, value) in &self.quota_snapshots.entries { - let Ok(snapshot) = serde_json::from_value::(value.clone()) else { - continue; - }; + for (key, snapshot) in self.snapshots() { let Some(quota) = UsableQuota::from_snapshot(key, snapshot) else { continue; }; - - match quota.kind { - CopilotQuotaKind::Premium => { - if quotas.first.is_none() { - quotas.first = Some(quota.clone()); - } - if quotas.premium.is_none() { - quotas.premium = Some(quota); - } - } - CopilotQuotaKind::Chat => { - if quotas.first.is_none() { - quotas.first = Some(quota.clone()); - } - if quotas.chat.is_none() { - quotas.chat = Some(quota); - } - } - CopilotQuotaKind::Completions => { - if quotas.first.is_none() { - quotas.first = Some(quota.clone()); - } - if quotas.completions.is_none() { - quotas.completions = Some(quota); - } - } + let slot = match quota.kind { + CopilotQuotaKind::Premium => &mut quotas.premium, + CopilotQuotaKind::Chat => &mut quotas.chat, + CopilotQuotaKind::Completions => &mut quotas.completions, CopilotQuotaKind::Other => { quotas.extra.push(NamedRateWindow::new( quota.id.clone(), quota.title.clone(), quota.to_rate_window(reset), )); + continue; } - } + }; + slot.get_or_insert_with(|| quota.clone()); + quotas.first.get_or_insert(quota); } let completions = UsableQuota::from_limited( From a0997d197ae09df8ffed5b7fb3a48d2c6cf3aee0 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sat, 10 Oct 2026 16:08:41 +0700 Subject: [PATCH 19/44] Share Copilot snapshot test helpers --- rust/src/providers/copilot/api.rs | 97 +++++++++++++++---------------- 1 file changed, 48 insertions(+), 49 deletions(-) diff --git a/rust/src/providers/copilot/api.rs b/rust/src/providers/copilot/api.rs index 16b9694fb4d..c52c6437e67 100755 --- a/rust/src/providers/copilot/api.rs +++ b/rust/src/providers/copilot/api.rs @@ -779,14 +779,32 @@ where mod tests { use super::*; - fn parse_snapshot(json: &str) -> UsageSnapshot { + fn parse_with_seat(json: &str, seat: Option) -> Result { let response: CopilotUsageResponse = serde_json::from_str(json).unwrap(); - snapshot_from_response_with_seat_entitlement(response, None).unwrap() + snapshot_from_response_with_seat_entitlement(response, seat) + } + + fn parse_snapshot(json: &str) -> UsageSnapshot { + parse_with_seat(json, None).unwrap() } fn parse_snapshot_result(json: &str) -> Result { - let response: CopilotUsageResponse = serde_json::from_str(json).unwrap(); - snapshot_from_response_with_seat_entitlement(response, None) + parse_with_seat(json, None) + } + + fn seat_window(usage: &UsageSnapshot) -> Option<&NamedRateWindow> { + usage + .extra_rate_windows + .iter() + .find(|window| window.id == SEAT_CREDIT_WINDOW_ID) + } + + fn assert_used(window: &RateWindow, expected: f64) { + assert!( + (window.used_percent - expected).abs() < 0.001, + "{} != {expected}", + window.used_percent + ); } #[test] @@ -813,8 +831,8 @@ mod tests { ); assert_eq!(usage.login_method.as_deref(), Some("Copilot Pro")); - assert!((usage.primary.used_percent - 20.0).abs() < 0.001); - assert!((usage.secondary.unwrap().used_percent - 10.0).abs() < 0.001); + assert_used(&usage.primary, 20.0); + assert_used(&usage.secondary.unwrap(), 10.0); } #[test] @@ -834,8 +852,8 @@ mod tests { ); assert_eq!(usage.login_method.as_deref(), Some("Copilot Free")); - assert!((usage.primary.used_percent - 50.0).abs() < 0.001); - assert!((usage.secondary.unwrap().used_percent - 80.0).abs() < 0.001); + assert_used(&usage.primary, 50.0); + assert_used(&usage.secondary.unwrap(), 80.0); } #[test] @@ -852,7 +870,7 @@ mod tests { }"#, ); - assert!((usage.primary.used_percent - 75.0).abs() < 0.001); + assert_used(&usage.primary, 75.0); } #[test] @@ -874,7 +892,7 @@ mod tests { }"#, ); - assert!((usage.primary.used_percent - 25.0).abs() < 0.001); + assert_used(&usage.primary, 25.0); assert!(usage.secondary.is_none()); } @@ -929,7 +947,7 @@ mod tests { ); assert_eq!(usage.login_method.as_deref(), Some("Copilot Business")); - assert!((usage.primary.used_percent - 60.0).abs() < 0.001); + assert_used(&usage.primary, 60.0); assert!(usage.secondary.is_none()); } @@ -948,7 +966,7 @@ mod tests { }"#, ); - assert!((usage.primary.used_percent - 100.0).abs() < 0.001); + assert_used(&usage.primary, 100.0); } #[test] @@ -971,11 +989,11 @@ mod tests { }"#, ); - assert!((usage.primary.used_percent - 50.0).abs() < 0.001); + assert_used(&usage.primary, 50.0); assert_eq!(usage.extra_rate_windows.len(), 1); assert_eq!(usage.extra_rate_windows[0].id, "additional-budget"); assert_eq!(usage.extra_rate_windows[0].title, "Additional Budget"); - assert!((usage.extra_rate_windows[0].window.used_percent - 75.0).abs() < 0.001); + assert_used(&usage.extra_rate_windows[0].window, 75.0); } #[test] @@ -995,7 +1013,7 @@ mod tests { ); assert_eq!(usage.login_method.as_deref(), Some("Copilot Pro")); - assert!((usage.primary.used_percent - 115.0).abs() < 0.001); + assert_used(&usage.primary, 115.0); assert_eq!( usage.primary.reset_description.as_deref(), Some("115% used") @@ -1017,7 +1035,7 @@ mod tests { }"#, ); - assert!((usage.primary.used_percent - 115.0).abs() < 0.001); + assert_used(&usage.primary, 115.0); assert_eq!( usage.primary.reset_description.as_deref(), Some("115% used") @@ -1069,7 +1087,7 @@ mod tests { #[test] fn configured_seat_allowance_adds_a_numeric_credit_window() { - let response: CopilotUsageResponse = serde_json::from_str( + let usage = parse_with_seat( r#"{ "copilot_plan": "business", "quota_snapshots": { @@ -1082,23 +1100,19 @@ mod tests { } } }"#, + Some(200.0), ) .unwrap(); - let usage = snapshot_from_response_with_seat_entitlement(response, Some(200.0)).unwrap(); - let seat = usage - .extra_rate_windows - .iter() - .find(|window| window.id == SEAT_CREDIT_WINDOW_ID) - .expect("configured seat-credit window"); - assert!((seat.window.used_percent - 25.0).abs() < 0.001); + let seat = seat_window(&usage).expect("configured seat-credit window"); + assert_used(&seat.window, 25.0); assert!(!seat.window.is_informational); assert_eq!(seat.title, "Credits used"); } #[test] fn missing_primary_quota_is_informational_when_seat_credit_is_available() { - let response: CopilotUsageResponse = serde_json::from_str( + let usage = parse_with_seat( r#"{ "copilot_plan": "business", "quota_snapshots": { @@ -1107,22 +1121,17 @@ mod tests { } } }"#, + Some(200.0), ) .unwrap(); - let usage = snapshot_from_response_with_seat_entitlement(response, Some(200.0)).unwrap(); assert!(usage.primary.is_informational); - assert!( - usage - .extra_rate_windows - .iter() - .any(|window| window.id == SEAT_CREDIT_WINDOW_ID) - ); + assert!(seat_window(&usage).is_some()); } #[test] fn non_finite_derived_seat_credit_percentage_is_omitted() { - let response: CopilotUsageResponse = serde_json::from_str( + let usage = parse_with_seat( r#"{ "copilot_plan": "business", "quota_snapshots": { @@ -1131,21 +1140,16 @@ mod tests { } } }"#, + Some(1e-308), ) .unwrap(); - let usage = snapshot_from_response_with_seat_entitlement(response, Some(1e-308)).unwrap(); - assert!( - usage - .extra_rate_windows - .iter() - .all(|window| window.id != SEAT_CREDIT_WINDOW_ID) - ); + assert!(seat_window(&usage).is_none()); } #[test] fn invalid_seat_allowance_keeps_credit_progress_unknown() { - let response: CopilotUsageResponse = serde_json::from_str( + let usage = parse_with_seat( r#"{ "copilot_plan": "business", "token_based_billing": true, @@ -1157,17 +1161,12 @@ mod tests { } } }"#, + Some(0.0), ) .unwrap(); - let usage = snapshot_from_response_with_seat_entitlement(response, Some(0.0)).unwrap(); assert!(usage.primary.is_informational); - assert!( - usage - .extra_rate_windows - .iter() - .all(|window| window.id != SEAT_CREDIT_WINDOW_ID) - ); + assert!(seat_window(&usage).is_none()); } #[test] @@ -1259,7 +1258,7 @@ mod tests { ); // Windows still render normally next to the counter. assert!(credits_row.window.is_informational); - assert!((usage.primary.used_percent - 20.0).abs() < 0.001); + assert_used(&usage.primary, 20.0); } #[test] From 690830c706838c6f075f9746ad5751eecc46937c Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sat, 10 Oct 2026 16:10:52 +0700 Subject: [PATCH 20/44] Pin Claude CLI error markers --- rust/src/providers/claude/mod.rs | 48 ++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/rust/src/providers/claude/mod.rs b/rust/src/providers/claude/mod.rs index b5fa7abe3df..54433f3ef30 100755 --- a/rust/src/providers/claude/mod.rs +++ b/rust/src/providers/claude/mod.rs @@ -2155,6 +2155,54 @@ Resets Dec 24 at 3:59pm (Europe/Paris) ); } + #[test] + fn cli_error_markers_map_to_fixed_errors() { + let git_bash = "Other(\"Claude CLI requires Git Bash on Windows. Install Git for Windows or set CLAUDE_CODE_GIT_BASH_PATH to your bash.exe path.\")"; + let cases = [ + ("Error: Not Logged In", "AuthRequired"), + ("login required to continue", "AuthRequired"), + ( + "TOKEN EXPIRED", + "OAuthExpired(\"Token expired. Run `claude login` to refresh.\")", + ), + ( + "{\"type\":\"token_expired\"}", + "OAuthExpired(\"Token expired. Run `claude login` to refresh.\")", + ), + ( + "authentication_error", + "OAuth(\"Authentication error. Run `claude login`.\")", + ), + ("Claude Code on Windows requires git-bash.", git_bash), + ( + "Running scripts is disabled on this system", + "Other(\"Claude CLI could not start because PowerShell script execution is disabled. Use claude.cmd or adjust the execution policy.\")", + ), + ( + "Cannot run a document in the middle of a pipeline", + "Other(\"Claude CLI resolved to a Unix shell script on Windows. Reinstall Claude Code or ensure claude.cmd is first on PATH.\")", + ), + // Auth markers win over environment markers. + ("requires git-bash; not logged in", "AuthRequired"), + ( + "requires git-bash; token expired", + "OAuthExpired(\"Token expired. Run `claude login` to refresh.\")", + ), + // Login wins over the other auth markers. + ("token expired; not logged in", "AuthRequired"), + ( + "authentication_error; token_expired", + "OAuthExpired(\"Token expired. Run `claude login` to refresh.\")", + ), + ("running scripts is disabled; requires git-bash", git_bash), + ]; + for (output, expected) in cases { + let error = claude_cli_error_from_output(output).expect(output); + assert_eq!(format!("{error:?}"), expected, "{output}"); + } + assert!(claude_cli_error_from_output("Current session 5% used").is_none()); + } + #[test] fn rejects_cli_output_without_usage_markers() { let provider = ClaudeProvider::new(); From 367cfa9e0d632b1d4fade264a16486e06182a504 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sat, 10 Oct 2026 16:12:42 +0700 Subject: [PATCH 21/44] Inline one-caller Claude CLI wrappers into an error table --- rust/src/providers/claude/mod.rs | 111 ++++++++++--------------------- 1 file changed, 36 insertions(+), 75 deletions(-) diff --git a/rust/src/providers/claude/mod.rs b/rust/src/providers/claude/mod.rs index 54433f3ef30..ee7544daaf0 100755 --- a/rust/src/providers/claude/mod.rs +++ b/rust/src/providers/claude/mod.rs @@ -592,15 +592,6 @@ async fn fetch_claude_cli_usage_text( return Ok(cached); } let combined = run_claude_usage_pty_probe(claude_path.clone(), probe_dir.clone()).await?; - - rerun_claude_usage_after_trust_prompt(claude_path, probe_dir, combined).await -} - -async fn rerun_claude_usage_after_trust_prompt( - claude_path: std::path::PathBuf, - probe_dir: std::path::PathBuf, - combined: String, -) -> Result { if !is_workspace_trust_prompt(&cli_screen::render(&combined, true).to_lowercase()) { return Ok(combined); } @@ -609,13 +600,30 @@ async fn rerun_claude_usage_after_trust_prompt( run_claude_usage_pty_probe(claude_path, probe_dir).await } +/// Windows launch failures the CLI prints instead of a usage screen, as +/// (lowercase marker, user-facing message). +const CLAUDE_CLI_ENVIRONMENT_ERRORS: &[(&str, &str)] = &[ + ( + "requires git-bash", + "Claude CLI requires Git Bash on Windows. Install Git for Windows or set \ + CLAUDE_CODE_GIT_BASH_PATH to your bash.exe path.", + ), + ( + "running scripts is disabled", + "Claude CLI could not start because PowerShell script execution is disabled. \ + Use claude.cmd or adjust the execution policy.", + ), + ( + "cannot run a document in the middle of a pipeline", + "Claude CLI resolved to a Unix shell script on Windows. Reinstall Claude Code or \ + ensure claude.cmd is first on PATH.", + ), +]; + +/// Auth markers are checked before the environment markers. fn claude_cli_error_from_output(output: &str) -> Option { let lowered = output.to_lowercase(); - claude_cli_auth_error(&lowered).or_else(|| claude_cli_environment_error(&lowered)) -} - -fn claude_cli_auth_error(lowered: &str) -> Option { - if claude_output_requires_login(lowered) { + if lowered.contains("not logged in") || lowered.contains("login required") { return Some(ProviderError::AuthRequired); } if lowered.contains("token expired") || lowered.contains("token_expired") { @@ -628,38 +636,10 @@ fn claude_cli_auth_error(lowered: &str) -> Option { "Authentication error. Run `claude login`.".to_string(), )); } - - None -} - -fn claude_output_requires_login(lowered: &str) -> bool { - lowered.contains("not logged in") || lowered.contains("login required") -} - -fn claude_cli_environment_error(lowered: &str) -> Option { - if lowered.contains("requires git-bash") { - return Some(ProviderError::Other( - "Claude CLI requires Git Bash on Windows. Install Git for Windows or set \ - CLAUDE_CODE_GIT_BASH_PATH to your bash.exe path." - .to_string(), - )); - } - if lowered.contains("running scripts is disabled") { - return Some(ProviderError::Other( - "Claude CLI could not start because PowerShell script execution is disabled. \ - Use claude.cmd or adjust the execution policy." - .to_string(), - )); - } - if lowered.contains("cannot run a document in the middle of a pipeline") { - return Some(ProviderError::Other( - "Claude CLI resolved to a Unix shell script on Windows. Reinstall Claude Code or \ - ensure claude.cmd is first on PATH." - .to_string(), - )); - } - - None + CLAUDE_CLI_ENVIRONMENT_ERRORS + .iter() + .find(|(marker, _)| lowered.contains(marker)) + .map(|(_, message)| ProviderError::Other((*message).to_string())) } /// Environment overrides for passive Claude CLI PTY probes. @@ -728,7 +708,10 @@ async fn run_claude_pty_probe( TtyCommandRunner::new() .run(&claude_path.to_string_lossy(), probe.script, options) .map(|result| result.text) - .map_err(claude_tty_error) + .map_err(|error| match error { + crate::cli::tty_runner::TtyCommandError::TimedOut => ProviderError::Timeout, + other => ProviderError::Other(format!("Claude CLI failed: {}", other)), + }) }) }) .await @@ -762,13 +745,6 @@ fn run_locked_probe( Ok(output) } -fn claude_tty_error(error: crate::cli::tty_runner::TtyCommandError) -> ProviderError { - match error { - crate::cli::tty_runner::TtyCommandError::TimedOut => ProviderError::Timeout, - other => ProviderError::Other(format!("Claude CLI failed: {}", other)), - } -} - fn last_good_failure_policy_for_error(error: &str) -> LastGoodFailurePolicy { let lower = error.to_ascii_lowercase(); if lower.contains("credentials not found") @@ -888,8 +864,12 @@ impl ClaudeProvider { ) -> Result { let mut failures = Vec::new(); - if let Some(result) = self.try_auto_admin_api(ctx, &mut failures).await? { - return Ok(result); + if self.admin_fetcher.has_credentials(ctx) { + tracing::debug!("Attempting Admin API fetch for Claude"); + let admin = self.admin_fetcher.fetch(ctx).await; + if let Some(result) = record_auto_source(&mut failures, "Admin API", admin)? { + return Ok(result); + } } if let Some(result) = @@ -944,17 +924,6 @@ impl ClaudeProvider { Err(claude_auto_fetch_error(failures)) } - async fn try_auto_admin_api( - &self, - ctx: &FetchContext, - failures: &mut Vec<(&'static str, ProviderError)>, - ) -> Result, ProviderError> { - if !self.admin_fetcher.has_credentials(ctx) { - return Ok(None); - } - record_auto_source(failures, "Admin API", self.fetch_via_admin_api(ctx).await) - } - async fn fetch_via_oauth( &self, ctx: &FetchContext, @@ -970,14 +939,6 @@ impl ClaudeProvider { self.oauth_fetcher.fetch().await } - async fn fetch_via_admin_api( - &self, - ctx: &FetchContext, - ) -> Result { - tracing::debug!("Attempting Admin API fetch for Claude"); - self.admin_fetcher.fetch(ctx).await - } - async fn fetch_via_web( &self, ctx: &FetchContext, From 5eeff080b043699dcbd670cea79f3caab365bb41 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sat, 10 Oct 2026 16:15:12 +0700 Subject: [PATCH 22/44] Pin Claude CLI label-section windows --- rust/src/providers/claude/mod.rs | 93 ++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/rust/src/providers/claude/mod.rs b/rust/src/providers/claude/mod.rs index ee7544daaf0..7cdc740319b 100755 --- a/rust/src/providers/claude/mod.rs +++ b/rust/src/providers/claude/mod.rs @@ -2164,6 +2164,99 @@ Resets Dec 24 at 3:59pm (Europe/Paris) assert!(claude_cli_error_from_output("Current session 5% used").is_none()); } + #[test] + fn all_percents_fold_case_and_clamp() { + let text = "50% USED\n20 % Left\n101% used\n150% left\n5.5% remaining\n1000% used\n7%Spent 8% available"; + assert_eq!( + extract_all_percents(text), + vec![50.0, 80.0, 100.0, 0.0, 94.5, 0.0, 7.0, 92.0] + ); + assert!(extract_all_percents("no numbers here").is_empty()); + } + + #[test] + fn label_sections_stop_at_their_window_and_the_next_section() { + let filler = |count: usize| vec!["filler"; count].join("\n"); + // Percent on the label line itself, and on the last line of the + // twelve-line window (label + 11). + assert_eq!( + extract_percent_near_label("Current session 30% used", "current session"), + Some(30.0) + ); + let at_last = format!("Current session\n{}\n40% used", filler(10)); + assert_eq!( + extract_percent_near_label(&at_last, "current session"), + Some(40.0) + ); + let past_window = format!("Current session\n{}\n40% used", filler(11)); + assert_eq!( + extract_percent_near_label(&past_window, "current session"), + None + ); + // The next "Current ..." heading ends the section, but the same + // heading does not. + let next_section = "Current session\nCurrent week\n40% used"; + assert_eq!( + extract_percent_near_label(next_section, "current session"), + None + ); + let same_label = "Current session\nCURRENT SESSION again\n40% used"; + assert_eq!( + extract_percent_near_label(same_label, "current session"), + Some(40.0) + ); + // A section without a value falls through to a later label line. + let later = "Current session\nCurrent week\n10% used\nCurrent session\n60% left"; + assert_eq!( + extract_percent_near_label(later, "current session"), + Some(40.0) + ); + assert_eq!( + extract_percent_near_label("Current week (all models)\n10% used", "current week"), + Some(10.0) + ); + + // Reset text uses a fourteen-line window (label + 13). + let reset_last = format!("Current week\n{}\nResets Mon 9am", filler(12)); + assert_eq!( + extract_reset_description(&reset_last, "current week").as_deref(), + Some("Resets Mon 9am") + ); + let reset_past = format!("Current week\n{}\nResets Mon 9am", filler(13)); + assert_eq!(extract_reset_description(&reset_past, "current week"), None); + assert_eq!( + extract_reset_description("Current week 5% used · resets Fri 1pm ", "current week") + .as_deref(), + Some("resets Fri 1pm") + ); + assert_eq!( + extract_reset_description( + "Current session\nCurrent week\nResets Mon", + "current session" + ), + None + ); + let later_reset = "Current session\nCurrent week\nCurrent session\nResets 5pm"; + assert_eq!( + extract_reset_description(later_reset, "current session").as_deref(), + Some("Resets 5pm") + ); + } + + #[test] + fn scoped_weekly_sections_use_a_fourteen_line_window() { + let now = Utc::now(); + let filler = |count: usize| vec!["filler"; count].join("\n"); + let inside = format!("Current week (Opus)\n{}\n25% used", filler(12)); + let limits = extract_cli_scoped_weekly_limits(&inside, now); + assert_eq!(limits.len(), 1); + assert_eq!(limits[0].window.used_percent, 25.0); + let outside = format!("Current week (Opus)\n{}\n25% used", filler(13)); + assert!(extract_cli_scoped_weekly_limits(&outside, now).is_empty()); + let next = "Current week (Opus)\nCurrent session\n25% used"; + assert!(extract_cli_scoped_weekly_limits(next, now).is_empty()); + } + #[test] fn rejects_cli_output_without_usage_markers() { let provider = ClaudeProvider::new(); From 334dd2da165d87beb945f74770a60dd947c05fdb Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sat, 10 Oct 2026 16:20:02 +0700 Subject: [PATCH 23/44] Share the Claude CLI label-section scan --- rust/src/providers/claude/cli_reset.rs | 47 ++++++--- rust/src/providers/claude/mod.rs | 128 +++++++------------------ 2 files changed, 70 insertions(+), 105 deletions(-) diff --git a/rust/src/providers/claude/cli_reset.rs b/rust/src/providers/claude/cli_reset.rs index 590f1d82d91..46510786f25 100644 --- a/rust/src/providers/claude/cli_reset.rs +++ b/rust/src/providers/claude/cli_reset.rs @@ -11,18 +11,25 @@ fn regex(cell: &'static OnceLock, pattern: &'static str) -> &'static Rege cell.get_or_init(|| Regex::new(pattern).expect("valid Claude CLI regex")) } -pub(super) fn parse_percent_line(line: &str) -> Option { +/// Every "N% used/left/..." in order, as used percent clamped to 0..=100. +pub(super) fn percent_matches(text: &str) -> impl Iterator + '_ { static PERCENT: OnceLock = OnceLock::new(); - let captures = regex( + regex( &PERCENT, r"(?i)(\d{1,3}(?:\.\d+)?)\s*%\s*(used|spent|consumed|left|remaining|available)", ) - .captures(line)?; - let value: f64 = captures.get(1)?.as_str().parse().ok()?; - match captures.get(2)?.as_str().to_ascii_lowercase().as_str() { - "left" | "remaining" | "available" => Some((100.0 - value).max(0.0)), - _ => Some(value.min(100.0)), - } + .captures_iter(text) + .filter_map(|captures| { + let value: f64 = captures.get(1)?.as_str().parse().ok()?; + match captures.get(2)?.as_str().to_ascii_lowercase().as_str() { + "left" | "remaining" | "available" => Some((100.0 - value).max(0.0)), + _ => Some(value.min(100.0)), + } + }) +} + +pub(super) fn parse_percent_line(line: &str) -> Option { + percent_matches(line).next() } pub(super) fn normalized_for_label_search(text: &str) -> String { @@ -37,6 +44,25 @@ pub(super) fn starts_next_usage_section(line: &str, current_label: &str) -> bool normalized.starts_with("current") && !normalized.contains(current_label) } +/// Up to `max_lines` lines from the label line at `idx`, ending before the +/// next "Current ..." heading that is not `label_normalized`. +pub(super) fn label_section<'a>( + lines: &'a [&'a str], + idx: usize, + label_normalized: &'a str, + max_lines: usize, +) -> impl Iterator { + lines + .iter() + .skip(idx) + .take(max_lines) + .enumerate() + .take_while(move |(offset, line)| { + *offset == 0 || !starts_next_usage_section(line, label_normalized) + }) + .map(|(_, line)| *line) +} + pub(super) fn extract_cli_scoped_weekly_limits( text: &str, now: DateTime, @@ -69,10 +95,7 @@ pub(super) fn extract_cli_scoped_weekly_limits( let mut used_percent = None; let mut reset_description = None; let current_label = normalized_for_label_search(line); - for (offset, section_line) in lines.iter().skip(idx).take(14).enumerate() { - if offset > 0 && starts_next_usage_section(section_line, ¤t_label) { - break; - } + for section_line in label_section(&lines, idx, ¤t_label, 14) { used_percent = used_percent.or_else(|| parse_percent_line(section_line)); reset_description = reset_description.or_else(|| { reset_re diff --git a/rust/src/providers/claude/mod.rs b/rust/src/providers/claude/mod.rs index 7cdc740319b..af7ea77643d 100755 --- a/rust/src/providers/claude/mod.rs +++ b/rust/src/providers/claude/mod.rs @@ -35,8 +35,8 @@ use admin_api::ClaudeAdminApiFetcher; #[cfg(test)] use cli_reset::parse_claude_reset_date_in_system_zone; use cli_reset::{ - extract_cli_scoped_weekly_limits, normalized_for_label_search, parse_claude_reset_date, - parse_percent_line, starts_next_usage_section, + extract_cli_scoped_weekly_limits, label_section, normalized_for_label_search, + parse_claude_reset_date, parse_percent_line, percent_matches, }; // ── Upstream 0.50.1 #2516: CLI usage-result cache ──────────────────────────── @@ -1010,26 +1010,15 @@ impl ClaudeProvider { )); } - // Parse session percent: "X% used" or "X% left" - let mut session_percent: Option = None; - let mut weekly_percent: Option = None; - - // Look for "Current session" section - if let Some(session_pct) = extract_percent_near_label(&clean, "current session") { - session_percent = Some(session_pct); - } - - // Look for "Current week" section - if let Some(weekly_pct) = extract_percent_near_label(&clean, "current week (all models)") - .or_else(|| extract_percent_near_label(&clean, "current week")) - { - weekly_percent = Some(weekly_pct); - } + let mut session_percent = extract_percent_near_label(&clean, "current session"); + let mut weekly_percent = WEEKLY_LABELS + .iter() + .find_map(|label| extract_percent_near_label(&clean, label)); // Fallback: collect all percentages in order. Activity stats carry // their own percentages, which must never be read as plan limits. if session_percent.is_none() && !activity_stats { - let all_percents = extract_all_percents(&clean); + let all_percents: Vec = percent_matches(&clean).collect(); if !all_percents.is_empty() { session_percent = Some(all_percents[0]); } @@ -1053,8 +1042,9 @@ impl ClaudeProvider { // Extract reset times let session_reset = extract_reset_description(&clean, "current session"); - let weekly_reset = extract_reset_description(&clean, "current week (all models)") - .or_else(|| extract_reset_description(&clean, "current week")); + let weekly_reset = WEEKLY_LABELS + .iter() + .find_map(|label| extract_reset_description(&clean, label)); let short_form_reset = if is_exhausted_short_form(&clean_lower) { extract_inline_reset_description(&clean) } else { @@ -1378,57 +1368,31 @@ fn is_cli_activity_stats_response(text: &str) -> bool { has_activity_overview || has_session_cost_summary } -/// Extract percentage near a label (e.g., "Current session") -/// Returns the percentage as "used" (not remaining) -fn extract_percent_near_label(text: &str, label: &str) -> Option { +/// The weekly heading Claude prints, newest wording first. +const WEEKLY_LABELS: [&str; 2] = ["current week (all models)", "current week"]; + +/// First match of `find` in any section headed by `label`; each section is +/// scanned for at most `max_lines` lines, label line included. +fn find_near_label( + text: &str, + label: &str, + max_lines: usize, + mut find: impl FnMut(&str) -> Option, +) -> Option { let label_normalized = normalized_for_label_search(label); let lines: Vec<&str> = text.lines().collect(); - - // Find the line containing the label - for (idx, line) in lines.iter().enumerate() { - if normalized_for_label_search(line).contains(&label_normalized) { - // Look in the next few lines for a percentage - for (offset, next_line) in lines.iter().skip(idx).take(12).enumerate() { - if offset > 0 && starts_next_usage_section(next_line, &label_normalized) { - break; - } - if let Some(pct) = parse_percent_line(next_line) { - return Some(pct); - } - } - } - } - - None + lines + .iter() + .enumerate() + .filter(|(_, line)| normalized_for_label_search(line).contains(&label_normalized)) + .find_map(|(idx, _)| { + label_section(&lines, idx, &label_normalized, max_lines).find_map(&mut find) + }) } -/// Extract all percentages from text in order -fn extract_all_percents(text: &str) -> Vec { - let re = match Regex::new( - r"(\d{1,3}(?:\.\d+)?)\s*%\s*(used|spent|consumed|left|remaining|available)", - ) { - Ok(r) => r, - Err(_) => return vec![], - }; - - let mut results = Vec::new(); - let lower = text.to_lowercase(); - - for caps in re.captures_iter(&lower) { - if let (Some(val_match), Some(kind_match)) = (caps.get(1), caps.get(2)) - && let Ok(val) = val_match.as_str().parse::() - { - let kind = kind_match.as_str(); - let used = if matches!(kind, "left" | "remaining" | "available") { - (100.0 - val).max(0.0) - } else { - val.min(100.0) - }; - results.push(used); - } - } - - results +/// Percentage near a label (e.g. "Current session"), as "used". +fn extract_percent_near_label(text: &str, label: &str) -> Option { + find_near_label(text, label, 12, parse_percent_line) } fn is_exhausted_short_form(clean_lower: &str) -> bool { @@ -1483,31 +1447,9 @@ fn extract_login_method(text: &str) -> Option { None } -/// Extract reset description near a label +/// Reset text near a label, from "resets" to the end of its line. fn extract_reset_description(text: &str, label: &str) -> Option { - let label_normalized = normalized_for_label_search(label); - let lines: Vec<&str> = text.lines().collect(); - - for (idx, line) in lines.iter().enumerate() { - if normalized_for_label_search(line).contains(&label_normalized) { - // Look in the next few lines for "Resets" - for (offset, next_line) in lines.iter().skip(idx).take(14).enumerate() { - if offset > 0 && starts_next_usage_section(next_line, &label_normalized) { - break; - } - let lower = next_line.to_lowercase(); - if lower.contains("resets") { - // Extract the reset info - if let Some(pos) = lower.find("resets") { - let reset_part = &next_line[pos..]; - return Some(reset_part.trim().to_string()); - } - } - } - } - } - - None + find_near_label(text, label, 14, extract_inline_reset_description) } /// Extract a "resets ..." suffix from a short single-line status. @@ -2168,10 +2110,10 @@ Resets Dec 24 at 3:59pm (Europe/Paris) fn all_percents_fold_case_and_clamp() { let text = "50% USED\n20 % Left\n101% used\n150% left\n5.5% remaining\n1000% used\n7%Spent 8% available"; assert_eq!( - extract_all_percents(text), + percent_matches(text).collect::>(), vec![50.0, 80.0, 100.0, 0.0, 94.5, 0.0, 7.0, 92.0] ); - assert!(extract_all_percents("no numbers here").is_empty()); + assert!(percent_matches("no numbers here").next().is_none()); } #[test] From de5a23a13d49609659ca0d482c81aad5d6bbee3b Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sat, 10 Oct 2026 16:26:55 +0700 Subject: [PATCH 24/44] Pin that rendered Claude CLI text carries no escapes --- rust/src/providers/claude/cli_screen/tests.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/rust/src/providers/claude/cli_screen/tests.rs b/rust/src/providers/claude/cli_screen/tests.rs index 1d3fbd0db1d..327bfff8a4d 100644 --- a/rust/src/providers/claude/cli_screen/tests.rs +++ b/rust/src/providers/claude/cli_screen/tests.rs @@ -227,3 +227,22 @@ fn redrawn_identity_uses_the_final_frame_with_cursor_positioned_spaces() { assert_eq!(usage.account_email.as_deref(), Some("fixture@example.com")); assert_eq!(usage.login_method.as_deref(), Some("Claude Max Account")); } + +#[test] +fn rendered_text_never_carries_an_escape() { + let inputs = [ + USAGE_FIXTURE, + STATUS_FIXTURE, + "plain \u{1b}[1mLogin method: Claude Max\u{1b}[22m", + "osc \u{1b}]0;title\u{7}tail \u{1b}]8;;x\u{1b}\\link", + "lone \u{1b}\n\u{1b}Xtrail \u{1b}", + "abc\u{1b}[2DZ \u{1b}(B done", + ]; + for input in inputs { + for preserve in [true, false] { + let rendered = render(input, preserve); + assert!(!rendered.contains('\u{1b}'), "{rendered:?}"); + assert_eq!(crate::providers::claude::strip_ansi(&rendered), rendered); + } + } +} From bd66fb6274167cc2a573dc9ae3752a10a9385fa3 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sat, 10 Oct 2026 16:29:00 +0700 Subject: [PATCH 25/44] Drop dead Claude escape stripping and wrappers --- rust/src/providers/claude/cli_screen/tests.rs | 41 ++++++++++++++++- rust/src/providers/claude/mod.rs | 44 ++----------------- .../providers/claude/reset_observations.rs | 10 ----- rust/src/providers/claude/web_api.rs | 9 +--- 4 files changed, 44 insertions(+), 60 deletions(-) diff --git a/rust/src/providers/claude/cli_screen/tests.rs b/rust/src/providers/claude/cli_screen/tests.rs index 327bfff8a4d..af0bd2f0089 100644 --- a/rust/src/providers/claude/cli_screen/tests.rs +++ b/rust/src/providers/claude/cli_screen/tests.rs @@ -186,7 +186,7 @@ fn a_cursor_sequence_after_plain_text_switches_to_replay() { #[test] fn differential_redraw_fixture_keeps_the_scoped_weekly_quota() { // Plain escape stripping fuses the redraw fragments, which is the bug. - let stripped = crate::providers::claude::strip_ansi(USAGE_FIXTURE); + let stripped = strip_ansi(USAGE_FIXTURE); assert!(stripped.contains("51%usd"), "{stripped}"); let provider = ClaudeProvider::new(); @@ -242,7 +242,44 @@ fn rendered_text_never_carries_an_escape() { for preserve in [true, false] { let rendered = render(input, preserve); assert!(!rendered.contains('\u{1b}'), "{rendered:?}"); - assert_eq!(crate::providers::claude::strip_ansi(&rendered), rendered); + assert_eq!(strip_ansi(&rendered), rendered); } } } + +/// Plain escape stripping, the pre-replay behavior these tests compare against. +fn strip_ansi(text: &str) -> String { + let mut result = String::with_capacity(text.len()); + let mut chars = text.chars().peekable(); + + while let Some(c) = chars.next() { + if c == '\x1B' { + // Skip CSI sequences: ESC[...letter + if chars.peek() == Some(&'[') { + chars.next(); + let mut final_char = None; + while let Some(&next) = chars.peek() { + chars.next(); + if next.is_ascii_alphabetic() { + final_char = Some(next); + break; + } + } + if final_char == Some('C') { + result.push(' '); + } + // Skip OSC sequences: ESC]...BEL + } else if chars.peek() == Some(&']') { + for next in chars.by_ref() { + if next == '\x07' || next == '\\' { + break; + } + } + } + } else { + result.push(c); + } + } + + result +} diff --git a/rust/src/providers/claude/mod.rs b/rust/src/providers/claude/mod.rs index af7ea77643d..8e8323f9cf0 100755 --- a/rust/src/providers/claude/mod.rs +++ b/rust/src/providers/claude/mod.rs @@ -1297,43 +1297,6 @@ fn detect_claude_version() -> Option { } } -/// Strip ANSI escape codes from text -fn strip_ansi(text: &str) -> String { - let mut result = String::with_capacity(text.len()); - let mut chars = text.chars().peekable(); - - while let Some(c) = chars.next() { - if c == '\x1B' { - // Skip CSI sequences: ESC[...letter - if chars.peek() == Some(&'[') { - chars.next(); - let mut final_char = None; - while let Some(&next) = chars.peek() { - chars.next(); - if next.is_ascii_alphabetic() { - final_char = Some(next); - break; - } - } - if final_char == Some('C') { - result.push(' '); - } - // Skip OSC sequences: ESC]...BEL - } else if chars.peek() == Some(&']') { - for next in chars.by_ref() { - if next == '\x07' || next == '\\' { - break; - } - } - } - } else { - result.push(c); - } - } - - result -} - fn is_non_interactive_slash_command_response(text: &str) -> bool { let mentions_usage_and_exit = text.contains("/usage") && text.contains("/exit"); let says_entered_commands = @@ -1459,12 +1422,11 @@ fn extract_inline_reset_description(text: &str) -> Option { Some(text[pos..].trim().to_string()) } -/// Clean up a plan name by removing ANSI codes and extra whitespace +/// Clean up a plan name from rendered (escape-free) text: drop bracketed +/// codes like `[22m` and trim. fn clean_plan_name(text: &str) -> String { - let cleaned = strip_ansi(text); - // Remove bracketed codes like [22m let re = Regex::new(r"\[\d+m").unwrap_or_else(|_| Regex::new(".^").unwrap()); - let result = re.replace_all(&cleaned, ""); + let result = re.replace_all(text, ""); result.trim().to_string() } diff --git a/rust/src/providers/claude/reset_observations.rs b/rust/src/providers/claude/reset_observations.rs index 6ac5fd007dd..79ed5d506de 100644 --- a/rust/src/providers/claude/reset_observations.rs +++ b/rust/src/providers/claude/reset_observations.rs @@ -51,16 +51,6 @@ pub struct ClaudeResetObservationMergeResult { pub changed: bool, } -pub fn default_store_path() -> Result { - let root = dirs::config_dir().ok_or_else(|| { - ClaudeResetObservationError::Read(std::io::Error::new( - std::io::ErrorKind::NotFound, - "configuration directory not found", - )) - })?; - Ok(root.join("CodexBar").join(STORE_RELATIVE_PATH)) -} - pub fn store_path(config_root: &Path) -> PathBuf { config_root.join(STORE_RELATIVE_PATH) } diff --git a/rust/src/providers/claude/web_api.rs b/rust/src/providers/claude/web_api.rs index dad46b0bf05..2e9c8a40dd6 100755 --- a/rust/src/providers/claude/web_api.rs +++ b/rust/src/providers/claude/web_api.rs @@ -772,11 +772,6 @@ impl ClaudeWebApiFetcher { fn format_reset_time(dt: DateTime) -> String { dt.format("%b %-d at %-I:%M%p").to_string() } - - /// Convert rate limit tier to plan name - fn tier_to_plan_name(tier: &str) -> String { - super::claude_plan_label(tier) - } } impl Default for ClaudeWebApiFetcher { @@ -953,11 +948,11 @@ mod tests { #[test] fn labels_max_5x_and_20x_plans() { assert_eq!( - ClaudeWebApiFetcher::tier_to_plan_name("default_claude_max_5x"), + crate::providers::claude::claude_plan_label("default_claude_max_5x"), "Claude Max 5x" ); assert_eq!( - ClaudeWebApiFetcher::tier_to_plan_name("v2_default_claude_max_20x"), + crate::providers::claude::claude_plan_label("v2_default_claude_max_20x"), "Claude Max 20x" ); } From dc699c749d3292f02b1265d8c80c35847d0e498b Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sat, 10 Oct 2026 16:31:21 +0700 Subject: [PATCH 26/44] Pin Claude Admin API model and cost ranking --- rust/src/providers/claude/admin_api_tests.rs | 67 ++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/rust/src/providers/claude/admin_api_tests.rs b/rust/src/providers/claude/admin_api_tests.rs index 3a5391c2fbc..737cdc6b51b 100644 --- a/rust/src/providers/claude/admin_api_tests.rs +++ b/rust/src/providers/claude/admin_api_tests.rs @@ -152,3 +152,70 @@ fn workspace_option_groups_the_existing_cost_request_only() { cost_report_query(&start, &end, false).len() + 1 ); } + +#[test] +fn messages_and_costs_fold_into_ranked_windows() { + let messages: MessagesUsageResponse = serde_json::from_str( + r#"{"data":[ + {"starting_at":"2026-09-23T00:00:00Z","ending_at":"2026-09-24T00:00:00Z","results":[ + {"model":"claude-b","uncached_input_tokens":10,"cache_read_input_tokens":5,"output_tokens":5}, + {"model":" ","uncached_input_tokens":1,"cache_creation":{"total_input_tokens":2}}, + {"model":"claude-a","uncached_input_tokens":20}]}, + {"starting_at":"2026-09-24T00:00:00Z","ending_at":"2026-09-25T00:00:00Z","results":[ + {"model":"claude-c","output_tokens":7}, + {"uncached_input_tokens":4}]}]}"#, + ) + .unwrap(); + let report = costs( + r#"{"data":[ + {"starting_at":"2026-09-22T00:00:00Z","ending_at":"2026-09-23T00:00:00Z","results":[ + {"amount":"300","description":" Web search "}, + {"amount":"100","cost_type":"tokens"}, + {"amount":"200","description":"","cost_type":"session"}, + {"amount":"50"}]}]}"#, + ); + let result = result_from_admin_usage(&report, &messages, now(), false); + let usage = &result.usage; + let windows: Vec<_> = usage + .extra_rate_windows + .iter() + .map(|window| { + ( + window.id.as_str(), + window.title.as_str(), + window.window.reset_description.as_deref().unwrap(), + ) + }) + .collect(); + assert_eq!( + windows, + [ + ("input-tokens", "Input tokens", "42"), + ("output-tokens", "Output tokens", "12"), + ("model-0", "Model: claude-a", "20 tokens"), + ("model-1", "Model: claude-b", "20 tokens"), + ("model-2", "Model: Claude API", "7 tokens"), + ("cost-0", "Cost: Web search", "$3.00"), + ("cost-1", "Cost: Claude API", "$2.50"), + ("cost-2", "Cost: tokens", "$1.00"), + ] + ); + assert_eq!( + usage.primary.reset_description.as_deref(), + Some("$6.50 over last 30 days") + ); + assert_eq!( + usage + .secondary + .as_ref() + .unwrap() + .reset_description + .as_deref(), + Some("54 tokens") + ); + assert_eq!( + usage.primary.resets_at.map(|at| at.to_rfc3339()).as_deref(), + Some("2026-09-22T00:00:00+00:00") + ); + assert!(result.display_details().is_empty()); +} From a91fbe5934d1decbff4dfcff0288a2f4a63066bc Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sat, 10 Oct 2026 16:35:11 +0700 Subject: [PATCH 27/44] Pin Claude reset date forms and observation store loading --- rust/src/providers/claude/mod.rs | 57 +++++++++++++++++++ .../providers/claude/reset_observations.rs | 25 ++++++++ 2 files changed, 82 insertions(+) diff --git a/rust/src/providers/claude/mod.rs b/rust/src/providers/claude/mod.rs index 8e8323f9cf0..d76f4713cf0 100755 --- a/rust/src/providers/claude/mod.rs +++ b/rust/src/providers/claude/mod.rs @@ -1951,6 +1951,63 @@ Resets Apr 5, 2pm (America/Bogota) ); } + #[test] + fn reset_dates_resolve_every_month_and_form() { + let now = "2026-09-24T12:00:00Z".parse::>().unwrap(); + let at = |text: &str, window: Option| { + parse_claude_reset_date(text, now, window).map(|date| date.to_rfc3339()) + }; + let months = [ + "Jan", "FEB", "mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "dEc", + ]; + for (index, month) in months.iter().enumerate() { + assert_eq!( + at(&format!("Resets {month} 5, 2027 at 3pm (UTC)"), None), + Some(format!("2027-{:02}-05T15:00:00+00:00", index + 1)), + "{month}" + ); + } + let rows = [ + ("Resets Foo 5, 2027 at 3pm (UTC)", None, None), + ("Resets Feb 30, 2027 at 3pm (UTC)", None, None), + ( + "Resets Sep 23 at 3pm (UTC)", + None, + Some("2027-09-23T15:00:00+00:00"), + ), + ( + "Resets Sep 23 at 3pm (UTC)", + Some(10_080), + Some("2026-09-23T15:00:00+00:00"), + ), + ( + "Resets Feb 29 at 3pm (UTC)", + None, + Some("2028-02-29T15:00:00+00:00"), + ), + ("Resets 3pm (UTC)", None, Some("2026-09-24T15:00:00+00:00")), + ("Resets 11am (UTC)", None, Some("2026-09-25T11:00:00+00:00")), + ( + "Resets 11am (UTC)", + Some(300), + Some("2026-09-24T11:00:00+00:00"), + ), + ( + "Resets Nov 1, 2026 at 1:30am (America/New_York)", + None, + Some("2026-11-01T05:30:00+00:00"), + ), + ( + "Resets Mar 8, 2026 at 2:30am (America/New_York)", + None, + None, + ), + ]; + for (text, window, expected) in rows { + assert_eq!(at(text, window).as_deref(), expected, "{text} {window:?}"); + } + } + #[test] fn parses_compact_usage_screen() { let provider = ClaudeProvider::new(); diff --git a/rust/src/providers/claude/reset_observations.rs b/rust/src/providers/claude/reset_observations.rs index 79ed5d506de..7302688d7a9 100644 --- a/rust/src/providers/claude/reset_observations.rs +++ b/rust/src/providers/claude/reset_observations.rs @@ -220,6 +220,31 @@ mod tests { ); } + #[test] + fn missing_store_loads_empty_and_invalid_scopes_fail() { + let root = tempdir().unwrap(); + assert_eq!(load_reset_observations(root.path(), "a").unwrap(), vec![]); + assert!(matches!( + load_reset_observations(root.path(), " "), + Err(ClaudeResetObservationError::EmptyAccountScope) + )); + let path = store_path(root.path()); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, br#"{"version":1}"#).unwrap(); + assert_eq!(load_reset_observations(root.path(), "a").unwrap(), vec![]); + let stray = observation("b", "2026-09-20T10:00:00Z", "2026-09-21T10:00:00Z"); + let store = serde_json::json!({"version": 1, "accounts": {"a": [stray]}}); + std::fs::write(&path, store.to_string()).unwrap(); + assert!(matches!( + load_reset_observations(root.path(), "z"), + Err(ClaudeResetObservationError::AccountScopeMismatch) + )); + assert!(matches!( + merge_and_persist_reset_observations(root.path(), "z", &[]), + Err(ClaudeResetObservationError::AccountScopeMismatch) + )); + } + #[test] fn malformed_and_future_stores_fail_closed() { let root = tempdir().unwrap(); From bfb9a058ddd94f31c3418ad6ed2e68fb0d0ba068 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sat, 10 Oct 2026 16:44:12 +0700 Subject: [PATCH 28/44] Share small Claude fold and window helpers --- rust/src/providers/claude/admin_api.rs | 115 ++++++++++-------- rust/src/providers/claude/cli_reset.rs | 70 ++++------- rust/src/providers/claude/quota_history.rs | 23 ++-- .../providers/claude/reset_observations.rs | 16 +-- rust/src/providers/claude/scoped_weekly.rs | 29 ++--- 5 files changed, 111 insertions(+), 142 deletions(-) diff --git a/rust/src/providers/claude/admin_api.rs b/rust/src/providers/claude/admin_api.rs index c16b2748fc0..d5ebb52d278 100644 --- a/rust/src/providers/claude/admin_api.rs +++ b/rust/src/providers/claude/admin_api.rs @@ -209,6 +209,18 @@ struct CacheCreation { total_input_tokens: Option, } +impl MessageResult { + fn input_tokens(&self) -> i64 { + self.uncached_input_tokens.unwrap_or(0) + + self + .cache_creation + .as_ref() + .and_then(|c| c.total_input_tokens) + .unwrap_or(0) + + self.cache_read_input_tokens.unwrap_or(0) + } +} + fn result_from_admin_usage( costs: &CostReportResponse, messages: &MessagesUsageResponse, @@ -225,14 +237,7 @@ fn result_from_admin_usage( .data .iter() .flat_map(|bucket| &bucket.results) - .map(|r| { - r.uncached_input_tokens.unwrap_or(0) - + r.cache_creation - .as_ref() - .and_then(|c| c.total_input_tokens) - .unwrap_or(0) - + r.cache_read_input_tokens.unwrap_or(0) - }) + .map(MessageResult::input_tokens) .sum(); let output_tokens: i64 = messages .data @@ -278,26 +283,13 @@ fn result_from_admin_usage( .with_login_method("Admin API"); usage.updated_at = now; - let mut model_tokens: HashMap = HashMap::new(); - for result in messages.data.iter().flat_map(|bucket| &bucket.results) { - let name = result - .model - .as_deref() - .map(str::trim) - .filter(|s| !s.is_empty()) - .unwrap_or("Claude API"); - let tokens = result.uncached_input_tokens.unwrap_or(0) - + result - .cache_creation - .as_ref() - .and_then(|c| c.total_input_tokens) - .unwrap_or(0) - + result.cache_read_input_tokens.unwrap_or(0) - + result.output_tokens.unwrap_or(0); - *model_tokens.entry(name.to_string()).or_default() += tokens; - } - let mut top_models: Vec<_> = model_tokens.into_iter().collect(); - top_models.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0))); + let top_models = ranked_totals( + messages.data.iter().flat_map(|bucket| &bucket.results), + |result| result.model.as_deref(), + "Claude API", + |result| result.input_tokens() + result.output_tokens.unwrap_or(0), + |a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)), + ); for (idx, (model, tokens)) in top_models.into_iter().take(3).enumerate() { usage = usage.with_extra_rate_window( format!("model-{idx}"), @@ -306,19 +298,18 @@ fn result_from_admin_usage( ); } - let mut cost_items: HashMap = HashMap::new(); - for result in costs.data.iter().flat_map(|bucket| &bucket.results) { - let name = result - .description - .as_deref() - .or(result.cost_type.as_deref()) - .map(str::trim) - .filter(|s| !s.is_empty()) - .unwrap_or("Claude API"); - *cost_items.entry(name.to_string()).or_default() += usd_from_lowest_unit(&result.amount); - } - let mut top_items: Vec<_> = cost_items.into_iter().collect(); - top_items.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); + let top_items = ranked_totals( + costs.data.iter().flat_map(|bucket| &bucket.results), + |result| { + result + .description + .as_deref() + .or(result.cost_type.as_deref()) + }, + "Claude API", + |result| usd_from_lowest_unit(&result.amount), + |a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal), + ); for (idx, (item, cost)) in top_items.into_iter().take(3).enumerate() { usage = usage.with_extra_rate_window( format!("cost-{idx}"), @@ -342,21 +333,16 @@ fn result_from_admin_usage( /// only when the report spans more than one workspace; a single workspace adds /// nothing over the organization view. fn workspace_spend_rows(costs: &CostReportResponse) -> Vec { - let mut workspaces: HashMap = HashMap::new(); - for result in costs.data.iter().flat_map(|bucket| &bucket.results) { - let name = result - .workspace_id - .as_deref() - .map(str::trim) - .filter(|s| !s.is_empty()) - .unwrap_or("Default"); - *workspaces.entry(name.to_string()).or_default() += usd_from_lowest_unit(&result.amount); - } - if workspaces.len() < 2 { + let ranked = ranked_totals( + costs.data.iter().flat_map(|bucket| &bucket.results), + |result| result.workspace_id.as_deref(), + "Default", + |result| usd_from_lowest_unit(&result.amount), + |a, b| b.1.total_cmp(&a.1).then_with(|| a.0.cmp(&b.0)), + ); + if ranked.len() < 2 { return Vec::new(); } - let mut ranked: Vec<_> = workspaces.into_iter().collect(); - ranked.sort_by(|a, b| b.1.total_cmp(&a.1).then_with(|| a.0.cmp(&b.0))); ranked .into_iter() .take(MAX_WORKSPACE_ROWS) @@ -372,6 +358,27 @@ fn workspace_spend_rows(costs: &CostReportResponse) -> Vec( + rows: impl Iterator, + name: impl Fn(&'a R) -> Option<&'a str>, + fallback: &str, + value: impl Fn(&R) -> V, + order: impl FnMut(&(String, V), &(String, V)) -> std::cmp::Ordering, +) -> Vec<(String, V)> { + let mut totals: HashMap = HashMap::new(); + for row in rows { + let key = name(row) + .map(str::trim) + .filter(|s| !s.is_empty()) + .unwrap_or(fallback); + *totals.entry(key.to_string()).or_default() += value(row); + } + let mut ranked: Vec<_> = totals.into_iter().collect(); + ranked.sort_by(order); + ranked +} + fn usd_from_lowest_unit(raw: &str) -> f64 { raw.parse::().unwrap_or(0.0) / 100.0 } diff --git a/rust/src/providers/claude/cli_reset.rs b/rust/src/providers/claude/cli_reset.rs index 46510786f25..89cbe058bcd 100644 --- a/rust/src/providers/claude/cli_reset.rs +++ b/rust/src/providers/claude/cli_reset.rs @@ -165,42 +165,33 @@ pub(super) fn parse_claude_reset_date_in_system_zone( let (raw, timezone) = normalize_claude_reset_text(text, system_timezone)?; let components = parse_claude_reset_components(&raw)?; let now_local = now.with_timezone(&timezone); - let candidates = match (components.year, components.month, components.day) { - (Some(year), Some(month), Some(day)) => local_reset_occurrences( - timezone, - year, - month, - day, - components.hour, - components.minute, - ), + // Without a year, try nearby years; without a date, yesterday to tomorrow. + let dates: Vec<(i32, u32, u32)> = match (components.year, components.month, components.day) { + (Some(year), Some(month), Some(day)) => vec![(year, month, day)], (None, Some(month), Some(day)) => (now_local.year() - 8..=now_local.year() + 8) - .flat_map(|year| { - local_reset_occurrences( - timezone, - year, - month, - day, - components.hour, - components.minute, - ) - }) + .map(|year| (year, month, day)) .collect(), (None, None, None) => (-1..=1) - .flat_map(|offset| { + .map(|offset| { let date = now_local.date_naive() + Duration::days(offset); - local_reset_occurrences( - timezone, - date.year(), - date.month(), - date.day(), - components.hour, - components.minute, - ) + (date.year(), date.month(), date.day()) }) .collect(), _ => return None, }; + let candidates = dates + .into_iter() + .flat_map(|(year, month, day)| { + local_reset_occurrences( + timezone, + year, + month, + day, + components.hour, + components.minute, + ) + }) + .collect(); resolve_claude_reset_occurrence(candidates, now, expected_window_minutes) } @@ -313,21 +304,14 @@ fn parse_claude_hour( } fn claude_month(month: &str) -> Option { - match month.to_ascii_lowercase().as_str() { - "jan" => Some(1), - "feb" => Some(2), - "mar" => Some(3), - "apr" => Some(4), - "may" => Some(5), - "jun" => Some(6), - "jul" => Some(7), - "aug" => Some(8), - "sep" => Some(9), - "oct" => Some(10), - "nov" => Some(11), - "dec" => Some(12), - _ => None, - } + const MONTHS: [&str; 12] = [ + "jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec", + ]; + let month = month.to_ascii_lowercase(); + (1..) + .zip(MONTHS) + .find(|(_, name)| *name == month) + .map(|(number, _)| number) } fn local_reset_occurrences( diff --git a/rust/src/providers/claude/quota_history.rs b/rust/src/providers/claude/quota_history.rs index 08e54c348f1..de49a50c1d4 100644 --- a/rust/src/providers/claude/quota_history.rs +++ b/rust/src/providers/claude/quota_history.rs @@ -158,24 +158,21 @@ pub fn aggregate_claude_quota_windows( history_coverage_established, } = options; let account_scope = account_scope.into(); + let empty = |account_scope| ClaudeQuotaHistoryReport { + account_scope, + windows: Vec::new(), + history_coverage_established, + }; let count = max_windows.clamp(1, 8); let duration_minutes = normalized_window_minutes(window_minutes); let duration = Duration::minutes(duration_minutes); let Some(live_reset_at) = live_reset_at.filter(|reset| reset.timestamp_millis() > 0) else { - return ClaudeQuotaHistoryReport { - account_scope, - windows: Vec::new(), - history_coverage_established, - }; + return empty(account_scope); }; let evidence = ResetEvidence::new(&account_scope, observations, now); if evidence.is_cancelled(live_reset_at) { - return ClaudeQuotaHistoryReport { - account_scope, - windows: Vec::new(), - history_coverage_established, - }; + return empty(account_scope); } let current_end = current_window_end(live_reset_at, now, duration); let boundaries = quota_boundaries(current_end, duration, &evidence, count); @@ -188,11 +185,7 @@ pub fn aggregate_claude_quota_windows( // transcript rows. Keep the account-scoped surface absent until a source // supplies explicit attribution for at least one row. if attributed_records.is_empty() { - return ClaudeQuotaHistoryReport { - account_scope, - windows: Vec::new(), - history_coverage_established, - }; + return empty(account_scope); } let deduped = deduplicate_claude_records(attributed_records); diff --git a/rust/src/providers/claude/reset_observations.rs b/rust/src/providers/claude/reset_observations.rs index 7302688d7a9..762ec069da6 100644 --- a/rust/src/providers/claude/reset_observations.rs +++ b/rust/src/providers/claude/reset_observations.rs @@ -60,20 +60,8 @@ pub fn load_reset_observations( account_scope: &str, ) -> Result, ClaudeResetObservationError> { validate_scope(account_scope)?; - let path = store_path(config_root); - let raw = match secure_file::read_string(&path) { - Ok(raw) => raw, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), - Err(error) => return Err(ClaudeResetObservationError::Read(error)), - }; - let store: ClaudeResetObservationStore = - serde_json::from_str(&raw).map_err(ClaudeResetObservationError::Deserialize)?; - validate_store(&store)?; - Ok(store - .accounts - .get(account_scope) - .cloned() - .unwrap_or_default()) + let mut store = load_store(&store_path(config_root))?; + Ok(store.accounts.remove(account_scope).unwrap_or_default()) } pub fn merge_reset_observations( diff --git a/rust/src/providers/claude/scoped_weekly.rs b/rust/src/providers/claude/scoped_weekly.rs index 35da92c0328..601093ff2e4 100644 --- a/rust/src/providers/claude/scoped_weekly.rs +++ b/rust/src/providers/claude/scoped_weekly.rs @@ -77,14 +77,7 @@ pub(super) fn weekly_all_window(limits: &[ScopedWeeklyLimit]) -> Option Option if limit.group.as_deref().is_some_and(|g| g != "session") { return None; } - let percent = limit.percent.filter(|value| value.is_finite())?; - let resets_at = limit_resets_at(limit); - Some(RateWindow::with_details( - percent.clamp(0.0, 100.0), - Some(5 * 60), - resets_at, - None, - )) + limit_window(limit, 5 * 60) }) } +/// A finite `percent`, clamped, as a window of `minutes`. +fn limit_window(limit: &ScopedWeeklyLimit, minutes: u32) -> Option { + let percent = limit.percent.filter(|value| value.is_finite())?; + Some(RateWindow::with_details( + percent.clamp(0.0, 100.0), + Some(minutes), + limit_resets_at(limit), + None, + )) +} + fn limit_resets_at(limit: &ScopedWeeklyLimit) -> Option> { limit .resets_at From ae3183f593833715a5c2d9e9772b1393a9d8911a Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sat, 10 Oct 2026 16:49:58 +0700 Subject: [PATCH 29/44] Pin Claude web request error mapping --- .../providers/claude/reset_opt_in_tests.rs | 185 +++++++++++++++++- 1 file changed, 184 insertions(+), 1 deletion(-) diff --git a/rust/src/providers/claude/reset_opt_in_tests.rs b/rust/src/providers/claude/reset_opt_in_tests.rs index de0434b8242..0b29f654bfa 100644 --- a/rust/src/providers/claude/reset_opt_in_tests.rs +++ b/rust/src/providers/claude/reset_opt_in_tests.rs @@ -1,7 +1,9 @@ -//! Wire tests for the `cedar_ember` opt-in on the Claude Web usage request. +//! Wire tests for the Claude Web requests: the `cedar_ember` usage opt-in +//! and the status and parse mapping of each GET. use super::ClaudeWebApiFetcher; use crate::core::ProviderError; +use crate::providers::test_support::{mock_response, mock_response_expect}; use mockito::{Matcher, Mock, Server, ServerGuard}; const USAGE_PATH: &str = "/organizations/org-123/usage"; @@ -141,3 +143,184 @@ async fn ordinary_forbidden_retries_and_a_second_forbidden_is_an_auth_failure() first.assert_async().await; retry.assert_async().await; } + +fn describe(result: Result) -> String { + match result { + Ok(value) => format!("ok {value}"), + Err(ProviderError::AuthRequired) => "auth".to_string(), + Err(ProviderError::Parse(message)) => format!("parse {message}"), + Err(ProviderError::Other(message)) => format!("other {message}"), + Err(other) => format!("unexpected {other:?}"), + } +} + +#[tokio::test] +async fn organization_lookup_maps_statuses_after_the_account_fallback() { + let cloudflare = format!("other {CLOUDFLARE_MESSAGE}"); + let rows: [(usize, &str, &str); 6] = [ + (200, r#"[{"uuid": "org-9", "name": "Team"}]"#, "ok org-9"), + (200, "[]", "parse No organizations found"), + (401, "{}", "auth"), + (403, "permission denied", "auth"), + (403, "Just a moment...", &cloudflare), + ( + 500, + "{}", + "other Failed to get organizations: 500 Internal Server Error", + ), + ]; + for (status, body, expected) in rows { + let mut server = Server::new_async().await; + let account = mock_response(&mut server, "GET", "/account", 500, "{}").await; + let orgs = mock_response(&mut server, "GET", "/organizations", status, body).await; + let fetcher = ClaudeWebApiFetcher::new().with_base_url(server.url()); + let headers = ClaudeWebApiFetcher::build_headers("sessionKey=sk-ant-fixture-token"); + + let got = describe( + fetcher + .get_organization_id("sessionKey=sk-ant-fixture-token", &headers) + .await, + ); + + assert_eq!(got, expected, "status {status}"); + account.assert_async().await; + orgs.assert_async().await; + } +} + +#[tokio::test] +async fn organization_lookup_prefers_the_cookie_then_the_account() { + let mut server = Server::new_async().await; + let account = mock_response_expect( + &mut server, + "GET", + "/account", + 200, + r#"{"memberships": [{"organization": {"uuid": " org-acct "}}]}"#, + 1, + ) + .await; + let orgs = mock_response_expect(&mut server, "GET", "/organizations", 200, "[]", 0).await; + let fetcher = ClaudeWebApiFetcher::new().with_base_url(server.url()); + let headers = ClaudeWebApiFetcher::build_headers("sessionKey=sk-ant-fixture-token"); + + let from_cookie = fetcher + .get_organization_id( + "sessionKey=sk-ant-fixture-token; lastActiveOrg=org-cookie", + &headers, + ) + .await; + let from_account = fetcher + .get_organization_id("sessionKey=sk-ant-fixture-token", &headers) + .await; + + assert_eq!(describe(from_cookie), "ok org-cookie"); + assert_eq!(describe(from_account), "ok org-acct"); + account.assert_async().await; + orgs.assert_async().await; +} + +#[tokio::test] +async fn extra_usage_and_account_failures_keep_their_plain_status_text() { + let rows: [(usize, &str, &str, &str); 4] = [ + ( + 401, + "{}", + "other Failed to get extra usage: 401 Unauthorized", + "other Failed to get account: 401 Unauthorized", + ), + ( + 403, + "Just a moment...", + "other Failed to get extra usage: 403 Forbidden", + "other Failed to get account: 403 Forbidden", + ), + ( + 500, + "{}", + "other Failed to get extra usage: 500 Internal Server Error", + "other Failed to get account: 500 Internal Server Error", + ), + ( + 200, + "not json", + "parse Failed to parse extra usage: ", + "parse Failed to parse account: ", + ), + ]; + for (status, body, extra_expected, account_expected) in rows { + let mut server = Server::new_async().await; + let extra = mock_response( + &mut server, + "GET", + "/organizations/org-123/overage_spend_limit", + status, + body, + ) + .await; + let account = mock_response(&mut server, "GET", "/account", status, body).await; + let fetcher = ClaudeWebApiFetcher::new().with_base_url(server.url()); + let headers = ClaudeWebApiFetcher::build_headers("sessionKey=sk-ant-fixture-token"); + + let extra_got = describe( + fetcher + .get_extra_usage("org-123", &headers) + .await + .map(|usage| format!("{:?}", usage.monthly_credit_limit)), + ); + let account_got = describe( + fetcher + .get_account_info(&headers) + .await + .map(|info| format!("{:?}", info.email_address)), + ); + + if status == 200 { + assert!(extra_got.starts_with(extra_expected), "{extra_got}"); + assert!(account_got.starts_with(account_expected), "{account_got}"); + } else { + assert_eq!(extra_got, extra_expected, "status {status}"); + assert_eq!(account_got, account_expected, "status {status}"); + } + extra.assert_async().await; + account.assert_async().await; + } +} + +#[tokio::test] +async fn extra_usage_and_account_parse_successful_bodies() { + let mut server = Server::new_async().await; + let extra = mock_response( + &mut server, + "GET", + "/organizations/org-123/overage_spend_limit", + 200, + r#"{"monthly_credit_limit": 5000, "used_credits": 1200, "currency": "USD", "is_enabled": true}"#, + ) + .await; + let account = mock_response( + &mut server, + "GET", + "/account", + 200, + r#"{"email_address": "a@example.com", "rate_limit_tier": "default_claude_max_5x"}"#, + ) + .await; + let fetcher = ClaudeWebApiFetcher::new().with_base_url(server.url()); + let headers = ClaudeWebApiFetcher::build_headers("sessionKey=sk-ant-fixture-token"); + + let usage = fetcher.get_extra_usage("org-123", &headers).await.unwrap(); + let info = fetcher.get_account_info(&headers).await.unwrap(); + + assert_eq!(usage.monthly_credit_limit, Some(5000.0)); + assert_eq!(usage.used_credits, Some(1200.0)); + assert_eq!(usage.currency.as_deref(), Some("USD")); + assert_eq!(usage.is_enabled, Some(true)); + assert_eq!(info.email_address.as_deref(), Some("a@example.com")); + assert_eq!( + info.rate_limit_tier.as_deref(), + Some("default_claude_max_5x") + ); + extra.assert_async().await; + account.assert_async().await; +} From a50d8fe69fd949b16d1dbcfa6afac3b033459c64 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sat, 10 Oct 2026 16:54:02 +0700 Subject: [PATCH 30/44] Share the Claude web GET and status checks --- rust/src/providers/claude/web_api.rs | 117 +++++++++++---------------- 1 file changed, 45 insertions(+), 72 deletions(-) diff --git a/rust/src/providers/claude/web_api.rs b/rust/src/providers/claude/web_api.rs index 2e9c8a40dd6..eb63f116b9e 100755 --- a/rust/src/providers/claude/web_api.rs +++ b/rust/src/providers/claude/web_api.rs @@ -58,6 +58,25 @@ fn classify_web_http_error( ProviderError::Other(format!("Failed to get {label}: {status}")) } +/// Pass a success through; otherwise read the body and classify the status. +async fn ensure_success( + response: reqwest::Response, + label: &str, +) -> Result { + let status = response.status(); + if status.is_success() { + return Ok(response); + } + let response_headers = response.headers().clone(); + let body = response.bytes().await?; + Err(classify_web_http_error( + label, + status, + &response_headers, + &body, + )) +} + /// Read the response body as text, then deserialize as JSON. On failure, include /// non-sensitive shape metadata so auth redirects, error envelopes, and schema /// changes are distinguishable without exposing account data in UI/log output. @@ -532,26 +551,7 @@ impl ClaudeWebApiFetcher { } let url = format!("{}/organizations", self.base_url); - - let response = self - .client - .get(&url) - .headers(headers.clone()) - .send() - .await?; - - let status = response.status(); - if !status.is_success() { - let response_headers = response.headers().clone(); - let body = response.bytes().await?; - return Err(classify_web_http_error( - "organizations", - status, - &response_headers, - &body, - )); - } - + let response = ensure_success(self.get(&url, headers).await?, "organizations").await?; let orgs: Vec = parse_json_with_body(response, "organizations").await?; orgs.into_iter() @@ -573,10 +573,7 @@ impl ClaudeWebApiFetcher { ) -> Result { let url = format!("{}/organizations/{}/usage", self.base_url, org_id); let opted_in = self - .client - .get(format!("{url}?{RESET_OPT_IN_QUERY}")) - .headers(headers.clone()) - .send() + .get(&format!("{url}?{RESET_OPT_IN_QUERY}"), headers) .await?; let response = match opted_in.status() { @@ -593,27 +590,15 @@ impl ClaudeWebApiFetcher { &body, )); } - self.get_plain_usage(&url, headers).await? + self.get(&url, headers).await? } - _ => self.get_plain_usage(&url, headers).await?, + _ => self.get(&url, headers).await?, }; - let status = response.status(); - if !status.is_success() { - let response_headers = response.headers().clone(); - let body = response.bytes().await?; - return Err(classify_web_http_error( - "usage", - status, - &response_headers, - &body, - )); - } - - parse_json_with_body(response, "usage").await + parse_json_with_body(ensure_success(response, "usage").await?, "usage").await } - async fn get_plain_usage( + async fn get( &self, url: &str, headers: &reqwest::header::HeaderMap, @@ -621,6 +606,24 @@ impl ClaudeWebApiFetcher { Ok(self.client.get(url).headers(headers.clone()).send().await?) } + /// GET and parse JSON. Unlike [`ensure_success`], a failure reports only + /// the status, without reading the body or mapping 401/403 to auth. + async fn get_json( + &self, + url: &str, + headers: &reqwest::header::HeaderMap, + label: &str, + ) -> Result { + let response = self.get(url, headers).await?; + if !response.status().is_success() { + return Err(ProviderError::Other(format!( + "Failed to get {label}: {}", + response.status() + ))); + } + parse_json_with_body(response, label).await + } + /// Get extra usage (credits) async fn get_extra_usage( &self, @@ -631,22 +634,7 @@ impl ClaudeWebApiFetcher { "{}/organizations/{}/overage_spend_limit", self.base_url, org_id ); - - let response = self - .client - .get(&url) - .headers(headers.clone()) - .send() - .await?; - - if !response.status().is_success() { - return Err(ProviderError::Other(format!( - "Failed to get extra usage: {}", - response.status() - ))); - } - - parse_json_with_body(response, "extra usage").await + self.get_json(&url, headers, "extra usage").await } /// Best-effort prepaid Extra usage balance. Non-fatal on any failure. @@ -680,22 +668,7 @@ impl ClaudeWebApiFetcher { headers: &reqwest::header::HeaderMap, ) -> Result { let url = format!("{}/account", self.base_url); - - let response = self - .client - .get(&url) - .headers(headers.clone()) - .send() - .await?; - - if !response.status().is_success() { - return Err(ProviderError::Other(format!( - "Failed to get account: {}", - response.status() - ))); - } - - parse_json_with_body(response, "account").await + self.get_json(&url, headers, "account").await } /// Convert a usage window to a RateWindow From 4f918f49bb9ac179c3faed53c39e2c2463501a45 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sat, 10 Oct 2026 16:57:03 +0700 Subject: [PATCH 31/44] Pin claude-swap envelope checks for both commands --- .../providers/claude/claude_swap/parser.rs | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/rust/src/providers/claude/claude_swap/parser.rs b/rust/src/providers/claude/claude_swap/parser.rs index 5ff813871c5..ce7341b1488 100644 --- a/rust/src/providers/claude/claude_swap/parser.rs +++ b/rust/src/providers/claude/claude_swap/parser.rs @@ -743,6 +743,51 @@ mod tests { ); } + #[test] + fn list_and_switch_share_the_envelope_checks() { + const NOT_OBJECT: &str = "claude-swap returned output that is not a JSON object."; + const NO_SCHEMA: &str = "claude-swap output has no schemaVersion field."; + const SCHEMA_2: &str = + "claude-swap output uses unsupported schema version 2; CodexBar supports version 1."; + const UNKNOWN: &str = "claude-swap reported Error: unknown error"; + let rows: [(&str, &str, &str); 12] = [ + ("not json", NOT_OBJECT, NOT_OBJECT), + ("[]", NOT_OBJECT, NOT_OBJECT), + (r#""text""#, NOT_OBJECT, NOT_OBJECT), + ("{}", NO_SCHEMA, NO_SCHEMA), + (r#"{"schemaVersion": "1"}"#, NO_SCHEMA, NO_SCHEMA), + (r#"{"schemaVersion": 1.5}"#, NO_SCHEMA, NO_SCHEMA), + (r#"{"schemaVersion": 2}"#, SCHEMA_2, SCHEMA_2), + ( + r#"{"schemaVersion": 2, "error": {"type": "LockHeld"}}"#, + SCHEMA_2, + SCHEMA_2, + ), + (r#"{"schemaVersion": 1, "error": {}}"#, UNKNOWN, UNKNOWN), + ( + r#"{"schemaVersion": 1, "error": {"type": "", "message": ""}}"#, + UNKNOWN, + UNKNOWN, + ), + ( + r#"{"schemaVersion": 1, "error": {"type": "LockHeld", "message": "busy"}}"#, + "claude-swap reported LockHeld: busy", + "claude-swap reported LockHeld: busy", + ), + ( + r#"{"schemaVersion": 1, "error": "text"}"#, + "claude-swap output is malformed: missing accounts array", + "claude-swap output is malformed: missing switched flag", + ), + ]; + for (raw, list_expected, switch_expected) in rows { + let list = parse_account_list(raw).unwrap_err().to_string(); + let switch = parse_switch_result(raw).unwrap_err().to_string(); + assert_eq!(list, list_expected, "{raw}"); + assert_eq!(switch, switch_expected, "{raw}"); + } + } + #[test] fn switch_result_requires_matching_target_slot() { let raw = json!({ From 89b2368efd9433d57459ba0fd4b4285b1081f105 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sat, 10 Oct 2026 16:59:13 +0700 Subject: [PATCH 32/44] Share the claude-swap envelope and termination call --- .../providers/claude/claude_swap/parser.rs | 50 +++++-------------- .../providers/claude/claude_swap/runner.rs | 23 +++------ 2 files changed, 19 insertions(+), 54 deletions(-) diff --git a/rust/src/providers/claude/claude_swap/parser.rs b/rust/src/providers/claude/claude_swap/parser.rs index ce7341b1488..685cbc636a5 100644 --- a/rust/src/providers/claude/claude_swap/parser.rs +++ b/rust/src/providers/claude/claude_swap/parser.rs @@ -212,9 +212,12 @@ fn parse_row( } /// Strictly parse the schema-v1 `cswap --list --json` envelope. -pub fn parse_account_list(raw: &str) -> Result { - let value: Value = serde_json::from_str(raw).map_err(|_| ClaudeSwapError::NotJsonObject)?; - let object = value.as_object().ok_or(ClaudeSwapError::NotJsonObject)?; +/// Parse a schema-v1 object and surface a reported `error` envelope before +/// any command-specific field is read. +fn parse_envelope(raw: &str) -> Result, ClaudeSwapError> { + let Ok(Value::Object(object)) = serde_json::from_str::(raw) else { + return Err(ClaudeSwapError::NotJsonObject); + }; let schema_version = object .get("schemaVersion") @@ -248,6 +251,11 @@ pub fn parse_account_list(raw: &str) -> Result Result { + let object = parse_envelope(raw)?; let raw_accounts = object .get("accounts") @@ -318,41 +326,7 @@ pub fn parse_account_list(raw: &str) -> Result --json` envelope. pub fn parse_switch_result(raw: &str) -> Result { - let value: Value = serde_json::from_str(raw).map_err(|_| ClaudeSwapError::NotJsonObject)?; - let object = value.as_object().ok_or(ClaudeSwapError::NotJsonObject)?; - - let schema_version = object - .get("schemaVersion") - .and_then(Value::as_i64) - .ok_or(ClaudeSwapError::MissingSchemaVersion)?; - if schema_version != 1 { - return Err(ClaudeSwapError::UnsupportedSchemaVersion(schema_version)); - } - if let Some(error) = object.get("error").and_then(Value::as_object) { - let kind = sanitize_display( - error.get("type").and_then(Value::as_str).unwrap_or("Error"), - MAX_LABEL_CHARS, - ); - let message = sanitize_display( - error - .get("message") - .and_then(Value::as_str) - .unwrap_or("unknown error"), - MAX_DIAGNOSTIC_CHARS, - ); - return Err(ClaudeSwapError::ReportedError { - kind: if kind.is_empty() { - "Error".to_string() - } else { - kind - }, - message: if message.is_empty() { - "unknown error".to_string() - } else { - message - }, - }); - } + let object = parse_envelope(raw)?; let switched = object .get("switched") diff --git a/rust/src/providers/claude/claude_swap/runner.rs b/rust/src/providers/claude/claude_swap/runner.rs index aa3e2cb5194..defa919227f 100644 --- a/rust/src/providers/claude/claude_swap/runner.rs +++ b/rust/src/providers/claude/claude_swap/runner.rs @@ -301,8 +301,12 @@ fn wait_bounded_for_child(child: &mut Child) { } } +/// No job containment off Windows; the uninhabited guard keeps one call shape. #[cfg(not(windows))] -fn terminate_child_tree(child: &mut Child) { +type ProcessTreeGuard = std::convert::Infallible; + +#[cfg(not(windows))] +fn terminate_child_tree(child: &mut Child, _tree: Option) { if let Err(error) = child.kill() { tracing::warn!(%error, "failed to kill the direct claude-swap child"); } @@ -356,13 +360,12 @@ fn run_bounded( return Err(ClaudeSwapError::Process(error)); } }; + #[cfg(not(windows))] + let mut process_tree: Option = None; let stdout = match child.stdout.take() { Some(stdout) => stdout, None => { - #[cfg(windows)] terminate_child_tree(&mut child, process_tree.take()); - #[cfg(not(windows))] - terminate_child_tree(&mut child); return Err(ClaudeSwapError::Process( "Failed to capture stdout.".to_string(), )); @@ -371,10 +374,7 @@ fn run_bounded( let stderr = match child.stderr.take() { Some(stderr) => stderr, None => { - #[cfg(windows)] terminate_child_tree(&mut child, process_tree.take()); - #[cfg(not(windows))] - terminate_child_tree(&mut child); return Err(ClaudeSwapError::Process( "Failed to capture stderr.".to_string(), )); @@ -418,10 +418,7 @@ fn run_bounded( Ok(Some(_status)) => child_exited = true, Ok(None) => {} Err(e) => { - #[cfg(windows)] terminate_child_tree(&mut child, process_tree.take()); - #[cfg(not(windows))] - terminate_child_tree(&mut child); return Err(ClaudeSwapError::Process(e.to_string())); } } @@ -432,19 +429,13 @@ fn run_bounded( // A disconnected channel only means failure when a reader ended // without delivering its outcome; after both readers send it is normal. if disconnected && !readers_done { - #[cfg(windows)] terminate_child_tree(&mut child, process_tree.take()); - #[cfg(not(windows))] - terminate_child_tree(&mut child); return Err(ClaudeSwapError::Process( "claude-swap output streams closed unexpectedly.".to_string(), )); } if Instant::now() >= deadline { - #[cfg(windows)] terminate_child_tree(&mut child, process_tree.take()); - #[cfg(not(windows))] - terminate_child_tree(&mut child); return Err(ClaudeSwapError::TimedOut(timeout.as_secs())); } std::thread::sleep(POLL_INTERVAL); From 7c2e5d7473f0003cccb97ff1950db5e75a02c088 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:02:45 +0700 Subject: [PATCH 33/44] Re-export only the claude-swap items used outside it --- rust/src/providers/claude/claude_swap.rs | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/rust/src/providers/claude/claude_swap.rs b/rust/src/providers/claude/claude_swap.rs index 4fc13b9e63c..ecfe3734fb0 100644 --- a/rust/src/providers/claude/claude_swap.rs +++ b/rust/src/providers/claude/claude_swap.rs @@ -24,17 +24,13 @@ mod sanitize; use chrono::{DateTime, Utc}; use serde::Serialize; -pub use parser::{parse_account_list, parse_switch_result, validate_switch_target}; +pub use parser::parse_account_list; pub use projection::{ ClaudeSwapAccount, ClaudeSwapAccountAction, ClaudeSwapHistoricalUsageDto, ClaudeSwapScopedWindowDto, ClaudeSwapSpendWindowDto, ClaudeSwapUsageWindowDto, - HISTORICAL_USAGE_PROVENANCE, action_for_account, project_accounts, + action_for_account, project_accounts, }; -pub use runner::{ - DEFAULT_TIMEOUT, MAX_OUTPUT_BYTES, SWITCH_TIMEOUT, list_arguments, read_account_list, - read_account_list_with_timeout, resolve_executable_path, switch_account, switch_arguments, -}; -pub use sanitize::{MAX_DIAGNOSTIC_CHARS, MAX_LABEL_CHARS, sanitize_display}; +pub use runner::{read_account_list, switch_account}; #[derive(Debug, thiserror::Error)] pub enum ClaudeSwapError { From d6734eb8bb261e6fd2315ffcd37a9361458dee81 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:09:50 +0700 Subject: [PATCH 34/44] Table-drive the Claude CLI parse and error-state tests --- rust/src/providers/claude/mod.rs | 338 +++++++++++++------------------ 1 file changed, 139 insertions(+), 199 deletions(-) diff --git a/rust/src/providers/claude/mod.rs b/rust/src/providers/claude/mod.rs index d76f4713cf0..4ecf6f564aa 100755 --- a/rust/src/providers/claude/mod.rs +++ b/rust/src/providers/claude/mod.rs @@ -1613,26 +1613,15 @@ mod tests { } #[test] - fn passive_probe_env_disables_autoupdater_and_color() { + fn passive_probe_env_disables_autoupdater_color_and_chrome() { let env = claude_passive_probe_env(HashMap::new()); - assert_eq!( - env.get("DISABLE_AUTOUPDATER").map(String::as_str), - Some("1") - ); - assert_eq!(env.get("NO_COLOR").map(String::as_str), Some("1")); - } - - #[test] - fn probe_avoids_chrome_and_fullscreen_startup_dialogs() { - let env = claude_passive_probe_env(HashMap::new()); - assert_eq!( - env.get("CLAUDE_CODE_ENABLE_CFC").map(String::as_str), - Some("0") - ); - let settings: serde_json::Value = - serde_json::from_str(&claude_usage_settings_args()[1]).unwrap(); - assert_eq!(settings["tui"], "default"); - assert_eq!(settings["remoteControlAtStartup"], false); + for (key, value) in [ + ("DISABLE_AUTOUPDATER", "1"), + ("NO_COLOR", "1"), + ("CLAUDE_CODE_ENABLE_CFC", "0"), + ] { + assert_eq!(env.get(key).map(String::as_str), Some(value), "{key}"); + } } #[test] @@ -1664,6 +1653,10 @@ mod tests { r#"{"remoteControlAtStartup":false,"tui":"default"}"#.to_string(), ] ); + let settings: serde_json::Value = + serde_json::from_str(&claude_usage_settings_args()[1]).unwrap(); + assert_eq!(settings["tui"], "default"); + assert_eq!(settings["remoteControlAtStartup"], false); } #[test] @@ -1740,9 +1733,20 @@ mod tests { assert!(other.join("session.jsonl").exists(), "other projects stay"); } + fn parse_ok(output: &str) -> ProviderFetchResult { + ClaudeProvider::new() + .parse_cli_output(output) + .expect("should parse") + } + + fn parse_err(output: &str) -> ProviderError { + ClaudeProvider::new() + .parse_cli_output(output) + .expect_err("should reject") + } + #[test] fn parses_current_cli_usage_screen() { - let provider = ClaudeProvider::new(); let output = r#" Status Config Usage @@ -1759,7 +1763,7 @@ Status Config Usage $3.31 / $70.00 spent · Resets Apr 1 (America/Bogota) "#; - let result = provider.parse_cli_output(output).expect("should parse"); + let result = parse_ok(output); assert_eq!(result.source_label, "cli"); assert_eq!(result.usage.primary.used_percent, 100.0); @@ -1780,36 +1784,29 @@ Status Config Usage } #[test] - fn parses_exhausted_short_form_as_full_session_usage() { - let provider = ClaudeProvider::new(); - let output = "You're out of extra usage · resets 12pm (America/Bogota)"; - - let result = provider.parse_cli_output(output).expect("should parse"); - - assert_eq!(result.usage.primary.used_percent, 100.0); - assert_eq!( - result.usage.primary.reset_description.as_deref(), - Some("resets 12pm (America/Bogota)") - ); - } - - #[test] - fn parses_hit_limit_short_form_as_full_session_usage() { - let provider = ClaudeProvider::new(); - let output = "You've hit your limit \u{00b7} resets 3:20pm (Asia/Shanghai)"; - - let result = provider.parse_cli_output(output).expect("should parse"); - - assert_eq!(result.usage.primary.used_percent, 100.0); - assert_eq!( - result.usage.primary.reset_description.as_deref(), - Some("resets 3:20pm (Asia/Shanghai)") - ); + fn parses_short_forms_as_full_session_usage() { + let rows = [ + ( + "You're out of extra usage · resets 12pm (America/Bogota)", + "resets 12pm (America/Bogota)", + ), + ( + "You've hit your limit \u{00b7} resets 3:20pm (Asia/Shanghai)", + "resets 3:20pm (Asia/Shanghai)", + ), + ]; + for (output, reset) in rows { + let result = parse_ok(output); + assert_eq!(result.usage.primary.used_percent, 100.0, "{output}"); + assert_eq!( + result.usage.primary.reset_description.as_deref(), + Some(reset) + ); + } } #[test] fn parses_remaining_available_and_decimal_percentages() { - let provider = ClaudeProvider::new(); let output = r#" Status Config Usage @@ -1825,7 +1822,7 @@ Status Config Usage 1% consumed "#; - let result = provider.parse_cli_output(output).expect("should parse"); + let result = parse_ok(output); assert_eq!(result.usage.primary.used_percent, 87.5); assert_eq!( @@ -1854,7 +1851,6 @@ Status Config Usage #[test] fn parses_all_cli_model_scoped_weekly_limits() { - let provider = ClaudeProvider::new(); let output = r#" Current session 10% used @@ -1873,7 +1869,7 @@ Current week (Opus only) Resets Apr 5, 2pm (America/Bogota) "#; - let result = provider.parse_cli_output(output).expect("should parse"); + let result = parse_ok(output); assert_eq!(result.usage.extra_rate_windows.len(), 2); assert_eq!( @@ -2010,7 +2006,6 @@ Resets Apr 5, 2pm (America/Bogota) #[test] fn parses_compact_usage_screen() { - let provider = ClaudeProvider::new(); let output = r#" Settings:StatusConfigUsage(tabtocycle) Loadingusagedata... @@ -2025,7 +2020,7 @@ Currentweek(Sonnetonly) ResetsFeb12at1:29pm(Asia/Calcutta) "#; - let result = provider.parse_cli_output(output).expect("should parse"); + let result = parse_ok(output); assert_eq!(result.usage.primary.used_percent, 6.0); assert_eq!( @@ -2053,7 +2048,6 @@ ResetsFeb12at1:29pm(Asia/Calcutta) #[test] fn does_not_promote_weekly_reset_to_session() { - let provider = ClaudeProvider::new(); let output = r#" Current session 17% used @@ -2062,7 +2056,7 @@ Current week (all models) Resets Dec 24 at 3:59pm (Europe/Paris) "#; - let result = provider.parse_cli_output(output).expect("should parse"); + let result = parse_ok(output); assert_eq!(result.usage.primary.used_percent, 17.0); assert_eq!(result.usage.primary.reset_description, None); @@ -2218,22 +2212,6 @@ Resets Dec 24 at 3:59pm (Europe/Paris) assert!(extract_cli_scoped_weekly_limits(next, now).is_empty()); } - #[test] - fn rejects_cli_output_without_usage_markers() { - let provider = ClaudeProvider::new(); - let output = "Claude Code on Windows requires git-bash."; - - let err = provider - .parse_cli_output(output) - .expect_err("should reject non-usage output"); - - assert!(matches!(err, ProviderError::Parse(_))); - assert_eq!( - err.to_string(), - "Parse error: Claude CLI did not return usage data" - ); - } - #[test] fn cli_parse_usage_error_can_fallback_to_oauth() { let err = ProviderError::Parse("Claude CLI did not return usage data".to_string()); @@ -2391,31 +2369,16 @@ Resets Dec 24 at 3:59pm (Europe/Paris) } #[test] - fn rejects_claude_2_1_non_interactive_slash_response() { - let provider = ClaudeProvider::new(); - let output = r#" + fn rejects_cli_output_that_is_not_a_usage_screen() { + let git_bash = "Claude Code on Windows requires git-bash."; + let claude_2_1 = r#" I see you've entered `/usage` and `/exit`. **Usage**: Token usage and statistics are typically displayed by the CLI interface itself. I don't have direct access to those metrics through my available tools. **Exit**: I'll end the session here. Goodbye! "#; - - let err = provider - .parse_cli_output(output) - .expect_err("should reject non-interactive slash command response"); - - assert!(matches!(err, ProviderError::Other(_))); - assert_eq!( - err.to_string(), - "Claude CLI treated /usage as a normal prompt instead of opening the interactive usage screen. Use Auto, OAuth, or Web mode for Claude usage." - ); - } - - #[test] - fn rejects_legacy_non_interactive_slash_response() { - let provider = ClaudeProvider::new(); - let output = r#" + let legacy = r#" I see you've entered two slash commands: 1. `/usage` - This appears to be a request to check usage information @@ -2423,18 +2386,7 @@ I see you've entered two slash commands: However, looking at the available custom slash commands, I don't see these commands defined. "#; - - let err = provider - .parse_cli_output(output) - .expect_err("should reject non-interactive slash command response"); - - assert!(matches!(err, ProviderError::Other(_))); - } - - #[test] - fn rejects_cli_activity_stats_without_plan_limits() { - let provider = ClaudeProvider::new(); - let output = r#" + let activity = r#" ❯ /usage Status Config Usage Stats @@ -2445,37 +2397,50 @@ Favorite model: glm-4.6 Total tokens: 263.3k Sessions: 6 Longest session: 18s Active days: 2/10 Longest streak: 1 day "#; - - let err = provider - .parse_cli_output(output) - .expect_err("should reject local activity stats"); - - assert!(matches!(err, ProviderError::Other(_))); - assert_eq!( - err.to_string(), - "Claude CLI /usage opened, but this Claude version returned local activity stats instead of plan limit percentages. Use Auto, OAuth, or Web mode for Claude limits." - ); - } - - #[test] - fn rejects_ansi_spaced_cli_activity_stats_without_plan_limits() { - let provider = ClaudeProvider::new(); - let output = "\x1b[2CTotal\x1b[1Ccost:\x1b[12C$0.0000\n\ + let ansi_activity = "\x1b[2CTotal\x1b[1Ccost:\x1b[12C$0.0000\n\ \x1b[2CTotal\x1b[1Cduration\x1b[1C(API):\x1b[2C0s\n\ \x1b[2CUsage:\x1b[17C0\x1b[1Cinput,\x1b[1C0\x1b[1Coutput,\x1b[1C0\x1b[1Ccache\x1b[1Cread"; - - let err = provider - .parse_cli_output(output) - .expect_err("should reject ANSI-spaced local activity stats"); - - assert!(matches!(err, ProviderError::Other(_))); + let rows: [(&str, &str, Option<&str>); 5] = [ + ( + git_bash, + "parse", + Some("Parse error: Claude CLI did not return usage data"), + ), + ( + claude_2_1, + "other", + Some( + "Claude CLI treated /usage as a normal prompt instead of opening the interactive usage screen. Use Auto, OAuth, or Web mode for Claude usage.", + ), + ), + (legacy, "other", None), + ( + activity, + "other", + Some( + "Claude CLI /usage opened, but this Claude version returned local activity stats instead of plan limit percentages. Use Auto, OAuth, or Web mode for Claude limits.", + ), + ), + (ansi_activity, "other", None), + ]; + for (output, kind, message) in rows { + let err = parse_err(output); + let got = match &err { + ProviderError::Parse(_) => "parse", + ProviderError::Other(_) => "other", + _ => "unexpected", + }; + assert_eq!(got, kind, "{output}"); + if let Some(message) = message { + assert_eq!(err.to_string(), message); + } + } } #[test] fn accepts_plan_limits_followed_by_activity_stats() { // Claude Code 2.1.27x on Windows prints the exit summary (cost, // duration, cache tokens) after the /usage view when the probe ends. - let provider = ClaudeProvider::new(); let output = r#" ❯ /usage @@ -2494,9 +2459,7 @@ Total duration (API): 0s Usage: 0 input, 0 output, 0 cache read "#; - let result = provider - .parse_cli_output(output) - .expect("plan limits should win over trailing activity stats"); + let result = parse_ok(output); assert_eq!(result.usage.primary.used_percent, 19.0); assert_eq!( @@ -2561,11 +2524,9 @@ Usage: 0 input, 0 output, 0 cache read #[test] fn cli_quota_without_credential_identity_cannot_prove_account_action() { - let provider = ClaudeProvider::new(); - let result = provider - .parse_cli_output("Current session\n25% used\nCurrent week (all models)\n40% used") - .expect("CLI quota should parse"); - let result = mark_live_claude_cli_result(result); + let result = mark_live_claude_cli_result(parse_ok( + "Current session\n25% used\nCurrent week (all models)\n40% used", + )); assert!(result.usage.account_email.is_none()); assert!(result.has_successful_claude_cli_quota); @@ -2578,77 +2539,56 @@ Usage: 0 input, 0 output, 0 cache read assert!(!result.has_successful_claude_cli_quota); } #[test] - fn cli_presence_maps_to_local_runtime_offline() { - assert_eq!( - ClaudeProvider::new().error_state_kind(&ProviderError::NotInstalled( - "Claude CLI not found. Install from https://docs.claude.ai/claude-code".to_string(), - )), - crate::core::ProviderStateKind::LocalRuntimeOffline - ); - // Other error kinds keep their default classification. - assert_eq!( - ClaudeProvider::new().error_state_kind(&ProviderError::AuthRequired), - crate::core::ProviderStateKind::NeedsAuthentication - ); - } - - #[test] - fn oauth_rate_limit_is_not_sign_in_required() { - let error = ProviderError::OAuthTransient( + fn error_states_and_last_good_policies() { + use crate::core::ProviderStateKind; + let provider = ClaudeProvider::new(); + // `true` checks the free function on the rendered text; `false` the + // provider method. Each row keeps the path its original test used. + type Row = ( + ProviderError, + ProviderStateKind, + Option<(LastGoodFailurePolicy, bool)>, + ); + let rows: [Row; 6] = [ + ( + ProviderError::NotInstalled( + "Claude CLI not found. Install from https://docs.claude.ai/claude-code".to_string(), + ), + ProviderStateKind::LocalRuntimeOffline, + None, + ), + ( + ProviderError::AuthRequired, + ProviderStateKind::NeedsAuthentication, + None, + ), + (ProviderError::OAuthTransient( "OAuth error: Claude OAuth usage endpoint is rate limited. Retrying in about 1s; credentials were preserved." .to_string(), - ); - assert_eq!( - ClaudeProvider::new().error_state_kind(&error), - crate::core::ProviderStateKind::Unknown - ); - assert_eq!( - ClaudeProvider::new().last_good_failure_policy_for_error(&error), - LastGoodFailurePolicy::Preserve - ); - } - - #[test] - fn oauth_refresh_cooldown_is_not_sign_in_required() { - let error = ProviderError::OAuthTransient( + ), ProviderStateKind::Unknown, Some((LastGoodFailurePolicy::Preserve, false))), + (ProviderError::OAuthTransient( "Claude OAuth token expired and token refresh is cooling down after a failed attempt. Please retry shortly, or run `claude login`." .to_string(), - ); - assert_eq!( - ClaudeProvider::new().error_state_kind(&error), - crate::core::ProviderStateKind::Unknown - ); - assert_eq!( - ClaudeProvider::new().last_good_failure_policy_for_error(&error), - LastGoodFailurePolicy::Preserve - ); - } - - #[test] - fn missing_oauth_credentials_still_require_sign_in() { - let error = ProviderError::OAuth( + ), ProviderStateKind::Unknown, Some((LastGoodFailurePolicy::Preserve, false))), + (ProviderError::OAuth( "Claude OAuth credentials not found. Run `claude` to authenticate.".to_string(), - ); - assert_eq!( - ClaudeProvider::new().error_state_kind(&error), - crate::core::ProviderStateKind::NeedsAuthentication - ); - assert_eq!( - last_good_failure_policy_for_error(&error.to_string()), - LastGoodFailurePolicy::Replace - ); - } - - #[test] - fn untyped_oauth_rate_limit_text_is_not_transient() { - let error = ProviderError::OAuth("OAuth API returned rate limited".to_string()); - assert_eq!( - ClaudeProvider::new().error_state_kind(&error), - crate::core::ProviderStateKind::NeedsAuthentication - ); - assert_eq!( - ClaudeProvider::new().last_good_failure_policy_for_error(&error), - LastGoodFailurePolicy::Replace - ); + ), ProviderStateKind::NeedsAuthentication, Some((LastGoodFailurePolicy::Replace, true))), + (ProviderError::OAuth("OAuth API returned rate limited".to_string()), ProviderStateKind::NeedsAuthentication, Some((LastGoodFailurePolicy::Replace, false))), + ]; + for (error, state, policy) in rows { + assert_eq!(provider.error_state_kind(&error), state, "{error}"); + match policy { + Some((expected, true)) => assert_eq!( + last_good_failure_policy_for_error(&error.to_string()), + expected + ), + Some((expected, false)) => assert_eq!( + provider.last_good_failure_policy_for_error(&error), + expected, + "{error}" + ), + None => {} + } + } } } From 42eaec8916ca4fcae86a07a32ea7f215ef44ed47 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:13:14 +0700 Subject: [PATCH 35/44] Table-drive the Claude web API tests --- rust/src/providers/claude/web_api.rs | 255 +++++++++------------------ 1 file changed, 87 insertions(+), 168 deletions(-) diff --git a/rust/src/providers/claude/web_api.rs b/rust/src/providers/claude/web_api.rs index eb63f116b9e..8dc110c2f2c 100755 --- a/rust/src/providers/claude/web_api.rs +++ b/rust/src/providers/claude/web_api.rs @@ -857,31 +857,22 @@ mod tests { } #[test] - fn keeps_sub_one_utilization_in_percent_units() { - let window = UsageWindow { - utilization: Some(0.23), - resets_at: None, - }; - - let rate = ClaudeWebApiFetcher::new().to_rate_window(&window, Some(300)); - - assert!((rate.used_percent - 0.23).abs() < f64::EPSILON); - } - - #[test] - fn one_percent_session_is_not_reported_as_full_quota() { - let window = UsageWindow { - utilization: Some(1.0), - resets_at: None, - }; + fn keeps_utilization_in_percent_units() { + // 1.0 is a 1% session, not a full quota. + for utilization in [0.23, 1.0, 23.0] { + let window = UsageWindow { + utilization: Some(utilization), + resets_at: None, + }; - let rate = ClaudeWebApiFetcher::new().to_rate_window(&window, Some(300)); + let rate = ClaudeWebApiFetcher::new().to_rate_window(&window, Some(300)); - assert!( - (rate.used_percent - 1.0).abs() < f64::EPSILON, - "session was {}, expected 1% (not 100%)", - rate.used_percent - ); + assert!( + (rate.used_percent - utilization).abs() < f64::EPSILON, + "session was {}, expected {utilization}% (not 100%)", + rate.used_percent + ); + } } #[test] @@ -906,18 +897,6 @@ mod tests { assert!(!idle.is_informational); } - #[test] - fn preserves_existing_percentage_utilization() { - let window = UsageWindow { - utilization: Some(23.0), - resets_at: None, - }; - - let rate = ClaudeWebApiFetcher::new().to_rate_window(&window, Some(300)); - - assert!((rate.used_percent - 23.0).abs() < f64::EPSILON); - } - #[test] fn labels_max_5x_and_20x_plans() { assert_eq!( @@ -931,44 +910,34 @@ mod tests { } #[test] - fn resolves_raw_session_key_from_primary_env_var() { + fn resolves_session_key_from_env_vars() { let _guard = env_lock().lock().expect("env lock"); - // SAFETY: running under env_lock() so no other test thread touches the - // environment concurrently; single-threaded w.r.t. these keys. - unsafe { - std::env::remove_var("CLAUDE_AI_SESSION_KEY"); - std::env::remove_var("CLAUDE_WEB_SESSION_KEY"); - std::env::set_var("CLAUDE_AI_SESSION_KEY", "sk-ant-primary"); - std::env::set_var("CLAUDE_WEB_SESSION_KEY", "sk-ant-secondary"); - } - - let session_key = ClaudeWebApiFetcher::resolve_session_key_from_env(); - - assert_eq!(session_key.as_deref(), Some("sk-ant-primary")); + // (CLAUDE_AI_SESSION_KEY, CLAUDE_WEB_SESSION_KEY, resolved key) + let rows = [ + (Some("sk-ant-primary"), "sk-ant-secondary", "sk-ant-primary"), + ( + None, + "sessionKey=sk-ant-cookie-format", + "sk-ant-cookie-format", + ), + ]; + for (primary, secondary, expected) in rows { + // SAFETY: env_lock() held for this whole test, so set_var/remove_var + // cannot race another thread's environment access. + unsafe { + std::env::remove_var("CLAUDE_AI_SESSION_KEY"); + std::env::remove_var("CLAUDE_WEB_SESSION_KEY"); + if let Some(primary) = primary { + std::env::set_var("CLAUDE_AI_SESSION_KEY", primary); + } + std::env::set_var("CLAUDE_WEB_SESSION_KEY", secondary); + } - // SAFETY: same env_lock()-guarded mutation; restoring state after the - // assertions, before the lock is released. - unsafe { - std::env::remove_var("CLAUDE_AI_SESSION_KEY"); - std::env::remove_var("CLAUDE_WEB_SESSION_KEY"); - } - } + let session_key = ClaudeWebApiFetcher::resolve_session_key_from_env(); - #[test] - fn resolves_session_key_assignment_from_env_var() { - let _guard = env_lock().lock().expect("env lock"); - // SAFETY: env_lock() held for this whole test, so set_var/remove_var - // cannot race another thread's environment access. - unsafe { - std::env::remove_var("CLAUDE_AI_SESSION_KEY"); - std::env::remove_var("CLAUDE_WEB_SESSION_KEY"); - std::env::set_var("CLAUDE_WEB_SESSION_KEY", "sessionKey=sk-ant-cookie-format"); + assert_eq!(session_key.as_deref(), Some(expected)); } - let session_key = ClaudeWebApiFetcher::resolve_session_key_from_env(); - - assert_eq!(session_key.as_deref(), Some("sk-ant-cookie-format")); - // SAFETY: cleanup while still holding the env_lock() guard. unsafe { std::env::remove_var("CLAUDE_AI_SESSION_KEY"); @@ -980,36 +949,19 @@ mod tests { fn build_headers_include_required_browser_context() { let headers = ClaudeWebApiFetcher::build_headers("sessionKey=sk-ant-cookie-format"); - assert_eq!( - headers - .get(header::COOKIE) - .and_then(|value| value.to_str().ok()), - Some("sessionKey=sk-ant-cookie-format") - ); - assert_eq!( - headers - .get(header::ACCEPT) - .and_then(|value| value.to_str().ok()), - Some("application/json") - ); - assert_eq!( - headers - .get(header::ORIGIN) - .and_then(|value| value.to_str().ok()), - Some("https://claude.ai") - ); - assert_eq!( - headers - .get(header::REFERER) - .and_then(|value| value.to_str().ok()), - Some("https://claude.ai/settings/usage") - ); - assert_eq!( - headers - .get("anthropic-client-platform") - .and_then(|value| value.to_str().ok()), - Some("web_claude_ai") - ); + for (name, value) in [ + (header::COOKIE.as_str(), "sessionKey=sk-ant-cookie-format"), + (header::ACCEPT.as_str(), "application/json"), + (header::ORIGIN.as_str(), "https://claude.ai"), + (header::REFERER.as_str(), "https://claude.ai/settings/usage"), + ("anthropic-client-platform", "web_claude_ai"), + ] { + assert_eq!( + headers.get(name).and_then(|value| value.to_str().ok()), + Some(value), + "{name}" + ); + } assert!(headers.contains_key(header::USER_AGENT)); } @@ -1081,30 +1033,45 @@ mod tests { } #[test] - fn parses_extra_design_and_routines_aliases() { - let usage: super::UsageResponse = serde_json::from_str( - r#"{ - "five_hour": { "utilization": 0.1 }, - "seven_day_omelette": { "utilization": 26 }, - "seven_day_cowork": { "utilization": 11 } - }"#, - ) - .unwrap(); - + fn parses_design_and_routines_aliases_preferring_the_named_key() { + let rows = [ + ( + r#"{ + "five_hour": { "utilization": 0.1 }, + "seven_day_omelette": { "utilization": 26 }, + "seven_day_cowork": { "utilization": 11 } + }"#, + 26.0, + 11.0, + ), + ( + r#"{ + "seven_day_design": { "utilization": 31 }, + "seven_day_omelette": { "utilization": 26 }, + "seven_day_routines": { "utilization": 19 }, + "seven_day_cowork": { "utilization": 11 } + }"#, + 31.0, + 19.0, + ), + ]; let fetcher = ClaudeWebApiFetcher::new(); - let design = usage - .seven_day_design - .as_ref() - .map(|w| fetcher.to_rate_window(w, Some(10080))) - .expect("design window"); - let routines = usage - .seven_day_routines - .as_ref() - .map(|w| fetcher.to_rate_window(w, Some(10080))) - .expect("routines window"); + for (json, design_percent, routines_percent) in rows { + let usage: super::UsageResponse = serde_json::from_str(json).unwrap(); + let design = usage + .seven_day_design + .as_ref() + .map(|w| fetcher.to_rate_window(w, Some(10080))) + .expect("design window"); + let routines = usage + .seven_day_routines + .as_ref() + .map(|w| fetcher.to_rate_window(w, Some(10080))) + .expect("routines window"); - assert!((design.used_percent - 26.0).abs() < f64::EPSILON); - assert!((routines.used_percent - 11.0).abs() < f64::EPSILON); + assert!((design.used_percent - design_percent).abs() < f64::EPSILON); + assert!((routines.used_percent - routines_percent).abs() < f64::EPSILON); + } } #[test] @@ -1129,34 +1096,6 @@ mod tests { assert_eq!(windows[0].title, "Fable only"); } - #[test] - fn parses_duplicate_design_and_routines_aliases_with_preferred_key() { - let usage: super::UsageResponse = serde_json::from_str( - r#"{ - "seven_day_design": { "utilization": 31 }, - "seven_day_omelette": { "utilization": 26 }, - "seven_day_routines": { "utilization": 19 }, - "seven_day_cowork": { "utilization": 11 } - }"#, - ) - .unwrap(); - - let fetcher = ClaudeWebApiFetcher::new(); - let design = usage - .seven_day_design - .as_ref() - .map(|w| fetcher.to_rate_window(w, Some(10080))) - .expect("design window"); - let routines = usage - .seven_day_routines - .as_ref() - .map(|w| fetcher.to_rate_window(w, Some(10080))) - .expect("routines window"); - - assert!((design.used_percent - 31.0).abs() < f64::EPSILON); - assert!((routines.used_percent - 19.0).abs() < f64::EPSILON); - } - #[test] fn parses_oauth_apps_window_and_embedded_extra_usage() { let usage: super::UsageResponse = serde_json::from_str( @@ -1327,26 +1266,6 @@ mod tests { ] ); } - - #[test] - fn web_extras_keep_routines_in_raw_snapshot() { - use crate::core::{NamedRateWindow, RateWindow, UsageSnapshot}; - - let mut snapshot = UsageSnapshot::new(RateWindow::new(10.0)); - super::append_web_extra_windows( - &mut snapshot, - Some(RateWindow::new(1.0)), - vec![NamedRateWindow::new( - "claude-weekly-scoped-fable", - "Fable only", - RateWindow::new(2.0), - )], - Some(RateWindow::new(3.0)), - ); - - assert_eq!(snapshot.extra_rate_windows.len(), 3); - assert_eq!(snapshot.extra_rate_windows[2].id, "claude-routines"); - } } #[cfg(test)] From 241d28b196fb7517c9ef36d2c6373a70d4cb47f7 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:17:43 +0700 Subject: [PATCH 36/44] Table-drive the Claude OAuth and claude-swap sanitize tests --- .../providers/claude/claude_swap/sanitize.rs | 105 +++---- .../claude/oauth/credentials_store.rs | 90 +++--- rust/src/providers/claude/oauth/refresh.rs | 73 ++--- rust/src/providers/claude/oauth/tests.rs | 270 +++++++----------- 4 files changed, 200 insertions(+), 338 deletions(-) diff --git a/rust/src/providers/claude/claude_swap/sanitize.rs b/rust/src/providers/claude/claude_swap/sanitize.rs index 44c4dd62983..6c75d6a91b2 100644 --- a/rust/src/providers/claude/claude_swap/sanitize.rs +++ b/rust/src/providers/claude/claude_swap/sanitize.rs @@ -106,81 +106,46 @@ mod tests { use super::*; #[test] - fn strips_terminal_escapes_and_bounds_length() { - let csi = "\u{1b}[31mred\u{1b}[0m"; - assert_eq!(sanitize_display(csi, MAX_LABEL_CHARS), "red"); - - let osc = "a\u{1b}]0;ignored\u{07}b"; - assert_eq!(sanitize_display(osc, MAX_LABEL_CHARS), "ab"); + fn sanitizes_display_text() { + let rows = [ + // Terminal escapes (CSI, OSC). + ("\u{1b}[31mred\u{1b}[0m", "red"), + ("a\u{1b}]0;ignored\u{07}b", "ab"), + // Line breaks and repeated spaces collapse. + ("one\r\ntwo\u{2028}three", "one two three"), + (" a b ", "a b"), + // Bidi controls: RLO ... PDF around reversed text would otherwise render + // as "txet"; then LRM / RLM / ALM / isolates. + ("safe\u{202E}txet\u{202C}", "safetxet"), + ("left\u{200F}right\u{200E}\u{061C}", "leftright"), + ("a\u{2066}b\u{2069}c", "abc"), + ("x\u{202A}y\u{202B}z\u{202D}", "xyz"), + // Default-ignorable code points. + ("co\u{00AD}de", "code"), + ("a\u{200B}\u{200C}\u{200D}b", "ab"), + ("word\u{2060}joiner", "wordjoiner"), + ("\u{FEFF}bom", "bom"), + ("e\u{FE0F}motion", "emotion"), + ("tag\u{E0061}\u{E007F}end", "tagend"), + ("filler\u{3164}text", "fillertext"), + // U+0301 is a combining acute accent (Mn), not default-ignorable. + ("e\u{0301}", "e\u{0301}"), + ]; + for (input, expected) in rows { + assert_eq!( + sanitize_display(input, MAX_LABEL_CHARS), + expected, + "{input:?}" + ); + } + } + #[test] + fn bounds_display_length() { let long = "x".repeat(MAX_LABEL_CHARS + 50); assert_eq!( sanitize_display(&long, MAX_LABEL_CHARS).chars().count(), MAX_LABEL_CHARS ); } - - #[test] - fn collapses_line_breaks_and_repeated_spaces() { - let multiline = "one\r\ntwo\u{2028}three"; - assert_eq!( - sanitize_display(multiline, MAX_LABEL_CHARS), - "one two three" - ); - assert_eq!(sanitize_display(" a b ", MAX_LABEL_CHARS), "a b"); - } - - #[test] - fn strips_bidi_controls_that_could_reorder_text() { - // RLO ... PDF around reversed text would otherwise render as "txet". - assert_eq!( - sanitize_display("safe\u{202E}txet\u{202C}", MAX_LABEL_CHARS), - "safetxet" - ); - // LRM / RLM / ALM / isolates. - assert_eq!( - sanitize_display("left\u{200F}right\u{200E}\u{061C}", MAX_LABEL_CHARS), - "leftright" - ); - assert_eq!( - sanitize_display("a\u{2066}b\u{2069}c", MAX_LABEL_CHARS), - "abc" - ); - assert_eq!( - sanitize_display("x\u{202A}y\u{202B}z\u{202D}", MAX_LABEL_CHARS), - "xyz" - ); - } - - #[test] - fn strips_default_ignorable_code_points() { - assert_eq!(sanitize_display("co\u{00AD}de", MAX_LABEL_CHARS), "code"); - assert_eq!( - sanitize_display("a\u{200B}\u{200C}\u{200D}b", MAX_LABEL_CHARS), - "ab" - ); - assert_eq!( - sanitize_display("word\u{2060}joiner", MAX_LABEL_CHARS), - "wordjoiner" - ); - assert_eq!(sanitize_display("\u{FEFF}bom", MAX_LABEL_CHARS), "bom"); - assert_eq!( - sanitize_display("e\u{FE0F}motion", MAX_LABEL_CHARS), - "emotion" - ); - assert_eq!( - sanitize_display("tag\u{E0061}\u{E007F}end", MAX_LABEL_CHARS), - "tagend" - ); - assert_eq!( - sanitize_display("filler\u{3164}text", MAX_LABEL_CHARS), - "fillertext" - ); - } - - #[test] - fn preserves_ordinary_combining_marks() { - // U+0301 is a combining acute accent (Mn), not default-ignorable. - assert_eq!(sanitize_display("e\u{0301}", MAX_LABEL_CHARS), "e\u{0301}"); - } } diff --git a/rust/src/providers/claude/oauth/credentials_store.rs b/rust/src/providers/claude/oauth/credentials_store.rs index 19056585faa..b9f67707351 100644 --- a/rust/src/providers/claude/oauth/credentials_store.rs +++ b/rust/src/providers/claude/oauth/credentials_store.rs @@ -460,6 +460,27 @@ fn apply_refresh_to_credentials_json( #[cfg(test)] mod tests { + use super::{ + CredentialSource, apply_refresh_to_credentials_json, cached_refreshed_if_fresher, + parse_credentials_json, replacement_from_changed_fresh_keyring, store_refreshed, + }; + use crate::providers::claude::oauth::ClaudeOAuthCredentials; + use chrono::{DateTime, Duration, Utc}; + + fn creds( + access_token: &str, + refresh_token: Option<&str>, + expires_at: Option>, + ) -> ClaudeOAuthCredentials { + ClaudeOAuthCredentials { + access_token: access_token.to_string(), + refresh_token: refresh_token.map(str::to_string), + expires_at, + scopes: vec!["user:profile".to_string()], + rate_limit_tier: None, + } + } + #[test] fn a_new_saved_login_cannot_inherit_an_older_longer_lived_rotation() { let path = std::path::PathBuf::from(format!("saved-cache-{}", uuid::Uuid::new_v4())); @@ -482,11 +503,6 @@ mod tests { assert!(super::cached_refreshed_if_fresher(&old_source, &shorter_login).is_some()); assert!(super::cached_refreshed_if_fresher(&new_source, &shorter_login).is_none()); } - use super::{ - CredentialSource, apply_refresh_to_credentials_json, cached_refreshed_if_fresher, - parse_credentials_json, replacement_from_changed_fresh_keyring, store_refreshed, - }; - use crate::providers::claude::oauth::ClaudeOAuthCredentials; #[test] fn parses_claude_code_credentials_payload() { @@ -548,21 +564,17 @@ mod tests { #[test] fn changed_fresh_keyring_replaces_expired_file_credentials() { - let expired_file = ClaudeOAuthCredentials { - access_token: "expired-file-token".to_string(), - refresh_token: Some("expired-file-refresh".to_string()), - expires_at: Some(chrono::Utc::now() - chrono::Duration::hours(1)), - scopes: vec!["user:profile".to_string()], - rate_limit_tier: None, - }; + let expired_file = creds( + "expired-file-token", + Some("expired-file-refresh"), + Some(Utc::now() - Duration::hours(1)), + ); let keyring_source = CredentialSource::Keyring("test-user".to_string()); - let fresh_keyring = ClaudeOAuthCredentials { - access_token: "fresh-keyring-token".to_string(), - refresh_token: Some("fresh-keyring-refresh".to_string()), - expires_at: Some(chrono::Utc::now() + chrono::Duration::hours(1)), - scopes: vec!["user:profile".to_string()], - rate_limit_tier: None, - }; + let fresh_keyring = creds( + "fresh-keyring-token", + Some("fresh-keyring-refresh"), + Some(Utc::now() + Duration::hours(1)), + ); let adopted = replacement_from_changed_fresh_keyring( &expired_file, @@ -572,13 +584,11 @@ mod tests { assert_eq!(adopted.0.access_token, "fresh-keyring-token"); assert_eq!(adopted.1, keyring_source); - let expired_keyring = ClaudeOAuthCredentials { - access_token: "another-keyring-token".to_string(), - refresh_token: None, - expires_at: Some(chrono::Utc::now() - chrono::Duration::minutes(1)), - scopes: vec!["user:profile".to_string()], - rate_limit_tier: None, - }; + let expired_keyring = creds( + "another-keyring-token", + None, + Some(Utc::now() - Duration::minutes(1)), + ); assert!( replacement_from_changed_fresh_keyring( &expired_file, @@ -645,22 +655,14 @@ mod tests { "env_source_not_shadowed_by_file_cache-unique-marker.json", )); - let file_cached_creds = ClaudeOAuthCredentials { - access_token: "file-refreshed-token".to_string(), - refresh_token: Some("file-refresh".to_string()), - expires_at: Some(chrono::Utc::now() + chrono::Duration::hours(1)), - scopes: vec!["user:profile".to_string()], - rate_limit_tier: None, - }; + let file_cached_creds = creds( + "file-refreshed-token", + Some("file-refresh"), + Some(Utc::now() + Duration::hours(1)), + ); store_refreshed(&file_source, &file_cached_creds); - let env_creds = ClaudeOAuthCredentials { - access_token: "env-token".to_string(), - refresh_token: None, - expires_at: None, - scopes: vec!["user:profile".to_string()], - rate_limit_tier: None, - }; + let env_creds = creds("env-token", None, None); // Looking up under the Environment source must not see the File // source's cached (and "fresher"-by-the-naive-rule) entry. @@ -672,13 +674,7 @@ mod tests { // Sanity check: the file source's own cache entry is still there and // still considered fresher than a file-read with no expiry. - let file_disk_creds = ClaudeOAuthCredentials { - access_token: "file-disk-token".to_string(), - refresh_token: Some("file-disk-refresh".to_string()), - expires_at: None, - scopes: vec!["user:profile".to_string()], - rate_limit_tier: None, - }; + let file_disk_creds = creds("file-disk-token", Some("file-disk-refresh"), None); let same_source_result = cached_refreshed_if_fresher(&file_source, &file_disk_creds); assert_eq!( same_source_result.map(|c| c.access_token), diff --git a/rust/src/providers/claude/oauth/refresh.rs b/rust/src/providers/claude/oauth/refresh.rs index 98735b4bc92..10cf4ab49e0 100644 --- a/rust/src/providers/claude/oauth/refresh.rs +++ b/rust/src/providers/claude/oauth/refresh.rs @@ -185,56 +185,35 @@ mod tests { // and 5xx stay transient (a retry can still heal them); 400/401 *without* // invalid_grant is likewise transient. #[test] - fn invalid_grant_on_400_or_401_is_terminal() { - for status in [400, 401] { + fn refresh_failure_kind_by_status_and_body() { + use RefreshFailureKind::{Terminal, Transient}; + const GRANT: &str = r#"{"error":"invalid_grant"}"#; + let rows = [ + (400, GRANT, Terminal), + (401, GRANT, Terminal), + // Case-insensitive match. + (400, r#"{"error":"INVALID_GRANT"}"#, Terminal), + // 403 is never terminal, even with invalid_grant in the body. + (403, GRANT, Transient), + // 400/401 with a different OAuth error are transient. + (400, r#"{"error":"invalid_client"}"#, Transient), + (401, r#"{"error":"invalid_client"}"#, Transient), + // 400/401 with no parseable error field are transient. + (400, "busy", Transient), + (401, "busy", Transient), + // Server and rate-limit errors. + (408, "busy", Transient), + (429, "busy", Transient), + (500, "busy", Transient), + (502, "busy", Transient), + (503, "busy", Transient), + ]; + for (status, body, kind) in rows { let failure = RefreshFailure::from_http_status( reqwest::StatusCode::from_u16(status).unwrap(), - r#"{"error":"invalid_grant"}"#, + body, ); - assert_eq!(failure.kind, RefreshFailureKind::Terminal, "HTTP {status}"); - } - // Case-insensitive match. - let failure = RefreshFailure::from_http_status( - reqwest::StatusCode::from_u16(400).unwrap(), - r#"{"error":"INVALID_GRANT"}"#, - ); - assert_eq!(failure.kind, RefreshFailureKind::Terminal); - } - - #[test] - fn forbidden_and_non_grant_4xx_stay_transient() { - // 403 is never terminal, even with invalid_grant in the body. - let failure = RefreshFailure::from_http_status( - reqwest::StatusCode::from_u16(403).unwrap(), - r#"{"error":"invalid_grant"}"#, - ); - assert_eq!(failure.kind, RefreshFailureKind::Transient); - // 400/401 with a different OAuth error are transient. - for status in [400, 401] { - let failure = RefreshFailure::from_http_status( - reqwest::StatusCode::from_u16(status).unwrap(), - r#"{"error":"invalid_client"}"#, - ); - assert_eq!(failure.kind, RefreshFailureKind::Transient, "HTTP {status}"); - } - // 400/401 with no parseable error field are transient. - for status in [400, 401] { - let failure = RefreshFailure::from_http_status( - reqwest::StatusCode::from_u16(status).unwrap(), - "busy", - ); - assert_eq!(failure.kind, RefreshFailureKind::Transient, "HTTP {status}"); - } - } - - #[test] - fn refresh_server_and_rate_limit_errors_stay_transient() { - for status in [408, 429, 500, 502, 503] { - let failure = RefreshFailure::from_http_status( - reqwest::StatusCode::from_u16(status).unwrap(), - "busy", - ); - assert_eq!(failure.kind, RefreshFailureKind::Transient, "HTTP {status}"); + assert_eq!(failure.kind, kind, "HTTP {status} {body}"); } } } diff --git a/rust/src/providers/claude/oauth/tests.rs b/rust/src/providers/claude/oauth/tests.rs index 3e3fa9ce5c7..01a8bd89835 100644 --- a/rust/src/providers/claude/oauth/tests.rs +++ b/rust/src/providers/claude/oauth/tests.rs @@ -2,7 +2,7 @@ use super::{ ClaudeOAuthCredentials, ClaudeOAuthFetcher, OAuthUsageResponse, UsageWindow, credential_identity, is_rate_limited_error, }; -use crate::core::ProviderError; +use crate::core::{ProviderError, UsageSnapshot}; use base64::Engine; use reqwest::header::HeaderValue; use std::time::Duration; @@ -29,6 +29,19 @@ fn test_credentials(access_token: &str) -> ClaudeOAuthCredentials { } } +fn creds(rate_limit_tier: Option<&str>) -> ClaudeOAuthCredentials { + ClaudeOAuthCredentials { + scopes: vec![], + rate_limit_tier: rate_limit_tier.map(str::to_string), + ..test_credentials("token") + } +} + +fn snapshot(json: &str, credentials: &ClaudeOAuthCredentials) -> UsageSnapshot { + let response: OAuthUsageResponse = serde_json::from_str(json).expect("OAuth usage body"); + ClaudeOAuthFetcher::new().build_usage_snapshot(&response, credentials) +} + #[test] fn credential_identity_uses_jwt_subject_when_available() { let payload = @@ -53,56 +66,32 @@ fn opaque_credential_identity_is_a_non_secret_fingerprint() { } #[test] -fn keeps_sub_one_utilization_in_percent_units() { - let window = UsageWindow { - utilization: Some(0.23), - resets_at: None, - }; - - let rate = ClaudeOAuthFetcher::to_rate_window(&window, Some(300)).expect("rate window"); - - assert!((rate.used_percent - 0.23).abs() < f64::EPSILON); -} - -#[test] -fn one_percent_session_is_not_reported_as_full_quota() { - let window = UsageWindow { - utilization: Some(1.0), - resets_at: None, - }; - - let rate = ClaudeOAuthFetcher::to_rate_window(&window, Some(300)).expect("rate window"); - - assert!( - (rate.used_percent - 1.0).abs() < f64::EPSILON, - "session was {}, expected 1% (not 100%)", - rate.used_percent - ); -} - -#[test] -fn preserves_existing_percentage_utilization() { - let window = UsageWindow { - utilization: Some(23.0), - resets_at: None, - }; - - let rate = ClaudeOAuthFetcher::to_rate_window(&window, Some(300)).expect("rate window"); - - assert!((rate.used_percent - 23.0).abs() < f64::EPSILON); +fn keeps_utilization_in_percent_units() { + // 1.0 is a 1% session, not a full quota. + for utilization in [0.23, 1.0, 23.0] { + let window = UsageWindow { + utilization: Some(utilization), + resets_at: None, + }; + + let rate = ClaudeOAuthFetcher::to_rate_window(&window, Some(300)).expect("rate window"); + + assert!( + (rate.used_percent - utilization).abs() < f64::EPSILON, + "session was {}, expected {utilization}% (not 100%)", + rate.used_percent + ); + } } #[test] fn missing_oauth_session_is_informational_and_keeps_weekly_lane() { - let response: OAuthUsageResponse = serde_json::from_str( + let usage = snapshot( r#"{ "seven_day": {"utilization": 51.0, "resets_at": "2026-08-20T12:00:00Z"} }"#, - ) - .expect("OAuth response without a session lane should parse"); - - let usage = - ClaudeOAuthFetcher::new().build_usage_snapshot(&response, &test_credentials("token")); + &test_credentials("token"), + ); assert!(usage.primary.is_informational); assert_eq!(usage.primary.window_minutes, Some(300)); @@ -115,7 +104,11 @@ fn missing_oauth_session_is_informational_and_keeps_weekly_lane() { #[test] fn parses_current_snake_case_oauth_usage_response() { - let response: OAuthUsageResponse = serde_json::from_str( + let credentials = ClaudeOAuthCredentials { + rate_limit_tier: Some("default_claude_ai".to_string()), + ..test_credentials("token") + }; + let usage = snapshot( r#"{ "five_hour": {"utilization": 1.0, "resets_at": "2026-05-22T22:10:00Z"}, "seven_day": {"utilization": 0.14, "resets_at": "2026-05-29T10:00:00Z"}, @@ -130,17 +123,8 @@ fn parses_current_snake_case_oauth_usage_response() { }], "extra_usage": {"is_enabled": true, "used_credits": 0, "monthly_limit": 1000, "currency": "USD"} }"#, - ) - .expect("snake_case OAuth response should parse"); - - let credentials = ClaudeOAuthCredentials { - access_token: "token".to_string(), - refresh_token: None, - expires_at: None, - scopes: vec!["user:profile".to_string()], - rate_limit_tier: Some("default_claude_ai".to_string()), - }; - let usage = ClaudeOAuthFetcher::new().build_usage_snapshot(&response, &credentials); + &credentials, + ); assert_eq!(usage.primary.used_percent, 1.0); assert!((usage.secondary.expect("weekly").used_percent - 0.14).abs() < 0.001); @@ -155,7 +139,8 @@ fn parses_current_snake_case_oauth_usage_response() { #[test] fn weekly_all_limit_wins_over_stale_seven_day_utilization() { - let response: OAuthUsageResponse = serde_json::from_str( + let credentials = creds(Some("default_claude_max_5x")); + let usage = snapshot( r#"{ "five_hour": {"utilization": 8.0, "resets_at": "2026-07-20T04:29:59Z"}, "seven_day": {"utilization": 1.0, "resets_at": "2026-07-26T22:59:59Z"}, @@ -175,17 +160,8 @@ fn weekly_all_limit_wins_over_stale_seven_day_utilization() { } ] }"#, - ) - .expect("oauth body with weekly_all"); - - let credentials = ClaudeOAuthCredentials { - access_token: "token".to_string(), - refresh_token: None, - expires_at: None, - scopes: vec![], - rate_limit_tier: Some("default_claude_max_5x".to_string()), - }; - let usage = ClaudeOAuthFetcher::new().build_usage_snapshot(&response, &credentials); + &credentials, + ); assert!((usage.primary.used_percent - 8.0).abs() < f64::EPSILON); // seven_day.utilization 1.0 would normalize to 100%; weekly_all wins. @@ -204,7 +180,8 @@ fn weekly_all_limit_wins_over_stale_seven_day_utilization() { fn issue_210_reporter_shape_secondary_is_one_percent_not_one_hundred() { // Mirrors the reporter JSON: session 8%, fable 2%, all-models should be 1% // while seven_day.utilization is the stale 1.0 (would display as 100%). - let response: OAuthUsageResponse = serde_json::from_str( + let credentials = creds(Some("default_claude_max_5x")); + let usage = snapshot( r#"{ "five_hour": { "utilization": 8.0, @@ -235,17 +212,8 @@ fn issue_210_reporter_shape_secondary_is_one_percent_not_one_hundred() { } ] }"#, - ) - .expect("issue 210 body"); - - let credentials = ClaudeOAuthCredentials { - access_token: "token".to_string(), - refresh_token: None, - expires_at: None, - scopes: vec![], - rate_limit_tier: Some("default_claude_max_5x".to_string()), - }; - let usage = ClaudeOAuthFetcher::new().build_usage_snapshot(&response, &credentials); + &credentials, + ); assert_eq!(usage.login_method.as_deref(), Some("Claude Max 5x")); assert!((usage.primary.used_percent - 8.0).abs() < f64::EPSILON); @@ -270,7 +238,8 @@ fn issue_279_session_limits_win_over_stale_five_hour_after_rollover() { // can transiently report 1.0 (normalizes to 100%) even though // claude.ai shows only 5% for the fresh window. The limits[] entry // (kind=="session") carries the true value and must win. - let response: OAuthUsageResponse = serde_json::from_str( + let credentials = creds(Some("default_claude_max_5x")); + let usage = snapshot( r#"{ "five_hour": {"utilization": 1.0, "resets_at": "2026-08-13T12:49:59.578826Z"}, "seven_day": {"utilization": 0.01, "resets_at": "2026-07-26T22:59:59Z"}, @@ -289,17 +258,8 @@ fn issue_279_session_limits_win_over_stale_five_hour_after_rollover() { } ] }"#, - ) - .expect("issue 279 body"); - - let credentials = ClaudeOAuthCredentials { - access_token: "token".to_string(), - refresh_token: None, - expires_at: None, - scopes: vec![], - rate_limit_tier: Some("default_claude_max_5x".to_string()), - }; - let usage = ClaudeOAuthFetcher::new().build_usage_snapshot(&response, &credentials); + &credentials, + ); // Primary session must be 5%, not the stale 100%. assert!( @@ -320,40 +280,29 @@ fn issue_279_session_limits_win_over_stale_five_hour_after_rollover() { fn session_falls_back_to_legacy_five_hour_without_limits_entry() { // When no limits[] session entry exists, the legacy five_hour field // is still the source of truth (backwards compatible). - let response: OAuthUsageResponse = serde_json::from_str( + let credentials = creds(None); + let usage = snapshot( r#"{ "five_hour": {"utilization": 10.0, "resets_at": "2026-08-13T12:49:59Z"} }"#, - ) - .expect("legacy-only body"); - - let credentials = ClaudeOAuthCredentials { - access_token: "token".to_string(), - refresh_token: None, - expires_at: None, - scopes: vec![], - rate_limit_tier: None, - }; - let usage = ClaudeOAuthFetcher::new().build_usage_snapshot(&response, &credentials); + &credentials, + ); assert!((usage.primary.used_percent - 10.0).abs() < f64::EPSILON); assert_eq!(usage.primary.window_minutes, Some(300)); } #[test] -fn parses_retry_after_seconds() { - let header = HeaderValue::from_static("17"); - let duration = ClaudeOAuthFetcher::retry_after_duration(Some(&header)); - - assert_eq!(duration, Duration::from_secs(17)); -} - -#[test] -fn invalid_retry_after_uses_default_backoff() { - let header = HeaderValue::from_static("not-a-date"); - let duration = ClaudeOAuthFetcher::retry_after_duration(Some(&header)); - - assert_eq!(duration, ClaudeOAuthFetcher::DEFAULT_RATE_LIMIT_BACKOFF); +fn retry_after_parses_seconds_and_falls_back_to_default_backoff() { + for (value, expected) in [ + ("17", Duration::from_secs(17)), + ("not-a-date", ClaudeOAuthFetcher::DEFAULT_RATE_LIMIT_BACKOFF), + ] { + let header = HeaderValue::from_static(value); + let duration = ClaudeOAuthFetcher::retry_after_duration(Some(&header)); + + assert_eq!(duration, expected, "{value}"); + } } #[test] @@ -384,66 +333,39 @@ fn only_the_rate_limit_refusal_counts_as_rate_limited() { #[test] fn oauth_extras_put_scoped_weekly_before_routines() { - let response: OAuthUsageResponse = serde_json::from_str( + // With and without a scoped reset time. + for json in [ r#"{ - "five_hour": {"utilization": 10.0}, - "seven_day_routines": {"utilization": 5.0}, - "limits": [{ - "kind": "weekly_scoped", - "group": "weekly", - "percent": 7, - "resets_at": "2026-05-29T10:00:00Z", - "scope": {"model": {"display_name": "Fable"}} - }] - }"#, - ) - .expect("oauth body"); - - let credentials = ClaudeOAuthCredentials { - access_token: "token".to_string(), - refresh_token: None, - expires_at: None, - scopes: vec![], - rate_limit_tier: None, - }; - let usage = ClaudeOAuthFetcher::new().build_usage_snapshot(&response, &credentials); - - let ids: Vec<&str> = usage - .extra_rate_windows - .iter() - .map(|w| w.id.as_str()) - .collect(); - assert_eq!(ids, vec!["claude-weekly-scoped-fable", "claude-routines"]); -} - -#[test] -fn oauth_extras_keep_routines_in_raw_snapshot() { - let response: OAuthUsageResponse = serde_json::from_str( + "five_hour": {"utilization": 10.0}, + "seven_day_routines": {"utilization": 5.0}, + "limits": [{ + "kind": "weekly_scoped", + "group": "weekly", + "percent": 7, + "resets_at": "2026-05-29T10:00:00Z", + "scope": {"model": {"display_name": "Fable"}} + }] + }"#, r#"{ - "five_hour": {"utilization": 10.0}, - "seven_day_routines": {"utilization": 5.0}, - "limits": [{ - "kind": "weekly_scoped", - "group": "weekly", - "percent": 7, - "scope": {"model": {"display_name": "Fable"}} - }] - }"#, - ) - .expect("oauth body"); - - let credentials = ClaudeOAuthCredentials { - access_token: "token".to_string(), - refresh_token: None, - expires_at: None, - scopes: vec![], - rate_limit_tier: None, - }; - let usage = ClaudeOAuthFetcher::new().build_usage_snapshot(&response, &credentials); + "five_hour": {"utilization": 10.0}, + "seven_day_routines": {"utilization": 5.0}, + "limits": [{ + "kind": "weekly_scoped", + "group": "weekly", + "percent": 7, + "scope": {"model": {"display_name": "Fable"}} + }] + }"#, + ] { + let usage = snapshot(json, &creds(None)); - assert_eq!(usage.extra_rate_windows.len(), 2); - assert_eq!(usage.extra_rate_windows[0].id, "claude-weekly-scoped-fable"); - assert_eq!(usage.extra_rate_windows[1].id, "claude-routines"); + let ids: Vec<&str> = usage + .extra_rate_windows + .iter() + .map(|w| w.id.as_str()) + .collect(); + assert_eq!(ids, vec!["claude-weekly-scoped-fable", "claude-routines"]); + } } // ── Refresh-token backoff (upstream 0.48.0 #2650 mapping) ─── From 70333acbf904fcbccf17a8904d4d998b3d5ac6ec Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:22:21 +0700 Subject: [PATCH 37/44] Share Claude test builders for quota history, claude-swap and junctions --- rust/src/providers/claude/accounts/login.rs | 34 ++--- .../claude/claude_swap/projection.rs | 64 +++++---- rust/src/providers/claude/quota_history.rs | 128 ++++-------------- 3 files changed, 79 insertions(+), 147 deletions(-) diff --git a/rust/src/providers/claude/accounts/login.rs b/rust/src/providers/claude/accounts/login.rs index a3f64d00f46..9a7625cc45b 100644 --- a/rust/src/providers/claude/accounts/login.rs +++ b/rust/src/providers/claude/accounts/login.rs @@ -416,10 +416,19 @@ mod tests { assert!(!removable.exists()); } + #[cfg(windows)] + fn make_junction(link: &Path, target: &Path, failure: &str) { + use std::os::windows::process::CommandExt; + let output = Command::new("powershell.exe") + .args(["-NoProfile", "-NonInteractive", "-Command", "New-Item -ItemType Junction -Path $env:CODEXBAR_TEST_LINK -Target $env:CODEXBAR_TEST_TARGET | Out-Null"]) + .env("CODEXBAR_TEST_LINK", link).env("CODEXBAR_TEST_TARGET", target) + .creation_flags(0x0800_0000).output().unwrap(); + assert!(output.status.success(), "{failure}"); + } + #[cfg(windows)] #[test] fn startup_cleanup_does_not_follow_junctions() { - use std::os::windows::process::CommandExt; let dir = tempfile::tempdir().unwrap(); let root = dir.path().join("logins"); let outside = dir.path().join("outside"); @@ -427,13 +436,10 @@ mod tests { std::fs::create_dir_all(&outside).unwrap(); std::fs::write(outside.join("keep.txt"), "preserved").unwrap(); let link = root.join(uuid::Uuid::new_v4().to_string()); - let output = Command::new("powershell.exe") - .args(["-NoProfile", "-NonInteractive", "-Command", "New-Item -ItemType Junction -Path $env:CODEXBAR_TEST_LINK -Target $env:CODEXBAR_TEST_TARGET | Out-Null"]) - .env("CODEXBAR_TEST_LINK", &link).env("CODEXBAR_TEST_TARGET", &outside) - .creation_flags(0x0800_0000).output().unwrap(); - assert!( - output.status.success(), - "Failed to create the isolated test junction." + make_junction( + &link, + &outside, + "Failed to create the isolated test junction.", ); cleanup_login_root(&root).unwrap(); assert!(outside.join("keep.txt").exists()); @@ -730,18 +736,14 @@ mod tests { #[cfg(windows)] #[test] fn ordinary_junction_is_not_treated_as_wsl_backed() { - use std::os::windows::process::CommandExt; let dir = tempfile::tempdir().unwrap(); let target = dir.path().join("target"); let link = dir.path().join("link"); std::fs::create_dir_all(&target).unwrap(); - let output = Command::new("powershell.exe") - .args(["-NoProfile", "-NonInteractive", "-Command", "New-Item -ItemType Junction -Path $env:CODEXBAR_TEST_LINK -Target $env:CODEXBAR_TEST_TARGET | Out-Null"]) - .env("CODEXBAR_TEST_LINK", &link).env("CODEXBAR_TEST_TARGET", &target) - .creation_flags(0x0800_0000).output().unwrap(); - assert!( - output.status.success(), - "Failed to create the local junction fixture." + make_junction( + &link, + &target, + "Failed to create the local junction fixture.", ); assert!(!path_is_wsl_backed(&link)); assert!(!path_is_wsl_backed(&target)); diff --git a/rust/src/providers/claude/claude_swap/projection.rs b/rust/src/providers/claude/claude_swap/projection.rs index f9382451ff7..eaf92779ee5 100644 --- a/rust/src/providers/claude/claude_swap/projection.rs +++ b/rust/src/providers/claude/claude_swap/projection.rs @@ -346,6 +346,17 @@ mod tests { parse_account_list(&raw.to_string()).unwrap() } + /// Parse and project a list holding only `account`. + fn project_one(account: serde_json::Value, active: Option) -> ClaudeSwapAccount { + let raw = json!({ + "schemaVersion": 1, + "activeAccountNumber": active, + "accounts": [account] + }); + let parsed = parse_account_list(&raw.to_string()).unwrap(); + project_accounts(&parsed, false).remove(0) + } + #[test] fn same_email_accounts_get_distinct_stable_ids_and_labels() { let projected = project_accounts(&list_fixture(), false); @@ -415,20 +426,16 @@ mod tests { #[test] fn unknown_status_is_neither_echoed_nor_actionable() { - let raw = json!({ - "schemaVersion": 1, - "activeAccountNumber": null, - "accounts": [{ + let account = project_one( + json!({ "number": 1, "email": "x@example.com", "active": false, "usageStatus": "super_secret_token\u{1b}]0;leak\u{07}", "usage": { "fiveHour": { "pct": 1.0 } } - }] - }); - let parsed = parse_account_list(&raw.to_string()).unwrap(); - let projected = project_accounts(&parsed, false); - let account = &projected[0]; + }), + None, + ); assert_eq!(account.status, "unknown"); assert!(account.action.is_none()); let error = account.error.as_deref().unwrap(); @@ -438,18 +445,15 @@ mod tests { #[test] fn foreign_credentials_expose_explicit_reauthentication_action() { - let raw = json!({ - "schemaVersion": 1, - "activeAccountNumber": 1, - "accounts": [{ + let account = project_one( + json!({ "number": 1, "email": "x@example.com", "active": true, "usageStatus": "foreign_credential" - }] - }); - let parsed = parse_account_list(&raw.to_string()).unwrap(); - let account = &project_accounts(&parsed, false)[0]; + }), + Some(1), + ); assert_eq!( account.action, Some(ClaudeSwapAccountAction::Reauthenticate) @@ -465,10 +469,8 @@ mod tests { #[test] fn historical_usage_is_typed_and_marked_as_source_reported() { - let raw = json!({ - "schemaVersion": 1, - "activeAccountNumber": null, - "accounts": [{ + let account = project_one( + json!({ "number": 1, "email": "x@example.com", "active": false, @@ -479,10 +481,9 @@ mod tests { "spend": { "used": 2.0, "limit": 20.0, "pct": 10.0, "currency": "USD" } }, "lastGoodFetchedAt": "2026-09-12T00:45:00Z" - }] - }); - let parsed = parse_account_list(&raw.to_string()).unwrap(); - let account = &project_accounts(&parsed, false)[0]; + }), + None, + ); assert!(account.is_disabled); assert_eq!( account.historical_usage.as_ref().unwrap().provenance, @@ -503,10 +504,8 @@ mod tests { #[test] fn spend_only_ok_usage_is_not_reported_as_empty() { - let raw = json!({ - "schemaVersion": 1, - "activeAccountNumber": null, - "accounts": [{ + let account = project_one( + json!({ "number": 1, "email": "spend@example.com", "active": false, @@ -514,10 +513,9 @@ mod tests { "usage": { "spend": { "used": 2.0, "limit": 20.0, "pct": 10.0 } } - }] - }); - let parsed = parse_account_list(&raw.to_string()).unwrap(); - let account = &project_accounts(&parsed, false)[0]; + }), + None, + ); assert!(account.spend.is_some()); assert!(account.error.is_none()); } diff --git a/rust/src/providers/claude/quota_history.rs b/rust/src/providers/claude/quota_history.rs index de49a50c1d4..962d677c498 100644 --- a/rust/src/providers/claude/quota_history.rs +++ b/rust/src/providers/claude/quota_history.rs @@ -428,25 +428,36 @@ mod tests { value.parse().expect("RFC3339 timestamp") } + /// A row whose tokens and cost are complete exactly when present. fn record( at: &str, key: Option, tokens: Option, cost: Option, - tokens_are_complete: bool, - cost_is_complete: bool, ) -> ClaudeQuotaHistoryRecord { ClaudeQuotaHistoryRecord { timestamp: ts(at), tokens, cost_usd: cost, - tokens_are_complete, - cost_is_complete, + tokens_are_complete: tokens.is_some(), + cost_is_complete: cost.is_some(), dedup_key: key, attribution: ClaudeHistoryAttribution::Account("account-a".into()), } } + /// One closed weekly window ending at the 2026-09-22 reset. + fn options() -> ClaudeQuotaHistoryOptions<'static> { + ClaudeQuotaHistoryOptions { + live_reset_at: Some(ts("2026-09-22T12:00:00Z")), + window_minutes: None, + observations: &[], + now: ts("2026-09-21T12:00:00Z"), + max_windows: 1, + history_coverage_established: true, + } + } + #[test] fn reset_observations_are_account_scoped_and_rollovers_are_exact() { let now = ts("2026-09-21T12:00:00Z"); @@ -470,21 +481,14 @@ mod tests { ]; let report = aggregate_claude_quota_windows( "account-a", - &[record( - "2026-09-20T10:00:00Z", - None, - Some(1), - Some(0.1), - true, - true, - )], + &[record("2026-09-20T10:00:00Z", None, Some(1), Some(0.1))], ClaudeQuotaHistoryOptions { live_reset_at: Some(live_reset), window_minutes: Some(10_080), observations: &observations, now, max_windows: 3, - history_coverage_established: true, + ..options() }, ); assert_eq!(report.windows.len(), 3); @@ -499,22 +503,8 @@ mod tests { request_id: "req-1".into(), }; let records = deduplicate_claude_records(vec![ - record( - "2026-09-20T10:00:00Z", - Some(key.clone()), - Some(10), - None, - true, - false, - ), - record( - "2026-09-20T10:00:00Z", - Some(key), - Some(20), - Some(0.4), - true, - true, - ), + record("2026-09-20T10:00:00Z", Some(key.clone()), Some(10), None), + record("2026-09-20T10:00:00Z", Some(key), Some(20), Some(0.4)), record( "2026-09-20T10:01:00Z", Some(ClaudeQuotaDedupKey::Session { @@ -523,8 +513,6 @@ mod tests { }), Some(3), Some(0.1), - true, - true, ), ]); assert_eq!(records.len(), 2); @@ -537,17 +525,10 @@ mod tests { let report = aggregate_claude_quota_windows( "account-a", &[ - record("2026-09-20T10:00:00Z", None, Some(10), None, true, false), - record("2026-09-20T11:00:00Z", None, None, Some(0.5), false, true), + record("2026-09-20T10:00:00Z", None, Some(10), None), + record("2026-09-20T11:00:00Z", None, None, Some(0.5)), ], - ClaudeQuotaHistoryOptions { - live_reset_at: Some(ts("2026-09-22T12:00:00Z")), - window_minutes: None, - observations: &[], - now: ts("2026-09-21T12:00:00Z"), - max_windows: 1, - history_coverage_established: true, - }, + options(), ); let window = &report.windows[0]; assert_eq!(window.total_tokens, Some(10)); @@ -561,24 +542,10 @@ mod tests { let report = aggregate_claude_quota_windows( "account-a", &[ - record( - "2026-09-20T10:00:00Z", - None, - Some(u64::MAX), - None, - true, - false, - ), - record("2026-09-20T11:00:00Z", None, Some(1), None, true, false), + record("2026-09-20T10:00:00Z", None, Some(u64::MAX), None), + record("2026-09-20T11:00:00Z", None, Some(1), None), ], - ClaudeQuotaHistoryOptions { - live_reset_at: Some(ts("2026-09-22T12:00:00Z")), - window_minutes: None, - observations: &[], - now: ts("2026-09-21T12:00:00Z"), - max_windows: 1, - history_coverage_established: true, - }, + options(), ); let window = &report.windows[0]; @@ -595,10 +562,7 @@ mod tests { ClaudeQuotaHistoryOptions { live_reset_at: Some(before), window_minutes: Some(10_080), - observations: &[], - now: ts("2026-09-21T12:00:00Z"), - max_windows: 1, - history_coverage_established: true, + ..options() }, ); assert_eq!(before, ts("2026-09-22T12:00:00Z")); @@ -607,14 +571,7 @@ mod tests { #[test] fn account_scoped_history_excludes_unavailable_and_mismatched_rows() { - let mut unavailable = record( - "2026-09-20T10:00:00Z", - None, - Some(100), - Some(1.0), - true, - true, - ); + let mut unavailable = record("2026-09-20T10:00:00Z", None, Some(100), Some(1.0)); unavailable.attribution = ClaudeHistoryAttribution::Unavailable; let mut other_account = unavailable.clone(); other_account.attribution = ClaudeHistoryAttribution::Account("account-b".into()); @@ -623,14 +580,7 @@ mod tests { let report = aggregate_claude_quota_windows( "account-a", &[unavailable, other_account, matching], - ClaudeQuotaHistoryOptions { - live_reset_at: Some(ts("2026-09-22T12:00:00Z")), - window_minutes: None, - observations: &[], - now: ts("2026-09-21T12:00:00Z"), - max_windows: 1, - history_coverage_established: true, - }, + options(), ); let window = &report.windows[0]; assert_eq!(window.entry_count, 1); @@ -640,27 +590,9 @@ mod tests { #[test] fn account_scoped_history_is_absent_without_explicit_attribution() { - let mut record = record( - "2026-09-20T10:00:00Z", - None, - Some(100), - Some(1.0), - true, - true, - ); + let mut record = record("2026-09-20T10:00:00Z", None, Some(100), Some(1.0)); record.attribution = ClaudeHistoryAttribution::Unavailable; - let report = aggregate_claude_quota_windows( - "account-a", - &[record], - ClaudeQuotaHistoryOptions { - live_reset_at: Some(ts("2026-09-22T12:00:00Z")), - window_minutes: None, - observations: &[], - now: ts("2026-09-21T12:00:00Z"), - max_windows: 1, - history_coverage_established: true, - }, - ); + let report = aggregate_claude_quota_windows("account-a", &[record], options()); assert!(report.windows.is_empty()); } From 7942da8107ae088a0de7225123aff381653e7ceb Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:43:51 +0700 Subject: [PATCH 38/44] Move claude-swap parser tests into parser_tests.rs --- .../providers/claude/claude_swap/parser.rs | 420 +----------------- .../claude/claude_swap/parser_tests.rs | 416 +++++++++++++++++ 2 files changed, 418 insertions(+), 418 deletions(-) create mode 100644 rust/src/providers/claude/claude_swap/parser_tests.rs diff --git a/rust/src/providers/claude/claude_swap/parser.rs b/rust/src/providers/claude/claude_swap/parser.rs index 685cbc636a5..5a1b31378c8 100644 --- a/rust/src/providers/claude/claude_swap/parser.rs +++ b/rust/src/providers/claude/claude_swap/parser.rs @@ -402,421 +402,5 @@ pub fn validate_switch_target( } #[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - fn list_fixture() -> Value { - json!({ - "schemaVersion": 1, - "activeAccountNumber": 2, - "accounts": [ - { - "number": 1, - "email": "same@example.com", - "organizationName": "Work", - "active": false, - "usageStatus": "ok", - "usage": { - "fiveHour": { "pct": 120.0, "resetsAt": "2026-09-12T01:00:00Z" }, - "sevenDay": { "pct": 18.0 }, - "scoped": [{ "name": "Fable only", "pct": 4.0 }] - }, - "usageFetchedAt": "2026-09-12T00:30:00.000Z" - }, - { - "number": 2, - "email": "same@example.com", - "organizationName": "Personal", - "active": true, - "usageStatus": "ok", - "usage": { "fiveHour": { "pct": 81.0 }, "sevenDay": { "pct": 18.0 } } - }, - { - "number": 3, - "email": "expired@example.com", - "organizationName": "", - "alias": "Backup", - "active": false, - "usageStatus": "token_expired" - } - ] - }) - } - - #[test] - fn parses_schema_v1_and_normalizes_windows() { - let parsed = parse_account_list(&list_fixture().to_string()).unwrap(); - assert_eq!(parsed.active_account_number, Some(2)); - assert_eq!(parsed.accounts.len(), 3); - let first = &parsed.accounts[0]; - assert_eq!(first.number, 1); - assert_eq!(first.organization_name, "Work"); - // Out-of-range percentages are clamped like upstream. - assert_eq!(first.usage.five_hour.as_ref().unwrap().used_percent, 100.0); - assert!(first.usage.five_hour.as_ref().unwrap().resets_at.is_some()); - assert_eq!(first.usage.scoped[0].name, "Fable only"); - assert_eq!(first.usage_status, ClaudeSwapUsageStatus::Ok); - } - - #[test] - fn switching_capability_defaults_to_true_and_accepts_booleans() { - let parsed = parse_account_list(&list_fixture().to_string()).unwrap(); - assert!(parsed.supports_account_switching); - for supported in [true, false] { - let mut fixture = list_fixture(); - fixture["supportsAccountSwitching"] = json!(supported); - let parsed = parse_account_list(&fixture.to_string()).unwrap(); - assert_eq!(parsed.supports_account_switching, supported); - } - } - - #[test] - fn rejects_non_boolean_switching_capability() { - for value in [ - json!(null), - json!(0), - json!(1), - json!("false"), - json!([]), - json!({}), - ] { - let mut fixture = list_fixture(); - fixture["supportsAccountSwitching"] = value.clone(); - assert!( - matches!( - parse_account_list(&fixture.to_string()), - Err(ClaudeSwapError::MalformedShape(ref message)) - if message == "supportsAccountSwitching is not a boolean" - ), - "{value} must be rejected" - ); - } - } - - #[test] - fn rejects_unknown_and_missing_schema_versions() { - let mut unknown = list_fixture(); - unknown["schemaVersion"] = json!(2); - assert!(matches!( - parse_account_list(&unknown.to_string()), - Err(ClaudeSwapError::UnsupportedSchemaVersion(2)) - )); - - let mut missing = list_fixture(); - missing.as_object_mut().unwrap().remove("schemaVersion"); - assert!(matches!( - parse_account_list(&missing.to_string()), - Err(ClaudeSwapError::MissingSchemaVersion) - )); - - assert!(matches!( - parse_account_list("not json"), - Err(ClaudeSwapError::NotJsonObject) - )); - assert!(matches!( - parse_account_list("[]"), - Err(ClaudeSwapError::NotJsonObject) - )); - } - - #[test] - fn unknown_status_is_not_echoed_to_the_row() { - let mut fixture = list_fixture(); - fixture["accounts"][2]["usageStatus"] = json!("super_secret_token\u{1b}]0;leak\u{07}"); - let parsed = parse_account_list(&fixture.to_string()).unwrap(); - let row = parsed.accounts.iter().find(|a| a.number == 3).unwrap(); - assert_eq!(row.usage_status, ClaudeSwapUsageStatus::Unknown); - assert!(!row.usage_status.as_label().contains("super_secret_token")); - } - - #[test] - fn external_labels_strip_escapes_and_respect_length_bounds() { - let hostile = format!("\u{1b}[31mEvil\u{1b}[0m\n{}", "x".repeat(400)); - let mut fixture = list_fixture(); - fixture["accounts"][2]["alias"] = json!(hostile); - let parsed = parse_account_list(&fixture.to_string()).unwrap(); - let alias = parsed - .accounts - .iter() - .find(|a| a.number == 3) - .unwrap() - .alias - .as_deref() - .unwrap(); - assert!(!alias.contains('\u{1b}')); - assert!(alias.contains("Evil")); - assert!(alias.chars().count() <= MAX_LABEL_CHARS); - } - - #[test] - fn reported_error_envelope_is_sanitized_and_bounded() { - let raw = json!({ - "schemaVersion": 1, - "error": { - "type": "\u{1b}[31mBad\u{07}", - "message": format!("\u{1b}]0;leak\u{07}{}", "y".repeat(900)) - } - }); - match parse_account_list(&raw.to_string()) { - Err(ClaudeSwapError::ReportedError { kind, message }) => { - assert!(!kind.contains('\u{1b}')); - assert!(!message.contains('\u{1b}')); - assert!(message.chars().count() <= MAX_DIAGNOSTIC_CHARS); - } - other => panic!("expected reported error, got {other:?}"), - } - } - - #[test] - fn surfaces_error_envelope_instead_of_partial_accounts() { - let raw = json!({ - "schemaVersion": 1, - "error": { "type": "LockHeld", "message": "another cswap is running" } - }); - match parse_account_list(&raw.to_string()) { - Err(ClaudeSwapError::ReportedError { kind, message }) => { - assert_eq!(kind, "LockHeld"); - assert!(message.contains("another cswap")); - } - other => panic!("expected reported error, got {other:?}"), - } - } - - #[test] - fn rejects_disagreeing_active_fields_and_duplicate_slots() { - let mut disagree = list_fixture(); - disagree["activeAccountNumber"] = json!(1); - assert!(matches!( - parse_account_list(&disagree.to_string()), - Err(ClaudeSwapError::MalformedShape(_)) - )); - - let mut duplicate = list_fixture(); - { - let accounts = duplicate["accounts"].as_array_mut().unwrap(); - accounts[1]["number"] = json!(1); - accounts[0]["active"] = json!(false); - } - duplicate["activeAccountNumber"] = json!(null); - assert!(matches!( - parse_account_list(&duplicate.to_string()), - Err(ClaudeSwapError::MalformedShape(_)) - )); - } - - #[test] - fn ignores_malformed_scoped_rows_without_dropping_valid_windows() { - let mut fixture = list_fixture(); - fixture["accounts"][0]["usage"]["scoped"] = json!([ - { "name": "Fable only", "pct": 4.0 }, - { "name": "", "pct": 9.0 }, - { "pct": 3.0 }, - "nonsense", - { "name": "Broken reset", "pct": 2.0, "resetsAt": "not-a-date" } - ]); - let parsed = parse_account_list(&fixture.to_string()).unwrap(); - let scoped = &parsed.accounts[0].usage.scoped; - assert_eq!(scoped.len(), 1); - assert_eq!(scoped[0].name, "Fable only"); - assert!(parsed.accounts[0].usage.five_hour.is_some()); - } - - #[test] - fn parses_spend_disabled_and_last_good_usage_as_additive_data() { - let mut fixture = list_fixture(); - fixture["accounts"][0]["disabled"] = json!(true); - fixture["accounts"][0]["usage"]["spend"] = json!({ - "used": 12.5, - "limit": 50.0, - "pct": 25.0, - "currency": " USD ", - "resetsAt": "2026-09-13T00:00:00Z" - }); - fixture["accounts"][0]["lastGoodUsage"] = json!({ - "fiveHour": { "pct": 44.0 }, - "sevenDay": { "pct": 19.0 }, - "spend": { "used": 8.0, "limit": 40.0, "pct": 20.0, "currency": "EUR" } - }); - fixture["accounts"][0]["lastGoodFetchedAt"] = json!("2026-09-12T00:45:00Z"); - fixture["accounts"][2]["usageStatus"] = json!("foreign_credential"); - - let parsed = parse_account_list(&fixture.to_string()).unwrap(); - let first = &parsed.accounts[0]; - assert!(first.is_disabled); - assert_eq!( - first.usage.spend.as_ref().unwrap().currency_code.as_deref(), - Some("USD") - ); - assert_eq!(first.usage.spend.as_ref().unwrap().used, 12.5); - let history = first.historical_usage.as_ref().unwrap(); - assert_eq!( - history.measurement.five_hour.as_ref().unwrap().used_percent, - 44.0 - ); - assert_eq!( - history - .measurement - .spend - .as_ref() - .unwrap() - .currency_code - .as_deref(), - Some("EUR") - ); - assert_eq!(history.fetched_at.to_rfc3339(), "2026-09-12T00:45:00+00:00"); - assert_eq!( - parsed.accounts[2].usage_status, - ClaudeSwapUsageStatus::ForeignCredential - ); - } - - #[test] - fn drops_invalid_additive_history_and_spend_without_dropping_live_usage() { - let mut fixture = list_fixture(); - fixture["accounts"][0]["usage"]["spend"] = json!({ - "used": -1.0, - "limit": 50.0, - "pct": 25.0 - }); - fixture["accounts"][0]["lastGoodUsage"] = json!({ - "fiveHour": { "pct": "not-a-number" }, - "scoped": [{ "name": "valid scope", "pct": 3.0 }] - }); - fixture["accounts"][0]["lastGoodFetchedAt"] = json!("not-a-date"); - let parsed = parse_account_list(&fixture.to_string()).unwrap(); - let first = &parsed.accounts[0]; - assert!(first.usage.five_hour.is_some()); - assert!(first.usage.spend.is_none()); - assert!(first.historical_usage.is_none()); - - fixture["accounts"][0]["lastGoodFetchedAt"] = json!("2026-09-12T00:45:00Z"); - let parsed = parse_account_list(&fixture.to_string()).unwrap(); - let history = parsed.accounts[0].historical_usage.as_ref().unwrap(); - assert!(history.measurement.five_hour.is_none()); - assert_eq!(history.measurement.scoped.len(), 1); - } - - #[test] - fn missing_spend_currency_remains_unknown() { - let mut fixture = list_fixture(); - fixture["accounts"][0]["usage"]["spend"] = json!({ - "used": 2.0, - "limit": 20.0, - "pct": 10.0 - }); - let parsed = parse_account_list(&fixture.to_string()).unwrap(); - assert_eq!( - parsed.accounts[0] - .usage - .spend - .as_ref() - .unwrap() - .currency_code, - None - ); - } - - #[test] - fn list_and_switch_share_the_envelope_checks() { - const NOT_OBJECT: &str = "claude-swap returned output that is not a JSON object."; - const NO_SCHEMA: &str = "claude-swap output has no schemaVersion field."; - const SCHEMA_2: &str = - "claude-swap output uses unsupported schema version 2; CodexBar supports version 1."; - const UNKNOWN: &str = "claude-swap reported Error: unknown error"; - let rows: [(&str, &str, &str); 12] = [ - ("not json", NOT_OBJECT, NOT_OBJECT), - ("[]", NOT_OBJECT, NOT_OBJECT), - (r#""text""#, NOT_OBJECT, NOT_OBJECT), - ("{}", NO_SCHEMA, NO_SCHEMA), - (r#"{"schemaVersion": "1"}"#, NO_SCHEMA, NO_SCHEMA), - (r#"{"schemaVersion": 1.5}"#, NO_SCHEMA, NO_SCHEMA), - (r#"{"schemaVersion": 2}"#, SCHEMA_2, SCHEMA_2), - ( - r#"{"schemaVersion": 2, "error": {"type": "LockHeld"}}"#, - SCHEMA_2, - SCHEMA_2, - ), - (r#"{"schemaVersion": 1, "error": {}}"#, UNKNOWN, UNKNOWN), - ( - r#"{"schemaVersion": 1, "error": {"type": "", "message": ""}}"#, - UNKNOWN, - UNKNOWN, - ), - ( - r#"{"schemaVersion": 1, "error": {"type": "LockHeld", "message": "busy"}}"#, - "claude-swap reported LockHeld: busy", - "claude-swap reported LockHeld: busy", - ), - ( - r#"{"schemaVersion": 1, "error": "text"}"#, - "claude-swap output is malformed: missing accounts array", - "claude-swap output is malformed: missing switched flag", - ), - ]; - for (raw, list_expected, switch_expected) in rows { - let list = parse_account_list(raw).unwrap_err().to_string(); - let switch = parse_switch_result(raw).unwrap_err().to_string(); - assert_eq!(list, list_expected, "{raw}"); - assert_eq!(switch, switch_expected, "{raw}"); - } - } - - #[test] - fn switch_result_requires_matching_target_slot() { - let raw = json!({ - "schemaVersion": 1, - "switched": true, - "from": { "number": 2 }, - "to": { "number": 3 }, - "reason": "switched" - }); - let parsed = parse_switch_result(&raw.to_string()).unwrap(); - assert!(parsed.switched); - assert_eq!(parsed.from_account_number, Some(2)); - assert_eq!(parsed.to_account_number, 3); - assert!(validate_switch_target(3, &parsed).is_ok()); - - let wrong = json!({ - "schemaVersion": 1, - "switched": true, - "from": { "number": 1 }, - "to": { "number": 2 }, - "reason": "switched" - }); - let wrong = parse_switch_result(&wrong.to_string()).unwrap(); - assert!(matches!( - validate_switch_target(3, &wrong), - Err(ClaudeSwapError::MismatchedTarget { - expected: 3, - actual: 2 - }) - )); - } - - #[test] - fn switch_result_rejects_missing_reason_and_bad_schema() { - let missing_reason = json!({ - "schemaVersion": 1, - "switched": true, - "from": { "number": 1 }, - "to": { "number": 2 } - }); - assert!(matches!( - parse_switch_result(&missing_reason.to_string()), - Err(ClaudeSwapError::MalformedShape(_)) - )); - - let bad_schema = json!({ - "schemaVersion": 9, - "switched": true, - "from": { "number": 1 }, - "to": { "number": 2 }, - "reason": "switched" - }); - assert!(matches!( - parse_switch_result(&bad_schema.to_string()), - Err(ClaudeSwapError::UnsupportedSchemaVersion(9)) - )); - } -} +#[path = "parser_tests.rs"] +mod tests; diff --git a/rust/src/providers/claude/claude_swap/parser_tests.rs b/rust/src/providers/claude/claude_swap/parser_tests.rs new file mode 100644 index 00000000000..f031bb26517 --- /dev/null +++ b/rust/src/providers/claude/claude_swap/parser_tests.rs @@ -0,0 +1,416 @@ +use super::*; +use serde_json::json; + +fn list_fixture() -> Value { + json!({ + "schemaVersion": 1, + "activeAccountNumber": 2, + "accounts": [ + { + "number": 1, + "email": "same@example.com", + "organizationName": "Work", + "active": false, + "usageStatus": "ok", + "usage": { + "fiveHour": { "pct": 120.0, "resetsAt": "2026-09-12T01:00:00Z" }, + "sevenDay": { "pct": 18.0 }, + "scoped": [{ "name": "Fable only", "pct": 4.0 }] + }, + "usageFetchedAt": "2026-09-12T00:30:00.000Z" + }, + { + "number": 2, + "email": "same@example.com", + "organizationName": "Personal", + "active": true, + "usageStatus": "ok", + "usage": { "fiveHour": { "pct": 81.0 }, "sevenDay": { "pct": 18.0 } } + }, + { + "number": 3, + "email": "expired@example.com", + "organizationName": "", + "alias": "Backup", + "active": false, + "usageStatus": "token_expired" + } + ] + }) +} + +#[test] +fn parses_schema_v1_and_normalizes_windows() { + let parsed = parse_account_list(&list_fixture().to_string()).unwrap(); + assert_eq!(parsed.active_account_number, Some(2)); + assert_eq!(parsed.accounts.len(), 3); + let first = &parsed.accounts[0]; + assert_eq!(first.number, 1); + assert_eq!(first.organization_name, "Work"); + // Out-of-range percentages are clamped like upstream. + assert_eq!(first.usage.five_hour.as_ref().unwrap().used_percent, 100.0); + assert!(first.usage.five_hour.as_ref().unwrap().resets_at.is_some()); + assert_eq!(first.usage.scoped[0].name, "Fable only"); + assert_eq!(first.usage_status, ClaudeSwapUsageStatus::Ok); +} + +#[test] +fn switching_capability_defaults_to_true_and_accepts_booleans() { + let parsed = parse_account_list(&list_fixture().to_string()).unwrap(); + assert!(parsed.supports_account_switching); + for supported in [true, false] { + let mut fixture = list_fixture(); + fixture["supportsAccountSwitching"] = json!(supported); + let parsed = parse_account_list(&fixture.to_string()).unwrap(); + assert_eq!(parsed.supports_account_switching, supported); + } +} + +#[test] +fn rejects_non_boolean_switching_capability() { + for value in [ + json!(null), + json!(0), + json!(1), + json!("false"), + json!([]), + json!({}), + ] { + let mut fixture = list_fixture(); + fixture["supportsAccountSwitching"] = value.clone(); + assert!( + matches!( + parse_account_list(&fixture.to_string()), + Err(ClaudeSwapError::MalformedShape(ref message)) + if message == "supportsAccountSwitching is not a boolean" + ), + "{value} must be rejected" + ); + } +} + +#[test] +fn rejects_unknown_and_missing_schema_versions() { + let mut unknown = list_fixture(); + unknown["schemaVersion"] = json!(2); + assert!(matches!( + parse_account_list(&unknown.to_string()), + Err(ClaudeSwapError::UnsupportedSchemaVersion(2)) + )); + + let mut missing = list_fixture(); + missing.as_object_mut().unwrap().remove("schemaVersion"); + assert!(matches!( + parse_account_list(&missing.to_string()), + Err(ClaudeSwapError::MissingSchemaVersion) + )); + + assert!(matches!( + parse_account_list("not json"), + Err(ClaudeSwapError::NotJsonObject) + )); + assert!(matches!( + parse_account_list("[]"), + Err(ClaudeSwapError::NotJsonObject) + )); +} + +#[test] +fn unknown_status_is_not_echoed_to_the_row() { + let mut fixture = list_fixture(); + fixture["accounts"][2]["usageStatus"] = json!("super_secret_token\u{1b}]0;leak\u{07}"); + let parsed = parse_account_list(&fixture.to_string()).unwrap(); + let row = parsed.accounts.iter().find(|a| a.number == 3).unwrap(); + assert_eq!(row.usage_status, ClaudeSwapUsageStatus::Unknown); + assert!(!row.usage_status.as_label().contains("super_secret_token")); +} + +#[test] +fn external_labels_strip_escapes_and_respect_length_bounds() { + let hostile = format!("\u{1b}[31mEvil\u{1b}[0m\n{}", "x".repeat(400)); + let mut fixture = list_fixture(); + fixture["accounts"][2]["alias"] = json!(hostile); + let parsed = parse_account_list(&fixture.to_string()).unwrap(); + let alias = parsed + .accounts + .iter() + .find(|a| a.number == 3) + .unwrap() + .alias + .as_deref() + .unwrap(); + assert!(!alias.contains('\u{1b}')); + assert!(alias.contains("Evil")); + assert!(alias.chars().count() <= MAX_LABEL_CHARS); +} + +#[test] +fn reported_error_envelope_is_sanitized_and_bounded() { + let raw = json!({ + "schemaVersion": 1, + "error": { + "type": "\u{1b}[31mBad\u{07}", + "message": format!("\u{1b}]0;leak\u{07}{}", "y".repeat(900)) + } + }); + match parse_account_list(&raw.to_string()) { + Err(ClaudeSwapError::ReportedError { kind, message }) => { + assert!(!kind.contains('\u{1b}')); + assert!(!message.contains('\u{1b}')); + assert!(message.chars().count() <= MAX_DIAGNOSTIC_CHARS); + } + other => panic!("expected reported error, got {other:?}"), + } +} + +#[test] +fn surfaces_error_envelope_instead_of_partial_accounts() { + let raw = json!({ + "schemaVersion": 1, + "error": { "type": "LockHeld", "message": "another cswap is running" } + }); + match parse_account_list(&raw.to_string()) { + Err(ClaudeSwapError::ReportedError { kind, message }) => { + assert_eq!(kind, "LockHeld"); + assert!(message.contains("another cswap")); + } + other => panic!("expected reported error, got {other:?}"), + } +} + +#[test] +fn rejects_disagreeing_active_fields_and_duplicate_slots() { + let mut disagree = list_fixture(); + disagree["activeAccountNumber"] = json!(1); + assert!(matches!( + parse_account_list(&disagree.to_string()), + Err(ClaudeSwapError::MalformedShape(_)) + )); + + let mut duplicate = list_fixture(); + { + let accounts = duplicate["accounts"].as_array_mut().unwrap(); + accounts[1]["number"] = json!(1); + accounts[0]["active"] = json!(false); + } + duplicate["activeAccountNumber"] = json!(null); + assert!(matches!( + parse_account_list(&duplicate.to_string()), + Err(ClaudeSwapError::MalformedShape(_)) + )); +} + +#[test] +fn ignores_malformed_scoped_rows_without_dropping_valid_windows() { + let mut fixture = list_fixture(); + fixture["accounts"][0]["usage"]["scoped"] = json!([ + { "name": "Fable only", "pct": 4.0 }, + { "name": "", "pct": 9.0 }, + { "pct": 3.0 }, + "nonsense", + { "name": "Broken reset", "pct": 2.0, "resetsAt": "not-a-date" } + ]); + let parsed = parse_account_list(&fixture.to_string()).unwrap(); + let scoped = &parsed.accounts[0].usage.scoped; + assert_eq!(scoped.len(), 1); + assert_eq!(scoped[0].name, "Fable only"); + assert!(parsed.accounts[0].usage.five_hour.is_some()); +} + +#[test] +fn parses_spend_disabled_and_last_good_usage_as_additive_data() { + let mut fixture = list_fixture(); + fixture["accounts"][0]["disabled"] = json!(true); + fixture["accounts"][0]["usage"]["spend"] = json!({ + "used": 12.5, + "limit": 50.0, + "pct": 25.0, + "currency": " USD ", + "resetsAt": "2026-09-13T00:00:00Z" + }); + fixture["accounts"][0]["lastGoodUsage"] = json!({ + "fiveHour": { "pct": 44.0 }, + "sevenDay": { "pct": 19.0 }, + "spend": { "used": 8.0, "limit": 40.0, "pct": 20.0, "currency": "EUR" } + }); + fixture["accounts"][0]["lastGoodFetchedAt"] = json!("2026-09-12T00:45:00Z"); + fixture["accounts"][2]["usageStatus"] = json!("foreign_credential"); + + let parsed = parse_account_list(&fixture.to_string()).unwrap(); + let first = &parsed.accounts[0]; + assert!(first.is_disabled); + assert_eq!( + first.usage.spend.as_ref().unwrap().currency_code.as_deref(), + Some("USD") + ); + assert_eq!(first.usage.spend.as_ref().unwrap().used, 12.5); + let history = first.historical_usage.as_ref().unwrap(); + assert_eq!( + history.measurement.five_hour.as_ref().unwrap().used_percent, + 44.0 + ); + assert_eq!( + history + .measurement + .spend + .as_ref() + .unwrap() + .currency_code + .as_deref(), + Some("EUR") + ); + assert_eq!(history.fetched_at.to_rfc3339(), "2026-09-12T00:45:00+00:00"); + assert_eq!( + parsed.accounts[2].usage_status, + ClaudeSwapUsageStatus::ForeignCredential + ); +} + +#[test] +fn drops_invalid_additive_history_and_spend_without_dropping_live_usage() { + let mut fixture = list_fixture(); + fixture["accounts"][0]["usage"]["spend"] = json!({ + "used": -1.0, + "limit": 50.0, + "pct": 25.0 + }); + fixture["accounts"][0]["lastGoodUsage"] = json!({ + "fiveHour": { "pct": "not-a-number" }, + "scoped": [{ "name": "valid scope", "pct": 3.0 }] + }); + fixture["accounts"][0]["lastGoodFetchedAt"] = json!("not-a-date"); + let parsed = parse_account_list(&fixture.to_string()).unwrap(); + let first = &parsed.accounts[0]; + assert!(first.usage.five_hour.is_some()); + assert!(first.usage.spend.is_none()); + assert!(first.historical_usage.is_none()); + + fixture["accounts"][0]["lastGoodFetchedAt"] = json!("2026-09-12T00:45:00Z"); + let parsed = parse_account_list(&fixture.to_string()).unwrap(); + let history = parsed.accounts[0].historical_usage.as_ref().unwrap(); + assert!(history.measurement.five_hour.is_none()); + assert_eq!(history.measurement.scoped.len(), 1); +} + +#[test] +fn missing_spend_currency_remains_unknown() { + let mut fixture = list_fixture(); + fixture["accounts"][0]["usage"]["spend"] = json!({ + "used": 2.0, + "limit": 20.0, + "pct": 10.0 + }); + let parsed = parse_account_list(&fixture.to_string()).unwrap(); + assert_eq!( + parsed.accounts[0] + .usage + .spend + .as_ref() + .unwrap() + .currency_code, + None + ); +} + +#[test] +fn list_and_switch_share_the_envelope_checks() { + const NOT_OBJECT: &str = "claude-swap returned output that is not a JSON object."; + const NO_SCHEMA: &str = "claude-swap output has no schemaVersion field."; + const SCHEMA_2: &str = + "claude-swap output uses unsupported schema version 2; CodexBar supports version 1."; + const UNKNOWN: &str = "claude-swap reported Error: unknown error"; + let rows: [(&str, &str, &str); 12] = [ + ("not json", NOT_OBJECT, NOT_OBJECT), + ("[]", NOT_OBJECT, NOT_OBJECT), + (r#""text""#, NOT_OBJECT, NOT_OBJECT), + ("{}", NO_SCHEMA, NO_SCHEMA), + (r#"{"schemaVersion": "1"}"#, NO_SCHEMA, NO_SCHEMA), + (r#"{"schemaVersion": 1.5}"#, NO_SCHEMA, NO_SCHEMA), + (r#"{"schemaVersion": 2}"#, SCHEMA_2, SCHEMA_2), + ( + r#"{"schemaVersion": 2, "error": {"type": "LockHeld"}}"#, + SCHEMA_2, + SCHEMA_2, + ), + (r#"{"schemaVersion": 1, "error": {}}"#, UNKNOWN, UNKNOWN), + ( + r#"{"schemaVersion": 1, "error": {"type": "", "message": ""}}"#, + UNKNOWN, + UNKNOWN, + ), + ( + r#"{"schemaVersion": 1, "error": {"type": "LockHeld", "message": "busy"}}"#, + "claude-swap reported LockHeld: busy", + "claude-swap reported LockHeld: busy", + ), + ( + r#"{"schemaVersion": 1, "error": "text"}"#, + "claude-swap output is malformed: missing accounts array", + "claude-swap output is malformed: missing switched flag", + ), + ]; + for (raw, list_expected, switch_expected) in rows { + let list = parse_account_list(raw).unwrap_err().to_string(); + let switch = parse_switch_result(raw).unwrap_err().to_string(); + assert_eq!(list, list_expected, "{raw}"); + assert_eq!(switch, switch_expected, "{raw}"); + } +} + +#[test] +fn switch_result_requires_matching_target_slot() { + let raw = json!({ + "schemaVersion": 1, + "switched": true, + "from": { "number": 2 }, + "to": { "number": 3 }, + "reason": "switched" + }); + let parsed = parse_switch_result(&raw.to_string()).unwrap(); + assert!(parsed.switched); + assert_eq!(parsed.from_account_number, Some(2)); + assert_eq!(parsed.to_account_number, 3); + assert!(validate_switch_target(3, &parsed).is_ok()); + + let wrong = json!({ + "schemaVersion": 1, + "switched": true, + "from": { "number": 1 }, + "to": { "number": 2 }, + "reason": "switched" + }); + let wrong = parse_switch_result(&wrong.to_string()).unwrap(); + assert!(matches!( + validate_switch_target(3, &wrong), + Err(ClaudeSwapError::MismatchedTarget { + expected: 3, + actual: 2 + }) + )); +} + +#[test] +fn switch_result_rejects_missing_reason_and_bad_schema() { + let missing_reason = json!({ + "schemaVersion": 1, + "switched": true, + "from": { "number": 1 }, + "to": { "number": 2 } + }); + assert!(matches!( + parse_switch_result(&missing_reason.to_string()), + Err(ClaudeSwapError::MalformedShape(_)) + )); + + let bad_schema = json!({ + "schemaVersion": 9, + "switched": true, + "from": { "number": 1 }, + "to": { "number": 2 }, + "reason": "switched" + }); + assert!(matches!( + parse_switch_result(&bad_schema.to_string()), + Err(ClaudeSwapError::UnsupportedSchemaVersion(9)) + )); +} From 8c489b1899e463a4c92aec8345cb00a3c4278a4e Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:43:52 +0700 Subject: [PATCH 39/44] Move Claude web API tests into web_api_tests.rs --- rust/src/providers/claude/web_api.rs | 428 +-------------------- rust/src/providers/claude/web_api_tests.rs | 424 ++++++++++++++++++++ 2 files changed, 426 insertions(+), 426 deletions(-) create mode 100644 rust/src/providers/claude/web_api_tests.rs diff --git a/rust/src/providers/claude/web_api.rs b/rust/src/providers/claude/web_api.rs index 8dc110c2f2c..add9d5cfd53 100755 --- a/rust/src/providers/claude/web_api.rs +++ b/rust/src/providers/claude/web_api.rs @@ -841,432 +841,8 @@ fn append_web_extra_windows( } #[cfg(test)] -mod tests { - use super::{ - AccountResponse, ClaudeWebApiFetcher, UsageWindow, classify_web_http_error, cookie_value, - describe_json_body_shape, is_cookie_authentication_failure, - }; - use crate::core::ProviderError; - use reqwest::StatusCode; - use reqwest::header; - use std::sync::{Mutex, OnceLock}; - - fn env_lock() -> &'static Mutex<()> { - static LOCK: OnceLock> = OnceLock::new(); - LOCK.get_or_init(|| Mutex::new(())) - } - - #[test] - fn keeps_utilization_in_percent_units() { - // 1.0 is a 1% session, not a full quota. - for utilization in [0.23, 1.0, 23.0] { - let window = UsageWindow { - utilization: Some(utilization), - resets_at: None, - }; - - let rate = ClaudeWebApiFetcher::new().to_rate_window(&window, Some(300)); - - assert!( - (rate.used_percent - utilization).abs() < f64::EPSILON, - "session was {}, expected {utilization}% (not 100%)", - rate.used_percent - ); - } - } - - #[test] - fn null_five_hour_session_is_informational_placeholder() { - let placeholder = crate::core::RateWindow::no_active_session(); - assert!(placeholder.is_informational); - assert_eq!(placeholder.window_minutes, Some(300)); - assert!((placeholder.used_percent - 0.0).abs() < f64::EPSILON); - assert_eq!( - placeholder.reset_description.as_deref(), - Some("No active 5h session") - ); - - // Real idle session (object present at 0%) stays unflagged. - let idle = ClaudeWebApiFetcher::new().to_rate_window( - &UsageWindow { - utilization: Some(0.0), - resets_at: None, - }, - Some(300), - ); - assert!(!idle.is_informational); - } - - #[test] - fn labels_max_5x_and_20x_plans() { - assert_eq!( - crate::providers::claude::claude_plan_label("default_claude_max_5x"), - "Claude Max 5x" - ); - assert_eq!( - crate::providers::claude::claude_plan_label("v2_default_claude_max_20x"), - "Claude Max 20x" - ); - } - - #[test] - fn resolves_session_key_from_env_vars() { - let _guard = env_lock().lock().expect("env lock"); - // (CLAUDE_AI_SESSION_KEY, CLAUDE_WEB_SESSION_KEY, resolved key) - let rows = [ - (Some("sk-ant-primary"), "sk-ant-secondary", "sk-ant-primary"), - ( - None, - "sessionKey=sk-ant-cookie-format", - "sk-ant-cookie-format", - ), - ]; - for (primary, secondary, expected) in rows { - // SAFETY: env_lock() held for this whole test, so set_var/remove_var - // cannot race another thread's environment access. - unsafe { - std::env::remove_var("CLAUDE_AI_SESSION_KEY"); - std::env::remove_var("CLAUDE_WEB_SESSION_KEY"); - if let Some(primary) = primary { - std::env::set_var("CLAUDE_AI_SESSION_KEY", primary); - } - std::env::set_var("CLAUDE_WEB_SESSION_KEY", secondary); - } - - let session_key = ClaudeWebApiFetcher::resolve_session_key_from_env(); - - assert_eq!(session_key.as_deref(), Some(expected)); - } - - // SAFETY: cleanup while still holding the env_lock() guard. - unsafe { - std::env::remove_var("CLAUDE_AI_SESSION_KEY"); - std::env::remove_var("CLAUDE_WEB_SESSION_KEY"); - } - } - - #[test] - fn build_headers_include_required_browser_context() { - let headers = ClaudeWebApiFetcher::build_headers("sessionKey=sk-ant-cookie-format"); - - for (name, value) in [ - (header::COOKIE.as_str(), "sessionKey=sk-ant-cookie-format"), - (header::ACCEPT.as_str(), "application/json"), - (header::ORIGIN.as_str(), "https://claude.ai"), - (header::REFERER.as_str(), "https://claude.ai/settings/usage"), - ("anthropic-client-platform", "web_claude_ai"), - ] { - assert_eq!( - headers.get(name).and_then(|value| value.to_str().ok()), - Some(value), - "{name}" - ); - } - assert!(headers.contains_key(header::USER_AGENT)); - } - - #[test] - fn stale_cookie_recovery_retries_only_after_authentication_failure() { - assert!(is_cookie_authentication_failure( - &ProviderError::AuthRequired - )); - assert!(!is_cookie_authentication_failure(&ProviderError::Timeout)); - assert!(!is_cookie_authentication_failure(&ProviderError::Other( - "Failed to get organizations: 503 Service Unavailable".to_string(), - ))); - assert!(!is_cookie_authentication_failure(&classify_web_http_error( - "organizations", - StatusCode::FORBIDDEN, - &header::HeaderMap::new(), - b"Just a moment...", - ))); - } - - #[test] - fn malformed_response_shape_does_not_echo_body_contents() { - let shape = describe_json_body_shape( - "sessionKey=secret-session-token", - Some("text/html; charset=utf-8"), - ); - - assert_eq!( - shape, - "content_type=text/html; charset=utf-8, body_len=31, body_kind=non-json" - ); - assert!(!shape.contains("secret-session-token")); - - let object_shape = - describe_json_body_shape(r#"{"z":"secret-value","a":true}"#, Some("application/json")); - assert_eq!( - object_shape, - "content_type=application/json, body_len=29, json_keys=[a, z]" - ); - assert!(!object_shape.contains("secret-value")); - } - - #[test] - fn extracts_last_active_org_from_cookie_header() { - let org = cookie_value( - "foo=bar; sessionKey=sk-ant-session; lastActiveOrg=org-123; other=value", - "lastActiveOrg", - ); - - assert_eq!(org.as_deref(), Some("org-123")); - } - - #[test] - fn account_membership_prefers_nested_organization_uuid() { - let account: AccountResponse = serde_json::from_str( - r#"{ - "email_address": "user@example.com", - "memberships": [ - { - "uuid": "membership-id", - "organization": { "uuid": "org-id" } - } - ] - }"#, - ) - .unwrap(); - - assert_eq!(account.first_membership_org_id().as_deref(), Some("org-id")); - } - - #[test] - fn parses_design_and_routines_aliases_preferring_the_named_key() { - let rows = [ - ( - r#"{ - "five_hour": { "utilization": 0.1 }, - "seven_day_omelette": { "utilization": 26 }, - "seven_day_cowork": { "utilization": 11 } - }"#, - 26.0, - 11.0, - ), - ( - r#"{ - "seven_day_design": { "utilization": 31 }, - "seven_day_omelette": { "utilization": 26 }, - "seven_day_routines": { "utilization": 19 }, - "seven_day_cowork": { "utilization": 11 } - }"#, - 31.0, - 19.0, - ), - ]; - let fetcher = ClaudeWebApiFetcher::new(); - for (json, design_percent, routines_percent) in rows { - let usage: super::UsageResponse = serde_json::from_str(json).unwrap(); - let design = usage - .seven_day_design - .as_ref() - .map(|w| fetcher.to_rate_window(w, Some(10080))) - .expect("design window"); - let routines = usage - .seven_day_routines - .as_ref() - .map(|w| fetcher.to_rate_window(w, Some(10080))) - .expect("routines window"); - - assert!((design.used_percent - design_percent).abs() < f64::EPSILON); - assert!((routines.used_percent - routines_percent).abs() < f64::EPSILON); - } - } - - #[test] - fn maps_scoped_weekly_limits_even_when_inactive() { - let usage: super::UsageResponse = serde_json::from_str( - r#"{ - "limits": [{ - "kind": "weekly_scoped", - "group": "weekly", - "percent": 7, - "resets_at": "2026-07-16T10:00:00Z", - "scope": {"model": {"id": null, "display_name": "Fable"}}, - "is_active": false - }] - }"#, - ) - .unwrap(); - - let windows = super::super::scoped_weekly::scoped_weekly_windows(&usage.limits); - assert_eq!(windows.len(), 1); - assert_eq!(windows[0].id, "claude-weekly-scoped-fable"); - assert_eq!(windows[0].title, "Fable only"); - } - - #[test] - fn parses_oauth_apps_window_and_embedded_extra_usage() { - let usage: super::UsageResponse = serde_json::from_str( - r#"{ - "five_hour": { "utilization": 0.1 }, - "seven_day_oauth_apps": { "utilization": 42 }, - "extra_usage": { - "is_enabled": true, - "monthly_credit_limit": 2000, - "used_credits": 550, - "currency": "USD" - } - }"#, - ) - .unwrap(); - - let fetcher = ClaudeWebApiFetcher::new(); - let oauth_apps = usage - .seven_day_oauth_apps - .as_ref() - .map(|w| fetcher.to_rate_window(w, Some(10080))) - .expect("oauth apps window"); - let extra = usage.extra_usage.expect("extra usage"); - - assert!((oauth_apps.used_percent - 42.0).abs() < f64::EPSILON); - assert_eq!(extra.is_enabled, Some(true)); - assert_eq!(extra.monthly_credit_limit, Some(2000.0)); - assert_eq!(extra.used_credits, Some(550.0)); - } - - #[test] - fn issue_279_session_limits_win_over_stale_five_hour_after_rollover() { - // Right after a 5h window rollover the legacy five_hour.utilization - // can transiently report 1.0 (normalizes to 100%) even though - // claude.ai shows only 5% for the fresh window. The limits[] entry - // (kind=="session") carries the true value and must win. - let usage: super::UsageResponse = serde_json::from_str( - r#"{ - "five_hour": {"utilization": 1.0, "resets_at": "2026-08-13T12:49:59.578826Z"}, - "seven_day": {"utilization": 0.01, "resets_at": "2026-07-26T22:59:59Z"}, - "limits": [ - { - "kind": "session", - "group": "session", - "percent": 5, - "resets_at": "2026-08-13T12:49:59.578826Z" - }, - { - "kind": "weekly_all", - "group": "weekly", - "percent": 1, - "resets_at": "2026-07-26T22:59:59Z" - } - ] - }"#, - ) - .expect("issue 279 body"); - - let fetcher = ClaudeWebApiFetcher::new(); - let (primary, secondary, _) = fetcher.build_rate_windows(&usage); - - // Primary session must be 5%, not the stale 100%. - assert!( - (primary.used_percent - 5.0).abs() < f64::EPSILON, - "primary was {}, expected 5% (not 100%)", - primary.used_percent - ); - assert!((primary.used_percent - 100.0).abs() > 1.0); - assert_eq!(primary.window_minutes, Some(300)); - assert!(primary.resets_at.is_some()); - - // Weekly lane is unaffected (still prefers limits weekly_all). - let weekly = secondary.expect("weekly"); - assert!((weekly.used_percent - 1.0).abs() < f64::EPSILON); - } - - #[test] - fn session_falls_back_to_legacy_five_hour_without_limits_entry() { - // When no limits[] session entry exists, the legacy five_hour field - // is still the source of truth (backwards compatible). - let usage: super::UsageResponse = serde_json::from_str( - r#"{ - "five_hour": {"utilization": 10.0, "resets_at": "2026-08-13T12:49:59Z"} - }"#, - ) - .expect("legacy-only body"); - - let fetcher = ClaudeWebApiFetcher::new(); - let (primary, _, _) = fetcher.build_rate_windows(&usage); - - assert!((primary.used_percent - 10.0).abs() < f64::EPSILON); - assert_eq!(primary.window_minutes, Some(300)); - } - - #[test] - fn parse_prepaid_balance_converts_cents_to_dollars() { - let balance = super::parse_prepaid_balance(r#"{"amount": 2550, "currency": "usd"}"#) - .expect("prepaid balance"); - assert!((balance.amount_dollars - 25.5).abs() < f64::EPSILON); - assert_eq!(balance.currency_code, "USD"); - } - - #[test] - fn parse_prepaid_balance_rejects_negative_or_non_finite() { - assert!(super::parse_prepaid_balance(r#"{"amount": -1, "currency": "USD"}"#).is_none()); - assert!(super::parse_prepaid_balance(r#"{"amount": 10, "currency": " "}"#).is_none()); - } - - #[test] - fn apply_prepaid_balance_attaches_to_same_currency_cost() { - let existing = crate::core::CostSnapshot::new(1.0, "USD", "Monthly").with_limit(20.0); - let balance = super::PrepaidBalance { - amount_dollars: 12.34, - currency_code: "USD".into(), - }; - let cost = super::apply_prepaid_balance(balance, Some(existing)); - assert_eq!(cost.balance, Some(12.34)); - assert!((cost.used - 1.0).abs() < f64::EPSILON); - assert_eq!(cost.limit, Some(20.0)); - assert_eq!(cost.period, "Monthly"); - } - - #[test] - fn apply_prepaid_balance_creates_extra_usage_when_missing_or_mismatch() { - let balance = super::PrepaidBalance { - amount_dollars: 5.0, - currency_code: "USD".into(), - }; - let created = super::apply_prepaid_balance(balance.clone(), None); - assert_eq!(created.balance, Some(5.0)); - assert_eq!(created.period, "Extra usage"); - assert!((created.used - 0.0).abs() < f64::EPSILON); - - let eur = crate::core::CostSnapshot::new(2.0, "EUR", "Monthly"); - let replaced = super::apply_prepaid_balance(balance, Some(eur)); - assert_eq!(replaced.currency_code, "USD"); - assert_eq!(replaced.period, "Extra usage"); - assert_eq!(replaced.balance, Some(5.0)); - } - - #[test] - fn web_extras_order_oauth_scoped_then_routines() { - use crate::core::{NamedRateWindow, RateWindow, UsageSnapshot}; - - let mut snapshot = UsageSnapshot::new(RateWindow::new(10.0)); - super::append_web_extra_windows( - &mut snapshot, - Some(RateWindow::new(1.0)), - vec![NamedRateWindow::new( - "claude-weekly-scoped-fable", - "Fable only", - RateWindow::new(2.0), - )], - Some(RateWindow::new(3.0)), - ); - - let ids: Vec<&str> = snapshot - .extra_rate_windows - .iter() - .map(|w| w.id.as_str()) - .collect(); - assert_eq!( - ids, - vec![ - "claude-oauth-apps", - "claude-weekly-scoped-fable", - "claude-routines" - ] - ); - } -} +#[path = "web_api_tests.rs"] +mod tests; #[cfg(test)] #[path = "cloudflare_tests.rs"] diff --git a/rust/src/providers/claude/web_api_tests.rs b/rust/src/providers/claude/web_api_tests.rs new file mode 100644 index 00000000000..2b591aff3a8 --- /dev/null +++ b/rust/src/providers/claude/web_api_tests.rs @@ -0,0 +1,424 @@ +use super::{ + AccountResponse, ClaudeWebApiFetcher, UsageWindow, classify_web_http_error, cookie_value, + describe_json_body_shape, is_cookie_authentication_failure, +}; +use crate::core::ProviderError; +use reqwest::StatusCode; +use reqwest::header; +use std::sync::{Mutex, OnceLock}; + +fn env_lock() -> &'static Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) +} + +#[test] +fn keeps_utilization_in_percent_units() { + // 1.0 is a 1% session, not a full quota. + for utilization in [0.23, 1.0, 23.0] { + let window = UsageWindow { + utilization: Some(utilization), + resets_at: None, + }; + + let rate = ClaudeWebApiFetcher::new().to_rate_window(&window, Some(300)); + + assert!( + (rate.used_percent - utilization).abs() < f64::EPSILON, + "session was {}, expected {utilization}% (not 100%)", + rate.used_percent + ); + } +} + +#[test] +fn null_five_hour_session_is_informational_placeholder() { + let placeholder = crate::core::RateWindow::no_active_session(); + assert!(placeholder.is_informational); + assert_eq!(placeholder.window_minutes, Some(300)); + assert!((placeholder.used_percent - 0.0).abs() < f64::EPSILON); + assert_eq!( + placeholder.reset_description.as_deref(), + Some("No active 5h session") + ); + + // Real idle session (object present at 0%) stays unflagged. + let idle = ClaudeWebApiFetcher::new().to_rate_window( + &UsageWindow { + utilization: Some(0.0), + resets_at: None, + }, + Some(300), + ); + assert!(!idle.is_informational); +} + +#[test] +fn labels_max_5x_and_20x_plans() { + assert_eq!( + crate::providers::claude::claude_plan_label("default_claude_max_5x"), + "Claude Max 5x" + ); + assert_eq!( + crate::providers::claude::claude_plan_label("v2_default_claude_max_20x"), + "Claude Max 20x" + ); +} + +#[test] +fn resolves_session_key_from_env_vars() { + let _guard = env_lock().lock().expect("env lock"); + // (CLAUDE_AI_SESSION_KEY, CLAUDE_WEB_SESSION_KEY, resolved key) + let rows = [ + (Some("sk-ant-primary"), "sk-ant-secondary", "sk-ant-primary"), + ( + None, + "sessionKey=sk-ant-cookie-format", + "sk-ant-cookie-format", + ), + ]; + for (primary, secondary, expected) in rows { + // SAFETY: env_lock() held for this whole test, so set_var/remove_var + // cannot race another thread's environment access. + unsafe { + std::env::remove_var("CLAUDE_AI_SESSION_KEY"); + std::env::remove_var("CLAUDE_WEB_SESSION_KEY"); + if let Some(primary) = primary { + std::env::set_var("CLAUDE_AI_SESSION_KEY", primary); + } + std::env::set_var("CLAUDE_WEB_SESSION_KEY", secondary); + } + + let session_key = ClaudeWebApiFetcher::resolve_session_key_from_env(); + + assert_eq!(session_key.as_deref(), Some(expected)); + } + + // SAFETY: cleanup while still holding the env_lock() guard. + unsafe { + std::env::remove_var("CLAUDE_AI_SESSION_KEY"); + std::env::remove_var("CLAUDE_WEB_SESSION_KEY"); + } +} + +#[test] +fn build_headers_include_required_browser_context() { + let headers = ClaudeWebApiFetcher::build_headers("sessionKey=sk-ant-cookie-format"); + + for (name, value) in [ + (header::COOKIE.as_str(), "sessionKey=sk-ant-cookie-format"), + (header::ACCEPT.as_str(), "application/json"), + (header::ORIGIN.as_str(), "https://claude.ai"), + (header::REFERER.as_str(), "https://claude.ai/settings/usage"), + ("anthropic-client-platform", "web_claude_ai"), + ] { + assert_eq!( + headers.get(name).and_then(|value| value.to_str().ok()), + Some(value), + "{name}" + ); + } + assert!(headers.contains_key(header::USER_AGENT)); +} + +#[test] +fn stale_cookie_recovery_retries_only_after_authentication_failure() { + assert!(is_cookie_authentication_failure( + &ProviderError::AuthRequired + )); + assert!(!is_cookie_authentication_failure(&ProviderError::Timeout)); + assert!(!is_cookie_authentication_failure(&ProviderError::Other( + "Failed to get organizations: 503 Service Unavailable".to_string(), + ))); + assert!(!is_cookie_authentication_failure(&classify_web_http_error( + "organizations", + StatusCode::FORBIDDEN, + &header::HeaderMap::new(), + b"Just a moment...", + ))); +} + +#[test] +fn malformed_response_shape_does_not_echo_body_contents() { + let shape = describe_json_body_shape( + "sessionKey=secret-session-token", + Some("text/html; charset=utf-8"), + ); + + assert_eq!( + shape, + "content_type=text/html; charset=utf-8, body_len=31, body_kind=non-json" + ); + assert!(!shape.contains("secret-session-token")); + + let object_shape = + describe_json_body_shape(r#"{"z":"secret-value","a":true}"#, Some("application/json")); + assert_eq!( + object_shape, + "content_type=application/json, body_len=29, json_keys=[a, z]" + ); + assert!(!object_shape.contains("secret-value")); +} + +#[test] +fn extracts_last_active_org_from_cookie_header() { + let org = cookie_value( + "foo=bar; sessionKey=sk-ant-session; lastActiveOrg=org-123; other=value", + "lastActiveOrg", + ); + + assert_eq!(org.as_deref(), Some("org-123")); +} + +#[test] +fn account_membership_prefers_nested_organization_uuid() { + let account: AccountResponse = serde_json::from_str( + r#"{ + "email_address": "user@example.com", + "memberships": [ + { + "uuid": "membership-id", + "organization": { "uuid": "org-id" } + } + ] + }"#, + ) + .unwrap(); + + assert_eq!(account.first_membership_org_id().as_deref(), Some("org-id")); +} + +#[test] +fn parses_design_and_routines_aliases_preferring_the_named_key() { + let rows = [ + ( + r#"{ + "five_hour": { "utilization": 0.1 }, + "seven_day_omelette": { "utilization": 26 }, + "seven_day_cowork": { "utilization": 11 } + }"#, + 26.0, + 11.0, + ), + ( + r#"{ + "seven_day_design": { "utilization": 31 }, + "seven_day_omelette": { "utilization": 26 }, + "seven_day_routines": { "utilization": 19 }, + "seven_day_cowork": { "utilization": 11 } + }"#, + 31.0, + 19.0, + ), + ]; + let fetcher = ClaudeWebApiFetcher::new(); + for (json, design_percent, routines_percent) in rows { + let usage: super::UsageResponse = serde_json::from_str(json).unwrap(); + let design = usage + .seven_day_design + .as_ref() + .map(|w| fetcher.to_rate_window(w, Some(10080))) + .expect("design window"); + let routines = usage + .seven_day_routines + .as_ref() + .map(|w| fetcher.to_rate_window(w, Some(10080))) + .expect("routines window"); + + assert!((design.used_percent - design_percent).abs() < f64::EPSILON); + assert!((routines.used_percent - routines_percent).abs() < f64::EPSILON); + } +} + +#[test] +fn maps_scoped_weekly_limits_even_when_inactive() { + let usage: super::UsageResponse = serde_json::from_str( + r#"{ + "limits": [{ + "kind": "weekly_scoped", + "group": "weekly", + "percent": 7, + "resets_at": "2026-07-16T10:00:00Z", + "scope": {"model": {"id": null, "display_name": "Fable"}}, + "is_active": false + }] + }"#, + ) + .unwrap(); + + let windows = super::super::scoped_weekly::scoped_weekly_windows(&usage.limits); + assert_eq!(windows.len(), 1); + assert_eq!(windows[0].id, "claude-weekly-scoped-fable"); + assert_eq!(windows[0].title, "Fable only"); +} + +#[test] +fn parses_oauth_apps_window_and_embedded_extra_usage() { + let usage: super::UsageResponse = serde_json::from_str( + r#"{ + "five_hour": { "utilization": 0.1 }, + "seven_day_oauth_apps": { "utilization": 42 }, + "extra_usage": { + "is_enabled": true, + "monthly_credit_limit": 2000, + "used_credits": 550, + "currency": "USD" + } + }"#, + ) + .unwrap(); + + let fetcher = ClaudeWebApiFetcher::new(); + let oauth_apps = usage + .seven_day_oauth_apps + .as_ref() + .map(|w| fetcher.to_rate_window(w, Some(10080))) + .expect("oauth apps window"); + let extra = usage.extra_usage.expect("extra usage"); + + assert!((oauth_apps.used_percent - 42.0).abs() < f64::EPSILON); + assert_eq!(extra.is_enabled, Some(true)); + assert_eq!(extra.monthly_credit_limit, Some(2000.0)); + assert_eq!(extra.used_credits, Some(550.0)); +} + +#[test] +fn issue_279_session_limits_win_over_stale_five_hour_after_rollover() { + // Right after a 5h window rollover the legacy five_hour.utilization + // can transiently report 1.0 (normalizes to 100%) even though + // claude.ai shows only 5% for the fresh window. The limits[] entry + // (kind=="session") carries the true value and must win. + let usage: super::UsageResponse = serde_json::from_str( + r#"{ + "five_hour": {"utilization": 1.0, "resets_at": "2026-08-13T12:49:59.578826Z"}, + "seven_day": {"utilization": 0.01, "resets_at": "2026-07-26T22:59:59Z"}, + "limits": [ + { + "kind": "session", + "group": "session", + "percent": 5, + "resets_at": "2026-08-13T12:49:59.578826Z" + }, + { + "kind": "weekly_all", + "group": "weekly", + "percent": 1, + "resets_at": "2026-07-26T22:59:59Z" + } + ] + }"#, + ) + .expect("issue 279 body"); + + let fetcher = ClaudeWebApiFetcher::new(); + let (primary, secondary, _) = fetcher.build_rate_windows(&usage); + + // Primary session must be 5%, not the stale 100%. + assert!( + (primary.used_percent - 5.0).abs() < f64::EPSILON, + "primary was {}, expected 5% (not 100%)", + primary.used_percent + ); + assert!((primary.used_percent - 100.0).abs() > 1.0); + assert_eq!(primary.window_minutes, Some(300)); + assert!(primary.resets_at.is_some()); + + // Weekly lane is unaffected (still prefers limits weekly_all). + let weekly = secondary.expect("weekly"); + assert!((weekly.used_percent - 1.0).abs() < f64::EPSILON); +} + +#[test] +fn session_falls_back_to_legacy_five_hour_without_limits_entry() { + // When no limits[] session entry exists, the legacy five_hour field + // is still the source of truth (backwards compatible). + let usage: super::UsageResponse = serde_json::from_str( + r#"{ + "five_hour": {"utilization": 10.0, "resets_at": "2026-08-13T12:49:59Z"} + }"#, + ) + .expect("legacy-only body"); + + let fetcher = ClaudeWebApiFetcher::new(); + let (primary, _, _) = fetcher.build_rate_windows(&usage); + + assert!((primary.used_percent - 10.0).abs() < f64::EPSILON); + assert_eq!(primary.window_minutes, Some(300)); +} + +#[test] +fn parse_prepaid_balance_converts_cents_to_dollars() { + let balance = super::parse_prepaid_balance(r#"{"amount": 2550, "currency": "usd"}"#) + .expect("prepaid balance"); + assert!((balance.amount_dollars - 25.5).abs() < f64::EPSILON); + assert_eq!(balance.currency_code, "USD"); +} + +#[test] +fn parse_prepaid_balance_rejects_negative_or_non_finite() { + assert!(super::parse_prepaid_balance(r#"{"amount": -1, "currency": "USD"}"#).is_none()); + assert!(super::parse_prepaid_balance(r#"{"amount": 10, "currency": " "}"#).is_none()); +} + +#[test] +fn apply_prepaid_balance_attaches_to_same_currency_cost() { + let existing = crate::core::CostSnapshot::new(1.0, "USD", "Monthly").with_limit(20.0); + let balance = super::PrepaidBalance { + amount_dollars: 12.34, + currency_code: "USD".into(), + }; + let cost = super::apply_prepaid_balance(balance, Some(existing)); + assert_eq!(cost.balance, Some(12.34)); + assert!((cost.used - 1.0).abs() < f64::EPSILON); + assert_eq!(cost.limit, Some(20.0)); + assert_eq!(cost.period, "Monthly"); +} + +#[test] +fn apply_prepaid_balance_creates_extra_usage_when_missing_or_mismatch() { + let balance = super::PrepaidBalance { + amount_dollars: 5.0, + currency_code: "USD".into(), + }; + let created = super::apply_prepaid_balance(balance.clone(), None); + assert_eq!(created.balance, Some(5.0)); + assert_eq!(created.period, "Extra usage"); + assert!((created.used - 0.0).abs() < f64::EPSILON); + + let eur = crate::core::CostSnapshot::new(2.0, "EUR", "Monthly"); + let replaced = super::apply_prepaid_balance(balance, Some(eur)); + assert_eq!(replaced.currency_code, "USD"); + assert_eq!(replaced.period, "Extra usage"); + assert_eq!(replaced.balance, Some(5.0)); +} + +#[test] +fn web_extras_order_oauth_scoped_then_routines() { + use crate::core::{NamedRateWindow, RateWindow, UsageSnapshot}; + + let mut snapshot = UsageSnapshot::new(RateWindow::new(10.0)); + super::append_web_extra_windows( + &mut snapshot, + Some(RateWindow::new(1.0)), + vec![NamedRateWindow::new( + "claude-weekly-scoped-fable", + "Fable only", + RateWindow::new(2.0), + )], + Some(RateWindow::new(3.0)), + ); + + let ids: Vec<&str> = snapshot + .extra_rate_windows + .iter() + .map(|w| w.id.as_str()) + .collect(); + assert_eq!( + ids, + vec![ + "claude-oauth-apps", + "claude-weekly-scoped-fable", + "claude-routines" + ] + ); +} From 950c284593593d1303003f0a01163d0e26ee41b2 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:43:52 +0700 Subject: [PATCH 40/44] Move Copilot API tests into api_tests.rs --- rust/src/providers/copilot/api.rs | 498 +----------------------- rust/src/providers/copilot/api_tests.rs | 494 +++++++++++++++++++++++ 2 files changed, 496 insertions(+), 496 deletions(-) create mode 100644 rust/src/providers/copilot/api_tests.rs diff --git a/rust/src/providers/copilot/api.rs b/rust/src/providers/copilot/api.rs index c52c6437e67..dbecaddbf19 100755 --- a/rust/src/providers/copilot/api.rs +++ b/rust/src/providers/copilot/api.rs @@ -776,499 +776,5 @@ where } #[cfg(test)] -mod tests { - use super::*; - - fn parse_with_seat(json: &str, seat: Option) -> Result { - let response: CopilotUsageResponse = serde_json::from_str(json).unwrap(); - snapshot_from_response_with_seat_entitlement(response, seat) - } - - fn parse_snapshot(json: &str) -> UsageSnapshot { - parse_with_seat(json, None).unwrap() - } - - fn parse_snapshot_result(json: &str) -> Result { - parse_with_seat(json, None) - } - - fn seat_window(usage: &UsageSnapshot) -> Option<&NamedRateWindow> { - usage - .extra_rate_windows - .iter() - .find(|window| window.id == SEAT_CREDIT_WINDOW_ID) - } - - fn assert_used(window: &RateWindow, expected: f64) { - assert!( - (window.used_percent - expected).abs() < 0.001, - "{} != {expected}", - window.used_percent - ); - } - - #[test] - fn paid_plan_parses_premium_and_chat_quotas() { - let usage = parse_snapshot( - r#"{ - "copilot_plan": "pro", - "quota_reset_date": "2026-06-01", - "quota_snapshots": { - "premium_interactions": { - "entitlement": 300, - "remaining": 240, - "percent_remaining": 80, - "quota_id": "premium_interactions" - }, - "chat": { - "entitlement": 1000, - "remaining": 900, - "percent_remaining": 90, - "quota_id": "chat" - } - } - }"#, - ); - - assert_eq!(usage.login_method.as_deref(), Some("Copilot Pro")); - assert_used(&usage.primary, 20.0); - assert_used(&usage.secondary.unwrap(), 10.0); - } - - #[test] - fn limited_user_quotas_parse_free_schema() { - let usage = parse_snapshot( - r#"{ - "copilot_plan": "free", - "monthly_quotas": { - "completions": 2000, - "chat": "50" - }, - "limited_user_quotas": { - "completions": "1000", - "chat": 10 - } - }"#, - ); - - assert_eq!(usage.login_method.as_deref(), Some("Copilot Free")); - assert_used(&usage.primary, 50.0); - assert_used(&usage.secondary.unwrap(), 80.0); - } - - #[test] - fn derives_missing_percent_and_accepts_numeric_strings() { - let usage = parse_snapshot( - r#"{ - "quota_snapshots": { - "premium_interactions": { - "entitlement": "100", - "remaining": "25", - "quota_id": "premium_interactions" - } - } - }"#, - ); - - assert_used(&usage.primary, 75.0); - } - - #[test] - fn ignores_placeholders_and_does_not_promote_chat_to_premium() { - let usage = parse_snapshot( - r#"{ - "quota_snapshots": { - "premium_interactions": { - "percent_remaining": 0, - "quota_id": "" - }, - "chat": { - "entitlement": 100, - "remaining": 75, - "percent_remaining": 75, - "quota_id": "chat" - } - } - }"#, - ); - - assert_used(&usage.primary, 25.0); - assert!(usage.secondary.is_none()); - } - - #[test] - fn drops_business_token_billing_zero_entitlement_quotas() { - let err = parse_snapshot_result( - r#"{ - "copilot_plan": "business", - "token_based_billing": true, - "quota_snapshots": { - "premium_interactions": { - "entitlement": 0, - "remaining": 0, - "percent_remaining": 100, - "quota_id": "premium_interactions" - }, - "chat": { - "entitlement": 0, - "remaining": 0, - "percent_remaining": 100, - "quota_id": "chat" - }, - "completions": { - "entitlement": 0, - "remaining": 0, - "percent_remaining": 100, - "quota_id": "completions" - } - } - }"#, - ) - .unwrap_err(); - - assert!( - err.to_string() - .contains("token-based billing usage is unavailable") - ); - } - - #[test] - fn keeps_percent_only_quota_snapshots_available() { - let usage = parse_snapshot( - r#"{ - "copilot_plan": "business", - "quota_snapshots": { - "chat": { - "percent_remaining": 40, - "quota_id": "chat" - } - } - }"#, - ); - - assert_eq!(usage.login_method.as_deref(), Some("Copilot Business")); - assert_used(&usage.primary, 60.0); - assert!(usage.secondary.is_none()); - } - - #[test] - fn keeps_fully_consumed_positive_entitlement_quota() { - let usage = parse_snapshot( - r#"{ - "quota_snapshots": { - "premium_interactions": { - "entitlement": 500, - "remaining": 0, - "percent_remaining": 0, - "quota_id": "premium_interactions" - } - } - }"#, - ); - - assert_used(&usage.primary, 100.0); - } - - #[test] - fn keeps_additional_budget_as_extra_window() { - let usage = parse_snapshot( - r#"{ - "copilot_plan": "pro", - "quota_snapshots": { - "premium_interactions": { - "entitlement": 500, - "remaining": 250, - "quota_id": "premium_interactions" - }, - "additional_budget": { - "entitlement": 100, - "remaining": 25, - "quota_id": "additional_budget" - } - } - }"#, - ); - - assert_used(&usage.primary, 50.0); - assert_eq!(usage.extra_rate_windows.len(), 1); - assert_eq!(usage.extra_rate_windows[0].id, "additional-budget"); - assert_eq!(usage.extra_rate_windows[0].title, "Additional Budget"); - assert_used(&usage.extra_rate_windows[0].window, 75.0); - } - - #[test] - fn preserves_over_quota_percent_remaining() { - let usage = parse_snapshot( - r#"{ - "copilot_plan": "pro", - "quota_snapshots": { - "premium_interactions": { - "entitlement": 500, - "remaining": -75, - "percent_remaining": -15, - "quota_id": "premium_interactions" - } - } - }"#, - ); - - assert_eq!(usage.login_method.as_deref(), Some("Copilot Pro")); - assert_used(&usage.primary, 115.0); - assert_eq!( - usage.primary.reset_description.as_deref(), - Some("115% used") - ); - assert!(usage.primary.is_exhausted()); - } - - #[test] - fn derives_over_quota_percent_from_negative_remaining() { - let usage = parse_snapshot( - r#"{ - "quota_snapshots": { - "chat": { - "entitlement": 500, - "remaining": -75, - "quota_id": "chat" - } - } - }"#, - ); - - assert_used(&usage.primary, 115.0); - assert_eq!( - usage.primary.reset_description.as_deref(), - Some("115% used") - ); - } - - #[test] - fn normalizes_enterprise_hosts() { - assert_eq!( - normalized_api_host(Some("github.com")), - "api.github.com".to_string() - ); - assert_eq!( - normalized_api_host(Some("github.example.com")), - "api.github.example.com".to_string() - ); - assert_eq!( - normalized_api_host(Some("api.github.example.com")), - "api.github.example.com".to_string() - ); - } - - // ── A15: credits_used counter for token-billed seats (upstream #2613) ─── - - #[test] - fn decodes_credits_used_as_number_or_string() { - let usage = parse_snapshot( - r#"{ - "copilot_plan": "business", - "quota_reset_date": "2026-06-01", - "quota_snapshots": { - "premium_interactions": { - "entitlement": 300, - "remaining": 240, - "percent_remaining": 80, - "quota_id": "premium_interactions", - "credits_used": "1234.56" - } - } - }"#, - ); - let extra = &usage.extra_rate_windows; - assert!( - extra.iter().any(|w| w.id == "ai-credits" - && w.window.reset_description.as_deref() == Some("1234.56 AI credits used")), - "{extra:?}" - ); - } - - #[test] - fn configured_seat_allowance_adds_a_numeric_credit_window() { - let usage = parse_with_seat( - r#"{ - "copilot_plan": "business", - "quota_snapshots": { - "premium_interactions": { - "entitlement": 300, - "remaining": 240, - "percent_remaining": 80, - "quota_id": "premium_interactions", - "credits_used": 50 - } - } - }"#, - Some(200.0), - ) - .unwrap(); - - let seat = seat_window(&usage).expect("configured seat-credit window"); - assert_used(&seat.window, 25.0); - assert!(!seat.window.is_informational); - assert_eq!(seat.title, "Credits used"); - } - - #[test] - fn missing_primary_quota_is_informational_when_seat_credit_is_available() { - let usage = parse_with_seat( - r#"{ - "copilot_plan": "business", - "quota_snapshots": { - "additional_budget": { - "credits_used": 50 - } - } - }"#, - Some(200.0), - ) - .unwrap(); - - assert!(usage.primary.is_informational); - assert!(seat_window(&usage).is_some()); - } - - #[test] - fn non_finite_derived_seat_credit_percentage_is_omitted() { - let usage = parse_with_seat( - r#"{ - "copilot_plan": "business", - "quota_snapshots": { - "premium_interactions": { - "credits_used": 1e308 - } - } - }"#, - Some(1e-308), - ) - .unwrap(); - - assert!(seat_window(&usage).is_none()); - } - - #[test] - fn invalid_seat_allowance_keeps_credit_progress_unknown() { - let usage = parse_with_seat( - r#"{ - "copilot_plan": "business", - "token_based_billing": true, - "quota_snapshots": { - "premium_interactions": { - "entitlement": 0, - "remaining": 0, - "credits_used": 50 - } - } - }"#, - Some(0.0), - ) - .unwrap(); - - assert!(usage.primary.is_informational); - assert!(seat_window(&usage).is_none()); - } - - #[test] - fn zero_entitlement_business_seat_surfaces_credits_counter() { - let usage = parse_snapshot( - r#"{ - "copilot_plan": "business", - "token_based_billing": true, - "quota_reset_date": "2026-06-01", - "quota_snapshots": { - "premium_interactions": { - "entitlement": 0, - "remaining": 0, - "percent_remaining": 100, - "quota_id": "premium_interactions", - "credits_used": 1234 - } - } - }"#, - ); - // Not an error anymore: informational counter row without a fake bar. - assert!(usage.primary.is_informational); - assert_eq!( - usage.primary.reset_description.as_deref(), - Some("1234 AI credits used") - ); - assert!(usage.primary.resets_at.is_some()); - assert_eq!(usage.login_method.as_deref(), Some("Copilot Business")); - } - - #[test] - fn placeholder_snapshot_still_carries_its_credits_counter() { - // Upstream carriesCreditsCounter: a placeholder cannot become a window, - // but its absolute counter is real consumption and must survive. - let usage = parse_snapshot( - r#"{ - "copilot_plan": "business", - "token_based_billing": true, - "quota_snapshots": { - "premium_interactions": { - "entitlement": 0, - "remaining": 0, - "percent_remaining": 0, - "quota_id": "", - "placeholder": true, - "credits_used": 42.5 - } - } - }"#, - ); - assert!(usage.primary.is_informational); - assert_eq!( - usage.primary.reset_description.as_deref(), - Some("42.50 AI credits used") - ); - } - - #[test] - fn premium_credits_counter_wins_over_chat() { - let usage = parse_snapshot( - r#"{ - "copilot_plan": "pro", - "quota_snapshots": { - "chat": { - "entitlement": 100, - "remaining": 75, - "percent_remaining": 75, - "quota_id": "chat", - "credits_used": 1 - }, - "premium_interactions": { - "entitlement": 300, - "remaining": 240, - "percent_remaining": 80, - "quota_id": "premium_interactions", - "credits_used": 7 - } - } - }"#, - ); - let credits_row = usage - .extra_rate_windows - .iter() - .find(|w| w.id == "ai-credits") - .expect("ai-credits row"); - assert_eq!( - credits_row.window.reset_description.as_deref(), - Some("7 AI credits used") - ); - // Windows still render normally next to the counter. - assert!(credits_row.window.is_informational); - assert_used(&usage.primary, 20.0); - } - - #[test] - fn business_seat_without_credits_keeps_existing_error() { - let err = parse_snapshot_result( - r#"{ - "copilot_plan": "business", - "token_based_billing": true - }"#, - ); - assert!(err.is_err()); - } -} +#[path = "api_tests.rs"] +mod tests; diff --git a/rust/src/providers/copilot/api_tests.rs b/rust/src/providers/copilot/api_tests.rs new file mode 100644 index 00000000000..88f62a8dc94 --- /dev/null +++ b/rust/src/providers/copilot/api_tests.rs @@ -0,0 +1,494 @@ +use super::*; + +fn parse_with_seat(json: &str, seat: Option) -> Result { + let response: CopilotUsageResponse = serde_json::from_str(json).unwrap(); + snapshot_from_response_with_seat_entitlement(response, seat) +} + +fn parse_snapshot(json: &str) -> UsageSnapshot { + parse_with_seat(json, None).unwrap() +} + +fn parse_snapshot_result(json: &str) -> Result { + parse_with_seat(json, None) +} + +fn seat_window(usage: &UsageSnapshot) -> Option<&NamedRateWindow> { + usage + .extra_rate_windows + .iter() + .find(|window| window.id == SEAT_CREDIT_WINDOW_ID) +} + +fn assert_used(window: &RateWindow, expected: f64) { + assert!( + (window.used_percent - expected).abs() < 0.001, + "{} != {expected}", + window.used_percent + ); +} + +#[test] +fn paid_plan_parses_premium_and_chat_quotas() { + let usage = parse_snapshot( + r#"{ + "copilot_plan": "pro", + "quota_reset_date": "2026-06-01", + "quota_snapshots": { + "premium_interactions": { + "entitlement": 300, + "remaining": 240, + "percent_remaining": 80, + "quota_id": "premium_interactions" + }, + "chat": { + "entitlement": 1000, + "remaining": 900, + "percent_remaining": 90, + "quota_id": "chat" + } + } + }"#, + ); + + assert_eq!(usage.login_method.as_deref(), Some("Copilot Pro")); + assert_used(&usage.primary, 20.0); + assert_used(&usage.secondary.unwrap(), 10.0); +} + +#[test] +fn limited_user_quotas_parse_free_schema() { + let usage = parse_snapshot( + r#"{ + "copilot_plan": "free", + "monthly_quotas": { + "completions": 2000, + "chat": "50" + }, + "limited_user_quotas": { + "completions": "1000", + "chat": 10 + } + }"#, + ); + + assert_eq!(usage.login_method.as_deref(), Some("Copilot Free")); + assert_used(&usage.primary, 50.0); + assert_used(&usage.secondary.unwrap(), 80.0); +} + +#[test] +fn derives_missing_percent_and_accepts_numeric_strings() { + let usage = parse_snapshot( + r#"{ + "quota_snapshots": { + "premium_interactions": { + "entitlement": "100", + "remaining": "25", + "quota_id": "premium_interactions" + } + } + }"#, + ); + + assert_used(&usage.primary, 75.0); +} + +#[test] +fn ignores_placeholders_and_does_not_promote_chat_to_premium() { + let usage = parse_snapshot( + r#"{ + "quota_snapshots": { + "premium_interactions": { + "percent_remaining": 0, + "quota_id": "" + }, + "chat": { + "entitlement": 100, + "remaining": 75, + "percent_remaining": 75, + "quota_id": "chat" + } + } + }"#, + ); + + assert_used(&usage.primary, 25.0); + assert!(usage.secondary.is_none()); +} + +#[test] +fn drops_business_token_billing_zero_entitlement_quotas() { + let err = parse_snapshot_result( + r#"{ + "copilot_plan": "business", + "token_based_billing": true, + "quota_snapshots": { + "premium_interactions": { + "entitlement": 0, + "remaining": 0, + "percent_remaining": 100, + "quota_id": "premium_interactions" + }, + "chat": { + "entitlement": 0, + "remaining": 0, + "percent_remaining": 100, + "quota_id": "chat" + }, + "completions": { + "entitlement": 0, + "remaining": 0, + "percent_remaining": 100, + "quota_id": "completions" + } + } + }"#, + ) + .unwrap_err(); + + assert!( + err.to_string() + .contains("token-based billing usage is unavailable") + ); +} + +#[test] +fn keeps_percent_only_quota_snapshots_available() { + let usage = parse_snapshot( + r#"{ + "copilot_plan": "business", + "quota_snapshots": { + "chat": { + "percent_remaining": 40, + "quota_id": "chat" + } + } + }"#, + ); + + assert_eq!(usage.login_method.as_deref(), Some("Copilot Business")); + assert_used(&usage.primary, 60.0); + assert!(usage.secondary.is_none()); +} + +#[test] +fn keeps_fully_consumed_positive_entitlement_quota() { + let usage = parse_snapshot( + r#"{ + "quota_snapshots": { + "premium_interactions": { + "entitlement": 500, + "remaining": 0, + "percent_remaining": 0, + "quota_id": "premium_interactions" + } + } + }"#, + ); + + assert_used(&usage.primary, 100.0); +} + +#[test] +fn keeps_additional_budget_as_extra_window() { + let usage = parse_snapshot( + r#"{ + "copilot_plan": "pro", + "quota_snapshots": { + "premium_interactions": { + "entitlement": 500, + "remaining": 250, + "quota_id": "premium_interactions" + }, + "additional_budget": { + "entitlement": 100, + "remaining": 25, + "quota_id": "additional_budget" + } + } + }"#, + ); + + assert_used(&usage.primary, 50.0); + assert_eq!(usage.extra_rate_windows.len(), 1); + assert_eq!(usage.extra_rate_windows[0].id, "additional-budget"); + assert_eq!(usage.extra_rate_windows[0].title, "Additional Budget"); + assert_used(&usage.extra_rate_windows[0].window, 75.0); +} + +#[test] +fn preserves_over_quota_percent_remaining() { + let usage = parse_snapshot( + r#"{ + "copilot_plan": "pro", + "quota_snapshots": { + "premium_interactions": { + "entitlement": 500, + "remaining": -75, + "percent_remaining": -15, + "quota_id": "premium_interactions" + } + } + }"#, + ); + + assert_eq!(usage.login_method.as_deref(), Some("Copilot Pro")); + assert_used(&usage.primary, 115.0); + assert_eq!( + usage.primary.reset_description.as_deref(), + Some("115% used") + ); + assert!(usage.primary.is_exhausted()); +} + +#[test] +fn derives_over_quota_percent_from_negative_remaining() { + let usage = parse_snapshot( + r#"{ + "quota_snapshots": { + "chat": { + "entitlement": 500, + "remaining": -75, + "quota_id": "chat" + } + } + }"#, + ); + + assert_used(&usage.primary, 115.0); + assert_eq!( + usage.primary.reset_description.as_deref(), + Some("115% used") + ); +} + +#[test] +fn normalizes_enterprise_hosts() { + assert_eq!( + normalized_api_host(Some("github.com")), + "api.github.com".to_string() + ); + assert_eq!( + normalized_api_host(Some("github.example.com")), + "api.github.example.com".to_string() + ); + assert_eq!( + normalized_api_host(Some("api.github.example.com")), + "api.github.example.com".to_string() + ); +} + +// ── A15: credits_used counter for token-billed seats (upstream #2613) ─── + +#[test] +fn decodes_credits_used_as_number_or_string() { + let usage = parse_snapshot( + r#"{ + "copilot_plan": "business", + "quota_reset_date": "2026-06-01", + "quota_snapshots": { + "premium_interactions": { + "entitlement": 300, + "remaining": 240, + "percent_remaining": 80, + "quota_id": "premium_interactions", + "credits_used": "1234.56" + } + } + }"#, + ); + let extra = &usage.extra_rate_windows; + assert!( + extra.iter().any(|w| w.id == "ai-credits" + && w.window.reset_description.as_deref() == Some("1234.56 AI credits used")), + "{extra:?}" + ); +} + +#[test] +fn configured_seat_allowance_adds_a_numeric_credit_window() { + let usage = parse_with_seat( + r#"{ + "copilot_plan": "business", + "quota_snapshots": { + "premium_interactions": { + "entitlement": 300, + "remaining": 240, + "percent_remaining": 80, + "quota_id": "premium_interactions", + "credits_used": 50 + } + } + }"#, + Some(200.0), + ) + .unwrap(); + + let seat = seat_window(&usage).expect("configured seat-credit window"); + assert_used(&seat.window, 25.0); + assert!(!seat.window.is_informational); + assert_eq!(seat.title, "Credits used"); +} + +#[test] +fn missing_primary_quota_is_informational_when_seat_credit_is_available() { + let usage = parse_with_seat( + r#"{ + "copilot_plan": "business", + "quota_snapshots": { + "additional_budget": { + "credits_used": 50 + } + } + }"#, + Some(200.0), + ) + .unwrap(); + + assert!(usage.primary.is_informational); + assert!(seat_window(&usage).is_some()); +} + +#[test] +fn non_finite_derived_seat_credit_percentage_is_omitted() { + let usage = parse_with_seat( + r#"{ + "copilot_plan": "business", + "quota_snapshots": { + "premium_interactions": { + "credits_used": 1e308 + } + } + }"#, + Some(1e-308), + ) + .unwrap(); + + assert!(seat_window(&usage).is_none()); +} + +#[test] +fn invalid_seat_allowance_keeps_credit_progress_unknown() { + let usage = parse_with_seat( + r#"{ + "copilot_plan": "business", + "token_based_billing": true, + "quota_snapshots": { + "premium_interactions": { + "entitlement": 0, + "remaining": 0, + "credits_used": 50 + } + } + }"#, + Some(0.0), + ) + .unwrap(); + + assert!(usage.primary.is_informational); + assert!(seat_window(&usage).is_none()); +} + +#[test] +fn zero_entitlement_business_seat_surfaces_credits_counter() { + let usage = parse_snapshot( + r#"{ + "copilot_plan": "business", + "token_based_billing": true, + "quota_reset_date": "2026-06-01", + "quota_snapshots": { + "premium_interactions": { + "entitlement": 0, + "remaining": 0, + "percent_remaining": 100, + "quota_id": "premium_interactions", + "credits_used": 1234 + } + } + }"#, + ); + // Not an error anymore: informational counter row without a fake bar. + assert!(usage.primary.is_informational); + assert_eq!( + usage.primary.reset_description.as_deref(), + Some("1234 AI credits used") + ); + assert!(usage.primary.resets_at.is_some()); + assert_eq!(usage.login_method.as_deref(), Some("Copilot Business")); +} + +#[test] +fn placeholder_snapshot_still_carries_its_credits_counter() { + // Upstream carriesCreditsCounter: a placeholder cannot become a window, + // but its absolute counter is real consumption and must survive. + let usage = parse_snapshot( + r#"{ + "copilot_plan": "business", + "token_based_billing": true, + "quota_snapshots": { + "premium_interactions": { + "entitlement": 0, + "remaining": 0, + "percent_remaining": 0, + "quota_id": "", + "placeholder": true, + "credits_used": 42.5 + } + } + }"#, + ); + assert!(usage.primary.is_informational); + assert_eq!( + usage.primary.reset_description.as_deref(), + Some("42.50 AI credits used") + ); +} + +#[test] +fn premium_credits_counter_wins_over_chat() { + let usage = parse_snapshot( + r#"{ + "copilot_plan": "pro", + "quota_snapshots": { + "chat": { + "entitlement": 100, + "remaining": 75, + "percent_remaining": 75, + "quota_id": "chat", + "credits_used": 1 + }, + "premium_interactions": { + "entitlement": 300, + "remaining": 240, + "percent_remaining": 80, + "quota_id": "premium_interactions", + "credits_used": 7 + } + } + }"#, + ); + let credits_row = usage + .extra_rate_windows + .iter() + .find(|w| w.id == "ai-credits") + .expect("ai-credits row"); + assert_eq!( + credits_row.window.reset_description.as_deref(), + Some("7 AI credits used") + ); + // Windows still render normally next to the counter. + assert!(credits_row.window.is_informational); + assert_used(&usage.primary, 20.0); +} + +#[test] +fn business_seat_without_credits_keeps_existing_error() { + let err = parse_snapshot_result( + r#"{ + "copilot_plan": "business", + "token_based_billing": true + }"#, + ); + assert!(err.is_err()); +} From f036d672b8a4cc9d2867f95b70203bf41b481510 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:54:56 +0700 Subject: [PATCH 41/44] Split Codex API client into credentials, parse and reset-credits modules --- rust/src/providers/codex/api.rs | 1925 +---------------- .../codex/api/credential_retry_tests.rs | 2 + rust/src/providers/codex/api/credentials.rs | 287 +++ rust/src/providers/codex/api/parse.rs | 463 ++++ rust/src/providers/codex/api/reset_credits.rs | 249 +++ rust/src/providers/codex/api/tests.rs | 940 ++++++++ 6 files changed, 1954 insertions(+), 1912 deletions(-) create mode 100644 rust/src/providers/codex/api/credentials.rs create mode 100644 rust/src/providers/codex/api/parse.rs create mode 100644 rust/src/providers/codex/api/reset_credits.rs create mode 100644 rust/src/providers/codex/api/tests.rs diff --git a/rust/src/providers/codex/api.rs b/rust/src/providers/codex/api.rs index 185ba2c53a0..b376525659d 100755 --- a/rust/src/providers/codex/api.rs +++ b/rust/src/providers/codex/api.rs @@ -3,50 +3,25 @@ //! Uses OAuth tokens stored by the Codex CLI in ~/.codex/auth.json use super::{pat, weekly_reset}; -use crate::core::{ - CostSnapshot, NamedRateWindow, ProviderError, RateWindow, RateWindowCadence, UsageSnapshot, -}; -use base64::Engine; -use chrono::{DateTime, TimeZone, Utc}; -use serde::Deserialize; -use std::collections::HashMap; +use crate::core::{CostSnapshot, ProviderError, UsageSnapshot}; +use chrono::Utc; use std::path::PathBuf; -use std::sync::{Arc, Mutex, OnceLock}; -use std::time::{Duration, Instant, SystemTime}; -use tokio::sync::Mutex as AsyncMutex; +use std::time::Instant; + +mod credentials; +mod parse; +mod reset_credits; + +use credentials::CodexCredentials; +use parse::format_plan_type; +use reset_credits::apply_reset_credits_window; +pub(super) use reset_credits::{ResetCredit, ResetCredits, next_available_reset_credit_expiry}; #[path = "subscription.rs"] mod subscription; const DEFAULT_BASE_URL: &str = "https://chatgpt.com/backend-api"; const USAGE_PATH: &str = "/wham/usage"; -const RESET_CREDITS_PATH: &str = "/wham/rate-limit-reset-credits"; -const CREDENTIAL_CACHE_TTL: Duration = Duration::from_secs(5); -/// Upstream 0.69.0 #4088: the Codex CLI owns `auth.json` and may be publishing a -/// replacement while we read it. A failed or stale read is repeated up to this many -/// times, `CREDENTIAL_READ_RETRY_DELAY` apart, before the error is reported. -const CREDENTIAL_READ_RETRIES: u32 = 2; -const CREDENTIAL_READ_RETRY_DELAY: Duration = Duration::from_millis(50); -const RESET_CREDITS_CACHE_TTL: Duration = Duration::from_secs(600); -const EXTERNAL_OAUTH_REFRESH_WINDOW: chrono::TimeDelta = chrono::Duration::minutes(5); - -static CREDENTIAL_CACHE: OnceLock>> = OnceLock::new(); -static RESET_CREDITS_CACHE: OnceLock>>>> = - OnceLock::new(); - -#[derive(Default)] -struct ResetCreditsCache { - loaded_at: Option, - value: Option, - confirmation_failure_at: Option, -} - -impl ResetCreditsCache { - fn confirmation_failed_recently(&self) -> bool { - self.confirmation_failure_at - .is_some_and(|failed| failed.elapsed() < RESET_CREDITS_CACHE_TTL) - } -} /// Codex API client pub struct CodexApi { @@ -320,360 +295,6 @@ impl CodexApi { Ok((usage, cost, reset_credits)) } - fn reset_credits_cache_slot( - &self, - creds: &CodexCredentials, - base_url: &str, - ) -> Arc> { - // The Codex home is part of the scope: two homes never share an - // observation, even when they hold the same account and token. - let auth_path = self.get_auth_path(); - let home = weekly_reset::scope_key(None, &auth_path); - let account = weekly_reset::scope_key(creds.account_id.as_deref(), &auth_path); - let token = weekly_reset::scope_key(Some(&creds.access_token), &auth_path); - let key = format!( - "{}|{home}|{account}|{token}", - base_url.trim_end_matches('/') - ); - let cache = RESET_CREDITS_CACHE.get_or_init(|| Mutex::new(HashMap::new())); - let mut cache = cache - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - Arc::clone( - cache - .entry(key) - .or_insert_with(|| Arc::new(AsyncMutex::new(ResetCreditsCache::default()))), - ) - } - - async fn fetch_rate_limit_reset_credits_cached( - &self, - creds: &CodexCredentials, - base_url: &str, - ) -> Option { - let slot = self.reset_credits_cache_slot(creds, base_url); - let mut cache = slot.lock().await; - if cache - .loaded_at - .is_some_and(|loaded| loaded.elapsed() < RESET_CREDITS_CACHE_TTL) - { - return cache.value.clone(); - } - cache.value = self - .fetch_rate_limit_reset_credits(creds, base_url) - .await - .ok(); - cache.loaded_at = Some(Instant::now()); - cache.confirmation_failure_at = None; - cache.value.clone() - } - - /// Reset credits for the initial weekly-reset decision. A pending delayed - /// candidate is revalidated against the current inventory, which must be - /// observed after the candidate was stored: the ten-minute cache can still - /// hold the very observation that created it, so that case reads fresh. - async fn initial_reset_credits( - &self, - creds: &CodexCredentials, - base_url: &str, - started: Instant, - observed: Option, - candidate_pending: bool, - ) -> Option { - if !candidate_pending { - return observed; - } - self.fresh_reset_credits_for_confirmation(creds, base_url, started) - .await - } - - async fn fresh_reset_credits_for_confirmation( - &self, - creds: &CodexCredentials, - base_url: &str, - started: Instant, - ) -> Option { - let slot = self.reset_credits_cache_slot(creds, base_url); - let mut cache = slot.lock().await; - cache.value.as_ref()?; - if cache.confirmation_failed_recently() { - return None; - } - if cache.loaded_at.is_some_and(|loaded| loaded >= started) { - return cache.value.clone(); - } - self.refetch_reset_credits_into(&mut cache, creds, base_url) - .await - } - - async fn fetch_rate_limit_reset_credits_fresh( - &self, - creds: &CodexCredentials, - base_url: &str, - ) -> Option { - let slot = self.reset_credits_cache_slot(creds, base_url); - let mut cache = slot.lock().await; - if cache.confirmation_failed_recently() { - return None; - } - self.refetch_reset_credits_into(&mut cache, creds, base_url) - .await - } - - /// A failed read keeps the cached inventory but blocks further - /// confirmation reads for one cache TTL. - async fn refetch_reset_credits_into( - &self, - cache: &mut ResetCreditsCache, - creds: &CodexCredentials, - base_url: &str, - ) -> Option { - let fresh = self - .fetch_rate_limit_reset_credits(creds, base_url) - .await - .ok(); - if let Some(value) = fresh.as_ref() { - cache.value = Some(value.clone()); - cache.loaded_at = Some(Instant::now()); - cache.confirmation_failure_at = None; - } else { - cache.confirmation_failure_at = Some(Instant::now()); - } - fresh - } - - async fn fetch_rate_limit_reset_credits( - &self, - creds: &CodexCredentials, - base_url: &str, - ) -> Result { - let response = self - .authed_get( - &format!("{}{}", base_url, RESET_CREDITS_PATH), - &creds.access_token, - creds.account_id.as_deref(), - ) - .send() - .await?; - if !response.status().is_success() { - return Err(super::authenticated_http_error(response, "Codex reset credits").await); - } - decode_reset_credits(&response.bytes().await?) - } - - /// Load credentials, tolerating a brief owner publication of `auth.json`. - /// - /// Upstream 0.69.0 #4088 (`CodexOAuthFetchStrategy.loadCredentials` on the - /// usage path, `retryStale: true`): every failed read is repeated. That covers - /// a missing (`NotInstalled`), unreadable (`Other`), malformed or incomplete - /// (`Parse`) file, and a credential the gate rejects as stale (`AuthRequired`, - /// such as a token inside its renewal window), because the CLI may be - /// publishing its renewal. This only rereads the file: no token is redeemed, - /// nothing is written, and the credential cache semantics are unchanged. After - /// the last read the error keeps its category, so unchanged stale credentials - /// still need their owner's renewal. - async fn load_credentials(&self) -> Result { - Self::reread_during_owner_publication(|| self.load_credentials_once()).await - } - - /// The bounded reread behind [`Self::load_credentials`]: `read` runs up to - /// `1 + CREDENTIAL_READ_RETRIES` times, `CREDENTIAL_READ_RETRY_DELAY` apart, - /// until it succeeds, and the last result is returned unchanged. Dropping the - /// returned future cancels the pending delay and any further read (upstream - /// checks task cancellation before each read). - async fn reread_during_owner_publication( - mut read: impl FnMut() -> Result, - ) -> Result { - let mut retries_remaining = CREDENTIAL_READ_RETRIES; - loop { - match read() { - Err(_) if retries_remaining > 0 => { - retries_remaining -= 1; - tokio::time::sleep(CREDENTIAL_READ_RETRY_DELAY).await; - } - result => return result, - } - } - } - - fn load_credentials_once(&self) -> Result { - let auth_path = self.get_auth_path(); - - let metadata = - std::fs::metadata(&auth_path).map_err(|error| self.credential_file_error(error))?; - let modified = metadata.modified().ok(); - if let Some(cached) = Self::cached_credentials(&auth_path, modified) { - Self::enforce_external_oauth_gate(&cached)?; - return Ok(cached); - } - - let content = std::fs::read_to_string(&auth_path) - .map_err(|error| self.credential_file_error(error))?; - - let credentials = Self::parse_credentials_json(&content)?; - Self::enforce_external_oauth_gate(&credentials)?; - Self::store_cached_credentials(auth_path, modified, credentials.clone()); - Ok(credentials) - } - - fn missing_credentials_error(&self) -> ProviderError { - // Upstream 0.50.0 #2679: when the CLI targets Amazon Bedrock or - // another custom backend without ChatGPT auth, sign-in guidance - // is wrong — rate limits simply are not available there. - if self.uses_custom_backend() { - return ProviderError::NotInstalled( - "Codex uses a custom backend (chatgpt_base_url / model_provider) without \ - ChatGPT auth. ChatGPT rate limits are unavailable for this setup." - .to_string(), - ); - } - - ProviderError::NotInstalled( - "Codex auth.json not found. Run `codex login` in a terminal to sign in.".to_string(), - ) - } - - fn credential_file_error(&self, error: std::io::Error) -> ProviderError { - if error.kind() == std::io::ErrorKind::NotFound { - return self.missing_credentials_error(); - } - - ProviderError::Other(format!("Failed to read Codex credentials: {error}")) - } - - fn parse_credentials_json(content: &str) -> Result { - let json: serde_json::Value = serde_json::from_str(content) - .map_err(|e| ProviderError::Parse(format!("Invalid Codex credentials JSON: {}", e)))?; - - // Check for OPENAI_API_KEY first - if let Some(api_key) = json.get("OPENAI_API_KEY").and_then(|v| v.as_str()) { - let trimmed = api_key.trim(); - if !trimmed.is_empty() { - return Ok(CodexCredentials { - access_token: trimmed.to_string(), - account_id: None, - is_external_oauth: false, - access_token_expires_at: None, - last_refresh: None, - }); - } - } - - // Otherwise, look for tokens object (external OAuth source) - let tokens = json.get("tokens").ok_or_else(|| { - ProviderError::Parse("Codex auth.json exists but contains no tokens.".to_string()) - })?; - - let access_token = tokens - .get("access_token") - .and_then(|v| v.as_str()) - .filter(|s| !s.is_empty()) - .ok_or_else(|| { - ProviderError::Parse("Missing access_token in Codex credentials".to_string()) - })? - .to_string(); - - let account_id = tokens - .get("account_id") - .and_then(|v| v.as_str()) - .filter(|s| !s.is_empty()) - .map(|s| s.to_string()); - - // Upstream 0.50.1 #2944: an OAuth token set with a refresh_token is an - // external (CLI-owned) OAuth source. The `last_refresh` timestamp is - // retained only as provenance for the opt-in safety gate. - let has_refresh_token = tokens - .get("refresh_token") - .and_then(|v| v.as_str()) - .is_some_and(|s| !s.trim().is_empty()); - let last_refresh = json - .get("last_refresh") - .and_then(|v| v.as_str()) - .and_then(parse_timestamp); - - let access_token_expires_at = parse_access_token_expiry(&access_token); - - Ok(CodexCredentials { - access_token, - account_id, - is_external_oauth: has_refresh_token, - access_token_expires_at, - last_refresh, - }) - } - - /// Upstream 0.50.1 #2944: when `codex_external_oauth_sources_allowed` is - /// OFF (the default), external OAuth credential files without refresh - /// provenance fail closed instead of being used silently. An external - /// OAuth source is an auth.json `tokens` object with a `refresh_token` - /// (CLI-owned OAuth, not an API key). Win-CodexBar never refreshes or - /// writes this source: the gate only decides whether the read-only usage - /// request may use it. When the access token is a JWT, its native expiry - /// is the validity authority; opaque tokens are sent to the server. - fn enforce_external_oauth_gate(credentials: &CodexCredentials) -> Result<(), ProviderError> { - if !credentials.is_external_oauth { - return Ok(()); - } - // The opt-in only matters without refresh provenance. Skip the settings - // load otherwise: credential reads repeat while the owner publishes. - let external_sources_allowed = credentials.last_refresh.is_some() - || crate::settings::Settings::load().codex_external_oauth_sources_allowed; - Self::enforce_external_oauth_gate_at(credentials, external_sources_allowed, Utc::now()) - } - - fn enforce_external_oauth_gate_at( - credentials: &CodexCredentials, - external_sources_allowed: bool, - now: DateTime, - ) -> Result<(), ProviderError> { - if !credentials.is_external_oauth { - return Ok(()); - } - if !external_sources_allowed && credentials.last_refresh.is_none() { - return Err(ProviderError::AuthRequired); - } - if let Some(expires_at) = credentials.access_token_expires_at - && expires_at - now <= EXTERNAL_OAUTH_REFRESH_WINDOW - { - return Err(ProviderError::AuthRequired); - } - Ok(()) - } - - fn credential_cache() -> &'static Mutex> { - CREDENTIAL_CACHE.get_or_init(|| Mutex::new(None)) - } - - fn cached_credentials( - path: &std::path::Path, - modified: Option, - ) -> Option { - let guard = Self::credential_cache().lock().ok()?; - let cached = guard.as_ref()?; - if cached.path == path - && cached.modified == modified - && cached.loaded_at.elapsed() <= CREDENTIAL_CACHE_TTL - { - return Some(cached.credentials.clone()); - } - None - } - - fn store_cached_credentials( - path: PathBuf, - modified: Option, - credentials: CodexCredentials, - ) { - if let Ok(mut guard) = Self::credential_cache().lock() { - *guard = Some(CachedCodexCredentials { - path, - modified, - loaded_at: Instant::now(), - credentials, - }); - } - } - fn get_auth_path(&self) -> PathBuf { self.codex_dir().join("auth.json") } @@ -709,355 +330,6 @@ impl CodexApi { }; parse_chatgpt_base_url(&content).is_some() || config_uses_non_chatgpt_provider(&content) } - - fn build_result_from_json( - &self, - json: &serde_json::Value, - ) -> Result<(UsageSnapshot, Option), ProviderError> { - // Extract plan type - let plan_type = json - .get("plan_type") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - - // Extract rate limit info - handle multiple possible structures - let (primary, secondary, monthly, code_review, code_review_verified) = - self.extract_rate_limits(json); - - // Build login method string - let login_method = plan_type.as_deref().map(format_plan_type); - - let mut usage = UsageSnapshot::new(primary); - if let Some(sec) = secondary { - usage = usage.with_secondary(sec); - } - // F5 (upstream 0.48.0): monthly (30-day) windows go to tertiary so the - // bridge and frontend can show a monthly reset instead of swallowing it. - if let Some(mo) = monthly { - usage = usage.with_tertiary(mo); - } - if let Some(cr) = code_review { - usage = if code_review_verified { - usage.with_code_review(cr) - } else { - usage.with_model_specific(cr) - }; - } - for extra in self.extract_additional_rate_limits(json) { - usage.extra_rate_windows.push(extra); - } - if let Some(method) = login_method { - usage = usage.with_login_method(method); - } - - // Extract credits if present - let cost = self.extract_credits(json); - - Ok((usage, cost)) - } - - #[cfg(test)] - pub(crate) fn build_result_from_json_for_test( - &self, - json: &serde_json::Value, - ) -> Result<(UsageSnapshot, Option), ProviderError> { - self.build_result_from_json(json) - } - - fn extract_rate_limits( - &self, - json: &serde_json::Value, - ) -> ( - RateWindow, - Option, - Option, - Option, - bool, - ) { - // Try rate_limit object - if let Some(rate_limit) = json.get("rate_limit") { - let primary_opt = rate_limit - .get("primary_window") - .and_then(|w| self.parse_window_if_present(w)); - - let secondary_opt = rate_limit - .get("secondary_window") - .and_then(|w| self.parse_window_if_present(w)); - - let code_review = rate_limit - .get("code_review_window") - .and_then(|w| self.parse_window_if_present(w)); - - let (primary, secondary) = normalize_named_windows(primary_opt, secondary_opt); - - // F5 (upstream 0.48.0): named windows carry only session/weekly/code_review. - // Monthly is extracted separately (from array windows) — return None here. - let code_review_verified = code_review.is_some(); - return (primary, secondary, None, code_review, code_review_verified); - } - - // Try rate_limits array - if let Some(rate_limits) = json.get("rate_limits").and_then(|v| v.as_array()) { - let windows = rate_limits - .iter() - .filter_map(|window| self.parse_window_if_present(window)) - .collect::>(); - let (primary, secondary, monthly, code_review) = normalize_array_windows(windows); - // F5 (upstream 0.48.0): route monthly to its own tertiary lane. - return (primary, secondary, monthly, code_review, false); - } - - // Try direct fields - let used_percent = json - .get("used_percent") - .or_else(|| json.get("usage_percent")) - .and_then(json_f64); - let primary = RateWindow::new(used_percent.unwrap_or(0.0)) - .with_usage_known(valid_used_percent(used_percent)); - - (primary, None, None, None, false) - } - - fn parse_window(&self, window: &serde_json::Value) -> RateWindow { - let used_percent = window - .get("used_percent") - .or_else(|| window.get("usage_percent")) - .and_then(json_f64); - - let window_minutes = window - .get("limit_window_seconds") - .and_then(json_i64) - .and_then(|seconds| u32::try_from(seconds / 60).ok()); - - let reset_at = window - .get("reset_at") - .and_then(json_i64) - .and_then(|ts| Utc.timestamp_opt(ts, 0).single()); - - RateWindow::with_details( - used_percent.unwrap_or(0.0), - window_minutes, - reset_at, - format_reset_countdown(reset_at), - ) - .with_usage_known(valid_used_percent(used_percent)) - } - - fn parse_window_if_present(&self, window: &serde_json::Value) -> Option { - (!window.is_null() && !is_placeholder_window(window)).then(|| self.parse_window(window)) - } - - fn extract_additional_rate_limits(&self, json: &serde_json::Value) -> Vec { - json.get("additional_rate_limits") - .and_then(|v| v.as_array()) - .into_iter() - .flatten() - .filter_map(|entry| self.parse_additional_rate_limit(entry)) - .collect() - } - - fn parse_additional_rate_limit(&self, entry: &serde_json::Value) -> Option { - let metered_feature = entry - .get("metered_feature") - .and_then(|v| v.as_str()) - .map(str::trim) - .filter(|v| !v.is_empty()); - let limit_name = entry - .get("limit_name") - .and_then(|v| v.as_str()) - .map(str::trim) - .filter(|v| !v.is_empty()); - - let rate_limit = entry.get("rate_limit").unwrap_or(entry); - let primary = rate_limit.get("primary_window"); - let secondary = rate_limit.get("secondary_window"); - let window = primary.or(secondary)?; - if is_placeholder_window(window) { - return None; - } - - let parsed = self.parse_window(window); - let feature = metered_feature.unwrap_or_default(); - let limit = limit_name.unwrap_or_default(); - let is_spark = feature.eq_ignore_ascii_case("codex_spark") - || feature.eq_ignore_ascii_case("spark") - || limit.to_ascii_lowercase().contains("spark"); - - if is_spark { - let is_weekly = secondary.is_some() && primary.is_none() - || parsed - .window_minutes - .is_some_and(|mins| mins >= 7 * 24 * 60); - let (id, title) = if is_weekly { - ("codex-spark-weekly", "Codex Spark Weekly") - } else { - ("codex-spark", "Codex Spark 5-hour") - }; - return Some(NamedRateWindow::new(id, title, parsed)); - } - - let label = limit_name.or(metered_feature)?; - let slug = slugify(label); - if slug.is_empty() { - return None; - } - - Some(NamedRateWindow::new( - format!("codex-{slug}"), - titleize_limit_label(label), - parsed, - )) - } - - fn extract_credits(&self, json: &serde_json::Value) -> Option { - let credits = json.get("credits")?; - - let has_credits = credits - .get("has_credits") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - - if !has_credits { - return None; - } - - let unlimited = credits - .get("unlimited") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - - if unlimited { - return None; - } - - let balance = credits - .get("balance") - .and_then(|v| v.as_f64()) - .unwrap_or(0.0); - - Some(CostSnapshot::new(balance, "USD", "Credits")) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum CodexWindowRole { - Session, - Weekly, - Monthly, - Unknown, -} - -fn codex_window_role(window: &RateWindow) -> CodexWindowRole { - match window - .window_minutes - .map(RateWindowCadence::from_minutes) - .unwrap_or(RateWindowCadence::Unknown) - { - RateWindowCadence::Session => CodexWindowRole::Session, - RateWindowCadence::Monthly => CodexWindowRole::Monthly, - RateWindowCadence::Weekly => CodexWindowRole::Weekly, - RateWindowCadence::Unknown => CodexWindowRole::Unknown, - } -} - -/// Normalize the named `primary_window`/`secondary_window` fields by duration. -fn normalize_named_windows( - primary: Option, - secondary: Option, -) -> (RateWindow, Option) { - match (primary, secondary) { - (None, None) => (RateWindow::no_active_session(), None), - (Some(window), None) | (None, Some(window)) => { - if codex_window_role(&window) == CodexWindowRole::Weekly { - (RateWindow::no_active_session(), Some(window)) - } else { - (window, None) - } - } - (Some(primary), Some(secondary)) => { - match (codex_window_role(&primary), codex_window_role(&secondary)) { - (CodexWindowRole::Weekly, CodexWindowRole::Session) => (secondary, Some(primary)), - (CodexWindowRole::Weekly, CodexWindowRole::Unknown) => { - (RateWindow::no_active_session(), Some(primary)) - } - (CodexWindowRole::Unknown, CodexWindowRole::Session) => (secondary, Some(primary)), - _ => (primary, Some(secondary)), - } - } - } -} - -/// Normalize an array of Codex windows without relying on the API's ordering. -/// -/// Returns (session, weekly, monthly, code_review). F5 (upstream 0.48.0): -/// monthly (30-day) windows are routed to their own lane so surfaces can -/// display a monthly reset instead of swallowing it into the weekly label. -fn normalize_array_windows( - windows: Vec, -) -> ( - RateWindow, - Option, - Option, - Option, -) { - if windows.is_empty() { - return (RateWindow::no_active_session(), None, None, None); - } - - // Preserve the old positional fallback when the API provides no role - // metadata at all. There is no safe way to infer session vs weekly then. - if !windows - .iter() - .any(|window| codex_window_role(window) != CodexWindowRole::Unknown) - { - let mut windows = windows.into_iter(); - return ( - windows.next().unwrap_or_else(RateWindow::no_active_session), - windows.next(), - windows.next(), - windows.next(), - ); - } - - let mut session = None; - let mut weekly = None; - let mut monthly = None; - let mut remaining = Vec::new(); - - for window in windows { - match codex_window_role(&window) { - CodexWindowRole::Session if session.is_none() => session = Some(window), - CodexWindowRole::Weekly if weekly.is_none() => weekly = Some(window), - CodexWindowRole::Monthly if monthly.is_none() => monthly = Some(window), - _ => remaining.push(window), - } - } - - ( - session.unwrap_or_else(RateWindow::no_active_session), - weekly, - monthly, - remaining.into_iter().next(), - ) -} - -fn format_plan_type(plan_type: &str) -> String { - match plan_type { - "guest" => "Guest".to_string(), - "free" => "ChatGPT Free".to_string(), - "go" => "Codex Go".to_string(), - "plus" => "ChatGPT Plus".to_string(), - "pro" => "ChatGPT Pro".to_string(), - "pro_lite" | "prolite" | "pro-lite" => "Pro Lite".to_string(), - "team" => "ChatGPT Team".to_string(), - "business" => "ChatGPT Business".to_string(), - "enterprise" => "ChatGPT Enterprise".to_string(), - "education" | "edu" => "ChatGPT Education".to_string(), - "free_workspace" | "freeWorkspace" => "Free Workspace".to_string(), - "quorum" => "Codex Quorum".to_string(), - "k12" => "Codex K12".to_string(), - other => format!("ChatGPT {}", capitalize(other)), - } } impl Default for CodexApi { @@ -1066,236 +338,6 @@ impl Default for CodexApi { } } -// --- Data structures --- - -#[derive(Clone)] -struct CodexCredentials { - access_token: String, - account_id: Option, - /// True when the source is an external OAuth token set (has a - /// `refresh_token`), as opposed to an `OPENAI_API_KEY`. The Codex CLI owns - /// refresh and persistence for this source; this app only reads it. The - /// `codex_external_oauth_sources_allowed` setting gates that read - /// (upstream 0.50.1 #2944). - is_external_oauth: bool, - /// Native access-token JWT expiry. When available, this is authoritative - /// for validity; the Codex CLI still owns the refresh lifecycle. - access_token_expires_at: Option>, - /// `last_refresh` timestamp from auth.json, when present. Its presence - /// supplies provenance when the external-source opt-in setting is OFF; - /// its age is not an access-token expiry signal. - last_refresh: Option>, -} - -struct CachedCodexCredentials { - path: PathBuf, - modified: Option, - loaded_at: Instant, - credentials: CodexCredentials, -} - -#[derive(Debug, Clone, Deserialize)] -pub(super) struct ResetCredit { - #[serde(default)] - pub(super) id: Option, - #[serde(default, alias = "resetType")] - pub(super) reset_type: Option, - #[serde(default)] - pub(super) status: Option, - #[serde(default)] - pub(super) expires_at: Option, -} - -#[derive(Debug, Clone, Deserialize)] -pub(super) struct ResetCredits { - #[serde(default)] - pub(super) credits: Vec, - pub(super) available_count: u32, -} - -fn decode_reset_credits(data: &[u8]) -> Result { - serde_json::from_slice(data) - .map_err(|e| ProviderError::Parse(format!("Failed to parse Codex reset credits: {e}"))) -} - -fn parse_credit_expiry(raw: &str) -> Option> { - DateTime::parse_from_rfc3339(raw) - .ok() - .map(|dt| dt.with_timezone(&Utc)) -} - -fn is_available_credit(credit: &ResetCredit) -> bool { - match credit.status.as_deref() { - None | Some("") => true, - Some(status) => status.eq_ignore_ascii_case("available"), - } -} - -pub(super) fn next_available_reset_credit_expiry( - credits: &[ResetCredit], - now: DateTime, -) -> Option> { - credits - .iter() - .filter(|credit| is_available_credit(credit)) - .filter_map(|credit| credit.expires_at.as_deref().and_then(parse_credit_expiry)) - .filter(|expires_at| *expires_at > now) - .min() -} - -fn reset_credits_rate_window(reset: &ResetCredits, now: DateTime) -> RateWindow { - let description = format!( - "{} reset credit{} available", - reset.available_count, - if reset.available_count == 1 { "" } else { "s" } - ); - let mut window = RateWindow::informational(description); - window.resets_at = next_available_reset_credit_expiry(&reset.credits, now); - window -} - -fn apply_reset_credits_window( - mut usage: UsageSnapshot, - reset: Option<&ResetCredits>, -) -> UsageSnapshot { - usage - .extra_rate_windows - .retain(|window| window.id != "reset-credits"); - if let Some(reset) = reset.filter(|reset| reset.available_count > 0) { - let window = reset_credits_rate_window(reset, Utc::now()); - usage = usage.with_extra_rate_window("reset-credits", "Reset credits", window); - } - usage -} - -// --- Helper functions --- - -/// Parse the native `exp` claim from an access-token JWT. Opaque or malformed -/// tokens return `None` and are handled by the read-only usage request. -fn parse_access_token_expiry(token: &str) -> Option> { - let payload = token.split('.').nth(1)?; - let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD - .decode(payload) - .or_else(|_| base64::engine::general_purpose::URL_SAFE.decode(payload)) - .ok()?; - let json: serde_json::Value = serde_json::from_slice(&decoded).ok()?; - let exp = json.get("exp")?.as_i64()?; - Utc.timestamp_opt(exp, 0).single() -} -fn parse_timestamp(raw: &str) -> Option> { - let trimmed = raw.trim(); - if trimmed.is_empty() { - return None; - } - DateTime::parse_from_rfc3339(trimmed) - .ok() - .map(|dt| dt.with_timezone(&Utc)) - .or_else(|| { - chrono::NaiveDateTime::parse_from_str(trimmed, "%Y-%m-%dT%H:%M:%S%.f") - .ok() - .map(|naive| DateTime::::from_naive_utc_and_offset(naive, Utc)) - }) -} - -fn json_f64(value: &serde_json::Value) -> Option { - value - .as_f64() - .or_else(|| value.as_i64().map(|value| value as f64)) - .or_else(|| value.as_str()?.trim().parse::().ok()) -} - -fn json_i64(value: &serde_json::Value) -> Option { - value - .as_i64() - .or_else(|| value.as_str()?.trim().parse::().ok()) -} - -fn valid_used_percent(value: Option) -> bool { - value.is_some_and(|value| value.is_finite() && (0.0..=100.0).contains(&value)) -} - -fn is_placeholder_window(window: &serde_json::Value) -> bool { - let has_usage = window - .get("used_percent") - .or_else(|| window.get("usage_percent")) - .and_then(json_f64) - .is_some(); - let has_duration = window - .get("limit_window_seconds") - .and_then(json_i64) - .is_some(); - let has_reset = window.get("reset_at").and_then(json_i64).is_some(); - - !has_usage && !has_duration && !has_reset -} - -fn slugify(label: &str) -> String { - let mut slug = String::new(); - let mut previous_dash = false; - - for ch in label.chars() { - if ch.is_ascii_alphanumeric() { - slug.push(ch.to_ascii_lowercase()); - previous_dash = false; - } else if !previous_dash && !slug.is_empty() { - slug.push('-'); - previous_dash = true; - } - } - - while slug.ends_with('-') { - slug.pop(); - } - slug -} - -fn titleize_limit_label(label: &str) -> String { - label - .split(['_', '-', ' ']) - .filter(|part| !part.is_empty()) - .map(|part| { - let mut chars = part.chars(); - match chars.next() { - Some(first) => first - .to_uppercase() - .chain(chars.flat_map(char::to_lowercase)) - .collect(), - None => String::new(), - } - }) - .collect::>() - .join(" ") -} - -fn format_reset_countdown(reset_at: Option>) -> Option { - let dt = reset_at?; - let now = Utc::now(); - if dt <= now { - return Some("now".to_string()); - } - let diff = dt - now; - let total_mins = diff.num_minutes(); - let hours = diff.num_hours(); - let mins = total_mins % 60; - if hours >= 24 { - let days = hours / 24; - let rem_h = hours % 24; - if rem_h == 0 { - Some(format!("{}d", days)) - } else { - Some(format!("{}d {}h", days, rem_h)) - } - } else if hours > 0 { - if mins == 0 { - Some(format!("{}h", hours)) - } else { - Some(format!("{}h {}m", hours, mins)) - } - } else { - Some(format!("{}m", mins)) - } -} - /// Whether config.toml selects a non-ChatGPT model provider (e.g. Bedrock), /// meaning the CLI never authenticates against ChatGPT. fn config_uses_non_chatgpt_provider(config_content: &str) -> bool { @@ -1359,949 +401,8 @@ fn normalize_base_url(url: &str) -> String { trimmed } -fn capitalize(s: &str) -> String { - let mut chars = s.chars(); - match chars.next() { - None => String::new(), - Some(first) => first.to_uppercase().chain(chars).collect(), - } -} - #[cfg(test)] mod credential_retry_tests; #[cfg(test)] -mod tests { - use super::*; - use crate::providers::test_support::{mock_response, mock_response_expect, mock_status_expect}; - use serde_json::json; - - #[test] - fn non_chatgpt_model_provider_is_detected_for_guidance() { - // Upstream 0.50.0 #2679: Bedrock and other custom backends get - // rate-limit guidance instead of login instructions. - assert!(config_uses_non_chatgpt_provider( - "model_provider = \"bedrock\"\n" - )); - assert!(config_uses_non_chatgpt_provider( - "# relay\nmodel_provider = 'ollama'" - )); - assert!(!config_uses_non_chatgpt_provider( - "model_provider = \"openai\"" - )); - assert!(!config_uses_non_chatgpt_provider( - "model = \"gpt-5\"\napproval_policy = \"never\"" - )); - } - - #[test] - fn parses_codex_credentials_without_retaining_refresh_token() { - let credentials = CodexApi::parse_credentials_json( - r#"{ - "tokens": { - "access_token": "access", - "refresh_token": "refresh", - "account_id": "acct_123" - } - }"#, - ) - .expect("credentials"); - - assert_eq!(credentials.access_token, "access"); - assert_eq!(credentials.account_id.as_deref(), Some("acct_123")); - } - - #[test] - fn decodes_reset_credits() { - let credits = decode_reset_credits( - br#"{"available_count":2,"credits":[{"id":"a","status":"available","expires_at":"2026-08-01T12:00:00Z"}]}"#, - ) - .expect("reset credits"); - assert_eq!(credits.available_count, 2); - assert_eq!(credits.credits.len(), 1); - assert_eq!(credits.credits[0].status.as_deref(), Some("available")); - assert_eq!( - credits.credits[0].expires_at.as_deref(), - Some("2026-08-01T12:00:00Z") - ); - } - - #[test] - fn missing_reset_credit_count_is_unavailable_not_zero() { - assert!(decode_reset_credits(br#"{"credits":[]}"#).is_err()); - } - - #[test] - fn next_expiry_picks_soonest_available() { - let now = utc("2026-07-01T00:00:00Z"); - let credits = vec![ - credit(Some("available"), "2026-07-10T00:00:00Z"), - credit(Some("available"), "2026-07-05T00:00:00Z"), - credit(Some("available"), "2026-07-20T00:00:00Z"), - ]; - let expiry = next_available_reset_credit_expiry(&credits, now).expect("expiry"); - assert_eq!(expiry, utc("2026-07-05T00:00:00Z")); - } - - #[test] - fn next_expiry_skips_past_and_non_available() { - let now = utc("2026-07-01T00:00:00Z"); - let credits = vec![ - credit(Some("available"), "2026-06-01T00:00:00Z"), - credit(Some("used"), "2026-07-03T00:00:00Z"), - credit(Some("AVAILABLE"), "2026-07-08T00:00:00Z"), - credit(None, "2026-07-09T00:00:00Z"), - ]; - let expiry = next_available_reset_credit_expiry(&credits, now).expect("expiry"); - assert_eq!(expiry, utc("2026-07-08T00:00:00Z")); - } - - #[test] - fn reset_credits_window_sets_informational_and_expiry() { - let now = utc("2026-07-01T00:00:00Z"); - let reset = ResetCredits { - available_count: 2, - credits: vec![ - credit(Some("available"), "2026-07-15T12:00:00Z"), - credit(Some("available"), "2026-07-10T12:00:00Z"), - ], - }; - let window = reset_credits_rate_window(&reset, now); - assert!(window.is_informational); - assert_eq!( - window.reset_description.as_deref(), - Some("2 reset credits available") - ); - assert_eq!(window.resets_at, Some(utc("2026-07-10T12:00:00Z"))); - } - - #[test] - fn reset_credits_window_count_only_without_expiry() { - let now = utc("2026-07-01T00:00:00Z"); - let reset = ResetCredits { - available_count: 1, - credits: vec![], - }; - let window = reset_credits_rate_window(&reset, now); - assert!(window.is_informational); - assert_eq!( - window.reset_description.as_deref(), - Some("1 reset credit available") - ); - assert!(window.resets_at.is_none()); - } - - const PLUS_USAGE: &str = r#"{"plan_type":"plus","rate_limit":{"primary_window":{"used_percent":10,"limit_window_seconds":18000}}}"#; - - fn utc(value: &str) -> DateTime { - DateTime::parse_from_rfc3339(value) - .unwrap() - .with_timezone(&Utc) - } - - fn credit(status: Option<&str>, expires_at: &str) -> ResetCredit { - ResetCredit { - id: None, - reset_type: None, - status: status.map(str::to_string), - expires_at: Some(expires_at.to_string()), - } - } - - /// An unsigned JWT whose payload carries only `exp` (seconds since the epoch). - fn jwt_with_exp(exp: i64) -> String { - let payload = - base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(format!(r#"{{"exp":{exp}}}"#)); - format!("header.{payload}.signature") - } - - fn external_creds(access_token: &str, last_refresh: Option>) -> CodexCredentials { - CodexCredentials { - access_token: access_token.to_string(), - account_id: None, - is_external_oauth: true, - access_token_expires_at: None, - last_refresh, - } - } - - async fn mock_reset_credits( - server: &mut mockito::ServerGuard, - available_count: u32, - ) -> mockito::Mock { - mock_response_expect( - server, - "GET", - RESET_CREDITS_PATH, - 200, - format!(r#"{{"available_count":{available_count},"credits":[]}}"#), - 1, - ) - .await - } - - fn write_codex_home(base_url: &str) -> tempfile::TempDir { - let dir = tempfile::tempdir().expect("temp codex home"); - std::fs::write( - dir.path().join("auth.json"), - r#"{"tokens":{"access_token":"test-token","account_id":"acct_test"}}"#, - ) - .expect("auth.json"); - std::fs::write( - dir.path().join("config.toml"), - format!("chatgpt_base_url = \"{base_url}\""), - ) - .expect("config.toml"); - dir - } - - #[tokio::test] - async fn reset_credit_cache_single_flight_and_unknown_are_ten_minute_observations() { - let mut server = mockito::Server::new_async().await; - let request = mock_status_expect(&mut server, "GET", RESET_CREDITS_PATH, 503, 1).await; - let home = write_codex_home(&server.url()); - let api = CodexApi::new().with_codex_home(home.path()); - let creds = api.load_credentials().await.unwrap(); - let base = server.url(); - let (first, second) = tokio::join!( - api.fetch_rate_limit_reset_credits_cached(&creds, &base), - api.fetch_rate_limit_reset_credits_cached(&creds, &base), - ); - assert!(first.is_none() && second.is_none()); - assert!( - api.fetch_rate_limit_reset_credits_cached(&creds, &base) - .await - .is_none() - ); - request.assert_async().await; - assert!(RESET_CREDITS_CACHE_TTL == Duration::from_secs(600)); - } - - #[tokio::test] - async fn reset_credit_cache_expires_and_token_rotation_uses_new_scope() { - let mut server = mockito::Server::new_async().await; - let first = server - .mock("GET", "/wham/rate-limit-reset-credits") - .match_header("authorization", "Bearer test-token") - .expect(1) - .with_status(200) - .with_header("content-type", "application/json") - .with_body(r#"{"available_count":2,"credits":[]}"#) - .create_async() - .await; - let home = write_codex_home(&server.url()); - let api = CodexApi::new().with_codex_home(home.path()); - let mut creds = api.load_credentials().await.unwrap(); - let base = server.url(); - assert_eq!( - api.fetch_rate_limit_reset_credits_cached(&creds, &base) - .await - .unwrap() - .available_count, - 2 - ); - assert_eq!( - api.fetch_rate_limit_reset_credits_cached(&creds, &base) - .await - .unwrap() - .available_count, - 2 - ); - first.assert_async().await; - first.remove_async().await; - let second = server - .mock("GET", "/wham/rate-limit-reset-credits") - .match_header("authorization", "Bearer test-token") - .expect(1) - .with_status(200) - .with_header("content-type", "application/json") - .with_body(r#"{"available_count":1,"credits":[]}"#) - .create_async() - .await; - api.reset_credits_cache_slot(&creds, &base) - .lock() - .await - .loaded_at = Some(Instant::now() - RESET_CREDITS_CACHE_TTL); - assert_eq!( - api.fetch_rate_limit_reset_credits_cached(&creds, &base) - .await - .unwrap() - .available_count, - 1 - ); - second.assert_async().await; - second.remove_async().await; - creds.access_token = "rotated-token".into(); - let rotated = server - .mock("GET", "/wham/rate-limit-reset-credits") - .match_header("authorization", "Bearer rotated-token") - .expect(1) - .with_status(200) - .with_header("content-type", "application/json") - .with_body(r#"{"available_count":3,"credits":[]}"#) - .create_async() - .await; - assert_eq!( - api.fetch_rate_limit_reset_credits_cached(&creds, &base) - .await - .unwrap() - .available_count, - 3 - ); - rotated.assert_async().await; - } - - #[tokio::test] - async fn reset_credit_cache_is_scoped_to_the_codex_home() { - // Same base URL, account and token, different Codex homes: each home - // makes its own observation instead of reading the other's. - let mut server = mockito::Server::new_async().await; - let base = server.url(); - let first = mock_reset_credits(&mut server, 2).await; - let first_home = write_codex_home(&base); - let first_api = CodexApi::new().with_codex_home(first_home.path()); - let first_creds = first_api.load_credentials().await.unwrap(); - let first_count = first_api - .fetch_rate_limit_reset_credits_cached(&first_creds, &base) - .await - .map(|credits| credits.available_count); - assert_eq!(first_count, Some(2)); - first.assert_async().await; - first.remove_async().await; - - let second = mock_status_expect(&mut server, "GET", RESET_CREDITS_PATH, 503, 1).await; - let second_home = write_codex_home(&base); - let second_api = CodexApi::new().with_codex_home(second_home.path()); - let second_creds = second_api.load_credentials().await.unwrap(); - assert_eq!(first_creds.access_token, second_creds.access_token); - assert_eq!(first_creds.account_id, second_creds.account_id); - assert!( - second_api - .fetch_rate_limit_reset_credits_cached(&second_creds, &base) - .await - .is_none() - ); - second.assert_async().await; - } - - #[tokio::test] - async fn suspicious_weekly_reset_uses_independent_credit_observations() { - let mut server = mockito::Server::new_async().await; - let cached_response = mock_reset_credits(&mut server, 2).await; - let home = write_codex_home(&server.url()); - let api = CodexApi::new().with_codex_home(home.path()); - let creds = api.load_credentials().await.unwrap(); - let base = server.url(); - api.fetch_rate_limit_reset_credits_cached(&creds, &base) - .await - .unwrap(); - cached_response.assert_async().await; - cached_response.remove_async().await; - - let started = Instant::now(); - let initial_response = mock_reset_credits(&mut server, 1).await; - let initial = api - .fresh_reset_credits_for_confirmation(&creds, &base, started) - .await - .unwrap(); - assert_eq!(initial.available_count, 1); - initial_response.assert_async().await; - initial_response.remove_async().await; - - let confirmation_response = mock_reset_credits(&mut server, 0).await; - let confirmation = api - .fetch_rate_limit_reset_credits_fresh(&creds, &base) - .await - .unwrap(); - assert_eq!(confirmation.available_count, 0); - confirmation_response.assert_async().await; - } - - #[tokio::test] - async fn pending_delayed_candidate_revalidates_with_a_fresh_credit_observation() { - let mut server = mockito::Server::new_async().await; - let candidate_observation = mock_reset_credits(&mut server, 2).await; - let home = write_codex_home(&server.url()); - let api = CodexApi::new().with_codex_home(home.path()); - let creds = api.load_credentials().await.unwrap(); - let base = server.url(); - let cached = api - .fetch_rate_limit_reset_credits_cached(&creds, &base) - .await; - candidate_observation.assert_async().await; - candidate_observation.remove_async().await; - - // A later refresh: the ten-minute cache still holds the observation - // that created the candidate. - let started = Instant::now(); - let changed = mock_reset_credits(&mut server, 1).await; - let without_candidate = api - .initial_reset_credits(&creds, &base, started, cached.clone(), false) - .await; - assert_eq!( - without_candidate.map(|credits| credits.available_count), - Some(2) - ); - let with_candidate = api - .initial_reset_credits(&creds, &base, started, cached, true) - .await; - assert_eq!( - with_candidate.map(|credits| credits.available_count), - Some(1) - ); - changed.assert_async().await; - } - - #[tokio::test] - async fn fetch_usage_attaches_reset_credits_from_http() { - let mut server = mockito::Server::new_async().await; - let soonest = (Utc::now() + chrono::Duration::days(5)).to_rfc3339(); - let later = (Utc::now() + chrono::Duration::days(12)).to_rfc3339(); - - let usage_mock = server - .mock("GET", "/wham/usage") - .match_header("authorization", "Bearer test-token") - .with_status(200) - .with_header("content-type", "application/json") - .with_body(PLUS_USAGE) - .create_async() - .await; - - let reset_body = format!( - r#"{{"available_count":2,"credits":[ - {{"status":"available","expires_at":"{later}"}}, - {{"status":"available","expires_at":"{soonest}"}} - ]}}"# - ); - let reset_mock = server - .mock("GET", "/wham/rate-limit-reset-credits") - .match_header("authorization", "Bearer test-token") - .with_status(200) - .with_header("content-type", "application/json") - .with_body(reset_body) - .create_async() - .await; - - let home = write_codex_home(&server.url()); - let api = CodexApi::new().with_codex_home(home.path()); - let (usage, _, _) = api.fetch_usage().await.expect("fetch_usage"); - - usage_mock.assert_async().await; - reset_mock.assert_async().await; - - let extra = usage - .extra_rate_windows - .iter() - .find(|w| w.id == "reset-credits") - .expect("reset-credits window attached"); - assert_eq!(extra.title, "Reset credits"); - assert!(extra.window.is_informational); - assert_eq!( - extra.window.reset_description.as_deref(), - Some("2 reset credits available") - ); - let expected = utc(&soonest); - assert_eq!(extra.window.resets_at, Some(expected)); - } - - #[tokio::test] - async fn authenticated_codex_http_distinguishes_401_from_403() { - for (status, expects_authentication) in [(401, true), (403, false)] { - let mut server = mockito::Server::new_async().await; - let mock = server - .mock("GET", "/wham/usage") - .with_status(status) - .with_body("fixture refusal") - .create_async() - .await; - - let home = write_codex_home(&server.url()); - let api = CodexApi::new().with_codex_home(home.path()); - let error = match api.fetch_usage().await { - Ok(_) => panic!("expected HTTP {status} to fail"), - Err(error) => error, - }; - - if expects_authentication { - assert!(matches!(error, ProviderError::AuthRequired)); - } else { - let message = error.to_string(); - assert!(message.contains("403")); - assert!(message.contains("fixture refusal")); - assert!(!matches!(error, ProviderError::AuthRequired)); - } - mock.assert_async().await; - } - } - - #[tokio::test] - async fn old_opaque_external_oauth_reaches_usage_request() { - let mut server = mockito::Server::new_async().await; - let usage_mock = server - .mock("GET", "/wham/usage") - .match_header("authorization", "Bearer opaque-token") - .with_status(200) - .with_header("content-type", "application/json") - .with_body(PLUS_USAGE) - .create_async() - .await; - let reset_mock = server - .mock("GET", "/wham/rate-limit-reset-credits") - .match_header("authorization", "Bearer opaque-token") - .with_status(200) - .with_header("content-type", "application/json") - .with_body(r#"{"available_count":0,"credits":[]}"#) - .create_async() - .await; - - let creds = CodexApi::parse_credentials_json( - r#"{ - "tokens": { - "access_token": "opaque-token", - "refresh_token": "refresh", - "account_id": "acct_test" - }, - "last_refresh": "2026-01-01T00:00:00Z" - }"#, - ) - .expect("credentials"); - assert!(CodexApi::enforce_external_oauth_gate_at(&creds, false, Utc::now()).is_ok()); - - let api = CodexApi::new(); - let (usage, _, _) = api - .fetch_usage_once(&creds, &server.url()) - .await - .expect("opaque OAuth usage request"); - assert_eq!(usage.primary.used_percent, 10.0); - usage_mock.assert_async().await; - reset_mock.assert_async().await; - } - - #[tokio::test] - async fn fetch_usage_skips_reset_credits_when_available_count_zero() { - let mut server = mockito::Server::new_async().await; - - let usage_mock = mock_response(&mut server, "GET", USAGE_PATH, 200, PLUS_USAGE).await; - - let reset_mock = mock_reset_credits(&mut server, 0).await; - - let home = write_codex_home(&server.url()); - let api = CodexApi::new().with_codex_home(home.path()); - let (usage, _, _) = api.fetch_usage().await.expect("fetch_usage"); - - usage_mock.assert_async().await; - reset_mock.assert_async().await; - - assert!( - usage - .extra_rate_windows - .iter() - .all(|w| w.id != "reset-credits"), - "available_count=0 must not attach reset-credits" - ); - } - - #[test] - fn keeps_weekly_window_in_secondary_when_session_is_absent() { - let api = CodexApi::new(); - let (usage, _) = api - .build_result_from_json(&json!({ - "rate_limit": { - "secondary_window": { - "used_percent": 25, - "limit_window_seconds": 604800, - "reset_at": 1783036800 - } - } - })) - .expect("codex usage"); - - assert!(usage.primary.is_informational); - assert_eq!(usage.primary.window_minutes, Some(300)); - assert_eq!( - usage.primary.reset_description.as_deref(), - Some("No active 5h session") - ); - - let weekly = usage.secondary.expect("weekly window"); - assert!(!weekly.is_informational); - assert_eq!(weekly.used_percent, 25.0); - assert_eq!(weekly.window_minutes, Some(10080)); - } - - #[test] - fn identifies_rate_limit_array_windows_by_duration() { - let api = CodexApi::new(); - let (usage, _) = api - .build_result_from_json(&json!({ - "rate_limits": [ - { - "used_percent": 25, - "limit_window_seconds": 604800, - "reset_at": 1783036800 - }, - { - "used_percent": 10, - "limit_window_seconds": 18000, - "reset_at": 1783018800 - } - ] - })) - .expect("codex usage"); - - assert!(!usage.primary.is_informational); - assert_eq!(usage.primary.used_percent, 10.0); - assert_eq!(usage.primary.window_minutes, Some(300)); - - let weekly = usage.secondary.expect("weekly window"); - assert_eq!(weekly.used_percent, 25.0); - assert_eq!(weekly.window_minutes, Some(10080)); - } - - #[test] - fn identifies_weekly_only_rate_limit_array_without_a_session() { - let api = CodexApi::new(); - let (usage, _) = api - .build_result_from_json(&json!({ - "rate_limits": [{ - "used_percent": 25, - "limit_window_seconds": 604800, - "reset_at": 1783036800 - }] - })) - .expect("codex usage"); - - assert!(usage.primary.is_informational); - assert_eq!(usage.secondary.expect("weekly window").used_percent, 25.0); - } - - #[test] - fn maps_codex_spark_additional_rate_limits() { - let api = CodexApi::new(); - let (usage, _) = api - .build_result_from_json(&json!({ - "plan_type": "pro", - "rate_limit": { - "primary_window": { "used_percent": 20, "limit_window_seconds": 18000 }, - "secondary_window": { "used_percent": 40, "limit_window_seconds": 604800 } - }, - "additional_rate_limits": [ - { - "limit_name": "Codex Spark", - "metered_feature": "codex_spark", - "rate_limit": { - "primary_window": { "used_percent": "17", "limit_window_seconds": 18000 } - } - }, - { - "limit_name": "Codex Spark Weekly", - "metered_feature": "codex_spark", - "rate_limit": { - "secondary_window": { "used_percent": 62, "limit_window_seconds": 604800 } - } - } - ] - })) - .expect("codex usage"); - - assert_eq!(usage.extra_rate_windows.len(), 2); - assert_eq!(usage.extra_rate_windows[0].id, "codex-spark"); - assert_eq!(usage.extra_rate_windows[0].title, "Codex Spark 5-hour"); - assert_eq!(usage.extra_rate_windows[0].window.used_percent, 17.0); - assert_eq!(usage.extra_rate_windows[1].id, "codex-spark-weekly"); - assert_eq!(usage.extra_rate_windows[1].title, "Codex Spark Weekly"); - assert_eq!(usage.extra_rate_windows[1].window.used_percent, 62.0); - } - - #[test] - fn ignores_placeholder_additional_rate_limits() { - let api = CodexApi::new(); - let (usage, _) = api - .build_result_from_json(&json!({ - "rate_limit": { - "primary_window": { "used_percent": 0, "limit_window_seconds": 18000 } - }, - "additional_rate_limits": [ - { - "limit_name": "placeholder", - "metered_feature": "placeholder", - "rate_limit": { "primary_window": {} } - } - ] - })) - .expect("codex usage"); - - assert!(usage.extra_rate_windows.is_empty()); - } - - fn win(minutes: u32, used: f64) -> RateWindow { - RateWindow::with_details(used, Some(minutes), None, None) - } - - #[test] - fn normalize_named_windows_routes_by_role() { - // Primary is reported as -1.0 for the "No active 5h session" placeholder. - let s = |used| win(300, used); - let w = |used| win(10_080, used); - let u = |used| win(999, used); - let m = |used| win(43_200, used); - type Row = (Option, Option, f64, Option); - let rows: Vec = vec![ - (None, None, -1.0, None), - (Some(w(1.0)), None, -1.0, Some(1.0)), - (Some(s(1.0)), None, 1.0, None), - (Some(u(1.0)), None, 1.0, None), - (Some(m(1.0)), None, 1.0, None), - (None, Some(w(2.0)), -1.0, Some(2.0)), - (None, Some(s(2.0)), 2.0, None), - (None, Some(u(2.0)), 2.0, None), - (Some(w(1.0)), Some(s(2.0)), 2.0, Some(1.0)), - (Some(w(1.0)), Some(u(2.0)), -1.0, Some(1.0)), - (Some(u(1.0)), Some(s(2.0)), 2.0, Some(1.0)), - (Some(s(1.0)), Some(w(2.0)), 1.0, Some(2.0)), - (Some(u(1.0)), Some(w(2.0)), 1.0, Some(2.0)), - (Some(s(1.0)), Some(s(2.0)), 1.0, Some(2.0)), - (Some(w(1.0)), Some(w(2.0)), 1.0, Some(2.0)), - (Some(u(1.0)), Some(u(2.0)), 1.0, Some(2.0)), - (Some(m(1.0)), Some(w(2.0)), 1.0, Some(2.0)), - ]; - for (index, (primary, secondary, want_primary, want_secondary)) in - rows.into_iter().enumerate() - { - let (got_primary, got_secondary) = normalize_named_windows(primary, secondary); - let got_primary = if got_primary.is_informational { - -1.0 - } else { - got_primary.used_percent - }; - assert_eq!(got_primary, want_primary, "row {index} primary"); - assert_eq!( - got_secondary.map(|window| window.used_percent), - want_secondary, - "row {index} secondary" - ); - } - } - - #[tokio::test] - async fn authed_get_sends_account_header_only_when_non_empty() { - let mut server = mockito::Server::new_async().await; - let with_account = server - .mock("GET", "/with") - .match_header("authorization", "Bearer tok") - .match_header("user-agent", "CodexBar") - .match_header("accept", "application/json") - .match_header("chatgpt-account-id", "acct-1") - .with_status(200) - .create_async() - .await; - let without_account = server - .mock("GET", "/without") - .match_header("authorization", "Bearer tok") - .match_header("chatgpt-account-id", mockito::Matcher::Missing) - .expect(2) - .with_status(200) - .create_async() - .await; - let api = CodexApi::new(); - for (path, account_id) in [ - ("/with", Some("acct-1")), - ("/without", Some("")), - ("/without", None), - ] { - let status = api - .authed_get(&format!("{}{path}", server.url()), "tok", account_id) - .send() - .await - .expect("send") - .status(); - assert_eq!(status.as_u16(), 200, "{path} {account_id:?}"); - } - with_account.assert_async().await; - without_account.assert_async().await; - } - - #[test] - fn f5_normalize_array_routes_session_weekly_monthly_to_lanes() { - // 5h session + weekly + monthly → (session, weekly, monthly, None) - let windows = vec![win(300, 10.0), win(10_080, 20.0), win(43_200, 30.0)]; - let (primary, secondary, tertiary, code_review) = normalize_array_windows(windows); - assert_eq!(primary.window_minutes, Some(300)); - assert_eq!(secondary.unwrap().window_minutes, Some(10_080)); - assert_eq!(tertiary.unwrap().window_minutes, Some(43_200)); - assert!(code_review.is_none()); - } - - #[test] - fn f5_normalize_array_monthly_routes_to_tertiary_not_secondary() { - // Monthly must go to tertiary, NOT secondary — so #268's weekly math - // and "Weekly" label stay untouched. - let windows = vec![win(43_200, 50.0), win(10_080, 20.0)]; - let (primary, secondary, tertiary, _) = normalize_array_windows(windows); - assert_eq!(primary.window_minutes, Some(300)); // no session → placeholder - assert_eq!(secondary.unwrap().window_minutes, Some(10_080)); - assert_eq!(tertiary.unwrap().window_minutes, Some(43_200)); - } - - #[test] - fn f5_normalize_array_empty_returns_placeholder_primary() { - let (primary, secondary, tertiary, code_review) = normalize_array_windows(vec![]); - assert!(primary.is_informational); - assert!(secondary.is_none()); - assert!(tertiary.is_none()); - assert!(code_review.is_none()); - } - - #[test] - fn f5_normalize_array_unknown_windows_fall_to_code_review() { - // Windows with unrecognized durations (not 300/10080/43200) go to the - // remaining/code_review bucket. - let windows = vec![win(300, 10.0), win(999, 5.0)]; - let (primary, secondary, tertiary, code_review) = normalize_array_windows(windows); - assert_eq!(primary.window_minutes, Some(300)); - assert!(secondary.is_none()); - assert!(tertiary.is_none()); - assert_eq!(code_review.unwrap().window_minutes, Some(999)); - } - - // ── Upstream 0.50.1 #2944: external OAuth source gate ────────────────── - - #[test] - fn confirmation_failure_fallback_keeps_first_successful_usage_and_cost() { - let state = weekly_reset::AccountState::default(); - let first = UsageSnapshot::new(RateWindow::new(10.0)).with_secondary(RateWindow::new(0.5)); - let cost = Some(CostSnapshot::new(3.25, "USD", "Monthly")); - let (usage, kept_cost) = (weekly_reset::preserve_weekly(&state, first), cost); - assert!((usage.secondary.expect("weekly").used_percent - 0.5).abs() < f64::EPSILON); - assert_eq!(kept_cost.expect("cost").used, 3.25); - } - #[test] - fn api_key_credentials_are_not_external_oauth() { - let creds = CodexApi::parse_credentials_json(r#"{"OPENAI_API_KEY": "sk-test"}"#) - .expect("credentials"); - assert!(!creds.is_external_oauth); - assert!(creds.access_token_expires_at.is_none()); - assert!(creds.last_refresh.is_none()); - assert!(CodexApi::enforce_external_oauth_gate(&creds).is_ok()); - } - - #[test] - fn oauth_tokens_with_refresh_token_are_external_source() { - let creds = CodexApi::parse_credentials_json( - r#"{ - "tokens": { - "access_token": "access", - "refresh_token": "refresh", - "account_id": "acct_123" - } - }"#, - ) - .expect("credentials"); - assert!(creds.is_external_oauth); - assert!(creds.access_token_expires_at.is_none()); - assert!(creds.last_refresh.is_none()); - } - - #[test] - fn oauth_tokens_without_refresh_token_are_not_external() { - let creds = CodexApi::parse_credentials_json( - r#"{ - "tokens": { - "access_token": "access", - "account_id": "acct_123" - } - }"#, - ) - .expect("credentials"); - assert!(!creds.is_external_oauth); - } - - #[test] - fn external_oauth_gate_fails_closed_without_last_refresh() { - let creds = external_creds("access", None); - let err = CodexApi::enforce_external_oauth_gate(&creds) - .expect_err("external OAuth without provenance must fail closed"); - assert!(matches!(err, ProviderError::AuthRequired)); - } - - #[test] - fn external_oauth_gate_ignores_old_last_refresh_for_opaque_token() { - let old = Utc::now() - chrono::Duration::days(10); - let creds = external_creds("access", Some(old)); - assert!(CodexApi::enforce_external_oauth_gate(&creds).is_ok()); - } - - #[test] - fn external_oauth_gate_allows_refresh_provenance() { - let fresh = Utc::now() - chrono::Duration::hours(1); - let creds = external_creds("access", Some(fresh)); - assert!(CodexApi::enforce_external_oauth_gate(&creds).is_ok()); - } - - #[test] - fn external_oauth_gate_uses_future_jwt_expiry_over_old_last_refresh() { - let now = Utc::now(); - let future = now + chrono::Duration::hours(2); - let token = jwt_with_exp(future.timestamp()); - let json = format!( - r#"{{"tokens":{{"access_token":"{token}","refresh_token":"refresh"}},"last_refresh":"2026-01-01T00:00:00Z"}}"# - ); - let creds = CodexApi::parse_credentials_json(&json).expect("credentials"); - assert!(creds.access_token_expires_at.is_some()); - assert!(CodexApi::enforce_external_oauth_gate_at(&creds, false, now).is_ok()); - assert!(CodexApi::enforce_external_oauth_gate_at(&creds, true, now).is_ok()); - } - - #[test] - fn external_oauth_gate_rejects_expired_jwt() { - let expired = Utc::now() - chrono::Duration::minutes(1); - let token = jwt_with_exp(expired.timestamp()); - let json = format!( - r#"{{"tokens":{{"access_token":"{token}","refresh_token":"refresh"}},"last_refresh":"{}"}}"#, - Utc::now().to_rfc3339() - ); - let creds = CodexApi::parse_credentials_json(&json).expect("credentials"); - let err = CodexApi::enforce_external_oauth_gate(&creds) - .expect_err("expired native OAuth must be rejected"); - assert!(matches!(err, ProviderError::AuthRequired)); - } - - #[test] - fn external_oauth_gate_requires_cli_refresh_when_jwt_is_near_expiry() { - let soon = Utc::now() + chrono::Duration::minutes(2); - let token = jwt_with_exp(soon.timestamp()); - let json = format!( - r#"{{"tokens":{{"access_token":"{token}","refresh_token":"refresh"}},"last_refresh":"{}"}}"#, - Utc::now().to_rfc3339() - ); - let creds = CodexApi::parse_credentials_json(&json).expect("credentials"); - let err = CodexApi::enforce_external_oauth_gate(&creds) - .expect_err("near-expiry native OAuth must refresh through the CLI"); - assert!(matches!(err, ProviderError::AuthRequired)); - } - - #[test] - fn external_oauth_gate_allows_missing_last_refresh_when_opted_in() { - let creds = external_creds("opaque-token", None); - assert!(CodexApi::enforce_external_oauth_gate_at(&creds, true, Utc::now()).is_ok()); - } - - #[test] - fn opaque_token_uses_refresh_provenance_when_no_jwt_expiry_exists() { - let fresh = Utc::now().to_rfc3339(); - let json = format!( - r#"{{"tokens":{{"access_token":"opaque-token","refresh_token":"refresh"}},"last_refresh":"{fresh}"}}"# - ); - let creds = CodexApi::parse_credentials_json(&json).expect("credentials"); - assert!(creds.access_token_expires_at.is_none()); - assert!(CodexApi::enforce_external_oauth_gate(&creds).is_ok()); - } - #[test] - fn parse_timestamp_reads_iso8601() { - assert!(parse_timestamp("2026-08-17T10:00:00Z").is_some()); - assert!(parse_timestamp("2026-08-17T10:00:00.123Z").is_some()); - assert!(parse_timestamp(" 2026-08-17T10:00:00Z ").is_some()); - assert!(parse_timestamp("").is_none()); - assert!(parse_timestamp("not-a-date").is_none()); - } -} +mod tests; diff --git a/rust/src/providers/codex/api/credential_retry_tests.rs b/rust/src/providers/codex/api/credential_retry_tests.rs index 7d9aca1163f..44068ca1827 100644 --- a/rust/src/providers/codex/api/credential_retry_tests.rs +++ b/rust/src/providers/codex/api/credential_retry_tests.rs @@ -6,7 +6,9 @@ //! injected reader does. The clock is paused, so the 50 ms retry delays are //! exact and the tests never race a writer thread. +use super::credentials::CREDENTIAL_READ_RETRY_DELAY; use super::*; +use base64::Engine; use std::cell::Cell; use std::path::Path; use std::time::Duration; diff --git a/rust/src/providers/codex/api/credentials.rs b/rust/src/providers/codex/api/credentials.rs new file mode 100644 index 00000000000..0b9bab83165 --- /dev/null +++ b/rust/src/providers/codex/api/credentials.rs @@ -0,0 +1,287 @@ +use super::CodexApi; +use crate::core::ProviderError; +use base64::Engine; +use chrono::{DateTime, TimeZone, Utc}; +use std::path::PathBuf; +use std::sync::{Mutex, OnceLock}; +use std::time::{Duration, Instant, SystemTime}; + +const CREDENTIAL_CACHE_TTL: Duration = Duration::from_secs(5); +/// Upstream 0.69.0 #4088: the Codex CLI owns `auth.json` and may be publishing a +/// replacement while we read it. A failed or stale read is repeated up to this many +/// times, `CREDENTIAL_READ_RETRY_DELAY` apart, before the error is reported. +const CREDENTIAL_READ_RETRIES: u32 = 2; +pub(super) const CREDENTIAL_READ_RETRY_DELAY: Duration = Duration::from_millis(50); +const EXTERNAL_OAUTH_REFRESH_WINDOW: chrono::TimeDelta = chrono::Duration::minutes(5); + +static CREDENTIAL_CACHE: OnceLock>> = OnceLock::new(); + +impl CodexApi { + /// Load credentials, tolerating a brief owner publication of `auth.json`. + /// + /// Upstream 0.69.0 #4088 (`CodexOAuthFetchStrategy.loadCredentials` on the + /// usage path, `retryStale: true`): every failed read is repeated. That covers + /// a missing (`NotInstalled`), unreadable (`Other`), malformed or incomplete + /// (`Parse`) file, and a credential the gate rejects as stale (`AuthRequired`, + /// such as a token inside its renewal window), because the CLI may be + /// publishing its renewal. This only rereads the file: no token is redeemed, + /// nothing is written, and the credential cache semantics are unchanged. After + /// the last read the error keeps its category, so unchanged stale credentials + /// still need their owner's renewal. + pub(super) async fn load_credentials(&self) -> Result { + Self::reread_during_owner_publication(|| self.load_credentials_once()).await + } + + /// The bounded reread behind [`Self::load_credentials`]: `read` runs up to + /// `1 + CREDENTIAL_READ_RETRIES` times, `CREDENTIAL_READ_RETRY_DELAY` apart, + /// until it succeeds, and the last result is returned unchanged. Dropping the + /// returned future cancels the pending delay and any further read (upstream + /// checks task cancellation before each read). + pub(super) async fn reread_during_owner_publication( + mut read: impl FnMut() -> Result, + ) -> Result { + let mut retries_remaining = CREDENTIAL_READ_RETRIES; + loop { + match read() { + Err(_) if retries_remaining > 0 => { + retries_remaining -= 1; + tokio::time::sleep(CREDENTIAL_READ_RETRY_DELAY).await; + } + result => return result, + } + } + } + + pub(super) fn load_credentials_once(&self) -> Result { + let auth_path = self.get_auth_path(); + + let metadata = + std::fs::metadata(&auth_path).map_err(|error| self.credential_file_error(error))?; + let modified = metadata.modified().ok(); + if let Some(cached) = Self::cached_credentials(&auth_path, modified) { + Self::enforce_external_oauth_gate(&cached)?; + return Ok(cached); + } + + let content = std::fs::read_to_string(&auth_path) + .map_err(|error| self.credential_file_error(error))?; + + let credentials = Self::parse_credentials_json(&content)?; + Self::enforce_external_oauth_gate(&credentials)?; + Self::store_cached_credentials(auth_path, modified, credentials.clone()); + Ok(credentials) + } + + fn missing_credentials_error(&self) -> ProviderError { + // Upstream 0.50.0 #2679: when the CLI targets Amazon Bedrock or + // another custom backend without ChatGPT auth, sign-in guidance + // is wrong — rate limits simply are not available there. + if self.uses_custom_backend() { + return ProviderError::NotInstalled( + "Codex uses a custom backend (chatgpt_base_url / model_provider) without \ + ChatGPT auth. ChatGPT rate limits are unavailable for this setup." + .to_string(), + ); + } + + ProviderError::NotInstalled( + "Codex auth.json not found. Run `codex login` in a terminal to sign in.".to_string(), + ) + } + + fn credential_file_error(&self, error: std::io::Error) -> ProviderError { + if error.kind() == std::io::ErrorKind::NotFound { + return self.missing_credentials_error(); + } + + ProviderError::Other(format!("Failed to read Codex credentials: {error}")) + } + + pub(super) fn parse_credentials_json(content: &str) -> Result { + let json: serde_json::Value = serde_json::from_str(content) + .map_err(|e| ProviderError::Parse(format!("Invalid Codex credentials JSON: {}", e)))?; + + // Check for OPENAI_API_KEY first + if let Some(api_key) = json.get("OPENAI_API_KEY").and_then(|v| v.as_str()) { + let trimmed = api_key.trim(); + if !trimmed.is_empty() { + return Ok(CodexCredentials { + access_token: trimmed.to_string(), + account_id: None, + is_external_oauth: false, + access_token_expires_at: None, + last_refresh: None, + }); + } + } + + // Otherwise, look for tokens object (external OAuth source) + let tokens = json.get("tokens").ok_or_else(|| { + ProviderError::Parse("Codex auth.json exists but contains no tokens.".to_string()) + })?; + + let access_token = tokens + .get("access_token") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .ok_or_else(|| { + ProviderError::Parse("Missing access_token in Codex credentials".to_string()) + })? + .to_string(); + + let account_id = tokens + .get("account_id") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + + // Upstream 0.50.1 #2944: an OAuth token set with a refresh_token is an + // external (CLI-owned) OAuth source. The `last_refresh` timestamp is + // retained only as provenance for the opt-in safety gate. + let has_refresh_token = tokens + .get("refresh_token") + .and_then(|v| v.as_str()) + .is_some_and(|s| !s.trim().is_empty()); + let last_refresh = json + .get("last_refresh") + .and_then(|v| v.as_str()) + .and_then(parse_timestamp); + + let access_token_expires_at = parse_access_token_expiry(&access_token); + + Ok(CodexCredentials { + access_token, + account_id, + is_external_oauth: has_refresh_token, + access_token_expires_at, + last_refresh, + }) + } + + /// Upstream 0.50.1 #2944: when `codex_external_oauth_sources_allowed` is + /// OFF (the default), external OAuth credential files without refresh + /// provenance fail closed instead of being used silently. An external + /// OAuth source is an auth.json `tokens` object with a `refresh_token` + /// (CLI-owned OAuth, not an API key). Win-CodexBar never refreshes or + /// writes this source: the gate only decides whether the read-only usage + /// request may use it. When the access token is a JWT, its native expiry + /// is the validity authority; opaque tokens are sent to the server. + pub(super) fn enforce_external_oauth_gate( + credentials: &CodexCredentials, + ) -> Result<(), ProviderError> { + if !credentials.is_external_oauth { + return Ok(()); + } + // The opt-in only matters without refresh provenance. Skip the settings + // load otherwise: credential reads repeat while the owner publishes. + let external_sources_allowed = credentials.last_refresh.is_some() + || crate::settings::Settings::load().codex_external_oauth_sources_allowed; + Self::enforce_external_oauth_gate_at(credentials, external_sources_allowed, Utc::now()) + } + + pub(super) fn enforce_external_oauth_gate_at( + credentials: &CodexCredentials, + external_sources_allowed: bool, + now: DateTime, + ) -> Result<(), ProviderError> { + if !credentials.is_external_oauth { + return Ok(()); + } + if !external_sources_allowed && credentials.last_refresh.is_none() { + return Err(ProviderError::AuthRequired); + } + if let Some(expires_at) = credentials.access_token_expires_at + && expires_at - now <= EXTERNAL_OAUTH_REFRESH_WINDOW + { + return Err(ProviderError::AuthRequired); + } + Ok(()) + } + + fn credential_cache() -> &'static Mutex> { + CREDENTIAL_CACHE.get_or_init(|| Mutex::new(None)) + } + + fn cached_credentials( + path: &std::path::Path, + modified: Option, + ) -> Option { + let guard = Self::credential_cache().lock().ok()?; + let cached = guard.as_ref()?; + if cached.path == path + && cached.modified == modified + && cached.loaded_at.elapsed() <= CREDENTIAL_CACHE_TTL + { + return Some(cached.credentials.clone()); + } + None + } + + fn store_cached_credentials( + path: PathBuf, + modified: Option, + credentials: CodexCredentials, + ) { + if let Ok(mut guard) = Self::credential_cache().lock() { + *guard = Some(CachedCodexCredentials { + path, + modified, + loaded_at: Instant::now(), + credentials, + }); + } + } +} + +#[derive(Clone)] +pub(super) struct CodexCredentials { + pub(super) access_token: String, + pub(super) account_id: Option, + /// True when the source is an external OAuth token set (has a + /// `refresh_token`), as opposed to an `OPENAI_API_KEY`. The Codex CLI owns + /// refresh and persistence for this source; this app only reads it. The + /// `codex_external_oauth_sources_allowed` setting gates that read + /// (upstream 0.50.1 #2944). + pub(super) is_external_oauth: bool, + /// Native access-token JWT expiry. When available, this is authoritative + /// for validity; the Codex CLI still owns the refresh lifecycle. + pub(super) access_token_expires_at: Option>, + /// `last_refresh` timestamp from auth.json, when present. Its presence + /// supplies provenance when the external-source opt-in setting is OFF; + /// its age is not an access-token expiry signal. + pub(super) last_refresh: Option>, +} + +struct CachedCodexCredentials { + path: PathBuf, + modified: Option, + loaded_at: Instant, + credentials: CodexCredentials, +} + +/// Parse the native `exp` claim from an access-token JWT. Opaque or malformed +/// tokens return `None` and are handled by the read-only usage request. +fn parse_access_token_expiry(token: &str) -> Option> { + let payload = token.split('.').nth(1)?; + let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(payload) + .or_else(|_| base64::engine::general_purpose::URL_SAFE.decode(payload)) + .ok()?; + let json: serde_json::Value = serde_json::from_slice(&decoded).ok()?; + let exp = json.get("exp")?.as_i64()?; + Utc.timestamp_opt(exp, 0).single() +} +pub(super) fn parse_timestamp(raw: &str) -> Option> { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return None; + } + DateTime::parse_from_rfc3339(trimmed) + .ok() + .map(|dt| dt.with_timezone(&Utc)) + .or_else(|| { + chrono::NaiveDateTime::parse_from_str(trimmed, "%Y-%m-%dT%H:%M:%S%.f") + .ok() + .map(|naive| DateTime::::from_naive_utc_and_offset(naive, Utc)) + }) +} diff --git a/rust/src/providers/codex/api/parse.rs b/rust/src/providers/codex/api/parse.rs new file mode 100644 index 00000000000..e3cd5efeeb7 --- /dev/null +++ b/rust/src/providers/codex/api/parse.rs @@ -0,0 +1,463 @@ +use super::CodexApi; +use crate::core::{ + CostSnapshot, NamedRateWindow, ProviderError, RateWindow, RateWindowCadence, UsageSnapshot, +}; +use chrono::{DateTime, TimeZone, Utc}; + +impl CodexApi { + pub(super) fn build_result_from_json( + &self, + json: &serde_json::Value, + ) -> Result<(UsageSnapshot, Option), ProviderError> { + // Extract plan type + let plan_type = json + .get("plan_type") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + // Extract rate limit info - handle multiple possible structures + let (primary, secondary, monthly, code_review, code_review_verified) = + self.extract_rate_limits(json); + + // Build login method string + let login_method = plan_type.as_deref().map(format_plan_type); + + let mut usage = UsageSnapshot::new(primary); + if let Some(sec) = secondary { + usage = usage.with_secondary(sec); + } + // F5 (upstream 0.48.0): monthly (30-day) windows go to tertiary so the + // bridge and frontend can show a monthly reset instead of swallowing it. + if let Some(mo) = monthly { + usage = usage.with_tertiary(mo); + } + if let Some(cr) = code_review { + usage = if code_review_verified { + usage.with_code_review(cr) + } else { + usage.with_model_specific(cr) + }; + } + for extra in self.extract_additional_rate_limits(json) { + usage.extra_rate_windows.push(extra); + } + if let Some(method) = login_method { + usage = usage.with_login_method(method); + } + + // Extract credits if present + let cost = self.extract_credits(json); + + Ok((usage, cost)) + } + + #[cfg(test)] + pub(crate) fn build_result_from_json_for_test( + &self, + json: &serde_json::Value, + ) -> Result<(UsageSnapshot, Option), ProviderError> { + self.build_result_from_json(json) + } + + fn extract_rate_limits( + &self, + json: &serde_json::Value, + ) -> ( + RateWindow, + Option, + Option, + Option, + bool, + ) { + // Try rate_limit object + if let Some(rate_limit) = json.get("rate_limit") { + let primary_opt = rate_limit + .get("primary_window") + .and_then(|w| self.parse_window_if_present(w)); + + let secondary_opt = rate_limit + .get("secondary_window") + .and_then(|w| self.parse_window_if_present(w)); + + let code_review = rate_limit + .get("code_review_window") + .and_then(|w| self.parse_window_if_present(w)); + + let (primary, secondary) = normalize_named_windows(primary_opt, secondary_opt); + + // F5 (upstream 0.48.0): named windows carry only session/weekly/code_review. + // Monthly is extracted separately (from array windows) — return None here. + let code_review_verified = code_review.is_some(); + return (primary, secondary, None, code_review, code_review_verified); + } + + // Try rate_limits array + if let Some(rate_limits) = json.get("rate_limits").and_then(|v| v.as_array()) { + let windows = rate_limits + .iter() + .filter_map(|window| self.parse_window_if_present(window)) + .collect::>(); + let (primary, secondary, monthly, code_review) = normalize_array_windows(windows); + // F5 (upstream 0.48.0): route monthly to its own tertiary lane. + return (primary, secondary, monthly, code_review, false); + } + + // Try direct fields + let used_percent = json + .get("used_percent") + .or_else(|| json.get("usage_percent")) + .and_then(json_f64); + let primary = RateWindow::new(used_percent.unwrap_or(0.0)) + .with_usage_known(valid_used_percent(used_percent)); + + (primary, None, None, None, false) + } + + fn parse_window(&self, window: &serde_json::Value) -> RateWindow { + let used_percent = window + .get("used_percent") + .or_else(|| window.get("usage_percent")) + .and_then(json_f64); + + let window_minutes = window + .get("limit_window_seconds") + .and_then(json_i64) + .and_then(|seconds| u32::try_from(seconds / 60).ok()); + + let reset_at = window + .get("reset_at") + .and_then(json_i64) + .and_then(|ts| Utc.timestamp_opt(ts, 0).single()); + + RateWindow::with_details( + used_percent.unwrap_or(0.0), + window_minutes, + reset_at, + format_reset_countdown(reset_at), + ) + .with_usage_known(valid_used_percent(used_percent)) + } + + fn parse_window_if_present(&self, window: &serde_json::Value) -> Option { + (!window.is_null() && !is_placeholder_window(window)).then(|| self.parse_window(window)) + } + + fn extract_additional_rate_limits(&self, json: &serde_json::Value) -> Vec { + json.get("additional_rate_limits") + .and_then(|v| v.as_array()) + .into_iter() + .flatten() + .filter_map(|entry| self.parse_additional_rate_limit(entry)) + .collect() + } + + fn parse_additional_rate_limit(&self, entry: &serde_json::Value) -> Option { + let metered_feature = entry + .get("metered_feature") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|v| !v.is_empty()); + let limit_name = entry + .get("limit_name") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|v| !v.is_empty()); + + let rate_limit = entry.get("rate_limit").unwrap_or(entry); + let primary = rate_limit.get("primary_window"); + let secondary = rate_limit.get("secondary_window"); + let window = primary.or(secondary)?; + if is_placeholder_window(window) { + return None; + } + + let parsed = self.parse_window(window); + let feature = metered_feature.unwrap_or_default(); + let limit = limit_name.unwrap_or_default(); + let is_spark = feature.eq_ignore_ascii_case("codex_spark") + || feature.eq_ignore_ascii_case("spark") + || limit.to_ascii_lowercase().contains("spark"); + + if is_spark { + let is_weekly = secondary.is_some() && primary.is_none() + || parsed + .window_minutes + .is_some_and(|mins| mins >= 7 * 24 * 60); + let (id, title) = if is_weekly { + ("codex-spark-weekly", "Codex Spark Weekly") + } else { + ("codex-spark", "Codex Spark 5-hour") + }; + return Some(NamedRateWindow::new(id, title, parsed)); + } + + let label = limit_name.or(metered_feature)?; + let slug = slugify(label); + if slug.is_empty() { + return None; + } + + Some(NamedRateWindow::new( + format!("codex-{slug}"), + titleize_limit_label(label), + parsed, + )) + } + + fn extract_credits(&self, json: &serde_json::Value) -> Option { + let credits = json.get("credits")?; + + let has_credits = credits + .get("has_credits") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + if !has_credits { + return None; + } + + let unlimited = credits + .get("unlimited") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + if unlimited { + return None; + } + + let balance = credits + .get("balance") + .and_then(|v| v.as_f64()) + .unwrap_or(0.0); + + Some(CostSnapshot::new(balance, "USD", "Credits")) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CodexWindowRole { + Session, + Weekly, + Monthly, + Unknown, +} + +fn codex_window_role(window: &RateWindow) -> CodexWindowRole { + match window + .window_minutes + .map(RateWindowCadence::from_minutes) + .unwrap_or(RateWindowCadence::Unknown) + { + RateWindowCadence::Session => CodexWindowRole::Session, + RateWindowCadence::Monthly => CodexWindowRole::Monthly, + RateWindowCadence::Weekly => CodexWindowRole::Weekly, + RateWindowCadence::Unknown => CodexWindowRole::Unknown, + } +} + +/// Normalize the named `primary_window`/`secondary_window` fields by duration. +pub(super) fn normalize_named_windows( + primary: Option, + secondary: Option, +) -> (RateWindow, Option) { + match (primary, secondary) { + (None, None) => (RateWindow::no_active_session(), None), + (Some(window), None) | (None, Some(window)) => { + if codex_window_role(&window) == CodexWindowRole::Weekly { + (RateWindow::no_active_session(), Some(window)) + } else { + (window, None) + } + } + (Some(primary), Some(secondary)) => { + match (codex_window_role(&primary), codex_window_role(&secondary)) { + (CodexWindowRole::Weekly, CodexWindowRole::Session) => (secondary, Some(primary)), + (CodexWindowRole::Weekly, CodexWindowRole::Unknown) => { + (RateWindow::no_active_session(), Some(primary)) + } + (CodexWindowRole::Unknown, CodexWindowRole::Session) => (secondary, Some(primary)), + _ => (primary, Some(secondary)), + } + } + } +} + +/// Normalize an array of Codex windows without relying on the API's ordering. +/// +/// Returns (session, weekly, monthly, code_review). F5 (upstream 0.48.0): +/// monthly (30-day) windows are routed to their own lane so surfaces can +/// display a monthly reset instead of swallowing it into the weekly label. +pub(super) fn normalize_array_windows( + windows: Vec, +) -> ( + RateWindow, + Option, + Option, + Option, +) { + if windows.is_empty() { + return (RateWindow::no_active_session(), None, None, None); + } + + // Preserve the old positional fallback when the API provides no role + // metadata at all. There is no safe way to infer session vs weekly then. + if !windows + .iter() + .any(|window| codex_window_role(window) != CodexWindowRole::Unknown) + { + let mut windows = windows.into_iter(); + return ( + windows.next().unwrap_or_else(RateWindow::no_active_session), + windows.next(), + windows.next(), + windows.next(), + ); + } + + let mut session = None; + let mut weekly = None; + let mut monthly = None; + let mut remaining = Vec::new(); + + for window in windows { + match codex_window_role(&window) { + CodexWindowRole::Session if session.is_none() => session = Some(window), + CodexWindowRole::Weekly if weekly.is_none() => weekly = Some(window), + CodexWindowRole::Monthly if monthly.is_none() => monthly = Some(window), + _ => remaining.push(window), + } + } + + ( + session.unwrap_or_else(RateWindow::no_active_session), + weekly, + monthly, + remaining.into_iter().next(), + ) +} + +pub(super) fn format_plan_type(plan_type: &str) -> String { + match plan_type { + "guest" => "Guest".to_string(), + "free" => "ChatGPT Free".to_string(), + "go" => "Codex Go".to_string(), + "plus" => "ChatGPT Plus".to_string(), + "pro" => "ChatGPT Pro".to_string(), + "pro_lite" | "prolite" | "pro-lite" => "Pro Lite".to_string(), + "team" => "ChatGPT Team".to_string(), + "business" => "ChatGPT Business".to_string(), + "enterprise" => "ChatGPT Enterprise".to_string(), + "education" | "edu" => "ChatGPT Education".to_string(), + "free_workspace" | "freeWorkspace" => "Free Workspace".to_string(), + "quorum" => "Codex Quorum".to_string(), + "k12" => "Codex K12".to_string(), + other => format!("ChatGPT {}", capitalize(other)), + } +} + +fn json_f64(value: &serde_json::Value) -> Option { + value + .as_f64() + .or_else(|| value.as_i64().map(|value| value as f64)) + .or_else(|| value.as_str()?.trim().parse::().ok()) +} + +fn json_i64(value: &serde_json::Value) -> Option { + value + .as_i64() + .or_else(|| value.as_str()?.trim().parse::().ok()) +} + +fn valid_used_percent(value: Option) -> bool { + value.is_some_and(|value| value.is_finite() && (0.0..=100.0).contains(&value)) +} + +fn is_placeholder_window(window: &serde_json::Value) -> bool { + let has_usage = window + .get("used_percent") + .or_else(|| window.get("usage_percent")) + .and_then(json_f64) + .is_some(); + let has_duration = window + .get("limit_window_seconds") + .and_then(json_i64) + .is_some(); + let has_reset = window.get("reset_at").and_then(json_i64).is_some(); + + !has_usage && !has_duration && !has_reset +} + +fn slugify(label: &str) -> String { + let mut slug = String::new(); + let mut previous_dash = false; + + for ch in label.chars() { + if ch.is_ascii_alphanumeric() { + slug.push(ch.to_ascii_lowercase()); + previous_dash = false; + } else if !previous_dash && !slug.is_empty() { + slug.push('-'); + previous_dash = true; + } + } + + while slug.ends_with('-') { + slug.pop(); + } + slug +} + +fn titleize_limit_label(label: &str) -> String { + label + .split(['_', '-', ' ']) + .filter(|part| !part.is_empty()) + .map(|part| { + let mut chars = part.chars(); + match chars.next() { + Some(first) => first + .to_uppercase() + .chain(chars.flat_map(char::to_lowercase)) + .collect(), + None => String::new(), + } + }) + .collect::>() + .join(" ") +} + +fn format_reset_countdown(reset_at: Option>) -> Option { + let dt = reset_at?; + let now = Utc::now(); + if dt <= now { + return Some("now".to_string()); + } + let diff = dt - now; + let total_mins = diff.num_minutes(); + let hours = diff.num_hours(); + let mins = total_mins % 60; + if hours >= 24 { + let days = hours / 24; + let rem_h = hours % 24; + if rem_h == 0 { + Some(format!("{}d", days)) + } else { + Some(format!("{}d {}h", days, rem_h)) + } + } else if hours > 0 { + if mins == 0 { + Some(format!("{}h", hours)) + } else { + Some(format!("{}h {}m", hours, mins)) + } + } else { + Some(format!("{}m", mins)) + } +} + +fn capitalize(s: &str) -> String { + let mut chars = s.chars(); + match chars.next() { + None => String::new(), + Some(first) => first.to_uppercase().chain(chars).collect(), + } +} diff --git a/rust/src/providers/codex/api/reset_credits.rs b/rust/src/providers/codex/api/reset_credits.rs new file mode 100644 index 00000000000..3743787db35 --- /dev/null +++ b/rust/src/providers/codex/api/reset_credits.rs @@ -0,0 +1,249 @@ +use super::super::weekly_reset; +use super::CodexApi; +use super::credentials::CodexCredentials; +use crate::core::{ProviderError, RateWindow, UsageSnapshot}; +use chrono::{DateTime, Utc}; +use serde::Deserialize; +use std::collections::HashMap; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::{Duration, Instant}; +use tokio::sync::Mutex as AsyncMutex; + +pub(super) const RESET_CREDITS_PATH: &str = "/wham/rate-limit-reset-credits"; +pub(super) const RESET_CREDITS_CACHE_TTL: Duration = Duration::from_secs(600); + +static RESET_CREDITS_CACHE: OnceLock>>>> = + OnceLock::new(); + +#[derive(Default)] +pub(super) struct ResetCreditsCache { + pub(super) loaded_at: Option, + value: Option, + confirmation_failure_at: Option, +} + +impl ResetCreditsCache { + fn confirmation_failed_recently(&self) -> bool { + self.confirmation_failure_at + .is_some_and(|failed| failed.elapsed() < RESET_CREDITS_CACHE_TTL) + } +} + +impl CodexApi { + pub(super) fn reset_credits_cache_slot( + &self, + creds: &CodexCredentials, + base_url: &str, + ) -> Arc> { + // The Codex home is part of the scope: two homes never share an + // observation, even when they hold the same account and token. + let auth_path = self.get_auth_path(); + let home = weekly_reset::scope_key(None, &auth_path); + let account = weekly_reset::scope_key(creds.account_id.as_deref(), &auth_path); + let token = weekly_reset::scope_key(Some(&creds.access_token), &auth_path); + let key = format!( + "{}|{home}|{account}|{token}", + base_url.trim_end_matches('/') + ); + let cache = RESET_CREDITS_CACHE.get_or_init(|| Mutex::new(HashMap::new())); + let mut cache = cache + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + Arc::clone( + cache + .entry(key) + .or_insert_with(|| Arc::new(AsyncMutex::new(ResetCreditsCache::default()))), + ) + } + + pub(super) async fn fetch_rate_limit_reset_credits_cached( + &self, + creds: &CodexCredentials, + base_url: &str, + ) -> Option { + let slot = self.reset_credits_cache_slot(creds, base_url); + let mut cache = slot.lock().await; + if cache + .loaded_at + .is_some_and(|loaded| loaded.elapsed() < RESET_CREDITS_CACHE_TTL) + { + return cache.value.clone(); + } + cache.value = self + .fetch_rate_limit_reset_credits(creds, base_url) + .await + .ok(); + cache.loaded_at = Some(Instant::now()); + cache.confirmation_failure_at = None; + cache.value.clone() + } + + /// Reset credits for the initial weekly-reset decision. A pending delayed + /// candidate is revalidated against the current inventory, which must be + /// observed after the candidate was stored: the ten-minute cache can still + /// hold the very observation that created it, so that case reads fresh. + pub(super) async fn initial_reset_credits( + &self, + creds: &CodexCredentials, + base_url: &str, + started: Instant, + observed: Option, + candidate_pending: bool, + ) -> Option { + if !candidate_pending { + return observed; + } + self.fresh_reset_credits_for_confirmation(creds, base_url, started) + .await + } + + pub(super) async fn fresh_reset_credits_for_confirmation( + &self, + creds: &CodexCredentials, + base_url: &str, + started: Instant, + ) -> Option { + let slot = self.reset_credits_cache_slot(creds, base_url); + let mut cache = slot.lock().await; + cache.value.as_ref()?; + if cache.confirmation_failed_recently() { + return None; + } + if cache.loaded_at.is_some_and(|loaded| loaded >= started) { + return cache.value.clone(); + } + self.refetch_reset_credits_into(&mut cache, creds, base_url) + .await + } + + pub(super) async fn fetch_rate_limit_reset_credits_fresh( + &self, + creds: &CodexCredentials, + base_url: &str, + ) -> Option { + let slot = self.reset_credits_cache_slot(creds, base_url); + let mut cache = slot.lock().await; + if cache.confirmation_failed_recently() { + return None; + } + self.refetch_reset_credits_into(&mut cache, creds, base_url) + .await + } + + /// A failed read keeps the cached inventory but blocks further + /// confirmation reads for one cache TTL. + async fn refetch_reset_credits_into( + &self, + cache: &mut ResetCreditsCache, + creds: &CodexCredentials, + base_url: &str, + ) -> Option { + let fresh = self + .fetch_rate_limit_reset_credits(creds, base_url) + .await + .ok(); + if let Some(value) = fresh.as_ref() { + cache.value = Some(value.clone()); + cache.loaded_at = Some(Instant::now()); + cache.confirmation_failure_at = None; + } else { + cache.confirmation_failure_at = Some(Instant::now()); + } + fresh + } + + async fn fetch_rate_limit_reset_credits( + &self, + creds: &CodexCredentials, + base_url: &str, + ) -> Result { + let response = self + .authed_get( + &format!("{}{}", base_url, RESET_CREDITS_PATH), + &creds.access_token, + creds.account_id.as_deref(), + ) + .send() + .await?; + if !response.status().is_success() { + return Err( + super::super::authenticated_http_error(response, "Codex reset credits").await, + ); + } + decode_reset_credits(&response.bytes().await?) + } +} + +#[derive(Debug, Clone, Deserialize)] +pub(in crate::providers::codex) struct ResetCredit { + #[serde(default)] + pub(in crate::providers::codex) id: Option, + #[serde(default, alias = "resetType")] + pub(in crate::providers::codex) reset_type: Option, + #[serde(default)] + pub(in crate::providers::codex) status: Option, + #[serde(default)] + pub(in crate::providers::codex) expires_at: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub(in crate::providers::codex) struct ResetCredits { + #[serde(default)] + pub(in crate::providers::codex) credits: Vec, + pub(in crate::providers::codex) available_count: u32, +} + +pub(super) fn decode_reset_credits(data: &[u8]) -> Result { + serde_json::from_slice(data) + .map_err(|e| ProviderError::Parse(format!("Failed to parse Codex reset credits: {e}"))) +} + +fn parse_credit_expiry(raw: &str) -> Option> { + DateTime::parse_from_rfc3339(raw) + .ok() + .map(|dt| dt.with_timezone(&Utc)) +} + +fn is_available_credit(credit: &ResetCredit) -> bool { + match credit.status.as_deref() { + None | Some("") => true, + Some(status) => status.eq_ignore_ascii_case("available"), + } +} + +pub(in crate::providers::codex) fn next_available_reset_credit_expiry( + credits: &[ResetCredit], + now: DateTime, +) -> Option> { + credits + .iter() + .filter(|credit| is_available_credit(credit)) + .filter_map(|credit| credit.expires_at.as_deref().and_then(parse_credit_expiry)) + .filter(|expires_at| *expires_at > now) + .min() +} + +pub(super) fn reset_credits_rate_window(reset: &ResetCredits, now: DateTime) -> RateWindow { + let description = format!( + "{} reset credit{} available", + reset.available_count, + if reset.available_count == 1 { "" } else { "s" } + ); + let mut window = RateWindow::informational(description); + window.resets_at = next_available_reset_credit_expiry(&reset.credits, now); + window +} + +pub(super) fn apply_reset_credits_window( + mut usage: UsageSnapshot, + reset: Option<&ResetCredits>, +) -> UsageSnapshot { + usage + .extra_rate_windows + .retain(|window| window.id != "reset-credits"); + if let Some(reset) = reset.filter(|reset| reset.available_count > 0) { + let window = reset_credits_rate_window(reset, Utc::now()); + usage = usage.with_extra_rate_window("reset-credits", "Reset credits", window); + } + usage +} diff --git a/rust/src/providers/codex/api/tests.rs b/rust/src/providers/codex/api/tests.rs new file mode 100644 index 00000000000..966e204c377 --- /dev/null +++ b/rust/src/providers/codex/api/tests.rs @@ -0,0 +1,940 @@ +use super::credentials::parse_timestamp; +use super::parse::{normalize_array_windows, normalize_named_windows}; +use super::reset_credits::{ + RESET_CREDITS_CACHE_TTL, RESET_CREDITS_PATH, decode_reset_credits, reset_credits_rate_window, +}; +use super::*; +use crate::core::RateWindow; +use crate::providers::test_support::{mock_response, mock_response_expect, mock_status_expect}; +use base64::Engine; +use chrono::DateTime; +use serde_json::json; +use std::time::Duration; + +#[test] +fn non_chatgpt_model_provider_is_detected_for_guidance() { + // Upstream 0.50.0 #2679: Bedrock and other custom backends get + // rate-limit guidance instead of login instructions. + assert!(config_uses_non_chatgpt_provider( + "model_provider = \"bedrock\"\n" + )); + assert!(config_uses_non_chatgpt_provider( + "# relay\nmodel_provider = 'ollama'" + )); + assert!(!config_uses_non_chatgpt_provider( + "model_provider = \"openai\"" + )); + assert!(!config_uses_non_chatgpt_provider( + "model = \"gpt-5\"\napproval_policy = \"never\"" + )); +} + +#[test] +fn parses_codex_credentials_without_retaining_refresh_token() { + let credentials = CodexApi::parse_credentials_json( + r#"{ + "tokens": { + "access_token": "access", + "refresh_token": "refresh", + "account_id": "acct_123" + } + }"#, + ) + .expect("credentials"); + + assert_eq!(credentials.access_token, "access"); + assert_eq!(credentials.account_id.as_deref(), Some("acct_123")); +} + +#[test] +fn decodes_reset_credits() { + let credits = decode_reset_credits( + br#"{"available_count":2,"credits":[{"id":"a","status":"available","expires_at":"2026-08-01T12:00:00Z"}]}"#, + ) + .expect("reset credits"); + assert_eq!(credits.available_count, 2); + assert_eq!(credits.credits.len(), 1); + assert_eq!(credits.credits[0].status.as_deref(), Some("available")); + assert_eq!( + credits.credits[0].expires_at.as_deref(), + Some("2026-08-01T12:00:00Z") + ); +} + +#[test] +fn missing_reset_credit_count_is_unavailable_not_zero() { + assert!(decode_reset_credits(br#"{"credits":[]}"#).is_err()); +} + +#[test] +fn next_expiry_picks_soonest_available() { + let now = utc("2026-07-01T00:00:00Z"); + let credits = vec![ + credit(Some("available"), "2026-07-10T00:00:00Z"), + credit(Some("available"), "2026-07-05T00:00:00Z"), + credit(Some("available"), "2026-07-20T00:00:00Z"), + ]; + let expiry = next_available_reset_credit_expiry(&credits, now).expect("expiry"); + assert_eq!(expiry, utc("2026-07-05T00:00:00Z")); +} + +#[test] +fn next_expiry_skips_past_and_non_available() { + let now = utc("2026-07-01T00:00:00Z"); + let credits = vec![ + credit(Some("available"), "2026-06-01T00:00:00Z"), + credit(Some("used"), "2026-07-03T00:00:00Z"), + credit(Some("AVAILABLE"), "2026-07-08T00:00:00Z"), + credit(None, "2026-07-09T00:00:00Z"), + ]; + let expiry = next_available_reset_credit_expiry(&credits, now).expect("expiry"); + assert_eq!(expiry, utc("2026-07-08T00:00:00Z")); +} + +#[test] +fn reset_credits_window_sets_informational_and_expiry() { + let now = utc("2026-07-01T00:00:00Z"); + let reset = ResetCredits { + available_count: 2, + credits: vec![ + credit(Some("available"), "2026-07-15T12:00:00Z"), + credit(Some("available"), "2026-07-10T12:00:00Z"), + ], + }; + let window = reset_credits_rate_window(&reset, now); + assert!(window.is_informational); + assert_eq!( + window.reset_description.as_deref(), + Some("2 reset credits available") + ); + assert_eq!(window.resets_at, Some(utc("2026-07-10T12:00:00Z"))); +} + +#[test] +fn reset_credits_window_count_only_without_expiry() { + let now = utc("2026-07-01T00:00:00Z"); + let reset = ResetCredits { + available_count: 1, + credits: vec![], + }; + let window = reset_credits_rate_window(&reset, now); + assert!(window.is_informational); + assert_eq!( + window.reset_description.as_deref(), + Some("1 reset credit available") + ); + assert!(window.resets_at.is_none()); +} + +const PLUS_USAGE: &str = r#"{"plan_type":"plus","rate_limit":{"primary_window":{"used_percent":10,"limit_window_seconds":18000}}}"#; + +fn utc(value: &str) -> DateTime { + DateTime::parse_from_rfc3339(value) + .unwrap() + .with_timezone(&Utc) +} + +fn credit(status: Option<&str>, expires_at: &str) -> ResetCredit { + ResetCredit { + id: None, + reset_type: None, + status: status.map(str::to_string), + expires_at: Some(expires_at.to_string()), + } +} + +/// An unsigned JWT whose payload carries only `exp` (seconds since the epoch). +fn jwt_with_exp(exp: i64) -> String { + let payload = + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(format!(r#"{{"exp":{exp}}}"#)); + format!("header.{payload}.signature") +} + +fn external_creds(access_token: &str, last_refresh: Option>) -> CodexCredentials { + CodexCredentials { + access_token: access_token.to_string(), + account_id: None, + is_external_oauth: true, + access_token_expires_at: None, + last_refresh, + } +} + +async fn mock_reset_credits( + server: &mut mockito::ServerGuard, + available_count: u32, +) -> mockito::Mock { + mock_response_expect( + server, + "GET", + RESET_CREDITS_PATH, + 200, + format!(r#"{{"available_count":{available_count},"credits":[]}}"#), + 1, + ) + .await +} + +fn write_codex_home(base_url: &str) -> tempfile::TempDir { + let dir = tempfile::tempdir().expect("temp codex home"); + std::fs::write( + dir.path().join("auth.json"), + r#"{"tokens":{"access_token":"test-token","account_id":"acct_test"}}"#, + ) + .expect("auth.json"); + std::fs::write( + dir.path().join("config.toml"), + format!("chatgpt_base_url = \"{base_url}\""), + ) + .expect("config.toml"); + dir +} + +#[tokio::test] +async fn reset_credit_cache_single_flight_and_unknown_are_ten_minute_observations() { + let mut server = mockito::Server::new_async().await; + let request = mock_status_expect(&mut server, "GET", RESET_CREDITS_PATH, 503, 1).await; + let home = write_codex_home(&server.url()); + let api = CodexApi::new().with_codex_home(home.path()); + let creds = api.load_credentials().await.unwrap(); + let base = server.url(); + let (first, second) = tokio::join!( + api.fetch_rate_limit_reset_credits_cached(&creds, &base), + api.fetch_rate_limit_reset_credits_cached(&creds, &base), + ); + assert!(first.is_none() && second.is_none()); + assert!( + api.fetch_rate_limit_reset_credits_cached(&creds, &base) + .await + .is_none() + ); + request.assert_async().await; + assert!(RESET_CREDITS_CACHE_TTL == Duration::from_secs(600)); +} + +#[tokio::test] +async fn reset_credit_cache_expires_and_token_rotation_uses_new_scope() { + let mut server = mockito::Server::new_async().await; + let first = server + .mock("GET", "/wham/rate-limit-reset-credits") + .match_header("authorization", "Bearer test-token") + .expect(1) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"available_count":2,"credits":[]}"#) + .create_async() + .await; + let home = write_codex_home(&server.url()); + let api = CodexApi::new().with_codex_home(home.path()); + let mut creds = api.load_credentials().await.unwrap(); + let base = server.url(); + assert_eq!( + api.fetch_rate_limit_reset_credits_cached(&creds, &base) + .await + .unwrap() + .available_count, + 2 + ); + assert_eq!( + api.fetch_rate_limit_reset_credits_cached(&creds, &base) + .await + .unwrap() + .available_count, + 2 + ); + first.assert_async().await; + first.remove_async().await; + let second = server + .mock("GET", "/wham/rate-limit-reset-credits") + .match_header("authorization", "Bearer test-token") + .expect(1) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"available_count":1,"credits":[]}"#) + .create_async() + .await; + api.reset_credits_cache_slot(&creds, &base) + .lock() + .await + .loaded_at = Some(Instant::now() - RESET_CREDITS_CACHE_TTL); + assert_eq!( + api.fetch_rate_limit_reset_credits_cached(&creds, &base) + .await + .unwrap() + .available_count, + 1 + ); + second.assert_async().await; + second.remove_async().await; + creds.access_token = "rotated-token".into(); + let rotated = server + .mock("GET", "/wham/rate-limit-reset-credits") + .match_header("authorization", "Bearer rotated-token") + .expect(1) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"available_count":3,"credits":[]}"#) + .create_async() + .await; + assert_eq!( + api.fetch_rate_limit_reset_credits_cached(&creds, &base) + .await + .unwrap() + .available_count, + 3 + ); + rotated.assert_async().await; +} + +#[tokio::test] +async fn reset_credit_cache_is_scoped_to_the_codex_home() { + // Same base URL, account and token, different Codex homes: each home + // makes its own observation instead of reading the other's. + let mut server = mockito::Server::new_async().await; + let base = server.url(); + let first = mock_reset_credits(&mut server, 2).await; + let first_home = write_codex_home(&base); + let first_api = CodexApi::new().with_codex_home(first_home.path()); + let first_creds = first_api.load_credentials().await.unwrap(); + let first_count = first_api + .fetch_rate_limit_reset_credits_cached(&first_creds, &base) + .await + .map(|credits| credits.available_count); + assert_eq!(first_count, Some(2)); + first.assert_async().await; + first.remove_async().await; + + let second = mock_status_expect(&mut server, "GET", RESET_CREDITS_PATH, 503, 1).await; + let second_home = write_codex_home(&base); + let second_api = CodexApi::new().with_codex_home(second_home.path()); + let second_creds = second_api.load_credentials().await.unwrap(); + assert_eq!(first_creds.access_token, second_creds.access_token); + assert_eq!(first_creds.account_id, second_creds.account_id); + assert!( + second_api + .fetch_rate_limit_reset_credits_cached(&second_creds, &base) + .await + .is_none() + ); + second.assert_async().await; +} + +#[tokio::test] +async fn suspicious_weekly_reset_uses_independent_credit_observations() { + let mut server = mockito::Server::new_async().await; + let cached_response = mock_reset_credits(&mut server, 2).await; + let home = write_codex_home(&server.url()); + let api = CodexApi::new().with_codex_home(home.path()); + let creds = api.load_credentials().await.unwrap(); + let base = server.url(); + api.fetch_rate_limit_reset_credits_cached(&creds, &base) + .await + .unwrap(); + cached_response.assert_async().await; + cached_response.remove_async().await; + + let started = Instant::now(); + let initial_response = mock_reset_credits(&mut server, 1).await; + let initial = api + .fresh_reset_credits_for_confirmation(&creds, &base, started) + .await + .unwrap(); + assert_eq!(initial.available_count, 1); + initial_response.assert_async().await; + initial_response.remove_async().await; + + let confirmation_response = mock_reset_credits(&mut server, 0).await; + let confirmation = api + .fetch_rate_limit_reset_credits_fresh(&creds, &base) + .await + .unwrap(); + assert_eq!(confirmation.available_count, 0); + confirmation_response.assert_async().await; +} + +#[tokio::test] +async fn pending_delayed_candidate_revalidates_with_a_fresh_credit_observation() { + let mut server = mockito::Server::new_async().await; + let candidate_observation = mock_reset_credits(&mut server, 2).await; + let home = write_codex_home(&server.url()); + let api = CodexApi::new().with_codex_home(home.path()); + let creds = api.load_credentials().await.unwrap(); + let base = server.url(); + let cached = api + .fetch_rate_limit_reset_credits_cached(&creds, &base) + .await; + candidate_observation.assert_async().await; + candidate_observation.remove_async().await; + + // A later refresh: the ten-minute cache still holds the observation + // that created the candidate. + let started = Instant::now(); + let changed = mock_reset_credits(&mut server, 1).await; + let without_candidate = api + .initial_reset_credits(&creds, &base, started, cached.clone(), false) + .await; + assert_eq!( + without_candidate.map(|credits| credits.available_count), + Some(2) + ); + let with_candidate = api + .initial_reset_credits(&creds, &base, started, cached, true) + .await; + assert_eq!( + with_candidate.map(|credits| credits.available_count), + Some(1) + ); + changed.assert_async().await; +} + +#[tokio::test] +async fn fetch_usage_attaches_reset_credits_from_http() { + let mut server = mockito::Server::new_async().await; + let soonest = (Utc::now() + chrono::Duration::days(5)).to_rfc3339(); + let later = (Utc::now() + chrono::Duration::days(12)).to_rfc3339(); + + let usage_mock = server + .mock("GET", "/wham/usage") + .match_header("authorization", "Bearer test-token") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(PLUS_USAGE) + .create_async() + .await; + + let reset_body = format!( + r#"{{"available_count":2,"credits":[ + {{"status":"available","expires_at":"{later}"}}, + {{"status":"available","expires_at":"{soonest}"}} + ]}}"# + ); + let reset_mock = server + .mock("GET", "/wham/rate-limit-reset-credits") + .match_header("authorization", "Bearer test-token") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(reset_body) + .create_async() + .await; + + let home = write_codex_home(&server.url()); + let api = CodexApi::new().with_codex_home(home.path()); + let (usage, _, _) = api.fetch_usage().await.expect("fetch_usage"); + + usage_mock.assert_async().await; + reset_mock.assert_async().await; + + let extra = usage + .extra_rate_windows + .iter() + .find(|w| w.id == "reset-credits") + .expect("reset-credits window attached"); + assert_eq!(extra.title, "Reset credits"); + assert!(extra.window.is_informational); + assert_eq!( + extra.window.reset_description.as_deref(), + Some("2 reset credits available") + ); + let expected = utc(&soonest); + assert_eq!(extra.window.resets_at, Some(expected)); +} + +#[tokio::test] +async fn authenticated_codex_http_distinguishes_401_from_403() { + for (status, expects_authentication) in [(401, true), (403, false)] { + let mut server = mockito::Server::new_async().await; + let mock = server + .mock("GET", "/wham/usage") + .with_status(status) + .with_body("fixture refusal") + .create_async() + .await; + + let home = write_codex_home(&server.url()); + let api = CodexApi::new().with_codex_home(home.path()); + let error = match api.fetch_usage().await { + Ok(_) => panic!("expected HTTP {status} to fail"), + Err(error) => error, + }; + + if expects_authentication { + assert!(matches!(error, ProviderError::AuthRequired)); + } else { + let message = error.to_string(); + assert!(message.contains("403")); + assert!(message.contains("fixture refusal")); + assert!(!matches!(error, ProviderError::AuthRequired)); + } + mock.assert_async().await; + } +} + +#[tokio::test] +async fn old_opaque_external_oauth_reaches_usage_request() { + let mut server = mockito::Server::new_async().await; + let usage_mock = server + .mock("GET", "/wham/usage") + .match_header("authorization", "Bearer opaque-token") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(PLUS_USAGE) + .create_async() + .await; + let reset_mock = server + .mock("GET", "/wham/rate-limit-reset-credits") + .match_header("authorization", "Bearer opaque-token") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"available_count":0,"credits":[]}"#) + .create_async() + .await; + + let creds = CodexApi::parse_credentials_json( + r#"{ + "tokens": { + "access_token": "opaque-token", + "refresh_token": "refresh", + "account_id": "acct_test" + }, + "last_refresh": "2026-01-01T00:00:00Z" + }"#, + ) + .expect("credentials"); + assert!(CodexApi::enforce_external_oauth_gate_at(&creds, false, Utc::now()).is_ok()); + + let api = CodexApi::new(); + let (usage, _, _) = api + .fetch_usage_once(&creds, &server.url()) + .await + .expect("opaque OAuth usage request"); + assert_eq!(usage.primary.used_percent, 10.0); + usage_mock.assert_async().await; + reset_mock.assert_async().await; +} + +#[tokio::test] +async fn fetch_usage_skips_reset_credits_when_available_count_zero() { + let mut server = mockito::Server::new_async().await; + + let usage_mock = mock_response(&mut server, "GET", USAGE_PATH, 200, PLUS_USAGE).await; + + let reset_mock = mock_reset_credits(&mut server, 0).await; + + let home = write_codex_home(&server.url()); + let api = CodexApi::new().with_codex_home(home.path()); + let (usage, _, _) = api.fetch_usage().await.expect("fetch_usage"); + + usage_mock.assert_async().await; + reset_mock.assert_async().await; + + assert!( + usage + .extra_rate_windows + .iter() + .all(|w| w.id != "reset-credits"), + "available_count=0 must not attach reset-credits" + ); +} + +#[test] +fn keeps_weekly_window_in_secondary_when_session_is_absent() { + let api = CodexApi::new(); + let (usage, _) = api + .build_result_from_json(&json!({ + "rate_limit": { + "secondary_window": { + "used_percent": 25, + "limit_window_seconds": 604800, + "reset_at": 1783036800 + } + } + })) + .expect("codex usage"); + + assert!(usage.primary.is_informational); + assert_eq!(usage.primary.window_minutes, Some(300)); + assert_eq!( + usage.primary.reset_description.as_deref(), + Some("No active 5h session") + ); + + let weekly = usage.secondary.expect("weekly window"); + assert!(!weekly.is_informational); + assert_eq!(weekly.used_percent, 25.0); + assert_eq!(weekly.window_minutes, Some(10080)); +} + +#[test] +fn identifies_rate_limit_array_windows_by_duration() { + let api = CodexApi::new(); + let (usage, _) = api + .build_result_from_json(&json!({ + "rate_limits": [ + { + "used_percent": 25, + "limit_window_seconds": 604800, + "reset_at": 1783036800 + }, + { + "used_percent": 10, + "limit_window_seconds": 18000, + "reset_at": 1783018800 + } + ] + })) + .expect("codex usage"); + + assert!(!usage.primary.is_informational); + assert_eq!(usage.primary.used_percent, 10.0); + assert_eq!(usage.primary.window_minutes, Some(300)); + + let weekly = usage.secondary.expect("weekly window"); + assert_eq!(weekly.used_percent, 25.0); + assert_eq!(weekly.window_minutes, Some(10080)); +} + +#[test] +fn identifies_weekly_only_rate_limit_array_without_a_session() { + let api = CodexApi::new(); + let (usage, _) = api + .build_result_from_json(&json!({ + "rate_limits": [{ + "used_percent": 25, + "limit_window_seconds": 604800, + "reset_at": 1783036800 + }] + })) + .expect("codex usage"); + + assert!(usage.primary.is_informational); + assert_eq!(usage.secondary.expect("weekly window").used_percent, 25.0); +} + +#[test] +fn maps_codex_spark_additional_rate_limits() { + let api = CodexApi::new(); + let (usage, _) = api + .build_result_from_json(&json!({ + "plan_type": "pro", + "rate_limit": { + "primary_window": { "used_percent": 20, "limit_window_seconds": 18000 }, + "secondary_window": { "used_percent": 40, "limit_window_seconds": 604800 } + }, + "additional_rate_limits": [ + { + "limit_name": "Codex Spark", + "metered_feature": "codex_spark", + "rate_limit": { + "primary_window": { "used_percent": "17", "limit_window_seconds": 18000 } + } + }, + { + "limit_name": "Codex Spark Weekly", + "metered_feature": "codex_spark", + "rate_limit": { + "secondary_window": { "used_percent": 62, "limit_window_seconds": 604800 } + } + } + ] + })) + .expect("codex usage"); + + assert_eq!(usage.extra_rate_windows.len(), 2); + assert_eq!(usage.extra_rate_windows[0].id, "codex-spark"); + assert_eq!(usage.extra_rate_windows[0].title, "Codex Spark 5-hour"); + assert_eq!(usage.extra_rate_windows[0].window.used_percent, 17.0); + assert_eq!(usage.extra_rate_windows[1].id, "codex-spark-weekly"); + assert_eq!(usage.extra_rate_windows[1].title, "Codex Spark Weekly"); + assert_eq!(usage.extra_rate_windows[1].window.used_percent, 62.0); +} + +#[test] +fn ignores_placeholder_additional_rate_limits() { + let api = CodexApi::new(); + let (usage, _) = api + .build_result_from_json(&json!({ + "rate_limit": { + "primary_window": { "used_percent": 0, "limit_window_seconds": 18000 } + }, + "additional_rate_limits": [ + { + "limit_name": "placeholder", + "metered_feature": "placeholder", + "rate_limit": { "primary_window": {} } + } + ] + })) + .expect("codex usage"); + + assert!(usage.extra_rate_windows.is_empty()); +} + +fn win(minutes: u32, used: f64) -> RateWindow { + RateWindow::with_details(used, Some(minutes), None, None) +} + +#[test] +fn normalize_named_windows_routes_by_role() { + // Primary is reported as -1.0 for the "No active 5h session" placeholder. + let s = |used| win(300, used); + let w = |used| win(10_080, used); + let u = |used| win(999, used); + let m = |used| win(43_200, used); + type Row = (Option, Option, f64, Option); + let rows: Vec = vec![ + (None, None, -1.0, None), + (Some(w(1.0)), None, -1.0, Some(1.0)), + (Some(s(1.0)), None, 1.0, None), + (Some(u(1.0)), None, 1.0, None), + (Some(m(1.0)), None, 1.0, None), + (None, Some(w(2.0)), -1.0, Some(2.0)), + (None, Some(s(2.0)), 2.0, None), + (None, Some(u(2.0)), 2.0, None), + (Some(w(1.0)), Some(s(2.0)), 2.0, Some(1.0)), + (Some(w(1.0)), Some(u(2.0)), -1.0, Some(1.0)), + (Some(u(1.0)), Some(s(2.0)), 2.0, Some(1.0)), + (Some(s(1.0)), Some(w(2.0)), 1.0, Some(2.0)), + (Some(u(1.0)), Some(w(2.0)), 1.0, Some(2.0)), + (Some(s(1.0)), Some(s(2.0)), 1.0, Some(2.0)), + (Some(w(1.0)), Some(w(2.0)), 1.0, Some(2.0)), + (Some(u(1.0)), Some(u(2.0)), 1.0, Some(2.0)), + (Some(m(1.0)), Some(w(2.0)), 1.0, Some(2.0)), + ]; + for (index, (primary, secondary, want_primary, want_secondary)) in rows.into_iter().enumerate() + { + let (got_primary, got_secondary) = normalize_named_windows(primary, secondary); + let got_primary = if got_primary.is_informational { + -1.0 + } else { + got_primary.used_percent + }; + assert_eq!(got_primary, want_primary, "row {index} primary"); + assert_eq!( + got_secondary.map(|window| window.used_percent), + want_secondary, + "row {index} secondary" + ); + } +} + +#[tokio::test] +async fn authed_get_sends_account_header_only_when_non_empty() { + let mut server = mockito::Server::new_async().await; + let with_account = server + .mock("GET", "/with") + .match_header("authorization", "Bearer tok") + .match_header("user-agent", "CodexBar") + .match_header("accept", "application/json") + .match_header("chatgpt-account-id", "acct-1") + .with_status(200) + .create_async() + .await; + let without_account = server + .mock("GET", "/without") + .match_header("authorization", "Bearer tok") + .match_header("chatgpt-account-id", mockito::Matcher::Missing) + .expect(2) + .with_status(200) + .create_async() + .await; + let api = CodexApi::new(); + for (path, account_id) in [ + ("/with", Some("acct-1")), + ("/without", Some("")), + ("/without", None), + ] { + let status = api + .authed_get(&format!("{}{path}", server.url()), "tok", account_id) + .send() + .await + .expect("send") + .status(); + assert_eq!(status.as_u16(), 200, "{path} {account_id:?}"); + } + with_account.assert_async().await; + without_account.assert_async().await; +} + +#[test] +fn f5_normalize_array_routes_session_weekly_monthly_to_lanes() { + // 5h session + weekly + monthly → (session, weekly, monthly, None) + let windows = vec![win(300, 10.0), win(10_080, 20.0), win(43_200, 30.0)]; + let (primary, secondary, tertiary, code_review) = normalize_array_windows(windows); + assert_eq!(primary.window_minutes, Some(300)); + assert_eq!(secondary.unwrap().window_minutes, Some(10_080)); + assert_eq!(tertiary.unwrap().window_minutes, Some(43_200)); + assert!(code_review.is_none()); +} + +#[test] +fn f5_normalize_array_monthly_routes_to_tertiary_not_secondary() { + // Monthly must go to tertiary, NOT secondary — so #268's weekly math + // and "Weekly" label stay untouched. + let windows = vec![win(43_200, 50.0), win(10_080, 20.0)]; + let (primary, secondary, tertiary, _) = normalize_array_windows(windows); + assert_eq!(primary.window_minutes, Some(300)); // no session → placeholder + assert_eq!(secondary.unwrap().window_minutes, Some(10_080)); + assert_eq!(tertiary.unwrap().window_minutes, Some(43_200)); +} + +#[test] +fn f5_normalize_array_empty_returns_placeholder_primary() { + let (primary, secondary, tertiary, code_review) = normalize_array_windows(vec![]); + assert!(primary.is_informational); + assert!(secondary.is_none()); + assert!(tertiary.is_none()); + assert!(code_review.is_none()); +} + +#[test] +fn f5_normalize_array_unknown_windows_fall_to_code_review() { + // Windows with unrecognized durations (not 300/10080/43200) go to the + // remaining/code_review bucket. + let windows = vec![win(300, 10.0), win(999, 5.0)]; + let (primary, secondary, tertiary, code_review) = normalize_array_windows(windows); + assert_eq!(primary.window_minutes, Some(300)); + assert!(secondary.is_none()); + assert!(tertiary.is_none()); + assert_eq!(code_review.unwrap().window_minutes, Some(999)); +} + +// ── Upstream 0.50.1 #2944: external OAuth source gate ────────────────── + +#[test] +fn confirmation_failure_fallback_keeps_first_successful_usage_and_cost() { + let state = weekly_reset::AccountState::default(); + let first = UsageSnapshot::new(RateWindow::new(10.0)).with_secondary(RateWindow::new(0.5)); + let cost = Some(CostSnapshot::new(3.25, "USD", "Monthly")); + let (usage, kept_cost) = (weekly_reset::preserve_weekly(&state, first), cost); + assert!((usage.secondary.expect("weekly").used_percent - 0.5).abs() < f64::EPSILON); + assert_eq!(kept_cost.expect("cost").used, 3.25); +} +#[test] +fn api_key_credentials_are_not_external_oauth() { + let creds = + CodexApi::parse_credentials_json(r#"{"OPENAI_API_KEY": "sk-test"}"#).expect("credentials"); + assert!(!creds.is_external_oauth); + assert!(creds.access_token_expires_at.is_none()); + assert!(creds.last_refresh.is_none()); + assert!(CodexApi::enforce_external_oauth_gate(&creds).is_ok()); +} + +#[test] +fn oauth_tokens_with_refresh_token_are_external_source() { + let creds = CodexApi::parse_credentials_json( + r#"{ + "tokens": { + "access_token": "access", + "refresh_token": "refresh", + "account_id": "acct_123" + } + }"#, + ) + .expect("credentials"); + assert!(creds.is_external_oauth); + assert!(creds.access_token_expires_at.is_none()); + assert!(creds.last_refresh.is_none()); +} + +#[test] +fn oauth_tokens_without_refresh_token_are_not_external() { + let creds = CodexApi::parse_credentials_json( + r#"{ + "tokens": { + "access_token": "access", + "account_id": "acct_123" + } + }"#, + ) + .expect("credentials"); + assert!(!creds.is_external_oauth); +} + +#[test] +fn external_oauth_gate_fails_closed_without_last_refresh() { + let creds = external_creds("access", None); + let err = CodexApi::enforce_external_oauth_gate(&creds) + .expect_err("external OAuth without provenance must fail closed"); + assert!(matches!(err, ProviderError::AuthRequired)); +} + +#[test] +fn external_oauth_gate_ignores_old_last_refresh_for_opaque_token() { + let old = Utc::now() - chrono::Duration::days(10); + let creds = external_creds("access", Some(old)); + assert!(CodexApi::enforce_external_oauth_gate(&creds).is_ok()); +} + +#[test] +fn external_oauth_gate_allows_refresh_provenance() { + let fresh = Utc::now() - chrono::Duration::hours(1); + let creds = external_creds("access", Some(fresh)); + assert!(CodexApi::enforce_external_oauth_gate(&creds).is_ok()); +} + +#[test] +fn external_oauth_gate_uses_future_jwt_expiry_over_old_last_refresh() { + let now = Utc::now(); + let future = now + chrono::Duration::hours(2); + let token = jwt_with_exp(future.timestamp()); + let json = format!( + r#"{{"tokens":{{"access_token":"{token}","refresh_token":"refresh"}},"last_refresh":"2026-01-01T00:00:00Z"}}"# + ); + let creds = CodexApi::parse_credentials_json(&json).expect("credentials"); + assert!(creds.access_token_expires_at.is_some()); + assert!(CodexApi::enforce_external_oauth_gate_at(&creds, false, now).is_ok()); + assert!(CodexApi::enforce_external_oauth_gate_at(&creds, true, now).is_ok()); +} + +#[test] +fn external_oauth_gate_rejects_expired_jwt() { + let expired = Utc::now() - chrono::Duration::minutes(1); + let token = jwt_with_exp(expired.timestamp()); + let json = format!( + r#"{{"tokens":{{"access_token":"{token}","refresh_token":"refresh"}},"last_refresh":"{}"}}"#, + Utc::now().to_rfc3339() + ); + let creds = CodexApi::parse_credentials_json(&json).expect("credentials"); + let err = CodexApi::enforce_external_oauth_gate(&creds) + .expect_err("expired native OAuth must be rejected"); + assert!(matches!(err, ProviderError::AuthRequired)); +} + +#[test] +fn external_oauth_gate_requires_cli_refresh_when_jwt_is_near_expiry() { + let soon = Utc::now() + chrono::Duration::minutes(2); + let token = jwt_with_exp(soon.timestamp()); + let json = format!( + r#"{{"tokens":{{"access_token":"{token}","refresh_token":"refresh"}},"last_refresh":"{}"}}"#, + Utc::now().to_rfc3339() + ); + let creds = CodexApi::parse_credentials_json(&json).expect("credentials"); + let err = CodexApi::enforce_external_oauth_gate(&creds) + .expect_err("near-expiry native OAuth must refresh through the CLI"); + assert!(matches!(err, ProviderError::AuthRequired)); +} + +#[test] +fn external_oauth_gate_allows_missing_last_refresh_when_opted_in() { + let creds = external_creds("opaque-token", None); + assert!(CodexApi::enforce_external_oauth_gate_at(&creds, true, Utc::now()).is_ok()); +} + +#[test] +fn opaque_token_uses_refresh_provenance_when_no_jwt_expiry_exists() { + let fresh = Utc::now().to_rfc3339(); + let json = format!( + r#"{{"tokens":{{"access_token":"opaque-token","refresh_token":"refresh"}},"last_refresh":"{fresh}"}}"# + ); + let creds = CodexApi::parse_credentials_json(&json).expect("credentials"); + assert!(creds.access_token_expires_at.is_none()); + assert!(CodexApi::enforce_external_oauth_gate(&creds).is_ok()); +} +#[test] +fn parse_timestamp_reads_iso8601() { + assert!(parse_timestamp("2026-08-17T10:00:00Z").is_some()); + assert!(parse_timestamp("2026-08-17T10:00:00.123Z").is_some()); + assert!(parse_timestamp(" 2026-08-17T10:00:00Z ").is_some()); + assert!(parse_timestamp("").is_none()); + assert!(parse_timestamp("not-a-date").is_none()); +} From c8584064c131921f25376104a58472c4eaf4f946 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:08:27 +0700 Subject: [PATCH 42/44] Split Claude CLI probe, binary lookup and text helpers out of mod.rs --- rust/src/providers/claude/cli_binary.rs | 116 ++ rust/src/providers/claude/cli_probe.rs | 568 +++++++ rust/src/providers/claude/cli_text.rs | 135 ++ rust/src/providers/claude/mod.rs | 1990 +---------------------- rust/src/providers/claude/tests.rs | 1164 +++++++++++++ 5 files changed, 1999 insertions(+), 1974 deletions(-) create mode 100644 rust/src/providers/claude/cli_binary.rs create mode 100644 rust/src/providers/claude/cli_probe.rs create mode 100644 rust/src/providers/claude/cli_text.rs create mode 100644 rust/src/providers/claude/tests.rs diff --git a/rust/src/providers/claude/cli_binary.rs b/rust/src/providers/claude/cli_binary.rs new file mode 100644 index 00000000000..3c5a05a7325 --- /dev/null +++ b/rust/src/providers/claude/cli_binary.rs @@ -0,0 +1,116 @@ +#[cfg(windows)] +use std::os::windows::process::CommandExt; +#[cfg(windows)] +use std::process::{Command as StdCommand, Stdio}; + +/// Locate the Claude CLI for shell integrations that need to reopen a session. +pub fn locate_claude_binary() -> Option { + if let Some(path) = std::env::var_os("CLAUDE_BINARY") + .map(std::path::PathBuf::from) + .filter(|path| path.is_file()) + { + return Some(path); + } + + #[cfg(windows)] + { + let candidates = [ + // Direct install + dirs::data_local_dir().map(|p| p.join("Programs").join("claude").join("claude.exe")), + // npm global (AppData\Roaming\npm) + dirs::data_local_dir().map(|p| p.join("npm").join("claude.cmd")), + dirs::home_dir().map(|h| { + h.join("AppData") + .join("Roaming") + .join("npm") + .join("claude.cmd") + }), + // npm global alternate (~\.npm-global) + dirs::home_dir().map(|h| h.join(".npm-global").join("claude.cmd")), + // Volta managed + dirs::data_local_dir().map(|p| { + p.join("Volta") + .join("tools") + .join("image") + .join("packages") + .join("@anthropic-ai") + .join("claude-code") + .join("bin") + .join("claude.cmd") + }), + // fnm managed (via shim) + dirs::data_local_dir().map(|p| p.join("fnm_multishells").join("claude.cmd")), + // PATH lookup + find_windows_claude_in_path(), + ]; + + candidates.into_iter().flatten().find(|p| p.exists()) + } + + #[cfg(not(windows))] + { + which::which("claude").ok() + } +} + +#[cfg(windows)] +fn find_windows_claude_in_path() -> Option { + const CREATE_NO_WINDOW: u32 = 0x08000000; + + let mut command = StdCommand::new("where"); + command + .arg("claude") + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .creation_flags(CREATE_NO_WINDOW); + let output = command.output().ok()?; + + if !output.status.success() { + return None; + } + + let mut matches: Vec<_> = String::from_utf8_lossy(&output.stdout) + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(std::path::PathBuf::from) + .collect(); + + matches.sort_by_key(|path| { + match path + .extension() + .and_then(|ext| ext.to_str()) + .map(|ext| ext.to_ascii_lowercase()) + .as_deref() + { + Some("cmd") => 0, + Some("bat") => 1, + Some("exe") => 2, + _ => 3, + } + }); + + matches.into_iter().find(|path| path.exists()) +} + +/// Detect the version of the claude CLI +pub(super) fn detect_claude_version() -> Option { + let claude_path = locate_claude_binary()?; + + #[cfg(windows)] + const CREATE_NO_WINDOW: u32 = 0x08000000; + + let mut cmd = std::process::Command::new(claude_path); + cmd.args(["--version"]); + #[cfg(windows)] + cmd.creation_flags(CREATE_NO_WINDOW); + + let output = cmd.output().ok()?; + + if output.status.success() { + let version_str = String::from_utf8_lossy(&output.stdout); + super::super::extract_semver(&version_str) + } else { + None + } +} diff --git a/rust/src/providers/claude/cli_probe.rs b/rust/src/providers/claude/cli_probe.rs new file mode 100644 index 00000000000..359f5ec8035 --- /dev/null +++ b/rust/src/providers/claude/cli_probe.rs @@ -0,0 +1,568 @@ +use super::cli_binary::locate_claude_binary; +use super::cli_text::is_workspace_trust_prompt; +use super::{ClaudeProvider, accounts, auto_precision, cli_screen, trust_dialog, unix_now_secs}; +use crate::cli::tty_runner::{TtyCommandOptions, TtyCommandRunner}; +use crate::core::ProviderError; +use std::time::{Duration, Instant}; + +const CLAUDE_PROBE_SESSION_ID_FILE: &str = ".codexbar-session-id"; +const CLAUDE_PROBE_LOCK_FILE: &str = ".codexbar-probe.lock"; +pub(super) const CLAUDE_PROBE_CACHE_FILE: &str = ".codexbar-usage-cache.json"; +/// How long a second codexbar process waits for a running probe to finish. +const CLAUDE_PROBE_LOCK_WAIT: Duration = Duration::from_secs(30); +/// Every codexbar process (the `serve` daemon, one-off `usage` calls from the +/// companion) launches its own Claude CLI for a probe. The interactive +/// `/usage` screen costs 6-10 s of CPU each time, so a recent successful +/// probe output is shared across processes for this long. +pub(super) const CLAUDE_PROBE_CACHE_TTL: Duration = Duration::from_secs(45); + +#[derive(serde::Serialize, serde::Deserialize)] +pub(super) struct ClaudeProbeCache { + pub(super) captured_at_unix: u64, + /// `claude_login_fingerprint` of the login the screen belongs to. + #[serde(default)] + pub(super) login: String, + pub(super) output: String, +} + +pub(super) fn load_cached_probe_output(probe_dir: &std::path::Path, login: &str) -> Option { + let raw = std::fs::read_to_string(probe_dir.join(CLAUDE_PROBE_CACHE_FILE)).ok()?; + let cache: ClaudeProbeCache = serde_json::from_str(&raw).ok()?; + let age = unix_now_secs().saturating_sub(cache.captured_at_unix); + if auto_precision::probe_screen_is_superseded(cache.captured_at_unix) + || login.is_empty() + || cache.login != login + || age > CLAUDE_PROBE_CACHE_TTL.as_secs() + || cache.output.trim().is_empty() + { + return None; + } + tracing::debug!(age_secs = age, "Reusing recent Claude CLI probe output"); + Some(cache.output) +} + +pub(super) fn store_cached_probe_output(probe_dir: &std::path::Path, login: &str, output: &str) { + let cache = ClaudeProbeCache { + captured_at_unix: unix_now_secs(), + login: login.to_string(), + output: output.to_string(), + }; + // Atomic, because other processes read the cache without the probe lock. + let stored = serde_json::to_vec(&cache) + .map_err(anyhow::Error::from) + .and_then(|json| { + crate::atomic_file::write_atomic(&probe_dir.join(CLAUDE_PROBE_CACHE_FILE), &json) + }); + if let Err(err) = stored { + tracing::debug!(error = %err, "failed to persist Claude probe cache"); + } +} + +/// Identifies the Claude login a probe runs under without reading any +/// credential: the location, size and modification time of Claude Code's +/// `.credentials.json`, which every login, token refresh and account switch +/// rewrites. Without a credentials file a probe screen is never shared. +fn claude_login_fingerprint() -> Option { + let credentials = accounts::config_dir().ok()?.join(".credentials.json"); + login_fingerprint_at(&credentials) +} + +pub(super) fn login_fingerprint_at(credentials: &std::path::Path) -> Option { + let metadata = std::fs::metadata(credentials).ok()?; + let modified = metadata + .modified() + .ok()? + .duration_since(std::time::UNIX_EPOCH) + .ok()?; + let identity = format!( + "{}|{}|{}", + credentials.display(), + metadata.len(), + modified.as_nanos() + ); + Some(crate::core::sha256_hex(identity.as_bytes())) +} + +/// The probe screen as it may appear in a log: secrets and email addresses +/// (the screen can show the signed-in account) are masked. +pub(super) fn redacted_probe_screen(visible: &str) -> String { + let redacted = crate::core::SecretRedactor::redact(visible); + crate::core::PersonalInfoRedactor::redact_emails_in_text(Some(&redacted), true) + .unwrap_or(redacted) +} + +/// Only a parseable usage screen is worth sharing; errors are retried live. +fn claude_cli_output_is_shareable(output: &str) -> bool { + claude_cli_error_from_output(output).is_none() + && ClaudeProvider::new().parse_cli_output(output).is_ok() +} + +/// Cross-process guard around the Claude PTY probe. Claude Code refuses to +/// start a session whose `--session-id` is already running ("Session ID … +/// is already in use"), so two codexbar processes (for example the +/// `serve` daemon and a one-off `usage` call) must not probe concurrently. +pub(super) struct ClaudeProbeLock(std::fs::File); + +impl ClaudeProbeLock { + /// Wait for the probe lock. `Ok(None)` means locking is unsupported here + /// and the probe runs unlocked. A probe still running elsewhere after the + /// wait is an error: probing alongside it would reuse its session id. + fn acquire(probe_dir: &std::path::Path) -> Result, ProviderError> { + Self::acquire_within(probe_dir, CLAUDE_PROBE_LOCK_WAIT) + } + + pub(super) fn acquire_within( + probe_dir: &std::path::Path, + wait: Duration, + ) -> Result, ProviderError> { + let path = probe_dir.join(CLAUDE_PROBE_LOCK_FILE); + let file = match std::fs::OpenOptions::new() + .create(true) + .read(true) + .write(true) + .truncate(false) + .open(&path) + { + Ok(file) => file, + Err(err) => { + tracing::debug!(error = %err, "Claude probe lock file unavailable; continuing unlocked"); + return Ok(None); + } + }; + let deadline = Instant::now() + wait; + loop { + match file.try_lock() { + Ok(()) => return Ok(Some(Self(file))), + Err(std::fs::TryLockError::WouldBlock) => {} + Err(std::fs::TryLockError::Error(err)) => { + tracing::debug!(error = %err, "Claude probe lock unavailable; continuing unlocked"); + return Ok(None); + } + } + if Instant::now() >= deadline { + return Err(ProviderError::Other( + "Timed out waiting for another CodexBar process to finish its Claude CLI \ + usage probe." + .to_string(), + )); + } + std::thread::sleep(Duration::from_millis(200)); + } + } +} + +impl Drop for ClaudeProbeLock { + fn drop(&mut self) { + // Best-effort unlock; closing the handle releases it anyway. + let _unlocked = self.0.unlock(); + } +} + +fn claude_usage_probe_dir() -> Result { + let base = dirs::data_local_dir() + .or_else(dirs::home_dir) + .ok_or_else(|| { + ProviderError::Other("Could not resolve a local data directory".to_string()) + })?; + let dir = base.join("CodexBar").join("claude-usage-probe"); + std::fs::create_dir_all(&dir).map_err(|e| { + ProviderError::Other(format!( + "Failed to prepare Claude CLI probe directory: {}", + e + )) + })?; + Ok(dir) +} + +/// Persist and reuse one probe session id so repeated `/usage` PTY launches do +/// not register a fresh empty Claude account session each refresh (upstream #2263). +pub(super) fn load_or_create_probe_session_id(probe_dir: &std::path::Path) -> String { + let path = probe_dir.join(CLAUDE_PROBE_SESSION_ID_FILE); + if let Ok(raw) = std::fs::read_to_string(&path) { + let trimmed = raw.trim(); + if uuid::Uuid::parse_str(trimmed).is_ok() { + return trimmed.to_ascii_lowercase(); + } + } + let id = uuid::Uuid::new_v4().to_string().to_ascii_lowercase(); + if let Err(err) = std::fs::write(&path, &id) { + tracing::debug!(error = %err, "failed to persist Claude probe session id"); + } + id +} + +/// Claude treats `--session-id` as create-only when a local transcript JSONL +/// already exists for that id. Clear probe-dir jsonl leftovers before reuse. +pub(super) fn cleanup_probe_session_jsonl(probe_dir: &std::path::Path) { + let Ok(entries) = std::fs::read_dir(probe_dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) == Some("jsonl") { + // Best-effort cleanup: a locked or missing probe file just stays. + let _removed = std::fs::remove_file(&path); + } + } + cleanup_probe_transcript(probe_dir); +} + +/// Claude stores the transcript for a working directory under +/// `/projects//.jsonl`, where the +/// config dir is `CLAUDE_CONFIG_DIR` or `~/.claude`. Remove the probe session +/// transcripts there, otherwise the fixed `--session-id` fails with "already +/// in use" on the next run. +fn cleanup_probe_transcript(probe_dir: &std::path::Path) { + let Ok(config_dir) = accounts::config_dir() else { + return; + }; + cleanup_probe_transcripts_in(&config_dir.join("projects"), probe_dir); +} + +pub(super) fn cleanup_probe_transcripts_in( + projects_root: &std::path::Path, + probe_dir: &std::path::Path, +) { + let project_dir = projects_root.join(claude_project_dir_name(probe_dir)); + let Ok(entries) = std::fs::read_dir(&project_dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + let is_file = entry.file_type().is_ok_and(|kind| kind.is_file()); + if is_file && path.extension().and_then(|e| e.to_str()) == Some("jsonl") { + // Best-effort cleanup: a locked transcript just stays. + let _removed = std::fs::remove_file(&path); + } + } + // Succeeds only when nothing else is left in the probe's project dir. + let _removed = std::fs::remove_dir(&project_dir); +} + +/// Longest project directory name Claude Code writes before it truncates the +/// name and appends a hash of the full path. +const CLAUDE_PROJECT_DIR_NAME_MAX: usize = 200; + +/// Claude Code's project directory name for a working directory: every UTF-16 +/// code unit that is not an ASCII letter or digit becomes `-` +/// (`C:\Users\x` -> `C--Users-x`), and long names are cut to 200 characters +/// plus `-`. (Claude Code also NFC-normalizes the +/// path first; Windows paths are normally NFC already.) +pub(super) fn claude_project_dir_name(dir: &std::path::Path) -> String { + let path = dir.to_string_lossy(); + let sanitized: String = path + .encode_utf16() + .map(|unit| match u8::try_from(unit) { + Ok(byte) if byte.is_ascii_alphanumeric() => char::from(byte), + _ => '-', + }) + .collect(); + if sanitized.len() <= CLAUDE_PROJECT_DIR_NAME_MAX { + return sanitized; + } + format!( + "{}-{}", + &sanitized[..CLAUDE_PROJECT_DIR_NAME_MAX], + javascript_hash_base36(&path) + ) +} + +/// `Math.abs(hash).toString(36)` of the JavaScript string hash +/// `hash = (hash << 5) - hash + charCode`, kept in 32 bits. +pub(super) fn javascript_hash_base36(text: &str) -> String { + let hash = text.encode_utf16().fold(0i32, |hash, unit| { + hash.wrapping_mul(31).wrapping_add(i32::from(unit)) + }); + let mut magnitude = i64::from(hash).unsigned_abs(); + let mut digits = Vec::new(); + loop { + digits.push(char::from_digit((magnitude % 36) as u32, 36).unwrap_or('0')); + magnitude /= 36; + if magnitude == 0 { + break; + } + } + digits.iter().rev().collect() +} + +/// Arguments shared by every Claude CLI `/usage` probe. +/// +/// The remote-control startup hook can otherwise change the interactive +/// session before the usage command is collected. Keep this override in one +/// helper so future CLI probe paths cannot silently omit it. +pub(super) fn claude_usage_settings_args() -> [String; 2] { + [ + "--settings".to_string(), + // Issue #778 (Claude Code 2.1.293): `tui: default` stops the "Try the new + // fullscreen renderer?" offer. Older builds ignore unknown settings keys. + r#"{"remoteControlAtStartup":false,"tui":"default"}"#.to_string(), + ] +} + +pub(super) fn claude_probe_launch_args(session_id: &str) -> Vec { + let mut args = vec![ + "--setting-sources".to_string(), + "user".to_string(), + "--allowed-tools".to_string(), + String::new(), + ]; + args.extend(claude_usage_settings_args()); + args.extend(["--session-id".to_string(), session_id.to_string()]); + args +} + +struct ClaudePtyProbeOptions { + script: &'static str, + timeout_secs: f64, + idle_timeout_secs: Option, + initial_delay_secs: f64, + script_char_delay_secs: f64, + script_line_delay_secs: f64, + screen_responder: Option, + /// Re-type the script at these offsets while no done marker is visible. + script_retry_delays_secs: &'static [f64], + script_done_substrings: &'static [&'static str], + script_echo_substrings: &'static [&'static str], + /// Idle window after the done marker appeared (trailing output only). + idle_timeout_after_done_secs: Option, + /// Reuse a recent usage screen another process stored, and share this + /// one (the `/usage` probe only, never the trust preflight). + share_output: bool, +} + +/// Offsets (seconds after launch) at which `/usage` is re-sent when Claude's +/// input widget was not ready for the first attempt. Claude Code needs roughly +/// 1-5 s to mount its prompt on Windows, and keystrokes before that are lost. +const CLAUDE_USAGE_RETRY_DELAYS_SECS: &[f64] = &[6.0, 9.5, 14.0]; +/// Output markers that prove `/usage` opened (limits view or activity stats). +const CLAUDE_USAGE_DONE_MARKERS: &[&str] = &[ + "current session", + "current week", + "total duration", + "favorite model:", + "total tokens:", +]; +/// The typed command as Claude echoes it into its prompt line. While this is +/// visible the first attempt is still being processed, so do not type again. +const CLAUDE_USAGE_ECHO_MARKERS: &[&str] = &["❯ /usage", "> /usage", "/usage show session cost"]; + +async fn run_claude_usage_pty_probe( + claude_path: std::path::PathBuf, + working_directory: std::path::PathBuf, +) -> Result { + run_claude_pty_probe( + claude_path, + working_directory, + ClaudePtyProbeOptions { + script: "/usage", + timeout_secs: 24.0, + idle_timeout_secs: Some(6.0), + initial_delay_secs: 3.0, + script_char_delay_secs: 0.04, + script_line_delay_secs: 0.0, + screen_responder: None, + script_retry_delays_secs: CLAUDE_USAGE_RETRY_DELAYS_SECS, + script_done_substrings: CLAUDE_USAGE_DONE_MARKERS, + script_echo_substrings: CLAUDE_USAGE_ECHO_MARKERS, + idle_timeout_after_done_secs: Some(1.5), + share_output: true, + }, + ) + .await +} + +async fn run_claude_trust_preflight( + claude_path: std::path::PathBuf, + working_directory: std::path::PathBuf, +) -> Result { + run_claude_pty_probe( + claude_path, + working_directory, + ClaudePtyProbeOptions { + script: "", + timeout_secs: 15.0, + idle_timeout_secs: Some(4.0), + initial_delay_secs: 0.6, + script_char_delay_secs: 0.0, + script_line_delay_secs: 0.0, + screen_responder: Some(trust_dialog::TRUST_RESPONDER), + script_retry_delays_secs: &[], + script_done_substrings: &[], + script_echo_substrings: &[], + idle_timeout_after_done_secs: None, + share_output: false, + }, + ) + .await +} + +pub(super) fn resolve_claude_cli_path() -> Result { + locate_claude_binary().ok_or_else(|| { + ProviderError::NotInstalled( + "Claude CLI not found. Install from https://docs.claude.ai/claude-code".to_string(), + ) + }) +} + +pub(super) async fn fetch_claude_cli_usage_text( + claude_path: std::path::PathBuf, +) -> Result { + let probe_dir = claude_usage_probe_dir()?; + if let Some(login) = claude_login_fingerprint() + && let Some(cached) = load_cached_probe_output(&probe_dir, &login) + { + return Ok(cached); + } + let combined = run_claude_usage_pty_probe(claude_path.clone(), probe_dir.clone()).await?; + if !is_workspace_trust_prompt(&cli_screen::render(&combined, true).to_lowercase()) { + return Ok(combined); + } + + run_claude_trust_preflight(claude_path.clone(), probe_dir.clone()).await?; + run_claude_usage_pty_probe(claude_path, probe_dir).await +} + +/// Windows launch failures the CLI prints instead of a usage screen, as +/// (lowercase marker, user-facing message). +const CLAUDE_CLI_ENVIRONMENT_ERRORS: &[(&str, &str)] = &[ + ( + "requires git-bash", + "Claude CLI requires Git Bash on Windows. Install Git for Windows or set \ + CLAUDE_CODE_GIT_BASH_PATH to your bash.exe path.", + ), + ( + "running scripts is disabled", + "Claude CLI could not start because PowerShell script execution is disabled. \ + Use claude.cmd or adjust the execution policy.", + ), + ( + "cannot run a document in the middle of a pipeline", + "Claude CLI resolved to a Unix shell script on Windows. Reinstall Claude Code or \ + ensure claude.cmd is first on PATH.", + ), +]; + +/// Auth markers are checked before the environment markers. +pub(super) fn claude_cli_error_from_output(output: &str) -> Option { + let lowered = output.to_lowercase(); + if lowered.contains("not logged in") || lowered.contains("login required") { + return Some(ProviderError::AuthRequired); + } + if lowered.contains("token expired") || lowered.contains("token_expired") { + return Some(ProviderError::OAuthExpired( + "Token expired. Run `claude login` to refresh.".to_string(), + )); + } + if lowered.contains("authentication_error") { + return Some(ProviderError::OAuth( + "Authentication error. Run `claude login`.".to_string(), + )); + } + CLAUDE_CLI_ENVIRONMENT_ERRORS + .iter() + .find(|(marker, _)| lowered.contains(marker)) + .map(|(_, message)| ProviderError::Other((*message).to_string())) +} + +/// Environment overrides for passive Claude CLI PTY probes. +pub(super) fn claude_passive_probe_env( + mut base: std::collections::HashMap, +) -> std::collections::HashMap { + // Passive status/usage probes must not mutate or update the user's Claude CLI installation. + base.insert("NO_COLOR".to_string(), "1".to_string()); + base.insert("DISABLE_AUTOUPDATER".to_string(), "1".to_string()); + // Issue #778 (Claude Code 2.1.293): stops the "Claude in Chrome extension + // detected" offer, which would swallow `/usage`. Claude Code reads this + // variable; older builds ignore it. Probe launch only; config is untouched. + base.insert("CLAUDE_CODE_ENABLE_CFC".to_string(), "0".to_string()); + base +} + +async fn run_claude_pty_probe( + claude_path: std::path::PathBuf, + working_directory: std::path::PathBuf, + probe: ClaudePtyProbeOptions, +) -> Result { + tokio::task::spawn_blocking(move || { + // Keep ownership in the worker: cancelling the async refresh does not + // stop spawn_blocking or its CLI process from rotating credentials. + let _account_operation = accounts::CREDENTIAL_OPERATION.blocking_lock(); + let login: Option<&dyn Fn() -> Option> = + probe.share_output.then_some(&claude_login_fingerprint); + run_locked_probe(&working_directory, login, || { + cleanup_probe_session_jsonl(&working_directory); + let session_id = load_or_create_probe_session_id(&working_directory); + let env = claude_passive_probe_env(TtyCommandRunner::enriched_environment()); + + let mut options = TtyCommandOptions::new() + .with_timeout(probe.timeout_secs) + .with_initial_delay(probe.initial_delay_secs) + .with_script_char_delay(probe.script_char_delay_secs) + .with_script_line_delay(probe.script_line_delay_secs) + .with_working_directory(working_directory.clone()) + .with_extra_args(claude_probe_launch_args(&session_id)); + if let Some(idle) = probe.idle_timeout_secs { + options = options.with_idle_timeout(idle); + } + if let Some(idle) = probe.idle_timeout_after_done_secs { + options = options.with_idle_timeout_after_done(idle); + } + if let Some(responder) = probe.screen_responder { + options = options.with_screen_responder(responder); + } + if !probe.script_retry_delays_secs.is_empty() { + options = options.with_script_retries( + probe.script_retry_delays_secs.to_vec(), + probe + .script_done_substrings + .iter() + .map(|marker| (*marker).to_string()) + .collect(), + probe + .script_echo_substrings + .iter() + .map(|marker| (*marker).to_string()) + .collect(), + ); + } + options.env = env.into(); + + TtyCommandRunner::new() + .run(&claude_path.to_string_lossy(), probe.script, options) + .map(|result| result.text) + .map_err(|error| match error { + crate::cli::tty_runner::TtyCommandError::TimedOut => ProviderError::Timeout, + other => ProviderError::Other(format!("Claude CLI failed: {}", other)), + }) + }) + }) + .await + .map_err(|e| ProviderError::Other(format!("Claude CLI probe failed: {}", e)))? +} + +/// Run one probe under the cross-process probe lock. `login` is set when the +/// screen may be shared and identifies the Claude login it belongs to: a +/// fresh screen another process stored for that login while this one waited +/// is reused, and a parseable screen is stored for the others unless the +/// login changed while the probe ran. +pub(super) fn run_locked_probe( + probe_dir: &std::path::Path, + login: Option<&dyn Fn() -> Option>, + probe: impl FnOnce() -> Result, +) -> Result { + let _probe_lock = ClaudeProbeLock::acquire(probe_dir)?; + let before = login.and_then(|login| login()); + if let Some(before) = &before + && let Some(cached) = load_cached_probe_output(probe_dir, before) + { + return Ok(cached); + } + let output = probe()?; + if let (Some(before), Some(login)) = (&before, login) + && login().as_ref() == Some(before) + && claude_cli_output_is_shareable(&output) + { + store_cached_probe_output(probe_dir, before, &output); + } + Ok(output) +} diff --git a/rust/src/providers/claude/cli_text.rs b/rust/src/providers/claude/cli_text.rs new file mode 100644 index 00000000000..c2883079c50 --- /dev/null +++ b/rust/src/providers/claude/cli_text.rs @@ -0,0 +1,135 @@ +use super::cli_reset::{label_section, normalized_for_label_search, parse_percent_line}; +use regex_lite::Regex; + +pub(super) fn is_non_interactive_slash_command_response(text: &str) -> bool { + let mentions_usage_and_exit = text.contains("/usage") && text.contains("/exit"); + let says_entered_commands = + text.contains("i see you've entered") || text.contains("you've entered two slash commands"); + let says_no_slash_command = text.contains("available custom slash commands") + && text.contains("don't see these commands"); + let says_usage_is_cli_only = text + .contains("token usage and statistics are typically displayed by the cli interface") + || text.contains("i don't have direct access to those metrics"); + + mentions_usage_and_exit + && (says_entered_commands || says_no_slash_command || says_usage_is_cli_only) +} + +pub(super) fn is_workspace_trust_prompt(text: &str) -> bool { + text.contains("quick safety check") + && text.contains("trust this folder") + && text.contains("yes, i trust this folder") +} + +/// Current `/usage` panels show a local session summary above the plan limits, +/// so the presence of a limit section outranks the activity-stats markers. +pub(super) fn has_plan_limit_section(text: &str) -> bool { + text.contains("current session") || text.contains("current week") +} + +pub(super) fn is_cli_activity_stats_response(text: &str) -> bool { + let has_activity_overview = text.contains("favorite model:") || text.contains("total tokens:"); + let has_session_cost_summary = + text.contains("total duration") && text.contains("usage:") && text.contains("cache read"); + + has_activity_overview || has_session_cost_summary +} + +/// The weekly heading Claude prints, newest wording first. +pub(super) const WEEKLY_LABELS: [&str; 2] = ["current week (all models)", "current week"]; + +/// First match of `find` in any section headed by `label`; each section is +/// scanned for at most `max_lines` lines, label line included. +fn find_near_label( + text: &str, + label: &str, + max_lines: usize, + mut find: impl FnMut(&str) -> Option, +) -> Option { + let label_normalized = normalized_for_label_search(label); + let lines: Vec<&str> = text.lines().collect(); + lines + .iter() + .enumerate() + .filter(|(_, line)| normalized_for_label_search(line).contains(&label_normalized)) + .find_map(|(idx, _)| { + label_section(&lines, idx, &label_normalized, max_lines).find_map(&mut find) + }) +} + +/// Percentage near a label (e.g. "Current session"), as "used". +pub(super) fn extract_percent_near_label(text: &str, label: &str) -> Option { + find_near_label(text, label, 12, parse_percent_line) +} + +pub(super) fn is_exhausted_short_form(clean_lower: &str) -> bool { + clean_lower.contains("out of extra usage") || clean_lower.contains("hit your limit") +} + +/// Extract email address from text +pub(super) fn extract_email(text: &str) -> Option { + // Try explicit patterns first + let patterns = [ + r"Account:\s*([^\s@]+@[^\s@]+\.[^\s]+)", + r"Email:\s*([^\s@]+@[^\s@]+\.[^\s]+)", + r"([A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,})", + ]; + + for pattern in patterns { + if let Ok(re) = Regex::new(pattern) + && let Some(caps) = re.captures(text) + && let Some(m) = caps.get(1) + { + return Some(m.as_str().trim().to_string()); + } + } + + None +} + +/// Extract login method / plan name from text +pub(super) fn extract_login_method(text: &str) -> Option { + // Look for explicit "Login method:" line + if let Ok(re) = Regex::new(r"(?i)login\s+method:\s*(.+)") + && let Some(caps) = re.captures(text) + && let Some(m) = caps.get(1) + { + let method = m.as_str().trim(); + if !method.is_empty() { + return Some(clean_plan_name(method)); + } + } + + // Look for "Claude " patterns + if let Ok(re) = Regex::new(r"(?i)(claude\s+(?:max|pro|ultra|team|free)[a-z0-9\s._-]*)") + && let Some(caps) = re.captures(text) + && let Some(m) = caps.get(1) + { + let plan = m.as_str().trim(); + if !plan.to_lowercase().contains("code") { + return Some(clean_plan_name(plan)); + } + } + + None +} + +/// Reset text near a label, from "resets" to the end of its line. +pub(super) fn extract_reset_description(text: &str, label: &str) -> Option { + find_near_label(text, label, 14, extract_inline_reset_description) +} + +/// Extract a "resets ..." suffix from a short single-line status. +pub(super) fn extract_inline_reset_description(text: &str) -> Option { + let lower = text.to_lowercase(); + let pos = lower.find("resets")?; + Some(text[pos..].trim().to_string()) +} + +/// Clean up a plan name from rendered (escape-free) text: drop bracketed +/// codes like `[22m` and trim. +fn clean_plan_name(text: &str) -> String { + let re = Regex::new(r"\[\d+m").unwrap_or_else(|_| Regex::new(".^").unwrap()); + let result = re.replace_all(text, ""); + result.trim().to_string() +} diff --git a/rust/src/providers/claude/mod.rs b/rust/src/providers/claude/mod.rs index 4ecf6f564aa..734928868de 100755 --- a/rust/src/providers/claude/mod.rs +++ b/rust/src/providers/claude/mod.rs @@ -4,8 +4,11 @@ pub mod accounts; mod admin_api; mod auto_precision; pub mod claude_swap; +mod cli_binary; +mod cli_probe; mod cli_reset; mod cli_screen; +mod cli_text; mod oauth; pub mod quota_history; mod reset_credits; @@ -16,27 +19,30 @@ mod web_api; use async_trait::async_trait; use chrono::Utc; -use regex_lite::Regex; -#[cfg(windows)] -use std::os::windows::process::CommandExt; -#[cfg(windows)] -use std::process::{Command as StdCommand, Stdio}; use std::sync::LazyLock; use std::sync::Mutex; use std::time::{Duration, Instant}; -use crate::cli::tty_runner::{TtyCommandOptions, TtyCommandRunner}; use crate::core::{ FetchContext, LastGoodFailurePolicy, Provider, ProviderError, ProviderFetchResult, ProviderId, RateWindow, SourceMode, UsageSnapshot, }; use admin_api::ClaudeAdminApiFetcher; +use cli_binary::detect_claude_version; +pub use cli_binary::locate_claude_binary; +use cli_probe::{ + claude_cli_error_from_output, fetch_claude_cli_usage_text, redacted_probe_screen, + resolve_claude_cli_path, +}; #[cfg(test)] use cli_reset::parse_claude_reset_date_in_system_zone; -use cli_reset::{ - extract_cli_scoped_weekly_limits, label_section, normalized_for_label_search, - parse_claude_reset_date, parse_percent_line, percent_matches, +use cli_reset::{extract_cli_scoped_weekly_limits, parse_claude_reset_date, percent_matches}; +use cli_text::{ + WEEKLY_LABELS, extract_email, extract_inline_reset_description, extract_login_method, + extract_percent_near_label, extract_reset_description, has_plan_limit_section, + is_cli_activity_stats_response, is_exhausted_short_form, + is_non_interactive_slash_command_response, }; // ── Upstream 0.50.1 #2516: CLI usage-result cache ──────────────────────────── @@ -179,26 +185,6 @@ fn claude_plan_label(tier: &str) -> String { } } -const CLAUDE_PROBE_SESSION_ID_FILE: &str = ".codexbar-session-id"; -const CLAUDE_PROBE_LOCK_FILE: &str = ".codexbar-probe.lock"; -const CLAUDE_PROBE_CACHE_FILE: &str = ".codexbar-usage-cache.json"; -/// How long a second codexbar process waits for a running probe to finish. -const CLAUDE_PROBE_LOCK_WAIT: Duration = Duration::from_secs(30); -/// Every codexbar process (the `serve` daemon, one-off `usage` calls from the -/// companion) launches its own Claude CLI for a probe. The interactive -/// `/usage` screen costs 6-10 s of CPU each time, so a recent successful -/// probe output is shared across processes for this long. -const CLAUDE_PROBE_CACHE_TTL: Duration = Duration::from_secs(45); - -#[derive(serde::Serialize, serde::Deserialize)] -struct ClaudeProbeCache { - captured_at_unix: u64, - /// `claude_login_fingerprint` of the login the screen belongs to. - #[serde(default)] - login: String, - output: String, -} - fn unix_now_secs() -> u64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -206,545 +192,6 @@ fn unix_now_secs() -> u64 { .unwrap_or(0) } -fn load_cached_probe_output(probe_dir: &std::path::Path, login: &str) -> Option { - let raw = std::fs::read_to_string(probe_dir.join(CLAUDE_PROBE_CACHE_FILE)).ok()?; - let cache: ClaudeProbeCache = serde_json::from_str(&raw).ok()?; - let age = unix_now_secs().saturating_sub(cache.captured_at_unix); - if auto_precision::probe_screen_is_superseded(cache.captured_at_unix) - || login.is_empty() - || cache.login != login - || age > CLAUDE_PROBE_CACHE_TTL.as_secs() - || cache.output.trim().is_empty() - { - return None; - } - tracing::debug!(age_secs = age, "Reusing recent Claude CLI probe output"); - Some(cache.output) -} - -fn store_cached_probe_output(probe_dir: &std::path::Path, login: &str, output: &str) { - let cache = ClaudeProbeCache { - captured_at_unix: unix_now_secs(), - login: login.to_string(), - output: output.to_string(), - }; - // Atomic, because other processes read the cache without the probe lock. - let stored = serde_json::to_vec(&cache) - .map_err(anyhow::Error::from) - .and_then(|json| { - crate::atomic_file::write_atomic(&probe_dir.join(CLAUDE_PROBE_CACHE_FILE), &json) - }); - if let Err(err) = stored { - tracing::debug!(error = %err, "failed to persist Claude probe cache"); - } -} - -/// Identifies the Claude login a probe runs under without reading any -/// credential: the location, size and modification time of Claude Code's -/// `.credentials.json`, which every login, token refresh and account switch -/// rewrites. Without a credentials file a probe screen is never shared. -fn claude_login_fingerprint() -> Option { - let credentials = accounts::config_dir().ok()?.join(".credentials.json"); - login_fingerprint_at(&credentials) -} - -fn login_fingerprint_at(credentials: &std::path::Path) -> Option { - let metadata = std::fs::metadata(credentials).ok()?; - let modified = metadata - .modified() - .ok()? - .duration_since(std::time::UNIX_EPOCH) - .ok()?; - let identity = format!( - "{}|{}|{}", - credentials.display(), - metadata.len(), - modified.as_nanos() - ); - Some(crate::core::sha256_hex(identity.as_bytes())) -} - -/// The probe screen as it may appear in a log: secrets and email addresses -/// (the screen can show the signed-in account) are masked. -fn redacted_probe_screen(visible: &str) -> String { - let redacted = crate::core::SecretRedactor::redact(visible); - crate::core::PersonalInfoRedactor::redact_emails_in_text(Some(&redacted), true) - .unwrap_or(redacted) -} - -/// Only a parseable usage screen is worth sharing; errors are retried live. -fn claude_cli_output_is_shareable(output: &str) -> bool { - claude_cli_error_from_output(output).is_none() - && ClaudeProvider::new().parse_cli_output(output).is_ok() -} - -/// Cross-process guard around the Claude PTY probe. Claude Code refuses to -/// start a session whose `--session-id` is already running ("Session ID … -/// is already in use"), so two codexbar processes (for example the -/// `serve` daemon and a one-off `usage` call) must not probe concurrently. -struct ClaudeProbeLock(std::fs::File); - -impl ClaudeProbeLock { - /// Wait for the probe lock. `Ok(None)` means locking is unsupported here - /// and the probe runs unlocked. A probe still running elsewhere after the - /// wait is an error: probing alongside it would reuse its session id. - fn acquire(probe_dir: &std::path::Path) -> Result, ProviderError> { - Self::acquire_within(probe_dir, CLAUDE_PROBE_LOCK_WAIT) - } - - fn acquire_within( - probe_dir: &std::path::Path, - wait: Duration, - ) -> Result, ProviderError> { - let path = probe_dir.join(CLAUDE_PROBE_LOCK_FILE); - let file = match std::fs::OpenOptions::new() - .create(true) - .read(true) - .write(true) - .truncate(false) - .open(&path) - { - Ok(file) => file, - Err(err) => { - tracing::debug!(error = %err, "Claude probe lock file unavailable; continuing unlocked"); - return Ok(None); - } - }; - let deadline = Instant::now() + wait; - loop { - match file.try_lock() { - Ok(()) => return Ok(Some(Self(file))), - Err(std::fs::TryLockError::WouldBlock) => {} - Err(std::fs::TryLockError::Error(err)) => { - tracing::debug!(error = %err, "Claude probe lock unavailable; continuing unlocked"); - return Ok(None); - } - } - if Instant::now() >= deadline { - return Err(ProviderError::Other( - "Timed out waiting for another CodexBar process to finish its Claude CLI \ - usage probe." - .to_string(), - )); - } - std::thread::sleep(Duration::from_millis(200)); - } - } -} - -impl Drop for ClaudeProbeLock { - fn drop(&mut self) { - // Best-effort unlock; closing the handle releases it anyway. - let _unlocked = self.0.unlock(); - } -} - -fn claude_usage_probe_dir() -> Result { - let base = dirs::data_local_dir() - .or_else(dirs::home_dir) - .ok_or_else(|| { - ProviderError::Other("Could not resolve a local data directory".to_string()) - })?; - let dir = base.join("CodexBar").join("claude-usage-probe"); - std::fs::create_dir_all(&dir).map_err(|e| { - ProviderError::Other(format!( - "Failed to prepare Claude CLI probe directory: {}", - e - )) - })?; - Ok(dir) -} - -/// Persist and reuse one probe session id so repeated `/usage` PTY launches do -/// not register a fresh empty Claude account session each refresh (upstream #2263). -fn load_or_create_probe_session_id(probe_dir: &std::path::Path) -> String { - let path = probe_dir.join(CLAUDE_PROBE_SESSION_ID_FILE); - if let Ok(raw) = std::fs::read_to_string(&path) { - let trimmed = raw.trim(); - if uuid::Uuid::parse_str(trimmed).is_ok() { - return trimmed.to_ascii_lowercase(); - } - } - let id = uuid::Uuid::new_v4().to_string().to_ascii_lowercase(); - if let Err(err) = std::fs::write(&path, &id) { - tracing::debug!(error = %err, "failed to persist Claude probe session id"); - } - id -} - -/// Claude treats `--session-id` as create-only when a local transcript JSONL -/// already exists for that id. Clear probe-dir jsonl leftovers before reuse. -fn cleanup_probe_session_jsonl(probe_dir: &std::path::Path) { - let Ok(entries) = std::fs::read_dir(probe_dir) else { - return; - }; - for entry in entries.flatten() { - let path = entry.path(); - if path.extension().and_then(|e| e.to_str()) == Some("jsonl") { - // Best-effort cleanup: a locked or missing probe file just stays. - let _removed = std::fs::remove_file(&path); - } - } - cleanup_probe_transcript(probe_dir); -} - -/// Claude stores the transcript for a working directory under -/// `/projects//.jsonl`, where the -/// config dir is `CLAUDE_CONFIG_DIR` or `~/.claude`. Remove the probe session -/// transcripts there, otherwise the fixed `--session-id` fails with "already -/// in use" on the next run. -fn cleanup_probe_transcript(probe_dir: &std::path::Path) { - let Ok(config_dir) = accounts::config_dir() else { - return; - }; - cleanup_probe_transcripts_in(&config_dir.join("projects"), probe_dir); -} - -fn cleanup_probe_transcripts_in(projects_root: &std::path::Path, probe_dir: &std::path::Path) { - let project_dir = projects_root.join(claude_project_dir_name(probe_dir)); - let Ok(entries) = std::fs::read_dir(&project_dir) else { - return; - }; - for entry in entries.flatten() { - let path = entry.path(); - let is_file = entry.file_type().is_ok_and(|kind| kind.is_file()); - if is_file && path.extension().and_then(|e| e.to_str()) == Some("jsonl") { - // Best-effort cleanup: a locked transcript just stays. - let _removed = std::fs::remove_file(&path); - } - } - // Succeeds only when nothing else is left in the probe's project dir. - let _removed = std::fs::remove_dir(&project_dir); -} - -/// Longest project directory name Claude Code writes before it truncates the -/// name and appends a hash of the full path. -const CLAUDE_PROJECT_DIR_NAME_MAX: usize = 200; - -/// Claude Code's project directory name for a working directory: every UTF-16 -/// code unit that is not an ASCII letter or digit becomes `-` -/// (`C:\Users\x` -> `C--Users-x`), and long names are cut to 200 characters -/// plus `-`. (Claude Code also NFC-normalizes the -/// path first; Windows paths are normally NFC already.) -fn claude_project_dir_name(dir: &std::path::Path) -> String { - let path = dir.to_string_lossy(); - let sanitized: String = path - .encode_utf16() - .map(|unit| match u8::try_from(unit) { - Ok(byte) if byte.is_ascii_alphanumeric() => char::from(byte), - _ => '-', - }) - .collect(); - if sanitized.len() <= CLAUDE_PROJECT_DIR_NAME_MAX { - return sanitized; - } - format!( - "{}-{}", - &sanitized[..CLAUDE_PROJECT_DIR_NAME_MAX], - javascript_hash_base36(&path) - ) -} - -/// `Math.abs(hash).toString(36)` of the JavaScript string hash -/// `hash = (hash << 5) - hash + charCode`, kept in 32 bits. -fn javascript_hash_base36(text: &str) -> String { - let hash = text.encode_utf16().fold(0i32, |hash, unit| { - hash.wrapping_mul(31).wrapping_add(i32::from(unit)) - }); - let mut magnitude = i64::from(hash).unsigned_abs(); - let mut digits = Vec::new(); - loop { - digits.push(char::from_digit((magnitude % 36) as u32, 36).unwrap_or('0')); - magnitude /= 36; - if magnitude == 0 { - break; - } - } - digits.iter().rev().collect() -} - -/// Arguments shared by every Claude CLI `/usage` probe. -/// -/// The remote-control startup hook can otherwise change the interactive -/// session before the usage command is collected. Keep this override in one -/// helper so future CLI probe paths cannot silently omit it. -fn claude_usage_settings_args() -> [String; 2] { - [ - "--settings".to_string(), - // Issue #778 (Claude Code 2.1.293): `tui: default` stops the "Try the new - // fullscreen renderer?" offer. Older builds ignore unknown settings keys. - r#"{"remoteControlAtStartup":false,"tui":"default"}"#.to_string(), - ] -} - -fn claude_probe_launch_args(session_id: &str) -> Vec { - let mut args = vec![ - "--setting-sources".to_string(), - "user".to_string(), - "--allowed-tools".to_string(), - String::new(), - ]; - args.extend(claude_usage_settings_args()); - args.extend(["--session-id".to_string(), session_id.to_string()]); - args -} - -struct ClaudePtyProbeOptions { - script: &'static str, - timeout_secs: f64, - idle_timeout_secs: Option, - initial_delay_secs: f64, - script_char_delay_secs: f64, - script_line_delay_secs: f64, - screen_responder: Option, - /// Re-type the script at these offsets while no done marker is visible. - script_retry_delays_secs: &'static [f64], - script_done_substrings: &'static [&'static str], - script_echo_substrings: &'static [&'static str], - /// Idle window after the done marker appeared (trailing output only). - idle_timeout_after_done_secs: Option, - /// Reuse a recent usage screen another process stored, and share this - /// one (the `/usage` probe only, never the trust preflight). - share_output: bool, -} - -/// Offsets (seconds after launch) at which `/usage` is re-sent when Claude's -/// input widget was not ready for the first attempt. Claude Code needs roughly -/// 1-5 s to mount its prompt on Windows, and keystrokes before that are lost. -const CLAUDE_USAGE_RETRY_DELAYS_SECS: &[f64] = &[6.0, 9.5, 14.0]; -/// Output markers that prove `/usage` opened (limits view or activity stats). -const CLAUDE_USAGE_DONE_MARKERS: &[&str] = &[ - "current session", - "current week", - "total duration", - "favorite model:", - "total tokens:", -]; -/// The typed command as Claude echoes it into its prompt line. While this is -/// visible the first attempt is still being processed, so do not type again. -const CLAUDE_USAGE_ECHO_MARKERS: &[&str] = &["❯ /usage", "> /usage", "/usage show session cost"]; - -async fn run_claude_usage_pty_probe( - claude_path: std::path::PathBuf, - working_directory: std::path::PathBuf, -) -> Result { - run_claude_pty_probe( - claude_path, - working_directory, - ClaudePtyProbeOptions { - script: "/usage", - timeout_secs: 24.0, - idle_timeout_secs: Some(6.0), - initial_delay_secs: 3.0, - script_char_delay_secs: 0.04, - script_line_delay_secs: 0.0, - screen_responder: None, - script_retry_delays_secs: CLAUDE_USAGE_RETRY_DELAYS_SECS, - script_done_substrings: CLAUDE_USAGE_DONE_MARKERS, - script_echo_substrings: CLAUDE_USAGE_ECHO_MARKERS, - idle_timeout_after_done_secs: Some(1.5), - share_output: true, - }, - ) - .await -} - -async fn run_claude_trust_preflight( - claude_path: std::path::PathBuf, - working_directory: std::path::PathBuf, -) -> Result { - run_claude_pty_probe( - claude_path, - working_directory, - ClaudePtyProbeOptions { - script: "", - timeout_secs: 15.0, - idle_timeout_secs: Some(4.0), - initial_delay_secs: 0.6, - script_char_delay_secs: 0.0, - script_line_delay_secs: 0.0, - screen_responder: Some(trust_dialog::TRUST_RESPONDER), - script_retry_delays_secs: &[], - script_done_substrings: &[], - script_echo_substrings: &[], - idle_timeout_after_done_secs: None, - share_output: false, - }, - ) - .await -} - -fn resolve_claude_cli_path() -> Result { - locate_claude_binary().ok_or_else(|| { - ProviderError::NotInstalled( - "Claude CLI not found. Install from https://docs.claude.ai/claude-code".to_string(), - ) - }) -} - -async fn fetch_claude_cli_usage_text( - claude_path: std::path::PathBuf, -) -> Result { - let probe_dir = claude_usage_probe_dir()?; - if let Some(login) = claude_login_fingerprint() - && let Some(cached) = load_cached_probe_output(&probe_dir, &login) - { - return Ok(cached); - } - let combined = run_claude_usage_pty_probe(claude_path.clone(), probe_dir.clone()).await?; - if !is_workspace_trust_prompt(&cli_screen::render(&combined, true).to_lowercase()) { - return Ok(combined); - } - - run_claude_trust_preflight(claude_path.clone(), probe_dir.clone()).await?; - run_claude_usage_pty_probe(claude_path, probe_dir).await -} - -/// Windows launch failures the CLI prints instead of a usage screen, as -/// (lowercase marker, user-facing message). -const CLAUDE_CLI_ENVIRONMENT_ERRORS: &[(&str, &str)] = &[ - ( - "requires git-bash", - "Claude CLI requires Git Bash on Windows. Install Git for Windows or set \ - CLAUDE_CODE_GIT_BASH_PATH to your bash.exe path.", - ), - ( - "running scripts is disabled", - "Claude CLI could not start because PowerShell script execution is disabled. \ - Use claude.cmd or adjust the execution policy.", - ), - ( - "cannot run a document in the middle of a pipeline", - "Claude CLI resolved to a Unix shell script on Windows. Reinstall Claude Code or \ - ensure claude.cmd is first on PATH.", - ), -]; - -/// Auth markers are checked before the environment markers. -fn claude_cli_error_from_output(output: &str) -> Option { - let lowered = output.to_lowercase(); - if lowered.contains("not logged in") || lowered.contains("login required") { - return Some(ProviderError::AuthRequired); - } - if lowered.contains("token expired") || lowered.contains("token_expired") { - return Some(ProviderError::OAuthExpired( - "Token expired. Run `claude login` to refresh.".to_string(), - )); - } - if lowered.contains("authentication_error") { - return Some(ProviderError::OAuth( - "Authentication error. Run `claude login`.".to_string(), - )); - } - CLAUDE_CLI_ENVIRONMENT_ERRORS - .iter() - .find(|(marker, _)| lowered.contains(marker)) - .map(|(_, message)| ProviderError::Other((*message).to_string())) -} - -/// Environment overrides for passive Claude CLI PTY probes. -fn claude_passive_probe_env( - mut base: std::collections::HashMap, -) -> std::collections::HashMap { - // Passive status/usage probes must not mutate or update the user's Claude CLI installation. - base.insert("NO_COLOR".to_string(), "1".to_string()); - base.insert("DISABLE_AUTOUPDATER".to_string(), "1".to_string()); - // Issue #778 (Claude Code 2.1.293): stops the "Claude in Chrome extension - // detected" offer, which would swallow `/usage`. Claude Code reads this - // variable; older builds ignore it. Probe launch only; config is untouched. - base.insert("CLAUDE_CODE_ENABLE_CFC".to_string(), "0".to_string()); - base -} - -async fn run_claude_pty_probe( - claude_path: std::path::PathBuf, - working_directory: std::path::PathBuf, - probe: ClaudePtyProbeOptions, -) -> Result { - tokio::task::spawn_blocking(move || { - // Keep ownership in the worker: cancelling the async refresh does not - // stop spawn_blocking or its CLI process from rotating credentials. - let _account_operation = accounts::CREDENTIAL_OPERATION.blocking_lock(); - let login: Option<&dyn Fn() -> Option> = - probe.share_output.then_some(&claude_login_fingerprint); - run_locked_probe(&working_directory, login, || { - cleanup_probe_session_jsonl(&working_directory); - let session_id = load_or_create_probe_session_id(&working_directory); - let env = claude_passive_probe_env(TtyCommandRunner::enriched_environment()); - - let mut options = TtyCommandOptions::new() - .with_timeout(probe.timeout_secs) - .with_initial_delay(probe.initial_delay_secs) - .with_script_char_delay(probe.script_char_delay_secs) - .with_script_line_delay(probe.script_line_delay_secs) - .with_working_directory(working_directory.clone()) - .with_extra_args(claude_probe_launch_args(&session_id)); - if let Some(idle) = probe.idle_timeout_secs { - options = options.with_idle_timeout(idle); - } - if let Some(idle) = probe.idle_timeout_after_done_secs { - options = options.with_idle_timeout_after_done(idle); - } - if let Some(responder) = probe.screen_responder { - options = options.with_screen_responder(responder); - } - if !probe.script_retry_delays_secs.is_empty() { - options = options.with_script_retries( - probe.script_retry_delays_secs.to_vec(), - probe - .script_done_substrings - .iter() - .map(|marker| (*marker).to_string()) - .collect(), - probe - .script_echo_substrings - .iter() - .map(|marker| (*marker).to_string()) - .collect(), - ); - } - options.env = env.into(); - - TtyCommandRunner::new() - .run(&claude_path.to_string_lossy(), probe.script, options) - .map(|result| result.text) - .map_err(|error| match error { - crate::cli::tty_runner::TtyCommandError::TimedOut => ProviderError::Timeout, - other => ProviderError::Other(format!("Claude CLI failed: {}", other)), - }) - }) - }) - .await - .map_err(|e| ProviderError::Other(format!("Claude CLI probe failed: {}", e)))? -} - -/// Run one probe under the cross-process probe lock. `login` is set when the -/// screen may be shared and identifies the Claude login it belongs to: a -/// fresh screen another process stored for that login while this one waited -/// is reused, and a parseable screen is stored for the others unless the -/// login changed while the probe ran. -fn run_locked_probe( - probe_dir: &std::path::Path, - login: Option<&dyn Fn() -> Option>, - probe: impl FnOnce() -> Result, -) -> Result { - let _probe_lock = ClaudeProbeLock::acquire(probe_dir)?; - let before = login.and_then(|login| login()); - if let Some(before) = &before - && let Some(cached) = load_cached_probe_output(probe_dir, before) - { - return Ok(cached); - } - let output = probe()?; - if let (Some(before), Some(login)) = (&before, login) - && login().as_ref() == Some(before) - && claude_cli_output_is_shareable(&output) - { - store_cached_probe_output(probe_dir, before, &output); - } - Ok(output) -} - fn last_good_failure_policy_for_error(error: &str) -> LastGoodFailurePolicy { let lower = error.to_ascii_lowercase(); if lower.contains("credentials not found") @@ -1185,1410 +632,5 @@ fn should_fallback_from_claude_cli_error(error: &ProviderError) -> bool { } } -/// Locate the Claude CLI for shell integrations that need to reopen a session. -pub fn locate_claude_binary() -> Option { - if let Some(path) = std::env::var_os("CLAUDE_BINARY") - .map(std::path::PathBuf::from) - .filter(|path| path.is_file()) - { - return Some(path); - } - - #[cfg(windows)] - { - let candidates = [ - // Direct install - dirs::data_local_dir().map(|p| p.join("Programs").join("claude").join("claude.exe")), - // npm global (AppData\Roaming\npm) - dirs::data_local_dir().map(|p| p.join("npm").join("claude.cmd")), - dirs::home_dir().map(|h| { - h.join("AppData") - .join("Roaming") - .join("npm") - .join("claude.cmd") - }), - // npm global alternate (~\.npm-global) - dirs::home_dir().map(|h| h.join(".npm-global").join("claude.cmd")), - // Volta managed - dirs::data_local_dir().map(|p| { - p.join("Volta") - .join("tools") - .join("image") - .join("packages") - .join("@anthropic-ai") - .join("claude-code") - .join("bin") - .join("claude.cmd") - }), - // fnm managed (via shim) - dirs::data_local_dir().map(|p| p.join("fnm_multishells").join("claude.cmd")), - // PATH lookup - find_windows_claude_in_path(), - ]; - - candidates.into_iter().flatten().find(|p| p.exists()) - } - - #[cfg(not(windows))] - { - which::which("claude").ok() - } -} - -#[cfg(windows)] -fn find_windows_claude_in_path() -> Option { - const CREATE_NO_WINDOW: u32 = 0x08000000; - - let mut command = StdCommand::new("where"); - command - .arg("claude") - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .creation_flags(CREATE_NO_WINDOW); - let output = command.output().ok()?; - - if !output.status.success() { - return None; - } - - let mut matches: Vec<_> = String::from_utf8_lossy(&output.stdout) - .lines() - .map(str::trim) - .filter(|line| !line.is_empty()) - .map(std::path::PathBuf::from) - .collect(); - - matches.sort_by_key(|path| { - match path - .extension() - .and_then(|ext| ext.to_str()) - .map(|ext| ext.to_ascii_lowercase()) - .as_deref() - { - Some("cmd") => 0, - Some("bat") => 1, - Some("exe") => 2, - _ => 3, - } - }); - - matches.into_iter().find(|path| path.exists()) -} - -/// Detect the version of the claude CLI -fn detect_claude_version() -> Option { - let claude_path = locate_claude_binary()?; - - #[cfg(windows)] - const CREATE_NO_WINDOW: u32 = 0x08000000; - - let mut cmd = std::process::Command::new(claude_path); - cmd.args(["--version"]); - #[cfg(windows)] - cmd.creation_flags(CREATE_NO_WINDOW); - - let output = cmd.output().ok()?; - - if output.status.success() { - let version_str = String::from_utf8_lossy(&output.stdout); - super::extract_semver(&version_str) - } else { - None - } -} - -fn is_non_interactive_slash_command_response(text: &str) -> bool { - let mentions_usage_and_exit = text.contains("/usage") && text.contains("/exit"); - let says_entered_commands = - text.contains("i see you've entered") || text.contains("you've entered two slash commands"); - let says_no_slash_command = text.contains("available custom slash commands") - && text.contains("don't see these commands"); - let says_usage_is_cli_only = text - .contains("token usage and statistics are typically displayed by the cli interface") - || text.contains("i don't have direct access to those metrics"); - - mentions_usage_and_exit - && (says_entered_commands || says_no_slash_command || says_usage_is_cli_only) -} - -fn is_workspace_trust_prompt(text: &str) -> bool { - text.contains("quick safety check") - && text.contains("trust this folder") - && text.contains("yes, i trust this folder") -} - -/// Current `/usage` panels show a local session summary above the plan limits, -/// so the presence of a limit section outranks the activity-stats markers. -fn has_plan_limit_section(text: &str) -> bool { - text.contains("current session") || text.contains("current week") -} - -fn is_cli_activity_stats_response(text: &str) -> bool { - let has_activity_overview = text.contains("favorite model:") || text.contains("total tokens:"); - let has_session_cost_summary = - text.contains("total duration") && text.contains("usage:") && text.contains("cache read"); - - has_activity_overview || has_session_cost_summary -} - -/// The weekly heading Claude prints, newest wording first. -const WEEKLY_LABELS: [&str; 2] = ["current week (all models)", "current week"]; - -/// First match of `find` in any section headed by `label`; each section is -/// scanned for at most `max_lines` lines, label line included. -fn find_near_label( - text: &str, - label: &str, - max_lines: usize, - mut find: impl FnMut(&str) -> Option, -) -> Option { - let label_normalized = normalized_for_label_search(label); - let lines: Vec<&str> = text.lines().collect(); - lines - .iter() - .enumerate() - .filter(|(_, line)| normalized_for_label_search(line).contains(&label_normalized)) - .find_map(|(idx, _)| { - label_section(&lines, idx, &label_normalized, max_lines).find_map(&mut find) - }) -} - -/// Percentage near a label (e.g. "Current session"), as "used". -fn extract_percent_near_label(text: &str, label: &str) -> Option { - find_near_label(text, label, 12, parse_percent_line) -} - -fn is_exhausted_short_form(clean_lower: &str) -> bool { - clean_lower.contains("out of extra usage") || clean_lower.contains("hit your limit") -} - -/// Extract email address from text -fn extract_email(text: &str) -> Option { - // Try explicit patterns first - let patterns = [ - r"Account:\s*([^\s@]+@[^\s@]+\.[^\s]+)", - r"Email:\s*([^\s@]+@[^\s@]+\.[^\s]+)", - r"([A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,})", - ]; - - for pattern in patterns { - if let Ok(re) = Regex::new(pattern) - && let Some(caps) = re.captures(text) - && let Some(m) = caps.get(1) - { - return Some(m.as_str().trim().to_string()); - } - } - - None -} - -/// Extract login method / plan name from text -fn extract_login_method(text: &str) -> Option { - // Look for explicit "Login method:" line - if let Ok(re) = Regex::new(r"(?i)login\s+method:\s*(.+)") - && let Some(caps) = re.captures(text) - && let Some(m) = caps.get(1) - { - let method = m.as_str().trim(); - if !method.is_empty() { - return Some(clean_plan_name(method)); - } - } - - // Look for "Claude " patterns - if let Ok(re) = Regex::new(r"(?i)(claude\s+(?:max|pro|ultra|team|free)[a-z0-9\s._-]*)") - && let Some(caps) = re.captures(text) - && let Some(m) = caps.get(1) - { - let plan = m.as_str().trim(); - if !plan.to_lowercase().contains("code") { - return Some(clean_plan_name(plan)); - } - } - - None -} - -/// Reset text near a label, from "resets" to the end of its line. -fn extract_reset_description(text: &str, label: &str) -> Option { - find_near_label(text, label, 14, extract_inline_reset_description) -} - -/// Extract a "resets ..." suffix from a short single-line status. -fn extract_inline_reset_description(text: &str) -> Option { - let lower = text.to_lowercase(); - let pos = lower.find("resets")?; - Some(text[pos..].trim().to_string()) -} - -/// Clean up a plan name from rendered (escape-free) text: drop bracketed -/// codes like `[22m` and trim. -fn clean_plan_name(text: &str) -> String { - let re = Regex::new(r"\[\d+m").unwrap_or_else(|_| Regex::new(".^").unwrap()); - let result = re.replace_all(text, ""); - result.trim().to_string() -} - #[cfg(test)] -mod tests { - use chrono::{DateTime, Utc}; - use std::collections::HashMap; - - use super::*; - - const LOGIN_A: &str = "login-a"; - - fn login_a() -> Option { - Some(LOGIN_A.to_string()) - } - - #[test] - fn probe_cache_roundtrip_and_expiry() { - let dir = tempfile::tempdir().unwrap(); - assert!(load_cached_probe_output(dir.path(), LOGIN_A).is_none()); - store_cached_probe_output(dir.path(), LOGIN_A, "Current session 12% used"); - assert_eq!( - load_cached_probe_output(dir.path(), LOGIN_A).as_deref(), - Some("Current session 12% used") - ); - let stale = ClaudeProbeCache { - captured_at_unix: unix_now_secs() - CLAUDE_PROBE_CACHE_TTL.as_secs() - 5, - login: LOGIN_A.to_string(), - output: "Current session 12% used".to_string(), - }; - std::fs::write( - dir.path().join(CLAUDE_PROBE_CACHE_FILE), - serde_json::to_string(&stale).unwrap(), - ) - .unwrap(); - assert!(load_cached_probe_output(dir.path(), LOGIN_A).is_none()); - } - - #[test] - fn probe_cache_is_never_shared_with_another_login() { - let dir = tempfile::tempdir().unwrap(); - store_cached_probe_output(dir.path(), LOGIN_A, "Current session 12% used"); - assert!(load_cached_probe_output(dir.path(), "login-b").is_none()); - - // Written before screens were scoped to a login. - let unscoped = format!( - r#"{{"captured_at_unix":{},"output":"Current session 12% used"}}"#, - unix_now_secs() - ); - std::fs::write(dir.path().join(CLAUDE_PROBE_CACHE_FILE), unscoped).unwrap(); - assert!(load_cached_probe_output(dir.path(), LOGIN_A).is_none()); - assert!(load_cached_probe_output(dir.path(), "").is_none()); - } - - #[test] - fn login_fingerprint_follows_credential_rewrites_without_reading_them() { - let dir = tempfile::tempdir().unwrap(); - let credentials = dir.path().join(".credentials.json"); - assert_eq!(login_fingerprint_at(&credentials), None); - - std::fs::write(&credentials, "{}").unwrap(); - let first = login_fingerprint_at(&credentials).expect("fingerprint"); - assert_eq!(login_fingerprint_at(&credentials).as_ref(), Some(&first)); - assert!(!first.contains(".credentials"), "only a digest is stored"); - - std::fs::write(&credentials, r#"{"another":"login"}"#).unwrap(); - assert_ne!(login_fingerprint_at(&credentials), Some(first)); - } - - const SHAREABLE_USAGE_SCREEN: &str = "Current session\n\ - ████████▌ 17% used\n\ - Resets 12pm (America/Bogota)\n"; - - #[test] - fn locked_probe_reuses_a_screen_stored_while_it_waited() { - let dir = tempfile::tempdir().unwrap(); - store_cached_probe_output(dir.path(), LOGIN_A, SHAREABLE_USAGE_SCREEN); - - let output = run_locked_probe(dir.path(), Some(&login_a), || { - panic!("a fresh shared screen must not launch another probe") - }) - .unwrap(); - assert_eq!(output, SHAREABLE_USAGE_SCREEN); - } - - #[test] - fn locked_probe_shares_only_parseable_usage_screens() { - let dir = tempfile::tempdir().unwrap(); - let output = run_locked_probe(dir.path(), Some(&login_a), || { - Ok("Not logged in".to_string()) - }); - assert_eq!(output.unwrap(), "Not logged in"); - assert!(load_cached_probe_output(dir.path(), LOGIN_A).is_none()); - - let output = run_locked_probe(dir.path(), Some(&login_a), || { - Ok(SHAREABLE_USAGE_SCREEN.into()) - }); - assert_eq!(output.unwrap(), SHAREABLE_USAGE_SCREEN); - assert_eq!( - load_cached_probe_output(dir.path(), LOGIN_A).as_deref(), - Some(SHAREABLE_USAGE_SCREEN) - ); - assert!( - !dir.path() - .read_dir() - .unwrap() - .flatten() - .any(|entry| entry.file_name().to_string_lossy().contains(".tmp-")), - "the atomic write left no staging file behind" - ); - } - - #[test] - fn locked_probe_keeps_a_screen_private_when_the_login_changed_meanwhile() { - let dir = tempfile::tempdir().unwrap(); - let calls = std::cell::Cell::new(0); - let switching_login = || { - calls.set(calls.get() + 1); - Some(format!("login-{}", calls.get())) - }; - let output = run_locked_probe(dir.path(), Some(&switching_login), || { - Ok(SHAREABLE_USAGE_SCREEN.into()) - }); - assert_eq!(output.unwrap(), SHAREABLE_USAGE_SCREEN); - assert_eq!(calls.get(), 2, "the login is read before and after"); - assert!(load_cached_probe_output(dir.path(), "login-1").is_none()); - assert!(load_cached_probe_output(dir.path(), "login-2").is_none()); - - let no_login = || None; - run_locked_probe(dir.path(), Some(&no_login), || { - Ok(SHAREABLE_USAGE_SCREEN.into()) - }) - .unwrap(); - assert!(!dir.path().join(CLAUDE_PROBE_CACHE_FILE).exists()); - } - - #[test] - fn unshared_probe_neither_reuses_nor_stores_screens() { - let dir = tempfile::tempdir().unwrap(); - store_cached_probe_output(dir.path(), LOGIN_A, SHAREABLE_USAGE_SCREEN); - let output = run_locked_probe(dir.path(), None, || Ok("trust preflight".into())); - assert_eq!(output.unwrap(), "trust preflight"); - - let other = tempfile::tempdir().unwrap(); - run_locked_probe(other.path(), None, || Ok(SHAREABLE_USAGE_SCREEN.into())).unwrap(); - assert!(!other.path().join(CLAUDE_PROBE_CACHE_FILE).exists()); - } - - #[test] - fn logged_probe_screen_masks_account_email_and_secrets() { - let screen = "Login: someone@example.com (Claude Max)\n\ - access_token=abcdef0123456789 sk-ant-abcdefgh12345678\n\ - Current session 12% used"; - let logged = redacted_probe_screen(screen); - assert!(!logged.contains("someone@example.com"), "{logged}"); - assert!(!logged.contains("abcdef0123456789"), "{logged}"); - assert!(!logged.contains("sk-ant-abcdefgh12345678"), "{logged}"); - assert!(logged.contains("Current session 12% used")); - } - - #[test] - fn probe_lock_wait_expiry_fails_instead_of_probing_alongside() { - let dir = tempfile::tempdir().unwrap(); - let held = ClaudeProbeLock::acquire_within(dir.path(), Duration::ZERO) - .expect("first lock") - .expect("file locking is supported"); - - let error = match ClaudeProbeLock::acquire_within(dir.path(), Duration::from_millis(300)) { - Ok(lock) => panic!("second lock acquired while held: {}", lock.is_some()), - Err(error) => error, - }; - assert!(error.to_string().contains("Timed out waiting"), "{error}"); - assert_eq!( - last_good_failure_policy_for_error(&error.to_string()), - LastGoodFailurePolicy::Preserve - ); - - drop(held); - assert!( - ClaudeProbeLock::acquire_within(dir.path(), Duration::ZERO) - .expect("lock after release") - .is_some() - ); - } - - #[test] - fn passive_probe_env_disables_autoupdater_color_and_chrome() { - let env = claude_passive_probe_env(HashMap::new()); - for (key, value) in [ - ("DISABLE_AUTOUPDATER", "1"), - ("NO_COLOR", "1"), - ("CLAUDE_CODE_ENABLE_CFC", "0"), - ] { - assert_eq!(env.get(key).map(String::as_str), Some(value), "{key}"); - } - } - - #[test] - fn probe_session_id_is_reused_from_probe_directory() { - let dir = tempfile::tempdir().unwrap(); - let first = load_or_create_probe_session_id(dir.path()); - let second = load_or_create_probe_session_id(dir.path()); - assert_eq!(first, second); - assert!(uuid::Uuid::parse_str(&first).is_ok()); - let args = claude_probe_launch_args(&first); - // Positional structure only: the settings pair is pinned once by - // `claude_usage_settings_args` being the sole composer. - assert_eq!( - args[..4], - ["--setting-sources", "user", "--allowed-tools", ""] - ); - assert_eq!(args[4], claude_usage_settings_args()[0]); - assert_eq!(args[5], claude_usage_settings_args()[1]); - assert_eq!(args[6], "--session-id"); - assert_eq!(args[7], first); - } - - #[test] - fn usage_probe_settings_disable_remote_control_startup() { - assert_eq!( - claude_usage_settings_args(), - [ - "--settings".to_string(), - r#"{"remoteControlAtStartup":false,"tui":"default"}"#.to_string(), - ] - ); - let settings: serde_json::Value = - serde_json::from_str(&claude_usage_settings_args()[1]).unwrap(); - assert_eq!(settings["tui"], "default"); - assert_eq!(settings["remoteControlAtStartup"], false); - } - - #[test] - fn probe_session_jsonl_cleanup_removes_transcript_files() { - let dir = tempfile::tempdir().unwrap(); - let jsonl = dir.path().join("session.jsonl"); - std::fs::write(&jsonl, "{}").unwrap(); - std::fs::write(dir.path().join("keep.txt"), "x").unwrap(); - cleanup_probe_session_jsonl(dir.path()); - assert!(!jsonl.exists()); - assert!(dir.path().join("keep.txt").exists()); - } - - #[test] - fn probe_project_dir_name_matches_claude_code() { - use std::path::Path; - assert_eq!( - claude_project_dir_name(Path::new( - r"C:\Users\user\AppData\Local\CodexBar\claude-usage-probe" - )), - "C--Users-user-AppData-Local-CodexBar-claude-usage-probe" - ); - assert_eq!( - claude_project_dir_name(Path::new("/Users/me/Library/Application Support/x")), - "-Users-me-Library-Application-Support-x" - ); - // One dash per UTF-16 code unit, so two for a character outside the BMP. - assert_eq!( - claude_project_dir_name(Path::new("C:\\Users\\J\u{f6}rg\u{1F600}\\probe")), - "C--Users-J-rg---probe" - ); - // Reference values from Claude Code's JavaScript implementation. - let long = format!( - r"C:\Users\user\AppData\Local\{}claude-usage-probe", - r"deep\".repeat(40) - ); - assert_eq!( - claude_project_dir_name(Path::new(&long)), - format!( - "C--Users-user-AppData-Local-{}de-ttzy4x", - "deep-".repeat(34) - ) - ); - assert_eq!(javascript_hash_base36("hello"), "1n1e4y"); - assert_eq!(javascript_hash_base36(""), "0"); - } - - #[test] - fn probe_transcript_cleanup_stays_inside_the_probe_project() { - use std::path::Path; - let projects = tempfile::tempdir().unwrap(); - let other = projects.path().join("C--work-repo"); - std::fs::create_dir_all(&other).unwrap(); - std::fs::write(other.join("session.jsonl"), "{}").unwrap(); - - let busy_probe = Path::new(r"C:\Users\user\AppData\Local\CodexBar\busy-probe"); - let busy = projects.path().join(claude_project_dir_name(busy_probe)); - std::fs::create_dir_all(busy.join("folder.jsonl")).unwrap(); - std::fs::write(busy.join("session.jsonl"), "{}").unwrap(); - std::fs::write(busy.join("notes.txt"), "x").unwrap(); - cleanup_probe_transcripts_in(projects.path(), busy_probe); - assert!(!busy.join("session.jsonl").exists()); - assert!(busy.join("notes.txt").exists()); - assert!(busy.join("folder.jsonl").is_dir(), "only files are removed"); - - let probe = Path::new(r"C:\Users\user\AppData\Local\CodexBar\claude-usage-probe"); - let project = projects.path().join(claude_project_dir_name(probe)); - std::fs::create_dir_all(&project).unwrap(); - std::fs::write(project.join("a.jsonl"), "{}").unwrap(); - std::fs::write(project.join("b.jsonl"), "{}").unwrap(); - cleanup_probe_transcripts_in(projects.path(), probe); - assert!(!project.exists(), "an emptied probe project dir is removed"); - - assert!(other.join("session.jsonl").exists(), "other projects stay"); - } - - fn parse_ok(output: &str) -> ProviderFetchResult { - ClaudeProvider::new() - .parse_cli_output(output) - .expect("should parse") - } - - fn parse_err(output: &str) -> ProviderError { - ClaudeProvider::new() - .parse_cli_output(output) - .expect_err("should reject") - } - - #[test] - fn parses_current_cli_usage_screen() { - let output = r#" -Status Config Usage - - Current session - ██████████████████████████████████████████████████ 100% used - Resets 12pm (America/Bogota) - - Current week (all models) - ████████████████████████▌ 49% used - Resets Apr 3, 2pm (America/Bogota) - - Extra usage - ██▍ 4% used - $3.31 / $70.00 spent · Resets Apr 1 (America/Bogota) -"#; - - let result = parse_ok(output); - - assert_eq!(result.source_label, "cli"); - assert_eq!(result.usage.primary.used_percent, 100.0); - assert_eq!( - result.usage.primary.reset_description.as_deref(), - Some("Resets 12pm (America/Bogota)") - ); - - let weekly = result - .usage - .secondary - .expect("weekly usage should be present"); - assert_eq!(weekly.used_percent, 49.0); - assert_eq!( - weekly.reset_description.as_deref(), - Some("Resets Apr 3, 2pm (America/Bogota)") - ); - } - - #[test] - fn parses_short_forms_as_full_session_usage() { - let rows = [ - ( - "You're out of extra usage · resets 12pm (America/Bogota)", - "resets 12pm (America/Bogota)", - ), - ( - "You've hit your limit \u{00b7} resets 3:20pm (Asia/Shanghai)", - "resets 3:20pm (Asia/Shanghai)", - ), - ]; - for (output, reset) in rows { - let result = parse_ok(output); - assert_eq!(result.usage.primary.used_percent, 100.0, "{output}"); - assert_eq!( - result.usage.primary.reset_description.as_deref(), - Some(reset) - ); - } - } - - #[test] - fn parses_remaining_available_and_decimal_percentages() { - let output = r#" -Status Config Usage - - Current session - 12.5% remaining - Resets 8pm - - Current week (all models) - 4% available - Resets Apr 4, 2pm - - Current week (Sonnet only) - 1% consumed -"#; - - let result = parse_ok(output); - - assert_eq!(result.usage.primary.used_percent, 87.5); - assert_eq!( - result.usage.primary.reset_description.as_deref(), - Some("Resets 8pm") - ); - - let weekly = result - .usage - .secondary - .expect("weekly usage should be present"); - assert_eq!(weekly.used_percent, 96.0); - assert_eq!( - weekly.reset_description.as_deref(), - Some("Resets Apr 4, 2pm") - ); - - let sonnet = result - .usage - .extra_rate_windows - .iter() - .find(|window| window.id == "claude-weekly-scoped-sonnet") - .expect("sonnet usage should be present"); - assert_eq!(sonnet.window.used_percent, 1.0); - } - - #[test] - fn parses_all_cli_model_scoped_weekly_limits() { - let output = r#" -Current session -10% used -Resets 12pm (America/Bogota) - -Current week (all models) -20% used -Resets Apr 3, 2pm (America/Bogota) - -Current week (Sonnet only) -30% used -Resets Apr 4, 2pm (America/Bogota) - -Current week (Opus only) -40% used -Resets Apr 5, 2pm (America/Bogota) -"#; - - let result = parse_ok(output); - - assert_eq!(result.usage.extra_rate_windows.len(), 2); - assert_eq!( - result.usage.extra_rate_windows[0].id, - "claude-weekly-scoped-sonnet" - ); - assert_eq!(result.usage.extra_rate_windows[0].title, "Sonnet only"); - assert_eq!(result.usage.extra_rate_windows[0].window.used_percent, 30.0); - assert_eq!( - result.usage.extra_rate_windows[1].id, - "claude-weekly-scoped-opus" - ); - assert!(result.usage.model_specific.is_none()); - } - - #[test] - fn scoped_weekly_parser_handles_non_ascii_labels_and_reset_prefixes() { - let now = "2026-04-02T18:00:00Z".parse::>().unwrap(); - let limits = extract_cli_scoped_weekly_limits( - "Current week (A€€)\n10% used\nİResets Apr 3 at 2pm (America/Bogota)", - now, - ); - - assert_eq!(limits.len(), 1); - assert_eq!(limits[0].title, "A€€"); - assert_eq!( - limits[0].window.resets_at, - Some("2026-04-03T19:00:00Z".parse().unwrap()) - ); - } - - #[test] - fn resolves_cli_reset_occurrences_in_the_reported_timezone() { - let now = "2026-04-02T18:00:00Z".parse::>().unwrap(); - - assert_eq!( - parse_claude_reset_date("Resets Apr 3, 2027, 2pm (America/Bogota)", now, None), - Some("2027-04-03T19:00:00Z".parse().unwrap()) - ); - assert_eq!( - parse_claude_reset_date("Resets Apr 3, 2pm (America/Bogota)", now, None), - Some("2026-04-03T19:00:00Z".parse().unwrap()) - ); - assert_eq!( - parse_claude_reset_date("Resets 12pm (America/Bogota)", now, None), - Some("2026-04-03T17:00:00Z".parse().unwrap()) - ); - assert_eq!( - parse_claude_reset_date("ResetsApr3at2pm(America/Bogota)", now, None), - Some("2026-04-03T19:00:00Z".parse().unwrap()) - ); - } - - #[test] - fn timezone_less_resets_use_the_supplied_system_zone() { - let now = "2026-03-07T18:00:00Z".parse::>().unwrap(); - - assert_eq!( - parse_claude_reset_date_in_system_zone( - "Resets Mar 8 at 3:30am", - now, - None, - "America/New_York".parse().unwrap(), - ), - Some("2026-03-08T07:30:00Z".parse().unwrap()) - ); - assert_eq!( - parse_claude_reset_date_in_system_zone( - "Resets Mar 8 at 3:30am (America/Los_Angeles)", - now, - None, - "America/New_York".parse().unwrap(), - ), - Some("2026-03-08T10:30:00Z".parse().unwrap()) - ); - } - - #[test] - fn reset_dates_resolve_every_month_and_form() { - let now = "2026-09-24T12:00:00Z".parse::>().unwrap(); - let at = |text: &str, window: Option| { - parse_claude_reset_date(text, now, window).map(|date| date.to_rfc3339()) - }; - let months = [ - "Jan", "FEB", "mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "dEc", - ]; - for (index, month) in months.iter().enumerate() { - assert_eq!( - at(&format!("Resets {month} 5, 2027 at 3pm (UTC)"), None), - Some(format!("2027-{:02}-05T15:00:00+00:00", index + 1)), - "{month}" - ); - } - let rows = [ - ("Resets Foo 5, 2027 at 3pm (UTC)", None, None), - ("Resets Feb 30, 2027 at 3pm (UTC)", None, None), - ( - "Resets Sep 23 at 3pm (UTC)", - None, - Some("2027-09-23T15:00:00+00:00"), - ), - ( - "Resets Sep 23 at 3pm (UTC)", - Some(10_080), - Some("2026-09-23T15:00:00+00:00"), - ), - ( - "Resets Feb 29 at 3pm (UTC)", - None, - Some("2028-02-29T15:00:00+00:00"), - ), - ("Resets 3pm (UTC)", None, Some("2026-09-24T15:00:00+00:00")), - ("Resets 11am (UTC)", None, Some("2026-09-25T11:00:00+00:00")), - ( - "Resets 11am (UTC)", - Some(300), - Some("2026-09-24T11:00:00+00:00"), - ), - ( - "Resets Nov 1, 2026 at 1:30am (America/New_York)", - None, - Some("2026-11-01T05:30:00+00:00"), - ), - ( - "Resets Mar 8, 2026 at 2:30am (America/New_York)", - None, - None, - ), - ]; - for (text, window, expected) in rows { - assert_eq!(at(text, window).as_deref(), expected, "{text} {window:?}"); - } - } - - #[test] - fn parses_compact_usage_screen() { - let output = r#" -Settings:StatusConfigUsage(tabtocycle) -Loadingusagedata... -Currentsession -6%used -Resets4:29am(Asia/Calcutta) -Currentweek(allmodels) -4%used -ResetsFeb12at1:29pm(Asia/Calcutta) -Currentweek(Sonnetonly) -1%used -ResetsFeb12at1:29pm(Asia/Calcutta) -"#; - - let result = parse_ok(output); - - assert_eq!(result.usage.primary.used_percent, 6.0); - assert_eq!( - result.usage.primary.reset_description.as_deref(), - Some("Resets4:29am(Asia/Calcutta)") - ); - assert_eq!( - result - .usage - .secondary - .expect("weekly usage should be present") - .used_percent, - 4.0 - ); - let sonnet = result - .usage - .extra_rate_windows - .iter() - .find(|window| window.id == "claude-weekly-scoped-sonnet") - .expect("sonnet usage should be present"); - assert_eq!(result.usage.extra_rate_windows.len(), 1); - assert_eq!(sonnet.title, "Sonnet only"); - assert_eq!(sonnet.window.used_percent, 1.0); - } - - #[test] - fn does_not_promote_weekly_reset_to_session() { - let output = r#" -Current session -17% used -Current week (all models) -4% used -Resets Dec 24 at 3:59pm (Europe/Paris) -"#; - - let result = parse_ok(output); - - assert_eq!(result.usage.primary.used_percent, 17.0); - assert_eq!(result.usage.primary.reset_description, None); - assert_eq!( - result - .usage - .secondary - .expect("weekly usage should be present") - .reset_description - .as_deref(), - Some("Resets Dec 24 at 3:59pm (Europe/Paris)") - ); - } - - #[test] - fn cli_error_markers_map_to_fixed_errors() { - let git_bash = "Other(\"Claude CLI requires Git Bash on Windows. Install Git for Windows or set CLAUDE_CODE_GIT_BASH_PATH to your bash.exe path.\")"; - let cases = [ - ("Error: Not Logged In", "AuthRequired"), - ("login required to continue", "AuthRequired"), - ( - "TOKEN EXPIRED", - "OAuthExpired(\"Token expired. Run `claude login` to refresh.\")", - ), - ( - "{\"type\":\"token_expired\"}", - "OAuthExpired(\"Token expired. Run `claude login` to refresh.\")", - ), - ( - "authentication_error", - "OAuth(\"Authentication error. Run `claude login`.\")", - ), - ("Claude Code on Windows requires git-bash.", git_bash), - ( - "Running scripts is disabled on this system", - "Other(\"Claude CLI could not start because PowerShell script execution is disabled. Use claude.cmd or adjust the execution policy.\")", - ), - ( - "Cannot run a document in the middle of a pipeline", - "Other(\"Claude CLI resolved to a Unix shell script on Windows. Reinstall Claude Code or ensure claude.cmd is first on PATH.\")", - ), - // Auth markers win over environment markers. - ("requires git-bash; not logged in", "AuthRequired"), - ( - "requires git-bash; token expired", - "OAuthExpired(\"Token expired. Run `claude login` to refresh.\")", - ), - // Login wins over the other auth markers. - ("token expired; not logged in", "AuthRequired"), - ( - "authentication_error; token_expired", - "OAuthExpired(\"Token expired. Run `claude login` to refresh.\")", - ), - ("running scripts is disabled; requires git-bash", git_bash), - ]; - for (output, expected) in cases { - let error = claude_cli_error_from_output(output).expect(output); - assert_eq!(format!("{error:?}"), expected, "{output}"); - } - assert!(claude_cli_error_from_output("Current session 5% used").is_none()); - } - - #[test] - fn all_percents_fold_case_and_clamp() { - let text = "50% USED\n20 % Left\n101% used\n150% left\n5.5% remaining\n1000% used\n7%Spent 8% available"; - assert_eq!( - percent_matches(text).collect::>(), - vec![50.0, 80.0, 100.0, 0.0, 94.5, 0.0, 7.0, 92.0] - ); - assert!(percent_matches("no numbers here").next().is_none()); - } - - #[test] - fn label_sections_stop_at_their_window_and_the_next_section() { - let filler = |count: usize| vec!["filler"; count].join("\n"); - // Percent on the label line itself, and on the last line of the - // twelve-line window (label + 11). - assert_eq!( - extract_percent_near_label("Current session 30% used", "current session"), - Some(30.0) - ); - let at_last = format!("Current session\n{}\n40% used", filler(10)); - assert_eq!( - extract_percent_near_label(&at_last, "current session"), - Some(40.0) - ); - let past_window = format!("Current session\n{}\n40% used", filler(11)); - assert_eq!( - extract_percent_near_label(&past_window, "current session"), - None - ); - // The next "Current ..." heading ends the section, but the same - // heading does not. - let next_section = "Current session\nCurrent week\n40% used"; - assert_eq!( - extract_percent_near_label(next_section, "current session"), - None - ); - let same_label = "Current session\nCURRENT SESSION again\n40% used"; - assert_eq!( - extract_percent_near_label(same_label, "current session"), - Some(40.0) - ); - // A section without a value falls through to a later label line. - let later = "Current session\nCurrent week\n10% used\nCurrent session\n60% left"; - assert_eq!( - extract_percent_near_label(later, "current session"), - Some(40.0) - ); - assert_eq!( - extract_percent_near_label("Current week (all models)\n10% used", "current week"), - Some(10.0) - ); - - // Reset text uses a fourteen-line window (label + 13). - let reset_last = format!("Current week\n{}\nResets Mon 9am", filler(12)); - assert_eq!( - extract_reset_description(&reset_last, "current week").as_deref(), - Some("Resets Mon 9am") - ); - let reset_past = format!("Current week\n{}\nResets Mon 9am", filler(13)); - assert_eq!(extract_reset_description(&reset_past, "current week"), None); - assert_eq!( - extract_reset_description("Current week 5% used · resets Fri 1pm ", "current week") - .as_deref(), - Some("resets Fri 1pm") - ); - assert_eq!( - extract_reset_description( - "Current session\nCurrent week\nResets Mon", - "current session" - ), - None - ); - let later_reset = "Current session\nCurrent week\nCurrent session\nResets 5pm"; - assert_eq!( - extract_reset_description(later_reset, "current session").as_deref(), - Some("Resets 5pm") - ); - } - - #[test] - fn scoped_weekly_sections_use_a_fourteen_line_window() { - let now = Utc::now(); - let filler = |count: usize| vec!["filler"; count].join("\n"); - let inside = format!("Current week (Opus)\n{}\n25% used", filler(12)); - let limits = extract_cli_scoped_weekly_limits(&inside, now); - assert_eq!(limits.len(), 1); - assert_eq!(limits[0].window.used_percent, 25.0); - let outside = format!("Current week (Opus)\n{}\n25% used", filler(13)); - assert!(extract_cli_scoped_weekly_limits(&outside, now).is_empty()); - let next = "Current week (Opus)\nCurrent session\n25% used"; - assert!(extract_cli_scoped_weekly_limits(next, now).is_empty()); - } - - #[test] - fn cli_parse_usage_error_can_fallback_to_oauth() { - let err = ProviderError::Parse("Claude CLI did not return usage data".to_string()); - - assert!(should_fallback_from_claude_cli_error(&err)); - } - - #[test] - fn cli_auth_error_does_not_fallback_to_oauth() { - assert!(!should_fallback_from_claude_cli_error( - &ProviderError::AuthRequired - )); - } - - #[test] - fn auto_fetch_error_keeps_all_source_failures() { - let err = claude_auto_fetch_error(vec![ - ("OAuth", ProviderError::OAuth("token expired".to_string())), - ("Web", ProviderError::NoCookies), - ( - "CLI", - ProviderError::Parse("Empty output from Claude CLI".to_string()), - ), - ]); - - assert_eq!( - err.to_string(), - "Claude usage failed from all configured sources. OAuth: OAuth error: token expired; Web: No cookies available for web API; CLI: Parse error: Empty output from Claude CLI" - ); - } - - fn oauth_rate_limited() -> ProviderError { - ClaudeOAuthFetcher::rate_limited_error(Duration::from_secs(30)) - } - - #[test] - fn auto_fetch_error_asks_for_a_browser_sign_in_when_only_the_browser_can_help() { - // (CLI failure, retention policy of the plain summary) - let cases = [ - ( - ProviderError::Parse("Claude CLI did not return usage data".to_string()), - LastGoodFailurePolicy::Preserve, - ), - ( - ProviderError::Other("Claude CLI failed: exit status 1".to_string()), - LastGoodFailurePolicy::Replace, - ), - ]; - for (cli_failure, policy) in cases { - let err = claude_auto_fetch_error(vec![ - ("Web", ProviderError::NoCookies), - ("OAuth", oauth_rate_limited()), - ("CLI", cli_failure), - ]); - let ProviderError::BrowserSignInRequired { - message, - sign_in_url, - } = &err - else { - panic!("expected a browser sign-in signal, got {err:?}"); - }; - assert_eq!(sign_in_url, CLAUDE_BROWSER_SIGN_IN_URL); - assert_eq!(err.to_string(), *message); - assert!( - message.starts_with( - "Claude usage failed from all configured sources. Web: No cookies available for web API; OAuth: Transient OAuth error: Claude OAuth usage endpoint is rate limited." - ), - "{message}" - ); - assert!( - message - .ends_with("Sign in at https://claude.ai/login in your browser, then refresh."), - "{message}" - ); - // ClaudeProvider::error_state_kind defers to this for every - // variant except a missing CLI. - assert_eq!( - err.state_kind(), - crate::core::ProviderStateKind::NeedsAuthentication - ); - // The hint leaves the desktop retention policy unchanged. - let plain = message - .strip_suffix(browser_sign_in_hint().as_str()) - .map(str::trim_end) - .expect("hint is appended"); - assert_eq!(last_good_failure_policy_for_error(plain), policy); - assert_eq!(last_good_failure_policy_for_error(message), policy); - } - } - - #[test] - fn auto_fetch_error_keeps_other_failure_mixes_untyped() { - let cli_failure = - || ProviderError::Parse("Claude CLI did not return usage data".to_string()); - let mixes = [ - // A browser session was there; the Web source failed differently. - vec![ - ("Web", ProviderError::AuthRequired), - ("OAuth", oauth_rate_limited()), - ("CLI", cli_failure()), - ], - // Signed out of Claude Code, not rate limited. - vec![ - ("Web", ProviderError::NoCookies), - ( - "OAuth", - ProviderError::OAuth( - "Claude OAuth credentials not found. Run `claude` to authenticate." - .to_string(), - ), - ), - ("CLI", cli_failure()), - ], - // Another transient OAuth failure. - vec![ - ("Web", ProviderError::NoCookies), - ( - "OAuth", - ProviderError::OAuthTransient( - "Claude OAuth token expired and token refresh is cooling down after a failed attempt." - .to_string(), - ), - ), - ("CLI", cli_failure()), - ], - // The CLI was not tried. - vec![ - ("Web", ProviderError::NoCookies), - ("OAuth", oauth_rate_limited()), - ], - ]; - for failures in mixes { - let err = claude_auto_fetch_error(failures); - assert!(matches!(err, ProviderError::Other(_)), "{err:?}"); - assert!( - !err.to_string().contains(CLAUDE_BROWSER_SIGN_IN_URL), - "{err}" - ); - } - } - - #[test] - fn transient_transport_failure_stops_auto_fallback_and_preserves_last_good() { - let provider = ClaudeProvider::new(); - assert!(provider.retains_last_good_on_transport_failure()); - assert_eq!( - provider.last_good_failure_policy_for_error(&ProviderError::Timeout), - LastGoodFailurePolicy::Preserve - ); - - let mut failures = Vec::new(); - let result = record_auto_source(&mut failures, "Web", Err(ProviderError::Timeout)); - assert!(matches!(result, Err(ProviderError::Timeout))); - assert!(failures.is_empty()); - } - - #[test] - fn rejects_cli_output_that_is_not_a_usage_screen() { - let git_bash = "Claude Code on Windows requires git-bash."; - let claude_2_1 = r#" -I see you've entered `/usage` and `/exit`. - -**Usage**: Token usage and statistics are typically displayed by the CLI interface itself. I don't have direct access to those metrics through my available tools. - -**Exit**: I'll end the session here. Goodbye! -"#; - let legacy = r#" -I see you've entered two slash commands: - -1. `/usage` - This appears to be a request to check usage information -2. `/exit` - This appears to be a request to exit - -However, looking at the available custom slash commands, I don't see these commands defined. -"#; - let activity = r#" -❯ /usage - -Status Config Usage Stats - -Overview Models - -Favorite model: glm-4.6 Total tokens: 263.3k -Sessions: 6 Longest session: 18s -Active days: 2/10 Longest streak: 1 day -"#; - let ansi_activity = "\x1b[2CTotal\x1b[1Ccost:\x1b[12C$0.0000\n\ - \x1b[2CTotal\x1b[1Cduration\x1b[1C(API):\x1b[2C0s\n\ - \x1b[2CUsage:\x1b[17C0\x1b[1Cinput,\x1b[1C0\x1b[1Coutput,\x1b[1C0\x1b[1Ccache\x1b[1Cread"; - let rows: [(&str, &str, Option<&str>); 5] = [ - ( - git_bash, - "parse", - Some("Parse error: Claude CLI did not return usage data"), - ), - ( - claude_2_1, - "other", - Some( - "Claude CLI treated /usage as a normal prompt instead of opening the interactive usage screen. Use Auto, OAuth, or Web mode for Claude usage.", - ), - ), - (legacy, "other", None), - ( - activity, - "other", - Some( - "Claude CLI /usage opened, but this Claude version returned local activity stats instead of plan limit percentages. Use Auto, OAuth, or Web mode for Claude limits.", - ), - ), - (ansi_activity, "other", None), - ]; - for (output, kind, message) in rows { - let err = parse_err(output); - let got = match &err { - ProviderError::Parse(_) => "parse", - ProviderError::Other(_) => "other", - _ => "unexpected", - }; - assert_eq!(got, kind, "{output}"); - if let Some(message) = message { - assert_eq!(err.to_string(), message); - } - } - } - - #[test] - fn accepts_plan_limits_followed_by_activity_stats() { - // Claude Code 2.1.27x on Windows prints the exit summary (cost, - // duration, cache tokens) after the /usage view when the probe ends. - let output = r#" -❯ /usage - -Status Config Usage Stats - -Current session -███████░░░░░░░░░░░░░░░░░░░░░░ 19% used -Resets 3pm (Europe/Berlin) - -Current week (all models) -█████████░░░░░░░░░░░░░░░░░░░░ 31% used -Resets Sep 19, 4pm (Europe/Berlin) - -Total cost: $0.0000 -Total duration (API): 0s -Usage: 0 input, 0 output, 0 cache read -"#; - - let result = parse_ok(output); - - assert_eq!(result.usage.primary.used_percent, 19.0); - assert_eq!( - result - .usage - .secondary - .as_ref() - .map(|window| window.used_percent), - Some(31.0) - ); - } - - // ── Upstream 0.50.1 #2516: revoked vs missing OAuth ──────────────────────── - - #[test] - fn oauth_revoked_error_is_detected() { - assert!(is_oauth_revoked_error(&ProviderError::OAuthRevoked( - "revoked".to_string() - ))); - assert!(!is_oauth_revoked_error(&ProviderError::OAuth( - "expired".to_string() - ))); - assert!(!is_oauth_revoked_error(&ProviderError::AuthRequired)); - } - - #[test] - fn rate_limited_and_revoked_oauth_reuse_the_cli_cache() { - let rate_limited = ProviderError::OAuthTransient( - "Claude OAuth usage endpoint is rate limited. Retrying in about 5m; credentials were preserved." - .to_string(), - ); - assert!(oauth::is_rate_limited_error(&rate_limited)); - assert!(oauth_failure_uses_cli_cache(&rate_limited)); - assert!(oauth_failure_uses_cli_cache(&ProviderError::OAuthRevoked( - "revoked".to_string() - ))); - // Other transient failures and plain expiry still probe the CLI. - assert!(!oauth_failure_uses_cli_cache( - &ProviderError::OAuthTransient("connection reset".to_string()) - )); - assert!(!oauth_failure_uses_cli_cache(&ProviderError::OAuth( - "expired".to_string() - ))); - assert!(!oauth_failure_uses_cli_cache(&ProviderError::AuthRequired)); - } - - #[test] - fn cli_result_cache_round_trips() { - let mut result = ProviderFetchResult::new(UsageSnapshot::new(RateWindow::new(42.0)), "cli"); - result.has_successful_claude_cli_quota = true; - cache_cli_result(result.clone()); - let cached = cached_cli_result().expect("cached result within TTL"); - assert!((cached.usage.primary.used_percent - 42.0).abs() < 0.01); - assert_eq!(cached.source_label, "cli"); - assert!(!cached.has_successful_claude_cli_quota); - - // A live non-CLI success clears the cache. Same test, because the - // global is shared and tests run in parallel without a lock. - clear_cli_result_cache(); - assert!(cached_cli_result().is_none()); - } - - #[test] - fn cli_quota_without_credential_identity_cannot_prove_account_action() { - let result = mark_live_claude_cli_result(parse_ok( - "Current session\n25% used\nCurrent week (all models)\n40% used", - )); - - assert!(result.usage.account_email.is_none()); - assert!(result.has_successful_claude_cli_quota); - } - - #[test] - fn non_cli_fetch_result_does_not_prove_account_action() { - let result = ProviderFetchResult::new(UsageSnapshot::new(RateWindow::new(42.0)), "oauth"); - - assert!(!result.has_successful_claude_cli_quota); - } - #[test] - fn error_states_and_last_good_policies() { - use crate::core::ProviderStateKind; - let provider = ClaudeProvider::new(); - // `true` checks the free function on the rendered text; `false` the - // provider method. Each row keeps the path its original test used. - type Row = ( - ProviderError, - ProviderStateKind, - Option<(LastGoodFailurePolicy, bool)>, - ); - let rows: [Row; 6] = [ - ( - ProviderError::NotInstalled( - "Claude CLI not found. Install from https://docs.claude.ai/claude-code".to_string(), - ), - ProviderStateKind::LocalRuntimeOffline, - None, - ), - ( - ProviderError::AuthRequired, - ProviderStateKind::NeedsAuthentication, - None, - ), - (ProviderError::OAuthTransient( - "OAuth error: Claude OAuth usage endpoint is rate limited. Retrying in about 1s; credentials were preserved." - .to_string(), - ), ProviderStateKind::Unknown, Some((LastGoodFailurePolicy::Preserve, false))), - (ProviderError::OAuthTransient( - "Claude OAuth token expired and token refresh is cooling down after a failed attempt. Please retry shortly, or run `claude login`." - .to_string(), - ), ProviderStateKind::Unknown, Some((LastGoodFailurePolicy::Preserve, false))), - (ProviderError::OAuth( - "Claude OAuth credentials not found. Run `claude` to authenticate.".to_string(), - ), ProviderStateKind::NeedsAuthentication, Some((LastGoodFailurePolicy::Replace, true))), - (ProviderError::OAuth("OAuth API returned rate limited".to_string()), ProviderStateKind::NeedsAuthentication, Some((LastGoodFailurePolicy::Replace, false))), - ]; - for (error, state, policy) in rows { - assert_eq!(provider.error_state_kind(&error), state, "{error}"); - match policy { - Some((expected, true)) => assert_eq!( - last_good_failure_policy_for_error(&error.to_string()), - expected - ), - Some((expected, false)) => assert_eq!( - provider.last_good_failure_policy_for_error(&error), - expected, - "{error}" - ), - None => {} - } - } - } -} +mod tests; diff --git a/rust/src/providers/claude/tests.rs b/rust/src/providers/claude/tests.rs new file mode 100644 index 00000000000..0deb0f407e4 --- /dev/null +++ b/rust/src/providers/claude/tests.rs @@ -0,0 +1,1164 @@ +use chrono::{DateTime, Utc}; +use std::collections::HashMap; + +use super::cli_probe::{ + CLAUDE_PROBE_CACHE_FILE, CLAUDE_PROBE_CACHE_TTL, ClaudeProbeCache, ClaudeProbeLock, + claude_passive_probe_env, claude_probe_launch_args, claude_project_dir_name, + claude_usage_settings_args, cleanup_probe_session_jsonl, cleanup_probe_transcripts_in, + javascript_hash_base36, load_cached_probe_output, load_or_create_probe_session_id, + login_fingerprint_at, run_locked_probe, store_cached_probe_output, +}; +use super::*; + +const LOGIN_A: &str = "login-a"; + +fn login_a() -> Option { + Some(LOGIN_A.to_string()) +} + +#[test] +fn probe_cache_roundtrip_and_expiry() { + let dir = tempfile::tempdir().unwrap(); + assert!(load_cached_probe_output(dir.path(), LOGIN_A).is_none()); + store_cached_probe_output(dir.path(), LOGIN_A, "Current session 12% used"); + assert_eq!( + load_cached_probe_output(dir.path(), LOGIN_A).as_deref(), + Some("Current session 12% used") + ); + let stale = ClaudeProbeCache { + captured_at_unix: unix_now_secs() - CLAUDE_PROBE_CACHE_TTL.as_secs() - 5, + login: LOGIN_A.to_string(), + output: "Current session 12% used".to_string(), + }; + std::fs::write( + dir.path().join(CLAUDE_PROBE_CACHE_FILE), + serde_json::to_string(&stale).unwrap(), + ) + .unwrap(); + assert!(load_cached_probe_output(dir.path(), LOGIN_A).is_none()); +} + +#[test] +fn probe_cache_is_never_shared_with_another_login() { + let dir = tempfile::tempdir().unwrap(); + store_cached_probe_output(dir.path(), LOGIN_A, "Current session 12% used"); + assert!(load_cached_probe_output(dir.path(), "login-b").is_none()); + + // Written before screens were scoped to a login. + let unscoped = format!( + r#"{{"captured_at_unix":{},"output":"Current session 12% used"}}"#, + unix_now_secs() + ); + std::fs::write(dir.path().join(CLAUDE_PROBE_CACHE_FILE), unscoped).unwrap(); + assert!(load_cached_probe_output(dir.path(), LOGIN_A).is_none()); + assert!(load_cached_probe_output(dir.path(), "").is_none()); +} + +#[test] +fn login_fingerprint_follows_credential_rewrites_without_reading_them() { + let dir = tempfile::tempdir().unwrap(); + let credentials = dir.path().join(".credentials.json"); + assert_eq!(login_fingerprint_at(&credentials), None); + + std::fs::write(&credentials, "{}").unwrap(); + let first = login_fingerprint_at(&credentials).expect("fingerprint"); + assert_eq!(login_fingerprint_at(&credentials).as_ref(), Some(&first)); + assert!(!first.contains(".credentials"), "only a digest is stored"); + + std::fs::write(&credentials, r#"{"another":"login"}"#).unwrap(); + assert_ne!(login_fingerprint_at(&credentials), Some(first)); +} + +const SHAREABLE_USAGE_SCREEN: &str = "Current session\n\ + ████████▌ 17% used\n\ + Resets 12pm (America/Bogota)\n"; + +#[test] +fn locked_probe_reuses_a_screen_stored_while_it_waited() { + let dir = tempfile::tempdir().unwrap(); + store_cached_probe_output(dir.path(), LOGIN_A, SHAREABLE_USAGE_SCREEN); + + let output = run_locked_probe(dir.path(), Some(&login_a), || { + panic!("a fresh shared screen must not launch another probe") + }) + .unwrap(); + assert_eq!(output, SHAREABLE_USAGE_SCREEN); +} + +#[test] +fn locked_probe_shares_only_parseable_usage_screens() { + let dir = tempfile::tempdir().unwrap(); + let output = run_locked_probe(dir.path(), Some(&login_a), || { + Ok("Not logged in".to_string()) + }); + assert_eq!(output.unwrap(), "Not logged in"); + assert!(load_cached_probe_output(dir.path(), LOGIN_A).is_none()); + + let output = run_locked_probe(dir.path(), Some(&login_a), || { + Ok(SHAREABLE_USAGE_SCREEN.into()) + }); + assert_eq!(output.unwrap(), SHAREABLE_USAGE_SCREEN); + assert_eq!( + load_cached_probe_output(dir.path(), LOGIN_A).as_deref(), + Some(SHAREABLE_USAGE_SCREEN) + ); + assert!( + !dir.path() + .read_dir() + .unwrap() + .flatten() + .any(|entry| entry.file_name().to_string_lossy().contains(".tmp-")), + "the atomic write left no staging file behind" + ); +} + +#[test] +fn locked_probe_keeps_a_screen_private_when_the_login_changed_meanwhile() { + let dir = tempfile::tempdir().unwrap(); + let calls = std::cell::Cell::new(0); + let switching_login = || { + calls.set(calls.get() + 1); + Some(format!("login-{}", calls.get())) + }; + let output = run_locked_probe(dir.path(), Some(&switching_login), || { + Ok(SHAREABLE_USAGE_SCREEN.into()) + }); + assert_eq!(output.unwrap(), SHAREABLE_USAGE_SCREEN); + assert_eq!(calls.get(), 2, "the login is read before and after"); + assert!(load_cached_probe_output(dir.path(), "login-1").is_none()); + assert!(load_cached_probe_output(dir.path(), "login-2").is_none()); + + let no_login = || None; + run_locked_probe(dir.path(), Some(&no_login), || { + Ok(SHAREABLE_USAGE_SCREEN.into()) + }) + .unwrap(); + assert!(!dir.path().join(CLAUDE_PROBE_CACHE_FILE).exists()); +} + +#[test] +fn unshared_probe_neither_reuses_nor_stores_screens() { + let dir = tempfile::tempdir().unwrap(); + store_cached_probe_output(dir.path(), LOGIN_A, SHAREABLE_USAGE_SCREEN); + let output = run_locked_probe(dir.path(), None, || Ok("trust preflight".into())); + assert_eq!(output.unwrap(), "trust preflight"); + + let other = tempfile::tempdir().unwrap(); + run_locked_probe(other.path(), None, || Ok(SHAREABLE_USAGE_SCREEN.into())).unwrap(); + assert!(!other.path().join(CLAUDE_PROBE_CACHE_FILE).exists()); +} + +#[test] +fn logged_probe_screen_masks_account_email_and_secrets() { + let screen = "Login: someone@example.com (Claude Max)\n\ + access_token=abcdef0123456789 sk-ant-abcdefgh12345678\n\ + Current session 12% used"; + let logged = redacted_probe_screen(screen); + assert!(!logged.contains("someone@example.com"), "{logged}"); + assert!(!logged.contains("abcdef0123456789"), "{logged}"); + assert!(!logged.contains("sk-ant-abcdefgh12345678"), "{logged}"); + assert!(logged.contains("Current session 12% used")); +} + +#[test] +fn probe_lock_wait_expiry_fails_instead_of_probing_alongside() { + let dir = tempfile::tempdir().unwrap(); + let held = ClaudeProbeLock::acquire_within(dir.path(), Duration::ZERO) + .expect("first lock") + .expect("file locking is supported"); + + let error = match ClaudeProbeLock::acquire_within(dir.path(), Duration::from_millis(300)) { + Ok(lock) => panic!("second lock acquired while held: {}", lock.is_some()), + Err(error) => error, + }; + assert!(error.to_string().contains("Timed out waiting"), "{error}"); + assert_eq!( + last_good_failure_policy_for_error(&error.to_string()), + LastGoodFailurePolicy::Preserve + ); + + drop(held); + assert!( + ClaudeProbeLock::acquire_within(dir.path(), Duration::ZERO) + .expect("lock after release") + .is_some() + ); +} + +#[test] +fn passive_probe_env_disables_autoupdater_color_and_chrome() { + let env = claude_passive_probe_env(HashMap::new()); + for (key, value) in [ + ("DISABLE_AUTOUPDATER", "1"), + ("NO_COLOR", "1"), + ("CLAUDE_CODE_ENABLE_CFC", "0"), + ] { + assert_eq!(env.get(key).map(String::as_str), Some(value), "{key}"); + } +} + +#[test] +fn probe_session_id_is_reused_from_probe_directory() { + let dir = tempfile::tempdir().unwrap(); + let first = load_or_create_probe_session_id(dir.path()); + let second = load_or_create_probe_session_id(dir.path()); + assert_eq!(first, second); + assert!(uuid::Uuid::parse_str(&first).is_ok()); + let args = claude_probe_launch_args(&first); + // Positional structure only: the settings pair is pinned once by + // `claude_usage_settings_args` being the sole composer. + assert_eq!( + args[..4], + ["--setting-sources", "user", "--allowed-tools", ""] + ); + assert_eq!(args[4], claude_usage_settings_args()[0]); + assert_eq!(args[5], claude_usage_settings_args()[1]); + assert_eq!(args[6], "--session-id"); + assert_eq!(args[7], first); +} + +#[test] +fn usage_probe_settings_disable_remote_control_startup() { + assert_eq!( + claude_usage_settings_args(), + [ + "--settings".to_string(), + r#"{"remoteControlAtStartup":false,"tui":"default"}"#.to_string(), + ] + ); + let settings: serde_json::Value = + serde_json::from_str(&claude_usage_settings_args()[1]).unwrap(); + assert_eq!(settings["tui"], "default"); + assert_eq!(settings["remoteControlAtStartup"], false); +} + +#[test] +fn probe_session_jsonl_cleanup_removes_transcript_files() { + let dir = tempfile::tempdir().unwrap(); + let jsonl = dir.path().join("session.jsonl"); + std::fs::write(&jsonl, "{}").unwrap(); + std::fs::write(dir.path().join("keep.txt"), "x").unwrap(); + cleanup_probe_session_jsonl(dir.path()); + assert!(!jsonl.exists()); + assert!(dir.path().join("keep.txt").exists()); +} + +#[test] +fn probe_project_dir_name_matches_claude_code() { + use std::path::Path; + assert_eq!( + claude_project_dir_name(Path::new( + r"C:\Users\user\AppData\Local\CodexBar\claude-usage-probe" + )), + "C--Users-user-AppData-Local-CodexBar-claude-usage-probe" + ); + assert_eq!( + claude_project_dir_name(Path::new("/Users/me/Library/Application Support/x")), + "-Users-me-Library-Application-Support-x" + ); + // One dash per UTF-16 code unit, so two for a character outside the BMP. + assert_eq!( + claude_project_dir_name(Path::new("C:\\Users\\J\u{f6}rg\u{1F600}\\probe")), + "C--Users-J-rg---probe" + ); + // Reference values from Claude Code's JavaScript implementation. + let long = format!( + r"C:\Users\user\AppData\Local\{}claude-usage-probe", + r"deep\".repeat(40) + ); + assert_eq!( + claude_project_dir_name(Path::new(&long)), + format!( + "C--Users-user-AppData-Local-{}de-ttzy4x", + "deep-".repeat(34) + ) + ); + assert_eq!(javascript_hash_base36("hello"), "1n1e4y"); + assert_eq!(javascript_hash_base36(""), "0"); +} + +#[test] +fn probe_transcript_cleanup_stays_inside_the_probe_project() { + use std::path::Path; + let projects = tempfile::tempdir().unwrap(); + let other = projects.path().join("C--work-repo"); + std::fs::create_dir_all(&other).unwrap(); + std::fs::write(other.join("session.jsonl"), "{}").unwrap(); + + let busy_probe = Path::new(r"C:\Users\user\AppData\Local\CodexBar\busy-probe"); + let busy = projects.path().join(claude_project_dir_name(busy_probe)); + std::fs::create_dir_all(busy.join("folder.jsonl")).unwrap(); + std::fs::write(busy.join("session.jsonl"), "{}").unwrap(); + std::fs::write(busy.join("notes.txt"), "x").unwrap(); + cleanup_probe_transcripts_in(projects.path(), busy_probe); + assert!(!busy.join("session.jsonl").exists()); + assert!(busy.join("notes.txt").exists()); + assert!(busy.join("folder.jsonl").is_dir(), "only files are removed"); + + let probe = Path::new(r"C:\Users\user\AppData\Local\CodexBar\claude-usage-probe"); + let project = projects.path().join(claude_project_dir_name(probe)); + std::fs::create_dir_all(&project).unwrap(); + std::fs::write(project.join("a.jsonl"), "{}").unwrap(); + std::fs::write(project.join("b.jsonl"), "{}").unwrap(); + cleanup_probe_transcripts_in(projects.path(), probe); + assert!(!project.exists(), "an emptied probe project dir is removed"); + + assert!(other.join("session.jsonl").exists(), "other projects stay"); +} + +fn parse_ok(output: &str) -> ProviderFetchResult { + ClaudeProvider::new() + .parse_cli_output(output) + .expect("should parse") +} + +fn parse_err(output: &str) -> ProviderError { + ClaudeProvider::new() + .parse_cli_output(output) + .expect_err("should reject") +} + +#[test] +fn parses_current_cli_usage_screen() { + let output = r#" +Status Config Usage + + Current session + ██████████████████████████████████████████████████ 100% used + Resets 12pm (America/Bogota) + + Current week (all models) + ████████████████████████▌ 49% used + Resets Apr 3, 2pm (America/Bogota) + + Extra usage + ██▍ 4% used + $3.31 / $70.00 spent · Resets Apr 1 (America/Bogota) +"#; + + let result = parse_ok(output); + + assert_eq!(result.source_label, "cli"); + assert_eq!(result.usage.primary.used_percent, 100.0); + assert_eq!( + result.usage.primary.reset_description.as_deref(), + Some("Resets 12pm (America/Bogota)") + ); + + let weekly = result + .usage + .secondary + .expect("weekly usage should be present"); + assert_eq!(weekly.used_percent, 49.0); + assert_eq!( + weekly.reset_description.as_deref(), + Some("Resets Apr 3, 2pm (America/Bogota)") + ); +} + +#[test] +fn parses_short_forms_as_full_session_usage() { + let rows = [ + ( + "You're out of extra usage · resets 12pm (America/Bogota)", + "resets 12pm (America/Bogota)", + ), + ( + "You've hit your limit \u{00b7} resets 3:20pm (Asia/Shanghai)", + "resets 3:20pm (Asia/Shanghai)", + ), + ]; + for (output, reset) in rows { + let result = parse_ok(output); + assert_eq!(result.usage.primary.used_percent, 100.0, "{output}"); + assert_eq!( + result.usage.primary.reset_description.as_deref(), + Some(reset) + ); + } +} + +#[test] +fn parses_remaining_available_and_decimal_percentages() { + let output = r#" +Status Config Usage + + Current session + 12.5% remaining + Resets 8pm + + Current week (all models) + 4% available + Resets Apr 4, 2pm + + Current week (Sonnet only) + 1% consumed +"#; + + let result = parse_ok(output); + + assert_eq!(result.usage.primary.used_percent, 87.5); + assert_eq!( + result.usage.primary.reset_description.as_deref(), + Some("Resets 8pm") + ); + + let weekly = result + .usage + .secondary + .expect("weekly usage should be present"); + assert_eq!(weekly.used_percent, 96.0); + assert_eq!( + weekly.reset_description.as_deref(), + Some("Resets Apr 4, 2pm") + ); + + let sonnet = result + .usage + .extra_rate_windows + .iter() + .find(|window| window.id == "claude-weekly-scoped-sonnet") + .expect("sonnet usage should be present"); + assert_eq!(sonnet.window.used_percent, 1.0); +} + +#[test] +fn parses_all_cli_model_scoped_weekly_limits() { + let output = r#" +Current session +10% used +Resets 12pm (America/Bogota) + +Current week (all models) +20% used +Resets Apr 3, 2pm (America/Bogota) + +Current week (Sonnet only) +30% used +Resets Apr 4, 2pm (America/Bogota) + +Current week (Opus only) +40% used +Resets Apr 5, 2pm (America/Bogota) +"#; + + let result = parse_ok(output); + + assert_eq!(result.usage.extra_rate_windows.len(), 2); + assert_eq!( + result.usage.extra_rate_windows[0].id, + "claude-weekly-scoped-sonnet" + ); + assert_eq!(result.usage.extra_rate_windows[0].title, "Sonnet only"); + assert_eq!(result.usage.extra_rate_windows[0].window.used_percent, 30.0); + assert_eq!( + result.usage.extra_rate_windows[1].id, + "claude-weekly-scoped-opus" + ); + assert!(result.usage.model_specific.is_none()); +} + +#[test] +fn scoped_weekly_parser_handles_non_ascii_labels_and_reset_prefixes() { + let now = "2026-04-02T18:00:00Z".parse::>().unwrap(); + let limits = extract_cli_scoped_weekly_limits( + "Current week (A€€)\n10% used\nİResets Apr 3 at 2pm (America/Bogota)", + now, + ); + + assert_eq!(limits.len(), 1); + assert_eq!(limits[0].title, "A€€"); + assert_eq!( + limits[0].window.resets_at, + Some("2026-04-03T19:00:00Z".parse().unwrap()) + ); +} + +#[test] +fn resolves_cli_reset_occurrences_in_the_reported_timezone() { + let now = "2026-04-02T18:00:00Z".parse::>().unwrap(); + + assert_eq!( + parse_claude_reset_date("Resets Apr 3, 2027, 2pm (America/Bogota)", now, None), + Some("2027-04-03T19:00:00Z".parse().unwrap()) + ); + assert_eq!( + parse_claude_reset_date("Resets Apr 3, 2pm (America/Bogota)", now, None), + Some("2026-04-03T19:00:00Z".parse().unwrap()) + ); + assert_eq!( + parse_claude_reset_date("Resets 12pm (America/Bogota)", now, None), + Some("2026-04-03T17:00:00Z".parse().unwrap()) + ); + assert_eq!( + parse_claude_reset_date("ResetsApr3at2pm(America/Bogota)", now, None), + Some("2026-04-03T19:00:00Z".parse().unwrap()) + ); +} + +#[test] +fn timezone_less_resets_use_the_supplied_system_zone() { + let now = "2026-03-07T18:00:00Z".parse::>().unwrap(); + + assert_eq!( + parse_claude_reset_date_in_system_zone( + "Resets Mar 8 at 3:30am", + now, + None, + "America/New_York".parse().unwrap(), + ), + Some("2026-03-08T07:30:00Z".parse().unwrap()) + ); + assert_eq!( + parse_claude_reset_date_in_system_zone( + "Resets Mar 8 at 3:30am (America/Los_Angeles)", + now, + None, + "America/New_York".parse().unwrap(), + ), + Some("2026-03-08T10:30:00Z".parse().unwrap()) + ); +} + +#[test] +fn reset_dates_resolve_every_month_and_form() { + let now = "2026-09-24T12:00:00Z".parse::>().unwrap(); + let at = |text: &str, window: Option| { + parse_claude_reset_date(text, now, window).map(|date| date.to_rfc3339()) + }; + let months = [ + "Jan", "FEB", "mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "dEc", + ]; + for (index, month) in months.iter().enumerate() { + assert_eq!( + at(&format!("Resets {month} 5, 2027 at 3pm (UTC)"), None), + Some(format!("2027-{:02}-05T15:00:00+00:00", index + 1)), + "{month}" + ); + } + let rows = [ + ("Resets Foo 5, 2027 at 3pm (UTC)", None, None), + ("Resets Feb 30, 2027 at 3pm (UTC)", None, None), + ( + "Resets Sep 23 at 3pm (UTC)", + None, + Some("2027-09-23T15:00:00+00:00"), + ), + ( + "Resets Sep 23 at 3pm (UTC)", + Some(10_080), + Some("2026-09-23T15:00:00+00:00"), + ), + ( + "Resets Feb 29 at 3pm (UTC)", + None, + Some("2028-02-29T15:00:00+00:00"), + ), + ("Resets 3pm (UTC)", None, Some("2026-09-24T15:00:00+00:00")), + ("Resets 11am (UTC)", None, Some("2026-09-25T11:00:00+00:00")), + ( + "Resets 11am (UTC)", + Some(300), + Some("2026-09-24T11:00:00+00:00"), + ), + ( + "Resets Nov 1, 2026 at 1:30am (America/New_York)", + None, + Some("2026-11-01T05:30:00+00:00"), + ), + ( + "Resets Mar 8, 2026 at 2:30am (America/New_York)", + None, + None, + ), + ]; + for (text, window, expected) in rows { + assert_eq!(at(text, window).as_deref(), expected, "{text} {window:?}"); + } +} + +#[test] +fn parses_compact_usage_screen() { + let output = r#" +Settings:StatusConfigUsage(tabtocycle) +Loadingusagedata... +Currentsession +6%used +Resets4:29am(Asia/Calcutta) +Currentweek(allmodels) +4%used +ResetsFeb12at1:29pm(Asia/Calcutta) +Currentweek(Sonnetonly) +1%used +ResetsFeb12at1:29pm(Asia/Calcutta) +"#; + + let result = parse_ok(output); + + assert_eq!(result.usage.primary.used_percent, 6.0); + assert_eq!( + result.usage.primary.reset_description.as_deref(), + Some("Resets4:29am(Asia/Calcutta)") + ); + assert_eq!( + result + .usage + .secondary + .expect("weekly usage should be present") + .used_percent, + 4.0 + ); + let sonnet = result + .usage + .extra_rate_windows + .iter() + .find(|window| window.id == "claude-weekly-scoped-sonnet") + .expect("sonnet usage should be present"); + assert_eq!(result.usage.extra_rate_windows.len(), 1); + assert_eq!(sonnet.title, "Sonnet only"); + assert_eq!(sonnet.window.used_percent, 1.0); +} + +#[test] +fn does_not_promote_weekly_reset_to_session() { + let output = r#" +Current session +17% used +Current week (all models) +4% used +Resets Dec 24 at 3:59pm (Europe/Paris) +"#; + + let result = parse_ok(output); + + assert_eq!(result.usage.primary.used_percent, 17.0); + assert_eq!(result.usage.primary.reset_description, None); + assert_eq!( + result + .usage + .secondary + .expect("weekly usage should be present") + .reset_description + .as_deref(), + Some("Resets Dec 24 at 3:59pm (Europe/Paris)") + ); +} + +#[test] +fn cli_error_markers_map_to_fixed_errors() { + let git_bash = "Other(\"Claude CLI requires Git Bash on Windows. Install Git for Windows or set CLAUDE_CODE_GIT_BASH_PATH to your bash.exe path.\")"; + let cases = [ + ("Error: Not Logged In", "AuthRequired"), + ("login required to continue", "AuthRequired"), + ( + "TOKEN EXPIRED", + "OAuthExpired(\"Token expired. Run `claude login` to refresh.\")", + ), + ( + "{\"type\":\"token_expired\"}", + "OAuthExpired(\"Token expired. Run `claude login` to refresh.\")", + ), + ( + "authentication_error", + "OAuth(\"Authentication error. Run `claude login`.\")", + ), + ("Claude Code on Windows requires git-bash.", git_bash), + ( + "Running scripts is disabled on this system", + "Other(\"Claude CLI could not start because PowerShell script execution is disabled. Use claude.cmd or adjust the execution policy.\")", + ), + ( + "Cannot run a document in the middle of a pipeline", + "Other(\"Claude CLI resolved to a Unix shell script on Windows. Reinstall Claude Code or ensure claude.cmd is first on PATH.\")", + ), + // Auth markers win over environment markers. + ("requires git-bash; not logged in", "AuthRequired"), + ( + "requires git-bash; token expired", + "OAuthExpired(\"Token expired. Run `claude login` to refresh.\")", + ), + // Login wins over the other auth markers. + ("token expired; not logged in", "AuthRequired"), + ( + "authentication_error; token_expired", + "OAuthExpired(\"Token expired. Run `claude login` to refresh.\")", + ), + ("running scripts is disabled; requires git-bash", git_bash), + ]; + for (output, expected) in cases { + let error = claude_cli_error_from_output(output).expect(output); + assert_eq!(format!("{error:?}"), expected, "{output}"); + } + assert!(claude_cli_error_from_output("Current session 5% used").is_none()); +} + +#[test] +fn all_percents_fold_case_and_clamp() { + let text = "50% USED\n20 % Left\n101% used\n150% left\n5.5% remaining\n1000% used\n7%Spent 8% available"; + assert_eq!( + percent_matches(text).collect::>(), + vec![50.0, 80.0, 100.0, 0.0, 94.5, 0.0, 7.0, 92.0] + ); + assert!(percent_matches("no numbers here").next().is_none()); +} + +#[test] +fn label_sections_stop_at_their_window_and_the_next_section() { + let filler = |count: usize| vec!["filler"; count].join("\n"); + // Percent on the label line itself, and on the last line of the + // twelve-line window (label + 11). + assert_eq!( + extract_percent_near_label("Current session 30% used", "current session"), + Some(30.0) + ); + let at_last = format!("Current session\n{}\n40% used", filler(10)); + assert_eq!( + extract_percent_near_label(&at_last, "current session"), + Some(40.0) + ); + let past_window = format!("Current session\n{}\n40% used", filler(11)); + assert_eq!( + extract_percent_near_label(&past_window, "current session"), + None + ); + // The next "Current ..." heading ends the section, but the same + // heading does not. + let next_section = "Current session\nCurrent week\n40% used"; + assert_eq!( + extract_percent_near_label(next_section, "current session"), + None + ); + let same_label = "Current session\nCURRENT SESSION again\n40% used"; + assert_eq!( + extract_percent_near_label(same_label, "current session"), + Some(40.0) + ); + // A section without a value falls through to a later label line. + let later = "Current session\nCurrent week\n10% used\nCurrent session\n60% left"; + assert_eq!( + extract_percent_near_label(later, "current session"), + Some(40.0) + ); + assert_eq!( + extract_percent_near_label("Current week (all models)\n10% used", "current week"), + Some(10.0) + ); + + // Reset text uses a fourteen-line window (label + 13). + let reset_last = format!("Current week\n{}\nResets Mon 9am", filler(12)); + assert_eq!( + extract_reset_description(&reset_last, "current week").as_deref(), + Some("Resets Mon 9am") + ); + let reset_past = format!("Current week\n{}\nResets Mon 9am", filler(13)); + assert_eq!(extract_reset_description(&reset_past, "current week"), None); + assert_eq!( + extract_reset_description("Current week 5% used · resets Fri 1pm ", "current week") + .as_deref(), + Some("resets Fri 1pm") + ); + assert_eq!( + extract_reset_description( + "Current session\nCurrent week\nResets Mon", + "current session" + ), + None + ); + let later_reset = "Current session\nCurrent week\nCurrent session\nResets 5pm"; + assert_eq!( + extract_reset_description(later_reset, "current session").as_deref(), + Some("Resets 5pm") + ); +} + +#[test] +fn scoped_weekly_sections_use_a_fourteen_line_window() { + let now = Utc::now(); + let filler = |count: usize| vec!["filler"; count].join("\n"); + let inside = format!("Current week (Opus)\n{}\n25% used", filler(12)); + let limits = extract_cli_scoped_weekly_limits(&inside, now); + assert_eq!(limits.len(), 1); + assert_eq!(limits[0].window.used_percent, 25.0); + let outside = format!("Current week (Opus)\n{}\n25% used", filler(13)); + assert!(extract_cli_scoped_weekly_limits(&outside, now).is_empty()); + let next = "Current week (Opus)\nCurrent session\n25% used"; + assert!(extract_cli_scoped_weekly_limits(next, now).is_empty()); +} + +#[test] +fn cli_parse_usage_error_can_fallback_to_oauth() { + let err = ProviderError::Parse("Claude CLI did not return usage data".to_string()); + + assert!(should_fallback_from_claude_cli_error(&err)); +} + +#[test] +fn cli_auth_error_does_not_fallback_to_oauth() { + assert!(!should_fallback_from_claude_cli_error( + &ProviderError::AuthRequired + )); +} + +#[test] +fn auto_fetch_error_keeps_all_source_failures() { + let err = claude_auto_fetch_error(vec![ + ("OAuth", ProviderError::OAuth("token expired".to_string())), + ("Web", ProviderError::NoCookies), + ( + "CLI", + ProviderError::Parse("Empty output from Claude CLI".to_string()), + ), + ]); + + assert_eq!( + err.to_string(), + "Claude usage failed from all configured sources. OAuth: OAuth error: token expired; Web: No cookies available for web API; CLI: Parse error: Empty output from Claude CLI" + ); +} + +fn oauth_rate_limited() -> ProviderError { + ClaudeOAuthFetcher::rate_limited_error(Duration::from_secs(30)) +} + +#[test] +fn auto_fetch_error_asks_for_a_browser_sign_in_when_only_the_browser_can_help() { + // (CLI failure, retention policy of the plain summary) + let cases = [ + ( + ProviderError::Parse("Claude CLI did not return usage data".to_string()), + LastGoodFailurePolicy::Preserve, + ), + ( + ProviderError::Other("Claude CLI failed: exit status 1".to_string()), + LastGoodFailurePolicy::Replace, + ), + ]; + for (cli_failure, policy) in cases { + let err = claude_auto_fetch_error(vec![ + ("Web", ProviderError::NoCookies), + ("OAuth", oauth_rate_limited()), + ("CLI", cli_failure), + ]); + let ProviderError::BrowserSignInRequired { + message, + sign_in_url, + } = &err + else { + panic!("expected a browser sign-in signal, got {err:?}"); + }; + assert_eq!(sign_in_url, CLAUDE_BROWSER_SIGN_IN_URL); + assert_eq!(err.to_string(), *message); + assert!( + message.starts_with( + "Claude usage failed from all configured sources. Web: No cookies available for web API; OAuth: Transient OAuth error: Claude OAuth usage endpoint is rate limited." + ), + "{message}" + ); + assert!( + message.ends_with("Sign in at https://claude.ai/login in your browser, then refresh."), + "{message}" + ); + // ClaudeProvider::error_state_kind defers to this for every + // variant except a missing CLI. + assert_eq!( + err.state_kind(), + crate::core::ProviderStateKind::NeedsAuthentication + ); + // The hint leaves the desktop retention policy unchanged. + let plain = message + .strip_suffix(browser_sign_in_hint().as_str()) + .map(str::trim_end) + .expect("hint is appended"); + assert_eq!(last_good_failure_policy_for_error(plain), policy); + assert_eq!(last_good_failure_policy_for_error(message), policy); + } +} + +#[test] +fn auto_fetch_error_keeps_other_failure_mixes_untyped() { + let cli_failure = || ProviderError::Parse("Claude CLI did not return usage data".to_string()); + let mixes = [ + // A browser session was there; the Web source failed differently. + vec![ + ("Web", ProviderError::AuthRequired), + ("OAuth", oauth_rate_limited()), + ("CLI", cli_failure()), + ], + // Signed out of Claude Code, not rate limited. + vec![ + ("Web", ProviderError::NoCookies), + ( + "OAuth", + ProviderError::OAuth( + "Claude OAuth credentials not found. Run `claude` to authenticate." + .to_string(), + ), + ), + ("CLI", cli_failure()), + ], + // Another transient OAuth failure. + vec![ + ("Web", ProviderError::NoCookies), + ( + "OAuth", + ProviderError::OAuthTransient( + "Claude OAuth token expired and token refresh is cooling down after a failed attempt." + .to_string(), + ), + ), + ("CLI", cli_failure()), + ], + // The CLI was not tried. + vec![ + ("Web", ProviderError::NoCookies), + ("OAuth", oauth_rate_limited()), + ], + ]; + for failures in mixes { + let err = claude_auto_fetch_error(failures); + assert!(matches!(err, ProviderError::Other(_)), "{err:?}"); + assert!( + !err.to_string().contains(CLAUDE_BROWSER_SIGN_IN_URL), + "{err}" + ); + } +} + +#[test] +fn transient_transport_failure_stops_auto_fallback_and_preserves_last_good() { + let provider = ClaudeProvider::new(); + assert!(provider.retains_last_good_on_transport_failure()); + assert_eq!( + provider.last_good_failure_policy_for_error(&ProviderError::Timeout), + LastGoodFailurePolicy::Preserve + ); + + let mut failures = Vec::new(); + let result = record_auto_source(&mut failures, "Web", Err(ProviderError::Timeout)); + assert!(matches!(result, Err(ProviderError::Timeout))); + assert!(failures.is_empty()); +} + +#[test] +fn rejects_cli_output_that_is_not_a_usage_screen() { + let git_bash = "Claude Code on Windows requires git-bash."; + let claude_2_1 = r#" +I see you've entered `/usage` and `/exit`. + +**Usage**: Token usage and statistics are typically displayed by the CLI interface itself. I don't have direct access to those metrics through my available tools. + +**Exit**: I'll end the session here. Goodbye! +"#; + let legacy = r#" +I see you've entered two slash commands: + +1. `/usage` - This appears to be a request to check usage information +2. `/exit` - This appears to be a request to exit + +However, looking at the available custom slash commands, I don't see these commands defined. +"#; + let activity = r#" +❯ /usage + +Status Config Usage Stats + +Overview Models + +Favorite model: glm-4.6 Total tokens: 263.3k +Sessions: 6 Longest session: 18s +Active days: 2/10 Longest streak: 1 day +"#; + let ansi_activity = "\x1b[2CTotal\x1b[1Ccost:\x1b[12C$0.0000\n\ + \x1b[2CTotal\x1b[1Cduration\x1b[1C(API):\x1b[2C0s\n\ + \x1b[2CUsage:\x1b[17C0\x1b[1Cinput,\x1b[1C0\x1b[1Coutput,\x1b[1C0\x1b[1Ccache\x1b[1Cread"; + let rows: [(&str, &str, Option<&str>); 5] = [ + ( + git_bash, + "parse", + Some("Parse error: Claude CLI did not return usage data"), + ), + ( + claude_2_1, + "other", + Some( + "Claude CLI treated /usage as a normal prompt instead of opening the interactive usage screen. Use Auto, OAuth, or Web mode for Claude usage.", + ), + ), + (legacy, "other", None), + ( + activity, + "other", + Some( + "Claude CLI /usage opened, but this Claude version returned local activity stats instead of plan limit percentages. Use Auto, OAuth, or Web mode for Claude limits.", + ), + ), + (ansi_activity, "other", None), + ]; + for (output, kind, message) in rows { + let err = parse_err(output); + let got = match &err { + ProviderError::Parse(_) => "parse", + ProviderError::Other(_) => "other", + _ => "unexpected", + }; + assert_eq!(got, kind, "{output}"); + if let Some(message) = message { + assert_eq!(err.to_string(), message); + } + } +} + +#[test] +fn accepts_plan_limits_followed_by_activity_stats() { + // Claude Code 2.1.27x on Windows prints the exit summary (cost, + // duration, cache tokens) after the /usage view when the probe ends. + let output = r#" +❯ /usage + +Status Config Usage Stats + +Current session +███████░░░░░░░░░░░░░░░░░░░░░░ 19% used +Resets 3pm (Europe/Berlin) + +Current week (all models) +█████████░░░░░░░░░░░░░░░░░░░░ 31% used +Resets Sep 19, 4pm (Europe/Berlin) + +Total cost: $0.0000 +Total duration (API): 0s +Usage: 0 input, 0 output, 0 cache read +"#; + + let result = parse_ok(output); + + assert_eq!(result.usage.primary.used_percent, 19.0); + assert_eq!( + result + .usage + .secondary + .as_ref() + .map(|window| window.used_percent), + Some(31.0) + ); +} + +// ── Upstream 0.50.1 #2516: revoked vs missing OAuth ──────────────────────── + +#[test] +fn oauth_revoked_error_is_detected() { + assert!(is_oauth_revoked_error(&ProviderError::OAuthRevoked( + "revoked".to_string() + ))); + assert!(!is_oauth_revoked_error(&ProviderError::OAuth( + "expired".to_string() + ))); + assert!(!is_oauth_revoked_error(&ProviderError::AuthRequired)); +} + +#[test] +fn rate_limited_and_revoked_oauth_reuse_the_cli_cache() { + let rate_limited = ProviderError::OAuthTransient( + "Claude OAuth usage endpoint is rate limited. Retrying in about 5m; credentials were preserved." + .to_string(), + ); + assert!(oauth::is_rate_limited_error(&rate_limited)); + assert!(oauth_failure_uses_cli_cache(&rate_limited)); + assert!(oauth_failure_uses_cli_cache(&ProviderError::OAuthRevoked( + "revoked".to_string() + ))); + // Other transient failures and plain expiry still probe the CLI. + assert!(!oauth_failure_uses_cli_cache( + &ProviderError::OAuthTransient("connection reset".to_string()) + )); + assert!(!oauth_failure_uses_cli_cache(&ProviderError::OAuth( + "expired".to_string() + ))); + assert!(!oauth_failure_uses_cli_cache(&ProviderError::AuthRequired)); +} + +#[test] +fn cli_result_cache_round_trips() { + let mut result = ProviderFetchResult::new(UsageSnapshot::new(RateWindow::new(42.0)), "cli"); + result.has_successful_claude_cli_quota = true; + cache_cli_result(result.clone()); + let cached = cached_cli_result().expect("cached result within TTL"); + assert!((cached.usage.primary.used_percent - 42.0).abs() < 0.01); + assert_eq!(cached.source_label, "cli"); + assert!(!cached.has_successful_claude_cli_quota); + + // A live non-CLI success clears the cache. Same test, because the + // global is shared and tests run in parallel without a lock. + clear_cli_result_cache(); + assert!(cached_cli_result().is_none()); +} + +#[test] +fn cli_quota_without_credential_identity_cannot_prove_account_action() { + let result = mark_live_claude_cli_result(parse_ok( + "Current session\n25% used\nCurrent week (all models)\n40% used", + )); + + assert!(result.usage.account_email.is_none()); + assert!(result.has_successful_claude_cli_quota); +} + +#[test] +fn non_cli_fetch_result_does_not_prove_account_action() { + let result = ProviderFetchResult::new(UsageSnapshot::new(RateWindow::new(42.0)), "oauth"); + + assert!(!result.has_successful_claude_cli_quota); +} +#[test] +fn error_states_and_last_good_policies() { + use crate::core::ProviderStateKind; + let provider = ClaudeProvider::new(); + // `true` checks the free function on the rendered text; `false` the + // provider method. Each row keeps the path its original test used. + type Row = ( + ProviderError, + ProviderStateKind, + Option<(LastGoodFailurePolicy, bool)>, + ); + let rows: [Row; 6] = [ + ( + ProviderError::NotInstalled( + "Claude CLI not found. Install from https://docs.claude.ai/claude-code".to_string(), + ), + ProviderStateKind::LocalRuntimeOffline, + None, + ), + ( + ProviderError::AuthRequired, + ProviderStateKind::NeedsAuthentication, + None, + ), + (ProviderError::OAuthTransient( + "OAuth error: Claude OAuth usage endpoint is rate limited. Retrying in about 1s; credentials were preserved." + .to_string(), + ), ProviderStateKind::Unknown, Some((LastGoodFailurePolicy::Preserve, false))), + (ProviderError::OAuthTransient( + "Claude OAuth token expired and token refresh is cooling down after a failed attempt. Please retry shortly, or run `claude login`." + .to_string(), + ), ProviderStateKind::Unknown, Some((LastGoodFailurePolicy::Preserve, false))), + (ProviderError::OAuth( + "Claude OAuth credentials not found. Run `claude` to authenticate.".to_string(), + ), ProviderStateKind::NeedsAuthentication, Some((LastGoodFailurePolicy::Replace, true))), + (ProviderError::OAuth("OAuth API returned rate limited".to_string()), ProviderStateKind::NeedsAuthentication, Some((LastGoodFailurePolicy::Replace, false))), + ]; + for (error, state, policy) in rows { + assert_eq!(provider.error_state_kind(&error), state, "{error}"); + match policy { + Some((expected, true)) => assert_eq!( + last_good_failure_policy_for_error(&error.to_string()), + expected + ), + Some((expected, false)) => assert_eq!( + provider.last_good_failure_policy_for_error(&error), + expected, + "{error}" + ), + None => {} + } + } +} From 5e6fc6513101bc757da38f9229d13079d6b888dd Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:49:21 +0700 Subject: [PATCH 43/44] Move the cswap list doc back above parse_account_list --- rust/src/providers/claude/claude_swap/parser.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/src/providers/claude/claude_swap/parser.rs b/rust/src/providers/claude/claude_swap/parser.rs index 5a1b31378c8..45ba70ca690 100644 --- a/rust/src/providers/claude/claude_swap/parser.rs +++ b/rust/src/providers/claude/claude_swap/parser.rs @@ -211,7 +211,6 @@ fn parse_row( }) } -/// Strictly parse the schema-v1 `cswap --list --json` envelope. /// Parse a schema-v1 object and surface a reported `error` envelope before /// any command-specific field is read. fn parse_envelope(raw: &str) -> Result, ClaudeSwapError> { @@ -254,6 +253,7 @@ fn parse_envelope(raw: &str) -> Result, ClaudeSwa Ok(object) } +/// Strictly parse the schema-v1 `cswap --list --json` envelope. pub fn parse_account_list(raw: &str) -> Result { let object = parse_envelope(raw)?; From 63dc01aa26613955df11c18ac2cfb5ee2b8eccee Mon Sep 17 00:00:00 2001 From: Factory Agent Date: Sun, 18 Oct 2026 04:07:16 +0700 Subject: [PATCH 44/44] Address CodeRabbit review on Claude and Codex providers --- rust/src/providers/claude/admin_api.rs | 2 +- rust/src/providers/claude/cli_text.rs | 3 +-- rust/src/providers/codex/reset_observations.rs | 2 +- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/rust/src/providers/claude/admin_api.rs b/rust/src/providers/claude/admin_api.rs index d5ebb52d278..9196ba85b9e 100644 --- a/rust/src/providers/claude/admin_api.rs +++ b/rust/src/providers/claude/admin_api.rs @@ -308,7 +308,7 @@ fn result_from_admin_usage( }, "Claude API", |result| usd_from_lowest_unit(&result.amount), - |a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal), + |a, b| b.1.total_cmp(&a.1).then_with(|| a.0.cmp(&b.0)), ); for (idx, (item, cost)) in top_items.into_iter().take(3).enumerate() { usage = usage.with_extra_rate_window( diff --git a/rust/src/providers/claude/cli_text.rs b/rust/src/providers/claude/cli_text.rs index 90fe9b5303b..4eb6e7a40ee 100644 --- a/rust/src/providers/claude/cli_text.rs +++ b/rust/src/providers/claude/cli_text.rs @@ -138,8 +138,7 @@ pub(super) fn extract_reset_description(text: &str, label: &str) -> Option Option { - let lower = text.to_lowercase(); - let pos = lower.find("resets")?; + let pos = text.to_ascii_lowercase().find("resets")?; Some(text[pos..].trim().to_string()) } diff --git a/rust/src/providers/codex/reset_observations.rs b/rust/src/providers/codex/reset_observations.rs index 615e815f8ef..aa85e9c3003 100644 --- a/rust/src/providers/codex/reset_observations.rs +++ b/rust/src/providers/codex/reset_observations.rs @@ -303,7 +303,6 @@ mod tests { )); } - #[cfg(windows)] #[test] fn blank_store_fails_load_but_merges_as_empty() { let root = temp_root("blank"); @@ -349,6 +348,7 @@ mod tests { )); } + #[cfg(windows)] #[test] fn persisted_file_is_secure_wrapped() { let root = temp_root("secure");