Skip to content

Commit 6171b42

Browse files
committed
fix(model-picker): show effective runtime choices
1 parent 7e0e29e commit 6171b42

20 files changed

Lines changed: 1031 additions & 41 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ Docs: https://docs.openclaw.ai
8080
- Cron: keep failed isolated-agent runs from marking successful result delivery when only the failure notification was delivered. Fixes #72985. Thanks @Allenbluff.
8181
- Discord: validate message-read results before normalizing channel history and report unexpected payloads with a Discord boundary error instead of `map is not a function`. Fixes #82252. Thanks @jessewunderlich.
8282
- Agents/runtime: apply `agents.defaults.models["provider/*"].agentRuntime` as provider-wide model runtime policy while preserving exact model runtime precedence. Fixes #82243. Thanks @rendrag-git.
83+
- Model picker: show the effective Codex runtime first for official OpenAI routes while keeping Pi available as an alternate and preserving Pi-first custom OpenAI-compatible providers. Fixes #82269. Thanks @rendrag-git.
8384
- Agents/auto-reply: restrict `NO_REPLY` prompt guidance to automatic group/channel replies, remove legacy silent-reply rewrites, and suppress accidental direct-chat silent tokens instead of delivering fallback text. Fixes #82254. Thanks @absol89.
8485
- Telegram: retain a longer partial-stream preview when a final callback only carries an ellipsis-truncated snapshot, preventing the visible answer and transcript mirror from being replaced by the short preview. Fixes #82239. Thanks @crash2kx.
8586
- Telegram/active-memory: run blocking memory recall through the Telegram provider for direct-message turns even when the hook context carries the raw chat id, preventing embedded recall from launching against an invalid numeric channel. Fixes #82177. Thanks @cslash-zz.

extensions/discord/src/monitor/model-picker.test.ts

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -532,6 +532,107 @@ describe("Discord model picker rendering", () => {
532532
expect(submitState?.modelIndex).toBe(3);
533533
});
534534

535+
it("defaults the runtime picker to the first effective runtime choice", () => {
536+
const data = createModelsProviderData({
537+
openai: ["gpt-4.1", "gpt-4o", "o3"],
538+
anthropic: ["claude-sonnet-4-5"],
539+
});
540+
data.runtimeChoicesByProvider = new Map([
541+
[
542+
"openai",
543+
[
544+
{
545+
id: "codex",
546+
label: "OpenAI Codex",
547+
description: "Use the OpenAI Codex runtime selected by the effective harness policy.",
548+
},
549+
{
550+
id: "pi",
551+
label: "OpenClaw Pi Default",
552+
description: "Use the built-in OpenClaw Pi runtime.",
553+
},
554+
],
555+
],
556+
]);
557+
558+
const rows = renderModelsViewRows({
559+
command: "models",
560+
userId: "42",
561+
data,
562+
provider: "openai",
563+
page: 1,
564+
providerPage: 2,
565+
currentModel: "openai/gpt-4o",
566+
pendingModel: "openai/o3",
567+
pendingModelIndex: 3,
568+
});
569+
570+
expect(rows).toHaveLength(4);
571+
const runtimeSelect = rows[1]?.components?.find(
572+
(component) => component.type === DISCORD_STRING_SELECT_COMPONENT_TYPE,
573+
);
574+
if (!runtimeSelect) {
575+
throw new Error("models view did not render a runtime select");
576+
}
577+
expect(runtimeSelect.options?.find((option) => option.value === "codex")?.default).toBe(true);
578+
expect(runtimeSelect.options?.find((option) => option.value === "pi")?.default).toBe(false);
579+
580+
const modelSelect = rows[2]?.components?.find(
581+
(component) => component.type === DISCORD_STRING_SELECT_COMPONENT_TYPE,
582+
);
583+
const parsedModelSelectState = parseDiscordModelPickerCustomId(modelSelect?.custom_id ?? "");
584+
expect(parsedModelSelectState?.runtime).toBeUndefined();
585+
586+
const navButtons = rows[3]?.components ?? [];
587+
const submitState = parseDiscordModelPickerCustomId(navButtons.at(-1)?.custom_id ?? "");
588+
expect(submitState?.action).toBe("submit");
589+
expect(submitState?.runtime).toBeUndefined();
590+
expect(submitState?.modelIndex).toBe(3);
591+
});
592+
593+
it("carries only explicit runtime picker state into model submit ids", () => {
594+
const data = createModelsProviderData({
595+
openai: ["gpt-4.1", "gpt-4o"],
596+
});
597+
data.runtimeChoicesByProvider = new Map([
598+
[
599+
"openai",
600+
[
601+
{
602+
id: "codex",
603+
label: "OpenAI Codex",
604+
description: "Use the OpenAI Codex runtime selected by the effective harness policy.",
605+
},
606+
{
607+
id: "pi",
608+
label: "OpenClaw Pi Default",
609+
description: "Use the built-in OpenClaw Pi runtime.",
610+
},
611+
],
612+
],
613+
]);
614+
615+
const rows = renderModelsViewRows({
616+
command: "models",
617+
userId: "42",
618+
data,
619+
provider: "openai",
620+
currentModel: "openai/gpt-4.1",
621+
pendingModel: "openai/gpt-4o",
622+
pendingModelIndex: 2,
623+
pendingRuntime: "pi",
624+
});
625+
626+
const modelSelect = rows[2]?.components?.find(
627+
(component) => component.type === DISCORD_STRING_SELECT_COMPONENT_TYPE,
628+
);
629+
expect(parseDiscordModelPickerCustomId(modelSelect?.custom_id ?? "")?.runtime).toBe("pi");
630+
const submitState = parseDiscordModelPickerCustomId(
631+
rows[3]?.components?.at(-1)?.custom_id ?? "",
632+
);
633+
expect(submitState?.runtime).toBe("pi");
634+
});
635+
535636
it("renders not-found model view with a back button", () => {
536637
const data = createModelsProviderData({ openai: ["gpt-4o"] });
537638

@@ -685,6 +786,38 @@ describe("Discord model picker recents view", () => {
685786
expect(backState.view).toBe("models");
686787
});
687788

789+
it("preserves explicit runtime state on recents submit and back buttons", () => {
790+
const data = createModelsProviderData({
791+
openai: ["gpt-4.1", "gpt-4o"],
792+
});
793+
794+
const rows = renderRecentsViewRows({
795+
command: "model",
796+
userId: "42",
797+
data,
798+
quickModels: ["openai/gpt-4o"],
799+
currentModel: "openai/gpt-4o",
800+
runtime: "codex",
801+
});
802+
803+
const defaultState = requireValue(
804+
parseDiscordModelPickerCustomId(rows[0]?.components?.[0]?.custom_id ?? ""),
805+
"default recents button custom id should parse",
806+
);
807+
const recentState = requireValue(
808+
parseDiscordModelPickerCustomId(rows[1]?.components?.[0]?.custom_id ?? ""),
809+
"recent model button custom id should parse",
810+
);
811+
const backState = requireValue(
812+
parseDiscordModelPickerCustomId(rows[2]?.components?.[0]?.custom_id ?? ""),
813+
"recents back button custom id should parse",
814+
);
815+
816+
expect(defaultState.runtime).toBe("codex");
817+
expect(recentState.runtime).toBe("codex");
818+
expect(backState.runtime).toBe("codex");
819+
});
820+
688821
it("includes (default) suffix on default model button label", () => {
689822
const data = createModelsProviderData({
690823
openai: ["gpt-4o"],

extensions/discord/src/monitor/model-picker.view.ts

Lines changed: 121 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import type { APISelectMenuOption } from "discord-api-types/v10";
22
import { ButtonStyle } from "discord-api-types/v10";
3-
import type { ModelsProviderData } from "openclaw/plugin-sdk/models-provider-runtime";
3+
import type {
4+
ModelsProviderData,
5+
ModelsRuntimeChoice,
6+
} from "openclaw/plugin-sdk/models-provider-runtime";
47
import { normalizeProviderId } from "openclaw/plugin-sdk/provider-model-shared";
58
import {
69
Button,
@@ -76,8 +79,10 @@ export type DiscordModelPickerModelViewParams = {
7679
page?: number;
7780
providerPage?: number;
7881
currentModel?: string;
82+
currentRuntime?: string;
7983
pendingModel?: string;
8084
pendingModelIndex?: number;
85+
pendingRuntime?: string;
8186
quickModels?: string[];
8287
layout?: DiscordModelPickerLayout;
8388
};
@@ -164,6 +169,59 @@ function createModelSelect(params: {
164169
return new DiscordModelPickerSelect();
165170
}
166171

172+
function getRuntimeChoices(params: {
173+
data: ModelsProviderData;
174+
provider: string;
175+
}): ModelsRuntimeChoice[] {
176+
const choices = params.data.runtimeChoicesByProvider?.get(normalizeProviderId(params.provider));
177+
if (choices?.length) {
178+
return choices;
179+
}
180+
return [
181+
{
182+
id: "pi",
183+
label: "OpenClaw Pi Default",
184+
description: "Use the built-in OpenClaw Pi runtime.",
185+
},
186+
];
187+
}
188+
189+
function resolveSelectedRuntime(params: {
190+
data: ModelsProviderData;
191+
provider: string;
192+
currentRuntime?: string;
193+
pendingRuntime?: string;
194+
}): string {
195+
const choices = getRuntimeChoices({ data: params.data, provider: params.provider });
196+
const allowed = new Set(choices.map((choice) => choice.id));
197+
const pending = params.pendingRuntime?.trim();
198+
if (pending && allowed.has(pending)) {
199+
return pending;
200+
}
201+
const current = params.currentRuntime?.trim();
202+
if (current && allowed.has(current)) {
203+
return current;
204+
}
205+
return choices[0]?.id ?? "pi";
206+
}
207+
208+
function resolveExplicitRuntimeState(params: {
209+
choices: ModelsRuntimeChoice[];
210+
currentRuntime?: string;
211+
pendingRuntime?: string;
212+
}): string | undefined {
213+
const allowed = new Set(params.choices.map((choice) => choice.id));
214+
const pending = params.pendingRuntime?.trim();
215+
if (pending && allowed.has(pending)) {
216+
return pending;
217+
}
218+
const current = params.currentRuntime?.trim();
219+
if (current && current !== "auto" && current !== "default" && allowed.has(current)) {
220+
return current;
221+
}
222+
return undefined;
223+
}
224+
167225
function buildRenderedShell(
168226
params: DiscordModelPickerRenderShellParams,
169227
): DiscordModelPickerRenderedView {
@@ -241,8 +299,10 @@ function buildModelRows(params: {
241299
providerPage: number;
242300
modelPage: DiscordModelPickerModelPage;
243301
currentModel?: string;
302+
currentRuntime?: string;
244303
pendingModel?: string;
245304
pendingModelIndex?: number;
305+
pendingRuntime?: string;
246306
quickModels?: string[];
247307
}): { rows: DiscordModelPickerRow[]; buttonRow: Row<Button> } {
248308
const parsedCurrentModel = parseCurrentModelRef(params.currentModel);
@@ -279,6 +339,49 @@ function buildModelRows(params: {
279339
]),
280340
);
281341

342+
const runtimeChoices = getRuntimeChoices({
343+
data: params.data,
344+
provider: params.modelPage.provider,
345+
});
346+
const selectedRuntime = resolveSelectedRuntime({
347+
data: params.data,
348+
provider: params.modelPage.provider,
349+
currentRuntime: params.currentRuntime,
350+
pendingRuntime: params.pendingRuntime,
351+
});
352+
const stateRuntime = resolveExplicitRuntimeState({
353+
choices: runtimeChoices,
354+
currentRuntime: params.currentRuntime,
355+
pendingRuntime: params.pendingRuntime,
356+
});
357+
358+
if (runtimeChoices.length > 1) {
359+
rows.push(
360+
new Row([
361+
createModelSelect({
362+
customId: buildDiscordModelPickerCustomId({
363+
command: params.command,
364+
action: "runtime",
365+
view: "models",
366+
provider: params.modelPage.provider,
367+
runtime: selectedRuntime,
368+
page: params.modelPage.page,
369+
providerPage: providerPage.page,
370+
modelIndex: params.pendingModelIndex,
371+
userId: params.userId,
372+
}),
373+
options: runtimeChoices.map((choice) => ({
374+
label: choice.label,
375+
value: choice.id,
376+
default: choice.id === selectedRuntime,
377+
...(choice.description ? { description: choice.description } : {}),
378+
})),
379+
placeholder: "Select runtime",
380+
}),
381+
]),
382+
);
383+
}
384+
282385
const selectedModelRef = parsedPendingModel ?? parsedCurrentModel;
283386
const modelOptions: APISelectMenuOption[] = params.modelPage.items.map((model) => ({
284387
label: model,
@@ -296,6 +399,7 @@ function buildModelRows(params: {
296399
action: "model",
297400
view: "models",
298401
provider: params.modelPage.provider,
402+
runtime: stateRuntime,
299403
page: params.modelPage.page,
300404
providerPage: providerPage.page,
301405
userId: params.userId,
@@ -327,6 +431,7 @@ function buildModelRows(params: {
327431
action: "cancel",
328432
view: "models",
329433
provider: params.modelPage.provider,
434+
runtime: stateRuntime,
330435
page: params.modelPage.page,
331436
providerPage: providerPage.page,
332437
userId: params.userId,
@@ -341,6 +446,7 @@ function buildModelRows(params: {
341446
action: "reset",
342447
view: "models",
343448
provider: params.modelPage.provider,
449+
runtime: stateRuntime,
344450
page: params.modelPage.page,
345451
providerPage: providerPage.page,
346452
userId: params.userId,
@@ -358,6 +464,7 @@ function buildModelRows(params: {
358464
action: "recents",
359465
view: "recents",
360466
provider: params.modelPage.provider,
467+
runtime: stateRuntime,
361468
page: params.modelPage.page,
362469
providerPage: providerPage.page,
363470
userId: params.userId,
@@ -376,6 +483,7 @@ function buildModelRows(params: {
376483
action: "submit",
377484
view: "models",
378485
provider: params.modelPage.provider,
486+
runtime: stateRuntime,
379487
page: params.modelPage.page,
380488
providerPage: providerPage.page,
381489
modelIndex: params.pendingModelIndex,
@@ -457,14 +565,21 @@ export function renderDiscordModelPickerModelsView(
457565
providerPage,
458566
modelPage,
459567
currentModel: params.currentModel,
568+
currentRuntime: params.currentRuntime,
460569
pendingModel: params.pendingModel,
461570
pendingModelIndex: params.pendingModelIndex,
571+
pendingRuntime: params.pendingRuntime,
462572
quickModels: params.quickModels,
463573
});
464574

465575
const defaultModel = `${params.data.resolvedDefault.provider}/${params.data.resolvedDefault.model}`;
466576
const pendingLine = params.pendingModel
467-
? `Selected: ${params.pendingModel} (press Submit)`
577+
? `Selected: ${params.pendingModel} · runtime ${resolveSelectedRuntime({
578+
data: params.data,
579+
provider: modelPage.provider,
580+
currentRuntime: params.currentRuntime,
581+
pendingRuntime: params.pendingRuntime,
582+
})} (press Submit)`
468583
: "Select a model, then press Submit.";
469584

470585
return buildRenderedShell({
@@ -483,6 +598,7 @@ export type DiscordModelPickerRecentsViewParams = {
483598
data: ModelsProviderData;
484599
quickModels: string[];
485600
currentModel?: string;
601+
runtime?: string;
486602
provider?: string;
487603
page?: number;
488604
providerPage?: number;
@@ -522,6 +638,7 @@ export function renderDiscordModelPickerRecentsView(
522638
view: "recents",
523639
recentSlot: 1,
524640
provider: params.provider,
641+
runtime: params.runtime,
525642
page: params.page,
526643
providerPage: params.providerPage,
527644
userId: params.userId,
@@ -544,6 +661,7 @@ export function renderDiscordModelPickerRecentsView(
544661
view: "recents",
545662
recentSlot: i + 2,
546663
provider: params.provider,
664+
runtime: params.runtime,
547665
page: params.page,
548666
providerPage: params.providerPage,
549667
userId: params.userId,
@@ -563,6 +681,7 @@ export function renderDiscordModelPickerRecentsView(
563681
action: "back",
564682
view: "models",
565683
provider: params.provider,
684+
runtime: params.runtime,
566685
page: params.page,
567686
providerPage: params.providerPage,
568687
userId: params.userId,

0 commit comments

Comments
 (0)