mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-19 05:12:16 +03:00
fix(vertex): preserve Claude prompt caching and usage metadata (#13220)
* fix(vertex): preserve Claude prompt caching * fix(vertex): normalize unsupported cache TTLs * docs(changelog): note Vertex prompt caching fix
This commit is contained in:
1
changelog.d/fixes/13220-vertex-claude-prompt-caching.md
Normal file
1
changelog.d/fixes/13220-vertex-claude-prompt-caching.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(vertex):** preserve Claude prompt-cache breakpoints for Vertex and Vertex Partner, use the documented five-minute ephemeral TTL by default, and forward cache usage metadata through streaming responses ([#13220](https://github.com/diegosouzapw/OmniRoute/pull/13220)) — fixes #13219
|
||||
@@ -218,6 +218,45 @@ function buildProjectScopedVertexUrl(
|
||||
return `https://aiplatform.googleapis.com/v1/projects/${project}/locations/${region}/publishers/google/models/${canonicalModel}:${operation}${querySeparator}${opaqueApiKey ? `key=${opaqueApiKey}` : ""}`;
|
||||
}
|
||||
|
||||
// Vertex does not support Anthropic's optional one-hour prompt-cache TTL on these
|
||||
// legacy Claude models. Keep the breakpoint, but omit ttl so Vertex uses its
|
||||
// documented five-minute ephemeral cache instead of rejecting the request.
|
||||
const VERTEX_ONE_HOUR_TTL_UNSUPPORTED = new Set([
|
||||
"claude-3-7-sonnet",
|
||||
"claude-3-5-sonnet-v2",
|
||||
"claude-3-5-sonnet",
|
||||
"claude-3-opus",
|
||||
]);
|
||||
|
||||
function downgradeUnsupportedVertexClaudeTtl(body: Record<string, unknown>, model: string): void {
|
||||
const normalizedModel = model.toLowerCase().split("@", 1)[0];
|
||||
if (!VERTEX_ONE_HOUR_TTL_UNSUPPORTED.has(normalizedModel)) return;
|
||||
|
||||
const normalizeBlock = (block: unknown) => {
|
||||
if (!block || typeof block !== "object" || Array.isArray(block)) return;
|
||||
const record = block as Record<string, unknown>;
|
||||
const cacheControl = record.cache_control;
|
||||
if (!cacheControl || typeof cacheControl !== "object" || Array.isArray(cacheControl)) return;
|
||||
const control = cacheControl as Record<string, unknown>;
|
||||
if (control.type === "ephemeral" && control.ttl === "1h") delete control.ttl;
|
||||
};
|
||||
|
||||
const system = body.system;
|
||||
if (Array.isArray(system)) system.forEach(normalizeBlock);
|
||||
|
||||
const messages = body.messages;
|
||||
if (Array.isArray(messages)) {
|
||||
for (const message of messages) {
|
||||
if (!message || typeof message !== "object" || Array.isArray(message)) continue;
|
||||
const content = (message as Record<string, unknown>).content;
|
||||
if (Array.isArray(content)) content.forEach(normalizeBlock);
|
||||
}
|
||||
}
|
||||
|
||||
const tools = body.tools;
|
||||
if (Array.isArray(tools)) tools.forEach(normalizeBlock);
|
||||
}
|
||||
|
||||
// Defensive normalizer: target-format resolution for manually-added custom Claude models under
|
||||
// "vertex"/"vertex-partner" was observed sending a Gemini-shaped body (contents/parts) to the
|
||||
// Anthropic rawPredict endpoint instead of the configured "claude" format, causing a hard
|
||||
@@ -260,6 +299,16 @@ function synthesizeClaudeSse(response: Record<string, unknown>): string {
|
||||
const stopReason = typeof response.stop_reason === "string" ? response.stop_reason : "end_turn";
|
||||
const stopSequence = (response.stop_sequence as string | null | undefined) ?? null;
|
||||
const content = Array.isArray(response.content) ? response.content : [];
|
||||
const inputUsage: Record<string, unknown> = {
|
||||
input_tokens: usage.input_tokens || 0,
|
||||
output_tokens: 0,
|
||||
};
|
||||
if (typeof usage.cache_creation_input_tokens === "number") {
|
||||
inputUsage.cache_creation_input_tokens = usage.cache_creation_input_tokens;
|
||||
}
|
||||
if (typeof usage.cache_read_input_tokens === "number") {
|
||||
inputUsage.cache_read_input_tokens = usage.cache_read_input_tokens;
|
||||
}
|
||||
|
||||
const events: Array<{ event: string; data: Record<string, unknown> }> = [];
|
||||
|
||||
@@ -275,7 +324,7 @@ function synthesizeClaudeSse(response: Record<string, unknown>): string {
|
||||
model,
|
||||
stop_reason: null,
|
||||
stop_sequence: null,
|
||||
usage: { input_tokens: usage.input_tokens || 0, output_tokens: 0 },
|
||||
usage: inputUsage,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -408,6 +457,7 @@ export class VertexExecutor extends BaseExecutor {
|
||||
// "model: Extra inputs are not permitted" if the translated request body still carries
|
||||
// one (the openai→claude request translator copies the client's model field over).
|
||||
delete body.model;
|
||||
downgradeUnsupportedVertexClaudeTtl(body, model);
|
||||
}
|
||||
|
||||
const result = await super.execute(input);
|
||||
|
||||
@@ -10,6 +10,8 @@ export function extractUsageFromResponse(responseBody, provider) {
|
||||
const isClaudeProvider =
|
||||
providerId === "claude" ||
|
||||
providerId === "anthropic" ||
|
||||
providerId === "vertex" ||
|
||||
providerId === "vertex-partner" ||
|
||||
providerId.startsWith("anthropic-compatible");
|
||||
|
||||
// OpenAI format (has prompt_tokens / completion_tokens)
|
||||
|
||||
@@ -301,6 +301,20 @@ function markMessageCacheControl(msg: ClaudeMessage, ttl?: string): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the cache marker OmniRoute adds when the client did not provide one.
|
||||
* Vertex defaults to five minutes when ttl is omitted; unlike 1h, that mode is
|
||||
* supported by every cache-capable Claude model on Vertex and has cheaper writes.
|
||||
*/
|
||||
export function createDefaultClaudeCacheControl(provider?: string | null): {
|
||||
type: string;
|
||||
ttl?: string;
|
||||
} {
|
||||
return provider === "vertex" || provider === "vertex-partner"
|
||||
? { type: "ephemeral" }
|
||||
: { type: "ephemeral", ttl: "1h" };
|
||||
}
|
||||
|
||||
/** True when the body carries at least one cache_control marker anywhere
|
||||
* (system blocks, message content blocks, or tools). Used to decide whether
|
||||
* preserve-mode has anything to preserve. */
|
||||
@@ -361,10 +375,15 @@ export function prepareClaudeRequest(
|
||||
preserveCacheControl = false;
|
||||
}
|
||||
|
||||
// 1. System: remove all cache_control, add only to last block with ttl 1h
|
||||
// 1. System: remove all cache_control, add only to the last block with the provider TTL
|
||||
// In passthrough mode, preserve existing cache_control markers
|
||||
const isVertexClaudeProvider = provider === "vertex" || provider === "vertex-partner";
|
||||
const supportsPromptCaching =
|
||||
provider === "claude" || provider?.startsWith?.("anthropic-compatible-");
|
||||
provider === "claude" ||
|
||||
isVertexClaudeProvider ||
|
||||
provider?.startsWith?.("anthropic-compatible-");
|
||||
// Vertex's documented default is a five-minute ephemeral cache. Omitting ttl is
|
||||
// both cheaper and compatible with Claude models that reject the optional 1h TTL.
|
||||
const isKimiCoding = provider === "kimi-coding" || provider === "kimi-coding-apikey";
|
||||
|
||||
// Non-Anthropic Claude-shape providers (kimi-coding, glmt, zai, …) cannot
|
||||
@@ -389,7 +408,7 @@ export function prepareClaudeRequest(
|
||||
body.system = systemBlocks.map((block, i) => {
|
||||
const { cache_control, ...rest } = block;
|
||||
if (i === systemBlocks.length - 1 && supportsPromptCaching) {
|
||||
return { ...rest, cache_control: { type: "ephemeral", ttl: "1h" } };
|
||||
return { ...rest, cache_control: createDefaultClaudeCacheControl(provider) };
|
||||
}
|
||||
return rest;
|
||||
});
|
||||
@@ -721,7 +740,7 @@ export function prepareClaudeRequest(
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Tools: remove all cache_control, add only to last non-deferred tool with ttl 1h
|
||||
// 3. Tools: remove all cache_control, add only to the last non-deferred tool
|
||||
// Tools with defer_loading=true cannot have cache_control (API rejects it)
|
||||
// In passthrough mode, preserve existing cache_control markers
|
||||
if (body.tools && Array.isArray(body.tools) && !preserveCacheControl) {
|
||||
@@ -732,7 +751,7 @@ export function prepareClaudeRequest(
|
||||
if (supportsPromptCaching) {
|
||||
for (let i = body.tools.length - 1; i >= 0; i--) {
|
||||
if (!body.tools[i].defer_loading) {
|
||||
body.tools[i].cache_control = { type: "ephemeral", ttl: "1h" };
|
||||
body.tools[i].cache_control = createDefaultClaudeCacheControl(provider);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,10 @@ import { supportsClaudeMaxEffort, supportsXHighEffort } from "../../config/provi
|
||||
import { adjustMaxTokens } from "../helpers/maxTokensHelper.ts";
|
||||
import { sanitizeToolId } from "../helpers/schemaCoercion.ts";
|
||||
import { safeParseJSON } from "../helpers/jsonUtil.ts";
|
||||
import { applyKimiCodingThinking } from "../helpers/claudeHelper.ts";
|
||||
import {
|
||||
applyKimiCodingThinking,
|
||||
createDefaultClaudeCacheControl,
|
||||
} from "../helpers/claudeHelper.ts";
|
||||
import { DEFAULT_THINKING_CLAUDE_SIGNATURE } from "../../config/defaultThinkingSignature.ts";
|
||||
import {
|
||||
getDefaultThinkingBudget,
|
||||
@@ -454,7 +457,7 @@ export function openaiToClaudeRequest(model, body, stream, credentials = null) {
|
||||
// rejects cache_control on defer_loading tools.
|
||||
for (let i = result.tools.length - 1; i >= 0; i--) {
|
||||
if (!result.tools[i].defer_loading) {
|
||||
result.tools[i].cache_control = { type: "ephemeral", ttl: "1h" };
|
||||
result.tools[i].cache_control = createDefaultClaudeCacheControl(routedProvider);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -491,7 +494,7 @@ export function openaiToClaudeRequest(model, body, stream, credentials = null) {
|
||||
const systemBlock = {
|
||||
type: "text",
|
||||
text: systemText,
|
||||
cache_control: { type: "ephemeral", ttl: "1h" },
|
||||
cache_control: createDefaultClaudeCacheControl(routedProvider),
|
||||
};
|
||||
// Merge with existing body.system if present
|
||||
if (Array.isArray(body.system)) {
|
||||
|
||||
@@ -216,7 +216,13 @@ export function providerSupportsCaching(
|
||||
return connectionCacheOverride.supportsPromptCaching;
|
||||
}
|
||||
if (!provider) return false;
|
||||
if (CACHING_PROVIDERS.has(provider.toLowerCase())) return true;
|
||||
const providerId = provider.toLowerCase();
|
||||
// Vertex is a mixed-format provider. Only its Anthropic Claude path accepts
|
||||
// cache_control; Gemini and OpenAI-format partner models use other mechanisms.
|
||||
if (providerId === "vertex" || providerId === "vertex-partner") {
|
||||
return targetFormat?.toLowerCase() === "claude";
|
||||
}
|
||||
if (CACHING_PROVIDERS.has(providerId)) return true;
|
||||
// All Claude-protocol providers support prompt caching
|
||||
if (targetFormat === "claude") return true;
|
||||
return false;
|
||||
|
||||
@@ -45,6 +45,11 @@ describe("Cache Control Policy", () => {
|
||||
assert.equal(providerSupportsCaching("openai"), true);
|
||||
assert.equal(providerSupportsCaching("codex"), true);
|
||||
assert.equal(providerSupportsCaching("azure"), true);
|
||||
// Vertex is mixed-format: only Claude partner models use cache_control.
|
||||
assert.equal(providerSupportsCaching("vertex", "claude"), true);
|
||||
assert.equal(providerSupportsCaching("vertex-partner", "claude"), true);
|
||||
assert.equal(providerSupportsCaching("vertex", "gemini"), false);
|
||||
assert.equal(providerSupportsCaching("vertex-partner", "gemini"), false);
|
||||
});
|
||||
|
||||
test("rejects non-caching providers", () => {
|
||||
@@ -108,6 +113,20 @@ describe("Cache Control Policy", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("preserves Claude Code cache markers for both Vertex provider IDs", () => {
|
||||
for (const targetProvider of ["vertex", "vertex-partner"]) {
|
||||
assert.equal(
|
||||
shouldPreserveCacheControl({
|
||||
userAgent: "claude-code/0.1.0",
|
||||
isCombo: false,
|
||||
targetProvider,
|
||||
targetFormat: "claude",
|
||||
}),
|
||||
true
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("preserves for combo with priority strategy + Claude client + caching provider", () => {
|
||||
assert.equal(
|
||||
shouldPreserveCacheControl({
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { describe, test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { prepareClaudeRequest } from "../../open-sse/translator/helpers/claudeHelper.ts";
|
||||
import { FORMATS } from "../../open-sse/translator/formats.ts";
|
||||
import { translateRequest } from "../../open-sse/translator/index.ts";
|
||||
|
||||
describe("Claude cache_control passthrough", () => {
|
||||
test("preserveCacheControl=true preserves cache_control in system blocks", () => {
|
||||
@@ -181,4 +183,59 @@ describe("Claude cache_control passthrough", () => {
|
||||
assert.equal(result.messages[2].content[0].cache_control, undefined);
|
||||
assert.deepEqual(result.tools[0].cache_control, { type: "ephemeral", ttl: "5m" });
|
||||
});
|
||||
|
||||
for (const provider of ["vertex", "vertex-partner"]) {
|
||||
test(`${provider} supports prompt caching with the cost-sensitive default TTL`, () => {
|
||||
const body = {
|
||||
system: [{ type: "text", text: "Stable system prefix" }],
|
||||
messages: [{ role: "user", content: [{ type: "text", text: "Dynamic question" }] }],
|
||||
tools: [
|
||||
{
|
||||
name: "lookup",
|
||||
description: "Stable tool definition",
|
||||
input_schema: { type: "object" },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = prepareClaudeRequest(body, provider, false, "claude-sonnet-4-6");
|
||||
|
||||
// Omitting ttl selects Anthropic's 5-minute default. Vertex does not support
|
||||
// ttl:1h on every Claude model, and one-hour writes cost more.
|
||||
assert.deepEqual(result.system[0].cache_control, { type: "ephemeral" });
|
||||
assert.deepEqual(result.tools[0].cache_control, { type: "ephemeral" });
|
||||
});
|
||||
|
||||
test(`${provider} uses the five-minute default through the full translation path`, () => {
|
||||
const result = translateRequest(
|
||||
FORMATS.OPENAI,
|
||||
FORMATS.CLAUDE,
|
||||
"claude-3-7-sonnet",
|
||||
{
|
||||
messages: [
|
||||
{ role: "system", content: "Stable system prefix" },
|
||||
{ role: "user", content: "Dynamic question" },
|
||||
],
|
||||
tools: [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "lookup",
|
||||
description: "Stable tool definition",
|
||||
parameters: { type: "object" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
true,
|
||||
null,
|
||||
provider,
|
||||
null,
|
||||
{ preserveCacheControl: true }
|
||||
);
|
||||
|
||||
assert.deepEqual(result.system[0].cache_control, { type: "ephemeral" });
|
||||
assert.deepEqual(result.tools[0].cache_control, { type: "ephemeral" });
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -401,6 +401,104 @@ test("VertexExecutor.execute strips the client's model field and injects anthrop
|
||||
}
|
||||
});
|
||||
|
||||
test("VertexExecutor downgrades unsupported Claude 1h cache TTLs without changing supported or 5m TTLs", async () => {
|
||||
const executor = new VertexExecutor();
|
||||
const originalFetch = globalThis.fetch;
|
||||
type CapturedBody = {
|
||||
system: Array<{ cache_control?: Record<string, string> }>;
|
||||
messages: Array<{ content: Array<{ cache_control?: Record<string, string> }> }>;
|
||||
tools: Array<{ cache_control?: Record<string, string> }>;
|
||||
};
|
||||
const sentBodies: CapturedBody[] = [];
|
||||
|
||||
globalThis.fetch = async (_url, options) => {
|
||||
sentBodies.push(JSON.parse(String(options?.body || "{}")) as CapturedBody);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
id: "msg_cache_ttl",
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
model: "claude-test",
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
stop_reason: "end_turn",
|
||||
usage: { input_tokens: 1, output_tokens: 1 },
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
};
|
||||
|
||||
const credentials = {
|
||||
apiKey: createServiceAccountJson({ projectId: "proj-claude-cache" }),
|
||||
accessToken: "ya29.claude-cache",
|
||||
};
|
||||
const body = {
|
||||
system: [{ type: "text", text: "stable", cache_control: { type: "ephemeral", ttl: "1h" } }],
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "question", cache_control: { type: "ephemeral", ttl: "1h" } },
|
||||
{ type: "text", text: "five-minute", cache_control: { type: "ephemeral", ttl: "5m" } },
|
||||
{ type: "text", text: "no ttl", cache_control: { type: "ephemeral" } },
|
||||
],
|
||||
},
|
||||
],
|
||||
tools: [
|
||||
{
|
||||
name: "lookup",
|
||||
input_schema: { type: "object" },
|
||||
cache_control: { type: "ephemeral", ttl: "1h" },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
try {
|
||||
for (const model of [
|
||||
"claude-3-7-sonnet",
|
||||
"claude-3-5-sonnet-v2@20241022",
|
||||
"claude-3-5-sonnet",
|
||||
"claude-3-opus@20240229",
|
||||
]) {
|
||||
await executor.execute({
|
||||
model,
|
||||
body: structuredClone(body),
|
||||
stream: false,
|
||||
credentials: { ...credentials },
|
||||
});
|
||||
}
|
||||
|
||||
await executor.execute({
|
||||
model: "claude-sonnet-4-6",
|
||||
body: structuredClone(body),
|
||||
stream: false,
|
||||
credentials: { ...credentials },
|
||||
});
|
||||
|
||||
const unsupportedBodies = sentBodies.slice(0, 4);
|
||||
for (const sent of unsupportedBodies) {
|
||||
assert.deepEqual(sent.system[0].cache_control, { type: "ephemeral" });
|
||||
assert.deepEqual(sent.messages[0].content[0].cache_control, { type: "ephemeral" });
|
||||
assert.deepEqual(sent.messages[0].content[1].cache_control, {
|
||||
type: "ephemeral",
|
||||
ttl: "5m",
|
||||
});
|
||||
assert.deepEqual(sent.messages[0].content[2].cache_control, { type: "ephemeral" });
|
||||
assert.deepEqual(sent.tools[0].cache_control, { type: "ephemeral" });
|
||||
}
|
||||
|
||||
assert.deepEqual(sentBodies[4].system[0].cache_control, {
|
||||
type: "ephemeral",
|
||||
ttl: "1h",
|
||||
});
|
||||
assert.deepEqual(sentBodies[4].tools[0].cache_control, {
|
||||
type: "ephemeral",
|
||||
ttl: "1h",
|
||||
});
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("VertexExecutor.execute synthesizes a genuine Anthropic-format SSE stream when rawPredict returns a complete JSON body for a streaming request", async () => {
|
||||
const executor = new VertexExecutor();
|
||||
const originalFetch = globalThis.fetch;
|
||||
@@ -420,7 +518,12 @@ test("VertexExecutor.execute synthesizes a genuine Anthropic-format SSE stream w
|
||||
content: [{ type: "text", text: "hello" }],
|
||||
stop_reason: "end_turn",
|
||||
stop_sequence: null,
|
||||
usage: { input_tokens: 5, output_tokens: 2 },
|
||||
usage: {
|
||||
input_tokens: 5,
|
||||
output_tokens: 2,
|
||||
cache_creation_input_tokens: 1_024,
|
||||
cache_read_input_tokens: 4_096,
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
@@ -458,6 +561,12 @@ test("VertexExecutor.execute synthesizes a genuine Anthropic-format SSE stream w
|
||||
"message_delta",
|
||||
"message_stop",
|
||||
]);
|
||||
assert.deepEqual(dataLines[0].message.usage, {
|
||||
input_tokens: 5,
|
||||
output_tokens: 0,
|
||||
cache_creation_input_tokens: 1_024,
|
||||
cache_read_input_tokens: 4_096,
|
||||
});
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
|
||||
@@ -192,6 +192,29 @@ test("extractUsageFromResponse totals Claude prompt tokens with cache read and c
|
||||
});
|
||||
});
|
||||
|
||||
for (const provider of ["vertex", "vertex-partner"]) {
|
||||
test(`extractUsageFromResponse totals Claude cache tokens for ${provider}`, () => {
|
||||
const usage = extractUsageFromResponse(
|
||||
{
|
||||
usage: {
|
||||
input_tokens: 10,
|
||||
output_tokens: 7,
|
||||
cache_read_input_tokens: 4_000,
|
||||
cache_creation_input_tokens: 1_000,
|
||||
},
|
||||
},
|
||||
provider
|
||||
);
|
||||
|
||||
assert.deepEqual(usage, {
|
||||
prompt_tokens: 5_010,
|
||||
completion_tokens: 7,
|
||||
cache_read_input_tokens: 4_000,
|
||||
cache_creation_input_tokens: 1_000,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test("extractUsageFromResponse surfaces Claude thinking tokens without inflating completion", () => {
|
||||
const usage = extractUsageFromResponse(
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user