mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-06 20:05:57 +02:00
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:
1 parent
2ec82c5774
commit
92ad158ce1
11 files changed
+368
-30
No files matched your search
@@ -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;
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user