diff --git a/CHANGELOG.md b/CHANGELOG.md index 2530a4fbde3..f6a21d64725 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai - Agents/commitments: keep inferred follow-ups internal when heartbeat target is none, strip raw source text from stored commitments, disable tools during due-commitment heartbeat turns, bound hidden extraction queue growth, expire stale commitments, and add QA/Docker safety coverage. Thanks @vignesh07. - Agents/commitments: run hidden follow-up extraction on the configured agent/default model instead of falling back to direct OpenAI, so OpenAI Codex OAuth-only gateways no longer spam background API-key failures. Fixes #75334. Thanks @sene1337. - Security/config-audit: redact CLI argv and execArgv secrets before persisting config audit records, covering write, observe, and recovery paths. Fixes #60826. Thanks @koshaji. +- Gateway/models: keep default and configured model-list views responsive when provider catalog discovery stalls, without hiding real catalog load failures, while `--all` still waits for the exact full catalog. Fixes #75297; refs #74404. Thanks @lisandromachado and @najef1979-code. - Plugins/runtime-deps: accept already materialized package-level runtime-deps supersets as converged, so later lazy plugin activation no longer prunes and relaunches `pnpm install` after gateway startup pre-staging, reducing event-loop pressure from repeated runtime-deps repair on packaged installs. Fixes #75283; refs #75297 and #72338. Thanks @brokemac79, @lisandromachado, and @midhunmonachan. - Discord: retry queued REST 429s against learned bucket/global cooldowns and reacquire fresh voice upload URLs after CDN upload rate limits, so outbound sends recover without reusing stale single-use upload URLs. Thanks @discord. - TTS/providers: keep bundled speech-provider compat fallback available when plugins are globally disabled, so cold gateway and CLI startup can still resolve fallback speech providers instead of leaving explicit TTS provider selection with no registered providers. Refs #75265. Thanks @sliekens. diff --git a/docs/cli/models.md b/docs/cli/models.md index d96b2e582cc..ba8903d20fd 100644 --- a/docs/cli/models.md +++ b/docs/cli/models.md @@ -56,6 +56,11 @@ Notes: rows from plugin manifests or bundled provider catalog metadata even when you have not authenticated with that provider yet. Those rows still show as unavailable until matching auth is configured. +- `models list` keeps the control plane responsive while provider catalog + discovery is slow. The default and configured views fall back to configured or + synthetic model rows after a short wait and let discovery finish in the + background. Use `--all` when you need the exact full discovered catalog and + are willing to wait for provider discovery. - Broad `models list --all` merges manifest catalog rows over registry rows without loading provider runtime supplement hooks. Provider-filtered manifest fast paths use only providers marked `static`; providers marked `refreshable` diff --git a/src/gateway/server-methods/models.test.ts b/src/gateway/server-methods/models.test.ts new file mode 100644 index 00000000000..4999d396b2b --- /dev/null +++ b/src/gateway/server-methods/models.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { ErrorCodes } from "../protocol/index.js"; +import { modelsHandlers } from "./models.js"; + +type Deferred = { + promise: Promise; + resolve: (value: T) => void; + reject: (error: unknown) => void; +}; + +function createDeferred(): Deferred { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +describe("models.list", () => { + it("does not block the configured view on slow model catalog discovery", async () => { + const catalog = createDeferred(); + const respond = vi.fn(); + + vi.useFakeTimers(); + try { + const request = modelsHandlers["models.list"]({ + req: { + type: "req", + id: "req-models-list-slow-catalog", + method: "models.list", + params: { view: "configured" }, + }, + params: { view: "configured" }, + respond, + client: null, + isWebchatConnect: () => false, + context: { + getRuntimeConfig: () => { + const config = { + models: { + providers: { + openai: { + baseUrl: "https://openai.example.com", + models: [{ id: "gpt-test", name: "GPT Test" }], + }, + }, + }, + }; + return config as unknown as OpenClawConfig; + }, + loadGatewayModelCatalog: vi.fn(() => catalog.promise), + logGateway: { + debug: vi.fn(), + }, + } as never, + }); + + await vi.advanceTimersByTimeAsync(800); + await request; + + expect(respond).toHaveBeenCalledWith( + true, + { + models: [ + { + id: "gpt-test", + name: "GPT Test", + provider: "openai", + }, + ], + }, + undefined, + ); + } finally { + vi.useRealTimers(); + } + }); + + it("keeps the all view exact instead of timing out to a partial catalog", async () => { + const catalog = createDeferred<[{ id: string; name: string; provider: string }]>(); + const respond = vi.fn(); + + vi.useFakeTimers(); + try { + const request = modelsHandlers["models.list"]({ + req: { + type: "req", + id: "req-models-list-all-slow-catalog", + method: "models.list", + params: { view: "all" }, + }, + params: { view: "all" }, + respond, + client: null, + isWebchatConnect: () => false, + context: { + getRuntimeConfig: () => ({}) as OpenClawConfig, + loadGatewayModelCatalog: vi.fn(() => catalog.promise), + logGateway: { + debug: vi.fn(), + }, + } as never, + }); + + await vi.advanceTimersByTimeAsync(800); + expect(respond).not.toHaveBeenCalled(); + + catalog.resolve([{ id: "gpt-test", name: "GPT Test", provider: "openai" }]); + await request; + + expect(respond).toHaveBeenCalledWith( + true, + { models: [{ id: "gpt-test", name: "GPT Test", provider: "openai" }] }, + undefined, + ); + } finally { + vi.useRealTimers(); + } + }); + + it("preserves catalog load errors before the timeout fallback wins", async () => { + const respond = vi.fn(); + + await modelsHandlers["models.list"]({ + req: { + type: "req", + id: "req-models-list-catalog-error", + method: "models.list", + params: { view: "configured" }, + }, + params: { view: "configured" }, + respond, + client: null, + isWebchatConnect: () => false, + context: { + getRuntimeConfig: () => ({}) as OpenClawConfig, + loadGatewayModelCatalog: vi.fn(() => Promise.reject(new Error("catalog failed"))), + logGateway: { + debug: vi.fn(), + }, + } as never, + }); + + expect(respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ + code: ErrorCodes.UNAVAILABLE, + message: "Error: catalog failed", + }), + ); + }); +}); diff --git a/src/gateway/server-methods/models.ts b/src/gateway/server-methods/models.ts index 1f9226a3e8c..c2fc194c246 100644 --- a/src/gateway/server-methods/models.ts +++ b/src/gateway/server-methods/models.ts @@ -8,14 +8,53 @@ import { formatValidationErrors, validateModelsListParams, } from "../protocol/index.js"; +import type { GatewayRequestContext } from "./shared-types.js"; import type { GatewayRequestHandlers } from "./types.js"; type ModelsListView = "default" | "configured" | "all"; +type GatewayModelCatalog = Awaited>; + +const MODELS_LIST_CATALOG_TIMEOUT_MS = 750; +let loggedSlowModelsListCatalog = false; function resolveModelsListView(params: Record): ModelsListView { return typeof params.view === "string" ? (params.view as ModelsListView) : "default"; } +async function loadModelsListCatalog( + context: GatewayRequestContext, + view: ModelsListView, +): Promise { + if (view === "all") { + return await context.loadGatewayModelCatalog(); + } + let timeout: NodeJS.Timeout | undefined; + const timedOut = Symbol("models-list-catalog-timeout"); + const catalogPromise = context.loadGatewayModelCatalog(); + const timeoutPromise = new Promise((resolve) => { + timeout = setTimeout(() => resolve(timedOut), MODELS_LIST_CATALOG_TIMEOUT_MS); + timeout.unref?.(); + }); + try { + const result = await Promise.race([catalogPromise, timeoutPromise]); + if (result === timedOut) { + catalogPromise.catch(() => undefined); + if (!loggedSlowModelsListCatalog) { + loggedSlowModelsListCatalog = true; + context.logGateway.debug( + `models.list continuing without model catalog after ${MODELS_LIST_CATALOG_TIMEOUT_MS}ms`, + ); + } + return []; + } + return result; + } finally { + if (timeout) { + clearTimeout(timeout); + } + } +} + export const modelsHandlers: GatewayRequestHandlers = { "models.list": async ({ params, respond, context }) => { if (!validateModelsListParams(params)) { @@ -30,12 +69,12 @@ export const modelsHandlers: GatewayRequestHandlers = { return; } try { - const catalog = await context.loadGatewayModelCatalog(); const cfg = context.getRuntimeConfig(); const workspaceDir = resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg)) ?? resolveDefaultAgentWorkspaceDir(); const view = resolveModelsListView(params); + const catalog = await loadModelsListCatalog(context, view); if (view === "all") { respond(true, { models: catalog }, undefined); return;