From f3ca157f5de6251ddd9df0050bef7890b5486e47 Mon Sep 17 00:00:00 2001 From: Kingsword Date: Mon, 7 Sep 2026 23:09:31 +0800 Subject: [PATCH] feat(models): sync official model catalog without blocking startup - Refresh the catalog in the background with caching and timeout fallback - Reload the runtime model registry when opening model selectors - Preserve custom providers, metadata overrides, and selected models - Remove only unchanged, unused, automatically added retired models - Add documentation, regression tests, and native TUI integration tests --- docs/CONFIGURATION.md | 31 ++ packages/zcode-tui/src/index.ts | 26 +- packages/zcode-tui/src/types.ts | 1 + scripts/check-runtime.ts | 1 + scripts/sync-runtime.ts | 28 ++ src/model-access.ts | 2 + src/model-catalog-refresh.ts | 555 ++++++++++++++++++++++++++ test/model-catalog-refresh.test.ts | 613 +++++++++++++++++++++++++++++ test/model-catalog-reload.test.ts | 58 +++ test/model-catalog-tui.test.ts | 119 ++++++ 10 files changed, 1425 insertions(+), 9 deletions(-) create mode 100644 src/model-catalog-refresh.ts create mode 100644 test/model-catalog-refresh.test.ts create mode 100644 test/model-catalog-reload.test.ts create mode 100644 test/model-catalog-tui.test.ts diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 59fb042..7c7fdf1 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -34,6 +34,37 @@ configured. This lets the official runtime and TUI start cleanly without pretending that model access is already configured. Choose one of the model-access paths below before sending a prompt. +## Automatic model catalog updates + +After the TUI is ready, it downloads the official model catalog in the background +and caches it for six hours in `~/.zcode/cli/model-catalog.json`. Startup, including +the first-run wizard, never waits for that request. A first installation starts +with the bundled model list; failed or slow requests leave that list usable. + +Opening `/model`, cycling models, or opening **Settings > Model providers** applies +any downloaded catalog and reloads the running session's model registry. These +actions use local data only and never wait for the network. If discovery is still +running, reopen the picker after it finishes. Providers added during first-run +login are included on the next model selection. Saved `model.main` and +`model.lite` are not automatically switched to a new release. + +Synchronization only covers existing `anthropic` providers named `zai` or +`bigmodel` using their official Coding Plan API roots. Custom endpoints and +protocols are excluded. Existing names, model IDs (including casing), and user +metadata overrides are preserved. New models include context/output limits, +modalities, and supported Anthropic reasoning-effort mappings. + +The adjacent `model-catalog-managed.json` records automatically added entries. +After a successful refresh, an entry missing from both official provider lists +is removed only when it was automatically added, is unchanged, has no catalog +override, and is not selected by `main`, `lite`, or the current session. Bundled +and manually added models are retained because their ownership is unknown. +Offline, invalid, empty, and incomplete responses do not trigger retirement. + +Set `ZCODE_DISABLE_MODEL_CATALOG_REFRESH=1` to disable discovery and automatic +configuration changes. `CI=1` also disables them. Requests honor `ZCODE_BASE_URL` +and use a five-second timeout; failures do not interrupt the TUI. + ## First-run setup wizard When the TUI starts while model access has not been set up, a setup wizard diff --git a/packages/zcode-tui/src/index.ts b/packages/zcode-tui/src/index.ts index 84e2a8e..3eac537 100644 --- a/packages/zcode-tui/src/index.ts +++ b/packages/zcode-tui/src/index.ts @@ -4,6 +4,7 @@ import { constants as osConstants } from "node:os"; import { basename } from "node:path"; import { missingCodingPlanKey } from "../../../src/prompt-preflight.ts"; +import { ModelCatalogRefresh } from "../../../src/model-catalog-refresh.ts"; import { preflightSubmission } from "./prompt-preflight.ts"; import { clearSetupPending, @@ -690,6 +691,7 @@ class ZCodeTui { private backgroundDrainScheduled = false; private backgroundHandoffInterruptInFlight = false; private updateCheckAbortController?: AbortController; + private modelCatalogRefresh?: ModelCatalogRefresh; private loginRequired: boolean; private removeStreamErrorGuards?: () => void; @@ -850,6 +852,13 @@ class ZCodeTui { this.updateTurnStatus(); this.ui.requestRender(true); this.startUpdateRefresh(updateCheck); + if (this.options.reloadModelOptions) { + this.modelCatalogRefresh = new ModelCatalogRefresh({ + baseUrl: process.env.ZCODE_BASE_URL?.trim() || "https://zcode.z.ai", + currentVersion: this.distributionVersion || this.options.version || "0.0.0" + }); + this.modelCatalogRefresh.start(); + } if (!this.loginRequired) void this.refreshGoal(); if (!this.loginRequired) void this.refreshSessionUsage(); if (await readSetupPending().catch(() => false)) { @@ -1643,6 +1652,7 @@ class ZCodeTui { const explicitModel = explicitModelRequest(input); if (explicitModel) { this.addUserMessage(submission.displayInput); + await this.refreshModelOptions(); await this.switchTransientModel(explicitModel); return; } @@ -3655,17 +3665,14 @@ class ZCodeTui { return true; } - /** - * Refresh modelOptions from the bridge. After a fresh login - * (loginRequired was true) the runtime skipped model loading, so the - * initial options list may be empty; all model-switch entry points share - * this refresh. - */ + /** All model selectors re-read the catalog, including after first-run login. */ private async refreshModelOptions(): Promise { - if (this.modelOptions.length === 0 && this.options.listModelOptions) { + const load = this.options.reloadModelOptions ?? this.options.listModelOptions; + if (load) { try { - const refreshed = await this.options.listModelOptions(); - if (Array.isArray(refreshed) && refreshed.length > 0) { + await this.modelCatalogRefresh?.apply([this.model]).catch(() => {}); + const refreshed = await load(); + if (Array.isArray(refreshed)) { this.modelOptions = [...refreshed]; } } catch (error) { @@ -5641,6 +5648,7 @@ class ZCodeTui { for (const controller of this.steerAbortControllers) controller.abort(); this.steerAbortControllers.clear(); this.updateCheckAbortController?.abort(); + this.modelCatalogRefresh?.stop(); if (this.turnTimer) clearInterval(this.turnTimer); if (this.rewindEscapeTimer) clearTimeout(this.rewindEscapeTimer); if (this.fullscreenWelcomeTransitionTimer) clearTimeout(this.fullscreenWelcomeTransitionTimer); diff --git a/packages/zcode-tui/src/types.ts b/packages/zcode-tui/src/types.ts index 9df0206..d907132 100644 --- a/packages/zcode-tui/src/types.ts +++ b/packages/zcode-tui/src/types.ts @@ -76,6 +76,7 @@ export interface RuntimeAdapter { listPluginReferences?: ListPluginReferences; listSkills?: ListSkills; listModelOptions?: () => Promise; + reloadModelOptions?: () => Promise; setTransientModel?: (modelId: string) => Promise; recallPreviousInput?: (skip: number) => Promise; readGoal?: () => Promise; diff --git a/scripts/check-runtime.ts b/scripts/check-runtime.ts index 2f45610..fdfd12d 100755 --- a/scripts/check-runtime.ts +++ b/scripts/check-runtime.ts @@ -144,6 +144,7 @@ if (patchRuntimeLoginModelDefaults(runtimeSource) !== runtimeSource || !/setMode:[A-Za-z_$][\w$]*\.setMode/u.test(runtimeSource) || !/listSkills:[A-Za-z_$][\w$]*\.listSkills/u.test(runtimeSource) || !/listModelOptions:[A-Za-z_$][\w$]*\.listModelOptions/u.test(runtimeSource) + || !/reloadModelOptions:[A-Za-z_$][\w$]*\.reloadModelOptions/u.test(runtimeSource) || !/setTransientModel:[A-Za-z_$][\w$]*\.setTransientModel/u.test(runtimeSource) || !/subscribeSessionEvents:[A-Za-z_$][\w$]*\.subscribeSessionEvents/u.test(runtimeSource) || !/sendBackgroundTaskMessage:[A-Za-z_$][\w$]*\.sendBackgroundTaskMessage/u.test(runtimeSource)) { diff --git a/scripts/sync-runtime.ts b/scripts/sync-runtime.ts index 5cb288b..11c825f 100755 --- a/scripts/sync-runtime.ts +++ b/scripts/sync-runtime.ts @@ -876,6 +876,27 @@ export function patchRuntimeTuiBridge(runtime: string): string { return patched; } +export function patchRuntimeModelCatalogReload(runtime: string): string { + if (/reloadModelOptions:[A-Za-z_$][\w$]*\.reloadModelOptions/u.test(runtime) + && runtime.includes(".reloadModelOptions=async()=>")) return runtime; + const list = /([A-Za-z_$][\w$]*)\.listModelOptions=async\(\)=>\(await ([A-Za-z_$][\w$]*)\(\)\)\.listModels\?\.\(\)\?\?\[\]/u.exec(runtime); + const createConfig = /[A-Za-z_$][\w$]*\(([A-Za-z_$][\w$]*),"createConfig"\)/u.exec(runtime)?.[1]; + const factoryStart = list ? runtime.lastIndexOf("function ", list.index) : -1; + const host = factoryStart >= 0 && list + ? /^function [A-Za-z_$][\w$]*\(([A-Za-z_$][\w$]*)(?:,|\))/u.exec(runtime.slice(factoryStart, list.index))?.[1] + : undefined; + const option = /listModelOptions:([A-Za-z_$][\w$]*)\.listModelOptions/u.exec(runtime); + if (!list || !createConfig || !host || !option || !runtime.includes('"setModelCatalogOverlay"')) { + throw new Error("ZCode runtime is incompatible with model catalog reload (config/overlay bridge anchor missing)."); + } + const [, bridge, getApp] = list; + // The native loader retains user/project/env precedence and translates provider + // metadata. The overlay replaces the registry without replacing the session. + const reload = `${bridge}.reloadModelOptions=async()=>{let $zApp=await ${getApp}(),$zConfig=${createConfig}({env:${host}.env??process.env,workingDirectory:(${host}.cwd??process.cwd)(),projectConfigPath:${host}.projectConfigPath,skipUserConfig:${host}.skipUserConfig,userConfigPath:${host}.userConfigPath}).config,$zModel=$zConfig.model;if($zModel&&$zApp.setModelCatalogOverlay)await $zApp.setModelCatalogOverlay({targets:[$zModel.main,...$zModel.lite?[$zModel.lite]:[],...$zModel.available??[]],catalogOverrides:$zConfig.modelCatalog.overrides});return $zApp.listModels?.()??[]}`; + return runtime.replace(list[0], `${reload},${list[0]}`) + .replace(option[0], `reloadModelOptions:${option[1]}.reloadModelOptions,${option[0]}`); +} + export function patchRuntimeOAuthHttpErrors(runtime: string): string { if (runtime.includes("empty or non-JSON response")) return runtime; if (!runtime.includes('"OAuth response is not valid JSON",{httpStatus:void 0}')) return runtime; @@ -1335,6 +1356,13 @@ export const runtimePatchPlan: readonly RuntimePatchDefinition[] = [ verify: (runtime) => runtime.includes(".readRuntimeProjection=async()=>{let $zRuntimeProjectionBridge=await ") && runtime.includes(".loadSessionContextMessages=async()=>await(await") }, + { + id: "model-catalog-reload", + requirement: "required", + apply: patchRuntimeModelCatalogReload, + verify: (runtime) => /reloadModelOptions:[A-Za-z_$][\w$]*\.reloadModelOptions/u.test(runtime) + && runtime.includes(".reloadModelOptions=async()=>") + }, { id: "goal-failure-pause", requirement: "optional", diff --git a/src/model-access.ts b/src/model-access.ts index e7d0f7f..d52b008 100644 --- a/src/model-access.ts +++ b/src/model-access.ts @@ -145,7 +145,9 @@ export async function updateUserConfig( ): Promise { const configPath = userConfigPath(env); const config = await readUserConfig(env); + const before = JSON.stringify(config); update(config); + if (JSON.stringify(config) === before) return configPath; const temporaryPath = join( dirname(configPath), diff --git a/src/model-catalog-refresh.ts b/src/model-catalog-refresh.ts new file mode 100644 index 0000000..d32d161 --- /dev/null +++ b/src/model-catalog-refresh.ts @@ -0,0 +1,555 @@ +import { randomUUID } from "node:crypto"; +import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { dirname, join, posix, win32 } from "node:path"; +import { isDeepStrictEqual } from "node:util"; + +import { updateUserConfig } from "./model-access.ts"; + +export const MODEL_CATALOG_REFRESH_TTL_MS = 6 * 60 * 60 * 1_000; +export const MODEL_CATALOG_URL_PATH = "/api/v1/client/configs"; +const DEFAULT_TIMEOUT_MS = 5_000; +const PROVIDER_BASE_URLS = { + zai: "https://api.z.ai/api/anthropic", + bigmodel: "https://open.bigmodel.cn/api/anthropic" +} as const; +type SupportedProviderId = keyof typeof PROVIDER_BASE_URLS; +const MODALITIES = new Set(["text", "audio", "image", "video", "pdf"]); +const EFFORTS = new Set(["low", "medium", "high", "xhigh", "max"]); + +interface RemoteBuiltinModel { + modelId: string; + name: string; + contextWindow?: number; + reasoning?: { + levels?: Record; + defaultLevel?: string; + }; + modalities?: { + input?: string[]; + output?: string[]; + }; + maxCompletionTokens?: number; +} + +interface RemoteBuiltinProvider { + id: string; + schema?: string; + models: string[]; +} + +interface RemoteClientConfigs { + builtinModels?: RemoteBuiltinModel[]; + builtinProviders?: RemoteBuiltinProvider[]; + retirementSafe?: boolean; +} + +export type ModelCatalogFetcher = (url: string, init: RequestInit) => Promise; + +export interface ModelCatalogCache { + endpoint?: string; + retirementSafe?: boolean; + lastFetchedAt: string; + builtinModels: RemoteBuiltinModel[]; + builtinProviders: RemoteBuiltinProvider[]; +} + +export interface RefreshModelCatalogOptions { + baseUrl: string; + currentVersion: string; + env?: NodeJS.ProcessEnv; + fetcher?: ModelCatalogFetcher; + now?: number; + signal?: AbortSignal; + timeoutMs?: number; +} + +export interface RefreshModelCatalogResult { + cachePath: string; + refreshed: boolean; + catalog: ModelCatalogCache | null; +} + +function enabledEnvironmentFlag(value: string | undefined): boolean { + if (value === undefined) return false; + return !["", "0", "false", "no", "off"].includes(value.trim().toLowerCase()); +} + +export function modelCatalogRefreshDisabled(env: NodeJS.ProcessEnv = process.env): boolean { + return enabledEnvironmentFlag(env.CI) + || enabledEnvironmentFlag(env.ZCODE_DISABLE_MODEL_CATALOG_REFRESH); +} + +export function modelCatalogCachePath( + env: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform = process.platform, + fallbackHome: string = homedir() +): string { + const path = platform === "win32" ? win32 : posix; + const configuredHome = (platform === "win32" ? env.USERPROFILE : env.HOME)?.trim(); + return path.join(configuredHome || fallbackHome, ".zcode", "cli", "model-catalog.json"); +} + +export function resolveModelCatalogEndpoint( + baseUrl: string, + env: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform = process.platform, + arch: string = process.arch +): string { + const url = new URL(baseUrl); + if (url.protocol !== "https:" && url.protocol !== "http:") { + throw new Error("Model catalog endpoint must use HTTP or HTTPS."); + } + url.pathname = `${url.pathname.replace(/\/+$/u, "")}${MODEL_CATALOG_URL_PATH}`; + url.hash = ""; + url.searchParams.set("platform", `${platform}-${arch}`); + const appVersion = env.ZCODE_APP_CLI_VERSION?.trim() || ""; + if (appVersion) url.searchParams.set("app_version", appVersion); + return url.toString(); +} + +function catalogEndpoint(options: RefreshModelCatalogOptions): string { + const env = options.env ?? process.env; + return resolveModelCatalogEndpoint(options.baseUrl, { + ...env, + ZCODE_APP_CLI_VERSION: env.ZCODE_APP_CLI_VERSION?.trim() || options.currentVersion + }); +} + +function toModelId(value: string): string { + return value.trim().toLowerCase(); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function modelId(value: unknown): value is string { + return typeof value === "string" && /^[a-z0-9][a-z0-9._-]*$/iu.test(value.trim()); +} + +function positiveInteger(value: unknown): value is number { + return typeof value === "number" && Number.isSafeInteger(value) && value > 0; +} + +function parseModalities(value: unknown): string[] | undefined { + if (!Array.isArray(value)) return undefined; + const valid = value.filter((item): item is string => typeof item === "string" && MODALITIES.has(item)); + return valid.length > 0 || value.length === 0 ? [...new Set(valid)] : undefined; +} + +function parseCatalog(data: unknown): RemoteClientConfigs { + if (!isRecord(data)) return {}; + const builtinModels: RemoteBuiltinModel[] = []; + const builtinProviders: RemoteBuiltinProvider[] = []; + const candidates: unknown[] = Array.isArray(data.builtinModels) ? [...data.builtinModels] : []; + // The official endpoint also advertises older supported models in providers. + // Include that membership before deciding whether an auto-added model retired. + for (const provider of Array.isArray(data.providers) ? data.providers : []) { + if (!isRecord(provider) || provider.schema !== "anthropic" || !Array.isArray(provider.models)) continue; + const family = provider.id === "z-ai" ? "zai" : provider.id === "bigmodel" ? "bigmodel" : undefined; + if (!family || typeof provider.baseUrl !== "string" + || provider.baseUrl.trim().replace(/\/+$/u, "") !== PROVIDER_BASE_URLS[family]) continue; + candidates.push(...provider.models); + builtinProviders.push({ + id: `builtin:${family}-coding-plan`, schema: "anthropic", + models: provider.models.flatMap((model) => isRecord(model) && modelId(model.modelId) ? [model.modelId] : []) + }); + } + const seen = new Set(); + for (const value of candidates) { + if (!isRecord(value) || !modelId(value.modelId)) continue; + const id = toModelId(value.modelId); + if (seen.has(id)) continue; + seen.add(id); + const model: RemoteBuiltinModel = { + modelId: value.modelId.trim(), + name: typeof value.name === "string" && value.name.trim() + ? value.name.trim() : value.modelId.trim() + }; + if (positiveInteger(value.contextWindow)) model.contextWindow = value.contextWindow; + if (positiveInteger(value.maxCompletionTokens)) model.maxCompletionTokens = value.maxCompletionTokens; + if (isRecord(value.modalities)) { + const input = parseModalities(value.modalities.input); + const output = parseModalities(value.modalities.output); + if (input || output) model.modalities = { ...(input ? { input } : {}), ...(output ? { output } : {}) }; + } + if (isRecord(value.reasoning) && isRecord(value.reasoning.levels)) { + model.reasoning = { + levels: value.reasoning.levels, + ...(typeof value.reasoning.defaultLevel === "string" ? { defaultLevel: value.reasoning.defaultLevel } : {}) + }; + } + builtinModels.push(model); + } + for (const value of Array.isArray(data.builtinProviders) ? data.builtinProviders : []) { + if (!isRecord(value) || typeof value.id !== "string" || !Array.isArray(value.models)) continue; + builtinProviders.push({ + id: value.id, + ...(typeof value.schema === "string" ? { schema: value.schema } : {}), + models: value.models.filter(modelId).map((id) => id.trim()) + }); + } + return { builtinModels, builtinProviders }; +} + +function usableCatalog(catalog: RemoteClientConfigs): boolean { + const ids = new Set(catalog.builtinModels?.map((model) => toModelId(model.modelId))); + return catalog.builtinProviders?.some((provider) => ( + providerFamilyForBuiltinId(provider.id) !== undefined + && (provider.schema === undefined || provider.schema === "anthropic") + && provider.models.some((id) => ids.has(toModelId(id))) + )) ?? false; +} + +function completeModelList(value: unknown): boolean { + return Array.isArray(value) && value.every((model) => isRecord(model) && modelId(model.modelId)); +} + +function completeBuiltinLists(value: Record): boolean { + return completeModelList(value.builtinModels) && Array.isArray(value.builtinProviders) + && value.builtinProviders.every((provider) => isRecord(provider) && typeof provider.id === "string" + && Array.isArray(provider.models) && provider.models.every(modelId)); +} + +async function readCatalogCache(cachePath: string, endpoint: string): Promise { + try { + const value: unknown = JSON.parse(await readFile(cachePath, "utf8")); + if (!isRecord(value) || value.endpoint !== endpoint) return undefined; + const lastFetchedAt = typeof value.lastFetchedAt === "string" ? value.lastFetchedAt : undefined; + if (!lastFetchedAt || !Number.isFinite(Date.parse(lastFetchedAt))) return undefined; + const catalog = parseCatalog(value); + if (!usableCatalog(catalog)) return undefined; + return { + endpoint, lastFetchedAt, + retirementSafe: value.retirementSafe === true && completeBuiltinLists(value), + builtinModels: catalog.builtinModels!, builtinProviders: catalog.builtinProviders! + }; + } catch { + return undefined; + } +} + +async function writePrivateJson(path: string, value: unknown): Promise { + const directory = dirname(path); + const temporaryPath = join(directory, `.model-catalog.${process.pid}.${randomUUID()}.tmp`); + await mkdir(directory, { recursive: true, mode: 0o700 }); + try { + await writeFile(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, { + encoding: "utf8", + flag: "wx", + mode: 0o600 + }); + await rename(temporaryPath, path); + } finally { + await rm(temporaryPath, { force: true }).catch(() => {}); + } +} + +function parseRemoteClientConfigs(body: unknown): RemoteClientConfigs { + if (!isRecord(body) || body.code !== 0) return {}; + const data = body.data; + const catalog = parseCatalog(data); + catalog.retirementSafe = isRecord(data) && completeBuiltinLists(data) + && Array.isArray(data.providers) && data.providers.every((provider) => ( + isRecord(provider) && typeof provider.id === "string" && typeof provider.schema === "string" + && typeof provider.baseUrl === "string" && completeModelList(provider.models) + )); + return catalog; +} + +export async function fetchRemoteModelCatalog( + options: RefreshModelCatalogOptions +): Promise { + if (options.signal?.aborted) return {}; + const controller = new AbortController(); + const abort = () => controller.abort(options.signal?.reason); + if (options.signal?.aborted) abort(); + else options.signal?.addEventListener("abort", abort, { once: true }); + const timeout = setTimeout( + () => controller.abort(new Error("Model catalog refresh timed out.")), + options.timeoutMs ?? DEFAULT_TIMEOUT_MS + ); + + try { + const url = catalogEndpoint(options); + const fetcher = options.fetcher ?? ((requestUrl, init) => fetch(requestUrl, init)); + const response = await fetcher(url, { + headers: { + accept: "application/json", + "user-agent": `zcode-app-cli/${options.currentVersion}` + }, + signal: controller.signal + }); + if (!response.ok) return {}; + const body: unknown = await response.json(); + return parseRemoteClientConfigs(body); + } catch { + return {}; + } finally { + clearTimeout(timeout); + options.signal?.removeEventListener("abort", abort); + } +} + +export async function refreshModelCatalog( + options: RefreshModelCatalogOptions +): Promise { + const env = options.env ?? process.env; + const cachePath = modelCatalogCachePath(env); + const now = options.now ?? Date.now(); + if (modelCatalogRefreshDisabled(env) || options.signal?.aborted) { + return { cachePath, refreshed: false, catalog: null }; + } + + let endpoint: string; + try { + endpoint = catalogEndpoint(options); + } catch { + return { cachePath, refreshed: false, catalog: null }; + } + + const cached = await readCatalogCache(cachePath, endpoint); + const existing = cached && Date.parse(cached.lastFetchedAt) <= now ? cached : undefined; + const age = existing ? now - Date.parse(existing.lastFetchedAt) : Infinity; + if (existing && age >= 0 && age < MODEL_CATALOG_REFRESH_TTL_MS) { + return { cachePath, refreshed: false, catalog: existing }; + } + + const remote = await fetchRemoteModelCatalog(options); + if (options.signal?.aborted) return { cachePath, refreshed: false, catalog: null }; + if (!usableCatalog(remote)) { + return { cachePath, refreshed: false, catalog: existing ?? null }; + } + + const cache: ModelCatalogCache = { + endpoint, + retirementSafe: remote.retirementSafe === true, + lastFetchedAt: new Date(now).toISOString(), + builtinModels: remote.builtinModels ?? [], + builtinProviders: remote.builtinProviders ?? [] + }; + await writePrivateJson(cachePath, cache).catch(() => {}); + return { cachePath, refreshed: true, catalog: cache }; +} + +function providerFamilyForBuiltinId(builtinId: string): SupportedProviderId | undefined { + if (builtinId === "builtin:zai-coding-plan") return "zai"; + if (builtinId === "builtin:bigmodel-coding-plan") return "bigmodel"; + return undefined; +} + +function buildReasoning(model: RemoteBuiltinModel): Record | undefined { + const options: Record = {}; + // Translate only the official Anthropic effort mapping supported by this runtime. + for (const [level, value] of Object.entries(model.reasoning?.levels ?? {})) { + if (!modelId(level) || !isRecord(value) || !isRecord(value.anthropic)) continue; + const operations = value.anthropic.set; + if (!Array.isArray(operations) || operations.length !== 1 || value.anthropic.unset !== undefined) continue; + const operation = operations[0]; + if (!isRecord(operation) || !Array.isArray(operation.path) + || operation.path.length !== 2 || operation.path[0] !== "output_config" || operation.path[1] !== "effort" + || typeof operation.value !== "string" || !EFFORTS.has(operation.value)) continue; + options[level] = { anthropic: { effort: operation.value } }; + } + const levels = Object.keys(options); + if (!levels.length) return undefined; + const defaultLevel = model.reasoning?.defaultLevel; + return { + enabled: true, + levels, + ...(defaultLevel && levels.includes(defaultLevel) ? { defaultLevel } : {}), + providerOptionsByLevel: options + }; +} + +function buildModelEntry(model: RemoteBuiltinModel): Record { + const entry: Record = { name: model.name }; + if (typeof model.contextWindow === "number") { + entry.limit = { + context: model.contextWindow, + ...(typeof model.maxCompletionTokens === "number" ? { output: model.maxCompletionTokens } : {}) + }; + } else if (typeof model.maxCompletionTokens === "number") { + entry.limit = { output: model.maxCompletionTokens }; + } + if (model.modalities && (model.modalities.input || model.modalities.output)) { + entry.modalities = { + ...(model.modalities.input ? { input: model.modalities.input } : {}), + ...(model.modalities.output ? { output: model.modalities.output } : {}) + }; + } + const reasoning = buildReasoning(model); + if (reasoning) entry.reasoning = reasoning; + return entry; +} + +function isOfficialProvider(provider: Record, family: SupportedProviderId): boolean { + if (provider.kind !== "anthropic" || !isRecord(provider.options)) return false; + const baseURL = provider.options.baseURL; + return typeof baseURL === "string" && baseURL.trim().replace(/\/+$/u, "") === PROVIDER_BASE_URLS[family]; +} + +function mergeModelEntry(entry: Record, existing: Record): Record { + const merged = { ...entry, ...existing }; + for (const key of ["limit", "modalities"]) { + if (isRecord(entry[key]) && isRecord(existing[key])) merged[key] = { ...entry[key], ...existing[key] }; + } + return merged; +} + +interface ManagedCatalog { + endpoint: string; + models: Record>; +} + +function catalogSource(endpoint: string): string { + if (!endpoint) return ""; + const url = new URL(endpoint); + url.searchParams.delete("app_version"); + url.searchParams.delete("platform"); + return url.toString(); +} + +async function readManagedCatalog(path: string, endpoint: string): Promise { + try { + const value: unknown = JSON.parse(await readFile(path, "utf8")); + if (isRecord(value) && value.endpoint === endpoint && isRecord(value.models)) { + return { + endpoint, + models: Object.fromEntries(Object.entries(value.models).filter( + (entry): entry is [string, Record] => isRecord(entry[1]) + )) + }; + } + } catch { + // Missing provenance means existing entries are user-owned. + } + return { endpoint, models: {} }; +} + +export async function applyRefreshedModelsToConfig( + result: RefreshModelCatalogResult, + env: NodeJS.ProcessEnv = process.env, + protectedModels: string[] = [] +): Promise { + const catalog = parseCatalog(result.catalog); + if (!usableCatalog(catalog)) return; + const age = Date.now() - Date.parse(result.catalog!.lastFetchedAt); + const retire = result.refreshed && result.catalog?.retirementSafe === true + && age >= 0 && age < MODEL_CATALOG_REFRESH_TTL_MS; + const managedPath = join(dirname(modelCatalogCachePath(env)), "model-catalog-managed.json"); + const managed = await readManagedCatalog(managedPath, catalogSource(result.catalog?.endpoint ?? "")); + const beforeManaged = JSON.stringify(managed); + + const modelsByFamily = new Map>>(); + const incompleteFamilies = new Set(); + const models = new Map(catalog.builtinModels!.map((model) => [toModelId(model.modelId), model])); + for (const provider of catalog.builtinProviders!) { + const family = providerFamilyForBuiltinId(provider.id); + if (!family || (provider.schema !== undefined && provider.schema !== "anthropic")) continue; + const familyModels = modelsByFamily.get(family) ?? new Map(); + // An incomplete provider response cannot establish that a model retired. + if (!provider.models.length || provider.models.some((id) => !models.has(toModelId(id)))) { + incompleteFamilies.add(family); + continue; + } + for (const id of provider.models) { + const model = models.get(toModelId(id)); + if (model) familyModels.set(toModelId(id), buildModelEntry(model)); + } + modelsByFamily.set(family, familyModels); + } + + await updateUserConfig((config) => { + if (!isRecord(config.provider)) return; + const providers = config.provider; + const selected = new Set(protectedModels.map(toModelId)); + if (typeof config.model === "string") selected.add(toModelId(config.model)); + if (isRecord(config.model)) { + for (const value of Object.values(config.model)) if (typeof value === "string") selected.add(toModelId(value)); + } + const overrides = isRecord(config.modelCatalog) && isRecord(config.modelCatalog.overrides) + ? config.modelCatalog.overrides : {}; + for (const [providerId, familyModels] of modelsByFamily) { + const current = providers[providerId]; + if (!isRecord(current) || !isOfficialProvider(current, providerId)) continue; + const currentModels = isRecord(current.models) ? current.models as Record : {}; + const merged: Record = { ...currentModels }; + const existingIds = new Map(Object.keys(currentModels).map((id) => [toModelId(id), id])); + for (const [id, existing] of Object.entries(currentModels)) { + const reference = `${providerId}/${id}`; + if (retire && !incompleteFamilies.has(providerId) + && !familyModels.has(toModelId(id)) && !selected.has(toModelId(reference)) + && !Object.hasOwn(overrides, reference) && isDeepStrictEqual(existing, managed.models[reference])) { + delete merged[id]; + delete managed.models[reference]; + } + } + for (const [modelId, entry] of familyModels) { + const id = existingIds.get(modelId) ?? modelId; + const existing = merged[id]; + if (Object.hasOwn(merged, id) && !isRecord(existing)) continue; + const reference = `${providerId}/${id}`; + const owned = !Object.hasOwn(merged, id) + || isDeepStrictEqual(existing, managed.models[reference]); + merged[id] = owned ? entry : mergeModelEntry(entry, existing as Record); + if (owned) managed.models[reference] = entry; + } + providers[providerId] = { ...current, models: merged }; + } + config.provider = providers; + }, env); + if (JSON.stringify(managed) !== beforeManaged) await writePrivateJson(managedPath, managed); +} + +/** Download in the background; sync only at a model-selection boundary. */ +export class ModelCatalogRefresh { + private controller = new AbortController(); + private pending?: Promise; + private result?: RefreshModelCatalogResult; + private nextRefreshAt = 0; + private timer?: ReturnType; + + constructor(private readonly options: RefreshModelCatalogOptions) {} + + start(): void { + if (this.controller.signal.aborted || this.pending || Date.now() < this.nextRefreshAt + || modelCatalogRefreshDisabled(this.options.env)) return; + clearTimeout(this.timer); + this.pending = refreshModelCatalog({ ...this.options, signal: this.controller.signal }) + .then((result) => { + if (!this.controller.signal.aborted) { + if (result.catalog) this.result = result; + else if (this.result) this.result = { ...this.result, refreshed: false }; + } + this.nextRefreshAt = result.catalog + ? Math.max(Date.now() + 60_000, Date.parse(result.catalog.lastFetchedAt) + MODEL_CATALOG_REFRESH_TTL_MS) + : Date.now() + 60_000; + }) + .catch(() => { + if (this.result) this.result = { ...this.result, refreshed: false }; + this.nextRefreshAt = Date.now() + 60_000; + }) + .finally(() => { + this.pending = undefined; + if (!this.controller.signal.aborted) { + this.timer = setTimeout(() => this.start(), Math.max(1, this.nextRefreshAt - Date.now())); + this.timer.unref?.(); + } + }); + } + + async apply(protectedModels: string[] = []): Promise { + this.start(); + // Never await the fetch: first-run/offline users keep the bundled catalog. + if (this.result && !this.controller.signal.aborted && !modelCatalogRefreshDisabled(this.options.env)) { + await applyRefreshedModelsToConfig(this.result, this.options.env, protectedModels); + } + } + + stop(): void { + clearTimeout(this.timer); + this.controller.abort(); + } +} diff --git a/test/model-catalog-refresh.test.ts b/test/model-catalog-refresh.test.ts new file mode 100644 index 0000000..3699e97 --- /dev/null +++ b/test/model-catalog-refresh.test.ts @@ -0,0 +1,613 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; + +import defaultUserConfig from "../config.example.json" with { type: "json" }; +import { + applyRefreshedModelsToConfig, + fetchRemoteModelCatalog, + modelCatalogCachePath, + modelCatalogRefreshDisabled, + ModelCatalogRefresh, + MODEL_CATALOG_REFRESH_TTL_MS, + MODEL_CATALOG_URL_PATH, + refreshModelCatalog, + resolveModelCatalogEndpoint +} from "../src/model-catalog-refresh.ts"; +import { ensureUserConfig, userConfigPath } from "../src/model-access.ts"; + +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map((directory) => ( + rm(directory, { recursive: true, force: true }) + ))); +}); + +async function temporaryHome(): Promise { + const home = await mkdtemp(join(tmpdir(), "zcode-model-catalog-")); + temporaryDirectories.push(home); + return home; +} + +async function writeCatalogCache( + cachePath: string, + builtinModels: unknown[], + builtinProviders: unknown[], + lastFetchedAt: string +): Promise { + await mkdir(dirname(cachePath), { recursive: true }); + await writeFile(cachePath, `${JSON.stringify({ + endpoint: resolveModelCatalogEndpoint("https://zcode.z.ai", { ZCODE_APP_CLI_VERSION: "3.11.2-21" }), + lastFetchedAt, + builtinModels, + builtinProviders + })}\n`); +} + +const sampleBuiltinModels = [ + { + modelId: "GLM-5.3", + name: "GLM-5.3", + contextWindow: 1_000_000, + maxCompletionTokens: 128_000, + reasoning: { + levels: Object.fromEntries(["low", "high", "max"].map((effort) => [effort, { + anthropic: { set: [{ path: ["output_config", "effort"], value: effort }] } + }])), + defaultLevel: "max" + }, + modalities: { input: ["text"], output: ["text"] } + }, + { + modelId: "GLM-5.3-Flash", + name: "GLM-5.3-Flash", + contextWindow: 1_000_000, + maxCompletionTokens: 128_000, + capabilities: { vision: true }, + reasoning: { levels: { low: {}, high: {}, max: {} }, defaultLevel: "max" }, + modalities: { input: ["text", "image", "video"], output: ["text"] } + } +]; + +const sampleBuiltinProviders = [ + { id: "builtin:zai-coding-plan", name: "Z.ai - Coding Plan", models: ["GLM-5.3", "GLM-5.3-Flash"], defaultModel: "GLM-5.3" }, + { id: "builtin:bigmodel-coding-plan", name: "BigModel - Coding Plan", models: ["GLM-5.3", "GLM-5.3-Flash"], defaultModel: "GLM-5.3" } +]; + +function mockFetcher(responseBody: unknown, status = 200): (url: string, init: RequestInit) => Promise { + return async (_url, _init) => new Response(JSON.stringify(responseBody), { + headers: { "content-type": "application/json" }, + status + }); +} + +describe("model catalog refresh — opt-out and paths", () => { + test("respects standard opt-out flags", () => { + expect(modelCatalogRefreshDisabled({})).toBe(false); + expect(modelCatalogRefreshDisabled({ CI: "true" })).toBe(true); + expect(modelCatalogRefreshDisabled({ ZCODE_DISABLE_MODEL_CATALOG_REFRESH: "1" })).toBe(true); + expect(modelCatalogRefreshDisabled({ ZCODE_DISABLE_MODEL_CATALOG_REFRESH: "false" })).toBe(false); + }); + + test("uses the cross-platform config directory", () => { + expect(modelCatalogCachePath({ HOME: "/home/alice" }, "linux", "/fallback")).toBe( + "/home/alice/.zcode/cli/model-catalog.json" + ); + expect(modelCatalogCachePath({ USERPROFILE: "C:\\Users\\Alice" }, "win32", "C:\\fallback")).toBe( + "C:\\Users\\Alice\\.zcode\\cli\\model-catalog.json" + ); + }); +}); + +describe("model catalog refresh — endpoint resolution", () => { + test("builds the endpoint URL with platform, arch, and app_version", () => { + const url = resolveModelCatalogEndpoint( + "https://zcode.z.ai", + { ZCODE_APP_CLI_VERSION: "3.11.2-21" }, + "darwin", + "arm64" + ); + const parsed = new URL(url); + expect(parsed.origin).toBe("https://zcode.z.ai"); + expect(parsed.pathname).toBe(MODEL_CATALOG_URL_PATH); + expect(parsed.searchParams.get("platform")).toBe("darwin-arm64"); + expect(parsed.searchParams.get("app_version")).toBe("3.11.2-21"); + }); + + test("strips a trailing slash from the base URL", () => { + const url = resolveModelCatalogEndpoint("https://zcode.z.ai/", {}, "linux", "x64"); + expect(new URL(url).origin).toBe("https://zcode.z.ai"); + }); + + test("omits app_version when not set", () => { + const url = resolveModelCatalogEndpoint("https://zcode.z.ai", {}, "darwin", "arm64"); + expect(new URL(url).searchParams.get("app_version")).toBeNull(); + }); +}); + +describe("model catalog refresh — fetchRemoteModelCatalog", () => { + test("parses builtinModels and builtinProviders from a successful response", async () => { + const remote = await fetchRemoteModelCatalog({ + baseUrl: "https://zcode.z.ai", + currentVersion: "3.11.2-21", + env: { ZCODE_APP_CLI_VERSION: "3.11.2-21" }, + fetcher: mockFetcher({ code: 0, msg: "", data: { builtinModels: sampleBuiltinModels, builtinProviders: sampleBuiltinProviders } }) + }); + expect(remote.builtinModels).toHaveLength(2); + expect(remote.builtinModels?.[0]?.modelId).toBe("GLM-5.3"); + expect(remote.builtinProviders).toHaveLength(2); + }); + + test("returns empty on a non-zero code", async () => { + const remote = await fetchRemoteModelCatalog({ + baseUrl: "https://zcode.z.ai", + currentVersion: "3.11.2-21", + fetcher: mockFetcher({ code: 3001, msg: "parameter error" }) + }); + expect(remote.builtinModels ?? []).toHaveLength(0); + }); + + test("returns empty on an HTTP error", async () => { + const remote = await fetchRemoteModelCatalog({ + baseUrl: "https://zcode.z.ai", + currentVersion: "3.11.2-21", + fetcher: async () => new Response("unavailable", { status: 503 }) + }); + expect(remote.builtinModels ?? []).toHaveLength(0); + }); +}); + +describe("model catalog refresh — refreshModelCatalog cache", () => { + test("skips fetching when a fresh cache exists", async () => { + const home = await temporaryHome(); + const cachePath = modelCatalogCachePath({ HOME: home, USERPROFILE: home }); + const now = Date.parse("2026-09-07T12:00:00.000Z"); + await writeCatalogCache(cachePath, sampleBuiltinModels, sampleBuiltinProviders, new Date(now - 60_000).toISOString()); + + let fetched = false; + const result = await refreshModelCatalog({ + baseUrl: "https://zcode.z.ai", + currentVersion: "3.11.2-21", + env: { HOME: home, USERPROFILE: home }, + fetcher: async () => { fetched = true; return new Response("{}", { status: 200 }); }, + now + }); + expect(fetched).toBe(false); + expect(result.refreshed).toBe(false); + expect(result.catalog?.builtinModels).toHaveLength(2); + }); + + test("fetches and writes a new cache when the TTL has expired", async () => { + const home = await temporaryHome(); + const cachePath = modelCatalogCachePath({ HOME: home, USERPROFILE: home }); + const now = Date.parse("2026-09-07T12:00:00.000Z"); + await writeCatalogCache(cachePath, [], [], new Date(now - MODEL_CATALOG_REFRESH_TTL_MS - 1).toISOString()); + + const result = await refreshModelCatalog({ + baseUrl: "https://zcode.z.ai", + currentVersion: "3.11.2-21", + env: { HOME: home, USERPROFILE: home, ZCODE_APP_CLI_VERSION: "3.11.2-21" }, + fetcher: mockFetcher({ code: 0, msg: "", data: { builtinModels: sampleBuiltinModels, builtinProviders: sampleBuiltinProviders } }), + now + }); + expect(result.refreshed).toBe(true); + expect(result.catalog?.builtinModels).toHaveLength(2); + + const persisted = JSON.parse(await readFile(cachePath, "utf8")); + expect(persisted.builtinModels).toHaveLength(2); + expect(persisted.lastFetchedAt).toBe(new Date(now).toISOString()); + }); + + test("does not overwrite an existing cache when the remote returns nothing", async () => { + const home = await temporaryHome(); + const cachePath = modelCatalogCachePath({ HOME: home, USERPROFILE: home }); + const now = Date.parse("2026-09-07T12:00:00.000Z"); + await writeCatalogCache(cachePath, sampleBuiltinModels, sampleBuiltinProviders, new Date(now - MODEL_CATALOG_REFRESH_TTL_MS - 1).toISOString()); + + const before = await readFile(cachePath, "utf8"); + const result = await refreshModelCatalog({ + baseUrl: "https://zcode.z.ai", + currentVersion: "3.11.2-21", + env: { HOME: home, USERPROFILE: home }, + fetcher: async () => new Response("{}", { status: 503 }), + now + }); + expect(result.refreshed).toBe(false); + expect(await readFile(cachePath, "utf8")).toBe(before); + }); +}); + +describe("model catalog refresh — applyRefreshedModelsToConfig", () => { + test("merges new models into provider.zai.models without removing existing entries", async () => { + const home = await temporaryHome(); + const env = { HOME: home, USERPROFILE: home }; + await ensureUserConfig(env); + const configPath = userConfigPath(env); + + const before = JSON.parse(await readFile(configPath, "utf8")) as { provider: Record }> }; + expect(Object.keys(before.provider.zai?.models ?? {})).toContain("glm-5.2"); + + const result = { + cachePath: modelCatalogCachePath(env, "linux", home), + refreshed: true, + catalog: { + lastFetchedAt: new Date().toISOString(), + builtinModels: sampleBuiltinModels, + builtinProviders: sampleBuiltinProviders + } + }; + await applyRefreshedModelsToConfig(result, env); + + const after = JSON.parse(await readFile(configPath, "utf8")) as { provider: Record }> }; + const zaiModels = after.provider.zai?.models ?? {}; + expect(Object.keys(zaiModels)).toContain("glm-5.2"); + expect(Object.keys(zaiModels)).toContain("glm-5.3"); + expect(Object.keys(zaiModels)).toContain("glm-5.3-flash"); + expect(Object.keys(zaiModels)).toContain("glm-5-turbo"); + }); + + test("preserves user-customized name fields while adding metadata", async () => { + const home = await temporaryHome(); + const env = { HOME: home, USERPROFILE: home }; + await ensureUserConfig(env); + const configPath = userConfigPath(env); + + const customName = "My Custom Model"; + await writeFile(configPath, JSON.stringify({ + ...defaultUserConfig, + provider: { + zai: { + ...defaultUserConfig.provider.zai, + models: { + ...defaultUserConfig.provider.zai.models, + "glm-5.3": { name: customName } + } + } + } + }, null, 2)); + + const result = { + cachePath: modelCatalogCachePath(env, "linux", home), + refreshed: true, + catalog: { + lastFetchedAt: new Date().toISOString(), + builtinModels: sampleBuiltinModels, + builtinProviders: sampleBuiltinProviders + } + }; + await applyRefreshedModelsToConfig(result, env); + + const after = JSON.parse(await readFile(configPath, "utf8")) as { provider: Record }> }; + const entry = after.provider.zai?.models?.["glm-5.3"]; + expect(entry?.name).toBe(customName); + expect(entry?.limit).toBeDefined(); + }); + + test("does not modify config when the catalog is empty", async () => { + const home = await temporaryHome(); + const env = { HOME: home, USERPROFILE: home }; + await ensureUserConfig(env); + const configPath = userConfigPath(env); + const before = await readFile(configPath, "utf8"); + + await applyRefreshedModelsToConfig({ + cachePath: modelCatalogCachePath(env, "linux", home), + refreshed: false, + catalog: null + }, env); + + expect(await readFile(configPath, "utf8")).toBe(before); + }); +}); + +function catalogResult(env: NodeJS.ProcessEnv, ids = ["GLM-6"]) { + return { + cachePath: modelCatalogCachePath(env), + refreshed: true, + catalog: { + retirementSafe: true, + lastFetchedAt: new Date().toISOString(), + builtinModels: ids.map((modelId) => ({ ...sampleBuiltinModels[0]!, modelId, name: modelId })), + builtinProviders: [{ id: "builtin:zai-coding-plan", models: ids }] + } + }; +} + +async function configuredHome() { + const home = await temporaryHome(); + const env = { HOME: home, USERPROFILE: home }; + await ensureUserConfig(env); + return env; +} + +async function readConfig(env: NodeJS.ProcessEnv) { + return JSON.parse(await readFile(userConfigPath(env), "utf8")); +} + +describe("model catalog validation and fallback", () => { + test("validates remote records before caching or writing runtime metadata", async () => { + const remote = await fetchRemoteModelCatalog({ + baseUrl: "https://zcode.z.ai", currentVersion: "3.11.2-21", + fetcher: mockFetcher({ code: 0, data: { + builtinModels: [null, {}, { modelId: 42 }, { modelId: "__proto__" }, { + modelId: "GLM-6", contextWindow: -1, maxCompletionTokens: 0, + modalities: { input: ["image", "future-format", 7], output: "text" } + }], + builtinProviders: [null, {}, { id: 7 }, { id: "builtin:zai-coding-plan", models: [null, "GLM-6"] }] + } }) + }); + expect(remote.builtinModels).toEqual([{ + modelId: "GLM-6", name: "GLM-6", modalities: { input: ["image"] } + }]); + expect(remote.builtinProviders).toEqual([{ id: "builtin:zai-coding-plan", models: ["GLM-6"] }]); + }); + + test("uses the actual package version and tolerates invalid endpoints and JSON", async () => { + let requested = ""; + await fetchRemoteModelCatalog({ + baseUrl: "https://zcode.z.ai/prefix///?channel=stable#unused", currentVersion: "3.11.2-21", env: {}, + fetcher: async (url, init) => { + requested = url; + expect(new Headers(init.headers).get("user-agent")).toBe("zcode-app-cli/3.11.2-21"); + return new Response("not json"); + } + }); + expect(new URL(requested).searchParams.get("app_version")).toBe("3.11.2-21"); + expect(new URL(requested).pathname).toBe(`/prefix${MODEL_CATALOG_URL_PATH}`); + expect(new URL(requested).hash).toBe(""); + expect(await fetchRemoteModelCatalog({ baseUrl: "not a url", currentVersion: "1" })).toEqual({}); + expect(await fetchRemoteModelCatalog({ + baseUrl: "https://zcode.z.ai", currentVersion: "1", + fetcher: mockFetcher({ code: "failure", data: catalogResult({}).catalog }) + })).toEqual({}); + }); + + test("bounds slow requests and honors cancellation before requesting", async () => { + let cancelled = false; + const remote = await fetchRemoteModelCatalog({ + baseUrl: "https://zcode.z.ai", currentVersion: "1", timeoutMs: 10, + fetcher: async (_url, init) => await new Promise((_resolve, reject) => { + init.signal!.addEventListener("abort", () => { cancelled = true; reject(init.signal!.reason); }, { once: true }); + }) + }); + expect(cancelled).toBe(true); + expect(remote).toEqual({}); + await fetchRemoteModelCatalog({ + baseUrl: "https://zcode.z.ai", currentVersion: "1", signal: AbortSignal.abort(), + fetcher: async () => { throw new Error("must not fetch"); } + }); + }); + + test("discards corrupted, future-dated and differently scoped caches", async () => { + const env = await configuredHome(); + const cachePath = modelCatalogCachePath(env); + const now = Date.now(); + for (const cache of [ + { lastFetchedAt: new Date(now).toISOString(), builtinModels: [null], builtinProviders: sampleBuiltinProviders }, + { lastFetchedAt: new Date(now + 86_400_000).toISOString(), builtinModels: sampleBuiltinModels, builtinProviders: sampleBuiltinProviders }, + { lastFetchedAt: new Date(now).toISOString(), builtinModels: sampleBuiltinModels, builtinProviders: sampleBuiltinProviders, endpoint: "https://other.test" } + ]) { + await writeFile(cachePath, JSON.stringify({ + endpoint: resolveModelCatalogEndpoint("https://zcode.z.ai", { ZCODE_APP_CLI_VERSION: "3.11.2-21" }), ...cache + })); + let fetched = false; + const result = await refreshModelCatalog({ + baseUrl: "https://zcode.z.ai", currentVersion: "3.11.2-21", env, now, + fetcher: async () => { fetched = true; return new Response("{}", { status: 503 }); } + }); + expect(fetched).toBe(true); + expect(result.refreshed).toBe(false); + } + }); + + test("does not refresh or persist anything when disabled", async () => { + const env = await configuredHome(); + let fetched = false; + const result = await refreshModelCatalog({ + baseUrl: "https://zcode.z.ai", currentVersion: "1", env: { ...env, CI: "true" }, + fetcher: async () => { fetched = true; return new Response("{}"); } + }); + expect(fetched).toBe(false); + expect(result.catalog).toBeNull(); + expect(await Bun.file(result.cachePath).exists()).toBe(false); + }); +}); + +describe("safe model synchronization", () => { + test("filters by Coding Plan membership and does not create missing providers", async () => { + const env = await configuredHome(); + const result = catalogResult(env, ["GLM-6", "GLM-7", "GLM-8"]); + result.catalog.builtinProviders = [ + { id: "builtin:zai-coding-plan", models: ["GLM-6"] }, + { id: "builtin:bigmodel-coding-plan", models: ["GLM-7"] }, + { id: "builtin:zai", models: ["GLM-8"] } + ]; + await applyRefreshedModelsToConfig(result, env); + const config = await readConfig(env); + expect(config.provider.zai.models["glm-6"]).toBeDefined(); + expect(config.provider.zai.models["glm-7"]).toBeUndefined(); + expect(config.provider.zai.models["glm-8"]).toBeUndefined(); + expect(config.provider.bigmodel).toBeUndefined(); + expect(config.model).toEqual(defaultUserConfig.model); + + config.provider.bigmodel = { + kind: "anthropic", options: { baseURL: "https://open.bigmodel.cn/api/anthropic", apiKey: "fixture-key" }, models: {} + }; + await writeFile(userConfigPath(env), JSON.stringify(config)); + await applyRefreshedModelsToConfig(result, env); + const afterLogin = await readConfig(env); + expect(afterLogin.provider.bigmodel.models["glm-7"]).toBeDefined(); + expect(afterLogin.provider.bigmodel.models["glm-6"]).toBeUndefined(); + expect(afterLogin.provider.bigmodel.options.apiKey).toBe("fixture-key"); + }); + + test("leaves custom endpoints and protocols untouched, including formatting", async () => { + const env = await configuredHome(); + const config = await readConfig(env); + for (const provider of [ + { ...config.provider.zai, options: { baseURL: "https://proxy.test/api/anthropic" } }, + { ...config.provider.zai, kind: "openai" }, + { models: {} } + ]) { + const before = JSON.stringify({ ...config, provider: { zai: provider } }); + await writeFile(userConfigPath(env), before); + await applyRefreshedModelsToConfig(catalogResult(env), env); + expect(await readFile(userConfigPath(env), "utf8")).toBe(before); + } + }); + + test("preserves imported model casing and nested overrides and adds runtime reasoning", async () => { + const env = await configuredHome(); + const config = await readConfig(env); + config.provider.zai.models["GLM-6"] = { + name: "My model", limit: { context: 42_000 }, modalities: { input: ["text", "image"] } + }; + await writeFile(userConfigPath(env), JSON.stringify(config)); + await applyRefreshedModelsToConfig(catalogResult(env), env); + const models = (await readConfig(env)).provider.zai.models; + expect(models["glm-6"]).toBeUndefined(); + expect(models["GLM-6"]).toMatchObject({ + name: "My model", limit: { context: 42_000, output: 128_000 }, + modalities: { input: ["text", "image"], output: ["text"] }, + reasoning: { enabled: true, defaultLevel: "max", levels: ["low", "high", "max"], + providerOptionsByLevel: { max: { anthropic: { effort: "max" } } } } + }); + }); + + test("updates automatically owned metadata and is idempotent", async () => { + const env = await configuredHome(); + const result = catalogResult(env); + await applyRefreshedModelsToConfig(result, env); + result.catalog.builtinModels[0]!.contextWindow = 2_000_000; + await applyRefreshedModelsToConfig(result, env); + expect((await readConfig(env)).provider.zai.models["glm-6"].limit.context).toBe(2_000_000); + const before = await stat(userConfigPath(env)); + await applyRefreshedModelsToConfig(result, env); + expect((await stat(userConfigPath(env))).mtimeMs).toBe(before.mtimeMs); + }); + + test("removes only unchanged auto-added retired models after an authoritative refresh", async () => { + const env = await configuredHome(); + await applyRefreshedModelsToConfig(catalogResult(env, ["GLM-6", "GLM-7", "GLM-8", "GLM-9", "GLM-10"]), env); + const config = await readConfig(env); + config.provider.zai.models["glm-7"].name = "User customized"; + config.model.main = "zai/glm-8"; + config.model.lite = "zai/glm-10"; + await writeFile(userConfigPath(env), JSON.stringify(config)); + const next = catalogResult(env, ["GLM-11"]); + await applyRefreshedModelsToConfig({ ...next, refreshed: false }, env, ["zai/glm-9"]); + expect((await readConfig(env)).provider.zai.models["glm-6"]).toBeDefined(); + await applyRefreshedModelsToConfig({ ...next, catalog: { + ...next.catalog, lastFetchedAt: new Date(Date.now() - MODEL_CATALOG_REFRESH_TTL_MS).toISOString() + } }, env, ["zai/glm-9"]); + expect((await readConfig(env)).provider.zai.models["glm-6"]).toBeDefined(); + await applyRefreshedModelsToConfig(next, env, ["zai/glm-9"]); + const after = await readConfig(env); + expect(after.provider.zai.models["glm-6"]).toBeUndefined(); + for (const id of ["glm-5.2", "glm-7", "glm-8", "glm-9", "glm-10", "glm-11"]) { + expect(after.provider.zai.models[id]).toBeDefined(); + } + expect(after.model).toEqual(config.model); + }); + + test("partial or empty provider responses cannot retire models", async () => { + const env = await configuredHome(); + await applyRefreshedModelsToConfig(catalogResult(env), env); + const before = await readFile(userConfigPath(env), "utf8"); + for (const ids of [[], ["GLM-7", "GLM-MISSING"]]) { + const partial = catalogResult(env, ["GLM-7"]); + partial.catalog.builtinProviders[0]!.models = ids; + await applyRefreshedModelsToConfig(partial, env); + expect(await readFile(userConfigPath(env), "utf8")).toBe(before); + } + }); + + test("retains ownership across package upgrades but isolates different catalog origins", async () => { + const env = await configuredHome(); + const first = catalogResult(env); + await applyRefreshedModelsToConfig({ ...first, catalog: { + ...first.catalog, endpoint: "https://zcode.z.ai/api/v1/client/configs?app_version=1&platform=darwin-arm64" + } }, env); + const second = catalogResult(env, ["GLM-7"]); + await applyRefreshedModelsToConfig({ ...second, catalog: { + ...second.catalog, endpoint: "https://zcode.z.ai/api/v1/client/configs?app_version=2&platform=darwin-arm64" + } }, env); + expect((await readConfig(env)).provider.zai.models["glm-6"]).toBeUndefined(); + const third = catalogResult(env, ["GLM-8"]); + await applyRefreshedModelsToConfig({ ...third, catalog: { + ...third.catalog, endpoint: "https://other.test/api/v1/client/configs?app_version=2" + } }, env); + expect((await readConfig(env)).provider.zai.models["glm-7"]).toBeDefined(); + }); + + test("keeps models still advertised by the official regular provider catalog", async () => { + const env = await configuredHome(); + await applyRefreshedModelsToConfig(catalogResult(env), env); + const remote = await fetchRemoteModelCatalog({ + baseUrl: "https://zcode.z.ai", currentVersion: "3.11.2-21", env, + fetcher: mockFetcher({ code: 0, data: { + ...catalogResult(env, ["GLM-7"]).catalog, + providers: [{ id: "z-ai", schema: "anthropic", baseUrl: "https://api.z.ai/api/anthropic", + models: [{ modelId: "GLM-6", name: "GLM-6" }] }] + } }) + }); + await applyRefreshedModelsToConfig({ + ...catalogResult(env), + catalog: { lastFetchedAt: new Date().toISOString(), retirementSafe: remote.retirementSafe, + builtinModels: remote.builtinModels!, builtinProviders: remote.builtinProviders! } + }, env); + const models = (await readConfig(env)).provider.zai.models; + expect(models["glm-6"]).toBeDefined(); + expect(models["glm-7"]).toBeDefined(); + }); + + test("missing catalog sections and invalid memberships never authorize retirement", async () => { + const env = await configuredHome(); + await applyRefreshedModelsToConfig(catalogResult(env), env); + const next = catalogResult(env, ["GLM-7"]); + for (const data of [ + next.catalog, + { ...next.catalog, providers: [], builtinProviders: [{ id: "builtin:zai-coding-plan", models: ["GLM-7", 42] }] }, + { ...next.catalog, providers: [{ id: "z-ai", models: [] }] } + ]) { + const remote = await fetchRemoteModelCatalog({ + baseUrl: "https://zcode.z.ai", currentVersion: "1", env, fetcher: mockFetcher({ code: 0, data }) + }); + expect(remote.retirementSafe).toBe(false); + await applyRefreshedModelsToConfig({ ...next, catalog: { + ...next.catalog, ...remote, builtinModels: remote.builtinModels!, builtinProviders: remote.builtinProviders! + } }, env); + expect((await readConfig(env)).provider.zai.models["glm-6"]).toBeDefined(); + } + }); +}); + +describe("non-blocking model discovery", () => { + test("model selection never awaits a slow first-run fetch, then applies the downloaded catalog", async () => { + const env = await configuredHome(); + const response = Promise.withResolvers(); + const requested = Promise.withResolvers(); + const refresh = new ModelCatalogRefresh({ + baseUrl: "https://zcode.z.ai", currentVersion: "3.11.2-21", env, + fetcher: async () => { requested.resolve(); return response.promise; } + }); + try { + refresh.start(); + await requested.promise; + await refresh.apply(); + expect((await readConfig(env)).provider.zai.models["glm-6"]).toBeUndefined(); + response.resolve(new Response(JSON.stringify({ code: 0, data: catalogResult(env).catalog }))); + for (let tries = 0; tries < 100; tries++) { + await Bun.sleep(5); + await refresh.apply(); + if ((await readConfig(env)).provider.zai.models["glm-6"]) break; + } + expect((await readConfig(env)).provider.zai.models["glm-6"]).toBeDefined(); + } finally { + refresh.stop(); + response.resolve(new Response("{}")); + } + }); +}); diff --git a/test/model-catalog-reload.test.ts b/test/model-catalog-reload.test.ts new file mode 100644 index 0000000..fa89e00 --- /dev/null +++ b/test/model-catalog-reload.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, test } from "bun:test"; + +import { patchRuntimeModelCatalogReload } from "../scripts/sync-runtime.ts"; + +const fixture = [ + 'label(createConfig,"createConfig");label(()=>{},"setModelCatalogOverlay");', + "function makeBridge(host){const bridge={};", + "bridge.listModelOptions=async()=>(await getApp()).listModels?.()??[];", + "return{listModelOptions:bridge.listModelOptions}}" +].join(""); + +describe("runtime model catalog reload bridge", () => { + test("uses the native merged config and updates the registry without replacing the session", async () => { + const targets = [ + { provider: "zai", model: "glm-6", apiKey: "user-key" }, + { provider: "custom", model: "project-lite", baseURL: "https://project.test" }, + { provider: "zai", model: "glm-7" } + ]; + const overrides = { "zai/glm-6": { contextWindow: 1_000_000 } }; + let overlay: unknown; + const app = { + sessionId: "existing-session", + setModelCatalogOverlay: async (value: unknown) => { overlay = value; }, + listModels: () => (overlay as { targets?: unknown[] })?.targets ?? [] + }; + let configOptions: unknown; + const createConfig = (options: unknown) => { + configOptions = options; + return { + config: { model: { main: targets[0], lite: targets[1], available: [targets[2]] }, modelCatalog: { overrides } } + }; + }; + const patched = patchRuntimeModelCatalogReload(fixture); + const makeBridge = new Function("getApp", "createConfig", ` + const label = (fn) => fn; + ${patched} + return makeBridge; + `)(async () => app, createConfig); + const host = { + env: { FIXTURE: "1" }, cwd: () => "/workspace", + projectConfigPath: "/project.json", userConfigPath: "/user.json", skipUserConfig: false + }; + const bridge = makeBridge(host); + expect(await bridge.reloadModelOptions()).toEqual(targets); + expect(overlay).toEqual({ targets, catalogOverrides: overrides }); + expect(configOptions).toEqual({ + env: host.env, workingDirectory: "/workspace", projectConfigPath: "/project.json", + userConfigPath: "/user.json", skipUserConfig: false + }); + expect(app.sessionId).toBe("existing-session"); + expect(patchRuntimeModelCatalogReload(patched)).toBe(patched); + }); + + test("rejects incompatible bundles instead of silently omitting runtime refresh", () => { + expect(() => patchRuntimeModelCatalogReload("unrecognized bundle")).toThrow("anchor missing"); + expect(() => patchRuntimeModelCatalogReload(fixture.replace('"createConfig"', '"renamed"'))).toThrow("anchor missing"); + }); +}); diff --git a/test/model-catalog-tui.test.ts b/test/model-catalog-tui.test.ts new file mode 100644 index 0000000..2903b8d --- /dev/null +++ b/test/model-catalog-tui.test.ts @@ -0,0 +1,119 @@ +import { expect, test } from "bun:test"; +import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { ensureUserConfig, userConfigPath } from "../src/model-access.ts"; +import { applyRefreshedModelsToConfig, modelCatalogCachePath, resolveModelCatalogEndpoint } from "../src/model-catalog-refresh.ts"; +import { readDistributionVersion } from "../src/launcher.ts"; + +function plainText(text: string): string { + return text.replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, "") + .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "").replace(/\r/g, ""); +} + +test.skipIf(process.platform === "win32").each([false, true])("native TUI starts before discovery and reloads models (first install: %p)", async (firstInstall) => { + const node = Bun.which("node"); + if (!node) throw new Error("Node.js is required for runtime integration tests."); + const home = await mkdtemp(join(tmpdir(), "zcode-catalog-tui-")); + const env = { HOME: home, USERPROFILE: home }; + const gate = Promise.withResolvers(); + const requested = Promise.withResolvers(); + const server = Bun.serve({ + hostname: "127.0.0.1", port: 0, + async fetch(request) { + if (new URL(request.url).pathname !== "/api/v1/client/configs") return new Response("", { status: 404 }); + requested.resolve(); + await gate.promise; + return Response.json({ code: 0, data: { + providers: [], + builtinModels: [{ modelId: "GLM-7", name: "GLM-7", contextWindow: 1_000_000, maxCompletionTokens: 128_000 }], + builtinProviders: [{ id: "builtin:zai-coding-plan", schema: "anthropic", models: ["GLM-7"] }] + } }); + } + }); + const baseUrl = server.url.origin; + const endpoint = resolveModelCatalogEndpoint(baseUrl, { ZCODE_APP_CLI_VERSION: readDistributionVersion() }); + await ensureUserConfig(env); + const config = JSON.parse(await readFile(userConfigPath(env), "utf8")); + config.provider.zai.options.apiKey = "fixture-key-not-real"; + config.ui.locale = "en-US"; + config.plugins.enabled = false; + config.memory.use = false; + config.memory.write = false; + await writeFile(userConfigPath(env), JSON.stringify(config)); + await applyRefreshedModelsToConfig({ cachePath: modelCatalogCachePath(env), refreshed: true, catalog: { + endpoint, lastFetchedAt: new Date().toISOString(), + builtinModels: [{ modelId: "GLM-6", name: "GLM-6" }], + builtinProviders: [{ id: "builtin:zai-coding-plan", models: ["GLM-6"] }] + } }, env); + if (firstInstall) await rm(join(home, ".zcode", "cli"), { recursive: true, force: true }); + + let output = ""; + const decoder = new TextDecoder(); + const terminal = new Bun.Terminal({ cols: 110, rows: 36, name: "xterm-256color", + data(_terminal, data) { output += decoder.decode(data, { stream: true }); } + }); + const child = Bun.spawn([node, join(import.meta.dir, "..", "bin", "zcode.js")], { + cwd: home, terminal, + env: { ...process.env, ...env, TERM: "xterm-256color", CI: "0", + ZCODE_BASE_URL: baseUrl, ZCODE_DISABLE_MODEL_CATALOG_REFRESH: "0", ZCODE_DISABLE_UPDATE_CHECK: "1", + ZCODE_TUI_MODE: "regular", ZCODE_NODE: node } + }); + const killTimer = setTimeout(() => child.kill("SIGKILL"), 20_000); + async function waitFor(pattern: RegExp, offset = 0) { + const deadline = Date.now() + 8_000; + while (Date.now() < deadline && child.exitCode === null) { + if (pattern.test(plainText(output.slice(offset)))) return; + await Bun.sleep(20); + } + throw new Error(`Missing ${pattern}: ${plainText(output).slice(-4000)}`); + } + try { + if (firstInstall) { + await waitFor(/Welcome to ZCode CLI/); + await requested.promise; + const initial = JSON.parse(await readFile(userConfigPath(env), "utf8")); + expect(initial.model.main).toBe("zai/glm-5.2"); + expect(initial.provider.zai.options.apiKey).toBeUndefined(); + return; + } + await waitFor(/zai\/glm-5\.2/); + await requested.promise; + const firstPicker = output.length; + terminal.write("/model\r"); + await waitFor(/Select model/, firstPicker); + expect(plainText(output.slice(firstPicker))).toContain("zai/glm-6"); + expect(plainText(output.slice(firstPicker))).not.toContain("zai/glm-7"); + terminal.write("\x1b"); + await Bun.sleep(50); + gate.resolve(); + const cacheExists = () => stat(modelCatalogCachePath(env)).then(() => true, () => false); + for (let i = 0; i < 100 && !await cacheExists(); i++) await Bun.sleep(20); + expect(await cacheExists()).toBe(true); + await Bun.sleep(30); + const nextPicker = output.length; + terminal.write("/model\r"); + await waitFor(/Select model/, nextPicker); + const currentPicker = plainText(output.slice(nextPicker)); + expect(currentPicker).toContain("zai/glm-7"); + expect(currentPicker).not.toContain("zai/glm-6"); + terminal.write("\x1b"); + await Bun.sleep(50); + const switched = output.length; + terminal.write("/model zai/glm-7\r"); + await waitFor(/Session model now: zai\/glm-7/, switched); + const saved = JSON.parse(await readFile(userConfigPath(env), "utf8")); + expect(saved.model).toEqual(config.model); + expect(saved.provider.zai.models["glm-7"]).toBeDefined(); + expect(saved.provider.zai.models["glm-6"]).toBeUndefined(); + } finally { + gate.resolve(); + if (child.exitCode === null) child.kill("SIGTERM"); + await child.exited; + clearTimeout(killTimer); + terminal.close(); + server.stop(true); + await rm(home, { recursive: true, force: true }); + } +}, 25_000);