Skip to content

Commit fac2185

Browse files
Merge branch 'main' into posthog-code/fix-archive-story-timestamps
2 parents 3daed6a + 836f029 commit fac2185

5 files changed

Lines changed: 88 additions & 38 deletions

File tree

packages/agent/src/adapters/base-acp-agent.ts

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,11 @@ import type {
1818
} from "@agentclientprotocol/sdk";
1919
import { restrictedModelMeta } from "@posthog/shared";
2020
import {
21+
compareModelsForPicker,
2122
DEFAULT_GATEWAY_MODEL,
2223
fetchGatewayModels,
2324
formatGatewayModelName,
2425
type GatewayModel,
25-
getClaudeModelRecency,
2626
isAnthropicModel,
2727
isCloudflareModel,
2828
isCloudflareModelId,
@@ -163,12 +163,7 @@ export abstract class BaseAcpAgent implements Agent {
163163
// silently dropping them.
164164
...(model.allowed ? {} : { _meta: restrictedModelMeta() }),
165165
}))
166-
// Sort oldest-to-newest so the picker is deterministic and the newest
167-
// model lands at the end of the list, closest to the trigger.
168-
.sort(
169-
(a, b) =>
170-
getClaudeModelRecency(a.value) - getClaudeModelRecency(b.value),
171-
);
166+
.sort((a, b) => compareModelsForPicker(a.value, b.value));
172167

173168
// Models the Claude adapter can drive: Anthropic ids, plus Cloudflare `@cf/` ids the gateway
174169
// serves over its Anthropic-Messages surface. Anything else (e.g. a Codex/GPT id) is a genuine

packages/agent/src/adapters/claude/session/model-config.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,18 @@ describe("applyAvailableModelsAllowlist", () => {
1919
).toEqual(rawModelOptions);
2020
});
2121

22+
it("reorders the allowlist to the picker order instead of the listed order", () => {
23+
expect(
24+
applyAvailableModelsAllowlist(rawModelOptions, [
25+
"claude-sonnet-4-6",
26+
"claude-opus-4-8",
27+
]).options,
28+
).toEqual([
29+
{ value: "claude-opus-4-8", name: "Claude Opus 4.8" },
30+
{ value: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" },
31+
]);
32+
});
33+
2234
it("switches the current model when the previous one is filtered out", () => {
2335
expect(
2436
applyAvailableModelsAllowlist(rawModelOptions, ["claude-sonnet-4-6"]),

packages/agent/src/adapters/claude/session/model-config.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { SessionConfigSelectOption } from "@agentclientprotocol/sdk";
2+
import { compareModelsForPicker } from "../../../gateway-models";
23

34
export interface ModelConfigOptions {
45
currentModelId: string;
@@ -30,6 +31,9 @@ export function applyAvailableModelsAllowlist(
3031

3132
if (filtered.length === 0) return modelOptions;
3233

34+
// The allowlist is a filter, not a display order: keep the picker's ordering.
35+
filtered.sort((a, b) => compareModelsForPicker(a.value, b.value));
36+
3337
const currentModelId = filtered.some(
3438
(o) => o.value === modelOptions.currentModelId,
3539
)

packages/agent/src/gateway-models.test.ts

Lines changed: 33 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { afterEach, describe, expect, it, vi } from "vitest";
22
import {
3+
compareModelsForPicker,
34
fetchGatewayModels,
45
fetchModelsList,
56
formatGatewayModelName,
@@ -34,7 +35,7 @@ describe("formatGatewayModelName", () => {
3435
).toBe("Claude Opus 4.8");
3536
});
3637

37-
it("formats OpenAI models as raw lowercase model ids", () => {
38+
it("uppercases the GPT acronym in OpenAI model ids", () => {
3839
expect(
3940
formatGatewayModelName({
4041
id: "GPT-5.5",
@@ -44,23 +45,23 @@ describe("formatGatewayModelName", () => {
4445
supports_vision: true,
4546
allowed: true,
4647
}),
47-
).toBe("gpt-5.5");
48+
).toBe("GPT-5.5");
4849
});
4950

50-
it("strips the openai/ prefix from OpenAI model ids", () => {
51+
it("strips the openai/ prefix, uppercases GPT, and title-cases the suffix", () => {
5152
expect(
5253
formatGatewayModelName({
53-
id: "openai/gpt-5.5",
54+
id: "openai/gpt-5.6-sol",
5455
owned_by: "openai",
5556
context_window: 200000,
5657
supports_streaming: true,
5758
supports_vision: true,
5859
allowed: true,
5960
}),
60-
).toBe("gpt-5.5");
61+
).toBe("GPT-5.6 Sol");
6162
});
6263

63-
it("formats Cloudflare models as the lowercase final path segment", () => {
64+
it("formats Cloudflare models as the final path segment with GLM uppercased", () => {
6465
expect(
6566
formatGatewayModelName({
6667
id: "@cf/zai-org/glm-5.2",
@@ -70,7 +71,20 @@ describe("formatGatewayModelName", () => {
7071
supports_vision: false,
7172
allowed: true,
7273
}),
73-
).toBe("glm-5.2");
74+
).toBe("GLM-5.2");
75+
});
76+
77+
it("leaves non-acronym Cloudflare models lowercase", () => {
78+
expect(
79+
formatGatewayModelName({
80+
id: "@cf/meta/llama-3.1-8b-instruct",
81+
owned_by: "cloudflare",
82+
context_window: 128000,
83+
supports_streaming: true,
84+
supports_vision: false,
85+
allowed: true,
86+
}),
87+
).toBe("llama-3.1-8b-instruct");
7488
});
7589

7690
it("blocks deprecated Claude gateway models", () => {
@@ -115,28 +129,28 @@ describe("getClaudeModelRecency", () => {
115129
getClaudeModelRecency("claude-fable-5"),
116130
);
117131
});
132+
});
118133

119-
it("produces the full picker display order, oldest to newest", () => {
134+
describe("compareModelsForPicker", () => {
135+
it("groups models by family, most capable first, newest version first", () => {
120136
// Models as the gateway might return them — arbitrary order.
121137
const gatewayOrder = [
122138
"claude-fable-5",
123-
"claude-opus-4-8",
139+
"claude-opus-4-7",
124140
"claude-mystery",
141+
"claude-sonnet-5",
125142
"claude-haiku-4-5",
126143
"claude-sonnet-4-6",
127-
"claude-opus-4-7",
144+
"claude-opus-4-8",
128145
];
129-
const displayed = [...gatewayOrder].sort(
130-
(a, b) => getClaudeModelRecency(a) - getClaudeModelRecency(b),
131-
);
132-
// The menu opens upward, so the newest model (last here) sits closest to
133-
// the trigger. Unknown/unversioned models rank newest and trail the list.
146+
const displayed = [...gatewayOrder].sort(compareModelsForPicker);
134147
expect(displayed).toEqual([
135-
"claude-haiku-4-5",
136-
"claude-sonnet-4-6",
137-
"claude-opus-4-7",
138-
"claude-opus-4-8",
139148
"claude-fable-5",
149+
"claude-opus-4-8",
150+
"claude-opus-4-7",
151+
"claude-sonnet-5",
152+
"claude-sonnet-4-6",
153+
"claude-haiku-4-5",
140154
"claude-mystery",
141155
]);
142156
});

packages/agent/src/gateway-models.ts

Lines changed: 37 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -258,16 +258,8 @@ export function getProviderName(ownedBy: string): string {
258258
return PROVIDER_NAMES[ownedBy] ?? ownedBy;
259259
}
260260

261-
// Sort key for ordering models oldest-to-newest in pickers. The model menu
262-
// opens upward (side="top"), so the last item sits closest to the trigger —
263-
// sorting ascending by this key puts the newest model right under the user's
264-
// cursor. The key is the version embedded in the model id, e.g.
265-
// "claude-sonnet-4-6" -> 4006, "claude-opus-4-8" -> 4008, "claude-fable-5" ->
266-
// 5000; a higher number means a newer model. An id with no recognisable
267-
// version (a brand-new or unexpected release) ranks as newest so it still
268-
// surfaces at the end rather than at an arbitrary gateway-determined position.
269-
// Only the first version group is read, so a trailing date suffix (e.g.
270-
// "-20251001") is ignored; the minor component is assumed to be < 1000.
261+
// Version embedded in the model id, e.g. "claude-opus-4-8" -> 4008. Ids with no
262+
// recognisable version rank newest. A trailing date suffix is ignored.
271263
export function getClaudeModelRecency(modelId: string): number {
272264
const match = modelId.toLowerCase().match(/-(\d+)(?:[-.](\d+))?/);
273265
if (!match) return Number.MAX_SAFE_INTEGER;
@@ -276,15 +268,48 @@ export function getClaudeModelRecency(modelId: string): number {
276268
return major * 1000 + minor;
277269
}
278270

271+
// Families ordered most-capable first; unknown families sort last.
272+
const MODEL_FAMILY_ORDER = ["fable", "opus", "sonnet", "haiku"];
273+
274+
function getModelFamilyRank(modelId: string): number {
275+
const id = modelId.toLowerCase();
276+
const index = MODEL_FAMILY_ORDER.findIndex((family) => id.includes(family));
277+
return index === -1 ? MODEL_FAMILY_ORDER.length : index;
278+
}
279+
280+
// Group by family, then newest version first within each family.
281+
export function compareModelsForPicker(a: string, b: string): number {
282+
const familyDiff = getModelFamilyRank(a) - getModelFamilyRank(b);
283+
if (familyDiff !== 0) return familyDiff;
284+
return getClaudeModelRecency(b) - getClaudeModelRecency(a);
285+
}
286+
279287
const PROVIDER_PREFIXES = ["anthropic/", "openai/", "google-vertex/"];
280288

289+
const KNOWN_ACRONYMS = new Set(["gpt", "glm"]);
290+
291+
// For a known acronym, uppercase it, keep the version attached, and title-case
292+
// any suffix: "gpt-5.6-sol" -> "GPT-5.6 Sol", "glm-5.2" -> "GLM-5.2". Other ids
293+
// stay lowercase to avoid mangling ordinary names (e.g. "llama-3.1-8b").
294+
function formatProviderModelName(modelId: string): string {
295+
const [acronym, version, ...suffix] = modelId.split("-");
296+
if (!KNOWN_ACRONYMS.has(acronym.toLowerCase())) return modelId.toLowerCase();
297+
const head = version
298+
? `${acronym.toUpperCase()}-${version}`
299+
: acronym.toUpperCase();
300+
const tail = suffix.map(
301+
(word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(),
302+
);
303+
return [head, ...tail].join(" ");
304+
}
305+
281306
export function formatGatewayModelName(model: GatewayModel): string {
282307
if (isCloudflareModel(model)) {
283-
return (model.id.split("/").pop() ?? model.id).toLowerCase();
308+
return formatProviderModelName(model.id.split("/").pop() ?? model.id);
284309
}
285310

286311
if (isOpenAIModel(model)) {
287-
return stripProviderPrefix(model.id).toLowerCase();
312+
return formatProviderModelName(stripProviderPrefix(model.id));
288313
}
289314

290315
return formatModelId(model.id);

0 commit comments

Comments
 (0)