@pi-unipi/background-tasks 2.6.1

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 (116) hide show
  1. package/README.md +87 -0
  2. package/extensions/anthropic-attribution.ts +1 -0
  3. package/extensions/delegate-child.ts +1 -0
  4. package/extensions/fusion-child.ts +1 -0
  5. package/package.json +40 -0
  6. package/src/__tests__/anthropic-attribution.test.ts +195 -0
  7. package/src/__tests__/config.test.ts +137 -0
  8. package/src/__tests__/core.test.ts +493 -0
  9. package/src/__tests__/delegate-artifacts.test.ts +528 -0
  10. package/src/__tests__/delegate-budget.test.ts +456 -0
  11. package/src/__tests__/delegate-launch.test.ts +676 -0
  12. package/src/__tests__/delegate-result-package.test.ts +350 -0
  13. package/src/__tests__/delegate-seed.test.ts +392 -0
  14. package/src/__tests__/durable-fs.test.ts +559 -0
  15. package/src/__tests__/extension-api.test.ts +579 -0
  16. package/src/__tests__/fusion-artifacts.test.ts +1039 -0
  17. package/src/__tests__/fusion-budget.test.ts +1356 -0
  18. package/src/__tests__/fusion-claude-cache.test.ts +320 -0
  19. package/src/__tests__/fusion-config.test.ts +335 -0
  20. package/src/__tests__/fusion-context-prompts.test.ts +670 -0
  21. package/src/__tests__/fusion-evaluation.test.ts +315 -0
  22. package/src/__tests__/fusion-extraction-equivalence.test.ts +58 -0
  23. package/src/__tests__/fusion-golden-bytes.test.ts +35 -0
  24. package/src/__tests__/fusion-high-cardinality.test.ts +192 -0
  25. package/src/__tests__/fusion-model-selector.test.ts +205 -0
  26. package/src/__tests__/fusion-orchestrator.test.ts +1194 -0
  27. package/src/__tests__/fusion-rpc.test.ts +369 -0
  28. package/src/__tests__/fusion-sdk.test.ts +1226 -0
  29. package/src/__tests__/fusion-v5-core.test.ts +219 -0
  30. package/src/__tests__/fusion-validate-orchestrator.test.ts +240 -0
  31. package/src/__tests__/fusion-web-fetch.test.ts +485 -0
  32. package/src/__tests__/fusion-workflows.test.ts +59 -0
  33. package/src/__tests__/helpers/delegate-deterministic-seed.ts +109 -0
  34. package/src/__tests__/helpers/delegate-seed-subprocess.ts +10 -0
  35. package/src/__tests__/helpers/fusion-canonical-subprocess.ts +21 -0
  36. package/src/__tests__/helpers/fusion-canonical.ts +140 -0
  37. package/src/__tests__/helpers/fusion-fake-pi.ts +279 -0
  38. package/src/__tests__/helpers/fusion-golden-corpus.ts +500 -0
  39. package/src/__tests__/helpers/fusion-high-cardinality.ts +140 -0
  40. package/src/__tests__/helpers/normalize.ts +22 -0
  41. package/src/__tests__/helpers/pi-hook-contract-evidence.json +18 -0
  42. package/src/__tests__/pi-launch.test.ts +202 -0
  43. package/src/__tests__/registry.test.ts +1580 -0
  44. package/src/__tests__/scripted-provider/delegate-ambient-provider.test.ts +130 -0
  45. package/src/__tests__/scripted-provider/delegate-child-guard.test.ts +631 -0
  46. package/src/__tests__/scripted-provider/delegate-guard-provider.ts +403 -0
  47. package/src/__tests__/scripted-provider/follow-up.test.ts +448 -0
  48. package/src/__tests__/scripted-provider/fusion-output-recovery.test.ts +132 -0
  49. package/src/__tests__/scripted-provider/fusion-reason.test.ts +310 -0
  50. package/src/__tests__/scripted-provider/fusion-runtime-guard.test.ts +163 -0
  51. package/src/__tests__/scripted-provider/hook-contract-provider.ts +179 -0
  52. package/src/__tests__/scripted-provider/hook-probe-a.ts +3 -0
  53. package/src/__tests__/scripted-provider/hook-probe-b.ts +3 -0
  54. package/src/__tests__/scripted-provider/hook-probe-extension.ts +126 -0
  55. package/src/__tests__/scripted-provider/output-recovery-provider.ts +153 -0
  56. package/src/__tests__/scripted-provider/pi-hook-contract-evidence.json +18 -0
  57. package/src/__tests__/scripted-provider/pi-hook-contract.test.ts +477 -0
  58. package/src/__tests__/scripted-provider/runtime-guard-probe.ts +28 -0
  59. package/src/__tests__/scripted-provider/runtime-guard-provider.ts +49 -0
  60. package/src/__tests__/scripted-provider/scripted-provider-extension.ts +408 -0
  61. package/src/__tests__/task-manager.test.ts +479 -0
  62. package/src/__tests__/windows-taskkill.test.ts +161 -0
  63. package/src/anthropic-attribution-path.ts +21 -0
  64. package/src/anthropic-attribution.ts +1983 -0
  65. package/src/attested-pi-run.ts +612 -0
  66. package/src/child-process.ts +55 -0
  67. package/src/common.ts +8 -0
  68. package/src/config.ts +292 -0
  69. package/src/context-parent-snapshot.ts +142 -0
  70. package/src/context-token-budget.ts +903 -0
  71. package/src/context-visible-conversation-v2.ts +551 -0
  72. package/src/delegate/artifacts.ts +487 -0
  73. package/src/delegate/budget.ts +415 -0
  74. package/src/delegate/hook-contract-evidence.json +18 -0
  75. package/src/delegate/hook-contract.ts +154 -0
  76. package/src/delegate/launch.ts +497 -0
  77. package/src/delegate/result-package.ts +459 -0
  78. package/src/delegate/runner.ts +449 -0
  79. package/src/delegate/seed.ts +423 -0
  80. package/src/delegate/types.ts +323 -0
  81. package/src/delegate-child-extension.ts +978 -0
  82. package/src/delegate-extension.ts +806 -0
  83. package/src/durable-fs.ts +386 -0
  84. package/src/extension-api.ts +548 -0
  85. package/src/fixtures/delegate-context-incident.json +17 -0
  86. package/src/fixtures/fusion-golden-bytes.json +310 -0
  87. package/src/fixtures/fusion-validate-golden-bytes.json +282 -0
  88. package/src/fusion/artifacts.ts +967 -0
  89. package/src/fusion/budget.ts +1162 -0
  90. package/src/fusion/child-protocol.ts +305 -0
  91. package/src/fusion/claude-cache.ts +207 -0
  92. package/src/fusion/clean-context.ts +91 -0
  93. package/src/fusion/config.ts +449 -0
  94. package/src/fusion/context.ts +265 -0
  95. package/src/fusion/evaluation.ts +800 -0
  96. package/src/fusion/orchestrator.ts +1288 -0
  97. package/src/fusion/output-contract.ts +34 -0
  98. package/src/fusion/pi-child.ts +2373 -0
  99. package/src/fusion/prompts.ts +345 -0
  100. package/src/fusion/result-package.ts +959 -0
  101. package/src/fusion/source-policy.ts +257 -0
  102. package/src/fusion/types.ts +1139 -0
  103. package/src/fusion/web-fetch.ts +1060 -0
  104. package/src/fusion/workflows.ts +184 -0
  105. package/src/fusion-child-extension.ts +1052 -0
  106. package/src/fusion-extension.ts +1293 -0
  107. package/src/index.ts +295 -0
  108. package/src/pi-launch.ts +225 -0
  109. package/src/registry.ts +2424 -0
  110. package/src/settings-overlay.ts +208 -0
  111. package/src/task-manager.ts +774 -0
  112. package/src/tools.ts +530 -0
  113. package/src/turndown.d.ts +15 -0
  114. package/src/types.ts +963 -0
  115. package/src/ui/fusion-model-selector.ts +322 -0
  116. package/src/windows-taskkill.ts +250 -0
@@ -0,0 +1,1983 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { appendFileSync, readFileSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { join } from 'node:path';
5
+
6
+ export const CLAUDE_CODE_SESSION_HEADER = 'X-Claude-Code-Session-Id';
7
+
8
+ const CLAUDE_CODE_VERSION = '2.1.173';
9
+ const CLAUDE_CODE_ENTRYPOINT = 'sdk-cli';
10
+ const CLAUDE_CODE_USER_AGENT = 'claude-cli/2.1.173 (external, sdk-cli)';
11
+ export const ANTHROPIC_1M_CONTEXT_BETA = 'context-1m-2025-08-07' as const;
12
+ export const CLAUDE_CODE_200K_SUBSCRIPTION_CONTEXT_WINDOW = 200_000 as const;
13
+
14
+ type ClaudeCode200KSubscriptionBetaValue =
15
+ | 'claude-code-20250219'
16
+ | 'oauth-2025-04-20'
17
+ | 'interleaved-thinking-2025-05-14'
18
+ | 'thinking-token-count-2026-05-13'
19
+ | 'context-management-2025-06-27'
20
+ | 'prompt-caching-scope-2026-01-05'
21
+ | 'advisor-tool-2026-03-01'
22
+ | 'structured-outputs-2025-12-15'
23
+ | 'mid-conversation-system-2026-04-07';
24
+
25
+ const CLAUDE_CODE_LEGACY_BETA_VALUES = [
26
+ 'claude-code-20250219',
27
+ 'oauth-2025-04-20',
28
+ 'interleaved-thinking-2025-05-14',
29
+ 'thinking-token-count-2026-05-13',
30
+ 'context-management-2025-06-27',
31
+ 'prompt-caching-scope-2026-01-05',
32
+ 'advisor-tool-2026-03-01',
33
+ 'structured-outputs-2025-12-15',
34
+ ] as const satisfies readonly ClaudeCode200KSubscriptionBetaValue[];
35
+ const CLAUDE_CODE_ADAPTIVE_200K_BETA_VALUES = [
36
+ 'claude-code-20250219',
37
+ 'oauth-2025-04-20',
38
+ 'interleaved-thinking-2025-05-14',
39
+ 'thinking-token-count-2026-05-13',
40
+ 'context-management-2025-06-27',
41
+ 'prompt-caching-scope-2026-01-05',
42
+ 'mid-conversation-system-2026-04-07',
43
+ ] as const satisfies readonly ClaudeCode200KSubscriptionBetaValue[];
44
+
45
+ function build200KSubscriptionBetaHeader(
46
+ values: readonly ClaudeCode200KSubscriptionBetaValue[],
47
+ ): string {
48
+ if ((values as readonly string[]).includes(ANTHROPIC_1M_CONTEXT_BETA)) {
49
+ throw new Error(
50
+ `Anthropic attribution 200K subscription policy must not emit ${ANTHROPIC_1M_CONTEXT_BETA}`,
51
+ );
52
+ }
53
+ return values.join(',');
54
+ }
55
+
56
+ export const CLAUDE_CODE_BETA = build200KSubscriptionBetaHeader(CLAUDE_CODE_LEGACY_BETA_VALUES);
57
+ const CLAUDE_CODE_ADAPTIVE_200K_BETA = build200KSubscriptionBetaHeader(
58
+ CLAUDE_CODE_ADAPTIVE_200K_BETA_VALUES,
59
+ );
60
+ const CLAUDE_AGENT_SDK_SYSTEM_TEXT =
61
+ "You are a Claude agent, built on Anthropic's Claude Agent SDK.";
62
+ const FINGERPRINT_SALT = '59cf53e54c78';
63
+ const AUDIT_ENV = 'PIPELINE_ANTHROPIC_ATTRIBUTION_AUDIT_PATH';
64
+ const CACHE_RETENTION_ENV = 'PI_CACHE_RETENTION';
65
+ export const ANTHROPIC_CACHE_RETENTION_ENTRY = 'pipeline-anthropic-cache-retention';
66
+ const ANTHROPIC_CACHE_RETENTION_SCHEMA = 'pipeline.anthropic_cache_retention.v1';
67
+ export const ANTHROPIC_ATTRIBUTION_CLAIM_CHANNEL = 'pi-anthropic-attribution:claim:v1';
68
+ const ANTHROPIC_ATTRIBUTION_CLAIM_SCHEMA = 'pi-anthropic-attribution.claim.v1';
69
+ const NATIVE_ATTESTATION_PLACEHOLDER = '00000';
70
+ const ANTHROPIC_CACHE_CONTROL_BREAKPOINT_LIMIT = 4;
71
+
72
+ // Sanitization behavior derived from the MIT-licensed ravshansbox/pi-anthropic-sps
73
+ // extension at commit 17409b5615f0ec0625776bc5434f92f2c55e3fd0. Keep exact-match
74
+ // semantics and all known Pi prompt variants; unrelated system text is preserved.
75
+ const ANTHROPIC_SYSTEM_PROMPT_BAD_LINES = new Set([
76
+ '- When asked about: extensions (docs/extensions.md, examples/extensions/), themes (docs/themes.md), skills (docs/skills.md), prompt templates (docs/prompt-templates.md), TUI components (docs/tui.md), keybindings (docs/keybindings.md), SDK integrations (docs/sdk.md), custom providers (docs/custom-provider.md), adding models (docs/models.md), pi packages (docs/packages.md)',
77
+ '- When asked about: extensions (docs/extensions.md, examples/extensions/), themes (docs/themes.md), skills (docs/skills.md), prompt templates (docs/prompt-templates.md), TUI components (docs/tui.md), keybindings (docs/keybindings.md), SDK integrations (docs/sdk.md), custom providers (docs/custom-provider.md), adding models (docs/models.md), pi packages (docs/packages.md), environment variables (docs/environment-variables.md)',
78
+ '- When working on pi topics, read the docs and examples, and follow .md cross-references before implementing',
79
+ ]);
80
+
81
+ type JsonObject = Record<string, unknown>;
82
+ export type CacheRetention = 'none' | 'short' | 'long';
83
+ type ProviderEnv = Record<string, string | undefined>;
84
+ export interface AnthropicCacheControl {
85
+ type: 'ephemeral';
86
+ ttl?: '1h' | '5m';
87
+ [key: string]: unknown;
88
+ }
89
+
90
+ const parseJsonSource = JSON.parse.bind(JSON) as (source: string) => unknown;
91
+
92
+ function parseJsonValue(text: string, label: string): unknown {
93
+ try {
94
+ return parseJsonSource(text);
95
+ } catch (error) {
96
+ throw new Error(
97
+ `${label} is invalid JSON: ${error instanceof Error ? error.message : String(error)}`,
98
+ );
99
+ }
100
+ }
101
+
102
+ function parseJsonObject(text: string, label: string): JsonObject {
103
+ const parsed = parseJsonValue(text, label);
104
+ if (!isPlainObject(parsed)) throw new Error(`${label} must be a JSON object`);
105
+ return parsed;
106
+ }
107
+
108
+ export interface ClaudeAttributionAccount {
109
+ readonly deviceId: string;
110
+ readonly accountUuid: string;
111
+ }
112
+
113
+ interface PiCostRatesLike {
114
+ readonly input?: number;
115
+ readonly output?: number;
116
+ readonly cacheRead?: number;
117
+ readonly cacheWrite?: number;
118
+ readonly inputTokensAbove?: number;
119
+ }
120
+
121
+ interface PiModelCostLike extends PiCostRatesLike {
122
+ readonly tiers?: readonly PiCostRatesLike[];
123
+ }
124
+
125
+ export interface PiModelLike {
126
+ readonly provider?: string;
127
+ readonly id?: string;
128
+ readonly api?: string;
129
+ readonly baseUrl?: string;
130
+ readonly maxTokens?: number;
131
+ readonly reasoning?: boolean;
132
+ readonly compat?: {
133
+ readonly supportsLongCacheRetention?: boolean;
134
+ readonly supportsCacheControlOnTools?: boolean;
135
+ };
136
+ readonly cost?: PiModelCostLike;
137
+ }
138
+
139
+ type ClaudeCodeThinkingPolicy = 'fixed-budget' | 'adaptive-effort';
140
+
141
+ export interface ClaudeCodeModelPolicy {
142
+ readonly modelId: string;
143
+ readonly beta: string;
144
+ readonly thinkingPolicy: ClaudeCodeThinkingPolicy;
145
+ readonly contextWindow: typeof CLAUDE_CODE_200K_SUBSCRIPTION_CONTEXT_WINDOW;
146
+ }
147
+
148
+ function claudeCode200KSubscriptionPolicy(
149
+ modelId: string,
150
+ beta: string,
151
+ thinkingPolicy: ClaudeCodeThinkingPolicy,
152
+ ): ClaudeCodeModelPolicy {
153
+ if (beta.split(',').includes(ANTHROPIC_1M_CONTEXT_BETA)) {
154
+ throw new Error(
155
+ `Anthropic attribution 200K subscription policy for ${modelId} must not emit ${ANTHROPIC_1M_CONTEXT_BETA}`,
156
+ );
157
+ }
158
+ return {
159
+ modelId,
160
+ beta,
161
+ thinkingPolicy,
162
+ contextWindow: CLAUDE_CODE_200K_SUBSCRIPTION_CONTEXT_WINDOW,
163
+ };
164
+ }
165
+
166
+ const CLAUDE_CODE_MODEL_POLICIES: Record<string, ClaudeCodeModelPolicy> = Object.freeze({
167
+ 'claude-3-5-haiku-20241022': claudeCode200KSubscriptionPolicy(
168
+ 'claude-3-5-haiku-20241022',
169
+ CLAUDE_CODE_BETA,
170
+ 'fixed-budget',
171
+ ),
172
+ 'claude-3-5-haiku-latest': claudeCode200KSubscriptionPolicy(
173
+ 'claude-3-5-haiku-latest',
174
+ CLAUDE_CODE_BETA,
175
+ 'fixed-budget',
176
+ ),
177
+ 'claude-3-5-sonnet-20240620': claudeCode200KSubscriptionPolicy(
178
+ 'claude-3-5-sonnet-20240620',
179
+ CLAUDE_CODE_BETA,
180
+ 'fixed-budget',
181
+ ),
182
+ 'claude-3-5-sonnet-20241022': claudeCode200KSubscriptionPolicy(
183
+ 'claude-3-5-sonnet-20241022',
184
+ CLAUDE_CODE_BETA,
185
+ 'fixed-budget',
186
+ ),
187
+ 'claude-3-7-sonnet-20250219': claudeCode200KSubscriptionPolicy(
188
+ 'claude-3-7-sonnet-20250219',
189
+ CLAUDE_CODE_BETA,
190
+ 'fixed-budget',
191
+ ),
192
+ 'claude-3-haiku-20240307': claudeCode200KSubscriptionPolicy(
193
+ 'claude-3-haiku-20240307',
194
+ CLAUDE_CODE_BETA,
195
+ 'fixed-budget',
196
+ ),
197
+ 'claude-3-opus-20240229': claudeCode200KSubscriptionPolicy(
198
+ 'claude-3-opus-20240229',
199
+ CLAUDE_CODE_BETA,
200
+ 'fixed-budget',
201
+ ),
202
+ 'claude-3-sonnet-20240229': claudeCode200KSubscriptionPolicy(
203
+ 'claude-3-sonnet-20240229',
204
+ CLAUDE_CODE_BETA,
205
+ 'fixed-budget',
206
+ ),
207
+ 'claude-fable-5': claudeCode200KSubscriptionPolicy(
208
+ 'claude-fable-5',
209
+ CLAUDE_CODE_ADAPTIVE_200K_BETA,
210
+ 'adaptive-effort',
211
+ ),
212
+ 'claude-haiku-4-5': claudeCode200KSubscriptionPolicy(
213
+ 'claude-haiku-4-5',
214
+ CLAUDE_CODE_BETA,
215
+ 'fixed-budget',
216
+ ),
217
+ 'claude-haiku-4-5-20251001': claudeCode200KSubscriptionPolicy(
218
+ 'claude-haiku-4-5-20251001',
219
+ CLAUDE_CODE_BETA,
220
+ 'fixed-budget',
221
+ ),
222
+ 'claude-opus-4-0': claudeCode200KSubscriptionPolicy(
223
+ 'claude-opus-4-0',
224
+ CLAUDE_CODE_BETA,
225
+ 'fixed-budget',
226
+ ),
227
+ 'claude-opus-4-1': claudeCode200KSubscriptionPolicy(
228
+ 'claude-opus-4-1',
229
+ CLAUDE_CODE_BETA,
230
+ 'fixed-budget',
231
+ ),
232
+ 'claude-opus-4-1-20250805': claudeCode200KSubscriptionPolicy(
233
+ 'claude-opus-4-1-20250805',
234
+ CLAUDE_CODE_BETA,
235
+ 'fixed-budget',
236
+ ),
237
+ 'claude-opus-4-20250514': claudeCode200KSubscriptionPolicy(
238
+ 'claude-opus-4-20250514',
239
+ CLAUDE_CODE_BETA,
240
+ 'fixed-budget',
241
+ ),
242
+ 'claude-opus-4-5': claudeCode200KSubscriptionPolicy(
243
+ 'claude-opus-4-5',
244
+ CLAUDE_CODE_BETA,
245
+ 'fixed-budget',
246
+ ),
247
+ 'claude-opus-4-5-20251101': claudeCode200KSubscriptionPolicy(
248
+ 'claude-opus-4-5-20251101',
249
+ CLAUDE_CODE_BETA,
250
+ 'fixed-budget',
251
+ ),
252
+ 'claude-opus-4-6': claudeCode200KSubscriptionPolicy(
253
+ 'claude-opus-4-6',
254
+ CLAUDE_CODE_ADAPTIVE_200K_BETA,
255
+ 'adaptive-effort',
256
+ ),
257
+ 'claude-opus-4-7': claudeCode200KSubscriptionPolicy(
258
+ 'claude-opus-4-7',
259
+ CLAUDE_CODE_ADAPTIVE_200K_BETA,
260
+ 'adaptive-effort',
261
+ ),
262
+ 'claude-opus-4-8': claudeCode200KSubscriptionPolicy(
263
+ 'claude-opus-4-8',
264
+ CLAUDE_CODE_ADAPTIVE_200K_BETA,
265
+ 'adaptive-effort',
266
+ ),
267
+ 'claude-opus-5': claudeCode200KSubscriptionPolicy(
268
+ 'claude-opus-5',
269
+ CLAUDE_CODE_ADAPTIVE_200K_BETA,
270
+ 'adaptive-effort',
271
+ ),
272
+ 'claude-sonnet-4-0': claudeCode200KSubscriptionPolicy(
273
+ 'claude-sonnet-4-0',
274
+ CLAUDE_CODE_BETA,
275
+ 'fixed-budget',
276
+ ),
277
+ 'claude-sonnet-4-20250514': claudeCode200KSubscriptionPolicy(
278
+ 'claude-sonnet-4-20250514',
279
+ CLAUDE_CODE_BETA,
280
+ 'fixed-budget',
281
+ ),
282
+ 'claude-sonnet-4-5': claudeCode200KSubscriptionPolicy(
283
+ 'claude-sonnet-4-5',
284
+ CLAUDE_CODE_BETA,
285
+ 'fixed-budget',
286
+ ),
287
+ 'claude-sonnet-4-5-20250929': claudeCode200KSubscriptionPolicy(
288
+ 'claude-sonnet-4-5-20250929',
289
+ CLAUDE_CODE_BETA,
290
+ 'fixed-budget',
291
+ ),
292
+ 'claude-sonnet-4-6': claudeCode200KSubscriptionPolicy(
293
+ 'claude-sonnet-4-6',
294
+ CLAUDE_CODE_ADAPTIVE_200K_BETA,
295
+ 'adaptive-effort',
296
+ ),
297
+ 'claude-sonnet-5': claudeCode200KSubscriptionPolicy(
298
+ 'claude-sonnet-5',
299
+ CLAUDE_CODE_ADAPTIVE_200K_BETA,
300
+ 'adaptive-effort',
301
+ ),
302
+ });
303
+
304
+ export interface PiSessionManagerLike {
305
+ getSessionId(): string;
306
+ getBranch(): readonly unknown[];
307
+ }
308
+
309
+ export interface PiContextLike {
310
+ readonly model?: PiModelLike;
311
+ readonly sessionManager: PiSessionManagerLike;
312
+ readonly ui?: {
313
+ notify(message: string, level: 'info' | 'warning' | 'error'): void;
314
+ };
315
+ }
316
+
317
+ export interface PiProviderRegistrationConfig {
318
+ readonly api?: string;
319
+ readonly headers?: Record<string, string>;
320
+ readonly streamSimple?: (
321
+ model: PiModelLike,
322
+ context: PiStreamContext,
323
+ options?: PiSimpleStreamOptions,
324
+ ) => AssistantMessageEventStreamLike;
325
+ }
326
+
327
+ export interface PiProviderRegistrationHost {
328
+ registerProvider(name: string, config: PiProviderRegistrationConfig): void;
329
+ }
330
+
331
+ interface PiCommandConfigLike {
332
+ readonly description: string;
333
+ readonly handler: (args: string, ctx: PiContextLike) => Promise<void> | void;
334
+ }
335
+
336
+ interface PiEventBusLike {
337
+ emit(channel: string, data: unknown): void;
338
+ on(channel: string, handler: (data: unknown) => void): () => void;
339
+ }
340
+
341
+ export interface PiExtensionHost extends PiProviderRegistrationHost {
342
+ readonly events: PiEventBusLike;
343
+ on(
344
+ eventName: 'session_start' | 'session_shutdown' | 'session_tree' | 'before_agent_start',
345
+ handler: (event: unknown, ctx: PiContextLike) => void,
346
+ ): void;
347
+ on(
348
+ eventName: 'before_provider_request',
349
+ handler: (event: { readonly payload: unknown }, ctx: PiContextLike) => unknown,
350
+ ): void;
351
+ registerCommand(name: string, config: PiCommandConfigLike): void;
352
+ appendEntry(customType: string, data?: unknown): void;
353
+ }
354
+
355
+ type PiContentBlock =
356
+ | { readonly type: 'text'; readonly text: string }
357
+ | { readonly type: 'image'; readonly mimeType: string; readonly data: string };
358
+
359
+ type PiMessage =
360
+ | { readonly role: 'user'; readonly content: string | readonly PiContentBlock[] }
361
+ | { readonly role: 'assistant'; readonly content: readonly JsonObject[] }
362
+ | {
363
+ readonly role: 'toolResult';
364
+ readonly toolCallId: string;
365
+ readonly content: readonly PiContentBlock[];
366
+ readonly isError?: boolean;
367
+ };
368
+
369
+ export interface PiStreamContext {
370
+ readonly messages: readonly PiMessage[];
371
+ readonly systemPrompt?: string;
372
+ readonly tools?: readonly PiToolLike[];
373
+ }
374
+
375
+ export interface PiToolLike {
376
+ readonly name: string;
377
+ readonly description?: string;
378
+ readonly parameters?: unknown;
379
+ }
380
+
381
+ export interface PiSimpleStreamOptions {
382
+ readonly apiKey?: string;
383
+ readonly headers?: Record<string, string>;
384
+ readonly maxTokens?: number;
385
+ readonly reasoning?: 'off' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
386
+ readonly thinkingBudgets?: Partial<
387
+ Record<'minimal' | 'low' | 'medium' | 'high' | 'xhigh', number>
388
+ >;
389
+ readonly signal?: AbortSignal;
390
+ readonly timeoutMs?: number;
391
+ readonly maxRetries?: number;
392
+ readonly temperature?: number;
393
+ readonly cacheRetention?: CacheRetention;
394
+ readonly sessionId?: string;
395
+ readonly env?: ProviderEnv;
396
+ readonly metadata?: { readonly user_id?: string };
397
+ readonly toolChoice?: unknown;
398
+ readonly onPayload?: (payload: JsonObject, model: PiModelLike) => Promise<unknown> | unknown;
399
+ readonly onResponse?: (
400
+ response: { readonly status: number; readonly headers: Record<string, string> },
401
+ model: PiModelLike,
402
+ ) => Promise<void> | void;
403
+ }
404
+
405
+ export interface AssistantMessageLike {
406
+ role: 'assistant';
407
+ content: JsonObject[];
408
+ api: string | undefined;
409
+ provider: string | undefined;
410
+ model: string | undefined;
411
+ usage: {
412
+ input: number;
413
+ output: number;
414
+ cacheRead: number;
415
+ cacheWrite: number;
416
+ cacheWrite1h?: number;
417
+ totalTokens: number;
418
+ cost: { input: number; output: number; cacheRead: number; cacheWrite: number; total: number };
419
+ };
420
+ stopReason: 'stop' | 'length' | 'toolUse' | 'aborted' | 'error';
421
+ timestamp: number;
422
+ responseId?: string;
423
+ errorMessage?: string;
424
+ }
425
+
426
+ type AssistantMessageEvent =
427
+ | { readonly type: 'start'; readonly partial: AssistantMessageLike }
428
+ | {
429
+ readonly type: 'text_start';
430
+ readonly contentIndex: number;
431
+ readonly partial: AssistantMessageLike;
432
+ }
433
+ | {
434
+ readonly type: 'text_delta';
435
+ readonly contentIndex: number;
436
+ readonly delta: string;
437
+ readonly partial: AssistantMessageLike;
438
+ }
439
+ | {
440
+ readonly type: 'text_end';
441
+ readonly contentIndex: number;
442
+ readonly content: string;
443
+ readonly partial: AssistantMessageLike;
444
+ }
445
+ | {
446
+ readonly type: 'thinking_start';
447
+ readonly contentIndex: number;
448
+ readonly partial: AssistantMessageLike;
449
+ }
450
+ | {
451
+ readonly type: 'thinking_delta';
452
+ readonly contentIndex: number;
453
+ readonly delta: string;
454
+ readonly partial: AssistantMessageLike;
455
+ }
456
+ | {
457
+ readonly type: 'thinking_end';
458
+ readonly contentIndex: number;
459
+ readonly content: string;
460
+ readonly partial: AssistantMessageLike;
461
+ }
462
+ | {
463
+ readonly type: 'toolcall_start';
464
+ readonly contentIndex: number;
465
+ readonly partial: AssistantMessageLike;
466
+ }
467
+ | {
468
+ readonly type: 'toolcall_delta';
469
+ readonly contentIndex: number;
470
+ readonly delta: string;
471
+ readonly partial: AssistantMessageLike;
472
+ }
473
+ | {
474
+ readonly type: 'toolcall_end';
475
+ readonly contentIndex: number;
476
+ readonly toolCall: JsonObject;
477
+ readonly partial: AssistantMessageLike;
478
+ }
479
+ | {
480
+ readonly type: 'done';
481
+ readonly reason: AssistantMessageLike['stopReason'];
482
+ readonly message: AssistantMessageLike;
483
+ }
484
+ | {
485
+ readonly type: 'error';
486
+ readonly reason: AssistantMessageLike['stopReason'];
487
+ readonly error: AssistantMessageLike;
488
+ };
489
+
490
+ export interface AssistantMessageEventStreamLike extends AsyncIterable<AssistantMessageEvent> {
491
+ push(event: AssistantMessageEvent): void;
492
+ end(result?: AssistantMessageLike): void;
493
+ result(): Promise<AssistantMessageLike>;
494
+ }
495
+
496
+ class LocalAssistantMessageEventStream implements AssistantMessageEventStreamLike {
497
+ private queue: AssistantMessageEvent[] = [];
498
+ private waiting: Array<(result: IteratorResult<AssistantMessageEvent>) => void> = [];
499
+ private done = false;
500
+ private readonly finalResultPromise: Promise<AssistantMessageLike>;
501
+ private resolveFinalResult!: (value: AssistantMessageLike) => void;
502
+
503
+ constructor() {
504
+ this.finalResultPromise = new Promise((resolve) => {
505
+ this.resolveFinalResult = resolve;
506
+ });
507
+ }
508
+
509
+ push(event: AssistantMessageEvent): void {
510
+ if (this.done) return;
511
+ if (event.type === 'done') {
512
+ this.done = true;
513
+ this.resolveFinalResult(event.message);
514
+ } else if (event.type === 'error') {
515
+ this.done = true;
516
+ this.resolveFinalResult(event.error);
517
+ }
518
+ const waiter = this.waiting.shift();
519
+ if (waiter) waiter({ value: event, done: false });
520
+ else this.queue.push(event);
521
+ }
522
+
523
+ end(result?: AssistantMessageLike): void {
524
+ this.done = true;
525
+ if (result !== undefined) this.resolveFinalResult(result);
526
+ while (this.waiting.length > 0) {
527
+ this.waiting.shift()?.({ value: undefined, done: true });
528
+ }
529
+ }
530
+
531
+ async *[Symbol.asyncIterator](): AsyncIterator<AssistantMessageEvent> {
532
+ for (;;) {
533
+ const queued = this.queue.shift();
534
+ if (queued) {
535
+ yield queued;
536
+ } else if (this.done) {
537
+ return;
538
+ } else {
539
+ const next = await new Promise<IteratorResult<AssistantMessageEvent>>((resolve) =>
540
+ this.waiting.push(resolve),
541
+ );
542
+ if (next.done) return;
543
+ yield next.value;
544
+ }
545
+ }
546
+ }
547
+
548
+ result(): Promise<AssistantMessageLike> {
549
+ return this.finalResultPromise;
550
+ }
551
+ }
552
+
553
+ function createAssistantMessageEventStream(): AssistantMessageEventStreamLike {
554
+ return new LocalAssistantMessageEventStream();
555
+ }
556
+
557
+ function isPlainObject(value: unknown): value is JsonObject {
558
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
559
+ }
560
+
561
+ function providerEnvValue(name: string, env?: ProviderEnv): string | undefined {
562
+ return env?.[name] ?? process.env[name];
563
+ }
564
+
565
+ function parseCacheRetention(value: string, source: string): CacheRetention {
566
+ if (value === 'none' || value === 'short' || value === 'long') return value;
567
+ throw new Error(
568
+ `Anthropic attribution ${source} must be one of none, short, or long; got ${JSON.stringify(value)}`,
569
+ );
570
+ }
571
+
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
+ */
578
+ export function resolveCacheRetentionPreference(
579
+ options?: {
580
+ readonly cacheRetention?: CacheRetention;
581
+ readonly env?: ProviderEnv;
582
+ },
583
+ sessionOverride?: Exclude<CacheRetention, 'none'>,
584
+ ): CacheRetention {
585
+ if (options?.cacheRetention !== undefined) return options.cacheRetention;
586
+ if (sessionOverride !== undefined) return sessionOverride;
587
+ const configured = providerEnvValue(CACHE_RETENTION_ENV, options?.env);
588
+ if (configured !== undefined) return parseCacheRetention(configured, CACHE_RETENTION_ENV);
589
+ return 'long';
590
+ }
591
+
592
+ /** Restore the latest branch-local command decision; custom entries stay out of LLM context. */
593
+ export function restoreAnthropicSessionCacheRetention(
594
+ entries: readonly unknown[],
595
+ ): Exclude<CacheRetention, 'none'> | undefined {
596
+ let restored: Exclude<CacheRetention, 'none'> | undefined;
597
+ for (const entry of entries) {
598
+ if (!isPlainObject(entry) || entry['type'] !== 'custom') continue;
599
+ if (entry['customType'] !== ANTHROPIC_CACHE_RETENTION_ENTRY) continue;
600
+ const data = entry['data'];
601
+ if (
602
+ !isPlainObject(data) ||
603
+ data['schema_version'] !== ANTHROPIC_CACHE_RETENTION_SCHEMA ||
604
+ (data['retention'] !== 'default' &&
605
+ data['retention'] !== 'short' &&
606
+ data['retention'] !== 'long')
607
+ ) {
608
+ throw new Error('Anthropic attribution found a malformed persisted cache retention entry');
609
+ }
610
+ restored = data['retention'] === 'default' ? undefined : data['retention'];
611
+ }
612
+ return restored;
613
+ }
614
+
615
+ function resolveAnthropicCacheControl(
616
+ model: PiModelLike | undefined,
617
+ options?: { readonly cacheRetention?: CacheRetention; readonly env?: ProviderEnv },
618
+ ): AnthropicCacheControl | undefined {
619
+ const retention = resolveCacheRetentionPreference(options);
620
+ if (retention === 'none') return undefined;
621
+ const ttl =
622
+ retention === 'long' && (model?.compat?.supportsLongCacheRetention ?? true) ? '1h' : undefined;
623
+ return ttl === undefined ? { type: 'ephemeral' } : { type: 'ephemeral', ttl };
624
+ }
625
+
626
+ function cloneAnthropicCacheControl(value: unknown): AnthropicCacheControl | undefined {
627
+ if (value === undefined) return undefined;
628
+ if (!isPlainObject(value)) {
629
+ throw new Error(
630
+ 'Anthropic attribution cannot safely process malformed cache_control; expected an object',
631
+ );
632
+ }
633
+ if (value['type'] !== 'ephemeral') {
634
+ throw new Error(
635
+ 'Anthropic attribution cannot safely process malformed cache_control.type; expected "ephemeral"',
636
+ );
637
+ }
638
+ const ttl = value['ttl'];
639
+ if (ttl !== undefined && ttl !== '1h' && ttl !== '5m') {
640
+ throw new Error(
641
+ 'Anthropic attribution cannot safely process malformed cache_control.ttl; expected "1h" or "5m"',
642
+ );
643
+ }
644
+ return {
645
+ ...value,
646
+ type: 'ephemeral',
647
+ ...(ttl === undefined ? {} : { ttl }),
648
+ } as AnthropicCacheControl;
649
+ }
650
+
651
+ function mergedCacheControl(
652
+ existing: unknown,
653
+ desired: AnthropicCacheControl | undefined,
654
+ ): AnthropicCacheControl | undefined {
655
+ const existingControl = cloneAnthropicCacheControl(existing);
656
+ if (existingControl === undefined) return desired === undefined ? undefined : { ...desired };
657
+ if (desired?.ttl === '1h' && existingControl.ttl !== '1h')
658
+ return { ...existingControl, ttl: '1h' };
659
+ return existingControl;
660
+ }
661
+
662
+ function cloneBlockWithCacheControl(
663
+ block: JsonObject,
664
+ desired: AnthropicCacheControl | undefined,
665
+ ): JsonObject {
666
+ const next = { ...block };
667
+ const cacheControl = mergedCacheControl(next['cache_control'], desired);
668
+ if (cacheControl !== undefined) next['cache_control'] = cacheControl;
669
+ return next;
670
+ }
671
+
672
+ function stripAnthropicSystemPromptBadLines(text: string): string {
673
+ return text
674
+ .split('\n')
675
+ .filter((line) => !ANTHROPIC_SYSTEM_PROMPT_BAD_LINES.has(line))
676
+ .join('\n');
677
+ }
678
+
679
+ interface CacheControlInspection {
680
+ readonly count: number;
681
+ readonly retention: Exclude<CacheRetention, 'none'> | undefined;
682
+ }
683
+
684
+ function inspectCacheControls(payload: JsonObject): CacheControlInspection {
685
+ let count = 0;
686
+ let hasLong = false;
687
+ const inspectBlock = (block: unknown): void => {
688
+ if (!isPlainObject(block) || block['cache_control'] === undefined) return;
689
+ const cacheControl = cloneAnthropicCacheControl(block['cache_control']);
690
+ count += 1;
691
+ if (cacheControl?.ttl === '1h') hasLong = true;
692
+ };
693
+
694
+ const system = payload['system'];
695
+ if (Array.isArray(system)) {
696
+ for (const block of system) inspectBlock(block);
697
+ }
698
+
699
+ const tools = payload['tools'];
700
+ if (Array.isArray(tools)) {
701
+ for (const tool of tools) inspectBlock(tool);
702
+ }
703
+
704
+ const messages = payload['messages'];
705
+ if (Array.isArray(messages)) {
706
+ for (const message of messages) {
707
+ if (!isPlainObject(message)) continue;
708
+ const content = message['content'];
709
+ if (Array.isArray(content)) {
710
+ for (const block of content) inspectBlock(block);
711
+ }
712
+ }
713
+ }
714
+
715
+ return { count, retention: count === 0 ? undefined : hasLong ? 'long' : 'short' };
716
+ }
717
+
718
+ function countCacheControlBreakpoints(payload: JsonObject): number {
719
+ return inspectCacheControls(payload).count;
720
+ }
721
+
722
+ function assertCacheControlBreakpointLimit(payload: JsonObject): void {
723
+ const count = countCacheControlBreakpoints(payload);
724
+ if (count > ANTHROPIC_CACHE_CONTROL_BREAKPOINT_LIMIT) {
725
+ throw new Error(
726
+ `Anthropic attribution produced ${count} cache_control breakpoints; Anthropic supports at most ${ANTHROPIC_CACHE_CONTROL_BREAKPOINT_LIMIT}`,
727
+ );
728
+ }
729
+ }
730
+
731
+ function assertNonEmptyString(value: unknown, fieldName: string, configPath: string): string {
732
+ if (typeof value !== 'string' || value.trim().length === 0) {
733
+ throw new Error(
734
+ `Anthropic attribution config ${configPath} missing/malformed required field ${fieldName}`,
735
+ );
736
+ }
737
+ return value;
738
+ }
739
+
740
+ export function extractClaudeAttributionAccount(
741
+ parsedConfig: unknown,
742
+ configPath: string,
743
+ ): ClaudeAttributionAccount {
744
+ if (!isPlainObject(parsedConfig)) {
745
+ throw new Error(`Anthropic attribution config ${configPath} is not a JSON object`);
746
+ }
747
+ const oauthAccount = parsedConfig['oauthAccount'];
748
+ if (!isPlainObject(oauthAccount)) {
749
+ throw new Error(
750
+ `Anthropic attribution config ${configPath} missing/malformed required field oauthAccount.accountUuid`,
751
+ );
752
+ }
753
+ return {
754
+ deviceId: assertNonEmptyString(parsedConfig['userID'], 'userID', configPath),
755
+ accountUuid: assertNonEmptyString(
756
+ oauthAccount['accountUuid'],
757
+ 'oauthAccount.accountUuid',
758
+ configPath,
759
+ ),
760
+ };
761
+ }
762
+
763
+ export function loadClaudeAttributionAccount(
764
+ configPath = join(homedir(), '.claude.json'),
765
+ ): ClaudeAttributionAccount {
766
+ let configText: string;
767
+ try {
768
+ configText = readFileSync(configPath, 'utf8');
769
+ } catch (error) {
770
+ throw new Error(
771
+ `Anthropic attribution config ${configPath} could not be read: ${error instanceof Error ? error.message : String(error)}`,
772
+ );
773
+ }
774
+ return extractClaudeAttributionAccount(
775
+ parseJsonValue(configText, `Anthropic attribution config ${configPath}`),
776
+ configPath,
777
+ );
778
+ }
779
+
780
+ export function isAnthropicContext(ctx: PiContextLike): boolean {
781
+ return ctx.model?.provider === 'anthropic';
782
+ }
783
+
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');
788
+ }
789
+ return sessionId;
790
+ }
791
+
792
+ function normalizedAnthropicModelId(model: PiModelLike): string {
793
+ if (typeof model.id !== 'string' || model.id.trim().length === 0) {
794
+ throw new Error('Anthropic attribution requires a non-empty model id');
795
+ }
796
+ const providerPrefix = 'anthropic/';
797
+ return model.id.startsWith(providerPrefix) ? model.id.slice(providerPrefix.length) : model.id;
798
+ }
799
+
800
+ export function resolveClaudeCodeModelPolicy(model: PiModelLike): ClaudeCodeModelPolicy {
801
+ const modelId = normalizedAnthropicModelId(model);
802
+ const policy = CLAUDE_CODE_MODEL_POLICIES[modelId];
803
+ if (policy === undefined) {
804
+ throw new Error(`Anthropic attribution has no Claude Code model policy for ${modelId}`);
805
+ }
806
+ return policy;
807
+ }
808
+
809
+ export function resolveAnthropicMaxTokens(model: PiModelLike): number {
810
+ return assertPositiveInteger(
811
+ model.maxTokens,
812
+ `model.maxTokens for ${normalizedAnthropicModelId(model)}`,
813
+ );
814
+ }
815
+
816
+ export function computeClaudeCodeFingerprint(
817
+ messageText: string,
818
+ version = CLAUDE_CODE_VERSION,
819
+ ): string {
820
+ const chars = [4, 7, 20].map((index) => messageText[index] || '0').join('');
821
+ return createHash('sha256')
822
+ .update(`${FINGERPRINT_SALT}${chars}${version}`)
823
+ .digest('hex')
824
+ .slice(0, 3);
825
+ }
826
+
827
+ function firstUserMessageTextFromPayload(payload: JsonObject): string {
828
+ const messages = payload['messages'];
829
+ if (!Array.isArray(messages)) return '';
830
+ for (const message of messages) {
831
+ if (!isPlainObject(message) || message['role'] !== 'user') continue;
832
+ const content = message['content'];
833
+ if (typeof content === 'string') return content;
834
+ if (Array.isArray(content)) {
835
+ const textBlock = content.find(
836
+ (block) =>
837
+ isPlainObject(block) && block['type'] === 'text' && typeof block['text'] === 'string',
838
+ );
839
+ if (isPlainObject(textBlock) && typeof textBlock['text'] === 'string')
840
+ return textBlock['text'];
841
+ }
842
+ }
843
+ return '';
844
+ }
845
+
846
+ export function buildClaudeCodeBillingSystemText(firstUserMessageText: string): string {
847
+ const fingerprint = computeClaudeCodeFingerprint(firstUserMessageText);
848
+ return `x-anthropic-billing-header: cc_version=${CLAUDE_CODE_VERSION}.${fingerprint}; cc_entrypoint=${CLAUDE_CODE_ENTRYPOINT}; cch=${NATIVE_ATTESTATION_PLACEHOLDER};`;
849
+ }
850
+
851
+ export function buildAnthropicAttributionHeaders(
852
+ sessionId: string,
853
+ model?: PiModelLike,
854
+ ): Record<string, string> {
855
+ const beta = model === undefined ? CLAUDE_CODE_BETA : resolveClaudeCodeModelPolicy(model).beta;
856
+ return {
857
+ [CLAUDE_CODE_SESSION_HEADER]: sessionId,
858
+ 'anthropic-beta': beta,
859
+ 'anthropic-version': '2023-06-01',
860
+ 'User-Agent': CLAUDE_CODE_USER_AGENT,
861
+ 'x-app': 'cli',
862
+ 'anthropic-dangerous-direct-browser-access': 'true',
863
+ };
864
+ }
865
+
866
+ export function registerAnthropicAttributionProvider(
867
+ pi: PiProviderRegistrationHost,
868
+ ctx: PiContextLike,
869
+ getSessionOverride: () => Exclude<CacheRetention, 'none'> | undefined = () => undefined,
870
+ ): void {
871
+ if (!isAnthropicContext(ctx)) return;
872
+ pi.registerProvider('anthropic', {
873
+ api: 'anthropic-messages',
874
+ headers: buildAnthropicAttributionHeaders(getSessionId(ctx), ctx.model),
875
+ streamSimple: (model, context, options) =>
876
+ streamAnthropicViaBetaMessages(model, context, {
877
+ ...(options ?? {}),
878
+ cacheRetention: resolveCacheRetentionPreference(options, getSessionOverride()),
879
+ }),
880
+ });
881
+ }
882
+
883
+ function assertPositiveInteger(value: unknown, fieldName: string): number {
884
+ if (!Number.isInteger(value) || typeof value !== 'number' || value <= 0) {
885
+ throw new Error(
886
+ `Anthropic attribution cannot safely process malformed ${fieldName}; expected a positive integer`,
887
+ );
888
+ }
889
+ return value;
890
+ }
891
+
892
+ function rewriteThinking(
893
+ payload: JsonObject,
894
+ maxTokens: number | undefined,
895
+ ): { readonly thinking: unknown; readonly budgetTokens: number | undefined } {
896
+ if (payload['thinking'] === undefined) return { thinking: undefined, budgetTokens: undefined };
897
+ if (!isPlainObject(payload['thinking'])) {
898
+ throw new Error(
899
+ 'Anthropic attribution cannot safely process malformed thinking; expected an object',
900
+ );
901
+ }
902
+ const thinking = { ...payload['thinking'] };
903
+ if (thinking['type'] === 'disabled')
904
+ return { thinking: { type: 'disabled' }, budgetTokens: undefined };
905
+ if (thinking['budget_tokens'] === undefined) return { thinking, budgetTokens: undefined };
906
+ const existingBudget = assertPositiveInteger(thinking['budget_tokens'], 'thinking.budget_tokens');
907
+ if (maxTokens !== undefined && existingBudget >= maxTokens) {
908
+ thinking['budget_tokens'] = maxTokens - 1;
909
+ }
910
+ if (typeof thinking['budget_tokens'] === 'number' && thinking['budget_tokens'] <= 0) {
911
+ throw new Error(
912
+ 'Anthropic attribution cannot satisfy thinking.budget_tokens < max_tokens when max_tokens <= 1',
913
+ );
914
+ }
915
+ return { thinking, budgetTokens: thinking['budget_tokens'] as number };
916
+ }
917
+
918
+ function isClaudeCodeIdentityText(text: string): boolean {
919
+ return (
920
+ text.startsWith('x-anthropic-billing-header:') ||
921
+ text === CLAUDE_AGENT_SDK_SYSTEM_TEXT ||
922
+ text === "You are Claude Code, Anthropic's official CLI for Claude." ||
923
+ text ===
924
+ "You are Claude Code, Anthropic's official CLI for Claude, running within the Claude Agent SDK."
925
+ );
926
+ }
927
+
928
+ function normalizeSystemBlock(block: unknown): unknown {
929
+ if (!isPlainObject(block)) return block;
930
+ const next = { ...block };
931
+ if (typeof next['text'] === 'string')
932
+ next['text'] = stripAnthropicSystemPromptBadLines(next['text']);
933
+ if (next['cache_control'] !== undefined)
934
+ next['cache_control'] = cloneAnthropicCacheControl(next['cache_control']);
935
+ return next;
936
+ }
937
+
938
+ function hasCacheControl(block: unknown): block is JsonObject {
939
+ return isPlainObject(block) && block['cache_control'] !== undefined;
940
+ }
941
+
942
+ function isSystemCacheSurface(block: unknown): block is JsonObject {
943
+ return isPlainObject(block) && typeof block['text'] === 'string';
944
+ }
945
+
946
+ function markSystemCacheSurface(
947
+ blocks: readonly unknown[],
948
+ desired: AnthropicCacheControl | undefined,
949
+ ): unknown[] {
950
+ const output = blocks.map((block) => (isPlainObject(block) ? { ...block } : block));
951
+ if (desired === undefined) return output;
952
+
953
+ let lastTextBlockIndex = -1;
954
+ for (let index = 0; index < output.length; index += 1) {
955
+ if (isSystemCacheSurface(output[index])) lastTextBlockIndex = index;
956
+ }
957
+
958
+ if (lastTextBlockIndex === -1) return output;
959
+
960
+ const withLongRetentionUpgrades =
961
+ desired.ttl === '1h'
962
+ ? output.map((block) =>
963
+ hasCacheControl(block) ? cloneBlockWithCacheControl(block, desired) : block,
964
+ )
965
+ : output;
966
+ withLongRetentionUpgrades[lastTextBlockIndex] = cloneBlockWithCacheControl(
967
+ withLongRetentionUpgrades[lastTextBlockIndex] as JsonObject,
968
+ desired,
969
+ );
970
+ return withLongRetentionUpgrades;
971
+ }
972
+
973
+ function withClaudeCodeSystemIdentity(
974
+ system: unknown,
975
+ billingSystemText: string,
976
+ cacheControl: AnthropicCacheControl | undefined,
977
+ ): unknown {
978
+ const identityBlocks: JsonObject[] = [
979
+ { type: 'text', text: billingSystemText },
980
+ { type: 'text', text: CLAUDE_AGENT_SDK_SYSTEM_TEXT },
981
+ ];
982
+ if (system === undefined) return markSystemCacheSurface(identityBlocks, cacheControl);
983
+ if (Array.isArray(system)) {
984
+ const withoutPriorIdentity = system
985
+ .filter((entry) => {
986
+ if (!isPlainObject(entry) || typeof entry['text'] !== 'string') return true;
987
+ return !isClaudeCodeIdentityText(entry['text']);
988
+ })
989
+ .map(normalizeSystemBlock);
990
+ return markSystemCacheSurface([...identityBlocks, ...withoutPriorIdentity], cacheControl);
991
+ }
992
+ if (typeof system === 'string') {
993
+ return markSystemCacheSurface(
994
+ [...identityBlocks, { type: 'text', text: stripAnthropicSystemPromptBadLines(system) }],
995
+ cacheControl,
996
+ );
997
+ }
998
+ throw new Error(
999
+ 'Anthropic attribution cannot safely apply Claude Code system identity to malformed system payload',
1000
+ );
1001
+ }
1002
+
1003
+ function appendAuditRecord(args: {
1004
+ readonly provider: 'anthropic';
1005
+ readonly headerRegistered: boolean;
1006
+ readonly metadataSessionMatchesHeader: boolean;
1007
+ readonly maxTokens: number | undefined;
1008
+ readonly thinkingBudgetTokens: number | undefined;
1009
+ readonly beta: string;
1010
+ readonly betaResourcePath: string;
1011
+ readonly nativeAttestation: 'placeholder-pending-live';
1012
+ }): void {
1013
+ const auditPath = process.env[AUDIT_ENV];
1014
+ if (auditPath === undefined || auditPath.length === 0) return;
1015
+ const record = {
1016
+ schema_version: 'pipeline.anthropic_attribution_audit.v1',
1017
+ provider: args.provider,
1018
+ header_name: CLAUDE_CODE_SESSION_HEADER,
1019
+ header_registered: args.headerRegistered,
1020
+ anthropic_beta: args.beta,
1021
+ anthropic_version: '2023-06-01',
1022
+ beta_resource_path: args.betaResourcePath,
1023
+ native_attestation: args.nativeAttestation,
1024
+ metadata_user_id_keys: ['account_uuid', 'device_id', 'session_id'],
1025
+ metadata_session_id_matches_header: args.metadataSessionMatchesHeader,
1026
+ account_uuid_present: true,
1027
+ device_id_present: true,
1028
+ max_tokens: args.maxTokens,
1029
+ thinking_budget_tokens: args.thinkingBudgetTokens,
1030
+ };
1031
+ appendFileSync(auditPath, `${JSON.stringify(record)}\n`, { encoding: 'utf8', mode: 0o600 });
1032
+ }
1033
+
1034
+ export function rewriteAnthropicRequestPayload(args: {
1035
+ readonly payload: unknown;
1036
+ readonly ctx: PiContextLike;
1037
+ readonly account: ClaudeAttributionAccount;
1038
+ readonly headerRegistered?: boolean;
1039
+ readonly cacheRetention?: CacheRetention;
1040
+ readonly env?: ProviderEnv;
1041
+ }): unknown {
1042
+ if (!isAnthropicContext(args.ctx)) return undefined;
1043
+ if (!isPlainObject(args.payload)) {
1044
+ throw new Error('Anthropic attribution expected provider payload to be a JSON object');
1045
+ }
1046
+
1047
+ const sessionId = getSessionId(args.ctx);
1048
+ const metadata = args.payload['metadata'] === undefined ? {} : args.payload['metadata'];
1049
+ if (!isPlainObject(metadata)) {
1050
+ throw new Error('Anthropic attribution expected payload.metadata to be an object when present');
1051
+ }
1052
+
1053
+ const policy = resolveClaudeCodeModelPolicy(args.ctx.model ?? {});
1054
+ const maxTokens =
1055
+ args.payload['max_tokens'] === undefined
1056
+ ? undefined
1057
+ : assertPositiveInteger(args.payload['max_tokens'], 'max_tokens');
1058
+ const { thinking, budgetTokens } = rewriteThinking(args.payload, maxTokens);
1059
+ const billingSystemText = buildClaudeCodeBillingSystemText(
1060
+ firstUserMessageTextFromPayload(args.payload),
1061
+ );
1062
+ const incomingCache = inspectCacheControls(args.payload);
1063
+ // The provider builder has already resolved environment/session defaults and
1064
+ // selected the cache surfaces. No incoming marker can therefore be Pi's
1065
+ // explicit call-level `cacheRetention: "none"` (used for compaction). Reapplying
1066
+ // the process default here would silently defeat that opt-out.
1067
+ const configuredCacheRetention = args.cacheRetention ?? incomingCache.retention;
1068
+ const cacheControl =
1069
+ configuredCacheRetention === undefined
1070
+ ? undefined
1071
+ : resolveAnthropicCacheControl(args.ctx.model, {
1072
+ cacheRetention: configuredCacheRetention,
1073
+ });
1074
+
1075
+ const rewritten: JsonObject = {
1076
+ ...args.payload,
1077
+ metadata: {
1078
+ ...metadata,
1079
+ user_id: JSON.stringify({
1080
+ account_uuid: args.account.accountUuid,
1081
+ device_id: args.account.deviceId,
1082
+ session_id: sessionId,
1083
+ }),
1084
+ },
1085
+ system: withClaudeCodeSystemIdentity(args.payload['system'], billingSystemText, cacheControl),
1086
+ };
1087
+ if (thinking !== undefined) rewritten['thinking'] = thinking;
1088
+ assertCacheControlBreakpointLimit(rewritten);
1089
+
1090
+ appendAuditRecord({
1091
+ provider: 'anthropic',
1092
+ headerRegistered: args.headerRegistered ?? true,
1093
+ metadataSessionMatchesHeader: true,
1094
+ maxTokens,
1095
+ thinkingBudgetTokens: budgetTokens,
1096
+ beta: policy.beta,
1097
+ betaResourcePath: '/v1/messages?beta=true',
1098
+ nativeAttestation: 'placeholder-pending-live',
1099
+ });
1100
+
1101
+ return rewritten;
1102
+ }
1103
+
1104
+ function sanitizeSurrogates(text: string): string {
1105
+ return text.replace(/[\uD800-\uDFFF]/g, '\uFFFD');
1106
+ }
1107
+
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) => {
1115
+ if (block.type === 'text') return { type: 'text', text: sanitizeSurrogates(block.text) };
1116
+ return {
1117
+ type: 'image',
1118
+ source: { type: 'base64', media_type: block.mimeType, data: block.data },
1119
+ };
1120
+ });
1121
+ if (!blocks.some((block) => block.type === 'text'))
1122
+ blocks.unshift({ type: 'text', text: '(see attached image)' });
1123
+ return blocks;
1124
+ }
1125
+
1126
+ function cloneMessageForCacheControl(message: JsonObject): JsonObject {
1127
+ const content = message['content'];
1128
+ return {
1129
+ ...message,
1130
+ ...(Array.isArray(content)
1131
+ ? { content: content.map((block) => (isPlainObject(block) ? { ...block } : block)) }
1132
+ : {}),
1133
+ };
1134
+ }
1135
+
1136
+ function isCacheableConversationBlock(role: unknown, block: JsonObject): boolean {
1137
+ if (role === 'assistant') return block['type'] === 'text';
1138
+ return block['type'] === 'text' || block['type'] === 'image' || block['type'] === 'tool_result';
1139
+ }
1140
+
1141
+ function markMessageContentCacheSurface(
1142
+ message: JsonObject,
1143
+ cacheControl: AnthropicCacheControl,
1144
+ ): boolean {
1145
+ const role = message['role'];
1146
+ if (role !== 'user' && role !== 'assistant') return false;
1147
+ const content = message['content'];
1148
+ 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;
1152
+ }
1153
+ if (!Array.isArray(content)) return false;
1154
+ for (let index = content.length - 1; index >= 0; index -= 1) {
1155
+ const block = content[index];
1156
+ if (!isPlainObject(block) || !isCacheableConversationBlock(role, block)) continue;
1157
+ content[index] = cloneBlockWithCacheControl(block, cacheControl);
1158
+ return true;
1159
+ }
1160
+ return false;
1161
+ }
1162
+
1163
+ function markLastConversationCacheSurface(
1164
+ messages: readonly JsonObject[],
1165
+ cacheControl: AnthropicCacheControl | undefined,
1166
+ ): JsonObject[] {
1167
+ const output = messages.map(cloneMessageForCacheControl);
1168
+ if (cacheControl === undefined) return output;
1169
+ for (let index = output.length - 1; index >= 0; index -= 1) {
1170
+ const message = output[index];
1171
+ if (message !== undefined && markMessageContentCacheSurface(message, cacheControl)) break;
1172
+ }
1173
+ return output;
1174
+ }
1175
+
1176
+ function convertMessages(
1177
+ messages: readonly PiMessage[],
1178
+ cacheControl?: AnthropicCacheControl,
1179
+ ): JsonObject[] {
1180
+ const params: JsonObject[] = [];
1181
+ for (let index = 0; index < messages.length; index += 1) {
1182
+ const message = messages[index];
1183
+ if (message === undefined) {
1184
+ throw new TypeError(`Anthropic message ${index} is missing`);
1185
+ }
1186
+ 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') {
1204
+ const content: JsonObject[] = [];
1205
+ 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
+ ) {
1217
+ const signature =
1218
+ 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
+ ) {
1229
+ content.push({
1230
+ type: 'tool_use',
1231
+ id: block['id'],
1232
+ name: block['name'],
1233
+ input: block['arguments'] ?? {},
1234
+ });
1235
+ }
1236
+ }
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;
1257
+ }
1258
+ index = lookahead - 1;
1259
+ params.push({ role: 'user', content: toolResults });
1260
+ }
1261
+ }
1262
+ return markLastConversationCacheSurface(params, cacheControl);
1263
+ }
1264
+
1265
+ function convertTools(
1266
+ tools: readonly PiToolLike[] | undefined,
1267
+ cacheControl?: AnthropicCacheControl,
1268
+ ): JsonObject[] {
1269
+ if (!tools || tools.length === 0) return [];
1270
+ return tools.map((tool, index) => {
1271
+ const parameters = isPlainObject(tool.parameters) ? tool.parameters : {};
1272
+ const converted: JsonObject = {
1273
+ name: tool.name,
1274
+ description: tool.description ?? '',
1275
+ input_schema: {
1276
+ type: 'object',
1277
+ properties: isPlainObject(parameters['properties']) ? parameters['properties'] : {},
1278
+ required: Array.isArray(parameters['required']) ? parameters['required'] : [],
1279
+ },
1280
+ };
1281
+ return cacheControl !== undefined && index === tools.length - 1
1282
+ ? cloneBlockWithCacheControl(converted, cacheControl)
1283
+ : converted;
1284
+ });
1285
+ }
1286
+
1287
+ function thinkingBudgetFor(
1288
+ level: NonNullable<PiSimpleStreamOptions['reasoning']>,
1289
+ maxTokens: number,
1290
+ custom?: PiSimpleStreamOptions['thinkingBudgets'],
1291
+ ): number {
1292
+ const defaults = {
1293
+ minimal: 1024,
1294
+ low: 4096,
1295
+ medium: 10240,
1296
+ high: 20480,
1297
+ xhigh: 32768,
1298
+ off: 0,
1299
+ } as const;
1300
+ const requested = level === 'off' ? 0 : (custom?.[level] ?? defaults[level]);
1301
+ return Math.min(maxTokens - 1, requested);
1302
+ }
1303
+
1304
+ function adaptiveEffortFor(
1305
+ level: Exclude<NonNullable<PiSimpleStreamOptions['reasoning']>, 'off'>,
1306
+ ): 'low' | 'medium' | 'high' | 'xhigh' {
1307
+ switch (level) {
1308
+ case 'low':
1309
+ case 'medium':
1310
+ case 'high':
1311
+ case 'xhigh':
1312
+ return level;
1313
+ case 'minimal':
1314
+ throw new Error(
1315
+ 'Anthropic attribution cannot map Pi reasoning=minimal to Claude adaptive effort; use low, medium, high, or xhigh',
1316
+ );
1317
+ }
1318
+ }
1319
+
1320
+ export function buildAnthropicRequestParams(
1321
+ model: PiModelLike,
1322
+ context: PiStreamContext,
1323
+ options?: PiSimpleStreamOptions,
1324
+ ): JsonObject {
1325
+ const policy = resolveClaudeCodeModelPolicy(model);
1326
+ const maxTokens = resolveAnthropicMaxTokens(model);
1327
+ const cacheControl = resolveAnthropicCacheControl(model, options);
1328
+ const params: JsonObject = {
1329
+ model: policy.modelId,
1330
+ messages: convertMessages(context.messages, cacheControl),
1331
+ max_tokens: maxTokens,
1332
+ stream: true,
1333
+ };
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;
1352
+ const reasoning = options?.reasoning;
1353
+ if (model.reasoning && reasoning !== undefined) {
1354
+ if (reasoning === 'off') {
1355
+ params['thinking'] = { type: 'disabled' };
1356
+ params['temperature'] = options?.temperature ?? 1;
1357
+ } else if (policy.thinkingPolicy === 'adaptive-effort') {
1358
+ params['thinking'] = { type: 'adaptive' };
1359
+ params['output_config'] = { effort: adaptiveEffortFor(reasoning) };
1360
+ } else {
1361
+ params['thinking'] = {
1362
+ type: 'enabled',
1363
+ budget_tokens: thinkingBudgetFor(reasoning, maxTokens, options?.thinkingBudgets),
1364
+ };
1365
+ }
1366
+ } else {
1367
+ params['thinking'] = { type: 'disabled' };
1368
+ params['temperature'] = options?.temperature ?? 1;
1369
+ }
1370
+ assertCacheControlBreakpointLimit(params);
1371
+ return params;
1372
+ }
1373
+
1374
+ function headersToRecord(headers: Headers): Record<string, string> {
1375
+ return Object.fromEntries([...headers.entries()]);
1376
+ }
1377
+
1378
+ function lowerHeaderMap(headers: Record<string, string> | undefined): Record<string, string> {
1379
+ const output: Record<string, string> = {};
1380
+ for (const [key, value] of Object.entries(headers ?? {})) output[key.toLowerCase()] = value;
1381
+ return output;
1382
+ }
1383
+
1384
+ function buildFetchHeaders(
1385
+ options: PiSimpleStreamOptions | undefined,
1386
+ apiKey: string,
1387
+ sessionHeader: string | undefined,
1388
+ beta: string,
1389
+ ): Record<string, string> {
1390
+ const optionHeaders = lowerHeaderMap(options?.headers);
1391
+ return {
1392
+ Accept: 'application/json',
1393
+ Authorization: `Bearer ${apiKey}`,
1394
+ 'Content-Type': 'application/json',
1395
+ 'User-Agent': optionHeaders['user-agent'] ?? CLAUDE_CODE_USER_AGENT,
1396
+ [CLAUDE_CODE_SESSION_HEADER]: sessionHeader ?? optionHeaders['x-claude-code-session-id'] ?? '',
1397
+ 'anthropic-beta': beta,
1398
+ 'anthropic-dangerous-direct-browser-access': 'true',
1399
+ 'anthropic-version': '2023-06-01',
1400
+ 'x-app': 'cli',
1401
+ };
1402
+ }
1403
+
1404
+ function mapStopReason(reason: unknown): AssistantMessageLike['stopReason'] {
1405
+ switch (reason) {
1406
+ case 'end_turn':
1407
+ case 'pause_turn':
1408
+ case 'stop_sequence':
1409
+ return 'stop';
1410
+ case 'max_tokens':
1411
+ return 'length';
1412
+ case 'tool_use':
1413
+ return 'toolUse';
1414
+ default:
1415
+ return 'error';
1416
+ }
1417
+ }
1418
+
1419
+ function parseStreamingJsonFragment(text: string): unknown {
1420
+ try {
1421
+ return parseJsonSource(text);
1422
+ } catch {
1423
+ return {};
1424
+ }
1425
+ }
1426
+
1427
+ function validCostRate(value: unknown, fallback: number, field: string): number {
1428
+ if (value === undefined) return fallback;
1429
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
1430
+ throw new Error(
1431
+ `Anthropic attribution model cost.${field} must be a finite non-negative number`,
1432
+ );
1433
+ }
1434
+ return value;
1435
+ }
1436
+
1437
+ function resolveModelCostRates(
1438
+ model: PiModelLike,
1439
+ totalInputTokens: number,
1440
+ ): Required<Pick<PiCostRatesLike, 'input' | 'output' | 'cacheRead' | 'cacheWrite'>> {
1441
+ let selected = model.cost;
1442
+ let matchedThreshold = -1;
1443
+ for (const tier of model.cost?.tiers ?? []) {
1444
+ const threshold = tier.inputTokensAbove;
1445
+ if (
1446
+ typeof threshold === 'number' &&
1447
+ Number.isFinite(threshold) &&
1448
+ threshold >= 0 &&
1449
+ totalInputTokens > threshold &&
1450
+ threshold > matchedThreshold
1451
+ ) {
1452
+ selected = tier;
1453
+ matchedThreshold = threshold;
1454
+ }
1455
+ }
1456
+ return {
1457
+ input: validCostRate(selected?.input, 3, 'input'),
1458
+ output: validCostRate(selected?.output, 15, 'output'),
1459
+ cacheRead: validCostRate(selected?.cacheRead, 0.3, 'cacheRead'),
1460
+ cacheWrite: validCostRate(selected?.cacheWrite, 3.75, 'cacheWrite'),
1461
+ };
1462
+ }
1463
+
1464
+ export function updateAnthropicUsage(
1465
+ output: AssistantMessageLike,
1466
+ usage: JsonObject | undefined,
1467
+ model: PiModelLike,
1468
+ ): void {
1469
+ if (!usage) return;
1470
+ if (typeof usage['input_tokens'] === 'number') output.usage.input = usage['input_tokens'];
1471
+ if (typeof usage['output_tokens'] === 'number') output.usage.output = usage['output_tokens'];
1472
+ if (typeof usage['cache_read_input_tokens'] === 'number')
1473
+ output.usage.cacheRead = usage['cache_read_input_tokens'];
1474
+ const cacheCreation = usage['cache_creation'];
1475
+ const reportedLongCacheWrite =
1476
+ isPlainObject(cacheCreation) && typeof cacheCreation['ephemeral_1h_input_tokens'] === 'number'
1477
+ ? cacheCreation['ephemeral_1h_input_tokens']
1478
+ : undefined;
1479
+ if (typeof usage['cache_creation_input_tokens'] === 'number') {
1480
+ output.usage.cacheWrite = usage['cache_creation_input_tokens'];
1481
+ output.usage.cacheWrite1h = reportedLongCacheWrite ?? 0;
1482
+ } else if (reportedLongCacheWrite !== undefined) {
1483
+ output.usage.cacheWrite1h = reportedLongCacheWrite;
1484
+ }
1485
+ const longCacheWrite = output.usage.cacheWrite1h ?? 0;
1486
+ if (
1487
+ !Number.isFinite(longCacheWrite) ||
1488
+ !Number.isInteger(longCacheWrite) ||
1489
+ longCacheWrite < 0 ||
1490
+ longCacheWrite > output.usage.cacheWrite
1491
+ ) {
1492
+ throw new Error(
1493
+ 'Anthropic attribution received malformed 1h cache usage exceeding total cache writes',
1494
+ );
1495
+ }
1496
+ output.usage.totalTokens =
1497
+ output.usage.input + output.usage.output + output.usage.cacheRead + output.usage.cacheWrite;
1498
+ const rates = resolveModelCostRates(
1499
+ model,
1500
+ output.usage.input + output.usage.cacheRead + output.usage.cacheWrite,
1501
+ );
1502
+ const shortCacheWrite = output.usage.cacheWrite - longCacheWrite;
1503
+ output.usage.cost.input = (output.usage.input * rates.input) / 1_000_000;
1504
+ output.usage.cost.output = (output.usage.output * rates.output) / 1_000_000;
1505
+ output.usage.cost.cacheRead = (output.usage.cacheRead * rates.cacheRead) / 1_000_000;
1506
+ output.usage.cost.cacheWrite =
1507
+ (shortCacheWrite * rates.cacheWrite + longCacheWrite * rates.input * 2) / 1_000_000;
1508
+ output.usage.cost.total =
1509
+ output.usage.cost.input +
1510
+ output.usage.cost.output +
1511
+ output.usage.cost.cacheRead +
1512
+ output.usage.cost.cacheWrite;
1513
+ }
1514
+
1515
+ async function* iterateSseEvents(
1516
+ response: Response,
1517
+ signal?: AbortSignal,
1518
+ ): AsyncGenerator<JsonObject> {
1519
+ if (!response.body) throw new Error('Anthropic beta messages response had no body');
1520
+ const reader = response.body.getReader();
1521
+ const decoder = new TextDecoder();
1522
+ let buffer = '';
1523
+ let eventName = '';
1524
+ let dataLines: string[] = [];
1525
+ function flush(): JsonObject | undefined {
1526
+ if (dataLines.length === 0) return undefined;
1527
+ const data = dataLines.join('\n');
1528
+ eventName = '';
1529
+ dataLines = [];
1530
+ if (data === '[DONE]') return undefined;
1531
+ return parseJsonObject(data, 'Anthropic beta messages SSE event');
1532
+ }
1533
+ function consumeLine(line: string): JsonObject | undefined {
1534
+ if (line.length === 0) return flush();
1535
+ if (line.startsWith(':')) return undefined;
1536
+ const colon = line.indexOf(':');
1537
+ const field = colon === -1 ? line : line.slice(0, colon);
1538
+ let value = colon === -1 ? '' : line.slice(colon + 1);
1539
+ if (value.startsWith(' ')) value = value.slice(1);
1540
+ if (field === 'event') eventName = value;
1541
+ if (field === 'data') dataLines.push(value);
1542
+ void eventName;
1543
+ return undefined;
1544
+ }
1545
+ try {
1546
+ for (;;) {
1547
+ if (signal?.aborted) throw new Error('Request was aborted');
1548
+ const { value, done } = await reader.read();
1549
+ if (done) break;
1550
+ buffer += decoder.decode(value, { stream: true });
1551
+ for (;;) {
1552
+ const match = /\r\n|\n|\r/.exec(buffer);
1553
+ if (match?.index === undefined) break;
1554
+ const line = buffer.slice(0, match.index);
1555
+ buffer = buffer.slice(match.index + match[0].length);
1556
+ const event = consumeLine(line);
1557
+ if (event) yield event;
1558
+ }
1559
+ }
1560
+ buffer += decoder.decode();
1561
+ if (buffer.length > 0) {
1562
+ const event = consumeLine(buffer);
1563
+ if (event) yield event;
1564
+ }
1565
+ const trailing = flush();
1566
+ if (trailing) yield trailing;
1567
+ } finally {
1568
+ reader.releaseLock();
1569
+ }
1570
+ }
1571
+
1572
+ function createOutput(model: PiModelLike): AssistantMessageLike {
1573
+ return {
1574
+ role: 'assistant',
1575
+ content: [],
1576
+ api: model.api,
1577
+ provider: model.provider,
1578
+ model: model.id,
1579
+ usage: {
1580
+ input: 0,
1581
+ output: 0,
1582
+ cacheRead: 0,
1583
+ cacheWrite: 0,
1584
+ totalTokens: 0,
1585
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
1586
+ },
1587
+ stopReason: 'stop',
1588
+ timestamp: Date.now(),
1589
+ };
1590
+ }
1591
+
1592
+ async function forwardToBuiltInAnthropic(
1593
+ model: PiModelLike,
1594
+ context: PiStreamContext,
1595
+ 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
+ }
1619
+ }
1620
+
1621
+ export function streamAnthropicViaBetaMessages(
1622
+ model: PiModelLike,
1623
+ context: PiStreamContext,
1624
+ options?: PiSimpleStreamOptions,
1625
+ ): AssistantMessageEventStreamLike {
1626
+ const stream = createAssistantMessageEventStream();
1627
+ const output = createOutput(model);
1628
+
1629
+ if (model.provider !== 'anthropic') {
1630
+ void forwardToBuiltInAnthropic(model, context, options, stream, output);
1631
+ return stream;
1632
+ }
1633
+
1634
+ void (async () => {
1635
+ try {
1636
+ const apiKey = options?.apiKey;
1637
+ if (typeof apiKey !== 'string' || apiKey.length === 0) {
1638
+ throw new Error(
1639
+ 'Anthropic attribution requires Pi OAuth apiKey/token; no credential was supplied',
1640
+ );
1641
+ }
1642
+ if (!apiKey.includes('sk-ant-oat')) {
1643
+ throw new Error(
1644
+ 'Anthropic attribution refuses non-OAuth Anthropic credential; subscription OAuth token is required',
1645
+ );
1646
+ }
1647
+
1648
+ const policy = resolveClaudeCodeModelPolicy(model);
1649
+ let params = buildAnthropicRequestParams(model, context, options);
1650
+ const nextParams = await options?.onPayload?.(params, model);
1651
+ if (nextParams !== undefined) {
1652
+ if (!isPlainObject(nextParams))
1653
+ throw new Error('Anthropic attribution onPayload returned a non-object payload');
1654
+ params = nextParams;
1655
+ }
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)
1665
+ throw new Error(
1666
+ 'Anthropic attribution could not derive session_id from rewritten metadata.user_id',
1667
+ );
1668
+
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
+ const headers = buildFetchHeaders(options, apiKey, sessionId, policy.beta);
1675
+ const requestInit: RequestInit = {
1676
+ method: 'POST',
1677
+ headers,
1678
+ body: JSON.stringify(params),
1679
+ };
1680
+ if (options?.signal) requestInit.signal = options.signal;
1681
+ const response = await fetch(url, requestInit);
1682
+ await options?.onResponse?.(
1683
+ { status: response.status, headers: headersToRecord(response.headers) },
1684
+ model,
1685
+ );
1686
+ if (!response.ok) {
1687
+ throw new Error(
1688
+ `Anthropic beta messages request failed: HTTP ${response.status} ${response.statusText}: ${await response.text()}`,
1689
+ );
1690
+ }
1691
+
1692
+ stream.push({ type: 'start', partial: output });
1693
+ const blocks = output.content as Array<JsonObject & { index?: number; partialJson?: string }>;
1694
+ for await (const event of iterateSseEvents(response, options?.signal)) {
1695
+ if (event['type'] === 'message_start' && isPlainObject(event['message'])) {
1696
+ if (typeof event['message']['id'] === 'string')
1697
+ output.responseId = event['message']['id'];
1698
+ updateAnthropicUsage(
1699
+ output,
1700
+ isPlainObject(event['message']['usage']) ? event['message']['usage'] : undefined,
1701
+ model,
1702
+ );
1703
+ } else if (
1704
+ event['type'] === 'content_block_start' &&
1705
+ typeof event['index'] === 'number' &&
1706
+ isPlainObject(event['content_block'])
1707
+ ) {
1708
+ const contentBlock = event['content_block'];
1709
+ if (contentBlock['type'] === 'text') {
1710
+ output.content.push({ type: 'text', text: '', index: event['index'] });
1711
+ stream.push({
1712
+ type: 'text_start',
1713
+ contentIndex: output.content.length - 1,
1714
+ partial: output,
1715
+ });
1716
+ } else if (contentBlock['type'] === 'thinking') {
1717
+ output.content.push({
1718
+ type: 'thinking',
1719
+ thinking: '',
1720
+ thinkingSignature: '',
1721
+ index: event['index'],
1722
+ });
1723
+ stream.push({
1724
+ type: 'thinking_start',
1725
+ contentIndex: output.content.length - 1,
1726
+ partial: output,
1727
+ });
1728
+ } else if (contentBlock['type'] === 'redacted_thinking') {
1729
+ output.content.push({
1730
+ type: 'thinking',
1731
+ thinking: '[Reasoning redacted]',
1732
+ thinkingSignature: contentBlock['data'],
1733
+ redacted: true,
1734
+ index: event['index'],
1735
+ });
1736
+ stream.push({
1737
+ type: 'thinking_start',
1738
+ contentIndex: output.content.length - 1,
1739
+ partial: output,
1740
+ });
1741
+ } else if (contentBlock['type'] === 'tool_use') {
1742
+ output.content.push({
1743
+ type: 'toolCall',
1744
+ id: contentBlock['id'],
1745
+ name: contentBlock['name'],
1746
+ arguments: contentBlock['input'] ?? {},
1747
+ partialJson: '',
1748
+ index: event['index'],
1749
+ });
1750
+ stream.push({
1751
+ type: 'toolcall_start',
1752
+ contentIndex: output.content.length - 1,
1753
+ partial: output,
1754
+ });
1755
+ }
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;
1764
+ const delta = event['delta'];
1765
+ if (
1766
+ delta['type'] === 'text_delta' &&
1767
+ block['type'] === 'text' &&
1768
+ typeof delta['text'] === 'string'
1769
+ ) {
1770
+ block['text'] = `${String(block['text'] ?? '')}${delta['text']}`;
1771
+ stream.push({
1772
+ type: 'text_delta',
1773
+ contentIndex: blockIndex,
1774
+ delta: delta['text'],
1775
+ partial: output,
1776
+ });
1777
+ } else if (
1778
+ delta['type'] === 'thinking_delta' &&
1779
+ block['type'] === 'thinking' &&
1780
+ typeof delta['thinking'] === 'string'
1781
+ ) {
1782
+ block['thinking'] = `${String(block['thinking'] ?? '')}${delta['thinking']}`;
1783
+ stream.push({
1784
+ type: 'thinking_delta',
1785
+ contentIndex: blockIndex,
1786
+ delta: delta['thinking'],
1787
+ partial: output,
1788
+ });
1789
+ } else if (
1790
+ delta['type'] === 'input_json_delta' &&
1791
+ block['type'] === 'toolCall' &&
1792
+ typeof delta['partial_json'] === 'string'
1793
+ ) {
1794
+ block.partialJson = `${block.partialJson ?? ''}${delta['partial_json']}`;
1795
+ block['arguments'] = parseStreamingJsonFragment(block.partialJson);
1796
+ stream.push({
1797
+ type: 'toolcall_delta',
1798
+ contentIndex: blockIndex,
1799
+ delta: delta['partial_json'],
1800
+ partial: output,
1801
+ });
1802
+ } else if (
1803
+ delta['type'] === 'signature_delta' &&
1804
+ block['type'] === 'thinking' &&
1805
+ typeof delta['signature'] === 'string'
1806
+ ) {
1807
+ block['thinkingSignature'] =
1808
+ `${String(block['thinkingSignature'] ?? '')}${delta['signature']}`;
1809
+ }
1810
+ } else if (event['type'] === 'content_block_stop' && typeof event['index'] === 'number') {
1811
+ const blockIndex = blocks.findIndex((block) => block.index === event['index']);
1812
+ const block = blocks[blockIndex];
1813
+ if (!block) continue;
1814
+ delete block.index;
1815
+ if (block['type'] === 'text') {
1816
+ stream.push({
1817
+ type: 'text_end',
1818
+ contentIndex: blockIndex,
1819
+ content: String(block['text'] ?? ''),
1820
+ partial: output,
1821
+ });
1822
+ } else if (block['type'] === 'thinking') {
1823
+ stream.push({
1824
+ type: 'thinking_end',
1825
+ contentIndex: blockIndex,
1826
+ content: String(block['thinking'] ?? ''),
1827
+ partial: output,
1828
+ });
1829
+ } else if (block['type'] === 'toolCall') {
1830
+ block['arguments'] = parseStreamingJsonFragment(block.partialJson ?? '{}');
1831
+ delete block.partialJson;
1832
+ stream.push({
1833
+ type: 'toolcall_end',
1834
+ contentIndex: blockIndex,
1835
+ toolCall: block,
1836
+ partial: output,
1837
+ });
1838
+ }
1839
+ } else if (event['type'] === 'message_delta') {
1840
+ if (isPlainObject(event['delta']) && event['delta']['stop_reason'])
1841
+ output.stopReason = mapStopReason(event['delta']['stop_reason']);
1842
+ updateAnthropicUsage(
1843
+ output,
1844
+ isPlainObject(event['usage']) ? event['usage'] : undefined,
1845
+ model,
1846
+ );
1847
+ }
1848
+ }
1849
+ 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');
1852
+ stream.push({ type: 'done', reason: output.stopReason, message: output });
1853
+ stream.end();
1854
+ } catch (error) {
1855
+ for (const block of output.content) {
1856
+ delete block['index'];
1857
+ delete block['partialJson'];
1858
+ }
1859
+ output.stopReason = options?.signal?.aborted ? 'aborted' : 'error';
1860
+ output.errorMessage = error instanceof Error ? error.message : String(error);
1861
+ stream.push({ type: 'error', reason: output.stopReason, error: output });
1862
+ stream.end();
1863
+ }
1864
+ })();
1865
+
1866
+ return stream;
1867
+ }
1868
+
1869
+ function cacheRetentionLabel(retention: CacheRetention): string {
1870
+ switch (retention) {
1871
+ case 'long':
1872
+ return '1-hour';
1873
+ case 'short':
1874
+ return '5-minute';
1875
+ case 'none':
1876
+ return 'disabled';
1877
+ }
1878
+ }
1879
+
1880
+ interface AnthropicAttributionClaimProbe {
1881
+ readonly schema_version: typeof ANTHROPIC_ATTRIBUTION_CLAIM_SCHEMA;
1882
+ readonly acknowledge: () => void;
1883
+ }
1884
+
1885
+ function isAnthropicAttributionClaimProbe(value: unknown): value is AnthropicAttributionClaimProbe {
1886
+ return (
1887
+ isPlainObject(value) &&
1888
+ value['schema_version'] === ANTHROPIC_ATTRIBUTION_CLAIM_SCHEMA &&
1889
+ typeof value['acknowledge'] === 'function'
1890
+ );
1891
+ }
1892
+
1893
+ /**
1894
+ * Prevent two independently installed copies from registering duplicate provider
1895
+ * hooks and `/unipi:claude-cache` commands in one Pi runtime. Pi loads extension factories
1896
+ * sequentially and its EventBus dispatches listeners synchronously, so an existing
1897
+ * owner acknowledges this probe before emit() returns. The winning extension only
1898
+ * publishes ownership after every registration below succeeds; a factory that throws
1899
+ * cannot strand a false claim that suppresses a healthy later copy.
1900
+ */
1901
+ export default function spawnAnthropicAttribution(pi: PiExtensionHost): void {
1902
+ const acknowledgements: true[] = [];
1903
+ const probe: AnthropicAttributionClaimProbe = {
1904
+ schema_version: ANTHROPIC_ATTRIBUTION_CLAIM_SCHEMA,
1905
+ acknowledge: () => {
1906
+ acknowledgements.push(true);
1907
+ },
1908
+ };
1909
+ pi.events.emit(ANTHROPIC_ATTRIBUTION_CLAIM_CHANNEL, probe);
1910
+ if (acknowledgements.length > 0) return;
1911
+
1912
+ let sessionCacheRetention: Exclude<CacheRetention, 'none'> | undefined;
1913
+ const getSessionOverride = (): Exclude<CacheRetention, 'none'> | undefined =>
1914
+ sessionCacheRetention;
1915
+
1916
+ // 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.
1919
+ pi.registerProvider('anthropic', {
1920
+ api: 'anthropic-messages',
1921
+ streamSimple: (model, context, options) =>
1922
+ streamAnthropicViaBetaMessages(model, context, {
1923
+ ...(options ?? {}),
1924
+ cacheRetention: resolveCacheRetentionPreference(options, getSessionOverride()),
1925
+ }),
1926
+ });
1927
+
1928
+ pi.registerCommand('unipi:claude-cache', {
1929
+ description: 'Show or set Claude cache retention for this session (short, long, default)',
1930
+ handler: (args, ctx) => {
1931
+ const action = args.trim().toLowerCase();
1932
+ if (action.length === 0 || action === 'status') {
1933
+ const effective = resolveCacheRetentionPreference(undefined, sessionCacheRetention);
1934
+ ctx.ui?.notify(
1935
+ `Claude cache retention: ${cacheRetentionLabel(effective)}${sessionCacheRetention === undefined ? ' (default)' : ' (session override)'}`,
1936
+ 'info',
1937
+ );
1938
+ return;
1939
+ }
1940
+ if (action !== 'short' && action !== 'long' && action !== 'default') {
1941
+ throw new Error('Usage: /unipi:claude-cache [status|short|long|default]');
1942
+ }
1943
+ sessionCacheRetention = action === 'default' ? undefined : action;
1944
+ pi.appendEntry(ANTHROPIC_CACHE_RETENTION_ENTRY, {
1945
+ schema_version: ANTHROPIC_CACHE_RETENTION_SCHEMA,
1946
+ retention: action,
1947
+ });
1948
+ const effective = resolveCacheRetentionPreference(undefined, sessionCacheRetention);
1949
+ ctx.ui?.notify(
1950
+ `Claude cache retention set to ${cacheRetentionLabel(effective)} for this session${action === 'default' ? ' (default policy)' : ''}.`,
1951
+ 'info',
1952
+ );
1953
+ },
1954
+ });
1955
+
1956
+ pi.on('session_start', (_event, ctx) => {
1957
+ sessionCacheRetention = restoreAnthropicSessionCacheRetention(ctx.sessionManager.getBranch());
1958
+ });
1959
+
1960
+ pi.on('session_shutdown', () => {
1961
+ sessionCacheRetention = undefined;
1962
+ });
1963
+
1964
+ pi.on('session_tree', (_event, ctx) => {
1965
+ sessionCacheRetention = restoreAnthropicSessionCacheRetention(ctx.sessionManager.getBranch());
1966
+ });
1967
+
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
+ // Publish ownership last. Extension loading is sequential, so later independent
1979
+ // copies probe this responder and become inert instead of registering duplicates.
1980
+ pi.events.on(ANTHROPIC_ATTRIBUTION_CLAIM_CHANNEL, (value) => {
1981
+ if (isAnthropicAttributionClaimProbe(value)) value.acknowledge();
1982
+ });
1983
+ }