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 @@...@@ -1,7 +1,11 @@
1import { useCallback, useSyncExternalStore } from "react";1import { useCallback, useSyncExternalStore } from "react";
2import { Option, Schema } from "effect";2import { Option, Schema } from "effect";
3import { MODEL_OPTIONS, normalizeModelSlug } from "@t3tools/contracts";
34
4const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1";5const 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
6const AppSettingsSchema = Schema.Struct({10const AppSettingsSchema = Schema.Struct({
7 codexBinaryPath: Schema.String.check(Schema.isMaxLength(4096)).pipe(11 codexBinaryPath: Schema.String.check(Schema.isMaxLength(4096)).pipe(
...@@ -14,8 +18,16 @@ const AppSettingsSchema = Schema.Struct({...@@ -14,8 +18,16 @@ const AppSettingsSchema = Schema.Struct({
14 enableAssistantStreaming: Schema.Boolean.pipe(18 enableAssistantStreaming: Schema.Boolean.pipe(
15 Schema.withConstructorDefault(() => Option.some(false)),19 Schema.withConstructorDefault(() => Option.some(false)),
16 ),20 ),
21 customCodexModels: Schema.Array(Schema.String).pipe(
22 Schema.withConstructorDefault(() => Option.some([])),
23 ),
17});24});
18export type AppSettings = typeof AppSettingsSchema.Type;25export type AppSettings = typeof AppSettingsSchema.Type;
26export interface AppModelOption {
27 slug: string;
28 name: string;
29 isCustom: boolean;
30}
1931
20const DEFAULT_APP_SETTINGS = AppSettingsSchema.makeUnsafe({});32const DEFAULT_APP_SETTINGS = AppSettingsSchema.makeUnsafe({});
2133
...@@ -23,6 +35,76 @@ let listeners: Array<() => void> = [];...@@ -23,6 +35,76 @@ let listeners: Array<() => void> = [];
23let cachedRawSettings: string | null | undefined;35let cachedRawSettings: string | null | undefined;
24let cachedSnapshot: AppSettings = DEFAULT_APP_SETTINGS;36let 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
26function emitChange(): void {108function emitChange(): void {
27 for (const listener of listeners) {109 for (const listener of listeners) {
28 listener();110 listener();
...@@ -35,7 +117,7 @@ function parsePersistedSettings(value: string | null): AppSettings {...@@ -35,7 +117,7 @@ function parsePersistedSettings(value: string | null): AppSettings {
35 }117 }
36118
37 try {119 try {
38 return Schema.decodeSync(Schema.fromJsonString(AppSettingsSchema))(value);120 return normalizeAppSettings(Schema.decodeSync(Schema.fromJsonString(AppSettingsSchema))(value));
39 } catch {121 } catch {
40 return DEFAULT_APP_SETTINGS;122 return DEFAULT_APP_SETTINGS;
41 }123 }
...@@ -96,10 +178,12 @@ export function useAppSettings() {...@@ -96,10 +178,12 @@ export function useAppSettings() {
96 );178 );
97179
98 const updateSettings = useCallback((patch: Partial<AppSettings>) => {180 const updateSettings = useCallback((patch: Partial<AppSettings>) => {
99 const next = Schema.decodeSync(AppSettingsSchema)({181 const next = normalizeAppSettings(
100 ...getAppSettingsSnapshot(),182 Schema.decodeSync(AppSettingsSchema)({
101 ...patch,183 ...getAppSettingsSnapshot(),
102 });184 ...patch,
185 }),
186 );
103 persistSettings(next);187 persistSettings(next);
104 emitChange();188 emitChange();
105 }, []);189 }, []);
apps/web/src/components/ChatView.tsx+37-28
...@@ -6,11 +6,9 @@ import {...@@ -6,11 +6,9 @@ import {
6 type EditorId,6 type EditorId,
7 type KeybindingCommand,7 type KeybindingCommand,
8 type MessageId,8 type MessageId,
9 MODEL_OPTIONS,
10 type ProjectId,9 type ProjectId,
11 type ProjectEntry,10 type ProjectEntry,
12 type ProjectScript,11 type ProjectScript,
13 ModelSlug,
14 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,12 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
15 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,13 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
16 REASONING_OPTIONS,14 REASONING_OPTIONS,
...@@ -112,7 +110,7 @@ import { Toggle } from "./ui/toggle";...@@ -112,7 +110,7 @@ import { Toggle } from "./ui/toggle";
112import { SidebarTrigger } from "./ui/sidebar";110import { SidebarTrigger } from "./ui/sidebar";
113import { newCommandId, newMessageId } from "~/lib/utils";111import { newCommandId, newMessageId } from "~/lib/utils";
114import { readNativeApi } from "~/nativeApi";112import { readNativeApi } from "~/nativeApi";
115import { useAppSettings } from "../appSettings";113import { getAppModelOptions, useAppSettings } from "../appSettings";
116import { clamp } from "effect/Number";114import { clamp } from "effect/Number";
117115
118function formatMessageMeta(createdAt: string, duration: string | null): string {116function formatMessageMeta(createdAt: string, duration: string | null): string {
...@@ -129,12 +127,6 @@ const IMAGE_ONLY_BOOTSTRAP_PROMPT =...@@ -129,12 +127,6 @@ const IMAGE_ONLY_BOOTSTRAP_PROMPT =
129const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];127const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];
130const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = [];128const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = [];
131const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120;129const 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}));
138const SCRIPT_TERMINAL_COLS = 120;130const SCRIPT_TERMINAL_COLS = 120;
139const SCRIPT_TERMINAL_ROWS = 30;131const SCRIPT_TERMINAL_ROWS = 30;
140132
...@@ -216,7 +208,7 @@ type ComposerCommandItem =...@@ -216,7 +208,7 @@ type ComposerCommandItem =
216 | {208 | {
217 id: string;209 id: string;
218 type: "model";210 type: "model";
219 model: ModelSlug;211 model: string;
220 label: string;212 label: string;
221 description: string;213 description: string;
222 };214 };
...@@ -438,6 +430,10 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -438,6 +430,10 @@ export default function ChatView({ threadId }: ChatViewProps) {
438 const selectedModel = resolveModelSlug(430 const selectedModel = resolveModelSlug(
439 activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL,431 activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL,
440 );432 );
433 const modelOptions = useMemo(
434 () => getAppModelOptions(settings.customCodexModels, selectedModel),
435 [selectedModel, settings.customCodexModels],
436 );
441 const phase = derivePhase(activeThread?.session ?? null);437 const phase = derivePhase(activeThread?.session ?? null);
442 const isSendBusy = sendPhase !== "idle";438 const isSendBusy = sendPhase !== "idle";
443 const isPreparingWorktree = sendPhase === "preparing-worktree";439 const isPreparingWorktree = sendPhase === "preparing-worktree";
...@@ -614,18 +610,26 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -614,18 +610,26 @@ export default function ChatView({ threadId }: ChatViewProps) {
614 ];610 ];
615 }611 }
616612
617 return SEARCHABLE_MODEL_OPTIONS.filter(({ searchSlug, searchName }) => {613 return modelOptions
618 const query = composerTrigger.query.trim().toLowerCase();614 .map(({ slug, name }) => ({
619 if (!query) return true;615 slug,
620 return searchSlug.includes(query) || searchName.includes(query);616 name,
621 }).map(({ slug, name }) => ({617 searchSlug: slug.toLowerCase(),
622 id: `model:${slug}`,618 searchName: name.toLowerCase(),
623 type: "model",619 }))
624 model: slug,620 .filter(({ searchSlug, searchName }) => {
625 label: name,621 const query = composerTrigger.query.trim().toLowerCase();
626 description: slug,622 if (!query) return true;
627 }));623 return searchSlug.includes(query) || searchName.includes(query);
628 }, [composerTrigger, workspaceEntries]);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]);
629 const composerMenuOpen = Boolean(composerTrigger);633 const composerMenuOpen = Boolean(composerTrigger);
630 const activeComposerMenuItem = useMemo(634 const activeComposerMenuItem = useMemo(
631 () =>635 () =>
...@@ -1660,7 +1664,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1660,7 +1664,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
1660 );1664 );
16611665
1662 const onModelSelect = useCallback(1666 const onModelSelect = useCallback(
1663 (model: ModelSlug) => {1667 (model: string) => {
1664 const api = readNativeApi();1668 const api = readNativeApi();
1665 if (!activeThread) return;1669 if (!activeThread) return;
1666 if (api) {1670 if (api) {
...@@ -2012,7 +2016,11 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2012,7 +2016,11 @@ export default function ChatView({ threadId }: ChatViewProps) {
2012 <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">2016 <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">
2013 <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">2017 <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">
2014 {/* Model picker */}2018 {/* Model picker */}
2015 <ModelPicker model={selectedModel} onModelChange={onModelSelect} />2019 <ModelPicker
2020 model={selectedModel}
2021 options={modelOptions}
2022 onModelChange={onModelSelect}
2023 />
20162024
2017 {/* Divider */}2025 {/* Divider */}
2018 <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" />2026 <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" />
...@@ -2912,12 +2920,13 @@ const MessagesTimeline = memo(function MessagesTimeline({...@@ -2912,12 +2920,13 @@ const MessagesTimeline = memo(function MessagesTimeline({
2912});2920});
29132921
2914const ModelPicker = memo(function ModelPicker(props: {2922const ModelPicker = memo(function ModelPicker(props: {
2915 model: ModelSlug;2923 model: string;
2916 onModelChange: (model: ModelSlug) => void;2924 options: ReadonlyArray<{ slug: string; name: string }>;
2925 onModelChange: (model: string) => void;
2917}) {2926}) {
2918 return (2927 return (
2919 <Select2928 <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 }))}
2921 value={props.model}2930 value={props.model}
2922 onValueChange={(value) => (value ? props.onModelChange(value) : undefined)}2931 onValueChange={(value) => (value ? props.onModelChange(value) : undefined)}
2923 >2932 >
...@@ -2925,7 +2934,7 @@ const ModelPicker = memo(function ModelPicker(props: {...@@ -2925,7 +2934,7 @@ const ModelPicker = memo(function ModelPicker(props: {
2925 <SelectValue />2934 <SelectValue />
2926 </SelectTrigger>2935 </SelectTrigger>
2927 <SelectPopup alignItemWithTrigger={false}>2936 <SelectPopup alignItemWithTrigger={false}>
2928 {MODEL_OPTIONS.map(({ slug, name }) => (2937 {props.options.map(({ slug, name }) => (
2929 <SelectItem key={slug} value={slug}>2938 <SelectItem key={slug} value={slug}>
2930 {name}2939 {name}
2931 </SelectItem>2940 </SelectItem>
apps/web/src/routes/_chat.settings.tsx+131-1
...@@ -1,8 +1,9 @@...@@ -1,8 +1,9 @@
1import { createFileRoute } from "@tanstack/react-router";1import { createFileRoute } from "@tanstack/react-router";
2import { useQuery } from "@tanstack/react-query";2import { useQuery } from "@tanstack/react-query";
3import { useCallback, useState } from "react";3import { useCallback, useState } from "react";
4import { MODEL_OPTIONS, normalizeModelSlug } from "@t3tools/contracts";
45
5import { useAppSettings } from "../appSettings";6import { MAX_CUSTOM_MODEL_LENGTH, useAppSettings } from "../appSettings";
6import { isElectron } from "../env";7import { isElectron } from "../env";
7import { useTheme } from "../hooks/useTheme";8import { useTheme } from "../hooks/useTheme";
8import { serverConfigQueryOptions } from "../lib/serverReactQuery";9import { serverConfigQueryOptions } from "../lib/serverReactQuery";
...@@ -52,9 +53,12 @@ function SettingsRouteView() {...@@ -52,9 +53,12 @@ function SettingsRouteView() {
52 const serverConfigQuery = useQuery(serverConfigQueryOptions());53 const serverConfigQuery = useQuery(serverConfigQueryOptions());
53 const [isOpeningKeybindings, setIsOpeningKeybindings] = useState(false);54 const [isOpeningKeybindings, setIsOpeningKeybindings] = useState(false);
54 const [openKeybindingsError, setOpenKeybindingsError] = useState<string | null>(null);55 const [openKeybindingsError, setOpenKeybindingsError] = useState<string | null>(null);
56 const [customModelInput, setCustomModelInput] = useState("");
57 const [customModelError, setCustomModelError] = useState<string | null>(null);
5558
56 const codexBinaryPath = settings.codexBinaryPath;59 const codexBinaryPath = settings.codexBinaryPath;
57 const codexHomePath = settings.codexHomePath;60 const codexHomePath = settings.codexHomePath;
61 const customCodexModels = settings.customCodexModels;
58 const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null;62 const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null;
5963
60 const openKeybindingsFile = useCallback(() => {64 const openKeybindingsFile = useCallback(() => {
...@@ -74,6 +78,42 @@ function SettingsRouteView() {...@@ -74,6 +78,42 @@ function SettingsRouteView() {
74 });78 });
75 }, [keybindingsConfigPath]);79 }, [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
77 return (117 return (
78 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground isolate">118 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground isolate">
79 <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-foreground">119 <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-foreground">
...@@ -193,6 +233,96 @@ function SettingsRouteView() {...@@ -193,6 +233,96 @@ function SettingsRouteView() {
193 </div>233 </div>
194 </section>234 </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
196 <section className="rounded-2xl border border-border bg-card p-5">326 <section className="rounded-2xl border border-border bg-card p-5">
197 <div className="mb-4">327 <div className="mb-4">
198 <h2 className="text-sm font-medium text-foreground">Runtime Mode</h2>328 <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", () => {...@@ -22,9 +22,14 @@ describe("normalizeModelSlug", () => {
22});22});
2323
24describe("resolveModelSlug", () => {24describe("resolveModelSlug", () => {
25 it("returns default for missing and unknown models", () => {25 it("returns default only when the model is missing", () => {
26 expect(resolveModelSlug(undefined)).toBe(DEFAULT_MODEL);26 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");
28 });33 });
2934
30 it("resolves only supported model options", () => {35 it("resolves only supported model options", () => {
packages/contracts/src/model.ts+5-8
...@@ -5,11 +5,12 @@ export const MODEL_OPTIONS = [...@@ -5,11 +5,12 @@ export const MODEL_OPTIONS = [
5 { slug: "gpt-5.2", name: "GPT-5.2" },5 { slug: "gpt-5.2", name: "GPT-5.2" },
6] as const;6] as const;
77
8export type ModelSlug = (typeof MODEL_OPTIONS)[number]["slug"];8export type BuiltInModelSlug = (typeof MODEL_OPTIONS)[number]["slug"];
9export type ModelSlug = string;
910
10export const DEFAULT_MODEL = "gpt-5.3-codex";11export const DEFAULT_MODEL = "gpt-5.3-codex";
1112
12export const MODEL_SLUG_ALIASES: Record<string, ModelSlug> = {13export const MODEL_SLUG_ALIASES: Record<string, BuiltInModelSlug> = {
13 "5.3": "gpt-5.3-codex",14 "5.3": "gpt-5.3-codex",
14 "gpt-5.3": "gpt-5.3-codex",15 "gpt-5.3": "gpt-5.3-codex",
15 "5.3-spark": "gpt-5.3-codex-spark",16 "5.3-spark": "gpt-5.3-codex-spark",
...@@ -26,16 +27,12 @@ export function normalizeModelSlug(model: string | null | undefined): ModelSlug...@@ -26,16 +27,12 @@ export function normalizeModelSlug(model: string | null | undefined): ModelSlug
26 return null;27 return null;
27 }28 }
2829
29 return MODEL_SLUG_ALIASES[trimmed] ?? (trimmed as ModelSlug);30 return MODEL_SLUG_ALIASES[trimmed] ?? trimmed;
30}31}
3132
32export function resolveModelSlug(model: string | null | undefined): ModelSlug {33export function resolveModelSlug(model: string | null | undefined): ModelSlug {
33 const normalized = normalizeModelSlug(model);34 const normalized = normalizeModelSlug(model);
34 if (!normalized) {35 return normalized ?? DEFAULT_MODEL;
35 return DEFAULT_MODEL;
36 }
37
38 return MODEL_OPTIONS.some((option) => option.slug === normalized) ? normalized : DEFAULT_MODEL;
39}36}
4037
41export const REASONING_OPTIONS = ["xhigh", "high", "medium", "low"] as const;38export const REASONING_OPTIONS = ["xhigh", "high", "medium", "low"] as const;