diff --git a/devlog/_plan/260905_provider_registration_selection/010_initial_selection.md b/devlog/_plan/260905_provider_registration_selection/010_initial_selection.md index 54633e503b..c053b731f9 100644 --- a/devlog/_plan/260905_provider_registration_selection/010_initial_selection.md +++ b/devlog/_plan/260905_provider_registration_selection/010_initial_selection.md @@ -14,6 +14,7 @@ Persisted provider field: ```ts initialModelSelection?: { version: 1; + registrationId: string; // new UUID on first creation only; preserved on overwrite status: "pending" | "ready" | "all-off"; modelCount?: number; }; @@ -84,6 +85,10 @@ No change to explicit model-ID routing. ### NEW src/providers/initial-model-selection-runtime.ts Own the ordinary-discovery completion write, independent of Codex integration. +Match registration UUID as well as normalized inventory-producing configuration +(including custom rows, combos and provider dependencies). Equal field values +after delete/re-add are not the same registration. Schema-default normalization +and order-independent comparison avoid spurious mismatches after load/save. Capture pending provider config and disabledModels before gather; use existing authoritative outcome metadata and the pure transition after discovery. Re-read under mutatePersistedConfig, compare the captured provider/selection identity, @@ -103,16 +108,16 @@ no pending provider keep the existing fast path, with no writes/new discovery. catalog evidence; never insert config writes inside an already sealed gather. The evidence-only gather entry point remains mutation-free. -### MODIFY src/codex/convergence.ts +### MODIFY src/codex/management-convergence.ts and src/codex/convergence.ts -Before prepareCatalog, clone snapshot config as now, run initial-selection -reconciliation using authoritative providerModelOutcomes (static included), then -run existing successful-discovery reconciliation; execute BOTH, do not short-circuit -one in an `a || b` call expression. Carry projected config if either changed. -After successful admitted commit, adopt state with disabledModels/modelDiscovery -and use existing coordinated save. A failed/stale/busy commit must not publish -state or OFF decisions. Snapshot identity already hashes complete config, so the -new provider field is covered without a second fingerprint implementation. +Implementation refinement: the management wrapper resolves pending initialization +BEFORE capturing catalog admission, just as retained sync does before its evidence +read. This avoids coupling durable initial selection to a later catalog-file write +or exposing an in-memory completed marker after a failed config save. The evidence +gather stays read-only; convergence only carries pending-provider names into final +visibility filtering. Existing later-arrival projection is untouched. Registration +choices commit independently of optional Codex catalog success. Snapshot identity +already hashes complete config, so no second fingerprint implementation is needed. ### MODIFY src/server/management/model-rows.ts and model-routes.ts diff --git a/devlog/_plan/260905_provider_registration_selection/020_registration_guidance.md b/devlog/_plan/260905_provider_registration_selection/020_registration_guidance.md index 7a9b05a1e7..2b87b1e086 100644 --- a/devlog/_plan/260905_provider_registration_selection/020_registration_guidance.md +++ b/devlog/_plan/260905_provider_registration_selection/020_registration_guidance.md @@ -41,7 +41,21 @@ toast for new registration). Capture whether provider existed before add/login. Wire modal-local OAuth and catalog OAuth completion through the same notice owner; existing account management/relogin continues Accounts navigation and does not reset selections. For initial Codex provider creation, onCodexAdded also shows -Models guidance; avoid showing it merely for every added pool account. +Models guidance. Explicit account/login completion also receives generic guidance +(including the pre-seeded OpenAI provider); it never resets model choices. Existing +Accounts navigation remains underneath the notice. Historical all-OFF copy is +shown only for a newly created provider, not as a claim that re-login reset switches. + +Implementation owners after source recheck: NEW +gui/src/pages/use-provider-models-notice.ts owns the operation token and render-local +notice lifecycle. ProviderWorkspaceShell's existing /api/selected-models completion +invokes a stable onModelsSettled callback; only an active notice triggers a later +config refresh. Reuse its existing refresh token for Retry, with no duplicate model +fetch and no new poll timer. useProvidersFetch adds a latest-request guard so an +earlier pending config response cannot overwrite the post-discovery snapshot. +The refresh result is explicit (applied/failed/superseded), with one bounded retry +for supersession. Failed config reads never become success guidance. API-base +changes clear both the active operation and render state, including A→B→A. ### MODIFY gui/src/pages/use-providers-oauth.ts as needed @@ -49,6 +63,18 @@ Forward an existing new-provider boolean/name at completion through its callback without touching credential polling, reauth identity rules or secrets. Code submission is not success; popup waits for existing login-settled signal. +Embedded/standalone Codex account add/reauth is a separate completion owner. +Reuse the same ProviderModelsNotice renderer directly in CodexAccountPool for +generic forward-auth guidance, preserving its pool state and catalog-refresh +warning. It does not need a model-count fetch or four callback-prop forwarding +layers. Cover this path as well as Providers' top-level modal completion. + +The JSON editor reports newly added provider names to Providers after successful +save; show one generic notice for a batch and the normal per-provider notice for +a single new row. It also strips initialModelSelection from editor payloads; that +two-line compatibility fix is carried in core c5ad48c19, already an ancestor of +this branch. Core final CI must validate that updated head before merge. + ### MODIFY gui/src/pages/providers-shared.ts Add the sanitized initialModelSelection read-only field to ProvidersConfig. Keep @@ -76,10 +102,13 @@ ocx models disable ocx models provider on ``` -Use a real ID from a trustworthy result where available; otherwise an explicitly -labeled placeholder. Include `ocx start` prerequisite when the proxy is absent, +Use the exact ID printed by `live` (native or namespaced), represented in examples +by an explicitly labeled, quoted placeholder. Include `ocx start` prerequisite when the proxy is absent, and `ocx sync` retry guidance when discovery remains pending. No credentials in commands or messages. No shell execution from the builder. +Rows marked native also receive explicit enable/disable --native command variants +in both human and JSON output, so account-qualified native IDs containing a slash +are not misparsed as routed provider/model selectors. ### MODIFY CLI completion owners @@ -120,6 +149,11 @@ commands or messages. No shell execution from the builder. - NEW tests/cli/model-selection-guidance.test.ts with both layout manifests. - Preserve existing auth URL/credential tests; no network/API-key requirements. +P recheck: core state shape is version1 + registrationId + status + modelCount; +safeConfigDTO exposes it read-only. Public threshold docs already landed in the core +layer, so this phase adds only registration-guidance text, not a second policy rewrite. +Core exact-head CI33947171242 passed at2cc90b447 before this cycle began. + Local checks: TypeScript, GUI lint/i18n, GUI build, docs build, whitespace. All test suites run remotely in GitHub CI, not locally. Runtime UI proof is a manual isolated fake-provider scenario, not a repository test suite: new 20-model key diff --git a/devlog/_plan/260905_provider_registration_selection/021_registration_notice.png b/devlog/_plan/260905_provider_registration_selection/021_registration_notice.png new file mode 100644 index 0000000000..c1e5a8e964 Binary files /dev/null and b/devlog/_plan/260905_provider_registration_selection/021_registration_notice.png differ diff --git a/devlog/_plan/260905_provider_registration_selection/022_models_all_off.png b/devlog/_plan/260905_provider_registration_selection/022_models_all_off.png new file mode 100644 index 0000000000..64728199c8 Binary files /dev/null and b/devlog/_plan/260905_provider_registration_selection/022_models_all_off.png differ diff --git a/devlog/_plan/260905_provider_registration_selection/023_onboarding_verification.md b/devlog/_plan/260905_provider_registration_selection/023_onboarding_verification.md new file mode 100644 index 0000000000..d63c825475 --- /dev/null +++ b/devlog/_plan/260905_provider_registration_selection/023_onboarding_verification.md @@ -0,0 +1,20 @@ +# Onboarding verification + +Source: wp2 on top of core c5ad48c19. No local test suites were executed. + +- Root TypeScript, GUI build, lint and i18n lint passed. +- New dialog/hook regression file passed static TypeScript checking. +- Independent source review PASS after fixing explicit config-refresh results and + API-target A→B→A notice lifetime; no remaining blocking findings. +- Isolated source backend, fake 20-model upstream and separate temporary + OPENCODEX_HOME/CODEX_HOME. The user's running proxy/config were not changed. +- Real browser Add Provider → custom registration → completed all-OFF notice + with count 20 → Open Models → registration-demo 0/20 visible, all 20 switches + unchecked. Existing native OpenAI rows remained 8/8 visible. +- Enabled demo-model-1 in the real UI and reloaded: 1/20 remained visible, its + checkbox stayed checked, and the other 19 stayed OFF. +- Screenshots 021 and 022 are actual built dashboard captures, not mockups. + +Full behavioral regression execution and final landing evidence are supplied by +the exact-head GitHub CI runs and stacked pull requests; static/manual evidence +does not substitute for those gates. diff --git a/docs-site/src/content/docs/fr/reference/configuration/providers.md b/docs-site/src/content/docs/fr/reference/configuration/providers.md index 992f097e3d..bec7932c09 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/fr/reference/configuration/providers.md @@ -6,6 +6,21 @@ description: Entrées du fournisseur, authentification, points de terminaison, c Un fournisseur indique à opencodex où se trouve un modèle, quel adaptateur de protocole il utilise et comment les requêtes sont authentifiées. +## Sélection des modèles à l’inscription + +Une nouvelle connexion sans OAuth attend une liste de modèles fiable avant de les exposer. Si l’onglet Models contient au moins 20 lignes distinctes, tous les interrupteurs de modèles sont initialement OFF ; le fournisseur reste ACTIVE. Les connexions utilisant effectivement OAuth ou la connexion ChatGPT conservent leurs valeurs par défaut. + +Cette règle ne s’applique qu’à l’inscription d’un nouveau fournisseur. Les mises à jour, reconnexions et remplacements de clé préservent les choix existants. Après l’initialisation, activez les modèles souhaités dans Models ou avec les commandes ci-dessous. La politique distincte concernant les nouveaux modèles reste inchangée. Remplacez `` par un ID de la liste. + +```sh +ocx models live --provider openrouter +ocx models enable '' +ocx models disable '' +ocx models provider openrouter on +``` + +Après une inscription ou une connexion OAuth dans l’interface, une boîte de dialogue permet d’ouvrir Models. La CLI affiche les commandes de gestion des modèles, aussi présentes dans les étapes suivantes du JSON. `--no-wait` indique une connexion en attente, pas terminée. Lancez le proxy avec `ocx start` avant les commandes de modèles en direct. + ## Champs de premier niveau liés aux fournisseurs | Champ | Type | Par défaut | Signification | diff --git a/docs-site/src/content/docs/ja/reference/configuration/providers.md b/docs-site/src/content/docs/ja/reference/configuration/providers.md index 922a77f3c3..bd33d34a3f 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -5,6 +5,21 @@ description: プロバイダー エントリ、認証、エンドポイント、 プロバイダーは、opencodex に、モデルが存在する場所、モデルが通信するワイヤー アダプター、およびリクエストの認証方法を伝えます。 +## 初回登録時のモデル選択 + +新しい非 OAuth 接続では、信頼できるモデル一覧の取得が完了するまでモデルの公開を保留します。Models タブの重複しないモデル行が20個以上なら、モデルのスイッチをすべて OFF にします。プロバイダー自体は ACTIVE のままです。実際の認証方式が OAuth または ChatGPT ログインなら既定値を維持します。 + +初回のプロバイダー登録にのみ適用され、更新、再ログイン、キー交換で既存の選択をリセットしません。初期設定後は Models または以下の CLI で必要なモデルを有効にできます。後から追加されるモデルのポリシーは変更しません。`` を一覧の ID に置き換えてください。 + +```sh +ocx models live --provider openrouter +ocx models enable '' +ocx models disable '' +ocx models provider openrouter on +``` + +GUI で登録または OAuth ログインが完了すると、Models ページへ移動できる案内が表示されます。CLI はモデル管理コマンドを出力し、JSON にも次の操作を含めます。`--no-wait` は完了ではなくログイン待機を示します。ライブモデルのコマンドを使う前に `ocx start` でプロキシを起動してください。 + ## プロバイダー関連のトップレベルフィールド |フィールド |タイプ |デフォルト |意味 | diff --git a/docs-site/src/content/docs/ko/reference/configuration/providers.md b/docs-site/src/content/docs/ko/reference/configuration/providers.md index da0b770ba2..3d65dbfb4d 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -5,6 +5,21 @@ description: 공급자 항목, 인증, 엔드포인트, 모델 카탈로그, 할 공급자는 opencodex에 모델의 위치, 사용하는 와이어 어댑터, 요청 인증 방식을 알려줍니다. +## 처음 등록할 때의 모델 선택 + +신규 비-OAuth 연결은 신뢰할 수 있는 모델 목록을 확보할 때까지 모델 노출을 보류합니다. Models 탭의 중복 없는 모델 행이 20개 이상이면 모델 스위치를 모두 OFF로 설정합니다. 프로바이더는 활성 상태를 유지합니다. 실제 인증 방식이 OAuth나 ChatGPT 로그인인 연결은 기존 기본값을 유지합니다. + +처음 등록할 때만 적용하며 업데이트, 재로그인, 키 교체로 기존 선택을 초기화하지 않습니다. 초기 설정이 끝나면 Models 탭이나 아래 CLI 명령으로 필요한 모델을 켤 수 있습니다. 이후 새 모델이 추가될 때의 정책은 별도입니다. ``는 목록에 나온 ID로 바꾸세요. + +```sh +ocx models live --provider openrouter +ocx models enable '' +ocx models disable '' +ocx models provider openrouter on +``` + +GUI에서 등록이나 OAuth 로그인을 마치면 Models 페이지로 이동하는 안내 팝업이 뜹니다. CLI는 모델 관리 명령을 출력하며 JSON 응답에도 다음 단계가 포함됩니다. `--no-wait`는 로그인 완료가 아닌 대기 상태를 표시합니다. 실시간 모델 명령을 쓰기 전에 `ocx start`로 프록시를 시작하세요. + ## 공급자 관련 최상위 필드 | 필드 | 타입 | 기본값 | 의미 | diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 7d6d4fa1d7..3c19a5066f 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -6,6 +6,21 @@ description: Provider entries, authentication, endpoints, model catalogs, quotas A provider tells opencodex where a model lives, which wire adapter it speaks, and how requests are authenticated. +## Initial model selection + +New non-OAuth connections wait for a reliable model list before exposing models. If that list contains at least 20 distinct Models-tab rows, all model switches start OFF; the provider itself stays ACTIVE. OAuth and ChatGPT-login connections keep their defaults, based on the effective authentication mode. + +This runs only for a new provider registration. Existing selections survive updates, re-login and key replacement. After initialization, enable the models you need in Models or with the CLI below; the separate new-model-arrival policy is unchanged. Replace `` with an ID from the list. + +```sh +ocx models live --provider openrouter +ocx models enable '' +ocx models disable '' +ocx models provider openrouter on +``` + +After GUI registration or OAuth login, the confirmation dialog opens the Models page. CLI registration and login print model-management commands; JSON includes structured next steps. `--no-wait` reports pending login, not completion. Start the proxy with `ocx start` before using live model commands. + ## Provider-related top-level fields | Field | Type | Default | Meaning | diff --git a/docs-site/src/content/docs/ru/reference/configuration/providers.md b/docs-site/src/content/docs/ru/reference/configuration/providers.md index 26ab107b45..a058fdb842 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -6,6 +6,21 @@ description: Записи провайдеров, аутентификация, Провайдер сообщает opencodex, где живёт модель, на каком wire-adapter'е она работает и как аутентифицируются запросы. +## Выбор моделей при первой регистрации + +Новое подключение без OAuth не публикует модели до получения достоверного списка. Если в Models не менее 20 уникальных строк моделей, все переключатели моделей изначально OFF, но сам провайдер остаётся ACTIVE. Подключения, фактически использующие OAuth или вход ChatGPT, сохраняют настройки по умолчанию. + +Правило действует только при регистрации нового провайдера. Обновления, повторный вход и смена ключа не сбрасывают существующий выбор. После инициализации включите нужные модели в Models или командами ниже. Отдельная политика появления новых моделей не меняется. Замените `` на ID из списка. + +```sh +ocx models live --provider openrouter +ocx models enable '' +ocx models disable '' +ocx models provider openrouter on +``` + +После регистрации или входа OAuth в интерфейсе диалог предлагает открыть Models. CLI выводит команды управления моделями; JSON содержит следующие шаги. `--no-wait` означает ожидание входа, а не завершение. Перед командами для актуального списка моделей запустите прокси через `ocx start`. + ## Верхнеуровневые поля, связанные с провайдерами | Поле | Тип | По умолчанию | Значение | diff --git a/docs-site/src/content/docs/tr/reference/configuration/providers.md b/docs-site/src/content/docs/tr/reference/configuration/providers.md index 217c8e2466..4213ab6001 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/tr/reference/configuration/providers.md @@ -6,6 +6,21 @@ description: Sağlayıcı girdileri, kimlik doğrulama, uç noktalar, model kata Bir sağlayıcı, opencodex'e bir modelin nerede yaşadığını, hangi hat adaptörünü konuştuğunu ve isteklerin nasıl doğrulandığını söyler. +## İlk kayıtta model seçimi + +Yeni OAuth dışı bağlantılar, modelleri göstermeden önce güvenilir bir model listesini bekler. Models sekmesinde en az 20 benzersiz model satırı varsa tüm model anahtarları başlangıçta OFF olur; sağlayıcının kendisi ACTIVE kalır. Gerçekte OAuth veya ChatGPT girişi kullanan bağlantılar varsayılanlarını korur. + +Bu kural yalnızca yeni sağlayıcı kaydında uygulanır. Güncellemeler, yeniden giriş ve anahtar değişimi mevcut seçimleri sıfırlamaz. İlk ayardan sonra gerekli modelleri Models üzerinden veya aşağıdaki CLI komutlarıyla açın. Sonradan gelen yeni modellerin ayrı politikası değişmez. `` yerine listedeki bir ID yazın. + +```sh +ocx models live --provider openrouter +ocx models enable '' +ocx models disable '' +ocx models provider openrouter on +``` + +Arayüzde kayıt veya OAuth girişi tamamlanınca Models sayfasını açan bir bilgilendirme penceresi gösterilir. CLI model yönetimi komutlarını yazdırır; JSON sonraki adımları içerir. `--no-wait` tamamlanmış değil, bekleyen girişi bildirir. Canlı model komutlarından önce proxy’yi `ocx start` ile başlatın. + ## Sağlayıcı ile ilgili üst düzey alanlar | Alan | Tip | Varsayılan | Anlamı | diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md index fe3b8cafe5..f2d245b5ec 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md @@ -5,6 +5,21 @@ description: 提供者条目、身份验证、端点、模型目录、配额、 提供者用于告诉 opencodex 模型位于哪里、使用哪种线协议适配器,以及请求如何进行身份验证。 +## 首次注册时的模型选择 + +新的非 OAuth 连接会等待可靠的模型列表,再公开模型。如果 Models 标签页中去重后的模型行达到20个,所有模型开关初始为 OFF,但提供者本身保持 ACTIVE。实际认证方式为 OAuth 或 ChatGPT 登录的连接保留默认设置。 + +仅在首次注册提供者时应用;更新、重新登录和更换密钥不会重置已有选择。初始化后,可在 Models 或使用以下 CLI 命令启用所需模型。后续新增模型的独立策略不变。请将 `` 替换为列表中的 ID。 + +```sh +ocx models live --provider openrouter +ocx models enable '' +ocx models disable '' +ocx models provider openrouter on +``` + +在界面中完成注册或 OAuth 登录后,提示框可打开 Models 页面。CLI 会输出模型管理命令,JSON 也包含后续步骤。`--no-wait` 表示登录仍在等待中,并非已完成。使用实时模型命令前,请先运行 `ocx start` 启动代理。 + ## 提供者相关顶级字段 | 字段 | 类型 | 默认值 | 含义 | diff --git a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md index 32511b66b0..7a27de4f61 100644 --- a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md @@ -5,6 +5,21 @@ description: 供應商項目、認證、端點、模型目錄、配額、context 供應商告訴 opencodex 模型在哪裡、它使用哪種 wire adapter,以及請求如何被認證。 +## 首次註冊時的模型選擇 + +新的非 OAuth 連線會先等待可靠的模型清單,再公開模型。如果 Models 分頁中去重後的模型列達到20個,所有模型開關初始為 OFF,但供應商本身保持 ACTIVE。實際驗證方式為 OAuth 或 ChatGPT 登入的連線保留預設值。 + +只在首次註冊供應商時套用;更新、重新登入與更換金鑰不會重設既有選擇。初始化後,可在 Models 或使用以下 CLI 指令啟用所需模型。後續新增模型的獨立政策不變。請將 `` 換成清單中的 ID。 + +```sh +ocx models live --provider openrouter +ocx models enable '' +ocx models disable '' +ocx models provider openrouter on +``` + +在介面中完成註冊或 OAuth 登入後,提示視窗可開啟 Models 頁面。CLI 會輸出模型管理指令,JSON 也包含後續步驟。`--no-wait` 表示登入仍在等待中,並非已完成。使用即時模型指令前,請先執行 `ocx start` 啟動代理。 + ## 供應商相關的頂層欄位 | 欄位 | 型別 | 預設值 | 意義 | diff --git a/gui/src/components/CodexAccountPool.tsx b/gui/src/components/CodexAccountPool.tsx index 37df538ef2..51cd7f1562 100644 --- a/gui/src/components/CodexAccountPool.tsx +++ b/gui/src/components/CodexAccountPool.tsx @@ -25,6 +25,8 @@ import { quotaAutoRefreshAvailability } from "../codex-quota-utils"; // Single definition lives with the controller that owns this data (WP3). export type { CodexAccountEntry } from "../hooks/useCodexAccountPool"; +import ProviderModelsNotice from "./ProviderModelsNotice"; +import { navigateHash } from "../hash-routing"; const DOCTOR_CMD = "ocx doctor"; type QuotaAutoRefreshSettings = Record; @@ -70,6 +72,7 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban const { accounts, activeId, loadState, switchingId, pauseUpdatingId, priorityUpdatingId, pausingExhausted, activePinnedId, load } = controller; const [confirm, setConfirm] = useState(null); const [showAdd, setShowAdd] = useState(false); + const [modelsNotice, setModelsNotice] = useState<{ catalogRefreshPending: boolean } | null>(null); const [advancedOpen, setAdvancedOpen] = useState(false); const hardLockFocusPending = useRef(false); const focusHardLockSetting = useCallback(() => { @@ -180,6 +183,7 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban completion.catalogRefreshPending ? "warn" : "ok", ); closeAddModal(); + setModelsNotice({ catalogRefreshPending: completion.catalogRefreshPending }); }, [closeAddModal, controller, showActionFeedback, t]); const setActive = async (id: string | null) => { @@ -581,6 +585,12 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban onAdded={handleAccountAdded} /> )} + {modelsNotice && setModelsNotice(null)} + onOpenModels={() => { setModelsNotice(null); navigateHash("models"); }} + />} ); } diff --git a/gui/src/components/ProviderModelsNotice.tsx b/gui/src/components/ProviderModelsNotice.tsx new file mode 100644 index 0000000000..c19844e84c --- /dev/null +++ b/gui/src/components/ProviderModelsNotice.tsx @@ -0,0 +1,63 @@ +import { useEffect, useId, useRef } from "react"; +import { useT } from "../i18n/shared"; + +export interface ProviderModelsNoticeProps { + provider: string; + loading: boolean; + failed: boolean; + providerKnown: boolean; + initialRegistration: boolean; + selection?: { status: "pending" | "ready" | "all-off"; modelCount?: number }; + catalogRefreshPending?: boolean; + onClose: () => void; + onOpenModels: () => void; + onRetry?: () => void; +} + +export default function ProviderModelsNotice(props: ProviderModelsNoticeProps) { + const t = useT(); + const titleId = useId(); + const dialog = useRef(null); + const primary = useRef(null); + useEffect(() => { + const previous = document.activeElement as HTMLElement | null; + primary.current?.focus(); + return () => { if (previous?.isConnected && typeof previous.focus === "function") previous.focus(); }; + }, []); + const pending = props.selection?.status === "pending"; + const unavailable = props.failed || !props.providerKnown; + const message = props.loading ? t("prov.modelsNoticeChecking") + : unavailable ? t("prov.modelsNoticeFailed") + : pending ? t("prov.modelsNoticePending") + : props.initialRegistration && props.selection?.status === "all-off" ? t("prov.modelsNoticeOff") + : t("prov.modelsNoticeReady"); + + return ( +
{ + if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); props.onClose(); } + if (event.key !== "Tab") return; + const buttons = dialog.current?.querySelectorAll("button:not([disabled])"); + const first = buttons?.[0], last = buttons?.[buttons.length - 1]; + if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus(); } + else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus(); } + }}> +
+

{t("prov.modelsNoticeTitle")}

+

{props.provider}

+

{message}

+ {props.initialRegistration && props.selection?.modelCount !== undefined && ( +

{t("prov.modelsNoticeCount", { count: props.selection.modelCount })}

+ )} + {props.catalogRefreshPending &&

{t("codexAuth.catalogRefreshPending")}

} + {!props.loading && (pending || unavailable) && props.onRetry && ( + + )} +
+ + +
+
+
+ ); +} diff --git a/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx b/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx index 5b0004c920..2564bc9c38 100644 --- a/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx +++ b/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx @@ -78,6 +78,7 @@ export default function ProviderWorkspaceShell({ jsonEditor, jsonSaving = false, modelsRefreshToken = 0, + onModelsSettled, activeAccountNeedsReauth, /** Stable key of active OAuth account ids — refetch overview quotas after account switch. */ quotaRefreshEpoch = 0, @@ -99,6 +100,8 @@ export default function ProviderWorkspaceShell({ jsonSaving?: boolean; /** Bump after login/config changes so /api/selected-models is refetched. */ modelsRefreshToken?: number; + /** Registration feedback re-reads config only after this discovery actually settles. */ + onModelsSettled?: (ok: boolean) => void; activeAccountNeedsReauth?: Record; /** * Monotonic quota revision. It moves only when something actually invalidates the quota @@ -176,6 +179,7 @@ export default function ProviderWorkspaceShell({ const timeout = window.setTimeout(() => { setModelsLoading(true); void (async () => { + let succeeded = false; try { const res = await fetch(`${apiBase}/api/selected-models`); const data = await readJsonOrThrow(res); @@ -185,11 +189,12 @@ export default function ProviderWorkspaceShell({ setLiveModelCounts(parseLiveModelCounts(data)); setSelectedModels(parseSelectedModels(data)); setModelsLoadFailed(false); + succeeded = true; } catch { if (cancelled) return; setModelsLoadFailed(true); } finally { - if (!cancelled) setModelsLoading(false); + if (!cancelled) { setModelsLoading(false); onModelsSettled?.(succeeded); } } })(); }, 0); @@ -197,7 +202,7 @@ export default function ProviderWorkspaceShell({ cancelled = true; window.clearTimeout(timeout); }; - }, [apiBase, modelsRefreshToken, modelsLoadEpoch]); + }, [apiBase, modelsRefreshToken, modelsLoadEpoch, onModelsSettled]); useEffect(() => { let cancelled = false; diff --git a/gui/src/hooks/useJsonConfigEditor.ts b/gui/src/hooks/useJsonConfigEditor.ts index f27110ab90..a72236fa56 100644 --- a/gui/src/hooks/useJsonConfigEditor.ts +++ b/gui/src/hooks/useJsonConfigEditor.ts @@ -10,6 +10,7 @@ const PROVIDER_EDITOR_DERIVED_FIELDS = [ "hasApiKey", "hasHeaders", "xaiResponsesOptInState", + "initialModelSelection", ] as const; type ProviderEditorConfig = { @@ -38,7 +39,7 @@ export function useJsonConfigEditor(deps: { notify: (msg: string, ok?: boolean) => void; fetchConfig: () => Promise; fetchProviderQuotas: (refresh?: boolean) => Promise; - onSaved: () => void; + onSaved: (addedProviders: string[]) => void; t: (key: string, values?: Record) => string; }) { const { apiBase, config, notify, fetchConfig, fetchProviderQuotas, onSaved, t } = deps; @@ -84,7 +85,9 @@ export function useJsonConfigEditor(deps: { setJsonBaseline(JSON.stringify(parsed, null, 2)); fetchConfig(); fetchProviderQuotas(true); - onSaved(); + const addedProviders = Object.keys((parsed as ProviderEditorConfig).providers) + .filter(name => !Object.hasOwn((baseline as ProviderEditorConfig).providers, name)); + onSaved(addedProviders); return true; } catch { notify(t("prov.saveFailed"), false); diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 1b91e05e1d..d57cd9850f 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -425,6 +425,15 @@ export const de: Record = { "prov.updateFail": "Dieser Anbieter konnte nicht aktualisiert werden.", "prov.networkError": "Netzwerkfehler. Prüfe, ob der Proxy läuft, und versuche es erneut.", "prov.added": "\"{name}\" hinzugefügt. Sofort aktiv — führe {cmd} aus (oder starte neu), um seine Modelle in Codex’ Auswahl zu listen.", + "prov.modelsNoticeTitle": "Modelle auswählen", + "prov.modelsNoticeChecking": "Die Modellliste wird geprüft. Modellschalter deaktivieren den Anbieter nicht.", + "prov.modelsNoticePending": "Die erste Modellliste ist noch nicht bestätigt. Modelle bleiben bis zum Abschluss der Erkennung ausgeblendet.", + "prov.modelsNoticeOff": "Bei der Registrierung wurden alle Modellschalter auf OFF gesetzt. Aktiviere die gewünschten Modelle auf der Seite Models.", + "prov.modelsNoticeReady": "Wähle auf der Seite Models aus, welche Modelle angezeigt werden. Die Schalter deaktivieren nicht den Anbieter selbst.", + "prov.modelsNoticeFailed": "Der Anbieter wurde gespeichert, die Modellliste konnte aber nicht aktualisiert werden. Versuche es erneut.", + "prov.modelsNoticeCount": "{count} Modelle", + "prov.modelsNoticeOpen": "Models öffnen", + "models.initialSelectionPending": "Erste Modellerkennung ausstehend", "prov.removeConfirm": "Anbieter \"{name}\" entfernen? Seine Modelle verschwinden aus Codex’ Auswahl.", "prov.hasApiKey": "API-Schlüssel konfiguriert", "prov.hasHeaders": "benutzerdefinierte Header konfiguriert", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index aec7477655..a5ac68bba8 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -448,6 +448,15 @@ export const en = { "prov.updateFail": "Couldn't update this provider.", "prov.networkError": "Network error. Check that the proxy is running and try again.", "prov.added": "Added \"{name}\". Live now — run {cmd} (or restart) to list its models in Codex's picker.", + "prov.modelsNoticeTitle": "Choose models", + "prov.modelsNoticeChecking": "Checking the model list. Model switches do not disable the provider.", + "prov.modelsNoticePending": "The initial model list is not confirmed yet. Models stay hidden until discovery finishes.", + "prov.modelsNoticeOff": "All model switches were turned OFF at registration. Enable the models you want on the Models page.", + "prov.modelsNoticeReady": "Choose which models appear on the Models page. Model switches do not disable the provider.", + "prov.modelsNoticeFailed": "The provider was saved, but the model list could not be refreshed. Try again.", + "prov.modelsNoticeCount": "{count} models", + "prov.modelsNoticeOpen": "Open Models", + "models.initialSelectionPending": "Initial discovery pending", "prov.removeConfirm": "Remove provider \"{name}\"? Its models disappear from Codex's picker.", "prov.hasApiKey": "api key configured", "prov.hasHeaders": "custom headers configured", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index b0f5c3958a..5774c57c90 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -435,6 +435,15 @@ export const fr: Record = { "prov.updateFail": "Impossible de mettre à jour ce fournisseur.", "prov.networkError": "Erreur réseau. Vérifiez que le proxy est en cours d’exécution et réessayez.", "prov.added": "« {name} » ajouté. Déjà actif — exécutez {cmd} (ou redémarrez) pour afficher ses modèles dans le sélecteur de Codex.", + "prov.modelsNoticeTitle": "Choisir les modèles", + "prov.modelsNoticeChecking": "Vérification de la liste des modèles. Les interrupteurs de modèles ne désactivent pas le fournisseur.", + "prov.modelsNoticePending": "La liste initiale n’est pas encore confirmée. Les modèles restent masqués jusqu’à la fin de la découverte.", + "prov.modelsNoticeOff": "Tous les interrupteurs de modèles ont été mis sur OFF à l’inscription. Activez les modèles souhaités sur la page Models.", + "prov.modelsNoticeReady": "Choisissez les modèles affichés sur la page Models. Ces interrupteurs ne désactivent pas le fournisseur.", + "prov.modelsNoticeFailed": "Le fournisseur a été enregistré, mais la liste des modèles n’a pas pu être actualisée. Réessayez.", + "prov.modelsNoticeCount": "{count} modèles", + "prov.modelsNoticeOpen": "Ouvrir Models", + "models.initialSelectionPending": "Découverte initiale en attente", "prov.removeConfirm": "Supprimer le fournisseur « {name} » ? Ses modèles disparaîtront du sélecteur de Codex.", "prov.hasApiKey": "clé API configurée", "prov.hasHeaders": "en-têtes personnalisés configurés", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index d881c4aafb..6541b08d47 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -431,6 +431,15 @@ export const ja: Record = { "prov.updateFail": "このプロバイダーを更新できませんでした。", "prov.networkError": "ネットワークエラーです。プロキシが実行中であることを確認して、もう一度試してください。", "prov.added": "\"{name}\" を追加しました。即時反映 — {cmd} を実行(または再起動)して Codex のピッカーにモデルを一覧表示します。", + "prov.modelsNoticeTitle": "モデル設定の案内", + "prov.modelsNoticeChecking": "モデル一覧を確認しています。モデルのスイッチを切ってもプロバイダーは無効になりません。", + "prov.modelsNoticePending": "初回のモデル一覧をまだ確認できていません。取得が完了するまでモデルの公開を保留します。", + "prov.modelsNoticeOff": "初回登録時にモデルのスイッチをすべて OFF にしました。Models ページで必要なモデルを有効にしてください。", + "prov.modelsNoticeReady": "Models ページで表示するモデルを選択できます。プロバイダー自体を無効にする操作ではありません。", + "prov.modelsNoticeFailed": "プロバイダーは保存しましたが、モデル一覧を更新できませんでした。再試行してください。", + "prov.modelsNoticeCount": "モデル {count} 個", + "prov.modelsNoticeOpen": "Models を開く", + "models.initialSelectionPending": "初回のモデル取得待ち", "prov.removeConfirm": "プロバイダー \"{name}\" を削除しますか? そのモデルは Codex のピッカーから消えます。", "prov.hasApiKey": "API キー設定済み", "prov.hasHeaders": "カスタムヘッダー設定済み", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index c0ff19b3f2..d4e3f44e99 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -434,6 +434,15 @@ export const ko: Record = { "prov.updateFail": "이 프로바이더를 업데이트하지 못했습니다.", "prov.networkError": "네트워크 오류입니다. 프록시가 실행 중인지 확인한 후 다시 시도하세요.", "prov.added": "\"{name}\" 을(를) 추가했습니다. 지금 활성화됨 — Codex 모델 선택기에 표시하려면 {cmd} 를 실행하세요(또는 재시작).", + "prov.modelsNoticeTitle": "모델 설정 안내", + "prov.modelsNoticeChecking": "모델 목록을 확인하고 있습니다. 모델 스위치를 꺼도 프로바이더는 비활성화되지 않습니다.", + "prov.modelsNoticePending": "초기 모델 목록을 아직 확인하지 못했습니다. 조회가 끝날 때까지 모델 노출을 보류합니다.", + "prov.modelsNoticeOff": "처음 등록할 때 모델 스위치를 모두 꺼 두었습니다. 모델 페이지에서 필요한 모델을 켜세요.", + "prov.modelsNoticeReady": "모델 페이지에서 사용할 모델을 켜거나 끌 수 있습니다. 프로바이더 자체를 끄는 것은 아닙니다.", + "prov.modelsNoticeFailed": "프로바이더는 저장했지만 모델 목록을 갱신하지 못했습니다. 다시 시도하세요.", + "prov.modelsNoticeCount": "모델 {count}개", + "prov.modelsNoticeOpen": "모델 페이지로 이동", + "models.initialSelectionPending": "초기 모델 조회 대기", "prov.removeConfirm": "프로바이더 \"{name}\" 을(를) 삭제할까요? 해당 모델이 Codex 선택기에서 사라집니다.", "prov.hasApiKey": "API 키 설정됨", "prov.hasHeaders": "커스텀 헤더 설정됨", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 9d38b2485b..044049398f 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -436,6 +436,15 @@ export const ru: Record = { "prov.updateFail": "Не удалось обновить этого провайдера.", "prov.networkError": "Ошибка сети. Проверьте, что прокси запущен, и повторите попытку.", "prov.added": "Провайдер \"{name}\" добавлен. Уже активен — выполните {cmd} (или перезапустите), чтобы его модели появились в селекторе моделей Codex.", + "prov.modelsNoticeTitle": "Настройка моделей", + "prov.modelsNoticeChecking": "Проверяем список моделей. Переключатели моделей не отключают провайдера.", + "prov.modelsNoticePending": "Начальный список моделей ещё не подтверждён. Модели скрыты до завершения обнаружения.", + "prov.modelsNoticeOff": "При регистрации все переключатели моделей были установлены в OFF. Включите нужные модели на странице Models.", + "prov.modelsNoticeReady": "На странице Models можно выбрать отображаемые модели. Эти переключатели не отключают самого провайдера.", + "prov.modelsNoticeFailed": "Провайдер сохранён, но обновить список моделей не удалось. Повторите попытку.", + "prov.modelsNoticeCount": "Моделей: {count}", + "prov.modelsNoticeOpen": "Открыть Models", + "models.initialSelectionPending": "Ожидание обнаружения моделей", "prov.removeConfirm": "Удалить провайдера \"{name}\"? Его модели исчезнут из селектора моделей Codex.", "prov.hasApiKey": "API-ключ настроен", "prov.hasHeaders": "настроены пользовательские заголовки", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 5b9ad0e3fb..1879d35ab3 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -418,6 +418,15 @@ export const tr: Record = { "prov.loginSameAccount": "Hâlâ aynı {provider} hesabı — tarayıcıda hesap değiştirin, ardından tekrar Hesap Ekle'yi deneyin.", "prov.loginOk": "{provider} hesabına giriş yapıldı. Modellerini listelemek için {cmd} çalıştırın (veya canlı olarak uygulanır).", "prov.added": "\"{name}\" eklendi. Modellerini listelemek için {cmd} çalıştırın (veya canlı olarak uygulanır).", + "prov.modelsNoticeTitle": "Model ayarları", + "prov.modelsNoticeChecking": "Model listesi kontrol ediliyor. Model anahtarları sağlayıcıyı devre dışı bırakmaz.", + "prov.modelsNoticePending": "İlk model listesi henüz doğrulanmadı. Keşif tamamlanana kadar modeller gizli kalır.", + "prov.modelsNoticeOff": "İlk kayıtta tüm model anahtarları OFF olarak ayarlandı. Models sayfasında ihtiyacınız olan modelleri açın.", + "prov.modelsNoticeReady": "Models sayfasında hangi modellerin görüneceğini seçin. Bu anahtarlar sağlayıcının kendisini kapatmaz.", + "prov.modelsNoticeFailed": "Sağlayıcı kaydedildi ancak model listesi yenilenemedi. Tekrar deneyin.", + "prov.modelsNoticeCount": "{count} model", + "prov.modelsNoticeOpen": "Models sayfasını aç", + "models.initialSelectionPending": "İlk model keşfi bekleniyor", "oauthTos.highTitle": "{provider}: abonelik OAuth riski", "oauthTos.elevatedTitle": "{provider}: gayri resmi OAuth köprüsü", "oauthTos.anthropicBody": "Claude abonelik OAuth jetonlarının OpenCodex gibi üçüncü taraf bir proxy üzerinden doğrudan yeniden kullanılması desteklenen bir Anthropic entegrasyonu değildir ve erişim kısıtlamalarına yol açabilir. Claude aboneliklerini kullanan desteklenen Agent SDK entegrasyonları ayrıdır.", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 915bacc6f8..dfc2acebda 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -323,6 +323,15 @@ export const zhTW: Record = { "prov.removed": "已移除 \"{name}\"。", "prov.removeFail": "移除 \"{name}\" 失敗。", "prov.added": "已新增 \"{name}\"。現已生效 — 執行 {cmd}(或重新啟動)以在 Codex 選擇器中列出其模型。", + "prov.modelsNoticeTitle": "模型設定提示", + "prov.modelsNoticeChecking": "正在檢查模型清單。關閉模型開關不會停用供應商。", + "prov.modelsNoticePending": "尚未確認初始模型清單。在探索完成之前,暫不公開模型。", + "prov.modelsNoticeOff": "首次註冊時已關閉所有模型開關。請在模型頁面啟用需要的模型。", + "prov.modelsNoticeReady": "可在模型頁面選擇要顯示的模型。模型開關不會停用供應商本身。", + "prov.modelsNoticeFailed": "供應商已儲存,但無法更新模型清單。請重試。", + "prov.modelsNoticeCount": "{count} 個模型", + "prov.modelsNoticeOpen": "開啟模型頁面", + "models.initialSelectionPending": "等待初始模型探索", "prov.removeConfirm": "移除供應商 \"{name}\"?其模型將從 Codex 選擇器中消失。", "prov.hasApiKey": "已配置 API 金鑰", "prov.hasHeaders": "已配置自訂請求標頭", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 0761f05e51..a79161cdba 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -431,6 +431,15 @@ export const zh: Record = { "prov.updateFail": "无法更新此提供方。", "prov.networkError": "网络错误。请确认代理正在运行后重试。", "prov.added": "已添加 \"{name}\"。现已生效 — 运行 {cmd}(或重启)以在 Codex 选择器中列出其模型。", + "prov.modelsNoticeTitle": "模型设置提示", + "prov.modelsNoticeChecking": "正在检查模型列表。关闭模型开关不会停用提供者。", + "prov.modelsNoticePending": "尚未确认初始模型列表。在发现完成之前,模型暂不公开。", + "prov.modelsNoticeOff": "首次注册时已关闭所有模型开关。请在模型页面启用需要的模型。", + "prov.modelsNoticeReady": "可在模型页面选择显示哪些模型。模型开关不会停用提供者本身。", + "prov.modelsNoticeFailed": "提供者已保存,但无法刷新模型列表。请重试。", + "prov.modelsNoticeCount": "{count} 个模型", + "prov.modelsNoticeOpen": "打开模型页面", + "models.initialSelectionPending": "等待初始模型发现", "prov.removeConfirm": "移除提供方 \"{name}\"?其模型将从 Codex 选择器中消失。", "prov.hasApiKey": "已配置 API 密钥", "prov.hasHeaders": "已配置自定义请求头", diff --git a/gui/src/pages/Models.tsx b/gui/src/pages/Models.tsx index d849780e49..91971cf54d 100644 --- a/gui/src/pages/Models.tsx +++ b/gui/src/pages/Models.tsx @@ -1207,10 +1207,11 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; // An empty provider has nothing to send: keep both bulk buttons inert so we never PUT an // empty target list (the management API rejects it with 400). const hasRows = rows.length > 0; + const selectionPending = rows.some(model => model.initialSelectionPending); const allOn = !hasRows || rows.every(isVisible); const allOff = !hasRows || rows.every(m => !isVisible(m)); const bulkToggle = (enable: boolean) => { - if (!hasRows) return; + if (!hasRows || selectionPending) return; void applyVisibility( "provider", provider, @@ -1298,7 +1299,7 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; background: preset.mode === mode ? undefined : "transparent", color: preset.mode === mode ? undefined : "var(--muted)", }} - disabled={busy || busyHere} + disabled={busy || busyHere || selectionPending} onClick={(e) => { e.stopPropagation(); // Switching from a custom selection destroys it, so confirm first. @@ -1339,8 +1340,8 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; ); })()} - - + +
{/* The label names the FUNCTION. It used to be `models.capValue` - "기본 128k" - which is a value masquerading as a name: even a @@ -1459,7 +1460,8 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; }} >
- void applyVisibility("models", provider, [{ id: m.id, native: m.native === true }], off)} disabled={busy} label={m.native ? m.id : m.namespaced} /> + void applyVisibility("models", provider, [{ id: m.id, native: m.native === true }], off)} disabled={busy || m.initialSelectionPending} label={m.native ? m.id : m.namespaced} /> + {m.initialSelectionPending && {t("models.initialSelectionPending")}} {aliases.models[provider]?.[m.id] && {aliases.models[provider][m.id].alias}} {m.native ? modelLabel(m.id) : formatNamespacedModelId(m.namespaced, t)} {aliases.models[provider]?.[m.id]?.source === "builtin" && {t("models.aliasAuto")}} diff --git a/gui/src/pages/Providers.tsx b/gui/src/pages/Providers.tsx index 47668f010e..6dfff66723 100644 --- a/gui/src/pages/Providers.tsx +++ b/gui/src/pages/Providers.tsx @@ -20,6 +20,8 @@ import { useProvidersFetch } from "./use-providers-fetch"; import { ProvidersPageModals } from "./providers-page-modals"; import { buildAccountLoginStatus, buildAddModalAccountRows } from "./providers-page-utils"; import type { CodexAccountMutationCompletion } from "../codex-account-mutation"; +import { useProviderModelsNotice } from "./use-provider-models-notice"; +import { navigateHash } from "../hash-routing"; export default function Providers({ apiBase }: { apiBase: string }) { const t = useT(); @@ -155,11 +157,18 @@ export default function Providers({ apiBase }: { apiBase: string }) { quotaRefreshWaiters.current = []; for (const resolve of waiters) resolve(ok); }, []); - const { fetchConfig, fetchOauth, fetchProviderQuotas } = useProvidersFetch({ + const { fetchConfig: refreshConfigResult, fetchOauth, fetchProviderQuotas } = useProvidersFetch({ apiBase, t, setConfig, setOauthProviders, setOauthStatus, notify, invalidateProviderQuotas, configCacheKey, }); + const fetchConfig = useCallback(async () => { await refreshConfigResult(); }, [refreshConfigResult]); + const modelsNotice = useProviderModelsNotice(apiBase, refreshConfigResult); + const openModelsNotice = modelsNotice.open; + const onProviderLoginSettled = useCallback((provider: string) => { + revealProviderAccounts(provider); + openModelsNotice(provider, false); + }, [revealProviderAccounts, openModelsNotice]); // WP3: one Codex account controller for the whole Providers page, shared by the // Overview tab and the Accounts tab so a mutation on either is instantly visible on @@ -204,7 +213,10 @@ export default function Providers({ apiBase }: { apiBase: string }) { const jsonEditor = useJsonConfigEditor({ apiBase, config, notify, - fetchConfig, fetchProviderQuotas, onSaved: () => setModelsRefreshToken(n => n + 1), + fetchConfig, fetchProviderQuotas, onSaved: added => { + if (added.length) modelsNotice.open(added, true); + setModelsRefreshToken(n => n + 1); + }, t: t as unknown as Parameters[0]["t"], }); const { @@ -266,7 +278,7 @@ export default function Providers({ apiBase }: { apiBase: string }) { apiBase, t, aliveRef, accountSets, setAccountSets, setBusy, setStatus, setLoginInfo, setOauthStatus, notify, fetchConfig, fetchOauth, fetchAccountSets, fetchProviderQuotas, bumpModelsRefresh, - onLoginSettled: revealProviderAccounts, + onLoginSettled: onProviderLoginSettled, }); const { removeProvider, confirmRemoveProvider, setProviderDisabled, setDefaultProvider, updateProvider } = useProvidersCrud({ @@ -390,6 +402,7 @@ export default function Providers({ apiBase }: { apiBase: string }) { }} jsonSaving={jsonSaving} modelsRefreshToken={modelsRefreshToken} + onModelsSettled={modelsNotice.modelsSettled} activeAccountNeedsReauth={activeAccountNeedsReauth} quotaRefreshEpoch={quotaRefresh.epoch} quotaForceRefresh={quotaRefresh.force} @@ -451,6 +464,25 @@ export default function Providers({ apiBase }: { apiBase: string }) { apiBase={apiBase} config={config} adding={adding} + modelsNotice={modelsNotice.notice ? { + provider: modelsNotice.notice.context.provider, + initialRegistration: modelsNotice.notice.context.initialRegistration, + catalogRefreshPending: modelsNotice.notice.context.catalogRefreshPending, + loading: modelsNotice.notice.loading, + failed: modelsNotice.notice.failed, + providerKnown: modelsNotice.notice.context.providers.every(name => !!config.providers[name]), + selection: modelsNotice.notice.context.providers.length === 1 + ? config.providers[modelsNotice.notice.context.provider]?.initialModelSelection + : modelsNotice.notice.context.providers.some(name => config.providers[name]?.initialModelSelection?.status === "pending") + ? { status: "pending" } : undefined, + onClose: modelsNotice.close, + onOpenModels: () => { modelsNotice.close(); navigateHash("models"); }, + onRetry: () => { + const current = modelsNotice.notice!.context; + modelsNotice.open(current.providers, current.initialRegistration, current.catalogRefreshPending); + bumpModelsRefresh(); + }, + } : null} addIntent={addIntent} busy={busy} addModalAccountRows={addModalAccountRows} @@ -472,7 +504,8 @@ export default function Providers({ apiBase }: { apiBase: string }) { onAdded={(name) => { setAdding(false); setAddIntent(null); - notify(t("prov.added", { name, cmd: "ocx sync" }), true); + clearStatus(); + modelsNotice.open(name, !config.providers[name]); fetchConfig(); fetchOauth(); fetchProviderQuotas(true); @@ -487,6 +520,7 @@ export default function Providers({ apiBase }: { apiBase: string }) { onCodexAdded={(completion) => { setCodexLoginOpen(false); notifyCodexCompletion(completion); + modelsNotice.open("openai", !config.providers.openai, completion.catalogRefreshPending); void fetchConfig(); void fetchOauth(); void fetchProviderQuotas(true); diff --git a/gui/src/pages/models-shared.ts b/gui/src/pages/models-shared.ts index 6e1f463db7..1575a52ac9 100644 --- a/gui/src/pages/models-shared.ts +++ b/gui/src/pages/models-shared.ts @@ -30,6 +30,7 @@ export interface ModelRow { id: string; namespaced: string; disabled: boolean; + initialSelectionPending?: boolean; native?: boolean; custom?: boolean; customId?: string; diff --git a/gui/src/pages/providers-page-modals.tsx b/gui/src/pages/providers-page-modals.tsx index 051c3d2ee6..ba5964675e 100644 --- a/gui/src/pages/providers-page-modals.tsx +++ b/gui/src/pages/providers-page-modals.tsx @@ -1,4 +1,5 @@ import AddProviderModal from "../components/AddProviderModal"; +import ProviderModelsNotice, { type ProviderModelsNoticeProps } from "../components/ProviderModelsNotice"; import AddCodexAccountModal from "../components/AddCodexAccountModal"; import OAuthTosWarningModal from "../components/OAuthTosWarningModal"; import { RemoveConfirmDialog, UnsavedLeaveDialog } from "../components/provider-workspace/ProviderDialogs"; @@ -12,6 +13,7 @@ export function ProvidersPageModals({ apiBase, config, adding, + modelsNotice, addIntent, busy, addModalAccountRows, @@ -43,6 +45,7 @@ export function ProvidersPageModals({ apiBase: string; config: ProvidersConfig; adding: boolean; + modelsNotice?: ProviderModelsNoticeProps | null; addIntent: AddProviderIntent | null; busy: string | null; addModalAccountRows: AccountLoginRow[]; @@ -73,6 +76,7 @@ export function ProvidersPageModals({ }) { return ( <> + {modelsNotice && } {adding && ( Promise) { + const [state, setState] = useState<{ apiBase: string; notice: Notice | null }>({ apiBase, notice: null }); + if (state.apiBase !== apiBase) setState({ apiBase, notice: null }); + const active = useRef(null); + useEffect(() => () => { active.current = null; }, [apiBase]); + const open = useCallback((provider: string | readonly string[], initialRegistration: boolean, catalogRefreshPending = false) => { + const providers = typeof provider === "string" ? [provider] : provider; + const context = { provider: providers.join(", "), providers, apiBase, initialRegistration: initialRegistration && providers.length === 1, catalogRefreshPending }; + active.current = context; + setState({ apiBase, notice: { context, loading: true, failed: false } }); + }, [apiBase]); + const close = useCallback(() => { active.current = null; setState(current => ({ ...current, notice: null })); }, []); + const modelsSettled = useCallback((ok: boolean) => { + const context = active.current; + if (!context || context.apiBase !== apiBase) return; + void refreshConfig().then(async result => { + // One newer config request may supersede this one; retry once, never poll. + if (result === "superseded" && active.current === context) result = await refreshConfig(); + if (active.current === context) setState(current => current.apiBase === context.apiBase + ? { ...current, notice: { context, loading: false, failed: !ok || result !== "applied" } } : current); + }).catch(() => { + if (active.current === context) setState(current => current.apiBase === context.apiBase + ? { ...current, notice: { context, loading: false, failed: true } } : current); + }); + }, [apiBase, refreshConfig]); + return { notice: state.apiBase === apiBase ? state.notice : null, open, close, modelsSettled }; +} diff --git a/gui/src/pages/use-providers-fetch.ts b/gui/src/pages/use-providers-fetch.ts index b310731d2f..5b7d8632eb 100644 --- a/gui/src/pages/use-providers-fetch.ts +++ b/gui/src/pages/use-providers-fetch.ts @@ -1,8 +1,9 @@ -import { useCallback } from "react"; +import { useCallback, useEffect, useRef } from "react"; import type { TFn } from "../i18n/shared"; import { readJsonIfOk, readJsonOrThrow } from "../fetch-json"; import { writeSessionListCache } from "../session-list-cache"; import type { OAuthStatus, ProvidersConfig } from "./providers-shared"; +export type ProvidersConfigRefreshResult = "applied" | "failed" | "superseded"; export function useProvidersFetch({ apiBase, @@ -25,14 +26,22 @@ export function useProvidersFetch({ /** Session seed key for instant Providers shell paint (no secrets — hasApiKey flags only). */ configCacheKey?: string; }) { - const fetchConfig = useCallback(async () => { + const configRequest = useRef(0); + useEffect(() => () => { configRequest.current += 1; }, [apiBase]); + const fetchConfig = useCallback(async (): Promise => { + const request = ++configRequest.current; try { const res = await fetch(`${apiBase}/api/config`); const data = await readJsonOrThrow(res); + if (request !== configRequest.current) return "superseded"; + if (!data) throw new Error("config response missing"); setConfig(data ?? null); if (configCacheKey && data) writeSessionListCache(configCacheKey, data); + return "applied"; } catch { + if (request !== configRequest.current) return "superseded"; notify(t("prov.loadConfigFail"), false); + return "failed"; } }, [apiBase, configCacheKey, notify, setConfig, t]); diff --git a/gui/tests/models-empty-provider.test.tsx b/gui/tests/models-empty-provider.test.tsx index 296c85d2b1..85843f64b4 100644 --- a/gui/tests/models-empty-provider.test.tsx +++ b/gui/tests/models-empty-provider.test.tsx @@ -130,10 +130,11 @@ test("Models page combines final visibility, atomic actions, discovery status, a }; let failNext = false; let failCatalog = false; + let initialSelectionPending = false; let modelFetches = 0; let resolveModels!: (response: Response) => void; const firstModels = new Promise(resolve => { resolveModels = resolve; }); - const rows = () => ids.map(id => ({ provider, id, namespaced: `${provider}/${id}`, disabled: disabled.has(id) })); + const rows = () => ids.map(id => ({ provider, id, namespaced: `${provider}/${id}`, disabled: initialSelectionPending || disabled.has(id), ...(initialSelectionPending ? { initialSelectionPending: true } : {}) })); testWindow.sessionStorage.setItem("ocx.models.catalog.v1:http://localhost", JSON.stringify({ models: rows(), providers: [{ name: provider, liveModels: true, models: ids }], @@ -491,6 +492,12 @@ test("Models page combines final visibility, atomic actions, discovery status, a await act(async () => { poll(); await new Promise(resolve => testWindow.setTimeout(resolve, 0)); }); expect(container.textContent).toContain("fallback-provider"); expect(container.textContent).toContain("Failed to load models"); + failCatalog = false; + initialSelectionPending = true; + await act(async () => { poll(); await new Promise(resolve => testWindow.setTimeout(resolve, 0)); }); + expect(container.textContent).toContain("Initial discovery pending"); + expect(switchFor("gemini-pro").disabled).toBe(true); + expect(buttonText("All on").disabled).toBe(true); } finally { if (root) { await act(async () => root?.unmount()); diff --git a/gui/tests/provider-models-notice.test.tsx b/gui/tests/provider-models-notice.test.tsx new file mode 100644 index 0000000000..b69007ab09 --- /dev/null +++ b/gui/tests/provider-models-notice.test.tsx @@ -0,0 +1,149 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act, useState, type ReactNode } from "react"; +import type { Root } from "react-dom/client"; +import ProviderModelsNotice, { type ProviderModelsNoticeProps } from "../src/components/ProviderModelsNotice"; +import { LanguageProvider } from "../src/i18n/provider"; +import { useProviderModelsNotice } from "../src/pages/use-provider-models-notice"; +import { useProvidersFetch } from "../src/pages/use-providers-fetch"; +import type { ProvidersConfig } from "../src/pages/providers-shared"; + +const keys = ["window", "document", "navigator", "localStorage", "sessionStorage", "fetch", "IS_REACT_ACT_ENVIRONMENT"] as const; +let saved: Record; +let win: Window; +let host: HTMLElement; +let root: Root | null; + +beforeEach(() => { + saved = Object.fromEntries(keys.map(key => [key, Reflect.get(globalThis, key)])); + win = new Window({ url: "http://localhost/#providers" }); + win.localStorage.setItem("ocx-lang", "en"); + for (const key of ["window", "document", "navigator", "localStorage", "sessionStorage"] as const) { + Object.defineProperty(globalThis, key, { configurable: true, value: key === "window" ? win : win[key] }); + } + Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { configurable: true, value: true }); + host = win.document.createElement("div") as unknown as HTMLElement; + win.document.body.appendChild(host as never); + root = null; +}); +afterEach(async () => { + if (root) await act(async () => { root?.unmount(); }); + await win.happyDOM.close(); + for (const key of keys) Object.defineProperty(globalThis, key, { configurable: true, value: saved[key] }); +}); +async function render(node: ReactNode) { + const { createRoot } = await import("react-dom/client"); + await act(async () => { root ??= createRoot(host); root.render(node); }); +} +function button(label: string): HTMLButtonElement { + const found = [...host.querySelectorAll("button")].find(node => node.textContent === label); + if (!found) throw new Error(`missing button ${label}`); + return found; +} + +test("all-OFF notice has keyboard navigation, explicit actions and focus restoration", async () => { + const trigger = win.document.createElement("button"); + win.document.body.appendChild(trigger); + trigger.focus(); + let closed = 0, opened = 0; + const props: ProviderModelsNoticeProps = { + provider: "openrouter", loading: false, failed: false, providerKnown: true, initialRegistration: true, + selection: { status: "all-off", modelCount: 20 }, onClose: () => { closed++; }, onOpenModels: () => { opened++; }, + }; + await render(); + expect(host.querySelector('[role="dialog"]')?.getAttribute("aria-modal")).toBe("true"); + expect(host.textContent).toContain("turned OFF at registration"); + expect(host.textContent).toContain("20 models"); + expect(win.document.activeElement as unknown).toBe(button("Open Models")); + button("Open Models").dispatchEvent(new win.KeyboardEvent("keydown", { key: "Tab", bubbles: true, cancelable: true }) as never); + expect(win.document.activeElement as unknown).toBe(button("Close")); + button("Close").dispatchEvent(new win.KeyboardEvent("keydown", { key: "Tab", shiftKey: true, bubbles: true, cancelable: true }) as never); + expect(win.document.activeElement as unknown).toBe(button("Open Models")); + button("Open Models").click(); + expect(opened).toBe(1); + button("Open Models").dispatchEvent(new win.KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }) as never); + expect(closed).toBe(1); + await act(async () => { root!.unmount(); root = null; }); + expect(win.document.activeElement).toBe(trigger); +}); + +test("pending/error recovery and generic OAuth/re-login copy stay truthful", async () => { + let retried = 0; + const props: ProviderModelsNoticeProps = { + provider: "xai", loading: false, failed: false, providerKnown: true, initialRegistration: false, + selection: { status: "pending" }, onClose: () => {}, onOpenModels: () => {}, onRetry: () => { retried++; }, + }; + await render(); + expect(host.textContent).toContain("not confirmed yet"); + button("Retry").click(); + expect(retried).toBe(1); + await render(); + expect(host.textContent).toContain("was saved"); + await render(); + expect(host.textContent).not.toContain("turned OFF at registration"); + expect(host.textContent).not.toContain("20 models"); + expect(host.textContent).toContain("Choose which models appear"); + expect(host.textContent).toContain("ocx sync"); +}); + +test("notice waits for post-discovery config refresh and ignores closed/superseded operations", async () => { + let controller: ReturnType; + const gates: Array<() => void> = []; + const refresh = () => new Promise<"applied">(resolve => gates.push(() => resolve("applied"))); + function Harness() { controller = useProviderModelsNotice("/notice", refresh); return null; } + await render(); + await act(async () => { controller!.open("one", true); }); + await act(async () => { controller!.modelsSettled(true); }); + expect(controller!.notice?.loading).toBe(true); + await act(async () => { gates.shift()!(); await Promise.resolve(); }); + expect(controller!.notice?.loading).toBe(false); + await act(async () => { controller!.modelsSettled(false); controller!.close(); }); + await act(async () => { gates.shift()!(); await Promise.resolve(); }); + expect(controller!.notice).toBeNull(); + await act(async () => { controller!.open("old", true); controller!.modelsSettled(true); controller!.open("new", true); }); + await act(async () => { gates.shift()!(); await Promise.resolve(); }); + expect(controller!.notice?.context.provider).toBe("new"); + expect(controller!.notice?.loading).toBe(true); +}); + +test("returning to an API target does not reopen its old notice", async () => { + let controller: ReturnType; + const refresh = async () => "applied" as const; + function Harness({ base }: { base: string }) { controller = useProviderModelsNotice(base, refresh); return null; } + await render(); + await act(async () => { controller!.open("old", true); }); + await render(); + expect(controller!.notice).toBeNull(); + await render(); + expect(controller!.notice).toBeNull(); +}); + +test("failed config refresh is not announced as successful model setup", async () => { + let controller: ReturnType; + function Harness() { controller = useProviderModelsNotice("/failed", async () => "failed"); return null; } + await render(); + await act(async () => { controller!.open("vendor", true); }); + await act(async () => { controller!.modelsSettled(true); await Promise.resolve(); }); + expect(controller!.notice?.loading).toBe(false); + expect(controller!.notice?.failed).toBe(true); +}); + +test("an older pending config response cannot overwrite the newer completed snapshot", async () => { + let loader: ReturnType; + const observed: { config: ProvidersConfig | null } = { config: null }; + const responses: Array<(response: Response) => void> = []; + Object.defineProperty(globalThis, "fetch", { configurable: true, value: () => new Promise(resolve => responses.push(resolve)) }); + function Harness() { + const [config, setConfig] = useState(null); + observed.config = config; + loader = useProvidersFetch({ apiBase: "/fresh", t: key => key, setConfig, setOauthProviders: () => {}, setOauthStatus: () => {}, notify: () => {}, invalidateProviderQuotas: () => {} }); + return null; + } + await render(); + const first = loader!.fetchConfig(); + const second = loader!.fetchConfig(); + const snapshot = (status: string) => ({ port: 0, defaultProvider: "vendor", providers: { vendor: { adapter: "openai-chat", baseUrl: "https://example.test", initialModelSelection: { status } } } }); + await act(async () => { responses[1]!(Response.json(snapshot("all-off"))); await second; }); + await act(async () => { responses[0]!(Response.json(snapshot("pending"))); await first; }); + expect(observed.config?.providers.vendor.initialModelSelection?.status).toBe("all-off"); +}); diff --git a/gui/tests/providers-codex-completion-toast.test.tsx b/gui/tests/providers-codex-completion-toast.test.tsx index 92d8eaf9d2..1852f8884f 100644 --- a/gui/tests/providers-codex-completion-toast.test.tsx +++ b/gui/tests/providers-codex-completion-toast.test.tsx @@ -5,6 +5,7 @@ import type { Root } from "react-dom/client"; import { clearClientResourceStoresForTests } from "../src/client-resource"; import { LanguageProvider } from "../src/i18n/provider"; import Providers from "../src/pages/Providers"; +import CodexAccountPool from "../src/components/CodexAccountPool"; const globals = [ "document", @@ -203,6 +204,7 @@ test("pending Codex completion stays amber, private, dismissible, and refreshes const warning = testWindow.document.querySelector(".toast-notice.notice-warn"); expect(warning).toBeTruthy(); expect(warning!.textContent).toContain("The change was saved"); + expect(host.querySelector('[role="dialog"]')?.textContent).toContain("Choose models"); expect(warning!.textContent).toContain("ocx sync"); expect(testWindow.document.body.textContent).not.toContain("private-account-detail"); expect(pathCount("/api/config")).toBeGreaterThan(before.config); @@ -232,3 +234,31 @@ test("completed Codex catalog convergence reports clean success without sync adv expect(success!.textContent).not.toContain("ocx sync"); expect(testWindow.document.querySelector(".toast-notice.notice-warn")).toBeNull(); }); + +for (const embedded of [false, true]) { + test(`Codex pool completion opens Models guidance (embedded=${embedded})`, async () => { + const { createRoot } = await import("react-dom/client"); + await act(async () => { + root = createRoot(host); + root.render(); + }); + await flush(); + await flush(); + await act(async () => { buttonWithText(host, "Add").click(); }); + await flush(); + const login = testWindow.document.querySelector('dialog[aria-label="Add Codex Account"] button.list-row') as HTMLButtonElement; + expect(login).toBeTruthy(); + await act(async () => { login.click(); }); + await flush(); + await act(async () => { jest.advanceTimersByTime(2_000); await Promise.resolve(); }); + await flush(); + await flush(); + const notice = host.querySelector('[role="dialog"]'); + expect(notice?.textContent).toContain("Choose models"); + expect(notice?.textContent).toContain("ocx sync"); + expect(notice?.textContent).not.toContain("All model switches were turned OFF"); + await act(async () => { buttonWithText(notice!, "Open Models").click(); }); + expect(testWindow.location.hash).toBe("#models"); + expect(host.querySelector('[role="dialog"]')).toBeNull(); + }); +} diff --git a/gui/tests/use-json-config-editor.test.tsx b/gui/tests/use-json-config-editor.test.tsx index 787db615e1..3619aed8f9 100644 --- a/gui/tests/use-json-config-editor.test.tsx +++ b/gui/tests/use-json-config-editor.test.tsx @@ -23,6 +23,7 @@ const config: Config = { allowPrivateNetwork: true, hasApiKey: true, hasHeaders: true, + initialModelSelection: { version: 1, registrationId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", status: "pending" }, note: "derived registry note", }, beta: { @@ -45,6 +46,7 @@ let responseFactory: () => Promise; let configRefreshes: number; let quotaRefreshes: number; let savedCallbacks: number; +let addedProviderNames: string[]; let notifications: Array<{ message: string; ok?: boolean }>; function Harness() { @@ -54,7 +56,7 @@ function Harness() { notify: (message, ok) => { notifications.push({ message, ok }); }, fetchConfig: async () => { configRefreshes += 1; }, fetchProviderQuotas: async () => { quotaRefreshes += 1; }, - onSaved: () => { savedCallbacks += 1; }, + onSaved: added => { savedCallbacks += 1; addedProviderNames = added; }, t: key => key, }); return null; @@ -84,6 +86,7 @@ beforeEach(() => { configRefreshes = 0; quotaRefreshes = 0; savedCallbacks = 0; + addedProviderNames = []; notifications = []; responseFactory = async () => Response.json({ success: true }); globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { @@ -153,6 +156,17 @@ test("Save sends one atomic provider PUT with baseline and next, then refreshes" expect(savedCallbacks).toBe(1); }); +test("successful batch registration reports new names for model-selection guidance", async () => { + await mountHook(); + await act(async () => { editor!.openJsonEditor(); }); + const next = JSON.parse(editor!.draft); + next.providers.gamma = { adapter: "openai-chat", baseUrl: "https://gamma.example.test/v1" }; + await act(async () => { editor!.setDraft(JSON.stringify(next)); }); + await act(async () => { expect(await editor!.saveConfig()).toBe(true); }); + expect(addedProviderNames).toEqual(["gamma"]); + expect(savedCallbacks).toBe(1); +}); + test("parse failures stay distinct from server failures and failed saves do not refresh", async () => { await mountHook(); await act(async () => { editor!.openJsonEditor(); }); diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index da6f5909b8..7bf5d8ebcd 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -825,6 +825,8 @@ "native-profile-startup.test.ts": "codex-integration", "native-profile-store.test.ts": "codex-integration", "new-model-policy.test.ts": "providers", + "initial-model-selection.test.ts": "providers", + "model-selection-guidance.test.ts": "cli", "nous-oauth-live.test.ts": "providers", "nous-oauth.test.ts": "providers", "novita-provider.test.ts": "providers", diff --git a/src/cli/account-auth.ts b/src/cli/account-auth.ts index 7ca6b04f41..f807659cb8 100644 --- a/src/cli/account-auth.ts +++ b/src/cli/account-auth.ts @@ -1,4 +1,5 @@ import { writeSync } from "node:fs"; +import { modelSelectionGuidance, modelSelectionNextSteps } from "./model-selection-guidance"; import { warnIfCodexCatalogRefreshPending } from "./account-catalog-refresh"; import { isCodexResetCreditOperationId } from "../codex/reset-credit-recovery"; import { @@ -137,7 +138,7 @@ async function login(argv: string[], deps: RuntimeApiDeps): Promise { }, deps); } if (noWait) { - if (wantsJson) printData(start, true); + printData({ ...start, modelSelection: modelSelectionNextSteps("openai", true) }, wantsJson, modelSelectionGuidance("openai", true)); return; } if (!start.flowId) throw new CliUsageError("login did not return a flow id"); @@ -153,7 +154,7 @@ async function login(argv: string[], deps: RuntimeApiDeps): Promise { {}, deps, ); if (state.status === "done") { - printData(state, wantsJson, [`Logged in${state.email ? ` as ${String(state.email)}` : ""}.`]); + printData({ ...state, modelSelection: modelSelectionNextSteps("openai") }, wantsJson, [`Logged in${state.email ? ` as ${String(state.email)}` : ""}.`, ...modelSelectionGuidance("openai")]); if (!wantsJson) warnIfCodexCatalogRefreshPending(state); return; } @@ -184,7 +185,7 @@ async function login(argv: string[], deps: RuntimeApiDeps): Promise { }, deps); } if (noWait) { - if (wantsJson) printData(start, true); + printData({ ...start, modelSelection: modelSelectionNextSteps(provider, true) }, wantsJson, modelSelectionGuidance(provider, true)); return; } for (let attempt = 0; attempt < 100; attempt++) { @@ -192,7 +193,7 @@ async function login(argv: string[], deps: RuntimeApiDeps): Promise { const state = await runtimeRequest>(`/api/oauth/status?provider=${encodeURIComponent(provider)}`, {}, deps); if (state.error) throw new CliUsageError(String(state.error)); if (state.loggedIn === true) { - printData(state, wantsJson, [`Logged in to ${provider}.`]); + printData({ ...state, modelSelection: modelSelectionNextSteps(provider) }, wantsJson, [`Logged in to ${provider}.`, ...modelSelectionGuidance(provider)]); return; } } diff --git a/src/cli/init.ts b/src/cli/init.ts index be3f0ac8b6..72ad3c1b70 100644 --- a/src/cli/init.ts +++ b/src/cli/init.ts @@ -1,4 +1,6 @@ import * as readline from "node:readline"; +import { modelSelectionGuidance } from "./model-selection-guidance"; +import { initializeProviderModelSelection } from "../providers/initial-model-selection"; import { existsSync, readFileSync, unlinkSync } from "node:fs"; import { injectCodexConfig } from "../codex/inject"; import { classifyOpenAiTierBackup, getConfigPath, getDefaultConfig, isValidProviderName, preserveOpenAiTierRollbackSnapshot, saveConfig } from "../config"; @@ -160,6 +162,7 @@ export async function runInit(): Promise { const portStr = await prompt.ask("\nProxy port [10100]: "); const port = parseInt(portStr, 10) || 10100; + initializeProviderModelSelection(providerName, providerConfig); const config: OcxConfig = { ...getDefaultConfig(), port, @@ -198,6 +201,7 @@ export async function runInit(): Promise { } console.log(`\n🚀 Setup complete! Run 'ocx start' to start the proxy.`); + for (const line of modelSelectionGuidance(providerName)) console.log(line); } catch (error) { const message = error instanceof Error ? error.message : String(error); if (/stdin (closed|reached EOF)/i.test(message)) { diff --git a/src/cli/model-selection-guidance.ts b/src/cli/model-selection-guidance.ts new file mode 100644 index 0000000000..6802c1758f --- /dev/null +++ b/src/cli/model-selection-guidance.ts @@ -0,0 +1,30 @@ +/** Existing model-management commands; use the exact ID from `live`, including native IDs. */ +export function modelSelectionNextSteps(provider: string, afterLogin = false) { + const name = provider === "codex" || provider === "chatgpt" ? "openai" : provider; + return { + provider: name, + afterLogin, + requiresRunningProxy: true, + commands: { + list: `ocx models live --provider ${name}`, + enable: 'ocx models enable ""', + disable: 'ocx models disable ""', + enableNative: 'ocx models enable "" --native', + disableNative: 'ocx models disable "" --native', + enableAll: `ocx models provider ${name} on`, + disableAll: `ocx models provider ${name} off`, + }, + }; +} + +export function modelSelectionGuidance(provider: string, afterLogin = false): string[] { + const next = modelSelectionNextSteps(provider, afterLogin); + return [ + afterLogin ? "After login completes, manage model switches with:" : "Manage model switches (the provider stays active):", + " Start the proxy first if needed: ocx start", + " Replace with an exact ID printed by the list command.", + " For rows marked native, use the --native variants (including IDs containing /).", + ...Object.values(next.commands).map(command => ` ${command}`), + " If initial discovery is still pending, check the provider connection and retry: ocx sync", + ]; +} diff --git a/src/cli/models-runtime.ts b/src/cli/models-runtime.ts index a2fc07ed03..e21fa25d9e 100644 --- a/src/cli/models-runtime.ts +++ b/src/cli/models-runtime.ts @@ -36,6 +36,7 @@ type ModelRow = { namespaced?: string; native?: boolean; disabled?: boolean; + initialSelectionPending?: boolean; custom?: boolean; customId?: string; displayName?: string; @@ -49,7 +50,7 @@ async function live(argv: string[], deps: RuntimeApiDeps): Promise { const rows = await runtimeRequest("/api/models", {}, deps); const filtered = provider ? rows.filter(row => row.provider === provider) : rows; printData(filtered, wantsJson, filtered.map(row => { - const flags = [row.native ? "native" : "routed", row.custom ? "custom" : "", row.disabled ? "disabled" : "enabled"].filter(Boolean); + const flags = [row.native ? "native" : "routed", row.custom ? "custom" : "", row.initialSelectionPending ? "initial discovery pending" : row.disabled ? "disabled" : "enabled"].filter(Boolean); return `${row.namespaced ?? `${row.provider}/${row.id}`} [${flags.join(", ")}]`; })); } diff --git a/src/cli/provider.ts b/src/cli/provider.ts index f9ac3b5b21..6795b3db52 100644 --- a/src/cli/provider.ts +++ b/src/cli/provider.ts @@ -18,6 +18,7 @@ import type { OcxProviderConfig } from "../types"; import { findLiveProxy } from "../server/proxy-liveness"; import { syncModelsToCodex } from "../codex/sync"; import { codexAccountNamespaceProviderCollisionError } from "../codex/account-namespace-match"; +import { modelSelectionGuidance, modelSelectionNextSteps } from "./model-selection-guidance"; // --------------------------------------------------------------------------- // Arg helpers @@ -212,6 +213,8 @@ async function handleAdd(args: string[]): Promise { } const existingProvider = config.providers[name]; + const { initializeProviderModelSelection } = await import("../providers/initial-model-selection"); + initializeProviderModelSelection(name, provConfig, existingProvider, config); config.providers[name] = provConfig; // A --force overwrite rotates the key/endpoint but must not drop a // user-configured price overlay (same rule as the /api/providers path and @@ -227,6 +230,7 @@ async function handleAdd(args: string[]): Promise { if (wantsJson) { console.log(JSON.stringify({ action: "added", + modelSelection: modelSelectionNextSteps(name), provider: name, adapter: provConfig.adapter, baseUrl: provConfig.baseUrl, @@ -255,6 +259,7 @@ async function handleAdd(args: string[]): Promise { const registryLabel = registryEntry ? ` (${registryEntry.label})` : ""; console.log(`✅ Provider "${name}"${registryLabel} added.`); + for (const line of modelSelectionGuidance(name)) console.log(line); if (setDefault) console.log(` Set as default provider.`); if (registryEntry?.authKind === "oauth") { console.log(` Authenticate with: ocx login ${name}`); diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index fa7e0b4b00..f810bfb422 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -1,4 +1,5 @@ import { effectiveProviderAlias, effectiveProviderAliasDecision } from "../../providers/default-aliases"; +import { initialModelSelectionPending } from "../../providers/initial-model-selection"; import { execFileSync } from "node:child_process"; import { createHash, createHmac, randomBytes } from "node:crypto"; import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync } from "node:fs"; @@ -2016,6 +2017,7 @@ export function filterCatalogVisibleModels( } } return models.filter(m => { + if (initialModelSelectionPending(config.providers[m.provider])) return false; const nativeAlias = m.provider === COMBO_NAMESPACE && m.nativeAlias === true; // disabledModels may be stored raw (canonical) or encoded (legacy UI writes). for (const stored of disabled) { diff --git a/src/codex/catalog/sync.ts b/src/codex/catalog/sync.ts index 0c8b00a2cf..6288f9bf87 100644 --- a/src/codex/catalog/sync.ts +++ b/src/codex/catalog/sync.ts @@ -1,4 +1,5 @@ import { effectiveProviderAlias } from "../../providers/default-aliases"; +import { pendingModelSelectionProviders } from "../../providers/initial-model-selection"; import { execFileSync } from "node:child_process"; import { createHash } from "node:crypto"; import { existsSync, readFileSync } from "node:fs"; @@ -775,6 +776,7 @@ export interface ObservedCatalogMergeInput { readonly disabledModels: ReadonlySet; readonly selectedModelsByProvider: ReadonlyMap>; readonly gatheredProviderNames: ReadonlySet; + readonly pendingProviderNames?: ReadonlySet; readonly degradedProviderNames: ReadonlySet; readonly legacyCustomModelSlugs: ReadonlySet; readonly multiAgentMode: MultiAgentMode; @@ -806,6 +808,7 @@ export function mergeCatalogEntriesFromObservedState({ disabledModels, selectedModelsByProvider, gatheredProviderNames, + pendingProviderNames = new Set(), degradedProviderNames, legacyCustomModelSlugs, multiAgentMode, @@ -855,6 +858,7 @@ export function mergeCatalogEntriesFromObservedState({ if (disabledModelKeys.has(key)) return false; const slash = slug.indexOf("/"); const provider = slug.slice(0, slash); + if (pendingProviderNames.has(provider)) return false; const selected = selectedModelKeysByProvider.get(provider); if (selected !== undefined && !selected.has(key)) return false; return !gatheredProviderNames.has(provider) || degradedProviderNames.has(provider); @@ -1050,6 +1054,7 @@ export function mergeCatalogEntriesFromObservedState({ if (freshExactComboEntries.has(entry)) return true; const slash = slug.indexOf("/"); const provider = slug.slice(0, slash); + if (pendingProviderNames.has(provider)) return false; const selected = selectedModelKeysByProvider.get(provider); return selected === undefined || selected.has(slugEquivalenceKey(slug)); }); @@ -1718,6 +1723,7 @@ function writeRetainedCatalogSync({ disabledModels: new Set(config.disabledModels ?? []), selectedModelsByProvider, gatheredProviderNames, + pendingProviderNames: pendingModelSelectionProviders(config), degradedProviderNames, legacyCustomModelSlugs: legacyCustomModelCatalogSlugs(config), multiAgentMode, @@ -1820,6 +1826,10 @@ export async function syncCatalogModels( config: OcxConfig, options?: CodexCatalogSyncOptions, ): Promise { + if (pendingModelSelectionProviders(config).size) { + const { resolvePendingInitialModelSelection } = await import("../../providers/initial-model-selection-runtime"); + await resolvePendingInitialModelSelection(config); + } const owningCodexHome = getCodexHome(); const preflightRead = readRetainedCatalogSync(config); if (preflightRead === null) { diff --git a/src/codex/convergence.ts b/src/codex/convergence.ts index b84bbcb909..df765a7853 100644 --- a/src/codex/convergence.ts +++ b/src/codex/convergence.ts @@ -2,6 +2,7 @@ import { join } from "node:path"; import { getConfigDir, saveConfigPreservingClaudeCode, websocketsEnabled, withExpectedConfigGenerationSync } from "../config"; import { reconcileSuccessfulModelDiscoveries } from "../providers/new-model-policy"; +import { pendingModelSelectionProviders } from "../providers/initial-model-selection"; import { COMBO_NAMESPACE } from "../combos"; import { getAuthStorePath } from "../oauth/store"; import type { OcxConfig } from "../types"; @@ -351,6 +352,7 @@ function prepareCatalog( disabledModels: new Set(config.disabledModels ?? []), selectedModelsByProvider, gatheredProviderNames, + pendingProviderNames: pendingModelSelectionProviders(config), degradedProviderNames, legacyCustomModelSlugs: legacyCustomModelCatalogSlugs(config), multiAgentMode, diff --git a/src/codex/management-convergence.ts b/src/codex/management-convergence.ts index 9847e5dd97..1603523622 100644 --- a/src/codex/management-convergence.ts +++ b/src/codex/management-convergence.ts @@ -1,4 +1,5 @@ import type { OcxConfig } from "../types"; +import { resolvePendingInitialModelSelection } from "../providers/initial-model-selection-runtime"; import { captureCatalogAdmissionSnapshot } from "./catalog-admission"; import { convergeCodexCatalog } from "./convergence"; import type { @@ -152,6 +153,8 @@ export function createManagementConvergeCodex( catalogRefresh: unexpectedCatalogFailure(false), }); } + // Registration choices are committed independently, before sealing catalog authority. + await resolvePendingInitialModelSelection(retainedConfig as OcxConfig); const snapshot = captureCatalogAdmissionSnapshot(retainedConfig); const result = await convergeCodexCatalog(snapshot, request, { onCommitBegin: () => { commitBegan = true; }, diff --git a/src/config.ts b/src/config.ts index 0e2a4d9ba0..fdcda9547c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -525,6 +525,12 @@ const providerConfigSchema = z.object({ modelAliases: z.record(z.string(), z.string()).optional(), modelDisplayNames: modelDisplayNamesSchema.optional(), defaultAliases: z.boolean().optional(), + initialModelSelection: z.object({ + version: z.literal(1), + registrationId: z.uuid(), + status: z.enum(["pending", "ready", "all-off"]), + modelCount: z.number().int().nonnegative().optional(), + }).optional().catch(undefined), requestPacing: requestPacingSchema.optional().catch(undefined), mcpMaxTools: z.number().int().positive().optional(), mcpMaxSchemaBytes: z.number().int().positive().optional(), diff --git a/src/oauth/index.ts b/src/oauth/index.ts index 3623398309..1a8bd07157 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -1,4 +1,5 @@ import type { KiroOAuthMetadata, OAuthController, OAuthCredentials } from "./types"; +import { initializeProviderModelSelection } from "../providers/initial-model-selection"; import { parseCallbackInput } from "./callback-server"; import type { OcxConfig, OcxProviderConfig, RefreshPolicy } from "../types"; import { ConfigMutationLockError, loadConfig, mutatePersistedConfig, saveConfig } from "../config"; @@ -1481,6 +1482,7 @@ export function upsertOAuthProvider(config: OcxConfig, provider: string): void { if (previousModeAllowsKey) next.authMode = "key"; } } + initializeProviderModelSelection(provider, next, existing, config); config.providers[provider] = next; } diff --git a/src/oauth/login-cli.ts b/src/oauth/login-cli.ts index 437b61e6d6..79a3aa6eca 100644 --- a/src/oauth/login-cli.ts +++ b/src/oauth/login-cli.ts @@ -1,4 +1,6 @@ import * as readline from "node:readline"; +import { modelSelectionGuidance } from "../cli/model-selection-guidance"; +import { initializeProviderModelSelection } from "../providers/initial-model-selection"; import { openUrl } from "../lib/open-url"; import { loadConfig, saveConfig } from "../config"; import { findLiveProxy } from "../server/proxy-liveness"; @@ -93,6 +95,7 @@ async function handleOAuthLogin(name: string): Promise { } const reload = await notifyRunningProxyAfterOAuthLogin(name); console.log(`\n✅ Logged in to ${name}. Try: ocx sync`); + for (const line of modelSelectionGuidance(name)) console.log(line); warnIfLiveReloadSkipped(reload); } @@ -156,6 +159,7 @@ export async function commitKeyLoginProvider( onLiveReload?: (result: LocalProviderReloadResult | null) => void, ): Promise { const mergedProvider = mergeKeyLoginProviderRow(provider, config.providers[name]); + initializeProviderModelSelection(name, mergedProvider, config.providers[name], config); config.providers[name] = mergedProvider; saveConfig(config); // Evaluate the reload BEFORE the optional call: `onLiveReload?.(await ...)` short-circuits @@ -211,6 +215,7 @@ async function handleKeyLogin(name: string): Promise { let reload: LocalProviderReloadResult | null = null; await commitKeyLoginProvider(config, name, provider, result => { reload = result; }); console.log(`✅ ${def.label} added. Try: ocx sync`); + for (const line of modelSelectionGuidance(name)) console.log(line); warnIfLiveReloadSkipped(reload); } diff --git a/src/providers/initial-model-selection-runtime.ts b/src/providers/initial-model-selection-runtime.ts new file mode 100644 index 0000000000..0039ee05aa --- /dev/null +++ b/src/providers/initial-model-selection-runtime.ts @@ -0,0 +1,90 @@ +import { mutatePersistedConfig, validateConfigCandidate } from "../config"; +import { isDeepStrictEqual } from "node:util"; +import type { OcxConfig, OcxProviderConfig } from "../types"; +import type { CatalogModel } from "../codex/catalog"; +import { + adoptInitialModelSelections, + initialModelSelection, + initialModelSelectionPending, + reconcileInitialModelSelections, +} from "./initial-model-selection"; + +interface InitialSelectionBaseline { + providers: string[]; + inventory: unknown; + disabled: string; +} + +function inventoryIdentity(config: OcxConfig): unknown { + const validated = validateConfigCandidate(config); + if (!validated.ok) return null; + // Compare all inventory-producing configuration, including custom rows and combos. + // Normalize schema defaults, ignoring completed-selection state and switch values. + // Listener binding intentionally differs between live and disk after a port/host edit; + // it cannot affect provider discovery and must not leave registration pending forever. + // The incarnation remains: identical delete/re-add is NOT the same registration. + const providers = Object.fromEntries(Object.entries(validated.config.providers).map(([name, provider]) => [name, { + ...provider, + initialModelSelection: initialModelSelection(provider)?.registrationId, + }])); + // Ephemeral only: never log this value, which may contain credentials. + return JSON.parse(JSON.stringify({ ...validated.config, providers, disabledModels: undefined, port: undefined, hostname: undefined })); +} + +export function captureInitialSelectionBaseline(config: OcxConfig): InitialSelectionBaseline | null { + const providers = Object.entries(config.providers) + .filter(([, provider]) => initialModelSelectionPending(provider)) + .map(([name]) => name); + if (!providers.length) return null; + const inventory = inventoryIdentity(config); + return inventory === null ? null : { providers, inventory, disabled: JSON.stringify(config.disabledModels ?? []) }; +} + +/** Commit only decisions whose provider and user-selection snapshot still match. */ +export function finalizeInitialModelSelection( + config: OcxConfig, + baseline: InitialSelectionBaseline | null, + models: readonly CatalogModel[], + authoritativeProviders: readonly string[], +): void { + if (!baseline || JSON.stringify(config.disabledModels ?? []) !== baseline.disabled) return; + if (!isDeepStrictEqual(inventoryIdentity(config), baseline.inventory)) return; + try { + const outcome = mutatePersistedConfig(fresh => { + if (!isDeepStrictEqual(inventoryIdentity(fresh), baseline.inventory)) return { changed: false, value: null }; + const providers: Record = {}; + for (const name of baseline.providers) { + const provider = fresh.providers[name]; + if (!provider || !initialModelSelection(provider)) continue; + // A concurrent successful initializer may already have committed its result. + // Adopt that result, including any later manual switch edits; never initialize twice. + if (initialModelSelectionPending(provider) && JSON.stringify(fresh.disabledModels ?? []) !== baseline.disabled) continue; + providers[name] = provider; + } + const projection = { ...fresh, providers }; + const changed = reconcileInitialModelSelections(projection, models, authoritativeProviders); + if (changed) fresh.disabledModels = projection.disabledModels; + return { changed, value: { ...projection, disabledModels: fresh.disabledModels } }; + }); + if (outcome.status === "unavailable" || !outcome.value) return; + adoptInitialModelSelections(config, outcome.value); + if (Object.keys(outcome.value.providers).length) { + config.disabledModels = outcome.value.disabledModels === undefined ? undefined : [...outcome.value.disabledModels]; + } + } catch { + // Keep pending publication fenced on contention or failed persistence. A later ordinary + // model refresh retries; no dedicated timer and no private exception/path output. + console.warn("[initial-model-selection] Could not save initial model choices; model exposure remains pending. Retry model discovery."); + } +} + +/** Ordinary discovery, before retained catalog evidence is captured. */ +export async function resolvePendingInitialModelSelection(config: OcxConfig): Promise { + const baseline = captureInitialSelectionBaseline(config); + if (!baseline) return; + const { gatherRoutedModels, uniqueCatalogModelsForPublicList } = await import("../codex/catalog"); + const outcomes: Array<{ provider: string; state: "authoritative" | "degraded" }> = []; + const models = await gatherRoutedModels(config, { providerModelOutcomes: outcomes }); + finalizeInitialModelSelection(config, baseline, uniqueCatalogModelsForPublicList(models), + outcomes.filter(outcome => outcome.state === "authoritative").map(outcome => outcome.provider)); +} diff --git a/src/providers/initial-model-selection.ts b/src/providers/initial-model-selection.ts new file mode 100644 index 0000000000..d00ae9c19f --- /dev/null +++ b/src/providers/initial-model-selection.ts @@ -0,0 +1,120 @@ +import type { OcxConfig, OcxProviderConfig } from "../types"; +import { randomUUID } from "node:crypto"; +import { getProviderRegistryEntry, providerMatchesRegistryTransport } from "./registry"; +import { routedSlug, slugEquivalenceKey } from "./slug-codec"; +import { comboDisabledModelSelectors } from "../combos/types"; +import { providerUsesKeyAuthOverride, resolveProviderApiKey } from "./key-store"; + +export const INITIAL_MODEL_SELECTION_THRESHOLD = 20; +type Selection = NonNullable; + +/** Read only the public, non-secret shape; editor input never owns this state. */ +export function initialModelSelection(provider: OcxProviderConfig | undefined): Selection | undefined { + const value = provider?.initialModelSelection; + if (!value || value.version !== 1 || typeof value.registrationId !== "string" + || !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value.registrationId) + || !["pending", "ready", "all-off"].includes(value.status)) return undefined; + return { + version: 1, + registrationId: value.registrationId, + status: value.status, + ...(Number.isSafeInteger(value.modelCount) && value.modelCount! >= 0 ? { modelCount: value.modelCount } : {}), + }; +} + +export function initialModelSelectionPending(provider: OcxProviderConfig | undefined): boolean { + return initialModelSelection(provider)?.status === "pending"; +} + +function loginConnection(name: string, provider: OcxProviderConfig): boolean { + const entry = getProviderRegistryEntry(name); + if (entry && providerMatchesRegistryTransport(name, provider)) { + if (entry.authKind === "forward") return true; + if (entry.authKind === "oauth") { + return !providerUsesKeyAuthOverride(entry, provider, resolveProviderApiKey(provider.apiKey)); + } + } + return provider.authMode === "oauth" || provider.authMode === "forward"; +} + +/** Registration only: absence on an existing row is legacy/exempt, never a migration trigger. */ +export function initializeProviderModelSelection( + name: string, + next: OcxProviderConfig, + existing?: OcxProviderConfig, + config?: Pick, +): void { + delete next.initialModelSelection; + if (existing) { + for (const key of ["selectedModels", "modelPreset", "newModelPolicy"] as const) { + if (next[key] === undefined && existing[key] !== undefined) { + Object.assign(next, { [key]: structuredClone(existing[key]) }); + } + } + if (existing.initialModelSelection !== undefined) next.initialModelSelection = structuredClone(existing.initialModelSelection); + } else { + // A deleted provider's discovery history belongs to that old registration too. + if (config?.modelDiscovery?.knownModels) delete config.modelDiscovery.knownModels[name]; + if (config?.modelDiscovery?.recentArrivals) delete config.modelDiscovery.recentArrivals[name]; + if (config?.disabledModels) { + const comboSelectors = new Set(Object.entries(config.combos ?? {}) + .flatMap(([id, combo]) => comboDisabledModelSelectors(id, combo))); + config.disabledModels = config.disabledModels.filter(selector => + !selector.startsWith(`${name}/`) || comboSelectors.has(selector)); + } + if (!loginConnection(name, next)) { + next.initialModelSelection = { version: 1, registrationId: randomUUID(), status: "pending" }; + } + } +} + +/** Count the canonical switch identities that the Models inventory displays. */ +export function reconcileInitialModelSelections( + config: OcxConfig, + models: Iterable<{ provider: string; id: string }>, + authoritativeProviders: Iterable, +): boolean { + const selectors = new Map>(); + for (const model of models) { + const ids = selectors.get(model.provider) ?? new Set(); + ids.add(routedSlug(model.provider, model.id)); + selectors.set(model.provider, ids); + } + const authoritative = new Set(authoritativeProviders); + let changed = false; + for (const [name, provider] of Object.entries(config.providers)) { + const initial = initialModelSelection(provider); + if (initial?.status !== "pending") continue; + if (loginConnection(name, provider)) { + provider.initialModelSelection = { version: 1, registrationId: initial.registrationId, status: "ready" }; + changed = true; + continue; + } + if (!authoritative.has(name)) continue; + const ids = selectors.get(name) ?? new Set(); + const allOff = ids.size >= INITIAL_MODEL_SELECTION_THRESHOLD; + if (allOff) { + const disabled = config.disabledModels ??= []; + const keys = new Set(disabled.map(slugEquivalenceKey)); + for (const id of ids) { + const key = slugEquivalenceKey(id); + if (!keys.has(key)) { disabled.push(id); keys.add(key); } + } + } + provider.initialModelSelection = { version: 1, registrationId: initial.registrationId, status: allOff ? "all-off" : "ready", modelCount: ids.size }; + changed = true; + } + return changed; +} + +export function adoptInitialModelSelections(target: OcxConfig, source: OcxConfig): void { + for (const [name, provider] of Object.entries(source.providers)) { + if (target.providers[name] && provider.initialModelSelection !== undefined) { + target.providers[name].initialModelSelection = structuredClone(provider.initialModelSelection); + } + } +} + +export function pendingModelSelectionProviders(config: Pick): Set { + return new Set(Object.entries(config.providers).filter(([, provider]) => initialModelSelectionPending(provider)).map(([name]) => name)); +} diff --git a/src/providers/key-store.ts b/src/providers/key-store.ts index bf8ec3198f..12e4ce6cb7 100644 --- a/src/providers/key-store.ts +++ b/src/providers/key-store.ts @@ -1,6 +1,17 @@ import { createRequire } from "node:module"; import { resolveEnvValue, saveConfigPreservingClaudeCode } from "../config"; import type { OcxConfig, OcxProviderConfig } from "../types"; +import type { ProviderRegistryEntry } from "./registry"; + +/** Shared with routing: a key-mode override is effective only while its key resolves. */ +export function providerUsesKeyAuthOverride( + entry: Pick, + provider: Pick, + resolvedKey: string | undefined, +): boolean { + return entry.authKind === "oauth" && entry.allowKeyAuthOverride === true + && provider.authMode === "key" && typeof resolvedKey === "string" && resolvedKey.trim().length > 0; +} /** * Opt-in OS keychain storage for provider API keys (#1221). @@ -194,4 +205,3 @@ export function restoreProviderKeyFromKeychain(config: OcxConfig, name: string): saveConfigPreservingClaudeCode(config); return { ok: true, restored: resolved.size }; } - diff --git a/src/router.ts b/src/router.ts index 1dcd78481e..4af0ea497e 100644 --- a/src/router.ts +++ b/src/router.ts @@ -9,7 +9,7 @@ import { } from "./combos"; import type { NormalizedComboConfig } from "./combos/types"; import { hasOwnProvider } from "./config/provider-name"; -import { resolveProviderApiKey } from "./providers/key-store"; +import { providerUsesKeyAuthOverride, resolveProviderApiKey } from "./providers/key-store"; import { assertProviderDestinationAllowed } from "./lib/destination-policy"; import { redactSecretString, redactUrlForLog } from "./lib/redact"; import { @@ -300,10 +300,7 @@ export function routedProviderConfig(providerName: string, provider: OcxProvider const repairLegacyMimoFreeAuth = providerName === "mimo-free" && staticModelCatalog && (provider.authMode === undefined || provider.authMode === "local"); - const explicitKeyOverride = registryEntry.authKind === "oauth" - && registryEntry.allowKeyAuthOverride === true - && provider.authMode === "key" - && resolvedApiKey !== undefined; + const explicitKeyOverride = providerUsesKeyAuthOverride(registryEntry, provider, resolvedApiKey); const canonicalAuthMode = explicitKeyOverride ? "key" : repairLegacyMimoFreeAuth diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index c0c7b77fd1..ccc23c5ef5 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -1,4 +1,5 @@ import { timingSafeEqual } from "node:crypto"; +import { initialModelSelection } from "../providers/initial-model-selection"; import { extractAccountId } from "../oauth/chatgpt"; import { formatErrorResponse } from "../bridge"; import { @@ -798,6 +799,7 @@ const PROVIDER_CONFIG_FIELD_POLICY = { models: "editor", liveModels: "editor", selectedModels: "editor", + initialModelSelection: "runtime", retainModels: "editor", newModelPolicy: "editor", modelPreset: "editor", @@ -1002,6 +1004,8 @@ export function safeConfigDTO(config: OcxConfig): unknown { if (name === "xai") { dto.xaiResponsesOptInState = xaiResponsesOptInState(provider); } + const selection = initialModelSelection(provider); + if (selection) dto.initialModelSelection = selection; providers[name] = dto; } return { diff --git a/src/server/management/agent-settings-routes.ts b/src/server/management/agent-settings-routes.ts index 75bcaf36ae..51ebc746cb 100644 --- a/src/server/management/agent-settings-routes.ts +++ b/src/server/management/agent-settings-routes.ts @@ -70,7 +70,7 @@ import type { PersistedUsageAttempt } from "../../usage/log"; import { isAllowedRequestOrigin, jsonResponse, providerManagementConfigError, publicProviderBaseUrl, safeConfigDTO } from "../auth-cors"; import { applySystemEnvToggle } from "../system-env"; -import { isPlainRecord, parseDebugLogQuery, tokPerSecondResult, unavailableCostReason, costResult, requestLogDto, stripRegistryOnlyStaticHeaders, fetchAllModels, fetchGrokCandidateModels, buildClaudeDesktopState } from "./shared"; +import { isPlainRecord, parseDebugLogQuery, tokPerSecondResult, unavailableCostReason, costResult, requestLogDto, stripRegistryOnlyStaticHeaders, fetchInitializedModels as fetchAllModels, fetchGrokCandidateModels, buildClaudeDesktopState } from "./shared"; import type { MetricUnavailableReason, TokPerSecondResult, CostEstimateReason, CostResult, MetricSource } from "./shared"; import { readManagementJsonBody, readOptionalManagementJsonBody, rethrowManagementBodyTooLarge } from "./body"; diff --git a/src/server/management/model-routes.ts b/src/server/management/model-routes.ts index 601444a958..a364694b2c 100644 --- a/src/server/management/model-routes.ts +++ b/src/server/management/model-routes.ts @@ -154,6 +154,7 @@ import { isPlainRecord, parseDebugLogQuery, tokPerSecondResult, unavailableCostR import type { MetricUnavailableReason, TokPerSecondResult, CostEstimateReason, CostResult, MetricSource } from "./shared"; import type { ManagementContext } from "./context"; import { listManagementModelRows, loadExportModels } from "./model-rows"; +import { initialModelSelectionPending } from "../../providers/initial-model-selection"; import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body"; import { hasModelPreset, @@ -536,6 +537,9 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise & { id: string; namespaced: string; disabled: boolean; + initialSelectionPending?: boolean; native?: boolean; custom?: boolean; customId?: string; @@ -164,7 +166,10 @@ export async function listManagementModelRows( ...(contextCap !== undefined ? { contextCap, contextCapped: m.contextCapped === true } : {}), }; }).filter((row): row is ManagementModelRow => row !== null); - return [...native, ...dedupedRouted, ...visibleCustomModels]; + return [...native, ...dedupedRouted, ...visibleCustomModels].map(row => + initialModelSelectionPending(config.providers[row.provider]) + ? { ...row, disabled: true, initialSelectionPending: true } + : row); } /** `/api/models` row → the narrower input the client-config serializers accept. */ diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index f6a6bf767c..e26420e003 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -41,6 +41,7 @@ import { fetchCursorUsableModels } from "../../adapters/cursor/live-models"; import { parseAntigravityAvailableModels } from "../../providers/antigravity-models"; import { enrichProviderFromCatalog, listKeyLoginProviders } from "../../oauth/key-providers"; import { deriveProviderPresets, providerConfigSeed } from "../../providers/derive"; +import { initializeProviderModelSelection } from "../../providers/initial-model-selection"; import { effectiveGoogleMode, providerCodexAccountMode, providerMatchesRegistryTransport } from "../../providers/registry"; import { extractModelEnvelopeRows, @@ -287,6 +288,10 @@ function adoptProviderEditorCandidate(live: OcxConfig, persisted: OcxConfig): vo else live.customModels = structuredClone(persisted.customModels); if (persisted.providerContextCaps === undefined) delete live.providerContextCaps; else live.providerContextCaps = structuredClone(persisted.providerContextCaps); + if (persisted.disabledModels === undefined) delete live.disabledModels; + else live.disabledModels = [...persisted.disabledModels]; + if (persisted.modelDiscovery === undefined) delete live.modelDiscovery; + else live.modelDiscovery = structuredClone(persisted.modelDiscovery); } /** @@ -831,8 +836,15 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise { */ export async function fetchAllModels(config: OcxConfig): Promise { const { gatherRoutedModels } = await import("../../codex/catalog"); - return gatherRoutedModels(config); + const baseline = captureInitialSelectionBaseline(config); + if (!baseline) return gatherRoutedModels(config); + const outcomes: Array<{ provider: string; state: "authoritative" | "degraded" }> = []; + const models = await gatherRoutedModels(config, { providerModelOutcomes: outcomes }); + finalizeInitialModelSelection(config, baseline, uniqueCatalogModelsForPublicList(models), + outcomes.filter(outcome => outcome.state === "authoritative").map(outcome => outcome.provider)); + return models; } export interface GrokCandidateModel { @@ -187,6 +195,11 @@ export interface GrokCandidateModel { native: boolean; } +/** Configuration pickers may retain disabled choices, but never offer provisional models. */ +export async function fetchInitializedModels(config: OcxConfig): Promise { + return (await fetchAllModels(config)).filter(model => !initialModelSelectionPending(config.providers[model.provider])); +} + /** * The model list `syncGrokConfig` would inject, BEFORE the user's exclusions. The Grok * page needs this to show a switch for a model the user has already excluded — such a diff --git a/src/types/provider.ts b/src/types/provider.ts index 691fb01e1c..79651e0012 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -345,6 +345,13 @@ export interface OcxProviderConfig { * full set so the user can pick). See devlog issue_052_provider-model-allowlist. */ selectedModels?: string[]; + /** Registration-owned state. Absent means legacy or OAuth-exempt, not uninitialized. */ + initialModelSelection?: { + version: 1; + registrationId: string; + status: "pending" | "ready" | "all-off"; + modelCount?: number; + }; /** * Per-provider retention allowlist for authoritative live discovery. When non-empty, any * model id in this list is preserved in the routed catalog even if the live `/models` diff --git a/structure/03_catalog-and-subagents.md b/structure/03_catalog-and-subagents.md index 41dc351646..2b067f0b4c 100644 --- a/structure/03_catalog-and-subagents.md +++ b/structure/03_catalog-and-subagents.md @@ -348,6 +348,21 @@ the request, and they never raise it. ## Subagents +New non-OAuth provider registrations carry `initialModelSelection` with a unique +registration identity. Until reliable live/static discovery completes, public +catalogs and model candidates withhold those providers' models; the provider itself +stays active. At 20 or more canonical Models switch rows, initialization appends +all corresponding disabled selectors once. Existing registrations and later manual +choices are not reinitialized. OAuth/ChatGPT forwarding is exempt using the same +usable-key override predicate as routing. Display aliases do not add switch rows. + +`src/providers/initial-model-selection-runtime.ts` commits the decision against a +matching registration/inventory snapshot before catalog authority is captured. +Ordinary management discovery also completes it with Codex integration OFF. The +final catalog merge fences pending retained rows, including delete/re-add recovery. +Raw management rows remain visible as pending/OFF. Config listener bindings are +excluded from inventory identity because live and persisted bindings may differ. + Codex `spawn_agent` advertises only the highest-priority first five picker-visible catalog rows. Use at most five configured `subagentModels` ids; they may contain bare catalog ids, routed `provider/model` ids, or exact account-qualified `/` ids. The diff --git a/structure/05_gui-and-management-api.md b/structure/05_gui-and-management-api.md index c71335657c..678ee07645 100644 --- a/structure/05_gui-and-management-api.md +++ b/structure/05_gui-and-management-api.md @@ -121,7 +121,7 @@ this document owns is which module holds which area and what invariant that area | Windows tray | `GET/POST /api/windows-tray` controls an owned, per-user HKCU login tray. The tray delegates fixed actions to the CLI and is never a proxy supervisor or restart-protection signal. | | Updates | `GET /api/update/check`, `POST /api/update/run`, and `GET /api/update/status` own dashboard self-update state. A launched worker PID is persisted in `update-job.json`; dead PIDs recover immediately, while legacy active records without a PID recover only after ten minutes. Live PIDs remain exclusive regardless of record age. `GET /api/update/badge` backs the sidebar badge: it reports that an update exists and links to the update surface rather than gating other actions. | | Providers | Create/update/delete ordinary provider configs and enrich registry metadata. The reserved `openai` card exposes Pool(default)/Direct account mode; `openai-apikey` remains the separate API route. | -| Models | Fetch routed model lists, disabled model visibility, and catalog-facing ids. | +| Models | Fetch routed model lists, disabled model visibility, and catalog-facing ids. New non-OAuth registration holds exposure until authoritative discovery; 20 or more distinct switch rows start OFF without disabling the provider. Pending rows cannot accept visibility changes. | | OAuth | Login/status/logout for OAuth-backed providers, plus multiauth account management: `GET /api/oauth/accounts`, `PUT /api/oauth/accounts/active`, `PUT /api/oauth/accounts/alias`, `DELETE /api/oauth/accounts` list masked accounts per provider, switch the active one, edit its display-only alias, and remove one. The login flow itself is `GET /api/oauth/providers`, `POST /api/oauth/login`, `POST /api/oauth/login/code`, `POST /api/oauth/login/cancel`, `POST /api/oauth/logout`, and `GET /api/oauth/status`; pool controls are `GET/PUT/PATCH /api/oauth/accounts/pool` and `POST /api/oauth/accounts/clear-cooldown`. Login accepts `addAccount: true` to force a fresh browser identity. Device flows return a structured `deviceCode`; the GUI highlights and copies it before the user opens the verification page. | | Key providers | `GET /api/key-providers` exposes API-key provider presets for setup and dashboard flows, and `GET/POST/DELETE /api/keys` owns the proxy's own admission keys. Multi-key pool per key-auth provider: `GET /api/providers/keys`, `POST /api/providers/keys`, `PUT /api/providers/keys/active`, `PUT /api/providers/keys/alias`, `DELETE /api/providers/keys` masked list, add (upsert + activate), switch, rename, and remove keys. `provider.apiKey` always mirrors the active pool entry so routing stays single-key. | | OpenAI account mode | Report one OpenAI Codex card with Pool/Direct controls and one API-key card. Mode PATCH persists live without restart or catalog identity changes; Pool owns account/quota controls and Direct uses caller/main login only. Main-account DTOs report real credential presence and terminal `needsReauth` state instead of treating missing/invalid native auth as an unknown quota. Selection order has its own route: `PUT /api/codex-auth/accounts/priority` takes `{ id, priority }`, where `priority` is an integer -100..100 or `null` to restore the default, accepts `__main__`, 404s an unknown id, and echoes the stored value. Re-ordering never clears thread affinity, so the response carries no `appliesImmediately`, but it does release any pin — see [`08_openai-provider-tiers.md`](08_openai-provider-tiers.md) for why. `PUT /api/codex-auth/active` with a null id releases one too, but that drops the operator's account selection along with it, so this route is the only operator-facing way to clear a pin while leaving the selected account in place. `GET /api/codex-auth/active` reports `pinned`, true only while the manually selected account is still the effective active one, plus `pinnedAccountId`, which names the pinned account whether or not it is the active one. Surfaces should render `pinnedAccountId`: under round-robin and fill-first the pin caps the tier ceiling at its own tier while the strategy cursor moves freely inside that tier, so `pinned` goes false on a sibling's turn even though the pin is still suppressing every higher tier — which is why the dashboard badges `pinnedAccountId` and the GUI controller tracks only the id. `pinned` answers the narrower question of whether routing is *currently* on the operator's choice; no surface in this repo asks it, and a new one almost certainly wants the id instead. | diff --git a/tests/cli/cli-account.test.ts b/tests/cli/cli-account.test.ts index 6f1dc0a09e..58146c6383 100644 --- a/tests/cli/cli-account.test.ts +++ b/tests/cli/cli-account.test.ts @@ -478,6 +478,12 @@ describe("account login --device", () => { expect(JSON.parse(result.stdout)).toMatchObject({ deviceCode: "ABCD-EFGH", url: "https://auth.openai.com/codex/device", + modelSelection: { + provider: "openai", + afterLogin: true, + requiresRunningProxy: true, + commands: { list: "ocx models live --provider openai" }, + }, }); }); @@ -2065,6 +2071,18 @@ describe("ocx account CLI (issue #180 matrix)", () => { expect(JSON.parse(result.stdout)).toEqual({ status: "done", catalogRefreshPending: true, + modelSelection: { + provider: "openai", afterLogin: false, requiresRunningProxy: true, + commands: { + list: "ocx models live --provider openai", + enable: 'ocx models enable ""', + disable: 'ocx models disable ""', + enableNative: 'ocx models enable "" --native', + disableNative: 'ocx models disable "" --native', + enableAll: "ocx models provider openai on", + disableAll: "ocx models provider openai off", + }, + }, }); expect(result.stderr).toBe(""); } finally { diff --git a/tests/cli/cli-provider.test.ts b/tests/cli/cli-provider.test.ts index b58adfd8cb..b83bc8d514 100644 --- a/tests/cli/cli-provider.test.ts +++ b/tests/cli/cli-provider.test.ts @@ -52,6 +52,26 @@ function readConfig(dir: string) { } describe("ocx provider", () => { + test("new provider registration initializes model selection but force overwrite preserves it", () => { + const { dir } = freshConfig(); + try { + const args = ["provider", "add", "model-fixture", "--adapter", "openai-chat", "--base-url", "https://models.example.test/v1", "--json"]; + const added = runCli(args, { OPENCODEX_HOME: dir }); + expect(added.status).toBe(0); + expect(JSON.parse(added.stdout).modelSelection.commands.list).toBe("ocx models live --provider model-fixture"); + const first = readConfig(dir); + expect(first.providers["model-fixture"].initialModelSelection.status).toBe("pending"); + const registrationId = first.providers["model-fixture"].initialModelSelection.registrationId; + first.providers["model-fixture"].selectedModels = ["chosen"]; + writeFileSync(join(dir, "config.json"), JSON.stringify(first)); + expect(runCli([...args, "--force"], { OPENCODEX_HOME: dir }).status).toBe(0); + const next = readConfig(dir).providers["model-fixture"]; + expect(next.selectedModels).toEqual(["chosen"]); + expect(next.initialModelSelection.registrationId).toBe(registrationId); + expect(next.disabled).not.toBe(true); + } finally { removeTreeWithRetry(dir); } + }); + test("provider --help prints usage", () => { const result = runCli(["provider", "--help"]); expect(result.status).toBe(0); diff --git a/tests/cli/model-selection-guidance.test.ts b/tests/cli/model-selection-guidance.test.ts new file mode 100644 index 0000000000..e3719fb1d2 --- /dev/null +++ b/tests/cli/model-selection-guidance.test.ts @@ -0,0 +1,54 @@ +import { expect, spyOn, test } from "bun:test"; +import { modelSelectionGuidance, modelSelectionNextSteps } from "../../src/cli/model-selection-guidance"; +import { handleModelsRuntimeCommand } from "../../src/cli/models-runtime"; + +test("registration guidance uses real CLI model commands and preserves exact listed IDs", () => { + const next = modelSelectionNextSteps("openrouter"); + expect(next.commands).toEqual({ + list: "ocx models live --provider openrouter", + enable: 'ocx models enable ""', + disable: 'ocx models disable ""', + enableNative: 'ocx models enable "" --native', + disableNative: 'ocx models disable "" --native', + enableAll: "ocx models provider openrouter on", + disableAll: "ocx models provider openrouter off", + }); + expect(next.requiresRunningProxy).toBe(true); + const text = modelSelectionGuidance("openrouter").join("\n"); + expect(text).toContain("ocx start"); + expect(text).toContain("the provider stays active"); + expect(text).toContain("For rows marked native"); + expect(text).not.toContain("http"); +}); + +test("generated native commands preserve qualified IDs through the actual CLI parser", async () => { + const log = spyOn(console, "log").mockImplementation(() => {}); + const writes: unknown[] = []; + try { + const commands = modelSelectionNextSteps("openai").commands; + for (const command of [commands.enableNative, commands.disableNative]) { + const [, , action, placeholder, ...flags] = command.split(" "); + const selector = placeholder.replace('""', "team/gpt-future-unlisted"); + expect(await handleModelsRuntimeCommand(action, [selector, ...flags], { + baseUrl: "http://model-guidance.test", + fetchImpl: (async (_input, init) => { + writes.push(JSON.parse(String(init?.body))); + return Response.json({ ok: true }); + }) as typeof fetch, + })).toBe(0); + } + expect(writes).toEqual([true, false].map(enabled => ({ + scope: "models", provider: "openai", enabled, + targets: [{ id: "team/gpt-future-unlisted", native: true }], + }))); + } finally { log.mockRestore(); } +}); + +test("Codex login aliases target the native provider and no-wait advice is explicitly future work", () => { + for (const alias of ["codex", "chatgpt", "openai"]) { + expect(modelSelectionNextSteps(alias).commands.list).toBe("ocx models live --provider openai"); + } + expect(modelSelectionNextSteps("xai", true).afterLogin).toBe(true); + expect(modelSelectionGuidance("xai", true)[0]).toContain("After login completes"); + expect(modelSelectionNextSteps("xai", true).commands.enable).not.toContain("xai/<"); +}); diff --git a/tests/codex-integration/codex-catalog.test.ts b/tests/codex-integration/codex-catalog.test.ts index febaf976cb..37d8c69798 100644 --- a/tests/codex-integration/codex-catalog.test.ts +++ b/tests/codex-integration/codex-catalog.test.ts @@ -3032,6 +3032,17 @@ function mergeObservedForTest( } describe("Codex catalog routed normalization", () => { + test("pending re-registration cannot recover ON rows from a degraded old catalog", () => { + const old = { ...nativeTemplate(), slug: "vendor/model-0", owned_by: "vendor", opencodex_catalog_kind: CODEX_PROVIDER_MODEL_CATALOG_KIND }; + const input = { + catalogModels: [old], routedEntries: [], + gatheredProviderNames: new Set(["vendor"]), degradedProviderNames: new Set(["vendor"]), + }; + expect(mergeObservedForTest(input).some(entry => entry.slug === "vendor/model-0")).toBe(true); + expect(mergeObservedForTest({ ...input, pendingProviderNames: new Set(["vendor"]) }) + .some(entry => entry.slug === "vendor/model-0")).toBe(false); + }); + test("does not reuse a routed native alias as the native catalog template", () => { const routedAlias = { ...nativeTemplate(), diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 2fa6e55152..51bd9d6ceb 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -662,6 +662,8 @@ "native-profile-startup.test.ts": "codex-integration", "native-profile-store.test.ts": "codex-integration", "new-model-policy.test.ts": "providers", + "initial-model-selection.test.ts": "providers", + "model-selection-guidance.test.ts": "cli", "nous-oauth-live.test.ts": "providers", "nous-oauth.test.ts": "providers", "novita-provider.test.ts": "providers", diff --git a/tests/providers/initial-model-selection.test.ts b/tests/providers/initial-model-selection.test.ts new file mode 100644 index 0000000000..46fc586cf0 --- /dev/null +++ b/tests/providers/initial-model-selection.test.ts @@ -0,0 +1,384 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import * as configStore from "../../src/config"; +import { flushConfigDirHardeningForTests } from "../../src/config/paths"; +import { filterCatalogVisibleModels } from "../../src/codex/catalog"; +import { clearModelCache } from "../../src/codex/model-cache"; +import { initializeProviderModelSelection, reconcileInitialModelSelections } from "../../src/providers/initial-model-selection"; +import { captureInitialSelectionBaseline, finalizeInitialModelSelection, resolvePendingInitialModelSelection } from "../../src/providers/initial-model-selection-runtime"; +import { providerConfigSeed } from "../../src/providers/derive"; +import { getProviderRegistryEntry } from "../../src/providers/registry"; +import { safeConfigDTO, providerEditorConfigDTO } from "../../src/server/auth-cors"; +import { handleManagementAPI } from "../../src/server/management-api"; +import { upsertOAuthProvider } from "../../src/oauth"; +import { commitKeyLoginProvider } from "../../src/oauth/login-cli"; +import type { OcxConfig, OcxProviderConfig } from "../../src/types"; +import { ManagementRequest } from "../helpers/management-auth"; +import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { catalogConvergenceFactory } from "../helpers/catalog-convergence"; + +let home = ""; +let previousHome: string | undefined; +let codex: IsolatedCodexHome; +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + home = mkdtempSync(join(tmpdir(), "ocx-initial-selection-")); + process.env.OPENCODEX_HOME = home; + codex = installIsolatedCodexHome("ocx-initial-selection-codex-"); +}); +afterEach(async () => { + clearModelCache(); + await flushConfigDirHardeningForTests(); + codex.restore(); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + removeTreeWithRetry(home); +}); + +function fixture(count = 20): OcxConfig { + const provider: OcxProviderConfig = { + adapter: "openai-chat", baseUrl: "https://models.example.test/v1", authMode: "key", + apiKey: "fixture-key", liveModels: false, + models: Array.from({ length: count }, (_, i) => `model-${i}`), + }; + initializeProviderModelSelection("vendor", provider); + return { port: 0, defaultProvider: "vendor", providers: { vendor: provider }, clientIntegrations: { codex: false } }; +} +function rows(count: number) { + return Array.from({ length: count }, (_, i) => ({ provider: "vendor", id: `model-${i}` })); +} +async function api(config: OcxConfig, path: string, body?: unknown, method = "PUT"): Promise { + const url = new URL(`http://localhost${path}`); + const response = await handleManagementAPI(new ManagementRequest(url, body === undefined ? {} : { + method, headers: { "content-type": "application/json" }, body: JSON.stringify(body), + }), url, config, { createManagementConvergeCodex: catalogConvergenceFactory() }); + if (!response) throw new Error("route missing"); + return response; +} + +describe("initial provider model switches", () => { + test.each([0, 19, 20])("authoritative %i-row boundary keeps the provider active", count => { + const config = fixture(count); + expect(reconcileInitialModelSelections(config, rows(count), ["vendor"])).toBe(true); + expect(config.providers.vendor.initialModelSelection).toEqual({ version: 1, registrationId: expect.any(String), status: count >= 20 ? "all-off" : "ready", modelCount: count }); + expect(config.providers.vendor.disabled).not.toBe(true); + expect(config.disabledModels ?? []).toHaveLength(count >= 20 ? count : 0); + expect(reconcileInitialModelSelections(config, rows(count), ["vendor"])).toBe(false); + }); + + test("counts duplicate selectors once and metadata overrides as real switch rows", () => { + const config = fixture(); + const listed = [...rows(19), { provider: "vendor", id: "model-0", custom: true }]; + reconcileInitialModelSelections(config, listed, ["vendor"]); + expect(config.providers.vendor.initialModelSelection?.modelCount).toBe(19); + expect(config.disabledModels).toBeUndefined(); + const withExtraRow = fixture(); + reconcileInitialModelSelections(withExtraRow, [...listed, { provider: "vendor", id: "additional-catalog-id" }], ["vendor"]); + expect(withExtraRow.providers.vendor.initialModelSelection?.status).toBe("all-off"); + expect(withExtraRow.disabledModels).toContain("vendor/additional-catalog-id"); + }); + + test("OFF preserves unrelated exclusions, uses canonical IDs and never repeats", () => { + const config = fixture(); + config.disabledModels = ["other/keep", "vendor/a/b"]; + const listed = [...rows(19), { provider: "vendor", id: "a/b" }]; + reconcileInitialModelSelections(config, listed, ["vendor"]); + expect(config.disabledModels).toHaveLength(21); + expect(config.disabledModels).toContain("other/keep"); + config.disabledModels = config.disabledModels.filter(id => id !== "vendor/model-0"); + expect(reconcileInitialModelSelections(config, listed, ["vendor"])).toBe(false); + expect(config.disabledModels).not.toContain("vendor/model-0"); + }); + + test("OAuth and ChatGPT forwarding are exempt, mixed-auth key connections are not", () => { + for (const name of ["openai", "cursor", "xai"]) { + const provider = providerConfigSeed(getProviderRegistryEntry(name)!); + initializeProviderModelSelection(name, provider); + expect(provider.initialModelSelection).toBeUndefined(); + } + const key = providerConfigSeed(getProviderRegistryEntry("xai")!); + key.authMode = "key"; + key.apiKey = "fixture-key"; + initializeProviderModelSelection("xai", key); + expect(key.initialModelSelection?.status).toBe("pending"); + const local = { adapter: "openai-chat", baseUrl: "http://127.0.0.1:11434/v1", authMode: "local" } satisfies OcxProviderConfig; + initializeProviderModelSelection("local-test", local); + expect((local as OcxProviderConfig).initialModelSelection?.status).toBe("pending"); + }); + + test("existing selections and marker survive provider replacement and OAuth upsert", () => { + const existing = fixture().providers.vendor; + existing.selectedModels = ["chosen"]; + existing.modelPreset = { mode: "custom" }; + existing.newModelPolicy = "off"; + existing.initialModelSelection = { ...existing.initialModelSelection!, status: "all-off", modelCount: 20 }; + const replacement: OcxProviderConfig = { adapter: "openai-chat", baseUrl: existing.baseUrl }; + initializeProviderModelSelection("vendor", replacement, existing); + expect(replacement.selectedModels).toEqual(["chosen"]); + expect(replacement.modelPreset).toEqual({ mode: "custom" }); + expect(replacement.newModelPolicy).toBe("off"); + expect(replacement.initialModelSelection).toEqual(existing.initialModelSelection); + const xai = providerConfigSeed(getProviderRegistryEntry("xai")!); + xai.selectedModels = ["grok-4.6"]; + const config: OcxConfig = { port: 0, defaultProvider: "xai", providers: { xai } }; + upsertOAuthProvider(config, "xai"); + expect(config.providers.xai.selectedModels).toEqual(["grok-4.6"]); + expect(config.providers.xai.initialModelSelection).toBeUndefined(); + }); + + test("an unresolved mixed-auth key follows the router's OAuth exemption", () => { + const env = "OCX_INITIAL_SELECTION_KEY_FIXTURE"; + const previous = process.env[env]; + delete process.env[env]; + try { + const provider = providerConfigSeed(getProviderRegistryEntry("xai")!); + provider.authMode = "key"; + provider.apiKey = `\${${env}}`; + initializeProviderModelSelection("xai", provider); + expect(provider.initialModelSelection).toBeUndefined(); + process.env[env] = "fixture-key"; + initializeProviderModelSelection("xai", provider); + expect(provider.initialModelSelection?.status).toBe("pending"); + } finally { + if (previous === undefined) delete process.env[env]; + else process.env[env] = previous; + } + }); + + test("intentional live/disk listener differences do not fence initial selection forever", async () => { + const config = fixture(); + configStore.saveConfig(config); + const baseline = configStore.loadConfig(); + const edited = configStore.loadConfig(); + edited.port = 23456; + edited.hostname = "127.0.0.2"; + configStore.saveConfig(edited); + configStore.reconcileLiveConfigFromDisk(config, baseline); + expect(config.port).toBe(0); + await resolvePendingInitialModelSelection(config); + expect(config.providers.vendor.initialModelSelection?.status).toBe("all-off"); + expect(config.port).toBe(0); + expect(configStore.loadConfig().port).toBe(23456); + expect(configStore.loadConfig().hostname).toBe("127.0.0.2"); + expect(configStore.loadConfig().disabledModels).toHaveLength(20); + }); + + test("management discovery finalizes and persists with Codex integration OFF", async () => { + const config = fixture(); + configStore.saveConfig(config); + expect(config.clientIntegrations?.codex).toBe(false); + const response = await api(config, "/api/models"); + expect(response.status).toBe(200); + const listed = (await response.json()).filter((row: { provider: string }) => row.provider === "vendor"); + expect(listed).toHaveLength(20); + expect(listed.every((row: { disabled: boolean }) => row.disabled)).toBe(true); + expect(config.providers.vendor.initialModelSelection?.status).toBe("all-off"); + const saved = configStore.loadConfig(); + expect(saved.providers.vendor.initialModelSelection?.modelCount).toBe(20); + expect(saved.disabledModels).toHaveLength(20); + expect(filterCatalogVisibleModels(rows(20), saved)).toEqual([]); + }); + + test("POST creation stamps its own pending state and overwrite preserves selections", async () => { + const config: OcxConfig = { ...configStore.getDefaultConfig(), port: 0, clientIntegrations: { codex: false } }; + configStore.saveConfig(config); + const provider = { + adapter: "openai-chat", baseUrl: "http://127.0.0.1:11434/v1", allowPrivateNetwork: true, + liveModels: false, models: rows(20).map(row => row.id), + initialModelSelection: { version: 1, registrationId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", status: "ready" }, + }; + expect((await api(config, "/api/providers", { name: "vendor", provider }, "POST")).status).toBe(200); + const created = config.providers.vendor; + expect(created.initialModelSelection?.status).toBe("pending"); + const registrationId = created.initialModelSelection?.registrationId; + expect(registrationId).not.toBe(provider.initialModelSelection.registrationId); + created.selectedModels = ["model-2"]; + created.modelPreset = { mode: "custom" }; + configStore.saveConfig(config); + expect((await api(config, "/api/providers", { name: "vendor", provider }, "POST")).status).toBe(200); + const saved = configStore.loadConfig().providers.vendor; + expect(saved.selectedModels).toEqual(["model-2"]); + expect(saved.modelPreset).toEqual({ mode: "custom" }); + expect(saved.initialModelSelection?.registrationId).toBe(registrationId); + expect(saved.disabled).not.toBe(true); + }); + + test("new registration clears orphaned OFF selectors without touching other providers", async () => { + const config: OcxConfig = { + ...configStore.getDefaultConfig(), port: 0, clientIntegrations: { codex: false }, + disabledModels: ["vendor/model-0", "vendor/a/b", "vendor-old/keep", "other/keep"], + modelDiscovery: { + newModelPolicy: "off", + knownModels: { vendor: { ids: ["old"], removed: [], updatedAt: "old" }, other: { ids: ["keep"], removed: [], updatedAt: "old" } }, + recentArrivals: { vendor: [{ id: "old", at: "old" }] }, + }, + }; + configStore.saveConfig(config); + const provider = { adapter: "openai-chat", baseUrl: "http://127.0.0.1:11434/v1", allowPrivateNetwork: true, liveModels: false, models: ["model-0", "a/b"] }; + expect((await api(config, "/api/providers", { name: "vendor", provider }, "POST")).status).toBe(200); + await api(config, "/api/models"); + expect(configStore.loadConfig().disabledModels).toEqual(["vendor-old/keep", "other/keep"]); + expect(config.providers.vendor.initialModelSelection?.status).toBe("ready"); + expect(config.providers.vendor.disabled).not.toBe(true); + expect(configStore.loadConfig().modelDiscovery?.knownModels?.vendor).toBeUndefined(); + expect(configStore.loadConfig().modelDiscovery?.knownModels?.other?.ids).toEqual(["keep"]); + expect(configStore.loadConfig().modelDiscovery?.recentArrivals?.vendor).toBeUndefined(); + }); + + test("new-registration cleanup preserves a current combo alias sharing the namespace", () => { + const config = fixture(); + config.disabledModels = ["vendor/combo-alias", "vendor/orphan", "other/keep"]; + config.combos = { retained: { alias: "vendor/combo-alias", targets: [{ provider: "other", model: "keep" }] } }; + const provider: OcxProviderConfig = { adapter: "openai-chat", baseUrl: "https://models.example.test/v1" }; + initializeProviderModelSelection("vendor", provider, undefined, config); + expect(config.disabledModels).toEqual(["vendor/combo-alias", "other/keep"]); + }); + + test("key-login commit initializes new rows and preserves choices during key replacement", async () => { + const config: OcxConfig = { ...configStore.getDefaultConfig(), port: 0, clientIntegrations: { codex: false } }; + configStore.saveConfig(config); + const provider: OcxProviderConfig = { adapter: "openai-chat", baseUrl: "https://models.example.test/v1", apiKey: "fixture-first" }; + await commitKeyLoginProvider(config, "vendor", provider); + const first = configStore.loadConfig().providers.vendor; + expect(first.initialModelSelection?.status).toBe("pending"); + config.providers.vendor.selectedModels = ["chosen"]; + configStore.saveConfig(config); + await commitKeyLoginProvider(config, "vendor", { ...provider, apiKey: "fixture-second" }); + const saved = configStore.loadConfig().providers.vendor; + expect(saved.apiKey).toBe("fixture-second"); + expect(saved.selectedModels).toEqual(["chosen"]); + expect(saved.initialModelSelection?.registrationId).toBe(first.initialModelSelection?.registrationId); + }); + + test("batch editor creates pending state server-side without resetting edited existing rows", async () => { + const config = fixture(); + config.providers.vendor.baseUrl = "http://127.0.0.1:11434/v1"; + config.providers.vendor.allowPrivateNetwork = true; + config.disabledModels = ["batch/model-0", "other/keep"]; + configStore.saveConfig(config); + const registrationId = config.providers.vendor.initialModelSelection?.registrationId; + const baseline = providerEditorConfigDTO(config); + const next = structuredClone(baseline); + next.providers.vendor.selectedModels = ["model-1"]; + next.providers.batch = { adapter: "openai-chat", baseUrl: "http://127.0.0.1:11435/v1", allowPrivateNetwork: true, liveModels: false, models: ["model-0"] }; + const response = await api(config, "/api/providers", { baseline, next }); + expect(response.status).toBe(200); + const saved = configStore.loadConfig(); + expect(saved.providers.batch.initialModelSelection?.status).toBe("pending"); + expect(saved.providers.batch.disabled).not.toBe(true); + expect(saved.providers.vendor.initialModelSelection?.registrationId).toBe(registrationId); + expect(saved.providers.vendor.selectedModels).toEqual(["model-1"]); + expect(saved.disabledModels).toEqual(["other/keep"]); + expect(config.disabledModels).toEqual(["other/keep"]); + }); + + test("degraded discovery does not complete initialization or expose models", () => { + const config = fixture(); + configStore.saveConfig(config); + const before = readFileSync(configStore.getConfigPath(), "utf8"); + finalizeInitialModelSelection(config, captureInitialSelectionBaseline(config), rows(20), []); + expect(config.providers.vendor.initialModelSelection?.status).toBe("pending"); + expect(filterCatalogVisibleModels(rows(20), config)).toEqual([]); + expect(readFileSync(configStore.getConfigPath(), "utf8")).toBe(before); + }); + + test("another initializer and subsequent manual enable are adopted, never overwritten", async () => { + const config = fixture(); + configStore.saveConfig(config); + const baseline = captureInitialSelectionBaseline(config); + const other = configStore.loadConfig(); + await resolvePendingInitialModelSelection(other); + other.disabledModels = other.disabledModels!.filter(id => id !== "vendor/model-0"); + configStore.saveConfig(other); + finalizeInitialModelSelection(config, baseline, rows(20), ["vendor"]); + expect(config.providers.vendor.initialModelSelection?.status).toBe("all-off"); + expect(config.disabledModels).not.toContain("vendor/model-0"); + expect(configStore.loadConfig().disabledModels).not.toContain("vendor/model-0"); + }); + + test("a concurrent provider edit invalidates an initial decision", () => { + const config = fixture(); + configStore.saveConfig(config); + const baseline = captureInitialSelectionBaseline(config); + const edited = configStore.loadConfig(); + edited.providers.vendor.selectedModels = ["model-2"]; + configStore.saveConfig(edited); + finalizeInitialModelSelection(config, baseline, rows(20), ["vendor"]); + expect(configStore.loadConfig().providers.vendor.selectedModels).toEqual(["model-2"]); + expect(configStore.loadConfig().disabledModels).toBeUndefined(); + }); + + test("failed persistence keeps pending, including management rows and candidate APIs", async () => { + // The failed write must leave both policy and visibility pending. + const config = fixture(); + configStore.saveConfig(config); + writeFileSync(configStore.getConfigPath(), "{invalid"); + const response = await api(config, "/api/models"); + const listed = (await response.json()).filter((row: { provider: string }) => row.provider === "vendor"); + expect(listed).toHaveLength(20); + expect(listed.every((row: { disabled: boolean; initialSelectionPending: boolean }) => row.disabled && row.initialSelectionPending)).toBe(true); + for (const path of ["/api/injection-model", "/api/subagent-model-fallback"]) { + const candidates = await (await api(config, path)).json(); + expect(JSON.stringify(candidates.available)).not.toContain("vendor/"); + } + const put = await api(config, "/api/model-visibility", { scope: "provider", provider: "vendor", enabled: true, targets: [{ id: "model-0" }] }); + expect(put.status).toBe(409); + expect(config.providers.vendor.disabled).not.toBe(true); + expect(readFileSync(configStore.getConfigPath(), "utf8")).toBe("{invalid"); + }); + + test("identical delete and re-registration cannot consume an earlier discovery", () => { + const old = fixture(); + configStore.saveConfig(old); + const baseline = captureInitialSelectionBaseline(old); + const replacement = fixture(); + expect(replacement.providers.vendor.initialModelSelection?.registrationId) + .not.toBe(old.providers.vendor.initialModelSelection?.registrationId); + configStore.saveConfig(replacement); + finalizeInitialModelSelection(old, baseline, rows(20), ["vendor"]); + const saved = configStore.loadConfig(); + expect(saved.providers.vendor.initialModelSelection?.status).toBe("pending"); + expect(saved.providers.vendor.initialModelSelection?.registrationId).toBe(replacement.providers.vendor.initialModelSelection?.registrationId); + expect(saved.disabledModels).toBeUndefined(); + }); + + test("custom inventory changes invalidate a gathered count", () => { + const config = fixture(19); + configStore.saveConfig(config); + const baseline = captureInitialSelectionBaseline(config); + const edited = configStore.loadConfig(); + edited.customModels = [{ id: "extra", provider: "vendor", modelId: "extra-model", displayName: "Extra" }]; + configStore.saveConfig(edited); + finalizeInitialModelSelection(config, baseline, rows(19), ["vendor"]); + const saved = configStore.loadConfig(); + expect(saved.providers.vendor.initialModelSelection?.status).toBe("pending"); + expect(saved.customModels?.[0].modelId).toBe("extra-model"); + }); + + test("a thrown transaction never publishes a completed marker", () => { + const config = fixture(); + configStore.saveConfig(config); + const mutation = spyOn(configStore, "mutatePersistedConfig").mockImplementation(() => { throw new Error("fixture failure"); }); + const warn = spyOn(console, "warn").mockImplementation(() => {}); + try { + finalizeInitialModelSelection(config, captureInitialSelectionBaseline(config), rows(20), ["vendor"]); + expect(config.providers.vendor.initialModelSelection?.status).toBe("pending"); + expect(config.disabledModels).toBeUndefined(); + } finally { mutation.mockRestore(); warn.mockRestore(); } + }); + + test("state round-trips as read-only DTO metadata; malformed state does not discard providers", () => { + const config = fixture(); + configStore.saveConfig(config); + const loaded = configStore.loadConfig(); + expect(loaded.providers.vendor.initialModelSelection?.status).toBe("pending"); + expect((safeConfigDTO(loaded) as { providers: Record }).providers.vendor.initialModelSelection?.status).toBe("pending"); + expect(providerEditorConfigDTO(loaded).providers.vendor.initialModelSelection).toBeUndefined(); + writeFileSync(configStore.getConfigPath(), JSON.stringify({ ...config, providers: { vendor: { ...config.providers.vendor, initialModelSelection: { version: 1, status: "invalid" } } } })); + expect(configStore.loadConfig().providers.vendor.initialModelSelection).toBeUndefined(); + expect(configStore.loadConfig().providers.vendor.apiKey).toBe("fixture-key"); + }); +}); diff --git a/tests/providers/provider-config-batch-management.test.ts b/tests/providers/provider-config-batch-management.test.ts index ae3af067f5..c0646c98f9 100644 --- a/tests/providers/provider-config-batch-management.test.ts +++ b/tests/providers/provider-config-batch-management.test.ts @@ -213,7 +213,14 @@ describe("atomic provider editor batch", () => { headers: { "x-beta-private": "keep-me" }, project: "private-beta-project", }); - expect(persisted.providers.gamma).toEqual(next.providers.gamma); + expect(persisted.providers.gamma).toEqual({ + ...next.providers.gamma, + initialModelSelection: { + version: 1, + registrationId: expect.stringMatching(/^[0-9a-f-]{36}$/), + status: "pending", + }, + }); expect(liveConfig.defaultProvider).toBe("beta"); expect(liveConfig.providers).toEqual(persisted.providers); expect(catalogRefreshes).toBe(1);