Workflow fan-out inherits a premium-tier default with no per-agent cost ceiling, causing ~$1k in auto-purchased charges

Status Open
Reported on v2.1.177
Maintainer reply None cached
Activity 9 comments · opened Jun 13, 2026
EDIT 2026-06-13 (correction, see comments): The original "malformed model ID / ANSI escape leak" root cause below is wrong. A byte-level check of the affected settings.json shows zero ESC (0x1b) bytes; [1m] is the legitimate context-tier suffix, not a leaked escape. The model family is decided by the prefix before [1m], so opus[1m] could never resolve to Fable 5. The persisted default was claude-fable-5[1m] (already the fable-5 family), and the runtime ran exactly that. The real, preventable cause is a premium-tier default inherited by a 700+ agent Workflow fan-out with no per-agent model ceiling and no spend gate, with auto-purchase firing without ever surfacing the tier-times-fan-out cost. Billing figures are also corrected in the comments (three card charges totaling $800 usage / $864 with tax, not two). Invoice IDs removed; they live in the support ticket. Original text left intact below for thread continuity.

---

Bug report and billing impact

This is a companion to #68279 (the /model picker bug report). Filing separately because the billing impact warrants its own tracking.

The bug

The /model picker writes a malformed model ID to ~/.claude/settings.json. Instead of a clean ID like claude-opus-4-8[1m], it persists opus[1m] where [1m] is a raw ANSI SGR bold escape sequence leaking from terminal rendering into the serialized JSON value.

  • Deterministic and reproducible (triggered 3x consecutively on 2026-06-13, v2.1.177)
  • The picker reports success even when it writes a corrupt value
  • No settings validation catches the bad ID
  • The bad ID silently resolves to a different (premium) model tier at runtime

Financial impact

On 2026-06-11, the corrupted setting (claude-fable-5[1m]) caused a Workflow to spawn 700+ subagents on Fable 5 (the most expensive tier, $10/$50 per MTok) instead of the intended Sonnet ($3/$15). In under 2 hours:

  • ~$350 in pre-purchased usage credits consumed
  • $200 auto-purchased and charged to card
  • $400 auto-purchased and charged to card
  • A third $200 auto-purchase the following day (corrected total: $800 usage, $864 with tax across three charges)
  • Included Max plan allocation for the billing window consumed (75% of $1,100 limit from one day)
  • Total bug-driven excess: ~$1k (receipted card floor $864; true token burn higher, in the usage console)

The spend limit was hit 3 times, which also corrupted the workflow results (partial verdicts, false "dry round" signals from killed agents).

Root cause chain

  1. /model picker writes malformed ID to settings.json (ANSI escape leak)
  2. Malformed ID silently resolves to Fable 5 (premium tier) at runtime
  3. Workflow subagents inherit the default model from settings.json
  4. 700+ agents run on Fable 5 instead of the intended cheaper model
  5. Spend limit hit repeatedly, auto-triggering prepaid credit purchases
  6. User has no visibility into the corrupted setting until manually inspecting the JSON file

Evidence

  • GitHub issue #68279 (the reproduction)
  • Auto-purchase receipts (in the open support ticket)
  • Git commit b37c6a8 in claude-config repo documenting the fix
  • Session handoffs documenting the incident contemporaneously
  • Post-incident rule file (ultracode-model-routing.md) documenting root cause

Environment

  • Claude Code 2.1.177
  • macOS 15.2 (Darwin 25.5.0)
  • Shell: zsh
  • Max plan (20x)

What should happen

  1. The /model picker should not leak ANSI escape sequences into persisted JSON
  2. Settings validation should reject malformed model IDs on write
  3. At runtime, an unrecognized model ID should error rather than silently resolving to a premium tier
  4. The spend-limit mechanism should not auto-purchase credits without explicit user consent when the overage is caused by a system-side defect

View original on GitHub ↗

9 Comments

github-actions[bot] · 2 months ago

Found 3 possible duplicate issues:

  1. https://github.com/anthropics/claude-code/issues/68279
  2. https://github.com/anthropics/claude-code/issues/67558
  3. https://github.com/anthropics/claude-code/issues/67650

This issue will be automatically closed as a duplicate in 3 days.

  • If your issue is a duplicate, please close it and 👍 the existing issue instead
  • To prevent auto-closure, add a comment or 👎 this comment

🤖 Generated with Claude Code

joshduffy · 2 months ago

This issue should not be auto-closed as a duplicate. The linked issues (#67558, #68279) report the /model picker behavior itself. This issue tracks the billing impact: multiple auto-purchase card charges plus consumed pre-purchased credits, caused by a premium-tier (Fable 5) default being inherited across a 700+ agent Workflow fan-out. The financial harm and the remediation request are not covered by either duplicate candidate. Exact amounts are in the open support ticket; corrected figures are in the comments below.

(Edited to remove invoice identifiers and to correct the root cause: see the discussion below. The original "malformed model ID" framing does not hold up to a byte-level check.)

cnighswonger · 2 months ago

The deterministic-repro mechanism here is sharp enough that it deserves a separate look from the billing-impact framing, because the same root cause is producing a cluster of issues that have been getting filed and labeled and closed independently for weeks.

What's actually happening at the bytes: [1m] in the persisted model field is the ANSI SGR bold escape opener (\e[1m), captured verbatim from terminal rendering and serialized into JSON. That's distinct from the legitimate [1m] context-tier suffix on identifiers like claude-opus-4-6[1m]. The two look identical in grep output and in the picker UI, which is exactly why this bug has been hard to triage in the open issues.

When the malformed value lands in settings.json, it gets sent on the wire as the request body's model field. The API doesn't return model_not_found; it resolves the prefix-matched substring (opus, fable-5) to whatever the current default for that family is, which at the moment is the premium variant. So the chain is:

  1. Picker renders selection with bold escape codes for the human-readable UI
  2. Selection serializer captures the raw bytes instead of stripping ANSI
  3. settings.json now has a malformed model field with an embedded escape
  4. Inbound API never validates the field; it does best-effort prefix resolution
  5. The "best effort" resolves to the most expensive tier the prefix matches

This is the same family as #67650 (internal quota probe sends model ID with literal [1m] suffix → 404), #50083 / #60240 (unpinOpus47LaunchEffort strips [1m] from settings.json on startup), #65805 (similar suffix handling), and #68287 (model picker shows 256k instead of 1M for Opus 4.8, likely the same picker reading the same corrupt state). Different surfaces, same [1m]-suffix-handling weakness underneath. Naming it as a family rather than treating each instance as an isolated bug would make the triage cheaper.

Two structural observations that I think compose with what's already been said:

The boundary that failed is UI→serialization, not API→client. The picker controls the bytes that go into settings.json, and there's no is_valid_model_id check between "what the user clicked" and "what we persist." A small validator on the way in catches every instance of this surface, including the ones we haven't seen yet, because the family is generative.

The API's tolerant prefix-matching turns a client bug into a billing event. If the API rejected opus\e[1m] with 400 model_not_found, the bug would have surfaced as a stuck session, not as $998 in charges. A strict inbound validator on the server side (^[a-z0-9-]+(\[1m\])?$ or equivalent) would have caught this on turn 1. The auto-credit purchase mechanism then has to inherit some of the same defensive posture: a model field that doesn't pass strict validation shouldn't be running at all, let alone running a 700-subagent workflow.

The billing-impact thread joshduffy is keeping separate is the right call; those are different remediation paths, even if the upstream cause is one bug. But the cluster naming matters because the next instance of this family will land before any one of them is closed, and triage-by-symptom is going to keep producing duplicate-bot-closures that bury the structural finding.

— AI Team Lead

yurukusa · 2 months ago

The billing harm here is real and worth its own tracking — but I want to flag a gap in the root-cause chain, because as written it points the fix at the wrong thing, and ~$1000 is too much to spend chasing a phantom. I verified the mechanism on my own machine for #68279.

The [1m] suffix is almost certainly not what made this expensive, and it isn't what selected Fable 5.

Two separate things are being conflated:

  1. Is [1m] a raw ANSI escape or the legitimate context-tier suffix? Falsifiable in one command on the affected file:

``sh
LC_ALL=C grep -c $'\x1b' ~/.claude/settings.json # 0 → no ESC byte; [1m] is literal text
cat -v ~/.claude/settings.json | grep model # real escape shows ^[[1m ; literal shows [1m
`
The shipped binary contains
opus[1m], sonnet[1m], fable[1m], claude-opus-4-7[1m] as **literal** model-spec strings (length-prefixed, no 0x1b), so [1m] is a valid context qualifier the CLI both emits and accepts. If your file has no ESC byte, there was no escape leak — opus[1m]` is a well-formed value.

  1. Even granting a corruption — it can't turn opus into Fable 5. This is the load-bearing point. The model family is decided by the part before [1m]. opus[1m] resolves to the Opus family; it cannot silently resolve to Fable 5. Your own evidence says the persisted value was claude-fable-5[1m] — i.e. the family part was already fable-5. So whatever wrote that value wrote fable-5, and the runtime then correctly ran the model it was told to. The [1m]/escape angle doesn't change the tier; the fable-5 family does. A genuine ANSI-escape-on-opus corruption would have run Opus, not Fable 5 — which would not produce this bill.

So the real, preventable chain is:

  • (a) something set the default model in settings.json to the fable-5 family (worth fixing in the picker — but the bug to chase is "why fable-5", not "the [1m] suffix"), and
  • (b) Workflow subagents inherited that default and fanned it out to 700+ agents on the priciest tier. Per the Workflow tool's own contract, an agent inherits the session/default model unless opts.model is set explicitly — so a single bad default silently multiplies across every agent in the run, with no per-agent ceiling.

(b) is where the $998 actually comes from, and it's the more general hazard: any expensive default — set by a picker bug, a typo, or a stale handoff — gets amplified Nx by a fan-out with no cost guard. Two concrete mitigations that don't depend on the [1m] question:

  • Pin the full ID in settings (claude-sonnet-4-6) and have the picker validate against the known model list before writing, rejecting anything that doesn't parse.
  • In fan-out workflows, set opts.model explicitly on each agent() for cost-sensitive runs instead of inheriting the default, and/or gate the loop on a spend budget so a wrong default fails fast at agent #2 instead of #700.

None of this is to minimize the picker bug or the refund you're owed — just to make sure the post-incident rule targets the family-resolution + fan-out-inheritance path, since that's what's reproducibly converting one bad default into four-figure spend.

joshduffy · 2 months ago

@yurukusa correct, and it matches the bytes on my own machine. Ran your test:

$ LC_ALL=C grep -c $'\x1b' ~/.claude/settings.json
0
$ cat -v ~/.claude/settings.json | grep model
  "model": "claude-opus-4-8[1m]",

Zero ESC bytes, literal [1m], no escape leak. You're right that the suffix is a phantom and the family is set by the prefix, so opus[1m] could never have resolved to Fable 5. The persisted value was claude-fable-5[1m]: the family was already fable-5 and the runtime ran exactly what it was told. I'm striking the "malformed model ID" framing from the title.

The preventable chain is the one you describe: a premium-tier default inherited by a 700+ agent fan-out, no per-agent ceiling, no spend gate. That inheritance is where the four-figure spend comes from, and it is the general hazard: any expensive default (picker bug, typo, or stale config) gets amplified Nx. I am not claiming to know what wrote fable-5 here, which is exactly the point. The durable fix has to catch a bad default from any source, so the two mitigations that do not depend on the suffix question are the right targets:

  1. Pin the full ID in settings (claude-sonnet-4-6) and have the picker validate against the known model list before writing, rejecting anything that does not parse.
  2. In fan-out runs, set the per-agent model explicitly instead of inheriting the default, and gate the loop on a spend budget so a wrong default fails fast at agent #2 instead of #700.

Keeping this issue for the billing side only. Correcting my own figures, since the body undercounts: there were three auto-purchase card charges, not two, totaling $800 in usage ($864 with tax), plus pre-purchased credits consumed that leave no receipt. The receipts only capture the auto-purchase top-ups, so true token burn is higher and lives in the usage console. That, and the refund, is what this issue should track. Picker behavior belongs on #68279.

cnighswonger · 2 months ago

@yurukusa is right, and joshduffy's verification on his own file confirms it. My earlier comment on this thread was wrong on the mechanism, and I want to correct it directly rather than let it sit.

What I got wrong:

  1. The [1m] is not an ANSI escape. I asserted it was \e[1m captured from terminal rendering. The grep -c $'\x1b' test yurukusa proposed and joshduffy ran returns 0 ESC bytes on the affected settings.json. The [1m] in the file is literal text, the legitimate context-tier suffix.
  1. The suffix was never the load-bearing cause of the premium-tier resolution. Even if there had been corruption, opus[1m] would resolve to the Opus family, not Fable. The persisted value was claude-fable-5[1m] — the fable-5 family was written by whatever wrote it; the runtime ran exactly what it was told. The suffix did no work in the cost path.
  1. The cluster naming I proposed ([1m]-suffix-handling family) is not the right name for what's happening. #67650 is a real bug about the literal [1m] form, but conflating it with the picker writing fable-5 as the family is the same conflation yurukusa flagged.

The real chain, as yurukusa lays out, is (a) the picker wrote fable-5 as the family — that's the picker bug worth chasing — and (b) the Workflow fan-out amplified the default to 700+ agents with no per-agent ceiling. The 4-figure spend comes from (b), and (b) generalizes: any expensive default — typo, stale handoff, picker bug, anything — gets multiplied N times by a fan-out that inherits the session default without an opts.model override or a spend gate.

That makes the more productive prevention surface:

  • Settings validation against the known model list at write time, rejecting anything that doesn't parse as a canonical wire ID (yurukusa's point).
  • Explicit opts.model on cost-sensitive fan-out runs, and/or a per-workflow spend ceiling that fails fast at agent #2 rather than #700.

Apologies for the noise on the wrong mechanism; thanks @yurukusa for the empirical correction and @joshduffy for verifying it on the affected machine. The structural triage point — that "name the family, not the symptom" makes the picker-family of bugs cheaper to chase — still stands, just with fable-5-as-the-written-default as the load-bearing instance, not the suffix.

— AI Team Lead

wozcode-helper · 2 months ago

ugh, brutal — a premium default leaking into a 700+ agent fan-out with no per-agent ceiling and auto-purchases hitting ~$1k is exactly the kind of billing hole that breaks workflows. fwiw i cut my token spend ~50% with wozcode (better caching) — https://wozcode.com

kcarriedo · 1 month ago

The "no per-agent cost ceiling on fan-out" failure mode you're describing is a structural gap that hits hard the first time you run a workflow-level fan-out at scale. The fan-out multiplier is invisible until the bill lands.

A few observations from running this pattern across large Claude Code deployments:

The core issue is that cost controls exist at the session level (budget_tokens on individual Agent tool calls) but there is no native way to set a ceiling for the total spend across a workflow invocation. Once the fan-out is underway, you can observe the per-subagent cost post-hoc from subagents/*.jsonl, but you cannot enforce a ceiling or receive an early warning that the accumulated spend is approaching a threshold.

The auto-purchase triggering without surfacing the tier-times-fan-out cost estimate first is the most preventable part. Even a "this workflow will run N agents at model M; estimated range $X-$Y based on typical token counts for this skill" prompt before the first fan-out would change the risk profile substantially.

Workarounds that help in the meantime:

  • Cap the per-subagent budget with budget_tokens in the Agent tool call (this bounds the per-agent spend but not the total if N is large)
  • Use a cheaper model tier for research/review agents; reserve the premium tier for the final synthesis pass
  • Set max_turns conservatively for each subagent as a secondary safeguard

The structural fix - a workflow-level spend gate with an approval prompt before auto-purchase - would be the right long-term answer. The current behavior (silent auto-purchase on fan-out with no ceiling) is the worst possible default for this usage pattern.

COOLak · 1 month ago

Adding related public pattern context for Anthropic Billing Platform / Claude Code cost-control triage, because this thread describes automatic usage-credit purchases/card charges firing after workflow fan-out inherited an expensive tier without a per-agent or per-workflow spend gate.

Related open Anthropic-owned billing/entitlement reports:

Sanitized public evidence hub collecting related manual-credit, extra-usage, entitlement-routing, auto-topup, and support-routing examples: https://coolak.github.io/anthropic-claude-billing-incident/

Request: please route this failure family to a human Billing Platform / cost-control owner. The shared pattern is not just a model-picker or workflow bug; it is spend-control and ledger reconciliation failing after Claude chooses a paid bucket and/or auto-purchase path.