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 [];