From 3b457aa5c195df02551ddf2fd328d36d8de5f594 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 10 Sep 2026 07:27:02 +0900 Subject: [PATCH 1/2] fix(models): explain the discovery dependency when model sync fails A provider whose live fetch fails gets an amber "Discovery failed" badge on its group header and nothing else. EmptyProviderHint already carries the guidance and the link into provider settings, but it only renders when the group has NO rows, so the reporter -- who had added a Gemini model by hand -- saw a failure badge next to a model that would not work, with nothing connecting the two. The mechanism they eventually found on their own is that discovery being ON is what holds those rows back. A newly added key provider is stamped initialModelSelection.status = "pending"; failed discovery is degraded, so initialization never finalizes; pending rows are then forced disabled and dropped from the Codex catalog. Turning "Discover models from provider" off makes the seed authoritative and releases them. DiscoveryDependencyHint renders that sentence for a failed group that has rows, alongside the same settings link. The control's name is interpolated from pws.liveModels rather than restated, so the copy names the real switch in all nine locales and cannot drift from its label. The link uses the existing navigateHash("providers"): hashBelongsToPage has no providers/ arm and rewrites providers/workspace to providers, so an invented per-provider deep link would be normalised away. Copy and CTA only. Flipping registry google to liveModels: false would change seed behaviour for every Gemini install, and treating degraded discovery plus configured rows as authoritative enough to finalize initialModelSelection contradicts tests/providers/initial-model-selection.test.ts:278-285. Both are maintainer policy rather than copy; the second is what would make a manually added model usable without turning discovery off, and belongs in its own issue. Closes #4075. --- gui/src/i18n/de.ts | 1 + gui/src/i18n/en.ts | 1 + gui/src/i18n/fr.ts | 1 + gui/src/i18n/ja.ts | 1 + gui/src/i18n/ko.ts | 1 + gui/src/i18n/ru.ts | 1 + gui/src/i18n/tr.ts | 1 + gui/src/i18n/zh-TW.ts | 1 + gui/src/i18n/zh.ts | 1 + gui/src/pages/Models.tsx | 6 +- gui/src/pages/models-provider-hints.tsx | 29 ++++ .../models-discovery-failed-hint.test.tsx | 162 ++++++++++++++++++ 12 files changed, 205 insertions(+), 1 deletion(-) create mode 100644 gui/tests/models-discovery-failed-hint.test.tsx diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 3b45878d574..f402a82b8a9 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -647,6 +647,7 @@ export const de: Record = { "models.discoveryFailedProvider": "Der Anbieter meldete einen Fehler bei der Modellerkennung.", "models.discoveryFailedGeneric": "Die Modellerkennung ist fehlgeschlagen.", "models.openProviderSettings": "Anbietereinstellungen öffnen", + "models.discoveryFailedDependency": "Die Modellsuche ist für diesen Anbieter aktiv. Solange sie fehlschlägt, schalte „{control}“ in den Anbietereinstellungen aus, um stattdessen manuell hinzugefügte oder statische Modelle zu nutzen.", "models.loading": "Lädt…", "models.search": "Modelle suchen…", "models.showMore": "{n} weitere anzeigen", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 00bfdf3a0f2..3ace3dfa6ed 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -676,6 +676,7 @@ export const en = { "models.discoveryFailedProvider": "The provider reported a model discovery error.", "models.discoveryFailedGeneric": "Model discovery failed.", "models.openProviderSettings": "Open provider settings", + "models.discoveryFailedDependency": "Model discovery is on for this provider. While it keeps failing, turn off “{control}” in provider settings to use manually added or static models instead.", "models.loading": "Loading…", "models.search": "Search models…", "models.showMore": "Show {n} more", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 64005501c8b..3f60f764e34 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -661,6 +661,7 @@ export const fr: Record = { "models.discoveryFailedProvider": "Le fournisseur a signalé une erreur de détection des modèles.", "models.discoveryFailedGeneric": "Échec de la détection des modèles.", "models.openProviderSettings": "Ouvrir les paramètres du fournisseur", + "models.discoveryFailedDependency": "La découverte de modèles est activée pour ce fournisseur. Tant qu’elle échoue, désactivez « {control} » dans les paramètres du fournisseur pour utiliser à la place les modèles ajoutés manuellement ou statiques.", "models.loading": "Chargement…", "models.search": "Rechercher des modèles…", "models.showMore": "Afficher {n} de plus", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 729ecdbcdec..12f03edff9d 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -605,6 +605,7 @@ export const ja: Record = { "models.discoveryFailedProvider": "プロバイダーがモデル検出エラーを報告しました。", "models.discoveryFailedGeneric": "モデル検出に失敗しました。", "models.openProviderSettings": "プロバイダー設定を開く", + "models.discoveryFailedDependency": "このプロバイダーではモデル検出が有効です。失敗が続く間は、プロバイダー設定で「{control}」をオフにすると、手動で追加したモデルや静的モデルを使えます。", "models.loading": "読み込み中…", "models.search": "モデルを検索…", "models.showMore": "さらに {n} 件表示", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index f8e500f7470..e9f741973c1 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -658,6 +658,7 @@ export const ko: Record = { "models.discoveryFailedProvider": "프로바이더가 모델 검색 오류를 보고했습니다.", "models.discoveryFailedGeneric": "모델 검색에 실패했습니다.", "models.openProviderSettings": "프로바이더 설정 열기", + "models.discoveryFailedDependency": "이 프로바이더는 모델 검색이 켜져 있어요. 계속 실패한다면 프로바이더 설정에서 ‘{control}’을 꺼야 직접 추가한 모델이나 정적 모델을 쓸 수 있어요.", "models.loading": "불러오는 중…", "models.search": "모델 검색…", "models.showMore": "{n}개 더 보기", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index fe654314a11..ac2375db534 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -660,6 +660,7 @@ export const ru: Record = { "models.discoveryFailedProvider": "Провайдер сообщил об ошибке обнаружения моделей.", "models.discoveryFailedGeneric": "Не удалось обнаружить модели.", "models.openProviderSettings": "Открыть настройки провайдера", + "models.discoveryFailedDependency": "Обнаружение моделей включено для этого провайдера. Пока оно не работает, отключите «{control}» в настройках провайдера, чтобы использовать вручную добавленные или статические модели.", "models.loading": "Загрузка…", "models.search": "Поиск моделей…", "models.showMore": "Показать ещё {n}", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index d6d37d1469b..d26ab6195db 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -663,6 +663,7 @@ export const tr: Record = { "models.discoveryFailedProvider": "Sağlayıcı bir model keşfi hatası bildirdi.", "models.discoveryFailedGeneric": "Model keşfi başarısız oldu.", "models.openProviderSettings": "Sağlayıcı ayarlarını aç", + "models.discoveryFailedDependency": "Bu sağlayıcı için model keşfi açık. Başarısız olmaya devam ettiği sürece, elle eklenen veya statik modelleri kullanmak için sağlayıcı ayarlarından „{control}“ seçeneğini kapatın.", "models.loading": "Yükleniyor…", "models.search": "Modellerde ara…", "models.showMore": "{n} tane daha göster", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index c1675c4a246..55bd911dec7 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -524,6 +524,7 @@ export const zhTW: Record = { "models.discoveryFailedProvider": "供應商報告了模型發現錯誤。", "models.discoveryFailedGeneric": "模型發現失敗。", "models.openProviderSettings": "開啟供應商設定", + "models.discoveryFailedDependency": "此供應商已開啟模型探索。若持續失敗,可在供應商設定中關閉「{control}」,改用手動新增的模型或靜態模型。", "models.loading": "載入中…", "models.search": "搜尋模型…", "models.showMore": "再顯示 {n} 個", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 06dceef46fc..1366aa03a17 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -655,6 +655,7 @@ export const zh: Record = { "models.discoveryFailedProvider": "提供方报告了模型发现错误。", "models.discoveryFailedGeneric": "模型发现失败。", "models.openProviderSettings": "打开提供方设置", + "models.discoveryFailedDependency": "该提供方已开启模型发现。若持续失败,可在提供方设置中关闭“{control}”,改用手动添加的模型或静态模型。", "models.loading": "加载中…", "models.search": "搜索模型…", "models.showMore": "再显示 {n} 个", diff --git a/gui/src/pages/Models.tsx b/gui/src/pages/Models.tsx index 4ab93645106..5130b6515d4 100644 --- a/gui/src/pages/Models.tsx +++ b/gui/src/pages/Models.tsx @@ -75,7 +75,7 @@ import { type ShadowCallData, type V2Status, } from "./models-shared"; -import { EmptyProviderHint } from "./models-provider-hints"; +import { DiscoveryDependencyHint, EmptyProviderHint } from "./models-provider-hints"; import { shadowCallModelOptions } from "./dashboard-shared"; import { shadowSourceModelBadge, shadowSourceModelLabel } from "./shadow-call-source"; @@ -1666,6 +1666,10 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; {rows.length === 0 && ( )} + {/* A group WITH rows and a failed fetch got the amber header badge and nothing that + explains the dependency; #4075 is the reporter having to discover on their own + that turning discovery off is what makes a manually added model usable. */} + {rows.length > 0 && discoveryFailure && } {pricingKnown && (
); } + +/** + * Shown on a provider group whose live discovery FAILED but which still has rows (#4075). + * + * `EmptyProviderHint` above only renders when a group has no rows at all, so a provider with a + * failed fetch and a manually added model got the amber header badge and nothing else. The badge + * says discovery failed; it never says that discovery being ON is what keeps those rows out of + * the picker. That is the whole mechanism the reporter had to find on their own — a newly added + * key provider is stamped `initialModelSelection.status = "pending"`, failed discovery is + * degraded so initialization never finalizes, and pending rows are forced disabled and dropped + * from the Codex catalog. Turning discovery off makes the seed authoritative and releases them. + * + * The control name is interpolated from the provider-settings catalog rather than restated, so + * this sentence names the real switch in all nine locales and cannot drift from its label. + */ +export function DiscoveryDependencyHint() { + const t = useT(); + return ( +
+
+ ); +} diff --git a/gui/tests/models-discovery-failed-hint.test.tsx b/gui/tests/models-discovery-failed-hint.test.tsx new file mode 100644 index 00000000000..c2394f781e2 --- /dev/null +++ b/gui/tests/models-discovery-failed-hint.test.tsx @@ -0,0 +1,162 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import type { Root } from "react-dom/client"; +import { renderToStaticMarkup } from "react-dom/server"; +import { clearClientResourceStoresForTests } from "../src/client-resource"; +import { LanguageProvider } from "../src/i18n/provider"; +import Models from "../src/pages/Models"; +import { DiscoveryDependencyHint } from "../src/pages/models-provider-hints"; + +/** + * Regression coverage for #4075 — "model sync failed" never explains the dependency. + * + * A provider whose live fetch fails gets an amber "Discovery failed" badge on the group header + * and nothing else. `EmptyProviderHint` carries the guidance, but it only renders when the group + * has NO rows, so the reporter — who had added a Gemini model by hand — saw a failure badge and a + * model that would not work, with nothing connecting the two. The mechanism they eventually found + * on their own is that discovery being ON is what holds those rows back: a newly added key + * provider is stamped `initialModelSelection.status = "pending"`, failed discovery is degraded so + * initialization never finalizes, and pending rows are forced disabled and dropped from the Codex + * catalog. Turning "Discover models from provider" off makes the seed authoritative. + * + * The English UI never says "model sync failed"; that is the reporter's paraphrase of + * `models.discoveryFailedBadge`. + */ + +let previousLanguage: unknown; +const originalFetch = globalThis.fetch; + +beforeEach(() => { + clearClientResourceStoresForTests(); + previousLanguage = (globalThis.navigator as { language?: unknown } | undefined)?.language; + Object.defineProperty(globalThis.navigator, "language", { configurable: true, value: "en-US" }); +}); + +afterEach(() => { + clearClientResourceStoresForTests(); + globalThis.fetch = originalFetch; + Object.defineProperty(globalThis.navigator, "language", { configurable: true, value: previousLanguage }); +}); + +const DOM_GLOBALS = [ + "document", "window", "localStorage", "sessionStorage", + "IS_REACT_ACT_ENVIRONMENT", "setInterval", "clearInterval", +] as const; + +const PROVIDER = "gemini-key-provider"; +const MODEL_ID = "gemini-3.8-pro"; + +/** + * Render the Models page against one provider whose rows exist, with the discovery state under + * test, and return the group's rendered text. + */ +async function renderModelsPage(discovery: Record | undefined): Promise { + const previousDescriptors = Object.fromEntries( + DOM_GLOBALS.map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)]), + ) as Record<(typeof DOM_GLOBALS)[number], PropertyDescriptor | undefined>; + const testWindow = new Window({ url: "http://localhost/" }); + const container = testWindow.document.createElement("div"); + testWindow.document.body.append(container); + let root: Root | undefined; + // The page's poll registration must not fire during the assertion, and it reads whichever + // setInterval is reachable, so both the window and the global are stubbed like the + // neighbouring models-empty-provider harness does. + Object.defineProperty(testWindow, "setInterval", { configurable: true, value: () => 1 }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: testWindow.document }, + window: { configurable: true, value: testWindow }, + localStorage: { configurable: true, value: testWindow.localStorage }, + sessionStorage: { configurable: true, value: testWindow.sessionStorage }, + IS_REACT_ACT_ENVIRONMENT: { configurable: true, value: true }, + setInterval: { configurable: true, value: () => 1 }, + clearInterval: { configurable: true, value: () => {} }, + }); + // Expanded, or the group body that carries the hint is never rendered. + testWindow.localStorage.setItem("ocx-models-collapsed:v2", JSON.stringify([])); + const rows = [{ provider: PROVIDER, id: MODEL_ID, namespaced: `${PROVIDER}/${MODEL_ID}`, disabled: false }]; + globalThis.fetch = (async (input: string | URL | Request) => { + const url = String(input); + if (url.endsWith("/api/models")) return Response.json(rows); + if (url.endsWith("/api/providers")) { + return Response.json([{ + name: PROVIDER, + liveModels: true, + models: [MODEL_ID], + ...(discovery ? { discovery } : {}), + }]); + } + if (url.endsWith("/api/selected-models")) { + return Response.json({ selected: { [PROVIDER]: [MODEL_ID] }, available: { [PROVIDER]: [MODEL_ID] } }); + } + if (url.endsWith("/api/provider-context-caps")) return Response.json({ caps: {} }); + if (url.endsWith("/api/combos")) return Response.json({ combos: [] }); + if (url.endsWith("/api/shadow-call-settings")) return Response.json({ enabled: false, model: "" }); + return new Response(null, { status: 404 }); + }) as typeof fetch; + + try { + const { createRoot } = await import("react-dom/client"); + await act(async () => { + root = createRoot(container); + root.render( + + + , + ); + }); + await act(async () => { + await new Promise(resolve => testWindow.setTimeout(resolve, 0)); + await Promise.resolve(); + }); + return container.textContent ?? ""; + } finally { + if (root) await act(async () => root?.unmount()); + container.remove(); + testWindow.close(); + for (const key of DOM_GLOBALS) { + const descriptor = previousDescriptors[key]; + if (descriptor) Object.defineProperty(globalThis, key, descriptor); + else delete (globalThis as Record)[key]; + } + } +} + +test("a failed group WITH rows explains the discovery dependency, not just that it failed", async () => { + const text = await renderModelsPage({ status: "failed", reason: "http", httpStatus: 401 }); + // The badge is the state before this change, and it stays. + expect(text).toContain("Discovery failed"); + // The new part: name the mechanism and the exact control that changes it. + expect(text).toContain("Model discovery is on for this provider"); + // Interpolated from pws.liveModels rather than restated, so the sentence can never name a + // control whose label has moved on. + expect(text).toContain("Discover models from provider"); + expect(text).toContain("Open provider settings"); +}); + +test("a healthy group with rows gets no dependency hint", async () => { + const text = await renderModelsPage({ status: "ok" }); + expect(text).toContain(MODEL_ID); + expect(text).not.toContain("Model discovery is on for this provider"); + expect(text).not.toContain("Discovery failed"); +}); + +test("a provider with no discovery state at all gets no dependency hint", async () => { + const text = await renderModelsPage(undefined); + expect(text).toContain(MODEL_ID); + expect(text).not.toContain("Model discovery is on for this provider"); +}); + +test("the hint routes to the existing providers hash, not an invented per-provider one", () => { + // hashBelongsToPage has no providers/ arm and rewrites providers/workspace to + // providers, so a per-provider deep link would be normalised away in the URL bar. + const html = renderToStaticMarkup( + + + , + ); + expect(html).toContain('class="link-btn"'); + expect(html).toContain('role="status"'); + expect(html).toContain("Open provider settings"); + expect(html).not.toContain(`providers/${PROVIDER}`); +}); From 3dc7bd19bdd2ba32b345700512dea540677c65db Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 10 Sep 2026 07:29:22 +0900 Subject: [PATCH 2/2] fix(catalog): pin pricingStatus on the wire and align the Free switch placement Two review findings on the free-model filter. The classifier had unit coverage and both consumers had predicate coverage, but nothing proved the field survives the projection between them. It is set on a discovery hint, merged through applyProviderConfigHints, spread by listManagementModelRows, and read by the Dashboard and the CLI off GET /api/models -- four hops, none of which names the field explicitly, so any one of them could drop it silently. The new case drives real discovery against a stubbed /models response and asserts a free row, a paid row, and an unpriced row whose field is ABSENT rather than "unknown", plus that manualPricing stays orthogonal. The Free only switch also sat below the search box in the provider workspace and above it on the Models page. Same filter, opposite sides of the same input. It now sits above the search box on both. --- .../provider-workspace/ProviderModels.tsx | 6 +- .../catalog-free-pricing-status.test.ts | 55 +++++++++++++++++++ 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/gui/src/components/provider-workspace/ProviderModels.tsx b/gui/src/components/provider-workspace/ProviderModels.tsx index 7b12814d9b8..bb31575a58b 100644 --- a/gui/src/components/provider-workspace/ProviderModels.tsx +++ b/gui/src/components/provider-workspace/ProviderModels.tsx @@ -254,11 +254,13 @@ function ProviderModelInventory({ item, apiBase, availableModels, selectedModels
: (!ready || busy) &&

{t(!modelRowsReady || modelsLoading ? "pws.modelsLoading" : "pws.modelOwnershipLoading")}

} {mutation && (mutation.outcome === "unconfirmed" || mutation.refreshPending) && !modelsLoadFailed && ownershipError !== ownershipKey && } - setQuery(event.target.value)} aria-label={t("pws.modelSearchPlaceholder")} /> + {/* Above the search box, matching the Models page group: the same filter must not sit on + opposite sides of the search input on the two surfaces that offer it. */} {pricingKnown &&
setFreeOnly(!freeOnly)} label={t("models.freeOnly")} showLabel />
} + setQuery(event.target.value)} aria-label={t("pws.modelSearchPlaceholder")} /> {modelRows !== null && visible.length === 0 ?

{t("pws.noModels")}

: filtered.length === 0 && modelRows !== null ?

{t(freeOnly && priced.length === 0 ? "models.noFreeMatch" : "pws.noModelMatch")}

diff --git a/tests/codex-integration/catalog-free-pricing-status.test.ts b/tests/codex-integration/catalog-free-pricing-status.test.ts index e1545a590ad..8fc0294d503 100644 --- a/tests/codex-integration/catalog-free-pricing-status.test.ts +++ b/tests/codex-integration/catalog-free-pricing-status.test.ts @@ -1,6 +1,10 @@ import { describe, expect, test } from "bun:test"; import { catalogHintsFromModelsApiItem, discoveredPricingStatus } from "../../src/codex/catalog/provider-fetch"; import { deriveEntry } from "../../src/codex/catalog/sync"; +import { clearModelCache } from "../../src/codex/model-cache"; +import { listManagementModelRows } from "../../src/server/management/model-rows"; +import { withStubbedProviderFetch } from "../helpers/catalog-provider-fetch"; +import type { OcxConfig } from "../../src/types"; /** * Regression coverage for #3666 — no way to filter free models in the Dashboard catalog. @@ -91,3 +95,54 @@ describe("discovered model pricing classification (#3666)", () => { expect(JSON.stringify(entry)).not.toContain("pricing"); }); }); + +/** + * The classifier above is only useful if the field survives the whole projection. It is set on a + * discovery hint, merged by `applyProviderConfigHints`, spread by `listManagementModelRows`, and + * read by the Dashboard and the CLI off `GET /api/models` — four hops, none of which names the + * field explicitly, so any one of them could drop it without a single unit test noticing. + */ +describe("pricingStatus on the /api/models wire (#3666)", () => { + const PROVIDER = "pricing-wire-test"; + + function fixture(): OcxConfig { + return withStubbedProviderFetch({ + port: 10100, + modelCacheTtlMs: 0, + providers: { + [PROVIDER]: { + adapter: "openai-chat", + // A literal address: discovery pins the peer, so a hostname would need real DNS. + baseUrl: "https://93.184.216.34/v1", + apiKey: "sk-test", + }, + }, + } as OcxConfig); + } + + test("a discovered free row carries the field to the management row list", async () => { + const previousFetch = globalThis.fetch; + globalThis.fetch = (async () => Response.json({ + data: [ + { id: "gemma-free", pricing: { prompt: "0.00000000", completion: "0" } }, + { id: "sonnet-paid", pricing: { prompt: "0.000003", completion: "0.000015" } }, + { id: "unpriced" }, + ], + })) as typeof fetch; + try { + const rows = await listManagementModelRows(fixture(), { entitlementWaitMs: 0 }); + const byId = (id: string) => rows.find(row => row.provider === PROVIDER && row.id === id); + expect(byId("gemma-free")?.pricingStatus).toBe("free"); + expect(byId("sonnet-paid")?.pricingStatus).toBe("paid"); + // Absent, not "unknown": the same omission contract the hint follows reaches the wire, so + // a client that treats a missing field as not-free is reading the intended signal. + expect(byId("unpriced")).toBeDefined(); + expect(Object.hasOwn(byId("unpriced")!, "pricingStatus")).toBe(false); + // Orthogonal to the operator's own overlay marker, which no row here has. + expect(rows.every(row => !Object.hasOwn(row, "manualPricing"))).toBe(true); + } finally { + globalThis.fetch = previousFetch; + clearModelCache(PROVIDER); + } + }); +});