From 870d993eb85c02bdcaba5e13e53167b3a641b296 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 28 Apr 2026 05:20:55 +0100 Subject: [PATCH] fix(ui): request configured model list --- CHANGELOG.md | 1 + docs/concepts/models.md | 1 + docs/gateway/protocol.md | 10 +- docs/web/control-ui.md | 1 + src/gateway/protocol/index.test.ts | 15 +++ .../protocol/schema/agents-models-skills.ts | 9 +- src/gateway/server-methods/models.ts | 33 ++++- .../server-startup-config.recovery.test.ts | 4 +- .../server.models-voicewake-misc.test.ts | 119 +++++++++++++++++- .../chat/slash-command-executor.node.test.ts | 4 +- ui/src/ui/chat/slash-command-executor.ts | 4 +- ui/src/ui/controllers/cron.test.ts | 22 ++++ ui/src/ui/controllers/cron.ts | 2 +- ui/src/ui/controllers/models.test.ts | 20 +++ ui/src/ui/controllers/models.ts | 4 +- 15 files changed, 236 insertions(+), 13 deletions(-) create mode 100644 ui/src/ui/controllers/models.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 69cf9bdf3c1..f9f0c19bc11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/concepts/models.md b/docs/concepts/models.md index 630fbaebdbf..ab4e70f016c 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). +- 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/docs/gateway/protocol.md b/docs/gateway/protocol.md index 803e7666b62..e9cae71fcef 100644 --- a/docs/gateway/protocol.md +++ b/docs/gateway/protocol.md @@ -288,7 +288,7 @@ enumeration of `src/gateway/server-methods/*.ts`. - - `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.` 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`. diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index 439c7ceb7be..4fee01d4c20 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -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. diff --git a/src/gateway/protocol/index.test.ts b/src/gateway/protocol/index.test.ts index 5f28abf0787..8ba18ed23b1 100644 --- a/src/gateway/protocol/index.test.ts +++ b/src/gateway/protocol/index.test.ts @@ -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); + }); +}); diff --git a/src/gateway/protocol/schema/agents-models-skills.ts b/src/gateway/protocol/schema/agents-models-skills.ts index 9ffbe230b13..5595f3d0d8c 100644 --- a/src/gateway/protocol/schema/agents-models-skills.ts +++ b/src/gateway/protocol/schema/agents-models-skills.ts @@ -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( { diff --git a/src/gateway/server-methods/models.ts b/src/gateway/server-methods/models.ts index 39722fc268b..874992b8120 100644 --- a/src/gateway/server-methods/models.ts +++ b/src/gateway/server-methods/models.ts @@ -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): 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))); diff --git a/src/gateway/server-startup-config.recovery.test.ts b/src/gateway/server-startup-config.recovery.test.ts index 064935e7f22..5ebe51ce33b 100644 --- a/src/gateway/server-startup-config.recovery.test.ts +++ b/src/gateway/server-startup-config.recovery.test.ts @@ -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, diff --git a/src/gateway/server.models-voicewake-misc.test.ts b/src/gateway/server.models-voicewake-misc.test.ts index bab80488728..3ed60eb50c9 100644 --- a/src/gateway/server.models-voicewake-misc.test.ts +++ b/src/gateway/server.models-voicewake-misc.test.ts @@ -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", diff --git a/ui/src/ui/chat/slash-command-executor.node.test.ts b/ui/src/ui/chat/slash-command-executor.node.test.ts index e5fbb055d8c..e72d71c48f1 100644 --- a/ui/src/ui/chat/slash-command-executor.node.test.ts +++ b/ui/src/ui/chat/slash-command-executor.node.test.ts @@ -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 () => { diff --git a/ui/src/ui/chat/slash-command-executor.ts b/ui/src/ui/chat/slash-command-executor.ts index dcb14ef8f83..8f1427bd889 100644 --- a/ui/src/ui/chat/slash-command-executor.ts +++ b/ui/src/ui/chat/slash-command-executor.ts @@ -725,7 +725,9 @@ async function loadModelCatalog( opts?: { allowFailure?: boolean }, ): Promise { 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) { diff --git a/ui/src/ui/controllers/cron.test.ts b/ui/src/ui/controllers/cron.test.ts index 95f84a5c25f..dd84560bf28 100644 --- a/ui/src/ui/controllers/cron.test.ts +++ b/ui/src/ui/controllers/cron.test.ts @@ -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 { } 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, diff --git a/ui/src/ui/controllers/cron.ts b/ui/src/ui/controllers/cron.ts index 409b1b979b2..10f3cca375f 100644 --- a/ui/src/ui/controllers/cron.ts +++ b/ui/src/ui/controllers/cron.ts @@ -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 = []; diff --git a/ui/src/ui/controllers/models.test.ts b/ui/src/ui/controllers/models.test.ts new file mode 100644 index 00000000000..03d94013a85 --- /dev/null +++ b/ui/src/ui/controllers/models.test.ts @@ -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" }, + ]); + }); +}); diff --git a/ui/src/ui/controllers/models.ts b/ui/src/ui/controllers/models.ts index d9e119c5c3a..237bf0a7c3b 100644 --- a/ui/src/ui/controllers/models.ts +++ b/ui/src/ui/controllers/models.ts @@ -10,7 +10,9 @@ import type { ModelCatalogEntry } from "../types.ts"; */ export async function loadModels(client: GatewayBrowserClient): Promise { 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 [];