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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ This repo is a CortexKit-maintained Anthropic auth monorepo for OpenCode and Pi.

### Patch Changes

- Read the Pi system prompt and tool declarations from the normalized transcript that Pi 0.86 hands `streamSimple` (`role: "system"` messages, via pi-ai's `getCurrentSystemPrompt` / `getCurrentTools`), so requests on Pi >= 0.86 carry the host prompt and tools again instead of sending neither; raw `Context` fields still win on older Pi (#244).
- Enroll newly bound Claustrum OAuth accounts into the OpenCode routing pool at startup or live without a restart, after exact credential-ID and provider-account verification; immediately prime quota for sticky-balanced routing, persist only secret-free tombstone rows, preserve disabled accounts, and recover missed manifest watch events with one process-shared metadata poll.
- Remove obsolete root-level build output before workspace builds so stale pre-custody CLI artifacts cannot bypass current account and Claustrum safeguards.
- Give consecutive Desktop fallback notices distinct, pre-registered message IDs before the assistant; defer delivery when safe ordering is unavailable and bound notice tracking across sessions (#230).
Expand Down
132 changes: 128 additions & 4 deletions packages/pi/src/convert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,129 @@ import type {
Tool,
ToolResultMessage,
} from '@earendil-works/pi-ai'
import * as piAi from '@earendil-works/pi-ai'

/**
* What `streamSimple` is handed, across pi versions.
*
* pi < 0.86 passes a raw `Context`: `systemPrompt` and `tools` populated, no
* `role: 'system'` messages. pi >= 0.86 passes a normalized `TranscriptContext`
* (`{ messages }` only): the prompt and the tool declarations are folded into a
* leading system message (`content` + `toolsAdded`), and later system messages
* may carry `toolsAdded` / `toolsRemoved` / `sections` deltas. Reading
* `context.systemPrompt` / `context.tools` on that shape yields `undefined` for
* both, so the request goes out with no host prompt and no tools — HTTP 200 and
* a reply from a bare model that says it has no bash tool.
*/
export type RequestContext = {
messages: Message[]
systemPrompt?: unknown
tools?: Tool[]
}

export type ResolvedRequestContext = {
systemPrompt: unknown
tools: Tool[]
messages: Message[]
}

type TranscriptSystemMessage = {
role: 'system'
content?: unknown
toolsAdded?: Tool[]
toolsRemoved?: { name: string }[]
}

function isTranscriptSystemMessage(
message: unknown,
): message is TranscriptSystemMessage {
return (
typeof message === 'object' &&
message !== null &&
(message as { role?: unknown }).role === 'system'
)
}

/**
* Resolve the host prompt, tool list, and message list from either context shape.
*
* On pi >= 0.86 the transcript helpers exported by pi-ai (`collapseSystemMessages`,
* `getCurrentSystemPrompt`, `getCurrentTools`) replay every system message into
* the current prompt and tool set; they are the same functions pi's built-in
* providers use, so `sections` patches and tool removals resolve identically.
* They are reached through the namespace import and a `typeof` check rather
* than named imports: on pi-ai < 0.86 the exports do not exist, and a missing
* named export fails the whole extension at load.
*
* If a transcript carries system messages but the helpers are absent (only
* reachable in tests pinned to an older pi-ai), a minimal replay keeps the
* prompt and tools rather than dropping them: system text concatenated in
* order, `toolsRemoved` then `toolsAdded` applied per message.
*
* The raw `Context` fields win when there is no system message, so pi < 0.86
* is unchanged.
*/
/**
* The pi-ai transcript helpers, as an optional set. Production reads them off
* the `@earendil-works/pi-ai` namespace, which pi's extension loader aliases to
* the host's own bundled pi-ai — so their presence tracks the running pi, not
* this package's lockfile. Tests pass a stub (or `{}`) to pin which branch runs.
*/
export type TranscriptHelpers = {
collapseSystemMessages?: (context: { messages: Message[] }) => {
messages: Message[]
}
getCurrentSystemPrompt?: (messages: readonly { role: string }[]) => string
getCurrentTools?: (messages: readonly { role: string }[]) => Tool[]
}

export function resolveRequestContext(
context: RequestContext,
helpers: TranscriptHelpers = piAi as unknown as TranscriptHelpers,
): ResolvedRequestContext {
const hasSystemMessages = context.messages.some(isTranscriptSystemMessage)
if (!hasSystemMessages) {
return {
systemPrompt: context.systemPrompt,
tools: context.tools ?? [],
messages: context.messages,
}
}

if (
typeof helpers.collapseSystemMessages === 'function' &&
typeof helpers.getCurrentSystemPrompt === 'function' &&
typeof helpers.getCurrentTools === 'function'
) {
const transcript = helpers.collapseSystemMessages({
messages: context.messages,
})
return {
systemPrompt: helpers.getCurrentSystemPrompt(transcript.messages),
tools: helpers.getCurrentTools(transcript.messages),
messages: transcript.messages.filter(
(message) => !isTranscriptSystemMessage(message),
),
}
}

const parts: string[] = []
const tools = new Map<string, Tool>()
for (const message of context.messages as unknown[]) {
if (!isTranscriptSystemMessage(message)) continue
const text = systemPromptText(message.content)
if (text.length > 0) parts.push(text)
for (const removed of message.toolsRemoved ?? []) tools.delete(removed.name)
for (const added of message.toolsAdded ?? []) tools.set(added.name, added)
}
return {
systemPrompt: parts.join('\n\n'),
tools: [...tools.values()],
messages: context.messages.filter(
(message) => !isTranscriptSystemMessage(message),
),
}
}

// Anchor identifying Pi's documentation paragraph — the only part of the prompt
// that Anthropic currently rejects in system[]. Unknown prompt shapes take the
Expand Down Expand Up @@ -545,7 +668,7 @@ function applyCacheMode(

export async function buildAnthropicRequest(
modelId: string,
context: Context,
context: Context | RequestContext,
options: SimpleStreamOptions | undefined,
cache: { enabled: boolean; mode: Cache1hMode },
fastModeEnabled = false,
Expand All @@ -555,7 +678,8 @@ export async function buildAnthropicRequest(
thinkingPrefixMismatchBehavior?: ThinkingPrefixMismatchBehavior
} = {},
): Promise<{ body: AnthropicRequestBody; bodyText: string }> {
const messages = convertMessages(context.messages, modelId)
const request = resolveRequestContext(context)
const messages = convertMessages(request.messages, modelId)
// Strip trailing assistant messages — Anthropic rejects prefill on some models
while (
messages.length &&
Expand All @@ -577,7 +701,7 @@ export async function buildAnthropicRequest(
},
{ type: 'text', text: CLAUDE_CODE_IDENTITY },
]
const systemPrompt = systemPromptText(context.systemPrompt)
const systemPrompt = systemPromptText(request.systemPrompt)
if (systemPrompt.trim()) {
// 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
Expand Down Expand Up @@ -618,7 +742,7 @@ export async function buildAnthropicRequest(
messages,
}

const tools = convertTools(context.tools)
const tools = convertTools(request.tools)
if (tools?.length) body.tools = tools

if (fastModeEnabled && isFastModeSupportedModel(modelId)) {
Expand Down
18 changes: 13 additions & 5 deletions packages/pi/src/stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,12 @@ import {
type ToolCall,
} from '@earendil-works/pi-ai'

import { buildAnthropicRequest, fromClaudeCodeToolName } from './convert.ts'
import {
buildAnthropicRequest,
fromClaudeCodeToolName,
type RequestContext,
resolveRequestContext,
} from './convert.ts'
import { getPiAccountStoragePath } from './paths.ts'

function errorText(error: unknown) {
Expand Down Expand Up @@ -359,7 +364,7 @@ export async function* parseSse(

async function sendAnthropicRequest(options: {
model: Model<Api>
context: Context
context: Context | RequestContext
streamOptions?: SimpleStreamOptions
accessToken?: string
apiAccount?: ApiKeyAccount
Expand Down Expand Up @@ -525,7 +530,7 @@ async function firstStreamingError(

async function executeWithFallback(options: {
model: Model<Api>
context: Context
context: Context | RequestContext
streamOptions?: SimpleStreamOptions
primaryAccessToken: string
storagePath: string
Expand Down Expand Up @@ -1191,7 +1196,7 @@ async function executeWithFallback(options: {

export function streamCortexKitAnthropic(
model: Model<Api>,
context: Context,
context: Context | RequestContext,
options?: SimpleStreamOptions,
effortTransitions?: readonly MidConversationEffortTransition[],
): AssistantMessageEventStream {
Expand All @@ -1205,6 +1210,9 @@ export function streamCortexKitAnthropic(
const accessToken = options?.apiKey ?? ''
if (!accessToken) throw new Error('Missing Anthropic OAuth access token')

// pi >= 0.86 carries the tool declarations in the transcript's system
// messages, not on `context.tools` (see resolveRequestContext).
const { tools: contextTools } = resolveRequestContext(context)
const storagePath = getPiAccountStoragePath()
const response = await executeWithFallback({
model,
Expand Down Expand Up @@ -1267,7 +1275,7 @@ export function streamCortexKitAnthropic(
output.content.push({
type: 'toolCall',
id: String(block.id),
name: fromClaudeCodeToolName(String(block.name), context.tools),
name: fromClaudeCodeToolName(String(block.name), contextTools),
arguments: {},
partialJson: '',
index: event.index,
Expand Down
146 changes: 145 additions & 1 deletion packages/pi/src/tests/convert.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
type ProviderAccountUuid,
} from '@cortexkit/anthropic-auth-core'
import type { Context, Message } from '@earendil-works/pi-ai'
import { buildAnthropicRequest } from '../convert'
import { buildAnthropicRequest, resolveRequestContext } from '../convert'

function userMsg(text: string): Message {
return { role: 'user', content: text, timestamp: 0 }
Expand Down Expand Up @@ -1242,3 +1242,147 @@ describe('buildAnthropicRequest — cache breakpoint budget', () => {
},
)
})

// pi >= 0.86 hands streamSimple a normalized TranscriptContext: `{ messages }`
// only, with the host prompt and tool declarations folded into a leading
// role: 'system' message and later system messages carrying tool deltas.
describe('resolveRequestContext — pi 0.86 transcript shape', () => {
const readTool = {
name: 'read',
description: 'Read a file',
parameters: { type: 'object', properties: {}, required: [] },
} as any
const bashTool = {
name: 'bash',
description: 'Run a command',
parameters: { type: 'object', properties: {}, required: [] },
} as any

function systemMsg(
content: string,
extra: Record<string, unknown> = {},
): Message {
return { role: 'system', content, timestamp: 0, ...extra } as any
}

test('raw Context (pi < 0.86) is returned unchanged', () => {
const context = {
messages: [userMsg('hello')],
systemPrompt: 'test prompt',
tools: [readTool],
}
const resolved = resolveRequestContext(context as any)
expect(resolved.systemPrompt).toBe('test prompt')
expect(resolved.tools).toEqual([readTool])
expect(resolved.messages).toBe(context.messages)
})

test('reads the prompt and tools out of the leading system message', () => {
const resolved = resolveRequestContext({
messages: [
systemMsg('test prompt', { toolsAdded: [readTool] }),
userMsg('hello'),
],
})
expect(resolved.systemPrompt).toBe('test prompt')
expect(resolved.tools).toEqual([readTool])
expect(resolved.messages).toEqual([userMsg('hello')])
})

test('replays later system messages: appended text, tool additions and removals', () => {
const resolved = resolveRequestContext({
messages: [
systemMsg('base', { toolsAdded: [readTool, bashTool] }),
userMsg('hello'),
assistantMsg('hi'),
systemMsg('more', { toolsRemoved: [{ name: 'read' }] }),
userMsg('again'),
],
})
expect(resolved.systemPrompt).toBe('base\n\nmore')
expect(resolved.tools.map((tool) => tool.name)).toEqual(['bash'])
expect(resolved.messages.map((message) => message.role)).toEqual([
'user',
'assistant',
'user',
])
})

// Which branch runs is decided by the host pi (its loader aliases pi-ai to the
// host's bundle), not by this package's lockfile, so both are pinned here with
// explicit helper sets rather than left to whatever pi-ai bun installed.
test('uses the pi-ai transcript helpers when the host exports them', () => {
const calls: string[] = []
const helpers = {
collapseSystemMessages: (context: { messages: Message[] }) => {
calls.push('collapse')
return {
messages: [
systemMsg('from helpers', { toolsAdded: [bashTool] }),
...context.messages.filter(
(message) => (message as { role: string }).role !== 'system',
),
],
}
},
getCurrentSystemPrompt: () => {
calls.push('prompt')
return 'from helpers (rendered)'
},
getCurrentTools: () => {
calls.push('tools')
return [bashTool]
},
}
const resolved = resolveRequestContext(
{
messages: [
systemMsg('ignored by the stub', { toolsAdded: [readTool] }),
userMsg('hello'),
],
},
helpers,
)
expect(calls).toEqual(['collapse', 'prompt', 'tools'])
expect(resolved.systemPrompt).toBe('from helpers (rendered)')
expect(resolved.tools).toEqual([bashTool])
expect(resolved.messages).toEqual([userMsg('hello')])
})

test('falls back to the local replay when the host has no helpers', () => {
const resolved = resolveRequestContext(
{
messages: [
systemMsg('base', { toolsAdded: [readTool] }),
userMsg('hello'),
systemMsg('more', { toolsAdded: [bashTool] }),
],
},
{},
)
expect(resolved.systemPrompt).toBe('base\n\nmore')
expect(resolved.tools.map((tool) => tool.name)).toEqual(['read', 'bash'])
})

test('buildAnthropicRequest sends the prompt and tools from a transcript context', async () => {
const { body } = await buildAnthropicRequest(
TEST_MODEL_ID,
{
messages: [
systemMsg(PI_PROMPT, { toolsAdded: [bashTool] }),
userMsg('hello'),
],
},
undefined,
defaultCache,
)
// system[] = billing header, identity, then the recognized host prompt.
expect(body.system).toHaveLength(3)
expect(String(body.system?.[2]?.text)).toContain('KEEP ONE')
expect(body.tools?.map((tool) => tool.name)).toEqual(['Bash'])
// The system message itself never reaches messages[].
expect(body.messages.every((message) => message.role !== 'system')).toBe(
true,
)
})
})
Loading