# Usage providers The status bar shows as many signed-in AI services as fit on the selected host, and clicking one opens its panel. The service selected in the panel comes first, followed by those closest to their limits. Narrowing the window hides segments that no longer fit; the panel still lists every available service. Compact mode leaves room for more services. Each service is a *provider*: one module in `crates/herdr-usage/src/providers/` with a unit struct implementing `Service`, listed once in `crates/herdr-usage/src/registry.rs`. Providers are ported from [CodexBar](https://github.com/steipete/CodexBar) (MIT), whose `docs/.md` and `Sources/CodexBarCore/Providers//` document each service's sign-in, endpoints, and response shapes. Detection, local and remote fetching, caching, refresh timing, the status bar, and the panel all work from the trait. A provider only says how to find its sign-in, how to ask its service, and how to read the answer. `herdr-usage` has no GPUI; the app draws the status bar and panel in `crates/herdr-gpui/src/usage/`. ## ChatGPT usage through OpenCode ChatGPT subscriptions used through OpenCode appear as **Codex** in the status bar. Detection first checks `$CODEX_HOME/auth.json` (`~/.codex/auth.json` by default). When that file has no OAuth access token, it checks the `openai` OAuth entry in `$XDG_DATA_HOME/opencode/auth.json` (`~/.local/share/opencode/auth.json` by default). No additional configuration is needed when Usage is enabled in Settings > Status bar. Use OpenCode's ChatGPT browser or device login; an OpenAI API key does not provide ChatGPT subscription limits. OpenCode's own Zen/Go billing is tracked by the separate OpenCode providers. Detection follows the selected host, including SSH hosts and, on Windows, WSL distributions. Each usage refresh reads the current saved token; OpenCode handles renewing its sign-in. If both Codex and OpenCode have OAuth credentials, Codex takes precedence, including when its usage request fails, so a failure does not switch the shown account. SSH hosts and WSL distributions need `python3` or `jq` to read the JSON sign-in; detection is skipped when neither parser is available. ## Grok prepaid balance Legacy and unified Grok accounts use the same CLI billing endpoint. Herdr reads the quota and wallet independently, including when the server omits the `isUnifiedBillingUser` flag; account migration is not required. Grok's CLI billing response can report a prepaid balance without an included usage percentage. Herdr shows that balance in USD (the [Grok billing contract](https://github.com/xai-org/grok-build/blob/main/crates/codegen/xai-grok-shell/src/extensions/billing.rs) sends USD cents), including zero balances, and the reported period end in the usage panel. Included usage stays **Not reported** until the API supplies a percentage or the existing on-demand usage ratio; the wallet does not imply a quota or a percentage used. The period end does not imply that purchased credits expire. When both quota and balance are available, the panel shows both and the status bar continues to show quota usage. Sign in with `grok login` on the selected host; no additional credentials are needed. ## The trait ```rust pub trait Service: Sync { fn meta(&self) -> &'static Meta; fn fetch(&self, probe: &mut Probe) -> Option>; } ``` What a provider is lives in one `static`: ```rust static META: Meta = Meta::new("openrouter", "OpenRouter") .dashboard("https://openrouter.ai/settings/credits") .status_page("https://status.openrouter.ai") .settings(&[Setting::new("api_key", &["OPENROUTER_API_KEY"], "…")]); impl Service for Openrouter { fn meta(&self) -> &'static Meta { &META } fn fetch(&self, probe: &mut Probe) -> Option> { let key = probe.setting("api_key")?; Some(probe.body(Request::get(URL).bearer(&key)).and_then(|body| parse(&body))) } } ``` `id` is lowercase ASCII and stable (config tables use it). The icon is `assets/icons/providers/.svg` when that exists; `.icon(path)` sets another embedded SVG. `.dashboard(url)` and `.status_page(url)` must be HTTPS. `fetch` returns `None` when the probed host has no sign-in and the config sets none: the provider is then left out, unless the user lists it in `[usage] show_providers`, in which case the panel shows its settings as a setup guide. It returns `Some(Err(..))` when a sign-in exists but reading failed, and `Some(Ok(report))` otherwise. Never block without a bound: every probe call has a timeout. ## Settings A provider declares every config value it reads. Users set them in `config-gpui.local.toml`: ```toml [usage.providers.openrouter] api_key = "sk-or-…" ``` ```rust .settings(&[Setting::new( "api_key", &["OPENROUTER_API_KEY"], "An API key from https://openrouter.ai/settings/keys.", )]) ``` - `name` is the key in the table. Use `api_key`, `cookie`, `base_url`, `token`, and for anything else a short snake_case name. - `env` lists environment variables read when the config has no value, in CodexBar's names. Apps launched from the Dock see few variables, so the config is the dependable place. - `help` tells the user what the value is and exactly how to get it. For a `cookie`: which site to sign in to, that it is under Developer Tools → Application → Cookies, which cookie names to copy, and that the value is the full `name=value; name2=value2` header. The panel shows this text when the provider is listed but not signed in. - Unknown provider ids and setting names in the config are ignored and named in the GUI config warning, so every name a provider reads must be declared. ## Probe `Probe` is the only way a provider touches the host, the config, or the network. The same calls work locally and on a remote host, where each runs in one SSH shell session. A `Secret` read on a remote host stays there as a shell variable; requests using it run there with `curl`. Never turn a secret into a `String` except through `text` for values that are not secret (emails, plan names, ids shown to the user). Remote JSON field extraction requires `python3` or `jq` on the host. Without either, field reads return no value rather than guessing by key name, which could select another account's credentials from a shared file. | Call | Returns | Use | | --- | --- | --- | | `setting(name)` | `Option` | A declared setting from config or its env vars (this machine). | | `text_setting(name)` | `Option` | A non-secret setting such as `base_url`. | | `env(name)` | `Option` | An environment variable on the probed host. | | `file(&HostPath)` | `Option` | A whole file on the host, e.g. a credentials JSON; trailing newlines dropped. | | `read(&HostPath)` | `Option` | A non-secret file brought back, e.g. an XML quota report. | | `exists(&HostPath)` | `bool` | Whether a path exists on the host. | | `file_text(&HostPath, &[key])` | `Option` | A non-secret JSON field of a file (an email) without bringing the file back. | | `keychain(service, account)` | `Option` | A macOS keychain password on the host (`security -w`), for items `security` itself may read without asking, such as Claude Code's. | | `field(&secret, &[key])` | `Option` | A string/number at a JSON path inside a secret (array steps are indices as strings). | | `text(&secret, &[key])` | `Option` | A non-secret JSON field revealed, e.g. a plan name. | | `cookies(&[domain], &[name])` | `Option` | The `cookie` setting, else (only for providers the user listed and allowed in the panel) those cookies from Chrome/Safari. Pass the cookie names the service needs; `&[]` takes all cookies of the domains. | | `cookies_any(&[domain], &[name])` | `Option` | Like `cookies`, satisfied by whichever of the names exists (renamed session cookies). | | `cookie_value(&[domain], name)` | `Option` | One cookie's bare value, for a service that wants it as a bearer or in a header. | | `foreign_keychain(service, account)`, `foreign_keychain_internet(server, account)` | `Option` | A keychain item another app owns, such as Zed's sign-in or `muse login`'s. Reading it makes macOS ask, so it is read only for a listed provider after the user presses Allow in its panel; until then the provider shows a lock in the status bar. A denial is remembered and takes the grant back. | | `env_text(name)` | `Option` | A host environment variable that is not secret, e.g. a local server URL. | | `body(Request)` | `Result` | The body of a successful answer; failures become typed usage errors. Most providers need only this. | | `http(Request)` | `Result` | The whole answer, for a provider that reads the status itself; runs where its secrets are. | | `exchange(Request, &[key])` | `Result` | A request whose JSON answer holds a new credential (token refresh or exchange); the new credential stays where the request ran. | | `command(program, &[arg], timeout)` | `Result` | Runs a CLI on the host (`~/.local/bin`, Homebrew and similar are on PATH). Its stdout comes back, so only for commands that print no secrets. | | `is_remote()`, `is_macos()` | `bool` | Where the probe runs. | | `requested()` | `bool` | Whether the provider is listed in `show_providers` rather than only detected. A detected provider must not do anything the user would notice. | | `app_data()` | `bool` | Whether the probe may touch another app's own data on this Mac, such as its `~/Library/Application Support` folder, directly or through a command it runs. False for a detected provider on a local Mac, where macOS may ask whether Herdr can access data from other apps; check it before such a read. A listed provider needs no Allow for it, and remote hosts are not gated, since that dialog never reaches the user over SSH. | `HostPath::home(".codex/auth.json")` is `~/.codex/auth.json` on the host; `HostPath::env_or("CODEX_HOME", ".codex", "auth.json")` honors the variable; `HostPath::absolute("/etc/x")`. `Request::get(url)` / `Request::post(url)` with `.header(name, value)`, `.bearer(&secret)`, `.cookie(&secret)`, `.secret_header(name, prefix, &secret)`, `.json(body)` (a body without secrets), `.body(vec![Part::Text(..), Part::Secret(..)])` (a body that contains a secret, e.g. a form login), and `.timeout(duration)`. A request may not mix this machine's settings with secrets read on a remote host. `Response { status, body }`: `response.ok()?` maps 401/403 to `UsageRejected`, 429 to `UsageRateLimited`, other failures to typed errors, and returns the body; `response.json::()?` also parses it. Parse with serde structs; `service::json(body)` maps parse errors without echoing the body. `service::Timestamp` accepts seconds, milliseconds, numeric strings, and RFC 3339; `service::number` deserializes numbers sent as strings. Shared readings live in `usage::values`: `number` (a JSON number or numeric string), `decimal` (a strict money string), `https_base` (a configured service address, HTTPS only, before sending a key there), `gateway` (a self-hosted proxy address, HTTP only on private networks), `encode` (a URL component), `invalid()` (the error for a response that lacks what the service documents), and fact formats `usd`, `plain`, `count`, `trimmed`. Use them rather than writing another. Errors: use `crate::Error::Usage*` variants (`UsageRejected`, `UsageRateLimited`, `UsageStatus`, `UsageJson`, `UsageConnect`, `UsageCommand(&'static str)` for a CLI that failed). Never build an error from response text. ## Report ```rust Report::new(Provider(&MyService), Account { email, plan }, windows) .with_balances([Balance::new("Credits", 12.5, Unit::Currency("USD".into())).out_of(50.)]) .with_sections([Section::Facts { title, facts }, Section::Shares { .. }, Section::Limit(window)]) .with_detail(MyDetail { .. }) // provider-specific, drawn by the app ``` - `Window::new(kind, used_percent, resets_at, length)`. `Kind` is `Session` (5 h), `Daily`, `Weekly`, `Monthly`, or `Named(String)` for a window the service names itself. `length` is `kind.length()` for the standard kinds, or the service's own window length, so the panel can show the pace. The status bar shows the two windows closest to their limits as `N% used