@moxxy/plugin-provider-anthropic 0.2.0 → 0.2.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.
package/dist/validate.js CHANGED
@@ -18,9 +18,31 @@ export async function validateKey(key, deps = {}) {
18
18
  return { ok: true };
19
19
  }
20
20
  catch (err) {
21
- return { ok: false, message: err instanceof Error ? err.message : String(err) };
21
+ return { ok: false, message: friendlyValidationError(err, key) };
22
22
  }
23
23
  }
24
+ /**
25
+ * Map a key-validation failure to a fixed friendly string by HTTP status, never
26
+ * echoing raw SDK error text (which can embed request/proxy URLs or header
27
+ * fragments) verbatim. The fallback is truncated and any occurrence of the key
28
+ * is scrubbed so a reflected error can't leak it into setup UIs / logs.
29
+ */
30
+ function friendlyValidationError(err, key) {
31
+ const status = err?.status;
32
+ if (typeof status === 'number') {
33
+ if (status === 401)
34
+ return 'key was rejected';
35
+ if (status === 403)
36
+ return 'key lacks access';
37
+ if (status === 429)
38
+ return 'rate limited — try again shortly';
39
+ if (status >= 500)
40
+ return 'Anthropic returned a server error';
41
+ }
42
+ const raw = err instanceof Error ? err.message : String(err);
43
+ const scrubbed = key ? raw.split(key).join('[redacted]') : raw;
44
+ return scrubbed.length > 200 ? `${scrubbed.slice(0, 200)}…` : scrubbed;
45
+ }
24
46
  function defaultMaker(apiKey) {
25
47
  return new Anthropic({ apiKey });
26
48
  }
@@ -1 +1 @@
1
- {"version":3,"file":"validate.js","sourceRoot":"","sources":["../src/validate.ts"],"names":[],"mappings":"AAAA,OAAO,SAAS,MAAM,mBAAmB,CAAC;AAY1C;;;GAGG;AACH,MAAM,CAAC,KAAK,UAAU,WAAW,CAAC,GAAW,EAAE,OAAwB,EAAE;IACvE,IAAI,CAAC,GAAG,IAAI,GAAG,CAAC,IAAI,EAAE,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QAClC,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,OAAO,EAAE,qBAAqB,EAAE,CAAC;IACvD,CAAC;IACD,MAAM,IAAI,GAAG,IAAI,CAAC,MAAM,IAAI,YAAY,CAAC;IACzC,IAAI,CAAC;QACH,MAAM,MAAM,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC;QACzB,MAAM,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAC;YAC3B,KAAK,EAAE,IAAI,CAAC,KAAK,IAAI,2BAA2B;YAChD,UAAU,EAAE,CAAC;YACb,QAAQ,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,CAAC;SAC9C,CAAC,CAAC;QACH,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,CAAC;IACtB,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,OAAO,EAAE,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,EAAE,CAAC;IAClF,CAAC;AACH,CAAC;AAED,SAAS,YAAY,CAAC,MAAc;IAClC,OAAO,IAAI,SAAS,CAAC,EAAE,MAAM,EAAE,CAE9B,CAAC;AACJ,CAAC"}
1
+ {"version":3,"file":"validate.js","sourceRoot":"","sources":["../src/validate.ts"],"names":[],"mappings":"AAAA,OAAO,SAAS,MAAM,mBAAmB,CAAC;AAY1C;;;GAGG;AACH,MAAM,CAAC,KAAK,UAAU,WAAW,CAAC,GAAW,EAAE,OAAwB,EAAE;IACvE,IAAI,CAAC,GAAG,IAAI,GAAG,CAAC,IAAI,EAAE,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QAClC,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,OAAO,EAAE,qBAAqB,EAAE,CAAC;IACvD,CAAC;IACD,MAAM,IAAI,GAAG,IAAI,CAAC,MAAM,IAAI,YAAY,CAAC;IACzC,IAAI,CAAC;QACH,MAAM,MAAM,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC;QACzB,MAAM,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAC;YAC3B,KAAK,EAAE,IAAI,CAAC,KAAK,IAAI,2BAA2B;YAChD,UAAU,EAAE,CAAC;YACb,QAAQ,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,CAAC;SAC9C,CAAC,CAAC;QACH,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,CAAC;IACtB,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,OAAO,EAAE,uBAAuB,CAAC,GAAG,EAAE,GAAG,CAAC,EAAE,CAAC;IACnE,CAAC;AACH,CAAC;AAED;;;;;GAKG;AACH,SAAS,uBAAuB,CAAC,GAAY,EAAE,GAAW;IACxD,MAAM,MAAM,GAAI,GAA+C,EAAE,MAAM,CAAC;IACxE,IAAI,OAAO,MAAM,KAAK,QAAQ,EAAE,CAAC;QAC/B,IAAI,MAAM,KAAK,GAAG;YAAE,OAAO,kBAAkB,CAAC;QAC9C,IAAI,MAAM,KAAK,GAAG;YAAE,OAAO,kBAAkB,CAAC;QAC9C,IAAI,MAAM,KAAK,GAAG;YAAE,OAAO,kCAAkC,CAAC;QAC9D,IAAI,MAAM,IAAI,GAAG;YAAE,OAAO,mCAAmC,CAAC;IAChE,CAAC;IACD,MAAM,GAAG,GAAG,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;IAC7D,MAAM,QAAQ,GAAG,GAAG,CAAC,CAAC,CAAC,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,YAAY,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC;IAC/D,OAAO,QAAQ,CAAC,MAAM,GAAG,GAAG,CAAC,CAAC,CAAC,GAAG,QAAQ,CAAC,KAAK,CAAC,CAAC,EAAE,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC,QAAQ,CAAC;AACzE,CAAC;AAED,SAAS,YAAY,CAAC,MAAc;IAClC,OAAO,IAAI,SAAS,CAAC,EAAE,MAAM,EAAE,CAE9B,CAAC;AACJ,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moxxy/plugin-provider-anthropic",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Anthropic (Claude) LLMProvider plugin for moxxy. Streams the Messages API with tool use + adaptive thinking; translates moxxy ProviderRequest/Event ↔ the Anthropic wire shape. Register it into an @moxxy/core Session to route turns to Claude models.",
5
5
  "keywords": [
6
6
  "moxxy",
@@ -45,17 +45,17 @@
45
45
  },
46
46
  "dependencies": {
47
47
  "@anthropic-ai/sdk": "^0.40.0",
48
- "@moxxy/sdk": "0.15.0"
48
+ "@moxxy/sdk": "0.15.1"
49
49
  },
50
50
  "devDependencies": {
51
51
  "@types/node": "^22.10.0",
52
52
  "typescript": "^5.7.3",
53
53
  "vitest": "^2.1.8",
54
54
  "zod": "^3.24.0",
55
- "@moxxy/mode-default": "0.1.0",
55
+ "@moxxy/core": "0.5.1",
56
+ "@moxxy/mode-default": "0.1.1",
56
57
  "@moxxy/tsconfig": "0.0.0",
57
- "@moxxy/vitest-preset": "0.0.0",
58
- "@moxxy/core": "0.3.0"
58
+ "@moxxy/vitest-preset": "0.0.0"
59
59
  },
60
60
  "scripts": {
61
61
  "build": "tsc -p tsconfig.json",
@@ -0,0 +1,376 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type Anthropic from '@anthropic-ai/sdk';
3
+ import type { ProviderEvent, ProviderRequest } from '@moxxy/sdk';
4
+ import { AnthropicProvider } from './provider.js';
5
+
6
+ /**
7
+ * Worst-case / failure-path coverage for the hardening fixes in provider.ts
8
+ * that landed without a dedicated regression test:
9
+ *
10
+ * - SDK stream teardown on early exit (abort / consumer abandonment) — the
11
+ * `finally` block must force-abort the SDK stream so a half-open HTTP
12
+ * connection can't linger under Esc-spam.
13
+ * - OAuth refresh concurrency coalescing — two concurrent near-expiry callers
14
+ * must share a single in-flight refresh (one endpoint hit, one client swap).
15
+ * - countTokens offline fallback — must NOT serialize megabytes of base64 into
16
+ * one mega-string; a multi-MB media blob yields a bounded, blob-independent
17
+ * estimate.
18
+ * - Index-less degraded stream — a stale, already-finished blockIndexToId entry
19
+ * must not misroute a new tool block's input deltas.
20
+ * - message_delta usage merge — delta-reported input/cache numbers win when
21
+ * present, message_start values survive when absent.
22
+ */
23
+
24
+ async function drain(it: AsyncIterable<ProviderEvent>): Promise<ProviderEvent[]> {
25
+ const out: ProviderEvent[] = [];
26
+ for await (const e of it) out.push(e);
27
+ return out;
28
+ }
29
+
30
+ /**
31
+ * A fake SDK stream object that is BOTH async-iterable AND exposes the
32
+ * `abort()` / `controller.abort()` surface the provider's finally-block reaches
33
+ * for. The inner generator hangs on an open "HTTP body" promise after the first
34
+ * events; that promise REJECTS the moment `abort()`/`controller.abort()` fires
35
+ * (or the request `signal` aborts), faithfully mirroring how the real SDK
36
+ * unblocks a pending `.next()` on teardown — so the test can't hang.
37
+ */
38
+ function abortableStream(signal?: AbortSignal): {
39
+ stream: AsyncIterable<unknown> & { abort: () => void; controller: { abort: () => void } };
40
+ aborts: { abort: number; controller: number };
41
+ } {
42
+ const aborts = { abort: 0, controller: 0 };
43
+ let rejectBody: ((e: Error) => void) | undefined;
44
+ const tearDown = (): void => rejectBody?.(Object.assign(new Error('aborted'), { name: 'AbortError' }));
45
+ signal?.addEventListener('abort', tearDown);
46
+ const controller = {
47
+ abort: () => {
48
+ aborts.controller += 1;
49
+ tearDown();
50
+ },
51
+ };
52
+ async function* gen(): AsyncGenerator<unknown> {
53
+ // First events so the provider enters the loop and records output, then
54
+ // hang on an open body until torn down.
55
+ yield { type: 'message_start', message: { usage: { input_tokens: 1, output_tokens: 0 } } };
56
+ yield { type: 'content_block_delta', delta: { type: 'text_delta', text: 'partial' } };
57
+ await new Promise<void>((_res, rej) => {
58
+ rejectBody = rej;
59
+ });
60
+ }
61
+ const g = gen();
62
+ const stream = Object.assign(g, {
63
+ abort: () => {
64
+ aborts.abort += 1;
65
+ tearDown();
66
+ },
67
+ controller,
68
+ });
69
+ return { stream, aborts };
70
+ }
71
+
72
+ describe('AnthropicProvider stream teardown', () => {
73
+ it('force-aborts the SDK stream when the signal aborts mid-stream', async () => {
74
+ const controller = new AbortController();
75
+ const { stream, aborts } = abortableStream(controller.signal);
76
+ const client = {
77
+ messages: { stream: () => stream, countTokens: async () => ({ input_tokens: 0 }) },
78
+ };
79
+ const p = new AnthropicProvider({ client: client as never });
80
+
81
+ const out: ProviderEvent[] = [];
82
+ const it = p.stream({ model: 'm', messages: [], signal: controller.signal })[
83
+ Symbol.asyncIterator
84
+ ]();
85
+ // Pull until the first content event arrives, then abort. The fake's open
86
+ // body promise rejects on signal-abort (as the real SDK does), unblocking
87
+ // the provider's pending pull so its catch/finally run deterministically.
88
+ // stream() yields its own message_start first; streamOnce does NOT emit a
89
+ // second one — it processes the fake's message_start silently, then yields
90
+ // the text_delta. So: pull1 = message_start, pull2 = text_delta 'partial',
91
+ // pull3 = provider awaits the hung body.
92
+ out.push((await it.next()).value); // message_start (from stream())
93
+ out.push((await it.next()).value); // text_delta 'partial'
94
+ const nextP = it.next(); // provider now awaits the hung body
95
+ controller.abort(); // rejects the body -> provider catch sees signal.aborted
96
+ out.push((await nextP).value); // 'aborted' error event
97
+ await it.next(); // drive generator to done (runs finally -> teardown)
98
+
99
+ // The finally-block tore the SDK stream down.
100
+ expect(aborts.abort + aborts.controller).toBeGreaterThan(0);
101
+ const abortedErr = out.find((e) => e?.type === 'error');
102
+ expect(abortedErr).toMatchObject({ message: 'aborted', retryable: false });
103
+ });
104
+
105
+ it('force-aborts the SDK stream when the consumer abandons the generator between events', async () => {
106
+ // Here the body is NOT hung: events stream eagerly. The consumer pulls a few
107
+ // then `.return()`s before the stream completes. The for-await is suspended
108
+ // BETWEEN yields (not inside an un-abortable await), so .return() unwinds it
109
+ // and our finally runs the teardown (drained never became true).
110
+ const aborts = { abort: 0, controller: 0 };
111
+ async function* gen(): AsyncGenerator<unknown> {
112
+ yield { type: 'message_start', message: { usage: { input_tokens: 1, output_tokens: 0 } } };
113
+ yield { type: 'content_block_delta', delta: { type: 'text_delta', text: 'a' } };
114
+ yield { type: 'content_block_delta', delta: { type: 'text_delta', text: 'b' } };
115
+ yield { type: 'content_block_delta', delta: { type: 'text_delta', text: 'c' } };
116
+ yield { type: 'message_delta', delta: { stop_reason: 'end_turn' }, usage: { output_tokens: 1 } };
117
+ yield { type: 'message_stop' };
118
+ }
119
+ const stream = Object.assign(gen(), {
120
+ abort: () => {
121
+ aborts.abort += 1;
122
+ },
123
+ controller: {
124
+ abort: () => {
125
+ aborts.controller += 1;
126
+ },
127
+ },
128
+ });
129
+ const client = {
130
+ messages: { stream: () => stream, countTokens: async () => ({ input_tokens: 0 }) },
131
+ };
132
+ const p = new AnthropicProvider({ client: client as never });
133
+
134
+ const it = p.stream({ model: 'm', messages: [] })[Symbol.asyncIterator]();
135
+ await it.next(); // message_start (stream())
136
+ await it.next(); // message_start (streamOnce)
137
+ await it.next(); // text_delta 'a'
138
+ // Consumer walks away before the stream completes (drained stays false).
139
+ await it.return?.(undefined);
140
+
141
+ expect(aborts.abort + aborts.controller).toBeGreaterThan(0);
142
+ });
143
+
144
+ it('does NOT abort the SDK stream on a clean completion (no teardown on the happy path)', async () => {
145
+ const aborts = { abort: 0, controller: 0 };
146
+ async function* gen(): AsyncGenerator<unknown> {
147
+ yield { type: 'message_start', message: { usage: { input_tokens: 1, output_tokens: 0 } } };
148
+ yield { type: 'content_block_delta', delta: { type: 'text_delta', text: 'hi' } };
149
+ yield { type: 'message_delta', delta: { stop_reason: 'end_turn' }, usage: { output_tokens: 1 } };
150
+ yield { type: 'message_stop' };
151
+ }
152
+ const stream = Object.assign(gen(), {
153
+ abort: () => {
154
+ aborts.abort += 1;
155
+ },
156
+ controller: {
157
+ abort: () => {
158
+ aborts.controller += 1;
159
+ },
160
+ },
161
+ });
162
+ const client = {
163
+ messages: { stream: () => stream, countTokens: async () => ({ input_tokens: 0 }) },
164
+ };
165
+ const p = new AnthropicProvider({ client: client as never });
166
+ const out = await drain(p.stream({ model: 'm', messages: [] }));
167
+ expect(out.at(-1)).toMatchObject({ type: 'message_end', stopReason: 'end_turn' });
168
+ // Drained naturally — teardown must NOT fire (would abort an already-closed
169
+ // request and could cancel keep-alive reuse).
170
+ expect(aborts.abort + aborts.controller).toBe(0);
171
+ });
172
+ });
173
+
174
+ describe('AnthropicProvider OAuth refresh concurrency', () => {
175
+ it('coalesces concurrent near-expiry refreshes onto a single in-flight refresh', async () => {
176
+ let refreshCalls = 0;
177
+ let resolveRefresh: ((v: { token: string; expiresAt: number }) => void) | undefined;
178
+ const refreshGate = new Promise<{ token: string; expiresAt: number }>((res) => {
179
+ resolveRefresh = res;
180
+ });
181
+
182
+ const streamCalls = { n: 0 };
183
+ const fakeStream = (): AsyncIterable<unknown> =>
184
+ (async function* () {
185
+ streamCalls.n += 1;
186
+ yield { type: 'message_start', message: { usage: { input_tokens: 1, output_tokens: 0 } } };
187
+ yield { type: 'message_delta', delta: { stop_reason: 'end_turn' }, usage: { output_tokens: 1 } };
188
+ yield { type: 'message_stop' };
189
+ })();
190
+ const client = {
191
+ messages: { stream: fakeStream, countTokens: async () => ({ input_tokens: 0 }) },
192
+ } as unknown as Anthropic;
193
+
194
+ const p = new AnthropicProvider({
195
+ oauthToken: 'tok-old',
196
+ oauthExpiresAt: Date.now() + 5_000, // inside skew window -> both callers want a refresh
197
+ oauthRefresh: async () => {
198
+ refreshCalls += 1;
199
+ return refreshGate;
200
+ },
201
+ client,
202
+ });
203
+ // Pin the fake across the refresh-driven client rebuild.
204
+ const internals = p as unknown as {
205
+ client: Anthropic;
206
+ makeOauthClient: (t: string) => Anthropic;
207
+ };
208
+ internals.makeOauthClient = () => client;
209
+
210
+ const baseReq: ProviderRequest = { model: 'm', messages: [] };
211
+ // Kick off two streams concurrently; both observe the stale expiry and call
212
+ // ensureFreshOauth before the (gated) refresh resolves.
213
+ const p1 = drain(p.stream(baseReq));
214
+ const p2 = drain(p.stream(baseReq));
215
+ // Let both reach the awaited refresh, then release it.
216
+ await Promise.resolve();
217
+ await Promise.resolve();
218
+ resolveRefresh!({ token: 'tok-new', expiresAt: Date.now() + 3_600_000 });
219
+ const [o1, o2] = await Promise.all([p1, p2]);
220
+
221
+ // The refresh endpoint was hit exactly once despite two concurrent callers.
222
+ expect(refreshCalls).toBe(1);
223
+ expect((p as unknown as { oauthToken?: string }).oauthToken).toBe('tok-new');
224
+ expect(o1.some((e) => e.type === 'error')).toBe(false);
225
+ expect(o2.some((e) => e.type === 'error')).toBe(false);
226
+ expect(streamCalls.n).toBe(2); // both turns still went out
227
+ });
228
+ });
229
+
230
+ describe('AnthropicProvider countTokens offline fallback', () => {
231
+ it('does not stringify a multi-MB base64 image blob, yielding a bounded estimate', async () => {
232
+ // A 4 MB base64 image: if the fallback stringified the bytes, the estimate
233
+ // would be ~1,000,000 tokens. The fixed media allowance keeps it tiny.
234
+ const bigData = 'A'.repeat(4_000_000);
235
+ let serialized = '';
236
+ const client = {
237
+ messages: {
238
+ countTokens: async () => {
239
+ throw new Error('countTokens unreachable');
240
+ },
241
+ stream: () => (async function* () {})(),
242
+ },
243
+ };
244
+ // Spy on JSON.stringify to prove the blob is never serialized.
245
+ const origStringify = JSON.stringify;
246
+ const spy = (v: unknown, ...rest: unknown[]): string => {
247
+ const s = origStringify(v as never, ...(rest as []));
248
+ if (s.length > serialized.length) serialized = s;
249
+ return s;
250
+ };
251
+ JSON.stringify = spy as typeof JSON.stringify;
252
+ try {
253
+ const p = new AnthropicProvider({ client: client as never });
254
+ const n = await p.countTokens({
255
+ model: 'm',
256
+ messages: [
257
+ {
258
+ role: 'user',
259
+ content: [
260
+ { type: 'text', text: 'describe this' },
261
+ { type: 'image', mediaType: 'image/png', data: bigData },
262
+ ],
263
+ },
264
+ ],
265
+ });
266
+ // Bounded estimate, NOT proportional to the 4 MB blob.
267
+ expect(n).toBeLessThan(10_000);
268
+ expect(n).toBeGreaterThan(0);
269
+ // No stringify call ever materialized the base64 payload.
270
+ expect(serialized.length).toBeLessThan(bigData.length);
271
+ } finally {
272
+ JSON.stringify = origStringify;
273
+ }
274
+ });
275
+
276
+ it('falls back to a char-based estimate (not a crash) when the API count throws', async () => {
277
+ const client = {
278
+ messages: {
279
+ countTokens: async () => {
280
+ throw new Error('network down');
281
+ },
282
+ stream: () => (async function* () {})(),
283
+ },
284
+ };
285
+ const p = new AnthropicProvider({ client: client as never });
286
+ const n = await p.countTokens({
287
+ model: 'm',
288
+ messages: [{ role: 'user', content: [{ type: 'text', text: 'x'.repeat(400) }] }],
289
+ });
290
+ // ~400 chars / 4 ≈ 100 tokens (plus the empty tools array length).
291
+ expect(n).toBeGreaterThanOrEqual(100);
292
+ expect(n).toBeLessThan(150);
293
+ });
294
+ });
295
+
296
+ describe('AnthropicProvider message_delta usage merge', () => {
297
+ it('prefers delta-reported input/cache tokens over message_start values when present', async () => {
298
+ const client = {
299
+ messages: {
300
+ stream: () =>
301
+ (async function* () {
302
+ yield {
303
+ type: 'message_start',
304
+ message: {
305
+ usage: {
306
+ input_tokens: 100,
307
+ output_tokens: 0,
308
+ cache_read_input_tokens: 10,
309
+ },
310
+ },
311
+ };
312
+ yield { type: 'content_block_delta', delta: { type: 'text_delta', text: 'hi' } };
313
+ // The delta CORRECTS input + cache numbers (some streaming modes do).
314
+ yield {
315
+ type: 'message_delta',
316
+ delta: { stop_reason: 'end_turn' },
317
+ usage: {
318
+ input_tokens: 120,
319
+ output_tokens: 5,
320
+ cache_read_input_tokens: 30,
321
+ cache_creation_input_tokens: 7,
322
+ },
323
+ };
324
+ yield { type: 'message_stop' };
325
+ })(),
326
+ countTokens: async () => ({ input_tokens: 0 }),
327
+ },
328
+ };
329
+ const p = new AnthropicProvider({ client: client as never });
330
+ const out = await drain(p.stream({ model: 'm', messages: [] }));
331
+ expect(out.at(-1)).toMatchObject({
332
+ type: 'message_end',
333
+ usage: {
334
+ inputTokens: 120,
335
+ outputTokens: 5,
336
+ cacheReadTokens: 30,
337
+ cacheCreationTokens: 7,
338
+ },
339
+ });
340
+ });
341
+ });
342
+
343
+ describe('AnthropicProvider degraded index-less stream', () => {
344
+ it('does not misroute a second tool block onto a stale finished entry when index is absent', async () => {
345
+ // No `index` on any event (older SDK / hand-rolled). Two strictly-serial
346
+ // tool blocks. The first finishes (its pendingToolUses entry is deleted on
347
+ // tool_use_end); the SECOND block's deltas must route to B, not the stale A
348
+ // entry left lingering in blockIndexToId.
349
+ const client = {
350
+ messages: {
351
+ stream: () =>
352
+ (async function* () {
353
+ yield { type: 'message_start', message: { usage: { input_tokens: 1, output_tokens: 0 } } };
354
+ yield { type: 'content_block_start', content_block: { type: 'tool_use', id: 'A', name: 'Read' } };
355
+ yield { type: 'content_block_delta', delta: { type: 'input_json_delta', partial_json: '{"a":1}' } };
356
+ yield { type: 'content_block_stop' };
357
+ yield { type: 'content_block_start', content_block: { type: 'tool_use', id: 'B', name: 'Glob' } };
358
+ yield { type: 'content_block_delta', delta: { type: 'input_json_delta', partial_json: '{"b":2}' } };
359
+ yield { type: 'content_block_stop' };
360
+ yield { type: 'message_delta', delta: { stop_reason: 'tool_use' }, usage: { output_tokens: 2 } };
361
+ yield { type: 'message_stop' };
362
+ })(),
363
+ countTokens: async () => ({ input_tokens: 0 }),
364
+ },
365
+ };
366
+ const p = new AnthropicProvider({ client: client as never });
367
+ const out = await drain(p.stream({ model: 'm', messages: [] }));
368
+ const ends = out.filter((e) => e.type === 'tool_use_end');
369
+ expect(ends).toHaveLength(2);
370
+ const endA = ends.find((e) => 'id' in e && (e as { id: string }).id === 'A');
371
+ const endB = ends.find((e) => 'id' in e && (e as { id: string }).id === 'B');
372
+ // Each tool received only its OWN input — no cross-routing onto a stale entry.
373
+ expect(endA).toMatchObject({ id: 'A', input: { a: 1 } });
374
+ expect(endB).toMatchObject({ id: 'B', input: { b: 2 } });
375
+ });
376
+ });
@@ -249,6 +249,60 @@ describe('AnthropicProvider OAuth token refresh', () => {
249
249
  const errors = out.filter((e): e is Extract<ProviderEvent, { type: 'error' }> => e.type === 'error');
250
250
  expect(errors).toHaveLength(1);
251
251
  expect(errors[0]!.message).toContain('proactive refresh failed');
252
+ // A failed proactive refresh emits NO message_start — the turn never opens,
253
+ // so consumers pairing message_start/message_end see nothing dangling.
254
+ expect(out.some((e) => e.type === 'message_start')).toBe(false);
255
+ });
256
+
257
+ it('does NOT refresh-and-replay a 401 surfaced AFTER content already streamed (no duplicate output)', async () => {
258
+ // First attempt streams a text delta, THEN throws a 401 mid-stream (token
259
+ // revoked during generation). Replaying would duplicate the text + emit a
260
+ // second message_end; the provider must instead surface the error once.
261
+ const fake = fakeClient((attempt) => {
262
+ if (attempt === 1) {
263
+ return [
264
+ { type: 'message_start', message: { usage: { input_tokens: 1, output_tokens: 0 } } },
265
+ { type: 'content_block_delta', delta: { type: 'text_delta', text: 'partial' } },
266
+ { __throw: unauthorizedError() } as unknown,
267
+ ];
268
+ }
269
+ return DONE_EVENTS;
270
+ });
271
+ // Make the fake throw when it reaches a `__throw` sentinel mid-iteration.
272
+ const msgs = fake.client.messages as unknown as { stream: () => AsyncIterable<unknown> };
273
+ const inner = msgs.stream.bind(msgs);
274
+ msgs.stream = () => {
275
+ const it = inner();
276
+ return (async function* () {
277
+ for await (const e of it) {
278
+ if (e && typeof e === 'object' && '__throw' in e) {
279
+ throw (e as { __throw: unknown }).__throw;
280
+ }
281
+ yield e;
282
+ }
283
+ })();
284
+ };
285
+
286
+ let refreshCalls = 0;
287
+ const p = new AnthropicProvider({
288
+ oauthToken: 'tok-old',
289
+ oauthExpiresAt: Date.now() + 3_600_000, // fresh -> the mid-stream 401 drives this path
290
+ oauthRefresh: async () => {
291
+ refreshCalls += 1;
292
+ return { token: 'tok-new' };
293
+ },
294
+ client: fake.client,
295
+ });
296
+ pinClient(p, fake.client);
297
+
298
+ const out = await drain(p.stream(baseReq));
299
+
300
+ // No replay: the 401 arrived after output, so we surface the error instead.
301
+ expect(refreshCalls).toBe(0);
302
+ // The partial text was streamed exactly once (not duplicated by a replay).
303
+ expect(out.filter((e) => e.type === 'text_delta')).toHaveLength(1);
304
+ expect(out.filter((e) => e.type === 'message_start')).toHaveLength(1);
305
+ expect(out.filter((e) => e.type === 'error')).toHaveLength(1);
252
306
  });
253
307
 
254
308
  it('countTokens proactively refreshes a near-expiry token before counting', async () => {
@@ -195,4 +195,50 @@ describe('AnthropicProvider.stream', () => {
195
195
  const p = new AnthropicProvider({ client: fake as never });
196
196
  expect(await p.countTokens({ model: 'm', messages: [] })).toBe(42);
197
197
  });
198
+
199
+ it('surfaces an error (not a junk tool input) when streamed tool JSON is malformed/truncated', async () => {
200
+ // A truncated input_json stream: the accumulated partial never closes.
201
+ const fake = fakeAnthropic([
202
+ { type: 'message_start', message: { usage: { input_tokens: 1, output_tokens: 0 } } },
203
+ { type: 'content_block_start', index: 0, content_block: { type: 'tool_use', id: 'c1', name: 'Read' } },
204
+ { type: 'content_block_delta', index: 0, delta: { type: 'input_json_delta', partial_json: '{"file_path":"a' } },
205
+ { type: 'content_block_stop', index: 0 },
206
+ { type: 'message_delta', delta: { stop_reason: 'tool_use' }, usage: { output_tokens: 2 } },
207
+ { type: 'message_stop' },
208
+ ]);
209
+ const p = new AnthropicProvider({ client: fake as never });
210
+ const events = [];
211
+ for await (const e of p.stream({ model: 'm', messages: [] })) events.push(e);
212
+ // The malformed call must NOT reach the loop as a valid tool_use_end with junk.
213
+ expect(events.some((e) => e.type === 'tool_use_end')).toBe(false);
214
+ const err = events.find((e) => e.type === 'error');
215
+ expect(err).toMatchObject({ retryable: false });
216
+ expect((err as { message: string }).message).toContain('c1');
217
+ // The turn is marked as an error rather than a clean tool-use completion.
218
+ expect(events.at(-1)).toMatchObject({ type: 'message_end', stopReason: 'error' });
219
+ });
220
+
221
+ it('clamps a caller maxTokens above the model output ceiling and defaults to it', async () => {
222
+ const calls: Array<{ max_tokens?: number }> = [];
223
+ const client = {
224
+ messages: {
225
+ stream: (args: { max_tokens?: number }) => {
226
+ calls.push(args);
227
+ return (async function* () {
228
+ yield { type: 'message_stop' };
229
+ })();
230
+ },
231
+ countTokens: async () => ({ input_tokens: 0 }),
232
+ },
233
+ };
234
+ const p = new AnthropicProvider({ client: client as never });
235
+ // haiku ceiling is 64_000 — an absurd request must be clamped, not forwarded.
236
+ for await (const _ of p.stream({ model: 'claude-haiku-4-5-20251001', maxTokens: 5_000_000, messages: [] })) {
237
+ void _;
238
+ }
239
+ expect(calls[0]!.max_tokens).toBe(64_000);
240
+ // No maxTokens → defaults to the descriptor ceiling.
241
+ for await (const _ of p.stream({ model: 'claude-haiku-4-5-20251001', messages: [] })) void _;
242
+ expect(calls[1]!.max_tokens).toBe(64_000);
243
+ });
198
244
  });