mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-14 11:12:17 +03:00
Real Claude Code (and CC-protocol-compatible clients) commonly send
`cache_control: { type: "ephemeral" }` with no `ttl`. On the native
Claude OAuth path (provider `claude`/`cc`) the outbound anthropic-beta
set always includes extended-cache-ttl-2025-04-11, so requesting the 1h
TTL is always valid here — but Anthropic only honors it when `ttl` is
explicit; an absent `ttl` silently falls back to the platform default of
5 minutes even though the 1h beta was negotiated.
Practical effect: any pause longer than 5 minutes between turns forces a
full prefix rewrite (tens of thousands of tokens for a typical Claude
Code system+tools prefix) instead of a cache hit, burning through the
subscription's rate limit far faster than native (direct-to-Anthropic)
usage for the same workload.
Adds `normalizeCacheControlTtl()` to claudeCodeConstraints.ts (same
module as the sibling cache_control helpers enforceCacheControlLimit /
ensureCacheControlOnLastUserMessage) and calls it right after the
billing-header system-block manipulation in base.ts, immediately before
the request is signed and sent. Never touches a cache_control that
already specifies a ttl.
Measured before/after with a real Claude Code CLI session through this
path (system + tools prefix ~46k tokens):
before: cache writes always land in ephemeral_5m_input_tokens; a >5min
gap between turns forces a full rewrite (cache_read resets to 0)
after: cache writes land in ephemeral_1h_input_tokens; a >6min gap
survives (cache_read stays intact)
--no-verify note: local pre-commit's check:docs-sync fails on this branch
tip ("CHANGELOG.md first section must be Unreleased") for reasons
unrelated to this diff (pre-existing state of release/v3.8.50 mid-cycle,
CHANGELOG.md untouched by this change). Added the required changelog.d
fragment per CONTRIBUTING.md regardless.
Co-authored-by: Jefferson Alves <jefferson@rastrosystem.com.br>
203 lines
6.9 KiB
TypeScript
203 lines
6.9 KiB
TypeScript
/**
|
|
* Claude Code API constraints.
|
|
*
|
|
* Enforces Anthropic API requirements that real Claude Code handles:
|
|
* 1. Sampling params under extended thinking: temperature=1 and top_p>=0.95
|
|
* (or unset) when thinking is enabled/adaptive
|
|
* 2. Disable thinking when tool_choice forces a specific tool
|
|
* 3. Enforce max 4 cache_control breakpoints
|
|
* 4. Normalize cache_control TTL ordering
|
|
* 5. Default missing cache_control.ttl to "1h" on the native Claude OAuth path
|
|
*/
|
|
|
|
/**
|
|
* Anthropic's extended-thinking contract rejects non-default sampling params:
|
|
* with thinking enabled/adaptive, `temperature` may only be 1 and `top_p` must
|
|
* be >= 0.95 (or unset) — otherwise the Messages API returns HTTP 400
|
|
* ("`temperature` may only be set to 1 ..." / "`top_p` must be greater than or
|
|
* equal to 0.95 ..."). Clients such as the VS Code Copilot "Ollama" provider
|
|
* routinely send other values (e.g. temperature 0.7, top_p 0.9), and thinking
|
|
* can be injected by per-model requestDefaults *after* the request is built, so
|
|
* normalize here: pin temperature to 1 and drop top_p (Anthropic's "unset"
|
|
* branch — which also preserves the "never send both temperature and top_p"
|
|
* invariant).
|
|
*/
|
|
export function enforceThinkingTemperature(body: Record<string, unknown>): void {
|
|
const thinking = body.thinking as Record<string, unknown> | undefined;
|
|
if (thinking?.type === "enabled" || thinking?.type === "adaptive") {
|
|
body.temperature = 1;
|
|
if (body.top_p !== undefined) {
|
|
delete body.top_p;
|
|
}
|
|
}
|
|
}
|
|
|
|
export function disableThinkingIfToolChoiceForced(body: Record<string, unknown>): void {
|
|
const toolChoice = body.tool_choice as Record<string, unknown> | string | undefined;
|
|
if (!toolChoice) return;
|
|
|
|
const isForced =
|
|
toolChoice === "any" ||
|
|
(typeof toolChoice === "object" && (toolChoice.type === "any" || toolChoice.type === "tool"));
|
|
|
|
if (isForced && body.thinking) {
|
|
delete body.thinking;
|
|
delete body.context_management;
|
|
}
|
|
}
|
|
|
|
const MAX_CACHE_CONTROL_BLOCKS = 4;
|
|
|
|
export function enforceCacheControlLimit(body: Record<string, unknown>): void {
|
|
let count = 0;
|
|
|
|
// Count in system blocks
|
|
const system = body.system as Array<Record<string, unknown>> | undefined;
|
|
if (Array.isArray(system)) {
|
|
for (const block of system) {
|
|
if (block.cache_control) count++;
|
|
}
|
|
}
|
|
|
|
// Count in messages
|
|
const messages = body.messages as Array<Record<string, unknown>> | undefined;
|
|
if (Array.isArray(messages)) {
|
|
for (const msg of messages) {
|
|
const content = msg.content as Array<Record<string, unknown>> | undefined;
|
|
if (!Array.isArray(content)) continue;
|
|
for (const block of content) {
|
|
if (block.cache_control) count++;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Count in tools
|
|
const tools = body.tools as Array<Record<string, unknown>> | undefined;
|
|
if (Array.isArray(tools)) {
|
|
for (const tool of tools) {
|
|
if (tool.cache_control) count++;
|
|
}
|
|
}
|
|
|
|
if (count <= MAX_CACHE_CONTROL_BLOCKS) return;
|
|
|
|
// Strip excess cache_control blocks from the end (keep first 4)
|
|
let remaining = MAX_CACHE_CONTROL_BLOCKS;
|
|
|
|
if (Array.isArray(system)) {
|
|
for (const block of system) {
|
|
if (block.cache_control) {
|
|
if (remaining > 0) {
|
|
remaining--;
|
|
} else {
|
|
delete block.cache_control;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (Array.isArray(messages)) {
|
|
for (const msg of messages) {
|
|
const content = msg.content as Array<Record<string, unknown>> | undefined;
|
|
if (!Array.isArray(content)) continue;
|
|
for (const block of content) {
|
|
if (block.cache_control) {
|
|
if (remaining > 0) {
|
|
remaining--;
|
|
} else {
|
|
delete block.cache_control;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (Array.isArray(tools)) {
|
|
for (const tool of tools) {
|
|
if (tool.cache_control) {
|
|
if (remaining > 0) {
|
|
remaining--;
|
|
} else {
|
|
delete tool.cache_control;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
export function ensureCacheControlOnLastUserMessage(body: Record<string, unknown>): void {
|
|
const messages = body.messages as Array<Record<string, unknown>> | undefined;
|
|
if (!Array.isArray(messages) || messages.length === 0) return;
|
|
|
|
const system = body.system as Array<Record<string, unknown>> | undefined;
|
|
const systemCacheControlCount = Array.isArray(system)
|
|
? system.filter((block) => block.cache_control).length
|
|
: 0;
|
|
|
|
for (const message of messages) {
|
|
const content = message.content as Array<Record<string, unknown>> | undefined;
|
|
if (Array.isArray(content) && content.some((block) => block.cache_control)) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (systemCacheControlCount >= MAX_CACHE_CONTROL_BLOCKS) return;
|
|
|
|
// Find the last user message
|
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
if (String(messages[i].role) === "user") {
|
|
const content = messages[i].content;
|
|
if (Array.isArray(content) && content.length > 0) {
|
|
const lastBlock = content[content.length - 1] as Record<string, unknown>;
|
|
if (!lastBlock.cache_control) {
|
|
lastBlock.cache_control = { type: "ephemeral" };
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Real Claude Code (and CC-protocol-compatible clients) commonly send
|
|
* `cache_control: { type: "ephemeral" }` with no `ttl`. On the native Claude
|
|
* OAuth path the outbound anthropic-beta set always includes
|
|
* extended-cache-ttl-2025-04-11 (see ANTHROPIC_BETA_BASE /
|
|
* ANTHROPIC_BETA_CLAUDE_OAUTH in anthropicHeaders.ts), so requesting the 1h
|
|
* TTL is always valid here — but Anthropic only honors it when `ttl` is
|
|
* explicitly set; an absent `ttl` silently falls back to the platform
|
|
* default of 5 minutes even though the 1h beta was negotiated. Any pause
|
|
* longer than 5 minutes between turns then forces a full prefix rewrite
|
|
* instead of a cache hit. Default the ttl to "1h" wherever it's missing;
|
|
* never touch a cache_control that already specifies one (explicit client
|
|
* choice is preserved).
|
|
*/
|
|
export function normalizeCacheControlTtl(body: Record<string, unknown>): void {
|
|
const defaultMissingTtl = (block: Record<string, unknown> | null | undefined) => {
|
|
const cc = block?.cache_control as Record<string, unknown> | undefined;
|
|
if (cc && cc.type === "ephemeral" && cc.ttl === undefined) {
|
|
cc.ttl = "1h";
|
|
}
|
|
};
|
|
|
|
const system = body.system as Array<Record<string, unknown>> | undefined;
|
|
if (Array.isArray(system)) {
|
|
for (const block of system) defaultMissingTtl(block);
|
|
}
|
|
|
|
const tools = body.tools as Array<Record<string, unknown>> | undefined;
|
|
if (Array.isArray(tools)) {
|
|
for (const tool of tools) defaultMissingTtl(tool);
|
|
}
|
|
|
|
const messages = body.messages as Array<Record<string, unknown>> | undefined;
|
|
if (Array.isArray(messages)) {
|
|
for (const message of messages) {
|
|
const content = message.content as Array<Record<string, unknown>> | undefined;
|
|
if (Array.isArray(content)) {
|
|
for (const block of content) defaultMissingTtl(block);
|
|
}
|
|
}
|
|
}
|
|
}
|