History

feat: add custom model names and option descriptors (#9807)

Julius Marminge committed 2 months ago

Commit 5a433241 parent dd7bc1419 files · +1423 −150Browse files

Unified · Split

19 files
  1. apps/server/src/provider/ClaudeModelCatalog.test.ts +56 −0
  2. apps/server/src/provider/ClaudeModelCatalog.ts +43 −24
  3. apps/server/src/provider/Layers/CodexProvider.ts +18 −26
  4. apps/server/src/provider/Layers/GrokProvider.ts +2 −1
  5. apps/server/src/provider/providerSnapshot.test.ts +21 −0
  6. apps/server/src/provider/providerSnapshot.ts +14 −9
  7. apps/web/src/components/settings/CustomModelEditor.tsx +387 −0
  8. apps/web/src/components/settings/ProviderInstanceCard.test.ts +42 −1
  9. apps/web/src/components/settings/ProviderInstanceCard.tsx +30 −21
  10. apps/web/src/components/settings/ProviderModelsSection.tsx +75 −28
  11. apps/web/src/components/settings/customModelEditor.logic.test.ts +259 −0
  12. apps/web/src/components/settings/customModelEditor.logic.ts +258 −0
  13. apps/web/src/modelSelection.ts +23 −27
  14. docs/user/composer.md +6 −0
  15. packages/contracts/src/model.ts +16 −0
  16. packages/contracts/src/settings.test.ts +34 −0
  17. packages/contracts/src/settings.ts +13 −12
  18. packages/shared/src/model.test.ts +52 −0
  19. packages/shared/src/model.ts +74 −1
apps/server/src/provider/ClaudeModelCatalog.test.ts+56−0
@@ -7,9 +7,11 @@ import { · 6 unmodified lines
77 formatClaudeVersionUpgradeMessage,
88 normalizeClaudeCatalogEffort,
99 resolveClaudeCatalogApiModelId,
10+ resolveClaudeCatalogEffort,
1011 resolveClaudeModelCatalog,
1112 resolveClaudeModelsForVersion,
1213 resolveClaudeModelSlug,
14+ scopeClaudeModelCatalog,
1315 } from "./ClaudeModelCatalog.ts";
1416
1517 /**
@@ -134,4 +136,58 @@ describe("Claude model catalog", () => { · 118 unmodified lines
134136 };
135137 assert.isFalse(hasValidClaudeManifestAdapters(malformed));
136138 });
139+
140+ it("appends custom models with their own descriptors and keeps bare slugs opaque", () => {
141+ const catalog = scopeClaudeModelCatalog(resolveClaudeModelCatalog(manifest()), [
142+ "synthetic",
143+ {
144+ slug: "claude-custom-tuned",
145+ name: "Tuned",
146+ capabilities: {
147+ optionDescriptors: [
148+ {
149+ id: "effort",
150+ label: "Reasoning",
151+ type: "select",
152+ options: [
153+ { id: "gentle", label: "Gentle", isDefault: true },
154+ { id: "brutal", label: "Brutal" },
155+ ],
156+ },
157+ ],
158+ },
159+ },
160+ ]);
161+
162+ // The bare custom slug shadows the built-in alias, so it no longer resolves to it.
163+ assert.strictEqual(resolveClaudeModelSlug(catalog, "synthetic"), "synthetic");
164+ assert.strictEqual(resolveClaudeCatalogEffort(catalog, "synthetic", "extreme"), undefined);
165+
166+ // The entry with descriptors resolves user-defined effort ids and passes
167+ // them through untouched (no effortMap, no model suffix).
168+ assert.strictEqual(
169+ resolveClaudeCatalogEffort(catalog, "claude-custom-tuned", "brutal"),
170+ "brutal",
171+ );
172+ assert.strictEqual(
173+ resolveClaudeCatalogEffort(catalog, "claude-custom-tuned", "bogus"),
174+ "gentle",
175+ );
176+ assert.strictEqual(
177+ normalizeClaudeCatalogEffort(catalog, "brutal", "claude-custom-tuned"),
178+ "brutal",
179+ );
180+ assert.strictEqual(
181+ resolveClaudeCatalogApiModelId(catalog, {
182+ instanceId: ProviderInstanceId.make("claudeAgent"),
183+ model: "claude-custom-tuned",
184+ options: [{ id: "effort", value: "brutal" }],
185+ }),
186+ "claude-custom-tuned",
187+ );
188+ assert.deepStrictEqual(
189+ resolveClaudeModelsForVersion(catalog, "3.2.0").map((model) => model.slug),
190+ ["claude-synthetic-next", "claude-custom-tuned"],
191+ );
192+ });
137193 });
apps/server/src/provider/ClaudeModelCatalog.ts+43−24
@@ -1,4 +1,5 @@
11 import {
2+ type CustomModelSetting,
23 type ModelCapabilities,
34 type ModelSelection,
45 ProviderDriverKind,
@@ -9,7 +10,7 @@ import { · 4 unmodified lines
910 getModelSelectionStringOptionValue,
1011 getProviderOptionCurrentValue,
1112 getProviderOptionDescriptors,
12− normalizeCustomModelSlug,
13+ readCustomModelEntries,
1314 } from "@t3tools/shared/model";
1415 import { compareSemverVersions } from "@t3tools/shared/semver";
1516
@@ -70,33 +71,51 @@ export function resolveClaudeModelCatalog(manifest: ModelManifestData): ClaudeMo · 54 unmodified lines
7071
7172 export const BUNDLED_CLAUDE_MODEL_CATALOG = resolveClaudeModelCatalog(BUNDLED_MODEL_MANIFEST);
7273
73−/** Keeps custom model aliases opaque while preserving canonical built-in models and capabilities. */
74+/**
75+ * Scope the catalog to one instance's settings: custom model slugs stay opaque
76+ * (a built-in alias they shadow is dropped, canonical slugs and capabilities
77+ * are preserved), and custom entries that declare their own capabilities are
78+ * appended so the adapter resolves effort / fast mode / thinking against the
79+ * user's descriptors instead of the empty default. Custom entries carry no
80+ * runtime profile, so option values pass through to Claude Code verbatim.
81+ */
7482 export function scopeClaudeModelCatalog(
7583 catalog: ClaudeModelCatalog,
76− customModels: ReadonlyArray<string>,
84+ customModels: ReadonlyArray<CustomModelSetting>,
7785 ): ClaudeModelCatalog {
78− const customAliases = new Set(
79− customModels.flatMap((model) => {
80− const slug = normalizeCustomModelSlug(model);
81− return slug ? [slug.toLowerCase()] : [];
82− }),
83− );
84− if (customAliases.size === 0) return catalog;
86+ const customEntries = readCustomModelEntries(customModels);
87+ if (customEntries.length === 0) return catalog;
88+ const customAliases = new Set(customEntries.map((entry) => entry.slug.toLowerCase()));
8589
86− return {
87− models: catalog.models.map((entry) => {
88− if (!entry.model.aliases?.some((alias) => customAliases.has(alias.toLowerCase()))) {
89− return entry;
90− }
91− return {
92− ...entry,
93− model: {
94− ...entry.model,
95− aliases: entry.model.aliases.filter((alias) => !customAliases.has(alias.toLowerCase())),
96− },
97− };
98− }),
99− };
90+ const builtInModels = catalog.models.map((entry) => {
91+ if (!entry.model.aliases?.some((alias) => customAliases.has(alias.toLowerCase()))) {
92+ return entry;
93+ }
94+ return {
95+ ...entry,
96+ model: {
97+ ...entry.model,
98+ aliases: entry.model.aliases.filter((alias) => !customAliases.has(alias.toLowerCase())),
99+ },
100+ };
101+ });
102+ const builtInSlugs = new Set(builtInModels.map((entry) => entry.model.slug));
103+ const customCatalogModels: Array<ClaudeCatalogModel> = [];
104+ for (const entry of customEntries) {
105+ if (!entry.capabilities || builtInSlugs.has(entry.slug)) continue;
106+ customCatalogModels.push({
107+ model: {
108+ slug: entry.slug,
109+ name: entry.name,
110+ isCustom: true,
111+ capabilities: entry.capabilities,
112+ },
113+ runtime: {},
114+ compatibility: {},
115+ });
116+ }
117+
118+ return { models: [...builtInModels, ...customCatalogModels] };
100119 }
101120
102121 export function resolveClaudeCatalogModel(
apps/server/src/provider/Layers/CodexProvider.ts+18−26
@@ -15,6 +15,7 @@ import * as CodexErrors from "effect-codex-app-server/errors"; · 14 unmodified lines
1515
1616 import type {
1717 CodexSettings,
18+ CustomModelSetting,
1819 ServerProvider,
1920 ServerProviderState,
2021 ModelCapabilities,
@@ -24,7 +25,7 @@ import type { · 3 unmodified lines
2425 } from "@t3tools/contracts";
2526 import { PREFERRED_DEFAULT_CODEX_MODELS, ServerSettingsError } from "@t3tools/contracts";
2627
27−import { createModelCapabilities } from "@t3tools/shared/model";
28+import { createModelCapabilities, readCustomModelEntries } from "@t3tools/shared/model";
2829 import { resolveSpawnCommand } from "@t3tools/shared/shell";
2930 import { codexAppServerArgs, resolveCodexLaunchArgs } from "./codexLaunchArgs.ts";
3031 import {
@@ -244,9 +245,14 @@ export function applyPreferredCodexDefaultModel( · 213 unmodified lines
244245 });
245246 }
246247
248+/**
249+ * Codex has no static default capability set, so a bare custom slug borrows
250+ * the first built-in's descriptors; an entry with its own capabilities keeps
251+ * them.
252+ */
247253 function appendCustomCodexModels(
248254 models: ReadonlyArray<ServerProviderModel>,
249− customModels: ReadonlyArray<string>,
255+ customModels: ReadonlyArray<CustomModelSetting>,
250256 ): ReadonlyArray<ServerProviderModel> {
251257 if (customModels.length === 0) {
252258 return models;
@@ -255,17 +261,16 @@ function appendCustomCodexModels( · 2 unmodified lines
255261 const seen = new Set(models.map((model) => model.slug));
256262 const fallbackCapabilities = models.find((model) => model.capabilities)?.capabilities ?? null;
257263 const customEntries: ServerProviderModel[] = [];
258− for (const rawModel of customModels) {
259− const slug = rawModel.trim();
260− if (!slug || seen.has(slug)) {
264+ for (const entry of readCustomModelEntries(customModels)) {
265+ if (seen.has(entry.slug)) {
261266 continue;
262267 }
263− seen.add(slug);
268+ seen.add(entry.slug);
264269 customEntries.push({
265− slug,
266− name: slug,
270+ slug: entry.slug,
271+ name: entry.name,
267272 isCustom: true,
268− capabilities: fallbackCapabilities,
273+ capabilities: entry.capabilities ?? fallbackCapabilities,
269274 });
270275 }
271276 return customEntries.length === 0 ? models : [...models, ...customEntries];
@@ -399,7 +404,7 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun · 127 unmodified lines
399404 readonly homePath?: string;
400405 readonly launchArgs?: string;
401406 readonly cwd: string;
402− readonly customModels?: ReadonlyArray<string>;
407+ readonly customModels?: ReadonlyArray<CustomModelSetting>;
403408 readonly environment?: NodeJS.ProcessEnv;
404409 }) {
405410 const { client, initialize } = yield* withCodexAppServerClient(input);
@@ -470,21 +475,8 @@ export const probeCodexSkillsForCwd = Effect.fn("probeCodexSkillsForCwd")(functi · 64 unmodified lines
470475 return parseCodexSkillsListResponse(skillsResponse, input.cwd);
471476 });
472477
473−const emptyCodexModelsFromSettings = (codexSettings: CodexSettings): ServerProvider["models"] => {
474− const models = new Set<string>();
475− for (const model of codexSettings.customModels) {
476− const trimmed = model.trim();
477− if (trimmed.length > 0) {
478− models.add(trimmed);
479− }
480− }
481− return Array.from(models, (model) => ({
482− slug: model,
483− name: model,
484− isCustom: true,
485− capabilities: null,
486− }));
487−};
478+const emptyCodexModelsFromSettings = (codexSettings: CodexSettings): ServerProvider["models"] =>
479+ appendCustomCodexModels([], codexSettings.customModels);
488480
489481 const makePendingCodexProvider = (
490482 codexSettings: CodexSettings,
@@ -562,7 +554,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu · 71 unmodified lines
562554 readonly homePath?: string;
563555 readonly launchArgs?: string;
564556 readonly cwd: string;
565− readonly customModels: ReadonlyArray<string>;
557+ readonly customModels: ReadonlyArray<CustomModelSetting>;
566558 readonly environment?: NodeJS.ProcessEnv;
567559 }) => Effect.Effect<
568560 CodexAppServerProviderSnapshot,
apps/server/src/provider/Layers/GrokProvider.ts+2−1
@@ -1,4 +1,5 @@
11 import {
2+ type CustomModelSetting,
23 type GrokSettings,
34 type ModelCapabilities,
45 type ServerProvider,
@@ -104,7 +105,7 @@ export function buildInitialGrokProviderSnapshot( · 99 unmodified lines
104105 }
105106
106107 function grokModelsFromSettings(
107− customModels: ReadonlyArray<string> | undefined,
108+ customModels: ReadonlyArray<CustomModelSetting> | undefined,
108109 builtInModels: ReadonlyArray<ServerProviderModel> = GROK_BUILT_IN_MODELS,
109110 ): ReadonlyArray<ServerProviderModel> {
110111 return providerModelsFromSettings(builtInModels, customModels ?? [], EMPTY_CAPABILITIES);
apps/server/src/provider/providerSnapshot.test.ts+21−0
@@ -52,6 +52,27 @@ describe("providerModelsFromSettings", () => { · 51 unmodified lines
5252 ]);
5353 });
5454
55+ it("keeps an entry's own name and capabilities over the driver default", () => {
56+ const capabilities = createModelCapabilities({
57+ optionDescriptors: [{ id: "fastMode", label: "Fast Mode", type: "boolean" }],
58+ });
59+ const models = providerModelsFromSettings(
60+ [],
61+ ["bare", { slug: "named", name: "Named", capabilities }],
62+ OPENCODE_CUSTOM_MODEL_CAPABILITIES,
63+ );
64+
65+ expect(models).toEqual([
66+ {
67+ slug: "bare",
68+ name: "bare",
69+ isCustom: true,
70+ capabilities: OPENCODE_CUSTOM_MODEL_CAPABILITIES,
71+ },
72+ { slug: "named", name: "Named", isCustom: true, capabilities },
73+ ]);
74+ });
75+
5576 it("preserves a custom slug that collides with a provider alias", () => {
5677 const capabilities = createModelCapabilities({ optionDescriptors: [] });
5778 const models = providerModelsFromSettings(
apps/server/src/provider/providerSnapshot.ts+14−9
@@ -1,4 +1,5 @@
11 import type {
2+ CustomModelSetting,
23 ProviderDriverKind,
34 ModelCapabilities,
45 ServerProvider,
@@ -14,7 +15,7 @@ import * as PlatformError from "effect/PlatformError"; · 9 unmodified lines
1415 import * as Schema from "effect/Schema";
1516 import * as Stream from "effect/Stream";
1617 import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
17−import { normalizeCustomModelSlug } from "@t3tools/shared/model";
18+import { readCustomModelEntries } from "@t3tools/shared/model";
1819 import { isWindowsCommandNotFound } from "../processRunner.ts";
1920 import { createProviderVersionAdvisory } from "./providerMaintenance.ts";
2021 import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts";
@@ -145,26 +146,30 @@ export function parseGenericCliVersion(output: string): string | null { · 124 unmodified lines
145146 return match?.[1] ?? null;
146147 }
147148
149+/**
150+ * Append the user's custom models after the built-ins. A custom entry that
151+ * declares its own capabilities keeps them; a bare slug gets the driver's
152+ * default set. Slugs that collide with a built-in are dropped.
153+ */
148154 export function providerModelsFromSettings(
149155 builtInModels: ReadonlyArray<ServerProviderModel>,
150− customModels: ReadonlyArray<string>,
156+ customModels: ReadonlyArray<CustomModelSetting>,
151157 customModelCapabilities: ModelCapabilities,
152158 ): ReadonlyArray<ServerProviderModel> {
153159 const resolvedBuiltInModels = [...builtInModels];
154160 const seen = new Set(resolvedBuiltInModels.map((model) => model.slug));
155161 const customEntries: ServerProviderModel[] = [];
156162
157− for (const candidate of customModels) {
158− const normalized = normalizeCustomModelSlug(candidate);
159− if (!normalized || seen.has(normalized)) {
163+ for (const entry of readCustomModelEntries(customModels)) {
164+ if (seen.has(entry.slug)) {
160165 continue;
161166 }
162− seen.add(normalized);
167+ seen.add(entry.slug);
163168 customEntries.push({
164− slug: normalized,
165− name: normalized,
169+ slug: entry.slug,
170+ name: entry.name,
166171 isCustom: true,
167− capabilities: customModelCapabilities,
172+ capabilities: entry.capabilities ?? customModelCapabilities,
168173 });
169174 }
170175
apps/web/src/components/settings/CustomModelEditor.tsx+387−0
@@ -0,0 +1,387 @@
1+"use client";
2+
3+import { PlusIcon, XIcon } from "lucide-react";
4+import { useMemo, useState } from "react";
5+import type { ProviderDriverKind, ServerProviderModel } from "@t3tools/contracts";
6+import type { CustomModelDefinition } from "@t3tools/shared/model";
7+
8+import { cn } from "../../lib/utils";
9+import { Button } from "../ui/button";
10+import { Input } from "../ui/input";
11+import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
12+import { Switch } from "../ui/switch";
13+import {
14+ DESCRIPTOR_PRESETS_BY_KIND,
15+ type CustomModelDraft,
16+ type EditorChoice,
17+ type EditorDescriptor,
18+ choiceFromPreset,
19+ definitionFromDraft,
20+ descriptorFromPreset,
21+ descriptorsFromCapabilities,
22+ draftFromDefinition,
23+ emptyEditorChoice,
24+ emptyEditorDescriptor,
25+ validateDraft,
26+} from "./customModelEditor.logic";
27+
28+const CUSTOM_ID_VALUE = "__custom__";
29+const START_FROM_NONE = "__none__";
30+
31+interface CustomModelEditorProps {
32+ readonly instanceId: string;
33+ readonly driverKind: ProviderDriverKind | null;
34+ readonly entry: CustomModelDefinition;
35+ /** Built-in models whose descriptors can be copied as a starting point. */
36+ readonly builtInModels: ReadonlyArray<ServerProviderModel>;
37+ readonly onSave: (next: CustomModelDefinition) => void;
38+ readonly onCancel: () => void;
39+}
40+
41+/**
42+ * Inline editor for one custom model: display name plus the option
43+ * descriptors the composer should offer for it (Reasoning effort, Fast
44+ * mode, ...). Draft state is local; nothing is persisted until Save.
45+ */
46+export function CustomModelEditor({
47+ instanceId,
48+ driverKind,
49+ entry,
50+ builtInModels,
51+ onSave,
52+ onCancel,
53+}: CustomModelEditorProps) {
54+ const [draft, setDraft] = useState<CustomModelDraft>(() => draftFromDefinition(entry));
55+ const [error, setError] = useState<string | null>(null);
56+ const presets = useMemo(
57+ () => (driverKind ? (DESCRIPTOR_PRESETS_BY_KIND[driverKind] ?? []) : []),
58+ [driverKind],
59+ );
60+ const startFromCandidates = useMemo(
61+ () => builtInModels.filter((model) => (model.capabilities?.optionDescriptors?.length ?? 0) > 0),
62+ [builtInModels],
63+ );
64+ const domId = (suffix: string) => `provider-instance-${instanceId}-custom-model-${suffix}`;
65+
66+ const updateDescriptor = (key: string, patch: Partial<EditorDescriptor>) => {
67+ setError(null);
68+ setDraft((current) => ({
69+ ...current,
70+ descriptors: current.descriptors.map((descriptor) =>
71+ descriptor.key === key ? { ...descriptor, ...patch } : descriptor,
72+ ),
73+ }));
74+ };
75+
76+ const updateChoice = (descriptorKey: string, choiceKey: string, patch: Partial<EditorChoice>) => {
77+ setError(null);
78+ setDraft((current) => ({
79+ ...current,
80+ descriptors: current.descriptors.map((descriptor) => {
81+ if (descriptor.key !== descriptorKey) return descriptor;
82+ return {
83+ ...descriptor,
84+ choices: descriptor.choices.map((choice) => {
85+ if (choice.key === choiceKey) return { ...choice, ...patch };
86+ // Only one choice can be the default.
87+ return patch.isDefault ? { ...choice, isDefault: false } : choice;
88+ }),
89+ };
90+ }),
91+ }));
92+ };
93+
94+ const removeDescriptor = (key: string) => {
95+ setError(null);
96+ setDraft((current) => ({
97+ ...current,
98+ descriptors: current.descriptors.filter((descriptor) => descriptor.key !== key),
99+ }));
100+ };
101+
102+ const addDescriptor = (descriptor: EditorDescriptor) => {
103+ setError(null);
104+ setDraft((current) => ({ ...current, descriptors: [...current.descriptors, descriptor] }));
105+ };
106+
107+ // Selecting a preset id replaces the descriptor's label/type/choices so the
108+ // usual values are one click away; "Custom…" leaves the row blank to type into.
109+ const applyPresetId = (descriptor: EditorDescriptor, value: string | null) => {
110+ if (value === null) return;
111+ if (value === CUSTOM_ID_VALUE) {
112+ updateDescriptor(descriptor.key, { id: "" });
113+ return;
114+ }
115+ const preset = presets.find((candidate) => candidate.id === value);
116+ if (!preset) return;
117+ updateDescriptor(descriptor.key, {
118+ id: preset.id,
119+ label: preset.label,
120+ type: preset.type,
121+ choices: (preset.choices ?? []).map(choiceFromPreset),
122+ currentBooleanValue: undefined,
123+ description: undefined,
124+ });
125+ };
126+
127+ const handleStartFrom = (slug: string | null) => {
128+ if (slug === null || slug === START_FROM_NONE) return;
129+ const model = startFromCandidates.find((candidate) => candidate.slug === slug);
130+ if (!model) return;
131+ setError(null);
132+ setDraft((current) => ({
133+ ...current,
134+ descriptors: descriptorsFromCapabilities(model.capabilities, driverKind),
135+ }));
136+ };
137+
138+ const handleSave = () => {
139+ const problem = validateDraft(draft);
140+ if (problem) {
141+ setError(problem);
142+ return;
143+ }
144+ onSave(definitionFromDraft(draft));
145+ };
146+
147+ const idSelectValue = (descriptor: EditorDescriptor) =>
148+ presets.some((preset) => preset.id === descriptor.id) ? descriptor.id : CUSTOM_ID_VALUE;
149+
150+ const renderChoice = (descriptor: EditorDescriptor, choice: EditorChoice) => (
151+ <div key={choice.key} className="flex items-center gap-2">
152+ <Input
153+ size="compact"
154+ value={choice.id}
155+ onChange={(event) => updateChoice(descriptor.key, choice.key, { id: event.target.value })}
156+ placeholder="value"
157+ className="w-28 font-mono"
158+ spellCheck={false}
159+ aria-label="Choice value"
160+ />
161+ <Input
162+ size="compact"
163+ value={choice.label}
164+ onChange={(event) =>
165+ updateChoice(descriptor.key, choice.key, { label: event.target.value })
166+ }
167+ placeholder="Label"
168+ className="min-w-0 flex-1"
169+ aria-label="Choice label"
170+ />
171+ <label className="flex shrink-0 items-center gap-1.5 text-[11px] text-muted-foreground">
172+ <Switch
173+ size="sm"
174+ checked={choice.isDefault}
175+ onCheckedChange={(checked) =>
176+ updateChoice(descriptor.key, choice.key, { isDefault: checked })
177+ }
178+ aria-label="Default choice"
179+ />
180+ Default
181+ </label>
182+ <Button
183+ size="icon-micro"
184+ variant="ghost-muted"
185+ aria-label="Remove choice"
186+ onClick={() =>
187+ updateDescriptor(descriptor.key, {
188+ choices: descriptor.choices.filter((candidate) => candidate.key !== choice.key),
189+ })
190+ }
191+ >
192+ <XIcon className="size-3" />
193+ </Button>
194+ </div>
195+ );
196+
197+ const renderDescriptor = (descriptor: EditorDescriptor, index: number) => (
198+ <div
199+ key={descriptor.key}
200+ className="flex flex-col gap-2 rounded-md border border-border/60 bg-background/40 p-2.5"
201+ >
202+ <div className="flex flex-wrap items-center gap-2">
203+ <span className="w-14 shrink-0 text-[11px] text-muted-foreground">Option {index + 1}</span>
204+ {presets.length > 0 ? (
205+ <Select
206+ value={idSelectValue(descriptor)}
207+ onValueChange={(value) => applyPresetId(descriptor, value)}
208+ >
209+ <SelectTrigger size="compact" className="w-40" aria-label="Option id">
210+ <SelectValue>
211+ {idSelectValue(descriptor) === CUSTOM_ID_VALUE ? "Custom…" : descriptor.id}
212+ </SelectValue>
213+ </SelectTrigger>
214+ <SelectPopup align="start" alignItemWithTrigger={false}>
215+ {presets.map((preset) => (
216+ <SelectItem key={preset.id} value={preset.id}>
217+ <span className="flex items-baseline gap-2">
218+ <code className="font-mono text-xs">{preset.id}</code>
219+ <span className="text-muted-foreground">{preset.label}</span>
220+ </span>
221+ </SelectItem>
222+ ))}
223+ <SelectItem value={CUSTOM_ID_VALUE}>Custom…</SelectItem>
224+ </SelectPopup>
225+ </Select>
226+ ) : null}
227+ {idSelectValue(descriptor) === CUSTOM_ID_VALUE ? (
228+ <Input
229+ size="compact"
230+ value={descriptor.id}
231+ onChange={(event) => updateDescriptor(descriptor.key, { id: event.target.value })}
232+ placeholder="optionId"
233+ className="w-36 font-mono"
234+ spellCheck={false}
235+ aria-label="Option id"
236+ />
237+ ) : null}
238+ <Input
239+ size="compact"
240+ value={descriptor.label}
241+ onChange={(event) => updateDescriptor(descriptor.key, { label: event.target.value })}
242+ placeholder="Label"
243+ className="min-w-0 flex-1"
244+ aria-label="Option label"
245+ />
246+ <Select
247+ value={descriptor.type}
248+ onValueChange={(value) =>
249+ updateDescriptor(descriptor.key, { type: value === "boolean" ? "boolean" : "select" })
250+ }
251+ >
252+ <SelectTrigger size="compact" className="w-24" aria-label="Option type">
253+ <SelectValue>{descriptor.type === "boolean" ? "Toggle" : "Choices"}</SelectValue>
254+ </SelectTrigger>
255+ <SelectPopup align="end" alignItemWithTrigger={false}>
256+ <SelectItem value="select">Choices</SelectItem>
257+ <SelectItem value="boolean">Toggle</SelectItem>
258+ </SelectPopup>
259+ </Select>
260+ <Button
261+ size="icon-micro"
262+ variant="ghost-muted"
263+ aria-label={`Remove option ${index + 1}`}
264+ onClick={() => removeDescriptor(descriptor.key)}
265+ >
266+ <XIcon className="size-3" />
267+ </Button>
268+ </div>
269+ {descriptor.type === "select" ? (
270+ <div className="flex flex-col gap-1.5 pl-16">
271+ {descriptor.choices.map((choice) => renderChoice(descriptor, choice))}
272+ <Button
273+ type="button"
274+ size="xs"
275+ variant="ghost-muted"
276+ className="-ml-2 self-start"
277+ onClick={() =>
278+ updateDescriptor(descriptor.key, {
279+ choices: [...descriptor.choices, emptyEditorChoice()],
280+ })
281+ }
282+ >
283+ <PlusIcon className="size-3" />
284+ Add choice
285+ </Button>
286+ </div>
287+ ) : null}
288+ </div>
289+ );
290+
291+ return (
292+ <div
293+ className="mx-2 mt-1 mb-2 flex flex-col gap-3 rounded-md border border-border bg-muted/20 p-3"
294+ onKeyDown={(event) => {
295+ if (event.key === "Escape") {
296+ event.preventDefault();
297+ onCancel();
298+ }
299+ }}
300+ >
301+ <div className="flex flex-col gap-1">
302+ <label htmlFor={domId("name")} className="text-xs text-muted-foreground">
303+ Display name
304+ </label>
305+ <Input
306+ id={domId("name")}
307+ size="sm"
308+ autoFocus
309+ value={draft.name}
310+ onChange={(event) => setDraft((current) => ({ ...current, name: event.target.value }))}
311+ placeholder={draft.slug}
312+ className="sm:w-72"
313+ spellCheck={false}
314+ />
315+ </div>
316+
317+ <div className="flex flex-col gap-2">
318+ <div className="flex flex-wrap items-center justify-between gap-2">
319+ <span className="text-xs text-muted-foreground">Options shown in the composer</span>
320+ {startFromCandidates.length > 0 ? (
321+ <Select value={START_FROM_NONE} onValueChange={handleStartFrom}>
322+ <SelectTrigger
323+ size="compact"
324+ className="w-44"
325+ aria-label="Copy options from a built-in model"
326+ >
327+ <SelectValue>Copy from…</SelectValue>
328+ </SelectTrigger>
329+ <SelectPopup align="end" alignItemWithTrigger={false}>
330+ {startFromCandidates.map((model) => (
331+ <SelectItem key={model.slug} value={model.slug}>
332+ {model.name}
333+ </SelectItem>
334+ ))}
335+ </SelectPopup>
336+ </Select>
337+ ) : null}
338+ </div>
339+ {draft.descriptors.length === 0 ? (
340+ <p className="text-xs text-muted-foreground/70">
341+ No custom options. The composer uses the provider's default options.
342+ </p>
343+ ) : null}
344+ {draft.descriptors.map(renderDescriptor)}
345+ <div className="flex flex-wrap gap-1">
346+ {presets
347+ .filter(
348+ (preset) => !draft.descriptors.some((descriptor) => descriptor.id === preset.id),
349+ )
350+ .map((preset) => (
351+ <Button
352+ key={preset.id}
353+ type="button"
354+ size="xs"
355+ variant="ghost-muted"
356+ className={cn("-ml-2 first:ml-0")}
357+ onClick={() => addDescriptor(descriptorFromPreset(preset))}
358+ >
359+ <PlusIcon className="size-3" />
360+ {preset.label}
361+ </Button>
362+ ))}
363+ <Button
364+ type="button"
365+ size="xs"
366+ variant="ghost-muted"
367+ onClick={() => addDescriptor(emptyEditorDescriptor())}
368+ >
369+ <PlusIcon className="size-3" />
370+ Custom option
371+ </Button>
372+ </div>
373+ </div>
374+
375+ {error ? <p className="text-xs text-destructive">{error}</p> : null}
376+
377+ <div className="flex gap-2">
378+ <Button size="sm" variant="outline" onClick={handleSave}>
379+ Save
380+ </Button>
381+ <Button size="sm" variant="ghost" onClick={onCancel}>
382+ Cancel
383+ </Button>
384+ </div>
385+ </div>
386+ );
387+}
apps/web/src/components/settings/ProviderInstanceCard.test.ts+42−1
@@ -36,11 +36,52 @@ describe("deriveProviderModelsForDisplay", () => { · 35 unmodified lines
3636 expect(
3737 deriveProviderModelsForDisplay({
3838 liveModels,
39− customModels: ["kept-custom"],
39+ customModels: [{ slug: "kept-custom", name: "kept-custom", capabilities: null }],
4040 }).map((model) => model.slug),
4141 ).toEqual(["server-model", "kept-custom"]);
4242 });
4343
44+ it("prefers the entry's name and capabilities over the stale live custom row", () => {
45+ const liveCapabilities = { optionDescriptors: [] };
46+ const customCapabilities = {
47+ optionDescriptors: [
48+ {
49+ id: "reasoningEffort",
50+ label: "Reasoning",
51+ type: "select" as const,
52+ options: [{ id: "high", label: "High", isDefault: true }],
53+ currentValue: "high",
54+ },
55+ ],
56+ };
57+ const liveModels: ReadonlyArray<ServerProviderModel> = [
58+ { slug: "bare", name: "bare", isCustom: true, capabilities: liveCapabilities },
59+ { slug: "named", name: "named", isCustom: true, capabilities: liveCapabilities },
60+ ];
61+
62+ const display = deriveProviderModelsForDisplay({
63+ liveModels,
64+ customModels: [
65+ { slug: "bare", name: "bare", capabilities: null },
66+ { slug: "named", name: "My Model", capabilities: customCapabilities },
67+ ],
68+ });
69+
70+ // A bare entry keeps the driver default the server filled in.
71+ expect(display[0]).toEqual({
72+ slug: "bare",
73+ name: "bare",
74+ isCustom: true,
75+ capabilities: liveCapabilities,
76+ });
77+ expect(display[1]).toEqual({
78+ slug: "named",
79+ name: "My Model",
80+ isCustom: true,
81+ capabilities: customCapabilities,
82+ });
83+ });
84+
4485 it("shows a redacted provider email in the editor header status line", () => {
4586 const instanceId = ProviderInstanceId.make("codex");
4687 const driver = ProviderDriverKind.make("codex");
apps/web/src/components/settings/ProviderInstanceCard.tsx+30−21
@@ -25,6 +25,11 @@ import { · 24 unmodified lines
2525 type ServerProviderModel,
2626 } from "@t3tools/contracts";
2727
28+import {
29+ type CustomModelDefinition,
30+ readCustomModelEntries,
31+ toCustomModelSetting,
32+} from "@t3tools/shared/model";
2833 import { cn } from "../../lib/utils";
2934 import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";
3035 import { normalizeProviderAccentColor } from "../../providerInstances";
@@ -96,16 +101,13 @@ function providerEnvironmentsEqual( · 65 unmodified lines
96101 }
97102
98103 /**
99− * Read a string[] at `key` from the opaque config blob, filtering out
100− * non-string entries. Used for `customModels`, which is always typed as
101− * `string[]` by the concrete driver schemas but arrives here as
102− * `Schema.Unknown`.
104+ * Read `customModels` from the opaque config blob. The concrete driver
105+ * schemas type it as `CustomModelSetting[]`, but it arrives here as
106+ * `Schema.Unknown`, so the shared reader does the shape checking.
103107 */
104−function readConfigStringArray(config: unknown, key: string): ReadonlyArray<string> {
108+function readConfigCustomModels(config: unknown): ReadonlyArray<CustomModelDefinition> {
105109 if (config === null || typeof config !== "object") return [];
106− const value = (config as Record<string, unknown>)[key];
107− if (!Array.isArray(value)) return [];
108− return value.filter((entry): entry is string => typeof entry === "string");
110+ return readCustomModelEntries((config as Record<string, unknown>).customModels);
109111 }
110112
111113 /**
@@ -127,9 +129,14 @@ function nextConfigBlobWithValue( · 15 unmodified lines
127129 return base;
128130 }
129131
132+/**
133+ * Custom rows come from current settings so name/descriptor edits show
134+ * instantly; a bare entry falls back to the live row's driver-default
135+ * capabilities (the server fills those in on its next probe).
136+ */
130137 export function deriveProviderModelsForDisplay(input: {
131138 readonly liveModels: ReadonlyArray<ServerProviderModel> | undefined;
132− readonly customModels: ReadonlyArray<string>;
139+ readonly customModels: ReadonlyArray<CustomModelDefinition>;
133140 }): ReadonlyArray<ServerProviderModel> {
134141 const liveCustomModelsBySlug = new Map(
135142 Arr.filterMap(input.liveModels ?? [], (model) =>
@@ -137,15 +144,13 @@ export function deriveProviderModelsForDisplay(input: { · 1 unmodified line
137144 ),
138145 );
139146 const serverModels = input.liveModels?.filter((model) => !model.isCustom) ?? [];
140− const customModels = input.customModels.map(
141− (slug) =>
142− liveCustomModelsBySlug.get(slug) ?? {
143− slug,
144− name: slug,
145− isCustom: true,
146− capabilities: null,
147− },
148− );
147+ const customModels = input.customModels.map((entry) => ({
148+ slug: entry.slug,
149+ name: entry.name,
150+ isCustom: true,
151+ capabilities:
152+ entry.capabilities ?? liveCustomModelsBySlug.get(entry.slug)?.capabilities ?? null,
153+ }));
149154 return [...serverModels, ...customModels];
150155 }
151156
@@ -463,7 +468,7 @@ export function ProviderInstanceCard({ · 311 unmodified lines
463468 ? instance.driver
464469 : null;
465470 const customModels =
466− instance.driver === "antigravity" ? [] : readConfigStringArray(instance.config, "customModels");
471+ instance.driver === "antigravity" ? [] : readConfigCustomModels(instance.config);
467472 // Server-returned models may lag behind settings writes. Treat probe
468473 // models as the source for built-ins only; custom rows come directly
469474 // from the current instance config so add/remove reflects immediately.
@@ -504,8 +509,12 @@ export function ProviderInstanceCard({ · 34 unmodified lines
504509 );
505510 };
506511
507− const updateCustomModels = (next: ReadonlyArray<string>) => {
508− const nextConfig = nextConfigBlobWithValue(instance.config, "customModels", [...next]);
512+ const updateCustomModels = (next: ReadonlyArray<CustomModelDefinition>) => {
513+ const nextConfig = nextConfigBlobWithValue(
514+ instance.config,
515+ "customModels",
516+ next.map(toCustomModelSetting),
517+ );
509518 const { config: _omit, ...rest } = instance;
510519 onUpdate({ ...rest, config: nextConfig } as ProviderInstanceConfig);
511520 };
apps/web/src/components/settings/ProviderModelsSection.tsx+75−28
@@ -1,13 +1,13 @@
11 "use client";
22
3−import { ArrowDownIcon, ArrowUpIcon, PlusIcon, StarIcon, XIcon } from "lucide-react";
3+import { ArrowDownIcon, ArrowUpIcon, PencilIcon, PlusIcon, StarIcon, XIcon } from "lucide-react";
44 import { useEffect, useMemo, useRef, useState } from "react";
55 import {
66 ProviderDriverKind,
77 type ProviderInstanceId,
88 type ServerProviderModel,
99 } from "@t3tools/contracts";
10−import { normalizeCustomModelSlug } from "@t3tools/shared/model";
10+import { type CustomModelDefinition, normalizeCustomModelSlug } from "@t3tools/shared/model";
1111
1212 import { cn } from "../../lib/utils";
1313 import { sortModelsForProviderInstance } from "../../modelOrdering";
@@ -16,6 +16,7 @@ import { Button } from "../ui/button"; · 2 unmodified lines
1616 import { Input } from "../ui/input";
1717 import { Switch } from "../ui/switch";
1818 import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
19+import { CustomModelEditor } from "./CustomModelEditor";
1920
2021 /**
2122 * Placeholder text for the "add a custom model" input, keyed by driver
@@ -109,11 +110,11 @@ interface ProviderModelsSectionProps { · 87 unmodified lines
109110 */
110111 readonly models: ReadonlyArray<ServerProviderModel>;
111112 /**
112− * The persisted custom-model slug list for this instance. Drives dedup,
113− * and is the array we hand back verbatim (with the new slug appended /
113+ * The persisted custom-model list for this instance, resolved. Drives
114+ * dedup, and is the list we hand back (with an entry appended / replaced /
114115 * removed) via `onChange`.
115116 */
116− readonly customModels: ReadonlyArray<string>;
117+ readonly customModels: ReadonlyArray<CustomModelDefinition>;
117118 /** Server-returned model slugs hidden from the model picker. */
118119 readonly hiddenModels: ReadonlyArray<string>;
119120 /** Model slugs favorited for this provider instance. */
@@ -125,7 +126,7 @@ interface ProviderModelsSectionProps { · 5 unmodified lines
125126 * write to the correct storage (legacy `settings.providers[kind]` vs.
126127 * `providerInstances[id].config`).
127128 */
128− readonly onChange: (next: ReadonlyArray<string>) => void;
129+ readonly onChange: (next: ReadonlyArray<CustomModelDefinition>) => void;
129130 readonly onHiddenModelsChange: (next: ReadonlyArray<string>) => void;
130131 readonly onFavoriteModelsChange: (next: ReadonlyArray<string>) => void;
131132 readonly onModelOrderChange: (next: ReadonlyArray<string>) => void;
@@ -159,6 +160,8 @@ export function ProviderModelsSection({ · 27 unmodified lines
159160 const [isAdding, setIsAdding] = useState(false);
160161 const [filter, setFilter] = useState("");
161162 const [error, setError] = useState<string | null>(null);
163+ // Slug of the custom model whose inline editor is open, if any.
164+ const [editingSlug, setEditingSlug] = useState<string | null>(null);
162165 const listRef = useRef<HTMLDivElement>(null);
163166 // Slug of a just-added custom model, scrolled into view once its row exists.
164167 const scrollToSlugRef = useRef<string | null>(null);
@@ -177,6 +180,7 @@ export function ProviderModelsSection({ · 12 unmodified lines
177180 const hiddenCount = displayModels.filter(
178181 (model) => !model.isCustom && hiddenModelSet.has(model.slug),
179182 ).length;
183+ const builtInModels = useMemo(() => models.filter((model) => !model.isCustom), [models]);
180184 const showFilter = models.length > FILTER_THRESHOLD;
181185 const normalizedFilter = filter.trim().toLowerCase();
182186 const isFiltering = showFilter && normalizedFilter.length > 0;
@@ -216,7 +220,7 @@ export function ProviderModelsSection({ · 33 unmodified lines
216220 setError(`Model slugs must be ${MAX_CUSTOM_MODEL_LENGTH} characters or less.`);
217221 return;
218222 }
219− if (customModels.includes(normalized)) {
223+ if (customModels.some((entry) => entry.slug === normalized)) {
220224 setError("That custom model is already saved.");
221225 return;
222226 }
@@ -225,7 +229,7 @@ export function ProviderModelsSection({ · 2 unmodified lines
225229 // which is also what lets the pending scroll target resolve and clear.
226230 scrollToSlugRef.current = normalized;
227231 setFilter("");
228− onChange([...customModels, normalized]);
232+ onChange([...customModels, { slug: normalized, name: normalized, capabilities: null }]);
229233 setInput("");
230234 setError(null);
231235 setIsAdding(false);
@@ -238,12 +242,18 @@ export function ProviderModelsSection({ · 6 unmodified lines
238242 };
239243
240244 const handleRemove = (slug: string) => {
241− onChange(customModels.filter((model) => model !== slug));
245+ if (editingSlug === slug) setEditingSlug(null);
246+ onChange(customModels.filter((entry) => entry.slug !== slug));
242247 onModelOrderChange(modelOrder.filter((model) => model !== slug));
243248 onFavoriteModelsChange(favoriteModels.filter((model) => model !== slug));
244249 setError(null);
245250 };
246251
252+ const handleSaveEdit = (next: CustomModelDefinition) => {
253+ onChange(customModels.map((entry) => (entry.slug === next.slug ? next : entry)));
254+ setEditingSlug(null);
255+ };
256+
247257 const setHidden = (slug: string, hidden: boolean) => {
248258 if (hidden === hiddenModelSet.has(slug)) return;
249259 onHiddenModelsChange(
@@ -355,21 +365,40 @@ export function ProviderModelsSection({ · 105 unmodified lines
355365 </>
356366 ) : null}
357367 {model.isCustom ? (
358− <Tooltip>
359− <TooltipTrigger
360− render={
361− <Button
362− size="icon-micro"
363− variant="ghost-muted"
364− aria-label={`Remove ${model.slug}`}
365− onClick={() => handleRemove(model.slug)}
366− />
367− }
368− >
369− <XIcon className="size-3" />
370− </TooltipTrigger>
371− <TooltipPopup side="top">Remove custom model</TooltipPopup>
372− </Tooltip>
368+ <>
369+ <Tooltip>
370+ <TooltipTrigger
371+ render={
372+ <Button
373+ size="icon-micro"
374+ variant="ghost-muted"
375+ aria-label={`Edit ${model.slug}`}
376+ onClick={() =>
377+ setEditingSlug((current) => (current === model.slug ? null : model.slug))
378+ }
379+ />
380+ }
381+ >
382+ <PencilIcon className="size-3" />
383+ </TooltipTrigger>
384+ <TooltipPopup side="top">Edit name and options</TooltipPopup>
385+ </Tooltip>
386+ <Tooltip>
387+ <TooltipTrigger
388+ render={
389+ <Button
390+ size="icon-micro"
391+ variant="ghost-muted"
392+ aria-label={`Remove ${model.slug}`}
393+ onClick={() => handleRemove(model.slug)}
394+ />
395+ }
396+ >
397+ <XIcon className="size-3" />
398+ </TooltipTrigger>
399+ <TooltipPopup side="top">Remove custom model</TooltipPopup>
400+ </Tooltip>
401+ </>
373402 ) : null}
374403 </span>
375404 );
@@ -420,20 +449,23 @@ export function ProviderModelsSection({ · 44 unmodified lines
420449 key={`${instanceId}:${model.slug}`}
421450 data-model-slug={model.slug}
422451 className={cn(
423− "grid h-7 grid-cols-[1.5rem_minmax(0,1fr)_auto_4rem_auto] items-center gap-2 rounded-md px-2 transition-colors hover:bg-muted/30",
452+ // Actions column is at least wide enough for the four custom-row
453+ // buttons so capability labels line up across built-in and custom rows.
454+ "grid h-7 grid-cols-[1.5rem_minmax(0,1fr)_auto_minmax(5.5rem,auto)_auto] items-center gap-2 rounded-md px-2 transition-colors hover:bg-muted/30",
424455 isHidden && "opacity-50",
425456 )}
426457 >
427458 {starButton(model, isFavorite)}
428459 <span className="flex min-w-0 items-baseline gap-2">
429460 <span className={cn(nameClassName, "truncate")}>{model.name}</span>
430− {model.isCustom ? (
431− <span className="text-[11px] text-muted-foreground/70">custom</span>
432− ) : model.name !== model.slug ? (
461+ {model.name !== model.slug ? (
433462 <code className="truncate font-mono text-[11px] text-muted-foreground/70">
434463 {model.slug}
435464 </code>
436465 ) : null}
466+ {model.isCustom ? (
467+ <span className="text-[11px] text-muted-foreground/70">custom</span>
468+ ) : null}
437469 </span>
438470 {/*
439471 Always a grid item so the columns line up across rows; the text
@@ -489,6 +521,10 @@ export function ProviderModelsSection({ · 49 unmodified lines
489521 const group = groupOf(model);
490522 const previous = visibleModels[index - 1];
491523 const startsGroup = previous === undefined || groupOf(previous) !== group;
524+ const editingEntry =
525+ model.isCustom && editingSlug === model.slug
526+ ? customModels.find((entry) => entry.slug === model.slug)
527+ : undefined;
492528 return (
493529 <div key={`${instanceId}:${model.slug}:group`}>
494530 {startsGroup && favoriteCount > 0 && group === "favorite"
@@ -501,6 +537,17 @@ export function ProviderModelsSection({ · 6 unmodified lines
501537 ? groupLabel("Hidden from picker", index === 0)
502538 : null}
503539 {renderRow(model)}
540+ {editingEntry ? (
541+ <CustomModelEditor
542+ key={`${instanceId}:${model.slug}:editor`}
543+ instanceId={instanceId}
544+ driverKind={driverKind}
545+ entry={editingEntry}
546+ builtInModels={builtInModels}
547+ onSave={handleSaveEdit}
548+ onCancel={() => setEditingSlug(null)}
549+ />
550+ ) : null}
504551 </div>
505552 );
506553 })}
apps/web/src/components/settings/customModelEditor.logic.test.ts+259−0
@@ -0,0 +1,259 @@
1+import { describe, expect, it } from "vite-plus/test";
2+import { ProviderDriverKind, type ModelCapabilities } from "@t3tools/contracts";
3+
4+import {
5+ DESCRIPTOR_PRESETS_BY_KIND,
6+ descriptorFromPreset,
7+ definitionFromDraft,
8+ descriptorsFromCapabilities,
9+ draftFromDefinition,
10+ validateDraft,
11+ type CustomModelDraft,
12+} from "./customModelEditor.logic";
13+
14+const draft = (overrides: Partial<CustomModelDraft>): CustomModelDraft => ({
15+ slug: "my-model",
16+ name: "",
17+ descriptors: [],
18+ ...overrides,
19+});
20+
21+describe("customModelEditor.logic", () => {
22+ it("round-trips a definition through the draft, marking the current value as default", () => {
23+ const definition = definitionFromDraft(
24+ draft({
25+ name: " My Model ",
26+ descriptors: [
27+ {
28+ key: "a",
29+ type: "select",
30+ id: "reasoningEffort",
31+ label: "Reasoning",
32+ choices: [
33+ { key: "a1", id: "low", label: "Low", isDefault: false },
34+ { key: "a2", id: "high", label: "", isDefault: true },
35+ ],
36+ },
37+ { key: "b", type: "boolean", id: "fastMode", label: "Fast Mode", choices: [] },
38+ ],
39+ }),
40+ );
41+
42+ expect(definition).toEqual({
43+ slug: "my-model",
44+ name: "My Model",
45+ capabilities: {
46+ optionDescriptors: [
47+ {
48+ id: "reasoningEffort",
49+ label: "Reasoning",
50+ type: "select",
51+ options: [
52+ { id: "low", label: "Low" },
53+ { id: "high", label: "high", isDefault: true },
54+ ],
55+ currentValue: "high",
56+ },
57+ { id: "fastMode", label: "Fast Mode", type: "boolean" },
58+ ],
59+ },
60+ });
61+
62+ const reopened = draftFromDefinition(definition);
63+ expect(reopened.name).toBe("My Model");
64+ expect(reopened.descriptors.map((descriptor) => descriptor.id)).toEqual([
65+ "reasoningEffort",
66+ "fastMode",
67+ ]);
68+ expect(reopened.descriptors[0]!.choices.map((choice) => choice.isDefault)).toEqual([
69+ false,
70+ true,
71+ ]);
72+ });
73+
74+ it("preserves the current choice when it differs from the built-in default", () => {
75+ const descriptors = descriptorsFromCapabilities(
76+ {
77+ optionDescriptors: [
78+ {
79+ id: "effort",
80+ label: "Reasoning",
81+ type: "select",
82+ currentValue: "high",
83+ options: [
84+ { id: "low", label: "Low", isDefault: true },
85+ { id: "high", label: "High" },
86+ ],
87+ },
88+ ],
89+ },
90+ ProviderDriverKind.make("claudeAgent"),
91+ );
92+ expect(descriptors[0]!.choices.map((choice) => choice.isDefault)).toEqual([false, true]);
93+ expect(
94+ definitionFromDraft(draft({ descriptors })).capabilities?.optionDescriptors?.[0],
95+ ).toMatchObject({ currentValue: "high" });
96+ });
97+
98+ it("drops prompt-injected choices when copying a built-in's descriptors", () => {
99+ const [copied] = descriptorsFromCapabilities(
100+ {
101+ optionDescriptors: [
102+ {
103+ id: "effort",
104+ label: "Reasoning",
105+ type: "select",
106+ options: [
107+ { id: "high", label: "High", isDefault: true },
108+ { id: "ultrathink", label: "Ultrathink" },
109+ ],
110+ promptInjectedValues: ["ultrathink"],
111+ },
112+ ],
113+ },
114+ ProviderDriverKind.make("claudeAgent"),
115+ );
116+ expect(copied!.choices.map((choice) => choice.id)).toEqual(["high"]);
117+ });
118+
119+ it.each([true, false, undefined])(
120+ "preserves boolean values through copy and edit: %s",
121+ (currentValue) => {
122+ const capabilities: ModelCapabilities = {
123+ optionDescriptors: [
124+ {
125+ id: "thinking",
126+ label: "Thinking",
127+ type: "boolean",
128+ ...(currentValue !== undefined ? { currentValue } : {}),
129+ },
130+ ],
131+ };
132+ const copied = definitionFromDraft(
133+ draft({
134+ descriptors: descriptorsFromCapabilities(capabilities, ProviderDriverKind.make("cursor")),
135+ }),
136+ );
137+ expect(copied.capabilities).toEqual(capabilities);
138+ const reopened = draftFromDefinition(copied);
139+ expect(definitionFromDraft({ ...reopened, name: "Renamed" }).capabilities).toEqual(
140+ capabilities,
141+ );
142+ },
143+ );
144+
145+ it("excludes Claude context choices from presets and copies without changing other providers or authored entries", () => {
146+ const capabilities: ModelCapabilities = {
147+ optionDescriptors: [
148+ {
149+ id: "contextWindow",
150+ label: "Context",
151+ type: "select",
152+ options: [{ id: "1m", label: "1M", isDefault: true }],
153+ },
154+ { id: "thinking", label: "Thinking", type: "boolean", currentValue: true },
155+ ],
156+ };
157+ const claude = ProviderDriverKind.make("claudeAgent");
158+ const copied = definitionFromDraft(
159+ draft({ descriptors: descriptorsFromCapabilities(capabilities, claude) }),
160+ );
161+ expect(copied.capabilities?.optionDescriptors).toEqual([capabilities.optionDescriptors![1]]);
162+ const presets = definitionFromDraft(
163+ draft({
164+ descriptors: (DESCRIPTOR_PRESETS_BY_KIND[claude] ?? []).map(descriptorFromPreset),
165+ }),
166+ );
167+ expect(
168+ presets.capabilities?.optionDescriptors?.some((option) => option.id === "contextWindow"),
169+ ).toBe(false);
170+ const cursorCopy = descriptorsFromCapabilities(capabilities, ProviderDriverKind.make("cursor"));
171+ expect(cursorCopy.map((option) => option.id)).toEqual(["contextWindow", "thinking"]);
172+ const authored = { slug: "custom", name: "Custom", capabilities };
173+ expect(
174+ definitionFromDraft(draftFromDefinition(authored)).capabilities?.optionDescriptors?.[0],
175+ ).toMatchObject(capabilities.optionDescriptors![0]!);
176+ });
177+
178+ it("preserves choice descriptions when copying, renaming, and saving", () => {
179+ const capabilities: ModelCapabilities = {
180+ optionDescriptors: [
181+ {
182+ id: "effort",
183+ label: "Reasoning",
184+ description: "Choose a reasoning level.",
185+ type: "select",
186+ options: [
187+ { id: "high", label: "High", isDefault: true },
188+ { id: "ultracode", label: "Ultracode", description: "Uses additional reasoning." },
189+ ],
190+ },
191+ ],
192+ };
193+ const copied = definitionFromDraft(
194+ draft({
195+ descriptors: descriptorsFromCapabilities(
196+ capabilities,
197+ ProviderDriverKind.make("claudeAgent"),
198+ ),
199+ }),
200+ );
201+ const reopened = draftFromDefinition(copied);
202+ const saved = definitionFromDraft({ ...reopened, name: "Renamed" });
203+ expect(saved.capabilities?.optionDescriptors?.[0]).toMatchObject(
204+ capabilities.optionDescriptors![0]!,
205+ );
206+ expect(copied.capabilities?.optionDescriptors?.[0]).toMatchObject(
207+ capabilities.optionDescriptors![0]!,
208+ );
209+ });
210+
211+ it.each(Object.entries(DESCRIPTOR_PRESETS_BY_KIND))(
212+ "offers saveable presets for %s",
213+ (_driver, presets) => {
214+ expect(
215+ validateDraft(draft({ descriptors: (presets ?? []).map(descriptorFromPreset) })),
216+ ).toBeNull();
217+ },
218+ );
219+
220+ it("collapses a blank name and no options back to a bare definition", () => {
221+ expect(definitionFromDraft(draft({ name: " " }))).toEqual({
222+ slug: "my-model",
223+ name: "my-model",
224+ capabilities: null,
225+ });
226+ expect(draftFromDefinition({ slug: "x", name: "x", capabilities: null }).name).toBe("");
227+ });
228+
229+ it("rejects duplicate ids, blank ids, and selects without choices", () => {
230+ const select = (id: string, choices: Array<{ id: string }>) => ({
231+ key: id,
232+ type: "select" as const,
233+ id,
234+ label: "Label",
235+ choices: choices.map((choice) => ({
236+ key: choice.id,
237+ label: "",
238+ isDefault: false,
239+ ...choice,
240+ })),
241+ });
242+
243+ expect(validateDraft(draft({ descriptors: [select("", [{ id: "a" }])] }))).toBe(
244+ "Option 1 needs an id.",
245+ );
246+ expect(
247+ validateDraft(
248+ draft({ descriptors: [select("effort", [{ id: "a" }]), select("effort", [{ id: "b" }])] }),
249+ ),
250+ ).toBe('Option 2: id "effort" is used twice.');
251+ expect(validateDraft(draft({ descriptors: [select("effort", [])] }))).toBe(
252+ "Option 1 needs at least one choice.",
253+ );
254+ expect(
255+ validateDraft(draft({ descriptors: [select("effort", [{ id: "a" }, { id: "a" }])] })),
256+ ).toBe('Option 1: choice "a" is used twice.');
257+ expect(validateDraft(draft({ descriptors: [select("effort", [{ id: "a" }])] }))).toBeNull();
258+ });
259+});
apps/web/src/components/settings/customModelEditor.logic.ts+258−0
@@ -0,0 +1,258 @@
1+import {
2+ type ModelCapabilities,
3+ ProviderDriverKind,
4+ type ProviderOptionDescriptor,
5+} from "@t3tools/contracts";
6+import { type CustomModelDefinition, createModelCapabilities } from "@t3tools/shared/model";
7+
8+/** Editable mirror of a `ProviderOptionChoice`. `key` is only a React key. */
9+export interface EditorChoice {
10+ readonly key: string;
11+ readonly id: string;
12+ readonly label: string;
13+ readonly isDefault: boolean;
14+ readonly description?: string;
15+}
16+
17+/** Editable mirror of a `ProviderOptionDescriptor`. `key` is only a React key. */
18+export interface EditorDescriptor {
19+ readonly key: string;
20+ readonly type: "select" | "boolean";
21+ readonly id: string;
22+ readonly label: string;
23+ readonly choices: ReadonlyArray<EditorChoice>;
24+ readonly currentBooleanValue?: boolean | undefined;
25+ readonly description?: string | undefined;
26+}
27+
28+export interface CustomModelDraft {
29+ readonly slug: string;
30+ readonly name: string;
31+ readonly descriptors: ReadonlyArray<EditorDescriptor>;
32+}
33+
34+export interface DescriptorPreset {
35+ readonly id: string;
36+ readonly label: string;
37+ readonly type: "select" | "boolean";
38+ readonly choices?: ReadonlyArray<{ id: string; label: string; isDefault?: boolean }>;
39+}
40+
41+const EFFORT_CHOICES = [
42+ { id: "low", label: "Low" },
43+ { id: "medium", label: "Medium", isDefault: true },
44+ { id: "high", label: "High" },
45+ { id: "xhigh", label: "Extra High" },
46+] as const;
47+
48+/**
49+ * Option ids each adapter actually reads off a turn's model selection, with
50+ * the usual choices pre-filled. Anything else the user types is stored
51+ * verbatim but will be ignored by the driver.
52+ */
53+export const DESCRIPTOR_PRESETS_BY_KIND: Partial<
54+ Record<ProviderDriverKind, ReadonlyArray<DescriptorPreset>>
55+> = {
56+ [ProviderDriverKind.make("codex")]: [
57+ { id: "reasoningEffort", label: "Reasoning", type: "select", choices: EFFORT_CHOICES },
58+ {
59+ id: "serviceTier",
60+ label: "Speed",
61+ type: "select",
62+ choices: [
63+ { id: "default", label: "Standard", isDefault: true },
64+ { id: "fast", label: "Fast" },
65+ ],
66+ },
67+ ],
68+ [ProviderDriverKind.make("claudeAgent")]: [
69+ {
70+ id: "effort",
71+ label: "Reasoning",
72+ type: "select",
73+ choices: [
74+ { id: "low", label: "Low" },
75+ { id: "medium", label: "Medium" },
76+ { id: "high", label: "High", isDefault: true },
77+ { id: "xhigh", label: "Extra High" },
78+ { id: "max", label: "Max" },
79+ ],
80+ },
81+ { id: "fastMode", label: "Fast Mode", type: "boolean" },
82+ { id: "thinking", label: "Thinking", type: "boolean" },
83+ ],
84+ [ProviderDriverKind.make("cursor")]: [
85+ { id: "reasoning", label: "Reasoning", type: "select", choices: EFFORT_CHOICES },
86+ { id: "fastMode", label: "Fast Mode", type: "boolean" },
87+ { id: "thinking", label: "Thinking", type: "boolean" },
88+ ],
89+ [ProviderDriverKind.make("grok")]: [
90+ { id: "reasoningEffort", label: "Reasoning", type: "select", choices: EFFORT_CHOICES },
91+ ],
92+ [ProviderDriverKind.make("opencode")]: [
93+ { id: "variant", label: "Reasoning", type: "select", choices: EFFORT_CHOICES },
94+ {
95+ id: "agent",
96+ label: "Agent",
97+ type: "select",
98+ choices: [
99+ { id: "build", label: "Build", isDefault: true },
100+ { id: "plan", label: "Plan" },
101+ ],
102+ },
103+ ],
104+};
105+
106+let nextKey = 0;
107+export function newEditorKey(): string {
108+ nextKey += 1;
109+ return `k${nextKey}`;
110+}
111+
112+export function choiceFromPreset(choice: {
113+ id: string;
114+ label: string;
115+ isDefault?: boolean;
116+}): EditorChoice {
117+ return { key: newEditorKey(), id: choice.id, label: choice.label, isDefault: !!choice.isDefault };
118+}
119+
120+export function descriptorFromPreset(preset: DescriptorPreset): EditorDescriptor {
121+ return {
122+ key: newEditorKey(),
123+ type: preset.type,
124+ id: preset.id,
125+ label: preset.label,
126+ choices: (preset.choices ?? []).map(choiceFromPreset),
127+ };
128+}
129+
130+export function emptyEditorDescriptor(): EditorDescriptor {
131+ return { key: newEditorKey(), type: "select", id: "", label: "", choices: [] };
132+}
133+
134+export function emptyEditorChoice(): EditorChoice {
135+ return { key: newEditorKey(), id: "", label: "", isDefault: false };
136+}
137+
138+/**
139+ * Prompt-injected choices (Claude's `ultrathink`) are delivered as prompt text
140+ * by built-in runtime profiles a custom entry does not have, so they are
141+ * dropped rather than stored as a plain option value.
142+ */
143+export function descriptorToEditor(descriptor: ProviderOptionDescriptor): EditorDescriptor {
144+ const promptInjected = new Set(
145+ descriptor.type === "select" ? (descriptor.promptInjectedValues ?? []) : [],
146+ );
147+ const choices =
148+ descriptor.type === "select"
149+ ? descriptor.options.filter((option) => !promptInjected.has(option.id))
150+ : [];
151+ const defaultChoice =
152+ choices.find((option) => option.id === descriptor.currentValue) ??
153+ choices.find((option) => option.isDefault);
154+ return {
155+ key: newEditorKey(),
156+ type: descriptor.type,
157+ id: descriptor.id,
158+ label: descriptor.label,
159+ ...(descriptor.description !== undefined ? { description: descriptor.description } : {}),
160+ ...(descriptor.type === "boolean" && descriptor.currentValue !== undefined
161+ ? { currentBooleanValue: descriptor.currentValue }
162+ : {}),
163+ choices: choices.map((option) => ({
164+ key: newEditorKey(),
165+ id: option.id,
166+ label: option.label,
167+ ...(option.description !== undefined ? { description: option.description } : {}),
168+ isDefault: option === defaultChoice,
169+ })),
170+ };
171+}
172+
173+export function draftFromDefinition(entry: CustomModelDefinition): CustomModelDraft {
174+ return {
175+ slug: entry.slug,
176+ name: entry.name === entry.slug ? "" : entry.name,
177+ descriptors: (entry.capabilities?.optionDescriptors ?? []).map(descriptorToEditor),
178+ };
179+}
180+
181+/** Claude context choices require runtime suffix mappings that custom entries do not carry. */
182+export function descriptorsFromCapabilities(
183+ capabilities: ModelCapabilities | null | undefined,
184+ driverKind: ProviderDriverKind | null,
185+): EditorDescriptor[] {
186+ return (capabilities?.optionDescriptors ?? [])
187+ .filter((descriptor) => driverKind !== "claudeAgent" || descriptor.id !== "contextWindow")
188+ .map(descriptorToEditor);
189+}
190+
191+/**
192+ * Validate the draft before saving. Returns the first problem in reading
193+ * order so the message is actionable, or `null` when the draft is sound.
194+ */
195+export function validateDraft(draft: CustomModelDraft): string | null {
196+ const seenIds = new Set<string>();
197+ for (const [index, descriptor] of draft.descriptors.entries()) {
198+ const position = `Option ${index + 1}`;
199+ const id = descriptor.id.trim();
200+ if (!id) return `${position} needs an id.`;
201+ if (seenIds.has(id)) return `${position}: id "${id}" is used twice.`;
202+ seenIds.add(id);
203+ if (!descriptor.label.trim()) return `${position} needs a label.`;
204+ if (descriptor.type !== "select") continue;
205+ if (descriptor.choices.length === 0) return `${position} needs at least one choice.`;
206+ const seenChoices = new Set<string>();
207+ for (const choice of descriptor.choices) {
208+ const choiceId = choice.id.trim();
209+ if (!choiceId) return `${position} has a choice without a value.`;
210+ if (seenChoices.has(choiceId)) {
211+ return `${position}: choice "${choiceId}" is used twice.`;
212+ }
213+ seenChoices.add(choiceId);
214+ }
215+ }
216+ return null;
217+}
218+
219+/** Convert a validated draft back into a definition. Blank name → slug. */
220+export function definitionFromDraft(draft: CustomModelDraft): CustomModelDefinition {
221+ const descriptors: ProviderOptionDescriptor[] = draft.descriptors.map((descriptor) => {
222+ const id = descriptor.id.trim();
223+ const label = descriptor.label.trim();
224+ if (descriptor.type === "boolean") {
225+ return {
226+ id,
227+ label,
228+ type: "boolean",
229+ ...(descriptor.description !== undefined ? { description: descriptor.description } : {}),
230+ ...(descriptor.currentBooleanValue !== undefined
231+ ? { currentValue: descriptor.currentBooleanValue }
232+ : {}),
233+ };
234+ }
235+ const options = descriptor.choices.map((choice) => ({
236+ id: choice.id.trim(),
237+ label: choice.label.trim() || choice.id.trim(),
238+ ...(choice.description !== undefined ? { description: choice.description } : {}),
239+ ...(choice.isDefault ? { isDefault: true } : {}),
240+ }));
241+ const currentValue = options.find((option) => option.isDefault)?.id;
242+ return {
243+ id,
244+ label,
245+ type: "select",
246+ ...(descriptor.description !== undefined ? { description: descriptor.description } : {}),
247+ options,
248+ ...(currentValue ? { currentValue } : {}),
249+ };
250+ });
251+ const name = draft.name.trim();
252+ return {
253+ slug: draft.slug,
254+ name: name || draft.slug,
255+ capabilities:
256+ descriptors.length > 0 ? createModelCapabilities({ optionDescriptors: descriptors }) : null,
257+ };
258+}
apps/web/src/modelSelection.ts+23−27
@@ -10,8 +10,10 @@ import { · 9 unmodified lines
1010 type ServerSettingsPatch,
1111 } from "@t3tools/contracts";
1212 import {
13+ type CustomModelDefinition,
1314 createModelSelection,
1415 normalizeCustomModelSlug,
16+ readCustomModelEntries,
1517 resolveSelectableModel,
1618 } from "@t3tools/shared/model";
1719 import { getComposerProviderState } from "./components/chat/composerProviderState";
@@ -55,14 +57,14 @@ function readInstanceCustomModels( · 37 unmodified lines
5557 settings: UnifiedSettings,
5658 instanceId: ProviderInstanceId,
5759 driverKind: ProviderDriverKind,
58−): ReadonlyArray<string> {
60+): ReadonlyArray<CustomModelDefinition> {
5961 if (driverKind === "antigravity") return [];
6062 const instance = settings.providerInstances?.[instanceId];
6163 const config = instance?.config;
6264 if (config !== null && typeof config === "object") {
6365 const value = (config as Record<string, unknown>).customModels;
6466 if (Array.isArray(value)) {
65− return value.filter((entry): entry is string => typeof entry === "string");
67+ return readCustomModelEntries(value);
6668 }
6769 }
6870 const defaultInstanceId = defaultInstanceIdForDriver(driverKind);
@@ -71,9 +73,9 @@ function readInstanceCustomModels( · 2 unmodified lines
7173 }
7274 const legacyProviders = settings.providers as Record<
7375 string,
74− { readonly customModels: ReadonlyArray<string> } | undefined
76+ { readonly customModels: ReadonlyArray<unknown> } | undefined
7577 >;
76− return legacyProviders[driverKind]?.customModels ?? [];
78+ return readCustomModelEntries(legacyProviders[driverKind]?.customModels ?? []);
7779 }
7880
7981 export interface AppModelOption {
@@ -151,26 +153,24 @@ function applyInstanceModelPreferences( · 71 unmodified lines
151153 );
152154 }
153155
154−export function normalizeCustomModelSlugs(
155− models: Iterable<string | null | undefined>,
156+export function normalizeCustomModelEntries(
157+ models: ReadonlyArray<CustomModelDefinition>,
156158 builtInModelSlugs: ReadonlySet<string>,
157−): string[] {
158− const normalizedModels: string[] = [];
159+): CustomModelDefinition[] {
160+ const normalizedModels: CustomModelDefinition[] = [];
159161 const seen = new Set<string>();
160162
161163 for (const candidate of models) {
162− const normalized = normalizeCustomModelSlug(candidate);
163164 if (
164− !normalized ||
165− normalized.length > MAX_CUSTOM_MODEL_LENGTH ||
166− builtInModelSlugs.has(normalized) ||
167− seen.has(normalized)
165+ candidate.slug.length > MAX_CUSTOM_MODEL_LENGTH ||
166+ builtInModelSlugs.has(candidate.slug) ||
167+ seen.has(candidate.slug)
168168 ) {
169169 continue;
170170 }
171171
172− seen.add(normalized);
173− normalizedModels.push(normalized);
172+ seen.add(candidate.slug);
173+ normalizedModels.push(candidate);
174174 if (normalizedModels.length >= MAX_CUSTOM_MODEL_COUNT) {
175175 break;
176176 }
@@ -205,17 +205,13 @@ export function getAppModelOptions( · 28 unmodified lines
205205 // see the user's authored custom models.
206206 const defaultInstanceId = defaultInstanceIdForDriver(provider);
207207 const customModels = readInstanceCustomModels(settings, defaultInstanceId, provider);
208− for (const slug of normalizeCustomModelSlugs(customModels, builtInModelSlugs)) {
209− if (seen.has(slug)) {
208+ for (const entry of normalizeCustomModelEntries(customModels, builtInModelSlugs)) {
209+ if (seen.has(entry.slug)) {
210210 continue;
211211 }
212212
213− seen.add(slug);
214− options.push({
215− slug,
216− name: slug,
217− isCustom: true,
218− });
213+ seen.add(entry.slug);
214+ options.push({ slug: entry.slug, name: entry.name, isCustom: true });
219215 }
220216
221217 const preferences = readInstanceModelPreferences(settings, defaultInstanceId);
@@ -257,13 +253,13 @@ export function getAppModelOptionsForInstance( · 35 unmodified lines
257253 );
258254
259255 const customModels = readInstanceCustomModels(settings, entry.instanceId, entry.driverKind);
260− for (const slug of normalizeCustomModelSlugs(customModels, builtInModelSlugs)) {
261− if (seen.has(slug)) {
256+ for (const custom of normalizeCustomModelEntries(customModels, builtInModelSlugs)) {
257+ if (seen.has(custom.slug)) {
262258 continue;
263259 }
264260
265− seen.add(slug);
266− options.push({ slug, name: slug, isCustom: true });
261+ seen.add(custom.slug);
262+ options.push({ slug: custom.slug, name: custom.name, isCustom: true });
267263 }
268264
269265 const preferences = readInstanceModelPreferences(settings, entry.instanceId);
docs/user/composer.md+6−0
@@ -30,6 +30,12 @@ messages while disconnected. Uploads resume when you reconnect. Drafts and queue · 29 unmodified lines
3030 messages survive app restarts. Signing out of T3 Connect keeps that work on your
3131 device until you sign back into the same account.
3232
33+## Custom models
34+
35+On web and desktop, use Settings → Providers → **Models** to add an unlisted model with a custom
36+name and options. Only options supported by the provider integration affect turns. Antigravity
37+uses its account catalog and does not support custom models.
38+
3339 ## Model defaults
3440
3541 T3 Code remembers your provider, model, and model options for new threads. A
packages/contracts/src/model.ts+16−0
@@ -127,6 +127,22 @@ export const ModelCapabilities = Schema.Struct({ · 126 unmodified lines
127127 });
128128 export type ModelCapabilities = typeof ModelCapabilities.Type;
129129
130+/**
131+ * A user-authored custom model. `name` and `capabilities` are optional so a
132+ * bare slug keeps its driver-default presentation; when `capabilities` is
133+ * set, its descriptors replace the driver default in the model picker.
134+ */
135+export const CustomModelEntry = Schema.Struct({
136+ slug: TrimmedNonEmptyString,
137+ name: Schema.optional(TrimmedNonEmptyString),
138+ capabilities: Schema.optional(ModelCapabilities),
139+});
140+export type CustomModelEntry = typeof CustomModelEntry.Type;
141+
142+/** On-disk custom model setting: the legacy bare slug, or a full entry. */
143+export const CustomModelSetting = Schema.Union([Schema.String, CustomModelEntry]);
144+export type CustomModelSetting = typeof CustomModelSetting.Type;
145+
130146 const CODEX_DRIVER_KIND = ProviderDriverKind.make("codex");
131147 const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent");
132148 const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor");
packages/contracts/src/settings.test.ts+34−0
@@ -73,6 +73,40 @@ describe("ServerSettings usage price overrides", () => { · 72 unmodified lines
7373 });
7474 });
7575
76+describe("custom model settings", () => {
77+ const capabilities = {
78+ optionDescriptors: [
79+ {
80+ id: "effort",
81+ label: "Reasoning",
82+ type: "select",
83+ options: [{ id: "high", label: "High", isDefault: true }],
84+ },
85+ ],
86+ };
87+
88+ it("accepts legacy bare slugs alongside full entries", () => {
89+ const decoded = decodeClaudeSettings({
90+ customModels: ["bare-slug", { slug: "named", name: "Named", capabilities }],
91+ });
92+ expect(decoded.customModels).toEqual([
93+ "bare-slug",
94+ { slug: "named", name: "Named", capabilities },
95+ ]);
96+ });
97+
98+ it("accepts entries at the settings patch boundary", () => {
99+ expect(
100+ decodeServerSettingsPatch({
101+ providers: { codex: { customModels: [{ slug: "x", capabilities }] } },
102+ }).providers?.codex?.customModels,
103+ ).toEqual([{ slug: "x", capabilities }]);
104+ expect(() =>
105+ decodeServerSettingsPatch({ providers: { codex: { customModels: [{ name: "no slug" }] } } }),
106+ ).toThrow();
107+ });
108+});
109+
76110 describe("ClaudeSettings auto-compaction", () => {
77111 it("uses Claude's default threshold when no override is configured", () => {
78112 expect(decodeClaudeSettings({}).autoCompactWindow).toBe("");
packages/contracts/src/settings.ts+13−12
@@ -6,6 +6,7 @@ import { ForwardCompatibleNullable, TrimmedNonEmptyString, TrimmedString } from · 5 unmodified lines
66 import { UsageLimitSourceId } from "./usageLimitSourceId.ts";
77 import { EnvironmentMachineKind, ThreadEnvMode } from "./environment.ts";
88 import {
9+ CustomModelSetting,
910 DEFAULT_TEXT_GENERATION_MODEL,
1011 DEFAULT_TEXT_GENERATION_REASONING_EFFORT,
1112 ProviderOptionSelections,
@@ -472,7 +473,7 @@ export const CodexSettings = makeProviderSettingsSchema( · 460 unmodified lines
472473 description: "Additional CLI arguments passed to codex app-server on session start.",
473474 }),
474475 ),
475− customModels: Schema.Array(Schema.String).pipe(
476+ customModels: Schema.Array(CustomModelSetting).pipe(
476477 Schema.withDecodingDefault(Effect.succeed([])),
477478 Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
478479 ),
@@ -510,7 +511,7 @@ export const ClaudeSettings = makeProviderSettingsSchema( · 31 unmodified lines
510511 providerSettingsForm: { placeholder: "~/.claude", clearWhenEmpty: "omit" },
511512 }),
512513 ),
513− customModels: Schema.Array(Schema.String).pipe(
514+ customModels: Schema.Array(CustomModelSetting).pipe(
514515 Schema.withDecodingDefault(Effect.succeed([])),
515516 Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
516517 ),
@@ -571,7 +572,7 @@ export const CursorSettings = makeProviderSettingsSchema( · 54 unmodified lines
571572 },
572573 }),
573574 ),
574− customModels: Schema.Array(Schema.String).pipe(
575+ customModels: Schema.Array(CustomModelSetting).pipe(
575576 Schema.withDecodingDefault(Effect.succeed([])),
576577 Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
577578 ),
@@ -597,7 +598,7 @@ export const GrokSettings = makeProviderSettingsSchema( · 19 unmodified lines
597598 providerSettingsForm: { placeholder: "grok", clearWhenEmpty: "omit" },
598599 }),
599600 ),
600− customModels: Schema.Array(Schema.String).pipe(
601+ customModels: Schema.Array(CustomModelSetting).pipe(
601602 Schema.withDecodingDefault(Effect.succeed([])),
602603 Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
603604 ),
@@ -682,7 +683,7 @@ export const AntigravitySettings = makeProviderSettingsSchema( · 78 unmodified lines
682683 providerSettingsForm: { placeholder: "Automatic", clearWhenEmpty: "persist" },
683684 }),
684685 ),
685− customModels: Schema.Array(Schema.String).pipe(
686+ customModels: Schema.Array(CustomModelSetting).pipe(
686687 Schema.withDecodingDefault(Effect.succeed([])),
687688 Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
688689 ),
@@ -732,7 +733,7 @@ export const OpenCodeSettings = makeProviderSettingsSchema( · 43 unmodified lines
732733 },
733734 }),
734735 ),
735− customModels: Schema.Array(Schema.String).pipe(
736+ customModels: Schema.Array(CustomModelSetting).pipe(
736737 Schema.withDecodingDefault(Effect.succeed([])),
737738 Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
738739 ),
@@ -1055,14 +1056,14 @@ const CodexSettingsPatch = Schema.Struct({ · 316 unmodified lines
10551056 homePath: Schema.optionalKey(TrimmedString),
10561057 shadowHomePath: Schema.optionalKey(TrimmedString),
10571058 launchArgs: Schema.optionalKey(TrimmedString),
1058− customModels: Schema.optionalKey(Schema.Array(Schema.String)),
1059+ customModels: Schema.optionalKey(Schema.Array(CustomModelSetting)),
10591060 });
10601061
10611062 const ClaudeSettingsPatch = Schema.Struct({
10621063 enabled: Schema.optionalKey(Schema.Boolean),
10631064 binaryPath: Schema.optionalKey(TrimmedString),
10641065 homePath: Schema.optionalKey(TrimmedString),
1065− customModels: Schema.optionalKey(Schema.Array(Schema.String)),
1066+ customModels: Schema.optionalKey(Schema.Array(CustomModelSetting)),
10661067 launchArgs: Schema.optionalKey(TrimmedString),
10671068 // Validated at the patch boundary so a typo fails the one update with a
10681069 // schema error instead of a generic whole-settings failure.
@@ -1075,13 +1076,13 @@ const CursorSettingsPatch = Schema.Struct({ · 6 unmodified lines
10751076 enabled: Schema.optionalKey(Schema.Boolean),
10761077 binaryPath: Schema.optionalKey(TrimmedString),
10771078 apiEndpoint: Schema.optionalKey(TrimmedString),
1078− customModels: Schema.optionalKey(Schema.Array(Schema.String)),
1079+ customModels: Schema.optionalKey(Schema.Array(CustomModelSetting)),
10791080 });
10801081
10811082 const GrokSettingsPatch = Schema.Struct({
10821083 enabled: Schema.optionalKey(Schema.Boolean),
10831084 binaryPath: Schema.optionalKey(TrimmedString),
1084− customModels: Schema.optionalKey(Schema.Array(Schema.String)),
1085+ customModels: Schema.optionalKey(Schema.Array(CustomModelSetting)),
10851086 });
10861087
10871088 const AntigravitySettingsPatch = Schema.Struct({
@@ -1091,7 +1092,7 @@ const AntigravitySettingsPatch = Schema.Struct({ · 3 unmodified lines
10911092 gcpProject: Schema.optionalKey(TrimmedString),
10921093 gcpLocation: Schema.optionalKey(TrimmedString),
10931094 binaryPath: Schema.optionalKey(TrimmedString),
1094− customModels: Schema.optionalKey(Schema.Array(Schema.String)),
1095+ customModels: Schema.optionalKey(Schema.Array(CustomModelSetting)),
10951096 });
10961097
10971098 const OpenCodeSettingsPatch = Schema.Struct({
@@ -1099,7 +1100,7 @@ const OpenCodeSettingsPatch = Schema.Struct({ · 1 unmodified line
10991100 binaryPath: Schema.optionalKey(TrimmedString),
11001101 serverUrl: Schema.optionalKey(TrimmedString),
11011102 serverPassword: Schema.optionalKey(TrimmedString),
1102− customModels: Schema.optionalKey(Schema.Array(Schema.String)),
1103+ customModels: Schema.optionalKey(Schema.Array(CustomModelSetting)),
11031104 });
11041105
11051106 export const ServerSettingsPatch = Schema.Struct({
packages/shared/src/model.test.ts+52−0
@@ -10,6 +10,8 @@ import { · 9 unmodified lines
1010 getModelSelectionBooleanOptionValue,
1111 getModelSelectionStringOptionValue,
1212 getProviderOptionDescriptors,
13+ readCustomModelEntries,
14+ toCustomModelSetting,
1315 getProviderOptionBooleanSelectionValue,
1416 getProviderOptionStringSelectionValue,
1517 } from "./model.ts";
@@ -192,3 +194,53 @@ describe("applyClaudePromptEffortPrefix", () => { · 176 unmodified lines
192194 );
193195 });
194196 });
197+
198+describe("readCustomModelEntries", () => {
199+ const capabilities: ModelCapabilities = {
200+ optionDescriptors: [
201+ {
202+ id: "effort",
203+ label: "Reasoning",
204+ type: "select",
205+ options: [{ id: "high", label: "High", isDefault: true }],
206+ currentValue: "high",
207+ },
208+ ],
209+ };
210+
211+ it("resolves bare slugs and entries, trimming and deduplicating on slug", () => {
212+ expect(
213+ readCustomModelEntries([
214+ " bare ",
215+ { slug: "named", name: " Named ", capabilities },
216+ "bare",
217+ { slug: "named", name: "Second" },
218+ "",
219+ { name: "no slug" },
220+ 42,
221+ ]),
222+ ).toEqual([
223+ { slug: "bare", name: "bare", capabilities: null },
224+ { slug: "named", name: "Named", capabilities },
225+ ]);
226+ });
227+
228+ it("drops unparseable capabilities but keeps the entry", () => {
229+ expect(
230+ readCustomModelEntries([{ slug: "x", capabilities: { optionDescriptors: "nope" } }]),
231+ ).toEqual([{ slug: "x", name: "x", capabilities: null }]);
232+ expect(readCustomModelEntries("not a list")).toEqual([]);
233+ });
234+
235+ it("writes the compact stored shape back", () => {
236+ expect(toCustomModelSetting({ slug: "x", name: "x", capabilities: null })).toBe("x");
237+ expect(
238+ toCustomModelSetting({ slug: "x", name: "x", capabilities: { optionDescriptors: [] } }),
239+ ).toBe("x");
240+ expect(toCustomModelSetting({ slug: "x", name: "X", capabilities })).toEqual({
241+ slug: "x",
242+ name: "X",
243+ capabilities,
244+ });
245+ });
246+});
packages/shared/src/model.ts+74−1
@@ -1,12 +1,15 @@
11 import {
2+ type CustomModelSetting,
23 MODEL_SLUG_ALIASES_BY_PROVIDER,
3− type ModelCapabilities,
4+ ModelCapabilities,
45 type ModelSelection,
56 ProviderDriverKind,
67 ProviderInstanceId,
78 type ProviderOptionDescriptor,
89 type ProviderOptionSelection,
910 } from "@t3tools/contracts";
11+import * as Option from "effect/Option";
12+import * as Schema from "effect/Schema";
1013
1114 const DEFAULT_PROVIDER_DRIVER_KIND = ProviderDriverKind.make("codex");
1215
@@ -247,6 +250,76 @@ export function normalizeCustomModelSlug(model: string | null | undefined): stri · 234 unmodified lines
247250 return model.trim() || null;
248251 }
249252
253+/** A custom model setting with its optional fields resolved. */
254+export interface CustomModelDefinition {
255+ readonly slug: string;
256+ readonly name: string;
257+ readonly capabilities: ModelCapabilities | null;
258+}
259+
260+const decodeCustomModelCapabilities = Schema.decodeUnknownOption(ModelCapabilities);
261+
262+/**
263+ * Read a `customModels` setting into resolved definitions. Accepts the typed
264+ * union as well as the opaque `providerInstances[id].config` blob clients see,
265+ * so it tolerates bare slugs, malformed rows, and unparseable capabilities
266+ * (dropped rather than failing the whole list). Slugs are trimmed and
267+ * deduplicated, first occurrence wins; `name` falls back to the slug.
268+ */
269+export function readCustomModelEntries(value: unknown): CustomModelDefinition[] {
270+ if (!Array.isArray(value)) return [];
271+ const entries: CustomModelDefinition[] = [];
272+ const seen = new Set<string>();
273+ for (const raw of value) {
274+ const record =
275+ typeof raw === "string"
276+ ? { slug: raw }
277+ : raw !== null && typeof raw === "object"
278+ ? (raw as { slug?: unknown; name?: unknown; capabilities?: unknown })
279+ : null;
280+ if (!record) continue;
281+ const slug = normalizeCustomModelSlug(typeof record.slug === "string" ? record.slug : null);
282+ if (!slug || seen.has(slug)) continue;
283+ seen.add(slug);
284+ const name =
285+ (typeof record.name === "string" ? normalizeCustomModelSlug(record.name) : null) ?? slug;
286+ const capabilities =
287+ record.capabilities === undefined || record.capabilities === null
288+ ? null
289+ : Option.getOrNull(decodeCustomModelCapabilities(record.capabilities));
290+ entries.push({
291+ slug,
292+ name,
293+ capabilities: capabilities
294+ ? createModelCapabilities({ optionDescriptors: capabilities.optionDescriptors ?? [] })
295+ : null,
296+ });
297+ }
298+ return entries;
299+}
300+
301+/** Slugs of a `customModels` setting, in stored order. */
302+export function readCustomModelSlugs(value: unknown): string[] {
303+ return readCustomModelEntries(value).map((entry) => entry.slug);
304+}
305+
306+/**
307+ * Write a definition back to the compact stored shape: a bare slug when it
308+ * carries nothing custom, otherwise an entry with only the set fields.
309+ */
310+export function toCustomModelSetting(entry: CustomModelDefinition): CustomModelSetting {
311+ const descriptors = entry.capabilities?.optionDescriptors ?? [];
312+ const name = entry.name !== entry.slug ? entry.name : undefined;
313+ if (!name && descriptors.length === 0) return entry.slug;
314+ return {
315+ slug: entry.slug,
316+ ...(name ? { name } : {}),
317+ ...(descriptors.length > 0
318+ ? { capabilities: createModelCapabilities({ optionDescriptors: descriptors }) }
319+ : {}),
320+ };
321+}
322+
250323 export function resolveSelectableModel(
251324 provider: ProviderDriverKind,
252325 value: string | null | undefined,