diff --git a/CHANGELOG.md b/CHANGELOG.md index f9f0c19bc11..b6949c59958 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,7 @@ Docs: https://docs.openclaw.ai - Gateway/hooks: route non-delivered hook completion and error summaries to the target agent's main session instead of the default agent session, preserving multi-agent hook isolation. Fixes #24693; carries forward #68667. Thanks @abersonFAC and @bluesky6868. - Control UI/models: request the configured Gateway model-list view so dashboards with only `models.providers.*.models` show those configured models first instead of flooding the picker with the full built-in catalog. Fixes #65405. Thanks @wbyanclaw. +- CLI/models: keep default-model and allowlist pickers on explicit `models.providers.*.models` entries when `models.mode` is `replace` instead of loading the full built-in catalog. Fixes #64950. Thanks @mrozentsvayg. - Discord: own the Carbon interaction listener and hand off Discord slash/component handling asynchronously, so compaction or long session locks no longer trip `InteractionEventListener` listener timeouts. Fixes #73204. Thanks @slideshow-dingo. - Compaction/diagnostics: keep unknown compaction failure classifications stable while logging sanitized detail for unclassified provider errors such as missing Ollama provider adapters. Thanks @gzsiang. - Models/fallbacks: record first-class `model.fallback_step` trajectory events with from/to models, failure detail, chain position, and final outcome so support exports preserve the primary model failure even when a later fallback also fails. Fixes #71744. Thanks @nikolaykazakovvs-ux. diff --git a/docs/concepts/models.md b/docs/concepts/models.md index ab4e70f016c..74508728a1e 100644 --- a/docs/concepts/models.md +++ b/docs/concepts/models.md @@ -61,6 +61,7 @@ The same `provider/model` can mean different things depending on where it came f - Auto fallback selections are temporary recovery state. They are stored with `modelOverrideSource: "auto"` so later turns can keep using the fallback chain without probing a known-bad primary first. - User session selections are exact. `/model`, the model picker, `session_status(model=...)`, and `sessions.patch` store `modelOverrideSource: "user"`; if that selected provider/model is unreachable, OpenClaw fails visibly instead of falling through to another configured model. - Cron `--model` / payload `model` is a per-job primary. It still uses configured fallbacks unless the job supplies explicit payload `fallbacks` (use `fallbacks: []` for a strict cron run). +- CLI default-model and allowlist pickers respect `models.mode: "replace"` by listing explicit `models.providers.*.models` instead of loading the full built-in catalog. - The Control UI model picker asks the Gateway for its configured model view: `agents.defaults.models` when present, otherwise explicit `models.providers.*.models`, otherwise the full catalog so fresh installs are not blank. ## Quick model policy diff --git a/src/commands/model-picker.test.ts b/src/commands/model-picker.test.ts index e654864f876..07c6a922d57 100644 --- a/src/commands/model-picker.test.ts +++ b/src/commands/model-picker.test.ts @@ -97,6 +97,18 @@ function createSelectAllMultiselect() { return vi.fn(async (params) => params.options.map((option: { value: string }) => option.value)); } +function configuredTextModel(id: string, name: string) { + return { + id, + name, + reasoning: false, + input: ["text" as const], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 8192, + }; +} + beforeEach(() => { vi.clearAllMocks(); providerModelPickerContributionRuntime.enabled = false; @@ -186,6 +198,45 @@ describe("promptDefaultModel", () => { ]); }); + it("uses configured provider models without loading the full catalog in replace mode", async () => { + loadModelCatalog.mockResolvedValue([ + { provider: "openai", id: "gpt-5.5", name: "GPT-5.5" }, + { provider: "anthropic", id: "claude-sonnet-4-6", name: "Claude Sonnet" }, + ]); + + const select = vi.fn(async (params) => params.options[0]?.value as never); + const prompter = makePrompter({ select }); + const config = { + models: { + mode: "replace", + providers: { + minimax: { + baseUrl: "https://api.minimax.test/v1", + models: [configuredTextModel("MiniMax-M2.7-highspeed", "MiniMax M2.7 Highspeed")], + }, + }, + }, + agents: { defaults: {} }, + } as OpenClawConfig; + + const result = await promptDefaultModel({ + config, + prompter, + allowKeep: false, + includeManual: false, + ignoreAllowlist: true, + }); + + expect(loadModelCatalog).not.toHaveBeenCalled(); + expect(select.mock.calls[0]?.[0]?.options).toEqual([ + expect.objectContaining({ + value: "minimax/MiniMax-M2.7-highspeed", + hint: expect.stringContaining("MiniMax M2.7 Highspeed"), + }), + ]); + expect(result.model).toBe("minimax/MiniMax-M2.7-highspeed"); + }); + it("treats byteplus plan models as preferred-provider matches", async () => { loadModelCatalog.mockResolvedValue([ { @@ -514,6 +565,43 @@ describe("promptModelAllowlist", () => { expect(result.scopeKeys).toEqual(["anthropic/claude-opus-4-6"]); }); + it("uses configured provider models without loading the full catalog in replace mode", async () => { + loadModelCatalog.mockResolvedValue([ + { + provider: "openai", + id: "gpt-5.5", + name: "GPT-5.5", + }, + ]); + + const multiselect = createSelectAllMultiselect(); + const prompter = makePrompter({ multiselect }); + const config = { + models: { + mode: "replace", + providers: { + minimax: { + baseUrl: "https://api.minimax.test/v1", + models: [configuredTextModel("MiniMax-M2.7-highspeed", "MiniMax M2.7 Highspeed")], + }, + zhipu: { + baseUrl: "https://api.zhipu.test/v1", + models: [configuredTextModel("glm-4.5-air", "GLM 4.5 Air")], + }, + }, + }, + agents: { defaults: {} }, + } as OpenClawConfig; + + const result = await promptModelAllowlist({ config, prompter }); + + expect(loadModelCatalog).not.toHaveBeenCalled(); + expect( + multiselect.mock.calls[0]?.[0]?.options.map((option: { value: string }) => option.value), + ).toEqual(["minimax/MiniMax-M2.7-highspeed", "zhipu/glm-4.5-air"]); + expect(result.models).toEqual(["minimax/MiniMax-M2.7-highspeed", "zhipu/glm-4.5-air"]); + }); + it("scopes the initial allowlist picker to the preferred provider", async () => { loadModelCatalog.mockResolvedValue([ { diff --git a/src/flows/model-picker.ts b/src/flows/model-picker.ts index 2301de4e9ff..444d3e9eb25 100644 --- a/src/flows/model-picker.ts +++ b/src/flows/model-picker.ts @@ -8,6 +8,7 @@ import { } from "../agents/model-picker-visibility.js"; import { buildAllowedModelSet, + buildConfiguredModelCatalog, buildModelAliasIndex, type ModelAliasIndex, modelKey, @@ -115,6 +116,13 @@ function resolveConfiguredModelKeys(cfg: OpenClawConfig): string[] { .filter((key) => key.length > 0); } +function loadPickerModelCatalog(cfg: OpenClawConfig): ReturnType { + if (cfg.models?.mode === "replace") { + return Promise.resolve(buildConfiguredModelCatalog({ cfg })); + } + return loadModelCatalog({ config: cfg }); +} + function normalizeModelKeys(values: string[]): string[] { const seen = new Set(); const next: string[] = []; @@ -625,7 +633,7 @@ export async function promptDefaultModel( const catalogProgress = params.prompter.progress("Loading available models"); let catalog: Awaited>; try { - catalog = await loadModelCatalog({ config: cfg }); + catalog = await loadPickerModelCatalog(cfg); } finally { catalogProgress.stop(); } @@ -897,7 +905,7 @@ export async function promptModelAllowlist(params: { const allowlistProgress = params.prompter.progress("Loading available models"); let catalog: Awaited>; try { - catalog = await loadModelCatalog({ config: cfg }); + catalog = await loadPickerModelCatalog(cfg); } finally { allowlistProgress.stop(); }