Skip to content
Closed
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(s) (legacy and Claude Code 2.1.263+ wording); 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
149 changes: 149 additions & 0 deletions src/claude/inbound-cache-stabilize.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
/**
* 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>`.
*
* TaskCreate nudge text has drifted across Claude Code builds; match the
* known exact paragraphs (legacy + 2.1.263 "tracking progress" form) as
* trailing alternatives so a mid-session nudge cannot pin older footers
* above it and flip `instructions` after a previously stable peel.
*/

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

/** Legacy Claude Code TaskCreate reminder (pre-"tracking progress"). */
const TASKCREATE_NUDGE_LEGACY =
"The task tools haven't been used recently. If you're working on tasks that would benefit from tracking, consider using TaskCreate to add them. Only use these if relevant to the current work. This is just a gentle reminder - ignore if not applicable.";

/**
* Claude Code 2.1.263+ TaskCreate reminder observed on hitrate S/T4
* (FREEZE-DIFF + tip0847 pilots). Mentions TaskUpdate and stale-list cleanup.
*/
const TASKCREATE_NUDGE_CC_2_1_263 =
"The task tools haven't been used recently. If you're working on tasks that would benefit from tracking progress, consider using TaskCreate to add new tasks and TaskUpdate to update task status (set to in_progress when starting, completed when done). Also consider cleaning up the task list if it has become stale. Only use these if relevant to the current work. This is just a gentle reminder - ignore if not applicable.";

const TRAILING_NUDGE_RE = new RegExp(
`(?:^|(?:\\r?\\n)+)[ \\t]*(${escapeRegExp(TASKCREATE_NUDGE_LEGACY)}|${escapeRegExp(TASKCREATE_NUDGE_CC_2_1_263)})[ \\t]*(?:\\r?\\n)*$`,
);

function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

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