diff --git a/packages/adapters/claude-local/src/index.ts b/packages/adapters/claude-local/src/index.ts index 61ba77cabe..f5a71e4ea2 100644 --- a/packages/adapters/claude-local/src/index.ts +++ b/packages/adapters/claude-local/src/index.ts @@ -36,19 +36,22 @@ export const label = "Claude Code"; export const SANDBOX_INSTALL_COMMAND = "npm install -g @anthropic-ai/claude-code"; +// Ordered the way the Claude app orders models (#14877): the newest release of each family +// first, by decreasing capability, then older releases grouped by family. The server applies the +// same order to discovered models (server/model-order.ts); the picker keeps it. export const models = [ - { id: "claude-opus-4-8", label: "Claude Opus 4.8" }, - { id: "claude-sonnet-5", label: "Claude Sonnet 5" }, { id: "claude-fable-5-1", label: "Claude Fable 5.1" }, - { id: "claude-fable-5", label: "Claude Fable 5" }, { id: "claude-mythos-5", label: "Claude Mythos 5" }, { id: "claude-opus-5-5", label: "Claude Opus 5.5" }, + { id: "claude-sonnet-5", label: "Claude Sonnet 5" }, + { id: "claude-haiku-4-5", label: "Claude Haiku 4.5" }, + { id: "claude-fable-5", label: "Claude Fable 5" }, { id: "claude-opus-5", label: "Claude Opus 5" }, + { id: "claude-opus-4-8", label: "Claude Opus 4.8" }, { id: "claude-opus-4-7", label: "Claude Opus 4.7" }, { id: "claude-opus-4-6", label: "Claude Opus 4.6" }, { id: "claude-sonnet-4-6", label: "Claude Sonnet 4.6" }, { id: "claude-sonnet-4-5", label: "Claude Sonnet 4.5" }, - { id: "claude-haiku-4-5", label: "Claude Haiku 4.5" }, ]; export const agentConfigurationDoc = `# claude_local agent configuration diff --git a/packages/adapters/claude-local/src/server/model-order.test.ts b/packages/adapters/claude-local/src/server/model-order.test.ts new file mode 100644 index 0000000000..9202d3c697 --- /dev/null +++ b/packages/adapters/claude-local/src/server/model-order.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; +import { models as DIRECT_MODELS } from "../index.js"; +import { parseClaudeModelId, sortClaudeModels } from "./model-order.js"; + +const ids = (models: { id: string }[]) => models.map((model) => model.id); + +describe("sortClaudeModels", () => { + it("keeps the advertised list in the order it is declared", () => { + // The static list is hand-ordered the way the picker shows it; the sort must agree with it. + expect(sortClaudeModels(DIRECT_MODELS)).toEqual(DIRECT_MODELS); + }); + + it("puts the newest release of each family first, then older releases grouped by family", () => { + const discovered = [ + { id: "claude-opus-4-8-20260529", label: "Claude Opus 4.8" }, + { id: "claude-sonnet-4-20250514", label: "Claude Sonnet 4" }, + { id: "claude-3-7-sonnet-20250219", label: "Claude Sonnet 3.7" }, + { id: "claude-haiku-4-5-20251001", label: "Claude Haiku 4.5" }, + { id: "claude-3-5-haiku-20241022", label: "Claude Haiku 3.5" }, + { id: "claude-opus-5-5", label: "Claude Opus 5.5" }, + { id: "claude-opus-4-8", label: "Claude Opus 4.8" }, + { id: "claude-fable-5-1", label: "Claude Fable 5.1" }, + ]; + + expect(ids(sortClaudeModels(discovered))).toEqual([ + // newest release per family, by decreasing capability + "claude-fable-5-1", + "claude-opus-5-5", + "claude-sonnet-4-20250514", + "claude-haiku-4-5-20251001", + // older releases, grouped by family, newest first, alias before dated snapshot + "claude-opus-4-8", + "claude-opus-4-8-20260529", + "claude-3-7-sonnet-20250219", + "claude-3-5-haiku-20241022", + ]); + }); + + it("orders dated snapshots of one release newest first, after the bare alias", () => { + const snapshots = [ + { id: "claude-sonnet-4-5-20250929", label: "Claude Sonnet 4.5 (Sep)" }, + { id: "claude-sonnet-4-5-20251115", label: "Claude Sonnet 4.5 (Nov)" }, + { id: "claude-sonnet-4-5", label: "Claude Sonnet 4.5" }, + ]; + + expect(ids(sortClaudeModels(snapshots))).toEqual([ + "claude-sonnet-4-5", + "claude-sonnet-4-5-20251115", + "claude-sonnet-4-5-20250929", + ]); + }); + + it("reads Bedrock region prefixes and revision suffixes", () => { + const bedrock = [ + { id: "us.anthropic.claude-sonnet-4-5-20250929-v2:0", label: "Bedrock Sonnet 4.5" }, + { id: "us.anthropic.claude-opus-4-6-v1", label: "Bedrock Opus 4.6" }, + { id: "us.anthropic.claude-fable-5-1", label: "Bedrock Fable 5.1" }, + ]; + + expect(ids(sortClaudeModels(bedrock))).toEqual([ + "us.anthropic.claude-fable-5-1", + "us.anthropic.claude-opus-4-6-v1", + "us.anthropic.claude-sonnet-4-5-20250929-v2:0", + ]); + }); + + it("keeps ids it cannot read at the end, in their incoming order", () => { + const mixed = [ + { id: "proxy/custom-model", label: "Custom" }, + { id: "claude-opus-5", label: "Claude Opus 5" }, + { id: "another-custom", label: "Another" }, + ]; + + expect(ids(sortClaudeModels(mixed))).toEqual(["claude-opus-5", "proxy/custom-model", "another-custom"]); + }); +}); + +describe("parseClaudeModelId", () => { + it("reads the current and the legacy id schemes", () => { + expect(parseClaudeModelId("claude-opus-4-8")).toMatchObject({ major: 4, minor: 8, pinned: false }); + expect(parseClaudeModelId("claude-opus-5")).toMatchObject({ major: 5, minor: 0, pinned: false }); + expect(parseClaudeModelId("claude-3-7-sonnet-20250219")).toMatchObject({ major: 3, minor: 7, pinned: true, snapshot: 20250219 }); + expect(parseClaudeModelId("claude-3-5-sonnet-latest")).toMatchObject({ major: 3, minor: 5, pinned: false }); + expect(parseClaudeModelId("claude-sonnet-5[1m]")).toMatchObject({ major: 5, minor: 0 }); + expect(parseClaudeModelId("us.anthropic.claude-opus-4-6-v1")).toMatchObject({ major: 4, minor: 6, pinned: true }); + }); + + it("returns null for ids that are not Claude models", () => { + expect(parseClaudeModelId("gpt-6-astra")).toBeNull(); + expect(parseClaudeModelId("claude-instant-1.2")).toBeNull(); + expect(parseClaudeModelId("arn:aws:bedrock:us-east-1:123:inference-profile/x")).toBeNull(); + }); +}); diff --git a/packages/adapters/claude-local/src/server/model-order.ts b/packages/adapters/claude-local/src/server/model-order.ts new file mode 100644 index 0000000000..fc051ef4ad --- /dev/null +++ b/packages/adapters/claude-local/src/server/model-order.ts @@ -0,0 +1,102 @@ +import type { AdapterModel } from "@paperclipai/adapter-utils"; + +/** + * Order Claude models the way the Claude app does (#14877): the newest release of each + * family first, by decreasing capability, then every older release grouped the same way + * with versions descending. Ids that do not look like a Claude model keep their incoming + * order at the end of the list, so a new naming scheme is still shown rather than dropped. + */ +const FAMILY_RANK: Readonly> = { + fable: 0, + mythos: 1, + opus: 2, + sonnet: 3, + haiku: 4, +}; + +const FAMILY_PATTERN = "fable|mythos|opus|sonnet|haiku"; +// Current scheme: claude-opus-4-8, claude-fable-5-1, claude-opus-5. +const MODERN_ID_RE = new RegExp(`^claude-(${FAMILY_PATTERN})-(\\d+)(?:-(\\d+))?$`); +// Legacy scheme: claude-3-7-sonnet, claude-3-5-haiku, claude-3-opus. +const LEGACY_ID_RE = new RegExp(`^claude-(\\d+)(?:-(\\d+))?-(${FAMILY_PATTERN})$`); + +interface ClaudeModelKey { + family: number; + major: number; + minor: number; + /** Dated snapshot (`-20260529`) or Bedrock revision rather than the bare alias. */ + pinned: boolean; + /** Snapshot date as YYYYMMDD, 0 when the id carries none; newer snapshots sort first. */ + snapshot: number; +} + +export function parseClaudeModelId(id: string): ClaudeModelKey | null { + let bare = id.trim().toLowerCase().replace(/\[1m\]$/, ""); + // Bedrock ids: us.anthropic.claude-opus-4-6-v1, us.anthropic.claude-sonnet-4-5-20250929-v2:0 + bare = bare.replace(/^[a-z]+\.anthropic\./, ""); + let pinned = false; + let snapshot = 0; + const revision = bare.match(/^(.*)-v\d+(?::\d+)?$/); + if (revision) { + bare = revision[1]; + pinned = true; + } + const dated = bare.match(/^(.*)-(\d{8})$/); + if (dated) { + bare = dated[1]; + pinned = true; + snapshot = Number(dated[2]); + } + if (bare.endsWith("-latest")) bare = bare.slice(0, -"-latest".length); + + const modern = bare.match(MODERN_ID_RE); + if (modern) { + return { + family: FAMILY_RANK[modern[1]], major: Number(modern[2]), minor: Number(modern[3] ?? 0), pinned, snapshot, + }; + } + const legacy = bare.match(LEGACY_ID_RE); + if (legacy) { + return { + family: FAMILY_RANK[legacy[3]], major: Number(legacy[1]), minor: Number(legacy[2] ?? 0), pinned, snapshot, + }; + } + return null; +} + +function compareVersions(a: ClaudeModelKey, b: ClaudeModelKey): number { + return a.major - b.major || a.minor - b.minor; +} + +export function sortClaudeModels(models: AdapterModel[]): AdapterModel[] { + const keyed = models.map((model, index) => ({ model, index, key: parseClaudeModelId(model.id) })); + + const newestByFamily = new Map(); + for (const { key } of keyed) { + if (!key) continue; + const newest = newestByFamily.get(key.family); + if (!newest || compareVersions(key, newest) > 0) newestByFamily.set(key.family, key); + } + // 0: newest release of its family, 1: older release, 2: not a recognizable Claude id. + const section = (key: ClaudeModelKey | null): number => { + if (!key) return 2; + return compareVersions(key, newestByFamily.get(key.family)!) === 0 ? 0 : 1; + }; + + return keyed + .sort((a, b) => { + const bySection = section(a.key) - section(b.key); + if (bySection !== 0) return bySection; + if (a.key && b.key) { + return ( + a.key.family - b.key.family + || compareVersions(b.key, a.key) + || Number(a.key.pinned) - Number(b.key.pinned) + || b.key.snapshot - a.key.snapshot + || a.index - b.index + ); + } + return a.index - b.index; + }) + .map((entry) => entry.model); +} diff --git a/packages/adapters/claude-local/src/server/models.ts b/packages/adapters/claude-local/src/server/models.ts index c2f5d5ed92..fe066585e4 100644 --- a/packages/adapters/claude-local/src/server/models.ts +++ b/packages/adapters/claude-local/src/server/models.ts @@ -1,26 +1,31 @@ import { createHash } from "node:crypto"; import type { AdapterModel } from "@paperclipai/adapter-utils"; import { models as DIRECT_MODELS } from "../index.js"; +import { sortClaudeModels } from "./model-order.js"; const ANTHROPIC_MODELS_ENDPOINT = "/v1/models"; const ANTHROPIC_MODELS_TIMEOUT_MS = 5000; const ANTHROPIC_MODELS_CACHE_TTL_MS = 60_000; const ANTHROPIC_API_VERSION = "2023-06-01"; -/** AWS Bedrock model IDs — region-qualified identifiers required by the Bedrock API. */ +/** + * AWS Bedrock model IDs — region-qualified identifiers required by the Bedrock API. Listed in + * the order the picker shows them (see model-order.ts): newest release of each family first, + * then older releases grouped by family. + */ const BEDROCK_MODELS: AdapterModel[] = [ - { id: "us.anthropic.claude-opus-4-8", label: "Bedrock Opus 4.8" }, - { id: "us.anthropic.claude-opus-5-5", label: "Bedrock Opus 5.5" }, - { id: "us.anthropic.claude-opus-5", label: "Bedrock Opus 5" }, - { id: "us.anthropic.claude-sonnet-5", label: "Bedrock Sonnet 5" }, // Fable 5.1's documented geo inference ID carries no -v1 suffix, unlike earlier entries. { id: "us.anthropic.claude-fable-5-1", label: "Bedrock Fable 5.1" }, - { id: "us.anthropic.claude-fable-5", label: "Bedrock Fable 5" }, - { id: "us.anthropic.claude-opus-4-7", label: "Bedrock Opus 4.7" }, - { id: "us.anthropic.claude-sonnet-4-6", label: "Bedrock Sonnet 4.6" }, - { id: "us.anthropic.claude-opus-4-6-v1", label: "Bedrock Opus 4.6" }, - { id: "us.anthropic.claude-sonnet-4-5-20250929-v2:0", label: "Bedrock Sonnet 4.5" }, + { id: "us.anthropic.claude-opus-5-5", label: "Bedrock Opus 5.5" }, + { id: "us.anthropic.claude-sonnet-5", label: "Bedrock Sonnet 5" }, { id: "us.anthropic.claude-haiku-4-5-20251001-v1:0", label: "Bedrock Haiku 4.5" }, + { id: "us.anthropic.claude-fable-5", label: "Bedrock Fable 5" }, + { id: "us.anthropic.claude-opus-5", label: "Bedrock Opus 5" }, + { id: "us.anthropic.claude-opus-4-8", label: "Bedrock Opus 4.8" }, + { id: "us.anthropic.claude-opus-4-7", label: "Bedrock Opus 4.7" }, + { id: "us.anthropic.claude-opus-4-6-v1", label: "Bedrock Opus 4.6" }, + { id: "us.anthropic.claude-sonnet-4-6", label: "Bedrock Sonnet 4.6" }, + { id: "us.anthropic.claude-sonnet-4-5-20250929-v2:0", label: "Bedrock Sonnet 4.5" }, ]; let cached: { keyFingerprint: string; baseUrl: string; expiresAt: number; models: AdapterModel[] } | null = null; @@ -52,10 +57,11 @@ function dedupeModels(models: AdapterModel[]): AdapterModel[] { } function mergedWithFallback(models: AdapterModel[]): AdapterModel[] { - return dedupeModels([ + // The Anthropic API returns models in its own order; the picker shows the curated one. + return sortClaudeModels(dedupeModels([ ...models, ...DIRECT_MODELS, - ]); + ])); } function resolveAnthropicApiKey(): string | null { @@ -109,9 +115,9 @@ async function fetchAnthropicModels(apiKey: string, baseUrl: string): Promise { - if (isBedrockEnv()) return dedupeModels(BEDROCK_MODELS); + if (isBedrockEnv()) return sortClaudeModels(dedupeModels(BEDROCK_MODELS)); - const fallback = dedupeModels(DIRECT_MODELS); + const fallback = sortClaudeModels(dedupeModels(DIRECT_MODELS)); const apiKey = resolveAnthropicApiKey(); if (!apiKey) return fallback; diff --git a/server/src/__tests__/adapter-models.test.ts b/server/src/__tests__/adapter-models.test.ts index c04f07fb00..98ec099837 100644 --- a/server/src/__tests__/adapter-models.test.ts +++ b/server/src/__tests__/adapter-models.test.ts @@ -63,8 +63,8 @@ describe("adapter model listing", () => { expect(models).toEqual(claudeFallbackModels); expect(models.some((model) => model.id === "claude-opus-4-8")).toBe(true); - // Newer flagship models are offered, but Opus 4.8 stays the default (first) option. - expect(models[0]?.id).toBe("claude-opus-4-8"); + // Newest release of the most capable family leads the list (#14877). + expect(models[0]?.id).toBe("claude-fable-5-1"); expect(models.some((model) => model.id === "claude-sonnet-5")).toBe(true); expect(models.some((model) => model.id === "claude-fable-5-1")).toBe(true); expect(models.some((model) => model.id === "claude-fable-5")).toBe(true); @@ -95,6 +95,11 @@ describe("adapter model listing", () => { expect(first.some((model) => model.id === "claude-opus-4-8-20260529")).toBe(true); expect(first.some((model) => model.id === "claude-opus-4-8")).toBe(true); expect(first.some((model) => model.id === "claude-opus-5-5")).toBe(true); + // Discovered models take the curated order too: the API's order is not shown as-is. + const firstIds = first.map((model) => model.id); + expect(firstIds[0]).toBe("claude-fable-5-1"); + expect(firstIds.indexOf("claude-opus-5-5")).toBeLessThan(firstIds.indexOf("claude-opus-4-8")); + expect(firstIds.indexOf("claude-opus-4-8")).toBeLessThan(firstIds.indexOf("claude-opus-4-8-20260529")); }); it("refreshes cached claude models on demand", async () => { @@ -159,8 +164,8 @@ describe("adapter model listing", () => { const models = await listAdapterModels("claude_local"); - // Keep Opus 4.8 first, using its documented dateless Bedrock ID. - expect(models[0]?.id).toBe("us.anthropic.claude-opus-4-8"); + // Fable 5.1 leads here too, using its documented dateless Bedrock ID. + expect(models[0]?.id).toBe("us.anthropic.claude-fable-5-1"); expect(models.map((model) => model.id)).toEqual(expect.arrayContaining([ "us.anthropic.claude-opus-5-5", "us.anthropic.claude-opus-5", "us.anthropic.claude-sonnet-5", "us.anthropic.claude-fable-5-1", "us.anthropic.claude-opus-4-7", "us.anthropic.claude-sonnet-4-6", diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index 1c5fa70d8f..630db5ca15 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -58,7 +58,7 @@ import { resolveManagedSandboxEnvironmentId, } from "../lib/adapter-test-environment"; import { environmentDisplayLabel } from "../lib/managed-sandbox-environment"; -import { extractModelName, extractProviderId } from "../lib/model-utils"; +import { adapterCuratesModelOrder, extractModelName, extractProviderId } from "../lib/model-utils"; import { queryKeys } from "../lib/queryKeys"; import { useCompany } from "../context/CompanyContext"; import { @@ -1744,6 +1744,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { allowDefault={adapterType !== "opencode_local" && adapterType !== "pi_local" && adapterType !== "paperclip_runner"} required={adapterType === "opencode_local" || adapterType === "pi_local"} groupByProvider={adapterType === "opencode_local" || adapterType === "pi_local"} + preserveOrder={adapterCuratesModelOrder(adapterType)} creatable detectedModel={detectedModel} detectedModelCandidates={[]} @@ -3725,6 +3726,7 @@ export function ModelDropdown({ allowDefault, required, groupByProvider, + preserveOrder, creatable, detectedModel, detectedModelCandidates, @@ -3743,6 +3745,8 @@ export function ModelDropdown({ allowDefault: boolean; required: boolean; groupByProvider: boolean; + /** Keep the adapter's list order (curated lists) instead of sorting ungrouped entries by id. */ + preserveOrder?: boolean; creatable?: boolean; detectedModel?: string | null; detectedModelCandidates?: string[]; @@ -3787,12 +3791,10 @@ export function ModelDropdown({ }, [models, modelSearch, promotedModelIds]); const groupedModels = useMemo(() => { if (!groupByProvider) { - return [ - { - provider: "models", - entries: [...filteredModels].sort((a, b) => a.id.localeCompare(b.id)), - }, - ]; + // A hand-ordered list (newest release of each family first, older releases at the end) is + // shown as the adapter ordered it; a discovered list has no stable order, so sort it. + const entries = preserveOrder ? filteredModels : [...filteredModels].sort((a, b) => a.id.localeCompare(b.id)); + return [{ provider: "models", entries }]; } const map = new Map(); for (const model of filteredModels) { @@ -3807,7 +3809,7 @@ export function ModelDropdown({ provider, entries: [...entries].sort((a, b) => a.id.localeCompare(b.id)), })); - }, [filteredModels, groupByProvider]); + }, [filteredModels, groupByProvider, preserveOrder]); async function handleDetectModel() { if (!onDetectModel) return; diff --git a/ui/src/components/ConfigureBuiltInAgentModal.tsx b/ui/src/components/ConfigureBuiltInAgentModal.tsx index 79e29131f8..0a8c01479f 100644 --- a/ui/src/components/ConfigureBuiltInAgentModal.tsx +++ b/ui/src/components/ConfigureBuiltInAgentModal.tsx @@ -22,6 +22,7 @@ import { builtInAgentsApi, type BuiltInAgentState, } from "@/api/builtInAgents"; +import { adapterCuratesModelOrder } from "../lib/model-utils"; /** Adapters whose config completeness is keyed on a non-empty `model`. */ function isModelBasedAdapter(adapterType: string): boolean { @@ -184,6 +185,7 @@ export function ConfigureBuiltInAgentModal({ allowDefault={adapterType !== "opencode_local"} required groupByProvider={false} + preserveOrder={adapterCuratesModelOrder(adapterType)} creatable /> )} diff --git a/ui/src/components/ModelDropdown.test.tsx b/ui/src/components/ModelDropdown.test.tsx new file mode 100644 index 0000000000..6a17a623cc --- /dev/null +++ b/ui/src/components/ModelDropdown.test.tsx @@ -0,0 +1,81 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, describe, expect, it } from "vitest"; +import type { AdapterModel } from "@paperclipai/adapter-utils"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import { ModelDropdown } from "./AgentConfigForm"; + +const roots: Root[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) { + act(() => root.unmount()); + } + document.body.innerHTML = ""; +}); + +function renderOpenDropdown( + models: AdapterModel[], + { groupByProvider = false, preserveOrder = false }: { groupByProvider?: boolean; preserveOrder?: boolean } = {}, +) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + roots.push(root); + act(() => { + root.render( + + {}} + open + onOpenChange={() => {}} + allowDefault={false} + required + groupByProvider={groupByProvider} + preserveOrder={preserveOrder} + /> + , + ); + }); +} + +function shownModelIds(): string[] { + return Array.from(document.body.querySelectorAll("span[title]")).map((span) => span.getAttribute("title") ?? ""); +} + +const curated = [ + { id: "claude-fable-5-1", label: "Claude Fable 5.1" }, + { id: "claude-opus-5-5", label: "Claude Opus 5.5" }, + { id: "claude-haiku-4-5", label: "Claude Haiku 4.5" }, + { id: "claude-opus-4-8", label: "Claude Opus 4.8" }, +]; + +describe("ModelDropdown", () => { + it("keeps a hand-ordered list in the adapter's order when preserveOrder is set", () => { + renderOpenDropdown(curated, { preserveOrder: true }); + + expect(shownModelIds()).toEqual(curated.map((model) => model.id)); + }); + + it("still sorts a discovered list by id by default", () => { + renderOpenDropdown(curated); + + expect(shownModelIds()).toEqual(["claude-fable-5-1", "claude-haiku-4-5", "claude-opus-4-8", "claude-opus-5-5"]); + }); + + it("still sorts provider groups by id", () => { + renderOpenDropdown( + [ + { id: "openai/gpt-6-sol", label: "gpt-6-sol" }, + { id: "openai/gpt-6-astra", label: "gpt-6-astra" }, + ], + { groupByProvider: true }, + ); + + expect(shownModelIds()).toEqual(["openai/gpt-6-astra", "openai/gpt-6-sol"]); + }); +}); diff --git a/ui/src/components/new-agent/NewAgentSetup.tsx b/ui/src/components/new-agent/NewAgentSetup.tsx index dcb167010b..d65b189a2f 100644 --- a/ui/src/components/new-agent/NewAgentSetup.tsx +++ b/ui/src/components/new-agent/NewAgentSetup.tsx @@ -64,6 +64,7 @@ import { AgentProviderConnection, type ProviderConnection, } from "./AgentProviderConnection"; +import { adapterCuratesModelOrder } from "../../lib/model-utils"; const controlClass = "w-full rounded-md border border-border bg-background px-2.5 py-1.5 text-sm leading-5 outline-none focus-visible:ring-2 focus-visible:ring-ring"; @@ -883,6 +884,7 @@ function Setup({ required={multiProvider} creatable groupByProvider={multiProvider} + preserveOrder={adapterCuratesModelOrder(brandType)} /> )} {efforts.length > 0 && ( diff --git a/ui/src/lib/model-utils.test.ts b/ui/src/lib/model-utils.test.ts new file mode 100644 index 0000000000..55a5471b44 --- /dev/null +++ b/ui/src/lib/model-utils.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { adapterCuratesModelOrder } from "./model-utils"; + +describe("adapterCuratesModelOrder", () => { + it("keeps the advertised order for built-in adapters with a hand-ordered list", () => { + for (const adapterType of ["claude_local", "codex_local", "paperclip_runner", "gemini_local", "opencode_local"]) { + expect(adapterCuratesModelOrder(adapterType)).toBe(true); + } + }); + + it("leaves discovered and third-party lists to the alphabetical fallback", () => { + // Cursor's list comes from `agent models`, whose order can change between refreshes. + expect(adapterCuratesModelOrder("cursor")).toBe(false); + // An externally installed adapter is not known to order its list deliberately. + expect(adapterCuratesModelOrder("acme_plugin_adapter")).toBe(false); + }); +}); diff --git a/ui/src/lib/model-utils.ts b/ui/src/lib/model-utils.ts index baa721f572..4774916fe2 100644 --- a/ui/src/lib/model-utils.ts +++ b/ui/src/lib/model-utils.ts @@ -14,3 +14,28 @@ export function extractModelName(modelId: string): string { if (!trimmed.includes("/")) return trimmed; return trimmed.slice(trimmed.indexOf("/") + 1).trim(); } + +/** + * Built-in adapters whose model list arrives in a deliberate order: Claude and Codex by family + * and version, the runner's Codex list, Gemini with `Auto` first, Grok, Kimi, OpenClaw, and the + * OpenCode and Pi lists, which the server sorts when discovered and which lead with the default + * model when it falls back to the declared list. The model dropdown shows these lists as the + * adapter advertises them. Cursor is left out because its list comes from `agent models` + * discovery, whose order can change between refreshes. Adapters not named here, including + * externally installed ones, keep the dropdown's alphabetical order. + */ +const CURATED_MODEL_ORDER_ADAPTERS: ReadonlySet = new Set([ + "claude_local", + "codex_local", + "paperclip_runner", + "gemini_local", + "grok_local", + "kimi_local", + "openclaw_gateway", + "opencode_local", + "pi_local", +]); + +export function adapterCuratesModelOrder(adapterType: string): boolean { + return CURATED_MODEL_ORDER_ADAPTERS.has(adapterType); +}