From 4de7b8bb2d494be99b97b980d10a71cb0572a256 Mon Sep 17 00:00:00 2001 From: Fabien Penso Date: Sat, 31 Oct 2026 21:12:10 -0700 Subject: [PATCH 01/10] refactor(i18n): show plan usage providers' setup help through Fluent Each usage provider's settings carried their help as English in herdr-usage, which the usage panel's Setup block showed as written. Setting::help becomes Setting::message, the ID of a message in the provider's own locales/en-US/usage-.ftl (156 messages across the providers that read settings). The panel shows it with i18n::message. One file per provider keeps parallel provider PRs out of each other's catalogs. The example config's generated provider section came from the English in herdr-usage. Its generator and bless test move to the app's usage tests, which read the English messages. The section is unchanged byte for byte. A new test checks that every setting names a message without arguments, and the en-XA leak test no longer allows the help as data. --- .../herdr-gpui/locales/en-US/usage-abacus.ftl | 7 ++ .../herdr-gpui/locales/en-US/usage-aiand.ftl | 7 ++ .../herdr-gpui/locales/en-US/usage-aixy.ftl | 8 ++ .../locales/en-US/usage-alibaba.ftl | 10 ++ .../locales/en-US/usage-alibabatokenplan.ftl | 9 ++ crates/herdr-gpui/locales/en-US/usage-amp.ftl | 8 ++ .../locales/en-US/usage-antigravity.ftl | 7 ++ .../locales/en-US/usage-atlascloud.ftl | 7 ++ .../locales/en-US/usage-augment.ftl | 7 ++ .../locales/en-US/usage-azureopenai.ftl | 10 ++ .../locales/en-US/usage-bedrock.ftl | 9 ++ .../locales/en-US/usage-bifrost.ftl | 8 ++ .../herdr-gpui/locales/en-US/usage-chutes.ftl | 8 ++ .../locales/en-US/usage-clawrouter.ftl | 8 ++ .../locales/en-US/usage-clinepass.ftl | 7 ++ .../locales/en-US/usage-codebuff.ftl | 8 ++ .../locales/en-US/usage-coderabbit.ftl | 7 ++ .../locales/en-US/usage-commandcode.ftl | 7 ++ .../locales/en-US/usage-copilot.ftl | 8 ++ .../herdr-gpui/locales/en-US/usage-cursor.ftl | 7 ++ .../locales/en-US/usage-deepgram.ftl | 9 ++ .../locales/en-US/usage-deepinfra.ftl | 7 ++ .../locales/en-US/usage-deepseek.ftl | 8 ++ .../herdr-gpui/locales/en-US/usage-devin.ftl | 8 ++ .../locales/en-US/usage-devpass.ftl | 7 ++ .../locales/en-US/usage-elevenlabs.ftl | 8 ++ .../locales/en-US/usage-factory.ftl | 8 ++ .../locales/en-US/usage-fireworks.ftl | 8 ++ .../herdr-gpui/locales/en-US/usage-gemini.ftl | 8 ++ .../locales/en-US/usage-gitkraken.ftl | 8 ++ .../herdr-gpui/locales/en-US/usage-grok.ftl | 7 ++ .../herdr-gpui/locales/en-US/usage-groq.ftl | 8 ++ .../locales/en-US/usage-helmcode.ftl | 8 ++ .../locales/en-US/usage-huggingface.ftl | 8 ++ .../herdr-gpui/locales/en-US/usage-hyper.ftl | 8 ++ .../herdr-gpui/locales/en-US/usage-ibmbob.ftl | 8 ++ .../locales/en-US/usage-jetbrains.ftl | 7 ++ .../herdr-gpui/locales/en-US/usage-kilo.ftl | 7 ++ .../herdr-gpui/locales/en-US/usage-kimi.ftl | 11 ++ .../locales/en-US/usage-litellm.ftl | 8 ++ .../herdr-gpui/locales/en-US/usage-llmman.ftl | 8 ++ .../locales/en-US/usage-llmproxy.ftl | 8 ++ .../locales/en-US/usage-longcat.ftl | 7 ++ .../herdr-gpui/locales/en-US/usage-manus.ftl | 8 ++ .../herdr-gpui/locales/en-US/usage-mimo.ftl | 8 ++ .../locales/en-US/usage-minimax.ftl | 9 ++ .../locales/en-US/usage-mistral.ftl | 7 ++ .../locales/en-US/usage-moonshot.ftl | 8 ++ .../herdr-gpui/locales/en-US/usage-muse.ftl | 7 ++ .../locales/en-US/usage-neuralwatt.ftl | 8 ++ .../herdr-gpui/locales/en-US/usage-notion.ftl | 8 ++ .../herdr-gpui/locales/en-US/usage-nous.ftl | 8 ++ .../herdr-gpui/locales/en-US/usage-ollama.ftl | 8 ++ .../herdr-gpui/locales/en-US/usage-openai.ftl | 10 ++ .../locales/en-US/usage-opencode.ftl | 8 ++ .../locales/en-US/usage-opencodego.ftl | 9 ++ .../locales/en-US/usage-openrouter.ftl | 11 ++ .../locales/en-US/usage-perplexity.ftl | 8 ++ crates/herdr-gpui/locales/en-US/usage-poe.ftl | 7 ++ .../herdr-gpui/locales/en-US/usage-qoder.ftl | 8 ++ .../locales/en-US/usage-qwencloud.ftl | 8 ++ .../locales/en-US/usage-raycast.ftl | 7 ++ .../locales/en-US/usage-replicate.ftl | 7 ++ .../herdr-gpui/locales/en-US/usage-sakana.ftl | 7 ++ .../locales/en-US/usage-stepfun.ftl | 9 ++ .../locales/en-US/usage-sub2api.ftl | 8 ++ .../locales/en-US/usage-synthetic.ftl | 7 ++ .../herdr-gpui/locales/en-US/usage-t3chat.ftl | 7 ++ .../locales/en-US/usage-typesafe.ftl | 7 ++ crates/herdr-gpui/locales/en-US/usage-v0.ftl | 8 ++ .../herdr-gpui/locales/en-US/usage-venice.ftl | 8 ++ .../herdr-gpui/locales/en-US/usage-vercel.ftl | 7 ++ .../locales/en-US/usage-vertexai.ftl | 7 ++ .../herdr-gpui/locales/en-US/usage-warp.ftl | 7 ++ .../locales/en-US/usage-wayfinder.ftl | 7 ++ .../locales/en-US/usage-windsurf.ftl | 7 ++ crates/herdr-gpui/locales/en-US/usage-xai.ftl | 8 ++ .../herdr-gpui/locales/en-US/usage-xkiro.ftl | 7 ++ crates/herdr-gpui/locales/en-US/usage-zai.ftl | 14 +++ crates/herdr-gpui/locales/en-US/usage-zed.ftl | 7 ++ .../herdr-gpui/locales/en-US/usage-zenmux.ftl | 7 ++ .../locales/en-US/usage-zoommate.ftl | 8 ++ crates/herdr-gpui/src/usage/panel.rs | 12 +- crates/herdr-gpui/src/usage/tests.rs | 1 + crates/herdr-gpui/src/usage/tests/en_xa.rs | 5 +- .../src/usage/tests/example_config.rs | 103 ++++++++++++++++++ crates/herdr-usage/src/providers/abacus.rs | 9 +- crates/herdr-usage/src/providers/aiand.rs | 3 +- crates/herdr-usage/src/providers/aixy.rs | 12 +- crates/herdr-usage/src/providers/alibaba.rs | 18 +-- .../src/providers/alibabatokenplan.rs | 14 +-- crates/herdr-usage/src/providers/amp.rs | 17 +-- .../herdr-usage/src/providers/antigravity.rs | 5 +- .../herdr-usage/src/providers/atlascloud.rs | 4 +- crates/herdr-usage/src/providers/augment.rs | 10 +- .../herdr-usage/src/providers/azureopenai.rs | 12 +- crates/herdr-usage/src/providers/bedrock.rs | 16 +-- crates/herdr-usage/src/providers/bifrost.rs | 7 +- crates/herdr-usage/src/providers/chutes.rs | 5 +- .../herdr-usage/src/providers/clawrouter.rs | 6 +- crates/herdr-usage/src/providers/clinepass.rs | 3 +- crates/herdr-usage/src/providers/codebuff.rs | 7 +- .../herdr-usage/src/providers/coderabbit.rs | 3 +- .../herdr-usage/src/providers/commandcode.rs | 5 +- crates/herdr-usage/src/providers/copilot.rs | 8 +- crates/herdr-usage/src/providers/cursor.rs | 10 +- crates/herdr-usage/src/providers/deepgram.rs | 8 +- crates/herdr-usage/src/providers/deepinfra.rs | 3 +- crates/herdr-usage/src/providers/deepseek.rs | 8 +- crates/herdr-usage/src/providers/devin.rs | 9 +- crates/herdr-usage/src/providers/devpass.rs | 3 +- .../herdr-usage/src/providers/elevenlabs.rs | 5 +- crates/herdr-usage/src/providers/factory.rs | 11 +- crates/herdr-usage/src/providers/fireworks.rs | 7 +- crates/herdr-usage/src/providers/gemini.rs | 7 +- crates/herdr-usage/src/providers/gitkraken.rs | 8 +- crates/herdr-usage/src/providers/grok.rs | 4 +- crates/herdr-usage/src/providers/groq.rs | 14 +-- crates/herdr-usage/src/providers/helmcode.rs | 18 +-- .../herdr-usage/src/providers/huggingface.rs | 14 +-- crates/herdr-usage/src/providers/hyper.rs | 15 +-- crates/herdr-usage/src/providers/ibmbob.rs | 10 +- crates/herdr-usage/src/providers/jetbrains.rs | 7 +- crates/herdr-usage/src/providers/kilo.rs | 4 +- crates/herdr-usage/src/providers/kimi.rs | 30 +---- crates/herdr-usage/src/providers/litellm.rs | 7 +- crates/herdr-usage/src/providers/llmman.rs | 11 +- crates/herdr-usage/src/providers/llmproxy.rs | 6 +- crates/herdr-usage/src/providers/longcat.rs | 6 +- crates/herdr-usage/src/providers/manus.rs | 12 +- crates/herdr-usage/src/providers/mimo.rs | 17 +-- crates/herdr-usage/src/providers/minimax.rs | 14 +-- crates/herdr-usage/src/providers/mistral.rs | 10 +- crates/herdr-usage/src/providers/moonshot.rs | 9 +- crates/herdr-usage/src/providers/muse.rs | 11 +- .../herdr-usage/src/providers/neuralwatt.rs | 5 +- crates/herdr-usage/src/providers/notion.rs | 17 +-- crates/herdr-usage/src/providers/nous.rs | 7 +- crates/herdr-usage/src/providers/ollama.rs | 12 +- crates/herdr-usage/src/providers/openai.rs | 13 +-- crates/herdr-usage/src/providers/opencode.rs | 13 +-- .../herdr-usage/src/providers/opencodego.rs | 19 +--- .../herdr-usage/src/providers/openrouter.rs | 12 +- .../herdr-usage/src/providers/perplexity.rs | 8 +- crates/herdr-usage/src/providers/poe.rs | 2 +- crates/herdr-usage/src/providers/qoder.rs | 16 +-- crates/herdr-usage/src/providers/qwencloud.rs | 15 +-- crates/herdr-usage/src/providers/raycast.rs | 8 +- crates/herdr-usage/src/providers/replicate.rs | 5 +- crates/herdr-usage/src/providers/sakana.rs | 6 +- crates/herdr-usage/src/providers/stepfun.rs | 23 +--- crates/herdr-usage/src/providers/sub2api.rs | 6 +- crates/herdr-usage/src/providers/synthetic.rs | 3 +- crates/herdr-usage/src/providers/t3chat.rs | 9 +- crates/herdr-usage/src/providers/typesafe.rs | 9 +- crates/herdr-usage/src/providers/v0.rs | 12 +- crates/herdr-usage/src/providers/venice.rs | 13 +-- crates/herdr-usage/src/providers/vercel.rs | 3 +- crates/herdr-usage/src/providers/vertexai.rs | 5 +- crates/herdr-usage/src/providers/warp.rs | 3 +- crates/herdr-usage/src/providers/wayfinder.rs | 4 +- crates/herdr-usage/src/providers/windsurf.rs | 11 +- crates/herdr-usage/src/providers/xai.rs | 10 +- crates/herdr-usage/src/providers/xkiro.rs | 3 +- crates/herdr-usage/src/providers/zai.rs | 37 ++----- crates/herdr-usage/src/providers/zed.rs | 9 +- crates/herdr-usage/src/providers/zenmux.rs | 3 +- crates/herdr-usage/src/providers/zoommate.rs | 19 +--- crates/herdr-usage/src/service.rs | 17 ++- crates/herdr-usage/src/settings.rs | 74 ------------- crates/herdr-usage/src/tests/provider_data.rs | 11 +- .../src/tests/provider_settings.rs | 28 ----- docs/i18n.md | 6 +- 173 files changed, 945 insertions(+), 762 deletions(-) create mode 100644 crates/herdr-gpui/locales/en-US/usage-abacus.ftl create mode 100644 crates/herdr-gpui/locales/en-US/usage-aiand.ftl create mode 100644 crates/herdr-gpui/locales/en-US/usage-aixy.ftl create mode 100644 crates/herdr-gpui/locales/en-US/usage-alibaba.ftl create mode 100644 crates/herdr-gpui/locales/en-US/usage-alibabatokenplan.ftl create mode 100644 crates/herdr-gpui/locales/en-US/usage-amp.ftl create mode 100644 crates/herdr-gpui/locales/en-US/usage-antigravity.ftl create mode 100644 crates/herdr-gpui/locales/en-US/usage-atlascloud.ftl create mode 100644 crates/herdr-gpui/locales/en-US/usage-augment.ftl create mode 100644 crates/herdr-gpui/locales/en-US/usage-azureopenai.ftl create mode 100644 crates/herdr-gpui/locales/en-US/usage-bedrock.ftl create mode 100644 crates/herdr-gpui/locales/en-US/usage-bifrost.ftl create mode 100644 crates/herdr-gpui/locales/en-US/usage-chutes.ftl create mode 100644 crates/herdr-gpui/locales/en-US/usage-clawrouter.ftl create mode 100644 crates/herdr-gpui/locales/en-US/usage-clinepass.ftl create mode 100644 crates/herdr-gpui/locales/en-US/usage-codebuff.ftl create mode 100644 crates/herdr-gpui/locales/en-US/usage-coderabbit.ftl create mode 100644 crates/herdr-gpui/locales/en-US/usage-commandcode.ftl create mode 100644 crates/herdr-gpui/locales/en-US/usage-copilot.ftl create mode 100644 crates/herdr-gpui/locales/en-US/usage-cursor.ftl create mode 100644 crates/herdr-gpui/locales/en-US/usage-deepgram.ftl create mode 100644 crates/herdr-gpui/locales/en-US/usage-deepinfra.ftl create mode 100644 crates/herdr-gpui/locales/en-US/usage-deepseek.ftl create mode 100644 crates/herdr-gpui/locales/en-US/usage-devin.ftl create mode 100644 crates/herdr-gpui/locales/en-US/usage-devpass.ftl create mode 100644 crates/herdr-gpui/locales/en-US/usage-elevenlabs.ftl create mode 100644 crates/herdr-gpui/locales/en-US/usage-factory.ftl create mode 100644 crates/herdr-gpui/locales/en-US/usage-fireworks.ftl create mode 100644 crates/herdr-gpui/locales/en-US/usage-gemini.ftl create mode 100644 crates/herdr-gpui/locales/en-US/usage-gitkraken.ftl create mode 100644 crates/herdr-gpui/locales/en-US/usage-grok.ftl create mode 100644 crates/herdr-gpui/locales/en-US/usage-groq.ftl create mode 100644 crates/herdr-gpui/locales/en-US/usage-helmcode.ftl create mode 100644 crates/herdr-gpui/locales/en-US/usage-huggingface.ftl create mode 100644 crates/herdr-gpui/locales/en-US/usage-hyper.ftl create mode 100644 crates/herdr-gpui/locales/en-US/usage-ibmbob.ftl create mode 100644 crates/herdr-gpui/locales/en-US/usage-jetbrains.ftl create mode 100644 crates/herdr-gpui/locales/en-US/usage-kilo.ftl create mode 100644 crates/herdr-gpui/locales/en-US/usage-kimi.ftl create mode 100644 crates/herdr-gpui/locales/en-US/usage-litellm.ftl create mode 100644 crates/herdr-gpui/locales/en-US/usage-llmman.ftl create mode 100644 crates/herdr-gpui/locales/en-US/usage-llmproxy.ftl create mode 100644 crates/herdr-gpui/locales/en-US/usage-longcat.ftl create mode 100644 crates/herdr-gpui/locales/en-US/usage-manus.ftl create mode 100644 crates/herdr-gpui/locales/en-US/usage-mimo.ftl create mode 100644 crates/herdr-gpui/locales/en-US/usage-minimax.ftl create mode 100644 crates/herdr-gpui/locales/en-US/usage-mistral.ftl create mode 100644 crates/herdr-gpui/locales/en-US/usage-moonshot.ftl create mode 100644 crates/herdr-gpui/locales/en-US/usage-muse.ftl create mode 100644 crates/herdr-gpui/locales/en-US/usage-neuralwatt.ftl create mode 100644 crates/herdr-gpui/locales/en-US/usage-notion.ftl create mode 100644 crates/herdr-gpui/locales/en-US/usage-nous.ftl create mode 100644 crates/herdr-gpui/locales/en-US/usage-ollama.ftl create mode 100644 crates/herdr-gpui/locales/en-US/usage-openai.ftl create mode 100644 crates/herdr-gpui/locales/en-US/usage-opencode.ftl create mode 100644 crates/herdr-gpui/locales/en-US/usage-opencodego.ftl create mode 100644 crates/herdr-gpui/locales/en-US/usage-openrouter.ftl create mode 100644 crates/herdr-gpui/locales/en-US/usage-perplexity.ftl create mode 100644 crates/herdr-gpui/locales/en-US/usage-poe.ftl create mode 100644 crates/herdr-gpui/locales/en-US/usage-qoder.ftl create mode 100644 crates/herdr-gpui/locales/en-US/usage-qwencloud.ftl create mode 100644 crates/herdr-gpui/locales/en-US/usage-raycast.ftl create mode 100644 crates/herdr-gpui/locales/en-US/usage-replicate.ftl create mode 100644 crates/herdr-gpui/locales/en-US/usage-sakana.ftl create mode 100644 crates/herdr-gpui/locales/en-US/usage-stepfun.ftl create mode 100644 crates/herdr-gpui/locales/en-US/usage-sub2api.ftl create mode 100644 crates/herdr-gpui/locales/en-US/usage-synthetic.ftl create mode 100644 crates/herdr-gpui/locales/en-US/usage-t3chat.ftl create mode 100644 crates/herdr-gpui/locales/en-US/usage-typesafe.ftl create mode 100644 crates/herdr-gpui/locales/en-US/usage-v0.ftl create mode 100644 crates/herdr-gpui/locales/en-US/usage-venice.ftl create mode 100644 crates/herdr-gpui/locales/en-US/usage-vercel.ftl create mode 100644 crates/herdr-gpui/locales/en-US/usage-vertexai.ftl create mode 100644 crates/herdr-gpui/locales/en-US/usage-warp.ftl create mode 100644 crates/herdr-gpui/locales/en-US/usage-wayfinder.ftl create mode 100644 crates/herdr-gpui/locales/en-US/usage-windsurf.ftl create mode 100644 crates/herdr-gpui/locales/en-US/usage-xai.ftl create mode 100644 crates/herdr-gpui/locales/en-US/usage-xkiro.ftl create mode 100644 crates/herdr-gpui/locales/en-US/usage-zai.ftl create mode 100644 crates/herdr-gpui/locales/en-US/usage-zed.ftl create mode 100644 crates/herdr-gpui/locales/en-US/usage-zenmux.ftl create mode 100644 crates/herdr-gpui/locales/en-US/usage-zoommate.ftl create mode 100644 crates/herdr-gpui/src/usage/tests/example_config.rs diff --git a/crates/herdr-gpui/locales/en-US/usage-abacus.ftl b/crates/herdr-gpui/locales/en-US/usage-abacus.ftl new file mode 100644 index 000000000..bf5b186cf --- /dev/null +++ b/crates/herdr-gpui/locales/en-US/usage-abacus.ftl @@ -0,0 +1,7 @@ +# Abacus AI's plan usage, in its usage panel. + +## Setup: how to find each value the provider reads. Setting names, +## environment variables, URLs, cookie and header names, and the +## service's own menu paths stay as written. + +usage-abacus-setting-cookie = Sign in to https://apps.abacus.ai, open Developer Tools > Application > Cookies for https://apps.abacus.ai, and copy its session cookies (names containing "session", "auth", or "sid"). Paste them as "name=value; name2=value2"; the whole Cookie header of any apps.abacus.ai request also works. diff --git a/crates/herdr-gpui/locales/en-US/usage-aiand.ftl b/crates/herdr-gpui/locales/en-US/usage-aiand.ftl new file mode 100644 index 000000000..a1607b21c --- /dev/null +++ b/crates/herdr-gpui/locales/en-US/usage-aiand.ftl @@ -0,0 +1,7 @@ +# ai&'s plan usage, in its usage panel. + +## Setup: how to find each value the provider reads. Setting names, +## environment variables, URLs, cookie and header names, and the +## service's own menu paths stay as written. + +usage-aiand-setting-api-key = An ai& API key (starts with sk-), created at https://console.aiand.com under Settings > API Keys > Create. Keys are shown only once. diff --git a/crates/herdr-gpui/locales/en-US/usage-aixy.ftl b/crates/herdr-gpui/locales/en-US/usage-aixy.ftl new file mode 100644 index 000000000..5a10886b9 --- /dev/null +++ b/crates/herdr-gpui/locales/en-US/usage-aixy.ftl @@ -0,0 +1,8 @@ +# Aixy's plan usage, in its usage panel. + +## Setup: how to find each value the provider reads. Setting names, +## environment variables, URLs, cookie and header names, and the +## service's own menu paths stay as written. + +usage-aixy-setting-api-key = The project-scoped Aixy API key your workload uses, from https://dash.aixy-gateway.com under the project's API keys. Usage is that key's own traffic from every machine. +usage-aixy-setting-base-url = Optional gateway URL for a self-hosted or dedicated Aixy, with or without /v1. Defaults to https://api.aixy-gateway.com. It must be HTTPS unless it is on localhost, a private network, or a .local host. diff --git a/crates/herdr-gpui/locales/en-US/usage-alibaba.ftl b/crates/herdr-gpui/locales/en-US/usage-alibaba.ftl new file mode 100644 index 000000000..fb950ac72 --- /dev/null +++ b/crates/herdr-gpui/locales/en-US/usage-alibaba.ftl @@ -0,0 +1,10 @@ +# Alibaba's plan usage, in its usage panel. + +## Setup: how to find each value the provider reads. Setting names, +## environment variables, URLs, cookie and header names, and the +## service's own menu paths stay as written. + +usage-alibaba-setting-cookie = Your Model Studio console session. Sign in to the Coding Plan page at https://modelstudio.console.alibabacloud.com (or https://bailian.console.aliyun.com in China), open Developer Tools > Application > Cookies for that site, and copy at least login_aliyunid_ticket, login_aliyunid_pk, login_aliyunid_csrf and cna. Paste them as "name=value; name2=value2", or copy the whole Cookie header of the data/api.json request from the Network tab. +usage-alibaba-setting-api-key = A Coding Plan API key (sk-sp-...) from the Coding Plan page of the Model Studio console. Used when no console cookie works; some mainland accounts only answer the cookie. +usage-alibaba-setting-region = Optional. "intl" (modelstudio.console.alibabacloud.com) or "cn" (bailian.console.aliyun.com). Unset tries International, then China mainland. +usage-alibaba-setting-sec-token = Optional. The console's sec_token, when it cannot be read from /tool/user/info.json: in Developer Tools > Network, the sec_token form field of the data/api.json request on the Coding Plan page. diff --git a/crates/herdr-gpui/locales/en-US/usage-alibabatokenplan.ftl b/crates/herdr-gpui/locales/en-US/usage-alibabatokenplan.ftl new file mode 100644 index 000000000..00b81c59d --- /dev/null +++ b/crates/herdr-gpui/locales/en-US/usage-alibabatokenplan.ftl @@ -0,0 +1,9 @@ +# Alibaba Token Plan's plan usage, in its usage panel. + +## Setup: how to find each value the provider reads. Setting names, +## environment variables, URLs, cookie and header names, and the +## service's own menu paths stay as written. + +usage-alibabatokenplan-setting-region = Which Token Plan to read: "intl" (International Team, the default), "cn" (China mainland Team), "intl-personal" or "cn-personal" (Personal/Solo). +usage-alibabatokenplan-setting-cookie = Only needed without a signed-in Bailian CLI (bl). Sign in to the Token Plan page (https://modelstudio.console.alibabacloud.com, or https://bailian.console.aliyun.com in China), open Developer Tools > Application > Cookies for that site, and copy at least login_aliyunid_ticket, login_aliyunid_pk, login_aliyunid_csrf and cna. Paste them as "name=value; name2=value2", or copy the whole Cookie header of the data/api.json request from the Network tab. +usage-alibabatokenplan-setting-sec-token = Optional. The console's sec_token, when it cannot be read from /tool/user/info.json: in Developer Tools > Network, the sec_token form field of any data/api.json request on the Token Plan page. diff --git a/crates/herdr-gpui/locales/en-US/usage-amp.ftl b/crates/herdr-gpui/locales/en-US/usage-amp.ftl new file mode 100644 index 000000000..be459cc56 --- /dev/null +++ b/crates/herdr-gpui/locales/en-US/usage-amp.ftl @@ -0,0 +1,8 @@ +# Amp's plan usage, in its usage panel. + +## Setup: how to find each value the provider reads. Setting names, +## environment variables, URLs, cookie and header names, and the +## service's own menu paths stay as written. + +usage-amp-setting-api-key = An Amp access token, created in Amp's settings at https://ampcode.com/settings. Not needed when the Amp CLI is installed and signed in on the host (`amp login`). +usage-amp-setting-cookie = Your ampcode.com browser session, used when neither the CLI nor a token is available; it shows Amp Free only. Sign in at https://ampcode.com, open Developer Tools → Application → Cookies → https://ampcode.com, and copy the `session` cookie as "session=value". diff --git a/crates/herdr-gpui/locales/en-US/usage-antigravity.ftl b/crates/herdr-gpui/locales/en-US/usage-antigravity.ftl new file mode 100644 index 000000000..f9c99405a --- /dev/null +++ b/crates/herdr-gpui/locales/en-US/usage-antigravity.ftl @@ -0,0 +1,7 @@ +# Antigravity's plan usage, in its usage panel. + +## Setup: how to find each value the provider reads. Setting names, +## environment variables, URLs, cookie and header names, and the +## service's own menu paths stay as written. + +usage-antigravity-setting-cli-path = The agy executable on the probed host when it is not on the PATH (install it with `brew install --cask antigravity-cli`, run `agy` once, and sign in). agy runs only when this is set or antigravity is in show_providers, as a signed-out agy opens a browser to sign in. diff --git a/crates/herdr-gpui/locales/en-US/usage-atlascloud.ftl b/crates/herdr-gpui/locales/en-US/usage-atlascloud.ftl new file mode 100644 index 000000000..66a9c2fab --- /dev/null +++ b/crates/herdr-gpui/locales/en-US/usage-atlascloud.ftl @@ -0,0 +1,7 @@ +# Atlas Cloud's plan usage, in its usage panel. + +## Setup: how to find each value the provider reads. Setting names, +## environment variables, URLs, cookie and header names, and the +## service's own menu paths stay as written. + +usage-atlascloud-setting-api-key = A standard Atlas Cloud API key from https://www.atlascloud.ai/console. It needs account balance read permission: use the account owner's key, or an Account Admin or Finance key for a team. A public key ID (ak_…) cannot authenticate. diff --git a/crates/herdr-gpui/locales/en-US/usage-augment.ftl b/crates/herdr-gpui/locales/en-US/usage-augment.ftl new file mode 100644 index 000000000..5465719e0 --- /dev/null +++ b/crates/herdr-gpui/locales/en-US/usage-augment.ftl @@ -0,0 +1,7 @@ +# Augment's plan usage, in its usage panel. + +## Setup: how to find each value the provider reads. Setting names, +## environment variables, URLs, cookie and header names, and the +## service's own menu paths stay as written. + +usage-augment-setting-cookie = Your app.augmentcode.com browser session, used when the Auggie CLI is not signed in. Sign in at https://app.augmentcode.com, open Developer Tools → Application → Cookies → https://app.augmentcode.com, and copy the session cookies (such as `_session`, `session`, `web_rpc_proxy_session`, or `auth0`), pasted as one header: "name=value; name2=value2". diff --git a/crates/herdr-gpui/locales/en-US/usage-azureopenai.ftl b/crates/herdr-gpui/locales/en-US/usage-azureopenai.ftl new file mode 100644 index 000000000..6b308d583 --- /dev/null +++ b/crates/herdr-gpui/locales/en-US/usage-azureopenai.ftl @@ -0,0 +1,10 @@ +# Azure OpenAI's plan usage, in its usage panel. + +## Setup: how to find each value the provider reads. Setting names, +## environment variables, URLs, cookie and header names, and the +## service's own menu paths stay as written. + +usage-azureopenai-setting-api-key = An Azure OpenAI resource key: in the Azure portal open the Azure OpenAI resource, then Resource Management > Keys and Endpoint, and copy KEY 1. +usage-azureopenai-setting-endpoint = The resource endpoint from the same Keys and Endpoint page, e.g. https://my-resource.openai.azure.com. Only HTTPS is accepted. +usage-azureopenai-setting-deployment = The deployment name to check, as listed under Deployments in Azure AI Foundry (https://ai.azure.com), e.g. chat-prod. +usage-azureopenai-setting-api-version = Optional. "v1" for the OpenAI-compatible v1 API, or a dated API version. Defaults to 2024-10-21. diff --git a/crates/herdr-gpui/locales/en-US/usage-bedrock.ftl b/crates/herdr-gpui/locales/en-US/usage-bedrock.ftl new file mode 100644 index 000000000..f908b415b --- /dev/null +++ b/crates/herdr-gpui/locales/en-US/usage-bedrock.ftl @@ -0,0 +1,9 @@ +# AWS Bedrock's plan usage, in its usage panel. + +## Setup: how to find each value the provider reads. Setting names, +## environment variables, URLs, cookie and header names, and the +## service's own menu paths stay as written. + +usage-bedrock-setting-profile = The AWS CLI profile to read Cost Explorer with, from ~/.aws/config on the selected host (run `aws sso login --profile ` first for SSO). Use "default" for the default credentials. The identity needs ce:GetCostAndUsage. AWS bills every Cost Explorer request, so this provider runs only once profile or budget is set here. +usage-bedrock-setting-budget = Optional monthly Bedrock budget in US dollars, e.g. 250, shown as a monthly limit. It does not cap AWS charges. +usage-bedrock-setting-aws-cli = Optional path to the AWS CLI v2 on the selected host when `aws` is not on its PATH, e.g. /opt/homebrew/bin/aws. diff --git a/crates/herdr-gpui/locales/en-US/usage-bifrost.ftl b/crates/herdr-gpui/locales/en-US/usage-bifrost.ftl new file mode 100644 index 000000000..1c26fe6d9 --- /dev/null +++ b/crates/herdr-gpui/locales/en-US/usage-bifrost.ftl @@ -0,0 +1,8 @@ +# Bifrost's plan usage, in its usage panel. + +## Setup: how to find each value the provider reads. Setting names, +## environment variables, URLs, cookie and header names, and the +## service's own menu paths stay as written. + +usage-bifrost-setting-api-key = Your Bifrost virtual key (vk-…), from the gateway's Virtual Keys page. It is sent as the x-bf-vk header to base_url only. +usage-bifrost-setting-base-url = Your Bifrost gateway's URL, e.g. https://bifrost.example.com. Bifrost has no public host. It must be HTTPS unless it is on localhost, a private network, or a .local host. diff --git a/crates/herdr-gpui/locales/en-US/usage-chutes.ftl b/crates/herdr-gpui/locales/en-US/usage-chutes.ftl new file mode 100644 index 000000000..2777cda72 --- /dev/null +++ b/crates/herdr-gpui/locales/en-US/usage-chutes.ftl @@ -0,0 +1,8 @@ +# Chutes's plan usage, in its usage panel. + +## Setup: how to find each value the provider reads. Setting names, +## environment variables, URLs, cookie and header names, and the +## service's own menu paths stay as written. + +usage-chutes-setting-api-key = A Chutes API key (cpk_…), created as described at https://chutes.ai/docs/getting-started/authentication. +usage-chutes-setting-base-url = Optional HTTPS management API URL. Defaults to https://api.chutes.ai. diff --git a/crates/herdr-gpui/locales/en-US/usage-clawrouter.ftl b/crates/herdr-gpui/locales/en-US/usage-clawrouter.ftl new file mode 100644 index 000000000..9dd4b70a1 --- /dev/null +++ b/crates/herdr-gpui/locales/en-US/usage-clawrouter.ftl @@ -0,0 +1,8 @@ +# ClawRouter's plan usage, in its usage panel. + +## Setup: how to find each value the provider reads. Setting names, +## environment variables, URLs, cookie and header names, and the +## service's own menu paths stay as written. + +usage-clawrouter-setting-api-key = A ClawRouter policy API key, created at https://clawrouter.openclaw.ai/dashboard/access. +usage-clawrouter-setting-base-url = Optional HTTPS service root or /v1 URL of another ClawRouter deployment. Defaults to https://clawrouter.openclaw.ai. diff --git a/crates/herdr-gpui/locales/en-US/usage-clinepass.ftl b/crates/herdr-gpui/locales/en-US/usage-clinepass.ftl new file mode 100644 index 000000000..cd5916f3e --- /dev/null +++ b/crates/herdr-gpui/locales/en-US/usage-clinepass.ftl @@ -0,0 +1,7 @@ +# ClinePass's plan usage, in its usage panel. + +## Setup: how to find each value the provider reads. Setting names, +## environment variables, URLs, cookie and header names, and the +## service's own menu paths stay as written. + +usage-clinepass-setting-api-key = A Cline API key for the account with the ClinePass subscription. Create one at https://app.cline.bot under Settings > API Keys. diff --git a/crates/herdr-gpui/locales/en-US/usage-codebuff.ftl b/crates/herdr-gpui/locales/en-US/usage-codebuff.ftl new file mode 100644 index 000000000..4950fd866 --- /dev/null +++ b/crates/herdr-gpui/locales/en-US/usage-codebuff.ftl @@ -0,0 +1,8 @@ +# Codebuff's plan usage, in its usage panel. + +## Setup: how to find each value the provider reads. Setting names, +## environment variables, URLs, cookie and header names, and the +## service's own menu paths stay as written. + +usage-codebuff-setting-api-key = A Codebuff API key from https://www.codebuff.com. It reads the credit balance only; without it, the session from `codebuff login` is used, which also reads the plan and weekly limit. +usage-codebuff-setting-base-url = An https URL that replaces https://www.codebuff.com, e.g. for a staging server. Other schemes are ignored so the token never travels unencrypted. diff --git a/crates/herdr-gpui/locales/en-US/usage-coderabbit.ftl b/crates/herdr-gpui/locales/en-US/usage-coderabbit.ftl new file mode 100644 index 000000000..1c2004357 --- /dev/null +++ b/crates/herdr-gpui/locales/en-US/usage-coderabbit.ftl @@ -0,0 +1,7 @@ +# CodeRabbit's plan usage, in its usage panel. + +## Setup: how to find each value the provider reads. Setting names, +## environment variables, URLs, cookie and header names, and the +## service's own menu paths stay as written. + +usage-coderabbit-setting-cli-path = The path of the coderabbit executable on the probed host, when it is not on the usual PATH (~/.local/bin, Homebrew). Sign in once with `coderabbit auth login`. diff --git a/crates/herdr-gpui/locales/en-US/usage-commandcode.ftl b/crates/herdr-gpui/locales/en-US/usage-commandcode.ftl new file mode 100644 index 000000000..537f97ea2 --- /dev/null +++ b/crates/herdr-gpui/locales/en-US/usage-commandcode.ftl @@ -0,0 +1,7 @@ +# Command Code's plan usage, in its usage panel. + +## Setup: how to find each value the provider reads. Setting names, +## environment variables, URLs, cookie and header names, and the +## service's own menu paths stay as written. + +usage-commandcode-setting-cookie = Sign in to https://commandcode.ai, open Developer Tools > Application > Cookies for https://commandcode.ai, and copy the session cookie: __Secure-better-auth.session_token (or __Secure-commandcode_prod_.session_token, whichever is present). Paste it as "name=value". diff --git a/crates/herdr-gpui/locales/en-US/usage-copilot.ftl b/crates/herdr-gpui/locales/en-US/usage-copilot.ftl new file mode 100644 index 000000000..845b125c2 --- /dev/null +++ b/crates/herdr-gpui/locales/en-US/usage-copilot.ftl @@ -0,0 +1,8 @@ +# Copilot's plan usage, in its usage panel. + +## Setup: how to find each value the provider reads. Setting names, +## environment variables, URLs, cookie and header names, and the +## service's own menu paths stay as written. + +usage-copilot-setting-token = A GitHub token of the account with Copilot, e.g. a fine-grained personal access token from https://github.com/settings/personal-access-tokens with no extra permissions, or the output of `gh auth token`. Not needed when a Copilot editor plugin is signed in on the host. +usage-copilot-setting-enterprise-host = For GitHub Enterprise Cloud with data residency: the host, such as octocorp.ghe.com. Usage is then read from api.. diff --git a/crates/herdr-gpui/locales/en-US/usage-cursor.ftl b/crates/herdr-gpui/locales/en-US/usage-cursor.ftl new file mode 100644 index 000000000..f02c19285 --- /dev/null +++ b/crates/herdr-gpui/locales/en-US/usage-cursor.ftl @@ -0,0 +1,7 @@ +# Cursor's plan usage, in its usage panel. + +## Setup: how to find each value the provider reads. Setting names, +## environment variables, URLs, cookie and header names, and the +## service's own menu paths stay as written. + +usage-cursor-setting-cookie = Your cursor.com session. Sign in at https://cursor.com/dashboard, open Developer Tools > Application > Cookies > https://cursor.com, and copy the WorkosCursorSessionToken cookie (or __Secure-next-auth.session-token on older accounts). Paste it as "WorkosCursorSessionToken=value". Not needed when Cursor is listed in show_providers and you are signed in with Chrome or Safari. diff --git a/crates/herdr-gpui/locales/en-US/usage-deepgram.ftl b/crates/herdr-gpui/locales/en-US/usage-deepgram.ftl new file mode 100644 index 000000000..23107354f --- /dev/null +++ b/crates/herdr-gpui/locales/en-US/usage-deepgram.ftl @@ -0,0 +1,9 @@ +# Deepgram's plan usage, in its usage panel. + +## Setup: how to find each value the provider reads. Setting names, +## environment variables, URLs, cookie and header names, and the +## service's own menu paths stay as written. + +usage-deepgram-setting-api-key = A Deepgram API key with the usage:read scope, created in the Deepgram Console (https://console.deepgram.com) under API Keys. +usage-deepgram-setting-project-id = Optional project UUID from the Deepgram Console. Leave unset to sum usage over every project the key can see. +usage-deepgram-setting-base-url = Optional HTTPS API URL for a proxy. Defaults to https://api.deepgram.com/v1. diff --git a/crates/herdr-gpui/locales/en-US/usage-deepinfra.ftl b/crates/herdr-gpui/locales/en-US/usage-deepinfra.ftl new file mode 100644 index 000000000..7481b99ed --- /dev/null +++ b/crates/herdr-gpui/locales/en-US/usage-deepinfra.ftl @@ -0,0 +1,7 @@ +# DeepInfra's plan usage, in its usage panel. + +## Setup: how to find each value the provider reads. Setting names, +## environment variables, URLs, cookie and header names, and the +## service's own menu paths stay as written. + +usage-deepinfra-setting-api-key = A DeepInfra API key from https://deepinfra.com/dash/api_keys, without a "Bearer " prefix. It must be allowed to read billing data. diff --git a/crates/herdr-gpui/locales/en-US/usage-deepseek.ftl b/crates/herdr-gpui/locales/en-US/usage-deepseek.ftl new file mode 100644 index 000000000..d55b12b49 --- /dev/null +++ b/crates/herdr-gpui/locales/en-US/usage-deepseek.ftl @@ -0,0 +1,8 @@ +# DeepSeek's plan usage, in its usage panel. + +## Setup: how to find each value the provider reads. Setting names, +## environment variables, URLs, cookie and header names, and the +## service's own menu paths stay as written. + +usage-deepseek-setting-api-key = A DeepSeek API key from https://platform.deepseek.com/api_keys. It reads the remaining credit balance. +usage-deepseek-setting-platform-token = Used when no API key is set. Sign in to https://platform.deepseek.com, open Developer Tools > Application > Local Storage > https://platform.deepseek.com, and copy the value of the userToken entry (either the whole JSON value or just its "value" field). diff --git a/crates/herdr-gpui/locales/en-US/usage-devin.ftl b/crates/herdr-gpui/locales/en-US/usage-devin.ftl new file mode 100644 index 000000000..2d22b7391 --- /dev/null +++ b/crates/herdr-gpui/locales/en-US/usage-devin.ftl @@ -0,0 +1,8 @@ +# Devin's plan usage, in its usage panel. + +## Setup: how to find each value the provider reads. Setting names, +## environment variables, URLs, cookie and header names, and the +## service's own menu paths stay as written. + +usage-devin-setting-token = Sign in to https://app.devin.ai, open your organization's Usage & Limits page, then in Developer Tools > Network select the successful billing/quota/usage request and copy its Authorization request header without the leading "Bearer ". The value is a browser session token and expires with that session. +usage-devin-setting-organization = The x-cog-org-id request header of the same request (an org-… or org_… id). An organization slug or its app.devin.ai URL also works. diff --git a/crates/herdr-gpui/locales/en-US/usage-devpass.ftl b/crates/herdr-gpui/locales/en-US/usage-devpass.ftl new file mode 100644 index 000000000..30a511c94 --- /dev/null +++ b/crates/herdr-gpui/locales/en-US/usage-devpass.ftl @@ -0,0 +1,7 @@ +# DevPass's plan usage, in its usage panel. + +## Setup: how to find each value the provider reads. Setting names, +## environment variables, URLs, cookie and header names, and the +## service's own menu paths stay as written. + +usage-devpass-setting-api-key = A regular LLM Gateway API key from https://devpass.llmgateway.io/dashboard. Publishable keys cannot read plan usage. diff --git a/crates/herdr-gpui/locales/en-US/usage-elevenlabs.ftl b/crates/herdr-gpui/locales/en-US/usage-elevenlabs.ftl new file mode 100644 index 000000000..93d1a619d --- /dev/null +++ b/crates/herdr-gpui/locales/en-US/usage-elevenlabs.ftl @@ -0,0 +1,8 @@ +# ElevenLabs's plan usage, in its usage panel. + +## Setup: how to find each value the provider reads. Setting names, +## environment variables, URLs, cookie and header names, and the +## service's own menu paths stay as written. + +usage-elevenlabs-setting-api-key = An ElevenLabs API key with the user_read permission, from https://elevenlabs.io/app/settings/api-keys. +usage-elevenlabs-setting-base-url = Optional HTTPS API origin for a proxy. Defaults to https://api.elevenlabs.io. diff --git a/crates/herdr-gpui/locales/en-US/usage-factory.ftl b/crates/herdr-gpui/locales/en-US/usage-factory.ftl new file mode 100644 index 000000000..812ea134e --- /dev/null +++ b/crates/herdr-gpui/locales/en-US/usage-factory.ftl @@ -0,0 +1,8 @@ +# Droid's plan usage, in its usage panel. + +## Setup: how to find each value the provider reads. Setting names, +## environment variables, URLs, cookie and header names, and the +## service's own menu paths stay as written. + +usage-factory-setting-api-key = A Factory API key (fk-…) from https://app.factory.ai/settings/api-keys. +usage-factory-setting-cookie = Used when no API key is set. Sign in to https://app.factory.ai, open Developer Tools > Application > Cookies > https://app.factory.ai, and copy the session cookies (wos-session, access-token, and any *.session-token) as "name=value; name2=value2". diff --git a/crates/herdr-gpui/locales/en-US/usage-fireworks.ftl b/crates/herdr-gpui/locales/en-US/usage-fireworks.ftl new file mode 100644 index 000000000..e08fa7761 --- /dev/null +++ b/crates/herdr-gpui/locales/en-US/usage-fireworks.ftl @@ -0,0 +1,8 @@ +# Fireworks's plan usage, in its usage panel. + +## Setup: how to find each value the provider reads. Setting names, +## environment variables, URLs, cookie and header names, and the +## service's own menu paths stay as written. + +usage-fireworks-setting-api-key = A Fireworks API key from https://app.fireworks.ai/settings/users/api-keys. It reads the account's billing summary. +usage-fireworks-setting-account-slug = The Fireworks account id, needed only when the API key can see several accounts. Find it in the account switcher on https://app.fireworks.ai or with `firectl whoami`. diff --git a/crates/herdr-gpui/locales/en-US/usage-gemini.ftl b/crates/herdr-gpui/locales/en-US/usage-gemini.ftl new file mode 100644 index 000000000..f1c856143 --- /dev/null +++ b/crates/herdr-gpui/locales/en-US/usage-gemini.ftl @@ -0,0 +1,8 @@ +# Gemini's plan usage, in its usage panel. + +## Setup: how to find each value the provider reads. Setting names, +## environment variables, URLs, cookie and header names, and the +## service's own menu paths stay as written. + +usage-gemini-setting-client-id = The Gemini CLI's OAuth client id, used to refresh an expired sign-in. Normally found in the installed CLI (OAUTH_CLIENT_ID in @google/gemini-cli-core/dist/src/code_assist/oauth2.js); set it only when the CLI is not on the host's PATH. +usage-gemini-setting-client-secret = The matching OAUTH_CLIENT_SECRET from the same file. diff --git a/crates/herdr-gpui/locales/en-US/usage-gitkraken.ftl b/crates/herdr-gpui/locales/en-US/usage-gitkraken.ftl new file mode 100644 index 000000000..4555a8b12 --- /dev/null +++ b/crates/herdr-gpui/locales/en-US/usage-gitkraken.ftl @@ -0,0 +1,8 @@ +# GitKraken AI's plan usage, in its usage panel. + +## Setup: how to find each value the provider reads. Setting names, +## environment variables, URLs, cookie and header names, and the +## service's own menu paths stay as written. + +usage-gitkraken-setting-token = A GitKraken account session access token. Sign in at https://gitkraken.dev/account#ai-usage, open Developer Tools > Network, select the successful ai-tasks/usage request, and copy only the value after "Bearer " in its Authorization header. Replace it when it expires. +usage-gitkraken-setting-org-id = Optional organization ID: the gk-org-id header of the same request, to read that organization's shared pool. diff --git a/crates/herdr-gpui/locales/en-US/usage-grok.ftl b/crates/herdr-gpui/locales/en-US/usage-grok.ftl new file mode 100644 index 000000000..4f7eb3c22 --- /dev/null +++ b/crates/herdr-gpui/locales/en-US/usage-grok.ftl @@ -0,0 +1,7 @@ +# Grok's plan usage, in its usage panel. + +## Setup: how to find each value the provider reads. Setting names, +## environment variables, URLs, cookie and header names, and the +## service's own menu paths stay as written. + +usage-grok-setting-token = A SuperGrok bearer token, only needed when the Grok CLI is not signed in on the selected host (run `grok login` there instead when you can). It is the "key" of the https://auth.x.ai entry in ~/.grok/auth.json. xai- API keys do not work. diff --git a/crates/herdr-gpui/locales/en-US/usage-groq.ftl b/crates/herdr-gpui/locales/en-US/usage-groq.ftl new file mode 100644 index 000000000..71ad90445 --- /dev/null +++ b/crates/herdr-gpui/locales/en-US/usage-groq.ftl @@ -0,0 +1,8 @@ +# Groq's plan usage, in its usage panel. + +## Setup: how to find each value the provider reads. Setting names, +## environment variables, URLs, cookie and header names, and the +## service's own menu paths stay as written. + +usage-groq-setting-api-key = A GroqCloud Enterprise API key from https://console.groq.com/keys. Only Enterprise organizations can read Prometheus metrics. +usage-groq-setting-base-url = Optional HTTPS API base URL for a private gateway. Defaults to https://api.groq.com/v1. diff --git a/crates/herdr-gpui/locales/en-US/usage-helmcode.ftl b/crates/herdr-gpui/locales/en-US/usage-helmcode.ftl new file mode 100644 index 000000000..e77551b90 --- /dev/null +++ b/crates/herdr-gpui/locales/en-US/usage-helmcode.ftl @@ -0,0 +1,8 @@ +# Helmcode's plan usage, in its usage panel. + +## Setup: how to find each value the provider reads. Setting names, +## environment variables, URLs, cookie and header names, and the +## service's own menu paths stay as written. + +usage-helmcode-setting-cookie = The dashboard session. Sign in to https://cloud.helmcode.com/dashboard (or https://cloud.nan.builders/dashboard for NaN Builders), open Developer Tools > Network, reload, and copy the Cookie request header of a request to cloud-api.helmcode.com (or cloud-api.nan.builders). Developer Tools > Application > Cookies lists the same cookies. Paste it as "name=value; name2=value2". +usage-helmcode-setting-tenant = Which dashboard a pasted cookie belongs to: "helmcode" (default) or "nanBuilders". diff --git a/crates/herdr-gpui/locales/en-US/usage-huggingface.ftl b/crates/herdr-gpui/locales/en-US/usage-huggingface.ftl new file mode 100644 index 000000000..426b382ff --- /dev/null +++ b/crates/herdr-gpui/locales/en-US/usage-huggingface.ftl @@ -0,0 +1,8 @@ +# Hugging Face's plan usage, in its usage panel. + +## Setup: how to find each value the provider reads. Setting names, +## environment variables, URLs, cookie and header names, and the +## service's own menu paths stay as written. + +usage-huggingface-setting-api-key = A Hugging Face access token from https://huggingface.co/settings/tokens. A classic read token works; a fine-grained token needs the Billing read permission. Not needed when `hf auth login` has saved a token on the host. +usage-huggingface-setting-cookie = Optional, for the prepaid credit balance. Sign in at https://huggingface.co/settings/billing with the token's account, open Developer Tools > Application > Cookies > https://huggingface.co, and copy the token cookie as "token=value" (or every cookie there as "name=value; name2=value2"). diff --git a/crates/herdr-gpui/locales/en-US/usage-hyper.ftl b/crates/herdr-gpui/locales/en-US/usage-hyper.ftl new file mode 100644 index 000000000..bd7a50ee6 --- /dev/null +++ b/crates/herdr-gpui/locales/en-US/usage-hyper.ftl @@ -0,0 +1,8 @@ +# Charm Hyper's plan usage, in its usage panel. + +## Setup: how to find each value the provider reads. Setting names, +## environment variables, URLs, cookie and header names, and the +## service's own menu paths stay as written. + +usage-hyper-setting-api-key = A Charm Hyper API key from https://hyper.charm.land. Used when no browser session is available or the session has expired. +usage-hyper-setting-cookie = Sign in at https://hyper.charm.land, open Developer Tools > Application > Cookies > https://hyper.charm.land, and copy every cookie there as one "name=value; name2=value2" header. diff --git a/crates/herdr-gpui/locales/en-US/usage-ibmbob.ftl b/crates/herdr-gpui/locales/en-US/usage-ibmbob.ftl new file mode 100644 index 000000000..c9588654d --- /dev/null +++ b/crates/herdr-gpui/locales/en-US/usage-ibmbob.ftl @@ -0,0 +1,8 @@ +# IBM Bob's plan usage, in its usage panel. + +## Setup: how to find each value the provider reads. Setting names, +## environment variables, URLs, cookie and header names, and the +## service's own menu paths stay as written. + +usage-ibmbob-setting-api-key = An IBM Bob API key that can read subscription usage, created at https://bob.ibm.com (the same key Bob Shell uses as BOBSHELL_API_KEY). +usage-ibmbob-setting-token = Instead of an API key, an IBM Cloud bearer token (a JWT) for your Bob account. It is sent as "Authorization: Bearer ". diff --git a/crates/herdr-gpui/locales/en-US/usage-jetbrains.ftl b/crates/herdr-gpui/locales/en-US/usage-jetbrains.ftl new file mode 100644 index 000000000..1e791c41a --- /dev/null +++ b/crates/herdr-gpui/locales/en-US/usage-jetbrains.ftl @@ -0,0 +1,7 @@ +# JetBrains AI's plan usage, in its usage panel. + +## Setup: how to find each value the provider reads. Setting names, +## environment variables, URLs, cookie and header names, and the +## service's own menu paths stay as written. + +usage-jetbrains-setting-ide-path = The config directory of the IDE to read, when the most recently used one is not the right one, e.g. "~/Library/Application Support/JetBrains/IntelliJIdea2025.3" on a Mac or "~/.config/JetBrains/PyCharm2025.3" on Linux. Its options/AIAssistantQuotaManager2.xml appears once AI Assistant has been used. On the Mac running Herdr the IDEs are only looked for when this is set or jetbrains is in show_providers, as macOS may ask before Herdr reads another app's data. diff --git a/crates/herdr-gpui/locales/en-US/usage-kilo.ftl b/crates/herdr-gpui/locales/en-US/usage-kilo.ftl new file mode 100644 index 000000000..de8214791 --- /dev/null +++ b/crates/herdr-gpui/locales/en-US/usage-kilo.ftl @@ -0,0 +1,7 @@ +# Kilo's plan usage, in its usage panel. + +## Setup: how to find each value the provider reads. Setting names, +## environment variables, URLs, cookie and header names, and the +## service's own menu paths stay as written. + +usage-kilo-setting-api-key = A Kilo API key from https://app.kilo.ai (Profile → API keys). Without one, the Kilo CLI's sign-in in ~/.local/share/kilo/auth.json is used; run `kilo auth login` to create it. diff --git a/crates/herdr-gpui/locales/en-US/usage-kimi.ftl b/crates/herdr-gpui/locales/en-US/usage-kimi.ftl new file mode 100644 index 000000000..7bb1e60de --- /dev/null +++ b/crates/herdr-gpui/locales/en-US/usage-kimi.ftl @@ -0,0 +1,11 @@ +# Kimi Code's plan usage, in its usage panel. + +## Setup: how to find each value the provider reads. Setting names, +## environment variables, URLs, cookie and header names, and the +## service's own menu paths stay as written. + +usage-kimi-setting-api-key = A Kimi Code API key from the Kimi Code console (https://www.kimi.com/code/console, or https://www.kimi.ai/code/console for International). Not needed when the Kimi Code CLI is signed in on the host. +usage-kimi-setting-region = Which Kimi service the credentials belong to: "china" (the default, kimi.com) or "international" (kimi.ai). Moonshot Open Platform keys belong to the moonshot provider instead. +usage-kimi-setting-base-url = An https Kimi Code API base to use with the API key instead of https://api.kimi.com, for a compatible proxy. Setting it stops the CLI's sign-in from being used. +usage-kimi-setting-token = Your Kimi web session, for the membership pool and plan, or on its own. Sign in at https://www.kimi.com/code/console, open Developer Tools → Application → Cookies → https://www.kimi.com, and copy the value of the `kimi-auth` cookie (a JWT starting with eyJ), without its name. On kimi.ai, copy `access_token` from Local Storage instead. Not needed when Kimi is listed in show_providers and you are signed in with Chrome or Safari. +usage-kimi-setting-cookie = Instead of token, a whole Kimi Cookie header that includes kimi-auth, as "kimi-auth=value; …". diff --git a/crates/herdr-gpui/locales/en-US/usage-litellm.ftl b/crates/herdr-gpui/locales/en-US/usage-litellm.ftl new file mode 100644 index 000000000..f236a35b2 --- /dev/null +++ b/crates/herdr-gpui/locales/en-US/usage-litellm.ftl @@ -0,0 +1,8 @@ +# LiteLLM's plan usage, in its usage panel. + +## Setup: how to find each value the provider reads. Setting names, +## environment variables, URLs, cookie and header names, and the +## service's own menu paths stay as written. + +usage-litellm-setting-api-key = A LiteLLM virtual key (sk-…) issued by your LiteLLM proxy, e.g. from its admin UI under Virtual Keys. A master key is not needed. +usage-litellm-setting-base-url = The LiteLLM proxy's URL, with or without /v1, e.g. https://litellm.example.com. It must be HTTPS unless it is on localhost, a private network, or a .local host. diff --git a/crates/herdr-gpui/locales/en-US/usage-llmman.ftl b/crates/herdr-gpui/locales/en-US/usage-llmman.ftl new file mode 100644 index 000000000..4f187ae2e --- /dev/null +++ b/crates/herdr-gpui/locales/en-US/usage-llmman.ftl @@ -0,0 +1,8 @@ +# llmman's plan usage, in its usage panel. + +## Setup: how to find each value the provider reads. Setting names, +## environment variables, URLs, cookie and header names, and the +## service's own menu paths stay as written. + +usage-llmman-setting-base-url = The llmman serve address, when not http://127.0.0.1:17434. A host without a scheme uses HTTP on port 17434, e.g. localhost:18000. Plain HTTP is allowed only for loopback, private-network, and .local hosts; others need HTTPS. Without it, LLMMAN_HOST on the probed host is used when set. +usage-llmman-setting-api-key = Only when the daemon was started with LLMMAN_API_KEYS: one of those keys. diff --git a/crates/herdr-gpui/locales/en-US/usage-llmproxy.ftl b/crates/herdr-gpui/locales/en-US/usage-llmproxy.ftl new file mode 100644 index 000000000..a76fe638a --- /dev/null +++ b/crates/herdr-gpui/locales/en-US/usage-llmproxy.ftl @@ -0,0 +1,8 @@ +# LLM Proxy's plan usage, in its usage panel. + +## Setup: how to find each value the provider reads. Setting names, +## environment variables, URLs, cookie and header names, and the +## service's own menu paths stay as written. + +usage-llmproxy-setting-api-key = The API key your LLM-API-Key-Proxy accepts (its PROXY_API_KEY), sent as a bearer token to base_url. +usage-llmproxy-setting-base-url = The proxy's URL, with or without /v1, e.g. https://proxy.example.com. It must be HTTPS unless it is on localhost, a private network, or a .local host. diff --git a/crates/herdr-gpui/locales/en-US/usage-longcat.ftl b/crates/herdr-gpui/locales/en-US/usage-longcat.ftl new file mode 100644 index 000000000..9df4b6582 --- /dev/null +++ b/crates/herdr-gpui/locales/en-US/usage-longcat.ftl @@ -0,0 +1,7 @@ +# LongCat's plan usage, in its usage panel. + +## Setup: how to find each value the provider reads. Setting names, +## environment variables, URLs, cookie and header names, and the +## service's own menu paths stay as written. + +usage-longcat-setting-cookie = The LongCat console session. Sign in at https://longcat.chat/platform/usage, open Developer Tools > Application > Cookies > https://longcat.chat, and copy every cookie of the site (the console does not document which one holds the session). Paste them as "name=value; name2=value2", or copy the Cookie request header of a request to longcat.chat from the Network tab. diff --git a/crates/herdr-gpui/locales/en-US/usage-manus.ftl b/crates/herdr-gpui/locales/en-US/usage-manus.ftl new file mode 100644 index 000000000..c8eeaff5c --- /dev/null +++ b/crates/herdr-gpui/locales/en-US/usage-manus.ftl @@ -0,0 +1,8 @@ +# Manus's plan usage, in its usage panel. + +## Setup: how to find each value the provider reads. Setting names, +## environment variables, URLs, cookie and header names, and the +## service's own menu paths stay as written. + +usage-manus-setting-session-token = Sign in to https://manus.im, open Developer Tools > Application > Cookies > https://manus.im, and copy the value of the session_id cookie (the value only, without "session_id="). Not needed when Manus is listed in show_providers and you are signed in with Chrome or Safari. +usage-manus-setting-cookie = Instead of session_token, a whole manus.im Cookie header that includes session_id, as "session_id=value; …". diff --git a/crates/herdr-gpui/locales/en-US/usage-mimo.ftl b/crates/herdr-gpui/locales/en-US/usage-mimo.ftl new file mode 100644 index 000000000..8f0c30132 --- /dev/null +++ b/crates/herdr-gpui/locales/en-US/usage-mimo.ftl @@ -0,0 +1,8 @@ +# Xiaomi MiMo's plan usage, in its usage panel. + +## Setup: how to find each value the provider reads. Setting names, +## environment variables, URLs, cookie and header names, and the +## service's own menu paths stay as written. + +usage-mimo-setting-cookie = The MiMo console session. Sign in at https://platform.xiaomimimo.com/#/console/balance, open Developer Tools → Application → Cookies → https://platform.xiaomimimo.com, copy the api-platform_serviceToken and userId cookies (and api-platform_ph and api-platform_slh when present), and paste them as "api-platform_serviceToken=value; userId=value". +usage-mimo-setting-base-url = Optional HTTPS API URL. Defaults to https://platform.xiaomimimo.com/api/v1. diff --git a/crates/herdr-gpui/locales/en-US/usage-minimax.ftl b/crates/herdr-gpui/locales/en-US/usage-minimax.ftl new file mode 100644 index 000000000..338b83e81 --- /dev/null +++ b/crates/herdr-gpui/locales/en-US/usage-minimax.ftl @@ -0,0 +1,9 @@ +# MiniMax's plan usage, in its usage panel. + +## Setup: how to find each value the provider reads. Setting names, +## environment variables, URLs, cookie and header names, and the +## service's own menu paths stay as written. + +usage-minimax-setting-api-key = A MiniMax Coding Plan or Token Plan API key (sk-cp-…) from https://platform.minimax.io/user-center/basic-information/interface-key. +usage-minimax-setting-cookie = Used when no API key is set. Sign in to https://platform.minimax.io, open Developer Tools > Application > Cookies > https://platform.minimax.io, and copy every cookie (including HERTZ-SESSION) as "name=value; name2=value2". +usage-minimax-setting-region = "global" for minimax.io (the default) or "cn" for the China mainland platform at minimaxi.com. diff --git a/crates/herdr-gpui/locales/en-US/usage-mistral.ftl b/crates/herdr-gpui/locales/en-US/usage-mistral.ftl new file mode 100644 index 000000000..e3ea16df1 --- /dev/null +++ b/crates/herdr-gpui/locales/en-US/usage-mistral.ftl @@ -0,0 +1,7 @@ +# Mistral's plan usage, in its usage panel. + +## Setup: how to find each value the provider reads. Setting names, +## environment variables, URLs, cookie and header names, and the +## service's own menu paths stay as written. + +usage-mistral-setting-cookie = Sign in to https://admin.mistral.ai/organization/usage, open Developer Tools > Application > Cookies for https://admin.mistral.ai, and copy the ory_session_… cookie (its name ends in a per-project suffix) and csrftoken. Paste them as "name=value; name2=value2"; the Cookie header of any admin.mistral.ai request also works. diff --git a/crates/herdr-gpui/locales/en-US/usage-moonshot.ftl b/crates/herdr-gpui/locales/en-US/usage-moonshot.ftl new file mode 100644 index 000000000..32fc9cfb0 --- /dev/null +++ b/crates/herdr-gpui/locales/en-US/usage-moonshot.ftl @@ -0,0 +1,8 @@ +# Moonshot / Kimi Open Platform's plan usage, in its usage panel. + +## Setup: how to find each value the provider reads. Setting names, +## environment variables, URLs, cookie and header names, and the +## service's own menu paths stay as written. + +usage-moonshot-setting-api-key = A Moonshot / Kimi Open Platform API key, from https://platform.moonshot.ai/console/api-keys (International) or https://platform.moonshot.cn/console/api-keys (China mainland). Use a key issued for the region set below. +usage-moonshot-setting-region = Which platform issued the key: "international" (the default, api.moonshot.ai, balances in USD) or "china" (api.moonshot.cn, balances in CNY). diff --git a/crates/herdr-gpui/locales/en-US/usage-muse.ftl b/crates/herdr-gpui/locales/en-US/usage-muse.ftl new file mode 100644 index 000000000..1a4716c55 --- /dev/null +++ b/crates/herdr-gpui/locales/en-US/usage-muse.ftl @@ -0,0 +1,7 @@ +# Muse Code's plan usage, in its usage panel. + +## Setup: how to find each value the provider reads. Setting names, +## environment variables, URLs, cookie and header names, and the +## service's own menu paths stay as written. + +usage-muse-setting-token = A Muse CLI device-code access token (it starts with dca:), used when this host has no `muse login`. It is providers.meta.access_token in ~/.config/muse/auth.json on a machine where you ran `muse login`. On a Mac where `muse login` kept it in the keychain instead, list muse in show_providers and press Allow in its usage panel rather than setting this. Dashboard LLM_ keys and LLM| inference keys are not accepted. diff --git a/crates/herdr-gpui/locales/en-US/usage-neuralwatt.ftl b/crates/herdr-gpui/locales/en-US/usage-neuralwatt.ftl new file mode 100644 index 000000000..26de7be1b --- /dev/null +++ b/crates/herdr-gpui/locales/en-US/usage-neuralwatt.ftl @@ -0,0 +1,8 @@ +# Neuralwatt's plan usage, in its usage panel. + +## Setup: how to find each value the provider reads. Setting names, +## environment variables, URLs, cookie and header names, and the +## service's own menu paths stay as written. + +usage-neuralwatt-setting-api-key = A Neuralwatt API key, created or copied at https://portal.neuralwatt.com/dashboard. +usage-neuralwatt-setting-base-url = Optional HTTPS API URL for a proxy. Defaults to https://api.neuralwatt.com. diff --git a/crates/herdr-gpui/locales/en-US/usage-notion.ftl b/crates/herdr-gpui/locales/en-US/usage-notion.ftl new file mode 100644 index 000000000..f7f8ef30e --- /dev/null +++ b/crates/herdr-gpui/locales/en-US/usage-notion.ftl @@ -0,0 +1,8 @@ +# Notion AI's plan usage, in its usage panel. + +## Setup: how to find each value the provider reads. Setting names, +## environment variables, URLs, cookie and header names, and the +## service's own menu paths stay as written. + +usage-notion-setting-cookie = Your Notion session cookie. Sign in at https://app.notion.com, open Developer Tools > Application > Cookies for https://app.notion.com (or notion.so), copy the token_v2 cookie and paste it as "token_v2=value" (add "; notion_user_id=value" for accounts signed in to several users). +usage-notion-setting-workspace-id = The workspace (space) ID to read, dashed or not, for accounts in several workspaces. Defaults to the first Business or Enterprise workspace. It is the x-notion-space-id header of requests in Developer Tools > Network. diff --git a/crates/herdr-gpui/locales/en-US/usage-nous.ftl b/crates/herdr-gpui/locales/en-US/usage-nous.ftl new file mode 100644 index 000000000..604678f4c --- /dev/null +++ b/crates/herdr-gpui/locales/en-US/usage-nous.ftl @@ -0,0 +1,8 @@ +# Nous Portal's plan usage, in its usage panel. + +## Setup: how to find each value the provider reads. Setting names, +## environment variables, URLs, cookie and header names, and the +## service's own menu paths stay as written. + +usage-nous-setting-token = A Nous Portal OAuth access token, used instead of Hermes Agent's login in ~/.hermes/auth.json (sign in with `hermes auth add nous`). It lasts about an hour; portal API keys are not accepted. +usage-nous-setting-base-url = An HTTPS portal origin such as https://portal.nousresearch.com, for a preview deployment. Plain HTTP is refused. diff --git a/crates/herdr-gpui/locales/en-US/usage-ollama.ftl b/crates/herdr-gpui/locales/en-US/usage-ollama.ftl new file mode 100644 index 000000000..ac8114ca5 --- /dev/null +++ b/crates/herdr-gpui/locales/en-US/usage-ollama.ftl @@ -0,0 +1,8 @@ +# Ollama's plan usage, in its usage panel. + +## Setup: how to find each value the provider reads. Setting names, +## environment variables, URLs, cookie and header names, and the +## service's own menu paths stay as written. + +usage-ollama-setting-cookie = The ollama.com browser session, needed for quota. Sign in at https://ollama.com/signin, open Developer Tools → Application → Cookies → https://ollama.com, copy the session cookie (wos-session, or __Secure-session on older sign-ins), and paste it as "name=value". +usage-ollama-setting-api-key = An Ollama API key from https://ollama.com/settings/keys. It only verifies access: Ollama shows quota on its website, not through the API. diff --git a/crates/herdr-gpui/locales/en-US/usage-openai.ftl b/crates/herdr-gpui/locales/en-US/usage-openai.ftl new file mode 100644 index 000000000..d41ee734f --- /dev/null +++ b/crates/herdr-gpui/locales/en-US/usage-openai.ftl @@ -0,0 +1,10 @@ +# OpenAI's plan usage, in its usage panel. + +## Setup: how to find each value the provider reads. Setting names, +## environment variables, URLs, cookie and header names, and the +## service's own menu paths stay as written. + +usage-openai-setting-api-key = An organization Admin API key (sk-admin-...), created by an organization owner at https://platform.openai.com/settings/organization/admin-keys. Project and service-account keys cannot read organization usage. +usage-openai-setting-project-id = Optional. A project ID (proj_...) from https://platform.openai.com/settings/organization/projects to limit spend and usage to that project. +usage-openai-setting-history-days = Optional. How many days of spend to total, 1 to 365. Defaults to 30. +usage-openai-setting-balance-fallback = Optional. Set to 1 to show the legacy prepaid credit balance when the key cannot read organization usage (older user API keys). Ignored with project_id. diff --git a/crates/herdr-gpui/locales/en-US/usage-opencode.ftl b/crates/herdr-gpui/locales/en-US/usage-opencode.ftl new file mode 100644 index 000000000..8b077858a --- /dev/null +++ b/crates/herdr-gpui/locales/en-US/usage-opencode.ftl @@ -0,0 +1,8 @@ +# OpenCode's plan usage, in its usage panel. + +## Setup: how to find each value the provider reads. Setting names, +## environment variables, URLs, cookie and header names, and the +## service's own menu paths stay as written. + +usage-opencode-setting-cookie = Your opencode.ai session. Sign in at https://opencode.ai/auth, open Developer Tools > Application > Cookies > https://opencode.ai, and copy the auth and __Host-console_session cookies (either one alone also works). Paste them as "auth=value; __Host-console_session=value". +usage-opencode-setting-workspace-id = Optional. The workspace to read, as a wrk_... ID or the https://opencode.ai/workspace/... URL of its dashboard. Defaults to the first workspace of the account. diff --git a/crates/herdr-gpui/locales/en-US/usage-opencodego.ftl b/crates/herdr-gpui/locales/en-US/usage-opencodego.ftl new file mode 100644 index 000000000..992dbaac3 --- /dev/null +++ b/crates/herdr-gpui/locales/en-US/usage-opencodego.ftl @@ -0,0 +1,9 @@ +# OpenCode Go's plan usage, in its usage panel. + +## Setup: how to find each value the provider reads. Setting names, +## environment variables, URLs, cookie and header names, and the +## service's own menu paths stay as written. + +usage-opencodego-setting-api-key = An OpenCode API key for the account with the Go subscription, created at https://opencode.ai/auth under API Keys. Preferred over the cookie. When unset, the key `opencode auth login` saved in ~/.local/share/opencode/auth.json is used. +usage-opencodego-setting-cookie = Your opencode.ai session, used when no API key is set. Sign in at https://opencode.ai/auth, open Developer Tools > Application > Cookies > https://opencode.ai, and copy the __Host-console_session and auth cookies (either one alone also works). Paste them as "__Host-console_session=value; auth=value". +usage-opencodego-setting-workspace-id = Optional, for the cookie sign-in. The workspace to read, as a wrk_... or org_... ID or its https://opencode.ai/console/... URL. Defaults to the first workspace of the account. diff --git a/crates/herdr-gpui/locales/en-US/usage-openrouter.ftl b/crates/herdr-gpui/locales/en-US/usage-openrouter.ftl new file mode 100644 index 000000000..c19d64ce8 --- /dev/null +++ b/crates/herdr-gpui/locales/en-US/usage-openrouter.ftl @@ -0,0 +1,11 @@ +# OpenRouter's plan usage, in its usage panel. + +## Setup: how to find each value the provider reads. Setting names, +## environment variables, URLs, cookie and header names, and the +## service's own menu paths stay as written. + +usage-openrouter-setting-api-key = An OpenRouter API key (sk-or-v1-…) from https://openrouter.ai/settings/keys. A Management API key also works and adds the last 30 days of activity. +usage-openrouter-setting-management-api-key = Optional Management API key from https://openrouter.ai/settings/management-keys, used only for the last 30 days of account activity. +usage-openrouter-setting-base-url = Optional HTTPS API URL for a proxy. Defaults to https://openrouter.ai/api/v1. +usage-openrouter-setting-http-referer = Optional client URL sent as the HTTP-Referer header. +usage-openrouter-setting-x-title = Optional client title sent as the X-Title header. Defaults to Herdr. diff --git a/crates/herdr-gpui/locales/en-US/usage-perplexity.ftl b/crates/herdr-gpui/locales/en-US/usage-perplexity.ftl new file mode 100644 index 000000000..4c24daa1d --- /dev/null +++ b/crates/herdr-gpui/locales/en-US/usage-perplexity.ftl @@ -0,0 +1,8 @@ +# Perplexity's plan usage, in its usage panel. + +## Setup: how to find each value the provider reads. Setting names, +## environment variables, URLs, cookie and header names, and the +## service's own menu paths stay as written. + +usage-perplexity-setting-cookie = The perplexity.ai web session. Sign in at https://www.perplexity.ai, open Developer Tools → Application → Cookies → https://www.perplexity.ai, copy the session cookie (__Secure-next-auth.session-token, or __Secure-authjs.session-token on newer sign-ins), and paste it as "name=value". +usage-perplexity-setting-session-token = Alternatively, only the value of that session cookie; each session cookie name is tried in turn. diff --git a/crates/herdr-gpui/locales/en-US/usage-poe.ftl b/crates/herdr-gpui/locales/en-US/usage-poe.ftl new file mode 100644 index 000000000..ef29ec062 --- /dev/null +++ b/crates/herdr-gpui/locales/en-US/usage-poe.ftl @@ -0,0 +1,7 @@ +# Poe's plan usage, in its usage panel. + +## Setup: how to find each value the provider reads. Setting names, +## environment variables, URLs, cookie and header names, and the +## service's own menu paths stay as written. + +usage-poe-setting-api-key = A Poe API key, created or copied at https://poe.com/api/keys. diff --git a/crates/herdr-gpui/locales/en-US/usage-qoder.ftl b/crates/herdr-gpui/locales/en-US/usage-qoder.ftl new file mode 100644 index 000000000..23e40e4a8 --- /dev/null +++ b/crates/herdr-gpui/locales/en-US/usage-qoder.ftl @@ -0,0 +1,8 @@ +# Qoder's plan usage, in its usage panel. + +## Setup: how to find each value the provider reads. Setting names, +## environment variables, URLs, cookie and header names, and the +## service's own menu paths stay as written. + +usage-qoder-setting-cookie = Your Qoder browser session. Sign in at https://qoder.com/account/usage (or https://qoder.com.cn/account/usage), open Developer Tools > Application > Cookies for qoder.com (or qoder.com.cn), and copy every cookie of the site as one header: "name=value; name2=value2". +usage-qoder-setting-site = Which Qoder site the account belongs to: "global" for qoder.com (the default) or "china" for qoder.com.cn. diff --git a/crates/herdr-gpui/locales/en-US/usage-qwencloud.ftl b/crates/herdr-gpui/locales/en-US/usage-qwencloud.ftl new file mode 100644 index 000000000..656a45df6 --- /dev/null +++ b/crates/herdr-gpui/locales/en-US/usage-qwencloud.ftl @@ -0,0 +1,8 @@ +# Qwen Cloud's plan usage, in its usage panel. + +## Setup: how to find each value the provider reads. Setting names, +## environment variables, URLs, cookie and header names, and the +## service's own menu paths stay as written. + +usage-qwencloud-setting-cookie = Your Qwen Cloud console session. Sign in at https://home.qwencloud.com/billing/subscription/token-plan-individual, open Developer Tools > Application > Cookies > https://home.qwencloud.com, and copy the login ticket (login_qwencloud_ticket or login_aliyunid_ticket) together with login_aliyunid_csrf and cna. Paste them as "name=value; name2=value2", or copy the whole Cookie header of the data/api.json request from the Network tab. +usage-qwencloud-setting-sec-token = Optional. The console's sec_token, when it cannot be read from /tool/user/info.json: in Developer Tools > Network, the sec_token form field of the data/api.json request on the Token Plan page. diff --git a/crates/herdr-gpui/locales/en-US/usage-raycast.ftl b/crates/herdr-gpui/locales/en-US/usage-raycast.ftl new file mode 100644 index 000000000..37bd5d38e --- /dev/null +++ b/crates/herdr-gpui/locales/en-US/usage-raycast.ftl @@ -0,0 +1,7 @@ +# Raycast's plan usage, in its usage panel. + +## Setup: how to find each value the provider reads. Setting names, +## environment variables, URLs, cookie and header names, and the +## service's own menu paths stay as written. + +usage-raycast-setting-cookie = Sign in at https://www.raycast.com/settings, open Developer Tools > Application > Cookies > https://www.raycast.com, and copy __raycast_session (and csrf_token when present) as one "__raycast_session=value; csrf_token=value" header. diff --git a/crates/herdr-gpui/locales/en-US/usage-replicate.ftl b/crates/herdr-gpui/locales/en-US/usage-replicate.ftl new file mode 100644 index 000000000..eeda251a0 --- /dev/null +++ b/crates/herdr-gpui/locales/en-US/usage-replicate.ftl @@ -0,0 +1,7 @@ +# Replicate's plan usage, in its usage panel. + +## Setup: how to find each value the provider reads. Setting names, +## environment variables, URLs, cookie and header names, and the +## service's own menu paths stay as written. + +usage-replicate-setting-cookie = Sign in at https://replicate.com/account/billing, open Developer Tools > Application > Cookies > https://replicate.com, and copy sessionid (and csrftoken when present) as one "sessionid=value; csrftoken=value" header. An API token does not work here. diff --git a/crates/herdr-gpui/locales/en-US/usage-sakana.ftl b/crates/herdr-gpui/locales/en-US/usage-sakana.ftl new file mode 100644 index 000000000..cccc70e1a --- /dev/null +++ b/crates/herdr-gpui/locales/en-US/usage-sakana.ftl @@ -0,0 +1,7 @@ +# Sakana AI's plan usage, in its usage panel. + +## Setup: how to find each value the provider reads. Setting names, +## environment variables, URLs, cookie and header names, and the +## service's own menu paths stay as written. + +usage-sakana-setting-cookie = Sign in to https://console.sakana.ai, open Developer Tools > Application > Cookies for https://console.sakana.ai, and copy every cookie there (the sign-in session cookies are required). Paste them as "name=value; name2=value2"; the Cookie header of a request to console.sakana.ai/billing in the Network tab also works. diff --git a/crates/herdr-gpui/locales/en-US/usage-stepfun.ftl b/crates/herdr-gpui/locales/en-US/usage-stepfun.ftl new file mode 100644 index 000000000..a546b3023 --- /dev/null +++ b/crates/herdr-gpui/locales/en-US/usage-stepfun.ftl @@ -0,0 +1,9 @@ +# StepFun's plan usage, in its usage panel. + +## Setup: how to find each value the provider reads. Setting names, +## environment variables, URLs, cookie and header names, and the +## service's own menu paths stay as written. + +usage-stepfun-setting-token = Your StepFun Oasis-Token. Sign in at https://platform.stepfun.com, open Developer Tools > Application > Cookies for platform.stepfun.com, and copy the value of the Oasis-Token cookie. Set webid too. +usage-stepfun-setting-webid = The value of the Oasis-Webid cookie next to Oasis-Token on platform.stepfun.com. StepFun rejects a token sent with another device's web id. +usage-stepfun-setting-cookie = Instead of token and webid: sign in at https://platform.stepfun.com, open Developer Tools > Application > Cookies for platform.stepfun.com, and copy the Oasis-Token and Oasis-Webid cookies as "Oasis-Token=…; Oasis-Webid=…". diff --git a/crates/herdr-gpui/locales/en-US/usage-sub2api.ftl b/crates/herdr-gpui/locales/en-US/usage-sub2api.ftl new file mode 100644 index 000000000..6bf799cae --- /dev/null +++ b/crates/herdr-gpui/locales/en-US/usage-sub2api.ftl @@ -0,0 +1,8 @@ +# sub2api's plan usage, in its usage panel. + +## Setup: how to find each value the provider reads. Setting names, +## environment variables, URLs, cookie and header names, and the +## service's own menu paths stay as written. + +usage-sub2api-setting-api-key = A sub2api group API key (sk-…), created in your sub2api deployment's dashboard under API keys. Usage is scoped to that key's group. +usage-sub2api-setting-base-url = Your sub2api deployment, e.g. https://sub2api.example.com. HTTPS is required, except plain HTTP on a loopback address such as http://127.0.0.1:8080. diff --git a/crates/herdr-gpui/locales/en-US/usage-synthetic.ftl b/crates/herdr-gpui/locales/en-US/usage-synthetic.ftl new file mode 100644 index 000000000..9d302ba47 --- /dev/null +++ b/crates/herdr-gpui/locales/en-US/usage-synthetic.ftl @@ -0,0 +1,7 @@ +# Synthetic's plan usage, in its usage panel. + +## Setup: how to find each value the provider reads. Setting names, +## environment variables, URLs, cookie and header names, and the +## service's own menu paths stay as written. + +usage-synthetic-setting-api-key = A Synthetic API key, created as described in https://dev.synthetic.new/docs/api/getting-started. diff --git a/crates/herdr-gpui/locales/en-US/usage-t3chat.ftl b/crates/herdr-gpui/locales/en-US/usage-t3chat.ftl new file mode 100644 index 000000000..50362e8d7 --- /dev/null +++ b/crates/herdr-gpui/locales/en-US/usage-t3chat.ftl @@ -0,0 +1,7 @@ +# T3 Chat's plan usage, in its usage panel. + +## Setup: how to find each value the provider reads. Setting names, +## environment variables, URLs, cookie and header names, and the +## service's own menu paths stay as written. + +usage-t3chat-setting-cookie = Your t3.chat browser session. Sign in at https://t3.chat, open Developer Tools → Application → Cookies → https://t3.chat, and copy every cookie there (the session cookies change names between releases), pasted as one header: "name=value; name2=value2". It is sent only to t3.chat. diff --git a/crates/herdr-gpui/locales/en-US/usage-typesafe.ftl b/crates/herdr-gpui/locales/en-US/usage-typesafe.ftl new file mode 100644 index 000000000..4db33fb9a --- /dev/null +++ b/crates/herdr-gpui/locales/en-US/usage-typesafe.ftl @@ -0,0 +1,7 @@ +# TypeSafe's plan usage, in its usage panel. + +## Setup: how to find each value the provider reads. Setting names, +## environment variables, URLs, cookie and header names, and the +## service's own menu paths stay as written. + +usage-typesafe-setting-cookie = Sign in at https://console.typesafe.ai/settings/billing, open Developer Tools > Application > Cookies > https://console.typesafe.ai, and copy every cookie there as one "name=value; name2=value2" header. An inference API key does not work here. diff --git a/crates/herdr-gpui/locales/en-US/usage-v0.ftl b/crates/herdr-gpui/locales/en-US/usage-v0.ftl new file mode 100644 index 000000000..ab073a7ac --- /dev/null +++ b/crates/herdr-gpui/locales/en-US/usage-v0.ftl @@ -0,0 +1,8 @@ +# v0's plan usage, in its usage panel. + +## Setup: how to find each value the provider reads. Setting names, +## environment variables, URLs, cookie and header names, and the +## service's own menu paths stay as written. + +usage-v0-setting-api-key = A v0 Platform API key from https://v0.app/settings/keys. +usage-v0-setting-scope = Optional project ID or slug to read that scope's billing and rate limit. diff --git a/crates/herdr-gpui/locales/en-US/usage-venice.ftl b/crates/herdr-gpui/locales/en-US/usage-venice.ftl new file mode 100644 index 000000000..a8a6c1d86 --- /dev/null +++ b/crates/herdr-gpui/locales/en-US/usage-venice.ftl @@ -0,0 +1,8 @@ +# Venice's plan usage, in its usage panel. + +## Setup: how to find each value the provider reads. Setting names, +## environment variables, URLs, cookie and header names, and the +## service's own menu paths stay as written. + +usage-venice-setting-api-key = A Venice API key from https://venice.ai/settings/api. It reads the USD and DIEM API balance. +usage-venice-setting-cookie = Used when no API key is set, for subscription credits. Sign in to https://venice.ai, open Developer Tools > Application > Cookies for https://venice.ai, and copy the __venice-auth.session-token cookie (or every __venice-auth.session-token.N chunk when it is split). Paste them as "name=value; name2=value2". diff --git a/crates/herdr-gpui/locales/en-US/usage-vercel.ftl b/crates/herdr-gpui/locales/en-US/usage-vercel.ftl new file mode 100644 index 000000000..5d31c96a0 --- /dev/null +++ b/crates/herdr-gpui/locales/en-US/usage-vercel.ftl @@ -0,0 +1,7 @@ +# Vercel AI Gateway's plan usage, in its usage panel. + +## Setup: how to find each value the provider reads. Setting names, +## environment variables, URLs, cookie and header names, and the +## service's own menu paths stay as written. + +usage-vercel-setting-api-key = An AI Gateway API key from the Vercel dashboard: AI Gateway > API Keys. The balance is the key's team, so use a key of the team to show. diff --git a/crates/herdr-gpui/locales/en-US/usage-vertexai.ftl b/crates/herdr-gpui/locales/en-US/usage-vertexai.ftl new file mode 100644 index 000000000..ec3f825fa --- /dev/null +++ b/crates/herdr-gpui/locales/en-US/usage-vertexai.ftl @@ -0,0 +1,7 @@ +# Vertex AI's plan usage, in its usage panel. + +## Setup: how to find each value the provider reads. Setting names, +## environment variables, URLs, cookie and header names, and the +## service's own menu paths stay as written. + +usage-vertexai-setting-project = The Google Cloud project id whose Vertex AI quotas to read, when gcloud has none set (`gcloud config set project PROJECT_ID`). Sign in with `gcloud auth application-default login`; the account needs Cloud Monitoring read access (roles/monitoring.viewer) on the project. diff --git a/crates/herdr-gpui/locales/en-US/usage-warp.ftl b/crates/herdr-gpui/locales/en-US/usage-warp.ftl new file mode 100644 index 000000000..77f4a9ce6 --- /dev/null +++ b/crates/herdr-gpui/locales/en-US/usage-warp.ftl @@ -0,0 +1,7 @@ +# Warp's plan usage, in its usage panel. + +## Setup: how to find each value the provider reads. Setting names, +## environment variables, URLs, cookie and header names, and the +## service's own menu paths stay as written. + +usage-warp-setting-api-key = A Warp API key (wk-…). In Warp open your profile menu, then Settings → Platform → API Keys, and create one. See https://docs.warp.dev/reference/cli/api-keys. diff --git a/crates/herdr-gpui/locales/en-US/usage-wayfinder.ftl b/crates/herdr-gpui/locales/en-US/usage-wayfinder.ftl new file mode 100644 index 000000000..2a63a59a5 --- /dev/null +++ b/crates/herdr-gpui/locales/en-US/usage-wayfinder.ftl @@ -0,0 +1,7 @@ +# Wayfinder's plan usage, in its usage panel. + +## Setup: how to find each value the provider reads. Setting names, +## environment variables, URLs, cookie and header names, and the +## service's own menu paths stay as written. + +usage-wayfinder-setting-base-url = The Wayfinder gateway, started with `wayfinder-router serve`. Defaults to http://127.0.0.1:8088; another URL must be HTTPS, or plain HTTP on a loopback address. diff --git a/crates/herdr-gpui/locales/en-US/usage-windsurf.ftl b/crates/herdr-gpui/locales/en-US/usage-windsurf.ftl new file mode 100644 index 000000000..3c78d2679 --- /dev/null +++ b/crates/herdr-gpui/locales/en-US/usage-windsurf.ftl @@ -0,0 +1,7 @@ +# Windsurf's plan usage, in its usage panel. + +## Setup: how to find each value the provider reads. Setting names, +## environment variables, URLs, cookie and header names, and the +## service's own menu paths stay as written. + +usage-windsurf-setting-session = The windsurf.com session bundle, for live quota. Sign in at https://windsurf.com/profile in Chrome, open Developer Tools → Application → Local Storage → https://windsurf.com (or https://app.devin.ai), and copy the values of devin_session_token, devin_auth1_token, devin_account_id, and devin_primary_org_id into one JSON object: {"{"}"devin_session_token": "…", "devin_auth1_token": "…", "devin_account_id": "…", "devin_primary_org_id": "…"{"}"}. Without it, the plan the Windsurf editor cached at its last launch is shown; on the Mac running Herdr only when windsurf is in show_providers, as macOS may ask before Herdr reads another app's data. diff --git a/crates/herdr-gpui/locales/en-US/usage-xai.ftl b/crates/herdr-gpui/locales/en-US/usage-xai.ftl new file mode 100644 index 000000000..e11cc0315 --- /dev/null +++ b/crates/herdr-gpui/locales/en-US/usage-xai.ftl @@ -0,0 +1,8 @@ +# xAI's plan usage, in its usage panel. + +## Setup: how to find each value the provider reads. Setting names, +## environment variables, URLs, cookie and header names, and the +## service's own menu paths stay as written. + +usage-xai-setting-api-key = A Management API key from https://console.x.ai under Settings > Management Keys, with billing read access. Inference API keys are not accepted. +usage-xai-setting-team-id = The xAI team ID to bill against, shown in the xAI Console URL and team settings. The Management key must belong to this team. diff --git a/crates/herdr-gpui/locales/en-US/usage-xkiro.ftl b/crates/herdr-gpui/locales/en-US/usage-xkiro.ftl new file mode 100644 index 000000000..e24d54211 --- /dev/null +++ b/crates/herdr-gpui/locales/en-US/usage-xkiro.ftl @@ -0,0 +1,7 @@ +# xKiro's plan usage, in its usage panel. + +## Setup: how to find each value the provider reads. Setting names, +## environment variables, URLs, cookie and header names, and the +## service's own menu paths stay as written. + +usage-xkiro-setting-api-key = An xKiro API key from your account at https://xkiro.com. It is sent only to api.xkiro.com; reading usage spends no tokens. diff --git a/crates/herdr-gpui/locales/en-US/usage-zai.ftl b/crates/herdr-gpui/locales/en-US/usage-zai.ftl new file mode 100644 index 000000000..2535aa697 --- /dev/null +++ b/crates/herdr-gpui/locales/en-US/usage-zai.ftl @@ -0,0 +1,14 @@ +# z.ai / GLM's plan usage, in its usage panel. + +## Setup: how to find each value the provider reads. Setting names, +## environment variables, URLs, cookie and header names, and the +## service's own menu paths stay as written. + +usage-zai-setting-api-key = A z.ai API key from https://z.ai/manage-apikey/apikey-list, or for BigModel China one from https://bigmodel.cn/usercenter/proj-mgmt/apikeys. +usage-zai-setting-region = "global" for api.z.ai (the default) or "bigmodel-cn" for open.bigmodel.cn, the China mainland GLM Coding Plan. +usage-zai-setting-api-host = An HTTPS host that replaces the region's API host, e.g. a proxy. +usage-zai-setting-quota-url = A full HTTPS URL that replaces the quota endpoint. +usage-zai-setting-balance-url = A full HTTPS URL that replaces the BigModel China balance endpoint. +usage-zai-setting-usage-scope = "personal" (the default) or "team" for BigModel team usage, which also needs organization and project. +usage-zai-setting-organization = For team usage: open https://bigmodel.cn/coding-plan/team/usage-stats, pick the team, and in Developer Tools > Network copy the Bigmodel-Organization request header of the quota/limit request. +usage-zai-setting-project = For team usage: the Bigmodel-Project request header of the same request. diff --git a/crates/herdr-gpui/locales/en-US/usage-zed.ftl b/crates/herdr-gpui/locales/en-US/usage-zed.ftl new file mode 100644 index 000000000..d99477ff4 --- /dev/null +++ b/crates/herdr-gpui/locales/en-US/usage-zed.ftl @@ -0,0 +1,7 @@ +# Zed's plan usage, in its usage panel. + +## Setup: how to find each value the provider reads. Setting names, +## environment variables, URLs, cookie and header names, and the +## service's own menu paths stay as written. + +usage-zed-setting-cookie = The zed.dev browser session, for token spend. Sign in at https://zed.dev in your browser (signing in inside the editor is not enough), open Developer Tools → Application → Cookies → https://zed.dev, copy the zed.session cookie, and paste it as "zed.session=value". diff --git a/crates/herdr-gpui/locales/en-US/usage-zenmux.ftl b/crates/herdr-gpui/locales/en-US/usage-zenmux.ftl new file mode 100644 index 000000000..9ede8508d --- /dev/null +++ b/crates/herdr-gpui/locales/en-US/usage-zenmux.ftl @@ -0,0 +1,7 @@ +# ZenMux's plan usage, in its usage panel. + +## Setup: how to find each value the provider reads. Setting names, +## environment variables, URLs, cookie and header names, and the +## service's own menu paths stay as written. + +usage-zenmux-setting-api-key = A Management API key created at https://zenmux.ai/platform/management. Normal inference API keys are rejected by the usage endpoints. diff --git a/crates/herdr-gpui/locales/en-US/usage-zoommate.ftl b/crates/herdr-gpui/locales/en-US/usage-zoommate.ftl new file mode 100644 index 000000000..eac3e4138 --- /dev/null +++ b/crates/herdr-gpui/locales/en-US/usage-zoommate.ftl @@ -0,0 +1,8 @@ +# ZoomMate's plan usage, in its usage panel. + +## Setup: how to find each value the provider reads. Setting names, +## environment variables, URLs, cookie and header names, and the +## service's own menu paths stay as written. + +usage-zoommate-setting-cookie = Your Zoom session cookies. Sign in at https://zoommate.zoom.us, open Developer Tools > Application > Cookies for https://zoommate.zoom.us (Zoom's sign-in cookies such as _zm_ssid, _zm_lang and cf_clearance live on zoom.us), and paste them all as "name=value; name2=value2". They are exchanged for a short-lived token on every refresh. +usage-zoommate-setting-token = A bearer token instead of cookies: on https://zoommate.zoom.us open Developer Tools > Network, reload the AI credit usage page, select the credits/status request and copy its Authorization header without "Bearer ". It expires after about an hour. diff --git a/crates/herdr-gpui/src/usage/panel.rs b/crates/herdr-gpui/src/usage/panel.rs index e09116824..d6b77b4a7 100644 --- a/crates/herdr-gpui/src/usage/panel.rs +++ b/crates/herdr-gpui/src/usage/panel.rs @@ -3,7 +3,11 @@ //! its service reports, and links to the service's own pages. use super::{Access, KeychainGrants, Reading, model::Provider, service::Service, text, ui::Ui}; -use crate::{i18n::fl, menu::Page, window::HerdrWindow}; +use crate::{ + i18n::{self, fl}, + menu::Page, + window::HerdrWindow, +}; use gpui::{prelude::*, *}; use std::time::{Instant, SystemTime}; @@ -383,7 +387,11 @@ fn setup(service: &dyn Service, ui: &Ui) -> AnyElement { .gap(px(2.)) .text_size(ui.small()) .child(key) - .child(div().text_color(ui.muted()).child(setting.help)) + .child( + div() + .text_color(ui.muted()) + .child(i18n::message(setting.message, &[])), + ) })) .into_any_element() } diff --git a/crates/herdr-gpui/src/usage/tests.rs b/crates/herdr-gpui/src/usage/tests.rs index dfdb0c76c..39b490828 100644 --- a/crates/herdr-gpui/src/usage/tests.rs +++ b/crates/herdr-gpui/src/usage/tests.rs @@ -7,6 +7,7 @@ use super::{ }; mod en_xa; +mod example_config; mod footer_fit; mod icons; mod keychain_grants; diff --git a/crates/herdr-gpui/src/usage/tests/en_xa.rs b/crates/herdr-gpui/src/usage/tests/en_xa.rs index 776c90f2f..8cdefbedd 100644 --- a/crates/herdr-gpui/src/usage/tests/en_xa.rs +++ b/crates/herdr-gpui/src/usage/tests/en_xa.rs @@ -135,8 +135,8 @@ fn readings() -> Vec { } /// What the services report and the user named: services, accounts, the -/// host, the named window, the balance's label, and each setting's key and -/// help, which `herdr-usage` writes. +/// host, the named window, the balance's label, and each setting's key, +/// which `herdr-usage` writes. fn data(readings: &[Reading], host: &str) -> Vec { let mut data: Vec = vec![ host.into(), @@ -155,7 +155,6 @@ fn data(readings: &[Reading], host: &str) -> Vec { data.push(meta.name.into()); data.push(reading.provider.name().into()); for setting in meta.settings { - data.push(setting.help.into()); data.push(format!("[usage.providers.{}] {}", meta.id, setting.name)); } } diff --git a/crates/herdr-gpui/src/usage/tests/example_config.rs b/crates/herdr-gpui/src/usage/tests/example_config.rs new file mode 100644 index 000000000..6852f217a --- /dev/null +++ b/crates/herdr-gpui/src/usage/tests/example_config.rs @@ -0,0 +1,103 @@ +//! The example config's usage provider section, generated from each +//! provider's settings and the English of their messages, and every +//! setting's message checked against `usage-.ftl`. +use super::*; +use crate::i18n; + +const PATH: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/config-gpui.example.toml"); +const START: &str = "# --- usage providers: generated from each provider's settings ---\n"; +const END: &str = "# --- end usage providers ---\n"; + +/// The provider section: what each provider reads and how to find it. +fn docs() -> String { + let mut docs = String::from(START); + let (configurable, automatic): (Vec<_>, Vec<_>) = + registry::all().partition(|provider| !provider.service().meta().settings.is_empty()); + docs.push_str("#\n# Found from the agent's own sign-in on the host, with nothing to set:\n"); + let ids: Vec<_> = automatic.iter().map(|provider| provider.id()).collect(); + for line in wrap(&ids.join(", "), 76) { + docs.push_str(&format!("# {line}\n")); + } + for provider in configurable { + let meta = provider.service().meta(); + docs.push_str(&format!( + "#\n# {} ({})\n# [usage.providers.{}]\n", + meta.name, meta.id, meta.id + )); + for setting in meta.settings { + for line in wrap(&i18n::english(setting.message, &[]), 76) { + docs.push_str(&format!("# {line}\n")); + } + let variables = if setting.env.is_empty() { + String::new() + } else { + format!(" # or {}", setting.env.join(", ")) + }; + docs.push_str(&format!("# {} = \"\"{variables}\n", setting.name)); + } + } + docs.push_str(END); + docs +} + +/// The generated section as it stands in `text`, markers included. +fn docs_in(text: &str) -> Option<&str> { + let start = text.find(START)?; + let end = text[start..].find(END)? + start + END.len(); + Some(&text[start..end]) +} + +fn wrap(text: &str, width: usize) -> Vec { + let mut lines = Vec::new(); + let mut line = String::new(); + for word in text.split_whitespace() { + if !line.is_empty() && line.len() + 1 + word.len() > width { + lines.push(std::mem::take(&mut line)); + } + if !line.is_empty() { + line.push(' '); + } + line.push_str(word); + } + if !line.is_empty() { + lines.push(line); + } + lines +} + +#[test] +fn every_setting_names_a_message_without_arguments() { + for provider in registry::all() { + for setting in provider.service().meta().settings { + assert_eq!( + i18n::takes(setting.message), + Some(&[][..]), + "{}.{}: {}", + provider.id(), + setting.name, + setting.message + ); + } + } +} + +/// Regenerate with `HERDR_BLESS_EXAMPLE=1 cargo test example_config_documents`. +#[test] +fn example_config_documents_every_provider_setting() { + // A Windows checkout may carry CRLF line endings. + let text = std::fs::read_to_string(PATH).unwrap().replace("\r\n", "\n"); + let expected = docs(); + if std::env::var_os("HERDR_BLESS_EXAMPLE").is_some() { + let updated = match docs_in(&text) { + Some(current) => text.replace(current, &expected), + None => format!("{}\n\n{expected}", text.trim_end_matches('\n')), + }; + std::fs::write(PATH, updated).unwrap(); + return; + } + assert_eq!( + docs_in(&text), + Some(expected.as_str()), + "config-gpui.example.toml is stale; rerun with HERDR_BLESS_EXAMPLE=1" + ); +} diff --git a/crates/herdr-usage/src/providers/abacus.rs b/crates/herdr-usage/src/providers/abacus.rs index 2cac65325..6fb8f3b4b 100644 --- a/crates/herdr-usage/src/providers/abacus.rs +++ b/crates/herdr-usage/src/providers/abacus.rs @@ -26,14 +26,7 @@ pub(crate) struct Abacus; static META: Meta = Meta::new("abacus", "Abacus AI") .dashboard("https://apps.abacus.ai/chatllm/admin/compute-points-usage") - .settings(&[Setting::new( - "cookie", - &[], - "Sign in to https://apps.abacus.ai, open Developer Tools > Application > Cookies \ - for https://apps.abacus.ai, and copy its session cookies (names containing \ - \"session\", \"auth\", or \"sid\"). Paste them as \"name=value; name2=value2\"; \ - the whole Cookie header of any apps.abacus.ai request also works.", - )]); + .settings(&[Setting::new("cookie", &[], "usage-abacus-setting-cookie")]); impl Service for Abacus { fn meta(&self) -> &'static Meta { diff --git a/crates/herdr-usage/src/providers/aiand.rs b/crates/herdr-usage/src/providers/aiand.rs index 7c1a96700..317609abc 100644 --- a/crates/herdr-usage/src/providers/aiand.rs +++ b/crates/herdr-usage/src/providers/aiand.rs @@ -28,8 +28,7 @@ static META: Meta = Meta::new("aiand", "ai&") .settings(&[Setting::new( "api_key", &["AIAND_API_KEY"], - "An ai& API key (starts with sk-), created at https://console.aiand.com under \ - Settings > API Keys > Create. Keys are shown only once.", + "usage-aiand-setting-api-key", )]); impl Service for Aiand { diff --git a/crates/herdr-usage/src/providers/aixy.rs b/crates/herdr-usage/src/providers/aixy.rs index 587c148d4..ddfdcf4a2 100644 --- a/crates/herdr-usage/src/providers/aixy.rs +++ b/crates/herdr-usage/src/providers/aixy.rs @@ -24,19 +24,11 @@ pub(crate) struct Aixy; static META: Meta = Meta::new("aixy", "Aixy") .dashboard("https://dash.aixy-gateway.com") .settings(&[ - Setting::new( - "api_key", - &["AIXY_API_KEY"], - "The project-scoped Aixy API key your workload uses, from \ - https://dash.aixy-gateway.com under the project's API keys. Usage is that \ - key's own traffic from every machine.", - ), + Setting::new("api_key", &["AIXY_API_KEY"], "usage-aixy-setting-api-key"), Setting::new( "base_url", &["AIXY_BASE_URL"], - "Optional gateway URL for a self-hosted or dedicated Aixy, with or without /v1. \ - Defaults to https://api.aixy-gateway.com. It must be HTTPS unless it is on \ - localhost, a private network, or a .local host.", + "usage-aixy-setting-base-url", ), ]); diff --git a/crates/herdr-usage/src/providers/alibaba.rs b/crates/herdr-usage/src/providers/alibaba.rs index 2334d4ee2..6658a5cba 100644 --- a/crates/herdr-usage/src/providers/alibaba.rs +++ b/crates/herdr-usage/src/providers/alibaba.rs @@ -52,12 +52,7 @@ static META: Meta = Meta::new("alibaba", "Alibaba") Setting::new( "cookie", &["ALIBABA_CODING_PLAN_COOKIE"], - "Your Model Studio console session. Sign in to the Coding Plan page at \ - https://modelstudio.console.alibabacloud.com (or https://bailian.console.aliyun.com \ - in China), open Developer Tools > Application > Cookies for that site, and copy \ - at least login_aliyunid_ticket, login_aliyunid_pk, login_aliyunid_csrf and cna. \ - Paste them as \"name=value; name2=value2\", or copy the whole Cookie header of the \ - data/api.json request from the Network tab.", + "usage-alibaba-setting-cookie", ), Setting::new( "api_key", @@ -66,22 +61,17 @@ static META: Meta = Meta::new("alibaba", "Alibaba") "ALIBABA_QWEN_API_KEY", "DASHSCOPE_API_KEY", ], - "A Coding Plan API key (sk-sp-...) from the Coding Plan page of the Model Studio \ - console. Used when no console cookie works; some mainland accounts only answer \ - the cookie.", + "usage-alibaba-setting-api-key", ), Setting::new( "region", &[], - "Optional. \"intl\" (modelstudio.console.alibabacloud.com) or \"cn\" \ - (bailian.console.aliyun.com). Unset tries International, then China mainland.", + "usage-alibaba-setting-region", ), Setting::new( "sec_token", &[], - "Optional. The console's sec_token, when it cannot be read from \ - /tool/user/info.json: in Developer Tools > Network, the sec_token form field of \ - the data/api.json request on the Coding Plan page.", + "usage-alibaba-setting-sec-token", ), ]); diff --git a/crates/herdr-usage/src/providers/alibabatokenplan.rs b/crates/herdr-usage/src/providers/alibabatokenplan.rs index a47991154..d38b9c1fe 100644 --- a/crates/herdr-usage/src/providers/alibabatokenplan.rs +++ b/crates/herdr-usage/src/providers/alibabatokenplan.rs @@ -52,25 +52,17 @@ static META: Meta = Meta::new("alibabatokenplan", "Alibaba Token Plan") Setting::new( "region", &[], - "Which Token Plan to read: \"intl\" (International Team, the default), \"cn\" \ - (China mainland Team), \"intl-personal\" or \"cn-personal\" (Personal/Solo).", + "usage-alibabatokenplan-setting-region", ), Setting::new( "cookie", &["ALIBABA_TOKEN_PLAN_COOKIE"], - "Only needed without a signed-in Bailian CLI (bl). Sign in to the Token Plan page \ - (https://modelstudio.console.alibabacloud.com, or https://bailian.console.aliyun.com \ - in China), open Developer Tools > Application > Cookies for that site, and copy \ - at least login_aliyunid_ticket, login_aliyunid_pk, login_aliyunid_csrf and cna. \ - Paste them as \"name=value; name2=value2\", or copy the whole Cookie header of the \ - data/api.json request from the Network tab.", + "usage-alibabatokenplan-setting-cookie", ), Setting::new( "sec_token", &[], - "Optional. The console's sec_token, when it cannot be read from \ - /tool/user/info.json: in Developer Tools > Network, the sec_token form field of \ - any data/api.json request on the Token Plan page.", + "usage-alibabatokenplan-setting-sec-token", ), ]); diff --git a/crates/herdr-usage/src/providers/amp.rs b/crates/herdr-usage/src/providers/amp.rs index b1c04e4f1..294d80c27 100644 --- a/crates/herdr-usage/src/providers/amp.rs +++ b/crates/herdr-usage/src/providers/amp.rs @@ -32,21 +32,8 @@ pub(crate) struct Amp; static META: Meta = Meta::new("amp", "Amp") .dashboard("https://ampcode.com/settings/usage") .settings(&[ - Setting::new( - "api_key", - &["AMP_API_KEY"], - "An Amp access token, created in Amp's settings at \ - https://ampcode.com/settings. Not needed when the Amp CLI is installed and \ - signed in on the host (`amp login`).", - ), - Setting::new( - "cookie", - &[], - "Your ampcode.com browser session, used when neither the CLI nor a token is \ - available; it shows Amp Free only. Sign in at https://ampcode.com, open \ - Developer Tools → Application → Cookies → https://ampcode.com, and copy the \ - `session` cookie as \"session=value\".", - ), + Setting::new("api_key", &["AMP_API_KEY"], "usage-amp-setting-api-key"), + Setting::new("cookie", &[], "usage-amp-setting-cookie"), ]); impl Service for Amp { diff --git a/crates/herdr-usage/src/providers/antigravity.rs b/crates/herdr-usage/src/providers/antigravity.rs index 70ff400f5..77da535c3 100644 --- a/crates/herdr-usage/src/providers/antigravity.rs +++ b/crates/herdr-usage/src/providers/antigravity.rs @@ -45,10 +45,7 @@ static META: Meta = Meta::new("antigravity", "Antigravity") .settings(&[Setting::new( "cli_path", &["ANTIGRAVITY_CLI_PATH"], - "The agy executable on the probed host when it is not on the PATH (install it \ - with `brew install --cask antigravity-cli`, run `agy` once, and sign in). agy \ - runs only when this is set or antigravity is in show_providers, as a \ - signed-out agy opens a browser to sign in.", + "usage-antigravity-setting-cli-path", )]); impl Service for Antigravity { diff --git a/crates/herdr-usage/src/providers/atlascloud.rs b/crates/herdr-usage/src/providers/atlascloud.rs index fb1fe5c1a..56e348c61 100644 --- a/crates/herdr-usage/src/providers/atlascloud.rs +++ b/crates/herdr-usage/src/providers/atlascloud.rs @@ -25,9 +25,7 @@ static META: Meta = Meta::new("atlascloud", "Atlas Cloud") .settings(&[Setting::new( "api_key", &["ATLASCLOUD_API_KEY"], - "A standard Atlas Cloud API key from https://www.atlascloud.ai/console. It needs \ - account balance read permission: use the account owner's key, or an Account Admin \ - or Finance key for a team. A public key ID (ak_…) cannot authenticate.", + "usage-atlascloud-setting-api-key", )]); impl Service for Atlascloud { diff --git a/crates/herdr-usage/src/providers/augment.rs b/crates/herdr-usage/src/providers/augment.rs index 78141f011..3456c576e 100644 --- a/crates/herdr-usage/src/providers/augment.rs +++ b/crates/herdr-usage/src/providers/augment.rs @@ -30,15 +30,7 @@ pub(crate) struct Augment; static META: Meta = Meta::new("augment", "Augment") .dashboard("https://app.augmentcode.com/account/subscription") .status_page("https://status.augmentcode.com") - .settings(&[Setting::new( - "cookie", - &[], - "Your app.augmentcode.com browser session, used when the Auggie CLI is not signed \ - in. Sign in at https://app.augmentcode.com, open Developer Tools → Application → \ - Cookies → https://app.augmentcode.com, and copy the session cookies (such as \ - `_session`, `session`, `web_rpc_proxy_session`, or `auth0`), pasted as one \ - header: \"name=value; name2=value2\".", - )]); + .settings(&[Setting::new("cookie", &[], "usage-augment-setting-cookie")]); impl Service for Augment { fn meta(&self) -> &'static Meta { diff --git a/crates/herdr-usage/src/providers/azureopenai.rs b/crates/herdr-usage/src/providers/azureopenai.rs index 2879918af..f20ef9ae8 100644 --- a/crates/herdr-usage/src/providers/azureopenai.rs +++ b/crates/herdr-usage/src/providers/azureopenai.rs @@ -32,26 +32,22 @@ static META: Meta = Meta::new("azureopenai", "Azure OpenAI") Setting::new( "api_key", &["AZURE_OPENAI_API_KEY"], - "An Azure OpenAI resource key: in the Azure portal open the Azure OpenAI \ - resource, then Resource Management > Keys and Endpoint, and copy KEY 1.", + "usage-azureopenai-setting-api-key", ), Setting::new( "endpoint", &["AZURE_OPENAI_ENDPOINT"], - "The resource endpoint from the same Keys and Endpoint page, e.g. \ - https://my-resource.openai.azure.com. Only HTTPS is accepted.", + "usage-azureopenai-setting-endpoint", ), Setting::new( "deployment", &["AZURE_OPENAI_DEPLOYMENT_NAME"], - "The deployment name to check, as listed under Deployments in Azure AI \ - Foundry (https://ai.azure.com), e.g. chat-prod.", + "usage-azureopenai-setting-deployment", ), Setting::new( "api_version", &["AZURE_OPENAI_API_VERSION"], - "Optional. \"v1\" for the OpenAI-compatible v1 API, or a dated API version. \ - Defaults to 2024-10-21.", + "usage-azureopenai-setting-api-version", ), ]); diff --git a/crates/herdr-usage/src/providers/bedrock.rs b/crates/herdr-usage/src/providers/bedrock.rs index dfdeed3db..4ca595fe5 100644 --- a/crates/herdr-usage/src/providers/bedrock.rs +++ b/crates/herdr-usage/src/providers/bedrock.rs @@ -34,26 +34,16 @@ static META: Meta = Meta::new("bedrock", "AWS Bedrock") .dashboard("https://console.aws.amazon.com/bedrock") .status_page("https://health.aws.amazon.com/health/status") .settings(&[ - Setting::new( - "profile", - &[], - "The AWS CLI profile to read Cost Explorer with, from ~/.aws/config on the \ - selected host (run `aws sso login --profile ` first for SSO). Use \ - \"default\" for the default credentials. The identity needs \ - ce:GetCostAndUsage. AWS bills every Cost Explorer request, so this provider \ - runs only once profile or budget is set here.", - ), + Setting::new("profile", &[], "usage-bedrock-setting-profile"), Setting::new( "budget", &["CODEXBAR_BEDROCK_BUDGET"], - "Optional monthly Bedrock budget in US dollars, e.g. 250, shown as a monthly \ - limit. It does not cap AWS charges.", + "usage-bedrock-setting-budget", ), Setting::new( "aws_cli", &["AWS_CLI_PATH"], - "Optional path to the AWS CLI v2 on the selected host when `aws` is not on \ - its PATH, e.g. /opt/homebrew/bin/aws.", + "usage-bedrock-setting-aws-cli", ), ]); diff --git a/crates/herdr-usage/src/providers/bifrost.rs b/crates/herdr-usage/src/providers/bifrost.rs index 2438125c6..d7b0d3724 100644 --- a/crates/herdr-usage/src/providers/bifrost.rs +++ b/crates/herdr-usage/src/providers/bifrost.rs @@ -29,15 +29,12 @@ static META: Meta = Meta::new("bifrost", "Bifrost").settings(&[ Setting::new( "api_key", &["BIFROST_API_KEY"], - "Your Bifrost virtual key (vk-…), from the gateway's Virtual Keys page. It is \ - sent as the x-bf-vk header to base_url only.", + "usage-bifrost-setting-api-key", ), Setting::new( "base_url", &["BIFROST_BASE_URL"], - "Your Bifrost gateway's URL, e.g. https://bifrost.example.com. Bifrost has no \ - public host. It must be HTTPS unless it is on localhost, a private network, \ - or a .local host.", + "usage-bifrost-setting-base-url", ), ]); diff --git a/crates/herdr-usage/src/providers/chutes.rs b/crates/herdr-usage/src/providers/chutes.rs index 8d9b563b0..eb7aa5140 100644 --- a/crates/herdr-usage/src/providers/chutes.rs +++ b/crates/herdr-usage/src/providers/chutes.rs @@ -39,13 +39,12 @@ static META: Meta = Meta::new("chutes", "Chutes") Setting::new( "api_key", &["CHUTES_API_KEY"], - "A Chutes API key (cpk_…), created as described at \ - https://chutes.ai/docs/getting-started/authentication.", + "usage-chutes-setting-api-key", ), Setting::new( "base_url", &["CHUTES_API_URL"], - "Optional HTTPS management API URL. Defaults to https://api.chutes.ai.", + "usage-chutes-setting-base-url", ), ]); diff --git a/crates/herdr-usage/src/providers/clawrouter.rs b/crates/herdr-usage/src/providers/clawrouter.rs index a9e2776ee..dac47ca49 100644 --- a/crates/herdr-usage/src/providers/clawrouter.rs +++ b/crates/herdr-usage/src/providers/clawrouter.rs @@ -29,14 +29,12 @@ static META: Meta = Meta::new("clawrouter", "ClawRouter") Setting::new( "api_key", &["CLAWROUTER_API_KEY"], - "A ClawRouter policy API key, created at \ - https://clawrouter.openclaw.ai/dashboard/access.", + "usage-clawrouter-setting-api-key", ), Setting::new( "base_url", &["CLAWROUTER_BASE_URL"], - "Optional HTTPS service root or /v1 URL of another ClawRouter deployment. \ - Defaults to https://clawrouter.openclaw.ai.", + "usage-clawrouter-setting-base-url", ), ]); diff --git a/crates/herdr-usage/src/providers/clinepass.rs b/crates/herdr-usage/src/providers/clinepass.rs index 3b7d653ef..d1ddf9d33 100644 --- a/crates/herdr-usage/src/providers/clinepass.rs +++ b/crates/herdr-usage/src/providers/clinepass.rs @@ -24,8 +24,7 @@ static META: Meta = Meta::new("clinepass", "ClinePass") .settings(&[Setting::new( "api_key", &["CLINE_API_KEY", "CLINEPASS_API_KEY"], - "A Cline API key for the account with the ClinePass subscription. Create one at \ - https://app.cline.bot under Settings > API Keys.", + "usage-clinepass-setting-api-key", )]); impl Service for Clinepass { diff --git a/crates/herdr-usage/src/providers/codebuff.rs b/crates/herdr-usage/src/providers/codebuff.rs index 3fa396293..50696638b 100644 --- a/crates/herdr-usage/src/providers/codebuff.rs +++ b/crates/herdr-usage/src/providers/codebuff.rs @@ -29,15 +29,12 @@ static META: Meta = Meta::new("codebuff", "Codebuff") Setting::new( "api_key", &["CODEBUFF_API_KEY"], - "A Codebuff API key from https://www.codebuff.com. It reads the credit balance \ - only; without it, the session from `codebuff login` is used, which also reads \ - the plan and weekly limit.", + "usage-codebuff-setting-api-key", ), Setting::new( "base_url", &["CODEBUFF_API_URL"], - "An https URL that replaces https://www.codebuff.com, e.g. for a staging server. \ - Other schemes are ignored so the token never travels unencrypted.", + "usage-codebuff-setting-base-url", ), ]); diff --git a/crates/herdr-usage/src/providers/coderabbit.rs b/crates/herdr-usage/src/providers/coderabbit.rs index 2936615a2..22b400f66 100644 --- a/crates/herdr-usage/src/providers/coderabbit.rs +++ b/crates/herdr-usage/src/providers/coderabbit.rs @@ -29,8 +29,7 @@ static META: Meta = Meta::new("coderabbit", "CodeRabbit") .settings(&[Setting::new( "cli_path", &["CODERABBIT_CLI_PATH"], - "The path of the coderabbit executable on the probed host, when it is not on the \ - usual PATH (~/.local/bin, Homebrew). Sign in once with `coderabbit auth login`.", + "usage-coderabbit-setting-cli-path", )]); impl Service for Coderabbit { diff --git a/crates/herdr-usage/src/providers/commandcode.rs b/crates/herdr-usage/src/providers/commandcode.rs index b88508b17..a378ae95c 100644 --- a/crates/herdr-usage/src/providers/commandcode.rs +++ b/crates/herdr-usage/src/providers/commandcode.rs @@ -54,10 +54,7 @@ static META: Meta = Meta::new("commandcode", "Command Code") .settings(&[Setting::new( "cookie", &[], - "Sign in to https://commandcode.ai, open Developer Tools > Application > Cookies \ - for https://commandcode.ai, and copy the session cookie: \ - __Secure-better-auth.session_token (or __Secure-commandcode_prod_.session_token, \ - whichever is present). Paste it as \"name=value\".", + "usage-commandcode-setting-cookie", )]); impl Service for Commandcode { diff --git a/crates/herdr-usage/src/providers/copilot.rs b/crates/herdr-usage/src/providers/copilot.rs index f85b22db5..d82c5d059 100644 --- a/crates/herdr-usage/src/providers/copilot.rs +++ b/crates/herdr-usage/src/providers/copilot.rs @@ -33,16 +33,12 @@ static META: Meta = Meta::new("copilot", "Copilot") Setting::new( "token", &["COPILOT_API_TOKEN"], - "A GitHub token of the account with Copilot, e.g. a fine-grained personal \ - access token from https://github.com/settings/personal-access-tokens with no \ - extra permissions, or the output of `gh auth token`. Not needed when a \ - Copilot editor plugin is signed in on the host.", + "usage-copilot-setting-token", ), Setting::new( "enterprise_host", &[], - "For GitHub Enterprise Cloud with data residency: the host, such as \ - octocorp.ghe.com. Usage is then read from api..", + "usage-copilot-setting-enterprise-host", ), ]); diff --git a/crates/herdr-usage/src/providers/cursor.rs b/crates/herdr-usage/src/providers/cursor.rs index 92e0c680f..399090def 100644 --- a/crates/herdr-usage/src/providers/cursor.rs +++ b/crates/herdr-usage/src/providers/cursor.rs @@ -45,15 +45,7 @@ pub(crate) struct Cursor; static META: Meta = Meta::new("cursor", "Cursor") .dashboard("https://cursor.com/dashboard?tab=usage") .status_page("https://status.cursor.com") - .settings(&[Setting::new( - "cookie", - &[], - "Your cursor.com session. Sign in at https://cursor.com/dashboard, open Developer \ - Tools > Application > Cookies > https://cursor.com, and copy the \ - WorkosCursorSessionToken cookie (or __Secure-next-auth.session-token on older \ - accounts). Paste it as \"WorkosCursorSessionToken=value\". Not needed when Cursor \ - is listed in show_providers and you are signed in with Chrome or Safari.", - )]); + .settings(&[Setting::new("cookie", &[], "usage-cursor-setting-cookie")]); impl Service for Cursor { fn meta(&self) -> &'static Meta { diff --git a/crates/herdr-usage/src/providers/deepgram.rs b/crates/herdr-usage/src/providers/deepgram.rs index f46911dbe..272a81161 100644 --- a/crates/herdr-usage/src/providers/deepgram.rs +++ b/crates/herdr-usage/src/providers/deepgram.rs @@ -28,19 +28,17 @@ static META: Meta = Meta::new("deepgram", "Deepgram") Setting::new( "api_key", &["DEEPGRAM_API_KEY"], - "A Deepgram API key with the usage:read scope, created in the Deepgram Console \ - (https://console.deepgram.com) under API Keys.", + "usage-deepgram-setting-api-key", ), Setting::new( "project_id", &["DEEPGRAM_PROJECT_ID"], - "Optional project UUID from the Deepgram Console. Leave unset to sum usage over \ - every project the key can see.", + "usage-deepgram-setting-project-id", ), Setting::new( "base_url", &["DEEPGRAM_API_URL"], - "Optional HTTPS API URL for a proxy. Defaults to https://api.deepgram.com/v1.", + "usage-deepgram-setting-base-url", ), ]); diff --git a/crates/herdr-usage/src/providers/deepinfra.rs b/crates/herdr-usage/src/providers/deepinfra.rs index 6b18c7aa0..e40655551 100644 --- a/crates/herdr-usage/src/providers/deepinfra.rs +++ b/crates/herdr-usage/src/providers/deepinfra.rs @@ -27,8 +27,7 @@ static META: Meta = Meta::new("deepinfra", "DeepInfra") .settings(&[Setting::new( "api_key", &["DEEPINFRA_API_KEY", "DEEPINFRA_TOKEN"], - "A DeepInfra API key from https://deepinfra.com/dash/api_keys, without a \ - \"Bearer \" prefix. It must be allowed to read billing data.", + "usage-deepinfra-setting-api-key", )]); impl Service for Deepinfra { diff --git a/crates/herdr-usage/src/providers/deepseek.rs b/crates/herdr-usage/src/providers/deepseek.rs index ef6ea133d..b4e7475dc 100644 --- a/crates/herdr-usage/src/providers/deepseek.rs +++ b/crates/herdr-usage/src/providers/deepseek.rs @@ -31,16 +31,12 @@ static META: Meta = Meta::new("deepseek", "DeepSeek") Setting::new( "api_key", &["DEEPSEEK_API_KEY", "DEEPSEEK_KEY"], - "A DeepSeek API key from https://platform.deepseek.com/api_keys. It reads \ - the remaining credit balance.", + "usage-deepseek-setting-api-key", ), Setting::new( "platform_token", &["DEEPSEEK_PLATFORM_TOKEN", "DEEPSEEK_USER_TOKEN"], - "Used when no API key is set. Sign in to https://platform.deepseek.com, open \ - Developer Tools > Application > Local Storage > https://platform.deepseek.com, \ - and copy the value of the userToken entry (either the whole JSON value or \ - just its \"value\" field).", + "usage-deepseek-setting-platform-token", ), ]); diff --git a/crates/herdr-usage/src/providers/devin.rs b/crates/herdr-usage/src/providers/devin.rs index 1308184c4..3411fa710 100644 --- a/crates/herdr-usage/src/providers/devin.rs +++ b/crates/herdr-usage/src/providers/devin.rs @@ -31,17 +31,12 @@ static META: Meta = Meta::new("devin", "Devin") Setting::new( "token", &["DEVIN_BEARER_TOKEN"], - "Sign in to https://app.devin.ai, open your organization's Usage & Limits \ - page, then in Developer Tools > Network select the successful \ - billing/quota/usage request and copy its Authorization request header \ - without the leading \"Bearer \". The value is a browser session token and \ - expires with that session.", + "usage-devin-setting-token", ), Setting::new( "organization", &["DEVIN_ORGANIZATION", "DEVIN_ORG"], - "The x-cog-org-id request header of the same request (an org-… or org_… \ - id). An organization slug or its app.devin.ai URL also works.", + "usage-devin-setting-organization", ), ]); diff --git a/crates/herdr-usage/src/providers/devpass.rs b/crates/herdr-usage/src/providers/devpass.rs index 5d6351202..0b0cdb220 100644 --- a/crates/herdr-usage/src/providers/devpass.rs +++ b/crates/herdr-usage/src/providers/devpass.rs @@ -24,8 +24,7 @@ static META: Meta = Meta::new("devpass", "DevPass") .settings(&[Setting::new( "api_key", &["DEVPASS_API_KEY"], - "A regular LLM Gateway API key from https://devpass.llmgateway.io/dashboard. \ - Publishable keys cannot read plan usage.", + "usage-devpass-setting-api-key", )]); impl Service for Devpass { diff --git a/crates/herdr-usage/src/providers/elevenlabs.rs b/crates/herdr-usage/src/providers/elevenlabs.rs index 61107e6a6..ee4d9af62 100644 --- a/crates/herdr-usage/src/providers/elevenlabs.rs +++ b/crates/herdr-usage/src/providers/elevenlabs.rs @@ -27,13 +27,12 @@ static META: Meta = Meta::new("elevenlabs", "ElevenLabs") Setting::new( "api_key", &["ELEVENLABS_API_KEY", "XI_API_KEY"], - "An ElevenLabs API key with the user_read permission, from \ - https://elevenlabs.io/app/settings/api-keys.", + "usage-elevenlabs-setting-api-key", ), Setting::new( "base_url", &["ELEVENLABS_API_URL"], - "Optional HTTPS API origin for a proxy. Defaults to https://api.elevenlabs.io.", + "usage-elevenlabs-setting-base-url", ), ]); diff --git a/crates/herdr-usage/src/providers/factory.rs b/crates/herdr-usage/src/providers/factory.rs index 03605c78c..9a73c0384 100644 --- a/crates/herdr-usage/src/providers/factory.rs +++ b/crates/herdr-usage/src/providers/factory.rs @@ -56,16 +56,9 @@ static META: Meta = Meta::new("factory", "Droid") Setting::new( "api_key", &["FACTORY_API_KEY"], - "A Factory API key (fk-…) from https://app.factory.ai/settings/api-keys.", - ), - Setting::new( - "cookie", - &[], - "Used when no API key is set. Sign in to https://app.factory.ai, open \ - Developer Tools > Application > Cookies > https://app.factory.ai, and copy \ - the session cookies (wos-session, access-token, and any *.session-token) \ - as \"name=value; name2=value2\".", + "usage-factory-setting-api-key", ), + Setting::new("cookie", &[], "usage-factory-setting-cookie"), ]); impl Service for Factory { diff --git a/crates/herdr-usage/src/providers/fireworks.rs b/crates/herdr-usage/src/providers/fireworks.rs index 7b3fc86e0..c2a8ecad1 100644 --- a/crates/herdr-usage/src/providers/fireworks.rs +++ b/crates/herdr-usage/src/providers/fireworks.rs @@ -32,15 +32,12 @@ static META: Meta = Meta::new("fireworks", "Fireworks") Setting::new( "api_key", &["FIREWORKS_API_KEY", "FIREWORKS_KEY"], - "A Fireworks API key from https://app.fireworks.ai/settings/users/api-keys. \ - It reads the account's billing summary.", + "usage-fireworks-setting-api-key", ), Setting::new( "account_slug", &["FIREWORKS_ACCOUNT_SLUG"], - "The Fireworks account id, needed only when the API key can see several \ - accounts. Find it in the account switcher on https://app.fireworks.ai or \ - with `firectl whoami`.", + "usage-fireworks-setting-account-slug", ), ]); diff --git a/crates/herdr-usage/src/providers/gemini.rs b/crates/herdr-usage/src/providers/gemini.rs index a83752d95..0c6646dfa 100644 --- a/crates/herdr-usage/src/providers/gemini.rs +++ b/crates/herdr-usage/src/providers/gemini.rs @@ -69,15 +69,12 @@ static META: Meta = Meta::new("gemini", "Gemini") Setting::new( "client_id", &["GEMINI_OAUTH_CLIENT_ID"], - "The Gemini CLI's OAuth client id, used to refresh an expired sign-in. \ - Normally found in the installed CLI (OAUTH_CLIENT_ID in \ - @google/gemini-cli-core/dist/src/code_assist/oauth2.js); set it only when \ - the CLI is not on the host's PATH.", + "usage-gemini-setting-client-id", ), Setting::new( "client_secret", &["GEMINI_OAUTH_CLIENT_SECRET"], - "The matching OAUTH_CLIENT_SECRET from the same file.", + "usage-gemini-setting-client-secret", ), ]); diff --git a/crates/herdr-usage/src/providers/gitkraken.rs b/crates/herdr-usage/src/providers/gitkraken.rs index 9078e18d4..fdd3f3c49 100644 --- a/crates/herdr-usage/src/providers/gitkraken.rs +++ b/crates/herdr-usage/src/providers/gitkraken.rs @@ -26,16 +26,12 @@ static META: Meta = Meta::new("gitkraken", "GitKraken AI") Setting::new( "token", &["GITKRAKEN_API_TOKEN"], - "A GitKraken account session access token. Sign in at \ - https://gitkraken.dev/account#ai-usage, open Developer Tools > Network, \ - select the successful ai-tasks/usage request, and copy only the value after \ - \"Bearer \" in its Authorization header. Replace it when it expires.", + "usage-gitkraken-setting-token", ), Setting::new( "org_id", &["GITKRAKEN_ORG_ID"], - "Optional organization ID: the gk-org-id header of the same request, to read \ - that organization's shared pool.", + "usage-gitkraken-setting-org-id", ), ]); diff --git a/crates/herdr-usage/src/providers/grok.rs b/crates/herdr-usage/src/providers/grok.rs index d62152d5a..2c1867b9f 100644 --- a/crates/herdr-usage/src/providers/grok.rs +++ b/crates/herdr-usage/src/providers/grok.rs @@ -39,9 +39,7 @@ static META: Meta = Meta::new("grok", "Grok") .settings(&[Setting::new( "token", &["GROK_OAUTH_TOKEN"], - "A SuperGrok bearer token, only needed when the Grok CLI is not signed in on the \ - selected host (run `grok login` there instead when you can). It is the \"key\" of \ - the https://auth.x.ai entry in ~/.grok/auth.json. xai- API keys do not work.", + "usage-grok-setting-token", )]); impl Service for Grok { diff --git a/crates/herdr-usage/src/providers/groq.rs b/crates/herdr-usage/src/providers/groq.rs index 8c243bb4a..144535da5 100644 --- a/crates/herdr-usage/src/providers/groq.rs +++ b/crates/herdr-usage/src/providers/groq.rs @@ -35,18 +35,8 @@ static META: Meta = Meta::new("groq", "Groq") .dashboard("https://console.groq.com/dashboard/usage") .status_page("https://status.groq.com") .settings(&[ - Setting::new( - "api_key", - &["GROQ_API_KEY"], - "A GroqCloud Enterprise API key from https://console.groq.com/keys. Only \ - Enterprise organizations can read Prometheus metrics.", - ), - Setting::new( - "base_url", - &["GROQ_API_URL"], - "Optional HTTPS API base URL for a private gateway. Defaults to \ - https://api.groq.com/v1.", - ), + Setting::new("api_key", &["GROQ_API_KEY"], "usage-groq-setting-api-key"), + Setting::new("base_url", &["GROQ_API_URL"], "usage-groq-setting-base-url"), ]); impl Service for Groq { diff --git a/crates/herdr-usage/src/providers/helmcode.rs b/crates/herdr-usage/src/providers/helmcode.rs index 4df0769c8..845516243 100644 --- a/crates/herdr-usage/src/providers/helmcode.rs +++ b/crates/herdr-usage/src/providers/helmcode.rs @@ -47,22 +47,8 @@ pub(crate) struct Helmcode; static META: Meta = Meta::new("helmcode", "Helmcode") .dashboard("https://cloud.helmcode.com/dashboard") .settings(&[ - Setting::new( - "cookie", - &[], - "The dashboard session. Sign in to https://cloud.helmcode.com/dashboard (or \ - https://cloud.nan.builders/dashboard for NaN Builders), open Developer Tools > \ - Network, reload, and copy the Cookie request header of a request to \ - cloud-api.helmcode.com (or cloud-api.nan.builders). Developer Tools > \ - Application > Cookies lists the same cookies. Paste it as \ - \"name=value; name2=value2\".", - ), - Setting::new( - "tenant", - &[], - "Which dashboard a pasted cookie belongs to: \"helmcode\" (default) or \ - \"nanBuilders\".", - ), + Setting::new("cookie", &[], "usage-helmcode-setting-cookie"), + Setting::new("tenant", &[], "usage-helmcode-setting-tenant"), ]); impl Service for Helmcode { diff --git a/crates/herdr-usage/src/providers/huggingface.rs b/crates/herdr-usage/src/providers/huggingface.rs index 6b8608f91..621ebe796 100644 --- a/crates/herdr-usage/src/providers/huggingface.rs +++ b/crates/herdr-usage/src/providers/huggingface.rs @@ -40,19 +40,9 @@ static META: Meta = Meta::new("huggingface", "Hugging Face") "HF_TOKEN", "HUGGING_FACE_HUB_TOKEN", ], - "A Hugging Face access token from https://huggingface.co/settings/tokens. A \ - classic read token works; a fine-grained token needs the Billing read \ - permission. Not needed when `hf auth login` has saved a token on the host.", - ), - Setting::new( - "cookie", - &[], - "Optional, for the prepaid credit balance. Sign in at \ - https://huggingface.co/settings/billing with the token's account, open \ - Developer Tools > Application > Cookies > https://huggingface.co, and copy \ - the token cookie as \"token=value\" (or every cookie there as \ - \"name=value; name2=value2\").", + "usage-huggingface-setting-api-key", ), + Setting::new("cookie", &[], "usage-huggingface-setting-cookie"), ]); impl Service for Huggingface { diff --git a/crates/herdr-usage/src/providers/hyper.rs b/crates/herdr-usage/src/providers/hyper.rs index 5ba9ee42f..a3f1101ba 100644 --- a/crates/herdr-usage/src/providers/hyper.rs +++ b/crates/herdr-usage/src/providers/hyper.rs @@ -24,19 +24,8 @@ pub(crate) struct Hyper; static META: Meta = Meta::new("hyper", "Charm Hyper") .dashboard("https://hyper.charm.land") .settings(&[ - Setting::new( - "api_key", - &["HYPER_API_KEY"], - "A Charm Hyper API key from https://hyper.charm.land. Used when no browser \ - session is available or the session has expired.", - ), - Setting::new( - "cookie", - &[], - "Sign in at https://hyper.charm.land, open Developer Tools > Application > \ - Cookies > https://hyper.charm.land, and copy every cookie there as one \ - \"name=value; name2=value2\" header.", - ), + Setting::new("api_key", &["HYPER_API_KEY"], "usage-hyper-setting-api-key"), + Setting::new("cookie", &[], "usage-hyper-setting-cookie"), ]); impl Service for Hyper { diff --git a/crates/herdr-usage/src/providers/ibmbob.rs b/crates/herdr-usage/src/providers/ibmbob.rs index 5aa243524..3987755c7 100644 --- a/crates/herdr-usage/src/providers/ibmbob.rs +++ b/crates/herdr-usage/src/providers/ibmbob.rs @@ -31,15 +31,9 @@ static META: Meta = Meta::new("ibmbob", "IBM Bob") Setting::new( "api_key", &["BOBSHELL_API_KEY"], - "An IBM Bob API key that can read subscription usage, created at \ - https://bob.ibm.com (the same key Bob Shell uses as BOBSHELL_API_KEY).", - ), - Setting::new( - "token", - &[], - "Instead of an API key, an IBM Cloud bearer token (a JWT) for your Bob account. \ - It is sent as \"Authorization: Bearer \".", + "usage-ibmbob-setting-api-key", ), + Setting::new("token", &[], "usage-ibmbob-setting-token"), ]); impl Service for Ibmbob { diff --git a/crates/herdr-usage/src/providers/jetbrains.rs b/crates/herdr-usage/src/providers/jetbrains.rs index 3b35e84cc..dd5a67627 100644 --- a/crates/herdr-usage/src/providers/jetbrains.rs +++ b/crates/herdr-usage/src/providers/jetbrains.rs @@ -49,12 +49,7 @@ pub(crate) struct Jetbrains; static META: Meta = Meta::new("jetbrains", "JetBrains AI").settings(&[Setting::new( "ide_path", &[], - "The config directory of the IDE to read, when the most recently used one is not \ - the right one, e.g. \"~/Library/Application Support/JetBrains/IntelliJIdea2025.3\" \ - on a Mac or \"~/.config/JetBrains/PyCharm2025.3\" on Linux. Its \ - options/AIAssistantQuotaManager2.xml appears once AI Assistant has been used. \ - On the Mac running Herdr the IDEs are only looked for when this is set or jetbrains is in \ - show_providers, as macOS may ask before Herdr reads another app's data.", + "usage-jetbrains-setting-ide-path", )]); impl Service for Jetbrains { diff --git a/crates/herdr-usage/src/providers/kilo.rs b/crates/herdr-usage/src/providers/kilo.rs index 00bfdaf2b..0eb67dde2 100644 --- a/crates/herdr-usage/src/providers/kilo.rs +++ b/crates/herdr-usage/src/providers/kilo.rs @@ -32,9 +32,7 @@ static META: Meta = Meta::new("kilo", "Kilo") .settings(&[Setting::new( "api_key", &["KILO_API_KEY"], - "A Kilo API key from https://app.kilo.ai (Profile → API keys). Without one, the \ - Kilo CLI's sign-in in ~/.local/share/kilo/auth.json is used; run `kilo auth \ - login` to create it.", + "usage-kilo-setting-api-key", )]); impl Service for Kilo { diff --git a/crates/herdr-usage/src/providers/kimi.rs b/crates/herdr-usage/src/providers/kimi.rs index b94121ffc..aa47d1e97 100644 --- a/crates/herdr-usage/src/providers/kimi.rs +++ b/crates/herdr-usage/src/providers/kimi.rs @@ -45,39 +45,19 @@ static META: Meta = Meta::new("kimi", "Kimi Code") Setting::new( "api_key", &["KIMI_CODE_API_KEY"], - "A Kimi Code API key from the Kimi Code console (https://www.kimi.com/code/console, \ - or https://www.kimi.ai/code/console for International). Not needed when the \ - Kimi Code CLI is signed in on the host.", - ), - Setting::new( - "region", - &[], - "Which Kimi service the credentials belong to: \"china\" (the default, kimi.com) \ - or \"international\" (kimi.ai). Moonshot Open Platform keys belong to the \ - moonshot provider instead.", + "usage-kimi-setting-api-key", ), + Setting::new("region", &[], "usage-kimi-setting-region"), Setting::new( "base_url", &["KIMI_CODE_BASE_URL"], - "An https Kimi Code API base to use with the API key instead of \ - https://api.kimi.com, for a compatible proxy. Setting it stops the CLI's \ - sign-in from being used.", - ), - Setting::new( - "token", - &["KIMI_AUTH_TOKEN"], - "Your Kimi web session, for the membership pool and plan, or on its own. Sign in \ - at https://www.kimi.com/code/console, open Developer Tools → Application → \ - Cookies → https://www.kimi.com, and copy the value of the `kimi-auth` cookie \ - (a JWT starting with eyJ), without its name. On kimi.ai, copy `access_token` \ - from Local Storage instead. Not needed when Kimi is listed in show_providers \ - and you are signed in with Chrome or Safari.", + "usage-kimi-setting-base-url", ), + Setting::new("token", &["KIMI_AUTH_TOKEN"], "usage-kimi-setting-token"), Setting::new( "cookie", &["KIMI_MANUAL_COOKIE"], - "Instead of token, a whole Kimi Cookie header that includes kimi-auth, as \ - \"kimi-auth=value; …\".", + "usage-kimi-setting-cookie", ), ]); diff --git a/crates/herdr-usage/src/providers/litellm.rs b/crates/herdr-usage/src/providers/litellm.rs index 0abf5ab80..9e75f65cd 100644 --- a/crates/herdr-usage/src/providers/litellm.rs +++ b/crates/herdr-usage/src/providers/litellm.rs @@ -26,15 +26,12 @@ static META: Meta = Meta::new("litellm", "LiteLLM").settings(&[ Setting::new( "api_key", &["LITELLM_API_KEY"], - "A LiteLLM virtual key (sk-…) issued by your LiteLLM proxy, e.g. from its admin \ - UI under Virtual Keys. A master key is not needed.", + "usage-litellm-setting-api-key", ), Setting::new( "base_url", &["LITELLM_BASE_URL"], - "The LiteLLM proxy's URL, with or without /v1, e.g. \ - https://litellm.example.com. It must be HTTPS unless it is on localhost, a \ - private network, or a .local host.", + "usage-litellm-setting-base-url", ), ]); diff --git a/crates/herdr-usage/src/providers/llmman.rs b/crates/herdr-usage/src/providers/llmman.rs index 6148053b0..7aa96f2dc 100644 --- a/crates/herdr-usage/src/providers/llmman.rs +++ b/crates/herdr-usage/src/providers/llmman.rs @@ -36,18 +36,11 @@ const MODELS: usize = 24; pub(crate) struct Llmman; static META: Meta = Meta::new("llmman", "llmman").settings(&[ - Setting::new( - "base_url", - &[], - "The llmman serve address, when not http://127.0.0.1:17434. A host without a \ - scheme uses HTTP on port 17434, e.g. localhost:18000. Plain HTTP is allowed \ - only for loopback, private-network, and .local hosts; others need HTTPS. \ - Without it, LLMMAN_HOST on the probed host is used when set.", - ), + Setting::new("base_url", &[], "usage-llmman-setting-base-url"), Setting::new( "api_key", &["LLMMAN_API_KEY"], - "Only when the daemon was started with LLMMAN_API_KEYS: one of those keys.", + "usage-llmman-setting-api-key", ), ]); diff --git a/crates/herdr-usage/src/providers/llmproxy.rs b/crates/herdr-usage/src/providers/llmproxy.rs index 2fb82f1df..1878b6a72 100644 --- a/crates/herdr-usage/src/providers/llmproxy.rs +++ b/crates/herdr-usage/src/providers/llmproxy.rs @@ -24,14 +24,12 @@ static META: Meta = Meta::new("llmproxy", "LLM Proxy").settings(&[ Setting::new( "api_key", &["LLM_PROXY_API_KEY"], - "The API key your LLM-API-Key-Proxy accepts (its PROXY_API_KEY), sent as a \ - bearer token to base_url.", + "usage-llmproxy-setting-api-key", ), Setting::new( "base_url", &["LLM_PROXY_BASE_URL"], - "The proxy's URL, with or without /v1, e.g. https://proxy.example.com. It must \ - be HTTPS unless it is on localhost, a private network, or a .local host.", + "usage-llmproxy-setting-base-url", ), ]); diff --git a/crates/herdr-usage/src/providers/longcat.rs b/crates/herdr-usage/src/providers/longcat.rs index 9c1ab150b..6becbedcb 100644 --- a/crates/herdr-usage/src/providers/longcat.rs +++ b/crates/herdr-usage/src/providers/longcat.rs @@ -35,11 +35,7 @@ static META: Meta = Meta::new("longcat", "LongCat") .settings(&[Setting::new( "cookie", &["LONGCAT_MANUAL_COOKIE"], - "The LongCat console session. Sign in at https://longcat.chat/platform/usage, open \ - Developer Tools > Application > Cookies > https://longcat.chat, and copy every \ - cookie of the site (the console does not document which one holds the session). \ - Paste them as \"name=value; name2=value2\", or copy the Cookie request header of \ - a request to longcat.chat from the Network tab.", + "usage-longcat-setting-cookie", )]); impl Service for Longcat { diff --git a/crates/herdr-usage/src/providers/manus.rs b/crates/herdr-usage/src/providers/manus.rs index 5594c38b5..a8b605fd5 100644 --- a/crates/herdr-usage/src/providers/manus.rs +++ b/crates/herdr-usage/src/providers/manus.rs @@ -35,17 +35,9 @@ static META: Meta = Meta::new("manus", "Manus") Setting::new( "session_token", &["MANUS_SESSION_TOKEN", "MANUS_SESSION_ID"], - "Sign in to https://manus.im, open Developer Tools > Application > Cookies > \ - https://manus.im, and copy the value of the session_id cookie (the value \ - only, without \"session_id=\"). Not needed when Manus is listed in \ - show_providers and you are signed in with Chrome or Safari.", - ), - Setting::new( - "cookie", - &["MANUS_COOKIE"], - "Instead of session_token, a whole manus.im Cookie header that includes \ - session_id, as \"session_id=value; …\".", + "usage-manus-setting-session-token", ), + Setting::new("cookie", &["MANUS_COOKIE"], "usage-manus-setting-cookie"), ]); impl Service for Manus { diff --git a/crates/herdr-usage/src/providers/mimo.rs b/crates/herdr-usage/src/providers/mimo.rs index 01d266193..1d15a648f 100644 --- a/crates/herdr-usage/src/providers/mimo.rs +++ b/crates/herdr-usage/src/providers/mimo.rs @@ -31,21 +31,8 @@ pub(crate) struct Mimo; static META: Meta = Meta::new("mimo", "Xiaomi MiMo") .dashboard("https://platform.xiaomimimo.com/#/console/balance") .settings(&[ - Setting::new( - "cookie", - &[], - "The MiMo console session. Sign in at \ - https://platform.xiaomimimo.com/#/console/balance, open Developer Tools → \ - Application → Cookies → https://platform.xiaomimimo.com, copy the \ - api-platform_serviceToken and userId cookies (and api-platform_ph and \ - api-platform_slh when present), and paste them as \ - \"api-platform_serviceToken=value; userId=value\".", - ), - Setting::new( - "base_url", - &["MIMO_API_URL"], - "Optional HTTPS API URL. Defaults to https://platform.xiaomimimo.com/api/v1.", - ), + Setting::new("cookie", &[], "usage-mimo-setting-cookie"), + Setting::new("base_url", &["MIMO_API_URL"], "usage-mimo-setting-base-url"), ]); impl Service for Mimo { diff --git a/crates/herdr-usage/src/providers/minimax.rs b/crates/herdr-usage/src/providers/minimax.rs index c36744f11..f602a98b8 100644 --- a/crates/herdr-usage/src/providers/minimax.rs +++ b/crates/herdr-usage/src/providers/minimax.rs @@ -62,22 +62,14 @@ static META: Meta = Meta::new("minimax", "MiniMax") Setting::new( "api_key", &["MINIMAX_CODING_API_KEY", "MINIMAX_API_KEY"], - "A MiniMax Coding Plan or Token Plan API key (sk-cp-…) from \ - https://platform.minimax.io/user-center/basic-information/interface-key.", + "usage-minimax-setting-api-key", ), Setting::new( "cookie", &["MINIMAX_COOKIE", "MINIMAX_COOKIE_HEADER"], - "Used when no API key is set. Sign in to https://platform.minimax.io, open \ - Developer Tools > Application > Cookies > https://platform.minimax.io, and \ - copy every cookie (including HERTZ-SESSION) as \"name=value; name2=value2\".", - ), - Setting::new( - "region", - &[], - "\"global\" for minimax.io (the default) or \"cn\" for the China mainland \ - platform at minimaxi.com.", + "usage-minimax-setting-cookie", ), + Setting::new("region", &[], "usage-minimax-setting-region"), ]); impl Service for Minimax { diff --git a/crates/herdr-usage/src/providers/mistral.rs b/crates/herdr-usage/src/providers/mistral.rs index a2e8b4692..d0b2e49c9 100644 --- a/crates/herdr-usage/src/providers/mistral.rs +++ b/crates/herdr-usage/src/providers/mistral.rs @@ -30,15 +30,7 @@ pub(crate) struct Mistral; static META: Meta = Meta::new("mistral", "Mistral") .dashboard("https://admin.mistral.ai/organization/usage") .status_page("https://status.mistral.ai") - .settings(&[Setting::new( - "cookie", - &[], - "Sign in to https://admin.mistral.ai/organization/usage, open Developer Tools > \ - Application > Cookies for https://admin.mistral.ai, and copy the ory_session_… \ - cookie (its name ends in a per-project suffix) and csrftoken. Paste them as \ - \"name=value; name2=value2\"; the Cookie header of any admin.mistral.ai request \ - also works.", - )]); + .settings(&[Setting::new("cookie", &[], "usage-mistral-setting-cookie")]); impl Service for Mistral { fn meta(&self) -> &'static Meta { diff --git a/crates/herdr-usage/src/providers/moonshot.rs b/crates/herdr-usage/src/providers/moonshot.rs index 23d36b1f3..3795020d8 100644 --- a/crates/herdr-usage/src/providers/moonshot.rs +++ b/crates/herdr-usage/src/providers/moonshot.rs @@ -30,17 +30,12 @@ static META: Meta = Meta::new("moonshot", "Moonshot / Kimi Open Platform") Setting::new( "api_key", &["MOONSHOT_API_KEY", "MOONSHOT_KEY"], - "A Moonshot / Kimi Open Platform API key, from \ - https://platform.moonshot.ai/console/api-keys (International) or \ - https://platform.moonshot.cn/console/api-keys (China mainland). Use a key \ - issued for the region set below.", + "usage-moonshot-setting-api-key", ), Setting::new( "region", &["MOONSHOT_REGION"], - "Which platform issued the key: \"international\" (the default, \ - api.moonshot.ai, balances in USD) or \"china\" (api.moonshot.cn, balances \ - in CNY).", + "usage-moonshot-setting-region", ), ]); diff --git a/crates/herdr-usage/src/providers/muse.rs b/crates/herdr-usage/src/providers/muse.rs index 4fb42c41c..5e34494d6 100644 --- a/crates/herdr-usage/src/providers/muse.rs +++ b/crates/herdr-usage/src/providers/muse.rs @@ -35,16 +35,7 @@ pub(crate) struct Muse; static META: Meta = Meta::new("muse", "Muse Code") .dashboard("https://dev.meta.ai") - .settings(&[Setting::new( - "token", - &[], - "A Muse CLI device-code access token (it starts with dca:), used when this host \ - has no `muse login`. It is providers.meta.access_token in \ - ~/.config/muse/auth.json on a machine where you ran `muse login`. On a Mac \ - where `muse login` kept it in the keychain instead, list muse in \ - show_providers and press Allow in its usage panel rather than setting \ - this. Dashboard LLM_ keys and LLM| inference keys are not accepted.", - )]); + .settings(&[Setting::new("token", &[], "usage-muse-setting-token")]); impl Service for Muse { fn meta(&self) -> &'static Meta { diff --git a/crates/herdr-usage/src/providers/neuralwatt.rs b/crates/herdr-usage/src/providers/neuralwatt.rs index 95256900b..cc3bb9ca7 100644 --- a/crates/herdr-usage/src/providers/neuralwatt.rs +++ b/crates/herdr-usage/src/providers/neuralwatt.rs @@ -29,13 +29,12 @@ static META: Meta = Meta::new("neuralwatt", "Neuralwatt") Setting::new( "api_key", &["NEURALWATT_API_KEY"], - "A Neuralwatt API key, created or copied at \ - https://portal.neuralwatt.com/dashboard.", + "usage-neuralwatt-setting-api-key", ), Setting::new( "base_url", &["NEURALWATT_API_URL"], - "Optional HTTPS API URL for a proxy. Defaults to https://api.neuralwatt.com.", + "usage-neuralwatt-setting-base-url", ), ]); diff --git a/crates/herdr-usage/src/providers/notion.rs b/crates/herdr-usage/src/providers/notion.rs index 33207af3e..d99be092b 100644 --- a/crates/herdr-usage/src/providers/notion.rs +++ b/crates/herdr-usage/src/providers/notion.rs @@ -42,21 +42,8 @@ static META: Meta = Meta::new("notion", "Notion AI") .dashboard("https://app.notion.com") .status_page("https://status.notion.so") .settings(&[ - Setting::new( - "cookie", - &[], - "Your Notion session cookie. Sign in at https://app.notion.com, open Developer \ - Tools > Application > Cookies for https://app.notion.com (or notion.so), copy \ - the token_v2 cookie and paste it as \"token_v2=value\" (add \ - \"; notion_user_id=value\" for accounts signed in to several users).", - ), - Setting::new( - "workspace_id", - &[], - "The workspace (space) ID to read, dashed or not, for accounts in several \ - workspaces. Defaults to the first Business or Enterprise workspace. It is the \ - x-notion-space-id header of requests in Developer Tools > Network.", - ), + Setting::new("cookie", &[], "usage-notion-setting-cookie"), + Setting::new("workspace_id", &[], "usage-notion-setting-workspace-id"), ]); impl Service for Notion { diff --git a/crates/herdr-usage/src/providers/nous.rs b/crates/herdr-usage/src/providers/nous.rs index 65fb92d44..c3b0cb349 100644 --- a/crates/herdr-usage/src/providers/nous.rs +++ b/crates/herdr-usage/src/providers/nous.rs @@ -38,15 +38,12 @@ static META: Meta = Meta::new("nous", "Nous Portal") Setting::new( "token", &["NOUS_PORTAL_ACCESS_TOKEN"], - "A Nous Portal OAuth access token, used instead of Hermes Agent's login in \ - ~/.hermes/auth.json (sign in with `hermes auth add nous`). It lasts about an \ - hour; portal API keys are not accepted.", + "usage-nous-setting-token", ), Setting::new( "base_url", &["NOUS_PORTAL_BASE_URL", "HERMES_PORTAL_BASE_URL"], - "An HTTPS portal origin such as https://portal.nousresearch.com, for a preview \ - deployment. Plain HTTP is refused.", + "usage-nous-setting-base-url", ), ]); diff --git a/crates/herdr-usage/src/providers/ollama.rs b/crates/herdr-usage/src/providers/ollama.rs index 916fbbbf7..1373c60ff 100644 --- a/crates/herdr-usage/src/providers/ollama.rs +++ b/crates/herdr-usage/src/providers/ollama.rs @@ -59,19 +59,11 @@ pub(crate) struct Ollama; static META: Meta = Meta::new("ollama", "Ollama") .dashboard("https://ollama.com/settings") .settings(&[ - Setting::new( - "cookie", - &[], - "The ollama.com browser session, needed for quota. Sign in at \ - https://ollama.com/signin, open Developer Tools → Application → Cookies → \ - https://ollama.com, copy the session cookie (wos-session, or __Secure-session \ - on older sign-ins), and paste it as \"name=value\".", - ), + Setting::new("cookie", &[], "usage-ollama-setting-cookie"), Setting::new( "api_key", &["OLLAMA_API_KEY", "OLLAMA_KEY"], - "An Ollama API key from https://ollama.com/settings/keys. It only verifies \ - access: Ollama shows quota on its website, not through the API.", + "usage-ollama-setting-api-key", ), ]); diff --git a/crates/herdr-usage/src/providers/openai.rs b/crates/herdr-usage/src/providers/openai.rs index 13b3a906a..d01bc5f1c 100644 --- a/crates/herdr-usage/src/providers/openai.rs +++ b/crates/herdr-usage/src/providers/openai.rs @@ -54,27 +54,22 @@ static META: Meta = Meta::new("openai", "OpenAI") Setting::new( "api_key", &["OPENAI_ADMIN_KEY", "OPENAI_API_KEY"], - "An organization Admin API key (sk-admin-...), created by an organization owner \ - at https://platform.openai.com/settings/organization/admin-keys. Project and \ - service-account keys cannot read organization usage.", + "usage-openai-setting-api-key", ), Setting::new( "project_id", &["OPENAI_PROJECT_ID"], - "Optional. A project ID (proj_...) from \ - https://platform.openai.com/settings/organization/projects to limit spend and \ - usage to that project.", + "usage-openai-setting-project-id", ), Setting::new( "history_days", &["OPENAI_HISTORY_DAYS"], - "Optional. How many days of spend to total, 1 to 365. Defaults to 30.", + "usage-openai-setting-history-days", ), Setting::new( "balance_fallback", &["OPENAI_ALLOW_BALANCE_FALLBACK"], - "Optional. Set to 1 to show the legacy prepaid credit balance when the key \ - cannot read organization usage (older user API keys). Ignored with project_id.", + "usage-openai-setting-balance-fallback", ), ]); diff --git a/crates/herdr-usage/src/providers/opencode.rs b/crates/herdr-usage/src/providers/opencode.rs index 580b45006..c6aae9dde 100644 --- a/crates/herdr-usage/src/providers/opencode.rs +++ b/crates/herdr-usage/src/providers/opencode.rs @@ -48,20 +48,11 @@ pub(crate) struct Opencode; static META: Meta = Meta::new("opencode", "OpenCode") .dashboard("https://opencode.ai/auth") .settings(&[ - Setting::new( - "cookie", - &[], - "Your opencode.ai session. Sign in at https://opencode.ai/auth, open Developer \ - Tools > Application > Cookies > https://opencode.ai, and copy the auth and \ - __Host-console_session cookies (either one alone also works). Paste them as \ - \"auth=value; __Host-console_session=value\".", - ), + Setting::new("cookie", &[], "usage-opencode-setting-cookie"), Setting::new( "workspace_id", &["CODEXBAR_OPENCODE_WORKSPACE_ID"], - "Optional. The workspace to read, as a wrk_... ID or the \ - https://opencode.ai/workspace/... URL of its dashboard. Defaults to the first \ - workspace of the account.", + "usage-opencode-setting-workspace-id", ), ]); diff --git a/crates/herdr-usage/src/providers/opencodego.rs b/crates/herdr-usage/src/providers/opencodego.rs index 306fccffd..119f73527 100644 --- a/crates/herdr-usage/src/providers/opencodego.rs +++ b/crates/herdr-usage/src/providers/opencodego.rs @@ -46,26 +46,13 @@ static META: Meta = Meta::new("opencodego", "OpenCode Go") Setting::new( "api_key", &["OPENCODE_API_KEY"], - "An OpenCode API key for the account with the Go subscription, created at \ - https://opencode.ai/auth under API Keys. Preferred over the cookie. When \ - unset, the key `opencode auth login` saved in \ - ~/.local/share/opencode/auth.json is used.", - ), - Setting::new( - "cookie", - &[], - "Your opencode.ai session, used when no API key is set. Sign in at \ - https://opencode.ai/auth, open Developer Tools > Application > Cookies > \ - https://opencode.ai, and copy the __Host-console_session and auth cookies \ - (either one alone also works). Paste them as \ - \"__Host-console_session=value; auth=value\".", + "usage-opencodego-setting-api-key", ), + Setting::new("cookie", &[], "usage-opencodego-setting-cookie"), Setting::new( "workspace_id", &["CODEXBAR_OPENCODE_WORKSPACE_ID"], - "Optional, for the cookie sign-in. The workspace to read, as a wrk_... or \ - org_... ID or its https://opencode.ai/console/... URL. Defaults to the first \ - workspace of the account.", + "usage-opencodego-setting-workspace-id", ), ]); diff --git a/crates/herdr-usage/src/providers/openrouter.rs b/crates/herdr-usage/src/providers/openrouter.rs index c90ebc9b3..4f7cac1ac 100644 --- a/crates/herdr-usage/src/providers/openrouter.rs +++ b/crates/herdr-usage/src/providers/openrouter.rs @@ -36,29 +36,27 @@ static META: Meta = Meta::new("openrouter", "OpenRouter") Setting::new( "api_key", &["OPENROUTER_API_KEY"], - "An OpenRouter API key (sk-or-v1-…) from https://openrouter.ai/settings/keys. \ - A Management API key also works and adds the last 30 days of activity.", + "usage-openrouter-setting-api-key", ), Setting::new( "management_api_key", &["OPENROUTER_MANAGEMENT_API_KEY"], - "Optional Management API key from https://openrouter.ai/settings/management-keys, \ - used only for the last 30 days of account activity.", + "usage-openrouter-setting-management-api-key", ), Setting::new( "base_url", &["OPENROUTER_API_URL"], - "Optional HTTPS API URL for a proxy. Defaults to https://openrouter.ai/api/v1.", + "usage-openrouter-setting-base-url", ), Setting::new( "http_referer", &["OPENROUTER_HTTP_REFERER"], - "Optional client URL sent as the HTTP-Referer header.", + "usage-openrouter-setting-http-referer", ), Setting::new( "x_title", &["OPENROUTER_X_TITLE"], - "Optional client title sent as the X-Title header. Defaults to Herdr.", + "usage-openrouter-setting-x-title", ), ]); diff --git a/crates/herdr-usage/src/providers/perplexity.rs b/crates/herdr-usage/src/providers/perplexity.rs index 9e91cb992..8b06724fb 100644 --- a/crates/herdr-usage/src/providers/perplexity.rs +++ b/crates/herdr-usage/src/providers/perplexity.rs @@ -52,16 +52,12 @@ static META: Meta = Meta::new("perplexity", "Perplexity") Setting::new( "cookie", &["PERPLEXITY_COOKIE"], - "The perplexity.ai web session. Sign in at https://www.perplexity.ai, open \ - Developer Tools → Application → Cookies → https://www.perplexity.ai, copy the \ - session cookie (__Secure-next-auth.session-token, or __Secure-authjs.session-token \ - on newer sign-ins), and paste it as \"name=value\".", + "usage-perplexity-setting-cookie", ), Setting::new( "session_token", &["PERPLEXITY_SESSION_TOKEN"], - "Alternatively, only the value of that session cookie; each session cookie name \ - is tried in turn.", + "usage-perplexity-setting-session-token", ), ]); diff --git a/crates/herdr-usage/src/providers/poe.rs b/crates/herdr-usage/src/providers/poe.rs index f13e37d8c..451b76c7a 100644 --- a/crates/herdr-usage/src/providers/poe.rs +++ b/crates/herdr-usage/src/providers/poe.rs @@ -33,7 +33,7 @@ static META: Meta = Meta::new("poe", "Poe") .settings(&[Setting::new( "api_key", &["POE_API_KEY"], - "A Poe API key, created or copied at https://poe.com/api/keys.", + "usage-poe-setting-api-key", )]); impl Service for Poe { diff --git a/crates/herdr-usage/src/providers/qoder.rs b/crates/herdr-usage/src/providers/qoder.rs index b21e3bf4e..f91776b81 100644 --- a/crates/herdr-usage/src/providers/qoder.rs +++ b/crates/herdr-usage/src/providers/qoder.rs @@ -27,20 +27,8 @@ pub(crate) struct Qoder; static META: Meta = Meta::new("qoder", "Qoder") .dashboard("https://qoder.com/account/usage") .settings(&[ - Setting::new( - "cookie", - &[], - "Your Qoder browser session. Sign in at https://qoder.com/account/usage (or \ - https://qoder.com.cn/account/usage), open Developer Tools > Application > \ - Cookies for qoder.com (or qoder.com.cn), and copy every cookie of the site as \ - one header: \"name=value; name2=value2\".", - ), - Setting::new( - "site", - &[], - "Which Qoder site the account belongs to: \"global\" for qoder.com (the \ - default) or \"china\" for qoder.com.cn.", - ), + Setting::new("cookie", &[], "usage-qoder-setting-cookie"), + Setting::new("site", &[], "usage-qoder-setting-site"), ]); impl Service for Qoder { diff --git a/crates/herdr-usage/src/providers/qwencloud.rs b/crates/herdr-usage/src/providers/qwencloud.rs index b40169778..c8f8e0fd6 100644 --- a/crates/herdr-usage/src/providers/qwencloud.rs +++ b/crates/herdr-usage/src/providers/qwencloud.rs @@ -48,20 +48,9 @@ static META: Meta = Meta::new("qwencloud", "Qwen Cloud") Setting::new( "cookie", &["QWEN_CLOUD_COOKIE"], - "Your Qwen Cloud console session. Sign in at \ - https://home.qwencloud.com/billing/subscription/token-plan-individual, open \ - Developer Tools > Application > Cookies > https://home.qwencloud.com, and copy \ - the login ticket (login_qwencloud_ticket or login_aliyunid_ticket) together with \ - login_aliyunid_csrf and cna. Paste them as \"name=value; name2=value2\", or copy \ - the whole Cookie header of the data/api.json request from the Network tab.", - ), - Setting::new( - "sec_token", - &[], - "Optional. The console's sec_token, when it cannot be read from \ - /tool/user/info.json: in Developer Tools > Network, the sec_token form field of \ - the data/api.json request on the Token Plan page.", + "usage-qwencloud-setting-cookie", ), + Setting::new("sec_token", &[], "usage-qwencloud-setting-sec-token"), ]); impl Service for Qwencloud { diff --git a/crates/herdr-usage/src/providers/raycast.rs b/crates/herdr-usage/src/providers/raycast.rs index 46ae0f959..b28e2b9db 100644 --- a/crates/herdr-usage/src/providers/raycast.rs +++ b/crates/herdr-usage/src/providers/raycast.rs @@ -26,13 +26,7 @@ pub(crate) struct Raycast; static META: Meta = Meta::new("raycast", "Raycast") .dashboard("https://www.raycast.com/settings") - .settings(&[Setting::new( - "cookie", - &[], - "Sign in at https://www.raycast.com/settings, open Developer Tools > Application > \ - Cookies > https://www.raycast.com, and copy __raycast_session (and csrf_token when \ - present) as one \"__raycast_session=value; csrf_token=value\" header.", - )]); + .settings(&[Setting::new("cookie", &[], "usage-raycast-setting-cookie")]); impl Service for Raycast { fn meta(&self) -> &'static Meta { diff --git a/crates/herdr-usage/src/providers/replicate.rs b/crates/herdr-usage/src/providers/replicate.rs index d00d1f92e..1fa3a8b86 100644 --- a/crates/herdr-usage/src/providers/replicate.rs +++ b/crates/herdr-usage/src/providers/replicate.rs @@ -28,10 +28,7 @@ static META: Meta = Meta::new("replicate", "Replicate") .settings(&[Setting::new( "cookie", &[], - "Sign in at https://replicate.com/account/billing, open Developer Tools > \ - Application > Cookies > https://replicate.com, and copy sessionid (and csrftoken \ - when present) as one \"sessionid=value; csrftoken=value\" header. An API token \ - does not work here.", + "usage-replicate-setting-cookie", )]); impl Service for Replicate { diff --git a/crates/herdr-usage/src/providers/sakana.rs b/crates/herdr-usage/src/providers/sakana.rs index 4b2a69b69..f0a01cf90 100644 --- a/crates/herdr-usage/src/providers/sakana.rs +++ b/crates/herdr-usage/src/providers/sakana.rs @@ -28,11 +28,7 @@ static META: Meta = Meta::new("sakana", "Sakana AI") .settings(&[Setting::new( "cookie", &["SAKANA_COOKIE"], - "Sign in to https://console.sakana.ai, open Developer Tools > Application > \ - Cookies for https://console.sakana.ai, and copy every cookie there (the \ - sign-in session cookies are required). Paste them as \"name=value; \ - name2=value2\"; the Cookie header of a request to console.sakana.ai/billing \ - in the Network tab also works.", + "usage-sakana-setting-cookie", )]); impl Service for Sakana { diff --git a/crates/herdr-usage/src/providers/stepfun.rs b/crates/herdr-usage/src/providers/stepfun.rs index 1f3c704a9..df333bc6d 100644 --- a/crates/herdr-usage/src/providers/stepfun.rs +++ b/crates/herdr-usage/src/providers/stepfun.rs @@ -32,26 +32,9 @@ pub(crate) struct Stepfun; static META: Meta = Meta::new("stepfun", "StepFun") .dashboard("https://platform.stepfun.com/plan-usage") .settings(&[ - Setting::new( - "token", - &["STEPFUN_TOKEN"], - "Your StepFun Oasis-Token. Sign in at https://platform.stepfun.com, open \ - Developer Tools > Application > Cookies for platform.stepfun.com, and copy the \ - value of the Oasis-Token cookie. Set webid too.", - ), - Setting::new( - "webid", - &[], - "The value of the Oasis-Webid cookie next to Oasis-Token on \ - platform.stepfun.com. StepFun rejects a token sent with another device's web id.", - ), - Setting::new( - "cookie", - &[], - "Instead of token and webid: sign in at https://platform.stepfun.com, open \ - Developer Tools > Application > Cookies for platform.stepfun.com, and copy the \ - Oasis-Token and Oasis-Webid cookies as \"Oasis-Token=…; Oasis-Webid=…\".", - ), + Setting::new("token", &["STEPFUN_TOKEN"], "usage-stepfun-setting-token"), + Setting::new("webid", &[], "usage-stepfun-setting-webid"), + Setting::new("cookie", &[], "usage-stepfun-setting-cookie"), ]); impl Service for Stepfun { diff --git a/crates/herdr-usage/src/providers/sub2api.rs b/crates/herdr-usage/src/providers/sub2api.rs index d1bfc0e55..6d51fcd0a 100644 --- a/crates/herdr-usage/src/providers/sub2api.rs +++ b/crates/herdr-usage/src/providers/sub2api.rs @@ -23,14 +23,12 @@ static META: Meta = Meta::new("sub2api", "sub2api").settings(&[ Setting::new( "api_key", &["SUB2API_API_KEY"], - "A sub2api group API key (sk-…), created in your sub2api deployment's dashboard \ - under API keys. Usage is scoped to that key's group.", + "usage-sub2api-setting-api-key", ), Setting::new( "base_url", &["SUB2API_BASE_URL"], - "Your sub2api deployment, e.g. https://sub2api.example.com. HTTPS is required, \ - except plain HTTP on a loopback address such as http://127.0.0.1:8080.", + "usage-sub2api-setting-base-url", ), ]); diff --git a/crates/herdr-usage/src/providers/synthetic.rs b/crates/herdr-usage/src/providers/synthetic.rs index ba6b24f56..9774531e5 100644 --- a/crates/herdr-usage/src/providers/synthetic.rs +++ b/crates/herdr-usage/src/providers/synthetic.rs @@ -26,8 +26,7 @@ static META: Meta = Meta::new("synthetic", "Synthetic") .settings(&[Setting::new( "api_key", &["SYNTHETIC_API_KEY"], - "A Synthetic API key, created as described in \ - https://dev.synthetic.new/docs/api/getting-started.", + "usage-synthetic-setting-api-key", )]); impl Service for Synthetic { diff --git a/crates/herdr-usage/src/providers/t3chat.rs b/crates/herdr-usage/src/providers/t3chat.rs index 5851abcc5..3c4b7fd5e 100644 --- a/crates/herdr-usage/src/providers/t3chat.rs +++ b/crates/herdr-usage/src/providers/t3chat.rs @@ -30,14 +30,7 @@ pub(crate) struct T3chat; static META: Meta = Meta::new("t3chat", "T3 Chat") .dashboard("https://t3.chat/settings/customization") - .settings(&[Setting::new( - "cookie", - &[], - "Your t3.chat browser session. Sign in at https://t3.chat, open Developer Tools → \ - Application → Cookies → https://t3.chat, and copy every cookie there (the session \ - cookies change names between releases), pasted as one header: \ - \"name=value; name2=value2\". It is sent only to t3.chat.", - )]); + .settings(&[Setting::new("cookie", &[], "usage-t3chat-setting-cookie")]); impl Service for T3chat { fn meta(&self) -> &'static Meta { diff --git a/crates/herdr-usage/src/providers/typesafe.rs b/crates/herdr-usage/src/providers/typesafe.rs index 9d4c758ac..e1e0c1235 100644 --- a/crates/herdr-usage/src/providers/typesafe.rs +++ b/crates/herdr-usage/src/providers/typesafe.rs @@ -35,14 +35,7 @@ pub(crate) struct Typesafe; static META: Meta = Meta::new("typesafe", "TypeSafe") .dashboard(BILLING) - .settings(&[Setting::new( - "cookie", - &[], - "Sign in at https://console.typesafe.ai/settings/billing, open Developer Tools > \ - Application > Cookies > https://console.typesafe.ai, and copy every cookie there \ - as one \"name=value; name2=value2\" header. An inference API key does not work \ - here.", - )]); + .settings(&[Setting::new("cookie", &[], "usage-typesafe-setting-cookie")]); impl Service for Typesafe { fn meta(&self) -> &'static Meta { diff --git a/crates/herdr-usage/src/providers/v0.rs b/crates/herdr-usage/src/providers/v0.rs index 56f1715c4..f221a11ba 100644 --- a/crates/herdr-usage/src/providers/v0.rs +++ b/crates/herdr-usage/src/providers/v0.rs @@ -22,16 +22,8 @@ pub(crate) struct V0; static META: Meta = Meta::new("v0", "v0") .dashboard("https://v0.app/settings/billing") .settings(&[ - Setting::new( - "api_key", - &["V0_API_KEY"], - "A v0 Platform API key from https://v0.app/settings/keys.", - ), - Setting::new( - "scope", - &["V0_SCOPE"], - "Optional project ID or slug to read that scope's billing and rate limit.", - ), + Setting::new("api_key", &["V0_API_KEY"], "usage-v0-setting-api-key"), + Setting::new("scope", &["V0_SCOPE"], "usage-v0-setting-scope"), ]); impl Service for V0 { diff --git a/crates/herdr-usage/src/providers/venice.rs b/crates/herdr-usage/src/providers/venice.rs index cba601d4b..e7fa46779 100644 --- a/crates/herdr-usage/src/providers/venice.rs +++ b/crates/herdr-usage/src/providers/venice.rs @@ -35,18 +35,9 @@ static META: Meta = Meta::new("venice", "Venice") Setting::new( "api_key", &["VENICE_API_KEY", "VENICE_KEY"], - "A Venice API key from https://venice.ai/settings/api. It reads the USD and \ - DIEM API balance.", - ), - Setting::new( - "cookie", - &[], - "Used when no API key is set, for subscription credits. Sign in to \ - https://venice.ai, open Developer Tools > Application > Cookies for \ - https://venice.ai, and copy the __venice-auth.session-token cookie (or every \ - __venice-auth.session-token.N chunk when it is split). Paste them as \ - \"name=value; name2=value2\".", + "usage-venice-setting-api-key", ), + Setting::new("cookie", &[], "usage-venice-setting-cookie"), ]); impl Service for Venice { diff --git a/crates/herdr-usage/src/providers/vercel.rs b/crates/herdr-usage/src/providers/vercel.rs index d15485e99..229aaf36c 100644 --- a/crates/herdr-usage/src/providers/vercel.rs +++ b/crates/herdr-usage/src/providers/vercel.rs @@ -26,8 +26,7 @@ static META: Meta = Meta::new("vercel", "Vercel AI Gateway") .settings(&[Setting::new( "api_key", &["AI_GATEWAY_API_KEY"], - "An AI Gateway API key from the Vercel dashboard: AI Gateway > API Keys. The \ - balance is the key's team, so use a key of the team to show.", + "usage-vercel-setting-api-key", )]); impl Service for Vercel { diff --git a/crates/herdr-usage/src/providers/vertexai.rs b/crates/herdr-usage/src/providers/vertexai.rs index cc31471c8..172c8ee70 100644 --- a/crates/herdr-usage/src/providers/vertexai.rs +++ b/crates/herdr-usage/src/providers/vertexai.rs @@ -50,10 +50,7 @@ static META: Meta = Meta::new("vertexai", "Vertex AI") "GCLOUD_PROJECT", "CLOUDSDK_CORE_PROJECT", ], - "The Google Cloud project id whose Vertex AI quotas to read, when gcloud has none \ - set (`gcloud config set project PROJECT_ID`). Sign in with `gcloud auth \ - application-default login`; the account needs Cloud Monitoring read access \ - (roles/monitoring.viewer) on the project.", + "usage-vertexai-setting-project", )]); impl Service for Vertexai { diff --git a/crates/herdr-usage/src/providers/warp.rs b/crates/herdr-usage/src/providers/warp.rs index 0a2778044..af33fb46b 100644 --- a/crates/herdr-usage/src/providers/warp.rs +++ b/crates/herdr-usage/src/providers/warp.rs @@ -42,8 +42,7 @@ static META: Meta = Meta::new("warp", "Warp") .settings(&[Setting::new( "api_key", &["WARP_API_KEY", "WARP_TOKEN"], - "A Warp API key (wk-…). In Warp open your profile menu, then Settings → Platform → \ - API Keys, and create one. See https://docs.warp.dev/reference/cli/api-keys.", + "usage-warp-setting-api-key", )]); impl Service for Warp { diff --git a/crates/herdr-usage/src/providers/wayfinder.rs b/crates/herdr-usage/src/providers/wayfinder.rs index 25aa84e0f..9f4519bd2 100644 --- a/crates/herdr-usage/src/providers/wayfinder.rs +++ b/crates/herdr-usage/src/providers/wayfinder.rs @@ -29,9 +29,7 @@ pub(crate) struct Wayfinder; static META: Meta = Meta::new("wayfinder", "Wayfinder").settings(&[Setting::new( "base_url", &["WAYFINDER_GATEWAY_URL"], - "The Wayfinder gateway, started with `wayfinder-router serve`. Defaults to \ - http://127.0.0.1:8088; another URL must be HTTPS, or plain HTTP on a loopback \ - address.", + "usage-wayfinder-setting-base-url", )]); impl Service for Wayfinder { diff --git a/crates/herdr-usage/src/providers/windsurf.rs b/crates/herdr-usage/src/providers/windsurf.rs index 9ba687f0e..0e9c91a56 100644 --- a/crates/herdr-usage/src/providers/windsurf.rs +++ b/crates/herdr-usage/src/providers/windsurf.rs @@ -44,16 +44,7 @@ static META: Meta = Meta::new("windsurf", "Windsurf") .settings(&[Setting::new( "session", &[], - "The windsurf.com session bundle, for live quota. Sign in at \ - https://windsurf.com/profile in Chrome, open Developer Tools → Application → \ - Local Storage → https://windsurf.com (or https://app.devin.ai), and copy the values \ - of devin_session_token, devin_auth1_token, devin_account_id, and \ - devin_primary_org_id into one JSON object: \ - {\"devin_session_token\": \"…\", \"devin_auth1_token\": \"…\", \ - \"devin_account_id\": \"…\", \"devin_primary_org_id\": \"…\"}. Without it, the \ - plan the Windsurf editor cached at its last launch is shown; on the Mac \ - running Herdr only when windsurf is in show_providers, as macOS may ask before Herdr reads \ - another app's data.", + "usage-windsurf-setting-session", )]); impl Service for Windsurf { diff --git a/crates/herdr-usage/src/providers/xai.rs b/crates/herdr-usage/src/providers/xai.rs index 6f7744d71..ae97fe13b 100644 --- a/crates/herdr-usage/src/providers/xai.rs +++ b/crates/herdr-usage/src/providers/xai.rs @@ -28,15 +28,9 @@ static META: Meta = Meta::new("xai", "xAI") Setting::new( "api_key", &["XAI_MANAGEMENT_API_KEY"], - "A Management API key from https://console.x.ai under Settings > Management \ - Keys, with billing read access. Inference API keys are not accepted.", - ), - Setting::new( - "team_id", - &["XAI_TEAM_ID"], - "The xAI team ID to bill against, shown in the xAI Console URL and team \ - settings. The Management key must belong to this team.", + "usage-xai-setting-api-key", ), + Setting::new("team_id", &["XAI_TEAM_ID"], "usage-xai-setting-team-id"), ]); impl Service for Xai { diff --git a/crates/herdr-usage/src/providers/xkiro.rs b/crates/herdr-usage/src/providers/xkiro.rs index 80fb5ad84..2a3f7cdf8 100644 --- a/crates/herdr-usage/src/providers/xkiro.rs +++ b/crates/herdr-usage/src/providers/xkiro.rs @@ -32,8 +32,7 @@ static META: Meta = Meta::new("xkiro", "xKiro") .settings(&[Setting::new( "api_key", &["XKIRO_API_KEY"], - "An xKiro API key from your account at https://xkiro.com. It is sent only to \ - api.xkiro.com; reading usage spends no tokens.", + "usage-xkiro-setting-api-key", )]); impl Service for Xkiro { diff --git a/crates/herdr-usage/src/providers/zai.rs b/crates/herdr-usage/src/providers/zai.rs index 8a6a58274..7fb22638e 100644 --- a/crates/herdr-usage/src/providers/zai.rs +++ b/crates/herdr-usage/src/providers/zai.rs @@ -51,50 +51,29 @@ pub(crate) struct Zai; static META: Meta = Meta::new("zai", "z.ai / GLM") .dashboard("https://z.ai/manage-apikey/coding-plan/personal/my-plan") .settings(&[ - Setting::new( - "api_key", - &["Z_AI_API_KEY"], - "A z.ai API key from https://z.ai/manage-apikey/apikey-list, or for BigModel \ - China one from https://bigmodel.cn/usercenter/proj-mgmt/apikeys.", - ), - Setting::new( - "region", - &[], - "\"global\" for api.z.ai (the default) or \"bigmodel-cn\" for \ - open.bigmodel.cn, the China mainland GLM Coding Plan.", - ), - Setting::new( - "api_host", - &["Z_AI_API_HOST"], - "An HTTPS host that replaces the region's API host, e.g. a proxy.", - ), + Setting::new("api_key", &["Z_AI_API_KEY"], "usage-zai-setting-api-key"), + Setting::new("region", &[], "usage-zai-setting-region"), + Setting::new("api_host", &["Z_AI_API_HOST"], "usage-zai-setting-api-host"), Setting::new( "quota_url", &["Z_AI_QUOTA_URL"], - "A full HTTPS URL that replaces the quota endpoint.", + "usage-zai-setting-quota-url", ), Setting::new( "balance_url", &["Z_AI_BALANCE_URL"], - "A full HTTPS URL that replaces the BigModel China balance endpoint.", - ), - Setting::new( - "usage_scope", - &[], - "\"personal\" (the default) or \"team\" for BigModel team usage, which also \ - needs organization and project.", + "usage-zai-setting-balance-url", ), + Setting::new("usage_scope", &[], "usage-zai-setting-usage-scope"), Setting::new( "organization", &["Z_AI_BIGMODEL_ORGANIZATION"], - "For team usage: open https://bigmodel.cn/coding-plan/team/usage-stats, pick \ - the team, and in Developer Tools > Network copy the Bigmodel-Organization \ - request header of the quota/limit request.", + "usage-zai-setting-organization", ), Setting::new( "project", &["Z_AI_BIGMODEL_PROJECT"], - "For team usage: the Bigmodel-Project request header of the same request.", + "usage-zai-setting-project", ), ]); diff --git a/crates/herdr-usage/src/providers/zed.rs b/crates/herdr-usage/src/providers/zed.rs index 07f9baf1c..dcd7f860b 100644 --- a/crates/herdr-usage/src/providers/zed.rs +++ b/crates/herdr-usage/src/providers/zed.rs @@ -41,14 +41,7 @@ pub(crate) struct Zed; static META: Meta = Meta::new("zed", "Zed") .dashboard("https://zed.dev/account") .status_page("https://status.zed.dev") - .settings(&[Setting::new( - "cookie", - &[], - "The zed.dev browser session, for token spend. Sign in at https://zed.dev in your \ - browser (signing in inside the editor is not enough), open Developer Tools → \ - Application → Cookies → https://zed.dev, copy the zed.session cookie, and paste it \ - as \"zed.session=value\".", - )]); + .settings(&[Setting::new("cookie", &[], "usage-zed-setting-cookie")]); impl Service for Zed { fn meta(&self) -> &'static Meta { diff --git a/crates/herdr-usage/src/providers/zenmux.rs b/crates/herdr-usage/src/providers/zenmux.rs index 551d1e21e..ac679df2c 100644 --- a/crates/herdr-usage/src/providers/zenmux.rs +++ b/crates/herdr-usage/src/providers/zenmux.rs @@ -24,8 +24,7 @@ static META: Meta = Meta::new("zenmux", "ZenMux") .settings(&[Setting::new( "api_key", &["ZENMUX_MANAGEMENT_API_KEY"], - "A Management API key created at https://zenmux.ai/platform/management. \ - Normal inference API keys are rejected by the usage endpoints.", + "usage-zenmux-setting-api-key", )]); impl Service for Zenmux { diff --git a/crates/herdr-usage/src/providers/zoommate.rs b/crates/herdr-usage/src/providers/zoommate.rs index fb99b91aa..8d73513d2 100644 --- a/crates/herdr-usage/src/providers/zoommate.rs +++ b/crates/herdr-usage/src/providers/zoommate.rs @@ -36,23 +36,8 @@ static META: Meta = Meta::new("zoommate", "ZoomMate") .dashboard("https://zoommate.zoom.us/#/?settings=credit-usage") .status_page("https://www.zoomstatus.com") .settings(&[ - Setting::new( - "cookie", - &[], - "Your Zoom session cookies. Sign in at https://zoommate.zoom.us, open Developer \ - Tools > Application > Cookies for https://zoommate.zoom.us (Zoom's sign-in \ - cookies such as _zm_ssid, _zm_lang and cf_clearance live on zoom.us), and \ - paste them all as \"name=value; name2=value2\". They are exchanged for a \ - short-lived token on every refresh.", - ), - Setting::new( - "token", - &[], - "A bearer token instead of cookies: on https://zoommate.zoom.us open Developer \ - Tools > Network, reload the AI credit usage page, select the credits/status \ - request and copy its Authorization header without \"Bearer \". It expires \ - after about an hour.", - ), + Setting::new("cookie", &[], "usage-zoommate-setting-cookie"), + Setting::new("token", &[], "usage-zoommate-setting-token"), ]); impl Service for Zoommate { diff --git a/crates/herdr-usage/src/service.rs b/crates/herdr-usage/src/service.rs index c1a54b53a..48e4ecb37 100644 --- a/crates/herdr-usage/src/service.rs +++ b/crates/herdr-usage/src/service.rs @@ -16,14 +16,21 @@ use std::time::{Duration, SystemTime}; pub struct Setting { pub name: &'static str, pub env: &'static [&'static str], - /// What the value is and where to find it, e.g. which cookie to copy - /// from which site's developer tools. - pub help: &'static str, + /// The Fluent message, in the app's `locales/*/usage-.ftl`, saying + /// what the value is and where to find it, e.g. which cookie to copy + /// from which site's developer tools. Crates below the app have no + /// messages (docs/i18n.md), so the app writes it in the shown language + /// and generates the example config's comments from its English. + pub message: &'static str, } impl Setting { - pub const fn new(name: &'static str, env: &'static [&'static str], help: &'static str) -> Self { - Self { name, env, help } + pub const fn new( + name: &'static str, + env: &'static [&'static str], + message: &'static str, + ) -> Self { + Self { name, env, message } } } diff --git a/crates/herdr-usage/src/settings.rs b/crates/herdr-usage/src/settings.rs index 8dbab2257..52cac3be4 100644 --- a/crates/herdr-usage/src/settings.rs +++ b/crates/herdr-usage/src/settings.rs @@ -103,77 +103,3 @@ impl UsageConfig { self.hide_providers.iter().any(|id| id == provider.id()) } } - -#[cfg(test)] -const DOCS_START: &str = "# --- usage providers: generated from each provider's settings ---\n"; -#[cfg(test)] -const DOCS_END: &str = "# --- end usage providers ---\n"; - -/// The example config's provider section: what each provider reads and how -/// to find it. A test keeps the checked-in example equal to this. -#[cfg(test)] -pub(super) fn example_docs() -> String { - let mut docs = String::from(DOCS_START); - let (configurable, automatic): (Vec<_>, Vec<_>) = - registry::all().partition(|provider| !provider.service().meta().settings.is_empty()); - docs.push_str("#\n# Found from the agent's own sign-in on the host, with nothing to set:\n"); - for line in wrap( - &automatic - .iter() - .map(|provider| provider.id()) - .collect::>() - .join(", "), - 76, - ) { - docs.push_str(&format!("# {line}\n")); - } - for provider in configurable { - let service = provider.service(); - docs.push_str(&format!( - "#\n# {} ({})\n# [usage.providers.{}]\n", - service.meta().name, - service.meta().id, - service.meta().id - )); - for setting in service.meta().settings { - for line in wrap(setting.help, 76) { - docs.push_str(&format!("# {line}\n")); - } - let variables = if setting.env.is_empty() { - String::new() - } else { - format!(" # or {}", setting.env.join(", ")) - }; - docs.push_str(&format!("# {} = \"\"{variables}\n", setting.name)); - } - } - docs.push_str(DOCS_END); - docs -} - -/// The generated section as it stands in `text`, markers included. -#[cfg(test)] -pub(super) fn docs_in(text: &str) -> Option<&str> { - let start = text.find(DOCS_START)?; - let end = text[start..].find(DOCS_END)? + start + DOCS_END.len(); - Some(&text[start..end]) -} - -#[cfg(test)] -fn wrap(text: &str, width: usize) -> Vec { - let mut lines = Vec::new(); - let mut line = String::new(); - for word in text.split_whitespace() { - if !line.is_empty() && line.len() + 1 + word.len() > width { - lines.push(std::mem::take(&mut line)); - } - if !line.is_empty() { - line.push(' '); - } - line.push_str(word); - } - if !line.is_empty() { - lines.push(line); - } - lines -} diff --git a/crates/herdr-usage/src/tests/provider_data.rs b/crates/herdr-usage/src/tests/provider_data.rs index 1f8664364..94260bc05 100644 --- a/crates/herdr-usage/src/tests/provider_data.rs +++ b/crates/herdr-usage/src/tests/provider_data.rs @@ -173,11 +173,16 @@ fn every_provider_is_registered_once_with_its_icon() { assert!(url.starts_with("https://"), "{url}"); } for setting in provider.service().meta().settings { + // The app checks each message's English; its ID names the + // provider's own file. assert!( - !setting.help.trim().is_empty(), - "{}.{}", + setting + .message + .starts_with(&format!("usage-{}-setting-", provider.id())), + "{}.{}: {}", provider.id(), - setting.name + setting.name, + setting.message ); } } diff --git a/crates/herdr-usage/src/tests/provider_settings.rs b/crates/herdr-usage/src/tests/provider_settings.rs index 02d479a1c..f5e221b08 100644 --- a/crates/herdr-usage/src/tests/provider_settings.rs +++ b/crates/herdr-usage/src/tests/provider_settings.rs @@ -38,34 +38,6 @@ fn config_ignores_unknown_providers_and_settings() { ); } -/// Regenerate with `HERDR_BLESS_EXAMPLE=1 cargo test example_config_documents`. -#[test] -fn example_config_documents_every_provider_setting() { - let path = concat!( - env!("CARGO_MANIFEST_DIR"), - "/../herdr-gpui/config-gpui.example.toml" - ); - // A Windows checkout may carry CRLF line endings. - let text = std::fs::read_to_string(path).unwrap().replace("\r\n", "\n"); - let expected = super::super::settings::example_docs(); - if std::env::var_os("HERDR_BLESS_EXAMPLE").is_some() { - let updated = match super::super::settings::docs_in(&text) { - Some(current) => text.replace(current, &expected), - None => format!( - "{}\n{expected}", - text.trim_end_matches('\n').to_owned() + "\n" - ), - }; - std::fs::write(path, updated).unwrap(); - return; - } - assert_eq!( - super::super::settings::docs_in(&text), - Some(expected.as_str()), - "config-gpui.example.toml is stale; rerun with HERDR_BLESS_EXAMPLE=1" - ); -} - #[test] fn settings_come_from_this_machines_config() { let settings = ProviderSettings::default().with("api_key", "config-key"); diff --git a/docs/i18n.md b/docs/i18n.md index a78a7c6fc..b341d519b 100644 --- a/docs/i18n.md +++ b/docs/i18n.md @@ -95,6 +95,10 @@ Crates below the app have no messages, so they return typed values and the app writes them out: seconds elapsed for `ago` ("3 min ago", "yesterday"), a cloud job's `Step` for `cloud::status`, and an Azure size's `VmSpec`, `SandboxSize`, `Price`, or `DeviceSize` for Settings. +A crate may also name a message by ID, as `herdr-commands` does for each +command and `herdr-usage` for each provider setting's help +(`usage-.ftl`); the app shows it with `i18n::message`, and a test +checks every ID the crate names with `i18n::takes`. ### Errors @@ -292,7 +296,7 @@ app still shows English literals: author and stay as Luau and the mod host write them. - Text `herdr-usage` writes for a service: the titles and labels of a report's facts and shares, balance labels and count units ("credits"), - windows it names itself, and each provider setting's help. The app + and windows it names itself. The app localizes the typed values (limit kinds, countdowns, pace, balances) itself, in `usage/text.rs`. - The remaining hand-built relative times (the orchestrator's, Security's, From 6a098ca21060a230cd6bc6cf39f68b2e0398280b Mon Sep 17 00:00:00 2001 From: Fabien Penso Date: Sat, 31 Oct 2026 22:19:36 -0700 Subject: [PATCH 02/10] refactor(i18n): name plan usage report words as typed text herdr-usage reports carried English Strings for section titles, fact labels and values, share labels, balance labels, window names, and the account line, and count units as English words, which the usage panel showed whatever the language. herdr_usage::Text is now the words a report shows: a message ID with arguments, which the app writes with usage::text::words, or the service's own words. Count units are a typed Count the app writes with a plural (usage-amount-*). Wayfinder names all of its words in usage-wayfinder.ftl; the other providers keep their English through a transitional From<&str>/From for Text and pair/pairs/share/shares helpers, which go once the last of them names its words. A crate below the app cannot use fl!'s compile-time check, so an app test scans herdr-usage for Text::message IDs and the arguments chained to them and checks each against the English messages. The en-XA leak test now draws facts, shares, and a credits balance a provider names. --- .../herdr-gpui/locales/en-US/usage-report.ftl | 6 + .../locales/en-US/usage-wayfinder.ftl | 43 ++++ crates/herdr-gpui/locales/en-US/usage.ftl | 20 ++ crates/herdr-gpui/src/control/server/hosts.rs | 16 +- crates/herdr-gpui/src/usage.rs | 2 +- crates/herdr-gpui/src/usage/panel.rs | 12 +- crates/herdr-gpui/src/usage/section.rs | 2 +- crates/herdr-gpui/src/usage/tests.rs | 2 + crates/herdr-gpui/src/usage/tests/en_xa.rs | 39 +++- .../src/usage/tests/report_messages.rs | 108 +++++++++ crates/herdr-gpui/src/usage/tests/words.rs | 81 +++++++ crates/herdr-gpui/src/usage/text.rs | 101 +++++--- crates/herdr-gpui/src/usage/ui.rs | 21 +- crates/herdr-usage/src/lib.rs | 2 + crates/herdr-usage/src/model.rs | 71 ++++-- crates/herdr-usage/src/providers/abacus.rs | 13 +- crates/herdr-usage/src/providers/aixy.rs | 30 +-- crates/herdr-usage/src/providers/alibaba.rs | 25 +- .../src/providers/alibabatokenplan.rs | 13 +- .../src/providers/alibabatokenplan/tests.rs | 16 +- crates/herdr-usage/src/providers/amp.rs | 31 ++- .../herdr-usage/src/providers/antigravity.rs | 3 +- crates/herdr-usage/src/providers/augment.rs | 35 ++- .../herdr-usage/src/providers/azureopenai.rs | 8 +- crates/herdr-usage/src/providers/bedrock.rs | 2 + crates/herdr-usage/src/providers/bifrost.rs | 26 ++- .../src/providers/bifrost/tests.rs | 15 +- crates/herdr-usage/src/providers/chutes.rs | 21 +- crates/herdr-usage/src/providers/claude.rs | 17 +- .../herdr-usage/src/providers/claude/cswap.rs | 7 +- .../herdr-usage/src/providers/clawrouter.rs | 21 +- crates/herdr-usage/src/providers/codebuff.rs | 31 +-- .../herdr-usage/src/providers/coderabbit.rs | 18 +- crates/herdr-usage/src/providers/codex.rs | 18 +- .../herdr-usage/src/providers/commandcode.rs | 5 +- crates/herdr-usage/src/providers/copilot.rs | 31 ++- crates/herdr-usage/src/providers/cursor.rs | 31 ++- crates/herdr-usage/src/providers/deepgram.rs | 15 +- crates/herdr-usage/src/providers/deepinfra.rs | 8 +- crates/herdr-usage/src/providers/deepseek.rs | 8 +- crates/herdr-usage/src/providers/devin.rs | 13 +- crates/herdr-usage/src/providers/devpass.rs | 21 +- crates/herdr-usage/src/providers/doubao.rs | 7 +- .../herdr-usage/src/providers/elevenlabs.rs | 23 +- crates/herdr-usage/src/providers/factory.rs | 22 +- crates/herdr-usage/src/providers/fireworks.rs | 3 +- crates/herdr-usage/src/providers/gemini.rs | 25 +- crates/herdr-usage/src/providers/gitkraken.rs | 3 +- crates/herdr-usage/src/providers/grok.rs | 14 +- .../herdr-usage/src/providers/grok/tests.rs | 13 +- .../src/providers/grok/tests/prepaid.rs | 6 +- crates/herdr-usage/src/providers/groq.rs | 3 +- crates/herdr-usage/src/providers/helmcode.rs | 12 +- .../herdr-usage/src/providers/huggingface.rs | 26 ++- crates/herdr-usage/src/providers/hyper.rs | 14 +- crates/herdr-usage/src/providers/ibmbob.rs | 18 +- crates/herdr-usage/src/providers/jetbrains.rs | 8 +- crates/herdr-usage/src/providers/kilo.rs | 40 +++- crates/herdr-usage/src/providers/kimi.rs | 15 +- crates/herdr-usage/src/providers/kiro.rs | 37 ++- crates/herdr-usage/src/providers/litellm.rs | 11 +- crates/herdr-usage/src/providers/llmman.rs | 7 +- .../herdr-usage/src/providers/llmman/tests.rs | 11 +- crates/herdr-usage/src/providers/llmproxy.rs | 13 +- crates/herdr-usage/src/providers/longcat.rs | 10 +- crates/herdr-usage/src/providers/manus.rs | 12 +- crates/herdr-usage/src/providers/mimo.rs | 22 +- crates/herdr-usage/src/providers/minimax.rs | 27 ++- crates/herdr-usage/src/providers/mistral.rs | 22 +- crates/herdr-usage/src/providers/moonshot.rs | 3 +- crates/herdr-usage/src/providers/muse.rs | 16 +- .../herdr-usage/src/providers/neuralwatt.rs | 29 ++- crates/herdr-usage/src/providers/notion.rs | 17 +- crates/herdr-usage/src/providers/nous.rs | 32 ++- crates/herdr-usage/src/providers/ollama.rs | 24 +- crates/herdr-usage/src/providers/openai.rs | 28 ++- crates/herdr-usage/src/providers/opencode.rs | 2 +- .../herdr-usage/src/providers/opencodego.rs | 5 +- .../herdr-usage/src/providers/openrouter.rs | 36 +-- .../herdr-usage/src/providers/perplexity.rs | 16 +- crates/herdr-usage/src/providers/poe.rs | 7 +- crates/herdr-usage/src/providers/qoder.rs | 11 +- crates/herdr-usage/src/providers/qwencloud.rs | 11 +- crates/herdr-usage/src/providers/raycast.rs | 17 +- crates/herdr-usage/src/providers/replicate.rs | 3 +- crates/herdr-usage/src/providers/sakana.rs | 21 +- crates/herdr-usage/src/providers/stepfun.rs | 8 +- crates/herdr-usage/src/providers/sub2api.rs | 50 ++-- crates/herdr-usage/src/providers/synthetic.rs | 23 +- crates/herdr-usage/src/providers/t3chat.rs | 15 +- crates/herdr-usage/src/providers/typesafe.rs | 15 +- crates/herdr-usage/src/providers/v0.rs | 3 +- crates/herdr-usage/src/providers/venice.rs | 19 +- crates/herdr-usage/src/providers/vertexai.rs | 10 +- crates/herdr-usage/src/providers/warp.rs | 20 +- crates/herdr-usage/src/providers/wayfinder.rs | 133 +++++++---- crates/herdr-usage/src/providers/windsurf.rs | 31 ++- crates/herdr-usage/src/providers/xai.rs | 5 +- crates/herdr-usage/src/providers/xkiro.rs | 34 ++- crates/herdr-usage/src/providers/zai.rs | 18 +- crates/herdr-usage/src/providers/zed.rs | 38 ++- crates/herdr-usage/src/providers/zenmux.rs | 30 ++- crates/herdr-usage/src/providers/zoommate.rs | 10 +- crates/herdr-usage/src/tests/labels.rs | 7 +- crates/herdr-usage/src/tests/opencode_auth.rs | 3 +- crates/herdr-usage/src/tests/provider_data.rs | 3 +- crates/herdr-usage/src/text.rs | 220 ++++++++++++++++++ docs/i18n.md | 22 +- 108 files changed, 1810 insertions(+), 617 deletions(-) create mode 100644 crates/herdr-gpui/locales/en-US/usage-report.ftl create mode 100644 crates/herdr-gpui/src/usage/tests/report_messages.rs create mode 100644 crates/herdr-gpui/src/usage/tests/words.rs create mode 100644 crates/herdr-usage/src/text.rs diff --git a/crates/herdr-gpui/locales/en-US/usage-report.ftl b/crates/herdr-gpui/locales/en-US/usage-report.ftl new file mode 100644 index 000000000..396ee93de --- /dev/null +++ b/crates/herdr-gpui/locales/en-US/usage-report.ftl @@ -0,0 +1,6 @@ +# Words several plan usage providers' reports share, in the usage panel, +# the Usage section, and the status bar. A provider's own words are in its +# usage-.ftl. + +# A section's title: the service's usage figures. +usage-report-usage = Usage diff --git a/crates/herdr-gpui/locales/en-US/usage-wayfinder.ftl b/crates/herdr-gpui/locales/en-US/usage-wayfinder.ftl index 2a63a59a5..397b80ca6 100644 --- a/crates/herdr-gpui/locales/en-US/usage-wayfinder.ftl +++ b/crates/herdr-gpui/locales/en-US/usage-wayfinder.ftl @@ -5,3 +5,46 @@ ## service's own menu paths stay as written. usage-wayfinder-setting-base-url = The Wayfinder gateway, started with `wayfinder-router serve`. Defaults to http://127.0.0.1:8088; another URL must be HTTPS, or plain HTTP on a loopback address. + +## The report: the gateway's health, how it routed requests, and what that +## saved over the last 30 days. Route names and the gateway's own health +## word ($status, such as ok or degraded) stay as it sends them. + +# The account line: how many models the local gateway serves. +usage-wayfinder-account = { $models -> + [one] { $models } model · local gateway + *[other] { $models } models · local gateway + } +# The plan line while the gateway serves nothing live. +usage-wayfinder-offline = Offline mode +usage-wayfinder-dry-run = Dry run +# The plan line while some provider keys are missing; $missing counts them. +usage-wayfinder-degraded = { $missing -> + [0] Degraded + [one] Degraded, { $missing } key missing + *[other] Degraded, { $missing } keys missing + } +usage-wayfinder-local-gateway = Local gateway +usage-wayfinder-gateway = Gateway +# $status is the gateway's own word for its health; $mode says whether it +# runs offline, as a dry run, both, or live. +usage-wayfinder-gateway-state = { $status } · { $models -> + [one] { $models } model + *[other] { $models } models + }{ $mode -> + [offline] { " · " }offline + [dry-run] { " · " }dry run + [both] { " · " }offline · dry run + *[live] {""} + } +usage-wayfinder-routed = Routed +usage-wayfinder-saved = Saved, last 30 days +# $amount is the money saved, such as $4.12; $percent is how much cheaper +# than the costliest route, such as 73.3. +usage-wayfinder-saved-value = { $amount } · { $percent }% vs highest-cost route +# The same, for a gateway without prices, which saves in relative terms. +usage-wayfinder-saved-relative = { $percent }% vs highest-cost route +usage-wayfinder-decision = Avg decision +# $time is a number of milliseconds, such as 0.2. +usage-wayfinder-milliseconds = { $time } ms +usage-wayfinder-requests-by-route = Requests by route, last 30 days diff --git a/crates/herdr-gpui/locales/en-US/usage.ftl b/crates/herdr-gpui/locales/en-US/usage.ftl index 55fde2cdb..5d87a72fa 100644 --- a/crates/herdr-gpui/locales/en-US/usage.ftl +++ b/crates/herdr-gpui/locales/en-US/usage.ftl @@ -71,6 +71,26 @@ usage-pace = { $standing -> # $amount and $total are amounts with their unit, such as $12.30. usage-balance-of = { $amount } of { $total } +# A balance counted in a service's credits. $amount is the number as +# written, such as 1,250; $count is the same number, for the plural. +usage-amount-credits = { $count -> + [one] { $amount } credit + *[other] { $amount } credits + } +# A balance counted in a service's points; as usage-amount-credits. +usage-amount-points = { $count -> + [one] { $amount } point + *[other] { $amount } points + } +# A balance counted in code reviews; as usage-amount-credits. +usage-amount-reviews = { $count -> + [one] { $amount } review + *[other] { $amount } reviews + } +# A balance in a unit only one service uses, named as it names it ($unit), +# such as DIEM. +usage-amount-named = { $amount } { $unit } + # The panel. usage-dashboard = Usage Dashboard usage-status-page = Status Page diff --git a/crates/herdr-gpui/src/control/server/hosts.rs b/crates/herdr-gpui/src/control/server/hosts.rs index cd2988de1..34492e4d2 100644 --- a/crates/herdr-gpui/src/control/server/hosts.rs +++ b/crates/herdr-gpui/src/control/server/hosts.rs @@ -219,8 +219,20 @@ pub(super) fn usage_info(reading: &crate::usage::Reading, now: SystemTime) -> Us let report = reading.report.as_ref(); UsageInfo { provider: reading.provider.name().to_owned(), - account: report.and_then(|report| report.account.email.clone()), - plan: report.and_then(|report| report.account.plan.clone()), + account: report.and_then(|report| { + report + .account + .email + .as_ref() + .map(crate::usage::text::english) + }), + plan: report.and_then(|report| { + report + .account + .plan + .as_ref() + .map(crate::usage::text::english) + }), windows: report .map(|report| { report diff --git a/crates/herdr-gpui/src/usage.rs b/crates/herdr-gpui/src/usage.rs index 639df5e0f..179543506 100644 --- a/crates/herdr-gpui/src/usage.rs +++ b/crates/herdr-gpui/src/usage.rs @@ -9,7 +9,7 @@ mod icons; mod panel; mod render; mod section; -mod text; +pub(crate) mod text; mod ui; #[cfg(test)] diff --git a/crates/herdr-gpui/src/usage/panel.rs b/crates/herdr-gpui/src/usage/panel.rs index d6b77b4a7..f179d827e 100644 --- a/crates/herdr-gpui/src/usage/panel.rs +++ b/crates/herdr-gpui/src/usage/panel.rs @@ -148,11 +148,15 @@ impl HerdrWindow { .items_end() .text_color(rgb(theme.muted)) .children( - account - .email - .map(|email| div().max_w_full().truncate().child(email)), + account.email.map(|email| { + div().max_w_full().truncate().child(text::words(&email)) + }), ) - .children(account.plan.map(|plan| div().text_size(small).child(plan))), + .children( + account + .plan + .map(|plan| div().text_size(small).child(text::words(&plan))), + ), ), ); // A sign-in waiting on Keychain access is explained below instead. diff --git a/crates/herdr-gpui/src/usage/section.rs b/crates/herdr-gpui/src/usage/section.rs index 243ceca42..808b222a3 100644 --- a/crates/herdr-gpui/src/usage/section.rs +++ b/crates/herdr-gpui/src/usage/section.rs @@ -174,7 +174,7 @@ impl HerdrWindow { div() .text_size(small) .text_color(rgb(theme.muted)) - .child(plan) + .child(text::words(&plan)) })), ); if let Some(error) = reading.error.clone() { diff --git a/crates/herdr-gpui/src/usage/tests.rs b/crates/herdr-gpui/src/usage/tests.rs index 39b490828..f27750ab6 100644 --- a/crates/herdr-gpui/src/usage/tests.rs +++ b/crates/herdr-gpui/src/usage/tests.rs @@ -11,9 +11,11 @@ mod example_config; mod footer_fit; mod icons; mod keychain_grants; +mod report_messages; mod section; mod status_bar_room; mod text; +mod words; fn provider(id: &str) -> Provider { registry::find(id).unwrap() diff --git a/crates/herdr-gpui/src/usage/tests/en_xa.rs b/crates/herdr-gpui/src/usage/tests/en_xa.rs index 8cdefbedd..debcd21a6 100644 --- a/crates/herdr-gpui/src/usage/tests/en_xa.rs +++ b/crates/herdr-gpui/src/usage/tests/en_xa.rs @@ -14,8 +14,9 @@ use crate::{ }; use gpui::{VisualTestContext, point, px, size}; use herdr_usage::{ + Text, details::{ClaudeAccount, ClaudeAccountStatus, OpenaiDaily}, - model::{Balance, Unit}, + model::{Balance, Count, Section, Unit}, }; use std::time::{Duration, SystemTime}; @@ -41,8 +42,9 @@ fn fresh(seen: &mut Vec, drawn: Vec) -> Vec { const HOUR: Duration = Duration::from_secs(3600); /// A report with every kind of line the panel draws: a session window on -/// pace, a weekly one ahead of it, a window the service names, and a -/// balance with a total. +/// pace, a weekly one ahead of it, a window the service names, a balance +/// with a total, one a provider names counted in credits, and facts and +/// shares a provider names (Wayfinder's), with the route names it reports. fn full(provider: Provider) -> Report { let now = SystemTime::now(); let mut report = Report::new( @@ -65,7 +67,34 @@ fn full(provider: Provider) -> Report { report .balances .push(Balance::new("Credits", 12.3, Unit::Currency("USD".into())).out_of(50.)); - report + // A balance a provider names, counted in credits. + report.balances.push(Balance::new( + Text::message("usage-wayfinder-saved"), + 1_250., + Unit::Count(Count::Credits), + )); + report.with_sections([ + Section::Facts { + title: Text::message("usage-report-usage"), + facts: vec![ + ( + Text::message("usage-wayfinder-gateway"), + Text::message("usage-wayfinder-gateway-state") + .arg("status", "ok") + .arg("models", 2) + .arg("mode", "offline"), + ), + ( + Text::message("usage-wayfinder-decision"), + Text::message("usage-wayfinder-milliseconds").arg("time", "0.2"), + ), + ], + }, + Section::Shares { + title: Text::message("usage-wayfinder-requests-by-route"), + shares: vec![(Text::service("local"), 71.), (Text::service("cloud"), 29.)], + }, + ]) } /// A provider that reads a setting, so its panel shows how to set it up @@ -149,6 +178,8 @@ fn data(readings: &[Reading], host: &str) -> Vec { "three@example.com".into(), "Oct 28".into(), "Oct 29".into(), + "local".into(), + "cloud".into(), ]; for reading in readings { let meta = reading.provider.service().meta(); diff --git a/crates/herdr-gpui/src/usage/tests/report_messages.rs b/crates/herdr-gpui/src/usage/tests/report_messages.rs new file mode 100644 index 000000000..5069ccd6b --- /dev/null +++ b/crates/herdr-gpui/src/usage/tests/report_messages.rs @@ -0,0 +1,108 @@ +//! Every message `herdr-usage` names with `Text::message`, checked against +//! the English files with the arguments chained to it, since a crate below +//! the app cannot use `fl!`'s compile-time check. +use crate::i18n; +use std::path::Path; + +/// `(file, id, argument names)` for each `Text::message("…")` in `source`, +/// with the names of the `.arg("…", …)` calls chained right after it. +fn named(file: &str, source: &str) -> Vec<(String, String, Vec)> { + const CALL: &str = "Text::message("; + let mut found = Vec::new(); + let mut rest = source; + while let Some(start) = rest.find(CALL) { + rest = &rest[start + CALL.len()..]; + let Some(id) = literal(rest) else { + continue; + }; + let mut after = &rest[closing(rest)..]; + let mut args = Vec::new(); + while let Some(call) = after.trim_start().strip_prefix(".arg(") { + let Some(name) = literal(call) else { break }; + args.push(name.to_owned()); + after = &call[closing(call)..]; + } + found.push((file.to_owned(), id.to_owned(), args)); + } + found +} + +/// The string literal `text` starts with, after whitespace. +fn literal(text: &str) -> Option<&str> { + let text = text.trim_start().strip_prefix('"')?; + Some(&text[..text.find('"')?]) +} + +/// Where the call whose arguments `text` starts with ends, past its `)`. +fn closing(text: &str) -> usize { + let mut depth = 1; + let mut quoted = false; + for (index, char) in text.char_indices() { + match char { + '"' => quoted = !quoted, + '(' if !quoted => depth += 1, + ')' if !quoted => { + depth -= 1; + if depth == 0 { + return index + 1; + } + } + _ => {} + } + } + text.len() +} + +fn sources(dir: &Path, found: &mut Vec<(String, String, Vec)>) { + for entry in std::fs::read_dir(dir).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + sources(&path, found); + } else if path.extension().is_some_and(|extension| extension == "rs") { + let source = std::fs::read_to_string(&path).unwrap(); + found.extend(named(&path.display().to_string(), &source)); + } + } +} + +#[test] +fn every_message_herdr_usage_names_exists_with_its_arguments() { + let mut found = Vec::new(); + sources( + &Path::new(env!("CARGO_MANIFEST_DIR")).join("../herdr-usage/src"), + &mut found, + ); + for (file, id, mut args) in found { + let Some(takes) = i18n::takes(&id) else { + panic!("{file}: no English message {id}"); + }; + let mut takes = takes.to_vec(); + args.sort_unstable(); + takes.sort_unstable(); + assert_eq!(args, takes, "{file}: {id} takes these arguments"); + } +} + +#[test] +fn the_scan_reads_chained_arguments() { + let source = r#" + Text::message("usage-report-a"), + Text::message("usage-report-b") + .arg("count", models.len().max(1)) + .arg("name", Text::service(name)), + Text::message( "usage-report-c" ).to_owned() + "#; + let found = named("x.rs", source); + let ids: Vec<_> = found + .iter() + .map(|(_, id, args)| (id.as_str(), args.join(","))) + .collect(); + assert_eq!( + ids, + [ + ("usage-report-a", String::new()), + ("usage-report-b", "count,name".into()), + ("usage-report-c", String::new()), + ] + ); +} diff --git a/crates/herdr-gpui/src/usage/tests/words.rs b/crates/herdr-gpui/src/usage/tests/words.rs new file mode 100644 index 000000000..408a57783 --- /dev/null +++ b/crates/herdr-gpui/src/usage/tests/words.rs @@ -0,0 +1,81 @@ +//! The words a report names, written by the app: messages with their +//! arguments read in English as the provider used to write them, and counts +//! take their unit's plural. +use super::super::text; +use herdr_usage::{ + Text, + model::{Balance, Count, Unit}, +}; + +/// Drops Fluent's isolation marks around placeables. +fn plain(text: String) -> String { + text.replace(['\u{2068}', '\u{2069}'], "") +} + +fn english(words: Text) -> String { + plain(text::words(&words)) +} + +#[test] +fn the_services_words_stay_as_they_are() { + assert_eq!(english(Text::service("Pro · Team")), "Pro · Team"); +} + +#[test] +fn wayfinder_reads_as_it_did() { + let state = |status: &str, models: usize, mode: &str| { + english( + Text::message("usage-wayfinder-gateway-state") + .arg("status", status) + .arg("models", models) + .arg("mode", mode), + ) + }; + assert_eq!(state("degraded", 2, "live"), "degraded · 2 models"); + assert_eq!(state("ok", 1, "both"), "ok · 1 model · offline · dry run"); + assert_eq!(state("ok", 3, "offline"), "ok · 3 models · offline"); + assert_eq!(state("ok", 3, "dry-run"), "ok · 3 models · dry run"); + let degraded = + |missing: usize| english(Text::message("usage-wayfinder-degraded").arg("missing", missing)); + assert_eq!(degraded(0), "Degraded"); + assert_eq!(degraded(1), "Degraded, 1 key missing"); + assert_eq!(degraded(3), "Degraded, 3 keys missing"); + assert_eq!( + english(Text::message("usage-wayfinder-account").arg("models", 2)), + "2 models · local gateway" + ); + assert_eq!( + english( + Text::message("usage-wayfinder-saved-value") + .arg("amount", "$4.12") + .arg("percent", "73.3") + ), + "$4.12 · 73.3% vs highest-cost route" + ); + assert_eq!( + english(Text::message("usage-wayfinder-milliseconds").arg("time", "0.2")), + "0.2 ms" + ); +} + +#[test] +fn counts_take_their_units_plural() { + let amount = |amount: f64, count: Count| { + plain(text::balance(&Balance::new( + Text::service(""), + amount, + Unit::Count(count), + ))) + }; + assert_eq!(amount(1., Count::Credits), "1 credit"); + assert_eq!(amount(1_250., Count::Credits), "1,250 credits"); + assert_eq!(amount(1., Count::Points), "1 point"); + assert_eq!(amount(2.5, Count::Points), "2.50 points"); + assert_eq!(amount(25., Count::Reviews), "25 reviews"); + assert_eq!(amount(12., Count::Named("DIEM".into())), "12 DIEM"); + // Every plural reads as the crate's own English does. + for count in [Count::Credits, Count::Points, Count::Reviews] { + let balance = Balance::new(Text::service(""), 1_250_000., Unit::Count(count.clone())); + assert_eq!(plain(text::balance(&balance)), balance.amount_text()); + } +} diff --git a/crates/herdr-gpui/src/usage/text.rs b/crates/herdr-gpui/src/usage/text.rs index d6982390a..ebeffc2f8 100644 --- a/crates/herdr-gpui/src/usage/text.rs +++ b/crates/herdr-gpui/src/usage/text.rs @@ -1,21 +1,53 @@ //! `herdr-usage`'s typed values in the shown language: limit kinds, times -//! left, pace, and balances. That crate's own English (`Kind::title`, -//! `Pace::describe`, `countdown`) stays for logs and tests; what a service -//! names itself, such as a model's window, is shown as it reports it. +//! left, pace, balances, and the words a report names. That crate's own +//! English (`Kind::title`, `Pace::describe`, `countdown`) stays for logs and +//! tests; what a service names itself, such as a model's window, is shown as +//! it reports it. -use super::model::{Balance, Kind, Pace, Window as Limit}; -use crate::i18n::fl; +use super::model::{Balance, Count, Kind, Pace, Unit, Window as Limit, group}; +use crate::i18n::{self, FluentValue, fl}; +use herdr_usage::{Arg, Text}; use std::time::{Duration, SystemTime}; +/// Words a report names in the shown language: a message `herdr-usage` +/// names by ID, with its arguments, or the service's own words. +pub(crate) fn words(text: &Text) -> String { + write(text, i18n::message) +} + +/// The same words in English, for command line output. +pub(crate) fn english(text: &Text) -> String { + write(text, i18n::english) +} + +fn write(text: &Text, message: fn(&str, &[(&str, FluentValue<'_>)]) -> String) -> String { + match text { + Text::Service(text) => text.clone(), + Text::Message { id, args } => { + let args: Vec<_> = args + .iter() + .map(|(name, arg)| { + let value = match arg { + Arg::Number(number) => FluentValue::from(*number), + Arg::Text(text) => FluentValue::from(write(text, message)), + }; + (*name, value) + }) + .collect(); + message(id, &args) + } + } +} + /// The selector value usage.ftl's messages pick a kind by; a named window /// takes `$name` instead. -fn kind(kind: &Kind) -> (&'static str, &str) { +fn kind(kind: &Kind) -> (&'static str, String) { match kind { - Kind::Session => ("session", ""), - Kind::Daily => ("daily", ""), - Kind::Weekly => ("weekly", ""), - Kind::Monthly => ("monthly", ""), - Kind::Named(name) => ("named", name), + Kind::Session => ("session", String::new()), + Kind::Daily => ("daily", String::new()), + Kind::Weekly => ("weekly", String::new()), + Kind::Monthly => ("monthly", String::new()), + Kind::Named(name) => ("named", words(name)), } } @@ -102,7 +134,7 @@ pub(super) fn bar_label(limit: &Limit, now: SystemTime) -> String { let percent = limit.percent(); match (&limit.kind, limit.resets_in(now)) { (Kind::Named(name), _) => { - fl!("usage-bar-named", percent = percent, name = name.as_str()) + fl!("usage-bar-named", percent = percent, name = words(name)) } (_, Some(left)) => fl!( "usage-bar-resets", @@ -114,23 +146,38 @@ pub(super) fn bar_label(limit: &Limit, now: SystemTime) -> String { } /// `$12.30`, or `$12.30 of $50.00` when the balance has a total. Amounts -/// and units are the service's. +/// and currencies are the service's. pub(super) fn balance(balance: &Balance) -> String { - let amount = balance.amount_text(); + let amount = amount(balance.amount, &balance.unit); match balance.total { - Some(total) => { - let total = Balance { - label: String::new(), - amount: total, - unit: balance.unit.clone(), - total: None, - }; - fl!( - "usage-balance-of", - amount = amount, - total = total.amount_text() - ) - } + Some(total) => fl!( + "usage-balance-of", + amount = amount, + total = self::amount(total, &balance.unit) + ), None => amount, } } + +/// `$12.30`, `12.30 EUR`, or `1,250 credits`: a currency as the crate +/// writes it, and a count with its unit, in the plural its number takes. +fn amount(amount: f64, unit: &Unit) -> String { + let count = match unit { + Unit::Currency(_) => { + return Balance::new(Text::service(""), amount, unit.clone()).amount_text(); + } + Unit::Count(count) => count, + }; + let whole = amount.round(); + let number = if (amount - whole).abs() < 0.005 { + group(whole as i64) + } else { + format!("{amount:.2}") + }; + match count { + Count::Credits => fl!("usage-amount-credits", amount = number, count = amount), + Count::Points => fl!("usage-amount-points", amount = number, count = amount), + Count::Reviews => fl!("usage-amount-reviews", amount = number, count = amount), + Count::Named(unit) => fl!("usage-amount-named", amount = number, unit = unit.as_str()), + } +} diff --git a/crates/herdr-gpui/src/usage/ui.rs b/crates/herdr-gpui/src/usage/ui.rs index 7d6f9247a..a43c5aba8 100644 --- a/crates/herdr-gpui/src/usage/ui.rs +++ b/crates/herdr-gpui/src/usage/ui.rs @@ -9,6 +9,7 @@ use super::{ }; use crate::config::Theme; use gpui::{prelude::*, *}; +use herdr_usage::Text; use std::time::SystemTime; pub(crate) struct Ui { @@ -98,7 +99,11 @@ impl Ui { .flex() .justify_between() .gap(px(8.)) - .child(div().text_color(self.muted()).child(balance.label.clone())) + .child( + div() + .text_color(self.muted()) + .child(text::words(&balance.label)), + ) .child( div() .font_weight(FontWeight::SEMIBOLD) @@ -121,24 +126,24 @@ impl Ui { .into_any_element() } - pub fn facts(&self, title: &str, facts: &[(String, String)]) -> AnyElement { + pub fn facts(&self, title: &Text, facts: &[(Text, Text)]) -> AnyElement { self.block() - .child(self.heading(title.to_owned())) + .child(self.heading(text::words(title))) .children(facts.iter().map(|(label, value)| { div() .flex() .justify_between() .gap(px(8.)) .text_size(self.small()) - .child(div().text_color(self.muted()).child(label.clone())) - .child(div().child(value.clone())) + .child(div().text_color(self.muted()).child(text::words(label))) + .child(div().child(text::words(value))) })) .into_any_element() } - pub fn shares(&self, title: &str, shares: &[(String, f32)]) -> AnyElement { + pub fn shares(&self, title: &Text, shares: &[(Text, f32)]) -> AnyElement { self.block() - .child(self.heading(title.to_owned())) + .child(self.heading(text::words(title))) .children(shares.iter().map(|(label, share)| { div() .flex() @@ -151,7 +156,7 @@ impl Ui { .flex_none() .truncate() .text_color(self.muted()) - .child(label.clone()), + .child(text::words(label)), ) .child(div().flex_1().child(self.bar(*share, 0., None))) .child( diff --git a/crates/herdr-usage/src/lib.rs b/crates/herdr-usage/src/lib.rs index 5ac4ba57e..569988a2f 100644 --- a/crates/herdr-usage/src/lib.rs +++ b/crates/herdr-usage/src/lib.rs @@ -16,6 +16,7 @@ mod providers; pub mod registry; pub mod service; pub mod settings; +pub mod text; pub mod values; #[cfg(all(test, unix))] @@ -28,6 +29,7 @@ pub use error::{Error, Result}; pub use model::{Host, Provider}; pub use probe::Shell; pub use settings::UsageConfig; +pub use text::{Arg, Text}; /// Detail a provider attaches to its report, for the app to draw. Found with /// [`Report::detail`] by type. diff --git a/crates/herdr-usage/src/model.rs b/crates/herdr-usage/src/model.rs index 458ee4957..9fe50deb1 100644 --- a/crates/herdr-usage/src/model.rs +++ b/crates/herdr-usage/src/model.rs @@ -2,7 +2,7 @@ //! which account, which limit windows, how much of each is used, when each //! resets, and whatever else the agent's service reports. -use super::service::Service; +use super::{service::Service, text::Text}; use herdr_client::ConnectTarget; use std::{ any::Any, @@ -105,24 +105,27 @@ impl std::fmt::Debug for Provider { } /// Which limit a window measures. A named window is one the service names -/// itself, such as a weekly limit scoped to one model. +/// itself, such as a weekly limit scoped to one model, or one a provider +/// names with a message. #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] pub enum Kind { Session, Daily, Weekly, Monthly, - Named(String), + Named(Text), } impl Kind { - pub fn title(&self) -> &str { + /// In English, or a named window's ID, for logs and tests; the app + /// writes the shown title. + pub fn title(&self) -> String { match self { - Self::Session => "Session", - Self::Daily => "Daily", - Self::Weekly => "Weekly", - Self::Monthly => "Monthly", - Self::Named(name) => name, + Self::Session => "Session".into(), + Self::Daily => "Daily".into(), + Self::Weekly => "Weekly".into(), + Self::Monthly => "Monthly".into(), + Self::Named(name) => name.to_string(), } } @@ -195,7 +198,7 @@ impl Window { /// a model window is named instead, as the service does. pub fn label(&self, now: SystemTime) -> String { let suffix = match (&self.kind, self.resets_in(now)) { - (Kind::Named(name), _) => name.clone(), + (Kind::Named(name), _) => name.to_string(), (_, Some(left)) => countdown(left), (Kind::Session, None) => "5h".into(), (Kind::Daily, None) => "day".into(), @@ -271,8 +274,8 @@ impl From for Severity { /// Who is signed in, as far as the sign-in and the service say. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct Account { - pub email: Option, - pub plan: Option, + pub email: Option, + pub plan: Option, } /// Anything else a service reports, in shapes the panel knows how to draw, so @@ -283,20 +286,20 @@ pub enum Section { Limit(Window), /// Label and value pairs under a heading. Facts { - title: String, - facts: Vec<(String, String)>, + title: Text, + facts: Vec<(Text, Text)>, }, /// Shares of one whole, such as which surfaces spent a weekly limit. Shares { - title: String, - shares: Vec<(String, f32)>, + title: Text, + shares: Vec<(Text, f32)>, }, } /// Money or credits left, or spent, in the unit the service counts in. #[derive(Clone, Debug, PartialEq)] pub struct Balance { - pub label: String, + pub label: Text, pub amount: f64, pub unit: Unit, /// What the amount is out of, when the service says, e.g. a monthly budget. @@ -307,12 +310,35 @@ pub struct Balance { pub enum Unit { /// An ISO 4217 code such as `USD`. Currency(String), - /// A count of the service's own units, named as it names them. - Count(String), + /// A count of the service's own units. + Count(Count), +} + +/// What a count counts. The app writes the units it knows with a plural; a +/// unit only one service uses keeps the name it gives it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Count { + Credits, + Points, + Reviews, + /// The service's own unit, such as `DIEM`. + Named(String), +} + +impl Count { + /// In English, for logs, tests, and values a provider writes itself. + pub fn name(&self) -> &str { + match self { + Self::Credits => "credits", + Self::Points => "points", + Self::Reviews => "reviews", + Self::Named(name) => name, + } + } } impl Balance { - pub fn new(label: impl Into, amount: f64, unit: Unit) -> Self { + pub fn new(label: impl Into, amount: f64, unit: Unit) -> Self { Self { label: label.into(), amount, @@ -326,7 +352,8 @@ impl Balance { self } - /// `$12.30`, `12.30 EUR`, or `1,250 credits`. + /// `$12.30`, `12.30 EUR`, or `1,250 credits`, in English for logs, + /// tests, and values a provider writes itself. pub fn amount_text(&self) -> String { amount_text(self.amount, &self.unit) } @@ -354,7 +381,7 @@ fn amount_text(amount: f64, unit: &Unit) -> String { } else { format!("{amount:.2}") }; - format!("{text} {name}") + format!("{text} {}", name.name()) } } } diff --git a/crates/herdr-usage/src/providers/abacus.rs b/crates/herdr-usage/src/providers/abacus.rs index 6fb8f3b4b..a9d9f1a59 100644 --- a/crates/herdr-usage/src/providers/abacus.rs +++ b/crates/herdr-usage/src/providers/abacus.rs @@ -7,6 +7,8 @@ //! profile in turn; here the first browser with abacus.ai cookies is used, //! and nothing is cached. +use crate::model::Count; +use crate::text::Text; use crate::{ Error, Result, { @@ -85,7 +87,7 @@ pub(crate) fn parse(points: &str, billing: Option<&str>) -> Result { (reset - start).to_std().ok() }); let window = Window::new(Kind::Named("Credits".into()), percent, resets_at, length); - let credits = Balance::new("Credits used", used.max(0.), Unit::Count("credits".into())); + let credits = Balance::new("Credits used", used.max(0.), Unit::Count(Count::Credits)); let credits = if total > 0. { credits.out_of(total) } else { @@ -93,7 +95,10 @@ pub(crate) fn parse(points: &str, billing: Option<&str>) -> Result { }; Ok(Report::new( Provider(&Abacus), - Account { email: None, plan }, + Account { + email: None, + plan: plan.map(Text::from), + }, vec![window], ) .with_balances([credits])) @@ -148,7 +153,7 @@ mod tests { let billing = r#"{"success": true, "result": {"nextBillingDate": "2026-10-15T00:00:00Z", "currentTier": "Pro"}}"#; let report = parse(POINTS, Some(billing)).unwrap(); - assert_eq!(report.account.plan.as_deref(), Some("Pro")); + assert_eq!(report.account.plan.as_ref().map(Text::as_str), Some("Pro")); let window = &report.windows[0]; assert!((window.used - 25.).abs() < 1e-4); assert!(window.resets_at.is_some()); @@ -156,7 +161,7 @@ mod tests { assert_eq!(window.length, Some(Duration::from_secs(30 * 86_400))); assert_eq!( report.balances, - vec![Balance::new("Credits used", 5000., Unit::Count("credits".into())).out_of(20000.)] + vec![Balance::new("Credits used", 5000., Unit::Count(Count::Credits)).out_of(20000.)] ); } diff --git a/crates/herdr-usage/src/providers/aixy.rs b/crates/herdr-usage/src/providers/aixy.rs index ddfdcf4a2..981254a06 100644 --- a/crates/herdr-usage/src/providers/aixy.rs +++ b/crates/herdr-usage/src/providers/aixy.rs @@ -3,6 +3,7 @@ //! here or on the probed host) at `https://api.aixy-gateway.com` or the //! configured `base_url`. Everything CodexBar reads is ported. +use crate::text::{Text, pair}; use crate::{ Error, Result, { @@ -128,7 +129,7 @@ pub(crate) fn parse(body: &str) -> Result { if windows.len() < 2 { let kind = match entry.interval.kind() { Some(kind) if windows.iter().all(|window| window.kind != kind) => kind, - _ => Kind::Named(entry.short_title()), + _ => Kind::Named(Text::from(entry.short_title())), }; windows.push(Window { kind, ..window }); } else { @@ -141,23 +142,25 @@ pub(crate) fn parse(body: &str) -> Result { facts: vec![ ( "Key".into(), - text(key.name.as_ref()).unwrap_or(key_id).to_owned(), + Text::from(text(key.name.as_ref()).unwrap_or(key_id).to_owned()), ), ( "Project".into(), - text(key.project_name.as_ref()) - .or(key.project_id.as_deref()) - .unwrap_or("Unknown") - .to_owned(), + Text::from( + text(key.project_name.as_ref()) + .or(key.project_id.as_deref()) + .unwrap_or("Unknown") + .to_owned(), + ), ), - ("Observed".into(), observed.to_owned()), + ("Observed".into(), Text::from(observed.to_owned())), ], }]; sections.extend(limits); if !parsed.is_empty() { sections.push(Section::Facts { title: "Applicable budgets".into(), - facts: parsed.iter().map(Entry::fact).collect(), + facts: parsed.iter().map(Entry::fact).map(pair).collect(), }); } let mut balances = Vec::new(); @@ -192,11 +195,11 @@ pub(crate) fn parse(body: &str) -> Result { sections.push(Section::Facts { title: "Last 7 days · this key".into(), facts: vec![ - ("Requests".into(), group(requests as i64)), - ("Tokens".into(), group(tokens as i64)), + ("Requests".into(), Text::from(group(requests as i64))), + ("Tokens".into(), Text::from(group(tokens as i64))), ( "Attributed spend".into(), - spend.map_or_else(|| "Unavailable".into(), usd), + Text::from(spend.map_or_else(|| "Unavailable".into(), usd)), ), ( "Cost coverage".into(), @@ -205,7 +208,8 @@ pub(crate) fn parse(body: &str) -> Result { group(attributed as i64), group(requests as i64), group(partial as i64) - ), + ) + .into(), ), ], }); @@ -389,7 +393,7 @@ impl Entry { .zip(self.resets_at) .and_then(|(start, end)| end.duration_since(start).ok()); Some(Window::new( - Kind::Named(self.title()), + Kind::Named(Text::from(self.title())), self.used_percent()?, self.resets_at, length, diff --git a/crates/herdr-usage/src/providers/alibaba.rs b/crates/herdr-usage/src/providers/alibaba.rs index 6658a5cba..cac6e1729 100644 --- a/crates/herdr-usage/src/providers/alibaba.rs +++ b/crates/herdr-usage/src/providers/alibaba.rs @@ -20,6 +20,7 @@ use super::oneconsole::{ CHROME_AGENT, DOMAINS, check, cornerstone, date, expand, find_object, find_value, first, form_body, gateway_request, sec_token, string, }; +use crate::text::{Text, pairs}; use crate::{ Error, Result, { @@ -355,7 +356,7 @@ pub(crate) fn parse(body: &str, now: SystemTime) -> Result { Provider(&Alibaba), Account { email: None, - plan: Some(plan), + plan: Some(Text::from(plan)), }, Vec::new(), ) @@ -366,14 +367,18 @@ pub(crate) fn parse(body: &str, now: SystemTime) -> Result { None => Err(invalid()), }; } - Ok( - Report::new(Provider(&Alibaba), Account { email: None, plan }, windows).with_sections([ - Section::Facts { - title: "Requests used".into(), - facts, - }, - ]), + Ok(Report::new( + Provider(&Alibaba), + Account { + email: None, + plan: plan.map(Text::from), + }, + windows, ) + .with_sections([Section::Facts { + title: "Requests used".into(), + facts: pairs(facts), + }])) } fn quota_of(info: &Map) -> Option<&Map> { @@ -507,7 +512,7 @@ mod tests { ) .unwrap(); assert_eq!( - report.account.plan.as_deref(), + report.account.plan.as_ref().map(Text::as_str), Some("Alibaba Coding Plan Pro") ); assert_eq!(report.windows.len(), 3); @@ -546,7 +551,7 @@ mod tests { ) .unwrap(); assert!(report.windows.is_empty()); - assert_eq!(report.account.plan.as_deref(), Some("Lite")); + assert_eq!(report.account.plan.as_ref().map(Text::as_str), Some("Lite")); assert!(parse(r#"{"data":{}}"#, at(1_700_000_000)).is_err()); } diff --git a/crates/herdr-usage/src/providers/alibabatokenplan.rs b/crates/herdr-usage/src/providers/alibabatokenplan.rs index d38b9c1fe..7349df236 100644 --- a/crates/herdr-usage/src/providers/alibabatokenplan.rs +++ b/crates/herdr-usage/src/providers/alibabatokenplan.rs @@ -18,6 +18,8 @@ use super::oneconsole::{ DOMAINS, check, cornerstone, date, encode, expand, find_object, find_value, first, form_body, gateway_request, sec_token, string, }; +use crate::model::Count; +use crate::text::{Text, pairs}; use crate::{ Error, Result, { @@ -492,7 +494,7 @@ pub(super) fn report_with(provider: Provider, team: Team) -> Report { .map(|used| Window::new(Kind::Monthly, used, team.resets_at, Some(MONTH))) .into_iter() .collect(); - let credits = || Unit::Count("credits".into()); + let credits = || Unit::Count(Count::Credits); let balance = match ( team.total.filter(|total| *total > 0.), team.remaining, @@ -509,7 +511,10 @@ pub(super) fn report_with(provider: Provider, team: Team) -> Report { }; let account = Account { email: None, - plan: team.plan.filter(|plan| !plan.trim().is_empty()), + plan: team + .plan + .filter(|plan| !plan.trim().is_empty()) + .map(Text::from), }; let mut report = Report::new(provider, account, windows).with_balances(balance); if percent.is_none() && report.balances.is_empty() { @@ -552,7 +557,7 @@ impl Personal { provider, Account { email: None, - plan: self.plan, + plan: self.plan.map(Text::from), }, self.windows, ); @@ -561,7 +566,7 @@ impl Personal { } else { report.with_sections([Section::Facts { title: "Credits used".into(), - facts, + facts: pairs(facts), }]) } } diff --git a/crates/herdr-usage/src/providers/alibabatokenplan/tests.rs b/crates/herdr-usage/src/providers/alibabatokenplan/tests.rs index 2bff68bf3..6c71acf58 100644 --- a/crates/herdr-usage/src/providers/alibabatokenplan/tests.rs +++ b/crates/herdr-usage/src/providers/alibabatokenplan/tests.rs @@ -1,5 +1,6 @@ use super::*; use crate::probe::Part; +use crate::text::Text; fn at(seconds: u64) -> SystemTime { SystemTime::UNIX_EPOCH + Duration::from_secs(seconds) @@ -12,7 +13,10 @@ fn parses_team_summary() { "TotalSurplusValue": 875, "NearestExpireDate": 1701000000000}, "Code": "200"}"#, ) .unwrap(); - assert_eq!(report.account.plan.as_deref(), Some("TOKEN PLAN")); + assert_eq!( + report.account.plan.as_ref().map(Text::as_str), + Some("TOKEN PLAN") + ); assert_eq!(report.windows[0].kind, Kind::Monthly); assert_eq!(report.windows[0].used, 12.5); assert_eq!(report.windows[0].resets_at, Some(at(1_701_000_000))); @@ -58,7 +62,10 @@ fn parses_personal_usage_with_tier_limits() { ) .unwrap() .report(Provider(&Alibabatokenplan)); - assert_eq!(report.account.plan.as_deref(), Some("Standard")); + assert_eq!( + report.account.plan.as_ref().map(Text::as_str), + Some("Standard") + ); assert_eq!(report.windows[0].kind, Kind::Session); assert!((report.windows[0].used - 3.).abs() < 1e-4); assert_eq!(report.windows[0].resets_at, Some(at(1_700_003_600))); @@ -77,7 +84,10 @@ fn cli_requires_numeric_ratios() { "per1MonthPercentage":0.2,"per1MonthResetTime":1702000000000}"#, ) .unwrap(); - assert_eq!(report.account.plan.as_deref(), Some("Token Plan")); + assert_eq!( + report.account.plan.as_ref().map(Text::as_str), + Some("Token Plan") + ); assert_eq!(report.windows.len(), 2); assert_eq!(report.windows[1].kind, Kind::Monthly); assert!(parse_cli(r#"{"per5HourPercentage":"0.5"}"#).is_none()); diff --git a/crates/herdr-usage/src/providers/amp.rs b/crates/herdr-usage/src/providers/amp.rs index 294d80c27..9ae03c571 100644 --- a/crates/herdr-usage/src/providers/amp.rs +++ b/crates/herdr-usage/src/providers/amp.rs @@ -9,6 +9,7 @@ //! Not ported: CodexBar reads the CLI's standard error when standard output //! is empty; the probe discards standard error, so such a report is missed. +use crate::text::{Text, pairs}; use crate::{ Error, Result, { @@ -204,18 +205,18 @@ pub(crate) fn parse_report(text: &str, now: SystemTime) -> Result { let sections = [ (!facts.is_empty()).then(|| Section::Facts { title: "Monthly allowances".into(), - facts, + facts: pairs(facts), }), organization.map(|organization| Section::Facts { title: "Account".into(), - facts: vec![("Organization".into(), organization)], + facts: vec![("Organization".into(), Text::from(organization))], }), ]; Ok(Report::new( Provider(&Amp), Account { - email, - plan: Some(plan), + email: email.map(Text::from), + plan: Some(Text::from(plan)), }, windows, ) @@ -599,8 +600,14 @@ mod tests { ); let body = serde_json::json!({"ok": true, "result": {"displayText": text}}).to_string(); let report = parse_api(&body, now).unwrap(); - assert_eq!(report.account.email.as_deref(), Some("user@example.com")); - assert_eq!(report.account.plan.as_deref(), Some("Megawatt")); + assert_eq!( + report.account.email.as_ref().map(Text::as_str), + Some("user@example.com") + ); + assert_eq!( + report.account.plan.as_ref().map(Text::as_str), + Some("Megawatt") + ); let agent = &report.windows[0]; assert_eq!(agent.kind, Kind::Monthly); assert!((agent.used - 7.15).abs() < 1e-3); @@ -637,7 +644,10 @@ mod tests { Individual credits: $25.64 remaining (set up automatic top-up to avoid running out)\n\ Workspace meow: $10.22 remaining (set up automatic top-up to avoid running out)\n"; let report = parse_report(text, now).unwrap(); - assert_eq!(report.account.plan.as_deref(), Some("Amp Free")); + assert_eq!( + report.account.plan.as_ref().map(Text::as_str), + Some("Amp Free") + ); let free = &report.windows[0]; assert_eq!(free.kind, Kind::Named("Amp Free".into())); assert!((free.used - 52.9).abs() < 1e-3); @@ -664,7 +674,10 @@ mod tests { **Amp Megawatt Subscription:** 68% other usage and 97% orb usage remaining - resets upon renewal in 5 days\n\ **Individual credits:** $3.23 remaining (set up auto-reload to avoid running out)\n"; let report = parse_report(text, now).unwrap(); - assert_eq!(report.account.plan.as_deref(), Some("Megawatt")); + assert_eq!( + report.account.plan.as_ref().map(Text::as_str), + Some("Megawatt") + ); let free = &report.windows[0]; assert_eq!(free.kind, Kind::Daily); assert_eq!(free.percent(), 100); @@ -691,7 +704,7 @@ mod tests { Individual credits: $12 remaining\n"; let report = parse_report(text, SystemTime::now()).unwrap(); assert!(report.windows.is_empty()); - assert_eq!(report.account.plan.as_deref(), Some("Amp")); + assert_eq!(report.account.plan.as_ref().map(Text::as_str), Some("Amp")); assert_eq!(report.balances[0].amount, 12.); } diff --git a/crates/herdr-usage/src/providers/antigravity.rs b/crates/herdr-usage/src/providers/antigravity.rs index 77da535c3..9fcac17ec 100644 --- a/crates/herdr-usage/src/providers/antigravity.rs +++ b/crates/herdr-usage/src/providers/antigravity.rs @@ -14,6 +14,7 @@ //! accounts CodexBar signs in itself (an interactive login with the app's //! OAuth client); and the local SQLite conversation history. +use crate::text::Text; use crate::{ Error, Result, { @@ -250,7 +251,7 @@ impl Bucket { Cadence::Other => (format!("{group} {}", name.unwrap_or(id)), None), }; Some(Window::new( - Kind::Named(kind), + Kind::Named(Text::from(kind)), 100. - fraction.clamp(0., 1.) * 100., self.reset_time.as_ref().and_then(Timestamp::time), length, diff --git a/crates/herdr-usage/src/providers/augment.rs b/crates/herdr-usage/src/providers/augment.rs index 3456c576e..651c75374 100644 --- a/crates/herdr-usage/src/providers/augment.rs +++ b/crates/herdr-usage/src/providers/augment.rs @@ -11,6 +11,8 @@ //! and its cached copy of the last working cookies. Here an expired cookie //! shows as rejected until the browser session is refreshed. +use crate::model::Count; +use crate::text::Text; use crate::{ Error, Result, { @@ -200,13 +202,18 @@ impl Usage { let remaining = self .remaining .unwrap_or_else(|| (limit - self.used.unwrap_or(0.)).max(0.)); - Balance::new("Credits left", remaining, Unit::Count("credits".into())).out_of(limit) + Balance::new("Credits left", remaining, Unit::Count(Count::Credits)).out_of(limit) }); Report::new( Provider(&Augment), Account { - email: email.filter(|email| !email.trim().is_empty()), - plan: self.plan.filter(|plan| !plan.trim().is_empty()), + email: email + .filter(|email| !email.trim().is_empty()) + .map(Text::from), + plan: self + .plan + .filter(|plan| !plan.trim().is_empty()) + .map(Text::from), }, vec![window], ) @@ -275,11 +282,14 @@ mod tests { 9 days remaining in this billing cycle (ends 6/9/2026)\n\ For more detail, visit https://app.augmentcode.com/account\n"; let report = parse_cli(output).unwrap(); - assert_eq!(report.account.plan.as_deref(), Some("Max Plan")); + assert_eq!( + report.account.plan.as_ref().map(Text::as_str), + Some("Max Plan") + ); assert_eq!( report.balances, vec![ - Balance::new("Credits left", 319_054., Unit::Count("credits".into())) + Balance::new("Credits left", 319_054., Unit::Count(Count::Credits)) .out_of(450_000.) ] ); @@ -299,8 +309,7 @@ mod tests { assert_eq!( report.balances, vec![ - Balance::new("Credits left", 11_657., Unit::Count("credits".into())) - .out_of(964_827.) + Balance::new("Credits left", 11_657., Unit::Count(Count::Credits)).out_of(964_827.) ] ); assert_eq!(report.windows[0].percent(), 99); @@ -322,8 +331,14 @@ mod tests { let subscription = r#"{"planName":"Developer","billingPeriodEnd":"2026-07-01T00:00:00Z", "email":"dev@example.com","organization":null}"#; let report = parse_web(credits, Some(subscription)).unwrap(); - assert_eq!(report.account.email.as_deref(), Some("dev@example.com")); - assert_eq!(report.account.plan.as_deref(), Some("Developer")); + assert_eq!( + report.account.email.as_ref().map(Text::as_str), + Some("dev@example.com") + ); + assert_eq!( + report.account.plan.as_ref().map(Text::as_str), + Some("Developer") + ); assert_eq!(report.windows[0].percent(), 10); assert_eq!( report.windows[0].resets_at, @@ -331,7 +346,7 @@ mod tests { ); assert_eq!( report.balances, - vec![Balance::new("Credits left", 15., Unit::Count("credits".into())).out_of(100.)] + vec![Balance::new("Credits left", 15., Unit::Count(Count::Credits)).out_of(100.)] ); } diff --git a/crates/herdr-usage/src/providers/azureopenai.rs b/crates/herdr-usage/src/providers/azureopenai.rs index f20ef9ae8..ad0d86a40 100644 --- a/crates/herdr-usage/src/providers/azureopenai.rs +++ b/crates/herdr-usage/src/providers/azureopenai.rs @@ -7,6 +7,7 @@ //! needs Azure Resource Manager credentials, which CodexBar does not read //! either. There is no local sign-in to detect. +use crate::text::pairs; use crate::{ Error, Result, { @@ -185,12 +186,12 @@ pub(crate) fn parse(body: &str, target: &Target) -> Result { facts.push(("API version".into(), target.version.clone())); let account = Account { email: None, - plan: Some(format!("Deployment: {}", target.deployment)), + plan: Some(format!("Deployment: {}", target.deployment).into()), }; Ok( Report::new(Provider(&Azureopenai), account, Vec::new()).with_sections([Section::Facts { title: "Deployment reachable".into(), - facts, + facts: pairs(facts), }]), ) } @@ -204,6 +205,7 @@ struct Completion { #[allow(clippy::unwrap_used, clippy::expect_used)] mod tests { use super::*; + use crate::text::Text; #[test] fn dated_version_uses_deployment_path() { @@ -247,7 +249,7 @@ mod tests { .unwrap(); assert!(report.windows.is_empty()); assert_eq!( - report.account.plan.as_deref(), + report.account.plan.as_ref().map(Text::as_str), Some("Deployment: chat-prod") ); let Section::Facts { facts, .. } = &report.sections[0] else { diff --git a/crates/herdr-usage/src/providers/bedrock.rs b/crates/herdr-usage/src/providers/bedrock.rs index 4ca595fe5..5b83b00dc 100644 --- a/crates/herdr-usage/src/providers/bedrock.rs +++ b/crates/herdr-usage/src/providers/bedrock.rs @@ -12,6 +12,7 @@ //! SigV4) from pasted access keys, the CloudWatch Claude token and request //! totals, and the daily cost history. +use crate::text::pair; use crate::{ Error, Result, { @@ -152,6 +153,7 @@ pub(crate) fn parse(body: &str, budget: Option, today: NaiveDate) -> Result facts: services .into_iter() .map(|(service, amount)| (service, format!("${amount:.2}"))) + .map(pair) .collect(), }); Ok(Report::new(Provider(&Bedrock), Account::default(), windows) diff --git a/crates/herdr-usage/src/providers/bifrost.rs b/crates/herdr-usage/src/providers/bifrost.rs index d7b0d3724..b9edd6c5e 100644 --- a/crates/herdr-usage/src/providers/bifrost.rs +++ b/crates/herdr-usage/src/providers/bifrost.rs @@ -4,6 +4,7 @@ //! `base_url`. No admin credential is used. Everything CodexBar reads is //! ported; the per-budget list and model rows are shown as facts. +use crate::text::{Text, pair, pairs}; use crate::{ Error, Result, { @@ -180,7 +181,7 @@ pub(crate) fn parse(body: &str, now: SystemTime) -> Result { let timing = Timing::new(reset, text(last.as_ref()), now); match max.filter(|_| known) { Some(max) => rate_limits.push(Section::Limit(Window::new( - Kind::Named(title), + Kind::Named(Text::from(title)), used.unwrap_or(0.) / max * 100., timing.resets_at, timing.length, @@ -224,7 +225,7 @@ pub(crate) fn parse(body: &str, now: SystemTime) -> Result { if !unknown_limits.is_empty() { sections.push(Section::Facts { title: "Rate limits".into(), - facts: unknown_limits, + facts: pairs(unknown_limits), }); } if inactive { @@ -242,12 +243,21 @@ pub(crate) fn parse(body: &str, now: SystemTime) -> Result { if budgets.len() > 1 || budgets.iter().any(|budget| budget.scope.is_some()) { sections.push(Section::Facts { title: "Budgets".into(), - facts: budgets.iter().take(24).map(Budget::fact).collect(), + facts: budgets + .iter() + .take(24) + .map(Budget::fact) + .map(pair) + .collect(), }); } let account = Account { - email: text(root.virtual_key_name.as_ref()).map(str::to_owned), - plan: first.and_then(|budget| budget.source.clone()), + email: text(root.virtual_key_name.as_ref()) + .map(str::to_owned) + .map(Text::from), + plan: first + .and_then(|budget| budget.source.clone()) + .map(Text::from), }; Ok(Report::new(Provider(&Bifrost), account, windows) .with_balances(balance) @@ -281,13 +291,13 @@ impl Budget<'_> { (Some("Daily"), None, None) => Kind::Daily, (Some("Weekly"), None, None) => Kind::Weekly, (Some("Monthly"), None, None) => Kind::Monthly, - (label, _, _) => Kind::Named( + (label, _, _) => Kind::Named(Text::from( [Some(self.title()), label.map(str::to_owned)] .into_iter() .flatten() .collect::>() .join(" · "), - ), + )), }; let length = self.timing.length.or_else(|| kind.length()); Window::new( @@ -448,7 +458,7 @@ fn models(rows: &[ModelUsage]) -> Option
{ } Some(Section::Facts { title: "Models".into(), - facts, + facts: pairs(facts), }) } diff --git a/crates/herdr-usage/src/providers/bifrost/tests.rs b/crates/herdr-usage/src/providers/bifrost/tests.rs index cb35a407f..0d25fc8d9 100644 --- a/crates/herdr-usage/src/providers/bifrost/tests.rs +++ b/crates/herdr-usage/src/providers/bifrost/tests.rs @@ -1,4 +1,5 @@ use super::*; +use crate::text::Text; const QUOTA: &str = r#"{"virtual_key_name":"fixture-team","budgets":[ {"id":"year","max_limit":1000,"current_usage":100,"reset_duration":"1Y"}, @@ -23,8 +24,14 @@ fn now() -> SystemTime { #[test] fn parses_budgets_limits_and_models() { let report = parse(QUOTA, now()).unwrap(); - assert_eq!(report.account.email.as_deref(), Some("fixture-team")); - assert_eq!(report.account.plan.as_deref(), Some("Engineering")); + assert_eq!( + report.account.email.as_ref().map(Text::as_str), + Some("fixture-team") + ); + assert_eq!( + report.account.plan.as_ref().map(Text::as_str), + Some("Engineering") + ); assert_eq!(report.windows.len(), 2); let month = report .windows @@ -60,11 +67,11 @@ fn parses_budgets_limits_and_models() { }; assert_eq!( facts("Rate limits").unwrap(), - [("Team pool Requests".to_owned(), "Unavailable".to_owned())] + [("Team pool Requests".into(), "Unavailable".into())] ); assert_eq!( facts("Models").unwrap(), - [("gpt-4o".to_owned(), "$5.00 · 1.2M tokens".to_owned())] + [("gpt-4o".into(), "$5.00 · 1.2M tokens".into())] ); assert_eq!(facts("Budgets").unwrap().len(), 2); } diff --git a/crates/herdr-usage/src/providers/chutes.rs b/crates/herdr-usage/src/providers/chutes.rs index eb7aa5140..a18c064c4 100644 --- a/crates/herdr-usage/src/providers/chutes.rs +++ b/crates/herdr-usage/src/providers/chutes.rs @@ -7,6 +7,7 @@ //! normalized name against CodexBar's candidate lists. Everything CodexBar //! reads is ported. +use crate::text::{Text, pairs}; use crate::{ Error, Result, { @@ -593,7 +594,9 @@ fn report(parsed: Parsed) -> Report { (quota, window) }); let mut rest = parsed.fallback.iter().map(|quota| { - let kind = Kind::Named(quota.label.clone().unwrap_or_else(|| "Quota".into())); + let kind = Kind::Named(Text::from( + quota.label.clone().unwrap_or_else(|| "Quota".into()), + )); (quota, quota.window(kind, None, None)) }); let shown: Vec<(&Quota, Window)> = match (rolling, monthly) { @@ -613,12 +616,15 @@ fn report(parsed: Parsed) -> Report { }); Report::new( Provider(&Chutes), - Account { email: None, plan }, + Account { + email: None, + plan: plan.map(Text::from), + }, shown.into_iter().map(|(_, window)| window).collect(), ) .with_sections((!facts.is_empty()).then(|| Section::Facts { title: "Quota".into(), - facts, + facts: pairs(facts), })) } @@ -642,7 +648,7 @@ mod tests { }"#, )); let report = report(parsed); - assert_eq!(report.account.plan.as_deref(), Some("Pro")); + assert_eq!(report.account.plan.as_ref().map(Text::as_str), Some("Pro")); let monthly = report .windows .iter() @@ -691,7 +697,7 @@ mod tests { assert_eq!(report.windows.len(), 1); assert_eq!(report.windows[0].used, 10.); assert_eq!( - report.account.plan.as_deref(), + report.account.plan.as_ref().map(Text::as_str), Some("No active subscription") ); let Section::Facts { facts, .. } = &report.sections[0] else { @@ -723,6 +729,9 @@ mod tests { fn empty_payload_reports_no_usage_data() { let report = report(parse(&value("{}"))); assert!(report.windows.is_empty()); - assert_eq!(report.account.plan.as_deref(), Some("No usage data")); + assert_eq!( + report.account.plan.as_ref().map(Text::as_str), + Some("No usage data") + ); } } diff --git a/crates/herdr-usage/src/providers/claude.rs b/crates/herdr-usage/src/providers/claude.rs index 73a13f6c9..584826e26 100644 --- a/crates/herdr-usage/src/providers/claude.rs +++ b/crates/herdr-usage/src/providers/claude.rs @@ -6,6 +6,7 @@ pub(crate) mod cswap; +use crate::text::Text; use crate::{ Result, { @@ -56,7 +57,7 @@ impl Service for Claude { None => Ok(Report::new( Provider(&Claude), Account { - email: Some(active.as_ref()?.email.clone()), + email: Some(Text::from(active.as_ref()?.email.clone())), plan: None, }, active.filter(|active| !active.windows.is_empty())?.windows, @@ -156,13 +157,13 @@ pub(crate) fn parse(body: &str, sign_in: SignIn) -> Result { let kind = match limit.kind.as_str() { "session" => Kind::Session, "weekly_all" => Kind::Weekly, - "weekly_scoped" => Kind::Named( + "weekly_scoped" => Kind::Named(Text::from( limit .scope? .model? .display_name .filter(|name| !name.trim().is_empty())?, - ), + )), _ => return None, }; let length = if kind == Kind::Session { SESSION } else { WEEK }; @@ -192,8 +193,8 @@ pub(crate) fn parse(body: &str, sign_in: SignIn) -> Result { .collect(); } let account = Account { - email: sign_in.email, - plan: plan(sign_in.plan.as_deref(), sign_in.tier.as_deref()), + email: sign_in.email.map(Text::from), + plan: plan(sign_in.plan.as_deref(), sign_in.tier.as_deref()).map(Text::from), }; let shares: Vec<_> = usage .seven_day_breakdown @@ -210,8 +211,8 @@ pub(crate) fn parse(body: &str, sign_in: SignIn) -> Result { vec![( "This month".into(), match (used, limit) { - (Some(used), Some(limit)) => format!("{used} of {limit}"), - (Some(used), None) => used, + (Some(used), Some(limit)) => format!("{used} of {limit}").into(), + (Some(used), None) => Text::from(used), _ => "On".into(), }, )] @@ -224,7 +225,7 @@ pub(crate) fn parse(body: &str, sign_in: SignIn) -> Result { (!shares.is_empty()) .then(|| Section::Shares { title: "This week by surface".into(), - shares, + shares: crate::text::shares(shares), }) .into_iter() .chain(spend), diff --git a/crates/herdr-usage/src/providers/claude/cswap.rs b/crates/herdr-usage/src/providers/claude/cswap.rs index afc8a1be0..da9e162a5 100644 --- a/crates/herdr-usage/src/providers/claude/cswap.rs +++ b/crates/herdr-usage/src/providers/claude/cswap.rs @@ -4,6 +4,7 @@ //! risks a stored refresh token nor adds calls to the usage endpoint. //! Without cswap on the probed host there are no further accounts. +use crate::text::Text; use crate::{ Result, { @@ -160,7 +161,11 @@ impl Usage { self.scoped .into_iter() .filter(|scoped| !scoped.name.trim().is_empty()) - .map(|scoped| scoped.reading.window(Kind::Named(scoped.name), WEEK)), + .map(|scoped| { + scoped + .reading + .window(Kind::Named(Text::from(scoped.name)), WEEK) + }), ) .collect(); windows.sort_by(|a, b| a.kind.cmp(&b.kind)); diff --git a/crates/herdr-usage/src/providers/clawrouter.rs b/crates/herdr-usage/src/providers/clawrouter.rs index dac47ca49..b333c3ae6 100644 --- a/crates/herdr-usage/src/providers/clawrouter.rs +++ b/crates/herdr-usage/src/providers/clawrouter.rs @@ -5,6 +5,7 @@ //! cost. CodexBar's per-provider cost chart is shown as shares. Everything //! CodexBar reads is ported. +use crate::text::{pair, pairs, share}; use crate::{ Result, { @@ -152,7 +153,7 @@ fn parse(body: &str) -> Result { }); let routed = Section::Facts { title: "Routed providers".into(), - facts: routed.collect(), + facts: routed.map(pair).collect(), }; let shares = (total > 0).then(|| Section::Shares { title: "Provider cost".into(), @@ -165,10 +166,11 @@ fn parse(body: &str) -> Result { (provider.actual_cost_micros.max(0) as f64 / total as f64 * 100.) as f32, ) }) + .map(share) .collect(), }); let account = Account { - email: Some(format!("{} routed providers", providers.len())), + email: Some(format!("{} routed providers", providers.len()).into()), plan: Some( if budget.configured { "Managed monthly budget" @@ -183,7 +185,7 @@ fn parse(body: &str) -> Result { .with_sections( [Section::Facts { title: "Usage".into(), - facts, + facts: pairs(facts), }] .into_iter() .chain((!providers.is_empty()).then_some(routed)) @@ -284,6 +286,7 @@ struct Routed { mod tests { use super::*; use crate::service::Timestamp; + use crate::text::Text; const BUDGETED: &str = r#"{ "policyId": "openclaw-smoke", @@ -325,10 +328,13 @@ mod tests { fn reads_a_monthly_budget() { let report = parse(BUDGETED).unwrap(); assert_eq!( - report.account.plan.as_deref(), + report.account.plan.as_ref().map(Text::as_str), Some("Managed monthly budget") ); - assert_eq!(report.account.email.as_deref(), Some("2 routed providers")); + assert_eq!( + report.account.email.as_ref().map(Text::as_str), + Some("2 routed providers") + ); let window = &report.windows[0]; assert_eq!(window.kind, Kind::Monthly); assert!((window.used - 0.024).abs() < 1e-4); @@ -353,7 +359,10 @@ mod tests { fn unmetered_policies_show_spend_only() { let report = parse(UNMETERED).unwrap(); assert!(report.windows.is_empty()); - assert_eq!(report.account.plan.as_deref(), Some("Unmetered")); + assert_eq!( + report.account.plan.as_ref().map(Text::as_str), + Some("Unmetered") + ); assert_eq!(report.balances[0].text(), "$1.25"); let Section::Facts { facts, .. } = &report.sections[1] else { panic!("expected routed providers"); diff --git a/crates/herdr-usage/src/providers/codebuff.rs b/crates/herdr-usage/src/providers/codebuff.rs index 50696638b..df0b6dc90 100644 --- a/crates/herdr-usage/src/providers/codebuff.rs +++ b/crates/herdr-usage/src/providers/codebuff.rs @@ -6,6 +6,8 @@ //! balance only; the CLI session also reads the plan and the weekly limit. //! Everything CodexBar reads is ported. +use crate::model::Count; +use crate::text::{Text, pairs}; use crate::{ Result, { @@ -131,7 +133,7 @@ pub(crate) fn parse( _ => {} } let balances = - remaining.map(|left| Balance::new("Remaining", left, Unit::Count("credits".into()))); + remaining.map(|left| Balance::new("Remaining", left, Unit::Count(Count::Credits))); let auto_top_up = ["autoTopupEnabled", "auto_topup_enabled"] .iter() .find_map(|key| usage.get(*key).and_then(Value::as_bool)); @@ -188,11 +190,11 @@ pub(crate) fn parse( } let section = (!facts.is_empty()).then(|| Section::Facts { title: "Account".into(), - facts, + facts: pairs(facts), }); let account = Account { - email, - plan: plan.map(|plan| title_case(&plan)), + email: email.map(Text::from), + plan: plan.map(|plan| title_case(&plan)).map(Text::from), }; Ok(Report::new(Provider(&Codebuff), account, windows) .with_balances(balances) @@ -226,6 +228,7 @@ fn time(value: Option<&Value>) -> Option { mod tests { use super::*; use crate::Error; + use crate::text::pair; #[test] fn api_key_reads_credits_only() { @@ -240,11 +243,7 @@ mod tests { assert!((report.windows[0].used - 25.).abs() < 1e-4); assert_eq!( report.balances, - vec![Balance::new( - "Remaining", - 75., - Unit::Count("credits".into()) - )] + vec![Balance::new("Remaining", 75., Unit::Count(Count::Credits))] ); assert_eq!(report.account, Account::default()); } @@ -260,8 +259,11 @@ mod tests { "email": "dev@example.com" }"#; let report = parse(usage, Some(subscription), Some("cli@example.com".into())).unwrap(); - assert_eq!(report.account.plan.as_deref(), Some("Pro")); - assert_eq!(report.account.email.as_deref(), Some("dev@example.com")); + assert_eq!(report.account.plan.as_ref().map(Text::as_str), Some("Pro")); + assert_eq!( + report.account.email.as_ref().map(Text::as_str), + Some("dev@example.com") + ); let credits = report .windows .iter() @@ -280,8 +282,11 @@ mod tests { let Some(Section::Facts { facts, .. }) = report.sections.first() else { panic!("expected account facts"); }; - assert_eq!(facts[0], ("Subscription".to_owned(), "Active".to_owned())); - assert_eq!(facts[1], ("Auto top-up".to_owned(), "On".to_owned())); + assert_eq!( + facts[0], + pair(("Subscription".to_owned(), "Active".to_owned())) + ); + assert_eq!(facts[1], pair(("Auto top-up".to_owned(), "On".to_owned()))); } #[test] diff --git a/crates/herdr-usage/src/providers/coderabbit.rs b/crates/herdr-usage/src/providers/coderabbit.rs index 22b400f66..5c4f7b8a7 100644 --- a/crates/herdr-usage/src/providers/coderabbit.rs +++ b/crates/herdr-usage/src/providers/coderabbit.rs @@ -6,6 +6,8 @@ //! CLI owns its credentials; nothing else is read. Self-hosted logins are not //! supported by the upstream command. +use crate::model::Count; +use crate::text::{Text, pairs}; use crate::{ Error, Result, { @@ -88,7 +90,7 @@ pub(crate) fn parse(text: &str) -> Option { } let account = Account { email: None, - plan: fields.get("plan").cloned(), + plan: fields.get("plan").cloned().map(Text::from), }; Some( Report::new(Provider(&Coderabbit), account, Vec::new()) @@ -96,12 +98,12 @@ pub(crate) fn parse(text: &str) -> Option { Balance::new( "Your reviews this period", reviews as f64, - Unit::Count("reviews".into()), + Unit::Count(Count::Reviews), ) })) .with_sections((!facts.is_empty()).then(|| Section::Facts { title: "Billing period".into(), - facts, + facts: pairs(facts), })), ) } @@ -156,17 +158,17 @@ mod tests { let report = parse(USAGE).unwrap(); assert!(report.windows.is_empty()); assert_eq!(report.balances[0].amount, 25.); - assert_eq!(report.balances[0].unit, Unit::Count("reviews".into())); + assert_eq!(report.balances[0].unit, Unit::Count(Count::Reviews)); let Section::Facts { facts, .. } = &report.sections[0] else { panic!("expected facts"); }; assert_eq!( facts, &[ - ("Organization".to_owned(), "Example Org".to_owned()), - ("User".to_owned(), "example-user".to_owned()), - ("Usage billing".to_owned(), "inactive".to_owned()), - ("Period resets".to_owned(), "2026-09-30".to_owned()), + ("Organization".into(), "Example Org".into()), + ("User".into(), "example-user".into()), + ("Usage billing".into(), "inactive".into()), + ("Period resets".into(), "2026-09-30".into()), ] ); } diff --git a/crates/herdr-usage/src/providers/codex.rs b/crates/herdr-usage/src/providers/codex.rs index d0bf06b22..e92f55c96 100644 --- a/crates/herdr-usage/src/providers/codex.rs +++ b/crates/herdr-usage/src/providers/codex.rs @@ -1,6 +1,7 @@ //! Codex plan usage, read with the Codex CLI's ChatGPT sign-in, falling back //! to OpenCode's OpenAI OAuth sign-in. API keys do not provide plan usage. +use crate::text::Text; use crate::{ Result, { @@ -81,7 +82,10 @@ pub(crate) fn parse(body: &str) -> Result { .rate_limit_reset_credits .map(|credits| Section::Facts { title: "Limit reset credits".into(), - facts: vec![("Available".into(), credits.available_count.to_string())], + facts: vec![( + "Available".into(), + Text::from(credits.available_count.to_string()), + )], }); let credits = usage.credits.map(|credits| Section::Facts { title: "Credits".into(), @@ -90,16 +94,20 @@ pub(crate) fn parse(body: &str) -> Result { if credits.unlimited.unwrap_or(false) { "Unlimited".into() } else { - credits.balance.unwrap_or_else(|| "0".into()) + Text::from(credits.balance.unwrap_or_else(|| "0".into())) }, )], }); let account = Account { - email: usage.email.filter(|email| !email.trim().is_empty()), - plan: usage + email: usage + .email + .filter(|email| !email.trim().is_empty()) + .map(Text::from), + plan: (usage .plan_type .map(|plan| title_case(&plan)) - .filter(|plan| !plan.is_empty()), + .filter(|plan| !plan.is_empty())) + .map(Text::from), }; Ok(Report::new(Provider(&Codex), account, windows) .with_sections(review.into_iter().chain(resets).chain(credits))) diff --git a/crates/herdr-usage/src/providers/commandcode.rs b/crates/herdr-usage/src/providers/commandcode.rs index a378ae95c..78c4bb0cc 100644 --- a/crates/herdr-usage/src/providers/commandcode.rs +++ b/crates/herdr-usage/src/providers/commandcode.rs @@ -8,6 +8,7 @@ //! omits the grant. CodexBar also accepts a bare session token; here the //! cookie is pasted as `name=value`. +use crate::text::Text; use crate::{ Result, { @@ -166,7 +167,7 @@ pub(crate) fn parse(credits: &str, subscription: Option<&str>) -> Result }; let account = Account { email: None, - plan: plan_name, + plan: plan_name.map(Text::from), }; Ok(Report::new(Provider(&Commandcode), account, windows).with_balances(balances)) } @@ -229,7 +230,7 @@ mod tests { #[test] fn rolling_windows_and_plan_sized_month() { let report = parse(CREDITS, Some(SUBSCRIPTION)).unwrap(); - assert_eq!(report.account.plan.as_deref(), Some("Go")); + assert_eq!(report.account.plan.as_ref().map(Text::as_str), Some("Go")); let kinds: Vec<_> = report.windows.iter().map(|window| &window.kind).collect(); assert_eq!(kinds, [&Kind::Session, &Kind::Weekly, &Kind::Monthly]); assert!((report.windows[0].used - 25.).abs() < 1e-4); diff --git a/crates/herdr-usage/src/providers/copilot.rs b/crates/herdr-usage/src/providers/copilot.rs index d82c5d059..bb45d0e41 100644 --- a/crates/herdr-usage/src/providers/copilot.rs +++ b/crates/herdr-usage/src/providers/copilot.rs @@ -10,6 +10,7 @@ //! and its optional budget bars, scraped from github.com's web billing page //! with browser cookies and a page nonce. +use crate::text::{Text, pairs}; use crate::{ Result, { @@ -173,21 +174,25 @@ pub(crate) fn parse(body: &str, login: Option) -> Result { .copilot_plan .map(|plan| title_case(&plan.replace('_', " "))) .filter(|plan| !plan.is_empty()); - let mut facts = Vec::new(); + let mut facts: Vec<(String, String)> = Vec::new(); if let Some(used) = credits { facts.push(("Credits used".into(), group(used.round() as i64))); } if unlimited && windows.is_empty() { facts.push(("Quota".into(), "Unlimited".into())); } - Ok( - Report::new(Provider(&Copilot), Account { email: login, plan }, windows).with_sections( - (!facts.is_empty()).then(|| Section::Facts { - title: "Credits".into(), - facts, - }), - ), + Ok(Report::new( + Provider(&Copilot), + Account { + email: login.map(Text::from), + plan: plan.map(Text::from), + }, + windows, ) + .with_sections((!facts.is_empty()).then(|| Section::Facts { + title: "Credits".into(), + facts: pairs(facts), + }))) } /// `2025-02-01` or a full timestamp, taken as UTC. @@ -287,8 +292,14 @@ mod tests { } }"#; let report = parse(body, Some("octocat".into())).unwrap(); - assert_eq!(report.account.email.as_deref(), Some("octocat")); - assert_eq!(report.account.plan.as_deref(), Some("Individual pro")); + assert_eq!( + report.account.email.as_ref().map(Text::as_str), + Some("octocat") + ); + assert_eq!( + report.account.plan.as_ref().map(Text::as_str), + Some("Individual pro") + ); assert_eq!(report.windows.len(), 2); let premium = report .windows diff --git a/crates/herdr-usage/src/providers/cursor.rs b/crates/herdr-usage/src/providers/cursor.rs index 399090def..dea505eff 100644 --- a/crates/herdr-usage/src/providers/cursor.rs +++ b/crates/herdr-usage/src/providers/cursor.rs @@ -15,6 +15,7 @@ //! usage-events cost history; and sending every cursor.com cookie when none //! has a known session name. +use crate::text::{Text, pairs}; use crate::{ Result, { @@ -158,7 +159,7 @@ pub(crate) fn parse( if let Some((used, limit)) = quota { sections.push(Section::Facts { title: "Usage".into(), - facts: vec![("Request quota".into(), format!("{used} / {limit}"))], + facts: vec![("Request quota".into(), format!("{used} / {limit}").into())], }); } else if let Some(grok) = sand .and_then(|body| json::(body).ok()) @@ -183,7 +184,8 @@ pub(crate) fn parse( "${:.2} of ${:.2}", cents(used).unwrap_or(0.), cents(limit).unwrap_or(0.) - ), + ) + .into(), )], }); } @@ -210,25 +212,28 @@ pub(crate) fn parse( None => None, }; if limit.is_some() && (used > 0. || label == "Team on-demand") { - let mut facts = vec![("Spent this cycle".into(), format!("${used:.2}"))]; + let mut facts: Vec<(String, String)> = + vec![("Spent this cycle".into(), format!("${used:.2}"))]; if label == "Team on-demand" && personal_used > 0. { facts.push(("Your share".into(), format!("${personal_used:.2}"))); } sections.push(Section::Facts { title: label.into(), - facts, + facts: pairs(facts), }); } let account = Account { - email: me + email: (me .and_then(|me| me.email) - .filter(|email| !email.trim().is_empty()), - plan: summary + .filter(|email| !email.trim().is_empty())) + .map(Text::from), + plan: (summary .membership_type .as_deref() .filter(|kind| !kind.trim().is_empty()) - .map(membership), + .map(membership)) + .map(Text::from), }; Ok(Report::new(Provider(&Cursor), account, windows) .with_balances(balance) @@ -403,8 +408,14 @@ mod tests { #[test] fn parses_usage_summary() { let report = parse(SUMMARY, Some(user()), None, None, at(1_736_000_000)).unwrap(); - assert_eq!(report.account.email.as_deref(), Some("dev@example.com")); - assert_eq!(report.account.plan.as_deref(), Some("Cursor Pro")); + assert_eq!( + report.account.email.as_ref().map(Text::as_str), + Some("dev@example.com") + ); + assert_eq!( + report.account.plan.as_ref().map(Text::as_str), + Some("Cursor Pro") + ); let monthly = &report.windows[0]; assert_eq!(monthly.kind, Kind::Monthly); assert_eq!(monthly.used, 30.); diff --git a/crates/herdr-usage/src/providers/deepgram.rs b/crates/herdr-usage/src/providers/deepgram.rs index 272a81161..984e72f99 100644 --- a/crates/herdr-usage/src/providers/deepgram.rs +++ b/crates/herdr-usage/src/providers/deepgram.rs @@ -5,6 +5,7 @@ //! does. Deepgram reports usage, not a balance or a limit, so the panel //! shows facts only. Everything CodexBar reads is ported. +use crate::text::{Text, pairs}; use crate::{ Result, { @@ -135,13 +136,13 @@ fn report(projects: &[Project], totals: &Totals) -> Report { Provider(&Deepgram), Account { email: None, - plan: Some(plan), + plan: Some(Text::from(plan)), }, Vec::new(), ) .with_sections([Section::Facts { title: "Usage summary".into(), - facts, + facts: pairs(facts), }]) } @@ -253,7 +254,10 @@ mod tests { .add(parse_breakdown(r#"{"start":"2026-05-20","results":[{"requests":5}]}"#).unwrap()); let report = report(&projects, &totals); assert!(report.windows.is_empty()); - assert_eq!(report.account.plan.as_deref(), Some("2 projects")); + assert_eq!( + report.account.plan.as_ref().map(Text::as_str), + Some("2 projects") + ); let Section::Facts { facts, .. } = &report.sections[0] else { panic!("expected facts"); }; @@ -274,7 +278,10 @@ mod tests { fn names_a_single_project() { let projects = parse_projects(PROJECTS).unwrap(); let report = report(&projects[..1], &parse_breakdown(BREAKDOWN).unwrap()); - assert_eq!(report.account.plan.as_deref(), Some("Project: Voice bot")); + assert_eq!( + report.account.plan.as_ref().map(Text::as_str), + Some("Project: Voice bot") + ); } #[test] diff --git a/crates/herdr-usage/src/providers/deepinfra.rs b/crates/herdr-usage/src/providers/deepinfra.rs index e40655551..7da142cfb 100644 --- a/crates/herdr-usage/src/providers/deepinfra.rs +++ b/crates/herdr-usage/src/providers/deepinfra.rs @@ -3,6 +3,7 @@ //! DeepInfra's documented billing endpoints. DeepInfra is API-only, so there //! is no agent sign-in to find; everything CodexBar reads is ported. +use crate::text::pairs; use crate::{ Error, Result, { @@ -98,7 +99,7 @@ pub(crate) fn parse(checklist: &str, usage: &str) -> Result { } let sections = (!facts.is_empty()).then(|| Section::Facts { title: "Account".into(), - facts, + facts: pairs(facts), }); Ok( Report::new(Provider(&Deepinfra), Account::default(), Vec::new()) @@ -130,6 +131,7 @@ struct Month { #[allow(clippy::unwrap_used, clippy::expect_used)] mod tests { use super::*; + use crate::text::pair; const USAGE: &str = r#"{"initial_month": "2026-01", "months": [ {"period": "2026-08", "total_cost": 1234}, @@ -165,11 +167,11 @@ mod tests { }; assert_eq!( facts[0], - ("Spent this month".to_owned(), "$4.50".to_owned()) + pair(("Spent this month".to_owned(), "$4.50".to_owned())) ); assert_eq!( facts[1], - ("Suspended".to_owned(), "unpaid invoice".to_owned()) + pair(("Suspended".to_owned(), "unpaid invoice".to_owned())) ); } diff --git a/crates/herdr-usage/src/providers/deepseek.rs b/crates/herdr-usage/src/providers/deepseek.rs index b4e7475dc..247d76658 100644 --- a/crates/herdr-usage/src/providers/deepseek.rs +++ b/crates/herdr-usage/src/providers/deepseek.rs @@ -8,6 +8,7 @@ //! and the Platform's per-key daily cost and token history, private //! dashboard endpoints whose calendar bucketing adds little to a balance. +use crate::text::pairs; use crate::{ Error, Result, { @@ -187,7 +188,7 @@ fn report(wallet: Option, available: bool) -> Report { .with_balances([Balance::new("Balance", wallet.total, unit)]) .with_sections([Section::Facts { title: "Credits".into(), - facts, + facts: pairs(facts), }]) } @@ -248,6 +249,7 @@ struct PlatformWallet { #[allow(clippy::unwrap_used, clippy::expect_used)] mod tests { use super::*; + use crate::text::pair; #[test] fn api_balance_prefers_funded_usd() { @@ -267,8 +269,8 @@ mod tests { let Some(Section::Facts { facts, .. }) = report.sections.first() else { panic!("expected credit facts"); }; - assert_eq!(facts[0], ("Paid".to_owned(), "$40.00".to_owned())); - assert_eq!(facts[1], ("Granted".to_owned(), "$10.00".to_owned())); + assert_eq!(facts[0], pair(("Paid".to_owned(), "$40.00".to_owned()))); + assert_eq!(facts[1], pair(("Granted".to_owned(), "$10.00".to_owned()))); assert_eq!(facts.len(), 2); } diff --git a/crates/herdr-usage/src/providers/devin.rs b/crates/herdr-usage/src/providers/devin.rs index 3411fa710..2c628e6b7 100644 --- a/crates/herdr-usage/src/providers/devin.rs +++ b/crates/herdr-usage/src/providers/devin.rs @@ -8,6 +8,7 @@ //! `Bearer ` prefix (as `DEVIN_AUTHORIZATION` holds it) is not accepted, //! since the probe cannot strip a prefix from a secret. +use crate::text::Text; use crate::{ Error, Result, { @@ -187,7 +188,10 @@ pub(crate) fn parse(body: &str, organization: &str) -> Result { .filter(|amount| amount.is_finite() && *amount >= 0.); Ok(Report::new( Provider(&Devin), - Account { email: None, plan }, + Account { + email: None, + plan: plan.map(Text::from), + }, daily.into_iter().chain(weekly).collect(), ) .with_balances( @@ -197,7 +201,7 @@ pub(crate) fn parse(body: &str, organization: &str) -> Result { ) .with_sections([Section::Facts { title: "Organization".into(), - facts: vec![("Name".into(), organization.to_owned())], + facts: vec![("Name".into(), Text::from(organization.to_owned()))], }])) } @@ -270,7 +274,10 @@ mod tests { report.windows[1].resets_at, Some(SystemTime::UNIX_EPOCH + Duration::from_secs(1_781_424_000)) ); - assert_eq!(report.account.plan.as_deref(), Some("Team Pro")); + assert_eq!( + report.account.plan.as_ref().map(Text::as_str), + Some("Team Pro") + ); assert_eq!(report.balances[0].amount, 12.5); } diff --git a/crates/herdr-usage/src/providers/devpass.rs b/crates/herdr-usage/src/providers/devpass.rs index 0b0cdb220..72a0e635e 100644 --- a/crates/herdr-usage/src/providers/devpass.rs +++ b/crates/herdr-usage/src/providers/devpass.rs @@ -4,6 +4,7 @@ //! Publishable keys cannot read plan state. As in CodexBar, there is no local //! sign-in or dashboard cookie to find. +use crate::text::{Text, pairs}; use crate::{ Result, { @@ -46,13 +47,13 @@ pub(crate) fn parse(body: &str) -> Result { let data = json::(body)?.data.ok_or_else(invalid)?; let key_used = money(&data.usage)?; let key_limit = data.limit.as_deref().map(money).transpose()?; - let mut key_facts = vec![("All-time key usage".into(), usd(key_used))]; + let mut key_facts: Vec<(String, String)> = vec![("All-time key usage".into(), usd(key_used))]; if let Some(limit) = key_limit { key_facts.push(("Key spending limit".into(), usd(limit))); } let key_section = Section::Facts { title: "API key (all time)".into(), - facts: key_facts, + facts: pairs(key_facts), }; let plan = match data.dev_plan.as_str() { "none" => { @@ -107,12 +108,12 @@ pub(crate) fn parse(body: &str) -> Result { facts: vec![ ( "Cycle used".into(), - format!("{} / {}", usd(used), usd(limit)), + format!("{} / {}", usd(used), usd(limit)).into(), ), - ("Cycle remaining".into(), usd(remaining)), + ("Cycle remaining".into(), Text::from(usd(remaining))), ( "Premium weekly".into(), - format!("{} / {}", usd(weekly_used), usd(weekly_limit)), + format!("{} / {}", usd(weekly_used), usd(weekly_limit)).into(), ), ], }; @@ -176,7 +177,10 @@ mod tests { #[test] fn plan_credits_and_premium_week_become_windows() { let report = parse(PRO).unwrap(); - assert_eq!(report.account.plan.as_deref(), Some("DevPass Pro")); + assert_eq!( + report.account.plan.as_ref().map(Text::as_str), + Some("DevPass Pro") + ); assert_eq!(report.windows.len(), 2); // Windows are ordered by kind: weekly before monthly. let monthly = &report.windows[1]; @@ -199,7 +203,10 @@ mod tests { "devPlanCreditsUsed":null}}"#; let report = parse(body).unwrap(); assert!(report.windows.is_empty()); - assert_eq!(report.account.plan.as_deref(), Some("Pay as you go")); + assert_eq!( + report.account.plan.as_ref().map(Text::as_str), + Some("Pay as you go") + ); assert_eq!(report.balances[0].amount, 3.5); assert_eq!(report.balances[0].total, Some(10.)); } diff --git a/crates/herdr-usage/src/providers/doubao.rs b/crates/herdr-usage/src/providers/doubao.rs index 0c06d4e36..6b5898360 100644 --- a/crates/herdr-usage/src/providers/doubao.rs +++ b/crates/herdr-usage/src/providers/doubao.rs @@ -7,6 +7,7 @@ //! Ark API-key probe, which reads `x-ratelimit-*` response headers that the //! probe does not return. Neither declares a setting here for that reason. +use crate::text::Text; use crate::{ Error, Result, { @@ -86,7 +87,7 @@ pub(crate) fn parse(stdout: &str) -> Result { }; let kind = match plan { None => kind, - Some(plan) => Kind::Named(format!("{plan} {title}")), + Some(plan) => Kind::Named(format!("{plan} {title}").into()), }; windows.push(Window::new( kind, @@ -101,7 +102,7 @@ pub(crate) fn parse(stdout: &str) -> Result { } let account = Account { email: None, - plan: method, + plan: method.map(Text::from), }; Ok(Report::new(Provider(&Doubao), account, windows)) } @@ -177,7 +178,7 @@ mod tests { #[test] fn coding_plan_is_standard_and_agent_plan_is_named() { let report = parse(OUTPUT).unwrap(); - assert_eq!(report.account.plan.as_deref(), Some("sso")); + assert_eq!(report.account.plan.as_ref().map(Text::as_str), Some("sso")); let find = |kind: Kind| { report .windows diff --git a/crates/herdr-usage/src/providers/elevenlabs.rs b/crates/herdr-usage/src/providers/elevenlabs.rs index ee4d9af62..daa7685c1 100644 --- a/crates/herdr-usage/src/providers/elevenlabs.rs +++ b/crates/herdr-usage/src/providers/elevenlabs.rs @@ -5,6 +5,7 @@ //! CodexBar reads is ported: character credits with their reset, voice and //! professional voice slots, and the plan tier and status. +use crate::text::{Text, pairs}; use crate::{ Result, { @@ -131,7 +132,10 @@ pub(crate) fn parse(body: &str) -> Result { }; Ok(Report::new( Provider(&Elevenlabs), - Account { email: None, plan }, + Account { + email: None, + plan: plan.map(Text::from), + }, vec![credits], ) .with_sections( @@ -139,7 +143,7 @@ pub(crate) fn parse(body: &str) -> Result { .iter() .map(|(title, used, limit)| { Section::Limit(Window::new( - Kind::Named((*title).to_owned()), + Kind::Named(Text::from((*title).to_owned())), percent(*used, *limit), None, None, @@ -147,7 +151,7 @@ pub(crate) fn parse(body: &str) -> Result { }) .chain([Section::Facts { title: "Usage".into(), - facts, + facts: pairs(facts), }]) .collect::>(), )) @@ -171,6 +175,7 @@ struct Subscription { mod tests { use super::*; use crate::Error; + use crate::text::pair; use std::time::{Duration, SystemTime}; /// Shaped like the documented subscription response. @@ -192,7 +197,10 @@ mod tests { fn reads_credits_slots_and_plan() { let report = parse(SUBSCRIPTION).unwrap(); assert_eq!(report.provider.id(), "elevenlabs"); - assert_eq!(report.account.plan.as_deref(), Some("Creator Annual")); + assert_eq!( + report.account.plan.as_ref().map(Text::as_str), + Some("Creator Annual") + ); let window = &report.windows[0]; assert_eq!(window.kind, Kind::Monthly); assert_eq!(window.percent(), 25); @@ -214,7 +222,7 @@ mod tests { }; assert_eq!( facts[0], - ("Credits".to_owned(), "25,000 of 100,000".to_owned()) + pair(("Credits".to_owned(), "25,000 of 100,000".to_owned())) ); } @@ -223,7 +231,10 @@ mod tests { let report = parse(r#"{"tier":"free","character_count":0,"character_limit":0,"status":"past_due"}"#) .unwrap(); - assert_eq!(report.account.plan.as_deref(), Some("Free · past_due")); + assert_eq!( + report.account.plan.as_ref().map(Text::as_str), + Some("Free · past_due") + ); assert_eq!(report.windows[0].percent(), 0); assert_eq!(report.sections.len(), 1); } diff --git a/crates/herdr-usage/src/providers/factory.rs b/crates/herdr-usage/src/providers/factory.rs index 9a73c0384..9d7c8c7f6 100644 --- a/crates/herdr-usage/src/providers/factory.rs +++ b/crates/herdr-usage/src/providers/factory.rs @@ -11,6 +11,7 @@ //! workos.com cookies, since the probe reads neither browser storage nor //! writes the session store that keeps rotated refresh tokens. +use crate::text::Text; use crate::{ Error, Result, { @@ -261,12 +262,13 @@ impl Me { fn account(&self, plan: Option) -> Account { Account { - email: self + email: (self .user_profile .as_ref() .and_then(|profile| profile.email.clone()) - .filter(|email| !email.trim().is_empty()), - plan, + .filter(|email| !email.trim().is_empty())) + .map(Text::from), + plan: plan.map(Text::from), } } @@ -279,7 +281,7 @@ impl Me { .filter(|name| !name.trim().is_empty())?; Some(Section::Facts { title: "Organization".into(), - facts: vec![("Name".into(), name)], + facts: vec![("Name".into(), Text::from(name))], }) } } @@ -489,9 +491,12 @@ mod tests { assert_eq!(report.windows[0].resets_at, Some(at(1_800_003_600))); assert_eq!(report.windows[0].length, Some(SESSION)); assert_eq!(report.balances[0].amount, 2.5); - assert_eq!(report.account.email.as_deref(), Some("dev@acme.test")); assert_eq!( - report.account.plan.as_deref(), + report.account.email.as_ref().map(Text::as_str), + Some("dev@acme.test") + ); + assert_eq!( + report.account.plan.as_ref().map(Text::as_str), Some("Factory Team - Team - Fallback: credits") ); } @@ -533,7 +538,10 @@ mod tests { assert_eq!(report.windows[1].percent(), 10); assert_eq!(report.windows[1].resets_at, Some(at(1_700_003_600))); assert_eq!(report.windows[1].length, Some(Duration::from_secs(3600))); - assert_eq!(report.account.plan.as_deref(), Some("Factory Team - Team")); + assert_eq!( + report.account.plan.as_ref().map(Text::as_str), + Some("Factory Team - Team") + ); assert_eq!( report.sections, vec![Section::Facts { diff --git a/crates/herdr-usage/src/providers/fireworks.rs b/crates/herdr-usage/src/providers/fireworks.rs index c2a8ecad1..fc735854a 100644 --- a/crates/herdr-usage/src/providers/fireworks.rs +++ b/crates/herdr-usage/src/providers/fireworks.rs @@ -8,6 +8,7 @@ //! discovered slug back to its config; this port rediscovers it each time //! instead, since it never writes the config. +use crate::text::Text; use crate::{ Error, Result, { @@ -171,7 +172,7 @@ pub(crate) fn parse(body: &str, slug: &str) -> Result { } let account = Section::Facts { title: "Account".into(), - facts: vec![("Slug".into(), slug.to_owned())], + facts: vec![("Slug".into(), Text::from(slug.to_owned()))], }; let report = Report::new(Provider(&Fireworks), Account::default(), Vec::new()); Ok(match currency { diff --git a/crates/herdr-usage/src/providers/gemini.rs b/crates/herdr-usage/src/providers/gemini.rs index 0c6646dfa..25aa7239c 100644 --- a/crates/herdr-usage/src/providers/gemini.rs +++ b/crates/herdr-usage/src/providers/gemini.rs @@ -14,6 +14,7 @@ //! a non-Gmail address; and the consumer-tier migration guidance, which here //! is the usual rejected sign-in. +use crate::text::{Text, pair}; use crate::{ Error, Result, { @@ -321,15 +322,20 @@ pub(crate) fn parse(body: &str, email: Option, plan: Option) -> let facts = models .iter() .map(|(id, fraction, _)| (id.clone(), format!("{}% left", (fraction * 100.).round()))) + .map(pair) .collect(); - Ok( - Report::new(Provider(&Gemini), Account { email, plan }, windows).with_sections([ - Section::Facts { - title: "Models".into(), - facts, - }, - ]), + Ok(Report::new( + Provider(&Gemini), + Account { + email: email.map(Text::from), + plan: plan.map(Text::from), + }, + windows, ) + .with_sections([Section::Facts { + title: "Models".into(), + facts, + }])) } #[derive(Deserialize)] @@ -420,7 +426,10 @@ mod tests { report.windows[0].resets_at, Some(SystemTime::UNIX_EPOCH + Duration::from_secs(1_735_689_600)) ); - assert_eq!(report.account.email.as_deref(), Some("me@gmail.com")); + assert_eq!( + report.account.email.as_ref().map(Text::as_str), + Some("me@gmail.com") + ); let Section::Facts { facts, .. } = &report.sections[0] else { panic!("facts"); }; diff --git a/crates/herdr-usage/src/providers/gitkraken.rs b/crates/herdr-usage/src/providers/gitkraken.rs index fdd3f3c49..2dcfd7460 100644 --- a/crates/herdr-usage/src/providers/gitkraken.rs +++ b/crates/herdr-usage/src/providers/gitkraken.rs @@ -5,6 +5,7 @@ //! are not supported there either, and there is no OAuth refresh, so an //! expired token has to be replaced by hand. +use crate::text::pairs; use crate::{ Result, { @@ -100,7 +101,7 @@ pub(crate) fn parse(body: &str) -> Result { Report::new(Provider(&Gitkraken), Account::default(), windows).with_sections([ Section::Facts { title: "Weekly usage".into(), - facts, + facts: pairs(facts), }, ]), ) diff --git a/crates/herdr-usage/src/providers/grok.rs b/crates/herdr-usage/src/providers/grok.rs index 2c1867b9f..3e0901cb5 100644 --- a/crates/herdr-usage/src/providers/grok.rs +++ b/crates/herdr-usage/src/providers/grok.rs @@ -11,6 +11,7 @@ //! paths, so only the Grok CLI's published client id and the legacy //! sign-in scope are recognized. +use crate::text::{Text, pairs}; use crate::{ Error, Result, { @@ -63,10 +64,11 @@ impl Service for Grok { continue; } let account = Account { - email: probe.text(&auth, &[scope, "email"]), - plan: probe + email: probe.text(&auth, &[scope, "email"]).map(Text::from), + plan: (probe .text(&auth, &[scope, "auth_mode"]) - .and_then(|mode| login_plan(&mode)), + .and_then(|mode| login_plan(&mode))) + .map(Text::from), }; return Some(fetch(probe, &token, account)); } @@ -137,7 +139,7 @@ pub(crate) fn parse( .or(credits.subscription_tier.as_deref()) .and_then(plan_name) { - account.plan = Some(plan); + account.plan = Some(Text::from(plan)); } let time = |text: &Option| text.clone().and_then(|text| Timestamp::Text(text).time()); let period_end = config @@ -185,7 +187,7 @@ pub(crate) fn parse( (None, None) => return Err(invalid()), }; let unknown = windows.is_empty().then(|| { - let mut facts = vec![("Usage".into(), "Not reported".into())]; + let mut facts: Vec<(String, String)> = vec![("Usage".into(), "Not reported".into())]; if let Some(end) = end { let end = chrono::DateTime::::from(end); facts.push(( @@ -195,7 +197,7 @@ pub(crate) fn parse( } Section::Facts { title: "Included credits".into(), - facts, + facts: pairs(facts), } }); Ok(Report::new(Provider(&Grok), account, windows) diff --git a/crates/herdr-usage/src/providers/grok/tests.rs b/crates/herdr-usage/src/providers/grok/tests.rs index 02a43bd59..2427fbd14 100644 --- a/crates/herdr-usage/src/providers/grok/tests.rs +++ b/crates/herdr-usage/src/providers/grok/tests.rs @@ -1,6 +1,7 @@ #![allow(clippy::unwrap_used, clippy::expect_used)] use super::*; +use crate::text::Text; fn at(text: &str) -> SystemTime { Timestamp::Text(text.into()).time().unwrap() @@ -23,7 +24,7 @@ fn parses_weekly_credits() { }"#; let account = Account { email: Some("user@example.com".into()), - plan: login_plan("oidc"), + plan: login_plan("oidc").map(Text::from), }; let report = parse( body, @@ -40,8 +41,14 @@ fn parses_weekly_credits() { report.windows[0].resets_at, Some(at("2026-08-13T00:00:00Z")) ); - assert_eq!(report.account.plan.as_deref(), Some("SuperGrok Heavy")); - assert_eq!(report.account.email.as_deref(), Some("user@example.com")); + assert_eq!( + report.account.plan.as_ref().map(Text::as_str), + Some("SuperGrok Heavy") + ); + assert_eq!( + report.account.email.as_ref().map(Text::as_str), + Some("user@example.com") + ); } #[test] diff --git a/crates/herdr-usage/src/providers/grok/tests/prepaid.rs b/crates/herdr-usage/src/providers/grok/tests/prepaid.rs index 612c6e2a1..e25925cb0 100644 --- a/crates/herdr-usage/src/providers/grok/tests/prepaid.rs +++ b/crates/herdr-usage/src/providers/grok/tests/prepaid.rs @@ -1,4 +1,5 @@ use super::*; +use crate::text::Text; #[test] fn unified_billing_shows_wallet_and_period_without_inventing_usage() { @@ -17,7 +18,10 @@ fn unified_billing_shows_wallet_and_period_without_inventing_usage() { ) .unwrap(); assert!(report.windows.is_empty()); - assert_eq!(report.account.plan.as_deref(), Some("SuperGrok")); + assert_eq!( + report.account.plan.as_ref().map(Text::as_str), + Some("SuperGrok") + ); assert_eq!( report.balances, vec![Balance::new( diff --git a/crates/herdr-usage/src/providers/groq.rs b/crates/herdr-usage/src/providers/groq.rs index 144535da5..27cc76a82 100644 --- a/crates/herdr-usage/src/providers/groq.rs +++ b/crates/herdr-usage/src/providers/groq.rs @@ -9,6 +9,7 @@ //! encode a secret nor read inside a JWT, and the bare cookie value cannot be //! cut out of a Cookie header. +use crate::text::pairs; use crate::{ Error, Result, { @@ -126,7 +127,7 @@ fn report([requests, tokens_in, tokens_out, cache]: [f64; 4]) -> Report { } Report::new(Provider(&Groq), metrics_account(), Vec::new()).with_sections([Section::Facts { title: "Last 5 minutes".into(), - facts, + facts: pairs(facts), }]) } diff --git a/crates/herdr-usage/src/providers/helmcode.rs b/crates/herdr-usage/src/providers/helmcode.rs index 845516243..225d8c9ea 100644 --- a/crates/herdr-usage/src/providers/helmcode.rs +++ b/crates/herdr-usage/src/providers/helmcode.rs @@ -7,6 +7,7 @@ //! CodexBar's per-tenant keychain cache of imported sessions is not ported: //! cookies are read again on each refresh. +use crate::text::{Text, pairs}; use crate::{ Error, Result, { @@ -158,7 +159,7 @@ fn parse(quota: &str, premium: bool, credits: Option<&str>, tenant: &str) -> Res .or(Some(MONTH)), }; windows.push(Window::new( - Kind::Named(model.model.clone()), + Kind::Named(Text::from(model.model.clone())), share(model) * 100., resets_at, length, @@ -177,14 +178,14 @@ fn parse(quota: &str, premium: bool, credits: Option<&str>, tenant: &str) -> Res Provider(&Helmcode), Account { email: None, - plan: Some(tenant.to_owned()), + plan: Some(Text::from(tenant.to_owned())), }, windows, ) .with_balances(credits.and_then(prepaid)) .with_sections((!facts.is_empty()).then(|| Section::Facts { title: "Model quotas".into(), - facts, + facts: pairs(facts), }))) } @@ -259,7 +260,10 @@ mod tests { #[test] fn reads_model_quotas_by_utilization() { let report = parse(QUOTA, false, None, "Helmcode Cloud").unwrap(); - assert_eq!(report.account.plan.as_deref(), Some("Helmcode Cloud")); + assert_eq!( + report.account.plan.as_ref().map(Text::as_str), + Some("Helmcode Cloud") + ); // Unlimited models have no percentage; rolling tiers need premium. assert_eq!(report.windows.len(), 2); let fast = report diff --git a/crates/herdr-usage/src/providers/huggingface.rs b/crates/herdr-usage/src/providers/huggingface.rs index 621ebe796..c57abe5e0 100644 --- a/crates/herdr-usage/src/providers/huggingface.rs +++ b/crates/herdr-usage/src/providers/huggingface.rs @@ -11,6 +11,7 @@ //! twelve-hour identity cache is not ported: `whoami-v2` is asked each //! refresh, and its failure only leaves the account out. +use crate::text::{Text, pairs}; use crate::{ Result, { @@ -169,7 +170,7 @@ pub(crate) fn parse( } let mut sections = vec![Section::Facts { title: "Inference Providers".into(), - facts, + facts: pairs(facts), }]; let mut windows = Vec::new(); if let Some(quota) = gpu.and_then(|body| json::(body).ok()) { @@ -187,8 +188,8 @@ pub(crate) fn parse( sections.push(Section::Facts { title: "ZeroGPU".into(), facts: vec![ - ("GPU time used".into(), minutes(consumed)), - ("GPU time remaining".into(), minutes(left)), + ("GPU time used".into(), Text::from(minutes(consumed))), + ("GPU time remaining".into(), Text::from(minutes(left))), ], }); } @@ -210,17 +211,19 @@ pub(crate) fn parse( { sections.push(Section::Facts { title: "Account".into(), - facts: vec![("User".into(), name.to_owned())], + facts: vec![("User".into(), Text::from(name.to_owned()))], }); } Account { - email: profile + email: (profile .email .map(|email| email.trim().to_owned()) - .filter(|email| !email.is_empty()), - plan: profile + .filter(|email| !email.is_empty())) + .map(Text::from), + plan: (profile .is_pro - .map(|pro| if pro { "PRO" } else { "Free" }.to_owned()), + .map(|pro| if pro { "PRO" } else { "Free" }.to_owned())) + .map(Text::from), } }); Ok(Report::new(Provider(&Huggingface), account, windows) @@ -419,8 +422,11 @@ mod tests { assert_eq!(report.windows[0].kind, Kind::Named("ZeroGPU".into())); assert!((report.windows[0].used - 40.).abs() < 0.01); assert!(report.windows[0].resets_at.is_some()); - assert_eq!(report.account.plan.as_deref(), Some("PRO")); - assert_eq!(report.account.email.as_deref(), Some("a@example.com")); + assert_eq!(report.account.plan.as_ref().map(Text::as_str), Some("PRO")); + assert_eq!( + report.account.email.as_ref().map(Text::as_str), + Some("a@example.com") + ); let Section::Facts { facts, .. } = &report.sections[0] else { panic!("expected facts"); }; diff --git a/crates/herdr-usage/src/providers/hyper.rs b/crates/herdr-usage/src/providers/hyper.rs index a3f1101ba..b91cbbc2d 100644 --- a/crates/herdr-usage/src/providers/hyper.rs +++ b/crates/herdr-usage/src/providers/hyper.rs @@ -4,6 +4,7 @@ //! back to an API key from the config or `HYPER_API_KEY`, as CodexBar's Auto //! mode does. The service reports only a balance: no plan, limit, or reset. +use crate::model::Count; use crate::{ Error, Result, { @@ -102,7 +103,7 @@ pub(crate) fn parse(body: &str, source: Source) -> Result { Report::new(Provider(&Hyper), account, Vec::new()).with_balances([Balance::new( "Hypercredits", balance, - Unit::Count("HC".into()), + Unit::Count(Count::Named("HC".into())), )]), ) } @@ -116,6 +117,7 @@ struct Credits { #[allow(clippy::unwrap_used, clippy::expect_used)] mod tests { use super::*; + use crate::text::Text; #[test] fn balance_is_hypercredits() { @@ -123,9 +125,15 @@ mod tests { assert!(report.windows.is_empty()); assert_eq!(report.balances.len(), 1); assert_eq!(report.balances[0].amount, 42.5); - assert_eq!(report.balances[0].unit, Unit::Count("HC".into())); + assert_eq!( + report.balances[0].unit, + Unit::Count(Count::Named("HC".into())) + ); assert_eq!(report.balances[0].amount_text(), "42.50 HC"); - assert_eq!(report.account.plan.as_deref(), Some("API key")); + assert_eq!( + report.account.plan.as_ref().map(Text::as_str), + Some("API key") + ); } #[test] diff --git a/crates/herdr-usage/src/providers/ibmbob.rs b/crates/herdr-usage/src/providers/ibmbob.rs index 3987755c7..ae6bc2bc0 100644 --- a/crates/herdr-usage/src/providers/ibmbob.rs +++ b/crates/herdr-usage/src/providers/ibmbob.rs @@ -7,6 +7,8 @@ //! be under `bob.ibm.com` before the key is sent there. Bob has no local //! sign-in that CodexBar reads. +use crate::model::Count; +use crate::text::{Text, pair}; use crate::{ Result, { @@ -206,7 +208,11 @@ pub(crate) fn report(teams: &[Team]) -> Result { .map(|limit| Window::new(Kind::Monthly, used / limit * 100., resets_at, Some(MONTH))) .into_iter() .collect(); - let mut balance = Balance::new("Bobcoins used", used, Unit::Count("Bobcoins".into())); + let mut balance = Balance::new( + "Bobcoins used", + used, + Unit::Count(Count::Named("Bobcoins".into())), + ); if let Some(limit) = limit { balance = balance.out_of(limit); } @@ -230,12 +236,15 @@ pub(crate) fn report(teams: &[Team]) -> Result { }; (label, value) }) + .map(pair) .collect(); Ok(Report::new( Provider(&Ibmbob), Account { email: None, - plan: (!plans.is_empty()).then(|| plans.join(", ")), + plan: (!plans.is_empty()) + .then(|| plans.join(", ")) + .map(Text::from), }, windows, ) @@ -328,7 +337,10 @@ mod tests { targets.next().unwrap().usage(r#"{"usage":25}"#).unwrap(), ]; let report = report(&teams).unwrap(); - assert_eq!(report.account.plan.as_deref(), Some("Enterprise, Pro+")); + assert_eq!( + report.account.plan.as_ref().map(Text::as_str), + Some("Enterprise, Pro+") + ); assert_eq!(report.windows.len(), 1); assert_eq!(report.windows[0].kind, Kind::Monthly); assert!((report.windows[0].used - 17.5).abs() < 0.001); diff --git a/crates/herdr-usage/src/providers/jetbrains.rs b/crates/herdr-usage/src/providers/jetbrains.rs index dd5a67627..859552823 100644 --- a/crates/herdr-usage/src/providers/jetbrains.rs +++ b/crates/herdr-usage/src/providers/jetbrains.rs @@ -7,6 +7,8 @@ //! `~/.config/{JetBrains,Google}` and `~/.local/share/JetBrains` elsewhere), //! taking the most recently written quota file, or set with `ide_path`. +use crate::model::Count; +use crate::text::pairs; use crate::{ Result, { @@ -170,12 +172,12 @@ pub(crate) fn parse(xml: &str, ide: Option) -> Result { Ok( Report::new(Provider(&Jetbrains), Account::default(), vec![window]) .with_balances([ - Balance::new("Credits left", available, Unit::Count("credits".into())) + Balance::new("Credits left", available, Unit::Count(Count::Credits)) .out_of(maximum), ]) .with_sections((!facts.is_empty()).then(|| Section::Facts { title: "AI Assistant".into(), - facts, + facts: pairs(facts), })), ) } @@ -369,7 +371,7 @@ mod tests { assert_eq!( report.balances, vec![ - Balance::new("Credits left", 992_521.7, Unit::Count("credits".into())) + Balance::new("Credits left", 992_521.7, Unit::Count(Count::Credits)) .out_of(1_000_000.) ] ); diff --git a/crates/herdr-usage/src/providers/kilo.rs b/crates/herdr-usage/src/providers/kilo.rs index 0eb67dde2..6135d7fee 100644 --- a/crates/herdr-usage/src/providers/kilo.rs +++ b/crates/herdr-usage/src/providers/kilo.rs @@ -9,6 +9,8 @@ //! pass state has no `subscription` object: only the plan name is read from //! such answers. +use crate::model::Count; +use crate::text::{Text, pairs}; use crate::{ Error, Result, { @@ -125,14 +127,19 @@ pub(crate) fn parse(body: &str) -> Result { if windows.is_empty() && balances.is_none() && plan.is_none() { return Err(invalid()); } - Ok( - Report::new(Provider(&Kilo), Account { email: None, plan }, windows) - .with_balances(balances) - .with_sections((!facts.is_empty()).then(|| Section::Facts { - title: "Plan".into(), - facts, - })), + Ok(Report::new( + Provider(&Kilo), + Account { + email: None, + plan: plan.map(Text::from), + }, + windows, ) + .with_balances(balances) + .with_sections((!facts.is_empty()).then(|| Section::Facts { + title: "Plan".into(), + facts: pairs(facts), + }))) } /// The batch answer is an array, an object keyed by index, or, for one @@ -259,7 +266,7 @@ fn credits(payload: Option<&Value>) -> Option { used, total, remaining, - Unit::Count("credits".into()), + Unit::Count(Count::Credits), )); } @@ -483,7 +490,10 @@ mod tests { #[test] fn reads_credit_blocks_and_the_pass() { let report = parse(PASS).unwrap(); - assert_eq!(report.account.plan.as_deref(), Some("Starter")); + assert_eq!( + report.account.plan.as_ref().map(Text::as_str), + Some("Starter") + ); assert_eq!( report.balances, vec![Balance::new("Credits left", 19., Unit::Currency("USD".into())).out_of(19.)] @@ -513,10 +523,13 @@ mod tests { {"result":{"data":{"json":{"enabled":true,"paymentMethod":"visa"}}}} ]"#; let report = parse(body).unwrap(); - assert_eq!(report.account.plan.as_deref(), Some("Kilo Pass Pro")); + assert_eq!( + report.account.plan.as_ref().map(Text::as_str), + Some("Kilo Pass Pro") + ); assert_eq!( report.balances, - vec![Balance::new("Credits left", 75., Unit::Count("credits".into())).out_of(100.)] + vec![Balance::new("Credits left", 75., Unit::Count(Count::Credits)).out_of(100.)] ); assert!(report.windows.is_empty()); let Section::Facts { facts, .. } = &report.sections[0] else { @@ -533,7 +546,10 @@ mod tests { {"result":{"data":{"enabled":true,"amountCents":5000,"paymentMethod":null}}} ]"#; let report = parse(body).unwrap(); - assert_eq!(report.account.plan.as_deref(), Some("Kilo Pass")); + assert_eq!( + report.account.plan.as_ref().map(Text::as_str), + Some("Kilo Pass") + ); assert_eq!(report.windows[0].percent(), 5); assert_eq!( report.balances, diff --git a/crates/herdr-usage/src/providers/kimi.rs b/crates/herdr-usage/src/providers/kimi.rs index aa47d1e97..6abb5704c 100644 --- a/crates/herdr-usage/src/providers/kimi.rs +++ b/crates/herdr-usage/src/providers/kimi.rs @@ -21,6 +21,7 @@ //! storage (where kimi.ai keeps `access_token`), retrying the next browser //! session when one is rejected, and multiple labeled web accounts. +use crate::text::{Text, pairs}; use crate::{ Error, Result, { @@ -287,14 +288,14 @@ impl Usage { Provider(&Kimi), Account { email: None, - plan: self.plan, + plan: self.plan.map(Text::from), }, windows, ) .with_sections(distinct.map(Section::Limit).into_iter().chain( (!facts.is_empty()).then(|| Section::Facts { title: "Requests".into(), - facts, + facts: pairs(facts), }), )) } @@ -691,7 +692,10 @@ mod tests { #[test] fn reads_request_counts() { let report = parse_code(COUNTS).unwrap().report(); - assert_eq!(report.account.plan.as_deref(), Some("Moderato")); + assert_eq!( + report.account.plan.as_ref().map(Text::as_str), + Some("Moderato") + ); let session = &report.windows[0]; assert_eq!(session.kind, Kind::Session); assert!((session.used - 69.5).abs() < 1e-4); @@ -773,7 +777,10 @@ mod tests { r#"{"subscription":{"active":true,"status":"SUBSCRIPTION_STATUS_ACTIVE","goods":{"title":"Allegretto"}}}"#, ); let report = usage.report(); - assert_eq!(report.account.plan.as_deref(), Some("Allegretto")); + assert_eq!( + report.account.plan.as_ref().map(Text::as_str), + Some("Allegretto") + ); assert_eq!(report.windows.len(), 3); assert_eq!(report.windows[2].kind, Kind::Named("Total usage".into())); assert_eq!(report.windows[2].percent(), 42); diff --git a/crates/herdr-usage/src/providers/kiro.rs b/crates/herdr-usage/src/providers/kiro.rs index ebe6c1098..a065a8e6d 100644 --- a/crates/herdr-usage/src/providers/kiro.rs +++ b/crates/herdr-usage/src/providers/kiro.rs @@ -10,6 +10,8 @@ //! and its pseudo-terminal fallback for Kiro CLI releases that print nothing //! through pipes; the `/context` window breakdown is left out too. +use crate::model::Count; +use crate::text::{Text, pairs}; use crate::{ Error, Result, { @@ -116,7 +118,7 @@ pub(crate) fn parse(output: &str, email: Option, now: SystemTime) -> Res Balance::new( "Credits left", (total - used).max(0.), - Unit::Count("credits".into()), + Unit::Count(Count::Credits), ) .out_of(total), ); @@ -156,15 +158,15 @@ pub(crate) fn parse(output: &str, email: Option, now: SystemTime) -> Res Ok(Report::new( Provider(&Kiro), Account { - email, - plan: Some(display_plan(&plan.name)), + email: email.map(Text::from), + plan: Some(Text::from(display_plan(&plan.name))), }, windows, ) .with_balances(balances) .with_sections((!facts.is_empty()).then(|| Section::Facts { title: "Overages".into(), - facts, + facts: pairs(facts), }))) } @@ -397,15 +399,21 @@ mod tests { Credits (12.50 of 50 covered in plan)\n\ ████████████████████ 25%\n"; let report = parse(output, Some("person@example.com".into()), now()).unwrap(); - assert_eq!(report.account.plan.as_deref(), Some("Kiro Free")); - assert_eq!(report.account.email.as_deref(), Some("person@example.com")); + assert_eq!( + report.account.plan.as_ref().map(Text::as_str), + Some("Kiro Free") + ); + assert_eq!( + report.account.email.as_ref().map(Text::as_str), + Some("person@example.com") + ); let window = &report.windows[0]; assert_eq!(window.kind, Kind::Monthly); assert_eq!(window.percent(), 25); assert!(window.resets_at.is_some()); assert_eq!( report.balances, - vec![Balance::new("Credits left", 37.5, Unit::Count("credits".into())).out_of(50.)] + vec![Balance::new("Credits left", 37.5, Unit::Count(Count::Credits)).out_of(50.)] ); } @@ -422,7 +430,10 @@ mod tests { "https://app.kiro.dev/account/usage\n", ); let report = parse(output, None, now()).unwrap(); - assert_eq!(report.account.plan.as_deref(), Some("Kiro Free")); + assert_eq!( + report.account.plan.as_ref().map(Text::as_str), + Some("Kiro Free") + ); assert_eq!(report.windows[0].percent(), 0); let bonus = &report.windows[1]; assert_eq!(bonus.kind, Kind::Named("Bonus credits".into())); @@ -450,7 +461,10 @@ mod tests { #[test] fn keeps_a_plan_only_summary() { let report = parse("Plan: KIRO PRO MAX | 1 usage breakdowns\n", None, now()).unwrap(); - assert_eq!(report.account.plan.as_deref(), Some("Kiro Pro Max")); + assert_eq!( + report.account.plan.as_ref().map(Text::as_str), + Some("Kiro Pro Max") + ); assert!(report.windows.is_empty()); assert!(report.balances.is_empty()); } @@ -463,7 +477,10 @@ mod tests { Credits used: 12.5\n\ Est. cost: $0.50 USD\n"; let report = parse(output, None, now()).unwrap(); - assert_eq!(report.account.plan.as_deref(), Some("Q Developer Pro")); + assert_eq!( + report.account.plan.as_ref().map(Text::as_str), + Some("Q Developer Pro") + ); assert_eq!(report.windows[0].percent(), 40); assert_eq!( report.sections, diff --git a/crates/herdr-usage/src/providers/litellm.rs b/crates/herdr-usage/src/providers/litellm.rs index 9e75f65cd..b2822d890 100644 --- a/crates/herdr-usage/src/providers/litellm.rs +++ b/crates/herdr-usage/src/providers/litellm.rs @@ -5,6 +5,7 @@ //! `/user/spend/report` when the deployment hides the info routes. Everything //! CodexBar reads is ported; no master key is used. +use crate::text::{Text, pairs}; use crate::{ Error, Result, { @@ -234,10 +235,10 @@ fn report( } let key_facts = (!facts.is_empty()).then(|| Section::Facts { title: "Key".into(), - facts, + facts: pairs(facts), }); let account = Account { - email, + email: email.map(Text::from), plan: Some("API key".into()), }; Report::new(Provider(&Litellm), account, windows) @@ -264,7 +265,7 @@ pub(crate) fn parse_spend(body: &str, scope: &str, start: &str, end: &str) -> Re ); let period = Section::Facts { title: "Spend report".into(), - facts: vec![("Period".into(), format!("{start} – {end} UTC"))], + facts: vec![("Period".into(), format!("{start} – {end} UTC").into())], }; Ok( Report::new(Provider(&Litellm), Account::default(), Vec::new()) @@ -285,7 +286,7 @@ impl Spend { fn window(&self) -> Option { (self.limit > 0.).then(|| { Window::new( - Kind::Named(format!("{} budget", self.scope)), + Kind::Named(format!("{} budget", self.scope).into()), self.used / self.limit * 100., self.resets_at.as_ref().and_then(Timestamp::time), self.length, @@ -426,7 +427,7 @@ mod tests { .unwrap(); let report = parse_user(USER, &key).unwrap(); assert_eq!( - report.account.email.as_deref(), + report.account.email.as_ref().map(Text::as_str), Some("litellm-user@example.com") ); assert_eq!(report.windows.len(), 2); diff --git a/crates/herdr-usage/src/providers/llmman.rs b/crates/herdr-usage/src/providers/llmman.rs index 7aa96f2dc..02d9598fa 100644 --- a/crates/herdr-usage/src/providers/llmman.rs +++ b/crates/herdr-usage/src/providers/llmman.rs @@ -15,6 +15,7 @@ //! `LLMMAN_HOST` is read on the probed host, which is this app's environment //! when the host is this machine, as in CodexBar. +use crate::text::{Text, pairs}; use crate::{ Error, Result, { @@ -237,19 +238,19 @@ pub(crate) fn parse(body: &str, keyed: bool, version: Option) -> Result< let plan = if keyed { "API key" } else { "Local daemon" }; let mut sections = vec![Section::Facts { title: "Daemon".into(), - facts: summary, + facts: pairs(summary), }]; if !models.is_empty() { sections.push(Section::Facts { title: "Loaded models".into(), - facts: models, + facts: pairs(models), }); } Ok(Report::new( Provider(&Llmman), Account { email: None, - plan: Some(plan.to_owned()), + plan: Some(Text::from(plan.to_owned())), }, windows, ) diff --git a/crates/herdr-usage/src/providers/llmman/tests.rs b/crates/herdr-usage/src/providers/llmman/tests.rs index c9e820163..0adff5130 100644 --- a/crates/herdr-usage/src/providers/llmman/tests.rs +++ b/crates/herdr-usage/src/providers/llmman/tests.rs @@ -1,4 +1,5 @@ use super::*; +use crate::text::Text; /// CodexBar's fixture: 8 GB + 2 GB loaded of 40 GB model memory. const NODE: &str = r#"{"memory":40000000000, @@ -25,7 +26,10 @@ fn loaded_weights_fill_the_memory_window_largest_first() { assert_eq!(report.windows[0].kind, Kind::Named("Memory".into())); assert_eq!(report.windows[0].percent(), 25); assert_eq!(report.windows[0].resets_at, None); - assert_eq!(report.account.plan.as_deref(), Some("API key")); + assert_eq!( + report.account.plan.as_ref().map(Text::as_str), + Some("API key") + ); assert_eq!( facts(&report, 0), ( @@ -54,7 +58,10 @@ fn loaded_weights_fill_the_memory_window_largest_first() { fn an_idle_open_daemon_still_reports() { let report = parse(r#"{"memory":0,"loaded":{},"stored":{}}"#, false, None).unwrap(); assert!(report.windows.is_empty()); - assert_eq!(report.account.plan.as_deref(), Some("Local daemon")); + assert_eq!( + report.account.plan.as_ref().map(Text::as_str), + Some("Local daemon") + ); assert_eq!(report.sections.len(), 1); assert_eq!( facts(&report, 0).1, diff --git a/crates/herdr-usage/src/providers/llmproxy.rs b/crates/herdr-usage/src/providers/llmproxy.rs index 1878b6a72..deffd0ee0 100644 --- a/crates/herdr-usage/src/providers/llmproxy.rs +++ b/crates/herdr-usage/src/providers/llmproxy.rs @@ -3,6 +3,7 @@ //! `LLM_PROXY_API_KEY`, here or on the probed host) at the configured //! `base_url`. Everything CodexBar reads is ported. +use crate::text::{Text, pair}; use crate::{ Error, Result, { @@ -123,13 +124,13 @@ pub(crate) fn parse(body: &str, now: SystemTime) -> Result { .collect(); let account = Account { email: None, - plan: Some(format!("{active}/{credentials} active keys")), + plan: Some(format!("{active}/{credentials} active keys").into()), }; let totals = Section::Facts { title: "Totals".into(), facts: vec![ - ("Requests".into(), count(requests)), - ("Tokens".into(), count(tokens)), + ("Requests".into(), Text::from(count(requests))), + ("Tokens".into(), Text::from(count(tokens))), ], }; let top = (!providers.is_empty()).then(|| Section::Facts { @@ -148,6 +149,7 @@ pub(crate) fn parse(body: &str, now: SystemTime) -> Result { } (name.clone(), text) }) + .map(pair) .collect(), }); Ok(Report::new(Provider(&Llmproxy), account, windows) @@ -265,7 +267,10 @@ mod tests { report.windows[0].resets_at, Timestamp::Text("2026-05-18T12:00:00Z".into()).time() ); - assert_eq!(report.account.plan.as_deref(), Some("3/4 active keys")); + assert_eq!( + report.account.plan.as_ref().map(Text::as_str), + Some("3/4 active keys") + ); assert_eq!(report.balances[0].amount, 15.5); let Section::Facts { facts, .. } = &report.sections[0] else { panic!("expected totals"); diff --git a/crates/herdr-usage/src/providers/longcat.rs b/crates/herdr-usage/src/providers/longcat.rs index 6becbedcb..db4b46e35 100644 --- a/crates/herdr-usage/src/providers/longcat.rs +++ b/crates/herdr-usage/src/providers/longcat.rs @@ -8,6 +8,7 @@ //! a pasted cURL command in place of the header; here only the header is //! accepted. +use crate::text::{Text, pairs}; use crate::{ Error, Result, { @@ -244,14 +245,14 @@ fn build( Report::new( Provider(&Longcat), Account { - email: name, + email: name.map(Text::from), plan: None, }, windows, ) .with_sections((!facts.is_empty()).then(|| Section::Facts { title: "Quota".into(), - facts, + facts: pairs(facts), })) } @@ -274,7 +275,10 @@ mod tests { ); let fuel = data(r#"{"code":0,"data":{"totalQuota":0,"list":[]}}"#); let report = build(Some(&account), None, Some(&usage), Some(&fuel)); - assert_eq!(report.account.email.as_deref(), Some("LongCat User")); + assert_eq!( + report.account.email.as_ref().map(Text::as_str), + Some("LongCat User") + ); assert_eq!(report.windows.len(), 1); assert_eq!(report.windows[0].used, 24.); } diff --git a/crates/herdr-usage/src/providers/manus.rs b/crates/herdr-usage/src/providers/manus.rs index a8b605fd5..8a3c119cf 100644 --- a/crates/herdr-usage/src/providers/manus.rs +++ b/crates/herdr-usage/src/providers/manus.rs @@ -7,6 +7,8 @@ //! Unlike CodexBar, a rejected browser session is not retried with the next //! one: an explicitly set token is simply tried first. +use crate::model::Count; +use crate::text::pairs; use crate::{ Result, { @@ -122,14 +124,10 @@ pub(crate) fn parse(body: &str) -> Result { )); } Ok(Report::new(Provider(&Manus), Account::default(), windows) - .with_balances([Balance::new( - "Balance", - total, - Unit::Count("credits".into()), - )]) + .with_balances([Balance::new("Balance", total, Unit::Count(Count::Credits))]) .with_sections([Section::Facts { title: "Credits".into(), - facts, + facts: pairs(facts), }])) } @@ -230,7 +228,7 @@ mod tests { assert_eq!(daily.percent(), 67); assert!(daily.resets_at.is_some()); assert_eq!(report.balances[0].amount, 4300.); - assert_eq!(report.balances[0].unit, Unit::Count("credits".into())); + assert_eq!(report.balances[0].unit, Unit::Count(Count::Credits)); let Section::Facts { facts, .. } = &report.sections[0] else { panic!("facts"); }; diff --git a/crates/herdr-usage/src/providers/mimo.rs b/crates/herdr-usage/src/providers/mimo.rs index 1d15a648f..a1cbf769d 100644 --- a/crates/herdr-usage/src/providers/mimo.rs +++ b/crates/herdr-usage/src/providers/mimo.rs @@ -8,6 +8,7 @@ //! separate wrapper script writes to `~/.codexbar/mimo-local-usage.json`. //! That file belongs to CodexBar's own tooling, not to MiMo. +use crate::text::{Text, pairs}; use crate::{ Error, Result, { @@ -131,13 +132,16 @@ pub(crate) fn parse(balance: &str, detail: Option<&str>, usage: Option<&str>) -> .filter(|code| !code.is_empty()); Ok(Report::new( Provider(&Mimo), - Account { email: None, plan }, + Account { + email: None, + plan: plan.map(Text::from), + }, window.into_iter().collect(), ) .with_balances([Balance::new("Balance", amount, Unit::Currency(currency))]) .with_sections((!facts.is_empty()).then(|| Section::Facts { title: "Account".into(), - facts, + facts: pairs(facts), }))) } @@ -201,6 +205,7 @@ struct UsageItem { #[allow(clippy::unwrap_used, clippy::expect_used)] mod tests { use super::*; + use crate::text::pair; use std::time::{Duration, SystemTime}; /// CodexBar's combined-snapshot fixtures. @@ -213,7 +218,10 @@ mod tests { fn merges_balance_and_token_plan() { let report = parse(BALANCE, Some(DETAIL), Some(USAGE)).unwrap(); assert_eq!(report.provider.id(), "mimo"); - assert_eq!(report.account.plan.as_deref(), Some("Standard")); + assert_eq!( + report.account.plan.as_ref().map(Text::as_str), + Some("Standard") + ); let window = &report.windows[0]; assert_eq!(window.kind, Kind::Monthly); assert!((window.used - 5.05).abs() < 1e-4); @@ -226,14 +234,14 @@ mod tests { let Section::Facts { facts, .. } = &report.sections[0] else { panic!("expected facts"); }; - assert_eq!(facts[0], ("Paid".to_owned(), "$20.00".to_owned())); - assert_eq!(facts[1], ("Granted".to_owned(), "$5.51".to_owned())); + assert_eq!(facts[0], pair(("Paid".to_owned(), "$20.00".to_owned()))); + assert_eq!(facts[1], pair(("Granted".to_owned(), "$5.51".to_owned()))); assert_eq!( facts[2], - ( + pair(( "Token plan".to_owned(), "10,100,158 of 200,000,000 credits".to_owned() - ) + )) ); } diff --git a/crates/herdr-usage/src/providers/minimax.rs b/crates/herdr-usage/src/providers/minimax.rs index f602a98b8..7d362068d 100644 --- a/crates/herdr-usage/src/providers/minimax.rs +++ b/crates/herdr-usage/src/providers/minimax.rs @@ -12,6 +12,8 @@ //! browser storage, and the page scrape and billing history add little to the //! quota windows. +use crate::model::Count; +use crate::text::Text; use crate::{ Error, Result, { @@ -225,13 +227,16 @@ pub(crate) fn parse(body: &str, now: SystemTime) -> Result { .into_iter() .flatten() .find(|value| value.is_finite()); - Ok( - Report::new(Provider(&Minimax), Account { email: None, plan }, windows) - .with_balances( - points.map(|points| Balance::new("Points", points, Unit::Count("points".into()))), - ) - .with_sections(others.into_iter().map(Section::Limit)), + Ok(Report::new( + Provider(&Minimax), + Account { + email: None, + plan: plan.map(Text::from), + }, + windows, ) + .with_balances(points.map(|points| Balance::new("Points", points, Unit::Count(Count::Points)))) + .with_sections(others.into_iter().map(Section::Limit))) } /// `general` and `MiniMax-M*` are text generation; the rest are named by @@ -314,10 +319,12 @@ impl Lane { _ if text && weekly => Kind::Weekly, Some(length) if text && near(length, SESSION) => Kind::Session, Some(length) if near(length, Duration::from_secs(86_400)) => { - Kind::Named(format!("{service} daily")) + Kind::Named(format!("{service} daily").into()) } - Some(length) => Kind::Named(format!("{service} {}h", length.as_secs().div_ceil(3600))), - None => Kind::Named(service.to_owned()), + Some(length) => { + Kind::Named(format!("{service} {}h", length.as_secs().div_ceil(3600)).into()) + } + None => Kind::Named(Text::from(service.to_owned())), }; let length = if weekly { length.or(Some(WEEK)) @@ -438,7 +445,7 @@ mod tests { "weekly_start_time": {week_start}, "weekly_end_time": {week_end}}}]}}"# ); let report = parse(&body, at(1_700_000_000)).unwrap(); - assert_eq!(report.account.plan.as_deref(), Some("Max")); + assert_eq!(report.account.plan.as_ref().map(Text::as_str), Some("Max")); assert_eq!(report.windows.len(), 2); let session = &report.windows[0]; assert_eq!(session.kind, Kind::Session); diff --git a/crates/herdr-usage/src/providers/mistral.rs b/crates/herdr-usage/src/providers/mistral.rs index d0b2e49c9..0f064e2a9 100644 --- a/crates/herdr-usage/src/providers/mistral.rs +++ b/crates/herdr-usage/src/providers/mistral.rs @@ -9,6 +9,7 @@ //! allowance still shows when the subscription page reports it. Daily usage //! buckets for CodexBar's cost chart are left out. +use crate::text::{Text, pairs}; use crate::{ Result, { @@ -174,17 +175,17 @@ pub(crate) fn parse( if !allowances.is_empty() { sections.push(Section::Facts { title: "Allowances".into(), - facts: allowances, + facts: pairs(allowances), }); } let tokens = |value: f64| group(value.round() as i64); sections.push(Section::Facts { title: "Tokens this month".into(), facts: vec![ - ("Input".into(), tokens(totals.input)), - ("Output".into(), tokens(totals.output)), - ("Cached".into(), tokens(totals.cached)), - ("Models".into(), totals.models.to_string()), + ("Input".into(), Text::from(tokens(totals.input))), + ("Output".into(), Text::from(tokens(totals.output))), + ("Cached".into(), Text::from(tokens(totals.cached))), + ("Models".into(), Text::from(totals.models.to_string())), ], }); if totals.cost > 0. { @@ -199,7 +200,7 @@ pub(crate) fn parse( if !shares.is_empty() { sections.push(Section::Shares { title: "Spend by model".into(), - shares, + shares: crate::text::shares(shares), }); } } @@ -451,6 +452,7 @@ struct Credits { #[allow(clippy::unwrap_used, clippy::expect_used)] mod tests { use super::*; + use crate::text::pair; const NOVEMBER: &str = r#"{"completion":{"models":{"mistral-large-latest::mistral-large-2411":{"input":[{"usage_type":"usage","event_type":"api_tokens","billing_metric":"mistral-large-2411","billing_display_name":"mistral-large-latest","billing_group":"input","timestamp":"2025-11-14","value":11121,"value_paid":11121}],"output":[{"usage_type":"usage","event_type":"api_tokens","billing_metric":"mistral-large-2411","billing_display_name":"mistral-large-latest","billing_group":"output","timestamp":"2025-11-14","value":1115,"value_paid":1115}]},"mistral-small-latest::mistral-small-2506":{"input":[{"usage_type":"usage","event_type":"api_tokens","billing_metric":"mistral-small-2506","billing_display_name":"mistral-small-latest","billing_group":"input","timestamp":"2025-11-14","value":20,"value_paid":20},{"usage_type":"usage","event_type":"api_tokens","billing_metric":"mistral-small-2506","billing_display_name":"mistral-small-latest","billing_group":"input","timestamp":"2025-11-24","value":100,"value_paid":100}],"output":[{"usage_type":"usage","event_type":"api_tokens","billing_metric":"mistral-small-2506","billing_display_name":"mistral-small-latest","billing_group":"output","timestamp":"2025-11-14","value":500,"value_paid":500},{"usage_type":"usage","event_type":"api_tokens","billing_metric":"mistral-small-2506","billing_display_name":"mistral-small-latest","billing_group":"output","timestamp":"2025-11-24","value":2482,"value_paid":2482}]}}},"ocr":{"models":{}},"connectors":{"models":{}},"libraries_api":{"pages":{"models":{}},"tokens":{"models":{}}},"fine_tuning":{"training":{},"storage":{}},"audio":{"models":{}},"vibe_usage":0.0,"date":"2025-11-01T00:00:00Z","previous_month":"2025-10","next_month":"2025-12","start_date":"2025-11-01T00:00:00Z","end_date":"2025-11-30T23:59:59.999Z","currency":"EUR","currency_symbol":"€","prices":[{"event_type":"api_tokens","billing_metric":"mistral-large-2411","billing_group":"input","price":"0.0000017000"},{"event_type":"api_tokens","billing_metric":"mistral-large-2411","billing_group":"output","price":"0.0000051000"},{"event_type":"api_tokens","billing_metric":"mistral-small-2506","billing_group":"input","price":"8.50E-8"},{"event_type":"api_tokens","billing_metric":"mistral-small-2506","billing_group":"output","price":"2.550E-7"}]}"#; @@ -474,10 +476,10 @@ mod tests { let Some(Section::Facts { facts, .. }) = report.sections.first() else { panic!("expected token facts"); }; - assert_eq!(facts[0], ("Input".to_owned(), "11,241".to_owned())); - assert_eq!(facts[1], ("Output".to_owned(), "4,097".to_owned())); - assert_eq!(facts[2], ("Cached".to_owned(), "0".to_owned())); - assert_eq!(facts[3], ("Models".to_owned(), "2".to_owned())); + assert_eq!(facts[0], pair(("Input".to_owned(), "11,241".to_owned()))); + assert_eq!(facts[1], pair(("Output".to_owned(), "4,097".to_owned()))); + assert_eq!(facts[2], pair(("Cached".to_owned(), "0".to_owned()))); + assert_eq!(facts[3], pair(("Models".to_owned(), "2".to_owned()))); let Some(Section::Shares { shares, .. }) = report.sections.get(1) else { panic!("expected model shares"); }; diff --git a/crates/herdr-usage/src/providers/moonshot.rs b/crates/herdr-usage/src/providers/moonshot.rs index 3795020d8..4e550d90b 100644 --- a/crates/herdr-usage/src/providers/moonshot.rs +++ b/crates/herdr-usage/src/providers/moonshot.rs @@ -7,6 +7,7 @@ //! default, or China mainland (`api.moonshot.cn`, CNY) with `region = //! "china"`. Kimi Code subscriptions are a separate provider (`kimi`). +use crate::text::pairs; use crate::{ Result, { @@ -109,7 +110,7 @@ pub(crate) fn parse(body: &str, region: Region) -> Result { .with_balances([Balance::new("Available", data.available_balance, unit())]) .with_sections([Section::Facts { title: "Balance".into(), - facts, + facts: pairs(facts), }]), ) } diff --git a/crates/herdr-usage/src/providers/muse.rs b/crates/herdr-usage/src/providers/muse.rs index 5e34494d6..9802d175a 100644 --- a/crates/herdr-usage/src/providers/muse.rs +++ b/crates/herdr-usage/src/providers/muse.rs @@ -14,6 +14,7 @@ //! On a remote host the minted inference key comes back with the response //! body, as it does to CodexBar, and is dropped by the parser. +use crate::text::Text; use crate::{ Result, { @@ -82,8 +83,8 @@ pub(crate) fn parse(body: &str) -> Result { .map(|email| email.trim().to_owned()) .filter(|email| !email.is_empty()); let account = Account { - email, - plan: plan.or_else(|| Some("Muse login".into())), + email: email.map(Text::from), + plan: plan.or_else(|| Some("Muse login".into())).map(Text::from), }; let status = if key.require_payment == Some(true) { Some("Payment method required; finish billing at https://dev.meta.ai") @@ -112,7 +113,7 @@ pub(crate) fn parse(body: &str) -> Result { let kind = if length == SESSION { Kind::Session } else { - Kind::Named(format!("{} hours", minutes / 60.)) + Kind::Named(format!("{} hours", minutes / 60.).into()) }; windows.push(Window::new( kind, @@ -184,9 +185,12 @@ mod tests { #[test] fn reads_both_windows_and_identity() { let report = parse(ACCOUNT).unwrap(); - assert_eq!(report.account.email.as_deref(), Some("ada@example.com")); assert_eq!( - report.account.plan.as_deref(), + report.account.email.as_ref().map(Text::as_str), + Some("ada@example.com") + ); + assert_eq!( + report.account.plan.as_ref().map(Text::as_str), Some("Muse Code Power Usage") ); assert_eq!(report.windows.len(), 2); @@ -210,7 +214,7 @@ mod tests { let report = parse(body).unwrap(); assert!(report.windows.is_empty()); assert_eq!( - report.account.plan.as_deref(), + report.account.plan.as_ref().map(Text::as_str), Some("Muse Code Power Usage") ); assert_eq!(report.sections.len(), 1); diff --git a/crates/herdr-usage/src/providers/neuralwatt.rs b/crates/herdr-usage/src/providers/neuralwatt.rs index cc3bb9ca7..b32f18f5a 100644 --- a/crates/herdr-usage/src/providers/neuralwatt.rs +++ b/crates/herdr-usage/src/providers/neuralwatt.rs @@ -5,6 +5,7 @@ //! credits (which never reset) are a balance, and a per-key spending //! allowance is an extra limit. Everything CodexBar reads is ported. +use crate::text::{Text, pairs}; use crate::{ Result, { @@ -129,10 +130,13 @@ fn parse(body: &str) -> Result { }; let allowance = allowance_used.map(|used| { Section::Limit(Window::new( - Kind::Named(format!( - "Key {}", - words(allowance.period.as_deref().unwrap_or("allowance")) - )), + Kind::Named( + format!( + "Key {}", + words(allowance.period.as_deref().unwrap_or("allowance")) + ) + .into(), + ), used, None, None, @@ -161,7 +165,10 @@ fn parse(body: &str) -> Result { .or_else(|| balance.accounting_method.map(|method| words(&method))); Ok(Report::new( Provider(&Neuralwatt), - Account { email: None, plan }, + Account { + email: None, + plan: plan.map(Text::from), + }, windows, ) .with_balances( @@ -172,7 +179,7 @@ fn parse(body: &str) -> Result { .into_iter() .chain((!facts.is_empty()).then(|| Section::Facts { title: "Usage".into(), - facts, + facts: pairs(facts), })), )) } @@ -262,7 +269,10 @@ mod tests { }"#, ) .unwrap(); - assert_eq!(report.account.plan.as_deref(), Some("Standard plan")); + assert_eq!( + report.account.plan.as_ref().map(Text::as_str), + Some("Standard plan") + ); let window = &report.windows[0]; assert_eq!(window.kind, Kind::Monthly); assert!((window.used - 69.5115).abs() < 0.01); @@ -293,7 +303,10 @@ mod tests { ) .unwrap(); assert!(report.windows.is_empty()); - assert_eq!(report.account.plan.as_deref(), Some("Token")); + assert_eq!( + report.account.plan.as_ref().map(Text::as_str), + Some("Token") + ); assert_eq!(report.balances[0].text(), "$51.00"); } diff --git a/crates/herdr-usage/src/providers/notion.rs b/crates/herdr-usage/src/providers/notion.rs index d99be092b..088e0b161 100644 --- a/crates/herdr-usage/src/providers/notion.rs +++ b/crates/herdr-usage/src/providers/notion.rs @@ -10,6 +10,7 @@ //! and CodexBar's persisted session cache. Allowances only exist on Business //! and Enterprise workspaces; others show that instead of windows. +use crate::text::{Text, pairs}; use crate::{ Result, { @@ -252,7 +253,7 @@ pub(crate) fn parse( Some(resets_at.and_then(calendar_month).unwrap_or(MONTH)), )); } - let mut facts = Vec::new(); + let mut facts: Vec<(String, String)> = Vec::new(); if let Some(name) = workspace .name .as_deref() @@ -274,18 +275,19 @@ pub(crate) fn parse( facts.push(("Enforcement".into(), title_case(enforcement))); } let account = Account { - email, - plan: workspace + email: email.map(Text::from), + plan: (workspace .tier .as_deref() .map(title_case) - .filter(|tier| !tier.is_empty()), + .filter(|tier| !tier.is_empty())) + .map(Text::from), }; Ok( Report::new(Provider(&Notion), account, windows).with_sections((!facts.is_empty()).then( || Section::Facts { title: "Notion AI".into(), - facts, + facts: pairs(facts), }, )), ) @@ -412,7 +414,10 @@ mod tests { let workspace = account.workspace(None).unwrap().clone(); let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_787_000_000); let report = parse(STATUS, account.email.clone(), &workspace, now).unwrap(); - assert_eq!(report.account.plan.as_deref(), Some("Business")); + assert_eq!( + report.account.plan.as_ref().map(Text::as_str), + Some("Business") + ); assert_eq!(report.windows.len(), 2); let rolling = &report.windows[0]; assert_eq!(rolling.kind, Kind::Session); diff --git a/crates/herdr-usage/src/providers/nous.rs b/crates/herdr-usage/src/providers/nous.rs index c3b0cb349..30b6b6954 100644 --- a/crates/herdr-usage/src/providers/nous.rs +++ b/crates/herdr-usage/src/providers/nous.rs @@ -11,6 +11,7 @@ //! which would need the token revealed; `expires_at` stored by Hermes is //! still honored. +use crate::text::{Text, pairs}; use crate::{ Error, Result, { @@ -245,7 +246,7 @@ pub(crate) fn parse(body: &str) -> Result { } balances.extend(purchased.map(|amount| Balance::new("Top-up credits", amount, usd()))); balances.extend(total.map(|amount| Balance::new("Total usable", amount, usd()))); - let mut facts = Vec::new(); + let mut facts: Vec<(String, String)> = Vec::new(); if let Some(organization) = account .organisation .and_then(|organization| organization.name) @@ -276,14 +277,19 @@ pub(crate) fn parse(body: &str) -> Result { .and_then(|user| user.email) .map(|email| email.trim().to_owned()) .filter(|email| !email.is_empty()); - Ok( - Report::new(Provider(&Nous), Account { email, plan }, windows) - .with_balances(balances) - .with_sections((!facts.is_empty()).then(|| Section::Facts { - title: "Subscription".into(), - facts, - })), + Ok(Report::new( + Provider(&Nous), + Account { + email: email.map(Text::from), + plan: plan.map(Text::from), + }, + windows, ) + .with_balances(balances) + .with_sections((!facts.is_empty()).then(|| Section::Facts { + title: "Subscription".into(), + facts: pairs(facts), + }))) } fn truthy(value: &serde_json::Value) -> bool { @@ -360,8 +366,14 @@ mod tests { #[test] fn keeps_subscription_and_purchased_credits_apart() { let report = parse(ACCOUNT).unwrap(); - assert_eq!(report.account.email.as_deref(), Some("dev@example.com")); - assert_eq!(report.account.plan.as_deref(), Some("Ultra")); + assert_eq!( + report.account.email.as_ref().map(Text::as_str), + Some("dev@example.com") + ); + assert_eq!( + report.account.plan.as_ref().map(Text::as_str), + Some("Ultra") + ); assert_eq!(report.windows.len(), 1); assert_eq!(report.windows[0].kind, Kind::Monthly); assert_eq!(report.windows[0].used, 75.); diff --git a/crates/herdr-usage/src/providers/ollama.rs b/crates/herdr-usage/src/providers/ollama.rs index 1373c60ff..a91a3be5f 100644 --- a/crates/herdr-usage/src/providers/ollama.rs +++ b/crates/herdr-usage/src/providers/ollama.rs @@ -12,6 +12,7 @@ //! Everything CodexBar reads is ported; the page is read by plain text //! search where CodexBar uses regular expressions. +use crate::text::Text; use crate::{ Error, Result, { @@ -138,7 +139,7 @@ pub(crate) fn parse_tags(body: &str) -> Result { ) .with_sections([Section::Facts { title: "Ollama Cloud".into(), - facts: vec![("Models".into(), tags.models.len().to_string())], + facts: vec![("Models".into(), Text::from(tags.models.len().to_string()))], }])) } @@ -180,8 +181,8 @@ pub(crate) fn parse(html: &str) -> Result { .map(|((used, resets_at), kind, length)| Window::new(kind, used, resets_at, Some(length))) .collect(); let account = Account { - email: email(html), - plan: plan(html).map(|plan| title_case(&plan)), + email: email(html).map(Text::from), + plan: plan(html).map(|plan| title_case(&plan)).map(Text::from), }; Ok(Report::new(Provider(&Ollama), account, windows)) } @@ -385,6 +386,7 @@ fn signed_out(html: &str) -> bool { #[allow(clippy::unwrap_used, clippy::expect_used)] mod tests { use super::*; + use crate::text::pair; fn at(rfc3339: &str) -> Option { Timestamp::Text(rfc3339.into()).time() @@ -449,8 +451,11 @@ mod tests { fn reads_monthly_included_credits() { let report = parse(MONTHLY_PAGE).unwrap(); assert_eq!(report.provider.id(), "ollama"); - assert_eq!(report.account.plan.as_deref(), Some("Pro")); - assert_eq!(report.account.email.as_deref(), Some("user@example.com")); + assert_eq!(report.account.plan.as_ref().map(Text::as_str), Some("Pro")); + assert_eq!( + report.account.email.as_ref().map(Text::as_str), + Some("user@example.com") + ); assert_eq!(report.windows.len(), 1); let window = &report.windows[0]; assert_eq!(window.kind, Kind::Monthly); @@ -461,7 +466,7 @@ mod tests { #[test] fn reads_legacy_session_and_weekly_windows() { let report = parse(LEGACY_PAGE).unwrap(); - assert_eq!(report.account.plan.as_deref(), Some("Free")); + assert_eq!(report.account.plan.as_ref().map(Text::as_str), Some("Free")); let session = &report.windows[0]; assert_eq!(session.kind, Kind::Session); assert!((session.used - 0.1).abs() < 1e-4); @@ -504,10 +509,13 @@ mod tests { let report = parse_tags(r#"{"models":[{"name":"gpt-oss:120b"},{"name":"qwen3"}]}"#).unwrap(); assert!(report.windows.is_empty()); - assert_eq!(report.account.plan.as_deref(), Some("API key")); + assert_eq!( + report.account.plan.as_ref().map(Text::as_str), + Some("API key") + ); let Section::Facts { facts, .. } = &report.sections[0] else { panic!("expected facts"); }; - assert_eq!(facts[0], ("Models".to_owned(), "2".to_owned())); + assert_eq!(facts[0], pair(("Models".to_owned(), "2".to_owned()))); } } diff --git a/crates/herdr-usage/src/providers/openai.rs b/crates/herdr-usage/src/providers/openai.rs index d01bc5f1c..936aa94b5 100644 --- a/crates/herdr-usage/src/providers/openai.rs +++ b/crates/herdr-usage/src/providers/openai.rs @@ -10,6 +10,7 @@ //! CodexBar's opt-in fallback does. There is no local sign-in: the Codex CLI's //! ChatGPT sign-in is the separate Codex provider. +use crate::text::{Text, pairs, share}; use crate::{ Error, Result, { @@ -280,18 +281,18 @@ impl Tally { let mut sections = vec![Section::Facts { title: "Spend".into(), facts: vec![ - ("Today".into(), usd(spend(today))), - ("Last 7 days".into(), usd(spend(since(7)))), + ("Today".into(), Text::from(usd(spend(today)))), + ("Last 7 days".into(), Text::from(usd(spend(since(7))))), ], }]; if requests > 0 || input + output > 0 { sections.push(Section::Facts { title: "Completions".into(), facts: vec![ - ("Requests".into(), count(requests)), - ("Input tokens".into(), count(input)), - ("Cached input tokens".into(), count(cached)), - ("Output tokens".into(), count(output)), + ("Requests".into(), Text::from(count(requests))), + ("Input tokens".into(), Text::from(count(input))), + ("Cached input tokens".into(), Text::from(count(cached))), + ("Output tokens".into(), Text::from(count(output))), ], }); } @@ -312,7 +313,7 @@ impl Tally { let account = Account { email: None, plan: Some(match project { - Some(project) => format!("Admin API: {project}"), + Some(project) => format!("Admin API: {project}").into(), None => "Admin API".into(), }), }; @@ -341,6 +342,7 @@ fn shares<'a>(title: &str, parts: impl Iterator) -> Optio .into_iter() .take(TOP) .map(|(label, value)| (label.to_owned(), (value / whole * 100.) as f32)) + .map(share) .collect(), }) } @@ -397,7 +399,7 @@ pub(crate) fn parse_grants(body: &str, now: i64) -> Result { .with_balances([balance]) .with_sections([Section::Facts { title: "Credit grants".into(), - facts, + facts: pairs(facts), }])) } @@ -534,7 +536,10 @@ mod tests { assert!(report.windows.is_empty()); assert_eq!(report.balances[0].label, "Spend, last 30 days"); assert!((report.balances[0].amount - 5.0).abs() < 1e-9); - assert_eq!(report.account.plan.as_deref(), Some("Admin API")); + assert_eq!( + report.account.plan.as_ref().map(Text::as_str), + Some("Admin API") + ); let Section::Facts { facts, .. } = &report.sections[0] else { panic!("expected spend facts"); }; @@ -562,7 +567,10 @@ mod tests { let report = tally.report(NOW, 1, Some("proj_abc")); assert_eq!(report.balances[0].label, "Spend today"); assert!((report.balances[0].amount - 3.0).abs() < 1e-9); - assert_eq!(report.account.plan.as_deref(), Some("Admin API: proj_abc")); + assert_eq!( + report.account.plan.as_ref().map(Text::as_str), + Some("Admin API: proj_abc") + ); } #[test] diff --git a/crates/herdr-usage/src/providers/opencode.rs b/crates/herdr-usage/src/providers/opencode.rs index c6aae9dde..6b43f8744 100644 --- a/crates/herdr-usage/src/providers/opencode.rs +++ b/crates/herdr-usage/src/providers/opencode.rs @@ -462,7 +462,7 @@ pub(crate) fn parse_billing(text: &str) -> Option { .with_balances(balances) .with_sections([Section::Facts { title: "This month".into(), - facts: vec![("Spent".into(), format!("${spent:.2}"))], + facts: vec![("Spent".into(), format!("${spent:.2}").into())], }]), ) } diff --git a/crates/herdr-usage/src/providers/opencodego.rs b/crates/herdr-usage/src/providers/opencodego.rs index 119f73527..3db1bcaa6 100644 --- a/crates/herdr-usage/src/providers/opencodego.rs +++ b/crates/herdr-usage/src/providers/opencodego.rs @@ -15,6 +15,7 @@ use super::opencode::{ BASE, COOKIES, DOMAINS, USER_AGENT, call, json_windows, meter, usage, value_number, windows as meter_windows, workspace_id, }; +use crate::text::Text; use crate::{ Error, Result, { @@ -201,7 +202,9 @@ fn report(windows: Vec, balance: Option) -> Report { Provider(&Opencodego), Account { email: None, - plan: (!windows.is_empty()).then(|| "Go".to_owned()), + plan: (!windows.is_empty()) + .then(|| "Go".to_owned()) + .map(Text::from), }, windows, ) diff --git a/crates/herdr-usage/src/providers/openrouter.rs b/crates/herdr-usage/src/providers/openrouter.rs index 4f7cac1ac..617c29b1d 100644 --- a/crates/herdr-usage/src/providers/openrouter.rs +++ b/crates/herdr-usage/src/providers/openrouter.rs @@ -10,6 +10,7 @@ //! completed day's activity separately to fill a history chart; only the //! 30-day totals are shown here. +use crate::text::{Text, pairs}; use crate::{ Result, { @@ -166,7 +167,10 @@ pub(crate) fn parse( .and_then(|free| free.then(|| "Free tier".to_owned())); Ok(Report::new( Provider(&Openrouter), - Account { email: None, plan }, + Account { + email: None, + plan: plan.map(Text::from), + }, windows, ) .with_balances(balance) @@ -195,10 +199,13 @@ fn activity_facts(rows: &[Activity]) -> Section { Section::Facts { title: "Last 30 days".into(), facts: vec![ - ("Spend".into(), usd(spend)), - ("Requests".into(), group(requests.round() as i64)), - ("Tokens".into(), group(tokens.round() as i64)), - ("Models".into(), models.len().to_string()), + ("Spend".into(), Text::from(usd(spend))), + ( + "Requests".into(), + Text::from(group(requests.round() as i64)), + ), + ("Tokens".into(), Text::from(group(tokens.round() as i64))), + ("Models".into(), Text::from(models.len().to_string())), ], } } @@ -270,7 +277,7 @@ impl KeyData { } fn facts(&self) -> Section { - let mut facts = Vec::new(); + let mut facts: Vec<(String, String)> = Vec::new(); match self.limit() { Some(limit) => { facts.push(("Spending cap".into(), usd(limit))); @@ -297,7 +304,7 @@ impl KeyData { } Section::Facts { title: "API key".into(), - facts, + facts: pairs(facts), } } } @@ -355,13 +362,16 @@ mod tests { .unwrap(); assert!(report.windows.is_empty()); assert!(report.balances.is_empty()); - assert_eq!(report.account.plan.as_deref(), Some("Free tier")); + assert_eq!( + report.account.plan.as_ref().map(Text::as_str), + Some("Free tier") + ); let Section::Facts { facts, .. } = &report.sections[0] else { panic!("expected facts"); }; assert_eq!( facts[0], - ("Spending cap".to_owned(), "No limit configured".to_owned()) + ("Spending cap".into(), "No limit configured".into()) ); } @@ -382,10 +392,10 @@ mod tests { assert_eq!( facts, &vec![ - ("Spend".to_owned(), "$1.50".to_owned()), - ("Requests".to_owned(), "5".to_owned()), - ("Tokens".to_owned(), "1,650".to_owned()), - ("Models".to_owned(), "2".to_owned()), + ("Spend".into(), "$1.50".into()), + ("Requests".into(), "5".into()), + ("Tokens".into(), "1,650".into()), + ("Models".into(), "2".into()), ] ); } diff --git a/crates/herdr-usage/src/providers/perplexity.rs b/crates/herdr-usage/src/providers/perplexity.rs index 8b06724fb..49e0c0f28 100644 --- a/crates/herdr-usage/src/providers/perplexity.rs +++ b/crates/herdr-usage/src/providers/perplexity.rs @@ -9,6 +9,8 @@ //! into numbered chunks into one value; here chunks are sent as they are, //! which the site's own session library also accepts. +use crate::model::Count; +use crate::text::{Text, pairs}; use crate::{ Error, Result, { @@ -131,7 +133,7 @@ pub(crate) fn parse(body: &str, now: SystemTime) -> Result { }; let pool = |label: &str, used: f64, total: f64| { (total > 0.).then(|| { - Balance::new(label, (total - used).max(0.), Unit::Count("credits".into())).out_of(total) + Balance::new(label, (total - used).max(0.), Unit::Count(Count::Credits)).out_of(total) }) }; let balances = [ @@ -162,13 +164,16 @@ pub(crate) fn parse(body: &str, now: SystemTime) -> Result { .map(str::to_owned); Ok(Report::new( Provider(&Perplexity), - Account { email: None, plan }, + Account { + email: None, + plan: plan.map(Text::from), + }, window.into_iter().collect(), ) .with_balances(balances.into_iter().flatten()) .with_sections([Section::Facts { title: "Credits".into(), - facts, + facts: pairs(facts), }])) } @@ -208,6 +213,7 @@ impl Grant { #[allow(clippy::unwrap_used, clippy::expect_used)] mod tests { use super::*; + use crate::text::pair; use std::time::Duration; const NOW: u64 = 1_780_000_000; @@ -238,7 +244,7 @@ mod tests { fn reads_recurring_and_bonus_credits() { let report = parse(&full(), now()).unwrap(); assert_eq!(report.provider.id(), "perplexity"); - assert_eq!(report.account.plan.as_deref(), Some("Max")); + assert_eq!(report.account.plan.as_ref().map(Text::as_str), Some("Max")); let window = &report.windows[0]; assert_eq!(window.kind, Kind::Monthly); assert!((window.used - 27.5).abs() < 1e-4); @@ -254,7 +260,7 @@ mod tests { }; assert_eq!( facts[0], - ("Credits".to_owned(), "2,750 of 10,000".to_owned()) + pair(("Credits".to_owned(), "2,750 of 10,000".to_owned())) ); } diff --git a/crates/herdr-usage/src/providers/poe.rs b/crates/herdr-usage/src/providers/poe.rs index 451b76c7a..93db4f90b 100644 --- a/crates/herdr-usage/src/providers/poe.rs +++ b/crates/herdr-usage/src/providers/poe.rs @@ -5,6 +5,8 @@ //! the balance. CodexBar's daily bar chart is summarized as per-period facts //! and a per-model split. Poe has no OAuth or cookie sign-in to port. +use crate::model::Count; +use crate::text::{pairs, share}; use crate::{ Result, { @@ -270,17 +272,18 @@ fn report(balance: Option, entries: &[Entry], now: SystemTime) -> Report { .iter() .take(5) .map(|(model, spent)| ((*model).to_owned(), (spent / total * 100.) as f32)) + .map(share) .collect(), }); } } let facts = (!facts.is_empty()).then(|| Section::Facts { title: "Points".into(), - facts, + facts: pairs(facts), }); Report::new(Provider(&Poe), Account::default(), Vec::new()) .with_balances( - balance.map(|balance| Balance::new("Points", balance, Unit::Count("points".into()))), + balance.map(|balance| Balance::new("Points", balance, Unit::Count(Count::Points))), ) .with_sections(facts.into_iter().chain(sections)) } diff --git a/crates/herdr-usage/src/providers/qoder.rs b/crates/herdr-usage/src/providers/qoder.rs index f91776b81..286c2250a 100644 --- a/crates/herdr-usage/src/providers/qoder.rs +++ b/crates/herdr-usage/src/providers/qoder.rs @@ -5,6 +5,8 @@ //! Not ported from CodexBar: parsing a pasted cURL or HTTP request capture to //! pick the site. The `site` setting chooses qoder.com.cn instead. +use crate::model::Count; +use crate::text::Text; use crate::{ Result, { @@ -96,13 +98,13 @@ pub(crate) fn parse(body: &str, china: bool) -> Result { let window = Window::new(Kind::Named("Credits".into()), percent, resets_at, None); let account = Account { email: None, - plan: china.then(|| "qoder.com.cn".to_owned()), + plan: china.then(|| "qoder.com.cn".to_owned()).map(Text::from), }; Ok( Report::new(Provider(&Qoder), account, vec![window]).with_balances([Balance::new( "Credits left", remaining, - Unit::Count("credits".into()), + Unit::Count(Count::Credits), ) .out_of(total)]), ) @@ -213,7 +215,10 @@ mod tests { assert_eq!(report.windows[0].percent(), 68); assert_eq!(report.balances[0].amount, 800.); assert_eq!(report.balances[0].total, Some(2500.)); - assert_eq!(report.account.plan.as_deref(), Some("qoder.com.cn")); + assert_eq!( + report.account.plan.as_ref().map(Text::as_str), + Some("qoder.com.cn") + ); } #[test] diff --git a/crates/herdr-usage/src/providers/qwencloud.rs b/crates/herdr-usage/src/providers/qwencloud.rs index c8f8e0fd6..e23fb18f1 100644 --- a/crates/herdr-usage/src/providers/qwencloud.rs +++ b/crates/herdr-usage/src/providers/qwencloud.rs @@ -143,6 +143,7 @@ pub(crate) fn parse( mod tests { use super::*; use crate::model::{Kind, Section}; + use crate::text::Text; use std::time::{Duration, SystemTime}; fn at(seconds: u64) -> SystemTime { @@ -165,7 +166,10 @@ mod tests { ), ) .unwrap(); - assert_eq!(report.account.plan.as_deref(), Some("Standard")); + assert_eq!( + report.account.plan.as_ref().map(Text::as_str), + Some("Standard") + ); assert_eq!(report.windows.len(), 2); assert_eq!(report.windows[0].kind, Kind::Session); assert!((report.windows[0].used - 3.).abs() < 1e-4); @@ -186,7 +190,10 @@ mod tests { None, ) .unwrap(); - assert_eq!(report.account.plan.as_deref(), Some("Token Plan")); + assert_eq!( + report.account.plan.as_ref().map(Text::as_str), + Some("Token Plan") + ); assert_eq!(report.windows[0].kind, Kind::Monthly); assert!((report.windows[0].used - 40.).abs() < 1e-4); assert!(report.sections.is_empty()); diff --git a/crates/herdr-usage/src/providers/raycast.rs b/crates/herdr-usage/src/providers/raycast.rs index b28e2b9db..28926b056 100644 --- a/crates/herdr-usage/src/providers/raycast.rs +++ b/crates/herdr-usage/src/providers/raycast.rs @@ -6,6 +6,8 @@ //! lives in the Raycast app's private storage. Top-up packages and the //! per-model breakdown are not fetched, as CodexBar does not either. +use crate::model::Count; +use crate::text::{Text, pairs}; use crate::{ Result, { @@ -76,8 +78,11 @@ pub(crate) fn parse(body: &str) -> Result { "max" => "Max".to_owned(), _ => tier, }); - let account = Account { email: None, plan }; - let credits_unit = || Unit::Count("credits".into()); + let account = Account { + email: None, + plan: plan.map(Text::from), + }; + let credits_unit = || Unit::Count(Count::Credits); // Rolled-over credits can leave more than the grant; the reported total // stays the denominator, so that reads as nothing used. let report = match (remaining, total) { @@ -91,7 +96,7 @@ pub(crate) fn parse(body: &str) -> Result { .with_balances([Balance::new("Credits left", remaining, credits_unit()).out_of(total)]) } _ => { - let mut facts = Vec::new(); + let mut facts: Vec<(String, String)> = Vec::new(); if let Some(remaining) = remaining { facts.push(("Left".into(), trimmed(remaining))); } @@ -109,7 +114,7 @@ pub(crate) fn parse(body: &str) -> Result { ) .with_sections([Section::Facts { title: "Credits".into(), - facts, + facts: pairs(facts), }]) } }; @@ -145,7 +150,7 @@ mod tests { "funding_subscription": {"tier": "pro", "status": "active"} }"#; let report = parse(body).unwrap(); - assert_eq!(report.account.plan.as_deref(), Some("Pro")); + assert_eq!(report.account.plan.as_ref().map(Text::as_str), Some("Pro")); assert_eq!(report.windows.len(), 1); let window = &report.windows[0]; assert_eq!(window.kind, Kind::Monthly); @@ -162,7 +167,7 @@ mod tests { "funding_subscription": {"tier": "pro_plus"}}"#; let report = parse(body).unwrap(); assert_eq!(report.windows[0].used, 0.); - assert_eq!(report.account.plan.as_deref(), Some("Pro+")); + assert_eq!(report.account.plan.as_ref().map(Text::as_str), Some("Pro+")); } #[test] diff --git a/crates/herdr-usage/src/providers/replicate.rs b/crates/herdr-usage/src/providers/replicate.rs index 1fa3a8b86..71ea388dd 100644 --- a/crates/herdr-usage/src/providers/replicate.rs +++ b/crates/herdr-usage/src/providers/replicate.rs @@ -7,6 +7,7 @@ //! CodexBar's cookie cache and retrying of several browser sessions are not //! ported: the first matching session is used. +use crate::text::Text; use crate::{ Error, Result, { @@ -189,7 +190,7 @@ pub(crate) fn parse( "User" } .into(), - owner.username.clone(), + Text::from(owner.username.clone()), )], }; Ok( diff --git a/crates/herdr-usage/src/providers/sakana.rs b/crates/herdr-usage/src/providers/sakana.rs index f0a01cf90..55d1ed796 100644 --- a/crates/herdr-usage/src/providers/sakana.rs +++ b/crates/herdr-usage/src/providers/sakana.rs @@ -7,6 +7,7 @@ //! Everything CodexBar reads is ported. CodexBar imports no browser cookies //! for Sakana; the automatic import here is an addition. +use crate::text::Text; use crate::{ Error, Result, { @@ -77,11 +78,16 @@ pub(crate) fn parse(billing: &str, payg: Option<&str>) -> Result { } let plan = plan(&nodes); let (balances, sections) = payg.and_then(pay_as_you_go).unwrap_or_default(); - Ok( - Report::new(Provider(&Sakana), Account { email: None, plan }, windows) - .with_balances(balances) - .with_sections(sections), + Ok(Report::new( + Provider(&Sakana), + Account { + email: None, + plan: plan.map(Text::from), + }, + windows, ) + .with_balances(balances) + .with_sections(sections)) } /// A window's body runs from its label to the next window label or card. @@ -209,7 +215,7 @@ fn pay_as_you_go(html: &str) -> Option<(Vec, Vec
)> { if let Some(range) = range { sections.push(Section::Facts { title: "Pay as you go".into(), - facts: vec![("Usage period".into(), range)], + facts: vec![("Usage period".into(), Text::from(range))], }); } } @@ -327,7 +333,10 @@ mod tests { #[test] fn billing_page_maps_windows_and_plan() { let report = parse(BILLING, None).unwrap(); - assert_eq!(report.account.plan.as_deref(), Some("Standard $20/mo")); + assert_eq!( + report.account.plan.as_ref().map(Text::as_str), + Some("Standard $20/mo") + ); assert_eq!( report.windows, vec![ diff --git a/crates/herdr-usage/src/providers/stepfun.rs b/crates/herdr-usage/src/providers/stepfun.rs index df333bc6d..e9afade74 100644 --- a/crates/herdr-usage/src/providers/stepfun.rs +++ b/crates/herdr-usage/src/providers/stepfun.rs @@ -9,6 +9,7 @@ //! deriving the Oasis-Webid from the token's JWT claims, which would mean //! decoding the secret here. The web id is therefore its own setting. +use crate::text::Text; use crate::{ Error, Result, { @@ -93,7 +94,10 @@ pub(crate) fn parse(body: &str, plan: Option) -> Result { if limits.status != Some(1) { return Err(Error::Rejected); } - let account = Account { email: None, plan }; + let account = Account { + email: None, + plan: plan.map(Text::from), + }; if limits.is_credit_plan() { let windows = limits .plan_credit_rate_limit @@ -260,7 +264,7 @@ mod tests { assert!(report.windows[0].resets_at.is_some()); assert_eq!(report.windows[1].kind, Kind::Weekly); assert_eq!(report.windows[1].percent(), 40); - assert_eq!(report.account.plan.as_deref(), Some("Plus")); + assert_eq!(report.account.plan.as_ref().map(Text::as_str), Some("Plus")); } #[test] diff --git a/crates/herdr-usage/src/providers/sub2api.rs b/crates/herdr-usage/src/providers/sub2api.rs index 6d51fcd0a..4a7be158b 100644 --- a/crates/herdr-usage/src/providers/sub2api.rs +++ b/crates/herdr-usage/src/providers/sub2api.rs @@ -6,6 +6,7 @@ //! are not ported: one key is read, so configure the group to watch. //! Daily totals use UTC days, where CodexBar passes the Mac's time zone. +use crate::text::{Text, pairs}; use crate::{ Error, Result, { @@ -147,7 +148,10 @@ fn parse(body: &str) -> Result { "5h" => (Kind::Session, Kind::Session.length()), "1d" => (Kind::Daily, Kind::Daily.length()), "7d" => (Kind::Weekly, Kind::Weekly.length()), - _ => (Kind::Named(format!("{} limit", rate.window.trim())), None), + _ => ( + Kind::Named(format!("{} limit", rate.window.trim()).into()), + None, + ), }; Window::new( kind, @@ -192,22 +196,27 @@ fn parse(body: &str) -> Result { )); } let plan = usage.plan_name.filter(|plan| !plan.trim().is_empty()); - Ok( - Report::new(Provider(&Sub2api), Account { email: None, plan }, windows) - .with_balances( - usage - .balance - .map(|balance| Balance::new("Balance", balance, currency(&unit))), - ) - .with_sections( - rate_sections - .into_iter() - .chain((!facts.is_empty()).then(|| Section::Facts { - title: "Usage summary".into(), - facts, - })), - ), + Ok(Report::new( + Provider(&Sub2api), + Account { + email: None, + plan: plan.map(Text::from), + }, + windows, + ) + .with_balances( + usage + .balance + .map(|balance| Balance::new("Balance", balance, currency(&unit))), ) + .with_sections( + rate_sections + .into_iter() + .chain((!facts.is_empty()).then(|| Section::Facts { + title: "Usage summary".into(), + facts: pairs(facts), + })), + )) } fn currency(unit: &str) -> Unit { @@ -215,7 +224,7 @@ fn currency(unit: &str) -> Unit { if code.len() == 3 && code.chars().all(|c| c.is_ascii_uppercase()) { Unit::Currency(code) } else { - Unit::Count(unit.trim().to_owned()) + Unit::Count(crate::model::Count::Named(unit.trim().to_owned())) } } @@ -282,7 +291,7 @@ struct Total { mod tests { use super::*; - fn facts(report: &Report) -> &[(String, String)] { + fn facts(report: &Report) -> &[(Text, Text)] { report .sections .iter() @@ -371,7 +380,10 @@ mod tests { (Kind::Monthly, 30.) ] ); - assert_eq!(report.account.plan.as_deref(), Some("Claude Team")); + assert_eq!( + report.account.plan.as_ref().map(Text::as_str), + Some("Claude Team") + ); assert!(facts(&report).contains(&("Expires".into(), "2026-08-15".into()))); } diff --git a/crates/herdr-usage/src/providers/synthetic.rs b/crates/herdr-usage/src/providers/synthetic.rs index 9774531e5..131acf911 100644 --- a/crates/herdr-usage/src/providers/synthetic.rs +++ b/crates/herdr-usage/src/providers/synthetic.rs @@ -4,6 +4,7 @@ //! reads is ported: the five-hour, weekly-token, and hourly-search lanes, the //! weekly credit budget, and the generic quota shapes CodexBar falls back to. +use crate::text::Text; use crate::{ Result, { @@ -201,10 +202,15 @@ pub(crate) fn parse(body: &str) -> Result { .collect(); let plan = first_string(&root, PLAN_KEYS) .or_else(|| data.and_then(|data| first_string(data, PLAN_KEYS))); - Ok( - Report::new(Provider(&Synthetic), Account { email: None, plan }, windows) - .with_balances(balance), + Ok(Report::new( + Provider(&Synthetic), + Account { + email: None, + plan: plan.map(Text::from), + }, + windows, ) + .with_balances(balance)) } struct Quota { @@ -225,7 +231,9 @@ impl Quota { Some(24) => Kind::Daily, Some(168) => Kind::Weekly, Some(720 | 744) => Kind::Monthly, - _ => Kind::Named(self.label.unwrap_or_else(|| format!("Quota {}", index + 1))), + _ => Kind::Named(Text::from( + self.label.unwrap_or_else(|| format!("Quota {}", index + 1)), + )), }); let length = self.length.or_else(|| match &kind { Kind::Named(name) if name == "Search hourly" => Some(Duration::from_secs(3600)), @@ -462,7 +470,10 @@ mod tests { fn reads_the_named_lanes_and_weekly_credits() { let report = parse(QUOTAS).unwrap(); assert_eq!(report.provider.id(), "synthetic"); - assert_eq!(report.account.plan.as_deref(), Some("Starter")); + assert_eq!( + report.account.plan.as_ref().map(Text::as_str), + Some("Starter") + ); let kinds: Vec<_> = report.windows.iter().map(|w| w.kind.clone()).collect(); assert_eq!( kinds, @@ -493,7 +504,7 @@ mod tests { ]}}"#, ) .unwrap(); - assert_eq!(report.account.plan.as_deref(), Some("Pro")); + assert_eq!(report.account.plan.as_ref().map(Text::as_str), Some("Pro")); assert_eq!(report.windows[0].kind, Kind::Daily); assert_eq!(report.windows[0].percent(), 25); assert_eq!(report.windows[0].resets_at, Some(at(1_776_400_000.))); diff --git a/crates/herdr-usage/src/providers/t3chat.rs b/crates/herdr-usage/src/providers/t3chat.rs index 3c4b7fd5e..47f4b44ae 100644 --- a/crates/herdr-usage/src/providers/t3chat.rs +++ b/crates/herdr-usage/src/providers/t3chat.rs @@ -9,6 +9,7 @@ //! Vercel security challenge that a bare Cookie header can trigger; here //! only the Cookie header is sent. +use crate::text::{Text, pairs}; use crate::{ Result, { @@ -140,12 +141,15 @@ pub(crate) fn parse(body: &str) -> Result { Ok(Report::new( Provider(&T3chat), - Account { email: None, plan }, + Account { + email: None, + plan: plan.map(Text::from), + }, vec![base, overage], ) .with_sections((!facts.is_empty()).then(|| Section::Facts { title: "Plan".into(), - facts, + facts: pairs(facts), }))) } @@ -208,7 +212,10 @@ mod tests { #[test] fn reads_base_and_overage_windows() { let report = parse(FIXTURE).unwrap(); - assert_eq!(report.account.plan.as_deref(), Some("Pro Monthly")); + assert_eq!( + report.account.plan.as_ref().map(Text::as_str), + Some("Pro Monthly") + ); assert_eq!(report.windows.len(), 2); let base = &report.windows[0]; assert_eq!(base.kind, Kind::Session); @@ -246,7 +253,7 @@ mod tests { r#""usagePeriodPercentage":40,"usageWindowNextResetAt":1790000000}"# ); let report = parse(body).unwrap(); - assert_eq!(report.account.plan.as_deref(), Some("Free")); + assert_eq!(report.account.plan.as_ref().map(Text::as_str), Some("Free")); assert_eq!(report.windows[0].percent(), 0); assert!(report.windows[0].resets_at.is_some()); assert_eq!(report.windows[1].percent(), 40); diff --git a/crates/herdr-usage/src/providers/typesafe.rs b/crates/herdr-usage/src/providers/typesafe.rs index e1e0c1235..11e349a3c 100644 --- a/crates/herdr-usage/src/providers/typesafe.rs +++ b/crates/herdr-usage/src/providers/typesafe.rs @@ -7,6 +7,7 @@ //! is read, as in CodexBar. TypeSafe reports no quota or reset, so there //! are only balances. +use crate::text::{Text, pairs}; use crate::{ Error, Result, { @@ -292,9 +293,12 @@ pub(crate) fn parse(body: &str) -> Result { }; let sections = (!credits.is_empty()).then(|| Section::Facts { title: "Credits".into(), - facts: credits, + facts: pairs(credits), }); - let account = Account { email: None, plan }; + let account = Account { + email: None, + plan: plan.map(Text::from), + }; Ok(Report::new(Provider(&Typesafe), account, Vec::new()) .with_balances([ Balance::new("Balance", balance, usd()), @@ -337,7 +341,7 @@ mod tests { assert_eq!(report.balances[0].amount, 4.98); assert_eq!(report.balances[1].label, "Spent (September 2026)"); assert_eq!(report.balances[1].amount, 0.01); - assert_eq!(report.account.plan.as_deref(), Some("Free")); + assert_eq!(report.account.plan.as_ref().map(Text::as_str), Some("Free")); let Section::Facts { facts, .. } = &report.sections[0] else { panic!("expected facts"); }; @@ -356,7 +360,10 @@ mod tests { fn plan_ids_read_as_names() { let body = BODY.replace("free_plan", "pro_monthly-PLAN"); let report = parse(&body).unwrap(); - assert_eq!(report.account.plan.as_deref(), Some("Pro Monthly Plan")); + assert_eq!( + report.account.plan.as_ref().map(Text::as_str), + Some("Pro Monthly Plan") + ); } #[test] diff --git a/crates/herdr-usage/src/providers/v0.rs b/crates/herdr-usage/src/providers/v0.rs index f221a11ba..9dd01ed6b 100644 --- a/crates/herdr-usage/src/providers/v0.rs +++ b/crates/herdr-usage/src/providers/v0.rs @@ -4,6 +4,7 @@ //! find, and CodexBar reads no cookies for it either. The free account's //! daily allowance and grace period are not shown, as in CodexBar. +use crate::text::pairs; use crate::{ Result, { @@ -122,7 +123,7 @@ pub(crate) fn parse(billing: &str, rate_limits: &str, scope: Option<&str>) -> Re Ok( Report::new(Provider(&V0), Account::default(), windows).with_sections([Section::Facts { title: "v0 API".into(), - facts, + facts: pairs(facts), }]), ) } diff --git a/crates/herdr-usage/src/providers/venice.rs b/crates/herdr-usage/src/providers/venice.rs index e7fa46779..fc51848ef 100644 --- a/crates/herdr-usage/src/providers/venice.rs +++ b/crates/herdr-usage/src/providers/venice.rs @@ -9,6 +9,8 @@ //! import here only takes the unsplit cookie, and a chunked one must be //! pasted as a whole `cookie` header. +use crate::model::Count; +use crate::text::{Text, pairs}; use crate::{ Error, Result, { @@ -82,7 +84,7 @@ pub(crate) fn parse_balance(body: &str) -> Result { .usd .map(|usd| Balance::new("USD", usd, Unit::Currency("USD".into()))); let diem = balances.diem.map(|diem| { - let balance = Balance::new("DIEM", diem, Unit::Count("DIEM".into())); + let balance = Balance::new("DIEM", diem, Unit::Count(Count::Named("DIEM".into()))); match allocation { Some(total) => balance.out_of(total), None => balance, @@ -167,7 +169,7 @@ pub(crate) fn parse_session(body: &str, now: SystemTime) -> Result { .monthly_refill_credits .filter(|refill| *refill > 0.) .ok_or(Error::Json(Category::Data))?; - let credits = || Unit::Count("credits".into()); + let credits = || Unit::Count(Count::Credits); let available = usage .available_credits .or(claims.bundled_credits) @@ -200,11 +202,11 @@ pub(crate) fn parse_session(body: &str, now: SystemTime) -> Result { } let section = (!facts.is_empty()).then(|| Section::Facts { title: "Credits".into(), - facts, + facts: pairs(facts), }); let account = Account { email: None, - plan: user_type, + plan: user_type.map(Text::from), }; Ok(Report::new(Provider(&Venice), account, Vec::new()) .with_balances(available.into_iter().chain(total).chain([spent])) @@ -293,6 +295,7 @@ struct CreditsUsage { #[allow(clippy::unwrap_used, clippy::expect_used)] mod tests { use super::*; + use crate::text::pair; fn encode(bytes: &[u8]) -> String { const TABLE: &[u8; 64] = @@ -333,7 +336,7 @@ mod tests { assert!((report.windows[0].used - 9.5).abs() < 1e-4); assert_eq!( report.balances, - vec![Balance::new("DIEM", 90.5, Unit::Count("DIEM".into())).out_of(100.)] + vec![Balance::new("DIEM", 90.5, Unit::Count(Count::Named("DIEM".into()))).out_of(100.)] ); } @@ -372,8 +375,8 @@ mod tests { "availableCredits": 750.5, "tierCap": 3000, "nextRefillAt": 1790500000000}}"#, ); let report = parse_session(&body, at(1_789_000_000)).unwrap(); - assert_eq!(report.account.plan.as_deref(), Some("pro")); - let credits = Unit::Count("credits".into()); + assert_eq!(report.account.plan.as_ref().map(Text::as_str), Some("pro")); + let credits = Unit::Count(Count::Credits); assert_eq!( report.balances, vec![ @@ -387,7 +390,7 @@ mod tests { }; assert_eq!( facts[0], - ("Bank cap".to_owned(), "3,000 credits".to_owned()) + pair(("Bank cap".to_owned(), "3,000 credits".to_owned())) ); assert_eq!(facts[1].0, "Next refill"); } diff --git a/crates/herdr-usage/src/providers/vertexai.rs b/crates/herdr-usage/src/providers/vertexai.rs index 172c8ee70..e7125ea70 100644 --- a/crates/herdr-usage/src/providers/vertexai.rs +++ b/crates/herdr-usage/src/providers/vertexai.rs @@ -17,6 +17,7 @@ //! a command that prints the token, or which would need request signing. //! Token costs from Claude Code's logs are not read either. +use crate::text::{Text, pairs}; use crate::{ Result, { @@ -204,14 +205,14 @@ pub(crate) fn report(email: Option, project: Option, peak: Optio Report::new( Provider(&Vertexai), Account { - email, + email: email.map(Text::from), plan: Some("gcloud".into()), }, windows, ) .with_sections([Section::Facts { title: "Google Cloud".into(), - facts, + facts: pairs(facts), }]) } @@ -373,7 +374,10 @@ mod tests { Some("my-project".into()), Some(peak), ); - assert_eq!(report.account.email.as_deref(), Some("dev@example.com")); + assert_eq!( + report.account.email.as_ref().map(Text::as_str), + Some("dev@example.com") + ); assert_eq!(report.windows[0].kind, Kind::Named("Peak quota".into())); assert_eq!(report.windows[0].percent(), 50); assert_eq!( diff --git a/crates/herdr-usage/src/providers/warp.rs b/crates/herdr-usage/src/providers/warp.rs index af33fb46b..e6e04e4ef 100644 --- a/crates/herdr-usage/src/providers/warp.rs +++ b/crates/herdr-usage/src/providers/warp.rs @@ -5,6 +5,8 @@ //! workspaces with the earliest expiry. The Warp app's own sign-in is not //! read, as CodexBar does not read it either. +use crate::model::Count; +use crate::text::{Text, pairs}; use crate::{ Error, Result, { @@ -129,7 +131,7 @@ pub(crate) fn parse(body: &str) -> Result { let balance = Balance::new( "Add-on credits", remaining.max(0) as f64, - Unit::Count("credits".into()), + Unit::Count(Count::Credits), ); if granted > 0 { balance.out_of(granted as f64) @@ -166,14 +168,14 @@ pub(crate) fn parse(body: &str) -> Result { Provider(&Warp), Account { email: None, - plan: unlimited.then(|| "Unlimited".to_owned()), + plan: unlimited.then(|| "Unlimited".to_owned()).map(Text::from), }, vec![monthly], ) .with_balances(bonus) .with_sections([Section::Facts { title: "Credits".into(), - facts, + facts: pairs(facts), }])) } @@ -268,6 +270,7 @@ impl Grant { #[allow(clippy::unwrap_used, clippy::expect_used)] mod tests { use super::*; + use crate::text::pair; /// CodexBar's fetcher fixture. const LIMITS: &str = r#"{ @@ -312,14 +315,14 @@ mod tests { }; assert_eq!( facts[0], - ("Monthly credits".to_owned(), "5 of 1,500".to_owned()) + pair(("Monthly credits".to_owned(), "5 of 1,500".to_owned())) ); assert_eq!( facts[1], - ( + pair(( "Next add-on expiry".to_owned(), "10 credits on Mar 1, 2026".to_owned() - ) + )) ); } @@ -330,7 +333,10 @@ mod tests { {"isUnlimited":true,"requestLimit":0,"requestsUsedSinceLastRefresh":0}}}}}"#, ) .unwrap(); - assert_eq!(report.account.plan.as_deref(), Some("Unlimited")); + assert_eq!( + report.account.plan.as_ref().map(Text::as_str), + Some("Unlimited") + ); assert_eq!(report.windows[0].percent(), 0); assert!(report.balances.is_empty()); } diff --git a/crates/herdr-usage/src/providers/wayfinder.rs b/crates/herdr-usage/src/providers/wayfinder.rs index 9f4519bd2..e82f4a1f8 100644 --- a/crates/herdr-usage/src/providers/wayfinder.rs +++ b/crates/herdr-usage/src/providers/wayfinder.rs @@ -13,6 +13,7 @@ use crate::{ model::{Account, Balance, Provider, Report, Section, Unit, group}, probe::{Probe, Request}, service::{Meta, Service, Setting, json}, + text::Text, }, }; use serde::{Deserialize, de::IgnoredAny}; @@ -88,42 +89,42 @@ fn parse(health: &str, models: &str, savings: &str, metrics: Option<&str>) -> Re let savings: Savings = json(savings)?; let missing = health.missing_keys.map_or(0, |keys| keys.len()); let status = if health.offline { - "Offline mode".to_owned() + Text::message("usage-wayfinder-offline") } else if models.dry_run { - "Dry run".to_owned() + Text::message("usage-wayfinder-dry-run") } else if health.status == "degraded" { - match missing { - 0 => "Degraded".to_owned(), - 1 => "Degraded, 1 key missing".to_owned(), - count => format!("Degraded, {count} keys missing"), - } + Text::message("usage-wayfinder-degraded").arg("missing", missing) } else { - "Local gateway".to_owned() + Text::message("usage-wayfinder-local-gateway") }; - let model_count = match models.models.len() { - 1 => "1 model".to_owned(), - count => format!("{count} models"), + let model_count = models.models.len(); + // The gateway's own health word, such as `ok` or `degraded`. + let mode = match (health.offline, models.dry_run) { + (false, false) => "live", + (true, false) => "offline", + (false, true) => "dry-run", + (true, true) => "both", }; - let mut gateway = format!("{} · {model_count}", health.status); - if health.offline { - gateway.push_str(" · offline"); - } - if models.dry_run { - gateway.push_str(" · dry run"); - } + let gateway = Text::message("usage-wayfinder-gateway-state") + .arg("status", health.status.as_str()) + .arg("models", model_count) + .arg("mode", mode); let mut routes: Vec<(String, Route)> = savings.by_route.into_iter().collect(); routes.sort_by(|a, b| b.1.requests.cmp(&a.1.requests).then_with(|| a.0.cmp(&b.0))); - let mut facts = vec![("Gateway".to_owned(), gateway)]; + let mut facts = vec![(Text::message("usage-wayfinder-gateway"), gateway)]; if savings.requests > 0 && !routes.is_empty() { + // Route names are the gateway's, as are the separators between them. facts.push(( - "Routed".into(), - routes - .iter() - .take(5) - .map(|(name, route)| format!("{name}: {}", group(route.requests))) - .collect::>() - .join(" · "), + Text::message("usage-wayfinder-routed"), + Text::service( + routes + .iter() + .take(5) + .map(|(name, route)| format!("{name}: {}", group(route.requests))) + .collect::>() + .join(" · "), + ), )); } let saved = savings.requests > 0 && savings.saved > 0.; @@ -133,26 +134,32 @@ fn parse(health: &str, models: &str, savings: &str, metrics: Option<&str>) -> Re } else { format!("{:.1}", savings.saved_pct) }; - let relative = format!("{percent}% vs highest-cost route"); // Unpriced gateways save in relative units, never dollars. - let text = match (savings.priced, savings.saved < 0.01) { - (false, _) => relative, - (true, true) => format!("<$0.01 · {relative}"), - (true, false) => format!("${:.2} · {relative}", savings.saved), + let value = match (savings.priced, savings.saved < 0.01) { + (false, _) => Text::message("usage-wayfinder-saved-relative").arg("percent", percent), + (true, true) => Text::message("usage-wayfinder-saved-value") + .arg("amount", "<$0.01") + .arg("percent", percent), + (true, false) => Text::message("usage-wayfinder-saved-value") + .arg("amount", format!("${:.2}", savings.saved)) + .arg("percent", percent), }; - facts.push(("Saved, last 30 days".into(), text)); + facts.push((Text::message("usage-wayfinder-saved"), value)); } if let Some(latency) = metrics.and_then(average_decision_ms) { - facts.push(("Avg decision".into(), format!("{latency:.1} ms"))); + facts.push(( + Text::message("usage-wayfinder-decision"), + Text::message("usage-wayfinder-milliseconds").arg("time", format!("{latency:.1}")), + )); } let shares = (savings.requests > 0).then(|| Section::Shares { - title: "Requests by route, last 30 days".into(), + title: Text::message("usage-wayfinder-requests-by-route"), shares: routes .iter() .take(5) .map(|(name, route)| { ( - name.clone(), + Text::service(name), (route.requests.max(0) as f64 / savings.requests as f64 * 100.) as f32, ) }) @@ -161,21 +168,21 @@ fn parse(health: &str, models: &str, savings: &str, metrics: Option<&str>) -> Re Ok(Report::new( Provider(&Wayfinder), Account { - email: Some(format!("{model_count} · local gateway")), + email: Some(Text::message("usage-wayfinder-account").arg("models", model_count)), plan: Some(status), }, Vec::new(), ) .with_balances((saved && savings.priced).then(|| { Balance::new( - "Saved, last 30 days", + Text::message("usage-wayfinder-saved"), savings.saved, Unit::Currency("USD".into()), ) })) .with_sections( [Section::Facts { - title: "Usage".into(), + title: Text::message("usage-report-usage"), facts, }] .into_iter() @@ -255,12 +262,12 @@ mod tests { let report = parse(HEALTH, MODELS, SAVINGS, Some(METRICS)).unwrap(); assert!(report.windows.is_empty()); assert_eq!( - report.account.email.as_deref(), - Some("2 models · local gateway") + report.account.email, + Some(Text::message("usage-wayfinder-account").arg("models", 2)) ); assert_eq!( - report.account.plan.as_deref(), - Some("Degraded, 1 key missing") + report.account.plan, + Some(Text::message("usage-wayfinder-degraded").arg("missing", 1)) ); assert_eq!(report.balances[0].text(), "$4.12"); let Section::Facts { facts, .. } = &report.sections[0] else { @@ -269,13 +276,27 @@ mod tests { assert_eq!( facts, &[ - ("Gateway".into(), "degraded · 2 models".into()), - ("Routed".into(), "local: 10 · cloud: 4".into()), ( - "Saved, last 30 days".into(), - "$4.12 · 73.3% vs highest-cost route".into() + Text::message("usage-wayfinder-gateway"), + Text::message("usage-wayfinder-gateway-state") + .arg("status", "degraded") + .arg("models", 2) + .arg("mode", "live") + ), + ( + Text::message("usage-wayfinder-routed"), + Text::service("local: 10 · cloud: 4") + ), + ( + Text::message("usage-wayfinder-saved"), + Text::message("usage-wayfinder-saved-value") + .arg("amount", "$4.12") + .arg("percent", "73.3") + ), + ( + Text::message("usage-wayfinder-decision"), + Text::message("usage-wayfinder-milliseconds").arg("time", "0.2") ), - ("Avg decision".into(), "0.2 ms".into()), ] ); let Section::Shares { shares, .. } = &report.sections[1] else { @@ -296,12 +317,24 @@ mod tests { ) .unwrap(); assert!(report.balances.is_empty()); - assert_eq!(report.account.plan.as_deref(), Some("Offline mode")); + assert_eq!( + report.account.plan, + Some(Text::message("usage-wayfinder-offline")) + ); let Section::Facts { facts, .. } = &report.sections[0] else { panic!("expected facts"); }; - assert_eq!(facts[0].1, "ok · 1 model · offline · dry run"); - assert_eq!(facts[2].1, "50% vs highest-cost route"); + assert_eq!( + facts[0].1, + Text::message("usage-wayfinder-gateway-state") + .arg("status", "ok") + .arg("models", 1) + .arg("mode", "both") + ); + assert_eq!( + facts[2].1, + Text::message("usage-wayfinder-saved-relative").arg("percent", "50") + ); } #[test] diff --git a/crates/herdr-usage/src/providers/windsurf.rs b/crates/herdr-usage/src/providers/windsurf.rs index 0e9c91a56..edfcb70fa 100644 --- a/crates/herdr-usage/src/providers/windsurf.rs +++ b/crates/herdr-usage/src/providers/windsurf.rs @@ -15,6 +15,7 @@ //! instead, which Connect servers accept alongside protobuf. A cache stored //! as a UTF-16 blob, which CodexBar also decodes, is not read. +use crate::text::Text; use crate::{ Error, Result, { @@ -201,7 +202,7 @@ fn counter( let total = total.filter(|total| *total > 0.)?; let used = used.or_else(|| remaining.map(|remaining| (total - remaining).max(0.)))?; Some(Window::new( - Kind::Named(name.to_owned()), + Kind::Named(Text::from(name.to_owned())), used.clamp(0., total) / total * 100., None, None, @@ -232,12 +233,22 @@ fn report( title: "Plan".into(), facts: vec![( "Renews".into(), - chrono::DateTime::::from(end) - .format("%b %-d, %Y") - .to_string(), + Text::from( + chrono::DateTime::::from(end) + .format("%b %-d, %Y") + .to_string(), + ), )], }); - Report::new(Provider(&Windsurf), Account { email: None, plan }, windows).with_sections(ends) + Report::new( + Provider(&Windsurf), + Account { + email: None, + plan: plan.map(Text::from), + }, + windows, + ) + .with_sections(ends) } #[derive(Deserialize)] @@ -296,6 +307,7 @@ struct QuotaUsage { #[allow(clippy::unwrap_used, clippy::expect_used)] mod tests { use super::*; + use crate::text::pair; fn at(seconds: u64) -> Option { Some(SystemTime::UNIX_EPOCH + Duration::from_secs(seconds)) @@ -320,7 +332,7 @@ mod tests { fn reads_cached_quota() { let report = parse_cache(CACHE).unwrap(); assert_eq!(report.provider.id(), "windsurf"); - assert_eq!(report.account.plan.as_deref(), Some("Pro")); + assert_eq!(report.account.plan.as_ref().map(Text::as_str), Some("Pro")); assert_eq!(report.windows.len(), 2); assert_eq!(report.windows[0].kind, Kind::Daily); assert_eq!(report.windows[0].percent(), 91); @@ -331,7 +343,10 @@ mod tests { let Section::Facts { facts, .. } = &report.sections[0] else { panic!("expected facts"); }; - assert_eq!(facts[0], ("Renews".to_owned(), "Mar 20, 2026".to_owned())); + assert_eq!( + facts[0], + pair(("Renews".to_owned(), "Mar 20, 2026".to_owned())) + ); } #[test] @@ -357,7 +372,7 @@ mod tests { "dailyQuotaResetAtUnix":"1774080000","weeklyQuotaResetAtUnix":"1774166400"}}"#, ) .unwrap(); - assert_eq!(report.account.plan.as_deref(), Some("Pro")); + assert_eq!(report.account.plan.as_ref().map(Text::as_str), Some("Pro")); assert_eq!(report.windows[0].percent(), 25); assert_eq!(report.windows[0].resets_at, at(1_774_080_000)); assert_eq!(report.windows[1].percent(), 60); diff --git a/crates/herdr-usage/src/providers/xai.rs b/crates/herdr-usage/src/providers/xai.rs index ae97fe13b..0b6e9a890 100644 --- a/crates/herdr-usage/src/providers/xai.rs +++ b/crates/herdr-usage/src/providers/xai.rs @@ -5,6 +5,7 @@ //! this API. This is the platform's prepaid billing, separate from the Grok //! consumer subscription; there is no local sign-in to find. +use crate::text::Text; use crate::{ Error, Result, { @@ -117,8 +118,8 @@ pub(crate) fn parse(balance: &str, usage: Option<&str>, today: NaiveDate) -> Res Section::Facts { title: "Spend".into(), facts: vec![ - ("Today".into(), usd(spend.today)), - (label.into(), usd(spend.total)), + ("Today".into(), Text::from(usd(spend.today))), + (label.into(), Text::from(usd(spend.total))), ], } }); diff --git a/crates/herdr-usage/src/providers/xkiro.rs b/crates/herdr-usage/src/providers/xkiro.rs index 2a3f7cdf8..15b7e1e79 100644 --- a/crates/herdr-usage/src/providers/xkiro.rs +++ b/crates/herdr-usage/src/providers/xkiro.rs @@ -8,6 +8,7 @@ //! percentage is invented. The wallet balance, which CodexBar leaves out, is //! shown as a balance when present; it never feeds the daily window. +use crate::text::{Text, pairs}; use crate::{ Result, { @@ -117,14 +118,19 @@ pub(crate) fn parse(body: &str, now: SystemTime) -> Result { .and_then(number) .map(|balance| Balance::new("Wallet", balance, Unit::Currency("USD".into()))); - Ok( - Report::new(Provider(&Xkiro), Account { email, plan }, windows) - .with_balances(wallet) - .with_sections([Section::Facts { - title: "Free tokens".into(), - facts, - }]), + Ok(Report::new( + Provider(&Xkiro), + Account { + email: email.map(Text::from), + plan: plan.map(Text::from), + }, + windows, ) + .with_balances(wallet) + .with_sections([Section::Facts { + title: "Free tokens".into(), + facts: pairs(facts), + }])) } /// A counter is a non-negative whole number or absent; anything else means @@ -201,7 +207,7 @@ mod tests { fn facts(report: &Report) -> Vec { match report.sections.as_slice() { [Section::Facts { facts, .. }] => { - facts.iter().map(|(_, value)| value.clone()).collect() + facts.iter().map(|(_, value)| value.to_string()).collect() } sections => panic!("unexpected sections {sections:?}"), } @@ -216,8 +222,14 @@ mod tests { assert!((window.used - 2.4807).abs() < 0.0001); assert_eq!(window.resets_at, Some(at(1_790_294_400))); assert_eq!(window.length, Some(DAY)); - assert_eq!(report.account.email.as_deref(), Some("dev@example.com")); - assert_eq!(report.account.plan.as_deref(), Some("Pay as you go")); + assert_eq!( + report.account.email.as_ref().map(Text::as_str), + Some("dev@example.com") + ); + assert_eq!( + report.account.plan.as_ref().map(Text::as_str), + Some("Pay as you go") + ); assert_eq!( facts(&report), ["124,035", "5,000,000", "4,875,965", "00:00 UTC"] @@ -252,7 +264,7 @@ mod tests { ); let report = parse(&body, at(1_790_251_200)).unwrap(); assert_eq!(report.windows[0].used, 100.); - assert_eq!(report.account.plan.as_deref(), Some("pro")); + assert_eq!(report.account.plan.as_ref().map(Text::as_str), Some("pro")); assert!(report.balances.is_empty()); } } diff --git a/crates/herdr-usage/src/providers/zai.rs b/crates/herdr-usage/src/providers/zai.rs index 7fb22638e..4826b11f1 100644 --- a/crates/herdr-usage/src/providers/zai.rs +++ b/crates/herdr-usage/src/providers/zai.rs @@ -13,6 +13,7 @@ //! a file back whole, so the file must hold only the key; and the hourly //! token chart, which the panel has no chart for. +use crate::text::{Text, pair, pairs}; use crate::{ Error, Result, { @@ -321,7 +322,7 @@ pub(crate) fn parse(body: &str, now: SystemTime) -> Result { 0, Section::Facts { title: "Quota details".into(), - facts, + facts: pairs(facts), }, ); } @@ -336,7 +337,15 @@ pub(crate) fn parse(body: &str, now: SystemTime) -> Result { .flatten() .map(|plan| plan.trim().to_owned()) .find(|plan| !plan.is_empty()); - Ok(Report::new(Provider(&Zai), Account { email: None, plan }, windows).with_sections(sections)) + Ok(Report::new( + Provider(&Zai), + Account { + email: None, + plan: plan.map(Text::from), + }, + windows, + ) + .with_sections(sections)) } /// Credit plans cost double on weekdays 06:00–10:00 UTC; no endpoint says so. @@ -402,6 +411,7 @@ pub(crate) fn parse_tokens(body: &str) -> Option
{ .into_iter() .take(20) .map(|(name, tokens)| (name, compact(tokens))) + .map(pair) .collect(), }) } @@ -518,7 +528,7 @@ impl Limit { (None, Some(length)) if length == SESSION => Kind::Session, (None, Some(length)) if length == WEEK => Kind::Weekly, (None, Some(length)) if length == DAY => Kind::Daily, - (None, Some(length)) => Kind::Named(format!("{}-hour", length.as_secs() / 3600)), + (None, Some(length)) => Kind::Named(format!("{}-hour", length.as_secs() / 3600).into()), (None, None) => Kind::Named("Quota".into()), }; Window::new(kind, self.used(), reset, length) @@ -603,7 +613,7 @@ mod tests { #[test] fn coding_plan_limits_become_session_weekly_and_mcp() { let report = parse(QUOTA, at(1_785_812_400)).unwrap(); - assert_eq!(report.account.plan.as_deref(), Some("Pro")); + assert_eq!(report.account.plan.as_ref().map(Text::as_str), Some("Pro")); assert_eq!(report.windows.len(), 2); let session = &report.windows[0]; assert_eq!(session.kind, Kind::Session); diff --git a/crates/herdr-usage/src/providers/zed.rs b/crates/herdr-usage/src/providers/zed.rs index dcd7f860b..62d2042d2 100644 --- a/crates/herdr-usage/src/providers/zed.rs +++ b/crates/herdr-usage/src/providers/zed.rs @@ -19,6 +19,7 @@ //! Not ported: the Linux editor sign-in lives in the Secret Service, which //! the probe cannot read. +use crate::text::{Text, pairs}; use crate::{ Result, { @@ -224,14 +225,16 @@ pub(crate) fn parse_editor(body: &str) -> Result { facts.push(("Billing".into(), "Overdue invoices".into())); } let account = Account { - email: Some(me.user.github_login.trim().to_owned()).filter(|login| !login.is_empty()), - plan: plan_name(&me.plan.plan_v3), + email: Some(me.user.github_login.trim().to_owned()) + .filter(|login| !login.is_empty()) + .map(Text::from), + plan: plan_name(&me.plan.plan_v3).map(Text::from), }; Ok( Report::new(Provider(&Zed), account, window.into_iter().collect()).with_sections([ Section::Facts { title: "Plan".into(), - facts, + facts: pairs(facts), }, ]), ) @@ -259,7 +262,7 @@ pub(crate) fn parse_billing(body: &str) -> Result { Provider(&Zed), Account { email: None, - plan: plan_name(&billing.plan), + plan: plan_name(&billing.plan).map(Text::from), }, window.into_iter().collect(), ) @@ -269,7 +272,7 @@ pub(crate) fn parse_billing(body: &str) -> Result { }]) .with_sections([Section::Facts { title: "Current billing period".into(), - facts, + facts: pairs(facts), }])) } @@ -332,6 +335,7 @@ struct TokenSpend { #[allow(clippy::unwrap_used, clippy::expect_used)] mod tests { use super::*; + use crate::text::pair; /// CodexBar's editor fixture. fn editor_fixture(limit: &str) -> String { @@ -361,8 +365,14 @@ mod tests { fn reads_the_editor_plan_and_billing_period() { let report = parse_editor(&editor_fixture("{\"limited\": 120}")).unwrap(); assert_eq!(report.provider.id(), "zed"); - assert_eq!(report.account.email.as_deref(), Some("octocat")); - assert_eq!(report.account.plan.as_deref(), Some("Zed Pro")); + assert_eq!( + report.account.email.as_ref().map(Text::as_str), + Some("octocat") + ); + assert_eq!( + report.account.plan.as_ref().map(Text::as_str), + Some("Zed Pro") + ); let window = &report.windows[0]; assert_eq!(window.percent(), 25); assert_eq!(window.length, Some(Duration::from_secs(31 * 86_400))); @@ -372,11 +382,11 @@ mod tests { }; assert_eq!( facts[0], - ("Edit predictions".to_owned(), "30 of 120".to_owned()) + pair(("Edit predictions".to_owned(), "30 of 120".to_owned())) ); assert_eq!( facts[1], - ("Billing".to_owned(), "Overdue invoices".to_owned()) + pair(("Billing".to_owned(), "Overdue invoices".to_owned())) ); } @@ -398,14 +408,20 @@ mod tests { #[test] fn reads_browser_billing() { let report = parse_billing(BILLING_BODY).unwrap(); - assert_eq!(report.account.plan.as_deref(), Some("Zed Pro")); + assert_eq!( + report.account.plan.as_ref().map(Text::as_str), + Some("Zed Pro") + ); assert_eq!(report.windows[0].percent(), 12); let balance = &report.balances[0]; assert_eq!(balance.text(), "$2.50 of $10.00"); let Section::Facts { facts, .. } = &report.sections[0] else { panic!("expected facts"); }; - assert!(facts.contains(&("Remaining budget".to_owned(), "$7.50".to_owned()))); + assert!(facts.contains(&( + Text::from("Remaining budget".to_owned()), + Text::from("$7.50".to_owned()) + ))); } #[test] diff --git a/crates/herdr-usage/src/providers/zenmux.rs b/crates/herdr-usage/src/providers/zenmux.rs index ac679df2c..d0ec9b32b 100644 --- a/crates/herdr-usage/src/providers/zenmux.rs +++ b/crates/herdr-usage/src/providers/zenmux.rs @@ -4,6 +4,7 @@ //! keys are refused by these endpoints. ZenMux has no local sign-in to find, //! and CodexBar reads no cookies for it either. +use crate::text::{Text, pairs}; use crate::{ Error, Result, { @@ -120,14 +121,19 @@ pub(crate) fn parse(subscription: &str, balance: Option<&str>) -> Result .and_then(|balance| balance.total_credits) .filter(|credits| credits.is_finite()) .map(|credits| Balance::new("PAYG balance", credits, Unit::Currency("USD".into()))); - Ok( - Report::new(Provider(&Zenmux), Account { email: None, plan }, windows) - .with_balances(balances) - .with_sections((!flows.is_empty()).then(|| Section::Facts { - title: "Quota".into(), - facts: flows, - })), + Ok(Report::new( + Provider(&Zenmux), + Account { + email: None, + plan: plan.map(Text::from), + }, + windows, ) + .with_balances(balances) + .with_sections((!flows.is_empty()).then(|| Section::Facts { + title: "Quota".into(), + facts: pairs(flows), + }))) } fn capitalize(value: &str) -> String { @@ -217,7 +223,10 @@ mod tests { #[test] fn parses_quotas_plan_and_balance() { let report = parse(SUBSCRIPTION, Some(BALANCE)).unwrap(); - assert_eq!(report.account.plan.as_deref(), Some("Ultra plan")); + assert_eq!( + report.account.plan.as_ref().map(Text::as_str), + Some("Ultra plan") + ); assert_eq!(report.windows.len(), 2); assert_eq!(report.windows[0].kind, Kind::Session); assert!((report.windows[0].used - 7.15).abs() < 0.001); @@ -240,7 +249,10 @@ mod tests { let body = SUBSCRIPTION.replace("\"healthy\"", "\"OVERDUE\""); let balance = r#"{"success":true,"data":{"currency":"usd","total_credits":-12.34}}"#; let report = parse(&body, Some(balance)).unwrap(); - assert_eq!(report.account.plan.as_deref(), Some("Ultra plan · Overdue")); + assert_eq!( + report.account.plan.as_ref().map(Text::as_str), + Some("Ultra plan · Overdue") + ); assert!((report.balances[0].amount + 12.34).abs() < 1e-9); } diff --git a/crates/herdr-usage/src/providers/zoommate.rs b/crates/herdr-usage/src/providers/zoommate.rs index 8d73513d2..9f808d035 100644 --- a/crates/herdr-usage/src/providers/zoommate.rs +++ b/crates/herdr-usage/src/providers/zoommate.rs @@ -10,6 +10,8 @@ //! 30-day `credits/history` dashboard; and CodexBar's per-host narrowing of //! the parent-domain cookies, since the probe takes one cookie header. +use crate::model::Count; +use crate::text::pairs; use crate::{ Error, Result, { @@ -130,12 +132,12 @@ pub(crate) fn parse(body: &str) -> Result { )] }; let balance = if unlimited { - Balance::new("Credits used", used, Unit::Count("credits".into())) + Balance::new("Credits used", used, Unit::Count(Count::Credits)) } else { let left = status.remaining_credit.unwrap_or(cap - used).max(0.); - Balance::new("Credits left", left, Unit::Count("credits".into())).out_of(cap) + Balance::new("Credits left", left, Unit::Count(Count::Credits)).out_of(cap) }; - let mut facts = Vec::new(); + let mut facts: Vec<(String, String)> = Vec::new(); if unlimited { facts.push(("Budget".into(), "Unlimited".into())); } @@ -150,7 +152,7 @@ pub(crate) fn parse(body: &str) -> Result { .with_balances([balance]) .with_sections((!facts.is_empty()).then(|| Section::Facts { title: "Credits".into(), - facts, + facts: pairs(facts), })), ) } diff --git a/crates/herdr-usage/src/tests/labels.rs b/crates/herdr-usage/src/tests/labels.rs index fadb7c661..e0e339685 100644 --- a/crates/herdr-usage/src/tests/labels.rs +++ b/crates/herdr-usage/src/tests/labels.rs @@ -65,7 +65,12 @@ fn balances_read_in_their_own_unit() { "4.50 EUR" ); assert_eq!( - Balance::new("Points", 1_250_000., Unit::Count("points".into())).text(), + Balance::new( + "Points", + 1_250_000., + Unit::Count(crate::model::Count::Points) + ) + .text(), "1,250,000 points" ); assert_eq!(group(-1234), "-1,234"); diff --git a/crates/herdr-usage/src/tests/opencode_auth.rs b/crates/herdr-usage/src/tests/opencode_auth.rs index 5db08ff8e..191d4e8d9 100644 --- a/crates/herdr-usage/src/tests/opencode_auth.rs +++ b/crates/herdr-usage/src/tests/opencode_auth.rs @@ -1,4 +1,5 @@ use super::*; +use crate::text::Text; use crate::{probe::Shell, providers::codex}; use std::{ fs, @@ -130,7 +131,7 @@ fn opencode_oauth_reads_plan_usage_on_the_selected_host() { host.with_json_parser(Some(parser)); let report = host.fetch().unwrap().unwrap(); assert_eq!(report.provider, provider("codex")); - assert_eq!(report.account.plan.as_deref(), Some("Plus")); + assert_eq!(report.account.plan.as_ref().map(Text::as_str), Some("Plus")); assert_eq!(report.windows[0].kind, Kind::Session); assert_eq!(report.windows[0].percent(), 23); let requests = host.requests(); diff --git a/crates/herdr-usage/src/tests/provider_data.rs b/crates/herdr-usage/src/tests/provider_data.rs index 94260bc05..e8b6ba51e 100644 --- a/crates/herdr-usage/src/tests/provider_data.rs +++ b/crates/herdr-usage/src/tests/provider_data.rs @@ -1,4 +1,5 @@ use super::*; +use crate::text::Text; use crate::{ model::{SESSION, Section}, probe::{Response, json_field}, @@ -109,7 +110,7 @@ const CODEX: &str = r#"{"email":"me@example.com","plan_type":"pro","rate_limit": fn codex_windows_are_known_by_length_not_slot() { let report = codex::parse(CODEX).unwrap(); assert_eq!(report.provider, provider("codex")); - assert_eq!(report.account.plan.as_deref(), Some("Pro")); + assert_eq!(report.account.plan.as_ref().map(Text::as_str), Some("Pro")); assert_eq!(report.windows.len(), 1); assert_eq!(report.windows[0].kind, Kind::Weekly); assert_eq!(report.windows[0].resets_at, Some(at(1_790_786_634))); diff --git a/crates/herdr-usage/src/text.rs b/crates/herdr-usage/src/text.rs new file mode 100644 index 000000000..d3879e180 --- /dev/null +++ b/crates/herdr-usage/src/text.rs @@ -0,0 +1,220 @@ +//! Words a report shows: a section's title, a fact's label and value, a +//! share's label, a balance's name, a window the service names, and the +//! account line. Crates below the app have no messages (docs/i18n.md), so a +//! provider names a message by ID, in the app's `usage-report.ftl` for words +//! several providers share or its own `usage-.ftl`, and the app writes +//! it in the shown language. What the service says itself, such as a model, +//! a project, or a plan, and data such as dates and amounts stay as they are. +//! +//! The app checks every ID a provider names, and the arguments it chains +//! with [`Text::arg`], against the English messages; so a message's +//! arguments are always chained where its ID is named. + +use std::{cmp::Ordering, fmt}; + +#[derive(Clone, Debug)] +pub enum Text { + /// A message by ID, such as `usage-report-credits-left`, with the + /// arguments it takes. + Message { + id: &'static str, + args: Vec<(&'static str, Arg)>, + }, + /// The service's own words, or data such as a date or an amount. + Service(String), +} + +/// A message's argument: a number its selectors may pick a plural by, or +/// words of either kind. +#[derive(Clone, Debug)] +pub enum Arg { + Number(f64), + Text(Text), +} + +impl Text { + pub const fn message(id: &'static str) -> Self { + Self::Message { + id, + args: Vec::new(), + } + } + + pub fn service(text: impl Into) -> Self { + Self::Service(text.into()) + } + + /// Adds an argument to a message; the service's words take none. + #[must_use] + pub fn arg(mut self, name: &'static str, value: impl Into) -> Self { + if let Self::Message { args, .. } = &mut self { + args.push((name, value.into())); + } + self + } + + /// The service's words, or the message's ID, for logs and tests: the + /// shown text is the app's to write. + pub fn as_str(&self) -> &str { + match self { + Self::Message { id, .. } => id, + Self::Service(text) => text, + } + } + + /// The message's ID, or `None` for the service's words. + pub fn id(&self) -> Option<&'static str> { + match self { + Self::Message { id, .. } => Some(id), + Self::Service(_) => None, + } + } +} + +/// The ID, or the service's words, for logs and tests: the shown text is +/// the app's to write. +impl fmt::Display for Text { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +macro_rules! number { + ($($type:ty),*) => {$( + impl From<$type> for Arg { + fn from(value: $type) -> Self { + Self::Number(value as f64) + } + } + )*}; +} + +number!(f32, f64, i32, i64, u32, u64, usize); + +impl From for Arg { + fn from(text: Text) -> Self { + Self::Text(text) + } +} + +impl From for Arg { + fn from(text: String) -> Self { + Self::Text(Text::Service(text)) + } +} + +impl From<&str> for Arg { + fn from(text: &str) -> Self { + Self::Text(Text::service(text)) + } +} + +// Providers not yet moved to messages still give English literals and +// `String`s, which these keep showing as they are. They go once every +// provider names its words, so a literal no longer compiles. +impl From<&str> for Text { + fn from(text: &str) -> Self { + Self::service(text) + } +} + +impl From for Text { + fn from(text: String) -> Self { + Self::Service(text) + } +} + +/// A fact as words, for providers that still build theirs from strings. +pub(crate) fn pair, B: Into>((label, value): (A, B)) -> (Text, Text) { + (label.into(), value.into()) +} + +/// Facts as words, for providers that still build theirs from strings. +pub(crate) fn pairs, B: Into>( + facts: impl IntoIterator, +) -> Vec<(Text, Text)> { + facts.into_iter().map(pair).collect() +} + +/// A share as words, for providers that still build theirs from strings. +pub(crate) fn share>((label, share): (A, f32)) -> (Text, f32) { + (label.into(), share) +} + +/// Shares as words, for providers that still build theirs from strings. +pub(crate) fn shares>( + shares: impl IntoIterator, +) -> Vec<(Text, f32)> { + shares.into_iter().map(share).collect() +} + +/// Tests and logs compare words with the service's text or a message's ID. +impl PartialEq<&str> for Text { + fn eq(&self, other: &&str) -> bool { + self.as_str() == *other + } +} + +impl PartialEq for Text { + fn eq(&self, other: &str) -> bool { + self.as_str() == other + } +} + +// Windows sort by kind, a named one by its name, so words order and compare +// as wholes; numbers by `total_cmp`, so the order is total. +impl Ord for Text { + fn cmp(&self, other: &Self) -> Ordering { + match (self, other) { + ( + Self::Message { id, args }, + Self::Message { + id: other_id, + args: other_args, + }, + ) => id.cmp(other_id).then_with(|| args.cmp(other_args)), + (Self::Service(text), Self::Service(other)) => text.cmp(other), + (Self::Message { .. }, Self::Service(_)) => Ordering::Less, + (Self::Service(_), Self::Message { .. }) => Ordering::Greater, + } + } +} + +impl PartialOrd for Text { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl PartialEq for Text { + fn eq(&self, other: &Self) -> bool { + self.cmp(other).is_eq() + } +} + +impl Eq for Text {} + +impl Ord for Arg { + fn cmp(&self, other: &Self) -> Ordering { + match (self, other) { + (Self::Number(number), Self::Number(other)) => number.total_cmp(other), + (Self::Text(text), Self::Text(other)) => text.cmp(other), + (Self::Number(_), Self::Text(_)) => Ordering::Less, + (Self::Text(_), Self::Number(_)) => Ordering::Greater, + } + } +} + +impl PartialOrd for Arg { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl PartialEq for Arg { + fn eq(&self, other: &Self) -> bool { + self.cmp(other).is_eq() + } +} + +impl Eq for Arg {} diff --git a/docs/i18n.md b/docs/i18n.md index b341d519b..ce083f784 100644 --- a/docs/i18n.md +++ b/docs/i18n.md @@ -96,9 +96,10 @@ the app writes them out: seconds elapsed for `ago` ("3 min ago", "yesterday"), a cloud job's `Step` for `cloud::status`, and an Azure size's `VmSpec`, `SandboxSize`, `Price`, or `DeviceSize` for Settings. A crate may also name a message by ID, as `herdr-commands` does for each -command and `herdr-usage` for each provider setting's help -(`usage-.ftl`); the app shows it with `i18n::message`, and a test -checks every ID the crate names with `i18n::takes`. +command and `herdr-usage` for each provider setting's help and its +report's words (`herdr_usage::Text`, in `usage-.ftl`); the app +shows it with `i18n::message`, and a test checks every ID the crate names, +with the arguments chained to it, with `i18n::takes`. ### Errors @@ -294,11 +295,16 @@ app still shows English literals: still show their English `Display`. A mod's own load and runtime diagnostics (`herdr_mods::Error`) are for its author and stay as Luau and the mod host write them. -- Text `herdr-usage` writes for a service: the titles and labels of a - report's facts and shares, balance labels and count units ("credits"), - and windows it names itself. The app - localizes the typed values (limit kinds, countdowns, pace, balances) - itself, in `usage/text.rs`. +- Text `herdr-usage` writes for a service. A report's words are + `herdr_usage::Text`: a message ID with its arguments, or the service's + own words, which `usage::text::words` writes out; count units are a + `Count` the app writes with a plural, and the typed values (limit kinds, + countdowns, pace, balances) are localized in `usage/text.rs`. Wayfinder + names all of its words (`usage-wayfinder.ftl`, with shared ones in + `usage-report.ftl`); the other providers still pass English section + titles, fact labels and values, balance labels, window names, and account + lines through `Text`'s transitional `From<&str>`, which goes once the last + of them names its words. - The remaining hand-built relative times (the orchestrator's, Security's, and code search's) should use `ago`, and the remaining hand-built plurals Fluent selectors. From efef7f7b003dbf4e6518a3f1ce6ee246570b3d7c Mon Sep 17 00:00:00 2001 From: Fabien Penso Date: Sat, 31 Oct 2026 22:23:32 -0700 Subject: [PATCH 03/10] fix(i18n): wrap a long plan usage fact value inside its card A fact's value sat at its natural width, so a longer one, as en-XA or another language writes it, ran past the usage card's edge. The value may now shrink and wraps under itself, set against the end side. --- crates/herdr-gpui/src/usage/ui.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/herdr-gpui/src/usage/ui.rs b/crates/herdr-gpui/src/usage/ui.rs index a43c5aba8..e56689f32 100644 --- a/crates/herdr-gpui/src/usage/ui.rs +++ b/crates/herdr-gpui/src/usage/ui.rs @@ -7,7 +7,7 @@ use super::{ model::{Balance, Pace, Report, Section, Severity, Window as Limit}, text, }; -use crate::config::Theme; +use crate::{config::Theme, direction::Logical}; use gpui::{prelude::*, *}; use herdr_usage::Text; use std::time::SystemTime; @@ -136,7 +136,9 @@ impl Ui { .gap(px(8.)) .text_size(self.small()) .child(div().text_color(self.muted()).child(text::words(label))) - .child(div().child(text::words(value))) + // A long value, or one longer in another language, + // wraps under itself rather than leaving the card. + .child(div().min_w_0().text_end().child(text::words(value))) })) .into_any_element() } From a4fbd2a6490249da6c56605c80b369e921b07526 Mon Sep 17 00:00:00 2001 From: Fabien Penso Date: Sat, 31 Oct 2026 22:38:39 -0700 Subject: [PATCH 04/10] refactor(i18n): show plan usage reports from Copilot to Kimi through Fluent Copilot, Cursor, Deepgram, DeepInfra, DeepSeek, Devin, DevPass, Doubao, ElevenLabs, Factory, Fireworks, Gemini, GitKraken, Grok, Groq, Helmcode, Hugging Face, Hyper, IBM Bob, JetBrains, Kilo, and Kimi now name each word their reports show in their own usage-.ftl: section titles, fact labels, values with English words or plurals, balance labels, the windows they name, and plan lines they compose. Names the services send (plans, models, tenants, products) stay as the service's words, and none of them uses Text's transitional conversions. Where a provider matched its own English labels (Cursor's on-demand budget, Doubao's window periods), it now carries a selector key the message picks by. Counts whose English never had a singular ("1 credits") keep it byte for byte, with only an other variant for translators to extend. usage/tests/words_b.rs checks each provider's messages read in English as it wrote them. --- .../locales/en-US/usage-copilot.ftl | 9 + .../herdr-gpui/locales/en-US/usage-cursor.ftl | 33 ++ .../locales/en-US/usage-deepgram.ftl | 23 ++ .../locales/en-US/usage-deepinfra.ftl | 13 + .../locales/en-US/usage-deepseek.ftl | 13 + .../herdr-gpui/locales/en-US/usage-devin.ftl | 8 + .../locales/en-US/usage-devpass.ftl | 17 + .../herdr-gpui/locales/en-US/usage-doubao.ftl | 17 + .../locales/en-US/usage-elevenlabs.ftl | 11 + .../locales/en-US/usage-factory.ftl | 23 ++ .../locales/en-US/usage-fireworks.ftl | 11 + .../herdr-gpui/locales/en-US/usage-gemini.ftl | 13 + .../locales/en-US/usage-gitkraken.ftl | 27 ++ .../herdr-gpui/locales/en-US/usage-grok.ftl | 11 + .../herdr-gpui/locales/en-US/usage-groq.ftl | 14 + .../locales/en-US/usage-helmcode.ftl | 15 + .../locales/en-US/usage-huggingface.ftl | 24 ++ .../herdr-gpui/locales/en-US/usage-hyper.ftl | 9 + .../herdr-gpui/locales/en-US/usage-ibmbob.ftl | 16 + .../locales/en-US/usage-jetbrains.ftl | 21 + .../herdr-gpui/locales/en-US/usage-kilo.ftl | 18 + .../herdr-gpui/locales/en-US/usage-kimi.ftl | 14 + crates/herdr-gpui/src/usage/tests.rs | 1 + crates/herdr-gpui/src/usage/tests/words_b.rs | 366 ++++++++++++++++++ crates/herdr-usage/src/providers/copilot.rs | 60 +-- crates/herdr-usage/src/providers/cursor.rs | 129 +++--- crates/herdr-usage/src/providers/deepgram.rs | 92 +++-- crates/herdr-usage/src/providers/deepinfra.rs | 66 +++- crates/herdr-usage/src/providers/deepseek.rs | 62 ++- crates/herdr-usage/src/providers/devin.rs | 21 +- crates/herdr-usage/src/providers/devpass.rs | 64 ++- crates/herdr-usage/src/providers/doubao.rs | 32 +- .../herdr-usage/src/providers/elevenlabs.rs | 60 ++- crates/herdr-usage/src/providers/factory.rs | 107 +++-- crates/herdr-usage/src/providers/fireworks.rs | 17 +- crates/herdr-usage/src/providers/gemini.rs | 61 ++- crates/herdr-usage/src/providers/gitkraken.rs | 81 ++-- crates/herdr-usage/src/providers/grok.rs | 25 +- .../herdr-usage/src/providers/grok/tests.rs | 9 +- .../src/providers/grok/tests/prepaid.rs | 23 +- crates/herdr-usage/src/providers/groq.rs | 32 +- crates/herdr-usage/src/providers/helmcode.rs | 45 ++- .../herdr-usage/src/providers/huggingface.rs | 102 +++-- crates/herdr-usage/src/providers/hyper.rs | 19 +- crates/herdr-usage/src/providers/ibmbob.rs | 31 +- crates/herdr-usage/src/providers/jetbrains.rs | 70 ++-- crates/herdr-usage/src/providers/kilo.rs | 94 +++-- crates/herdr-usage/src/providers/kimi.rs | 62 ++- docs/i18n.md | 5 +- 49 files changed, 1639 insertions(+), 457 deletions(-) create mode 100644 crates/herdr-gpui/locales/en-US/usage-doubao.ftl create mode 100644 crates/herdr-gpui/src/usage/tests/words_b.rs diff --git a/crates/herdr-gpui/locales/en-US/usage-copilot.ftl b/crates/herdr-gpui/locales/en-US/usage-copilot.ftl index 845b125c2..0834bd508 100644 --- a/crates/herdr-gpui/locales/en-US/usage-copilot.ftl +++ b/crates/herdr-gpui/locales/en-US/usage-copilot.ftl @@ -6,3 +6,12 @@ usage-copilot-setting-token = A GitHub token of the account with Copilot, e.g. a fine-grained personal access token from https://github.com/settings/personal-access-tokens with no extra permissions, or the output of `gh auth token`. Not needed when a Copilot editor plugin is signed in on the host. usage-copilot-setting-enterprise-host = For GitHub Enterprise Cloud with data residency: the host, such as octocorp.ghe.com. Usage is then read from api.. + +## The report: premium request and chat quotas, and credits used. + +usage-copilot-premium = Premium +usage-copilot-chat = Chat +usage-copilot-credits = Credits +usage-copilot-credits-used = Credits used +usage-copilot-quota = Quota +usage-copilot-unlimited = Unlimited diff --git a/crates/herdr-gpui/locales/en-US/usage-cursor.ftl b/crates/herdr-gpui/locales/en-US/usage-cursor.ftl index f02c19285..7e49b10b0 100644 --- a/crates/herdr-gpui/locales/en-US/usage-cursor.ftl +++ b/crates/herdr-gpui/locales/en-US/usage-cursor.ftl @@ -5,3 +5,36 @@ ## service's own menu paths stay as written. usage-cursor-setting-cookie = Your cursor.com session. Sign in at https://cursor.com/dashboard, open Developer Tools > Application > Cookies > https://cursor.com, and copy the WorkosCursorSessionToken cookie (or __Secure-next-auth.session-token on older accounts). Paste it as "WorkosCursorSessionToken=value". Not needed when Cursor is listed in show_providers and you are signed in with Chrome or Safari. + +## The report: the billing cycle's included, Auto, and API usage, the +## on-demand budget, and the legacy request quota. Plan names, such as +## Cursor Pro, and the Grok Bot allowance's name stay as Cursor names them. + +# The billing cycle's limit on Auto and Composer requests. +usage-cursor-auto = Auto + Composer +# The billing cycle's limit on requests to named API models. +usage-cursor-api = API +usage-cursor-request-quota = Request quota +usage-cursor-included = Included usage +usage-cursor-this-cycle = This cycle +# $used and $limit are dollar amounts, such as $15.00 and $50.00. +usage-cursor-of = { $used } of { $limit } +# The on-demand budget: $budget is team for a budget the whole team shares, +# or personal for the member's own. +usage-cursor-budget = { $budget -> + [team] Team on-demand + *[personal] On-demand + } +# The balance of an on-demand budget with a cap; $budget as above. +usage-cursor-budget-left = { $budget -> + [team] Team on-demand left + *[personal] On-demand left + } +# The spend of an on-demand budget without a cap; $budget as above. +usage-cursor-budget-spend = { $budget -> + [team] Team on-demand spend + *[personal] On-demand spend + } +usage-cursor-spent = Spent this cycle +# The member's own part of a team's on-demand spend. +usage-cursor-your-share = Your share diff --git a/crates/herdr-gpui/locales/en-US/usage-deepgram.ftl b/crates/herdr-gpui/locales/en-US/usage-deepgram.ftl index 23107354f..b37f67d32 100644 --- a/crates/herdr-gpui/locales/en-US/usage-deepgram.ftl +++ b/crates/herdr-gpui/locales/en-US/usage-deepgram.ftl @@ -7,3 +7,26 @@ usage-deepgram-setting-api-key = A Deepgram API key with the usage:read scope, created in the Deepgram Console (https://console.deepgram.com) under API Keys. usage-deepgram-setting-project-id = Optional project UUID from the Deepgram Console. Leave unset to sum usage over every project the key can see. usage-deepgram-setting-base-url = Optional HTTPS API URL for a proxy. Defaults to https://api.deepgram.com/v1. + +## The report: usage summed over the key's projects. Project names stay as +## Deepgram sends them. + +usage-deepgram-no-projects = No projects +# The plan line for a key that sees one project; $project is its name. +usage-deepgram-project = Project: { $project } +# The plan line for a key that sees several projects; $count counts them. +usage-deepgram-projects = { $count -> + *[other] { $count } projects + } +usage-deepgram-usage-summary = Usage summary +usage-deepgram-requests = Requests +usage-deepgram-audio = Audio +# $hours is the audio transcribed, in hours, such as 1.8; $billable is the +# hours billed, such as 2.5. +usage-deepgram-audio-hours = { $hours } hours · { $billable } billable +usage-deepgram-agent-hours = Agent hours +usage-deepgram-tokens = Tokens +usage-deepgram-tts-characters = TTS characters +usage-deepgram-period = Period +# $start and $end are dates, such as 2026-05-20 and 2026-06-30. +usage-deepgram-period-range = { $start } to { $end } diff --git a/crates/herdr-gpui/locales/en-US/usage-deepinfra.ftl b/crates/herdr-gpui/locales/en-US/usage-deepinfra.ftl index 7481b99ed..c05bb6422 100644 --- a/crates/herdr-gpui/locales/en-US/usage-deepinfra.ftl +++ b/crates/herdr-gpui/locales/en-US/usage-deepinfra.ftl @@ -5,3 +5,16 @@ ## service's own menu paths stay as written. usage-deepinfra-setting-api-key = A DeepInfra API key from https://deepinfra.com/dash/api_keys, without a "Bearer " prefix. It must be allowed to read billing data. + +## The report: the prepaid balance, the billing cycle's spend, and whether +## the account is suspended. A suspension reason stays as DeepInfra sends it. + +# Money the account owes DeepInfra. +usage-deepinfra-owed = Owed +# Prepaid money left. +usage-deepinfra-available = Available +usage-deepinfra-cycle-spend = Billing cycle spend +usage-deepinfra-month-spend = Spent this month +# A fact's label, and its value when DeepInfra gives no reason. +usage-deepinfra-suspended = Suspended +usage-deepinfra-account = Account diff --git a/crates/herdr-gpui/locales/en-US/usage-deepseek.ftl b/crates/herdr-gpui/locales/en-US/usage-deepseek.ftl index d55b12b49..e741410b9 100644 --- a/crates/herdr-gpui/locales/en-US/usage-deepseek.ftl +++ b/crates/herdr-gpui/locales/en-US/usage-deepseek.ftl @@ -6,3 +6,16 @@ usage-deepseek-setting-api-key = A DeepSeek API key from https://platform.deepseek.com/api_keys. It reads the remaining credit balance. usage-deepseek-setting-platform-token = Used when no API key is set. Sign in to https://platform.deepseek.com, open Developer Tools > Application > Local Storage > https://platform.deepseek.com, and copy the value of the userToken entry (either the whole JSON value or just its "value" field). + +## The report: the credit balance in the currency it is funded in, split +## into paid and granted credit. + +usage-deepseek-balance = Balance +usage-deepseek-credits = Credits +# Credit the account bought. +usage-deepseek-paid = Paid +# Credit DeepSeek gave, such as a sign-up bonus. +usage-deepseek-granted = Granted +usage-deepseek-status = Status +usage-deepseek-add-credits = Add credits at platform.deepseek.com +usage-deepseek-unavailable = Balance unavailable for API calls diff --git a/crates/herdr-gpui/locales/en-US/usage-devin.ftl b/crates/herdr-gpui/locales/en-US/usage-devin.ftl index 2d22b7391..2aa196bb7 100644 --- a/crates/herdr-gpui/locales/en-US/usage-devin.ftl +++ b/crates/herdr-gpui/locales/en-US/usage-devin.ftl @@ -6,3 +6,11 @@ usage-devin-setting-token = Sign in to https://app.devin.ai, open your organization's Usage & Limits page, then in Developer Tools > Network select the successful billing/quota/usage request and copy its Authorization request header without the leading "Bearer ". The value is a browser session token and expires with that session. usage-devin-setting-organization = The x-cog-org-id request header of the same request (an org-… or org_… id). An organization slug or its app.devin.ai URL also works. + +## The report: daily and weekly quotas, the extra usage balance, and the +## organization. Plan and organization names stay as Devin sends them. + +usage-devin-extra-usage = Extra usage balance +usage-devin-organization = Organization +# The organization's name. +usage-devin-name = Name diff --git a/crates/herdr-gpui/locales/en-US/usage-devpass.ftl b/crates/herdr-gpui/locales/en-US/usage-devpass.ftl index 30a511c94..d0afc7266 100644 --- a/crates/herdr-gpui/locales/en-US/usage-devpass.ftl +++ b/crates/herdr-gpui/locales/en-US/usage-devpass.ftl @@ -5,3 +5,20 @@ ## service's own menu paths stay as written. usage-devpass-setting-api-key = A regular LLM Gateway API key from https://devpass.llmgateway.io/dashboard. Publishable keys cannot read plan usage. + +## The report: the billing cycle's plan credits, the premium weekly +## allowance, and the key's own spend. Plan names, such as DevPass Pro, stay +## as DevPass names them. + +# The plan line of a key without a DevPass plan. +usage-devpass-pay-as-you-go = Pay as you go +usage-devpass-api-key = API key (all time) +usage-devpass-key-usage = All-time key usage +usage-devpass-key-limit = Key spending limit +# The balance of a key without a DevPass plan: what it spent, ever. +usage-devpass-key-spend = Key usage (all time) +usage-devpass-credits = DevPass credits +usage-devpass-cycle-used = Cycle used +usage-devpass-cycle-remaining = Cycle remaining +usage-devpass-premium-weekly = Premium weekly +usage-devpass-credits-left = Plan credits left diff --git a/crates/herdr-gpui/locales/en-US/usage-doubao.ftl b/crates/herdr-gpui/locales/en-US/usage-doubao.ftl new file mode 100644 index 000000000..05c5c1b35 --- /dev/null +++ b/crates/herdr-gpui/locales/en-US/usage-doubao.ftl @@ -0,0 +1,17 @@ +# Doubao's plan usage, in its usage panel. + +## The report: the Coding Plan's and Agent Plan's windows. The sign-in +## method, such as sso, stays as arkcli reports it. + +# A window of a plan other than the personal Coding Plan: $plan is agent +# (the Agent Plan), team (the team Coding Plan), or agent-team (the team +# Agent Plan); $period is session (five hours), weekly, or monthly. +usage-doubao-window = { $plan -> + [agent] Agent + [team] Team + *[agent-team] Agent team + } { $period -> + [session] 5-hour + [weekly] weekly + *[monthly] monthly + } diff --git a/crates/herdr-gpui/locales/en-US/usage-elevenlabs.ftl b/crates/herdr-gpui/locales/en-US/usage-elevenlabs.ftl index 93d1a619d..e9f112c2b 100644 --- a/crates/herdr-gpui/locales/en-US/usage-elevenlabs.ftl +++ b/crates/herdr-gpui/locales/en-US/usage-elevenlabs.ftl @@ -6,3 +6,14 @@ usage-elevenlabs-setting-api-key = An ElevenLabs API key with the user_read permission, from https://elevenlabs.io/app/settings/api-keys. usage-elevenlabs-setting-base-url = Optional HTTPS API origin for a proxy. Defaults to https://api.elevenlabs.io. + +## The report: character credits, voice slots, and the plan. The plan's +## tier and status, such as Creator Annual or past_due, stay as ElevenLabs +## sends them. + +usage-elevenlabs-voice-slots = Voice slots +usage-elevenlabs-professional-voices = Professional voices +# Character credits used this billing month. +usage-elevenlabs-credits = Credits +# $used and $limit are counts, such as 25,000 and 100,000. +usage-elevenlabs-of = { $used } of { $limit } diff --git a/crates/herdr-gpui/locales/en-US/usage-factory.ftl b/crates/herdr-gpui/locales/en-US/usage-factory.ftl index 812ea134e..8529c8c70 100644 --- a/crates/herdr-gpui/locales/en-US/usage-factory.ftl +++ b/crates/herdr-gpui/locales/en-US/usage-factory.ftl @@ -6,3 +6,26 @@ usage-factory-setting-api-key = A Factory API key (fk-…) from https://app.factory.ai/settings/api-keys. usage-factory-setting-cookie = Used when no API key is set. Sign in to https://app.factory.ai, open Developer Tools > Application > Cookies > https://app.factory.ai, and copy the session cookies (wos-session, access-token, and any *.session-token) as "name=value; name2=value2". + +## The report: the 5-hour, weekly, and monthly windows, or the older +## Standard and Premium token allowances, the extra usage balance, and the +## organization. Tier, plan, and organization names stay as Factory sends +## them. + +# Windows of the Core token pool, beside the standard ones. +usage-factory-core-session = Core 5-hour +usage-factory-core-weekly = Core weekly +usage-factory-core-monthly = Core monthly +usage-factory-extra-usage = Extra usage balance +# The older billing's token allowances. +usage-factory-standard = Standard +usage-factory-premium = Premium +# The plan line with what happens past the limits: $plan is the tier and +# plan, such as Factory Team - Pro; $preference is Factory's word for the +# fallback, such as credits. +usage-factory-plan-fallback = { $plan } - Fallback: { $preference } +# The same, without a tier or plan. +usage-factory-fallback = Fallback: { $preference } +usage-factory-organization = Organization +# The organization's name. +usage-factory-name = Name diff --git a/crates/herdr-gpui/locales/en-US/usage-fireworks.ftl b/crates/herdr-gpui/locales/en-US/usage-fireworks.ftl index e08fa7761..de6b54bc0 100644 --- a/crates/herdr-gpui/locales/en-US/usage-fireworks.ftl +++ b/crates/herdr-gpui/locales/en-US/usage-fireworks.ftl @@ -6,3 +6,14 @@ usage-fireworks-setting-api-key = A Fireworks API key from https://app.fireworks.ai/settings/users/api-keys. It reads the account's billing summary. usage-fireworks-setting-account-slug = The Fireworks account id, needed only when the API key can see several accounts. Find it in the account switcher on https://app.fireworks.ai or with `firectl whoami`. + +## The report: API spend over the last 30 days, and the account it was read +## for. The account slug stays as Fireworks names it. + +usage-fireworks-spend = Spend, last 30 days +usage-fireworks-rated-spend = Rated spend +# The rated spend of an account with no rated line items. +usage-fireworks-none = None +usage-fireworks-account = Account +# The account's short name in Fireworks URLs, such as acme. +usage-fireworks-slug = Slug diff --git a/crates/herdr-gpui/locales/en-US/usage-gemini.ftl b/crates/herdr-gpui/locales/en-US/usage-gemini.ftl index f1c856143..a5457a615 100644 --- a/crates/herdr-gpui/locales/en-US/usage-gemini.ftl +++ b/crates/herdr-gpui/locales/en-US/usage-gemini.ftl @@ -6,3 +6,16 @@ usage-gemini-setting-client-id = The Gemini CLI's OAuth client id, used to refresh an expired sign-in. Normally found in the installed CLI (OAUTH_CLIENT_ID in @google/gemini-cli-core/dist/src/code_assist/oauth2.js); set it only when the CLI is not on the host's PATH. usage-gemini-setting-client-secret = The matching OAUTH_CLIENT_SECRET from the same file. + +## The report: each model family's daily quota and each model's share left. +## Model and family names, such as gemini-2.5-pro or Flash Lite, and a paid +## tier's name stay as Google sends them. + +# The plan line for Code Assist's tiers. +usage-gemini-paid = Paid +usage-gemini-workspace = Workspace +usage-gemini-free = Free +usage-gemini-legacy = Legacy +usage-gemini-models = Models +# $percent is the share of a model's quota left, such as 40. +usage-gemini-left = { $percent }% left diff --git a/crates/herdr-gpui/locales/en-US/usage-gitkraken.ftl b/crates/herdr-gpui/locales/en-US/usage-gitkraken.ftl index 4555a8b12..765d71cea 100644 --- a/crates/herdr-gpui/locales/en-US/usage-gitkraken.ftl +++ b/crates/herdr-gpui/locales/en-US/usage-gitkraken.ftl @@ -6,3 +6,30 @@ usage-gitkraken-setting-token = A GitKraken account session access token. Sign in at https://gitkraken.dev/account#ai-usage, open Developer Tools > Network, select the successful ai-tasks/usage request, and copy only the value after "Bearer " in its Authorization header. Replace it when it expires. usage-gitkraken-setting-org-id = Optional organization ID: the gk-org-id header of the same request, to read that organization's shared pool. + +## The report: weekly personal credits and an organization's shared pool. +## $amount, $used, and $limit are credit counts as written, such as 1,200 or +## 55.50; $count is the number a plural form is picked by. + +usage-gitkraken-weekly-usage = Weekly usage +usage-gitkraken-personal = Personal +# The organization's credits, which its members share. +usage-gitkraken-shared-pool = Shared pool +usage-gitkraken-your-share = Your shared usage +# The shared pool's credits other members used. +usage-gitkraken-rest = Rest of organization +usage-gitkraken-credits = { $count -> + *[other] { $amount } credits + } +# Credits used of a weekly allowance; $count is the allowance. +usage-gitkraken-used-of = { $count -> + *[other] { $used } / { $limit } credits used + } +# Credits used without a limit; $count is the credits used. +usage-gitkraken-used-unlimited = { $count -> + *[other] { $used } credits used · Unlimited + } +# Credits used with no allowance at all; $count as above. +usage-gitkraken-used-no-allowance = { $count -> + *[other] { $used } credits used · No allowance + } diff --git a/crates/herdr-gpui/locales/en-US/usage-grok.ftl b/crates/herdr-gpui/locales/en-US/usage-grok.ftl index 4f7eb3c22..1eaf8b8a7 100644 --- a/crates/herdr-gpui/locales/en-US/usage-grok.ftl +++ b/crates/herdr-gpui/locales/en-US/usage-grok.ftl @@ -5,3 +5,14 @@ ## service's own menu paths stay as written. usage-grok-setting-token = A SuperGrok bearer token, only needed when the Grok CLI is not signed in on the selected host (run `grok login` there instead when you can). It is the "key" of the https://auth.x.ai entry in ~/.grok/auth.json. xai- API keys do not work. + +## The report: the billing period's credit usage and the prepaid balance. +## Plan names, such as SuperGrok Heavy, stay as Grok names them. + +# A credit window whose period is neither a week nor a month. +usage-grok-credits = Credits +usage-grok-prepaid-balance = Prepaid balance +usage-grok-included-credits = Included credits +# The usage of a billing period Grok reports no percentage for. +usage-grok-not-reported = Not reported +usage-grok-period-ends = Period ends diff --git a/crates/herdr-gpui/locales/en-US/usage-groq.ftl b/crates/herdr-gpui/locales/en-US/usage-groq.ftl index 71ad90445..99f0f504b 100644 --- a/crates/herdr-gpui/locales/en-US/usage-groq.ftl +++ b/crates/herdr-gpui/locales/en-US/usage-groq.ftl @@ -6,3 +6,17 @@ usage-groq-setting-api-key = A GroqCloud Enterprise API key from https://console.groq.com/keys. Only Enterprise organizations can read Prometheus metrics. usage-groq-setting-base-url = Optional HTTPS API base URL for a private gateway. Defaults to https://api.groq.com/v1. + +## The report: request and token rates over the last five minutes, from +## GroqCloud's Prometheus metrics, an Enterprise feature. + +# The plan line: where the rates come from. +usage-groq-prometheus = Prometheus metrics +usage-groq-last-5-minutes = Last 5 minutes +usage-groq-requests = Requests / min +usage-groq-tokens = Tokens / min +usage-groq-cache-hits = Cache hits / min +usage-groq-metrics = Metrics +usage-groq-unavailable = Unavailable +# Why a key that is not an Enterprise key shows no rates. +usage-groq-enterprise-only = Prometheus metrics need a GroqCloud Enterprise key diff --git a/crates/herdr-gpui/locales/en-US/usage-helmcode.ftl b/crates/herdr-gpui/locales/en-US/usage-helmcode.ftl index e77551b90..52f6fca01 100644 --- a/crates/herdr-gpui/locales/en-US/usage-helmcode.ftl +++ b/crates/herdr-gpui/locales/en-US/usage-helmcode.ftl @@ -6,3 +6,18 @@ usage-helmcode-setting-cookie = The dashboard session. Sign in to https://cloud.helmcode.com/dashboard (or https://cloud.nan.builders/dashboard for NaN Builders), open Developer Tools > Network, reload, and copy the Cookie request header of a request to cloud-api.helmcode.com (or cloud-api.nan.builders). Developer Tools > Application > Cookies lists the same cookies. Paste it as "name=value; name2=value2". usage-helmcode-setting-tenant = Which dashboard a pasted cookie belongs to: "helmcode" (default) or "nanBuilders". + +## The report: each model's token quota and the prepaid balance. Model and +## tenant names, such as Helmcode Cloud, stay as Helmcode names them. + +usage-helmcode-model-quotas = Model quotas +usage-helmcode-prepaid-balance = Prepaid balance +# A model's tokens used of its cap: $used and $cap are token counts as +# written, such as 250,000 and 1,000,000; $count is the cap, for the plural. +usage-helmcode-tokens = { $count -> + *[other] { $used } / { $cap } tokens + } +# The same, with the tokens paid from prepaid credit ($credit, such as 5,000). +usage-helmcode-tokens-credit = { $count -> + *[other] { $used } / { $cap } tokens · { $credit } credit-funded + } diff --git a/crates/herdr-gpui/locales/en-US/usage-huggingface.ftl b/crates/herdr-gpui/locales/en-US/usage-huggingface.ftl index 426b382ff..5d3d9358c 100644 --- a/crates/herdr-gpui/locales/en-US/usage-huggingface.ftl +++ b/crates/herdr-gpui/locales/en-US/usage-huggingface.ftl @@ -6,3 +6,27 @@ usage-huggingface-setting-api-key = A Hugging Face access token from https://huggingface.co/settings/tokens. A classic read token works; a fine-grained token needs the Billing read permission. Not needed when `hf auth login` has saved a token on the host. usage-huggingface-setting-cookie = Optional, for the prepaid credit balance. Sign in at https://huggingface.co/settings/billing with the token's account, open Developer Tools > Application > Cookies > https://huggingface.co, and copy the token cookie as "token=value" (or every cookie there as "name=value; name2=value2"). + +## The report: month-to-date Inference Providers charges, the ZeroGPU +## quota, prepaid credits, and the account. User names and the PRO plan's +## name stay as Hugging Face sends them. + +# Hugging Face's product for running models through partner providers. +usage-huggingface-inference-providers = Inference Providers +usage-huggingface-billable = Billable usage +usage-huggingface-gross = Gross inference usage +usage-huggingface-included = Included inference amount +usage-huggingface-spending-limit = Spending limit +usage-huggingface-requests = Requests +# Hugging Face's free GPU time for Spaces. +usage-huggingface-zerogpu = ZeroGPU +usage-huggingface-gpu-used = GPU time used +usage-huggingface-gpu-left = GPU time remaining +# $minutes is a number of minutes, such as 10 or 2.5. +usage-huggingface-minutes = { $minutes } min +usage-huggingface-month-spend = Inference this month +usage-huggingface-prepaid = Prepaid credits +usage-huggingface-account = Account +usage-huggingface-user = User +# The plan line of an account without PRO. +usage-huggingface-free = Free diff --git a/crates/herdr-gpui/locales/en-US/usage-hyper.ftl b/crates/herdr-gpui/locales/en-US/usage-hyper.ftl index bd7a50ee6..57a1f138d 100644 --- a/crates/herdr-gpui/locales/en-US/usage-hyper.ftl +++ b/crates/herdr-gpui/locales/en-US/usage-hyper.ftl @@ -6,3 +6,12 @@ usage-hyper-setting-api-key = A Charm Hyper API key from https://hyper.charm.land. Used when no browser session is available or the session has expired. usage-hyper-setting-cookie = Sign in at https://hyper.charm.land, open Developer Tools > Application > Cookies > https://hyper.charm.land, and copy every cookie there as one "name=value; name2=value2" header. + +## The report: the Hypercredit balance, and the sign-in it was read with. + +# The plan line: the balance was read with a hyper.charm.land browser +# session, or with an API key. +usage-hyper-browser-session = Browser session +usage-hyper-api-key = API key +# Charm Hyper's credits, shown as HC. +usage-hyper-hypercredits = Hypercredits diff --git a/crates/herdr-gpui/locales/en-US/usage-ibmbob.ftl b/crates/herdr-gpui/locales/en-US/usage-ibmbob.ftl index c9588654d..b1d84d943 100644 --- a/crates/herdr-gpui/locales/en-US/usage-ibmbob.ftl +++ b/crates/herdr-gpui/locales/en-US/usage-ibmbob.ftl @@ -6,3 +6,19 @@ usage-ibmbob-setting-api-key = An IBM Bob API key that can read subscription usage, created at https://bob.ibm.com (the same key Bob Shell uses as BOBSHELL_API_KEY). usage-ibmbob-setting-token = Instead of an API key, an IBM Cloud bearer token (a JWT) for your Bob account. It is sent as "Authorization: Bearer ". + +## The report: Bobcoins used per team against each team's budget. +## Bobcoins are Bob's credits; instance, team, and plan names stay as Bob +## sends them. + +usage-ibmbob-bobcoins-used = Bobcoins used +usage-ibmbob-bobcoin-usage = Bobcoin usage +# A team's Bobcoins used of its budget: $used and $limit are amounts as +# written, such as 10 and 40; $count is the budget, for the plural. +usage-ibmbob-used-of = { $count -> + *[other] { $used } / { $limit } Bobcoins + } +# A team's Bobcoins used without a budget; $count is the amount used. +usage-ibmbob-used = { $count -> + *[other] { $used } Bobcoins used + } diff --git a/crates/herdr-gpui/locales/en-US/usage-jetbrains.ftl b/crates/herdr-gpui/locales/en-US/usage-jetbrains.ftl index 1e791c41a..94abd8c96 100644 --- a/crates/herdr-gpui/locales/en-US/usage-jetbrains.ftl +++ b/crates/herdr-gpui/locales/en-US/usage-jetbrains.ftl @@ -5,3 +5,24 @@ ## service's own menu paths stay as written. usage-jetbrains-setting-ide-path = The config directory of the IDE to read, when the most recently used one is not the right one, e.g. "~/Library/Application Support/JetBrains/IntelliJIdea2025.3" on a Mac or "~/.config/JetBrains/PyCharm2025.3" on Linux. Its options/AIAssistantQuotaManager2.xml appears once AI Assistant has been used. On the Mac running Herdr the IDEs are only looked for when this is set or jetbrains is in show_providers, as macOS may ask before Herdr reads another app's data. + +## The report: AI Assistant credits from the IDE's quota file. IDE names +## and the IDE's own quota word, such as Available, stay as written. + +usage-jetbrains-credits-left = Credits left +# JetBrains' AI Assistant, whose credits these are. +usage-jetbrains-ai-assistant = AI Assistant +usage-jetbrains-ide = IDE +usage-jetbrains-quota = Quota +usage-jetbrains-refill = Refill +# The credits the next refill adds: $amount is a count as written, such as +# 1,000,000; $count is the same number, for the plural. +usage-jetbrains-refill-amount = { $count -> + *[other] { $amount } credits + } +# The same, with how often it comes: $days is a number of days, such as 30. +usage-jetbrains-refill-every = { $count -> + *[other] { $amount } credits + } every { $days -> + *[other] { $days } days + } diff --git a/crates/herdr-gpui/locales/en-US/usage-kilo.ftl b/crates/herdr-gpui/locales/en-US/usage-kilo.ftl index de8214791..2b3d56731 100644 --- a/crates/herdr-gpui/locales/en-US/usage-kilo.ftl +++ b/crates/herdr-gpui/locales/en-US/usage-kilo.ftl @@ -5,3 +5,21 @@ ## service's own menu paths stay as written. usage-kilo-setting-api-key = A Kilo API key from https://app.kilo.ai (Profile → API keys). Without one, the Kilo CLI's sign-in in ~/.local/share/kilo/auth.json is used; run `kilo auth login` to create it. + +## The report: credits left, Kilo Pass usage this billing period, and auto +## top-up. Plan names, such as Starter or Kilo Pass, and payment methods +## stay as Kilo sends them. + +usage-kilo-credits-left = Credits left +usage-kilo-plan = Plan +# Kilo's monthly subscription, as a fact's label. +usage-kilo-pass = Kilo Pass +# $used and $base are dollar amounts, such as $0.00 and $19.00: the pass's +# usage of its base credits this period. +usage-kilo-pass-used = { $used } of { $base } +# The same, with bonus credits on top ($bonus, such as $9.50). +usage-kilo-pass-bonus = { $used } of { $base } (+ { $bonus } bonus) +usage-kilo-top-up = Auto top-up +# Whether auto top-up is on, when Kilo names no payment method. +usage-kilo-off = Off +usage-kilo-on = On diff --git a/crates/herdr-gpui/locales/en-US/usage-kimi.ftl b/crates/herdr-gpui/locales/en-US/usage-kimi.ftl index 7bb1e60de..255f44bda 100644 --- a/crates/herdr-gpui/locales/en-US/usage-kimi.ftl +++ b/crates/herdr-gpui/locales/en-US/usage-kimi.ftl @@ -9,3 +9,17 @@ usage-kimi-setting-region = Which Kimi service the credentials belong to: "china usage-kimi-setting-base-url = An https Kimi Code API base to use with the API key instead of https://api.kimi.com, for a compatible proxy. Setting it stops the CLI's sign-in from being used. usage-kimi-setting-token = Your Kimi web session, for the membership pool and plan, or on its own. Sign in at https://www.kimi.com/code/console, open Developer Tools → Application → Cookies → https://www.kimi.com, and copy the value of the `kimi-auth` cookie (a JWT starting with eyJ), without its name. On kimi.ai, copy `access_token` from Local Storage instead. Not needed when Kimi is listed in show_providers and you are signed in with Chrome or Safari. usage-kimi-setting-cookie = Instead of token, a whole Kimi Cookie header that includes kimi-auth, as "kimi-auth=value; …". + +## The report: the 5-hour rate limit, the weekly request quota, and the +## monthly membership pool. Plan names, such as Moderato, stay as Kimi +## names them. + +# The monthly pool the whole membership shares. +usage-kimi-total-usage = Total usage +# The 7-day limit on Kimi Code alone. +usage-kimi-code-weekly = Code 7-day +usage-kimi-requests = Requests +usage-kimi-weekly-requests = Weekly requests +usage-kimi-rate-limit-requests = Rate limit requests +# $used and $limit are request counts as written, such as 214 and 2,048. +usage-kimi-of = { $used } of { $limit } diff --git a/crates/herdr-gpui/src/usage/tests.rs b/crates/herdr-gpui/src/usage/tests.rs index f27750ab6..cc330d8b1 100644 --- a/crates/herdr-gpui/src/usage/tests.rs +++ b/crates/herdr-gpui/src/usage/tests.rs @@ -16,6 +16,7 @@ mod section; mod status_bar_room; mod text; mod words; +mod words_b; fn provider(id: &str) -> Provider { registry::find(id).unwrap() diff --git a/crates/herdr-gpui/src/usage/tests/words_b.rs b/crates/herdr-gpui/src/usage/tests/words_b.rs new file mode 100644 index 000000000..d37ecc907 --- /dev/null +++ b/crates/herdr-gpui/src/usage/tests/words_b.rs @@ -0,0 +1,366 @@ +//! The words a report names, written by the app: messages with their +//! arguments read in English as the provider used to write them, and counts +//! take their unit's plural. +use super::super::text; +use herdr_usage::Text; + +/// Drops Fluent's isolation marks around placeables. +fn plain(text: String) -> String { + text.replace(['\u{2068}', '\u{2069}'], "") +} + +fn english(words: Text) -> String { + plain(text::words(&words)) +} + +fn message(id: &'static str) -> String { + english(Text::message(id)) +} + +#[test] +fn copilot_reads_as_it_did() { + assert_eq!(message("usage-copilot-premium"), "Premium"); + assert_eq!(message("usage-copilot-chat"), "Chat"); + assert_eq!(message("usage-copilot-credits"), "Credits"); + assert_eq!(message("usage-copilot-credits-used"), "Credits used"); + assert_eq!(message("usage-copilot-quota"), "Quota"); + assert_eq!(message("usage-copilot-unlimited"), "Unlimited"); +} + +#[test] +fn cursor_reads_as_it_did() { + assert_eq!(message("usage-cursor-auto"), "Auto + Composer"); + assert_eq!(message("usage-cursor-api"), "API"); + assert_eq!( + english( + Text::message("usage-cursor-of") + .arg("used", "$15.00") + .arg("limit", "$50.00") + ), + "$15.00 of $50.00" + ); + let budget = |id: &'static str, budget: &str| english(Text::message(id).arg("budget", budget)); + assert_eq!(budget("usage-cursor-budget", "team"), "Team on-demand"); + assert_eq!(budget("usage-cursor-budget", "personal"), "On-demand"); + assert_eq!( + budget("usage-cursor-budget-left", "team"), + "Team on-demand left" + ); + assert_eq!( + budget("usage-cursor-budget-left", "personal"), + "On-demand left" + ); + assert_eq!( + budget("usage-cursor-budget-spend", "team"), + "Team on-demand spend" + ); + assert_eq!( + budget("usage-cursor-budget-spend", "personal"), + "On-demand spend" + ); + assert_eq!(message("usage-cursor-included"), "Included usage"); + assert_eq!(message("usage-cursor-spent"), "Spent this cycle"); +} + +#[test] +fn deepgram_reads_as_it_did() { + assert_eq!(message("usage-deepgram-no-projects"), "No projects"); + assert_eq!( + english(Text::message("usage-deepgram-project").arg("project", "Voice bot")), + "Project: Voice bot" + ); + assert_eq!( + english(Text::message("usage-deepgram-projects").arg("count", 2)), + "2 projects" + ); + assert_eq!( + english( + Text::message("usage-deepgram-audio-hours") + .arg("hours", "1.8") + .arg("billable", "2.5") + ), + "1.8 hours · 2.5 billable" + ); + assert_eq!( + english( + Text::message("usage-deepgram-period-range") + .arg("start", "2026-05-20") + .arg("end", "2026-06-30") + ), + "2026-05-20 to 2026-06-30" + ); + assert_eq!(message("usage-deepgram-tts-characters"), "TTS characters"); +} + +#[test] +fn deepinfra_reads_as_it_did() { + assert_eq!(message("usage-deepinfra-owed"), "Owed"); + assert_eq!(message("usage-deepinfra-available"), "Available"); + assert_eq!( + message("usage-deepinfra-cycle-spend"), + "Billing cycle spend" + ); + assert_eq!(message("usage-deepinfra-month-spend"), "Spent this month"); + assert_eq!(message("usage-deepinfra-suspended"), "Suspended"); +} + +#[test] +fn deepseek_reads_as_it_did() { + assert_eq!( + message("usage-deepseek-add-credits"), + "Add credits at platform.deepseek.com" + ); + assert_eq!( + message("usage-deepseek-unavailable"), + "Balance unavailable for API calls" + ); +} + +#[test] +fn devin_reads_as_it_did() { + assert_eq!(message("usage-devin-extra-usage"), "Extra usage balance"); + assert_eq!(message("usage-devin-organization"), "Organization"); +} + +#[test] +fn devpass_reads_as_it_did() { + assert_eq!(message("usage-devpass-pay-as-you-go"), "Pay as you go"); + assert_eq!(message("usage-devpass-api-key"), "API key (all time)"); + assert_eq!(message("usage-devpass-key-spend"), "Key usage (all time)"); + assert_eq!(message("usage-devpass-credits-left"), "Plan credits left"); +} + +#[test] +fn doubao_reads_as_it_did() { + let window = |plan: &str, period: &str| { + english( + Text::message("usage-doubao-window") + .arg("plan", plan) + .arg("period", period), + ) + }; + assert_eq!(window("agent", "session"), "Agent 5-hour"); + assert_eq!(window("team", "weekly"), "Team weekly"); + assert_eq!(window("agent-team", "monthly"), "Agent team monthly"); +} + +#[test] +fn elevenlabs_reads_as_it_did() { + assert_eq!( + english( + Text::message("usage-elevenlabs-of") + .arg("used", "25,000") + .arg("limit", "100,000") + ), + "25,000 of 100,000" + ); + assert_eq!( + message("usage-elevenlabs-professional-voices"), + "Professional voices" + ); +} + +#[test] +fn factory_reads_as_it_did() { + assert_eq!(message("usage-factory-core-session"), "Core 5-hour"); + assert_eq!(message("usage-factory-core-monthly"), "Core monthly"); + assert_eq!( + english( + Text::message("usage-factory-plan-fallback") + .arg("plan", "Factory Team - Team") + .arg("preference", "credits") + ), + "Factory Team - Team - Fallback: credits" + ); + assert_eq!( + english(Text::message("usage-factory-fallback").arg("preference", "credits")), + "Fallback: credits" + ); +} + +#[test] +fn fireworks_reads_as_it_did() { + assert_eq!(message("usage-fireworks-spend"), "Spend, last 30 days"); + assert_eq!(message("usage-fireworks-none"), "None"); +} + +#[test] +fn gemini_reads_as_it_did() { + assert_eq!( + english(Text::message("usage-gemini-left").arg("percent", "40")), + "40% left" + ); + assert_eq!(message("usage-gemini-workspace"), "Workspace"); +} + +#[test] +fn gitkraken_reads_as_it_did() { + assert_eq!( + english( + Text::message("usage-gitkraken-credits") + .arg("amount", "600") + .arg("count", 600) + ), + "600 credits" + ); + assert_eq!( + english( + Text::message("usage-gitkraken-used-of") + .arg("used", "120") + .arg("limit", "400") + .arg("count", 400) + ), + "120 / 400 credits used" + ); + assert_eq!( + english( + Text::message("usage-gitkraken-used-unlimited") + .arg("used", "55.50") + .arg("count", 55.5) + ), + "55.50 credits used · Unlimited" + ); + assert_eq!( + english( + Text::message("usage-gitkraken-used-no-allowance") + .arg("used", "1") + .arg("count", 1) + ), + "1 credits used · No allowance" + ); + assert_eq!(message("usage-gitkraken-rest"), "Rest of organization"); +} + +#[test] +fn grok_reads_as_it_did() { + assert_eq!(message("usage-grok-not-reported"), "Not reported"); + assert_eq!(message("usage-grok-included-credits"), "Included credits"); +} + +#[test] +fn groq_reads_as_it_did() { + assert_eq!(message("usage-groq-requests"), "Requests / min"); + assert_eq!( + message("usage-groq-enterprise-only"), + "Prometheus metrics need a GroqCloud Enterprise key" + ); +} + +#[test] +fn helmcode_reads_as_it_did() { + assert_eq!( + english( + Text::message("usage-helmcode-tokens") + .arg("used", "1,500,000") + .arg("cap", "2,000,000") + .arg("count", 2_000_000) + ), + "1,500,000 / 2,000,000 tokens" + ); + assert_eq!( + english( + Text::message("usage-helmcode-tokens-credit") + .arg("used", "250,000") + .arg("cap", "1,000,000") + .arg("count", 1_000_000) + .arg("credit", "5,000") + ), + "250,000 / 1,000,000 tokens · 5,000 credit-funded" + ); +} + +#[test] +fn huggingface_reads_as_it_did() { + assert_eq!( + english(Text::message("usage-huggingface-minutes").arg("minutes", "2.5")), + "2.5 min" + ); + assert_eq!( + message("usage-huggingface-included"), + "Included inference amount" + ); +} + +#[test] +fn hyper_reads_as_it_did() { + assert_eq!(message("usage-hyper-browser-session"), "Browser session"); + assert_eq!(message("usage-hyper-hypercredits"), "Hypercredits"); +} + +#[test] +fn ibmbob_reads_as_it_did() { + assert_eq!( + english( + Text::message("usage-ibmbob-used-of") + .arg("used", "10") + .arg("limit", "40") + .arg("count", 40) + ), + "10 / 40 Bobcoins" + ); + assert_eq!( + english( + Text::message("usage-ibmbob-used") + .arg("used", "12.5") + .arg("count", 12.5) + ), + "12.5 Bobcoins used" + ); +} + +#[test] +fn jetbrains_reads_as_it_did() { + assert_eq!( + english( + Text::message("usage-jetbrains-refill-every") + .arg("amount", "1,000,000") + .arg("count", 1_000_000) + .arg("days", 30) + ), + "1,000,000 credits every 30 days" + ); + assert_eq!( + english( + Text::message("usage-jetbrains-refill-amount") + .arg("amount", "500") + .arg("count", 500) + ), + "500 credits" + ); +} + +#[test] +fn kilo_reads_as_it_did() { + assert_eq!( + english( + Text::message("usage-kilo-pass-bonus") + .arg("used", "$0.00") + .arg("base", "$19.00") + .arg("bonus", "$9.50") + ), + "$0.00 of $19.00 (+ $9.50 bonus)" + ); + assert_eq!( + english( + Text::message("usage-kilo-pass-used") + .arg("used", "$1.00") + .arg("base", "$19.00") + ), + "$1.00 of $19.00" + ); + assert_eq!(message("usage-kilo-off"), "Off"); + assert_eq!(message("usage-kilo-on"), "On"); +} + +#[test] +fn kimi_reads_as_it_did() { + assert_eq!( + english( + Text::message("usage-kimi-of") + .arg("used", "214") + .arg("limit", "2,048") + ), + "214 of 2,048" + ); + assert_eq!(message("usage-kimi-code-weekly"), "Code 7-day"); +} diff --git a/crates/herdr-usage/src/providers/copilot.rs b/crates/herdr-usage/src/providers/copilot.rs index bb45d0e41..63886e10e 100644 --- a/crates/herdr-usage/src/providers/copilot.rs +++ b/crates/herdr-usage/src/providers/copilot.rs @@ -10,7 +10,7 @@ //! and its optional budget bars, scraped from github.com's web billing page //! with browser cookies and a page nonce. -use crate::text::{Text, pairs}; +use crate::text::Text; use crate::{ Result, { @@ -142,17 +142,13 @@ pub(crate) fn parse(body: &str, login: Option) -> Result { .as_ref() .and_then(Snapshot::used) .or_else(|| fallback(monthly.chat, limited.chat)); - let windows: Vec = [("Premium", premium), ("Chat", chat)] - .into_iter() - .filter_map(|(name, used)| { - Some(Window::new( - Kind::Named(name.into()), - used?, - resets, - Some(MONTH), - )) - }) - .collect(); + let windows: Vec = [ + (Text::message("usage-copilot-premium"), premium), + (Text::message("usage-copilot-chat"), chat), + ] + .into_iter() + .filter_map(|(name, used)| Some(Window::new(Kind::Named(name), used?, resets, Some(MONTH)))) + .collect(); let credits = snapshots .premium_interactions .as_ref() @@ -174,24 +170,30 @@ pub(crate) fn parse(body: &str, login: Option) -> Result { .copilot_plan .map(|plan| title_case(&plan.replace('_', " "))) .filter(|plan| !plan.is_empty()); - let mut facts: Vec<(String, String)> = Vec::new(); + let mut facts = Vec::new(); if let Some(used) = credits { - facts.push(("Credits used".into(), group(used.round() as i64))); + facts.push(( + Text::message("usage-copilot-credits-used"), + Text::service(group(used.round() as i64)), + )); } if unlimited && windows.is_empty() { - facts.push(("Quota".into(), "Unlimited".into())); + facts.push(( + Text::message("usage-copilot-quota"), + Text::message("usage-copilot-unlimited"), + )); } Ok(Report::new( Provider(&Copilot), Account { - email: login.map(Text::from), - plan: plan.map(Text::from), + email: login.map(Text::service), + plan: plan.map(Text::service), }, windows, ) .with_sections((!facts.is_empty()).then(|| Section::Facts { - title: "Credits".into(), - facts: pairs(facts), + title: Text::message("usage-copilot-credits"), + facts, }))) } @@ -304,7 +306,7 @@ mod tests { let premium = report .windows .iter() - .find(|w| w.kind == Kind::Named("Premium".into())) + .find(|w| w.kind == Kind::Named(Text::message("usage-copilot-premium"))) .unwrap(); assert_eq!(premium.percent(), 10); assert_eq!(premium.length, Some(MONTH)); @@ -315,7 +317,7 @@ mod tests { let chat = report .windows .iter() - .find(|w| w.kind == Kind::Named("Chat".into())) + .find(|w| w.kind == Kind::Named(Text::message("usage-copilot-chat"))) .unwrap(); assert_eq!(chat.percent(), 50); assert!(report.sections.is_empty()); @@ -337,10 +339,16 @@ mod tests { assert_eq!( report.sections, vec![Section::Facts { - title: "Credits".into(), + title: Text::message("usage-copilot-credits"), facts: vec![ - ("Credits used".into(), "31".into()), - ("Quota".into(), "Unlimited".into()), + ( + Text::message("usage-copilot-credits-used"), + Text::service("31") + ), + ( + Text::message("usage-copilot-quota"), + Text::message("usage-copilot-unlimited") + ), ], }] ); @@ -355,13 +363,13 @@ mod tests { let chat = report .windows .iter() - .find(|w| w.kind == Kind::Named("Chat".into())) + .find(|w| w.kind == Kind::Named(Text::message("usage-copilot-chat"))) .unwrap(); assert_eq!(chat.percent(), 20); let premium = report .windows .iter() - .find(|w| w.kind == Kind::Named("Premium".into())) + .find(|w| w.kind == Kind::Named(Text::message("usage-copilot-premium"))) .unwrap(); assert_eq!(premium.percent(), 75); } diff --git a/crates/herdr-usage/src/providers/cursor.rs b/crates/herdr-usage/src/providers/cursor.rs index dea505eff..276d98018 100644 --- a/crates/herdr-usage/src/providers/cursor.rs +++ b/crates/herdr-usage/src/providers/cursor.rs @@ -15,7 +15,7 @@ //! usage-events cost history; and sending every cursor.com cookie when none //! has a known session name. -use crate::text::{Text, pairs}; +use crate::text::Text; use crate::{ Result, { @@ -147,19 +147,23 @@ pub(crate) fn parse( let mut windows = vec![Window::new(Kind::Monthly, headline, end, cycle)]; if quota.is_none() { windows.extend( - [("Auto + Composer", auto), ("API", api)] - .into_iter() - .filter_map(|(name, used)| { - Some(Window::new(Kind::Named(name.into()), used?, end, cycle)) - }), + [ + (Text::message("usage-cursor-auto"), auto), + (Text::message("usage-cursor-api"), api), + ] + .into_iter() + .filter_map(|(name, used)| Some(Window::new(Kind::Named(name), used?, end, cycle))), ); } let mut sections = Vec::new(); if let Some((used, limit)) = quota { sections.push(Section::Facts { - title: "Usage".into(), - facts: vec![("Request quota".into(), format!("{used} / {limit}").into())], + title: Text::message("usage-report-usage"), + facts: vec![( + Text::message("usage-cursor-request-quota"), + Text::service(format!("{used} / {limit}")), + )], }); } else if let Some(grok) = sand .and_then(|body| json::(body).ok()) @@ -177,15 +181,12 @@ pub(crate) fn parse( .find(|(used, limit)| used.is_some_and(|v| v > 0.) || limit.is_some_and(|v| v > 0.)); if let Some((used, limit)) = included { sections.push(Section::Facts { - title: "Included usage".into(), + title: Text::message("usage-cursor-included"), facts: vec![( - "This cycle".into(), - format!( - "${:.2} of ${:.2}", - cents(used).unwrap_or(0.), - cents(limit).unwrap_or(0.) - ) - .into(), + Text::message("usage-cursor-this-cycle"), + Text::message("usage-cursor-of") + .arg("used", format!("${:.2}", cents(used).unwrap_or(0.))) + .arg("limit", format!("${:.2}", cents(limit).unwrap_or(0.))), )], }); } @@ -194,32 +195,48 @@ pub(crate) fn parse( let team_on_demand = team.on_demand.unwrap_or_default(); let personal_used = cents(on_demand.used).unwrap_or(0.); // A personal cap wins; a team with no member cap shares one budget. - let (used, limit, label) = match (cents(on_demand.limit), cents(team_on_demand.limit)) { - (Some(limit), _) if limit > 0. => (personal_used, Some(limit), "On-demand"), + // `team` or `personal`, the budget the messages name. + let (used, limit, budget) = match (cents(on_demand.limit), cents(team_on_demand.limit)) { + (Some(limit), _) if limit > 0. => (personal_used, Some(limit), "personal"), (_, Some(limit)) if limit > 0. => ( cents(team_on_demand.used).unwrap_or(0.), Some(limit), - "Team on-demand", + "team", ), - _ => (personal_used, None, "On-demand"), + _ => (personal_used, None, "personal"), }; + let team = budget == "team"; let usd = || Unit::Currency("USD".into()); let balance = match limit { - Some(limit) => { - Some(Balance::new(format!("{label} left"), (limit - used).max(0.), usd()).out_of(limit)) - } - None if used > 0. => Some(Balance::new(format!("{label} spend"), used, usd())), + Some(limit) => Some( + Balance::new( + Text::message("usage-cursor-budget-left").arg("budget", budget), + (limit - used).max(0.), + usd(), + ) + .out_of(limit), + ), + None if used > 0. => Some(Balance::new( + Text::message("usage-cursor-budget-spend").arg("budget", budget), + used, + usd(), + )), None => None, }; - if limit.is_some() && (used > 0. || label == "Team on-demand") { - let mut facts: Vec<(String, String)> = - vec![("Spent this cycle".into(), format!("${used:.2}"))]; - if label == "Team on-demand" && personal_used > 0. { - facts.push(("Your share".into(), format!("${personal_used:.2}"))); + if limit.is_some() && (used > 0. || team) { + let mut facts = vec![( + Text::message("usage-cursor-spent"), + Text::service(format!("${used:.2}")), + )]; + if team && personal_used > 0. { + facts.push(( + Text::message("usage-cursor-your-share"), + Text::service(format!("${personal_used:.2}")), + )); } sections.push(Section::Facts { - title: label.into(), - facts: pairs(facts), + title: Text::message("usage-cursor-budget").arg("budget", budget), + facts, }); } @@ -227,13 +244,13 @@ pub(crate) fn parse( email: (me .and_then(|me| me.email) .filter(|email| !email.trim().is_empty())) - .map(Text::from), + .map(Text::service), plan: (summary .membership_type .as_deref() .filter(|kind| !kind.trim().is_empty()) .map(membership)) - .map(Text::from), + .map(Text::service), }; Ok(Report::new(Provider(&Cursor), account, windows) .with_balances(balance) @@ -367,7 +384,7 @@ impl Sand { .and_then(|(start, end)| end.duration_since(start).ok()) .filter(|length| !length.is_zero()); Some(Window::new( - Kind::Named("Grok Bot".into()), + Kind::Named(Text::service("Grok Bot")), used, resets_at, length, @@ -421,20 +438,31 @@ mod tests { assert_eq!(monthly.used, 30.); assert_eq!(monthly.resets_at, Some(at(1_738_368_000))); assert_eq!(monthly.length, Some(Duration::from_secs(31 * 86_400))); - assert_eq!(report.windows[1].kind, Kind::Named("API".into())); + assert_eq!( + report.windows[1].kind, + Kind::Named(Text::message("usage-cursor-api")) + ); assert_eq!(report.windows[1].used, 40.); assert_eq!( report.windows[2].kind, - Kind::Named("Auto + Composer".into()) + Kind::Named(Text::message("usage-cursor-auto")) ); assert_eq!(report.windows[2].used, 20.); let balance = &report.balances[0]; - assert_eq!(balance.label, "On-demand left"); + assert_eq!( + balance.label, + Text::message("usage-cursor-budget-left").arg("budget", "personal") + ); assert_eq!(balance.amount, 95.); assert_eq!(balance.total, Some(100.)); assert!(report.sections.contains(&Section::Facts { - title: "Included usage".into(), - facts: vec![("This cycle".into(), "$15.00 of $50.00".into())], + title: Text::message("usage-cursor-included"), + facts: vec![( + Text::message("usage-cursor-this-cycle"), + Text::message("usage-cursor-of") + .arg("used", "$15.00") + .arg("limit", "$50.00") + )], })); } @@ -451,8 +479,11 @@ mod tests { assert_eq!(report.windows.len(), 1); assert_eq!(report.windows[0].used, 25.); assert!(report.sections.contains(&Section::Facts { - title: "Usage".into(), - facts: vec![("Request quota".into(), "125 / 500".into())], + title: Text::message("usage-report-usage"), + facts: vec![( + Text::message("usage-cursor-request-quota"), + Text::service("125 / 500") + )], })); } @@ -477,19 +508,25 @@ mod tests { let report = parse(summary, None, None, Some(sand), at(1_736_500_000)).unwrap(); assert_eq!(report.windows[0].used, 25.); assert_eq!(report.windows[0].length, Some(MONTH)); - assert_eq!(report.balances[0].label, "Team on-demand left"); + assert_eq!( + report.balances[0].label, + Text::message("usage-cursor-budget-left").arg("budget", "team") + ); assert_eq!(report.balances[0].amount, 480.); let Some(Section::Limit(grok)) = report.sections.first() else { panic!("expected Grok Bot limit"); }; - assert_eq!(grok.kind, Kind::Named("Grok Bot".into())); + assert_eq!(grok.kind, Kind::Named(Text::service("Grok Bot"))); assert_eq!(grok.used, 42.5); assert_eq!(grok.length, Some(Duration::from_secs(7 * 86_400))); assert!(report.sections.contains(&Section::Facts { - title: "Team on-demand".into(), + title: Text::message("usage-cursor-budget").arg("budget", "team"), facts: vec![ - ("Spent this cycle".into(), "$20.00".into()), - ("Your share".into(), "$3.00".into()), + (Text::message("usage-cursor-spent"), Text::service("$20.00")), + ( + Text::message("usage-cursor-your-share"), + Text::service("$3.00") + ), ], })); } diff --git a/crates/herdr-usage/src/providers/deepgram.rs b/crates/herdr-usage/src/providers/deepgram.rs index 984e72f99..d571ddfc9 100644 --- a/crates/herdr-usage/src/providers/deepgram.rs +++ b/crates/herdr-usage/src/providers/deepgram.rs @@ -5,7 +5,7 @@ //! does. Deepgram reports usage, not a balance or a limit, so the panel //! shows facts only. Everything CodexBar reads is ported. -use crate::text::{Text, pairs}; +use crate::text::Text; use crate::{ Result, { @@ -102,47 +102,62 @@ fn parse_breakdown(body: &str) -> Result { fn report(projects: &[Project], totals: &Totals) -> Report { let plan = match projects { - [] => "No projects".to_owned(), - [project] => format!( - "Project: {}", - project.name.as_deref().unwrap_or(&project.project_id) + [] => Text::message("usage-deepgram-no-projects"), + [project] => Text::message("usage-deepgram-project").arg( + "project", + project.name.as_deref().unwrap_or(&project.project_id), ), - projects => format!("{} projects", projects.len()), + projects => Text::message("usage-deepgram-projects").arg("count", projects.len()), }; - let mut facts = vec![("Requests".to_owned(), whole(totals.requests))]; + let mut facts = vec![( + Text::message("usage-deepgram-requests"), + Text::service(whole(totals.requests)), + )]; if totals.hours > 0. || totals.total_hours > 0. { facts.push(( - "Audio".into(), - format!( - "{} hours · {} billable", - decimal(totals.hours), - decimal(totals.total_hours) - ), + Text::message("usage-deepgram-audio"), + Text::message("usage-deepgram-audio-hours") + .arg("hours", decimal(totals.hours)) + .arg("billable", decimal(totals.total_hours)), )); } if totals.agent_hours > 0. { - facts.push(("Agent hours".into(), decimal(totals.agent_hours))); + facts.push(( + Text::message("usage-deepgram-agent-hours"), + Text::service(decimal(totals.agent_hours)), + )); } if totals.tokens_in > 0. || totals.tokens_out > 0. { - facts.push(("Tokens".into(), whole(totals.tokens_in + totals.tokens_out))); + facts.push(( + Text::message("usage-deepgram-tokens"), + Text::service(whole(totals.tokens_in + totals.tokens_out)), + )); } if totals.tts > 0. { - facts.push(("TTS characters".into(), whole(totals.tts))); + facts.push(( + Text::message("usage-deepgram-tts-characters"), + Text::service(whole(totals.tts)), + )); } if let (Some(start), Some(end)) = (&totals.start, &totals.end) { - facts.push(("Period".into(), format!("{start} to {end}"))); + facts.push(( + Text::message("usage-deepgram-period"), + Text::message("usage-deepgram-period-range") + .arg("start", start.as_str()) + .arg("end", end.as_str()), + )); } Report::new( Provider(&Deepgram), Account { email: None, - plan: Some(Text::from(plan)), + plan: Some(plan), }, Vec::new(), ) .with_sections([Section::Facts { - title: "Usage summary".into(), - facts: pairs(facts), + title: Text::message("usage-deepgram-usage-summary"), + facts, }]) } @@ -255,8 +270,8 @@ mod tests { let report = report(&projects, &totals); assert!(report.windows.is_empty()); assert_eq!( - report.account.plan.as_ref().map(Text::as_str), - Some("2 projects") + report.account.plan, + Some(Text::message("usage-deepgram-projects").arg("count", 2)) ); let Section::Facts { facts, .. } = &report.sections[0] else { panic!("expected facts"); @@ -265,13 +280,30 @@ mod tests { facts .iter() .find(|(name, _)| name == label) - .map(|(_, value)| value.as_str()) + .map(|(_, value)| value.clone()) }; - assert_eq!(fact("Requests"), Some("55")); - assert_eq!(fact("Audio"), Some("1.8 hours · 2.5 billable")); - assert_eq!(fact("Tokens"), Some("1,250")); - assert_eq!(fact("TTS characters"), Some("1,200")); - assert_eq!(fact("Period"), Some("2026-05-20 to 2026-06-30")); + assert_eq!(fact("usage-deepgram-requests"), Some(Text::service("55"))); + assert_eq!( + fact("usage-deepgram-audio"), + Some( + Text::message("usage-deepgram-audio-hours") + .arg("hours", "1.8") + .arg("billable", "2.5") + ) + ); + assert_eq!(fact("usage-deepgram-tokens"), Some(Text::service("1,250"))); + assert_eq!( + fact("usage-deepgram-tts-characters"), + Some(Text::service("1,200")) + ); + assert_eq!( + fact("usage-deepgram-period"), + Some( + Text::message("usage-deepgram-period-range") + .arg("start", "2026-05-20") + .arg("end", "2026-06-30") + ) + ); } #[test] @@ -279,8 +311,8 @@ mod tests { let projects = parse_projects(PROJECTS).unwrap(); let report = report(&projects[..1], &parse_breakdown(BREAKDOWN).unwrap()); assert_eq!( - report.account.plan.as_ref().map(Text::as_str), - Some("Project: Voice bot") + report.account.plan, + Some(Text::message("usage-deepgram-project").arg("project", "Voice bot")) ); } diff --git a/crates/herdr-usage/src/providers/deepinfra.rs b/crates/herdr-usage/src/providers/deepinfra.rs index 7da142cfb..622a5c57f 100644 --- a/crates/herdr-usage/src/providers/deepinfra.rs +++ b/crates/herdr-usage/src/providers/deepinfra.rs @@ -3,7 +3,7 @@ //! DeepInfra's documented billing endpoints. DeepInfra is API-only, so there //! is no agent sign-in to find; everything CodexBar reads is ported. -use crate::text::pairs; +use crate::text::Text; use crate::{ Error, Result, { @@ -74,32 +74,43 @@ pub(crate) fn parse(checklist: &str, usage: &str) -> Result { }; let usd = || Unit::Currency("USD".into()); let mut balances = vec![if balance > 0. { - Balance::new("Owed", balance, usd()) + Balance::new(Text::message("usage-deepinfra-owed"), balance, usd()) } else { - Balance::new("Available", 0. - balance, usd()) + Balance::new( + Text::message("usage-deepinfra-available"), + 0. - balance, + usd(), + ) }]; balances.push(match checklist.limit.filter(|limit| *limit > 0.) { - Some(limit) => Balance::new("Billing cycle spend", recent, usd()).out_of(limit), - None => Balance::new("Spent this month", month_cost, usd()), + Some(limit) => { + Balance::new(Text::message("usage-deepinfra-cycle-spend"), recent, usd()).out_of(limit) + } + None => Balance::new( + Text::message("usage-deepinfra-month-spend"), + month_cost, + usd(), + ), }); let mut facts = Vec::new(); if checklist.limit.is_some_and(|limit| limit > 0.) { facts.push(( - "Spent this month".to_owned(), - Balance::new("", month_cost, usd()).amount_text(), + Text::message("usage-deepinfra-month-spend"), + Text::service(Balance::new(Text::service(""), month_cost, usd()).amount_text()), )); } if checklist.suspended.unwrap_or(false) { + // DeepInfra's own reason, when it gives one. let reason = checklist .suspend_reason .map(|reason| reason.trim().to_owned()) .filter(|reason| !reason.is_empty()) - .unwrap_or_else(|| "Suspended".into()); - facts.push(("Suspended".into(), reason)); + .map_or_else(|| Text::message("usage-deepinfra-suspended"), Text::service); + facts.push((Text::message("usage-deepinfra-suspended"), reason)); } let sections = (!facts.is_empty()).then(|| Section::Facts { - title: "Account".into(), - facts: pairs(facts), + title: Text::message("usage-deepinfra-account"), + facts, }); Ok( Report::new(Provider(&Deepinfra), Account::default(), Vec::new()) @@ -131,7 +142,6 @@ struct Month { #[allow(clippy::unwrap_used, clippy::expect_used)] mod tests { use super::*; - use crate::text::pair; const USAGE: &str = r#"{"initial_month": "2026-01", "months": [ {"period": "2026-08", "total_cost": 1234}, @@ -144,9 +154,15 @@ mod tests { r#"{"stripe_balance": -20.5, "recent": 0.5, "limit": null, "suspended": false}"#; let report = parse(checklist, USAGE).unwrap(); assert!(report.windows.is_empty()); - assert_eq!(report.balances[0].label, "Available"); + assert_eq!( + report.balances[0].label, + Text::message("usage-deepinfra-available") + ); assert!((report.balances[0].amount - 20.).abs() < 1e-9); - assert_eq!(report.balances[1].label, "Spent this month"); + assert_eq!( + report.balances[1].label, + Text::message("usage-deepinfra-month-spend") + ); assert!((report.balances[1].amount - 4.5).abs() < 1e-9); assert!(report.sections.is_empty()); } @@ -156,22 +172,36 @@ mod tests { let checklist = r#"{"stripe_balance": 3, "recent": 12, "limit": 50, "suspended": true, "suspend_reason": " unpaid invoice "}"#; let report = parse(checklist, USAGE).unwrap(); - assert_eq!(report.balances[0].label, "Owed"); + assert_eq!( + report.balances[0].label, + Text::message("usage-deepinfra-owed") + ); assert!((report.balances[0].amount - 15.).abs() < 1e-9); assert_eq!( report.balances[1], - Balance::new("Billing cycle spend", 12., Unit::Currency("USD".into())).out_of(50.) + Balance::new( + Text::message("usage-deepinfra-cycle-spend"), + 12., + Unit::Currency("USD".into()) + ) + .out_of(50.) ); let Some(Section::Facts { facts, .. }) = report.sections.first() else { panic!("expected account facts"); }; assert_eq!( facts[0], - pair(("Spent this month".to_owned(), "$4.50".to_owned())) + ( + Text::message("usage-deepinfra-month-spend"), + Text::service("$4.50") + ) ); assert_eq!( facts[1], - pair(("Suspended".to_owned(), "unpaid invoice".to_owned())) + ( + Text::message("usage-deepinfra-suspended"), + Text::service("unpaid invoice") + ) ); } diff --git a/crates/herdr-usage/src/providers/deepseek.rs b/crates/herdr-usage/src/providers/deepseek.rs index 247d76658..ea6752824 100644 --- a/crates/herdr-usage/src/providers/deepseek.rs +++ b/crates/herdr-usage/src/providers/deepseek.rs @@ -8,7 +8,7 @@ //! and the Platform's per-key daily cost and token history, private //! dashboard endpoints whose calendar bucketing adds little to a balance. -use crate::text::pairs; +use crate::text::Text; use crate::{ Error, Result, { @@ -166,29 +166,36 @@ fn report(wallet: Option, available: bool) -> Report { paid: 0., }); let unit = Unit::Currency(wallet.currency.trim().to_uppercase()); + let amount = |amount: f64| { + Text::service(Balance::new(Text::service(""), amount, unit.clone()).amount_text()) + }; let mut facts = vec![ + (Text::message("usage-deepseek-paid"), amount(wallet.paid)), ( - "Paid".to_owned(), - Balance::new("Paid", wallet.paid, unit.clone()).amount_text(), - ), - ( - "Granted".to_owned(), - Balance::new("Granted", wallet.granted, unit.clone()).amount_text(), + Text::message("usage-deepseek-granted"), + amount(wallet.granted), ), ]; if wallet.total <= 0. { facts.push(( - "Status".into(), - "Add credits at platform.deepseek.com".into(), + Text::message("usage-deepseek-status"), + Text::message("usage-deepseek-add-credits"), )); } else if !available { - facts.push(("Status".into(), "Balance unavailable for API calls".into())); + facts.push(( + Text::message("usage-deepseek-status"), + Text::message("usage-deepseek-unavailable"), + )); } Report::new(Provider(&Deepseek), Account::default(), Vec::new()) - .with_balances([Balance::new("Balance", wallet.total, unit)]) + .with_balances([Balance::new( + Text::message("usage-deepseek-balance"), + wallet.total, + unit, + )]) .with_sections([Section::Facts { - title: "Credits".into(), - facts: pairs(facts), + title: Text::message("usage-deepseek-credits"), + facts, }]) } @@ -249,7 +256,6 @@ struct PlatformWallet { #[allow(clippy::unwrap_used, clippy::expect_used)] mod tests { use super::*; - use crate::text::pair; #[test] fn api_balance_prefers_funded_usd() { @@ -264,13 +270,29 @@ mod tests { assert!(report.windows.is_empty()); assert_eq!( report.balances, - vec![Balance::new("Balance", 50., Unit::Currency("USD".into()))] + vec![Balance::new( + Text::message("usage-deepseek-balance"), + 50., + Unit::Currency("USD".into()) + )] ); let Some(Section::Facts { facts, .. }) = report.sections.first() else { panic!("expected credit facts"); }; - assert_eq!(facts[0], pair(("Paid".to_owned(), "$40.00".to_owned()))); - assert_eq!(facts[1], pair(("Granted".to_owned(), "$10.00".to_owned()))); + assert_eq!( + facts[0], + ( + Text::message("usage-deepseek-paid"), + Text::service("$40.00") + ) + ); + assert_eq!( + facts[1], + ( + Text::message("usage-deepseek-granted"), + Text::service("$10.00") + ) + ); assert_eq!(facts.len(), 2); } @@ -289,7 +311,7 @@ mod tests { let Some(Section::Facts { facts, .. }) = report.sections.first() else { panic!("expected credit facts"); }; - assert_eq!(facts[2].1, "Balance unavailable for API calls"); + assert_eq!(facts[2].1, Text::message("usage-deepseek-unavailable")); } #[test] @@ -317,8 +339,8 @@ mod tests { let Some(Section::Facts { facts, .. }) = report.sections.first() else { panic!("expected credit facts"); }; - assert_eq!(facts[0].1, "$7.97"); - assert_eq!(facts[1].1, "$0.50"); + assert_eq!(facts[0].1, Text::service("$7.97")); + assert_eq!(facts[1].1, Text::service("$0.50")); } #[test] diff --git a/crates/herdr-usage/src/providers/devin.rs b/crates/herdr-usage/src/providers/devin.rs index 2c628e6b7..66afcbe45 100644 --- a/crates/herdr-usage/src/providers/devin.rs +++ b/crates/herdr-usage/src/providers/devin.rs @@ -190,18 +190,23 @@ pub(crate) fn parse(body: &str, organization: &str) -> Result { Provider(&Devin), Account { email: None, - plan: plan.map(Text::from), + plan: plan.map(Text::service), }, daily.into_iter().chain(weekly).collect(), ) - .with_balances( - overage.map(|amount| { - Balance::new("Extra usage balance", amount, Unit::Currency("USD".into())) - }), - ) + .with_balances(overage.map(|amount| { + Balance::new( + Text::message("usage-devin-extra-usage"), + amount, + Unit::Currency("USD".into()), + ) + })) .with_sections([Section::Facts { - title: "Organization".into(), - facts: vec![("Name".into(), Text::from(organization.to_owned()))], + title: Text::message("usage-devin-organization"), + facts: vec![( + Text::message("usage-devin-name"), + Text::service(organization), + )], }])) } diff --git a/crates/herdr-usage/src/providers/devpass.rs b/crates/herdr-usage/src/providers/devpass.rs index 72a0e635e..1e785ffe1 100644 --- a/crates/herdr-usage/src/providers/devpass.rs +++ b/crates/herdr-usage/src/providers/devpass.rs @@ -4,7 +4,7 @@ //! Publishable keys cannot read plan state. As in CodexBar, there is no local //! sign-in or dashboard cookie to find. -use crate::text::{Text, pairs}; +use crate::text::Text; use crate::{ Result, { @@ -47,23 +47,33 @@ pub(crate) fn parse(body: &str) -> Result { let data = json::(body)?.data.ok_or_else(invalid)?; let key_used = money(&data.usage)?; let key_limit = data.limit.as_deref().map(money).transpose()?; - let mut key_facts: Vec<(String, String)> = vec![("All-time key usage".into(), usd(key_used))]; + let mut key_facts = vec![( + Text::message("usage-devpass-key-usage"), + Text::service(usd(key_used)), + )]; if let Some(limit) = key_limit { - key_facts.push(("Key spending limit".into(), usd(limit))); + key_facts.push(( + Text::message("usage-devpass-key-limit"), + Text::service(usd(limit)), + )); } let key_section = Section::Facts { - title: "API key (all time)".into(), - facts: pairs(key_facts), + title: Text::message("usage-devpass-api-key"), + facts: key_facts, }; let plan = match data.dev_plan.as_str() { "none" => { - let mut spend = Balance::new("Key usage (all time)", key_used, usd_unit()); + let mut spend = Balance::new( + Text::message("usage-devpass-key-spend"), + key_used, + usd_unit(), + ); if let Some(limit) = key_limit { spend = spend.out_of(limit); } let account = Account { email: None, - plan: Some("Pay as you go".into()), + plan: Some(Text::message("usage-devpass-pay-as-you-go")), }; return Ok(Report::new(Provider(&Devpass), account, Vec::new()) .with_balances([spend]) @@ -104,25 +114,33 @@ pub(crate) fn parse(body: &str) -> Result { )); } let credits = Section::Facts { - title: "DevPass credits".into(), + title: Text::message("usage-devpass-credits"), facts: vec![ ( - "Cycle used".into(), - format!("{} / {}", usd(used), usd(limit)).into(), + Text::message("usage-devpass-cycle-used"), + Text::service(format!("{} / {}", usd(used), usd(limit))), + ), + ( + Text::message("usage-devpass-cycle-remaining"), + Text::service(usd(remaining)), ), - ("Cycle remaining".into(), Text::from(usd(remaining))), ( - "Premium weekly".into(), - format!("{} / {}", usd(weekly_used), usd(weekly_limit)).into(), + Text::message("usage-devpass-premium-weekly"), + Text::service(format!("{} / {}", usd(weekly_used), usd(weekly_limit))), ), ], }; let account = Account { email: None, - plan: Some(plan.into()), + plan: Some(Text::service(plan)), }; Ok(Report::new(Provider(&Devpass), account, windows) - .with_balances([Balance::new("Plan credits left", remaining, usd_unit()).out_of(limit)]) + .with_balances([Balance::new( + Text::message("usage-devpass-credits-left"), + remaining, + usd_unit(), + ) + .out_of(limit)]) .with_sections([credits, key_section])) } @@ -131,7 +149,7 @@ fn usd_unit() -> Unit { } fn usd(amount: f64) -> String { - Balance::new("", amount, usd_unit()).amount_text() + Balance::new(Text::service(""), amount, usd_unit()).amount_text() } /// Amounts arrive as unsigned decimal strings; anything else is a changed @@ -195,6 +213,16 @@ mod tests { assert_eq!(report.balances[0].amount, 212.); assert_eq!(report.balances[0].total, Some(237.)); assert_eq!(report.sections.len(), 2); + let Section::Facts { facts, .. } = &report.sections[0] else { + panic!("expected credit facts"); + }; + assert_eq!( + facts[0], + ( + Text::message("usage-devpass-cycle-used"), + Text::service("$25.00 / $237.00") + ) + ); } #[test] @@ -204,8 +232,8 @@ mod tests { let report = parse(body).unwrap(); assert!(report.windows.is_empty()); assert_eq!( - report.account.plan.as_ref().map(Text::as_str), - Some("Pay as you go") + report.account.plan, + Some(Text::message("usage-devpass-pay-as-you-go")) ); assert_eq!(report.balances[0].amount, 3.5); assert_eq!(report.balances[0].total, Some(10.)); diff --git a/crates/herdr-usage/src/providers/doubao.rs b/crates/herdr-usage/src/providers/doubao.rs index 6b5898360..101bc3802 100644 --- a/crates/herdr-usage/src/providers/doubao.rs +++ b/crates/herdr-usage/src/providers/doubao.rs @@ -69,11 +69,13 @@ pub(crate) fn parse(stdout: &str) -> Result { } let mut windows = Vec::new(); for item in answer.items { + // The plan `usage-doubao-window` names; the personal Coding Plan's + // windows are the standard ones. let plan = match item.product.to_ascii_lowercase().as_str() { "coding-plan" => None, - "agent-plan" => Some("Agent"), - "coding-plan-team" => Some("Team"), - "agent-plan-team" => Some("Agent team"), + "agent-plan" => Some("agent"), + "coding-plan-team" => Some("team"), + "agent-plan-team" => Some("agent-team"), _ => continue, }; if item.subscribed == Some(false) { @@ -82,12 +84,16 @@ pub(crate) fn parse(stdout: &str) -> Result { // A product that failed to load reports no periods; it must not // hide the others, but with nothing else it is an error below. for period in item.periods.unwrap_or_default() { - let Some((kind, title, length)) = period_kind(&period.label) else { + let Some((kind, name, length)) = period_kind(&period.label) else { continue; }; let kind = match plan { None => kind, - Some(plan) => Kind::Named(format!("{plan} {title}").into()), + Some(plan) => Kind::Named( + Text::message("usage-doubao-window") + .arg("plan", plan) + .arg("period", name), + ), }; windows.push(Window::new( kind, @@ -102,14 +108,15 @@ pub(crate) fn parse(stdout: &str) -> Result { } let account = Account { email: None, - plan: method.map(Text::from), + plan: method.map(Text::service), }; Ok(Report::new(Provider(&Doubao), account, windows)) } +/// The window's kind, the period `usage-doubao-window` names, and its length. fn period_kind(label: &str) -> Option<(Kind, &'static str, Duration)> { Some(match label.trim().to_ascii_lowercase().as_str() { - "session" | "5-hour" | "five_hour" | "5h" => (Kind::Session, "5-hour", SESSION), + "session" | "5-hour" | "five_hour" | "5h" => (Kind::Session, "session", SESSION), "weekly" | "week" => (Kind::Weekly, "weekly", WEEK), "monthly" | "month" => (Kind::Monthly, "monthly", MONTH), _ => return None, @@ -193,10 +200,17 @@ mod tests { assert!(session.resets_at.is_some()); assert!((find(Kind::Weekly).used - 2.71).abs() < 1e-4); assert!((find(Kind::Monthly).used - 1.36).abs() < 1e-4); - let agent = find(Kind::Named("Agent weekly".into())); + let named = |period: &str| { + Kind::Named( + Text::message("usage-doubao-window") + .arg("plan", "agent") + .arg("period", period), + ) + }; + let agent = find(named("weekly")); assert!((agent.used - 28.7).abs() < 1e-4); assert_eq!(agent.length, Some(WEEK)); - assert_eq!(find(Kind::Named("Agent 5-hour".into())).used, 0.); + assert_eq!(find(named("session")).used, 0.); assert_eq!(report.windows.len(), 6); } diff --git a/crates/herdr-usage/src/providers/elevenlabs.rs b/crates/herdr-usage/src/providers/elevenlabs.rs index daa7685c1..279ecf014 100644 --- a/crates/herdr-usage/src/providers/elevenlabs.rs +++ b/crates/herdr-usage/src/providers/elevenlabs.rs @@ -5,7 +5,7 @@ //! CodexBar reads is ported: character credits with their reset, voice and //! professional voice slots, and the plan tier and status. -use crate::text::{Text, pairs}; +use crate::text::Text; use crate::{ Result, { @@ -77,12 +77,12 @@ pub(crate) fn parse(body: &str) -> Result { ); let slots = [ ( - "Voice slots", + Text::message("usage-elevenlabs-voice-slots"), subscription.voice_slots_used, subscription.voice_limit, ), ( - "Professional voices", + Text::message("usage-elevenlabs-professional-voices"), subscription.professional_voice_slots_used, subscription.professional_voice_limit, ), @@ -94,18 +94,19 @@ pub(crate) fn parse(body: &str) -> Result { }) .collect::>(); let mut facts = vec![( - "Credits".to_owned(), - format!( - "{} of {}", - group(subscription.character_count), - group(subscription.character_limit) - ), + Text::message("usage-elevenlabs-credits"), + Text::message("usage-elevenlabs-of") + .arg("used", group(subscription.character_count)) + .arg("limit", group(subscription.character_limit)), )]; - facts.extend( - slots - .iter() - .map(|(title, used, limit)| ((*title).to_owned(), format!("{used} of {limit}"))), - ); + facts.extend(slots.iter().map(|(title, used, limit)| { + ( + title.clone(), + Text::message("usage-elevenlabs-of") + .arg("used", used.to_string()) + .arg("limit", limit.to_string()), + ) + })); let tier = subscription .tier .as_deref() @@ -123,6 +124,7 @@ pub(crate) fn parse(body: &str) -> Result { .as_deref() .map(str::trim) .filter(|status| !status.is_empty()); + // The tier and status are ElevenLabs' own words, such as past_due. let plan = match (tier, status) { (Some(tier), Some(status)) if !status.eq_ignore_ascii_case("active") => { Some(format!("{tier} · {status}")) @@ -134,7 +136,7 @@ pub(crate) fn parse(body: &str) -> Result { Provider(&Elevenlabs), Account { email: None, - plan: plan.map(Text::from), + plan: plan.map(Text::service), }, vec![credits], ) @@ -143,15 +145,15 @@ pub(crate) fn parse(body: &str) -> Result { .iter() .map(|(title, used, limit)| { Section::Limit(Window::new( - Kind::Named(Text::from((*title).to_owned())), + Kind::Named(title.clone()), percent(*used, *limit), None, None, )) }) .chain([Section::Facts { - title: "Usage".into(), - facts: pairs(facts), + title: Text::message("usage-report-usage"), + facts, }]) .collect::>(), )) @@ -175,7 +177,6 @@ struct Subscription { mod tests { use super::*; use crate::Error; - use crate::text::pair; use std::time::{Duration, SystemTime}; /// Shaped like the documented subscription response. @@ -211,7 +212,10 @@ mod tests { let Section::Limit(voices) = &report.sections[0] else { panic!("expected a limit"); }; - assert_eq!(voices.kind, Kind::Named("Voice slots".into())); + assert_eq!( + voices.kind, + Kind::Named(Text::message("usage-elevenlabs-voice-slots")) + ); assert_eq!(voices.percent(), 10); let Section::Limit(professional) = &report.sections[1] else { panic!("expected a limit"); @@ -222,7 +226,21 @@ mod tests { }; assert_eq!( facts[0], - pair(("Credits".to_owned(), "25,000 of 100,000".to_owned())) + ( + Text::message("usage-elevenlabs-credits"), + Text::message("usage-elevenlabs-of") + .arg("used", "25,000") + .arg("limit", "100,000") + ) + ); + assert_eq!( + facts[1], + ( + Text::message("usage-elevenlabs-voice-slots"), + Text::message("usage-elevenlabs-of") + .arg("used", "3") + .arg("limit", "30") + ) ); } diff --git a/crates/herdr-usage/src/providers/factory.rs b/crates/herdr-usage/src/providers/factory.rs index 9d7c8c7f6..6953611bb 100644 --- a/crates/herdr-usage/src/providers/factory.rs +++ b/crates/herdr-usage/src/providers/factory.rs @@ -159,15 +159,27 @@ fn parse_limits(me: &Me, body: &str, now: SystemTime) -> Result> .filter(|core| core.has_usage()) .map(|core| { [ - ("Core 5-hour", &core.five_hour, Some(SESSION)), - ("Core weekly", &core.weekly, Some(WEEK)), - ("Core monthly", &core.monthly, Kind::Monthly.length()), + ( + Text::message("usage-factory-core-session"), + &core.five_hour, + Some(SESSION), + ), + ( + Text::message("usage-factory-core-weekly"), + &core.weekly, + Some(WEEK), + ), + ( + Text::message("usage-factory-core-monthly"), + &core.monthly, + Kind::Monthly.length(), + ), ] .into_iter() .map(|(name, window, length)| { Section::Limit(Window { length, - ..window.window(Kind::Named(name.into()), now) + ..window.window(Kind::Named(name), now) }) }) .collect::>() @@ -176,7 +188,7 @@ fn parse_limits(me: &Me, body: &str, now: SystemTime) -> Result> let cents = billing.extra_usage_balance_cents.unwrap_or(0.); let balance = (cents > 0. || billing.extra_usage_allowed == Some(true)).then(|| { Balance::new( - "Extra usage balance", + Text::message("usage-factory-extra-usage"), cents / 100., Unit::Currency("USD".into()), ) @@ -199,18 +211,21 @@ fn parse_usage(me: &Me, body: &str) -> Result { .zip(end) .and_then(|(start, end)| end.duration_since(start).ok()) .filter(|length| !length.is_zero()); - let windows: Vec = [("Standard", usage.standard), ("Premium", usage.premium)] - .into_iter() - .filter_map(|(name, tokens)| { - let tokens = tokens?; - Some(Window::new( - Kind::Named(name.into()), - tokens.used_percent(), - end, - length, - )) - }) - .collect(); + let windows: Vec = [ + (Text::message("usage-factory-standard"), usage.standard), + (Text::message("usage-factory-premium"), usage.premium), + ] + .into_iter() + .filter_map(|(name, tokens)| { + let tokens = tokens?; + Some(Window::new( + Kind::Named(name), + tokens.used_percent(), + end, + length, + )) + }) + .collect(); if windows.is_empty() { return Err(invalid()); } @@ -235,8 +250,9 @@ impl Me { .filter(|id| !id.is_empty()) } - /// `Factory Team - Pro - Fallback: credits`, as CodexBar shows it. - fn plan(&self, overage: Option<&str>) -> Option { + /// `Factory Team - Pro - Fallback: credits`, as CodexBar shows it: the + /// tier and plan are Factory's names, the overage preference its word. + fn plan(&self, overage: Option<&str>) -> Option { let subscription = self.organization.as_ref()?.subscription.as_ref(); let mut parts = Vec::new(); if let Some(tier) = subscription @@ -254,21 +270,32 @@ impl Me { { parts.push(plan.to_owned()); } - if let Some(overage) = overage.map(str::trim).filter(|overage| !overage.is_empty()) { - parts.push(format!("Fallback: {overage}")); + let plan = (!parts.is_empty()).then(|| parts.join(" - ")); + match ( + plan, + overage.map(str::trim).filter(|overage| !overage.is_empty()), + ) { + (Some(plan), Some(overage)) => Some( + Text::message("usage-factory-plan-fallback") + .arg("plan", plan) + .arg("preference", overage), + ), + (None, Some(overage)) => { + Some(Text::message("usage-factory-fallback").arg("preference", overage)) + } + (plan, None) => plan.map(Text::service), } - (!parts.is_empty()).then(|| parts.join(" - ")) } - fn account(&self, plan: Option) -> Account { + fn account(&self, plan: Option) -> Account { Account { email: (self .user_profile .as_ref() .and_then(|profile| profile.email.clone()) .filter(|email| !email.trim().is_empty())) - .map(Text::from), - plan: plan.map(Text::from), + .map(Text::service), + plan, } } @@ -280,8 +307,8 @@ impl Me { .clone() .filter(|name| !name.trim().is_empty())?; Some(Section::Facts { - title: "Organization".into(), - facts: vec![("Name".into(), Text::from(name))], + title: Text::message("usage-factory-organization"), + facts: vec![(Text::message("usage-factory-name"), Text::service(name))], }) } } @@ -496,8 +523,12 @@ mod tests { Some("dev@acme.test") ); assert_eq!( - report.account.plan.as_ref().map(Text::as_str), - Some("Factory Team - Team - Fallback: credits") + report.account.plan, + Some( + Text::message("usage-factory-plan-fallback") + .arg("plan", "Factory Team - Team") + .arg("preference", "credits") + ) ); } @@ -532,21 +563,27 @@ mod tests { }"#; let report = parse_usage(&me, body).unwrap(); assert_eq!(report.windows.len(), 2); - assert_eq!(report.windows[0].kind, Kind::Named("Premium".into())); + assert_eq!( + report.windows[0].kind, + Kind::Named(Text::message("usage-factory-premium")) + ); assert_eq!(report.windows[0].percent(), 25); - assert_eq!(report.windows[1].kind, Kind::Named("Standard".into())); + assert_eq!( + report.windows[1].kind, + Kind::Named(Text::message("usage-factory-standard")) + ); assert_eq!(report.windows[1].percent(), 10); assert_eq!(report.windows[1].resets_at, Some(at(1_700_003_600))); assert_eq!(report.windows[1].length, Some(Duration::from_secs(3600))); assert_eq!( - report.account.plan.as_ref().map(Text::as_str), - Some("Factory Team - Team") + report.account.plan, + Some(Text::service("Factory Team - Team")) ); assert_eq!( report.sections, vec![Section::Facts { - title: "Organization".into(), - facts: vec![("Name".into(), "Acme".into())], + title: Text::message("usage-factory-organization"), + facts: vec![(Text::message("usage-factory-name"), Text::service("Acme"))], }] ); } diff --git a/crates/herdr-usage/src/providers/fireworks.rs b/crates/herdr-usage/src/providers/fireworks.rs index fc735854a..d1f2a455a 100644 --- a/crates/herdr-usage/src/providers/fireworks.rs +++ b/crates/herdr-usage/src/providers/fireworks.rs @@ -171,22 +171,25 @@ pub(crate) fn parse(body: &str, slug: &str) -> Result { } } let account = Section::Facts { - title: "Account".into(), - facts: vec![("Slug".into(), Text::from(slug.to_owned()))], + title: Text::message("usage-fireworks-account"), + facts: vec![(Text::message("usage-fireworks-slug"), Text::service(slug))], }; let report = Report::new(Provider(&Fireworks), Account::default(), Vec::new()); Ok(match currency { Some(code) => report .with_balances([Balance::new( - "Spend, last 30 days", + Text::message("usage-fireworks-spend"), total, Unit::Currency(code), )]) .with_sections([account]), None => report.with_sections([ Section::Facts { - title: "Spend, last 30 days".into(), - facts: vec![("Rated spend".into(), "None".into())], + title: Text::message("usage-fireworks-spend"), + facts: vec![( + Text::message("usage-fireworks-rated-spend"), + Text::message("usage-fireworks-none"), + )], }, account, ]), @@ -270,8 +273,8 @@ mod tests { assert_eq!( report.sections, vec![Section::Facts { - title: "Account".into(), - facts: vec![("Slug".into(), "acme".into())], + title: Text::message("usage-fireworks-account"), + facts: vec![(Text::message("usage-fireworks-slug"), Text::service("acme"))], }] ); } diff --git a/crates/herdr-usage/src/providers/gemini.rs b/crates/herdr-usage/src/providers/gemini.rs index 25aa7239c..127661070 100644 --- a/crates/herdr-usage/src/providers/gemini.rs +++ b/crates/herdr-usage/src/providers/gemini.rs @@ -14,7 +14,7 @@ //! a non-Gmail address; and the consumer-tier migration guidance, which here //! is the usual rejected sign-in. -use crate::text::{Text, pair}; +use crate::text::Text; use crate::{ Error, Result, { @@ -212,9 +212,9 @@ struct CodeAssist { impl CodeAssist { /// A named paid tier wins; a free tier on a non-Gmail account is /// Workspace, as CodexBar reads the `hd` claim. - fn plan(&self, email: Option<&str>) -> Option { + fn plan(&self, email: Option<&str>) -> Option { if let Some(paid) = &self.paid { - return Some(paid.clone()); + return Some(Text::service(paid)); } let workspace = email @@ -224,10 +224,10 @@ impl CodeAssist { domain != "gmail.com" && domain != "googlemail.com" }); match self.tier.as_deref()? { - "standard-tier" => Some("Paid".into()), - "free-tier" if workspace => Some("Workspace".into()), - "free-tier" => Some("Free".into()), - "legacy-tier" => Some("Legacy".into()), + "standard-tier" => Some(Text::message("usage-gemini-paid")), + "free-tier" if workspace => Some(Text::message("usage-gemini-workspace")), + "free-tier" => Some(Text::message("usage-gemini-free")), + "legacy-tier" => Some(Text::message("usage-gemini-legacy")), _ => None, } } @@ -273,8 +273,9 @@ fn gemini_project(body: &str) -> Option { } /// Each model keeps its lowest remaining share; Pro, Flash, and Flash Lite -/// models become the daily windows, as CodexBar groups them. -pub(crate) fn parse(body: &str, email: Option, plan: Option) -> Result { +/// models become the daily windows, as CodexBar groups them; the families +/// are Gemini's model names. +pub(crate) fn parse(body: &str, email: Option, plan: Option) -> Result { let answer: Quota = json(body)?; let mut models: Vec<(String, f64, Option)> = Vec::new(); for bucket in answer.buckets.unwrap_or_default() { @@ -312,7 +313,7 @@ pub(crate) fn parse(body: &str, email: Option, plan: Option) -> .filter(|(id, ..)| family(id) == Some(name)) .min_by(|a, b| a.1.total_cmp(&b.1))?; Some(Window::new( - Kind::Named(name.into()), + Kind::Named(Text::service(name)), 100. - fraction * 100., *reset, Some(DAY), @@ -321,19 +322,24 @@ pub(crate) fn parse(body: &str, email: Option, plan: Option) -> .collect(); let facts = models .iter() - .map(|(id, fraction, _)| (id.clone(), format!("{}% left", (fraction * 100.).round()))) - .map(pair) + .map(|(id, fraction, _)| { + ( + Text::service(id), + Text::message("usage-gemini-left") + .arg("percent", format!("{}", (fraction * 100.).round())), + ) + }) .collect(); Ok(Report::new( Provider(&Gemini), Account { - email: email.map(Text::from), - plan: plan.map(Text::from), + email: email.map(Text::service), + plan, }, windows, ) .with_sections([Section::Facts { - title: "Models".into(), + title: Text::message("usage-gemini-models"), facts, }])) } @@ -402,7 +408,12 @@ mod tests { {"modelId": "gemini-2.5-flash", "remainingFraction": 0.4, "resetTime": "2025-01-01T00:00:00Z", "tokenType": "OUTPUT"}, {"modelId": "gemini-2.5-flash-lite", "remainingFraction": 0.8, "resetTime": "2025-01-01T00:00:00Z"} ]}"#; - let report = parse(body, Some("me@gmail.com".into()), Some("Free".into())).unwrap(); + let report = parse( + body, + Some("me@gmail.com".into()), + Some(Text::message("usage-gemini-free")), + ) + .unwrap(); let used: Vec<_> = report .windows .iter() @@ -434,6 +445,13 @@ mod tests { panic!("facts"); }; assert_eq!(facts.len(), 3); + assert_eq!( + facts[0], + ( + Text::service("gemini-2.5-flash"), + Text::message("usage-gemini-left").arg("percent", "40") + ) + ); assert!(parse(r#"{"buckets": []}"#, None, None).is_err()); } @@ -444,17 +462,20 @@ mod tests { "cloudaicompanionProject": "cloudaicompanion-123"}"#, ); assert_eq!(assist.project.as_deref(), Some("cloudaicompanion-123")); - assert_eq!(assist.plan(Some("me@gmail.com")).as_deref(), Some("Free")); assert_eq!( - assist.plan(Some("me@acme.com")).as_deref(), - Some("Workspace") + assist.plan(Some("me@gmail.com")), + Some(Text::message("usage-gemini-free")) + ); + assert_eq!( + assist.plan(Some("me@acme.com")), + Some(Text::message("usage-gemini-workspace")) ); let paid = parse_code_assist( r#"{"currentTier": {"id": "standard-tier"}, "paidTier": {"name": "Plus"}, "cloudaicompanionProject": {"id": "proj-1"}}"#, ); assert_eq!(paid.project.as_deref(), Some("proj-1")); - assert_eq!(paid.plan(None).as_deref(), Some("Plus")); + assert_eq!(paid.plan(None), Some(Text::service("Plus"))); assert_eq!(parse_code_assist("not json"), CodeAssist::default()); // Live shape for an account Google no longer offers a tier to. let ineligible = parse_code_assist( diff --git a/crates/herdr-usage/src/providers/gitkraken.rs b/crates/herdr-usage/src/providers/gitkraken.rs index 2dcfd7460..773172f9b 100644 --- a/crates/herdr-usage/src/providers/gitkraken.rs +++ b/crates/herdr-usage/src/providers/gitkraken.rs @@ -5,7 +5,7 @@ //! are not supported there either, and there is no OAuth refresh, so an //! expired token has to be replaced by hand. -use crate::text::pairs; +use crate::text::Text; use crate::{ Result, { @@ -74,23 +74,32 @@ pub(crate) fn parse(body: &str) -> Result { let pool = data .organization .and_then(|organization| Quota::new(organization.used, organization.limit)); - let mut facts = vec![("Personal".to_owned(), personal.describe())]; + let mut facts = vec![( + Text::message("usage-gitkraken-personal"), + personal.describe(), + )]; let mut windows = Vec::new(); if let Some(used) = personal.percent() { windows.push(Window::new(Kind::Weekly, used, Some(resets_at), Some(WEEK))); } if let Some(pool) = &pool { - facts.push(("Shared pool".into(), pool.describe())); + facts.push(( + Text::message("usage-gitkraken-shared-pool"), + pool.describe(), + )); if let Some(shared) = data .shared_used .filter(|shared| shared.is_finite() && *shared >= 0. && *shared <= pool.used) { - facts.push(("Your shared usage".into(), credits(shared))); - facts.push(("Rest of organization".into(), credits(pool.used - shared))); + facts.push((Text::message("usage-gitkraken-your-share"), credits(shared))); + facts.push(( + Text::message("usage-gitkraken-rest"), + credits(pool.used - shared), + )); } if let Some(used) = pool.percent() { windows.push(Window::new( - Kind::Named("Shared pool".into()), + Kind::Named(Text::message("usage-gitkraken-shared-pool")), used, Some(resets_at), Some(WEEK), @@ -100,15 +109,17 @@ pub(crate) fn parse(body: &str) -> Result { Ok( Report::new(Provider(&Gitkraken), Account::default(), windows).with_sections([ Section::Facts { - title: "Weekly usage".into(), - facts: pairs(facts), + title: Text::message("usage-gitkraken-weekly-usage"), + facts, }, ]), ) } -fn credits(value: f64) -> String { - format!("{} credits", count(value)) +fn credits(value: f64) -> Text { + Text::message("usage-gitkraken-credits") + .arg("amount", count(value)) + .arg("count", value) } /// A limit of zero is no allowance and -1 unlimited; neither has a percent. @@ -129,14 +140,21 @@ impl Quota { (self.limit > 0.).then(|| self.used / self.limit * 100.) } - fn describe(&self) -> String { + fn describe(&self) -> Text { let used = count(self.used); if self.limit > 0. { - format!("{used} / {} credits used", count(self.limit)) + Text::message("usage-gitkraken-used-of") + .arg("used", used) + .arg("limit", count(self.limit)) + .arg("count", self.limit) } else if self.limit == -1. { - format!("{used} credits used · Unlimited") + Text::message("usage-gitkraken-used-unlimited") + .arg("used", used) + .arg("count", self.used) } else { - format!("{used} credits used · No allowance") + Text::message("usage-gitkraken-used-no-allowance") + .arg("used", used) + .arg("count", self.used) } } } @@ -178,7 +196,10 @@ mod tests { assert!((report.windows[0].used - 30.).abs() < 0.01); assert_eq!(report.windows[0].length, Some(WEEK)); assert!(report.windows[0].resets_at.is_some()); - assert_eq!(report.windows[1].kind, Kind::Named("Shared pool".into())); + assert_eq!( + report.windows[1].kind, + Kind::Named(Text::message("usage-gitkraken-shared-pool")) + ); assert!((report.windows[1].used - 30.).abs() < 0.01); let Section::Facts { facts, .. } = &report.sections[0] else { panic!("expected facts"); @@ -187,14 +208,25 @@ mod tests { assert_eq!( labels, [ - "Personal", - "Shared pool", - "Your shared usage", - "Rest of organization" + "usage-gitkraken-personal", + "usage-gitkraken-shared-pool", + "usage-gitkraken-your-share", + "usage-gitkraken-rest" ] ); - assert_eq!(facts[0].1, "120 / 400 credits used"); - assert_eq!(facts[3].1, "600 credits"); + assert_eq!( + facts[0].1, + Text::message("usage-gitkraken-used-of") + .arg("used", "120") + .arg("limit", "400") + .arg("count", 400) + ); + assert_eq!( + facts[3].1, + Text::message("usage-gitkraken-credits") + .arg("amount", "600") + .arg("count", 600) + ); } #[test] @@ -205,7 +237,12 @@ mod tests { let Section::Facts { facts, .. } = &report.sections[0] else { panic!("expected facts"); }; - assert_eq!(facts[0].1, "55.50 credits used · Unlimited"); + assert_eq!( + facts[0].1, + Text::message("usage-gitkraken-used-unlimited") + .arg("used", "55.50") + .arg("count", 55.5) + ); } #[test] diff --git a/crates/herdr-usage/src/providers/grok.rs b/crates/herdr-usage/src/providers/grok.rs index 3e0901cb5..f1faeb70f 100644 --- a/crates/herdr-usage/src/providers/grok.rs +++ b/crates/herdr-usage/src/providers/grok.rs @@ -11,7 +11,7 @@ //! paths, so only the Grok CLI's published client id and the legacy //! sign-in scope are recognized. -use crate::text::{Text, pairs}; +use crate::text::Text; use crate::{ Error, Result, { @@ -64,11 +64,11 @@ impl Service for Grok { continue; } let account = Account { - email: probe.text(&auth, &[scope, "email"]).map(Text::from), + email: probe.text(&auth, &[scope, "email"]).map(Text::service), plan: (probe .text(&auth, &[scope, "auth_mode"]) .and_then(|mode| login_plan(&mode))) - .map(Text::from), + .map(Text::service), }; return Some(fetch(probe, &token, account)); } @@ -139,7 +139,7 @@ pub(crate) fn parse( .or(credits.subscription_tier.as_deref()) .and_then(plan_name) { - account.plan = Some(Text::from(plan)); + account.plan = Some(Text::service(plan)); } let time = |text: &Option| text.clone().and_then(|text| Timestamp::Text(text).time()); let period_end = config @@ -174,7 +174,7 @@ pub(crate) fn parse( }); let balance = config.prepaid_balance.map(|cents| { Balance::new( - "Prepaid balance", + Text::message("usage-grok-prepaid-balance"), cents as f64 / 100., Unit::Currency("USD".into()), ) @@ -187,17 +187,20 @@ pub(crate) fn parse( (None, None) => return Err(invalid()), }; let unknown = windows.is_empty().then(|| { - let mut facts: Vec<(String, String)> = vec![("Usage".into(), "Not reported".into())]; + let mut facts = vec![( + Text::message("usage-report-usage"), + Text::message("usage-grok-not-reported"), + )]; if let Some(end) = end { let end = chrono::DateTime::::from(end); facts.push(( - "Period ends".into(), - end.format("%b %-d, %Y %H:%M UTC").to_string(), + Text::message("usage-grok-period-ends"), + Text::service(end.format("%b %-d, %Y %H:%M UTC").to_string()), )); } Section::Facts { - title: "Included credits".into(), - facts: pairs(facts), + title: Text::message("usage-grok-included-credits"), + facts, } }); Ok(Report::new(Provider(&Grok), account, windows) @@ -217,7 +220,7 @@ fn kind(length: Option) -> Kind { { Kind::Monthly } - _ => Kind::Named("Credits".into()), + _ => Kind::Named(Text::message("usage-grok-credits")), } } diff --git a/crates/herdr-usage/src/providers/grok/tests.rs b/crates/herdr-usage/src/providers/grok/tests.rs index 2427fbd14..ef7aaf1c7 100644 --- a/crates/herdr-usage/src/providers/grok/tests.rs +++ b/crates/herdr-usage/src/providers/grok/tests.rs @@ -23,8 +23,8 @@ fn parses_weekly_credits() { } }"#; let account = Account { - email: Some("user@example.com".into()), - plan: login_plan("oidc").map(Text::from), + email: Some(Text::service("user@example.com")), + plan: login_plan("oidc").map(Text::service), }; let report = parse( body, @@ -77,7 +77,10 @@ fn future_start_leaves_length_unknown() { "currentPeriod":{"start":"2026-08-14T00:00:00Z","end":"2026-08-21T00:00:00Z"}}}"#; let report = parse(body, None, Account::default(), at("2026-08-12T00:00:00Z")).unwrap(); assert_eq!(report.windows[0].length, None); - assert_eq!(report.windows[0].kind, Kind::Named("Credits".into())); + assert_eq!( + report.windows[0].kind, + Kind::Named(Text::message("usage-grok-credits")) + ); } #[test] diff --git a/crates/herdr-usage/src/providers/grok/tests/prepaid.rs b/crates/herdr-usage/src/providers/grok/tests/prepaid.rs index e25925cb0..3fc0da01e 100644 --- a/crates/herdr-usage/src/providers/grok/tests/prepaid.rs +++ b/crates/herdr-usage/src/providers/grok/tests/prepaid.rs @@ -25,7 +25,7 @@ fn unified_billing_shows_wallet_and_period_without_inventing_usage() { assert_eq!( report.balances, vec![Balance::new( - "Prepaid balance", + Text::message("usage-grok-prepaid-balance"), 14.46, Unit::Currency("USD".into()) )] @@ -33,10 +33,16 @@ fn unified_billing_shows_wallet_and_period_without_inventing_usage() { assert_eq!( report.sections, vec![Section::Facts { - title: "Included credits".into(), + title: Text::message("usage-grok-included-credits"), facts: vec![ - ("Usage".into(), "Not reported".into()), - ("Period ends".into(), "Aug 13, 2026 00:00 UTC".into()), + ( + Text::message("usage-report-usage"), + Text::message("usage-grok-not-reported") + ), + ( + Text::message("usage-grok-period-ends"), + Text::service("Aug 13, 2026 00:00 UTC") + ), ], }] ); @@ -56,7 +62,7 @@ fn wallet_alone_is_a_valid_report_including_zero_and_string_cents() { assert_eq!( report.balances, vec![Balance::new( - "Prepaid balance", + Text::message("usage-grok-prepaid-balance"), amount, Unit::Currency("USD".into()) )] @@ -64,8 +70,11 @@ fn wallet_alone_is_a_valid_report_including_zero_and_string_cents() { assert_eq!( report.sections, vec![Section::Facts { - title: "Included credits".into(), - facts: vec![("Usage".into(), "Not reported".into())], + title: Text::message("usage-grok-included-credits"), + facts: vec![( + Text::message("usage-report-usage"), + Text::message("usage-grok-not-reported") + )], }] ); } diff --git a/crates/herdr-usage/src/providers/groq.rs b/crates/herdr-usage/src/providers/groq.rs index 27cc76a82..7c1bfbdb1 100644 --- a/crates/herdr-usage/src/providers/groq.rs +++ b/crates/herdr-usage/src/providers/groq.rs @@ -9,7 +9,7 @@ //! encode a secret nor read inside a JWT, and the bare cookie value cannot be //! cut out of a Cookie header. -use crate::text::pairs; +use crate::text::Text; use crate::{ Error, Result, { @@ -113,30 +113,30 @@ fn report([requests, tokens_in, tokens_out, cache]: [f64; 4]) -> Report { } else { 2 }; - format!("{value:.digits$}") + Text::service(format!("{value:.digits$}")) }; let mut facts = vec![ - ("Requests / min".to_owned(), per_minute(requests)), + (Text::message("usage-groq-requests"), per_minute(requests)), ( - "Tokens / min".to_owned(), + Text::message("usage-groq-tokens"), per_minute(tokens_in + tokens_out), ), ]; if cache > 0. { - facts.push(("Cache hits / min".to_owned(), per_minute(cache))); + facts.push((Text::message("usage-groq-cache-hits"), per_minute(cache))); } Report::new(Provider(&Groq), metrics_account(), Vec::new()).with_sections([Section::Facts { - title: "Last 5 minutes".into(), - facts: pairs(facts), + title: Text::message("usage-groq-last-5-minutes"), + facts, }]) } fn enterprise_only() -> Report { Report::new(Provider(&Groq), metrics_account(), Vec::new()).with_sections([Section::Facts { - title: "Metrics".into(), + title: Text::message("usage-groq-metrics"), facts: vec![( - "Unavailable".into(), - "Prometheus metrics need a GroqCloud Enterprise key".into(), + Text::message("usage-groq-unavailable"), + Text::message("usage-groq-enterprise-only"), )], }]) } @@ -144,7 +144,7 @@ fn enterprise_only() -> Report { fn metrics_account() -> Account { Account { email: None, - plan: Some("Prometheus metrics".into()), + plan: Some(Text::message("usage-groq-prometheus")), } } @@ -208,8 +208,14 @@ mod tests { let Section::Facts { facts, .. } = &report.sections[0] else { panic!("expected facts"); }; - assert_eq!(facts[0], ("Requests / min".into(), "120".into())); - assert_eq!(facts[1], ("Tokens / min".into(), "9000".into())); + assert_eq!( + facts[0], + (Text::message("usage-groq-requests"), Text::service("120")) + ); + assert_eq!( + facts[1], + (Text::message("usage-groq-tokens"), Text::service("9000")) + ); assert_eq!(facts.len(), 2); } diff --git a/crates/herdr-usage/src/providers/helmcode.rs b/crates/herdr-usage/src/providers/helmcode.rs index 225d8c9ea..3aca87e96 100644 --- a/crates/herdr-usage/src/providers/helmcode.rs +++ b/crates/herdr-usage/src/providers/helmcode.rs @@ -7,7 +7,7 @@ //! CodexBar's per-tenant keychain cache of imported sessions is not ported: //! cookies are read again on each refresh. -use crate::text::{Text, pairs}; +use crate::text::Text; use crate::{ Error, Result, { @@ -159,33 +159,36 @@ fn parse(quota: &str, premium: bool, credits: Option<&str>, tenant: &str) -> Res .or(Some(MONTH)), }; windows.push(Window::new( - Kind::Named(Text::from(model.model.clone())), + Kind::Named(Text::service(&model.model)), share(model) * 100., resets_at, length, )); - let mut tokens = format!( - "{} / {} tokens", - group(model.tokens_used as i64), - group(model.cap as i64) - ); - if let Some(credit) = model.credit_tokens.filter(|credit| *credit > 0) { - tokens.push_str(&format!(" · {} credit-funded", group(credit as i64))); - } - facts.push((model.model.clone(), tokens)); + let tokens = match model.credit_tokens.filter(|credit| *credit > 0) { + Some(credit) => Text::message("usage-helmcode-tokens-credit") + .arg("used", group(model.tokens_used as i64)) + .arg("cap", group(model.cap as i64)) + .arg("count", model.cap) + .arg("credit", group(credit as i64)), + None => Text::message("usage-helmcode-tokens") + .arg("used", group(model.tokens_used as i64)) + .arg("cap", group(model.cap as i64)) + .arg("count", model.cap), + }; + facts.push((Text::service(&model.model), tokens)); } Ok(Report::new( Provider(&Helmcode), Account { email: None, - plan: Some(Text::from(tenant.to_owned())), + plan: Some(Text::service(tenant)), }, windows, ) .with_balances(credits.and_then(prepaid)) .with_sections((!facts.is_empty()).then(|| Section::Facts { - title: "Model quotas".into(), - facts: pairs(facts), + title: Text::message("usage-helmcode-model-quotas"), + facts, }))) } @@ -201,7 +204,7 @@ fn prepaid(body: &str) -> Option { return None; } Some(Balance::new( - "Prepaid balance", + Text::message("usage-helmcode-prepaid-balance"), (micros as f64 / 1e6).max(0.), Unit::Currency(currency), )) @@ -269,7 +272,7 @@ mod tests { let fast = report .windows .iter() - .find(|window| window.kind == Kind::Named("helm-fast".into())) + .find(|window| window.kind == Kind::Named(Text::service("helm-fast"))) .unwrap(); assert_eq!(fast.used, 75.); // No period end: the first of the next month. @@ -285,8 +288,12 @@ mod tests { assert_eq!( facts[1], ( - "helm-coder".into(), - "250,000 / 1,000,000 tokens · 5,000 credit-funded".into() + Text::service("helm-coder"), + Text::message("usage-helmcode-tokens-credit") + .arg("used", "250,000") + .arg("cap", "1,000,000") + .arg("count", 1_000_000) + .arg("credit", "5,000") ) ); } @@ -303,7 +310,7 @@ mod tests { let burst = report .windows .iter() - .find(|window| window.kind == Kind::Named("helm-burst".into())) + .find(|window| window.kind == Kind::Named(Text::service("helm-burst"))) .unwrap(); assert_eq!(burst.used, 10.); assert_eq!(burst.length, Some(Duration::from_secs(5 * 3600))); diff --git a/crates/herdr-usage/src/providers/huggingface.rs b/crates/herdr-usage/src/providers/huggingface.rs index c57abe5e0..409f50104 100644 --- a/crates/herdr-usage/src/providers/huggingface.rs +++ b/crates/herdr-usage/src/providers/huggingface.rs @@ -11,7 +11,7 @@ //! twelve-hour identity cache is not ported: `whoami-v2` is asked each //! refresh, and its failure only leaves the account out. -use crate::text::{Text, pairs}; +use crate::text::Text; use crate::{ Result, { @@ -153,24 +153,34 @@ pub(crate) fn parse( }; // Hugging Face's own billing page deducts the included amount. let billable = (gross - included).max(0.); - let usd = |label: &str, value: f64| Balance::new(label, value, Unit::Currency("USD".into())); - let mut facts = vec![("Billable usage".to_owned(), usd("", billable).amount_text())]; + let usd = |label: Text, value: f64| Balance::new(label, value, Unit::Currency("USD".into())); + let amount = |value: f64| Text::service(usd(Text::service(""), value).amount_text()); + let mut facts = vec![( + Text::message("usage-huggingface-billable"), + amount(billable), + )]; if included > 0. { - facts.push(("Gross inference usage".into(), usd("", gross).amount_text())); + facts.push((Text::message("usage-huggingface-gross"), amount(gross))); facts.push(( - "Included inference amount".into(), - usd("", included).amount_text(), + Text::message("usage-huggingface-included"), + amount(included), )); } if limit > 0. { - facts.push(("Spending limit".into(), usd("", limit).amount_text())); + facts.push(( + Text::message("usage-huggingface-spending-limit"), + amount(limit), + )); } if let Some(requests) = inference.num_requests { - facts.push(("Requests".into(), requests.to_string())); + facts.push(( + Text::message("usage-huggingface-requests"), + Text::service(requests.to_string()), + )); } let mut sections = vec![Section::Facts { - title: "Inference Providers".into(), - facts: pairs(facts), + title: Text::message("usage-huggingface-inference-providers"), + facts, }]; let mut windows = Vec::new(); if let Some(quota) = gpu.and_then(|body| json::(body).ok()) { @@ -180,27 +190,30 @@ pub(crate) fn parse( { let consumed = (total - left).max(0.); windows.push(Window::new( - Kind::Named("ZeroGPU".into()), + Kind::Named(Text::service("ZeroGPU")), consumed / total * 100., quota.resets_at.as_ref().and_then(Timestamp::time), None, )); sections.push(Section::Facts { - title: "ZeroGPU".into(), + title: Text::message("usage-huggingface-zerogpu"), facts: vec![ - ("GPU time used".into(), Text::from(minutes(consumed))), - ("GPU time remaining".into(), Text::from(minutes(left))), + ( + Text::message("usage-huggingface-gpu-used"), + minutes(consumed), + ), + (Text::message("usage-huggingface-gpu-left"), minutes(left)), ], }); } } - let mut spend = usd("Inference this month", billable); + let mut spend = usd(Text::message("usage-huggingface-month-spend"), billable); if limit > 0. { spend = spend.out_of(limit); } let mut balances = vec![spend]; if let Some(wallet) = wallet { - balances.push(usd("Prepaid credits", wallet)); + balances.push(usd(Text::message("usage-huggingface-prepaid"), wallet)); } let account = profile.map_or_else(Account::default, |profile| { if let Some(name) = profile @@ -210,8 +223,8 @@ pub(crate) fn parse( .filter(|name| !name.is_empty()) { sections.push(Section::Facts { - title: "Account".into(), - facts: vec![("User".into(), Text::from(name.to_owned()))], + title: Text::message("usage-huggingface-account"), + facts: vec![(Text::message("usage-huggingface-user"), Text::service(name))], }); } Account { @@ -219,11 +232,15 @@ pub(crate) fn parse( .email .map(|email| email.trim().to_owned()) .filter(|email| !email.is_empty())) - .map(Text::from), - plan: (profile - .is_pro - .map(|pro| if pro { "PRO" } else { "Free" }.to_owned())) - .map(Text::from), + .map(Text::service), + // PRO is Hugging Face's name for the plan. + plan: profile.is_pro.map(|pro| { + if pro { + Text::service("PRO") + } else { + Text::message("usage-huggingface-free") + } + }), } }); Ok(Report::new(Provider(&Huggingface), account, windows) @@ -232,12 +249,13 @@ pub(crate) fn parse( } /// `10 min`, with a decimal under ten minutes. -fn minutes(seconds: f64) -> String { - if seconds >= 600. { - format!("{:.0} min", seconds / 60.) +fn minutes(seconds: f64) -> Text { + let minutes = if seconds >= 600. { + format!("{:.0}", seconds / 60.) } else { - format!("{:.1} min", seconds / 60.) - } + format!("{:.1}", seconds / 60.) + }; + Text::message("usage-huggingface-minutes").arg("minutes", minutes) } /// The balance in the billing page's server-rendered `data-props`: the @@ -417,9 +435,15 @@ mod tests { let report = parse(BILLING, Some(GPU), Some(profile), Some(12.345)).unwrap(); assert!((report.balances[0].amount - 0.45).abs() < 1e-9); assert_eq!(report.balances[0].total, None); - assert_eq!(report.balances[1].label, "Prepaid credits"); + assert_eq!( + report.balances[1].label, + Text::message("usage-huggingface-prepaid") + ); assert_eq!(report.windows.len(), 1); - assert_eq!(report.windows[0].kind, Kind::Named("ZeroGPU".into())); + assert_eq!( + report.windows[0].kind, + Kind::Named(Text::service("ZeroGPU")) + ); assert!((report.windows[0].used - 40.).abs() < 0.01); assert!(report.windows[0].resets_at.is_some()); assert_eq!(report.account.plan.as_ref().map(Text::as_str), Some("PRO")); @@ -434,17 +458,23 @@ mod tests { assert_eq!( labels, [ - "Billable usage", - "Gross inference usage", - "Included inference amount", - "Requests" + "usage-huggingface-billable", + "usage-huggingface-gross", + "usage-huggingface-included", + "usage-huggingface-requests" ] ); let Section::Facts { facts, .. } = &report.sections[1] else { panic!("expected facts"); }; - assert_eq!(facts[0].1, "10 min"); - assert_eq!(facts[1].1, "15 min"); + assert_eq!( + facts[0].1, + Text::message("usage-huggingface-minutes").arg("minutes", "10") + ); + assert_eq!( + facts[1].1, + Text::message("usage-huggingface-minutes").arg("minutes", "15") + ); } #[test] diff --git a/crates/herdr-usage/src/providers/hyper.rs b/crates/herdr-usage/src/providers/hyper.rs index b91cbbc2d..ad1e43aa2 100644 --- a/crates/herdr-usage/src/providers/hyper.rs +++ b/crates/herdr-usage/src/providers/hyper.rs @@ -5,6 +5,7 @@ //! mode does. The service reports only a balance: no plan, limit, or reset. use crate::model::Count; +use crate::text::Text; use crate::{ Error, Result, { @@ -91,17 +92,14 @@ pub(crate) fn parse(body: &str, source: Source) -> Result { .ok_or_else(invalid)?; let account = Account { email: None, - plan: Some( - match source { - Source::Session => "Browser session", - Source::Key => "API key", - } - .into(), - ), + plan: Some(match source { + Source::Session => Text::message("usage-hyper-browser-session"), + Source::Key => Text::message("usage-hyper-api-key"), + }), }; Ok( Report::new(Provider(&Hyper), account, Vec::new()).with_balances([Balance::new( - "Hypercredits", + Text::message("usage-hyper-hypercredits"), balance, Unit::Count(Count::Named("HC".into())), )]), @@ -117,7 +115,6 @@ struct Credits { #[allow(clippy::unwrap_used, clippy::expect_used)] mod tests { use super::*; - use crate::text::Text; #[test] fn balance_is_hypercredits() { @@ -131,8 +128,8 @@ mod tests { ); assert_eq!(report.balances[0].amount_text(), "42.50 HC"); assert_eq!( - report.account.plan.as_ref().map(Text::as_str), - Some("API key") + report.account.plan, + Some(Text::message("usage-hyper-api-key")) ); } diff --git a/crates/herdr-usage/src/providers/ibmbob.rs b/crates/herdr-usage/src/providers/ibmbob.rs index ae6bc2bc0..695ec595f 100644 --- a/crates/herdr-usage/src/providers/ibmbob.rs +++ b/crates/herdr-usage/src/providers/ibmbob.rs @@ -8,7 +8,7 @@ //! sign-in that CodexBar reads. use crate::model::Count; -use crate::text::{Text, pair}; +use crate::text::Text; use crate::{ Result, { @@ -209,7 +209,7 @@ pub(crate) fn report(teams: &[Team]) -> Result { .into_iter() .collect(); let mut balance = Balance::new( - "Bobcoins used", + Text::message("usage-ibmbob-bobcoins-used"), used, Unit::Count(Count::Named("Bobcoins".into())), ); @@ -225,18 +225,23 @@ pub(crate) fn report(teams: &[Team]) -> Result { let facts = teams .iter() .map(|team| { + // The instance's and team's names, as Bob names them. let label = if team.team == team.instance { - team.team.clone() + Text::service(&team.team) } else { - format!("{} · {}", team.instance, team.team) + Text::service(format!("{} · {}", team.instance, team.team)) }; let value = match team.limit { - Some(limit) => format!("{} / {} Bobcoins", plain(team.used), plain(limit)), - None => format!("{} Bobcoins used", plain(team.used)), + Some(limit) => Text::message("usage-ibmbob-used-of") + .arg("used", plain(team.used)) + .arg("limit", plain(limit)) + .arg("count", limit), + None => Text::message("usage-ibmbob-used") + .arg("used", plain(team.used)) + .arg("count", team.used), }; (label, value) }) - .map(pair) .collect(); Ok(Report::new( Provider(&Ibmbob), @@ -244,13 +249,13 @@ pub(crate) fn report(teams: &[Team]) -> Result { email: None, plan: (!plans.is_empty()) .then(|| plans.join(", ")) - .map(Text::from), + .map(Text::service), }, windows, ) .with_balances([balance]) .with_sections([Section::Facts { - title: "Bobcoin usage".into(), + title: Text::message("usage-ibmbob-bobcoin-usage"), facts, }])) } @@ -355,7 +360,13 @@ mod tests { }; assert_eq!( facts[0], - ("Personal · Solo".into(), "10 / 40 Bobcoins".into()) + ( + Text::service("Personal · Solo"), + Text::message("usage-ibmbob-used-of") + .arg("used", "10") + .arg("limit", "40") + .arg("count", 40) + ) ); } diff --git a/crates/herdr-usage/src/providers/jetbrains.rs b/crates/herdr-usage/src/providers/jetbrains.rs index 859552823..0f26cbac4 100644 --- a/crates/herdr-usage/src/providers/jetbrains.rs +++ b/crates/herdr-usage/src/providers/jetbrains.rs @@ -8,7 +8,7 @@ //! taking the most recently written quota file, or set with `ide_path`. use crate::model::Count; -use crate::text::pairs; +use crate::text::Text; use crate::{ Result, { @@ -149,10 +149,14 @@ pub(crate) fn parse(xml: &str, ide: Option) -> Result { let mut facts = Vec::new(); if let Some(ide) = &ide { - facts.push(("IDE".to_owned(), format!("{} {}", ide.name, ide.version))); + facts.push(( + Text::message("usage-jetbrains-ide"), + Text::service(format!("{} {}", ide.name, ide.version)), + )); } + // The IDE's own word for the quota, such as Available. if let Some(kind) = quota.kind.filter(|kind| !kind.trim().is_empty()) { - facts.push(("Quota".to_owned(), kind)); + facts.push((Text::message("usage-jetbrains-quota"), Text::service(kind))); } let amount = refill.as_ref().and_then(|refill| { refill @@ -160,24 +164,30 @@ pub(crate) fn parse(xml: &str, ide: Option) -> Result { .or(refill.tariff.as_ref().and_then(|tariff| tariff.amount)) }); if let Some(amount) = amount { - let every = period.map_or_else(String::new, |period| { - format!(" every {} days", (period.as_secs() / 86_400).max(1)) - }); - facts.push(( - "Refill".to_owned(), - format!("{} credits{every}", group(amount.round() as i64)), - )); + let credits = group(amount.round() as i64); + let refill = match period { + Some(period) => Text::message("usage-jetbrains-refill-every") + .arg("amount", credits) + .arg("count", amount.round()) + .arg("days", (period.as_secs() / 86_400).max(1)), + None => Text::message("usage-jetbrains-refill-amount") + .arg("amount", credits) + .arg("count", amount.round()), + }; + facts.push((Text::message("usage-jetbrains-refill"), refill)); } Ok( Report::new(Provider(&Jetbrains), Account::default(), vec![window]) - .with_balances([ - Balance::new("Credits left", available, Unit::Count(Count::Credits)) - .out_of(maximum), - ]) + .with_balances([Balance::new( + Text::message("usage-jetbrains-credits-left"), + available, + Unit::Count(Count::Credits), + ) + .out_of(maximum)]) .with_sections((!facts.is_empty()).then(|| Section::Facts { - title: "AI Assistant".into(), - facts: pairs(facts), + title: Text::message("usage-jetbrains-ai-assistant"), + facts, })), ) } @@ -371,18 +381,34 @@ mod tests { assert_eq!( report.balances, vec![ - Balance::new("Credits left", 992_521.7, Unit::Count(Count::Credits)) - .out_of(1_000_000.) + Balance::new( + Text::message("usage-jetbrains-credits-left"), + 992_521.7, + Unit::Count(Count::Credits) + ) + .out_of(1_000_000.) ] ); assert_eq!( report.sections, vec![Section::Facts { - title: "AI Assistant".into(), + title: Text::message("usage-jetbrains-ai-assistant"), facts: vec![ - ("IDE".into(), "IntelliJ IDEA 2025.3".into()), - ("Quota".into(), "Available".into()), - ("Refill".into(), "1,000,000 credits every 30 days".into()), + ( + Text::message("usage-jetbrains-ide"), + Text::service("IntelliJ IDEA 2025.3") + ), + ( + Text::message("usage-jetbrains-quota"), + Text::service("Available") + ), + ( + Text::message("usage-jetbrains-refill"), + Text::message("usage-jetbrains-refill-every") + .arg("amount", "1,000,000") + .arg("count", 1_000_000) + .arg("days", 30) + ), ], }] ); diff --git a/crates/herdr-usage/src/providers/kilo.rs b/crates/herdr-usage/src/providers/kilo.rs index 6135d7fee..aadb09b69 100644 --- a/crates/herdr-usage/src/providers/kilo.rs +++ b/crates/herdr-usage/src/providers/kilo.rs @@ -10,7 +10,7 @@ //! such answers. use crate::model::Count; -use crate::text::{Text, pairs}; +use crate::text::Text; use crate::{ Error, Result, { @@ -101,19 +101,26 @@ pub(crate) fn parse(body: &str) -> Result { Some(MONTH), )); let base = (total - pass.bonus).max(0.); - let mut text = format!("{} of {}", usd(used), usd(base)); - if pass.bonus > 0. { - text.push_str(&format!(" (+ {} bonus)", usd(pass.bonus))); - } - facts.push(("Kilo Pass".to_owned(), text)); + let text = if pass.bonus > 0. { + Text::message("usage-kilo-pass-bonus") + .arg("used", usd(used)) + .arg("base", usd(base)) + .arg("bonus", usd(pass.bonus)) + } else { + Text::message("usage-kilo-pass-used") + .arg("used", usd(used)) + .arg("base", usd(base)) + }; + facts.push((Text::message("usage-kilo-pass"), text)); } if let Some((enabled, method)) = top_up { facts.push(( - "Auto top-up".to_owned(), + Text::message("usage-kilo-top-up"), + // The payment method, such as visa, or the amount, as Kilo sends it. match (enabled, method) { - (false, _) => "Off".to_owned(), - (true, Some(method)) => method, - (true, None) => "On".to_owned(), + (false, _) => Text::message("usage-kilo-off"), + (true, Some(method)) => Text::service(method), + (true, None) => Text::message("usage-kilo-on"), }, )); } @@ -121,7 +128,14 @@ pub(crate) fn parse(body: &str) -> Result { let balances = credits.and_then(|credits| { let total = credits.total?; let remaining = credits.remaining.unwrap_or((total - credits.used).max(0.)); - Some(Balance::new("Credits left", remaining, credits.unit).out_of(total)) + Some( + Balance::new( + Text::message("usage-kilo-credits-left"), + remaining, + credits.unit, + ) + .out_of(total), + ) }); if windows.is_empty() && balances.is_none() && plan.is_none() { @@ -131,14 +145,14 @@ pub(crate) fn parse(body: &str) -> Result { Provider(&Kilo), Account { email: None, - plan: plan.map(Text::from), + plan: plan.map(Text::service), }, windows, ) .with_balances(balances) .with_sections((!facts.is_empty()).then(|| Section::Facts { - title: "Plan".into(), - facts: pairs(facts), + title: Text::message("usage-kilo-plan"), + facts, }))) } @@ -496,7 +510,14 @@ mod tests { ); assert_eq!( report.balances, - vec![Balance::new("Credits left", 19., Unit::Currency("USD".into())).out_of(19.)] + vec![ + Balance::new( + Text::message("usage-kilo-credits-left"), + 19., + Unit::Currency("USD".into()) + ) + .out_of(19.) + ] ); assert_eq!(report.windows.len(), 1); let pass = &report.windows[0]; @@ -506,10 +527,19 @@ mod tests { assert_eq!( report.sections, vec![Section::Facts { - title: "Plan".into(), + title: Text::message("usage-kilo-plan"), facts: vec![ - ("Kilo Pass".into(), "$0.00 of $19.00 (+ $9.50 bonus)".into()), - ("Auto top-up".into(), "Off".into()), + ( + Text::message("usage-kilo-pass"), + Text::message("usage-kilo-pass-bonus") + .arg("used", "$0.00") + .arg("base", "$19.00") + .arg("bonus", "$9.50") + ), + ( + Text::message("usage-kilo-top-up"), + Text::message("usage-kilo-off") + ), ], }] ); @@ -529,13 +559,23 @@ mod tests { ); assert_eq!( report.balances, - vec![Balance::new("Credits left", 75., Unit::Count(Count::Credits)).out_of(100.)] + vec![ + Balance::new( + Text::message("usage-kilo-credits-left"), + 75., + Unit::Count(Count::Credits) + ) + .out_of(100.) + ] ); assert!(report.windows.is_empty()); let Section::Facts { facts, .. } = &report.sections[0] else { panic!("facts expected"); }; - assert_eq!(facts[0], ("Auto top-up".into(), "visa".into())); + assert_eq!( + facts[0], + (Text::message("usage-kilo-top-up"), Text::service("visa")) + ); } #[test] @@ -553,12 +593,22 @@ mod tests { assert_eq!(report.windows[0].percent(), 5); assert_eq!( report.balances, - vec![Balance::new("Credits left", 0., Unit::Currency("USD".into())).out_of(0.)] + vec![ + Balance::new( + Text::message("usage-kilo-credits-left"), + 0., + Unit::Currency("USD".into()) + ) + .out_of(0.) + ] ); let Section::Facts { facts, .. } = &report.sections[0] else { panic!("facts expected"); }; - assert_eq!(facts[1], ("Auto top-up".into(), "$50".into())); + assert_eq!( + facts[1], + (Text::message("usage-kilo-top-up"), Text::service("$50")) + ); } #[test] diff --git a/crates/herdr-usage/src/providers/kimi.rs b/crates/herdr-usage/src/providers/kimi.rs index 6abb5704c..1dc48d9c5 100644 --- a/crates/herdr-usage/src/providers/kimi.rs +++ b/crates/herdr-usage/src/providers/kimi.rs @@ -21,7 +21,7 @@ //! storage (where kimi.ai keeps `access_token`), retrying the next browser //! session when one is rejected, and multiple labeled web accounts. -use crate::text::{Text, pairs}; +use crate::text::Text; use crate::{ Error, Result, { @@ -267,18 +267,22 @@ impl Usage { }) }); let requests = |(used, limit): (f64, f64)| { - format!( - "{} of {}", - group(used.round() as i64), - group(limit.round() as i64) - ) + Text::message("usage-kimi-of") + .arg("used", group(used.round() as i64)) + .arg("limit", group(limit.round() as i64)) }; - let facts: Vec<(String, String)> = [ - ("Weekly requests", self.weekly_requests.map(requests)), - ("Rate limit requests", self.session_requests.map(requests)), + let facts: Vec<(Text, Text)> = [ + ( + Text::message("usage-kimi-weekly-requests"), + self.weekly_requests.map(requests), + ), + ( + Text::message("usage-kimi-rate-limit-requests"), + self.session_requests.map(requests), + ), ] .into_iter() - .filter_map(|(label, value)| Some((label.to_owned(), value?))) + .filter_map(|(label, value)| Some((label, value?))) .collect(); let windows = [self.session, self.weekly, self.monthly] .into_iter() @@ -288,14 +292,14 @@ impl Usage { Provider(&Kimi), Account { email: None, - plan: self.plan.map(Text::from), + plan: self.plan.map(Text::service), }, windows, ) .with_sections(distinct.map(Section::Limit).into_iter().chain( (!facts.is_empty()).then(|| Section::Facts { - title: "Requests".into(), - facts: pairs(facts), + title: Text::message("usage-kimi-requests"), + facts, }), )) } @@ -431,7 +435,7 @@ pub(crate) fn parse_stats(body: &str) -> Option { } let ratio = limit.ratio.filter(|ratio| ratio.is_finite())?; Some(Window::new( - Kind::Named("Code 7-day".into()), + Kind::Named(Text::message("usage-kimi-code-weekly")), ratio * 100., limit.reset_time.as_ref().and_then(Timestamp::time), Some(WEEK), @@ -454,7 +458,7 @@ pub(crate) fn parse_plan(body: &str) -> Option { } fn total_usage() -> Kind { - Kind::Named("Total usage".into()) + Kind::Named(Text::message("usage-kimi-total-usage")) } /// A ratio pool, unless it is a zero placeholder next to populated counts @@ -708,10 +712,20 @@ mod tests { assert_eq!( report.sections, vec![Section::Facts { - title: "Requests".into(), + title: Text::message("usage-kimi-requests"), facts: vec![ - ("Weekly requests".into(), "214 of 2,048".into()), - ("Rate limit requests".into(), "139 of 200".into()), + ( + Text::message("usage-kimi-weekly-requests"), + Text::message("usage-kimi-of") + .arg("used", "214") + .arg("limit", "2,048") + ), + ( + Text::message("usage-kimi-rate-limit-requests"), + Text::message("usage-kimi-of") + .arg("used", "139") + .arg("limit", "200") + ), ], }] ); @@ -737,7 +751,10 @@ mod tests { assert_eq!(session.used, 0.); assert_eq!(session.resets_at, Some(at("2026-09-16T20:15:44Z"))); let monthly = &report.windows[1]; - assert_eq!(monthly.kind, Kind::Named("Total usage".into())); + assert_eq!( + monthly.kind, + Kind::Named(Text::message("usage-kimi-total-usage")) + ); assert!((monthly.used - 0.56).abs() < 1e-4); assert_eq!(monthly.length, Some(MONTH)); } @@ -782,12 +799,15 @@ mod tests { Some("Allegretto") ); assert_eq!(report.windows.len(), 3); - assert_eq!(report.windows[2].kind, Kind::Named("Total usage".into())); + assert_eq!( + report.windows[2].kind, + Kind::Named(Text::message("usage-kimi-total-usage")) + ); assert_eq!(report.windows[2].percent(), 42); // The Code 7-day ratio differs from the weekly counts, so it shows. assert!(matches!( &report.sections[0], - Section::Limit(window) if window.kind == Kind::Named("Code 7-day".into()) && window.percent() == 50 + Section::Limit(window) if window.kind == Kind::Named(Text::message("usage-kimi-code-weekly")) && window.percent() == 50 )); } diff --git a/docs/i18n.md b/docs/i18n.md index ce083f784..29553b583 100644 --- a/docs/i18n.md +++ b/docs/i18n.md @@ -300,8 +300,9 @@ app still shows English literals: own words, which `usage::text::words` writes out; count units are a `Count` the app writes with a plural, and the typed values (limit kinds, countdowns, pace, balances) are localized in `usage/text.rs`. Wayfinder - names all of its words (`usage-wayfinder.ftl`, with shared ones in - `usage-report.ftl`); the other providers still pass English section + and the providers from Copilot to Kimi in `providers/` name all of their + words (`usage-.ftl`, with shared ones in `usage-report.ftl`); + the other providers still pass English section titles, fact labels and values, balance labels, window names, and account lines through `Text`'s transitional `From<&str>`, which goes once the last of them names its words. From aa4ed2c42329e0a2fa868fa763e2e8000a68106a Mon Sep 17 00:00:00 2001 From: Fabien Penso Date: Sat, 31 Oct 2026 22:52:19 -0700 Subject: [PATCH 05/10] refactor(i18n): show plan usage reports from Kiro to OpenRouter through Fluent Kiro, LiteLLM, llmman, LLM Proxy, LongCat, Manus, MiMo, MiniMax, Mistral, Moonshot, Muse, Neuralwatt, Notion, Nous, Ollama, OpenAI, OpenCode, OpenCode Go, and OpenRouter now name each word their reports show in their own usage-.ftl. Plans, models, product names (Vibe, Notion AI, Ollama Cloud), and the services' own status words stay as the service's words. OneConsole is shared gateway code and shows none. OpenAI's unnamed line items and models are now None rather than English fallback keys, and show through messages. LiteLLM's spend parser takes a Reporter enum instead of matching its own label. MiMo, Neuralwatt, and Muse counts gain a singular ("1 credit" where it said "1 credits"). Named windows sort by their words; message IDs are chosen so each provider's windows keep their order, which its tests hold, and Report::new documents it. usage/tests/words_c.rs checks each provider's English reads as it wrote it. --- .../herdr-gpui/locales/en-US/usage-kiro.ftl | 13 + .../locales/en-US/usage-litellm.ftl | 26 ++ .../herdr-gpui/locales/en-US/usage-llmman.ftl | 18 + .../locales/en-US/usage-llmproxy.ftl | 20 + .../locales/en-US/usage-longcat.ftl | 11 + .../herdr-gpui/locales/en-US/usage-manus.ftl | 15 + .../herdr-gpui/locales/en-US/usage-mimo.ftl | 18 + .../locales/en-US/usage-minimax.ftl | 18 + .../locales/en-US/usage-mistral.ftl | 20 + .../locales/en-US/usage-moonshot.ftl | 10 + .../herdr-gpui/locales/en-US/usage-muse.ftl | 16 + .../locales/en-US/usage-neuralwatt.ftl | 27 ++ .../herdr-gpui/locales/en-US/usage-notion.ftl | 11 + .../herdr-gpui/locales/en-US/usage-nous.ftl | 15 + .../herdr-gpui/locales/en-US/usage-ollama.ftl | 11 + .../herdr-gpui/locales/en-US/usage-openai.ftl | 34 ++ .../locales/en-US/usage-opencode.ftl | 11 + .../locales/en-US/usage-opencodego.ftl | 9 + .../locales/en-US/usage-openrouter.ftl | 28 ++ crates/herdr-gpui/src/usage/tests.rs | 1 + crates/herdr-gpui/src/usage/tests/words_c.rs | 416 ++++++++++++++++++ crates/herdr-usage/src/model.rs | 4 + crates/herdr-usage/src/providers/kiro.rs | 64 ++- crates/herdr-usage/src/providers/litellm.rs | 178 ++++++-- crates/herdr-usage/src/providers/llmman.rs | 45 +- .../herdr-usage/src/providers/llmman/tests.rs | 78 ++-- crates/herdr-usage/src/providers/llmproxy.rs | 82 ++-- crates/herdr-usage/src/providers/longcat.rs | 34 +- crates/herdr-usage/src/providers/manus.rs | 74 +++- crates/herdr-usage/src/providers/mimo.rs | 61 ++- crates/herdr-usage/src/providers/minimax.rs | 77 +++- crates/herdr-usage/src/providers/mistral.rs | 124 ++++-- crates/herdr-usage/src/providers/moonshot.rs | 52 ++- crates/herdr-usage/src/providers/muse.rs | 52 ++- .../herdr-usage/src/providers/neuralwatt.rs | 136 ++++-- crates/herdr-usage/src/providers/notion.rs | 67 ++- crates/herdr-usage/src/providers/nous.rs | 111 +++-- crates/herdr-usage/src/providers/ollama.rs | 31 +- crates/herdr-usage/src/providers/openai.rs | 184 +++++--- crates/herdr-usage/src/providers/opencode.rs | 49 ++- .../herdr-usage/src/providers/opencodego.rs | 38 +- .../herdr-usage/src/providers/openrouter.rs | 152 +++++-- docs/i18n.md | 2 +- 43 files changed, 1988 insertions(+), 455 deletions(-) create mode 100644 crates/herdr-gpui/locales/en-US/usage-kiro.ftl create mode 100644 crates/herdr-gpui/src/usage/tests/words_c.rs diff --git a/crates/herdr-gpui/locales/en-US/usage-kiro.ftl b/crates/herdr-gpui/locales/en-US/usage-kiro.ftl new file mode 100644 index 000000000..88405cce7 --- /dev/null +++ b/crates/herdr-gpui/locales/en-US/usage-kiro.ftl @@ -0,0 +1,13 @@ +# Kiro's plan usage, in its usage panel. + +## The report: the plan's monthly credits, bonus credits, and overages, as +## the Kiro CLI's /usage report prints them. Plan names and the overage +## status (such as Enabled or Disabled) stay as the CLI prints them. + +usage-kiro-credits-left = Credits left +# A window: bonus credits used, which expire on their own date. +usage-kiro-bonus-credits = Bonus credits +# A section's title and its fact: whether overages are enabled. +usage-kiro-overages = Overages +usage-kiro-overage-credits-used = Overage credits used +usage-kiro-estimated-overage-cost = Estimated overage cost diff --git a/crates/herdr-gpui/locales/en-US/usage-litellm.ftl b/crates/herdr-gpui/locales/en-US/usage-litellm.ftl index f236a35b2..6d96b6793 100644 --- a/crates/herdr-gpui/locales/en-US/usage-litellm.ftl +++ b/crates/herdr-gpui/locales/en-US/usage-litellm.ftl @@ -6,3 +6,29 @@ usage-litellm-setting-api-key = A LiteLLM virtual key (sk-…) issued by your LiteLLM proxy, e.g. from its admin UI under Virtual Keys. A master key is not needed. usage-litellm-setting-base-url = The LiteLLM proxy's URL, with or without /v1, e.g. https://litellm.example.com. It must be HTTPS unless it is on localhost, a private network, or a .local host. + +## The report: the key's personal and team budgets, or its spend this month +## when the proxy hides the budgets. Team aliases ($team, such as ai) stay +## as the proxy names them. + +# A budget's window and balance, by whose it is. +usage-litellm-personal-budget = Personal budget +usage-litellm-team-budget = Team budget +usage-litellm-team-named-budget = Team { $team } budget +# The same, for spend without a budget limit. +usage-litellm-personal-spend = Personal spend +usage-litellm-team-spend = Team spend +usage-litellm-team-named-spend = Team { $team } spend +# Spend summed from a self-scoped spend report, with no budget known. +usage-litellm-key-spend-only = Key spend only +usage-litellm-user-spend-only = User spend only +# A section's title: the virtual key, its team, and when it expires. +usage-litellm-key = Key +usage-litellm-team = Team +usage-litellm-key-expires = Key expires +# The plan line: the report is read with a virtual key. +usage-litellm-api-key = API key +usage-litellm-spend-report = Spend report +usage-litellm-period = Period +# $start and $end are the report's first and last days, such as 2026-09-01. +usage-litellm-period-range = { $start } – { $end } UTC diff --git a/crates/herdr-gpui/locales/en-US/usage-llmman.ftl b/crates/herdr-gpui/locales/en-US/usage-llmman.ftl index 4f187ae2e..aa3db6d14 100644 --- a/crates/herdr-gpui/locales/en-US/usage-llmman.ftl +++ b/crates/herdr-gpui/locales/en-US/usage-llmman.ftl @@ -6,3 +6,21 @@ usage-llmman-setting-base-url = The llmman serve address, when not http://127.0.0.1:17434. A host without a scheme uses HTTP on port 17434, e.g. localhost:18000. Plain HTTP is allowed only for loopback, private-network, and .local hosts; others need HTTPS. Without it, LLMMAN_HOST on the probed host is used when set. usage-llmman-setting-api-key = Only when the daemon was started with LLMMAN_API_KEYS: one of those keys. + +## The report: the daemon's model memory, its loaded and stored models, +## and its version. Model names, counts, and sizes (such as 10.0 GB) stay +## as figures. + +# A window and a fact: the model memory loaded weights fill. +usage-llmman-memory = Memory +# $used and $total are sizes, such as 10.0 GB and 40.0 GB. +usage-llmman-memory-used = { $used } of { $total } +usage-llmman-loaded = Loaded +usage-llmman-stored = Stored +usage-llmman-version = Version +# A section's title: the daemon's summary. +usage-llmman-daemon = Daemon +usage-llmman-loaded-models = Loaded models +# The plan line: read with a key, or from a daemon open to this host. +usage-llmman-api-key = API key +usage-llmman-local-daemon = Local daemon diff --git a/crates/herdr-gpui/locales/en-US/usage-llmproxy.ftl b/crates/herdr-gpui/locales/en-US/usage-llmproxy.ftl index a76fe638a..44b8ecf48 100644 --- a/crates/herdr-gpui/locales/en-US/usage-llmproxy.ftl +++ b/crates/herdr-gpui/locales/en-US/usage-llmproxy.ftl @@ -6,3 +6,23 @@ usage-llmproxy-setting-api-key = The API key your LLM-API-Key-Proxy accepts (its PROXY_API_KEY), sent as a bearer token to base_url. usage-llmproxy-setting-base-url = The proxy's URL, with or without /v1, e.g. https://proxy.example.com. It must be HTTPS unless it is on localhost, a private network, or a .local host. + +## The report: the proxy's quota, request and token totals, and its busiest +## upstream providers. Provider names (such as openai) stay as the proxy +## names them. + +# A window: the least quota left across the proxy's credentials. +usage-llmproxy-quota = Quota +# The plan line: $active of the proxy's $keys credentials are active, such +# as 3/4. +usage-llmproxy-active-keys = { $active }/{ $keys } active keys +usage-llmproxy-totals = Totals +usage-llmproxy-requests = Requests +usage-llmproxy-tokens = Tokens +# A section's title: the providers with the most requests. +usage-llmproxy-providers = Providers +# A provider's figures: $requests and $tokens are counts, such as 120 and +# 6,000; $cost is its approximate spend, such as $12.50. +usage-llmproxy-provider = { $requests } req · { $tokens } tok +usage-llmproxy-provider-cost = { $requests } req · { $tokens } tok · { $cost } +usage-llmproxy-approx-spend = Approx. spend diff --git a/crates/herdr-gpui/locales/en-US/usage-longcat.ftl b/crates/herdr-gpui/locales/en-US/usage-longcat.ftl index 9df4b6582..cc8b8530a 100644 --- a/crates/herdr-gpui/locales/en-US/usage-longcat.ftl +++ b/crates/herdr-gpui/locales/en-US/usage-longcat.ftl @@ -5,3 +5,14 @@ ## service's own menu paths stay as written. usage-longcat-setting-cookie = The LongCat console session. Sign in at https://longcat.chat/platform/usage, open Developer Tools > Application > Cookies > https://longcat.chat, and copy every cookie of the site (the console does not document which one holds the session). Paste them as "name=value; name2=value2", or copy the Cookie request header of a request to longcat.chat from the Network tab. + +## The report: the token quota and the fuel packs on top of it. Token +## counts (such as 1212576/50000000) stay as figures. + +usage-longcat-token-quota = Token quota +usage-longcat-tokens-used = Tokens used +# A window: the fuel packs' tokens used, until the nearest one expires. +usage-longcat-fuel-pack = Fuel pack +usage-longcat-fuel-pack-left = Fuel pack left +# A section's title: the token quota and fuel pack figures. +usage-longcat-quota = Quota diff --git a/crates/herdr-gpui/locales/en-US/usage-manus.ftl b/crates/herdr-gpui/locales/en-US/usage-manus.ftl index c8eeaff5c..8588481d6 100644 --- a/crates/herdr-gpui/locales/en-US/usage-manus.ftl +++ b/crates/herdr-gpui/locales/en-US/usage-manus.ftl @@ -6,3 +6,18 @@ usage-manus-setting-session-token = Sign in to https://manus.im, open Developer Tools > Application > Cookies > https://manus.im, and copy the value of the session_id cookie (the value only, without "session_id="). Not needed when Manus is listed in show_providers and you are signed in with Chrome or Safari. usage-manus-setting-cookie = Instead of session_token, a whole manus.im Cookie header that includes session_id, as "session_id=value; …". + +## The report: the credit balance, the plan's monthly credits, and the +## credits that refresh on the service's interval. The interval's name +## (such as Daily) stays as the service sends it. + +usage-manus-monthly-credits = Monthly credits +usage-manus-daily-refresh = Daily refresh +usage-manus-free = Free +usage-manus-monthly = Monthly +# The refreshing credits, when the service names no interval. +usage-manus-refresh = Refresh +# $left credits are left of $total, both counts such as 3,000 and 4,000. +usage-manus-left-of = { $left } of { $total } +usage-manus-balance = Balance +usage-manus-credits = Credits diff --git a/crates/herdr-gpui/locales/en-US/usage-mimo.ftl b/crates/herdr-gpui/locales/en-US/usage-mimo.ftl index 8f0c30132..04fd450b3 100644 --- a/crates/herdr-gpui/locales/en-US/usage-mimo.ftl +++ b/crates/herdr-gpui/locales/en-US/usage-mimo.ftl @@ -6,3 +6,21 @@ usage-mimo-setting-cookie = The MiMo console session. Sign in at https://platform.xiaomimimo.com/#/console/balance, open Developer Tools → Application → Cookies → https://platform.xiaomimimo.com, copy the api-platform_serviceToken and userId cookies (and api-platform_ph and api-platform_slh when present), and paste them as "api-platform_serviceToken=value; userId=value". usage-mimo-setting-base-url = Optional HTTPS API URL. Defaults to https://platform.xiaomimimo.com/api/v1. + +## The report: the balance, what of it was paid or granted, and the token +## plan's monthly credits. The plan's name stays as MiMo sends it. + +usage-mimo-paid = Paid +usage-mimo-granted = Granted +usage-mimo-token-plan = Token plan +# $used and $limit are credit counts, such as 10,100,158 and 200,000,000; +# $count is the limit as a number, for the plural. +usage-mimo-credits-used = { $count -> + [one] { $used } of { $limit } credit + *[other] { $used } of { $limit } credits + } +# The token plan's state once its period has ended. +usage-mimo-expired = Expired +usage-mimo-balance = Balance +# A section's title: the account's balance and token plan. +usage-mimo-account = Account diff --git a/crates/herdr-gpui/locales/en-US/usage-minimax.ftl b/crates/herdr-gpui/locales/en-US/usage-minimax.ftl index 338b83e81..cd57f1acc 100644 --- a/crates/herdr-gpui/locales/en-US/usage-minimax.ftl +++ b/crates/herdr-gpui/locales/en-US/usage-minimax.ftl @@ -7,3 +7,21 @@ usage-minimax-setting-api-key = A MiniMax Coding Plan or Token Plan API key (sk-cp-…) from https://platform.minimax.io/user-center/basic-information/interface-key. usage-minimax-setting-cookie = Used when no API key is set. Sign in to https://platform.minimax.io, open Developer Tools > Application > Cookies > https://platform.minimax.io, and copy every cookie (including HERTZ-SESSION) as "name=value; name2=value2". usage-minimax-setting-region = "global" for minimax.io (the default) or "cn" for the China mainland platform at minimaxi.com. + +## The report: the Coding or Token Plan's quota lanes, named by what each +## makes, and the points balance. Plan names, and the model names of lanes +## that make something else, stay as MiniMax sends them. + +usage-minimax-points = Points +# What a quota lane makes. +usage-minimax-text = Text +usage-minimax-video = Video +usage-minimax-speech = Speech +usage-minimax-image-to-video = Image to video +usage-minimax-text-to-video = Text to video +usage-minimax-image = Image +usage-minimax-music = Music +# A lane's daily window; $service is what it makes, such as Video. +usage-minimax-daily = { $service } daily +# A lane's window of $hours hours, such as Speech 4h. +usage-minimax-hours = { $service } { $hours }h diff --git a/crates/herdr-gpui/locales/en-US/usage-mistral.ftl b/crates/herdr-gpui/locales/en-US/usage-mistral.ftl index e3ea16df1..384495385 100644 --- a/crates/herdr-gpui/locales/en-US/usage-mistral.ftl +++ b/crates/herdr-gpui/locales/en-US/usage-mistral.ftl @@ -5,3 +5,23 @@ ## service's own menu paths stay as written. usage-mistral-setting-cookie = Sign in to https://admin.mistral.ai/organization/usage, open Developer Tools > Application > Cookies for https://admin.mistral.ai, and copy the ory_session_… cookie (its name ends in a per-project suffix) and csrftoken. Paste them as "name=value; name2=value2"; the Cookie header of any admin.mistral.ai request also works. + +## The report: this month's API spend and tokens, the subscription's +## included API and Vibe allowances, and credits. Vibe is the product's +## name, and model names stay as Mistral sends them. + +usage-mistral-api-spend = API spend this month +usage-mistral-credits = Credits +# A window and a fact: the API spend the subscription includes. +usage-mistral-included-api = Included API +# An allowance's spend: $used and $limit are amounts, such as 0.51 EUR and +# 25.50 EUR. +usage-mistral-allowance-used = { $used } of { $limit } used +usage-mistral-allowances = Allowances +usage-mistral-tokens-this-month = Tokens this month +usage-mistral-input = Input +usage-mistral-output = Output +usage-mistral-cached = Cached +# How many models used tokens this month. +usage-mistral-models = Models +usage-mistral-spend-by-model = Spend by model diff --git a/crates/herdr-gpui/locales/en-US/usage-moonshot.ftl b/crates/herdr-gpui/locales/en-US/usage-moonshot.ftl index 32fc9cfb0..8927e09cd 100644 --- a/crates/herdr-gpui/locales/en-US/usage-moonshot.ftl +++ b/crates/herdr-gpui/locales/en-US/usage-moonshot.ftl @@ -6,3 +6,13 @@ usage-moonshot-setting-api-key = A Moonshot / Kimi Open Platform API key, from https://platform.moonshot.ai/console/api-keys (International) or https://platform.moonshot.cn/console/api-keys (China mainland). Use a key issued for the region set below. usage-moonshot-setting-region = Which platform issued the key: "international" (the default, api.moonshot.ai, balances in USD) or "china" (api.moonshot.cn, balances in CNY). + +## The report: the prepaid balance and its voucher and cash parts. Amounts +## (such as $46.59) stay as figures. + +usage-moonshot-available = Available +usage-moonshot-balance = Balance +usage-moonshot-voucher = Voucher +usage-moonshot-cash = Cash +# How far the cash balance is below zero. +usage-moonshot-in-deficit = In deficit diff --git a/crates/herdr-gpui/locales/en-US/usage-muse.ftl b/crates/herdr-gpui/locales/en-US/usage-muse.ftl index 1a4716c55..0f7560465 100644 --- a/crates/herdr-gpui/locales/en-US/usage-muse.ftl +++ b/crates/herdr-gpui/locales/en-US/usage-muse.ftl @@ -5,3 +5,19 @@ ## service's own menu paths stay as written. usage-muse-setting-token = A Muse CLI device-code access token (it starts with dca:), used when this host has no `muse login`. It is providers.meta.access_token in ~/.config/muse/auth.json on a machine where you ran `muse login`. On a Mac where `muse login` kept it in the keychain instead, list muse in show_providers and press Allow in its usage panel rather than setting this. Dashboard LLM_ keys and LLM| inference keys are not accepted. + +## The report: the subscription's rolling and weekly windows, or why the +## login has none. The plan's name stays as Muse sends it. + +# The plan line when the login names no plan. +usage-muse-login = Muse login +usage-muse-subscription = Subscription +usage-muse-status = Status +usage-muse-payment-required = Payment method required; finish billing at https://dev.meta.ai +usage-muse-no-subscription = No active subscription on this login +usage-muse-no-quota = Quota not included in this login response +# A rolling window other than 5 hours; $hours is its length, such as 2. +usage-muse-hours = { $hours -> + [one] { $hours } hour + *[other] { $hours } hours + } diff --git a/crates/herdr-gpui/locales/en-US/usage-neuralwatt.ftl b/crates/herdr-gpui/locales/en-US/usage-neuralwatt.ftl index 26de7be1b..a01839894 100644 --- a/crates/herdr-gpui/locales/en-US/usage-neuralwatt.ftl +++ b/crates/herdr-gpui/locales/en-US/usage-neuralwatt.ftl @@ -6,3 +6,30 @@ usage-neuralwatt-setting-api-key = A Neuralwatt API key, created or copied at https://portal.neuralwatt.com/dashboard. usage-neuralwatt-setting-base-url = Optional HTTPS API URL for a proxy. Defaults to https://api.neuralwatt.com. + +## The report: the subscription's energy allowance, the key's spending +## allowance, prepaid credits, and this month's spend. Plan names, the +## allowance period ($period, such as Monthly), and the accounting method +## stay as Neuralwatt sends them. + +# A window for a subscription billed by the year. +usage-neuralwatt-yearly = Yearly +usage-neuralwatt-energy = Energy +# $used and $included are kilowatt-hours, such as 13.90 and 20. +usage-neuralwatt-energy-used = { $used } / { $included } kWh +# The key's spending allowance, by its period. +usage-neuralwatt-key-period = Key { $period } +usage-neuralwatt-key-allowance = Key Allowance +usage-neuralwatt-this-month = This month +# This month's spend: $cost is an amount, such as $4.20; $requests is the +# request count, such as 1,234, and $count the same as a number. +usage-neuralwatt-month-requests = { $count -> + [one] { $cost } · { $requests } request + *[other] { $cost } · { $requests } requests + } +usage-neuralwatt-credits-used = Credits used +# $used and $total are amounts, such as $17.32 and $50.00. +usage-neuralwatt-credits-of = { $used } of { $total } +# The plan line; $plan is the plan's name, such as Standard. +usage-neuralwatt-plan = { $plan } plan +usage-neuralwatt-prepaid-credits = Prepaid credits diff --git a/crates/herdr-gpui/locales/en-US/usage-notion.ftl b/crates/herdr-gpui/locales/en-US/usage-notion.ftl index f7f8ef30e..1df2b5a15 100644 --- a/crates/herdr-gpui/locales/en-US/usage-notion.ftl +++ b/crates/herdr-gpui/locales/en-US/usage-notion.ftl @@ -6,3 +6,14 @@ usage-notion-setting-cookie = Your Notion session cookie. Sign in at https://app.notion.com, open Developer Tools > Application > Cookies for https://app.notion.com (or notion.so), copy the token_v2 cookie and paste it as "token_v2=value" (add "; notion_user_id=value" for accounts signed in to several users). usage-notion-setting-workspace-id = The workspace (space) ID to read, dashed or not, for accounts in several workspaces. Defaults to the first Business or Enterprise workspace. It is the x-notion-space-id header of requests in Developer Tools > Network. + +## The report: the workspace's rolling and billing-period AI allowances. +## Workspace and tier names, and Notion's enforcement word (such as +## Preview), stay as Notion sends them. + +# A rolling window longer than six hours. +usage-notion-rolling = Rolling +usage-notion-workspace = Workspace +usage-notion-allowance = Allowance +usage-notion-not-tracked = Not tracked; allowances apply to Business and Enterprise workspaces +usage-notion-enforcement = Enforcement diff --git a/crates/herdr-gpui/locales/en-US/usage-nous.ftl b/crates/herdr-gpui/locales/en-US/usage-nous.ftl index 604678f4c..d47c94dd7 100644 --- a/crates/herdr-gpui/locales/en-US/usage-nous.ftl +++ b/crates/herdr-gpui/locales/en-US/usage-nous.ftl @@ -6,3 +6,18 @@ usage-nous-setting-token = A Nous Portal OAuth access token, used instead of Hermes Agent's login in ~/.hermes/auth.json (sign in with `hermes auth add nous`). It lasts about an hour; portal API keys are not accepted. usage-nous-setting-base-url = An HTTPS portal origin such as https://portal.nousresearch.com, for a preview deployment. Plain HTTP is refused. + +## The report: the subscription's monthly credits, top-up credits, and when +## the subscription renews. Plan and organization names, and the renewal +## date (such as Oct 12), stay as figures. + +usage-nous-subscription-credits = Subscription credits +usage-nous-monthly-grant = Monthly grant +usage-nous-top-up-credits = Top-up credits +usage-nous-total-usable = Total usable +usage-nous-organization = Organization +usage-nous-rollover-credits = Rollover credits +usage-nous-renews = Renews +# A section's title, and the plan line of an active subscription that names +# no plan. +usage-nous-subscription = Subscription diff --git a/crates/herdr-gpui/locales/en-US/usage-ollama.ftl b/crates/herdr-gpui/locales/en-US/usage-ollama.ftl index ac8114ca5..a9f86b86f 100644 --- a/crates/herdr-gpui/locales/en-US/usage-ollama.ftl +++ b/crates/herdr-gpui/locales/en-US/usage-ollama.ftl @@ -6,3 +6,14 @@ usage-ollama-setting-cookie = The ollama.com browser session, needed for quota. Sign in at https://ollama.com/signin, open Developer Tools → Application → Cookies → https://ollama.com, copy the session cookie (wos-session, or __Secure-session on older sign-ins), and paste it as "name=value". usage-ollama-setting-api-key = An Ollama API key from https://ollama.com/settings/keys. It only verifies access: Ollama shows quota on its website, not through the API. + +## The report: the Plan & Billing page's usage windows, or, with an API key, +## how many cloud models it can reach. Ollama Cloud is the product's name, +## and the plan's name stays as the page shows it. + +# A window older pages showed in place of the session. +usage-ollama-hourly = Hourly +# The plan line: the report is read with an API key. +usage-ollama-api-key = API key +# How many cloud models the key can reach. +usage-ollama-models = Models diff --git a/crates/herdr-gpui/locales/en-US/usage-openai.ftl b/crates/herdr-gpui/locales/en-US/usage-openai.ftl index d41ee734f..d6006a2fa 100644 --- a/crates/herdr-gpui/locales/en-US/usage-openai.ftl +++ b/crates/herdr-gpui/locales/en-US/usage-openai.ftl @@ -8,3 +8,37 @@ usage-openai-setting-api-key = An organization Admin API key (sk-admin-...), cre usage-openai-setting-project-id = Optional. A project ID (proj_...) from https://platform.openai.com/settings/organization/projects to limit spend and usage to that project. usage-openai-setting-history-days = Optional. How many days of spend to total, 1 to 365. Defaults to 30. usage-openai-setting-balance-fallback = Optional. Set to 1 to show the legacy prepaid credit balance when the key cannot read organization usage (older user API keys). Ignored with project_id. + +## The report: the organization's API spend and completion usage from the +## Admin API, or the legacy credit-grant balance. Line items, models, and +## project IDs stay as OpenAI sends them. + +# The spend balance over the history setting's $days days. +usage-openai-spend-days = { $days -> + [1] Spend today + *[other] Spend, last { $days } days + } +usage-openai-spend = Spend +usage-openai-today = Today +usage-openai-last-7-days = Last 7 days +usage-openai-completions = Completions +usage-openai-requests = Requests +usage-openai-input-tokens = Input tokens +usage-openai-cached-input-tokens = Cached input tokens +usage-openai-output-tokens = Output tokens +usage-openai-spend-by-line-item = Spend by line item +# Spend the Admin API names no line item for. +usage-openai-unnamed-line-item = API +usage-openai-tokens-by-model = Tokens by model +# Tokens the Admin API names no model for. +usage-openai-unnamed-model = Responses and Chat Completions +# The plan line: read with an Admin API key, for every project or for +# $project, a project ID such as proj_abc. +usage-openai-admin-api = Admin API +usage-openai-admin-api-project = Admin API: { $project } +# The plan line of the legacy balance fallback. +usage-openai-api-balance = API balance +usage-openai-api-credits-left = API credits left +usage-openai-used = Used +usage-openai-next-expiry = Next expiry +usage-openai-credit-grants = Credit grants diff --git a/crates/herdr-gpui/locales/en-US/usage-opencode.ftl b/crates/herdr-gpui/locales/en-US/usage-opencode.ftl index 8b077858a..f999d0f13 100644 --- a/crates/herdr-gpui/locales/en-US/usage-opencode.ftl +++ b/crates/herdr-gpui/locales/en-US/usage-opencode.ftl @@ -6,3 +6,14 @@ usage-opencode-setting-cookie = Your opencode.ai session. Sign in at https://opencode.ai/auth, open Developer Tools > Application > Cookies > https://opencode.ai, and copy the auth and __Host-console_session cookies (either one alone also works). Paste them as "auth=value; __Host-console_session=value". usage-opencode-setting-workspace-id = Optional. The workspace to read, as a wrk_... ID or the https://opencode.ai/workspace/... URL of its dashboard. Defaults to the first workspace of the account. + +## The report: a subscription workspace's windows, or a pay-as-you-go +## workspace's Zen balance and monthly spend. Zen is OpenCode's name for +## its model gateway. + +usage-opencode-zen-balance = Zen balance +usage-opencode-monthly-limit-left = Monthly limit left +# The plan line of a workspace without a subscription. +usage-opencode-pay-as-you-go = Pay as you go +usage-opencode-this-month = This month +usage-opencode-spent = Spent diff --git a/crates/herdr-gpui/locales/en-US/usage-opencodego.ftl b/crates/herdr-gpui/locales/en-US/usage-opencodego.ftl index 992dbaac3..494b046f1 100644 --- a/crates/herdr-gpui/locales/en-US/usage-opencodego.ftl +++ b/crates/herdr-gpui/locales/en-US/usage-opencodego.ftl @@ -7,3 +7,12 @@ usage-opencodego-setting-api-key = An OpenCode API key for the account with the Go subscription, created at https://opencode.ai/auth under API Keys. Preferred over the cookie. When unset, the key `opencode auth login` saved in ~/.local/share/opencode/auth.json is used. usage-opencodego-setting-cookie = Your opencode.ai session, used when no API key is set. Sign in at https://opencode.ai/auth, open Developer Tools > Application > Cookies > https://opencode.ai, and copy the __Host-console_session and auth cookies (either one alone also works). Paste them as "__Host-console_session=value; auth=value". usage-opencodego-setting-workspace-id = Optional, for the cookie sign-in. The workspace to read, as a wrk_... or org_... ID or its https://opencode.ai/console/... URL. Defaults to the first workspace of the account. + +## The report: the Go subscription's windows and the workspace's Zen +## balance. Go and OpenCode Go are the product's names; Zen is OpenCode's +## model gateway. + +usage-opencodego-zen-balance = Zen balance +usage-opencodego-subscription = Subscription +# The workspace has no OpenCode Go subscription. +usage-opencodego-none = None diff --git a/crates/herdr-gpui/locales/en-US/usage-openrouter.ftl b/crates/herdr-gpui/locales/en-US/usage-openrouter.ftl index c19d64ce8..f73710fe9 100644 --- a/crates/herdr-gpui/locales/en-US/usage-openrouter.ftl +++ b/crates/herdr-gpui/locales/en-US/usage-openrouter.ftl @@ -9,3 +9,31 @@ usage-openrouter-setting-management-api-key = Optional Management API key from h usage-openrouter-setting-base-url = Optional HTTPS API URL for a proxy. Defaults to https://openrouter.ai/api/v1. usage-openrouter-setting-http-referer = Optional client URL sent as the HTTP-Referer header. usage-openrouter-setting-x-title = Optional client title sent as the X-Title header. Defaults to Herdr. + +## The report: the key's spending cap and spend, the account's prepaid +## credits, and, with a Management API key, the last 30 days of activity. +## The cap's reset window (such as monthly) stays as OpenRouter sends it. + +usage-openrouter-credits = Credits +# The plan line of a free-tier key. +usage-openrouter-free-tier = Free tier +usage-openrouter-last-30-days = Last 30 days +usage-openrouter-spend = Spend +usage-openrouter-requests = Requests +usage-openrouter-tokens = Tokens +# How many models the account used in the last 30 days. +usage-openrouter-models = Models +# A window: the key's spending cap, when it names no daily, weekly, or +# monthly reset. +usage-openrouter-api-key-limit = API key limit +usage-openrouter-spending-cap = Spending cap +# The spending cap of a key without one. +usage-openrouter-no-limit = No limit configured +usage-openrouter-cap-remaining = Cap remaining +usage-openrouter-key-used = Key used +usage-openrouter-reset-window = Reset window +usage-openrouter-today = Today +usage-openrouter-this-week = This week +usage-openrouter-this-month = This month +# A section's title: the key's cap and spend. +usage-openrouter-api-key = API key diff --git a/crates/herdr-gpui/src/usage/tests.rs b/crates/herdr-gpui/src/usage/tests.rs index cc330d8b1..07d460107 100644 --- a/crates/herdr-gpui/src/usage/tests.rs +++ b/crates/herdr-gpui/src/usage/tests.rs @@ -17,6 +17,7 @@ mod status_bar_room; mod text; mod words; mod words_b; +mod words_c; fn provider(id: &str) -> Provider { registry::find(id).unwrap() diff --git a/crates/herdr-gpui/src/usage/tests/words_c.rs b/crates/herdr-gpui/src/usage/tests/words_c.rs new file mode 100644 index 000000000..a92415f8b --- /dev/null +++ b/crates/herdr-gpui/src/usage/tests/words_c.rs @@ -0,0 +1,416 @@ +//! The words a third batch of providers' reports name, read in English as +//! each provider used to write them. +use super::super::text; +use herdr_usage::Text; + +/// Drops Fluent's isolation marks around placeables. +fn plain(text: String) -> String { + text.replace(['\u{2068}', '\u{2069}'], "") +} + +fn english(words: Text) -> String { + plain(text::words(&words)) +} + +#[test] +fn kiro_reads_as_it_did() { + assert_eq!( + english(Text::message("usage-kiro-credits-left")), + "Credits left" + ); + assert_eq!( + english(Text::message("usage-kiro-bonus-credits")), + "Bonus credits" + ); + assert_eq!(english(Text::message("usage-kiro-overages")), "Overages"); + assert_eq!( + english(Text::message("usage-kiro-overage-credits-used")), + "Overage credits used" + ); + assert_eq!( + english(Text::message("usage-kiro-estimated-overage-cost")), + "Estimated overage cost" + ); +} + +#[test] +fn litellm_reads_as_it_did() { + assert_eq!( + english(Text::message("usage-litellm-personal-budget")), + "Personal budget" + ); + assert_eq!( + english(Text::message("usage-litellm-team-budget")), + "Team budget" + ); + assert_eq!( + english(Text::message("usage-litellm-team-named-budget").arg("team", "ai")), + "Team ai budget" + ); + assert_eq!( + english(Text::message("usage-litellm-personal-spend")), + "Personal spend" + ); + assert_eq!( + english(Text::message("usage-litellm-team-spend")), + "Team spend" + ); + assert_eq!( + english(Text::message("usage-litellm-team-named-spend").arg("team", "ai")), + "Team ai spend" + ); + assert_eq!( + english(Text::message("usage-litellm-key-spend-only")), + "Key spend only" + ); + assert_eq!( + english(Text::message("usage-litellm-user-spend-only")), + "User spend only" + ); + assert_eq!(english(Text::message("usage-litellm-api-key")), "API key"); + assert_eq!( + english( + Text::message("usage-litellm-period-range") + .arg("start", "2026-09-01") + .arg("end", "2026-09-02") + ), + "2026-09-01 – 2026-09-02 UTC" + ); +} + +#[test] +fn llmman_reads_as_it_did() { + assert_eq!( + english( + Text::message("usage-llmman-memory-used") + .arg("used", "10.0 GB") + .arg("total", "40.0 GB") + ), + "10.0 GB of 40.0 GB" + ); + assert_eq!( + english(Text::message("usage-llmman-loaded-models")), + "Loaded models" + ); + assert_eq!( + english(Text::message("usage-llmman-local-daemon")), + "Local daemon" + ); + assert_eq!(english(Text::message("usage-llmman-api-key")), "API key"); +} + +#[test] +fn llmproxy_reads_as_it_did() { + assert_eq!( + english( + Text::message("usage-llmproxy-active-keys") + .arg("active", "3") + .arg("keys", "4") + ), + "3/4 active keys" + ); + assert_eq!( + english( + Text::message("usage-llmproxy-provider") + .arg("requests", "120") + .arg("tokens", "6,000") + ), + "120 req · 6,000 tok" + ); + assert_eq!( + english( + Text::message("usage-llmproxy-provider-cost") + .arg("requests", "120") + .arg("tokens", "6,000") + .arg("cost", "$12.50") + ), + "120 req · 6,000 tok · $12.50" + ); + assert_eq!( + english(Text::message("usage-llmproxy-approx-spend")), + "Approx. spend" + ); +} + +#[test] +fn longcat_reads_as_it_did() { + assert_eq!( + english(Text::message("usage-longcat-token-quota")), + "Token quota" + ); + assert_eq!( + english(Text::message("usage-longcat-fuel-pack-left")), + "Fuel pack left" + ); +} + +#[test] +fn manus_reads_as_it_did() { + assert_eq!( + english( + Text::message("usage-manus-left-of") + .arg("left", "3,000") + .arg("total", "4,000") + ), + "3,000 of 4,000" + ); + assert_eq!( + english(Text::message("usage-manus-monthly-credits")), + "Monthly credits" + ); + assert_eq!( + english(Text::message("usage-manus-daily-refresh")), + "Daily refresh" + ); + assert_eq!(english(Text::message("usage-manus-refresh")), "Refresh"); +} + +#[test] +fn mimo_reads_as_it_did() { + let used = |used: &str, limit: &str, count: i64| { + english( + Text::message("usage-mimo-credits-used") + .arg("used", used) + .arg("limit", limit) + .arg("count", count), + ) + }; + assert_eq!( + used("10,100,158", "200,000,000", 200_000_000), + "10,100,158 of 200,000,000 credits" + ); + assert_eq!(used("0", "1", 1), "0 of 1 credit"); + assert_eq!(english(Text::message("usage-mimo-expired")), "Expired"); + assert_eq!( + english(Text::message("usage-mimo-token-plan")), + "Token plan" + ); +} + +#[test] +fn minimax_reads_as_it_did() { + assert_eq!( + english( + Text::message("usage-minimax-daily") + .arg("service", Text::message("usage-minimax-video")) + ), + "Video daily" + ); + assert_eq!( + english( + Text::message("usage-minimax-hours") + .arg("service", Text::message("usage-minimax-speech")) + .arg("hours", 4) + ), + "Speech 4h" + ); + assert_eq!( + english(Text::message("usage-minimax-daily").arg("service", Text::service("custom-model"))), + "custom-model daily" + ); + assert_eq!( + english(Text::message("usage-minimax-image-to-video")), + "Image to video" + ); + assert_eq!( + english(Text::message("usage-minimax-text-to-video")), + "Text to video" + ); +} + +#[test] +fn mistral_reads_as_it_did() { + assert_eq!( + english( + Text::message("usage-mistral-allowance-used") + .arg("used", "0.51 EUR") + .arg("limit", "25.50 EUR") + ), + "0.51 EUR of 25.50 EUR used" + ); + assert_eq!( + english(Text::message("usage-mistral-api-spend")), + "API spend this month" + ); + assert_eq!( + english(Text::message("usage-mistral-included-api")), + "Included API" + ); +} + +#[test] +fn moonshot_reads_as_it_did() { + assert_eq!( + english(Text::message("usage-moonshot-in-deficit")), + "In deficit" + ); + assert_eq!( + english(Text::message("usage-moonshot-available")), + "Available" + ); +} + +#[test] +fn muse_reads_as_it_did() { + let hours = |hours: f64| english(Text::message("usage-muse-hours").arg("hours", hours)); + assert_eq!(hours(2.), "2 hours"); + assert_eq!(hours(2.5), "2.5 hours"); + assert_eq!(hours(1.), "1 hour"); + assert_eq!(english(Text::message("usage-muse-login")), "Muse login"); + assert_eq!( + english(Text::message("usage-muse-payment-required")), + "Payment method required; finish billing at https://dev.meta.ai" + ); + assert_eq!( + english(Text::message("usage-muse-no-subscription")), + "No active subscription on this login" + ); + assert_eq!( + english(Text::message("usage-muse-no-quota")), + "Quota not included in this login response" + ); +} + +#[test] +fn neuralwatt_reads_as_it_did() { + assert_eq!( + english( + Text::message("usage-neuralwatt-energy-used") + .arg("used", "13.90") + .arg("included", "20") + ), + "13.90 / 20 kWh" + ); + assert_eq!( + english(Text::message("usage-neuralwatt-key-period").arg("period", "Monthly")), + "Key Monthly" + ); + assert_eq!( + english(Text::message("usage-neuralwatt-key-allowance")), + "Key Allowance" + ); + let month = |requests: &str, count: i64| { + english( + Text::message("usage-neuralwatt-month-requests") + .arg("cost", "$4.20") + .arg("requests", requests) + .arg("count", count), + ) + }; + assert_eq!(month("1,234", 1234), "$4.20 · 1,234 requests"); + assert_eq!(month("1", 1), "$4.20 · 1 request"); + assert_eq!( + english( + Text::message("usage-neuralwatt-credits-of") + .arg("used", "$17.32") + .arg("total", "$50.00") + ), + "$17.32 of $50.00" + ); + assert_eq!( + english(Text::message("usage-neuralwatt-plan").arg("plan", "Standard")), + "Standard plan" + ); + assert_eq!(english(Text::message("usage-neuralwatt-yearly")), "Yearly"); +} + +#[test] +fn notion_reads_as_it_did() { + assert_eq!( + english(Text::message("usage-notion-not-tracked")), + "Not tracked; allowances apply to Business and Enterprise workspaces" + ); + assert_eq!(english(Text::message("usage-notion-rolling")), "Rolling"); +} + +#[test] +fn nous_reads_as_it_did() { + assert_eq!( + english(Text::message("usage-nous-subscription-credits")), + "Subscription credits" + ); + assert_eq!( + english(Text::message("usage-nous-top-up-credits")), + "Top-up credits" + ); + assert_eq!( + english(Text::message("usage-nous-subscription")), + "Subscription" + ); +} + +#[test] +fn ollama_reads_as_it_did() { + assert_eq!(english(Text::message("usage-ollama-hourly")), "Hourly"); + assert_eq!(english(Text::message("usage-ollama-api-key")), "API key"); + assert_eq!(english(Text::message("usage-ollama-models")), "Models"); +} + +#[test] +fn openai_reads_as_it_did() { + let spend = |days: i64| english(Text::message("usage-openai-spend-days").arg("days", days)); + assert_eq!(spend(1), "Spend today"); + assert_eq!(spend(30), "Spend, last 30 days"); + assert_eq!(spend(365), "Spend, last 365 days"); + assert_eq!( + english(Text::message("usage-openai-admin-api-project").arg("project", "proj_abc")), + "Admin API: proj_abc" + ); + assert_eq!( + english(Text::message("usage-openai-admin-api")), + "Admin API" + ); + assert_eq!( + english(Text::message("usage-openai-unnamed-line-item")), + "API" + ); + assert_eq!( + english(Text::message("usage-openai-unnamed-model")), + "Responses and Chat Completions" + ); + assert_eq!( + english(Text::message("usage-openai-api-credits-left")), + "API credits left" + ); +} + +#[test] +fn opencode_reads_as_it_did() { + assert_eq!( + english(Text::message("usage-opencode-zen-balance")), + "Zen balance" + ); + assert_eq!( + english(Text::message("usage-opencode-monthly-limit-left")), + "Monthly limit left" + ); + assert_eq!( + english(Text::message("usage-opencode-pay-as-you-go")), + "Pay as you go" + ); +} + +#[test] +fn opencodego_reads_as_it_did() { + assert_eq!( + english(Text::message("usage-opencodego-zen-balance")), + "Zen balance" + ); + assert_eq!(english(Text::message("usage-opencodego-none")), "None"); +} + +#[test] +fn openrouter_reads_as_it_did() { + assert_eq!( + english(Text::message("usage-openrouter-no-limit")), + "No limit configured" + ); + assert_eq!( + english(Text::message("usage-openrouter-api-key-limit")), + "API key limit" + ); + assert_eq!( + english(Text::message("usage-openrouter-last-30-days")), + "Last 30 days" + ); +} diff --git a/crates/herdr-usage/src/model.rs b/crates/herdr-usage/src/model.rs index 9fe50deb1..724a5b788 100644 --- a/crates/herdr-usage/src/model.rs +++ b/crates/herdr-usage/src/model.rs @@ -443,6 +443,10 @@ impl PartialEq for Report { } impl Report { + /// Windows go session, daily, weekly, monthly, then named ones by their + /// words: a message's ID and arguments, or the service's text. Providers + /// pick message IDs that keep their windows in a sensible order, and + /// their tests hold it. pub fn new(provider: Provider, account: Account, mut windows: Vec) -> Self { windows.sort_by(|a, b| a.kind.cmp(&b.kind)); Self { diff --git a/crates/herdr-usage/src/providers/kiro.rs b/crates/herdr-usage/src/providers/kiro.rs index a065a8e6d..52f290350 100644 --- a/crates/herdr-usage/src/providers/kiro.rs +++ b/crates/herdr-usage/src/providers/kiro.rs @@ -11,7 +11,7 @@ //! through pipes; the `/context` window breakdown is left out too. use crate::model::Count; -use crate::text::{Text, pairs}; +use crate::text::Text; use crate::{ Error, Result, { @@ -116,7 +116,7 @@ pub(crate) fn parse(output: &str, email: Option, now: SystemTime) -> Res if credits.is_some() { balances.push( Balance::new( - "Credits left", + Text::message("usage-kiro-credits-left"), (total - used).max(0.), Unit::Count(Count::Credits), ) @@ -129,7 +129,7 @@ pub(crate) fn parse(output: &str, email: Option, now: SystemTime) -> Res .and_then(leading_number) .map(|(days, _)| now + Duration::from_secs((days.max(0.) as u64) * 86_400)); windows.push(Window::new( - Kind::Named("Bonus credits".into()), + Kind::Named(Text::message("usage-kiro-bonus-credits")), used / total * 100., expires, None, @@ -140,33 +140,39 @@ pub(crate) fn parse(output: &str, email: Option, now: SystemTime) -> Res if let Some(status) = after(&text, "Overages:").map(|rest| line(rest).trim().to_owned()) && !status.is_empty() { - facts.push(("Overages".to_owned(), status)); + facts.push((Text::message("usage-kiro-overages"), Text::service(status))); } if let Some((used, _)) = after(&text, "Credits used:").and_then(|rest| leading_number(rest.trim_start())) { - facts.push(("Overage credits used".to_owned(), plain(used))); + facts.push(( + Text::message("usage-kiro-overage-credits-used"), + Text::service(plain(used)), + )); } if let Some((cost, rest)) = after(&text, "Est. cost:").and_then(|rest| { let rest = rest.trim_start(); leading_number(rest.strip_prefix('$').unwrap_or(rest)) }) && rest.trim_start().starts_with("USD") { - facts.push(("Estimated overage cost".to_owned(), format!("${cost:.2}"))); + facts.push(( + Text::message("usage-kiro-estimated-overage-cost"), + Text::service(format!("${cost:.2}")), + )); } Ok(Report::new( Provider(&Kiro), Account { - email: email.map(Text::from), - plan: Some(Text::from(display_plan(&plan.name))), + email: email.map(Text::service), + plan: Some(Text::service(display_plan(&plan.name))), }, windows, ) .with_balances(balances) .with_sections((!facts.is_empty()).then(|| Section::Facts { - title: "Overages".into(), - facts: pairs(facts), + title: Text::message("usage-kiro-overages"), + facts, }))) } @@ -413,7 +419,14 @@ mod tests { assert!(window.resets_at.is_some()); assert_eq!( report.balances, - vec![Balance::new("Credits left", 37.5, Unit::Count(Count::Credits)).out_of(50.)] + vec![ + Balance::new( + Text::message("usage-kiro-credits-left"), + 37.5, + Unit::Count(Count::Credits) + ) + .out_of(50.) + ] ); } @@ -436,7 +449,10 @@ mod tests { ); assert_eq!(report.windows[0].percent(), 0); let bonus = &report.windows[1]; - assert_eq!(bonus.kind, Kind::Named("Bonus credits".into())); + assert_eq!( + bonus.kind, + Kind::Named(Text::message("usage-kiro-bonus-credits")) + ); assert_eq!(bonus.percent(), 2); assert_eq!( bonus.resets_at, @@ -445,8 +461,11 @@ mod tests { assert_eq!( report.sections, vec![Section::Facts { - title: "Overages".into(), - facts: vec![("Overages".into(), "Disabled".into())], + title: Text::message("usage-kiro-overages"), + facts: vec![( + Text::message("usage-kiro-overages"), + Text::service("Disabled") + )], }] ); } @@ -485,11 +504,20 @@ mod tests { assert_eq!( report.sections, vec![Section::Facts { - title: "Overages".into(), + title: Text::message("usage-kiro-overages"), facts: vec![ - ("Overages".into(), "Enabled".into()), - ("Overage credits used".into(), "12.50".into()), - ("Estimated overage cost".into(), "$0.50".into()), + ( + Text::message("usage-kiro-overages"), + Text::service("Enabled") + ), + ( + Text::message("usage-kiro-overage-credits-used"), + Text::service("12.50") + ), + ( + Text::message("usage-kiro-estimated-overage-cost"), + Text::service("$0.50") + ), ], }] ); diff --git a/crates/herdr-usage/src/providers/litellm.rs b/crates/herdr-usage/src/providers/litellm.rs index b2822d890..45b81304c 100644 --- a/crates/herdr-usage/src/providers/litellm.rs +++ b/crates/herdr-usage/src/providers/litellm.rs @@ -5,7 +5,7 @@ //! `/user/spend/report` when the deployment hides the info routes. Everything //! CodexBar reads is ported; no master key is used. -use crate::text::{Text, pairs}; +use crate::text::Text; use crate::{ Error, Result, { @@ -73,9 +73,12 @@ fn fetch(probe: &mut Probe, key: &Secret) -> Result { let query = format!("?start_date={start}&end_date={end}"); let response = probe.http(get("key/spend/report", &query))?; let (scope, body) = if matches!(response.status, 401 | 403 | 404) { - ("User", probe.body(get("user/spend/report", &query))?) + ( + Reporter::User, + probe.body(get("user/spend/report", &query))?, + ) } else { - ("Key", response.ok()?) + (Reporter::Key, response.ok()?) }; return parse_spend(&body, scope, &start, &end); } @@ -151,7 +154,7 @@ pub(crate) fn parse_user(body: &str, key: &KeyInfo) -> Result { let email = nonempty(user.user_email.clone()) .or_else(|| nonempty(user.user_alias.clone())) .or_else(|| nonempty(preferred)); - let personal = user.budget.budget("Personal"); + let personal = user.budget.budget(Scope::Personal); if root.teams.iter().any(|team| team.team_id.is_none()) { return Err(invalid()); } @@ -160,7 +163,7 @@ pub(crate) fn parse_user(body: &str, key: &KeyInfo) -> Result { .iter() .find(|team| team.team_id.as_deref() == Some(id)) }); - let team_budget = team.map(|team| team.budget.budget(&team_title(team.team_alias.as_deref()))); + let team_budget = team.map(|team| team.budget.budget(Scope::team(team.team_alias.as_deref()))); Ok(report( email, team.and_then(|team| nonempty(team.team_alias.clone())), @@ -177,7 +180,7 @@ pub(crate) fn parse_team(body: &str, key: &KeyInfo) -> Result { if id.is_some_and(|id| Some(id.as_str()) != key.team_id.as_deref()) { return Err(invalid()); } - let budget = team.budget.budget(&team_title(team.team_alias.as_deref())); + let budget = team.budget.budget(Scope::team(team.team_alias.as_deref())); Ok(report( None, nonempty(team.team_alias), @@ -187,11 +190,49 @@ pub(crate) fn parse_team(body: &str, key: &KeyInfo) -> Result { )) } -fn team_title(alias: Option<&str>) -> String { - match alias.map(str::trim).filter(|alias| !alias.is_empty()) { - Some(alias) => format!("Team {alias}"), - None => "Team".into(), +/// Whose budget a figure is: the key's user, or its team, by alias when it +/// has one. +enum Scope { + Personal, + Team(Option), +} + +impl Scope { + fn team(alias: Option<&str>) -> Self { + Self::Team( + alias + .map(str::trim) + .filter(|alias| !alias.is_empty()) + .map(str::to_owned), + ) } + + fn budget(&self) -> Text { + match self { + Self::Personal => Text::message("usage-litellm-personal-budget"), + Self::Team(None) => Text::message("usage-litellm-team-budget"), + Self::Team(Some(alias)) => { + Text::message("usage-litellm-team-named-budget").arg("team", alias.as_str()) + } + } + } + + fn spend(&self) -> Text { + match self { + Self::Personal => Text::message("usage-litellm-personal-spend"), + Self::Team(None) => Text::message("usage-litellm-team-spend"), + Self::Team(Some(alias)) => { + Text::message("usage-litellm-team-named-spend").arg("team", alias.as_str()) + } + } + } +} + +/// Whose self-scoped spend report answered: the key's, or its user's. +#[derive(Clone, Copy)] +pub(crate) enum Reporter { + Key, + User, } fn report( @@ -211,12 +252,12 @@ fn report( .or(team_budget.as_ref()) .filter(|spend| spend.used > 0. || spend.limit > 0.) .map(|spend| { - let label = if spend.limit > 0. { "budget" } else { "spend" }; - let balance = Balance::new( - format!("{} {label}", spend.scope), - spend.used, - Unit::Currency("USD".into()), - ); + let label = if spend.limit > 0. { + spend.scope.budget() + } else { + spend.scope.spend() + }; + let balance = Balance::new(label, spend.used, Unit::Currency("USD".into())); if spend.limit > 0. { balance.out_of(spend.limit) } else { @@ -225,21 +266,21 @@ fn report( }); let mut facts = Vec::new(); if let Some(team) = &team { - facts.push(("Team".to_owned(), team.clone())); + facts.push((Text::message("usage-litellm-team"), Text::service(team))); } if let Some(expires) = &key.expires { facts.push(( - "Key expires".to_owned(), - expires.chars().take(10).collect::(), + Text::message("usage-litellm-key-expires"), + Text::service(expires.chars().take(10).collect::()), )); } let key_facts = (!facts.is_empty()).then(|| Section::Facts { - title: "Key".into(), - facts: pairs(facts), + title: Text::message("usage-litellm-key"), + facts, }); let account = Account { - email: email.map(Text::from), - plan: Some("API key".into()), + email: email.map(Text::service), + plan: Some(Text::message("usage-litellm-api-key")), }; Report::new(Provider(&Litellm), account, windows) .with_balances(balance) @@ -247,7 +288,7 @@ fn report( } /// Sums a spend report's rows once each: model subtotals are not added again. -pub(crate) fn parse_spend(body: &str, scope: &str, start: &str, end: &str) -> Result { +pub(crate) fn parse_spend(body: &str, scope: Reporter, start: &str, end: &str) -> Result { let rows: Vec = json(body)?; if rows.is_empty() { return Err(invalid()); @@ -258,14 +299,19 @@ pub(crate) fn parse_spend(body: &str, scope: &str, start: &str, end: &str) -> Re .map(|cost| total + cost) .ok_or_else(invalid) })?; - let balance = Balance::new( - format!("{scope} spend only"), - used, - Unit::Currency("USD".into()), - ); + let label = match scope { + Reporter::Key => Text::message("usage-litellm-key-spend-only"), + Reporter::User => Text::message("usage-litellm-user-spend-only"), + }; + let balance = Balance::new(label, used, Unit::Currency("USD".into())); let period = Section::Facts { - title: "Spend report".into(), - facts: vec![("Period".into(), format!("{start} – {end} UTC").into())], + title: Text::message("usage-litellm-spend-report"), + facts: vec![( + Text::message("usage-litellm-period"), + Text::message("usage-litellm-period-range") + .arg("start", start) + .arg("end", end), + )], }; Ok( Report::new(Provider(&Litellm), Account::default(), Vec::new()) @@ -275,7 +321,7 @@ pub(crate) fn parse_spend(body: &str, scope: &str, start: &str, end: &str) -> Re } struct Spend { - scope: String, + scope: Scope, used: f64, limit: f64, resets_at: Option, @@ -286,7 +332,7 @@ impl Spend { fn window(&self) -> Option { (self.limit > 0.).then(|| { Window::new( - Kind::Named(format!("{} budget", self.scope).into()), + Kind::Named(self.scope.budget()), self.used / self.limit * 100., self.resets_at.as_ref().and_then(Timestamp::time), self.length, @@ -366,9 +412,9 @@ struct Budget { } impl Budget { - fn budget(&self, scope: &str) -> Spend { + fn budget(&self, scope: Scope) -> Spend { Spend { - scope: scope.to_owned(), + scope, used: self.spend.filter(|spend| spend.is_finite()).unwrap_or(0.), limit: self .max_budget @@ -432,20 +478,41 @@ mod tests { ); assert_eq!(report.windows.len(), 2); let personal = &report.windows[0]; - assert_eq!(personal.kind, Kind::Named("Personal budget".into())); + assert_eq!( + personal.kind, + Kind::Named(Text::message("usage-litellm-personal-budget")) + ); assert_eq!(personal.percent(), 71); let team = &report.windows[1]; - assert_eq!(team.kind, Kind::Named("Team ai budget".into())); + assert_eq!( + team.kind, + Kind::Named(Text::message("usage-litellm-team-named-budget").arg("team", "ai")) + ); assert_eq!(team.percent(), 22); assert_eq!(team.length, Some(WEEK)); assert!(team.resets_at.is_some()); - assert_eq!(report.balances[0].label, "Personal budget"); + assert_eq!( + report.balances[0].label, + Text::message("usage-litellm-personal-budget") + ); assert_eq!(report.balances[0].total, Some(300.)); let Section::Facts { facts, .. } = &report.sections[0] else { panic!("expected key facts"); }; - assert_eq!(facts[0], ("Team".into(), "ai".into())); - assert_eq!(facts[1], ("Key expires".into(), "2026-09-11".into())); + assert_eq!( + facts, + &[ + (Text::message("usage-litellm-team"), Text::service("ai")), + ( + Text::message("usage-litellm-key-expires"), + Text::service("2026-09-11") + ), + ] + ); + assert_eq!( + report.account.plan, + Some(Text::message("usage-litellm-api-key")) + ); } #[test] @@ -457,7 +524,10 @@ mod tests { let report = parse_team(body, &key).unwrap(); assert_eq!(report.windows[0].percent(), 25); assert_eq!(report.windows[0].length, Some(MONTH)); - assert_eq!(report.balances[0].label, "Team ai budget"); + assert_eq!( + report.balances[0].label, + Text::message("usage-litellm-team-named-budget").arg("team", "ai") + ); } #[test] @@ -475,7 +545,10 @@ mod tests { let body = r#"{"user_info":{"user_id":"user-123","max_budget":null,"spend":12.5}}"#; let report = parse_user(body, &key).unwrap(); assert!(report.windows.is_empty()); - assert_eq!(report.balances[0].label, "Personal spend"); + assert_eq!( + report.balances[0].label, + Text::message("usage-litellm-personal-spend") + ); assert_eq!(report.balances[0].amount, 12.5); } @@ -486,10 +559,25 @@ mod tests { "models":[{"model":"gpt-4o","total_cost":1.25}]}, {"group_by_day":"2026-09-02","total_cost":0.75} ]"#; - let report = parse_spend(body, "Key", "2026-09-01", "2026-09-02").unwrap(); + let report = parse_spend(body, Reporter::Key, "2026-09-01", "2026-09-02").unwrap(); assert_eq!(report.balances[0].amount, 2.); - assert_eq!(report.balances[0].label, "Key spend only"); - assert!(parse_spend("[]", "Key", "a", "b").is_err()); + assert_eq!( + report.balances[0].label, + Text::message("usage-litellm-key-spend-only") + ); + assert_eq!( + report.sections, + vec![Section::Facts { + title: Text::message("usage-litellm-spend-report"), + facts: vec![( + Text::message("usage-litellm-period"), + Text::message("usage-litellm-period-range") + .arg("start", "2026-09-01") + .arg("end", "2026-09-02") + )], + }] + ); + assert!(parse_spend("[]", Reporter::Key, "a", "b").is_err()); } #[test] diff --git a/crates/herdr-usage/src/providers/llmman.rs b/crates/herdr-usage/src/providers/llmman.rs index 02d9598fa..b7c1b9e32 100644 --- a/crates/herdr-usage/src/providers/llmman.rs +++ b/crates/herdr-usage/src/providers/llmman.rs @@ -15,7 +15,7 @@ //! `LLMMAN_HOST` is read on the probed host, which is this app's environment //! when the host is this machine, as in CodexBar. -use crate::text::{Text, pairs}; +use crate::text::Text; use crate::{ Error, Result, { @@ -201,12 +201,14 @@ pub(crate) fn parse(body: &str, keyed: bool, version: Option) -> Result< let node: Node = json(body)?; let total = |models: &BTreeMap| models.values().sum::(); let in_use = total(&node.loaded); - let count = - |models: &BTreeMap| format!("{} · {}", models.len(), size(total(models))); + // A count and a size, such as `2 · 10.0 GB`: figures only. + let count = |models: &BTreeMap| { + Text::service(format!("{} · {}", models.len(), size(total(models)))) + }; let windows = if node.memory > 0 { vec![Window::new( - Kind::Named("Memory".into()), + Kind::Named(Text::message("usage-llmman-memory")), in_use as f64 / node.memory as f64 * 100., None, None, @@ -216,41 +218,52 @@ pub(crate) fn parse(body: &str, keyed: bool, version: Option) -> Result< }; let mut summary = vec![ - ("Loaded".to_owned(), count(&node.loaded)), - ("Stored".to_owned(), count(&node.stored)), + (Text::message("usage-llmman-loaded"), count(&node.loaded)), + (Text::message("usage-llmman-stored"), count(&node.stored)), ]; if node.memory > 0 { summary.push(( - "Memory".to_owned(), - format!("{} of {}", size(in_use), size(node.memory)), + Text::message("usage-llmman-memory"), + Text::message("usage-llmman-memory-used") + .arg("used", size(in_use)) + .arg("total", size(node.memory)), )); } - summary.extend(version.map(|version| ("Version".to_owned(), version))); + summary.extend(version.map(|version| { + ( + Text::message("usage-llmman-version"), + Text::service(version), + ) + })); let mut loaded: Vec<_> = node.loaded.iter().filter(|(name, _)| label(name)).collect(); loaded.sort_by(|(a, a_size), (b, b_size)| b_size.cmp(a_size).then_with(|| a.cmp(b))); let models: Vec<_> = loaded .into_iter() .take(MODELS) - .map(|(name, weight)| (name.clone(), size(*weight))) + .map(|(name, weight)| (Text::service(name), Text::service(size(*weight)))) .collect(); - let plan = if keyed { "API key" } else { "Local daemon" }; + let plan = if keyed { + Text::message("usage-llmman-api-key") + } else { + Text::message("usage-llmman-local-daemon") + }; let mut sections = vec![Section::Facts { - title: "Daemon".into(), - facts: pairs(summary), + title: Text::message("usage-llmman-daemon"), + facts: summary, }]; if !models.is_empty() { sections.push(Section::Facts { - title: "Loaded models".into(), - facts: pairs(models), + title: Text::message("usage-llmman-loaded-models"), + facts: models, }); } Ok(Report::new( Provider(&Llmman), Account { email: None, - plan: Some(Text::from(plan.to_owned())), + plan: Some(plan), }, windows, ) diff --git a/crates/herdr-usage/src/providers/llmman/tests.rs b/crates/herdr-usage/src/providers/llmman/tests.rs index 0adff5130..8ec207359 100644 --- a/crates/herdr-usage/src/providers/llmman/tests.rs +++ b/crates/herdr-usage/src/providers/llmman/tests.rs @@ -6,15 +6,9 @@ const NODE: &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}}"#; -fn facts(report: &Report, index: usize) -> (&str, Vec<(&str, &str)>) { +fn facts(report: &Report, index: usize) -> (&Text, &[(Text, Text)]) { match &report.sections[index] { - Section::Facts { title, facts } => ( - title.as_str(), - facts - .iter() - .map(|(label, value)| (label.as_str(), value.as_str())) - .collect(), - ), + Section::Facts { title, facts } => (title, facts), section => panic!("unexpected section {section:?}"), } } @@ -23,33 +17,56 @@ fn facts(report: &Report, index: usize) -> (&str, Vec<(&str, &str)>) { fn loaded_weights_fill_the_memory_window_largest_first() { let report = parse(NODE, true, Some("0.9.0".into())).unwrap(); assert_eq!(report.windows.len(), 1); - assert_eq!(report.windows[0].kind, Kind::Named("Memory".into())); + assert_eq!( + report.windows[0].kind, + Kind::Named(Text::message("usage-llmman-memory")) + ); assert_eq!(report.windows[0].percent(), 25); assert_eq!(report.windows[0].resets_at, None); assert_eq!( - report.account.plan.as_ref().map(Text::as_str), - Some("API key") + report.account.plan, + Some(Text::message("usage-llmman-api-key")) ); assert_eq!( facts(&report, 0), ( - "Daemon", - vec![ - ("Loaded", "2 · 10.0 GB"), - ("Stored", "3 · 10.5 GB"), - ("Memory", "10.0 GB of 40.0 GB"), - ("Version", "0.9.0"), - ] + &Text::message("usage-llmman-daemon"), + &[ + ( + Text::message("usage-llmman-loaded"), + Text::service("2 · 10.0 GB") + ), + ( + Text::message("usage-llmman-stored"), + Text::service("3 · 10.5 GB") + ), + ( + Text::message("usage-llmman-memory"), + Text::message("usage-llmman-memory-used") + .arg("used", "10.0 GB") + .arg("total", "40.0 GB") + ), + ( + Text::message("usage-llmman-version"), + Text::service("0.9.0") + ), + ][..] ) ); assert_eq!( facts(&report, 1), ( - "Loaded models", - vec![ - ("fixture/large:Q4_K_M", "8.0 GB"), - ("fixture/small:Q4_K_M", "2.0 GB"), - ] + &Text::message("usage-llmman-loaded-models"), + &[ + ( + Text::service("fixture/large:Q4_K_M"), + Text::service("8.0 GB") + ), + ( + Text::service("fixture/small:Q4_K_M"), + Text::service("2.0 GB") + ), + ][..] ) ); } @@ -59,13 +76,22 @@ fn an_idle_open_daemon_still_reports() { let report = parse(r#"{"memory":0,"loaded":{},"stored":{}}"#, false, None).unwrap(); assert!(report.windows.is_empty()); assert_eq!( - report.account.plan.as_ref().map(Text::as_str), - Some("Local daemon") + report.account.plan, + Some(Text::message("usage-llmman-local-daemon")) ); assert_eq!(report.sections.len(), 1); assert_eq!( facts(&report, 0).1, - [("Loaded", "0 · 0 B"), ("Stored", "0 · 0 B")] + [ + ( + Text::message("usage-llmman-loaded"), + Text::service("0 · 0 B") + ), + ( + Text::message("usage-llmman-stored"), + Text::service("0 · 0 B") + ), + ] ); } diff --git a/crates/herdr-usage/src/providers/llmproxy.rs b/crates/herdr-usage/src/providers/llmproxy.rs index deffd0ee0..9c996ad76 100644 --- a/crates/herdr-usage/src/providers/llmproxy.rs +++ b/crates/herdr-usage/src/providers/llmproxy.rs @@ -3,7 +3,7 @@ //! `LLM_PROXY_API_KEY`, here or on the probed host) at the configured //! `base_url`. Everything CodexBar reads is ported. -use crate::text::{Text, pair}; +use crate::text::Text; use crate::{ Error, Result, { @@ -119,43 +119,65 @@ pub(crate) fn parse(body: &str, now: SystemTime) -> Result { let cost = summary.approx_cost.or((summed > 0.).then_some(summed)); let windows = remaining - .map(|left| Window::new(Kind::Named("Quota".into()), 100. - left, reset, None)) + .map(|left| { + Window::new( + Kind::Named(Text::message("usage-llmproxy-quota")), + 100. - left, + reset, + None, + ) + }) .into_iter() .collect(); let account = Account { email: None, - plan: Some(format!("{active}/{credentials} active keys").into()), + plan: Some( + Text::message("usage-llmproxy-active-keys") + .arg("active", active.to_string()) + .arg("keys", credentials.to_string()), + ), }; let totals = Section::Facts { - title: "Totals".into(), + title: Text::message("usage-llmproxy-totals"), facts: vec![ - ("Requests".into(), Text::from(count(requests))), - ("Tokens".into(), Text::from(count(tokens))), + ( + Text::message("usage-llmproxy-requests"), + Text::service(count(requests)), + ), + ( + Text::message("usage-llmproxy-tokens"), + Text::service(count(tokens)), + ), ], }; let top = (!providers.is_empty()).then(|| Section::Facts { - title: "Providers".into(), + title: Text::message("usage-llmproxy-providers"), facts: providers .iter() .take(TOP) .map(|(name, provider)| { - let mut text = format!( - "{} req · {} tok", - count(provider.requests), - count(provider.tokens) - ); - if let Some(cost) = provider.cost { - text.push_str(&format!(" · ${cost:.2}")); - } - (name.clone(), text) + let (requests, tokens) = (count(provider.requests), count(provider.tokens)); + let text = match provider.cost { + Some(cost) => Text::message("usage-llmproxy-provider-cost") + .arg("requests", requests) + .arg("tokens", tokens) + .arg("cost", format!("${cost:.2}")), + None => Text::message("usage-llmproxy-provider") + .arg("requests", requests) + .arg("tokens", tokens), + }; + (Text::service(name), text) }) - .map(pair) .collect(), }); Ok(Report::new(Provider(&Llmproxy), account, windows) - .with_balances( - cost.map(|cost| Balance::new("Approx. spend", cost, Unit::Currency("USD".into()))), - ) + .with_balances(cost.map(|cost| { + Balance::new( + Text::message("usage-llmproxy-approx-spend"), + cost, + Unit::Currency("USD".into()), + ) + })) .with_sections(std::iter::once(totals).chain(top))) } @@ -268,21 +290,31 @@ mod tests { Timestamp::Text("2026-05-18T12:00:00Z".into()).time() ); assert_eq!( - report.account.plan.as_ref().map(Text::as_str), - Some("3/4 active keys") + report.account.plan, + Some( + Text::message("usage-llmproxy-active-keys") + .arg("active", "3") + .arg("keys", "4") + ) ); assert_eq!(report.balances[0].amount, 15.5); let Section::Facts { facts, .. } = &report.sections[0] else { panic!("expected totals"); }; - assert_eq!(facts[0].1, "160"); - assert_eq!(facts[1].1, "7,000"); + assert_eq!(facts[0].1, Text::service("160")); + assert_eq!(facts[1].1, Text::service("7,000")); let Section::Facts { facts, .. } = &report.sections[1] else { panic!("expected providers"); }; assert_eq!( facts[0], - ("openai".into(), "120 req · 6,000 tok · $12.50".into()) + ( + Text::service("openai"), + Text::message("usage-llmproxy-provider-cost") + .arg("requests", "120") + .arg("tokens", "6,000") + .arg("cost", "$12.50") + ) ); } diff --git a/crates/herdr-usage/src/providers/longcat.rs b/crates/herdr-usage/src/providers/longcat.rs index db4b46e35..bf61acc87 100644 --- a/crates/herdr-usage/src/providers/longcat.rs +++ b/crates/herdr-usage/src/providers/longcat.rs @@ -8,7 +8,7 @@ //! a pasted cURL command in place of the header; here only the header is //! accepted. -use crate::text::{Text, pairs}; +use crate::text::Text; use crate::{ Error, Result, { @@ -203,12 +203,15 @@ fn build( .unwrap_or(0.) .max(0.); windows.push(Window::new( - Kind::Named("Token quota".into()), + Kind::Named(Text::message("usage-longcat-token-quota")), used / total * 100., None, None, )); - facts.push(("Tokens used".to_owned(), format!("{used:.0}/{total:.0}"))); + facts.push(( + Text::message("usage-longcat-tokens-used"), + Text::service(format!("{used:.0}/{total:.0}")), + )); } if let Some(fuel) = fuel { let packages = fuel @@ -231,28 +234,28 @@ fn build( left.iter().sum::() }; windows.push(Window::new( - Kind::Named("Fuel pack".into()), + Kind::Named(Text::message("usage-longcat-fuel-pack")), (total - remaining).max(0.) / total * 100., nearest, None, )); facts.push(( - "Fuel pack left".to_owned(), - format!("{remaining:.0}/{total:.0}"), + Text::message("usage-longcat-fuel-pack-left"), + Text::service(format!("{remaining:.0}/{total:.0}")), )); } } Report::new( Provider(&Longcat), Account { - email: name.map(Text::from), + email: name.map(Text::service), plan: None, }, windows, ) .with_sections((!facts.is_empty()).then(|| Section::Facts { - title: "Quota".into(), - facts: pairs(facts), + title: Text::message("usage-longcat-quota"), + facts, })) } @@ -297,7 +300,13 @@ mod tests { let Section::Facts { facts, .. } = &report.sections[0] else { panic!("expected facts"); }; - assert_eq!(facts[0].1, "1212576/50000000"); + assert_eq!( + facts[0], + ( + Text::message("usage-longcat-tokens-used"), + Text::service("1212576/50000000") + ) + ); } #[test] @@ -309,7 +318,10 @@ mod tests { let report = build(None, None, None, Some(&fuel)); assert_eq!(report.windows.len(), 1); let window = &report.windows[0]; - assert_eq!(window.kind, Kind::Named("Fuel pack".into())); + assert_eq!( + window.kind, + Kind::Named(Text::message("usage-longcat-fuel-pack")) + ); assert_eq!(window.used, 25.); assert_eq!( window.resets_at, diff --git a/crates/herdr-usage/src/providers/manus.rs b/crates/herdr-usage/src/providers/manus.rs index 8a3c119cf..b9e90db31 100644 --- a/crates/herdr-usage/src/providers/manus.rs +++ b/crates/herdr-usage/src/providers/manus.rs @@ -8,7 +8,7 @@ //! one: an explicitly set token is simply tried first. use crate::model::Count; -use crate::text::pairs; +use crate::text::Text; use crate::{ Result, { @@ -88,7 +88,7 @@ pub(crate) fn parse(body: &str) -> Result { let mut windows = Vec::new(); if monthly > 0. { windows.push(Window::new( - Kind::Named("Monthly credits".into()), + Kind::Named(Text::message("usage-manus-monthly-credits")), (monthly - periodic) / monthly * 100., None, None, @@ -97,19 +97,24 @@ pub(crate) fn parse(body: &str) -> Result { if max_refresh > 0. { let reset = credits.next_refresh_time.as_ref().and_then(refresh_time); windows.push(Window::new( - Kind::Named("Daily refresh".into()), + Kind::Named(Text::message("usage-manus-daily-refresh")), (max_refresh - refresh) / max_refresh * 100., reset, None, )); } let count = |value: f64| group(value.round() as i64); - let mut facts = vec![("Free".into(), count(free))]; + let of = |left: f64, total: f64| { + Text::message("usage-manus-left-of") + .arg("left", count(left)) + .arg("total", count(total)) + }; + let mut facts = vec![( + Text::message("usage-manus-free"), + Text::service(count(free)), + )]; if monthly > 0. { - facts.push(( - "Monthly".into(), - format!("{} of {}", count(periodic), count(monthly)), - )); + facts.push((Text::message("usage-manus-monthly"), of(periodic, monthly))); } if max_refresh > 0. { let label = credits @@ -117,17 +122,22 @@ pub(crate) fn parse(body: &str) -> Result { .as_deref() .map(str::trim) .filter(|interval| !interval.is_empty()) - .map_or_else(|| "Refresh".to_owned(), title_case); - facts.push(( - label, - format!("{} of {}", count(refresh), count(max_refresh)), - )); + // The service's own interval, such as `daily`, names it. + .map_or_else( + || Text::message("usage-manus-refresh"), + |interval| Text::service(title_case(interval)), + ); + facts.push((label, of(refresh, max_refresh))); } Ok(Report::new(Provider(&Manus), Account::default(), windows) - .with_balances([Balance::new("Balance", total, Unit::Count(Count::Credits))]) + .with_balances([Balance::new( + Text::message("usage-manus-balance"), + total, + Unit::Count(Count::Credits), + )]) .with_sections([Section::Facts { - title: "Credits".into(), - facts: pairs(facts), + title: Text::message("usage-manus-credits"), + facts, }])) } @@ -222,7 +232,10 @@ mod tests { assert_eq!(report.windows.len(), 2); // Named windows sort by name: "Daily refresh" before "Monthly credits". let monthly = &report.windows[1]; - assert_eq!(monthly.kind, Kind::Named("Monthly credits".into())); + assert_eq!( + monthly.kind, + Kind::Named(Text::message("usage-manus-monthly-credits")) + ); assert_eq!(monthly.percent(), 25); let daily = &report.windows[0]; assert_eq!(daily.percent(), 67); @@ -232,7 +245,28 @@ mod tests { let Section::Facts { facts, .. } = &report.sections[0] else { panic!("facts"); }; - assert!(facts.contains(&("Daily".into(), "100 of 300".into()))); + assert_eq!( + facts, + &[ + (Text::message("usage-manus-free"), Text::service("300")), + ( + Text::message("usage-manus-monthly"), + Text::message("usage-manus-left-of") + .arg("left", "3,000") + .arg("total", "4,000") + ), + ( + Text::service("Daily"), + Text::message("usage-manus-left-of") + .arg("left", "100") + .arg("total", "300") + ), + ] + ); + assert_eq!( + report.balances[0].label, + Text::message("usage-manus-balance") + ); } #[test] @@ -243,6 +277,10 @@ mod tests { assert_eq!(report.balances[0].amount, 12.); let daily = &report.windows[0]; assert_eq!(daily.percent(), 0); + let Section::Facts { facts, .. } = &report.sections[0] else { + panic!("facts"); + }; + assert_eq!(facts[1].0, Text::message("usage-manus-refresh")); assert_eq!( daily.resets_at, Some(SystemTime::UNIX_EPOCH + Duration::from_secs(978_307_200 + 780_000_000)) diff --git a/crates/herdr-usage/src/providers/mimo.rs b/crates/herdr-usage/src/providers/mimo.rs index a1cbf769d..ca5f690aa 100644 --- a/crates/herdr-usage/src/providers/mimo.rs +++ b/crates/herdr-usage/src/providers/mimo.rs @@ -8,7 +8,7 @@ //! separate wrapper script writes to `~/.codexbar/mimo-local-usage.json`. //! That file belongs to CodexBar's own tooling, not to MiMo. -use crate::text::{Text, pairs}; +use crate::text::Text; use crate::{ Error, Result, { @@ -110,21 +110,30 @@ pub(crate) fn parse(balance: &str, detail: Option<&str>, usage: Option<&str>) -> period_end.map(|_| MONTH), ) }); - let money = - |amount: f64| Balance::new("", amount, Unit::Currency(currency.clone())).amount_text(); + let money = |amount: f64| { + Text::service( + Balance::new(Text::service(""), amount, Unit::Currency(currency.clone())).amount_text(), + ) + }; let mut facts = Vec::new(); if let (Some(paid), Some(granted)) = (balance.cash_balance, balance.gift_balance) { - facts.push(("Paid".to_owned(), money(paid))); - facts.push(("Granted".to_owned(), money(granted))); + facts.push((Text::message("usage-mimo-paid"), money(paid))); + facts.push((Text::message("usage-mimo-granted"), money(granted))); } if let Some(item) = &item { facts.push(( - "Token plan".to_owned(), - format!("{} of {} credits", group(item.used), group(item.limit)), + Text::message("usage-mimo-token-plan"), + Text::message("usage-mimo-credits-used") + .arg("used", group(item.used)) + .arg("limit", group(item.limit)) + .arg("count", item.limit), )); } if detail.as_ref().is_some_and(|detail| detail.expired) { - facts.push(("Token plan".to_owned(), "Expired".to_owned())); + facts.push(( + Text::message("usage-mimo-token-plan"), + Text::message("usage-mimo-expired"), + )); } let plan = detail .and_then(|detail| detail.plan_code) @@ -134,14 +143,18 @@ pub(crate) fn parse(balance: &str, detail: Option<&str>, usage: Option<&str>) -> Provider(&Mimo), Account { email: None, - plan: plan.map(Text::from), + plan: plan.map(Text::service), }, window.into_iter().collect(), ) - .with_balances([Balance::new("Balance", amount, Unit::Currency(currency))]) + .with_balances([Balance::new( + Text::message("usage-mimo-balance"), + amount, + Unit::Currency(currency), + )]) .with_sections((!facts.is_empty()).then(|| Section::Facts { - title: "Account".into(), - facts: pairs(facts), + title: Text::message("usage-mimo-account"), + facts, }))) } @@ -205,7 +218,6 @@ struct UsageItem { #[allow(clippy::unwrap_used, clippy::expect_used)] mod tests { use super::*; - use crate::text::pair; use std::time::{Duration, SystemTime}; /// CodexBar's combined-snapshot fixtures. @@ -234,14 +246,23 @@ mod tests { let Section::Facts { facts, .. } = &report.sections[0] else { panic!("expected facts"); }; - assert_eq!(facts[0], pair(("Paid".to_owned(), "$20.00".to_owned()))); - assert_eq!(facts[1], pair(("Granted".to_owned(), "$5.51".to_owned()))); assert_eq!( - facts[2], - pair(( - "Token plan".to_owned(), - "10,100,158 of 200,000,000 credits".to_owned() - )) + facts, + &[ + (Text::message("usage-mimo-paid"), Text::service("$20.00")), + (Text::message("usage-mimo-granted"), Text::service("$5.51")), + ( + Text::message("usage-mimo-token-plan"), + Text::message("usage-mimo-credits-used") + .arg("used", "10,100,158") + .arg("limit", "200,000,000") + .arg("count", 200_000_000) + ), + ] + ); + assert_eq!( + report.balances[0].label, + Text::message("usage-mimo-balance") ); } diff --git a/crates/herdr-usage/src/providers/minimax.rs b/crates/herdr-usage/src/providers/minimax.rs index 7d362068d..ab7b7a64e 100644 --- a/crates/herdr-usage/src/providers/minimax.rs +++ b/crates/herdr-usage/src/providers/minimax.rs @@ -231,34 +231,40 @@ pub(crate) fn parse(body: &str, now: SystemTime) -> Result { Provider(&Minimax), Account { email: None, - plan: plan.map(Text::from), + plan: plan.map(Text::service), }, windows, ) - .with_balances(points.map(|points| Balance::new("Points", points, Unit::Count(Count::Points)))) + .with_balances(points.map(|points| { + Balance::new( + Text::message("usage-minimax-points"), + points, + Unit::Count(Count::Points), + ) + })) .with_sections(others.into_iter().map(Section::Limit))) } /// `general` and `MiniMax-M*` are text generation; the rest are named by -/// what they make. -fn service_name(model: &str) -> String { +/// what they make, or else by the model's own name. +fn service_name(model: &str) -> Text { let lower = model.trim().to_lowercase(); if is_text(model) { - "Text".into() + Text::message("usage-minimax-text") } else if lower == "video" { - "Video".into() + Text::message("usage-minimax-video") } else if lower.contains("speech") { - "Speech".into() + Text::message("usage-minimax-speech") } else if lower.contains("hailuo") && lower.contains("fast") { - "Image to video".into() + Text::message("usage-minimax-image-to-video") } else if lower.contains("hailuo") { - "Text to video".into() + Text::message("usage-minimax-text-to-video") } else if lower.starts_with("image-") { - "Image".into() + Text::message("usage-minimax-image") } else if lower.contains("music") { - "Music".into() + Text::message("usage-minimax-music") } else { - model.trim().to_owned() + Text::service(model.trim()) } } @@ -279,7 +285,7 @@ struct Lane { } impl Lane { - fn window(&self, service: &str, text: bool, weekly: bool, now: SystemTime) -> Option { + fn window(&self, service: &Text, text: bool, weekly: bool, now: SystemTime) -> Option { let full = self.remaining_percent.is_some_and(|left| left >= 100.); let empty = self.total.unwrap_or(0.) == 0. && self.remaining.unwrap_or(0.) == 0.; // Status 3 is a lane the plan does not include, or an unlimited weekly @@ -319,12 +325,14 @@ impl Lane { _ if text && weekly => Kind::Weekly, Some(length) if text && near(length, SESSION) => Kind::Session, Some(length) if near(length, Duration::from_secs(86_400)) => { - Kind::Named(format!("{service} daily").into()) + Kind::Named(Text::message("usage-minimax-daily").arg("service", service.clone())) } - Some(length) => { - Kind::Named(format!("{service} {}h", length.as_secs().div_ceil(3600)).into()) - } - None => Kind::Named(Text::from(service.to_owned())), + Some(length) => Kind::Named( + Text::message("usage-minimax-hours") + .arg("service", service.clone()) + .arg("hours", length.as_secs().div_ceil(3600)), + ), + None => Kind::Named(service.clone()), }; let length = if weekly { length.or(Some(WEEK)) @@ -489,9 +497,40 @@ mod tests { let Section::Limit(video) = &report.sections[0] else { panic!("limit"); }; - assert_eq!(video.kind, Kind::Named("Video daily".into())); + assert_eq!( + video.kind, + Kind::Named( + Text::message("usage-minimax-daily") + .arg("service", Text::message("usage-minimax-video")) + ) + ); assert_eq!(video.percent(), 70); assert_eq!(report.balances[0].amount, 14000.); + assert_eq!( + report.balances[0].label, + Text::message("usage-minimax-points") + ); + } + + #[test] + fn other_lanes_are_named_by_what_they_make_or_their_model() { + let body = r#"{"model_remains": [{"model_name": "speech-2.5", + "current_interval_remaining_percent": 40, + "start_time": 1780243200000, "end_time": 1780257600000}, + {"model_name": "custom-model", "current_interval_remaining_percent": 10}]}"#; + let report = parse(body, at(1_780_250_000)).unwrap(); + assert_eq!( + report.windows[0].kind, + Kind::Named( + Text::message("usage-minimax-hours") + .arg("service", Text::message("usage-minimax-speech")) + .arg("hours", 4) + ) + ); + let Section::Limit(custom) = &report.sections[0] else { + panic!("limit"); + }; + assert_eq!(custom.kind, Kind::Named(Text::service("custom-model"))); } #[test] diff --git a/crates/herdr-usage/src/providers/mistral.rs b/crates/herdr-usage/src/providers/mistral.rs index 0f064e2a9..2bb07203d 100644 --- a/crates/herdr-usage/src/providers/mistral.rs +++ b/crates/herdr-usage/src/providers/mistral.rs @@ -9,7 +9,7 @@ //! allowance still shows when the subscription page reports it. Daily usage //! buckets for CodexBar's cost chart are left out. -use crate::text::{Text, pairs}; +use crate::text::Text; use crate::{ Result, { @@ -139,7 +139,7 @@ pub(crate) fn parse( .unwrap_or_else(|| "EUR".into()); // A negative total is a refund adjustment, shown as nothing spent. let mut balances = vec![Balance::new( - "API spend this month", + Text::message("usage-mistral-api-spend"), totals.cost.max(0.), Unit::Currency(currency), )]; @@ -149,58 +149,89 @@ pub(crate) fn parse( - credits.ongoing_usage_balance.unwrap_or(0.); if available.is_finite() { let unit = Unit::Currency(credits.currency.trim().to_uppercase()); - balances.push(Balance::new("Credits", available.max(0.), unit)); + balances.push(Balance::new( + Text::message("usage-mistral-credits"), + available.max(0.), + unit, + )); } } let budgets = subscription.map(budgets).unwrap_or_default(); let mut windows = Vec::new(); let mut allowances = Vec::new(); - for (title, budget) in [("Included API", budgets.api), ("Vibe", budgets.vibe)] { + // Vibe is the product's name. + let lanes = [ + (Text::message("usage-mistral-included-api"), budgets.api), + (Text::service("Vibe"), budgets.vibe), + ]; + for (title, budget) in lanes { let Some(budget) = budget else { continue }; windows.push(Window::new( - Kind::Named(title.into()), + Kind::Named(title.clone()), budget.usage_percentage, budget.resets_at, Some(MONTH), )); let used = budget.limit * budget.usage_percentage / 100.; - let text = Balance::new(title, used, Unit::Currency(budget.currency)) - .out_of(budget.limit) - .text(); - allowances.push((title.to_owned(), format!("{text} used"))); + let money = |amount: f64| { + Balance::new( + Text::service(""), + amount, + Unit::Currency(budget.currency.clone()), + ) + .amount_text() + }; + allowances.push(( + title, + Text::message("usage-mistral-allowance-used") + .arg("used", money(used)) + .arg("limit", money(budget.limit)), + )); } let mut sections = Vec::new(); if !allowances.is_empty() { sections.push(Section::Facts { - title: "Allowances".into(), - facts: pairs(allowances), + title: Text::message("usage-mistral-allowances"), + facts: allowances, }); } let tokens = |value: f64| group(value.round() as i64); sections.push(Section::Facts { - title: "Tokens this month".into(), + title: Text::message("usage-mistral-tokens-this-month"), facts: vec![ - ("Input".into(), Text::from(tokens(totals.input))), - ("Output".into(), Text::from(tokens(totals.output))), - ("Cached".into(), Text::from(tokens(totals.cached))), - ("Models".into(), Text::from(totals.models.to_string())), + ( + Text::message("usage-mistral-input"), + Text::service(tokens(totals.input)), + ), + ( + Text::message("usage-mistral-output"), + Text::service(tokens(totals.output)), + ), + ( + Text::message("usage-mistral-cached"), + Text::service(tokens(totals.cached)), + ), + ( + Text::message("usage-mistral-models"), + Text::service(totals.models.to_string()), + ), ], }); if totals.cost > 0. { - let mut shares: Vec<(String, f32)> = totals + let mut shares: Vec<(Text, f32)> = totals .by_model .into_iter() .filter(|(_, cost)| *cost > 0.) - .map(|(name, cost)| (name, (cost / totals.cost * 100.) as f32)) + .map(|(name, cost)| (Text::service(name), (cost / totals.cost * 100.) as f32)) .collect(); shares.sort_by(|a, b| b.1.total_cmp(&a.1)); shares.truncate(5); if !shares.is_empty() { sections.push(Section::Shares { - title: "Spend by model".into(), - shares: crate::text::shares(shares), + title: Text::message("usage-mistral-spend-by-model"), + shares, }); } } @@ -452,7 +483,6 @@ struct Credits { #[allow(clippy::unwrap_used, clippy::expect_used)] mod tests { use super::*; - use crate::text::pair; const NOVEMBER: &str = r#"{"completion":{"models":{"mistral-large-latest::mistral-large-2411":{"input":[{"usage_type":"usage","event_type":"api_tokens","billing_metric":"mistral-large-2411","billing_display_name":"mistral-large-latest","billing_group":"input","timestamp":"2025-11-14","value":11121,"value_paid":11121}],"output":[{"usage_type":"usage","event_type":"api_tokens","billing_metric":"mistral-large-2411","billing_display_name":"mistral-large-latest","billing_group":"output","timestamp":"2025-11-14","value":1115,"value_paid":1115}]},"mistral-small-latest::mistral-small-2506":{"input":[{"usage_type":"usage","event_type":"api_tokens","billing_metric":"mistral-small-2506","billing_display_name":"mistral-small-latest","billing_group":"input","timestamp":"2025-11-14","value":20,"value_paid":20},{"usage_type":"usage","event_type":"api_tokens","billing_metric":"mistral-small-2506","billing_display_name":"mistral-small-latest","billing_group":"input","timestamp":"2025-11-24","value":100,"value_paid":100}],"output":[{"usage_type":"usage","event_type":"api_tokens","billing_metric":"mistral-small-2506","billing_display_name":"mistral-small-latest","billing_group":"output","timestamp":"2025-11-14","value":500,"value_paid":500},{"usage_type":"usage","event_type":"api_tokens","billing_metric":"mistral-small-2506","billing_display_name":"mistral-small-latest","billing_group":"output","timestamp":"2025-11-24","value":2482,"value_paid":2482}]}}},"ocr":{"models":{}},"connectors":{"models":{}},"libraries_api":{"pages":{"models":{}},"tokens":{"models":{}}},"fine_tuning":{"training":{},"storage":{}},"audio":{"models":{}},"vibe_usage":0.0,"date":"2025-11-01T00:00:00Z","previous_month":"2025-10","next_month":"2025-12","start_date":"2025-11-01T00:00:00Z","end_date":"2025-11-30T23:59:59.999Z","currency":"EUR","currency_symbol":"€","prices":[{"event_type":"api_tokens","billing_metric":"mistral-large-2411","billing_group":"input","price":"0.0000017000"},{"event_type":"api_tokens","billing_metric":"mistral-large-2411","billing_group":"output","price":"0.0000051000"},{"event_type":"api_tokens","billing_metric":"mistral-small-2506","billing_group":"input","price":"8.50E-8"},{"event_type":"api_tokens","billing_metric":"mistral-small-2506","billing_group":"output","price":"2.550E-7"}]}"#; @@ -470,20 +500,34 @@ mod tests { fn billing_usage_prices_tokens() { let report = parse(NOVEMBER, None, None).unwrap(); let expected = 0.0189057 + 0.0056865 + 0.0000102 + 0.00076041; - assert_eq!(report.balances[0].label, "API spend this month"); + assert_eq!( + report.balances[0].label, + Text::message("usage-mistral-api-spend") + ); assert!((report.balances[0].amount - expected).abs() < 1e-9); assert_eq!(report.balances[0].unit, Unit::Currency("EUR".into())); let Some(Section::Facts { facts, .. }) = report.sections.first() else { panic!("expected token facts"); }; - assert_eq!(facts[0], pair(("Input".to_owned(), "11,241".to_owned()))); - assert_eq!(facts[1], pair(("Output".to_owned(), "4,097".to_owned()))); - assert_eq!(facts[2], pair(("Cached".to_owned(), "0".to_owned()))); - assert_eq!(facts[3], pair(("Models".to_owned(), "2".to_owned()))); + assert_eq!( + facts, + &[ + ( + Text::message("usage-mistral-input"), + Text::service("11,241") + ), + ( + Text::message("usage-mistral-output"), + Text::service("4,097") + ), + (Text::message("usage-mistral-cached"), Text::service("0")), + (Text::message("usage-mistral-models"), Text::service("2")), + ] + ); let Some(Section::Shares { shares, .. }) = report.sections.get(1) else { panic!("expected model shares"); }; - assert_eq!(shares[0].0, "mistral-large-latest"); + assert_eq!(shares[0].0, Text::service("mistral-large-latest")); assert!(report.windows.is_empty()); } @@ -495,20 +539,36 @@ mod tests { "ongoing_usage_balance": 2.5, "currency": "eur"}"#; let report = parse(NOVEMBER, Some(credits), Some(&html)).unwrap(); let api = &report.windows[0]; - assert_eq!(api.kind, Kind::Named("Included API".into())); + assert_eq!( + api.kind, + Kind::Named(Text::message("usage-mistral-included-api")) + ); assert!((api.used - 2.).abs() < 1e-4); assert!(api.resets_at.is_some()); assert_eq!(api.length, Some(MONTH)); - assert_eq!(report.windows[1].kind, Kind::Named("Vibe".into())); + assert_eq!(report.windows[1].kind, Kind::Named(Text::service("Vibe"))); assert_eq!( report.balances[1], - Balance::new("Credits", 12.5, Unit::Currency("EUR".into())) + Balance::new( + Text::message("usage-mistral-credits"), + 12.5, + Unit::Currency("EUR".into()) + ) ); let Some(Section::Facts { title, facts }) = report.sections.first() else { panic!("expected allowance facts"); }; - assert_eq!(title, "Allowances"); - assert_eq!(facts[0].1, "0.51 EUR of 25.50 EUR used"); + assert_eq!(title, &Text::message("usage-mistral-allowances")); + assert_eq!( + facts[0], + ( + Text::message("usage-mistral-included-api"), + Text::message("usage-mistral-allowance-used") + .arg("used", "0.51 EUR") + .arg("limit", "25.50 EUR") + ) + ); + assert_eq!(facts[1].0, Text::service("Vibe")); } #[test] diff --git a/crates/herdr-usage/src/providers/moonshot.rs b/crates/herdr-usage/src/providers/moonshot.rs index 4e550d90b..438d587c0 100644 --- a/crates/herdr-usage/src/providers/moonshot.rs +++ b/crates/herdr-usage/src/providers/moonshot.rs @@ -7,7 +7,7 @@ //! default, or China mainland (`api.moonshot.cn`, CNY) with `region = //! "china"`. Kimi Code subscriptions are a separate provider (`kimi`). -use crate::text::pairs; +use crate::text::Text; use crate::{ Result, { @@ -97,20 +97,34 @@ pub(crate) fn parse(body: &str, region: Region) -> Result { } let data = response.data; let unit = || Unit::Currency(region.currency().into()); - let text = |amount: f64| Balance::new("", amount, unit()).amount_text(); + let text = + |amount: f64| Text::service(Balance::new(Text::service(""), amount, unit()).amount_text()); let mut facts = vec![ - ("Voucher".to_owned(), text(data.voucher_balance)), - ("Cash".to_owned(), text(data.cash_balance)), + ( + Text::message("usage-moonshot-voucher"), + text(data.voucher_balance), + ), + ( + Text::message("usage-moonshot-cash"), + text(data.cash_balance), + ), ]; if data.cash_balance < 0. { - facts.push(("In deficit".to_owned(), text(data.cash_balance.abs()))); + facts.push(( + Text::message("usage-moonshot-in-deficit"), + text(data.cash_balance.abs()), + )); } Ok( Report::new(Provider(&Moonshot), Account::default(), Vec::new()) - .with_balances([Balance::new("Available", data.available_balance, unit())]) + .with_balances([Balance::new( + Text::message("usage-moonshot-available"), + data.available_balance, + unit(), + )]) .with_sections([Section::Facts { - title: "Balance".into(), - facts: pairs(facts), + title: Text::message("usage-moonshot-balance"), + facts, }]), ) } @@ -144,16 +158,22 @@ mod tests { let report = parse(FIXTURE, Region::International).unwrap(); assert!(report.windows.is_empty()); assert_eq!(report.balances.len(), 1); - assert_eq!(report.balances[0].label, "Available"); + assert_eq!( + report.balances[0].label, + Text::message("usage-moonshot-available") + ); assert!((report.balances[0].amount - 49.58894).abs() < 1e-9); assert_eq!(report.balances[0].unit, Unit::Currency("USD".into())); assert_eq!( report.sections, vec![Section::Facts { - title: "Balance".into(), + title: Text::message("usage-moonshot-balance"), facts: vec![ - ("Voucher".into(), "$46.59".into()), - ("Cash".into(), "$3.00".into()), + ( + Text::message("usage-moonshot-voucher"), + Text::service("$46.59") + ), + (Text::message("usage-moonshot-cash"), Text::service("$3.00")), ], }] ); @@ -170,7 +190,13 @@ mod tests { let Section::Facts { facts, .. } = &report.sections[0] else { panic!("facts expected"); }; - assert_eq!(facts[2], ("In deficit".into(), "$1.50".into())); + assert_eq!( + facts[2], + ( + Text::message("usage-moonshot-in-deficit"), + Text::service("$1.50") + ) + ); } #[test] diff --git a/crates/herdr-usage/src/providers/muse.rs b/crates/herdr-usage/src/providers/muse.rs index 9802d175a..65cf0458e 100644 --- a/crates/herdr-usage/src/providers/muse.rs +++ b/crates/herdr-usage/src/providers/muse.rs @@ -83,23 +83,23 @@ pub(crate) fn parse(body: &str) -> Result { .map(|email| email.trim().to_owned()) .filter(|email| !email.is_empty()); let account = Account { - email: email.map(Text::from), - plan: plan.or_else(|| Some("Muse login".into())).map(Text::from), + email: email.map(Text::service), + plan: Some(plan.map_or_else(|| Text::message("usage-muse-login"), Text::service)), }; let status = if key.require_payment == Some(true) { - Some("Payment method required; finish billing at https://dev.meta.ai") + Some(Text::message("usage-muse-payment-required")) } else if key.is_subs_active != Some(true) { - Some("No active subscription on this login") + Some(Text::message("usage-muse-no-subscription")) } else if key.subs_usage.is_none() { - Some("Quota not included in this login response") + Some(Text::message("usage-muse-no-quota")) } else { None }; if let Some(status) = status { return Ok( Report::new(Provider(&Muse), account, Vec::new()).with_sections([Section::Facts { - title: "Subscription".into(), - facts: vec![("Status".into(), status.into())], + title: Text::message("usage-muse-subscription"), + facts: vec![(Text::message("usage-muse-status"), status)], }]), ); } @@ -113,7 +113,7 @@ pub(crate) fn parse(body: &str) -> Result { let kind = if length == SESSION { Kind::Session } else { - Kind::Named(format!("{} hours", minutes / 60.).into()) + Kind::Named(Text::message("usage-muse-hours").arg("hours", minutes / 60.)) }; windows.push(Window::new( kind, @@ -217,10 +217,33 @@ mod tests { report.account.plan.as_ref().map(Text::as_str), Some("Muse Code Power Usage") ); - assert_eq!(report.sections.len(), 1); + assert_eq!( + report.sections, + vec![Section::Facts { + title: Text::message("usage-muse-subscription"), + facts: vec![( + Text::message("usage-muse-status"), + Text::message("usage-muse-no-quota") + )], + }] + ); } } + #[test] + fn another_rolling_window_is_named_by_its_hours() { + let body = ACCOUNT.replace( + "\"window_duration_mins\":300", + "\"window_duration_mins\":120", + ); + let report = parse(&body).unwrap(); + // Named windows sort after the weekly one. + assert_eq!( + report.windows[1].kind, + Kind::Named(Text::message("usage-muse-hours").arg("hours", 2.)) + ); + } + #[test] fn inactive_or_unpaid_invents_no_quota() { for body in [ @@ -229,6 +252,17 @@ mod tests { ] { assert!(parse(body).unwrap().windows.is_empty()); } + let unpaid = parse(r#"{"require_payment":true,"is_subs_active":false}"#).unwrap(); + assert_eq!(unpaid.account.plan, Some(Text::message("usage-muse-login"))); + let Section::Facts { facts, .. } = &unpaid.sections[0] else { + panic!("facts expected"); + }; + assert_eq!(facts[0].1, Text::message("usage-muse-payment-required")); + let inactive = parse(r#"{"is_subs_active":false}"#).unwrap(); + let Section::Facts { facts, .. } = &inactive.sections[0] else { + panic!("facts expected"); + }; + assert_eq!(facts[0].1, Text::message("usage-muse-no-subscription")); } #[test] diff --git a/crates/herdr-usage/src/providers/neuralwatt.rs b/crates/herdr-usage/src/providers/neuralwatt.rs index b32f18f5a..723426314 100644 --- a/crates/herdr-usage/src/providers/neuralwatt.rs +++ b/crates/herdr-usage/src/providers/neuralwatt.rs @@ -5,7 +5,7 @@ //! credits (which never reset) are a balance, and a per-key spending //! allowance is an extra limit. Everything CodexBar reads is ported. -use crate::text::{Text, pairs}; +use crate::text::Text; use crate::{ Result, { @@ -97,7 +97,7 @@ fn parse(body: &str) -> Result { .and_then(|at| Timestamp::Text(at).time()); let kind = match subscription.billing_interval.as_deref() { Some(interval) if interval.to_lowercase().starts_with("year") => { - Kind::Named("Yearly".into()) + Kind::Named(Text::message("usage-neuralwatt-yearly")) } _ => Kind::Monthly, }; @@ -110,8 +110,10 @@ fn parse(body: &str) -> Result { let windows: Vec = match (consumed, included) { (Some(consumed), Some(included)) => { facts.push(( - "Energy".to_owned(), - format!("{} / {} kWh", plain(consumed), plain(included)), + Text::message("usage-neuralwatt-energy"), + Text::message("usage-neuralwatt-energy-used") + .arg("used", plain(consumed)) + .arg("included", plain(included)), )); vec![Window::new(kind, consumed / included * 100., end, length)] } @@ -128,32 +130,31 @@ fn parse(body: &str) -> Result { .zip(positive(allowance.limit_usd)) .map(|(spent, limit)| spent / limit * 100.) }; - let allowance = allowance_used.map(|used| { - Section::Limit(Window::new( - Kind::Named( - format!( - "Key {}", - words(allowance.period.as_deref().unwrap_or("allowance")) - ) - .into(), - ), - used, - None, - None, - )) - }); + // The period is the service's word, such as `monthly`. + let name = match allowance.period.as_deref() { + Some(period) => Text::message("usage-neuralwatt-key-period").arg("period", words(period)), + None => Text::message("usage-neuralwatt-key-allowance"), + }; + let allowance = + allowance_used.map(|used| Section::Limit(Window::new(Kind::Named(name), used, None, None))); if let Some(month) = quota.usage.and_then(|usage| usage.current_month) { - let mut spend = format!("${:.2}", month.cost_usd.unwrap_or(0.)); - if let Some(requests) = month.requests { - spend.push_str(&format!(" · {} requests", group(requests))); - } - facts.push(("This month".into(), spend)); + let cost = format!("${:.2}", month.cost_usd.unwrap_or(0.)); + let spend = match month.requests { + Some(requests) => Text::message("usage-neuralwatt-month-requests") + .arg("cost", cost) + .arg("requests", group(requests)) + .arg("count", requests), + None => Text::service(cost), + }; + facts.push((Text::message("usage-neuralwatt-this-month"), spend)); } if let Some(total) = total { facts.push(( - "Credits used".into(), - format!("${:.2} of ${total:.2}", used.unwrap_or(0.)), + Text::message("usage-neuralwatt-credits-used"), + Text::message("usage-neuralwatt-credits-of") + .arg("used", format!("${:.2}", used.unwrap_or(0.))) + .arg("total", format!("${total:.2}")), )); } @@ -161,25 +162,31 @@ fn parse(body: &str) -> Result { .plan .map(|plan| plan.trim().replace('_', " ")) .filter(|plan| !plan.is_empty()) - .map(|plan| format!("{} plan", words(&plan))) - .or_else(|| balance.accounting_method.map(|method| words(&method))); + .map(|plan| Text::message("usage-neuralwatt-plan").arg("plan", words(&plan))) + // The accounting method is the service's word, such as `energy`. + .or_else(|| { + balance + .accounting_method + .map(|method| Text::service(words(&method))) + }); Ok(Report::new( Provider(&Neuralwatt), - Account { - email: None, - plan: plan.map(Text::from), - }, + Account { email: None, plan }, windows, ) - .with_balances( - remaining.map(|left| Balance::new("Prepaid credits", left, Unit::Currency("USD".into()))), - ) + .with_balances(remaining.map(|left| { + Balance::new( + Text::message("usage-neuralwatt-prepaid-credits"), + left, + Unit::Currency("USD".into()), + ) + })) .with_sections( allowance .into_iter() .chain((!facts.is_empty()).then(|| Section::Facts { - title: "Usage".into(), - facts: pairs(facts), + title: Text::message("usage-report-usage"), + facts, })), )) } @@ -270,8 +277,8 @@ mod tests { ) .unwrap(); assert_eq!( - report.account.plan.as_ref().map(Text::as_str), - Some("Standard plan") + report.account.plan, + Some(Text::message("usage-neuralwatt-plan").arg("plan", "Standard")) ); let window = &report.windows[0]; assert_eq!(window.kind, Kind::Monthly); @@ -285,14 +292,42 @@ mod tests { let Section::Limit(allowance) = &report.sections[0] else { panic!("expected the key allowance"); }; - assert_eq!(allowance.kind, Kind::Named("Key Monthly".into())); + assert_eq!( + allowance.kind, + Kind::Named(Text::message("usage-neuralwatt-key-period").arg("period", "Monthly")) + ); assert_eq!(allowance.used, 25.); let Section::Facts { facts, .. } = &report.sections[1] else { panic!("expected facts"); }; - assert!(facts.contains(&("Energy".into(), "13.90 / 20 kWh".into()))); - assert!(facts.contains(&("This month".into(), "$4.20 · 1,234 requests".into()))); - assert!(facts.contains(&("Credits used".into(), "$17.32 of $50.00".into()))); + assert_eq!( + facts, + &[ + ( + Text::message("usage-neuralwatt-energy"), + Text::message("usage-neuralwatt-energy-used") + .arg("used", "13.90") + .arg("included", "20") + ), + ( + Text::message("usage-neuralwatt-this-month"), + Text::message("usage-neuralwatt-month-requests") + .arg("cost", "$4.20") + .arg("requests", "1,234") + .arg("count", 1234) + ), + ( + Text::message("usage-neuralwatt-credits-used"), + Text::message("usage-neuralwatt-credits-of") + .arg("used", "$17.32") + .arg("total", "$50.00") + ), + ] + ); + assert_eq!( + report.balances[0].label, + Text::message("usage-neuralwatt-prepaid-credits") + ); } #[test] @@ -303,10 +338,7 @@ mod tests { ) .unwrap(); assert!(report.windows.is_empty()); - assert_eq!( - report.account.plan.as_ref().map(Text::as_str), - Some("Token") - ); + assert_eq!(report.account.plan, Some(Text::service("Token"))); assert_eq!(report.balances[0].text(), "$51.00"); } @@ -335,6 +367,18 @@ mod tests { panic!("expected the key allowance"); }; assert_eq!(allowance.used, 100.); + let unnamed = parse( + r#"{"balance": {"credits_remaining_usd": 1}, + "key": {"allowance": {"blocked": true}}}"#, + ) + .unwrap(); + let Section::Limit(allowance) = &unnamed.sections[0] else { + panic!("expected the key allowance"); + }; + assert_eq!( + allowance.kind, + Kind::Named(Text::message("usage-neuralwatt-key-allowance")) + ); } #[test] diff --git a/crates/herdr-usage/src/providers/notion.rs b/crates/herdr-usage/src/providers/notion.rs index 088e0b161..d30dc89ac 100644 --- a/crates/herdr-usage/src/providers/notion.rs +++ b/crates/herdr-usage/src/providers/notion.rs @@ -10,7 +10,7 @@ //! and CodexBar's persisted session cache. Allowances only exist on Business //! and Enterprise workspaces; others show that instead of windows. -use crate::text::{Text, pairs}; +use crate::text::Text; use crate::{ Result, { @@ -231,7 +231,7 @@ pub(crate) fn parse( let length = window.window.as_deref().and_then(window_length); let kind = match length { Some(length) if length <= ROLLING => Kind::Session, - _ => Kind::Named("Rolling".into()), + _ => Kind::Named(Text::message("usage-notion-rolling")), }; let resets_at = status .resets_in_seconds @@ -253,18 +253,21 @@ pub(crate) fn parse( Some(resets_at.and_then(calendar_month).unwrap_or(MONTH)), )); } - let mut facts: Vec<(String, String)> = Vec::new(); + let mut facts = Vec::new(); if let Some(name) = workspace .name .as_deref() .filter(|name| !name.trim().is_empty()) { - facts.push(("Workspace".into(), name.trim().to_owned())); + facts.push(( + Text::message("usage-notion-workspace"), + Text::service(name.trim()), + )); } if not_applicable { facts.push(( - "Allowance".into(), - "Not tracked; allowances apply to Business and Enterprise workspaces".into(), + Text::message("usage-notion-allowance"), + Text::message("usage-notion-not-tracked"), )); } if let Some(enforcement) = status @@ -272,22 +275,27 @@ pub(crate) fn parse( .as_deref() .filter(|enforcement| !enforcement.trim().is_empty()) { - facts.push(("Enforcement".into(), title_case(enforcement))); + // Notion's own word, such as `preview`. + facts.push(( + Text::message("usage-notion-enforcement"), + Text::service(title_case(enforcement)), + )); } let account = Account { - email: email.map(Text::from), + email: email.map(Text::service), plan: (workspace .tier .as_deref() .map(title_case) .filter(|tier| !tier.is_empty())) - .map(Text::from), + .map(Text::service), }; Ok( Report::new(Provider(&Notion), account, windows).with_sections((!facts.is_empty()).then( || Section::Facts { - title: "Notion AI".into(), - facts: pairs(facts), + // The product's name. + title: Text::service("Notion AI"), + facts, }, )), ) @@ -433,6 +441,29 @@ mod tests { ); // 2026-08-29 back to 2026-07-29 is 31 days. assert_eq!(monthly.length, Some(Duration::from_secs(31 * 86_400))); + let Section::Facts { facts, .. } = &report.sections[0] else { + panic!("expected facts"); + }; + assert_eq!( + facts, + &[ + ( + Text::message("usage-notion-workspace"), + Text::service("Acme") + ), + ( + Text::message("usage-notion-enforcement"), + Text::service("Preview") + ), + ] + ); + let daily = STATUS.replace(r#""window": "6h""#, r#""window": "1d""#); + let report = parse(&daily, None, &workspace, now).unwrap(); + // Named windows sort after the monthly one. + assert_eq!( + report.windows[1].kind, + Kind::Named(Text::message("usage-notion-rolling")) + ); } #[test] @@ -453,7 +484,19 @@ mod tests { let Section::Facts { facts, .. } = &report.sections[0] else { panic!("expected facts"); }; - assert_eq!(facts[1].0, "Allowance"); + assert_eq!( + facts, + &[ + ( + Text::message("usage-notion-workspace"), + Text::service("Home") + ), + ( + Text::message("usage-notion-allowance"), + Text::message("usage-notion-not-tracked") + ), + ] + ); } #[test] diff --git a/crates/herdr-usage/src/providers/nous.rs b/crates/herdr-usage/src/providers/nous.rs index 30b6b6954..134edabe2 100644 --- a/crates/herdr-usage/src/providers/nous.rs +++ b/crates/herdr-usage/src/providers/nous.rs @@ -11,7 +11,7 @@ //! which would need the token revealed; `expires_at` stored by Hermes is //! still honored. -use crate::text::{Text, pairs}; +use crate::text::Text; use crate::{ Error, Result, { @@ -232,45 +232,70 @@ pub(crate) fn parse(body: &str) -> Result { let usd = || Unit::Currency("USD".into()); let mut balances = Vec::new(); match (remaining, grant) { - (Some(remaining), Some(grant)) => balances - .push(Balance::new("Subscription credits", remaining.max(0.), usd()).out_of(grant)), + (Some(remaining), Some(grant)) => balances.push( + Balance::new( + Text::message("usage-nous-subscription-credits"), + remaining.max(0.), + usd(), + ) + .out_of(grant), + ), (Some(remaining), None) => { balances.push(Balance::new( - "Subscription credits", + Text::message("usage-nous-subscription-credits"), remaining.max(0.), usd(), )); } - (None, Some(grant)) => balances.push(Balance::new("Monthly grant", grant, usd())), + (None, Some(grant)) => balances.push(Balance::new( + Text::message("usage-nous-monthly-grant"), + grant, + usd(), + )), (None, None) => {} } - balances.extend(purchased.map(|amount| Balance::new("Top-up credits", amount, usd()))); - balances.extend(total.map(|amount| Balance::new("Total usable", amount, usd()))); - let mut facts: Vec<(String, String)> = Vec::new(); + balances.extend( + purchased + .map(|amount| Balance::new(Text::message("usage-nous-top-up-credits"), amount, usd())), + ); + balances.extend( + total.map(|amount| Balance::new(Text::message("usage-nous-total-usable"), amount, usd())), + ); + let mut facts = Vec::new(); if let Some(organization) = account .organisation .and_then(|organization| organization.name) .filter(|name| !name.trim().is_empty()) { - facts.push(("Organization".into(), organization.trim().to_owned())); + facts.push(( + Text::message("usage-nous-organization"), + Text::service(organization.trim()), + )); } if let Some(rollover) = rollover.filter(|rollover| *rollover > 0.) { - facts.push(("Rollover credits".into(), format!("${rollover:.2}"))); + facts.push(( + Text::message("usage-nous-rollover-credits"), + Text::service(format!("${rollover:.2}")), + )); } if let Some(renews) = renews { facts.push(( - "Renews".into(), - chrono::DateTime::::from(renews) - .format("%b %-d") - .to_string(), + Text::message("usage-nous-renews"), + Text::service( + chrono::DateTime::::from(renews) + .format("%b %-d") + .to_string(), + ), )); } let plan = subscription .plan .map(|plan| plan.trim().to_owned()) .filter(|plan| !plan.is_empty()) + .map(Text::service) .or_else(|| { - (access.has_active_subscription == Some(true)).then(|| "Subscription".to_owned()) + (access.has_active_subscription == Some(true)) + .then(|| Text::message("usage-nous-subscription")) }); let email = account .user @@ -280,15 +305,15 @@ pub(crate) fn parse(body: &str) -> Result { Ok(Report::new( Provider(&Nous), Account { - email: email.map(Text::from), - plan: plan.map(Text::from), + email: email.map(Text::service), + plan, }, windows, ) .with_balances(balances) .with_sections((!facts.is_empty()).then(|| Section::Facts { - title: "Subscription".into(), - facts: pairs(facts), + title: Text::message("usage-nous-subscription"), + facts, }))) } @@ -381,21 +406,33 @@ mod tests { let balances: Vec<_> = report .balances .iter() - .map(|balance| (balance.label.as_str(), balance.amount, balance.total)) + .map(|balance| (balance.label.clone(), balance.amount, balance.total)) .collect(); assert_eq!( balances, [ - ("Subscription credits", 55., Some(220.)), - ("Top-up credits", 19.25, None), - ("Total usable", 74.25, None), + ( + Text::message("usage-nous-subscription-credits"), + 55., + Some(220.) + ), + (Text::message("usage-nous-top-up-credits"), 19.25, None), + (Text::message("usage-nous-total-usable"), 74.25, None), ] ); let Section::Facts { facts, .. } = &report.sections[0] else { panic!("expected facts"); }; - assert_eq!(facts[0], ("Organization".into(), "Example account".into())); - assert_eq!(facts[1], ("Renews".into(), "Oct 12".into())); + assert_eq!( + facts, + &[ + ( + Text::message("usage-nous-organization"), + Text::service("Example account") + ), + (Text::message("usage-nous-renews"), Text::service("Oct 12")), + ] + ); } #[test] @@ -412,7 +449,29 @@ mod tests { fn grant_without_remaining_has_no_meter() { let report = parse(r#"{"subscription":{"plan":"Ultra","monthly_credits":220}}"#).unwrap(); assert!(report.windows.is_empty()); - assert_eq!(report.balances[0].label, "Monthly grant"); + assert_eq!( + report.balances[0].label, + Text::message("usage-nous-monthly-grant") + ); + let unnamed = parse( + r#"{"subscription":{"rollover_credits":2}, + "paid_service_access":{"has_active_subscription":true}}"#, + ) + .unwrap(); + assert_eq!( + unnamed.account.plan, + Some(Text::message("usage-nous-subscription")) + ); + let Section::Facts { facts, .. } = &unnamed.sections[0] else { + panic!("expected facts"); + }; + assert_eq!( + facts, + &[( + Text::message("usage-nous-rollover-credits"), + Text::service("$2.00") + )] + ); } #[test] diff --git a/crates/herdr-usage/src/providers/ollama.rs b/crates/herdr-usage/src/providers/ollama.rs index a91a3be5f..c737b1639 100644 --- a/crates/herdr-usage/src/providers/ollama.rs +++ b/crates/herdr-usage/src/providers/ollama.rs @@ -133,13 +133,17 @@ pub(crate) fn parse_tags(body: &str) -> Result { Provider(&Ollama), Account { email: None, - plan: Some("API key".into()), + plan: Some(Text::message("usage-ollama-api-key")), }, Vec::new(), ) .with_sections([Section::Facts { - title: "Ollama Cloud".into(), - facts: vec![("Models".into(), Text::from(tags.models.len().to_string()))], + // The product's name. + title: Text::service("Ollama Cloud"), + facts: vec![( + Text::message("usage-ollama-models"), + Text::service(tags.models.len().to_string()), + )], }])) } @@ -158,7 +162,7 @@ pub(crate) fn parse(html: &str) -> Result { block(html, HOURLY_LABEL).map(|block| { ( block, - Kind::Named("Hourly".into()), + Kind::Named(Text::message("usage-ollama-hourly")), Duration::from_secs(3600), ) }) @@ -181,8 +185,8 @@ pub(crate) fn parse(html: &str) -> Result { .map(|((used, resets_at), kind, length)| Window::new(kind, used, resets_at, Some(length))) .collect(); let account = Account { - email: email(html).map(Text::from), - plan: plan(html).map(|plan| title_case(&plan)).map(Text::from), + email: email(html).map(Text::service), + plan: plan(html).map(|plan| Text::service(title_case(&plan))), }; Ok(Report::new(Provider(&Ollama), account, windows)) } @@ -386,7 +390,6 @@ fn signed_out(html: &str) -> bool { #[allow(clippy::unwrap_used, clippy::expect_used)] mod tests { use super::*; - use crate::text::pair; fn at(rfc3339: &str) -> Option { Timestamp::Text(rfc3339.into()).time() @@ -475,6 +478,11 @@ mod tests { assert_eq!(weekly.kind, Kind::Weekly); assert!((weekly.used - 0.7).abs() < 1e-4); assert_eq!(weekly.resets_at, at("2026-02-02T00:00:00Z")); + let hourly = parse("Hourly usage5% used").unwrap(); + assert_eq!( + hourly.windows[0].kind, + Kind::Named(Text::message("usage-ollama-hourly")) + ); } #[test] @@ -510,12 +518,15 @@ mod tests { parse_tags(r#"{"models":[{"name":"gpt-oss:120b"},{"name":"qwen3"}]}"#).unwrap(); assert!(report.windows.is_empty()); assert_eq!( - report.account.plan.as_ref().map(Text::as_str), - Some("API key") + report.account.plan, + Some(Text::message("usage-ollama-api-key")) ); let Section::Facts { facts, .. } = &report.sections[0] else { panic!("expected facts"); }; - assert_eq!(facts[0], pair(("Models".to_owned(), "2".to_owned()))); + assert_eq!( + facts[0], + (Text::message("usage-ollama-models"), Text::service("2")) + ); } } diff --git a/crates/herdr-usage/src/providers/openai.rs b/crates/herdr-usage/src/providers/openai.rs index 936aa94b5..c30346aa9 100644 --- a/crates/herdr-usage/src/providers/openai.rs +++ b/crates/herdr-usage/src/providers/openai.rs @@ -10,7 +10,7 @@ //! CodexBar's opt-in fallback does. There is no local sign-in: the Codex CLI's //! ChatGPT sign-in is the separate Codex provider. -use crate::text::{Text, pairs, share}; +use crate::text::Text; use crate::{ Error, Result, { @@ -195,8 +195,10 @@ struct Day { input: u64, cached: u64, output: u64, - lines: HashMap, - models: HashMap, + /// By line item, or `None` for spend the API names no line item for. + lines: HashMap, f64>, + /// By model, or `None` for tokens the API names no model for. + models: HashMap, u64>, } /// Admin API buckets gathered by UTC day start. @@ -219,7 +221,7 @@ impl Tally { .filter(|value| value.is_finite()) .unwrap_or(0.); day.cost += amount; - *day.lines.entry(name(result.line_item, "API")).or_default() += amount; + *day.lines.entry(name(result.line_item)).or_default() += amount; } } Ok(next) @@ -238,9 +240,7 @@ impl Tally { day.input += input; day.cached += result.input_cached_tokens; day.output += output; - *day.models - .entry(name(result.model, "Responses and Chat Completions")) - .or_default() += input + output; + *day.models.entry(name(result.model)).or_default() += input + output; } } Ok(next) @@ -258,13 +258,9 @@ impl Tally { .sum::() }; let total = spend(since(days)); - let label = if days == 1 { - "Spend today".to_owned() - } else { - format!("Spend, last {days} days") - }; - let mut lines: HashMap<&str, f64> = HashMap::new(); - let mut models: HashMap<&str, u64> = HashMap::new(); + let label = Text::message("usage-openai-spend-days").arg("days", days); + let mut lines: HashMap, f64> = HashMap::new(); + let mut models: HashMap, u64> = HashMap::new(); let (mut requests, mut input, mut cached, mut output) = (0, 0, 0, 0); for (_, day) in &window { requests += day.requests; @@ -272,33 +268,56 @@ impl Tally { cached += day.cached; output += day.output; for (line, cost) in &day.lines { - *lines.entry(line.as_str()).or_default() += *cost; + *lines.entry(line.as_deref()).or_default() += *cost; } for (model, tokens) in &day.models { - *models.entry(model.as_str()).or_default() += *tokens; + *models.entry(model.as_deref()).or_default() += *tokens; } } let mut sections = vec![Section::Facts { - title: "Spend".into(), + title: Text::message("usage-openai-spend"), facts: vec![ - ("Today".into(), Text::from(usd(spend(today)))), - ("Last 7 days".into(), Text::from(usd(spend(since(7))))), + ( + Text::message("usage-openai-today"), + Text::service(usd(spend(today))), + ), + ( + Text::message("usage-openai-last-7-days"), + Text::service(usd(spend(since(7)))), + ), ], }]; if requests > 0 || input + output > 0 { sections.push(Section::Facts { - title: "Completions".into(), + title: Text::message("usage-openai-completions"), facts: vec![ - ("Requests".into(), Text::from(count(requests))), - ("Input tokens".into(), Text::from(count(input))), - ("Cached input tokens".into(), Text::from(count(cached))), - ("Output tokens".into(), Text::from(count(output))), + ( + Text::message("usage-openai-requests"), + Text::service(count(requests)), + ), + ( + Text::message("usage-openai-input-tokens"), + Text::service(count(input)), + ), + ( + Text::message("usage-openai-cached-input-tokens"), + Text::service(count(cached)), + ), + ( + Text::message("usage-openai-output-tokens"), + Text::service(count(output)), + ), ], }); } - sections.extend(shares("Spend by line item", lines.into_iter())); sections.extend(shares( - "Tokens by model", + Text::message("usage-openai-spend-by-line-item"), + Text::message("usage-openai-unnamed-line-item"), + lines.into_iter(), + )); + sections.extend(shares( + Text::message("usage-openai-tokens-by-model"), + Text::message("usage-openai-unnamed-model"), models .into_iter() .map(|(model, tokens)| (model, tokens as f64)), @@ -313,8 +332,10 @@ impl Tally { let account = Account { email: None, plan: Some(match project { - Some(project) => format!("Admin API: {project}").into(), - None => "Admin API".into(), + Some(project) => { + Text::message("usage-openai-admin-api-project").arg("project", project) + } + None => Text::message("usage-openai-admin-api"), }), }; Report::new(Provider(&Openai), account, Vec::new()) @@ -328,30 +349,37 @@ impl Tally { } } -/// The largest parts of a whole, as percentages of it. -fn shares<'a>(title: &str, parts: impl Iterator) -> Option
{ - let mut parts: Vec<(&str, f64)> = parts.filter(|(_, value)| *value > 0.).collect(); +/// The largest parts of a whole, as percentages of it; a part the API names +/// nothing for is called `unnamed`. +fn shares<'a>( + title: Text, + unnamed: Text, + parts: impl Iterator, f64)>, +) -> Option
{ + let mut parts: Vec<(Option<&str>, f64)> = parts.filter(|(_, value)| *value > 0.).collect(); let whole: f64 = parts.iter().map(|(_, value)| value).sum(); if whole <= 0. { return None; } - parts.sort_by(|a, b| b.1.total_cmp(&a.1).then_with(|| a.0.cmp(b.0))); + parts.sort_by(|a, b| b.1.total_cmp(&a.1).then_with(|| a.0.cmp(&b.0))); Some(Section::Shares { - title: title.into(), + title, shares: parts .into_iter() .take(TOP) - .map(|(label, value)| (label.to_owned(), (value / whole * 100.) as f32)) - .map(share) + .map(|(label, value)| { + let label = label.map_or_else(|| unnamed.clone(), Text::service); + (label, (value / whole * 100.) as f32) + }) .collect(), }) } -fn name(value: Option, fallback: &str) -> String { +/// The API's name for a line item or model, when it gives one. +fn name(value: Option) -> Option { value .map(|value| value.trim().to_owned()) .filter(|value| !value.is_empty()) - .unwrap_or_else(|| fallback.to_owned()) } fn count(value: u64) -> String { @@ -379,27 +407,37 @@ pub(crate) fn parse_grants(body: &str, now: i64) -> Result { }) .min(); let available = grants.total_available.max(0.); - let mut balance = Balance::new("API credits left", available, Unit::Currency("USD".into())); + let mut balance = Balance::new( + Text::message("usage-openai-api-credits-left"), + available, + Unit::Currency("USD".into()), + ); if grants.total_granted > 0. { balance = balance.out_of(grants.total_granted); } - let mut facts = vec![("Used".to_owned(), usd(grants.total_used))]; + let mut facts = vec![( + Text::message("usage-openai-used"), + Text::service(usd(grants.total_used)), + )]; if let Some(expiry) = expiry .and_then(|at| at.duration_since(SystemTime::UNIX_EPOCH).ok()) .and_then(|since| i64::try_from(since.as_secs()).ok()) .and_then(|secs| chrono::DateTime::from_timestamp(secs, 0)) { - facts.push(("Next expiry".into(), expiry.format("%Y-%m-%d").to_string())); + facts.push(( + Text::message("usage-openai-next-expiry"), + Text::service(expiry.format("%Y-%m-%d").to_string()), + )); } let account = Account { email: None, - plan: Some("API balance".into()), + plan: Some(Text::message("usage-openai-api-balance")), }; Ok(Report::new(Provider(&Openai), account, Vec::new()) .with_balances([balance]) .with_sections([Section::Facts { - title: "Credit grants".into(), - facts: pairs(facts), + title: Text::message("usage-openai-credit-grants"), + facts, }])) } @@ -534,27 +572,52 @@ mod tests { assert_eq!(tally.add_completions(&completions()).unwrap(), None); let report = tally.report(NOW, 30, None); assert!(report.windows.is_empty()); - assert_eq!(report.balances[0].label, "Spend, last 30 days"); + assert_eq!( + report.balances[0].label, + Text::message("usage-openai-spend-days").arg("days", 30) + ); assert!((report.balances[0].amount - 5.0).abs() < 1e-9); assert_eq!( - report.account.plan.as_ref().map(Text::as_str), - Some("Admin API") + report.account.plan, + Some(Text::message("usage-openai-admin-api")) ); let Section::Facts { facts, .. } = &report.sections[0] else { panic!("expected spend facts"); }; - assert_eq!(facts[0], ("Today".into(), "$3.00".into())); - assert_eq!(facts[1], ("Last 7 days".into(), "$5.00".into())); + assert_eq!( + facts, + &[ + (Text::message("usage-openai-today"), Text::service("$3.00")), + ( + Text::message("usage-openai-last-7-days"), + Text::service("$5.00") + ), + ] + ); let Section::Facts { facts, .. } = &report.sections[1] else { panic!("expected completion facts"); }; - assert_eq!(facts[0], ("Requests".into(), "7".into())); - assert_eq!(facts[1], ("Input tokens".into(), "1,000".into())); + assert_eq!( + facts[0], + (Text::message("usage-openai-requests"), Text::service("7")) + ); + assert_eq!( + facts[1], + ( + Text::message("usage-openai-input-tokens"), + Text::service("1,000") + ) + ); let Section::Shares { shares, .. } = &report.sections[2] else { panic!("expected line item shares"); }; - assert_eq!(shares[0].0, "API"); + assert_eq!(shares[0].0, Text::message("usage-openai-unnamed-line-item")); assert_eq!(shares[0].1.round(), 60.); + assert_eq!(shares[1].0, Text::service("gpt-4o, input")); + let Section::Shares { shares, .. } = &report.sections[3] else { + panic!("expected model shares"); + }; + assert_eq!(shares[0].0, Text::service("gpt-4o-2024-08-06")); let daily = report.detail::().unwrap(); assert_eq!(daily.days.len(), 2); assert_eq!(daily.days[1].1, 3.0); @@ -565,11 +628,14 @@ mod tests { let mut tally = Tally::default(); tally.add_costs(&costs()).unwrap(); let report = tally.report(NOW, 1, Some("proj_abc")); - assert_eq!(report.balances[0].label, "Spend today"); + assert_eq!( + report.balances[0].label, + Text::message("usage-openai-spend-days").arg("days", 1) + ); assert!((report.balances[0].amount - 3.0).abs() < 1e-9); assert_eq!( - report.account.plan.as_ref().map(Text::as_str), - Some("Admin API: proj_abc") + report.account.plan, + Some(Text::message("usage-openai-admin-api-project").arg("project", "proj_abc")) ); } @@ -602,7 +668,15 @@ mod tests { let Section::Facts { facts, .. } = &report.sections[0] else { panic!("expected grant facts"); }; - assert_eq!(facts[0], ("Used".into(), "$4.50".into())); + assert_eq!( + facts[0], + (Text::message("usage-openai-used"), Text::service("$4.50")) + ); + assert_eq!(facts[1].0, Text::message("usage-openai-next-expiry")); assert_eq!(facts.len(), 2); + assert_eq!( + report.account.plan, + Some(Text::message("usage-openai-api-balance")) + ); } } diff --git a/crates/herdr-usage/src/providers/opencode.rs b/crates/herdr-usage/src/providers/opencode.rs index 6b43f8744..27e2442eb 100644 --- a/crates/herdr-usage/src/providers/opencode.rs +++ b/crates/herdr-usage/src/providers/opencode.rs @@ -19,6 +19,7 @@ use crate::{ model::{Account, Balance, Kind, Provider, Report, Section, Unit, Window}, probe::{Probe, Request, Secret}, service::{Meta, Service, Setting, Timestamp}, + text::Text, values, }, }; @@ -447,22 +448,35 @@ pub(crate) fn parse_billing(text: &str) -> Option { let usd = || Unit::Currency("USD".into()); let mut balances = Vec::new(); if let Some(balance) = balance { - balances.push(Balance::new("Zen balance", balance / MICRO_CENTS, usd())); + balances.push(Balance::new( + Text::message("usage-opencode-zen-balance"), + balance / MICRO_CENTS, + usd(), + )); } if let Some(limit) = limit.filter(|limit| *limit > 0.) { - balances - .push(Balance::new("Monthly limit left", (limit - spent).max(0.), usd()).out_of(limit)); + balances.push( + Balance::new( + Text::message("usage-opencode-monthly-limit-left"), + (limit - spent).max(0.), + usd(), + ) + .out_of(limit), + ); } let account = Account { email: None, - plan: Some("Pay as you go".into()), + plan: Some(Text::message("usage-opencode-pay-as-you-go")), }; Some( Report::new(Provider(&Opencode), account, Vec::new()) .with_balances(balances) .with_sections([Section::Facts { - title: "This month".into(), - facts: vec![("Spent".into(), format!("${spent:.2}").into())], + title: Text::message("usage-opencode-this-month"), + facts: vec![( + Text::message("usage-opencode-spent"), + Text::service(format!("${spent:.2}")), + )], }]), ) } @@ -541,8 +555,29 @@ mod tests { ) .unwrap(); assert!(report.windows.is_empty()); - assert_eq!(report.balances[0].label, "Zen balance"); + assert_eq!( + report.balances[0].label, + Text::message("usage-opencode-zen-balance") + ); assert_eq!(report.balances[0].amount, 5.); + assert_eq!( + report.balances[1].label, + Text::message("usage-opencode-monthly-limit-left") + ); + assert_eq!( + report.account.plan, + Some(Text::message("usage-opencode-pay-as-you-go")) + ); + assert_eq!( + report.sections, + vec![Section::Facts { + title: Text::message("usage-opencode-this-month"), + facts: vec![( + Text::message("usage-opencode-spent"), + Text::service("$2.50") + )], + }] + ); assert_eq!(report.balances[1].amount, 7.5); assert_eq!(report.balances[1].total, Some(10.)); let script = r#"$R[1]={customerID:"cus_TEST",balance:$R[2]=1250000000,monthlyUsage:1500000000,subscription:null}"#; diff --git a/crates/herdr-usage/src/providers/opencodego.rs b/crates/herdr-usage/src/providers/opencodego.rs index 3db1bcaa6..afabff546 100644 --- a/crates/herdr-usage/src/providers/opencodego.rs +++ b/crates/herdr-usage/src/providers/opencodego.rs @@ -202,21 +202,28 @@ fn report(windows: Vec, balance: Option) -> Report { Provider(&Opencodego), Account { email: None, - plan: (!windows.is_empty()) - .then(|| "Go".to_owned()) - .map(Text::from), + // The plan's name. + plan: (!windows.is_empty()).then(|| Text::service("Go")), }, windows, ) - .with_balances( - balance.map(|balance| Balance::new("Zen balance", balance, Unit::Currency("USD".into()))), - ) + .with_balances(balance.map(|balance| { + Balance::new( + Text::message("usage-opencodego-zen-balance"), + balance, + Unit::Currency("USD".into()), + ) + })) } fn no_subscription(balance: Option) -> Report { report(Vec::new(), balance).with_sections([Section::Facts { - title: "Subscription".into(), - facts: vec![("OpenCode Go".into(), "None".into())], + title: Text::message("usage-opencodego-subscription"), + // The product's name, and that the workspace has none of it. + facts: vec![( + Text::service("OpenCode Go"), + Text::message("usage-opencodego-none"), + )], }]) } @@ -411,7 +418,20 @@ mod tests { fn missing_subscription_keeps_the_balance() { let report = parse_console("null", Some(5.), at(NOW)).unwrap(); assert!(report.windows.is_empty()); - assert_eq!(report.balances[0].label, "Zen balance"); + assert_eq!( + report.balances[0].label, + Text::message("usage-opencodego-zen-balance") + ); + assert_eq!( + report.sections, + vec![Section::Facts { + title: Text::message("usage-opencodego-subscription"), + facts: vec![( + Text::service("OpenCode Go"), + Text::message("usage-opencodego-none") + )], + }] + ); let report = parse_console(r#"{"access":null}"#, None, at(NOW)).unwrap(); assert!(report.balances.is_empty()); } diff --git a/crates/herdr-usage/src/providers/openrouter.rs b/crates/herdr-usage/src/providers/openrouter.rs index 617c29b1d..948784129 100644 --- a/crates/herdr-usage/src/providers/openrouter.rs +++ b/crates/herdr-usage/src/providers/openrouter.rs @@ -10,7 +10,7 @@ //! completed day's activity separately to fill a history chart; only the //! 30-day totals are shown here. -use crate::text::{Text, pairs}; +use crate::text::Text; use crate::{ Result, { @@ -146,7 +146,7 @@ pub(crate) fn parse( } let balance = credits.as_ref().map(|credits| { Balance::new( - "Credits", + Text::message("usage-openrouter-credits"), (credits.total_credits - credits.total_usage).max(0.), Unit::Currency("USD".into()), ) @@ -164,13 +164,10 @@ pub(crate) fn parse( let plan = key .as_ref() .and_then(|key| key.is_free_tier) - .and_then(|free| free.then(|| "Free tier".to_owned())); + .and_then(|free| free.then(|| Text::message("usage-openrouter-free-tier"))); Ok(Report::new( Provider(&Openrouter), - Account { - email: None, - plan: plan.map(Text::from), - }, + Account { email: None, plan }, windows, ) .with_balances(balance) @@ -197,15 +194,24 @@ fn activity_facts(rows: &[Activity]) -> Section { .filter(|model| !model.is_empty()) .collect(); Section::Facts { - title: "Last 30 days".into(), + title: Text::message("usage-openrouter-last-30-days"), facts: vec![ - ("Spend".into(), Text::from(usd(spend))), ( - "Requests".into(), - Text::from(group(requests.round() as i64)), + Text::message("usage-openrouter-spend"), + Text::service(usd(spend)), + ), + ( + Text::message("usage-openrouter-requests"), + Text::service(group(requests.round() as i64)), + ), + ( + Text::message("usage-openrouter-tokens"), + Text::service(group(tokens.round() as i64)), + ), + ( + Text::message("usage-openrouter-models"), + Text::service(models.len().to_string()), ), - ("Tokens".into(), Text::from(group(tokens.round() as i64))), - ("Models".into(), Text::from(models.len().to_string())), ], } } @@ -270,41 +276,58 @@ impl KeyData { Some("daily") => Kind::Daily, Some("weekly") => Kind::Weekly, Some("monthly") => Kind::Monthly, - _ => Kind::Named("API key limit".into()), + _ => Kind::Named(Text::message("usage-openrouter-api-key-limit")), }; let length = kind.length(); Some(Window::new(kind, spent / limit * 100., None, length)) } fn facts(&self) -> Section { - let mut facts: Vec<(String, String)> = Vec::new(); + let money = |amount: f64| Text::service(usd(amount)); + let mut facts = Vec::new(); match self.limit() { Some(limit) => { - facts.push(("Spending cap".into(), usd(limit))); + facts.push((Text::message("usage-openrouter-spending-cap"), money(limit))); if let Some(spent) = self.spent(limit) { - facts.push(("Cap remaining".into(), usd(limit - spent))); + facts.push(( + Text::message("usage-openrouter-cap-remaining"), + money(limit - spent), + )); } if let Some(usage) = self.usage { - facts.push(("Key used".into(), usd(usage))); + facts.push((Text::message("usage-openrouter-key-used"), money(usage))); } } - None => facts.push(("Spending cap".into(), "No limit configured".into())), + None => facts.push(( + Text::message("usage-openrouter-spending-cap"), + Text::message("usage-openrouter-no-limit"), + )), } + // OpenRouter's own word for the cap's window, such as `monthly`. if let Some(reset) = self.reset() { - facts.push(("Reset window".into(), reset.to_owned())); + facts.push(( + Text::message("usage-openrouter-reset-window"), + Text::service(reset), + )); } for (label, value) in [ - ("Today", self.usage_daily), - ("This week", self.usage_weekly), - ("This month", self.usage_monthly), + (Text::message("usage-openrouter-today"), self.usage_daily), + ( + Text::message("usage-openrouter-this-week"), + self.usage_weekly, + ), + ( + Text::message("usage-openrouter-this-month"), + self.usage_monthly, + ), ] { if let Some(value) = value { - facts.push((label.into(), usd(value))); + facts.push((label, money(value))); } } Section::Facts { - title: "API key".into(), - facts: pairs(facts), + title: Text::message("usage-openrouter-api-key"), + facts, } } } @@ -347,9 +370,43 @@ mod tests { let Section::Facts { facts, .. } = &report.sections[0] else { panic!("expected facts"); }; - assert!(facts.contains(&("Spending cap".into(), "$20.00".into()))); - assert!(facts.contains(&("Cap remaining".into(), "$15.00".into()))); - assert!(facts.contains(&("This month".into(), "$4.00".into()))); + assert_eq!( + facts, + &[ + ( + Text::message("usage-openrouter-spending-cap"), + Text::service("$20.00") + ), + ( + Text::message("usage-openrouter-cap-remaining"), + Text::service("$15.00") + ), + ( + Text::message("usage-openrouter-key-used"), + Text::service("$5.00") + ), + ( + Text::message("usage-openrouter-reset-window"), + Text::service("monthly") + ), + ( + Text::message("usage-openrouter-today"), + Text::service("$1.00") + ), + ( + Text::message("usage-openrouter-this-week"), + Text::service("$2.00") + ), + ( + Text::message("usage-openrouter-this-month"), + Text::service("$4.00") + ), + ] + ); + assert_eq!( + report.balances[0].label, + Text::message("usage-openrouter-credits") + ); } #[test] @@ -363,15 +420,18 @@ mod tests { assert!(report.windows.is_empty()); assert!(report.balances.is_empty()); assert_eq!( - report.account.plan.as_ref().map(Text::as_str), - Some("Free tier") + report.account.plan, + Some(Text::message("usage-openrouter-free-tier")) ); let Section::Facts { facts, .. } = &report.sections[0] else { panic!("expected facts"); }; assert_eq!( facts[0], - ("Spending cap".into(), "No limit configured".into()) + ( + Text::message("usage-openrouter-spending-cap"), + Text::message("usage-openrouter-no-limit") + ) ); } @@ -388,18 +448,36 @@ mod tests { let Some(Section::Facts { title, facts }) = report.sections.last() else { panic!("expected facts"); }; - assert_eq!(title, "Last 30 days"); + assert_eq!(title, &Text::message("usage-openrouter-last-30-days")); assert_eq!( facts, &vec![ - ("Spend".into(), "$1.50".into()), - ("Requests".into(), "5".into()), - ("Tokens".into(), "1,650".into()), - ("Models".into(), "2".into()), + ( + Text::message("usage-openrouter-spend"), + Text::service("$1.50") + ), + ( + Text::message("usage-openrouter-requests"), + Text::service("5") + ), + ( + Text::message("usage-openrouter-tokens"), + Text::service("1,650") + ), + (Text::message("usage-openrouter-models"), Text::service("2")), ] ); } + #[test] + fn a_cap_without_a_known_window_is_the_key_limit() { + let report = parse(Some(r#"{"data":{"limit":10,"usage":2}}"#), None, None).unwrap(); + assert_eq!( + report.windows[0].kind, + Kind::Named(Text::message("usage-openrouter-api-key-limit")) + ); + } + #[test] fn nothing_parsed_is_an_error() { assert!(parse(Some("{}"), Some("[]"), None).is_err()); diff --git a/docs/i18n.md b/docs/i18n.md index 29553b583..89aa7ff73 100644 --- a/docs/i18n.md +++ b/docs/i18n.md @@ -300,7 +300,7 @@ app still shows English literals: own words, which `usage::text::words` writes out; count units are a `Count` the app writes with a plural, and the typed values (limit kinds, countdowns, pace, balances) are localized in `usage/text.rs`. Wayfinder - and the providers from Copilot to Kimi in `providers/` name all of their + and the providers from Copilot to OpenRouter in `providers/` name all of their words (`usage-.ftl`, with shared ones in `usage-report.ftl`); the other providers still pass English section titles, fact labels and values, balance labels, window names, and account From b10aa773fd71917288049f2dca94e3ddb405240c Mon Sep 17 00:00:00 2001 From: Fabien Penso Date: Sat, 31 Oct 2026 23:06:17 -0700 Subject: [PATCH 06/10] refactor(i18n): show plan usage reports from Perplexity on through Fluent Perplexity, Poe, Qoder, Raycast, Replicate, Sakana, StepFun, Sub2API, Synthetic, T3 Chat, Typesafe, v0, Venice, Vercel, Vertex AI, Warp, Windsurf, xAI, xKiro, Z.ai, Zed, ZenMux, and ZoomMate now name each word their reports show in their own usage-.ftl. Plan names the providers map themselves, currencies, and tool and protocol names stay as written. Qwen Cloud's words come from the Alibaba token plan code, which the last batch moves, so its test no longer pins them. Pi never reports. Poe's points, Venice's credits, Warp's credits, and ZenMux's flows gain a singular. Synthetic gives its hourly length only to the search slot's own window, not any quota the service names "Search hourly". Z.ai picks its model detail by a selector, and ZenMux names its session and weekly facts rather than borrowing Kind::title's English. usage/tests/words_d.rs checks each provider's English reads as it wrote it. --- .../locales/en-US/usage-perplexity.ftl | 12 + crates/herdr-gpui/locales/en-US/usage-poe.ftl | 31 ++ .../herdr-gpui/locales/en-US/usage-qoder.ftl | 6 + .../locales/en-US/usage-raycast.ftl | 11 + .../locales/en-US/usage-replicate.ftl | 10 + .../herdr-gpui/locales/en-US/usage-sakana.ftl | 9 + .../locales/en-US/usage-stepfun.ftl | 5 + .../locales/en-US/usage-sub2api.ftl | 17 + .../locales/en-US/usage-synthetic.ftl | 8 + .../herdr-gpui/locales/en-US/usage-t3chat.ftl | 9 + .../locales/en-US/usage-typesafe.ftl | 16 + crates/herdr-gpui/locales/en-US/usage-v0.ftl | 17 + .../herdr-gpui/locales/en-US/usage-venice.ftl | 23 + .../herdr-gpui/locales/en-US/usage-vercel.ftl | 5 + .../locales/en-US/usage-vertexai.ftl | 10 + .../herdr-gpui/locales/en-US/usage-warp.ftl | 18 + .../locales/en-US/usage-windsurf.ftl | 10 + crates/herdr-gpui/locales/en-US/usage-xai.ftl | 12 + .../herdr-gpui/locales/en-US/usage-xkiro.ftl | 15 + crates/herdr-gpui/locales/en-US/usage-zai.ftl | 32 ++ crates/herdr-gpui/locales/en-US/usage-zed.ftl | 20 + .../herdr-gpui/locales/en-US/usage-zenmux.ftl | 22 + .../locales/en-US/usage-zoommate.ftl | 15 + crates/herdr-gpui/src/usage/tests.rs | 1 + crates/herdr-gpui/src/usage/tests/words_d.rs | 447 ++++++++++++++++++ .../herdr-usage/src/providers/perplexity.rs | 56 ++- crates/herdr-usage/src/providers/poe.rs | 135 ++++-- crates/herdr-usage/src/providers/qoder.rs | 12 +- crates/herdr-usage/src/providers/qwencloud.rs | 3 +- crates/herdr-usage/src/providers/raycast.rs | 54 ++- crates/herdr-usage/src/providers/replicate.rs | 38 +- crates/herdr-usage/src/providers/sakana.rs | 36 +- crates/herdr-usage/src/providers/stepfun.rs | 9 +- crates/herdr-usage/src/providers/sub2api.rs | 103 ++-- crates/herdr-usage/src/providers/synthetic.rs | 19 +- crates/herdr-usage/src/providers/t3chat.rs | 39 +- crates/herdr-usage/src/providers/typesafe.rs | 56 ++- crates/herdr-usage/src/providers/v0.rs | 80 +++- crates/herdr-usage/src/providers/venice.rs | 110 ++++- crates/herdr-usage/src/providers/vercel.rs | 13 +- crates/herdr-usage/src/providers/vertexai.rs | 46 +- crates/herdr-usage/src/providers/warp.rs | 48 +- crates/herdr-usage/src/providers/windsurf.rs | 32 +- crates/herdr-usage/src/providers/xai.rs | 30 +- crates/herdr-usage/src/providers/xkiro.rs | 52 +- crates/herdr-usage/src/providers/zai.rs | 154 ++++-- crates/herdr-usage/src/providers/zed.rs | 95 ++-- crates/herdr-usage/src/providers/zenmux.rs | 113 +++-- crates/herdr-usage/src/providers/zoommate.rs | 51 +- docs/i18n.md | 2 +- 50 files changed, 1756 insertions(+), 411 deletions(-) create mode 100644 crates/herdr-gpui/src/usage/tests/words_d.rs diff --git a/crates/herdr-gpui/locales/en-US/usage-perplexity.ftl b/crates/herdr-gpui/locales/en-US/usage-perplexity.ftl index 4c24daa1d..1483a1ce2 100644 --- a/crates/herdr-gpui/locales/en-US/usage-perplexity.ftl +++ b/crates/herdr-gpui/locales/en-US/usage-perplexity.ftl @@ -6,3 +6,15 @@ usage-perplexity-setting-cookie = The perplexity.ai web session. Sign in at https://www.perplexity.ai, open Developer Tools → Application → Cookies → https://www.perplexity.ai, copy the session cookie (__Secure-next-auth.session-token, or __Secure-authjs.session-token on newer sign-ins), and paste it as "name=value". usage-perplexity-setting-session-token = Alternatively, only the value of that session cookie; each session cookie name is tried in turn. + +## The report: recurring, purchased, and bonus credits. Plan names (Pro, +## Max) stay as Perplexity names them. + +usage-perplexity-credits = Credits +usage-perplexity-bonus-credits = Bonus credits +usage-perplexity-purchased-credits = Purchased credits +# Recurring credits used this month; $used and $total are grouped numbers, +# such as 2,750 and 10,000. +usage-perplexity-used-of = { $used } of { $total } +# When the earliest bonus credits expire; the value is a date. +usage-perplexity-bonus-expires = Bonus expires diff --git a/crates/herdr-gpui/locales/en-US/usage-poe.ftl b/crates/herdr-gpui/locales/en-US/usage-poe.ftl index ef29ec062..e962431a3 100644 --- a/crates/herdr-gpui/locales/en-US/usage-poe.ftl +++ b/crates/herdr-gpui/locales/en-US/usage-poe.ftl @@ -5,3 +5,34 @@ ## service's own menu paths stay as written. usage-poe-setting-api-key = A Poe API key, created or copied at https://poe.com/api/keys. + +## The report: the point balance and the last 30 days of points history. +## Model (bot) names stay as Poe sends them. + +usage-poe-points = Points +usage-poe-current-balance = Current balance +usage-poe-today = Today +usage-poe-last-7-days = Last 7 days +usage-poe-last-30-days = Last 30 days +usage-poe-top-model = Top model +usage-poe-points-by-model = Points by model, last 30 days +# A number of points; $points is the number, and $amount is it as shown, +# such as 1,500 or 42.5. +usage-poe-point-count = { $points -> + [one] { $amount } point + *[other] { $amount } points + } +# A period's spend; $points is usage-poe-point-count, $requests the number +# of requests, and $count it grouped, such as 1,204. +usage-poe-summary = { $points } · { $requests -> + [one] { $count } request + *[other] { $count } requests + } +# The same, with $cost the period's cost in dollars, such as $0.09. +usage-poe-summary-cost = { $points } · { $requests -> + [one] { $count } request + *[other] { $count } requests + } · { $cost } +# The model that spent the most; $model is its name, $points +# usage-poe-point-count. +usage-poe-model-points = { $model } · { $points } diff --git a/crates/herdr-gpui/locales/en-US/usage-qoder.ftl b/crates/herdr-gpui/locales/en-US/usage-qoder.ftl index 23e40e4a8..dbbda9bf5 100644 --- a/crates/herdr-gpui/locales/en-US/usage-qoder.ftl +++ b/crates/herdr-gpui/locales/en-US/usage-qoder.ftl @@ -6,3 +6,9 @@ usage-qoder-setting-cookie = Your Qoder browser session. Sign in at https://qoder.com/account/usage (or https://qoder.com.cn/account/usage), open Developer Tools > Application > Cookies for qoder.com (or qoder.com.cn), and copy every cookie of the site as one header: "name=value; name2=value2". usage-qoder-setting-site = Which Qoder site the account belongs to: "global" for qoder.com (the default) or "china" for qoder.com.cn. + +## The report: the plan's credits, with any shared pool added. The China +## site's domain (qoder.com.cn) stays as written. + +usage-qoder-credits = Credits +usage-qoder-credits-left = Credits left diff --git a/crates/herdr-gpui/locales/en-US/usage-raycast.ftl b/crates/herdr-gpui/locales/en-US/usage-raycast.ftl index 37bd5d38e..7a3d33a73 100644 --- a/crates/herdr-gpui/locales/en-US/usage-raycast.ftl +++ b/crates/herdr-gpui/locales/en-US/usage-raycast.ftl @@ -5,3 +5,14 @@ ## service's own menu paths stay as written. usage-raycast-setting-cookie = Sign in at https://www.raycast.com/settings, open Developer Tools > Application > Cookies > https://www.raycast.com, and copy __raycast_session (and csrf_token when present) as one "__raycast_session=value; csrf_token=value" header. + +## The report: Raycast AI credits. Plan names (Pro, Pro+, Max) stay as +## Raycast names them. + +usage-raycast-credits = Credits +usage-raycast-credits-left = Credits left +# Credits left, when there is no grant to measure them against. +usage-raycast-left = Left +usage-raycast-total = Total +# When the credits renew; the value is a date. +usage-raycast-renews = Renews diff --git a/crates/herdr-gpui/locales/en-US/usage-replicate.ftl b/crates/herdr-gpui/locales/en-US/usage-replicate.ftl index eeda251a0..831db1c44 100644 --- a/crates/herdr-gpui/locales/en-US/usage-replicate.ftl +++ b/crates/herdr-gpui/locales/en-US/usage-replicate.ftl @@ -5,3 +5,13 @@ ## service's own menu paths stay as written. usage-replicate-setting-cookie = Sign in at https://replicate.com/account/billing, open Developer Tools > Application > Cookies > https://replicate.com, and copy sessionid (and csrftoken when present) as one "sessionid=value; csrftoken=value" header. An API token does not work here. + +## The report: this month's spend, the credit balance, and the account the +## token belongs to. The user or organization name stays as Replicate +## sends it. + +usage-replicate-spent-this-month = Spent this month +usage-replicate-credit-balance = Credit balance +usage-replicate-account = Account +usage-replicate-organization = Organization +usage-replicate-user = User diff --git a/crates/herdr-gpui/locales/en-US/usage-sakana.ftl b/crates/herdr-gpui/locales/en-US/usage-sakana.ftl index cccc70e1a..4c4c6f83d 100644 --- a/crates/herdr-gpui/locales/en-US/usage-sakana.ftl +++ b/crates/herdr-gpui/locales/en-US/usage-sakana.ftl @@ -5,3 +5,12 @@ ## service's own menu paths stay as written. usage-sakana-setting-cookie = Sign in to https://console.sakana.ai, open Developer Tools > Application > Cookies for https://console.sakana.ai, and copy every cookie there (the sign-in session cookies are required). Paste them as "name=value; name2=value2"; the Cookie header of a request to console.sakana.ai/billing in the Network tab also works. + +## The report: the pay-as-you-go credit balance and spend. The plan card's +## name and price, and the page's date range, stay as Sakana shows them. + +usage-sakana-credit-balance = Credit balance +usage-sakana-pay-as-you-go-usage = Pay-as-you-go usage +usage-sakana-pay-as-you-go = Pay as you go +# The date range the spend covers, such as Jun 02, 2026 - Jul 01, 2026. +usage-sakana-usage-period = Usage period diff --git a/crates/herdr-gpui/locales/en-US/usage-stepfun.ftl b/crates/herdr-gpui/locales/en-US/usage-stepfun.ftl index a546b3023..00e83dbdf 100644 --- a/crates/herdr-gpui/locales/en-US/usage-stepfun.ftl +++ b/crates/herdr-gpui/locales/en-US/usage-stepfun.ftl @@ -7,3 +7,8 @@ usage-stepfun-setting-token = Your StepFun Oasis-Token. Sign in at https://platform.stepfun.com, open Developer Tools > Application > Cookies for platform.stepfun.com, and copy the value of the Oasis-Token cookie. Set webid too. usage-stepfun-setting-webid = The value of the Oasis-Webid cookie next to Oasis-Token on platform.stepfun.com. StepFun rejects a token sent with another device's web id. usage-stepfun-setting-cookie = Instead of token and webid: sign in at https://platform.stepfun.com, open Developer Tools > Application > Cookies for platform.stepfun.com, and copy the Oasis-Token and Oasis-Webid cookies as "Oasis-Token=…; Oasis-Webid=…". + +## The report: a credit plan's monthly credit, as a usage window. Plan +## names stay as StepFun sends them. + +usage-stepfun-credit = Credit diff --git a/crates/herdr-gpui/locales/en-US/usage-sub2api.ftl b/crates/herdr-gpui/locales/en-US/usage-sub2api.ftl index 6bf799cae..9e3e382fa 100644 --- a/crates/herdr-gpui/locales/en-US/usage-sub2api.ftl +++ b/crates/herdr-gpui/locales/en-US/usage-sub2api.ftl @@ -6,3 +6,20 @@ usage-sub2api-setting-api-key = A sub2api group API key (sk-…), created in your sub2api deployment's dashboard under API keys. Usage is scoped to that key's group. usage-sub2api-setting-base-url = Your sub2api deployment, e.g. https://sub2api.example.com. HTTPS is required, except plain HTTP on a loopback address such as http://127.0.0.1:8080. + +## The report: the key's quota, rate limits, request and token totals, and +## when it expires. Plan names stay as the Sub2API instance sends them. + +usage-sub2api-quota = Quota +# A rate limit over a window the instance names; $window is its own name +# for it, such as 30m. +usage-sub2api-window-limit = { $window } limit +usage-sub2api-today-requests = Today requests +# Tokens and their cost, such as 1,200 · $1.25. +usage-sub2api-today-tokens = Today tokens +usage-sub2api-all-time-requests = All time requests +usage-sub2api-all-time-tokens = All time tokens +# When the key or subscription expires; the value is a date. +usage-sub2api-expires = Expires +usage-sub2api-balance = Balance +usage-sub2api-usage-summary = Usage summary diff --git a/crates/herdr-gpui/locales/en-US/usage-synthetic.ftl b/crates/herdr-gpui/locales/en-US/usage-synthetic.ftl index 9d302ba47..90152c157 100644 --- a/crates/herdr-gpui/locales/en-US/usage-synthetic.ftl +++ b/crates/herdr-gpui/locales/en-US/usage-synthetic.ftl @@ -5,3 +5,11 @@ ## service's own menu paths stay as written. usage-synthetic-setting-api-key = A Synthetic API key, created as described in https://dev.synthetic.new/docs/api/getting-started. + +## The report: the rolling, weekly, and search quotas, and the weekly +## credits. Quota and plan names stay as Synthetic sends them. + +usage-synthetic-search-hourly = Search hourly +usage-synthetic-weekly-credits-left = Weekly credits left +# A quota Synthetic sends without a name; $number counts from 1. +usage-synthetic-quota = Quota { $number } diff --git a/crates/herdr-gpui/locales/en-US/usage-t3chat.ftl b/crates/herdr-gpui/locales/en-US/usage-t3chat.ftl index 50362e8d7..c5732a6ea 100644 --- a/crates/herdr-gpui/locales/en-US/usage-t3chat.ftl +++ b/crates/herdr-gpui/locales/en-US/usage-t3chat.ftl @@ -5,3 +5,12 @@ ## service's own menu paths stay as written. usage-t3chat-setting-cookie = Your t3.chat browser session. Sign in at https://t3.chat, open Developer Tools → Application → Cookies → https://t3.chat, and copy every cookie there (the session cookies change names between releases), pasted as one header: "name=value; name2=value2". It is sent only to t3.chat. + +## The report: the overage window and the plan's usage band and +## subscription status. Plan names, the band, and the status stay as T3 +## Chat sends them. + +usage-t3chat-overage = Overage +usage-t3chat-plan = Plan +usage-t3chat-usage-band = Usage band +usage-t3chat-subscription = Subscription diff --git a/crates/herdr-gpui/locales/en-US/usage-typesafe.ftl b/crates/herdr-gpui/locales/en-US/usage-typesafe.ftl index 4db33fb9a..9192d0056 100644 --- a/crates/herdr-gpui/locales/en-US/usage-typesafe.ftl +++ b/crates/herdr-gpui/locales/en-US/usage-typesafe.ftl @@ -5,3 +5,19 @@ ## service's own menu paths stay as written. usage-typesafe-setting-cookie = Sign in at https://console.typesafe.ai/settings/billing, open Developer Tools > Application > Cookies > https://console.typesafe.ai, and copy every cookie there as one "name=value; name2=value2" header. An inference API key does not work here. + +## The report: the balance, the billing cycle's spend, and the active +## credit grants. Plan names and the cycle's name stay as Typesafe sends +## them. + +usage-typesafe-balance = Balance +usage-typesafe-spent = Spent +# $cycle is Typesafe's name for the billing cycle, such as September 2026. +usage-typesafe-spent-cycle = Spent ({ $cycle }) +usage-typesafe-credits = Credits +usage-typesafe-credit = Credit +# A credit grant: $remaining dollars left of $amount, such as 4.98 of 5, +# and $expires the day it expires, such as Oct 19. +usage-typesafe-credit-left = { $remaining } of { $amount }, expires { $expires } +# How many more active credit grants there are; the value is a number. +usage-typesafe-additional-credits = Additional credits diff --git a/crates/herdr-gpui/locales/en-US/usage-v0.ftl b/crates/herdr-gpui/locales/en-US/usage-v0.ftl index ab073a7ac..1032724a7 100644 --- a/crates/herdr-gpui/locales/en-US/usage-v0.ftl +++ b/crates/herdr-gpui/locales/en-US/usage-v0.ftl @@ -6,3 +6,20 @@ usage-v0-setting-api-key = A v0 Platform API key from https://v0.app/settings/keys. usage-v0-setting-scope = Optional project ID or slug to read that scope's billing and rate limit. + +## The report: the billing allowance, the on-demand balance, and the API +## rate limit. The billing type (token or legacy) and the project scope stay +## as v0 sends them. + +usage-v0-api = v0 API +usage-v0-rate-limit = Rate limit +usage-v0-billing-remaining = Billing remaining +usage-v0-on-demand-balance = On-demand balance +usage-v0-rate-limit-remaining = Rate-limit remaining +usage-v0-billing-type = Billing type +usage-v0-scope = Scope +# What is left of a limit; $remaining and $limit are grouped numbers, such +# as 750 and 1,000. +usage-v0-remaining-of = { $remaining } of { $limit } +# A limit whose remaining amount v0 does not report; $limit as above. +usage-v0-unavailable = Unavailable (limit { $limit }) diff --git a/crates/herdr-gpui/locales/en-US/usage-venice.ftl b/crates/herdr-gpui/locales/en-US/usage-venice.ftl index a8a6c1d86..16ed9a9ce 100644 --- a/crates/herdr-gpui/locales/en-US/usage-venice.ftl +++ b/crates/herdr-gpui/locales/en-US/usage-venice.ftl @@ -6,3 +6,26 @@ usage-venice-setting-api-key = A Venice API key from https://venice.ai/settings/api. It reads the USD and DIEM API balance. usage-venice-setting-cookie = Used when no API key is set, for subscription credits. Sign in to https://venice.ai, open Developer Tools > Application > Cookies for https://venice.ai, and copy the __venice-auth.session-token cookie (or every __venice-auth.session-token.N chunk when it is split). Paste them as "name=value; name2=value2". + +## The report: the USD and DIEM balances, and a web session's subscription +## credits. Balances named USD and DIEM, and the user type (such as pro), +## stay as Venice names them. + +usage-venice-diem-allocation = DIEM allocation +usage-venice-status = Status +usage-venice-api-calls = API calls +usage-venice-unavailable-for-api-calls = Balance unavailable for API calls +usage-venice-subscription-credits = Subscription credits +usage-venice-total-credits = Total credits +usage-venice-used-this-cycle = Used this cycle +usage-venice-credits = Credits +# The most credits the plan can bank. +usage-venice-bank-cap = Bank cap +# A number of credits; $credits is the number, and $count it grouped, such +# as 3,000. +usage-venice-credit-count = { $credits -> + [one] { $count } credit + *[other] { $count } credits + } +# When the subscription credits refill; the value is a date. +usage-venice-next-refill = Next refill diff --git a/crates/herdr-gpui/locales/en-US/usage-vercel.ftl b/crates/herdr-gpui/locales/en-US/usage-vercel.ftl index 5d31c96a0..8c2040ea8 100644 --- a/crates/herdr-gpui/locales/en-US/usage-vercel.ftl +++ b/crates/herdr-gpui/locales/en-US/usage-vercel.ftl @@ -5,3 +5,8 @@ ## service's own menu paths stay as written. usage-vercel-setting-api-key = An AI Gateway API key from the Vercel dashboard: AI Gateway > API Keys. The balance is the key's team, so use a key of the team to show. + +## The report: the key's team balance and lifetime spend. + +usage-vercel-available-balance = Available balance +usage-vercel-lifetime-spend = Lifetime spend diff --git a/crates/herdr-gpui/locales/en-US/usage-vertexai.ftl b/crates/herdr-gpui/locales/en-US/usage-vertexai.ftl index ec3f825fa..f49527bce 100644 --- a/crates/herdr-gpui/locales/en-US/usage-vertexai.ftl +++ b/crates/herdr-gpui/locales/en-US/usage-vertexai.ftl @@ -5,3 +5,13 @@ ## service's own menu paths stay as written. usage-vertexai-setting-project = The Google Cloud project id whose Vertex AI quotas to read, when gcloud has none set (`gcloud config set project PROJECT_ID`). Sign in with `gcloud auth application-default login`; the account needs Cloud Monitoring read access (roles/monitoring.viewer) on the project. + +## The report: the highest share of any Vertex AI quota used in the last +## day, and the Google Cloud project. Project IDs stay as gcloud names +## them. + +usage-vertexai-peak-quota = Peak quota +usage-vertexai-google-cloud = Google Cloud +usage-vertexai-project = Project +# No project is configured; the command stays as written. +usage-vertexai-project-not-set = Not set: run `gcloud config set project PROJECT_ID` diff --git a/crates/herdr-gpui/locales/en-US/usage-warp.ftl b/crates/herdr-gpui/locales/en-US/usage-warp.ftl index 77f4a9ce6..d96bd7463 100644 --- a/crates/herdr-gpui/locales/en-US/usage-warp.ftl +++ b/crates/herdr-gpui/locales/en-US/usage-warp.ftl @@ -5,3 +5,21 @@ ## service's own menu paths stay as written. usage-warp-setting-api-key = A Warp API key (wk-…). In Warp open your profile menu, then Settings → Platform → API Keys, and create one. See https://docs.warp.dev/reference/cli/api-keys. + +## The report: the monthly credits and add-on credit grants. + +usage-warp-credits = Credits +usage-warp-add-on-credits = Add-on credits +usage-warp-monthly-credits = Monthly credits +# The plan line, and the monthly credits, on an unlimited plan. +usage-warp-unlimited = Unlimited +# Monthly credits used; $used and $limit are grouped numbers, such as 5 and +# 1,500. +usage-warp-used-of = { $used } of { $limit } +usage-warp-next-add-on-expiry = Next add-on expiry +# The add-on credits that expire first; $credits is their number, $count it +# grouped, such as 1,200, and $date the day, such as Mar 1, 2026. +usage-warp-credits-on = { $credits -> + [one] { $count } credit on { $date } + *[other] { $count } credits on { $date } + } diff --git a/crates/herdr-gpui/locales/en-US/usage-windsurf.ftl b/crates/herdr-gpui/locales/en-US/usage-windsurf.ftl index 3c78d2679..7e0cec941 100644 --- a/crates/herdr-gpui/locales/en-US/usage-windsurf.ftl +++ b/crates/herdr-gpui/locales/en-US/usage-windsurf.ftl @@ -5,3 +5,13 @@ ## service's own menu paths stay as written. usage-windsurf-setting-session = The windsurf.com session bundle, for live quota. Sign in at https://windsurf.com/profile in Chrome, open Developer Tools → Application → Local Storage → https://windsurf.com (or https://app.devin.ai), and copy the values of devin_session_token, devin_auth1_token, devin_account_id, and devin_primary_org_id into one JSON object: {"{"}"devin_session_token": "…", "devin_auth1_token": "…", "devin_account_id": "…", "devin_primary_org_id": "…"{"}"}. Without it, the plan the Windsurf editor cached at its last launch is shown; on the Mac running Herdr only when windsurf is in show_providers, as macOS may ask before Herdr reads another app's data. + +## The report: message and flow action counters, when the daily and weekly +## quotas are missing, and when the plan renews. Plan names stay as +## Windsurf sends them. + +usage-windsurf-messages = Messages +usage-windsurf-flow-actions = Flow actions +usage-windsurf-plan = Plan +# When the plan renews; the value is a date. +usage-windsurf-renews = Renews diff --git a/crates/herdr-gpui/locales/en-US/usage-xai.ftl b/crates/herdr-gpui/locales/en-US/usage-xai.ftl index e11cc0315..93fd01df2 100644 --- a/crates/herdr-gpui/locales/en-US/usage-xai.ftl +++ b/crates/herdr-gpui/locales/en-US/usage-xai.ftl @@ -6,3 +6,15 @@ usage-xai-setting-api-key = A Management API key from https://console.x.ai under Settings > Management Keys, with billing read access. Inference API keys are not accepted. usage-xai-setting-team-id = The xAI team ID to bill against, shown in the xAI Console URL and team settings. The Management key must belong to this team. + +## The report: the team's prepaid balance and its spend over the last 30 +## days. + +usage-xai-prepaid-balance = Prepaid balance +# The plan line: the report comes from xAI's Management API. +usage-xai-management-api = Management API +usage-xai-spend = Spend +usage-xai-today = Today +usage-xai-last-30-days = Last 30 days +# The same, when some days of the history were missing. +usage-xai-last-30-days-partial = Last 30 days (partial) diff --git a/crates/herdr-gpui/locales/en-US/usage-xkiro.ftl b/crates/herdr-gpui/locales/en-US/usage-xkiro.ftl index e24d54211..3ad673fb2 100644 --- a/crates/herdr-gpui/locales/en-US/usage-xkiro.ftl +++ b/crates/herdr-gpui/locales/en-US/usage-xkiro.ftl @@ -5,3 +5,18 @@ ## service's own menu paths stay as written. usage-xkiro-setting-api-key = An xKiro API key from your account at https://xkiro.com. It is sent only to api.xkiro.com; reading usage spends no tokens. + +## The report: the daily free tokens and the wallet balance. Plan names +## stay as xKiro sends them. + +usage-xkiro-free-tokens = Free tokens +usage-xkiro-tokens-used-today = Tokens used today +usage-xkiro-daily-allowance = Daily allowance +# The daily allowance of an account xKiro reports no cap for. +usage-xkiro-no-cap-reported = No cap reported +usage-xkiro-tokens-remaining = Tokens remaining +# When the free tokens reset; the value is a time of day, 00:00 UTC. +usage-xkiro-daily-reset = Daily reset +# The plan line of an account without a plan. +usage-xkiro-pay-as-you-go = Pay as you go +usage-xkiro-wallet = Wallet diff --git a/crates/herdr-gpui/locales/en-US/usage-zai.ftl b/crates/herdr-gpui/locales/en-US/usage-zai.ftl index 2535aa697..f8a44a574 100644 --- a/crates/herdr-gpui/locales/en-US/usage-zai.ftl +++ b/crates/herdr-gpui/locales/en-US/usage-zai.ftl @@ -12,3 +12,35 @@ usage-zai-setting-balance-url = A full HTTPS URL that replaces the BigModel Chin usage-zai-setting-usage-scope = "personal" (the default) or "team" for BigModel team usage, which also needs organization and project. usage-zai-setting-organization = For team usage: open https://bigmodel.cn/coding-plan/team/usage-stats, pick the team, and in Developer Tools > Network copy the Bigmodel-Organization request header of the quota/limit request. usage-zai-setting-project = For team usage: the Bigmodel-Project request header of the same request. + +## The report: the coding plan's token or credit quotas, the MCP allowance, +## and tokens per model over the last 30 days. Model names and plan names +## stay as Z.ai sends them, and MCP is the protocol's name. + +# A quota window Z.ai sends in hours; $hours is how many, such as 3. +usage-zai-hours = { $hours }-hour +# A quota window of a length Z.ai does not say. +usage-zai-quota = Quota +usage-zai-quota-details = Quota details +usage-zai-credit-quota = Credit quota +usage-zai-token-quota = Token quota +usage-zai-session-credit-quota = Session credit quota +usage-zai-session-token-quota = Session token quota +usage-zai-mcp-quota = MCP quota +# How much of a quota is used; $percent is a number such as 22.4, $limit +# and $remaining counts such as 1000 and 776, and $counts says which of +# those two Z.ai sent. +usage-zai-detail = { $percent }% used{ $counts -> + [both] { " · " }{ $limit } limit · { $remaining } remaining + [limit] { " · " }{ $limit } limit + [remaining] { " · " }{ $remaining } remaining + *[none] {""} + } +# Credit plans cost double at peak hours, weekdays 06:00 to 10:00 UTC. +usage-zai-quota-rate = Quota rate +usage-zai-peak = Peak +usage-zai-off-peak = Off-peak +usage-zai-account-balance = Account balance +usage-zai-tokens-last-30-days = Tokens, last 30 days +# A model Z.ai reports tokens for without its name. +usage-zai-unknown-model = Unknown diff --git a/crates/herdr-gpui/locales/en-US/usage-zed.ftl b/crates/herdr-gpui/locales/en-US/usage-zed.ftl index d99477ff4..3dc2b1f18 100644 --- a/crates/herdr-gpui/locales/en-US/usage-zed.ftl +++ b/crates/herdr-gpui/locales/en-US/usage-zed.ftl @@ -5,3 +5,23 @@ ## service's own menu paths stay as written. usage-zed-setting-cookie = The zed.dev browser session, for token spend. Sign in at https://zed.dev in your browser (signing in inside the editor is not enough), open Developer Tools → Application → Cookies → https://zed.dev, copy the zed.session cookie, and paste it as "zed.session=value". + +## The report: edit predictions, token spend, and the billing period. Plan +## names (such as Zed Pro) stay as Zed names them. + +usage-zed-plan = Plan +usage-zed-edit-predictions = Edit predictions +# Edit predictions used of the plan's allowance; $used and $limit are +# counts, such as 30 and 120. +usage-zed-used-of = { $used } of { $limit } +# Edit predictions used on a plan without a limit; $used is a count. +usage-zed-used-unlimited = { $used } used · Unlimited +usage-zed-billing = Billing +usage-zed-overdue-invoices = Overdue invoices +usage-zed-token-spend = Token spend +usage-zed-current-billing-period = Current billing period +usage-zed-spent = Spent +usage-zed-spend-limit = Spend limit +# The spend limit, when Zed does not report one. +usage-zed-not-reported = Not reported +usage-zed-remaining-budget = Remaining budget diff --git a/crates/herdr-gpui/locales/en-US/usage-zenmux.ftl b/crates/herdr-gpui/locales/en-US/usage-zenmux.ftl index 9ede8508d..a11aaff33 100644 --- a/crates/herdr-gpui/locales/en-US/usage-zenmux.ftl +++ b/crates/herdr-gpui/locales/en-US/usage-zenmux.ftl @@ -5,3 +5,25 @@ ## service's own menu paths stay as written. usage-zenmux-setting-api-key = A Management API key created at https://zenmux.ai/platform/management. Normal inference API keys are rejected by the usage endpoints. + +## The report: the 5-hour and weekly flow quotas, the plan, and the +## pay-as-you-go balance. The plan tier and account status stay as ZenMux +## sends them, capitalized. + +usage-zenmux-quota = Quota +usage-zenmux-session = Session +usage-zenmux-weekly = Weekly +# Flows used of a quota; $used and $max are numbers such as 57.20 and 800, +# and $flows is $max, to pick the plural by. +usage-zenmux-flows = { $flows -> + [one] { $used } / { $max } flow + *[other] { $used } / { $max } flows + } +# The plan line; $tier is the plan's tier, such as Ultra. +usage-zenmux-tier-plan = { $tier } plan +# The same, with $status the account's status when it is not healthy, such +# as Overdue. +usage-zenmux-tier-plan-status = { $tier } plan · { $status } +# When the plan expires; the value is a date. +usage-zenmux-plan-expires = Plan expires +usage-zenmux-payg-balance = PAYG balance diff --git a/crates/herdr-gpui/locales/en-US/usage-zoommate.ftl b/crates/herdr-gpui/locales/en-US/usage-zoommate.ftl index eac3e4138..6077be7ea 100644 --- a/crates/herdr-gpui/locales/en-US/usage-zoommate.ftl +++ b/crates/herdr-gpui/locales/en-US/usage-zoommate.ftl @@ -6,3 +6,18 @@ usage-zoommate-setting-cookie = Your Zoom session cookies. Sign in at https://zoommate.zoom.us, open Developer Tools > Application > Cookies for https://zoommate.zoom.us (Zoom's sign-in cookies such as _zm_ssid, _zm_lang and cf_clearance live on zoom.us), and paste them all as "name=value; name2=value2". They are exchanged for a short-lived token on every refresh. usage-zoommate-setting-token = A bearer token instead of cookies: on https://zoommate.zoom.us open Developer Tools > Network, reload the AI credit usage page, select the credits/status request and copy its Authorization header without "Bearer ". It expires after about an hour. + +## The report: the cycle's credits, any overage, and whether the quota is +## exhausted. + +usage-zoommate-credits = Credits +usage-zoommate-credits-used = Credits used +usage-zoommate-credits-left = Credits left +usage-zoommate-budget = Budget +usage-zoommate-unlimited = Unlimited +usage-zoommate-overage = Overage +# Credits spent past the budget; $credits is a number with two decimals, +# such as 12.50. +usage-zoommate-overage-credits = { $credits } credits +usage-zoommate-quota = Quota +usage-zoommate-exhausted = Exhausted diff --git a/crates/herdr-gpui/src/usage/tests.rs b/crates/herdr-gpui/src/usage/tests.rs index 07d460107..fedf58ce1 100644 --- a/crates/herdr-gpui/src/usage/tests.rs +++ b/crates/herdr-gpui/src/usage/tests.rs @@ -18,6 +18,7 @@ mod text; mod words; mod words_b; mod words_c; +mod words_d; fn provider(id: &str) -> Provider { registry::find(id).unwrap() diff --git a/crates/herdr-gpui/src/usage/tests/words_d.rs b/crates/herdr-gpui/src/usage/tests/words_d.rs new file mode 100644 index 000000000..c10e0e1f0 --- /dev/null +++ b/crates/herdr-gpui/src/usage/tests/words_d.rs @@ -0,0 +1,447 @@ +//! The words the reports of Perplexity through ZoomMate name read in +//! English as the providers used to write them. +use super::super::text; +use herdr_usage::Text; + +/// Drops Fluent's isolation marks around placeables. +fn plain(text: String) -> String { + text.replace(['\u{2068}', '\u{2069}'], "") +} + +fn english(words: Text) -> String { + plain(text::words(&words)) +} + +/// A message without arguments, in English. +fn label(id: &'static str) -> String { + english(Text::message(id)) +} + +#[test] +fn perplexity_reads_as_it_did() { + assert_eq!(label("usage-perplexity-credits"), "Credits"); + assert_eq!(label("usage-perplexity-bonus-credits"), "Bonus credits"); + assert_eq!( + label("usage-perplexity-purchased-credits"), + "Purchased credits" + ); + assert_eq!(label("usage-perplexity-bonus-expires"), "Bonus expires"); + assert_eq!( + english( + Text::message("usage-perplexity-used-of") + .arg("used", "2,750") + .arg("total", "10,000") + ), + "2,750 of 10,000" + ); +} + +#[test] +fn poe_reads_as_it_did() { + let points = |points: f64, amount: &str| { + Text::message("usage-poe-point-count") + .arg("points", points) + .arg("amount", amount) + }; + assert_eq!(english(points(1500., "1,500")), "1,500 points"); + assert_eq!(english(points(42.5, "42.5")), "42.5 points"); + assert_eq!(english(points(1., "1")), "1 point"); + let summary = |requests: u64| { + Text::message("usage-poe-summary") + .arg("points", points(300., "300")) + .arg("requests", requests) + .arg("count", requests.to_string()) + }; + assert_eq!(english(summary(2)), "300 points · 2 requests"); + assert_eq!(english(summary(1)), "300 points · 1 request"); + assert_eq!( + english( + Text::message("usage-poe-summary-cost") + .arg("points", points(1000., "1,000")) + .arg("requests", 1204) + .arg("count", "1,204") + .arg("cost", "$0.09") + ), + "1,000 points · 1,204 requests · $0.09" + ); + assert_eq!( + english( + Text::message("usage-poe-model-points") + .arg("model", "Claude-Sonnet") + .arg("points", points(900., "900")) + ), + "Claude-Sonnet · 900 points" + ); + assert_eq!(label("usage-poe-points"), "Points"); + assert_eq!(label("usage-poe-current-balance"), "Current balance"); + assert_eq!(label("usage-poe-today"), "Today"); + assert_eq!(label("usage-poe-last-7-days"), "Last 7 days"); + assert_eq!(label("usage-poe-last-30-days"), "Last 30 days"); + assert_eq!(label("usage-poe-top-model"), "Top model"); + assert_eq!( + label("usage-poe-points-by-model"), + "Points by model, last 30 days" + ); +} + +#[test] +fn qoder_reads_as_it_did() { + assert_eq!(label("usage-qoder-credits"), "Credits"); + assert_eq!(label("usage-qoder-credits-left"), "Credits left"); +} + +#[test] +fn raycast_reads_as_it_did() { + assert_eq!(label("usage-raycast-credits"), "Credits"); + assert_eq!(label("usage-raycast-credits-left"), "Credits left"); + assert_eq!(label("usage-raycast-left"), "Left"); + assert_eq!(label("usage-raycast-total"), "Total"); + assert_eq!(label("usage-raycast-renews"), "Renews"); +} + +#[test] +fn replicate_reads_as_it_did() { + assert_eq!( + label("usage-replicate-spent-this-month"), + "Spent this month" + ); + assert_eq!(label("usage-replicate-credit-balance"), "Credit balance"); + assert_eq!(label("usage-replicate-account"), "Account"); + assert_eq!(label("usage-replicate-organization"), "Organization"); + assert_eq!(label("usage-replicate-user"), "User"); +} + +#[test] +fn sakana_reads_as_it_did() { + assert_eq!(label("usage-sakana-credit-balance"), "Credit balance"); + assert_eq!( + label("usage-sakana-pay-as-you-go-usage"), + "Pay-as-you-go usage" + ); + assert_eq!(label("usage-sakana-pay-as-you-go"), "Pay as you go"); + assert_eq!(label("usage-sakana-usage-period"), "Usage period"); +} + +#[test] +fn stepfun_reads_as_it_did() { + assert_eq!(label("usage-stepfun-credit"), "Credit"); +} + +#[test] +fn sub2api_reads_as_it_did() { + assert_eq!(label("usage-sub2api-quota"), "Quota"); + assert_eq!( + english(Text::message("usage-sub2api-window-limit").arg("window", "30m")), + "30m limit" + ); + assert_eq!(label("usage-sub2api-today-requests"), "Today requests"); + assert_eq!(label("usage-sub2api-today-tokens"), "Today tokens"); + assert_eq!( + label("usage-sub2api-all-time-requests"), + "All time requests" + ); + assert_eq!(label("usage-sub2api-all-time-tokens"), "All time tokens"); + assert_eq!(label("usage-sub2api-expires"), "Expires"); + assert_eq!(label("usage-sub2api-balance"), "Balance"); + assert_eq!(label("usage-sub2api-usage-summary"), "Usage summary"); +} + +#[test] +fn synthetic_reads_as_it_did() { + assert_eq!(label("usage-synthetic-search-hourly"), "Search hourly"); + assert_eq!( + label("usage-synthetic-weekly-credits-left"), + "Weekly credits left" + ); + assert_eq!( + english(Text::message("usage-synthetic-quota").arg("number", 2)), + "Quota 2" + ); +} + +#[test] +fn t3chat_reads_as_it_did() { + assert_eq!(label("usage-t3chat-overage"), "Overage"); + assert_eq!(label("usage-t3chat-plan"), "Plan"); + assert_eq!(label("usage-t3chat-usage-band"), "Usage band"); + assert_eq!(label("usage-t3chat-subscription"), "Subscription"); +} + +#[test] +fn typesafe_reads_as_it_did() { + assert_eq!(label("usage-typesafe-balance"), "Balance"); + assert_eq!(label("usage-typesafe-spent"), "Spent"); + assert_eq!( + english(Text::message("usage-typesafe-spent-cycle").arg("cycle", "September 2026")), + "Spent (September 2026)" + ); + assert_eq!(label("usage-typesafe-credits"), "Credits"); + assert_eq!(label("usage-typesafe-credit"), "Credit"); + assert_eq!( + english( + Text::message("usage-typesafe-credit-left") + .arg("remaining", "4.98") + .arg("amount", "5") + .arg("expires", "Oct 19") + ), + "4.98 of 5, expires Oct 19" + ); + assert_eq!( + label("usage-typesafe-additional-credits"), + "Additional credits" + ); +} + +#[test] +fn v0_reads_as_it_did() { + assert_eq!(label("usage-v0-api"), "v0 API"); + assert_eq!(label("usage-v0-rate-limit"), "Rate limit"); + assert_eq!(label("usage-v0-billing-remaining"), "Billing remaining"); + assert_eq!(label("usage-v0-on-demand-balance"), "On-demand balance"); + assert_eq!( + label("usage-v0-rate-limit-remaining"), + "Rate-limit remaining" + ); + assert_eq!(label("usage-v0-billing-type"), "Billing type"); + assert_eq!(label("usage-v0-scope"), "Scope"); + assert_eq!( + english( + Text::message("usage-v0-remaining-of") + .arg("remaining", "750") + .arg("limit", "1,000") + ), + "750 of 1,000" + ); + assert_eq!( + english(Text::message("usage-v0-unavailable").arg("limit", "1,000")), + "Unavailable (limit 1,000)" + ); +} + +#[test] +fn venice_reads_as_it_did() { + assert_eq!(label("usage-venice-diem-allocation"), "DIEM allocation"); + assert_eq!(label("usage-venice-status"), "Status"); + assert_eq!(label("usage-venice-api-calls"), "API calls"); + assert_eq!( + label("usage-venice-unavailable-for-api-calls"), + "Balance unavailable for API calls" + ); + assert_eq!( + label("usage-venice-subscription-credits"), + "Subscription credits" + ); + assert_eq!(label("usage-venice-total-credits"), "Total credits"); + assert_eq!(label("usage-venice-used-this-cycle"), "Used this cycle"); + assert_eq!(label("usage-venice-credits"), "Credits"); + assert_eq!(label("usage-venice-bank-cap"), "Bank cap"); + assert_eq!(label("usage-venice-next-refill"), "Next refill"); + let credits = |credits: f64, count: &str| { + english( + Text::message("usage-venice-credit-count") + .arg("credits", credits) + .arg("count", count), + ) + }; + assert_eq!(credits(3000., "3,000"), "3,000 credits"); + assert_eq!(credits(1., "1"), "1 credit"); +} + +#[test] +fn vercel_reads_as_it_did() { + assert_eq!(label("usage-vercel-available-balance"), "Available balance"); + assert_eq!(label("usage-vercel-lifetime-spend"), "Lifetime spend"); +} + +#[test] +fn vertexai_reads_as_it_did() { + assert_eq!(label("usage-vertexai-peak-quota"), "Peak quota"); + assert_eq!(label("usage-vertexai-google-cloud"), "Google Cloud"); + assert_eq!(label("usage-vertexai-project"), "Project"); + assert_eq!( + label("usage-vertexai-project-not-set"), + "Not set: run `gcloud config set project PROJECT_ID`" + ); +} + +#[test] +fn warp_reads_as_it_did() { + assert_eq!(label("usage-warp-credits"), "Credits"); + assert_eq!(label("usage-warp-add-on-credits"), "Add-on credits"); + assert_eq!(label("usage-warp-monthly-credits"), "Monthly credits"); + assert_eq!(label("usage-warp-unlimited"), "Unlimited"); + assert_eq!( + english( + Text::message("usage-warp-used-of") + .arg("used", "5") + .arg("limit", "1,500") + ), + "5 of 1,500" + ); + assert_eq!(label("usage-warp-next-add-on-expiry"), "Next add-on expiry"); + let expiring = |credits: i64, count: &str| { + english( + Text::message("usage-warp-credits-on") + .arg("credits", credits) + .arg("count", count) + .arg("date", "Mar 1, 2026"), + ) + }; + assert_eq!(expiring(10, "10"), "10 credits on Mar 1, 2026"); + assert_eq!(expiring(1, "1"), "1 credit on Mar 1, 2026"); +} + +#[test] +fn windsurf_reads_as_it_did() { + assert_eq!(label("usage-windsurf-messages"), "Messages"); + assert_eq!(label("usage-windsurf-flow-actions"), "Flow actions"); + assert_eq!(label("usage-windsurf-plan"), "Plan"); + assert_eq!(label("usage-windsurf-renews"), "Renews"); +} + +#[test] +fn xai_reads_as_it_did() { + assert_eq!(label("usage-xai-prepaid-balance"), "Prepaid balance"); + assert_eq!(label("usage-xai-management-api"), "Management API"); + assert_eq!(label("usage-xai-spend"), "Spend"); + assert_eq!(label("usage-xai-today"), "Today"); + assert_eq!(label("usage-xai-last-30-days"), "Last 30 days"); + assert_eq!( + label("usage-xai-last-30-days-partial"), + "Last 30 days (partial)" + ); +} + +#[test] +fn xkiro_reads_as_it_did() { + assert_eq!(label("usage-xkiro-free-tokens"), "Free tokens"); + assert_eq!(label("usage-xkiro-tokens-used-today"), "Tokens used today"); + assert_eq!(label("usage-xkiro-daily-allowance"), "Daily allowance"); + assert_eq!(label("usage-xkiro-no-cap-reported"), "No cap reported"); + assert_eq!(label("usage-xkiro-tokens-remaining"), "Tokens remaining"); + assert_eq!(label("usage-xkiro-daily-reset"), "Daily reset"); + assert_eq!(label("usage-xkiro-pay-as-you-go"), "Pay as you go"); + assert_eq!(label("usage-xkiro-wallet"), "Wallet"); +} + +#[test] +fn zai_reads_as_it_did() { + assert_eq!( + english(Text::message("usage-zai-hours").arg("hours", 3_u64)), + "3-hour" + ); + assert_eq!(label("usage-zai-quota"), "Quota"); + assert_eq!(label("usage-zai-quota-details"), "Quota details"); + assert_eq!(label("usage-zai-credit-quota"), "Credit quota"); + assert_eq!(label("usage-zai-token-quota"), "Token quota"); + assert_eq!( + label("usage-zai-session-credit-quota"), + "Session credit quota" + ); + assert_eq!( + label("usage-zai-session-token-quota"), + "Session token quota" + ); + assert_eq!(label("usage-zai-mcp-quota"), "MCP quota"); + let detail = |percent: &str, counts: &str| { + english( + Text::message("usage-zai-detail") + .arg("percent", percent) + .arg("counts", counts) + .arg("limit", "1000") + .arg("remaining", "776"), + ) + }; + assert_eq!(detail("9", "none"), "9% used"); + assert_eq!( + detail("22.4", "both"), + "22.4% used · 1000 limit · 776 remaining" + ); + assert_eq!(detail("22.4", "limit"), "22.4% used · 1000 limit"); + assert_eq!(detail("22.4", "remaining"), "22.4% used · 776 remaining"); + assert_eq!(label("usage-zai-quota-rate"), "Quota rate"); + assert_eq!(label("usage-zai-peak"), "Peak"); + assert_eq!(label("usage-zai-off-peak"), "Off-peak"); + assert_eq!(label("usage-zai-account-balance"), "Account balance"); + assert_eq!( + label("usage-zai-tokens-last-30-days"), + "Tokens, last 30 days" + ); + assert_eq!(label("usage-zai-unknown-model"), "Unknown"); +} + +#[test] +fn zed_reads_as_it_did() { + assert_eq!(label("usage-zed-plan"), "Plan"); + assert_eq!(label("usage-zed-edit-predictions"), "Edit predictions"); + assert_eq!( + english( + Text::message("usage-zed-used-of") + .arg("used", "30") + .arg("limit", "120") + ), + "30 of 120" + ); + assert_eq!( + english(Text::message("usage-zed-used-unlimited").arg("used", "30")), + "30 used · Unlimited" + ); + assert_eq!(label("usage-zed-billing"), "Billing"); + assert_eq!(label("usage-zed-overdue-invoices"), "Overdue invoices"); + assert_eq!(label("usage-zed-token-spend"), "Token spend"); + assert_eq!( + label("usage-zed-current-billing-period"), + "Current billing period" + ); + assert_eq!(label("usage-zed-spent"), "Spent"); + assert_eq!(label("usage-zed-spend-limit"), "Spend limit"); + assert_eq!(label("usage-zed-not-reported"), "Not reported"); + assert_eq!(label("usage-zed-remaining-budget"), "Remaining budget"); +} + +#[test] +fn zenmux_reads_as_it_did() { + assert_eq!(label("usage-zenmux-quota"), "Quota"); + assert_eq!(label("usage-zenmux-session"), "Session"); + assert_eq!(label("usage-zenmux-weekly"), "Weekly"); + let flows = |used: &str, max: &str, flows: f64| { + english( + Text::message("usage-zenmux-flows") + .arg("used", used) + .arg("max", max) + .arg("flows", flows), + ) + }; + assert_eq!(flows("57.20", "800", 800.), "57.20 / 800 flows"); + assert_eq!(flows("0.50", "1", 1.), "0.50 / 1 flow"); + assert_eq!( + english(Text::message("usage-zenmux-tier-plan").arg("tier", "Ultra")), + "Ultra plan" + ); + assert_eq!( + english( + Text::message("usage-zenmux-tier-plan-status") + .arg("tier", "Ultra") + .arg("status", "Overdue") + ), + "Ultra plan · Overdue" + ); + assert_eq!(label("usage-zenmux-plan-expires"), "Plan expires"); + assert_eq!(label("usage-zenmux-payg-balance"), "PAYG balance"); +} + +#[test] +fn zoommate_reads_as_it_did() { + assert_eq!(label("usage-zoommate-credits"), "Credits"); + assert_eq!(label("usage-zoommate-credits-used"), "Credits used"); + assert_eq!(label("usage-zoommate-credits-left"), "Credits left"); + assert_eq!(label("usage-zoommate-budget"), "Budget"); + assert_eq!(label("usage-zoommate-unlimited"), "Unlimited"); + assert_eq!(label("usage-zoommate-overage"), "Overage"); + assert_eq!( + english(Text::message("usage-zoommate-overage-credits").arg("credits", "12.50")), + "12.50 credits" + ); + assert_eq!(label("usage-zoommate-quota"), "Quota"); + assert_eq!(label("usage-zoommate-exhausted"), "Exhausted"); +} diff --git a/crates/herdr-usage/src/providers/perplexity.rs b/crates/herdr-usage/src/providers/perplexity.rs index 49e0c0f28..a1c157389 100644 --- a/crates/herdr-usage/src/providers/perplexity.rs +++ b/crates/herdr-usage/src/providers/perplexity.rs @@ -10,7 +10,7 @@ //! which the site's own session library also accepts. use crate::model::Count; -use crate::text::{Text, pairs}; +use crate::text::Text; use crate::{ Error, Result, { @@ -131,19 +131,29 @@ pub(crate) fn parse(body: &str, now: SystemTime) -> Result { } else { Some(Window::new(Kind::Monthly, 100., renewal, Some(MONTH))) }; - let pool = |label: &str, used: f64, total: f64| { + let pool = |label: Text, used: f64, total: f64| { (total > 0.).then(|| { Balance::new(label, (total - used).max(0.), Unit::Count(Count::Credits)).out_of(total) }) }; let balances = [ - pool("Bonus credits", promo_used, promo), - pool("Purchased credits", purchased_used, purchased), + pool( + Text::message("usage-perplexity-bonus-credits"), + promo_used, + promo, + ), + pool( + Text::message("usage-perplexity-purchased-credits"), + purchased_used, + purchased, + ), ]; let whole = |value: f64| group(value.round() as i64); let mut facts = vec![( - "Credits".to_owned(), - format!("{} of {}", whole(recurring_used), whole(recurring)), + Text::message("usage-perplexity-credits"), + Text::message("usage-perplexity-used-of") + .arg("used", whole(recurring_used)) + .arg("total", whole(recurring)), )]; let expiry = credits .credit_grants @@ -153,27 +163,28 @@ pub(crate) fn parse(body: &str, now: SystemTime) -> Result { .min(); if let Some(expiry) = expiry { facts.push(( - "Bonus expires".to_owned(), - chrono::DateTime::::from(expiry) - .format("%b %-d, %Y") - .to_string(), + Text::message("usage-perplexity-bonus-expires"), + Text::service( + chrono::DateTime::::from(expiry) + .format("%b %-d, %Y") + .to_string(), + ), )); } - let plan = (recurring > 0.) - .then_some(if recurring < 5000. { "Pro" } else { "Max" }) - .map(str::to_owned); + // Perplexity's own plan names. + let plan = (recurring > 0.).then_some(if recurring < 5000. { "Pro" } else { "Max" }); Ok(Report::new( Provider(&Perplexity), Account { email: None, - plan: plan.map(Text::from), + plan: plan.map(Text::service), }, window.into_iter().collect(), ) .with_balances(balances.into_iter().flatten()) .with_sections([Section::Facts { - title: "Credits".into(), - facts: pairs(facts), + title: Text::message("usage-perplexity-credits"), + facts, }])) } @@ -213,7 +224,6 @@ impl Grant { #[allow(clippy::unwrap_used, clippy::expect_used)] mod tests { use super::*; - use crate::text::pair; use std::time::Duration; const NOW: u64 = 1_780_000_000; @@ -253,14 +263,22 @@ mod tests { Some(SystemTime::UNIX_EPOCH + Duration::from_secs(RENEWAL)) ); assert_eq!(report.balances.len(), 1); - assert_eq!(report.balances[0].label, "Bonus credits"); + assert_eq!( + report.balances[0].label, + Text::message("usage-perplexity-bonus-credits") + ); assert_eq!(report.balances[0].amount, 20_000.); let Section::Facts { facts, .. } = &report.sections[0] else { panic!("expected facts"); }; assert_eq!( facts[0], - pair(("Credits".to_owned(), "2,750 of 10,000".to_owned())) + ( + Text::message("usage-perplexity-credits"), + Text::message("usage-perplexity-used-of") + .arg("used", "2,750") + .arg("total", "10,000") + ) ); } diff --git a/crates/herdr-usage/src/providers/poe.rs b/crates/herdr-usage/src/providers/poe.rs index 93db4f90b..c0c805dca 100644 --- a/crates/herdr-usage/src/providers/poe.rs +++ b/crates/herdr-usage/src/providers/poe.rs @@ -6,7 +6,7 @@ //! and a per-model split. Poe has no OAuth or cookie sign-in to port. use crate::model::Count; -use crate::text::{pairs, share}; +use crate::text::Text; use crate::{ Result, { @@ -212,16 +212,18 @@ impl Summary { }) } - fn text(&self) -> String { - let mut text = format!( - "{} · {} requests", - points(self.points), - group(self.requests as i64) - ); - if let Some(cost) = self.cost { - text.push_str(&format!(" · ${cost:.2}")); + fn text(&self) -> Text { + match self.cost { + None => Text::message("usage-poe-summary") + .arg("points", points(self.points)) + .arg("requests", self.requests) + .arg("count", group(self.requests as i64)), + Some(cost) => Text::message("usage-poe-summary-cost") + .arg("points", points(self.points)) + .arg("requests", self.requests) + .arg("count", group(self.requests as i64)) + .arg("cost", format!("${cost:.2}")), } - text } } @@ -235,25 +237,28 @@ fn report(balance: Option, entries: &[Entry], now: SystemTime) -> Report { let week = now.checked_sub(7 * DAY).unwrap_or(SystemTime::UNIX_EPOCH); let mut facts = Vec::new(); if let Some(balance) = balance { - facts.push(("Current balance".to_owned(), points(balance))); + facts.push((Text::message("usage-poe-current-balance"), points(balance))); } let mut sections = Vec::new(); if !entries.is_empty() { let summaries = [ ( - "Today", + Text::message("usage-poe-today"), Summary::of(entries.iter().filter(|entry| day(entry.at) == today)), ), ( - "Last 7 days", + Text::message("usage-poe-last-7-days"), Summary::of(entries.iter().filter(|entry| entry.at >= week)), ), - ("Last 30 days", Summary::of(entries)), + ( + Text::message("usage-poe-last-30-days"), + Summary::of(entries), + ), ]; facts.extend( summaries - .iter() - .map(|(label, summary)| ((*label).to_owned(), summary.text())), + .into_iter() + .map(|(label, summary)| (label, summary.text())), ); let mut models: HashMap<&str, f64> = HashMap::new(); for entry in entries { @@ -262,38 +267,49 @@ fn report(balance: Option, entries: &[Entry], now: SystemTime) -> Report { let mut models: Vec<_> = models.into_iter().collect(); models.sort_by(|a, b| b.1.total_cmp(&a.1).then_with(|| a.0.cmp(b.0))); if let Some((model, spent)) = models.first() { - facts.push(("Top model".into(), format!("{model} · {}", points(*spent)))); + facts.push(( + Text::message("usage-poe-top-model"), + Text::message("usage-poe-model-points") + .arg("model", *model) + .arg("points", points(*spent)), + )); } let total: f64 = models.iter().map(|(_, spent)| spent).sum(); if total > 0. { sections.push(Section::Shares { - title: "Points by model, last 30 days".into(), + title: Text::message("usage-poe-points-by-model"), shares: models .iter() .take(5) - .map(|(model, spent)| ((*model).to_owned(), (spent / total * 100.) as f32)) - .map(share) + .map(|(model, spent)| (Text::service(*model), (spent / total * 100.) as f32)) .collect(), }); } } let facts = (!facts.is_empty()).then(|| Section::Facts { - title: "Points".into(), - facts: pairs(facts), + title: Text::message("usage-poe-points"), + facts, }); Report::new(Provider(&Poe), Account::default(), Vec::new()) - .with_balances( - balance.map(|balance| Balance::new("Points", balance, Unit::Count(Count::Points))), - ) + .with_balances(balance.map(|balance| { + Balance::new( + Text::message("usage-poe-points"), + balance, + Unit::Count(Count::Points), + ) + })) .with_sections(facts.into_iter().chain(sections)) } -fn points(value: f64) -> String { - if value >= 1000. || value.fract() == 0. { - format!("{} points", group(value.round() as i64)) +fn points(value: f64) -> Text { + let amount = if value >= 1000. || value.fract() == 0. { + group(value.round() as i64) } else { - format!("{value:.1} points") - } + format!("{value:.1}") + }; + Text::message("usage-poe-point-count") + .arg("points", value) + .arg("amount", amount) } #[cfg(test)] @@ -301,6 +317,12 @@ fn points(value: f64) -> String { mod tests { use super::*; + fn point_count(points: f64, amount: &str) -> Text { + Text::message("usage-poe-point-count") + .arg("points", points) + .arg("amount", amount) + } + fn at(seconds: u64) -> SystemTime { SystemTime::UNIX_EPOCH + Duration::from_secs(seconds) } @@ -326,7 +348,15 @@ mod tests { let Section::Facts { facts, .. } = &report.sections[0] else { panic!("expected facts"); }; - assert_eq!(facts, &[("Current balance".into(), "1,500 points".into())]); + assert_eq!( + facts, + &[( + Text::message("usage-poe-current-balance"), + Text::message("usage-poe-point-count") + .arg("points", 1500.) + .arg("amount", "1,500") + )] + ); } #[test] @@ -356,25 +386,42 @@ mod tests { let Section::Facts { facts, .. } = &report.sections[0] else { panic!("expected facts"); }; - let fact = |label: &str| { - facts - .iter() - .find(|(name, _)| name == label) - .map(|(_, value)| value.as_str()) + let summary = |points: f64, amount: &str, requests: u64| { + Text::message("usage-poe-summary-cost") + .arg("points", point_count(points, amount)) + .arg("requests", requests) + .arg("count", requests.to_string()) + .arg("cost", "$0.09") }; - assert_eq!(fact("Current balance"), Some("42.5 points")); - assert_eq!(fact("Today"), Some("300 points · 1 requests · $0.09")); - assert_eq!(fact("Last 7 days"), Some("400 points · 2 requests · $0.09")); assert_eq!( - fact("Last 30 days"), - Some("1,000 points · 3 requests · $0.09") + facts, + &[ + ( + Text::message("usage-poe-current-balance"), + point_count(42.5, "42.5") + ), + (Text::message("usage-poe-today"), summary(300., "300", 1)), + ( + Text::message("usage-poe-last-7-days"), + summary(400., "400", 2) + ), + ( + Text::message("usage-poe-last-30-days"), + summary(1000., "1,000", 3) + ), + ( + Text::message("usage-poe-top-model"), + Text::message("usage-poe-model-points") + .arg("model", "Claude-Sonnet") + .arg("points", point_count(900., "900")) + ), + ] ); - assert_eq!(fact("Top model"), Some("Claude-Sonnet · 900 points")); let Section::Shares { shares, .. } = &report.sections[1] else { panic!("expected shares"); }; - assert_eq!(shares[0], ("Claude-Sonnet".into(), 90.)); - assert_eq!(shares[1], ("GPT-5".into(), 10.)); + assert_eq!(shares[0], (Text::service("Claude-Sonnet"), 90.)); + assert_eq!(shares[1], (Text::service("GPT-5"), 10.)); } #[test] diff --git a/crates/herdr-usage/src/providers/qoder.rs b/crates/herdr-usage/src/providers/qoder.rs index 286c2250a..f47af3c56 100644 --- a/crates/herdr-usage/src/providers/qoder.rs +++ b/crates/herdr-usage/src/providers/qoder.rs @@ -95,14 +95,20 @@ pub(crate) fn parse(body: &str, china: bool) -> Result { _ => 100., }; let resets_at = usage.next_reset_at.as_ref().and_then(Timestamp::time); - let window = Window::new(Kind::Named("Credits".into()), percent, resets_at, None); + let window = Window::new( + Kind::Named(Text::message("usage-qoder-credits")), + percent, + resets_at, + None, + ); let account = Account { email: None, - plan: china.then(|| "qoder.com.cn".to_owned()).map(Text::from), + // The China site's domain, a name rather than words. + plan: china.then(|| Text::service("qoder.com.cn")), }; Ok( Report::new(Provider(&Qoder), account, vec![window]).with_balances([Balance::new( - "Credits left", + Text::message("usage-qoder-credits-left"), remaining, Unit::Count(Count::Credits), ) diff --git a/crates/herdr-usage/src/providers/qwencloud.rs b/crates/herdr-usage/src/providers/qwencloud.rs index e23fb18f1..67edbe481 100644 --- a/crates/herdr-usage/src/providers/qwencloud.rs +++ b/crates/herdr-usage/src/providers/qwencloud.rs @@ -179,7 +179,8 @@ mod tests { let Section::Facts { facts, .. } = &report.sections[0] else { panic!("expected credit facts"); }; - assert_eq!(facts[1], ("Weekly".into(), "500 / 50,000 credits".into())); + // The facts' words are alibabatokenplan's, and tested there. + assert_eq!(facts.len(), 2); } #[test] diff --git a/crates/herdr-usage/src/providers/raycast.rs b/crates/herdr-usage/src/providers/raycast.rs index 28926b056..f9097ac8b 100644 --- a/crates/herdr-usage/src/providers/raycast.rs +++ b/crates/herdr-usage/src/providers/raycast.rs @@ -7,7 +7,7 @@ //! per-model breakdown are not fetched, as CodexBar does not either. use crate::model::Count; -use crate::text::{Text, pairs}; +use crate::text::Text; use crate::{ Result, { @@ -67,6 +67,7 @@ pub(crate) fn parse(body: &str) -> Result { Some(at) => Some(at.time().ok_or_else(invalid)?), None => None, }; + // Raycast's own plan names, or its tier as it sends it. let plan = credits .funding_subscription .and_then(|funding| funding.tier) @@ -80,7 +81,7 @@ pub(crate) fn parse(body: &str) -> Result { }); let account = Account { email: None, - plan: plan.map(Text::from), + plan: plan.map(Text::service), }; let credits_unit = || Unit::Count(Count::Credits); // Rolled-over credits can leave more than the grant; the reported total @@ -93,28 +94,45 @@ pub(crate) fn parse(body: &str) -> Result { account, vec![Window::new(Kind::Monthly, used, renewal, Some(MONTH))], ) - .with_balances([Balance::new("Credits left", remaining, credits_unit()).out_of(total)]) + .with_balances([Balance::new( + Text::message("usage-raycast-credits-left"), + remaining, + credits_unit(), + ) + .out_of(total)]) } _ => { - let mut facts: Vec<(String, String)> = Vec::new(); + let mut facts = Vec::new(); if let Some(remaining) = remaining { - facts.push(("Left".into(), trimmed(remaining))); + facts.push(( + Text::message("usage-raycast-left"), + Text::service(trimmed(remaining)), + )); } if let Some(total) = total { - facts.push(("Total".into(), trimmed(total))); + facts.push(( + Text::message("usage-raycast-total"), + Text::service(trimmed(total)), + )); } if let Some(renewal) = renewal { let at = chrono::DateTime::::from(renewal); - facts.push(("Renews".into(), at.format("%b %-d").to_string())); + facts.push(( + Text::message("usage-raycast-renews"), + Text::service(at.format("%b %-d").to_string()), + )); } Report::new(Provider(&Raycast), account, Vec::new()) - .with_balances( - remaining - .map(|remaining| Balance::new("Credits left", remaining, credits_unit())), - ) + .with_balances(remaining.map(|remaining| { + Balance::new( + Text::message("usage-raycast-credits-left"), + remaining, + credits_unit(), + ) + })) .with_sections([Section::Facts { - title: "Credits".into(), - facts: pairs(facts), + title: Text::message("usage-raycast-credits"), + facts, }]) } }; @@ -178,8 +196,14 @@ mod tests { let Section::Facts { facts, .. } = &report.sections[0] else { panic!("expected facts"); }; - assert_eq!(facts[0], ("Left".into(), "12.5".into())); - assert_eq!(facts[1], ("Total".into(), "0".into())); + assert_eq!( + facts[0], + (Text::message("usage-raycast-left"), Text::service("12.5")) + ); + assert_eq!( + facts[1], + (Text::message("usage-raycast-total"), Text::service("0")) + ); } #[test] diff --git a/crates/herdr-usage/src/providers/replicate.rs b/crates/herdr-usage/src/providers/replicate.rs index 71ea388dd..0d8d63153 100644 --- a/crates/herdr-usage/src/providers/replicate.rs +++ b/crates/herdr-usage/src/providers/replicate.rs @@ -177,20 +177,27 @@ pub(crate) fn parse( .as_deref() .and_then(money); let usd = || Unit::Currency("USD".into()); - let mut balances = vec![Balance::new("Spent this month", spent, usd())]; + let mut balances = vec![Balance::new( + Text::message("usage-replicate-spent-this-month"), + spent, + usd(), + )]; if let Some(balance) = balance { - balances.push(Balance::new("Credit balance", balance, usd())); + balances.push(Balance::new( + Text::message("usage-replicate-credit-balance"), + balance, + usd(), + )); } let account = Section::Facts { - title: "Account".into(), + title: Text::message("usage-replicate-account"), facts: vec![( if owner.organization { - "Organization" + Text::message("usage-replicate-organization") } else { - "User" - } - .into(), - Text::from(owner.username.clone()), + Text::message("usage-replicate-user") + }, + Text::service(owner.username.clone()), )], }; Ok( @@ -293,7 +300,20 @@ mod tests { let owner = owner(PAGE).unwrap(); let report = parse(&owner, INVOICES, Some(r#"{"unused_credit":"80.0"}"#), now()).unwrap(); assert!(report.windows.is_empty()); - assert_eq!(report.balances[0].label, "Spent this month"); + assert_eq!( + report.balances[0].label, + Text::message("usage-replicate-spent-this-month") + ); + let Section::Facts { facts, .. } = &report.sections[0] else { + panic!("expected facts"); + }; + assert_eq!( + facts, + &[( + Text::message("usage-replicate-user"), + Text::service("demo-user") + )] + ); assert!((report.balances[0].amount - 12.4).abs() < 1e-9); assert_eq!(report.balances[1].amount, 80.); let without = parse(&owner, INVOICES, Some(r#"{"unused_credit":"NaN"}"#), now()).unwrap(); diff --git a/crates/herdr-usage/src/providers/sakana.rs b/crates/herdr-usage/src/providers/sakana.rs index 55d1ed796..aace91b10 100644 --- a/crates/herdr-usage/src/providers/sakana.rs +++ b/crates/herdr-usage/src/providers/sakana.rs @@ -82,7 +82,7 @@ pub(crate) fn parse(billing: &str, payg: Option<&str>) -> Result { Provider(&Sakana), Account { email: None, - plan: plan.map(Text::from), + plan: plan.map(Text::service), }, windows, ) @@ -188,7 +188,11 @@ fn pay_as_you_go(html: &str) -> Option<(Vec, Vec
)> { .flatten() .next()?; let usd = || Unit::Currency("USD".into()); - let mut balances = vec![Balance::new("Credit balance", balance, usd())]; + let mut balances = vec![Balance::new( + Text::message("usage-sakana-credit-balance"), + balance, + usd(), + )]; let usage = nodes .iter() .position(|node| matches!(node, Node::Text(text) if text == "Usage")) @@ -211,11 +215,18 @@ fn pay_as_you_go(html: &str) -> Option<(Vec, Vec
)> { }); let mut sections = Vec::new(); if let Some(spent) = usage { - balances.push(Balance::new("Pay-as-you-go usage", spent, usd())); + balances.push(Balance::new( + Text::message("usage-sakana-pay-as-you-go-usage"), + spent, + usd(), + )); if let Some(range) = range { sections.push(Section::Facts { - title: "Pay as you go".into(), - facts: vec![("Usage period".into(), Text::from(range))], + title: Text::message("usage-sakana-pay-as-you-go"), + facts: vec![( + Text::message("usage-sakana-usage-period"), + Text::service(range), + )], }); } } @@ -364,15 +375,22 @@ mod tests { assert_eq!( report.balances, vec![ - Balance::new("Credit balance", 12.34, usd.clone()), - Balance::new("Pay-as-you-go usage", 5.67, usd), + Balance::new( + Text::message("usage-sakana-credit-balance"), + 12.34, + usd.clone() + ), + Balance::new(Text::message("usage-sakana-pay-as-you-go-usage"), 5.67, usd), ] ); assert_eq!( report.sections, vec![Section::Facts { - title: "Pay as you go".into(), - facts: vec![("Usage period".into(), "Jun 02, 2026 - Jul 01, 2026".into())], + title: Text::message("usage-sakana-pay-as-you-go"), + facts: vec![( + Text::message("usage-sakana-usage-period"), + Text::service("Jun 02, 2026 - Jul 01, 2026") + )], }] ); } diff --git a/crates/herdr-usage/src/providers/stepfun.rs b/crates/herdr-usage/src/providers/stepfun.rs index e9afade74..ef8840489 100644 --- a/crates/herdr-usage/src/providers/stepfun.rs +++ b/crates/herdr-usage/src/providers/stepfun.rs @@ -96,7 +96,7 @@ pub(crate) fn parse(body: &str, plan: Option) -> Result { } let account = Account { email: None, - plan: plan.map(Text::from), + plan: plan.map(Text::service), }; if limits.is_credit_plan() { let windows = limits @@ -109,7 +109,7 @@ pub(crate) fn parse(body: &str, plan: Option) -> Result { .as_ref() .and_then(Timestamp::time); Some(Window::new( - Kind::Named("Credit".into()), + Kind::Named(Text::message("usage-stepfun-credit")), (1. - left) * 100., resets_at, resets_at.map(|_| MONTH), @@ -288,7 +288,10 @@ mod tests { }"#; let report = parse(body, None).unwrap(); assert_eq!(report.windows.len(), 1); - assert_eq!(report.windows[0].kind, Kind::Named("Credit".into())); + assert_eq!( + report.windows[0].kind, + Kind::Named(Text::message("usage-stepfun-credit")) + ); assert_eq!(report.windows[0].percent(), 50); assert_eq!(report.windows[0].length, Some(MONTH)); } diff --git a/crates/herdr-usage/src/providers/sub2api.rs b/crates/herdr-usage/src/providers/sub2api.rs index 4a7be158b..ff9c2e2a5 100644 --- a/crates/herdr-usage/src/providers/sub2api.rs +++ b/crates/herdr-usage/src/providers/sub2api.rs @@ -6,7 +6,7 @@ //! are not ported: one key is read, so configure the group to watch. //! Daily totals use UTC days, where CodexBar passes the Mac's time zone. -use crate::text::{Text, pairs}; +use crate::text::Text; use crate::{ Error, Result, { @@ -125,17 +125,17 @@ fn parse(body: &str) -> Result { } else if let Some(quota) = &usage.quota { let quota_unit = quota.unit.as_deref().unwrap_or(&unit); windows.extend(limit( - Kind::Named("Quota".into()), + Kind::Named(Text::message("usage-sub2api-quota")), quota.used, Some(quota.limit), )); facts.push(( - "Quota".to_owned(), - format!( + Text::message("usage-sub2api-quota"), + Text::service(format!( "{} / {}", money(quota.used, quota_unit), money(quota.limit, quota_unit) - ), + )), )); } let rates: Vec = usage @@ -149,7 +149,10 @@ fn parse(body: &str) -> Result { "1d" => (Kind::Daily, Kind::Daily.length()), "7d" => (Kind::Weekly, Kind::Weekly.length()), _ => ( - Kind::Named(format!("{} limit", rate.window.trim()).into()), + Kind::Named( + Text::message("usage-sub2api-window-limit") + .arg("window", rate.window.trim()), + ), None, ), }; @@ -171,28 +174,36 @@ fn parse(body: &str) -> Result { }; if let Some(totals) = &usage.usage { - for (title, total) in [("Today", &totals.today), ("All time", &totals.total)] { + for (requests, tokens, total) in [ + ( + Text::message("usage-sub2api-today-requests"), + Text::message("usage-sub2api-today-tokens"), + &totals.today, + ), + ( + Text::message("usage-sub2api-all-time-requests"), + Text::message("usage-sub2api-all-time-tokens"), + &totals.total, + ), + ] { let Some(total) = total else { continue; }; + facts.push((requests, Text::service(group(total.requests.unwrap_or(0))))); facts.push(( - format!("{title} requests"), - group(total.requests.unwrap_or(0)), - )); - facts.push(( - format!("{title} tokens"), - format!( + tokens, + Text::service(format!( "{} · {}", group(total.total_tokens.unwrap_or(0)), money(total.actual_cost.unwrap_or(0.), "USD") - ), + )), )); } } if let Some(expires) = expires.filter(|at| Timestamp::Text(at.clone()).time().is_some()) { facts.push(( - "Expires".into(), - expires.get(..10).unwrap_or(&expires).to_owned(), + Text::message("usage-sub2api-expires"), + Text::service(expires.get(..10).unwrap_or(&expires)), )); } let plan = usage.plan_name.filter(|plan| !plan.trim().is_empty()); @@ -200,21 +211,23 @@ fn parse(body: &str) -> Result { Provider(&Sub2api), Account { email: None, - plan: plan.map(Text::from), + plan: plan.map(Text::service), }, windows, ) - .with_balances( - usage - .balance - .map(|balance| Balance::new("Balance", balance, currency(&unit))), - ) + .with_balances(usage.balance.map(|balance| { + Balance::new( + Text::message("usage-sub2api-balance"), + balance, + currency(&unit), + ) + })) .with_sections( rate_sections .into_iter() .chain((!facts.is_empty()).then(|| Section::Facts { - title: "Usage summary".into(), - facts: pairs(facts), + title: Text::message("usage-sub2api-usage-summary"), + facts, })), )) } @@ -229,7 +242,7 @@ fn currency(unit: &str) -> Unit { } fn money(amount: f64, unit: &str) -> String { - Balance::new("", amount, currency(unit)).amount_text() + Balance::new(Text::service(""), amount, currency(unit)).amount_text() } #[derive(Deserialize)] @@ -325,7 +338,7 @@ mod tests { let quota = report .windows .iter() - .find(|window| window.kind == Kind::Named("Quota".into())) + .find(|window| window.kind == Kind::Named(Text::message("usage-sub2api-quota"))) .unwrap(); assert_eq!(quota.used, 25.); let session = report @@ -347,10 +360,35 @@ mod tests { assert_eq!(weekly.used, 20.); assert!(report.balances.is_empty()); let facts = facts(&report); - assert!(facts.contains(&("Today requests".into(), "4".into()))); - assert!(facts.contains(&("Today tokens".into(), "1,200 · $1.25".into()))); - assert!(facts.contains(&("All time requests".into(), "40".into()))); - assert!(facts.contains(&("Expires".into(), "2026-08-01".into()))); + assert_eq!( + facts, + &[ + ( + Text::message("usage-sub2api-quota"), + Text::service("$25.00 / $100.00") + ), + ( + Text::message("usage-sub2api-today-requests"), + Text::service("4") + ), + ( + Text::message("usage-sub2api-today-tokens"), + Text::service("1,200 · $1.25") + ), + ( + Text::message("usage-sub2api-all-time-requests"), + Text::service("40") + ), + ( + Text::message("usage-sub2api-all-time-tokens"), + Text::service("12,000 · $25.00") + ), + ( + Text::message("usage-sub2api-expires"), + Text::service("2026-08-01") + ), + ] + ); } #[test] @@ -384,7 +422,10 @@ mod tests { report.account.plan.as_ref().map(Text::as_str), Some("Claude Team") ); - assert!(facts(&report).contains(&("Expires".into(), "2026-08-15".into()))); + assert!(facts(&report).contains(&( + Text::message("usage-sub2api-expires"), + Text::service("2026-08-15") + ))); } #[test] diff --git a/crates/herdr-usage/src/providers/synthetic.rs b/crates/herdr-usage/src/providers/synthetic.rs index 131acf911..9d66f7caa 100644 --- a/crates/herdr-usage/src/providers/synthetic.rs +++ b/crates/herdr-usage/src/providers/synthetic.rs @@ -151,7 +151,7 @@ pub(crate) fn parse(body: &str) -> Result { (slot(&["weeklyTokenLimit"]), Some(Kind::Weekly)), ( slot(&["search", "hourly"]), - Some(Kind::Named("Search hourly".into())), + Some(Kind::Named(Text::message("usage-synthetic-search-hourly"))), ), ]; let quotas: Vec<(Quota, Option)> = if slots.iter().any(|(slot, _)| slot.is_some()) { @@ -189,7 +189,7 @@ pub(crate) fn parse(body: &str) -> Result { .find_map(|(quota, _)| quota.credits) .map(|(used, limit)| { Balance::new( - "Weekly credits left", + Text::message("usage-synthetic-weekly-credits-left"), (limit - used).max(0.), Unit::Currency("USD".into()), ) @@ -206,7 +206,7 @@ pub(crate) fn parse(body: &str) -> Result { Provider(&Synthetic), Account { email: None, - plan: plan.map(Text::from), + plan: plan.map(Text::service), }, windows, ) @@ -231,12 +231,15 @@ impl Quota { Some(24) => Kind::Daily, Some(168) => Kind::Weekly, Some(720 | 744) => Kind::Monthly, - _ => Kind::Named(Text::from( - self.label.unwrap_or_else(|| format!("Quota {}", index + 1)), + _ => Kind::Named(self.label.map_or_else( + || Text::message("usage-synthetic-quota").arg("number", index + 1), + Text::service, )), }); let length = self.length.or_else(|| match &kind { - Kind::Named(name) if name == "Search hourly" => Some(Duration::from_secs(3600)), + Kind::Named(name) if *name == Text::message("usage-synthetic-search-hourly") => { + Some(Duration::from_secs(3600)) + } _ => None, }); Window::new(kind, self.used, self.resets_at, length) @@ -480,7 +483,7 @@ mod tests { [ Kind::Session, Kind::Weekly, - Kind::Named("Search hourly".into()) + Kind::Named(Text::message("usage-synthetic-search-hourly")) ] ); assert_eq!(report.windows[0].percent(), 20); @@ -508,7 +511,7 @@ mod tests { assert_eq!(report.windows[0].kind, Kind::Daily); assert_eq!(report.windows[0].percent(), 25); assert_eq!(report.windows[0].resets_at, Some(at(1_776_400_000.))); - assert_eq!(report.windows[1].kind, Kind::Named("Burst".into())); + assert_eq!(report.windows[1].kind, Kind::Named(Text::service("Burst"))); assert_eq!(report.windows[1].percent(), 50); } diff --git a/crates/herdr-usage/src/providers/t3chat.rs b/crates/herdr-usage/src/providers/t3chat.rs index 47f4b44ae..9df62b425 100644 --- a/crates/herdr-usage/src/providers/t3chat.rs +++ b/crates/herdr-usage/src/providers/t3chat.rs @@ -9,7 +9,7 @@ //! Vercel security challenge that a bare Cookie header can trigger; here //! only the Cookie header is sent. -use crate::text::{Text, pairs}; +use crate::text::Text; use crate::{ Result, { @@ -112,7 +112,7 @@ pub(crate) fn parse(body: &str) -> Result { ); let (start, end) = (period("currentPeriodStart")?, period("currentPeriodEnd")?); let overage = Window::new( - Kind::Named("Overage".into()), + Kind::Named(Text::message("usage-t3chat-overage")), number("usageMonthPercentage")? .or(number("usagePeriodPercentage")?) .unwrap_or(0.), @@ -134,22 +134,26 @@ pub(crate) fn parse(body: &str) -> Result { Some(subscription) => text(subscription, "status")?, None => None, }; - let facts: Vec<(String, String)> = [("Usage band", band), ("Subscription", status)] - .into_iter() - .filter_map(|(label, value)| Some((label.to_owned(), value?))) - .collect(); + // The band and status are T3 Chat's own words, such as standard or active. + let facts: Vec<(Text, Text)> = [ + (Text::message("usage-t3chat-usage-band"), band), + (Text::message("usage-t3chat-subscription"), status), + ] + .into_iter() + .filter_map(|(label, value)| Some((label, Text::service(value?)))) + .collect(); Ok(Report::new( Provider(&T3chat), Account { email: None, - plan: plan.map(Text::from), + plan: plan.map(Text::service), }, vec![base, overage], ) .with_sections((!facts.is_empty()).then(|| Section::Facts { - title: "Plan".into(), - facts: pairs(facts), + title: Text::message("usage-t3chat-plan"), + facts, }))) } @@ -226,7 +230,10 @@ mod tests { Some(SystemTime::UNIX_EPOCH + Duration::from_secs(1_790_000_000)) ); let overage = &report.windows[1]; - assert_eq!(overage.kind, Kind::Named("Overage".into())); + assert_eq!( + overage.kind, + Kind::Named(Text::message("usage-t3chat-overage")) + ); assert_eq!(overage.percent(), 12); assert_eq!( overage.resets_at, @@ -236,10 +243,16 @@ mod tests { assert_eq!( report.sections, vec![Section::Facts { - title: "Plan".into(), + title: Text::message("usage-t3chat-plan"), facts: vec![ - ("Usage band".into(), "standard".into()), - ("Subscription".into(), "active".into()), + ( + Text::message("usage-t3chat-usage-band"), + Text::service("standard") + ), + ( + Text::message("usage-t3chat-subscription"), + Text::service("active") + ), ], }] ); diff --git a/crates/herdr-usage/src/providers/typesafe.rs b/crates/herdr-usage/src/providers/typesafe.rs index 11e349a3c..117b71920 100644 --- a/crates/herdr-usage/src/providers/typesafe.rs +++ b/crates/herdr-usage/src/providers/typesafe.rs @@ -7,7 +7,7 @@ //! is read, as in CodexBar. TypeSafe reports no quota or reset, so there //! are only balances. -use crate::text::{Text, pairs}; +use crate::text::Text; use crate::{ Error, Result, { @@ -239,6 +239,7 @@ pub(crate) fn parse(body: &str) -> Result { .cycle_label .map(|label| label.trim().to_owned()) .filter(|label| !label.is_empty()); + // Typesafe's plan names, from its plan IDs. let plan = billing .plan .map(|plan| plan.trim().to_owned()) @@ -255,11 +256,11 @@ pub(crate) fn parse(body: &str) -> Result { } }); let usd = || Unit::Currency("USD".into()); - let spent_label = match &cycle { - Some(cycle) => format!("Spent ({cycle})"), - None => "Spent".into(), + let spent_label = match cycle { + Some(cycle) => Text::message("usage-typesafe-spent-cycle").arg("cycle", cycle), + None => Text::message("usage-typesafe-spent"), }; - let active: Vec<(String, String)> = billing + let active: Vec<(Text, Text)> = billing .credits .unwrap_or_default() .into_iter() @@ -271,12 +272,11 @@ pub(crate) fn parse(body: &str) -> Result { let expires = Timestamp::Text(credit.expires_at?).time()?; let expires = chrono::DateTime::::from(expires).format("%b %-d"); Some(( - "Credit".to_owned(), - format!( - "{} of {}, expires {expires}", - trimmed(remaining), - trimmed(amount) - ), + Text::message("usage-typesafe-credit"), + Text::message("usage-typesafe-credit-left") + .arg("remaining", trimmed(remaining)) + .arg("amount", trimmed(amount)) + .arg("expires", expires.to_string()), )) }) .collect(); @@ -284,24 +284,24 @@ pub(crate) fn parse(body: &str) -> Result { let shown = MAX_CREDIT_ROWS - 1; let mut credits = active[..shown].to_vec(); credits.push(( - "Additional credits".to_owned(), - (active.len() - shown).to_string(), + Text::message("usage-typesafe-additional-credits"), + Text::service((active.len() - shown).to_string()), )); credits } else { active }; let sections = (!credits.is_empty()).then(|| Section::Facts { - title: "Credits".into(), - facts: pairs(credits), + title: Text::message("usage-typesafe-credits"), + facts: credits, }); let account = Account { email: None, - plan: plan.map(Text::from), + plan: plan.map(Text::service), }; Ok(Report::new(Provider(&Typesafe), account, Vec::new()) .with_balances([ - Balance::new("Balance", balance, usd()), + Balance::new(Text::message("usage-typesafe-balance"), balance, usd()), Balance::new(spent_label, spent, usd()), ]) .with_sections(sections)) @@ -337,16 +337,30 @@ mod tests { fn billing_maps_balance_spend_plan_and_active_credits() { let report = parse(BODY).unwrap(); assert!(report.windows.is_empty()); - assert_eq!(report.balances[0].label, "Balance"); + assert_eq!( + report.balances[0].label, + Text::message("usage-typesafe-balance") + ); assert_eq!(report.balances[0].amount, 4.98); - assert_eq!(report.balances[1].label, "Spent (September 2026)"); + assert_eq!( + report.balances[1].label, + Text::message("usage-typesafe-spent-cycle").arg("cycle", "September 2026") + ); assert_eq!(report.balances[1].amount, 0.01); assert_eq!(report.account.plan.as_ref().map(Text::as_str), Some("Free")); let Section::Facts { facts, .. } = &report.sections[0] else { panic!("expected facts"); }; - assert_eq!(facts.len(), 1); - assert_eq!(facts[0].1, "4.98 of 5, expires Oct 19"); + assert_eq!( + facts, + &[( + Text::message("usage-typesafe-credit"), + Text::message("usage-typesafe-credit-left") + .arg("remaining", "4.98") + .arg("amount", "5") + .arg("expires", "Oct 19") + )] + ); } #[test] diff --git a/crates/herdr-usage/src/providers/v0.rs b/crates/herdr-usage/src/providers/v0.rs index 9dd01ed6b..f2a6e2456 100644 --- a/crates/herdr-usage/src/providers/v0.rs +++ b/crates/herdr-usage/src/providers/v0.rs @@ -4,7 +4,7 @@ //! find, and CodexBar reads no cookies for it either. The free account's //! daily allowance and grace period are not shown, as in CodexBar. -use crate::text::pairs; +use crate::text::Text; use crate::{ Result, { @@ -104,26 +104,42 @@ pub(crate) fn parse(billing: &str, rate_limits: &str, scope: Option<&str>) -> Re } let windows = [ allowance.window(Kind::Monthly, Some(MONTH)), - rate.window(Kind::Named("Rate limit".into()), None), + rate.window(Kind::Named(Text::message("usage-v0-rate-limit")), None), ] .into_iter() .flatten() .collect(); - let mut facts = vec![("Billing remaining".to_owned(), allowance.describe())]; + let mut facts = vec![( + Text::message("usage-v0-billing-remaining"), + allowance.describe(), + )]; if let Some(on_demand) = on_demand { - facts.push(("On-demand balance".into(), count(on_demand))); + facts.push(( + Text::message("usage-v0-on-demand-balance"), + Text::service(count(on_demand)), + )); } - facts.push(("Rate-limit remaining".into(), rate.describe())); + facts.push(( + Text::message("usage-v0-rate-limit-remaining"), + rate.describe(), + )); + // v0's own billing type, such as token or legacy. if let Some(kind) = billing.billing_type { - facts.push(("Billing type".into(), kind.trim().to_owned())); + facts.push(( + Text::message("usage-v0-billing-type"), + Text::service(kind.trim()), + )); } if let Some(scope) = scope { - facts.push(("Scope".into(), scope.chars().take(120).collect())); + facts.push(( + Text::message("usage-v0-scope"), + Text::service(scope.chars().take(120).collect::()), + )); } Ok( Report::new(Provider(&V0), Account::default(), windows).with_sections([Section::Facts { - title: "v0 API".into(), - facts: pairs(facts), + title: Text::message("usage-v0-api"), + facts, }]), ) } @@ -152,10 +168,12 @@ impl Quota { )) } - fn describe(&self) -> String { + fn describe(&self) -> Text { match self.remaining { - Some(remaining) => format!("{} of {}", count(remaining), count(self.limit)), - None => format!("Unavailable (limit {})", count(self.limit)), + Some(remaining) => Text::message("usage-v0-remaining-of") + .arg("remaining", count(remaining)) + .arg("limit", count(self.limit)), + None => Text::message("usage-v0-unavailable").arg("limit", count(self.limit)), } } } @@ -223,17 +241,40 @@ mod tests { assert!((billing.used - 25.).abs() < 0.01); assert!(billing.resets_at.is_some()); let rate = &report.windows[1]; - assert_eq!(rate.kind, Kind::Named("Rate limit".into())); + assert_eq!(rate.kind, Kind::Named(Text::message("usage-v0-rate-limit"))); assert!((rate.used - 20.).abs() < 0.01); let Section::Facts { facts, .. } = &report.sections[0] else { panic!("expected facts"); }; assert_eq!( - facts[0], - ("Billing remaining".into(), "750 of 1,000".into()) + facts, + &[ + ( + Text::message("usage-v0-billing-remaining"), + Text::message("usage-v0-remaining-of") + .arg("remaining", "750") + .arg("limit", "1,000") + ), + ( + Text::message("usage-v0-on-demand-balance"), + Text::service("120") + ), + ( + Text::message("usage-v0-rate-limit-remaining"), + Text::message("usage-v0-remaining-of") + .arg("remaining", "80") + .arg("limit", "100") + ), + ( + Text::message("usage-v0-billing-type"), + Text::service("token") + ), + ( + Text::message("usage-v0-scope"), + Text::service("project-demo") + ), + ] ); - assert_eq!(facts[1], ("On-demand balance".into(), "120".into())); - assert_eq!(facts.last().unwrap().1, "project-demo"); } #[test] @@ -248,7 +289,10 @@ mod tests { let Section::Facts { facts, .. } = &report.sections[0] else { panic!("expected facts"); }; - assert_eq!(facts[0].1, "Unavailable (limit 1,000)"); + assert_eq!( + facts[0].1, + Text::message("usage-v0-unavailable").arg("limit", "1,000") + ); } #[test] diff --git a/crates/herdr-usage/src/providers/venice.rs b/crates/herdr-usage/src/providers/venice.rs index fc51848ef..beca3c18f 100644 --- a/crates/herdr-usage/src/providers/venice.rs +++ b/crates/herdr-usage/src/providers/venice.rs @@ -10,7 +10,7 @@ //! pasted as a whole `cookie` header. use crate::model::Count; -use crate::text::{Text, pairs}; +use crate::text::Text; use crate::{ Error, Result, { @@ -80,11 +80,16 @@ pub(crate) fn parse_balance(body: &str) -> Result { .map(|currency| currency.trim().to_uppercase()) .filter(|currency| !currency.is_empty()); let allocation = answer.diem_epoch_allocation.filter(|total| *total > 0.); + // Each balance is named by its currency, as Venice names them. let usd = balances .usd - .map(|usd| Balance::new("USD", usd, Unit::Currency("USD".into()))); + .map(|usd| Balance::new(Text::service("USD"), usd, Unit::Currency("USD".into()))); let diem = balances.diem.map(|diem| { - let balance = Balance::new("DIEM", diem, Unit::Count(Count::Named("DIEM".into()))); + let balance = Balance::new( + Text::service("DIEM"), + diem, + Unit::Count(Count::Named("DIEM".into())), + ); match allocation { Some(total) => balance.out_of(total), None => balance, @@ -100,7 +105,7 @@ pub(crate) fn parse_balance(body: &str) -> Result { // reported, so the window carries no length or reset time. let windows = match (can_consume, currency.as_deref(), balances.diem, allocation) { (true, Some(currency), Some(diem), Some(total)) if currency != "USD" => vec![Window::new( - Kind::Named("DIEM allocation".into()), + Kind::Named(Text::message("usage-venice-diem-allocation")), (total - diem) / total * 100., None, None, @@ -108,10 +113,10 @@ pub(crate) fn parse_balance(body: &str) -> Result { _ => Vec::new(), }; let status = (!can_consume).then(|| Section::Facts { - title: "Status".into(), + title: Text::message("usage-venice-status"), facts: vec![( - "API calls".into(), - "Balance unavailable for API calls".into(), + Text::message("usage-venice-api-calls"), + Text::message("usage-venice-unavailable-for-api-calls"), )], }); Ok(Report::new(Provider(&Venice), Account::default(), windows) @@ -174,19 +179,38 @@ pub(crate) fn parse_session(body: &str, now: SystemTime) -> Result { .available_credits .or(claims.bundled_credits) .filter(|credits| *credits >= 0.) - .map(|amount| Balance::new("Subscription credits", amount, credits())); + .map(|amount| { + Balance::new( + Text::message("usage-venice-subscription-credits"), + amount, + credits(), + ) + }); let total = claims .venice_credits .filter(|credits| *credits >= 0.) - .map(|amount| Balance::new("Total credits", amount, credits())); + .map(|amount| { + Balance::new( + Text::message("usage-venice-total-credits"), + amount, + credits(), + ) + }); // Monthly refill is not a spending cap: banked credits can outlast it, // so spend is shown against it without a quota window. - let spent = Balance::new("Used this cycle", used, credits()).out_of(refill); + let spent = Balance::new( + Text::message("usage-venice-used-this-cycle"), + used, + credits(), + ) + .out_of(refill); let mut facts = Vec::new(); if let Some(cap) = usage.tier_cap.filter(|cap| *cap >= 0.) { facts.push(( - "Bank cap".to_owned(), - format!("{} credits", group(cap.round() as i64)), + Text::message("usage-venice-bank-cap"), + Text::message("usage-venice-credit-count") + .arg("credits", cap.round()) + .arg("count", group(cap.round() as i64)), )); } if let Some(refill_at) = usage @@ -196,17 +220,18 @@ pub(crate) fn parse_session(body: &str, now: SystemTime) -> Result { .and_then(|seconds| chrono::DateTime::from_timestamp(seconds as i64, 0)) { facts.push(( - "Next refill".into(), - refill_at.format("%b %-d, %Y").to_string(), + Text::message("usage-venice-next-refill"), + Text::service(refill_at.format("%b %-d, %Y").to_string()), )); } let section = (!facts.is_empty()).then(|| Section::Facts { - title: "Credits".into(), - facts: pairs(facts), + title: Text::message("usage-venice-credits"), + facts, }); + // Venice's own user type, such as pro. let account = Account { email: None, - plan: user_type.map(Text::from), + plan: user_type.map(Text::service), }; Ok(Report::new(Provider(&Venice), account, Vec::new()) .with_balances(available.into_iter().chain(total).chain([spent])) @@ -295,7 +320,6 @@ struct CreditsUsage { #[allow(clippy::unwrap_used, clippy::expect_used)] mod tests { use super::*; - use crate::text::pair; fn encode(bytes: &[u8]) -> String { const TABLE: &[u8; 64] = @@ -336,7 +360,14 @@ mod tests { assert!((report.windows[0].used - 9.5).abs() < 1e-4); assert_eq!( report.balances, - vec![Balance::new("DIEM", 90.5, Unit::Count(Count::Named("DIEM".into()))).out_of(100.)] + vec![ + Balance::new( + Text::service("DIEM"), + 90.5, + Unit::Count(Count::Named("DIEM".into())) + ) + .out_of(100.) + ] ); } @@ -360,7 +391,16 @@ mod tests { "balances": {"diem": null, "usd": 100.0}, "diemEpochAllocation": null}"#, ) .unwrap(); - assert_eq!(report.sections.len(), 1); + assert_eq!( + report.sections, + vec![Section::Facts { + title: Text::message("usage-venice-status"), + facts: vec![( + Text::message("usage-venice-api-calls"), + Text::message("usage-venice-unavailable-for-api-calls") + )], + }] + ); assert!(matches!( parse_balance(r#"{"balances": {}}"#), Err(Error::Json(_)) @@ -380,9 +420,18 @@ mod tests { assert_eq!( report.balances, vec![ - Balance::new("Subscription credits", 750.5, credits.clone()), - Balance::new("Total credits", 1500., credits.clone()), - Balance::new("Used this cycle", 250., credits).out_of(1000.), + Balance::new( + Text::message("usage-venice-subscription-credits"), + 750.5, + credits.clone() + ), + Balance::new( + Text::message("usage-venice-total-credits"), + 1500., + credits.clone() + ), + Balance::new(Text::message("usage-venice-used-this-cycle"), 250., credits) + .out_of(1000.), ] ); let Some(Section::Facts { facts, .. }) = report.sections.first() else { @@ -390,9 +439,20 @@ mod tests { }; assert_eq!( facts[0], - pair(("Bank cap".to_owned(), "3,000 credits".to_owned())) + ( + Text::message("usage-venice-bank-cap"), + Text::message("usage-venice-credit-count") + .arg("credits", 3000.) + .arg("count", "3,000") + ) + ); + assert_eq!( + facts[1], + ( + Text::message("usage-venice-next-refill"), + Text::service("Sep 27, 2026") + ) ); - assert_eq!(facts[1].0, "Next refill"); } #[test] diff --git a/crates/herdr-usage/src/providers/vercel.rs b/crates/herdr-usage/src/providers/vercel.rs index 229aaf36c..9bdd24196 100644 --- a/crates/herdr-usage/src/providers/vercel.rs +++ b/crates/herdr-usage/src/providers/vercel.rs @@ -12,6 +12,7 @@ use crate::{ model::{Account, Balance, Provider, Report, Unit}, probe::{Probe, Request}, service::{Meta, Service, Setting, json}, + text::Text, values::{decimal, invalid}, }, }; @@ -60,8 +61,12 @@ pub(crate) fn parse(body: &str) -> Result { let usd = || Unit::Currency("USD".into()); Ok( Report::new(Provider(&Vercel), Account::default(), Vec::new()).with_balances([ - Balance::new("Available balance", balance, usd()), - Balance::new("Lifetime spend", spent, usd()), + Balance::new( + Text::message("usage-vercel-available-balance"), + balance, + usd(), + ), + Balance::new(Text::message("usage-vercel-lifetime-spend"), spent, usd()), ]), ) } @@ -91,8 +96,8 @@ mod tests { assert_eq!( texts, [ - ("Available balance", "$95.50".to_owned()), - ("Lifetime spend", "$4.50".to_owned()), + ("usage-vercel-available-balance", "$95.50".to_owned()), + ("usage-vercel-lifetime-spend", "$4.50".to_owned()), ] ); assert!( diff --git a/crates/herdr-usage/src/providers/vertexai.rs b/crates/herdr-usage/src/providers/vertexai.rs index e7125ea70..3357943eb 100644 --- a/crates/herdr-usage/src/providers/vertexai.rs +++ b/crates/herdr-usage/src/providers/vertexai.rs @@ -17,7 +17,7 @@ //! a command that prints the token, or which would need request signing. //! Token costs from Claude Code's logs are not read either. -use crate::text::{Text, pairs}; +use crate::text::Text; use crate::{ Result, { @@ -195,24 +195,35 @@ fn series(probe: &mut Probe, token: &Secret, project: &str, filter: &str) -> Res pub(crate) fn report(email: Option, project: Option, peak: Option) -> Report { let windows = peak - .map(|peak| Window::new(Kind::Named("Peak quota".into()), peak, None, None)) + .map(|peak| { + Window::new( + Kind::Named(Text::message("usage-vertexai-peak-quota")), + peak, + None, + None, + ) + }) .into_iter() .collect(); let facts = vec![( - "Project".to_owned(), - project.unwrap_or_else(|| "Not set: run `gcloud config set project PROJECT_ID`".into()), + Text::message("usage-vertexai-project"), + project.map_or_else( + || Text::message("usage-vertexai-project-not-set"), + Text::service, + ), )]; Report::new( Provider(&Vertexai), Account { - email: email.map(Text::from), - plan: Some("gcloud".into()), + email: email.map(Text::service), + // The tool the credentials come from, a name. + plan: Some(Text::service("gcloud")), }, windows, ) .with_sections([Section::Facts { - title: "Google Cloud".into(), - facts: pairs(facts), + title: Text::message("usage-vertexai-google-cloud"), + facts, }]) } @@ -378,13 +389,19 @@ mod tests { report.account.email.as_ref().map(Text::as_str), Some("dev@example.com") ); - assert_eq!(report.windows[0].kind, Kind::Named("Peak quota".into())); + assert_eq!( + report.windows[0].kind, + Kind::Named(Text::message("usage-vertexai-peak-quota")) + ); assert_eq!(report.windows[0].percent(), 50); assert_eq!( report.sections, vec![Section::Facts { - title: "Google Cloud".into(), - facts: vec![("Project".into(), "my-project".into())], + title: Text::message("usage-vertexai-google-cloud"), + facts: vec![( + Text::message("usage-vertexai-project"), + Text::service("my-project") + )], }] ); } @@ -393,7 +410,12 @@ mod tests { fn no_matching_limit_means_no_window() { let usage: Page = json(USAGE).unwrap(); assert_eq!(peak(&usage.time_series, &[]), None); - assert!(report(None, None, None).windows.is_empty()); + let unset = report(None, None, None); + assert!(unset.windows.is_empty()); + let Section::Facts { facts, .. } = &unset.sections[0] else { + panic!("expected facts"); + }; + assert_eq!(facts[0].1, Text::message("usage-vertexai-project-not-set")); } #[test] diff --git a/crates/herdr-usage/src/providers/warp.rs b/crates/herdr-usage/src/providers/warp.rs index e6e04e4ef..b153c7455 100644 --- a/crates/herdr-usage/src/providers/warp.rs +++ b/crates/herdr-usage/src/providers/warp.rs @@ -6,7 +6,7 @@ //! read, as CodexBar does not read it either. use crate::model::Count; -use crate::text::{Text, pairs}; +use crate::text::Text; use crate::{ Error, Result, { @@ -129,7 +129,7 @@ pub(crate) fn parse(body: &str) -> Result { let remaining: i64 = grants.iter().map(|grant| grant.remaining()).sum(); let bonus = (granted > 0 || remaining > 0).then(|| { let balance = Balance::new( - "Add-on credits", + Text::message("usage-warp-add-on-credits"), remaining.max(0) as f64, Unit::Count(Count::Credits), ); @@ -145,11 +145,13 @@ pub(crate) fn parse(body: &str) -> Result { .filter_map(|grant| Some((grant.expires()?, grant.remaining()))) .min_by_key(|(at, _)| *at); let mut facts = vec![( - "Monthly credits".to_owned(), + Text::message("usage-warp-monthly-credits"), if unlimited { - "Unlimited".to_owned() + Text::message("usage-warp-unlimited") } else { - format!("{} of {}", group(used), group(limit)) + Text::message("usage-warp-used-of") + .arg("used", group(used)) + .arg("limit", group(limit)) }, )]; if let Some((at, _)) = expiring { @@ -160,22 +162,25 @@ pub(crate) fn parse(body: &str) -> Result { .sum(); let date = chrono::DateTime::::from(at).format("%b %-d, %Y"); facts.push(( - "Next add-on expiry".to_owned(), - format!("{} credits on {date}", group(soonest)), + Text::message("usage-warp-next-add-on-expiry"), + Text::message("usage-warp-credits-on") + .arg("credits", soonest) + .arg("count", group(soonest)) + .arg("date", date.to_string()), )); } Ok(Report::new( Provider(&Warp), Account { email: None, - plan: unlimited.then(|| "Unlimited".to_owned()).map(Text::from), + plan: unlimited.then(|| Text::message("usage-warp-unlimited")), }, vec![monthly], ) .with_balances(bonus) .with_sections([Section::Facts { - title: "Credits".into(), - facts: pairs(facts), + title: Text::message("usage-warp-credits"), + facts, }])) } @@ -270,7 +275,6 @@ impl Grant { #[allow(clippy::unwrap_used, clippy::expect_used)] mod tests { use super::*; - use crate::text::pair; /// CodexBar's fetcher fixture. const LIMITS: &str = r#"{ @@ -315,14 +319,22 @@ mod tests { }; assert_eq!( facts[0], - pair(("Monthly credits".to_owned(), "5 of 1,500".to_owned())) + ( + Text::message("usage-warp-monthly-credits"), + Text::message("usage-warp-used-of") + .arg("used", "5") + .arg("limit", "1,500") + ) ); assert_eq!( facts[1], - pair(( - "Next add-on expiry".to_owned(), - "10 credits on Mar 1, 2026".to_owned() - )) + ( + Text::message("usage-warp-next-add-on-expiry"), + Text::message("usage-warp-credits-on") + .arg("credits", 10) + .arg("count", "10") + .arg("date", "Mar 1, 2026") + ) ); } @@ -334,8 +346,8 @@ mod tests { ) .unwrap(); assert_eq!( - report.account.plan.as_ref().map(Text::as_str), - Some("Unlimited") + report.account.plan, + Some(Text::message("usage-warp-unlimited")) ); assert_eq!(report.windows[0].percent(), 0); assert!(report.balances.is_empty()); diff --git a/crates/herdr-usage/src/providers/windsurf.rs b/crates/herdr-usage/src/providers/windsurf.rs index edfcb70fa..39f941121 100644 --- a/crates/herdr-usage/src/providers/windsurf.rs +++ b/crates/herdr-usage/src/providers/windsurf.rs @@ -163,14 +163,14 @@ pub(crate) fn parse_cache(body: &str) -> Result { [ ( daily.is_none(), - "Messages", + Text::message("usage-windsurf-messages"), usage.messages, usage.used_messages, usage.remaining_messages, ), ( weekly.is_none(), - "Flow actions", + Text::message("usage-windsurf-flow-actions"), usage.flow_actions, usage.used_flow_actions, usage.remaining_flow_actions, @@ -194,7 +194,7 @@ pub(crate) fn parse_cache(body: &str) -> Result { /// `used / total` of a counter, the used count inferred from the remainder /// when the cache only has that. fn counter( - name: &str, + name: Text, total: Option, used: Option, remaining: Option, @@ -202,7 +202,7 @@ fn counter( let total = total.filter(|total| *total > 0.)?; let used = used.or_else(|| remaining.map(|remaining| (total - remaining).max(0.)))?; Some(Window::new( - Kind::Named(Text::from(name.to_owned())), + Kind::Named(name), used.clamp(0., total) / total * 100., None, None, @@ -230,10 +230,10 @@ fn report( .map(|plan| plan.trim().to_owned()) .filter(|plan| !plan.is_empty()); let ends = plan_end.map(|end| Section::Facts { - title: "Plan".into(), + title: Text::message("usage-windsurf-plan"), facts: vec![( - "Renews".into(), - Text::from( + Text::message("usage-windsurf-renews"), + Text::service( chrono::DateTime::::from(end) .format("%b %-d, %Y") .to_string(), @@ -244,7 +244,7 @@ fn report( Provider(&Windsurf), Account { email: None, - plan: plan.map(Text::from), + plan: plan.map(Text::service), }, windows, ) @@ -307,7 +307,6 @@ struct QuotaUsage { #[allow(clippy::unwrap_used, clippy::expect_used)] mod tests { use super::*; - use crate::text::pair; fn at(seconds: u64) -> Option { Some(SystemTime::UNIX_EPOCH + Duration::from_secs(seconds)) @@ -345,7 +344,10 @@ mod tests { }; assert_eq!( facts[0], - pair(("Renews".to_owned(), "Mar 20, 2026".to_owned())) + ( + Text::message("usage-windsurf-renews"), + Text::service("Mar 20, 2026") + ) ); } @@ -356,9 +358,15 @@ mod tests { "flowActions":150000,"usedFlowActions":0}}"#, ) .unwrap(); - assert_eq!(report.windows[0].kind, Kind::Named("Flow actions".into())); + assert_eq!( + report.windows[0].kind, + Kind::Named(Text::message("usage-windsurf-flow-actions")) + ); assert_eq!(report.windows[0].percent(), 0); - assert_eq!(report.windows[1].kind, Kind::Named("Messages".into())); + assert_eq!( + report.windows[1].kind, + Kind::Named(Text::message("usage-windsurf-messages")) + ); assert_eq!(report.windows[1].percent(), 71); assert!(report.sections.is_empty()); } diff --git a/crates/herdr-usage/src/providers/xai.rs b/crates/herdr-usage/src/providers/xai.rs index 0b6e9a890..ab24d66bc 100644 --- a/crates/herdr-usage/src/providers/xai.rs +++ b/crates/herdr-usage/src/providers/xai.rs @@ -105,21 +105,24 @@ pub(crate) fn parse(balance: &str, usage: Option<&str>, today: NaiveDate) -> Res .ok_or_else(invalid)?; // Adding zero turns an empty ledger's -0 into 0. let balance = Balance::new( - "Prepaid balance", + Text::message("usage-xai-prepaid-balance"), -cents / 100. + 0., Unit::Currency("USD".into()), ); let spend = usage.and_then(|body| spend(body, today)).map(|spend| { let label = if spend.partial { - "Last 30 days (partial)" + Text::message("usage-xai-last-30-days-partial") } else { - "Last 30 days" + Text::message("usage-xai-last-30-days") }; Section::Facts { - title: "Spend".into(), + title: Text::message("usage-xai-spend"), facts: vec![ - ("Today".into(), Text::from(usd(spend.today))), - (label.into(), Text::from(usd(spend.total))), + ( + Text::message("usage-xai-today"), + Text::service(usd(spend.today)), + ), + (label, Text::service(usd(spend.total))), ], } }); @@ -127,7 +130,7 @@ pub(crate) fn parse(balance: &str, usage: Option<&str>, today: NaiveDate) -> Res Provider(&Xai), Account { email: None, - plan: Some("Management API".into()), + plan: Some(Text::message("usage-xai-management-api")), }, Vec::new(), ) @@ -250,8 +253,17 @@ mod tests { let Section::Facts { facts, .. } = &report.sections[0] else { panic!("expected facts"); }; - assert_eq!(facts[0], ("Today".into(), "$0.25".into())); - assert_eq!(facts[1], ("Last 30 days".into(), "$2.01".into())); + assert_eq!( + facts[0], + (Text::message("usage-xai-today"), Text::service("$0.25")) + ); + assert_eq!( + facts[1], + ( + Text::message("usage-xai-last-30-days"), + Text::service("$2.01") + ) + ); } #[test] diff --git a/crates/herdr-usage/src/providers/xkiro.rs b/crates/herdr-usage/src/providers/xkiro.rs index 15b7e1e79..879eba51a 100644 --- a/crates/herdr-usage/src/providers/xkiro.rs +++ b/crates/herdr-usage/src/providers/xkiro.rs @@ -8,7 +8,7 @@ //! percentage is invented. The wallet balance, which CodexBar leaves out, is //! shown as a balance when present; it never feeds the daily window. -use crate::text::{Text, pairs}; +use crate::text::Text; use crate::{ Result, { @@ -82,22 +82,30 @@ pub(crate) fn parse(body: &str, now: SystemTime) -> Result { _ => Vec::new(), }; - let tokens = |value: u64| group(i64::try_from(value).unwrap_or(i64::MAX)); + let tokens = |value: u64| Text::service(group(i64::try_from(value).unwrap_or(i64::MAX))); let mut facts = Vec::new(); if let Some(used) = used { - facts.push(("Tokens used today".to_owned(), tokens(used))); + facts.push((Text::message("usage-xkiro-tokens-used-today"), tokens(used))); } match limit { - Some(limit) => facts.push(("Daily allowance".to_owned(), tokens(limit))), - None if uncapped => { - facts.push(("Daily allowance".to_owned(), "No cap reported".to_owned())) - } + Some(limit) => facts.push((Text::message("usage-xkiro-daily-allowance"), tokens(limit))), + None if uncapped => facts.push(( + Text::message("usage-xkiro-daily-allowance"), + Text::message("usage-xkiro-no-cap-reported"), + )), None => {} } if let Some(remaining) = remaining { - facts.push(("Tokens remaining".to_owned(), tokens(remaining))); + facts.push(( + Text::message("usage-xkiro-tokens-remaining"), + tokens(remaining), + )); } - facts.push(("Daily reset".to_owned(), "00:00 UTC".to_owned())); + // A time of day, which reads the same in every language. + facts.push(( + Text::message("usage-xkiro-daily-reset"), + Text::service("00:00 UTC"), + )); let text = |value: Option<&Value>| { value @@ -107,8 +115,8 @@ pub(crate) fn parse(body: &str, now: SystemTime) -> Result { .map(str::to_owned) }; let plan = match &usage.plan { - Some(Value::Null) => Some("Pay as you go".to_owned()), - plan => text(plan.as_ref()), + Some(Value::Null) => Some(Text::message("usage-xkiro-pay-as-you-go")), + plan => text(plan.as_ref()).map(Text::service), }; let email = text(usage.user.as_ref().and_then(|user| user.get("email"))); let wallet = usage @@ -116,20 +124,26 @@ pub(crate) fn parse(body: &str, now: SystemTime) -> Result { .as_ref() .and_then(|wallet| wallet.get("balance_usd")) .and_then(number) - .map(|balance| Balance::new("Wallet", balance, Unit::Currency("USD".into()))); + .map(|balance| { + Balance::new( + Text::message("usage-xkiro-wallet"), + balance, + Unit::Currency("USD".into()), + ) + }); Ok(Report::new( Provider(&Xkiro), Account { - email: email.map(Text::from), - plan: plan.map(Text::from), + email: email.map(Text::service), + plan, }, windows, ) .with_balances(wallet) .with_sections([Section::Facts { - title: "Free tokens".into(), - facts: pairs(facts), + title: Text::message("usage-xkiro-free-tokens"), + facts, }])) } @@ -227,8 +241,8 @@ mod tests { Some("dev@example.com") ); assert_eq!( - report.account.plan.as_ref().map(Text::as_str), - Some("Pay as you go") + report.account.plan, + Some(Text::message("usage-xkiro-pay-as-you-go")) ); assert_eq!( facts(&report), @@ -243,7 +257,7 @@ mod tests { for (fields, expected) in [ ( r#""used_today":0,"limit_per_day":null,"remaining":null"#, - vec!["0", "No cap reported", "00:00 UTC"], + vec!["0", "usage-xkiro-no-cap-reported", "00:00 UTC"], ), (r#""remaining":12"#, vec!["12", "00:00 UTC"]), (r#""limit_per_day":500000"#, vec!["500,000", "00:00 UTC"]), diff --git a/crates/herdr-usage/src/providers/zai.rs b/crates/herdr-usage/src/providers/zai.rs index 4826b11f1..350e27f95 100644 --- a/crates/herdr-usage/src/providers/zai.rs +++ b/crates/herdr-usage/src/providers/zai.rs @@ -13,7 +13,7 @@ //! a file back whole, so the file must hold only the key; and the hourly //! token chart, which the panel has no chart for. -use crate::text::{Text, pair, pairs}; +use crate::text::Text; use crate::{ Error, Result, { @@ -271,7 +271,7 @@ pub(crate) fn parse(body: &str, now: SystemTime) -> Result { let mcp = limits.iter().rfind(|limit| limit.kind == "TIME_LIMIT"); let mut windows: Vec = Vec::new(); - let mut facts: Vec<(String, String)> = Vec::new(); + let mut facts: Vec<(Text, Text)> = Vec::new(); let credits = plan_limits.iter().any(|limit| limit.kind == "CREDIT_LIMIT"); let chosen: Vec<&Limit> = match plan_limits.as_slice() { [] => Vec::new(), @@ -283,46 +283,52 @@ pub(crate) fn parse(body: &str, now: SystemTime) -> Result { } if let Some(last) = chosen.last() { let label = if credits { - "Credit quota" + Text::message("usage-zai-credit-quota") } else { - "Token quota" + Text::message("usage-zai-token-quota") }; - facts.push((label.into(), last.detail())); + facts.push((label, last.detail())); } if let [first, _] = chosen.as_slice() { let label = if credits { - "Session credit quota" + Text::message("usage-zai-session-credit-quota") } else { - "Session token quota" + Text::message("usage-zai-session-token-quota") }; - facts.push((label.into(), first.detail())); + facts.push((label, first.detail())); } if credits { - facts.push(("Quota rate".into(), quota_rate(now))); + facts.push((Text::message("usage-zai-quota-rate"), quota_rate(now))); } let mut sections = Vec::new(); if let Some(mcp) = mcp { - let window = mcp.window(now, Some("MCP")); + // MCP is the protocol's name, the same in every language. + let window = mcp.window(now, Some(Text::service("MCP"))); if windows.is_empty() { windows.push(window); } else { sections.push(Section::Limit(window)); } - facts.push(("MCP quota".into(), mcp.detail())); + facts.push((Text::message("usage-zai-mcp-quota"), mcp.detail())); facts.extend( mcp.usage_details .iter() .flatten() .take(20) - .filter_map(|detail| Some((detail.model_code.clone()?, detail.usage?.to_string()))), + .filter_map(|detail| { + Some(( + Text::service(detail.model_code.clone()?), + Text::service(detail.usage?.to_string()), + )) + }), ); } if !facts.is_empty() { sections.insert( 0, Section::Facts { - title: "Quota details".into(), - facts: pairs(facts), + title: Text::message("usage-zai-quota-details"), + facts, }, ); } @@ -341,7 +347,7 @@ pub(crate) fn parse(body: &str, now: SystemTime) -> Result { Provider(&Zai), Account { email: None, - plan: plan.map(Text::from), + plan: plan.map(Text::service), }, windows, ) @@ -349,13 +355,13 @@ pub(crate) fn parse(body: &str, now: SystemTime) -> Result { } /// Credit plans cost double on weekdays 06:00–10:00 UTC; no endpoint says so. -fn quota_rate(now: SystemTime) -> String { +fn quota_rate(now: SystemTime) -> Text { let now = chrono::DateTime::::from(now); let weekday = !matches!(now.weekday(), Weekday::Sat | Weekday::Sun); if weekday && (6..10).contains(&now.hour()) { - "Peak".into() + Text::message("usage-zai-peak") } else { - "Off-peak".into() + Text::message("usage-zai-off-peak") } } @@ -371,7 +377,7 @@ pub(crate) fn parse_balance(body: &str) -> Option { .or(data.balance) .filter(|value| value.is_finite())?; Some(Balance::new( - "Account balance", + Text::message("usage-zai-account-balance"), amount, Unit::Currency("CNY".into()), )) @@ -383,7 +389,7 @@ pub(crate) fn parse_tokens(body: &str) -> Option
{ if usage.success != Some(true) || usage.code != Some(200) { return None; } - let mut totals: Vec<(String, u64)> = usage + let mut totals: Vec<(Option, u64)> = usage .data? .model_data_list .unwrap_or_default() @@ -397,21 +403,27 @@ pub(crate) fn parse_tokens(body: &str) -> Option
{ .filter(|value| *value > 0.) .map(|value| value as u64) .fold(0_u64, u64::saturating_add); - (model.model_name.unwrap_or_else(|| "Unknown".into()), tokens) + (model.model_name, tokens) }) .filter(|(_, tokens)| *tokens > 0) .collect(); if totals.is_empty() { return None; } - totals.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0))); + // A model without a name sorts where its English name did. + let name = |model: &Option| model.clone().unwrap_or_else(|| "Unknown".into()); + totals.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| name(&a.0).cmp(&name(&b.0)))); Some(Section::Facts { - title: "Tokens, last 30 days".into(), + title: Text::message("usage-zai-tokens-last-30-days"), facts: totals .into_iter() .take(20) - .map(|(name, tokens)| (name, compact(tokens))) - .map(pair) + .map(|(model, tokens)| { + ( + model.map_or_else(|| Text::message("usage-zai-unknown-model"), Text::service), + Text::service(compact(tokens)), + ) + }) .collect(), }) } @@ -506,7 +518,7 @@ impl Limit { used.clamp(0, total) as f64 / total as f64 * 100. } - fn window(&self, now: SystemTime, name: Option<&str>) -> Window { + fn window(&self, now: SystemTime, name: Option) -> Window { // A single minute unit marks the monthly MCP allowance. let length = if self.kind == "TIME_LIMIT" && self.unit == 5 && self.number == 1 { Some(MONTH) @@ -524,30 +536,38 @@ impl Limit { <= SESSION + Duration::from_secs(60) }); let kind = match (name, length) { - (Some(name), _) => Kind::Named(name.into()), + (Some(name), _) => Kind::Named(name), (None, Some(length)) if length == SESSION => Kind::Session, (None, Some(length)) if length == WEEK => Kind::Weekly, (None, Some(length)) if length == DAY => Kind::Daily, - (None, Some(length)) => Kind::Named(format!("{}-hour", length.as_secs() / 3600).into()), - (None, None) => Kind::Named("Quota".into()), + (None, Some(length)) => { + Kind::Named(Text::message("usage-zai-hours").arg("hours", length.as_secs() / 3600)) + } + (None, None) => Kind::Named(Text::message("usage-zai-quota")), }; Window::new(kind, self.used(), reset, length) } - fn detail(&self) -> String { + fn detail(&self) -> Text { let used = self.used(); - let mut text = if used.fract() == 0. { - format!("{used:.0}% used") + let percent = if used.fract() == 0. { + format!("{used:.0}") } else { - format!("{used:.1}% used") + format!("{used:.1}") }; - if let Some(total) = self.usage { - text.push_str(&format!(" · {total} limit")); - } - if let Some(remaining) = self.remaining { - text.push_str(&format!(" · {remaining} remaining")); - } - text + // Which of the limit and the remaining count the service sent. + let counts = match (self.usage, self.remaining) { + (Some(_), Some(_)) => "both", + (Some(_), None) => "limit", + (None, Some(_)) => "remaining", + (None, None) => "none", + }; + let count = |value: Option| value.map(|value| value.to_string()).unwrap_or_default(); + Text::message("usage-zai-detail") + .arg("percent", percent) + .arg("counts", counts) + .arg("limit", count(self.usage)) + .arg("remaining", count(self.remaining)) } } @@ -610,6 +630,14 @@ mod tests { SystemTime::UNIX_EPOCH + Duration::from_secs(seconds) } + fn detail(percent: &str, counts: &str, limit: &str, remaining: &str) -> Text { + Text::message("usage-zai-detail") + .arg("percent", percent) + .arg("counts", counts) + .arg("limit", limit) + .arg("remaining", remaining) + } + #[test] fn coding_plan_limits_become_session_weekly_and_mcp() { let report = parse(QUOTA, at(1_785_812_400)).unwrap(); @@ -632,15 +660,32 @@ mod tests { _ => None, }) .unwrap(); - assert_eq!(mcp.kind, Kind::Named("MCP".into())); + assert_eq!(mcp.kind, Kind::Named(Text::service("MCP"))); assert_eq!(mcp.length, Some(MONTH)); // 224 of 1000 used, from the counts rather than the rounded percentage. assert!((mcp.used - 22.4).abs() < 0.01); let Section::Facts { facts, .. } = &report.sections[0] else { panic!("quota details first"); }; - assert!(facts.contains(&("search-prime".into(), "210".into()))); - assert!(facts.contains(&("Token quota".into(), "9% used".into()))); + assert_eq!( + facts, + &[ + ( + Text::message("usage-zai-token-quota"), + detail("9", "none", "", "") + ), + ( + Text::message("usage-zai-session-token-quota"), + detail("25", "none", "", "") + ), + ( + Text::message("usage-zai-mcp-quota"), + detail("22.4", "both", "1000", "776") + ), + (Text::service("search-prime"), Text::service("210")), + (Text::service("web-reader"), Text::service("14")), + ] + ); } #[test] @@ -649,6 +694,23 @@ mod tests { {"type":"CREDIT_LIMIT","unit":3,"number":5,"usage":2000,"currentValue":100,"remaining":1900, "percentage":5,"nextResetTime":1786073946574}]}}"#; let report = parse(body, at(1_785_816_000)).unwrap(); + let Section::Facts { facts, .. } = &report.sections[0] else { + panic!("expected facts"); + }; + assert_eq!( + facts[0], + ( + Text::message("usage-zai-credit-quota"), + detail("5", "both", "2000", "1900") + ) + ); + assert_eq!( + facts[1], + ( + Text::message("usage-zai-quota-rate"), + Text::message("usage-zai-off-peak") + ) + ); assert_eq!(report.windows.len(), 1); assert_eq!(report.windows[0].kind, Kind::Session); assert_eq!(report.windows[0].resets_at, None); @@ -685,9 +747,9 @@ mod tests { assert_eq!( facts, vec![ - ("glm-big".into(), "1.25M".into()), - ("glm-4.6".into(), "100".into()), - ("glm-4.5".into(), "75".into()), + (Text::service("glm-big"), Text::service("1.25M")), + (Text::service("glm-4.6"), Text::service("100")), + (Text::service("glm-4.5"), Text::service("75")), ] ); } diff --git a/crates/herdr-usage/src/providers/zed.rs b/crates/herdr-usage/src/providers/zed.rs index 62d2042d2..ead1163d3 100644 --- a/crates/herdr-usage/src/providers/zed.rs +++ b/crates/herdr-usage/src/providers/zed.rs @@ -19,7 +19,7 @@ //! Not ported: the Linux editor sign-in lives in the Secret Service, which //! the probe cannot read. -use crate::text::{Text, pairs}; +use crate::text::Text; use crate::{ Result, { @@ -186,13 +186,13 @@ fn predictions( usage: &EditPredictions, null_is_unlimited: bool, period: Option<(SystemTime, Duration)>, -) -> Result<(Option, (String, String))> { +) -> Result<(Option, (Text, Text))> { Ok(match predictions_limit(&usage.limit, null_is_unlimited)? { Predictions::Unlimited => ( None, ( - "Edit predictions".into(), - format!("{} used · Unlimited", usage.used), + Text::message("usage-zed-edit-predictions"), + Text::message("usage-zed-used-unlimited").arg("used", usage.used.to_string()), ), ), Predictions::Limited(limit) => ( @@ -205,8 +205,10 @@ fn predictions( ) }), ( - "Edit predictions".into(), - format!("{} of {limit}", usage.used.min(limit)), + Text::message("usage-zed-edit-predictions"), + Text::message("usage-zed-used-of") + .arg("used", usage.used.min(limit).to_string()) + .arg("limit", limit.to_string()), ), ), }) @@ -222,19 +224,22 @@ pub(crate) fn parse_editor(body: &str) -> Result { let (window, prediction) = predictions(&me.plan.usage.edit_predictions, false, period)?; let mut facts = vec![prediction]; if me.plan.has_overdue_invoices { - facts.push(("Billing".into(), "Overdue invoices".into())); + facts.push(( + Text::message("usage-zed-billing"), + Text::message("usage-zed-overdue-invoices"), + )); } let account = Account { email: Some(me.user.github_login.trim().to_owned()) .filter(|login| !login.is_empty()) - .map(Text::from), - plan: plan_name(&me.plan.plan_v3).map(Text::from), + .map(Text::service), + plan: plan_name(&me.plan.plan_v3).map(Text::service), }; Ok( Report::new(Provider(&Zed), account, window.into_iter().collect()).with_sections([ Section::Facts { - title: "Plan".into(), - facts: pairs(facts), + title: Text::message("usage-zed-plan"), + facts, }, ]), ) @@ -246,23 +251,33 @@ pub(crate) fn parse_billing(body: &str) -> Result { let spend = &billing.current_usage.token_spend; let spent = spend.spend_in_cents / 100.; let cap = spend.limit_in_cents.map(|cents| cents / 100.); - let balance = Balance::new("Token spend", spent, Unit::Currency("USD".into())); + let balance = Balance::new( + Text::message("usage-zed-token-spend"), + spent, + Unit::Currency("USD".into()), + ); let mut facts = vec![ prediction, - ("Spent".into(), usd(spent)), + (Text::message("usage-zed-spent"), Text::service(usd(spent))), ( - "Spend limit".into(), - cap.map_or_else(|| "Not reported".into(), usd), + Text::message("usage-zed-spend-limit"), + cap.map_or_else( + || Text::message("usage-zed-not-reported"), + |cap| Text::service(usd(cap)), + ), ), ]; if let Some(cap) = cap { - facts.push(("Remaining budget".into(), usd(cap - spent))); + facts.push(( + Text::message("usage-zed-remaining-budget"), + Text::service(usd(cap - spent)), + )); } Ok(Report::new( Provider(&Zed), Account { email: None, - plan: plan_name(&billing.plan).map(Text::from), + plan: plan_name(&billing.plan).map(Text::service), }, window.into_iter().collect(), ) @@ -271,8 +286,8 @@ pub(crate) fn parse_billing(body: &str) -> Result { None => balance, }]) .with_sections([Section::Facts { - title: "Current billing period".into(), - facts: pairs(facts), + title: Text::message("usage-zed-current-billing-period"), + facts, }])) } @@ -335,7 +350,6 @@ struct TokenSpend { #[allow(clippy::unwrap_used, clippy::expect_used)] mod tests { use super::*; - use crate::text::pair; /// CodexBar's editor fixture. fn editor_fixture(limit: &str) -> String { @@ -382,11 +396,19 @@ mod tests { }; assert_eq!( facts[0], - pair(("Edit predictions".to_owned(), "30 of 120".to_owned())) + ( + Text::message("usage-zed-edit-predictions"), + Text::message("usage-zed-used-of") + .arg("used", "30") + .arg("limit", "120") + ) ); assert_eq!( facts[1], - pair(("Billing".to_owned(), "Overdue invoices".to_owned())) + ( + Text::message("usage-zed-billing"), + Text::message("usage-zed-overdue-invoices") + ) ); } @@ -397,7 +419,10 @@ mod tests { let Section::Facts { facts, .. } = &report.sections[0] else { panic!("expected facts"); }; - assert_eq!(facts[0].1, "30 used · Unlimited"); + assert_eq!( + facts[0].1, + Text::message("usage-zed-used-unlimited").arg("used", "30") + ); } #[test] @@ -418,10 +443,26 @@ mod tests { let Section::Facts { facts, .. } = &report.sections[0] else { panic!("expected facts"); }; - assert!(facts.contains(&( - Text::from("Remaining budget".to_owned()), - Text::from("$7.50".to_owned()) - ))); + assert_eq!( + facts, + &[ + ( + Text::message("usage-zed-edit-predictions"), + Text::message("usage-zed-used-of") + .arg("used", "12") + .arg("limit", "100") + ), + (Text::message("usage-zed-spent"), Text::service("$2.50")), + ( + Text::message("usage-zed-spend-limit"), + Text::service("$10.00") + ), + ( + Text::message("usage-zed-remaining-budget"), + Text::service("$7.50") + ), + ] + ); } #[test] diff --git a/crates/herdr-usage/src/providers/zenmux.rs b/crates/herdr-usage/src/providers/zenmux.rs index d0ec9b32b..1ea53cb50 100644 --- a/crates/herdr-usage/src/providers/zenmux.rs +++ b/crates/herdr-usage/src/providers/zenmux.rs @@ -4,7 +4,7 @@ //! keys are refused by these endpoints. ZenMux has no local sign-in to find, //! and CodexBar reads no cookies for it either. -use crate::text::{Text, pairs}; +use crate::text::Text; use crate::{ Error, Result, { @@ -56,20 +56,33 @@ pub(crate) fn parse(subscription: &str, balance: Option<&str>) -> Result .into_data() .ok_or_else(invalid)?; let quotas = [ - (Kind::Session, detail.quota_5_hour, SESSION), - (Kind::Weekly, detail.quota_7_day, WEEK), + ( + Kind::Session, + Text::message("usage-zenmux-session"), + detail.quota_5_hour, + SESSION, + ), + ( + Kind::Weekly, + Text::message("usage-zenmux-weekly"), + detail.quota_7_day, + WEEK, + ), ]; let mut flows = Vec::new(); let mut windows = Vec::new(); - for (kind, quota, length) in quotas { + for (kind, title, quota, length) in quotas { let Some(quota) = quota else { continue }; let Some(percentage) = quota.usage_percentage else { continue; }; if let (Some(used), Some(max)) = (quota.used_flows, quota.max_flows) { flows.push(( - kind.title().to_owned(), - format!("{} / {} flows", plain(used), plain(max)), + title, + Text::message("usage-zenmux-flows") + .arg("used", plain(used)) + .arg("max", plain(max)) + .arg("flows", max), )); } windows.push(Window::new( @@ -84,17 +97,22 @@ pub(crate) fn parse(subscription: &str, balance: Option<&str>) -> Result .as_ref() .and_then(|plan| plan.tier.as_deref()) .map(capitalize) - .filter(|tier| !tier.is_empty()) - .map(|tier| format!("{tier} plan")); + .filter(|tier| !tier.is_empty()); let status = detail .account_status .as_deref() .map(str::trim) .filter(|status| !status.is_empty() && !status.eq_ignore_ascii_case("healthy")) .map(capitalize); + // The tier and status are ZenMux's own words, capitalized. let plan = match (tier, status) { - (Some(tier), Some(status)) => Some(format!("{tier} · {status}")), - (tier, status) => tier.or(status), + (Some(tier), Some(status)) => Some( + Text::message("usage-zenmux-tier-plan-status") + .arg("tier", tier) + .arg("status", status), + ), + (Some(tier), None) => Some(Text::message("usage-zenmux-tier-plan").arg("tier", tier)), + (None, status) => status.map(Text::service), }; if let Some(expires) = detail .plan @@ -103,10 +121,12 @@ pub(crate) fn parse(subscription: &str, balance: Option<&str>) -> Result .and_then(Timestamp::time) { flows.push(( - "Plan expires".into(), - chrono::DateTime::::from(expires) - .format("%Y-%m-%d") - .to_string(), + Text::message("usage-zenmux-plan-expires"), + Text::service( + chrono::DateTime::::from(expires) + .format("%Y-%m-%d") + .to_string(), + ), )); } let balances = balance @@ -120,20 +140,21 @@ pub(crate) fn parse(subscription: &str, balance: Option<&str>) -> Result }) .and_then(|balance| balance.total_credits) .filter(|credits| credits.is_finite()) - .map(|credits| Balance::new("PAYG balance", credits, Unit::Currency("USD".into()))); - Ok(Report::new( - Provider(&Zenmux), - Account { - email: None, - plan: plan.map(Text::from), - }, - windows, + .map(|credits| { + Balance::new( + Text::message("usage-zenmux-payg-balance"), + credits, + Unit::Currency("USD".into()), + ) + }); + Ok( + Report::new(Provider(&Zenmux), Account { email: None, plan }, windows) + .with_balances(balances) + .with_sections((!flows.is_empty()).then(|| Section::Facts { + title: Text::message("usage-zenmux-quota"), + facts: flows, + })), ) - .with_balances(balances) - .with_sections((!flows.is_empty()).then(|| Section::Facts { - title: "Quota".into(), - facts: pairs(flows), - }))) } fn capitalize(value: &str) -> String { @@ -224,8 +245,8 @@ mod tests { fn parses_quotas_plan_and_balance() { let report = parse(SUBSCRIPTION, Some(BALANCE)).unwrap(); assert_eq!( - report.account.plan.as_ref().map(Text::as_str), - Some("Ultra plan") + report.account.plan, + Some(Text::message("usage-zenmux-tier-plan").arg("tier", "Ultra")) ); assert_eq!(report.windows.len(), 2); assert_eq!(report.windows[0].kind, Kind::Session); @@ -239,9 +260,29 @@ mod tests { let Section::Facts { facts, .. } = &report.sections[0] else { panic!("expected facts"); }; - assert_eq!(facts[0].1, "57.20 / 800 flows"); - assert_eq!(facts[1].1, "416.11 / 6182 flows"); - assert_eq!(facts[2], ("Plan expires".into(), "2026-04-12".into())); + let flows = |used: &str, max: &str, flows: f64| { + Text::message("usage-zenmux-flows") + .arg("used", used) + .arg("max", max) + .arg("flows", flows) + }; + assert_eq!( + facts, + &[ + ( + Text::message("usage-zenmux-session"), + flows("57.20", "800", 800.) + ), + ( + Text::message("usage-zenmux-weekly"), + flows("416.11", "6182", 6182.) + ), + ( + Text::message("usage-zenmux-plan-expires"), + Text::service("2026-04-12") + ), + ] + ); } #[test] @@ -250,8 +291,12 @@ mod tests { let balance = r#"{"success":true,"data":{"currency":"usd","total_credits":-12.34}}"#; let report = parse(&body, Some(balance)).unwrap(); assert_eq!( - report.account.plan.as_ref().map(Text::as_str), - Some("Ultra plan · Overdue") + report.account.plan, + Some( + Text::message("usage-zenmux-tier-plan-status") + .arg("tier", "Ultra") + .arg("status", "Overdue") + ) ); assert!((report.balances[0].amount + 12.34).abs() < 1e-9); } diff --git a/crates/herdr-usage/src/providers/zoommate.rs b/crates/herdr-usage/src/providers/zoommate.rs index 9f808d035..f59cb9ab7 100644 --- a/crates/herdr-usage/src/providers/zoommate.rs +++ b/crates/herdr-usage/src/providers/zoommate.rs @@ -11,7 +11,7 @@ //! the parent-domain cookies, since the probe takes one cookie header. use crate::model::Count; -use crate::text::pairs; +use crate::text::Text; use crate::{ Error, Result, { @@ -132,27 +132,45 @@ pub(crate) fn parse(body: &str) -> Result { )] }; let balance = if unlimited { - Balance::new("Credits used", used, Unit::Count(Count::Credits)) + Balance::new( + Text::message("usage-zoommate-credits-used"), + used, + Unit::Count(Count::Credits), + ) } else { let left = status.remaining_credit.unwrap_or(cap - used).max(0.); - Balance::new("Credits left", left, Unit::Count(Count::Credits)).out_of(cap) + Balance::new( + Text::message("usage-zoommate-credits-left"), + left, + Unit::Count(Count::Credits), + ) + .out_of(cap) }; - let mut facts: Vec<(String, String)> = Vec::new(); + let mut facts = Vec::new(); if unlimited { - facts.push(("Budget".into(), "Unlimited".into())); + facts.push(( + Text::message("usage-zoommate-budget"), + Text::message("usage-zoommate-unlimited"), + )); } if let Some(overage) = status.overage_credit.filter(|overage| *overage > 0.) { - facts.push(("Overage".into(), format!("{overage:.2} credits"))); + facts.push(( + Text::message("usage-zoommate-overage"), + Text::message("usage-zoommate-overage-credits").arg("credits", format!("{overage:.2}")), + )); } if status.is_quota_available == Some(false) { - facts.push(("Quota".into(), "Exhausted".into())); + facts.push(( + Text::message("usage-zoommate-quota"), + Text::message("usage-zoommate-exhausted"), + )); } Ok( Report::new(Provider(&Zoommate), Account::default(), windows) .with_balances([balance]) .with_sections((!facts.is_empty()).then(|| Section::Facts { - title: "Credits".into(), - facts: pairs(facts), + title: Text::message("usage-zoommate-credits"), + facts, })), ) } @@ -215,7 +233,20 @@ mod tests { let body = STATUS_BODY.replace("\"is_unlimited\": false", "\"is_unlimited\": true"); let report = parse(&body).unwrap(); assert!(report.windows.is_empty()); - assert_eq!(report.balances[0].label, "Credits used"); + assert_eq!( + report.balances[0].label, + Text::message("usage-zoommate-credits-used") + ); + let Section::Facts { facts, .. } = &report.sections[0] else { + panic!("expected facts"); + }; + assert_eq!( + facts[0], + ( + Text::message("usage-zoommate-budget"), + Text::message("usage-zoommate-unlimited") + ) + ); assert_eq!(report.balances[0].amount, 678.); } diff --git a/docs/i18n.md b/docs/i18n.md index 89aa7ff73..ec7cd738e 100644 --- a/docs/i18n.md +++ b/docs/i18n.md @@ -300,7 +300,7 @@ app still shows English literals: own words, which `usage::text::words` writes out; count units are a `Count` the app writes with a plural, and the typed values (limit kinds, countdowns, pace, balances) are localized in `usage/text.rs`. Wayfinder - and the providers from Copilot to OpenRouter in `providers/` name all of their + and the providers from Copilot to Zoommate in `providers/` name all of their words (`usage-.ftl`, with shared ones in `usage-report.ftl`); the other providers still pass English section titles, fact labels and values, balance labels, window names, and account From 262a2bf894cb08e6427c2f15a927fdcd3648687e Mon Sep 17 00:00:00 2001 From: Fabien Penso Date: Sat, 31 Oct 2026 23:20:53 -0700 Subject: [PATCH 07/10] refactor(i18n): show the last plan usage reports through Fluent Abacus, AI& (aiand), Aixy, Alibaba, the Alibaba token plan, Amp, Antigravity, AtlasCloud, Augment, Azure OpenAI, Bedrock, Bifrost, Chutes, Claude (and claude-swap's accounts), ClawRouter, Codebuff, CodeRabbit, Codex, and Command Code now name each word their reports show in their own usage-.ftl. Plan and product names and the services' own words stay as they send them. Cline Pass shows none. With every provider naming its words, Text's transitional From<&str>/From and the pair/pairs/share/shares helpers go, so a report can no longer carry an English literal: it must be a message or Text::service. The crate's and the app's tests build their fixtures that way. usage/tests/words_a.rs checks each provider's English reads as it wrote it, and docs/i18n.md moves plan usage's reports out of "What is left". --- .../herdr-gpui/locales/en-US/usage-abacus.ftl | 7 + .../herdr-gpui/locales/en-US/usage-aiand.ftl | 6 + .../herdr-gpui/locales/en-US/usage-aixy.ftl | 65 +++ .../locales/en-US/usage-alibaba.ftl | 15 + .../locales/en-US/usage-alibabatokenplan.ftl | 21 + crates/herdr-gpui/locales/en-US/usage-amp.ftl | 20 + .../locales/en-US/usage-antigravity.ftl | 15 + .../locales/en-US/usage-atlascloud.ftl | 4 + .../locales/en-US/usage-augment.ftl | 9 + .../locales/en-US/usage-azureopenai.ftl | 11 + .../locales/en-US/usage-bedrock.ftl | 7 + .../locales/en-US/usage-bifrost.ftl | 73 +++ .../herdr-gpui/locales/en-US/usage-chutes.ftl | 22 + .../herdr-gpui/locales/en-US/usage-claude.ftl | 18 + .../locales/en-US/usage-clawrouter.ftl | 33 ++ .../locales/en-US/usage-codebuff.ftl | 15 + .../locales/en-US/usage-coderabbit.ftl | 14 + .../herdr-gpui/locales/en-US/usage-codex.ftl | 14 + .../locales/en-US/usage-commandcode.ftl | 8 + crates/herdr-gpui/src/usage/tests.rs | 1 + crates/herdr-gpui/src/usage/tests/en_xa.rs | 12 +- .../src/usage/tests/status_bar_room.rs | 15 +- crates/herdr-gpui/src/usage/tests/text.rs | 9 +- crates/herdr-gpui/src/usage/tests/words_a.rs | 459 ++++++++++++++++++ crates/herdr-usage/src/providers/abacus.rs | 24 +- crates/herdr-usage/src/providers/aiand.rs | 9 +- crates/herdr-usage/src/providers/aixy.rs | 168 ++++--- crates/herdr-usage/src/providers/alibaba.rs | 35 +- .../src/providers/alibabatokenplan.rs | 66 ++- .../src/providers/alibabatokenplan/tests.rs | 10 +- crates/herdr-usage/src/providers/amp.rs | 80 +-- .../herdr-usage/src/providers/antigravity.rs | 66 ++- .../herdr-usage/src/providers/atlascloud.rs | 7 +- crates/herdr-usage/src/providers/augment.rs | 56 ++- .../herdr-usage/src/providers/azureopenai.rs | 46 +- crates/herdr-usage/src/providers/bedrock.rs | 23 +- crates/herdr-usage/src/providers/bifrost.rs | 185 ++++--- .../src/providers/bifrost/tests.rs | 73 ++- crates/herdr-usage/src/providers/chutes.rs | 131 +++-- crates/herdr-usage/src/providers/claude.rs | 36 +- .../herdr-usage/src/providers/claude/cswap.rs | 2 +- .../src/providers/claude/cswap/tests.rs | 2 +- .../herdr-usage/src/providers/clawrouter.rs | 128 ++--- crates/herdr-usage/src/providers/codebuff.rs | 63 ++- .../herdr-usage/src/providers/coderabbit.rs | 50 +- crates/herdr-usage/src/providers/codex.rs | 20 +- .../herdr-usage/src/providers/commandcode.rs | 23 +- crates/herdr-usage/src/tests/labels.rs | 9 +- crates/herdr-usage/src/tests/provider_data.rs | 22 +- crates/herdr-usage/src/text.rs | 39 -- docs/i18n.md | 19 +- 51 files changed, 1740 insertions(+), 525 deletions(-) create mode 100644 crates/herdr-gpui/locales/en-US/usage-claude.ftl create mode 100644 crates/herdr-gpui/locales/en-US/usage-codex.ftl create mode 100644 crates/herdr-gpui/src/usage/tests/words_a.rs diff --git a/crates/herdr-gpui/locales/en-US/usage-abacus.ftl b/crates/herdr-gpui/locales/en-US/usage-abacus.ftl index bf5b186cf..72b2e6545 100644 --- a/crates/herdr-gpui/locales/en-US/usage-abacus.ftl +++ b/crates/herdr-gpui/locales/en-US/usage-abacus.ftl @@ -5,3 +5,10 @@ ## service's own menu paths stay as written. usage-abacus-setting-cookie = Sign in to https://apps.abacus.ai, open Developer Tools > Application > Cookies for https://apps.abacus.ai, and copy its session cookies (names containing "session", "auth", or "sid"). Paste them as "name=value; name2=value2"; the whole Cookie header of any apps.abacus.ai request also works. + +## The report: the organization's compute credits this billing cycle. + +# The limit's name. +usage-abacus-credits = Credits +# The balance: credits used out of the cycle's total. +usage-abacus-credits-used = Credits used diff --git a/crates/herdr-gpui/locales/en-US/usage-aiand.ftl b/crates/herdr-gpui/locales/en-US/usage-aiand.ftl index a1607b21c..9f432130f 100644 --- a/crates/herdr-gpui/locales/en-US/usage-aiand.ftl +++ b/crates/herdr-gpui/locales/en-US/usage-aiand.ftl @@ -5,3 +5,9 @@ ## service's own menu paths stay as written. usage-aiand-setting-api-key = An ai& API key (starts with sk-), created at https://console.aiand.com under Settings > API Keys > Create. Keys are shown only once. + +## The report: the money spent over the 30 days ai& keeps request logs. + +usage-aiand-spend = Spend, last 30 days +# The same, when only the newest pages of logs were summed. +usage-aiand-spend-partial = Spend, last 30 days (partial) diff --git a/crates/herdr-gpui/locales/en-US/usage-aixy.ftl b/crates/herdr-gpui/locales/en-US/usage-aixy.ftl index 5a10886b9..94d43893c 100644 --- a/crates/herdr-gpui/locales/en-US/usage-aixy.ftl +++ b/crates/herdr-gpui/locales/en-US/usage-aixy.ftl @@ -6,3 +6,68 @@ usage-aixy-setting-api-key = The project-scoped Aixy API key your workload uses, from https://dash.aixy-gateway.com under the project's API keys. Usage is that key's own traffic from every machine. usage-aixy-setting-base-url = Optional gateway URL for a self-hosted or dedicated Aixy, with or without /v1. Defaults to https://api.aixy-gateway.com. It must be HTTPS unless it is on localhost, a private network, or a .local host. + +## The report: the budgets that apply to the key, and the key's own usage +## over the last seven days. Key and project names stay as Aixy sends them. + +usage-aixy-key-title = Aixy key +usage-aixy-key = Key +usage-aixy-project = Project +# A project Aixy names neither by name nor by ID. +usage-aixy-unknown = Unknown +# When Aixy read the figures; the value is its timestamp. +usage-aixy-observed = Observed +usage-aixy-budgets = Applicable budgets +# A budget's name: who it covers ($scope), how often it resets ($interval), +# whether it is shared or personal ($sharing), and whether it stops +# requests or only watches them ($enforcement). +usage-aixy-budget = { $scope -> + [organization] Organization + [project] Project + [team] Team + [user] User + *[api_key] Key + } · { $interval -> + [daily] Daily + [weekly] Weekly + [monthly] Monthly + *[lifetime] Lifetime + } · { $sharing -> + [shared] Shared + *[personal] Personal + } · { $enforcement -> + [hard] Hard + *[monitor] Monitor + } +# A budget's short name as a limit, such as "Project monthly"; $scope and +# $interval as in usage-aixy-budget. +usage-aixy-budget-short = { $scope -> + [organization] Organization + [project] Project + [team] Team + [user] User + *[api_key] Key + } { $interval -> + [daily] daily + [weekly] weekly + [monthly] monthly + *[lifetime] lifetime + } +# A budget's state, in dollars such as $70.00: $left of $limit left, and +# $spent so far. +usage-aixy-budget-left = { $left } of { $limit } left, { $spent } spent +# The same for a budget that stops requests, which also holds $reserved for +# requests still running. +usage-aixy-budget-reserved = { $left } of { $limit } left, { $spent } spent, { $reserved } reserved +usage-aixy-unavailable = Unavailable +# The balance: what the key's last seven days cost, as far as Aixy could +# attribute it. +usage-aixy-attributed-week = Last 7 days, attributed +usage-aixy-week = Last 7 days · this key +usage-aixy-requests = Requests +usage-aixy-tokens = Tokens +usage-aixy-attributed-spend = Attributed spend +usage-aixy-cost-coverage = Cost coverage +# How many requests have a cost: $attributed of all $requests, of which +# $partial only in part. Each is a grouped number, such as 1,200. +usage-aixy-coverage = { $attributed } / { $requests } requests, { $partial } partial diff --git a/crates/herdr-gpui/locales/en-US/usage-alibaba.ftl b/crates/herdr-gpui/locales/en-US/usage-alibaba.ftl index fb950ac72..ee839ee57 100644 --- a/crates/herdr-gpui/locales/en-US/usage-alibaba.ftl +++ b/crates/herdr-gpui/locales/en-US/usage-alibaba.ftl @@ -8,3 +8,18 @@ usage-alibaba-setting-cookie = Your Model Studio console session. Sign in to the usage-alibaba-setting-api-key = A Coding Plan API key (sk-sp-...) from the Coding Plan page of the Model Studio console. Used when no console cookie works; some mainland accounts only answer the cookie. usage-alibaba-setting-region = Optional. "intl" (modelstudio.console.alibabacloud.com) or "cn" (bailian.console.aliyun.com). Unset tries International, then China mainland. usage-alibaba-setting-sec-token = Optional. The console's sec_token, when it cannot be read from /tool/user/info.json: in Developer Tools > Network, the sec_token form field of the data/api.json request on the Coding Plan page. + +## The report: the Coding Plan's requests used in each quota window. Plan +## names stay as Alibaba sends them. + +usage-alibaba-requests-used = Requests used +# A quota window's name, beside its requests used out of the total. +usage-alibaba-window = { $kind -> + [session] Session + [weekly] Weekly + *[monthly] Monthly + } +# Shown for an active plan whose quota Alibaba does not report. +usage-alibaba-coding-plan = Coding Plan +usage-alibaba-status = Status +usage-alibaba-active-unreported = Active, quota not reported diff --git a/crates/herdr-gpui/locales/en-US/usage-alibabatokenplan.ftl b/crates/herdr-gpui/locales/en-US/usage-alibabatokenplan.ftl index 00b81c59d..8c5e6fca5 100644 --- a/crates/herdr-gpui/locales/en-US/usage-alibabatokenplan.ftl +++ b/crates/herdr-gpui/locales/en-US/usage-alibabatokenplan.ftl @@ -7,3 +7,24 @@ usage-alibabatokenplan-setting-region = Which Token Plan to read: "intl" (International Team, the default), "cn" (China mainland Team), "intl-personal" or "cn-personal" (Personal/Solo). usage-alibabatokenplan-setting-cookie = Only needed without a signed-in Bailian CLI (bl). Sign in to the Token Plan page (https://modelstudio.console.alibabacloud.com, or https://bailian.console.aliyun.com in China), open Developer Tools > Application > Cookies for that site, and copy at least login_aliyunid_ticket, login_aliyunid_pk, login_aliyunid_csrf and cna. Paste them as "name=value; name2=value2", or copy the whole Cookie header of the data/api.json request from the Network tab. usage-alibabatokenplan-setting-sec-token = Optional. The console's sec_token, when it cannot be read from /tool/user/info.json: in Developer Tools > Network, the sec_token form field of any data/api.json request on the Token Plan page. + +## The report: the token plan's credits, for a team subscription or a +## personal plan's windows. Qwen Cloud's report uses these words too. Plan +## names stay as Alibaba sends them. + +# The balance: credits left of the subscription's total. +usage-alibabatokenplan-credits-left = Credits left +# Shown for a team account without an active token plan. +usage-alibabatokenplan-subscription = Subscription +usage-alibabatokenplan-active-plans = Active token plans +usage-alibabatokenplan-none = None +usage-alibabatokenplan-credits-used = Credits used +# A window's name, beside its credits used. +usage-alibabatokenplan-window = { $kind -> + [session] Session + [weekly] Weekly + *[monthly] Monthly + } +# Credits used in a window: $used of $total, each a grouped number such as +# 5,000. +usage-alibabatokenplan-credits-of = { $used } / { $total } credits diff --git a/crates/herdr-gpui/locales/en-US/usage-amp.ftl b/crates/herdr-gpui/locales/en-US/usage-amp.ftl index be459cc56..172cba074 100644 --- a/crates/herdr-gpui/locales/en-US/usage-amp.ftl +++ b/crates/herdr-gpui/locales/en-US/usage-amp.ftl @@ -6,3 +6,23 @@ usage-amp-setting-api-key = An Amp access token, created in Amp's settings at https://ampcode.com/settings. Not needed when the Amp CLI is installed and signed in on the host (`amp login`). usage-amp-setting-cookie = Your ampcode.com browser session, used when neither the CLI nor a token is available; it shows Amp Free only. Sign in at https://ampcode.com, open Developer Tools → Application → Cookies → https://ampcode.com, and copy the `session` cookie as "session=value". + +## The report: the subscription's monthly allowances, credits, and +## workspace balances. Plan names, including Amp Free, and organization +## names stay as Amp sends them. + +# A limit: how much of the month's Orb allowance is used. +usage-amp-orb-usage = Orb usage +usage-amp-monthly-allowances = Monthly allowances +# The agent allowance left this month; the value is a dollar amount. +usage-amp-agent = Agent +# Orb machine hours left this month on the a1.small size. +usage-amp-orb-hours = Orb (a1.small hours) +# $hours is a whole number of hours, such as 732. +usage-amp-hours = { $hours }h +usage-amp-under-an-hour = < 1h +usage-amp-individual-credits = Individual credits +# A workspace's credit balance; $name is the workspace's name. +usage-amp-workspace = Workspace { $name } +usage-amp-account = Account +usage-amp-organization = Organization diff --git a/crates/herdr-gpui/locales/en-US/usage-antigravity.ftl b/crates/herdr-gpui/locales/en-US/usage-antigravity.ftl index f9c99405a..401ae3cb9 100644 --- a/crates/herdr-gpui/locales/en-US/usage-antigravity.ftl +++ b/crates/herdr-gpui/locales/en-US/usage-antigravity.ftl @@ -5,3 +5,18 @@ ## service's own menu paths stay as written. usage-antigravity-setting-cli-path = The agy executable on the probed host when it is not on the PATH (install it with `brew install --cask antigravity-cli`, run `agy` once, and sign in). agy runs only when this is set or antigravity is in show_providers, as a signed-out agy opens a browser to sign in. + +## The report: each model group's limits, as agy reports them. Model +## families (Gemini, Claude/GPT) and the names agy gives groups and limits +## stay as written. + +# A group of models agy gives no name. +usage-antigravity-quota = Quota +# A limit's name: $group is the model group, such as Gemini; $cadence says +# whether the limit resets every five hours, weekly, or otherwise, when +# $name is agy's own name for it (empty for the other two). +usage-antigravity-window = { $group } { $cadence -> + [session] 5-hour + [weekly] weekly + *[other] { $name } + } diff --git a/crates/herdr-gpui/locales/en-US/usage-atlascloud.ftl b/crates/herdr-gpui/locales/en-US/usage-atlascloud.ftl index 66a9c2fab..5d7f336ca 100644 --- a/crates/herdr-gpui/locales/en-US/usage-atlascloud.ftl +++ b/crates/herdr-gpui/locales/en-US/usage-atlascloud.ftl @@ -5,3 +5,7 @@ ## service's own menu paths stay as written. usage-atlascloud-setting-api-key = A standard Atlas Cloud API key from https://www.atlascloud.ai/console. It needs account balance read permission: use the account owner's key, or an Account Admin or Finance key for a team. A public key ID (ak_…) cannot authenticate. + +## The report: the account's prepaid balance. + +usage-atlascloud-available-balance = Available balance diff --git a/crates/herdr-gpui/locales/en-US/usage-augment.ftl b/crates/herdr-gpui/locales/en-US/usage-augment.ftl index 5465719e0..a11b08480 100644 --- a/crates/herdr-gpui/locales/en-US/usage-augment.ftl +++ b/crates/herdr-gpui/locales/en-US/usage-augment.ftl @@ -5,3 +5,12 @@ ## service's own menu paths stay as written. usage-augment-setting-cookie = Your app.augmentcode.com browser session, used when the Auggie CLI is not signed in. Sign in at https://app.augmentcode.com, open Developer Tools → Application → Cookies → https://app.augmentcode.com, and copy the session cookies (such as `_session`, `session`, `web_rpc_proxy_session`, or `auth0`), pasted as one header: "name=value; name2=value2". + +## The report: the billing cycle's credits. Plan names, such as Max Plan, +## stay as Augment or auggie writes them. + +# The balance: credits left of the cycle's allowance. +usage-augment-credits-left = Credits left +# The plan line when auggie names no plan: the monthly allowance, where +# $amount is a grouped number such as 450,000. +usage-augment-credits-per-month = { $amount } credits/month diff --git a/crates/herdr-gpui/locales/en-US/usage-azureopenai.ftl b/crates/herdr-gpui/locales/en-US/usage-azureopenai.ftl index 6b308d583..fe74f9ae0 100644 --- a/crates/herdr-gpui/locales/en-US/usage-azureopenai.ftl +++ b/crates/herdr-gpui/locales/en-US/usage-azureopenai.ftl @@ -8,3 +8,14 @@ usage-azureopenai-setting-api-key = An Azure OpenAI resource key: in the Azure p usage-azureopenai-setting-endpoint = The resource endpoint from the same Keys and Endpoint page, e.g. https://my-resource.openai.azure.com. Only HTTPS is accepted. usage-azureopenai-setting-deployment = The deployment name to check, as listed under Deployments in Azure AI Foundry (https://ai.azure.com), e.g. chat-prod. usage-azureopenai-setting-api-version = Optional. "v1" for the OpenAI-compatible v1 API, or a dated API version. Defaults to 2024-10-21. + +## The report: that the deployment answers, and what it runs. Resource, +## deployment, model, and API version names stay as written. + +# The plan line; $deployment is the deployment's name, such as chat-prod. +usage-azureopenai-deployment-plan = Deployment: { $deployment } +usage-azureopenai-reachable = Deployment reachable +usage-azureopenai-resource = Resource +usage-azureopenai-deployment = Deployment +usage-azureopenai-model = Model +usage-azureopenai-api-version = API version diff --git a/crates/herdr-gpui/locales/en-US/usage-bedrock.ftl b/crates/herdr-gpui/locales/en-US/usage-bedrock.ftl index f908b415b..60111c236 100644 --- a/crates/herdr-gpui/locales/en-US/usage-bedrock.ftl +++ b/crates/herdr-gpui/locales/en-US/usage-bedrock.ftl @@ -7,3 +7,10 @@ usage-bedrock-setting-profile = The AWS CLI profile to read Cost Explorer with, from ~/.aws/config on the selected host (run `aws sso login --profile ` first for SSO). Use "default" for the default credentials. The identity needs ce:GetCostAndUsage. AWS bills every Cost Explorer request, so this provider runs only once profile or budget is set here. usage-bedrock-setting-budget = Optional monthly Bedrock budget in US dollars, e.g. 250, shown as a monthly limit. It does not cap AWS charges. usage-bedrock-setting-aws-cli = Optional path to the AWS CLI v2 on the selected host when `aws` is not on its PATH, e.g. /opt/homebrew/bin/aws. + +## The report: this month's Bedrock spend, by AWS service. Service names +## stay as Cost Explorer sends them. + +# The balance: spend since the month began, against the budget if set. +usage-bedrock-month-to-date = Month to date +usage-bedrock-spend-by-service = Spend by service diff --git a/crates/herdr-gpui/locales/en-US/usage-bifrost.ftl b/crates/herdr-gpui/locales/en-US/usage-bifrost.ftl index 1c26fe6d9..4d8668931 100644 --- a/crates/herdr-gpui/locales/en-US/usage-bifrost.ftl +++ b/crates/herdr-gpui/locales/en-US/usage-bifrost.ftl @@ -6,3 +6,76 @@ usage-bifrost-setting-api-key = Your Bifrost virtual key (vk-…), from the gateway's Virtual Keys page. It is sent as the x-bf-vk header to base_url only. usage-bifrost-setting-base-url = Your Bifrost gateway's URL, e.g. https://bifrost.example.com. Bifrost has no public host. It must be HTTPS unless it is on localhost, a private network, or a .local host. + +## The report: the virtual key's budgets, rate limits, and model spend. +## The key's name, budget and pool names, provider and model names, and +## budget IDs stay as Bifrost sends them. + +# A limit set for one provider; $name is the provider's name, or its +# number in the key's list. +usage-bifrost-provider-scope = Provider { $name } +# A limit set for one model; $name is the model's name, after its +# provider's when Bifrost gives one (openai · gpt-4o), or its number. +usage-bifrost-model-scope = Model { $name } +# A rate limit's dimension. +usage-bifrost-tokens = Tokens +usage-bifrost-requests = Requests +# A rate limit drawn from a named pool: $source is the pool's name, such +# as Team pool, and $limit what it limits (usage-bifrost-tokens or +# usage-bifrost-requests). +usage-bifrost-sourced-limit = { $source } { $limit } +# A rate limit for one provider or model: $scope is a scope above, and +# $limit the limit's name. +usage-bifrost-scoped-limit = { $scope } { $limit } +# A budget without a name of its own. +usage-bifrost-budget = Budget +# A budget for one provider or model: $scope is a scope above, and $budget +# the budget's name. +usage-bifrost-scoped-budget = { $scope } · { $budget } +# A budget's calendar period, as a limit's name after the budget's +# ($budget), in a balance's name, and after an amount ($amount, such as +# $42.17 / $125.00). +usage-bifrost-periodic = { $budget } · { $period -> + [hourly] Hourly + [daily] Daily + [weekly] Weekly + [monthly] Monthly + [quarterly] Quarterly + *[yearly] Yearly + } +usage-bifrost-period = { $period -> + [hourly] Hourly + [daily] Daily + [weekly] Weekly + [monthly] Monthly + [quarterly] Quarterly + *[yearly] Yearly + } +usage-bifrost-amount-period = { $amount } · { $period -> + [hourly] Hourly + [daily] Daily + [weekly] Weekly + [monthly] Monthly + [quarterly] Quarterly + *[yearly] Yearly + } +# The balance of a budget without a limit: only what it spent. +usage-bifrost-spend = Spend +# A budget without a pool name, by its ID ($id); $budget is its name. +usage-bifrost-budget-id = { $budget } { $id } +usage-bifrost-rate-limits = Rate limits +# A rate limit whose maximum Bifrost does not report. +usage-bifrost-unavailable = Unavailable +# Shown while Bifrost has turned the virtual key off. +usage-bifrost-virtual-key = Virtual key +usage-bifrost-status = Status +usage-bifrost-inactive = Inactive +usage-bifrost-budgets = Budgets +usage-bifrost-models = Models +# A model row without a model name. +usage-bifrost-model = Model +# A model's spend: $cost in dollars, such as $5.00, and $tokens an +# abbreviated count, such as 1.2M. +usage-bifrost-model-cost = { $cost } · { $tokens } tokens +# How many more models spent, beyond those listed. +usage-bifrost-other-models = Other models diff --git a/crates/herdr-gpui/locales/en-US/usage-chutes.ftl b/crates/herdr-gpui/locales/en-US/usage-chutes.ftl index 2777cda72..0c9713b3d 100644 --- a/crates/herdr-gpui/locales/en-US/usage-chutes.ftl +++ b/crates/herdr-gpui/locales/en-US/usage-chutes.ftl @@ -6,3 +6,25 @@ usage-chutes-setting-api-key = A Chutes API key (cpk_…), created as described at https://chutes.ai/docs/getting-started/authentication. usage-chutes-setting-base-url = Optional HTTPS management API URL. Defaults to https://api.chutes.ai. + +## The report: the subscription's rolling and monthly quotas, or the +## account's per-chute quotas. Plan names, quota names, and units Chutes +## names stay as it sends them. + +# The rolling quota, which refills over four hours. +usage-chutes-rolling = 4-hour +usage-chutes-monthly = Monthly +# A quota Chutes gives no name. +usage-chutes-quota = Quota +# The section listing each quota's amounts. +usage-chutes-quota-title = Quota +# A quota's use: $used of $limit, plain numbers such as 250 and 1000, in +# $unit, which is Chutes' own unit (such as requests) or +# usage-chutes-credits. +usage-chutes-amounts = { $used }/{ $limit } { $unit } +# The unit of a quota Chutes gives none. +usage-chutes-credits = credits +# The plan line of an account without a subscription. +usage-chutes-no-subscription = No active subscription +# The plan line when Chutes reports no quotas at all. +usage-chutes-no-usage = No usage data diff --git a/crates/herdr-gpui/locales/en-US/usage-claude.ftl b/crates/herdr-gpui/locales/en-US/usage-claude.ftl new file mode 100644 index 000000000..755f45d89 --- /dev/null +++ b/crates/herdr-gpui/locales/en-US/usage-claude.ftl @@ -0,0 +1,18 @@ +# Claude's plan usage, in its usage panel. + +## The report: this week's use by surface, and extra usage beyond the +## plan. Plan, model, and surface names (Claude Code, Chats) stay as +## Claude sends them. + +usage-claude-week-by-surface = This week by surface +# Spending beyond the plan's limits, which the account may turn on. +usage-claude-extra-usage = Extra usage +usage-claude-this-month = This month +# Extra usage spent this month: $used of the monthly $limit, each an amount +# with its currency, such as 12.34 EUR. +usage-claude-spent-of = { $used } of { $limit } +# Extra usage turned on, with nothing spent reported. +usage-claude-on = On +usage-claude-status = Status +# Extra usage turned off. +usage-claude-off = Off diff --git a/crates/herdr-gpui/locales/en-US/usage-clawrouter.ftl b/crates/herdr-gpui/locales/en-US/usage-clawrouter.ftl index 9dd4b70a1..8394031ef 100644 --- a/crates/herdr-gpui/locales/en-US/usage-clawrouter.ftl +++ b/crates/herdr-gpui/locales/en-US/usage-clawrouter.ftl @@ -6,3 +6,36 @@ usage-clawrouter-setting-api-key = A ClawRouter policy API key, created at https://clawrouter.openclaw.ai/dashboard/access. usage-clawrouter-setting-base-url = Optional HTTPS service root or /v1 URL of another ClawRouter deployment. Defaults to https://clawrouter.openclaw.ai. + +## The report: the router's requests, tokens, and cost this month, its +## budget, and each routed provider. Provider names and the budget +## ledger's name stay as ClawRouter sends them. + +# The account line: how many providers the router sent requests to. +usage-clawrouter-routed-count = { $count } routed providers +# The plan line, with a monthly budget set or without one. +usage-clawrouter-managed-budget = Managed monthly budget +usage-clawrouter-unmetered = Unmetered +usage-clawrouter-requests = Requests +# All $requests, then how many $succeeded and $failed; each a grouped +# number, such as 1,200. +usage-clawrouter-request-counts = { $requests } · { $succeeded } succeeded · { $failed } failed +usage-clawrouter-tokens = Tokens +# All $tokens, then how many were $input and $output; each a grouped +# number. +usage-clawrouter-token-counts = { $tokens } · { $input } input · { $output } output +usage-clawrouter-actual-cost = Actual cost +usage-clawrouter-budget-ledger = Budget ledger +usage-clawrouter-monthly-budget = Monthly budget +# $amount is spent of the budget, such as $0.012000 / $25.00; $remaining +# the dollars left. +usage-clawrouter-budget-remaining = { $amount } · { $remaining } remaining +# The balance: dollars spent this month. +usage-clawrouter-spent-this-month = Spent this month +usage-clawrouter-routed-providers = Routed providers +# A provider's month: $requests and $tokens are grouped numbers, $cost +# dollars such as $0.004000. +usage-clawrouter-provider-usage = { $requests } requests · { $cost } · { $tokens } tokens +usage-clawrouter-provider-cost = Provider cost +# A provider ClawRouter gives no name. +usage-clawrouter-unknown = Unknown diff --git a/crates/herdr-gpui/locales/en-US/usage-codebuff.ftl b/crates/herdr-gpui/locales/en-US/usage-codebuff.ftl index 4950fd866..f7ef60c4f 100644 --- a/crates/herdr-gpui/locales/en-US/usage-codebuff.ftl +++ b/crates/herdr-gpui/locales/en-US/usage-codebuff.ftl @@ -6,3 +6,18 @@ usage-codebuff-setting-api-key = A Codebuff API key from https://www.codebuff.com. It reads the credit balance only; without it, the session from `codebuff login` is used, which also reads the plan and weekly limit. usage-codebuff-setting-base-url = An https URL that replaces https://www.codebuff.com, e.g. for a staging server. Other schemes are ignored so the token never travels unencrypted. + +## The report: the account's credits, weekly limit, and billing settings. +## Plan names and the subscription's status (such as Active) stay as +## Codebuff sends them. + +# The limit: how much of the credit quota is spent. +usage-codebuff-credits = Credits +# The balance: credits left. +usage-codebuff-remaining = Remaining +usage-codebuff-account = Account +usage-codebuff-subscription = Subscription +# Whether credits are bought automatically when they run low. +usage-codebuff-auto-top-up = Auto top-up +usage-codebuff-on = On +usage-codebuff-off = Off diff --git a/crates/herdr-gpui/locales/en-US/usage-coderabbit.ftl b/crates/herdr-gpui/locales/en-US/usage-coderabbit.ftl index 1c2004357..a09c06562 100644 --- a/crates/herdr-gpui/locales/en-US/usage-coderabbit.ftl +++ b/crates/herdr-gpui/locales/en-US/usage-coderabbit.ftl @@ -5,3 +5,17 @@ ## service's own menu paths stay as written. usage-coderabbit-setting-cli-path = The path of the coderabbit executable on the probed host, when it is not on the usual PATH (~/.local/bin, Homebrew). Sign in once with `coderabbit auth login`. + +## The report: the current billing period, as `coderabbit usage` prints +## it. Its values, such as the organization, the user, and whether usage +## billing is active, stay as the CLI writes them. + +# The balance: code reviews the user ran this billing period. +usage-coderabbit-your-reviews = Your reviews this period +usage-coderabbit-billing-period = Billing period +usage-coderabbit-organization = Organization +usage-coderabbit-user = User +# Whether reviews past the plan are billed by use. +usage-coderabbit-usage-billing = Usage billing +# When the billing period starts again; the value is the CLI's date. +usage-coderabbit-period-resets = Period resets diff --git a/crates/herdr-gpui/locales/en-US/usage-codex.ftl b/crates/herdr-gpui/locales/en-US/usage-codex.ftl new file mode 100644 index 000000000..3c04ab0b9 --- /dev/null +++ b/crates/herdr-gpui/locales/en-US/usage-codex.ftl @@ -0,0 +1,14 @@ +# Codex's plan usage, in its usage panel. + +## The report: the code review limit and the account's credits. Plan +## names stay as ChatGPT sends them. + +# The limit on Codex's code reviews. +usage-codex-code-review = Code review +# Credits that reset a reached limit early; the fact is how many are left. +usage-codex-reset-credits = Limit reset credits +usage-codex-available = Available +usage-codex-credits = Credits +usage-codex-balance = Balance +# A credit balance without limit. +usage-codex-unlimited = Unlimited diff --git a/crates/herdr-gpui/locales/en-US/usage-commandcode.ftl b/crates/herdr-gpui/locales/en-US/usage-commandcode.ftl index 537f97ea2..04867c944 100644 --- a/crates/herdr-gpui/locales/en-US/usage-commandcode.ftl +++ b/crates/herdr-gpui/locales/en-US/usage-commandcode.ftl @@ -5,3 +5,11 @@ ## service's own menu paths stay as written. usage-commandcode-setting-cookie = Sign in to https://commandcode.ai, open Developer Tools > Application > Cookies for https://commandcode.ai, and copy the session cookie: __Secure-better-auth.session_token (or __Secure-commandcode_prod_.session_token, whichever is present). Paste it as "name=value". + +## The report: the plan's monthly credits and any bought on top. Plan +## names (Go, Pro, Max, Ultra) stay as Command Code names its plans. + +# The balance: dollars of the plan's monthly credits left. +usage-commandcode-monthly-left = Monthly credits left +# The balance: dollars of credits bought beyond the plan. +usage-commandcode-purchased = Purchased credits diff --git a/crates/herdr-gpui/src/usage/tests.rs b/crates/herdr-gpui/src/usage/tests.rs index fedf58ce1..c19f13164 100644 --- a/crates/herdr-gpui/src/usage/tests.rs +++ b/crates/herdr-gpui/src/usage/tests.rs @@ -16,6 +16,7 @@ mod section; mod status_bar_room; mod text; mod words; +mod words_a; mod words_b; mod words_c; mod words_d; diff --git a/crates/herdr-gpui/src/usage/tests/en_xa.rs b/crates/herdr-gpui/src/usage/tests/en_xa.rs index debcd21a6..ce3cbbf68 100644 --- a/crates/herdr-gpui/src/usage/tests/en_xa.rs +++ b/crates/herdr-gpui/src/usage/tests/en_xa.rs @@ -50,8 +50,8 @@ fn full(provider: Provider) -> Report { let mut report = Report::new( provider, Account { - email: Some("someone@example.com".into()), - plan: Some("Pro".into()), + email: Some(Text::service("someone@example.com")), + plan: Some(Text::service("Pro")), }, vec![ Window::new(Kind::Session, 40., Some(now + 3 * HOUR), Some(5 * HOUR)), @@ -61,12 +61,12 @@ fn full(provider: Provider) -> Report { Some(now + 24 * 3 * HOUR), Some(24 * 7 * HOUR), ), - Window::new(Kind::Named("Opus".into()), 10., None, None), + Window::new(Kind::Named(Text::service("Opus")), 10., None, None), ], ); - report - .balances - .push(Balance::new("Credits", 12.3, Unit::Currency("USD".into())).out_of(50.)); + report.balances.push( + Balance::new(Text::service("Credits"), 12.3, Unit::Currency("USD".into())).out_of(50.), + ); // A balance a provider names, counted in credits. report.balances.push(Balance::new( Text::message("usage-wayfinder-saved"), diff --git a/crates/herdr-gpui/src/usage/tests/status_bar_room.rs b/crates/herdr-gpui/src/usage/tests/status_bar_room.rs index 96eb42754..327b943ef 100644 --- a/crates/herdr-gpui/src/usage/tests/status_bar_room.rs +++ b/crates/herdr-gpui/src/usage/tests/status_bar_room.rs @@ -3,9 +3,10 @@ use super::*; use crate::config::status_bar::{Button, Detail}; use crate::sidebar::layout_tests::{fixture_window, full_draw}; use gpui::{TestAppContext, px, size}; +use herdr_usage::Text; fn named(name: &str, used: f64) -> Window { - Window::new(Kind::Named(name.into()), used, None, None) + Window::new(Kind::Named(Text::service(name)), used, None, None) } /// Antigravity's shape: a 5-hour and a weekly quota per model family. @@ -43,15 +44,21 @@ fn a_detailed_segment_names_its_two_tightest_windows_in_report_order() { }; assert_eq!( names(&windows), - [Kind::Named("a".into()), Kind::Named("c".into())] + [ + Kind::Named(Text::service("a")), + Kind::Named(Text::service("c")) + ] ); // Ties keep report order, and fewer windows than room show them all. let even = [named("a", 5.), named("b", 5.), named("c", 5.)]; assert_eq!( names(&even), - [Kind::Named("a".into()), Kind::Named("b".into())] + [ + Kind::Named(Text::service("a")), + Kind::Named(Text::service("b")) + ] ); - assert_eq!(names(&even[..1]), [Kind::Named("a".into())]); + assert_eq!(names(&even[..1]), [Kind::Named(Text::service("a"))]); assert!(names(&[]).is_empty()); } diff --git a/crates/herdr-gpui/src/usage/tests/text.rs b/crates/herdr-gpui/src/usage/tests/text.rs index fd6958fbc..ec0c9d30d 100644 --- a/crates/herdr-gpui/src/usage/tests/text.rs +++ b/crates/herdr-gpui/src/usage/tests/text.rs @@ -1,7 +1,10 @@ //! `herdr-usage`'s typed values read as its own English did, now through //! usage.ftl. use super::super::text; -use herdr_usage::model::{Balance, Kind, Pace, Unit, Window}; +use herdr_usage::{ + Text, + model::{Balance, Kind, Pace, Unit, Window}, +}; use std::time::{Duration, SystemTime}; /// Drops Fluent's isolation marks around placeables. @@ -44,7 +47,7 @@ fn limits_name_their_kind_or_the_service_name() { let weekly = Window::new(Kind::Weekly, 11., None, None); assert_eq!(plain(text::left_heading(&weekly)), "Weekly 89% left"); assert_eq!(plain(text::bar_label(&weekly, now)), weekly.label(now)); - let named = Window::new(Kind::Named("Opus".into()), 3., None, None); + let named = Window::new(Kind::Named(Text::service("Opus")), 3., None, None); assert_eq!(plain(text::title(&named.kind)), "Opus"); assert_eq!(plain(text::bar_label(&named, now)), named.label(now)); let session = Window::new( @@ -58,7 +61,7 @@ fn limits_name_their_kind_or_the_service_name() { #[test] fn balances_read_their_total() { - let dollars = Balance::new("Credits", 12.3, Unit::Currency("USD".into())); + let dollars = Balance::new(Text::service("Credits"), 12.3, Unit::Currency("USD".into())); assert_eq!(plain(text::balance(&dollars)), dollars.text()); let budget = dollars.out_of(50.); assert_eq!(plain(text::balance(&budget)), budget.text()); diff --git a/crates/herdr-gpui/src/usage/tests/words_a.rs b/crates/herdr-gpui/src/usage/tests/words_a.rs new file mode 100644 index 000000000..a9920fd24 --- /dev/null +++ b/crates/herdr-gpui/src/usage/tests/words_a.rs @@ -0,0 +1,459 @@ +//! The words batch A's providers name, written by the app: each message +//! reads in English as the provider used to write it. +use super::super::text; +use herdr_usage::Text; + +/// Drops Fluent's isolation marks around placeables. +fn plain(text: String) -> String { + text.replace(['\u{2068}', '\u{2069}'], "") +} + +fn english(words: Text) -> String { + plain(text::words(&words)) +} + +#[test] +fn abacus_reads_as_it_did() { + assert_eq!(english(Text::message("usage-abacus-credits")), "Credits"); + assert_eq!( + english(Text::message("usage-abacus-credits-used")), + "Credits used" + ); +} + +#[test] +fn aiand_reads_as_it_did() { + assert_eq!( + english(Text::message("usage-aiand-spend")), + "Spend, last 30 days" + ); + assert_eq!( + english(Text::message("usage-aiand-spend-partial")), + "Spend, last 30 days (partial)" + ); +} + +#[test] +fn aixy_reads_as_it_did() { + let budget = |scope: &str, interval: &str, sharing: &str, enforcement: &str| { + english( + Text::message("usage-aixy-budget") + .arg("scope", scope) + .arg("interval", interval) + .arg("sharing", sharing) + .arg("enforcement", enforcement), + ) + }; + assert_eq!( + budget("project", "monthly", "shared", "hard"), + "Project · Monthly · Shared · Hard" + ); + assert_eq!( + budget("organization", "daily", "personal", "monitor"), + "Organization · Daily · Personal · Monitor" + ); + assert_eq!( + budget("team", "weekly", "shared", "hard"), + "Team · Weekly · Shared · Hard" + ); + assert_eq!( + budget("user", "lifetime", "personal", "monitor"), + "User · Lifetime · Personal · Monitor" + ); + assert_eq!( + budget("api_key", "monthly", "shared", "hard"), + "Key · Monthly · Shared · Hard" + ); + let short = |scope: &str, interval: &str| { + english( + Text::message("usage-aixy-budget-short") + .arg("scope", scope) + .arg("interval", interval), + ) + }; + assert_eq!(short("project", "monthly"), "Project monthly"); + assert_eq!(short("organization", "daily"), "Organization daily"); + assert_eq!(short("team", "weekly"), "Team weekly"); + assert_eq!(short("user", "lifetime"), "User lifetime"); + assert_eq!(short("api_key", "weekly"), "Key weekly"); + assert_eq!( + english( + Text::message("usage-aixy-budget-left") + .arg("left", "$60.00") + .arg("limit", "$100.00") + .arg("spent", "$40.00") + ), + "$60.00 of $100.00 left, $40.00 spent" + ); + assert_eq!( + english( + Text::message("usage-aixy-budget-reserved") + .arg("left", "$70.00") + .arg("limit", "$100.00") + .arg("spent", "$20.00") + .arg("reserved", "$10.00") + ), + "$70.00 of $100.00 left, $20.00 spent, $10.00 reserved" + ); + assert_eq!( + english( + Text::message("usage-aixy-coverage") + .arg("attributed", "10") + .arg("requests", "1,200") + .arg("partial", "2") + ), + "10 / 1,200 requests, 2 partial" + ); + for (id, words) in [ + ("usage-aixy-key-title", "Aixy key"), + ("usage-aixy-week", "Last 7 days · this key"), + ("usage-aixy-attributed-week", "Last 7 days, attributed"), + ("usage-aixy-unavailable", "Unavailable"), + ("usage-aixy-unknown", "Unknown"), + ] { + assert_eq!(english(Text::message(id)), words); + } +} + +#[test] +fn alibaba_reads_as_it_did() { + let window = |kind: &str| english(Text::message("usage-alibaba-window").arg("kind", kind)); + assert_eq!(window("session"), "Session"); + assert_eq!(window("weekly"), "Weekly"); + assert_eq!(window("monthly"), "Monthly"); + assert_eq!( + english(Text::message("usage-alibaba-active-unreported")), + "Active, quota not reported" + ); +} + +#[test] +fn alibabatokenplan_reads_as_it_did() { + let window = + |kind: &str| english(Text::message("usage-alibabatokenplan-window").arg("kind", kind)); + assert_eq!(window("session"), "Session"); + assert_eq!(window("weekly"), "Weekly"); + assert_eq!(window("monthly"), "Monthly"); + assert_eq!( + english( + Text::message("usage-alibabatokenplan-credits-of") + .arg("used", "150") + .arg("total", "5,000") + ), + "150 / 5,000 credits" + ); + assert_eq!( + english(Text::message("usage-alibabatokenplan-active-plans")), + "Active token plans" + ); +} + +#[test] +fn amp_reads_as_it_did() { + assert_eq!( + english(Text::message("usage-amp-hours").arg("hours", "732")), + "732h" + ); + assert_eq!(english(Text::message("usage-amp-under-an-hour")), "< 1h"); + assert_eq!( + english(Text::message("usage-amp-workspace").arg("name", "meow")), + "Workspace meow" + ); + assert_eq!( + english(Text::message("usage-amp-orb-hours")), + "Orb (a1.small hours)" + ); +} + +#[test] +fn antigravity_reads_as_it_did() { + let window = |group: Text, cadence: &str, name: &str| { + english( + Text::message("usage-antigravity-window") + .arg("group", group) + .arg("cadence", cadence) + .arg("name", name), + ) + }; + assert_eq!( + window(Text::service("Gemini"), "session", ""), + "Gemini 5-hour" + ); + assert_eq!( + window(Text::service("Claude/GPT"), "weekly", ""), + "Claude/GPT weekly" + ); + assert_eq!( + window(Text::message("usage-antigravity-quota"), "other", "Images"), + "Quota Images" + ); +} + +#[test] +fn atlascloud_reads_as_it_did() { + assert_eq!( + english(Text::message("usage-atlascloud-available-balance")), + "Available balance" + ); +} + +#[test] +fn augment_reads_as_it_did() { + assert_eq!( + english(Text::message("usage-augment-credits-per-month").arg("amount", "450,000")), + "450,000 credits/month" + ); + assert_eq!( + english(Text::message("usage-augment-credits-left")), + "Credits left" + ); +} + +#[test] +fn azureopenai_reads_as_it_did() { + assert_eq!( + english(Text::message("usage-azureopenai-deployment-plan").arg("deployment", "chat-prod")), + "Deployment: chat-prod" + ); + assert_eq!( + english(Text::message("usage-azureopenai-reachable")), + "Deployment reachable" + ); +} + +#[test] +fn bedrock_reads_as_it_did() { + assert_eq!( + english(Text::message("usage-bedrock-month-to-date")), + "Month to date" + ); + assert_eq!( + english(Text::message("usage-bedrock-spend-by-service")), + "Spend by service" + ); +} + +#[test] +fn bifrost_reads_as_it_did() { + let tokens = || Text::message("usage-bifrost-tokens"); + let provider = || Text::message("usage-bifrost-provider-scope").arg("name", "openai"); + let pool = Text::message("usage-bifrost-sourced-limit") + .arg("source", "Team pool") + .arg("limit", Text::message("usage-bifrost-requests")); + assert_eq!(english(pool.clone()), "Team pool Requests"); + assert_eq!( + english( + Text::message("usage-bifrost-scoped-limit") + .arg("scope", provider()) + .arg("limit", tokens()) + ), + "Provider openai Tokens" + ); + assert_eq!( + english( + Text::message("usage-bifrost-scoped-limit") + .arg( + "scope", + Text::message("usage-bifrost-model-scope").arg("name", "openai · gpt-4o") + ) + .arg("limit", pool) + ), + "Model openai · gpt-4o Team pool Requests" + ); + let scoped = Text::message("usage-bifrost-scoped-budget") + .arg("scope", provider()) + .arg("budget", Text::message("usage-bifrost-budget")); + assert_eq!(english(scoped.clone()), "Provider openai · Budget"); + for (period, words) in [ + ("hourly", "Hourly"), + ("daily", "Daily"), + ("weekly", "Weekly"), + ("monthly", "Monthly"), + ("quarterly", "Quarterly"), + ("yearly", "Yearly"), + ] { + assert_eq!( + english(Text::message("usage-bifrost-period").arg("period", period)), + words + ); + assert_eq!( + english( + Text::message("usage-bifrost-periodic") + .arg("budget", "Engineering") + .arg("period", period) + ), + format!("Engineering · {words}") + ); + assert_eq!( + english( + Text::message("usage-bifrost-amount-period") + .arg("amount", "$42.17 / $125.00") + .arg("period", period) + ), + format!("$42.17 / $125.00 · {words}") + ); + } + assert_eq!( + english( + Text::message("usage-bifrost-periodic") + .arg("budget", scoped) + .arg("period", "monthly") + ), + "Provider openai · Budget · Monthly" + ); + assert_eq!( + english( + Text::message("usage-bifrost-budget-id") + .arg("budget", Text::message("usage-bifrost-budget")) + .arg("id", "year") + ), + "Budget year" + ); + assert_eq!( + english( + Text::message("usage-bifrost-model-cost") + .arg("cost", "$5.00") + .arg("tokens", "1.2M") + ), + "$5.00 · 1.2M tokens" + ); + assert_eq!( + english(Text::message("usage-bifrost-other-models")), + "Other models" + ); +} + +#[test] +fn chutes_reads_as_it_did() { + let amounts = |unit: Text| { + english( + Text::message("usage-chutes-amounts") + .arg("used", "250") + .arg("limit", "1000") + .arg("unit", unit), + ) + }; + assert_eq!( + amounts(Text::message("usage-chutes-credits")), + "250/1000 credits" + ); + assert_eq!(amounts(Text::service("requests")), "250/1000 requests"); + assert_eq!(english(Text::message("usage-chutes-rolling")), "4-hour"); + assert_eq!( + english(Text::message("usage-chutes-no-subscription")), + "No active subscription" + ); + assert_eq!( + english(Text::message("usage-chutes-no-usage")), + "No usage data" + ); +} + +#[test] +fn claude_reads_as_it_did() { + assert_eq!( + english( + Text::message("usage-claude-spent-of") + .arg("used", "12.34 EUR") + .arg("limit", "50.00 EUR") + ), + "12.34 EUR of 50.00 EUR" + ); + assert_eq!( + english(Text::message("usage-claude-week-by-surface")), + "This week by surface" + ); + assert_eq!(english(Text::message("usage-claude-off")), "Off"); +} + +#[test] +fn clawrouter_reads_as_it_did() { + assert_eq!( + english(Text::message("usage-clawrouter-routed-count").arg("count", 2)), + "2 routed providers" + ); + assert_eq!( + english( + Text::message("usage-clawrouter-request-counts") + .arg("requests", "1,200") + .arg("succeeded", "1,190") + .arg("failed", "10") + ), + "1,200 · 1,190 succeeded · 10 failed" + ); + assert_eq!( + english( + Text::message("usage-clawrouter-token-counts") + .arg("tokens", "63,000") + .arg("input", "42,000") + .arg("output", "21,000") + ), + "63,000 · 42,000 input · 21,000 output" + ); + assert_eq!( + english( + Text::message("usage-clawrouter-budget-remaining") + .arg("amount", "$0.006000 / $25.00") + .arg("remaining", "$24.994000") + ), + "$0.006000 / $25.00 · $24.994000 remaining" + ); + assert_eq!( + english( + Text::message("usage-clawrouter-provider-usage") + .arg("requests", "4") + .arg("cost", "$0.004000") + .arg("tokens", "42,000") + ), + "4 requests · $0.004000 · 42,000 tokens" + ); + assert_eq!( + english(Text::message("usage-clawrouter-managed-budget")), + "Managed monthly budget" + ); +} + +#[test] +fn codebuff_reads_as_it_did() { + assert_eq!( + english(Text::message("usage-codebuff-auto-top-up")), + "Auto top-up" + ); + assert_eq!(english(Text::message("usage-codebuff-on")), "On"); +} + +#[test] +fn coderabbit_reads_as_it_did() { + assert_eq!( + english(Text::message("usage-coderabbit-your-reviews")), + "Your reviews this period" + ); + assert_eq!( + english(Text::message("usage-coderabbit-period-resets")), + "Period resets" + ); +} + +#[test] +fn codex_reads_as_it_did() { + assert_eq!( + english(Text::message("usage-codex-code-review")), + "Code review" + ); + assert_eq!( + english(Text::message("usage-codex-reset-credits")), + "Limit reset credits" + ); +} + +#[test] +fn commandcode_reads_as_it_did() { + assert_eq!( + english(Text::message("usage-commandcode-monthly-left")), + "Monthly credits left" + ); + assert_eq!( + english(Text::message("usage-commandcode-purchased")), + "Purchased credits" + ); +} diff --git a/crates/herdr-usage/src/providers/abacus.rs b/crates/herdr-usage/src/providers/abacus.rs index a9d9f1a59..089c56f84 100644 --- a/crates/herdr-usage/src/providers/abacus.rs +++ b/crates/herdr-usage/src/providers/abacus.rs @@ -86,8 +86,17 @@ pub(crate) fn parse(points: &str, billing: Option<&str>) -> Result { let start = reset.checked_sub_months(chrono::Months::new(1))?; (reset - start).to_std().ok() }); - let window = Window::new(Kind::Named("Credits".into()), percent, resets_at, length); - let credits = Balance::new("Credits used", used.max(0.), Unit::Count(Count::Credits)); + let window = Window::new( + Kind::Named(Text::message("usage-abacus-credits")), + percent, + resets_at, + length, + ); + let credits = Balance::new( + Text::message("usage-abacus-credits-used"), + used.max(0.), + Unit::Count(Count::Credits), + ); let credits = if total > 0. { credits.out_of(total) } else { @@ -97,7 +106,7 @@ pub(crate) fn parse(points: &str, billing: Option<&str>) -> Result { Provider(&Abacus), Account { email: None, - plan: plan.map(Text::from), + plan: plan.map(Text::service), }, vec![window], ) @@ -161,7 +170,14 @@ mod tests { assert_eq!(window.length, Some(Duration::from_secs(30 * 86_400))); assert_eq!( report.balances, - vec![Balance::new("Credits used", 5000., Unit::Count(Count::Credits)).out_of(20000.)] + vec![ + Balance::new( + Text::message("usage-abacus-credits-used"), + 5000., + Unit::Count(Count::Credits) + ) + .out_of(20000.) + ] ); } diff --git a/crates/herdr-usage/src/providers/aiand.rs b/crates/herdr-usage/src/providers/aiand.rs index 317609abc..fe3e6825d 100644 --- a/crates/herdr-usage/src/providers/aiand.rs +++ b/crates/herdr-usage/src/providers/aiand.rs @@ -11,6 +11,7 @@ use crate::{ model::{Account, Balance, Provider, Report, Unit}, probe::{Probe, Request}, service::{Meta, Service, Setting, json}, + text::Text, }, }; use serde::Deserialize; @@ -104,9 +105,9 @@ impl Tally { pub(crate) fn report(self) -> Report { let balance = self.currency.map(|currency| { let label = if self.complete { - "Spend, last 30 days" + Text::message("usage-aiand-spend") } else { - "Spend, last 30 days (partial)" + Text::message("usage-aiand-spend-partial") }; Balance::new( label, @@ -208,7 +209,7 @@ mod tests { assert!(report.windows.is_empty()); assert_eq!(report.balances.len(), 1); let balance = &report.balances[0]; - assert_eq!(balance.label, "Spend, last 30 days"); + assert_eq!(balance.label, Text::message("usage-aiand-spend")); assert_eq!(balance.unit, Unit::Currency("USD".into())); assert!((balance.amount - 1.3625).abs() < 1e-9); } @@ -219,7 +220,7 @@ mod tests { tally.add(FIRST).unwrap(); assert_eq!( tally.report().balances[0].label, - "Spend, last 30 days (partial)" + Text::message("usage-aiand-spend-partial") ); } diff --git a/crates/herdr-usage/src/providers/aixy.rs b/crates/herdr-usage/src/providers/aixy.rs index 981254a06..2b0557f8f 100644 --- a/crates/herdr-usage/src/providers/aixy.rs +++ b/crates/herdr-usage/src/providers/aixy.rs @@ -3,7 +3,7 @@ //! here or on the probed host) at `https://api.aixy-gateway.com` or the //! configured `base_url`. Everything CodexBar reads is ported. -use crate::text::{Text, pair}; +use crate::text::Text; use crate::{ Error, Result, { @@ -129,7 +129,7 @@ pub(crate) fn parse(body: &str) -> Result { if windows.len() < 2 { let kind = match entry.interval.kind() { Some(kind) if windows.iter().all(|window| window.kind != kind) => kind, - _ => Kind::Named(Text::from(entry.short_title())), + _ => Kind::Named(entry.short_title()), }; windows.push(Window { kind, ..window }); } else { @@ -138,29 +138,29 @@ pub(crate) fn parse(body: &str) -> Result { } let mut sections = vec![Section::Facts { - title: "Aixy key".into(), + title: Text::message("usage-aixy-key-title"), facts: vec![ ( - "Key".into(), - Text::from(text(key.name.as_ref()).unwrap_or(key_id).to_owned()), + Text::message("usage-aixy-key"), + Text::service(text(key.name.as_ref()).unwrap_or(key_id)), ), ( - "Project".into(), - Text::from( - text(key.project_name.as_ref()) - .or(key.project_id.as_deref()) - .unwrap_or("Unknown") - .to_owned(), - ), + Text::message("usage-aixy-project"), + text(key.project_name.as_ref()) + .or(key.project_id.as_deref()) + .map_or_else(|| Text::message("usage-aixy-unknown"), Text::service), + ), + ( + Text::message("usage-aixy-observed"), + Text::service(observed), ), - ("Observed".into(), Text::from(observed.to_owned())), ], }]; sections.extend(limits); if !parsed.is_empty() { sections.push(Section::Facts { - title: "Applicable budgets".into(), - facts: parsed.iter().map(Entry::fact).map(pair).collect(), + title: Text::message("usage-aixy-budgets"), + facts: parsed.iter().map(Entry::fact).collect(), }); } let mut balances = Vec::new(); @@ -187,36 +187,45 @@ pub(crate) fn parse(body: &str) -> Result { } if let Some(spend) = spend { balances.push(Balance::new( - "Last 7 days, attributed", + Text::message("usage-aixy-attributed-week"), spend, Unit::Currency("USD".into()), )); } sections.push(Section::Facts { - title: "Last 7 days · this key".into(), + title: Text::message("usage-aixy-week"), facts: vec![ - ("Requests".into(), Text::from(group(requests as i64))), - ("Tokens".into(), Text::from(group(tokens as i64))), ( - "Attributed spend".into(), - Text::from(spend.map_or_else(|| "Unavailable".into(), usd)), + Text::message("usage-aixy-requests"), + Text::service(group(requests as i64)), + ), + ( + Text::message("usage-aixy-tokens"), + Text::service(group(tokens as i64)), ), ( - "Cost coverage".into(), - format!( - "{} / {} requests, {} partial", - group(attributed as i64), - group(requests as i64), - group(partial as i64) - ) - .into(), + Text::message("usage-aixy-attributed-spend"), + spend.map_or_else( + || Text::message("usage-aixy-unavailable"), + |spend| Text::service(usd(spend)), + ), + ), + ( + Text::message("usage-aixy-cost-coverage"), + Text::message("usage-aixy-coverage") + .arg("attributed", group(attributed as i64)) + .arg("requests", group(requests as i64)) + .arg("partial", group(partial as i64)), ), ], }); } None => sections.push(Section::Facts { - title: "Last 7 days · this key".into(), - facts: vec![("Usage".into(), "Unavailable".into())], + title: Text::message("usage-aixy-week"), + facts: vec![( + Text::message("usage-report-usage"), + Text::message("usage-aixy-unavailable"), + )], }), } Ok(Report::new(Provider(&Aixy), Account::default(), windows) @@ -242,18 +251,20 @@ impl Interval { } } - fn title(self) -> &'static str { + /// The wire word, which the budget messages name the interval by. + fn key(self) -> &'static str { match self { - Self::Daily => "Daily", - Self::Weekly => "Weekly", - Self::Monthly => "Monthly", - Self::Lifetime => "Lifetime", + Self::Daily => "daily", + Self::Weekly => "weekly", + Self::Monthly => "monthly", + Self::Lifetime => "lifetime", } } } struct Entry { id: String, + /// The wire word, which the budget messages name the scope by. scope: &'static str, interval: Interval, shared: bool, @@ -271,11 +282,11 @@ impl Entry { fn new(budget: &BudgetRow, key_id: &str, project: Option<&str>) -> Result { let id = text(budget.id.as_ref()).ok_or_else(invalid)?.to_owned(); let scope = match text(budget.scope.as_ref()) { - Some("organization") => "Organization", - Some("project") => "Project", - Some("team") => "Team", - Some("user") => "User", - Some("api_key") => "Key", + Some("organization") => "organization", + Some("project") => "project", + Some("team") => "team", + Some("user") => "user", + Some("api_key") => "api_key", _ => return Err(invalid()), }; let interval = match text(budget.interval.as_ref()) { @@ -373,18 +384,18 @@ impl Entry { .map(|spent| ((spent + self.reserved) / self.limit * 100.).min(100.)) } - fn title(&self) -> String { - format!( - "{} · {} · {} · {}", - self.scope, - self.interval.title(), - if self.shared { "Shared" } else { "Personal" }, - if self.hard { "Hard" } else { "Monitor" }, - ) + fn title(&self) -> Text { + Text::message("usage-aixy-budget") + .arg("scope", self.scope) + .arg("interval", self.interval.key()) + .arg("sharing", if self.shared { "shared" } else { "personal" }) + .arg("enforcement", if self.hard { "hard" } else { "monitor" }) } - fn short_title(&self) -> String { - format!("{} {}", self.scope, self.interval.title().to_lowercase()) + fn short_title(&self) -> Text { + Text::message("usage-aixy-budget-short") + .arg("scope", self.scope) + .arg("interval", self.interval.key()) } fn window(&self) -> Option { @@ -393,28 +404,27 @@ impl Entry { .zip(self.resets_at) .and_then(|(start, end)| end.duration_since(start).ok()); Some(Window::new( - Kind::Named(Text::from(self.title())), + Kind::Named(self.title()), self.used_percent()?, self.resets_at, length, )) } - fn fact(&self) -> (String, String) { + fn fact(&self) -> (Text, Text) { let value = match (self.spent, self.remaining) { - (Some(spent), Some(remaining)) => { - let mut value = format!( - "{} of {} left, {} spent", - usd(remaining), - usd(self.limit), - usd(spent) - ); - if self.hard { - value.push_str(&format!(", {} reserved", usd(self.reserved))); - } - value + (Some(spent), Some(remaining)) if self.hard => { + Text::message("usage-aixy-budget-reserved") + .arg("left", usd(remaining)) + .arg("limit", usd(self.limit)) + .arg("spent", usd(spent)) + .arg("reserved", usd(self.reserved)) } - _ => "Unavailable".into(), + (Some(spent), Some(remaining)) => Text::message("usage-aixy-budget-left") + .arg("left", usd(remaining)) + .arg("limit", usd(self.limit)) + .arg("spent", usd(spent)), + _ => Text::message("usage-aixy-unavailable"), }; (self.title(), value) } @@ -589,22 +599,40 @@ mod tests { let Section::Facts { title, facts } = &report.sections[0] else { panic!("expected key facts"); }; - assert_eq!(title, "Aixy key"); + assert_eq!(title, &Text::message("usage-aixy-key-title")); assert_eq!(facts[0].1, "Developer CLI"); let budgets = report .sections .iter() .find_map(|section| match section { - Section::Facts { title, facts } if title == "Applicable budgets" => Some(facts), + Section::Facts { title, facts } if title == "usage-aixy-budgets" => Some(facts), _ => None, }) .unwrap(); - assert_eq!(budgets[0].0, "Project · Monthly · Shared · Hard"); + assert_eq!( + budgets[0].0, + Text::message("usage-aixy-budget") + .arg("scope", "project") + .arg("interval", "monthly") + .arg("sharing", "shared") + .arg("enforcement", "hard") + ); assert_eq!( budgets[0].1, - "$70.00 of $100.00 left, $20.00 spent, $10.00 reserved" + Text::message("usage-aixy-budget-reserved") + .arg("left", "$70.00") + .arg("limit", "$100.00") + .arg("spent", "$20.00") + .arg("reserved", "$10.00") + ); + assert_eq!(budgets[1].1, Text::message("usage-aixy-unavailable")); + assert_eq!( + budgets[2].1, + Text::message("usage-aixy-budget-left") + .arg("left", "$60.00") + .arg("limit", "$100.00") + .arg("spent", "$40.00") ); - assert_eq!(budgets[1].1, "Unavailable"); } #[test] diff --git a/crates/herdr-usage/src/providers/alibaba.rs b/crates/herdr-usage/src/providers/alibaba.rs index cac6e1729..28b04d36f 100644 --- a/crates/herdr-usage/src/providers/alibaba.rs +++ b/crates/herdr-usage/src/providers/alibaba.rs @@ -20,7 +20,7 @@ use super::oneconsole::{ CHROME_AGENT, DOMAINS, check, cornerstone, date, expand, find_object, find_value, first, form_body, gateway_request, sec_token, string, }; -use crate::text::{Text, pairs}; +use crate::text::Text; use crate::{ Error, Result, { @@ -293,9 +293,11 @@ pub(crate) fn parse(body: &str, now: SystemTime) -> Result { string, ) }); + // The window's name in a fact, selected by these words. let slots = [ ( Kind::Session, + "session", &["per5HourUsedQuota", "perFiveHourUsedQuota"][..], &["per5HourTotalQuota", "perFiveHourTotalQuota"][..], &[ @@ -305,12 +307,14 @@ pub(crate) fn parse(body: &str, now: SystemTime) -> Result { ), ( Kind::Weekly, + "weekly", &["perWeekUsedQuota"][..], &["perWeekTotalQuota"][..], &["perWeekQuotaNextRefreshTime"][..], ), ( Kind::Monthly, + "monthly", &["perBillMonthUsedQuota", "perMonthUsedQuota"][..], &["perBillMonthTotalQuota", "perMonthTotalQuota"][..], &[ @@ -321,7 +325,7 @@ pub(crate) fn parse(body: &str, now: SystemTime) -> Result { ]; let mut windows = Vec::new(); let mut facts = Vec::new(); - for (kind, used_keys, total_keys, reset_keys) in slots { + for (kind, name, used_keys, total_keys, reset_keys) in slots { let Some(quota) = quota else { break; }; @@ -336,8 +340,8 @@ pub(crate) fn parse(body: &str, now: SystemTime) -> Result { reset = reset.map(|at| fresh_session_reset(at, now)); } facts.push(( - kind.title().to_owned(), - format!("{} / {}", group(used as i64), group(total as i64)), + Text::message("usage-alibaba-window").arg("kind", name), + Text::service(format!("{} / {}", group(used as i64), group(total as i64))), )); let length = kind.length(); windows.push(Window::new(kind, used / total * 100., reset, length)); @@ -356,13 +360,16 @@ pub(crate) fn parse(body: &str, now: SystemTime) -> Result { Provider(&Alibaba), Account { email: None, - plan: Some(Text::from(plan)), + plan: Some(Text::service(plan)), }, Vec::new(), ) .with_sections([Section::Facts { - title: "Coding Plan".into(), - facts: vec![("Status".into(), "Active, quota not reported".into())], + title: Text::message("usage-alibaba-coding-plan"), + facts: vec![( + Text::message("usage-alibaba-status"), + Text::message("usage-alibaba-active-unreported"), + )], }])), None => Err(invalid()), }; @@ -371,13 +378,13 @@ pub(crate) fn parse(body: &str, now: SystemTime) -> Result { Provider(&Alibaba), Account { email: None, - plan: plan.map(Text::from), + plan: plan.map(Text::service), }, windows, ) .with_sections([Section::Facts { - title: "Requests used".into(), - facts: pairs(facts), + title: Text::message("usage-alibaba-requests-used"), + facts, }])) } @@ -525,7 +532,13 @@ mod tests { let Section::Facts { facts, .. } = &report.sections[0] else { panic!("expected request facts"); }; - assert_eq!(facts[0], ("Session".into(), "52 / 1,000".into())); + assert_eq!( + facts[0], + ( + Text::message("usage-alibaba-window").arg("kind", "session"), + Text::service("52 / 1,000") + ) + ); } #[test] diff --git a/crates/herdr-usage/src/providers/alibabatokenplan.rs b/crates/herdr-usage/src/providers/alibabatokenplan.rs index 7349df236..666eb9dff 100644 --- a/crates/herdr-usage/src/providers/alibabatokenplan.rs +++ b/crates/herdr-usage/src/providers/alibabatokenplan.rs @@ -19,7 +19,7 @@ use super::oneconsole::{ gateway_request, sec_token, string, }; use crate::model::Count; -use crate::text::{Text, pairs}; +use crate::text::Text; use crate::{ Error, Result, { @@ -500,13 +500,27 @@ pub(super) fn report_with(provider: Provider, team: Team) -> Report { team.remaining, team.used, ) { - (Some(total), Some(left), _) => { - Some(Balance::new("Credits left", left.max(0.), credits()).out_of(total)) - } - (Some(total), None, Some(used)) => { - Some(Balance::new("Credits left", (total - used).max(0.), credits()).out_of(total)) - } - (None, Some(left), _) => Some(Balance::new("Credits left", left.max(0.), credits())), + (Some(total), Some(left), _) => Some( + Balance::new( + Text::message("usage-alibabatokenplan-credits-left"), + left.max(0.), + credits(), + ) + .out_of(total), + ), + (Some(total), None, Some(used)) => Some( + Balance::new( + Text::message("usage-alibabatokenplan-credits-left"), + (total - used).max(0.), + credits(), + ) + .out_of(total), + ), + (None, Some(left), _) => Some(Balance::new( + Text::message("usage-alibabatokenplan-credits-left"), + left.max(0.), + credits(), + )), _ => None, }; let account = Account { @@ -514,13 +528,16 @@ pub(super) fn report_with(provider: Provider, team: Team) -> Report { plan: team .plan .filter(|plan| !plan.trim().is_empty()) - .map(Text::from), + .map(Text::service), }; let mut report = Report::new(provider, account, windows).with_balances(balance); if percent.is_none() && report.balances.is_empty() { report = report.with_sections([Section::Facts { - title: "Subscription".into(), - facts: vec![("Active token plans".into(), "None".into())], + title: Text::message("usage-alibabatokenplan-subscription"), + facts: vec![( + Text::message("usage-alibabatokenplan-active-plans"), + Text::message("usage-alibabatokenplan-none"), + )], }]); } report @@ -536,7 +553,7 @@ pub(super) struct Personal { impl Personal { pub(super) fn report(self, provider: Provider) -> Report { - let facts: Vec<(String, String)> = self + let facts: Vec<(Text, Text)> = self .windows .iter() .zip(self.totals) @@ -544,12 +561,10 @@ impl Personal { let total = total.filter(|total| *total > 0.)?; let used = total * f64::from(window.used) / 100.; Some(( - window.kind.title().to_owned(), - format!( - "{} / {} credits", - group(used.round() as i64), - group(total.round() as i64) - ), + Text::message("usage-alibabatokenplan-window").arg("kind", kind(&window.kind)), + Text::message("usage-alibabatokenplan-credits-of") + .arg("used", group(used.round() as i64)) + .arg("total", group(total.round() as i64)), )) }) .collect(); @@ -557,7 +572,7 @@ impl Personal { provider, Account { email: None, - plan: self.plan.map(Text::from), + plan: self.plan.map(Text::service), }, self.windows, ); @@ -565,13 +580,22 @@ impl Personal { report } else { report.with_sections([Section::Facts { - title: "Credits used".into(), - facts: pairs(facts), + title: Text::message("usage-alibabatokenplan-credits-used"), + facts, }]) } } } +/// A window's kind as the word `usage-alibabatokenplan-window` selects by. +fn kind(kind: &Kind) -> &'static str { + match kind { + Kind::Session => "session", + Kind::Weekly => "weekly", + _ => "monthly", + } +} + /// `strict` is the CLI's contract: ratios must be JSON numbers within 0..=1, /// resets millisecond numbers, and a reset only counts with its ratio. pub(super) fn personal( diff --git a/crates/herdr-usage/src/providers/alibabatokenplan/tests.rs b/crates/herdr-usage/src/providers/alibabatokenplan/tests.rs index 6c71acf58..f9536dcdb 100644 --- a/crates/herdr-usage/src/providers/alibabatokenplan/tests.rs +++ b/crates/herdr-usage/src/providers/alibabatokenplan/tests.rs @@ -74,7 +74,15 @@ fn parses_personal_usage_with_tier_limits() { let Section::Facts { facts, .. } = &report.sections[0] else { panic!("expected credit facts"); }; - assert_eq!(facts[0], ("Session".into(), "150 / 5,000 credits".into())); + assert_eq!( + facts[0], + ( + Text::message("usage-alibabatokenplan-window").arg("kind", "session"), + Text::message("usage-alibabatokenplan-credits-of") + .arg("used", "150") + .arg("total", "5,000") + ) + ); } #[test] diff --git a/crates/herdr-usage/src/providers/amp.rs b/crates/herdr-usage/src/providers/amp.rs index 9ae03c571..18ab10265 100644 --- a/crates/herdr-usage/src/providers/amp.rs +++ b/crates/herdr-usage/src/providers/amp.rs @@ -9,7 +9,7 @@ //! Not ported: CodexBar reads the CLI's standard error when standard output //! is empty; the probe discards standard error, so such a report is missed. -use crate::text::{Text, pairs}; +use crate::text::Text; use crate::{ Error, Result, { @@ -175,48 +175,56 @@ pub(crate) fn parse_report(text: &str, now: SystemTime) -> Result { )); if let Some(orb) = subscription.orb_used { windows.push(Window::new( - Kind::Named("Orb usage".into()), + Kind::Named(Text::message("usage-amp-orb-usage")), orb, Some(subscription.resets_at), subscription.length, )); } if let Some(agent) = subscription.agent_remaining { - facts.push(("Agent".to_owned(), format!("${agent:.2}"))); + facts.push(( + Text::message("usage-amp-agent"), + Text::service(format!("${agent:.2}")), + )); } if let Some(hours) = subscription.orb_hours_remaining { let hours = if hours > 0. && hours < 1. { - "< 1h".to_owned() + Text::message("usage-amp-under-an-hour") } else { - format!("{}h", hours.max(0.).floor()) + Text::message("usage-amp-hours").arg("hours", hours.max(0.).floor().to_string()) }; - facts.push(("Orb (a1.small hours)".to_owned(), hours)); + facts.push((Text::message("usage-amp-orb-hours"), hours)); } } let usd = || Unit::Currency("USD".into()); let balances = individual - .map(|amount| Balance::new("Individual credits", amount, usd())) + .map(|amount| Balance::new(Text::message("usage-amp-individual-credits"), amount, usd())) .into_iter() - .chain( - workspaces - .into_iter() - .map(|(name, amount)| Balance::new(format!("Workspace {name}"), amount, usd())), - ); + .chain(workspaces.into_iter().map(|(name, amount)| { + Balance::new( + Text::message("usage-amp-workspace").arg("name", name), + amount, + usd(), + ) + })); let sections = [ (!facts.is_empty()).then(|| Section::Facts { - title: "Monthly allowances".into(), - facts: pairs(facts), + title: Text::message("usage-amp-monthly-allowances"), + facts, }), organization.map(|organization| Section::Facts { - title: "Account".into(), - facts: vec![("Organization".into(), Text::from(organization))], + title: Text::message("usage-amp-account"), + facts: vec![( + Text::message("usage-amp-organization"), + Text::service(organization), + )], }), ]; Ok(Report::new( Provider(&Amp), Account { - email: email.map(Text::from), - plan: Some(Text::from(plan)), + email: email.map(Text::service), + plan: Some(Text::service(plan)), }, windows, ) @@ -249,7 +257,7 @@ pub(crate) fn parse_html(html: &str, now: SystemTime) -> Result { Provider(&Amp), Account { email: None, - plan: Some("Amp Free".into()), + plan: Some(Text::service("Amp Free")), }, vec![free], )) @@ -321,7 +329,12 @@ fn dollar_free(quota: f64, left: f64, hourly: f64, hours: Option, now: Syst let length = hours .filter(|hours| *hours > 0.) .and_then(|hours| Duration::try_from_secs_f64(hours * 3600.).ok()); - Window::new(Kind::Named("Amp Free".into()), percent, full, length) + Window::new( + Kind::Named(Text::service("Amp Free")), + percent, + full, + length, + ) } /// `Amp Megawatt Tier: agent usage $18.57 of $20 remaining …, resets upon @@ -614,12 +627,12 @@ mod tests { assert_eq!(agent.resets_at, Some(at("2026-10-13T00:00:00Z"))); assert_eq!(agent.length, Some(Duration::from_secs(30 * 86_400))); let orb = &report.windows[1]; - assert_eq!(orb.kind, Kind::Named("Orb usage".into())); + assert_eq!(orb.kind, Kind::Named(Text::message("usage-amp-orb-usage"))); assert!((orb.used - 2.2933).abs() < 1e-3); assert_eq!( report.balances, vec![Balance::new( - "Individual credits", + Text::message("usage-amp-individual-credits"), 20., Unit::Currency("USD".into()) )] @@ -627,10 +640,13 @@ mod tests { assert_eq!( report.sections, vec![Section::Facts { - title: "Monthly allowances".into(), + title: Text::message("usage-amp-monthly-allowances"), facts: vec![ - ("Agent".into(), "$18.57".into()), - ("Orb (a1.small hours)".into(), "732h".into()), + (Text::message("usage-amp-agent"), Text::service("$18.57")), + ( + Text::message("usage-amp-orb-hours"), + Text::message("usage-amp-hours").arg("hours", "732") + ), ], }] ); @@ -649,19 +665,25 @@ mod tests { Some("Amp Free") ); let free = &report.windows[0]; - assert_eq!(free.kind, Kind::Named("Amp Free".into())); + assert_eq!(free.kind, Kind::Named(Text::service("Amp Free"))); assert!((free.used - 52.9).abs() < 1e-3); assert_eq!(free.length, Some(Duration::from_secs(24 * 3600))); let wait = free.resets_at.unwrap().duration_since(now).unwrap(); assert!((wait.as_secs_f64() - 5.29 / 0.42 * 3600.).abs() < 1.); assert_eq!(report.balances.len(), 2); - assert_eq!(report.balances[1].label, "Workspace meow"); + assert_eq!( + report.balances[1].label, + Text::message("usage-amp-workspace").arg("name", "meow") + ); assert!((report.balances[1].amount - 10.22).abs() < 1e-9); assert_eq!( report.sections, vec![Section::Facts { - title: "Account".into(), - facts: vec![("Organization".into(), "echo".into())], + title: Text::message("usage-amp-account"), + facts: vec![( + Text::message("usage-amp-organization"), + Text::service("echo") + )], }] ); } diff --git a/crates/herdr-usage/src/providers/antigravity.rs b/crates/herdr-usage/src/providers/antigravity.rs index 9fcac17ec..549de16b8 100644 --- a/crates/herdr-usage/src/providers/antigravity.rs +++ b/crates/herdr-usage/src/providers/antigravity.rs @@ -141,17 +141,18 @@ pub(crate) fn parse(body: &str) -> Result { )) } -fn group_title(name: Option) -> String { +/// The model family a group's limits are named by, or the group's own name. +fn group_title(name: Option) -> Text { let name = name.unwrap_or_default().trim().to_owned(); let lower = name.to_lowercase(); if lower.contains("gemini") { - "Gemini".into() + Text::service("Gemini") } else if lower.contains("claude") || lower.contains("gpt") { - "Claude/GPT".into() + Text::service("Claude/GPT") } else if name.is_empty() { - "Quota".into() + Text::message("usage-antigravity-quota") } else { - name + Text::service(name) } } @@ -226,7 +227,7 @@ impl Remaining { } impl Bucket { - fn window(self, group: &str) -> Option { + fn window(self, group: &Text) -> Option { if self.disabled == Some(true) { return None; } @@ -245,13 +246,20 @@ impl Bucket { .map(cadence) .find(|cadence| *cadence != Cadence::Other) .unwrap_or(Cadence::Other); - let (kind, length) = match cadence { - Cadence::Session => (format!("{group} 5-hour"), Some(SESSION)), - Cadence::Weekly => (format!("{group} weekly"), Some(WEEK)), - Cadence::Other => (format!("{group} {}", name.unwrap_or(id)), None), + // One message for every cadence, so windows still sort by group + // first, then cadence, as their English names did. + let (cadence, name, length) = match cadence { + Cadence::Session => ("session", String::new(), Some(SESSION)), + Cadence::Weekly => ("weekly", String::new(), Some(WEEK)), + Cadence::Other => ("other", name.unwrap_or(id), None), }; Some(Window::new( - Kind::Named(Text::from(kind)), + Kind::Named( + Text::message("usage-antigravity-window") + .arg("group", group.clone()) + .arg("cadence", cadence) + .arg("name", name), + ), 100. - fraction.clamp(0., 1.) * 100., self.reset_time.as_ref().and_then(Timestamp::time), length, @@ -323,21 +331,23 @@ mod tests { let used: Vec<_> = report .windows .iter() - .map(|window| { - ( - window.kind.title().to_owned(), - window.percent(), - window.length, - ) - }) + .map(|window| (window.kind.clone(), window.percent(), window.length)) .collect(); + let kind = |group: &str, cadence: &str| { + Kind::Named( + Text::message("usage-antigravity-window") + .arg("group", group) + .arg("cadence", cadence) + .arg("name", ""), + ) + }; assert_eq!( used, [ - ("Claude/GPT 5-hour".to_owned(), 0, Some(SESSION)), - ("Claude/GPT weekly".to_owned(), 11, Some(WEEK)), - ("Gemini 5-hour".to_owned(), 5, Some(SESSION)), - ("Gemini weekly".to_owned(), 14, Some(WEEK)), + (kind("Claude/GPT", "session"), 0, Some(SESSION)), + (kind("Claude/GPT", "weekly"), 11, Some(WEEK)), + (kind("Gemini", "session"), 5, Some(SESSION)), + (kind("Gemini", "weekly"), 14, Some(WEEK)), ] ); assert_eq!( @@ -363,8 +373,16 @@ mod tests { "remaining": {"case": "remainingFraction", "value": 0.25}}"#, ) .unwrap(); - let window = bucket.window("Gemini").unwrap(); - assert_eq!(window.kind, Kind::Named("Gemini 5-hour".into())); + let window = bucket.window(&Text::service("Gemini")).unwrap(); + assert_eq!( + window.kind, + Kind::Named( + Text::message("usage-antigravity-window") + .arg("group", "Gemini") + .arg("cadence", "session") + .arg("name", "") + ) + ); assert_eq!(window.percent(), 75); } } diff --git a/crates/herdr-usage/src/providers/atlascloud.rs b/crates/herdr-usage/src/providers/atlascloud.rs index 56e348c61..a012f1787 100644 --- a/crates/herdr-usage/src/providers/atlascloud.rs +++ b/crates/herdr-usage/src/providers/atlascloud.rs @@ -11,6 +11,7 @@ use crate::{ model::{Account, Balance, Provider, Report, Unit}, probe::{Probe, Request}, service::{Meta, Service, Setting, json}, + text::Text, values::{decimal, invalid}, }, }; @@ -61,7 +62,11 @@ pub(crate) fn parse(body: &str) -> Result { .ok_or_else(invalid)?; Ok( Report::new(Provider(&Atlascloud), Account::default(), Vec::new()).with_balances([ - Balance::new("Available balance", amount, Unit::Currency("USD".into())), + Balance::new( + Text::message("usage-atlascloud-available-balance"), + amount, + Unit::Currency("USD".into()), + ), ]), ) } diff --git a/crates/herdr-usage/src/providers/augment.rs b/crates/herdr-usage/src/providers/augment.rs index 651c75374..b9ea77928 100644 --- a/crates/herdr-usage/src/providers/augment.rs +++ b/crates/herdr-usage/src/providers/augment.rs @@ -94,7 +94,9 @@ pub(crate) fn parse_web(credits: &str, subscription: Option<&str>) -> Result Result { used: Some(used.unwrap_or((limit - remaining).max(0.))), limit: Some(limit), cycle_end, - plan: plan.or_else(|| { - monthly.map(|monthly| format!("{} credits/month", group(monthly.round() as i64))) - }), + plan: plan + .filter(|plan| !plan.trim().is_empty()) + .map(Text::service) + .or_else(|| { + monthly.map(|monthly| { + Text::message("usage-augment-credits-per-month") + .arg("amount", group(monthly.round() as i64)) + }) + }), }; Ok(usage.report(None)) } @@ -185,7 +193,7 @@ struct Usage { used: Option, limit: Option, cycle_end: Option, - plan: Option, + plan: Option, } impl Usage { @@ -202,18 +210,20 @@ impl Usage { let remaining = self .remaining .unwrap_or_else(|| (limit - self.used.unwrap_or(0.)).max(0.)); - Balance::new("Credits left", remaining, Unit::Count(Count::Credits)).out_of(limit) + Balance::new( + Text::message("usage-augment-credits-left"), + remaining, + Unit::Count(Count::Credits), + ) + .out_of(limit) }); Report::new( Provider(&Augment), Account { email: email .filter(|email| !email.trim().is_empty()) - .map(Text::from), - plan: self - .plan - .filter(|plan| !plan.trim().is_empty()) - .map(Text::from), + .map(Text::service), + plan: self.plan, }, vec![window], ) @@ -289,8 +299,12 @@ mod tests { assert_eq!( report.balances, vec![ - Balance::new("Credits left", 319_054., Unit::Count(Count::Credits)) - .out_of(450_000.) + Balance::new( + Text::message("usage-augment-credits-left"), + 319_054., + Unit::Count(Count::Credits) + ) + .out_of(450_000.) ] ); let window = &report.windows[0]; @@ -309,7 +323,12 @@ mod tests { assert_eq!( report.balances, vec![ - Balance::new("Credits left", 11_657., Unit::Count(Count::Credits)).out_of(964_827.) + Balance::new( + Text::message("usage-augment-credits-left"), + 11_657., + Unit::Count(Count::Credits) + ) + .out_of(964_827.) ] ); assert_eq!(report.windows[0].percent(), 99); @@ -346,7 +365,14 @@ mod tests { ); assert_eq!( report.balances, - vec![Balance::new("Credits left", 15., Unit::Count(Count::Credits)).out_of(100.)] + vec![ + Balance::new( + Text::message("usage-augment-credits-left"), + 15., + Unit::Count(Count::Credits) + ) + .out_of(100.) + ] ); } diff --git a/crates/herdr-usage/src/providers/azureopenai.rs b/crates/herdr-usage/src/providers/azureopenai.rs index ad0d86a40..af3244d22 100644 --- a/crates/herdr-usage/src/providers/azureopenai.rs +++ b/crates/herdr-usage/src/providers/azureopenai.rs @@ -7,7 +7,7 @@ //! needs Azure Resource Manager credentials, which CodexBar does not read //! either. There is no local sign-in to detect. -use crate::text::pairs; +use crate::text::Text; use crate::{ Error, Result, { @@ -177,21 +177,36 @@ impl Target { pub(crate) fn parse(body: &str, target: &Target) -> Result { let completion: Completion = json(body)?; let mut facts = vec![ - ("Resource".to_owned(), target.host.clone()), - ("Deployment".to_owned(), target.deployment.clone()), + ( + Text::message("usage-azureopenai-resource"), + Text::service(&target.host), + ), + ( + Text::message("usage-azureopenai-deployment"), + Text::service(&target.deployment), + ), ]; if let Some(model) = completion.model.filter(|model| !model.trim().is_empty()) { - facts.push(("Model".into(), model.trim().to_owned())); + facts.push(( + Text::message("usage-azureopenai-model"), + Text::service(model.trim()), + )); } - facts.push(("API version".into(), target.version.clone())); + facts.push(( + Text::message("usage-azureopenai-api-version"), + Text::service(&target.version), + )); let account = Account { email: None, - plan: Some(format!("Deployment: {}", target.deployment).into()), + plan: Some( + Text::message("usage-azureopenai-deployment-plan") + .arg("deployment", target.deployment.as_str()), + ), }; Ok( Report::new(Provider(&Azureopenai), account, Vec::new()).with_sections([Section::Facts { - title: "Deployment reachable".into(), - facts: pairs(facts), + title: Text::message("usage-azureopenai-reachable"), + facts, }]), ) } @@ -205,7 +220,6 @@ struct Completion { #[allow(clippy::unwrap_used, clippy::expect_used)] mod tests { use super::*; - use crate::text::Text; #[test] fn dated_version_uses_deployment_path() { @@ -249,13 +263,19 @@ mod tests { .unwrap(); assert!(report.windows.is_empty()); assert_eq!( - report.account.plan.as_ref().map(Text::as_str), - Some("Deployment: chat-prod") + report.account.plan, + Some(Text::message("usage-azureopenai-deployment-plan").arg("deployment", "chat-prod")) ); let Section::Facts { facts, .. } = &report.sections[0] else { panic!("expected facts"); }; - assert!(facts.contains(&("Model".into(), "gpt-4o-2024-08-06".into()))); - assert!(facts.contains(&("Resource".into(), "resource.openai.azure.com".into()))); + assert!(facts.contains(&( + Text::message("usage-azureopenai-model"), + Text::service("gpt-4o-2024-08-06") + ))); + assert!(facts.contains(&( + Text::message("usage-azureopenai-resource"), + Text::service("resource.openai.azure.com") + ))); } } diff --git a/crates/herdr-usage/src/providers/bedrock.rs b/crates/herdr-usage/src/providers/bedrock.rs index 5b83b00dc..80d5a7c67 100644 --- a/crates/herdr-usage/src/providers/bedrock.rs +++ b/crates/herdr-usage/src/providers/bedrock.rs @@ -12,7 +12,7 @@ //! SigV4) from pasted access keys, the CloudWatch Claude token and request //! totals, and the daily cost history. -use crate::text::pair; +use crate::text::Text; use crate::{ Error, Result, { @@ -143,17 +143,25 @@ pub(crate) fn parse(body: &str, budget: Option, today: NaiveDate) -> Result .map(|budget| Window::new(Kind::Monthly, spend / budget * 100., resets_at, Some(MONTH))) .into_iter() .collect(); - let mut balance = Balance::new("Month to date", spend, Unit::Currency("USD".into())); + let mut balance = Balance::new( + Text::message("usage-bedrock-month-to-date"), + spend, + Unit::Currency("USD".into()), + ); if let Some(budget) = budget { balance = balance.out_of(budget); } services.sort_by(|a, b| b.1.total_cmp(&a.1)); let breakdown = (!services.is_empty()).then(|| Section::Facts { - title: "Spend by service".into(), + title: Text::message("usage-bedrock-spend-by-service"), facts: services .into_iter() - .map(|(service, amount)| (service, format!("${amount:.2}"))) - .map(pair) + .map(|(service, amount)| { + ( + Text::service(service), + Text::service(format!("${amount:.2}")), + ) + }) .collect(), }); Ok(Report::new(Provider(&Bedrock), Account::default(), windows) @@ -264,7 +272,10 @@ mod tests { }; assert_eq!( facts[0], - ("Claude Opus (Bedrock Edition)".into(), "$30.00".into()) + ( + Text::service("Claude Opus (Bedrock Edition)"), + Text::service("$30.00") + ) ); assert_eq!(facts.len(), 2); } diff --git a/crates/herdr-usage/src/providers/bifrost.rs b/crates/herdr-usage/src/providers/bifrost.rs index b9edd6c5e..531b09c69 100644 --- a/crates/herdr-usage/src/providers/bifrost.rs +++ b/crates/herdr-usage/src/providers/bifrost.rs @@ -4,7 +4,7 @@ //! `base_url`. No admin credential is used. Everything CodexBar reads is //! ported; the per-budget list and model rows are shown as facts. -use crate::text::{Text, pair, pairs}; +use crate::text::Text; use crate::{ Error, Result, { @@ -80,20 +80,27 @@ fn text(value: Option<&String>) -> Option<&str> { pub(crate) fn parse(body: &str, now: SystemTime) -> Result { let root: Root = json(body)?; - let mut scopes: Vec<(Option, &Limits)> = vec![(None, &root.limits)]; + let mut scopes: Vec<(Option, &Limits)> = vec![(None, &root.limits)]; for (index, config) in root.provider_configs.iter().enumerate() { let name = text(config.provider.as_ref()).map_or_else(|| (index + 1).to_string(), str::to_owned); - scopes.push((Some(format!("Provider {name}")), &config.limits)); + scopes.push(( + Some(Text::message("usage-bifrost-provider-scope").arg("name", name)), + &config.limits, + )); } for (index, config) in root.model_configs.iter().enumerate() { let model = text(config.model_name.as_ref()).map_or_else(|| (index + 1).to_string(), str::to_owned); + // The provider and model names are Bifrost's, as is their pairing. let name = match text(config.provider.as_ref()) { Some(provider) => format!("{provider} · {model}"), None => model, }; - scopes.push((Some(format!("Model {name}")), &config.limits)); + scopes.push(( + Some(Text::message("usage-bifrost-model-scope").arg("name", name)), + &config.limits, + )); } let mut budgets: Vec = Vec::new(); @@ -149,14 +156,14 @@ pub(crate) fn parse(body: &str, now: SystemTime) -> Result { for limit in selected { for (dimension, max, used, reset, last) in [ ( - "Tokens", + Text::message("usage-bifrost-tokens"), limit.token_max_limit, limit.token_current_usage, &limit.token_reset_duration, &limit.token_last_reset, ), ( - "Requests", + Text::message("usage-bifrost-requests"), limit.request_max_limit, limit.request_current_usage, &limit.request_reset_duration, @@ -169,24 +176,31 @@ pub(crate) fn parse(body: &str, now: SystemTime) -> Result { if !known && reset.is_none() { continue; } - let title = [ - scope.as_deref(), - text(limit.source_name.as_ref()), - Some(dimension), - ] - .into_iter() - .flatten() - .collect::>() - .join(" "); + // The limit's dimension, after the name of the pool it draws + // from and the scope it covers, when there are. + let title = match text(limit.source_name.as_ref()) { + Some(source) => Text::message("usage-bifrost-sourced-limit") + .arg("source", source) + .arg("limit", dimension), + None => dimension, + }; + let title = match scope { + Some(scope) => Text::message("usage-bifrost-scoped-limit") + .arg("scope", scope.clone()) + .arg("limit", title), + None => title, + }; let timing = Timing::new(reset, text(last.as_ref()), now); match max.filter(|_| known) { Some(max) => rate_limits.push(Section::Limit(Window::new( - Kind::Named(Text::from(title)), + Kind::Named(title), used.unwrap_or(0.) / max * 100., timing.resets_at, timing.length, ))), - None => unknown_limits.push((title, "Unavailable".to_owned())), + None => { + unknown_limits.push((title, Text::message("usage-bifrost-unavailable"))) + } } } } @@ -208,10 +222,11 @@ pub(crate) fn parse(body: &str, now: SystemTime) -> Result { } let first = budgets.iter().find(|budget| budget.scope.is_none()); let balance = first.map(|budget| { - let label = budget.timing.label.map_or_else( - || if budget.limit > 0. { "Budget" } else { "Spend" }.to_owned(), - str::to_owned, - ); + let label = match budget.timing.label { + Some(period) => Text::message("usage-bifrost-period").arg("period", period), + None if budget.limit > 0. => Text::message("usage-bifrost-budget"), + None => Text::message("usage-bifrost-spend"), + }; let balance = Balance::new(label, budget.used, Unit::Currency("USD".into())); if budget.limit > 0. { balance.out_of(budget.limit) @@ -224,16 +239,19 @@ pub(crate) fn parse(body: &str, now: SystemTime) -> Result { sections.extend(rate_limits); if !unknown_limits.is_empty() { sections.push(Section::Facts { - title: "Rate limits".into(), - facts: pairs(unknown_limits), + title: Text::message("usage-bifrost-rate-limits"), + facts: unknown_limits, }); } if inactive { sections.insert( 0, Section::Facts { - title: "Virtual key".into(), - facts: vec![("Status".into(), "Inactive".into())], + title: Text::message("usage-bifrost-virtual-key"), + facts: vec![( + Text::message("usage-bifrost-status"), + Text::message("usage-bifrost-inactive"), + )], }, ); } @@ -242,22 +260,15 @@ pub(crate) fn parse(body: &str, now: SystemTime) -> Result { } if budgets.len() > 1 || budgets.iter().any(|budget| budget.scope.is_some()) { sections.push(Section::Facts { - title: "Budgets".into(), - facts: budgets - .iter() - .take(24) - .map(Budget::fact) - .map(pair) - .collect(), + title: Text::message("usage-bifrost-budgets"), + facts: budgets.iter().take(24).map(Budget::fact).collect(), }); } let account = Account { - email: text(root.virtual_key_name.as_ref()) - .map(str::to_owned) - .map(Text::from), + email: text(root.virtual_key_name.as_ref()).map(Text::service), plan: first - .and_then(|budget| budget.source.clone()) - .map(Text::from), + .and_then(|budget| budget.source.as_deref()) + .map(Text::service), }; Ok(Report::new(Provider(&Bifrost), account, windows) .with_balances(balance) @@ -266,7 +277,7 @@ pub(crate) fn parse(body: &str, now: SystemTime) -> Result { struct Budget<'a> { id: String, - scope: Option, + scope: Option, source: Option, limit: f64, used: f64, @@ -275,29 +286,31 @@ struct Budget<'a> { } impl Budget<'_> { - fn title(&self) -> String { - [ - self.scope.as_deref(), - Some(self.source.as_deref().unwrap_or("Budget")), - ] - .into_iter() - .flatten() - .collect::>() - .join(" · ") + /// The budget's pool name, or `Budget`, after the scope it covers. + fn title(&self) -> Text { + let name = self + .source + .as_deref() + .map_or_else(|| Text::message("usage-bifrost-budget"), Text::service); + match &self.scope { + Some(scope) => Text::message("usage-bifrost-scoped-budget") + .arg("scope", scope.clone()) + .arg("budget", name), + None => name, + } } fn window(&self) -> Window { let kind = match (self.timing.label, &self.source, &self.scope) { - (Some("Daily"), None, None) => Kind::Daily, - (Some("Weekly"), None, None) => Kind::Weekly, - (Some("Monthly"), None, None) => Kind::Monthly, - (label, _, _) => Kind::Named(Text::from( - [Some(self.title()), label.map(str::to_owned)] - .into_iter() - .flatten() - .collect::>() - .join(" · "), - )), + (Some("daily"), None, None) => Kind::Daily, + (Some("weekly"), None, None) => Kind::Weekly, + (Some("monthly"), None, None) => Kind::Monthly, + (Some(period), _, _) => Kind::Named( + Text::message("usage-bifrost-periodic") + .arg("budget", self.title()) + .arg("period", period), + ), + (None, _, _) => Kind::Named(self.title()), }; let length = self.timing.length.or_else(|| kind.length()); Window::new( @@ -308,20 +321,24 @@ impl Budget<'_> { ) } - fn fact(&self) -> (String, String) { + fn fact(&self) -> (Text, Text) { let label = match self.source { Some(_) => self.title(), - None => format!("{} {}", self.title(), self.id), + None => Text::message("usage-bifrost-budget-id") + .arg("budget", self.title()) + .arg("id", self.id.as_str()), }; - let mut value = if self.limit > 0. { + let amount = if self.limit > 0. { format!("${:.2} / ${:.2}", self.used, self.limit) } else { format!("${:.2}", self.used) }; - if let Some(period) = self.timing.label { - value.push_str(" · "); - value.push_str(period); - } + let value = match self.timing.label { + Some(period) => Text::message("usage-bifrost-amount-period") + .arg("amount", amount) + .arg("period", period), + None => Text::service(amount), + }; (label, value) } } @@ -333,6 +350,7 @@ struct Timing { seconds: Option, resets_at: Option, length: Option, + /// A calendar period, as the `usage-bifrost-period` messages select it. label: Option<&'static str>, } @@ -350,12 +368,12 @@ impl Timing { start.checked_add(Duration::try_from_secs_f64(cycles * seconds).ok()?) }); let label = match raw { - Some("1h") => Some("Hourly"), - Some("1d") => Some("Daily"), - Some("1w") => Some("Weekly"), - Some("1M") => Some("Monthly"), - Some("1Q") => Some("Quarterly"), - Some("1Y") => Some("Yearly"), + Some("1h") => Some("hourly"), + Some("1d") => Some("daily"), + Some("1w") => Some("weekly"), + Some("1M") => Some("monthly"), + Some("1Q") => Some("quarterly"), + Some("1Y") => Some("yearly"), _ => None, }; Self { @@ -423,11 +441,12 @@ fn duration(raw: &str) -> Option { /// The key-wide budget's model spend, costliest first. fn models(rows: &[ModelUsage]) -> Option
{ - let mut rows: Vec<(&str, f64, f64)> = rows + // A row without a model name sorts by `Model`, as it is shown. + let mut rows: Vec<(Option<&str>, f64, f64)> = rows .iter() .map(|row| { ( - text(row.model.as_ref()).unwrap_or("Model"), + text(row.model.as_ref()), row.total_cost.unwrap_or(0.), row.total_tokens.unwrap_or(0.), ) @@ -441,24 +460,32 @@ fn models(rows: &[ModelUsage]) -> Option
{ b.1.partial_cmp(&a.1) .unwrap_or(Ordering::Equal) .then(b.2.partial_cmp(&a.2).unwrap_or(Ordering::Equal)) - .then(a.0.cmp(b.0)) + .then(a.0.unwrap_or("Model").cmp(b.0.unwrap_or("Model"))) }); - let mut facts: Vec<(String, String)> = rows + let mut facts: Vec<(Text, Text)> = rows .iter() .take(TOP_MODELS) .map(|(model, cost, tokens)| { ( - model_name(model), - format!("${cost:.2} · {} tokens", token_count(*tokens)), + model.map_or_else( + || Text::message("usage-bifrost-model"), + |model| Text::service(model_name(model)), + ), + Text::message("usage-bifrost-model-cost") + .arg("cost", format!("${cost:.2}")) + .arg("tokens", token_count(*tokens)), ) }) .collect(); if rows.len() > TOP_MODELS { - facts.push(("Other models".into(), (rows.len() - TOP_MODELS).to_string())); + facts.push(( + Text::message("usage-bifrost-other-models"), + Text::service((rows.len() - TOP_MODELS).to_string()), + )); } Some(Section::Facts { - title: "Models".into(), - facts: pairs(facts), + title: Text::message("usage-bifrost-models"), + facts, }) } diff --git a/crates/herdr-usage/src/providers/bifrost/tests.rs b/crates/herdr-usage/src/providers/bifrost/tests.rs index 0d25fc8d9..3e5c36243 100644 --- a/crates/herdr-usage/src/providers/bifrost/tests.rs +++ b/crates/herdr-usage/src/providers/bifrost/tests.rs @@ -36,27 +36,42 @@ fn parses_budgets_limits_and_models() { let month = report .windows .iter() - .find(|window| window.kind == Kind::Named("Engineering · Monthly".into())) + .find(|window| { + window.kind + == Kind::Named( + Text::message("usage-bifrost-periodic") + .arg("budget", "Engineering") + .arg("period", "monthly"), + ) + }) .unwrap(); assert!((month.used - 33.736).abs() < 0.001); assert_eq!(month.resets_at, None); - assert_eq!(report.balances[0].label, "Monthly"); + assert_eq!( + report.balances[0].label, + Text::message("usage-bifrost-period").arg("period", "monthly") + ); assert_eq!(report.balances[0].amount, 42.17); assert_eq!(report.balances[0].total, Some(125.)); let limits: Vec<_> = report .sections .iter() .filter_map(|section| match section { - Section::Limit(window) => Some((window.kind.title().to_owned(), window.percent())), + Section::Limit(window) => Some((window.kind.clone(), window.percent())), _ => None, }) .collect(); + let pool = |limit: &'static str| { + Text::message("usage-bifrost-sourced-limit") + .arg("source", "Team pool") + .arg("limit", Text::message(limit)) + }; assert_eq!( limits, [ - ("Tokens".to_owned(), 35), - ("Requests".to_owned(), 2), - ("Team pool Tokens".to_owned(), 0), + (Kind::Named(Text::message("usage-bifrost-tokens")), 35), + (Kind::Named(Text::message("usage-bifrost-requests")), 2), + (Kind::Named(pool("usage-bifrost-tokens")), 0), ] ); let facts = |title: &str| { @@ -66,14 +81,41 @@ fn parses_budgets_limits_and_models() { }) }; assert_eq!( - facts("Rate limits").unwrap(), - [("Team pool Requests".into(), "Unavailable".into())] + facts("usage-bifrost-rate-limits").unwrap(), + [( + pool("usage-bifrost-requests"), + Text::message("usage-bifrost-unavailable") + )] ); assert_eq!( - facts("Models").unwrap(), - [("gpt-4o".into(), "$5.00 · 1.2M tokens".into())] + facts("usage-bifrost-models").unwrap(), + [( + Text::service("gpt-4o"), + Text::message("usage-bifrost-model-cost") + .arg("cost", "$5.00") + .arg("tokens", "1.2M") + )] + ); + let budgets = facts("usage-bifrost-budgets").unwrap(); + assert_eq!( + budgets, + [ + ( + Text::service("Engineering"), + Text::message("usage-bifrost-amount-period") + .arg("amount", "$42.17 / $125.00") + .arg("period", "monthly") + ), + ( + Text::message("usage-bifrost-budget-id") + .arg("budget", Text::message("usage-bifrost-budget")) + .arg("id", "year"), + Text::message("usage-bifrost-amount-period") + .arg("amount", "$100.00 / $1000.00") + .arg("period", "yearly") + ), + ] ); - assert_eq!(facts("Budgets").unwrap().len(), 2); } #[test] @@ -103,7 +145,10 @@ fn unlimited_budget_keeps_spend_without_window() { ) .unwrap(); assert!(report.windows.is_empty()); - assert_eq!(report.balances[0].label, "Spend"); + assert_eq!( + report.balances[0].label, + Text::message("usage-bifrost-spend") + ); assert_eq!(report.balances[0].total, None); let empty = parse(r#"{"virtual_key_name":"svc","budgets":null}"#, now()).unwrap(); assert!(empty.balances.is_empty()); @@ -120,7 +165,9 @@ fn inactive_key_without_quotas_is_rejected() { now(), ) .unwrap(); - assert!(matches!(&report.sections[0], Section::Facts { title, .. } if title == "Virtual key")); + assert!( + matches!(&report.sections[0], Section::Facts { title, .. } if title == "usage-bifrost-virtual-key") + ); } #[test] diff --git a/crates/herdr-usage/src/providers/chutes.rs b/crates/herdr-usage/src/providers/chutes.rs index a18c064c4..1f8b00941 100644 --- a/crates/herdr-usage/src/providers/chutes.rs +++ b/crates/herdr-usage/src/providers/chutes.rs @@ -7,7 +7,7 @@ //! normalized name against CodexBar's candidate lists. Everything CodexBar //! reads is ported. -use crate::text::{Text, pairs}; +use crate::text::Text; use crate::{ Error, Result, { @@ -374,7 +374,8 @@ struct Quota { /// The object it was read from, to tell the same quota found twice. source: String, label: Option, - unit: String, + /// The service's unit; `None` counts credits. + unit: Option, used: Option, limit: Option, percent: f64, @@ -415,9 +416,7 @@ impl Quota { label: lookup(object, LABEL_KEYS) .and_then(text) .or(label.map(str::to_owned)), - unit: lookup(object, UNIT_KEYS) - .and_then(text) - .unwrap_or_else(|| "credits".into()), + unit: lookup(object, UNIT_KEYS).and_then(text), used: used.or(limit .zip(remaining) .map(|(limit, remaining)| (limit - remaining).max(0.))), @@ -429,7 +428,12 @@ impl Quota { } fn is_rolling(&self) -> bool { - let label = format!("{} {}", self.label.as_deref().unwrap_or(""), self.unit).to_lowercase(); + let label = format!( + "{} {}", + self.label.as_deref().unwrap_or(""), + self.unit.as_deref().unwrap_or("credits") + ) + .to_lowercase(); ["rolling", "4h", "4 h", "4-hour", "four hour", "four-hour"] .iter() .any(|word| label.contains(word)) @@ -437,21 +441,30 @@ impl Quota { } fn is_monthly(&self) -> bool { - let label = format!("{} {}", self.label.as_deref().unwrap_or(""), self.unit).to_lowercase(); + let label = format!( + "{} {}", + self.label.as_deref().unwrap_or(""), + self.unit.as_deref().unwrap_or("credits") + ) + .to_lowercase(); ["month", "billing", "subscription"] .iter() .any(|word| label.contains(word)) || self.minutes.is_some_and(|minutes| minutes >= 40_320) } - fn amounts(&self) -> Option { + fn amounts(&self) -> Option { let limit = self.limit.filter(|limit| *limit > 0.)?; - Some(format!( - "{}/{} {}", - values::trimmed(self.used?), - values::trimmed(limit), - self.unit - )) + let unit = self + .unit + .as_deref() + .map_or_else(|| Text::message("usage-chutes-credits"), Text::service); + Some( + Text::message("usage-chutes-amounts") + .arg("used", values::trimmed(self.used?)) + .arg("limit", values::trimmed(limit)) + .arg("unit", unit), + ) } fn window(&self, kind: Kind, minutes: Option, resets_at: Option) -> Window { @@ -585,7 +598,11 @@ fn report(parsed: Parsed) -> Report { let rolling = parsed.rolling.as_ref().map(|quota| { ( quota, - quota.window(Kind::Named("4-hour".into()), Some(ROLLING_MINUTES), None), + quota.window( + Kind::Named(Text::message("usage-chutes-rolling")), + Some(ROLLING_MINUTES), + None, + ), ) }); let monthly = parsed.monthly.as_ref().map(|quota| { @@ -594,9 +611,12 @@ fn report(parsed: Parsed) -> Report { (quota, window) }); let mut rest = parsed.fallback.iter().map(|quota| { - let kind = Kind::Named(Text::from( - quota.label.clone().unwrap_or_else(|| "Quota".into()), - )); + let kind = Kind::Named( + quota + .label + .as_deref() + .map_or_else(|| Text::message("usage-chutes-quota"), Text::service), + ); (quota, quota.window(kind, None, None)) }); let shown: Vec<(&Quota, Window)> = match (rolling, monthly) { @@ -605,29 +625,37 @@ fn report(parsed: Parsed) -> Report { (None, Some(monthly)) => vec![monthly], (None, None) => rest.take(2).collect(), }; - let facts: Vec<(String, String)> = shown + let facts: Vec<(Text, Text)> = shown .iter() - .filter_map(|(quota, window)| Some((window.kind.title().to_owned(), quota.amounts()?))) + .filter_map(|(quota, window)| Some((name(&window.kind), quota.amounts()?))) .collect(); - let plan = parsed.plan.or_else(|| match parsed.active { - Some(false) => Some("No active subscription".into()), - None if shown.is_empty() => Some("No usage data".into()), - _ => None, - }); + let plan = parsed + .plan + .map(Text::service) + .or_else(|| match parsed.active { + Some(false) => Some(Text::message("usage-chutes-no-subscription")), + None if shown.is_empty() => Some(Text::message("usage-chutes-no-usage")), + _ => None, + }); Report::new( Provider(&Chutes), - Account { - email: None, - plan: plan.map(Text::from), - }, + Account { email: None, plan }, shown.into_iter().map(|(_, window)| window).collect(), ) .with_sections((!facts.is_empty()).then(|| Section::Facts { - title: "Quota".into(), - facts: pairs(facts), + title: Text::message("usage-chutes-quota-title"), + facts, })) } +/// A shown window's name: the monthly quota, or the name its kind holds. +fn name(kind: &Kind) -> Text { + match kind { + Kind::Named(name) => name.clone(), + _ => Text::message("usage-chutes-monthly"), + } +} + #[cfg(test)] #[allow(clippy::unwrap_used, clippy::expect_used)] mod tests { @@ -662,7 +690,7 @@ mod tests { let rolling = report .windows .iter() - .find(|window| window.kind == Kind::Named("4-hour".into())) + .find(|window| window.kind == Kind::Named(Text::message("usage-chutes-rolling"))) .unwrap(); assert_eq!(rolling.used, 40.); assert_eq!(rolling.length, Some(Duration::from_secs(4 * 3600))); @@ -673,8 +701,24 @@ mod tests { let Section::Facts { facts, .. } = &report.sections[0] else { panic!("expected facts"); }; - assert!(facts.contains(&("4-hour".into(), "40/100 requests".into()))); - assert!(facts.contains(&("Monthly".into(), "250/1000 credits".into()))); + assert!( + facts.contains(&( + Text::message("usage-chutes-rolling"), + Text::message("usage-chutes-amounts") + .arg("used", "40") + .arg("limit", "100") + .arg("unit", "requests") + )) + ); + assert!( + facts.contains(&( + Text::message("usage-chutes-monthly"), + Text::message("usage-chutes-amounts") + .arg("used", "250") + .arg("limit", "1000") + .arg("unit", "credits") + )) + ); } #[test] @@ -697,13 +741,22 @@ mod tests { assert_eq!(report.windows.len(), 1); assert_eq!(report.windows[0].used, 10.); assert_eq!( - report.account.plan.as_ref().map(Text::as_str), - Some("No active subscription") + report.account.plan, + Some(Text::message("usage-chutes-no-subscription")) ); let Section::Facts { facts, .. } = &report.sections[0] else { panic!("expected facts"); }; - assert_eq!(facts, &[("Quota".into(), "10/100 credits".into())]); + assert_eq!( + facts, + &[( + Text::message("usage-chutes-quota"), + Text::message("usage-chutes-amounts") + .arg("used", "10") + .arg("limit", "100") + .arg("unit", Text::message("usage-chutes-credits")) + )] + ); } #[test] @@ -730,8 +783,8 @@ mod tests { let report = report(parse(&value("{}"))); assert!(report.windows.is_empty()); assert_eq!( - report.account.plan.as_ref().map(Text::as_str), - Some("No usage data") + report.account.plan, + Some(Text::message("usage-chutes-no-usage")) ); } } diff --git a/crates/herdr-usage/src/providers/claude.rs b/crates/herdr-usage/src/providers/claude.rs index 584826e26..8bd14f7c7 100644 --- a/crates/herdr-usage/src/providers/claude.rs +++ b/crates/herdr-usage/src/providers/claude.rs @@ -57,7 +57,7 @@ impl Service for Claude { None => Ok(Report::new( Provider(&Claude), Account { - email: Some(Text::from(active.as_ref()?.email.clone())), + email: Some(Text::service(&active.as_ref()?.email)), plan: None, }, active.filter(|active| !active.windows.is_empty())?.windows, @@ -157,7 +157,7 @@ pub(crate) fn parse(body: &str, sign_in: SignIn) -> Result { let kind = match limit.kind.as_str() { "session" => Kind::Session, "weekly_all" => Kind::Weekly, - "weekly_scoped" => Kind::Named(Text::from( + "weekly_scoped" => Kind::Named(Text::service( limit .scope? .model? @@ -193,39 +193,49 @@ pub(crate) fn parse(body: &str, sign_in: SignIn) -> Result { .collect(); } let account = Account { - email: sign_in.email.map(Text::from), - plan: plan(sign_in.plan.as_deref(), sign_in.tier.as_deref()).map(Text::from), + email: sign_in.email.map(Text::service), + plan: plan(sign_in.plan.as_deref(), sign_in.tier.as_deref()).map(Text::service), }; let shares: Vec<_> = usage .seven_day_breakdown .map(|breakdown| breakdown.rows) .unwrap_or_default() .into_iter() - .filter_map(|row| Some((row.display_name?, row.percent.filter(|p| *p > 0.)? as f32))) + .filter_map(|row| { + Some(( + Text::service(row.display_name?), + row.percent.filter(|p| *p > 0.)? as f32, + )) + }) .collect(); let spend = usage.spend.map(|spend| Section::Facts { - title: "Extra usage".into(), + title: Text::message("usage-claude-extra-usage"), facts: if spend.enabled.unwrap_or(false) { let used = spend.used.as_ref().and_then(Money::text); let limit = spend.limit.as_ref().and_then(Money::text); vec![( - "This month".into(), + Text::message("usage-claude-this-month"), match (used, limit) { - (Some(used), Some(limit)) => format!("{used} of {limit}").into(), - (Some(used), None) => Text::from(used), - _ => "On".into(), + (Some(used), Some(limit)) => Text::message("usage-claude-spent-of") + .arg("used", used) + .arg("limit", limit), + (Some(used), None) => Text::service(used), + _ => Text::message("usage-claude-on"), }, )] } else { - vec![("Status".into(), "Off".into())] + vec![( + Text::message("usage-claude-status"), + Text::message("usage-claude-off"), + )] }, }); Ok( Report::new(Provider(&Claude), account, windows).with_sections( (!shares.is_empty()) .then(|| Section::Shares { - title: "This week by surface".into(), - shares: crate::text::shares(shares), + title: Text::message("usage-claude-week-by-surface"), + shares, }) .into_iter() .chain(spend), diff --git a/crates/herdr-usage/src/providers/claude/cswap.rs b/crates/herdr-usage/src/providers/claude/cswap.rs index da9e162a5..b6731dedc 100644 --- a/crates/herdr-usage/src/providers/claude/cswap.rs +++ b/crates/herdr-usage/src/providers/claude/cswap.rs @@ -164,7 +164,7 @@ impl Usage { .map(|scoped| { scoped .reading - .window(Kind::Named(Text::from(scoped.name)), WEEK) + .window(Kind::Named(Text::service(scoped.name)), WEEK) }), ) .collect(); diff --git a/crates/herdr-usage/src/providers/claude/cswap/tests.rs b/crates/herdr-usage/src/providers/claude/cswap/tests.rs index 2d831c150..0bdd4f281 100644 --- a/crates/herdr-usage/src/providers/claude/cswap/tests.rs +++ b/crates/herdr-usage/src/providers/claude/cswap/tests.rs @@ -40,7 +40,7 @@ fn reads_session_weekly_and_model_windows() { [ (Kind::Session, 25, Some(SESSION)), (Kind::Weekly, 16, Some(WEEK)), - (Kind::Named("Fable".into()), 80, Some(WEEK)), + (Kind::Named(Text::service("Fable")), 80, Some(WEEK)), ] ); assert_eq!( diff --git a/crates/herdr-usage/src/providers/clawrouter.rs b/crates/herdr-usage/src/providers/clawrouter.rs index b333c3ae6..659b1e49f 100644 --- a/crates/herdr-usage/src/providers/clawrouter.rs +++ b/crates/herdr-usage/src/providers/clawrouter.rs @@ -5,7 +5,7 @@ //! cost. CodexBar's per-provider cost chart is shown as shares. Everything //! CodexBar reads is ported. -use crate::text::{pair, pairs, share}; +use crate::text::Text; use crate::{ Result, { @@ -86,25 +86,27 @@ fn parse(body: &str) -> Result { let mut balances = Vec::new(); let mut facts = vec![ ( - "Requests".to_owned(), - format!( - "{} · {} succeeded · {} failed", - group(summary.request_count), - group(summary.success_count), - group(summary.error_count) - ), + Text::message("usage-clawrouter-requests"), + Text::message("usage-clawrouter-request-counts") + .arg("requests", group(summary.request_count)) + .arg("succeeded", group(summary.success_count)) + .arg("failed", group(summary.error_count)), ), ( - "Tokens".to_owned(), - format!( - "{} · {} input · {} output", - group(summary.total_tokens), - group(summary.input_tokens), - group(summary.output_tokens) - ), + Text::message("usage-clawrouter-tokens"), + Text::message("usage-clawrouter-token-counts") + .arg("tokens", group(summary.total_tokens)) + .arg("input", group(summary.input_tokens)) + .arg("output", group(summary.output_tokens)), + ), + ( + Text::message("usage-clawrouter-actual-cost"), + Text::service(format!("${actual:.6}")), + ), + ( + Text::message("usage-clawrouter-budget-ledger"), + Text::service(&budget.ledger), ), - ("Actual cost".to_owned(), format!("${actual:.6}")), - ("Budget ledger".to_owned(), budget.ledger.clone()), ]; match (spent, limit) { (Some(spent), Some(limit)) => { @@ -118,17 +120,25 @@ fn parse(body: &str) -> Result { )); } balances.push( - Balance::new("Spent this month", spent, Unit::Currency("USD".into())).out_of(limit), + Balance::new( + Text::message("usage-clawrouter-spent-this-month"), + spent, + Unit::Currency("USD".into()), + ) + .out_of(limit), ); - let mut text = format!("${spent:.6} / ${limit:.2}"); - if let Some(remaining) = remaining { - text.push_str(&format!(" · ${remaining:.6} remaining")); - } - facts.push(("Monthly budget".into(), text)); + let amount = format!("${spent:.6} / ${limit:.2}"); + let text = match remaining { + Some(remaining) => Text::message("usage-clawrouter-budget-remaining") + .arg("amount", amount) + .arg("remaining", format!("${remaining:.6}")), + None => Text::service(amount), + }; + facts.push((Text::message("usage-clawrouter-monthly-budget"), text)); } _ if actual > 0. => { balances.push(Balance::new( - "Spent this month", + Text::message("usage-clawrouter-spent-this-month"), actual, Unit::Currency("USD".into()), )); @@ -142,50 +152,47 @@ fn parse(body: &str) -> Result { .sum(); let routed = providers.iter().take(SHOWN_PROVIDERS).map(|provider| { ( - name(&provider.provider).to_owned(), - format!( - "{} requests · ${:.6} · {} tokens", - group(provider.request_count), - dollars(provider.actual_cost_micros), - group(provider.total_tokens) - ), + name(&provider.provider), + Text::message("usage-clawrouter-provider-usage") + .arg("requests", group(provider.request_count)) + .arg( + "cost", + format!("${:.6}", dollars(provider.actual_cost_micros)), + ) + .arg("tokens", group(provider.total_tokens)), ) }); let routed = Section::Facts { - title: "Routed providers".into(), - facts: routed.map(pair).collect(), + title: Text::message("usage-clawrouter-routed-providers"), + facts: routed.collect(), }; let shares = (total > 0).then(|| Section::Shares { - title: "Provider cost".into(), + title: Text::message("usage-clawrouter-provider-cost"), shares: providers .iter() .take(SHOWN_PROVIDERS) .map(|provider| { ( - name(&provider.provider).to_owned(), + name(&provider.provider), (provider.actual_cost_micros.max(0) as f64 / total as f64 * 100.) as f32, ) }) - .map(share) .collect(), }); let account = Account { - email: Some(format!("{} routed providers", providers.len()).into()), - plan: Some( - if budget.configured { - "Managed monthly budget" - } else { - "Unmetered" - } - .into(), - ), + email: Some(Text::message("usage-clawrouter-routed-count").arg("count", providers.len())), + plan: Some(if budget.configured { + Text::message("usage-clawrouter-managed-budget") + } else { + Text::message("usage-clawrouter-unmetered") + }), }; Ok(Report::new(Provider(&Clawrouter), account, windows) .with_balances(balances) .with_sections( [Section::Facts { - title: "Usage".into(), - facts: pairs(facts), + title: Text::message("usage-report-usage"), + facts, }] .into_iter() .chain((!providers.is_empty()).then_some(routed)) @@ -193,10 +200,10 @@ fn parse(body: &str) -> Result { )) } -fn name(provider: &str) -> &str { +fn name(provider: &str) -> Text { match provider.trim() { - "" => "Unknown", - name => name, + "" => Text::message("usage-clawrouter-unknown"), + name => Text::service(name), } } @@ -286,7 +293,6 @@ struct Routed { mod tests { use super::*; use crate::service::Timestamp; - use crate::text::Text; const BUDGETED: &str = r#"{ "policyId": "openclaw-smoke", @@ -328,12 +334,12 @@ mod tests { fn reads_a_monthly_budget() { let report = parse(BUDGETED).unwrap(); assert_eq!( - report.account.plan.as_ref().map(Text::as_str), - Some("Managed monthly budget") + report.account.plan, + Some(Text::message("usage-clawrouter-managed-budget")) ); assert_eq!( - report.account.email.as_ref().map(Text::as_str), - Some("2 routed providers") + report.account.email, + Some(Text::message("usage-clawrouter-routed-count").arg("count", 2)) ); let window = &report.windows[0]; assert_eq!(window.kind, Kind::Monthly); @@ -348,7 +354,13 @@ mod tests { panic!("expected routed providers"); }; assert_eq!(facts[0].0, "openai"); - assert_eq!(facts[0].1, "4 requests · $0.004000 · 42,000 tokens"); + assert_eq!( + facts[0].1, + Text::message("usage-clawrouter-provider-usage") + .arg("requests", "4") + .arg("cost", "$0.004000") + .arg("tokens", "42,000") + ); let Section::Shares { shares, .. } = &report.sections[2] else { panic!("expected cost shares"); }; @@ -360,8 +372,8 @@ mod tests { let report = parse(UNMETERED).unwrap(); assert!(report.windows.is_empty()); assert_eq!( - report.account.plan.as_ref().map(Text::as_str), - Some("Unmetered") + report.account.plan, + Some(Text::message("usage-clawrouter-unmetered")) ); assert_eq!(report.balances[0].text(), "$1.25"); let Section::Facts { facts, .. } = &report.sections[1] else { diff --git a/crates/herdr-usage/src/providers/codebuff.rs b/crates/herdr-usage/src/providers/codebuff.rs index df0b6dc90..a38636822 100644 --- a/crates/herdr-usage/src/providers/codebuff.rs +++ b/crates/herdr-usage/src/providers/codebuff.rs @@ -7,7 +7,7 @@ //! Everything CodexBar reads is ported. use crate::model::Count; -use crate::text::{Text, pairs}; +use crate::text::Text; use crate::{ Result, { @@ -118,22 +118,27 @@ pub(crate) fn parse( let mut windows = Vec::new(); match total { Some(total) if total > 0. => windows.push(Window::new( - Kind::Named("Credits".into()), + Kind::Named(Text::message("usage-codebuff-credits")), spent / total * 100., resets_at, None, )), // No usable quota: shown as spent rather than as a healthy bar. _ if used.is_some() || remaining.is_some() => windows.push(Window::new( - Kind::Named("Credits".into()), + Kind::Named(Text::message("usage-codebuff-credits")), 100., resets_at, None, )), _ => {} } - let balances = - remaining.map(|left| Balance::new("Remaining", left, Unit::Count(Count::Credits))); + let balances = remaining.map(|left| { + Balance::new( + Text::message("usage-codebuff-remaining"), + left, + Unit::Count(Count::Credits), + ) + }); let auto_top_up = ["autoTopupEnabled", "auto_topup_enabled"] .iter() .find_map(|key| usage.get(*key).and_then(Value::as_bool)); @@ -180,21 +185,28 @@ pub(crate) fn parse( .and_then(|details| details.get("status")) .and_then(text) { - facts.push(("Subscription".to_owned(), title_case(&status))); + facts.push(( + Text::message("usage-codebuff-subscription"), + Text::service(title_case(&status)), + )); } if let Some(enabled) = auto_top_up { facts.push(( - "Auto top-up".to_owned(), - if enabled { "On" } else { "Off" }.to_owned(), + Text::message("usage-codebuff-auto-top-up"), + if enabled { + Text::message("usage-codebuff-on") + } else { + Text::message("usage-codebuff-off") + }, )); } let section = (!facts.is_empty()).then(|| Section::Facts { - title: "Account".into(), - facts: pairs(facts), + title: Text::message("usage-codebuff-account"), + facts, }); let account = Account { - email: email.map(Text::from), - plan: plan.map(|plan| title_case(&plan)).map(Text::from), + email: email.map(Text::service), + plan: plan.map(|plan| Text::service(title_case(&plan))), }; Ok(Report::new(Provider(&Codebuff), account, windows) .with_balances(balances) @@ -228,7 +240,6 @@ fn time(value: Option<&Value>) -> Option { mod tests { use super::*; use crate::Error; - use crate::text::pair; #[test] fn api_key_reads_credits_only() { @@ -239,11 +250,18 @@ mod tests { ) .unwrap(); assert_eq!(report.windows.len(), 1); - assert_eq!(report.windows[0].kind, Kind::Named("Credits".into())); + assert_eq!( + report.windows[0].kind, + Kind::Named(Text::message("usage-codebuff-credits")) + ); assert!((report.windows[0].used - 25.).abs() < 1e-4); assert_eq!( report.balances, - vec![Balance::new("Remaining", 75., Unit::Count(Count::Credits))] + vec![Balance::new( + Text::message("usage-codebuff-remaining"), + 75., + Unit::Count(Count::Credits) + )] ); assert_eq!(report.account, Account::default()); } @@ -267,7 +285,7 @@ mod tests { let credits = report .windows .iter() - .find(|window| window.kind == Kind::Named("Credits".into())) + .find(|window| window.kind == Kind::Named(Text::message("usage-codebuff-credits"))) .unwrap(); assert!((credits.used - 40.).abs() < 1e-4); assert!(credits.resets_at.is_some()); @@ -284,9 +302,18 @@ mod tests { }; assert_eq!( facts[0], - pair(("Subscription".to_owned(), "Active".to_owned())) + ( + Text::message("usage-codebuff-subscription"), + Text::service("Active") + ) + ); + assert_eq!( + facts[1], + ( + Text::message("usage-codebuff-auto-top-up"), + Text::message("usage-codebuff-on") + ) ); - assert_eq!(facts[1], pair(("Auto top-up".to_owned(), "On".to_owned()))); } #[test] diff --git a/crates/herdr-usage/src/providers/coderabbit.rs b/crates/herdr-usage/src/providers/coderabbit.rs index 5c4f7b8a7..8d09cf922 100644 --- a/crates/herdr-usage/src/providers/coderabbit.rs +++ b/crates/herdr-usage/src/providers/coderabbit.rs @@ -7,7 +7,7 @@ //! supported by the upstream command. use crate::model::Count; -use crate::text::{Text, pairs}; +use crate::text::Text; use crate::{ Error, Result, { @@ -77,33 +77,43 @@ pub(crate) fn parse(text: &str) -> Option { return None; } let mut facts = Vec::new(); + // The values are the CLI's own, such as `inactive` for billing. let named = [ - ("Organization", fields.get("organization")), - ("User", fields.get("user")), - ("Usage billing", billing.as_ref()), - ("Period resets", resets.as_ref()), + ( + Text::message("usage-coderabbit-organization"), + fields.get("organization"), + ), + (Text::message("usage-coderabbit-user"), fields.get("user")), + ( + Text::message("usage-coderabbit-usage-billing"), + billing.as_ref(), + ), + ( + Text::message("usage-coderabbit-period-resets"), + resets.as_ref(), + ), ]; for (label, value) in named { if let Some(value) = value { - facts.push((label.to_owned(), value.clone())); + facts.push((label, Text::service(value))); } } let account = Account { email: None, - plan: fields.get("plan").cloned().map(Text::from), + plan: fields.get("plan").map(Text::service), }; Some( Report::new(Provider(&Coderabbit), account, Vec::new()) .with_balances(reviews.map(|reviews| { Balance::new( - "Your reviews this period", + Text::message("usage-coderabbit-your-reviews"), reviews as f64, Unit::Count(Count::Reviews), ) })) .with_sections((!facts.is_empty()).then(|| Section::Facts { - title: "Billing period".into(), - facts: pairs(facts), + title: Text::message("usage-coderabbit-billing-period"), + facts, })), ) } @@ -165,10 +175,22 @@ mod tests { assert_eq!( facts, &[ - ("Organization".into(), "Example Org".into()), - ("User".into(), "example-user".into()), - ("Usage billing".into(), "inactive".into()), - ("Period resets".into(), "2026-09-30".into()), + ( + Text::message("usage-coderabbit-organization"), + Text::service("Example Org") + ), + ( + Text::message("usage-coderabbit-user"), + Text::service("example-user") + ), + ( + Text::message("usage-coderabbit-usage-billing"), + Text::service("inactive") + ), + ( + Text::message("usage-coderabbit-period-resets"), + Text::service("2026-09-30") + ), ] ); } diff --git a/crates/herdr-usage/src/providers/codex.rs b/crates/herdr-usage/src/providers/codex.rs index e92f55c96..8d4d80f7f 100644 --- a/crates/herdr-usage/src/providers/codex.rs +++ b/crates/herdr-usage/src/providers/codex.rs @@ -74,27 +74,27 @@ pub(crate) fn parse(body: &str) -> Result { .next() .map(|window| { Section::Limit(Window { - kind: Kind::Named("Code review".into()), + kind: Kind::Named(Text::message("usage-codex-code-review")), ..window }) }); let resets = usage .rate_limit_reset_credits .map(|credits| Section::Facts { - title: "Limit reset credits".into(), + title: Text::message("usage-codex-reset-credits"), facts: vec![( - "Available".into(), - Text::from(credits.available_count.to_string()), + Text::message("usage-codex-available"), + Text::service(credits.available_count.to_string()), )], }); let credits = usage.credits.map(|credits| Section::Facts { - title: "Credits".into(), + title: Text::message("usage-codex-credits"), facts: vec![( - "Balance".into(), + Text::message("usage-codex-balance"), if credits.unlimited.unwrap_or(false) { - "Unlimited".into() + Text::message("usage-codex-unlimited") } else { - Text::from(credits.balance.unwrap_or_else(|| "0".into())) + Text::service(credits.balance.unwrap_or_else(|| "0".into())) }, )], }); @@ -102,12 +102,12 @@ pub(crate) fn parse(body: &str) -> Result { email: usage .email .filter(|email| !email.trim().is_empty()) - .map(Text::from), + .map(Text::service), plan: (usage .plan_type .map(|plan| title_case(&plan)) .filter(|plan| !plan.is_empty())) - .map(Text::from), + .map(Text::service), }; Ok(Report::new(Provider(&Codex), account, windows) .with_sections(review.into_iter().chain(resets).chain(credits))) diff --git a/crates/herdr-usage/src/providers/commandcode.rs b/crates/herdr-usage/src/providers/commandcode.rs index 78c4bb0cc..877c99b4e 100644 --- a/crates/herdr-usage/src/providers/commandcode.rs +++ b/crates/herdr-usage/src/providers/commandcode.rs @@ -146,13 +146,21 @@ pub(crate) fn parse(credits: &str, subscription: Option<&str>) -> Result windows.push(Window::new(Kind::Monthly, used, period_end, Some(MONTH))); } let usd = || Unit::Currency("USD".into()); - let left = Balance::new("Monthly credits left", remaining.max(0.), usd()); + let left = Balance::new( + Text::message("usage-commandcode-monthly-left"), + remaining.max(0.), + usd(), + ); let mut balances = vec![match total { Some(total) => left.out_of(total), None => left, }]; if purchased > 0. { - balances.push(Balance::new("Purchased credits", purchased, usd())); + balances.push(Balance::new( + Text::message("usage-commandcode-purchased"), + purchased, + usd(), + )); } let plan_name = match (plan, known) { (Some((_, name, _)), _) => Some((*name).to_owned()), @@ -167,7 +175,7 @@ pub(crate) fn parse(credits: &str, subscription: Option<&str>) -> Result }; let account = Account { email: None, - plan: plan_name.map(Text::from), + plan: plan_name.map(Text::service), }; Ok(Report::new(Provider(&Commandcode), account, windows).with_balances(balances)) } @@ -246,8 +254,13 @@ mod tests { assert_eq!( report.balances, vec![ - Balance::new("Monthly credits left", 8.5, usd.clone()).out_of(10.), - Balance::new("Purchased credits", 2., usd), + Balance::new( + Text::message("usage-commandcode-monthly-left"), + 8.5, + usd.clone() + ) + .out_of(10.), + Balance::new(Text::message("usage-commandcode-purchased"), 2., usd), ] ); } diff --git a/crates/herdr-usage/src/tests/labels.rs b/crates/herdr-usage/src/tests/labels.rs index e0e339685..981aeccf8 100644 --- a/crates/herdr-usage/src/tests/labels.rs +++ b/crates/herdr-usage/src/tests/labels.rs @@ -1,5 +1,6 @@ use super::*; use crate::model::{Balance, Severity, Unit, countdown, group}; +use crate::text::Text; #[test] fn labels_count_down_in_the_two_coarsest_units() { @@ -22,7 +23,7 @@ fn labels_count_down_in_the_two_coarsest_units() { session.label(now + Duration::from_secs(20_000)), "2% used 0m" ); - let named = Window::new(Kind::Named("Fable".into()), 0., Some(now), None); + let named = Window::new(Kind::Named(Text::service("Fable")), 0., Some(now), None); assert_eq!(named.label(now), "0% used Fable"); for (kind, suffix) in [ (Kind::Session, "5h"), @@ -57,16 +58,16 @@ fn pace_compares_use_with_an_even_spend() { #[test] fn balances_read_in_their_own_unit() { - let usd = Balance::new("Credits", 12.3, Unit::Currency("USD".into())); + let usd = Balance::new(Text::service("Credits"), 12.3, Unit::Currency("USD".into())); assert_eq!(usd.text(), "$12.30"); assert_eq!(usd.clone().out_of(50.).text(), "$12.30 of $50.00"); assert_eq!( - Balance::new("Left", 4.5, Unit::Currency("EUR".into())).text(), + Balance::new(Text::service("Left"), 4.5, Unit::Currency("EUR".into())).text(), "4.50 EUR" ); assert_eq!( Balance::new( - "Points", + Text::service("Points"), 1_250_000., Unit::Count(crate::model::Count::Points) ) diff --git a/crates/herdr-usage/src/tests/provider_data.rs b/crates/herdr-usage/src/tests/provider_data.rs index e8b6ba51e..abcc2053e 100644 --- a/crates/herdr-usage/src/tests/provider_data.rs +++ b/crates/herdr-usage/src/tests/provider_data.rs @@ -44,27 +44,35 @@ fn claude_reads_every_window_account_and_detail() { [ (Kind::Session, 3, Some(SESSION)), (Kind::Weekly, 15, Some(WEEK)), - (Kind::Named("Fable".into()), 0, Some(WEEK)), + (Kind::Named(Text::service("Fable")), 0, Some(WEEK)), ] ); assert_eq!(report.windows[0].resets_at, Some(at(1_790_324_400))); assert_eq!( report.account, Account { - email: Some("me@example.com".into()), - plan: Some("Max 20x".into()), + email: Some(Text::service("me@example.com")), + plan: Some(Text::service("Max 20x")), } ); assert_eq!( report.sections, [ Section::Shares { - title: "This week by surface".into(), - shares: vec![("Claude Code".into(), 90.), ("Chats".into(), 10.)], + title: Text::message("usage-claude-week-by-surface"), + shares: vec![ + (Text::service("Claude Code"), 90.), + (Text::service("Chats"), 10.) + ], }, Section::Facts { - title: "Extra usage".into(), - facts: vec![("This month".into(), "12.34 EUR of 50.00 EUR".into())], + title: Text::message("usage-claude-extra-usage"), + facts: vec![( + Text::message("usage-claude-this-month"), + Text::message("usage-claude-spent-of") + .arg("used", "12.34 EUR") + .arg("limit", "50.00 EUR") + )], }, ] ); diff --git a/crates/herdr-usage/src/text.rs b/crates/herdr-usage/src/text.rs index d3879e180..d130e0583 100644 --- a/crates/herdr-usage/src/text.rs +++ b/crates/herdr-usage/src/text.rs @@ -109,45 +109,6 @@ impl From<&str> for Arg { } } -// Providers not yet moved to messages still give English literals and -// `String`s, which these keep showing as they are. They go once every -// provider names its words, so a literal no longer compiles. -impl From<&str> for Text { - fn from(text: &str) -> Self { - Self::service(text) - } -} - -impl From for Text { - fn from(text: String) -> Self { - Self::Service(text) - } -} - -/// A fact as words, for providers that still build theirs from strings. -pub(crate) fn pair, B: Into>((label, value): (A, B)) -> (Text, Text) { - (label.into(), value.into()) -} - -/// Facts as words, for providers that still build theirs from strings. -pub(crate) fn pairs, B: Into>( - facts: impl IntoIterator, -) -> Vec<(Text, Text)> { - facts.into_iter().map(pair).collect() -} - -/// A share as words, for providers that still build theirs from strings. -pub(crate) fn share>((label, share): (A, f32)) -> (Text, f32) { - (label.into(), share) -} - -/// Shares as words, for providers that still build theirs from strings. -pub(crate) fn shares>( - shares: impl IntoIterator, -) -> Vec<(Text, f32)> { - shares.into_iter().map(share).collect() -} - /// Tests and logs compare words with the service's text or a message's ID. impl PartialEq<&str> for Text { fn eq(&self, other: &&str) -> bool { diff --git a/docs/i18n.md b/docs/i18n.md index ec7cd738e..de9ef71a0 100644 --- a/docs/i18n.md +++ b/docs/i18n.md @@ -266,7 +266,11 @@ and paired phones' line other crates gave the app, errors from every crate below the app, which Settings shows through `$error`, browser tabs (`browser/`): pages, page notes, VS Code, and px0, and chats (`chat/`), whose durations and turn summaries `chat::text` writes from -`herdr-chat-protocol`'s typed values. Most of the +`herdr-chat-protocol`'s typed values, and plan usage's reports: every +provider names its words as `herdr_usage::Text` (a message ID with its +arguments, in its `usage-.ftl`, or the service's own words), +which `usage::text::words` writes, with count units a typed `Count`. Most +of the app still shows English literals: - Dialogs and popover menus in `menu/` (about 700 literals): the worktree @@ -295,17 +299,8 @@ app still shows English literals: still show their English `Display`. A mod's own load and runtime diagnostics (`herdr_mods::Error`) are for its author and stay as Luau and the mod host write them. -- Text `herdr-usage` writes for a service. A report's words are - `herdr_usage::Text`: a message ID with its arguments, or the service's - own words, which `usage::text::words` writes out; count units are a - `Count` the app writes with a plural, and the typed values (limit kinds, - countdowns, pace, balances) are localized in `usage/text.rs`. Wayfinder - and the providers from Copilot to Zoommate in `providers/` name all of their - words (`usage-.ftl`, with shared ones in `usage-report.ftl`); - the other providers still pass English section - titles, fact labels and values, balance labels, window names, and account - lines through `Text`'s transitional `From<&str>`, which goes once the last - of them names its words. +- Plan usage dates a provider writes with `chrono`'s English month names + (Nous's renewal, "Oct 12") should be formatted by the app. - The remaining hand-built relative times (the orchestrator's, Security's, and code search's) should use `ago`, and the remaining hand-built plurals Fluent selectors. From 9d3bb872aac5f25f67be7b94f8b50547214cdee3 Mon Sep 17 00:00:00 2001 From: Fabien Penso Date: Sun, 1 Nov 2026 09:49:20 -0800 Subject: [PATCH 08/10] fix(i18n): name usage windows in English in the usage command Named windows became message IDs in this branch, and the usage command labeled each window with `Kind::title`, which gives a named window's ID: it printed usage-copilot-premium instead of Premium, and Doubao's window lost its plan and period, which only the message's arguments carry. The command now writes a named window through `usage::text::english`, and a test checks the labels and the JSON the command answers. --- crates/herdr-gpui/src/control/server/hosts.rs | 2 +- .../src/control/server/hosts/tests.rs | 1 + .../src/control/server/hosts/tests/usage.rs | 71 +++++++++++++++++++ crates/herdr-gpui/src/usage/text.rs | 10 +++ 4 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 crates/herdr-gpui/src/control/server/hosts/tests/usage.rs diff --git a/crates/herdr-gpui/src/control/server/hosts.rs b/crates/herdr-gpui/src/control/server/hosts.rs index 34492e4d2..0bb23f64c 100644 --- a/crates/herdr-gpui/src/control/server/hosts.rs +++ b/crates/herdr-gpui/src/control/server/hosts.rs @@ -239,7 +239,7 @@ pub(super) fn usage_info(reading: &crate::usage::Reading, now: SystemTime) -> Us .windows .iter() .map(|window| UsageWindow { - label: window.kind.title().to_owned(), + label: crate::usage::text::english_title(&window.kind), used_percent: window.used, resets_in_seconds: window.resets_in(now).map(|left| left.as_secs()), }) diff --git a/crates/herdr-gpui/src/control/server/hosts/tests.rs b/crates/herdr-gpui/src/control/server/hosts/tests.rs index 4035d8ad3..fbfa9a047 100644 --- a/crates/herdr-gpui/src/control/server/hosts/tests.rs +++ b/crates/herdr-gpui/src/control/server/hosts/tests.rs @@ -12,6 +12,7 @@ mod pr; mod security; mod security_open; mod strict; +mod usage; mod wsl_forwards; fn caller(workspace: Option<&str>) -> Caller { diff --git a/crates/herdr-gpui/src/control/server/hosts/tests/usage.rs b/crates/herdr-gpui/src/control/server/hosts/tests/usage.rs new file mode 100644 index 000000000..d65ed1540 --- /dev/null +++ b/crates/herdr-gpui/src/control/server/hosts/tests/usage.rs @@ -0,0 +1,71 @@ +use super::*; +use crate::usage::{ + Reading, + model::{Account, Kind, Report, Window}, + registry, +}; +use herdr_usage::Text; +use std::time::Duration; + +fn reading(id: &str, windows: Vec) -> Reading { + let provider = registry::find(id).unwrap(); + Reading { + provider, + report: Some(Report::new(provider, Account::default(), windows)), + error: None, + access: None, + } +} + +/// `herdr-gpui usage` answers each window's English title, which it prints +/// and `--json` sends as is: a named window's words with their arguments, +/// never the message ID the provider names. +#[test] +fn the_usage_command_prints_named_windows_in_english() { + let now = SystemTime::now(); + let resets = Some(now + Duration::from_secs(3600)); + let copilot = reading( + "copilot", + vec![Window::new( + Kind::Named(Text::message("usage-copilot-premium")), + 40., + resets, + None, + )], + ); + let doubao = reading( + "doubao", + vec![ + Window::new(Kind::Weekly, 10., None, None), + Window::new( + Kind::Named( + Text::message("usage-doubao-window") + .arg("plan", "agent") + .arg("period", "weekly"), + ), + 28.7, + None, + None, + ), + ], + ); + let usage = [copilot, doubao] + .iter() + .map(|reading| usage_info(reading, now)) + .collect::>(); + let labels: Vec<_> = usage + .iter() + .flat_map(|info| info.windows.iter().map(|window| window.label.as_str())) + .collect(); + assert_eq!(labels, ["Premium", "Weekly", "Agent weekly"]); + let json = serde_json::to_string(&Response::Host { + reply: Reply::Usage { + host: "Local".into(), + usage, + note: None, + }, + }) + .unwrap(); + assert!(json.contains(r#""label":"Agent weekly""#), "{json}"); + assert!(!json.contains("usage-"), "{json}"); +} diff --git a/crates/herdr-gpui/src/usage/text.rs b/crates/herdr-gpui/src/usage/text.rs index ebeffc2f8..4edf3ed09 100644 --- a/crates/herdr-gpui/src/usage/text.rs +++ b/crates/herdr-gpui/src/usage/text.rs @@ -39,6 +39,16 @@ fn write(text: &Text, message: fn(&str, &[(&str, FluentValue<'_>)]) -> String) - } } +/// A window's title in English, for command line output: the fixed kinds +/// as `Kind::title` names them, and a named window's words, with any +/// arguments, rather than its message ID. +pub(crate) fn english_title(kind: &Kind) -> String { + match kind { + Kind::Named(name) => english(name), + kind => kind.title(), + } +} + /// The selector value usage.ftl's messages pick a kind by; a named window /// takes `$name` instead. fn kind(kind: &Kind) -> (&'static str, String) { From 64919080b698f7183f876109048331a3325e7ee4 Mon Sep 17 00:00:00 2001 From: Fabien Penso Date: Sun, 1 Nov 2026 09:51:29 -0800 Subject: [PATCH 09/10] refactor(i18n): borrow Z.ai's model names to sort their token totals Z.ai's token totals sorted ties by cloned model names; the sort key now borrows them, with an unnamed model still sorting as Unknown did. Synthetic's search lane runs an hour because it is the dedicated lane, which now holds a message, so a generic quota the service names "Search hourly" is the service's own name and keeps no length. A test pins that. --- crates/herdr-usage/src/providers/synthetic.rs | 11 +++++++++++ crates/herdr-usage/src/providers/zai.rs | 6 ++++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/crates/herdr-usage/src/providers/synthetic.rs b/crates/herdr-usage/src/providers/synthetic.rs index 9d66f7caa..ca8eb909b 100644 --- a/crates/herdr-usage/src/providers/synthetic.rs +++ b/crates/herdr-usage/src/providers/synthetic.rs @@ -515,6 +515,17 @@ mod tests { assert_eq!(report.windows[1].percent(), 50); } + /// Only the dedicated search lane runs an hour: a generic quota the + /// service happens to name the same way is the service's own name. + #[test] + fn a_generic_quota_named_search_hourly_keeps_no_length() { + let report = + parse(r#"{"data":{"quotas":[{"name":"Search hourly","usedPercent":0.2}]}}"#).unwrap(); + let window = &report.windows[0]; + assert_eq!(window.kind, Kind::Named(Text::service("Search hourly"))); + assert_eq!(window.length, None); + } + #[test] fn a_response_without_quotas_is_an_error() { assert!(matches!( diff --git a/crates/herdr-usage/src/providers/zai.rs b/crates/herdr-usage/src/providers/zai.rs index 350e27f95..c4e83fdff 100644 --- a/crates/herdr-usage/src/providers/zai.rs +++ b/crates/herdr-usage/src/providers/zai.rs @@ -411,8 +411,10 @@ pub(crate) fn parse_tokens(body: &str) -> Option
{ return None; } // A model without a name sorts where its English name did. - let name = |model: &Option| model.clone().unwrap_or_else(|| "Unknown".into()); - totals.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| name(&a.0).cmp(&name(&b.0)))); + fn name(model: &Option) -> &str { + model.as_deref().unwrap_or("Unknown") + } + totals.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| name(&a.0).cmp(name(&b.0)))); Some(Section::Facts { title: Text::message("usage-zai-tokens-last-30-days"), facts: totals From d4cc253c9a4ff249f50c6029d598cf4d309c1b77 Mon Sep 17 00:00:00 2001 From: Fabien Penso Date: Sun, 1 Nov 2026 11:02:26 -0800 Subject: [PATCH 10/10] chore(i18n): keep main's remaining-work list in the i18n guide Every open i18n pull request edits the remaining list in docs/i18n.md, so each merge made the others conflict. This branch now keeps main's copy; one change updates the list once they have all merged. --- docs/i18n.md | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/docs/i18n.md b/docs/i18n.md index 1e8bccc9e..d5616bfaa 100644 --- a/docs/i18n.md +++ b/docs/i18n.md @@ -95,10 +95,6 @@ Crates below the app have no messages, so they return typed values and the app writes them out: seconds elapsed for `ago` ("3 min ago", "yesterday"), a cloud job's `Step` for `cloud::status`, and an Azure size's `VmSpec`, `SandboxSize`, `Price`, or `DeviceSize` for Settings. -A crate may also name a message by ID, as `herdr-commands` does for each -command and `herdr-usage` for each provider setting's help -(`usage-.ftl`); the app shows it with `i18n::message`, and a test -checks every ID the crate names with `i18n::takes`. ### Errors @@ -305,7 +301,7 @@ app still shows English literals: author and stay as Luau and the mod host write them. - Text `herdr-usage` writes for a service: the titles and labels of a report's facts and shares, balance labels and count units ("credits"), - and windows it names itself. The app + windows it names itself, and each provider setting's help. The app localizes the typed values (limit kinds, countdowns, pace, balances) itself, in `usage/text.rs`. - The remaining hand-built relative times (the orchestrator's, Security's,