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