Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 2 additions & 7 deletions packages/agent/src/adapters/base-acp-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions packages/agent/src/adapters/claude/session/model-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]),
Expand Down
4 changes: 4 additions & 0 deletions packages/agent/src/adapters/claude/session/model-config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { SessionConfigSelectOption } from "@agentclientprotocol/sdk";
import { compareModelsForPicker } from "../../../gateway-models";

export interface ModelConfigOptions {
currentModelId: string;
Expand Down Expand Up @@ -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,
)
Expand Down
52 changes: 33 additions & 19 deletions packages/agent/src/gateway-models.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
compareModelsForPicker,
fetchGatewayModels,
fetchModelsList,
formatGatewayModelName,
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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", () => {
Expand Down Expand Up @@ -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",
]);
});
Expand Down
49 changes: 37 additions & 12 deletions packages/agent/src/gateway-models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Comment thread
Twixes marked this conversation as resolved.
}

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();
Comment thread
Twixes marked this conversation as resolved.
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);
Expand Down
Loading