@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.
- package/CHANGELOG.md +61 -2
- package/dist/types/auth-broker/client.d.ts +9 -1
- package/dist/types/auth-broker/redact.d.ts +7 -0
- package/dist/types/auth-broker/remote-store.d.ts +50 -9
- package/dist/types/auth-broker/types.d.ts +14 -0
- package/dist/types/auth-broker/wire-schemas.d.ts +25 -0
- package/dist/types/auth-storage.d.ts +200 -6
- package/dist/types/core.d.ts +1 -0
- package/dist/types/model-cache.d.ts +4 -1
- package/dist/types/model-manager.d.ts +11 -0
- package/dist/types/provider-models/openai-compat.d.ts +5 -0
- package/dist/types/provider-models/special.d.ts +3 -0
- package/dist/types/providers/anthropic.d.ts +31 -0
- package/dist/types/providers/cursor.d.ts +9 -1
- package/dist/types/providers/kiro-codewhisperer.d.ts +8 -0
- package/dist/types/providers/mock.d.ts +8 -0
- package/dist/types/providers/register-builtins.d.ts +1 -0
- package/dist/types/providers/transform-messages.d.ts +18 -0
- package/dist/types/types.d.ts +34 -8
- package/dist/types/usage/grok-cli.d.ts +5 -0
- package/dist/types/usage.d.ts +6 -0
- package/dist/types/utils/discovery/openai-compatible.d.ts +5 -0
- package/dist/types/utils/event-stream.d.ts +4 -2
- package/dist/types/utils/fallback-transport.d.ts +10 -0
- package/dist/types/utils/http-inspector.d.ts +1 -0
- package/dist/types/utils/idle-iterator.d.ts +13 -1
- package/dist/types/utils/json-parse.d.ts +19 -0
- package/dist/types/utils/oauth/callback-server.d.ts +13 -0
- package/dist/types/utils/oauth/kiro.d.ts +71 -0
- package/dist/types/utils/oauth/types.d.ts +1 -1
- package/dist/types/utils/parse-bind.d.ts +8 -5
- package/dist/types/utils/tool-call-healing.d.ts +7 -0
- package/dist/types/utils/tool-choice-capability.d.ts +11 -0
- package/package.json +3 -2
- package/src/auth-broker/client.ts +30 -0
- package/src/auth-broker/redact.ts +15 -0
- package/src/auth-broker/refresher.ts +4 -2
- package/src/auth-broker/remote-store.ts +693 -70
- package/src/auth-broker/server.ts +57 -12
- package/src/auth-broker/types.ts +16 -0
- package/src/auth-broker/wire-schemas.ts +21 -0
- package/src/auth-gateway/server.ts +84 -19
- package/src/auth-storage.ts +985 -41
- package/src/core.ts +1 -0
- package/src/model-cache.ts +23 -4
- package/src/model-manager.ts +70 -11
- package/src/model-thinking.ts +45 -1
- package/src/models.json +9604 -1932
- package/src/openai-completions-compat.ts +2 -1
- package/src/provider-models/descriptors.ts +7 -1
- package/src/provider-models/openai-compat.ts +52 -28
- package/src/provider-models/special.ts +12 -0
- package/src/providers/amazon-bedrock.ts +2 -1
- package/src/providers/anthropic.ts +831 -27
- package/src/providers/cursor.ts +83 -3
- package/src/providers/kiro-codewhisperer.ts +572 -0
- package/src/providers/mock.ts +15 -2
- package/src/providers/ollama.ts +9 -2
- package/src/providers/openai-codex-responses.ts +16 -9
- package/src/providers/openai-completions.ts +6 -1
- package/src/providers/openai-responses-shared.ts +180 -18
- package/src/providers/register-builtins.ts +24 -2
- package/src/providers/transform-messages.ts +64 -1
- package/src/stream.ts +25 -2
- package/src/types.ts +36 -7
- package/src/usage/grok-cli.ts +86 -1
- package/src/usage.ts +7 -0
- package/src/utils/discovery/openai-compatible.ts +89 -4
- package/src/utils/event-stream.ts +11 -2
- package/src/utils/fallback-transport.ts +44 -2
- package/src/utils/http-inspector.ts +1 -0
- package/src/utils/idle-iterator.ts +29 -6
- package/src/utils/json-parse.ts +80 -0
- package/src/utils/oauth/callback-server.ts +31 -1
- package/src/utils/oauth/index.ts +14 -1
- package/src/utils/oauth/kiro.ts +448 -0
- package/src/utils/oauth/synthetic.ts +2 -3
- package/src/utils/oauth/types.ts +1 -0
- package/src/utils/parse-bind.ts +27 -0
- package/src/utils/tool-call-healing.ts +13 -2
- 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 {
|
|
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 =
|
|
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
|
|
1989
|
+
body,
|
|
1528
1990
|
};
|
|
1529
|
-
return nextParams;
|
|
1530
1991
|
};
|
|
1531
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
2139
|
+
firstItemTimeoutMs: firstEventWatchdogMs,
|
|
1633
2140
|
errorMessage: idleTimeoutAbortError.message,
|
|
1634
|
-
firstItemErrorMessage:
|
|
2141
|
+
firstItemErrorMessage: "Anthropic stream timed out while waiting for the first event",
|
|
1635
2142
|
onIdle: () => activeAbortTracker.abortLocally(idleTimeoutAbortError),
|
|
1636
|
-
onFirstItemTimeout: () =>
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
const
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
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 =
|
|
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 =
|
|
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
|
}
|