diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index 23f9887dd..d367ead1c 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -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 { diff --git a/docs/shadow-call-intercept.md b/docs/shadow-call-intercept.md index 6be790c78..ff527c87d 100644 --- a/docs/shadow-call-intercept.md +++ b/docs/shadow-call-intercept.md @@ -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. diff --git a/src/lib/shadow-call.ts b/src/lib/shadow-call.ts index 30a36564a..e49c30aee 100644 --- a/src/lib/shadow-call.ts +++ b/src/lib/shadow-call.ts @@ -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; + } +} diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index e714b3d81..0549b43d1 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -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"; @@ -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") { diff --git a/src/types.ts b/src/types.ts index c4827a8fa..32e1b9bea 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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. */ diff --git a/tests/responses-shadow-intercept.test.ts b/tests/responses-shadow-intercept.test.ts index a04da5d5e..e946f65b4 100644 --- a/tests/responses-shadow-intercept.test.ts +++ b/tests/responses-shadow-intercept.test.ts @@ -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"; @@ -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, @@ -67,10 +104,14 @@ function interceptConfig(): OcxConfig { } as OcxConfig; } -async function post(config: OcxConfig, model: string): Promise { +async function post(config: OcxConfig, model: string, requestKind?: string): Promise { + const headers: Record = { "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" }] }], @@ -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 @@ -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 () => {