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 {...@@ -7,9 +7,11 @@ import {
7 formatClaudeVersionUpgradeMessage,7 formatClaudeVersionUpgradeMessage,
8 normalizeClaudeCatalogEffort,8 normalizeClaudeCatalogEffort,
9 resolveClaudeCatalogApiModelId,9 resolveClaudeCatalogApiModelId,
10 resolveClaudeCatalogEffort,
10 resolveClaudeModelCatalog,11 resolveClaudeModelCatalog,
11 resolveClaudeModelsForVersion,12 resolveClaudeModelsForVersion,
12 resolveClaudeModelSlug,13 resolveClaudeModelSlug,
14 scopeClaudeModelCatalog,
13} from "./ClaudeModelCatalog.ts";15} from "./ClaudeModelCatalog.ts";
1416
15/**17/**
...@@ -134,4 +136,58 @@ describe("Claude model catalog", () => {...@@ -134,4 +136,58 @@ describe("Claude model catalog", () => {
134 };136 };
135 assert.isFalse(hasValidClaudeManifestAdapters(malformed));137 assert.isFalse(hasValidClaudeManifestAdapters(malformed));
136 });138 });
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 });
137});193});
apps/server/src/provider/ClaudeModelCatalog.ts+43-24
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import {1import {
2 type CustomModelSetting,
2 type ModelCapabilities,3 type ModelCapabilities,
3 type ModelSelection,4 type ModelSelection,
4 ProviderDriverKind,5 ProviderDriverKind,
...@@ -9,7 +10,7 @@ import {...@@ -9,7 +10,7 @@ import {
9 getModelSelectionStringOptionValue,10 getModelSelectionStringOptionValue,
10 getProviderOptionCurrentValue,11 getProviderOptionCurrentValue,
11 getProviderOptionDescriptors,12 getProviderOptionDescriptors,
12 normalizeCustomModelSlug,13 readCustomModelEntries,
13} from "@t3tools/shared/model";14} from "@t3tools/shared/model";
14import { compareSemverVersions } from "@t3tools/shared/semver";15import { compareSemverVersions } from "@t3tools/shared/semver";
1516
...@@ -70,33 +71,51 @@ export function resolveClaudeModelCatalog(manifest: ModelManifestData): ClaudeMo...@@ -70,33 +71,51 @@ export function resolveClaudeModelCatalog(manifest: ModelManifestData): ClaudeMo
7071
71export const BUNDLED_CLAUDE_MODEL_CATALOG = resolveClaudeModelCatalog(BUNDLED_MODEL_MANIFEST);72export 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 */
74export function scopeClaudeModelCatalog(82export function scopeClaudeModelCatalog(
75 catalog: ClaudeModelCatalog,83 catalog: ClaudeModelCatalog,
76 customModels: ReadonlyArray<string>,84 customModels: ReadonlyArray<CustomModelSetting>,
77): ClaudeModelCatalog {85): ClaudeModelCatalog {
78 const customAliases = new Set(86 const customEntries = readCustomModelEntries(customModels);
79 customModels.flatMap((model) => {87 if (customEntries.length === 0) return catalog;
80 const slug = normalizeCustomModelSlug(model);88 const customAliases = new Set(customEntries.map((entry) => entry.slug.toLowerCase()));
81 return slug ? [slug.toLowerCase()] : [];
82 }),
83 );
84 if (customAliases.size === 0) return catalog;
8589
86 return {90 const builtInModels = catalog.models.map((entry) => {
87 models: catalog.models.map((entry) => {91 if (!entry.model.aliases?.some((alias) => customAliases.has(alias.toLowerCase()))) {
88 if (!entry.model.aliases?.some((alias) => customAliases.has(alias.toLowerCase()))) {92 return entry;
89 return entry;93 }
90 }94 return {
91 return {95 ...entry,
92 ...entry,96 model: {
93 model: {97 ...entry.model,
94 ...entry.model,98 aliases: entry.model.aliases.filter((alias) => !customAliases.has(alias.toLowerCase())),
95 aliases: entry.model.aliases.filter((alias) => !customAliases.has(alias.toLowerCase())),99 },
96 },100 };
97 };101 });
98 }),102 const builtInSlugs = new Set(builtInModels.map((entry) => entry.model.slug));
99 };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] };
100}119}
101120
102export function resolveClaudeCatalogModel(121export function resolveClaudeCatalogModel(
apps/server/src/provider/Layers/CodexProvider.ts+18-26
...@@ -15,6 +15,7 @@ import * as CodexErrors from "effect-codex-app-server/errors";...@@ -15,6 +15,7 @@ import * as CodexErrors from "effect-codex-app-server/errors";
1515
16import type {16import type {
17 CodexSettings,17 CodexSettings,
18 CustomModelSetting,
18 ServerProvider,19 ServerProvider,
19 ServerProviderState,20 ServerProviderState,
20 ModelCapabilities,21 ModelCapabilities,
...@@ -24,7 +25,7 @@ import type {...@@ -24,7 +25,7 @@ import type {
24} from "@t3tools/contracts";25} from "@t3tools/contracts";
25import { PREFERRED_DEFAULT_CODEX_MODELS, ServerSettingsError } from "@t3tools/contracts";26import { PREFERRED_DEFAULT_CODEX_MODELS, ServerSettingsError } from "@t3tools/contracts";
2627
27import { createModelCapabilities } from "@t3tools/shared/model";28import { createModelCapabilities, readCustomModelEntries } from "@t3tools/shared/model";
28import { resolveSpawnCommand } from "@t3tools/shared/shell";29import { resolveSpawnCommand } from "@t3tools/shared/shell";
29import { codexAppServerArgs, resolveCodexLaunchArgs } from "./codexLaunchArgs.ts";30import { codexAppServerArgs, resolveCodexLaunchArgs } from "./codexLaunchArgs.ts";
30import {31import {
...@@ -244,9 +245,14 @@ export function applyPreferredCodexDefaultModel(...@@ -244,9 +245,14 @@ export function applyPreferredCodexDefaultModel(
244 });245 });
245}246}
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 */
247function appendCustomCodexModels(253function appendCustomCodexModels(
248 models: ReadonlyArray<ServerProviderModel>,254 models: ReadonlyArray<ServerProviderModel>,
249 customModels: ReadonlyArray<string>,255 customModels: ReadonlyArray<CustomModelSetting>,
250): ReadonlyArray<ServerProviderModel> {256): ReadonlyArray<ServerProviderModel> {
251 if (customModels.length === 0) {257 if (customModels.length === 0) {
252 return models;258 return models;
...@@ -255,17 +261,16 @@ function appendCustomCodexModels(...@@ -255,17 +261,16 @@ function appendCustomCodexModels(
255 const seen = new Set(models.map((model) => model.slug));261 const seen = new Set(models.map((model) => model.slug));
256 const fallbackCapabilities = models.find((model) => model.capabilities)?.capabilities ?? null;262 const fallbackCapabilities = models.find((model) => model.capabilities)?.capabilities ?? null;
257 const customEntries: ServerProviderModel[] = [];263 const customEntries: ServerProviderModel[] = [];
258 for (const rawModel of customModels) {264 for (const entry of readCustomModelEntries(customModels)) {
259 const slug = rawModel.trim();265 if (seen.has(entry.slug)) {
260 if (!slug || seen.has(slug)) {
261 continue;266 continue;
262 }267 }
263 seen.add(slug);268 seen.add(entry.slug);
264 customEntries.push({269 customEntries.push({
265 slug,270 slug: entry.slug,
266 name: slug,271 name: entry.name,
267 isCustom: true,272 isCustom: true,
268 capabilities: fallbackCapabilities,273 capabilities: entry.capabilities ?? fallbackCapabilities,
269 });274 });
270 }275 }
271 return customEntries.length === 0 ? models : [...models, ...customEntries];276 return customEntries.length === 0 ? models : [...models, ...customEntries];
...@@ -399,7 +404,7 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun...@@ -399,7 +404,7 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun
399 readonly homePath?: string;404 readonly homePath?: string;
400 readonly launchArgs?: string;405 readonly launchArgs?: string;
401 readonly cwd: string;406 readonly cwd: string;
402 readonly customModels?: ReadonlyArray<string>;407 readonly customModels?: ReadonlyArray<CustomModelSetting>;
403 readonly environment?: NodeJS.ProcessEnv;408 readonly environment?: NodeJS.ProcessEnv;
404}) {409}) {
405 const { client, initialize } = yield* withCodexAppServerClient(input);410 const { client, initialize } = yield* withCodexAppServerClient(input);
...@@ -470,21 +475,8 @@ export const probeCodexSkillsForCwd = Effect.fn("probeCodexSkillsForCwd")(functi...@@ -470,21 +475,8 @@ export const probeCodexSkillsForCwd = Effect.fn("probeCodexSkillsForCwd")(functi
470 return parseCodexSkillsListResponse(skillsResponse, input.cwd);475 return parseCodexSkillsListResponse(skillsResponse, input.cwd);
471});476});
472477
473const emptyCodexModelsFromSettings = (codexSettings: CodexSettings): ServerProvider["models"] => {478const emptyCodexModelsFromSettings = (codexSettings: CodexSettings): ServerProvider["models"] =>
474 const models = new Set<string>();479 appendCustomCodexModels([], codexSettings.customModels);
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};
488480
489const makePendingCodexProvider = (481const makePendingCodexProvider = (
490 codexSettings: CodexSettings,482 codexSettings: CodexSettings,
...@@ -562,7 +554,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu...@@ -562,7 +554,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu
562 readonly homePath?: string;554 readonly homePath?: string;
563 readonly launchArgs?: string;555 readonly launchArgs?: string;
564 readonly cwd: string;556 readonly cwd: string;
565 readonly customModels: ReadonlyArray<string>;557 readonly customModels: ReadonlyArray<CustomModelSetting>;
566 readonly environment?: NodeJS.ProcessEnv;558 readonly environment?: NodeJS.ProcessEnv;
567 }) => Effect.Effect<559 }) => Effect.Effect<
568 CodexAppServerProviderSnapshot,560 CodexAppServerProviderSnapshot,
apps/server/src/provider/Layers/GrokProvider.ts+2-1
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import {1import {
2 type CustomModelSetting,
2 type GrokSettings,3 type GrokSettings,
3 type ModelCapabilities,4 type ModelCapabilities,
4 type ServerProvider,5 type ServerProvider,
...@@ -104,7 +105,7 @@ export function buildInitialGrokProviderSnapshot(...@@ -104,7 +105,7 @@ export function buildInitialGrokProviderSnapshot(
104}105}
105106
106function grokModelsFromSettings(107function grokModelsFromSettings(
107 customModels: ReadonlyArray<string> | undefined,108 customModels: ReadonlyArray<CustomModelSetting> | undefined,
108 builtInModels: ReadonlyArray<ServerProviderModel> = GROK_BUILT_IN_MODELS,109 builtInModels: ReadonlyArray<ServerProviderModel> = GROK_BUILT_IN_MODELS,
109): ReadonlyArray<ServerProviderModel> {110): ReadonlyArray<ServerProviderModel> {
110 return providerModelsFromSettings(builtInModels, customModels ?? [], EMPTY_CAPABILITIES);111 return providerModelsFromSettings(builtInModels, customModels ?? [], EMPTY_CAPABILITIES);
apps/server/src/provider/providerSnapshot.test.ts+21
...@@ -52,6 +52,27 @@ describe("providerModelsFromSettings", () => {...@@ -52,6 +52,27 @@ describe("providerModelsFromSettings", () => {
52 ]);52 ]);
53 });53 });
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
55 it("preserves a custom slug that collides with a provider alias", () => {76 it("preserves a custom slug that collides with a provider alias", () => {
56 const capabilities = createModelCapabilities({ optionDescriptors: [] });77 const capabilities = createModelCapabilities({ optionDescriptors: [] });
57 const models = providerModelsFromSettings(78 const models = providerModelsFromSettings(
apps/server/src/provider/providerSnapshot.ts+14-9
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import type {1import type {
2 CustomModelSetting,
2 ProviderDriverKind,3 ProviderDriverKind,
3 ModelCapabilities,4 ModelCapabilities,
4 ServerProvider,5 ServerProvider,
...@@ -14,7 +15,7 @@ import * as PlatformError from "effect/PlatformError";...@@ -14,7 +15,7 @@ import * as PlatformError from "effect/PlatformError";
14import * as Schema from "effect/Schema";15import * as Schema from "effect/Schema";
15import * as Stream from "effect/Stream";16import * as Stream from "effect/Stream";
16import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";17import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
17import { normalizeCustomModelSlug } from "@t3tools/shared/model";18import { readCustomModelEntries } from "@t3tools/shared/model";
18import { isWindowsCommandNotFound } from "../processRunner.ts";19import { isWindowsCommandNotFound } from "../processRunner.ts";
19import { createProviderVersionAdvisory } from "./providerMaintenance.ts";20import { createProviderVersionAdvisory } from "./providerMaintenance.ts";
20import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts";21import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts";
...@@ -145,26 +146,30 @@ export function parseGenericCliVersion(output: string): string | null {...@@ -145,26 +146,30 @@ export function parseGenericCliVersion(output: string): string | null {
145 return match?.[1] ?? null;146 return match?.[1] ?? null;
146}147}
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 */
148export function providerModelsFromSettings(154export function providerModelsFromSettings(
149 builtInModels: ReadonlyArray<ServerProviderModel>,155 builtInModels: ReadonlyArray<ServerProviderModel>,
150 customModels: ReadonlyArray<string>,156 customModels: ReadonlyArray<CustomModelSetting>,
151 customModelCapabilities: ModelCapabilities,157 customModelCapabilities: ModelCapabilities,
152): ReadonlyArray<ServerProviderModel> {158): ReadonlyArray<ServerProviderModel> {
153 const resolvedBuiltInModels = [...builtInModels];159 const resolvedBuiltInModels = [...builtInModels];
154 const seen = new Set(resolvedBuiltInModels.map((model) => model.slug));160 const seen = new Set(resolvedBuiltInModels.map((model) => model.slug));
155 const customEntries: ServerProviderModel[] = [];161 const customEntries: ServerProviderModel[] = [];
156162
157 for (const candidate of customModels) {163 for (const entry of readCustomModelEntries(customModels)) {
158 const normalized = normalizeCustomModelSlug(candidate);164 if (seen.has(entry.slug)) {
159 if (!normalized || seen.has(normalized)) {
160 continue;165 continue;
161 }166 }
162 seen.add(normalized);167 seen.add(entry.slug);
163 customEntries.push({168 customEntries.push({
164 slug: normalized,169 slug: entry.slug,
165 name: normalized,170 name: entry.name,
166 isCustom: true,171 isCustom: true,
167 capabilities: customModelCapabilities,172 capabilities: entry.capabilities ?? customModelCapabilities,
168 });173 });
169 }174 }
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", () => {...@@ -36,11 +36,52 @@ describe("deriveProviderModelsForDisplay", () => {
36 expect(36 expect(
37 deriveProviderModelsForDisplay({37 deriveProviderModelsForDisplay({
38 liveModels,38 liveModels,
39 customModels: ["kept-custom"],39 customModels: [{ slug: "kept-custom", name: "kept-custom", capabilities: null }],
40 }).map((model) => model.slug),40 }).map((model) => model.slug),
41 ).toEqual(["server-model", "kept-custom"]);41 ).toEqual(["server-model", "kept-custom"]);
42 });42 });
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
44 it("shows a redacted provider email in the editor header status line", () => {85 it("shows a redacted provider email in the editor header status line", () => {
45 const instanceId = ProviderInstanceId.make("codex");86 const instanceId = ProviderInstanceId.make("codex");
46 const driver = ProviderDriverKind.make("codex");87 const driver = ProviderDriverKind.make("codex");
apps/web/src/components/settings/ProviderInstanceCard.tsx+30-21
...@@ -25,6 +25,11 @@ import {...@@ -25,6 +25,11 @@ import {
25 type ServerProviderModel,25 type ServerProviderModel,
26} from "@t3tools/contracts";26} from "@t3tools/contracts";
2727
28import {
29 type CustomModelDefinition,
30 readCustomModelEntries,
31 toCustomModelSetting,
32} from "@t3tools/shared/model";
28import { cn } from "../../lib/utils";33import { cn } from "../../lib/utils";
29import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";34import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";
30import { normalizeProviderAccentColor } from "../../providerInstances";35import { normalizeProviderAccentColor } from "../../providerInstances";
...@@ -96,16 +101,13 @@ function providerEnvironmentsEqual(...@@ -96,16 +101,13 @@ function providerEnvironmentsEqual(
96}101}
97102
98/**103/**
99 * Read a string[] at `key` from the opaque config blob, filtering out104 * Read `customModels` from the opaque config blob. The concrete driver
100 * non-string entries. Used for `customModels`, which is always typed as105 * schemas type it as `CustomModelSetting[]`, but it arrives here as
101 * `string[]` by the concrete driver schemas but arrives here as106 * `Schema.Unknown`, so the shared reader does the shape checking.
102 * `Schema.Unknown`.
103 */107 */
104function readConfigStringArray(config: unknown, key: string): ReadonlyArray<string> {108function readConfigCustomModels(config: unknown): ReadonlyArray<CustomModelDefinition> {
105 if (config === null || typeof config !== "object") return [];109 if (config === null || typeof config !== "object") return [];
106 const value = (config as Record<string, unknown>)[key];110 return readCustomModelEntries((config as Record<string, unknown>).customModels);
107 if (!Array.isArray(value)) return [];
108 return value.filter((entry): entry is string => typeof entry === "string");
109}111}
110112
111/**113/**
...@@ -127,9 +129,14 @@ function nextConfigBlobWithValue(...@@ -127,9 +129,14 @@ function nextConfigBlobWithValue(
127 return base;129 return base;
128}130}
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 */
130export function deriveProviderModelsForDisplay(input: {137export function deriveProviderModelsForDisplay(input: {
131 readonly liveModels: ReadonlyArray<ServerProviderModel> | undefined;138 readonly liveModels: ReadonlyArray<ServerProviderModel> | undefined;
132 readonly customModels: ReadonlyArray<string>;139 readonly customModels: ReadonlyArray<CustomModelDefinition>;
133}): ReadonlyArray<ServerProviderModel> {140}): ReadonlyArray<ServerProviderModel> {
134 const liveCustomModelsBySlug = new Map(141 const liveCustomModelsBySlug = new Map(
135 Arr.filterMap(input.liveModels ?? [], (model) =>142 Arr.filterMap(input.liveModels ?? [], (model) =>
...@@ -137,15 +144,13 @@ export function deriveProviderModelsForDisplay(input: {...@@ -137,15 +144,13 @@ export function deriveProviderModelsForDisplay(input: {
137 ),144 ),
138 );145 );
139 const serverModels = input.liveModels?.filter((model) => !model.isCustom) ?? [];146 const serverModels = input.liveModels?.filter((model) => !model.isCustom) ?? [];
140 const customModels = input.customModels.map(147 const customModels = input.customModels.map((entry) => ({
141 (slug) =>148 slug: entry.slug,
142 liveCustomModelsBySlug.get(slug) ?? {149 name: entry.name,
143 slug,150 isCustom: true,
144 name: slug,151 capabilities:
145 isCustom: true,152 entry.capabilities ?? liveCustomModelsBySlug.get(entry.slug)?.capabilities ?? null,
146 capabilities: null,153 }));
147 },
148 );
149 return [...serverModels, ...customModels];154 return [...serverModels, ...customModels];
150}155}
151156
...@@ -463,7 +468,7 @@ export function ProviderInstanceCard({...@@ -463,7 +468,7 @@ export function ProviderInstanceCard({
463 ? instance.driver468 ? instance.driver
464 : null;469 : null;
465 const customModels =470 const customModels =
466 instance.driver === "antigravity" ? [] : readConfigStringArray(instance.config, "customModels");471 instance.driver === "antigravity" ? [] : readConfigCustomModels(instance.config);
467 // Server-returned models may lag behind settings writes. Treat probe472 // Server-returned models may lag behind settings writes. Treat probe
468 // models as the source for built-ins only; custom rows come directly473 // models as the source for built-ins only; custom rows come directly
469 // from the current instance config so add/remove reflects immediately.474 // from the current instance config so add/remove reflects immediately.
...@@ -504,8 +509,12 @@ export function ProviderInstanceCard({...@@ -504,8 +509,12 @@ export function ProviderInstanceCard({
504 );509 );
505 };510 };
506511
507 const updateCustomModels = (next: ReadonlyArray<string>) => {512 const updateCustomModels = (next: ReadonlyArray<CustomModelDefinition>) => {
508 const nextConfig = nextConfigBlobWithValue(instance.config, "customModels", [...next]);513 const nextConfig = nextConfigBlobWithValue(
514 instance.config,
515 "customModels",
516 next.map(toCustomModelSetting),
517 );
509 const { config: _omit, ...rest } = instance;518 const { config: _omit, ...rest } = instance;
510 onUpdate({ ...rest, config: nextConfig } as ProviderInstanceConfig);519 onUpdate({ ...rest, config: nextConfig } as ProviderInstanceConfig);
511 };520 };
apps/web/src/components/settings/ProviderModelsSection.tsx+75-28
...@@ -1,13 +1,13 @@...@@ -1,13 +1,13 @@
1"use client";1"use client";
22
3import { ArrowDownIcon, ArrowUpIcon, PlusIcon, StarIcon, XIcon } from "lucide-react";3import { ArrowDownIcon, ArrowUpIcon, PencilIcon, PlusIcon, StarIcon, XIcon } from "lucide-react";
4import { useEffect, useMemo, useRef, useState } from "react";4import { useEffect, useMemo, useRef, useState } from "react";
5import {5import {
6 ProviderDriverKind,6 ProviderDriverKind,
7 type ProviderInstanceId,7 type ProviderInstanceId,
8 type ServerProviderModel,8 type ServerProviderModel,
9} from "@t3tools/contracts";9} from "@t3tools/contracts";
10import { normalizeCustomModelSlug } from "@t3tools/shared/model";10import { type CustomModelDefinition, normalizeCustomModelSlug } from "@t3tools/shared/model";
1111
12import { cn } from "../../lib/utils";12import { cn } from "../../lib/utils";
13import { sortModelsForProviderInstance } from "../../modelOrdering";13import { sortModelsForProviderInstance } from "../../modelOrdering";
...@@ -16,6 +16,7 @@ import { Button } from "../ui/button";...@@ -16,6 +16,7 @@ import { Button } from "../ui/button";
16import { Input } from "../ui/input";16import { Input } from "../ui/input";
17import { Switch } from "../ui/switch";17import { Switch } from "../ui/switch";
18import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";18import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
19import { CustomModelEditor } from "./CustomModelEditor";
1920
20/**21/**
21 * Placeholder text for the "add a custom model" input, keyed by driver22 * Placeholder text for the "add a custom model" input, keyed by driver
...@@ -109,11 +110,11 @@ interface ProviderModelsSectionProps {...@@ -109,11 +110,11 @@ interface ProviderModelsSectionProps {
109 */110 */
110 readonly models: ReadonlyArray<ServerProviderModel>;111 readonly models: ReadonlyArray<ServerProviderModel>;
111 /**112 /**
112 * The persisted custom-model slug list for this instance. Drives dedup,113 * The persisted custom-model list for this instance, resolved. Drives
113 * and is the array we hand back verbatim (with the new slug appended /114 * dedup, and is the list we hand back (with an entry appended / replaced /
114 * removed) via `onChange`.115 * removed) via `onChange`.
115 */116 */
116 readonly customModels: ReadonlyArray<string>;117 readonly customModels: ReadonlyArray<CustomModelDefinition>;
117 /** Server-returned model slugs hidden from the model picker. */118 /** Server-returned model slugs hidden from the model picker. */
118 readonly hiddenModels: ReadonlyArray<string>;119 readonly hiddenModels: ReadonlyArray<string>;
119 /** Model slugs favorited for this provider instance. */120 /** Model slugs favorited for this provider instance. */
...@@ -125,7 +126,7 @@ interface ProviderModelsSectionProps {...@@ -125,7 +126,7 @@ interface ProviderModelsSectionProps {
125 * write to the correct storage (legacy `settings.providers[kind]` vs.126 * write to the correct storage (legacy `settings.providers[kind]` vs.
126 * `providerInstances[id].config`).127 * `providerInstances[id].config`).
127 */128 */
128 readonly onChange: (next: ReadonlyArray<string>) => void;129 readonly onChange: (next: ReadonlyArray<CustomModelDefinition>) => void;
129 readonly onHiddenModelsChange: (next: ReadonlyArray<string>) => void;130 readonly onHiddenModelsChange: (next: ReadonlyArray<string>) => void;
130 readonly onFavoriteModelsChange: (next: ReadonlyArray<string>) => void;131 readonly onFavoriteModelsChange: (next: ReadonlyArray<string>) => void;
131 readonly onModelOrderChange: (next: ReadonlyArray<string>) => void;132 readonly onModelOrderChange: (next: ReadonlyArray<string>) => void;
...@@ -159,6 +160,8 @@ export function ProviderModelsSection({...@@ -159,6 +160,8 @@ export function ProviderModelsSection({
159 const [isAdding, setIsAdding] = useState(false);160 const [isAdding, setIsAdding] = useState(false);
160 const [filter, setFilter] = useState("");161 const [filter, setFilter] = useState("");
161 const [error, setError] = useState<string | null>(null);162 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);
162 const listRef = useRef<HTMLDivElement>(null);165 const listRef = useRef<HTMLDivElement>(null);
163 // Slug of a just-added custom model, scrolled into view once its row exists.166 // Slug of a just-added custom model, scrolled into view once its row exists.
164 const scrollToSlugRef = useRef<string | null>(null);167 const scrollToSlugRef = useRef<string | null>(null);
...@@ -177,6 +180,7 @@ export function ProviderModelsSection({...@@ -177,6 +180,7 @@ export function ProviderModelsSection({
177 const hiddenCount = displayModels.filter(180 const hiddenCount = displayModels.filter(
178 (model) => !model.isCustom && hiddenModelSet.has(model.slug),181 (model) => !model.isCustom && hiddenModelSet.has(model.slug),
179 ).length;182 ).length;
183 const builtInModels = useMemo(() => models.filter((model) => !model.isCustom), [models]);
180 const showFilter = models.length > FILTER_THRESHOLD;184 const showFilter = models.length > FILTER_THRESHOLD;
181 const normalizedFilter = filter.trim().toLowerCase();185 const normalizedFilter = filter.trim().toLowerCase();
182 const isFiltering = showFilter && normalizedFilter.length > 0;186 const isFiltering = showFilter && normalizedFilter.length > 0;
...@@ -216,7 +220,7 @@ export function ProviderModelsSection({...@@ -216,7 +220,7 @@ export function ProviderModelsSection({
216 setError(`Model slugs must be ${MAX_CUSTOM_MODEL_LENGTH} characters or less.`);220 setError(`Model slugs must be ${MAX_CUSTOM_MODEL_LENGTH} characters or less.`);
217 return;221 return;
218 }222 }
219 if (customModels.includes(normalized)) {223 if (customModels.some((entry) => entry.slug === normalized)) {
220 setError("That custom model is already saved.");224 setError("That custom model is already saved.");
221 return;225 return;
222 }226 }
...@@ -225,7 +229,7 @@ export function ProviderModelsSection({...@@ -225,7 +229,7 @@ export function ProviderModelsSection({
225 // which is also what lets the pending scroll target resolve and clear.229 // which is also what lets the pending scroll target resolve and clear.
226 scrollToSlugRef.current = normalized;230 scrollToSlugRef.current = normalized;
227 setFilter("");231 setFilter("");
228 onChange([...customModels, normalized]);232 onChange([...customModels, { slug: normalized, name: normalized, capabilities: null }]);
229 setInput("");233 setInput("");
230 setError(null);234 setError(null);
231 setIsAdding(false);235 setIsAdding(false);
...@@ -238,12 +242,18 @@ export function ProviderModelsSection({...@@ -238,12 +242,18 @@ export function ProviderModelsSection({
238 };242 };
239243
240 const handleRemove = (slug: string) => {244 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));
242 onModelOrderChange(modelOrder.filter((model) => model !== slug));247 onModelOrderChange(modelOrder.filter((model) => model !== slug));
243 onFavoriteModelsChange(favoriteModels.filter((model) => model !== slug));248 onFavoriteModelsChange(favoriteModels.filter((model) => model !== slug));
244 setError(null);249 setError(null);
245 };250 };
246251
252 const handleSaveEdit = (next: CustomModelDefinition) => {
253 onChange(customModels.map((entry) => (entry.slug === next.slug ? next : entry)));
254 setEditingSlug(null);
255 };
256
247 const setHidden = (slug: string, hidden: boolean) => {257 const setHidden = (slug: string, hidden: boolean) => {
248 if (hidden === hiddenModelSet.has(slug)) return;258 if (hidden === hiddenModelSet.has(slug)) return;
249 onHiddenModelsChange(259 onHiddenModelsChange(
...@@ -355,21 +365,40 @@ export function ProviderModelsSection({...@@ -355,21 +365,40 @@ export function ProviderModelsSection({
355 </>365 </>
356 ) : null}366 ) : null}
357 {model.isCustom ? (367 {model.isCustom ? (
358 <Tooltip>368 <>
359 <TooltipTrigger369 <Tooltip>
360 render={370 <TooltipTrigger
361 <Button371 render={
362 size="icon-micro"372 <Button
363 variant="ghost-muted"373 size="icon-micro"
364 aria-label={`Remove ${model.slug}`}374 variant="ghost-muted"
365 onClick={() => handleRemove(model.slug)}375 aria-label={`Edit ${model.slug}`}
366 />376 onClick={() =>
367 }377 setEditingSlug((current) => (current === model.slug ? null : model.slug))
368 >378 }
369 <XIcon className="size-3" />379 />
370 </TooltipTrigger>380 }
371 <TooltipPopup side="top">Remove custom model</TooltipPopup>381 >
372 </Tooltip>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 </>
373 ) : null}402 ) : null}
374 </span>403 </span>
375 );404 );
...@@ -420,20 +449,23 @@ export function ProviderModelsSection({...@@ -420,20 +449,23 @@ export function ProviderModelsSection({
420 key={`${instanceId}:${model.slug}`}449 key={`${instanceId}:${model.slug}`}
421 data-model-slug={model.slug}450 data-model-slug={model.slug}
422 className={cn(451 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",
424 isHidden && "opacity-50",455 isHidden && "opacity-50",
425 )}456 )}
426 >457 >
427 {starButton(model, isFavorite)}458 {starButton(model, isFavorite)}
428 <span className="flex min-w-0 items-baseline gap-2">459 <span className="flex min-w-0 items-baseline gap-2">
429 <span className={cn(nameClassName, "truncate")}>{model.name}</span>460 <span className={cn(nameClassName, "truncate")}>{model.name}</span>
430 {model.isCustom ? (461 {model.name !== model.slug ? (
431 <span className="text-[11px] text-muted-foreground/70">custom</span>
432 ) : model.name !== model.slug ? (
433 <code className="truncate font-mono text-[11px] text-muted-foreground/70">462 <code className="truncate font-mono text-[11px] text-muted-foreground/70">
434 {model.slug}463 {model.slug}
435 </code>464 </code>
436 ) : null}465 ) : null}
466 {model.isCustom ? (
467 <span className="text-[11px] text-muted-foreground/70">custom</span>
468 ) : null}
437 </span>469 </span>
438 {/*470 {/*
439 Always a grid item so the columns line up across rows; the text471 Always a grid item so the columns line up across rows; the text
...@@ -489,6 +521,10 @@ export function ProviderModelsSection({...@@ -489,6 +521,10 @@ export function ProviderModelsSection({
489 const group = groupOf(model);521 const group = groupOf(model);
490 const previous = visibleModels[index - 1];522 const previous = visibleModels[index - 1];
491 const startsGroup = previous === undefined || groupOf(previous) !== group;523 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;
492 return (528 return (
493 <div key={`${instanceId}:${model.slug}:group`}>529 <div key={`${instanceId}:${model.slug}:group`}>
494 {startsGroup && favoriteCount > 0 && group === "favorite"530 {startsGroup && favoriteCount > 0 && group === "favorite"
...@@ -501,6 +537,17 @@ export function ProviderModelsSection({...@@ -501,6 +537,17 @@ export function ProviderModelsSection({
501 ? groupLabel("Hidden from picker", index === 0)537 ? groupLabel("Hidden from picker", index === 0)
502 : null}538 : null}
503 {renderRow(model)}539 {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}
504 </div>551 </div>
505 );552 );
506 })}553 })}
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 {...@@ -10,8 +10,10 @@ import {
10 type ServerSettingsPatch,10 type ServerSettingsPatch,
11} from "@t3tools/contracts";11} from "@t3tools/contracts";
12import {12import {
13 type CustomModelDefinition,
13 createModelSelection,14 createModelSelection,
14 normalizeCustomModelSlug,15 normalizeCustomModelSlug,
16 readCustomModelEntries,
15 resolveSelectableModel,17 resolveSelectableModel,
16} from "@t3tools/shared/model";18} from "@t3tools/shared/model";
17import { getComposerProviderState } from "./components/chat/composerProviderState";19import { getComposerProviderState } from "./components/chat/composerProviderState";
...@@ -55,14 +57,14 @@ function readInstanceCustomModels(...@@ -55,14 +57,14 @@ function readInstanceCustomModels(
55 settings: UnifiedSettings,57 settings: UnifiedSettings,
56 instanceId: ProviderInstanceId,58 instanceId: ProviderInstanceId,
57 driverKind: ProviderDriverKind,59 driverKind: ProviderDriverKind,
58): ReadonlyArray<string> {60): ReadonlyArray<CustomModelDefinition> {
59 if (driverKind === "antigravity") return [];61 if (driverKind === "antigravity") return [];
60 const instance = settings.providerInstances?.[instanceId];62 const instance = settings.providerInstances?.[instanceId];
61 const config = instance?.config;63 const config = instance?.config;
62 if (config !== null && typeof config === "object") {64 if (config !== null && typeof config === "object") {
63 const value = (config as Record<string, unknown>).customModels;65 const value = (config as Record<string, unknown>).customModels;
64 if (Array.isArray(value)) {66 if (Array.isArray(value)) {
65 return value.filter((entry): entry is string => typeof entry === "string");67 return readCustomModelEntries(value);
66 }68 }
67 }69 }
68 const defaultInstanceId = defaultInstanceIdForDriver(driverKind);70 const defaultInstanceId = defaultInstanceIdForDriver(driverKind);
...@@ -71,9 +73,9 @@ function readInstanceCustomModels(...@@ -71,9 +73,9 @@ function readInstanceCustomModels(
71 }73 }
72 const legacyProviders = settings.providers as Record<74 const legacyProviders = settings.providers as Record<
73 string,75 string,
74 { readonly customModels: ReadonlyArray<string> } | undefined76 { readonly customModels: ReadonlyArray<unknown> } | undefined
75 >;77 >;
76 return legacyProviders[driverKind]?.customModels ?? [];78 return readCustomModelEntries(legacyProviders[driverKind]?.customModels ?? []);
77}79}
7880
79export interface AppModelOption {81export interface AppModelOption {
...@@ -151,26 +153,24 @@ function applyInstanceModelPreferences(...@@ -151,26 +153,24 @@ function applyInstanceModelPreferences(
151 );153 );
152}154}
153155
154export function normalizeCustomModelSlugs(156export function normalizeCustomModelEntries(
155 models: Iterable<string | null | undefined>,157 models: ReadonlyArray<CustomModelDefinition>,
156 builtInModelSlugs: ReadonlySet<string>,158 builtInModelSlugs: ReadonlySet<string>,
157): string[] {159): CustomModelDefinition[] {
158 const normalizedModels: string[] = [];160 const normalizedModels: CustomModelDefinition[] = [];
159 const seen = new Set<string>();161 const seen = new Set<string>();
160162
161 for (const candidate of models) {163 for (const candidate of models) {
162 const normalized = normalizeCustomModelSlug(candidate);
163 if (164 if (
164 !normalized ||165 candidate.slug.length > MAX_CUSTOM_MODEL_LENGTH ||
165 normalized.length > MAX_CUSTOM_MODEL_LENGTH ||166 builtInModelSlugs.has(candidate.slug) ||
166 builtInModelSlugs.has(normalized) ||167 seen.has(candidate.slug)
167 seen.has(normalized)
168 ) {168 ) {
169 continue;169 continue;
170 }170 }
171171
172 seen.add(normalized);172 seen.add(candidate.slug);
173 normalizedModels.push(normalized);173 normalizedModels.push(candidate);
174 if (normalizedModels.length >= MAX_CUSTOM_MODEL_COUNT) {174 if (normalizedModels.length >= MAX_CUSTOM_MODEL_COUNT) {
175 break;175 break;
176 }176 }
...@@ -205,17 +205,13 @@ export function getAppModelOptions(...@@ -205,17 +205,13 @@ export function getAppModelOptions(
205 // see the user's authored custom models.205 // see the user's authored custom models.
206 const defaultInstanceId = defaultInstanceIdForDriver(provider);206 const defaultInstanceId = defaultInstanceIdForDriver(provider);
207 const customModels = readInstanceCustomModels(settings, defaultInstanceId, provider);207 const customModels = readInstanceCustomModels(settings, defaultInstanceId, provider);
208 for (const slug of normalizeCustomModelSlugs(customModels, builtInModelSlugs)) {208 for (const entry of normalizeCustomModelEntries(customModels, builtInModelSlugs)) {
209 if (seen.has(slug)) {209 if (seen.has(entry.slug)) {
210 continue;210 continue;
211 }211 }
212212
213 seen.add(slug);213 seen.add(entry.slug);
214 options.push({214 options.push({ slug: entry.slug, name: entry.name, isCustom: true });
215 slug,
216 name: slug,
217 isCustom: true,
218 });
219 }215 }
220216
221 const preferences = readInstanceModelPreferences(settings, defaultInstanceId);217 const preferences = readInstanceModelPreferences(settings, defaultInstanceId);
...@@ -257,13 +253,13 @@ export function getAppModelOptionsForInstance(...@@ -257,13 +253,13 @@ export function getAppModelOptionsForInstance(
257 );253 );
258254
259 const customModels = readInstanceCustomModels(settings, entry.instanceId, entry.driverKind);255 const customModels = readInstanceCustomModels(settings, entry.instanceId, entry.driverKind);
260 for (const slug of normalizeCustomModelSlugs(customModels, builtInModelSlugs)) {256 for (const custom of normalizeCustomModelEntries(customModels, builtInModelSlugs)) {
261 if (seen.has(slug)) {257 if (seen.has(custom.slug)) {
262 continue;258 continue;
263 }259 }
264260
265 seen.add(slug);261 seen.add(custom.slug);
266 options.push({ slug, name: slug, isCustom: true });262 options.push({ slug: custom.slug, name: custom.name, isCustom: true });
267 }263 }
268264
269 const preferences = readInstanceModelPreferences(settings, entry.instanceId);265 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...@@ -30,6 +30,12 @@ messages while disconnected. Uploads resume when you reconnect. Drafts and queue
30messages survive app restarts. Signing out of T3 Connect keeps that work on your30messages survive app restarts. Signing out of T3 Connect keeps that work on your
31device until you sign back into the same account.31device 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
33## Model defaults39## Model defaults
3440
35T3 Code remembers your provider, model, and model options for new threads. A41T3 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({...@@ -127,6 +127,22 @@ export const ModelCapabilities = Schema.Struct({
127});127});
128export type ModelCapabilities = typeof ModelCapabilities.Type;128export 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
130const CODEX_DRIVER_KIND = ProviderDriverKind.make("codex");146const CODEX_DRIVER_KIND = ProviderDriverKind.make("codex");
131const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent");147const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent");
132const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor");148const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor");
packages/contracts/src/settings.test.ts+34
...@@ -73,6 +73,40 @@ describe("ServerSettings usage price overrides", () => {...@@ -73,6 +73,40 @@ describe("ServerSettings usage price overrides", () => {
73 });73 });
74});74});
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
76describe("ClaudeSettings auto-compaction", () => {110describe("ClaudeSettings auto-compaction", () => {
77 it("uses Claude's default threshold when no override is configured", () => {111 it("uses Claude's default threshold when no override is configured", () => {
78 expect(decodeClaudeSettings({}).autoCompactWindow).toBe("");112 expect(decodeClaudeSettings({}).autoCompactWindow).toBe("");
packages/contracts/src/settings.ts+13-12
...@@ -6,6 +6,7 @@ import { ForwardCompatibleNullable, TrimmedNonEmptyString, TrimmedString } from...@@ -6,6 +6,7 @@ import { ForwardCompatibleNullable, TrimmedNonEmptyString, TrimmedString } from
6import { UsageLimitSourceId } from "./usageLimitSourceId.ts";6import { UsageLimitSourceId } from "./usageLimitSourceId.ts";
7import { EnvironmentMachineKind, ThreadEnvMode } from "./environment.ts";7import { EnvironmentMachineKind, ThreadEnvMode } from "./environment.ts";
8import {8import {
9 CustomModelSetting,
9 DEFAULT_TEXT_GENERATION_MODEL,10 DEFAULT_TEXT_GENERATION_MODEL,
10 DEFAULT_TEXT_GENERATION_REASONING_EFFORT,11 DEFAULT_TEXT_GENERATION_REASONING_EFFORT,
11 ProviderOptionSelections,12 ProviderOptionSelections,
...@@ -472,7 +473,7 @@ export const CodexSettings = makeProviderSettingsSchema(...@@ -472,7 +473,7 @@ export const CodexSettings = makeProviderSettingsSchema(
472 description: "Additional CLI arguments passed to codex app-server on session start.",473 description: "Additional CLI arguments passed to codex app-server on session start.",
473 }),474 }),
474 ),475 ),
475 customModels: Schema.Array(Schema.String).pipe(476 customModels: Schema.Array(CustomModelSetting).pipe(
476 Schema.withDecodingDefault(Effect.succeed([])),477 Schema.withDecodingDefault(Effect.succeed([])),
477 Schema.annotateKey({ providerSettingsForm: { hidden: true } }),478 Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
478 ),479 ),
...@@ -510,7 +511,7 @@ export const ClaudeSettings = makeProviderSettingsSchema(...@@ -510,7 +511,7 @@ export const ClaudeSettings = makeProviderSettingsSchema(
510 providerSettingsForm: { placeholder: "~/.claude", clearWhenEmpty: "omit" },511 providerSettingsForm: { placeholder: "~/.claude", clearWhenEmpty: "omit" },
511 }),512 }),
512 ),513 ),
513 customModels: Schema.Array(Schema.String).pipe(514 customModels: Schema.Array(CustomModelSetting).pipe(
514 Schema.withDecodingDefault(Effect.succeed([])),515 Schema.withDecodingDefault(Effect.succeed([])),
515 Schema.annotateKey({ providerSettingsForm: { hidden: true } }),516 Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
516 ),517 ),
...@@ -571,7 +572,7 @@ export const CursorSettings = makeProviderSettingsSchema(...@@ -571,7 +572,7 @@ export const CursorSettings = makeProviderSettingsSchema(
571 },572 },
572 }),573 }),
573 ),574 ),
574 customModels: Schema.Array(Schema.String).pipe(575 customModels: Schema.Array(CustomModelSetting).pipe(
575 Schema.withDecodingDefault(Effect.succeed([])),576 Schema.withDecodingDefault(Effect.succeed([])),
576 Schema.annotateKey({ providerSettingsForm: { hidden: true } }),577 Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
577 ),578 ),
...@@ -597,7 +598,7 @@ export const GrokSettings = makeProviderSettingsSchema(...@@ -597,7 +598,7 @@ export const GrokSettings = makeProviderSettingsSchema(
597 providerSettingsForm: { placeholder: "grok", clearWhenEmpty: "omit" },598 providerSettingsForm: { placeholder: "grok", clearWhenEmpty: "omit" },
598 }),599 }),
599 ),600 ),
600 customModels: Schema.Array(Schema.String).pipe(601 customModels: Schema.Array(CustomModelSetting).pipe(
601 Schema.withDecodingDefault(Effect.succeed([])),602 Schema.withDecodingDefault(Effect.succeed([])),
602 Schema.annotateKey({ providerSettingsForm: { hidden: true } }),603 Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
603 ),604 ),
...@@ -682,7 +683,7 @@ export const AntigravitySettings = makeProviderSettingsSchema(...@@ -682,7 +683,7 @@ export const AntigravitySettings = makeProviderSettingsSchema(
682 providerSettingsForm: { placeholder: "Automatic", clearWhenEmpty: "persist" },683 providerSettingsForm: { placeholder: "Automatic", clearWhenEmpty: "persist" },
683 }),684 }),
684 ),685 ),
685 customModels: Schema.Array(Schema.String).pipe(686 customModels: Schema.Array(CustomModelSetting).pipe(
686 Schema.withDecodingDefault(Effect.succeed([])),687 Schema.withDecodingDefault(Effect.succeed([])),
687 Schema.annotateKey({ providerSettingsForm: { hidden: true } }),688 Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
688 ),689 ),
...@@ -732,7 +733,7 @@ export const OpenCodeSettings = makeProviderSettingsSchema(...@@ -732,7 +733,7 @@ export const OpenCodeSettings = makeProviderSettingsSchema(
732 },733 },
733 }),734 }),
734 ),735 ),
735 customModels: Schema.Array(Schema.String).pipe(736 customModels: Schema.Array(CustomModelSetting).pipe(
736 Schema.withDecodingDefault(Effect.succeed([])),737 Schema.withDecodingDefault(Effect.succeed([])),
737 Schema.annotateKey({ providerSettingsForm: { hidden: true } }),738 Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
738 ),739 ),
...@@ -1055,14 +1056,14 @@ const CodexSettingsPatch = Schema.Struct({...@@ -1055,14 +1056,14 @@ const CodexSettingsPatch = Schema.Struct({
1055 homePath: Schema.optionalKey(TrimmedString),1056 homePath: Schema.optionalKey(TrimmedString),
1056 shadowHomePath: Schema.optionalKey(TrimmedString),1057 shadowHomePath: Schema.optionalKey(TrimmedString),
1057 launchArgs: Schema.optionalKey(TrimmedString),1058 launchArgs: Schema.optionalKey(TrimmedString),
1058 customModels: Schema.optionalKey(Schema.Array(Schema.String)),1059 customModels: Schema.optionalKey(Schema.Array(CustomModelSetting)),
1059});1060});
10601061
1061const ClaudeSettingsPatch = Schema.Struct({1062const ClaudeSettingsPatch = Schema.Struct({
1062 enabled: Schema.optionalKey(Schema.Boolean),1063 enabled: Schema.optionalKey(Schema.Boolean),
1063 binaryPath: Schema.optionalKey(TrimmedString),1064 binaryPath: Schema.optionalKey(TrimmedString),
1064 homePath: Schema.optionalKey(TrimmedString),1065 homePath: Schema.optionalKey(TrimmedString),
1065 customModels: Schema.optionalKey(Schema.Array(Schema.String)),1066 customModels: Schema.optionalKey(Schema.Array(CustomModelSetting)),
1066 launchArgs: Schema.optionalKey(TrimmedString),1067 launchArgs: Schema.optionalKey(TrimmedString),
1067 // Validated at the patch boundary so a typo fails the one update with a1068 // Validated at the patch boundary so a typo fails the one update with a
1068 // schema error instead of a generic whole-settings failure.1069 // schema error instead of a generic whole-settings failure.
...@@ -1075,13 +1076,13 @@ const CursorSettingsPatch = Schema.Struct({...@@ -1075,13 +1076,13 @@ const CursorSettingsPatch = Schema.Struct({
1075 enabled: Schema.optionalKey(Schema.Boolean),1076 enabled: Schema.optionalKey(Schema.Boolean),
1076 binaryPath: Schema.optionalKey(TrimmedString),1077 binaryPath: Schema.optionalKey(TrimmedString),
1077 apiEndpoint: Schema.optionalKey(TrimmedString),1078 apiEndpoint: Schema.optionalKey(TrimmedString),
1078 customModels: Schema.optionalKey(Schema.Array(Schema.String)),1079 customModels: Schema.optionalKey(Schema.Array(CustomModelSetting)),
1079});1080});
10801081
1081const GrokSettingsPatch = Schema.Struct({1082const GrokSettingsPatch = Schema.Struct({
1082 enabled: Schema.optionalKey(Schema.Boolean),1083 enabled: Schema.optionalKey(Schema.Boolean),
1083 binaryPath: Schema.optionalKey(TrimmedString),1084 binaryPath: Schema.optionalKey(TrimmedString),
1084 customModels: Schema.optionalKey(Schema.Array(Schema.String)),1085 customModels: Schema.optionalKey(Schema.Array(CustomModelSetting)),
1085});1086});
10861087
1087const AntigravitySettingsPatch = Schema.Struct({1088const AntigravitySettingsPatch = Schema.Struct({
...@@ -1091,7 +1092,7 @@ const AntigravitySettingsPatch = Schema.Struct({...@@ -1091,7 +1092,7 @@ const AntigravitySettingsPatch = Schema.Struct({
1091 gcpProject: Schema.optionalKey(TrimmedString),1092 gcpProject: Schema.optionalKey(TrimmedString),
1092 gcpLocation: Schema.optionalKey(TrimmedString),1093 gcpLocation: Schema.optionalKey(TrimmedString),
1093 binaryPath: Schema.optionalKey(TrimmedString),1094 binaryPath: Schema.optionalKey(TrimmedString),
1094 customModels: Schema.optionalKey(Schema.Array(Schema.String)),1095 customModels: Schema.optionalKey(Schema.Array(CustomModelSetting)),
1095});1096});
10961097
1097const OpenCodeSettingsPatch = Schema.Struct({1098const OpenCodeSettingsPatch = Schema.Struct({
...@@ -1099,7 +1100,7 @@ const OpenCodeSettingsPatch = Schema.Struct({...@@ -1099,7 +1100,7 @@ const OpenCodeSettingsPatch = Schema.Struct({
1099 binaryPath: Schema.optionalKey(TrimmedString),1100 binaryPath: Schema.optionalKey(TrimmedString),
1100 serverUrl: Schema.optionalKey(TrimmedString),1101 serverUrl: Schema.optionalKey(TrimmedString),
1101 serverPassword: Schema.optionalKey(TrimmedString),1102 serverPassword: Schema.optionalKey(TrimmedString),
1102 customModels: Schema.optionalKey(Schema.Array(Schema.String)),1103 customModels: Schema.optionalKey(Schema.Array(CustomModelSetting)),
1103});1104});
11041105
1105export const ServerSettingsPatch = Schema.Struct({1106export const ServerSettingsPatch = Schema.Struct({
packages/shared/src/model.test.ts+52
...@@ -10,6 +10,8 @@ import {...@@ -10,6 +10,8 @@ import {
10 getModelSelectionBooleanOptionValue,10 getModelSelectionBooleanOptionValue,
11 getModelSelectionStringOptionValue,11 getModelSelectionStringOptionValue,
12 getProviderOptionDescriptors,12 getProviderOptionDescriptors,
13 readCustomModelEntries,
14 toCustomModelSetting,
13 getProviderOptionBooleanSelectionValue,15 getProviderOptionBooleanSelectionValue,
14 getProviderOptionStringSelectionValue,16 getProviderOptionStringSelectionValue,
15} from "./model.ts";17} from "./model.ts";
...@@ -192,3 +194,53 @@ describe("applyClaudePromptEffortPrefix", () => {...@@ -192,3 +194,53 @@ describe("applyClaudePromptEffortPrefix", () => {
192 );194 );
193 });195 });
194});196});
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 @@...@@ -1,12 +1,15 @@
1import {1import {
2 type CustomModelSetting,
2 MODEL_SLUG_ALIASES_BY_PROVIDER,3 MODEL_SLUG_ALIASES_BY_PROVIDER,
3 type ModelCapabilities,4 ModelCapabilities,
4 type ModelSelection,5 type ModelSelection,
5 ProviderDriverKind,6 ProviderDriverKind,
6 ProviderInstanceId,7 ProviderInstanceId,
7 type ProviderOptionDescriptor,8 type ProviderOptionDescriptor,
8 type ProviderOptionSelection,9 type ProviderOptionSelection,
9} from "@t3tools/contracts";10} from "@t3tools/contracts";
11import * as Option from "effect/Option";
12import * as Schema from "effect/Schema";
1013
11const DEFAULT_PROVIDER_DRIVER_KIND = ProviderDriverKind.make("codex");14const DEFAULT_PROVIDER_DRIVER_KIND = ProviderDriverKind.make("codex");
1215
...@@ -247,6 +250,76 @@ export function normalizeCustomModelSlug(model: string | null | undefined): stri...@@ -247,6 +250,76 @@ export function normalizeCustomModelSlug(model: string | null | undefined): stri
247 return model.trim() || null;250 return model.trim() || null;
248}251}
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
250export function resolveSelectableModel(323export function resolveSelectableModel(
251 provider: ProviderDriverKind,324 provider: ProviderDriverKind,
252 value: string | null | undefined,325 value: string | null | undefined,