Skip to content

Commit 4ccc6da

Browse files
committed
fix(opencode-go): hide unavailable hy3 from catalogs (#82)
1 parent a5378ea commit 4ccc6da

4 files changed

Lines changed: 96 additions & 2 deletions

File tree

src/codex/catalog.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,20 @@ type RawEntry = Record<string, unknown>;
303303
type RawCatalog = { models?: RawEntry[]; [k: string]: unknown };
304304
const JAWCODE_CATALOG_AUGMENT_PROVIDERS = new Set(["opencode-go"]);
305305

306+
/**
307+
* Exact provider/model pairs whose discovery endpoint advertises them but whose inference backend
308+
* rejects them. Apply this after live/static/metadata sources converge so no source can resurrect
309+
* an uncallable picker row. Remove an entry once authenticated inference proves it usable again.
310+
*/
311+
const ROUTED_MODEL_COMPATIBILITY_EXCLUSIONS = new Set([
312+
// Issue #82: Zen Go /models advertises HY3, but Console Go rejects it as outside the lite list.
313+
"opencode-go/hy3-preview",
314+
]);
315+
316+
function isRoutedModelCompatibilityExcluded(slug: string): boolean {
317+
return ROUTED_MODEL_COMPATIBILITY_EXCLUSIONS.has(slug);
318+
}
319+
306320
/**
307321
* Image/video GENERATION model families. opencodex routes chat/coding models into Codex; media-
308322
* generation models (Grok image/video, DALL·E, Imagen, Sora, Veo, …) are useless to a coding agent
@@ -328,6 +342,7 @@ export function isMediaGenerationModelId(id: string): boolean {
328342
}
329343

330344
function shouldExposeRoutedModel(model: CatalogModel): boolean {
345+
if (isRoutedModelCompatibilityExcluded(`${model.provider}/${model.id}`)) return false;
331346
if (model.provider === "cursor" && model.id === "gemini-3-pro-image-preview") return true;
332347
return !isMediaGenerationModelId(model.id);
333348
}
@@ -1329,9 +1344,10 @@ export function mergeCatalogEntriesForSync(
13291344
}
13301345

13311346
let finalRoutedEntries = routedEntries;
1332-
if (routedEntries.length === 0 && catalogModels.some(m => typeof m.slug === "string" && (m.slug as string).includes("/"))) {
1347+
const preservingExistingRouted = routedEntries.length === 0
1348+
&& catalogModels.some(m => typeof m.slug === "string" && (m.slug as string).includes("/"));
1349+
if (preservingExistingRouted) {
13331350
finalRoutedEntries = catalogModels.filter(m => typeof m.slug === "string" && (m.slug as string).includes("/"));
1334-
console.warn(`[opencodex] catalog sync: routed model fetch returned empty; preserving ${finalRoutedEntries.length} existing routed entr${finalRoutedEntries.length === 1 ? "y" : "ies"} on disk.`);
13351351
} else {
13361352
const freshSlugs = new Set(routedEntries.flatMap(entry => typeof entry.slug === "string" ? [entry.slug] : []));
13371353
const preservedForeignRouted = catalogModels.filter(m => {
@@ -1341,6 +1357,14 @@ export function mergeCatalogEntriesForSync(
13411357
});
13421358
finalRoutedEntries = [...routedEntries, ...preservedForeignRouted];
13431359
}
1360+
// Reapply final catalog policy to rows preserved from disk. Those rows bypass
1361+
// gatherRoutedModels, so filtering only the freshly gathered list can resurrect an excluded id.
1362+
finalRoutedEntries = finalRoutedEntries.filter(entry =>
1363+
typeof entry.slug !== "string" || !isRoutedModelCompatibilityExcluded(entry.slug)
1364+
);
1365+
if (preservingExistingRouted) {
1366+
console.warn(`[opencodex] catalog sync: routed model fetch returned empty; preserving ${finalRoutedEntries.length} existing routed entr${finalRoutedEntries.length === 1 ? "y" : "ies"} on disk.`);
1367+
}
13441368

13451369
const mergedEntries = [...native, ...finalRoutedEntries].map(m => {
13461370
const normalized = normalizeServiceTiers(m);

structure/03_catalog-and-subagents.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
- forces strict Codex catalog fields required by the current parser;
1212
- hides `disabledModels` (routed namespaced ids are excluded; BARE native slugs flip the
1313
catalog entry to `visibility: "hide"` and drop from the bare `/v1/models` list);
14+
- applies exact provider/model compatibility exclusions after live discovery and metadata
15+
augmentation, so upstream-advertised but uncallable rows never enter dashboard or Codex pickers;
1416
- strips native-only service tier and WebSocket metadata unless explicitly enabled;
1517
- backs up the pristine catalog once to `~/.opencodex/catalog-backup.json`;
1618
- invalidates `$CODEX_HOME/models_cache.json` when model visibility changes.

tests/codex-catalog-sync-hardening.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,31 @@ describe("Codex catalog sync hardening", () => {
118118
expect(slugs).toContain("gpt-5.5");
119119
});
120120

121+
test("empty routed refresh drops compatibility-excluded rows while preserving other routed entries", () => {
122+
const catalogPath = join(codexHome, "catalog.json");
123+
writeFileSync(join(codexHome, "config.toml"), 'model_catalog_json = "catalog.json"\n', "utf8");
124+
writeFileSync(catalogPath, JSON.stringify({
125+
models: [
126+
nativeEntry("gpt-5.5", 0),
127+
routedEntry("kiro/claude-opus-4.8", 5),
128+
routedEntry("opencode-go/glm-5.2", 6),
129+
routedEntry("opencode-go/hy3-preview", 7),
130+
],
131+
}, null, 2) + "\n");
132+
133+
const r = runScript(codexHome, opencodexHome, `
134+
const { syncCatalogModels } = require("./src/codex/catalog");
135+
syncCatalogModels({ providers: {} }).then(res => console.log(JSON.stringify(res)));
136+
`);
137+
expect(r.status).toBe(0);
138+
expect(r.stderr).toContain("routed model fetch returned empty; preserving 2 existing routed entries");
139+
140+
const slugs = (JSON.parse(readFileSync(catalogPath, "utf8")).models as Array<{ slug: string }>).map(m => m.slug);
141+
expect(slugs).toContain("kiro/claude-opus-4.8");
142+
expect(slugs).toContain("opencode-go/glm-5.2");
143+
expect(slugs).not.toContain("opencode-go/hy3-preview");
144+
});
145+
121146
test("preserves existing routed entries for providers absent from the current sync config", () => {
122147
const catalogPath = join(codexHome, "catalog.json");
123148
writeFileSync(join(codexHome, "config.toml"), 'model_catalog_json = "catalog.json"\n', "utf8");

tests/provider-live-models.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import type { OcxConfig } from "../src/types";
88
// authority/fallback contract of fetchProviderModels through the public gatherRoutedModels seam.
99

1010
const PROVIDER = "xai-live-test";
11+
const HY3_PROVIDER = "opencode-go";
12+
const HY3_CONTROL_PROVIDER = "hy3-control-live-test";
1113

1214
function config(): OcxConfig {
1315
return {
@@ -29,6 +31,8 @@ const originalFetch = globalThis.fetch;
2931
afterEach(() => {
3032
globalThis.fetch = originalFetch;
3133
clearModelCache(PROVIDER);
34+
clearModelCache(HY3_PROVIDER);
35+
clearModelCache(HY3_CONTROL_PROVIDER);
3236
});
3337

3438
describe("live provider model discovery (authority + fallback)", () => {
@@ -67,6 +71,45 @@ describe("live provider model discovery (authority + fallback)", () => {
6771
}
6872
});
6973

74+
test("HY3 compatibility guard hides only opencode-go/hy3-preview from live discovery", async () => {
75+
globalThis.fetch = (async (url: string | URL | Request) => {
76+
const isOpenCodeGo = String(url).startsWith("https://opencode-go.test/");
77+
return new Response(JSON.stringify({
78+
data: isOpenCodeGo
79+
? [
80+
{ id: "glm-5.2" },
81+
{ id: "hy3-preview" },
82+
{ id: "future-live-model" },
83+
]
84+
: [{ id: "hy3-preview" }],
85+
}), { status: 200, headers: { "content-type": "application/json" } });
86+
}) as typeof fetch;
87+
88+
const models = await gatherRoutedModels({
89+
providers: {
90+
[HY3_PROVIDER]: {
91+
baseUrl: "https://opencode-go.test/v1",
92+
adapter: "openai-chat",
93+
authMode: "key",
94+
apiKey: "sk-test",
95+
models: ["glm-5.2"],
96+
},
97+
[HY3_CONTROL_PROVIDER]: {
98+
baseUrl: "https://hy3-control.test/v1",
99+
adapter: "openai-chat",
100+
authMode: "key",
101+
apiKey: "sk-test",
102+
},
103+
},
104+
} as unknown as OcxConfig);
105+
const slugs = models.map(model => `${model.provider}/${model.id}`);
106+
107+
expect(slugs).not.toContain("opencode-go/hy3-preview");
108+
expect(slugs).toContain("opencode-go/glm-5.2");
109+
expect(slugs).toContain("opencode-go/future-live-model");
110+
expect(slugs).toContain("hy3-control-live-test/hy3-preview");
111+
});
112+
70113
test("fetch failure falls back to the configured static list", async () => {
71114
globalThis.fetch = (async () => {
72115
throw new Error("network down");

0 commit comments

Comments
 (0)