authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-04 16:11:20-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-04 16:11:20-07:00
log5a433244d0b827176df8b76a05959cd1a7a98ce2
tree0c1977f93448345050a781a6bb37ad321bd4ddec
parentdd7bc147f799f290eb58578a7f81643ecf9ad52e
signature Signed by PGP key B5690EEEBB952194

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


19 files changed, 1423 insertions(+), 150 deletions(-)

apps/server/src/provider/ClaudeModelCatalog.test.ts+56
......@@ -7,9 +7,11 @@ import {
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", () => {
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 @@
11import {
2 type CustomModelSetting,
23 type ModelCapabilities,
34 type ModelSelection,
45 ProviderDriverKind,
......@@ -9,7 +10,7 @@ import {
910 getModelSelectionStringOptionValue,
1011 getProviderOptionCurrentValue,
1112 getProviderOptionDescriptors,
12 normalizeCustomModelSlug,
13 readCustomModelEntries,
1314} from "@t3tools/shared/model";
1415import { compareSemverVersions } from "@t3tools/shared/semver";
1516
......@@ -70,33 +71,51 @@ export function resolveClaudeModelCatalog(manifest: ModelManifestData): ClaudeMo
7071
7172export 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 */
7482export 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
102121export function resolveClaudeCatalogModel(
apps/server/src/provider/Layers/CodexProvider.ts+18-26
......@@ -15,6 +15,7 @@ import * as CodexErrors from "effect-codex-app-server/errors";
1515
1616import type {
1717 CodexSettings,
18 CustomModelSetting,
1819 ServerProvider,
1920 ServerProviderState,
2021 ModelCapabilities,
......@@ -24,7 +25,7 @@ import type {
2425} from "@t3tools/contracts";
2526import { PREFERRED_DEFAULT_CODEX_MODELS, ServerSettingsError } from "@t3tools/contracts";
2627
27import { createModelCapabilities } from "@t3tools/shared/model";
28import { createModelCapabilities, readCustomModelEntries } from "@t3tools/shared/model";
2829import { resolveSpawnCommand } from "@t3tools/shared/shell";
2930import { codexAppServerArgs, resolveCodexLaunchArgs } from "./codexLaunchArgs.ts";
3031import {
......@@ -244,9 +245,14 @@ export function applyPreferredCodexDefaultModel(
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 */
247253function 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(
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
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
470475 return parseCodexSkillsListResponse(skillsResponse, input.cwd);
471476});
472477
473const 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};
478const emptyCodexModelsFromSettings = (codexSettings: CodexSettings): ServerProvider["models"] =>
479 appendCustomCodexModels([], codexSettings.customModels);
488480
489481const makePendingCodexProvider = (
490482 codexSettings: CodexSettings,
......@@ -562,7 +554,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu
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 @@
11import {
2 type CustomModelSetting,
23 type GrokSettings,
34 type ModelCapabilities,
45 type ServerProvider,
......@@ -104,7 +105,7 @@ export function buildInitialGrokProviderSnapshot(
104105}
105106
106107function 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
......@@ -52,6 +52,27 @@ describe("providerModelsFromSettings", () => {
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 @@
11import type {
2 CustomModelSetting,
23 ProviderDriverKind,
34 ModelCapabilities,
45 ServerProvider,
......@@ -14,7 +15,7 @@ import * as PlatformError from "effect/PlatformError";
1415import * as Schema from "effect/Schema";
1516import * as Stream from "effect/Stream";
1617import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
17import { normalizeCustomModelSlug } from "@t3tools/shared/model";
18import { readCustomModelEntries } from "@t3tools/shared/model";
1819import { isWindowsCommandNotFound } from "../processRunner.ts";
1920import { createProviderVersionAdvisory } from "./providerMaintenance.ts";
2021import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts";
......@@ -145,26 +146,30 @@ export function parseGenericCliVersion(output: string): string | null {
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 */
148154export 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 created+387
......@@ -0,0 +1,387 @@
1"use client";
2
3import { PlusIcon, XIcon } from "lucide-react";
4import { useMemo, useState } from "react";
5import type { ProviderDriverKind, ServerProviderModel } from "@t3tools/contracts";
6import type { CustomModelDefinition } from "@t3tools/shared/model";
7
8import { cn } from "../../lib/utils";
9import { Button } from "../ui/button";
10import { Input } from "../ui/input";
11import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
12import { Switch } from "../ui/switch";
13import {
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
28const CUSTOM_ID_VALUE = "__custom__";
29const START_FROM_NONE = "__none__";
30
31interface 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 */
46export 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", () => {
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 {
2525 type ServerProviderModel,
2626} from "@t3tools/contracts";
2727
28import {
29 type CustomModelDefinition,
30 readCustomModelEntries,
31 toCustomModelSetting,
32} from "@t3tools/shared/model";
2833import { cn } from "../../lib/utils";
2934import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";
3035import { normalizeProviderAccentColor } from "../../providerInstances";
......@@ -96,16 +101,13 @@ function providerEnvironmentsEqual(
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 */
104function readConfigStringArray(config: unknown, key: string): ReadonlyArray<string> {
108function 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(
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 */
130137export 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: {
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({
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({
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
3import { ArrowDownIcon, ArrowUpIcon, PlusIcon, StarIcon, XIcon } from "lucide-react";
3import { ArrowDownIcon, ArrowUpIcon, PencilIcon, PlusIcon, StarIcon, XIcon } from "lucide-react";
44import { useEffect, useMemo, useRef, useState } from "react";
55import {
66 ProviderDriverKind,
77 type ProviderInstanceId,
88 type ServerProviderModel,
99} from "@t3tools/contracts";
10import { normalizeCustomModelSlug } from "@t3tools/shared/model";
10import { type CustomModelDefinition, normalizeCustomModelSlug } from "@t3tools/shared/model";
1111
1212import { cn } from "../../lib/utils";
1313import { sortModelsForProviderInstance } from "../../modelOrdering";
......@@ -16,6 +16,7 @@ import { Button } from "../ui/button";
1616import { Input } from "../ui/input";
1717import { Switch } from "../ui/switch";
1818import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
19import { CustomModelEditor } from "./CustomModelEditor";
1920
2021/**
2122 * Placeholder text for the "add a custom model" input, keyed by driver
......@@ -109,11 +110,11 @@ interface ProviderModelsSectionProps {
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 {
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({
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({
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({
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({
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({
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({
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({
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({
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({
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 created+259
......@@ -0,0 +1,259 @@
1import { describe, expect, it } from "vite-plus/test";
2import { ProviderDriverKind, type ModelCapabilities } from "@t3tools/contracts";
3
4import {
5 DESCRIPTOR_PRESETS_BY_KIND,
6 descriptorFromPreset,
7 definitionFromDraft,
8 descriptorsFromCapabilities,
9 draftFromDefinition,
10 validateDraft,
11 type CustomModelDraft,
12} from "./customModelEditor.logic";
13
14const draft = (overrides: Partial<CustomModelDraft>): CustomModelDraft => ({
15 slug: "my-model",
16 name: "",
17 descriptors: [],
18 ...overrides,
19});
20
21describe("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 created+258
......@@ -0,0 +1,258 @@
1import {
2 type ModelCapabilities,
3 ProviderDriverKind,
4 type ProviderOptionDescriptor,
5} from "@t3tools/contracts";
6import { type CustomModelDefinition, createModelCapabilities } from "@t3tools/shared/model";
7
8/** Editable mirror of a `ProviderOptionChoice`. `key` is only a React key. */
9export 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. */
18export 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
28export interface CustomModelDraft {
29 readonly slug: string;
30 readonly name: string;
31 readonly descriptors: ReadonlyArray<EditorDescriptor>;
32}
33
34export 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
41const 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 */
53export 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
106let nextKey = 0;
107export function newEditorKey(): string {
108 nextKey += 1;
109 return `k${nextKey}`;
110}
111
112export 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
120export 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
130export function emptyEditorDescriptor(): EditorDescriptor {
131 return { key: newEditorKey(), type: "select", id: "", label: "", choices: [] };
132}
133
134export 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 */
143export 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
173export 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. */
182export 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 */
195export 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. */
220export 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 {
1010 type ServerSettingsPatch,
1111} from "@t3tools/contracts";
1212import {
13 type CustomModelDefinition,
1314 createModelSelection,
1415 normalizeCustomModelSlug,
16 readCustomModelEntries,
1517 resolveSelectableModel,
1618} from "@t3tools/shared/model";
1719import { getComposerProviderState } from "./components/chat/composerProviderState";
......@@ -55,14 +57,14 @@ function readInstanceCustomModels(
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(
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
7981export interface AppModelOption {
......@@ -151,26 +153,24 @@ function applyInstanceModelPreferences(
151153 );
152154}
153155
154export function normalizeCustomModelSlugs(
155 models: Iterable<string | null | undefined>,
156export 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(
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(
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
......@@ -30,6 +30,12 @@ messages while disconnected. Uploads resume when you reconnect. Drafts and queue
3030messages survive app restarts. Signing out of T3 Connect keeps that work on your
3131device until you sign back into the same account.
3232
33## Custom models
34
35On web and desktop, use Settings → Providers → **Models** to add an unlisted model with a custom
36name and options. Only options supported by the provider integration affect turns. Antigravity
37uses its account catalog and does not support custom models.
38
3339## Model defaults
3440
3541T3 Code remembers your provider, model, and model options for new threads. A
packages/contracts/src/model.ts+16
......@@ -127,6 +127,22 @@ export const ModelCapabilities = Schema.Struct({
127127});
128128export 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 */
135export const CustomModelEntry = Schema.Struct({
136 slug: TrimmedNonEmptyString,
137 name: Schema.optional(TrimmedNonEmptyString),
138 capabilities: Schema.optional(ModelCapabilities),
139});
140export type CustomModelEntry = typeof CustomModelEntry.Type;
141
142/** On-disk custom model setting: the legacy bare slug, or a full entry. */
143export const CustomModelSetting = Schema.Union([Schema.String, CustomModelEntry]);
144export type CustomModelSetting = typeof CustomModelSetting.Type;
145
130146const CODEX_DRIVER_KIND = ProviderDriverKind.make("codex");
131147const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent");
132148const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor");
packages/contracts/src/settings.test.ts+34
......@@ -73,6 +73,40 @@ describe("ServerSettings usage price overrides", () => {
7373 });
7474});
7575
76describe("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
76110describe("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
66import { UsageLimitSourceId } from "./usageLimitSourceId.ts";
77import { EnvironmentMachineKind, ThreadEnvMode } from "./environment.ts";
88import {
9 CustomModelSetting,
910 DEFAULT_TEXT_GENERATION_MODEL,
1011 DEFAULT_TEXT_GENERATION_REASONING_EFFORT,
1112 ProviderOptionSelections,
......@@ -472,7 +473,7 @@ export const CodexSettings = makeProviderSettingsSchema(
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(
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(
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(
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(
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(
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({
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
10611062const 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({
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
10811082const 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
10871088const AntigravitySettingsPatch = Schema.Struct({
......@@ -1091,7 +1092,7 @@ const AntigravitySettingsPatch = Schema.Struct({
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
10971098const OpenCodeSettingsPatch = Schema.Struct({
......@@ -1099,7 +1100,7 @@ const OpenCodeSettingsPatch = Schema.Struct({
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
11051106export const ServerSettingsPatch = Schema.Struct({
packages/shared/src/model.test.ts+52
......@@ -10,6 +10,8 @@ import {
1010 getModelSelectionBooleanOptionValue,
1111 getModelSelectionStringOptionValue,
1212 getProviderOptionDescriptors,
13 readCustomModelEntries,
14 toCustomModelSetting,
1315 getProviderOptionBooleanSelectionValue,
1416 getProviderOptionStringSelectionValue,
1517} from "./model.ts";
......@@ -192,3 +194,53 @@ describe("applyClaudePromptEffortPrefix", () => {
192194 );
193195 });
194196});
197
198describe("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 @@
11import {
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";
11import * as Option from "effect/Option";
12import * as Schema from "effect/Schema";
1013
1114const DEFAULT_PROVIDER_DRIVER_KIND = ProviderDriverKind.make("codex");
1215
......@@ -247,6 +250,76 @@ export function normalizeCustomModelSlug(model: string | null | undefined): stri
247250 return model.trim() || null;
248251}
249252
253/** A custom model setting with its optional fields resolved. */
254export interface CustomModelDefinition {
255 readonly slug: string;
256 readonly name: string;
257 readonly capabilities: ModelCapabilities | null;
258}
259
260const 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 */
269export 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. */
302export 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 */
310export 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
250323export function resolveSelectableModel(
251324 provider: ProviderDriverKind,
252325 value: string | null | undefined,