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 {