Skip to content

Commit 7d9d260

Browse files
committed
refactor(shadow-call): give the intercepted model set one home
Codex's helper slug is not stable across client versions -- gpt-5.4-mini up to 0.144.x, gpt-5.6-luna from 0.145.0 -- so any surface that hard-codes it goes stale on the next client bump. The prefix set now lives in src/lib/shadow-call.ts and the management API reports it, so the runtime, the API and the GUI all name the same models instead of three literals drifting apart. The routed-id exclusion moves with it: a shadow call is always a bare native slug, and an explicit provider/model selection must never be hijacked.
1 parent f23e446 commit 7d9d260

5 files changed

Lines changed: 133 additions & 12 deletions

File tree

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/**
2+
* Which models the runtime actually intercepts as shadow calls.
3+
*
4+
* Codex's hard-coded helper slug moves between client versions (gpt-5.4-mini up
5+
* to 0.144.x, gpt-5.6-luna from 0.145.0), so the GUI must render whatever the
6+
* runtime reports rather than a literal baked into a label. The fallback only
7+
* covers a runtime too old to send `sourceModels`.
8+
*/
9+
const FALLBACK_SOURCE_MODELS = ["gpt-5.4-mini", "gpt-5.6-luna"];
10+
11+
export function shadowSourceModelList(sourceModels?: string[]): string[] {
12+
const cleaned = Array.isArray(sourceModels)
13+
? sourceModels.filter(v => typeof v === "string" && v.trim() !== "").map(v => v.trim())
14+
: [];
15+
return cleaned.length > 0 ? cleaned : FALLBACK_SOURCE_MODELS;
16+
}
17+
18+
/** Comma-joined source models for inline badges and warning text. */
19+
export function shadowSourceModelLabel(sourceModels?: string[]): string {
20+
return shadowSourceModelList(sourceModels).join(", ");
21+
}
22+
23+
/** Short badge form: drops the shared `gpt-` prefix to keep the row compact. */
24+
export function shadowSourceModelBadge(sourceModels?: string[]): string {
25+
return shadowSourceModelList(sourceModels)
26+
.map(id => id.replace(/^gpt-/, ""))
27+
.join(", ");
28+
}

src/lib/shadow-call.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/**
2+
* Shadow-call intercept source models.
3+
*
4+
* Codex's hard-coded helper model is not stable across client versions: it was
5+
* `gpt-5.4-mini` up to 0.144.x and became `gpt-5.6-luna` in 0.145.0. The
6+
* intercept therefore matches a prefix SET, and every surface that names the
7+
* intercepted model (management API, GUI badges/tooltips, CLI) reads it from
8+
* here instead of hard-coding a slug that goes stale on the next client bump.
9+
*/
10+
export const DEFAULT_SHADOW_SOURCE_MODELS = ["gpt-5.4-mini", "gpt-5.6-luna"] as const;
11+
12+
/** Normalize a persisted `sourceModels` override; falls back to the defaults. */
13+
export function shadowSourceModels(configured?: unknown): string[] {
14+
const configuredStrings = Array.isArray(configured)
15+
? configured
16+
.filter((v): v is string => typeof v === "string" && v.trim() !== "")
17+
.map(v => v.trim())
18+
: [];
19+
return configuredStrings.length > 0 ? configuredStrings : [...DEFAULT_SHADOW_SOURCE_MODELS];
20+
}
21+
22+
/**
23+
* True when `modelId` is one of Codex's helper/shadow source models.
24+
* Routed ids (`provider/model`) are hard-excluded: a shadow call is always a
25+
* bare native slug, and an explicit routed selection must never be hijacked.
26+
*/
27+
export function isShadowSourceModel(modelId: string, configured?: unknown): boolean {
28+
if (modelId.includes("/")) return false;
29+
return shadowSourceModels(configured).some(prefix => modelId.startsWith(prefix));
30+
}

src/server/management/config-routes.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
import { removeCredential } from "../../oauth/store";
2525
import { providerDestinationResolvedError } from "../../lib/destination-policy";
2626
import { isStreamMode } from "../../lib/bun-stream-caps";
27+
import { shadowSourceModels } from "../../lib/shadow-call";
2728
import { enrichProviderFromCatalog, listKeyLoginProviders } from "../../oauth/key-providers";
2829
import { deriveProviderPresets } from "../../providers/derive";
2930
import { providerCodexAccountMode } from "../../providers/registry";
@@ -354,7 +355,11 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
354355

355356
if (url.pathname === "/api/shadow-call-settings" && req.method === "GET") {
356357
const sci = config.shadowCallIntercept ?? {};
357-
return jsonResponse({ enabled: sci.enabled === true, model: sci.model ?? "" });
358+
return jsonResponse({
359+
enabled: sci.enabled === true,
360+
model: sci.model ?? "",
361+
sourceModels: shadowSourceModels(sci.sourceModels),
362+
});
358363
}
359364

360365
if (url.pathname === "/api/shadow-call-settings" && req.method === "PUT") {
@@ -376,7 +381,12 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
376381
}
377382
saveConfigPreservingClaudeCode(config);
378383
const sci = config.shadowCallIntercept;
379-
return jsonResponse({ ok: true, enabled: sci.enabled === true, model: sci.model ?? "" });
384+
return jsonResponse({
385+
ok: true,
386+
enabled: sci.enabled === true,
387+
model: sci.model ?? "",
388+
sourceModels: shadowSourceModels(sci.sourceModels),
389+
});
380390
}
381391
return null;
382392
}

src/server/responses/core.ts

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -174,16 +174,9 @@ export function sidecarOutcomeRecorder(
174174

175175

176176

177-
export const DEFAULT_SHADOW_SOURCE_MODELS = ["gpt-5.4-mini", "gpt-5.6-luna"] as const;
178-
179-
export function isShadowSourceModel(modelId: string, configured?: unknown): boolean {
180-
if (modelId.includes("/")) return false;
181-
const configuredStrings = Array.isArray(configured)
182-
? configured.filter((v): v is string => typeof v === "string" && v.trim() !== "")
183-
: [];
184-
const prefixes = configuredStrings.length > 0 ? configuredStrings : DEFAULT_SHADOW_SOURCE_MODELS;
185-
return prefixes.some(prefix => modelId.startsWith(prefix.trim()));
186-
}
177+
import { isShadowSourceModel } from "../../lib/shadow-call";
178+
179+
export { DEFAULT_SHADOW_SOURCE_MODELS, isShadowSourceModel, shadowSourceModels } from "../../lib/shadow-call";
187180

188181

189182

tests/responses-shadow-intercept.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@
44
* must match a source-model set, not a single literal.
55
*/
66
import { afterEach, describe, expect, test } from "bun:test";
7+
import { mkdtempSync, rmSync } from "node:fs";
8+
import { tmpdir } from "node:os";
9+
import { join } from "node:path";
710
import { handleResponses, isShadowSourceModel } from "../src/server/responses";
11+
import { handleManagementAPI } from "../src/server/management-api";
812
import type { OcxConfig } from "../src/types";
913

1014
const originalFetch = globalThis.fetch;
@@ -111,3 +115,59 @@ describe("shadow call intercept request path (issue #311)", () => {
111115
expect(response.status).toBe(404);
112116
});
113117
});
118+
119+
/**
120+
* The GUI badge/tooltip used to hard-code "5.4-mini", so it kept naming a model
121+
* Codex no longer sends. The management API is the single source of truth for
122+
* which models are intercepted; every client renders what it reports.
123+
*/
124+
async function withTempHome<T>(run: () => Promise<T>): Promise<T> {
125+
const previousHome = process.env.OPENCODEX_HOME;
126+
const dir = mkdtempSync(join(tmpdir(), "ocx-shadow-"));
127+
process.env.OPENCODEX_HOME = dir;
128+
try {
129+
return await run();
130+
} finally {
131+
if (previousHome === undefined) delete process.env.OPENCODEX_HOME;
132+
else process.env.OPENCODEX_HOME = previousHome;
133+
rmSync(dir, { recursive: true, force: true });
134+
}
135+
}
136+
137+
async function shadowApi(config: OcxConfig, method: string, body?: unknown): Promise<Record<string, unknown>> {
138+
// Management API enforces a same-origin gate; a browserless caller must look local.
139+
const headers: Record<string, string> = { origin: "http://127.0.0.1:10100", host: "127.0.0.1:10100" };
140+
if (body !== undefined) headers["content-type"] = "application/json";
141+
const req = new Request("http://localhost/api/shadow-call-settings", {
142+
method,
143+
headers,
144+
body: body === undefined ? undefined : JSON.stringify(body),
145+
});
146+
const res = await handleManagementAPI(req, new URL(req.url), config, { refreshCodexCatalog: async () => {} });
147+
expect(res).not.toBeNull();
148+
expect(res!.status).toBe(200);
149+
return await res!.json() as Record<string, unknown>;
150+
}
151+
152+
describe("shadow-call settings API reports the intercepted source models", () => {
153+
test("GET reports the defaults, including the 0.145.0 helper model", async () => {
154+
await withTempHome(async () => {
155+
const body = await shadowApi({ port: 0, defaultProvider: "xai", providers: {} } as OcxConfig, "GET");
156+
expect(body.sourceModels).toEqual(["gpt-5.4-mini", "gpt-5.6-luna"]);
157+
});
158+
});
159+
160+
test("GET and PUT report a configured override instead of the defaults", async () => {
161+
await withTempHome(async () => {
162+
const config = {
163+
port: 0,
164+
defaultProvider: "xai",
165+
providers: {},
166+
shadowCallIntercept: { enabled: true, model: "gpt-5.5", sourceModels: ["gpt-5.6-luna"] },
167+
} as OcxConfig;
168+
expect((await shadowApi(config, "GET")).sourceModels).toEqual(["gpt-5.6-luna"]);
169+
const put = await shadowApi(config, "PUT", { enabled: true });
170+
expect(put.sourceModels).toEqual(["gpt-5.6-luna"]);
171+
});
172+
});
173+
});

0 commit comments

Comments
 (0)