Skip to content

Commit b7ce5aa

Browse files
lidge-junBricol1982
andcommitted
fix(codex): clamp catalog reasoning efforts to installed binary's ladder
Adopts the approach from PR #223 by @Bricol1982 with safe-fallback improvements: when ALL efforts are unsupported, fall back to universal [low,medium,high] instead of preserving the unsupported ladder. Prevents Codex 0.133.0 catalog parse failure. Co-authored-by: Bricol <Bricol1982@users.noreply.github.com>
1 parent 746f52b commit b7ce5aa

3 files changed

Lines changed: 136 additions & 3 deletions

File tree

src/codex/catalog.ts

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -690,7 +690,7 @@ export function materializeBundledCodexCatalog(path: string, deps: BundledCatalo
690690

691691
function loadCatalogForSync(path: string): RawCatalog | null {
692692
const bundled = isDefaultCatalogPath(path) ? loadBundledCodexCatalog() : null;
693-
if (bundled) return bundled;
693+
if (bundled) return JSON.parse(JSON.stringify(bundled)) as RawCatalog;
694694
const catalog = readCatalog(path);
695695
if (catalog && findNativeTemplate(catalog)) return catalog;
696696
return readCatalog(catalogBackupPathFor(path))
@@ -829,6 +829,66 @@ function ensureUltraReasoningLevel(entry: RawEntry): void {
829829
entry.supported_reasoning_levels = levels;
830830
}
831831

832+
/** Reasoning-effort labels accepted by the installed Codex binary's bundled catalog. */
833+
export function codexSupportedReasoningEfforts(deps: BundledCatalogDeps = {}): Set<string> | null {
834+
const bundled = loadBundledCodexCatalog(deps);
835+
if (!bundled) return null;
836+
const efforts = new Set<string>();
837+
for (const model of bundled.models ?? []) {
838+
if (typeof model.slug !== "string" || model.slug.includes("/")) continue;
839+
const levels = Array.isArray(model.supported_reasoning_levels) ? model.supported_reasoning_levels : [];
840+
for (const level of levels) {
841+
const effort = (level as { effort?: unknown })?.effort;
842+
if (typeof effort === "string") efforts.add(effort);
843+
}
844+
if (typeof model.default_reasoning_level === "string") efforts.add(model.default_reasoning_level);
845+
}
846+
return efforts.size > 0 ? efforts : null;
847+
}
848+
849+
/** Highest surviving rung at or below the original default, with a conservative empty fallback. */
850+
export function clampedDefaultEffort(original: string, surviving: readonly string[]): string {
851+
if (surviving.length === 0) return "medium";
852+
const ranked = [...surviving]
853+
.map(effort => ({ effort, rank: codexEffortRank(effort) }))
854+
.sort((a, b) => a.rank - b.rank);
855+
const originalRank = codexEffortRank(original);
856+
const atOrBelow = ranked.filter(item => item.rank >= 0 && item.rank <= originalRank);
857+
return (atOrBelow.at(-1) ?? ranked[0]!).effort;
858+
}
859+
860+
/** Remove reasoning efforts the installed Codex binary cannot deserialize from one entry. */
861+
export function clampEntryToCodexSupportedEfforts(entry: RawEntry, supported: Set<string> | null): void {
862+
if (!supported) return;
863+
const levels = Array.isArray(entry.supported_reasoning_levels)
864+
? entry.supported_reasoning_levels as Array<{ effort?: string }>
865+
: null;
866+
if (levels && levels.length > 0) {
867+
const kept = levels.filter(level => typeof level?.effort === "string" && supported.has(level.effort));
868+
entry.supported_reasoning_levels = kept.length > 0
869+
? kept
870+
: CODEX_REASONING_LEVELS
871+
.filter(level => level.effort === "low" || level.effort === "medium" || level.effort === "high")
872+
.map(level => ({ ...level }));
873+
}
874+
const currentDefault = entry.default_reasoning_level;
875+
if (typeof currentDefault === "string" && !supported.has(currentDefault)) {
876+
const surviving = (Array.isArray(entry.supported_reasoning_levels) ? entry.supported_reasoning_levels : [])
877+
.flatMap(level => typeof (level as { effort?: string })?.effort === "string"
878+
? [(level as { effort: string }).effort]
879+
: []);
880+
entry.default_reasoning_level = clampedDefaultEffort(currentDefault, surviving);
881+
}
882+
}
883+
884+
/** Clamp every catalog entry to the reasoning ladder accepted by the installed Codex binary. */
885+
export function clampCatalogModelsToCodexSupport(models: RawEntry[], deps: BundledCatalogDeps = {}): RawEntry[] {
886+
const supported = codexSupportedReasoningEfforts(deps);
887+
if (!supported) return models;
888+
for (const entry of models) clampEntryToCodexSupportedEfforts(entry, supported);
889+
return models;
890+
}
891+
832892
/**
833893
* Native entry from the pinned upstream snapshot, finished for emission. Keeps the entry's
834894
* OWN identity (display_name, description, priority, availability_nux — it is the model's own
@@ -1926,6 +1986,7 @@ export async function syncCatalogModels(config: OcxConfig): Promise<{ added: num
19261986
// native template can never leak supports_websockets while the flag is off.
19271987
const wsEnabled = websocketsEnabled(config);
19281988
catalog.models = mergeCatalogEntriesForSync(catalog.models ?? [], goEntries, baseline, featured, wsEnabled, goIds, template, disabledNativeSlugs(config), gatheredProviderNames, multiAgentMode, exactComboSlugs, hasPhysicalComboProvider);
1989+
clampCatalogModelsToCodexSupport(catalog.models);
19291990

19301991
atomicWriteFile(catalogPath, JSON.stringify(catalog, null, 2) + "\n");
19311992
return { added: goEntries.length, path: catalogPath };

tests/codex-catalog.test.ts

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, spyOn, test } from "bun:test";
22
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
33
import { tmpdir } from "node:os";
44
import { join } from "node:path";
5-
import { augmentRoutedModelsWithJawcodeMetadata, augmentRoutedModelsWithRegistryOpenAiApiRows, buildCatalogEntries, deriveComboCatalogModel, exactComboCatalogSlugs, filterCatalogVisibleModels, filterSupportedNativeSlugs, gatherRoutedModels, isDatedVariantId, isMediaGenerationModelId, loadBundledCodexCatalog, materializeBundledCodexCatalog, mergeCatalogEntriesForSync, NATIVE_OPENAI_MODELS, normalizeRoutedCatalogEntry, resetCatalogRuntimeStateForTests, resetOpenAiApiCatalogWarningStateForTests } from "../src/codex/catalog";
5+
import { augmentRoutedModelsWithJawcodeMetadata, augmentRoutedModelsWithRegistryOpenAiApiRows, buildCatalogEntries, clampCatalogModelsToCodexSupport, clampEntryToCodexSupportedEfforts, clampedDefaultEffort, deriveComboCatalogModel, exactComboCatalogSlugs, filterCatalogVisibleModels, filterSupportedNativeSlugs, gatherRoutedModels, isDatedVariantId, isMediaGenerationModelId, loadBundledCodexCatalog, materializeBundledCodexCatalog, mergeCatalogEntriesForSync, NATIVE_OPENAI_MODELS, normalizeRoutedCatalogEntry, resetCatalogRuntimeStateForTests, resetOpenAiApiCatalogWarningStateForTests } from "../src/codex/catalog";
66
import {
77
CURSOR_STATIC_MODELS,
88
filterCursorConfiguredModelsByLiveDiscovery,
@@ -1712,3 +1712,75 @@ describe("media-generation model filtering", () => {
17121712
}
17131713
});
17141714
});
1715+
1716+
describe("Codex reasoning-effort capability clamp", () => {
1717+
function bundledCatalogDeps(efforts: string[]) {
1718+
return {
1719+
commandCandidates: () => ["codex"],
1720+
execFileSync: () => JSON.stringify({
1721+
models: [{
1722+
slug: "gpt-5.5",
1723+
base_instructions: "test",
1724+
supported_reasoning_levels: efforts.map(effort => ({ effort, description: effort })),
1725+
default_reasoning_level: "medium",
1726+
}],
1727+
}),
1728+
};
1729+
}
1730+
1731+
function routedEntry() {
1732+
return {
1733+
slug: "openrouter/example",
1734+
supported_reasoning_levels: ["low", "medium", "high", "xhigh", "max", "ultra"]
1735+
.map(effort => ({ effort, description: effort })),
1736+
default_reasoning_level: "max",
1737+
};
1738+
}
1739+
1740+
test("strips max and ultra when the installed Codex ladder stops at xhigh", () => {
1741+
const models = [routedEntry()];
1742+
1743+
clampCatalogModelsToCodexSupport(models, bundledCatalogDeps(["low", "medium", "high", "xhigh"]));
1744+
1745+
expect(models[0]!.supported_reasoning_levels.map(level => level.effort))
1746+
.toEqual(["low", "medium", "high", "xhigh"]);
1747+
});
1748+
1749+
test("preserves max and ultra when the installed Codex ladder includes them", () => {
1750+
const models = [routedEntry()];
1751+
1752+
clampCatalogModelsToCodexSupport(models, bundledCatalogDeps(["low", "medium", "high", "xhigh", "max", "ultra"]));
1753+
1754+
expect(models[0]!.supported_reasoning_levels.map(level => level.effort))
1755+
.toEqual(["low", "medium", "high", "xhigh", "max", "ultra"]);
1756+
});
1757+
1758+
test("falls back to the conservative universal ladder when every advertised effort is unsupported", () => {
1759+
const entry = {
1760+
supported_reasoning_levels: [{ effort: "max" }, { effort: "ultra" }],
1761+
default_reasoning_level: "ultra",
1762+
};
1763+
1764+
clampEntryToCodexSupportedEfforts(entry, new Set(["low", "medium", "high", "xhigh"]));
1765+
1766+
expect(entry.supported_reasoning_levels.map(level => level.effort)).toEqual(["low", "medium", "high"]);
1767+
expect(clampedDefaultEffort("max", [])).toBe("medium");
1768+
});
1769+
1770+
test("repairs an unsupported max default to the highest surviving xhigh rung", () => {
1771+
const entry = routedEntry();
1772+
1773+
clampEntryToCodexSupportedEfforts(entry, new Set(["low", "medium", "high", "xhigh"]));
1774+
1775+
expect(entry.default_reasoning_level).toBe("xhigh");
1776+
});
1777+
1778+
test("is a no-op when the installed Codex binary cannot be probed", () => {
1779+
const models = [routedEntry()];
1780+
const before = structuredClone(models);
1781+
1782+
clampCatalogModelsToCodexSupport(models, { commandCandidates: () => [] });
1783+
1784+
expect(models).toEqual(before);
1785+
});
1786+
});

tests/google-models-listing.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ describe("google models listing via catalog", () => {
7777
expect(seen[0].url).toBe("https://generativelanguage.googleapis.com/v1beta/models?pageSize=1000");
7878
expect(seen[0].headers["x-goog-api-key"]).toBe("gk-123");
7979
const ids = models.filter(m => m.provider === "google").map(m => m.id);
80-
expect(ids).toEqual(["gemini-3.1-pro-preview", "gemini-3.5-flash", "gemini-3.6-flash"]);
80+
expect(ids).toEqual(["gemini-3.1-pro-preview", "gemini-3.5-flash", "gemini-3.5-flash-lite", "gemini-3.6-flash"]);
8181
expect(ids).not.toContain("gemini-3-pro");
8282
expect(ids).not.toContain("gemini-3-flash");
8383
expect(getStaleCached("google")).toBeNull();

0 commit comments

Comments
 (0)