@gajae-code/ai 0.13.2 → 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 (81) hide show
  1. package/CHANGELOG.md +61 -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/provider-models/special.d.ts +3 -0
  13. package/dist/types/providers/anthropic.d.ts +31 -0
  14. package/dist/types/providers/cursor.d.ts +9 -1
  15. package/dist/types/providers/kiro-codewhisperer.d.ts +8 -0
  16. package/dist/types/providers/mock.d.ts +8 -0
  17. package/dist/types/providers/register-builtins.d.ts +1 -0
  18. package/dist/types/providers/transform-messages.d.ts +18 -0
  19. package/dist/types/types.d.ts +34 -8
  20. package/dist/types/usage/grok-cli.d.ts +5 -0
  21. package/dist/types/usage.d.ts +6 -0
  22. package/dist/types/utils/discovery/openai-compatible.d.ts +5 -0
  23. package/dist/types/utils/event-stream.d.ts +4 -2
  24. package/dist/types/utils/fallback-transport.d.ts +10 -0
  25. package/dist/types/utils/http-inspector.d.ts +1 -0
  26. package/dist/types/utils/idle-iterator.d.ts +13 -1
  27. package/dist/types/utils/json-parse.d.ts +19 -0
  28. package/dist/types/utils/oauth/callback-server.d.ts +13 -0
  29. package/dist/types/utils/oauth/kiro.d.ts +71 -0
  30. package/dist/types/utils/oauth/types.d.ts +1 -1
  31. package/dist/types/utils/parse-bind.d.ts +8 -5
  32. package/dist/types/utils/tool-call-healing.d.ts +7 -0
  33. package/dist/types/utils/tool-choice-capability.d.ts +11 -0
  34. package/package.json +3 -2
  35. package/src/auth-broker/client.ts +30 -0
  36. package/src/auth-broker/redact.ts +15 -0
  37. package/src/auth-broker/refresher.ts +4 -2
  38. package/src/auth-broker/remote-store.ts +693 -70
  39. package/src/auth-broker/server.ts +57 -12
  40. package/src/auth-broker/types.ts +16 -0
  41. package/src/auth-broker/wire-schemas.ts +21 -0
  42. package/src/auth-gateway/server.ts +84 -19
  43. package/src/auth-storage.ts +985 -41
  44. package/src/core.ts +1 -0
  45. package/src/model-cache.ts +23 -4
  46. package/src/model-manager.ts +70 -11
  47. package/src/model-thinking.ts +45 -1
  48. package/src/models.json +9604 -1932
  49. package/src/openai-completions-compat.ts +2 -1
  50. package/src/provider-models/descriptors.ts +7 -1
  51. package/src/provider-models/openai-compat.ts +52 -28
  52. package/src/provider-models/special.ts +12 -0
  53. package/src/providers/amazon-bedrock.ts +2 -1
  54. package/src/providers/anthropic.ts +831 -27
  55. package/src/providers/cursor.ts +83 -3
  56. package/src/providers/kiro-codewhisperer.ts +572 -0
  57. package/src/providers/mock.ts +15 -2
  58. package/src/providers/ollama.ts +9 -2
  59. package/src/providers/openai-codex-responses.ts +16 -9
  60. package/src/providers/openai-completions.ts +6 -1
  61. package/src/providers/openai-responses-shared.ts +180 -18
  62. package/src/providers/register-builtins.ts +24 -2
  63. package/src/providers/transform-messages.ts +64 -1
  64. package/src/stream.ts +25 -2
  65. package/src/types.ts +36 -7
  66. package/src/usage/grok-cli.ts +86 -1
  67. package/src/usage.ts +7 -0
  68. package/src/utils/discovery/openai-compatible.ts +89 -4
  69. package/src/utils/event-stream.ts +11 -2
  70. package/src/utils/fallback-transport.ts +44 -2
  71. package/src/utils/http-inspector.ts +1 -0
  72. package/src/utils/idle-iterator.ts +29 -6
  73. package/src/utils/json-parse.ts +80 -0
  74. package/src/utils/oauth/callback-server.ts +31 -1
  75. package/src/utils/oauth/index.ts +14 -1
  76. package/src/utils/oauth/kiro.ts +448 -0
  77. package/src/utils/oauth/synthetic.ts +2 -3
  78. package/src/utils/oauth/types.ts +1 -0
  79. package/src/utils/parse-bind.ts +27 -0
  80. package/src/utils/tool-call-healing.ts +13 -2
  81. package/src/utils/tool-choice-capability.ts +386 -6
@@ -73,7 +73,12 @@ import {
73
73
  getStreamIdleTimeoutMs,
74
74
  iterateWithIdleTimeout,
75
75
  } from "../utils/idle-iterator";
76
- import { isCompleteJson, parseJsonWithRepair, parseStreamingJson } from "../utils/json-parse";
76
+ import {
77
+ findUnnecessaryUnicodeEscape,
78
+ isCompleteJson,
79
+ parseJsonWithRepair,
80
+ parseStreamingJson,
81
+ } from "../utils/json-parse";
77
82
  import { parseGitHubCopilotApiKey } from "../utils/oauth/github-copilot";
78
83
  import { notifyProviderResponse } from "../utils/provider-response";
79
84
  import { isCopilotTransientModelError } from "../utils/retry";
@@ -99,7 +104,7 @@ import {
99
104
  hasCopilotVisionInput,
100
105
  resolveGitHubCopilotBaseUrl,
101
106
  } from "./github-copilot-headers";
102
- import { transformMessages } from "./transform-messages";
107
+ import { hasAdjacentPrivateThinkingBlocks, transformMessages } from "./transform-messages";
103
108
  import { NON_VISION_IMAGE_PLACEHOLDER } from "./vision-guard";
104
109
 
105
110
  export type AnthropicHeaderOptions = {
@@ -336,7 +341,18 @@ type AnthropicThinkingReplayRepairScope = "none" | "latest" | "all";
336
341
  * the session rather than a single stream, and only a completed stream re-arms
337
342
  * it — an unacceptable shape never completes, so it can never buy more repairs.
338
343
  */
339
- 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
+ };
340
356
 
341
357
  type AnthropicProviderSessionState = ProviderSessionState & {
342
358
  strictToolsDisabled: boolean;
@@ -344,6 +360,25 @@ type AnthropicProviderSessionState = ProviderSessionState & {
344
360
  generatedCacheBudget: GeneratedCacheBudget;
345
361
  thinkingReplayRepairScope: AnthropicThinkingReplayRepairScope;
346
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;
347
382
  };
348
383
 
349
384
  function createAnthropicProviderSessionState(): AnthropicProviderSessionState {
@@ -359,6 +394,8 @@ function createAnthropicProviderSessionState(): AnthropicProviderSessionState {
359
394
  state.generatedCacheBudget = 2;
360
395
  state.thinkingReplayRepairScope = "none";
361
396
  state.thinkingReplayRepairAttempts = 0;
397
+ state.thinkingReplayRejectedPayload = undefined;
398
+ state.cpaToolAliasSteering = undefined;
362
399
  },
363
400
  };
364
401
  return state;
@@ -487,6 +524,74 @@ export function isAnthropicMaskedProxyRejection(error: unknown): boolean {
487
524
  return /"type"\s*:\s*"api_error"/.test(message) && /an error occurred while processing/i.test(message);
488
525
  }
489
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
+
490
595
  /**
491
596
  * Anthropic rejects a request carrying more than four `cache_control`
492
597
  * breakpoints. An Anthropic-compatible gateway may attach its own block-level
@@ -509,6 +614,132 @@ export function isAnthropicCacheBreakpointOverflowError(error: unknown): boolean
509
614
  return /maximum of \d+ blocks/i.test(message) || /at most \d+ blocks/i.test(message);
510
615
  }
511
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
+
512
743
  function hasStrictAnthropicTools(params: MessageCreateParamsStreaming): boolean {
513
744
  const tools = params.tools as Array<{ strict?: unknown }> | undefined;
514
745
  return tools?.some(tool => tool.strict === true) ?? false;
@@ -1250,6 +1481,131 @@ function getAnthropicCompat(
1250
1481
 
1251
1482
  const PROVIDER_MAX_RETRIES = 3;
1252
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
+ }
1253
1609
 
1254
1610
  /**
1255
1611
  * Check if an error from the Anthropic SDK is a rate-limit/transient error that
@@ -1398,6 +1754,93 @@ export function applyAnthropicUsageExtras(usage: Usage, source: AnthropicUsageLi
1398
1754
  }
1399
1755
  }
1400
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
+
1401
1844
  export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
1402
1845
  model: Model<"anthropic-messages">,
1403
1846
  context: Context,
@@ -1477,6 +1920,9 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
1477
1920
  let strictFallbackErrorMessage: string | undefined;
1478
1921
  let dropFastMode = providerSessionState?.fastModeDisabled ?? false;
1479
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;
1480
1926
  let thinkingReplayRepairScope: AnthropicThinkingReplayRepairScope =
1481
1927
  providerSessionState?.thinkingReplayRepairScope ?? "none";
1482
1928
  let thinkingReplayRepairAttempts = providerSessionState?.thinkingReplayRepairAttempts ?? 0;
@@ -1517,18 +1963,50 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
1517
1963
  if (replacementPayload !== undefined) {
1518
1964
  nextParams = replacementPayload as typeof nextParams;
1519
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
+ }
1520
1978
  validateCacheControls(nextParams as AnthropicCacheParams);
1979
+ return nextParams;
1980
+ };
1981
+ let params = await prepareParams();
1982
+ const setRawRequestDump = (body: MessageCreateParamsStreaming): void => {
1521
1983
  rawRequestDump = {
1522
1984
  provider: model.provider,
1523
1985
  api: output.api,
1524
1986
  model: model.id,
1525
1987
  method: "POST",
1526
1988
  url: `${baseUrl}/v1/messages`,
1527
- body: nextParams,
1989
+ body,
1528
1990
  };
1529
- return nextParams;
1530
1991
  };
1531
- 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
+ }
1532
2010
 
1533
2011
  type Block = (
1534
2012
  | ThinkingContent
@@ -1560,10 +2038,14 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
1560
2038
  if (orphaned.type === "toolCall") {
1561
2039
  if (!isCompleteJson(orphaned.partialJson)) {
1562
2040
  orphaned.incompleteArguments = true;
2041
+ orphaned.incompleteArgumentsReason = "truncated";
1563
2042
  truncatedToolCalls.add(orphaned);
1564
2043
  }
1565
2044
  if (orphaned.partialJson.trim()) {
1566
2045
  orphaned.arguments = parseStreamingJson(orphaned.partialJson);
2046
+ if (findUnnecessaryUnicodeEscape(orphaned.partialJson)) {
2047
+ orphaned.escapedNonAsciiArguments = true;
2048
+ }
1567
2049
  }
1568
2050
  }
1569
2051
  delete (orphaned as { index?: number }).index;
@@ -1590,25 +2072,49 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
1590
2072
  const firstEventFallbackMs = getProviderFirstEventTimeoutFallbackMs(model.provider);
1591
2073
  const firstEventTimeoutMs =
1592
2074
  options?.streamFirstEventTimeoutMs ?? getStreamFirstEventTimeoutMs(idleTimeoutMs, firstEventFallbackMs);
2075
+ const endpointClass = classifyAnthropicEndpoint(options?.client?.baseURL ?? baseUrl);
1593
2076
  stream.push({ type: "start", partial: output });
1594
2077
  // Retry loop for transient errors from the stream.
1595
2078
  // Provider-level transport/rate-limit failures: only before any streamed content starts.
1596
2079
  // Malformed envelopes/JSON: only before replay-unsafe text/tool events are visible on this stream.
1597
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;
1598
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;
1599
2099
  // Retries reset output.content; drop stale block correlations from the aborted attempt.
1600
2100
  blocksByAnthropicIndex.clear();
1601
2101
  truncatedToolCalls.clear();
1602
2102
  sawTerminalStopReason = false;
1603
2103
  activeAbortTracker = createAbortSourceTracker(options?.signal);
1604
- const firstEventTimeoutAbortError = new FirstEventTimeoutError(
1605
- "Anthropic stream timed out while waiting for the first event",
1606
- );
2104
+ let firstEventTimeoutAbortError: FirstEventTimeoutError | undefined;
1607
2105
  const idleTimeoutAbortError = new Error("Anthropic stream stalled while waiting for the next event");
1608
2106
  const { requestSignal } = activeAbortTracker;
1609
- 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
+ );
1610
2115
  let streamedReplayUnsafeContent = false;
1611
2116
  let sawProviderSafetyStop = false;
2117
+ let sawFirstSemanticEvent = false;
1612
2118
 
1613
2119
  try {
1614
2120
  const {
@@ -1621,6 +2127,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
1621
2127
  options?.client ? event => options?.onSseEvent?.(event, model, options?.attemptScope) : undefined,
1622
2128
  );
1623
2129
  await notifyProviderResponse(options, response, model, requestId);
2130
+ firstEventWaitStartedAt = Date.now();
1624
2131
  let sawEvent = false;
1625
2132
  let sawMessageStart = false;
1626
2133
  let sawTerminalEnvelope = false;
@@ -1629,13 +2136,27 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
1629
2136
 
1630
2137
  for await (const event of iterateWithIdleTimeout(anthropicStream, {
1631
2138
  idleTimeoutMs,
1632
- firstItemTimeoutMs: firstEventTimeoutMs,
2139
+ firstItemTimeoutMs: firstEventWatchdogMs,
1633
2140
  errorMessage: idleTimeoutAbortError.message,
1634
- firstItemErrorMessage: firstEventTimeoutAbortError.message,
2141
+ firstItemErrorMessage: "Anthropic stream timed out while waiting for the first event",
1635
2142
  onIdle: () => activeAbortTracker.abortLocally(idleTimeoutAbortError),
1636
- 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
+ },
1637
2153
  abortSignal: options?.signal,
1638
- 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
+ },
1639
2160
  })) {
1640
2161
  sawEvent = true;
1641
2162
  if (sawMessageStop) {
@@ -1835,6 +2356,9 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
1835
2356
  if (!isCompleteJson(block.partialJson)) truncatedToolCalls.add(block);
1836
2357
  if (block.partialJson.trim()) {
1837
2358
  block.arguments = parseStreamingJson(block.partialJson);
2359
+ if (findUnnecessaryUnicodeEscape(block.partialJson)) {
2360
+ block.escapedNonAsciiArguments = true;
2361
+ }
1838
2362
  }
1839
2363
  delete (block as { partialJson?: string }).partialJson;
1840
2364
  stream.push({
@@ -1936,13 +2460,52 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
1936
2460
  providerSessionState.thinkingReplayRepairScope = "none";
1937
2461
  providerSessionState.thinkingReplayRepairAttempts = 0;
1938
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
+ }
1939
2469
  break;
1940
2470
  } catch (streamError) {
1941
2471
  const localAbortReason = activeAbortTracker.getLocalAbortReason();
1942
- 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;
1943
2503
  if (localAbortReason || sawProviderSafetyStop) {
1944
2504
  throw streamFailure;
1945
2505
  }
2506
+ if (firstEventRetryMaxAttempts !== undefined && providerRetryAttempt + 1 >= firstEventRetryMaxAttempts) {
2507
+ throw streamFailure;
2508
+ }
1946
2509
  if (
1947
2510
  !options?.fallbackManaged &&
1948
2511
  !disableStrictTools &&
@@ -1957,6 +2520,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
1957
2520
  disableStrictTools = true;
1958
2521
  params = await prepareParams();
1959
2522
  providerRetryAttempt = 0;
2523
+ providerUploadCount++;
1960
2524
  resetOutputForRetry();
1961
2525
  continue;
1962
2526
  }
@@ -1986,6 +2550,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
1986
2550
  droppedForcedToolChoice = true;
1987
2551
  params = await prepareParams();
1988
2552
  providerRetryAttempt = 0;
2553
+ providerUploadCount++;
1989
2554
  resetOutputForRetry();
1990
2555
  continue;
1991
2556
  }
@@ -1994,7 +2559,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
1994
2559
  const maskedProxyRejection = isAnthropicMaskedProxyRejection(streamFailure);
1995
2560
  if (
1996
2561
  !options?.fallbackManaged &&
1997
- thinkingReplayRepairScope !== "all" &&
2562
+ thinkingReplayRepairScope === "none" &&
1998
2563
  thinkingReplayRepairAttempts < ANTHROPIC_MAX_THINKING_REPAIRS &&
1999
2564
  firstTokenTime === undefined &&
2000
2565
  (thinkingSignatureInvalid ||
@@ -2004,15 +2569,47 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
2004
2569
  // flight there is nothing to repair and the error must surface.
2005
2570
  (maskedProxyRejection && hasNativeThinkingBlocks(params.messages)))
2006
2571
  ) {
2007
- // "cannot be modified" means the cited blocks must be replayed byte for
2008
- // byte, so editing that turn again can never converge — the only recovery
2009
- // is to stop replaying native thinking at all. The invalid-signature 400
2010
- // cites blocks anywhere in history and needs the same full-history scope.
2011
- // Only the unclassifiable masked rejection is worth probing latest-first.
2012
- const nextScope: AnthropicThinkingReplayRepairScope =
2013
- thinkingSignatureInvalid || thinkingBlocksImmutable || thinkingReplayRepairScope === "latest"
2014
- ? "all"
2015
- : "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;
2016
2613
  thinkingReplayRepairAttempts++;
2017
2614
  logger.debug("anthropic: repairing assistant thinking replay after provider rejection", {
2018
2615
  model: model.id,
@@ -2030,13 +2627,73 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
2030
2627
  providerSessionState.thinkingReplayRepairScope = nextScope;
2031
2628
  }
2032
2629
  }
2033
- 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++;
2034
2634
  // The provider retry budget is deliberately NOT reset here: a repair that
2035
2635
  // keeps being rejected must run out instead of renewing the budget it is
2036
2636
  // supposed to consume (issue #4011).
2037
2637
  resetOutputForRetry();
2038
2638
  continue;
2039
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
+ }
2040
2697
  if (
2041
2698
  !options?.fallbackManaged &&
2042
2699
  !dropFastMode &&
@@ -2054,6 +2711,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
2054
2711
  dropFastMode = true;
2055
2712
  params = await prepareParams();
2056
2713
  providerRetryAttempt = 0;
2714
+ providerUploadCount++;
2057
2715
  resetOutputForRetry();
2058
2716
  continue;
2059
2717
  }
@@ -2082,9 +2740,82 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
2082
2740
  generatedCacheBudget = nextBudget;
2083
2741
  params = await prepareParams();
2084
2742
  providerRetryAttempt = 0;
2743
+ providerUploadCount++;
2085
2744
  resetOutputForRetry();
2086
2745
  continue;
2087
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
+ }
2088
2819
  const isTransientEnvelopeFailure =
2089
2820
  isTransientStreamParseError(streamFailure) || isTransientStreamEnvelopeError(streamFailure);
2090
2821
  const canRetryTransientEnvelopeFailure = isTransientEnvelopeFailure && !streamedReplayUnsafeContent;
@@ -2092,12 +2823,15 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
2092
2823
  firstTokenTime === undefined && isProviderRetryableError(streamFailure, model.provider);
2093
2824
  if (
2094
2825
  activeAbortTracker.wasCallerAbort() ||
2826
+ (firstEventRetryMaxAttempts !== undefined &&
2827
+ providerRetryAttempt + 1 >= firstEventRetryMaxAttempts) ||
2095
2828
  providerRetryAttempt >= resolveRetryBudget(options?.streamMaxRetries, PROVIDER_MAX_RETRIES) ||
2096
2829
  (!canRetryTransientEnvelopeFailure && !canRetryProviderFailure)
2097
2830
  ) {
2098
2831
  throw streamFailure;
2099
2832
  }
2100
2833
  providerRetryAttempt++;
2834
+ providerUploadCount++;
2101
2835
  const delayMs = PROVIDER_BASE_DELAY_MS * 2 ** (providerRetryAttempt - 1);
2102
2836
  if (options?.providerRetryWait) {
2103
2837
  await options.providerRetryWait(delayMs, options.signal);
@@ -2114,6 +2848,9 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
2114
2848
  truncatedToolCalls.add(block);
2115
2849
  if (block.partialJson.trim()) {
2116
2850
  block.arguments = parseStreamingJson(block.partialJson);
2851
+ if (findUnnecessaryUnicodeEscape(block.partialJson)) {
2852
+ block.escapedNonAsciiArguments = true;
2853
+ }
2117
2854
  }
2118
2855
  delete (block as { partialJson?: string }).partialJson;
2119
2856
  }
@@ -2123,6 +2860,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
2123
2860
  for (const block of output.content) {
2124
2861
  if (block.type === "toolCall" && truncatedToolCalls.has(block)) {
2125
2862
  block.incompleteArguments = true;
2863
+ block.incompleteArgumentsReason = "truncated";
2126
2864
  }
2127
2865
  }
2128
2866
  }
@@ -2131,6 +2869,23 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
2131
2869
  if (dropFastMode && resolveServiceTier(options?.serviceTier, model.provider) === "priority") {
2132
2870
  output.disabledFeatures = [...(output.disabledFeatures ?? []), "priority"];
2133
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
+ }
2134
2889
  stream.push({ type: "done", reason: output.stopReason, message: output });
2135
2890
  stream.end();
2136
2891
  } catch (error) {
@@ -2143,7 +2898,8 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
2143
2898
  output.errorStatus = extractHttpStatusFromError(localAbortReason ?? error);
2144
2899
  output.transportFailure = transportFailureFacts(localAbortReason ?? error);
2145
2900
  if (output.errorKind !== "provider_safety_stop" || !output.errorMessage) {
2146
- output.errorMessage = localAbortReason?.message ?? (await finalizeErrorMessage(error, rawRequestDump));
2901
+ output.errorMessage =
2902
+ localAbortReason?.message ?? (await finalizeAnthropicErrorMessage(error, rawRequestDump));
2147
2903
  }
2148
2904
  output.errorMessage = rewriteCopilotError(output.errorMessage, error, model.provider);
2149
2905
  output.duration = Date.now() - startTime;
@@ -2863,6 +3619,49 @@ function buildToolResultBlock(model: Model<"anthropic-messages">, msg: ToolResul
2863
3619
  return block;
2864
3620
  }
2865
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
+
2866
3665
  export function convertAnthropicMessages(
2867
3666
  messages: Message[],
2868
3667
  model: Model<"anthropic-messages">,
@@ -3000,6 +3799,11 @@ export function convertAnthropicMessages(
3000
3799
  }
3001
3800
  }
3002
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);
3003
3807
  if (params.length > 0 && params[params.length - 1]?.role === "assistant") {
3004
3808
  params.push({ role: "user", content: "Continue." });
3005
3809
  }