Skip to content
Closed
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
8 changes: 5 additions & 3 deletions docs-site/src/content/docs/guides/claude-code.md
Original file line number Diff line number Diff line change
Expand Up @@ -378,9 +378,11 @@ other 5xx `api_error`. `Retry-After` is preserved.
and the penultimate user message, plus top-level automatic `cache_control`. Stable turns normally
produce about a 99.9% cache hit rate.

**Native OpenAI/ChatGPT routing:** derives a session-scoped `prompt_cache_key` (from
`metadata.user_id` when present, falling back to a system-content hash) and `session_id` header
for cache affinity. The cache key includes model and full tool schemas.
**Native OpenAI/ChatGPT routing:** when system content exists, derives a content-scoped
`prompt_cache_key` from the resolved model, normalized system content, and full tool schemas.
Without system content, it falls back to `metadata.user_id`. When metadata is present, it
Comment on lines +381 to +383

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Correct the documented cache-key precedence

This section now says system content takes precedence for prompt_cache_key, but anthropicToResponsesTranslation still enters the metadata.user_id branch first whenever Claude Code sends metadata, so normal sessions with both metadata and system content remain session-scoped and do not use the normalized system/tool hash described here. Users following these docs will expect cross-session content cache affinity that the implementation does not provide; either change the precedence in code or keep the docs aligned with metadata-first behavior.

Useful? React with 👍 / 👎.

separately derives a per-session `session_id` header for backend affinity. Tool and
system-reminder listings are canonicalized before either cache input is built.
Comment on lines +381 to +385

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Native OpenAI/ChatGPT prompt-cache-key precedence is described backward in all 5 locales. src/claude/inbound.ts (Lines 457-489) checks metadata.user_id FIRST — metadata always wins the session-scoped prompt_cache_key when present, and the content-scoped hash (model + normalized system + tool schemas) is only an else if fallback used when metadata is absent. All five doc pages instead present system-content-presence as the primary discriminator ("when system content exists → content-scoped key; without it → falls back to metadata.user_id"), which inverts the real precedence and misleads readers about when each caching mode actually applies (materially, for Claude Code CLI traffic, which per the code's own comments always sends metadata.user_id, so it never reaches the content-scoped path these docs describe as primary).

  • docs-site/src/content/docs/guides/claude-code.md#L381-L385: rewrite to state metadata-first precedence, e.g. "when metadata.user_id is present, derives a session-scoped prompt_cache_key from it plus a separate session_id header; otherwise, when system content exists, falls back to a content-scoped key from the resolved model, normalized system content, and full tool schemas."
  • docs-site/src/content/docs/ja/guides/claude-code.md#L296-L300: apply the equivalent corrected precedence in Japanese.
  • docs-site/src/content/docs/ko/guides/claude-code.md#L333-L336: apply the equivalent corrected precedence in Korean.
  • docs-site/src/content/docs/ru/guides/claude-code.md#L313-L318: apply the equivalent corrected precedence in Russian.
  • docs-site/src/content/docs/zh-cn/guides/claude-code.md#L291-L294: apply the equivalent corrected precedence in Simplified Chinese.
📍 Affects 5 files
  • docs-site/src/content/docs/guides/claude-code.md#L381-L385 (this comment)
  • docs-site/src/content/docs/ja/guides/claude-code.md#L296-L300
  • docs-site/src/content/docs/ko/guides/claude-code.md#L333-L336
  • docs-site/src/content/docs/ru/guides/claude-code.md#L313-L318
  • docs-site/src/content/docs/zh-cn/guides/claude-code.md#L291-L294
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs-site/src/content/docs/guides/claude-code.md` around lines 381 - 385,
Correct the prompt-cache-key precedence description in
docs-site/src/content/docs/guides/claude-code.md:381-385,
docs-site/src/content/docs/ja/guides/claude-code.md:296-300,
docs-site/src/content/docs/ko/guides/claude-code.md:333-336,
docs-site/src/content/docs/ru/guides/claude-code.md:313-318, and
docs-site/src/content/docs/zh-cn/guides/claude-code.md:291-294. In every locale,
state that metadata.user_id takes precedence and produces the session-scoped
prompt_cache_key plus separate session_id; only when metadata.user_id is absent
should system content enable the content-scoped key based on the resolved model,
normalized system content, and full tool schemas, preserving each locale’s
language and existing canonicalization details.


**Token math:** Anthropic output subtracts `cached_tokens` and `cache_write_tokens` from
`input_tokens`, exposing them as `cache_read_input_tokens` and `cache_creation_input_tokens`.
Expand Down
8 changes: 5 additions & 3 deletions docs-site/src/content/docs/ja/guides/claude-code.md
Original file line number Diff line number Diff line change
Expand Up @@ -293,9 +293,11 @@ role、`tool_use_id` のない `tool_result`、id/name のない `tool_use`、na
分岐点と最上位自動 `cache_control` を管理します。安定した会話では通常キャッシュヒット率が約
99.9% です。

**ネイティブ OpenAI/ChatGPT ルーティング:** セッションスコープ `prompt_cache_key`(`metadata.user_id` があれば
使用、なければシステム内容ハッシュ使用)とキャッシュ選好のための `session_id` ヘッダーを作ります。
キャッシュキーにはモデルと全体ツールスキーマが含まれます。
**ネイティブ OpenAI/ChatGPT ルーティング:** システム内容がある場合、解決済みモデル、正規化された
システム内容、全体ツールスキーマから内容スコープの `prompt_cache_key` を作ります。システム内容が
ない場合は `metadata.user_id` を使います。メタデータがある場合は、バックエンド親和性のために別途
セッションごとの `session_id` ヘッダーを作ります。ツールとシステムリマインダーの一覧は、どちらの
キャッシュ入力を作る前にも正規化されます。

**トークン計算:** Anthropic 出力は `input_tokens` から `cached_tokens` と `cache_write_tokens` を引き、
それぞれ `cache_read_input_tokens` と `cache_creation_input_tokens` として公開します。リクエストログはこれを再び
Expand Down
7 changes: 4 additions & 3 deletions docs-site/src/content/docs/ko/guides/claude-code.md
Original file line number Diff line number Diff line change
Expand Up @@ -330,9 +330,10 @@ role, `tool_use_id` 없는 `tool_result`, id/name 없는 `tool_use`, name 없는
분기점과 최상위 자동 `cache_control`을 관리해요. 안정적인 대화에서는 보통 캐시 적중률이 약
99.9%예요.

**네이티브 OpenAI/ChatGPT 라우팅:** 세션 범위 `prompt_cache_key`(`metadata.user_id`가 있으면
사용하고, 없으면 시스템 내용 해시 사용)와 캐시 선호도를 위한 `session_id` 헤더를 만들어요.
캐시 키에는 모델과 전체 도구 스키마가 들어가요.
**네이티브 OpenAI/ChatGPT 라우팅:** 시스템 내용이 있으면 확인된 모델, 정규화된 시스템 내용, 전체
도구 스키마에서 내용 범위 `prompt_cache_key`를 만들어요. 시스템 내용이 없으면 `metadata.user_id`를
대신 사용해요. 메타데이터가 있으면 백엔드 친화성을 위해 별도의 세션별 `session_id` 헤더를 만들어요.
도구와 시스템 리마인더 목록은 두 캐시 입력을 만들기 전에 정규화돼요.

**토큰 계산:** Anthropic 출력은 `input_tokens`에서 `cached_tokens`와 `cache_write_tokens`를 빼고,
각각 `cache_read_input_tokens`와 `cache_creation_input_tokens`로 노출해요. 요청 로그는 이를 다시
Expand Down
9 changes: 6 additions & 3 deletions docs-site/src/content/docs/ru/guides/claude-code.md
Original file line number Diff line number Diff line change
Expand Up @@ -310,9 +310,12 @@ id/name; именованный `tool_choice` без имени.
автоматическим `cache_control` верхнего уровня. На стабильных ходах диалога попадание в кеш
обычно составляет около 99,9%.

**Нативная маршрутизация OpenAI/ChatGPT:** формируется сессионный `prompt_cache_key` (из
`metadata.user_id`, если он есть, иначе — из хеша системного содержимого) и заголовок
`session_id` для привязки к кешу. В ключ кеша входят модель и полные схемы инструментов.
**Нативная маршрутизация OpenAI/ChatGPT:** при наличии системного содержимого формируется
ключ `prompt_cache_key`, привязанный к разрешённой модели, нормализованному системному
содержимому и полным схемам инструментов. Без системного содержимого используется
`metadata.user_id`. При наличии метаданных отдельно формируется сессионный заголовок
`session_id` для привязки к бэкенду. Списки инструментов и системных напоминаний нормализуются
до формирования обоих входов кеша.

**Арифметика токенов:** в ответе Anthropic из `input_tokens` вычитаются `cached_tokens` и
`cache_write_tokens`, которые выводятся как `cache_read_input_tokens` и
Expand Down
7 changes: 4 additions & 3 deletions docs-site/src/content/docs/zh-cn/guides/claude-code.md
Original file line number Diff line number Diff line change
Expand Up @@ -288,9 +288,10 @@ role;`tool_result` 缺少 `tool_use_id`;`tool_use` 缺少 id/name;指定
**Anthropic 路由请求:**适配器会管理工具、系统内容和倒数第二条用户消息的缓存断点,以及顶层
自动 `cache_control`。稳定轮次通常能达到约 99.9% 的缓存命中率。

**原生 OpenAI/ChatGPT 路由:**派生会话范围的 `prompt_cache_key`(存在时取自
`metadata.user_id`,否则回退到系统内容哈希)和用于缓存亲和性的 `session_id` 请求头。
缓存键包含模型和完整的工具 schema。
**原生 OpenAI/ChatGPT 路由:**存在系统内容时,从解析后的模型、规范化的系统内容和完整工具
schema 派生内容范围的 `prompt_cache_key`。没有系统内容时,回退到 `metadata.user_id`。
存在元数据时,会另行派生用于后端亲和性的每会话 `session_id` 请求头。工具和系统提醒列表会在
构建任一缓存输入前规范化。

**Token 计算:**Anthropic 输出会从 `input_tokens` 中减去 `cached_tokens` 和
`cache_write_tokens`,并将它们分别公开为 `cache_read_input_tokens` 和
Expand Down
82 changes: 82 additions & 0 deletions src/adapters/anthropic-sort-stabilize.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/**
* Deterministic ordering of Claude Code's own tool/skills/deferred-tools listings on
* the native Anthropic passthrough (`anthropicNativePassthrough`, `claude-messages.ts`).
* Claude Code enumerates MCP tools/skills in whatever order its own reconnect/discovery
* race resolves them, so byte-identical conversations can arrive with different array
* order turn to turn — busting Anthropic's prompt-cache prefix for no reason. Sorting is
* safe: `tool_choice` targets tools by name, not position, and the two system-reminder
* blocks below are pure listings with no inherent order the model depends on.
*
* Ported (algorithm only, re-implemented in TypeScript) from `sort-stabilization.mjs`,
* MIT licensed, github.com/cnighswonger/claude-code-cache-fix.
*/

type Rec = Record<string, unknown>;

function isRec(v: unknown): v is Rec {
return !!v && typeof v === "object" && !Array.isArray(v);
}

const SKILLS_BLOCK_RE = /^([\s\S]*?\n\n)(- [\s\S]+?)(\n<\/system-reminder>)([\s\S]*)$/;
const DEFERRED_TOOLS_BLOCK_RE = /^(<system-reminder>\nThe following deferred tools are now available[^\n]*\n)([\s\S]+?)(\n<\/system-reminder>)([\s\S]*)$/;

function codePointCompare(a: string, b: string): number {
return a < b ? -1 : a > b ? 1 : 0;
}

export function isSkillsBlockText(text: unknown): text is string {
return typeof text === "string" && text.includes("User-invocable skills");
}

export function isDeferredToolsBlockText(text: unknown): text is string {
return typeof text === "string" && text.includes("deferred tools are now available");
}

export function sortSkillsBlockText(text: string): string {
const match = text.match(SKILLS_BLOCK_RE);
if (!match) return text;
const [, header, entriesText, footer, suffix] = match;
const entries = entriesText.split(/\n(?=- )/);
entries.sort(codePointCompare);
return header + entries.join("\n") + footer + suffix;
}

export function sortDeferredToolsBlockText(text: string): string {
const match = text.match(DEFERRED_TOOLS_BLOCK_RE);
if (!match) return text;
const [, header, toolsList, footer, suffix] = match;
const tools = toolsList.split("\n").map(t => t.trim()).filter(Boolean);
tools.sort(codePointCompare);
return header + tools.join("\n") + footer + suffix;
}

/** Normalize known system-reminder listings; leave all other instructions untouched. */
export function normalizeSystemReminderText(text: string): string {
if (isSkillsBlockText(text)) return sortSkillsBlockText(text);
if (isDeferredToolsBlockText(text)) return sortDeferredToolsBlockText(text);
Comment on lines +55 to +56

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Normalize every reminder block in a system string

When one system text contains both a skills reminder and a later deferred-tools reminder, this early return normalizes only the skills list and leaves the deferred-tools list in its original discovery order. The code already preserves following reminder blocks, so this combined-block shape is accepted, but routed instructions/prompt_cache_key and native passthrough bodies still vary whenever the later deferred-tools listing arrives in a different order; run both normalizers over the same text or scan each reminder block independently.

Useful? React with 👍 / 👎.

return text;
}

/** Sort tool definitions by name; unnamed server tools sort first. */
export function sortToolsByName(tools: unknown[]): void {
tools.sort((a, b) => {
const nameA = isRec(a) && typeof a.name === "string" ? a.name : "";
const nameB = isRec(b) && typeof b.name === "string" ? b.name : "";
return codePointCompare(nameA, nameB);
});
}

/** Sort `body.system` skills/deferred-tools listings and `body.tools` by name, in place. */
export function stabilizeSystemAndToolOrder(body: Rec): void {
if (Array.isArray(body.system)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Normalize native string system prompts too

When native passthrough receives the valid Anthropic string form of system, this guard skips it entirely, so a string system reminder such as User-invocable skills remains in the client’s nondeterministic order while the array form is normalized. That leaves native Anthropic /v1/messages and /count_tokens cache inputs unstable for one of the accepted request shapes; handle typeof body.system === "string" with normalizeSystemReminderText before the array branch.

Useful? React with 👍 / 👎.

const system = body.system as unknown[];
for (let i = 0; i < system.length; i++) {
const block = system[i];
if (!isRec(block) || block.type !== "text" || typeof block.text !== "string") continue;
const normalized = normalizeSystemReminderText(block.text);
if (normalized !== block.text) system[i] = { ...block, text: normalized };
}
}

if (Array.isArray(body.tools)) sortToolsByName(body.tools as unknown[]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve tool cache breakpoints after sorting

When a native Anthropic request includes a tool-level cache_control breakpoint on the last tool, this unconditional sort can move that annotated tool away from the end, e.g. [Zeta, Alpha(cache_control)] becomes [Alpha(cache_control), Zeta]. Anthropic cache breakpoints are positional, and the repo’s own applyPromptCaching strategy places the breakpoint on the last tool so the whole tool prefix is cached; after this change the prefix can omit later tools and defeat the cache stabilization this patch is trying to provide. Reapply or move the tool-level breakpoint after sorting, or avoid sorting arrays that already carry explicit tool breakpoints.

Useful? React with 👍 / 👎.

}
Comment on lines +70 to +82

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

stabilizeSystemAndToolOrder skips string-typed body.system.

Only Array.isArray(body.system) is handled (Line 71). Anthropic's /v1/messages API also accepts system as a plain string, and src/claude/inbound.ts's systemToInstructions/systemMessageText explicitly handle both shapes. Here, a string system containing the skills/deferred-tools listing silently skips normalization for native-passthrough requests, leaving that shape's cache prefix unstabilized — the exact bug this PR sets out to fix.

🐛 Proposed fix
 export function stabilizeSystemAndToolOrder(body: Rec): void {
-  if (Array.isArray(body.system)) {
+  if (typeof body.system === "string") {
+    body.system = normalizeSystemReminderText(body.system);
+  } else if (Array.isArray(body.system)) {
     const system = body.system as unknown[];
     for (let i = 0; i < system.length; i++) {
       const block = system[i];
       if (!isRec(block) || block.type !== "text" || typeof block.text !== "string") continue;
       const normalized = normalizeSystemReminderText(block.text);
       if (normalized !== block.text) system[i] = { ...block, text: normalized };
     }
   }
 
   if (Array.isArray(body.tools)) sortToolsByName(body.tools as unknown[]);
 }

tests/claude-native-passthrough.test.ts only exercises the array-system shape (Lines 194-197), so this gap isn't currently covered by tests either.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export function stabilizeSystemAndToolOrder(body: Rec): void {
if (Array.isArray(body.system)) {
const system = body.system as unknown[];
for (let i = 0; i < system.length; i++) {
const block = system[i];
if (!isRec(block) || block.type !== "text" || typeof block.text !== "string") continue;
const normalized = normalizeSystemReminderText(block.text);
if (normalized !== block.text) system[i] = { ...block, text: normalized };
}
}
if (Array.isArray(body.tools)) sortToolsByName(body.tools as unknown[]);
}
export function stabilizeSystemAndToolOrder(body: Rec): void {
if (typeof body.system === "string") {
body.system = normalizeSystemReminderText(body.system);
} else if (Array.isArray(body.system)) {
const system = body.system as unknown[];
for (let i = 0; i < system.length; i++) {
const block = system[i];
if (!isRec(block) || block.type !== "text" || typeof block.text !== "string") continue;
const normalized = normalizeSystemReminderText(block.text);
if (normalized !== block.text) system[i] = { ...block, text: normalized };
}
}
if (Array.isArray(body.tools)) sortToolsByName(body.tools as unknown[]);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/adapters/anthropic-sort-stabilize.ts` around lines 70 - 82, Update
stabilizeSystemAndToolOrder to normalize string-valued body.system using
normalizeSystemReminderText, while preserving the existing per-block handling
for array-valued system content. Ensure native-passthrough requests with a plain
string system receive the same stabilization as array requests, and add coverage
for the string shape in the relevant native passthrough tests.

24 changes: 19 additions & 5 deletions src/claude/inbound.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@
* - top_k is accepted and silently dropped (no Responses equivalent, CCR parity).
*/
import type { OcxClaudeCodeConfig } from "../types";
import {
normalizeSystemReminderText,
sortToolsByName,
} from "../adapters/anthropic-sort-stabilize";
import { resolveAlias } from "./alias";
import { stripOneMillionMarker } from "./context-windows";
import { resolveDesktop3pAlias } from "./desktop-3p";
Expand Down Expand Up @@ -69,11 +73,12 @@ export function effortFromOutputConfig(outputConfig: unknown): string | undefine
}

function systemToInstructions(system: unknown): string | undefined {
if (typeof system === "string") return system.length > 0 ? system : undefined;
if (typeof system === "string") return system.length > 0 ? normalizeSystemReminderText(system) : undefined;
if (Array.isArray(system)) {
const parts: string[] = [];
for (const block of system) {
if (isRec(block) && block.type === "text" && typeof block.text === "string") parts.push(block.text);
if (!isRec(block) || block.type !== "text" || typeof block.text !== "string") continue;
parts.push(normalizeSystemReminderText(block.text));
}
return parts.length > 0 ? parts.join("\n\n") : undefined;
}
Expand Down Expand Up @@ -229,11 +234,13 @@ function blockedSkillCallIds(messages: readonly unknown[], blocked: readonly str
* `instructions` is the only shape that works on every route.
*/
function systemMessageText(content: unknown): string {
if (typeof content === "string") return content;
if (typeof content === "string") return normalizeSystemReminderText(content);
if (!Array.isArray(content)) return "";
const parts: string[] = [];
for (const raw of content) {
if (isRec(raw) && raw.type === "text" && typeof raw.text === "string") parts.push(raw.text);
if (isRec(raw) && raw.type === "text" && typeof raw.text === "string") {
parts.push(normalizeSystemReminderText(raw.text));
}
}
return parts.join("\n\n");
}
Expand Down Expand Up @@ -340,7 +347,14 @@ function toolsToResponses(tools: unknown): Rec[] | undefined {
}
// Other server tools (bash_*, text_editor_*, ...) have no routed equivalent: drop.
}
return out.length > 0 ? out : undefined;
if (out.length === 0) return undefined;
// Claude Code's MCP tool discovery races non-deterministically turn to turn, so this
// array can arrive in a different order for an otherwise-identical conversation. This
// feeds both the outgoing wire body and prompt_cache_key below, so an unstable order
// busts the cache prefix and the cache-key routing together. tool_choice targets by
// name (see toolChoiceToResponses), not position, so sorting is behavior-preserving.
sortToolsByName(out);
return out;
}

function toolChoiceToResponses(choice: unknown, body: Rec): void {
Expand Down
27 changes: 18 additions & 9 deletions src/server/claude-messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@
* internal Request, so routing/OAuth/account-pool/failover/sidecars are inherited
* unchanged. The Responses output (SSE or JSON) is converted back to Anthropic shape.
*/
import { createHash } from "node:crypto";
import { FORWARD_HEADERS } from "../adapters/openai-responses";
import { enforceAnthropicImageLimits } from "../adapters/anthropic-image-guard";
import { normalizeAnthropicImages } from "../adapters/anthropic-image-normalize";
import { stabilizeSystemAndToolOrder } from "../adapters/anthropic-sort-stabilize";
import { AnthropicRequestError, anthropicToResponsesTranslation, extractOcxRouteDirective, resolveInboundModel, type ClaudeCacheKeySource } from "../claude/inbound";
import { resolveDesktop3pAlias } from "../claude/desktop-3p";
import { recordDesktopRequest } from "../claude/desktop-health";
Expand Down Expand Up @@ -104,6 +106,11 @@ function uuidFromHex(hex32: string): string {
return `${h.slice(0, 8)}-${h.slice(8, 12)}-4${h.slice(13, 16)}-8${h.slice(17, 20)}-${h.slice(20, 32)}`;
}

function claudeMetadataUserId(body: unknown): string | undefined {
if (!isRec(body) || !isRec(body.metadata)) return undefined;
return typeof body.metadata.user_id === "string" ? body.metadata.user_id : undefined;
}

function anthropicUsageToOcx(usage: Rec | undefined): { inputTokens: number; outputTokens: number; cachedInputTokens?: number; cacheReadInputTokens?: number; cacheCreationInputTokens?: number } | undefined {
if (!usage) return undefined;
const num = (v: unknown) => typeof v === "number" ? v : 0;
Expand Down Expand Up @@ -312,6 +319,7 @@ async function anthropicNativePassthrough(
await normalizeAnthropicImages(body.messages);
enforceAnthropicImageLimits(body.messages);
}
stabilizeSystemAndToolOrder(body);
const headers = new Headers();
req.headers.forEach((value, name) => {
if (!PASSTHROUGH_STRIP_HEADERS.has(name.toLowerCase())) headers.set(name, value);
Expand Down Expand Up @@ -649,15 +657,16 @@ export async function handleClaudeMessages(
headers.set("authorization", `Bearer ${token.accessToken}`);
headers.set("chatgpt-account-id", token.chatgptAccountId);
}
// ChatGPT-backend prompt-cache affinity rides the session_id HEADER (codex
// clients always send their session uuid; devlog 090 follow-up: body-level
// prompt_cache_key alone still yielded cached_tokens:0). Claude Code never sends
// the header, so synthesize a stable per-session uuid from the same cache key —
// but ONLY for a real per-session key (metadata.user_id). The system-hash fallback
// key is shared across Desktop conversations, and a shared session_id's backend
// semantics are unproven (audit 133 R2#3): body prompt_cache_key only there.
if (cacheKeySource === "metadata" && !headers.has("session_id") && typeof internalBody.prompt_cache_key === "string") {
headers.set("session_id", uuidFromHex(internalBody.prompt_cache_key));
// ChatGPT-backend prompt-cache affinity rides the session_id HEADER. Keep it
// session-scoped even when prompt_cache_key is content-first: metadata.user_id
// identifies the Claude Code session, while the content hash is shared across
// equivalent conversations and must never become a shared session identifier.
const metadataUserId = claudeMetadataUserId(anthropicBody);
if (!headers.has("session_id") && metadataUserId) {
headers.set(
"session_id",
uuidFromHex(createHash("sha256").update(metadataUserId).digest("hex").slice(0, 32)),
);
}
}
const internalReq = new Request("http://localhost/v1/responses", {
Expand Down
Loading
Loading