diff --git a/src/dedup.test.ts b/src/dedup.test.ts new file mode 100644 index 00000000..59ea9635 --- /dev/null +++ b/src/dedup.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect } from "vitest"; +import { RequestDeduplicator } from "./dedup.js"; + +describe("RequestDeduplicator.hash", () => { + it("produces the same key for retries that only differ by injected timestamp (string content)", () => { + const body1 = Buffer.from( + JSON.stringify({ + model: "blockrun/auto", + messages: [{ role: "user", content: "[Mon 2024-01-15 10:30 PST] hello" }], + }), + ); + const body2 = Buffer.from( + JSON.stringify({ + model: "blockrun/auto", + messages: [{ role: "user", content: "[Mon 2024-01-15 10:31 PST] hello" }], + }), + ); + + expect(RequestDeduplicator.hash(body1)).toBe(RequestDeduplicator.hash(body2)); + }); + + it("produces the same key for retries of Anthropic-style array content blocks", () => { + // Vision/multimodal messages send content as [{type: "text", text}, {type: "image_url", ...}] + // instead of a plain string. OpenClaw injects a fresh timestamp into the leading text + // block on every retry — without stripping it there, a timed-out request that gets + // retried never dedupes against the original and can be paid for twice. + const body1 = Buffer.from( + JSON.stringify({ + model: "blockrun/auto", + messages: [ + { + role: "user", + content: [ + { type: "text", text: "[Mon 2024-01-15 10:30 PST] what's in this image?" }, + { type: "image_url", image_url: { url: "https://example.com/a.png" } }, + ], + }, + ], + }), + ); + const body2 = Buffer.from( + JSON.stringify({ + model: "blockrun/auto", + messages: [ + { + role: "user", + content: [ + { type: "text", text: "[Mon 2024-01-15 10:31 PST] what's in this image?" }, + { type: "image_url", image_url: { url: "https://example.com/a.png" } }, + ], + }, + ], + }), + ); + + expect(RequestDeduplicator.hash(body1)).toBe(RequestDeduplicator.hash(body2)); + }); + + it("preserves timestamp-shaped prefixes in non-leading text blocks (user data, not injected)", () => { + // OpenClaw only injects into the FIRST text block. A bracketed timestamp at the + // start of a later text block is the user's own content (e.g. a pasted log line) — + // stripping it would make two genuinely different requests collide on one key, + // wrongly deduping a distinct paid request. + const mk = (day: string) => + Buffer.from( + JSON.stringify({ + model: "blockrun/auto", + messages: [ + { + role: "user", + content: [ + { type: "text", text: "[Mon 2024-01-15 10:30 PST] explain this log line" }, + { type: "text", text: `[${day} 2024-01-16 09:15 UTC] connection refused` }, + ], + }, + ], + }), + ); + + expect(RequestDeduplicator.hash(mk("Tue"))).not.toBe(RequestDeduplicator.hash(mk("Wed"))); + }); + + it("still produces different keys when array content actually differs", () => { + const body1 = Buffer.from( + JSON.stringify({ + model: "blockrun/auto", + messages: [ + { + role: "user", + content: [{ type: "text", text: "[Mon 2024-01-15 10:30 PST] describe image A" }], + }, + ], + }), + ); + const body2 = Buffer.from( + JSON.stringify({ + model: "blockrun/auto", + messages: [ + { + role: "user", + content: [{ type: "text", text: "[Mon 2024-01-15 10:30 PST] describe image B" }], + }, + ], + }), + ); + + expect(RequestDeduplicator.hash(body1)).not.toBe(RequestDeduplicator.hash(body2)); + }); +}); diff --git a/src/dedup.ts b/src/dedup.ts index b6b0aa12..7bb942b6 100644 --- a/src/dedup.ts +++ b/src/dedup.ts @@ -7,6 +7,8 @@ import { createHash } from "node:crypto"; +import { TIMESTAMP_PATTERN, stripLeadingTextBlockTimestamp } from "./timestamp-strip.js"; + export type CachedResponse = { status: number; headers: Record; @@ -46,8 +48,6 @@ function canonicalize(obj: unknown): unknown { * * This ensures requests with different timestamps but same content hash identically. */ -const TIMESTAMP_PATTERN = /^\[\w{3}\s+\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}\s+\w+\]\s*/; - function stripTimestamps(obj: unknown): unknown { if (obj === null || typeof obj !== "object") { return obj; @@ -58,8 +58,16 @@ function stripTimestamps(obj: unknown): unknown { const result: Record = {}; for (const [key, value] of Object.entries(obj as Record)) { if (key === "content" && typeof value === "string") { - // Strip timestamp prefix from message content + // Strip timestamp prefix from plain-string message content result[key] = value.replace(TIMESTAMP_PATTERN, ""); + } else if (key === "content" && Array.isArray(value)) { + // Anthropic-style content blocks (e.g. [{type: "text", text: "..."}, {type: "image_url", ...}]). + // OpenClaw injects its timestamp into the FIRST text block only — the plain-string + // branch above never fires for these, so without this the injected timestamp stays + // in the hash input and breaks dedup on every retry of a multimodal message. + // Later text blocks are user data; a bracketed timestamp there must be preserved + // so genuinely different requests keep different keys. + result[key] = stripLeadingTextBlockTimestamp(value.map(stripTimestamps)); } else { result[key] = stripTimestamps(value); } diff --git a/src/response-cache.test.ts b/src/response-cache.test.ts index baa8db4a..cf880882 100644 --- a/src/response-cache.test.ts +++ b/src/response-cache.test.ts @@ -79,6 +79,77 @@ describe("ResponseCache", () => { expect(ResponseCache.generateKey(body1)).toBe(ResponseCache.generateKey(body2)); }); + it("should strip timestamps from Anthropic-style array content blocks", () => { + // Multimodal/vision messages use content: [{type: "text", text}, ...] instead + // of a plain string. A retried request gets a fresh injected timestamp on the + // leading text block each time — the key must still match or every retry of a + // vision message misses the cache and gets billed as a brand-new request. + const body1 = JSON.stringify({ + model: "gpt-4", + messages: [ + { + role: "user", + content: [ + { type: "text", text: "[Mon 2024-01-15 10:30 PST] describe this" }, + { type: "image_url", image_url: { url: "https://example.com/a.png" } }, + ], + }, + ], + }); + const body2 = JSON.stringify({ + model: "gpt-4", + messages: [ + { + role: "user", + content: [ + { type: "text", text: "[Mon 2024-01-15 10:31 PST] describe this" }, + { type: "image_url", image_url: { url: "https://example.com/a.png" } }, + ], + }, + ], + }); + + expect(ResponseCache.generateKey(body1)).toBe(ResponseCache.generateKey(body2)); + }); + + it("should preserve timestamp-shaped prefixes in non-leading text blocks", () => { + // Only the first text block carries the injected timestamp; a bracketed + // timestamp starting a later block is user data. Stripping it would give two + // different requests the same cache key and serve the wrong cached response. + const mk = (day: string) => + JSON.stringify({ + model: "gpt-4", + messages: [ + { + role: "user", + content: [ + { type: "text", text: "[Mon 2024-01-15 10:30 PST] explain this log line" }, + { type: "text", text: `[${day} 2024-01-16 09:15 UTC] connection refused` }, + ], + }, + ], + }); + + expect(ResponseCache.generateKey(mk("Tue"))).not.toBe(ResponseCache.generateKey(mk("Wed"))); + }); + + it("should generate different keys when array content actually differs", () => { + const mk = (text: string) => + JSON.stringify({ + model: "gpt-4", + messages: [ + { + role: "user", + content: [{ type: "text", text: `[Mon 2024-01-15 10:30 PST] ${text}` }], + }, + ], + }); + + expect(ResponseCache.generateKey(mk("describe image A"))).not.toBe( + ResponseCache.generateKey(mk("describe image B")), + ); + }); + it("should handle Buffer input", () => { const body = Buffer.from( JSON.stringify({ diff --git a/src/response-cache.ts b/src/response-cache.ts index 6e24b25f..4447b7d3 100644 --- a/src/response-cache.ts +++ b/src/response-cache.ts @@ -14,6 +14,8 @@ import { createHash } from "node:crypto"; +import { TIMESTAMP_PATTERN, stripLeadingTextBlockTimestamp } from "./timestamp-strip.js"; + export type CachedLLMResponse = { body: Buffer; status: number; @@ -70,8 +72,6 @@ function canonicalize(obj: unknown): unknown { * separate cache slots — otherwise the first one's response is served to the * second, which breaks the client. */ -const TIMESTAMP_PATTERN = /^\[\w{3}\s+\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}\s+\w+\]\s*/; - function normalizeForCache(obj: Record): Record { const result: Record = {}; @@ -89,6 +89,15 @@ function normalizeForCache(obj: Record): Record { + if ( + !stripped && + block !== null && + typeof block === "object" && + (block as { type?: unknown }).type === "text" && + typeof (block as { text?: unknown }).text === "string" + ) { + stripped = true; + const b = block as { text: string }; + return { ...b, text: b.text.replace(TIMESTAMP_PATTERN, "") }; + } + return block; + }); +}