mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 13:50:49 +00:00
perf: keep models list responsive during catalog discovery (#75326)
* perf: keep models list responsive during catalog discovery * docs: record models list responsiveness fix * fix: preserve models catalog load failures
This commit is contained in:
committed by
GitHub
parent
fd0ca5987b
commit
4987482e4c
@@ -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.
|
||||
|
||||
@@ -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`
|
||||
|
||||
156
src/gateway/server-methods/models.test.ts
Normal file
156
src/gateway/server-methods/models.test.ts
Normal file
@@ -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<T> = {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
reject: (error: unknown) => void;
|
||||
};
|
||||
|
||||
function createDeferred<T>(): Deferred<T> {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error: unknown) => void;
|
||||
const promise = new Promise<T>((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<never>();
|
||||
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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<ReturnType<GatewayRequestContext["loadGatewayModelCatalog"]>>;
|
||||
|
||||
const MODELS_LIST_CATALOG_TIMEOUT_MS = 750;
|
||||
let loggedSlowModelsListCatalog = false;
|
||||
|
||||
function resolveModelsListView(params: Record<string, unknown>): ModelsListView {
|
||||
return typeof params.view === "string" ? (params.view as ModelsListView) : "default";
|
||||
}
|
||||
|
||||
async function loadModelsListCatalog(
|
||||
context: GatewayRequestContext,
|
||||
view: ModelsListView,
|
||||
): Promise<GatewayModelCatalog> {
|
||||
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<typeof timedOut>((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;
|
||||
|
||||
Reference in New Issue
Block a user