Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
19 changes: 18 additions & 1 deletion packages/pi/src/convert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -391,7 +391,24 @@ 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 })
}
// 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 = {
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
78 changes: 76 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,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(
Expand Down Expand Up @@ -326,6 +330,76 @@ 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<Record<string, unknown>>
expect(content).toHaveLength(3)
expect(content[0]).toEqual({ type: 'text', text: 'PI PROMPT' })
})

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(
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,
})
})
})