diff --git a/apps/web/src/appSettings.test.ts b/apps/web/src/appSettings.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..8eb8b6937746a54ca6a458ae9178ab41d3db813e --- /dev/null +++ b/apps/web/src/appSettings.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; + +import { getAppModelOptions, normalizeCustomModelSlugs } from "./appSettings"; + +describe("normalizeCustomModelSlugs", () => { + it("normalizes aliases, removes built-ins, and deduplicates values", () => { + expect( + normalizeCustomModelSlugs([ + " custom/internal-model ", + "gpt-5.3-codex", + "5.3", + "custom/internal-model", + "", + null, + ]), + ).toEqual(["custom/internal-model"]); + }); +}); + +describe("getAppModelOptions", () => { + it("appends saved custom models after the built-in options", () => { + const options = getAppModelOptions(["custom/internal-model"]); + + expect(options.map((option) => option.slug)).toEqual([ + "gpt-5.3-codex", + "gpt-5.3-codex-spark", + "gpt-5.2-codex", + "gpt-5.2", + "custom/internal-model", + ]); + }); + + it("keeps the currently selected custom model available even if it is no longer saved", () => { + const options = getAppModelOptions([], "custom/selected-model"); + + expect(options.at(-1)).toEqual({ + slug: "custom/selected-model", + name: "custom/selected-model", + isCustom: true, + }); + }); +}); diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index 2c4aa9a2a7f32d53637ccced77db9b81ac306fa0..48a31c7a7cf66aa123a67571555a949397de66d7 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -1,7 +1,11 @@ import { useCallback, useSyncExternalStore } from "react"; import { Option, Schema } from "effect"; +import { MODEL_OPTIONS, normalizeModelSlug } from "@t3tools/contracts"; const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1"; +const MAX_CUSTOM_MODEL_COUNT = 32; +export const MAX_CUSTOM_MODEL_LENGTH = 256; +const BUILT_IN_MODEL_SLUGS = new Set(MODEL_OPTIONS.map((option) => option.slug)); const AppSettingsSchema = Schema.Struct({ codexBinaryPath: Schema.String.check(Schema.isMaxLength(4096)).pipe( @@ -14,8 +18,16 @@ const AppSettingsSchema = Schema.Struct({ enableAssistantStreaming: Schema.Boolean.pipe( Schema.withConstructorDefault(() => Option.some(false)), ), + customCodexModels: Schema.Array(Schema.String).pipe( + Schema.withConstructorDefault(() => Option.some([])), + ), }); export type AppSettings = typeof AppSettingsSchema.Type; +export interface AppModelOption { + slug: string; + name: string; + isCustom: boolean; +} const DEFAULT_APP_SETTINGS = AppSettingsSchema.makeUnsafe({}); @@ -23,6 +35,76 @@ let listeners: Array<() => void> = []; let cachedRawSettings: string | null | undefined; let cachedSnapshot: AppSettings = DEFAULT_APP_SETTINGS; +export function normalizeCustomModelSlugs( + models: Iterable, +): AppSettings["customCodexModels"] { + const normalizedModels: string[] = []; + const seen = new Set(); + + for (const candidate of models) { + const normalized = normalizeModelSlug(candidate); + if ( + !normalized || + normalized.length > MAX_CUSTOM_MODEL_LENGTH || + BUILT_IN_MODEL_SLUGS.has(normalized) || + seen.has(normalized) + ) { + continue; + } + + seen.add(normalized); + normalizedModels.push(normalized); + if (normalizedModels.length >= MAX_CUSTOM_MODEL_COUNT) { + break; + } + } + + return normalizedModels; +} + +function normalizeAppSettings(settings: AppSettings): AppSettings { + return { + ...settings, + customCodexModels: normalizeCustomModelSlugs(settings.customCodexModels), + }; +} + +export function getAppModelOptions( + customModels: readonly string[], + selectedModel?: string | null, +): AppModelOption[] { + const options: AppModelOption[] = MODEL_OPTIONS.map(({ slug, name }) => ({ + slug, + name, + isCustom: false, + })); + const seen = new Set(options.map((option) => option.slug)); + + for (const slug of normalizeCustomModelSlugs(customModels)) { + if (seen.has(slug)) { + continue; + } + + seen.add(slug); + options.push({ + slug, + name: slug, + isCustom: true, + }); + } + + const normalizedSelectedModel = normalizeModelSlug(selectedModel); + if (normalizedSelectedModel && !seen.has(normalizedSelectedModel)) { + options.push({ + slug: normalizedSelectedModel, + name: normalizedSelectedModel, + isCustom: true, + }); + } + + return options; +} + function emitChange(): void { for (const listener of listeners) { listener(); @@ -35,7 +117,7 @@ function parsePersistedSettings(value: string | null): AppSettings { } try { - return Schema.decodeSync(Schema.fromJsonString(AppSettingsSchema))(value); + return normalizeAppSettings(Schema.decodeSync(Schema.fromJsonString(AppSettingsSchema))(value)); } catch { return DEFAULT_APP_SETTINGS; } @@ -96,10 +178,12 @@ export function useAppSettings() { ); const updateSettings = useCallback((patch: Partial) => { - const next = Schema.decodeSync(AppSettingsSchema)({ - ...getAppSettingsSnapshot(), - ...patch, - }); + const next = normalizeAppSettings( + Schema.decodeSync(AppSettingsSchema)({ + ...getAppSettingsSnapshot(), + ...patch, + }), + ); persistSettings(next); emitChange(); }, []); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 8a7b9e48bd3b7bd5ff200db9d8d2e43a1869fbe1..697fd67e6af95a4718c457bf54c87d7723cbd44c 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -6,11 +6,9 @@ import { type EditorId, type KeybindingCommand, type MessageId, - MODEL_OPTIONS, type ProjectId, type ProjectEntry, type ProjectScript, - ModelSlug, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, REASONING_OPTIONS, @@ -112,7 +110,7 @@ import { Toggle } from "./ui/toggle"; import { SidebarTrigger } from "./ui/sidebar"; import { newCommandId, newMessageId } from "~/lib/utils"; import { readNativeApi } from "~/nativeApi"; -import { useAppSettings } from "../appSettings"; +import { getAppModelOptions, useAppSettings } from "../appSettings"; import { clamp } from "effect/Number"; function formatMessageMeta(createdAt: string, duration: string | null): string { @@ -129,12 +127,6 @@ const IMAGE_ONLY_BOOTSTRAP_PROMPT = const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = []; const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120; -const SEARCHABLE_MODEL_OPTIONS = MODEL_OPTIONS.map(({ slug, name }) => ({ - slug, - name, - searchSlug: slug.toLowerCase(), - searchName: name.toLowerCase(), -})); const SCRIPT_TERMINAL_COLS = 120; const SCRIPT_TERMINAL_ROWS = 30; @@ -216,7 +208,7 @@ type ComposerCommandItem = | { id: string; type: "model"; - model: ModelSlug; + model: string; label: string; description: string; }; @@ -438,6 +430,10 @@ export default function ChatView({ threadId }: ChatViewProps) { const selectedModel = resolveModelSlug( activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL, ); + const modelOptions = useMemo( + () => getAppModelOptions(settings.customCodexModels, selectedModel), + [selectedModel, settings.customCodexModels], + ); const phase = derivePhase(activeThread?.session ?? null); const isSendBusy = sendPhase !== "idle"; const isPreparingWorktree = sendPhase === "preparing-worktree"; @@ -614,18 +610,26 @@ export default function ChatView({ threadId }: ChatViewProps) { ]; } - return SEARCHABLE_MODEL_OPTIONS.filter(({ searchSlug, searchName }) => { - const query = composerTrigger.query.trim().toLowerCase(); - if (!query) return true; - return searchSlug.includes(query) || searchName.includes(query); - }).map(({ slug, name }) => ({ - id: `model:${slug}`, - type: "model", - model: slug, - label: name, - description: slug, - })); - }, [composerTrigger, workspaceEntries]); + return modelOptions + .map(({ slug, name }) => ({ + slug, + name, + searchSlug: slug.toLowerCase(), + searchName: name.toLowerCase(), + })) + .filter(({ searchSlug, searchName }) => { + const query = composerTrigger.query.trim().toLowerCase(); + if (!query) return true; + return searchSlug.includes(query) || searchName.includes(query); + }) + .map(({ slug, name }) => ({ + id: `model:${slug}`, + type: "model" as const, + model: slug, + label: name, + description: slug, + })); + }, [composerTrigger, modelOptions, workspaceEntries]); const composerMenuOpen = Boolean(composerTrigger); const activeComposerMenuItem = useMemo( () => @@ -1660,7 +1664,7 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const onModelSelect = useCallback( - (model: ModelSlug) => { + (model: string) => { const api = readNativeApi(); if (!activeThread) return; if (api) { @@ -2012,7 +2016,11 @@ export default function ChatView({ threadId }: ChatViewProps) {
{/* Model picker */} - + {/* Divider */} @@ -2912,12 +2920,13 @@ const MessagesTimeline = memo(function MessagesTimeline({ }); const ModelPicker = memo(function ModelPicker(props: { - model: ModelSlug; - onModelChange: (model: ModelSlug) => void; + model: string; + options: ReadonlyArray<{ slug: string; name: string }>; + onModelChange: (model: string) => void; }) { return ( { + setCustomModelInput(event.target.value); + if (customModelError) { + setCustomModelError(null); + } + }} + placeholder="your-model-slug" + spellCheck={false} + /> + + Example: gpt-6.7-codex-ultra-preview + + + + + + + {customModelError ? ( +

{customModelError}

+ ) : null} + +
+
+

Saved custom models: {customCodexModels.length}

+ {customCodexModels.length > 0 ? ( + + ) : null} +
+ + {customCodexModels.length > 0 ? ( +
+ {customCodexModels.map((slug) => ( +
+ + {slug} + + +
+ ))} +
+ ) : ( +
+ No custom models saved yet. +
+ )} +
+
+ +

Runtime Mode

diff --git a/packages/contracts/src/model.test.ts b/packages/contracts/src/model.test.ts index 1c279983b30f8220cd5e7052aa84d72453f7d258..2f31b6ebe35a5c12c7f6c9dbdec74a051fb39d63 100644 --- a/packages/contracts/src/model.test.ts +++ b/packages/contracts/src/model.test.ts @@ -22,9 +22,14 @@ describe("normalizeModelSlug", () => { }); describe("resolveModelSlug", () => { - it("returns default for missing and unknown models", () => { + it("returns default only when the model is missing", () => { expect(resolveModelSlug(undefined)).toBe(DEFAULT_MODEL); - expect(resolveModelSlug("gpt-4.1")).toBe(DEFAULT_MODEL); + expect(resolveModelSlug(null)).toBe(DEFAULT_MODEL); + }); + + it("preserves unknown custom models", () => { + expect(resolveModelSlug("gpt-4.1")).toBe("gpt-4.1"); + expect(resolveModelSlug("custom/internal-model")).toBe("custom/internal-model"); }); it("resolves only supported model options", () => { diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index e2971eb06bf1c4e0350fae3b4d32ad8f70b5b3a4..cf019e98bd611795c3fab42d257507c64c1f9bfb 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -5,11 +5,12 @@ export const MODEL_OPTIONS = [ { slug: "gpt-5.2", name: "GPT-5.2" }, ] as const; -export type ModelSlug = (typeof MODEL_OPTIONS)[number]["slug"]; +export type BuiltInModelSlug = (typeof MODEL_OPTIONS)[number]["slug"]; +export type ModelSlug = string; export const DEFAULT_MODEL = "gpt-5.3-codex"; -export const MODEL_SLUG_ALIASES: Record = { +export const MODEL_SLUG_ALIASES: Record = { "5.3": "gpt-5.3-codex", "gpt-5.3": "gpt-5.3-codex", "5.3-spark": "gpt-5.3-codex-spark", @@ -26,16 +27,12 @@ export function normalizeModelSlug(model: string | null | undefined): ModelSlug return null; } - return MODEL_SLUG_ALIASES[trimmed] ?? (trimmed as ModelSlug); + return MODEL_SLUG_ALIASES[trimmed] ?? trimmed; } export function resolveModelSlug(model: string | null | undefined): ModelSlug { const normalized = normalizeModelSlug(model); - if (!normalized) { - return DEFAULT_MODEL; - } - - return MODEL_OPTIONS.some((option) => option.slug === normalized) ? normalized : DEFAULT_MODEL; + return normalized ?? DEFAULT_MODEL; } export const REASONING_OPTIONS = ["xhigh", "high", "medium", "low"] as const;