Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
109 changes: 109 additions & 0 deletions src/dedup.test.ts
Original file line number Diff line number Diff line change
@@ -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));
});
});
14 changes: 11 additions & 3 deletions src/dedup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@

import { createHash } from "node:crypto";

import { TIMESTAMP_PATTERN, stripLeadingTextBlockTimestamp } from "./timestamp-strip.js";

export type CachedResponse = {
status: number;
headers: Record<string, string>;
Expand Down Expand Up @@ -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;
Expand All @@ -58,8 +58,16 @@ function stripTimestamps(obj: unknown): unknown {
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
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);
}
Expand Down
71 changes: 71 additions & 0 deletions src/response-cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
13 changes: 11 additions & 2 deletions src/response-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@

import { createHash } from "node:crypto";

import { TIMESTAMP_PATTERN, stripLeadingTextBlockTimestamp } from "./timestamp-strip.js";

export type CachedLLMResponse = {
body: Buffer;
status: number;
Expand Down Expand Up @@ -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<string, unknown>): Record<string, unknown> {
const result: Record<string, unknown> = {};

Expand All @@ -89,6 +89,15 @@ function normalizeForCache(obj: Record<string, unknown>): Record<string, unknown
if (typeof m.content === "string") {
return { ...m, content: m.content.replace(TIMESTAMP_PATTERN, "") };
}
if (Array.isArray(m.content)) {
// Anthropic-style content blocks — the injected timestamp lives in the
// FIRST text block's `text` field. Without this, a retried multimodal
// message (vision, image attachments) gets a fresh injected timestamp
// each time and never hits the cache, so it's billed as a brand-new
// request. Later text blocks are user data and are left untouched so
// genuinely different requests keep different cache keys.
return { ...m, content: stripLeadingTextBlockTimestamp(m.content) };
}
}
return msg;
});
Expand Down
35 changes: 35 additions & 0 deletions src/timestamp-strip.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/**
* OpenClaw-injected timestamp stripping, shared by the request deduplicator
* and the response cache so their key normalization cannot drift apart.
*
* OpenClaw injects a fresh [DAY YYYY-MM-DD HH:MM TZ] prefix on every request:
* for plain-string content it is prepended to the string, and for array-form
* (multimodal) content it lands in the FIRST text block only. Later text
* blocks never carry an injected stamp — a bracketed timestamp there is the
* user's own data (e.g. a pasted log line) and must be preserved, otherwise
* two genuinely different requests collide on the same key and the wrong
* cached response is served (or a distinct paid request is wrongly deduped).
*/
export const TIMESTAMP_PATTERN = /^\[\w{3}\s+\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}\s+\w+\]\s*/;

/**
* Strip the injected timestamp prefix from the first text block of an
* array-form content value. All other blocks are returned unchanged.
*/
export function stripLeadingTextBlockTimestamp(blocks: unknown[]): unknown[] {
let stripped = false;
return blocks.map((block) => {
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;
});
}