fix(claude-local): order Claude models the way the Claude app does (#14917)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Each agent runs on an adapter, and the operator picks the agent's
model from a list the adapter advertises.
> - The `claude_local` adapter advertises a static list and merges in
the models the Anthropic API returns. Neither list has a deliberate
order.
> - The model dropdown then sorts every list by id. For Claude this
shows "Fable 5", "Fable 5.1", "Haiku 4.5", "Mythos 5", "Opus 4.6" ...
which is not the order of capability, release, or version (#14877).
> - This pull request gives the adapter one defined order, the one the
Claude app uses: the newest release of each family first, by decreasing
capability, then older releases grouped by family. The server applies it
to discovered models, and the dropdown keeps the adapter's order instead
of re-sorting.
> - The benefit is that a user who knows the Claude app finds the right
model at once, and older models sit at the end of the list.

## Linked Issues or Issue Description

Fixes #14877.

Related: #14147 touches the same adapter's model list (the
`ANTHROPIC_MODEL` default label) and does not change ordering. #14878 is
the Codex counterpart and depends on the dropdown change in this PR.

## What Changed

- `packages/adapters/claude-local/src/server/model-order.ts` (new):
`sortClaudeModels()` and `parseClaudeModelId()`. The parser reads the
current scheme (`claude-opus-4-8`), the legacy scheme
(`claude-3-7-sonnet-20250219`), dated snapshots, `-latest` aliases, the
`[1m]` suffix, and Bedrock ids (`us.anthropic.…-v1`, `…-v2:0`). The sort
puts the newest release of each family first (Fable, Mythos, Opus,
Sonnet, Haiku), then older releases grouped by family with versions
descending. An alias sorts before its dated snapshots, and dated
snapshots of one release sort newest first. Ids that are not Claude
models keep their incoming order at the end.
- `packages/adapters/claude-local/src/server/models.ts`: apply the order
to the static fallback, to the merged API list, and to the Bedrock list.
Reorder `BEDROCK_MODELS` to match.
- `packages/adapters/claude-local/src/index.ts`: reorder the advertised
`models` list to the same order.
- `ui/src/components/AgentConfigForm.tsx`: `ModelDropdown` gets a
`preserveOrder` prop. With it the dropdown shows the list as the adapter
ordered it; without it the list is sorted by id as before.
`ui/src/lib/model-utils.ts` adds `adapterCuratesModelOrder()`, true for
the built-in adapters whose list arrives in a deliberate order
(`claude_local`, `codex_local`, `paperclip_runner`, `gemini_local`,
`grok_local`, `kimi_local`, `openclaw_gateway`, and `opencode_local` /
`pi_local`, which the server sorts when discovered and which lead with
the default model when it falls back to the declared list). Cursor is
not in the set because its list comes from `agent models` discovery, and
adapters not named there, including externally installed ones, keep the
alphabetical fallback. The three dropdown call sites (`AgentConfigForm`,
`ConfigureBuiltInAgentModal`, `NewAgentSetup`) pass it; `NewAgentSetup`
decides by the resolved brand type, because a `paperclip_runner` agent
fetches the Claude or Codex list for its brand. Grouped lists
(`opencode_local`, `pi_local`) are unchanged.
- Tests: `model-order.test.ts` (adapter, including the snapshot-date
tie-breaker), `ModelDropdown.test.tsx` (ui: preserved order with the
prop, alphabetical without it, provider groups unchanged),
`model-utils.test.ts` (which adapters opt in), and two updated
expectations plus one new order assertion in
`server/src/__tests__/adapter-models.test.ts`.

Mythos is not in the Claude app's list. This PR ranks it directly after
Fable, in the top capability tier. The rank table in `model-order.ts` is
one line to change if you prefer a different slot.

## Verification

Run from the repository root:

```sh
pnpm exec vitest run packages/adapters/claude-local server/src/__tests__/adapter-models.test.ts ui/src/lib/model-utils.test.ts ui/src/components/ModelDropdown.test.tsx ui/src/components/AgentConfigForm.render.test.tsx ui/src/components/ConfigureBuiltInAgentModal.test.tsx ui/src/pages/NewAgent.test.tsx
pnpm --filter @paperclipai/adapter-claude-local typecheck
pnpm --filter @paperclipai/ui typecheck
pnpm --filter @paperclipai/server typecheck
pnpm check:module-boundaries && pnpm check:token-gates && pnpm check:tokens
```

Red on `master`, green here:

- `ModelDropdown.test.tsx` fails against the unmodified dropdown because
the ids come back sorted alphabetically even with `preserveOrder`.
- `adapter-models.test.ts` fails against the unmodified adapter because
the first model is `claude-opus-4-8`, not `claude-fable-5-1`.

Manual check: open an agent that uses `claude_local`, open the model
dropdown. With no `ANTHROPIC_API_KEY` the list reads Fable 5.1, Mythos
5, Opus 5.5, Sonnet 5, Haiku 4.5, Fable 5, Opus 5, Opus 4.8, Opus 4.7,
Opus 4.6, Sonnet 4.6, Sonnet 4.5. With a key, the discovered models slot
into the same order.

## Risks

- The other built-in adapters in the set (Gemini with `Auto` first,
Grok, Kimi, OpenClaw, the runner's Codex list, and the OpenCode and Pi
lists in the built-in-agent modal) are now shown as their adapter
delivers them instead of alphabetized. Cursor's discovered list and
every adapter outside the set, including externally installed ones, keep
the alphabetical order they had, so no option moves between refreshes.
Grouped lists are unchanged.
- The first entry of the Claude list changes from Opus 4.8 to Fable 5.1.
Nothing reads the first entry as a default: `DEFAULT_CLAUDE_LOCAL_MODEL`
is `claude-opus-5` and is resolved separately.
- No API, schema, or migration change.

## Model Used

Anthropic Claude Fable 5.1 (`claude-fable-5-1`) through Claude Code,
extended thinking on, with tool use for reading the repository, running
vitest and tsc, and editing files. The account holder reviewed the
change and owns the commit.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: abderbj <115119179+abderbj@users.noreply.github.com>
This commit is contained in:
abderrahmen bejaouiandabderbj authored and GitHub committed 2026-10-02 13:24:04 -07:00
1 parent 2ec82c5774
commit 92ad158ce1
11 files changed
+368 -30

No files matched your search

+7 -4
View File
@@ -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
@@ -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();
});
});
@@ -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<Record<string, number>> = {
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<number, ClaudeModelKey>();
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);
}
@@ -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<Ad
}
async function loadClaudeModels(options?: { forceRefresh?: boolean }): Promise<AdapterModel[]> {
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;
+9 -4
View File
@@ -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",
+10 -8
View File
@@ -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<string, AdapterModel[]>();
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;
@@ -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
/>
)}
+81
View File
@@ -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(
<TooltipProvider>
<ModelDropdown
models={models}
value=""
onChange={() => {}}
open
onOpenChange={() => {}}
allowDefault={false}
required
groupByProvider={groupByProvider}
preserveOrder={preserveOrder}
/>
</TooltipProvider>,
);
});
}
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"]);
});
});
@@ -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 && (
+17
View File
@@ -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);
});
});
+25
View File
@@ -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<string> = 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);
}