From 9f312d1f9e28584a30819d6bc4da64663f5ee6ac Mon Sep 17 00:00:00 2001 From: stantheman0128 Date: Fri, 24 Jul 2026 02:04:40 +0800 Subject: [PATCH] fix(models): surface live discovery failure status on Models page Keep providers visible after /v1/models auth failures and expose the last discovery outcome (HTTP 401/403, network, malformed) so the Models tab can badge the group and guide manual custom-model entry (#329). Co-authored-by: Cursor --- .../src/content/docs/guides/web-dashboard.md | 2 +- gui/src/i18n/de.ts | 6 + gui/src/i18n/en.ts | 6 + gui/src/i18n/ja.ts | 6 + gui/src/i18n/ko.ts | 6 + gui/src/i18n/ru.ts | 6 + gui/src/i18n/zh.ts | 6 + gui/src/models-groups.ts | 30 +++ gui/src/pages/Models.tsx | 28 ++- gui/tests/models-empty-provider.test.tsx | 17 +- src/codex/catalog/provider-fetch.ts | 73 +++++- src/codex/model-cache.ts | 59 +++++ src/server/management/provider-routes.ts | 4 + tests/models-page-groups.test.ts | 64 ++++- tests/provider-discovery-status-api.test.ts | 122 ++++++++++ tests/provider-discovery-status.test.ts | 218 ++++++++++++++++++ 16 files changed, 639 insertions(+), 14 deletions(-) create mode 100644 tests/provider-discovery-status-api.test.ts create mode 100644 tests/provider-discovery-status.test.ts diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md index 5ab85e46f..fabbf3e64 100644 --- a/docs-site/src/content/docs/guides/web-dashboard.md +++ b/docs-site/src/content/docs/guides/web-dashboard.md @@ -36,7 +36,7 @@ bun run dev:gui | **Add provider** | Search registry-backed presets for account login, API-key services, local servers, or a custom endpoint. | | **Codex Auth** | Add ChatGPT/Codex pool accounts, select the next-session account, refresh 5h / weekly / 30d quotas, and configure quota auto-switch and transient-failure failover. | | **Subagents** | Feature up to five bare native or namespaced routed models in the `spawn_agent` override list. | -| **Models** | Toggle native GPT and routed models, set provider allowlists and context caps, choose v1/base/v2, and configure the v2 thread limit. Configured providers stay visible as zero-model groups when discovery is off or returns no rows. | +| **Models** | Toggle native GPT and routed models, set provider allowlists and context caps, choose v1/base/v2, and configure the v2 thread limit. Configured providers stay visible as zero-model groups when discovery is off or returns no rows. Failed live discovery (for example HTTP 401 on `/v1/models`) shows an amber status badge and keeps the provider's `+` custom-model action available. | | **Logs** | Auto-refresh recent requests with tokens, requested effort, resolved model, provider, status, request id, duration, and error details. | | **Usage / Debug** | Inspect token-usage coverage and trends, or enable opt-in provider transport and usage-extraction diagnostics. | | **Stop** | Gracefully stop the proxy and installed background service, restore native Codex, and exit (`POST /api/stop`). | diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 45acb0246..b046dffc0 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -373,6 +373,12 @@ export const de = { "models.noRoutedHint": "Melde dich zuerst bei einem Anbieter an oder füge einen hinzu.", "models.emptyDiscovery": "Es wurden keine Modelle gefunden. Prüfe den Anbieter-Endpunkt oder füge ein statisches/eigenes Modell hinzu.", "models.emptyDiscoveryDisabled": "Die Live-Modellerkennung ist aus und es sind keine statischen Modelle konfiguriert.", + "models.emptyDiscoveryFailed": "{reason}. Der Anbieter bleibt sichtbar — füge ein eigenes Modell hinzu oder korrigiere die Authentifizierung und aktualisiere.", + "models.discoveryFailed": "Erkennung fehlgeschlagen", + "models.discoveryFailedHttp": "Erkennung fehlgeschlagen (HTTP {status})", + "models.discoveryFailedNetwork": "Erkennung fehlgeschlagen (Netzwerk)", + "models.discoveryFailedPolicy": "Erkennung fehlgeschlagen (blockiert)", + "models.discoveryFailedMalformed": "Erkennung fehlgeschlagen (ungültige Antwort)", "models.openProviderSettings": "Anbietereinstellungen öffnen", "models.loading": "Lädt…", "models.search": "Modelle suchen…", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 7e643ddec..72b27ae71 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -388,6 +388,12 @@ export const en = { "models.noRoutedHint": "Log into a provider or add one first.", "models.emptyDiscovery": "No models were discovered. Check the provider endpoint or add a static/custom model.", "models.emptyDiscoveryDisabled": "Live model discovery is off and no static models are configured.", + "models.emptyDiscoveryFailed": "{reason}. The provider stays visible — add a custom model or fix auth, then refresh.", + "models.discoveryFailed": "Discovery failed", + "models.discoveryFailedHttp": "Discovery failed (HTTP {status})", + "models.discoveryFailedNetwork": "Discovery failed (network)", + "models.discoveryFailedPolicy": "Discovery failed (blocked)", + "models.discoveryFailedMalformed": "Discovery failed (invalid response)", "models.openProviderSettings": "Open provider settings", "models.loading": "Loading…", "models.search": "Search models…", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 34b7d757b..847c41902 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -353,6 +353,12 @@ export const ja: Record = { "models.noRoutedHint": "まずプロバイダーにログインするか追加してください。", "models.emptyDiscovery": "モデルが見つかりませんでした。プロバイダーのエンドポイントを確認するか、静的/カスタムモデルを追加してください。", "models.emptyDiscoveryDisabled": "ライブモデル検出がオフで、静的モデルも設定されていません。", + "models.emptyDiscoveryFailed": "{reason}。プロバイダーは表示されたままです — カスタムモデルを追加するか認証を直してから更新してください。", + "models.discoveryFailed": "検出に失敗しました", + "models.discoveryFailedHttp": "検出に失敗しました (HTTP {status})", + "models.discoveryFailedNetwork": "検出に失敗しました (ネットワーク)", + "models.discoveryFailedPolicy": "検出に失敗しました (ブロック)", + "models.discoveryFailedMalformed": "検出に失敗しました (無効な応答)", "models.openProviderSettings": "プロバイダー設定を開く", "models.loading": "読み込み中…", "models.search": "モデルを検索…", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 77e95ff53..4a01c8431 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -383,6 +383,12 @@ export const ko: Record = { "models.noRoutedHint": "먼저 프로바이더에 로그인하거나 추가하세요.", "models.emptyDiscovery": "발견된 모델이 없습니다. 프로바이더 엔드포인트를 확인하거나 정적/사용자 모델을 추가하세요.", "models.emptyDiscoveryDisabled": "실시간 모델 검색이 꺼져 있고 정적 모델도 설정되지 않았습니다.", + "models.emptyDiscoveryFailed": "{reason}. 프로바이더는 계속 표시됩니다 — 사용자 모델을 추가하거나 인증을 고친 뒤 새로고침하세요.", + "models.discoveryFailed": "모델 검색 실패", + "models.discoveryFailedHttp": "모델 검색 실패 (HTTP {status})", + "models.discoveryFailedNetwork": "모델 검색 실패 (네트워크)", + "models.discoveryFailedPolicy": "모델 검색 실패 (차단됨)", + "models.discoveryFailedMalformed": "모델 검색 실패 (잘못된 응답)", "models.openProviderSettings": "프로바이더 설정 열기", "models.loading": "불러오는 중…", "models.search": "모델 검색…", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 46af542c8..c08af4305 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -388,6 +388,12 @@ export const ru: Record = { "models.noRoutedHint": "Сначала войдите в провайдера или добавьте нового.", "models.emptyDiscovery": "Модели не обнаружены. Проверьте адрес провайдера или добавьте статическую/пользовательскую модель.", "models.emptyDiscoveryDisabled": "Автообнаружение моделей выключено, статические модели не настроены.", + "models.emptyDiscoveryFailed": "{reason}. Провайдер остаётся видимым — добавьте пользовательскую модель или исправьте авторизацию и обновите.", + "models.discoveryFailed": "Обнаружение не удалось", + "models.discoveryFailedHttp": "Обнаружение не удалось (HTTP {status})", + "models.discoveryFailedNetwork": "Обнаружение не удалось (сеть)", + "models.discoveryFailedPolicy": "Обнаружение не удалось (заблокировано)", + "models.discoveryFailedMalformed": "Обнаружение не удалось (некорректный ответ)", "models.openProviderSettings": "Открыть настройки провайдера", "models.loading": "Загрузка…", "models.search": "Поиск моделей…", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 84e6c85f4..119b1ba11 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -383,6 +383,12 @@ export const zh: Record = { "models.noRoutedHint": "请先登录提供方或添加一个。", "models.emptyDiscovery": "未发现任何模型。请检查提供方端点,或添加静态/自定义模型。", "models.emptyDiscoveryDisabled": "实时模型发现已关闭,且尚未配置静态模型。", + "models.emptyDiscoveryFailed": "{reason}。提供方仍会显示 — 请添加自定义模型或修复鉴权后刷新。", + "models.discoveryFailed": "模型发现失败", + "models.discoveryFailedHttp": "模型发现失败(HTTP {status})", + "models.discoveryFailedNetwork": "模型发现失败(网络)", + "models.discoveryFailedPolicy": "模型发现失败(已拦截)", + "models.discoveryFailedMalformed": "模型发现失败(响应无效)", "models.openProviderSettings": "打开提供方设置", "models.loading": "加载中…", "models.search": "搜索模型…", diff --git a/gui/src/models-groups.ts b/gui/src/models-groups.ts index 0671ccb1b..ad2abe156 100644 --- a/gui/src/models-groups.ts +++ b/gui/src/models-groups.ts @@ -1,10 +1,23 @@ /** Pure grouping for the Models page, including configured providers with zero model rows. */ +import type { TKey } from "./i18n/shared"; + +/** Mirrors the management-API discovery DTO from `publicModelsDiscoveryStatus`. */ +export interface ProviderDiscoverySummary { + ok: boolean; + kind: string; + at?: number; + httpStatus?: number; + fallback?: string; + detail?: string; +} + export interface ConfiguredProviderSummary { name: string; authMode?: string; disabled?: boolean; liveModels?: boolean; models?: string[]; + discovery?: ProviderDiscoverySummary | null; } export interface ProviderModelGroup { @@ -13,6 +26,7 @@ export interface ProviderModelGroup { native: boolean; liveModels: boolean; configuredModels: string[]; + discovery: ProviderDiscoverySummary | null; } export function buildProviderModelGroups( @@ -45,6 +59,7 @@ export function buildProviderModelGroups 0 && providerRows.every(row => row.native === true), liveModels: configured?.liveModels !== false, configuredModels: configured?.models ?? [], + discovery: configured?.discovery ?? null, }; }) .sort((a, b) => { @@ -52,3 +67,18 @@ export function buildProviderModelGroups) => string, +): string | null { + if (!discovery || discovery.ok) return null; + if (discovery.kind === "http" && typeof discovery.httpStatus === "number") { + return t("models.discoveryFailedHttp", { status: discovery.httpStatus }); + } + if (discovery.kind === "network") return t("models.discoveryFailedNetwork"); + if (discovery.kind === "policy") return t("models.discoveryFailedPolicy"); + if (discovery.kind === "malformed") return t("models.discoveryFailedMalformed"); + return t("models.discoveryFailed"); +} diff --git a/gui/src/pages/Models.tsx b/gui/src/pages/Models.tsx index f12117192..d34dfa7e1 100644 --- a/gui/src/pages/Models.tsx +++ b/gui/src/pages/Models.tsx @@ -5,7 +5,7 @@ import { useT } from "../i18n/shared"; import type { TFn, TKey } from "../i18n/shared"; import { modelLabel } from "../model-display"; import { type ComboItem, parseComboList } from "../combo-workspace-data"; -import { buildProviderModelGroups, type ConfiguredProviderSummary } from "../models-groups"; +import { buildProviderModelGroups, discoveryFailureBadgeLabel, type ConfiguredProviderSummary } from "../models-groups"; interface ModelRow { provider: string; @@ -726,7 +726,7 @@ export default function Models({ apiBase }: { apiBase: string }) { { // eslint-disable-next-line react-hooks/refs -- The hover ref is only read by row event handlers nested in this renderer. - groups.map(({ provider, rows, native: isNative, liveModels }) => { + groups.map(({ provider, rows, native: isNative, liveModels, discovery }) => { const isCollapsed = collapsed.has(provider); const activeCount = rows.filter(m => !disabled.has(m.namespaced)).length; const capOn = contextCaps[provider] === contextCapValue; @@ -742,6 +742,7 @@ export default function Models({ apiBase }: { apiBase: string }) { const remaining = filtered.length - visible.length; const allOn = rows.length > 0 && rows.every(m => !disabled.has(m.namespaced)); const allOff = rows.length > 0 && rows.every(m => disabled.has(m.namespaced)); + const discoveryBadge = discoveryFailureBadgeLabel(discovery, t); const bulkToggle = (enable: boolean) => { const next = new Set(disabled); for (const m of rows) { if (enable) next.delete(m.namespaced); else next.add(m.namespaced); } @@ -754,6 +755,14 @@ export default function Models({ apiBase }: { apiBase: string }) { {provider} {isNative && {t("models.nativeGroupLabel")}} + {discoveryBadge && ( + + {discoveryBadge} + + )} {t("models.active", { active: activeCount, total: rows.length })}
e.stopPropagation()} style={{ gap: 6 }}> @@ -792,7 +801,7 @@ export default function Models({ apiBase }: { apiBase: string }) { {!isCollapsed && (
{isNative &&

{t("models.nativeHint")}

} - {rows.length === 0 && } + {rows.length === 0 && } {rows.length > PAGE / 2 && (
diff --git a/gui/tests/models-empty-provider.test.tsx b/gui/tests/models-empty-provider.test.tsx index 836059a91..0854e7f4b 100644 --- a/gui/tests/models-empty-provider.test.tsx +++ b/gui/tests/models-empty-provider.test.tsx @@ -2,6 +2,7 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; import { renderToStaticMarkup } from "react-dom/server"; import { LanguageProvider } from "../src/i18n/provider"; import { EmptyProviderHint } from "../src/pages/Models"; +import type { ProviderDiscoverySummary } from "../src/models-groups"; let previousLanguage: unknown; @@ -20,10 +21,10 @@ afterEach(() => { }); }); -function renderHint(liveModels: boolean): string { +function renderHint(liveModels: boolean, discovery?: ProviderDiscoverySummary | null): string { return renderToStaticMarkup( - + , ); } @@ -40,3 +41,15 @@ test("empty static provider explains that live discovery is disabled", () => { expect(html).toContain("Live model discovery is off"); expect(html).toContain('role="status"'); }); + +test("empty provider with HTTP 401 discovery status surfaces the concrete reason (#329)", () => { + const html = renderHint(true, { + ok: false, + kind: "http", + httpStatus: 401, + fallback: "configured", + }); + expect(html).toContain("Discovery failed (HTTP 401)"); + expect(html).toContain("provider stays visible"); + expect(html).toContain('href="#providers"'); +}); diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index 15f058f26..b148b678d 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -4,7 +4,17 @@ import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync } from import { delimiter, dirname, join, resolve } from "node:path"; import { atomicWriteFile, expandUserPath, getConfigDir, websocketsEnabled } from "../../config"; import { CODEX_CONFIG_PATH, CODEX_MODELS_CACHE_PATH, DEFAULT_CATALOG_PATH, readRootTomlString, resolveCodexConfigPath } from "../paths"; -import { clearModelCache, DEFAULT_MODEL_CACHE_TTL_MS, getFreshCached, getStaleCached, isModelsFetchCoolingDown, markModelsFetchFailure, setCached } from "../model-cache"; +import { + clearModelCache, + DEFAULT_MODEL_CACHE_TTL_MS, + getFreshCached, + getStaleCached, + isModelsFetchCoolingDown, + markModelsFetchFailure, + setCached, + setModelsDiscoveryStatus, + type ProviderDiscoveryFallback, +} from "../model-cache"; import { buildModelsRequest, resolveModelsAuthToken } from "../../oauth"; import type { OcxConfig, OcxProviderConfig } from "../../types"; import { modelInList } from "../../types"; @@ -235,7 +245,12 @@ export async function fetchProviderModels(name: string, prov: OcxProviderConfig, : models ); if (prov.adapter === "cursor") { - if (prov.liveModels === false || !apiKey) return configured; + if (prov.liveModels === false || !apiKey) { + if (prov.liveModels === false) { + setModelsDiscoveryStatus(name, { ok: true, kind: "skipped", fallback: "none" }); + } + return configured; + } // Cursor uses a bespoke GetUsableModels RPC (not /models), returning the full effort-suffixed // variants this PLAN can use. Keep the base-model UX (the request builder appends the effort // suffix) but filter the static seed to the bases the account actually has — so models not on the @@ -251,13 +266,25 @@ export async function fetchProviderModels(name: string, prov: OcxProviderConfig, const available = filterCursorConfiguredModelsByLiveDiscovery(configured, liveResult.models); const result = available.length > 0 ? available : configured; setCached(name, result); + setModelsDiscoveryStatus(name, { + ok: true, + kind: result.length === 0 ? "empty" : "ok", + fallback: "none", + }); return result; } markModelsFetchFailure(name); + const staleCursor = getStaleCached(name); + const fallback: ProviderDiscoveryFallback = staleCursor ? "stale" : "configured"; + setModelsDiscoveryStatus(name, { + ok: false, + kind: "network", + fallback, + detail: liveResult.error, + }); console.warn( `[opencodex] Cursor model discovery for "${name}" failed [${liveResult.error}]${liveResult.detail ? `: ${liveResult.detail}` : ""}; using stale/static catalog degradation.`, ); - const staleCursor = getStaleCached(name); return staleCursor ? applyConfigHintsToCachedModels(name, prov, staleCursor) : configured; } if (prov.authMode === "oauth" && !apiKey) { @@ -267,6 +294,7 @@ export async function fetchProviderModels(name: string, prov: OcxProviderConfig, return configured; } if (prov.liveModels === false) { + setModelsDiscoveryStatus(name, { ok: true, kind: "skipped", fallback: "none" }); return configured; } const fresh = getFreshCached(name, ttlMs); @@ -281,7 +309,7 @@ export async function fetchProviderModels(name: string, prov: OcxProviderConfig, const urlClass = new URL(url).hostname.endsWith("aiplatform.googleapis.com") ? "vertex-aiplatform" : "provider-models"; - const failedDiscoveryFallback = (): { models: CatalogModel[]; fallback: "stale" | "configured" } => { + const failedDiscoveryFallback = (): { models: CatalogModel[]; fallback: ProviderDiscoveryFallback } => { markModelsFetchFailure(name); const stale = getStaleCached(name); return { @@ -298,6 +326,12 @@ export async function fetchProviderModels(name: string, prov: OcxProviderConfig, }); if (destinationError) { const { models, fallback } = failedDiscoveryFallback(); + setModelsDiscoveryStatus(name, { + ok: false, + kind: "policy", + fallback, + detail: destinationError, + }); console.warn( `[opencodex] Provider model discovery for "${name}" was blocked by destination policy: ${destinationError} [urlClass=${urlClass}, fallback=${fallback}].`, ); @@ -307,6 +341,12 @@ export async function fetchProviderModels(name: string, prov: OcxProviderConfig, const res = await fetch(url, { headers, signal: AbortSignal.timeout(8000) }); if (!res.ok) { const { models, fallback } = failedDiscoveryFallback(); + setModelsDiscoveryStatus(name, { + ok: false, + kind: "http", + httpStatus: res.status, + fallback, + }); console.warn( `[opencodex] Provider model discovery for "${name}" failed with HTTP ${res.status} [urlClass=${urlClass}, fallback=${fallback}].`, ); @@ -325,6 +365,13 @@ export async function fetchProviderModels(name: string, prov: OcxProviderConfig, const diagnostic = contentType === "application/json" || contentType.endsWith("+json") ? "returned invalid JSON in a 2xx response" : "returned a non-JSON 2xx response"; + setModelsDiscoveryStatus(name, { + ok: false, + kind: "malformed", + httpStatus: res.status, + fallback, + detail: diagnostic, + }); console.warn( `[opencodex] Provider model discovery for "${name}" ${diagnostic} [status=${res.status}, contentType=${contentType}, urlClass=${urlClass}, fallback=${fallback}].`, ); @@ -335,6 +382,13 @@ export async function fetchProviderModels(name: string, prov: OcxProviderConfig, : undefined; if (!isProviderModelsApiItems(data)) { const { models, fallback } = failedDiscoveryFallback(); + setModelsDiscoveryStatus(name, { + ok: false, + kind: "malformed", + httpStatus: res.status, + fallback, + detail: "malformed 2xx data", + }); console.warn( `[opencodex] Provider model discovery for "${name}" returned malformed 2xx data [status=${res.status}, contentType=${contentType}, urlClass=${urlClass}, fallback=${fallback}].`, ); @@ -377,9 +431,20 @@ export async function fetchProviderModels(name: string, prov: OcxProviderConfig, warnDroppedConfiguredIdsOnce(name, droppedConfiguredIds); } setCached(name, live); + setModelsDiscoveryStatus(name, { + ok: true, + kind: live.length === 0 ? "empty" : "ok", + fallback: "none", + }); return live; } catch (error) { const { models, fallback } = failedDiscoveryFallback(); + setModelsDiscoveryStatus(name, { + ok: false, + kind: "network", + fallback, + detail: error instanceof Error ? error.name : "unknown", + }); console.warn( `[opencodex] Provider model discovery for "${name}" threw ${error instanceof Error ? error.name : "unknown"} [urlClass=${urlClass}, fallback=${fallback}].`, ); diff --git a/src/codex/model-cache.ts b/src/codex/model-cache.ts index e79e4ffce..39b826c5a 100644 --- a/src/codex/model-cache.ts +++ b/src/codex/model-cache.ts @@ -25,15 +25,71 @@ export const MODELS_FETCH_FAILURE_COOLDOWN_MS = 30_000; const failureAt = new Map(); +/** + * Last live-discovery outcome per provider. Survives cooldown so the Models page can badge + * HTTP 401 / network failures without re-fetching (issue #329). Cleared with the cache when + * credentials or provider config change. + */ +export type ProviderDiscoveryKind = + | "ok" + | "empty" + | "http" + | "network" + | "policy" + | "malformed" + | "skipped"; + +export type ProviderDiscoveryFallback = "stale" | "configured" | "none"; + +export interface ProviderDiscoveryStatus { + ok: boolean; + kind: ProviderDiscoveryKind; + at: number; + httpStatus?: number; + fallback?: ProviderDiscoveryFallback; + /** Short, secret-free diagnostic (error name or policy reason). */ + detail?: string; +} + +const discoveryStatus = new Map(); + export function markModelsFetchFailure(provider: string, now = Date.now()): void { failureAt.set(provider, now); } +export function clearModelsFetchFailure(provider: string): void { + failureAt.delete(provider); +} + export function isModelsFetchCoolingDown(provider: string, cooldownMs = MODELS_FETCH_FAILURE_COOLDOWN_MS, now = Date.now()): boolean { const at = failureAt.get(provider); return at !== undefined && now - at < cooldownMs; } +export function setModelsDiscoveryStatus( + provider: string, + status: Omit & { at?: number }, +): void { + const next: ProviderDiscoveryStatus = { + ok: status.ok, + kind: status.kind, + at: status.at ?? Date.now(), + }; + if (typeof status.httpStatus === "number") next.httpStatus = status.httpStatus; + if (status.fallback) next.fallback = status.fallback; + if (status.detail) next.detail = status.detail.slice(0, 160); + discoveryStatus.set(provider, next); +} + +export function getModelsDiscoveryStatus(provider: string): ProviderDiscoveryStatus | undefined { + return discoveryStatus.get(provider); +} + +/** Public DTO for management API / GUI (stable field names). */ +export function publicModelsDiscoveryStatus(provider: string): ProviderDiscoveryStatus | null { + return getModelsDiscoveryStatus(provider) ?? null; +} + /** Fresh cached models for a provider, or null when absent/stale (caller should re-fetch). */ export function getFreshCached(provider: string, ttlMs: number, now = Date.now()): CatalogModel[] | null { const entry = cache.get(provider); @@ -48,6 +104,7 @@ export function getStaleCached(provider: string): CatalogModel[] | null { export function setCached(provider: string, models: CatalogModel[], now = Date.now()): void { cache.set(provider, { models, fetchedAt: now }); + clearModelsFetchFailure(provider); } /** Drop one provider's cache (or all) so the next resolve forces a live re-fetch. */ @@ -55,8 +112,10 @@ export function clearModelCache(provider?: string): void { if (provider) { cache.delete(provider); failureAt.delete(provider); + discoveryStatus.delete(provider); } else { cache.clear(); failureAt.clear(); + discoveryStatus.clear(); } } diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index 89ae391cd..5383599e1 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -69,6 +69,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise ({ name, adapter: p.adapter, baseUrl: publicProviderBaseUrl(p.baseUrl), defaultModel: p.defaultModel, hasApiKey: !!p.apiKey, @@ -78,6 +79,9 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise { ]); expect(groups[0]?.native).toBe(true); expect(groups[1]?.native).toBe(false); + expect(groups[1]?.discovery).toBeNull(); }); test("excludes disabled and empty forward providers but preserves row-backed groups", () => { @@ -40,4 +46,58 @@ describe("Models page provider grouping", () => { expect(groups.map(group => group.provider)).toEqual(["combo", "configured"]); expect(groups.find(group => group.provider === "configured")?.configuredModels).toEqual(["m1"]); }); + + test("forwards provider discovery status into each group (#329)", () => { + const discovery: ProviderDiscoverySummary = { + ok: false, + kind: "http", + httpStatus: 401, + fallback: "configured", + at: 1, + }; + const groups = buildProviderModelGroups( + [], + [{ name: "ark-plan", liveModels: true, discovery }], + ); + expect(groups).toHaveLength(1); + expect(groups[0]?.discovery).toEqual(discovery); + }); +}); + +describe("discoveryFailureBadgeLabel", () => { + const labels: Partial> = { + "models.discoveryFailed": "Discovery failed", + "models.discoveryFailedHttp": "Discovery failed (HTTP {status})", + "models.discoveryFailedNetwork": "Discovery failed (network)", + "models.discoveryFailedPolicy": "Discovery failed (blocked)", + "models.discoveryFailedMalformed": "Discovery failed (invalid response)", + }; + const t = (key: TKey, vars?: Record) => { + let out = labels[key] ?? key; + if (vars) { + for (const [name, value] of Object.entries(vars)) { + out = out.split(`{${name}}`).join(String(value)); + } + } + return out; + }; + + test("returns null for missing or successful discovery", () => { + expect(discoveryFailureBadgeLabel(null, t)).toBeNull(); + expect(discoveryFailureBadgeLabel({ ok: true, kind: "ok" }, t)).toBeNull(); + expect(discoveryFailureBadgeLabel({ ok: true, kind: "empty" }, t)).toBeNull(); + }); + + test("formats HTTP, network, policy, and malformed failures", () => { + expect(discoveryFailureBadgeLabel({ ok: false, kind: "http", httpStatus: 401 }, t)) + .toBe("Discovery failed (HTTP 401)"); + expect(discoveryFailureBadgeLabel({ ok: false, kind: "http", httpStatus: 403 }, t)) + .toBe("Discovery failed (HTTP 403)"); + expect(discoveryFailureBadgeLabel({ ok: false, kind: "network" }, t)) + .toBe("Discovery failed (network)"); + expect(discoveryFailureBadgeLabel({ ok: false, kind: "policy" }, t)) + .toBe("Discovery failed (blocked)"); + expect(discoveryFailureBadgeLabel({ ok: false, kind: "malformed" }, t)) + .toBe("Discovery failed (invalid response)"); + }); }); diff --git a/tests/provider-discovery-status-api.test.ts b/tests/provider-discovery-status-api.test.ts new file mode 100644 index 000000000..3234b1b7d --- /dev/null +++ b/tests/provider-discovery-status-api.test.ts @@ -0,0 +1,122 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { clearModelCache } from "../src/codex/model-cache"; +import { loadConfig, saveConfig } from "../src/config"; +import { startServer } from "../src/server"; +import type { OcxConfig } from "../src/types"; +import { installIsolatedCodexHome, type IsolatedCodexHome } from "./helpers/isolated-codex-home"; + +const previousOpencodexHome = process.env.OPENCODEX_HOME; +const TEST_DIR = join(import.meta.dir, ".tmp-discovery-status-api"); +let isolatedCodexHome: IsolatedCodexHome | null = null; +const originalFetch = globalThis.fetch; + +beforeEach(() => { + isolatedCodexHome = installIsolatedCodexHome("ocx-discovery-status-"); + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + clearModelCache(); +}); + +afterEach(() => { + globalThis.fetch = originalFetch; + clearModelCache(); + if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousOpencodexHome; + isolatedCodexHome?.restore(); + isolatedCodexHome = null; + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true, force: true }); +}); + +describe("product path: Models discovery status via management API (#329)", () => { + test("GET /api/providers exposes HTTP 401 discovery after catalog poll, and provider stays grouped", async () => { + // Upstream that authorizes inference-shaped POSTs but rejects /models listing, + // matching the Volcengine Agent Plan shape reported in #329. + const upstream = Bun.serve({ + port: 0, + fetch(req) { + const url = new URL(req.url); + if (url.pathname.endsWith("/models") || url.pathname.includes("/v1/models")) { + return new Response(JSON.stringify({ + type: "error", + error: { type: "authentication_error", message: "AuthenticationError" }, + }), { + status: 401, + headers: { "content-type": "application/json" }, + }); + } + if (req.method === "POST") { + return new Response(JSON.stringify({ id: "msg_ok", content: [{ type: "text", text: "OK" }] }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + return new Response("not found", { status: 404 }); + }, + }); + + const config: OcxConfig = { + port: 0, + hostname: "127.0.0.1", + defaultProvider: "agent-plan", + providers: { + "agent-plan": { + adapter: "anthropic", + baseUrl: `http://127.0.0.1:${upstream.port}/api/plan`, + authMode: "key", + apiKey: "plan-key-redacted", + defaultModel: "glm-5-2", + allowPrivateNetwork: true, + }, + }, + }; + saveConfig(config); + + const server = startServer(0); + try { + // Product path #1: Models page loads /api/models (triggers live discovery). + const modelsRes = await fetch(new URL("/api/models", server.url)); + expect(modelsRes.status).toBe(200); + const models = await modelsRes.json() as Array<{ provider: string; id: string }>; + const agentRows = models.filter(row => row.provider === "agent-plan"); + expect(agentRows.map(row => row.id)).toEqual(["glm-5-2"]); + + // Product path #2: Models page loads /api/providers for group metadata + badge. + const providersRes = await fetch(new URL("/api/providers", server.url)); + expect(providersRes.status).toBe(200); + const providers = await providersRes.json() as Array<{ + name: string; + liveModels: boolean; + discovery: { + ok: boolean; + kind: string; + httpStatus?: number; + fallback?: string; + } | null; + }>; + const agent = providers.find(provider => provider.name === "agent-plan"); + expect(agent).toMatchObject({ + liveModels: true, + discovery: { + ok: false, + kind: "http", + httpStatus: 401, + fallback: "configured", + }, + }); + + // Credential/config changes clear the cache (PATCH /api/providers, POST key, etc.). + clearModelCache("agent-plan"); + const providersAfterClear = await fetch(new URL("/api/providers", server.url)).then(r => r.json()) as Array<{ + name: string; + discovery: unknown; + }>; + expect(providersAfterClear.find(p => p.name === "agent-plan")?.discovery).toBeNull(); + } finally { + await server.stop(true); + upstream.stop(true); + } + }, 20_000); +}); diff --git a/tests/provider-discovery-status.test.ts b/tests/provider-discovery-status.test.ts new file mode 100644 index 000000000..a736933c0 --- /dev/null +++ b/tests/provider-discovery-status.test.ts @@ -0,0 +1,218 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { gatherRoutedModels } from "../src/codex/catalog"; +import { + clearModelCache, + getModelsDiscoveryStatus, + isModelsFetchCoolingDown, +} from "../src/codex/model-cache"; + +afterEach(() => { + clearModelCache(); +}); + +describe("provider discovery status (#329)", () => { + test("HTTP 401 keeps configured fallback models and records discovery status", async () => { + const provider = "ark-401"; + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => new Response(JSON.stringify({ + error: { message: "AuthenticationError", type: "authentication_error" }, + }), { status: 401, headers: { "content-type": "application/json" } })) as typeof fetch; + try { + const models = await gatherRoutedModels({ + providers: { + [provider]: { + baseUrl: "https://example.invalid/api/plan", + adapter: "anthropic", + authMode: "key", + apiKey: "plan-key", + defaultModel: "glm-5-2", + }, + }, + }); + expect(models.map(model => `${model.provider}/${model.id}`)).toEqual([`${provider}/glm-5-2`]); + expect(getModelsDiscoveryStatus(provider)).toMatchObject({ + ok: false, + kind: "http", + httpStatus: 401, + fallback: "configured", + }); + expect(isModelsFetchCoolingDown(provider)).toBe(true); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("HTTP 403 records sibling auth failure without erasing the provider catalog fallback", async () => { + const provider = "ark-403"; + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => new Response("forbidden", { status: 403 })) as typeof fetch; + try { + const models = await gatherRoutedModels({ + providers: { + [provider]: { + baseUrl: "https://example.invalid/v1", + adapter: "openai-chat", + authMode: "key", + apiKey: "k", + models: ["known-model"], + }, + }, + }); + expect(models.map(model => model.id)).toEqual(["known-model"]); + expect(getModelsDiscoveryStatus(provider)).toMatchObject({ + ok: false, + kind: "http", + httpStatus: 403, + fallback: "configured", + }); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("network failure records kind=network and falls back to configured models", async () => { + const provider = "ark-net"; + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => { + throw new TypeError("fetch failed"); + }) as typeof fetch; + try { + const models = await gatherRoutedModels({ + providers: { + [provider]: { + baseUrl: "https://example.invalid/v1", + adapter: "openai-chat", + authMode: "key", + apiKey: "k", + models: ["offline-model"], + }, + }, + }); + expect(models.map(model => model.id)).toEqual(["offline-model"]); + expect(getModelsDiscoveryStatus(provider)).toMatchObject({ + ok: false, + kind: "network", + fallback: "configured", + detail: "TypeError", + }); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("authoritative empty live catalog records kind=empty without a failure badge", async () => { + const provider = "ark-empty"; + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => new Response(JSON.stringify({ data: [] }), { + status: 200, + headers: { "content-type": "application/json" }, + })) as typeof fetch; + try { + const models = await gatherRoutedModels({ + providers: { + [provider]: { + baseUrl: "https://example.invalid/v1", + adapter: "openai-chat", + authMode: "key", + apiKey: "k", + }, + }, + }); + expect(models.filter(model => model.provider === provider)).toEqual([]); + expect(getModelsDiscoveryStatus(provider)).toMatchObject({ + ok: true, + kind: "empty", + fallback: "none", + }); + expect(isModelsFetchCoolingDown(provider)).toBe(false); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("successful discovery after auth fix clears the failure status and cooldown", async () => { + const provider = "ark-retry"; + const originalFetch = globalThis.fetch; + let calls = 0; + globalThis.fetch = (async () => { + calls += 1; + if (calls === 1) { + return new Response("unauthorized", { status: 401 }); + } + return new Response(JSON.stringify({ data: [{ id: "live-ok" }] }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }) as typeof fetch; + try { + await gatherRoutedModels({ + providers: { + [provider]: { + baseUrl: "https://example.invalid/v1", + adapter: "openai-chat", + authMode: "key", + apiKey: "bad", + models: ["seed"], + }, + }, + }); + expect(getModelsDiscoveryStatus(provider)?.httpStatus).toBe(401); + + // Credential / config change clears cache + status (same path as PATCH /api/providers). + clearModelCache(provider); + expect(getModelsDiscoveryStatus(provider)).toBeUndefined(); + + const models = await gatherRoutedModels({ + providers: { + [provider]: { + baseUrl: "https://example.invalid/v1", + adapter: "openai-chat", + authMode: "key", + apiKey: "good", + models: ["seed"], + }, + }, + }); + expect(models.map(model => model.id)).toEqual(["live-ok"]); + expect(getModelsDiscoveryStatus(provider)).toMatchObject({ + ok: true, + kind: "ok", + fallback: "none", + }); + expect(isModelsFetchCoolingDown(provider)).toBe(false); + expect(calls).toBe(2); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("zero-model provider with only defaultModel stays selectable after 401 (#329 / #308)", async () => { + const provider = "agent-plan"; + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => new Response("AuthenticationError", { status: 401 })) as typeof fetch; + try { + const models = await gatherRoutedModels({ + providers: { + [provider]: { + baseUrl: "https://ark.example/api/plan", + adapter: "anthropic", + authMode: "key", + apiKey: "plan", + defaultModel: "glm-5-2", + // No static models[] — this is the Agent Plan shape from the issue. + }, + }, + }); + expect(models).toEqual([ + expect.objectContaining({ provider, id: "glm-5-2" }), + ]); + expect(getModelsDiscoveryStatus(provider)).toMatchObject({ + ok: false, + kind: "http", + httpStatus: 401, + }); + } finally { + globalThis.fetch = originalFetch; + } + }); +});