Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
3a0b88f
fix(openai-responses): repair oversized replay call_ids on non-forwar…
jgautheron Jul 27, 2026
41deeef
fix(claude/inbound): prefer content-based prompt_cache_key over sessi…
jgautheron Jul 27, 2026
23ec63f
fix(cli): ocx restart no longer silently no-ops when codexAutoStart i…
jgautheron Jul 27, 2026
ad58ac8
Merge fix/call-id-oversized-replay into main
jgautheron Jul 27, 2026
0215404
Merge fix/cross-session-cache-key-priority into main
jgautheron Jul 27, 2026
44d0294
Merge fix/restart-ignores-codex-autostart into main
jgautheron Jul 27, 2026
152682b
fix: serialize parallel subagent dispatches sharing a cold prompt_cac…
jgautheron Jul 27, 2026
627e43c
Merge fix/cold-cache-key-parallel-lease into main
jgautheron Jul 27, 2026
5823712
feat(router): warn when a provider's bare gpt-/o1-/o3-/o4- model is u…
jgautheron Jul 27, 2026
4430ed3
Merge fix/warn-unreachable-bare-model-aliases into main
jgautheron Jul 27, 2026
d9a2a55
Merge remote-tracking branch 'upstream/dev'
jgautheron Jul 27, 2026
9ce0464
feat(router): extend bare-model warning to MODEL_PROVIDER_PATTERNS co…
jgautheron Jul 27, 2026
00b3861
Merge fix/warn-unreachable-bare-model-aliases (pattern-table extensio…
jgautheron Jul 27, 2026
ff0596d
fix(cli): restart/tray-start lost the Grok fence + Codex model sync
jgautheron Jul 27, 2026
e20c21e
test(claude-inbound): fix devlog 130 B3 test to match content-first c…
jgautheron Jul 27, 2026
8298112
Merge branch 'fix/cache-key-provenance-test-parity'
jgautheron Jul 27, 2026
b001af9
docs(router): simplify bare-model-alias warning comment
jgautheron Jul 27, 2026
7b0fb67
docs: simplify cold-cache-key-lease comments
jgautheron Jul 27, 2026
ba2085b
docs(claude/inbound): simplify cache-key-priority comments
jgautheron Jul 27, 2026
2f3a7dd
docs: simplify restart/grok-sync-parity comments
jgautheron Jul 27, 2026
b4f95ae
docs(claude-inbound): simplify cache-key test comments
jgautheron Jul 27, 2026
bae00dd
Merge branch 'fix/warn-unreachable-bare-model-aliases'
jgautheron Jul 27, 2026
4da468a
Merge branch 'fix/cold-cache-key-parallel-lease'
jgautheron Jul 27, 2026
479390e
Merge branch 'fix/cross-session-cache-key-priority'
jgautheron Jul 27, 2026
a0972c2
Merge branch 'fix/restart-grok-model-sync-parity'
jgautheron Jul 27, 2026
11cdee2
Merge branch 'fix/cache-key-provenance-test-parity'
jgautheron Jul 27, 2026
d637087
fix(usage): generic wildcard overlay fallback for private-gateway pri…
jgautheron Jul 27, 2026
3af6d4a
fix(adapters): extract cached_tokens from Responses API usage
jgautheron Jul 27, 2026
bfda29d
test(router): rename ambiguous fixture provider key
jgautheron Jul 27, 2026
d99a373
Merge branch 'fix/openai-responses-cached-tokens'
jgautheron Jul 27, 2026
b8ddda0
Merge branch 'fix/warn-test-fixture-provider-naming'
jgautheron Jul 27, 2026
dc72a4a
fix: stabilize cache inputs
jgautheron Jul 28, 2026
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
7 changes: 4 additions & 3 deletions docs-site/src/content/docs/guides/claude-code.md
Original file line number Diff line number Diff line change
Expand Up @@ -383,9 +383,10 @@ 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:** derives a content-scoped `prompt_cache_key` from the
resolved model, normalized system content, and full tool schemas. When `metadata.user_id` is
present, it 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 +386 to +389

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Doc omits the metadata.user_id fallback for prompt_cache_key.

This section states the key is unconditionally "content-scoped," but anthropicToResponsesTranslation (src/claude/inbound.ts) only does that when systemParts.length > 0; with no system content it falls back to hashing metadata.user_id (cacheKeySource = "metadata"). Worth a one-line caveat so operators reading this don't assume every request gets a content-scoped key.

Additionally, please confirm the ja/ko/ru/zh-cn translations of this guide are updated to match — they aren't included in this diff.

📝 Suggested doc tweak
 **Native OpenAI/ChatGPT routing:** derives a content-scoped `prompt_cache_key` from the
 resolved model, normalized system content, and full tool schemas. When `metadata.user_id` is
 present, it separately derives a per-session `session_id` header for backend affinity. Tool and
 system-reminder listings are canonicalized before either cache input is built.
+
+When a request has no system content to fingerprint, `prompt_cache_key` falls back to a
+`metadata.user_id`-derived key instead.

As per path instructions, "translated locale pages (ja, ko, ru, zh-cn) are not left contradicting the English source."

📝 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
**Native OpenAI/ChatGPT routing:** derives a content-scoped `prompt_cache_key` from the
resolved model, normalized system content, and full tool schemas. When `metadata.user_id` is
present, it separately derives a per-session `session_id` header for backend affinity. Tool and
system-reminder listings are canonicalized before either cache input is built.
**Native OpenAI/ChatGPT routing:** derives a content-scoped `prompt_cache_key` from the
resolved model, normalized system content, and full tool schemas. When `metadata.user_id` is
present, it separately derives a per-session `session_id` header for backend affinity. Tool and
system-reminder listings are canonicalized before either cache input is built.
When a request has no system content to fingerprint, `prompt_cache_key` falls back to a
`metadata.user_id`-derived key instead.
🤖 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 386 - 389,
Update the Native OpenAI/ChatGPT routing documentation to state that
prompt_cache_key is content-scoped when system content exists, but falls back to
hashing metadata.user_id when no system content is present. Apply the same
clarification to the ja, ko, ru, and zh-cn translations so they remain
consistent with the English guide.

Source: Path instructions


**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
78 changes: 78 additions & 0 deletions src/adapters/anthropic-sort-stabilize.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/**
* 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.
*/
Comment on lines +1 to +12

Copy link
Copy Markdown

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm whether stabilizeSystemAndToolOrder is ever imported/called
rg -n "stabilizeSystemAndToolOrder" --type=ts

Repository: lidge-jun/opencodex

Length of output: 157


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== File outline ==\n'
ast-grep outline src/adapters/anthropic-sort-stabilize.ts --view expanded || true

printf '\n== Search for related symbols and file imports ==\n'
rg -n --hidden --glob '!node_modules' --glob '!dist' --glob '!build' \
  "anthropic-sort-stabilize|sort-stabilize|stabilizeSystemAndToolOrder|normalizeSystemReminderText|sortToolsByName|anthropicNativePassthrough|claude-messages" src

printf '\n== Key files that mention the native passthrough or inbound normalization ==\n'
for f in src/adapters/claude-messages.ts src/adapters/inbound.ts src/adapters/anthropic-sort-stabilize.ts; do
  if [ -f "$f" ]; then
    echo "--- $f ---"
    wc -l "$f"
    cat -n "$f" | sed -n '1,220p'
  fi
done

Repository: lidge-jun/opencodex

Length of output: 7115


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== Search for imports of the file by path ==\n'
rg -n --hidden --glob '!node_modules' --glob '!dist' --glob '!build' \
  "from ['\"].*anthropic-sort-stabilize['\"]|require\\(['\"].*anthropic-sort-stabilize['\"]\\)" src

printf '\n== Search for any direct references to the function name in non-source files ==\n'
rg -n --hidden --glob '!node_modules' --glob '!dist' --glob '!build' \
  "stabilizeSystemAndToolOrder" .

Repository: lidge-jun/opencodex

Length of output: 465


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '== Imports and passthrough body flow in src/server/claude-messages.ts =='
ast-grep outline src/server/claude-messages.ts --view expanded || true

echo
echo '== Relevant slices =='
cat -n src/server/claude-messages.ts | sed -n '1,140p'
echo
cat -n src/server/claude-messages.ts | sed -n '280,380p'
echo
cat -n src/server/claude-messages.ts | sed -n '540,620p'
echo
cat -n src/server/claude-messages.ts | sed -n '840,880p'

Repository: lidge-jun/opencodex

Length of output: 21360


Call stabilizeSystemAndToolOrder() before the native passthrough request is serialized.
src/adapters/anthropic-sort-stabilize.ts:66-78 exports the normalizer, but src/server/claude-messages.ts:291-329 still forwards body to Anthropic unchanged. On sk-ant-* requests that leaves system/tools ordering dependent on Claude Code’s discovery race, so the prompt-cache prefix can still drift. Apply the helper in anthropicNativePassthrough() before JSON.stringify(body).

🤖 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 1 - 12, Update
anthropicNativePassthrough() to call stabilizeSystemAndToolOrder() on the
request body before it is serialized with JSON.stringify. Ensure sk-ant-* native
passthrough requests send the normalized system and tools ordering while
preserving the existing request flow and payload contents.


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

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] = match;
const entries = entriesText.split(/\n(?=- )/);
entries.sort();
return header + entries.join("\n") + footer;
}

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

/** 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);
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 nameA.localeCompare(nameB);
});
}

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

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 Invoke native Anthropic order stabilization

This helper is documented as stabilizing the native Anthropic passthrough, but rg stabilizeSystemAndToolOrder only finds this definition and the native path still serializes body directly in anthropicNativePassthrough. As a result, subscription/native Claude requests continue to forward nondeterministic system reminder and tools ordering, so the cache-input fix only applies to routed translation. Call this in the native passthrough before JSON.stringify(body).

Useful? React with 👍 / 👎.

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[]);
}
15 changes: 14 additions & 1 deletion src/adapters/openai-responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -660,10 +660,14 @@ function usageFromResponsesPayload(payload: unknown): OcxUsage | undefined {
const inputTokens = typeof usage.input_tokens === "number" ? usage.input_tokens : 0;
const outputTokens = typeof usage.output_tokens === "number" ? usage.output_tokens : 0;
if (inputTokens === 0 && outputTokens === 0) return undefined;
const inputDetails = isPlainObject(usage.input_tokens_details) ? usage.input_tokens_details : undefined;
const outputDetails = isPlainObject(usage.output_tokens_details) ? usage.output_tokens_details : undefined;
return {
inputTokens,
outputTokens,
...(typeof usage.total_tokens === "number" ? { totalTokens: usage.total_tokens } : {}),
...(typeof inputDetails?.cached_tokens === "number" ? { cachedInputTokens: inputDetails.cached_tokens } : {}),
...(typeof outputDetails?.reasoning_tokens === "number" ? { reasoningOutputTokens: outputDetails.reasoning_tokens } : {}),
Comment on lines +663 to +670

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Preserve cacheReadInputTokens alongside cachedInputTokens.

This parser reads the same input_tokens_details.cached_tokens field as src/server/request-log.ts:440-492, but only sets cachedInputTokens. Since OcxUsage also exposes cacheReadInputTokens (src/types.ts:308-323), downstream usage or cost accounting can under-report cache reads for passthrough Responses.

Proposed fix
-    ...(typeof inputDetails?.cached_tokens === "number" ? { cachedInputTokens: inputDetails.cached_tokens } : {}),
+    ...(typeof inputDetails?.cached_tokens === "number"
+      ? {
+          cachedInputTokens: inputDetails.cached_tokens,
+          cacheReadInputTokens: inputDetails.cached_tokens,
+        }
+      : {}),
📝 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
const inputDetails = isPlainObject(usage.input_tokens_details) ? usage.input_tokens_details : undefined;
const outputDetails = isPlainObject(usage.output_tokens_details) ? usage.output_tokens_details : undefined;
return {
inputTokens,
outputTokens,
...(typeof usage.total_tokens === "number" ? { totalTokens: usage.total_tokens } : {}),
...(typeof inputDetails?.cached_tokens === "number" ? { cachedInputTokens: inputDetails.cached_tokens } : {}),
...(typeof outputDetails?.reasoning_tokens === "number" ? { reasoningOutputTokens: outputDetails.reasoning_tokens } : {}),
const inputDetails = isPlainObject(usage.input_tokens_details) ? usage.input_tokens_details : undefined;
const outputDetails = isPlainObject(usage.output_tokens_details) ? usage.output_tokens_details : undefined;
return {
inputTokens,
outputTokens,
...(typeof usage.total_tokens === "number" ? { totalTokens: usage.total_tokens } : {}),
...(typeof inputDetails?.cached_tokens === "number"
? {
cachedInputTokens: inputDetails.cached_tokens,
cacheReadInputTokens: inputDetails.cached_tokens,
}
: {}),
...(typeof outputDetails?.reasoning_tokens === "number" ? { reasoningOutputTokens: outputDetails.reasoning_tokens } : {}),
🤖 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/openai-responses.ts` around lines 663 - 670, Update the usage
object returned by the Responses parser to populate both cachedInputTokens and
cacheReadInputTokens from inputDetails.cached_tokens, preserving the existing
numeric guard and all other token mappings.

};
}

Expand Down Expand Up @@ -741,7 +745,16 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
outBody = stripUnsupportedForwardParams(outBody);
}
else outBody = stripConflictingHostedTools(outBody);
if (forward || parsed._previousResponseInputExpanded === true) {
// Claude Code's Messages API never sets previousResponseId at all, so on a
// custom apiKey-auth provider (non-forward) the original forward-only guard
// never fired, and long tool-chains hit "Invalid 'input[N].call_id': string
// too long" (call_id replay can exceed the Responses API's 64-char limit).
// !unexpandedMiss is a safe superset of the old condition: it still covers
// forward mode and already-expanded replays, but also covers any request with
// no previousResponseId at all (every Claude-surfaced request) — while still
// excluding the one genuinely risky case, a raw unexpanded native-Codex
// continuation that might reference call ids stored upstream.
if (!unexpandedMiss) {
Comment on lines +748 to +757

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Add regression coverage for the replay-repair truth table.

!unexpandedMiss is not a superset of the previous forward || expanded condition: it now skips forward requests that have a previousResponseId and unexpanded input. Verify that exclusion is intentional, then test at least:

  • API-key request without previousResponseId: oversized call_id is repaired.
  • Forward request without previousResponseId: oversized call_id is repaired.
  • Unexpanded continuation with previousResponseId: repair is intentionally skipped.
  • Expanded replay: oversized call_id is repaired.

No companion test file is included in the supplied review context. As per path instructions, adapter behavior changes should include a focused regression test near the existing tests for that subsystem.

🤖 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/openai-responses.ts` around lines 748 - 757, Add focused
regression coverage near the existing tests for the adapter behavior around the
replay-repair condition `!unexpandedMiss`. Verify the truth table by asserting
oversized `call_id` values are repaired for API-key requests without
`previousResponseId`, forward requests without `previousResponseId`, and
expanded replays, while repair is intentionally skipped for an unexpanded
continuation with `previousResponseId`; also confirm whether that
forward-with-`previousResponseId` exclusion is intentional.

Source: Path instructions

outBody = repairOversizedReplayCallIds(outBody);
}
outBody = stripUnsupportedReasoningSummaryDelivery(outBody, parsed.modelId);
Expand Down
73 changes: 48 additions & 25 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 Expand Up @@ -443,26 +457,30 @@ export function anthropicToResponsesTranslation(raw: unknown, cc?: OcxClaudeCode
let cacheKeySource: ClaudeCacheKeySource = null;
if (isRec(raw.metadata) && typeof raw.metadata.user_id === "string") {
body.user = raw.metadata.user_id;
// OpenAI-side prompt caching is routed by prompt_cache_key (Codex clients send
// their session id; without it consecutive /v1/messages turns reported
// cached_tokens: 0 on the ChatGPT backend — devlog 090). Claude Code's
// metadata.user_id embeds the session uuid, so hashing it yields a stable
// per-session key with a bounded length/charset.
body.prompt_cache_key = createHash("sha256").update(raw.metadata.user_id).digest("hex").slice(0, 32);
cacheKeySource = "metadata";
} else if (systemParts.length > 0) {
// Claude Desktop sends no metadata.user_id (H1, devlog 130): without any key the
// ChatGPT/OpenAI backends reported cached_tokens:0 on every turn. Fall back to a
// cache-cohort hash (devlog 260712 B4 + Pro review 012): fingerprint what the
// upstream actually receives — resolved model, post-translation system, and the
// FULL translated tool definitions in WIRE ORDER (sorting the hash while sending
// a different order would break the key↔prefix correspondence). canonical JSON
// (recursive key sort) + a version field so future normalization changes never
// mix cohorts. system-only keys herded different models/toolsets into one key
// and burned OpenAI's ~15 RPM per-key routing budget (audit R1#4/R2#5/R1#10).
// 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).
}
// OpenAI-side prompt caching is routed by prompt_cache_key, not pure byte-prefix
// matching (Codex clients send their session id; without any key, consecutive
// /v1/messages turns reported cached_tokens: 0 on the ChatGPT backend).
//
// Content-first: fingerprint what the upstream actually receives — resolved model,
// post-translation system, and the FULL translated tool definitions in WIRE ORDER
// (sorting the hash while sending a different order would break the key<->prefix
// correspondence). Canonical JSON (recursive key sort) + a version field so future
// normalization changes never mix cohorts.
//
// Deliberately preferred over metadata.user_id (Claude Code's session uuid) even
// when present: a session-scoped key means every NEW Claude Code session gets a
// fresh key, so a cache warmed by yesterday's session is unreachable today even for
// byte-identical system+tools content — this is a guaranteed cold rewrite on the
// first call of every session, not just cross-session drift. Content-based keying
// lets any session hit a cache any other session already warmed. Session-only keys
// were originally chosen because pure-content keying herds many different
// sessions/models/toolsets onto one key, burning OpenAI's per-key routing budget at
// multi-tenant scale; that risk is far smaller for a single-operator deployment
// where the actual pain is inter-session cache misses. Exact-prefix matching still
// isolates content; the key only steers routing affinity. Callers must NOT
// synthesize a session_id header from this fallback.
if (systemParts.length > 0) {
body.prompt_cache_key = createHash("sha256")
.update(canonicalJson({
version: 2,
Comment on lines 485 to 486

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Keep xAI conversation affinity session-scoped

When a Claude request with system content is routed to provider xai, this content-only key omits both metadata.user_id and user messages. The responses pipeline passes parsed.options.promptCacheKey into resolveProviderTransport, which derives x-grok-conv-id and x-grok-session-id from it (src/providers/xai-transport.ts:101-109), so two separate Claude Code sessions with the same system/tools but different histories now get sent as the same Grok conversation/session. Keep the old metadata-derived key for xAI transport affinity, or avoid feeding this shared cache key into those headers.

Useful? React with 👍 / 👎.

Expand All @@ -472,6 +490,11 @@ export function anthropicToResponsesTranslation(raw: unknown, cc?: OcxClaudeCode
}))
.digest("hex").slice(0, 32);
cacheKeySource = "system";
} else if (isRec(raw.metadata) && typeof raw.metadata.user_id === "string") {
// No system content to fingerprint: fall back to the session-scoped key so the
// request still gets SOME key rather than none.
body.prompt_cache_key = createHash("sha256").update(raw.metadata.user_id).digest("hex").slice(0, 32);
cacheKeySource = "metadata";
}

const thinking = raw.thinking;
Expand Down
23 changes: 21 additions & 2 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,7 @@ async function handleEnsure() {

/** Fixed tray action: start the proxy without depending on codexAutoStart. */
async function handleTrayProxyStart(): Promise<void> {
const config = loadConfig();
const ok = await runTrayProxyStart({
findLive: findLiveProxy,
diagnoseService: () => {
Expand All @@ -395,7 +396,6 @@ async function handleTrayProxyStart(): Promise<void> {
},
startService: () => serviceCommand("start"),
startDirect: () => {
const config = loadConfig();
const port = (config.port ?? 10100) > 0 ? (config.port ?? 10100) : 10100;
const child = spawn(process.execPath, startArgv(port), {
detached: true,
Expand All @@ -406,6 +406,19 @@ async function handleTrayProxyStart(): Promise<void> {
child.unref();
},
waitForProxy,
onStarted: async port => {
await syncModelsToCodex(port).catch(e => {
console.error(`⚠️ Model sync skipped: ${e instanceof Error ? e.message : String(e)}`);
});
try {
const { syncGrokConfig } = await import("../grok/sync");
const g = await syncGrokConfig(port, config, config.hostname ? { hostname: config.hostname } : {});

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 Use the live proxy host when syncing Grok

In the already-running tray start path, waitForProxy/findLiveProxy knows the hostname that the live proxy actually answered on, but onStarted receives only the port and then writes the Grok fence with config.hostname. If the config was edited after the proxy started, this can overwrite ~/.grok/config.toml with a host the running process never bound; handleEnsure deliberately uses live.hostname in that branch. Pass the live hostname through TrayProxyLive/onStarted and use it here.

Useful? React with 👍 / 👎.

if (g.changed) console.log(" + Grok Build config updated (~/.grok/config.toml)");
else if (!g.ok) console.error(`⚠️ ${g.message}`);
} catch (err) {
console.error(`⚠️ ${grokSyncFailureMessage(err)}`);
}
},
info: message => console.log(message),
error: message => console.error(message),
});
Expand Down Expand Up @@ -887,7 +900,13 @@ switch (command) {
case "restart": {
// A failed stop must not be followed by a re-inject: with a foreign service still running
// (ownership mismatch) we would rewrite shared config we just declined to touch.
if (await handleStop()) await handleEnsure();
//
// handleEnsure() early-returns without relaunching when codexAutoStart is disabled —
// that flag governs whether opencodex registers itself with the Codex CLI on boot, an
// unrelated concern, but it silently ate `restart` too: stop succeeded, nothing ever
// came back up. handleTrayProxyStart() (already used by the tray's own restart action)
// relaunches the proxy unconditionally, independent of codexAutoStart.
if (await handleStop()) await handleTrayProxyStart();
else console.error("↩️ Restart aborted: the proxy was not stopped cleanly.");
break;
}
Expand Down
10 changes: 10 additions & 0 deletions src/cli/tray-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,22 @@ export interface TrayProxyStartIo {
waitForProxy: () => Promise<TrayProxyLive | null>;
info: (message: string) => void;
error: (message: string) => void;
/**
* Called once a live proxy is confirmed on `port` — whether it was already running or
* this call just started it. `handleEnsure` performs the Grok-fence and Codex-model
* sync inline in both of its branches; this action skipped both entirely, so `ocx restart`
* silently lost them once restart was repointed at this action. Optional so tests that
* don't care about the sync can omit it.
*/
onStarted?: (port: number) => void | Promise<void>;
Comment on lines +17 to +24

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Preserve the live hostname in the post-start callback.

handleEnsure() already uses live.hostname because the running proxy’s bound hostname can differ from config.hostname. This new callback exposes only port, so handleTrayProxyStart() cannot preserve that metadata and may rewrite ~/.grok/config.toml with a stale or default hostname for an already-running proxy. Extend TrayProxyLive/onStarted to carry the live metadata and use it in both branches.

Also applies to: 32-32, 52-52

🤖 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/cli/tray-proxy.ts` around lines 17 - 24, Extend the
TrayProxyLive/onStarted contract to pass the live proxy metadata, including
hostname, rather than only the port. Update handleEnsure’s already-running and
newly-started branches to invoke the callback with the same live object, and
update handleTrayProxyStart() to use the callback’s live hostname when
synchronizing configuration instead of config.hostname.

}

/** Side-effect coordinator for the tray's fixed proxy-start action. */
export async function runTrayProxyStart(io: TrayProxyStartIo): Promise<boolean> {
const live = await io.findLive();
if (live) {
io.info(`Proxy already running on port ${live.port}.`);
await io.onStarted?.(live.port);
return true;
}

Expand All @@ -40,6 +49,7 @@ export async function runTrayProxyStart(io: TrayProxyStartIo): Promise<boolean>
return false;
}
io.info(`Proxy running on port ${started.port}.`);
await io.onStarted?.(started.port);
return true;
}

Expand Down
Loading
Loading