| author | |
| committer | |
| log | 56de1465fa81eadc3c09140f7fff1579299e3bad |
| tree | 49e6400ae882df5ba0b2b1466890721ba1ba5446 |
| parent | b500106fde0f90344174cf113d439777c6f3b266 |
| signature | Signed by PGP key B5690EEEBB952194 |
* custom models
* better fake version6 files changed, 311 insertions(+), 44 deletions(-)
apps/web/src/appSettings.test.ts created+42| ... | ... | @@ -0,0 +1,42 @@ |
| 1 | import { describe, expect, it } from "vitest"; | |
| 2 | ||
| 3 | import { getAppModelOptions, normalizeCustomModelSlugs } from "./appSettings"; | |
| 4 | ||
| 5 | describe("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 | ||
| 20 | describe("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 | 1 | import { useCallback, useSyncExternalStore } from "react"; |
| 2 | 2 | import { Option, Schema } from "effect"; |
| 3 | import { MODEL_OPTIONS, normalizeModelSlug } from "@t3tools/contracts"; | |
| 3 | 4 | |
| 4 | 5 | const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1"; |
| 6 | const MAX_CUSTOM_MODEL_COUNT = 32; | |
| 7 | export const MAX_CUSTOM_MODEL_LENGTH = 256; | |
| 8 | const BUILT_IN_MODEL_SLUGS = new Set<string>(MODEL_OPTIONS.map((option) => option.slug)); | |
| 5 | 9 | |
| 6 | 10 | const AppSettingsSchema = Schema.Struct({ |
| 7 | 11 | codexBinaryPath: Schema.String.check(Schema.isMaxLength(4096)).pipe( |
| ... | ... | @@ -14,8 +18,16 @@ const AppSettingsSchema = Schema.Struct({ |
| 14 | 18 | enableAssistantStreaming: Schema.Boolean.pipe( |
| 15 | 19 | Schema.withConstructorDefault(() => Option.some(false)), |
| 16 | 20 | ), |
| 21 | customCodexModels: Schema.Array(Schema.String).pipe( | |
| 22 | Schema.withConstructorDefault(() => Option.some([])), | |
| 23 | ), | |
| 17 | 24 | }); |
| 18 | 25 | export type AppSettings = typeof AppSettingsSchema.Type; |
| 26 | export interface AppModelOption { | |
| 27 | slug: string; | |
| 28 | name: string; | |
| 29 | isCustom: boolean; | |
| 30 | } | |
| 19 | 31 | |
| 20 | 32 | const DEFAULT_APP_SETTINGS = AppSettingsSchema.makeUnsafe({}); |
| 21 | 33 | |
| ... | ... | @@ -23,6 +35,76 @@ let listeners: Array<() => void> = []; |
| 23 | 35 | let cachedRawSettings: string | null | undefined; |
| 24 | 36 | let cachedSnapshot: AppSettings = DEFAULT_APP_SETTINGS; |
| 25 | 37 | |
| 38 | export 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 | ||
| 65 | function normalizeAppSettings(settings: AppSettings): AppSettings { | |
| 66 | return { | |
| 67 | ...settings, | |
| 68 | customCodexModels: normalizeCustomModelSlugs(settings.customCodexModels), | |
| 69 | }; | |
| 70 | } | |
| 71 | ||
| 72 | export 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 | ||
| 26 | 108 | function emitChange(): void { |
| 27 | 109 | for (const listener of listeners) { |
| 28 | 110 | listener(); |
| ... | ... | @@ -35,7 +117,7 @@ function parsePersistedSettings(value: string | null): AppSettings { |
| 35 | 117 | } |
| 36 | 118 | |
| 37 | 119 | try { |
| 38 | return Schema.decodeSync(Schema.fromJsonString(AppSettingsSchema))(value); | |
| 120 | return normalizeAppSettings(Schema.decodeSync(Schema.fromJsonString(AppSettingsSchema))(value)); | |
| 39 | 121 | } catch { |
| 40 | 122 | return DEFAULT_APP_SETTINGS; |
| 41 | 123 | } |
| ... | ... | @@ -96,10 +178,12 @@ export function useAppSettings() { |
| 96 | 178 | ); |
| 97 | 179 | |
| 98 | 180 | 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 | ); | |
| 103 | 187 | persistSettings(next); |
| 104 | 188 | emitChange(); |
| 105 | 189 | }, []); |
apps/web/src/components/ChatView.tsx+37-28| ... | ... | @@ -6,11 +6,9 @@ import { |
| 6 | 6 | type EditorId, |
| 7 | 7 | type KeybindingCommand, |
| 8 | 8 | type MessageId, |
| 9 | MODEL_OPTIONS, | |
| 10 | 9 | type ProjectId, |
| 11 | 10 | type ProjectEntry, |
| 12 | 11 | type ProjectScript, |
| 13 | ModelSlug, | |
| 14 | 12 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, |
| 15 | 13 | PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, |
| 16 | 14 | REASONING_OPTIONS, |
| ... | ... | @@ -112,7 +110,7 @@ import { Toggle } from "./ui/toggle"; |
| 112 | 110 | import { SidebarTrigger } from "./ui/sidebar"; |
| 113 | 111 | import { newCommandId, newMessageId } from "~/lib/utils"; |
| 114 | 112 | import { readNativeApi } from "~/nativeApi"; |
| 115 | import { useAppSettings } from "../appSettings"; | |
| 113 | import { getAppModelOptions, useAppSettings } from "../appSettings"; | |
| 116 | 114 | import { clamp } from "effect/Number"; |
| 117 | 115 | |
| 118 | 116 | function formatMessageMeta(createdAt: string, duration: string | null): string { |
| ... | ... | @@ -129,12 +127,6 @@ const IMAGE_ONLY_BOOTSTRAP_PROMPT = |
| 129 | 127 | const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; |
| 130 | 128 | const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = []; |
| 131 | 129 | const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120; |
| 132 | const SEARCHABLE_MODEL_OPTIONS = MODEL_OPTIONS.map(({ slug, name }) => ({ | |
| 133 | slug, | |
| 134 | name, | |
| 135 | searchSlug: slug.toLowerCase(), | |
| 136 | searchName: name.toLowerCase(), | |
| 137 | })); | |
| 138 | 130 | const SCRIPT_TERMINAL_COLS = 120; |
| 139 | 131 | const SCRIPT_TERMINAL_ROWS = 30; |
| 140 | 132 | |
| ... | ... | @@ -216,7 +208,7 @@ type ComposerCommandItem = |
| 216 | 208 | | { |
| 217 | 209 | id: string; |
| 218 | 210 | type: "model"; |
| 219 | model: ModelSlug; | |
| 211 | model: string; | |
| 220 | 212 | label: string; |
| 221 | 213 | description: string; |
| 222 | 214 | }; |
| ... | ... | @@ -438,6 +430,10 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 438 | 430 | const selectedModel = resolveModelSlug( |
| 439 | 431 | activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL, |
| 440 | 432 | ); |
| 433 | const modelOptions = useMemo( | |
| 434 | () => getAppModelOptions(settings.customCodexModels, selectedModel), | |
| 435 | [selectedModel, settings.customCodexModels], | |
| 436 | ); | |
| 441 | 437 | const phase = derivePhase(activeThread?.session ?? null); |
| 442 | 438 | const isSendBusy = sendPhase !== "idle"; |
| 443 | 439 | const isPreparingWorktree = sendPhase === "preparing-worktree"; |
| ... | ... | @@ -614,18 +610,26 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 614 | 610 | ]; |
| 615 | 611 | } |
| 616 | 612 | |
| 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]); | |
| 629 | 633 | const composerMenuOpen = Boolean(composerTrigger); |
| 630 | 634 | const activeComposerMenuItem = useMemo( |
| 631 | 635 | () => |
| ... | ... | @@ -1660,7 +1664,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1660 | 1664 | ); |
| 1661 | 1665 | |
| 1662 | 1666 | const onModelSelect = useCallback( |
| 1663 | (model: ModelSlug) => { | |
| 1667 | (model: string) => { | |
| 1664 | 1668 | const api = readNativeApi(); |
| 1665 | 1669 | if (!activeThread) return; |
| 1666 | 1670 | if (api) { |
| ... | ... | @@ -2012,7 +2016,11 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2012 | 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 | 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 | 2018 | {/* Model picker */} |
| 2015 | <ModelPicker model={selectedModel} onModelChange={onModelSelect} /> | |
| 2019 | <ModelPicker | |
| 2020 | model={selectedModel} | |
| 2021 | options={modelOptions} | |
| 2022 | onModelChange={onModelSelect} | |
| 2023 | /> | |
| 2016 | 2024 | |
| 2017 | 2025 | {/* Divider */} |
| 2018 | 2026 | <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" /> |
| ... | ... | @@ -2912,12 +2920,13 @@ const MessagesTimeline = memo(function MessagesTimeline({ |
| 2912 | 2920 | }); |
| 2913 | 2921 | |
| 2914 | 2922 | const 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; | |
| 2917 | 2926 | }) { |
| 2918 | 2927 | return ( |
| 2919 | 2928 | <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 | 2930 | value={props.model} |
| 2922 | 2931 | onValueChange={(value) => (value ? props.onModelChange(value) : undefined)} |
| 2923 | 2932 | > |
| ... | ... | @@ -2925,7 +2934,7 @@ const ModelPicker = memo(function ModelPicker(props: { |
| 2925 | 2934 | <SelectValue /> |
| 2926 | 2935 | </SelectTrigger> |
| 2927 | 2936 | <SelectPopup alignItemWithTrigger={false}> |
| 2928 | {MODEL_OPTIONS.map(({ slug, name }) => ( | |
| 2937 | {props.options.map(({ slug, name }) => ( | |
| 2929 | 2938 | <SelectItem key={slug} value={slug}> |
| 2930 | 2939 | {name} |
| 2931 | 2940 | </SelectItem> |
apps/web/src/routes/_chat.settings.tsx+131-1| ... | ... | @@ -1,8 +1,9 @@ |
| 1 | 1 | import { createFileRoute } from "@tanstack/react-router"; |
| 2 | 2 | import { useQuery } from "@tanstack/react-query"; |
| 3 | 3 | import { useCallback, useState } from "react"; |
| 4 | import { MODEL_OPTIONS, normalizeModelSlug } from "@t3tools/contracts"; | |
| 4 | 5 | |
| 5 | import { useAppSettings } from "../appSettings"; | |
| 6 | import { MAX_CUSTOM_MODEL_LENGTH, useAppSettings } from "../appSettings"; | |
| 6 | 7 | import { isElectron } from "../env"; |
| 7 | 8 | import { useTheme } from "../hooks/useTheme"; |
| 8 | 9 | import { serverConfigQueryOptions } from "../lib/serverReactQuery"; |
| ... | ... | @@ -52,9 +53,12 @@ function SettingsRouteView() { |
| 52 | 53 | const serverConfigQuery = useQuery(serverConfigQueryOptions()); |
| 53 | 54 | const [isOpeningKeybindings, setIsOpeningKeybindings] = useState(false); |
| 54 | 55 | const [openKeybindingsError, setOpenKeybindingsError] = useState<string | null>(null); |
| 56 | const [customModelInput, setCustomModelInput] = useState(""); | |
| 57 | const [customModelError, setCustomModelError] = useState<string | null>(null); | |
| 55 | 58 | |
| 56 | 59 | const codexBinaryPath = settings.codexBinaryPath; |
| 57 | 60 | const codexHomePath = settings.codexHomePath; |
| 61 | const customCodexModels = settings.customCodexModels; | |
| 58 | 62 | const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null; |
| 59 | 63 | |
| 60 | 64 | const openKeybindingsFile = useCallback(() => { |
| ... | ... | @@ -74,6 +78,42 @@ function SettingsRouteView() { |
| 74 | 78 | }); |
| 75 | 79 | }, [keybindingsConfigPath]); |
| 76 | 80 | |
| 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 | 117 | return ( |
| 78 | 118 | <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground isolate"> |
| 79 | 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 | 233 | </div> |
| 194 | 234 | </section> |
| 195 | 235 | |
| 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 | 326 | <section className="rounded-2xl border border-border bg-card p-5"> |
| 197 | 327 | <div className="mb-4"> |
| 198 | 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 | 22 | }); |
| 23 | 23 | |
| 24 | 24 | describe("resolveModelSlug", () => { |
| 25 | it("returns default for missing and unknown models", () => { | |
| 25 | it("returns default only when the model is missing", () => { | |
| 26 | 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 | }); |
| 29 | 34 | |
| 30 | 35 | it("resolves only supported model options", () => { |
packages/contracts/src/model.ts+5-8| ... | ... | @@ -5,11 +5,12 @@ export const MODEL_OPTIONS = [ |
| 5 | 5 | { slug: "gpt-5.2", name: "GPT-5.2" }, |
| 6 | 6 | ] as const; |
| 7 | 7 | |
| 8 | export type ModelSlug = (typeof MODEL_OPTIONS)[number]["slug"]; | |
| 8 | export type BuiltInModelSlug = (typeof MODEL_OPTIONS)[number]["slug"]; | |
| 9 | export type ModelSlug = string; | |
| 9 | 10 | |
| 10 | 11 | export const DEFAULT_MODEL = "gpt-5.3-codex"; |
| 11 | 12 | |
| 12 | export const MODEL_SLUG_ALIASES: Record<string, ModelSlug> = { | |
| 13 | export const MODEL_SLUG_ALIASES: Record<string, BuiltInModelSlug> = { | |
| 13 | 14 | "5.3": "gpt-5.3-codex", |
| 14 | 15 | "gpt-5.3": "gpt-5.3-codex", |
| 15 | 16 | "5.3-spark": "gpt-5.3-codex-spark", |
| ... | ... | @@ -26,16 +27,12 @@ export function normalizeModelSlug(model: string | null | undefined): ModelSlug |
| 26 | 27 | return null; |
| 27 | 28 | } |
| 28 | 29 | |
| 29 | return MODEL_SLUG_ALIASES[trimmed] ?? (trimmed as ModelSlug); | |
| 30 | return MODEL_SLUG_ALIASES[trimmed] ?? trimmed; | |
| 30 | 31 | } |
| 31 | 32 | |
| 32 | 33 | export function resolveModelSlug(model: string | null | undefined): ModelSlug { |
| 33 | 34 | 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; | |
| 39 | 36 | } |
| 40 | 37 | |
| 41 | 38 | export const REASONING_OPTIONS = ["xhigh", "high", "medium", "low"] as const; |