@cortexkit/pi-anthropic-auth 1.22.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`) 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 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. 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.
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:
@@ -83,7 +93,7 @@ The sidecar uses the same JSON shape as the OpenCode package, including `routing
83
93
  /claude-quota
84
94
  ```
85
95
 
86
- `/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. `/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`).
87
97
 
88
98
  ### Claude quota window priming
89
99
 
@@ -95,6 +105,10 @@ Prime marker identities live in `anthropic-auth-state.json`. Plugin-owned refres
95
105
 
96
106
  Pi's `/claude-prime` command displays status only. The `on` and `off` arguments are ignored; toggling priming requires OpenCode.
97
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
+
98
112
  ## Relay
99
113
 
100
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, detectClaustrumConnection, executeAccountCommand, executeCache1hCommand, executeCacheKeepCommand, executeDumpCommand, executeFastModeCommand, executeLoggingCommand, executePrimeCommand, executeRoutingCommand, getCache1hPersistentMode, getCacheKeepWindow, 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';
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) => {
@@ -153,15 +153,29 @@ export function registerCommands(pi) {
153
153
  const path = getPiAccountStoragePath();
154
154
  const storage = await loadAccounts(path);
155
155
  const action = parseAccountCommandAction(args ?? '');
156
- const result = executeAccountCommand({
156
+ const result = await executeAccountCommand({
157
157
  argumentsText: args ?? '',
158
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,
159
165
  claustrum: action.type === 'status'
160
166
  ? await detectClaustrumConnection()
161
167
  : undefined,
162
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
+ }
163
177
  if (!result.updated) {
164
- notify(ctx, result.text);
178
+ notify(ctx, result.text, result.text.startsWith('Refused:') ? 'error' : 'info');
165
179
  return;
166
180
  }
167
181
  // Wire persistent mutations via core helpers
package/dist/convert.d.ts CHANGED
@@ -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
  };
package/dist/convert.js CHANGED
@@ -1,4 +1,5 @@
1
- import { applyClaudeCodeMetadata, applyMidConversationOutputConfig, applyThinkingBindingControls, buildBillingHeaderValue, CLAUDE_CODE_ENTRYPOINT, CLAUDE_CODE_IDENTITY, CLAUDE_FABLE_MYTHOS_5_SUMMARIZED_THINKING, CLAUDE_OPUS_5_ADAPTIVE_THINKING, CLAUDE_SONNET_5_ADAPTIVE_THINKING, ClaudeCodeFirstUserTextTracker, 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.
@@ -292,6 +293,49 @@ function addEphemeralCacheControl(body) {
292
293
  break;
293
294
  }
294
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
+ }
295
339
  function splitPiSystemPrompt(prompt) {
296
340
  const sanitized = sanitize(prompt);
297
341
  const paragraphs = sanitized.split(/\n\n+/);
@@ -324,35 +368,83 @@ function prependCachedPromptBlock(messages, text) {
324
368
  firstUser.content.unshift(promptBlock);
325
369
  }
326
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
+ */
327
420
  function applyCacheMode(body, enabled, mode) {
328
421
  if (!enabled)
329
422
  return;
330
423
  if (mode === 'automatic') {
331
- body.cache_control = { type: 'ephemeral' };
424
+ walkCacheControlHolders(body, (holder) => {
425
+ delete holder.cache_control;
426
+ });
427
+ body.cache_control = { type: 'ephemeral', ttl: '1h' };
332
428
  return;
333
429
  }
334
- const addTtl = (value) => {
335
- if (!value || typeof value !== 'object')
336
- return;
337
- if (Array.isArray(value)) {
338
- for (const item of value)
339
- addTtl(item);
340
- return;
341
- }
342
- const record = value;
343
- const cacheControl = record.cache_control;
344
- if (cacheControl && typeof cacheControl === 'object') {
345
- ;
346
- cacheControl.ttl = '1h';
347
- }
348
- for (const child of Object.values(record))
349
- addTtl(child);
350
- };
351
- if (mode === 'hybrid')
352
- body.cache_control = { type: 'ephemeral' };
353
- addTtl(body);
430
+ walkCacheControlHolders(body, (holder) => {
431
+ ;
432
+ holder.cache_control.ttl = '1h';
433
+ });
354
434
  }
355
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
+ };
356
448
  const messages = convertMessages(context.messages, modelId);
357
449
  // Strip trailing assistant messages — Anthropic rejects prefill on some models
358
450
  while (messages.length &&
@@ -368,7 +460,8 @@ export async function buildAnthropicRequest(modelId, context, options, cache, fa
368
460
  },
369
461
  { type: 'text', text: CLAUDE_CODE_IDENTITY },
370
462
  ];
371
- if (context.systemPrompt?.trim()) {
463
+ const systemPrompt = systemPromptText(context.systemPrompt);
464
+ if (systemPrompt.trim()) {
372
465
  // Pi's prompt cannot sit whole in the top-level system[] array: two lines of
373
466
  // its documentation paragraph (the docs/*.md enumeration and the "follow .md
374
467
  // cross-references" instruction) are each independently sufficient to make
@@ -393,7 +486,7 @@ export async function buildAnthropicRequest(modelId, context, options, cache, fa
393
486
  // cache_control is set explicitly because addEphemeralCacheControl's
394
487
  // message-level breakpoint only fires for array content on the *last* user
395
488
  // message, which is not this one after the first turn.
396
- const prompt = splitPiSystemPrompt(context.systemPrompt);
489
+ const prompt = splitPiSystemPrompt(systemPrompt);
397
490
  if (prompt.systemText) {
398
491
  system.push({ type: 'text', text: prompt.systemText });
399
492
  }
@@ -415,7 +508,8 @@ export async function buildAnthropicRequest(modelId, context, options, cache, fa
415
508
  const isFableOrMythos5 = isClaudeFableOrMythos5Model(modelId);
416
509
  const isSonnet5 = isClaudeSonnet5Model(modelId);
417
510
  const isOpus5 = isClaudeOpus5Model(modelId);
418
- // 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
419
513
  // adaptive thinking visible (display defaults to "omitted") and map reasoning
420
514
  // to output_config effort. Pi's typed options cannot express
421
515
  // thinking-disabled, so there is no disable case here (see transform.ts for
@@ -428,11 +522,14 @@ export async function buildAnthropicRequest(modelId, context, options, cache, fa
428
522
  else if (isSonnet5) {
429
523
  body.thinking = { ...CLAUDE_SONNET_5_ADAPTIVE_THINKING };
430
524
  }
525
+ else if (isOpus55) {
526
+ body.thinking = { ...CLAUDE_OPUS_5_5_ADAPTIVE_THINKING };
527
+ }
431
528
  else if (isOpus5) {
432
529
  body.thinking = { ...CLAUDE_OPUS_5_ADAPTIVE_THINKING };
433
530
  }
434
531
  if (options?.reasoning) {
435
- if (isFableOrMythos5 || isSonnet5 || isOpus5) {
532
+ if (isFableOrMythos5 || isSonnet5 || isOpus5 || isOpus55) {
436
533
  body.output_config = { effort: options.reasoning };
437
534
  }
438
535
  else {
@@ -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
+ }
@@ -3,10 +3,25 @@ type SessionEntryLike = {
3
3
  id?: unknown;
4
4
  type?: unknown;
5
5
  thinkingLevel?: unknown;
6
+ firstKeptEntryId?: unknown;
6
7
  message?: {
7
8
  role?: unknown;
8
9
  };
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[];
10
25
  /** Build Pi's active, compaction-aware Fable 5.1 effort timeline. */
11
26
  export declare function collectPiEffortHistory(contextEntries: readonly SessionEntryLike[], fullBranch: readonly SessionEntryLike[]): MidConversationEffortTransition[];
12
27
  export {};
@@ -1,4 +1,44 @@
1
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
+ }
2
42
  function entryRole(entry) {
3
43
  if (entry.type === 'compaction' || entry.type === 'branch_summary') {
4
44
  return 'user';
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>;
package/dist/index.js CHANGED
@@ -1,8 +1,17 @@
1
- import { authorize, CLAUDE_FABLE_MYTHOS_5_1_PRICING, CLAUDE_FABLE_MYTHOS_5_CONTEXT_WINDOW, CLAUDE_FABLE_MYTHOS_5_MAX_OUTPUT_TOKENS, CLAUDE_FABLE_MYTHOS_5_MODEL_SPECS, CLAUDE_FABLE_MYTHOS_5_PRICING, exchange, isClaudeFableOrMythos51Model, refreshClaudeOAuthToken, } from '@cortexkit/anthropic-auth-core';
1
+ import { authorize, CLAUDE_FABLE_MYTHOS_5_1_PRICING, CLAUDE_FABLE_MYTHOS_5_CONTEXT_WINDOW, CLAUDE_FABLE_MYTHOS_5_MAX_OUTPUT_TOKENS, CLAUDE_FABLE_MYTHOS_5_MODEL_SPECS, CLAUDE_FABLE_MYTHOS_5_PRICING, exchange, getClaustrumMode, isClaudeFableOrMythos51Model, loadAccounts, refreshClaudeOAuthToken, } from '@cortexkit/anthropic-auth-core';
2
2
  import { registerCommands } from './commands.js';
3
- import { collectPiEffortHistory } from './effort-history.js';
4
- import { streamCortexKitAnthropic } from './stream.js';
3
+ import { createPiCustodyCommands, requirePiEnrollment } from './custody.js';
4
+ import { collectPiEffortHistory, deriveContextEntries, } from './effort-history.js';
5
+ import { getPiAccountStoragePath } from './paths.js';
6
+ import { closePiScopedRuntime, getPiScopedRuntime, streamCortexKitAnthropic, } from './stream.js';
7
+ async function assertLocalAuthentication() {
8
+ if (getClaustrumMode(await loadAccounts(getPiAccountStoragePath())) ===
9
+ 'claustrum') {
10
+ throw new Error('Local Anthropic login and refresh are disabled while Claustrum custody is active');
11
+ }
12
+ }
5
13
  async function loginAnthropic(callbacks) {
14
+ await assertLocalAuthentication();
6
15
  const auth = await authorize('max');
7
16
  callbacks.onAuth({ url: auth.url });
8
17
  const callback = await callbacks.onPrompt({
@@ -12,6 +21,7 @@ async function loginAnthropic(callbacks) {
12
21
  if (result.type !== 'success') {
13
22
  throw new Error('Anthropic OAuth exchange failed');
14
23
  }
24
+ await assertLocalAuthentication();
15
25
  return {
16
26
  refresh: result.refresh,
17
27
  access: result.access,
@@ -22,6 +32,7 @@ function textImageInput() {
22
32
  return ['text', 'image'];
23
33
  }
24
34
  async function refreshAnthropicToken(credentials) {
35
+ await assertLocalAuthentication();
25
36
  const refreshed = await refreshClaudeOAuthToken({
26
37
  refreshToken: credentials.refresh,
27
38
  });
@@ -31,14 +42,36 @@ async function refreshAnthropicToken(credentials) {
31
42
  expires: refreshed.expires,
32
43
  };
33
44
  }
34
- export default function cortexKitPiAnthropicAuth(pi) {
35
- registerCommands(pi);
45
+ export default async function cortexKitPiAnthropicAuth(pi, options = {}) {
46
+ const storagePath = getPiAccountStoragePath();
47
+ registerCommands(pi, createPiCustodyCommands({
48
+ storagePath,
49
+ reconfigure: configureProvider,
50
+ connect: options.connectScoped,
51
+ }));
36
52
  const effortHistoryBySession = new Map();
37
53
  pi.on('turn_start', async (_event, ctx) => {
38
54
  const sessionId = ctx.sessionManager.getSessionId();
39
55
  if (!sessionId)
40
56
  return;
41
- const transitions = collectPiEffortHistory(ctx.sessionManager.buildContextEntries(), ctx.sessionManager.getBranch());
57
+ // This hook only adds mid-conversation effort markers to Fable/Mythos 5.1
58
+ // requests. A host whose session entries do not match what this reads must
59
+ // cost the session its transitions and nothing else: the handler runs
60
+ // before every turn, and an exception here surfaced as a per-turn extension
61
+ // error while collecting no effort history at all (issue #200).
62
+ //
63
+ // The catch stays quiet: `ExtensionAPI` carries no log surface on either
64
+ // host, and writing to stdout from a per-turn hook corrupts the host's
65
+ // rendering — which is the same per-turn noise this fix removes. The
66
+ // degraded state is observable in the request: no effort markers.
67
+ let transitions;
68
+ try {
69
+ const branch = ctx.sessionManager.getBranch();
70
+ transitions = collectPiEffortHistory(deriveContextEntries(branch), branch);
71
+ }
72
+ catch {
73
+ transitions = [];
74
+ }
42
75
  effortHistoryBySession.delete(sessionId);
43
76
  effortHistoryBySession.set(sessionId, transitions);
44
77
  while (effortHistoryBySession.size > 128) {
@@ -53,8 +86,9 @@ export default function cortexKitPiAnthropicAuth(pi) {
53
86
  const sessionId = ctx.sessionManager.getSessionId();
54
87
  if (sessionId)
55
88
  effortHistoryBySession.delete(sessionId);
89
+ closePiScopedRuntime(storagePath);
56
90
  });
57
- pi.registerProvider('anthropic', {
91
+ const configuration = {
58
92
  name: 'Anthropic (CortexKit OAuth)',
59
93
  baseUrl: 'https://api.anthropic.com',
60
94
  api: 'cortexkit-anthropic-messages',
@@ -78,6 +112,15 @@ export default function cortexKitPiAnthropicAuth(pi) {
78
112
  maxTokens: CLAUDE_FABLE_MYTHOS_5_MAX_OUTPUT_TOKENS,
79
113
  };
80
114
  }),
115
+ {
116
+ id: 'claude-opus-5-5',
117
+ name: 'Claude Opus 5.5',
118
+ reasoning: true,
119
+ input: textImageInput(),
120
+ cost: { input: 4, output: 20, cacheRead: 0.2, cacheWrite: 8 },
121
+ contextWindow: 1_000_000,
122
+ maxTokens: 128_000,
123
+ },
81
124
  {
82
125
  id: 'claude-opus-5',
83
126
  name: 'Claude Opus 5',
@@ -133,5 +176,57 @@ export default function cortexKitPiAnthropicAuth(pi) {
133
176
  streamSimple: (model, context, options) => streamCortexKitAnthropic(model, context, options, options?.sessionId
134
177
  ? effortHistoryBySession.get(options.sessionId)
135
178
  : undefined),
136
- });
179
+ };
180
+ async function configureProvider() {
181
+ if (getClaustrumMode(await loadAccounts(storagePath)) !== 'claustrum') {
182
+ closePiScopedRuntime(storagePath);
183
+ pi.registerProvider('anthropic', configuration);
184
+ return;
185
+ }
186
+ const streamSimple = configuration.streamSimple;
187
+ if (!streamSimple)
188
+ throw new Error('Anthropic stream implementation is unavailable');
189
+ const configured = async () => {
190
+ if (getClaustrumMode(await loadAccounts(storagePath)) !== 'claustrum')
191
+ return false;
192
+ await requirePiEnrollment();
193
+ return true;
194
+ };
195
+ const provider = {
196
+ id: 'anthropic',
197
+ name: 'Anthropic (Claustrum)',
198
+ baseUrl: 'https://api.anthropic.com',
199
+ auth: {
200
+ // Native ambient auth avoids fake keys and local OAuth refresh. Pi refuses
201
+ // a leftover stored OAuth credential because this provider has no OAuth
202
+ // handler; setup must obtain consent before removing that local entry.
203
+ apiKey: {
204
+ name: 'Claustrum',
205
+ check: async () => (await configured())
206
+ ? { type: 'api_key', source: 'Claustrum' }
207
+ : undefined,
208
+ resolve: async () => (await configured())
209
+ ? { auth: {}, source: 'Claustrum' }
210
+ : undefined,
211
+ },
212
+ },
213
+ getModels: () => (configuration.models ?? []).map((model) => ({
214
+ ...model,
215
+ provider: 'anthropic',
216
+ api: model.api ?? 'cortexkit-anthropic-messages',
217
+ baseUrl: model.baseUrl ?? 'https://api.anthropic.com',
218
+ })),
219
+ // Preserve the legacy provider's simplified option surface for raw calls.
220
+ stream: (model, context, options) => streamSimple(model, context, options),
221
+ streamSimple,
222
+ };
223
+ pi.registerProvider(provider);
224
+ getPiScopedRuntime(storagePath, {
225
+ ...(options.connectScoped && { connect: options.connectScoped }),
226
+ ...(options.pollIntervalMs !== undefined && {
227
+ pollIntervalMs: options.pollIntervalMs,
228
+ }),
229
+ }).start();
230
+ }
231
+ await configureProvider();
137
232
  }