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
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,9 @@ subscription with a warning when detection is inconclusive. See
Codex uses small helper models for tasks such as titles and commit messages. Enable
`shadowCallIntercept` to redirect recognized source-model prefixes to another configured model. The
replacement runs at low effort. Set `sourceModels` only when a client uses different helper ids.
Codex 0.145.0+ marks request purpose in `x-codex-turn-metadata`: normal `request_kind: "turn"`
requests keep the selected model, while recognized maintenance requests can be redirected. Clients
without that metadata retain the legacy prefix behavior.

```json
{
Expand Down
14 changes: 9 additions & 5 deletions docs/shadow-call-intercept.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,14 +63,18 @@ the defaults rather than extending them:

### Behavior

- When enabled, ALL requests whose bare model id starts with one of the source-model prefixes
(default `gpt-5.4-mini`, `gpt-5.6-luna`) are rewritten to the configured model
- Matching maintenance requests, including `prewarm`, `compaction`, and `memory`, are
rewritten to the configured model
- Normal user turns identified by `x-codex-turn-metadata` with `request_kind: "turn"` are
never rewritten
- Headerless legacy clients retain the original prefix behavior: matching bare model ids are
rewritten
- Missing, malformed, or unrecognized turn metadata retains the legacy prefix behavior
- Reasoning effort is forced to `low` (matching the original behavior)
- The original model ID is logged as `shadowCallRewrittenFrom` in request logs
- When disabled (default), no interception occurs

### Warning

Enabling this redirects every request for a source model, not just Codex's background helper turns.
`gpt-5.6-luna` is also a selectable chat model, so if you pick it as your main model while the
intercept is on, those turns are redirected too — narrow `sourceModels` if that matters to you.
Headerless clients cannot distinguish foreground turns from background helper calls. If such a
client uses `gpt-5.6-luna` as its main model, narrow `sourceModels` or disable the intercept.
24 changes: 24 additions & 0 deletions src/lib/shadow-call.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,27 @@ export function isShadowSourceModel(modelId: string, configured?: unknown): bool
if (modelId.includes("/")) return false;
return shadowSourceModels(configured).some(prefix => modelId.startsWith(prefix));
}

/**
* Decide whether a matching source model should use the opt-in intercept.
*
* Codex 0.145.0+ identifies normal user turns and maintenance requests in
* x-codex-turn-metadata. Only an explicit normal turn bypasses interception;
* missing or unrecognized metadata retains the legacy opt-in prefix behavior.
*/
export function shouldInterceptShadowCall(
modelId: string,
configured: unknown,
headers: Headers,
): boolean {
if (!isShadowSourceModel(modelId, configured)) return false;
const rawMetadata = headers.get("x-codex-turn-metadata");
if (rawMetadata === null) return true;

try {
const parsed = JSON.parse(rawMetadata) as { request_kind?: unknown };
return parsed?.request_kind !== "turn";
} catch {
return true;
}
}
8 changes: 6 additions & 2 deletions src/server/responses/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ export function sidecarOutcomeRecorder(



import { isShadowSourceModel } from "../../lib/shadow-call";
import { isShadowSourceModel, shouldInterceptShadowCall } from "../../lib/shadow-call";

export { DEFAULT_SHADOW_SOURCE_MODELS, isShadowSourceModel, shadowSourceModels } from "../../lib/shadow-call";

Expand Down Expand Up @@ -1247,7 +1247,11 @@ async function handleResponsesInner(
// Shadow call intercept: rewrite Codex's hard-coded helper calls
// (gpt-5.4-mini on older clients, gpt-5.6-luna on 0.145.0+)
const _sci = config.shadowCallIntercept;
if (_sci?.enabled && _sci.model && isShadowSourceModel(parsed.modelId, _sci.sourceModels)) {
if (_sci?.enabled && _sci.model && shouldInterceptShadowCall(
parsed.modelId,
_sci.sourceModels,
req.headers,
)) {
const _sciOriginal = parsed.modelId;
parsed.modelId = _sci.model;
if (parsed._rawBody && typeof parsed._rawBody === "object") {
Expand Down
3 changes: 2 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -645,7 +645,8 @@ export interface OcxConfig {
* Shadow call intercept: redirect Codex's hard-coded helper calls (title generation,
* commit messages, skill orchestration) to a user-chosen model. Default intercepted
* source models: gpt-5.4-mini (older clients) and gpt-5.6-luna (Codex 0.145.0+).
* Opt-in; disabled by default. When enabled, effort is forced to low.
* Opt-in; disabled by default. Matching maintenance/helper requests are forced to low.
* Normal Codex turns identified by request_kind=turn are never rewritten.
*/
shadowCallIntercept?: {
/** When true, requests for known shadow/helper source models are rewritten to the configured model. */
Expand Down
60 changes: 57 additions & 3 deletions tests/responses-shadow-intercept.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { handleResponses, isShadowSourceModel } from "../src/server/responses";
import { shouldInterceptShadowCall } from "../src/lib/shadow-call";
import { handleManagementAPI } from "../src/server/management-api";
import type { OcxConfig } from "../src/types";

Expand Down Expand Up @@ -51,6 +52,42 @@ describe("isShadowSourceModel", () => {
});
});

describe("shouldInterceptShadowCall", () => {
const metadata = (requestKind: string) => new Headers({
"x-codex-turn-metadata": JSON.stringify({ request_kind: requestKind }),
});

test("intercepts recognized maintenance kinds but not normal turns", () => {
expect(shouldInterceptShadowCall("gpt-5.6-luna", undefined, metadata("memory"))).toBe(true);
expect(shouldInterceptShadowCall("gpt-5.6-luna", undefined, metadata("compaction"))).toBe(true);
expect(shouldInterceptShadowCall("gpt-5.6-luna", undefined, metadata("prewarm"))).toBe(true);
expect(shouldInterceptShadowCall("gpt-5.6-luna", undefined, metadata("turn"))).toBe(false);
});

test("keeps legacy matching for headerless, malformed, and unrecognized metadata", () => {
expect(shouldInterceptShadowCall("gpt-5.6-luna", undefined, new Headers())).toBe(true);
expect(shouldInterceptShadowCall(
"gpt-5.6-luna",
undefined,
new Headers({ "x-codex-turn-metadata": "{" }),
)).toBe(true);
expect(shouldInterceptShadowCall(
"gpt-5.6-luna",
undefined,
new Headers({ "x-codex-turn-metadata": "{}" }),
)).toBe(true);
expect(shouldInterceptShadowCall("gpt-5.6-luna", undefined, metadata("future-kind"))).toBe(true);
});

test("uses case-insensitive Headers lookup and still excludes non-source models", () => {
const headers = new Headers({
"X-CoDeX-TuRn-MeTaDaTa": JSON.stringify({ request_kind: "turn" }),
});
expect(shouldInterceptShadowCall("gpt-5.6-luna", undefined, headers)).toBe(false);
expect(shouldInterceptShadowCall("gpt-5.6-terra", undefined, metadata("memory"))).toBe(false);
});
});

function interceptConfig(): OcxConfig {
return {
port: 0,
Expand All @@ -67,10 +104,14 @@ function interceptConfig(): OcxConfig {
} as OcxConfig;
}

async function post(config: OcxConfig, model: string): Promise<Response> {
async function post(config: OcxConfig, model: string, requestKind?: string): Promise<Response> {
const headers: Record<string, string> = { "content-type": "application/json" };
if (requestKind) {
headers["x-codex-turn-metadata"] = JSON.stringify({ request_kind: requestKind });
}
return handleResponses(new Request("http://localhost/v1/responses", {
method: "POST",
headers: { "content-type": "application/json" },
headers,
body: JSON.stringify({
model,
input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] }],
Expand All @@ -91,7 +132,7 @@ describe("shadow call intercept request path (issue #311)", () => {
}), { status: 200, headers: { "content-type": "application/json" } });
}) as typeof fetch;

await post(interceptConfig(), "gpt-5.6-luna");
await post(interceptConfig(), "gpt-5.6-luna", "memory");

expect(bodies.length).toBe(1);
// Routed through xai openai-chat: upstream model is the decoded routed id, not the helper id
Expand All @@ -101,6 +142,19 @@ describe("shadow call intercept request path (issue #311)", () => {
expect(effort).toBe("low");
});

test("does not rewrite a foreground gpt-5.6-luna turn", async () => {
let sawFetch = false;
globalThis.fetch = (async () => {
sawFetch = true;
return new Response(JSON.stringify({ error: { message: "unreachable" } }), { status: 500 });
}) as typeof fetch;

const response = await post(interceptConfig(), "gpt-5.6-luna", "turn");

expect(sawFetch).toBe(false);
expect(response.status).toBe(404);
});

test("leaves gpt-5.6-terra requests unrewritten", async () => {
let sawFetch = false;
globalThis.fetch = (async () => {
Expand Down
Loading