Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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