diff --git a/extensions/discord/src/monitor/model-picker.test-utils.ts b/extensions/discord/src/monitor/model-picker.test-utils.ts index 60b1c41e8ba..f0e60eb112e 100644 --- a/extensions/discord/src/monitor/model-picker.test-utils.ts +++ b/extensions/discord/src/monitor/model-picker.test-utils.ts @@ -21,5 +21,6 @@ export function createModelsProviderData( provider: defaultProvider, model: entries[defaultProvider]?.[0] ?? "gpt-4o", }, + modelNames: new Map(), }; } diff --git a/extensions/mattermost/src/mattermost/model-picker.test.ts b/extensions/mattermost/src/mattermost/model-picker.test.ts index b43fac9cc87..753c9218aeb 100644 --- a/extensions/mattermost/src/mattermost/model-picker.test.ts +++ b/extensions/mattermost/src/mattermost/model-picker.test.ts @@ -23,6 +23,7 @@ const data = { provider: "anthropic", model: "claude-opus-4-5", }, + modelNames: new Map(), }; describe("Mattermost model picker", () => { @@ -154,6 +155,7 @@ describe("Mattermost model picker", () => { provider: "openai", model: "gpt-5", }, + modelNames: new Map(), }; expect( diff --git a/extensions/mattermost/src/mattermost/slash-http.send-config.test.ts b/extensions/mattermost/src/mattermost/slash-http.send-config.test.ts index fb1fe618d23..003380c5e62 100644 --- a/extensions/mattermost/src/mattermost/slash-http.send-config.test.ts +++ b/extensions/mattermost/src/mattermost/slash-http.send-config.test.ts @@ -16,7 +16,7 @@ const mockState = vi.hoisted(() => ({ team_id: "team-1", })), resolveCommandText: vi.fn((_trigger: string, text: string) => text), - buildModelsProviderData: vi.fn(async () => ({ providers: [] })), + buildModelsProviderData: vi.fn(async () => ({ providers: [], modelNames: new Map() })), resolveMattermostModelPickerEntry: vi.fn(() => ({ kind: "summary" })), authorizeMattermostCommandInvocation: vi.fn(() => ({ ok: true, diff --git a/extensions/telegram/src/bot-handlers.runtime.ts b/extensions/telegram/src/bot-handlers.runtime.ts index e50159ffa46..9f80786c692 100644 --- a/extensions/telegram/src/bot-handlers.runtime.ts +++ b/extensions/telegram/src/bot-handlers.runtime.ts @@ -1366,7 +1366,7 @@ export const registerTelegramHandlers = ({ runtimeCfg, sessionState.agentId, ); - const { byProvider, providers } = modelData; + const { byProvider, providers, modelNames } = modelData; const editMessageWithButtons = async ( text: string, @@ -1441,6 +1441,7 @@ export const registerTelegramHandlers = ({ currentPage: safePage, totalPages, pageSize, + modelNames, }); const text = formatModelsAvailableHeader({ provider, diff --git a/extensions/telegram/src/bot-message-dispatch.test.ts b/extensions/telegram/src/bot-message-dispatch.test.ts index 854e8c113a1..229b00a17d3 100644 --- a/extensions/telegram/src/bot-message-dispatch.test.ts +++ b/extensions/telegram/src/bot-message-dispatch.test.ts @@ -32,6 +32,7 @@ const buildModelsProviderData = vi.hoisted(() => byProvider: new Map>(), providers: [], resolvedDefault: { provider: "openai", model: "gpt-test" }, + modelNames: new Map(), })), ); const listSkillCommandsForAgents = vi.hoisted(() => vi.fn(() => [])); diff --git a/extensions/telegram/src/bot-native-commands.menu-test-support.ts b/extensions/telegram/src/bot-native-commands.menu-test-support.ts index 1f01cf6ed18..be7f683bd52 100644 --- a/extensions/telegram/src/bot-native-commands.menu-test-support.ts +++ b/extensions/telegram/src/bot-native-commands.menu-test-support.ts @@ -112,6 +112,7 @@ export function createNativeCommandTestParams( byProvider: new Map>(), providers: [], resolvedDefault: { provider: "openai", model: "gpt-4.1" }, + modelNames: new Map(), })) as TelegramBotDeps["buildModelsProviderData"], listSkillCommandsForAgents, wasSentByBot: vi.fn(() => false) as TelegramBotDeps["wasSentByBot"], diff --git a/extensions/telegram/src/bot-native-commands.session-meta.test.ts b/extensions/telegram/src/bot-native-commands.session-meta.test.ts index e79bced88cf..7b0da16429c 100644 --- a/extensions/telegram/src/bot-native-commands.session-meta.test.ts +++ b/extensions/telegram/src/bot-native-commands.session-meta.test.ts @@ -209,6 +209,7 @@ function registerAndResolveCommandHandlerBase(params: { byProvider: new Map>(), providers: [], resolvedDefault: { provider: "openai", model: "gpt-4.1" }, + modelNames: new Map(), })), listSkillCommandsForAgents: vi.fn(() => []), wasSentByBot: vi.fn(() => false), diff --git a/extensions/telegram/src/bot-native-commands.test-helpers.ts b/extensions/telegram/src/bot-native-commands.test-helpers.ts index 625ad8a9263..33bd1f7b8f7 100644 --- a/extensions/telegram/src/bot-native-commands.test-helpers.ts +++ b/extensions/telegram/src/bot-native-commands.test-helpers.ts @@ -126,6 +126,7 @@ export function createNativeCommandsHarness(params?: { byProvider: new Map>(), providers: [], resolvedDefault: { provider: "openai", model: "gpt-4.1" }, + modelNames: new Map(), })), listSkillCommandsForAgents: vi.fn(() => []), wasSentByBot: vi.fn(() => false), diff --git a/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts b/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts index cd468a74ed5..357135a02c0 100644 --- a/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts +++ b/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts @@ -205,6 +205,7 @@ function createModelsProviderDataFromConfig(cfg: OpenClawConfig): { byProvider: Map>; providers: string[]; resolvedDefault: { provider: string; model: string }; + modelNames: Map; } { const byProvider = new Map>(); const add = (providerRaw: string | undefined, modelRaw: string | undefined) => { @@ -227,7 +228,7 @@ function createModelsProviderDataFromConfig(cfg: OpenClawConfig): { } const providers = [...byProvider.keys()].toSorted(); - return { byProvider, providers, resolvedDefault }; + return { byProvider, providers, resolvedDefault, modelNames: new Map() }; } vi.doMock("openclaw/plugin-sdk/command-auth", async (importOriginal) => { diff --git a/extensions/telegram/src/bot.media.e2e-harness.ts b/extensions/telegram/src/bot.media.e2e-harness.ts index f51845ea062..874c4aa7d6c 100644 --- a/extensions/telegram/src/bot.media.e2e-harness.ts +++ b/extensions/telegram/src/bot.media.e2e-harness.ts @@ -162,6 +162,7 @@ export const telegramBotDepsForTest: TelegramBotDeps = { byProvider: new Map>(), providers: [], resolvedDefault: { provider: "openai", model: "gpt-4.1" }, + modelNames: new Map(), })) as TelegramBotDeps["buildModelsProviderData"], listSkillCommandsForAgents: vi.fn(() => []) as TelegramBotDeps["listSkillCommandsForAgents"], wasSentByBot: vi.fn(() => false) as TelegramBotDeps["wasSentByBot"], diff --git a/extensions/telegram/src/model-buttons.test.ts b/extensions/telegram/src/model-buttons.test.ts index 3a6b5832f49..5dd05b6701f 100644 --- a/extensions/telegram/src/model-buttons.test.ts +++ b/extensions/telegram/src/model-buttons.test.ts @@ -223,6 +223,66 @@ describe("buildModelsKeyboard", () => { } }); + it("uses modelNames for display text when provided", () => { + const modelNames = new Map([ + ["nexos/a1b2c3d4-e5f6-7890-abcd-ef1234567890", "Claude Sonnet 4"], + ["nexos/claude-opus-4", "Claude Opus 4"], + ]); + const result = buildModelsKeyboard({ + provider: "nexos", + models: ["a1b2c3d4-e5f6-7890-abcd-ef1234567890", "claude-opus-4"], + currentPage: 1, + totalPages: 1, + modelNames, + }); + // 2 model rows + back button + expect(result).toHaveLength(3); + expect(result[0]?.[0]?.text).toBe("Claude Sonnet 4"); + expect(result[1]?.[0]?.text).toBe("Claude Opus 4"); + // callback_data still uses the raw model ID, not the display name + expect(result[0]?.[0]?.callback_data).toBe( + "mdl_sel_nexos/a1b2c3d4-e5f6-7890-abcd-ef1234567890", + ); + }); + + it("falls back to model ID when modelNames does not contain an entry", () => { + const modelNames = new Map([["anthropic/known-id", "Known Model"]]); + const result = buildModelsKeyboard({ + provider: "anthropic", + models: ["known-id", "unknown-id"], + currentPage: 1, + totalPages: 1, + modelNames, + }); + expect(result[0]?.[0]?.text).toBe("Known Model"); + expect(result[1]?.[0]?.text).toBe("unknown-id"); + }); + + it("uses provider-scoped modelNames keys to avoid cross-provider collisions", () => { + const modelNames = new Map([ + ["openai/shared-id", "OpenAI Shared"], + ["anthropic/shared-id", "Anthropic Shared"], + ]); + + const openaiResult = buildModelsKeyboard({ + provider: "openai", + models: ["shared-id"], + currentPage: 1, + totalPages: 1, + modelNames, + }); + const anthropicResult = buildModelsKeyboard({ + provider: "anthropic", + models: ["shared-id"], + currentPage: 1, + totalPages: 1, + modelNames, + }); + + expect(openaiResult[0]?.[0]?.text).toBe("OpenAI Shared"); + expect(anthropicResult[0]?.[0]?.text).toBe("Anthropic Shared"); + }); + it("renders pagination controls for first, middle, and last pages", () => { const cases = [ { diff --git a/extensions/telegram/src/model-buttons.ts b/extensions/telegram/src/model-buttons.ts index f6a16457d6c..684ab0c5eec 100644 --- a/extensions/telegram/src/model-buttons.ts +++ b/extensions/telegram/src/model-buttons.ts @@ -33,6 +33,9 @@ export type ModelsKeyboardParams = { currentPage: number; totalPages: number; pageSize?: number; + /** Optional map from provider/model to display name. When provided, the + * display name is shown on the button instead of the raw model ID. */ + modelNames?: ReadonlyMap; }; const MODELS_PAGE_SIZE = 8; @@ -180,7 +183,7 @@ export function buildProviderKeyboard(providers: ProviderInfo[]): ButtonRow[] { * Build model list keyboard with pagination and back button. */ export function buildModelsKeyboard(params: ModelsKeyboardParams): ButtonRow[] { - const { provider, models, currentModel, currentPage, totalPages } = params; + const { provider, models, currentModel, currentPage, totalPages, modelNames } = params; const pageSize = params.pageSize ?? MODELS_PAGE_SIZE; if (models.length === 0) { @@ -207,7 +210,8 @@ export function buildModelsKeyboard(params: ModelsKeyboardParams): ButtonRow[] { } const isCurrentModel = model === currentModelId; - const displayText = truncateModelId(model, 38); + const displayLabel = modelNames?.get(`${provider}/${model}`) ?? model; + const displayText = truncateModelId(displayLabel, 38); const text = isCurrentModel ? `${displayText} ✓` : displayText; rows.push([ diff --git a/src/auto-reply/reply/commands-models.ts b/src/auto-reply/reply/commands-models.ts index e1b1547f837..31a10305924 100644 --- a/src/auto-reply/reply/commands-models.ts +++ b/src/auto-reply/reply/commands-models.ts @@ -28,6 +28,8 @@ export type ModelsProviderData = { byProvider: Map>; providers: string[]; resolvedDefault: { provider: string; model: string }; + /** Map from provider/model to human-readable display name (when different from model ID). */ + modelNames: Map; }; /** @@ -119,7 +121,16 @@ export async function buildModelsProviderData( const providers = [...byProvider.keys()].toSorted(); - return { byProvider, providers, resolvedDefault }; + // Build a provider-scoped model display-name map so surfaces can show + // human-readable names without colliding across providers that share IDs. + const modelNames = new Map(); + for (const entry of catalog) { + if (entry.name && entry.name !== entry.id) { + modelNames.set(`${normalizeProviderId(entry.provider)}/${entry.id}`, entry.name); + } + } + + return { byProvider, providers, resolvedDefault, modelNames }; } function formatProviderLine(params: { provider: string; count: number }): string { @@ -234,7 +245,10 @@ export async function resolveModelsCommandReply(params: { const argText = body.replace(/^\/models\b/i, "").trim(); const { provider, page, pageSize, all } = parseModelsArgs(argText); - const { byProvider, providers } = await buildModelsProviderData(params.cfg, params.agentId); + const { byProvider, providers, modelNames } = await buildModelsProviderData( + params.cfg, + params.agentId, + ); const isTelegram = params.surface === "telegram"; // Provider list (no provider specified) @@ -310,6 +324,7 @@ export async function resolveModelsCommandReply(params: { currentPage: safePage, totalPages, pageSize: telegramPageSize, + modelNames, }); const text = formatModelsAvailableHeader({