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 <hi@obviy.us>
This commit is contained in:
Ayaan Zaidi 2026-09-20 21:24:43 +05:30 • committed by GitHub
parent 2a932d2338
commit 3e48684b8f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 379 additions and 256 deletions

View file

@ -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"
}

View file

@ -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:

View file

@ -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<string, string>;
byProvider: ReadonlyMap<string, ModelsProviderMenu>;
};
export function buildModelsMenu(data: {
byProvider: ReadonlyMap<string, ReadonlySet<string>>;
modelNames: ReadonlyMap<string, string>;
modelAvailability: ReadonlyMap<string, ModelReadiness>;
loginProviders: ReadonlySet<string>;
}): ModelsMenu {
const modelNames = new Map(data.modelNames);
const byProvider = new Map<string, ModelsProviderMenu>();
for (const [id, models] of data.byProvider) {
const notices = new Set<string>();
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 };
}

View file

@ -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);
});

View file

@ -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<string, string>;
modelMenu?: {
modelNames: ReadonlyMap<string, string>;
byProvider: ReadonlyMap<string, ModelsProviderMenu>;
};
modelMenu?: ModelsMenu;
refreshWarning?: string;
runtimeChoicesByProvider?: Map<string, ModelsRuntimeChoice[]>;
runtimeChoicesByModel?: Map<string, ModelsRuntimeChoice[]>;
isCurrent?: () => boolean;
};
type ModelsProviderMenu = { available: number; notice: string };
type ModelReadiness = Pick<ModelAuthAvailabilityEvaluation, "availability" | "unavailableReason">;
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<ModelCatalogEntry, "provider" | "id">) =>
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<string>();
@ -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<string, ReadonlySet<string>>;
modelNames: ReadonlyMap<string, string>;
modelAvailability: ReadonlyMap<string, ModelReadiness>;
loginProviders: ReadonlySet<string>;
}): NonNullable<ModelsProviderData["modelMenu"]> {
const modelNames = new Map(data.modelNames);
const byProvider = new Map<string, ModelsProviderMenu>();
for (const [id, models] of data.byProvider) {
const notices = new Set<string>();
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;

View file

@ -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;
}

View file

@ -130,6 +130,7 @@ const PROVIDER_ICON_ALIASES: Readonly<Record<string, string>> = {
// Brand display names for provider ids whose title-cased id reads wrong.
const PROVIDER_DISPLAY_LABELS: Readonly<Record<string, string>> = {
anthropic: "Anthropic",
"claude-cli": "Claude CLI",
google: "Google",
"github-copilot": "GitHub",
"llama-cpp": "llama.cpp",

View file

@ -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) => {

View file

@ -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 }));

View file

@ -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}",

View file

@ -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",

View file

@ -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;
}

View file

@ -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: {
}
</span>
</button>`;
return params.entry.supportsTools === false
? html`<openclaw-tooltip .content=${t("chat.modelControls.chatOnlyHelp")}>
${option}
</openclaw-tooltip>`
return detail
? html`<openclaw-tooltip .content=${detail}> ${option} </openclaw-tooltip>`
: option;
}

View file

@ -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`
<section
class="chat-controls__provider-model-group"
@ -382,7 +387,7 @@ export function renderChatModelPicker(params: ChatModelPickerParams) {
<div
class="chat-controls__provider-heading"
data-chat-model-provider=${provider}
title=${authLabel ?? nothing}
title=${[routeDetail, authLabel].filter(Boolean).join(" · ") || nothing}
>
<button
class="chat-controls__provider-toggle"
@ -393,6 +398,7 @@ export function renderChatModelPicker(params: ChatModelPickerParams) {
aria-label=${`${t("chat.modelControls.providerModels", {
provider: providerDisplayLabel(provider),
})} (${options.length})`}
aria-description=${routeDetail ?? nothing}
?disabled=${params.disabled}
@click=${toggleModelProviderGroup}
>