fix(pi): Claude Code billing rejection and missing claude-opus-5 - #147
fix(pi): Claude Code billing rejection and missing claude-opus-5#147unspecd-dev wants to merge 7 commits into
Conversation
There was a problem hiding this comment.
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
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
|
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. Part 1 — the So a third and fourth On your own billing-header note — I'd treat that as the more likely mechanism, not a nit. In Cheap discriminator, if you still have the VM: on the released package, send one request with One caveat on the cache-anchor question you flagged. Testing notes both look right to me: |
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.
a3c8830 to
849baf1
Compare
|
@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 On 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 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 |
Summary
Two fixes in
packages/pi, both reproduced against the released package and verified on a clean macOS VM.400 You're out of extra usageon a valid Claude subscription.claude-opus-5is not selectable in Pi, despite feat: support Claude Opus 5 #143 adding Opus 5 request conversion and documenting/claude-fastsupport 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 textSymptom
Every Claude request from Pi returns:
Reproduced on
claude-opus-4-8. The dumped request is well-formed Claude Code traffic — correctuser-agent, the fullanthropic-betaset, the stainless headers, and a validx-anthropic-billing-header— and the same account works in OpenCode with@cortexkit/opencode-anthropic-auth.Cause
packages/pi/src/convert.tspushes Pi's whole system prompt as a third entry insystem[]. Only part of that prompt is a problem: two lines inside the Pi documentation paragraph, each independently sufficient to produce the 400.Isolated by bisecting the prompt paragraph by paragraph, then line by line, against the released build. Ruled out along the way:
@earendil-workscchbilling headerThe same two lines are accepted without issue inside
messages[].Fix
system[]andmessages[]are separate fields of the request body. Keep the identity, tool contract and guidelines insystem[], and move only the documentation paragraph intomessages[]— as its own content block ahead of the user's text: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 atmessages[0]— Anthropic returnsmessages.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.
role: "system"message after the user's first messageThe 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_controlis set explicitly rather than left toaddEphemeralCacheControl, 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_ANCHORis'Pi documentation'. If pi upstream renames that heading the split stops separating, the whole prompt returns tosystem[], and every request 400s. Matching on prompt text is the same approachPARAGRAPH_REMOVAL_ANCHORSalready 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 intomessages[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 fromcontext.systemPrompton 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
cchbilling header is not implicated. The released build never mutatesmessagesbetweenbuildBillingHeaderValueandsignRequestBody, socchcovers exactly the transmitted first user message — and it still 400s. Removing the prompt entirely, withcchcomputed 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;convertTextAndImagesjoins 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 affectstool_resultand 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 upisClaudeOpus5ModelandCLAUDE_OPUS_5_ADAPTIVE_THINKINGfrom core, and updatedpackages/pi/README.mdto document/claude-fastsupport forclaude-opus-5. The model was never added to themodelsarray inpackages/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.tsasserts on the shape ofmessages[]through a sharedbuildMessageshelper that hard-codedsystemPrompt: 'test'for every case. With a prompt now split across two locations, every index-based assertion would shift, sosystemPromptis now an optional parameter and those cases assert raw conversion output as before. The new shape gets its own coverage:system[], and the documentation paragraph does notcache_controlsystem[]and the documentation paragraph is droppedsystem[]packages/pi/src/tests/index.test.tsgains an Opus 5 registration case alongside the existing Sonnet 5 one.Note for reviewers: the root
testscript iscd packages/opencode && bun run test, sopackages/pi's own suite is not reached by CI — it runs withbun run --cwd packages/pi test.Verification
Clean macOS VM —
brew install pi-coding-agent0.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 touchconvert.ts). Each configuration was produced by editing one block inconvert.ts, rebuildingpackages/pi/distand copying it over the installed package;packages/corestayed 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 lintandbun run format:checkare clean on849baf1.claude-opus-4-8returns 200 on this branch where the released package returns 400.claude-opus-5now appears in/modeland returns 200, withthinking: {"type":"adaptive","display":"summarized"}andoutput_config: {"effort":"medium"}, confirming theCLAUDE_OPUS_5_ADAPTIVE_THINKINGpath executes. Anthropic validates model IDs — an unrecognised id returns404 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 foursystem[]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.
Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.Summary by cubic
Fixes Claude Code OAuth billing rejections in Pi and makes
claude-opus-5selectable. 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.claude-opus-5with reasoning, text+image input, costs { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, contextWindow: 1_000_000, maxTokens: 128_000; updatespackages/pi/README.md.Written for commit 849baf1. Summary will update on new commits.
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.Confidence Score: 5/5
The PR appears safe to merge.
No blocking failure remains.
Important Files Changed
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 requestReviews (4): Last reviewed commit: "fix(pi): split the prompt, cache the doc..." | Re-trigger Greptile
Context used: