pi-background-tasks 2.4.2 → 2.6.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.
Files changed (203) hide show
  1. package/BACKGROUND-TASKS-INSTRUCTIONS.md +1 -1
  2. package/README.md +70 -25
  3. package/TESTING.md +42 -2
  4. package/TEST_PLAN.md +7 -4
  5. package/dist/extensions/anthropic-attribution-child.js +4 -0
  6. package/dist/extensions/anthropic-attribution-child.js.map +1 -0
  7. package/dist/extensions/anthropic-attribution.js +107 -0
  8. package/dist/extensions/anthropic-attribution.js.map +1 -0
  9. package/dist/extensions/background-tasks.js +2 -0
  10. package/dist/extensions/background-tasks.js.map +1 -0
  11. package/dist/extensions/delegate-child.js +2 -0
  12. package/dist/extensions/delegate-child.js.map +1 -0
  13. package/dist/extensions/fusion-child.js +2 -0
  14. package/dist/extensions/fusion-child.js.map +1 -0
  15. package/dist/package.json +5 -0
  16. package/dist/src/core/anthropic-attribution-path.js +24 -0
  17. package/dist/src/core/anthropic-attribution-path.js.map +1 -0
  18. package/dist/src/core/anthropic-attribution.js +2247 -0
  19. package/dist/src/core/anthropic-attribution.js.map +1 -0
  20. package/dist/src/core/attested-pi-contract.js +5 -0
  21. package/dist/src/core/attested-pi-contract.js.map +1 -0
  22. package/dist/src/core/attested-pi-run.js +749 -0
  23. package/dist/src/core/attested-pi-run.js.map +1 -0
  24. package/dist/src/core/canonical-json.js +19 -0
  25. package/dist/src/core/canonical-json.js.map +1 -0
  26. package/dist/src/core/common.js +787 -0
  27. package/dist/src/core/common.js.map +1 -0
  28. package/dist/src/core/config.js +78 -0
  29. package/dist/src/core/config.js.map +1 -0
  30. package/dist/src/core/context/parent-snapshot.js +75 -0
  31. package/dist/src/core/context/parent-snapshot.js.map +1 -0
  32. package/dist/src/core/context/token-budget.js +631 -0
  33. package/dist/src/core/context/token-budget.js.map +1 -0
  34. package/dist/src/core/context/visible-conversation-v2.js +390 -0
  35. package/dist/src/core/context/visible-conversation-v2.js.map +1 -0
  36. package/dist/src/core/delegate/artifacts.js +357 -0
  37. package/dist/src/core/delegate/artifacts.js.map +1 -0
  38. package/dist/src/core/delegate/budget.js +266 -0
  39. package/dist/src/core/delegate/budget.js.map +1 -0
  40. package/dist/src/core/delegate/facade-contract.js +7 -0
  41. package/dist/src/core/delegate/facade-contract.js.map +1 -0
  42. package/dist/src/core/delegate/hook-contract-evidence.json +18 -0
  43. package/dist/src/core/delegate/hook-contract.js +124 -0
  44. package/dist/src/core/delegate/hook-contract.js.map +1 -0
  45. package/dist/src/core/delegate/launch.js +366 -0
  46. package/dist/src/core/delegate/launch.js.map +1 -0
  47. package/dist/src/core/delegate/result-package.js +293 -0
  48. package/dist/src/core/delegate/result-package.js.map +1 -0
  49. package/dist/src/core/delegate/runner.js +398 -0
  50. package/dist/src/core/delegate/runner.js.map +1 -0
  51. package/dist/src/core/delegate/seed.js +334 -0
  52. package/dist/src/core/delegate/seed.js.map +1 -0
  53. package/dist/src/core/delegate/types.js +103 -0
  54. package/dist/src/core/delegate/types.js.map +1 -0
  55. package/dist/src/core/durable-fs.js +372 -0
  56. package/dist/src/core/durable-fs.js.map +1 -0
  57. package/dist/src/core/extension-api.js +382 -0
  58. package/dist/src/core/extension-api.js.map +1 -0
  59. package/dist/src/core/fusion/artifacts.js +654 -0
  60. package/dist/src/core/fusion/artifacts.js.map +1 -0
  61. package/dist/src/core/fusion/budget.js +949 -0
  62. package/dist/src/core/fusion/budget.js.map +1 -0
  63. package/dist/src/core/fusion/child-protocol.js +176 -0
  64. package/dist/src/core/fusion/child-protocol.js.map +1 -0
  65. package/dist/src/core/fusion/claude-cache.js +156 -0
  66. package/dist/src/core/fusion/claude-cache.js.map +1 -0
  67. package/dist/src/core/fusion/clean-context.js +64 -0
  68. package/dist/src/core/fusion/clean-context.js.map +1 -0
  69. package/dist/src/core/fusion/config.js +349 -0
  70. package/dist/src/core/fusion/config.js.map +1 -0
  71. package/dist/src/core/fusion/context.js +184 -0
  72. package/dist/src/core/fusion/context.js.map +1 -0
  73. package/dist/src/core/fusion/evaluation.js +696 -0
  74. package/dist/src/core/fusion/evaluation.js.map +1 -0
  75. package/dist/src/core/fusion/facade-contract.js +3 -0
  76. package/dist/src/core/fusion/facade-contract.js.map +1 -0
  77. package/dist/src/core/fusion/orchestrator.js +867 -0
  78. package/dist/src/core/fusion/orchestrator.js.map +1 -0
  79. package/dist/src/core/fusion/output-contract.js +27 -0
  80. package/dist/src/core/fusion/output-contract.js.map +1 -0
  81. package/dist/src/core/fusion/pi-child.js +1741 -0
  82. package/dist/src/core/fusion/pi-child.js.map +1 -0
  83. package/dist/src/core/fusion/prompts.js +260 -0
  84. package/dist/src/core/fusion/prompts.js.map +1 -0
  85. package/dist/src/core/fusion/result-package.js +857 -0
  86. package/dist/src/core/fusion/result-package.js.map +1 -0
  87. package/dist/src/core/fusion/source-policy.js +259 -0
  88. package/dist/src/core/fusion/source-policy.js.map +1 -0
  89. package/dist/src/core/fusion/types.js +207 -0
  90. package/dist/src/core/fusion/types.js.map +1 -0
  91. package/dist/src/core/fusion/web-fetch.js +777 -0
  92. package/dist/src/core/fusion/web-fetch.js.map +1 -0
  93. package/dist/src/core/fusion/workflows.js +126 -0
  94. package/dist/src/core/fusion/workflows.js.map +1 -0
  95. package/dist/src/core/lazy-module.js +181 -0
  96. package/dist/src/core/lazy-module.js.map +1 -0
  97. package/dist/src/core/pi-launch.js +431 -0
  98. package/dist/src/core/pi-launch.js.map +1 -0
  99. package/dist/src/core/registry.js +3331 -0
  100. package/dist/src/core/registry.js.map +1 -0
  101. package/dist/src/core/reload-shell-owner.js +1396 -0
  102. package/dist/src/core/reload-shell-owner.js.map +1 -0
  103. package/dist/src/core/shell-policy.js +80 -0
  104. package/dist/src/core/shell-policy.js.map +1 -0
  105. package/dist/src/core/task-durable.js +51 -0
  106. package/dist/src/core/task-durable.js.map +1 -0
  107. package/dist/src/core/update-check.js +92 -0
  108. package/dist/src/core/update-check.js.map +1 -0
  109. package/dist/src/core/windows-taskkill.js +185 -0
  110. package/dist/src/core/windows-taskkill.js.map +1 -0
  111. package/dist/src/delegate-child-extension.js +776 -0
  112. package/dist/src/delegate-child-extension.js.map +1 -0
  113. package/dist/src/delegate-extension.js +746 -0
  114. package/dist/src/delegate-extension.js.map +1 -0
  115. package/dist/src/extension.js +953 -0
  116. package/dist/src/extension.js.map +1 -0
  117. package/dist/src/fusion-child-extension.js +760 -0
  118. package/dist/src/fusion-child-extension.js.map +1 -0
  119. package/dist/src/fusion-extension.js +1030 -0
  120. package/dist/src/fusion-extension.js.map +1 -0
  121. package/dist/src/ui/background-tasks-manager.js +689 -0
  122. package/dist/src/ui/background-tasks-manager.js.map +1 -0
  123. package/dist/src/ui/fusion-model-selector.js +277 -0
  124. package/dist/src/ui/fusion-model-selector.js.map +1 -0
  125. package/docs/INDEX.md +35 -33
  126. package/docs/api/eventbus-v1.md +21 -5
  127. package/docs/choose-a-workflow.md +2 -0
  128. package/docs/commands/bg-clear.md +3 -3
  129. package/docs/commands/bg-update.md +3 -3
  130. package/docs/commands/bg.md +18 -6
  131. package/docs/commands/claude-cache.md +6 -6
  132. package/docs/commands/fusion-models.md +49 -4
  133. package/docs/commands/fusion.md +3 -3
  134. package/docs/commands/jobs.md +3 -3
  135. package/docs/commands/kill.md +3 -3
  136. package/docs/commands/logs.md +3 -3
  137. package/docs/commands/task-manager.md +5 -5
  138. package/docs/concepts/completion-delivery.md +6 -2
  139. package/docs/getting-started.md +7 -1
  140. package/docs/manifest.json +228 -42
  141. package/docs/operations/configuration.md +58 -5
  142. package/docs/operations/releasing.md +4 -2
  143. package/docs/operations/testing.md +22 -0
  144. package/docs/operations/troubleshooting.md +6 -2
  145. package/docs/read-before-edit.md +44 -31
  146. package/docs/reference/runtime-contracts.md +87 -51
  147. package/docs/reference/shortcuts-and-dock.md +28 -14
  148. package/docs/subsystems/anthropic-attribution.md +35 -13
  149. package/docs/subsystems/attested-pi-runs.md +12 -7
  150. package/docs/subsystems/background-task-runtime.md +53 -11
  151. package/docs/subsystems/child-launch-durability-and-safety.md +28 -9
  152. package/docs/subsystems/delegation.md +16 -3
  153. package/docs/subsystems/docs-freshness-gate.md +14 -7
  154. package/docs/subsystems/fusion.md +36 -11
  155. package/docs/subsystems/host-ui-and-telemetry.md +22 -9
  156. package/docs/tools/bg_delegate.md +3 -1
  157. package/docs/tools/bg_kill.md +3 -1
  158. package/docs/tools/bg_logs.md +3 -1
  159. package/docs/tools/bg_result.md +5 -3
  160. package/docs/tools/bg_run.md +31 -5
  161. package/docs/tools/bg_run_pi_attested.md +3 -1
  162. package/docs/tools/bg_status.md +3 -1
  163. package/docs/tools/fusion_investigate.md +3 -1
  164. package/docs/tools/fusion_reason.md +3 -1
  165. package/docs/tools/fusion_research.md +3 -1
  166. package/docs/tools/fusion_validate.md +3 -1
  167. package/extensions/anthropic-attribution-child.ts +3 -0
  168. package/extensions/anthropic-attribution.ts +166 -1
  169. package/package.json +12 -10
  170. package/src/core/anthropic-attribution-path.ts +5 -4
  171. package/src/core/anthropic-attribution.ts +1505 -237
  172. package/src/core/attested-pi-contract.ts +4 -0
  173. package/src/core/attested-pi-run.ts +468 -81
  174. package/src/core/canonical-json.ts +21 -0
  175. package/src/core/common.ts +450 -40
  176. package/src/core/config.ts +121 -0
  177. package/src/core/context/visible-conversation-v2.ts +3 -6
  178. package/src/core/delegate/artifacts.ts +13 -9
  179. package/src/core/delegate/budget.ts +13 -6
  180. package/src/core/delegate/facade-contract.ts +6 -0
  181. package/src/core/delegate/result-package.ts +32 -14
  182. package/src/core/delegate/runner.ts +129 -58
  183. package/src/core/delegate/seed.ts +5 -7
  184. package/src/core/durable-fs.ts +178 -30
  185. package/src/core/extension-api.ts +36 -8
  186. package/src/core/fusion/artifacts.ts +17 -16
  187. package/src/core/fusion/clean-context.ts +8 -5
  188. package/src/core/fusion/config.ts +2 -1
  189. package/src/core/fusion/context.ts +2 -6
  190. package/src/core/fusion/facade-contract.ts +2 -0
  191. package/src/core/fusion/orchestrator.ts +3 -3
  192. package/src/core/fusion/prompts.ts +1 -1
  193. package/src/core/fusion/result-package.ts +259 -72
  194. package/src/core/fusion/source-policy.ts +43 -17
  195. package/src/core/lazy-module.ts +215 -0
  196. package/src/core/pi-launch.ts +390 -65
  197. package/src/core/registry.ts +2010 -381
  198. package/src/core/reload-shell-owner.ts +1662 -0
  199. package/src/core/shell-policy.ts +134 -0
  200. package/src/core/task-durable.ts +67 -0
  201. package/src/delegate-extension.ts +404 -144
  202. package/src/extension.ts +387 -114
  203. package/src/fusion-extension.ts +202 -96
@@ -1,13 +1,20 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { appendFileSync, readFileSync } from 'node:fs';
3
3
  import { homedir } from 'node:os';
4
- import { join } from 'node:path';
4
+ import { isAbsolute, join } from 'node:path';
5
+ import { anthropicMessagesApi } from '@earendil-works/pi-ai/compat';
6
+ import type {
7
+ AssistantMessageEventStream as HostAssistantMessageEventStream,
8
+ Context as HostContext,
9
+ Model as HostModel,
10
+ SimpleStreamOptions as HostSimpleStreamOptions,
11
+ } from '@earendil-works/pi-ai';
5
12
 
6
13
  export const CLAUDE_CODE_SESSION_HEADER = 'X-Claude-Code-Session-Id';
7
14
 
8
- const CLAUDE_CODE_VERSION = '2.1.173';
15
+ const CLAUDE_CODE_VERSION = '2.1.251';
9
16
  const CLAUDE_CODE_ENTRYPOINT = 'sdk-cli';
10
- const CLAUDE_CODE_USER_AGENT = 'claude-cli/2.1.173 (external, sdk-cli)';
17
+ const CLAUDE_CODE_USER_AGENT = 'claude-cli/2.1.251 (external, sdk-cli)';
11
18
  export const ANTHROPIC_1M_CONTEXT_BETA = 'context-1m-2025-08-07' as const;
12
19
  export const CLAUDE_CODE_200K_SUBSCRIPTION_CONTEXT_WINDOW = 200_000 as const;
13
20
 
@@ -20,7 +27,9 @@ type ClaudeCode200KSubscriptionBetaValue =
20
27
  | 'prompt-caching-scope-2026-01-05'
21
28
  | 'advisor-tool-2026-03-01'
22
29
  | 'structured-outputs-2025-12-15'
23
- | 'mid-conversation-system-2026-04-07';
30
+ | 'mid-conversation-system-2026-04-07'
31
+ | 'thinking-binding-controls-2026-08-01'
32
+ | 'cache-diagnosis-2026-04-07';
24
33
 
25
34
  const CLAUDE_CODE_LEGACY_BETA_VALUES = [
26
35
  'claude-code-20250219',
@@ -41,6 +50,11 @@ const CLAUDE_CODE_ADAPTIVE_200K_BETA_VALUES = [
41
50
  'prompt-caching-scope-2026-01-05',
42
51
  'mid-conversation-system-2026-04-07',
43
52
  ] as const satisfies readonly ClaudeCode200KSubscriptionBetaValue[];
53
+ const CLAUDE_CODE_FABLE_5_1_200K_BETA_VALUES = [
54
+ ...CLAUDE_CODE_ADAPTIVE_200K_BETA_VALUES,
55
+ 'thinking-binding-controls-2026-08-01',
56
+ 'cache-diagnosis-2026-04-07',
57
+ ] as const satisfies readonly ClaudeCode200KSubscriptionBetaValue[];
44
58
 
45
59
  function build200KSubscriptionBetaHeader(
46
60
  values: readonly ClaudeCode200KSubscriptionBetaValue[],
@@ -57,17 +71,32 @@ export const CLAUDE_CODE_BETA = build200KSubscriptionBetaHeader(CLAUDE_CODE_LEGA
57
71
  const CLAUDE_CODE_ADAPTIVE_200K_BETA = build200KSubscriptionBetaHeader(
58
72
  CLAUDE_CODE_ADAPTIVE_200K_BETA_VALUES,
59
73
  );
74
+ const CLAUDE_CODE_FABLE_5_1_200K_BETA = build200KSubscriptionBetaHeader(
75
+ CLAUDE_CODE_FABLE_5_1_200K_BETA_VALUES,
76
+ );
60
77
  const CLAUDE_AGENT_SDK_SYSTEM_TEXT =
61
78
  "You are a Claude agent, built on Anthropic's Claude Agent SDK.";
62
79
  const FINGERPRINT_SALT = '59cf53e54c78';
63
80
  const AUDIT_ENV = 'PIPELINE_ANTHROPIC_ATTRIBUTION_AUDIT_PATH';
64
81
  const CACHE_RETENTION_ENV = 'PI_CACHE_RETENTION';
82
+ export const ANTHROPIC_ACCOUNT_CONFIG_PATH_ENV = 'PI_ANTHROPIC_ACCOUNT_CONFIG_PATH';
65
83
  export const ANTHROPIC_CACHE_RETENTION_ENTRY = 'pipeline-anthropic-cache-retention';
66
84
  const ANTHROPIC_CACHE_RETENTION_SCHEMA = 'pipeline.anthropic_cache_retention.v1';
67
85
  export const ANTHROPIC_ATTRIBUTION_CLAIM_CHANNEL = 'pi-anthropic-attribution:claim:v1';
68
86
  const ANTHROPIC_ATTRIBUTION_CLAIM_SCHEMA = 'pi-anthropic-attribution.claim.v1';
69
87
  const NATIVE_ATTESTATION_PLACEHOLDER = '00000';
70
88
  const ANTHROPIC_CACHE_CONTROL_BREAKPOINT_LIMIT = 4;
89
+ const ANTHROPIC_LINEAGE_DIAGNOSTIC_TYPE = 'anthropic-cache-lineage';
90
+ const ANTHROPIC_LINEAGE_SCHEMA = 'pi-anthropic-attribution.lineage.v1';
91
+ // Bump whenever any system/tool/message wire projection changes. Old receipts then
92
+ // become legacy and cannot authorize signature replay under a rewritten prefix.
93
+ const ANTHROPIC_PROJECTION_VERSION = 3;
94
+ const ANTHROPIC_OFFICIAL_ORIGIN = 'https://api.anthropic.com';
95
+ const ANTHROPIC_BETA_MESSAGES_URL = `${ANTHROPIC_OFFICIAL_ORIGIN}/v1/messages?beta=true`;
96
+ const ANTHROPIC_CACHE_DIAGNOSTICS_BETA = 'cache-diagnosis-2026-04-07';
97
+ const ANTHROPIC_THINKING_BINDING_BETA = 'thinking-binding-controls-2026-08-01';
98
+ const COMPACTION_SUMMARY_PREFIX =
99
+ 'The conversation history before this point was compacted into the following summary:';
71
100
 
72
101
  // Sanitization behavior derived from the MIT-licensed ravshansbox/pi-anthropic-sps
73
102
  // extension at commit 17409b5615f0ec0625776bc5434f92f2c55e3fd0. Keep exact-match
@@ -125,8 +154,11 @@ interface PiModelCostLike extends PiCostRatesLike {
125
154
  export interface PiModelLike {
126
155
  readonly provider?: string;
127
156
  readonly id?: string;
157
+ readonly name?: string;
128
158
  readonly api?: string;
129
159
  readonly baseUrl?: string;
160
+ readonly input?: readonly ('text' | 'image')[];
161
+ readonly contextWindow?: number;
130
162
  readonly maxTokens?: number;
131
163
  readonly reasoning?: boolean;
132
164
  readonly compat?: {
@@ -143,12 +175,18 @@ export interface ClaudeCodeModelPolicy {
143
175
  readonly beta: string;
144
176
  readonly thinkingPolicy: ClaudeCodeThinkingPolicy;
145
177
  readonly contextWindow: typeof CLAUDE_CODE_200K_SUBSCRIPTION_CONTEXT_WINDOW;
178
+ readonly enforcesThinkingPrefixBinding: boolean;
179
+ readonly supportsCacheDiagnostics: boolean;
146
180
  }
147
181
 
148
182
  function claudeCode200KSubscriptionPolicy(
149
183
  modelId: string,
150
184
  beta: string,
151
185
  thinkingPolicy: ClaudeCodeThinkingPolicy,
186
+ features: {
187
+ readonly enforcesThinkingPrefixBinding?: boolean;
188
+ readonly supportsCacheDiagnostics?: boolean;
189
+ } = {},
152
190
  ): ClaudeCodeModelPolicy {
153
191
  if (beta.split(',').includes(ANTHROPIC_1M_CONTEXT_BETA)) {
154
192
  throw new Error(
@@ -160,6 +198,8 @@ function claudeCode200KSubscriptionPolicy(
160
198
  beta,
161
199
  thinkingPolicy,
162
200
  contextWindow: CLAUDE_CODE_200K_SUBSCRIPTION_CONTEXT_WINDOW,
201
+ enforcesThinkingPrefixBinding: features.enforcesThinkingPrefixBinding === true,
202
+ supportsCacheDiagnostics: features.supportsCacheDiagnostics === true,
163
203
  };
164
204
  }
165
205
 
@@ -209,6 +249,12 @@ const CLAUDE_CODE_MODEL_POLICIES: Record<string, ClaudeCodeModelPolicy> = Object
209
249
  CLAUDE_CODE_ADAPTIVE_200K_BETA,
210
250
  'adaptive-effort',
211
251
  ),
252
+ 'claude-fable-5-1': claudeCode200KSubscriptionPolicy(
253
+ 'claude-fable-5-1',
254
+ CLAUDE_CODE_FABLE_5_1_200K_BETA,
255
+ 'adaptive-effort',
256
+ { enforcesThinkingPrefixBinding: true, supportsCacheDiagnostics: true },
257
+ ),
212
258
  'claude-haiku-4-5': claudeCode200KSubscriptionPolicy(
213
259
  'claude-haiku-4-5',
214
260
  CLAUDE_CODE_BETA,
@@ -344,10 +390,6 @@ export interface PiExtensionHost extends PiProviderRegistrationHost {
344
390
  eventName: 'session_start' | 'session_shutdown' | 'session_tree' | 'before_agent_start',
345
391
  handler: (event: unknown, ctx: PiContextLike) => void,
346
392
  ): void;
347
- on(
348
- eventName: 'before_provider_request',
349
- handler: (event: { readonly payload: unknown }, ctx: PiContextLike) => unknown,
350
- ): void;
351
393
  registerCommand(name: string, config: PiCommandConfigLike): void;
352
394
  appendEntry(customType: string, data?: unknown): void;
353
395
  }
@@ -356,14 +398,36 @@ type PiContentBlock =
356
398
  | { readonly type: 'text'; readonly text: string }
357
399
  | { readonly type: 'image'; readonly mimeType: string; readonly data: string };
358
400
 
401
+ interface PiAssistantDiagnosticLike {
402
+ readonly type: string;
403
+ readonly timestamp: number;
404
+ readonly details?: JsonObject;
405
+ }
406
+
359
407
  type PiMessage =
360
- | { readonly role: 'user'; readonly content: string | readonly PiContentBlock[] }
361
- | { readonly role: 'assistant'; readonly content: readonly JsonObject[] }
408
+ | {
409
+ readonly role: 'user';
410
+ readonly content: string | readonly PiContentBlock[];
411
+ readonly timestamp?: number;
412
+ }
413
+ | {
414
+ readonly role: 'assistant';
415
+ readonly content: readonly JsonObject[];
416
+ readonly provider?: string;
417
+ readonly api?: string;
418
+ readonly model?: string;
419
+ readonly responseId?: string;
420
+ readonly stopReason?: string;
421
+ readonly diagnostics?: readonly PiAssistantDiagnosticLike[];
422
+ readonly timestamp?: number;
423
+ }
362
424
  | {
363
425
  readonly role: 'toolResult';
364
426
  readonly toolCallId: string;
427
+ readonly toolName?: string;
365
428
  readonly content: readonly PiContentBlock[];
366
429
  readonly isError?: boolean;
430
+ readonly timestamp?: number;
367
431
  };
368
432
 
369
433
  export interface PiStreamContext {
@@ -402,6 +466,10 @@ export interface PiSimpleStreamOptions {
402
466
  ) => Promise<void> | void;
403
467
  }
404
468
 
469
+ export interface AnthropicTransportDependencies {
470
+ readonly loadAccount?: () => ClaudeAttributionAccount;
471
+ }
472
+
405
473
  export interface AssistantMessageLike {
406
474
  role: 'assistant';
407
475
  content: JsonObject[];
@@ -414,13 +482,20 @@ export interface AssistantMessageLike {
414
482
  cacheRead: number;
415
483
  cacheWrite: number;
416
484
  cacheWrite1h?: number;
485
+ reasoning?: number;
417
486
  totalTokens: number;
418
487
  cost: { input: number; output: number; cacheRead: number; cacheWrite: number; total: number };
419
488
  };
420
- stopReason: 'stop' | 'length' | 'toolUse' | 'aborted' | 'error';
489
+ stopReason: 'pending' | 'stop' | 'length' | 'toolUse' | 'aborted' | 'error' | 'deferred';
421
490
  timestamp: number;
491
+ responseModel?: string;
422
492
  responseId?: string;
493
+ providerThinkingLevel?: string;
494
+ endTurn?: boolean;
495
+ diagnostics?: PiAssistantDiagnosticLike[];
496
+ deferred?: unknown;
423
497
  errorMessage?: string;
498
+ rawStopReason?: string;
424
499
  }
425
500
 
426
501
  type AssistantMessageEvent =
@@ -569,12 +644,8 @@ function parseCacheRetention(value: string, source: string): CacheRetention {
569
644
  );
570
645
  }
571
646
 
572
- /**
573
- * Resolve retention without allowing the extension default to override an
574
- * explicit call-level posture (notably Pi's cacheRetention=none compaction calls).
575
- * Precedence: request option -> persisted session override -> process/provider env
576
- * -> the repo policy default of one hour.
577
- */
647
+ /** Resolve an explicit request posture. Registered Pi sessions apply the stronger
648
+ * one-hour policy through resolveRegisteredCacheRetention below. */
578
649
  export function resolveCacheRetentionPreference(
579
650
  options?: {
580
651
  readonly cacheRetention?: CacheRetention;
@@ -589,6 +660,20 @@ export function resolveCacheRetentionPreference(
589
660
  return 'long';
590
661
  }
591
662
 
663
+ function resolveRegisteredCacheRetention(
664
+ options: PiSimpleStreamOptions | undefined,
665
+ sessionOverride: Exclude<CacheRetention, 'none'> | undefined,
666
+ ): CacheRetention {
667
+ if (options?.cacheRetention === 'none') return 'none';
668
+ if (sessionOverride !== undefined) return sessionOverride;
669
+ const configured = providerEnvValue(CACHE_RETENTION_ENV, options?.env);
670
+ if (configured !== undefined) return parseCacheRetention(configured, CACHE_RETENTION_ENV);
671
+ if (options?.cacheRetention === 'long') return 'long';
672
+ // Pi's generic provider default is five minutes. Subscription coding sessions
673
+ // routinely have turns longer than that, so the attributed route pins one hour.
674
+ return 'long';
675
+ }
676
+
592
677
  /** Restore the latest branch-local command decision; custom entries stay out of LLM context. */
593
678
  export function restoreAnthropicSessionCacheRetention(
594
679
  entries: readonly unknown[],
@@ -715,8 +800,48 @@ function inspectCacheControls(payload: JsonObject): CacheControlInspection {
715
800
  return { count, retention: count === 0 ? undefined : hasLong ? 'long' : 'short' };
716
801
  }
717
802
 
803
+ interface CacheControlOccurrence {
804
+ readonly path: string;
805
+ readonly control: AnthropicCacheControl;
806
+ }
807
+
808
+ function cacheControlOccurrences(payload: JsonObject): CacheControlOccurrence[] {
809
+ const occurrences: CacheControlOccurrence[] = [];
810
+ const inspectBlocks = (value: unknown, path: string): void => {
811
+ if (!Array.isArray(value)) return;
812
+ value.forEach((block, index) => {
813
+ if (!isPlainObject(block)) return;
814
+ const blockPath = `${path}[${String(index)}]`;
815
+ if (block['cache_control'] !== undefined) {
816
+ const control = cloneAnthropicCacheControl(block['cache_control']);
817
+ if (control !== undefined) occurrences.push({ path: blockPath, control });
818
+ }
819
+ if (block['type'] === 'tool_result') inspectBlocks(block['content'], `${blockPath}.content`);
820
+ });
821
+ };
822
+
823
+ if (payload['cache_control'] !== undefined) {
824
+ const control = cloneAnthropicCacheControl(payload['cache_control']);
825
+ if (control !== undefined) occurrences.push({ path: '$', control });
826
+ }
827
+ inspectBlocks(payload['system'], '$.system');
828
+ inspectBlocks(payload['tools'], '$.tools');
829
+ const messages = payload['messages'];
830
+ if (Array.isArray(messages)) {
831
+ messages.forEach((message, index) => {
832
+ if (isPlainObject(message))
833
+ inspectBlocks(message['content'], `$.messages[${String(index)}].content`);
834
+ });
835
+ }
836
+ return occurrences;
837
+ }
838
+
718
839
  function countCacheControlBreakpoints(payload: JsonObject): number {
719
- return inspectCacheControls(payload).count;
840
+ return cacheControlOccurrences(payload).length;
841
+ }
842
+
843
+ function cacheControlTopologySha256(payload: JsonObject): string {
844
+ return sha256Canonical(cacheControlOccurrences(payload));
720
845
  }
721
846
 
722
847
  function assertCacheControlBreakpointLimit(payload: JsonObject): void {
@@ -760,20 +885,43 @@ export function extractClaudeAttributionAccount(
760
885
  };
761
886
  }
762
887
 
888
+ export function resolveClaudeAttributionConfigPath(
889
+ explicitConfigPath?: string,
890
+ env?: ProviderEnv,
891
+ homeDirectory = homedir(),
892
+ ): string {
893
+ const configuredPath = providerEnvValue(ANTHROPIC_ACCOUNT_CONFIG_PATH_ENV, env);
894
+ const selectedPath = explicitConfigPath ?? configuredPath ?? join(homeDirectory, '.claude.json');
895
+ const source =
896
+ explicitConfigPath !== undefined
897
+ ? 'explicit configPath'
898
+ : configuredPath !== undefined
899
+ ? ANTHROPIC_ACCOUNT_CONFIG_PATH_ENV
900
+ : 'default ~/.claude.json path';
901
+ if (selectedPath.trim().length === 0 || !isAbsolute(selectedPath)) {
902
+ throw new Error(
903
+ `Anthropic attribution ${source} must be a non-empty absolute file path; got ${JSON.stringify(selectedPath)}`,
904
+ );
905
+ }
906
+ return selectedPath;
907
+ }
908
+
763
909
  export function loadClaudeAttributionAccount(
764
- configPath = join(homedir(), '.claude.json'),
910
+ configPath?: string,
911
+ env?: ProviderEnv,
765
912
  ): ClaudeAttributionAccount {
913
+ const selectedPath = resolveClaudeAttributionConfigPath(configPath, env);
766
914
  let configText: string;
767
915
  try {
768
- configText = readFileSync(configPath, 'utf8');
916
+ configText = readFileSync(selectedPath, 'utf8');
769
917
  } catch (error) {
770
918
  throw new Error(
771
- `Anthropic attribution config ${configPath} could not be read: ${error instanceof Error ? error.message : String(error)}`,
919
+ `Anthropic attribution config ${selectedPath} could not be read: ${error instanceof Error ? error.message : String(error)}`,
772
920
  );
773
921
  }
774
922
  return extractClaudeAttributionAccount(
775
- parseJsonValue(configText, `Anthropic attribution config ${configPath}`),
776
- configPath,
923
+ parseJsonValue(configText, `Anthropic attribution config ${selectedPath}`),
924
+ selectedPath,
777
925
  );
778
926
  }
779
927
 
@@ -781,12 +929,15 @@ export function isAnthropicContext(ctx: PiContextLike): boolean {
781
929
  return ctx.model?.provider === 'anthropic';
782
930
  }
783
931
 
784
- function getSessionId(ctx: PiContextLike): string {
785
- const sessionId = ctx.sessionManager.getSessionId();
786
- if (typeof sessionId !== 'string' || sessionId.trim().length === 0) {
787
- throw new Error('Anthropic attribution requires a non-empty Pi session id');
932
+ function requireSessionId(value: unknown, source: string): string {
933
+ if (typeof value !== 'string' || value.trim().length === 0) {
934
+ throw new Error(`Anthropic attribution requires a non-empty ${source}`);
788
935
  }
789
- return sessionId;
936
+ return value;
937
+ }
938
+
939
+ function getSessionId(ctx: PiContextLike): string {
940
+ return requireSessionId(ctx.sessionManager.getSessionId(), 'Pi session id');
790
941
  }
791
942
 
792
943
  function normalizedAnthropicModelId(model: PiModelLike): string {
@@ -867,16 +1018,22 @@ export function registerAnthropicAttributionProvider(
867
1018
  pi: PiProviderRegistrationHost,
868
1019
  ctx: PiContextLike,
869
1020
  getSessionOverride: () => Exclude<CacheRetention, 'none'> | undefined = () => undefined,
1021
+ dependencies: AnthropicTransportDependencies = {},
870
1022
  ): void {
871
1023
  if (!isAnthropicContext(ctx)) return;
872
1024
  pi.registerProvider('anthropic', {
873
1025
  api: 'anthropic-messages',
874
1026
  headers: buildAnthropicAttributionHeaders(getSessionId(ctx), ctx.model),
875
1027
  streamSimple: (model, context, options) =>
876
- streamAnthropicViaBetaMessages(model, context, {
877
- ...(options ?? {}),
878
- cacheRetention: resolveCacheRetentionPreference(options, getSessionOverride()),
879
- }),
1028
+ streamAnthropicViaBetaMessages(
1029
+ model,
1030
+ context,
1031
+ {
1032
+ ...(options ?? {}),
1033
+ cacheRetention: resolveRegisteredCacheRetention(options, getSessionOverride()),
1034
+ },
1035
+ dependencies,
1036
+ ),
880
1037
  });
881
1038
  }
882
1039
 
@@ -1031,26 +1188,50 @@ function appendAuditRecord(args: {
1031
1188
  appendFileSync(auditPath, `${JSON.stringify(record)}\n`, { encoding: 'utf8', mode: 0o600 });
1032
1189
  }
1033
1190
 
1034
- export function rewriteAnthropicRequestPayload(args: {
1191
+ function requireAttributionAccount(value: unknown): ClaudeAttributionAccount {
1192
+ if (
1193
+ !isPlainObject(value) ||
1194
+ typeof value['deviceId'] !== 'string' ||
1195
+ value['deviceId'].trim().length === 0 ||
1196
+ typeof value['accountUuid'] !== 'string' ||
1197
+ value['accountUuid'].trim().length === 0
1198
+ ) {
1199
+ throw new Error('Anthropic attribution account loader returned malformed account identity');
1200
+ }
1201
+ return { deviceId: value['deviceId'], accountUuid: value['accountUuid'] };
1202
+ }
1203
+
1204
+ function anthropicMetadataUserId(account: ClaudeAttributionAccount, sessionId: string): string {
1205
+ return JSON.stringify({
1206
+ account_uuid: account.accountUuid,
1207
+ device_id: account.deviceId,
1208
+ session_id: sessionId,
1209
+ });
1210
+ }
1211
+
1212
+ interface AnthropicAttributionForSessionArgs {
1035
1213
  readonly payload: unknown;
1036
- readonly ctx: PiContextLike;
1214
+ readonly model: PiModelLike;
1215
+ readonly sessionId: string;
1037
1216
  readonly account: ClaudeAttributionAccount;
1038
1217
  readonly headerRegistered?: boolean;
1039
1218
  readonly cacheRetention?: CacheRetention;
1040
- readonly env?: ProviderEnv;
1041
- }): unknown {
1042
- if (!isAnthropicContext(args.ctx)) return undefined;
1219
+ }
1220
+
1221
+ function rewriteAnthropicRequestPayloadForSession(
1222
+ args: AnthropicAttributionForSessionArgs,
1223
+ ): JsonObject {
1043
1224
  if (!isPlainObject(args.payload)) {
1044
1225
  throw new Error('Anthropic attribution expected provider payload to be a JSON object');
1045
1226
  }
1046
1227
 
1047
- const sessionId = getSessionId(args.ctx);
1228
+ const sessionId = requireSessionId(args.sessionId, 'provider options.sessionId');
1048
1229
  const metadata = args.payload['metadata'] === undefined ? {} : args.payload['metadata'];
1049
1230
  if (!isPlainObject(metadata)) {
1050
1231
  throw new Error('Anthropic attribution expected payload.metadata to be an object when present');
1051
1232
  }
1052
1233
 
1053
- const policy = resolveClaudeCodeModelPolicy(args.ctx.model ?? {});
1234
+ const policy = resolveClaudeCodeModelPolicy(args.model);
1054
1235
  const maxTokens =
1055
1236
  args.payload['max_tokens'] === undefined
1056
1237
  ? undefined
@@ -1068,7 +1249,7 @@ export function rewriteAnthropicRequestPayload(args: {
1068
1249
  const cacheControl =
1069
1250
  configuredCacheRetention === undefined
1070
1251
  ? undefined
1071
- : resolveAnthropicCacheControl(args.ctx.model, {
1252
+ : resolveAnthropicCacheControl(args.model, {
1072
1253
  cacheRetention: configuredCacheRetention,
1073
1254
  });
1074
1255
 
@@ -1076,11 +1257,7 @@ export function rewriteAnthropicRequestPayload(args: {
1076
1257
  ...args.payload,
1077
1258
  metadata: {
1078
1259
  ...metadata,
1079
- user_id: JSON.stringify({
1080
- account_uuid: args.account.accountUuid,
1081
- device_id: args.account.deviceId,
1082
- session_id: sessionId,
1083
- }),
1260
+ user_id: anthropicMetadataUserId(args.account, sessionId),
1084
1261
  },
1085
1262
  system: withClaudeCodeSystemIdentity(args.payload['system'], billingSystemText, cacheControl),
1086
1263
  };
@@ -1101,24 +1278,111 @@ export function rewriteAnthropicRequestPayload(args: {
1101
1278
  return rewritten;
1102
1279
  }
1103
1280
 
1281
+ function assertProtectedAnthropicAttribution(args: {
1282
+ readonly payload: JsonObject;
1283
+ readonly account: ClaudeAttributionAccount;
1284
+ readonly sessionId: string;
1285
+ readonly billingSystemText: string;
1286
+ readonly modelId: string;
1287
+ readonly cacheControlTopologySha256: string;
1288
+ }): void {
1289
+ if (args.payload['model'] !== args.modelId || args.payload['stream'] !== true) {
1290
+ throw new Error(
1291
+ 'Anthropic protected attribution model/stream route changed during payload middleware',
1292
+ );
1293
+ }
1294
+ const metadata = args.payload['metadata'];
1295
+ if (
1296
+ !isPlainObject(metadata) ||
1297
+ metadata['user_id'] !== anthropicMetadataUserId(args.account, args.sessionId)
1298
+ ) {
1299
+ throw new Error(
1300
+ 'Anthropic protected attribution metadata.user_id/account/device/session_id changed during payload middleware',
1301
+ );
1302
+ }
1303
+ const system = args.payload['system'];
1304
+ if (!Array.isArray(system)) {
1305
+ throw new Error(
1306
+ 'Anthropic protected attribution system identity was removed during payload middleware',
1307
+ );
1308
+ }
1309
+ const billingBlocks = system.filter(
1310
+ (block) =>
1311
+ isPlainObject(block) &&
1312
+ typeof block['text'] === 'string' &&
1313
+ block['text'].startsWith('x-anthropic-billing-header:'),
1314
+ );
1315
+ const sdkBlocks = system.filter(
1316
+ (block) => isPlainObject(block) && block['text'] === CLAUDE_AGENT_SDK_SYSTEM_TEXT,
1317
+ );
1318
+ if (
1319
+ billingBlocks.length !== 1 ||
1320
+ billingBlocks[0]?.['text'] !== args.billingSystemText ||
1321
+ sdkBlocks.length !== 1 ||
1322
+ system[0] !== billingBlocks[0] ||
1323
+ system[1] !== sdkBlocks[0]
1324
+ ) {
1325
+ throw new Error(
1326
+ 'Anthropic protected attribution system identity changed during payload middleware',
1327
+ );
1328
+ }
1329
+ assertCacheControlBreakpointLimit(args.payload);
1330
+ if (cacheControlTopologySha256(args.payload) !== args.cacheControlTopologySha256) {
1331
+ throw new Error('Anthropic protected cache-control topology changed during payload middleware');
1332
+ }
1333
+ }
1334
+
1335
+ export function rewriteAnthropicRequestPayload(args: {
1336
+ readonly payload: unknown;
1337
+ readonly ctx: PiContextLike;
1338
+ readonly account: ClaudeAttributionAccount;
1339
+ readonly headerRegistered?: boolean;
1340
+ readonly cacheRetention?: CacheRetention;
1341
+ readonly env?: ProviderEnv;
1342
+ }): unknown {
1343
+ if (!isAnthropicContext(args.ctx)) return undefined;
1344
+ return rewriteAnthropicRequestPayloadForSession({
1345
+ payload: args.payload,
1346
+ model: args.ctx.model ?? {},
1347
+ sessionId: getSessionId(args.ctx),
1348
+ account: args.account,
1349
+ ...(args.headerRegistered === undefined ? {} : { headerRegistered: args.headerRegistered }),
1350
+ ...(args.cacheRetention === undefined ? {} : { cacheRetention: args.cacheRetention }),
1351
+ });
1352
+ }
1353
+
1104
1354
  function sanitizeSurrogates(text: string): string {
1105
- return text.replace(/[\uD800-\uDFFF]/g, '\uFFFD');
1355
+ let sanitized = '';
1356
+ for (let index = 0; index < text.length; index += 1) {
1357
+ const code = text.charCodeAt(index);
1358
+ if (code >= 0xd800 && code <= 0xdbff) {
1359
+ const next = index + 1 < text.length ? text.charCodeAt(index + 1) : -1;
1360
+ if (next >= 0xdc00 && next <= 0xdfff) {
1361
+ sanitized += text[index] ?? '';
1362
+ sanitized += text[index + 1] ?? '';
1363
+ index += 1;
1364
+ } else {
1365
+ sanitized += '\uFFFD';
1366
+ }
1367
+ } else if (code >= 0xdc00 && code <= 0xdfff) {
1368
+ sanitized += '\uFFFD';
1369
+ } else {
1370
+ sanitized += text[index] ?? '';
1371
+ }
1372
+ }
1373
+ return sanitized;
1106
1374
  }
1107
1375
 
1108
- function convertContentBlocks(content: readonly PiContentBlock[]): string | JsonObject[] {
1109
- const hasImages = content.some((block) => block.type === 'image');
1110
- if (!hasImages)
1111
- return sanitizeSurrogates(
1112
- content.map((block) => (block.type === 'text' ? block.text : '')).join('\n'),
1113
- );
1114
- const blocks = content.map((block) => {
1376
+ function convertContentBlocks(content: readonly PiContentBlock[]): JsonObject[] {
1377
+ const blocks: JsonObject[] = content.map((block) => {
1115
1378
  if (block.type === 'text') return { type: 'text', text: sanitizeSurrogates(block.text) };
1116
1379
  return {
1117
1380
  type: 'image',
1118
1381
  source: { type: 'base64', media_type: block.mimeType, data: block.data },
1119
1382
  };
1120
1383
  });
1121
- if (!blocks.some((block) => block.type === 'text'))
1384
+ if (blocks.length === 0) blocks.push({ type: 'text', text: '' });
1385
+ if (!blocks.some((block) => block['type'] === 'text'))
1122
1386
  blocks.unshift({ type: 'text', text: '(see attached image)' });
1123
1387
  return blocks;
1124
1388
  }
@@ -1146,9 +1410,9 @@ function markMessageContentCacheSurface(
1146
1410
  if (role !== 'user' && role !== 'assistant') return false;
1147
1411
  const content = message['content'];
1148
1412
  if (typeof content === 'string') {
1149
- if (content.trim().length === 0) return false;
1150
- message['content'] = [{ type: 'text', text: content, cache_control: { ...cacheControl } }];
1151
- return true;
1413
+ throw new Error(
1414
+ 'Anthropic attribution cache projection encountered non-canonical string message content',
1415
+ );
1152
1416
  }
1153
1417
  if (!Array.isArray(content)) return false;
1154
1418
  for (let index = content.length - 1; index >= 0; index -= 1) {
@@ -1173,92 +1437,448 @@ function markLastConversationCacheSurface(
1173
1437
  return output;
1174
1438
  }
1175
1439
 
1440
+ function canonicalizeJson(value: unknown): unknown {
1441
+ if (Array.isArray(value)) return value.map(canonicalizeJson);
1442
+ if (!isPlainObject(value)) return value;
1443
+ const output: JsonObject = {};
1444
+ for (const key of Object.keys(value).sort()) {
1445
+ const child = value[key];
1446
+ if (child !== undefined) output[key] = canonicalizeJson(child);
1447
+ }
1448
+ return output;
1449
+ }
1450
+
1451
+ function canonicalJson(value: unknown): string {
1452
+ return JSON.stringify(canonicalizeJson(value));
1453
+ }
1454
+
1455
+ function sha256Canonical(value: unknown): string {
1456
+ return createHash('sha256').update(canonicalJson(value), 'utf8').digest('hex');
1457
+ }
1458
+
1459
+ function isSha256(value: unknown): value is string {
1460
+ return typeof value === 'string' && /^[a-f0-9]{64}$/u.test(value);
1461
+ }
1462
+
1463
+ function isNonEmptyString(value: unknown): value is string {
1464
+ return typeof value === 'string' && value.trim().length > 0;
1465
+ }
1466
+
1467
+ function blockWithoutCacheControl(block: unknown): unknown {
1468
+ if (!isPlainObject(block)) return block;
1469
+ const output = { ...block };
1470
+ delete output['cache_control'];
1471
+ return output;
1472
+ }
1473
+
1474
+ function promptMessagesWithoutCacheControls(messages: readonly JsonObject[]): JsonObject[] {
1475
+ return messages.map((message) => {
1476
+ const content = message['content'];
1477
+ return {
1478
+ ...message,
1479
+ ...(Array.isArray(content) ? { content: content.map(blockWithoutCacheControl) } : {}),
1480
+ };
1481
+ });
1482
+ }
1483
+
1484
+ function promptMessagesHash(messages: readonly JsonObject[]): string {
1485
+ return sha256Canonical(promptMessagesWithoutCacheControls(messages));
1486
+ }
1487
+
1488
+ function assistantContentHash(content: readonly JsonObject[]): string {
1489
+ return sha256Canonical(content);
1490
+ }
1491
+
1492
+ interface AnthropicLineageDetails {
1493
+ readonly schema_version: typeof ANTHROPIC_LINEAGE_SCHEMA;
1494
+ readonly projection_version: typeof ANTHROPIC_PROJECTION_VERSION;
1495
+ readonly source_provider: 'anthropic';
1496
+ readonly source_api: 'anthropic-messages';
1497
+ readonly source_model: string;
1498
+ readonly response_id: string;
1499
+ readonly assistant_content_sha256: string;
1500
+ readonly conversation_static_sha256: string;
1501
+ readonly request_message_count: number;
1502
+ readonly request_messages_sha256: string;
1503
+ readonly cache_profile_sha256: string;
1504
+ readonly cache_retention: CacheRetention;
1505
+ readonly compaction_boundary_sha256: string | null;
1506
+ readonly signature_epoch_sha256: string;
1507
+ readonly signature_epoch_inherits_prior: boolean;
1508
+ readonly previous_message_id: string | null;
1509
+ }
1510
+
1511
+ function parseAnthropicLineageDetails(
1512
+ message: Extract<PiMessage, { role: 'assistant' }>,
1513
+ ): AnthropicLineageDetails | undefined {
1514
+ const diagnostic = [...(message.diagnostics ?? [])]
1515
+ .reverse()
1516
+ .find((candidate) => candidate.type === ANTHROPIC_LINEAGE_DIAGNOSTIC_TYPE);
1517
+ const details = diagnostic?.details;
1518
+ if (!isPlainObject(details)) return undefined;
1519
+
1520
+ const sourceModel = details['source_model'];
1521
+ const responseId = details['response_id'];
1522
+ const assistantContentSha256 = details['assistant_content_sha256'];
1523
+ const conversationStaticSha256 = details['conversation_static_sha256'];
1524
+ const requestMessageCount = details['request_message_count'];
1525
+ const requestMessagesSha256 = details['request_messages_sha256'];
1526
+ const cacheProfileSha256 = details['cache_profile_sha256'];
1527
+ const cacheRetention = details['cache_retention'];
1528
+ const compactionBoundarySha256 = details['compaction_boundary_sha256'];
1529
+ const signatureEpochSha256 = details['signature_epoch_sha256'];
1530
+ const signatureEpochInheritsPrior = details['signature_epoch_inherits_prior'];
1531
+ const previousMessageId = details['previous_message_id'];
1532
+ if (
1533
+ details['schema_version'] !== ANTHROPIC_LINEAGE_SCHEMA ||
1534
+ details['projection_version'] !== ANTHROPIC_PROJECTION_VERSION ||
1535
+ details['source_provider'] !== 'anthropic' ||
1536
+ details['source_api'] !== 'anthropic-messages' ||
1537
+ !isNonEmptyString(sourceModel) ||
1538
+ !isNonEmptyString(responseId) ||
1539
+ !isSha256(assistantContentSha256) ||
1540
+ !isSha256(conversationStaticSha256) ||
1541
+ typeof requestMessageCount !== 'number' ||
1542
+ !Number.isSafeInteger(requestMessageCount) ||
1543
+ requestMessageCount < 0 ||
1544
+ !isSha256(requestMessagesSha256) ||
1545
+ !isSha256(cacheProfileSha256) ||
1546
+ (cacheRetention !== 'none' && cacheRetention !== 'short' && cacheRetention !== 'long') ||
1547
+ (compactionBoundarySha256 !== null && !isSha256(compactionBoundarySha256)) ||
1548
+ !isSha256(signatureEpochSha256) ||
1549
+ typeof signatureEpochInheritsPrior !== 'boolean' ||
1550
+ (previousMessageId !== null && !isNonEmptyString(previousMessageId))
1551
+ ) {
1552
+ return undefined;
1553
+ }
1554
+
1555
+ return {
1556
+ schema_version: ANTHROPIC_LINEAGE_SCHEMA,
1557
+ projection_version: ANTHROPIC_PROJECTION_VERSION,
1558
+ source_provider: 'anthropic',
1559
+ source_api: 'anthropic-messages',
1560
+ source_model: sourceModel,
1561
+ response_id: responseId,
1562
+ assistant_content_sha256: assistantContentSha256,
1563
+ conversation_static_sha256: conversationStaticSha256,
1564
+ request_message_count: requestMessageCount,
1565
+ request_messages_sha256: requestMessagesSha256,
1566
+ cache_profile_sha256: cacheProfileSha256,
1567
+ cache_retention: cacheRetention,
1568
+ compaction_boundary_sha256: compactionBoundarySha256,
1569
+ signature_epoch_sha256: signatureEpochSha256,
1570
+ signature_epoch_inherits_prior: signatureEpochInheritsPrior,
1571
+ previous_message_id: previousMessageId,
1572
+ };
1573
+ }
1574
+
1575
+ function isSuccessfulLineageStopReason(
1576
+ stopReason: string | undefined,
1577
+ ): stopReason is 'stop' | 'length' | 'toolUse' {
1578
+ return stopReason === 'stop' || stopReason === 'length' || stopReason === 'toolUse';
1579
+ }
1580
+
1581
+ function lineageBindsContainingAssistant(
1582
+ message: Extract<PiMessage, { role: 'assistant' }>,
1583
+ lineage: AnthropicLineageDetails,
1584
+ ): boolean {
1585
+ return (
1586
+ message.provider === lineage.source_provider &&
1587
+ message.api === lineage.source_api &&
1588
+ isNonEmptyString(message.model) &&
1589
+ message.model === lineage.source_model &&
1590
+ isSuccessfulLineageStopReason(message.stopReason) &&
1591
+ isNonEmptyString(message.responseId) &&
1592
+ message.responseId === lineage.response_id &&
1593
+ lineage.assistant_content_sha256 === assistantContentHash(message.content)
1594
+ );
1595
+ }
1596
+
1597
+ function canTargetReadAnthropicThinking(sourceModel: string, targetModel: string): boolean {
1598
+ if (sourceModel === targetModel) return true;
1599
+ return (
1600
+ targetModel === 'claude-fable-5-1' && CLAUDE_CODE_MODEL_POLICIES[sourceModel] !== undefined
1601
+ );
1602
+ }
1603
+
1604
+ interface SignatureEpochPolicy {
1605
+ readonly sha256: string;
1606
+ readonly inheritsPrior: boolean;
1607
+ }
1608
+
1609
+ function initialSignatureEpochPolicy(
1610
+ targetModelId: string,
1611
+ cacheRetention: CacheRetention,
1612
+ compactionBoundarySha256: string | null,
1613
+ ): SignatureEpochPolicy {
1614
+ return {
1615
+ sha256: sha256Canonical({
1616
+ projection_version: ANTHROPIC_PROJECTION_VERSION,
1617
+ kind: 'initial',
1618
+ target_model: targetModelId,
1619
+ cache_retention: cacheRetention,
1620
+ compaction_boundary_sha256: compactionBoundarySha256,
1621
+ }),
1622
+ inheritsPrior: compactionBoundarySha256 === null,
1623
+ };
1624
+ }
1625
+
1626
+ function resolveSignatureEpochPolicy(
1627
+ model: PiModelLike,
1628
+ messages: readonly PiMessage[],
1629
+ cacheRetention: CacheRetention,
1630
+ compactionBoundarySha256: string | null,
1631
+ ): SignatureEpochPolicy {
1632
+ const targetModelId = normalizedAnthropicModelId(model);
1633
+ const latestState = targetLineageState({ messages }, targetModelId);
1634
+ if (latestState.kind === 'none') {
1635
+ return initialSignatureEpochPolicy(targetModelId, cacheRetention, compactionBoundarySha256);
1636
+ }
1637
+ if (latestState.kind === 'untrusted') {
1638
+ return {
1639
+ sha256: sha256Canonical({
1640
+ projection_version: ANTHROPIC_PROJECTION_VERSION,
1641
+ kind: 'untrusted-lineage-reset',
1642
+ target_model: targetModelId,
1643
+ cache_retention: cacheRetention,
1644
+ compaction_boundary_sha256: compactionBoundarySha256,
1645
+ latest_assistant_sha256: latestState.assistantSha256,
1646
+ }),
1647
+ inheritsPrior: false,
1648
+ };
1649
+ }
1650
+
1651
+ const latest = latestState.lineage;
1652
+ const retentionChanged = latest.cache_retention !== cacheRetention;
1653
+ const declaredNewCompaction =
1654
+ compactionBoundarySha256 !== null &&
1655
+ compactionBoundarySha256 !== latest.compaction_boundary_sha256;
1656
+ if (retentionChanged || declaredNewCompaction) {
1657
+ return {
1658
+ sha256: sha256Canonical({
1659
+ projection_version: ANTHROPIC_PROJECTION_VERSION,
1660
+ kind: 'transition',
1661
+ previous_signature_epoch_sha256: latest.signature_epoch_sha256,
1662
+ target_model: targetModelId,
1663
+ cache_retention: cacheRetention,
1664
+ compaction_boundary_sha256: compactionBoundarySha256,
1665
+ reason: retentionChanged ? 'cache-retention' : 'compaction',
1666
+ }),
1667
+ inheritsPrior: false,
1668
+ };
1669
+ }
1670
+ return {
1671
+ sha256: latest.signature_epoch_sha256,
1672
+ inheritsPrior: latest.signature_epoch_inherits_prior,
1673
+ };
1674
+ }
1675
+
1676
+ function isTrustedReplayableAnthropicAssistant(
1677
+ message: Extract<PiMessage, { role: 'assistant' }>,
1678
+ targetModel: PiModelLike,
1679
+ targetCacheRetention: CacheRetention,
1680
+ conversationStaticSha256: string,
1681
+ wireMessagesBeforeAssistant: readonly JsonObject[],
1682
+ ): boolean {
1683
+ if (typeof message.model !== 'string') return false;
1684
+ const lineage = parseAnthropicLineageDetails(message);
1685
+ if (
1686
+ lineage === undefined ||
1687
+ !lineageBindsContainingAssistant(message, lineage) ||
1688
+ !canTargetReadAnthropicThinking(message.model, normalizedAnthropicModelId(targetModel)) ||
1689
+ lineage.cache_retention !== targetCacheRetention ||
1690
+ lineage.conversation_static_sha256 !== conversationStaticSha256 ||
1691
+ lineage.request_message_count !== wireMessagesBeforeAssistant.length ||
1692
+ lineage.request_messages_sha256 !== promptMessagesHash(wireMessagesBeforeAssistant)
1693
+ ) {
1694
+ return false;
1695
+ }
1696
+ return true;
1697
+ }
1698
+
1699
+ function normalizeAnthropicToolCallId(id: string): string {
1700
+ if (/^[a-zA-Z0-9_-]{1,64}$/u.test(id)) return id;
1701
+ const safe = id.replace(/[^a-zA-Z0-9_-]/gu, '_');
1702
+ const digest = createHash('sha256').update(id, 'utf8').digest('hex').slice(0, 12);
1703
+ const prefix = safe.slice(0, 64 - digest.length - 1) || 'tool';
1704
+ return `${prefix}_${digest}`.slice(0, 64);
1705
+ }
1706
+
1707
+ function projectedUserContent(content: string | readonly PiContentBlock[]): JsonObject[] {
1708
+ if (typeof content === 'string') {
1709
+ return content.trim().length > 0 ? [{ type: 'text', text: sanitizeSurrogates(content) }] : [];
1710
+ }
1711
+ const blocks: JsonObject[] = [];
1712
+ for (const block of content) {
1713
+ if (block.type === 'text') {
1714
+ if (block.text.trim().length > 0)
1715
+ blocks.push({ type: 'text', text: sanitizeSurrogates(block.text) });
1716
+ continue;
1717
+ }
1718
+ if (block.type === 'image') {
1719
+ blocks.push({
1720
+ type: 'image',
1721
+ source: { type: 'base64', media_type: block.mimeType, data: block.data },
1722
+ });
1723
+ continue;
1724
+ }
1725
+ throw new Error('Anthropic attribution encountered an unsupported user content block');
1726
+ }
1727
+ return blocks;
1728
+ }
1729
+
1730
+ function compactionBoundarySha256FromPiMessages(messages: readonly PiMessage[]): string | null {
1731
+ const first = messages[0];
1732
+ if (first?.role !== 'user') return null;
1733
+ const content = projectedUserContent(first.content);
1734
+ return declaredCompactionBoundarySha256([{ role: 'user', content }]);
1735
+ }
1736
+
1176
1737
  function convertMessages(
1738
+ model: PiModelLike,
1177
1739
  messages: readonly PiMessage[],
1740
+ conversationStaticSha256: string,
1741
+ cacheRetention: CacheRetention,
1742
+ signatureEpoch: SignatureEpochPolicy,
1178
1743
  cacheControl?: AnthropicCacheControl,
1179
1744
  ): JsonObject[] {
1180
1745
  const params: JsonObject[] = [];
1746
+ const toolCallIdMap = new Map<string, string>();
1747
+ const normalizedToolCallOwners = new Map<string, string>();
1748
+ let pendingToolCalls: Array<{ readonly id: string; readonly name: string }> = [];
1749
+ let completedPendingToolCallIds = new Set<string>();
1750
+
1751
+ const normalizeToolCallId = (id: string): string => {
1752
+ const existing = toolCallIdMap.get(id);
1753
+ if (existing !== undefined) return existing;
1754
+ const normalized = normalizeAnthropicToolCallId(id);
1755
+ const owner = normalizedToolCallOwners.get(normalized);
1756
+ if (owner !== undefined && owner !== id) {
1757
+ throw new Error('Anthropic attribution tool-call ID normalization collision');
1758
+ }
1759
+ normalizedToolCallOwners.set(normalized, id);
1760
+ toolCallIdMap.set(id, normalized);
1761
+ return normalized;
1762
+ };
1763
+
1764
+ const flushMissingToolResults = (): void => {
1765
+ const missing = pendingToolCalls.filter((call) => !completedPendingToolCallIds.has(call.id));
1766
+ if (missing.length > 0) {
1767
+ params.push({
1768
+ role: 'user',
1769
+ content: missing.map((call) => ({
1770
+ type: 'tool_result',
1771
+ tool_use_id: call.id,
1772
+ content: 'No result provided',
1773
+ is_error: true,
1774
+ })),
1775
+ });
1776
+ }
1777
+ pendingToolCalls = [];
1778
+ completedPendingToolCallIds = new Set<string>();
1779
+ };
1780
+
1181
1781
  for (let index = 0; index < messages.length; index += 1) {
1182
1782
  const message = messages[index];
1183
- if (message === undefined) {
1184
- throw new TypeError(`Anthropic message ${index} is missing`);
1185
- }
1783
+ if (message === undefined) throw new TypeError(`Anthropic message ${index} is missing`);
1784
+
1186
1785
  if (message.role === 'user') {
1187
- if (typeof message.content === 'string') {
1188
- if (message.content.trim().length > 0)
1189
- params.push({ role: 'user', content: sanitizeSurrogates(message.content) });
1190
- } else {
1191
- const content = message.content
1192
- .map((block) =>
1193
- block.type === 'text'
1194
- ? { type: 'text', text: sanitizeSurrogates(block.text) }
1195
- : {
1196
- type: 'image',
1197
- source: { type: 'base64', media_type: block.mimeType, data: block.data },
1198
- },
1199
- )
1200
- .filter((block) => block.type !== 'text' || String(block.text).trim().length > 0);
1201
- if (content.length > 0) params.push({ role: 'user', content });
1202
- }
1203
- } else if (message.role === 'assistant') {
1786
+ flushMissingToolResults();
1787
+ const content = projectedUserContent(message.content);
1788
+ if (content.length > 0) params.push({ role: 'user', content });
1789
+ continue;
1790
+ }
1791
+
1792
+ if (message.role === 'assistant') {
1793
+ flushMissingToolResults();
1794
+ if (message.stopReason === 'error' || message.stopReason === 'aborted') continue;
1795
+ const lineage = parseAnthropicLineageDetails(message);
1796
+ const epochAllowsReplay =
1797
+ signatureEpoch.inheritsPrior || lineage?.signature_epoch_sha256 === signatureEpoch.sha256;
1798
+ const preserveThinking =
1799
+ epochAllowsReplay &&
1800
+ isTrustedReplayableAnthropicAssistant(
1801
+ message,
1802
+ model,
1803
+ cacheRetention,
1804
+ conversationStaticSha256,
1805
+ params,
1806
+ );
1204
1807
  const content: JsonObject[] = [];
1205
1808
  for (const block of message.content) {
1206
- if (
1207
- block['type'] === 'text' &&
1208
- typeof block['text'] === 'string' &&
1209
- block['text'].trim().length > 0
1210
- ) {
1211
- content.push({ type: 'text', text: sanitizeSurrogates(block['text']) });
1212
- } else if (
1213
- block['type'] === 'thinking' &&
1214
- typeof block['thinking'] === 'string' &&
1215
- block['thinking'].trim().length > 0
1216
- ) {
1809
+ if (block['type'] === 'text') {
1810
+ if (typeof block['text'] !== 'string')
1811
+ throw new Error('Anthropic attribution encountered malformed assistant text');
1812
+ if (block['text'].trim().length > 0)
1813
+ content.push({ type: 'text', text: sanitizeSurrogates(block['text']) });
1814
+ continue;
1815
+ }
1816
+ if (block['type'] === 'thinking') {
1817
+ if (typeof block['thinking'] !== 'string')
1818
+ throw new Error('Anthropic attribution encountered malformed assistant thinking');
1217
1819
  const signature =
1218
1820
  typeof block['thinkingSignature'] === 'string' ? block['thinkingSignature'] : '';
1219
- content.push(
1220
- signature.length > 0
1221
- ? { type: 'thinking', thinking: sanitizeSurrogates(block['thinking']), signature }
1222
- : { type: 'text', text: sanitizeSurrogates(block['thinking']) },
1223
- );
1224
- } else if (
1225
- block['type'] === 'toolCall' &&
1226
- typeof block['id'] === 'string' &&
1227
- typeof block['name'] === 'string'
1228
- ) {
1821
+ if (preserveThinking && signature.trim().length > 0) {
1822
+ content.push(
1823
+ block['redacted'] === true
1824
+ ? { type: 'redacted_thinking', data: signature }
1825
+ : {
1826
+ type: 'thinking',
1827
+ // A signature authenticates the exact provider-returned reasoning bytes.
1828
+ // Provenance and lineage checks above make this raw replay safe; changing
1829
+ // even valid non-BMP Unicode here would invalidate the opaque signature.
1830
+ thinking: block['thinking'],
1831
+ signature,
1832
+ },
1833
+ );
1834
+ } else if (block['redacted'] !== true && block['thinking'].trim().length > 0) {
1835
+ content.push({ type: 'text', text: sanitizeSurrogates(block['thinking']) });
1836
+ }
1837
+ continue;
1838
+ }
1839
+ if (block['type'] === 'toolCall') {
1840
+ if (typeof block['id'] !== 'string' || typeof block['name'] !== 'string')
1841
+ throw new Error('Anthropic attribution encountered malformed assistant tool call');
1842
+ const id = normalizeToolCallId(block['id']);
1229
1843
  content.push({
1230
1844
  type: 'tool_use',
1231
- id: block['id'],
1845
+ id,
1232
1846
  name: block['name'],
1233
- input: block['arguments'] ?? {},
1847
+ input: canonicalizeJson(block['arguments'] ?? {}),
1234
1848
  });
1849
+ continue;
1235
1850
  }
1851
+ throw new Error(
1852
+ `Anthropic attribution encountered unsupported assistant block type ${JSON.stringify(block['type'])}`,
1853
+ );
1236
1854
  }
1237
- if (content.length > 0) params.push({ role: 'assistant', content });
1238
- } else if (message.role === 'toolResult') {
1239
- const toolResults: JsonObject[] = [
1240
- {
1241
- type: 'tool_result',
1242
- tool_use_id: message.toolCallId,
1243
- content: convertContentBlocks(message.content),
1244
- is_error: message.isError === true,
1245
- },
1246
- ];
1247
- let lookahead = index + 1;
1248
- while (lookahead < messages.length && messages[lookahead]?.role === 'toolResult') {
1249
- const next = messages[lookahead] as Extract<PiMessage, { role: 'toolResult' }>;
1250
- toolResults.push({
1251
- type: 'tool_result',
1252
- tool_use_id: next.toolCallId,
1253
- content: convertContentBlocks(next.content),
1254
- is_error: next.isError === true,
1255
- });
1256
- lookahead += 1;
1855
+ if (content.length > 0) {
1856
+ params.push({ role: 'assistant', content });
1857
+ pendingToolCalls = content
1858
+ .filter((block) => block['type'] === 'tool_use')
1859
+ .map((block) => ({ id: String(block['id']), name: String(block['name']) }));
1257
1860
  }
1258
- index = lookahead - 1;
1259
- params.push({ role: 'user', content: toolResults });
1861
+ continue;
1862
+ }
1863
+
1864
+ const toolResults: JsonObject[] = [];
1865
+ let lookahead = index;
1866
+ while (lookahead < messages.length && messages[lookahead]?.role === 'toolResult') {
1867
+ const result = messages[lookahead] as Extract<PiMessage, { role: 'toolResult' }>;
1868
+ const toolUseId = normalizeToolCallId(result.toolCallId);
1869
+ completedPendingToolCallIds.add(toolUseId);
1870
+ toolResults.push({
1871
+ type: 'tool_result',
1872
+ tool_use_id: toolUseId,
1873
+ content: convertContentBlocks(result.content),
1874
+ is_error: result.isError === true,
1875
+ });
1876
+ lookahead += 1;
1260
1877
  }
1878
+ index = lookahead - 1;
1879
+ if (toolResults.length > 0) params.push({ role: 'user', content: toolResults });
1261
1880
  }
1881
+ flushMissingToolResults();
1262
1882
  return markLastConversationCacheSurface(params, cacheControl);
1263
1883
  }
1264
1884
 
@@ -1274,8 +1894,10 @@ function convertTools(
1274
1894
  description: tool.description ?? '',
1275
1895
  input_schema: {
1276
1896
  type: 'object',
1277
- properties: isPlainObject(parameters['properties']) ? parameters['properties'] : {},
1278
- required: Array.isArray(parameters['required']) ? parameters['required'] : [],
1897
+ properties: canonicalizeJson(
1898
+ isPlainObject(parameters['properties']) ? parameters['properties'] : {},
1899
+ ),
1900
+ required: Array.isArray(parameters['required']) ? [...parameters['required']] : [],
1279
1901
  },
1280
1902
  };
1281
1903
  return cacheControl !== undefined && index === tools.length - 1
@@ -1317,45 +1939,98 @@ function adaptiveEffortFor(
1317
1939
  }
1318
1940
  }
1319
1941
 
1942
+ function conversationStaticHash(system: readonly unknown[], tools: readonly JsonObject[]): string {
1943
+ const normalizedSystem = system
1944
+ .filter(
1945
+ (block) =>
1946
+ !isPlainObject(block) ||
1947
+ typeof block['text'] !== 'string' ||
1948
+ !isClaudeCodeIdentityText(block['text']),
1949
+ )
1950
+ .map(blockWithoutCacheControl);
1951
+ return sha256Canonical({
1952
+ attribution_profile: {
1953
+ claude_code_version: CLAUDE_CODE_VERSION,
1954
+ entrypoint: CLAUDE_CODE_ENTRYPOINT,
1955
+ },
1956
+ system: normalizedSystem,
1957
+ tools: tools.map(blockWithoutCacheControl),
1958
+ });
1959
+ }
1960
+
1320
1961
  export function buildAnthropicRequestParams(
1321
1962
  model: PiModelLike,
1322
1963
  context: PiStreamContext,
1323
1964
  options?: PiSimpleStreamOptions,
1324
1965
  ): JsonObject {
1966
+ return buildAnthropicRequest(model, context, options).params;
1967
+ }
1968
+
1969
+ function buildAnthropicRequest(
1970
+ model: PiModelLike,
1971
+ context: PiStreamContext,
1972
+ options?: PiSimpleStreamOptions,
1973
+ ): { readonly params: JsonObject; readonly signatureEpoch: SignatureEpochPolicy } {
1325
1974
  const policy = resolveClaudeCodeModelPolicy(model);
1326
1975
  const maxTokens = resolveAnthropicMaxTokens(model);
1327
1976
  const cacheControl = resolveAnthropicCacheControl(model, options);
1977
+ const cacheRetention: CacheRetention =
1978
+ cacheControl === undefined ? 'none' : cacheControl.ttl === '1h' ? 'long' : 'short';
1979
+ let signatureEpoch = resolveSignatureEpochPolicy(
1980
+ model,
1981
+ context.messages,
1982
+ cacheRetention,
1983
+ compactionBoundarySha256FromPiMessages(context.messages),
1984
+ );
1985
+ const system =
1986
+ context.systemPrompt && context.systemPrompt.trim().length > 0
1987
+ ? markSystemCacheSurface(
1988
+ [
1989
+ {
1990
+ type: 'text',
1991
+ text: sanitizeSurrogates(stripAnthropicSystemPromptBadLines(context.systemPrompt)),
1992
+ },
1993
+ ],
1994
+ cacheControl,
1995
+ )
1996
+ : [];
1997
+ const tools = convertTools(
1998
+ context.tools,
1999
+ model.compat?.supportsCacheControlOnTools === false ? undefined : cacheControl,
2000
+ );
2001
+ const staticSha256 = conversationStaticHash(system, tools);
1328
2002
  const params: JsonObject = {
1329
2003
  model: policy.modelId,
1330
- messages: convertMessages(context.messages, cacheControl),
2004
+ messages: convertMessages(
2005
+ model,
2006
+ context.messages,
2007
+ staticSha256,
2008
+ cacheRetention,
2009
+ signatureEpoch,
2010
+ cacheControl,
2011
+ ),
1331
2012
  max_tokens: maxTokens,
1332
2013
  stream: true,
2014
+ tools,
1333
2015
  };
1334
- if (context.systemPrompt && context.systemPrompt.trim().length > 0) {
1335
- params['system'] = markSystemCacheSurface(
1336
- [
1337
- {
1338
- type: 'text',
1339
- text: sanitizeSurrogates(stripAnthropicSystemPromptBadLines(context.systemPrompt)),
1340
- },
1341
- ],
1342
- cacheControl,
1343
- );
1344
- }
1345
- const tools = convertTools(
1346
- context.tools,
1347
- model.compat?.supportsCacheControlOnTools === false ? undefined : cacheControl,
1348
- );
1349
- if (tools.length > 0) params['tools'] = tools;
1350
- else params['tools'] = [];
1351
- if (options?.toolChoice !== undefined) params['tool_choice'] = options.toolChoice;
2016
+ if (system.length > 0) params['system'] = system;
2017
+ if (options?.toolChoice !== undefined)
2018
+ params['tool_choice'] = canonicalizeJson(options.toolChoice);
1352
2019
  const reasoning = options?.reasoning;
1353
2020
  if (model.reasoning && reasoning !== undefined) {
1354
2021
  if (reasoning === 'off') {
2022
+ if (policy.enforcesThinkingPrefixBinding) {
2023
+ throw new Error('Anthropic attribution cannot disable thinking for Claude Fable 5.1');
2024
+ }
1355
2025
  params['thinking'] = { type: 'disabled' };
1356
2026
  params['temperature'] = options?.temperature ?? 1;
1357
2027
  } else if (policy.thinkingPolicy === 'adaptive-effort') {
1358
- params['thinking'] = { type: 'adaptive' };
2028
+ params['thinking'] = {
2029
+ type: 'adaptive',
2030
+ ...(policy.enforcesThinkingPrefixBinding
2031
+ ? { block_binding: { prefix_mismatch_behavior: 'error' } }
2032
+ : {}),
2033
+ };
1359
2034
  params['output_config'] = { effort: adaptiveEffortFor(reasoning) };
1360
2035
  } else {
1361
2036
  params['thinking'] = {
@@ -1363,12 +2038,364 @@ export function buildAnthropicRequestParams(
1363
2038
  budget_tokens: thinkingBudgetFor(reasoning, maxTokens, options?.thinkingBudgets),
1364
2039
  };
1365
2040
  }
2041
+ } else if (policy.enforcesThinkingPrefixBinding) {
2042
+ params['thinking'] = {
2043
+ type: 'adaptive',
2044
+ block_binding: { prefix_mismatch_behavior: 'error' },
2045
+ };
1366
2046
  } else {
1367
2047
  params['thinking'] = { type: 'disabled' };
1368
2048
  params['temperature'] = options?.temperature ?? 1;
1369
2049
  }
2050
+ const previous = latestLineageForTarget(context, normalizedAnthropicModelId(model));
2051
+ if (
2052
+ previous !== undefined &&
2053
+ signatureEpoch.sha256 === previous.signature_epoch_sha256 &&
2054
+ (previous.cache_profile_sha256 !== cacheProfileHash(model, policy, params, staticSha256) ||
2055
+ !hasLineageRequestPrefix(requestMessagesFromPayload(params), previous))
2056
+ ) {
2057
+ // Resume/reload and profile edits can invalidate a lane. Re-project before transport,
2058
+ // rather than just clearing the response ID while retaining prefix-bound signatures.
2059
+ signatureEpoch = {
2060
+ sha256: sha256Canonical({
2061
+ projection_version: ANTHROPIC_PROJECTION_VERSION,
2062
+ kind: 'lineage-reset',
2063
+ previous_signature_epoch_sha256: previous.signature_epoch_sha256,
2064
+ previous_response_id: previous.response_id,
2065
+ cache_profile_sha256: cacheProfileHash(model, policy, params, staticSha256),
2066
+ request_messages_sha256: promptMessagesHash(requestMessagesFromPayload(params)),
2067
+ }),
2068
+ inheritsPrior: false,
2069
+ };
2070
+ params['messages'] = convertMessages(
2071
+ model,
2072
+ context.messages,
2073
+ staticSha256,
2074
+ cacheRetention,
2075
+ signatureEpoch,
2076
+ cacheControl,
2077
+ );
2078
+ }
1370
2079
  assertCacheControlBreakpointLimit(params);
1371
- return params;
2080
+ return { params, signatureEpoch };
2081
+ }
2082
+
2083
+ interface PreparedAnthropicLineage {
2084
+ readonly key: string;
2085
+ readonly details: Omit<AnthropicLineageDetails, 'response_id' | 'assistant_content_sha256'>;
2086
+ }
2087
+
2088
+ function requestMessagesFromPayload(payload: JsonObject): JsonObject[] {
2089
+ const messages = payload['messages'];
2090
+ if (!Array.isArray(messages) || messages.some((message) => !isPlainObject(message))) {
2091
+ throw new Error('Anthropic attribution final payload.messages must be an object array');
2092
+ }
2093
+ for (const message of messages as JsonObject[]) {
2094
+ if (message['role'] !== 'user' && message['role'] !== 'assistant') {
2095
+ throw new Error('Anthropic attribution final payload message has an unsupported role');
2096
+ }
2097
+ const content = message['content'];
2098
+ if (!Array.isArray(content) || content.some((block) => !isPlainObject(block))) {
2099
+ throw new Error(
2100
+ 'Anthropic attribution final payload message content must use canonical block arrays',
2101
+ );
2102
+ }
2103
+ }
2104
+ return messages as JsonObject[];
2105
+ }
2106
+
2107
+ function systemBlocksFromPayload(payload: JsonObject): unknown[] {
2108
+ const system = payload['system'];
2109
+ if (system === undefined) return [];
2110
+ if (typeof system === 'string') return [{ type: 'text', text: system }];
2111
+ if (!Array.isArray(system))
2112
+ throw new Error('Anthropic attribution final payload.system must be a string or block array');
2113
+ return system;
2114
+ }
2115
+
2116
+ function toolsFromPayload(payload: JsonObject): JsonObject[] {
2117
+ const tools = payload['tools'];
2118
+ if (tools === undefined) return [];
2119
+ if (!Array.isArray(tools) || tools.some((tool) => !isPlainObject(tool))) {
2120
+ throw new Error('Anthropic attribution final payload.tools must be an object array');
2121
+ }
2122
+ return tools as JsonObject[];
2123
+ }
2124
+
2125
+ function cacheProfileHash(
2126
+ model: PiModelLike,
2127
+ policy: ClaudeCodeModelPolicy,
2128
+ payload: JsonObject,
2129
+ staticSha256: string,
2130
+ ): string {
2131
+ return sha256Canonical({
2132
+ projection_version: ANTHROPIC_PROJECTION_VERSION,
2133
+ model: normalizedAnthropicModelId(model),
2134
+ beta: policy.beta,
2135
+ conversation_static_sha256: staticSha256,
2136
+ thinking: payload['thinking'],
2137
+ output_config: payload['output_config'],
2138
+ tool_choice: payload['tool_choice'],
2139
+ });
2140
+ }
2141
+
2142
+ function cacheRetentionFromPayload(payload: JsonObject): CacheRetention {
2143
+ return inspectCacheControls(payload).retention ?? 'none';
2144
+ }
2145
+
2146
+ type TargetLineageState =
2147
+ | { readonly kind: 'none' }
2148
+ | { readonly kind: 'trusted'; readonly lineage: AnthropicLineageDetails }
2149
+ | { readonly kind: 'untrusted'; readonly assistantSha256: string };
2150
+
2151
+ function untrustedAssistantSha256(
2152
+ message: Extract<PiMessage, { role: 'assistant' }>,
2153
+ ): string {
2154
+ return sha256Canonical({
2155
+ provider: message.provider ?? null,
2156
+ api: message.api ?? null,
2157
+ model: message.model ?? null,
2158
+ response_id: message.responseId ?? null,
2159
+ stop_reason: message.stopReason ?? null,
2160
+ assistant_content_sha256: assistantContentHash(message.content),
2161
+ lineage_diagnostics: (message.diagnostics ?? []).filter(
2162
+ (diagnostic) => diagnostic.type === ANTHROPIC_LINEAGE_DIAGNOSTIC_TYPE,
2163
+ ),
2164
+ });
2165
+ }
2166
+
2167
+ function targetLineageState(
2168
+ context: PiStreamContext,
2169
+ targetModelId: string,
2170
+ ): TargetLineageState {
2171
+ for (let index = context.messages.length - 1; index >= 0; index -= 1) {
2172
+ const message = context.messages[index];
2173
+ if (message?.role !== 'assistant') continue;
2174
+
2175
+ const lineageDiagnostics = (message.diagnostics ?? []).filter(
2176
+ (diagnostic) => diagnostic.type === ANTHROPIC_LINEAGE_DIAGNOSTIC_TYPE,
2177
+ );
2178
+ const latestRawDetails = lineageDiagnostics.at(-1)?.details;
2179
+ const receiptClaimsTarget = latestRawDetails?.['source_model'] === targetModelId;
2180
+ const assistantClaimsTarget = message.model === targetModelId;
2181
+ if (!assistantClaimsTarget && !receiptClaimsTarget) continue;
2182
+
2183
+ if (
2184
+ (message.stopReason === 'error' || message.stopReason === 'aborted') &&
2185
+ lineageDiagnostics.length === 0
2186
+ ) {
2187
+ continue;
2188
+ }
2189
+
2190
+ const lineage = parseAnthropicLineageDetails(message);
2191
+ if (
2192
+ lineage?.source_model === targetModelId &&
2193
+ lineageBindsContainingAssistant(message, lineage)
2194
+ ) {
2195
+ return { kind: 'trusted', lineage };
2196
+ }
2197
+ return { kind: 'untrusted', assistantSha256: untrustedAssistantSha256(message) };
2198
+ }
2199
+ return { kind: 'none' };
2200
+ }
2201
+
2202
+ function latestLineageForTarget(
2203
+ context: PiStreamContext,
2204
+ targetModelId: string,
2205
+ ): AnthropicLineageDetails | undefined {
2206
+ const state = targetLineageState(context, targetModelId);
2207
+ return state.kind === 'trusted' ? state.lineage : undefined;
2208
+ }
2209
+
2210
+ function declaredCompactionBoundarySha256(messages: readonly JsonObject[]): string | null {
2211
+ const firstMessage = messages[0];
2212
+ if (firstMessage?.['role'] !== 'user' || !Array.isArray(firstMessage['content'])) return null;
2213
+ const declaresCompaction = firstMessage['content'].some(
2214
+ (block) =>
2215
+ isPlainObject(block) &&
2216
+ block['type'] === 'text' &&
2217
+ typeof block['text'] === 'string' &&
2218
+ block['text'].startsWith(COMPACTION_SUMMARY_PREFIX),
2219
+ );
2220
+ if (!declaresCompaction) return null;
2221
+ return sha256Canonical(promptMessagesWithoutCacheControls([firstMessage])[0]);
2222
+ }
2223
+
2224
+ function hasLineageRequestPrefix(
2225
+ messages: readonly JsonObject[],
2226
+ previous: AnthropicLineageDetails,
2227
+ ): boolean {
2228
+ return (
2229
+ messages.length >= previous.request_message_count &&
2230
+ promptMessagesHash(messages.slice(0, previous.request_message_count)) ===
2231
+ previous.request_messages_sha256
2232
+ );
2233
+ }
2234
+
2235
+ function prepareAnthropicLineageDetails(args: {
2236
+ readonly model: PiModelLike;
2237
+ readonly policy: ClaudeCodeModelPolicy;
2238
+ readonly context: PiStreamContext;
2239
+ readonly payload: JsonObject;
2240
+ readonly signatureEpoch: SignatureEpochPolicy;
2241
+ }): PreparedAnthropicLineage['details'] {
2242
+ const targetModelId = normalizedAnthropicModelId(args.model);
2243
+ const messages = requestMessagesFromPayload(args.payload);
2244
+ const staticSha256 = conversationStaticHash(
2245
+ systemBlocksFromPayload(args.payload),
2246
+ toolsFromPayload(args.payload),
2247
+ );
2248
+ const profileSha256 = cacheProfileHash(args.model, args.policy, args.payload, staticSha256);
2249
+ const cacheRetention = cacheRetentionFromPayload(args.payload);
2250
+ const compactionBoundarySha256 = declaredCompactionBoundarySha256(messages);
2251
+ const signatureEpoch = args.signatureEpoch;
2252
+ let previous = latestLineageForTarget(args.context, targetModelId);
2253
+ if (
2254
+ previous !== undefined &&
2255
+ (signatureEpoch.sha256 !== previous.signature_epoch_sha256 ||
2256
+ !hasLineageRequestPrefix(messages, previous) ||
2257
+ previous.cache_profile_sha256 !== profileSha256 ||
2258
+ previous.cache_retention !== cacheRetention)
2259
+ ) {
2260
+ previous = undefined;
2261
+ }
2262
+ return {
2263
+ schema_version: ANTHROPIC_LINEAGE_SCHEMA,
2264
+ projection_version: ANTHROPIC_PROJECTION_VERSION,
2265
+ source_provider: 'anthropic',
2266
+ source_api: 'anthropic-messages',
2267
+ source_model: targetModelId,
2268
+ conversation_static_sha256: staticSha256,
2269
+ request_message_count: messages.length,
2270
+ request_messages_sha256: promptMessagesHash(messages),
2271
+ cache_profile_sha256: profileSha256,
2272
+ cache_retention: cacheRetention,
2273
+ compaction_boundary_sha256: compactionBoundarySha256,
2274
+ signature_epoch_sha256: signatureEpoch.sha256,
2275
+ signature_epoch_inherits_prior: signatureEpoch.inheritsPrior,
2276
+ previous_message_id: previous?.response_id ?? null,
2277
+ };
2278
+ }
2279
+
2280
+ class AnthropicLineageCoordinator {
2281
+ private readonly inFlight = new Set<string>();
2282
+
2283
+ prepare(args: {
2284
+ readonly sessionId: string;
2285
+ readonly model: PiModelLike;
2286
+ readonly policy: ClaudeCodeModelPolicy;
2287
+ readonly context: PiStreamContext;
2288
+ readonly payload: JsonObject;
2289
+ readonly signatureEpoch: SignatureEpochPolicy;
2290
+ }): PreparedAnthropicLineage {
2291
+ const targetModelId = normalizedAnthropicModelId(args.model);
2292
+ const key = `${args.sessionId}\u0000${targetModelId}`;
2293
+ if (this.inFlight.has(key)) {
2294
+ throw new Error(
2295
+ `Anthropic cache lineage already has an in-flight request for ${targetModelId}; concurrent continuations must fork`,
2296
+ );
2297
+ }
2298
+ const details = prepareAnthropicLineageDetails(args);
2299
+ this.inFlight.add(key);
2300
+ return { key, details };
2301
+ }
2302
+
2303
+ release(prepared: PreparedAnthropicLineage): void {
2304
+ this.inFlight.delete(prepared.key);
2305
+ }
2306
+ }
2307
+
2308
+ const anthropicLineageCoordinator = new AnthropicLineageCoordinator();
2309
+
2310
+ export function createAnthropicLineageDiagnostic(args: {
2311
+ readonly model: PiModelLike;
2312
+ readonly responseId: string;
2313
+ readonly assistantContent: readonly JsonObject[];
2314
+ readonly requestPayload: JsonObject;
2315
+ readonly previousMessageId?: string | null;
2316
+ }): PiAssistantDiagnosticLike {
2317
+ if (!isNonEmptyString(args.responseId)) {
2318
+ throw new Error('Anthropic lineage diagnostic requires a non-empty response ID');
2319
+ }
2320
+ if (args.previousMessageId !== undefined && args.previousMessageId !== null) {
2321
+ if (!isNonEmptyString(args.previousMessageId)) {
2322
+ throw new Error('Anthropic lineage diagnostic requires a non-empty previous response ID');
2323
+ }
2324
+ }
2325
+ const policy = resolveClaudeCodeModelPolicy(args.model);
2326
+ const messages = requestMessagesFromPayload(args.requestPayload);
2327
+ const staticSha256 = conversationStaticHash(
2328
+ systemBlocksFromPayload(args.requestPayload),
2329
+ toolsFromPayload(args.requestPayload),
2330
+ );
2331
+ const cacheRetention = cacheRetentionFromPayload(args.requestPayload);
2332
+ const compactionBoundarySha256 = declaredCompactionBoundarySha256(messages);
2333
+ const signatureEpoch = initialSignatureEpochPolicy(
2334
+ normalizedAnthropicModelId(args.model),
2335
+ cacheRetention,
2336
+ compactionBoundarySha256,
2337
+ );
2338
+ return {
2339
+ type: ANTHROPIC_LINEAGE_DIAGNOSTIC_TYPE,
2340
+ timestamp: Date.now(),
2341
+ details: {
2342
+ schema_version: ANTHROPIC_LINEAGE_SCHEMA,
2343
+ projection_version: ANTHROPIC_PROJECTION_VERSION,
2344
+ source_provider: 'anthropic',
2345
+ source_api: 'anthropic-messages',
2346
+ source_model: normalizedAnthropicModelId(args.model),
2347
+ response_id: args.responseId,
2348
+ assistant_content_sha256: assistantContentHash(args.assistantContent),
2349
+ conversation_static_sha256: staticSha256,
2350
+ request_message_count: messages.length,
2351
+ request_messages_sha256: promptMessagesHash(messages),
2352
+ cache_profile_sha256: cacheProfileHash(args.model, policy, args.requestPayload, staticSha256),
2353
+ cache_retention: cacheRetention,
2354
+ compaction_boundary_sha256: compactionBoundarySha256,
2355
+ signature_epoch_sha256: signatureEpoch.sha256,
2356
+ signature_epoch_inherits_prior: signatureEpoch.inheritsPrior,
2357
+ previous_message_id: args.previousMessageId ?? null,
2358
+ },
2359
+ };
2360
+ }
2361
+
2362
+ function appendLineageDiagnostic(
2363
+ output: AssistantMessageLike,
2364
+ prepared: PreparedAnthropicLineage,
2365
+ ): void {
2366
+ if (!isNonEmptyString(output.responseId)) {
2367
+ throw new Error(
2368
+ 'Anthropic attribution successful response is missing responseId lineage proof',
2369
+ );
2370
+ }
2371
+ output.diagnostics ??= [];
2372
+ output.diagnostics.push({
2373
+ type: ANTHROPIC_LINEAGE_DIAGNOSTIC_TYPE,
2374
+ timestamp: Date.now(),
2375
+ details: {
2376
+ ...prepared.details,
2377
+ response_id: output.responseId,
2378
+ assistant_content_sha256: assistantContentHash(output.content),
2379
+ },
2380
+ });
2381
+ }
2382
+
2383
+ function appendProviderCacheDiagnostic(
2384
+ output: AssistantMessageLike,
2385
+ messageStart: JsonObject,
2386
+ ): void {
2387
+ const diagnostics = messageStart['diagnostics'];
2388
+ const inputTransformations = messageStart['input_transformations'];
2389
+ if (diagnostics === undefined && inputTransformations === undefined) return;
2390
+ output.diagnostics ??= [];
2391
+ output.diagnostics.push({
2392
+ type: 'anthropic-provider-cache',
2393
+ timestamp: Date.now(),
2394
+ details: {
2395
+ cache_diagnostics: canonicalizeJson(diagnostics ?? null),
2396
+ input_transformations: canonicalizeJson(inputTransformations ?? []),
2397
+ },
2398
+ });
1372
2399
  }
1373
2400
 
1374
2401
  function headersToRecord(headers: Headers): Record<string, string> {
@@ -1381,6 +2408,35 @@ function lowerHeaderMap(headers: Record<string, string> | undefined): Record<str
1381
2408
  return output;
1382
2409
  }
1383
2410
 
2411
+ function resolveAnthropicBetaMessagesUrl(model: PiModelLike): string {
2412
+ const configured =
2413
+ typeof model.baseUrl === 'string' && model.baseUrl.trim().length > 0
2414
+ ? model.baseUrl.trim()
2415
+ : ANTHROPIC_OFFICIAL_ORIGIN;
2416
+ let parsed: URL;
2417
+ try {
2418
+ parsed = new URL(configured);
2419
+ } catch (error) {
2420
+ throw new Error(
2421
+ `Anthropic attribution requires the official Anthropic HTTPS endpoint; invalid model.baseUrl: ${error instanceof Error ? error.message : String(error)}`,
2422
+ );
2423
+ }
2424
+ if (
2425
+ parsed.origin !== ANTHROPIC_OFFICIAL_ORIGIN ||
2426
+ parsed.protocol !== 'https:' ||
2427
+ parsed.username.length > 0 ||
2428
+ parsed.password.length > 0 ||
2429
+ (parsed.pathname !== '' && parsed.pathname !== '/') ||
2430
+ parsed.search.length > 0 ||
2431
+ parsed.hash.length > 0
2432
+ ) {
2433
+ throw new Error(
2434
+ `Anthropic attribution requires the official Anthropic HTTPS endpoint ${ANTHROPIC_OFFICIAL_ORIGIN}; refusing model.baseUrl ${JSON.stringify(configured)}`,
2435
+ );
2436
+ }
2437
+ return ANTHROPIC_BETA_MESSAGES_URL;
2438
+ }
2439
+
1384
2440
  function buildFetchHeaders(
1385
2441
  options: PiSimpleStreamOptions | undefined,
1386
2442
  apiKey: string,
@@ -1424,6 +2480,23 @@ function parseStreamingJsonFragment(text: string): unknown {
1424
2480
  }
1425
2481
  }
1426
2482
 
2483
+ function parseCompletedToolInput(text: string): JsonObject {
2484
+ const parsed = parseJsonValue(text, 'Anthropic streamed tool input');
2485
+ if (!isPlainObject(parsed)) {
2486
+ throw new Error('Anthropic streamed tool input must complete as a JSON object');
2487
+ }
2488
+ return parsed;
2489
+ }
2490
+
2491
+ function anthropicStreamErrorMessage(event: JsonObject): string {
2492
+ const error = event['error'];
2493
+ if (isPlainObject(error) && typeof error['message'] === 'string') {
2494
+ const type = typeof error['type'] === 'string' ? `${error['type']}: ` : '';
2495
+ return `Anthropic beta messages stream error: ${type}${error['message']}`;
2496
+ }
2497
+ return 'Anthropic beta messages stream emitted an error event';
2498
+ }
2499
+
1427
2500
  function validCostRate(value: unknown, fallback: number, field: string): number {
1428
2501
  if (value === undefined) return fallback;
1429
2502
  if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
@@ -1523,12 +2596,25 @@ async function* iterateSseEvents(
1523
2596
  let eventName = '';
1524
2597
  let dataLines: string[] = [];
1525
2598
  function flush(): JsonObject | undefined {
1526
- if (dataLines.length === 0) return undefined;
2599
+ if (dataLines.length === 0) {
2600
+ eventName = '';
2601
+ return undefined;
2602
+ }
1527
2603
  const data = dataLines.join('\n');
2604
+ const declaredEventName = eventName;
1528
2605
  eventName = '';
1529
2606
  dataLines = [];
1530
2607
  if (data === '[DONE]') return undefined;
1531
- return parseJsonObject(data, 'Anthropic beta messages SSE event');
2608
+ const event = parseJsonObject(data, 'Anthropic beta messages SSE event');
2609
+ if (
2610
+ declaredEventName.length > 0 &&
2611
+ (typeof event['type'] !== 'string' || event['type'] !== declaredEventName)
2612
+ ) {
2613
+ throw new Error(
2614
+ `Anthropic beta messages SSE event name ${JSON.stringify(declaredEventName)} did not match data.type`,
2615
+ );
2616
+ }
2617
+ return event;
1532
2618
  }
1533
2619
  function consumeLine(line: string): JsonObject | undefined {
1534
2620
  if (line.length === 0) return flush();
@@ -1539,7 +2625,6 @@ async function* iterateSseEvents(
1539
2625
  if (value.startsWith(' ')) value = value.slice(1);
1540
2626
  if (field === 'event') eventName = value;
1541
2627
  if (field === 'data') dataLines.push(value);
1542
- void eventName;
1543
2628
  return undefined;
1544
2629
  }
1545
2630
  try {
@@ -1589,49 +2674,42 @@ function createOutput(model: PiModelLike): AssistantMessageLike {
1589
2674
  };
1590
2675
  }
1591
2676
 
1592
- async function forwardToBuiltInAnthropic(
2677
+ type HostForwardingModel = PiModelLike & HostModel<'anthropic-messages'>;
2678
+ type HostForwardingContext = PiStreamContext & HostContext;
2679
+ type HostForwardingOptions = PiSimpleStreamOptions & HostSimpleStreamOptions;
2680
+ type HostForwardingStream = AssistantMessageEventStreamLike & HostAssistantMessageEventStream;
2681
+
2682
+ function forwardToBuiltInAnthropic(
1593
2683
  model: PiModelLike,
1594
2684
  context: PiStreamContext,
1595
2685
  options: PiSimpleStreamOptions | undefined,
1596
- stream: AssistantMessageEventStreamLike,
1597
- output: AssistantMessageLike,
1598
- ): Promise<void> {
1599
- try {
1600
- const dynamicImport = new Function('specifier', 'return import(specifier)') as (
1601
- specifier: string,
1602
- ) => Promise<{
1603
- streamSimpleAnthropic: (
1604
- model: PiModelLike,
1605
- context: PiStreamContext,
1606
- options?: PiSimpleStreamOptions,
1607
- ) => AssistantMessageEventStreamLike;
1608
- }>;
1609
- const mod = await dynamicImport('@earendil-works/pi-ai/anthropic');
1610
- const delegated = mod.streamSimpleAnthropic(model, context, options);
1611
- for await (const event of delegated) stream.push(event);
1612
- stream.end(await delegated.result());
1613
- } catch (error) {
1614
- output.stopReason = 'error';
1615
- output.errorMessage = `Anthropic attribution could not delegate non-target provider ${JSON.stringify(model.provider)}: ${error instanceof Error ? error.message : String(error)}`;
1616
- stream.push({ type: 'error', reason: 'error', error: output });
1617
- stream.end();
1618
- }
2686
+ ): AssistantMessageEventStreamLike {
2687
+ // Pi aliases these SDK imports to its own version while loading extensions.
2688
+ // The host therefore owns both its legacy Context or normalized TranscriptContext
2689
+ // input and the complete stream/event/result shape. These intersections only mark
2690
+ // that host-owned boundary; no request, callback, event, or result is reconstructed.
2691
+ const delegated = anthropicMessagesApi().streamSimple(
2692
+ model as HostForwardingModel,
2693
+ context as HostForwardingContext,
2694
+ options as HostForwardingOptions | undefined,
2695
+ );
2696
+ return delegated as HostForwardingStream;
1619
2697
  }
1620
2698
 
1621
2699
  export function streamAnthropicViaBetaMessages(
1622
2700
  model: PiModelLike,
1623
2701
  context: PiStreamContext,
1624
2702
  options?: PiSimpleStreamOptions,
2703
+ dependencies: AnthropicTransportDependencies = {},
1625
2704
  ): AssistantMessageEventStreamLike {
1626
- const stream = createAssistantMessageEventStream();
1627
- const output = createOutput(model);
1628
-
1629
2705
  if (model.provider !== 'anthropic') {
1630
- void forwardToBuiltInAnthropic(model, context, options, stream, output);
1631
- return stream;
2706
+ return forwardToBuiltInAnthropic(model, context, options);
1632
2707
  }
1633
2708
 
2709
+ const stream = createAssistantMessageEventStream();
2710
+ const output = createOutput(model);
1634
2711
  void (async () => {
2712
+ let preparedLineage: PreparedAnthropicLineage | undefined;
1635
2713
  try {
1636
2714
  const apiKey = options?.apiKey;
1637
2715
  if (typeof apiKey !== 'string' || apiKey.length === 0) {
@@ -1645,37 +2723,111 @@ export function streamAnthropicViaBetaMessages(
1645
2723
  );
1646
2724
  }
1647
2725
 
2726
+ const sessionId = requireSessionId(options?.sessionId, 'options.sessionId');
2727
+ const account = requireAttributionAccount(
2728
+ dependencies.loadAccount?.() ?? loadClaudeAttributionAccount(undefined, options?.env),
2729
+ );
2730
+ const url = resolveAnthropicBetaMessagesUrl(model);
1648
2731
  const policy = resolveClaudeCodeModelPolicy(model);
1649
- let params = buildAnthropicRequestParams(model, context, options);
2732
+ const request = buildAnthropicRequest(model, context, options);
2733
+ let params = request.params;
2734
+ const billingSystemText = buildClaudeCodeBillingSystemText(
2735
+ firstUserMessageTextFromPayload(params),
2736
+ );
2737
+ const provisionalLineage = prepareAnthropicLineageDetails({
2738
+ model,
2739
+ policy,
2740
+ context,
2741
+ payload: params,
2742
+ signatureEpoch: request.signatureEpoch,
2743
+ });
2744
+ if (policy.supportsCacheDiagnostics) {
2745
+ if (!policy.beta.split(',').includes(ANTHROPIC_CACHE_DIAGNOSTICS_BETA)) {
2746
+ throw new Error('Anthropic cache diagnostics policy is missing its required beta header');
2747
+ }
2748
+ params['diagnostics'] = {
2749
+ previous_message_id: provisionalLineage.previous_message_id,
2750
+ };
2751
+ }
2752
+ if (
2753
+ policy.enforcesThinkingPrefixBinding &&
2754
+ !policy.beta.split(',').includes(ANTHROPIC_THINKING_BINDING_BETA)
2755
+ ) {
2756
+ throw new Error('Anthropic thinking-binding policy is missing its required beta header');
2757
+ }
2758
+ params = rewriteAnthropicRequestPayloadForSession({
2759
+ payload: params,
2760
+ model,
2761
+ sessionId,
2762
+ account,
2763
+ headerRegistered: true,
2764
+ ...(options?.cacheRetention === undefined
2765
+ ? {}
2766
+ : { cacheRetention: options.cacheRetention }),
2767
+ });
2768
+ const protectedCacheControlTopologySha256 = cacheControlTopologySha256(params);
1650
2769
  const nextParams = await options?.onPayload?.(params, model);
1651
2770
  if (nextParams !== undefined) {
1652
2771
  if (!isPlainObject(nextParams))
1653
2772
  throw new Error('Anthropic attribution onPayload returned a non-object payload');
1654
2773
  params = nextParams;
1655
2774
  }
1656
- const metadataUserId = isPlainObject(params['metadata'])
1657
- ? params['metadata']['user_id']
1658
- : undefined;
1659
- let sessionId: string | undefined;
1660
- if (typeof metadataUserId === 'string') {
1661
- const parsed = parseJsonObject(metadataUserId, 'Anthropic attribution metadata.user_id');
1662
- if (typeof parsed['session_id'] === 'string') sessionId = parsed['session_id'];
1663
- }
1664
- if (!sessionId)
2775
+ assertProtectedAnthropicAttribution({
2776
+ payload: params,
2777
+ account,
2778
+ sessionId,
2779
+ billingSystemText,
2780
+ modelId: policy.modelId,
2781
+ cacheControlTopologySha256: protectedCacheControlTopologySha256,
2782
+ });
2783
+
2784
+ preparedLineage = anthropicLineageCoordinator.prepare({
2785
+ sessionId,
2786
+ model,
2787
+ policy,
2788
+ context,
2789
+ payload: params,
2790
+ signatureEpoch: request.signatureEpoch,
2791
+ });
2792
+ if (
2793
+ preparedLineage.details.previous_message_id !== provisionalLineage.previous_message_id ||
2794
+ preparedLineage.details.conversation_static_sha256 !==
2795
+ provisionalLineage.conversation_static_sha256 ||
2796
+ preparedLineage.details.request_message_count !==
2797
+ provisionalLineage.request_message_count ||
2798
+ preparedLineage.details.request_messages_sha256 !==
2799
+ provisionalLineage.request_messages_sha256 ||
2800
+ preparedLineage.details.cache_profile_sha256 !== provisionalLineage.cache_profile_sha256 ||
2801
+ preparedLineage.details.cache_retention !== provisionalLineage.cache_retention ||
2802
+ preparedLineage.details.compaction_boundary_sha256 !==
2803
+ provisionalLineage.compaction_boundary_sha256 ||
2804
+ preparedLineage.details.signature_epoch_sha256 !==
2805
+ provisionalLineage.signature_epoch_sha256 ||
2806
+ preparedLineage.details.signature_epoch_inherits_prior !==
2807
+ provisionalLineage.signature_epoch_inherits_prior
2808
+ ) {
1665
2809
  throw new Error(
1666
- 'Anthropic attribution could not derive session_id from rewritten metadata.user_id',
2810
+ 'Anthropic request/cache lineage changed during before_provider_request transforms',
1667
2811
  );
2812
+ }
2813
+ if (policy.supportsCacheDiagnostics) {
2814
+ const diagnostics = params['diagnostics'];
2815
+ if (
2816
+ !isPlainObject(diagnostics) ||
2817
+ diagnostics['previous_message_id'] !== preparedLineage.details.previous_message_id
2818
+ ) {
2819
+ throw new Error(
2820
+ 'Anthropic cache diagnostics were removed or changed during before_provider_request transforms',
2821
+ );
2822
+ }
2823
+ }
1668
2824
 
1669
- const baseUrl =
1670
- model.baseUrl && model.baseUrl.length > 0
1671
- ? model.baseUrl.replace(/\/$/, '')
1672
- : 'https://api.anthropic.com';
1673
- const url = `${baseUrl}/v1/messages?beta=true`;
1674
2825
  const headers = buildFetchHeaders(options, apiKey, sessionId, policy.beta);
1675
2826
  const requestInit: RequestInit = {
1676
2827
  method: 'POST',
1677
2828
  headers,
1678
2829
  body: JSON.stringify(params),
2830
+ redirect: 'error',
1679
2831
  };
1680
2832
  if (options?.signal) requestInit.signal = options.signal;
1681
2833
  const response = await fetch(url, requestInit);
@@ -1688,26 +2840,67 @@ export function streamAnthropicViaBetaMessages(
1688
2840
  `Anthropic beta messages request failed: HTTP ${response.status} ${response.statusText}: ${await response.text()}`,
1689
2841
  );
1690
2842
  }
2843
+ const contentType = response.headers.get('content-type')?.toLowerCase() ?? '';
2844
+ if (!contentType.includes('text/event-stream')) {
2845
+ throw new Error(
2846
+ `Anthropic beta messages response must be text/event-stream; got ${JSON.stringify(contentType || '(missing)')}`,
2847
+ );
2848
+ }
1691
2849
 
1692
2850
  stream.push({ type: 'start', partial: output });
1693
2851
  const blocks = output.content as Array<JsonObject & { index?: number; partialJson?: string }>;
2852
+ const activeContentIndexes = new Set<number>();
2853
+ let sawMessageStart = false;
2854
+ let sawMessageStop = false;
2855
+ let sawTerminalStopReason = false;
1694
2856
  for await (const event of iterateSseEvents(response, options?.signal)) {
1695
- if (event['type'] === 'message_start' && isPlainObject(event['message'])) {
2857
+ const eventType = event['type'];
2858
+ if (typeof eventType !== 'string') {
2859
+ throw new Error('Anthropic beta messages SSE event is missing string data.type');
2860
+ }
2861
+ if (eventType === 'error') throw new Error(anthropicStreamErrorMessage(event));
2862
+ if (eventType === 'ping') continue;
2863
+ if (sawMessageStop) {
2864
+ throw new Error('Anthropic beta messages stream emitted data after message_stop');
2865
+ }
2866
+ if (eventType !== 'message_start' && !sawMessageStart) {
2867
+ throw new Error('Anthropic beta messages stream emitted content before message_start');
2868
+ }
2869
+
2870
+ if (eventType === 'message_start') {
2871
+ if (sawMessageStart || !isPlainObject(event['message'])) {
2872
+ throw new Error(
2873
+ 'Anthropic beta messages stream emitted malformed/duplicate message_start',
2874
+ );
2875
+ }
2876
+ if (!isNonEmptyString(event['message']['id'])) {
2877
+ throw new Error('Anthropic beta messages message_start is missing a response id');
2878
+ }
2879
+ sawMessageStart = true;
1696
2880
  if (typeof event['message']['id'] === 'string')
1697
2881
  output.responseId = event['message']['id'];
2882
+ appendProviderCacheDiagnostic(output, event['message']);
1698
2883
  updateAnthropicUsage(
1699
2884
  output,
1700
2885
  isPlainObject(event['message']['usage']) ? event['message']['usage'] : undefined,
1701
2886
  model,
1702
2887
  );
1703
- } else if (
1704
- event['type'] === 'content_block_start' &&
1705
- typeof event['index'] === 'number' &&
1706
- isPlainObject(event['content_block'])
1707
- ) {
2888
+ } else if (eventType === 'content_block_start') {
2889
+ const contentIndex = event['index'];
1708
2890
  const contentBlock = event['content_block'];
2891
+ if (
2892
+ !Number.isSafeInteger(contentIndex) ||
2893
+ (contentIndex as number) < 0 ||
2894
+ !isPlainObject(contentBlock)
2895
+ ) {
2896
+ throw new Error('Anthropic beta messages stream emitted malformed content_block_start');
2897
+ }
2898
+ if (activeContentIndexes.has(contentIndex as number)) {
2899
+ throw new Error('Anthropic beta messages stream duplicated an active content block');
2900
+ }
2901
+ activeContentIndexes.add(contentIndex as number);
1709
2902
  if (contentBlock['type'] === 'text') {
1710
- output.content.push({ type: 'text', text: '', index: event['index'] });
2903
+ output.content.push({ type: 'text', text: '', index: contentIndex });
1711
2904
  stream.push({
1712
2905
  type: 'text_start',
1713
2906
  contentIndex: output.content.length - 1,
@@ -1718,7 +2911,7 @@ export function streamAnthropicViaBetaMessages(
1718
2911
  type: 'thinking',
1719
2912
  thinking: '',
1720
2913
  thinkingSignature: '',
1721
- index: event['index'],
2914
+ index: contentIndex,
1722
2915
  });
1723
2916
  stream.push({
1724
2917
  type: 'thinking_start',
@@ -1726,12 +2919,15 @@ export function streamAnthropicViaBetaMessages(
1726
2919
  partial: output,
1727
2920
  });
1728
2921
  } else if (contentBlock['type'] === 'redacted_thinking') {
2922
+ if (typeof contentBlock['data'] !== 'string') {
2923
+ throw new Error('Anthropic redacted_thinking block is missing string data');
2924
+ }
1729
2925
  output.content.push({
1730
2926
  type: 'thinking',
1731
2927
  thinking: '[Reasoning redacted]',
1732
2928
  thinkingSignature: contentBlock['data'],
1733
2929
  redacted: true,
1734
- index: event['index'],
2930
+ index: contentIndex,
1735
2931
  });
1736
2932
  stream.push({
1737
2933
  type: 'thinking_start',
@@ -1739,29 +2935,48 @@ export function streamAnthropicViaBetaMessages(
1739
2935
  partial: output,
1740
2936
  });
1741
2937
  } else if (contentBlock['type'] === 'tool_use') {
2938
+ if (
2939
+ typeof contentBlock['id'] !== 'string' ||
2940
+ typeof contentBlock['name'] !== 'string' ||
2941
+ !isPlainObject(contentBlock['input'] ?? {})
2942
+ ) {
2943
+ throw new Error('Anthropic tool_use block is malformed');
2944
+ }
1742
2945
  output.content.push({
1743
2946
  type: 'toolCall',
1744
2947
  id: contentBlock['id'],
1745
2948
  name: contentBlock['name'],
1746
2949
  arguments: contentBlock['input'] ?? {},
1747
2950
  partialJson: '',
1748
- index: event['index'],
2951
+ index: contentIndex,
1749
2952
  });
1750
2953
  stream.push({
1751
2954
  type: 'toolcall_start',
1752
2955
  contentIndex: output.content.length - 1,
1753
2956
  partial: output,
1754
2957
  });
2958
+ } else {
2959
+ throw new Error(
2960
+ `Anthropic beta messages stream emitted unsupported content block ${JSON.stringify(contentBlock['type'])}`,
2961
+ );
1755
2962
  }
1756
- } else if (
1757
- event['type'] === 'content_block_delta' &&
1758
- typeof event['index'] === 'number' &&
1759
- isPlainObject(event['delta'])
1760
- ) {
1761
- const blockIndex = blocks.findIndex((block) => block.index === event['index']);
1762
- const block = blocks[blockIndex];
1763
- if (!block) continue;
2963
+ } else if (eventType === 'content_block_delta') {
2964
+ const contentIndex = event['index'];
1764
2965
  const delta = event['delta'];
2966
+ if (
2967
+ !Number.isSafeInteger(contentIndex) ||
2968
+ !activeContentIndexes.has(contentIndex as number) ||
2969
+ !isPlainObject(delta)
2970
+ ) {
2971
+ throw new Error(
2972
+ 'Anthropic beta messages stream emitted malformed/orphan content_block_delta',
2973
+ );
2974
+ }
2975
+ const blockIndex = blocks.findIndex((block) => block.index === contentIndex);
2976
+ const block = blocks[blockIndex];
2977
+ if (!block) {
2978
+ throw new Error('Anthropic beta messages stream delta has no projected content block');
2979
+ }
1765
2980
  if (
1766
2981
  delta['type'] === 'text_delta' &&
1767
2982
  block['type'] === 'text' &&
@@ -1806,11 +3021,26 @@ export function streamAnthropicViaBetaMessages(
1806
3021
  ) {
1807
3022
  block['thinkingSignature'] =
1808
3023
  `${String(block['thinkingSignature'] ?? '')}${delta['signature']}`;
3024
+ } else {
3025
+ throw new Error(
3026
+ `Anthropic beta messages stream emitted unsupported/mismatched delta ${JSON.stringify(delta['type'])}`,
3027
+ );
1809
3028
  }
1810
- } else if (event['type'] === 'content_block_stop' && typeof event['index'] === 'number') {
1811
- const blockIndex = blocks.findIndex((block) => block.index === event['index']);
3029
+ } else if (eventType === 'content_block_stop') {
3030
+ const contentIndex = event['index'];
3031
+ if (
3032
+ !Number.isSafeInteger(contentIndex) ||
3033
+ !activeContentIndexes.delete(contentIndex as number)
3034
+ ) {
3035
+ throw new Error(
3036
+ 'Anthropic beta messages stream emitted malformed/orphan content_block_stop',
3037
+ );
3038
+ }
3039
+ const blockIndex = blocks.findIndex((block) => block.index === contentIndex);
1812
3040
  const block = blocks[blockIndex];
1813
- if (!block) continue;
3041
+ if (!block) {
3042
+ throw new Error('Anthropic beta messages stream stop has no projected content block');
3043
+ }
1814
3044
  delete block.index;
1815
3045
  if (block['type'] === 'text') {
1816
3046
  stream.push({
@@ -1827,7 +3057,10 @@ export function streamAnthropicViaBetaMessages(
1827
3057
  partial: output,
1828
3058
  });
1829
3059
  } else if (block['type'] === 'toolCall') {
1830
- block['arguments'] = parseStreamingJsonFragment(block.partialJson ?? '{}');
3060
+ block['arguments'] =
3061
+ block.partialJson && block.partialJson.length > 0
3062
+ ? parseCompletedToolInput(block.partialJson)
3063
+ : canonicalizeJson(block['arguments'] ?? {});
1831
3064
  delete block.partialJson;
1832
3065
  stream.push({
1833
3066
  type: 'toolcall_end',
@@ -1836,19 +3069,54 @@ export function streamAnthropicViaBetaMessages(
1836
3069
  partial: output,
1837
3070
  });
1838
3071
  }
1839
- } else if (event['type'] === 'message_delta') {
1840
- if (isPlainObject(event['delta']) && event['delta']['stop_reason'])
1841
- output.stopReason = mapStopReason(event['delta']['stop_reason']);
3072
+ } else if (eventType === 'message_delta') {
3073
+ if (!isPlainObject(event['delta'])) {
3074
+ throw new Error('Anthropic beta messages stream emitted malformed message_delta');
3075
+ }
3076
+ const stopReason = event['delta']['stop_reason'];
3077
+ if (typeof stopReason === 'string') {
3078
+ const mapped = mapStopReason(stopReason);
3079
+ if (mapped === 'error') {
3080
+ throw new Error(
3081
+ `Anthropic beta messages stream emitted unsupported stop reason ${JSON.stringify(stopReason)}`,
3082
+ );
3083
+ }
3084
+ output.stopReason = mapped;
3085
+ sawTerminalStopReason = true;
3086
+ }
1842
3087
  updateAnthropicUsage(
1843
3088
  output,
1844
3089
  isPlainObject(event['usage']) ? event['usage'] : undefined,
1845
3090
  model,
1846
3091
  );
3092
+ } else if (eventType === 'message_stop') {
3093
+ if (activeContentIndexes.size > 0) {
3094
+ throw new Error(
3095
+ 'Anthropic beta messages message_stop arrived with open content blocks',
3096
+ );
3097
+ }
3098
+ sawMessageStop = true;
3099
+ } else {
3100
+ throw new Error(
3101
+ `Anthropic beta messages stream emitted unsupported event ${JSON.stringify(eventType)}`,
3102
+ );
1847
3103
  }
1848
3104
  }
1849
3105
  if (options?.signal?.aborted) throw new Error('Request was aborted');
1850
- if (output.stopReason === 'error')
1851
- throw new Error(output.errorMessage || 'Anthropic stream ended with error stop reason');
3106
+ if (!sawMessageStart) {
3107
+ throw new Error('Anthropic beta messages stream ended without message_start');
3108
+ }
3109
+ if (!sawMessageStop) {
3110
+ throw new Error('Anthropic beta messages stream ended without message_stop');
3111
+ }
3112
+ if (!sawTerminalStopReason || output.stopReason === 'error') {
3113
+ throw new Error(
3114
+ 'Anthropic beta messages stream ended without a valid terminal stop reason',
3115
+ );
3116
+ }
3117
+ if (preparedLineage === undefined)
3118
+ throw new Error('Anthropic attribution completed without prepared cache lineage');
3119
+ appendLineageDiagnostic(output, preparedLineage);
1852
3120
  stream.push({ type: 'done', reason: output.stopReason, message: output });
1853
3121
  stream.end();
1854
3122
  } catch (error) {
@@ -1860,6 +3128,8 @@ export function streamAnthropicViaBetaMessages(
1860
3128
  output.errorMessage = error instanceof Error ? error.message : String(error);
1861
3129
  stream.push({ type: 'error', reason: output.stopReason, error: output });
1862
3130
  stream.end();
3131
+ } finally {
3132
+ if (preparedLineage !== undefined) anthropicLineageCoordinator.release(preparedLineage);
1863
3133
  }
1864
3134
  })();
1865
3135
 
@@ -1898,7 +3168,10 @@ function isAnthropicAttributionClaimProbe(value: unknown): value is AnthropicAtt
1898
3168
  * publishes ownership after every registration below succeeds; a factory that throws
1899
3169
  * cannot strand a false claim that suppresses a healthy later copy.
1900
3170
  */
1901
- export default function spawnAnthropicAttribution(pi: PiExtensionHost): void {
3171
+ export default function spawnAnthropicAttribution(
3172
+ pi: PiExtensionHost,
3173
+ dependencies: AnthropicTransportDependencies = {},
3174
+ ): void {
1902
3175
  const acknowledgements: true[] = [];
1903
3176
  const probe: AnthropicAttributionClaimProbe = {
1904
3177
  schema_version: ANTHROPIC_ATTRIBUTION_CLAIM_SCHEMA,
@@ -1914,15 +3187,20 @@ export default function spawnAnthropicAttribution(pi: PiExtensionHost): void {
1914
3187
  sessionCacheRetention;
1915
3188
 
1916
3189
  // Registration is global but route-scoped by provider name. Keeping it at
1917
- // factory scope avoids lifecycle-dependent provider availability; the custom
1918
- // transport derives session/model headers from the attributed payload.
3190
+ // factory scope avoids lifecycle-dependent provider availability; every custom
3191
+ // transport request owns attribution from its request-scoped session options.
1919
3192
  pi.registerProvider('anthropic', {
1920
3193
  api: 'anthropic-messages',
1921
3194
  streamSimple: (model, context, options) =>
1922
- streamAnthropicViaBetaMessages(model, context, {
1923
- ...(options ?? {}),
1924
- cacheRetention: resolveCacheRetentionPreference(options, getSessionOverride()),
1925
- }),
3195
+ streamAnthropicViaBetaMessages(
3196
+ model,
3197
+ context,
3198
+ {
3199
+ ...(options ?? {}),
3200
+ cacheRetention: resolveRegisteredCacheRetention(options, getSessionOverride()),
3201
+ },
3202
+ dependencies,
3203
+ ),
1926
3204
  });
1927
3205
 
1928
3206
  pi.registerCommand('claude-cache', {
@@ -1965,16 +3243,6 @@ export default function spawnAnthropicAttribution(pi: PiExtensionHost): void {
1965
3243
  sessionCacheRetention = restoreAnthropicSessionCacheRetention(ctx.sessionManager.getBranch());
1966
3244
  });
1967
3245
 
1968
- pi.on('before_provider_request', (event, ctx) => {
1969
- if (!isAnthropicContext(ctx)) return undefined;
1970
- return rewriteAnthropicRequestPayload({
1971
- payload: event.payload,
1972
- ctx,
1973
- account: loadClaudeAttributionAccount(),
1974
- headerRegistered: true,
1975
- });
1976
- });
1977
-
1978
3246
  // Publish ownership last. Extension loading is sequential, so later independent
1979
3247
  // copies probe this responder and become inert instead of registering duplicates.
1980
3248
  pi.events.on(ANTHROPIC_ATTRIBUTION_CLAIM_CHANNEL, (value) => {