Token Usage¶
Hawk exposes the token usage that flows through Middleman
per provider and model, split by user, team, job (eval set or scan) or
task (Inspect task name). The web viewer's usage page, the
hawk usage command and HawkClient.get_usage* all read the same three endpoints
described here. Every view only covers models the caller is allowed to use.
Usage page¶
/usage opens in Live. The URL saves the range, filters and grouping, so
views can be bookmarked or shared.
Show cache usage is off by default. It adds separate cache-read and cache-write charts without changing reported-token totals. These counts are model-wide, so selecting a user or team does not filter them.
Endpoints¶
All three live under the API's /usage prefix and need a bearer token.
| Endpoint | What it returns |
|---|---|
GET /usage/limits |
Middleman's current rate-limit snapshots (limit and remaining, per provider/model) |
GET /usage |
Token totals over the live window (last ~5 minutes), per model, split by group_by |
GET /usage/history |
Token totals per bin over [start, end], per model, split by group_by, plus model-level requests, 429s and cache tokens |
Parameters¶
| Parameter | Endpoints | Meaning |
|---|---|---|
group_by |
/usage, /usage/history |
user (default), team, job or task |
team |
/usage/history |
Exact recorded team label; requires group_by=user |
start, end |
/usage/history |
Unix seconds, end > start |
bin_seconds |
/usage/history |
Bin width, default 300; must be a positive multiple of 60 |
/usage/history rejects a range of more than 1000 bins with 400, so widen
bin_seconds for long ranges (e.g. 3600 for a week, 86400 for a year).
Response shapes¶
GET /usage returns one entry per model:
[
{
"provider": "anthropic",
"model": "claude-sonnet-4-5",
"users": [{"user": "auth0|abc", "tokens": 120345}],
"teams": [],
"jobs": [],
"tasks": []
}
]
Exactly one of users, teams, jobs, tasks is filled, matching group_by;
the others are empty lists. Each entry is {"<label field>": ..., "tokens": N}
where the label field is user, team, job or task, sorted by tokens
descending, zero-token labels dropped.
GET /usage/history returns one entry per model with a tokens_limit (Middleman's
per-minute token limit for the model, 0 when it has none) and a bins list:
[
{
"provider": "anthropic",
"model": "claude-sonnet-4-5",
"tokens_limit": 2000000,
"bins": [
{
"start": 1758153600,
"users": [],
"teams": [],
"jobs": [{"job": "hawk-8f3a…", "tokens": 91020}, {"job": "direct", "tokens": 1200}],
"tasks": [],
"requests": 41,
"rate_limited": 0,
"cache_read": 350000,
"cache_write": 12000
}
]
}
]
Bins are [start, start + bin_seconds); the list always covers the whole range,
so a quiet bin has empty label lists and zero counters. Models with no activity at
all in the range are omitted. requests, rate_limited, cache_read and
cache_write are model-level totals in every grouping (they are not attributed
to a label).
Label semantics¶
| Label | Grouping | Meaning |
|---|---|---|
unassigned |
team | The caller's token carried no team claim |
a+b |
team | The caller is in several teams; sorted and +-joined, counted once |
direct |
job, task | Traffic with no x-hawk-job-id header: coding agents, notebooks, scripts, anything not launched by Hawk |
scan |
task | A scan or scan-resume job (scans have no Inspect task) |
unknown |
task | A job that sent no task name, e.g. runners predating the x-inspect-task-name header |
other |
job, task | Everything past the 50 highest-token labels for that model, plus any job that also used a model you may not see, summed |
Job labels are the eval set id or scan id, the same value shown as the job id in
the viewer and by hawk eval-set / hawk scan. Task labels are the Inspect task
name (Task.name, e.g. gaia). A job that used any model outside your model
groups is not named: its tokens on the models you can see are counted under
other, the same rule that hides the run itself.
Where the numbers come from¶
user and team read the CloudWatch metrics Middleman emits per request
(Middleman namespace, InputTokens + OutputTokens on the
[provider, model, user] and [provider, model, team] dimension sets). The
models offered in /usage/history are discovered from CloudWatch, which only
lists metrics active in the last ~2 weeks, so a model idle for longer drops out
of the history view even if the metric data itself is retained.
Team member history (group_by=user&team=…) uses traffic logs with the same
31-day limit and top-50/other behavior as job/task history. It matches exact
recorded membership: a+b differs from a; unassigned means an explicitly
empty team list. Member totals use uncached input plus output and can differ
from the team overview. Requests and cache counts remain model-wide.
Each returned model includes the applied team_filter.
job and task read Middleman's traffic log in CloudWatch Logs with a Logs
Insights query. Job ids are far too high-cardinality for a metric dimension (a
fortnight of production traffic is thousands of jobs), so the per-request log is
the only source. Consequences:
- Token counts in the traffic log exist only for requests since 2026-09-18; earlier bins are empty in these groupings.
- History is bounded by the traffic log group's retention (90 days by default), not by CloudWatch metric retention.
- The log must be enabled (
hawk:middlemanTrafficLogLevelofsummaryorfull, the default issummary) and the API must be configured with its log group (HAWK_API_MIDDLEMAN_TRAFFIC_LOG_GROUP, set automatically by the Pulumi stack when the log is on). Without it,group_by=jobandgroup_by=taskreturn[]. - Logs Insights returns at most 10,000 rows per query, so a history request
runs three queries over all models: one ranking labels by tokens over the range
(used to keep the top labels per model, as many as fit in one query), one
binning those labels, and one binning everything else per model as
other. A top-label result that hits the row cap is split by label and re-queried, up to three levels deep, so a request issues between 3 and 17 queries. Each returns its rows ordered by tokens descending, so only the smallest cells can ever be cut. The range is widened to whole bins and capped at 31 days for these groupings. Results are cached in Valkey for 60 s (15 min for ranges ending more than an hour ago); the live window is aligned to the minute so concurrent viewers share one query.
Tokens per label are input plus output as the provider reports them for
user/team, and uncached input plus output for job/task. Cache reads and
writes are separate model-level counters (cache_read, cache_write). For
Anthropic, whose input_tokens excludes cached tokens, every grouping agrees;
for OpenAI prompt_tokens includes cached tokens, so user/team run higher
than job/task on cache-heavy traffic.
CLI and Python client¶
hawk usage prints your own usage per provider/model (it filters the user
grouping to your subject), for the live window or a lookback:
hawk usage # summed over roughly the last 24 hours
hawk usage --hours 168 # roughly the last 7 days
hawk usage --live # the recent ~5 minute window
hawk usage --json # machine-readable {"window": ..., "rows": [...]}
The lookback is rounded outward to whole bins, so a request for N hours is
answered over up to N+1 — most visibly at --hours 1, which covers two clock
hours. Both the table heading and the window object in --json report the
range actually summed, with start and end as Unix seconds.
The Python client returns the raw endpoint JSON for any grouping:
import time
import hawk.client
async with hawk.client.HawkClient(token=token) as client:
live = await client.get_usage(group_by="job")
now = int(time.time())
week = await client.get_usage_history(
now - 7 * 86400, now, bin_seconds=3600, group_by="task"
)
group_by is only sent when it differs from user, so the default call works
against API servers that predate the newer groupings; asking such a server for
team, job or task fails with 422.
Recipes¶
Who is using all the tokens right now? GET /usage (default group_by=user)
and read the top of each model's users. For a longer view, /usage/history
with bin_seconds=3600 over the last day and sum each user across bins.
Which runs are burning through a model? GET /usage/history?group_by=job
over the period of interest. Each job label is an eval set or scan id you can
open in the viewer or inspect with hawk logs <id>; a fat direct
slice means the tokens are not coming from Hawk jobs at all.
Which teams? group_by=team. unassigned is users with no team claim in
their token; a+b labels are users in several teams. Team attribution starts
from the deploy that enabled it (see
Usage attribution by team).
Was a key leaked? Look for a single user whose usage jumps well above their
history (group_by=user, hourly bins over the last few days), then check
group_by=job for the same window: legitimate eval traffic shows up under job
ids, while a leaked or scripted key shows as direct. direct traffic under a
service account at unusual hours, or against models that account never used
before, is the pattern to escalate.
What is a run's task mix? group_by=task on the run's time range. unknown
is a job whose runner predates the task-name header; scan is Scout scan
traffic.