Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 17 additions & 9 deletions packages/zcode-tui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -690,6 +691,7 @@ class ZCodeTui {
private backgroundDrainScheduled = false;
private backgroundHandoffInterruptInFlight = false;
private updateCheckAbortController?: AbortController;
private modelCatalogRefresh?: ModelCatalogRefresh;
private loginRequired: boolean;
private removeStreamErrorGuards?: () => void;

Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -1643,6 +1652,7 @@ class ZCodeTui {
const explicitModel = explicitModelRequest(input);
if (explicitModel) {
this.addUserMessage(submission.displayInput);
await this.refreshModelOptions();
await this.switchTransientModel(explicitModel);
return;
}
Expand Down Expand Up @@ -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<void> {
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) {
Expand Down Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions packages/zcode-tui/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ export interface RuntimeAdapter {
listPluginReferences?: ListPluginReferences;
listSkills?: ListSkills;
listModelOptions?: () => Promise<unknown[]>;
reloadModelOptions?: () => Promise<unknown[]>;
setTransientModel?: (modelId: string) => Promise<unknown>;
recallPreviousInput?: (skip: number) => Promise<unknown>;
readGoal?: () => Promise<unknown>;
Expand Down
1 change: 1 addition & 0 deletions scripts/check-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
28 changes: 28 additions & 0 deletions scripts/sync-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions src/model-access.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,9 @@ export async function updateUserConfig(
): Promise<string> {
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),
Expand Down
Loading