Skip to content

Commit 2e07c8b

Browse files
committed
fix(google): surface Vertex defaultModel in catalog when models list is absent (#202)
1 parent 984d719 commit 2e07c8b

3 files changed

Lines changed: 136 additions & 8 deletions

File tree

devlog/_plan/260722_issue_bug_sweep/040_merge_readiness_review.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@
2727

2828
### B1 — #202 Vertex: 진단만, 실제 미노출 미해결 (High)
2929

30+
> **해결됨 (2026-07-22, WP-fix-1)**: `catalog.ts fetchProviderModels`에서 adapter=google·googleMode=vertex·models 없음·defaultModel 있음 4조건일 때 defaultModel을 configured에 시드하고, `withVertexDefaultSeed()`로 5개 캐시 fallback(fresh/cooldown/non-2xx/malformed/thrown) + authoritative-empty 분기 모두에 보존. 회귀 테스트(defaultModel-only + 빈 캐시 fresh/stale)는 수정 전 RED. 리뷰어 2라운드(FAIL→PASS). `bun test tests/vertex-catalog.test.ts` 10 pass, tsc 0.
31+
3032
- 위치: `src/codex/catalog.ts:1243``configured``prov.models ?? []`에서만 생성, `defaultModel` fallback 없음.
3133
- 트리거: 이슈 그대로 `defaultModel`만 있고 `models` 배열이 없는 Vertex provider.
3234
- 영향: discovery 실패 후 configured가 비어 default Gemini가 여전히 대시보드·`/v1/models`에 미노출.

src/codex/catalog.ts

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1240,11 +1240,22 @@ function catalogHintsFromModelsApiItem(providerName: string, item: ProviderModel
12401240
async function fetchProviderModels(name: string, prov: OcxProviderConfig, ttlMs: number, contextCap?: number): Promise<CatalogModel[]> {
12411241
if (prov.authMode === "forward") return []; // ChatGPT backend has no /models
12421242
const apiKey = await resolveModelsAuthToken(name, prov);
1243-
const configured: CatalogModel[] = (prov.models ?? []).map(id => ({
1243+
const seedVertexDefault = prov.adapter === "google"
1244+
&& prov.googleMode === "vertex"
1245+
&& (prov.models?.length ?? 0) === 0
1246+
&& Boolean(prov.defaultModel);
1247+
const configuredIds = seedVertexDefault && prov.defaultModel ? [prov.defaultModel] : (prov.models ?? []);
1248+
const configured: CatalogModel[] = configuredIds.map(id => ({
12441249
id,
12451250
provider: name,
12461251
...catalogHintsFromProviderConfig(name, prov, id, contextCap),
12471252
}));
1253+
const vertexDefaultSeed = seedVertexDefault ? configured[0] : undefined;
1254+
const withVertexDefaultSeed = (models: CatalogModel[]): CatalogModel[] => (
1255+
vertexDefaultSeed && !models.some(model => model.id === vertexDefaultSeed.id)
1256+
? [...models, vertexDefaultSeed]
1257+
: models
1258+
);
12481259
if (prov.adapter === "cursor") {
12491260
if (prov.liveModels === false || !apiKey) return configured;
12501261
// Cursor uses a bespoke GetUsableModels RPC (not /models), returning the full effort-suffixed
@@ -1276,12 +1287,12 @@ async function fetchProviderModels(name: string, prov: OcxProviderConfig, ttlMs:
12761287
return configured;
12771288
}
12781289
const fresh = getFreshCached(name, ttlMs);
1279-
if (fresh) return applyConfigHintsToCachedModels(name, prov, fresh, contextCap); // dedups Codex's frequent /v1/models polling within the TTL
1290+
if (fresh) return withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, fresh, contextCap)); // dedups Codex's frequent /v1/models polling within the TTL
12801291
if (isModelsFetchCoolingDown(name)) {
12811292
// A recently-failed provider (unreachable API, missing proxy, bad key) must not re-pay the
12821293
// fetch timeout on every catalog poll — the dashboard polls this path per page load.
12831294
const stale = getStaleCached(name);
1284-
return stale ? applyConfigHintsToCachedModels(name, prov, stale, contextCap) : configured;
1295+
return stale ? withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, stale, contextCap)) : configured;
12851296
}
12861297
const { url, headers } = buildModelsRequest(prov, apiKey, name);
12871298
const urlClass = new URL(url).hostname.endsWith("aiplatform.googleapis.com")
@@ -1296,7 +1307,7 @@ async function fetchProviderModels(name: string, prov: OcxProviderConfig, ttlMs:
12961307
console.warn(
12971308
`[opencodex] Provider model discovery for "${name}" failed with HTTP ${res.status} [urlClass=${urlClass}, fallback=${fallback}].`,
12981309
);
1299-
return stale ? applyConfigHintsToCachedModels(name, prov, stale, contextCap) : configured;
1310+
return stale ? withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, stale, contextCap)) : configured;
13001311
}
13011312
const json = await res.json() as unknown;
13021313
const data = json !== null && typeof json === "object" && !Array.isArray(json)
@@ -1308,7 +1319,7 @@ async function fetchProviderModels(name: string, prov: OcxProviderConfig, ttlMs:
13081319
`[opencodex] Provider model discovery for "${name}" returned malformed 2xx data; using stale/static catalog degradation.`,
13091320
);
13101321
const stale = getStaleCached(name);
1311-
return stale ? applyConfigHintsToCachedModels(name, prov, stale, contextCap) : configured;
1322+
return stale ? withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, stale, contextCap)) : configured;
13121323
}
13131324
const items = data;
13141325
const live = items.map(m => applyProviderConfigHints(name, prov, {
@@ -1331,7 +1342,7 @@ async function fetchProviderModels(name: string, prov: OcxProviderConfig, ttlMs:
13311342
if (dated) {
13321343
// Reapply config hints so alias-keyed overrides (modelContextWindows etc.) win.
13331344
live.push(applyProviderConfigHints(name, prov, { ...dated, id: m.id }, contextCap));
1334-
} else if (shouldRetainConfiguredProviderModel(name, m.id)) {
1345+
} else if (seedVertexDefault || shouldRetainConfiguredProviderModel(name, m.id)) {
13351346
live.push(m);
13361347
} else {
13371348
droppedConfiguredIds.push(m.id);
@@ -1355,7 +1366,7 @@ async function fetchProviderModels(name: string, prov: OcxProviderConfig, ttlMs:
13551366
console.warn(
13561367
`[opencodex] Provider model discovery for "${name}" threw ${error instanceof Error ? error.name : "unknown"} [urlClass=${urlClass}, fallback=${fallback}].`,
13571368
);
1358-
return stale ? applyConfigHintsToCachedModels(name, prov, stale, contextCap) : configured;
1369+
return stale ? withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, stale, contextCap)) : configured;
13591370
}
13601371
}
13611372

tests/vertex-catalog.test.ts

Lines changed: 116 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { afterEach, describe, expect, spyOn, test } from "bun:test";
22
import { gatherRoutedModels } from "../src/codex/catalog";
3-
import { clearModelCache } from "../src/codex/model-cache";
3+
import { clearModelCache, markModelsFetchFailure, setCached } from "../src/codex/model-cache";
44
import type { OcxConfig } from "../src/types";
55

66
const originalFetch = globalThis.fetch;
@@ -28,6 +28,121 @@ function vertexProvider(name: string): OcxConfig {
2828
}
2929

3030
describe("Vertex catalog configuration", () => {
31+
test("defaultModel without models survives failed Vertex discovery", async () => {
32+
globalThis.fetch = (() => { throw new TypeError("offline"); }) as typeof fetch;
33+
const config: OcxConfig = {
34+
providers: {
35+
"vertex-default": {
36+
adapter: "google",
37+
googleMode: "vertex",
38+
baseUrl: "https://aiplatform.googleapis.com",
39+
apiKey: "test-key",
40+
defaultModel: "gemini-2.5-pro",
41+
},
42+
},
43+
};
44+
45+
const rows = await gatherRoutedModels(config);
46+
47+
expect(rows).toContainEqual(expect.objectContaining({
48+
provider: "vertex-default",
49+
id: "gemini-2.5-pro",
50+
}));
51+
});
52+
53+
test("defaultModel-only Vertex configuration survives authoritative empty discovery", async () => {
54+
globalThis.fetch = (() => Promise.resolve(Response.json({ data: [] }))) as typeof fetch;
55+
const config: OcxConfig = {
56+
providers: {
57+
"vertex-empty": {
58+
adapter: "google",
59+
googleMode: "vertex",
60+
baseUrl: "https://aiplatform.googleapis.com",
61+
apiKey: "test-key",
62+
defaultModel: "gemini-2.5-pro",
63+
},
64+
},
65+
};
66+
67+
const rows = await gatherRoutedModels(config);
68+
69+
expect(rows).toContainEqual(expect.objectContaining({
70+
provider: "vertex-empty",
71+
id: "gemini-2.5-pro",
72+
}));
73+
});
74+
75+
test("defaultModel-only Vertex configuration survives a fresh empty cache", async () => {
76+
globalThis.fetch = (() => { throw new Error("must not fetch"); }) as typeof fetch;
77+
const provider = "vertex-fresh-empty";
78+
setCached(provider, []);
79+
const config: OcxConfig = {
80+
providers: {
81+
[provider]: {
82+
adapter: "google",
83+
googleMode: "vertex",
84+
baseUrl: "https://aiplatform.googleapis.com",
85+
apiKey: "test-key",
86+
defaultModel: "gemini-2.5-pro",
87+
},
88+
},
89+
};
90+
91+
const rows = await gatherRoutedModels(config);
92+
93+
expect(rows).toContainEqual(expect.objectContaining({
94+
provider,
95+
id: "gemini-2.5-pro",
96+
}));
97+
});
98+
99+
test("defaultModel-only Vertex configuration survives an empty stale cache during cooldown", async () => {
100+
globalThis.fetch = (() => { throw new Error("must not fetch"); }) as typeof fetch;
101+
const provider = "vertex-stale-empty";
102+
setCached(provider, [], 0);
103+
markModelsFetchFailure(provider);
104+
const config: OcxConfig = {
105+
modelCacheTtlMs: 1,
106+
providers: {
107+
[provider]: {
108+
adapter: "google",
109+
googleMode: "vertex",
110+
baseUrl: "https://aiplatform.googleapis.com",
111+
apiKey: "test-key",
112+
defaultModel: "gemini-2.5-pro",
113+
},
114+
},
115+
};
116+
117+
const rows = await gatherRoutedModels(config);
118+
119+
expect(rows).toContainEqual(expect.objectContaining({
120+
provider,
121+
id: "gemini-2.5-pro",
122+
}));
123+
});
124+
125+
test("non-Vertex defaultModel without models is not seeded after discovery failure", async () => {
126+
globalThis.fetch = (() => { throw new TypeError("offline"); }) as typeof fetch;
127+
const config: OcxConfig = {
128+
providers: {
129+
"custom-openai": {
130+
adapter: "openai-chat",
131+
baseUrl: "https://example.invalid/v1",
132+
apiKey: "test-key",
133+
defaultModel: "typoed-default",
134+
},
135+
},
136+
};
137+
138+
const rows = await gatherRoutedModels(config);
139+
140+
expect(rows).not.toContainEqual(expect.objectContaining({
141+
provider: "custom-openai",
142+
id: "typoed-default",
143+
}));
144+
});
145+
31146
test("models + liveModels false exposes configured Vertex ids without discovery", async () => {
32147
globalThis.fetch = (() => { throw new Error("must not fetch"); }) as typeof fetch;
33148
const config = vertexProvider("vertex-static");

0 commit comments

Comments
 (0)