Skip to content

Commit e60821f

Browse files
authored
feat: fold legacy models into separate menus (#5190)
1 parent 64bf016 commit e60821f

17 files changed

Lines changed: 475 additions & 88 deletions

apps/mobile/src/features/threads/NewTaskDraftScreen.tsx

Lines changed: 2 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ import {
4343
type ComposerDraft,
4444
} from "../../state/use-composer-drafts";
4545
import { useEnvironmentServerConfig, useProjects } from "../../state/entities";
46-
import { resolveSelectableModelSelection } from "../../lib/modelOptions";
46+
import { buildModelMenuActions, resolveSelectableModelSelection } from "../../lib/modelOptions";
4747
import { deriveThreadTitleFromPrompt } from "../../lib/projectThreadStartTurn";
4848
import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration";
4949
import { enqueueThreadOutboxMessage, removeThreadOutboxMessage } from "../../state/thread-outbox";
@@ -543,27 +543,7 @@ export function NewTaskDraftScreen(props: {
543543
);
544544

545545
const modelMenuActions = useMemo(
546-
() =>
547-
flow.providerGroups.map((group) => ({
548-
id: `provider:${group.providerKey}`,
549-
title: group.providerLabel,
550-
subtitle: group.models.find(
551-
(model) =>
552-
flow.selectedModel &&
553-
model.selection.instanceId === flow.selectedModel.instanceId &&
554-
model.selection.model === flow.selectedModel.model,
555-
)?.label,
556-
subactions: group.models.map((option) => ({
557-
id: `model:${option.key}`,
558-
title: option.label,
559-
state:
560-
flow.selectedModel &&
561-
option.selection.instanceId === flow.selectedModel.instanceId &&
562-
option.selection.model === flow.selectedModel.model
563-
? ("on" as const)
564-
: undefined,
565-
})),
566-
})),
546+
() => buildModelMenuActions(flow.providerGroups, flow.selectedModel),
567547
[flow.providerGroups, flow.selectedModel],
568548
);
569549
const providerOptionDescriptors = useMemo(

apps/mobile/src/features/threads/ThreadComposer.tsx

Lines changed: 2 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ import {
5353
import { ControlPill, ControlPillMenu } from "../../components/ControlPill";
5454
import { ProviderIcon } from "../../components/ProviderIcon";
5555
import type { DraftComposerImageAttachment } from "../../lib/composerImages";
56-
import { buildModelOptions, groupByProvider } from "../../lib/modelOptions";
56+
import { buildModelMenuActions, buildModelOptions, groupByProvider } from "../../lib/modelOptions";
5757
import { useScaledTextRole } from "../settings/appearance/useScaledTextRole";
5858
import type { RemoteClientConnectionState } from "../../lib/connection";
5959
import {
@@ -606,25 +606,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
606606
[providerOptionDescriptors],
607607
);
608608
const modelMenuActions = useMemo(
609-
() =>
610-
providerGroups.map((group) => ({
611-
id: `provider:${group.providerKey}`,
612-
title: group.providerLabel,
613-
subtitle: group.models.find(
614-
(model) =>
615-
model.selection.instanceId === currentModelSelection.instanceId &&
616-
model.selection.model === currentModelSelection.model,
617-
)?.label,
618-
subactions: group.models.map((option) => ({
619-
id: `model:${option.key}`,
620-
title: option.label,
621-
state:
622-
option.selection.instanceId === currentModelSelection.instanceId &&
623-
option.selection.model === currentModelSelection.model
624-
? ("on" as const)
625-
: undefined,
626-
})),
627-
})),
609+
() => buildModelMenuActions(providerGroups, currentModelSelection),
628610
[providerGroups, currentModelSelection],
629611
);
630612

apps/mobile/src/lib/modelOptions.test.ts

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,92 @@ import { describe, expect, it } from "vite-plus/test";
22

33
import { ProviderInstanceId, type ServerConfig } from "@t3tools/contracts";
44

5-
import { buildModelOptions, resolveSelectableModelSelection } from "./modelOptions";
5+
import {
6+
buildModelMenuActions,
7+
buildModelOptions,
8+
groupByProvider,
9+
resolveSelectableModelSelection,
10+
} from "./modelOptions";
611

712
describe("mobile model options", () => {
13+
it("folds legacy models into a provider-scoped menu", () => {
14+
const config = {
15+
providers: [
16+
{
17+
instanceId: "codex",
18+
driver: "codex",
19+
displayName: "Codex",
20+
enabled: true,
21+
installed: true,
22+
auth: { status: "authenticated" },
23+
models: [
24+
{
25+
slug: "gpt-5.6-sol",
26+
name: "GPT-5.6 Sol",
27+
isCustom: false,
28+
capabilities: null,
29+
},
30+
{
31+
slug: "gpt-5.4",
32+
name: "GPT-5.4",
33+
isCustom: false,
34+
isLegacy: true,
35+
capabilities: null,
36+
},
37+
],
38+
},
39+
],
40+
} as unknown as ServerConfig;
41+
42+
const actions = buildModelMenuActions(groupByProvider(buildModelOptions(config, null)), null);
43+
44+
expect(actions).toMatchObject([
45+
{
46+
title: "Codex",
47+
subactions: [{ id: "model:codex:gpt-5.6-sol", title: "GPT-5.6 Sol" }],
48+
},
49+
{
50+
id: "legacy-models:codex",
51+
title: "Codex legacy models",
52+
subactions: [{ id: "model:codex:gpt-5.4", title: "GPT-5.4" }],
53+
},
54+
]);
55+
});
56+
57+
it("omits an empty provider menu when every model is legacy", () => {
58+
const config = {
59+
providers: [
60+
{
61+
instanceId: "codex",
62+
driver: "codex",
63+
displayName: "Codex",
64+
enabled: true,
65+
installed: true,
66+
auth: { status: "authenticated" },
67+
models: [
68+
{
69+
slug: "gpt-5.4",
70+
name: "GPT-5.4",
71+
isCustom: false,
72+
isLegacy: true,
73+
capabilities: null,
74+
},
75+
],
76+
},
77+
],
78+
} as unknown as ServerConfig;
79+
80+
expect(
81+
buildModelMenuActions(groupByProvider(buildModelOptions(config, null)), null),
82+
).toMatchObject([
83+
{
84+
id: "legacy-models:codex",
85+
title: "Codex legacy models",
86+
subactions: [{ id: "model:codex:gpt-5.4" }],
87+
},
88+
]);
89+
});
90+
891
it("normalizes a legacy fallback selection against current capabilities", () => {
992
const config = {
1093
providers: [

apps/mobile/src/lib/modelOptions.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type {
33
ModelSelection,
44
ServerConfig as T3ServerConfig,
55
} from "@t3tools/contracts";
6+
import type { MenuAction } from "@react-native-menu/menu";
67
import {
78
buildProviderOptionSelectionsFromDescriptors,
89
getProviderOptionDescriptors,
@@ -16,6 +17,7 @@ export type ModelOption = {
1617
readonly providerLabel: string;
1718
readonly providerDriver: string;
1819
readonly isDefault: boolean;
20+
readonly isLegacy: boolean;
1921
readonly capabilities: ModelCapabilities | null;
2022
readonly selection: ModelSelection;
2123
};
@@ -105,6 +107,7 @@ export function buildModelOptions(
105107
providerLabel,
106108
providerDriver: provider.driver,
107109
isDefault: model.isDefault === true,
110+
isLegacy: model.isLegacy === true,
108111
capabilities: model.capabilities,
109112
selection: normalizeSelectionOptions(
110113
{
@@ -135,6 +138,7 @@ export function buildModelOptions(
135138
providerLabel,
136139
providerDriver: fallbackModelSelection.instanceId,
137140
isDefault: false,
141+
isLegacy: false,
138142
capabilities: null,
139143
selection: fallbackModelSelection,
140144
});
@@ -164,3 +168,53 @@ export function groupByProvider(options: ReadonlyArray<ModelOption>): ReadonlyAr
164168
models: group.models,
165169
}));
166170
}
171+
172+
function modelMenuAction(option: ModelOption, selectedModel: ModelSelection | null): MenuAction {
173+
return {
174+
id: `model:${option.key}`,
175+
title: option.label,
176+
state:
177+
option.selection.instanceId === selectedModel?.instanceId &&
178+
option.selection.model === selectedModel.model
179+
? "on"
180+
: undefined,
181+
};
182+
}
183+
184+
export function buildModelMenuActions(
185+
groups: ReadonlyArray<ProviderGroup>,
186+
selectedModel: ModelSelection | null,
187+
): MenuAction[] {
188+
return groups.flatMap((group) => {
189+
const currentModels = group.models.filter((model) => !model.isLegacy);
190+
const legacyModels = group.models.filter((model) => model.isLegacy);
191+
const selected = group.models.find(
192+
(model) =>
193+
model.selection.instanceId === selectedModel?.instanceId &&
194+
model.selection.model === selectedModel.model,
195+
);
196+
197+
return [
198+
...(currentModels.length > 0
199+
? [
200+
{
201+
id: `provider:${group.providerKey}`,
202+
title: group.providerLabel,
203+
subtitle: selected && !selected.isLegacy ? selected.label : undefined,
204+
subactions: currentModels.map((option) => modelMenuAction(option, selectedModel)),
205+
},
206+
]
207+
: []),
208+
...(legacyModels.length > 0
209+
? [
210+
{
211+
id: `legacy-models:${group.providerKey}`,
212+
title: `${group.providerLabel} legacy models`,
213+
subtitle: selected?.isLegacy ? selected.label : undefined,
214+
subactions: legacyModels.map((option) => modelMenuAction(option, selectedModel)),
215+
},
216+
]
217+
: []),
218+
];
219+
});
220+
}

apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,27 @@ import * as Schema from "effect/Schema";
99
import {
1010
buildClaudeCapabilitiesProbeQueryOptions,
1111
CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES,
12+
isLegacyClaudeModel,
1213
probeClaudeCapabilities,
1314
} from "./ClaudeProvider.ts";
1415

1516
const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings);
1617

18+
it("keeps only the Claude 5 family out of legacy models", () => {
19+
assert.deepStrictEqual(
20+
["claude-fable-5", "claude-opus-5", "claude-sonnet-5", "claude-opus-4-8"].map((model) => [
21+
model,
22+
isLegacyClaudeModel(model),
23+
]),
24+
[
25+
["claude-fable-5", false],
26+
["claude-opus-5", false],
27+
["claude-sonnet-5", false],
28+
["claude-opus-4-8", true],
29+
],
30+
);
31+
});
32+
1733
it("isolates Claude capability probes without dropping workspace setting sources", () => {
1834
const abortController = new AbortController();
1935
const options = buildClaudeCapabilitiesProbeQueryOptions({

apps/server/src/provider/Layers/ClaudeProvider.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,13 @@ const MINIMUM_CLAUDE_FABLE_5_VERSION = "2.1.169";
5656
const MINIMUM_CLAUDE_OPUS_4_8_VERSION = "2.1.154";
5757
const MINIMUM_CLAUDE_OPUS_4_7_VERSION = "2.1.111";
5858

59-
const BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [
59+
const CURRENT_CLAUDE_MODELS = new Set(["claude-fable-5", "claude-opus-5", "claude-sonnet-5"]);
60+
61+
export function isLegacyClaudeModel(model: string): boolean {
62+
return !CURRENT_CLAUDE_MODELS.has(model);
63+
}
64+
65+
const CLAUDE_MODEL_CATALOG: ReadonlyArray<ServerProviderModel> = [
6066
{
6167
slug: "claude-fable-5",
6268
name: "Claude Fable 5",
@@ -309,6 +315,10 @@ const BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [
309315
},
310316
];
311317

318+
const BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = CLAUDE_MODEL_CATALOG.map((model) =>
319+
isLegacyClaudeModel(model.slug) ? { ...model, isLegacy: true } : model,
320+
);
321+
312322
function supportsClaudeOpus5(version: string | null | undefined): boolean {
313323
return version ? compareSemverVersions(version, MINIMUM_CLAUDE_OPUS_5_VERSION) >= 0 : false;
314324
}

apps/server/src/provider/Layers/CodexProvider.test.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,25 @@
11
import { assert, it } from "@effect/vitest";
22

3-
import { applyPreferredCodexDefaultModel, mapCodexModelCapabilities } from "./CodexProvider.ts";
3+
import {
4+
applyPreferredCodexDefaultModel,
5+
isLegacyCodexModel,
6+
mapCodexModelCapabilities,
7+
} from "./CodexProvider.ts";
8+
9+
it("keeps only the GPT-5.6 Codex family out of legacy models", () => {
10+
assert.deepStrictEqual(
11+
["gpt-5.6-luna", "gpt-5.6-terra", "gpt-5.6-sol", "gpt-5.4"].map((model) => [
12+
model,
13+
isLegacyCodexModel(model),
14+
]),
15+
[
16+
["gpt-5.6-luna", false],
17+
["gpt-5.6-terra", false],
18+
["gpt-5.6-sol", false],
19+
["gpt-5.4", true],
20+
],
21+
);
22+
});
423

524
it("maps current Codex model capability fields", () => {
625
const capabilities = mapCodexModelCapabilities({

apps/server/src/provider/Layers/CodexProvider.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,11 @@ const REASONING_EFFORT_LABELS: Readonly<Record<string, string>> = {
6262
};
6363

6464
const DEFAULT_SERVICE_TIER_ID = "default";
65+
const CURRENT_CODEX_MODELS = new Set(["gpt-5.6-luna", "gpt-5.6-terra", "gpt-5.6-sol"]);
66+
67+
export function isLegacyCodexModel(model: string): boolean {
68+
return !CURRENT_CODEX_MODELS.has(model);
69+
}
6570

6671
function reasoningEffortLabel(reasoningEffort: string): string {
6772
return REASONING_EFFORT_LABELS[reasoningEffort] ?? reasoningEffort;
@@ -190,6 +195,7 @@ function parseCodexModelListResponse(
190195
name: toDisplayName(model),
191196
isCustom: false,
192197
...(model.isDefault ? { isDefault: true } : {}),
198+
...(isLegacyCodexModel(model.model) ? { isLegacy: true } : {}),
193199
capabilities: mapCodexModelCapabilities(model),
194200
}));
195201
}

apps/web/src/components/chat/ModelListRow.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { Button } from "../ui/button";
1212
import { Kbd } from "../ui/kbd";
1313
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
1414
import { cn } from "~/lib/utils";
15+
import { modelPickerModelKey } from "./modelPickerKeys";
1516

1617
export const ModelListRow = memo(function ModelListRow(props: {
1718
index: number;
@@ -46,7 +47,7 @@ export const ModelListRow = memo(function ModelListRow(props: {
4647
<ComboboxItem
4748
hideIndicator
4849
index={props.index}
49-
value={`${props.instanceId}:${props.model.slug}`}
50+
value={modelPickerModelKey(props.instanceId, props.model.slug)}
5051
disabled={Boolean(props.disabledReason)}
5152
contentClassName="flex w-full items-center gap-3"
5253
className={cn(

0 commit comments

Comments
 (0)