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. diff --git a/packages/pi/src/convert.ts b/packages/pi/src/convert.ts index f7a9fdd1..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,7 +396,49 @@ export async function buildAnthropicRequest( { type: 'text', text: CLAUDE_CODE_IDENTITY }, ] if (context.systemPrompt?.trim()) { - system.push({ type: 'text', text: sanitize(context.systemPrompt) }) + // 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, 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 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) + } + } } const body: AnthropicRequestBody = { 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', diff --git a/packages/pi/src/tests/convert.test.ts b/packages/pi/src/tests/convert.test.ts index 148731c7..d0ed84ba 100644 --- a/packages/pi/src/tests/convert.test.ts +++ b/packages/pi/src/tests/convert.test.ts @@ -33,10 +33,22 @@ function toolResultMsg(toolCallId: string, text: string): Message { const defaultCache = { enabled: false, mode: 'hybrid' as const } -async function buildMessages(messages: Message[]) { +// 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 = { messages, - systemPrompt: 'test', + systemPrompt, tools: [], } const { body } = await buildAnthropicRequest( @@ -326,6 +338,93 @@ describe('convertMessages — empty base64 image guard', () => { }) }) +describe('buildAnthropicRequest — Claude Code system[] shape', () => { + // 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', + { messages, systemPrompt, tools: [] } as any, + undefined, + defaultCache, + ) + return body + } + + 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 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('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( + [ + { + 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(String(content[0]?.text)).toContain('MOVE THIS') + expect(content[1]).toMatchObject({ type: 'text', text: 'see image' }) + }) + + 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. 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(3) + expect(JSON.stringify(body.system)).not.toContain('MOVE THIS') + }) + + 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 () => { + 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, + }) + }) })