Skip to content
2 changes: 1 addition & 1 deletion packages/pi/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
49 changes: 48 additions & 1 deletion packages/pi/src/convert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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 = {
Expand Down
9 changes: 9 additions & 0 deletions packages/pi/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
103 changes: 101 additions & 2 deletions packages/pi/src/tests/convert.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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<Record<string, unknown>>
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<Record<string, unknown>>
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<Record<string, unknown>>
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(
Expand Down
19 changes: 19 additions & 0 deletions packages/pi/src/tests/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
})
})