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 67690a9d1c8..0a34364b435 100644 --- a/gui/src/pages/Models.tsx +++ b/gui/src/pages/Models.tsx @@ -76,7 +76,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"; @@ -1680,6 +1680,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}`); +});