diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index 94f78fd36..65245512c 100644 --- a/docs-site/src/content/docs/guides/claude-code.md +++ b/docs-site/src/content/docs/guides/claude-code.md @@ -125,6 +125,8 @@ fall back to 350k. `ANTHROPIC_SMALL_FAST_MODEL`. The effective Haiku is `tierModels.haiku ?? smallFastModel`, fed to both Haiku variables. +When both `tierModels.haiku` and `smallFastModel` are absent, OpenCodex leaves both helper variables unset; Claude Code then chooses its native helper model (currently Sonnet), which may incur native-provider charges. + ## Roster agents (injectAgents) `ocx claude` (and the system-env daemon) syncs your featured subagent roster (Subagents tab, diff --git a/docs-site/src/content/docs/ja/guides/claude-code.md b/docs-site/src/content/docs/ja/guides/claude-code.md index a2600d9c1..ac08ca284 100644 --- a/docs-site/src/content/docs/ja/guides/claude-code.md +++ b/docs-site/src/content/docs/ja/guides/claude-code.md @@ -124,6 +124,8 @@ Claude ページで圧縮値を調整できます。**警告:** モデルの実 `ANTHROPIC_SMALL_FAST_MODEL` です。実際の Haiku 値は `tierModels.haiku ?? smallFastModel` で、 両 Haiku 変数に入ります。 +`tierModels.haiku` と `smallFastModel` の両方がない場合、OpenCodex は 2 つのヘルパーモデル変数を未設定のままにします。その後 Claude Code がネイティブのヘルパーモデル(現在は Sonnet)を選択し、ネイティブプロバイダーで料金が発生する可能性があります。 + ## ロスターエージェント(injectAgents) `ocx claude` とシステム環境デーモンは推奨サブエージェントロスター(Subagents タブ、最大 5 モデル)と diff --git a/docs-site/src/content/docs/ko/guides/claude-code.md b/docs-site/src/content/docs/ko/guides/claude-code.md index 0fbbbf643..fb315651e 100644 --- a/docs-site/src/content/docs/ko/guides/claude-code.md +++ b/docs-site/src/content/docs/ko/guides/claude-code.md @@ -126,6 +126,8 @@ Claude 페이지에서 압축 값을 조절할 수 있어요. **경고:** 모델 `ANTHROPIC_SMALL_FAST_MODEL`이에요. 실제 Haiku 값은 `tierModels.haiku ?? smallFastModel`이며, 두 Haiku 변수에 모두 들어가요. +`tierModels.haiku`와 `smallFastModel`이 모두 없으면 OpenCodex는 두 보조 모델 변수를 설정하지 않아요. 그러면 Claude Code가 네이티브 보조 모델(현재 Sonnet)을 선택하며, 네이티브 프로바이더 요금이 발생할 수 있어요. + ## 로스터 에이전트(injectAgents) `ocx claude`와 시스템 환경 데몬은 추천 서브에이전트 로스터(Subagents 탭, 최대 5개 모델)와 diff --git a/docs-site/src/content/docs/ru/guides/claude-code.md b/docs-site/src/content/docs/ru/guides/claude-code.md index a98d4f340..c6115f31f 100644 --- a/docs-site/src/content/docs/ru/guides/claude-code.md +++ b/docs-site/src/content/docs/ru/guides/claude-code.md @@ -134,6 +134,8 @@ shell-файлом: `ANTHROPIC_MODEL`, четыре `ANTHROPIC_DEFAULT_{OPUS,SON устаревший `ANTHROPIC_SMALL_FAST_MODEL`. Эффективное значение Haiku — `tierModels.haiku ?? smallFastModel`; оно подставляется в обе переменные Haiku. +Если отсутствуют и `tierModels.haiku`, и `smallFastModel`, OpenCodex оставляет обе переменные вспомогательной модели незаданными. Затем Claude Code выбирает нативную вспомогательную модель (сейчас Sonnet), что может привести к расходам у нативного провайдера. + ## Агенты из ростера (injectAgents) `ocx claude` (и демон системного окружения) синхронизирует ваш ростер избранных подагентов diff --git a/docs-site/src/content/docs/zh-cn/guides/claude-code.md b/docs-site/src/content/docs/zh-cn/guides/claude-code.md index c6b1a65ad..598576bbe 100644 --- a/docs-site/src/content/docs/zh-cn/guides/claude-code.md +++ b/docs-site/src/content/docs/zh-cn/guides/claude-code.md @@ -121,6 +121,8 @@ user-agent 会获得易读的 CLI 形式,其他客户端会获得 Desktop 哈 `ANTHROPIC_SMALL_FAST_MODEL`。有效 Haiku 值为 `tierModels.haiku ?? smallFastModel`,并会 提供给两个 Haiku 变量。 +当 `tierModels.haiku` 和 `smallFastModel` 均未设置时,OpenCodex 会让两个辅助模型变量保持未设置;随后 Claude Code 会选择其原生辅助模型(目前为 Sonnet),并可能产生原生提供方费用。 + ## 名册代理(injectAgents) `ocx claude`(以及系统环境守护进程)会把你的精选子代理名册(Subagents 标签页,最多 5 个模型) diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 5f81f37b1..6cade5be0 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -376,6 +376,13 @@ 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.discoveryFailedBadge": "Erkennung fehlgeschlagen", + "models.discoveryFailedHttp": "Die Modellerkennung ist fehlgeschlagen (HTTP {status}).", + "models.discoveryFailedBlocked": "Die Modellerkennung wurde durch die Zielrichtlinie blockiert.", + "models.discoveryFailedInvalidResponse": "Die Modellerkennung lieferte eine ungültige Antwort.", + "models.discoveryFailedNetwork": "Die Modellerkennung ist an einem Netzwerkfehler gescheitert.", + "models.discoveryFailedProvider": "Der Anbieter meldete einen Fehler bei der Modellerkennung.", + "models.discoveryFailedGeneric": "Die Modellerkennung ist fehlgeschlagen.", "models.openProviderSettings": "Anbietereinstellungen öffnen", "models.loading": "Lädt…", "models.search": "Modelle suchen…", @@ -716,6 +723,9 @@ export const de = { "claude.manualEnv": "Manuelle Einrichtung (erweitert)", "claude.smallFastModel": "Hintergrund-Hilfsmodell", "claude.smallFastModelHint": "Das Modell für Hintergrundarbeit wie Chat-Zusammenfassungen und Themenerkennung. Auch der haiku-Alias der Subagenten nutzt es. Leer = Claude-Standard (Haiku).", + "claude.smallFastModelAccurateHint": "Das Modell, das Claude Code für Hintergrundaufgaben wie Chat-Zusammenfassungen und Themenerkennung verwendet. Auch der haiku-Alias der Subagenten nutzt es.", + "claude.smallFastModelUnsetOption": "Claude Code wählen lassen (natives Modell)", + "claude.smallFastModelNativeWarning": "Wenn kein Modell gewählt ist, setzt OpenCodex keine Hilfsmodell-Overrides. Claude Code kann dann sein natives Sonnet-Modell verwenden, wodurch Kosten bei deinem nativen Anbieter entstehen können.", "claude.slotUnset": "Claude-Standard verwenden", "claude.modelMap": "Modell-Abfangen", "claude.modelMapHint": "Fängt Anfragen für ein bestimmtes Modell ab und leitet sie an das gewählte Modell um. Standardmäßig leer — wirkt erst mit einer Regel.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 55a78fb6c..81bc40d28 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -391,6 +391,13 @@ 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.discoveryFailedBadge": "Discovery failed", + "models.discoveryFailedHttp": "Model discovery failed (HTTP {status}).", + "models.discoveryFailedBlocked": "Model discovery was blocked by the destination policy.", + "models.discoveryFailedInvalidResponse": "Model discovery returned an invalid response.", + "models.discoveryFailedNetwork": "Model discovery failed due to a network error.", + "models.discoveryFailedProvider": "The provider reported a model discovery error.", + "models.discoveryFailedGeneric": "Model discovery failed.", "models.openProviderSettings": "Open provider settings", "models.loading": "Loading…", "models.search": "Search models…", @@ -1023,6 +1030,9 @@ export const en = { "claude.manualEnv": "Manual setup (advanced)", "claude.smallFastModel": "Background helper model", "claude.smallFastModelHint": "The model Claude Code uses for background work like chat summaries and topic detection. The haiku subagent alias uses it too. Empty = Claude default (Haiku).", + "claude.smallFastModelAccurateHint": "The model Claude Code uses for background work such as chat summaries and topic detection. The haiku subagent alias uses it too.", + "claude.smallFastModelUnsetOption": "Let Claude Code choose (native model)", + "claude.smallFastModelNativeWarning": "When unset, OpenCodex leaves the helper-model overrides unset. Claude Code may use its native Sonnet model, which may incur charges from your native provider.", "claude.slotUnset": "Use Claude default", "claude.modelMap": "Model interception", "claude.modelMapHint": "Intercepts requests for a specific model and reroutes them to the one you pick. Empty by default — nothing happens until you add a rule.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index ef5052421..e7dfd58c1 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -356,6 +356,13 @@ export const ja: Record = { "models.noRoutedHint": "まずプロバイダーにログインするか追加してください。", "models.emptyDiscovery": "モデルが見つかりませんでした。プロバイダーのエンドポイントを確認するか、静的/カスタムモデルを追加してください。", "models.emptyDiscoveryDisabled": "ライブモデル検出がオフで、静的モデルも設定されていません。", + "models.discoveryFailedBadge": "検出に失敗", + "models.discoveryFailedHttp": "モデル検出に失敗しました(HTTP {status})。", + "models.discoveryFailedBlocked": "モデル検出は宛先ポリシーによりブロックされました。", + "models.discoveryFailedInvalidResponse": "モデル検出が無効な応答を返しました。", + "models.discoveryFailedNetwork": "ネットワークエラーによりモデル検出に失敗しました。", + "models.discoveryFailedProvider": "プロバイダーがモデル検出エラーを報告しました。", + "models.discoveryFailedGeneric": "モデル検出に失敗しました。", "models.openProviderSettings": "プロバイダー設定を開く", "models.loading": "読み込み中…", "models.search": "モデルを検索…", @@ -978,6 +985,9 @@ export const ja: Record = { "claude.manualEnv": "手動セットアップ(高度)", "claude.smallFastModel": "バックグラウンドヘルパーモデル", "claude.smallFastModelHint": "チャットの要約やトピック検出のようなバックグラウンド作業に Claude Code が使うモデルです。haiku サブエージェントエイリアスもこれを使います。空 = Claude デフォルト(Haiku)。", + "claude.smallFastModelAccurateHint": "チャットの要約やトピック検出など、Claude Code がバックグラウンド処理に使うモデルです。サブエージェントの haiku エイリアスもこのモデルを使います。", + "claude.smallFastModelUnsetOption": "Claude Code に選択させる(ネイティブモデル)", + "claude.smallFastModelNativeWarning": "未設定の場合、OpenCodex はヘルパーモデルの上書きを設定しません。Claude Code がネイティブの Sonnet モデルを使用し、ネイティブプロバイダーで料金が発生する可能性があります。", "claude.slotUnset": "Claude デフォルトを使用", "claude.modelMap": "モデルの傍受", "claude.modelMapHint": "特定モデルへのリクエストを傍受し、選んだモデルに再ルーティングします。デフォルトは空 — ルールを追加するまで何も起きません。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index b38e70921..df1797959 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -386,6 +386,13 @@ export const ko: Record = { "models.noRoutedHint": "먼저 프로바이더에 로그인하거나 추가하세요.", "models.emptyDiscovery": "발견된 모델이 없습니다. 프로바이더 엔드포인트를 확인하거나 정적/사용자 모델을 추가하세요.", "models.emptyDiscoveryDisabled": "실시간 모델 검색이 꺼져 있고 정적 모델도 설정되지 않았습니다.", + "models.discoveryFailedBadge": "검색 실패", + "models.discoveryFailedHttp": "모델 검색에 실패했습니다(HTTP {status}).", + "models.discoveryFailedBlocked": "대상 정책 때문에 모델 검색이 차단되었습니다.", + "models.discoveryFailedInvalidResponse": "모델 검색이 잘못된 응답을 받았습니다.", + "models.discoveryFailedNetwork": "네트워크 오류로 모델 검색에 실패했습니다.", + "models.discoveryFailedProvider": "프로바이더가 모델 검색 오류를 보고했습니다.", + "models.discoveryFailedGeneric": "모델 검색에 실패했습니다.", "models.openProviderSettings": "프로바이더 설정 열기", "models.loading": "불러오는 중…", "models.search": "모델 검색…", @@ -738,6 +745,9 @@ export const ko: Record = { "claude.manualEnv": "직접 설정하기 (고급)", "claude.smallFastModel": "백그라운드 보조 모델", "claude.smallFastModelHint": "Claude Code가 대화 요약, 주제 감지 같은 배후 작업에 쓰는 모델입니다. 서브에이전트의 haiku 별칭도 이 모델을 씁니다. 비워두면 Claude 기본값(Haiku).", + "claude.smallFastModelAccurateHint": "Claude Code가 대화 요약, 주제 감지 같은 백그라운드 작업에 쓰는 모델입니다. 서브에이전트의 haiku 별칭도 이 모델을 사용합니다.", + "claude.smallFastModelUnsetOption": "Claude Code가 선택(네이티브 모델)", + "claude.smallFastModelNativeWarning": "비워 두면 OpenCodex가 보조 모델 환경 변수를 설정하지 않습니다. Claude Code가 네이티브 Sonnet 모델을 사용할 수 있으며, 네이티브 프로바이더 요금이 발생할 수 있습니다.", "claude.slotUnset": "Claude 기본값 사용", "claude.modelMap": "모델 가로채기", "claude.modelMapHint": "Claude가 특정 모델을 요청하면 가로채서 지정한 모델로 보냅니다. 기본값은 비어 있어요 — 규칙을 추가할 때만 동작합니다.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 1691c07c5..148af25f6 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -391,6 +391,13 @@ export const ru: Record = { "models.noRoutedHint": "Сначала войдите в провайдера или добавьте нового.", "models.emptyDiscovery": "Модели не обнаружены. Проверьте адрес провайдера или добавьте статическую/пользовательскую модель.", "models.emptyDiscoveryDisabled": "Автообнаружение моделей выключено, статические модели не настроены.", + "models.discoveryFailedBadge": "Ошибка обнаружения", + "models.discoveryFailedHttp": "Не удалось обнаружить модели (HTTP {status}).", + "models.discoveryFailedBlocked": "Обнаружение моделей заблокировано политикой назначения.", + "models.discoveryFailedInvalidResponse": "Обнаружение моделей вернуло недопустимый ответ.", + "models.discoveryFailedNetwork": "Обнаружение моделей не удалось из-за сетевой ошибки.", + "models.discoveryFailedProvider": "Провайдер сообщил об ошибке обнаружения моделей.", + "models.discoveryFailedGeneric": "Не удалось обнаружить модели.", "models.openProviderSettings": "Открыть настройки провайдера", "models.loading": "Загрузка…", "models.search": "Поиск моделей…", @@ -1023,6 +1030,9 @@ export const ru: Record = { "claude.manualEnv": "Ручная настройка (для продвинутых)", "claude.smallFastModel": "Фоновая вспомогательная модель", "claude.smallFastModelHint": "Модель, которую Claude Code использует для фоновых задач вроде суммаризации чатов и определения тем. Её также использует алиас подагента haiku. Пусто = значение Claude по умолчанию (Haiku).", + "claude.smallFastModelAccurateHint": "Модель, которую Claude Code использует для фоновых задач, например суммаризации чатов и определения тем. Её также использует алиас подагента haiku.", + "claude.smallFastModelUnsetOption": "Разрешить Claude Code выбрать нативную модель", + "claude.smallFastModelNativeWarning": "Если модель не выбрана, OpenCodex не задаёт переопределения вспомогательной модели. Claude Code может использовать нативную модель Sonnet, что может привести к расходам у вашего нативного провайдера.", "claude.slotUnset": "Модель Claude по умолчанию", "claude.modelMap": "Перехват моделей", "claude.modelMapHint": "Перехватывает запросы к определённой модели и перенаправляет их на выбранную вами. По умолчанию список пуст — пока вы не добавите правило, ничего не происходит.", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 600cd8c03..e6414158f 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -386,6 +386,13 @@ export const zh: Record = { "models.noRoutedHint": "请先登录提供方或添加一个。", "models.emptyDiscovery": "未发现任何模型。请检查提供方端点,或添加静态/自定义模型。", "models.emptyDiscoveryDisabled": "实时模型发现已关闭,且尚未配置静态模型。", + "models.discoveryFailedBadge": "发现失败", + "models.discoveryFailedHttp": "模型发现失败(HTTP {status})。", + "models.discoveryFailedBlocked": "模型发现被目标策略阻止。", + "models.discoveryFailedInvalidResponse": "模型发现返回了无效响应。", + "models.discoveryFailedNetwork": "由于网络错误,模型发现失败。", + "models.discoveryFailedProvider": "提供方报告了模型发现错误。", + "models.discoveryFailedGeneric": "模型发现失败。", "models.openProviderSettings": "打开提供方设置", "models.loading": "加载中…", "models.search": "搜索模型…", @@ -738,6 +745,9 @@ export const zh: Record = { "claude.manualEnv": "手动配置(高级)", "claude.smallFastModel": "后台辅助模型", "claude.smallFastModelHint": "Claude Code 用于对话摘要、主题识别等后台工作的模型。子代理的 haiku 别名也使用它。留空 = Claude 默认(Haiku)。", + "claude.smallFastModelAccurateHint": "Claude Code 用于聊天摘要、主题识别等后台工作的模型。子代理的 haiku 别名也使用此模型。", + "claude.smallFastModelUnsetOption": "让 Claude Code 选择(原生模型)", + "claude.smallFastModelNativeWarning": "留空时,OpenCodex 不会设置辅助模型覆盖项。Claude Code 可能使用其原生 Sonnet 模型,并可能产生原生提供方费用。", "claude.slotUnset": "使用 Claude 默认值", "claude.modelMap": "模型拦截", "claude.modelMapHint": "拦截对特定模型的请求并重定向到你指定的模型。默认为空——添加规则后才生效。", diff --git a/gui/src/models-groups.ts b/gui/src/models-groups.ts index 0671ccb1b..193ae1e6a 100644 --- a/gui/src/models-groups.ts +++ b/gui/src/models-groups.ts @@ -1,10 +1,20 @@ /** Pure grouping for the Models page, including configured providers with zero model rows. */ +export type ProviderDiscoverySummary = + | { status: "ok" } + | { status: "failed"; reason: "http"; httpStatus: number } + | { + status: "failed"; + reason: "blocked" | "invalid_response" | "network" | "provider"; + httpStatus?: never; + }; + export interface ConfiguredProviderSummary { name: string; authMode?: string; disabled?: boolean; liveModels?: boolean; models?: string[]; + discovery?: ProviderDiscoverySummary; } export interface ProviderModelGroup { @@ -13,6 +23,7 @@ export interface ProviderModelGroup { native: boolean; liveModels: boolean; configuredModels: string[]; + discovery?: ProviderDiscoverySummary; } export function buildProviderModelGroups( @@ -45,6 +56,7 @@ export function buildProviderModelGroups 0 && providerRows.every(row => row.native === true), liveModels: configured?.liveModels !== false, configuredModels: configured?.models ?? [], + discovery: configured?.discovery, }; }) .sort((a, b) => { diff --git a/gui/src/pages/ClaudeCode.tsx b/gui/src/pages/ClaudeCode.tsx index ef0200634..08a17e199 100644 --- a/gui/src/pages/ClaudeCode.tsx +++ b/gui/src/pages/ClaudeCode.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useState } from "react"; -import { Notice, Select } from "../ui"; +import { Notice, Select, type SelectOption } from "../ui"; import { IconPlus, IconX } from "../icons"; import { Trans } from "../i18n/provider"; import { useT } from "../i18n/shared"; @@ -19,6 +19,7 @@ interface ClaudeCodeState { autoCompactWindow: number | null; injectAgents: boolean; smallFastModel: string; + tierModels?: { haiku?: string }; effectiveModelEnv: Record; available: string[]; aliases: { id: string; display_name: string }[]; @@ -101,6 +102,41 @@ export function AutoConnectSetting({ ); } +export function SmallFastModelSetting({ + value, + tierHaikuModel, + options, + onChange, +}: { + value: string; + tierHaikuModel?: string; + options: SelectOption[]; + onChange: (value: string) => void; +}) { + const t = useT(); + const effectiveHelperModel = tierHaikuModel ?? value; + return ( + <> +
{t("claude.smallFastModel")}
+

+ {t("claude.smallFastModelAccurateHint")} +

+ setState({ ...state, smallFastModel: v })} - label={t("claude.smallFastModel")} - style={{ maxWidth: 420 }} + onChange={smallFastModel => setState({ ...state, smallFastModel })} />
{t("claude.modelMap")} {rows.length}
diff --git a/gui/src/pages/Models.tsx b/gui/src/pages/Models.tsx index f12117192..0bde23050 100644 --- a/gui/src/pages/Models.tsx +++ b/gui/src/pages/Models.tsx @@ -5,7 +5,11 @@ 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, + type ConfiguredProviderSummary, + type ProviderDiscoverySummary, +} from "../models-groups"; interface ModelRow { provider: string; @@ -179,11 +183,12 @@ export default function Models({ apiBase }: { apiBase: string }) { const load = useCallback(async () => { try { - const [data, capsData, providerData] = await Promise.all([ + const [data, capsData] = await Promise.all([ fetch(`${apiBase}/api/models`).then(r => r.json()) as Promise, fetch(`${apiBase}/api/provider-context-caps`).then(r => r.json()) as Promise, - fetch(`${apiBase}/api/providers`).then(r => r.json()) as Promise, ]); + const providerData = await fetch(`${apiBase}/api/providers`) + .then(r => r.json()) as ConfiguredProviderSummary[]; void loadV2(); // best-effort, independent of the models fetch void loadShadowCall(); setModels(data); @@ -726,7 +731,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; @@ -740,6 +745,7 @@ export default function Models({ apiBase }: { apiBase: string }) { const shown = limit[provider] ?? PAGE; const visible = sorted.slice(0, shown); const remaining = filtered.length - visible.length; + const discoveryFailure = liveModels && discovery?.status === "failed" ? discovery : undefined; 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 bulkToggle = (enable: boolean) => { @@ -754,6 +760,15 @@ export default function Models({ apiBase }: { apiBase: string }) { {provider} {isNative && {t("models.nativeGroupLabel")}} + {discoveryFailure && ( + + {t("models.discoveryFailedBadge")} + + )} {t("models.active", { active: activeCount, total: rows.length })}
e.stopPropagation()} style={{ gap: 6 }}> @@ -792,7 +807,9 @@ export default function Models({ apiBase }: { apiBase: string }) { {!isCollapsed && (
{isNative &&

{t("models.nativeHint")}

} - {rows.length === 0 && } + {rows.length === 0 && ( + + )} {rows.length > PAGE / 2 && ( , + discovery: Extract, +): string { + switch (discovery.reason) { + case "http": + return t("models.discoveryFailedHttp", { status: discovery.httpStatus }); + case "blocked": + return t("models.discoveryFailedBlocked"); + case "invalid_response": + return t("models.discoveryFailedInvalidResponse"); + case "network": + return t("models.discoveryFailedNetwork"); + case "provider": + return t("models.discoveryFailedProvider"); + default: + return t("models.discoveryFailedGeneric"); + } +} + +export function EmptyProviderHint({ + liveModels, + discovery, + showFailureBadge = true, +}: { + liveModels: boolean; + discovery?: ProviderDiscoverySummary; + showFailureBadge?: boolean; +}) { const t = useT(); + const failed = liveModels && discovery?.status === "failed" ? discovery : undefined; return (
diff --git a/gui/tests/claude-code-background-helper.test.tsx b/gui/tests/claude-code-background-helper.test.tsx new file mode 100644 index 000000000..eaac56533 --- /dev/null +++ b/gui/tests/claude-code-background-helper.test.tsx @@ -0,0 +1,65 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { renderToStaticMarkup } from "react-dom/server"; +import { LanguageProvider } from "../src/i18n/provider"; +import { SmallFastModelSetting } from "../src/pages/ClaudeCode"; + +let originalLanguageDescriptor: PropertyDescriptor | undefined; + +beforeEach(() => { + originalLanguageDescriptor = Object.getOwnPropertyDescriptor(globalThis.navigator, "language"); + Object.defineProperty(globalThis.navigator, "language", { + configurable: true, + value: "en-US", + }); +}); + +afterEach(() => { + if (originalLanguageDescriptor) { + Object.defineProperty(globalThis.navigator, "language", originalLanguageDescriptor); + } else { + delete (globalThis.navigator as { language?: string }).language; + } +}); + +const options = [ + { value: "", label: "Let Claude Code choose (native model)" }, + { value: "gemini/gemini-3-flash", label: "gemini/gemini-3-flash" }, +]; + +function renderSetting(value: string, tierHaikuModel?: string): string { + return renderToStaticMarkup( + + {}} + /> + , + ); +} + +test("unset background helper explains native selection and Sonnet cost", () => { + const html = renderSetting(""); + expect(html).toContain("Let Claude Code choose (native model)"); + expect(html).toContain("background work such as chat summaries and topic detection"); + expect(html).toContain("native Sonnet model"); + expect(html).toContain("may incur charges from your native provider"); + expect(html).toContain('role="status"'); +}); + +test("tier Haiku override is the effective helper and hides the native warning", () => { + const html = renderSetting("", "mock/tier-haiku"); + expect(html).toContain("Let Claude Code choose (native model)"); + expect(html).toContain("background work such as chat summaries and topic detection"); + expect(html).not.toContain("native Sonnet model"); + expect(html).not.toContain('role="status"'); +}); + +test("selected background helper keeps the neutral description and hides the native warning", () => { + const html = renderSetting("gemini/gemini-3-flash"); + expect(html).toContain("gemini/gemini-3-flash"); + expect(html).toContain("background work such as chat summaries and topic detection"); + expect(html).not.toContain("native Sonnet model"); + expect(html).not.toContain('role="status"'); +}); diff --git a/gui/tests/models-empty-provider.test.tsx b/gui/tests/models-empty-provider.test.tsx index 836059a91..3d41ee554 100644 --- a/gui/tests/models-empty-provider.test.tsx +++ b/gui/tests/models-empty-provider.test.tsx @@ -1,9 +1,23 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import http2 from "node:http2"; +import { act } from "react"; +import type { Root } from "react-dom/client"; import { renderToStaticMarkup } from "react-dom/server"; import { LanguageProvider } from "../src/i18n/provider"; -import { EmptyProviderHint } from "../src/pages/Models"; +import Models, { EmptyProviderHint } from "../src/pages/Models"; +import type { ProviderDiscoverySummary } from "../src/models-groups"; +import { gatherRoutedModels } from "../../src/codex/catalog"; +import { + clearModelCache, + getProviderDiscoveryStatus, + markProviderDiscoveryFailed, + type ProviderModelDiscoveryStatus, +} from "../../src/codex/model-cache"; +import { handleManagementAPI } from "../../src/server/management-api"; let previousLanguage: unknown; +const originalFetch = globalThis.fetch; beforeEach(() => { previousLanguage = (globalThis.navigator as { language?: unknown } | undefined)?.language; @@ -14,29 +28,382 @@ beforeEach(() => { }); afterEach(() => { + globalThis.fetch = originalFetch; + clearModelCache(); Object.defineProperty(globalThis.navigator, "language", { configurable: true, value: previousLanguage, }); }); -function renderHint(liveModels: boolean): string { +function renderHint(liveModels: boolean, discovery?: ProviderDiscoverySummary): string { return renderToStaticMarkup( - + , ); } +async function providerDto( + provider: string, + adapter: "openai-chat" | "cursor" = "openai-chat", + liveModels = true, +): Promise> { + const requestUrl = new URL("http://127.0.0.1/api/providers"); + const response = await handleManagementAPI( + new Request(requestUrl), + requestUrl, + { + providers: { + [provider]: { + adapter, + baseUrl: adapter === "cursor" ? "https://api2.cursor.sh" : "https://api.example.test/v1", + liveModels, + models: [], + }, + }, + }, + ); + const providers = await response!.json() as Array>; + return providers[0] ?? {}; +} + +test("Models page keeps the discovery-failure badge visible with fallback rows", async () => { + const domGlobals = ["document", "window", "localStorage", "IS_REACT_ACT_ENVIRONMENT"] as const; + const previousDescriptors = Object.fromEntries( + domGlobals.map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)]), + ) as Record<(typeof domGlobals)[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; + + Object.defineProperties(globalThis, { + document: { configurable: true, value: testWindow.document }, + window: { configurable: true, value: testWindow }, + localStorage: { configurable: true, value: testWindow.localStorage }, + IS_REACT_ACT_ENVIRONMENT: { configurable: true, value: true }, + }); + globalThis.fetch = (async (input) => { + const url = String(input); + if (url.endsWith("/api/models")) { + return Response.json([{ + provider: "fallback-provider", + id: "configured-fallback", + namespaced: "fallback-provider/configured-fallback", + disabled: false, + }]); + } + if (url.endsWith("/api/providers")) { + return Response.json([{ + name: "fallback-provider", + liveModels: true, + models: ["configured-fallback"], + discovery: { status: "failed", reason: "http", httpStatus: 401 }, + }]); + } + if (url.endsWith("/api/provider-context-caps")) return Response.json({ caps: {} }); + if (url.endsWith("/api/combos")) return Response.json([]); + 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(); + }); + + expect(container.textContent).toContain("fallback-provider/configured-fallback"); + expect(container.querySelector(".badge.badge-amber")?.textContent).toContain("Discovery failed"); + expect(container.textContent).not.toContain("No models were discovered"); + } finally { + if (root) { + await act(async () => root?.unmount()); + } + testWindow.close(); + for (const key of domGlobals) { + const descriptor = previousDescriptors[key]; + if (descriptor) Object.defineProperty(globalThis, key, descriptor); + else delete (globalThis as Record)[key]; + } + } +}); + +async function withCursorDiscoveryServer( + status: number, + run: (baseUrl: string) => Promise, +): Promise { + const server = http2.createServer(); + server.on("stream", stream => { + stream.respond({ ":status": status, "content-type": "application/proto" }); + stream.end(); + }); + await new Promise((resolve, reject) => { + const onError = (error: Error) => reject(error); + server.once("error", onError); + server.listen(0, "127.0.0.1", () => { + server.off("error", onError); + resolve(); + }); + }); + + const address = server.address(); + if (!address || typeof address === "string") throw new Error("HTTP/2 fixture did not bind a TCP port"); + try { + return await run(`http://127.0.0.1:${address.port}`); + } finally { + await new Promise(resolve => server.close(() => resolve())); + } +} + test("empty live-discovery provider renders endpoint guidance and a settings link", () => { - const html = renderHint(true); + const html = renderHint(true, { status: "ok" }); expect(html).toContain("No models were discovered"); expect(html).toContain('href="#providers"'); expect(html).toContain("Open provider settings"); + expect(html).not.toContain("Discovery failed"); +}); + +test("failed HTTP discovery renders an amber status badge and reason", () => { + const html = renderHint(true, { status: "failed", reason: "http", httpStatus: 401 }); + expect(html).toContain("Discovery failed"); + expect(html).toContain("HTTP 401"); + expect(html).toContain('class="badge badge-amber"'); + expect(html).toContain('role="status"'); + expect(html).toContain('href="#providers"'); +}); + +test("failed discovery renders each server-owned reason without provider detail", () => { + const cases: Array<[ProviderDiscoverySummary, string]> = [ + [{ status: "failed", reason: "blocked" }, "blocked by the destination policy"], + [{ status: "failed", reason: "invalid_response" }, "returned an invalid response"], + [{ status: "failed", reason: "network" }, "due to a network error"], + [{ status: "failed", reason: "provider" }, "provider reported a model discovery error"], + ]; + + for (const [discovery, reason] of cases) { + const html = renderHint(true, discovery); + expect(html).toContain("Discovery failed"); + expect(html).toContain(reason); + expect(html).toContain("Open provider settings"); + } +}); + +test("HTTP 401 discovery exposes HTTP status and badge", async () => { + const provider = "activation-http-401"; + globalThis.fetch = (async () => new Response(null, { status: 401 })) as typeof fetch; + + await gatherRoutedModels({ + providers: { + [provider]: { + adapter: "openai-chat", + baseUrl: "https://93.184.216.34/v1", + apiKey: "sk-test", + }, + }, + }); + + const discovery = { status: "failed", reason: "http", httpStatus: 401 } as const; + expect(getProviderDiscoveryStatus(provider)).toEqual(discovery); + expect(await providerDto(provider)).toMatchObject({ discovery }); + const html = renderHint(true, discovery); + expect(html).toContain("Discovery failed"); + expect(html).toContain("HTTP 401"); + expect(html).toContain('href="#providers"'); +}); + +test("destination-blocked discovery exposes blocked status and badge", async () => { + const provider = "activation-blocked"; + let fetchCalls = 0; + globalThis.fetch = (async () => { + fetchCalls += 1; + return Response.json({ data: [] }); + }) as typeof fetch; + + const models = await gatherRoutedModels({ + providers: { + [provider]: { + adapter: "openai-chat", + baseUrl: "http://198.18.0.1/v1", + apiKey: "sk-test", + models: ["static-fallback"], + }, + }, + }); + + const discovery = { status: "failed", reason: "blocked" } as const; + expect(fetchCalls).toBe(0); + expect(models.map(model => model.id)).toEqual(["static-fallback"]); + expect(getProviderDiscoveryStatus(provider)).toEqual(discovery); + expect(await providerDto(provider)).toMatchObject({ discovery }); + const html = renderHint(true, discovery); + expect(html).toContain("Discovery failed"); + expect(html).toContain("blocked by the destination policy"); + expect(html).toContain('href="#providers"'); +}); + +test("invalid JSON or malformed model data exposes invalid-response status and badge", async () => { + const fixtures = [ + { name: "invalid-json", body: "{not-json" }, + { name: "missing-data", body: JSON.stringify({ models: [] }) }, + { name: "malformed-data", body: JSON.stringify({ data: [{ id: 42 }] }) }, + ]; + + for (const fixture of fixtures) { + const provider = `activation-${fixture.name}`; + globalThis.fetch = (async () => new Response(fixture.body, { + status: 200, + headers: { "content-type": "application/json" }, + })) as typeof fetch; + const models = await gatherRoutedModels({ + providers: { + [provider]: { + adapter: "openai-chat", + baseUrl: "https://93.184.216.34/v1", + apiKey: "sk-test", + models: ["static-fallback"], + }, + }, + }); + + const discovery = { status: "failed", reason: "invalid_response" } as const; + expect(models.map(model => model.id)).toEqual(["static-fallback"]); + expect(getProviderDiscoveryStatus(provider)).toEqual(discovery); + expect(await providerDto(provider)).toMatchObject({ discovery }); + const html = renderHint(true, discovery); + expect(html).toContain("Discovery failed"); + expect(html).toContain("returned an invalid response"); + clearModelCache(provider); + } +}); + +test("network discovery failure exposes sanitized network status and badge", async () => { + const provider = "activation-network"; + const sentinel = "SENTINEL-PRIVATE-URL-https://secret.invalid/account"; + globalThis.fetch = (async () => { + throw new TypeError(sentinel); + }) as typeof fetch; + + await gatherRoutedModels({ + providers: { + [provider]: { + adapter: "openai-chat", + baseUrl: "https://93.184.216.34/v1", + apiKey: "sk-test", + }, + }, + }); + + const discovery = { status: "failed", reason: "network" } as const; + expect(getProviderDiscoveryStatus(provider)).toEqual(discovery); + const dto = await providerDto(provider); + expect(dto).toMatchObject({ discovery }); + const html = renderHint(true, discovery); + expect(html).toContain("Discovery failed"); + expect(html).toContain("due to a network error"); + expect(JSON.stringify(dto)).not.toContain(sentinel); + expect(html).not.toContain(sentinel); +}); + +test("Cursor discovery failure exposes provider status and badge", async () => { + const provider = "activation-cursor"; + const rawDetail = "HTTP 401"; + const models = await withCursorDiscoveryServer(401, baseUrl => gatherRoutedModels({ + providers: { + [provider]: { + adapter: "cursor", + baseUrl, + apiKey: "bad-token", + models: ["auto"], + }, + }, + })); + + const discovery = { status: "failed", reason: "provider" } as const; + expect(models.map(model => model.id)).toEqual(["auto"]); + expect(getProviderDiscoveryStatus(provider)).toEqual(discovery); + const dto = await providerDto(provider, "cursor"); + expect(dto).toMatchObject({ discovery }); + const html = renderHint(true, discovery); + expect(html).toContain("Discovery failed"); + expect(html).toContain("provider reported a model discovery error"); + expect(JSON.stringify(dto)).not.toContain(rawDetail); + expect(html).not.toContain(rawDetail); +}); + +test("successful discovery clears every prior failure reason", async () => { + const provider = "activation-reset"; + const failures: Array> = [ + { status: "failed", reason: "blocked" }, + { status: "failed", reason: "http", httpStatus: 401 }, + { status: "failed", reason: "invalid_response" }, + { status: "failed", reason: "network" }, + { status: "failed", reason: "provider" }, + ]; + globalThis.fetch = (async () => Response.json({ data: [] })) as typeof fetch; + + for (const { status: _status, ...failure } of failures) { + markProviderDiscoveryFailed(provider, failure); + await gatherRoutedModels({ + modelCacheTtlMs: 0, + providers: { + [provider]: { + adapter: "openai-chat", + baseUrl: "https://93.184.216.34/v1", + apiKey: "sk-test", + }, + }, + }); + + const discovery = { status: "ok" } as const; + expect(getProviderDiscoveryStatus(provider)).toEqual(discovery); + expect(await providerDto(provider)).toMatchObject({ discovery }); + const html = renderHint(true, discovery); + expect(html).toContain("No models were discovered"); + expect(html).not.toContain("Discovery failed"); + } + + clearModelCache(provider); + expect(getProviderDiscoveryStatus(provider)).toBeUndefined(); + expect(await providerDto(provider)).not.toHaveProperty("discovery"); +}); + +test("static catalog paths clear stale discovery failures and omit them from the API", async () => { + for (const adapter of ["openai-chat", "cursor"] as const) { + const provider = `static-${adapter}`; + markProviderDiscoveryFailed(provider, { reason: "http", httpStatus: 401 }); + expect(await providerDto(provider, adapter, false)).not.toHaveProperty("discovery"); + + const models = await gatherRoutedModels({ + modelCacheTtlMs: 0, + providers: { + [provider]: { + adapter, + baseUrl: adapter === "cursor" ? "https://api2.cursor.sh" : "https://api.example.test/v1", + liveModels: false, + models: ["configured-fallback"], + }, + }, + }); + + expect(models.map(model => model.id)).toEqual(["configured-fallback"]); + expect(getProviderDiscoveryStatus(provider)).toBeUndefined(); + expect(await providerDto(provider, adapter, false)).not.toHaveProperty("discovery"); + } }); test("empty static provider explains that live discovery is disabled", () => { const html = renderHint(false); expect(html).toContain("Live model discovery is off"); expect(html).toContain('role="status"'); + expect(html).not.toContain("Discovery failed"); }); diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index 15f058f26..04556b0b4 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -4,7 +4,19 @@ 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, + clearProviderDiscoveryStatus, + DEFAULT_MODEL_CACHE_TTL_MS, + getFreshCached, + getStaleCached, + isModelsFetchCoolingDown, + markModelsFetchFailure, + markProviderDiscoveryFailed, + markProviderDiscoveryOk, + setCached, + type ProviderModelDiscoveryFailure, +} from "../model-cache"; import { buildModelsRequest, resolveModelsAuthToken } from "../../oauth"; import type { OcxConfig, OcxProviderConfig } from "../../types"; import { modelInList } from "../../types"; @@ -235,7 +247,11 @@ export async function fetchProviderModels(name: string, prov: OcxProviderConfig, : models ); if (prov.adapter === "cursor") { - if (prov.liveModels === false || !apiKey) return configured; + if (prov.liveModels === false) { + clearProviderDiscoveryStatus(name); + return configured; + } + if (!apiKey) 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 @@ -250,10 +266,12 @@ export async function fetchProviderModels(name: string, prov: OcxProviderConfig, if (liveResult.ok) { const available = filterCursorConfiguredModelsByLiveDiscovery(configured, liveResult.models); const result = available.length > 0 ? available : configured; + markProviderDiscoveryOk(name); setCached(name, result); return result; } markModelsFetchFailure(name); + markProviderDiscoveryFailed(name, { reason: "provider" }); console.warn( `[opencodex] Cursor model discovery for "${name}" failed [${liveResult.error}]${liveResult.detail ? `: ${liveResult.detail}` : ""}; using stale/static catalog degradation.`, ); @@ -267,6 +285,7 @@ export async function fetchProviderModels(name: string, prov: OcxProviderConfig, return configured; } if (prov.liveModels === false) { + clearProviderDiscoveryStatus(name); return configured; } const fresh = getFreshCached(name, ttlMs); @@ -281,8 +300,11 @@ 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 = ( + failure: ProviderModelDiscoveryFailure, + ): { models: CatalogModel[]; fallback: "stale" | "configured" } => { markModelsFetchFailure(name); + markProviderDiscoveryFailed(name, failure); const stale = getStaleCached(name); return { models: stale @@ -297,7 +319,7 @@ export async function fetchProviderModels(name: string, prov: OcxProviderConfig, allowPrivateNetwork: prov.allowPrivateNetwork, }); if (destinationError) { - const { models, fallback } = failedDiscoveryFallback(); + const { models, fallback } = failedDiscoveryFallback({ reason: "blocked" }); console.warn( `[opencodex] Provider model discovery for "${name}" was blocked by destination policy: ${destinationError} [urlClass=${urlClass}, fallback=${fallback}].`, ); @@ -306,7 +328,7 @@ 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(); + const { models, fallback } = failedDiscoveryFallback({ reason: "http", httpStatus: res.status }); console.warn( `[opencodex] Provider model discovery for "${name}" failed with HTTP ${res.status} [urlClass=${urlClass}, fallback=${fallback}].`, ); @@ -321,7 +343,7 @@ export async function fetchProviderModels(name: string, prov: OcxProviderConfig, try { json = JSON.parse(body) as unknown; } catch { - const { models, fallback } = failedDiscoveryFallback(); + const { models, fallback } = failedDiscoveryFallback({ reason: "invalid_response" }); const diagnostic = contentType === "application/json" || contentType.endsWith("+json") ? "returned invalid JSON in a 2xx response" : "returned a non-JSON 2xx response"; @@ -334,7 +356,7 @@ export async function fetchProviderModels(name: string, prov: OcxProviderConfig, ? (json as { data?: unknown }).data : undefined; if (!isProviderModelsApiItems(data)) { - const { models, fallback } = failedDiscoveryFallback(); + const { models, fallback } = failedDiscoveryFallback({ reason: "invalid_response" }); console.warn( `[opencodex] Provider model discovery for "${name}" returned malformed 2xx data [status=${res.status}, contentType=${contentType}, urlClass=${urlClass}, fallback=${fallback}].`, ); @@ -376,10 +398,11 @@ export async function fetchProviderModels(name: string, prov: OcxProviderConfig, && !QUIET_AUTHORITATIVE_CATALOG_PROVIDERS.has(name)) { warnDroppedConfiguredIdsOnce(name, droppedConfiguredIds); } + markProviderDiscoveryOk(name); setCached(name, live); return live; } catch (error) { - const { models, fallback } = failedDiscoveryFallback(); + const { models, fallback } = failedDiscoveryFallback({ reason: "network" }); 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..0958bb323 100644 --- a/src/codex/model-cache.ts +++ b/src/codex/model-cache.ts @@ -17,6 +17,28 @@ interface CacheEntry { fetchedAt: number; } +export type ProviderModelDiscoveryFailureReason = + | "http" + | "blocked" + | "invalid_response" + | "network" + | "provider"; + +export type ProviderModelDiscoveryStatus = + | { status: "ok" } + | { status: "failed"; reason: "http"; httpStatus: number } + | { + status: "failed"; + reason: Exclude; + httpStatus?: never; + }; + +export type ProviderModelDiscoveryFailure = ProviderModelDiscoveryStatus extends infer Status + ? Status extends { status: "failed" } + ? Omit + : never + : never; + const cache = new Map(); /** Cooldown after a failed live `/models` fetch, so a dead/unreachable provider doesn't re-pay @@ -24,11 +46,31 @@ const cache = new Map(); export const MODELS_FETCH_FAILURE_COOLDOWN_MS = 30_000; const failureAt = new Map(); +const discoveryStatus = new Map(); export function markModelsFetchFailure(provider: string, now = Date.now()): void { failureAt.set(provider, now); } +export function markProviderDiscoveryOk(provider: string): void { + discoveryStatus.set(provider, { status: "ok" }); +} + +export function markProviderDiscoveryFailed( + provider: string, + failure: ProviderModelDiscoveryFailure, +): void { + discoveryStatus.set(provider, { status: "failed", ...failure }); +} + +export function clearProviderDiscoveryStatus(provider: string): void { + discoveryStatus.delete(provider); +} + +export function getProviderDiscoveryStatus(provider: string): ProviderModelDiscoveryStatus | undefined { + return discoveryStatus.get(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; @@ -55,8 +97,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..1f848c783 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -31,6 +31,7 @@ import { clearProviderQuotaCache, fetchProviderQuotaReports } from "../../provid import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers"; import { clearThreadAccountMap } from "../../codex/routing"; import { primeCodexPoolQuotas } from "../../codex/auth-api"; +import { getProviderDiscoveryStatus } from "../../codex/model-cache"; import { DEFAULT_PROVIDER_CONTEXT_CAP, globalContextCapValue, providerContextCap, providerContextCaps, setAllProviderContextCaps, setGlobalContextCapValue, setProviderContextCap } from "../../providers/context-cap"; import { resolveCodexHomeDir } from "../../codex/home"; import { scanStorage } from "../../storage/scanner"; @@ -78,6 +79,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise> { + const requestUrl = new URL("http://127.0.0.1/api/providers"); + const response = await handleManagementAPI( + new Request(requestUrl), + requestUrl, + { + providers: { + [provider]: { + adapter: "openai-chat", + baseUrl: "https://api.example.test/v1", + models: [], + }, + }, + }, + ); + const providers = await response!.json() as Array>; + return providers[0] ?? {}; +} + describe("combo catalog capability intersection", () => { const memberA = { provider: "a", @@ -1419,7 +1446,7 @@ describe("Codex catalog routed normalization", () => { } }); - test("model discovery blocks a private destination by default before fetch", async () => { + test("destination-blocked discovery records blocked status", async () => { const provider = "discovery-private-blocked"; const warning = spyOn(console, "warn").mockImplementation(() => {}); let fetchCalls = 0; @@ -1447,6 +1474,10 @@ describe("Codex catalog routed normalization", () => { expect(models.map(model => `${model.provider}/${model.id}`)).toEqual([ `${provider}/static-fallback`, ]); + expect(getProviderDiscoveryStatus(provider)).toEqual({ status: "failed", reason: "blocked" }); + expect(await providerDiscoveryDto(provider)).toMatchObject({ + discovery: { status: "failed", reason: "blocked" }, + }); const warningText = warning.mock.calls.flat().join(" "); expect(warningText).toContain("blocked by destination policy"); expect(warningText).toContain("benchmark address"); @@ -1484,6 +1515,7 @@ describe("Codex catalog routed normalization", () => { expect(models.map(model => `${model.provider}/${model.id}`)).toEqual([ `${provider}/live-private-model`, ]); + expect(getProviderDiscoveryStatus(provider)).toEqual({ status: "ok" }); } finally { clearModelCache(provider); } @@ -1553,6 +1585,7 @@ describe("Codex catalog routed normalization", () => { `${provider}/last-known-good`, ]); expect(getStaleCached(provider)).toEqual(stale); + expect(getProviderDiscoveryStatus(provider)).toEqual({ status: "failed", reason: "invalid_response" }); const warningText = warning.mock.calls.flat().join(" "); expect(warningText).toContain("returned invalid JSON in a 2xx response"); expect(warningText).toContain("contentType=application/problem+json"); @@ -1565,9 +1598,9 @@ describe("Codex catalog routed normalization", () => { }); test("HTTP non-OK discovery returns configured models with status diagnostics", async () => { - const provider = "discovery-http-503"; + const provider = "discovery-http-401"; const warning = spyOn(console, "warn").mockImplementation(() => {}); - globalThis.fetch = (async () => new Response(null, { status: 503 })) as typeof fetch; + globalThis.fetch = (async () => new Response(null, { status: 401 })) as typeof fetch; try { const models = await gatherRoutedModels({ @@ -1584,8 +1617,16 @@ describe("Codex catalog routed normalization", () => { expect(models.map(model => `${model.provider}/${model.id}`)).toEqual([ `${provider}/static-fallback`, ]); + expect(getProviderDiscoveryStatus(provider)).toEqual({ + status: "failed", + reason: "http", + httpStatus: 401, + }); + expect(await providerDiscoveryDto(provider)).toMatchObject({ + discovery: { status: "failed", reason: "http", httpStatus: 401 }, + }); const warningText = warning.mock.calls.flat().join(" "); - expect(warningText).toContain("failed with HTTP 503"); + expect(warningText).toContain("failed with HTTP 401"); expect(warningText).toContain("fallback=configured"); } finally { warning.mockRestore(); @@ -1593,11 +1634,12 @@ describe("Codex catalog routed normalization", () => { } }); - test("thrown fetch discovery returns configured models without SyntaxError conflation", async () => { + test("network discovery failure records sanitized network status", async () => { const provider = "discovery-fetch-throw"; + const sentinel = "SENTINEL-PRIVATE-URL-https://secret.invalid/account"; const warning = spyOn(console, "warn").mockImplementation(() => {}); globalThis.fetch = (async () => { - throw new TypeError("fetch failed"); + throw new TypeError(sentinel); }) as typeof fetch; try { @@ -1615,11 +1657,16 @@ describe("Codex catalog routed normalization", () => { expect(models.map(model => `${model.provider}/${model.id}`)).toEqual([ `${provider}/static-fallback`, ]); + expect(getProviderDiscoveryStatus(provider)).toEqual({ status: "failed", reason: "network" }); + expect(JSON.stringify(getProviderDiscoveryStatus(provider))).not.toContain(sentinel); + const dto = await providerDiscoveryDto(provider); + expect(dto).toMatchObject({ discovery: { status: "failed", reason: "network" } }); + expect(JSON.stringify(dto)).not.toContain(sentinel); const warningText = warning.mock.calls.flat().join(" "); expect(warningText).toContain("threw TypeError"); expect(warningText).toContain("fallback=configured"); expect(warningText).not.toContain("SyntaxError"); - expect(warningText).not.toContain("fetch failed"); + expect(warningText).not.toContain(sentinel); } finally { warning.mockRestore(); clearModelCache(provider); @@ -1657,6 +1704,7 @@ describe("Codex catalog routed normalization", () => { `${provider}/last-known-good`, ]); expect(getStaleCached(provider)).toEqual([{ provider, id: "last-known-good" }]); + expect(getProviderDiscoveryStatus(provider)).toEqual({ status: "failed", reason: "invalid_response" }); } finally { warning.mockRestore(); clearModelCache(provider); @@ -1689,12 +1737,58 @@ describe("Codex catalog routed normalization", () => { `${provider}/static-fallback`, ]); expect(getStaleCached(provider)).toBeNull(); + expect(getProviderDiscoveryStatus(provider)).toEqual({ status: "failed", reason: "invalid_response" }); } finally { warning.mockRestore(); clearModelCache(provider); } }); + test("invalid JSON or malformed model data records invalid-response status", async () => { + const cases = [ + { name: "invalid-json", body: "{not-json" }, + { name: "missing-data", body: JSON.stringify({ models: [] }) }, + { name: "malformed-data", body: JSON.stringify({ data: [{ id: 42 }] }) }, + ]; + const warning = spyOn(console, "warn").mockImplementation(() => {}); + + try { + for (const fixture of cases) { + const provider = `discovery-${fixture.name}`; + globalThis.fetch = (async () => new Response(fixture.body, { + status: 200, + headers: { "content-type": "application/json" }, + })) as typeof fetch; + + const models = await gatherRoutedModels({ + providers: { + [provider]: { + adapter: "openai-chat", + baseUrl: "https://93.184.216.34/v1", + apiKey: "sk-test", + models: ["static-fallback"], + }, + }, + }); + + expect(models.map(model => `${model.provider}/${model.id}`)).toEqual([ + `${provider}/static-fallback`, + ]); + expect(getProviderDiscoveryStatus(provider)).toEqual({ + status: "failed", + reason: "invalid_response", + }); + expect(await providerDiscoveryDto(provider)).toMatchObject({ + discovery: { status: "failed", reason: "invalid_response" }, + }); + clearModelCache(provider); + } + } finally { + warning.mockRestore(); + clearModelCache(); + } + }); + test("schema-valid empty discovery is authoritative, warned, and cached", async () => { const provider = "authoritative-empty"; const warning = spyOn(console, "warn").mockImplementation(() => {}); @@ -1725,6 +1819,7 @@ describe("Codex catalog routed normalization", () => { expect(second).toEqual([]); expect(fetchCalls).toBe(1); expect(getStaleCached(provider)).toEqual([]); + expect(getProviderDiscoveryStatus(provider)).toEqual({ status: "ok" }); const warningText = warning.mock.calls.flat().join(" "); expect(warningText).toContain(provider); expect(warningText).toContain("configured-ghost"); @@ -1735,6 +1830,41 @@ describe("Codex catalog routed normalization", () => { } }); + test("successful catalog discovery clears every prior failure reason", async () => { + const provider = "discovery-status-reset"; + const failures: Array> = [ + { status: "failed", reason: "blocked" }, + { status: "failed", reason: "http", httpStatus: 401 }, + { status: "failed", reason: "invalid_response" }, + { status: "failed", reason: "network" }, + { status: "failed", reason: "provider" }, + ]; + globalThis.fetch = (async () => Response.json({ data: [] })) as typeof fetch; + + for (const { status: _status, ...failure } of failures) { + markProviderDiscoveryFailed(provider, failure); + expect(getProviderDiscoveryStatus(provider)).toEqual({ status: "failed", ...failure }); + + await gatherRoutedModels({ + modelCacheTtlMs: 0, + providers: { + [provider]: { + adapter: "openai-chat", + baseUrl: "https://93.184.216.34/v1", + apiKey: "sk-test", + }, + }, + }); + + expect(getProviderDiscoveryStatus(provider)).toEqual({ status: "ok" }); + expect(await providerDiscoveryDto(provider)).toMatchObject({ discovery: { status: "ok" } }); + } + + clearModelCache(provider); + expect(getProviderDiscoveryStatus(provider)).toBeUndefined(); + expect(await providerDiscoveryDto(provider)).not.toHaveProperty("discovery"); + }); + test("routed entries receive exact jawcode context metadata", () => { const entries = buildCatalogEntries(nativeTemplate(), [], [ { provider: "opencode-go", id: "deepseek-v4-pro" }, diff --git a/tests/cursor-hardening.test.ts b/tests/cursor-hardening.test.ts index 347cea8ab..222ee6b60 100644 --- a/tests/cursor-hardening.test.ts +++ b/tests/cursor-hardening.test.ts @@ -8,7 +8,8 @@ import { import { fetchCursorUsableModels } from "../src/adapters/cursor/live-models"; import { armTimeoutDestroyFallback, createLiveCursorTransport, createTerminalSettler } from "../src/adapters/cursor/live-transport"; import { gatherRoutedModels } from "../src/codex/catalog"; -import { clearModelCache } from "../src/codex/model-cache"; +import { clearModelCache, getProviderDiscoveryStatus } from "../src/codex/model-cache"; +import { handleManagementAPI } from "../src/server/management-api"; async function withDiscoveryServer( handler: (stream: http2.ServerHttp2Stream) => void, @@ -41,6 +42,25 @@ function respond(status: number, body = new Uint8Array()): (stream: http2.Server }; } +async function cursorDiscoveryDto(provider: string): Promise> { + const requestUrl = new URL("http://127.0.0.1/api/providers"); + const response = await handleManagementAPI( + new Request(requestUrl), + requestUrl, + { + providers: { + [provider]: { + adapter: "cursor", + baseUrl: "https://api2.cursor.sh", + models: [], + }, + }, + }, + ); + const providers = await response!.json() as Array>; + return providers[0] ?? {}; +} + describe("Cursor live-model discovery hardening", () => { test("returns discovered models as typed success", async () => { const body = toBinary(GetUsableModelsResponseSchema, create(GetUsableModelsResponseSchema, { @@ -59,6 +79,33 @@ describe("Cursor live-model discovery hardening", () => { expect(result).toMatchObject({ ok: false, error: "auth", detail: "HTTP 401" }); }); + test("Cursor catalog discovery failure records provider status", async () => { + const provider = "cursor-discovery-failed"; + const rawDetail = "HTTP 401"; + const warning = spyOn(console, "warn").mockImplementation(() => {}); + try { + const models = await withDiscoveryServer(respond(401), baseUrl => gatherRoutedModels({ + providers: { + [provider]: { + adapter: "cursor", + baseUrl, + apiKey: "bad-token", + models: ["auto"], + }, + }, + })); + + expect(models.map(model => `${model.provider}/${model.id}`)).toEqual([`${provider}/auto`]); + expect(getProviderDiscoveryStatus(provider)).toEqual({ status: "failed", reason: "provider" }); + const dto = await cursorDiscoveryDto(provider); + expect(dto).toMatchObject({ discovery: { status: "failed", reason: "provider" } }); + expect(JSON.stringify(dto)).not.toContain(rawDetail); + } finally { + warning.mockRestore(); + clearModelCache(provider); + } + }); + test("classifies non-auth HTTP failures", async () => { const result = await withDiscoveryServer(respond(503), baseUrl => fetchCursorUsableModels({ apiKey: "test-token", baseUrl })); diff --git a/tests/management-provider-validation.test.ts b/tests/management-provider-validation.test.ts index d4b1f15f2..d70ca2084 100644 --- a/tests/management-provider-validation.test.ts +++ b/tests/management-provider-validation.test.ts @@ -27,6 +27,7 @@ import { startServer, } from "../src/server"; import { handleManagementAPI } from "../src/server/management-api"; +import { clearModelCache, markProviderDiscoveryFailed } from "../src/codex/model-cache"; import type { OcxConfig } from "../src/types"; import { fakeChatGptJwt } from "./helpers/fake-chatgpt-jwt"; import { installIsolatedCodexHome, type IsolatedCodexHome } from "./helpers/isolated-codex-home"; @@ -115,6 +116,42 @@ afterEach(() => { }); describe("provider management validation", () => { + test("provider discovery status is additive and omitted before an attempt", async () => { + markProviderDiscoveryFailed("auth-broken", { reason: "http", httpStatus: 401 }); + try { + const requestUrl = new URL("http://127.0.0.1/api/providers"); + const response = await handleManagementAPI( + new Request(requestUrl), + requestUrl, + { + providers: { + "auth-broken": { + adapter: "openai-chat", + baseUrl: "https://api.example.test/v1", + models: [], + }, + "not-attempted": { + adapter: "openai-chat", + baseUrl: "https://static.example.test/v1", + liveModels: false, + models: [], + }, + }, + }, + ); + const providers = await response!.json() as Array>; + + expect(providers).toContainEqual(expect.objectContaining({ + name: "auth-broken", + discovery: { status: "failed", reason: "http", httpStatus: 401 }, + })); + expect(providers.find(provider => provider.name === "not-attempted")) + .not.toHaveProperty("discovery"); + } finally { + clearModelCache(); + } + }); + test("provider management rejects externally supplied forward auth providers", async () => { if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); mkdirSync(TEST_DIR, { recursive: true }); diff --git a/tests/models-page-groups.test.ts b/tests/models-page-groups.test.ts index 5b46fe86b..1d88a83cf 100644 --- a/tests/models-page-groups.test.ts +++ b/tests/models-page-groups.test.ts @@ -9,7 +9,11 @@ describe("Models page provider grouping", () => { [{ provider: "openai", id: "gpt-5.6-sol", native: true }], [ { name: "openai", authMode: "forward" }, - { name: "empty-live", liveModels: true }, + { + name: "empty-live", + liveModels: true, + discovery: { status: "failed", reason: "http", httpStatus: 401 }, + }, { name: "empty-static", liveModels: false, models: [] }, ], ); @@ -21,6 +25,7 @@ describe("Models page provider grouping", () => { ]); expect(groups[0]?.native).toBe(true); expect(groups[1]?.native).toBe(false); + expect(groups[1]?.discovery).toEqual({ status: "failed", reason: "http", httpStatus: 401 }); }); test("excludes disabled and empty forward providers but preserves row-backed groups", () => {