diff --git a/packages/agent/src/adapters/base-acp-agent.ts b/packages/agent/src/adapters/base-acp-agent.ts index 249b3655e9..243228a65a 100644 --- a/packages/agent/src/adapters/base-acp-agent.ts +++ b/packages/agent/src/adapters/base-acp-agent.ts @@ -18,11 +18,11 @@ import type { } from "@agentclientprotocol/sdk"; import { restrictedModelMeta } from "@posthog/shared"; import { + compareModelsForPicker, DEFAULT_GATEWAY_MODEL, fetchGatewayModels, formatGatewayModelName, type GatewayModel, - getClaudeModelRecency, isAnthropicModel, isCloudflareModel, isCloudflareModelId, @@ -163,12 +163,7 @@ export abstract class BaseAcpAgent implements Agent { // silently dropping them. ...(model.allowed ? {} : { _meta: restrictedModelMeta() }), })) - // Sort oldest-to-newest so the picker is deterministic and the newest - // model lands at the end of the list, closest to the trigger. - .sort( - (a, b) => - getClaudeModelRecency(a.value) - getClaudeModelRecency(b.value), - ); + .sort((a, b) => compareModelsForPicker(a.value, b.value)); // Models the Claude adapter can drive: Anthropic ids, plus Cloudflare `@cf/` ids the gateway // serves over its Anthropic-Messages surface. Anything else (e.g. a Codex/GPT id) is a genuine diff --git a/packages/agent/src/adapters/claude/session/model-config.test.ts b/packages/agent/src/adapters/claude/session/model-config.test.ts index 98b0b2ddf0..5561454baa 100644 --- a/packages/agent/src/adapters/claude/session/model-config.test.ts +++ b/packages/agent/src/adapters/claude/session/model-config.test.ts @@ -19,6 +19,18 @@ describe("applyAvailableModelsAllowlist", () => { ).toEqual(rawModelOptions); }); + it("reorders the allowlist to the picker order instead of the listed order", () => { + expect( + applyAvailableModelsAllowlist(rawModelOptions, [ + "claude-sonnet-4-6", + "claude-opus-4-8", + ]).options, + ).toEqual([ + { value: "claude-opus-4-8", name: "Claude Opus 4.8" }, + { value: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, + ]); + }); + it("switches the current model when the previous one is filtered out", () => { expect( applyAvailableModelsAllowlist(rawModelOptions, ["claude-sonnet-4-6"]), diff --git a/packages/agent/src/adapters/claude/session/model-config.ts b/packages/agent/src/adapters/claude/session/model-config.ts index f83ed1b717..d2f754730f 100644 --- a/packages/agent/src/adapters/claude/session/model-config.ts +++ b/packages/agent/src/adapters/claude/session/model-config.ts @@ -1,4 +1,5 @@ import type { SessionConfigSelectOption } from "@agentclientprotocol/sdk"; +import { compareModelsForPicker } from "../../../gateway-models"; export interface ModelConfigOptions { currentModelId: string; @@ -30,6 +31,9 @@ export function applyAvailableModelsAllowlist( if (filtered.length === 0) return modelOptions; + // The allowlist is a filter, not a display order: keep the picker's ordering. + filtered.sort((a, b) => compareModelsForPicker(a.value, b.value)); + const currentModelId = filtered.some( (o) => o.value === modelOptions.currentModelId, ) diff --git a/packages/agent/src/gateway-models.test.ts b/packages/agent/src/gateway-models.test.ts index bd5d34dab0..a92614c462 100644 --- a/packages/agent/src/gateway-models.test.ts +++ b/packages/agent/src/gateway-models.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { + compareModelsForPicker, fetchGatewayModels, fetchModelsList, formatGatewayModelName, @@ -34,7 +35,7 @@ describe("formatGatewayModelName", () => { ).toBe("Claude Opus 4.8"); }); - it("formats OpenAI models as raw lowercase model ids", () => { + it("uppercases the GPT acronym in OpenAI model ids", () => { expect( formatGatewayModelName({ id: "GPT-5.5", @@ -44,23 +45,23 @@ describe("formatGatewayModelName", () => { supports_vision: true, allowed: true, }), - ).toBe("gpt-5.5"); + ).toBe("GPT-5.5"); }); - it("strips the openai/ prefix from OpenAI model ids", () => { + it("strips the openai/ prefix, uppercases GPT, and title-cases the suffix", () => { expect( formatGatewayModelName({ - id: "openai/gpt-5.5", + id: "openai/gpt-5.6-sol", owned_by: "openai", context_window: 200000, supports_streaming: true, supports_vision: true, allowed: true, }), - ).toBe("gpt-5.5"); + ).toBe("GPT-5.6 Sol"); }); - it("formats Cloudflare models as the lowercase final path segment", () => { + it("formats Cloudflare models as the final path segment with GLM uppercased", () => { expect( formatGatewayModelName({ id: "@cf/zai-org/glm-5.2", @@ -70,7 +71,20 @@ describe("formatGatewayModelName", () => { supports_vision: false, allowed: true, }), - ).toBe("glm-5.2"); + ).toBe("GLM-5.2"); + }); + + it("leaves non-acronym Cloudflare models lowercase", () => { + expect( + formatGatewayModelName({ + id: "@cf/meta/llama-3.1-8b-instruct", + owned_by: "cloudflare", + context_window: 128000, + supports_streaming: true, + supports_vision: false, + allowed: true, + }), + ).toBe("llama-3.1-8b-instruct"); }); it("blocks deprecated Claude gateway models", () => { @@ -115,28 +129,28 @@ describe("getClaudeModelRecency", () => { getClaudeModelRecency("claude-fable-5"), ); }); +}); - it("produces the full picker display order, oldest to newest", () => { +describe("compareModelsForPicker", () => { + it("groups models by family, most capable first, newest version first", () => { // Models as the gateway might return them — arbitrary order. const gatewayOrder = [ "claude-fable-5", - "claude-opus-4-8", + "claude-opus-4-7", "claude-mystery", + "claude-sonnet-5", "claude-haiku-4-5", "claude-sonnet-4-6", - "claude-opus-4-7", + "claude-opus-4-8", ]; - const displayed = [...gatewayOrder].sort( - (a, b) => getClaudeModelRecency(a) - getClaudeModelRecency(b), - ); - // The menu opens upward, so the newest model (last here) sits closest to - // the trigger. Unknown/unversioned models rank newest and trail the list. + const displayed = [...gatewayOrder].sort(compareModelsForPicker); expect(displayed).toEqual([ - "claude-haiku-4-5", - "claude-sonnet-4-6", - "claude-opus-4-7", - "claude-opus-4-8", "claude-fable-5", + "claude-opus-4-8", + "claude-opus-4-7", + "claude-sonnet-5", + "claude-sonnet-4-6", + "claude-haiku-4-5", "claude-mystery", ]); }); diff --git a/packages/agent/src/gateway-models.ts b/packages/agent/src/gateway-models.ts index 62a379665d..9faf9e5f7d 100644 --- a/packages/agent/src/gateway-models.ts +++ b/packages/agent/src/gateway-models.ts @@ -258,16 +258,8 @@ export function getProviderName(ownedBy: string): string { return PROVIDER_NAMES[ownedBy] ?? ownedBy; } -// Sort key for ordering models oldest-to-newest in pickers. The model menu -// opens upward (side="top"), so the last item sits closest to the trigger — -// sorting ascending by this key puts the newest model right under the user's -// cursor. The key is the version embedded in the model id, e.g. -// "claude-sonnet-4-6" -> 4006, "claude-opus-4-8" -> 4008, "claude-fable-5" -> -// 5000; a higher number means a newer model. An id with no recognisable -// version (a brand-new or unexpected release) ranks as newest so it still -// surfaces at the end rather than at an arbitrary gateway-determined position. -// Only the first version group is read, so a trailing date suffix (e.g. -// "-20251001") is ignored; the minor component is assumed to be < 1000. +// Version embedded in the model id, e.g. "claude-opus-4-8" -> 4008. Ids with no +// recognisable version rank newest. A trailing date suffix is ignored. export function getClaudeModelRecency(modelId: string): number { const match = modelId.toLowerCase().match(/-(\d+)(?:[-.](\d+))?/); if (!match) return Number.MAX_SAFE_INTEGER; @@ -276,15 +268,48 @@ export function getClaudeModelRecency(modelId: string): number { return major * 1000 + minor; } +// Families ordered most-capable first; unknown families sort last. +const MODEL_FAMILY_ORDER = ["fable", "opus", "sonnet", "haiku"]; + +function getModelFamilyRank(modelId: string): number { + const id = modelId.toLowerCase(); + const index = MODEL_FAMILY_ORDER.findIndex((family) => id.includes(family)); + return index === -1 ? MODEL_FAMILY_ORDER.length : index; +} + +// Group by family, then newest version first within each family. +export function compareModelsForPicker(a: string, b: string): number { + const familyDiff = getModelFamilyRank(a) - getModelFamilyRank(b); + if (familyDiff !== 0) return familyDiff; + return getClaudeModelRecency(b) - getClaudeModelRecency(a); +} + const PROVIDER_PREFIXES = ["anthropic/", "openai/", "google-vertex/"]; +const KNOWN_ACRONYMS = new Set(["gpt", "glm"]); + +// For a known acronym, uppercase it, keep the version attached, and title-case +// any suffix: "gpt-5.6-sol" -> "GPT-5.6 Sol", "glm-5.2" -> "GLM-5.2". Other ids +// stay lowercase to avoid mangling ordinary names (e.g. "llama-3.1-8b"). +function formatProviderModelName(modelId: string): string { + const [acronym, version, ...suffix] = modelId.split("-"); + if (!KNOWN_ACRONYMS.has(acronym.toLowerCase())) return modelId.toLowerCase(); + const head = version + ? `${acronym.toUpperCase()}-${version}` + : acronym.toUpperCase(); + const tail = suffix.map( + (word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(), + ); + return [head, ...tail].join(" "); +} + export function formatGatewayModelName(model: GatewayModel): string { if (isCloudflareModel(model)) { - return (model.id.split("/").pop() ?? model.id).toLowerCase(); + return formatProviderModelName(model.id.split("/").pop() ?? model.id); } if (isOpenAIModel(model)) { - return stripProviderPrefix(model.id).toLowerCase(); + return formatProviderModelName(stripProviderPrefix(model.id)); } return formatModelId(model.id);