mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 09:39:25 +00:00
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:
parent
2a932d2338
commit
3e48684b8f
14 changed files with 379 additions and 256 deletions
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
88
src/auto-reply/reply/commands-models-menu.ts
Normal file
88
src/auto-reply/reply/commands-models-menu.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
18
src/shared/model-runtime-route.ts
Normal file
18
src/shared/model-runtime-route.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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 }));
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue