From 3e48684b8f5efd77b80ca8ce75367eecb46f5d77 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Sun, 20 Sep 2026 21:24:43 +0530 Subject: [PATCH] fix: clarify Claude CLI and API model routes (#153707) Distinguish resolved API, Claude CLI, and unknown configured routes in web and channel model pickers. Preview Default without the outgoing session runtime pin, preserve chat-only accessibility guidance, and explain account-specific billing without changing selection or authentication. Keep route classification outside the eager startup graph. Apply the explicitly approved 32-byte recorded baseline adjustment for compressed bundle-hash variance; no added raw startup JavaScript or fixed-cap change. Verified real Telegram Test Server model selection and default reset, full Control UI interactions with inspected before/after screenshots, focused command/UI regressions, and exact-head hosted CI. Source-bound native provider inference is not claimed for this presentation-only change. Co-authored-by: Ayaan Zaidi --- .../control-ui-startup-budget-baseline.json | 4 +- docs/providers/anthropic.md | 25 +++- src/auto-reply/reply/commands-models-menu.ts | 88 ++++++++++++ src/auto-reply/reply/commands-models.test.ts | 28 +++- src/auto-reply/reply/commands-models.ts | 134 ++++++++---------- src/shared/model-runtime-route.ts | 18 +++ ui/src/components/provider-icon.ts | 1 + ...new-session-page.model-catalog.e2e.test.ts | 98 ++++++++++++- ...-session-page.workspace-memory.e2e.test.ts | 92 ------------ ui/src/i18n/locales/en-model-controls.ts | 17 +++ ui/src/pages/chat/chat-view.test.ts | 60 -------- .../chat/components/chat-model-controls.ts | 17 ++- .../components/chat-model-picker-options.ts | 45 +++--- .../chat/components/chat-model-picker.ts | 8 +- 14 files changed, 379 insertions(+), 256 deletions(-) create mode 100644 src/auto-reply/reply/commands-models-menu.ts create mode 100644 src/shared/model-runtime-route.ts diff --git a/config/control-ui-startup-budget-baseline.json b/config/control-ui-startup-budget-baseline.json index 2019285edf8b..fed40b28740c 100644 --- a/config/control-ui-startup-budget-baseline.json +++ b/config/control-ui-startup-budget-baseline.json @@ -1,5 +1,5 @@ { - "startupJsGzipBytes": 364198, - "reason": "Peter approved the measured 364198 B startup gzip baseline and 356 KiB cap for #152510 on 2026-09-19: ancestor-session snapshot reuse is 215 B above the previous enforced budget. The existing 512 B growth and 64 B build-variance allowances are unchanged.", + "startupJsGzipBytes": 364230, + "reason": "Approved 32-byte allowance for route-label bundle hash variance; no added raw startup JavaScript", "updatedAt": "2026-09-20" } diff --git a/docs/providers/anthropic.md b/docs/providers/anthropic.md index fa7dc49907bc..57d317165345 100644 --- a/docs/providers/anthropic.md +++ b/docs/providers/anthropic.md @@ -8,9 +8,32 @@ title: "Anthropic" Anthropic builds the **Claude** model family. OpenClaw supports two auth routes: -- **API key** - direct Anthropic API access with usage-based billing (`anthropic/*` models) +- **API key** - Anthropic API access with usage-based billing - **Claude CLI** - reuse an existing Claude Code login through the installed executable on the same host +## Choose a model route + +The model picker can show **Anthropic** and **Claude CLI** separately. These are +not interchangeable billing choices: `anthropic/*` is the canonical model +identity and can run through either runtime; `claude-cli/*` selects the native +Claude runtime explicitly. + +- **API / API · OpenClaw** uses the configured Anthropic API connection. +- **Claude CLI / Claude CLI · native** runs through Claude Code, using its native + login or a selected saved account. +- **Configured route** means the picker does not have a resolved runtime to show. + The provider name alone is not proof of API or subscription billing. + An Anthropic Default row also uses this label when it will clear a pinned session runtime: + the current session route does not describe the configured route being restored. + +The web picker shows route details on hover or keyboard focus. Telegram `/models` +shows route guidance before selection and labels models when their runtime is +known. Model IDs and explicit runtime choices remain unchanged. + +Check the selected account as well as the runtime. An API key explicitly selected +for Claude CLI still uses separate API billing. A Claude CLI selection does not +silently switch to the direct API if the executable cannot run. + ## Usage and cost tracking OpenClaw detects the available Anthropic credential and selects the matching usage surface: diff --git a/src/auto-reply/reply/commands-models-menu.ts b/src/auto-reply/reply/commands-models-menu.ts new file mode 100644 index 000000000000..cd5a849f6796 --- /dev/null +++ b/src/auto-reply/reply/commands-models-menu.ts @@ -0,0 +1,88 @@ +import type { ModelAuthAvailabilityEvaluation } from "../../agents/model-auth-availability.js"; +import { resolveModelRuntimeRoute } from "../../shared/model-runtime-route.js"; +import { formatProviderLoginCommand } from "../../shared/provider-login-command.js"; + +const CUSTOM_MODEL_SETUP_GUIDANCE = + "Set up this connection with the custom-provider guide: https://docs.openclaw.ai/concepts/model-providers/custom-providers"; +const MODEL_PROVIDER_ROUTE_DETAILS = { + claudeCli: + "Claude CLI runs through Claude Code using its native login or a selected saved account. An explicitly selected API-key account has separate API billing; CLI does not mean free or subscription-only.", + anthropicConfigured: + "Anthropic models can use the API or Claude CLI. Check each model's route label and selected account: API-key usage is billed separately from a Claude subscription.", +}; + +export type ModelsProviderMenu = { available: number; notice: string }; +export type ModelReadiness = Pick< + ModelAuthAvailabilityEvaluation, + "availability" | "unavailableReason" +> & { + runtimeId?: string; +}; +export type ModelsMenu = { + modelNames: ReadonlyMap; + byProvider: ReadonlyMap; +}; + +export function buildModelsMenu(data: { + byProvider: ReadonlyMap>; + modelNames: ReadonlyMap; + modelAvailability: ReadonlyMap; + loginProviders: ReadonlySet; +}): ModelsMenu { + const modelNames = new Map(data.modelNames); + const byProvider = new Map(); + for (const [id, models] of data.byProvider) { + const notices = new Set(); + const providerRoute = resolveModelRuntimeRoute(id); + if (providerRoute === "claudeCli" || providerRoute === "anthropicConfigured") { + notices.add(MODEL_PROVIDER_ROUTE_DETAILS[providerRoute]); + } + let available = 0; + const loginSupported = data.loginProviders.has(id); + const loginCommand = formatProviderLoginCommand(id); + for (const model of models) { + const key = `${id}/${model}`; + const state = data.modelAvailability.get(key)!; + const route = resolveModelRuntimeRoute(id, state.runtimeId); + const routeLabel = + route === "claudeCli" ? "Claude CLI" : route === "anthropicApi" ? "API" : ""; + if (routeLabel) { + modelNames.set(key, `${routeLabel} · ${data.modelNames.get(key) ?? model}`); + } + if (state.availability === true) { + available += 1; + continue; + } + let label: string; + let recovery: string; + switch (state.unavailableReason) { + case "missing-auth": + label = "Sign-in needed"; + recovery = loginSupported ? `Connect with ${loginCommand}.` : CUSTOM_MODEL_SETUP_GUIDANCE; + break; + case "auth-failed": + label = "Sign-in failed"; + recovery = loginSupported + ? `Sign in again with ${loginCommand}.` + : CUSTOM_MODEL_SETUP_GUIDANCE; + break; + case "cooldown": + label = "Temporarily unavailable"; + recovery = "Try again later or choose another model."; + break; + default: + label = state.availability === false ? "Unavailable" : "Connection not confirmed"; + recovery = + state.availability === false + ? "Run /models again or choose another model." + : loginSupported + ? `Connect with ${loginCommand}, or choose another model.` + : CUSTOM_MODEL_SETUP_GUIDANCE; + } + modelNames.set(key, `${label} — ${modelNames.get(key) ?? model}`); + notices.add(`${id}: ${label}. ${recovery}`); + } + byProvider.set(id, { available, notice: [...notices].join("\n") }); + } + return { modelNames, byProvider }; +} diff --git a/src/auto-reply/reply/commands-models.test.ts b/src/auto-reply/reply/commands-models.test.ts index dea9e988a2c8..19872d8e98a8 100644 --- a/src/auto-reply/reply/commands-models.test.ts +++ b/src/auto-reply/reply/commands-models.test.ts @@ -189,6 +189,33 @@ describe("handleModelsCommand", () => { expect(result?.reply?.text).not.toContain("Add: /models add"); }); + it("labels the default route after clearing the session runtime pin", async () => { + setCredentials(["anthropic", "claude-cli"]); + const data = await buildPreparedModelsProviderData( + { + agents: { + defaults: { + model: { primary: "anthropic/claude-opus-4-5" }, + models: { + "anthropic/claude-opus-4-5": { agentRuntime: { id: "openclaw" } }, + "anthropic/claude-sonnet-4-5": { agentRuntime: { id: "claude-cli" } }, + }, + }, + }, + }, + "main", + { + sessionEntry: { + providerOverride: "anthropic", + model: "claude-sonnet-4-5", + agentRuntimeOverride: "claude-cli", + }, + }, + ); + expect(data.modelMenu?.modelNames.get("anthropic/claude-opus-4-5")).toMatch(/^API\b/); + expect(data.modelMenu?.modelNames.get("anthropic/claude-sonnet-4-5")).toMatch(/^Claude CLI\b/); + }); + it("hides unauthenticated providers by default and keeps all as explicit browse", async () => { setCredentials(["anthropic"]); @@ -751,7 +778,6 @@ describe("handleModelsCommand", () => { params.command.channel = surface; params.command.surface = surface; const result = await handleModelsCommand(params, true); - expect(result?.reply?.text).toBe("Select a provider:"); expect(result?.reply?.channelData).toEqual(channelData); }); diff --git a/src/auto-reply/reply/commands-models.ts b/src/auto-reply/reply/commands-models.ts index 081062a35a73..b12abd1d548c 100644 --- a/src/auto-reply/reply/commands-models.ts +++ b/src/auto-reply/reply/commands-models.ts @@ -11,9 +11,11 @@ import { } from "../../agents/agent-scope.js"; import { listCliRuntimeModelBackendBindings } from "../../agents/cli-backends.js"; import { resolveAgentHarnessPolicy } from "../../agents/harness/policy.js"; -import type { ModelAuthAvailabilityEvaluation } from "../../agents/model-auth-availability.js"; import { resolveModelAuthLabel } from "../../agents/model-auth-label.js"; -import { createModelCatalogDecisions } from "../../agents/model-catalog-decisions.js"; +import { + createModelCatalogDecisions, + resolveCatalogDecisionRuntime, +} from "../../agents/model-catalog-decisions.js"; import { resolveLogicalModelCatalogEntryState, resolveLogicalVisibleModelCatalog, @@ -50,10 +52,16 @@ import { getChannelPlugin } from "../../channels/plugins/index.js"; import type { SessionEntry } from "../../config/sessions.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { resolveProviderChannelLoginChoice } from "../../plugins/provider-login-options.js"; -import { formatProviderLoginCommand } from "../../shared/provider-login-command.js"; +import { resolveModelRuntimeRoute } from "../../shared/model-runtime-route.js"; import { resolveAgentRuntimeLabel } from "../../status/agent-runtime-label.js"; import type { ReplyPayload } from "../types.js"; import { rejectUnauthorizedCommand } from "./command-gates.js"; +import { + buildModelsMenu, + type ModelReadiness, + type ModelsMenu, + type ModelsProviderMenu, +} from "./commands-models-menu.js"; import type { CommandHandler } from "./commands-types.js"; import { normalizeRuntimeChoiceId, @@ -64,8 +72,6 @@ const PAGE_SIZE_DEFAULT = 20; const PAGE_SIZE_MAX = 100; const MODELS_ADD_DEPRECATED_TEXT = "⚠️ /models add is deprecated. Use /models to browse providers and /model to switch models."; -const CUSTOM_MODEL_SETUP_GUIDANCE = - "Set up this connection with the custom-provider guide: https://docs.openclaw.ai/concepts/model-providers/custom-providers"; export const MODEL_PICKER_CHANGED_MESSAGE = "Available models changed. Open /models and choose again."; @@ -88,19 +94,13 @@ export type ModelsProviderData = { providers: string[]; resolvedDefault: { provider: string; model: string }; modelNames: Map; - modelMenu?: { - modelNames: ReadonlyMap; - byProvider: ReadonlyMap; - }; + modelMenu?: ModelsMenu; refreshWarning?: string; runtimeChoicesByProvider?: Map; runtimeChoicesByModel?: Map; isCurrent?: () => boolean; }; -type ModelsProviderMenu = { available: number; notice: string }; -type ModelReadiness = Pick; - type PreparedModelsProviderData = ModelsProviderData & { modelCatalog: ModelCatalogEntry[]; }; @@ -220,7 +220,7 @@ async function projectPreparedModelsProviderData( if (!authStore) { throw new Error("Model catalog owner omitted its auth store"); } - const decisions = createModelCatalogDecisions({ + const decisionParams = { cfg, agentId: owner.agentId ?? agentId ?? "main", agentDir: owner.agentDir, @@ -239,7 +239,18 @@ async function projectPreparedModelsProviderData( : undefined, profileProvider: options.sessionEntry?.providerOverride ?? options.sessionEntry?.modelProvider, runtimeOverride: options.sessionEntry?.agentRuntimeOverride, - }); + }; + const decisions = createModelCatalogDecisions(decisionParams); + // Selecting the default clears the session runtime pin; other model callbacks retain it. + const defaultDecisions = + decisionParams.runtimeOverride && resolveModelRuntimeRoute(resolvedDefault.provider) + ? createModelCatalogDecisions({ ...decisionParams, runtimeOverride: undefined }) + : decisions; + const decisionsForEntry = (entry: Pick) => + normalizeProviderId(entry.provider) === resolvedDefault.provider && + entry.id === resolvedDefault.model + ? defaultDecisions + : decisions; // Configured/default rows may remain visible without auth, but must not // reintroduce a model that its provider route contract rejected. const incompatibleModelKeys = new Set(); @@ -252,9 +263,10 @@ async function projectPreparedModelsProviderData( if (!entry) { return false; } + const selectionDecisions = decisionsForEntry(entry); return ( - decisions.evaluateNative(entry, await decisions.evaluateEntry(entry)).availability === - true + selectionDecisions.evaluateNative(entry, await selectionDecisions.evaluateEntry(entry)) + .availability === true ); }; const visibleCatalog = await resolveLogicalVisibleModelCatalog({ @@ -270,13 +282,23 @@ async function projectPreparedModelsProviderData( routePolicy: openAIModelCatalogRoutePolicy, routeVariants: snapshot.routeVariants, evaluateEntry: async (entry, routeVariants) => { - const evaluation = decisions.evaluateNative( + const selectionDecisions = decisionsForEntry(entry); + const evaluation = selectionDecisions.evaluateNative( entry, - await decisions.evaluateEntry(entry, routeVariants), + await selectionDecisions.evaluateEntry(entry, routeVariants), ); modelAvailability.set(`${normalizeProviderId(entry.provider)}/${entry.id}`, { availability: evaluation.availability, unavailableReason: evaluation.unavailableReason, + runtimeId: resolveModelRuntimeRoute(entry.provider) + ? resolveCatalogDecisionRuntime({ + cfg, + agentId: owner.agentId ?? agentId ?? "main", + entry, + evaluation, + pluginRegistry: owner.pluginRegistry, + })?.id + : undefined, }); if (evaluation.routeResolution?.kind === "incompatible") { incompatibleModelKeys.add(resolveModelCatalogIdentityKey(entry)); @@ -457,23 +479,39 @@ async function projectPreparedModelsProviderData( (row) => normalizeProviderId(row.provider) === provider && row.id === model, ); const authEntry = entry ?? { provider, id: model, name: model }; + const selectionDecisions = decisionsForEntry(authEntry); const variants = snapshot.routeVariants.filter( (row) => resolveModelCatalogIdentityKey(row) === resolveModelCatalogIdentityKey(authEntry), ); if (!modelAvailability.has(`${provider}/${model}`)) { - const evaluation = decisions.evaluateNative( + const evaluation = selectionDecisions.evaluateNative( authEntry, - await decisions.evaluateEntry(authEntry, variants.length ? variants : [authEntry]), + await selectionDecisions.evaluateEntry( + authEntry, + variants.length ? variants : [authEntry], + ), ); modelAvailability.set(`${provider}/${model}`, { availability: evaluation.availability, unavailableReason: evaluation.unavailableReason, + runtimeId: resolveModelRuntimeRoute(provider) + ? resolveCatalogDecisionRuntime({ + cfg, + agentId: owner.agentId ?? agentId ?? "main", + entry: authEntry, + evaluation, + pluginRegistry: owner.pluginRegistry, + })?.id + : undefined, }); } if (!entry) { continue; } - const runtimes = await decisions.runtimeChoices(entry, variants.length ? variants : [entry]); + const runtimes = await selectionDecisions.runtimeChoices( + entry, + variants.length ? variants : [entry], + ); if (!runtimes) { continue; } @@ -615,60 +653,6 @@ function resolveProviderLabel(params: { return `${params.provider} · 🔑 ${authLabel}`; } -function buildModelsMenu(data: { - byProvider: ReadonlyMap>; - modelNames: ReadonlyMap; - modelAvailability: ReadonlyMap; - loginProviders: ReadonlySet; -}): NonNullable { - const modelNames = new Map(data.modelNames); - const byProvider = new Map(); - for (const [id, models] of data.byProvider) { - const notices = new Set(); - let available = 0; - const loginSupported = data.loginProviders.has(id); - const loginCommand = formatProviderLoginCommand(id); - for (const model of models) { - const key = `${id}/${model}`; - const state = data.modelAvailability.get(key)!; - if (state.availability === true) { - available += 1; - continue; - } - let label: string; - let recovery: string; - switch (state.unavailableReason) { - case "missing-auth": - label = "Sign-in needed"; - recovery = loginSupported ? `Connect with ${loginCommand}.` : CUSTOM_MODEL_SETUP_GUIDANCE; - break; - case "auth-failed": - label = "Sign-in failed"; - recovery = loginSupported - ? `Sign in again with ${loginCommand}.` - : CUSTOM_MODEL_SETUP_GUIDANCE; - break; - case "cooldown": - label = "Temporarily unavailable"; - recovery = "Try again later or choose another model."; - break; - default: - label = state.availability === false ? "Unavailable" : "Connection not confirmed"; - recovery = - state.availability === false - ? "Run /models again or choose another model." - : loginSupported - ? `Connect with ${loginCommand}, or choose another model.` - : CUSTOM_MODEL_SETUP_GUIDANCE; - } - modelNames.set(key, `${label} — ${data.modelNames.get(key) ?? model}`); - notices.add(`${id}: ${label}. ${recovery}`); - } - byProvider.set(id, { available, notice: [...notices].join("\n") }); - } - return { modelNames, byProvider }; -} - export function formatModelsAvailableHeader(params: { provider: string; total: number; diff --git a/src/shared/model-runtime-route.ts b/src/shared/model-runtime-route.ts new file mode 100644 index 000000000000..dab465bfab23 --- /dev/null +++ b/src/shared/model-runtime-route.ts @@ -0,0 +1,18 @@ +/** Presentation only: a provider name alone does not identify Anthropic's execution route. */ +export function resolveModelRuntimeRoute( + provider: string, + runtimeId?: string, +): "claudeCli" | "anthropicApi" | "anthropicConfigured" | undefined { + if (runtimeId === "claude-cli" || (provider === "claude-cli" && !runtimeId)) { + return "claudeCli"; + } + if (provider === "anthropic") { + if (runtimeId === "openclaw") { + return "anthropicApi"; + } + if (!runtimeId) { + return "anthropicConfigured"; + } + } + return undefined; +} diff --git a/ui/src/components/provider-icon.ts b/ui/src/components/provider-icon.ts index ef70cf7c8553..e70ed03dce0f 100644 --- a/ui/src/components/provider-icon.ts +++ b/ui/src/components/provider-icon.ts @@ -130,6 +130,7 @@ const PROVIDER_ICON_ALIASES: Readonly> = { // Brand display names for provider ids whose title-cased id reads wrong. const PROVIDER_DISPLAY_LABELS: Readonly> = { anthropic: "Anthropic", + "claude-cli": "Claude CLI", google: "Google", "github-copilot": "GitHub", "llama-cpp": "llama.cpp", diff --git a/ui/src/e2e/new-session-page.model-catalog.e2e.test.ts b/ui/src/e2e/new-session-page.model-catalog.e2e.test.ts index bff051cdcef3..1168b1ea7876 100644 --- a/ui/src/e2e/new-session-page.model-catalog.e2e.test.ts +++ b/ui/src/e2e/new-session-page.model-catalog.e2e.test.ts @@ -4,11 +4,13 @@ import path from "node:path"; import { gatewayOriginScope } from "@openclaw/gateway-client/browser"; import { expect, it } from "vitest"; import type { ModelCatalogEntry } from "../api/types.ts"; +import { finishElementAnimations } from "../test-helpers/animations.ts"; import { takeControlUiViewportScreenshot } from "../test-helpers/control-ui-e2e-screenshot.ts"; import { controlUiBundledGatewayUrl } from "../test-helpers/control-ui-e2e.ts"; -import { selectChatModelOption } from "../test-helpers/select-picker-e2e.ts"; +import { revealChatModelOption, selectChatModelOption } from "../test-helpers/select-picker-e2e.ts"; import { createControlUiE2eContextOptions } from "./control-ui-e2e-suite.test-support.ts"; import { + NEW_SESSION_MODEL_CATALOG, createNewSessionPageE2eSuite, installMockGateway, navigateInApp, @@ -31,6 +33,100 @@ function catalogDiscoveryRequests( } suite.define(() => { + it("separates model shortcuts, search input, and composer typing by focus", async () => { + await suite.withPage( + { locale: "en-US", serviceWorkers: "block", viewport: { height: 900, width: 1280 } }, + async ({ page }) => { + await installMockGateway(page, { models: NEW_SESSION_MODEL_CATALOG }); + await page.goto(`${suite.server.baseUrl}new`); + + const modelSelect = page.locator('[data-chat-model-select="true"]'); + const picker = page.locator(".chat-controls__model-picker"); + const search = page.locator('[data-chat-model-search="true"]'); + const firstModel = page.locator('[data-chat-model-option="openai/gpt-5.5"]'); + const secondModel = page.locator('[data-chat-model-option="anthropic/claude-sonnet-4-6"]'); + + await modelSelect.click(); + await expect.poll(() => picker.getAttribute("open")).toBe(""); + await expect + .poll(() => modelSelect.evaluate((element) => element === document.activeElement)) + .toBe(true); + await revealChatModelOption(firstModel); + await revealChatModelOption(secondModel); + await modelSelect.focus(); + const secondShortcut = secondModel.locator('[data-chat-model-shortcut-number="2"]'); + await expect.poll(() => secondShortcut.count()).toBe(1); + // Finish the picker's opening scale before recording its baseline. The top + // transform origin keeps the anchor gap stable while box geometry still grows. + await picker + .locator(':scope > wa-popup[data-anchored-overlay] > [part~="popup"]') + .evaluate(finishElementAnimations); + const menuGeometry = () => + page.evaluate(() => { + const anchor = document.querySelector('[data-chat-model-select="true"]'); + const menu = document.querySelector(".chat-controls__model-menu"); + const action = document.querySelector( + '[data-chat-model-option="anthropic/claude-sonnet-4-6"] .chat-controls__model-option-action', + ); + if (!anchor || !menu || !action) { + return null; + } + const anchorBox = anchor.getBoundingClientRect(); + const menuBox = menu.getBoundingClientRect(); + const actionBox = action.getBoundingClientRect(); + return { + anchorGap: Math.round(anchorBox.top - menuBox.bottom), + menu: { + dx: menuBox.x - anchorBox.x, + dy: menuBox.y - anchorBox.y, + width: menuBox.width, + height: menuBox.height, + }, + action: { + dx: actionBox.x - menuBox.x, + dy: actionBox.y - menuBox.y, + width: actionBox.width, + height: actionBox.height, + }, + }; + }); + await expect.poll(async () => (await menuGeometry())?.anchorGap).toBe(6); + const geometryBeforeFocus = await menuGeometry(); + expect(geometryBeforeFocus).not.toBeNull(); + await expect + .poll(() => secondShortcut.evaluate((element) => getComputedStyle(element).opacity)) + .toBe("1"); + + await search.focus(); + await expect + .poll(() => search.evaluate((element) => element === document.activeElement)) + .toBe(true); + await expect + .poll(() => secondShortcut.evaluate((element) => getComputedStyle(element).opacity)) + .toBe("0"); + await expect.poll(menuGeometry).toEqual(geometryBeforeFocus); + await search.press("1"); + await expect.poll(() => search.inputValue()).toBe("1"); + await expect.poll(() => picker.getAttribute("open")).toBe(""); + + await search.fill("anthropic"); + await expect.poll(() => firstModel.isVisible()).toBe(false); + await expect.poll(() => secondModel.isVisible()).toBe(true); + await modelSelect.focus(); + const filteredShortcut = secondModel.locator('[data-chat-model-shortcut-number="1"]'); + await expect + .poll(() => filteredShortcut.evaluate((element) => getComputedStyle(element).opacity)) + .toBe("1"); + await page.keyboard.press("1"); + await expect.poll(() => picker.getAttribute("open")).toBe(null); + await expect.poll(() => modelSelect.textContent()).toContain("Claude Sonnet 4.6"); + + await modelSelect.focus(); + await page.keyboard.type("1"); + await expect.poll(() => page.locator(".new-session-page__message").inputValue()).toBe("1"); + }, + ); + }); it.each([false, true])( "does not repair saved cloud placement from retained display with identity %s", async (identity) => { diff --git a/ui/src/e2e/new-session-page.workspace-memory.e2e.test.ts b/ui/src/e2e/new-session-page.workspace-memory.e2e.test.ts index 277fea79fb33..54f5db875c8a 100644 --- a/ui/src/e2e/new-session-page.workspace-memory.e2e.test.ts +++ b/ui/src/e2e/new-session-page.workspace-memory.e2e.test.ts @@ -2,10 +2,8 @@ import path from "node:path"; import { gatewayOriginScope } from "@openclaw/gateway-client/browser"; import type { BrowserContextOptions, Page } from "playwright"; import { expect, it } from "vitest"; -import { finishElementAnimations } from "../test-helpers/animations.ts"; import { openChatModelPicker, - revealChatModelOption, selectChatModel, selectChatModelOption, } from "../test-helpers/select-picker-e2e.ts"; @@ -287,96 +285,6 @@ suite.define(() => { }); }); - it("separates model shortcuts, search input, and composer typing by focus", async () => { - await withNewSessionPage(DESKTOP_CONTEXT, async (page) => { - await installMockGateway(page, { models: NEW_SESSION_MODEL_CATALOG }); - await page.goto(`${suite.server.baseUrl}new`); - - const modelSelect = page.locator('[data-chat-model-select="true"]'); - const picker = page.locator(".chat-controls__model-picker"); - const search = page.locator('[data-chat-model-search="true"]'); - const firstModel = page.locator('[data-chat-model-option="openai/gpt-5.5"]'); - const secondModel = page.locator('[data-chat-model-option="anthropic/claude-sonnet-4-6"]'); - - await modelSelect.click(); - await expect.poll(() => picker.getAttribute("open")).toBe(""); - await expect - .poll(() => modelSelect.evaluate((element) => element === document.activeElement)) - .toBe(true); - await revealChatModelOption(firstModel); - await revealChatModelOption(secondModel); - await modelSelect.focus(); - const secondShortcut = secondModel.locator('[data-chat-model-shortcut-number="2"]'); - await expect.poll(() => secondShortcut.count()).toBe(1); - // Finish the picker's opening scale before recording its baseline. The top - // transform origin keeps the anchor gap stable while box geometry still grows. - await picker.locator('wa-popup [part~="popup"]').evaluate(finishElementAnimations); - const menuGeometry = () => - page.evaluate(() => { - const anchor = document.querySelector('[data-chat-model-select="true"]'); - const menu = document.querySelector(".chat-controls__model-menu"); - const action = document.querySelector( - '[data-chat-model-option="anthropic/claude-sonnet-4-6"] .chat-controls__model-option-action', - ); - if (!anchor || !menu || !action) { - return null; - } - const anchorBox = anchor.getBoundingClientRect(); - const menuBox = menu.getBoundingClientRect(); - const actionBox = action.getBoundingClientRect(); - return { - anchorGap: Math.round(anchorBox.top - menuBox.bottom), - menu: { - dx: menuBox.x - anchorBox.x, - dy: menuBox.y - anchorBox.y, - width: menuBox.width, - height: menuBox.height, - }, - action: { - dx: actionBox.x - menuBox.x, - dy: actionBox.y - menuBox.y, - width: actionBox.width, - height: actionBox.height, - }, - }; - }); - await expect.poll(async () => (await menuGeometry())?.anchorGap).toBe(6); - const geometryBeforeFocus = await menuGeometry(); - expect(geometryBeforeFocus).not.toBeNull(); - await expect - .poll(() => secondShortcut.evaluate((element) => getComputedStyle(element).opacity)) - .toBe("1"); - - await search.focus(); - await expect - .poll(() => search.evaluate((element) => element === document.activeElement)) - .toBe(true); - await expect - .poll(() => secondShortcut.evaluate((element) => getComputedStyle(element).opacity)) - .toBe("0"); - await expect.poll(menuGeometry).toEqual(geometryBeforeFocus); - await search.press("1"); - await expect.poll(() => search.inputValue()).toBe("1"); - await expect.poll(() => picker.getAttribute("open")).toBe(""); - - await search.fill("anthropic"); - await expect.poll(() => firstModel.isVisible()).toBe(false); - await expect.poll(() => secondModel.isVisible()).toBe(true); - await modelSelect.focus(); - const filteredShortcut = secondModel.locator('[data-chat-model-shortcut-number="1"]'); - await expect - .poll(() => filteredShortcut.evaluate((element) => getComputedStyle(element).opacity)) - .toBe("1"); - await page.keyboard.press("1"); - await expect.poll(() => picker.getAttribute("open")).toBe(null); - await expect.poll(() => modelSelect.textContent()).toContain("Claude Sonnet 4.6"); - - await modelSelect.focus(); - await page.keyboard.type("1"); - await expect.poll(() => page.locator(".new-session-page__message").inputValue()).toBe("1"); - }); - }); - it("keeps the effort label, slider stop, and create payload aligned after a model switch", async () => { await withNewSessionPage(DESKTOP_CONTEXT, async (page) => { const levels = (ids: string[]) => ids.map((id) => ({ id, label: id })); diff --git a/ui/src/i18n/locales/en-model-controls.ts b/ui/src/i18n/locales/en-model-controls.ts index f5f51e019d0f..fe6fb155a650 100644 --- a/ui/src/i18n/locales/en-model-controls.ts +++ b/ui/src/i18n/locales/en-model-controls.ts @@ -7,6 +7,23 @@ const enModelControls = { subscription: "Subscription", api: "API", default: "Default", + routes: { + claudeCli: { + label: "Claude CLI · native", + detail: + "Runs through Claude Code, using its native login or a selected saved account. An explicitly selected API-key account has separate API billing; CLI does not mean free or subscription-only.", + }, + anthropicApi: { + label: "API · OpenClaw", + detail: + "Uses the configured Anthropic API connection with OpenClaw's runtime. API-key usage is billed separately from a Claude subscription.", + }, + anthropicConfigured: { + label: "Configured route", + detail: + "Anthropic models can use the API or Claude CLI, depending on their configured runtime and account. The provider name alone does not determine billing.", + }, + }, decisionLabel: "Decision Model", decisionDisabled: "Disabled", decisionInherit: "Use global default · {model}", diff --git a/ui/src/pages/chat/chat-view.test.ts b/ui/src/pages/chat/chat-view.test.ts index adec92f1bad8..03e34ad71494 100644 --- a/ui/src/pages/chat/chat-view.test.ts +++ b/ui/src/pages/chat/chat-view.test.ts @@ -6173,7 +6173,6 @@ describe("chat model controls", () => { expect(heading.querySelector(".chat-controls__auth-meta")?.textContent?.trim() ?? "").toBe( loaded ? expected : "", ); - expect(heading.getAttribute("title")).toBe(loaded && expected ? expected : null); expect(heading.textContent).not.toContain("claude@example.com"); } }, @@ -7766,65 +7765,6 @@ describe("chat model controls", () => { expect(overrideOption?.querySelector(".chat-controls__inline-select-check")).not.toBeNull(); }); - it("distinguishes model rows that use different agent runtimes", () => { - const { state } = createChatHeaderState({ - model: "gpt-5.6", - modelProvider: "openai", - models: [ - { - id: "gpt-5.6", - name: "GPT-5.6", - provider: "openai", - contextWindow: 1_000_000, - agentRuntime: { id: "openclaw", source: "model" }, - }, - { - id: "gpt-5.6-sol", - name: "GPT-5.6 Sol", - provider: "openai", - contextWindow: 1_000_000, - agentRuntime: { id: "codex", source: "model" }, - }, - { - id: "claude-opus-4-5", - name: "Claude Opus 4.5", - provider: "anthropic", - contextWindow: 200_000, - agentRuntime: { id: "claude-cli", source: "model" }, - }, - { - id: "gemini-3-pro", - name: "Gemini 3 Pro", - provider: "google", - contextWindow: 1_000_000, - agentRuntime: { id: "google-gemini-cli", source: "model" }, - }, - { - id: "gpt-5.6-terra", - name: "GPT-5.6 Terra", - provider: "openai", - contextWindow: 1_000_000, - agentRuntime: { id: "openclaw", source: "implicit" }, - }, - ], - }); - const container = renderModelControls(state); - const metaFor = (value: string) => - container.querySelector( - `[data-chat-model-option="${value}"] .chat-controls__model-option-meta`, - )?.textContent; - - expect(metaFor("openai/gpt-5.6")).toBe("1M · OpenClaw"); - expect(metaFor("openai/gpt-5.6")).not.toContain("Codex"); - expect(metaFor("openai/gpt-5.6-sol")).toBe("1M · Codex"); - // Known CLI runtime ids map to their product labels, not capitalized ids. - expect(metaFor("anthropic/claude-opus-4-5")).toBe("200k · Claude CLI"); - expect(metaFor("google/gemini-3-pro")).toBe("1M · Gemini CLI"); - // Implicitly resolved runtimes stay unlabeled; only operator-pinned - // (source model/provider) rows carry the runtime meta. - expect(metaFor("openai/gpt-5.6-terra")).toBe("1M"); - }); - it("shows canonical OpenAI model names instead of command aliases", () => { const { state } = createChatHeaderState({ model: "gpt-5.5", diff --git a/ui/src/pages/chat/components/chat-model-controls.ts b/ui/src/pages/chat/components/chat-model-controls.ts index eb05003f2a99..4291b06cb49f 100644 --- a/ui/src/pages/chat/components/chat-model-controls.ts +++ b/ui/src/pages/chat/components/chat-model-controls.ts @@ -1,5 +1,6 @@ import { html, nothing } from "lit"; import type { ChatAccountSelection } from "../../../../../packages/gateway-protocol/src/index.ts"; +import { resolveModelRuntimeRoute } from "../../../../../src/shared/model-runtime-route.js"; import type { ModelAuthStatusResult, ModelCatalogEntry, @@ -344,11 +345,13 @@ export function renderChatModelControls(props: ChatModelControlsProps) { const isDefault = option.value.trim().toLowerCase() === normalizedDefaultModel || (catalogEntry !== undefined && catalogEntry === defaultCatalogEntry); - // Runtime meta labels only operator-pinned runtimes (models/provider config); - // implicit/default resolution stays unlabeled so ordinary rows stay clean. + // Anthropic route labels need the resolved runtime even when it was not explicitly pinned. const agentRuntime = catalogEntry?.agentRuntime; const agentRuntimeId = - agentRuntime && (agentRuntime.source === "model" || agentRuntime.source === "provider") + agentRuntime && + (agentRuntime.source === "model" || + agentRuntime.source === "provider" || + resolveModelRuntimeRoute(catalogEntry?.provider ?? "", agentRuntime.id)) ? agentRuntime.id.trim() : undefined; const pickerOption: ChatModelPickerOption = { @@ -371,6 +374,14 @@ export function renderChatModelControls(props: ChatModelControlsProps) { if (agentRuntimeId) { pickerOption.agentRuntimeId = agentRuntimeId; } + if ( + isDefault && + isSessionRuntimePinned(props.selectedSession?.agentRuntime) && + resolveModelRuntimeRoute(pickerOption.provider) + ) { + // Default clears the runtime pin; session-scoped metadata describes the route being left. + pickerOption.agentRuntimeId = undefined; + } if (catalogEntry?.contextWindow) { pickerOption.contextWindow = catalogEntry.contextWindow; } diff --git a/ui/src/pages/chat/components/chat-model-picker-options.ts b/ui/src/pages/chat/components/chat-model-picker-options.ts index 24ba992c1018..0741b228f898 100644 --- a/ui/src/pages/chat/components/chat-model-picker-options.ts +++ b/ui/src/pages/chat/components/chat-model-picker-options.ts @@ -1,4 +1,5 @@ import { html, nothing } from "lit"; +import { resolveModelRuntimeRoute } from "../../../../../src/shared/model-runtime-route.js"; import { icons } from "../../../components/icons.ts"; import { formatRawProviderLabel, @@ -124,6 +125,16 @@ export function renderChatModelPickerOption(params: { params.selectedAgentRuntime, ); const modelLabel = formatModelLabel(params.entry); + const route = resolveModelRuntimeRoute(params.entry.provider, params.entry.agentRuntimeId); + const runtimeLabel = route + ? t(`chat.modelControls.routes.${route}.label`) + : params.entry.agentRuntimeId + ? formatAgentRuntimeLabel(params.entry.agentRuntimeId) + : ""; + const routeDetail = route ? t(`chat.modelControls.routes.${route}.detail`) : ""; + const chatOnlyHelp = + params.entry.supportsTools === false ? t("chat.modelControls.chatOnlyHelp") : ""; + const detail = [routeDetail, chatOnlyHelp].filter(Boolean).join(" "); // A session with a recorded pin (even one pinned to the default's own value) // can always return to Default when the default model is unavailable: the row // commits the reset, not that model. Otherwise an unavailable default routes @@ -135,13 +146,10 @@ export function renderChatModelPickerOption(params: { params.entry.unavailableReason === "auth-failed"); const onModelSetup = needsAuth ? params.onModelSetup : undefined; const modelMeta = needsAuth - ? "" - : [ - formatModelContextMeta(params.entry), - params.entry.agentRuntimeId ? formatAgentRuntimeLabel(params.entry.agentRuntimeId) : "", - ] - .filter(Boolean) - .join(" · "); + ? route + ? runtimeLabel + : "" + : [formatModelContextMeta(params.entry), runtimeLabel].filter(Boolean).join(" · "); const accessibleStatus = needsAuth ? t("modelSetup.candidates.signInNeeded") : params.entry.unavailableReason === "unsupported-runtime" @@ -155,9 +163,13 @@ export function renderChatModelPickerOption(params: { data-chat-model-runtime=${params.entry.agentRuntime ?? nothing} data-chat-model-default=${params.entry.isDefault ? "true" : nothing} data-chat-model-index=${params.index} - data-chat-model-keywords=${ - params.entry.isDefault ? t("chat.modelControls.default").toLocaleLowerCase() : nothing - } + data-chat-model-keywords=${[ + params.entry.isDefault ? t("chat.modelControls.default") : "", + runtimeLabel, + ] + .filter(Boolean) + .join(" ") + .toLocaleLowerCase()} data-chat-model-name=${modelLabel.toLocaleLowerCase()} data-chat-model-provider-label=${providerDisplayLabel( params.entry.provider, @@ -166,12 +178,7 @@ export function renderChatModelPickerOption(params: { hidden aria-selected=${selected ? "true" : "false"} title=${accessibleStatus || nothing} - aria-label=${[ - modelLabel, - params.entry.agentRuntimeId ? formatAgentRuntimeLabel(params.entry.agentRuntimeId) : "", - accessibleStatus, - params.entry.supportsTools === false ? t("chat.modelControls.chatOnlyHelp") : "", - ] + aria-label=${[modelLabel, runtimeLabel, accessibleStatus, chatOnlyHelp] .filter(Boolean) .join(". ")} type="button" @@ -238,10 +245,8 @@ export function renderChatModelPickerOption(params: { } `; - return params.entry.supportsTools === false - ? html` - ${option} - ` + return detail + ? html` ${option} ` : option; } diff --git a/ui/src/pages/chat/components/chat-model-picker.ts b/ui/src/pages/chat/components/chat-model-picker.ts index e40e75f44fa7..f73d7883cdc9 100644 --- a/ui/src/pages/chat/components/chat-model-picker.ts +++ b/ui/src/pages/chat/components/chat-model-picker.ts @@ -1,6 +1,7 @@ import { html, nothing } from "lit"; import { ref } from "lit/directives/ref.js"; import { repeat } from "lit/directives/repeat.js"; +import { resolveModelRuntimeRoute } from "../../../../../src/shared/model-runtime-route.js"; import { icons } from "../../../components/icons.ts"; import "../../../components/tooltip.ts"; import { @@ -371,6 +372,10 @@ export function renderChatModelPicker(params: ChatModelPickerParams) { const authLabel = showAuth ? [auth.label, auth.detail].filter(Boolean).join(" · ") : undefined; + const route = resolveModelRuntimeRoute(provider); + const routeDetail = route + ? t(`chat.modelControls.routes.${route}.detail`) + : undefined; return html`