Skip to content

fix(pi): Claude Code billing rejection and missing claude-opus-5 - #147

Open
unspecd-dev wants to merge 7 commits into
cortexkit:mainfrom
unspecd-dev:fix/pi-claude-code-billing
Open

fix(pi): Claude Code billing rejection and missing claude-opus-5#147
unspecd-dev wants to merge 7 commits into
cortexkit:mainfrom
unspecd-dev:fix/pi-claude-code-billing

Conversation

@unspecd-dev

@unspecd-dev unspecd-dev commented Aug 11, 2026

Copy link
Copy Markdown

Summary

Two fixes in packages/pi, both reproduced against the released package and verified on a clean macOS VM.

  1. All Claude requests from Pi fail with 400 You're out of extra usage on a valid Claude subscription.
  2. claude-opus-5 is not selectable in Pi, despite feat: support Claude Opus 5 #143 adding Opus 5 request conversion and documenting /claude-fast support for it.

This description has been rewritten since the PR was opened: the original diagnosis was wrong, and the fix has changed as a result. See "What changed since the PR was opened" at the end.


1. fix(pi): split the prompt, cache the documentation paragraph ahead of user text

Symptom

Every Claude request from Pi returns:

400 {"type":"error","error":{"type":"invalid_request_error",
"message":"You're out of extra usage. Add more at claude.ai/settings/usage and keep going."}}

Reproduced on claude-opus-4-8. The dumped request is well-formed Claude Code traffic — correct user-agent, the full anthropic-beta set, the stainless headers, and a valid x-anthropic-billing-header — and the same account works in OpenCode with @cortexkit/opencode-anthropic-auth.

Cause

packages/pi/src/convert.ts pushes Pi's whole system prompt as a third entry in system[]. Only part of that prompt is a problem: two lines inside the Pi documentation paragraph, each independently sufficient to produce the 400.

- When asked about: extensions (docs/extensions.md, examples/extensions/), themes
  (docs/themes.md), skills (docs/skills.md), prompt templates (docs/prompt-templates.md), …
- When working on pi topics, read the docs and examples, and follow .md cross-references
  before implementing

Isolated by bisecting the prompt paragraph by paragraph, then line by line, against the released build. Ruled out along the way:

hypothesis test result
entry count 2697 bytes of neutral filler as a third entry 200
payload size 2697 bytes passes, 2529 bytes fails not size
a hash of the block append one character to the paragraph still 400
the install paths / @earendil-works the paragraph's first four lines only 200
the harness identity sentence remove it, keep the rest still 400
the cch billing header see notes below not implicated

The same two lines are accepted without issue inside messages[].

Fix

system[] and messages[] are separate fields of the request body. Keep the identity, tool contract and guidelines in system[], and move only the documentation paragraph into messages[] — as its own content block ahead of the user's text:

system[]     [0] x-anthropic-billing-header: …
             [1] You are Claude Code, …
             [2] Pi's prompt minus the documentation paragraph

messages[0]  block 0: the documentation paragraph  ← cache_control: ephemeral
             block 1: what the user typed
messages[1]  role: assistant …

Nothing is deleted, and the paragraphs that actually shape behaviour keep system-level weight and stay out of anything that rewrites conversation history.

Why a content block rather than a message

Two constraints. A role: "system" message cannot go at messages[0] — Anthropic returns messages.0: use the top-level 'system' parameter for the initial system prompt — so nothing can be placed ahead of the user's first message.

And a cache prefix matches contiguously from the start of the request, stopping at the first byte that differs. Anything placed at or behind the user's first message therefore falls outside the reusable prefix, because that message differs between conversations.

Measured with the whole prompt relocated, across three consecutive sessions in the same directory: the first establishes the cache, the second repeats the same opening message, and the third opens with a different one. The third is what matters — it is what a user does every time they start a new conversation.

where the relocated text is placed tokens written on session 3
concatenated into the user's first message, one block ~1.1k
a separate role: "system" message after the user's first message ~1.1k
a separate block inside the user's first message, before their text 11

The first two put the relocated text at or behind text that changes, so the match ends before reaching it and all of it is written again. The third puts the breakpoint at a block boundary the user's words cannot move, so it is still cached when the next conversation starts.

cache_control is set explicitly rather than left to addEphemeralCacheControl, whose message-level breakpoint targets the last user message — not this one, after the first turn.

Notes for review

The split keys on a string. PI_DOCS_ANCHOR is 'Pi documentation'. If pi upstream renames that heading the split stops separating, the whole prompt returns to system[], and every request 400s. Matching on prompt text is the same approach PARAGRAPH_REMOVAL_ANCHORS already takes on the OpenCode side, so the precedent exists — but the failure here is total rather than cosmetic. A fallback that moves the whole prompt into messages[0] as its own block when no paragraph matches would keep requests working instead of breaking them, and I'm happy to add it — but it is a degraded state rather than an equivalent one, and worth understanding before choosing it. system[] is rebuilt from context.systemPrompt on every request, so whatever sits there is present in full every time. messages[] is conversation history, and a long enough session gets trimmed. The split deliberately puts only the documentation paragraph on the trimmable side: losing Pi's doc pointers late in a session costs the model the ability to look up its own internals, which is recoverable. The fallback would put the identity, tool contract and editing guidelines there too, and losing those mid-session is not.

The cch billing header is not implicated. The released build never mutates messages between buildBillingHeaderValue and signRequestBody, so cch covers exactly the transmitted first user message — and it still 400s. Removing the prompt entirely, with cch computed the same way, returns 200.

addEphemeralCacheControl's message-level breakpoint never fires in normal use. It walks backward to the last user message but only marks it when the content is an array; convertTextAndImages joins text-only content into a plain string, so the array branch is unreachable for ordinary text. Consequence: the conversation tail is not covered by a breakpoint and is reprocessed each turn, including on the current released build. Independent of this PR and out of scope for it. I have a change that fixes it for plain text, but it also affects tool_result and image content which I haven't exercised — happy to open an issue, or a separate PR if you'd rather see the code.


2. fix(pi): register claude-opus-5 in the provider model list

#143 added Opus 5 request conversion in packages/pi/src/convert.ts, wiring up isClaudeOpus5Model and CLAUDE_OPUS_5_ADAPTIVE_THINKING from core, and updated packages/pi/README.md to document /claude-fast support for claude-opus-5. The model was never added to the models array in packages/pi/src/index.ts — that file was last touched by #118.

Without that entry the Opus 5 code path is unreachable from Pi, so the documented model does not appear in /model. The cost entry follows Anthropic's published rates for Opus 5: $5/MTok input, $25/MTok output, $0.50/MTok cache reads and $6.25/MTok cache writes.

A separate commit updates packages/pi/README.md, which enumerates the provider catalog and would otherwise omit the newly registered model.


Tests

packages/pi/src/tests/convert.test.ts asserts on the shape of messages[] through a shared buildMessages helper that hard-coded systemPrompt: 'test' for every case. With a prompt now split across two locations, every index-based assertion would shift, so systemPrompt is now an optional parameter and those cases assert raw conversion output as before. The new shape gets its own coverage:

  • the non-documentation paragraphs land in system[], and the documentation paragraph does not
  • the documentation paragraph becomes the first block of the first user message, with the user's text after it
  • that block carries cache_control
  • a structured first user message (text + image) gets the block unshifted rather than replaced
  • with no user message at all, the remaining paragraphs still reach system[] and the documentation paragraph is dropped
  • a prompt containing no documentation paragraph is left whole in system[]

packages/pi/src/tests/index.test.ts gains an Opus 5 registration case alongside the existing Sonnet 5 one.

Note for reviewers: the root test script is cd packages/opencode && bun run test, so packages/pi's own suite is not reached by CI — it runs with bun run --cwd packages/pi test.


Verification

Clean macOS VM — brew install pi-coding-agent 0.84.1, pi install npm:@cortexkit/pi-anthropic-auth, authenticated with Pi's /login anthropic. The released package reproduced both bugs (tested on 1.19.0; v1.19.1 does not touch convert.ts). Each configuration was produced by editing one block in convert.ts, rebuilding packages/pi/dist and copying it over the installed package; packages/core stayed at the released build.

bun run typecheck, bun run build, bun run test (1023 pass), bun run --cwd packages/pi test (66 pass), bun run lint and bun run format:check are clean on 849baf1.

claude-opus-4-8 returns 200 on this branch where the released package returns 400.

claude-opus-5 now appears in /model and returns 200, with thinking: {"type":"adaptive","display":"summarized"} and output_config: {"effort":"medium"}, confirming the CLAUDE_OPUS_5_ADAPTIVE_THINKING path executes. Anthropic validates model IDs — an unrecognised id returns 404 not_found_error — so a successful response confirms the model string was accepted.


What changed since the PR was opened

The PR originally claimed Anthropic rejects any third-party content in system[] alongside the identity block, and fixed it by moving the whole prompt into the first user message. @iceteaSA disproved the diagnosis with an OpenCode request carrying four system[] entries and 96KB of prompt that returns 200, and separately flagged that the relocation would cost prompt caching.

Both were right. Bisecting the prompt showed the trigger is two specific lines, not third-party content generally; and measuring the cache showed the first fix left the prompt outside the reusable prefix. The current version moves only what has to move and keeps it inside the prefix.


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.


Summary by cubic

Fixes Claude Code OAuth billing rejections in Pi and makes claude-opus-5 selectable. Old: Pi placed its full prompt in system[], causing 400 “You're out of extra usage.” New: system[] keeps the billing header, Claude Code identity, and non-doc guidance; only the “Pi documentation” paragraph moves to messages[0] as a cache-marked block before the user’s text.

  • The documentation block sets cache_control: { type: 'ephemeral' } so it’s cached across turns and the cached prefix ends before user text; it is dropped when no user message exists. Split keyed by the “Pi documentation” heading.
  • Registers claude-opus-5 with reasoning, text+image input, costs { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, contextWindow: 1_000_000, maxTokens: 128_000; updates packages/pi/README.md.

Written for commit 849baf1. Summary will update on new commits.

Review in cubic

Greptile Summary

The PR adjusts Pi’s Claude request construction to keep the identified documentation paragraph out of top-level system[] while preserving the remaining system instructions, and registers Claude Opus 5 in the provider catalog.

  • Splits the Pi prompt and moves its documentation paragraph into the first user message with an ephemeral cache breakpoint.
  • Drops that paragraph when no converted user message exists, completing the previously requested fallback fix.
  • Adds Claude Opus 5 metadata, documentation, and focused conversion and registration tests.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
packages/pi/src/convert.ts Splits the Pi prompt between top-level system instructions and a cacheable first-user documentation block, while safely dropping the rejected paragraph when no user message exists.
packages/pi/src/index.ts Registers Claude Opus 5 with reasoning support, text/image input, pricing, and token limits.
packages/pi/src/tests/convert.test.ts Adds focused coverage for prompt splitting, cache marking, structured user content, empty converted conversations, and unchanged no-prompt behavior.
packages/pi/src/tests/index.test.ts Verifies the Claude Opus 5 provider registration metadata.
packages/pi/README.md Updates the documented Pi provider catalog to include Claude Opus 5.

Sequence Diagram

sequenceDiagram
  participant Pi
  participant Convert as buildAnthropicRequest
  participant Sign as signRequestBody
  participant Anthropic
  Pi->>Convert: context with systemPrompt and messages
  Convert->>Convert: Split documentation paragraph from remaining prompt
  Convert->>Convert: Keep remaining instructions in system[]
  alt First user message exists
    Convert->>Convert: Prepend documentation cache block to user content
  else No user message exists
    Convert->>Convert: Drop documentation paragraph
  end
  Convert->>Sign: Serialize final request body
  Sign->>Sign: Compute CCH over final body
  Sign->>Anthropic: Send signed request
Loading

Reviews (4): Last reviewed commit: "fix(pi): split the prompt, cache the doc..." | Re-trigger Greptile

Context used:

Comment thread packages/pi/src/convert.ts Outdated

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 5 files

Architecture diagram
sequenceDiagram
    participant UI as "Pi TUI / User"
    participant Ext as "Pi Startup\ncortexKitPiAnthropicAuth"
    participant Catalog as "Provider Model Catalog\n(index.ts)"
    participant Convert as "Request Converter\n(convert.ts)"
    participant Pkg as "@cortexkit/anthropic-auth-core"
    participant API as "Anthropic API"

    Note over UI,API: PR focus: Claude Code billing request shape\nand claude-opus-5 model availability

    rect rgb(240,240,240)
    Note over UI,Catalog: A. Model registration (claude-opus-5)
    UI->>Ext: /model command
    Ext->>Catalog: register provider models
    Catalog->>Catalog: include claude-opus-5 (reasoning,\ncost, context window, max tokens)
    Catalog-->>UI: model appears in /model list
    end

    rect rgb(240,240,240)
    Note over UI,API: B. Request conversion and billing header flow
    UI->>Convert: send user message\n(e.g. "hi")
    Convert->>Pkg: fetch Claude Code billing header\n(x-anthropic-billing-header)
    Pkg-->>Convert: cc_version, cch
    Convert->>Convert: sanitize Pi system prompt\n(no Pi identity fingerprint)
    Convert->>Convert: build system[] array

    Note over Convert: system[] now contains ONLY:\n[0] billing header (added later)\n[1] Claude Code identity block\nPi prompt relocated out of system[]

    Convert->>Convert: find first user message\nin messages[] history

    alt First user message is a plain string
        Convert->>Convert: prepend prompt to string\n-> "You are an expert coding assistant...\n\nhi"
    else First user message is structured content (array)
        Convert->>Convert: unshift text block containing prompt
    else No user message present
        Convert->>Convert: append prompt to system[]\n[NOTE: fallback recreates\nrejected 3-entry shape]
    end

    Convert->>Convert: addEphemeralCacheControl\nanchors on system.at(-1)\n= Claude Code identity block
    Convert->>Convert: buildBillingHeaderValue\ncomputes cch over user message\nBEFORE prompt prepend
    Convert->>API: POST /messages\nwith system[] (2 entries)\n+ user message with prompt

    alt HTTP 200 OK
        API-->>UI: completion (model accepted,\nbilling OK)
    else HTTP 400 (only if fallback path\nrecreates 3-entry system[])
        API-->>Convert: "You're out of extra usage"
    end
    end
Loading

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread packages/pi/src/convert.ts Outdated
@iceteaSA

Copy link
Copy Markdown
Contributor

Not a maintainer — dogfooding this plugin on the OpenCode side. Two data points from that side, since the diagnosis in part 1 is interesting and the OpenCode path is a natural control.

Part 2 is unambiguous. packages/pi/src/index.ts registers claude-opus-4-8 and claude-sonnet-5 and no Opus 5 entry, while convert.ts has the full Opus 5 path (isClaudeOpus5Model at 414, CLAUDE_OPUS_5_ADAPTIVE_THINKING at 427). That's the invisible-feature shape — the code is reachable only if the model can be selected. Same class of gap as the /claude-account command that was fully tested but missing from the config hook. Your cost figures match the published rates.

Part 1 — the system[] claim doesn't reproduce on OpenCode, which narrows the cause. A working request from my live OpenCode traffic today, HTTP 200:

system[] entries: 4
  [0]    81 bytes  x-anthropic-billing-header: cc_version=2.1.177.3bf; cc_entrypoint=cli;
  [1]    57 bytes  You are Claude Code, Anthropic's official CLI for Claude.
  [2] 96751 bytes  You are an interactive CLI tool that helps users with software enginee…
  [3] 16556 bytes  <use_parallel_tool_calls>…

So a third and fourth system[] block carrying a large non-Claude-Code prompt is not rejected per se — the same subscription accepts it continuously on OAuth. Whatever triggers the 400 on Pi, "extra text in system[]" alone isn't sufficient, so the relocation may be fixing the symptom rather than the cause. Worth ruling out the alternatives before settling on it: Pi's system[] is built fresh in convert.ts:382-395 rather than by transforming a host-provided array, so the block ordering and the exact identity text are candidates too.

On your own billing-header note — I'd treat that as the more likely mechanism, not a nit. In convert.ts the billing block is built at 385 from messages, and signRequestBody runs at 460; relocating the prompt into messages[0] between those two points means cch is computed over a first user message that is not the one transmitted. OpenCode's transform.ts has the same two-phase shape (buildBillingHeaderValue at 1245, signRequestBody at 1305) but nothing mutates messages[0] in between, so the hash always covers the transmitted body. If cch mismatch is what Anthropic is actually objecting to — and "You're out of extra usage" is exactly the kind of unhelpful surface error a rejected billing header produces — then computing the header after relocation may be the fix, and the relocation itself may be incidental. That would also explain why the released version fails on every request rather than only large ones.

Cheap discriminator, if you still have the VM: on the released package, send one request with system[] reduced to just [billing, identity] and the prompt dropped entirely (not relocated). If that 400s too, system[] shape isn't the variable. And on this branch, moving buildBillingHeaderValue to after the relocation should be a no-op if the header isn't implicated, and a fix if it is.

One caveat on the cache-anchor question you flagged. addEphemeralCacheControl anchoring on the identity block instead of the prompt is a real behaviour change beyond the 400: on the OpenCode side the anchor placement determines whether a large stable prefix is cached, and a full-prefix rebuild on a big context is measurable — I've measured 450–560K cache-write tokens on a bust. Your identical message0Hash across a turn shows the request is stable, which is necessary but not sufficient; the check that would settle it is the response usagecache_read_input_tokens on the follow-up should be non-trivial rather than near-zero. Anchoring on an 81-byte billing block and a 57-byte identity block leaves very little cacheable prefix if the prompt moved out of system[] into a message.

Testing notes both look right to me: packages/pi's suite genuinely isn't reached by the root test script, and making systemPrompt optional in buildMessages is the correct fix for the eight broken conversion tests rather than pinning them to the new shape.

Relocating the prompt into the first user message avoided the 400 but left it
outside the cached prefix, so it was reprocessed on every turn. Carry it as a
role: system message with its own cache_control marker instead: the full prompt
is preserved, system[] keeps only the billing header and identity block, and the
breakpoint is set on every request rather than depending on
addEphemeralCacheControl's array-content check, which Pi's plain-string messages
never satisfy.
… user text

Carrying the whole prompt as a role: system message fixed the 400 but placed it
behind the user's first message, so a new conversation with different opening
words re-cached ~1.1k tokens. Only the documentation paragraph is rejected in
system[], so keep the identity, tool contract and guidelines there — where they
carry system weight and survive compaction — and move only that paragraph into
messages[], as its own cache-marked block ahead of the user's text. A cache
prefix matches contiguously from the start of the request, so the block boundary
lets it end before the user's words: measured 11 tokens written on a new
conversation against ~1.1k previously.
@unspecd-dev
unspecd-dev force-pushed the fix/pi-claude-code-billing branch from a3c8830 to 849baf1 Compare August 16, 2026 13:55
@unspecd-dev

Copy link
Copy Markdown
Author

@iceteaSA — thanks, both points held up and both changed the fix. I've rewritten the description; short version of what your review led to:

Your OpenCode data point ruled out the diagnosis. It sent me back to bisect the prompt instead, and a control agrees with you from the other direction — 2697 bytes of neutral filler in the same system[] position returns 200. It's the content, and it turned out to be two specific lines.

On cch — I don't think it's implicated. Your first discriminator settled it: dropping the prompt entirely, with the header computed exactly as before, returns 200. I didn't need the second one (moving buildBillingHeaderValue after the relocation) because the released build already computes cch over the transmitted first user message and still 400s — the mismatch you describe only appears in the patched build, which succeeds. Reasoning in the description.

On the cache you were right, and it decided the final shape. I used pi's status line for the read/write figures rather than the response usage — same numbers, easier to watch per turn. The first version was writing the same token count as a control with no prompt in the request at all, so it was contributing nothing to the prefix. The part I hadn't considered until measuring was what happens when a new conversation starts — the prompt sits behind the user's first message, which changes, so the prefix match ends before reaching it. Carrying it as a separate content block inside that message instead puts the breakpoint at a boundary the user's words can't move, and the same measurement drops from ~1.1k tokens to 11.

On your specific check: reads are ~2.3k per turn rather than near-zero, and the anchor concern turned out not to apply — under the final shape roughly half the prompt stays in system[] (1346 bytes of instructions there, 1351 in the documentation block), so the cacheable prefix is that plus tools rather than the 138 bytes you were picturing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants