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 (MIT), whose
docs/<id>.md and Sources/CodexBarCore/Providers/<Name>/ 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 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.
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
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.
pub trait Service: Sync {
fn meta(&self) -> &'static Meta;
fn fetch(&self, probe: &mut Probe) -> Option<Result<Report>>;
}What a provider is lives in one static:
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<Result<Report>> {
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/<id>.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.
A provider declares every config value it reads. Users set them in
config-gpui.local.toml:
[usage.providers.openrouter]
api_key = "sk-or-…".settings(&[Setting::new(
"api_key",
&["OPENROUTER_API_KEY"],
"An API key from https://openrouter.ai/settings/keys.",
)])nameis the key in the table. Useapi_key,cookie,base_url,token, and for anything else a short snake_case name.envlists 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.helptells the user what the value is and exactly how to get it. For acookie: 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 fullname=value; name2=value2header. 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 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<Secret> |
A declared setting from config or its env vars (this machine). |
text_setting(name) |
Option<String> |
A non-secret setting such as base_url. |
env(name) |
Option<Secret> |
An environment variable on the probed host. |
file(&HostPath) |
Option<Secret> |
A whole file on the host, e.g. a credentials JSON; trailing newlines dropped. |
read(&HostPath) |
Option<String> |
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<String> |
A non-secret JSON field of a file (an email) without bringing the file back. |
keychain(service, account) |
Option<Secret> |
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<Secret> |
A string/number at a JSON path inside a secret (array steps are indices as strings). |
text(&secret, &[key]) |
Option<String> |
A non-secret JSON field revealed, e.g. a plan name. |
cookies(&[domain], &[name]) |
Option<Secret> |
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<Secret> |
Like cookies, satisfied by whichever of the names exists (renamed session cookies). |
cookie_value(&[domain], name) |
Option<Secret> |
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<Secret> |
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<String> |
A host environment variable that is not secret, e.g. a local server URL. |
body(Request) |
Result<String> |
The body of a successful answer; failures become typed usage errors. Most providers need only this. |
http(Request) |
Result<Response> |
The whole answer, for a provider that reads the status itself; runs where its secrets are. |
exchange(Request, &[key]) |
Result<Secret> |
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<Output> |
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::<T>()? 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::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 appWindow::new(kind, used_percent, resets_at, length).KindisSession(5 h),Daily,Weekly,Monthly, orNamed(String)for a window the service names itself.lengthiskind.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 asN% used <time to reset>, or, with[status_bar] usage = "compact", only the tightest share; the panel lists every window.Balanceis money or credits:Unit::Currency("USD")orUnit::Count("credits"), without_of(total)when there is a limit. A provider with no windows shows its first balance in the status bar.- Prefer windows and balances; use sections for anything else worth seeing.
Provider(&MyService)builds the report's provider from the unit struct.
The panel draws windows, balances, and sections for every provider. A
provider with richer data stores it with with_detail, makes its type pub
and re-exports it from herdr_usage::details, and the app's
crates/herdr-gpui/src/usage/details.rs draws it, reading it back with
report.detail::<MyDetail>() and composing Ui pieces:
ui.limit(&window), ui.balances(&[..]), ui.facts(title, &facts),
ui.shares(title, &shares), ui.bar(fill, used, None), ui.block(),
ui.heading(..), ui.rule(), with ui.theme, ui.small(), ui.muted().
Every provider module ends with #[cfg(test)] mod tests that parses a
fixture response shaped like the real one (take it from CodexBar's tests
or docs) and checks the windows, balances, and account it produces. Keep
parsing in a pure fn parse(body: &str, ..) -> Result<Report> so it can be
tested without a network.
Sources that need more than the probe offers are left out and noted in the module doc: reading a browser's localStorage or IndexedDB, local SQLite databases, WebView sessions, gRPC-web, interactive logins, and signing requests (AWS SigV4, Google service accounts).