| 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,7 +1,11 @@ |
| 1 | import { useCallback, useSyncExternalStore } from "react"; | 1 | import { useCallback, useSyncExternalStore } from "react"; |
| 2 | import { Option, Schema } from "effect"; | 2 | import { Option, Schema } from "effect"; |
| 3 | import { MODEL_OPTIONS, normalizeModelSlug } from "@t3tools/contracts"; | ||
| 3 | 4 | ||
| 4 | const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1"; | 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 | const AppSettingsSchema = Schema.Struct({ | 10 | const 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 | }); |
| 18 | export type AppSettings = typeof AppSettingsSchema.Type; | 25 | export type AppSettings = typeof AppSettingsSchema.Type; |
| 26 | export interface AppModelOption { | ||
| 27 | slug: string; | ||
| 28 | name: string; | ||
| 29 | isCustom: boolean; | ||
| 30 | } | ||
| 19 | 31 | ||
| 20 | const DEFAULT_APP_SETTINGS = AppSettingsSchema.makeUnsafe({}); | 32 | const DEFAULT_APP_SETTINGS = AppSettingsSchema.makeUnsafe({}); |
| 21 | 33 | ||
| ... | @@ -23,6 +35,76 @@ let listeners: Array<() => void> = []; | ... | @@ -23,6 +35,76 @@ let listeners: Array<() => void> = []; |
| 23 | let cachedRawSettings: string | null | undefined; | 35 | let cachedRawSettings: string | null | undefined; |
| 24 | let cachedSnapshot: AppSettings = DEFAULT_APP_SETTINGS; | 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 | function emitChange(): void { | 108 | function 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 | } |
| 36 | 118 | ||
| 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 | ); |
| 97 | 179 | ||
| 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"; |
| 112 | import { SidebarTrigger } from "./ui/sidebar"; | 110 | import { SidebarTrigger } from "./ui/sidebar"; |
| 113 | import { newCommandId, newMessageId } from "~/lib/utils"; | 111 | import { newCommandId, newMessageId } from "~/lib/utils"; |
| 114 | import { readNativeApi } from "~/nativeApi"; | 112 | import { readNativeApi } from "~/nativeApi"; |
| 115 | import { useAppSettings } from "../appSettings"; | 113 | import { getAppModelOptions, useAppSettings } from "../appSettings"; |
| 116 | import { clamp } from "effect/Number"; | 114 | import { clamp } from "effect/Number"; |
| 117 | 115 | ||
| 118 | function formatMessageMeta(createdAt: string, duration: string | null): string { | 116 | function 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 = |
| 129 | const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; | 127 | const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; |
| 130 | const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = []; | 128 | const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = []; |
| 131 | const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120; | 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 | const SCRIPT_TERMINAL_COLS = 120; | 130 | const SCRIPT_TERMINAL_COLS = 120; |
| 139 | const SCRIPT_TERMINAL_ROWS = 30; | 131 | const SCRIPT_TERMINAL_ROWS = 30; |
| 140 | 132 | ||
| ... | @@ -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 | } |
| 616 | 612 | ||
| 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 | ); |
| 1661 | 1665 | ||
| 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 | /> | ||
| 2016 | 2024 | ||
| 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 | }); |
| 2913 | 2921 | ||
| 2914 | const ModelPicker = memo(function ModelPicker(props: { | 2922 | const 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 | <Select | 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 | 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 @@ |
| 1 | import { createFileRoute } from "@tanstack/react-router"; | 1 | import { createFileRoute } from "@tanstack/react-router"; |
| 2 | import { useQuery } from "@tanstack/react-query"; | 2 | import { useQuery } from "@tanstack/react-query"; |
| 3 | import { useCallback, useState } from "react"; | 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 | import { isElectron } from "../env"; | 7 | import { isElectron } from "../env"; |
| 7 | import { useTheme } from "../hooks/useTheme"; | 8 | import { useTheme } from "../hooks/useTheme"; |
| 8 | import { serverConfigQueryOptions } from "../lib/serverReactQuery"; | 9 | import { 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); | ||
| 55 | 58 | ||
| 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; |
| 59 | 63 | ||
| 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]); |
| 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 | 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> |
| 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 | <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 | }); |
| 23 | 23 | ||
| 24 | describe("resolveModelSlug", () => { | 24 | describe("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 | }); |
| 29 | 34 | ||
| 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; |
| 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 | export const DEFAULT_MODEL = "gpt-5.3-codex"; | 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 | "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 | } |
| 28 | 29 | ||
| 29 | return MODEL_SLUG_ALIASES[trimmed] ?? (trimmed as ModelSlug); | 30 | return MODEL_SLUG_ALIASES[trimmed] ?? trimmed; |
| 30 | } | 31 | } |
| 31 | 32 | ||
| 32 | export function resolveModelSlug(model: string | null | undefined): ModelSlug { | 33 | export 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 | } |
| 40 | 37 | ||
| 41 | export const REASONING_OPTIONS = ["xhigh", "high", "medium", "low"] as const; | 38 | export const REASONING_OPTIONS = ["xhigh", "high", "medium", "low"] as const; |