authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-02-28 14:42:10-08:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-02-28 14:42:10-08:00
log56de1465fa81eadc3c09140f7fff1579299e3bad
tree49e6400ae882df5ba0b2b1466890721ba1ba5446
parentb500106fde0f90344174cf113d439777c6f3b266
signature Signed by PGP key B5690EEEBB952194

feat: custom models (#121)

* custom models * better fake version

6 files changed, 311 insertions(+), 44 deletions(-)

apps/web/src/appSettings.test.ts created+42
......@@ -0,0 +1,42 @@
1import { describe, expect, it } from "vitest";
2
3import { getAppModelOptions, normalizeCustomModelSlugs } from "./appSettings";
4
5describe("normalizeCustomModelSlugs", () => {
6 it("normalizes aliases, removes built-ins, and deduplicates values", () => {
7 expect(
8 normalizeCustomModelSlugs([
9 " custom/internal-model ",
10 "gpt-5.3-codex",
11 "5.3",
12 "custom/internal-model",
13 "",
14 null,
15 ]),
16 ).toEqual(["custom/internal-model"]);
17 });
18});
19
20describe("getAppModelOptions", () => {
21 it("appends saved custom models after the built-in options", () => {
22 const options = getAppModelOptions(["custom/internal-model"]);
23
24 expect(options.map((option) => option.slug)).toEqual([
25 "gpt-5.3-codex",
26 "gpt-5.3-codex-spark",
27 "gpt-5.2-codex",
28 "gpt-5.2",
29 "custom/internal-model",
30 ]);
31 });
32
33 it("keeps the currently selected custom model available even if it is no longer saved", () => {
34 const options = getAppModelOptions([], "custom/selected-model");
35
36 expect(options.at(-1)).toEqual({
37 slug: "custom/selected-model",
38 name: "custom/selected-model",
39 isCustom: true,
40 });
41 });
42});
apps/web/src/appSettings.ts+89-5
......@@ -1,7 +1,11 @@
11import { useCallback, useSyncExternalStore } from "react";
22import { Option, Schema } from "effect";
3import { MODEL_OPTIONS, normalizeModelSlug } from "@t3tools/contracts";
34
45const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1";
6const MAX_CUSTOM_MODEL_COUNT = 32;
7export const MAX_CUSTOM_MODEL_LENGTH = 256;
8const BUILT_IN_MODEL_SLUGS = new Set<string>(MODEL_OPTIONS.map((option) => option.slug));
59
610const AppSettingsSchema = Schema.Struct({
711 codexBinaryPath: Schema.String.check(Schema.isMaxLength(4096)).pipe(
......@@ -14,8 +18,16 @@ const AppSettingsSchema = Schema.Struct({
1418 enableAssistantStreaming: Schema.Boolean.pipe(
1519 Schema.withConstructorDefault(() => Option.some(false)),
1620 ),
21 customCodexModels: Schema.Array(Schema.String).pipe(
22 Schema.withConstructorDefault(() => Option.some([])),
23 ),
1724});
1825export type AppSettings = typeof AppSettingsSchema.Type;
26export interface AppModelOption {
27 slug: string;
28 name: string;
29 isCustom: boolean;
30}
1931
2032const DEFAULT_APP_SETTINGS = AppSettingsSchema.makeUnsafe({});
2133
......@@ -23,6 +35,76 @@ let listeners: Array<() => void> = [];
2335let cachedRawSettings: string | null | undefined;
2436let cachedSnapshot: AppSettings = DEFAULT_APP_SETTINGS;
2537
38export function normalizeCustomModelSlugs(
39 models: Iterable<string | null | undefined>,
40): AppSettings["customCodexModels"] {
41 const normalizedModels: string[] = [];
42 const seen = new Set<string>();
43
44 for (const candidate of models) {
45 const normalized = normalizeModelSlug(candidate);
46 if (
47 !normalized ||
48 normalized.length > MAX_CUSTOM_MODEL_LENGTH ||
49 BUILT_IN_MODEL_SLUGS.has(normalized) ||
50 seen.has(normalized)
51 ) {
52 continue;
53 }
54
55 seen.add(normalized);
56 normalizedModels.push(normalized);
57 if (normalizedModels.length >= MAX_CUSTOM_MODEL_COUNT) {
58 break;
59 }
60 }
61
62 return normalizedModels;
63}
64
65function normalizeAppSettings(settings: AppSettings): AppSettings {
66 return {
67 ...settings,
68 customCodexModels: normalizeCustomModelSlugs(settings.customCodexModels),
69 };
70}
71
72export function getAppModelOptions(
73 customModels: readonly string[],
74 selectedModel?: string | null,
75): AppModelOption[] {
76 const options: AppModelOption[] = MODEL_OPTIONS.map(({ slug, name }) => ({
77 slug,
78 name,
79 isCustom: false,
80 }));
81 const seen = new Set(options.map((option) => option.slug));
82
83 for (const slug of normalizeCustomModelSlugs(customModels)) {
84 if (seen.has(slug)) {
85 continue;
86 }
87
88 seen.add(slug);
89 options.push({
90 slug,
91 name: slug,
92 isCustom: true,
93 });
94 }
95
96 const normalizedSelectedModel = normalizeModelSlug(selectedModel);
97 if (normalizedSelectedModel && !seen.has(normalizedSelectedModel)) {
98 options.push({
99 slug: normalizedSelectedModel,
100 name: normalizedSelectedModel,
101 isCustom: true,
102 });
103 }
104
105 return options;
106}
107
26108function emitChange(): void {
27109 for (const listener of listeners) {
28110 listener();
......@@ -35,7 +117,7 @@ function parsePersistedSettings(value: string | null): AppSettings {
35117 }
36118
37119 try {
38 return Schema.decodeSync(Schema.fromJsonString(AppSettingsSchema))(value);
120 return normalizeAppSettings(Schema.decodeSync(Schema.fromJsonString(AppSettingsSchema))(value));
39121 } catch {
40122 return DEFAULT_APP_SETTINGS;
41123 }
......@@ -96,10 +178,12 @@ export function useAppSettings() {
96178 );
97179
98180 const updateSettings = useCallback((patch: Partial<AppSettings>) => {
99 const next = Schema.decodeSync(AppSettingsSchema)({
100 ...getAppSettingsSnapshot(),
101 ...patch,
102 });
181 const next = normalizeAppSettings(
182 Schema.decodeSync(AppSettingsSchema)({
183 ...getAppSettingsSnapshot(),
184 ...patch,
185 }),
186 );
103187 persistSettings(next);
104188 emitChange();
105189 }, []);
apps/web/src/components/ChatView.tsx+37-28
......@@ -6,11 +6,9 @@ import {
66 type EditorId,
77 type KeybindingCommand,
88 type MessageId,
9 MODEL_OPTIONS,
109 type ProjectId,
1110 type ProjectEntry,
1211 type ProjectScript,
13 ModelSlug,
1412 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
1513 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
1614 REASONING_OPTIONS,
......@@ -112,7 +110,7 @@ import { Toggle } from "./ui/toggle";
112110import { SidebarTrigger } from "./ui/sidebar";
113111import { newCommandId, newMessageId } from "~/lib/utils";
114112import { readNativeApi } from "~/nativeApi";
115import { useAppSettings } from "../appSettings";
113import { getAppModelOptions, useAppSettings } from "../appSettings";
116114import { clamp } from "effect/Number";
117115
118116function formatMessageMeta(createdAt: string, duration: string | null): string {
......@@ -129,12 +127,6 @@ const IMAGE_ONLY_BOOTSTRAP_PROMPT =
129127const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];
130128const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = [];
131129const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120;
132const SEARCHABLE_MODEL_OPTIONS = MODEL_OPTIONS.map(({ slug, name }) => ({
133 slug,
134 name,
135 searchSlug: slug.toLowerCase(),
136 searchName: name.toLowerCase(),
137}));
138130const SCRIPT_TERMINAL_COLS = 120;
139131const SCRIPT_TERMINAL_ROWS = 30;
140132
......@@ -216,7 +208,7 @@ type ComposerCommandItem =
216208 | {
217209 id: string;
218210 type: "model";
219 model: ModelSlug;
211 model: string;
220212 label: string;
221213 description: string;
222214 };
......@@ -438,6 +430,10 @@ export default function ChatView({ threadId }: ChatViewProps) {
438430 const selectedModel = resolveModelSlug(
439431 activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL,
440432 );
433 const modelOptions = useMemo(
434 () => getAppModelOptions(settings.customCodexModels, selectedModel),
435 [selectedModel, settings.customCodexModels],
436 );
441437 const phase = derivePhase(activeThread?.session ?? null);
442438 const isSendBusy = sendPhase !== "idle";
443439 const isPreparingWorktree = sendPhase === "preparing-worktree";
......@@ -614,18 +610,26 @@ export default function ChatView({ threadId }: ChatViewProps) {
614610 ];
615611 }
616612
617 return SEARCHABLE_MODEL_OPTIONS.filter(({ searchSlug, searchName }) => {
618 const query = composerTrigger.query.trim().toLowerCase();
619 if (!query) return true;
620 return searchSlug.includes(query) || searchName.includes(query);
621 }).map(({ slug, name }) => ({
622 id: `model:${slug}`,
623 type: "model",
624 model: slug,
625 label: name,
626 description: slug,
627 }));
628 }, [composerTrigger, workspaceEntries]);
613 return modelOptions
614 .map(({ slug, name }) => ({
615 slug,
616 name,
617 searchSlug: slug.toLowerCase(),
618 searchName: name.toLowerCase(),
619 }))
620 .filter(({ searchSlug, searchName }) => {
621 const query = composerTrigger.query.trim().toLowerCase();
622 if (!query) return true;
623 return searchSlug.includes(query) || searchName.includes(query);
624 })
625 .map(({ slug, name }) => ({
626 id: `model:${slug}`,
627 type: "model" as const,
628 model: slug,
629 label: name,
630 description: slug,
631 }));
632 }, [composerTrigger, modelOptions, workspaceEntries]);
629633 const composerMenuOpen = Boolean(composerTrigger);
630634 const activeComposerMenuItem = useMemo(
631635 () =>
......@@ -1660,7 +1664,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
16601664 );
16611665
16621666 const onModelSelect = useCallback(
1663 (model: ModelSlug) => {
1667 (model: string) => {
16641668 const api = readNativeApi();
16651669 if (!activeThread) return;
16661670 if (api) {
......@@ -2012,7 +2016,11 @@ export default function ChatView({ threadId }: ChatViewProps) {
20122016 <div className="flex flex-wrap items-center justify-between gap-2 px-2.5 pb-2.5 sm:flex-nowrap sm:gap-0 sm:px-3 sm:pb-3">
20132017 <div className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden sm:min-w-max sm:overflow-visible">
20142018 {/* Model picker */}
2015 <ModelPicker model={selectedModel} onModelChange={onModelSelect} />
2019 <ModelPicker
2020 model={selectedModel}
2021 options={modelOptions}
2022 onModelChange={onModelSelect}
2023 />
20162024
20172025 {/* Divider */}
20182026 <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" />
......@@ -2912,12 +2920,13 @@ const MessagesTimeline = memo(function MessagesTimeline({
29122920});
29132921
29142922const ModelPicker = memo(function ModelPicker(props: {
2915 model: ModelSlug;
2916 onModelChange: (model: ModelSlug) => void;
2923 model: string;
2924 options: ReadonlyArray<{ slug: string; name: string }>;
2925 onModelChange: (model: string) => void;
29172926}) {
29182927 return (
29192928 <Select
2920 items={MODEL_OPTIONS.map((option) => ({ label: option.name, value: option.slug }))}
2929 items={props.options.map((option) => ({ label: option.name, value: option.slug }))}
29212930 value={props.model}
29222931 onValueChange={(value) => (value ? props.onModelChange(value) : undefined)}
29232932 >
......@@ -2925,7 +2934,7 @@ const ModelPicker = memo(function ModelPicker(props: {
29252934 <SelectValue />
29262935 </SelectTrigger>
29272936 <SelectPopup alignItemWithTrigger={false}>
2928 {MODEL_OPTIONS.map(({ slug, name }) => (
2937 {props.options.map(({ slug, name }) => (
29292938 <SelectItem key={slug} value={slug}>
29302939 {name}
29312940 </SelectItem>
apps/web/src/routes/_chat.settings.tsx+131-1
......@@ -1,8 +1,9 @@
11import { createFileRoute } from "@tanstack/react-router";
22import { useQuery } from "@tanstack/react-query";
33import { useCallback, useState } from "react";
4import { MODEL_OPTIONS, normalizeModelSlug } from "@t3tools/contracts";
45
5import { useAppSettings } from "../appSettings";
6import { MAX_CUSTOM_MODEL_LENGTH, useAppSettings } from "../appSettings";
67import { isElectron } from "../env";
78import { useTheme } from "../hooks/useTheme";
89import { serverConfigQueryOptions } from "../lib/serverReactQuery";
......@@ -52,9 +53,12 @@ function SettingsRouteView() {
5253 const serverConfigQuery = useQuery(serverConfigQueryOptions());
5354 const [isOpeningKeybindings, setIsOpeningKeybindings] = useState(false);
5455 const [openKeybindingsError, setOpenKeybindingsError] = useState<string | null>(null);
56 const [customModelInput, setCustomModelInput] = useState("");
57 const [customModelError, setCustomModelError] = useState<string | null>(null);
5558
5659 const codexBinaryPath = settings.codexBinaryPath;
5760 const codexHomePath = settings.codexHomePath;
61 const customCodexModels = settings.customCodexModels;
5862 const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null;
5963
6064 const openKeybindingsFile = useCallback(() => {
......@@ -74,6 +78,42 @@ function SettingsRouteView() {
7478 });
7579 }, [keybindingsConfigPath]);
7680
81 const addCustomModel = useCallback(() => {
82 const normalized = normalizeModelSlug(customModelInput);
83 if (!normalized) {
84 setCustomModelError("Enter a model slug.");
85 return;
86 }
87 if (MODEL_OPTIONS.some((option) => option.slug === normalized)) {
88 setCustomModelError("That model is already built in.");
89 return;
90 }
91 if (normalized.length > MAX_CUSTOM_MODEL_LENGTH) {
92 setCustomModelError(`Model slugs must be ${MAX_CUSTOM_MODEL_LENGTH} characters or less.`);
93 return;
94 }
95 if (customCodexModels.includes(normalized)) {
96 setCustomModelError("That custom model is already saved.");
97 return;
98 }
99
100 updateSettings({
101 customCodexModels: [...customCodexModels, normalized],
102 });
103 setCustomModelInput("");
104 setCustomModelError(null);
105 }, [customCodexModels, customModelInput, updateSettings]);
106
107 const removeCustomModel = useCallback(
108 (slug: string) => {
109 updateSettings({
110 customCodexModels: customCodexModels.filter((model) => model !== slug),
111 });
112 setCustomModelError(null);
113 },
114 [customCodexModels, updateSettings],
115 );
116
77117 return (
78118 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground isolate">
79119 <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-foreground">
......@@ -193,6 +233,96 @@ function SettingsRouteView() {
193233 </div>
194234 </section>
195235
236 <section className="rounded-2xl border border-border bg-card p-5">
237 <div className="mb-4">
238 <h2 className="text-sm font-medium text-foreground">Models</h2>
239 <p className="mt-1 text-xs text-muted-foreground">
240 Save additional Codex model slugs so they appear in the chat model picker.
241 </p>
242 </div>
243
244 <div className="space-y-4">
245 <form
246 className="flex flex-col gap-3 sm:flex-row sm:items-start"
247 onSubmit={(event) => {
248 event.preventDefault();
249 addCustomModel();
250 }}
251 >
252 <label className="block flex-1 space-y-1">
253 <span className="text-xs font-medium text-foreground">Custom model slug</span>
254 <Input
255 value={customModelInput}
256 onChange={(event) => {
257 setCustomModelInput(event.target.value);
258 if (customModelError) {
259 setCustomModelError(null);
260 }
261 }}
262 placeholder="your-model-slug"
263 spellCheck={false}
264 />
265 <span className="text-xs text-muted-foreground">
266 Example: <code>gpt-6.7-codex-ultra-preview</code>
267 </span>
268 </label>
269
270 <Button className="sm:mt-6" type="submit">
271 Add model
272 </Button>
273 </form>
274
275 {customModelError ? (
276 <p className="text-xs text-destructive">{customModelError}</p>
277 ) : null}
278
279 <div className="space-y-2">
280 <div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
281 <p>Saved custom models: {customCodexModels.length}</p>
282 {customCodexModels.length > 0 ? (
283 <Button
284 size="xs"
285 variant="outline"
286 onClick={() =>
287 updateSettings({
288 customCodexModels: defaults.customCodexModels,
289 })
290 }
291 >
292 Reset custom models
293 </Button>
294 ) : null}
295 </div>
296
297 {customCodexModels.length > 0 ? (
298 <div className="space-y-2">
299 {customCodexModels.map((slug) => (
300 <div
301 key={slug}
302 className="flex items-center justify-between gap-3 rounded-lg border border-border bg-background px-3 py-2"
303 >
304 <code className="min-w-0 flex-1 truncate text-xs text-foreground">
305 {slug}
306 </code>
307 <Button
308 size="xs"
309 variant="ghost"
310 onClick={() => removeCustomModel(slug)}
311 >
312 Remove
313 </Button>
314 </div>
315 ))}
316 </div>
317 ) : (
318 <div className="rounded-lg border border-dashed border-border bg-background px-3 py-4 text-xs text-muted-foreground">
319 No custom models saved yet.
320 </div>
321 )}
322 </div>
323 </div>
324 </section>
325
196326 <section className="rounded-2xl border border-border bg-card p-5">
197327 <div className="mb-4">
198328 <h2 className="text-sm font-medium text-foreground">Runtime Mode</h2>
packages/contracts/src/model.test.ts+7-2
......@@ -22,9 +22,14 @@ describe("normalizeModelSlug", () => {
2222});
2323
2424describe("resolveModelSlug", () => {
25 it("returns default for missing and unknown models", () => {
25 it("returns default only when the model is missing", () => {
2626 expect(resolveModelSlug(undefined)).toBe(DEFAULT_MODEL);
27 expect(resolveModelSlug("gpt-4.1")).toBe(DEFAULT_MODEL);
27 expect(resolveModelSlug(null)).toBe(DEFAULT_MODEL);
28 });
29
30 it("preserves unknown custom models", () => {
31 expect(resolveModelSlug("gpt-4.1")).toBe("gpt-4.1");
32 expect(resolveModelSlug("custom/internal-model")).toBe("custom/internal-model");
2833 });
2934
3035 it("resolves only supported model options", () => {
packages/contracts/src/model.ts+5-8
......@@ -5,11 +5,12 @@ export const MODEL_OPTIONS = [
55 { slug: "gpt-5.2", name: "GPT-5.2" },
66] as const;
77
8export type ModelSlug = (typeof MODEL_OPTIONS)[number]["slug"];
8export type BuiltInModelSlug = (typeof MODEL_OPTIONS)[number]["slug"];
9export type ModelSlug = string;
910
1011export const DEFAULT_MODEL = "gpt-5.3-codex";
1112
12export const MODEL_SLUG_ALIASES: Record<string, ModelSlug> = {
13export const MODEL_SLUG_ALIASES: Record<string, BuiltInModelSlug> = {
1314 "5.3": "gpt-5.3-codex",
1415 "gpt-5.3": "gpt-5.3-codex",
1516 "5.3-spark": "gpt-5.3-codex-spark",
......@@ -26,16 +27,12 @@ export function normalizeModelSlug(model: string | null | undefined): ModelSlug
2627 return null;
2728 }
2829
29 return MODEL_SLUG_ALIASES[trimmed] ?? (trimmed as ModelSlug);
30 return MODEL_SLUG_ALIASES[trimmed] ?? trimmed;
3031}
3132
3233export function resolveModelSlug(model: string | null | undefined): ModelSlug {
3334 const normalized = normalizeModelSlug(model);
34 if (!normalized) {
35 return DEFAULT_MODEL;
36 }
37
38 return MODEL_OPTIONS.some((option) => option.slug === normalized) ? normalized : DEFAULT_MODEL;
35 return normalized ?? DEFAULT_MODEL;
3936}
4037
4138export const REASONING_OPTIONS = ["xhigh", "high", "medium", "low"] as const;