@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/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 { estimateTextTokens, toFriendlyError } from '@moxxy/sdk';
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
- /** Force a token refresh and rebuild the client with the new bearer. */
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 (!this.oauth?.refresh) throw new Error('no refresh callback');
153
- const next = await this.oauth.refresh();
154
- this.oauthToken = next.token;
155
- this.oauthExpiresAt = next.expiresAt;
156
- this.client = this.makeOauthClient(next.token);
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: req.maxTokens ?? 4096,
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 always arrives before any SSE body, so in OAuth mode we can force
264
- // a single refresh and replay the request with no risk of duplicate output.
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
- parsed = { _rawPartial: t.partial };
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
- if (typeof event.index === 'number') blockIndexToId.delete(event.index);
466
+ pruneBlockIndex(blockIndexToId, event.index, id);
404
467
  }
405
468
  }
406
469
  break;
407
470
  }
408
471
  case 'message_delta': {
409
- if (event.delta?.stop_reason) {
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
- // Preserve cache fields captured at message_start — the delta
414
- // usage only carries the final output_tokens count.
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
- ...usage,
417
- inputTokens: usage?.inputTokens ?? 0,
418
- outputTokens: event.usage.output_tokens ?? usage?.outputTokens ?? 0,
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
- const blob =
469
- (systemForCount ?? '') +
470
- messages.map((m) => JSON.stringify(m.content)).join('') +
471
- JSON.stringify(tools ?? []);
472
- return estimateTextTokens(blob);
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?: { output_tokens?: number };
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; otherwise refuse to
523
- // guess and let the delta drop rather than misroute it.
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()) return id;
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';
@@ -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
- const textBlock = msg.content.find((c) => c.type === 'text');
81
- if (textBlock && textBlock.type === 'text') {
82
- system = system ? `${system}\n\n${textBlock.text}` : textBlock.text;
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.map(toAnthropicBlock);
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.map(toAnthropicBlock);
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
- function toAnthropicBlock(block: ContentBlock): AnthropicContentBlock {
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 (projection only forwards reasoning that carries a
165
- // signature or encrypted blob, so the text fallback below is unreachable).
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 { type: 'text', text: block.text };
218
+ return null;
173
219
  }
174
220
  }
175
221
 
@@ -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 instanceof Error ? err.message : String(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> };