From 731f1e8e5288ad322ae23ee555a4f1b1f1b0958f Mon Sep 17 00:00:00 2001 From: asm <113964+asm@users.noreply.github.com> Date: Thu, 13 Aug 2026 17:00:25 -0700 Subject: [PATCH 01/17] feat: route skills to model classes (modelClasses + skillModelClasses), compose one-shots with skill invocations Skills bound to a model class (frontmatter metadata model-class, or the skillModelClasses config table) stream on the class's model for that send only. Classes are edited in Settings -> Models -> Model Classes; broken bindings fail the send with actionable errors; /model+thinking one-shots compose with skill invocations and bypass class routing. Co-Authored-By: Claude Fable 5 --- docs/agents/agent-skills.mdx | 39 +++ docs/config/models.mdx | 15 +- src/browser/features/ChatInput/index.tsx | 14 +- .../utils.oneShotSkillComposition.test.ts | 139 ++++++++ src/browser/features/ChatInput/utils.ts | 44 ++- .../Settings/Sections/ModelClassesEditor.tsx | 196 +++++++++++ .../Sections/ModelClassesEditor.ui.test.tsx | 137 ++++++++ .../Sections/ModelsSection.stories.tsx | 7 + .../Settings/Sections/ModelsSection.tsx | 3 + .../Settings/Sections/settingsStoryUtils.tsx | 3 + src/browser/hooks/useModelClasses.ts | 124 +++++++ src/browser/stories/mocks/orpc.ts | 11 + src/common/config/schemas/appConfigOnDisk.ts | 14 + src/common/orpc/schemas/api.ts | 11 + src/common/types/project.ts | 13 + src/common/utils/ai/modelAvailability.test.ts | 63 ++++ src/common/utils/ai/modelAvailability.ts | 42 +++ src/common/utils/ai/skillModelClasses.test.ts | 196 +++++++++++ src/common/utils/ai/skillModelClasses.ts | 205 ++++++++++++ src/node/config.modelClasses.test.ts | 58 ++++ src/node/config.ts | 17 + src/node/orpc/router.ts | 14 + .../agentSession.skillModelRouting.test.ts | 263 +++++++++++++++ src/node/services/agentSession.ts | 316 +++++++++++++++--- .../builtInSkillContent.generated.ts | 54 ++- 25 files changed, 1933 insertions(+), 65 deletions(-) create mode 100644 src/browser/features/ChatInput/utils.oneShotSkillComposition.test.ts create mode 100644 src/browser/features/Settings/Sections/ModelClassesEditor.tsx create mode 100644 src/browser/features/Settings/Sections/ModelClassesEditor.ui.test.tsx create mode 100644 src/browser/hooks/useModelClasses.ts create mode 100644 src/common/utils/ai/modelAvailability.test.ts create mode 100644 src/common/utils/ai/modelAvailability.ts create mode 100644 src/common/utils/ai/skillModelClasses.test.ts create mode 100644 src/common/utils/ai/skillModelClasses.ts create mode 100644 src/node/config.modelClasses.test.ts create mode 100644 src/node/services/agentSession.skillModelRouting.test.ts diff --git a/docs/agents/agent-skills.mdx b/docs/agents/agent-skills.mdx index 0f91e2eb309..83f254d17ff 100644 --- a/docs/agents/agent-skills.mdx +++ b/docs/agents/agent-skills.mdx @@ -240,6 +240,45 @@ Substitution rules: Use the `argument-hint` frontmatter field to document the expected arguments in invocation UIs. +## Per-skill model routing + +Mechanical skills (session wrap-up, worktree helpers, PR chores) rarely need your frontier model. Skill invocations can be routed to a **model class** — an indirection that survives model churn, since bindings name a class and only the class map names concrete models. + +Configure the three canonical classes — `large`, `medium`, `small` — in **Settings → Models → Model Classes** (a model plus an optional thinking level per class). Canonical names keep skill bindings portable across machines. The classes are stored in `~/.mux/config.json`, where values use the [one-shot override syntax](/config/models#one-shot-overrides): a model alias or full `provider:model` id, with an optional `+thinking` suffix (named level or model-relative numeric index). Hand-edited custom class names in config.json also work and are preserved by the Settings editor: + +```json +{ + "modelClasses": { + "large": "fable+max", + "medium": "sonnet+high", + "small": "haiku+0" + } +} +``` + +Bind skills to classes in either of two places: + +- **Skill frontmatter** — the spec-standard `metadata` map, so the binding travels with the skill and other agent tools ignore it: + + ```yaml + metadata: + model-class: small + ``` + +- **Config routing table** — for skills you don't own, `skillModelClasses` in `~/.mux/config.json` maps skill names to classes and **wins over frontmatter**: + + ```json + { + "skillModelClasses": { "done": "small", "wt": "small" } + } + ``` + +Routing applies to the slash invocation's send only: the workspace's selected model is untouched, and your next message streams on it again. If auto-compaction triggers, the threshold is computed against the routed model's context window, while the compaction request itself keeps your model (it must fit the uncompacted history). + +Broken bindings fail loudly: when a skill is bound to a class that isn't configured, the class value is malformed, or no configured provider route can serve the class's model (a retired model, a removed provider or key), the send fails with an error naming the mapping to fix — and the Model Classes editor shows the same "no configured route" warning inline. Two deliberate exceptions keep skills portable and resilient: frontmatter bindings are ignored entirely while you have no model classes configured at all, and infrastructure hiccups (an unreadable skill or config) fall back to the workspace model instead of failing the send. + +To override routing for one invocation, compose a one-shot prefix with the skill: `/sonnet+high /done` runs the skill on Sonnet regardless of its class. Explicit one-shots always win over class routing. + ## Dynamic context injection (experiment) Enable the **Skill dynamic context injection** experiment (Settings → Experiments) to let skills pull live command output into their instructions. When you invoke a skill, any line whose entire content is `` !`command` `` runs in the workspace, and the line is replaced with a fenced block containing the command’s output before the model sees the skill: diff --git a/docs/config/models.mdx b/docs/config/models.mdx index f0bdd2dd2ff..80ff29e1b78 100644 --- a/docs/config/models.mdx +++ b/docs/config/models.mdx @@ -65,12 +65,15 @@ Override the model or thinking level for a single message using slash commands. ### Syntax -| Command | Effect | -| --------------------------- | ---------------------------------------- | -| `/sonnet explain this code` | Use Sonnet for one message | -| `/opus+high deep review` | Use Opus with high thinking | -| `/haiku+0 quick answer` | Use Haiku at its lowest thinking level | -| `/+2 analyze this` | Keep current model, set thinking level 2 | +| Command | Effect | +| --------------------------- | ------------------------------------------- | +| `/sonnet explain this code` | Use Sonnet for one message | +| `/opus+high deep review` | Use Opus with high thinking | +| `/haiku+0 quick answer` | Use Haiku at its lowest thinking level | +| `/+2 analyze this` | Keep current model, set thinking level 2 | +| `/haiku+0 /done` | Run the `done` skill on Haiku for this send | + +One-shot prefixes compose with [skill invocations](/agents/agent-skills): `/haiku+0 /done cleanup` invokes the skill normally (arguments, snapshots) while overriding the model for that send. An explicit one-shot also wins over the skill's own [model-class routing](/agents/agent-skills#per-skill-model-routing). ### Thinking levels diff --git a/src/browser/features/ChatInput/index.tsx b/src/browser/features/ChatInput/index.tsx index 6073cf69ec5..5b974932a07 100644 --- a/src/browser/features/ChatInput/index.tsx +++ b/src/browser/features/ChatInput/index.tsx @@ -2542,6 +2542,9 @@ const ChatInputInner: React.FC = (props) => { agentSkillDescriptors, api, discovery: skillDiscovery, + // One-shot × skill composition ("/haiku+0 /done") ships for workspace + // sends; the creation composer has no one-shot support to compose with. + composeOneShot: variant === "workspace", }); const combinedSkillRefs = await resolveInlineSkillRefsForSend({ messageText, @@ -2662,6 +2665,9 @@ const ChatInputInner: React.FC = (props) => { try { const modelOneShot = parsed?.type === "model-oneshot" ? parsed : null; + // Model/thinking override from either a bare one-shot ("/haiku+0 msg") + // or one composed with a skill invocation ("/haiku+0 /done args"). + const oneShotOverride = modelOneShot ?? skillInvocation?.oneShot ?? null; // Mirror the creation-composer /goal bypass: with attachments present, // send the raw text as a normal message instead of processing the // command, which would drop the files. Transferred staging-failure @@ -2684,7 +2690,7 @@ const ChatInputInner: React.FC = (props) => { // the composer, it must not restore stale command text over the newer turn. asyncCommandTokenRef.current++; - const modelOverride = modelOneShot?.modelString; + const modelOverride = oneShotOverride?.modelString; // Regular message (or / one-shot override) - send directly via API const messageTextForSend = modelOneShot?.message ?? skillInvocation?.userText ?? messageText; @@ -2902,7 +2908,7 @@ const ChatInputInner: React.FC = (props) => { // One-shot models/thinking shouldn't update the persisted session defaults. // Resolve thinking level: numeric indices are model-relative (0 = model's lowest allowed level) - const rawThinkingOverride = modelOneShot?.thinkingLevel; + const rawThinkingOverride = oneShotOverride?.thinkingLevel; const thinkingOverride = rawThinkingOverride != null ? resolveThinkingInput(rawThinkingOverride, policyModel) @@ -2919,7 +2925,7 @@ const ChatInputInner: React.FC = (props) => { : {}), ...(modelOverride ? { model: modelOverride } : {}), ...(thinkingOverride ? { thinkingLevel: thinkingOverride } : {}), - ...(modelOneShot ? { skipAiSettingsPersistence: true } : {}), + ...(oneShotOverride ? { skipAiSettingsPersistence: true } : {}), ...(goalInterventionPolicy ? { goalInterventionPolicy } : {}), ...(overrides?.queueDispatchMode ? { queueDispatchMode: overrides.queueDispatchMode } @@ -2968,7 +2974,7 @@ const ChatInputInner: React.FC = (props) => { sendMessageOptions.thinkingLevel ?? "off" ); - if (modelOneShot) { + if (oneShotOverride) { trackCommandUsed("model"); } diff --git a/src/browser/features/ChatInput/utils.oneShotSkillComposition.test.ts b/src/browser/features/ChatInput/utils.oneShotSkillComposition.test.ts new file mode 100644 index 00000000000..527b56b0675 --- /dev/null +++ b/src/browser/features/ChatInput/utils.oneShotSkillComposition.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, test } from "bun:test"; + +import { KNOWN_MODELS } from "@/common/constants/knownModels"; +import type { AgentSkillDescriptor } from "@/common/types/agentSkill"; +import { parseCommandWithSkillInvocation } from "./utils"; + +function descriptor(name: string): AgentSkillDescriptor { + return { name, description: `${name} description`, scope: "project" }; +} + +describe("parseCommandWithSkillInvocation one-shot composition", () => { + test("composes '/haiku+0 /done args' into a skill invocation with a one-shot override", async () => { + const result = await parseCommandWithSkillInvocation({ + messageText: "/haiku+0 /done now please", + agentSkillDescriptors: [descriptor("done")], + api: null, + discovery: null, + composeOneShot: true, + }); + + expect(result.parsed).toBeNull(); + expect(result.skillInvocation?.descriptor.name).toBe("done"); + expect(result.skillInvocation?.userText).toBe("Using skill done: now please"); + // Arguments are relative to the skill token so $ARGUMENTS substitution + // sees "now please", not the one-shot prefix. + expect(result.skillInvocation?.argumentText).toBe("now please"); + expect(result.skillInvocation?.oneShot).toEqual({ + modelString: KNOWN_MODELS.HAIKU.id, + thinkingLevel: 0, + }); + }); + + test("composes a thinking-only override ('/+2 /done')", async () => { + const result = await parseCommandWithSkillInvocation({ + messageText: "/+2 /done", + agentSkillDescriptors: [descriptor("done")], + api: null, + discovery: null, + composeOneShot: true, + }); + + expect(result.parsed).toBeNull(); + expect(result.skillInvocation?.userText).toBe("Use skill done"); + expect(result.skillInvocation?.oneShot).toEqual({ thinkingLevel: 2 }); + }); + + test("does not compose without the composeOneShot opt-in (creation composer)", async () => { + const result = await parseCommandWithSkillInvocation({ + messageText: "/haiku+0 /done now", + agentSkillDescriptors: [descriptor("done")], + api: null, + discovery: null, + }); + + expect(result.skillInvocation).toBeNull(); + expect(result.parsed?.type).toBe("model-oneshot"); + }); + + test("keeps valid registered-command invocations out of composition ('/haiku+0 /compact')", async () => { + const result = await parseCommandWithSkillInvocation({ + messageText: "/haiku+0 /compact", + agentSkillDescriptors: [descriptor("compact")], + api: null, + discovery: null, + composeOneShot: true, + }); + + expect(result.skillInvocation).toBeNull(); + expect(result.parsed).toMatchObject({ type: "model-oneshot", message: "/compact" }); + }); + + test("mirrors direct invocation for unknown-command remainders, even on command-colliding names", async () => { + // "/compact now" is an invalid compact usage: its handler returns + // unknown-command, and unknown commands are exactly what skill invocation + // consumes. Direct typing already resolves a skill named "compact" here, + // so the composed form must behave identically. + const direct = await parseCommandWithSkillInvocation({ + messageText: "/compact now", + agentSkillDescriptors: [descriptor("compact")], + api: null, + discovery: null, + composeOneShot: true, + }); + const composed = await parseCommandWithSkillInvocation({ + messageText: "/haiku+0 /compact now", + agentSkillDescriptors: [descriptor("compact")], + api: null, + discovery: null, + composeOneShot: true, + }); + + expect(direct.skillInvocation?.descriptor.name).toBe("compact"); + expect(composed.skillInvocation?.descriptor.name).toBe("compact"); + expect(composed.skillInvocation?.oneShot).toEqual({ + modelString: KNOWN_MODELS.HAIKU.id, + thinkingLevel: 0, + }); + }); + + test("keeps nested one-shots out of composition ('/haiku+0 /opus hi')", async () => { + const result = await parseCommandWithSkillInvocation({ + messageText: "/haiku+0 /opus hi", + agentSkillDescriptors: [descriptor("done")], + api: null, + discovery: null, + composeOneShot: true, + }); + + expect(result.skillInvocation).toBeNull(); + expect(result.parsed).toMatchObject({ type: "model-oneshot", message: "/opus hi" }); + }); + + test("falls back to a plain one-shot when the remainder is not a known skill", async () => { + const result = await parseCommandWithSkillInvocation({ + messageText: "/haiku+0 /nothere do it", + agentSkillDescriptors: [descriptor("done")], + api: null, + discovery: null, + composeOneShot: true, + }); + + expect(result.skillInvocation).toBeNull(); + expect(result.parsed).toMatchObject({ type: "model-oneshot", message: "/nothere do it" }); + }); + + test("plain skill invocations are unaffected by the composition flag", async () => { + const result = await parseCommandWithSkillInvocation({ + messageText: "/done now", + agentSkillDescriptors: [descriptor("done")], + api: null, + discovery: null, + composeOneShot: true, + }); + + expect(result.parsed).toBeNull(); + expect(result.skillInvocation?.descriptor.name).toBe("done"); + expect(result.skillInvocation?.oneShot).toBeUndefined(); + }); +}); diff --git a/src/browser/features/ChatInput/utils.ts b/src/browser/features/ChatInput/utils.ts index 385da03aa1f..5e6d3bc5b1c 100644 --- a/src/browser/features/ChatInput/utils.ts +++ b/src/browser/features/ChatInput/utils.ts @@ -8,6 +8,7 @@ import { import { resolveSkillUserInvocable } from "@/common/orpc/schemas/agentSkill"; import type { AgentSkillDescriptor } from "@/common/types/agentSkill"; import type { ParsedRuntime } from "@/common/types/runtime"; +import type { ParsedThinkingInput } from "@/common/types/thinking"; import { buildAgentSkillMetadata, dedupeAgentSkillRefs, @@ -29,6 +30,16 @@ export interface SkillInvocation { userText: string; /** Trimmed text after the slash command (e.g. "123 high" for "/fix-issue 123 high"). */ argumentText: string; + /** + * One-shot model/thinking override composed with the invocation + * ("/haiku+0 /done args"). Applies to this send only; carrying + * skipAiSettingsPersistence also bypasses backend per-skill class routing + * (an explicit override wins over the skill's model class). + */ + oneShot?: { + modelString?: string; + thinkingLevel?: ParsedThinkingInput; + }; } export type SkillResolutionTarget = @@ -133,9 +144,11 @@ export async function parseCommandWithSkillInvocation(options: { agentSkillDescriptors: AgentSkillDescriptor[]; api: APIClient | null; discovery: SkillResolutionTarget | null; + /** Allow "/model+thinking /skill args" composition (workspace sends only). */ + composeOneShot?: boolean; }): Promise<{ parsed: ParsedCommand; skillInvocation: SkillInvocation | null }> { const parsed = parseCommand(options.messageText); - const skillInvocation = await resolveSkillInvocation({ + let skillInvocation = await resolveSkillInvocation({ messageText: options.messageText, parsed, agentSkillDescriptors: options.agentSkillDescriptors, @@ -143,6 +156,35 @@ export async function parseCommandWithSkillInvocation(options: { discovery: options.discovery, }); + // Compose one-shot model overrides with skill invocations: "/haiku+0 /done args" + // runs the done skill on Haiku for this send only. Re-running parseCommand on the + // one-shot's message keeps registered commands ("/haiku+0 /compact") and nested + // one-shots out of skill resolution — only unknown-command remainders are + // candidate skills, exactly like a bare "/done args". + if ( + options.composeOneShot === true && + skillInvocation == null && + parsed?.type === "model-oneshot" + ) { + const innerParsed = parseCommand(parsed.message); + const innerInvocation = await resolveSkillInvocation({ + messageText: parsed.message, + parsed: innerParsed, + agentSkillDescriptors: options.agentSkillDescriptors, + api: options.api, + discovery: options.discovery, + }); + if (innerInvocation != null) { + skillInvocation = { + ...innerInvocation, + oneShot: { + ...(parsed.modelString != null ? { modelString: parsed.modelString } : {}), + ...(parsed.thinkingLevel != null ? { thinkingLevel: parsed.thinkingLevel } : {}), + }, + }; + } + } + return { parsed: skillInvocation == null ? parsed : null, skillInvocation, diff --git a/src/browser/features/Settings/Sections/ModelClassesEditor.tsx b/src/browser/features/Settings/Sections/ModelClassesEditor.tsx new file mode 100644 index 00000000000..84148ae712b --- /dev/null +++ b/src/browser/features/Settings/Sections/ModelClassesEditor.tsx @@ -0,0 +1,196 @@ +import { X } from "lucide-react"; + +import { Button } from "@/browser/components/Button/Button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/browser/components/SelectPrimitive/SelectPrimitive"; +import { useModelClasses } from "@/browser/hooks/useModelClasses"; +import { useModelsFromSettings } from "@/browser/hooks/useModelsFromSettings"; +import { useProvidersConfig } from "@/browser/hooks/useProvidersConfig"; +import { useRouting } from "@/browser/hooks/useRouting"; +import { getThinkingOptionLabel, type ThinkingLevel } from "@/common/types/thinking"; +import { isModelServableWithProvidersConfig } from "@/common/utils/ai/modelAvailability"; +import { normalizeToCanonical } from "@/common/utils/ai/models"; +import { + buildModelClassValue, + CANONICAL_MODEL_CLASSES, + parseModelClassValue, + splitModelClassValue, +} from "@/common/utils/ai/skillModelClasses"; +import { getThinkingPolicyForModel, resolveThinkingInput } from "@/common/utils/thinking/policy"; + +const MODEL_SELECT_TRIGGER_CLASS = + "border-border-medium bg-background-secondary hover:bg-hover h-7 w-64 cursor-pointer rounded-md border px-2 text-xs transition-colors"; +const THINKING_SELECT_TRIGGER_CLASS = + "border-border-medium bg-background-secondary hover:bg-hover h-7 w-28 cursor-pointer rounded-md border px-2 text-xs transition-colors"; + +/** Sentinel Select value for "no thinking suffix" (Radix rejects empty item values). */ +const THINKING_DEFAULT_OPTION = "default"; + +/** + * Model class editor (Settings → Models). + * + * Model classes are the indirection behind per-skill model routing: skills + * bind to a class name (frontmatter `metadata: model-class`, or the + * `skillModelClasses` table in config.json) and the class maps to a concrete + * model here. When models change, updating the class re-routes every bound + * skill at once. + * + * The editor surfaces exactly the canonical classes (large/medium/small) so + * skill bindings stay portable across machines; hand-edited custom classes in + * config.json keep working and are preserved on save, but are not editable + * here. + */ +export function ModelClassesEditor() { + const { modelClasses, setModelClass } = useModelClasses(); + const { models } = useModelsFromSettings(); + const { config: providersConfig } = useProvidersConfig(); + const routing = useRouting(); + + // Settings can list aliases that canonicalize to the same model; dedupe via + // canonical form so SelectItem values stay unique. + const modelCandidates = Array.from(new Set(models.map((model) => normalizeToCanonical(model)))); + + const canonicalNames: readonly string[] = CANONICAL_MODEL_CLASSES; + const customEntries = Object.entries(modelClasses) + .filter(([name]) => !canonicalNames.includes(name)) + .sort(([a], [b]) => a.localeCompare(b)); + + const renderClassRow = (className: string) => { + const rawValue = modelClasses[className]; + const parsed = rawValue ? parseModelClassValue(rawValue) : null; + const { thinkingSuffix } = rawValue ? splitModelClassValue(rawValue) : { thinkingSuffix: null }; + const selectedModel = parsed?.model ?? ""; + // Show numeric (model-relative) suffixes as the level they resolve to for + // the selected model; re-saving through the select writes the named level. + const selectedThinking: ThinkingLevel | null = + parsed?.thinkingLevel != null && parsed.model + ? resolveThinkingInput(parsed.thinkingLevel, parsed.model) + : null; + const thinkingOptions = selectedModel ? getThinkingPolicyForModel(selectedModel) : []; + // Ensure the selected model is offerable even if hidden from the picker + // list (e.g. a hand-configured custom model). + const rowModelCandidates = + selectedModel && !modelCandidates.includes(selectedModel) + ? [selectedModel, ...modelCandidates] + : modelCandidates; + // Proactive churn warning: the class points at a model no configured + // route can serve (skill sends bound to it will fail with the same + // verdict). Null providersConfig = still loading — say nothing yet. + const modelUnavailable = + parsed != null && + providersConfig != null && + !isModelServableWithProvidersConfig({ + canonicalModel: parsed.model, + routePriority: routing.routePriority, + routeOverrides: routing.routeOverrides, + providersConfig, + }); + + return ( +
+ {className} + + + {rawValue !== undefined && ( + + )} + {rawValue !== undefined && parsed == null && ( + + invalid value: {rawValue} + + )} + {modelUnavailable && ( + + no configured route can serve this model — update this class + + )} +
+ ); + }; + + return ( +
+
Model Classes
+

+ Size classes for per-skill model routing. A skill bound to a class (frontmatter{" "} + metadata: model-class, or the{" "} + skillModelClasses table in config.json) runs on the + class's model for that invocation only — your workspace model is untouched. When models + change, update the class here and every bound skill follows. +

+ +
{canonicalNames.map((name) => renderClassRow(name))}
+ + {customEntries.length > 0 && ( +

+ Custom classes (edit in config.json):{" "} + {customEntries.map(([name, value]) => `${name} → ${value}`).join(", ")} +

+ )} +
+ ); +} diff --git a/src/browser/features/Settings/Sections/ModelClassesEditor.ui.test.tsx b/src/browser/features/Settings/Sections/ModelClassesEditor.ui.test.tsx new file mode 100644 index 00000000000..175d36a9fb1 --- /dev/null +++ b/src/browser/features/Settings/Sections/ModelClassesEditor.ui.test.tsx @@ -0,0 +1,137 @@ +import { cleanup, render, fireEvent, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { installDom } from "../../../../../tests/ui/dom"; + +let apiMock: { + config: { + getConfig: ReturnType; + updateModelClasses: ReturnType; + onConfigChanged: ReturnType; + }; +} | null = null; + +/** Providers map for the availability warning; null = still loading (warning suppressed). */ +let providersConfigMock: Record | null = + null; + +void mock.module("@/browser/contexts/API", () => ({ + useOptionalAPI: () => (apiMock ? { api: apiMock } : null), + // useRouting (imported by the editor) reads the API through useAPI. + useAPI: () => ({ api: apiMock }), +})); + +void mock.module("@/browser/hooks/useProvidersConfig", () => ({ + useProvidersConfig: () => ({ config: providersConfigMock, loading: providersConfigMock == null }), +})); + +void mock.module("@/browser/hooks/useModelsFromSettings", () => ({ + useModelsFromSettings: () => ({ + models: ["anthropic:claude-haiku-4-5", "anthropic:claude-sonnet-5", "anthropic:claude-fable-5"], + hiddenModelsForSelector: [], + }), +})); + +import { ModelClassesEditor } from "./ModelClassesEditor"; + +function createApiMock(modelClasses: Record) { + return { + config: { + getConfig: mock(() => Promise.resolve({ modelClasses })), + updateModelClasses: mock(() => Promise.resolve(undefined)), + onConfigChanged: mock((_input: undefined, _opts: { signal?: AbortSignal }) => + Promise.resolve( + (async function* (): AsyncGenerator { + // Subscription that ends immediately: the hook's initial fetch has + // already run; these tests drive state via direct interactions. + await Promise.resolve(); + yield* [] as void[]; + })() + ) + ), + }, + }; +} + +describe("ModelClassesEditor", () => { + let restoreDom: (() => void) | null = null; + + beforeEach(() => { + restoreDom = installDom(); + }); + + afterEach(() => { + cleanup(); + restoreDom?.(); + restoreDom = null; + apiMock = null; + providersConfigMock = null; + }); + + test("renders the three canonical class rows; clear button only on configured classes", async () => { + apiMock = createApiMock({ small: "anthropic:claude-haiku-4-5+0" }); + // Row presence is asserted via the labeled row groups, not the select + // triggers: other suites in the same process (TasksSection) mock + // SelectPrimitive with native elements, and bun's mock.module leaks across + // test files, so select internals are not stable to assert on. + const { getByRole, queryByLabelText } = render(); + + await waitFor(() => { + expect(apiMock?.config.getConfig).toHaveBeenCalled(); + expect(queryByLabelText("Clear model class small")).not.toBeNull(); + }); + + for (const name of ["large", "medium", "small"]) { + expect(getByRole("group", { name: `Model class ${name}` })).toBeTruthy(); + } + // Unset classes have nothing to clear. + expect(queryByLabelText("Clear model class large")).toBeNull(); + expect(queryByLabelText("Clear model class medium")).toBeNull(); + }); + + test("clearing a canonical class preserves hand-edited custom classes in the write", async () => { + apiMock = createApiMock({ + small: "anthropic:claude-haiku-4-5+0", + "my-custom": "anthropic:claude-fable-5+max", + }); + const { getByLabelText, queryByLabelText } = render(); + + await waitFor(() => expect(queryByLabelText("Clear model class small")).not.toBeNull()); + fireEvent.click(getByLabelText("Clear model class small")); + + await waitFor(() => expect(apiMock?.config.updateModelClasses).toHaveBeenCalled()); + expect(apiMock?.config.updateModelClasses).toHaveBeenCalledWith({ + modelClasses: { "my-custom": "anthropic:claude-fable-5+max" }, + }); + }); + + test("lists custom classes as config-managed instead of hiding them", async () => { + apiMock = createApiMock({ "my-custom": "anthropic:claude-fable-5+max" }); + const { findByText } = render(); + + expect(await findByText(/my-custom → anthropic:claude-fable-5\+max/)).toBeTruthy(); + }); + + test("flags an unparseable configured value instead of silently dropping it", async () => { + apiMock = createApiMock({ small: "garbage" }); + const { findByText } = render(); + + expect(await findByText(/invalid value: garbage/)).toBeTruthy(); + }); + + test("warns when no configured route can serve a class model", async () => { + apiMock = createApiMock({ small: "anthropic:claude-haiku-4-5+0" }); + providersConfigMock = { anthropic: { isConfigured: false } }; + const { findByText } = render(); + + expect(await findByText(/no configured route can serve this model/)).toBeTruthy(); + }); + + test("does not warn when the class model has a configured route", async () => { + apiMock = createApiMock({ small: "anthropic:claude-haiku-4-5+0" }); + providersConfigMock = { anthropic: { isConfigured: true, isEnabled: true } }; + const { queryByText, queryByLabelText } = render(); + + await waitFor(() => expect(queryByLabelText("Clear model class small")).not.toBeNull()); + expect(queryByText(/no configured route can serve this model/)).toBeNull(); + }); +}); diff --git a/src/browser/features/Settings/Sections/ModelsSection.stories.tsx b/src/browser/features/Settings/Sections/ModelsSection.stories.tsx index 07df912205f..c82c2751bb7 100644 --- a/src/browser/features/Settings/Sections/ModelsSection.stories.tsx +++ b/src/browser/features/Settings/Sections/ModelsSection.stories.tsx @@ -70,6 +70,13 @@ export const ModelsConfigured: Story = { ); return setupSettingsStory({ + modelClasses: { + large: "anthropic:claude-opus-4-8+max", + // xai is deliberately unconfigured in this story: exercises the + // "no configured route" warning on the medium row. + medium: "xai:grok-beta", + small: "anthropic:claude-sonnet-4-20250514+0", + }, providersConfig: { anthropic: { apiKeySet: true, diff --git a/src/browser/features/Settings/Sections/ModelsSection.tsx b/src/browser/features/Settings/Sections/ModelsSection.tsx index 0f4ed911339..5f553256ea8 100644 --- a/src/browser/features/Settings/Sections/ModelsSection.tsx +++ b/src/browser/features/Settings/Sections/ModelsSection.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { ArrowRight, Info, Loader2, Plus, ShieldCheck } from "lucide-react"; import { useProviderOptions } from "@/browser/hooks/useProviderOptions"; import { Button } from "@/browser/components/Button/Button"; +import { ModelClassesEditor } from "./ModelClassesEditor"; import { ModelFallbacksEditor } from "./ModelFallbacksEditor"; import { ProviderIcon } from "@/browser/components/ProviderIcon/ProviderIcon"; import { @@ -592,6 +593,8 @@ export function ModelsSection() { + +
diff --git a/src/browser/features/Settings/Sections/settingsStoryUtils.tsx b/src/browser/features/Settings/Sections/settingsStoryUtils.tsx index c53c6415f2a..7dad5b688b1 100644 --- a/src/browser/features/Settings/Sections/settingsStoryUtils.tsx +++ b/src/browser/features/Settings/Sections/settingsStoryUtils.tsx @@ -124,6 +124,8 @@ export function SettingsSectionStory(props: SettingsSectionStoryProps) { interface SetupSettingsStoryOptions { layoutPresets?: LayoutPresetsConfig; + /** Initial model classes for Settings → Models → Model Classes. */ + modelClasses?: Record; providersConfig?: Record< string, { @@ -178,6 +180,7 @@ export function setupSettingsStory(options: SetupSettingsStoryOptions): APIClien taskSettings: options.taskSettings, serverAuthSessions: options.serverAuthSessions, layoutPresets: options.layoutPresets, + modelClasses: options.modelClasses, }); } diff --git a/src/browser/hooks/useModelClasses.ts b/src/browser/hooks/useModelClasses.ts new file mode 100644 index 00000000000..85d1c6d342b --- /dev/null +++ b/src/browser/hooks/useModelClasses.ts @@ -0,0 +1,124 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useOptionalAPI } from "@/browser/contexts/API"; +import { sanitizeModelClasses } from "@/common/utils/ai/skillModelClasses"; + +export interface ModelClassesState { + /** Class name → model value in one-shot syntax ("haiku+0"). */ + modelClasses: Record; + // Arrow-function property type so consumers can destructure without + // tripping @typescript-eslint/unbound-method. + /** Set (or clear, with null/empty) one class's model value. */ + setModelClass: (className: string, value: string | null) => void; +} + +/** + * Reads/writes the model-classes map (skill routing indirection) from app + * config. Mirrors useModelFallbacks: fetch on mount, subscribe to config + * changes, optimistically apply local edits while ignoring stale fetches. + * Writes are full-map replacements, so hand-edited custom classes survive + * edits made through the Settings editor. + */ +export function useModelClasses(): ModelClassesState { + const api = useOptionalAPI()?.api ?? null; + const [modelClasses, setMap] = useState>({}); + // Ignore stale config fetches so backend refreshes can't overwrite newer optimistic edits. + const fetchVersionRef = useRef(0); + + const fetchConfig = useCallback(async () => { + const getConfig = api?.config?.getConfig; + if (!getConfig) { + return; + } + + const fetchVersion = ++fetchVersionRef.current; + + try { + const config = await getConfig(); + if (fetchVersion !== fetchVersionRef.current) { + return; + } + setMap(config.modelClasses ?? {}); + } catch { + // Best-effort only. + } + }, [api]); + + useEffect(() => { + const onConfigChanged = api?.config?.onConfigChanged; + if (!onConfigChanged) { + return; + } + + const abortController = new AbortController(); + const { signal } = abortController; + let iterator: AsyncIterator | null = null; + + void fetchConfig(); + + (async () => { + try { + const subscribedIterator = await onConfigChanged(undefined, { signal }); + if (signal.aborted) { + void subscribedIterator.return?.(); + return; + } + iterator = subscribedIterator; + for await (const _ of subscribedIterator) { + if (signal.aborted) { + break; + } + void fetchConfig(); + } + } catch { + // Subscription cancelled via abort signal - expected on cleanup. + } + })(); + + return () => { + abortController.abort(); + void iterator?.return?.(); + }; + }, [api, fetchConfig]); + + const setModelClass = useCallback( + (className: string, value: string | null) => { + const key = className.trim(); + if (!key) { + return; + } + + const next = { ...modelClasses }; + const trimmed = value?.trim() ?? ""; + if (!trimmed) { + delete next[key]; + } else { + next[key] = trimmed; + } + // Mirror the backend's strict-on-write sanitization locally so the UI + // immediately reflects what will actually persist. + const sanitized = sanitizeModelClasses(next); + + fetchVersionRef.current++; + setMap(sanitized); + + // Guarded lookup rather than a chained call: in partial-API environments + // (story mocks, tests) a missing route must not throw synchronously — + // the .catch below can only intercept async failures. + const updateModelClasses = api?.config?.updateModelClasses; + if (!updateModelClasses) { + return; + } + updateModelClasses({ modelClasses: sanitized }).catch(() => { + // If the write fails, re-fetch so the UI reverts to the backend's + // actual map rather than displaying classes routing never applies. + void fetchConfig(); + }); + }, + [api, fetchConfig, modelClasses] + ); + + return { + modelClasses, + setModelClass, + }; +} diff --git a/src/browser/stories/mocks/orpc.ts b/src/browser/stories/mocks/orpc.ts index 7ec8973c548..27ffa315f03 100644 --- a/src/browser/stories/mocks/orpc.ts +++ b/src/browser/stories/mocks/orpc.ts @@ -145,6 +145,8 @@ export interface MockORPCClientOptions { agentDefinitions?: AgentDefinitionDescriptor[]; /** Initial per-subagent AI defaults for config.getConfig (e.g., Settings → Tasks section) */ subagentAiDefaults?: SubagentAiDefaults; + /** Initial model classes for config.getConfig (Settings → Models → Model Classes) */ + modelClasses?: Record; /** Coder lifecycle preferences for config.getConfig (e.g., Settings → Coder section) */ coderWorkspaceArchiveBehavior?: CoderWorkspaceArchiveBehavior; /** What to do with mux-managed worktrees when archiving a chat. */ @@ -391,6 +393,7 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl taskSettings: initialTaskSettings, subagentAiDefaults: initialSubagentAiDefaults, agentAiDefaults: initialAgentAiDefaults, + modelClasses: initialModelClasses, coderWorkspaceArchiveBehavior: initialCoderWorkspaceArchiveBehavior = "stop", worktreeArchiveBehavior: initialWorktreeArchiveBehavior = "keep", chatTranscriptFullWidth: initialChatTranscriptFullWidth = false, @@ -640,6 +643,7 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl let layoutPresets = initialLayoutPresets ?? DEFAULT_LAYOUT_PRESETS_CONFIG; let subagentAiDefaults = deriveSubagentAiDefaults(); + let modelClasses: Record | undefined = initialModelClasses; const mockStats: ChatStats = { consumers: [], @@ -776,6 +780,7 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl defaultRuntime, agentAiDefaults, subagentAiDefaults, + modelClasses, muxGovernorUrl, heartbeatDefaultPrompt, heartbeatDefaultIntervalMs, @@ -842,6 +847,12 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl notifyConfigChanged(); return Promise.resolve(undefined); }, + updateModelClasses: (input: { modelClasses: Record }) => { + modelClasses = + Object.keys(input.modelClasses).length > 0 ? { ...input.modelClasses } : undefined; + notifyConfigChanged(); + return Promise.resolve(undefined); + }, updateMuxGatewayPrefs: (input: { muxGatewayEnabled: boolean; muxGatewayModels: string[]; diff --git a/src/common/config/schemas/appConfigOnDisk.ts b/src/common/config/schemas/appConfigOnDisk.ts index 33ebbf24f40..ffb73849b70 100644 --- a/src/common/config/schemas/appConfigOnDisk.ts +++ b/src/common/config/schemas/appConfigOnDisk.ts @@ -130,6 +130,20 @@ export const AppConfigOnDiskSchema = z * runtime sanitization rules (drop self, de-dupe, cap length). */ modelFallbacks: ModelFallbacksSchema.optional(), + /** + * Named model classes (e.g. large/medium/small → a model alias or + * "provider:model" id with an optional "+thinking" suffix, one-shot + * syntax). Indirection layer so bindings survive model churn; consumed by + * per-skill model routing (see skillModelClasses and the skill frontmatter + * metadata "model-class" key). + */ + modelClasses: z.record(z.string(), z.string()).optional(), + /** + * Per-skill routing table (skill name → class name in modelClasses). + * Wins over a skill's own frontmatter metadata "model-class" binding, so + * skills the user does not own can be routed without editing them. + */ + skillModelClasses: z.record(z.string(), z.string()).optional(), defaultModel: z.string().optional(), advisorModelString: z.string().optional(), advisorThinkingLevel: ThinkingLevelSchema.optional(), diff --git a/src/common/orpc/schemas/api.ts b/src/common/orpc/schemas/api.ts index 213265df486..e6ab981fc8d 100644 --- a/src/common/orpc/schemas/api.ts +++ b/src/common/orpc/schemas/api.ts @@ -2279,6 +2279,7 @@ export const config = { routeOverrides: z.record(z.string(), z.string()).optional(), minThinkingLevelByModel: z.record(z.string(), ThinkingLevelSchema).optional(), modelFallbacks: ModelFallbacksSchema.optional(), + modelClasses: z.record(z.string(), z.string()).optional(), defaultModel: z.string().optional(), advisorModelString: AdvisorModelStringSchema, advisorThinkingLevel: AdvisorThinkingLevelSchema, @@ -2364,6 +2365,16 @@ export const config = { }), output: z.void(), }, + updateModelClasses: { + input: z.object({ + // Full-map replacement keyed by class name (canonical slots are + // large/medium/small; hand-edited custom names are preserved). Values + // use the one-shot syntax ("haiku+0"); the backend drops unparseable + // entries before persisting. + modelClasses: z.record(z.string(), z.string()), + }), + output: z.void(), + }, updateCoderPrefs: { input: z .object({ diff --git a/src/common/types/project.ts b/src/common/types/project.ts index 1940733d152..2b6541cf75f 100644 --- a/src/common/types/project.ts +++ b/src/common/types/project.ts @@ -114,6 +114,19 @@ export interface ProjectsConfig { */ modelFallbacks?: ModelFallbacks; + /** + * Named model classes (e.g. large/medium/small → a model alias or + * "provider:model" id with an optional "+thinking" suffix, one-shot syntax). + * Indirection layer so bindings survive model churn; consumed by per-skill + * model routing. + */ + modelClasses?: Record; + /** + * Per-skill routing table (skill name → class name in modelClasses). Wins + * over a skill's own frontmatter metadata "model-class" binding. + */ + skillModelClasses?: Record; + /** * Default model used for new workspaces (shared via ~/.mux/config.json). * Mirrors the browser localStorage cache (DEFAULT_MODEL_KEY). diff --git a/src/common/utils/ai/modelAvailability.test.ts b/src/common/utils/ai/modelAvailability.test.ts new file mode 100644 index 00000000000..056da3ba493 --- /dev/null +++ b/src/common/utils/ai/modelAvailability.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, test } from "bun:test"; + +import type { ProvidersConfigMap } from "@/common/orpc/types"; +import { isModelServableWithProvidersConfig } from "./modelAvailability"; + +const MODEL = "anthropic:claude-haiku-4-5"; + +function providers(entry: { isConfigured: boolean; isEnabled?: boolean }): ProvidersConfigMap { + return { anthropic: entry } as unknown as ProvidersConfigMap; +} + +describe("isModelServableWithProvidersConfig", () => { + test("serves a model whose direct provider is configured", () => { + expect( + isModelServableWithProvidersConfig({ + canonicalModel: MODEL, + routePriority: ["direct"], + providersConfig: providers({ isConfigured: true }), + }) + ).toBe(true); + }); + + test("rejects a model whose provider is not configured", () => { + expect( + isModelServableWithProvidersConfig({ + canonicalModel: MODEL, + routePriority: ["direct"], + providersConfig: providers({ isConfigured: false }), + }) + ).toBe(false); + }); + + test("a disabled provider does not count as configured", () => { + expect( + isModelServableWithProvidersConfig({ + canonicalModel: MODEL, + routePriority: ["direct"], + providersConfig: providers({ isConfigured: true, isEnabled: false }), + }) + ).toBe(false); + }); + + test("a configured provider outside the route priority list cannot serve", () => { + // Availability must honor route priority (matching a real send), not just + // "some provider somewhere is configured". + expect( + isModelServableWithProvidersConfig({ + canonicalModel: MODEL, + routePriority: [], + providersConfig: providers({ isConfigured: true }), + }) + ).toBe(false); + }); + + test("route priority defaults to direct when omitted", () => { + expect( + isModelServableWithProvidersConfig({ + canonicalModel: MODEL, + providersConfig: providers({ isConfigured: true }), + }) + ).toBe(true); + }); +}); diff --git a/src/common/utils/ai/modelAvailability.ts b/src/common/utils/ai/modelAvailability.ts new file mode 100644 index 00000000000..a773153b328 --- /dev/null +++ b/src/common/utils/ai/modelAvailability.ts @@ -0,0 +1,42 @@ +import type { ProvidersConfigMap } from "@/common/orpc/types"; +import { isModelAvailable } from "@/common/routing"; +import { isGatewayModelAccessibleFromAuthoritativeCatalog } from "@/common/utils/providers/gatewayModelCatalog"; + +/** + * Can the current routing state actually serve this model? + * + * Wraps the routing layer's isModelAvailable with the same provider + * predicates the Settings UI uses (useRouting): a provider counts as + * configured when `isConfigured` is set and it is not disabled, and gateway + * accessibility consults the authoritative model catalog. Route priority and + * per-model overrides are honored, so a gateway that is configured but not in + * the priority list does not count — matching what a send would really do. + * + * Callers that cannot obtain a ProvidersConfigMap (degraded state, minimal + * test mocks) must skip the check rather than pass an empty map: + * "cannot determine" is not "unavailable". + */ +export function isModelServableWithProvidersConfig(args: { + canonicalModel: string; + routePriority?: string[]; + routeOverrides?: Record; + providersConfig: ProvidersConfigMap; +}): boolean { + const providersConfig = args.providersConfig; + return isModelAvailable( + args.canonicalModel, + args.routePriority ?? ["direct"], + args.routeOverrides ?? {}, + (provider) => + providersConfig[provider]?.isConfigured === true && + providersConfig[provider]?.isEnabled !== false, + (gateway, modelId) => + isGatewayModelAccessibleFromAuthoritativeCatalog( + gateway, + modelId, + providersConfig[gateway]?.models, + providersConfig[gateway]?.discoveredModels, + providersConfig[gateway]?.removedModels + ) + ); +} diff --git a/src/common/utils/ai/skillModelClasses.test.ts b/src/common/utils/ai/skillModelClasses.test.ts new file mode 100644 index 00000000000..eb24be56285 --- /dev/null +++ b/src/common/utils/ai/skillModelClasses.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, test } from "bun:test"; + +import { KNOWN_MODELS } from "@/common/constants/knownModels"; +import { + buildModelClassValue, + parseModelClassValue, + resolveSkillModelClassBinding, + sanitizeModelClasses, + splitModelClassValue, +} from "./skillModelClasses"; + +describe("parseModelClassValue", () => { + test("resolves a bare alias without a thinking level", () => { + expect(parseModelClassValue("haiku")).toEqual({ model: KNOWN_MODELS.HAIKU.id }); + }); + + test("resolves alias + numeric thinking (deferred as an index)", () => { + expect(parseModelClassValue("haiku+0")).toEqual({ + model: KNOWN_MODELS.HAIKU.id, + thinkingLevel: 0, + }); + }); + + test("resolves alias + named thinking", () => { + expect(parseModelClassValue("sonnet+high")).toEqual({ + model: KNOWN_MODELS.SONNET.id, + thinkingLevel: "high", + }); + }); + + test("accepts full provider:model ids (unlike the composer one-shot parser)", () => { + expect(parseModelClassValue("anthropic:claude-fable-5+max")).toEqual({ + model: KNOWN_MODELS.FABLE.id, + thinkingLevel: "max", + }); + }); + + test("rejects unknown model input", () => { + expect(parseModelClassValue("not-a-model")).toBeNull(); + expect(parseModelClassValue("")).toBeNull(); + }); + + test("rejects an invalid thinking suffix instead of ignoring it", () => { + expect(parseModelClassValue("haiku+bogus")).toBeNull(); + expect(parseModelClassValue("haiku+")).toBeNull(); + }); +}); + +describe("splitModelClassValue / buildModelClassValue", () => { + test("round-trips a raw thinking suffix so numeric levels survive model changes", () => { + // "+0" is model-relative user intent ("lowest"): swapping the model in an + // editor must not concretize it to the old model's floor. + const { thinkingSuffix } = splitModelClassValue("haiku+0"); + expect(thinkingSuffix).toBe("0"); + expect(buildModelClassValue(KNOWN_MODELS.SONNET.id, thinkingSuffix)).toBe( + `${KNOWN_MODELS.SONNET.id}+0` + ); + }); + + test("handles suffix-less values", () => { + expect(splitModelClassValue("sonnet")).toEqual({ modelPart: "sonnet", thinkingSuffix: null }); + expect(buildModelClassValue("sonnet", null)).toBe("sonnet"); + }); +}); + +describe("sanitizeModelClasses", () => { + test("keeps parseable entries and drops unparseable ones", () => { + expect( + sanitizeModelClasses({ + small: "haiku+0", + broken: "not-a-model", + badThinking: "haiku+bogus", + "": "haiku", + " spaced ": " sonnet+high ", + }) + ).toEqual({ + small: "haiku+0", + spaced: "sonnet+high", + }); + }); + + test("preserves custom class names alongside canonical ones", () => { + expect( + sanitizeModelClasses({ small: "haiku", "my-custom": "anthropic:claude-opus-5+high" }) + ).toEqual({ + small: "haiku", + "my-custom": "anthropic:claude-opus-5+high", + }); + }); +}); + +describe("resolveSkillModelClassBinding", () => { + const modelClasses = { + small: "haiku+0", + large: "anthropic:claude-fable-5+max", + }; + + test("binds via frontmatter metadata and resolves numeric thinking to a concrete level", () => { + const binding = resolveSkillModelClassBinding({ + skillName: "done", + frontmatterMetadata: { "model-class": "small" }, + modelClasses, + }); + // Haiku's lowest allowed thinking level is "off": index 0 must resolve + // model-relatively, not to the literal level "0". + expect(binding).toEqual({ + status: "resolved", + className: "small", + model: KNOWN_MODELS.HAIKU.id, + thinkingLevel: "off", + }); + }); + + test("config routing table wins over frontmatter metadata", () => { + const binding = resolveSkillModelClassBinding({ + skillName: "done", + frontmatterMetadata: { "model-class": "small" }, + modelClasses, + skillModelClasses: { done: "large" }, + }); + expect(binding).toMatchObject({ + status: "resolved", + model: KNOWN_MODELS.FABLE.id, + thinkingLevel: "max", + }); + }); + + test("table entries for other skills do not shadow the metadata binding", () => { + const binding = resolveSkillModelClassBinding({ + skillName: "done", + frontmatterMetadata: { "model-class": "small" }, + modelClasses, + skillModelClasses: { review: "large" }, + }); + expect(binding).toMatchObject({ status: "resolved", model: KNOWN_MODELS.HAIKU.id }); + }); + + test("reports an unknown class name instead of swallowing it", () => { + expect( + resolveSkillModelClassBinding({ + skillName: "done", + frontmatterMetadata: { "model-class": "tiny" }, + modelClasses, + }) + ).toEqual({ status: "unknown-class", className: "tiny" }); + }); + + test("reports an invalid class value instead of swallowing it", () => { + expect( + resolveSkillModelClassBinding({ + skillName: "done", + frontmatterMetadata: { "model-class": "small" }, + modelClasses: { small: "not-a-model" }, + }) + ).toEqual({ status: "invalid-value", className: "small", value: "not-a-model" }); + }); + + test("skills without any binding are unbound", () => { + expect(resolveSkillModelClassBinding({ skillName: "done", modelClasses })).toEqual({ + status: "unbound", + }); + }); + + test("frontmatter bindings are inert until the user configures model classes", () => { + // A skill shipping `metadata: model-class` must not error for users who + // never opted into model classes. + expect( + resolveSkillModelClassBinding({ + skillName: "done", + frontmatterMetadata: { "model-class": "small" }, + }) + ).toEqual({ status: "unbound" }); + }); + + test("a config-table binding is explicit intent and errors even without a class map", () => { + expect( + resolveSkillModelClassBinding({ + skillName: "done", + skillModelClasses: { done: "small" }, + }) + ).toEqual({ status: "unknown-class", className: "small" }); + }); + + test("a class without a thinking suffix overrides only the model", () => { + const binding = resolveSkillModelClassBinding({ + skillName: "done", + frontmatterMetadata: { "model-class": "medium" }, + modelClasses: { medium: "sonnet" }, + }); + expect(binding).toEqual({ + status: "resolved", + className: "medium", + model: KNOWN_MODELS.SONNET.id, + }); + }); +}); diff --git a/src/common/utils/ai/skillModelClasses.ts b/src/common/utils/ai/skillModelClasses.ts new file mode 100644 index 00000000000..d60fb8e747b --- /dev/null +++ b/src/common/utils/ai/skillModelClasses.ts @@ -0,0 +1,205 @@ +import type { ProvidersConfigMap } from "@/common/orpc/types"; +import { + parseThinkingInput, + type ParsedThinkingInput, + type ThinkingLevel, +} from "@/common/types/thinking"; +import { normalizeModelInput } from "@/common/utils/ai/normalizeModelInput"; +import { resolveThinkingInput } from "@/common/utils/thinking/policy"; + +/** + * Skill frontmatter `metadata` key naming the model class a skill prefers to + * run on (e.g. `metadata: { model-class: small }`). The spec-standard metadata + * map is used instead of a new frontmatter field so skills stay portable: + * other agent tools ignore unknown metadata entries, and mux already parses + * and preserves the map. + */ +export const SKILL_MODEL_CLASS_METADATA_KEY = "model-class"; + +export interface ModelClassTarget { + model: string; + /** Deferred: numeric indices are model-relative until resolved. */ + thinkingLevel?: ParsedThinkingInput; +} + +/** + * Parse a `modelClasses` config value. Values use the one-shot override + * syntax: a model alias or full "provider:model" id with an optional + * `+thinking` suffix ("haiku+0", "sonnet+high", "anthropic:claude-fable-5+max"). + * + * Unlike the composer's one-shot key parser (which only accepts known aliases + * so unknown slash commands aren't swallowed), full model ids are accepted + * here — config values are explicit user intent with no shadowing risk. + * + * Returns null when the model or thinking part is invalid (callers fail open). + */ +export function parseModelClassValue(value: string): ModelClassTarget | null { + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + + const plusIndex = trimmed.indexOf("+"); + const modelPart = plusIndex === -1 ? trimmed : trimmed.slice(0, plusIndex); + const thinkingPart = plusIndex === -1 ? null : trimmed.slice(plusIndex + 1); + + const normalized = normalizeModelInput(modelPart); + if (normalized.model == null) { + return null; + } + + if (thinkingPart == null) { + return { model: normalized.model }; + } + + const thinkingLevel = parseThinkingInput(thinkingPart); + if (thinkingLevel == null) { + return null; + } + + return { model: normalized.model, thinkingLevel }; +} + +/** + * Split a class value into its model part and raw thinking suffix. The raw + * suffix is what follows the first "+" verbatim: editors preserve it across + * model changes so a model-relative numeric level ("+0" = lowest allowed) + * keeps its meaning on the new model. + */ +export function splitModelClassValue(value: string): { + modelPart: string; + thinkingSuffix: string | null; +} { + const plusIndex = value.indexOf("+"); + if (plusIndex === -1) { + return { modelPart: value, thinkingSuffix: null }; + } + return { modelPart: value.slice(0, plusIndex), thinkingSuffix: value.slice(plusIndex + 1) }; +} + +/** Inverse of splitModelClassValue: build a `model[+thinking]` class value. */ +export function buildModelClassValue(model: string, thinkingSuffix: string | null): string { + return thinkingSuffix ? `${model}+${thinkingSuffix}` : model; +} + +/** + * Class names surfaced as fixed slots in the Settings → Models editor. The + * config map accepts arbitrary names (hand-edited custom classes are preserved + * and keep routing), but skills are portable across machines only when they + * bind to this shared vocabulary. + */ +export const CANONICAL_MODEL_CLASSES = ["large", "medium", "small"] as const; + +/** + * Strict-on-write sanitization for the modelClasses map (mirrors + * sanitizeModelFallbacks): trim class names, drop empty names and values that + * don't parse as `model[+thinking]`, so the send path never reads a class it + * cannot resolve. Reads stay lenient/fail-open. + */ +export function sanitizeModelClasses(map: Record): Record { + const out: Record = {}; + for (const [rawName, rawValue] of Object.entries(map)) { + const name = rawName.trim(); + if (!name) { + continue; + } + const value = typeof rawValue === "string" ? rawValue.trim() : ""; + if (!value || parseModelClassValue(value) == null) { + continue; + } + out[name] = value; + } + return out; +} + +export type SkillModelClassBinding = + | { status: "unbound" } + | { status: "unknown-class"; className: string } + | { status: "invalid-value"; className: string; value: string } + | { status: "resolved"; className: string; model: string; thinkingLevel?: ThinkingLevel }; + +/** + * Resolve the model-class binding for a slash-invoked skill. + * + * Class-binding precedence: the config-side routing table + * (`skillModelClasses[skillName]`) wins over the skill's own frontmatter + * `metadata["model-class"]` — local config is explicit user intent and works + * for skills the user does not own. + * + * Broken bindings are reported, not swallowed: a bound skill whose class is + * missing or malformed returns a distinct status so callers can raise an + * actionable error instead of silently running on an unintended (possibly + * expensive) model. One deliberate exception: frontmatter bindings are inert + * while the user has no `modelClasses` configured at all — skills shipping + * `metadata: model-class` must not break for users who never opted into model + * classes. A config-table binding is explicit local intent and always counts. + */ +export function resolveSkillModelClassBinding(args: { + skillName: string; + frontmatterMetadata?: Record; + modelClasses?: Record; + skillModelClasses?: Record; + providersConfig?: ProvidersConfigMap | null; +}): SkillModelClassBinding { + const tableClass = args.skillModelClasses?.[args.skillName]; + const boundViaTable = typeof tableClass === "string" && tableClass.trim().length > 0; + const rawClass = boundViaTable + ? tableClass + : args.frontmatterMetadata?.[SKILL_MODEL_CLASS_METADATA_KEY]; + const className = typeof rawClass === "string" ? rawClass.trim() : ""; + if (!className) { + return { status: "unbound" }; + } + + const modelClasses = args.modelClasses ?? {}; + const classValue = modelClasses[className]; + if (typeof classValue !== "string") { + if (!boundViaTable && Object.keys(modelClasses).length === 0) { + return { status: "unbound" }; + } + return { status: "unknown-class", className }; + } + + const target = parseModelClassValue(classValue); + if (!target) { + return { status: "invalid-value", className, value: classValue }; + } + + // Numeric thinking indices are model-relative; resolve to a concrete level + // now. Downstream enforceThinkingPolicy still clamps (min-thinking floors). + const thinkingLevel = + target.thinkingLevel != null + ? resolveThinkingInput(target.thinkingLevel, target.model, args.providersConfig) + : undefined; + + return { + status: "resolved", + className, + model: target.model, + ...(thinkingLevel != null ? { thinkingLevel } : {}), + }; +} + +export type SkillModelClassRoutingProblem = + | { kind: "unknown-class"; skillName: string; className: string } + | { kind: "invalid-value"; skillName: string; className: string; value: string } + | { kind: "model-unavailable"; skillName: string; className: string; model: string }; + +/** + * User-facing message for a broken skill model-class binding. The copy always + * names the fix location and the one-shot bypass so a stale mapping never + * strands the user. + */ +export function describeSkillModelClassRoutingProblem( + problem: SkillModelClassRoutingProblem +): string { + const fixHint = `Update it in Settings → Models → Model Classes, or bypass routing with a one-shot override (e.g. "/sonnet /${problem.skillName}").`; + switch (problem.kind) { + case "unknown-class": + return `Skill "${problem.skillName}" is bound to model class "${problem.className}", but no class with that name is configured. ${fixHint}`; + case "invalid-value": + return `Model class "${problem.className}" (used by skill "${problem.skillName}") has an invalid value "${problem.value}". ${fixHint}`; + case "model-unavailable": + return `Model class "${problem.className}" (used by skill "${problem.skillName}") maps to "${problem.model}", but no configured provider route can serve it. ${fixHint}`; + } +} diff --git a/src/node/config.modelClasses.test.ts b/src/node/config.modelClasses.test.ts new file mode 100644 index 00000000000..c4231485869 --- /dev/null +++ b/src/node/config.modelClasses.test.ts @@ -0,0 +1,58 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; + +import { Config } from "@/node/config"; + +describe("Config model classes persistence", () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "mux-model-classes-")); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it("round-trips modelClasses and skillModelClasses through editConfig saves", async () => { + const config = new Config(tempDir); + await config.editConfig((cfg) => ({ + ...cfg, + modelClasses: { small: "haiku+0", large: "anthropic:claude-fable-5+max" }, + skillModelClasses: { done: "small" }, + })); + + // A fresh instance re-reads from disk: the fields must survive the + // whitelist-based saveConfig serialization. + const reloaded = new Config(tempDir).loadConfigOrDefault(); + expect(reloaded.modelClasses).toEqual({ + small: "haiku+0", + large: "anthropic:claude-fable-5+max", + }); + expect(reloaded.skillModelClasses).toEqual({ done: "small" }); + + // An unrelated edit (another full save cycle) must not strip them. + const second = new Config(tempDir); + await second.editConfig((cfg) => ({ ...cfg, defaultModel: "anthropic:claude-opus-5" })); + const reloadedAgain = new Config(tempDir).loadConfigOrDefault(); + expect(reloadedAgain.modelClasses?.small).toBe("haiku+0"); + expect(reloadedAgain.skillModelClasses?.done).toBe("small"); + }); + + it("drops non-string entries on load instead of failing (self-healing)", async () => { + await fs.writeFile( + path.join(tempDir, "config.json"), + JSON.stringify({ + projects: [], + modelClasses: { small: "haiku+0", bad: 42 }, + skillModelClasses: 7, + }) + ); + + const loaded = new Config(tempDir).loadConfigOrDefault(); + expect(loaded.modelClasses).toEqual({ small: "haiku+0" }); + expect(loaded.skillModelClasses).toBeUndefined(); + }); +}); diff --git a/src/node/config.ts b/src/node/config.ts index 89ff57b5816..60bd62a883f 100644 --- a/src/node/config.ts +++ b/src/node/config.ts @@ -1243,6 +1243,11 @@ export class Config { const modelFallbacks = normalizeModelFallbacks(parsed.modelFallbacks); + // Values validated at use time (resolveSkillModelClass fails open), so a + // malformed entry never breaks config load — self-healing on read. + const modelClasses = parseOptionalStringRecord(parsed.modelClasses); + const skillModelClasses = parseOptionalStringRecord(parsed.skillModelClasses); + const defaultModel = normalizeOptionalModelString(parsed.defaultModel); const advisorModelString = parseOptionalNonEmptyString(parsed.advisorModelString); const advisorThinkingLevel = parseOptionalThinkingLevel(parsed.advisorThinkingLevel); @@ -1389,6 +1394,8 @@ export class Config { routeOverrides, minThinkingLevelByModel, modelFallbacks, + modelClasses, + skillModelClasses, defaultModel, advisorModelString, advisorThinkingLevel, @@ -1583,6 +1590,16 @@ export class Config { data.modelFallbacks = modelFallbacks; } + const modelClasses = parseOptionalStringRecord(config.modelClasses); + if (modelClasses !== undefined) { + data.modelClasses = modelClasses; + } + + const skillModelClasses = parseOptionalStringRecord(config.skillModelClasses); + if (skillModelClasses !== undefined) { + data.skillModelClasses = skillModelClasses; + } + const apiServerBindHost = parseOptionalNonEmptyString(config.apiServerBindHost); if (apiServerBindHost) { data.apiServerBindHost = apiServerBindHost; diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index a94fcada3d1..57329a1aeff 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -76,6 +76,7 @@ import { normalizeUserPreferences } from "@/common/config/schemas/userPreference import { normalizeAgentAiDefaults } from "@/common/types/agentAiDefaults"; import { isValidModelFormat, normalizeSelectedModel } from "@/common/utils/ai/models"; import { sanitizeModelFallbacks } from "@/common/utils/ai/modelFallbacks"; +import { sanitizeModelClasses } from "@/common/utils/ai/skillModelClasses"; import { DEFAULT_TASK_SETTINGS, deriveLegacySubagentAiDefaultsFromAgentDefaults, @@ -1063,6 +1064,7 @@ export const router = (authToken?: string) => { routeOverrides: config.routeOverrides, minThinkingLevelByModel: config.minThinkingLevelByModel, modelFallbacks: config.modelFallbacks, + modelClasses: config.modelClasses, defaultModel: config.defaultModel, advisorModelString: config.advisorModelString ?? null, advisorThinkingLevel: config.advisorThinkingLevel ?? null, @@ -1237,6 +1239,18 @@ export const router = (authToken?: string) => { modelFallbacks: Object.keys(sanitized).length > 0 ? sanitized : undefined, })); }), + updateModelClasses: t + .input(schemas.config.updateModelClasses.input) + .output(schemas.config.updateModelClasses.output) + .handler(async ({ context, input }) => { + // Full-map replacement. Strict-on-write: unparseable class values are + // dropped so skill routing never reads a class it cannot resolve. + const sanitized = sanitizeModelClasses(input.modelClasses); + await context.config.editConfig((config) => ({ + ...config, + modelClasses: Object.keys(sanitized).length > 0 ? sanitized : undefined, + })); + }), updateModelPreferences: t .input(schemas.config.updateModelPreferences.input) .output(schemas.config.updateModelPreferences.output) diff --git a/src/node/services/agentSession.skillModelRouting.test.ts b/src/node/services/agentSession.skillModelRouting.test.ts new file mode 100644 index 00000000000..aa48b211d3c --- /dev/null +++ b/src/node/services/agentSession.skillModelRouting.test.ts @@ -0,0 +1,263 @@ +import { afterEach, describe, expect, it, mock } from "bun:test"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; + +import { KNOWN_MODELS } from "@/common/constants/knownModels"; +import { Ok } from "@/common/types/result"; +import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; +import type { Config } from "@/node/config"; +import type { AIService, StreamMessageOptions } from "@/node/services/aiService"; + +import { createAgentSessionHarness } from "./agentSession.testHarness"; + +const USER_MODEL = "anthropic:claude-fable-5"; + +describe("AgentSession.sendMessage (per-skill model routing)", () => { + let historyCleanup: (() => Promise) | undefined; + afterEach(async () => { + await historyCleanup?.(); + }); + + async function createWorkspaceWithSkill(args: { skillName: string; metadataYaml?: string }) { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "mux-skill-routing-")); + const skillDir = path.join(tmp, ".mux", "skills", args.skillName); + await fs.mkdir(skillDir, { recursive: true }); + const skillMarkdown = `---\nname: ${args.skillName}\ndescription: Test skill\n${args.metadataYaml ?? ""}---\n\nDo the thing.\n`; + await fs.writeFile(path.join(skillDir, "SKILL.md"), skillMarkdown, "utf-8"); + return tmp; + } + + async function createRoutingHarness(args: { + workspacePath: string; + configValues?: { + modelClasses?: Record; + skillModelClasses?: Record; + routePriority?: string[]; + }; + /** When provided, getProvidersConfigSafe sees this map (enables the availability check). */ + providersConfig?: Record; + }) { + const workspaceId = "ws-skill-routing"; + const workspaceMeta = { + id: workspaceId, + name: "ws", + projectName: "proj", + projectPath: args.workspacePath, + namedWorkspacePath: args.workspacePath, + runtimeConfig: { type: "local" }, + } as unknown as FrontendWorkspaceMetadata; + + const streamed: StreamMessageOptions[] = []; + const streamMessage = mock((opts: StreamMessageOptions) => { + streamed.push(opts); + return Promise.resolve(Ok(undefined)); + }); + + const config = { + srcDir: "/tmp", + getSessionDir: mock((_workspaceId: string) => "/tmp"), + loadConfigOrDefault: mock(() => ({ ...args.configValues })), + } as unknown as Config; + + const { session, cleanup } = await createAgentSessionHarness({ + workspaceId, + config, + aiServiceOverrides: { + getWorkspaceMetadata: mock((_id: string) => Promise.resolve(Ok(workspaceMeta))), + streamMessage: streamMessage as unknown as AIService["streamMessage"], + ...(args.providersConfig != null + ? { getProvidersConfig: mock(() => args.providersConfig) } + : {}), + } as unknown as Partial, + }); + historyCleanup = cleanup; + return { session, streamed }; + } + + function skillSendOptions(overrides?: Record) { + return { + model: USER_MODEL, + agentId: "exec", + muxMetadata: { + type: "agent-skill", + rawCommand: "/done", + skillName: "done", + scope: "project", + }, + ...overrides, + }; + } + + it("streams a metadata-bound skill on its class model with resolved thinking", async () => { + const workspacePath = await createWorkspaceWithSkill({ + skillName: "done", + metadataYaml: "metadata:\n model-class: small\n", + }); + const { session, streamed } = await createRoutingHarness({ + workspacePath, + configValues: { modelClasses: { small: "haiku+0" } }, + }); + + const result = await session.sendMessage("Use skill done", skillSendOptions()); + expect(result.success).toBe(true); + expect(streamed).toHaveLength(1); + expect(streamed[0].modelString).toBe(KNOWN_MODELS.HAIKU.id); + // "+0" is model-relative: haiku's lowest allowed level is "off". + expect(streamed[0].thinkingLevel).toBe("off"); + session.dispose(); + }); + + it("lets the config skillModelClasses table win over frontmatter metadata", async () => { + const workspacePath = await createWorkspaceWithSkill({ + skillName: "done", + metadataYaml: "metadata:\n model-class: small\n", + }); + const { session, streamed } = await createRoutingHarness({ + workspacePath, + configValues: { + modelClasses: { small: "haiku+0", big: "anthropic:claude-opus-5+high" }, + skillModelClasses: { done: "big" }, + }, + }); + + const result = await session.sendMessage("Use skill done", skillSendOptions()); + expect(result.success).toBe(true); + expect(streamed[0].modelString).toBe(KNOWN_MODELS.OPUS.id); + expect(streamed[0].thinkingLevel).toBe("high"); + session.dispose(); + }); + + it("routes a table-bound skill that has no frontmatter metadata", async () => { + const workspacePath = await createWorkspaceWithSkill({ skillName: "done" }); + const { session, streamed } = await createRoutingHarness({ + workspacePath, + configValues: { + modelClasses: { small: "haiku+0" }, + skillModelClasses: { done: "small" }, + }, + }); + + const result = await session.sendMessage("Use skill done", skillSendOptions()); + expect(result.success).toBe(true); + expect(streamed[0].modelString).toBe(KNOWN_MODELS.HAIKU.id); + session.dispose(); + }); + + it("never re-routes sends that carry an explicit override (skipAiSettingsPersistence)", async () => { + const workspacePath = await createWorkspaceWithSkill({ + skillName: "done", + metadataYaml: "metadata:\n model-class: small\n", + }); + const { session, streamed } = await createRoutingHarness({ + workspacePath, + configValues: { modelClasses: { small: "haiku+0" } }, + }); + + const result = await session.sendMessage( + "Use skill done", + skillSendOptions({ skipAiSettingsPersistence: true }) + ); + expect(result.success).toBe(true); + expect(streamed[0].modelString).toBe(USER_MODEL); + expect(streamed[0].thinkingLevel).toBeUndefined(); + session.dispose(); + }); + + it("fails the send with an actionable error when the bound class is not configured", async () => { + const workspacePath = await createWorkspaceWithSkill({ + skillName: "done", + metadataYaml: "metadata:\n model-class: tiny\n", + }); + const { session, streamed } = await createRoutingHarness({ + workspacePath, + configValues: { modelClasses: { small: "haiku+0" } }, + }); + + const result = await session.sendMessage("Use skill done", skillSendOptions()); + expect(result.success).toBe(false); + // The error must name the class so the user knows which mapping to fix. + const raw = !result.success && result.error.type === "unknown" ? result.error.raw : ""; + expect(raw).toContain('"tiny"'); + expect(streamed).toHaveLength(0); + session.dispose(); + }); + + it("fails the send with an actionable error when the class value is invalid", async () => { + const workspacePath = await createWorkspaceWithSkill({ + skillName: "done", + metadataYaml: "metadata:\n model-class: small\n", + }); + const { session, streamed } = await createRoutingHarness({ + workspacePath, + // Hand-edited config can hold values the strict-on-write path would + // have rejected; the send must not silently ignore them. + configValues: { modelClasses: { small: "not-a-model" } }, + }); + + const result = await session.sendMessage("Use skill done", skillSendOptions()); + expect(result.success).toBe(false); + const raw = !result.success && result.error.type === "unknown" ? result.error.raw : ""; + expect(raw).toContain('"small"'); + expect(streamed).toHaveLength(0); + session.dispose(); + }); + + it("fails the send when no configured route can serve the class model", async () => { + const workspacePath = await createWorkspaceWithSkill({ + skillName: "done", + metadataYaml: "metadata:\n model-class: small\n", + }); + const { session, streamed } = await createRoutingHarness({ + workspacePath, + configValues: { modelClasses: { small: "haiku+0" }, routePriority: ["direct"] }, + providersConfig: { anthropic: { isConfigured: false } }, + }); + + const result = await session.sendMessage("Use skill done", skillSendOptions()); + expect(result.success).toBe(false); + const raw = !result.success && result.error.type === "unknown" ? result.error.raw : ""; + expect(raw).toContain(KNOWN_MODELS.HAIKU.id); + expect(streamed).toHaveLength(0); + session.dispose(); + }); + + it("routes normally when the class model has a configured route", async () => { + const workspacePath = await createWorkspaceWithSkill({ + skillName: "done", + metadataYaml: "metadata:\n model-class: small\n", + }); + const { session, streamed } = await createRoutingHarness({ + workspacePath, + configValues: { modelClasses: { small: "haiku+0" }, routePriority: ["direct"] }, + providersConfig: { anthropic: { isConfigured: true, isEnabled: true } }, + }); + + const result = await session.sendMessage("Use skill done", skillSendOptions()); + expect(result.success).toBe(true); + expect(streamed[0].modelString).toBe(KNOWN_MODELS.HAIKU.id); + session.dispose(); + }); + + it("leaves non-skill sends untouched even with routing configured", async () => { + const workspacePath = await createWorkspaceWithSkill({ + skillName: "done", + metadataYaml: "metadata:\n model-class: small\n", + }); + const { session, streamed } = await createRoutingHarness({ + workspacePath, + configValues: { + modelClasses: { small: "haiku+0" }, + skillModelClasses: { done: "small" }, + }, + }); + + const result = await session.sendMessage("plain message", { + model: USER_MODEL, + agentId: "exec", + }); + expect(result.success).toBe(true); + expect(streamed[0].modelString).toBe(USER_MODEL); + session.dispose(); + }); +}); diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 8e421446619..58352e8d1c9 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -131,6 +131,11 @@ import { import { createDisplayUsage } from "@/common/utils/tokens/displayUsage"; import { readAgentSkill } from "@/node/services/agentSkills/agentSkillsService"; import { resolveSkillStorageContext } from "@/node/services/agentSkills/skillStorageContext"; +import { + describeSkillModelClassRoutingProblem, + resolveSkillModelClassBinding, +} from "@/common/utils/ai/skillModelClasses"; +import { isModelServableWithProvidersConfig } from "@/common/utils/ai/modelAvailability"; import { createLoadedSkillSnapshot, extractLoadedSkillSnapshotsFromMessages, @@ -2906,6 +2911,34 @@ export class AgentSession { ...(delegatedToolNames != null ? { delegatedToolNames } : {}), }); + // Per-skill model routing. Applied before the user message is created so + // startup retries (retrySendOptions) replay the routed model, and before + // the compaction threshold check so context-limit math uses the model that + // will actually stream. preRoutingOptions feeds the compaction REQUEST + // below: a turn routed to a small model must never compact on that small + // model — the compaction model has to fit the full uncompacted history. + const preRoutingOptions = optionsForStream; + const skillModelOverride = await this.resolveSkillModelClassOverride( + typedMuxMetadata, + optionsForStream + ); + // A bound-but-broken class mapping fails the send before anything is + // persisted: the user gets an actionable error (update the mapping or + // bypass with a one-shot) instead of a silent run on the wrong model. + if (skillModelOverride?.kind === "config-error") { + return Err(createUnknownSendMessageError(skillModelOverride.message)); + } + if (skillModelOverride != null) { + modelForStream = skillModelOverride.model; + optionsForStream = { + ...optionsForStream, + model: skillModelOverride.model, + ...(skillModelOverride.thinkingLevel != null + ? { thinkingLevel: skillModelOverride.thinkingLevel } + : {}), + }; + } + const userMessage = createMuxMessage( messageId, "user", @@ -3012,7 +3045,11 @@ export class AgentSession { const autoCompactionRequest = this.buildAutoCompactionRequest({ followUpContent, - baseOptions: optionsForStream, + // Pre-routing options: the compaction request must inherit the user's + // model (able to read the full history), never a per-skill routed + // small model. The deferred follow-up re-enters sendMessage with the + // same skill metadata and re-routes itself. + baseOptions: preRoutingOptions, reason: "on-send", }); @@ -6308,6 +6345,230 @@ export class AgentSession { return { snapshotMessage, materializedTokens: tokens }; } + /** + * Build a reader that resolves a skill package with the same roots and + * precedence as skill discovery for this workspace. Shared by snapshot + * materialization and per-skill model routing so both resolve identically. + */ + private buildSkillReader(args: { + metadata: WorkspaceMetadata; + runtime: Runtime; + workspacePath: string; + disableWorkspaceAgents: boolean | undefined; + }): (skillName: string) => Promise>> { + // When workspace agents are disabled, resolve skills from the project path instead of + // the worktree so skill invocation uses the same precedence/discovery root as the UI. + const skillDiscoveryPath = args.disableWorkspaceAgents + ? args.metadata.projectPath + : args.workspacePath; + + // claude-skills-compat experiment: resolve slash-invoked skills with the same + // roots as discovery. Guard for test mocks that may not implement the gate. + const includeClaudeSkills = + typeof this.aiService.isClaudeSkillsCompatEnabled === "function" && + this.aiService.isClaudeSkillsCompatEnabled(); + // agent-plugins experiment: same treatment for plugin-provided skills. + const includeAgentPlugins = + typeof this.aiService.isAgentPluginsEnabled === "function" && + this.aiService.isAgentPluginsEnabled(); + // agent-plugins experiment: resolve host-local project workspaces through + // the same storage context as the skill read tool so checkout-level + // plugin containers stay reachable — for subProjectPath workspaces the + // execution path is a subdirectory of the checkout and default + // discovery misses them. disableWorkspaceAgents keeps default + // discovery: it anchors at projectPath, which is already the + // checkout-level root the UI lists in that mode. + const muxScope = + !args.disableWorkspaceAgents && + typeof this.aiService.resolveMuxToolScopeForWorkspace === "function" + ? this.aiService.resolveMuxToolScopeForWorkspace( + args.metadata, + args.runtime, + args.workspacePath + ) + : null; + const skillCtx = + muxScope?.type === "project" && muxScope.projectStorageAuthority === "host-local" + ? resolveSkillStorageContext({ + runtime: args.runtime, + workspacePath: skillDiscoveryPath, + muxScope, + includeClaudeSkills, + includeAgentPlugins, + }) + : null; + return (skillName: string) => + readAgentSkill( + skillCtx?.runtime ?? args.runtime, + skillCtx?.workspacePath ?? skillDiscoveryPath, + skillName, + { + ...(skillCtx != null ? { roots: skillCtx.roots, containment: skillCtx.containment } : {}), + includeClaudeSkills, + includeAgentPlugins, + } + ); + } + + /** + * Per-skill model routing: a slash-invoked skill bound to a model class + * (config `skillModelClasses` table, else skill frontmatter metadata + * "model-class") streams on the class's model for this send only. + * + * Explicit overrides win: sends carrying skipAiSettingsPersistence (one-shot + * /model commands, compaction requests) are never re-routed. Workspace AI + * settings are untouched: persistence happens in WorkspaceService (with the + * user's model) before this runs. + * + * Error posture: a *bound* skill whose routing cannot be delivered — unknown + * class, malformed class value, or a class model no configured route can + * serve — returns a config-error so the send fails with an actionable + * message instead of silently streaming on an unintended (often expensive) + * model. Unbound skills route nothing, and infrastructure failures (config + * or skill unreadable, providers state unavailable) still fail open: those + * are not user mapping mistakes, and a skill send must survive them. + */ + private async resolveSkillModelClassOverride( + muxMetadata: MuxMessageMetadata | undefined, + options: SendMessageOptions + ): Promise< + | { kind: "override"; className: string; model: string; thinkingLevel?: ThinkingLevel } + | { kind: "config-error"; message: string } + | null + > { + if (options.skipAiSettingsPersistence === true) { + return null; + } + if (muxMetadata?.type !== "agent-skill") { + return null; + } + + try { + // Defensive config access mirroring getPreferredCompactionSettings: test + // harnesses may provide a partial Config. + const maybeConfig = this.config as Config & { + loadConfigOrDefault?: () => { + modelClasses?: Record; + skillModelClasses?: Record; + routePriority?: string[]; + routeOverrides?: Record; + } | null; + }; + if (typeof maybeConfig.loadConfigOrDefault !== "function") { + return null; + } + const cfg = maybeConfig.loadConfigOrDefault(); + const modelClasses = cfg?.modelClasses; + const skillModelClasses = cfg?.skillModelClasses; + + const skillName = muxMetadata.skillName; + if (!SkillNameSchema.safeParse(skillName).success) { + return null; + } + + // Fast path: with no classes configured and no table binding for this + // skill, routing can never apply — skip the (possibly remote) SKILL.md + // frontmatter read entirely. + const hasModelClasses = modelClasses != null && Object.keys(modelClasses).length > 0; + const hasTableBinding = typeof skillModelClasses?.[skillName] === "string"; + if (!hasModelClasses && !hasTableBinding) { + return null; + } + + // Read frontmatter only when the config table doesn't already bind the + // skill — skips a (possibly remote) SKILL.md read when unnecessary. + let frontmatterMetadata: Record | undefined; + if (!hasTableBinding) { + if (typeof this.aiService.getWorkspaceMetadata !== "function") { + return null; + } + const metadataResult = await this.aiService.getWorkspaceMetadata(this.workspaceId); + if (!metadataResult.success) { + return null; + } + const { runtime, workspacePath } = createRuntimeContextForWorkspace(metadataResult.data); + const resolved = await this.buildSkillReader({ + metadata: metadataResult.data, + runtime, + workspacePath, + disableWorkspaceAgents: options.disableWorkspaceAgents, + })(skillName); + frontmatterMetadata = resolved.package.frontmatter.metadata; + } + + const providersConfig = this.getProvidersConfigSafe(); + const binding = resolveSkillModelClassBinding({ + skillName, + frontmatterMetadata, + modelClasses, + skillModelClasses, + providersConfig, + }); + + switch (binding.status) { + case "unbound": + return null; + case "unknown-class": + return { + kind: "config-error", + message: describeSkillModelClassRoutingProblem({ + kind: "unknown-class", + skillName, + className: binding.className, + }), + }; + case "invalid-value": + return { + kind: "config-error", + message: describeSkillModelClassRoutingProblem({ + kind: "invalid-value", + skillName, + className: binding.className, + value: binding.value, + }), + }; + case "resolved": { + // Availability is a routing-state question (gateways count: a model + // can be servable via OpenRouter without a direct provider key). + // Null providersConfig means "cannot determine", never "unavailable". + if ( + providersConfig != null && + !isModelServableWithProvidersConfig({ + canonicalModel: binding.model, + routePriority: cfg?.routePriority, + routeOverrides: cfg?.routeOverrides, + providersConfig, + }) + ) { + return { + kind: "config-error", + message: describeSkillModelClassRoutingProblem({ + kind: "model-unavailable", + skillName, + className: binding.className, + model: binding.model, + }), + }; + } + + log.debug( + `skill model routing: /${skillName} → class "${binding.className}" → ${binding.model}` + + (binding.thinkingLevel != null ? `+${binding.thinkingLevel}` : "") + ); + return { + kind: "override", + className: binding.className, + model: binding.model, + ...(binding.thinkingLevel != null ? { thinkingLevel: binding.thinkingLevel } : {}), + }; + } + } + } catch (error) { + log.debug(`skill model routing: fail-open for skill send: ${getErrorMessage(error)}`); + return null; + } + } + private async materializeAgentSkillSnapshots( muxMetadata: MuxMessageMetadata | undefined, disableWorkspaceAgents: boolean | undefined @@ -6334,10 +6595,6 @@ export class AgentSession { const metadata = metadataResult.data; const { runtime, workspacePath } = createRuntimeContextForWorkspace(metadata); - // When workspace agents are disabled, resolve skills from the project path instead of - // the worktree so skill invocation uses the same precedence/discovery root as the UI. - const skillDiscoveryPath = disableWorkspaceAgents ? metadata.projectPath : workspacePath; - // Dedupe per skill against recent persisted snapshots. A wider window keeps multi-skill // turns from reloading snapshots that were persisted together on the previous turn. const recentSnapshots: Array<{ skillName: string; sha256: string }> = []; @@ -6366,49 +6623,12 @@ export class AgentSession { let resolved: Awaited>; try { - // claude-skills-compat experiment: resolve slash-invoked skills with the same - // roots as discovery. Guard for test mocks that may not implement the gate. - const includeClaudeSkills = - typeof this.aiService.isClaudeSkillsCompatEnabled === "function" && - this.aiService.isClaudeSkillsCompatEnabled(); - // agent-plugins experiment: same treatment for plugin-provided skills. - const includeAgentPlugins = - typeof this.aiService.isAgentPluginsEnabled === "function" && - this.aiService.isAgentPluginsEnabled(); - // agent-plugins experiment: resolve host-local project workspaces through - // the same storage context as the skill read tool so checkout-level - // plugin containers stay reachable — for subProjectPath workspaces the - // execution path is a subdirectory of the checkout and default - // discovery misses them. disableWorkspaceAgents keeps default - // discovery: it anchors at projectPath, which is already the - // checkout-level root the UI lists in that mode. - const muxScope = - !disableWorkspaceAgents && - typeof this.aiService.resolveMuxToolScopeForWorkspace === "function" - ? this.aiService.resolveMuxToolScopeForWorkspace(metadata, runtime, workspacePath) - : null; - const skillCtx = - muxScope?.type === "project" && muxScope.projectStorageAuthority === "host-local" - ? resolveSkillStorageContext({ - runtime, - workspacePath: skillDiscoveryPath, - muxScope, - includeClaudeSkills, - includeAgentPlugins, - }) - : null; - resolved = await readAgentSkill( - skillCtx?.runtime ?? runtime, - skillCtx?.workspacePath ?? skillDiscoveryPath, - parsedName.data, - { - ...(skillCtx != null - ? { roots: skillCtx.roots, containment: skillCtx.containment } - : {}), - includeClaudeSkills, - includeAgentPlugins, - } - ); + resolved = await this.buildSkillReader({ + metadata, + runtime, + workspacePath, + disableWorkspaceAgents, + })(parsedName.data); } catch (error) { if (ref.source === "slash") { throw error; diff --git a/src/node/services/agentSkills/builtInSkillContent.generated.ts b/src/node/services/agentSkills/builtInSkillContent.generated.ts index 119658ede5f..820f1e78e9d 100644 --- a/src/node/services/agentSkills/builtInSkillContent.generated.ts +++ b/src/node/services/agentSkills/builtInSkillContent.generated.ts @@ -1503,6 +1503,45 @@ export const BUILTIN_SKILL_FILES: Record> = { "", "Use the `argument-hint` frontmatter field to document the expected arguments in invocation UIs.", "", + "## Per-skill model routing", + "", + "Mechanical skills (session wrap-up, worktree helpers, PR chores) rarely need your frontier model. Skill invocations can be routed to a **model class** — an indirection that survives model churn, since bindings name a class and only the class map names concrete models.", + "", + "Configure the three canonical classes — `large`, `medium`, `small` — in **Settings → Models → Model Classes** (a model plus an optional thinking level per class). Canonical names keep skill bindings portable across machines. The classes are stored in `~/.mux/config.json`, where values use the [one-shot override syntax](/config/models#one-shot-overrides): a model alias or full `provider:model` id, with an optional `+thinking` suffix (named level or model-relative numeric index). Hand-edited custom class names in config.json also work and are preserved by the Settings editor:", + "", + "```json", + "{", + ' "modelClasses": {', + ' "large": "fable+max",', + ' "medium": "sonnet+high",', + ' "small": "haiku+0"', + " }", + "}", + "```", + "", + "Bind skills to classes in either of two places:", + "", + "- **Skill frontmatter** — the spec-standard `metadata` map, so the binding travels with the skill and other agent tools ignore it:", + "", + " ```yaml", + " metadata:", + " model-class: small", + " ```", + "", + "- **Config routing table** — for skills you don't own, `skillModelClasses` in `~/.mux/config.json` maps skill names to classes and **wins over frontmatter**:", + "", + " ```json", + " {", + ' "skillModelClasses": { "done": "small", "wt": "small" }', + " }", + " ```", + "", + "Routing applies to the slash invocation's send only: the workspace's selected model is untouched, and your next message streams on it again. If auto-compaction triggers, the threshold is computed against the routed model's context window, while the compaction request itself keeps your model (it must fit the uncompacted history).", + "", + "Broken bindings fail loudly: when a skill is bound to a class that isn't configured, the class value is malformed, or no configured provider route can serve the class's model (a retired model, a removed provider or key), the send fails with an error naming the mapping to fix — and the Model Classes editor shows the same \"no configured route\" warning inline. Two deliberate exceptions keep skills portable and resilient: frontmatter bindings are ignored entirely while you have no model classes configured at all, and infrastructure hiccups (an unreadable skill or config) fall back to the workspace model instead of failing the send.", + "", + "To override routing for one invocation, compose a one-shot prefix with the skill: `/sonnet+high /done` runs the skill on Sonnet regardless of its class. Explicit one-shots always win over class routing.", + "", "## Dynamic context injection (experiment)", "", "Enable the **Skill dynamic context injection** experiment (Settings → Experiments) to let skills pull live command output into their instructions. When you invoke a skill, any line whose entire content is `` !`command` `` runs in the workspace, and the line is replaced with a fenced block containing the command’s output before the model sees the skill:", @@ -3247,12 +3286,15 @@ export const BUILTIN_SKILL_FILES: Record> = { "", "### Syntax", "", - "| Command | Effect |", - "| --------------------------- | ---------------------------------------- |", - "| `/sonnet explain this code` | Use Sonnet for one message |", - "| `/opus+high deep review` | Use Opus with high thinking |", - "| `/haiku+0 quick answer` | Use Haiku at its lowest thinking level |", - "| `/+2 analyze this` | Keep current model, set thinking level 2 |", + "| Command | Effect |", + "| --------------------------- | ------------------------------------------- |", + "| `/sonnet explain this code` | Use Sonnet for one message |", + "| `/opus+high deep review` | Use Opus with high thinking |", + "| `/haiku+0 quick answer` | Use Haiku at its lowest thinking level |", + "| `/+2 analyze this` | Keep current model, set thinking level 2 |", + "| `/haiku+0 /done` | Run the `done` skill on Haiku for this send |", + "", + "One-shot prefixes compose with [skill invocations](/agents/agent-skills): `/haiku+0 /done cleanup` invokes the skill normally (arguments, snapshots) while overriding the model for that send. An explicit one-shot also wins over the skill's own [model-class routing](/agents/agent-skills#per-skill-model-routing).", "", "### Thinking levels", "", From 260780db67e63496eebd064f400c9bfa842b8d9a Mon Sep 17 00:00:00 2001 From: asm <113964+asm@users.noreply.github.com> Date: Thu, 13 Aug 2026 20:03:44 -0700 Subject: [PATCH 02/17] =?UTF-8?q?=F0=9F=A4=96=20fix:=20address=20review=20?= =?UTF-8?q?findings=20in=20skill=20model-class=20routing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Send path: - Resolve the skill model-class override before the pricing gate, PDF preflight, and history mutations so a broken binding can no longer charge gates against the wrong model or persist side effects before erroring. - Route compaction follow-ups with the pre-routing options so a routed skill send that triggers auto-compaction resumes on the original model/thinking, and only compact routed sends at >=100% usage. - Add a dedicated skipSkillModelRouting wire flag instead of overloading skipAiSettingsPersistence; one-shot model overrides set it explicitly and compaction retries re-derive it from the original message text. - One-shot composed sends now carry the full command prefix in muxMetadata so transcript badges render the model prefix. Binding semantics: - Frontmatter bindings to an unconfigured class are inert (portable skills can ship model-class metadata without breaking sends); a dangling skillModelClasses table entry still errors loudly since the table is the user's explicit routing intent. Blank table entries are treated as unbound. Settings/editor: - Store model-class maps verbatim instead of sanitizing away entries this build cannot parse, so edits from an older/newer build no longer destroy unknown classes. - Gate class edits and the unroutable-model warning on config/routing load completion to avoid clobbering state during the initial fetch. - Carry thinking suffixes across model swaps only when the new model's policy supports them; show raw invalid values in a tooltip. - Share provider/gateway servability predicates between the editor warning and the send-time gate so the two can't drift. Co-Authored-By: Claude Fable 5 --- docs/agents/agent-skills.mdx | 4 +- src/browser/features/ChatInput/index.tsx | 14 +- src/browser/features/ChatInput/utils.ts | 10 +- .../Settings/Sections/ModelClassesEditor.tsx | 56 ++++++-- .../Sections/ModelClassesEditor.ui.test.tsx | 19 +++ src/browser/hooks/useCompactAndRetry.ts | 12 ++ src/browser/hooks/useModelClasses.ts | 30 ++-- src/browser/hooks/useRouting.ts | 29 ++-- src/common/orpc/schemas/api.ts | 6 +- src/common/orpc/schemas/stream.ts | 7 + src/common/types/message.ts | 9 ++ src/common/utils/ai/modelAvailability.ts | 49 +++++-- src/common/utils/ai/skillModelClasses.test.ts | 39 ++--- src/common/utils/ai/skillModelClasses.ts | 32 ++--- src/node/config.ts | 5 +- src/node/orpc/router.ts | 13 +- .../agentSession.skillModelRouting.test.ts | 81 ++++++++++- src/node/services/agentSession.ts | 133 +++++++++++++----- .../builtInSkillContent.generated.ts | 4 +- 19 files changed, 410 insertions(+), 142 deletions(-) diff --git a/docs/agents/agent-skills.mdx b/docs/agents/agent-skills.mdx index 83f254d17ff..7a3181bac3c 100644 --- a/docs/agents/agent-skills.mdx +++ b/docs/agents/agent-skills.mdx @@ -275,9 +275,9 @@ Bind skills to classes in either of two places: Routing applies to the slash invocation's send only: the workspace's selected model is untouched, and your next message streams on it again. If auto-compaction triggers, the threshold is computed against the routed model's context window, while the compaction request itself keeps your model (it must fit the uncompacted history). -Broken bindings fail loudly: when a skill is bound to a class that isn't configured, the class value is malformed, or no configured provider route can serve the class's model (a retired model, a removed provider or key), the send fails with an error naming the mapping to fix — and the Model Classes editor shows the same "no configured route" warning inline. Two deliberate exceptions keep skills portable and resilient: frontmatter bindings are ignored entirely while you have no model classes configured at all, and infrastructure hiccups (an unreadable skill or config) fall back to the workspace model instead of failing the send. +Broken bindings fail loudly: when a bound class exists but its value is malformed, or no configured provider route can serve its model (a retired model, a removed provider or key), the send fails with an error naming the mapping to fix — and the Model Classes editor shows the same "no configured route" warning inline. A dangling `skillModelClasses` table entry (naming a class you deleted) also errors, since the table is your own explicit routing intent. Frontmatter bindings to a class you never defined are simply ignored, so skills you don't own can ship `model-class` metadata without ever breaking your sends; infrastructure hiccups (an unreadable skill or config) likewise fall back to the workspace model instead of failing the send. -To override routing for one invocation, compose a one-shot prefix with the skill: `/sonnet+high /done` runs the skill on Sonnet regardless of its class. Explicit one-shots always win over class routing. +To override routing for one invocation, compose a one-shot prefix with the skill: `/sonnet+high /done` runs the skill on Sonnet regardless of its class. A model-carrying one-shot always wins over class routing; a thinking-only one-shot (`/+2 /done`) layers on top of it — the skill still routes to its class model, at the overridden thinking level. ## Dynamic context injection (experiment) diff --git a/src/browser/features/ChatInput/index.tsx b/src/browser/features/ChatInput/index.tsx index 5b974932a07..24a4f07d743 100644 --- a/src/browser/features/ChatInput/index.tsx +++ b/src/browser/features/ChatInput/index.tsx @@ -2725,11 +2725,20 @@ const ChatInputInner: React.FC = (props) => { } } + // Composed one-shot sends highlight the full "/haiku+0 /done" prefix: + // the transcript badge check requires rawCommand.startsWith(commandPrefix), + // and the combined prefix also keeps the explicit override visible. + const composedPrefixMatch = skillInvocation?.oneShot + ? new RegExp(`^\\S+\\s+/${skillInvocation.descriptor.name}(?=\\s|$)`).exec( + messageText.trim() + ) + : null; const skillMuxMetadata = skillInvocation ? buildSkillInvocationMetadata( appendStagedAttachmentNotice(messageText, sendAttachments), skillInvocation.descriptor, - skillInvocation.argumentText + skillInvocation.argumentText, + composedPrefixMatch?.[0] ) : undefined; @@ -2926,6 +2935,9 @@ const ChatInputInner: React.FC = (props) => { ...(modelOverride ? { model: modelOverride } : {}), ...(thinkingOverride ? { thinkingLevel: thinkingOverride } : {}), ...(oneShotOverride ? { skipAiSettingsPersistence: true } : {}), + // Only a model-carrying one-shot bypasses class routing; a + // thinking-only override (/+2 /skill) layers on top of routing. + ...(modelOverride ? { skipSkillModelRouting: true } : {}), ...(goalInterventionPolicy ? { goalInterventionPolicy } : {}), ...(overrides?.queueDispatchMode ? { queueDispatchMode: overrides.queueDispatchMode } diff --git a/src/browser/features/ChatInput/utils.ts b/src/browser/features/ChatInput/utils.ts index 5e6d3bc5b1c..9881fd5ef01 100644 --- a/src/browser/features/ChatInput/utils.ts +++ b/src/browser/features/ChatInput/utils.ts @@ -56,11 +56,17 @@ function isUnknownSlashCommand(value: ParsedCommand): value is UnknownSlashComma export function buildSkillInvocationMetadata( rawCommand: string, descriptor: AgentSkillDescriptor, - argumentText: string + argumentText: string, + /** + * Overrides the default `/${name}` prefix for composed one-shot invocations + * ("/haiku+0 /done"): the transcript badge only renders when rawCommand + * starts with commandPrefix, so the prefix must include the one-shot token. + */ + commandPrefixOverride?: string ): MuxMessageMetadata { return buildAgentSkillMetadata({ rawCommand, - commandPrefix: `/${descriptor.name}`, + commandPrefix: commandPrefixOverride ?? `/${descriptor.name}`, skillName: descriptor.name, scope: descriptor.scope, arguments: argumentText, diff --git a/src/browser/features/Settings/Sections/ModelClassesEditor.tsx b/src/browser/features/Settings/Sections/ModelClassesEditor.tsx index 84148ae712b..c281a80472f 100644 --- a/src/browser/features/Settings/Sections/ModelClassesEditor.tsx +++ b/src/browser/features/Settings/Sections/ModelClassesEditor.tsx @@ -8,11 +8,16 @@ import { SelectTrigger, SelectValue, } from "@/browser/components/SelectPrimitive/SelectPrimitive"; +import { TooltipIfPresent } from "@/browser/components/Tooltip/Tooltip"; import { useModelClasses } from "@/browser/hooks/useModelClasses"; import { useModelsFromSettings } from "@/browser/hooks/useModelsFromSettings"; import { useProvidersConfig } from "@/browser/hooks/useProvidersConfig"; import { useRouting } from "@/browser/hooks/useRouting"; -import { getThinkingOptionLabel, type ThinkingLevel } from "@/common/types/thinking"; +import { + getThinkingOptionLabel, + parseThinkingInput, + type ThinkingLevel, +} from "@/common/types/thinking"; import { isModelServableWithProvidersConfig } from "@/common/utils/ai/modelAvailability"; import { normalizeToCanonical } from "@/common/utils/ai/models"; import { @@ -46,7 +51,7 @@ const THINKING_DEFAULT_OPTION = "default"; * here. */ export function ModelClassesEditor() { - const { modelClasses, setModelClass } = useModelClasses(); + const { modelClasses, loaded: classesLoaded, setModelClass } = useModelClasses(); const { models } = useModelsFromSettings(); const { config: providersConfig } = useProvidersConfig(); const routing = useRouting(); @@ -67,11 +72,36 @@ export function ModelClassesEditor() { const selectedModel = parsed?.model ?? ""; // Show numeric (model-relative) suffixes as the level they resolve to for // the selected model; re-saving through the select writes the named level. + // providersConfig resolves mappedToModel aliases — the send-path resolver + // passes it too, so the ladder shown here matches what routing will use. const selectedThinking: ThinkingLevel | null = parsed?.thinkingLevel != null && parsed.model - ? resolveThinkingInput(parsed.thinkingLevel, parsed.model) + ? resolveThinkingInput(parsed.thinkingLevel, parsed.model, providersConfig) + : null; + const thinkingOptions = selectedModel + ? getThinkingPolicyForModel(selectedModel, providersConfig) + : []; + // On a model switch, carry the raw thinking suffix only when it stays + // meaningful: numeric suffixes are model-relative by design, named levels + // must exist in the new model's ladder, and an unparseable suffix (the + // "invalid value" repair case) is dropped so picking a model actually + // fixes the row instead of re-persisting a value sanitization-era builds + // would have deleted. + const carrySuffixTo = (nextModel: string): string | null => { + if (parsed == null || thinkingSuffix == null) { + return null; + } + const parsedSuffix = parseThinkingInput(thinkingSuffix); + if (parsedSuffix == null) { + return null; + } + if (typeof parsedSuffix === "number") { + return thinkingSuffix; + } + return getThinkingPolicyForModel(nextModel, providersConfig).includes(parsedSuffix) + ? thinkingSuffix : null; - const thinkingOptions = selectedModel ? getThinkingPolicyForModel(selectedModel) : []; + }; // Ensure the selected model is offerable even if hidden from the picker // list (e.g. a hand-configured custom model). const rowModelCandidates = @@ -80,10 +110,14 @@ export function ModelClassesEditor() { : modelCandidates; // Proactive churn warning: the class points at a model no configured // route can serve (skill sends bound to it will fail with the same - // verdict). Null providersConfig = still loading — say nothing yet. + // verdict). Null providersConfig = still loading — say nothing yet. The + // verdict also waits for routing.loaded: judging against the default + // ["direct"] priority would flash a false warning on gateway-routed + // setups every time Settings opens. const modelUnavailable = parsed != null && providersConfig != null && + routing.loaded && !isModelServableWithProvidersConfig({ canonicalModel: parsed.model, routePriority: routing.routePriority, @@ -104,8 +138,9 @@ export function ModelClassesEditor() {