mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-02 21:32:10 +03:00
fix(sse): RTK must preserve cache_control-marked tool_result blocks (#4560)
Integrated into release/v3.8.33
This commit is contained in:
committed by
GitHub
parent
66218036fd
commit
3ff08e2e14
@@ -27,6 +27,23 @@ type ToolMeta = { toolName: string; command: string | null };
|
||||
// the build-typescript filter).
|
||||
const SHELL_TOOL_NAME_RE = /\b(bash|shell|terminal|run_command|execute_command|exec|command)\b/;
|
||||
|
||||
/**
|
||||
* A content block (or text sub-block) carrying `cache_control` is an explicit upstream
|
||||
* prompt-cache breakpoint — the provider caches the prefix up to and INCLUDING it. Rewriting
|
||||
* such a block's text invalidates that cached prefix every turn (guaranteed cache miss), the
|
||||
* "provider cache broken" regression once RTK started compressing Anthropic tool_result blocks.
|
||||
* So we preserve any cache_control-marked block byte-for-byte. (#3936: under caching, only ever
|
||||
* preserve more of the prefix — never rewrite a client-declared breakpoint.)
|
||||
*/
|
||||
function hasCacheControlMarker(part: unknown): boolean {
|
||||
return (
|
||||
!!part &&
|
||||
typeof part === "object" &&
|
||||
(part as Record<string, unknown>).cache_control !== undefined &&
|
||||
(part as Record<string, unknown>).cache_control !== null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the shell command + whether to skip RTK filters for a tool result, given the
|
||||
* tool id (OpenAI `tool_call_id` / Anthropic `tool_use_id`) and the lookup built from the
|
||||
@@ -456,6 +473,8 @@ function processToolResultBlocks(
|
||||
let compressed = false;
|
||||
const nextContent = content.map((part) => {
|
||||
if (!isAnthropicToolResultBlock(part)) return part;
|
||||
// The block itself is a cache breakpoint — preserve it byte-for-byte.
|
||||
if (hasCacheControlMarker(part)) return part;
|
||||
const toolUseId = typeof part.tool_use_id === "string" ? part.tool_use_id : null;
|
||||
const { command, skipFilters } = resolveToolMeta(toolUseId, toolCallLookup);
|
||||
const inner = part.content;
|
||||
@@ -473,6 +492,8 @@ function processToolResultBlocks(
|
||||
let blockChanged = false;
|
||||
const nextInner = inner.map((sub) => {
|
||||
if (!isTextBlock(sub) || !sub.text) return sub;
|
||||
// A text sub-block can carry its own cache breakpoint — preserve it byte-for-byte.
|
||||
if (hasCacheControlMarker(sub)) return sub;
|
||||
const processed = processRtkText(sub.text, { config, command, skipFilters });
|
||||
collect(processed);
|
||||
if (!processed.compressed) return sub;
|
||||
|
||||
96
tests/unit/compression/rtk-cache-control-preserve.test.ts
Normal file
96
tests/unit/compression/rtk-cache-control-preserve.test.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { applyRtkCompression } from "../../../open-sse/services/compression/engines/rtk/index.ts";
|
||||
|
||||
// Regression: a tool_result block the client marked with `cache_control` is an explicit
|
||||
// prompt-cache breakpoint — the upstream caches the prefix up to and INCLUDING that block.
|
||||
// RTK (since the Anthropic tool_result support added in v3.8.32) rewrote the block's inner
|
||||
// content, so the cached prefix no longer matched byte-for-byte → guaranteed cache miss at
|
||||
// that breakpoint (reported as "provider cache again broken" after upgrading). Compression
|
||||
// must never alter a block carrying `cache_control`. Mirrors #3936's invariant: under
|
||||
// caching, only ever preserve more of the prefix — never rewrite a declared breakpoint.
|
||||
|
||||
const GIT_STATUS = `On branch main
|
||||
Your branch is up to date with 'origin/main'.
|
||||
|
||||
Changes not staged for commit:
|
||||
(use "git add <file>..." to update what will be committed)
|
||||
(use "git restore <file>..." to discard changes in working directory)
|
||||
modified: src/app.ts
|
||||
modified: src/lib/util.ts
|
||||
|
||||
Untracked files:
|
||||
(use "git add <file>..." to include in what will be committed)
|
||||
src/new.ts
|
||||
|
||||
no changes added to commit (use "git add" and/or "git commit -a")`;
|
||||
|
||||
function anthropicBody(toolResultBlock: Record<string, unknown>) {
|
||||
return {
|
||||
model: "anthropic/claude-sonnet-4",
|
||||
messages: [
|
||||
{ role: "user", content: "run git status" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "tool_use", id: "toolu_1", name: "bash", input: { command: "git status" } },
|
||||
],
|
||||
},
|
||||
{ role: "user", content: [toolResultBlock] },
|
||||
],
|
||||
} as Record<string, unknown>;
|
||||
}
|
||||
|
||||
// Pull the single tool_result block out of message[2].content[0] with a typed shape (no `any`).
|
||||
function toolResultBlock(body: Record<string, unknown>): Record<string, unknown> {
|
||||
const messages = body.messages as Array<{ content: Array<Record<string, unknown>> }>;
|
||||
return messages[2].content[0];
|
||||
}
|
||||
|
||||
test("RTK preserves a tool_result block carrying cache_control byte-for-byte (string content)", () => {
|
||||
const block = {
|
||||
type: "tool_result",
|
||||
tool_use_id: "toolu_1",
|
||||
content: GIT_STATUS,
|
||||
cache_control: { type: "ephemeral" },
|
||||
};
|
||||
const body = anthropicBody(block);
|
||||
const before = JSON.stringify(toolResultBlock(body));
|
||||
|
||||
const res = applyRtkCompression(body, { config: { enabled: true, applyToToolResults: true } });
|
||||
const after = JSON.stringify(toolResultBlock(res.body as Record<string, unknown>));
|
||||
|
||||
// The marked breakpoint block must be untouched — content identical, marker intact.
|
||||
assert.equal(after, before, "cache_control-marked tool_result must not be rewritten");
|
||||
assert.deepEqual(toolResultBlock(res.body as Record<string, unknown>).cache_control, { type: "ephemeral" });
|
||||
});
|
||||
|
||||
test("RTK preserves an inner text sub-block carrying cache_control (array content)", () => {
|
||||
const block = {
|
||||
type: "tool_result",
|
||||
tool_use_id: "toolu_1",
|
||||
content: [{ type: "text", text: GIT_STATUS, cache_control: { type: "ephemeral" } }],
|
||||
};
|
||||
const body = anthropicBody(block);
|
||||
const before = JSON.stringify(toolResultBlock(body));
|
||||
|
||||
const res = applyRtkCompression(body, { config: { enabled: true, applyToToolResults: true } });
|
||||
const after = JSON.stringify(toolResultBlock(res.body as Record<string, unknown>));
|
||||
|
||||
assert.equal(after, before, "cache_control-marked text sub-block must not be rewritten");
|
||||
});
|
||||
|
||||
test("RTK still compresses tool_result blocks WITHOUT cache_control (no over-protection)", () => {
|
||||
const block = {
|
||||
type: "tool_result",
|
||||
tool_use_id: "toolu_1",
|
||||
content: GIT_STATUS,
|
||||
};
|
||||
const body = anthropicBody(block);
|
||||
|
||||
const res = applyRtkCompression(body, { config: { enabled: true, applyToToolResults: true } });
|
||||
const after = toolResultBlock(res.body as Record<string, unknown>).content as string;
|
||||
|
||||
assert.equal(res.compressed, true, "unmarked tool_result should still compress");
|
||||
assert.ok(!after.includes('(use "git add'), "hint lines should be dropped when uncached");
|
||||
});
|
||||
Reference in New Issue
Block a user