fix(ui): request configured model list

This commit is contained in:
Peter Steinberger
2026-04-28 05:20:55 +01:00
parent 000d52be37
commit 870d993eb8
15 changed files with 236 additions and 13 deletions

View File

@@ -42,6 +42,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- 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.
- 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).
- 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

@@ -288,7 +288,7 @@ enumeration of `src/gateway/server-methods/*.ts`.
</Accordion>
<Accordion title="Models and usage">
- `models.list` returns the runtime-allowed model catalog.
- `models.list` returns the runtime-allowed model catalog. Pass `{ "view": "configured" }` for picker-sized configured models (`agents.defaults.models` first, then `models.providers.*.models`), or `{ "view": "all" }` for the full catalog.
- `usage.status` returns provider usage windows/remaining quota summaries.
- `usage.cost` returns aggregated cost usage summaries for a date range.
- `doctor.memory.status` returns vector-memory / cached embedding readiness for the active default agent workspace. Pass `{ "probe": true }` or `{ "deep": true }` only when the caller explicitly wants a live embedding provider ping.
@@ -465,6 +465,14 @@ enumeration of `src/gateway/server-methods/*.ts`.
- Config mode patches `skills.entries.<skillKey>` values such as `enabled`,
`apiKey`, and `env`.
### `models.list` views
`models.list` accepts an optional `view` parameter:
- Omitted or `"default"`: current runtime behavior. If `agents.defaults.models` is configured, the response is the allowed catalog; otherwise the response is the full Gateway catalog.
- `"configured"`: picker-sized behavior. If `agents.defaults.models` is configured, it still wins. Otherwise the response uses explicit `models.providers.*.models` entries, falling back to the full catalog only when no configured model rows exist.
- `"all"`: full Gateway catalog, bypassing `agents.defaults.models`. Use this for diagnostics and discovery UIs, not normal model pickers.
## Exec approvals
- When an exec request needs approval, the gateway broadcasts `exec.approval.requested`.

View File

@@ -148,6 +148,7 @@ Imported themes are stored only in the current browser profile. They are not wri
- During an active send and the final history refresh, the chat view keeps local optimistic user/assistant messages visible if `chat.history` briefly returns an older snapshot; the canonical transcript replaces those local messages once the Gateway history catches up.
- `chat.inject` appends an assistant note to the session transcript and broadcasts a `chat` event for UI-only updates (no agent run, no channel delivery).
- The chat header model and thinking pickers patch the active session immediately through `sessions.patch`; they are persistent session overrides, not one-turn-only send options.
- The chat model picker requests the Gateway's configured model view. If `agents.defaults.models` is present, that allowlist drives the picker. Otherwise the picker shows explicit `models.providers.*.models` entries before falling back to the full catalog for fresh installs.
- When fresh Gateway session usage reports show high context pressure, the chat composer area shows a context notice and, at recommended compaction levels, a compact button that runs the normal session compaction path. Stale token snapshots are hidden until the Gateway reports fresh usage again.
</Accordion>
<Accordion title="Talk mode (browser realtime)">

View File

@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
import { TALK_TEST_PROVIDER_ID } from "../../test-utils/talk-test-provider.js";
import {
formatValidationErrors,
validateModelsListParams,
validateTalkConfigResult,
validateTalkRealtimeSessionParams,
validateWakeParams,
@@ -175,3 +176,17 @@ describe("validateWakeParams", () => {
).toBe(true);
});
});
describe("validateModelsListParams", () => {
it("accepts the supported model catalog views", () => {
expect(validateModelsListParams({})).toBe(true);
expect(validateModelsListParams({ view: "default" })).toBe(true);
expect(validateModelsListParams({ view: "configured" })).toBe(true);
expect(validateModelsListParams({ view: "all" })).toBe(true);
});
it("rejects unknown model catalog views and extra fields", () => {
expect(validateModelsListParams({ view: "available" })).toBe(false);
expect(validateModelsListParams({ view: "configured", provider: "minimax" })).toBe(false);
});
});

View File

@@ -178,7 +178,14 @@ export const AgentsFilesSetResultSchema = Type.Object(
{ additionalProperties: false },
);
export const ModelsListParamsSchema = Type.Object({}, { additionalProperties: false });
export const ModelsListParamsSchema = Type.Object(
{
view: Type.Optional(
Type.Union([Type.Literal("default"), Type.Literal("configured"), Type.Literal("all")]),
),
},
{ additionalProperties: false },
);
export const ModelsListResultSchema = Type.Object(
{

View File

@@ -1,5 +1,6 @@
import { DEFAULT_PROVIDER } from "../../agents/defaults.js";
import { buildAllowedModelSet } from "../../agents/model-selection.js";
import type { ModelCatalogEntry } from "../../agents/model-catalog.types.js";
import { buildAllowedModelSet, buildConfiguredModelCatalog } from "../../agents/model-selection.js";
import {
ErrorCodes,
errorShape,
@@ -8,6 +9,18 @@ import {
} from "../protocol/index.js";
import type { GatewayRequestHandlers } from "./types.js";
type ModelsListView = "default" | "configured" | "all";
function sortModelCatalogEntries(entries: ModelCatalogEntry[]): ModelCatalogEntry[] {
return entries.toSorted(
(a, b) => a.provider.localeCompare(b.provider) || a.id.localeCompare(b.id),
);
}
function resolveModelsListView(params: Record<string, unknown>): ModelsListView {
return typeof params.view === "string" ? (params.view as ModelsListView) : "default";
}
export const modelsHandlers: GatewayRequestHandlers = {
"models.list": async ({ params, respond, context }) => {
if (!validateModelsListParams(params)) {
@@ -24,12 +37,26 @@ export const modelsHandlers: GatewayRequestHandlers = {
try {
const catalog = await context.loadGatewayModelCatalog();
const cfg = context.getRuntimeConfig();
const { allowedCatalog } = buildAllowedModelSet({
const view = resolveModelsListView(params);
if (view === "all") {
respond(true, { models: catalog }, undefined);
return;
}
const allowed = buildAllowedModelSet({
cfg,
catalog,
defaultProvider: DEFAULT_PROVIDER,
});
const models = allowedCatalog.length > 0 ? allowedCatalog : catalog;
const configuredCatalog =
view === "configured" ? sortModelCatalogEntries(buildConfiguredModelCatalog({ cfg })) : [];
const models =
view === "configured" && allowed.allowAny && configuredCatalog.length > 0
? configuredCatalog
: allowed.allowedCatalog.length > 0
? allowed.allowedCatalog
: configuredCatalog.length > 0
? configuredCatalog
: catalog;
respond(true, { models }, undefined);
} catch (err) {
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, String(err)));

View File

@@ -266,13 +266,13 @@ describe("gateway startup config recovery", () => {
},
},
},
} as OpenClawConfig;
} as unknown as OpenClawConfig;
const autoEnabledConfig = {
...sourceConfig,
channels: {
telegram: { enabled: true },
},
} as OpenClawConfig;
} as unknown as OpenClawConfig;
const initialSnapshot = {
...buildTestConfigSnapshot({
path: configPath,

View File

@@ -146,7 +146,10 @@ const expectedSortedCatalog = (): ModelCatalogRpcEntry[] => [
];
describe("gateway server models + voicewake", () => {
const listModels = async () => rpcReq<{ models: ModelCatalogRpcEntry[] }>(ws, "models.list");
const listModels = async (params?: { view?: "default" | "configured" | "all" }) =>
params
? rpcReq<{ models: ModelCatalogRpcEntry[] }>(ws, "models.list", params)
: rpcReq<{ models: ModelCatalogRpcEntry[] }>(ws, "models.list");
const seedPiCatalog = () => {
piSdkMock.enabled = true;
@@ -475,6 +478,120 @@ describe("gateway server models + voicewake", () => {
expect(piSdkMock.discoverCalls).toBe(1);
});
test("models.list keeps default view on the full catalog when no allowlist is configured", async () => {
await withModelsConfig(
{
models: {
providers: {
minimax: {
baseUrl: "https://minimax.example.com/v1",
models: [{ id: "MiniMax-M2.7-highspeed", name: "MiniMax M2.7 Highspeed" }],
},
},
},
},
async () => {
seedPiCatalog();
const res = await listModels();
expect(res.ok).toBe(true);
expect(res.payload?.models).toEqual(expectedSortedCatalog());
},
);
});
test("models.list configured view uses models.providers when no allowlist is configured", async () => {
await withModelsConfig(
{
models: {
providers: {
zhipu: {
baseUrl: "https://zhipu.example.com/v1",
models: [{ id: "glm-4.5-air", name: "GLM 4.5 Air", reasoning: true }],
},
minimax: {
baseUrl: "https://minimax.example.com/v1",
models: [{ id: "MiniMax-M2.7-highspeed", name: "MiniMax M2.7 Highspeed" }],
},
},
},
},
async () => {
seedPiCatalog();
const res = await listModels({ view: "configured" });
expect(res.ok).toBe(true);
expect(res.payload?.models).toEqual([
{
id: "MiniMax-M2.7-highspeed",
name: "MiniMax M2.7 Highspeed",
provider: "minimax",
},
{
id: "glm-4.5-air",
name: "GLM 4.5 Air",
provider: "zhipu",
reasoning: true,
},
]);
},
);
});
test("models.list configured view still prefers agents.defaults.models allowlist", async () => {
await withModelsConfig(
{
agents: {
defaults: {
model: { primary: "openai/gpt-test-z" },
models: {
"openai/gpt-test-z": {},
},
},
},
models: {
providers: {
minimax: {
baseUrl: "https://minimax.example.com/v1",
models: [{ id: "MiniMax-M2.7-highspeed", name: "MiniMax M2.7 Highspeed" }],
},
},
},
},
async () => {
seedPiCatalog();
const res = await listModels({ view: "configured" });
expect(res.ok).toBe(true);
expect(res.payload?.models).toEqual([
{
id: "gpt-test-z",
name: "gpt-test-z",
provider: "openai",
},
]);
},
);
});
test("models.list all view bypasses agents.defaults.models allowlist", async () => {
await withModelsConfig(
{
agents: {
defaults: {
model: { primary: "openai/gpt-test-z" },
models: {
"openai/gpt-test-z": {},
},
},
},
},
async () => {
seedPiCatalog();
const res = await listModels({ view: "all" });
expect(res.ok).toBe(true);
expect(res.payload?.models).toEqual(expectedSortedCatalog());
},
);
});
test("models.list filters to allowlisted configured models by default", async () => {
await expectAllowlistedModels({
primary: "openai/gpt-test-z",

View File

@@ -269,7 +269,7 @@ describe("executeSlashCommand directives", () => {
"**Current model:** `gpt-4.1-mini`\n**Available:** `gpt-4.1-mini`, `gpt-4.1`",
);
expect(request).toHaveBeenNthCalledWith(1, "sessions.list", {});
expect(request).toHaveBeenNthCalledWith(2, "models.list", {});
expect(request).toHaveBeenNthCalledWith(2, "models.list", { view: "configured" });
});
it("mirrors resolved provider-qualified model refs after /model changes", async () => {
@@ -587,7 +587,7 @@ describe("executeSlashCommand directives", () => {
"Current thinking level: low.\nOptions: off, minimal, low, medium, high.",
);
expect(request).toHaveBeenNthCalledWith(1, "sessions.list", {});
expect(request).toHaveBeenNthCalledWith(2, "models.list", {});
expect(request).toHaveBeenNthCalledWith(2, "models.list", { view: "configured" });
});
it("accepts minimal and xhigh thinking levels", async () => {

View File

@@ -725,7 +725,9 @@ async function loadModelCatalog(
opts?: { allowFailure?: boolean },
): Promise<ModelCatalogEntry[]> {
try {
const result = await client.request<{ models: ModelCatalogEntry[] }>("models.list", {});
const result = await client.request<{ models: ModelCatalogEntry[] }>("models.list", {
view: "configured",
});
return result?.models ?? [];
} catch (err) {
if (opts?.allowFailure) {

View File

@@ -3,6 +3,7 @@ import { DEFAULT_CRON_FORM } from "../app-defaults.ts";
import {
addCronJob,
cancelCronEdit,
loadCronModelSuggestions,
loadCronJobsPage,
loadCronRuns,
loadMoreCronRuns,
@@ -58,6 +59,27 @@ function createState(overrides: Partial<CronState> = {}): CronState {
}
describe("cron controller", () => {
it("loads model suggestions from the configured model view", async () => {
const request = vi.fn(async () => ({
models: [
{ id: "z-model", provider: "zai" },
{ id: "a-model", provider: "anthropic" },
{ id: "z-model", provider: "other" },
{ provider: "missing-id" },
],
}));
const state = {
client: { request } as unknown as CronState["client"],
connected: true,
cronModelSuggestions: [],
};
await loadCronModelSuggestions(state);
expect(request).toHaveBeenCalledWith("models.list", { view: "configured" });
expect(state.cronModelSuggestions).toEqual(["a-model", "z-model"]);
});
it("normalizes stale announce mode when session/payload no longer support announce", () => {
const normalized = normalizeCronFormState({
...DEFAULT_CRON_FORM,

View File

@@ -205,7 +205,7 @@ export async function loadCronModelSuggestions(state: CronModelSuggestionsState)
return;
}
try {
const res = await state.client.request("models.list", {});
const res = await state.client.request("models.list", { view: "configured" });
const models = (res as { models?: unknown[] } | null)?.models;
if (!Array.isArray(models)) {
state.cronModelSuggestions = [];

View File

@@ -0,0 +1,20 @@
import { describe, expect, it, vi } from "vitest";
import type { GatewayBrowserClient } from "../gateway.ts";
import { loadModels } from "./models.ts";
describe("loadModels", () => {
it("requests the configured model list view", async () => {
const request = vi.fn(async () => ({
models: [
{ id: "MiniMax-M2.7-highspeed", name: "MiniMax M2.7 Highspeed", provider: "minimax" },
],
}));
const models = await loadModels({ request } as unknown as GatewayBrowserClient);
expect(request).toHaveBeenCalledWith("models.list", { view: "configured" });
expect(models).toEqual([
{ id: "MiniMax-M2.7-highspeed", name: "MiniMax M2.7 Highspeed", provider: "minimax" },
]);
});
});

View File

@@ -10,7 +10,9 @@ import type { ModelCatalogEntry } from "../types.ts";
*/
export async function loadModels(client: GatewayBrowserClient): Promise<ModelCatalogEntry[]> {
try {
const result = await client.request<{ models: ModelCatalogEntry[] }>("models.list", {});
const result = await client.request<{ models: ModelCatalogEntry[] }>("models.list", {
view: "configured",
});
return result?.models ?? [];
} catch {
return [];