From da9f61cac8792634e6740748393e122f95b7803e Mon Sep 17 00:00:00 2001 From: unspecd-dev <315087515+unspecd-dev@users.noreply.github.com> Date: Wed, 12 Aug 2026 00:12:22 +0530 Subject: [PATCH 1/5] fix(pi): relocate system prompt out of system[] for Claude Code billing --- packages/pi/src/convert.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/pi/src/convert.ts b/packages/pi/src/convert.ts index f7a9fdd1..fd6b0a3b 100644 --- a/packages/pi/src/convert.ts +++ b/packages/pi/src/convert.ts @@ -391,7 +391,21 @@ export async function buildAnthropicRequest( { type: 'text', text: CLAUDE_CODE_IDENTITY }, ] if (context.systemPrompt?.trim()) { - system.push({ type: 'text', text: sanitize(context.systemPrompt) }) + // Anthropic validates system[] for OAuth requests using Claude Code + // billing. Third-party system content alongside the identity block is + // rejected with 400 "You're out of extra usage". Keep only the billing + // header and identity in system[], and relocate Pi's prompt to the first + // user message, where it is functionally equivalent. + const prompt = sanitize(context.systemPrompt) + const firstUser = messages.find((m) => m.role === 'user') + const content = firstUser?.content + if (firstUser && typeof content === 'string') { + firstUser.content = `${prompt}\n\n${content}` + } else if (firstUser && Array.isArray(content)) { + content.unshift({ type: 'text', text: prompt }) + } else { + system.push({ type: 'text', text: prompt }) + } } const body: AnthropicRequestBody = { From 2faf7d73f8f0fcce86a69b9562579c17d5e9b98a Mon Sep 17 00:00:00 2001 From: unspecd-dev <315087515+unspecd-dev@users.noreply.github.com> Date: Wed, 12 Aug 2026 00:12:22 +0530 Subject: [PATCH 2/5] fix(pi): register claude-opus-5 in the provider model list --- packages/pi/src/index.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/pi/src/index.ts b/packages/pi/src/index.ts index 5023fc68..738846e3 100644 --- a/packages/pi/src/index.ts +++ b/packages/pi/src/index.ts @@ -80,6 +80,15 @@ export default function cortexKitPiAnthropicAuth(pi: ExtensionAPI) { contextWindow: CLAUDE_FABLE_MYTHOS_5_CONTEXT_WINDOW, maxTokens: CLAUDE_FABLE_MYTHOS_5_MAX_OUTPUT_TOKENS, })), + { + id: 'claude-opus-5', + name: 'Claude Opus 5', + reasoning: true, + input: textImageInput(), + cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, + contextWindow: 1_000_000, + maxTokens: 128_000, + }, { id: 'claude-opus-4-8', name: 'Claude Opus 4.8', From ff24876b8340ebee397bd85a1c9986e0d828101e Mon Sep 17 00:00:00 2001 From: unspecd-dev <315087515+unspecd-dev@users.noreply.github.com> Date: Wed, 12 Aug 2026 00:12:22 +0530 Subject: [PATCH 3/5] docs(pi): list claude-opus-5 in the provider catalog --- packages/pi/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/pi/README.md b/packages/pi/README.md index 81c7e3e9..3dd5ef4c 100644 --- a/packages/pi/README.md +++ b/packages/pi/README.md @@ -2,7 +2,7 @@ Pi package for CortexKit Anthropic OAuth support. It overrides Pi's built-in `anthropic` provider with a CortexKit provider extension backed by the shared `@cortexkit/anthropic-auth-core` package. -The Pi provider catalog includes Claude Fable 5 (`claude-fable-5`), limited-access Claude Mythos 5 (`claude-mythos-5`), Claude Opus 4.8, Claude Opus 4.5, Claude Sonnet 4.5, and Claude Sonnet 5 (`claude-sonnet-5`). Fable/Mythos reasoning uses Anthropic adaptive thinking with `thinking.display: "summarized"` and `output_config.effort`; the package does not send rejected manual `thinking.budget_tokens` for those models. +The Pi provider catalog includes Claude Fable 5 (`claude-fable-5`), limited-access Claude Mythos 5 (`claude-mythos-5`), Claude Opus 5 (`claude-opus-5`), Claude Opus 4.8, Claude Opus 4.5, Claude Sonnet 4.5, and Claude Sonnet 5 (`claude-sonnet-5`). Fable/Mythos reasoning uses Anthropic adaptive thinking with `thinking.display: "summarized"` and `output_config.effort`; the package does not send rejected manual `thinking.budget_tokens` for those models. This package is part of the CortexKit Anthropic Auth monorepo, which supports both OpenCode (`@cortexkit/opencode-anthropic-auth`) and Pi (`@cortexkit/pi-anthropic-auth`) through the same shared core logic. From b9e54918a4b8b50cb2596a5e47b6a6e91e017020 Mon Sep 17 00:00:00 2001 From: unspecd-dev <315087515+unspecd-dev@users.noreply.github.com> Date: Wed, 12 Aug 2026 00:12:22 +0530 Subject: [PATCH 4/5] test(pi): cover system[] relocation and claude-opus-5 registration --- packages/pi/src/tests/convert.test.ts | 76 ++++++++++++++++++++++++++- packages/pi/src/tests/index.test.ts | 19 +++++++ 2 files changed, 93 insertions(+), 2 deletions(-) diff --git a/packages/pi/src/tests/convert.test.ts b/packages/pi/src/tests/convert.test.ts index 148731c7..cb61b961 100644 --- a/packages/pi/src/tests/convert.test.ts +++ b/packages/pi/src/tests/convert.test.ts @@ -33,10 +33,14 @@ function toolResultMsg(toolCallId: string, text: string): Message { const defaultCache = { enabled: false, mode: 'hybrid' as const } -async function buildMessages(messages: Message[]) { +// systemPrompt is opt-in. buildAnthropicRequest relocates a non-empty prompt +// onto the first user message, so tests that assert raw conversion output pass +// no prompt and observe messages unchanged. The relocation itself is covered by +// the "Claude Code system[] shape" block below. +async function buildMessages(messages: Message[], systemPrompt?: string) { const context = { messages, - systemPrompt: 'test', + systemPrompt, tools: [], } const { body } = await buildAnthropicRequest( @@ -326,6 +330,74 @@ describe('convertMessages — empty base64 image guard', () => { }) }) +describe('buildAnthropicRequest — Claude Code system[] shape', () => { + // Anthropic rejects OAuth requests carrying Claude Code billing headers when + // third-party system content sits in system[] alongside the identity block. + // These cases pin the resulting shape: system[] holds only the billing header + // and the identity block, and Pi's prompt rides on the first user message. + async function buildBody(messages: Message[], systemPrompt?: string) { + const { body } = await buildAnthropicRequest( + 'claude-sonnet-4-20250514', + { messages, systemPrompt, tools: [] } as any, + undefined, + defaultCache, + ) + return body + } + + test('keeps system[] to the billing header and identity block', async () => { + const body = await buildBody([userMsg('hello')], 'PI PROMPT') + expect(body.system).toHaveLength(2) + expect(JSON.stringify(body.system)).not.toContain('PI PROMPT') + }) + + test('prepends the prompt to a string first user message', async () => { + const body = await buildBody([userMsg('hello')], 'PI PROMPT') + expect(body.messages[0]).toEqual({ + role: 'user', + content: 'PI PROMPT\n\nhello', + }) + }) + + test('prepends a text block when the first user message is structured', async () => { + const body = await buildBody( + [ + { + role: 'user', + content: [ + { type: 'text', text: 'see image' }, + { type: 'image', mimeType: 'image/png', data: 'aGVsbG8=' }, + ], + timestamp: 0, + } as Message, + ], + 'PI PROMPT', + ) + const content = body.messages[0]?.content as Array> + expect(content).toHaveLength(3) + expect(content[0]).toEqual({ type: 'text', text: 'PI PROMPT' }) + }) + + test('falls back to system[] when there is no user message', async () => { + const body = await buildBody([assistantMsg('only assistant')], 'PI PROMPT') + expect(body.system).toHaveLength(3) + // toMatchObject, not toEqual: the ephemeral cache anchor attaches to the + // last system entry, which in this fallback path is the prompt itself. + expect(body.system?.[2]).toMatchObject({ type: 'text', text: 'PI PROMPT' }) + }) + + test('leaves the identity block as the last system entry for cache anchoring', async () => { + const body = await buildBody([userMsg('hello')], 'PI PROMPT') + expect(String(body.system?.at(-1)?.text)).toContain('Claude Code') + }) + + test('leaves system[] and messages untouched when no prompt is set', async () => { + const body = await buildBody([userMsg('hello')]) + expect(body.system).toHaveLength(2) + expect(body.messages[0]).toEqual({ role: 'user', content: 'hello' }) + }) +}) + describe('buildAnthropicRequest — Fable/Mythos thinking', () => { test('maps Pi reasoning to output_config effort for Claude Fable 5', async () => { const { body } = await buildAnthropicRequest( diff --git a/packages/pi/src/tests/index.test.ts b/packages/pi/src/tests/index.test.ts index c6acf867..05c58f8c 100644 --- a/packages/pi/src/tests/index.test.ts +++ b/packages/pi/src/tests/index.test.ts @@ -44,4 +44,23 @@ describe('cortexKitPiAnthropicAuth provider registration', () => { maxTokens: 128_000, }) }) + + test('exposes Claude Opus 5 in the Pi Anthropic catalog', () => { + const { pi, providers } = mockPi() + + cortexKitPiAnthropicAuth(pi) + + const opus5 = providers + .get('anthropic') + ?.models?.find((model) => model.id === 'claude-opus-5') + expect(opus5).toMatchObject({ + id: 'claude-opus-5', + name: 'Claude Opus 5', + reasoning: true, + input: ['text', 'image'], + cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, + contextWindow: 1_000_000, + maxTokens: 128_000, + }) + }) }) From 9c0ec08d9e2fc82993b56a3950d7268516a98a5a Mon Sep 17 00:00:00 2001 From: unspecd-dev <315087515+unspecd-dev@users.noreply.github.com> Date: Wed, 12 Aug 2026 01:11:33 +0530 Subject: [PATCH 5/5] fix(pi): drop the system[] fallback for prompt relocation --- packages/pi/src/convert.ts | 7 +++++-- packages/pi/src/tests/convert.test.ts | 12 +++++++----- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/packages/pi/src/convert.ts b/packages/pi/src/convert.ts index fd6b0a3b..8cf65d77 100644 --- a/packages/pi/src/convert.ts +++ b/packages/pi/src/convert.ts @@ -403,9 +403,12 @@ export async function buildAnthropicRequest( firstUser.content = `${prompt}\n\n${content}` } else if (firstUser && Array.isArray(content)) { content.unshift({ type: 'text', text: prompt }) - } else { - system.push({ type: 'text', text: prompt }) } + // No else: with no user message, messages[] is necessarily empty + // (convertMessages emits only user/assistant, and trailing assistants are + // stripped above), so the request is already invalid. Pushing the prompt + // into system[] there would recreate the rejected three-entry shape for + // no benefit, so the prompt is dropped instead. } const body: AnthropicRequestBody = { diff --git a/packages/pi/src/tests/convert.test.ts b/packages/pi/src/tests/convert.test.ts index cb61b961..945dba82 100644 --- a/packages/pi/src/tests/convert.test.ts +++ b/packages/pi/src/tests/convert.test.ts @@ -378,12 +378,14 @@ describe('buildAnthropicRequest — Claude Code system[] shape', () => { expect(content[0]).toEqual({ type: 'text', text: 'PI PROMPT' }) }) - test('falls back to system[] when there is no user message', async () => { + test('drops the prompt when there is no user message to carry it', async () => { const body = await buildBody([assistantMsg('only assistant')], 'PI PROMPT') - expect(body.system).toHaveLength(3) - // toMatchObject, not toEqual: the ephemeral cache anchor attaches to the - // last system entry, which in this fallback path is the prompt itself. - expect(body.system?.[2]).toMatchObject({ type: 'text', text: 'PI PROMPT' }) + // convertMessages emits only user/assistant and trailing assistants are + // stripped, so a conversation with no user message converts to empty. + // system[] must stay at two entries even on this path. + expect(body.messages).toHaveLength(0) + expect(body.system).toHaveLength(2) + expect(JSON.stringify(body.system)).not.toContain('PI PROMPT') }) test('leaves the identity block as the last system entry for cache anchoring', async () => {