Skip to content

Commit 79c6136

Browse files
authored
perf(control-ui): avoid startup catalog wait
Start the optional model catalog load early for chat.startup and cap startup-only catalog waiting at 25ms, while preserving the 750ms optional catalog wait for other gateway surfaces. Adds regressions for slow catalog omission, async cached metadata, and agent-scoped startup metadata.
1 parent c4a0ca0 commit 79c6136

3 files changed

Lines changed: 216 additions & 25 deletions

File tree

src/gateway/server-methods/chat.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,10 @@ import {
163163
buildWebchatAssistantMessageFromReplyPayloads,
164164
buildWebchatAudioContentBlocksFromReplyPayloads,
165165
} from "./chat-webchat-media.js";
166-
import { loadOptionalServerMethodModelCatalog } from "./optional-model-catalog.js";
166+
import {
167+
loadOptionalServerMethodModelCatalog,
168+
startOptionalServerMethodModelCatalogLoad,
169+
} from "./optional-model-catalog.js";
167170
import { hasTrackedActiveSessionRun } from "./session-active-runs.js";
168171
import { emitSessionsChanged } from "./session-change-event.js";
169172
import type {
@@ -514,6 +517,7 @@ export { sanitizeChatSendMessageInput } from "../chat-input-sanitize.js";
514517

515518
export const CHAT_HISTORY_MAX_SINGLE_MESSAGE_BYTES = 128 * 1024;
516519
const CHAT_HISTORY_OVERSIZED_PLACEHOLDER = "[chat.history omitted: message too large]";
520+
const CHAT_STARTUP_OPTIONAL_MODEL_CATALOG_TIMEOUT_MS = 25;
517521
const MANAGED_OUTGOING_IMAGE_PATH_PREFIX = "/api/chat/media/outgoing/";
518522
let chatHistoryPlaceholderEmitCount = 0;
519523
const chatHistoryManagedImageCleanupState = new Map<string, Promise<void>>();
@@ -2475,14 +2479,26 @@ async function handleChatHistoryRequest({
24752479
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, selectedAgent.error));
24762480
return;
24772481
}
2482+
const startupModelCatalogLoad =
2483+
method === "chat.startup" ? startOptionalServerMethodModelCatalogLoad(context) : undefined;
24782484
const modelCatalogPromise = measureDiagnosticsTimelineSpan(
24792485
`gateway.${method}.model_catalog`,
2480-
() => loadOptionalServerMethodModelCatalog(context, method),
2486+
() =>
2487+
startupModelCatalogLoad
2488+
? loadOptionalServerMethodModelCatalog(context, method, {
2489+
logOnceKey: "chat.startup",
2490+
startedLoad: startupModelCatalogLoad,
2491+
timeoutMs: CHAT_STARTUP_OPTIONAL_MODEL_CATALOG_TIMEOUT_MS,
2492+
})
2493+
: loadOptionalServerMethodModelCatalog(context, method),
24812494
{
24822495
config: cfg,
24832496
phase: method,
24842497
},
24852498
);
2499+
if (startupModelCatalogLoad) {
2500+
void modelCatalogPromise.catch(() => undefined);
2501+
}
24862502
const sessionId = entry?.sessionId;
24872503
const sessionAgentId = resolveSessionAgentId({
24882504
sessionKey,

src/gateway/server-methods/optional-model-catalog.ts

Lines changed: 45 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,38 +7,72 @@ import type { GatewayRequestContext } from "./types.js";
77
* Optional model-catalog loader for methods where metadata improves the result
88
* but should never block the primary session response path.
99
*/
10-
const OPTIONAL_MODEL_CATALOG_TIMEOUT_MS = 750;
10+
const DEFAULT_OPTIONAL_MODEL_CATALOG_TIMEOUT_MS = 750;
1111

1212
const loggedSlowCatalogKeys = new Set<string>();
1313

14+
export type OptionalServerMethodModelCatalogLoad = {
15+
promise: Promise<ModelCatalogEntry[] | undefined>;
16+
};
17+
18+
type LoadOptionalServerMethodModelCatalogOptions = {
19+
logOnceKey?: string;
20+
startedLoad?: OptionalServerMethodModelCatalogLoad;
21+
timeoutMs?: number;
22+
};
23+
24+
function normalizeOptionalModelCatalog(value: unknown): ModelCatalogEntry[] | undefined {
25+
return Array.isArray(value) ? value : undefined;
26+
}
27+
28+
export function startOptionalServerMethodModelCatalogLoad(
29+
context: GatewayRequestContext,
30+
): OptionalServerMethodModelCatalogLoad {
31+
let catalogPromise: Promise<unknown>;
32+
try {
33+
catalogPromise = context.loadGatewayModelCatalog();
34+
} catch {
35+
catalogPromise = Promise.resolve(undefined);
36+
}
37+
const promise = catalogPromise.then(
38+
(value) => {
39+
const catalog = normalizeOptionalModelCatalog(value);
40+
return catalog;
41+
},
42+
() => {
43+
return undefined;
44+
},
45+
);
46+
return {
47+
promise,
48+
};
49+
}
50+
1451
/** Loads the gateway model catalog with a short timeout and one-time slow logs. */
1552
export async function loadOptionalServerMethodModelCatalog(
1653
context: GatewayRequestContext,
1754
surface: string,
18-
options?: { logOnceKey?: string },
55+
options?: LoadOptionalServerMethodModelCatalogOptions,
1956
): Promise<ModelCatalogEntry[] | undefined> {
2057
let timeout: NodeJS.Timeout | undefined;
2158
const timedOut = Symbol("server-method-model-catalog-timeout");
59+
const timeoutMs = options?.timeoutMs ?? DEFAULT_OPTIONAL_MODEL_CATALOG_TIMEOUT_MS;
60+
const catalogLoad = options?.startedLoad ?? startOptionalServerMethodModelCatalogLoad(context);
2261
const timeoutPromise = new Promise<typeof timedOut>((resolve) => {
23-
timeout = setTimeout(() => resolve(timedOut), OPTIONAL_MODEL_CATALOG_TIMEOUT_MS);
62+
timeout = setTimeout(() => resolve(timedOut), timeoutMs);
2463
timeout.unref?.();
2564
});
2665
try {
27-
const result = await Promise.race([
28-
context.loadGatewayModelCatalog().catch(() => undefined),
29-
timeoutPromise,
30-
]);
66+
const result = await Promise.race([catalogLoad.promise, timeoutPromise]);
3167
if (result === timedOut) {
3268
const logOnceKey = options?.logOnceKey ?? "session-metadata";
3369
if (!loggedSlowCatalogKeys.has(logOnceKey)) {
3470
loggedSlowCatalogKeys.add(logOnceKey);
35-
context.logGateway.debug(
36-
`${surface} continuing without model catalog after ${OPTIONAL_MODEL_CATALOG_TIMEOUT_MS}ms`,
37-
);
71+
context.logGateway.debug(`${surface} continuing without model catalog after ${timeoutMs}ms`);
3872
}
3973
return undefined;
4074
}
41-
return Array.isArray(result) ? result : undefined;
75+
return normalizeOptionalModelCatalog(result);
4276
} finally {
4377
if (timeout) {
4478
clearTimeout(timeout);

src/gateway/server.chat.gateway-server-chat-b.test.ts

Lines changed: 153 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -464,6 +464,74 @@ describe("gateway server chat", () => {
464464
});
465465
});
466466

467+
test("chat.startup does not wait for slow optional model catalog metadata", async () => {
468+
const sessionDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gw-"));
469+
try {
470+
testState.sessionStorePath = path.join(sessionDir, "sessions.json");
471+
await writeSessionStore({
472+
entries: {
473+
main: {
474+
sessionId: "sess-main",
475+
modelProvider: "test-provider",
476+
model: "slow-catalog-model",
477+
updatedAt: Date.now(),
478+
},
479+
},
480+
});
481+
const catalog =
482+
createDeferred<Awaited<ReturnType<GatewayRequestContext["loadGatewayModelCatalog"]>>>();
483+
const responses: Array<{ ok: boolean; payload?: unknown; error?: unknown }> = [];
484+
const context = {
485+
loadGatewayModelCatalog: vi
486+
.fn<GatewayRequestContext["loadGatewayModelCatalog"]>()
487+
.mockReturnValue(catalog.promise),
488+
logGateway: {
489+
info: vi.fn(),
490+
warn: vi.fn(),
491+
error: vi.fn(),
492+
debug: vi.fn(),
493+
},
494+
chatAbortControllers: new Map(),
495+
chatRunBuffers: new Map(),
496+
getRuntimeConfig: () => ({}),
497+
} as unknown as GatewayRequestContext;
498+
const { chatHandlers } = await import("./server-methods/chat.js");
499+
500+
await chatHandlers["chat.startup"]({
501+
req: {
502+
type: "req",
503+
id: "startup-slow-catalog",
504+
method: "chat.startup",
505+
params: { sessionKey: "main" },
506+
},
507+
params: { sessionKey: "main" },
508+
client: null,
509+
isWebchatConnect: () => false,
510+
respond: ((ok, payload, error) => {
511+
responses.push({ ok, payload, error });
512+
}) as RespondFn,
513+
context,
514+
});
515+
516+
expect(context.loadGatewayModelCatalog).toHaveBeenCalledTimes(1);
517+
expect(responses).toHaveLength(1);
518+
expect(responses[0]?.ok).toBe(true);
519+
const payload = responses[0]?.payload as
520+
| {
521+
agentsList?: { agents?: Array<{ id?: string }> };
522+
metadata?: unknown;
523+
sessionInfo?: { sessionId?: string };
524+
}
525+
| undefined;
526+
expect(payload?.sessionInfo?.sessionId).toBe("sess-main");
527+
expect(payload?.agentsList?.agents?.map((agent) => agent.id)).toContain("main");
528+
expect(payload?.metadata).toBeUndefined();
529+
} finally {
530+
testState.sessionStorePath = undefined;
531+
await fs.rm(sessionDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
532+
}
533+
});
534+
467535
test("chat.startup omits metadata when configured model visibility needs full discovery", async () => {
468536
await withGatewayChatHarness(async ({ ws }) => {
469537
await writeGatewayConfig({
@@ -497,8 +565,18 @@ describe("gateway server chat", () => {
497565
});
498566

499567
test("chat.startup scopes metadata to agent session keys without explicit agentId", async () => {
500-
await withGatewayChatHarness(async ({ ws }) => {
501-
await writeGatewayConfig({
568+
const sessionDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gw-"));
569+
try {
570+
testState.sessionStorePath = path.join(sessionDir, "sessions.json");
571+
await writeSessionStore({
572+
entries: {
573+
"agent:work:main": {
574+
sessionId: "sess-work",
575+
updatedAt: Date.now(),
576+
},
577+
},
578+
});
579+
const config = {
502580
agents: {
503581
defaults: {
504582
model: {
@@ -515,6 +593,9 @@ describe("gateway server chat", () => {
515593
model: {
516594
primary: "minimax/MiniMax-M2.7-highspeed",
517595
},
596+
models: {
597+
"minimax/MiniMax-M2.7-highspeed": {},
598+
},
518599
},
519600
],
520601
},
@@ -530,25 +611,85 @@ describe("gateway server chat", () => {
530611
},
531612
},
532613
},
533-
});
534-
await connectOk(ws);
614+
};
615+
await writeGatewayConfig(config);
616+
const responses: Array<{ ok: boolean; payload?: unknown; error?: unknown }> = [];
617+
const context = {
618+
loadGatewayModelCatalog: vi
619+
.fn<GatewayRequestContext["loadGatewayModelCatalog"]>()
620+
.mockImplementation(async () => {
621+
await Promise.resolve();
622+
await Promise.resolve();
623+
return [
624+
{
625+
id: "gpt-main",
626+
name: "GPT Main",
627+
provider: "openai",
628+
input: ["text"],
629+
},
630+
{
631+
id: "MiniMax-M2.7-highspeed",
632+
name: "MiniMax M2.7 Highspeed",
633+
provider: "minimax",
634+
input: ["text"],
635+
},
636+
];
637+
}),
638+
logGateway: {
639+
info: vi.fn(),
640+
warn: vi.fn(),
641+
error: vi.fn(),
642+
debug: vi.fn(),
643+
},
644+
chatAbortControllers: new Map(),
645+
chatRunBuffers: new Map(),
646+
getRuntimeConfig: () => config,
647+
} as unknown as GatewayRequestContext;
648+
const { chatHandlers } = await import("./server-methods/chat.js");
535649

536-
const startup = await rpcReq<{
537-
metadata?: {
538-
models?: Array<{ id?: string; provider?: string }>;
539-
};
540-
}>(ws, "chat.startup", { sessionKey: "agent:work:main" });
650+
await chatHandlers["chat.startup"]({
651+
req: {
652+
type: "req",
653+
id: "startup-agent-scoped-metadata",
654+
method: "chat.startup",
655+
params: { sessionKey: "agent:work:main" },
656+
},
657+
params: { sessionKey: "agent:work:main" },
658+
client: null,
659+
isWebchatConnect: () => false,
660+
respond: ((ok, payload, error) => {
661+
responses.push({ ok, payload, error });
662+
}) as RespondFn,
663+
context,
664+
});
541665

542-
expect(startup.ok).toBe(true);
543-
expect(startup.payload?.metadata?.models).toEqual(
666+
expect(context.loadGatewayModelCatalog).toHaveBeenCalledTimes(1);
667+
expect(responses).toHaveLength(1);
668+
expect(responses[0]?.ok).toBe(true);
669+
const payload = responses[0]?.payload as
670+
| {
671+
metadata?: {
672+
models?: Array<{ id?: string; provider?: string }>;
673+
};
674+
sessionInfo?: { key?: string; sessionId?: string };
675+
}
676+
| undefined;
677+
expect(payload?.sessionInfo).toMatchObject({
678+
key: "agent:work:main",
679+
sessionId: "sess-work",
680+
});
681+
expect(payload?.metadata?.models).toEqual(
544682
expect.arrayContaining([
545683
expect.objectContaining({
546684
id: "MiniMax-M2.7-highspeed",
547685
provider: "minimax",
548686
}),
549687
]),
550688
);
551-
});
689+
} finally {
690+
testState.sessionStorePath = undefined;
691+
await fs.rm(sessionDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
692+
}
552693
});
553694

554695
test("chat.metadata coalesces configured models and text commands", async () => {

0 commit comments

Comments
 (0)