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..f505e1f4 100644 --- a/packages/pi/src/convert.ts +++ b/packages/pi/src/convert.ts @@ -391,7 +391,36 @@ 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 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 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. } 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..a37067d2 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 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) { const context = { messages, - systemPrompt: 'test', + systemPrompt, tools: [], } const { body } = await buildAnthropicRequest( @@ -326,6 +330,87 @@ 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. + 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('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('leaves a structured first user message untouched', 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(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 () => { + const body = await buildBody([assistantMsg('only assistant')], '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 () => { + 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, + }) + }) })