fix(cli): respect replace mode in model picker

This commit is contained in:
Peter Steinberger
2026-04-28 05:26:20 +01:00
parent 1a2f60c0a1
commit 35c9dd06b2
4 changed files with 100 additions and 2 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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([
{

View File

@@ -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<typeof loadModelCatalog> {
if (cfg.models?.mode === "replace") {
return Promise.resolve(buildConfiguredModelCatalog({ cfg }));
}
return loadModelCatalog({ config: cfg });
}
function normalizeModelKeys(values: string[]): string[] {
const seen = new Set<string>();
const next: string[] = [];
@@ -625,7 +633,7 @@ export async function promptDefaultModel(
const catalogProgress = params.prompter.progress("Loading available models");
let catalog: Awaited<ReturnType<typeof loadModelCatalog>>;
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<ReturnType<typeof loadModelCatalog>>;
try {
catalog = await loadModelCatalog({ config: cfg });
catalog = await loadPickerModelCatalog(cfg);
} finally {
allowlistProgress.stop();
}