Skip to content
Binary file not shown.
36 changes: 36 additions & 0 deletions docs/claude-instructions-cache-stabilize/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Claude instructions cache stabilize (OCXFIX)

OpenCodex inbound conversion puts Claude Code system text into OpenAI Responses
`instructions`. Claude Code then appends a growing `<total_tokens>…</total_tokens>`
footer (and occasional TaskCreate nudges) on every turn. That prefix churn
causes prompt-cache misses on Muse/Go.

This change strips those dynamic footers from `instructions` and reattaches the
latest notice as a trailing `input` message so the cacheable prefix stays stable.

Relocation is opt-in: `translateAnthropicRequest` / `anthropicToResponsesTranslation`
take `stabilizePromptCache?: boolean` (default **false**). Ordinary Anthropic
callers keep a matching suffix in `instructions`. The Claude Code `/v1/messages`
inbound path passes `true`. The matcher is `<total_tokens>N tokens left</total_tokens>`
plus the exact TaskCreate paragraph; it is not gated on `metadata.user_id`.
Outside opt-in, the Desktop `prompt_cache_key` fallback hashes raw `systemParts`.
When opted in, that fallback hashes the same string as `body.instructions`.

## Paper

See [PAPER_OCXFIX.pdf](./PAPER_OCXFIX.pdf) (Warexpor).

Measured cache-hit rates on Muse Spark 1.3 via OpenCode Go / OpenCodex:

| Slice | Baseline mean | OCXFIX mean |
| --- | ---: | ---: |
| Claude Code (n=75) | 0.168384 | 0.864374 |
| Claude S/T4 (n=5) | 0.134922 | 0.982700 |
| Grok Build (n=75) | 0.966526 | 0.941345 |

Cause: Anthropic→Responses conversion stores Claude system text in `instructions`;
Claude Code appends growing `<total_tokens>` (and rare TaskCreate nudges), so
`instructions_sha` changes every turn. Grok traffic has no `instructions` field
and is the control.

Code: `src/claude/inbound-cache-stabilize.ts`, wired from `src/claude/inbound.ts`.
1 change: 1 addition & 0 deletions scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,7 @@
"claude-desktop-remote-hub.test.ts": "claude-integration",
"claude-dotenv-provenance-transport.test.ts": "claude-integration",
"claude-gateway-cache.test.ts": "claude-integration",
"claude-inbound-cache-stabilize.test.ts": "claude-integration",
"claude-inbound-debug.test.ts": "claude-integration",
"claude-inbound.test.ts": "claude-integration",
"claude-management-api.test.ts": "claude-integration",
Expand Down
128 changes: 128 additions & 0 deletions src/claude/inbound-cache-stabilize.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
/**
* Claude Code appends growing `<total_tokens>…</total_tokens>` footers (and
* occasional TaskCreate nudges) into system text that becomes Responses
* `instructions`. That churn breaks Muse/Go prefix cache on the instructions
* prefix even when tools stay stable. Strip dynamics from instructions;
* surface the latest notice on `input` instead.
*
* Relocation is identified by harness shape: only a trailing, unfenced,
* canonical notice at the end of instructions is moved. An unmatched fence
* opener covers through EOF. No match → the original string is returned
* byte-for-byte. The matcher is content identity only; `translateAnthropicRequest`
* must pass `stabilizePromptCache: true` before this helper runs. Claude Code
* writes `<total_tokens>N tokens left</total_tokens>`.
*/

const TRAILING_TOTAL_RE =
/(?:^|(?:\r?\n)+)[ \t]*(<total_tokens>\d+\s+tokens left<\/total_tokens>)[ \t]*(?:\r?\n)*$/;

const TRAILING_NUDGE_RE =
/(?:^|(?:\r?\n)+)[ \t]*(The task tools haven't been used recently\.\s+If you're working on tasks that would benefit from tracking, consider using TaskCreate to add them\.\s+Only use these if relevant to the current work\.\s+This is just a gentle reminder - ignore if not applicable\.)[ \t]*(?:\r?\n)*$/;

interface FenceRange {
start: number;
end: number;
}

const FENCE_OPEN_RE = /^( {0,3})(`{3,}|~{3,})/;
const FENCE_CLOSE_RE = /^( {0,3})(`{3,}|~{3,})[ \t]*$/;

/**
* Markdown fence ranges. An unmatched opener covers through EOF: unfinished
* fenced examples are code content, not a harness suffix. A closer must be a
* standalone fence line (no info string) of the same character and at least
* the opener's length.
*/
function fencedRanges(source: string): FenceRange[] {
const ranges: FenceRange[] = [];
let offset = 0;
let openAt: number | null = null;
let openFence = "";
while (offset <= source.length) {
const nl = source.indexOf("\n", offset);
const lineEnd = nl === -1 ? source.length : nl;
const line = source.slice(offset, lineEnd).replace(/\r$/, "");
if (openAt === null) {
const open = FENCE_OPEN_RE.exec(line);
if (open) {
openAt = offset;
openFence = open[2]!;
}
} else {
const close = FENCE_CLOSE_RE.exec(line);
if (
close
&& close[2]![0] === openFence[0]
&& close[2]!.length >= openFence.length
) {
ranges.push({ start: openAt, end: lineEnd });
openAt = null;
openFence = "";
}
}
if (nl === -1) break;
offset = nl + 1;
}
if (openAt !== null) ranges.push({ start: openAt, end: source.length });
return ranges;
}

function isInsideFence(ranges: readonly FenceRange[], index: number): boolean {
return ranges.some(range => index >= range.start && index < range.end);
}

function peelOne(
rest: string,
ranges: readonly FenceRange[],
): { rest: string; total?: string; nudge?: string } | null {
const total = rest.match(TRAILING_TOTAL_RE);
if (total?.[1]) {
const matchStart = rest.length - total[0].length;
const tagAt = matchStart + total[0].indexOf(total[1]);
if (!isInsideFence(ranges, tagAt)) {
return { rest: rest.slice(0, rest.length - total[0].length), total: total[1] };
}
}
const nudge = rest.match(TRAILING_NUDGE_RE);
if (nudge?.[1]) {
const matchStart = rest.length - nudge[0].length;
const tagAt = matchStart + nudge[0].indexOf(nudge[1]);
if (!isInsideFence(ranges, tagAt)) {
return { rest: rest.slice(0, rest.length - nudge[0].length), nudge: nudge[1] };
}
}
return null;
}

export function stabilizeClaudeInstructionsForPromptCache(
instructions: string,
): { instructions: string; dynamicNotice: string | null } {
if (!instructions) {
return { instructions: "", dynamicNotice: null };
}

const ranges = fencedRanges(instructions);
let rest = instructions;
let latestTotal: string | null = null;
let latestNudge: string | null = null;
let peeled = false;
for (;;) {
const next = peelOne(rest, ranges);
if (!next) break;
peeled = true;
rest = next.rest;
if (next.total && latestTotal === null) latestTotal = next.total;
if (next.nudge && latestNudge === null) latestNudge = next.nudge;
}

if (!peeled) {
return { instructions, dynamicNotice: null };
}

const noticeParts: string[] = [];
if (latestTotal) noticeParts.push(latestTotal);
if (latestNudge) noticeParts.push(latestNudge);
const dynamicNotice = noticeParts.length > 0 ? noticeParts.join("\n\n") : null;

return { instructions: rest, dynamicNotice };
}
66 changes: 59 additions & 7 deletions src/claude/inbound.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export { resolveInboundModel, effortForThinkingBudget, effortFromOutputConfig, e
import { AnthropicRequestError, isRec, type Rec } from "./inbound-records";
import { resolveInboundModel, effortForThinkingBudget, effortFromOutputConfig, formatFromOutputConfig } from "./inbound-model-options";
import { systemToInstructions, toolsToResponses, toolChoiceToResponses } from "./inbound-content-options";
import { stabilizeClaudeInstructionsForPromptCache } from "./inbound-cache-stabilize";
import { decodeReasoningEnvelope, encodeReasoningEnvelope, OCX_REASONING_PREFIX } from "../responses/reasoning-envelope";
import { createTranslatorBudget, type TranslatorBudget } from "../lib/translator-budget";

Expand Down Expand Up @@ -287,29 +288,52 @@ export interface ClaudeInboundTranslation {
cacheKeySource: ClaudeCacheKeySource;
}

/**
* Translator-level opt-in for Claude Code harness footer relocation.
* Default is off: a matching textual suffix is not permission to change its role.
* The `/v1/messages` inbound path passes true. Do not infer this from metadata.user_id.
*/
export interface ClaudeInboundTranslateOptions {
stabilizePromptCache?: boolean;
}

/**
* Translate an Anthropic Messages request body into a /v1/responses request body.
* Throws AnthropicRequestError (-> 400 invalid_request_error) on malformed input.
*/
export function anthropicToResponsesBody(raw: unknown, cc?: OcxClaudeCodeConfig): Rec {
return anthropicToResponsesTranslation(raw, cc).body;
export function anthropicToResponsesBody(
raw: unknown,
cc?: OcxClaudeCodeConfig,
options?: ClaudeInboundTranslateOptions,
): Rec {
return anthropicToResponsesTranslation(raw, cc, undefined, options).body;
}

/**
* Full translation result: the wire body plus the prompt-cache-key provenance as an
* OUT-OF-BODY tuple (audit 133 R3#1 — an in-body marker would leak upstream through
* the native Responses forward and 400).
*/
export function anthropicToResponsesTranslation(raw: unknown, cc?: OcxClaudeCodeConfig, budget?: TranslatorBudget): ClaudeInboundTranslation {
export function anthropicToResponsesTranslation(
raw: unknown,
cc?: OcxClaudeCodeConfig,
budget?: TranslatorBudget,
options?: ClaudeInboundTranslateOptions,
): ClaudeInboundTranslation {
const activeBudget = budget ?? createTranslatorBudget();
try {
return translateAnthropicRequest(raw, cc, activeBudget);
return translateAnthropicRequest(raw, cc, activeBudget, options);
} finally {
if (!budget) activeBudget.dispose();
}
}

function translateAnthropicRequest(raw: unknown, cc: OcxClaudeCodeConfig | undefined, budget: TranslatorBudget): ClaudeInboundTranslation {
function translateAnthropicRequest(
raw: unknown,
cc: OcxClaudeCodeConfig | undefined,
budget: TranslatorBudget,
options?: ClaudeInboundTranslateOptions,
): ClaudeInboundTranslation {
if (!isRec(raw)) throw new AnthropicRequestError("request body must be a JSON object");
if (typeof raw.model !== "string" || raw.model.length === 0) {
throw new AnthropicRequestError("model is required");
Expand Down Expand Up @@ -345,7 +369,32 @@ function translateAnthropicRequest(raw: unknown, cc: OcxClaudeCodeConfig | undef
stream: raw.stream === true,
};

if (systemParts.length > 0) body.instructions = systemParts.join("\n\n");
const joinedSystem = systemParts.length > 0 ? systemParts.join("\n\n") : "";
const stabilizePromptCache = options?.stabilizePromptCache === true;
// Desktop fallback hashes raw systemParts unless the caller opted into
// harness cleanup. Opt-in then hashes the same string as body.instructions.
let cacheSystem: string | string[] = systemParts;
if (joinedSystem) {
if (stabilizePromptCache) {
// Claude Code appends growing <total_tokens>N tokens left</total_tokens>
// footers (and occasional TaskCreate nudges) into system text. That churn
// breaks Muse/Go prefix cache on the Responses instructions prefix even
// when tools stay stable. Relocation is caller-opted, not inferred from
// a matching suffix or metadata.user_id.
const stabilized = stabilizeClaudeInstructionsForPromptCache(joinedSystem);
if (stabilized.instructions) body.instructions = stabilized.instructions;
if (stabilized.dynamicNotice) {
input.push({
type: "message",
role: "user",
content: [{ type: "input_text", text: stabilized.dynamicNotice }],
});
}
cacheSystem = stabilized.instructions;
} else {
body.instructions = joinedSystem;
}
}

const tools = toolsToResponses(raw.tools);
if (tools) body.tools = tools;
Expand Down Expand Up @@ -383,11 +432,14 @@ function translateAnthropicRequest(raw: unknown, cc: OcxClaudeCodeConfig | undef
// Exact-prefix matching still isolates content; the key only steers routing
// affinity. Callers must NOT synthesize a session_id header from this fallback
// (audit 133 R2#3).
// Outside opt-in, hash the raw systemParts array (pre-stabilize Desktop
// key). Opt-in hashes the same string used for body.instructions so the
// key tracks the cacheable prefix after peel.
body.prompt_cache_key = createHash("sha256")
.update(canonicalJson({
version: 2,
model: body.model,
system: systemParts,
system: cacheSystem,
tools: Array.isArray(body.tools) ? body.tools : [],
}))
.digest("hex").slice(0, 32);
Expand Down
4 changes: 3 additions & 1 deletion src/server/claude-messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -755,7 +755,9 @@ async function handleClaudeMessagesWithBudget(
};
delete anthropicBody.thinking;
}
const translation = anthropicToResponsesTranslation(anthropicBody, config.claudeCode, translatorBudget);
const translation = anthropicToResponsesTranslation(anthropicBody, config.claudeCode, translatorBudget, {
stabilizePromptCache: true,
});
internalBody = translation.body;
// The Anthropic translator builds its body from model/input/store/stream plus sampling
// fields only, so the caller intent is applied to the TRANSLATED body rather than the
Expand Down
Loading
Loading