mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 07:20:43 +00:00
fix(ui): request configured model list
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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)">
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
20
ui/src/ui/controllers/models.test.ts
Normal file
20
ui/src/ui/controllers/models.test.ts
Normal 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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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 [];
|
||||
|
||||
Reference in New Issue
Block a user