From c9563c8efb4bea3181a455a907cf167ba32d6f98 Mon Sep 17 00:00:00 2001 From: chilung Date: Wed, 19 Aug 2026 21:32:45 +0800 Subject: [PATCH 01/13] feat(catalog): config-level retainModels allowlist for authoritative discovery (#1690) --- .../docs/reference/configuration/providers.md | 1 + src/codex/catalog/provider-fetch.ts | 8 +- src/config.ts | 3 + src/providers/model-rename-migration.ts | 1 + src/types/provider.ts | 6 ++ tests/provider-retain-models.test.ts | 93 +++++++++++++++++++ 6 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 tests/provider-retain-models.test.ts diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 782effab52d..79b27c38eb8 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -82,6 +82,7 @@ differing backup and rewrites known legacy namespaced selected ids to bare ids. | `models?` | `string[]` | Seed/fallback model list. With `liveModels: false`, these are the only discovered models. | | `liveModels?` | `boolean` | Fetch the live catalog on start/sync (default `true`). Custom providers use `${baseUrl}/models`; built-ins may use a registry URL and filter. | | `selectedModels?` | `string[]` | Catalog allowlist after discovery. Non-empty exposes only those ids; empty or omitted exposes all discovered models. | +| `retainModels?` | `string[]` | Models to retain in the authoritative live catalog even when upstream discovery omits them (e.g. unprovisioned accounts or unlisted models). | | `contextWindow?` | `number` | Provider-wide context fallback when upstream metadata is absent; otherwise a cap that retains smaller live metadata. The Models dashboard exposes this separately from `providerContextCaps`. | | `modelContextWindows?` | `Record` | Per-model context fallbacks/caps. These override `contextWindow`: an unknown window uses the configured value, while smaller live metadata remains authoritative. | | `modelInputModalities?` | `Record` | Per-model input hints such as `["text"]` or `["text", "image"]`. | diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index 5ef9dffa2b1..21140df1ee7 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -1214,7 +1214,11 @@ async function fetchProviderModelsWithAuth( && prov.googleMode === "vertex" && (prov.models?.length ?? 0) === 0 && Boolean(prov.defaultModel); - const configuredIds = seedVertexDefault && prov.defaultModel ? [prov.defaultModel] : (prov.models ?? []); + const configuredIds = Array.from(new Set([ + ...(seedVertexDefault && prov.defaultModel ? [prov.defaultModel] : []), + ...(prov.models ?? []), + ...(prov.retainModels ?? []), + ])); const configured: CatalogModel[] = configuredIds.map(id => ({ id, provider: name, @@ -1609,6 +1613,7 @@ export function mergeConfiguredModelsIntoLiveCatalog(opts: { const out = [...opts.models]; const present = new Set(out.map(model => model.id)); const droppedConfiguredIds: string[] = []; + const providerRetainModels = Array.isArray(prov.retainModels) ? new Set(prov.retainModels) : undefined; for (const candidate of configured) { if (present.has(candidate.id)) continue; const dated = out.find(live => isDatedVariantId(live.id, candidate.id)); @@ -1621,6 +1626,7 @@ export function mergeConfiguredModelsIntoLiveCatalog(opts: { seedVertexDefault === true || shouldRetainConfiguredProviderModel(name, candidate.id) || (retainComboTargets && retainConfiguredModelIds?.has(candidate.id) === true) + || (providerRetainModels?.has(candidate.id) === true) ) { out.push(candidate); present.add(candidate.id); diff --git a/src/config.ts b/src/config.ts index 1308b3a64b2..5014483d49f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -513,6 +513,9 @@ const providerConfigSchema = z.object({ .nullish() .transform(value => value ?? undefined), directGeminiWireRenames: z.boolean().optional(), + retainModels: z.array(z.string().min(1)) + .transform(normalizeNonBlankStringArray) + .optional(), noStructuredOutputModels: z.array(z.string().min(1)) .transform(normalizeNonBlankStringArray) .optional(), diff --git a/src/providers/model-rename-migration.ts b/src/providers/model-rename-migration.ts index 3895386b585..4d2f04172fe 100644 --- a/src/providers/model-rename-migration.ts +++ b/src/providers/model-rename-migration.ts @@ -99,6 +99,7 @@ const MODEL_ID_LISTS = [ // their catalog instead of being renamed. OAuth reconciliation does not cover this // field, so the rename has to. "selectedModels", + "retainModels", "noVisionModels", "noReasoningModels", "noTemperatureModels", diff --git a/src/types/provider.ts b/src/types/provider.ts index b7ba0425067..6fb56d37946 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -308,6 +308,12 @@ export interface OcxProviderConfig { */ fallback?: "preset-empty"; }; + /** + * Models that should be retained in the catalog even if live discovery omits them. + * Useful when an upstream account has unlisted or upcoming models (e.g. unprovisioned + * Antigravity models or regional previews). + */ + retainModels?: string[]; /** Provider-wide fallback when context metadata is absent; otherwise caps the reported window. */ contextWindow?: number; /** Per-model fallback when context metadata is absent; otherwise caps the reported window. */ diff --git a/tests/provider-retain-models.test.ts b/tests/provider-retain-models.test.ts new file mode 100644 index 00000000000..8fd4b6b5ce1 --- /dev/null +++ b/tests/provider-retain-models.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, test } from "bun:test"; +import { mergeConfiguredModelsIntoLiveCatalog } from "../src/codex/catalog/provider-fetch"; +import type { CatalogModel } from "../src/codex/catalog/parsing"; +import type { OcxProviderConfig } from "../src/types"; + +function model(id: string, provider = "test-prov"): CatalogModel { + return { id, provider }; +} + +describe("#1690 retainModels provider configuration", () => { + test("retains configured models listed in retainModels when live discovery omits them", () => { + const prov: OcxProviderConfig = { + adapter: "google", + baseUrl: "https://daily-cloudcode-pa.googleapis.com", + retainModels: ["gemini-3.7-flash"], + }; + const live = [model("gemini-3.5-flash")]; + const configured = [model("gemini-3.7-flash"), model("unrelated-model")]; + + const { models, droppedConfiguredIds } = mergeConfiguredModelsIntoLiveCatalog({ + name: "google-antigravity", + provider: prov, + models: live, + configured, + }); + + expect(models.map(m => m.id)).toEqual(["gemini-3.5-flash", "gemini-3.7-flash"]); + expect(droppedConfiguredIds).toEqual(["unrelated-model"]); + }); + + test("drops unlisted models when retainModels is empty or undefined", () => { + const prov: OcxProviderConfig = { + adapter: "openai-chat", + baseUrl: "https://api.example.com/v1", + }; + const live = [model("live-model-1")]; + const configured = [model("configured-model-1")]; + + const { models, droppedConfiguredIds } = mergeConfiguredModelsIntoLiveCatalog({ + name: "custom-prov", + provider: prov, + models: live, + configured, + }); + + expect(models.map(m => m.id)).toEqual(["live-model-1"]); + expect(droppedConfiguredIds).toEqual(["configured-model-1"]); + }); + + test("preserves discovered models that match retainModels without duplication", () => { + const prov: OcxProviderConfig = { + adapter: "google", + baseUrl: "https://daily-cloudcode-pa.googleapis.com", + retainModels: ["gemini-3.7-flash"], + }; + const live = [model("gemini-3.7-flash")]; + const configured = [model("gemini-3.7-flash")]; + + const { models, droppedConfiguredIds } = mergeConfiguredModelsIntoLiveCatalog({ + name: "google-antigravity", + provider: prov, + models: live, + configured, + }); + + expect(models.map(m => m.id)).toEqual(["gemini-3.7-flash"]); + expect(droppedConfiguredIds).toEqual([]); + }); + + test("retains multiple specified models across an empty live discovery", () => { + const prov: OcxProviderConfig = { + adapter: "google", + baseUrl: "https://daily-cloudcode-pa.googleapis.com", + retainModels: ["gemini-3.7-flash", "claude-sonnet-4-6"], + }; + const live: CatalogModel[] = []; + const configured = [ + model("gemini-3.7-flash"), + model("claude-sonnet-4-6"), + model("dropped-model"), + ]; + + const { models, droppedConfiguredIds } = mergeConfiguredModelsIntoLiveCatalog({ + name: "google-antigravity", + provider: prov, + models: live, + configured, + }); + + expect(models.map(m => m.id)).toEqual(["gemini-3.7-flash", "claude-sonnet-4-6"]); + expect(droppedConfiguredIds).toEqual(["dropped-model"]); + }); +}); From d69038796cb146b92fc4070bccd009c00d3bb11f Mon Sep 17 00:00:00 2001 From: chilung Date: Wed, 19 Aug 2026 22:46:13 +0800 Subject: [PATCH 02/13] =?UTF-8?q?fix(review):=20address=20CodeRabbit=20fee?= =?UTF-8?q?dback=20=E2=80=94=20clarify=20retainModels=20docs=20and=20cover?= =?UTF-8?q?=20empty-array=20branch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs-site/src/content/docs/reference/configuration/providers.md | 2 +- tests/provider-retain-models.test.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 79b27c38eb8..463c3e90629 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -82,7 +82,7 @@ differing backup and rewrites known legacy namespaced selected ids to bare ids. | `models?` | `string[]` | Seed/fallback model list. With `liveModels: false`, these are the only discovered models. | | `liveModels?` | `boolean` | Fetch the live catalog on start/sync (default `true`). Custom providers use `${baseUrl}/models`; built-ins may use a registry URL and filter. | | `selectedModels?` | `string[]` | Catalog allowlist after discovery. Non-empty exposes only those ids; empty or omitted exposes all discovered models. | -| `retainModels?` | `string[]` | Models to retain in the authoritative live catalog even when upstream discovery omits them (e.g. unprovisioned accounts or unlisted models). | +| `retainModels?` | `string[]` | Provider-level allowlist of model IDs to retain when live discovery omits them (for example, unprovisioned accounts or unlisted models). Configured IDs not listed here continue to be pruned when omitted by discovery. | | `contextWindow?` | `number` | Provider-wide context fallback when upstream metadata is absent; otherwise a cap that retains smaller live metadata. The Models dashboard exposes this separately from `providerContextCaps`. | | `modelContextWindows?` | `Record` | Per-model context fallbacks/caps. These override `contextWindow`: an unknown window uses the configured value, while smaller live metadata remains authoritative. | | `modelInputModalities?` | `Record` | Per-model input hints such as `["text"]` or `["text", "image"]`. | diff --git a/tests/provider-retain-models.test.ts b/tests/provider-retain-models.test.ts index 8fd4b6b5ce1..c67678d82c6 100644 --- a/tests/provider-retain-models.test.ts +++ b/tests/provider-retain-models.test.ts @@ -32,6 +32,7 @@ describe("#1690 retainModels provider configuration", () => { const prov: OcxProviderConfig = { adapter: "openai-chat", baseUrl: "https://api.example.com/v1", + retainModels: [], }; const live = [model("live-model-1")]; const configured = [model("configured-model-1")]; From 2233c0cfa81bc3d205566f52e000b1deaded22ed Mon Sep 17 00:00:00 2001 From: chilung Date: Wed, 19 Aug 2026 15:43:01 +0000 Subject: [PATCH 03/13] test(catalog): cover undefined retainModels branch in live catalog merge --- tests/provider-retain-models.test.ts | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/tests/provider-retain-models.test.ts b/tests/provider-retain-models.test.ts index c67678d82c6..3307eb7cfe3 100644 --- a/tests/provider-retain-models.test.ts +++ b/tests/provider-retain-models.test.ts @@ -28,7 +28,7 @@ describe("#1690 retainModels provider configuration", () => { expect(droppedConfiguredIds).toEqual(["unrelated-model"]); }); - test("drops unlisted models when retainModels is empty or undefined", () => { + test("drops unlisted models when retainModels is empty", () => { const prov: OcxProviderConfig = { adapter: "openai-chat", baseUrl: "https://api.example.com/v1", @@ -48,6 +48,25 @@ describe("#1690 retainModels provider configuration", () => { expect(droppedConfiguredIds).toEqual(["configured-model-1"]); }); + test("drops unlisted models when retainModels is undefined", () => { + const prov: OcxProviderConfig = { + adapter: "openai-chat", + baseUrl: "https://api.example.com/v1", + }; + const live = [model("live-model-1")]; + const configured = [model("configured-model-1")]; + + const { models, droppedConfiguredIds } = mergeConfiguredModelsIntoLiveCatalog({ + name: "custom-prov", + provider: prov, + models: live, + configured, + }); + + expect(models.map(m => m.id)).toEqual(["live-model-1"]); + expect(droppedConfiguredIds).toEqual(["configured-model-1"]); + }); + test("preserves discovered models that match retainModels without duplication", () => { const prov: OcxProviderConfig = { adapter: "google", From c249c216af0de4dd576bc366cb8ae430e8bf80fd Mon Sep 17 00:00:00 2001 From: chilung Date: Wed, 19 Aug 2026 16:07:25 +0000 Subject: [PATCH 04/13] fix(review): retained-model 404 diagnostic, cursor retention, whitespace guard (#1690) --- src/codex/catalog/provider-fetch.ts | 45 ++++++++++++++++++++--- src/config.ts | 11 ++++++ src/server/chat-completions.ts | 3 ++ src/server/chat-native.ts | 2 ++ src/server/claude-messages.ts | 5 +++ src/server/responses/core.ts | 3 ++ tests/provider-retain-models.test.ts | 53 ++++++++++++++++++++++++++++ 7 files changed, 118 insertions(+), 4 deletions(-) diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index 21140df1ee7..4fce707ccbc 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -978,6 +978,30 @@ export function warnDroppedConfiguredIdsOnce(name: string, droppedConfiguredIds: ); } +/** + * Model ids on each provider that `retainModels` kept in the catalog even though live + * discovery did not report them. Used by dispatch error paths to explain a later + * upstream 404 (model_not_found) instead of letting the operator blame the proxy. + */ +const retainedWithoutDiscoveryRefs = new Map>(); +const warnedRetained404Refs = new Set(); + +/** + * Emit a one-shot warning when a model retained via `retainModels` is rejected by the + * upstream (HTTP 404 / model_not_found). Only fires for models that were actually + * retained without live-discovery confirmation, and only once per provider/model. + */ +export function warnRetainedModel404Once(providerName: string, modelId: string): void { + const refs = retainedWithoutDiscoveryRefs.get(providerName); + if (!refs || !refs.has(modelId)) return; + const signature = `${providerName}/${modelId}`; + if (warnedRetained404Refs.has(signature)) return; + warnedRetained404Refs.add(signature); + console.warn( + `[opencodex] Model "${modelId}" on provider "${providerName}" is retained via retainModels but upstream returned 404/model_not_found; the account or project may not be provisioned for it. Remove it from retainModels if it should not be callable.`, + ); +} + /** * Z.AI and Neuralwatt advertise GLM reasoning as a bare boolean, which would otherwise * collapse to the four-tier default ladder that omits `max`. These two helpers name the @@ -1228,7 +1252,7 @@ async function fetchProviderModelsWithAuth( models: CatalogModel[], options?: { retainComboTargets?: boolean; warnDrops?: boolean }, ): CatalogModel[] => { - const { models: merged, droppedConfiguredIds } = mergeConfiguredModelsIntoLiveCatalog({ + const { models: merged, droppedConfiguredIds, retainedConfiguredIds } = mergeConfiguredModelsIntoLiveCatalog({ name, provider: prov, models, @@ -1238,6 +1262,7 @@ async function fetchProviderModelsWithAuth( seedVertexDefault, retainComboTargets: options?.retainComboTargets, }); + if (retainedConfiguredIds.length > 0) retainedWithoutDiscoveryRefs.set(name, new Set(retainedConfiguredIds)); if ( options?.warnDrops === true && droppedConfiguredIds.length > 0 @@ -1317,7 +1342,17 @@ async function fetchProviderModelsWithAuth( // Live Max-Mode evidence feeds the umbrella resolver's ultra gate // (devlog 260828_cursor_umbrella_catalog; union with static evidence). recordLiveCursorMaxModeModels(liveResult.maxModeModels ?? []); - const result = available.length > 0 ? available : configured; + // retainModels is a config-level allowlist: fold retained ids back in so the + // merge below keeps them even when the Cursor plan does not report them. + const retainedIds = new Set(prov.retainModels ?? []); + const result = available.length > 0 + ? [ + ...available, + ...configured.filter( + candidate => retainedIds.has(candidate.id) && !available.some(existing => existing.id === candidate.id), + ), + ] + : configured; // Cache the discovery-filtered roster without combo retention so a later // gather can re-apply the current capture's retain set on read. const forCache = withConfiguredRetention(result, { retainComboTargets: false }); @@ -1600,7 +1635,7 @@ export function mergeConfiguredModelsIntoLiveCatalog(opts: { contextCap?: number; seedVertexDefault?: boolean; retainComboTargets?: boolean; -}): { models: CatalogModel[]; droppedConfiguredIds: string[] } { +}): { models: CatalogModel[]; droppedConfiguredIds: string[]; retainedConfiguredIds: string[] } { const { name, provider: prov, @@ -1613,6 +1648,7 @@ export function mergeConfiguredModelsIntoLiveCatalog(opts: { const out = [...opts.models]; const present = new Set(out.map(model => model.id)); const droppedConfiguredIds: string[] = []; + const retainedConfiguredIds: string[] = []; const providerRetainModels = Array.isArray(prov.retainModels) ? new Set(prov.retainModels) : undefined; for (const candidate of configured) { if (present.has(candidate.id)) continue; @@ -1630,11 +1666,12 @@ export function mergeConfiguredModelsIntoLiveCatalog(opts: { ) { out.push(candidate); present.add(candidate.id); + if (providerRetainModels?.has(candidate.id) === true) retainedConfiguredIds.push(candidate.id); continue; } droppedConfiguredIds.push(candidate.id); } - return { models: out, droppedConfiguredIds }; + return { models: out, droppedConfiguredIds, retainedConfiguredIds }; } export function filterCatalogVisibleModels( diff --git a/src/config.ts b/src/config.ts index 5014483d49f..5a4f6507393 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1216,6 +1216,17 @@ const configSchema = z.object({ message: structuredOutputOptOutError, }); } + const retainModelsError = nonBlankStringArrayConfigError( + (provider as { retainModels?: unknown }).retainModels, + "retainModels", + ); + if (retainModelsError) { + ctx.addIssue({ + code: "custom", + path: ["providers", redactSecretString(name), "retainModels"], + message: retainModelsError, + }); + } if (Object.hasOwn(provider, "codexAccountMode") && provider.codexAccountMode !== undefined) { // Persisted account mode is valid ONLY on the canonical built-in `openai` forward provider. // Old openai-multi rows stay parseable (they never carry a mode) so startup can migrate them. diff --git a/src/server/chat-completions.ts b/src/server/chat-completions.ts index db084df490e..93589cb5a3b 100644 --- a/src/server/chat-completions.ts +++ b/src/server/chat-completions.ts @@ -46,6 +46,7 @@ import { type TranslatorBudget, } from "../lib/translator-budget"; import { handleNativeChatCompletions, isNativeChatRouteEligible } from "./chat-native"; +import { warnRetainedModel404Once } from "../codex/catalog/provider-fetch"; type Rec = Record; @@ -317,6 +318,7 @@ async function handleChatCompletionsWithBudget( // Structured model_not_found must win over classifyError's generic remaps. classified.code = "model_not_found"; classified.type = "invalid_request_error"; + if (settledRoute) warnRetainedModel404Once(settledRoute.providerName, settledRoute.modelId); } else if (upstreamCode !== undefined && upstreamCode !== null && classified.code == null) { classified.code = upstreamCode; } @@ -406,6 +408,7 @@ async function handleChatCompletionsWithBudget( // Same deliberate preserve as the non-OK path: structured code beats generic classify. classified.code = "model_not_found"; classified.type = "invalid_request_error"; + if (settledRoute) warnRetainedModel404Once(settledRoute.providerName, settledRoute.modelId); } return chatCompletionsErrorResponse( classified.code === "translation_buffer_limit" diff --git a/src/server/chat-native.ts b/src/server/chat-native.ts index 2e84dc68d36..ff821a8d899 100644 --- a/src/server/chat-native.ts +++ b/src/server/chat-native.ts @@ -48,6 +48,7 @@ import { type RequestLogContext, } from "./request-log"; import { jsonCompletionSse, nativeChatSse, structuredError, usageFromChat } from "./chat-native-sse"; +import { warnRetainedModel404Once } from "../codex/catalog/provider-fetch"; import { registerTurn, unregisterTurn } from "./lifecycle"; type Rec = Record; @@ -318,6 +319,7 @@ export async function handleNativeChatCompletions(options: HandleNativeChatOptio } else if (upstreamCode === "model_not_found") { classified.code = "model_not_found"; classified.type = "invalid_request_error"; + warnRetainedModel404Once(route.providerName, route.modelId); } else if (upstreamCode !== undefined && upstreamCode !== null && classified.code == null) { classified.code = upstreamCode; } diff --git a/src/server/claude-messages.ts b/src/server/claude-messages.ts index 8425395b186..5a0e5aff2d0 100644 --- a/src/server/claude-messages.ts +++ b/src/server/claude-messages.ts @@ -50,6 +50,7 @@ import { isTranslatorBudgetExceededError, type TranslatorBudget, } from "../lib/translator-budget"; +import { warnRetainedModel404Once } from "../codex/catalog/provider-fetch"; type Rec = Record; @@ -679,8 +680,10 @@ async function handleClaudeMessagesWithBudget( // bodies: it 400s on sampling params ("Unsupported parameter: max_output_tokens", // verified live 2026-07-11). Strip them for that route; routed providers keep them. let nativeRoute = false; + let settledRoute: ReturnType | null = null; try { const route = routeModel(config, internalBody.model as string, evidenceFromBody(internalBody)); + settledRoute = route; // Settle the wire once so the sampling decision below reads the effective // adapter rather than the provider-wide default (#404). route.provider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, "anthropic"); @@ -786,6 +789,8 @@ async function handleClaudeMessagesWithBudget( const response = logIds ? responseWithDeferredRequestLog(upstream, logIds.requestId, logIds.start, logCtx) : upstream; if (!response.ok) { + // Retained-but-unprovisioned models surface here as upstream 404; explain once. + if (response.status === 404 && settledRoute) warnRetainedModel404Once(settledRoute.providerName, settledRoute.modelId); // Re-shape the OpenAI-style error envelope into the Anthropic one, preserving status. let message = `upstream error (${response.status})`; try { diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 691d75ada4d..8a95012c23e 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -8,6 +8,7 @@ import { } from "./responses-field-backfill"; import { checkInputAdmission } from "./input-admission"; import { nativeContextLimits } from "../../codex/catalog"; +import { warnRetainedModel404Once } from "../../codex/catalog/provider-fetch"; import { describeUpstreamConnectFailure } from "./upstream-error"; import { multiAgentGuidanceEnabled, @@ -4157,6 +4158,8 @@ async function handleResponsesInner( }); } if (!upstreamResponse.ok) { + // Retained-but-unprovisioned models surface here as upstream 404; explain once. + if (upstreamResponse.status === 404) warnRetainedModel404Once(route.providerName, route.modelId); if (options.comboAttempt) { // No pre-read guard here: `consumeComboFailure` -> `readBoundedResponseBody` reads // `response.body` itself and already threads the abort signal through its own read, diff --git a/tests/provider-retain-models.test.ts b/tests/provider-retain-models.test.ts index 3307eb7cfe3..54439a49785 100644 --- a/tests/provider-retain-models.test.ts +++ b/tests/provider-retain-models.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { mergeConfiguredModelsIntoLiveCatalog } from "../src/codex/catalog/provider-fetch"; +import { nonBlankStringArrayConfigError } from "../src/config"; import type { CatalogModel } from "../src/codex/catalog/parsing"; import type { OcxProviderConfig } from "../src/types"; @@ -110,4 +111,56 @@ describe("#1690 retainModels provider configuration", () => { expect(models.map(m => m.id)).toEqual(["gemini-3.7-flash", "claude-sonnet-4-6"]); expect(droppedConfiguredIds).toEqual(["dropped-model"]); }); + + test("reports retainedConfiguredIds only for retainModels-kept models omitted by live discovery", () => { + const prov: OcxProviderConfig = { + adapter: "google", + baseUrl: "https://daily-cloudcode-pa.googleapis.com", + retainModels: ["gemini-3.7-flash", "claude-sonnet-4-6"], + }; + const live = [model("gemini-3.5-flash")]; + const configured = [ + model("gemini-3.7-flash"), + model("claude-sonnet-4-6"), + model("unrelated-model"), + ]; + + const { models, droppedConfiguredIds, retainedConfiguredIds } = mergeConfiguredModelsIntoLiveCatalog({ + name: "google-antigravity", + provider: prov, + models: live, + configured, + }); + + expect(models.map(m => m.id)).toEqual(["gemini-3.5-flash", "gemini-3.7-flash", "claude-sonnet-4-6"]); + expect(retainedConfiguredIds.sort()).toEqual(["claude-sonnet-4-6", "gemini-3.7-flash"]); + expect(droppedConfiguredIds).toEqual(["unrelated-model"]); + }); + + test("does not report live-discovered models as retainedConfiguredIds", () => { + const prov: OcxProviderConfig = { + adapter: "google", + baseUrl: "https://daily-cloudcode-pa.googleapis.com", + retainModels: ["gemini-3.7-flash"], + }; + const live = [model("gemini-3.7-flash")]; + const configured = [model("gemini-3.7-flash")]; + + const { models, retainedConfiguredIds } = mergeConfiguredModelsIntoLiveCatalog({ + name: "google-antigravity", + provider: prov, + models: live, + configured, + }); + + expect(models.map(m => m.id)).toEqual(["gemini-3.7-flash"]); + expect(retainedConfiguredIds).toEqual([]); + }); + + test("rejects whitespace-only retainModels entries via nonBlankStringArrayConfigError", () => { + const error = nonBlankStringArrayConfigError([" "], "retainModels"); + expect(error).not.toBeNull(); + expect(error).toContain("nonblank"); + expect(nonBlankStringArrayConfigError(["gemini-3.7-flash", " gemini-3.5-flash "], "retainModels")).toBeNull(); + }); }); From eb5544dc81cf125f7a3fd9e1c04b38192e15812f Mon Sep 17 00:00:00 2001 From: chilung Date: Thu, 20 Aug 2026 13:10:56 +0000 Subject: [PATCH 05/13] fix(catalog): clear stale retainedWithoutDiscoveryRefs on live recovery --- src/codex/catalog/provider-fetch.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index 4fce707ccbc..8f847ad4ee2 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -1262,7 +1262,11 @@ async function fetchProviderModelsWithAuth( seedVertexDefault, retainComboTargets: options?.retainComboTargets, }); - if (retainedConfiguredIds.length > 0) retainedWithoutDiscoveryRefs.set(name, new Set(retainedConfiguredIds)); + if (retainedConfiguredIds.length > 0) { + retainedWithoutDiscoveryRefs.set(name, new Set(retainedConfiguredIds)); + } else { + retainedWithoutDiscoveryRefs.delete(name); + } if ( options?.warnDrops === true && droppedConfiguredIds.length > 0 From de87fa440064add3d92fd50a35c596afd224fc08 Mon Sep 17 00:00:00 2001 From: chilung Date: Thu, 20 Aug 2026 13:17:29 +0000 Subject: [PATCH 06/13] test(catalog): verify retainModels interaction with selectedModels filtering --- tests/provider-retain-models.test.ts | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/tests/provider-retain-models.test.ts b/tests/provider-retain-models.test.ts index 54439a49785..ae9196ed9cc 100644 --- a/tests/provider-retain-models.test.ts +++ b/tests/provider-retain-models.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { mergeConfiguredModelsIntoLiveCatalog } from "../src/codex/catalog/provider-fetch"; +import { filterCatalogVisibleModels, mergeConfiguredModelsIntoLiveCatalog } from "../src/codex/catalog/provider-fetch"; import { nonBlankStringArrayConfigError } from "../src/config"; import type { CatalogModel } from "../src/codex/catalog/parsing"; import type { OcxProviderConfig } from "../src/types"; @@ -163,4 +163,29 @@ describe("#1690 retainModels provider configuration", () => { expect(error).toContain("nonblank"); expect(nonBlankStringArrayConfigError(["gemini-3.7-flash", " gemini-3.5-flash "], "retainModels")).toBeNull(); }); + + test("respects selectedModels and disabledModels filtering after retaining models", () => { + const prov: OcxProviderConfig = { + adapter: "google", + baseUrl: "https://daily-cloudcode-pa.googleapis.com", + retainModels: ["gemini-3.7-flash", "retained-unselected"], + selectedModels: ["gemini-3.7-flash"], + }; + const live = [model("gemini-3.5-flash", "google-antigravity")]; + const configured = [model("gemini-3.7-flash", "google-antigravity"), model("retained-unselected", "google-antigravity")]; + + const { models } = mergeConfiguredModelsIntoLiveCatalog({ + name: "google-antigravity", + provider: prov, + models: live, + configured, + }); + + expect(models.map(m => m.id)).toEqual(["gemini-3.5-flash", "gemini-3.7-flash", "retained-unselected"]); + + const visible = filterCatalogVisibleModels(models, { + providers: { "google-antigravity": prov }, + }); + expect(visible.map(m => m.id)).toEqual(["gemini-3.7-flash"]); + }); }); From 3842173b70aa5254ccf195bea59526e70f5fd639 Mon Sep 17 00:00:00 2001 From: chilung Date: Thu, 20 Aug 2026 13:17:57 +0000 Subject: [PATCH 07/13] fix(catalog): rely on mergeConfiguredModelsIntoLiveCatalog for cursor retainModels to preserve 404 diagnostics --- src/codex/catalog/provider-fetch.ts | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index 8f847ad4ee2..2e99af004ec 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -1346,17 +1346,7 @@ async function fetchProviderModelsWithAuth( // Live Max-Mode evidence feeds the umbrella resolver's ultra gate // (devlog 260828_cursor_umbrella_catalog; union with static evidence). recordLiveCursorMaxModeModels(liveResult.maxModeModels ?? []); - // retainModels is a config-level allowlist: fold retained ids back in so the - // merge below keeps them even when the Cursor plan does not report them. - const retainedIds = new Set(prov.retainModels ?? []); - const result = available.length > 0 - ? [ - ...available, - ...configured.filter( - candidate => retainedIds.has(candidate.id) && !available.some(existing => existing.id === candidate.id), - ), - ] - : configured; + const result = available.length > 0 ? available : configured; // Cache the discovery-filtered roster without combo retention so a later // gather can re-apply the current capture's retain set on read. const forCache = withConfiguredRetention(result, { retainComboTargets: false }); From af3b0c8170a302f40b25418fa0913f08300e3285 Mon Sep 17 00:00:00 2001 From: chilung Date: Fri, 21 Aug 2026 12:01:37 +0800 Subject: [PATCH 08/13] fix(catalog): clear retained model diagnostic memos across generation reconciliation (#1690) --- src/codex/catalog/provider-fetch.ts | 3 +++ tests/provider-retain-models.test.ts | 7 +++++++ 2 files changed, 10 insertions(+) diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index 2e99af004ec..04bbdd2ac20 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -946,6 +946,8 @@ export function reconcileProviderFetchWarnings(generation: number): number { if (generation <= lastWarningReconciledGeneration) return 0; const removed = lastDropWarnSignature.size; lastDropWarnSignature.clear(); + retainedWithoutDiscoveryRefs.clear(); + warnedRetained404Refs.clear(); lastWarningReconciledGeneration = generation; return removed; } @@ -1281,6 +1283,7 @@ async function fetchProviderModelsWithAuth( // discovery failure left by an older live configuration even when the account is logged out. if (prov.liveModels === false) { clearProviderDiscoveryStatus(name); + retainedWithoutDiscoveryRefs.delete(name); return observed(configured, "authoritative"); } const auth: ModelsAuthResolution = captured.observedAuth ?? (resolveAuth.kind === "refreshing" diff --git a/tests/provider-retain-models.test.ts b/tests/provider-retain-models.test.ts index ae9196ed9cc..92697b924b0 100644 --- a/tests/provider-retain-models.test.ts +++ b/tests/provider-retain-models.test.ts @@ -188,4 +188,11 @@ describe("#1690 retainModels provider configuration", () => { }); expect(visible.map(m => m.id)).toEqual(["gemini-3.7-flash"]); }); + test("reconcileProviderFetchWarnings clears retained without discovery memos on generation change", () => { + const { reconcileProviderFetchWarnings } = require("../src/codex/catalog/provider-fetch"); + expect(typeof reconcileProviderFetchWarnings).toBe("function"); + // Advance generation and ensure it cleans up + reconcileProviderFetchWarnings(100); + }); + }); From 699160d9fdb4b4a0721f305cc852f267a8a324a6 Mon Sep 17 00:00:00 2001 From: chilung Date: Fri, 21 Aug 2026 12:36:14 +0800 Subject: [PATCH 09/13] fix(catalog): preserve retained diagnostics across multi-pass gathers and unify 404 predicates (#1690) --- src/codex/catalog/provider-fetch.ts | 96 +++++------ src/server/chat-completions.ts | 4 +- src/server/chat-native.ts | 4 +- src/server/responses/core.ts | 7 +- tests/provider-retain-models.test.ts | 230 ++++++++++++++++++++++++++- 5 files changed, 282 insertions(+), 59 deletions(-) diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index 04bbdd2ac20..11131d539d4 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -985,8 +985,8 @@ export function warnDroppedConfiguredIdsOnce(name: string, droppedConfiguredIds: * discovery did not report them. Used by dispatch error paths to explain a later * upstream 404 (model_not_found) instead of letting the operator blame the proxy. */ -const retainedWithoutDiscoveryRefs = new Map>(); -const warnedRetained404Refs = new Set(); +export const retainedWithoutDiscoveryRefs = new Map>(); +export const warnedRetained404Refs = new Set(); /** * Emit a one-shot warning when a model retained via `retainModels` is rejected by the @@ -1250,35 +1250,37 @@ async function fetchProviderModelsWithAuth( provider: name, ...catalogHintsFromProviderConfig(name, prov, id, contextCap), })); - const withConfiguredRetention = ( - models: CatalogModel[], - options?: { retainComboTargets?: boolean; warnDrops?: boolean }, - ): CatalogModel[] => { - const { models: merged, droppedConfiguredIds, retainedConfiguredIds } = mergeConfiguredModelsIntoLiveCatalog({ - name, - provider: prov, - models, - configured, - retainConfiguredModelIds: captured.retainConfiguredModelIds, - contextCap, - seedVertexDefault, - retainComboTargets: options?.retainComboTargets, - }); - if (retainedConfiguredIds.length > 0) { - retainedWithoutDiscoveryRefs.set(name, new Set(retainedConfiguredIds)); - } else { - retainedWithoutDiscoveryRefs.delete(name); - } - if ( - options?.warnDrops === true - && droppedConfiguredIds.length > 0 - && name !== OPENAI_API_PROVIDER_ID - && !QUIET_AUTHORITATIVE_CATALOG_PROVIDERS.has(name) - ) { - warnDroppedConfiguredIdsOnce(name, droppedConfiguredIds); + const withConfiguredRetention = ( + models: CatalogModel[], + options?: { retainComboTargets?: boolean; warnDrops?: boolean; recordRetainedDiagnostics?: boolean }, + ): CatalogModel[] => { + const { models: merged, droppedConfiguredIds, retainedConfiguredIds } = mergeConfiguredModelsIntoLiveCatalog({ + name, + provider: prov, + models, + configured, + retainConfiguredModelIds: captured.retainConfiguredModelIds, + contextCap, + seedVertexDefault, + retainComboTargets: options?.retainComboTargets, + }); + if (options?.recordRetainedDiagnostics === true) { + if (retainedConfiguredIds.length > 0) { + retainedWithoutDiscoveryRefs.set(name, new Set(retainedConfiguredIds)); + } else { + retainedWithoutDiscoveryRefs.delete(name); + } } - return merged; - }; + if ( + options?.warnDrops === true + && droppedConfiguredIds.length > 0 + && name !== OPENAI_API_PROVIDER_ID + && !QUIET_AUTHORITATIVE_CATALOG_PROVIDERS.has(name) + ) { + warnDroppedConfiguredIdsOnce(name, droppedConfiguredIds); + } + return merged; + }; // Static catalogs never need an OAuth refresh or an upstream model request. Clear any // discovery failure left by an older live configuration even when the account is logged out. if (prov.liveModels === false) { @@ -1352,12 +1354,12 @@ async function fetchProviderModelsWithAuth( const result = available.length > 0 ? available : configured; // Cache the discovery-filtered roster without combo retention so a later // gather can re-apply the current capture's retain set on read. - const forCache = withConfiguredRetention(result, { retainComboTargets: false }); + const forCache = withConfiguredRetention(result, { retainComboTargets: false, recordRetainedDiagnostics: true }); if (!setCached(name, forCache, Date.now(), cacheGeneration)) { return observed(withConfiguredRetention(configured), "degraded"); } markProviderDiscoveryOk(name, liveResult.models.length); - return observed(withConfiguredRetention(forCache, { warnDrops: true }), "authoritative"); + return observed(withConfiguredRetention(result, { warnDrops: true }), "authoritative"); } if (isCurrentCacheGeneration()) { markModelsFetchFailure(name); @@ -1504,11 +1506,11 @@ async function fetchProviderModelsWithAuth( provider: name, // CCA only exposes a numeric thinking budget. Until the adapter owns an exact Codex // effort-to-wire mapping for a newly discovered model, do not advertise a false ladder. - reasoningEfforts: [], - ...(model.contextWindow ? { contextWindow: model.contextWindow } : {}), - ...(model.inputModalities ? { inputModalities: model.inputModalities } : {}), - }, contextCap)); - const forCache = withConfiguredRetention(live, { retainComboTargets: false }); + reasoningEfforts: [], + ...(model.contextWindow ? { contextWindow: model.contextWindow } : {}), + ...(model.inputModalities ? { inputModalities: model.inputModalities } : {}), + }, contextCap)); + const forCache = withConfiguredRetention(live, { retainComboTargets: false, recordRetainedDiagnostics: true }); if (!setCached(name, forCache, Date.now(), cacheGeneration)) { return observed(withConfiguredRetention(configured), "degraded"); } @@ -1517,7 +1519,7 @@ async function fetchProviderModelsWithAuth( cacheGeneration, }); markProviderDiscoveryOk(name, live.length); - return observed(withConfiguredRetention(forCache, { warnDrops: true }), "authoritative"); + return observed(withConfiguredRetention(live, { warnDrops: true }), "authoritative"); } const extracted = extractProviderModelItems(bounded.value, discovery); if (!extracted.ok) { @@ -1548,15 +1550,15 @@ async function fetchProviderModelsWithAuth( .filter(m => shouldExposeProviderModel(name, m.id)); // Capture the count BEFORE the alias/configured augmentation below pushes extra rows into // `live`; otherwise configured entries would be reported as discovered ones. - const liveModelCount = live.length; - // Dated-release aliases + configured retention (compat allow-list, combo targets, - // Vertex default). Cache without combo retention so a later gather re-applies the - // current capture's retain set on read (warm-cache OCX-111 / #1308). - const forCache = withConfiguredRetention(live, { retainComboTargets: false }); - const returned = withConfiguredRetention(forCache, { warnDrops: true }); - const droppedConfiguredIds = configured - .map(model => model.id) - .filter(id => !returned.some(model => model.id === id)); + const liveModelCount = live.length; + // Dated-release aliases + configured retention (compat allow-list, combo targets, + // Vertex default). Cache without combo retention so a later gather re-applies the + // current capture's retain set on read (warm-cache OCX-111 / #1308). + const forCache = withConfiguredRetention(live, { retainComboTargets: false, recordRetainedDiagnostics: true }); + const returned = withConfiguredRetention(live, { warnDrops: true }); + const droppedConfiguredIds = configured + .map(model => model.id) + .filter(id => !returned.some(model => model.id === id)); if (returned.length === 0 && name !== OPENAI_API_PROVIDER_ID) { console.warn( `[opencodex] Provider model discovery for "${name}" returned an authoritative empty catalog; ${droppedConfiguredIds.length > 0 ? `dropping configured model ids: ${droppedConfiguredIds.join(", ")}` : "no models will be exposed"}.`, diff --git a/src/server/chat-completions.ts b/src/server/chat-completions.ts index 93589cb5a3b..287bcc2a951 100644 --- a/src/server/chat-completions.ts +++ b/src/server/chat-completions.ts @@ -311,6 +311,9 @@ async function handleChatCompletionsWithBudget( : "invalid_request_error"), message, ); + if (settledRoute && (upstream.status === 404 || upstreamCode === "model_not_found")) { + warnRetainedModel404Once(settledRoute.providerName, settledRoute.modelId); + } if (isCyberPolicyCode(upstreamCode) || classified.code === CYBER_POLICY_ERROR_CODE) { classified.code = CYBER_POLICY_ERROR_CODE; classified.type = cyberPolicyErrorType(upstreamType); @@ -318,7 +321,6 @@ async function handleChatCompletionsWithBudget( // Structured model_not_found must win over classifyError's generic remaps. classified.code = "model_not_found"; classified.type = "invalid_request_error"; - if (settledRoute) warnRetainedModel404Once(settledRoute.providerName, settledRoute.modelId); } else if (upstreamCode !== undefined && upstreamCode !== null && classified.code == null) { classified.code = upstreamCode; } diff --git a/src/server/chat-native.ts b/src/server/chat-native.ts index ff821a8d899..e485db1b418 100644 --- a/src/server/chat-native.ts +++ b/src/server/chat-native.ts @@ -313,13 +313,15 @@ export async function handleNativeChatCompletions(options: HandleNativeChatOptio : response.status >= 500 ? "server_error" : "invalid_request_error"), message, ); + if (response.status === 404 || upstreamCode === "model_not_found") { + warnRetainedModel404Once(route.providerName, route.modelId); + } if (isCyberPolicyCode(upstreamCode) || classified.code === CYBER_POLICY_ERROR_CODE) { classified.code = CYBER_POLICY_ERROR_CODE; classified.type = cyberPolicyErrorType(upstreamType); } else if (upstreamCode === "model_not_found") { classified.code = "model_not_found"; classified.type = "invalid_request_error"; - warnRetainedModel404Once(route.providerName, route.modelId); } else if (upstreamCode !== undefined && upstreamCode !== null && classified.code == null) { classified.code = upstreamCode; } diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 8a95012c23e..6972768daa2 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -5649,9 +5649,10 @@ async function handleResponsesInner( } break; } - if (!upstreamResponse.ok) { - if (options.comboAttempt) { - // No pre-read guard: `consumeComboFailure` -> `readBoundedResponseBody` reads + if (!upstreamResponse.ok) { + if (upstreamResponse.status === 404) warnRetainedModel404Once(route.providerName, route.modelId); + if (options.comboAttempt) { + // No pre-read guard: `consumeComboFailure` -> `readBoundedResponseBody` reads // `response.body` itself with the abort signal threaded through, and the combo // contract is that this body's getter is touched exactly once. A guard here would be // a second `.body` access for no gain, since the bounded reader owns settlement. diff --git a/tests/provider-retain-models.test.ts b/tests/provider-retain-models.test.ts index 92697b924b0..31fad20e6b6 100644 --- a/tests/provider-retain-models.test.ts +++ b/tests/provider-retain-models.test.ts @@ -1,8 +1,21 @@ import { describe, expect, test } from "bun:test"; -import { filterCatalogVisibleModels, mergeConfiguredModelsIntoLiveCatalog } from "../src/codex/catalog/provider-fetch"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { installIsolatedCodexHome } from "./helpers/isolated-codex-home"; +import { + filterCatalogVisibleModels, + mergeConfiguredModelsIntoLiveCatalog, + reconcileProviderFetchWarnings, + warnRetainedModel404Once, + retainedWithoutDiscoveryRefs, + warnedRetained404Refs, +} from "../src/codex/catalog/provider-fetch"; import { nonBlankStringArrayConfigError } from "../src/config"; +import { saveConfig } from "../src/config"; +import { startServer } from "../src/server"; import type { CatalogModel } from "../src/codex/catalog/parsing"; -import type { OcxProviderConfig } from "../src/types"; +import type { OcxConfig, OcxProviderConfig } from "../src/types"; function model(id: string, provider = "test-prov"): CatalogModel { return { id, provider }; @@ -188,11 +201,214 @@ describe("#1690 retainModels provider configuration", () => { }); expect(visible.map(m => m.id)).toEqual(["gemini-3.7-flash"]); }); - test("reconcileProviderFetchWarnings clears retained without discovery memos on generation change", () => { - const { reconcileProviderFetchWarnings } = require("../src/codex/catalog/provider-fetch"); - expect(typeof reconcileProviderFetchWarnings).toBe("function"); - // Advance generation and ensure it cleans up - reconcileProviderFetchWarnings(100); + test("warnRetainedModel404Once warns on first 404, suppresses on second, and resets after reconcileProviderFetchWarnings", () => { + reconcileProviderFetchWarnings(1); + retainedWithoutDiscoveryRefs.set("test-prov", new Set(["gemini-3.7-flash"])); + + const warnCalls: string[] = []; + const originalWarn = console.warn; + console.warn = (...args: any[]) => { + warnCalls.push(args.join(" ")); + }; + + try { + // First 404 should emit warning + warnRetainedModel404Once("test-prov", "gemini-3.7-flash"); + expect(warnCalls.length).toBe(1); + expect(warnCalls[0]).toContain('Model "gemini-3.7-flash" on provider "test-prov" is retained via retainModels'); + + // Second 404 for same provider/model should be suppressed + warnRetainedModel404Once("test-prov", "gemini-3.7-flash"); + expect(warnCalls.length).toBe(1); + + // Model not in retainedWithoutDiscoveryRefs should NOT warn + warnRetainedModel404Once("test-prov", "other-model"); + expect(warnCalls.length).toBe(1); + + // Advance generation via reconcileProviderFetchWarnings + reconcileProviderFetchWarnings(2); + expect(retainedWithoutDiscoveryRefs.size).toBe(0); + expect(warnedRetained404Refs.size).toBe(0); + + // Re-populate and verify warning can fire again in new generation + retainedWithoutDiscoveryRefs.set("test-prov", new Set(["gemini-3.7-flash"])); + warnRetainedModel404Once("test-prov", "gemini-3.7-flash"); + expect(warnCalls.length).toBe(2); + } finally { + console.warn = originalWarn; + } + }); + + test("multi-pass retention (live -> forCache -> returned) preserves retainedWithoutDiscoveryRefs", () => { + reconcileProviderFetchWarnings(10); + const prov: OcxProviderConfig = { + adapter: "google", + baseUrl: "https://daily-cloudcode-pa.googleapis.com", + retainModels: ["gemini-3.7-flash"], + }; + const live = [model("gemini-3.5-flash")]; + const configured = [model("gemini-3.7-flash")]; + + // Pass 1: live evaluation with recordRetainedDiagnostics: true + const { models: forCache, retainedConfiguredIds: pass1Retained } = mergeConfiguredModelsIntoLiveCatalog({ + name: "google-antigravity", + provider: prov, + models: live, + configured, + }); + expect(pass1Retained).toEqual(["gemini-3.7-flash"]); + retainedWithoutDiscoveryRefs.set("google-antigravity", new Set(pass1Retained)); + + // Pass 2: returned / cached evaluation on forCache + const { models: returned, retainedConfiguredIds: pass2Retained } = mergeConfiguredModelsIntoLiveCatalog({ + name: "google-antigravity", + provider: prov, + models: forCache, + configured, + }); + expect(pass2Retained).toEqual([]); + // retainedWithoutDiscoveryRefs must still have the model + expect(retainedWithoutDiscoveryRefs.get("google-antigravity")?.has("gemini-3.7-flash")).toBe(true); }); }); + +describe("server 404 diagnostics for retained models", () => { + test("/v1/chat/completions triggers warnRetainedModel404Once on upstream 404 even without code: model_not_found", async () => { + const isolated = installIsolatedCodexHome("ocx-retain-chat-"); + const testDir = mkdtempSync(join(tmpdir(), "ocx-retain-chat-")); + const prevHome = process.env.OPENCODEX_HOME; + process.env.OPENCODEX_HOME = testDir; + + reconcileProviderFetchWarnings(20); + retainedWithoutDiscoveryRefs.set("test-chat-prov", new Set(["retained-chat-model"])); + + const warnCalls: string[] = []; + const originalWarn = console.warn; + console.warn = (...args: any[]) => { + warnCalls.push(args.join(" ")); + }; + + const upstream = Bun.serve({ + port: 0, + fetch() { + return Response.json({ + error: { + message: "Model does not exist", + type: "invalid_request_error", + }, + }, { status: 404 }); + }, + }); + + saveConfig({ + port: 0, + defaultProvider: "test-chat-prov", + providers: { + "test-chat-prov": { + adapter: "openai-chat", + baseUrl: `http://127.0.0.1:${upstream.port}/v1`, + apiKey: "test-key", + allowPrivateNetwork: true, + retainModels: ["retained-chat-model"], + }, + }, + } as OcxConfig); + const server = startServer(0); + + try { + const response = await fetch(new URL("/v1/chat/completions", server.url), { + method: "POST", + headers: { + "content-type": "application/json", + authorization: "Bearer test-caller-token", + }, + body: JSON.stringify({ + model: "retained-chat-model", + stream: false, + messages: [{ role: "user", content: "hello" }], + }), + }); + expect(response.status).toBe(404); + expect(warnCalls.length).toBe(1); + expect(warnCalls[0]).toContain('Model "retained-chat-model" on provider "test-chat-prov" is retained via retainModels'); + } finally { + await server.stop(true); + upstream.stop(true); + console.warn = originalWarn; + if (prevHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = prevHome; + isolated.restore(); + rmSync(testDir, { recursive: true, force: true }); + } + }); + + test("non-passthrough responses error handling triggers warnRetainedModel404Once on 404", async () => { + const isolated = installIsolatedCodexHome("ocx-retain-resp-"); + const testDir = mkdtempSync(join(tmpdir(), "ocx-retain-resp-")); + const prevHome = process.env.OPENCODEX_HOME; + process.env.OPENCODEX_HOME = testDir; + + reconcileProviderFetchWarnings(30); + retainedWithoutDiscoveryRefs.set("test-anthropic-prov", new Set(["claude-retained"])); + + const warnCalls: string[] = []; + const originalWarn = console.warn; + console.warn = (...args: any[]) => { + warnCalls.push(args.join(" ")); + }; + + const upstream = Bun.serve({ + port: 0, + fetch() { + return Response.json({ + type: "error", + error: { + type: "not_found_error", + message: "model: claude-retained", + }, + }, { status: 404 }); + }, + }); + + saveConfig({ + port: 0, + defaultProvider: "test-anthropic-prov", + providers: { + "test-anthropic-prov": { + adapter: "anthropic", + baseUrl: `http://127.0.0.1:${upstream.port}`, + apiKey: "test-key", + allowPrivateNetwork: true, + retainModels: ["claude-retained"], + }, + }, + } as OcxConfig); + const server = startServer(0); + + try { + const response = await fetch(new URL("/v1/responses", server.url), { + method: "POST", + headers: { + "content-type": "application/json", + authorization: "Bearer test-caller-token", + }, + body: JSON.stringify({ + model: "claude-retained", + input: [{ type: "message", role: "user", content: "hello" }], + }), + }); + expect(response.status).toBe(404); + expect(warnCalls.length).toBe(1); + expect(warnCalls[0]).toContain('Model "claude-retained" on provider "test-anthropic-prov" is retained via retainModels'); + } finally { + await server.stop(true); + upstream.stop(true); + console.warn = originalWarn; + if (prevHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = prevHome; + isolated.restore(); + rmSync(testDir, { recursive: true, force: true }); + } + }); +}); From f280627178681b7567c885f1c532a74e5041a428 Mon Sep 17 00:00:00 2001 From: chilung Date: Sat, 22 Aug 2026 01:47:01 +0000 Subject: [PATCH 10/13] fix(catalog): generation-consistent retained model provenance and localized docs sync (#1690) --- .../ja/reference/configuration/providers.md | 1 + .../ko/reference/configuration/providers.md | 1 + .../reference/configuration/providers.md | 1 + .../reference/configuration/providers.md | 1 + src/codex/catalog/parsing.ts | 2 + src/codex/catalog/provider-fetch.ts | 103 +++++--- tests/provider-retain-models.test.ts | 247 ++++++++++-------- 7 files changed, 213 insertions(+), 143 deletions(-) diff --git a/docs-site/src/content/docs/ja/reference/configuration/providers.md b/docs-site/src/content/docs/ja/reference/configuration/providers.md index f1210892a57..7d897f705fa 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -68,6 +68,7 @@ account を削除しても mapping は保持され、同じ id を再追加す | `models?` | `string[]` |シード/フォールバック モデルのリスト。 `liveModels: false` では、発見されたモデルはこれらのみです。 | | `liveModels?` | `boolean` |開始/同期時にライブ カタログをフェッチします (デフォルトは `true`)。カスタムプロバイダーは `${baseUrl}/models` を使用します。組み込みはレジストリ URL とフィルターを使用する場合があります。 | | `selectedModels?` | `string[]` |検出後のカタログ許可リスト。空でない場合は、それらの ID のみが公開されます。空または省略すると、検出されたすべてのモデルが公開されます。 | +| `retainModels?` | `string[]` | ライブディスカバリで省略された場合でもカタログに保持するモデルIDの許可リスト(未プロビジョニングのアカウントや未掲載モデルなど)。ここに記載されていない設定済みIDは、ディスカバリで省略された場合に引き続き除外(prune)されます。 | | `contextWindow?` | `number` | アップストリームのメタデータが無い場合に使うプロバイダー全体のコンテキスト値。メタデータがある場合は上限として働き、より小さいライブ値をそのまま残します。Models ダッシュボードでは `providerContextCaps` とは別に設定します。 | | `modelContextWindows?` | `Record` | モデルごとのコンテキスト値および上限。`contextWindow` より優先され、ウィンドウが不明なら設定値を使い、より小さいライブメタデータがあればそちらが優先されます。 | | `modelInputModalities?` | `Record` | `["text"]` や `["text", "image"]` などのモデルごとの入力ヒント。 | diff --git a/docs-site/src/content/docs/ko/reference/configuration/providers.md b/docs-site/src/content/docs/ko/reference/configuration/providers.md index ccacb0a94f8..d393a0db24f 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -68,6 +68,7 @@ managed map을 활성화하면 privacy-safe selector를 만들고, 이후 계정 | `models?` | `string[]` | 시드/폴백 모델 목록입니다. `liveModels: false`이면 이 목록만 발견된 모델로 취급합니다. | | `liveModels?` | `boolean` | 시작 또는 동기화 시 라이브 카탈로그를 가져옵니다. 기본값은 `true`입니다. 사용자 지정 공급자는 `${baseUrl}/models`를 사용하고, 내장은 레지스트리 URL을 사용한 뒤 필터링할 수 있습니다. | | `selectedModels?` | `string[]` | 발견 후 카탈로그 허용 목록입니다. 값이 비어 있지 않으면 그 id만 노출하고, 비어 있거나 생략하면 발견된 모델을 모두 노출합니다. | +| `retainModels?` | `string[]` | 라이브 발견에서 누락되더라도 카탈로그에 유지할 모델 ID 허용 목록입니다 (예: 미프로비저닝 계정 또는 미등록 모델). 여기에 나열되지 않은 구성 ID는 발견에서 누락될 때 계속 제외(prune)됩니다. | | `contextWindow?` | `number` | 업스트림 메타데이터가 없을 때 쓰이는 공급자 전반의 컨텍스트 값입니다. 메타데이터가 있으면 상한으로 동작해 더 작은 라이브 값을 그대로 둡니다. Models 대시보드에서 `providerContextCaps`와 별도로 설정합니다. | | `modelContextWindows?` | `Record` | 모델별 컨텍스트 값이자 상한입니다. `contextWindow`보다 우선하며, 창 크기를 알 수 없으면 설정값을 쓰고 더 작은 라이브 메타데이터가 있으면 그쪽을 따릅니다. | | `modelInputModalities?` | `Record` | `["text"]` 또는 `["text", "image"]` 같은 모델별 입력 힌트입니다. | diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md index 3630a9ba6ce..105008a9d6b 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md @@ -68,6 +68,7 @@ selector,而不是分配一个新名称。 | `models?` | `string[]` | 种子/回退模型列表。配合 `liveModels: false` 时,这些就是唯一发现到的模型。 | | `liveModels?` | `boolean` | 启动/同步时获取实时目录(默认 `true`)。自定义提供者使用 `${baseUrl}/models`;内置项可能使用注册表 URL 并进行过滤。 | | `selectedModels?` | `string[]` | 发现之后的目录允许列表。非空时只暴露这些 id;为空或省略时则暴露全部发现到的模型。 | +| `retainModels?` | `string[]` | 提供者级模型 ID 保留允许列表,当实时发现遗漏它们时仍予以保留(例如未开通账号或未列出模型)。未在此列出的配置模型在发现遗漏时继续被剪除(prune)。 | | `contextWindow?` | `number` | 上游缺少元数据时使用的提供者级上下文数值;有元数据时作为上限,保留更小的实时数值。Models 面板中与 `providerContextCaps` 分开设置。 | | `modelContextWindows?` | `Record` | 按模型设置的上下文数值与上限。优先于 `contextWindow`:窗口未知时采用所配置的数值,而更小的实时元数据仍然优先。 | | `modelInputModalities?` | `Record` | 按模型设置的输入提示,例如 `["text"]` 或 `["text", "image"]`。 | diff --git a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md index b0a46f49ecc..991dc084bd8 100644 --- a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md @@ -50,6 +50,7 @@ description: 供應商項目、認證、端點、模型目錄、配額、context | `models?` | `string[]` | 播種/後備模型清單。在 `liveModels: false` 時,這些是唯一探索的模型。 | | `liveModels?` | `boolean` | 在啟動/同步時擷取即時目錄(預設 `true`)。自訂供應商使用 `${baseUrl}/models`;內建可能使用 registry URL 並過濾。 | | `selectedModels?` | `string[]` | 探索後的目錄允許清單。非空時僅暴露那些 id;空或省略時暴露所有探索的模型。 | +| `retainModels?` | `string[]` | 當即時探索遺漏模型時仍保留在目錄中的允許清單(例如未開通帳號或未列出模型)。未在此列出的配置模型在探索遺漏時維持剪除(prune)。 | | `contextWindow?` | `number` | 供應商範圍的 Codex 可見 context 上限。較小的即時中繼資料被保留。 | | `modelContextWindows?` | `Record` | Per-model context 上限。這些覆寫 `contextWindow` 且永不提高較小的即時中繼資料。 | | `modelInputModalities?` | `Record` | Per-model 輸入提示,如 `["text"]` 或 `["text", "image"]`。 | diff --git a/src/codex/catalog/parsing.ts b/src/codex/catalog/parsing.ts index 94241e8a753..be3168e46ce 100644 --- a/src/codex/catalog/parsing.ts +++ b/src/codex/catalog/parsing.ts @@ -131,6 +131,8 @@ export interface CatalogModel { /** Optional provider-specific copy for the advertised Fast tier. */ fastTierDescription?: string; supportsReasoningSummaries?: boolean; + /** Whether this model was retained in the catalog via retainModels without live discovery confirmation. */ + retainedWithoutDiscovery?: boolean; /** * Codex tool calling mode for this routed model. * "code_mode_only" (default) sets entry.tool_mode = "code_mode_only". diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index 11131d539d4..79efc0a7c99 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -985,8 +985,8 @@ export function warnDroppedConfiguredIdsOnce(name: string, droppedConfiguredIds: * discovery did not report them. Used by dispatch error paths to explain a later * upstream 404 (model_not_found) instead of letting the operator blame the proxy. */ -export const retainedWithoutDiscoveryRefs = new Map>(); -export const warnedRetained404Refs = new Set(); +const retainedWithoutDiscoveryRefs = new Map>(); +const warnedRetained404Refs = new Set(); /** * Emit a one-shot warning when a model retained via `retainModels` is rejected by the @@ -1004,6 +1004,18 @@ export function warnRetainedModel404Once(providerName: string, modelId: string): ); } +/** Test-only helper: check whether a provider model is currently tracked as retained without discovery. */ +export function isRetainedModelWithoutDiscoveryForTests(providerName: string, modelId: string): boolean { + return retainedWithoutDiscoveryRefs.get(providerName)?.has(modelId) === true; +} + +/** Test-only helper: reset retained model warning states and reconciled generation. */ +export function resetRetainedModelWarningsForTests(): void { + retainedWithoutDiscoveryRefs.clear(); + warnedRetained404Refs.clear(); + lastWarningReconciledGeneration = 0; +} + /** * Z.AI and Neuralwatt advertise GLM reasoning as a bare boolean, which would otherwise * collapse to the four-tier default ladder that omits `max`. These two helpers name the @@ -1227,10 +1239,30 @@ async function fetchProviderModelsWithAuth( resolveAuth: ModelsAuthResolver, ): Promise { const { name, provider: prov, discovery, request } = captured; + function syncRetainedModelDiagnostics(models: readonly CatalogModel[]): void { + if (prov.liveModels === false || !Array.isArray(prov.retainModels) || prov.retainModels.length === 0) { + retainedWithoutDiscoveryRefs.delete(name); + return; + } + const retainSet = new Set(prov.retainModels); + const retainedIds = models + .filter(m => m.retainedWithoutDiscovery === true && retainSet.has(m.id)) + .map(m => m.id); + if (retainedIds.length > 0) { + retainedWithoutDiscoveryRefs.set(name, new Set(retainedIds)); + } else { + retainedWithoutDiscoveryRefs.delete(name); + } + } const observed = ( models: CatalogModel[], state: CatalogGatherProviderModelOutcome["state"], - ): ProviderModelsResult => ({ models, outcome: { provider: name, state } }); + ): ProviderModelsResult => { + if (isCurrentCacheGeneration()) { + syncRetainedModelDiagnostics(models); + } + return { models, outcome: { provider: name, state } }; + }; // Capture before any credential refresh or outbound await. OAuth account changes clear this // generation, so a request started with the former account cannot later publish its result. const cacheGeneration = captureModelCacheGeneration(name); @@ -1250,42 +1282,34 @@ async function fetchProviderModelsWithAuth( provider: name, ...catalogHintsFromProviderConfig(name, prov, id, contextCap), })); - const withConfiguredRetention = ( - models: CatalogModel[], - options?: { retainComboTargets?: boolean; warnDrops?: boolean; recordRetainedDiagnostics?: boolean }, - ): CatalogModel[] => { - const { models: merged, droppedConfiguredIds, retainedConfiguredIds } = mergeConfiguredModelsIntoLiveCatalog({ - name, - provider: prov, - models, - configured, - retainConfiguredModelIds: captured.retainConfiguredModelIds, - contextCap, - seedVertexDefault, - retainComboTargets: options?.retainComboTargets, - }); - if (options?.recordRetainedDiagnostics === true) { - if (retainedConfiguredIds.length > 0) { - retainedWithoutDiscoveryRefs.set(name, new Set(retainedConfiguredIds)); - } else { - retainedWithoutDiscoveryRefs.delete(name); - } + const withConfiguredRetention = ( + models: CatalogModel[], + options?: { retainComboTargets?: boolean; warnDrops?: boolean }, + ): CatalogModel[] => { + const { models: merged, droppedConfiguredIds } = mergeConfiguredModelsIntoLiveCatalog({ + name, + provider: prov, + models, + configured, + retainConfiguredModelIds: captured.retainConfiguredModelIds, + contextCap, + seedVertexDefault, + retainComboTargets: options?.retainComboTargets, + }); + if ( + options?.warnDrops === true + && droppedConfiguredIds.length > 0 + && name !== OPENAI_API_PROVIDER_ID + && !QUIET_AUTHORITATIVE_CATALOG_PROVIDERS.has(name) + ) { + warnDroppedConfiguredIdsOnce(name, droppedConfiguredIds); } - if ( - options?.warnDrops === true - && droppedConfiguredIds.length > 0 - && name !== OPENAI_API_PROVIDER_ID - && !QUIET_AUTHORITATIVE_CATALOG_PROVIDERS.has(name) - ) { - warnDroppedConfiguredIdsOnce(name, droppedConfiguredIds); - } - return merged; - }; + return merged; + }; // Static catalogs never need an OAuth refresh or an upstream model request. Clear any // discovery failure left by an older live configuration even when the account is logged out. if (prov.liveModels === false) { clearProviderDiscoveryStatus(name); - retainedWithoutDiscoveryRefs.delete(name); return observed(configured, "authoritative"); } const auth: ModelsAuthResolution = captured.observedAuth ?? (resolveAuth.kind === "refreshing" @@ -1354,7 +1378,7 @@ async function fetchProviderModelsWithAuth( const result = available.length > 0 ? available : configured; // Cache the discovery-filtered roster without combo retention so a later // gather can re-apply the current capture's retain set on read. - const forCache = withConfiguredRetention(result, { retainComboTargets: false, recordRetainedDiagnostics: true }); + const forCache = withConfiguredRetention(result, { retainComboTargets: false }); if (!setCached(name, forCache, Date.now(), cacheGeneration)) { return observed(withConfiguredRetention(configured), "degraded"); } @@ -1510,7 +1534,7 @@ async function fetchProviderModelsWithAuth( ...(model.contextWindow ? { contextWindow: model.contextWindow } : {}), ...(model.inputModalities ? { inputModalities: model.inputModalities } : {}), }, contextCap)); - const forCache = withConfiguredRetention(live, { retainComboTargets: false, recordRetainedDiagnostics: true }); + const forCache = withConfiguredRetention(live, { retainComboTargets: false }); if (!setCached(name, forCache, Date.now(), cacheGeneration)) { return observed(withConfiguredRetention(configured), "degraded"); } @@ -1554,7 +1578,7 @@ async function fetchProviderModelsWithAuth( // Dated-release aliases + configured retention (compat allow-list, combo targets, // Vertex default). Cache without combo retention so a later gather re-applies the // current capture's retain set on read (warm-cache OCX-111 / #1308). - const forCache = withConfiguredRetention(live, { retainComboTargets: false, recordRetainedDiagnostics: true }); + const forCache = withConfiguredRetention(live, { retainComboTargets: false }); const returned = withConfiguredRetention(live, { warnDrops: true }); const droppedConfiguredIds = configured .map(model => model.id) @@ -1663,9 +1687,10 @@ export function mergeConfiguredModelsIntoLiveCatalog(opts: { || (retainComboTargets && retainConfiguredModelIds?.has(candidate.id) === true) || (providerRetainModels?.has(candidate.id) === true) ) { - out.push(candidate); + const isRetainedFromConfig = providerRetainModels?.has(candidate.id) === true; + out.push(isRetainedFromConfig ? { ...candidate, retainedWithoutDiscovery: true } : candidate); present.add(candidate.id); - if (providerRetainModels?.has(candidate.id) === true) retainedConfiguredIds.push(candidate.id); + if (isRetainedFromConfig) retainedConfiguredIds.push(candidate.id); continue; } droppedConfiguredIds.push(candidate.id); diff --git a/tests/provider-retain-models.test.ts b/tests/provider-retain-models.test.ts index 31fad20e6b6..5ccfdb1fa01 100644 --- a/tests/provider-retain-models.test.ts +++ b/tests/provider-retain-models.test.ts @@ -5,12 +5,13 @@ import { join } from "node:path"; import { installIsolatedCodexHome } from "./helpers/isolated-codex-home"; import { filterCatalogVisibleModels, + isRetainedModelWithoutDiscoveryForTests, mergeConfiguredModelsIntoLiveCatalog, reconcileProviderFetchWarnings, + resetRetainedModelWarningsForTests, warnRetainedModel404Once, - retainedWithoutDiscoveryRefs, - warnedRetained404Refs, } from "../src/codex/catalog/provider-fetch"; +import { clearModelCache } from "../src/codex/model-cache"; import { nonBlankStringArrayConfigError } from "../src/config"; import { saveConfig } from "../src/config"; import { startServer } from "../src/server"; @@ -40,6 +41,8 @@ describe("#1690 retainModels provider configuration", () => { expect(models.map(m => m.id)).toEqual(["gemini-3.5-flash", "gemini-3.7-flash"]); expect(droppedConfiguredIds).toEqual(["unrelated-model"]); + expect(models.find(m => m.id === "gemini-3.7-flash")?.retainedWithoutDiscovery).toBe(true); + expect(models.find(m => m.id === "gemini-3.5-flash")?.retainedWithoutDiscovery).toBeUndefined(); }); test("drops unlisted models when retainModels is empty", () => { @@ -99,6 +102,7 @@ describe("#1690 retainModels provider configuration", () => { expect(models.map(m => m.id)).toEqual(["gemini-3.7-flash"]); expect(droppedConfiguredIds).toEqual([]); + expect(models[0]!.retainedWithoutDiscovery).toBeUndefined(); }); test("retains multiple specified models across an empty live discovery", () => { @@ -125,7 +129,7 @@ describe("#1690 retainModels provider configuration", () => { expect(droppedConfiguredIds).toEqual(["dropped-model"]); }); - test("reports retainedConfiguredIds only for retainModels-kept models omitted by live discovery", () => { + test("reports retainedConfiguredIds and stamps retainedWithoutDiscovery", () => { const prov: OcxProviderConfig = { adapter: "google", baseUrl: "https://daily-cloudcode-pa.googleapis.com", @@ -201,9 +205,10 @@ describe("#1690 retainModels provider configuration", () => { }); expect(visible.map(m => m.id)).toEqual(["gemini-3.7-flash"]); }); - test("warnRetainedModel404Once warns on first 404, suppresses on second, and resets after reconcileProviderFetchWarnings", () => { + + test("warnRetainedModel404Once warns on first 404, suppresses on second, and resets on new generation", () => { + resetRetainedModelWarningsForTests(); reconcileProviderFetchWarnings(1); - retainedWithoutDiscoveryRefs.set("test-prov", new Set(["gemini-3.7-flash"])); const warnCalls: string[] = []; const originalWarn = console.warn; @@ -212,126 +217,115 @@ describe("#1690 retainModels provider configuration", () => { }; try { - // First 404 should emit warning - warnRetainedModel404Once("test-prov", "gemini-3.7-flash"); - expect(warnCalls.length).toBe(1); - expect(warnCalls[0]).toContain('Model "gemini-3.7-flash" on provider "test-prov" is retained via retainModels'); - - // Second 404 for same provider/model should be suppressed - warnRetainedModel404Once("test-prov", "gemini-3.7-flash"); - expect(warnCalls.length).toBe(1); - - // Model not in retainedWithoutDiscoveryRefs should NOT warn - warnRetainedModel404Once("test-prov", "other-model"); - expect(warnCalls.length).toBe(1); - - // Advance generation via reconcileProviderFetchWarnings - reconcileProviderFetchWarnings(2); - expect(retainedWithoutDiscoveryRefs.size).toBe(0); - expect(warnedRetained404Refs.size).toBe(0); - - // Re-populate and verify warning can fire again in new generation - retainedWithoutDiscoveryRefs.set("test-prov", new Set(["gemini-3.7-flash"])); - warnRetainedModel404Once("test-prov", "gemini-3.7-flash"); - expect(warnCalls.length).toBe(2); + // Model not in retained refs does not warn + warnRetainedModel404Once("test-prov", "unknown-model"); + expect(warnCalls.length).toBe(0); } finally { console.warn = originalWarn; } }); - - test("multi-pass retention (live -> forCache -> returned) preserves retainedWithoutDiscoveryRefs", () => { - reconcileProviderFetchWarnings(10); - const prov: OcxProviderConfig = { - adapter: "google", - baseUrl: "https://daily-cloudcode-pa.googleapis.com", - retainModels: ["gemini-3.7-flash"], - }; - const live = [model("gemini-3.5-flash")]; - const configured = [model("gemini-3.7-flash")]; - - // Pass 1: live evaluation with recordRetainedDiagnostics: true - const { models: forCache, retainedConfiguredIds: pass1Retained } = mergeConfiguredModelsIntoLiveCatalog({ - name: "google-antigravity", - provider: prov, - models: live, - configured, - }); - expect(pass1Retained).toEqual(["gemini-3.7-flash"]); - retainedWithoutDiscoveryRefs.set("google-antigravity", new Set(pass1Retained)); - - // Pass 2: returned / cached evaluation on forCache - const { models: returned, retainedConfiguredIds: pass2Retained } = mergeConfiguredModelsIntoLiveCatalog({ - name: "google-antigravity", - provider: prov, - models: forCache, - configured, - }); - expect(pass2Retained).toEqual([]); - // retainedWithoutDiscoveryRefs must still have the model - expect(retainedWithoutDiscoveryRefs.get("google-antigravity")?.has("gemini-3.7-flash")).toBe(true); - }); - }); -describe("server 404 diagnostics for retained models", () => { - test("/v1/chat/completions triggers warnRetainedModel404Once on upstream 404 even without code: model_not_found", async () => { - const isolated = installIsolatedCodexHome("ocx-retain-chat-"); - const testDir = mkdtempSync(join(tmpdir(), "ocx-retain-chat-")); +describe("catalog lifecycle and server 404 diagnostics for retained models", () => { + test("warm-cache gather restores provenance after generation reconcile and permits 404 warning", async () => { + const isolated = installIsolatedCodexHome("ocx-retain-warm-"); + const testDir = mkdtempSync(join(tmpdir(), "ocx-retain-warm-")); const prevHome = process.env.OPENCODEX_HOME; process.env.OPENCODEX_HOME = testDir; - reconcileProviderFetchWarnings(20); - retainedWithoutDiscoveryRefs.set("test-chat-prov", new Set(["retained-chat-model"])); - + resetRetainedModelWarningsForTests(); const warnCalls: string[] = []; const originalWarn = console.warn; console.warn = (...args: any[]) => { warnCalls.push(args.join(" ")); }; + let upstreamCalls = 0; const upstream = Bun.serve({ port: 0, - fetch() { + fetch(req) { + const url = new URL(req.url); + if (url.pathname.includes("/models")) { + upstreamCalls += 1; + return Response.json({ + data: [{ id: "claude-live-1", owned_by: "anthropic" }], + }); + } return Response.json({ - error: { - message: "Model does not exist", - type: "invalid_request_error", - }, + type: "error", + error: { type: "not_found_error", message: "model: claude-retained" }, }, { status: 404 }); }, }); saveConfig({ port: 0, - defaultProvider: "test-chat-prov", + defaultProvider: "test-warm-prov", providers: { - "test-chat-prov": { - adapter: "openai-chat", - baseUrl: `http://127.0.0.1:${upstream.port}/v1`, + "test-warm-prov": { + adapter: "anthropic", + baseUrl: `http://127.0.0.1:${upstream.port}`, apiKey: "test-key", allowPrivateNetwork: true, - retainModels: ["retained-chat-model"], + retainModels: ["claude-retained"], }, }, } as OcxConfig); const server = startServer(0); try { - const response = await fetch(new URL("/v1/chat/completions", server.url), { + // 1. Initial live gather populates cache with claude-live-1 and retained claude-retained + const catRes1 = await fetch(new URL("/v1/models", server.url), { + headers: { authorization: "Bearer test-caller-token" }, + }); + expect(catRes1.status).toBe(200); + const cat1 = (await catRes1.json()) as { data: Array<{ id: string }> }; + expect(cat1.data.map(m => m.id)).toEqual(["test-warm-prov/claude-live-1", "test-warm-prov/claude-retained"]); + expect(upstreamCalls).toBe(1); + expect(isRetainedModelWithoutDiscoveryForTests("test-warm-prov", "claude-retained")).toBe(true); + + // 2. Generation reconcile clears in-memory maps + reconcileProviderFetchWarnings(100); + expect(isRetainedModelWithoutDiscoveryForTests("test-warm-prov", "claude-retained")).toBe(false); + + // 3. Second gather within TTL hits fresh cache (no upstream /models request) + const catRes2 = await fetch(new URL("/v1/models", server.url), { + headers: { authorization: "Bearer test-caller-token" }, + }); + expect(catRes2.status).toBe(200); + expect(upstreamCalls).toBe(1); // No new upstream discovery request + // Provenance MUST be restored from cached metadata! + expect(isRetainedModelWithoutDiscoveryForTests("test-warm-prov", "claude-retained")).toBe(true); + + // 4. Request for retained model fails with 404 upstream -> emits exactly one warning + const resp404 = await fetch(new URL("/v1/responses", server.url), { method: "POST", headers: { "content-type": "application/json", authorization: "Bearer test-caller-token", }, body: JSON.stringify({ - model: "retained-chat-model", - stream: false, - messages: [{ role: "user", content: "hello" }], + model: "claude-retained", + input: [{ type: "message", role: "user", content: "hello" }], + }), + }); + expect(resp404.status).toBe(404); + expect(warnCalls.length).toBe(1); + expect(warnCalls[0]).toContain('Model "claude-retained" on provider "test-warm-prov" is retained via retainModels'); + + // 5. Second 404 is suppressed + await fetch(new URL("/v1/responses", server.url), { + method: "POST", + headers: { + "content-type": "application/json", + authorization: "Bearer test-caller-token", + }, + body: JSON.stringify({ + model: "claude-retained", + input: [{ type: "message", role: "user", content: "hello" }], }), }); - expect(response.status).toBe(404); expect(warnCalls.length).toBe(1); - expect(warnCalls[0]).toContain('Model "retained-chat-model" on provider "test-chat-prov" is retained via retainModels'); } finally { await server.stop(true); upstream.stop(true); @@ -343,15 +337,13 @@ describe("server 404 diagnostics for retained models", () => { } }); - test("non-passthrough responses error handling triggers warnRetainedModel404Once on 404", async () => { - const isolated = installIsolatedCodexHome("ocx-retain-resp-"); - const testDir = mkdtempSync(join(tmpdir(), "ocx-retain-resp-")); + test("/v1/chat/completions triggers warnRetainedModel404Once on plain 404 and model_not_found", async () => { + const isolated = installIsolatedCodexHome("ocx-retain-chat-"); + const testDir = mkdtempSync(join(tmpdir(), "ocx-retain-chat-")); const prevHome = process.env.OPENCODEX_HOME; process.env.OPENCODEX_HOME = testDir; - reconcileProviderFetchWarnings(30); - retainedWithoutDiscoveryRefs.set("test-anthropic-prov", new Set(["claude-retained"])); - + resetRetainedModelWarningsForTests(); const warnCalls: string[] = []; const originalWarn = console.warn; console.warn = (...args: any[]) => { @@ -360,47 +352,53 @@ describe("server 404 diagnostics for retained models", () => { const upstream = Bun.serve({ port: 0, - fetch() { + fetch(req) { + const url = new URL(req.url); + if (url.pathname.includes("/models")) { + return Response.json({ data: [] }); + } return Response.json({ - type: "error", - error: { - type: "not_found_error", - message: "model: claude-retained", - }, + error: { message: "Model does not exist", type: "invalid_request_error" }, }, { status: 404 }); }, }); saveConfig({ port: 0, - defaultProvider: "test-anthropic-prov", + defaultProvider: "test-chat-prov", providers: { - "test-anthropic-prov": { - adapter: "anthropic", - baseUrl: `http://127.0.0.1:${upstream.port}`, + "test-chat-prov": { + adapter: "openai-chat", + baseUrl: `http://127.0.0.1:${upstream.port}/v1`, apiKey: "test-key", allowPrivateNetwork: true, - retainModels: ["claude-retained"], + retainModels: ["retained-chat-model"], }, }, } as OcxConfig); const server = startServer(0); try { - const response = await fetch(new URL("/v1/responses", server.url), { + // Populate models + await fetch(new URL("/v1/models", server.url), { + headers: { authorization: "Bearer test-caller-token" }, + }); + + const response = await fetch(new URL("/v1/chat/completions", server.url), { method: "POST", headers: { "content-type": "application/json", authorization: "Bearer test-caller-token", }, body: JSON.stringify({ - model: "claude-retained", - input: [{ type: "message", role: "user", content: "hello" }], + model: "retained-chat-model", + stream: false, + messages: [{ role: "user", content: "hello" }], }), }); expect(response.status).toBe(404); expect(warnCalls.length).toBe(1); - expect(warnCalls[0]).toContain('Model "claude-retained" on provider "test-anthropic-prov" is retained via retainModels'); + expect(warnCalls[0]).toContain('Model "retained-chat-model" on provider "test-chat-prov" is retained via retainModels'); } finally { await server.stop(true); upstream.stop(true); @@ -411,4 +409,45 @@ describe("server 404 diagnostics for retained models", () => { rmSync(testDir, { recursive: true, force: true }); } }); + + test("delayed stale writer from prior generation cannot install stale diagnostic state", async () => { + const { fetchProviderModels } = await import("../src/codex/catalog/provider-fetch"); + resetRetainedModelWarningsForTests(); + clearModelCache(); + + let resolveDelayedDiscovery: (res: Response) => void; + const delayedPromise = new Promise(resolve => { + resolveDelayedDiscovery = resolve; + }); + + const provGen1: OcxProviderConfig = { + adapter: "openai-chat", + baseUrl: "https://api.example.com/v1", + apiKey: "test-key", + retainModels: ["retained-gen1"], + fetch: (async (url: RequestInfo | URL) => { + if (String(url).includes("/models")) { + return delayedPromise; + } + return new Response(JSON.stringify({ data: [] }), { status: 200, headers: { "content-type": "application/json" } }); + }) as typeof fetch, + }; + + // 1. Start live gather for Gen 1 (hangs on delayedPromise) + const inFlightGather = fetchProviderModels("test-stale-prov", provGen1, 60_000); + + // 2. Cache is cleared / generation bumped (e.g. config changed to Gen 2 without retainModels) + clearModelCache("test-stale-prov"); + reconcileProviderFetchWarnings(200); + + // 3. Resolve delayed discovery from Gen 1 + resolveDelayedDiscovery!(new Response(JSON.stringify({ data: [{ id: "live-gen1" }] }), { + status: 200, + headers: { "content-type": "application/json" }, + })); + await inFlightGather; + + // Stale writer was rejected by setCached, so retainedWithoutDiscoveryRefs must not have retained-gen1! + expect(isRetainedModelWithoutDiscoveryForTests("test-stale-prov", "retained-gen1")).toBe(false); + }); }); From b6ddd6f770ef6e7af96ae09026bdf2d5012bdd78 Mon Sep 17 00:00:00 2001 From: chilung Date: Sat, 22 Aug 2026 12:02:54 +0000 Subject: [PATCH 11/13] fix(catalog): initialize cache generation before observed callback and tighten retainModels types (#1690) --- src/codex/catalog/provider-fetch.ts | 8 ++++---- src/types/provider.ts | 4 ++-- tests/provider-retain-models.test.ts | 8 +++++--- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index 79efc0a7c99..b724ccb0f76 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -1239,6 +1239,10 @@ async function fetchProviderModelsWithAuth( resolveAuth: ModelsAuthResolver, ): Promise { const { name, provider: prov, discovery, request } = captured; + // Capture before any credential refresh or outbound await. OAuth account changes clear this + // generation, so a request started with the former account cannot later publish its result. + const cacheGeneration = captureModelCacheGeneration(name); + const isCurrentCacheGeneration = () => isModelCacheGenerationCurrent(name, cacheGeneration); function syncRetainedModelDiagnostics(models: readonly CatalogModel[]): void { if (prov.liveModels === false || !Array.isArray(prov.retainModels) || prov.retainModels.length === 0) { retainedWithoutDiscoveryRefs.delete(name); @@ -1263,10 +1267,6 @@ async function fetchProviderModelsWithAuth( } return { models, outcome: { provider: name, state } }; }; - // Capture before any credential refresh or outbound await. OAuth account changes clear this - // generation, so a request started with the former account cannot later publish its result. - const cacheGeneration = captureModelCacheGeneration(name); - const isCurrentCacheGeneration = () => isModelCacheGenerationCurrent(name, cacheGeneration); if (prov.authMode === "forward") return observed([], "authoritative"); // ChatGPT backend has no /models const seedVertexDefault = prov.adapter === "google" && prov.googleMode === "vertex" diff --git a/src/types/provider.ts b/src/types/provider.ts index 6fb56d37946..ecd36554744 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -309,9 +309,9 @@ export interface OcxProviderConfig { fallback?: "preset-empty"; }; /** - * Models that should be retained in the catalog even if live discovery omits them. + * Models that should be retained in the catalog when configured in `models` even if live discovery omits them. * Useful when an upstream account has unlisted or upcoming models (e.g. unprovisioned - * Antigravity models or regional previews). + * Antigravity models or regional previews). Processed by mergeConfiguredModelsIntoLiveCatalog. */ retainModels?: string[]; /** Provider-wide fallback when context metadata is absent; otherwise caps the reported window. */ diff --git a/tests/provider-retain-models.test.ts b/tests/provider-retain-models.test.ts index 5ccfdb1fa01..33f3f18afa3 100644 --- a/tests/provider-retain-models.test.ts +++ b/tests/provider-retain-models.test.ts @@ -206,14 +206,15 @@ describe("#1690 retainModels provider configuration", () => { expect(visible.map(m => m.id)).toEqual(["gemini-3.7-flash"]); }); - test("warnRetainedModel404Once warns on first 404, suppresses on second, and resets on new generation", () => { + test("warnRetainedModel404Once does not warn for non-retained models", () => { resetRetainedModelWarningsForTests(); reconcileProviderFetchWarnings(1); const warnCalls: string[] = []; const originalWarn = console.warn; console.warn = (...args: any[]) => { - warnCalls.push(args.join(" ")); + const msg = args.join(" "); + if (msg.includes("retained via retainModels")) warnCalls.push(msg); }; try { @@ -237,7 +238,8 @@ describe("catalog lifecycle and server 404 diagnostics for retained models", () const warnCalls: string[] = []; const originalWarn = console.warn; console.warn = (...args: any[]) => { - warnCalls.push(args.join(" ")); + const msg = args.join(" "); + if (msg.includes("retained via retainModels")) warnCalls.push(msg); }; let upstreamCalls = 0; From ba4d4fb89a22ee7815f787f73ef3971f2139fcb3 Mon Sep 17 00:00:00 2001 From: chilung Date: Sun, 23 Aug 2026 10:37:26 +0000 Subject: [PATCH 12/13] fix(catalog): address review feedback on retainModels state preservation and diagnostics (#1690) --- src/codex/catalog/provider-fetch.ts | 8 +- src/types/provider.ts | 4 +- tests/provider-retain-models.test.ts | 133 ++++++++++++++++++++++++++- 3 files changed, 139 insertions(+), 6 deletions(-) diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index b724ccb0f76..f218cf14d4d 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -1243,7 +1243,11 @@ async function fetchProviderModelsWithAuth( // generation, so a request started with the former account cannot later publish its result. const cacheGeneration = captureModelCacheGeneration(name); const isCurrentCacheGeneration = () => isModelCacheGenerationCurrent(name, cacheGeneration); - function syncRetainedModelDiagnostics(models: readonly CatalogModel[]): void { + function syncRetainedModelDiagnostics( + models: readonly CatalogModel[], + state: CatalogGatherProviderModelOutcome["state"], + ): void { + if (state !== "authoritative") return; if (prov.liveModels === false || !Array.isArray(prov.retainModels) || prov.retainModels.length === 0) { retainedWithoutDiscoveryRefs.delete(name); return; @@ -1263,7 +1267,7 @@ async function fetchProviderModelsWithAuth( state: CatalogGatherProviderModelOutcome["state"], ): ProviderModelsResult => { if (isCurrentCacheGeneration()) { - syncRetainedModelDiagnostics(models); + syncRetainedModelDiagnostics(models, state); } return { models, outcome: { provider: name, state } }; }; diff --git a/src/types/provider.ts b/src/types/provider.ts index ecd36554744..400279b1896 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -310,8 +310,10 @@ export interface OcxProviderConfig { }; /** * Models that should be retained in the catalog when configured in `models` even if live discovery omits them. + * Applies to configured model IDs processed by `mergeConfiguredModelsIntoLiveCatalog`, and does not + * independently seed catalog entries for IDs listed only in `retainModels`. * Useful when an upstream account has unlisted or upcoming models (e.g. unprovisioned - * Antigravity models or regional previews). Processed by mergeConfiguredModelsIntoLiveCatalog. + * Antigravity models or regional previews). */ retainModels?: string[]; /** Provider-wide fallback when context metadata is absent; otherwise caps the reported window. */ diff --git a/tests/provider-retain-models.test.ts b/tests/provider-retain-models.test.ts index 33f3f18afa3..fbf130e7841 100644 --- a/tests/provider-retain-models.test.ts +++ b/tests/provider-retain-models.test.ts @@ -206,7 +206,7 @@ describe("#1690 retainModels provider configuration", () => { expect(visible.map(m => m.id)).toEqual(["gemini-3.7-flash"]); }); - test("warnRetainedModel404Once does not warn for non-retained models", () => { + test("warnRetainedModel404Once stays silent for a model that was never retained", () => { resetRetainedModelWarningsForTests(); reconcileProviderFetchWarnings(1); @@ -351,6 +351,7 @@ describe("catalog lifecycle and server 404 diagnostics for retained models", () console.warn = (...args: any[]) => { warnCalls.push(args.join(" ")); }; + const retainedWarnings = () => warnCalls.filter(line => line.includes("is retained via retainModels")); const upstream = Bun.serve({ port: 0, @@ -399,8 +400,8 @@ describe("catalog lifecycle and server 404 diagnostics for retained models", () }), }); expect(response.status).toBe(404); - expect(warnCalls.length).toBe(1); - expect(warnCalls[0]).toContain('Model "retained-chat-model" on provider "test-chat-prov" is retained via retainModels'); + expect(retainedWarnings()).toHaveLength(1); + expect(retainedWarnings()[0]).toContain('Model "retained-chat-model" on provider "test-chat-prov"'); } finally { await server.stop(true); upstream.stop(true); @@ -452,4 +453,130 @@ describe("catalog lifecycle and server 404 diagnostics for retained models", () // Stale writer was rejected by setCached, so retainedWithoutDiscoveryRefs must not have retained-gen1! expect(isRetainedModelWithoutDiscoveryForTests("test-stale-prov", "retained-gen1")).toBe(false); }); + + test("degraded catalog gather preserves prior retained model provenance", async () => { + const { fetchProviderModels } = await import("../src/codex/catalog/provider-fetch"); + resetRetainedModelWarningsForTests(); + clearModelCache(); + + let failDiscovery = false; + const prov: OcxProviderConfig = { + adapter: "openai-chat", + baseUrl: "https://api.example.com/v1", + apiKey: "test-key", + retainModels: ["retained-model"], + fetch: (async (url: RequestInfo | URL) => { + if (String(url).includes("/models")) { + if (failDiscovery) { + return new Response("Internal Server Error", { status: 500 }); + } + return new Response(JSON.stringify({ data: [{ id: "live-model" }] }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + return new Response("{}", { status: 200 }); + }) as typeof fetch, + }; + + // 1. Authoritative gather registers retained-model provenance + const firstModels = await fetchProviderModels("test-degrade-prov", prov, 10); + expect(firstModels.map(m => m.id)).toEqual(["live-model", "retained-model"]); + expect(isRetainedModelWithoutDiscoveryForTests("test-degrade-prov", "retained-model")).toBe(true); + + // 2. Degraded gather (upstream 500) must NOT erase retained model provenance + failDiscovery = true; + await new Promise(resolve => setTimeout(resolve, 20)); + const secondModels = await fetchProviderModels("test-degrade-prov", prov, 10); + expect(secondModels.map(m => m.id)).toEqual(["live-model", "retained-model"]); + expect(isRetainedModelWithoutDiscoveryForTests("test-degrade-prov", "retained-model")).toBe(true); + }); + + test("Cursor and Antigravity retain configured combo targets in returned results but exclude from forCache", async () => { + const { gatherRoutedModels, clearGatherRoutedModelsInflight } = await import("../src/codex/catalog/provider-fetch"); + const { getStaleCached } = await import("../src/codex/model-cache"); + const { setFetchCursorUsableModelsForTests } = await import("../src/adapters/cursor/live-models"); + const { withStubbedProviderFetch } = await import("./helpers/catalog-provider-fetch"); + const { resetCatalogRuntimeStateForTests } = await import("../src/codex/catalog/sync"); + resetRetainedModelWarningsForTests(); + resetCatalogRuntimeStateForTests(); + clearGatherRoutedModelsInflight(); + + const warnCalls: string[] = []; + const originalWarn = console.warn; + console.warn = (...args: any[]) => { + warnCalls.push(args.join(" ")); + }; + + const prevFetch = globalThis.fetch; + setFetchCursorUsableModelsForTests(async () => ({ + ok: true, + models: ["gpt-5.5"], + })); + globalThis.fetch = (async (url: RequestInfo | URL) => { + if (String(url).includes("fetchAvailableModels")) { + return new Response(JSON.stringify({ + models: { "gemini-3.7-flash": { maxTokens: 1_048_576 } }, + agentModelSorts: [{ groups: [{ modelIds: ["gemini-3.7-flash"] }] }], + }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + return new Response("{}", { status: 200 }); + }) as typeof fetch; + + try { + const config = { + providers: { + "cursor-test": { + adapter: "cursor" as const, + baseUrl: "https://api.cursor.test", + apiKey: "test-key", + models: ["gpt-5.5", "retained-combo-cursor", "configured-ghost-cursor"], + }, + "ag-test": { + adapter: "google" as const, + baseUrl: "https://daily-cloudcode-pa.googleapis.com", + apiKey: "test-key", + project: "test-project", + googleMode: "cloud-code-assist" as const, + models: ["gemini-3.7-flash", "retained-combo-ag", "configured-ghost-ag"], + }, + }, + combos: { + "combo-cursor": { + strategy: "failover" as const, + targets: [{ provider: "cursor-test", model: "retained-combo-cursor" }], + }, + "combo-ag": { + strategy: "failover" as const, + targets: [{ provider: "ag-test", model: "retained-combo-ag" }], + }, + }, + }; + + const gathered = await gatherRoutedModels(withStubbedProviderFetch(config) as any); + const gatheredIds = gathered.map(m => m.id); + expect(gatheredIds).toContain("retained-combo-cursor"); + expect(gatheredIds).toContain("retained-combo-ag"); + expect(gatheredIds).toContain("gpt-5.5"); + expect(gatheredIds).toContain("gemini-3.7-flash"); + expect(gatheredIds).not.toContain("configured-ghost-cursor"); + expect(gatheredIds).not.toContain("configured-ghost-ag"); + + // Cache excludes combo targets + expect(getStaleCached("cursor-test")?.map(m => m.id) ?? []).toEqual(["gpt-5.5"]); + expect(getStaleCached("ag-test")?.map(m => m.id) ?? []).toEqual(["gemini-3.7-flash"]); + + // Drop warnings emitted for configured ghosts + const droppedWarnings = warnCalls.filter(msg => msg.includes("omitted configured model ids")); + expect(droppedWarnings.some(msg => msg.includes("configured-ghost-cursor"))).toBe(true); + expect(droppedWarnings.some(msg => msg.includes("configured-ghost-ag"))).toBe(true); + } finally { + setFetchCursorUsableModelsForTests(null); + globalThis.fetch = prevFetch; + console.warn = originalWarn; + } + }); }); From 324d632a38d5599abf2fb79b626d417de665e9cb Mon Sep 17 00:00:00 2001 From: chilung Date: Tue, 25 Aug 2026 05:07:18 +0000 Subject: [PATCH 13/13] docs(types): align retainModels JSDoc with independent seeding contract (#1690) --- src/types/provider.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/types/provider.ts b/src/types/provider.ts index 400279b1896..b4a4da25682 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -309,9 +309,9 @@ export interface OcxProviderConfig { fallback?: "preset-empty"; }; /** - * Models that should be retained in the catalog when configured in `models` even if live discovery omits them. - * Applies to configured model IDs processed by `mergeConfiguredModelsIntoLiveCatalog`, and does not - * independently seed catalog entries for IDs listed only in `retainModels`. + * Model IDs to retain in the authoritative live catalog even when upstream discovery omits them. + * IDs listed here are independently seeded as configured entries and kept through + * `mergeConfiguredModelsIntoLiveCatalog`; they do not need to also appear in `models`. * Useful when an upstream account has unlisted or upcoming models (e.g. unprovisioned * Antigravity models or regional previews). */