@moxxy/plugin-provider-anthropic 0.2.0 → 0.2.2
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/provider.d.ts +6 -1
- package/dist/provider.d.ts.map +1 -1
- package/dist/provider.js +187 -35
- package/dist/provider.js.map +1 -1
- package/dist/translate.d.ts.map +1 -1
- package/dist/translate.js +53 -8
- package/dist/translate.js.map +1 -1
- package/dist/validate.js +23 -1
- package/dist/validate.js.map +1 -1
- package/package.json +5 -5
- package/src/hardening.test.ts +376 -0
- package/src/oauth-refresh.test.ts +54 -0
- package/src/provider.test.ts +46 -0
- package/src/provider.ts +196 -34
- package/src/translate.test.ts +52 -0
- package/src/translate.ts +55 -9
- package/src/validate.test.ts +29 -0
- package/src/validate.ts +20 -1
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
|
|
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
|
}
|
package/dist/validate.js.map
CHANGED
|
@@ -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,
|
|
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.
|
|
3
|
+
"version": "0.2.2",
|
|
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.
|
|
48
|
+
"@moxxy/sdk": "0.15.2"
|
|
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/
|
|
55
|
+
"@moxxy/core": "0.5.2",
|
|
56
|
+
"@moxxy/mode-default": "0.1.2",
|
|
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 () => {
|
package/src/provider.test.ts
CHANGED
|
@@ -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
|
});
|