fix(catalog): register plain Codex route for worker -wm SKUs
Codex backend discovery advertises worker-mode SKUs under a `-wm` suffix (gpt-5.6-luna-wm). Authoritative discovery replaced the bundled catalog and kept those slugs verbatim, so a configured `openai-codex/gpt-5.6-luna` vanished from the resolved catalog and the resolver's fuzzy fallback selected `-wm` instead — a route some ChatGPT accounts reject. Model discovery now recognizes the `-wm` suffix: when the bundled Codex catalog ships the plain SKU, the `-wm` row is also registered under its plain id (re-derived so the 1M-window floor and daybreak pricing keyed on that slug still apply). Unknown `-wm` SKUs keep their authoritative verbatim slug and non-worker models are untouched, so distinct models and other providers are unaffected.
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { type } from "@oh-my-pi/omptype";
|
||||
import { parseKnownModel, semverEqual } from "../identity/classify";
|
||||
import { getBundledModels } from "../models";
|
||||
import { resolveOpenAIDaybreakStandardCost } from "../openai-pricing";
|
||||
import type { FetchImpl, ModelSpec } from "../types";
|
||||
import { discoveryFetch } from "../utils";
|
||||
@@ -22,6 +23,21 @@ const GPT_5_6_CONTEXT_WINDOW = 372_000;
|
||||
*/
|
||||
const GPT_5_6_1M_CONTEXT_WINDOW = 1_000_000;
|
||||
const CODEX_GPT_5_6_1M_SLUGS: ReadonlySet<string> = new Set(["gpt-5.6-luna", "gpt-5.6-sol", "gpt-5.6-terra"]);
|
||||
/**
|
||||
* Codex advertises worker-mode SKUs under a `-wm` suffix (`gpt-5.6-luna-wm`).
|
||||
*
|
||||
* Those rows route through the same Codex backend as their plain SKU, but an
|
||||
* authoritative discovery list that only advertises the `-wm` slug prunes the
|
||||
* bundled plain model, leaving a configured `openai-codex/gpt-5.6-luna`
|
||||
* unresolvable except via fuzzy fallback onto the `-wm` row — which this user's
|
||||
* ChatGPT account rejects. The compatibility rule, scoped to Codex discovery:
|
||||
* a `-wm` slug whose plain counterpart exists in the bundled Codex catalog is
|
||||
* ALSO registered under its plain id (re-derived, not cloned, so the 1M-window
|
||||
* floor and daybreak pricing keyed on the plain slug still apply). Models
|
||||
* without a bundled plain counterpart stay verbatim, preserving authoritative
|
||||
* discovery for genuinely distinct `-wm` SKUs.
|
||||
*/
|
||||
const CODEX_WORKER_SUFFIX = "-wm";
|
||||
const CODEX_REMOTE_COMPACTION = {
|
||||
enabled: true,
|
||||
api: "openai-codex-responses",
|
||||
@@ -194,11 +210,26 @@ function normalizeCodexModels(payload: unknown, baseUrl: string): ModelSpec<"ope
|
||||
}
|
||||
|
||||
const entries = parsedResponse.models ?? parsedResponse.data ?? [];
|
||||
const normalized: NormalizedCodexModel[] = [];
|
||||
const parsedEntries: ParsedCodexModelEntry[] = [];
|
||||
for (const entry of entries) {
|
||||
const model = normalizeCodexModelEntry(entry, baseUrl);
|
||||
if (model) {
|
||||
normalized.push(model);
|
||||
const parsed = parseCodexModelEntry(entry);
|
||||
if (parsed) {
|
||||
parsedEntries.push(parsed);
|
||||
}
|
||||
}
|
||||
|
||||
// A worker `-wm` slug gets an extra plain-id route only when the bundled
|
||||
// catalog ships the plain SKU (the "safe" precondition); the backend's own
|
||||
// plain slug wins over any synthesized clone, and unknown `-wm` SKUs stay
|
||||
// verbatim.
|
||||
const advertisedSlugs = new Set(parsedEntries.map(parsed => parsed.slug));
|
||||
const bundledCodexModelIds = getBundledCodexModelIds();
|
||||
const normalized: NormalizedCodexModel[] = [];
|
||||
for (const parsed of parsedEntries) {
|
||||
normalized.push(buildNormalizedCodexModel(parsed, parsed.slug, baseUrl));
|
||||
const plainSlug = plainCounterpartForWorkerSlug(parsed.slug, bundledCodexModelIds);
|
||||
if (plainSlug && !advertisedSlugs.has(plainSlug)) {
|
||||
normalized.push(buildNormalizedCodexModel(parsed, plainSlug, baseUrl));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,7 +243,37 @@ function normalizeCodexModels(payload: unknown, baseUrl: string): ModelSpec<"ope
|
||||
return normalized.map(item => item.model);
|
||||
}
|
||||
|
||||
function normalizeCodexModelEntry(entry: unknown, baseUrl: string): NormalizedCodexModel | null {
|
||||
/** Ids of the bundled Codex catalog, consulted once per discovery run. */
|
||||
function getBundledCodexModelIds(): ReadonlySet<string> {
|
||||
const ids = new Set(getBundledModels("openai-codex").map(model => model.id));
|
||||
return ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a Codex worker `-wm` slug to its plain counterpart when the bundled
|
||||
* catalog registers that plain SKU. Returns `null` for non-worker slugs and
|
||||
* for `-wm` slugs without a safe plain counterpart.
|
||||
*/
|
||||
function plainCounterpartForWorkerSlug(slug: string, bundledCodexModelIds: ReadonlySet<string>): string | null {
|
||||
if (!slug.endsWith(CODEX_WORKER_SUFFIX)) {
|
||||
return null;
|
||||
}
|
||||
const plain = slug.slice(0, -CODEX_WORKER_SUFFIX.length);
|
||||
return plain.length > 0 && bundledCodexModelIds.has(plain) ? plain : null;
|
||||
}
|
||||
|
||||
interface ParsedCodexModelEntry {
|
||||
slug: string;
|
||||
name: string;
|
||||
contextWindow: number | null;
|
||||
reasoning: boolean;
|
||||
input: ("text" | "image")[];
|
||||
preferWebsockets: boolean;
|
||||
useResponsesLite: boolean;
|
||||
priority: number;
|
||||
}
|
||||
|
||||
function parseCodexModelEntry(entry: unknown): ParsedCodexModelEntry | null {
|
||||
const parsedEntry = codexModelEntrySchema(entry);
|
||||
if (parsedEntry instanceof type.errors) {
|
||||
return null;
|
||||
@@ -229,44 +290,53 @@ function normalizeCodexModelEntry(entry: unknown, baseUrl: string): NormalizedCo
|
||||
return null;
|
||||
}
|
||||
|
||||
const name = toNonEmptyString(payload.display_name) ?? slug;
|
||||
return {
|
||||
slug,
|
||||
name: toNonEmptyString(payload.display_name) ?? slug,
|
||||
contextWindow: toPositiveInt(payload.context_window),
|
||||
reasoning: supportsReasoning(payload.default_reasoning_level, payload.supported_reasoning_levels),
|
||||
input: normalizeInputModalities(payload.input_modalities),
|
||||
preferWebsockets: toBoolean(payload.prefer_websockets) === true,
|
||||
useResponsesLite: toBoolean(payload.use_responses_lite) === true,
|
||||
priority: toFiniteNumber(payload.priority) ?? Number.MAX_SAFE_INTEGER,
|
||||
};
|
||||
}
|
||||
|
||||
function buildNormalizedCodexModel(parsed: ParsedCodexModelEntry, slug: string, baseUrl: string): NormalizedCodexModel {
|
||||
// Codex discovery historically omitted `context_window` for GPT-5.6-family
|
||||
// SKUs (#5705); luna/sol/terra additionally floor the reported value because
|
||||
// the registry still declares the pre-1M 272000 window.
|
||||
const parsed = parseKnownModel(slug);
|
||||
// the registry still declares the pre-1M 272000 window. Re-derived from the
|
||||
// effective `slug` so a synthesized plain route gets the same treatment as
|
||||
// one actually advertised under that id.
|
||||
const parsedKnown = parseKnownModel(slug);
|
||||
const fallbackContextWindow =
|
||||
parsed.family === "openai" && semverEqual(parsed.version, "5.6")
|
||||
parsedKnown.family === "openai" && semverEqual(parsedKnown.version, "5.6")
|
||||
? GPT_5_6_CONTEXT_WINDOW
|
||||
: DEFAULT_CONTEXT_WINDOW;
|
||||
const reportedContextWindow = toPositiveInt(payload.context_window) ?? fallbackContextWindow;
|
||||
const reportedContextWindow = parsed.contextWindow ?? fallbackContextWindow;
|
||||
const contextWindow = CODEX_GPT_5_6_1M_SLUGS.has(slug)
|
||||
? Math.max(reportedContextWindow, GPT_5_6_1M_CONTEXT_WINDOW)
|
||||
: reportedContextWindow;
|
||||
const maxTokens = Math.min(DEFAULT_MAX_TOKENS, contextWindow);
|
||||
const reasoning = supportsReasoning(payload.default_reasoning_level, payload.supported_reasoning_levels);
|
||||
const input = normalizeInputModalities(payload.input_modalities);
|
||||
const preferWebsockets = toBoolean(payload.prefer_websockets) === true;
|
||||
const useResponsesLite = toBoolean(payload.use_responses_lite) === true;
|
||||
const priority = toFiniteNumber(payload.priority) ?? Number.MAX_SAFE_INTEGER;
|
||||
const daybreakCost = resolveOpenAIDaybreakStandardCost(slug);
|
||||
|
||||
return {
|
||||
priority,
|
||||
priority: parsed.priority,
|
||||
model: {
|
||||
id: slug,
|
||||
name,
|
||||
name: parsed.name,
|
||||
api: "openai-codex-responses",
|
||||
provider: "openai-codex",
|
||||
baseUrl,
|
||||
reasoning,
|
||||
input,
|
||||
reasoning: parsed.reasoning,
|
||||
input: parsed.input,
|
||||
cost: daybreakCost ? { ...daybreakCost } : { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
remoteCompaction: CODEX_REMOTE_COMPACTION,
|
||||
contextWindow,
|
||||
maxTokens,
|
||||
...(preferWebsockets ? { preferWebsockets: true } : {}),
|
||||
...(useResponsesLite ? { useResponsesLite: true } : {}),
|
||||
...(priority !== Number.MAX_SAFE_INTEGER ? { priority } : {}),
|
||||
...(parsed.preferWebsockets ? { preferWebsockets: true } : {}),
|
||||
...(parsed.useResponsesLite ? { useResponsesLite: true } : {}),
|
||||
...(parsed.priority !== Number.MAX_SAFE_INTEGER ? { priority: parsed.priority } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -538,4 +538,148 @@ describe("Codex model discovery", () => {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("registers a plain route when the backend advertises only the worker `-wm` slug", async () => {
|
||||
const fetchFn: typeof fetch = Object.assign(
|
||||
async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
models: [
|
||||
{
|
||||
slug: "gpt-5.6-luna-wm",
|
||||
display_name: "GPT-5.6 Luna",
|
||||
context_window: 272_000,
|
||||
default_reasoning_level: "medium",
|
||||
supported_reasoning_levels: ["low", "medium", "high"],
|
||||
input_modalities: ["text", "image"],
|
||||
supported_in_api: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
{ preconnect() {} },
|
||||
);
|
||||
const result = await fetchCodexModels({
|
||||
accessToken: "test-token",
|
||||
baseUrl: "https://codex.example/backend-api",
|
||||
clientVersion: "0.99.0",
|
||||
fetchFn,
|
||||
});
|
||||
|
||||
// The authoritative `-wm` row stays surfaced verbatim…
|
||||
expect(result?.models.some(model => model.id === "gpt-5.6-luna-wm")).toBe(true);
|
||||
// …and the configured plain slug must also resolve to a real route.
|
||||
const plainModel = result?.models.find(model => model.id === "gpt-5.6-luna");
|
||||
expect(plainModel).toBeDefined();
|
||||
// The plain route is re-derived for the plain slug, so the 1M floor applies.
|
||||
expect(plainModel?.contextWindow).toBe(1_000_000);
|
||||
expect(plainModel?.provider).toBe("openai-codex");
|
||||
});
|
||||
|
||||
it("keeps the plain route through authoritative discovery that advertises only the `-wm` slug", async () => {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "pi-catalog-codex-luna-wm-"));
|
||||
const bundledLuna: ModelSpec<"openai-codex-responses"> = {
|
||||
id: "gpt-5.6-luna",
|
||||
name: "GPT-5.6 Luna",
|
||||
api: "openai-codex-responses",
|
||||
provider: "openai-codex",
|
||||
baseUrl: "https://chatgpt.com/backend-api",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 1_000_000,
|
||||
maxTokens: 128_000,
|
||||
};
|
||||
const fetchFn: typeof fetch = Object.assign(
|
||||
async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
models: [
|
||||
{
|
||||
slug: "gpt-5.6-luna-wm",
|
||||
display_name: "GPT-5.6 Luna",
|
||||
default_reasoning_level: "medium",
|
||||
supported_reasoning_levels: ["low", "medium", "high"],
|
||||
input_modalities: ["text", "image"],
|
||||
supported_in_api: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
{ preconnect() {} },
|
||||
);
|
||||
try {
|
||||
const options = openaiCodexModelManagerOptions({
|
||||
resolveAccounts: async () => [{ accessToken: "test-token" }],
|
||||
fetch: fetchFn,
|
||||
});
|
||||
const result = await resolveProviderModels(
|
||||
{ ...options, staticModels: [bundledLuna], cacheDbPath: path.join(tempDir, "models.db") },
|
||||
"online",
|
||||
);
|
||||
|
||||
const ids = result.models.map(model => model.id);
|
||||
// The exact-id resolution the resolver performs for
|
||||
// `openai-codex/gpt-5.6-luna` needs this row present.
|
||||
expect(ids).toContain("gpt-5.6-luna");
|
||||
expect(ids).toContain("gpt-5.6-luna-wm");
|
||||
} finally {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps a `-wm` slug verbatim when it has no bundled plain counterpart", async () => {
|
||||
const fetchFn: typeof fetch = Object.assign(
|
||||
async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
models: [
|
||||
{
|
||||
slug: "gpt-9.9-mystery-wm",
|
||||
display_name: "GPT-9.9 Mystery (worker)",
|
||||
input_modalities: ["text"],
|
||||
supported_in_api: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
{ preconnect() {} },
|
||||
);
|
||||
const result = await fetchCodexModels({
|
||||
accessToken: "test-token",
|
||||
baseUrl: "https://codex.example/backend-api",
|
||||
clientVersion: "0.99.0",
|
||||
fetchFn,
|
||||
});
|
||||
// No bundled `gpt-9.9-mystery` entry, so no phantom plain route is made up.
|
||||
expect(result?.models.map(model => model.id)).toEqual(["gpt-9.9-mystery-wm"]);
|
||||
});
|
||||
|
||||
it("leaves a non-worker slug untouched by the worker-mapping rule", async () => {
|
||||
const fetchFn: typeof fetch = Object.assign(
|
||||
async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
models: [
|
||||
{
|
||||
slug: "gpt-5.6-luna",
|
||||
display_name: "GPT-5.6 Luna",
|
||||
default_reasoning_level: "medium",
|
||||
supported_reasoning_levels: ["low", "medium", "high"],
|
||||
input_modalities: ["text", "image"],
|
||||
supported_in_api: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
{ preconnect() {} },
|
||||
);
|
||||
const result = await fetchCodexModels({
|
||||
accessToken: "test-token",
|
||||
baseUrl: "https://codex.example/backend-api",
|
||||
clientVersion: "0.99.0",
|
||||
fetchFn,
|
||||
});
|
||||
expect(result?.models.map(model => model.id)).toEqual(["gpt-5.6-luna"]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user