@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.
- package/LICENSE +21 -0
- package/README.md +54 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +19 -0
- package/dist/index.js.map +1 -0
- package/dist/provider.d.ts +99 -0
- package/dist/provider.d.ts.map +1 -0
- package/dist/provider.js +438 -0
- package/dist/provider.js.map +1 -0
- package/dist/translate.d.ts +76 -0
- package/dist/translate.d.ts.map +1 -0
- package/dist/translate.js +125 -0
- package/dist/translate.js.map +1 -0
- package/dist/validate.d.ts +21 -0
- package/dist/validate.d.ts.map +1 -0
- package/dist/validate.js +27 -0
- package/dist/validate.js.map +1 -0
- package/package.json +67 -0
- package/src/index.ts +24 -0
- package/src/oauth-mode.test.ts +151 -0
- package/src/oauth-refresh.test.ts +310 -0
- package/src/provider.test.ts +198 -0
- package/src/provider.ts +547 -0
- package/src/real-api.test.ts +154 -0
- package/src/translate.test.ts +125 -0
- package/src/translate.ts +194 -0
- package/src/validate.test.ts +43 -0
- package/src/validate.ts +39 -0
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import { describe, it, expect } 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
|
+
* Coverage for the OAuth token-refresh paths in provider.ts:
|
|
8
|
+
* - ensureFreshOauth() : proactive near-expiry refresh BEFORE the request.
|
|
9
|
+
* - 401 -> refreshOauthNow() -> replay : reactive single refresh + replay.
|
|
10
|
+
* - refresh-failure surfacing without an infinite replay loop.
|
|
11
|
+
*
|
|
12
|
+
* Harness notes
|
|
13
|
+
* -------------
|
|
14
|
+
* `refreshOauthNow()` rebuilds `this.client` via the PRIVATE `makeOauthClient`,
|
|
15
|
+
* which constructs a REAL `Anthropic` SDK client. To keep every attempt routed
|
|
16
|
+
* at our fake (and off the network) across a refresh, we stub `makeOauthClient`
|
|
17
|
+
* on the instance so it returns the fake — faithfully exercising the real
|
|
18
|
+
* "refresh -> rebuild client -> replay" flow while staying offline. Reaching
|
|
19
|
+
* into a private member for TEST SETUP mirrors the existing oauth-mode.test.ts
|
|
20
|
+
* pattern (which casts to read `client`); production code is never touched.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** Install the fake on a provider, including across refresh-driven client rebuilds. */
|
|
24
|
+
function pinClient(p: AnthropicProvider, client: Anthropic): void {
|
|
25
|
+
const internals = p as unknown as {
|
|
26
|
+
client: Anthropic;
|
|
27
|
+
makeOauthClient: (token: string) => Anthropic;
|
|
28
|
+
};
|
|
29
|
+
internals.client = client;
|
|
30
|
+
internals.makeOauthClient = () => client;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const DONE_EVENTS = [
|
|
34
|
+
{ type: 'message_start', message: { usage: { input_tokens: 1, output_tokens: 0 } } },
|
|
35
|
+
{ type: 'message_delta', delta: { stop_reason: 'end_turn' }, usage: { output_tokens: 2 } },
|
|
36
|
+
{ type: 'message_stop' },
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
/** An error shaped like the Anthropic SDK's APIError for a 401 (carries `status`). */
|
|
40
|
+
function unauthorizedError(): Error {
|
|
41
|
+
return Object.assign(new Error('Unauthorized'), { status: 401 });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface FakeClient {
|
|
45
|
+
client: Anthropic;
|
|
46
|
+
/** Number of times `messages.stream` was invoked (i.e. request attempts). */
|
|
47
|
+
streamCalls: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* A fake SDK client whose `messages.stream` behaviour is driven by a per-call
|
|
52
|
+
* factory. The factory receives the 1-based attempt number and returns either
|
|
53
|
+
* the events to yield (success) or throws (transport/HTTP error).
|
|
54
|
+
*/
|
|
55
|
+
function fakeClient(
|
|
56
|
+
perAttempt: (attempt: number) => ReadonlyArray<unknown>,
|
|
57
|
+
): FakeClient {
|
|
58
|
+
const state: FakeClient = { client: undefined as unknown as Anthropic, streamCalls: 0 };
|
|
59
|
+
state.client = {
|
|
60
|
+
messages: {
|
|
61
|
+
stream: () => {
|
|
62
|
+
state.streamCalls += 1;
|
|
63
|
+
const attempt = state.streamCalls;
|
|
64
|
+
// Resolve the events eagerly so a thrown error surfaces from the
|
|
65
|
+
// generator body (matching how the SDK throws mid-iteration).
|
|
66
|
+
return (async function* () {
|
|
67
|
+
const events = perAttempt(attempt);
|
|
68
|
+
for (const e of events) yield e;
|
|
69
|
+
})();
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
} as unknown as Anthropic;
|
|
73
|
+
return state;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function drain(it: AsyncIterable<ProviderEvent>): Promise<ProviderEvent[]> {
|
|
77
|
+
const out: ProviderEvent[] = [];
|
|
78
|
+
for await (const e of it) out.push(e);
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const baseReq: ProviderRequest = {
|
|
83
|
+
model: 'claude-x',
|
|
84
|
+
messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
describe('AnthropicProvider OAuth token refresh', () => {
|
|
88
|
+
it('proactively refreshes a near-expiry token before the request', async () => {
|
|
89
|
+
const fake = fakeClient(() => DONE_EVENTS);
|
|
90
|
+
let refreshCalls = 0;
|
|
91
|
+
const refreshedAt = Date.now() + 3_600_000;
|
|
92
|
+
|
|
93
|
+
const p = new AnthropicProvider({
|
|
94
|
+
oauthToken: 'tok-old',
|
|
95
|
+
oauthExpiresAt: Date.now() + 10_000, // inside the 60s skew window -> refresh
|
|
96
|
+
oauthRefresh: async () => {
|
|
97
|
+
refreshCalls += 1;
|
|
98
|
+
return { token: 'tok-new', expiresAt: refreshedAt };
|
|
99
|
+
},
|
|
100
|
+
client: fake.client,
|
|
101
|
+
});
|
|
102
|
+
pinClient(p, fake.client);
|
|
103
|
+
|
|
104
|
+
const out = await drain(p.stream(baseReq));
|
|
105
|
+
|
|
106
|
+
expect(refreshCalls).toBe(1);
|
|
107
|
+
// The provider swapped in the new bearer and recorded the new expiry.
|
|
108
|
+
const internals = p as unknown as { oauthToken?: string; oauthExpiresAt?: number };
|
|
109
|
+
expect(internals.oauthToken).toBe('tok-new');
|
|
110
|
+
expect(internals.oauthExpiresAt).toBe(refreshedAt);
|
|
111
|
+
// Exactly one request attempt, ending cleanly.
|
|
112
|
+
expect(fake.streamCalls).toBe(1);
|
|
113
|
+
expect(out.filter((e) => e.type === 'message_start')).toHaveLength(1);
|
|
114
|
+
expect(out.at(-1)).toMatchObject({ type: 'message_end', stopReason: 'end_turn' });
|
|
115
|
+
expect(out.some((e) => e.type === 'error')).toBe(false);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('does NOT refresh a token that is comfortably fresh', async () => {
|
|
119
|
+
const fake = fakeClient(() => DONE_EVENTS);
|
|
120
|
+
let refreshCalls = 0;
|
|
121
|
+
const p = new AnthropicProvider({
|
|
122
|
+
oauthToken: 'tok-fresh',
|
|
123
|
+
oauthExpiresAt: Date.now() + 3_600_000, // far outside the skew window
|
|
124
|
+
oauthRefresh: async () => {
|
|
125
|
+
refreshCalls += 1;
|
|
126
|
+
return { token: 'tok-new' };
|
|
127
|
+
},
|
|
128
|
+
client: fake.client,
|
|
129
|
+
});
|
|
130
|
+
pinClient(p, fake.client);
|
|
131
|
+
|
|
132
|
+
const out = await drain(p.stream(baseReq));
|
|
133
|
+
|
|
134
|
+
expect(refreshCalls).toBe(0);
|
|
135
|
+
expect(fake.streamCalls).toBe(1);
|
|
136
|
+
expect(out.at(-1)).toMatchObject({ type: 'message_end' });
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('on a 401 refreshes once and replays, emitting exactly one message_start/message_end', async () => {
|
|
140
|
+
// First attempt 401s; second attempt (post-refresh) succeeds.
|
|
141
|
+
const fake = fakeClient((attempt) => {
|
|
142
|
+
if (attempt === 1) throw unauthorizedError();
|
|
143
|
+
return DONE_EVENTS;
|
|
144
|
+
});
|
|
145
|
+
let refreshCalls = 0;
|
|
146
|
+
const p = new AnthropicProvider({
|
|
147
|
+
oauthToken: 'tok-old',
|
|
148
|
+
oauthExpiresAt: Date.now() + 3_600_000, // fresh -> no proactive refresh; the 401 drives it
|
|
149
|
+
oauthRefresh: async () => {
|
|
150
|
+
refreshCalls += 1;
|
|
151
|
+
return { token: 'tok-new', expiresAt: Date.now() + 3_600_000 };
|
|
152
|
+
},
|
|
153
|
+
client: fake.client,
|
|
154
|
+
});
|
|
155
|
+
pinClient(p, fake.client);
|
|
156
|
+
|
|
157
|
+
const out = await drain(p.stream(baseReq));
|
|
158
|
+
|
|
159
|
+
// Refreshed exactly once; replayed exactly once (two stream attempts total).
|
|
160
|
+
expect(refreshCalls).toBe(1);
|
|
161
|
+
expect(fake.streamCalls).toBe(2);
|
|
162
|
+
expect((p as unknown as { oauthToken?: string }).oauthToken).toBe('tok-new');
|
|
163
|
+
// The provider yields a single message_start (from stream()) regardless of
|
|
164
|
+
// the internal replay, and a single terminal message_end from the successful
|
|
165
|
+
// attempt. No error event on a recovered 401.
|
|
166
|
+
expect(out.filter((e) => e.type === 'message_start')).toHaveLength(1);
|
|
167
|
+
expect(out.filter((e) => e.type === 'message_end')).toHaveLength(1);
|
|
168
|
+
expect(out.some((e) => e.type === 'error')).toBe(false);
|
|
169
|
+
expect(out.at(-1)).toMatchObject({ type: 'message_end', stopReason: 'end_turn' });
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('surfaces a single error and does NOT replay again when the replay also 401s', async () => {
|
|
173
|
+
// Every attempt 401s. The provider must refresh once, replay once, then
|
|
174
|
+
// surface the error — never loop.
|
|
175
|
+
const fake = fakeClient(() => {
|
|
176
|
+
throw unauthorizedError();
|
|
177
|
+
});
|
|
178
|
+
let refreshCalls = 0;
|
|
179
|
+
const p = new AnthropicProvider({
|
|
180
|
+
oauthToken: 'tok-old',
|
|
181
|
+
oauthExpiresAt: Date.now() + 3_600_000,
|
|
182
|
+
oauthRefresh: async () => {
|
|
183
|
+
refreshCalls += 1;
|
|
184
|
+
return { token: 'tok-new' };
|
|
185
|
+
},
|
|
186
|
+
client: fake.client,
|
|
187
|
+
});
|
|
188
|
+
pinClient(p, fake.client);
|
|
189
|
+
|
|
190
|
+
const out = await drain(p.stream(baseReq));
|
|
191
|
+
|
|
192
|
+
// Refreshed once, two attempts (original + single replay), then stop.
|
|
193
|
+
expect(refreshCalls).toBe(1);
|
|
194
|
+
expect(fake.streamCalls).toBe(2);
|
|
195
|
+
const errors = out.filter((e) => e.type === 'error');
|
|
196
|
+
expect(errors).toHaveLength(1);
|
|
197
|
+
// The terminal event is the error; no second message_end.
|
|
198
|
+
expect(out.at(-1)).toMatchObject({ type: 'error' });
|
|
199
|
+
expect(out.filter((e) => e.type === 'message_end')).toHaveLength(0);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('surfaces a clear error when the refresh callback itself throws (no replay)', async () => {
|
|
203
|
+
const fake = fakeClient((attempt) => {
|
|
204
|
+
if (attempt === 1) throw unauthorizedError();
|
|
205
|
+
return DONE_EVENTS;
|
|
206
|
+
});
|
|
207
|
+
let refreshCalls = 0;
|
|
208
|
+
const p = new AnthropicProvider({
|
|
209
|
+
oauthToken: 'tok-old',
|
|
210
|
+
oauthExpiresAt: Date.now() + 3_600_000,
|
|
211
|
+
oauthRefresh: async () => {
|
|
212
|
+
refreshCalls += 1;
|
|
213
|
+
throw new Error('refresh endpoint down');
|
|
214
|
+
},
|
|
215
|
+
client: fake.client,
|
|
216
|
+
});
|
|
217
|
+
pinClient(p, fake.client);
|
|
218
|
+
|
|
219
|
+
const out = await drain(p.stream(baseReq));
|
|
220
|
+
|
|
221
|
+
// Attempted the refresh once after the 401; it threw, so NO replay attempt.
|
|
222
|
+
expect(refreshCalls).toBe(1);
|
|
223
|
+
expect(fake.streamCalls).toBe(1);
|
|
224
|
+
const errors = out.filter((e): e is Extract<ProviderEvent, { type: 'error' }> => e.type === 'error');
|
|
225
|
+
expect(errors).toHaveLength(1);
|
|
226
|
+
expect(errors[0]!.message).toContain('refresh endpoint down');
|
|
227
|
+
expect(out.filter((e) => e.type === 'message_end')).toHaveLength(0);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('surfaces a clear error when a proactive (pre-request) refresh throws, and does not send a request', async () => {
|
|
231
|
+
const fake = fakeClient(() => DONE_EVENTS);
|
|
232
|
+
let refreshCalls = 0;
|
|
233
|
+
const p = new AnthropicProvider({
|
|
234
|
+
oauthToken: 'tok-old',
|
|
235
|
+
oauthExpiresAt: Date.now() + 5_000, // near expiry -> proactive refresh fires
|
|
236
|
+
oauthRefresh: async () => {
|
|
237
|
+
refreshCalls += 1;
|
|
238
|
+
throw new Error('proactive refresh failed');
|
|
239
|
+
},
|
|
240
|
+
client: fake.client,
|
|
241
|
+
});
|
|
242
|
+
pinClient(p, fake.client);
|
|
243
|
+
|
|
244
|
+
const out = await drain(p.stream(baseReq));
|
|
245
|
+
|
|
246
|
+
expect(refreshCalls).toBe(1);
|
|
247
|
+
// Proactive refresh failed before the request — no stream attempt at all.
|
|
248
|
+
expect(fake.streamCalls).toBe(0);
|
|
249
|
+
const errors = out.filter((e): e is Extract<ProviderEvent, { type: 'error' }> => e.type === 'error');
|
|
250
|
+
expect(errors).toHaveLength(1);
|
|
251
|
+
expect(errors[0]!.message).toContain('proactive refresh failed');
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('countTokens proactively refreshes a near-expiry token before counting', async () => {
|
|
255
|
+
let countCalls = 0;
|
|
256
|
+
const client = {
|
|
257
|
+
messages: {
|
|
258
|
+
countTokens: async () => {
|
|
259
|
+
countCalls += 1;
|
|
260
|
+
return { input_tokens: 42 };
|
|
261
|
+
},
|
|
262
|
+
},
|
|
263
|
+
} as unknown as Anthropic;
|
|
264
|
+
let refreshCalls = 0;
|
|
265
|
+
const refreshedAt = Date.now() + 3_600_000;
|
|
266
|
+
const p = new AnthropicProvider({
|
|
267
|
+
oauthToken: 'tok-old',
|
|
268
|
+
oauthExpiresAt: Date.now() + 10_000, // inside the 60s skew window -> refresh
|
|
269
|
+
oauthRefresh: async () => {
|
|
270
|
+
refreshCalls += 1;
|
|
271
|
+
return { token: 'tok-new', expiresAt: refreshedAt };
|
|
272
|
+
},
|
|
273
|
+
client,
|
|
274
|
+
});
|
|
275
|
+
pinClient(p, client);
|
|
276
|
+
|
|
277
|
+
const n = await p.countTokens(baseReq);
|
|
278
|
+
|
|
279
|
+
expect(n).toBe(42);
|
|
280
|
+
expect(refreshCalls).toBe(1);
|
|
281
|
+
expect(countCalls).toBe(1);
|
|
282
|
+
const internals = p as unknown as { oauthToken?: string; oauthExpiresAt?: number };
|
|
283
|
+
expect(internals.oauthToken).toBe('tok-new');
|
|
284
|
+
expect(internals.oauthExpiresAt).toBe(refreshedAt);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('does not refresh on a non-401 error (surfaces it directly, single attempt)', async () => {
|
|
288
|
+
const serverError = Object.assign(new Error('boom'), { status: 500 });
|
|
289
|
+
const fake = fakeClient(() => {
|
|
290
|
+
throw serverError;
|
|
291
|
+
});
|
|
292
|
+
let refreshCalls = 0;
|
|
293
|
+
const p = new AnthropicProvider({
|
|
294
|
+
oauthToken: 'tok-old',
|
|
295
|
+
oauthExpiresAt: Date.now() + 3_600_000,
|
|
296
|
+
oauthRefresh: async () => {
|
|
297
|
+
refreshCalls += 1;
|
|
298
|
+
return { token: 'tok-new' };
|
|
299
|
+
},
|
|
300
|
+
client: fake.client,
|
|
301
|
+
});
|
|
302
|
+
pinClient(p, fake.client);
|
|
303
|
+
|
|
304
|
+
const out = await drain(p.stream(baseReq));
|
|
305
|
+
|
|
306
|
+
expect(refreshCalls).toBe(0);
|
|
307
|
+
expect(fake.streamCalls).toBe(1);
|
|
308
|
+
expect(out.filter((e) => e.type === 'error')).toHaveLength(1);
|
|
309
|
+
});
|
|
310
|
+
});
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { AnthropicProvider } from './provider.js';
|
|
3
|
+
|
|
4
|
+
// A minimal fake Anthropic SDK client to drive the translator without HTTP.
|
|
5
|
+
function fakeAnthropic(stream: ReadonlyArray<unknown>): { messages: { stream: () => AsyncIterable<unknown>; countTokens: () => Promise<{ input_tokens: number }> } } {
|
|
6
|
+
return {
|
|
7
|
+
messages: {
|
|
8
|
+
stream: () => {
|
|
9
|
+
return (async function* () {
|
|
10
|
+
for (const e of stream) yield e;
|
|
11
|
+
})();
|
|
12
|
+
},
|
|
13
|
+
countTokens: async () => ({ input_tokens: 42 }),
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('AnthropicProvider.stream', () => {
|
|
19
|
+
it('translates content_block_delta text into text_delta events', async () => {
|
|
20
|
+
const fake = fakeAnthropic([
|
|
21
|
+
{ type: 'message_start', message: { usage: { input_tokens: 10, output_tokens: 0 } } },
|
|
22
|
+
{ type: 'content_block_delta', delta: { type: 'text_delta', text: 'hi' } },
|
|
23
|
+
{ type: 'message_delta', delta: { stop_reason: 'end_turn' }, usage: { output_tokens: 5 } },
|
|
24
|
+
{ type: 'message_stop' },
|
|
25
|
+
]);
|
|
26
|
+
const p = new AnthropicProvider({ client: fake as never });
|
|
27
|
+
const events = [];
|
|
28
|
+
for await (const e of p.stream({ model: 'm', messages: [] })) events.push(e);
|
|
29
|
+
expect(events[0]).toMatchObject({ type: 'message_start' });
|
|
30
|
+
expect(events.find((e) => e.type === 'text_delta')).toMatchObject({ delta: 'hi' });
|
|
31
|
+
expect(events[events.length - 1]).toMatchObject({ type: 'message_end', stopReason: 'end_turn' });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('translates tool_use blocks with streamed input_json', async () => {
|
|
35
|
+
const fake = fakeAnthropic([
|
|
36
|
+
{ type: 'message_start', message: { usage: { input_tokens: 1, output_tokens: 0 } } },
|
|
37
|
+
{ type: 'content_block_start', content_block: { type: 'tool_use', id: 'c1', name: 'Read' } },
|
|
38
|
+
{ type: 'content_block_delta', delta: { type: 'input_json_delta', partial_json: '{"file' } },
|
|
39
|
+
{ type: 'content_block_delta', delta: { type: 'input_json_delta', partial_json: '_path":"a"}' } },
|
|
40
|
+
{ type: 'content_block_stop' },
|
|
41
|
+
{ type: 'message_delta', delta: { stop_reason: 'tool_use' }, usage: { output_tokens: 2 } },
|
|
42
|
+
{ type: 'message_stop' },
|
|
43
|
+
]);
|
|
44
|
+
const p = new AnthropicProvider({ client: fake as never });
|
|
45
|
+
const events = [];
|
|
46
|
+
for await (const e of p.stream({ model: 'm', messages: [] })) events.push(e);
|
|
47
|
+
const start = events.find((e) => e.type === 'tool_use_start');
|
|
48
|
+
expect(start).toMatchObject({ id: 'c1', name: 'Read' });
|
|
49
|
+
const end = events.find((e) => e.type === 'tool_use_end');
|
|
50
|
+
expect(end).toMatchObject({ id: 'c1', input: { file_path: 'a' } });
|
|
51
|
+
const last = events[events.length - 1];
|
|
52
|
+
expect(last).toMatchObject({ type: 'message_end', stopReason: 'tool_use' });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('routes deltas correctly with two parallel tool_use blocks', async () => {
|
|
56
|
+
// Anthropic emits content_block events with an `index` field that
|
|
57
|
+
// distinguishes parallel blocks. Before the fix, idOfBlock returned
|
|
58
|
+
// the first pending map key for every delta, so block B's deltas
|
|
59
|
+
// would land on block A's partial input.
|
|
60
|
+
const fake = fakeAnthropic([
|
|
61
|
+
{ type: 'message_start', message: { usage: { input_tokens: 1, output_tokens: 0 } } },
|
|
62
|
+
{ type: 'content_block_start', index: 0, content_block: { type: 'tool_use', id: 'A', name: 'Read' } },
|
|
63
|
+
{ type: 'content_block_start', index: 1, content_block: { type: 'tool_use', id: 'B', name: 'Glob' } },
|
|
64
|
+
// Interleave deltas across the two blocks.
|
|
65
|
+
{ type: 'content_block_delta', index: 0, delta: { type: 'input_json_delta', partial_json: '{"file_path":' } },
|
|
66
|
+
{ type: 'content_block_delta', index: 1, delta: { type: 'input_json_delta', partial_json: '{"pattern":' } },
|
|
67
|
+
{ type: 'content_block_delta', index: 0, delta: { type: 'input_json_delta', partial_json: '"a"}' } },
|
|
68
|
+
{ type: 'content_block_delta', index: 1, delta: { type: 'input_json_delta', partial_json: '"*.ts"}' } },
|
|
69
|
+
{ type: 'content_block_stop', index: 0 },
|
|
70
|
+
{ type: 'content_block_stop', index: 1 },
|
|
71
|
+
{ type: 'message_delta', delta: { stop_reason: 'tool_use' }, usage: { output_tokens: 3 } },
|
|
72
|
+
{ type: 'message_stop' },
|
|
73
|
+
]);
|
|
74
|
+
const p = new AnthropicProvider({ client: fake as never });
|
|
75
|
+
const events = [];
|
|
76
|
+
for await (const e of p.stream({ model: 'm', messages: [] })) events.push(e);
|
|
77
|
+
|
|
78
|
+
const ends = events.filter((e) => e.type === 'tool_use_end');
|
|
79
|
+
expect(ends).toHaveLength(2);
|
|
80
|
+
const endA = ends.find((e) => 'id' in e && e.id === 'A');
|
|
81
|
+
const endB = ends.find((e) => 'id' in e && e.id === 'B');
|
|
82
|
+
expect(endA).toMatchObject({ id: 'A', input: { file_path: 'a' } });
|
|
83
|
+
expect(endB).toMatchObject({ id: 'B', input: { pattern: '*.ts' } });
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('translates a thinking block into reasoning_delta then a concatenated reasoning_signature', async () => {
|
|
87
|
+
const fake = fakeAnthropic([
|
|
88
|
+
{ type: 'message_start', message: { usage: { input_tokens: 1, output_tokens: 0 } } },
|
|
89
|
+
{ type: 'content_block_start', index: 0, content_block: { type: 'thinking' } },
|
|
90
|
+
{ type: 'content_block_delta', index: 0, delta: { type: 'thinking_delta', thinking: 'let me ' } },
|
|
91
|
+
{ type: 'content_block_delta', index: 0, delta: { type: 'thinking_delta', thinking: 'think' } },
|
|
92
|
+
{ type: 'content_block_delta', index: 0, delta: { type: 'signature_delta', signature: 'sig-' } },
|
|
93
|
+
{ type: 'content_block_delta', index: 0, delta: { type: 'signature_delta', signature: 'part2' } },
|
|
94
|
+
{ type: 'content_block_stop', index: 0 },
|
|
95
|
+
{ type: 'message_delta', delta: { stop_reason: 'end_turn' }, usage: { output_tokens: 2 } },
|
|
96
|
+
{ type: 'message_stop' },
|
|
97
|
+
]);
|
|
98
|
+
const p = new AnthropicProvider({ client: fake as never });
|
|
99
|
+
const events = [];
|
|
100
|
+
for await (const e of p.stream({ model: 'm', messages: [] })) events.push(e);
|
|
101
|
+
const reasoning = events
|
|
102
|
+
.filter((e) => e.type === 'reasoning_delta')
|
|
103
|
+
.map((e) => (e as { delta: string }).delta)
|
|
104
|
+
.join('');
|
|
105
|
+
expect(reasoning).toBe('let me think');
|
|
106
|
+
const sig = events.find((e) => e.type === 'reasoning_signature');
|
|
107
|
+
// Signature deltas accumulate and flush once at content_block_stop.
|
|
108
|
+
expect(sig).toMatchObject({ signature: 'sig-part2' });
|
|
109
|
+
expect(events.filter((e) => e.type === 'reasoning_signature')).toHaveLength(1);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('passes through a redacted_thinking block as an encrypted reasoning_signature', async () => {
|
|
113
|
+
const fake = fakeAnthropic([
|
|
114
|
+
{ type: 'message_start', message: { usage: { input_tokens: 1, output_tokens: 0 } } },
|
|
115
|
+
{ type: 'content_block_start', index: 0, content_block: { type: 'redacted_thinking', data: 'OPAQUE' } },
|
|
116
|
+
{ type: 'content_block_stop', index: 0 },
|
|
117
|
+
{ type: 'message_delta', delta: { stop_reason: 'end_turn' }, usage: { output_tokens: 1 } },
|
|
118
|
+
{ type: 'message_stop' },
|
|
119
|
+
]);
|
|
120
|
+
const p = new AnthropicProvider({ client: fake as never });
|
|
121
|
+
const events = [];
|
|
122
|
+
for await (const e of p.stream({ model: 'm', messages: [] })) events.push(e);
|
|
123
|
+
const sig = events.find((e) => e.type === 'reasoning_signature');
|
|
124
|
+
expect(sig).toMatchObject({ redacted: true, encrypted: 'OPAQUE' });
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('preserves cache_read/creation token counts from message_start through message_end', async () => {
|
|
128
|
+
const fake = fakeAnthropic([
|
|
129
|
+
{
|
|
130
|
+
type: 'message_start',
|
|
131
|
+
message: {
|
|
132
|
+
usage: {
|
|
133
|
+
input_tokens: 100,
|
|
134
|
+
output_tokens: 0,
|
|
135
|
+
cache_read_input_tokens: 80,
|
|
136
|
+
cache_creation_input_tokens: 20,
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
{ type: 'content_block_delta', delta: { type: 'text_delta', text: 'hi' } },
|
|
141
|
+
// The delta usage only carries the final output_tokens; cache fields must survive.
|
|
142
|
+
{ type: 'message_delta', delta: { stop_reason: 'end_turn' }, usage: { output_tokens: 5 } },
|
|
143
|
+
{ type: 'message_stop' },
|
|
144
|
+
]);
|
|
145
|
+
const p = new AnthropicProvider({ client: fake as never });
|
|
146
|
+
const events = [];
|
|
147
|
+
for await (const e of p.stream({ model: 'm', messages: [] })) events.push(e);
|
|
148
|
+
expect(events[events.length - 1]).toMatchObject({
|
|
149
|
+
type: 'message_end',
|
|
150
|
+
usage: {
|
|
151
|
+
inputTokens: 100,
|
|
152
|
+
outputTokens: 5,
|
|
153
|
+
cacheReadTokens: 80,
|
|
154
|
+
cacheCreationTokens: 20,
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('yields exactly one clean aborted error when the signal is already aborted', async () => {
|
|
160
|
+
const controller = new AbortController();
|
|
161
|
+
controller.abort();
|
|
162
|
+
const fake = fakeAnthropic([
|
|
163
|
+
{ type: 'message_start', message: { usage: { input_tokens: 1, output_tokens: 0 } } },
|
|
164
|
+
{ type: 'content_block_delta', delta: { type: 'text_delta', text: 'should not arrive' } },
|
|
165
|
+
{ type: 'message_stop' },
|
|
166
|
+
]);
|
|
167
|
+
const p = new AnthropicProvider({ client: fake as never });
|
|
168
|
+
const events = [];
|
|
169
|
+
for await (const e of p.stream({ model: 'm', messages: [], signal: controller.signal })) {
|
|
170
|
+
events.push(e);
|
|
171
|
+
}
|
|
172
|
+
const errors = events.filter((e) => e.type === 'error');
|
|
173
|
+
expect(errors).toHaveLength(1);
|
|
174
|
+
expect(errors[0]).toMatchObject({ message: 'aborted', retryable: false });
|
|
175
|
+
expect(events.some((e) => e.type === 'text_delta')).toBe(false);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('emits error event when stream throws', async () => {
|
|
179
|
+
const fake = {
|
|
180
|
+
messages: {
|
|
181
|
+
stream: () => {
|
|
182
|
+
throw new Error('boom');
|
|
183
|
+
},
|
|
184
|
+
countTokens: async () => ({ input_tokens: 0 }),
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
const p = new AnthropicProvider({ client: fake as never });
|
|
188
|
+
const events = [];
|
|
189
|
+
for await (const e of p.stream({ model: 'm', messages: [] })) events.push(e);
|
|
190
|
+
expect(events.find((e) => e.type === 'error')).toBeDefined();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('countTokens proxies to the SDK', async () => {
|
|
194
|
+
const fake = fakeAnthropic([]);
|
|
195
|
+
const p = new AnthropicProvider({ client: fake as never });
|
|
196
|
+
expect(await p.countTokens({ model: 'm', messages: [] })).toBe(42);
|
|
197
|
+
});
|
|
198
|
+
});
|