Skip to content

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:middlemanTrafficLogLevel of summary or full, the default is summary) 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=job and group_by=task return [].
  • 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.