@cortexkit/pi-anthropic-auth 1.21.0 → 1.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,10 +2,20 @@
2
2
 
3
3
  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.
4
4
 
5
- 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.
5
+ The Pi provider catalog includes Claude Fable 5 (`claude-fable-5`) and 5.1 (`claude-fable-5-1`), limited-access Claude Mythos 5 (`claude-mythos-5`) and 5.1 (`claude-mythos-5-1`), Claude Opus 5 (`claude-opus-5`), Claude Opus 5.5 (`claude-opus-5-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. Opus 5.5 features always-on adaptive thinking with summarized display. OAuth Fable 5.1 sessions preserve Pi thinking-level changes as mid-conversation effort markers, so switching effort does not rewrite the cached prefix. Replayed signed/redacted thinking follows the configured prefix-mismatch behavior.
6
6
 
7
7
  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.
8
8
 
9
+ ## Setup wizard (recommended)
10
+
11
+ Use the unified setup wizard to install the extension and optionally configure Claustrum custody:
12
+
13
+ ```bash
14
+ bunx @cortexkit/opencode-anthropic-auth setup
15
+ ```
16
+
17
+ ---
18
+
9
19
  ## Install
10
20
 
11
21
  Install with Pi's package manager:
@@ -42,7 +52,7 @@ Pi state is stored separately from OpenCode at:
42
52
 
43
53
  Override the path with `PI_ANTHROPIC_AUTH_FILE`. The package also respects `PI_AGENT_DIR` when deriving the default sidecar path.
44
54
 
45
- The sidecar uses the same JSON shape as the OpenCode package, including `routing`, `claudeCache`, `cacheKeep`, `prime`, `claudeFast`, `dump`, `relay`, and fallback `accounts` blocks. Runtime OAuth/quota state is stored in `anthropic-auth-state.json`; sticky session assignments are stored separately in `anthropic-auth-routing-state.json` with SHA-256-hashed session IDs.
55
+ The sidecar uses the same JSON shape as the OpenCode package, including `routing`, `claudeCache`, `cacheKeep`, `prime`, `claudeFast`, `thinkingBinding`, `dump`, `relay`, and fallback `accounts` blocks. `thinkingBinding.prefixMismatchBehavior` accepts `account-default` (default), `error`, or `drop_block` for OAuth Fable 5.1 replay. Runtime OAuth/quota state is stored in `anthropic-auth-state.json`; sticky session assignments are stored separately in `anthropic-auth-routing-state.json` with SHA-256-hashed session IDs.
46
56
 
47
57
  ## Commands
48
58
 
@@ -77,10 +87,13 @@ The sidecar uses the same JSON shape as the OpenCode package, including `routing
77
87
  /claude-routing sticky-balanced
78
88
  /claude-routing reset
79
89
 
90
+ /claude-account
91
+ /claude-account reset-backoff
92
+
80
93
  /claude-quota
81
94
  ```
82
95
 
83
- `/claude-quota` reports sidecar OAuth fallback quota state from `~/.pi/agent/anthropic-auth.json`. `/claude-routing fallback-first` prefers usable OAuth fallback accounts before the main account; `/claude-routing main-first` restores the default. `/claude-routing sticky-balanced` assigns each Pi session to an OAuth account according to current 5-hour, 7-day, and matching model-scoped quota headroom, then persists that assignment across transient failures and process restarts. `/claude-routing reset` clears the current Pi session's assignment. Direct Opus sessions prefer usable accounts whose Fable quota is exhausted. API-key routes use the same sidecar shape as OpenCode and are sent directly to their configured Anthropic-compatible base URL, such as Kie's `https://api.kie.ai/claude`, but Pi only uses them after the main OAuth model response reports HTTP 429 or a streaming rate-limit error and a live quota check confirms 0% remaining. `/claude-cachekeep always` keeps active hybrid caches warm while Pi remains open; `/claude-cachekeep HH-HH` limits prewarms to a local time window. Both send `max_tokens: 0` pre-warm requests about five minutes before the 1-hour TTL expires. Running `/claude-cachekeep` without arguments lists live tracked sessions across Pi processes through a temporary lease registry that stores only session IDs and cache timing. `/claude-fast on` adds Anthropic `speed: "fast"` plus the `fast-mode-2026-02-01` beta header for supported Opus models (`claude-opus-4-6`, `claude-opus-4-7`, `claude-opus-4-8`, and `claude-opus-5`).
96
+ `/claude-account reset-backoff` clears the main OAuth refresh backoff and its matching quota backoff. Persisted refresh backoffs are bound to the refresh token that produced them, so replacing a rejected credential immediately escapes its stale latch. `/claude-quota` reports sidecar OAuth fallback quota state from `~/.pi/agent/anthropic-auth.json`. `/claude-routing fallback-first` prefers usable OAuth fallback accounts before the main account; `/claude-routing main-first` restores the default. `/claude-routing sticky-balanced` assigns each Pi session to an OAuth account according to current 5-hour, 7-day, and matching model-scoped quota headroom, then persists that assignment across transient failures and process restarts. Changing the session model discards the old assignment before quota-based reselection. `/claude-routing reset` clears the current Pi session's assignment. Direct Opus sessions prefer usable accounts whose Fable quota is exhausted. API-key routes use the same sidecar shape as OpenCode and are sent directly to their configured Anthropic-compatible base URL, such as Kie's `https://api.kie.ai/claude`, but Pi only uses them after the main OAuth model response reports HTTP 429 or a streaming rate-limit error and a live quota check confirms 0% remaining. `/claude-cachekeep always` keeps active hybrid caches warm while Pi remains open; `/claude-cachekeep HH-HH` limits prewarms to a local time window. Both send `max_tokens: 0` pre-warm requests about five minutes before the 1-hour TTL expires. Running `/claude-cachekeep` without arguments lists live tracked sessions across Pi processes through a temporary lease registry that stores only session IDs and cache timing. `/claude-fast on` adds Anthropic `speed: "fast"` plus the `fast-mode-2026-02-01` beta header for supported Opus models (`claude-opus-4-6`, `claude-opus-4-7`, `claude-opus-4-8`, `claude-opus-5`, and `claude-opus-5-5`).
84
97
 
85
98
  ### Claude quota window priming
86
99
 
@@ -92,6 +105,10 @@ Prime marker identities live in `anthropic-auth-state.json`. Plugin-owned refres
92
105
 
93
106
  Pi's `/claude-prime` command displays status only. The `on` and `off` arguments are ignored; toggling priming requires OpenCode.
94
107
 
108
+ ## Anthropic-compatible proxy overrides
109
+
110
+ API-key fallback routes honor `ANTHROPIC_CUSTOM_HEADERS`, `ANTHROPIC_MODEL`, and `ANTHROPIC_DEFAULT_{SONNET,OPUS,HAIKU,FABLE}_MODEL`. These variables never alter OAuth requests. Custom headers may add proxy metadata but cannot replace route authentication, Anthropic protocol headers, body framing, or internal correlation headers; a configuration containing an invalid or protected header is ignored as a whole. Versioned provider base paths are preserved without duplicating `/v1`.
111
+
95
112
  ## Relay
96
113
 
97
114
  The Pi package can use the same user-owned Cloudflare relay config as the OpenCode package. The relay setup helper currently lives in the OpenCode package CLI:
@@ -1,2 +1,3 @@
1
1
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
2
- export declare function registerCommands(pi: ExtensionAPI): void;
2
+ import type { PiCustodyCommands } from './custody.ts';
3
+ export declare function registerCommands(pi: ExtensionAPI, custody?: PiCustodyCommands): void;
package/dist/commands.js CHANGED
@@ -1,10 +1,10 @@
1
- import { buildClaudeQuotaSummary, buildFallbackQuotaSummaries, buildPrimeAccountStatuses, CLAUDE_ACCOUNT_COMMAND_NAME, CLAUDE_CACHE_KEEP_COMMAND_NAME, CLAUDE_PRIME_COMMAND_NAME, CLAUDE_ROUTING_COMMAND_NAME, createEmptyStorage, executeAccountCommand, executeCache1hCommand, executeCacheKeepCommand, executeDumpCommand, executeFastModeCommand, executeLoggingCommand, executePrimeCommand, executeRoutingCommand, getCache1hPersistentMode, getCacheKeepWindow, getPersistedLogLevel, getRoutingMode, isCache1hPersistentlyEnabled, isCacheKeepAlways, isCacheKeepHybridActive, isCacheKeepPersistentlyEnabled, isDumpPersistentlyEnabled, isFastModePersistentlyEnabled, isPrimePersistentlyEnabled, loadAccounts, parseCache1hCommandAction, parseCacheKeepCommandAction, parseDumpCommandAction, parseFastModeCommandAction, parseLoggingCommandAction, parseRoutingCommandAction, removeAccountPersistent, reorderAccountsPersistent, setAccountEnabledPersistent, setCache1hPersistentEnabled, setCache1hPersistentMode, setCacheKeepPersistentAlways, setCacheKeepPersistentEnabled, setCacheKeepPersistentWindow, setCacheKeepSubagentsEnabled, setDumpEnabled, setDumpPersistentEnabled, setFastModePersistentEnabled, setLogLevelPersistent, setRoutingMode, } from '@cortexkit/anthropic-auth-core';
1
+ import { buildClaudeQuotaSummary, buildFallbackQuotaSummaries, buildPrimeAccountStatuses, CLAUDE_ACCOUNT_COMMAND_NAME, CLAUDE_CACHE_KEEP_COMMAND_NAME, CLAUDE_PRIME_COMMAND_NAME, CLAUDE_ROUTING_COMMAND_NAME, createEmptyStorage, detectClaustrumConnection, executeAccountCommand, executeCache1hCommand, executeCacheKeepCommand, executeDumpCommand, executeFastModeCommand, executeLoggingCommand, executePrimeCommand, executeRoutingCommand, formatEnrollmentStatus, getCache1hPersistentMode, getCacheKeepWindow, getClaustrumMode, getPersistedLogLevel, getRoutingMode, isCache1hPersistentlyEnabled, isCacheKeepAlways, isCacheKeepHybridActive, isCacheKeepPersistentlyEnabled, isDumpPersistentlyEnabled, isFastModePersistentlyEnabled, isPrimePersistentlyEnabled, loadAccounts, mergeMainQuotaErrorClearedAt, mergeMainRefreshErrorClearedAt, parseAccountCommandAction, parseCache1hCommandAction, parseCacheKeepCommandAction, parseDumpCommandAction, parseFastModeCommandAction, parseLoggingCommandAction, parseRoutingCommandAction, removeAccountPersistent, reorderAccountsPersistent, saveAccountState, setAccountEnabledPersistent, setCache1hPersistentEnabled, setCache1hPersistentMode, setCacheKeepPersistentAlways, setCacheKeepPersistentEnabled, setCacheKeepPersistentWindow, setCacheKeepSubagentsEnabled, setDumpEnabled, setDumpPersistentEnabled, setFastModePersistentEnabled, setLogLevelPersistent, setRoutingMode, } from '@cortexkit/anthropic-auth-core';
2
2
  import { getPiAccountStoragePath } from './paths.js';
3
3
  import { clearPiStickyRoutingSession, getPiTrackedCacheKeepSessions, } from './stream.js';
4
4
  function notify(ctx, message, kind = 'info') {
5
5
  ctx.ui.notify(message, kind);
6
6
  }
7
- export function registerCommands(pi) {
7
+ export function registerCommands(pi, custody) {
8
8
  pi.registerCommand('claude-cache', {
9
9
  description: 'Show or configure Claude 1-hour prompt cache mode',
10
10
  handler: async (args, ctx) => {
@@ -152,12 +152,30 @@ export function registerCommands(pi) {
152
152
  handler: async (args, ctx) => {
153
153
  const path = getPiAccountStoragePath();
154
154
  const storage = await loadAccounts(path);
155
- const result = executeAccountCommand({
155
+ const action = parseAccountCommandAction(args ?? '');
156
+ const result = await executeAccountCommand({
156
157
  argumentsText: args ?? '',
157
158
  storage: storage ?? createEmptyStorage(),
159
+ path,
160
+ transition: custody?.transition ??
161
+ (async () => ({
162
+ text: 'Refused: Pi custody controller is unavailable.',
163
+ })),
164
+ resetEnrollment: custody?.reset,
165
+ claustrum: action.type === 'status'
166
+ ? await detectClaustrumConnection()
167
+ : undefined,
158
168
  });
169
+ if (action.type === 'status' && custody) {
170
+ try {
171
+ result.text += `\n${formatEnrollmentStatus(await custody.status(), getClaustrumMode(storage) === 'claustrum').join('\n')}`;
172
+ }
173
+ catch {
174
+ result.text += '\n- Enrollment: unavailable';
175
+ }
176
+ }
159
177
  if (!result.updated) {
160
- notify(ctx, result.text);
178
+ notify(ctx, result.text, result.text.startsWith('Refused:') ? 'error' : 'info');
161
179
  return;
162
180
  }
163
181
  // Wire persistent mutations via core helpers
@@ -181,6 +199,29 @@ export function registerCommands(pi) {
181
199
  await reorderAccountsPersistent(newOrder, path);
182
200
  }
183
201
  }
202
+ else if (mutationAction === 'reset-backoff') {
203
+ const nextStorage = storage ?? createEmptyStorage();
204
+ nextStorage.refresh = nextStorage.refresh ?? {};
205
+ nextStorage.refresh.mainLastRefreshError = undefined;
206
+ nextStorage.refresh.mainRefreshErrorClearedAt =
207
+ mergeMainRefreshErrorClearedAt(nextStorage.refresh.mainRefreshErrorClearedAt, Date.now());
208
+ const mainIdentity = nextStorage.mainAccountId;
209
+ const quotaError = nextStorage.quota?.mainLastQuotaApiError;
210
+ if (!quotaError?.accountIdentity ||
211
+ !mainIdentity ||
212
+ quotaError.accountIdentity === mainIdentity) {
213
+ nextStorage.quota = nextStorage.quota ?? {};
214
+ nextStorage.quota.mainLastQuotaApiError = undefined;
215
+ const nextQuotaErrorGeneration = (nextStorage.quota.mainQuotaErrorGeneration ?? 0) + 1;
216
+ nextStorage.quota.mainQuotaErrorGeneration = Math.max(nextStorage.quota.mainQuotaErrorGeneration ?? 0, nextQuotaErrorGeneration);
217
+ nextStorage.quota.mainQuotaErrorClearedAt =
218
+ mergeMainQuotaErrorClearedAt(nextStorage.quota.mainQuotaErrorClearedAt, Date.now());
219
+ }
220
+ await saveAccountState(nextStorage, path, {
221
+ mainRefresh: true,
222
+ mainQuota: true,
223
+ });
224
+ }
184
225
  notify(ctx, result.text);
185
226
  },
186
227
  });
package/dist/convert.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type Cache1hMode, type ClaudeCodeIdentity } from '@cortexkit/anthropic-auth-core';
1
+ import { type Cache1hMode, type ClaudeCodeIdentity, type MidConversationEffortTransition, type ThinkingPrefixMismatchBehavior } from '@cortexkit/anthropic-auth-core';
2
2
  import type { Context, SimpleStreamOptions, Tool } from '@earendil-works/pi-ai';
3
3
  export type AnthropicRequestBody = {
4
4
  model: string;
@@ -19,6 +19,7 @@ export type AnthropicRequestBody = {
19
19
  };
20
20
  cache_control?: {
21
21
  type: 'ephemeral';
22
+ ttl?: '1h';
22
23
  };
23
24
  speed?: 'fast';
24
25
  };
@@ -26,7 +27,10 @@ export declare function fromClaudeCodeToolName(name: string, tools?: Tool[]): st
26
27
  export declare function buildAnthropicRequest(modelId: string, context: Context, options: SimpleStreamOptions | undefined, cache: {
27
28
  enabled: boolean;
28
29
  mode: Cache1hMode;
29
- }, fastModeEnabled?: boolean, identity?: ClaudeCodeIdentity): Promise<{
30
+ }, fastModeEnabled?: boolean, identity?: ClaudeCodeIdentity, controls?: {
31
+ effortTransitions?: readonly MidConversationEffortTransition[];
32
+ thinkingPrefixMismatchBehavior?: ThinkingPrefixMismatchBehavior;
33
+ }): Promise<{
30
34
  body: AnthropicRequestBody;
31
35
  bodyText: string;
32
36
  }>;
package/dist/convert.js CHANGED
@@ -1,8 +1,10 @@
1
- import { applyClaudeCodeMetadata, buildBillingHeaderValue, CLAUDE_CODE_ENTRYPOINT, CLAUDE_CODE_IDENTITY, CLAUDE_FABLE_MYTHOS_5_SUMMARIZED_THINKING, CLAUDE_OPUS_5_ADAPTIVE_THINKING, CLAUDE_SONNET_5_ADAPTIVE_THINKING, isClaudeFableOrMythos5Model, isClaudeOpus5Model, isClaudeSonnet5Model, isFastModeSupportedModel, isOpenAIReasoningSignature, orderClaudeCodeBody, signRequestBody, } from '@cortexkit/anthropic-auth-core';
1
+ import { applyClaudeCodeMetadata, applyMidConversationOutputConfig, applyThinkingBindingControls, buildBillingHeaderValue, CLAUDE_CODE_ENTRYPOINT, CLAUDE_CODE_IDENTITY, CLAUDE_FABLE_MYTHOS_5_SUMMARIZED_THINKING, CLAUDE_OPUS_5_5_ADAPTIVE_THINKING, CLAUDE_OPUS_5_ADAPTIVE_THINKING, CLAUDE_SONNET_5_ADAPTIVE_THINKING, ClaudeCodeFirstUserTextTracker, isClaudeFableOrMythos5Model, isClaudeOpus5Model, isClaudeOpus55Model, isClaudeSonnet5Model, isFastModeSupportedModel, isOpenAIReasoningSignature, orderClaudeCodeBody, signRequestBody, } from '@cortexkit/anthropic-auth-core';
2
+ import { collapseSystemMessages, getCurrentSystemPrompt, getCurrentTools, normalizeContext, } from '@earendil-works/pi-ai';
2
3
  // Anchor identifying Pi's documentation paragraph — the only part of the prompt
3
4
  // that Anthropic currently rejects in system[]. Unknown prompt shapes take the
4
5
  // service-preserving fallback below instead of returning the full prompt there.
5
6
  const PI_DOCS_ANCHOR = 'Pi documentation';
7
+ const firstUserTextTracker = new ClaudeCodeFirstUserTextTracker();
6
8
  const ANTHROPIC_REPLAY_APIS = new Set([
7
9
  'anthropic-messages',
8
10
  'cortexkit-anthropic-messages',
@@ -291,6 +293,49 @@ function addEphemeralCacheControl(body) {
291
293
  break;
292
294
  }
293
295
  }
296
+ /**
297
+ * Flatten a host system prompt to text.
298
+ *
299
+ * Pi types `Context.systemPrompt` as `string`, but Oh My Pi 18.x types it as
300
+ * `string[]` — "ordered system prompt blocks" — and hands that array to the
301
+ * provider unflattened, where `.trim()` is not a function and no request was
302
+ * ever built (issue #201).
303
+ *
304
+ * Blocks join on a blank line, which is how the host's own providers flatten
305
+ * them (`normalizeSystemPrompts(prompt).join('\n\n')`), so the paragraph split
306
+ * below classifies the same text on either host. The host asks providers to
307
+ * preserve its entries as distinct blocks, and that is deliberately not done
308
+ * here: an unrecognized prompt shape is carried in messages[] precisely because
309
+ * placing the host prompt in top-level system[] is the request shape Anthropic
310
+ * rejects with 400 "You're out of extra usage" (see splitPiSystemPrompt).
311
+ * Joining loses no text and no paragraph boundary; re-emitting the entries as
312
+ * system[] blocks would reintroduce that rejection.
313
+ *
314
+ * Only a `{ type: 'text', text }` block is read. The host declares strings, so
315
+ * that branch is for the next drift in this same field: returning '' there
316
+ * would silently ship requests with no host prompt at all, which is worse than
317
+ * the crash this replaces. `type` is checked rather than reading any object's
318
+ * `text` field, so a tool, image, or other structured block never has its
319
+ * metadata flattened into the prompt.
320
+ */
321
+ function systemPromptText(prompt) {
322
+ if (typeof prompt === 'string')
323
+ return prompt;
324
+ if (!Array.isArray(prompt))
325
+ return '';
326
+ const parts = [];
327
+ for (const block of prompt) {
328
+ if (typeof block === 'string') {
329
+ parts.push(block);
330
+ continue;
331
+ }
332
+ const record = block;
333
+ if (record?.type === 'text' && typeof record.text === 'string') {
334
+ parts.push(record.text);
335
+ }
336
+ }
337
+ return parts.join('\n\n');
338
+ }
294
339
  function splitPiSystemPrompt(prompt) {
295
340
  const sanitized = sanitize(prompt);
296
341
  const paragraphs = sanitized.split(/\n\n+/);
@@ -323,35 +368,83 @@ function prependCachedPromptBlock(messages, text) {
323
368
  firstUser.content.unshift(promptBlock);
324
369
  }
325
370
  }
371
+ /**
372
+ * Visit every object that can legitimately hold an Anthropic cache breakpoint:
373
+ * the request root, each `system[]` block, each tool, and each message with its
374
+ * content blocks — exactly where addEphemeralCacheControl and
375
+ * prependCachedPromptBlock place them.
376
+ *
377
+ * Deliberately not a deep walk. A tool's `input_schema` and a replayed
378
+ * `tool_use.input` are arbitrary caller data, so a nested field named
379
+ * `cache_control` there is a tool parameter or argument, not a breakpoint:
380
+ * deleting it or writing `ttl` into it would corrupt the tool contract.
381
+ */
382
+ function walkCacheControlHolders(body, visit) {
383
+ const holders = [body];
384
+ if (body.system)
385
+ holders.push(...body.system);
386
+ if (body.tools)
387
+ holders.push(...body.tools);
388
+ for (const message of body.messages) {
389
+ holders.push(message);
390
+ if (Array.isArray(message.content))
391
+ holders.push(...message.content);
392
+ }
393
+ for (const holder of holders) {
394
+ if (!holder || typeof holder !== 'object')
395
+ continue;
396
+ const record = holder;
397
+ const cacheControl = record.cache_control;
398
+ if (cacheControl && typeof cacheControl === 'object')
399
+ visit(record);
400
+ }
401
+ }
402
+ /**
403
+ * Anthropic accepts at most four cache breakpoints per request. This provider
404
+ * composes its own body and has already placed exactly four
405
+ * (addEphemeralCacheControl's last tool, last system block and last user block,
406
+ * plus the cached prompt block on the first user message), so the top-level
407
+ * control this used to add on top of them made five cache_control sites in one
408
+ * body — the shape behind "A maximum of 4 blocks with cache_control may be
409
+ * provided. Found 5." on a request that never reached the model (issue #201).
410
+ *
411
+ * Each mode now places its own breakpoints and nothing else, as the OpenCode
412
+ * rewrite path does (`applyAutomaticCache1h` / `applyHybridCache1h` both clear
413
+ * every breakpoint first):
414
+ * - `automatic`: the top-level control alone, at 1h.
415
+ * - `hybrid`: the four block breakpoints extended to 1h, no top-level control.
416
+ * That is the placement OpenCode's hybrid anchors reconstruct by hand and
417
+ * this converter emits natively.
418
+ * - `explicit`: the same four breakpoints, TTL only.
419
+ */
326
420
  function applyCacheMode(body, enabled, mode) {
327
421
  if (!enabled)
328
422
  return;
329
423
  if (mode === 'automatic') {
330
- body.cache_control = { type: 'ephemeral' };
424
+ walkCacheControlHolders(body, (holder) => {
425
+ delete holder.cache_control;
426
+ });
427
+ body.cache_control = { type: 'ephemeral', ttl: '1h' };
331
428
  return;
332
429
  }
333
- const addTtl = (value) => {
334
- if (!value || typeof value !== 'object')
335
- return;
336
- if (Array.isArray(value)) {
337
- for (const item of value)
338
- addTtl(item);
339
- return;
340
- }
341
- const record = value;
342
- const cacheControl = record.cache_control;
343
- if (cacheControl && typeof cacheControl === 'object') {
344
- ;
345
- cacheControl.ttl = '1h';
346
- }
347
- for (const child of Object.values(record))
348
- addTtl(child);
349
- };
350
- if (mode === 'hybrid')
351
- body.cache_control = { type: 'ephemeral' };
352
- addTtl(body);
430
+ walkCacheControlHolders(body, (holder) => {
431
+ ;
432
+ holder.cache_control.ttl = '1h';
433
+ });
353
434
  }
354
- export async function buildAnthropicRequest(modelId, context, options, cache, fastModeEnabled = false, identity) {
435
+ export async function buildAnthropicRequest(modelId, context, options, cache, fastModeEnabled = false, identity, controls = {}) {
436
+ // Pi 0.86 passes a normalized transcript to providers. Resolve instructions,
437
+ // named sections and tool changes through its own replay helpers; raw host
438
+ // prompts (including ordered OMP blocks) still enter through normalization.
439
+ const transcript = collapseSystemMessages(normalizeContext({
440
+ ...context,
441
+ systemPrompt: systemPromptText(context.systemPrompt),
442
+ }));
443
+ context = {
444
+ messages: transcript.messages,
445
+ systemPrompt: getCurrentSystemPrompt(transcript.messages),
446
+ tools: getCurrentTools(transcript.messages),
447
+ };
355
448
  const messages = convertMessages(context.messages, modelId);
356
449
  // Strip trailing assistant messages — Anthropic rejects prefill on some models
357
450
  while (messages.length &&
@@ -361,11 +454,14 @@ export async function buildAnthropicRequest(modelId, context, options, cache, fa
361
454
  const system = [
362
455
  {
363
456
  type: 'text',
364
- text: buildBillingHeaderValue(messages, undefined, CLAUDE_CODE_ENTRYPOINT),
457
+ text: buildBillingHeaderValue(messages, undefined, CLAUDE_CODE_ENTRYPOINT, options?.sessionId
458
+ ? firstUserTextTracker.resolve(options.sessionId, messages)
459
+ : undefined),
365
460
  },
366
461
  { type: 'text', text: CLAUDE_CODE_IDENTITY },
367
462
  ];
368
- if (context.systemPrompt?.trim()) {
463
+ const systemPrompt = systemPromptText(context.systemPrompt);
464
+ if (systemPrompt.trim()) {
369
465
  // Pi's prompt cannot sit whole in the top-level system[] array: two lines of
370
466
  // its documentation paragraph (the docs/*.md enumeration and the "follow .md
371
467
  // cross-references" instruction) are each independently sufficient to make
@@ -390,7 +486,7 @@ export async function buildAnthropicRequest(modelId, context, options, cache, fa
390
486
  // cache_control is set explicitly because addEphemeralCacheControl's
391
487
  // message-level breakpoint only fires for array content on the *last* user
392
488
  // message, which is not this one after the first turn.
393
- const prompt = splitPiSystemPrompt(context.systemPrompt);
489
+ const prompt = splitPiSystemPrompt(systemPrompt);
394
490
  if (prompt.systemText) {
395
491
  system.push({ type: 'text', text: prompt.systemText });
396
492
  }
@@ -412,7 +508,8 @@ export async function buildAnthropicRequest(modelId, context, options, cache, fa
412
508
  const isFableOrMythos5 = isClaudeFableOrMythos5Model(modelId);
413
509
  const isSonnet5 = isClaudeSonnet5Model(modelId);
414
510
  const isOpus5 = isClaudeOpus5Model(modelId);
415
- // Sonnet 5 and Opus 5 share Fable/Mythos's adaptive-summarized contract: make
511
+ const isOpus55 = isClaudeOpus55Model(modelId);
512
+ // Sonnet 5, Opus 5, and Opus 5.5 share Fable/Mythos's adaptive-summarized contract: make
416
513
  // adaptive thinking visible (display defaults to "omitted") and map reasoning
417
514
  // to output_config effort. Pi's typed options cannot express
418
515
  // thinking-disabled, so there is no disable case here (see transform.ts for
@@ -425,11 +522,14 @@ export async function buildAnthropicRequest(modelId, context, options, cache, fa
425
522
  else if (isSonnet5) {
426
523
  body.thinking = { ...CLAUDE_SONNET_5_ADAPTIVE_THINKING };
427
524
  }
525
+ else if (isOpus55) {
526
+ body.thinking = { ...CLAUDE_OPUS_5_5_ADAPTIVE_THINKING };
527
+ }
428
528
  else if (isOpus5) {
429
529
  body.thinking = { ...CLAUDE_OPUS_5_ADAPTIVE_THINKING };
430
530
  }
431
531
  if (options?.reasoning) {
432
- if (isFableOrMythos5 || isSonnet5 || isOpus5) {
532
+ if (isFableOrMythos5 || isSonnet5 || isOpus5 || isOpus55) {
433
533
  body.output_config = { effort: options.reasoning };
434
534
  }
435
535
  else {
@@ -448,10 +548,13 @@ export async function buildAnthropicRequest(modelId, context, options, cache, fa
448
548
  };
449
549
  }
450
550
  }
551
+ applyMidConversationOutputConfig(body, controls.effortTransitions ?? []);
451
552
  addEphemeralCacheControl(body);
452
553
  applyCacheMode(body, cache.enabled, cache.mode);
453
- if (identity)
554
+ if (identity) {
555
+ applyThinkingBindingControls(body, controls.thinkingPrefixMismatchBehavior ?? 'account-default');
454
556
  applyClaudeCodeMetadata(body, identity);
557
+ }
455
558
  const unsigned = JSON.stringify(orderClaudeCodeBody(body));
456
559
  const bodyText = await signRequestBody(unsigned);
457
560
  return { body, bodyText };
@@ -0,0 +1,17 @@
1
+ import { type AccountCommandResult, type ClaustrumEnrollmentStatus, type ClaustrumMode, type ClaustrumScopedClient } from '@cortexkit/anthropic-auth-core';
2
+ export declare function requirePiEnrollment(): Promise<{
3
+ state: 'approved';
4
+ proposedName: string;
5
+ approvedName?: string;
6
+ tokenGeneration: number;
7
+ }>;
8
+ export interface PiCustodyCommands {
9
+ transition(mode: ClaustrumMode): Promise<AccountCommandResult>;
10
+ status(): Promise<ClaustrumEnrollmentStatus>;
11
+ reset(): Promise<AccountCommandResult>;
12
+ }
13
+ export declare function createPiCustodyCommands(options: {
14
+ reconfigure: () => Promise<void>;
15
+ storagePath?: string;
16
+ connect?: () => Promise<ClaustrumScopedClient>;
17
+ }): PiCustodyCommands;
@@ -0,0 +1,66 @@
1
+ import { CLAUSTRUM_PI_ENROLLMENT_NAME, ClaustrumScopedCustody, connectClaustrumScopedClient, getHostClaustrumEnrollmentPaths, readClaustrumEnrollmentStatus, resetClaustrumEnrollmentState, setClaustrumModePersistent, } from '@cortexkit/anthropic-auth-core';
2
+ import { getPiAccountStoragePath, getPiClaustrumConnectionOptions, hasPiLocalAnthropicOAuth, } from './paths.js';
3
+ export async function requirePiEnrollment() {
4
+ const status = await readClaustrumEnrollmentStatus(getHostClaustrumEnrollmentPaths('pi'), CLAUSTRUM_PI_ENROLLMENT_NAME);
5
+ if (status.state !== 'approved' ||
6
+ status.proposedName !== CLAUSTRUM_PI_ENROLLMENT_NAME) {
7
+ throw new Error('Pi requires its own approved Claustrum enrollment; run setup');
8
+ }
9
+ return status;
10
+ }
11
+ export function createPiCustodyCommands(options) {
12
+ const storagePath = options.storagePath ?? getPiAccountStoragePath();
13
+ const paths = () => getHostClaustrumEnrollmentPaths('pi');
14
+ return {
15
+ status: () => readClaustrumEnrollmentStatus(paths(), CLAUSTRUM_PI_ENROLLMENT_NAME),
16
+ async reset() {
17
+ const result = await resetClaustrumEnrollmentState(paths(), CLAUSTRUM_PI_ENROLLMENT_NAME);
18
+ const messages = {
19
+ reset: 'Terminal enrollment state cleared. Run setup to enroll Pi again.',
20
+ idle: 'Pi has no enrollment state to reset.',
21
+ 'refused-pending': 'Refused: Pi enrollment is still pending.',
22
+ 'refused-approved': 'Refused: Pi already has an approved enrollment token.',
23
+ busy: 'Pi enrollment is busy in another process.',
24
+ };
25
+ return { text: messages[result] };
26
+ },
27
+ async transition(mode) {
28
+ if (mode === 'claustrum') {
29
+ if (await hasPiLocalAnthropicOAuth()) {
30
+ return {
31
+ text: 'Refused: Pi still has a local Anthropic OAuth credential. Run `bunx @cortexkit/opencode-anthropic-auth setup` to approve its removal and enable Claustrum.',
32
+ };
33
+ }
34
+ // A slash command never invokes the administrative CLI or approves itself.
35
+ // Existing enrollment and grants must be verified before committing mode.
36
+ await requirePiEnrollment();
37
+ const client = await (options.connect?.() ??
38
+ connectClaustrumScopedClient(getPiClaustrumConnectionOptions(storagePath)));
39
+ const custody = new ClaustrumScopedCustody({
40
+ client,
41
+ tokenPath: paths().tokenPath,
42
+ });
43
+ try {
44
+ const inventory = await custody.discover();
45
+ const active = inventory.accounts.filter((account) => account.state === 'active');
46
+ if (!active.length)
47
+ return {
48
+ text: 'Refused: Claustrum has no accessible active Anthropic accounts.',
49
+ };
50
+ for (const account of active)
51
+ await custody.authorize(account);
52
+ }
53
+ finally {
54
+ custody.close();
55
+ }
56
+ }
57
+ await setClaustrumModePersistent(mode, storagePath);
58
+ await options.reconfigure();
59
+ return {
60
+ text: mode === 'claustrum'
61
+ ? 'Pi now uses Claustrum for Anthropic authentication.'
62
+ : 'Pi now uses local Anthropic authentication. Use /login anthropic if needed.',
63
+ };
64
+ },
65
+ };
66
+ }
@@ -0,0 +1,27 @@
1
+ import { type MidConversationEffortTransition } from '@cortexkit/anthropic-auth-core';
2
+ type SessionEntryLike = {
3
+ id?: unknown;
4
+ type?: unknown;
5
+ thinkingLevel?: unknown;
6
+ firstKeptEntryId?: unknown;
7
+ message?: {
8
+ role?: unknown;
9
+ };
10
+ };
11
+ /**
12
+ * Reproduce the host's context-entry view — the entries the next request will
13
+ * actually carry — from the branch path, ordered root to leaf.
14
+ *
15
+ * `SessionManager.buildContextEntries()` computes this on Pi hosts but does not
16
+ * exist on Oh My Pi 18.x, where calling it threw on every turn (issue #200).
17
+ * `getBranch()` is present on both hosts and is the same path that method walks,
18
+ * so the compaction trim is applied here instead of asking the host for it.
19
+ *
20
+ * A branch without compaction is already the context. After a compaction the
21
+ * context is the compaction entry, then the tail retained from
22
+ * `firstKeptEntryId`, then everything appended after the compaction.
23
+ */
24
+ export declare function deriveContextEntries<Entry extends SessionEntryLike>(branch: readonly Entry[]): Entry[];
25
+ /** Build Pi's active, compaction-aware Fable 5.1 effort timeline. */
26
+ export declare function collectPiEffortHistory(contextEntries: readonly SessionEntryLike[], fullBranch: readonly SessionEntryLike[]): MidConversationEffortTransition[];
27
+ export {};
@@ -0,0 +1,103 @@
1
+ import { normalizeAdaptiveEffort, } from '@cortexkit/anthropic-auth-core';
2
+ /**
3
+ * Reproduce the host's context-entry view — the entries the next request will
4
+ * actually carry — from the branch path, ordered root to leaf.
5
+ *
6
+ * `SessionManager.buildContextEntries()` computes this on Pi hosts but does not
7
+ * exist on Oh My Pi 18.x, where calling it threw on every turn (issue #200).
8
+ * `getBranch()` is present on both hosts and is the same path that method walks,
9
+ * so the compaction trim is applied here instead of asking the host for it.
10
+ *
11
+ * A branch without compaction is already the context. After a compaction the
12
+ * context is the compaction entry, then the tail retained from
13
+ * `firstKeptEntryId`, then everything appended after the compaction.
14
+ */
15
+ export function deriveContextEntries(branch) {
16
+ let compactionIndex = -1;
17
+ for (let index = 0; index < branch.length; index++) {
18
+ if (branch[index]?.type === 'compaction')
19
+ compactionIndex = index;
20
+ }
21
+ const compaction = branch[compactionIndex];
22
+ if (!compaction)
23
+ return branch.slice();
24
+ const contextEntries = [compaction];
25
+ let retaining = false;
26
+ for (let index = 0; index < compactionIndex; index++) {
27
+ const entry = branch[index];
28
+ if (!entry)
29
+ continue;
30
+ if (entry.id === compaction.firstKeptEntryId)
31
+ retaining = true;
32
+ if (retaining)
33
+ contextEntries.push(entry);
34
+ }
35
+ for (let index = compactionIndex + 1; index < branch.length; index++) {
36
+ const entry = branch[index];
37
+ if (entry)
38
+ contextEntries.push(entry);
39
+ }
40
+ return contextEntries;
41
+ }
42
+ function entryRole(entry) {
43
+ if (entry.type === 'compaction' || entry.type === 'branch_summary') {
44
+ return 'user';
45
+ }
46
+ if (entry.type === 'custom_message')
47
+ return 'user';
48
+ return entry.type === 'message' ? entry.message?.role : undefined;
49
+ }
50
+ function appendTransition(transitions, afterAssistantMessages, effort) {
51
+ const previous = transitions.at(-1);
52
+ if (previous?.effort === effort)
53
+ return;
54
+ if (previous?.afterAssistantMessages === afterAssistantMessages) {
55
+ previous.effort = effort;
56
+ return;
57
+ }
58
+ transitions.push({ afterAssistantMessages, effort });
59
+ }
60
+ /** Build Pi's active, compaction-aware Fable 5.1 effort timeline. */
61
+ export function collectPiEffortHistory(contextEntries, fullBranch) {
62
+ let activeEffort = 'high';
63
+ let assistantMessages = 0;
64
+ const transitions = [];
65
+ const first = contextEntries[0];
66
+ const compactionIndex = first?.type === 'compaction'
67
+ ? fullBranch.findIndex((entry) => entry.id === first.id)
68
+ : -1;
69
+ if (compactionIndex >= 0) {
70
+ for (let index = 0; index <= compactionIndex; index++) {
71
+ const entry = fullBranch[index];
72
+ if (entry?.type !== 'thinking_level_change')
73
+ continue;
74
+ activeEffort = normalizeAdaptiveEffort(entry.thinkingLevel) ?? 'high';
75
+ }
76
+ appendTransition(transitions, 0, activeEffort);
77
+ }
78
+ const branchIndexById = new Map(fullBranch.map((entry, index) => [entry.id, index]));
79
+ for (const entry of contextEntries) {
80
+ if (entry === first && entry.type === 'compaction')
81
+ continue;
82
+ const branchIndex = branchIndexById.get(entry.id);
83
+ const retainedBeforeCompaction = compactionIndex >= 0 &&
84
+ typeof branchIndex === 'number' &&
85
+ branchIndex < compactionIndex;
86
+ if (entry.type === 'thinking_level_change') {
87
+ if (!retainedBeforeCompaction) {
88
+ activeEffort = normalizeAdaptiveEffort(entry.thinkingLevel) ?? 'high';
89
+ }
90
+ continue;
91
+ }
92
+ const role = entryRole(entry);
93
+ if (role === 'assistant') {
94
+ assistantMessages++;
95
+ }
96
+ else if (role === 'user' ||
97
+ role === 'toolResult' ||
98
+ role === 'tool_result') {
99
+ appendTransition(transitions, assistantMessages, activeEffort);
100
+ }
101
+ }
102
+ return transitions;
103
+ }
package/dist/index.d.ts CHANGED
@@ -1,2 +1,6 @@
1
+ import { type ClaustrumScopedClient } from '@cortexkit/anthropic-auth-core';
1
2
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
2
- export default function cortexKitPiAnthropicAuth(pi: ExtensionAPI): void;
3
+ export default function cortexKitPiAnthropicAuth(pi: ExtensionAPI, options?: {
4
+ connectScoped?: () => Promise<ClaustrumScopedClient>;
5
+ pollIntervalMs?: number;
6
+ }): Promise<void>;