statusline: expose per-model weekly rate limits (Opus/Sonnet/Fable) to the status line command

Status Open
Reported on v2.1.199
Maintainer reply None cached
Activity 8 comments · opened Jul 3, 2026

Feature request

Expose the per-model weekly rate limits (Opus / Sonnet / Fable) to the status line command's stdin JSON, so custom status lines can display the same meters that /status shows.

Current behavior

The status line JSON only ever includes two entries under rate_limits:

"rate_limits": {
  "five_hour": { "used_percentage": ..., "resets_at": ... },
  "seven_day": { "used_percentage": ..., "resets_at": ... }
}

Internally, Claude Code already tracks more than this. The rate-limit object is populated from the anthropic-ratelimit-unified-* response headers into these keys:

  • five_hour
  • seven_day
  • seven_day_overage_included ← labeled "Fable 5 limit" in the UI
  • overage

and the UI additionally distinguishes seven_day_opus ("Opus limit") and seven_day_sonnet ("Sonnet limit").

But the function that assembles the status line payload spreads only five_hour and seven_day, dropping the model-specific weekly limits (observed in 2.1.199, minified, paraphrased):

rate_limits = {
  ...v.five_hour && { five_hour: { used_percentage: v.five_hour.utilization * 100, resets_at: v.five_hour.resets_at } },
  ...v.seven_day && { seven_day: { used_percentage: v.seven_day.utilization * 100, resets_at: v.seven_day.resets_at } },
  // seven_day_opus / seven_day_sonnet / seven_day_overage_included are not forwarded
};

So /status can show "Current week (Fable) — 60% used, resets …", but a custom status line has no way to read that number. It isn't persisted to disk anywhere either — the only source is the in-memory header parse.

Requested behavior

Forward the model-specific weekly limits to the status line JSON as well, using the same { used_percentage, resets_at } shape, e.g.:

"rate_limits": {
  "five_hour":                  { "used_percentage": ..., "resets_at": ... },
  "seven_day":                  { "used_percentage": ..., "resets_at": ... },
  "seven_day_opus":             { "used_percentage": ..., "resets_at": ... },
  "seven_day_sonnet":           { "used_percentage": ..., "resets_at": ... },
  "seven_day_overage_included": { "used_percentage": ..., "resets_at": ... }
}

Each key would be present only when the corresponding limit is active, mirroring the existing conditional-spread pattern. This looks like a one-line change in the payload builder (the data is already in the object being spread from).

Why

Fable 5 has a dedicated weekly limit that's separate from the general 7-day window. Users who track their usage in a custom status line currently can't surface it, even though the client already has the value and shows it in /status.

Environment

  • Claude Code 2.1.199
  • Status line configured via settings.json → statusLine.command

View original on GitHub ↗

3 Comments

kuk1song · 1 month ago

Coming over from #77846 (closed as a duplicate of this one), so the verified findings from that thread live in one place.

The 7d_oi header does ship in production, but only on Fable responses. That's the seven_day_overage_included key this issue asks for. From @d-mato's independent probing (d-mato/claude-usage-bar#2): 7d_oi 0.65 next to 7d 0.36 on the same response, still there after the 2026-07-20 billing transition, and matching the weekly_scoped Fable meter from the usage endpoint. Haiku and Opus responses don't carry it.

So the fix proposed here is real and cheap, but conditional: a session only sees 7d_oi while it's actually sending Fable requests. The question it can't answer is the one most people are asking: how much Fable headroom is left before I switch.

Claude Code's other source doesn't have that gap. The get_usage control request returns the shape statusLine would need (verified on 2.1.218 and 2.1.220):

"model_scoped": [{ "display_name": "Fable", "utilization": 95, "resets_at": "..." }]

display_name comes from the server, so there's no model list to keep in sync client side. And forwarding it doesn't have to mean a fetch at render time: Claude Code already caches the same numbers in ~/.claude.json (cachedUsageUtilization, limits[] entries with kind: "weekly_scoped", plus a fetchedAtMs to age them out).

Meanwhile the ecosystem is working around it. claude-hud renders model_scoped in full (jarrodwatts/claude-hud#669) and reads it from a get_usage-fed snapshot (#690, shipped in v0.7.0), with a cachedUsageUtilization fallback open in #720. claude-usage-bar switched its whole data source to get_usage in v0.5.0. The headless VPS setup on #77846 and the desk meters on #81015 are burning a second credential, or a throwaway Haiku call, just to read a percentage. All of it retires the day stdin carries the scoped windows.

sean3808 · 1 month ago

Still reproduces on 2.1.259 (win32, Max). Adding the current code-level state, because the statusline payload builder has been touched since the 2.1.199 snapshot in the issue body — model_scoped just wasn't carried along with it.

1. model_scoped is now a declared field on the other surface, not merely an observed response

The status/SDK schema in 2.1.259 defines it inline alongside the other windows:

rate_limits: {
  five_hour, seven_day, seven_day_oauth_apps, seven_day_opus, seven_day_sonnet,
  model_scoped: array({
    display_name: string,   // "Server-supplied label for the model bucket (e.g. 'Fable')."
    utilization:  number|null,
    resets_at:    string|null,
  }).optional(),
  //  "Per-model weekly windows from the server limits[] array, filtered by the
  //   overage-included-models allowlist. Additive — present only when the server emits them."
  extra_usage: { is_enabled, monthly_limit, used_credits, utilization, currency }
}

This promotes @kuk1song's get_usage observation from 2.1.218/2.1.220 into a committed shape, and it settles the "which models, and who keeps that list in sync" question: the server supplies both the label and the allowlist filter, so there is nothing model-specific to maintain client side.

2. The projection from limits[] to model_scoped already exists in-process

Same build, the function returning { session, subscription_type, rate_limits_available, rate_limits, behaviors } builds and attaches it (minified, paraphrased):

let r;
if (n !== null) {
  try {
    r = projectScoped(n.limits, includedModels()).map(o => ({
      display_name: o.displayName,
      utilization:  o.limit.utilization ?? null,
      resets_at:    typeof o.limit.resets_at === "number"
                      ? new Date(o.limit.resets_at * 1000).toISOString()
                      : o.limit.resets_at ?? null,
    }));
  } catch (e) {
    log(`model_scoped projection failed: ${e}`, { level: "error" });
  }
}
return {
  session: { ... },
  subscription_type: ...,
  rate_limits_available: ...,
  rate_limits: n === null
    ? null
    : (r !== undefined && r.length > 0 ? { ...n, model_scoped: r } : n),
  behaviors: ...,
};

So the ask needs no new fetch, no new cache, and no new schema — the mapping is written, guarded, and already running.

3. The statusline builder is a separate function that reconstructs rate_limits from scratch

const no = rateLimitState();
const mo = {
  ...no.five_hour && { five_hour: { used_percentage: no.five_hour.utilization * 100, resets_at: no.five_hour.resets_at } },
  ...no.seven_day && { seven_day: { used_percentage: no.seven_day.utilization * 100, resets_at: no.seven_day.resets_at } },
  ...provider() === "gateway" && no.overage && { spend_limit: { used_percentage: no.overage.utilization * 100, resets_at: no.overage.resets_at } },
};
// ...
...(mo.five_hour || mo.seven_day || mo.spend_limit) && { rate_limits: mo },

Three branches, no fourth. spend_limit is the new one relative to the 2.1.199 snapshot above — this is not a frozen code path. The two surfaces have simply diverged: one declares and populates model_scoped, the other assembles a parallel object that structurally cannot contain it.

4. Shape of the fix

Reuse the existing projection and widen the emit guard:

const scoped = /* same projection the status surface already runs */;
const mo = {
  ...five_hour, ...seven_day, ...spend_limit,
  ...(scoped?.length && { model_scoped: scoped }),
};
...(mo.five_hour || mo.seven_day || mo.spend_limit || mo.model_scoped) && { rate_limits: mo },

Consumers are already written against exactly this key. claude-hud parses rate_limits.model_scoped straight from stdin (parseScopedWindows) and renders it with no client-side model list — any non-empty display_name is accepted, so "Fable 5.1" and any future bucket need zero plugin changes. Today that path is simply unreachable from stdin, and the plugin falls back to the external get_usage-fed snapshot @kuk1song described.

Repro on 2.1.259

/usage renders "Current week (Fable) — 69% used, resets Sep 3, 9:59pm" while the statusline payload for that same session carries rate_limits with only five_hour and seven_day. The all-models weekly meter appears in the status line; the Fable one — the binding constraint — is absent.

kitaekatt · 1 month ago

On 2.1.260 (macOS) this account gets only five_hour and seven_day in the
statusline rate_limits -- no model_scoped, and no seven_day_opus or
seven_day_sonnet either -- while /usage shows the Fable weekly bar for the
same session. That is what the three-branch builder above predicts: the object
is rebuilt from scratch, so it can only carry the keys those branches spread.

Worth flagging because #88137 says the per-model keys are already delivered, and
proposes adding seven_day_fable beside them. If the builder has always had
three branches, there is nothing there to extend.

Small note for consumers: the schema says utilization and an ISO resets_at,
but stdin sends used_percentage and an epoch.

Showing cached comments. Read the full discussion on GitHub ↗