Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion artifacts/issue-3670-anthropic-cache-eval.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"url": "https://platform.claude.com/docs/en/build-with-claude/prompt-caching",
"retrievedAt": "2026-07-18",
"providerSourceBlobOid": "a654fcc1d261547c5fa525763058a57cb3dca230",
"providerSourceSha256": "c1fa95dcafdbb5e7f4bcc1863c73656bc0184c6614f7b4ac81f382a1bfacf449",
"providerSourceSha256": "8debae1adf66b013b4ce3b03c396915a4867bf551ac111acd1951827ec461188",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Bind the artifact to the changed provider blob

When this commit is checked out, HEAD:packages/ai/src/providers/anthropic.ts has blob OID f32963c12f37671f2158042ff87382cb9b72e502, but the regenerated artifact still records the parent blob a654fcc1d261547c5fa525763058a57cb3dca230. Consequently, anthropic-cache-eval.integration.test.ts builds an artifact using currentSourceIdentity() and unconditionally fails at expect(artifact).toEqual(derivedArtifact) (and later validateSource) even though the SHA-256 was updated; update providerSourceBlobOid alongside it.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 61588d3 — regenerated the artifact at the committed HEAD so providerSourceBlobOid now records f32963c12f37671f2158042ff87382cb9b72e502 (matching HEAD:packages/ai/src/providers/anthropic.ts). Both cache-eval integration tests pass against the committed identity.

"inputFixtureSha256": "562926fba79f003eff4d45c0da2292ac66d84302e3960b2d7493441ade1f9e04"
},
"derivationCommands": [
Expand Down
4 changes: 4 additions & 0 deletions packages/ai/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## [Unreleased]

### Fixed

- Anthropic thinking-replay repairs no longer resend when the failed request carried no native `thinking`/`redacted_thinking` blocks. The invalid-signature and blocks-immutable 400 arms fired the repair unconditionally, but with nothing to drop the rebuilt replay is byte-identical, so each resend drew the same deterministic rejection and burned the repair budget on no-ops before the error surfaced (up to two wasted full-size round trips; the masked proxy-rejection arm already guarded against this). The repair now requires native thinking blocks in flight for every trigger class, and a rejection that provably cannot be repaired surfaces immediately.

## [0.13.1] - 2026-08-11

### Fixed
Expand Down
15 changes: 9 additions & 6 deletions packages/ai/src/providers/anthropic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1997,12 +1997,15 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
thinkingReplayRepairScope !== "all" &&
thinkingReplayRepairAttempts < ANTHROPIC_MAX_THINKING_REPAIRS &&
firstTokenTime === undefined &&
(thinkingSignatureInvalid ||
thinkingBlocksImmutable ||
// Masked proxy rejection: unclassifiable on its own, so the replayed
// request shape is the evidence. Without signed thinking blocks in
// flight there is nothing to repair and the error must surface.
(maskedProxyRejection && hasNativeThinkingBlocks(params.messages)))
(thinkingSignatureInvalid || thinkingBlocksImmutable || maskedProxyRejection) &&
// The repair can only change the request when native thinking blocks are
// actually in flight. Without them the rebuilt replay is byte-identical,
// so resending would draw the same deterministic rejection and burn the
// repair budget on no-ops — the error must surface instead. (For the
// masked proxy rejection this guard is also the classifier: the body is
// unclassifiable on its own, so the replayed request shape is the only
// evidence a thinking repair is worth attempting.)
hasNativeThinkingBlocks(params.messages)
) {
// "cannot be modified" means the cited blocks must be replayed byte for
// byte, so editing that turn again can never converge — the only recovery
Expand Down
118 changes: 110 additions & 8 deletions packages/ai/test/anthropic-thinking-repair-retry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,92 @@ describe("Anthropic thinking replay repair retry", () => {
expect(requestBodies).toHaveLength(1);
});

// A repair that cannot drop any native thinking block rebuilds a byte-identical
// request, so resending it can only draw the same deterministic 400 again. The
// invalid-signature rejection must surface on the first attempt when no
// thinking blocks are in flight instead of burning the repair budget on no-ops.
it("surfaces an invalid-signature 400 without resending when the request replays no thinking blocks", async () => {
const user: UserMessage = {
role: "user",
content: "first",
timestamp: Date.now(),
};
const assistant: AssistantMessage = {
role: "assistant",
content: [{ type: "text", text: "plain answer" }],
api: "anthropic-messages",
provider: "anthropic",
model: model.id,
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: Date.now(),
};
const context: Context = {
messages: [user, assistant, { ...user, content: "next prompt", timestamp: Date.now() + 1 }],
};

const requestBodies: unknown[] = [];
const create = ((body: unknown) => {
requestBodies.push(body);
return createAnthropicSignatureInvalid400() as never;
}) as unknown as Anthropic["messages"]["create"];
const client = { messages: { create } } as Anthropic;

const result = await streamAnthropic(model, context, { client }).result();

expect(result.stopReason).toBe("error");
expect(result.errorMessage).toContain("signature");
expect(requestBodies).toHaveLength(1);
});

it("surfaces a mutation 400 without resending when the request replays no thinking blocks", async () => {
const user: UserMessage = {
role: "user",
content: "first",
timestamp: Date.now(),
};
const assistant: AssistantMessage = {
role: "assistant",
content: [{ type: "text", text: "plain answer" }],
api: "anthropic-messages",
provider: "anthropic",
model: model.id,
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: Date.now(),
};
const context: Context = {
messages: [user, assistant, { ...user, content: "next prompt", timestamp: Date.now() + 1 }],
};

const requestBodies: unknown[] = [];
const create = ((body: unknown) => {
requestBodies.push(body);
return createAnthropicThinking400() as never;
}) as unknown as Anthropic["messages"]["create"];
const client = { messages: { create } } as Anthropic;

const result = await streamAnthropic(model, context, { client }).result();

expect(result.stopReason).toBe("error");
expect(result.errorMessage).toContain("cannot be modified");
expect(requestBodies).toHaveLength(1);
});

// Real captured session failure (2026-07-29): the mutation 400 says "latest
// assistant message" but cites `messages.1.content.1` — a HISTORICAL turn. The
// provider demands those blocks be replayed verbatim, so a latest-only edit is
Expand Down Expand Up @@ -725,30 +811,46 @@ describe("Anthropic thinking replay repair retry", () => {
tools: [tool],
});

it("keeps thinking repair active when a later forced-tool_choice fallback rebuilds params", async () => {
it("keeps thinking repair active when a later fast-mode fallback rebuilds params", async () => {
// A forced tool_choice can never carry native thinking (prepareParams
// strips it proactively), so the fallback that follows the repair must be
// one that coexists with replayed thinking: fast mode.
const createFastModeUnsupported400 = (): MockAnthropicRequest => ({
async withResponse() {
const error = new Error(
"400 invalid_request_error: 'claude-sonnet-4-6' does not support the `speed` parameter.",
);
(error as { status?: number }).status = 400;
throw error;
},
});
const requestBodies: unknown[] = [];
let attempt = 0;
const create = ((body: unknown) => {
requestBodies.push(body);
attempt += 1;
if (attempt === 1) return createAnthropicSignatureInvalid400() as never;
if (attempt === 2) return createForcedToolChoice400() as never;
if (attempt === 2) return createFastModeUnsupported400() as never;
return createSuccessfulRequest() as never;
}) as unknown as Anthropic["messages"]["create"];
const client = { messages: { create } } as Anthropic;

const result = await streamAnthropic(model, makeContext(), { client, toolChoice: "any" }).result();
const result = await streamAnthropic(model, makeContext(), { client, serviceTier: "priority" }).result();

expect(result.stopReason).toBe("stop");
expect(requestBodies).toHaveLength(3);
// Signature repair activates on attempt 2; the forced-tool_choice fallback
// rebuild (attempt 3) must not reintroduce the dropped signature, and must
// drop the forced tool_choice.
// The rejected first attempt replayed native thinking, so the repair is
// exercised for real, not vacuously.
expect(JSON.stringify(requestBodies[0])).toContain("sig_history");
expect((requestBodies[0] as { speed?: unknown }).speed).toBe("fast");
// Signature repair activates on attempt 2; the fast-mode fallback rebuild
// (attempt 3) must not reintroduce the dropped signature, and must drop
// the speed parameter.
expect(JSON.stringify(requestBodies[1])).not.toContain("sig_history");
expect((requestBodies[1] as { speed?: unknown }).speed).toBe("fast");
const thirdBody = JSON.stringify(requestBodies[2]);
expect(thirdBody).not.toContain("sig_history");
expect(thirdBody).not.toContain("tool_choice");
expect((requestBodies[2] as { tool_choice?: unknown }).tool_choice).toBeUndefined();
expect((requestBodies[2] as { speed?: unknown }).speed).toBeUndefined();
expect(thirdBody).toContain("history answer");
});

Expand Down
Loading