@gajae-code/ai 0.13.3 → 0.14.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.
Files changed (67) hide show
  1. package/CHANGELOG.md +45 -2
  2. package/dist/types/auth-broker/client.d.ts +9 -1
  3. package/dist/types/auth-broker/redact.d.ts +7 -0
  4. package/dist/types/auth-broker/remote-store.d.ts +50 -9
  5. package/dist/types/auth-broker/types.d.ts +14 -0
  6. package/dist/types/auth-broker/wire-schemas.d.ts +25 -0
  7. package/dist/types/auth-storage.d.ts +200 -6
  8. package/dist/types/core.d.ts +1 -0
  9. package/dist/types/model-cache.d.ts +4 -1
  10. package/dist/types/model-manager.d.ts +11 -0
  11. package/dist/types/provider-models/openai-compat.d.ts +5 -0
  12. package/dist/types/providers/anthropic.d.ts +31 -0
  13. package/dist/types/providers/cursor.d.ts +9 -1
  14. package/dist/types/providers/mock.d.ts +7 -1
  15. package/dist/types/providers/transform-messages.d.ts +18 -0
  16. package/dist/types/types.d.ts +28 -14
  17. package/dist/types/usage/grok-cli.d.ts +5 -0
  18. package/dist/types/usage.d.ts +6 -0
  19. package/dist/types/utils/discovery/openai-compatible.d.ts +5 -0
  20. package/dist/types/utils/event-stream.d.ts +4 -2
  21. package/dist/types/utils/fallback-transport.d.ts +10 -0
  22. package/dist/types/utils/http-inspector.d.ts +1 -0
  23. package/dist/types/utils/idle-iterator.d.ts +13 -1
  24. package/dist/types/utils/oauth/callback-server.d.ts +13 -0
  25. package/dist/types/utils/parse-bind.d.ts +8 -5
  26. package/dist/types/utils/tool-call-healing.d.ts +7 -0
  27. package/dist/types/utils/tool-choice-capability.d.ts +11 -0
  28. package/package.json +3 -2
  29. package/src/auth-broker/client.ts +30 -0
  30. package/src/auth-broker/redact.ts +15 -0
  31. package/src/auth-broker/refresher.ts +4 -2
  32. package/src/auth-broker/remote-store.ts +693 -70
  33. package/src/auth-broker/server.ts +57 -12
  34. package/src/auth-broker/types.ts +16 -0
  35. package/src/auth-broker/wire-schemas.ts +21 -0
  36. package/src/auth-gateway/server.ts +84 -19
  37. package/src/auth-storage.ts +985 -41
  38. package/src/core.ts +1 -0
  39. package/src/model-cache.ts +23 -4
  40. package/src/model-manager.ts +70 -11
  41. package/src/model-thinking.ts +21 -1
  42. package/src/models.json +1733 -392
  43. package/src/provider-models/descriptors.ts +5 -1
  44. package/src/provider-models/openai-compat.ts +52 -28
  45. package/src/providers/amazon-bedrock.ts +2 -1
  46. package/src/providers/anthropic.ts +824 -29
  47. package/src/providers/cursor.ts +83 -3
  48. package/src/providers/mock.ts +13 -3
  49. package/src/providers/ollama.ts +9 -2
  50. package/src/providers/openai-codex-responses.ts +16 -9
  51. package/src/providers/openai-completions.ts +5 -3
  52. package/src/providers/openai-responses-shared.ts +175 -21
  53. package/src/providers/register-builtins.ts +5 -2
  54. package/src/providers/transform-messages.ts +64 -1
  55. package/src/stream.ts +12 -2
  56. package/src/types.ts +28 -13
  57. package/src/usage/grok-cli.ts +86 -1
  58. package/src/usage.ts +7 -0
  59. package/src/utils/discovery/openai-compatible.ts +89 -4
  60. package/src/utils/event-stream.ts +11 -2
  61. package/src/utils/fallback-transport.ts +44 -2
  62. package/src/utils/http-inspector.ts +1 -0
  63. package/src/utils/idle-iterator.ts +29 -6
  64. package/src/utils/oauth/callback-server.ts +31 -1
  65. package/src/utils/parse-bind.ts +27 -0
  66. package/src/utils/tool-call-healing.ts +13 -2
  67. package/src/utils/tool-choice-capability.ts +386 -6
@@ -104,7 +104,7 @@ import {
104
104
  hasCopilotVisionInput,
105
105
  resolveGitHubCopilotBaseUrl,
106
106
  } from "./github-copilot-headers";
107
- import { transformMessages } from "./transform-messages";
107
+ import { hasAdjacentPrivateThinkingBlocks, transformMessages } from "./transform-messages";
108
108
  import { NON_VISION_IMAGE_PLACEHOLDER } from "./vision-guard";
109
109
 
110
110
  export type AnthropicHeaderOptions = {
@@ -341,7 +341,18 @@ type AnthropicThinkingReplayRepairScope = "none" | "latest" | "all";
341
341
  * the session rather than a single stream, and only a completed stream re-arms
342
342
  * it — an unacceptable shape never completes, so it can never buy more repairs.
343
343
  */
344
- const ANTHROPIC_MAX_THINKING_REPAIRS = 2;
344
+ const ANTHROPIC_MAX_THINKING_REPAIRS = 1;
345
+
346
+ type AnthropicPayloadFingerprint = {
347
+ sha256: string;
348
+ bytes: number;
349
+ };
350
+
351
+ type AnthropicThinkingRepairCandidate = {
352
+ scope: Exclude<AnthropicThinkingReplayRepairScope, "none">;
353
+ params: MessageCreateParamsStreaming;
354
+ fingerprint: AnthropicPayloadFingerprint;
355
+ };
345
356
 
346
357
  type AnthropicProviderSessionState = ProviderSessionState & {
347
358
  strictToolsDisabled: boolean;
@@ -349,6 +360,25 @@ type AnthropicProviderSessionState = ProviderSessionState & {
349
360
  generatedCacheBudget: GeneratedCacheBudget;
350
361
  thinkingReplayRepairScope: AnthropicThinkingReplayRepairScope;
351
362
  thinkingReplayRepairAttempts: number;
363
+ thinkingReplayRejectedPayload?: AnthropicPayloadFingerprint;
364
+ /**
365
+ * Managed-mode escalation for the CPA alias-restore failure (issue #4338):
366
+ * corrective steering recorded against one exact turn, applied by the next
367
+ * managed attempt that rebuilds the same turn and released on success.
368
+ */
369
+ cpaToolAliasSteering?: AnthropicCpaToolAliasSteering;
370
+ };
371
+
372
+ type AnthropicCpaToolAliasSteering = {
373
+ /** Corrective steering text appended to the next build of the same turn. */
374
+ message: string;
375
+ /**
376
+ * Fingerprint of the last user message when the failure was recorded. The
377
+ * steering only applies to a rebuild of the same logical turn; a later turn
378
+ * with a different prompt expires it instead of replaying a stale
379
+ * correction.
380
+ */
381
+ turnFingerprint: string;
352
382
  };
353
383
 
354
384
  function createAnthropicProviderSessionState(): AnthropicProviderSessionState {
@@ -364,6 +394,8 @@ function createAnthropicProviderSessionState(): AnthropicProviderSessionState {
364
394
  state.generatedCacheBudget = 2;
365
395
  state.thinkingReplayRepairScope = "none";
366
396
  state.thinkingReplayRepairAttempts = 0;
397
+ state.thinkingReplayRejectedPayload = undefined;
398
+ state.cpaToolAliasSteering = undefined;
367
399
  },
368
400
  };
369
401
  return state;
@@ -492,6 +524,74 @@ export function isAnthropicMaskedProxyRejection(error: unknown): boolean {
492
524
  return /"type"\s*:\s*"api_error"/.test(message) && /an error occurred while processing/i.test(message);
493
525
  }
494
526
 
527
+ function fingerprintAnthropicPayload(params: MessageCreateParamsStreaming): AnthropicPayloadFingerprint {
528
+ const body = JSON.stringify({ ...params, stream: true });
529
+ return {
530
+ sha256: nodeCrypto.createHash("sha256").update(body).digest("hex"),
531
+ bytes: Buffer.byteLength(body),
532
+ };
533
+ }
534
+
535
+ function anthropicPayloadChanged(left: AnthropicPayloadFingerprint, right: AnthropicPayloadFingerprint): boolean {
536
+ return left.bytes !== right.bytes || left.sha256 !== right.sha256;
537
+ }
538
+
539
+ function extractAnthropicCitedContentPath(error: unknown): { messageIndex: number; contentIndex: number } | undefined {
540
+ const message = error instanceof Error ? error.message : String(error);
541
+ const match = /messages\.(\d+)\.content\.(\d+)/i.exec(message);
542
+ if (!match) return undefined;
543
+ return { messageIndex: Number(match[1]), contentIndex: Number(match[2]) };
544
+ }
545
+
546
+ function countNativeThinkingBlocks(content: unknown): number {
547
+ if (!Array.isArray(content)) return 0;
548
+ return content.filter(block => {
549
+ if (!isRecord(block)) return false;
550
+ return block.type === "thinking" || block.type === "redacted_thinking";
551
+ }).length;
552
+ }
553
+
554
+ function describeAnthropicOutgoingPath(error: unknown, params: MessageCreateParamsStreaming): string {
555
+ const cited = extractAnthropicCitedContentPath(error);
556
+ const messages = params.messages;
557
+ let latestAssistantIndex = -1;
558
+ for (let index = messages.length - 1; index >= 0; index--) {
559
+ if (messages[index]?.role === "assistant") {
560
+ latestAssistantIndex = index;
561
+ break;
562
+ }
563
+ }
564
+ const latest = latestAssistantIndex >= 0 ? messages[latestAssistantIndex] : undefined;
565
+ const latestThinking = countNativeThinkingBlocks(latest?.content);
566
+ const latestDescription =
567
+ latest === undefined
568
+ ? "GJC's outgoing request has no assistant message"
569
+ : `GJC's latest outgoing assistant message is messages[${latestAssistantIndex}] with ${latestThinking} native thinking block(s)`;
570
+ if (!cited) return `${latestDescription}; the rejection did not contain a messages.N.content.M path`;
571
+ const outgoing = messages[cited.messageIndex];
572
+ if (!outgoing) {
573
+ return `Anthropic cited messages.${cited.messageIndex}.content.${cited.contentIndex}, but GJC's outgoing request has only ${messages.length} messages; ${latestDescription}`;
574
+ }
575
+ const contentBlocks = Array.isArray(outgoing.content) ? outgoing.content.length : 1;
576
+ return `Anthropic cited messages.${cited.messageIndex}.content.${cited.contentIndex}, but GJC's outgoing messages[${cited.messageIndex}] has role=${outgoing.role} and ${contentBlocks} content block(s); ${latestDescription}`;
577
+ }
578
+
579
+ function createAnthropicThinkingRepairNoopError(
580
+ error: unknown,
581
+ params: MessageCreateParamsStreaming,
582
+ fingerprint: AnthropicPayloadFingerprint,
583
+ capturedDiagnostic?: string,
584
+ ): Error {
585
+ const diagnostic = describeAnthropicOutgoingPath(error, params);
586
+ const terminal = new Error(
587
+ `Anthropic thinking-replay repair was not sent because both latest-assistant and all-assistant transforms produced the same ${fingerprint.bytes}-byte payload (sha256=${fingerprint.sha256}). ${diagnostic}. GJC did not resend the rejected body and did not change thinking mode.${capturedDiagnostic ? `\n${capturedDiagnostic}` : ""}`,
588
+ );
589
+ const status = extractHttpStatusFromError(error);
590
+ if (status !== undefined) (terminal as Error & { status?: number }).status = status;
591
+ (terminal as Error & { anthropicHttp400AlreadyCaptured?: boolean }).anthropicHttp400AlreadyCaptured = true;
592
+ return terminal;
593
+ }
594
+
495
595
  /**
496
596
  * Anthropic rejects a request carrying more than four `cache_control`
497
597
  * breakpoints. An Anthropic-compatible gateway may attach its own block-level
@@ -514,6 +614,132 @@ export function isAnthropicCacheBreakpointOverflowError(error: unknown): boolean
514
614
  return /maximum of \d+ blocks/i.test(message) || /at most \d+ blocks/i.test(message);
515
615
  }
516
616
 
617
+ export type AnthropicContextManagementInjectionDiagnostic = {
618
+ strategy: string;
619
+ message: string;
620
+ captureNote: string;
621
+ };
622
+
623
+ const CLEAR_THINKING_STRATEGY_PATTERN = /\b(clear_thinking_[a-z0-9_-]{1,64})\b/i;
624
+
625
+ function formatAnthropicDiagnosticBaseUrl(requestUrl: unknown): string {
626
+ if (typeof requestUrl !== "string") return "the configured Anthropic base URL";
627
+ try {
628
+ const url = new URL(requestUrl);
629
+ const basePath = url.pathname.replace(/\/v1\/messages\/?$/, "");
630
+ return `${url.origin}${basePath}`;
631
+ } catch {
632
+ return "the configured Anthropic base URL";
633
+ }
634
+ }
635
+
636
+ /**
637
+ * Diagnose a context-management strategy named by an Anthropic 400 but absent
638
+ * from the body GJC sent. This mismatch is evidence of intermediary mutation,
639
+ * not permission to silently enable thinking or retry the request.
640
+ */
641
+ export function diagnoseAnthropicContextManagementInjection(
642
+ error: unknown,
643
+ dump: RawHttpRequestDump | undefined,
644
+ ): AnthropicContextManagementInjectionDiagnostic | undefined {
645
+ if (extractHttpStatusFromError(error) !== 400) return undefined;
646
+ if (dump?.api !== "anthropic-messages" || !isRecord(dump.body)) return undefined;
647
+ if (isAnthropicApiBaseUrl(dump.url)) return undefined;
648
+ if (Object.hasOwn(dump.body, "thinking") || Object.hasOwn(dump.body, "context_management")) return undefined;
649
+
650
+ const errorMessage = error instanceof Error ? error.message : String(error);
651
+ if (!/invalid_request_error/i.test(errorMessage)) return undefined;
652
+ const strategy = CLEAR_THINKING_STRATEGY_PATTERN.exec(errorMessage)?.[1];
653
+ if (!strategy) return undefined;
654
+ if (!/\bstrategy\b/i.test(errorMessage) || !/\bthinking\b/i.test(errorMessage)) return undefined;
655
+
656
+ const baseUrl = formatAnthropicDiagnosticBaseUrl(dump.url);
657
+ return {
658
+ strategy,
659
+ message: [
660
+ `GJC did not send \`thinking\` or \`context_management\`, but the Anthropic 400 names the \`${strategy}\` context-management strategy.`,
661
+ `An intermediary at ${baseUrl} likely injected that strategy into the outgoing request.`,
662
+ "Enable thinking explicitly for this model, or fix/replace the intermediary so it does not add clear-thinking edits to requests without thinking. GJC did not auto-enable thinking or retry because that would change request cost and semantics.",
663
+ ].join("\n"),
664
+ captureNote: `The HTTP 400 references context-management strategy ${strategy}, but this captured outgoing body contains neither thinking nor context_management; an intermediary may have added it after GJC sent the request.`,
665
+ };
666
+ }
667
+
668
+ async function finalizeAnthropicErrorMessage(error: unknown, dump: RawHttpRequestDump | undefined): Promise<string> {
669
+ if (
670
+ error instanceof Error &&
671
+ (error as Error & { anthropicHttp400AlreadyCaptured?: boolean }).anthropicHttp400AlreadyCaptured
672
+ ) {
673
+ return error.message;
674
+ }
675
+ const diagnostic = diagnoseAnthropicContextManagementInjection(error, dump);
676
+ if (diagnostic && dump) {
677
+ dump.diagnostics = {
678
+ ...(dump.diagnostics ?? {}),
679
+ anthropicContextManagement: {
680
+ strategy: diagnostic.strategy,
681
+ note: diagnostic.captureNote,
682
+ },
683
+ };
684
+ }
685
+ const message = await finalizeErrorMessage(error, dump);
686
+ return diagnostic ? `${message}\n\n${diagnostic.message}` : message;
687
+ }
688
+
689
+ /**
690
+ * CPA's Claude-OAuth layer cloaks downstream tool names into
691
+ * `mcp__<server>__<token>_<base>` aliases upstream. When the model emits a
692
+ * tool call whose alias embeds a token that appears nowhere in the request,
693
+ * CPA cannot restore the name and kills the whole stream with an HTTP 500 SSE
694
+ * `error` event instead of forwarding the call (issue #4338). The signature is
695
+ * precise and machine-parseable: it quotes the rejected alias and the failure
696
+ * mode verbatim. Native Anthropic never emits this phrasing, so the text itself
697
+ * is the route gate.
698
+ */
699
+ const CPA_TOOL_ALIAS_RESTORE_PATTERN =
700
+ /cannot restore Claude OAuth MCP tool alias \\?"([^"\\\\]+)\\?": no unique request-local match/i;
701
+
702
+ /**
703
+ * Aliases observed as `mcp__<server>__<token>_<base>` with a 12-character
704
+ * lowercase-alphanumeric token segment (`find` = `yw7zaf6emg3l` in both
705
+ * captured traces). Tolerate 8-16 chars so extraction survives token-length
706
+ * drift while staying out of the base name; a base that itself contains
707
+ * underscores (`todo_write`) is preserved by the trailing `.+`.
708
+ */
709
+ const CPA_TOOL_ALIAS_BASE_PATTERN = /^mcp__[^_]+__[a-z0-9]{8,16}_(.+)$/;
710
+
711
+ export interface CpaToolAliasRestoreFailure {
712
+ /** The rejected tool-call name exactly as CPA quoted it. */
713
+ alias: string;
714
+ /**
715
+ * Base tool name parsed out of the alias (`mcp__<server>__<token>_<base>`
716
+ * → `<base>`), when the alias shape is well-formed. `undefined` for a
717
+ * malformed alias — callers must then fall back to direct discovery and
718
+ * never invent a name.
719
+ */
720
+ baseName?: string;
721
+ }
722
+
723
+ /**
724
+ * Classifies the CPA alias-restore signature and extracts the rejected alias
725
+ * plus its base tool name. Claims only statusless in-stream SSE error events
726
+ * and HTTP 5xx failures: a non-5xx status carrying this text is not the
727
+ * observed CPA delivery shape and is left to the other classifiers.
728
+ */
729
+ export function parseCpaToolAliasRestoreFailure(error: unknown): CpaToolAliasRestoreFailure | undefined {
730
+ const status = extractHttpStatusFromError(error);
731
+ if (status !== undefined && status < 500) return undefined;
732
+ const message = error instanceof Error ? error.message : String(error);
733
+ const match = CPA_TOOL_ALIAS_RESTORE_PATTERN.exec(message);
734
+ if (!match) return undefined;
735
+ const alias = match[1]!;
736
+ return { alias, baseName: CPA_TOOL_ALIAS_BASE_PATTERN.exec(alias)?.[1] };
737
+ }
738
+
739
+ export function isCpaToolAliasRestoreFailure(error: unknown): boolean {
740
+ return parseCpaToolAliasRestoreFailure(error) !== undefined;
741
+ }
742
+
517
743
  function hasStrictAnthropicTools(params: MessageCreateParamsStreaming): boolean {
518
744
  const tools = params.tools as Array<{ strict?: unknown }> | undefined;
519
745
  return tools?.some(tool => tool.strict === true) ?? false;
@@ -1255,6 +1481,131 @@ function getAnthropicCompat(
1255
1481
 
1256
1482
  const PROVIDER_MAX_RETRIES = 3;
1257
1483
  const PROVIDER_BASE_DELAY_MS = 2000;
1484
+ const ANTHROPIC_CUSTOM_ENDPOINT_FIRST_EVENT_GRACE_MS = 120_000;
1485
+ const ANTHROPIC_LARGE_REQUEST_BYTES = 1_000_000;
1486
+ const ANTHROPIC_LARGE_FIRST_EVENT_TIMEOUT_MAX_ATTEMPTS = 1;
1487
+ const ANTHROPIC_SMALL_FIRST_EVENT_TIMEOUT_MAX_ATTEMPTS = 2;
1488
+
1489
+ function classifyAnthropicEndpoint(baseUrl: string): "canonical" | "custom" {
1490
+ try {
1491
+ const url = new URL(baseUrl);
1492
+ return url.protocol.toLowerCase() === "https:" &&
1493
+ url.hostname.toLowerCase() === "api.anthropic.com" &&
1494
+ (url.port === "" || url.port === "443") &&
1495
+ url.username === "" &&
1496
+ url.password === "" &&
1497
+ url.search === "" &&
1498
+ url.hash === "" &&
1499
+ (url.pathname === "" || url.pathname === "/")
1500
+ ? "canonical"
1501
+ : "custom";
1502
+ } catch {
1503
+ return "custom";
1504
+ }
1505
+ }
1506
+
1507
+ function resolveAnthropicFirstEventWatchdogMs(
1508
+ firstEventTimeoutMs: number | undefined,
1509
+ endpointClass: "canonical" | "custom",
1510
+ requestBytes: number,
1511
+ ): number | undefined {
1512
+ if (
1513
+ firstEventTimeoutMs === undefined ||
1514
+ firstEventTimeoutMs <= 0 ||
1515
+ endpointClass === "canonical" ||
1516
+ requestBytes < ANTHROPIC_LARGE_REQUEST_BYTES
1517
+ ) {
1518
+ return firstEventTimeoutMs;
1519
+ }
1520
+ return firstEventTimeoutMs + ANTHROPIC_CUSTOM_ENDPOINT_FIRST_EVENT_GRACE_MS;
1521
+ }
1522
+
1523
+ function resolveAnthropicFirstEventTimeoutMaxAttempts(requestBytes: number): number {
1524
+ return requestBytes >= ANTHROPIC_LARGE_REQUEST_BYTES
1525
+ ? ANTHROPIC_LARGE_FIRST_EVENT_TIMEOUT_MAX_ATTEMPTS
1526
+ : ANTHROPIC_SMALL_FIRST_EVENT_TIMEOUT_MAX_ATTEMPTS;
1527
+ }
1528
+
1529
+ function normalizeStreamFailure(error: unknown): unknown {
1530
+ if (error instanceof Error) return error;
1531
+ if (error !== null && typeof error === "object") {
1532
+ // Structured rejections (e.g. `{ status, error, headers }` from an SDK or
1533
+ // injected client) carry transport metadata downstream classification
1534
+ // reads; wrap them in a mutable Error but copy every enumerable own
1535
+ // property so status/provider-code/header extraction still works.
1536
+ let message: string;
1537
+ try {
1538
+ message = JSON.stringify(error) || String(error);
1539
+ } catch {
1540
+ message = String(error);
1541
+ }
1542
+ const wrapper = new Error(message);
1543
+ Object.assign(wrapper, error as object);
1544
+ return wrapper;
1545
+ }
1546
+ // Primitive rejections (string/number/boolean/null/undefined): wrap with the
1547
+ // same string form downstream matchers already use (String(error)).
1548
+ return new Error(String(error));
1549
+ }
1550
+
1551
+ function attachAnthropicGraceFailureFacts(
1552
+ error: unknown,
1553
+ args: {
1554
+ elapsedMs: number;
1555
+ requestBytes: number;
1556
+ firstEventTimeoutMs: number | undefined;
1557
+ endpointClass: "canonical" | "custom";
1558
+ awaitingFirstEvent: boolean;
1559
+ },
1560
+ ): void {
1561
+ if (
1562
+ !(error instanceof Error) ||
1563
+ !args.awaitingFirstEvent ||
1564
+ args.firstEventTimeoutMs === undefined ||
1565
+ args.firstEventTimeoutMs <= 0 ||
1566
+ args.elapsedMs < args.firstEventTimeoutMs ||
1567
+ args.endpointClass !== "custom" ||
1568
+ args.requestBytes < ANTHROPIC_LARGE_REQUEST_BYTES
1569
+ ) {
1570
+ return;
1571
+ }
1572
+ Object.assign(error, {
1573
+ requestBytes: args.requestBytes,
1574
+ firstEventElapsedMs: args.elapsedMs,
1575
+ firstEventTimeoutMs: args.firstEventTimeoutMs,
1576
+ endpointClass: args.endpointClass,
1577
+ retryMaxAttempts: ANTHROPIC_LARGE_FIRST_EVENT_TIMEOUT_MAX_ATTEMPTS,
1578
+ });
1579
+ }
1580
+
1581
+ function createAnthropicFirstEventTimeoutError(args: {
1582
+ elapsedMs: number;
1583
+ requestBytes: number;
1584
+ firstEventTimeoutMs: number | undefined;
1585
+ endpointClass: "canonical" | "custom";
1586
+ /** Uploads this provider invocation already consumed before the timeout. */
1587
+ providerAttemptsConsumed?: number;
1588
+ }): FirstEventTimeoutError {
1589
+ const totalCeiling = resolveAnthropicFirstEventTimeoutMaxAttempts(args.requestBytes);
1590
+ // The session counts a whole provider invocation as one attempt, so the
1591
+ // ceiling handed up must bound TOTAL uploads across the invocation: subtract
1592
+ // the provider replays already spent inside this invocation. A small request
1593
+ // whose first upload 529'd and whose replay then timed out has already
1594
+ // consumed two uploads; reporting the full two-attempt ceiling would let the
1595
+ // session upload a third time.
1596
+ const retryMaxAttempts = Math.max(1, totalCeiling - (args.providerAttemptsConsumed ?? 0));
1597
+ const timeoutLabel = args.firstEventTimeoutMs === undefined ? "disabled" : `${args.firstEventTimeoutMs}ms`;
1598
+ return new FirstEventTimeoutError(
1599
+ `Anthropic stream timed out while waiting for the first event (elapsed=${args.elapsedMs}ms request_bytes=${args.requestBytes} endpoint=${args.endpointClass} configured_timeout=${timeoutLabel}; override with PI_STREAM_FIRST_EVENT_TIMEOUT_MS)`,
1600
+ {
1601
+ requestBytes: args.requestBytes,
1602
+ firstEventElapsedMs: args.elapsedMs,
1603
+ firstEventTimeoutMs: args.firstEventTimeoutMs,
1604
+ endpointClass: args.endpointClass,
1605
+ retryMaxAttempts,
1606
+ },
1607
+ );
1608
+ }
1258
1609
 
1259
1610
  /**
1260
1611
  * Check if an error from the Anthropic SDK is a rate-limit/transient error that
@@ -1403,6 +1754,93 @@ export function applyAnthropicUsageExtras(usage: Usage, source: AnthropicUsageLi
1403
1754
  }
1404
1755
  }
1405
1756
 
1757
+ /**
1758
+ * Unique request-local tool whose wire name equals the parsed base, if any.
1759
+ * Only an exact, singular match is trusted; zero or multiple matches yield
1760
+ * `undefined` so the repair never guesses among ambiguous aliases.
1761
+ */
1762
+ function resolveCpaCallableToolName(
1763
+ params: MessageCreateParamsStreaming,
1764
+ failure: CpaToolAliasRestoreFailure,
1765
+ ): string | undefined {
1766
+ if (failure.baseName === undefined) return undefined;
1767
+ const tools = params.tools as Array<{ name?: string }> | undefined;
1768
+ if (!tools) return undefined;
1769
+ let match: string | undefined;
1770
+ for (const tool of tools) {
1771
+ if (tool.name !== failure.baseName) continue;
1772
+ if (match !== undefined) return undefined;
1773
+ match = tool.name;
1774
+ }
1775
+ return match;
1776
+ }
1777
+
1778
+ /**
1779
+ * Corrective steering for the one retry after a CPA alias-restore failure. A
1780
+ * provable unique callable name is stated deterministically; otherwise the
1781
+ * model is directed at tool discovery instead of being handed an invented
1782
+ * name, mirroring the agent loop's "not found → discover and activate"
1783
+ * guidance. The rejected alias is echoed verbatim so the model knows which
1784
+ * call was wrong; nothing else from the request is quoted.
1785
+ */
1786
+ function buildCpaToolAliasSteering(failure: CpaToolAliasRestoreFailure, callableToolName?: string): string {
1787
+ const rejected = `Your previous tool call "${failure.alias}" was rejected by the Claude OAuth proxy: the tool name is not callable in this request.`;
1788
+ if (callableToolName !== undefined) {
1789
+ return `${rejected} The callable tool is "${callableToolName}". Call it by exactly that name; do not construct or reconstruct prefixed or aliased tool names.`;
1790
+ }
1791
+ return `${rejected} If you need this capability, call \`search_tool_bm25\` to discover and activate the matching tool, then retry.`;
1792
+ }
1793
+
1794
+ /**
1795
+ * Actionable terminal error for a CPA alias-restore failure that survived the
1796
+ * single corrective attempt. Deliberately statusless: no HTTP status, no
1797
+ * transport facts, and no recognizable status phrase, so neither the provider
1798
+ * generic 5xx retry nor the managed fallback controller re-sends the unchanged
1799
+ * request. Only the rejected alias and the deterministic callable name (when
1800
+ * provable) are quoted — never the request body or headers.
1801
+ */
1802
+ function createCpaToolAliasTerminalError(failure: CpaToolAliasRestoreFailure, callableToolName?: string): Error {
1803
+ const base = `Claude OAuth proxy rejected tool call "${failure.alias}": the proxy cannot restore the Claude OAuth MCP tool alias (no unique request-local match), and the corrective retry was rejected again.`;
1804
+ const guidance =
1805
+ callableToolName !== undefined
1806
+ ? ` The callable tool name is "${callableToolName}"; call it by exactly that name.`
1807
+ : ` No unique callable tool name could be determined; discover the correct tool name before retrying.`;
1808
+ return new Error(`${base}${guidance} The turn was not re-sent.`);
1809
+ }
1810
+
1811
+ /**
1812
+ * Stable identity for the logical turn currently being prompted: the
1813
+ * serialized content of the last user message. Fallback rebuilds of the same
1814
+ * turn keep the same fingerprint; the next user prompt changes it.
1815
+ */
1816
+ function cpaTurnFingerprint(messages: Message[]): string {
1817
+ for (let index = messages.length - 1; index >= 0; index--) {
1818
+ const message = messages[index];
1819
+ if (message.role !== "user") continue;
1820
+ const content = message.content;
1821
+ const serialized = typeof content === "string" ? content : JSON.stringify(content);
1822
+ return `${serialized.length}:${serialized}`;
1823
+ }
1824
+ return "";
1825
+ }
1826
+
1827
+ /**
1828
+ * Append corrective steering as a trailing user turn, preserving role
1829
+ * alternation by merging into the last user message when it is already a user
1830
+ * turn.
1831
+ */
1832
+ function appendCpaSteeringToMessages(params: MessageCreateParamsStreaming, text: string): void {
1833
+ const messages = params.messages as MessageParam[];
1834
+ const last = messages[messages.length - 1];
1835
+ if (last && last.role === "user") {
1836
+ last.content = Array.isArray(last.content)
1837
+ ? [...last.content, { type: "text", text }]
1838
+ : `${last.content}\n\n${text}`;
1839
+ } else {
1840
+ messages.push({ role: "user", content: text });
1841
+ }
1842
+ }
1843
+
1406
1844
  export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
1407
1845
  model: Model<"anthropic-messages">,
1408
1846
  context: Context,
@@ -1482,6 +1920,9 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
1482
1920
  let strictFallbackErrorMessage: string | undefined;
1483
1921
  let dropFastMode = providerSessionState?.fastModeDisabled ?? false;
1484
1922
  let droppedForcedToolChoice = false;
1923
+ // Exactly one corrective retry per request for the CPA alias-restore
1924
+ // signature (issue #4338); recurrence terminalizes instead of resending.
1925
+ let cpaAliasRepairApplied = false;
1485
1926
  let thinkingReplayRepairScope: AnthropicThinkingReplayRepairScope =
1486
1927
  providerSessionState?.thinkingReplayRepairScope ?? "none";
1487
1928
  let thinkingReplayRepairAttempts = providerSessionState?.thinkingReplayRepairAttempts ?? 0;
@@ -1522,18 +1963,50 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
1522
1963
  if (replacementPayload !== undefined) {
1523
1964
  nextParams = replacementPayload as typeof nextParams;
1524
1965
  }
1966
+ // Managed-mode CPA steering (issue #4338): a previous managed attempt of
1967
+ // this exact turn recorded a corrective tool-name message. Apply it only
1968
+ // while the turn is unchanged; a different user prompt expires it so a
1969
+ // stale correction never leaks into a later turn.
1970
+ const cpaSteering = providerSessionState?.cpaToolAliasSteering;
1971
+ if (cpaSteering) {
1972
+ if (cpaSteering.turnFingerprint === cpaTurnFingerprint(context.messages)) {
1973
+ appendCpaSteeringToMessages(nextParams, cpaSteering.message);
1974
+ } else {
1975
+ providerSessionState.cpaToolAliasSteering = undefined;
1976
+ }
1977
+ }
1525
1978
  validateCacheControls(nextParams as AnthropicCacheParams);
1979
+ return nextParams;
1980
+ };
1981
+ let params = await prepareParams();
1982
+ const setRawRequestDump = (body: MessageCreateParamsStreaming): void => {
1526
1983
  rawRequestDump = {
1527
1984
  provider: model.provider,
1528
1985
  api: output.api,
1529
1986
  model: model.id,
1530
1987
  method: "POST",
1531
1988
  url: `${baseUrl}/v1/messages`,
1532
- body: nextParams,
1989
+ body,
1533
1990
  };
1534
- return nextParams;
1535
1991
  };
1536
- let params = await prepareParams();
1992
+ setRawRequestDump(params);
1993
+ const inheritedRejectedPayload = providerSessionState?.thinkingReplayRejectedPayload;
1994
+ if (
1995
+ inheritedRejectedPayload &&
1996
+ thinkingReplayRepairScope !== "none" &&
1997
+ !anthropicPayloadChanged(inheritedRejectedPayload, fingerprintAnthropicPayload(params))
1998
+ ) {
1999
+ throw createAnthropicThinkingRepairNoopError(
2000
+ new Error(
2001
+ "invalid_request_error: persisted Anthropic thinking repair did not change the outgoing payload",
2002
+ ),
2003
+ params,
2004
+ inheritedRejectedPayload,
2005
+ );
2006
+ }
2007
+ if (providerSessionState?.thinkingReplayRejectedPayload) {
2008
+ providerSessionState.thinkingReplayRejectedPayload = undefined;
2009
+ }
1537
2010
 
1538
2011
  type Block = (
1539
2012
  | ThinkingContent
@@ -1565,10 +2038,14 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
1565
2038
  if (orphaned.type === "toolCall") {
1566
2039
  if (!isCompleteJson(orphaned.partialJson)) {
1567
2040
  orphaned.incompleteArguments = true;
2041
+ orphaned.incompleteArgumentsReason = "truncated";
1568
2042
  truncatedToolCalls.add(orphaned);
1569
2043
  }
1570
2044
  if (orphaned.partialJson.trim()) {
1571
2045
  orphaned.arguments = parseStreamingJson(orphaned.partialJson);
2046
+ if (findUnnecessaryUnicodeEscape(orphaned.partialJson)) {
2047
+ orphaned.escapedNonAsciiArguments = true;
2048
+ }
1572
2049
  }
1573
2050
  }
1574
2051
  delete (orphaned as { index?: number }).index;
@@ -1595,25 +2072,49 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
1595
2072
  const firstEventFallbackMs = getProviderFirstEventTimeoutFallbackMs(model.provider);
1596
2073
  const firstEventTimeoutMs =
1597
2074
  options?.streamFirstEventTimeoutMs ?? getStreamFirstEventTimeoutMs(idleTimeoutMs, firstEventFallbackMs);
2075
+ const endpointClass = classifyAnthropicEndpoint(options?.client?.baseURL ?? baseUrl);
1598
2076
  stream.push({ type: "start", partial: output });
1599
2077
  // Retry loop for transient errors from the stream.
1600
2078
  // Provider-level transport/rate-limit failures: only before any streamed content starts.
1601
2079
  // Malformed envelopes/JSON: only before replay-unsafe text/tool events are visible on this stream.
1602
2080
  let providerRetryAttempt = 0;
2081
+ // Total uploads this invocation has spent, including corrective-policy
2082
+ // replays (strict-tool/forced-tool/fast-mode/thinking/CPA) that reset
2083
+ // providerRetryAttempt before `continue`. The timeout ceiling must bound
2084
+ // TOTAL uploads, so it reads this counter, not the resettable one.
2085
+ let providerUploadCount = 0;
1603
2086
  while (true) {
2087
+ let firstEventWaitStartedAt: number | undefined;
2088
+ const requestBytes = fingerprintAnthropicPayload(params).bytes;
2089
+ const firstEventWatchdogMs = resolveAnthropicFirstEventWatchdogMs(
2090
+ firstEventTimeoutMs,
2091
+ endpointClass,
2092
+ requestBytes,
2093
+ );
2094
+ const requestUploadCeilingBound =
2095
+ endpointClass === "custom" &&
2096
+ requestBytes >= ANTHROPIC_LARGE_REQUEST_BYTES &&
2097
+ firstEventTimeoutMs !== undefined &&
2098
+ firstEventTimeoutMs > 0;
1604
2099
  // Retries reset output.content; drop stale block correlations from the aborted attempt.
1605
2100
  blocksByAnthropicIndex.clear();
1606
2101
  truncatedToolCalls.clear();
1607
2102
  sawTerminalStopReason = false;
1608
2103
  activeAbortTracker = createAbortSourceTracker(options?.signal);
1609
- const firstEventTimeoutAbortError = new FirstEventTimeoutError(
1610
- "Anthropic stream timed out while waiting for the first event",
1611
- );
2104
+ let firstEventTimeoutAbortError: FirstEventTimeoutError | undefined;
1612
2105
  const idleTimeoutAbortError = new Error("Anthropic stream stalled while waiting for the next event");
1613
2106
  const { requestSignal } = activeAbortTracker;
1614
- const anthropicRequest = client.messages.create({ ...params, stream: true }, { signal: requestSignal });
2107
+ setRawRequestDump(params);
2108
+ const anthropicRequest = client.messages.create(
2109
+ { ...params, stream: true },
2110
+ {
2111
+ signal: requestSignal,
2112
+ ...(requestUploadCeilingBound ? { maxRetries: 0 } : {}),
2113
+ },
2114
+ );
1615
2115
  let streamedReplayUnsafeContent = false;
1616
2116
  let sawProviderSafetyStop = false;
2117
+ let sawFirstSemanticEvent = false;
1617
2118
 
1618
2119
  try {
1619
2120
  const {
@@ -1626,6 +2127,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
1626
2127
  options?.client ? event => options?.onSseEvent?.(event, model, options?.attemptScope) : undefined,
1627
2128
  );
1628
2129
  await notifyProviderResponse(options, response, model, requestId);
2130
+ firstEventWaitStartedAt = Date.now();
1629
2131
  let sawEvent = false;
1630
2132
  let sawMessageStart = false;
1631
2133
  let sawTerminalEnvelope = false;
@@ -1634,13 +2136,27 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
1634
2136
 
1635
2137
  for await (const event of iterateWithIdleTimeout(anthropicStream, {
1636
2138
  idleTimeoutMs,
1637
- firstItemTimeoutMs: firstEventTimeoutMs,
2139
+ firstItemTimeoutMs: firstEventWatchdogMs,
1638
2140
  errorMessage: idleTimeoutAbortError.message,
1639
- firstItemErrorMessage: firstEventTimeoutAbortError.message,
2141
+ firstItemErrorMessage: "Anthropic stream timed out while waiting for the first event",
1640
2142
  onIdle: () => activeAbortTracker.abortLocally(idleTimeoutAbortError),
1641
- onFirstItemTimeout: () => activeAbortTracker.abortLocally(firstEventTimeoutAbortError),
2143
+ onFirstItemTimeout: () => {
2144
+ firstEventTimeoutAbortError = createAnthropicFirstEventTimeoutError({
2145
+ elapsedMs: Date.now() - (firstEventWaitStartedAt ?? Date.now()),
2146
+ requestBytes,
2147
+ firstEventTimeoutMs,
2148
+ endpointClass,
2149
+ providerAttemptsConsumed: providerUploadCount,
2150
+ });
2151
+ activeAbortTracker.abortLocally(firstEventTimeoutAbortError);
2152
+ },
1642
2153
  abortSignal: options?.signal,
1643
- isProgressItem: isProgressEvent,
2154
+ isProgressItem: event => {
2155
+ if (!isRecord(event) || (!sawMessageStart && event.type !== "message_start")) return false;
2156
+ const progress = isProgressEvent(event);
2157
+ if (progress) sawFirstSemanticEvent = true;
2158
+ return progress;
2159
+ },
1644
2160
  })) {
1645
2161
  sawEvent = true;
1646
2162
  if (sawMessageStop) {
@@ -1840,9 +2356,9 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
1840
2356
  if (!isCompleteJson(block.partialJson)) truncatedToolCalls.add(block);
1841
2357
  if (block.partialJson.trim()) {
1842
2358
  block.arguments = parseStreamingJson(block.partialJson);
1843
- // Raw-wire check: after decode the escape is invisible.
1844
- if (findUnnecessaryUnicodeEscape(block.partialJson))
2359
+ if (findUnnecessaryUnicodeEscape(block.partialJson)) {
1845
2360
  block.escapedNonAsciiArguments = true;
2361
+ }
1846
2362
  }
1847
2363
  delete (block as { partialJson?: string }).partialJson;
1848
2364
  stream.push({
@@ -1944,13 +2460,52 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
1944
2460
  providerSessionState.thinkingReplayRepairScope = "none";
1945
2461
  providerSessionState.thinkingReplayRepairAttempts = 0;
1946
2462
  }
2463
+ // Release a recorded CPA steering once any stream completes: a
2464
+ // successful stream consumed it, and a failed one ends the turn (a
2465
+ // later turn's different user prompt would expire it anyway).
2466
+ if (providerSessionState?.cpaToolAliasSteering) {
2467
+ providerSessionState.cpaToolAliasSteering = undefined;
2468
+ }
1947
2469
  break;
1948
2470
  } catch (streamError) {
1949
2471
  const localAbortReason = activeAbortTracker.getLocalAbortReason();
1950
- const streamFailure = localAbortReason ?? streamError;
2472
+ // Normalize unknown rejections (a primitive string from an injected
2473
+ // custom client is a supported surface) to a mutable Error. Boxed
2474
+ // primitives silently discard every fact stamped below, which let
2475
+ // a ceiling-bound upload slip past the one-attempt ceiling and
2476
+ // string-matched corrective branches re-upload the body.
2477
+ const streamFailure = localAbortReason ?? normalizeStreamFailure(streamError);
2478
+ attachAnthropicGraceFailureFacts(streamFailure, {
2479
+ elapsedMs: Date.now() - (firstEventWaitStartedAt ?? Date.now()),
2480
+ requestBytes,
2481
+ firstEventTimeoutMs,
2482
+ endpointClass,
2483
+ awaitingFirstEvent: !sawFirstSemanticEvent,
2484
+ });
2485
+ // A ceiling-bound upload failed before stream iteration began (for
2486
+ // example an immediate 529 from withResponse()): the grace clock
2487
+ // never started, so the facts above cannot apply, but the one-attempt
2488
+ // upload ceiling must still bound the outer provider retry loop.
2489
+ // Otherwise the multi-megabyte body is re-uploaded up to the default
2490
+ // streamMaxRetries budget despite the ceiling. Once iteration has
2491
+ // begun, only the grace-clock path above decides.
2492
+ if (requestUploadCeilingBound && firstEventWaitStartedAt === undefined) {
2493
+ Object.assign(streamFailure as Error, {
2494
+ requestBytes,
2495
+ endpointClass,
2496
+ retryMaxAttempts: ANTHROPIC_LARGE_FIRST_EVENT_TIMEOUT_MAX_ATTEMPTS,
2497
+ });
2498
+ }
2499
+ const firstEventRetryMaxAttempts =
2500
+ typeof (streamFailure as { retryMaxAttempts?: unknown }).retryMaxAttempts === "number"
2501
+ ? (streamFailure as { retryMaxAttempts: number }).retryMaxAttempts
2502
+ : undefined;
1951
2503
  if (localAbortReason || sawProviderSafetyStop) {
1952
2504
  throw streamFailure;
1953
2505
  }
2506
+ if (firstEventRetryMaxAttempts !== undefined && providerRetryAttempt + 1 >= firstEventRetryMaxAttempts) {
2507
+ throw streamFailure;
2508
+ }
1954
2509
  if (
1955
2510
  !options?.fallbackManaged &&
1956
2511
  !disableStrictTools &&
@@ -1965,6 +2520,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
1965
2520
  disableStrictTools = true;
1966
2521
  params = await prepareParams();
1967
2522
  providerRetryAttempt = 0;
2523
+ providerUploadCount++;
1968
2524
  resetOutputForRetry();
1969
2525
  continue;
1970
2526
  }
@@ -1994,6 +2550,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
1994
2550
  droppedForcedToolChoice = true;
1995
2551
  params = await prepareParams();
1996
2552
  providerRetryAttempt = 0;
2553
+ providerUploadCount++;
1997
2554
  resetOutputForRetry();
1998
2555
  continue;
1999
2556
  }
@@ -2002,7 +2559,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
2002
2559
  const maskedProxyRejection = isAnthropicMaskedProxyRejection(streamFailure);
2003
2560
  if (
2004
2561
  !options?.fallbackManaged &&
2005
- thinkingReplayRepairScope !== "all" &&
2562
+ thinkingReplayRepairScope === "none" &&
2006
2563
  thinkingReplayRepairAttempts < ANTHROPIC_MAX_THINKING_REPAIRS &&
2007
2564
  firstTokenTime === undefined &&
2008
2565
  (thinkingSignatureInvalid ||
@@ -2012,15 +2569,47 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
2012
2569
  // flight there is nothing to repair and the error must surface.
2013
2570
  (maskedProxyRejection && hasNativeThinkingBlocks(params.messages)))
2014
2571
  ) {
2015
- // "cannot be modified" means the cited blocks must be replayed byte for
2016
- // byte, so editing that turn again can never converge — the only recovery
2017
- // is to stop replaying native thinking at all. The invalid-signature 400
2018
- // cites blocks anywhere in history and needs the same full-history scope.
2019
- // Only the unclassifiable masked rejection is worth probing latest-first.
2020
- const nextScope: AnthropicThinkingReplayRepairScope =
2021
- thinkingSignatureInvalid || thinkingBlocksImmutable || thinkingReplayRepairScope === "latest"
2022
- ? "all"
2023
- : "latest";
2572
+ const rejectedFingerprint = fingerprintAnthropicPayload(params);
2573
+ const scopes: Array<Exclude<AnthropicThinkingReplayRepairScope, "none">> = thinkingSignatureInvalid
2574
+ ? ["all"]
2575
+ : ["latest", "all"];
2576
+ let candidate: AnthropicThinkingRepairCandidate | undefined;
2577
+ const transforms: Record<string, unknown> = {};
2578
+ for (const scope of scopes) {
2579
+ thinkingReplayRepairScope = scope;
2580
+ const candidateParams = await prepareParams();
2581
+ const fingerprint = fingerprintAnthropicPayload(candidateParams);
2582
+ const changed = anthropicPayloadChanged(rejectedFingerprint, fingerprint);
2583
+ transforms[scope] = { changed, sha256: fingerprint.sha256, bytes: fingerprint.bytes };
2584
+ if (changed) {
2585
+ candidate = { scope, params: candidateParams, fingerprint };
2586
+ break;
2587
+ }
2588
+ }
2589
+ if (rawRequestDump) {
2590
+ rawRequestDump.diagnostics = {
2591
+ ...(rawRequestDump.diagnostics ?? {}),
2592
+ anthropicThinkingRepair: {
2593
+ rejected: rejectedFingerprint,
2594
+ disposition: candidate ? `send-${candidate.scope}` : "no-op-terminal",
2595
+ transforms,
2596
+ outgoingMismatch: describeAnthropicOutgoingPath(streamFailure, params),
2597
+ },
2598
+ };
2599
+ }
2600
+ const captured = await finalizeAnthropicErrorMessage(streamFailure, rawRequestDump);
2601
+ logger.warn("anthropic: thinking replay rejected; evaluated bounded repair", {
2602
+ model: model.id,
2603
+ disposition: candidate ? `send-${candidate.scope}` : "no-op-terminal",
2604
+ rejectedSha256: rejectedFingerprint.sha256,
2605
+ rejectedBytes: rejectedFingerprint.bytes,
2606
+ diagnostic: captured,
2607
+ });
2608
+ if (!candidate) {
2609
+ thinkingReplayRepairScope = "none";
2610
+ throw createAnthropicThinkingRepairNoopError(streamFailure, params, rejectedFingerprint, captured);
2611
+ }
2612
+ const nextScope = candidate.scope;
2024
2613
  thinkingReplayRepairAttempts++;
2025
2614
  logger.debug("anthropic: repairing assistant thinking replay after provider rejection", {
2026
2615
  model: model.id,
@@ -2038,13 +2627,73 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
2038
2627
  providerSessionState.thinkingReplayRepairScope = nextScope;
2039
2628
  }
2040
2629
  }
2041
- params = await prepareParams();
2630
+ params = candidate.params;
2631
+ // The corrective replay uploads the repaired body: count it so the
2632
+ // first-event timeout ceiling bounds TOTAL uploads (issue #4464).
2633
+ providerUploadCount++;
2042
2634
  // The provider retry budget is deliberately NOT reset here: a repair that
2043
2635
  // keeps being rejected must run out instead of renewing the budget it is
2044
2636
  // supposed to consume (issue #4011).
2045
2637
  resetOutputForRetry();
2046
2638
  continue;
2047
2639
  }
2640
+ // Managed attempts never take the repair branch above: the fallback
2641
+ // controller owns retries, so the provider must not retry inside the
2642
+ // attempt it was handed. That left the repair unreachable for the
2643
+ // coding agent, which prompts exclusively through managed attempts —
2644
+ // every turn rebuilt the same replay from the same history, drew the
2645
+ // same deterministic 400, and the session never converged (issue
2646
+ // #4262: one rejected 1.3 MB request every 12s, indefinitely).
2647
+ // Recording the escalation costs no round trip and keeps the retry
2648
+ // boundary intact: the next managed attempt builds a repaired replay.
2649
+ // The masked `api_error` stays out — it names no cause and may be a
2650
+ // transient blip, so only a rejection that provably indicts the
2651
+ // replayed thinking blocks may cost the session its native replay.
2652
+ if (
2653
+ options?.fallbackManaged &&
2654
+ providerSessionState &&
2655
+ providerSessionState.thinkingReplayRepairScope !== "all" &&
2656
+ firstTokenTime === undefined &&
2657
+ (thinkingSignatureInvalid || thinkingBlocksImmutable) &&
2658
+ hasNativeThinkingBlocks(params.messages)
2659
+ ) {
2660
+ const rejectedFingerprint = fingerprintAnthropicPayload(params);
2661
+ thinkingReplayRepairScope = "all";
2662
+ const candidateParams = await prepareParams();
2663
+ const candidateFingerprint = fingerprintAnthropicPayload(candidateParams);
2664
+ const changed = anthropicPayloadChanged(rejectedFingerprint, candidateFingerprint);
2665
+ if (rawRequestDump) {
2666
+ rawRequestDump.diagnostics = {
2667
+ ...(rawRequestDump.diagnostics ?? {}),
2668
+ anthropicThinkingRepair: {
2669
+ rejected: rejectedFingerprint,
2670
+ disposition: changed ? "record-all-for-managed-retry" : "no-op-terminal",
2671
+ transforms: {
2672
+ all: { changed, sha256: candidateFingerprint.sha256, bytes: candidateFingerprint.bytes },
2673
+ },
2674
+ outgoingMismatch: describeAnthropicOutgoingPath(streamFailure, params),
2675
+ },
2676
+ };
2677
+ }
2678
+ const captured = await finalizeAnthropicErrorMessage(streamFailure, rawRequestDump);
2679
+ logger.warn("anthropic: managed thinking replay rejected; evaluated repair", {
2680
+ model: model.id,
2681
+ disposition: changed ? "record-all-for-managed-retry" : "no-op-terminal",
2682
+ rejectedSha256: rejectedFingerprint.sha256,
2683
+ rejectedBytes: rejectedFingerprint.bytes,
2684
+ diagnostic: captured,
2685
+ });
2686
+ if (!changed) {
2687
+ thinkingReplayRepairScope = "none";
2688
+ throw createAnthropicThinkingRepairNoopError(streamFailure, params, rejectedFingerprint, captured);
2689
+ }
2690
+ logger.debug("anthropic: recording thinking replay repair for the next managed attempt", {
2691
+ model: model.id,
2692
+ error: streamFailure instanceof Error ? streamFailure.message : String(streamFailure),
2693
+ });
2694
+ providerSessionState.thinkingReplayRepairScope = "all";
2695
+ providerSessionState.thinkingReplayRejectedPayload = rejectedFingerprint;
2696
+ }
2048
2697
  if (
2049
2698
  !options?.fallbackManaged &&
2050
2699
  !dropFastMode &&
@@ -2062,6 +2711,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
2062
2711
  dropFastMode = true;
2063
2712
  params = await prepareParams();
2064
2713
  providerRetryAttempt = 0;
2714
+ providerUploadCount++;
2065
2715
  resetOutputForRetry();
2066
2716
  continue;
2067
2717
  }
@@ -2090,9 +2740,82 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
2090
2740
  generatedCacheBudget = nextBudget;
2091
2741
  params = await prepareParams();
2092
2742
  providerRetryAttempt = 0;
2743
+ providerUploadCount++;
2093
2744
  resetOutputForRetry();
2094
2745
  continue;
2095
2746
  }
2747
+ // CPA (Claude-OAuth proxy) alias-restore failure (issue #4338): the
2748
+ // proxy 500s the whole stream because the model emitted a cloaked
2749
+ // `mcp__<server>__<token>_<base>` tool name whose random token segment
2750
+ // matches nothing in the request. The generic 5xx retry below would
2751
+ // blindly re-send the unchanged request and re-sample the same drift;
2752
+ // instead, correct the request exactly once and terminalize on
2753
+ // recurrence. The narrow CPA phrase is the route gate, so native
2754
+ // Anthropic and non-CPA proxies are untouched.
2755
+ const cpaAliasFailure = parseCpaToolAliasRestoreFailure(streamFailure);
2756
+ if (cpaAliasFailure && firstTokenTime === undefined) {
2757
+ if (options?.fallbackManaged) {
2758
+ // The managed fallback controller owns retries: never retry
2759
+ // inside the attempt it handed us. Record the corrective
2760
+ // steering against this exact turn and surface the raw error;
2761
+ // the controller's next attempt rebuilds the request with the
2762
+ // steering. Without shared session state there is nowhere to
2763
+ // record, so fall through to the controller unchanged.
2764
+ if (!providerSessionState) throw streamFailure;
2765
+ const turnFingerprint = cpaTurnFingerprint(context.messages);
2766
+ if (providerSessionState.cpaToolAliasSteering?.turnFingerprint === turnFingerprint) {
2767
+ // The steering was already applied to this attempt and the proxy
2768
+ // rejected again: the single corrective attempt is spent. Surface
2769
+ // an actionable terminal error instead of another unchanged
2770
+ // resend.
2771
+ throw createCpaToolAliasTerminalError(
2772
+ cpaAliasFailure,
2773
+ resolveCpaCallableToolName(params, cpaAliasFailure),
2774
+ );
2775
+ }
2776
+ providerSessionState.cpaToolAliasSteering = {
2777
+ message: buildCpaToolAliasSteering(
2778
+ cpaAliasFailure,
2779
+ resolveCpaCallableToolName(params, cpaAliasFailure),
2780
+ ),
2781
+ turnFingerprint,
2782
+ };
2783
+ logger.debug("anthropic: recording CPA tool alias steering for the next managed attempt", {
2784
+ model: model.id,
2785
+ alias: cpaAliasFailure.alias,
2786
+ baseName: cpaAliasFailure.baseName,
2787
+ error: streamFailure instanceof Error ? streamFailure.message : String(streamFailure),
2788
+ });
2789
+ throw streamFailure;
2790
+ }
2791
+ if (!cpaAliasRepairApplied) {
2792
+ cpaAliasRepairApplied = true;
2793
+ logger.debug("anthropic: repairing CPA tool alias restore failure with corrective steering", {
2794
+ model: model.id,
2795
+ alias: cpaAliasFailure.alias,
2796
+ baseName: cpaAliasFailure.baseName,
2797
+ error: streamFailure instanceof Error ? streamFailure.message : String(streamFailure),
2798
+ });
2799
+ appendCpaSteeringToMessages(
2800
+ params,
2801
+ buildCpaToolAliasSteering(cpaAliasFailure, resolveCpaCallableToolName(params, cpaAliasFailure)),
2802
+ );
2803
+ // Exactly one corrective attempt per request: the provider retry
2804
+ // budget is deliberately NOT reset, so a persistent failure runs
2805
+ // out instead of renewing the budget it is supposed to consume
2806
+ // (issue #4011), and the recurrence branch below terminalizes
2807
+ // before the generic 5xx retry can re-send the unchanged request.
2808
+ // This corrective replay uploads the steered body: count it so the
2809
+ // first-event timeout ceiling bounds TOTAL uploads (issue #4464).
2810
+ providerUploadCount++;
2811
+ resetOutputForRetry();
2812
+ continue;
2813
+ }
2814
+ throw createCpaToolAliasTerminalError(
2815
+ cpaAliasFailure,
2816
+ resolveCpaCallableToolName(params, cpaAliasFailure),
2817
+ );
2818
+ }
2096
2819
  const isTransientEnvelopeFailure =
2097
2820
  isTransientStreamParseError(streamFailure) || isTransientStreamEnvelopeError(streamFailure);
2098
2821
  const canRetryTransientEnvelopeFailure = isTransientEnvelopeFailure && !streamedReplayUnsafeContent;
@@ -2100,12 +2823,15 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
2100
2823
  firstTokenTime === undefined && isProviderRetryableError(streamFailure, model.provider);
2101
2824
  if (
2102
2825
  activeAbortTracker.wasCallerAbort() ||
2826
+ (firstEventRetryMaxAttempts !== undefined &&
2827
+ providerRetryAttempt + 1 >= firstEventRetryMaxAttempts) ||
2103
2828
  providerRetryAttempt >= resolveRetryBudget(options?.streamMaxRetries, PROVIDER_MAX_RETRIES) ||
2104
2829
  (!canRetryTransientEnvelopeFailure && !canRetryProviderFailure)
2105
2830
  ) {
2106
2831
  throw streamFailure;
2107
2832
  }
2108
2833
  providerRetryAttempt++;
2834
+ providerUploadCount++;
2109
2835
  const delayMs = PROVIDER_BASE_DELAY_MS * 2 ** (providerRetryAttempt - 1);
2110
2836
  if (options?.providerRetryWait) {
2111
2837
  await options.providerRetryWait(delayMs, options.signal);
@@ -2122,7 +2848,9 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
2122
2848
  truncatedToolCalls.add(block);
2123
2849
  if (block.partialJson.trim()) {
2124
2850
  block.arguments = parseStreamingJson(block.partialJson);
2125
- if (findUnnecessaryUnicodeEscape(block.partialJson)) block.escapedNonAsciiArguments = true;
2851
+ if (findUnnecessaryUnicodeEscape(block.partialJson)) {
2852
+ block.escapedNonAsciiArguments = true;
2853
+ }
2126
2854
  }
2127
2855
  delete (block as { partialJson?: string }).partialJson;
2128
2856
  }
@@ -2132,6 +2860,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
2132
2860
  for (const block of output.content) {
2133
2861
  if (block.type === "toolCall" && truncatedToolCalls.has(block)) {
2134
2862
  block.incompleteArguments = true;
2863
+ block.incompleteArgumentsReason = "truncated";
2135
2864
  }
2136
2865
  }
2137
2866
  }
@@ -2140,6 +2869,23 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
2140
2869
  if (dropFastMode && resolveServiceTier(options?.serviceTier, model.provider) === "priority") {
2141
2870
  output.disabledFeatures = [...(output.disabledFeatures ?? []), "priority"];
2142
2871
  }
2872
+ // Defense-in-depth (#4443): when the provider stream assembles an
2873
+ // assistant message whose content carries directly adjacent private
2874
+ // blocks, emit a bounded diagnostic naming only the envelope shape —
2875
+ // block count and adjacent-pair presence — never raw thinking text,
2876
+ // signatures, or redacted payloads. The send-boundary collapse
2877
+ // remains the wire source of truth; this is a read-only observation.
2878
+ // Scoped to this stream invocation: each completed turn with the
2879
+ // defect is a distinct upstream producer worth surfacing, so the
2880
+ // diagnostic is not latched across invocations.
2881
+ if (hasAdjacentPrivateThinkingBlocks(output.content)) {
2882
+ logger.warn("anthropic: stream assembled assistant content with adjacent thinking blocks", {
2883
+ model: model.id,
2884
+ provider: model.provider,
2885
+ contentBlockCount: output.content.length,
2886
+ hasAdjacentPrivateBlocks: true,
2887
+ });
2888
+ }
2143
2889
  stream.push({ type: "done", reason: output.stopReason, message: output });
2144
2890
  stream.end();
2145
2891
  } catch (error) {
@@ -2152,7 +2898,8 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
2152
2898
  output.errorStatus = extractHttpStatusFromError(localAbortReason ?? error);
2153
2899
  output.transportFailure = transportFailureFacts(localAbortReason ?? error);
2154
2900
  if (output.errorKind !== "provider_safety_stop" || !output.errorMessage) {
2155
- output.errorMessage = localAbortReason?.message ?? (await finalizeErrorMessage(error, rawRequestDump));
2901
+ output.errorMessage =
2902
+ localAbortReason?.message ?? (await finalizeAnthropicErrorMessage(error, rawRequestDump));
2156
2903
  }
2157
2904
  output.errorMessage = rewriteCopilotError(output.errorMessage, error, model.provider);
2158
2905
  output.duration = Date.now() - startTime;
@@ -2872,6 +3619,49 @@ function buildToolResultBlock(model: Model<"anthropic-messages">, msg: ToolResul
2872
3619
  return block;
2873
3620
  }
2874
3621
 
3622
+ /**
3623
+ * Anthropic rejects a replayed assistant message containing adjacent
3624
+ * `thinking`/`redacted_thinking` blocks — even when each block individually
3625
+ * carries a valid signature — with a 400 citing the second block: "cannot be
3626
+ * modified. These blocks must remain as in the original response." (issue #4416)
3627
+ *
3628
+ * The adjacency can originate from the provider stream (two `content_block_start`
3629
+ * events for `thinking` in one message with no intervening `tool_use`), from a
3630
+ * history mutation that removed a separating `tool_use`, or from an earlier
3631
+ * conversion phase in `convertAnthropicMessages` that skipped an empty `text`
3632
+ * block sitting between two thinking blocks. Because that last path exists, the
3633
+ * invariant cannot be enforced in the shared `transformMessages` phase — it must
3634
+ * run on the final wire output.
3635
+ *
3636
+ * This collapses each run of adjacent `thinking`/`redacted_thinking` blocks down
3637
+ * to the first block, preserving its bytes, signature, and type verbatim (never
3638
+ * concatenating, editing, synthesizing, or choosing the last). Blocks separated
3639
+ * by any non-thinking block (`text`, `tool_use`, …) are legitimate
3640
+ * interleaved-thinking shape and pass through unchanged. `thinking` and
3641
+ * `redacted_thinking` are treated as one adjacency class per the API contract.
3642
+ *
3643
+ * The pass is O(n) per message and idempotent: an already-collapsed array is a
3644
+ * no-op, so re-runs through `convertAnthropicMessages` (e.g. forced-tool-choice
3645
+ * or unreplayable-thinking rebuilds) are safe.
3646
+ */
3647
+ function collapseAdjacentThinkingBlocks(messages: MessageParam[]): void {
3648
+ for (const message of messages) {
3649
+ if (message.role !== "assistant" || !Array.isArray(message.content)) continue;
3650
+ const content = message.content;
3651
+ let write = 0;
3652
+ let inThinkingRun = false;
3653
+ for (let read = 0; read < content.length; read++) {
3654
+ const block = content[read];
3655
+ if (block === undefined) continue;
3656
+ const isThinkingBlock = block.type === "thinking" || block.type === "redacted_thinking";
3657
+ if (isThinkingBlock && inThinkingRun) continue; // only the first block of a run survives
3658
+ inThinkingRun = isThinkingBlock;
3659
+ content[write++] = block;
3660
+ }
3661
+ if (write < content.length) content.length = write;
3662
+ }
3663
+ }
3664
+
2875
3665
  export function convertAnthropicMessages(
2876
3666
  messages: Message[],
2877
3667
  model: Model<"anthropic-messages">,
@@ -3009,6 +3799,11 @@ export function convertAnthropicMessages(
3009
3799
  }
3010
3800
  }
3011
3801
 
3802
+ // Final send-time invariant (issue #4416): collapse any run of adjacent
3803
+ // `thinking`/`redacted_thinking` blocks within one assistant message down to
3804
+ // the first block. This runs on the wire output because earlier phases here
3805
+ // (e.g. skipping empty text blocks) can themselves create the adjacency.
3806
+ collapseAdjacentThinkingBlocks(params);
3012
3807
  if (params.length > 0 && params[params.length - 1]?.role === "assistant") {
3013
3808
  params.push({ role: "user", content: "Continue." });
3014
3809
  }