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* read the usage 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.
Select a person or team to switch between Models, Jobs and Tasks without losing that selection. Teams also have a Members view. Opening a job or task shows its usage by model, attributed to that job or task. Jobs show their current status, even when viewing older usage; jobs without recorded model usage remain available on the Jobs page. Running only filters the job table to currently running jobs while keeping the cost order. Status checks cover only jobs in the usage results, across all pages. The main grouping tabs remain available throughout drilldowns.
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.
Historic cost estimates use Middleman's current reference prices, cached in the browser for six hours. Estimates include only usage with known counts and prices; a dash means no estimate is available. Model lists and user, team, job and task totals sort by estimated cost, using the lower end of ranges and putting unavailable estimates last. Shares across models use the displayed cost estimates; shares within one model use reported tokens. Anthropic cache writes use separate five-minute and one-hour rates. Older writes without a recorded duration show a range when both rates are available; otherwise their cost is unavailable. Historical prices, negotiated rates and service tiers can differ.
Model totals include available cache costs. Metric-based user/team estimates
have no per-label cache split, so they can overstate or understate cost.
Team member views use the same ordinary user token totals and cost estimates,
filtered to the team's observed members. Jobs and tasks use the cache counts
recorded for each label.
Some traffic through Middleman's former unified API (/completions, since removed)
has missing token/cache counts, so estimates covering it can be incomplete or
unavailable.
For native OpenAI models without a cache-write price, an absent write count is treated as zero. Models with a write price require a recorded write count, including an explicit zero; missing counts remain unpriced. This also applies to history.
Endpoints¶
These endpoints 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 |
GET /usage/prices |
Current prices in USD per million tokens for permitted models; confidential details and unavailable prices stay null |
Explicit zero prices now remain zero in Middleman's /model_info and
/permitted_models_info responses. Zero means a known free rate; null means
the price is unavailable.
Parameters¶
| Parameter | Endpoints | Meaning |
|---|---|---|
group_by |
/usage, /usage/history |
user (default), team, job or task |
team |
/usage/history |
Exact recorded team label; scopes members (group_by=user) or jobs/tasks |
user |
/usage/history |
Exact user identity; scopes group_by=job or task |
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. Traffic-log labels with only cache usage are retained for pricing.
Nullable input_tokens, output_tokens, uncached_input_tokens,
cache_read_tokens, cache_write_tokens, cache_write_5m_tokens and
cache_write_1h_tokens expose available cost inputs;
null means unknown, while zero means a reported zero. History bins also expose
model-wide input/output and cache counts independently of capped label lists.
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 API (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 the same provider token accounting as the
team overview; log coverage and recorded membership can still differ.
The API returns recorded per-member cache counts and the applied team_filter.
The usage page uses this response to identify members, then shows their ordinary
user metrics for the selected window, including usage outside that recorded team.
Its member cost estimates have no per-user cache split. Requests, 429s and optional
cache charts remain model-wide.
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 an unscoped 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. Scoped requests also run batched job permission checks, retrying capped batches with the same split limit.
Tokens for OpenAI-routed models are input plus output, including cached input,
in every grouping, including team members, jobs and tasks. Anthropic totals use
uncached input plus output in every grouping; other traffic-log provider routes also
exclude cache reads and writes. Cache reads and writes remain separate
model-level counters (cache_read, cache_write). Traffic-log cost estimates
derive uncached input from the recorded input and cache counts so cached input
is priced once.
CLI and Python client¶
hawk usage prints your own usage per provider/model (it filters the user
grouping to your subject and, where the server resolves one from your token, your
email — the two labels middleman keys the dimension on), 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.