Skip to content

Commit 2c6bdc8

Browse files
authored
perf(control-ui): reuse startup model metadata (#91531)
1 parent 72e4083 commit 2c6bdc8

9 files changed

Lines changed: 361 additions & 50 deletions

File tree

src/agents/model-catalog-browse.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,18 @@ function resolveModelCatalogBrowseTimeoutMs(value: number | undefined): number {
4343
);
4444
}
4545

46+
/** True when a browse view cannot be answered from read-only cached catalog entries. */
47+
export function modelCatalogBrowseRequiresFullDiscovery(params: {
48+
cfg: OpenClawConfig;
49+
view?: ModelCatalogBrowseView;
50+
}): boolean {
51+
const view = params.view ?? "default";
52+
return (
53+
view === "all" ||
54+
parseConfiguredModelVisibilityEntries({ cfg: params.cfg }).providerWildcards.size > 0
55+
);
56+
}
57+
4658
/** Loads catalog entries for browse views, using read-only discovery unless full catalog is required. */
4759
export async function loadModelCatalogForBrowse(params: {
4860
cfg: OpenClawConfig;
@@ -52,10 +64,7 @@ export async function loadModelCatalogForBrowse(params: {
5264
onTimeout?: (timeoutMs: number) => void;
5365
}): Promise<ModelCatalogEntry[]> {
5466
const view = params.view ?? "default";
55-
if (view === "all") {
56-
return await params.loadCatalog({ readOnly: false });
57-
}
58-
if (parseConfiguredModelVisibilityEntries({ cfg: params.cfg }).providerWildcards.size > 0) {
67+
if (modelCatalogBrowseRequiresFullDiscovery({ cfg: params.cfg, view })) {
5968
// Wildcards depend on provider discovery; read-only cached entries can hide matching models.
6069
return await params.loadCatalog({ readOnly: false });
6170
}

src/gateway/server-methods/chat.ts

Lines changed: 87 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ import { resolveProviderIdForAuth } from "../../agents/provider-auth-aliases.js"
4343
import type { AgentMessage } from "../../agents/runtime/index.js";
4444
import { ensureSandboxWorkspaceForSession } from "../../agents/sandbox/context.js";
4545
import { resolveAgentTimeoutMs } from "../../agents/timeout.js";
46+
import type { ModelCatalogEntry } from "../../agents/model-catalog.types.js";
47+
import { modelCatalogBrowseRequiresFullDiscovery } from "../../agents/model-catalog-browse.js";
4648
import { dispatchInboundMessage } from "../../auto-reply/dispatch.js";
4749
import { getReplyPayloadMetadata, type ReplyPayload } from "../../auto-reply/reply-payload.js";
4850
import { createReplyDispatcher } from "../../auto-reply/reply/reply-dispatcher.js";
@@ -213,6 +215,11 @@ type PreRegisteredAgentRun = {
213215

214216
type ChatHistoryMethod = "chat.history" | "chat.startup";
215217

218+
type ChatMetadataResult = {
219+
commands?: unknown[];
220+
models?: unknown[];
221+
};
222+
216223
type ChatSendAckServerTiming = {
217224
receivedToAckMs: number;
218225
loadSessionMs: number;
@@ -324,31 +331,76 @@ async function handleChatMetadataRequest({
324331
return;
325332
}
326333
try {
327-
const [{ buildModelsListResult }, { buildCommandsListResult }] = await Promise.all([
328-
import("./models-list-result.js"),
329-
import("./commands-list-result.js"),
330-
]);
331-
const [models, commands] = await Promise.all([
332-
buildModelsListResult({
334+
respond(
335+
true,
336+
await buildChatMetadataResult({
337+
cfg,
333338
context,
334339
agentId: requestedAgentId,
335-
params: { view: "configured" },
336340
}),
337-
Promise.resolve(
338-
buildCommandsListResult({
339-
cfg,
340-
agentId: requestedAgentId,
341-
includeArgs: true,
342-
scope: "text",
343-
}),
344-
),
345-
]);
346-
respond(true, { ...models, ...commands });
341+
);
347342
} catch (err) {
348343
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, String(err)));
349344
}
350345
}
351346

347+
async function buildChatMetadataResult(params: {
348+
cfg: OpenClawConfig;
349+
context: GatewayRequestContext;
350+
agentId: string;
351+
preloadedModelCatalog?: ModelCatalogEntry[];
352+
}): Promise<ChatMetadataResult> {
353+
const [{ buildModelsListResult }, { buildCommandsListResult }] = await Promise.all([
354+
import("./models-list-result.js"),
355+
import("./commands-list-result.js"),
356+
]);
357+
const [models, commands] = await Promise.all([
358+
buildModelsListResult({
359+
context: params.context,
360+
agentId: params.agentId,
361+
params: { view: "configured" },
362+
preloadedCatalog: params.preloadedModelCatalog,
363+
}),
364+
Promise.resolve(
365+
buildCommandsListResult({
366+
cfg: params.cfg,
367+
agentId: params.agentId,
368+
includeArgs: true,
369+
scope: "text",
370+
}),
371+
),
372+
]);
373+
return { ...models, ...commands };
374+
}
375+
376+
async function buildChatStartupMetadataResult(params: {
377+
cfg: OpenClawConfig;
378+
context: GatewayRequestContext;
379+
agentId: string;
380+
modelCatalog: ModelCatalogEntry[] | undefined;
381+
}): Promise<ChatMetadataResult | undefined> {
382+
if (!params.modelCatalog) {
383+
return undefined;
384+
}
385+
if (modelCatalogBrowseRequiresFullDiscovery({ cfg: params.cfg, view: "configured" })) {
386+
return undefined;
387+
}
388+
try {
389+
const { buildModelsListResult } = await import("./models-list-result.js");
390+
return await buildModelsListResult({
391+
context: params.context,
392+
agentId: params.agentId,
393+
params: { view: "configured" },
394+
preloadedCatalog: params.modelCatalog,
395+
});
396+
} catch (err) {
397+
params.context.logGateway.debug(
398+
`chat.startup continuing without metadata: ${formatErrorMessage(err)}`,
399+
);
400+
return undefined;
401+
}
402+
}
403+
352404
function normalizeUnknownText(value: unknown): string | undefined {
353405
return typeof value === "string" ? normalizeOptionalText(value) : undefined;
354406
}
@@ -2380,9 +2432,11 @@ async function handleChatHistoryRequest({
23802432
context,
23812433
method,
23822434
includeAgentsList,
2435+
includeMetadata,
23832436
}: GatewayRequestHandlerOptions & {
23842437
method: ChatHistoryMethod;
23852438
includeAgentsList?: boolean;
2439+
includeMetadata?: boolean;
23862440
}) {
23872441
if (!validateChatHistoryParams(params)) {
23882442
respond(
@@ -2510,6 +2564,15 @@ async function handleChatHistoryRequest({
25102564
);
25112565
}
25122566
const modelCatalog = await modelCatalogPromise;
2567+
const defaultAgentId = resolveDefaultAgentId(cfg);
2568+
const startupMetadata = includeMetadata
2569+
? await buildChatStartupMetadataResult({
2570+
cfg,
2571+
context,
2572+
agentId: sessionAgentId,
2573+
modelCatalog,
2574+
})
2575+
: undefined;
25132576
const sessionInfo = buildGatewaySessionInfo({
25142577
cfg,
25152578
storePath,
@@ -2519,7 +2582,6 @@ async function handleChatHistoryRequest({
25192582
agentId: selectedAgent.agentId,
25202583
modelCatalog,
25212584
});
2522-
const defaultAgentId = resolveDefaultAgentId(cfg);
25232585
const activeRunAgentId =
25242586
canonicalKey === "global" ? (selectedAgent.agentId ?? defaultAgentId) : selectedAgent.agentId;
25252587
sessionInfo.hasActiveRun = hasTrackedActiveSessionRun({
@@ -2560,6 +2622,7 @@ async function handleChatHistoryRequest({
25602622
verboseLevel,
25612623
...(boundedInFlightRun ? { inFlightRun: boundedInFlightRun } : {}),
25622624
...(includeAgentsList ? { agentsList: listAgentsForGateway(cfg, modelCatalog) } : {}),
2625+
...(startupMetadata ? { metadata: startupMetadata } : {}),
25632626
};
25642627
respond(true, payload);
25652628
}
@@ -2569,7 +2632,12 @@ export const chatHandlers: GatewayRequestHandlers = {
25692632
await handleChatHistoryRequest({ ...opts, method: "chat.history" });
25702633
},
25712634
"chat.startup": async (opts) => {
2572-
await handleChatHistoryRequest({ ...opts, method: "chat.startup", includeAgentsList: true });
2635+
await handleChatHistoryRequest({
2636+
...opts,
2637+
method: "chat.startup",
2638+
includeAgentsList: true,
2639+
includeMetadata: true,
2640+
});
25732641
},
25742642
"chat.metadata": handleChatMetadataRequest,
25752643
"chat.message.get": async ({ params, respond, context }) => {

src/gateway/server-methods/models-list-result.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,7 @@ export async function buildModelsListResult(params: {
264264
context: GatewayRequestContext;
265265
agentId?: string;
266266
params: Record<string, unknown>;
267+
preloadedCatalog?: ModelCatalogEntry[];
267268
}): Promise<{ models: ModelsListEntry[] }> {
268269
const cfg = params.context.getRuntimeConfig();
269270
const agentId = params.agentId ?? resolveDefaultAgentId(cfg);
@@ -272,7 +273,13 @@ export async function buildModelsListResult(params: {
272273
const catalog = await loadModelCatalogForBrowse({
273274
cfg,
274275
view,
275-
loadCatalog: params.context.loadGatewayModelCatalog,
276+
loadCatalog: async (loadParams) => {
277+
const readOnlyLoad = loadParams.readOnly ?? true;
278+
if (params.preloadedCatalog && readOnlyLoad) {
279+
return params.preloadedCatalog;
280+
}
281+
return await params.context.loadGatewayModelCatalog(loadParams);
282+
},
276283
onTimeout: (timeoutMs) => {
277284
if (loggedSlowModelsListCatalog) {
278285
return;

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

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -378,6 +378,27 @@ describe("gateway server chat", () => {
378378

379379
test("chat.startup returns chat history with the initial agents list", async () => {
380380
await withGatewayChatHarness(async ({ ws, createSessionDir }) => {
381+
await writeGatewayConfig({
382+
agents: {
383+
defaults: {
384+
model: {
385+
primary: "openai/gpt-main",
386+
},
387+
models: {
388+
"openai/gpt-main": {},
389+
},
390+
},
391+
list: [{ id: "main", default: true }],
392+
},
393+
models: {
394+
providers: {
395+
openai: {
396+
baseUrl: "https://openai.example.com/v1",
397+
models: [{ id: "gpt-main", name: "GPT Main" }],
398+
},
399+
},
400+
},
401+
});
381402
await connectOk(ws);
382403
const sessionDir = await createSessionDir();
383404
const updatedAt = Date.now();
@@ -407,6 +428,10 @@ describe("gateway server chat", () => {
407428
defaultId?: string | null;
408429
mainKey?: string | null;
409430
};
431+
metadata?: {
432+
commands?: Array<{ name?: string; textAliases?: string[] }>;
433+
models?: Array<{ id?: string; provider?: string }>;
434+
};
410435
messages?: unknown[];
411436
sessionInfo?: { key?: string; sessionId?: string };
412437
}>(ws, "chat.startup", { sessionKey: "main" });
@@ -419,6 +444,15 @@ describe("gateway server chat", () => {
419444
key: "agent:main:main",
420445
sessionId: "sess-main",
421446
});
447+
expect(startup.payload?.metadata?.models).toEqual(
448+
expect.arrayContaining([
449+
expect.objectContaining({
450+
id: "gpt-main",
451+
provider: "openai",
452+
}),
453+
]),
454+
);
455+
expect(startup.payload?.metadata?.commands).toBeUndefined();
422456
expect(startup.payload?.messages).toEqual(
423457
expect.arrayContaining([
424458
expect.objectContaining({
@@ -430,6 +464,93 @@ describe("gateway server chat", () => {
430464
});
431465
});
432466

467+
test("chat.startup omits metadata when configured model visibility needs full discovery", async () => {
468+
await withGatewayChatHarness(async ({ ws }) => {
469+
await writeGatewayConfig({
470+
agents: {
471+
defaults: {
472+
model: { primary: "openai/gpt-main" },
473+
models: {
474+
"openai/*": {},
475+
},
476+
},
477+
list: [{ id: "main", default: true }],
478+
},
479+
models: {
480+
providers: {
481+
openai: {
482+
baseUrl: "https://openai.example.com/v1",
483+
models: [{ id: "gpt-main", name: "GPT Main" }],
484+
},
485+
},
486+
},
487+
});
488+
await connectOk(ws);
489+
490+
const startup = await rpcReq<{ metadata?: unknown }>(ws, "chat.startup", {
491+
sessionKey: "main",
492+
});
493+
494+
expect(startup.ok).toBe(true);
495+
expect(startup.payload?.metadata).toBeUndefined();
496+
});
497+
});
498+
499+
test("chat.startup scopes metadata to agent session keys without explicit agentId", async () => {
500+
await withGatewayChatHarness(async ({ ws }) => {
501+
await writeGatewayConfig({
502+
agents: {
503+
defaults: {
504+
model: {
505+
primary: "openai/gpt-main",
506+
},
507+
models: {
508+
"openai/gpt-main": {},
509+
},
510+
},
511+
list: [
512+
{ id: "main", default: true },
513+
{
514+
id: "work",
515+
model: {
516+
primary: "minimax/MiniMax-M2.7-highspeed",
517+
},
518+
},
519+
],
520+
},
521+
models: {
522+
providers: {
523+
openai: {
524+
baseUrl: "https://openai.example.com/v1",
525+
models: [{ id: "gpt-main", name: "GPT Main" }],
526+
},
527+
minimax: {
528+
baseUrl: "https://minimax.example.com/v1",
529+
models: [{ id: "MiniMax-M2.7-highspeed", name: "MiniMax M2.7 Highspeed" }],
530+
},
531+
},
532+
},
533+
});
534+
await connectOk(ws);
535+
536+
const startup = await rpcReq<{
537+
metadata?: {
538+
models?: Array<{ id?: string; provider?: string }>;
539+
};
540+
}>(ws, "chat.startup", { sessionKey: "agent:work:main" });
541+
542+
expect(startup.ok).toBe(true);
543+
expect(startup.payload?.metadata?.models).toEqual(
544+
expect.arrayContaining([
545+
expect.objectContaining({
546+
id: "MiniMax-M2.7-highspeed",
547+
provider: "minimax",
548+
}),
549+
]),
550+
);
551+
});
552+
});
553+
433554
test("chat.metadata coalesces configured models and text commands", async () => {
434555
await withGatewayChatHarness(async ({ ws }) => {
435556
await writeGatewayConfig({

ui/src/test-helpers/control-ui-e2e.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -454,6 +454,9 @@ function installControlUiMockGateway(input: {
454454
scope: "agent",
455455
},
456456
messages: scenario.historyMessages,
457+
metadata: {
458+
models: scenario.models,
459+
},
457460
sessionId: "control-ui-e2e-session",
458461
thinkingLevel: null,
459462
};

0 commit comments

Comments
 (0)