@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/src/provider.ts
CHANGED
|
@@ -10,7 +10,8 @@ import type {
|
|
|
10
10
|
|
|
11
11
|
type MessageStreamParams = Anthropic.Messages.MessageStreamParams;
|
|
12
12
|
type MessageCountTokensParams = Anthropic.Messages.MessageCountTokensParams;
|
|
13
|
-
import {
|
|
13
|
+
import { toFriendlyError } from '@moxxy/sdk';
|
|
14
|
+
import type { AnthropicContentBlock } from './translate.js';
|
|
14
15
|
import { toAnthropicMessages, toAnthropicTools } from './translate.js';
|
|
15
16
|
|
|
16
17
|
export interface AnthropicProviderConfig {
|
|
@@ -97,6 +98,11 @@ export class AnthropicProvider implements LLMProvider {
|
|
|
97
98
|
};
|
|
98
99
|
private oauthToken?: string;
|
|
99
100
|
private oauthExpiresAt?: number;
|
|
101
|
+
// Single in-flight refresh shared by concurrent callers (parallel streams /
|
|
102
|
+
// countTokens near expiry) so the refresh endpoint is hit once and the
|
|
103
|
+
// client is swapped once — a second refresh can rotate/invalidate the token
|
|
104
|
+
// and poison state on providers that rotate refresh tokens.
|
|
105
|
+
private refreshing?: Promise<void>;
|
|
100
106
|
|
|
101
107
|
constructor(config: AnthropicProviderConfig = {}) {
|
|
102
108
|
this.name = config.name ?? 'anthropic';
|
|
@@ -147,13 +153,24 @@ export class AnthropicProvider implements LLMProvider {
|
|
|
147
153
|
await this.refreshOauthNow();
|
|
148
154
|
}
|
|
149
155
|
|
|
150
|
-
/**
|
|
156
|
+
/**
|
|
157
|
+
* Force a token refresh and rebuild the client with the new bearer.
|
|
158
|
+
* Coalesces concurrent calls onto a single in-flight refresh so the endpoint
|
|
159
|
+
* is hit once and the client is swapped once.
|
|
160
|
+
*/
|
|
151
161
|
private async refreshOauthNow(): Promise<void> {
|
|
152
|
-
if (
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
this.
|
|
156
|
-
|
|
162
|
+
if (this.refreshing) return this.refreshing;
|
|
163
|
+
const refresh = this.oauth?.refresh;
|
|
164
|
+
if (!refresh) throw new Error('no refresh callback');
|
|
165
|
+
this.refreshing = (async () => {
|
|
166
|
+
const next = await refresh();
|
|
167
|
+
this.oauthToken = next.token;
|
|
168
|
+
this.oauthExpiresAt = next.expiresAt;
|
|
169
|
+
this.client = this.makeOauthClient(next.token);
|
|
170
|
+
})().finally(() => {
|
|
171
|
+
this.refreshing = undefined;
|
|
172
|
+
});
|
|
173
|
+
return this.refreshing;
|
|
157
174
|
}
|
|
158
175
|
|
|
159
176
|
/**
|
|
@@ -217,10 +234,11 @@ export class AnthropicProvider implements LLMProvider {
|
|
|
217
234
|
const systemParam = this.buildSystemParam(system, cacheSystem, req.system);
|
|
218
235
|
const model = req.model || this.defaultModel;
|
|
219
236
|
|
|
220
|
-
yield { type: 'message_start', model };
|
|
221
|
-
|
|
222
237
|
// In OAuth mode refresh the bearer proactively when it's near expiry, so
|
|
223
238
|
// we don't fire a request on a token we already knew was about to die.
|
|
239
|
+
// Done BEFORE emitting message_start so a turn that never reaches the API
|
|
240
|
+
// (proactive refresh throws) doesn't leave a dangling open message for
|
|
241
|
+
// consumers that pair message_start/message_end.
|
|
224
242
|
if (this.oauth) {
|
|
225
243
|
try {
|
|
226
244
|
await this.ensureFreshOauth();
|
|
@@ -230,6 +248,20 @@ export class AnthropicProvider implements LLMProvider {
|
|
|
230
248
|
}
|
|
231
249
|
}
|
|
232
250
|
|
|
251
|
+
yield { type: 'message_start', model };
|
|
252
|
+
|
|
253
|
+
// Default + clamp max_tokens to the active model's output ceiling. We hold
|
|
254
|
+
// the catalog, so default to the descriptor's `maxOutputTokens` (4096 when
|
|
255
|
+
// unknown) and never forward a caller-supplied value above the ceiling —
|
|
256
|
+
// an over-ceiling request 400s server-side after the whole body is built.
|
|
257
|
+
const ceiling = this.models.find((m) => m.id === model)?.maxOutputTokens;
|
|
258
|
+
const maxTokens =
|
|
259
|
+
req.maxTokens !== undefined
|
|
260
|
+
? ceiling !== undefined
|
|
261
|
+
? Math.min(req.maxTokens, ceiling)
|
|
262
|
+
: req.maxTokens
|
|
263
|
+
: (ceiling ?? 4096);
|
|
264
|
+
|
|
233
265
|
// NARROW cast: `messages`/`tools`/`systemParam` are our hand-rolled
|
|
234
266
|
// Anthropic shapes (e.g. `media_type: string`) which the SDK narrows to
|
|
235
267
|
// literal unions it can't see we never violate. The body is otherwise
|
|
@@ -237,7 +269,7 @@ export class AnthropicProvider implements LLMProvider {
|
|
|
237
269
|
// `temperature` are checked at compile time.
|
|
238
270
|
const requestBody: MessageStreamParams = {
|
|
239
271
|
model,
|
|
240
|
-
max_tokens:
|
|
272
|
+
max_tokens: maxTokens,
|
|
241
273
|
system: systemParam as MessageStreamParams['system'],
|
|
242
274
|
messages: messages as MessageStreamParams['messages'],
|
|
243
275
|
tools: tools as MessageStreamParams['tools'],
|
|
@@ -260,12 +292,18 @@ export class AnthropicProvider implements LLMProvider {
|
|
|
260
292
|
if (effort) body.output_config = { effort };
|
|
261
293
|
}
|
|
262
294
|
|
|
263
|
-
// A 401
|
|
264
|
-
// a single refresh and replay the request
|
|
295
|
+
// A genuine auth 401 arrives before any SSE body, so in OAuth mode we can
|
|
296
|
+
// force a single refresh and replay the request. But `isUnauthorized()`
|
|
297
|
+
// also matches a 401 surfaced MID-stream (token revoked during a long
|
|
298
|
+
// generation, proxy 401 on a chunk); replaying after content already
|
|
299
|
+
// streamed would duplicate text/tool calls into the same turn. Track
|
|
300
|
+
// whether the first attempt produced any output and only replay when it
|
|
301
|
+
// produced none.
|
|
302
|
+
const progress = { produced: false };
|
|
265
303
|
try {
|
|
266
|
-
yield* this.streamOnce(requestBody, req.signal);
|
|
304
|
+
yield* this.streamOnce(requestBody, req.signal, progress);
|
|
267
305
|
} catch (err) {
|
|
268
|
-
if (this.oauth?.refresh && isUnauthorized(err)) {
|
|
306
|
+
if (this.oauth?.refresh && isUnauthorized(err) && !progress.produced) {
|
|
269
307
|
try {
|
|
270
308
|
await this.refreshOauthNow();
|
|
271
309
|
yield* this.streamOnce(requestBody, req.signal);
|
|
@@ -288,6 +326,11 @@ export class AnthropicProvider implements LLMProvider {
|
|
|
288
326
|
private async *streamOnce(
|
|
289
327
|
requestBody: MessageStreamParams,
|
|
290
328
|
signal: AbortSignal | undefined,
|
|
329
|
+
// Set to `true` the moment this attempt yields any content event (anything
|
|
330
|
+
// past message_start). `stream()` reads it to decide whether a 401 is safe
|
|
331
|
+
// to refresh-and-replay (replaying after output already streamed would
|
|
332
|
+
// duplicate text/tool calls). Optional so the replay attempt can omit it.
|
|
333
|
+
progress?: { produced: boolean },
|
|
291
334
|
): AsyncIterable<ProviderEvent> {
|
|
292
335
|
const stream = this.client.messages.stream(
|
|
293
336
|
requestBody,
|
|
@@ -316,6 +359,10 @@ export class AnthropicProvider implements LLMProvider {
|
|
|
316
359
|
// the excess-property check) against a narrower declared type.
|
|
317
360
|
let usage: TokenUsage | undefined;
|
|
318
361
|
|
|
362
|
+
// Set once the stream is fully drained on the happy path; gates the
|
|
363
|
+
// finally-block teardown so we only force-abort on an early exit
|
|
364
|
+
// (abort/throw/consumer abandonment), never on a clean completion.
|
|
365
|
+
let drained = false;
|
|
319
366
|
try {
|
|
320
367
|
for await (const event of stream as AsyncIterable<AnthropicStreamEvent>) {
|
|
321
368
|
if (signal?.aborted) {
|
|
@@ -343,6 +390,7 @@ export class AnthropicProvider implements LLMProvider {
|
|
|
343
390
|
break;
|
|
344
391
|
}
|
|
345
392
|
case 'content_block_start': {
|
|
393
|
+
if (progress) progress.produced = true;
|
|
346
394
|
const block = event.content_block;
|
|
347
395
|
if (block && block.type === 'tool_use') {
|
|
348
396
|
pendingToolUses.set(block.id, { name: block.name, partial: '' });
|
|
@@ -361,6 +409,7 @@ export class AnthropicProvider implements LLMProvider {
|
|
|
361
409
|
break;
|
|
362
410
|
}
|
|
363
411
|
case 'content_block_delta': {
|
|
412
|
+
if (progress) progress.produced = true;
|
|
364
413
|
const delta = event.delta;
|
|
365
414
|
if (!delta) break;
|
|
366
415
|
if (delta.type === 'text_delta' && typeof delta.text === 'string') {
|
|
@@ -370,7 +419,7 @@ export class AnthropicProvider implements LLMProvider {
|
|
|
370
419
|
} else if (delta.type === 'signature_delta' && typeof delta.signature === 'string') {
|
|
371
420
|
pendingThinkingSig += delta.signature;
|
|
372
421
|
} else if (delta.type === 'input_json_delta' && typeof delta.partial_json === 'string') {
|
|
373
|
-
const id = idOfBlock(event, blockIndexToId);
|
|
422
|
+
const id = idOfBlock(event, blockIndexToId, pendingToolUses);
|
|
374
423
|
if (id) {
|
|
375
424
|
const t = pendingToolUses.get(id);
|
|
376
425
|
if (t) {
|
|
@@ -382,40 +431,65 @@ export class AnthropicProvider implements LLMProvider {
|
|
|
382
431
|
break;
|
|
383
432
|
}
|
|
384
433
|
case 'content_block_stop': {
|
|
434
|
+
if (progress) progress.produced = true;
|
|
385
435
|
if (typeof event.index === 'number' && thinkingBlockIndices.has(event.index)) {
|
|
386
436
|
thinkingBlockIndices.delete(event.index);
|
|
387
437
|
if (pendingThinkingSig) yield { type: 'reasoning_signature', signature: pendingThinkingSig };
|
|
388
438
|
pendingThinkingSig = '';
|
|
389
439
|
break;
|
|
390
440
|
}
|
|
391
|
-
const id = idOfBlock(event, blockIndexToId);
|
|
441
|
+
const id = idOfBlock(event, blockIndexToId, pendingToolUses);
|
|
392
442
|
if (id) {
|
|
393
443
|
const t = pendingToolUses.get(id);
|
|
394
444
|
if (t) {
|
|
395
|
-
let parsed: unknown
|
|
445
|
+
let parsed: unknown;
|
|
396
446
|
try {
|
|
397
447
|
parsed = t.partial ? JSON.parse(t.partial) : {};
|
|
398
448
|
} catch {
|
|
399
|
-
|
|
449
|
+
// A truncated/malformed tool-input stream is a real failure, not
|
|
450
|
+
// a valid call with junk args. Surface it as an error (the loop
|
|
451
|
+
// treats a stream-level error as authoritative) and mark the turn
|
|
452
|
+
// `error`, instead of feeding `{ _rawPartial }` into the tool —
|
|
453
|
+
// which erased all signal that the model's call was garbage.
|
|
454
|
+
pendingToolUses.delete(id);
|
|
455
|
+
pruneBlockIndex(blockIndexToId, event.index, id);
|
|
456
|
+
stopReason = 'error';
|
|
457
|
+
yield {
|
|
458
|
+
type: 'error',
|
|
459
|
+
message: `tool_use input JSON was malformed/truncated for ${id}`,
|
|
460
|
+
retryable: false,
|
|
461
|
+
};
|
|
462
|
+
break;
|
|
400
463
|
}
|
|
401
464
|
yield { type: 'tool_use_end', id, input: parsed };
|
|
402
465
|
pendingToolUses.delete(id);
|
|
403
|
-
|
|
466
|
+
pruneBlockIndex(blockIndexToId, event.index, id);
|
|
404
467
|
}
|
|
405
468
|
}
|
|
406
469
|
break;
|
|
407
470
|
}
|
|
408
471
|
case 'message_delta': {
|
|
409
|
-
|
|
472
|
+
// STICKY error: once a malformed/truncated tool-input stream marked
|
|
473
|
+
// the turn `error` at content_block_stop, a trailing message_delta
|
|
474
|
+
// (which a truncated tool-use turn still reports as `tool_use`) must
|
|
475
|
+
// NOT clobber it back to a clean completion — that would re-run the
|
|
476
|
+
// junk tool. Usage numbers below still merge as usual.
|
|
477
|
+
if (event.delta?.stop_reason && stopReason !== 'error') {
|
|
410
478
|
stopReason = mapStopReason(event.delta.stop_reason);
|
|
411
479
|
}
|
|
412
480
|
if (event.usage) {
|
|
413
|
-
//
|
|
414
|
-
//
|
|
481
|
+
// Prefer delta-reported input/cache numbers when present (some
|
|
482
|
+
// streaming modes report or correct them here), but fall back to
|
|
483
|
+
// the message_start values otherwise — mirroring the defensive
|
|
484
|
+
// `?? previous` pattern used for outputTokens.
|
|
485
|
+
const du = event.usage;
|
|
486
|
+
const cacheRead = du.cache_read_input_tokens ?? usage?.cacheReadTokens;
|
|
487
|
+
const cacheCreation = du.cache_creation_input_tokens ?? usage?.cacheCreationTokens;
|
|
415
488
|
usage = {
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
489
|
+
inputTokens: du.input_tokens ?? usage?.inputTokens ?? 0,
|
|
490
|
+
outputTokens: du.output_tokens ?? usage?.outputTokens ?? 0,
|
|
491
|
+
...(cacheRead !== undefined ? { cacheReadTokens: cacheRead } : {}),
|
|
492
|
+
...(cacheCreation !== undefined ? { cacheCreationTokens: cacheCreation } : {}),
|
|
419
493
|
};
|
|
420
494
|
}
|
|
421
495
|
break;
|
|
@@ -424,6 +498,7 @@ export class AnthropicProvider implements LLMProvider {
|
|
|
424
498
|
break;
|
|
425
499
|
}
|
|
426
500
|
}
|
|
501
|
+
drained = true;
|
|
427
502
|
} catch (err) {
|
|
428
503
|
// A cancel surfaces as a thrown AbortError mid-await — report it as the
|
|
429
504
|
// clean terminal 'aborted' event. Every other error propagates so
|
|
@@ -433,6 +508,21 @@ export class AnthropicProvider implements LLMProvider {
|
|
|
433
508
|
return;
|
|
434
509
|
}
|
|
435
510
|
throw err;
|
|
511
|
+
} finally {
|
|
512
|
+
// Guarantee socket teardown independent of whether the AbortSignal
|
|
513
|
+
// propagated into the SDK. On any early exit (abort, throw, or the
|
|
514
|
+
// consumer abandoning the generator — at which point the JS runtime
|
|
515
|
+
// runs this finally) explicitly abort the SDK stream so a half-open
|
|
516
|
+
// HTTP connection can't linger under repeated rapid cancellation.
|
|
517
|
+
if (!drained) {
|
|
518
|
+
const s = stream as unknown as { abort?: () => void; controller?: { abort?: () => void } };
|
|
519
|
+
try {
|
|
520
|
+
s.abort?.();
|
|
521
|
+
s.controller?.abort?.();
|
|
522
|
+
} catch {
|
|
523
|
+
// Best-effort cleanup — a fake/partial stream may expose neither.
|
|
524
|
+
}
|
|
525
|
+
}
|
|
436
526
|
}
|
|
437
527
|
|
|
438
528
|
yield { type: 'message_end', stopReason, usage };
|
|
@@ -465,15 +555,53 @@ export class AnthropicProvider implements LLMProvider {
|
|
|
465
555
|
});
|
|
466
556
|
return result.input_tokens;
|
|
467
557
|
} catch {
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
558
|
+
// Estimate WITHOUT serializing megabytes of base64 into one mega-string:
|
|
559
|
+
// a media block's bytes have nothing to do with its token cost, and
|
|
560
|
+
// stringifying them both spikes memory on a large multimodal history and
|
|
561
|
+
// wildly inflates the estimate. Sum char-lengths of textual content and
|
|
562
|
+
// charge each media block a small fixed allowance instead.
|
|
563
|
+
let chars = systemForCount?.length ?? 0;
|
|
564
|
+
for (const m of messages) {
|
|
565
|
+
for (const block of m.content) {
|
|
566
|
+
chars += estimateBlockChars(block);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
chars += JSON.stringify(tools ?? []).length;
|
|
570
|
+
// Mirror estimateTextTokens (≈4 chars/token) on the accumulated length
|
|
571
|
+
// without ever materializing the concatenated string.
|
|
572
|
+
return Math.ceil(chars / 4);
|
|
473
573
|
}
|
|
474
574
|
}
|
|
475
575
|
}
|
|
476
576
|
|
|
577
|
+
/** Rough per-image/-document token allowance for the offline estimate fallback. */
|
|
578
|
+
const MEDIA_BLOCK_TOKENS = 1500;
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* Char-length contribution of one Anthropic content block for the offline
|
|
582
|
+
* token estimate. Skips the base64 `data` of image/document blocks (charging a
|
|
583
|
+
* fixed token allowance, scaled back to chars) so a multi-MB blob never gets
|
|
584
|
+
* stringified just to be divided by 4.
|
|
585
|
+
*/
|
|
586
|
+
function estimateBlockChars(block: AnthropicContentBlock): number {
|
|
587
|
+
switch (block.type) {
|
|
588
|
+
case 'text':
|
|
589
|
+
return block.text.length;
|
|
590
|
+
case 'tool_use':
|
|
591
|
+
return block.name.length + JSON.stringify(block.input ?? {}).length;
|
|
592
|
+
case 'tool_result':
|
|
593
|
+
return block.content.length;
|
|
594
|
+
case 'thinking':
|
|
595
|
+
return block.thinking.length + block.signature.length;
|
|
596
|
+
case 'redacted_thinking':
|
|
597
|
+
return block.data.length;
|
|
598
|
+
case 'image':
|
|
599
|
+
case 'document':
|
|
600
|
+
// Charge a fixed allowance instead of the base64 byte length.
|
|
601
|
+
return MEDIA_BLOCK_TOKENS * 4;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
477
605
|
interface AnthropicStreamEvent {
|
|
478
606
|
type:
|
|
479
607
|
| 'message_start'
|
|
@@ -508,25 +636,59 @@ interface AnthropicStreamEvent {
|
|
|
508
636
|
signature?: string;
|
|
509
637
|
stop_reason?: string;
|
|
510
638
|
};
|
|
511
|
-
usage?: {
|
|
639
|
+
usage?: {
|
|
640
|
+
input_tokens?: number;
|
|
641
|
+
output_tokens?: number;
|
|
642
|
+
cache_read_input_tokens?: number;
|
|
643
|
+
cache_creation_input_tokens?: number;
|
|
644
|
+
};
|
|
512
645
|
}
|
|
513
646
|
|
|
514
647
|
function idOfBlock(
|
|
515
648
|
event: AnthropicStreamEvent,
|
|
516
649
|
blockIndexToId: Map<number, string>,
|
|
650
|
+
pendingToolUses?: ReadonlyMap<string, unknown>,
|
|
517
651
|
): string | null {
|
|
518
652
|
if (typeof event.index === 'number') {
|
|
519
653
|
return blockIndexToId.get(event.index) ?? null;
|
|
520
654
|
}
|
|
521
655
|
// Fallback when `index` is missing (older SDKs / hand-rolled fakes): only
|
|
522
|
-
// unambiguous when exactly one tool_use is pending
|
|
523
|
-
//
|
|
656
|
+
// unambiguous when exactly one tool_use is pending. `pruneBlockIndex` deletes
|
|
657
|
+
// a finished block's entry on content_block_stop even in an index-less stream,
|
|
658
|
+
// so a stale entry can't linger to falsely satisfy size===1 for a later block;
|
|
659
|
+
// we still require the id to be PENDING as a belt-and-suspenders guard.
|
|
524
660
|
if (blockIndexToId.size === 1) {
|
|
525
|
-
for (const id of blockIndexToId.values())
|
|
661
|
+
for (const id of blockIndexToId.values()) {
|
|
662
|
+
if (!pendingToolUses || pendingToolUses.has(id)) return id;
|
|
663
|
+
}
|
|
526
664
|
}
|
|
527
665
|
return null;
|
|
528
666
|
}
|
|
529
667
|
|
|
668
|
+
/**
|
|
669
|
+
* Remove a finished tool block's index→id mapping. Prefers the numeric index
|
|
670
|
+
* (real Anthropic streams), but falls back to deleting by VALUE (`id`) so an
|
|
671
|
+
* index-less stream doesn't leave a stale entry lingering — which would break
|
|
672
|
+
* the size===1 fallback for the next serial tool block (its deltas would route
|
|
673
|
+
* nowhere). Index-less streams therefore stay correct for strictly-serial tools.
|
|
674
|
+
*/
|
|
675
|
+
function pruneBlockIndex(
|
|
676
|
+
blockIndexToId: Map<number, string>,
|
|
677
|
+
index: number | undefined,
|
|
678
|
+
id: string,
|
|
679
|
+
): void {
|
|
680
|
+
if (typeof index === 'number') {
|
|
681
|
+
blockIndexToId.delete(index);
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
for (const [k, v] of blockIndexToId) {
|
|
685
|
+
if (v === id) {
|
|
686
|
+
blockIndexToId.delete(k);
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
530
692
|
function mapStopReason(s: string): StopReason {
|
|
531
693
|
if (s === 'tool_use') return 'tool_use';
|
|
532
694
|
if (s === 'max_tokens') return 'max_tokens';
|
package/src/translate.test.ts
CHANGED
|
@@ -98,6 +98,58 @@ describe('toAnthropicMessages', () => {
|
|
|
98
98
|
]);
|
|
99
99
|
expect(messages[0]!.content[0]!.cache_control).toBeUndefined();
|
|
100
100
|
});
|
|
101
|
+
|
|
102
|
+
it('joins all text blocks of a multi-block system message (does not drop the rest)', () => {
|
|
103
|
+
const { system } = toAnthropicMessages([
|
|
104
|
+
{
|
|
105
|
+
role: 'system',
|
|
106
|
+
content: [
|
|
107
|
+
{ type: 'text', text: 'first' },
|
|
108
|
+
{ type: 'text', text: 'second' },
|
|
109
|
+
],
|
|
110
|
+
},
|
|
111
|
+
]);
|
|
112
|
+
expect(system).toBe('first\n\nsecond');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('degrades an unsupported image media type to a text placeholder rather than forwarding bytes', () => {
|
|
116
|
+
const { messages } = toAnthropicMessages([
|
|
117
|
+
{
|
|
118
|
+
role: 'user',
|
|
119
|
+
content: [{ type: 'image', mediaType: 'image/tiff', data: 'AAAA' }],
|
|
120
|
+
},
|
|
121
|
+
]);
|
|
122
|
+
const block = messages[0]!.content[0]!;
|
|
123
|
+
expect(block.type).toBe('text');
|
|
124
|
+
expect((block as { text: string }).text).toContain('image/tiff');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('degrades an unsupported document media type to a text placeholder', () => {
|
|
128
|
+
const { messages } = toAnthropicMessages([
|
|
129
|
+
{
|
|
130
|
+
role: 'user',
|
|
131
|
+
content: [{ type: 'document', mediaType: 'application/zip', data: 'AAAA' }],
|
|
132
|
+
},
|
|
133
|
+
]);
|
|
134
|
+
const block = messages[0]!.content[0]!;
|
|
135
|
+
expect(block.type).toBe('text');
|
|
136
|
+
expect((block as { text: string }).text).toContain('application/zip');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('drops an unsigned/unredacted reasoning block instead of leaking it as assistant text', () => {
|
|
140
|
+
const { messages } = toAnthropicMessages([
|
|
141
|
+
{
|
|
142
|
+
role: 'assistant',
|
|
143
|
+
content: [
|
|
144
|
+
{ type: 'reasoning', text: 'secret chain of thought' },
|
|
145
|
+
{ type: 'text', text: 'answer' },
|
|
146
|
+
],
|
|
147
|
+
},
|
|
148
|
+
]);
|
|
149
|
+
// The reasoning block is dropped; only the visible answer survives.
|
|
150
|
+
expect(messages[0]!.content).toHaveLength(1);
|
|
151
|
+
expect(messages[0]!.content[0]).toMatchObject({ type: 'text', text: 'answer' });
|
|
152
|
+
});
|
|
101
153
|
});
|
|
102
154
|
|
|
103
155
|
describe('toAnthropicTools', () => {
|
package/src/translate.ts
CHANGED
|
@@ -3,6 +3,15 @@ import { zodToJsonSchema } from '@moxxy/sdk';
|
|
|
3
3
|
|
|
4
4
|
type CacheControl = { type: 'ephemeral' };
|
|
5
5
|
|
|
6
|
+
/** Media types Anthropic's Messages API accepts for `image`/`document` blocks. */
|
|
7
|
+
const IMAGE_MEDIA_TYPES: ReadonlySet<string> = new Set([
|
|
8
|
+
'image/png',
|
|
9
|
+
'image/jpeg',
|
|
10
|
+
'image/gif',
|
|
11
|
+
'image/webp',
|
|
12
|
+
]);
|
|
13
|
+
const DOCUMENT_MEDIA_TYPES: ReadonlySet<string> = new Set(['application/pdf']);
|
|
14
|
+
|
|
6
15
|
export interface AnthropicMessageInput {
|
|
7
16
|
role: 'user' | 'assistant';
|
|
8
17
|
content: Array<AnthropicContentBlock>;
|
|
@@ -77,16 +86,22 @@ export function toAnthropicMessages(
|
|
|
77
86
|
const wantCache = cacheIdx?.has(i) ?? false;
|
|
78
87
|
|
|
79
88
|
if (msg.role === 'system') {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
89
|
+
// Join ALL text blocks of a system message (not just the first) so a
|
|
90
|
+
// multi-block system prompt isn't silently truncated; mirrors the
|
|
91
|
+
// cross-message join below.
|
|
92
|
+
const text = msg.content
|
|
93
|
+
.filter((c): c is Extract<ContentBlock, { type: 'text' }> => c.type === 'text')
|
|
94
|
+
.map((c) => c.text)
|
|
95
|
+
.join('\n\n');
|
|
96
|
+
if (text) {
|
|
97
|
+
system = system ? `${system}\n\n${text}` : text;
|
|
83
98
|
}
|
|
84
99
|
return;
|
|
85
100
|
}
|
|
86
101
|
|
|
87
102
|
if (msg.role === 'user') {
|
|
88
103
|
flushUser();
|
|
89
|
-
const content = msg.content
|
|
104
|
+
const content = mapBlocks(msg.content);
|
|
90
105
|
if (wantCache) markCache(content[content.length - 1]);
|
|
91
106
|
out.push({ role: 'user', content });
|
|
92
107
|
return;
|
|
@@ -94,7 +109,7 @@ export function toAnthropicMessages(
|
|
|
94
109
|
|
|
95
110
|
if (msg.role === 'assistant') {
|
|
96
111
|
flushUser();
|
|
97
|
-
const content = msg.content
|
|
112
|
+
const content = mapBlocks(msg.content);
|
|
98
113
|
if (wantCache) markCache(content[content.length - 1]);
|
|
99
114
|
out.push({ role: 'assistant', content });
|
|
100
115
|
return;
|
|
@@ -123,7 +138,17 @@ export function toAnthropicMessages(
|
|
|
123
138
|
return { system, messages: out };
|
|
124
139
|
}
|
|
125
140
|
|
|
126
|
-
|
|
141
|
+
/** Translate a message's blocks, dropping any that translate to nothing. */
|
|
142
|
+
function mapBlocks(blocks: ReadonlyArray<ContentBlock>): AnthropicContentBlock[] {
|
|
143
|
+
const out: AnthropicContentBlock[] = [];
|
|
144
|
+
for (const b of blocks) {
|
|
145
|
+
const t = toAnthropicBlock(b);
|
|
146
|
+
if (t) out.push(t);
|
|
147
|
+
}
|
|
148
|
+
return out;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function toAnthropicBlock(block: ContentBlock): AnthropicContentBlock | null {
|
|
127
152
|
switch (block.type) {
|
|
128
153
|
case 'text':
|
|
129
154
|
return { type: 'text', text: block.text };
|
|
@@ -137,12 +162,29 @@ function toAnthropicBlock(block: ContentBlock): AnthropicContentBlock {
|
|
|
137
162
|
is_error: block.isError,
|
|
138
163
|
};
|
|
139
164
|
case 'image':
|
|
165
|
+
// Reject unsupported media types locally (degrade to a placeholder, as
|
|
166
|
+
// audio does) rather than uploading bytes that 400 deep in the SDK. A
|
|
167
|
+
// resumed session or hostile channel can supply an out-of-allow-list
|
|
168
|
+
// type; the documented Anthropic image set is png/jpeg/gif/webp.
|
|
169
|
+
if (!IMAGE_MEDIA_TYPES.has(block.mediaType)) {
|
|
170
|
+
return {
|
|
171
|
+
type: 'text',
|
|
172
|
+
text: `[image attachment dropped: ${block.mediaType} not a supported image type]`,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
140
175
|
return {
|
|
141
176
|
type: 'image',
|
|
142
177
|
source: { type: 'base64', media_type: block.mediaType, data: block.data },
|
|
143
178
|
};
|
|
144
179
|
case 'document':
|
|
145
180
|
// Native document support (PDF). Claude reads text + figures/layout.
|
|
181
|
+
// Documented Anthropic document set is application/pdf only.
|
|
182
|
+
if (!DOCUMENT_MEDIA_TYPES.has(block.mediaType)) {
|
|
183
|
+
return {
|
|
184
|
+
type: 'text',
|
|
185
|
+
text: `[document attachment dropped: ${block.mediaType} not a supported document type]`,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
146
188
|
return {
|
|
147
189
|
type: 'document',
|
|
148
190
|
source: { type: 'base64', media_type: block.mediaType, data: block.data },
|
|
@@ -161,15 +203,19 @@ function toAnthropicBlock(block: ContentBlock): AnthropicContentBlock {
|
|
|
161
203
|
case 'reasoning':
|
|
162
204
|
// Replayed verbatim so Anthropic accepts an interleaved-thinking tool-use
|
|
163
205
|
// continuation. `redacted` → the opaque encrypted blob; otherwise the
|
|
164
|
-
// signed thinking block
|
|
165
|
-
//
|
|
206
|
+
// signed thinking block. Drop an unsigned/unredacted reasoning block
|
|
207
|
+
// rather than degrading it to a stray assistant text block: Anthropic
|
|
208
|
+
// rejects an unsigned thinking block, and emitting it as text could leak
|
|
209
|
+
// raw reasoning into assistant output or 400 a tool-use turn. The
|
|
210
|
+
// translator stays self-consistent without depending on the upstream
|
|
211
|
+
// projection invariant (maintained in a different package).
|
|
166
212
|
if (block.redacted && block.encrypted) {
|
|
167
213
|
return { type: 'redacted_thinking', data: block.encrypted };
|
|
168
214
|
}
|
|
169
215
|
if (block.signature) {
|
|
170
216
|
return { type: 'thinking', thinking: block.text, signature: block.signature };
|
|
171
217
|
}
|
|
172
|
-
return
|
|
218
|
+
return null;
|
|
173
219
|
}
|
|
174
220
|
}
|
|
175
221
|
|
package/src/validate.test.ts
CHANGED
|
@@ -40,4 +40,33 @@ describe('anthropic validateKey', () => {
|
|
|
40
40
|
const args = create.mock.calls[0]?.[0] as Record<string, unknown>;
|
|
41
41
|
expect(args.model).toBe('claude-opus-4-7');
|
|
42
42
|
});
|
|
43
|
+
|
|
44
|
+
it('maps a 401 to a fixed friendly message (no raw SDK text echoed)', async () => {
|
|
45
|
+
const make = () => ({
|
|
46
|
+
messages: {
|
|
47
|
+
create: async () => {
|
|
48
|
+
throw Object.assign(new Error('GET https://proxy.internal/v1 401 x-api-key=...'), { status: 401 });
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
const res = await validateKey('sk-ant-some-long-enough-key', { client: make });
|
|
53
|
+
expect(res).toEqual({ ok: false, message: 'key was rejected' });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('scrubs the key from a fallback (status-less) error message', async () => {
|
|
57
|
+
const key = 'sk-ant-secret-but-long-enough';
|
|
58
|
+
const make = () => ({
|
|
59
|
+
messages: {
|
|
60
|
+
create: async () => {
|
|
61
|
+
throw new Error(`request failed with key ${key}`);
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
const res = await validateKey(key, { client: make });
|
|
66
|
+
expect(res.ok).toBe(false);
|
|
67
|
+
if (!res.ok) {
|
|
68
|
+
expect(res.message).not.toContain(key);
|
|
69
|
+
expect(res.message).toContain('[redacted]');
|
|
70
|
+
}
|
|
71
|
+
});
|
|
43
72
|
});
|
package/src/validate.ts
CHANGED
|
@@ -28,10 +28,29 @@ export async function validateKey(key: string, deps: ValidateKeyDeps = {}): Prom
|
|
|
28
28
|
});
|
|
29
29
|
return { ok: true };
|
|
30
30
|
} catch (err) {
|
|
31
|
-
return { ok: false, message: err
|
|
31
|
+
return { ok: false, message: friendlyValidationError(err, key) };
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Map a key-validation failure to a fixed friendly string by HTTP status, never
|
|
37
|
+
* echoing raw SDK error text (which can embed request/proxy URLs or header
|
|
38
|
+
* fragments) verbatim. The fallback is truncated and any occurrence of the key
|
|
39
|
+
* is scrubbed so a reflected error can't leak it into setup UIs / logs.
|
|
40
|
+
*/
|
|
41
|
+
function friendlyValidationError(err: unknown, key: string): string {
|
|
42
|
+
const status = (err as { status?: unknown } | null | undefined)?.status;
|
|
43
|
+
if (typeof status === 'number') {
|
|
44
|
+
if (status === 401) return 'key was rejected';
|
|
45
|
+
if (status === 403) return 'key lacks access';
|
|
46
|
+
if (status === 429) return 'rate limited — try again shortly';
|
|
47
|
+
if (status >= 500) return 'Anthropic returned a server error';
|
|
48
|
+
}
|
|
49
|
+
const raw = err instanceof Error ? err.message : String(err);
|
|
50
|
+
const scrubbed = key ? raw.split(key).join('[redacted]') : raw;
|
|
51
|
+
return scrubbed.length > 200 ? `${scrubbed.slice(0, 200)}…` : scrubbed;
|
|
52
|
+
}
|
|
53
|
+
|
|
35
54
|
function defaultMaker(apiKey: string): { messages: { create: (args: Record<string, unknown>) => Promise<unknown> } } {
|
|
36
55
|
return new Anthropic({ apiKey }) as unknown as {
|
|
37
56
|
messages: { create: (args: Record<string, unknown>) => Promise<unknown> };
|