From aecf30521a52b1236ae90a9ac682c0a13c7727b8 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/7] 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 6e68cde32ee5f5c51cf43d0b2bf98b3f84f593d4 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/7] 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 f7fce8e9fea8b22ebb2103dce9705bc3cc047fd3 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/7] 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 fa2337727bead061afff7439442ab401d612474c 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/7] 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 5086572803170481b83b6436f6cc7f5adde0d4f9 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/7] 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 () => { From aaf4c920d177b7e4df18f04cf479c4154b440361 Mon Sep 17 00:00:00 2001 From: unspecd-dev <315087515+unspecd-dev@users.noreply.github.com> Date: Sat, 15 Aug 2026 19:31:22 +0530 Subject: [PATCH 6/7] fix(pi): carry the system prompt as a cacheable system message 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. --- packages/pi/src/convert.ts | 40 +++++++++++++++++---------- packages/pi/src/tests/convert.test.ts | 37 ++++++++++++++++--------- 2 files changed, 50 insertions(+), 27 deletions(-) diff --git a/packages/pi/src/convert.ts b/packages/pi/src/convert.ts index 8cf65d77..f505e1f4 100644 --- a/packages/pi/src/convert.ts +++ b/packages/pi/src/convert.ts @@ -391,24 +391,36 @@ export async function buildAnthropicRequest( { type: 'text', text: CLAUDE_CODE_IDENTITY }, ] if (context.systemPrompt?.trim()) { - // 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. + // Pi's prompt cannot sit in the top-level system[] array: two lines of its + // documentation paragraph (the docs/*.md enumeration and the "follow .md + // cross-references" instruction) are each independently sufficient to make + // Anthropic reject the request with 400 "You're out of extra usage". Entry + // count and payload size are ruled out — 2697 bytes of neutral filler in the + // same position is accepted. The same text is accepted inside messages[], so + // carry the prompt as a role: "system" message and keep system[] to the + // billing header and identity block. + // + // cache_control is set here rather than left to addEphemeralCacheControl, + // whose message-level breakpoint only fires when a message's content is an + // array — Pi sends plain strings, so it never fires. Without this marker the + // prompt sits outside the cached prefix and is reprocessed every turn. 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 }) + const firstUserIndex = messages.findIndex((m) => m.role === 'user') + if (firstUserIndex !== -1) { + messages.splice(firstUserIndex + 1, 0, { + role: 'system', + content: [ + { + type: 'text', + text: prompt, + cache_control: { type: 'ephemeral' }, + }, + ], + }) } // 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. + // stripped above), so the request is already invalid. } const body: AnthropicRequestBody = { diff --git a/packages/pi/src/tests/convert.test.ts b/packages/pi/src/tests/convert.test.ts index 945dba82..a37067d2 100644 --- a/packages/pi/src/tests/convert.test.ts +++ b/packages/pi/src/tests/convert.test.ts @@ -33,8 +33,8 @@ function toolResultMsg(toolCallId: string, text: string): Message { const defaultCache = { enabled: false, mode: 'hybrid' as const } -// systemPrompt is opt-in. buildAnthropicRequest relocates a non-empty prompt -// onto the first user message, so tests that assert raw conversion output pass +// systemPrompt is opt-in. buildAnthropicRequest carries a non-empty prompt as a +// separate role: "system" 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) { @@ -331,10 +331,10 @@ 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. + // Pi's prompt cannot sit in the top-level system[] array — see the note in + // convert.ts. These cases pin the resulting shape: system[] holds only the + // billing header and the identity block, and Pi's prompt is carried as a + // role: "system" message immediately after the first user message. async function buildBody(messages: Message[], systemPrompt?: string) { const { body } = await buildAnthropicRequest( 'claude-sonnet-4-20250514', @@ -351,15 +351,25 @@ describe('buildAnthropicRequest — Claude Code system[] shape', () => { expect(JSON.stringify(body.system)).not.toContain('PI PROMPT') }) - test('prepends the prompt to a string first user message', async () => { + test('carries the prompt as a system message after the first user message', async () => { const body = await buildBody([userMsg('hello')], 'PI PROMPT') - expect(body.messages[0]).toEqual({ - role: 'user', - content: 'PI PROMPT\n\nhello', + expect(body.messages[0]).toEqual({ role: 'user', content: 'hello' }) + expect(body.messages[1]).toEqual({ + role: 'system', + content: [ + { + type: 'text', + text: 'PI PROMPT', + // Set explicitly: addEphemeralCacheControl's message-level breakpoint + // only fires for array content on the last user message, which Pi's + // plain-string messages never satisfy. + cache_control: { type: 'ephemeral' }, + }, + ], }) }) - test('prepends a text block when the first user message is structured', async () => { + test('leaves a structured first user message untouched', async () => { const body = await buildBody( [ { @@ -374,8 +384,9 @@ describe('buildAnthropicRequest — Claude Code system[] shape', () => { 'PI PROMPT', ) const content = body.messages[0]?.content as Array> - expect(content).toHaveLength(3) - expect(content[0]).toEqual({ type: 'text', text: 'PI PROMPT' }) + expect(content).toHaveLength(2) + expect(content[0]).toMatchObject({ type: 'text', text: 'see image' }) + expect(body.messages[1]).toMatchObject({ role: 'system' }) }) test('drops the prompt when there is no user message to carry it', async () => { From 849baf1783aef6e1e6cde9292271dfc55ab1d5b1 Mon Sep 17 00:00:00 2001 From: unspecd-dev <315087515+unspecd-dev@users.noreply.github.com> Date: Sun, 16 Aug 2026 19:24:14 +0530 Subject: [PATCH 7/7] fix(pi): split the prompt, cache the documentation paragraph ahead of user text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/pi/src/convert.ts | 68 ++++++++++++------- packages/pi/src/tests/convert.test.ts | 96 +++++++++++++++------------ 2 files changed, 98 insertions(+), 66 deletions(-) diff --git a/packages/pi/src/convert.ts b/packages/pi/src/convert.ts index f505e1f4..3ab61dfe 100644 --- a/packages/pi/src/convert.ts +++ b/packages/pi/src/convert.ts @@ -27,6 +27,11 @@ import type { ToolResultMessage, } from '@earendil-works/pi-ai' +// Anchor identifying Pi's documentation paragraph — the only part of the prompt +// that Anthropic rejects in system[]. If pi upstream renames this heading the +// split stops separating and the whole prompt returns to system[], which 400s. +const PI_DOCS_ANCHOR = 'Pi documentation' + const CLAUDE_CODE_TOOLS = new Map( [ 'Read', @@ -391,36 +396,49 @@ export async function buildAnthropicRequest( { type: 'text', text: CLAUDE_CODE_IDENTITY }, ] if (context.systemPrompt?.trim()) { - // Pi's prompt cannot sit in the top-level system[] array: two lines of its - // documentation paragraph (the docs/*.md enumeration and the "follow .md + // Pi's prompt cannot sit whole in the top-level system[] array: two lines of + // its documentation paragraph (the docs/*.md enumeration and the "follow .md // cross-references" instruction) are each independently sufficient to make // Anthropic reject the request with 400 "You're out of extra usage". Entry // count and payload size are ruled out — 2697 bytes of neutral filler in the - // same position is accepted. The same text is accepted inside messages[], so - // carry the prompt as a role: "system" message and keep system[] to the - // billing header and identity block. + // same position is accepted, and the same text is accepted inside messages[]. + // + // Keep the identity, tool contract and guidelines in system[], where they + // carry system weight and survive context compaction, and carry only the + // documentation paragraph in messages[]. + // + // It goes in as its own content block ahead of the user's text, not merged + // into it and not as a separate message. A cache prefix matches contiguously + // from the start of the request, so a block boundary here lets the prefix end + // before the user's words: a new conversation with a different first message + // still reads the paragraph from cache instead of re-writing ~1.1k tokens. + // A role: "system" message cannot be used at messages[0] — Anthropic rejects + // that — and placing one after the first user message puts it behind content + // that varies, which defeats the caching. // - // cache_control is set here rather than left to addEphemeralCacheControl, - // whose message-level breakpoint only fires when a message's content is an - // array — Pi sends plain strings, so it never fires. Without this marker the - // prompt sits outside the cached prefix and is reprocessed every turn. - const prompt = sanitize(context.systemPrompt) - const firstUserIndex = messages.findIndex((m) => m.role === 'user') - if (firstUserIndex !== -1) { - messages.splice(firstUserIndex + 1, 0, { - role: 'system', - content: [ - { - type: 'text', - text: prompt, - cache_control: { type: 'ephemeral' }, - }, - ], - }) + // cache_control is set explicitly because addEphemeralCacheControl's + // message-level breakpoint only fires for array content on the *last* user + // message, which is not this one after the first turn. + const paras = sanitize(context.systemPrompt).split(/\n\n+/) + const keep = paras.filter((p) => !p.includes(PI_DOCS_ANCHOR)) + const docs = paras.filter((p) => p.includes(PI_DOCS_ANCHOR)) + if (keep.length) { + system.push({ type: 'text', text: keep.join('\n\n') }) + } + if (docs.length) { + const firstUser = messages.find((m) => m.role === 'user') + const content = firstUser?.content + const docsBlock = { + type: 'text', + text: docs.join('\n\n'), + cache_control: { type: 'ephemeral' as const }, + } + if (firstUser && typeof content === 'string') { + firstUser.content = [docsBlock, { type: 'text', text: content }] + } else if (firstUser && Array.isArray(content)) { + content.unshift(docsBlock) + } } - // 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. } const body: AnthropicRequestBody = { diff --git a/packages/pi/src/tests/convert.test.ts b/packages/pi/src/tests/convert.test.ts index a37067d2..d0ed84ba 100644 --- a/packages/pi/src/tests/convert.test.ts +++ b/packages/pi/src/tests/convert.test.ts @@ -33,9 +33,17 @@ function toolResultMsg(toolCallId: string, text: string): Message { const defaultCache = { enabled: false, mode: 'hybrid' as const } -// systemPrompt is opt-in. buildAnthropicRequest carries a non-empty prompt as a -// separate role: "system" message, so tests that assert raw conversion output pass -// no prompt and observe messages unchanged. The relocation itself is covered by +// Mirrors the shape of Pi's real prompt: instruction paragraphs followed by the +// documentation paragraph, which is the only part Anthropic rejects in system[]. +const PI_PROMPT = [ + 'KEEP ONE: you are an assistant.', + 'KEEP TWO: available tools.', + 'Pi documentation (read only when the user asks about pi itself):\n- MOVE THIS', +].join('\n\n') + +// systemPrompt is opt-in. buildAnthropicRequest splits a non-empty prompt between +// system[] and the first user message, so tests that assert raw conversion output +// pass no prompt and observe messages unchanged. The split itself is covered by // the "Claude Code system[] shape" block below. async function buildMessages(messages: Message[], systemPrompt?: string) { const context = { @@ -331,10 +339,11 @@ describe('convertMessages — empty base64 image guard', () => { }) describe('buildAnthropicRequest — Claude Code system[] shape', () => { - // Pi's prompt cannot sit in the top-level system[] array — see the note in - // convert.ts. These cases pin the resulting shape: system[] holds only the - // billing header and the identity block, and Pi's prompt is carried as a - // role: "system" message immediately after the first user message. + // Anthropic rejects Pi's documentation paragraph inside the top-level + // system[] array — see the note in convert.ts. These cases pin the resulting + // shape: the remaining paragraphs stay in system[], and the documentation + // paragraph is carried as its own marked content block ahead of the user's + // text inside the first user message. async function buildBody(messages: Message[], systemPrompt?: string) { const { body } = await buildAnthropicRequest( 'claude-sonnet-4-20250514', @@ -345,31 +354,33 @@ describe('buildAnthropicRequest — Claude Code system[] shape', () => { 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('keeps the non-documentation paragraphs in system[]', async () => { + const body = await buildBody([userMsg('hello')], PI_PROMPT) + expect(body.system).toHaveLength(3) + const text = String(body.system?.[2]?.text) + expect(text).toContain('KEEP ONE') + expect(text).toContain('KEEP TWO') + expect(text).not.toContain('MOVE THIS') }) - test('carries the prompt as a system message after the first user message', async () => { - const body = await buildBody([userMsg('hello')], 'PI PROMPT') - expect(body.messages[0]).toEqual({ role: 'user', content: 'hello' }) - expect(body.messages[1]).toEqual({ - role: 'system', - content: [ - { - type: 'text', - text: 'PI PROMPT', - // Set explicitly: addEphemeralCacheControl's message-level breakpoint - // only fires for array content on the last user message, which Pi's - // plain-string messages never satisfy. - cache_control: { type: 'ephemeral' }, - }, - ], - }) + test('carries the documentation paragraph as its own block ahead of the user text', async () => { + const body = await buildBody([userMsg('hello')], PI_PROMPT) + const content = body.messages[0]?.content as Array> + expect(content).toHaveLength(2) + expect(String(content[0]?.text)).toContain('MOVE THIS') + expect(content[1]).toMatchObject({ type: 'text', text: 'hello' }) }) - test('leaves a structured first user message untouched', async () => { + test('marks the documentation block so the cached prefix ends before the user text', async () => { + // Without this marker the prefix would extend into the user's own words, + // and a new conversation with a different first message would have to + // re-cache the paragraph. + const body = await buildBody([userMsg('hello')], PI_PROMPT) + const content = body.messages[0]?.content as Array> + expect(content[0]?.cache_control).toEqual({ type: 'ephemeral' }) + }) + + test('unshifts the documentation block onto a structured first user message', async () => { const body = await buildBody( [ { @@ -381,27 +392,30 @@ describe('buildAnthropicRequest — Claude Code system[] shape', () => { timestamp: 0, } as Message, ], - 'PI PROMPT', + PI_PROMPT, ) const content = body.messages[0]?.content as Array> - expect(content).toHaveLength(2) - expect(content[0]).toMatchObject({ type: 'text', text: 'see image' }) - expect(body.messages[1]).toMatchObject({ role: 'system' }) + expect(content).toHaveLength(3) + expect(String(content[0]?.text)).toContain('MOVE THIS') + expect(content[1]).toMatchObject({ type: 'text', text: 'see image' }) }) - test('drops the prompt when there is no user message to carry it', async () => { - const body = await buildBody([assistantMsg('only assistant')], 'PI PROMPT') + test('drops the documentation paragraph when there is no user message to carry it', async () => { // 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. + // stripped, so a conversation with no user message converts to empty. The + // remaining paragraphs still go to system[], which is a shape Anthropic + // accepts; only the documentation paragraph is dropped. + const body = await buildBody([assistantMsg('only assistant')], PI_PROMPT) expect(body.messages).toHaveLength(0) - expect(body.system).toHaveLength(2) - expect(JSON.stringify(body.system)).not.toContain('PI PROMPT') + expect(body.system).toHaveLength(3) + expect(JSON.stringify(body.system)).not.toContain('MOVE THIS') }) - 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 the whole prompt in system[] when it has no documentation paragraph', async () => { + const body = await buildBody([userMsg('hello')], 'KEEP ONLY') + expect(body.system).toHaveLength(3) + expect(String(body.system?.[2]?.text)).toContain('KEEP ONLY') + expect(body.messages[0]).toEqual({ role: 'user', content: 'hello' }) }) test('leaves system[] and messages untouched when no prompt is set', async () => {