authorgravatar for maxtheyoung@gmail.comMaxwell Young <maxtheyoung@gmail.com> 2026-07-20 23:32:30+12:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-20 13:32:30+02:00
logfa69f05b69d05003a6994194c12edd1d8aee080c
tree879aa3622f9b3790869c873c86955073fbd804eb
parentc710167bde19e665c8517b606718bfbd406f2dd1
signature Signed by PGP key B5690EEEBB952194

[codex] preserve custom model slugs (#4168)


11 files changed, 88 insertions(+), 58 deletions(-)

apps/server/src/provider/Layers/ClaudeProvider.ts-5
......@@ -2,7 +2,6 @@ import {
22 type ClaudeSettings,
33 type ModelCapabilities,
44 type ModelSelection,
5 ProviderDriverKind,
65 type ServerProviderModel,
76 type ServerProviderSlashCommand,
87} from "@t3tools/contracts";
......@@ -44,7 +43,6 @@ const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabili
4443 optionDescriptors: [],
4544});
4645
47const PROVIDER = ProviderDriverKind.make("claudeAgent");
4846const CLAUDE_PRESENTATION = {
4947 displayName: "Claude",
5048 showInteractionModeToggle: true,
......@@ -691,7 +689,6 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
691689 const checkedAt = DateTime.formatIso(yield* DateTime.now);
692690 const allModels = providerModelsFromSettings(
693691 BUILT_IN_MODELS,
694 PROVIDER,
695692 claudeSettings.customModels,
696693 DEFAULT_CLAUDE_MODEL_CAPABILITIES,
697694 );
......@@ -782,7 +779,6 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
782779
783780 const models = providerModelsFromSettings(
784781 getBuiltInClaudeModelsForVersion(parsedVersion),
785 PROVIDER,
786782 claudeSettings.customModels,
787783 DEFAULT_CLAUDE_MODEL_CAPABILITIES,
788784 );
......@@ -851,7 +847,6 @@ export const makePendingClaudeProvider = (
851847 const checkedAt = yield* nowIso;
852848 const models = providerModelsFromSettings(
853849 BUILT_IN_MODELS,
854 PROVIDER,
855850 claudeSettings.customModels,
856851 DEFAULT_CLAUDE_MODEL_CAPABILITIES,
857852 );
apps/server/src/provider/Layers/CursorProvider.ts+1-4
......@@ -8,7 +8,6 @@ import type {
88 ServerProviderModel,
99 ServerProviderState,
1010} from "@t3tools/contracts";
11import { ProviderDriverKind } from "@t3tools/contracts";
1211import type * as EffectAcpSchema from "effect-acp/schema";
1312import { causeErrorTag } from "@t3tools/shared/observability";
1413import * as Crypto from "effect/Crypto";
......@@ -51,7 +50,6 @@ import { CursorListAvailableModelsResponse } from "../acp/CursorAcpExtension.ts"
5150const decodeCursorListAvailableModelsResponse = Schema.decodeUnknownEffect(
5251 CursorListAvailableModelsResponse,
5352);
54const PROVIDER = ProviderDriverKind.make("cursor");
5553const CURSOR_PRESENTATION = {
5654 displayName: "Cursor",
5755 badgeLabel: "Early Access",
......@@ -576,7 +574,7 @@ export const discoverCursorModelsViaAcp = (
576574export function getCursorFallbackModels(
577575 cursorSettings: Pick<CursorSettings, "customModels">,
578576): ReadonlyArray<ServerProviderModel> {
579 return providerModelsFromSettings([], PROVIDER, cursorSettings.customModels, EMPTY_CAPABILITIES);
577 return providerModelsFromSettings([], cursorSettings.customModels, EMPTY_CAPABILITIES);
580578}
581579
582580/** Timeout for `agent about` — it's slower than a simple `--version` probe. */
......@@ -638,7 +636,6 @@ export function buildCursorProviderSnapshot(input: {
638636 checkedAt: input.checkedAt,
639637 models: providerModelsFromSettings(
640638 input.discoveredModels ?? [],
641 PROVIDER,
642639 input.cursorSettings.customModels,
643640 EMPTY_CAPABILITIES,
644641 ),
apps/server/src/provider/Layers/GrokProvider.ts+1-8
......@@ -1,7 +1,6 @@
11import {
22 type GrokSettings,
33 type ModelCapabilities,
4 ProviderDriverKind,
54 type ServerProvider,
65 type ServerProviderModel,
76} from "@t3tools/contracts";
......@@ -38,7 +37,6 @@ const GROK_PRESENTATION = {
3837 showInteractionModeToggle: false,
3938 requiresNewThreadForModelChange: true,
4039} as const;
41const PROVIDER = ProviderDriverKind.make("grok");
4240const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({
4341 optionDescriptors: [],
4442});
......@@ -98,12 +96,7 @@ function grokModelsFromSettings(
9896 customModels: ReadonlyArray<string> | undefined,
9997 builtInModels: ReadonlyArray<ServerProviderModel> = GROK_BUILT_IN_MODELS,
10098): ReadonlyArray<ServerProviderModel> {
101 return providerModelsFromSettings(
102 builtInModels,
103 PROVIDER,
104 customModels ?? [],
105 EMPTY_CAPABILITIES,
106 );
99 return providerModelsFromSettings(builtInModels, customModels ?? [], EMPTY_CAPABILITIES);
107100}
108101
109102function buildGrokDiscoveredModelsFromSessionModelState(
apps/server/src/provider/Layers/OpenCodeProvider.ts+3-22
......@@ -1,5 +1,4 @@
11import {
2 ProviderDriverKind,
32 type ModelCapabilities,
43 type OpenCodeSettings,
54 type ServerProviderModel,
......@@ -25,7 +24,6 @@ import {
2524} from "../opencodeRuntime.ts";
2625import type { Agent, ProviderListResponse } from "@opencode-ai/sdk/v2";
2726
28const PROVIDER = ProviderDriverKind.make("opencode");
2927const OPENCODE_PRESENTATION = {
3028 displayName: "OpenCode",
3129 showInteractionModeToggle: false,
......@@ -259,7 +257,6 @@ export const makePendingOpenCodeProvider = (
259257 const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso);
260258 const models = providerModelsFromSettings(
261259 [],
262 PROVIDER,
263260 openCodeSettings.customModels,
264261 DEFAULT_OPENCODE_MODEL_CAPABILITIES,
265262 );
......@@ -319,12 +316,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu
319316 presentation: OPENCODE_PRESENTATION,
320317 enabled: openCodeSettings.enabled,
321318 checkedAt,
322 models: providerModelsFromSettings(
323 [],
324 PROVIDER,
325 customModels,
326 DEFAULT_OPENCODE_MODEL_CAPABILITIES,
327 ),
319 models: providerModelsFromSettings([], customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES),
328320 probe: {
329321 installed: failure.installed,
330322 version,
......@@ -340,12 +332,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu
340332 presentation: OPENCODE_PRESENTATION,
341333 enabled: false,
342334 checkedAt,
343 models: providerModelsFromSettings(
344 [],
345 PROVIDER,
346 customModels,
347 DEFAULT_OPENCODE_MODEL_CAPABILITIES,
348 ),
335 models: providerModelsFromSettings([], customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES),
349336 probe: {
350337 installed: false,
351338 version: null,
......@@ -391,12 +378,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu
391378 presentation: OPENCODE_PRESENTATION,
392379 enabled: openCodeSettings.enabled,
393380 checkedAt,
394 models: providerModelsFromSettings(
395 [],
396 PROVIDER,
397 customModels,
398 DEFAULT_OPENCODE_MODEL_CAPABILITIES,
399 ),
381 models: providerModelsFromSettings([], customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES),
400382 probe: {
401383 installed: true,
402384 version,
......@@ -444,7 +426,6 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu
444426
445427 const models = providerModelsFromSettings(
446428 flattenOpenCodeModels(inventoryExit.value),
447 PROVIDER,
448429 customModels,
449430 DEFAULT_OPENCODE_MODEL_CAPABILITIES,
450431 );
apps/server/src/provider/providerSnapshot.test.ts+20-2
......@@ -1,5 +1,5 @@
11import { describe, expect, it } from "@effect/vitest";
2import { ProviderDriverKind, type ModelCapabilities } from "@t3tools/contracts";
2import type { ModelCapabilities } from "@t3tools/contracts";
33import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
44import { createModelCapabilities } from "@t3tools/shared/model";
55import * as Effect from "effect/Effect";
......@@ -38,7 +38,6 @@ describe("providerModelsFromSettings", () => {
3838 it("applies the provided capabilities to custom models", () => {
3939 const models = providerModelsFromSettings(
4040 [],
41 ProviderDriverKind.make("opencode"),
4241 ["openai/gpt-5"],
4342 OPENCODE_CUSTOM_MODEL_CAPABILITIES,
4443 );
......@@ -52,6 +51,25 @@ describe("providerModelsFromSettings", () => {
5251 },
5352 ]);
5453 });
54
55 it("preserves a custom slug that collides with a provider alias", () => {
56 const capabilities = createModelCapabilities({ optionDescriptors: [] });
57 const models = providerModelsFromSettings(
58 [
59 {
60 slug: "claude-opus-4-8",
61 name: "Claude Opus 4.8",
62 isCustom: false,
63 capabilities,
64 },
65 ],
66 [" opus "],
67 capabilities,
68 );
69
70 expect(models.map((model) => model.slug)).toEqual(["claude-opus-4-8", "opus"]);
71 expect(models[1]?.isCustom).toBe(true);
72 });
5573});
5674
5775describe("ProviderCommandNotFoundError", () => {
apps/server/src/provider/providerSnapshot.ts+2-3
......@@ -13,7 +13,7 @@ import * as PlatformError from "effect/PlatformError";
1313import * as Schema from "effect/Schema";
1414import * as Stream from "effect/Stream";
1515import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
16import { normalizeModelSlug } from "@t3tools/shared/model";
16import { normalizeCustomModelSlug } from "@t3tools/shared/model";
1717import { isWindowsCommandNotFound } from "../processRunner.ts";
1818import { createProviderVersionAdvisory } from "./providerMaintenance.ts";
1919import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts";
......@@ -140,7 +140,6 @@ export function parseGenericCliVersion(output: string): string | null {
140140
141141export function providerModelsFromSettings(
142142 builtInModels: ReadonlyArray<ServerProviderModel>,
143 provider: ProviderDriverKind,
144143 customModels: ReadonlyArray<string>,
145144 customModelCapabilities: ModelCapabilities,
146145): ReadonlyArray<ServerProviderModel> {
......@@ -149,7 +148,7 @@ export function providerModelsFromSettings(
149148 const customEntries: ServerProviderModel[] = [];
150149
151150 for (const candidate of customModels) {
152 const normalized = normalizeModelSlug(candidate, provider);
151 const normalized = normalizeCustomModelSlug(candidate);
153152 if (!normalized || seen.has(normalized)) {
154153 continue;
155154 }
apps/web/src/components/settings/ProviderModelsSection.tsx+2-2
......@@ -16,7 +16,7 @@ import {
1616 type ProviderInstanceId,
1717 type ServerProviderModel,
1818} from "@t3tools/contracts";
19import { normalizeModelSlug } from "@t3tools/shared/model";
19import { normalizeCustomModelSlug } from "@t3tools/shared/model";
2020
2121import { cn } from "../../lib/utils";
2222import { sortModelsForProviderInstance } from "../../modelOrdering";
......@@ -111,7 +111,7 @@ export function ProviderModelsSection({
111111 }, [favoriteModelSet, modelOrder, models]);
112112
113113 const handleAdd = () => {
114 const normalized = driverKind ? normalizeModelSlug(input, driverKind) : input.trim() || null;
114 const normalized = normalizeCustomModelSlug(input);
115115 if (!normalized) {
116116 setError("Enter a model slug.");
117117 return;
apps/web/src/modelSelection.test.ts+33
......@@ -101,6 +101,39 @@ describe("instance-scoped model selection", () => {
101101 ).toBe("openai/gpt-5.5");
102102 });
103103
104 it("preserves a custom slug that collides with a provider alias", () => {
105 const providers = [
106 provider({
107 provider: ProviderDriverKind.make("claudeAgent"),
108 instanceId: "claude_openrouter",
109 models: ["claude-opus-4-8"],
110 }),
111 ];
112 const settings: UnifiedSettings = {
113 ...settingsWithProviderInstances(),
114 providerInstances: {
115 ...settingsWithProviderInstances().providerInstances,
116 [ProviderInstanceId.make("claude_openrouter")]: {
117 driver: ProviderDriverKind.make("claudeAgent"),
118 config: { customModels: ["opus"] },
119 },
120 },
121 };
122 const openrouter = deriveProviderInstanceEntries(providers)[0]!;
123
124 expect(
125 getAppModelOptionsForInstance(settings, openrouter).map((option) => option.slug),
126 ).toEqual(["claude-opus-4-8", "opus"]);
127 expect(
128 resolveAppModelSelectionForInstance(
129 ProviderInstanceId.make("claude_openrouter"),
130 settings,
131 providers,
132 "opus",
133 ),
134 ).toBe("opus");
135 });
136
104137 it("includes Grok custom models from the selected provider instance", () => {
105138 const providers = [provider({ provider: ProviderDriverKind.make("grok"), instanceId: "grok" })];
106139 const settings: UnifiedSettings = {
apps/web/src/modelSelection.ts+4-6
......@@ -9,7 +9,7 @@ import {
99} from "@t3tools/contracts";
1010import {
1111 createModelSelection,
12 normalizeModelSlug,
12 normalizeCustomModelSlug,
1313 resolveSelectableModel,
1414} from "@t3tools/shared/model";
1515import { getComposerProviderState } from "./components/chat/composerProviderState";
......@@ -117,13 +117,12 @@ function applyInstanceModelPreferences(
117117export function normalizeCustomModelSlugs(
118118 models: Iterable<string | null | undefined>,
119119 builtInModelSlugs: ReadonlySet<string>,
120 provider: ProviderDriverKind = ProviderDriverKind.make("codex"),
121120): string[] {
122121 const normalizedModels: string[] = [];
123122 const seen = new Set<string>();
124123
125124 for (const candidate of models) {
126 const normalized = normalizeModelSlug(candidate, provider);
125 const normalized = normalizeCustomModelSlug(candidate);
127126 if (
128127 !normalized ||
129128 normalized.length > MAX_CUSTOM_MODEL_LENGTH ||
......@@ -163,7 +162,7 @@ export function getAppModelOptions(
163162 // see the user's authored custom models.
164163 const defaultInstanceId = defaultInstanceIdForDriver(provider);
165164 const customModels = readInstanceCustomModels(settings, defaultInstanceId, provider);
166 for (const slug of normalizeCustomModelSlugs(customModels, builtInModelSlugs, provider)) {
165 for (const slug of normalizeCustomModelSlugs(customModels, builtInModelSlugs)) {
167166 if (seen.has(slug)) {
168167 continue;
169168 }
......@@ -206,8 +205,7 @@ export function getAppModelOptionsForInstance(
206205 );
207206
208207 const customModels = readInstanceCustomModels(settings, entry.instanceId, entry.driverKind);
209 const normalizer = entry.driverKind;
210 for (const slug of normalizeCustomModelSlugs(customModels, builtInModelSlugs, normalizer)) {
208 for (const slug of normalizeCustomModelSlugs(customModels, builtInModelSlugs)) {
211209 if (seen.has(slug)) {
212210 continue;
213211 }
packages/shared/src/model.test.ts+12-1
......@@ -1,5 +1,5 @@
11import { describe, expect, it } from "vite-plus/test";
2import { ProviderInstanceId, type ModelCapabilities } from "@t3tools/contracts";
2import { ProviderDriverKind, ProviderInstanceId, type ModelCapabilities } from "@t3tools/contracts";
33
44import {
55 buildProviderOptionSelectionsFromDescriptors,
......@@ -10,6 +10,8 @@ import {
1010 getProviderOptionDescriptors,
1111 getProviderOptionBooleanSelectionValue,
1212 getProviderOptionStringSelectionValue,
13 normalizeCustomModelSlug,
14 normalizeModelSlug,
1315} from "./model.ts";
1416
1517const codexCaps: ModelCapabilities = createModelCapabilities({
......@@ -144,3 +146,12 @@ describe("descriptor helpers", () => {
144146 expect(getModelSelectionBooleanOptionValue(selection, "fastMode")).toBe(true);
145147 });
146148});
149
150describe("model slug normalization", () => {
151 it("preserves exact custom slugs instead of expanding provider aliases", () => {
152 const claude = ProviderDriverKind.make("claudeAgent");
153
154 expect(normalizeModelSlug("opus", claude)).toBe("claude-opus-4-8");
155 expect(normalizeCustomModelSlug(" opus ")).toBe("opus");
156 });
157});
packages/shared/src/model.ts+10-5
......@@ -236,11 +236,7 @@ export function normalizeModelSlug(
236236 model: string | null | undefined,
237237 provider: ProviderDriverKind = DEFAULT_PROVIDER_DRIVER_KIND,
238238): string | null {
239 if (typeof model !== "string") {
240 return null;
241 }
242
243 const trimmed = model.trim();
239 const trimmed = normalizeCustomModelSlug(model);
244240 if (!trimmed) {
245241 return null;
246242 }
......@@ -252,6 +248,15 @@ export function normalizeModelSlug(
252248 return typeof aliased === "string" ? aliased : trimmed;
253249}
254250
251/** Custom model identifiers are provider-owned, so only trim them; never expand aliases. */
252export function normalizeCustomModelSlug(model: string | null | undefined): string | null {
253 if (typeof model !== "string") {
254 return null;
255 }
256
257 return model.trim() || null;
258}
259
255260export function resolveSelectableModel(
256261 provider: ProviderDriverKind,
257262 value: string | null | undefined,