@moxxy/plugin-provider-anthropic 0.2.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.
@@ -0,0 +1,547 @@
1
+ import Anthropic from '@anthropic-ai/sdk';
2
+ import type {
3
+ LLMProvider,
4
+ ModelDescriptor,
5
+ ProviderEvent,
6
+ ProviderRequest,
7
+ StopReason,
8
+ TokenUsage,
9
+ } from '@moxxy/sdk';
10
+
11
+ type MessageStreamParams = Anthropic.Messages.MessageStreamParams;
12
+ type MessageCountTokensParams = Anthropic.Messages.MessageCountTokensParams;
13
+ import { estimateTextTokens, toFriendlyError } from '@moxxy/sdk';
14
+ import { toAnthropicMessages, toAnthropicTools } from './translate.js';
15
+
16
+ export interface AnthropicProviderConfig {
17
+ readonly apiKey?: string;
18
+ readonly baseURL?: string;
19
+ readonly defaultModel?: string;
20
+ readonly client?: Anthropic;
21
+ /**
22
+ * Override the reported provider name (used in error context). Defaults
23
+ * to `'anthropic'`. The `claude-code` subscription provider reuses this
24
+ * class with `name: 'claude-code'` so errors/metering attribute correctly.
25
+ */
26
+ readonly name?: string;
27
+ /**
28
+ * Override the advertised model catalog. Defaults to the Anthropic catalog
29
+ * ({@link anthropicModels}). An Anthropic-compatible vendor that reuses this
30
+ * class (e.g. `@moxxy/plugin-provider-zai`'s GLM Coding Plan path, which
31
+ * points `baseURL` at z.ai's Anthropic Messages endpoint) passes its own
32
+ * descriptors so context-window lookups (compaction/elision budgets) and
33
+ * capability gating run against the vendor's models, not Claude's.
34
+ */
35
+ readonly models?: ReadonlyArray<ModelDescriptor>;
36
+ /**
37
+ * OAuth (Claude subscription) mode. When set, the client authenticates
38
+ * with `Authorization: Bearer <oauthToken>` instead of an `x-api-key`,
39
+ * and the request/response is otherwise the standard Messages API. Used
40
+ * by `@moxxy/plugin-provider-claude-code`; the plain `anthropic` provider
41
+ * leaves all of these unset and behaves exactly as before.
42
+ */
43
+ readonly oauthToken?: string;
44
+ /** `anthropic-beta` values sent with every OAuth-mode request (joined by `,`). */
45
+ readonly oauthBeta?: ReadonlyArray<string>;
46
+ /**
47
+ * Text injected as the FIRST system block in OAuth mode. Claude rejects
48
+ * subscription tokens unless the system prompt leads with the Claude Code
49
+ * identity line, so the provider prepends this ahead of the real system
50
+ * prompt (which follows as the next block).
51
+ */
52
+ readonly systemPreamble?: string;
53
+ /** Epoch-ms expiry of `oauthToken`, if known — drives proactive refresh. */
54
+ readonly oauthExpiresAt?: number;
55
+ /**
56
+ * Refresh callback. Returns a fresh access token (and its expiry). Invoked
57
+ * proactively just before a request when the current token is near expiry,
58
+ * and once more reactively if a request still comes back 401. Omit for
59
+ * non-refreshable tokens (e.g. a pasted `claude setup-token`).
60
+ */
61
+ readonly oauthRefresh?: () => Promise<{ readonly token: string; readonly expiresAt?: number }>;
62
+ }
63
+
64
+ // Hardcoded model catalog (re-exported to @moxxy/plugin-provider-claude-code, which
65
+ // reuses this provider class for the subscription path). Deriving it from the Models
66
+ // API is a larger change (auth + caching) — deliberately deferred (TECH_DEBT P3 #8).
67
+ // Values verified against the current Anthropic model catalog (2026-06): fable-5,
68
+ // opus-4-8, opus-4-7 and opus-4-6 carry a 1M context window with a 128k streaming
69
+ // ceiling; sonnet-4-6 is 1M/64k; haiku-4-5 is 200k/64k. fable-5 is Anthropic's most
70
+ // capable model (always-on reasoning); the loop never sets `temperature`, which
71
+ // fable-5/opus-4-8/4.7 reject — so they stream cleanly here, same as opus-4-7 already did.
72
+ // `supportsReasoning` marks models that accept adaptive thinking (`thinking:
73
+ // {type:'adaptive', display:'summarized'}`) — fable-5/opus-4-8/4-7/4-6 and sonnet-4-6
74
+ // do; haiku-4-5 does not (effort/adaptive-thinking error there), so it stays off.
75
+ export const anthropicModels: ReadonlyArray<ModelDescriptor> = [
76
+ { id: 'claude-fable-5', contextWindow: 1_000_000, maxOutputTokens: 128_000, supportsTools: true, supportsStreaming: true, supportsImages: true, supportsDocuments: true, supportsReasoning: true },
77
+ { id: 'claude-opus-4-8', contextWindow: 1_000_000, maxOutputTokens: 128_000, supportsTools: true, supportsStreaming: true, supportsImages: true, supportsDocuments: true, supportsReasoning: true },
78
+ { id: 'claude-opus-4-7', contextWindow: 1_000_000, maxOutputTokens: 128_000, supportsTools: true, supportsStreaming: true, supportsImages: true, supportsDocuments: true, supportsReasoning: true },
79
+ { id: 'claude-opus-4-6', contextWindow: 1_000_000, maxOutputTokens: 128_000, supportsTools: true, supportsStreaming: true, supportsImages: true, supportsDocuments: true, supportsReasoning: true },
80
+ { id: 'claude-sonnet-4-6', contextWindow: 1_000_000, maxOutputTokens: 64_000, supportsTools: true, supportsStreaming: true, supportsImages: true, supportsDocuments: true, supportsReasoning: true },
81
+ { id: 'claude-haiku-4-5-20251001', contextWindow: 200_000, maxOutputTokens: 64_000, supportsTools: true, supportsStreaming: true, supportsImages: true, supportsDocuments: true },
82
+ ];
83
+
84
+ export class AnthropicProvider implements LLMProvider {
85
+ readonly name: string;
86
+ readonly models: ReadonlyArray<ModelDescriptor>;
87
+ // Mutable so OAuth-mode refresh can swap in a client carrying the new
88
+ // bearer token; the plain apiKey client never changes after construction.
89
+ private client: Anthropic;
90
+ private readonly defaultModel: string;
91
+ private readonly baseURL?: string;
92
+ // Present only in OAuth (Claude subscription) mode.
93
+ private readonly oauth?: {
94
+ readonly beta: ReadonlyArray<string>;
95
+ readonly systemPreamble?: string;
96
+ readonly refresh?: () => Promise<{ readonly token: string; readonly expiresAt?: number }>;
97
+ };
98
+ private oauthToken?: string;
99
+ private oauthExpiresAt?: number;
100
+
101
+ constructor(config: AnthropicProviderConfig = {}) {
102
+ this.name = config.name ?? 'anthropic';
103
+ this.models = config.models ?? anthropicModels;
104
+ this.defaultModel = config.defaultModel ?? 'claude-sonnet-4-6';
105
+ if (config.baseURL) this.baseURL = config.baseURL;
106
+
107
+ if (config.oauthToken) {
108
+ this.oauthToken = config.oauthToken;
109
+ this.oauthExpiresAt = config.oauthExpiresAt;
110
+ this.oauth = {
111
+ beta: config.oauthBeta ?? [],
112
+ ...(config.systemPreamble ? { systemPreamble: config.systemPreamble } : {}),
113
+ ...(config.oauthRefresh ? { refresh: config.oauthRefresh } : {}),
114
+ };
115
+ this.client = config.client ?? this.makeOauthClient(config.oauthToken);
116
+ } else {
117
+ this.client =
118
+ config.client ??
119
+ new Anthropic({
120
+ apiKey: config.apiKey ?? process.env.ANTHROPIC_API_KEY,
121
+ ...(config.baseURL ? { baseURL: config.baseURL } : {}),
122
+ });
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Build an Anthropic client in OAuth (bearer) mode. `apiKey: null` stops
128
+ * the SDK from falling back to `ANTHROPIC_API_KEY` and suppresses the
129
+ * `x-api-key` header (its `apiKeyAuth()` returns `{}` when apiKey is null),
130
+ * so only `Authorization: Bearer <token>` goes out, plus the beta header.
131
+ */
132
+ private makeOauthClient(token: string): Anthropic {
133
+ const beta = this.oauth?.beta ?? [];
134
+ return new Anthropic({
135
+ apiKey: null,
136
+ authToken: token,
137
+ ...(beta.length > 0 ? { defaultHeaders: { 'anthropic-beta': beta.join(',') } } : {}),
138
+ ...(this.baseURL ? { baseURL: this.baseURL } : {}),
139
+ });
140
+ }
141
+
142
+ /** Proactively refresh the bearer token when it's within the skew window. */
143
+ private async ensureFreshOauth(): Promise<void> {
144
+ if (!this.oauth?.refresh) return;
145
+ if (this.oauthExpiresAt === undefined) return;
146
+ if (Date.now() + 60_000 < this.oauthExpiresAt) return;
147
+ await this.refreshOauthNow();
148
+ }
149
+
150
+ /** Force a token refresh and rebuild the client with the new bearer. */
151
+ private async refreshOauthNow(): Promise<void> {
152
+ if (!this.oauth?.refresh) throw new Error('no refresh callback');
153
+ const next = await this.oauth.refresh();
154
+ this.oauthToken = next.token;
155
+ this.oauthExpiresAt = next.expiresAt;
156
+ this.client = this.makeOauthClient(next.token);
157
+ }
158
+
159
+ /**
160
+ * Build the `system` request field. In OAuth mode the Claude Code identity
161
+ * preamble MUST lead, so we always emit block form with the preamble first;
162
+ * the real system prompt follows (carrying the cache breakpoint if set).
163
+ * In apiKey mode the behaviour is unchanged: a bare string, upgraded to a
164
+ * single cache-marked block only when a system cache hint is present.
165
+ *
166
+ * `extraSystem` is `ProviderRequest.system` — the hook-injection side
167
+ * channel (e.g. the memory consolidation nudge), delivered IN ADDITION to
168
+ * the message-derived system prompt. It rides as a separate block AFTER
169
+ * the cache-marked one so volatile per-request text never busts the
170
+ * stable system-prefix cache.
171
+ */
172
+ private buildSystemParam(
173
+ system: string | undefined,
174
+ cacheSystem: boolean,
175
+ extraSystem?: string,
176
+ ): string | undefined | Array<{ type: 'text'; text: string; cache_control?: { type: 'ephemeral' } }> {
177
+ const preamble = this.oauth?.systemPreamble;
178
+ if (preamble || extraSystem) {
179
+ const blocks: Array<{ type: 'text'; text: string; cache_control?: { type: 'ephemeral' } }> = [];
180
+ if (preamble) blocks.push({ type: 'text', text: preamble });
181
+ if (system) {
182
+ blocks.push(
183
+ cacheSystem
184
+ ? { type: 'text', text: system, cache_control: { type: 'ephemeral' } }
185
+ : { type: 'text', text: system },
186
+ );
187
+ }
188
+ if (extraSystem) blocks.push({ type: 'text', text: extraSystem });
189
+ return blocks.length > 0 ? blocks : undefined;
190
+ }
191
+ return cacheSystem && system
192
+ ? [{ type: 'text', text: system, cache_control: { type: 'ephemeral' } }]
193
+ : system;
194
+ }
195
+
196
+ async *stream(req: ProviderRequest): AsyncIterable<ProviderEvent> {
197
+ // Translate provider-neutral cache hints into Anthropic cache_control
198
+ // markers. `tools`/`system` mark those session-stable regions; a
199
+ // `{ messageIndex }` hint marks the end of that message (the rolling
200
+ // prefix breakpoint). Anthropic honors at most 4 breakpoints.
201
+ const hints = req.cacheHints ?? [];
202
+ const cacheTools = hints.some((h) => h.target === 'tools');
203
+ const cacheSystem = hints.some((h) => h.target === 'system');
204
+ const cacheMessageIndices = new Set<number>();
205
+ for (const h of hints) {
206
+ if (typeof h.target === 'object') cacheMessageIndices.add(h.target.messageIndex);
207
+ }
208
+
209
+ const { system, messages } = toAnthropicMessages(req.messages, { cacheMessageIndices });
210
+ const tools =
211
+ req.tools && req.tools.length > 0
212
+ ? toAnthropicTools(req.tools, { cacheLast: cacheTools })
213
+ : undefined;
214
+ // OAuth mode prepends the Claude Code identity preamble as the first
215
+ // system block; apiKey mode keeps the prior string/cache-block behaviour.
216
+ // req.system (hook-injected extra system text) is appended last.
217
+ const systemParam = this.buildSystemParam(system, cacheSystem, req.system);
218
+ const model = req.model || this.defaultModel;
219
+
220
+ yield { type: 'message_start', model };
221
+
222
+ // In OAuth mode refresh the bearer proactively when it's near expiry, so
223
+ // we don't fire a request on a token we already knew was about to die.
224
+ if (this.oauth) {
225
+ try {
226
+ await this.ensureFreshOauth();
227
+ } catch (err) {
228
+ yield { type: 'error', ...toFriendlyError(err, { provider: this.name }) };
229
+ return;
230
+ }
231
+ }
232
+
233
+ // NARROW cast: `messages`/`tools`/`systemParam` are our hand-rolled
234
+ // Anthropic shapes (e.g. `media_type: string`) which the SDK narrows to
235
+ // literal unions it can't see we never violate. The body is otherwise
236
+ // typed as the SDK's real `MessageStreamParams` — `model`/`max_tokens`/
237
+ // `temperature` are checked at compile time.
238
+ const requestBody: MessageStreamParams = {
239
+ model,
240
+ max_tokens: req.maxTokens ?? 4096,
241
+ system: systemParam as MessageStreamParams['system'],
242
+ messages: messages as MessageStreamParams['messages'],
243
+ tools: tools as MessageStreamParams['tools'],
244
+ ...(req.temperature !== undefined ? { temperature: req.temperature } : {}),
245
+ };
246
+
247
+ // Reasoning: on supported models (gated upstream by `supportsReasoning`),
248
+ // enable adaptive thinking with summarized display so the model streams a
249
+ // readable reasoning summary (`display: 'omitted'` — the default — would
250
+ // stream empty thinking blocks). Adaptive thinking auto-enables interleaved
251
+ // thinking on the 4.6+ catalog, so no beta header is needed. `effort` maps
252
+ // to `output_config.effort`. Fields are attached via a cast because the
253
+ // pinned SDK's `MessageStreamParams` predates these params (same hand-rolled
254
+ // approach used for system/messages/tools above).
255
+ const reasoningOn = req.reasoning !== undefined && req.reasoning !== false;
256
+ if (reasoningOn) {
257
+ const effort = typeof req.reasoning === 'object' ? req.reasoning.effort : undefined;
258
+ const body = requestBody as unknown as Record<string, unknown>;
259
+ body.thinking = { type: 'adaptive', display: 'summarized' };
260
+ if (effort) body.output_config = { effort };
261
+ }
262
+
263
+ // A 401 always arrives before any SSE body, so in OAuth mode we can force
264
+ // a single refresh and replay the request with no risk of duplicate output.
265
+ try {
266
+ yield* this.streamOnce(requestBody, req.signal);
267
+ } catch (err) {
268
+ if (this.oauth?.refresh && isUnauthorized(err)) {
269
+ try {
270
+ await this.refreshOauthNow();
271
+ yield* this.streamOnce(requestBody, req.signal);
272
+ return;
273
+ } catch (retryErr) {
274
+ yield { type: 'error', ...toFriendlyError(retryErr, { provider: this.name }) };
275
+ return;
276
+ }
277
+ }
278
+ yield { type: 'error', ...toFriendlyError(err, { provider: this.name }) };
279
+ }
280
+ }
281
+
282
+ /**
283
+ * One streaming attempt. THROWS on transport/HTTP errors so `stream()` can
284
+ * decide whether to refresh-and-replay (OAuth 401) or surface the error;
285
+ * yields content events plus a terminal `message_end` on the happy path.
286
+ * Abort is terminal here (yields the abort error and returns).
287
+ */
288
+ private async *streamOnce(
289
+ requestBody: MessageStreamParams,
290
+ signal: AbortSignal | undefined,
291
+ ): AsyncIterable<ProviderEvent> {
292
+ const stream = this.client.messages.stream(
293
+ requestBody,
294
+ // Pass the AbortSignal into the SDK request options so cancelling
295
+ // tears down the underlying HTTP request. Without this, Esc only
296
+ // stopped our loop while the model kept generating upstream.
297
+ signal ? { signal } : undefined,
298
+ );
299
+
300
+ const pendingToolUses = new Map<string, { name: string; partial: string }>();
301
+ // Anthropic's stream events carry a block `index` on every delta/stop;
302
+ // we map that index to the tool_use id at content_block_start time so
303
+ // parallel tool_use blocks route their deltas correctly. Without this,
304
+ // we used to return the first key in `pendingToolUses` for every event,
305
+ // causing two parallel blocks to overwrite each other's partial JSON.
306
+ const blockIndexToId = new Map<number, string>();
307
+ // Track which block indices are `thinking` blocks so their signature_delta
308
+ // accumulates and flushes as a reasoning_signature at content_block_stop.
309
+ const thinkingBlockIndices = new Set<number>();
310
+ let pendingThinkingSig = '';
311
+ let stopReason: StopReason = 'end_turn';
312
+ // Type as the SDK's `TokenUsage` (which carries the optional cache fields)
313
+ // so cacheReadTokens/cacheCreationTokens are first-class on the accumulator
314
+ // and the message_delta merge below is type-checked to preserve them —
315
+ // rather than the cache fields sneaking in only via a spread (which bypasses
316
+ // the excess-property check) against a narrower declared type.
317
+ let usage: TokenUsage | undefined;
318
+
319
+ try {
320
+ for await (const event of stream as AsyncIterable<AnthropicStreamEvent>) {
321
+ if (signal?.aborted) {
322
+ yield { type: 'error', message: 'aborted', retryable: false };
323
+ return;
324
+ }
325
+ switch (event.type) {
326
+ case 'message_start': {
327
+ // Anthropic reports cache hits/writes only on the message_start
328
+ // usage block — `cache_read_input_tokens` (billed 0.1x) and
329
+ // `cache_creation_input_tokens` (billed 1.25x). Capture them here
330
+ // so the metering layer can prove cache savings; without this the
331
+ // fields are silently dropped and cache wins are invisible.
332
+ const u = event.message?.usage;
333
+ usage = {
334
+ inputTokens: u?.input_tokens ?? 0,
335
+ outputTokens: u?.output_tokens ?? 0,
336
+ ...(u?.cache_read_input_tokens !== undefined
337
+ ? { cacheReadTokens: u.cache_read_input_tokens }
338
+ : {}),
339
+ ...(u?.cache_creation_input_tokens !== undefined
340
+ ? { cacheCreationTokens: u.cache_creation_input_tokens }
341
+ : {}),
342
+ };
343
+ break;
344
+ }
345
+ case 'content_block_start': {
346
+ const block = event.content_block;
347
+ if (block && block.type === 'tool_use') {
348
+ pendingToolUses.set(block.id, { name: block.name, partial: '' });
349
+ // Real Anthropic events carry `index` here; fall back to the
350
+ // arrival ordinal when callers (e.g. test fakes) omit it.
351
+ const idx = typeof event.index === 'number' ? event.index : blockIndexToId.size;
352
+ blockIndexToId.set(idx, block.id);
353
+ yield { type: 'tool_use_start', id: block.id, name: block.name };
354
+ } else if (block && block.type === 'thinking') {
355
+ if (typeof event.index === 'number') thinkingBlockIndices.add(event.index);
356
+ pendingThinkingSig = '';
357
+ } else if (block && block.type === 'redacted_thinking') {
358
+ // No readable text — replay the opaque blob verbatim on round-trip.
359
+ yield { type: 'reasoning_signature', redacted: true, ...(block.data ? { encrypted: block.data } : {}) };
360
+ }
361
+ break;
362
+ }
363
+ case 'content_block_delta': {
364
+ const delta = event.delta;
365
+ if (!delta) break;
366
+ if (delta.type === 'text_delta' && typeof delta.text === 'string') {
367
+ yield { type: 'text_delta', delta: delta.text };
368
+ } else if (delta.type === 'thinking_delta' && typeof delta.thinking === 'string') {
369
+ yield { type: 'reasoning_delta', delta: delta.thinking };
370
+ } else if (delta.type === 'signature_delta' && typeof delta.signature === 'string') {
371
+ pendingThinkingSig += delta.signature;
372
+ } else if (delta.type === 'input_json_delta' && typeof delta.partial_json === 'string') {
373
+ const id = idOfBlock(event, blockIndexToId);
374
+ if (id) {
375
+ const t = pendingToolUses.get(id);
376
+ if (t) {
377
+ t.partial += delta.partial_json;
378
+ yield { type: 'tool_use_delta', id, partialInput: delta.partial_json };
379
+ }
380
+ }
381
+ }
382
+ break;
383
+ }
384
+ case 'content_block_stop': {
385
+ if (typeof event.index === 'number' && thinkingBlockIndices.has(event.index)) {
386
+ thinkingBlockIndices.delete(event.index);
387
+ if (pendingThinkingSig) yield { type: 'reasoning_signature', signature: pendingThinkingSig };
388
+ pendingThinkingSig = '';
389
+ break;
390
+ }
391
+ const id = idOfBlock(event, blockIndexToId);
392
+ if (id) {
393
+ const t = pendingToolUses.get(id);
394
+ if (t) {
395
+ let parsed: unknown = {};
396
+ try {
397
+ parsed = t.partial ? JSON.parse(t.partial) : {};
398
+ } catch {
399
+ parsed = { _rawPartial: t.partial };
400
+ }
401
+ yield { type: 'tool_use_end', id, input: parsed };
402
+ pendingToolUses.delete(id);
403
+ if (typeof event.index === 'number') blockIndexToId.delete(event.index);
404
+ }
405
+ }
406
+ break;
407
+ }
408
+ case 'message_delta': {
409
+ if (event.delta?.stop_reason) {
410
+ stopReason = mapStopReason(event.delta.stop_reason);
411
+ }
412
+ if (event.usage) {
413
+ // Preserve cache fields captured at message_start — the delta
414
+ // usage only carries the final output_tokens count.
415
+ usage = {
416
+ ...usage,
417
+ inputTokens: usage?.inputTokens ?? 0,
418
+ outputTokens: event.usage.output_tokens ?? usage?.outputTokens ?? 0,
419
+ };
420
+ }
421
+ break;
422
+ }
423
+ case 'message_stop':
424
+ break;
425
+ }
426
+ }
427
+ } catch (err) {
428
+ // A cancel surfaces as a thrown AbortError mid-await — report it as the
429
+ // clean terminal 'aborted' event. Every other error propagates so
430
+ // `stream()` can classify it (OAuth 401 → refresh+replay; else error).
431
+ if (signal?.aborted) {
432
+ yield { type: 'error', message: 'aborted', retryable: false };
433
+ return;
434
+ }
435
+ throw err;
436
+ }
437
+
438
+ yield { type: 'message_end', stopReason, usage };
439
+ }
440
+
441
+ async countTokens(req: Pick<ProviderRequest, 'model' | 'messages' | 'system' | 'tools'>): Promise<number> {
442
+ const { system, messages } = toAnthropicMessages(req.messages);
443
+ const tools = req.tools && req.tools.length > 0 ? toAnthropicTools(req.tools) : undefined;
444
+ // Mirror stream(): in OAuth mode the request carries the identity preamble
445
+ // as an extra system block, and req.system (hook-injected extra system
446
+ // text) is appended as another — count both for a faithful estimate.
447
+ const parts = [this.oauth?.systemPreamble, system, req.system].filter(
448
+ (s): s is string => typeof s === 'string' && s.length > 0,
449
+ );
450
+ const systemForCount = parts.length > 0 ? parts.join('\n\n') : undefined;
451
+ try {
452
+ // Mirror stream(): in OAuth mode refresh a near-expiry bearer before the
453
+ // request so a token we already knew was about to die doesn't 401 us
454
+ // straight into the (less accurate) text-estimate fallback below.
455
+ if (this.oauth) await this.ensureFreshOauth();
456
+ const result = await this.client.messages.countTokens({
457
+ model: req.model || this.defaultModel,
458
+ ...(systemForCount !== undefined ? { system: systemForCount } : {}),
459
+ // NARROW cast: our hand-rolled message/tool shapes carry `media_type:
460
+ // string`, which the SDK narrows to a literal union it can't see we
461
+ // never violate. The method itself is fully typed (no `as unknown` on
462
+ // the resource anymore); only these two args need the structural cast.
463
+ messages: messages as MessageCountTokensParams['messages'],
464
+ ...(tools !== undefined ? { tools: tools as MessageCountTokensParams['tools'] } : {}),
465
+ });
466
+ return result.input_tokens;
467
+ } catch {
468
+ const blob =
469
+ (systemForCount ?? '') +
470
+ messages.map((m) => JSON.stringify(m.content)).join('') +
471
+ JSON.stringify(tools ?? []);
472
+ return estimateTextTokens(blob);
473
+ }
474
+ }
475
+ }
476
+
477
+ interface AnthropicStreamEvent {
478
+ type:
479
+ | 'message_start'
480
+ | 'content_block_start'
481
+ | 'content_block_delta'
482
+ | 'content_block_stop'
483
+ | 'message_delta'
484
+ | 'message_stop';
485
+ message?: {
486
+ usage?: {
487
+ input_tokens?: number;
488
+ output_tokens?: number;
489
+ cache_read_input_tokens?: number;
490
+ cache_creation_input_tokens?: number;
491
+ };
492
+ };
493
+ content_block?: {
494
+ type: 'text' | 'tool_use' | 'thinking' | 'redacted_thinking';
495
+ id: string;
496
+ name: string;
497
+ /** redacted_thinking carries an opaque encrypted blob (no readable text). */
498
+ data?: string;
499
+ };
500
+ index?: number;
501
+ delta?: {
502
+ type?: 'text_delta' | 'input_json_delta' | 'thinking_delta' | 'signature_delta';
503
+ text?: string;
504
+ partial_json?: string;
505
+ /** thinking_delta payload (the summarized reasoning text). */
506
+ thinking?: string;
507
+ /** signature_delta payload (the thinking-block signature for round-trip). */
508
+ signature?: string;
509
+ stop_reason?: string;
510
+ };
511
+ usage?: { output_tokens?: number };
512
+ }
513
+
514
+ function idOfBlock(
515
+ event: AnthropicStreamEvent,
516
+ blockIndexToId: Map<number, string>,
517
+ ): string | null {
518
+ if (typeof event.index === 'number') {
519
+ return blockIndexToId.get(event.index) ?? null;
520
+ }
521
+ // Fallback when `index` is missing (older SDKs / hand-rolled fakes): only
522
+ // unambiguous when exactly one tool_use is pending; otherwise refuse to
523
+ // guess and let the delta drop rather than misroute it.
524
+ if (blockIndexToId.size === 1) {
525
+ for (const id of blockIndexToId.values()) return id;
526
+ }
527
+ return null;
528
+ }
529
+
530
+ function mapStopReason(s: string): StopReason {
531
+ if (s === 'tool_use') return 'tool_use';
532
+ if (s === 'max_tokens') return 'max_tokens';
533
+ if (s === 'stop_sequence') return 'stop_sequence';
534
+ if (s === 'end_turn') return 'end_turn';
535
+ return 'error';
536
+ }
537
+
538
+ /**
539
+ * True when the SDK error is an HTTP 401. The Anthropic SDK throws
540
+ * `APIError` instances carrying a numeric `status`; an expired/revoked OAuth
541
+ * bearer is the only 401 we want to refresh-and-retry on.
542
+ */
543
+ function isUnauthorized(err: unknown): boolean {
544
+ return typeof (err as { status?: unknown } | null | undefined)?.status === 'number'
545
+ ? (err as { status: number }).status === 401
546
+ : false;
547
+ }