From 98df6761a93dae7d7c8a655a00a00405edc336a1 Mon Sep 17 00:00:00 2001 From: PoZiTiV4ik Date: Wed, 9 Sep 2026 23:56:16 +0300 Subject: [PATCH 1/2] feat: add custom models for Codex and Claude Code Adapt T3 Code custom model definitions and editing to MonoCode provider settings and the composer. Preserve opaque model IDs, persist custom catalogs, and keep model options and defaults in sync. Validation: npm run check:web (1459 tests and TypeScript), plus browser checks using the local Codex model catalog. Desktop build not run. --- NOTICE | 31 +++ README.md | 4 + src/chrome/CustomModelEditor.tsx | 394 +++++++++++++++++++++++++++++ src/chrome/CustomModelsSection.tsx | 270 ++++++++++++++++++++ src/chrome/ModelPicker.tsx | 14 +- src/chrome/ModelSettings.tsx | 11 +- src/lib/customModelEditor.test.ts | 135 ++++++++++ src/lib/customModelEditor.ts | 211 +++++++++++++++ src/lib/customModels.test.ts | 275 ++++++++++++++++++++ src/lib/customModels.ts | 181 +++++++++++++ src/lib/harness/claude.ts | 23 +- src/lib/harness/claudeLive.test.ts | 72 +++++- src/lib/harness/codexLive.test.ts | 23 +- src/lib/harness/registry.test.ts | 57 +++++ src/lib/harness/registry.ts | 23 +- src/lib/models.ts | 176 ++++++++++++- src/surfaces/SettingsView.tsx | 119 +++++---- 17 files changed, 1950 insertions(+), 69 deletions(-) create mode 100644 src/chrome/CustomModelEditor.tsx create mode 100644 src/chrome/CustomModelsSection.tsx create mode 100644 src/lib/customModelEditor.test.ts create mode 100644 src/lib/customModelEditor.ts create mode 100644 src/lib/customModels.test.ts create mode 100644 src/lib/customModels.ts diff --git a/NOTICE b/NOTICE index 42bd0b6d..e38d1d15 100644 --- a/NOTICE +++ b/NOTICE @@ -2,3 +2,34 @@ MonoCode is not affiliated with, endorsed by, or sponsored by the makers of the agent harnesses it can drive. Provider marks that appear in the UI (including Claude, Codex, Cursor, GitHub, GitLab, Linear, Grok, OpenCode, Pi, omp, and fx) are trademarks of their respective owners and are used only to identify those products. + +Custom model management includes code adapted from T3 Code: +https://github.com/pingdotgg/t3code +Reference commit: e16b8b05 +Sources: packages/shared/src/model.ts, +apps/web/src/components/settings/customModelEditor.logic.ts, +apps/web/src/components/settings/CustomModelEditor.tsx, +apps/web/src/components/settings/ProviderModelsSection.tsx, +apps/server/src/provider/Layers/CodexProvider.ts. + +MIT License + +Copyright (c) 2026 T3 Tools Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 476d64f0..e365cec3 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,10 @@ This is very early and you should expect bugs. Small, focused pull requests are welcome. Anything large is worth an issue first - see [CONTRIBUTING.md](CONTRIBUTING.md). +### Custom models + +In Settings → Providers, use **Add model** under Codex or Claude Code to add an exact model ID supported by your CLI configuration. Custom models appear in the model picker and can be favorited or used by default. Expand **Custom models** to edit a display name and composer options (such as reasoning or fast mode), copy options from a discovered model, or remove an entry. Configure authentication and any custom endpoints in the provider's CLI as usual. + ## Build from source Supports macOS, Linux, and Windows. diff --git a/src/chrome/CustomModelEditor.tsx b/src/chrome/CustomModelEditor.tsx new file mode 100644 index 00000000..c2fbcc17 --- /dev/null +++ b/src/chrome/CustomModelEditor.tsx @@ -0,0 +1,394 @@ +// Custom model editing is adapted from T3 Code; see NOTICE. +import { useId, useState } from "react"; +import { + copyModelSettings, + CUSTOM_MODEL_PRESETS, + definitionFromDraft, + draftFromDefinition, + emptyEditorChoice, + emptyEditorSetting, + settingToEditor, + validateCustomModelDraft, + type EditorChoice, + type EditorSetting, +} from "../lib/customModelEditor"; +import type { + CustomModelDefinition, + CustomModelHarness, +} from "../lib/customModels"; +import type { AgentModel } from "../lib/models"; +import { Plus, X } from "./icons"; + +const inputClass = + "min-w-0 rounded-md border border-content/10 bg-content/5 px-2 py-1.5 text-[12px] text-content outline-none placeholder:text-content/30 focus:border-content/30"; +const buttonClass = + "flex items-center justify-center gap-1.5 rounded-md border border-content/10 px-2.5 py-1 text-[12px] text-content/70 hover:bg-content/10 hover:text-content focus-visible:outline focus-visible:outline-accent"; +const iconButtonClass = + "grid size-6 shrink-0 place-items-center rounded-md text-content/40 hover:bg-content/10 hover:text-content focus-visible:outline focus-visible:outline-accent"; +const CUSTOM_OPTION = "__custom__"; + +export function CustomModelEditor({ + harness, + entry, + builtInModels, + onSave, + onCancel, +}: { + harness: CustomModelHarness; + entry: CustomModelDefinition; + builtInModels: AgentModel[]; + onSave: (entry: CustomModelDefinition) => string | null; + onCancel: () => void; +}) { + const id = useId(); + const [draft, setDraft] = useState(() => draftFromDefinition(entry)); + const [error, setError] = useState(null); + const presets = CUSTOM_MODEL_PRESETS[harness]; + const candidates = builtInModels.filter((model) => model.settings?.length); + + const updateSetting = (key: string, patch: Partial) => { + setError(null); + setDraft((current) => ({ + ...current, + settings: current.settings.map((setting) => + setting.key === key ? { ...setting, ...patch } : setting, + ), + })); + }; + + const updateChoice = ( + settingKey: string, + choiceKey: string, + patch: Partial, + ) => { + setError(null); + setDraft((current) => ({ + ...current, + settings: current.settings.map((setting) => + setting.key !== settingKey + ? setting + : { + ...setting, + choices: setting.choices.map((choice) => + choice.key === choiceKey + ? { ...choice, ...patch } + : patch.isDefault + ? { ...choice, isDefault: false } + : choice, + ), + }, + ), + })); + }; + + const addSetting = (setting: EditorSetting) => { + setError(null); + setDraft((current) => ({ + ...current, + settings: [...current.settings, setting], + })); + }; + + return ( +
{ + if (event.key !== "Escape" || event.nativeEvent.isComposing) return; + event.preventDefault(); + event.stopPropagation(); + onCancel(); + }} + onSubmit={(event) => { + event.preventDefault(); + const problem = validateCustomModelDraft(draft); + setError(problem ?? onSave(definitionFromDraft(draft))); + }} + > + + +
+
+ + Options in the composer + + {candidates.length > 0 ? ( + + ) : null} +
+ + {draft.settings.length === 0 ? ( +

+ {harness === "codex" + ? "Uses the options from the Codex catalog until you add your own." + : "Add the options your model supports, or leave it with no extra options."} +

+ ) : null} + + {draft.settings.map((setting, index) => { + const presetId = presets.some((preset) => preset.id === setting.id) + ? setting.id + : CUSTOM_OPTION; + return ( +
+ + Option {index + 1} + +
+ + {presetId === CUSTOM_OPTION ? ( + + updateSetting(setting.key, { id: event.target.value }) + } + /> + ) : null} + + updateSetting(setting.key, { label: event.target.value }) + } + /> + + +
+ + {setting.kind === "toggle" ? ( + + ) : ( +
+ {setting.choices.map((choice, choiceIndex) => ( +
+ + updateChoice(setting.key, choice.key, { + value: event.target.value, + }) + } + /> + + updateChoice(setting.key, choice.key, { + label: event.target.value, + }) + } + /> + + +
+ ))} + +
+ )} +
+ ); + })} + +
+ {presets + .filter( + (preset) => + !draft.settings.some((setting) => setting.id === preset.id), + ) + .map((preset) => ( + + ))} + +
+
+ + {error ? ( +

+ {error} +

+ ) : null} +
+ + +
+
+ ); +} diff --git a/src/chrome/CustomModelsSection.tsx b/src/chrome/CustomModelsSection.tsx new file mode 100644 index 00000000..83ee9b74 --- /dev/null +++ b/src/chrome/CustomModelsSection.tsx @@ -0,0 +1,270 @@ +import { + useEffect, + useId, + useRef, + useState, + useSyncExternalStore, +} from "react"; +import { + MAX_CUSTOM_MODEL_COUNT, + normalizeCustomModelSlug, + type CustomModelHarness, +} from "../lib/customModels"; +import { + addCustomModel, + getModelSnapshot, + loadCustomModels, + providerModelsFor, + removeCustomModel, + subscribeModels, + updateCustomModel, +} from "../lib/models"; +import { HARNESS_TITLE } from "../lib/session"; +import { CustomModelEditor } from "./CustomModelEditor"; +import { ChevronDown, Pencil, Plus, X } from "./icons"; + +const buttonClass = + "flex items-center gap-1.5 rounded-md px-2 py-1 text-[12px] text-content/50 hover:bg-content/10 hover:text-content focus-visible:outline focus-visible:outline-accent"; + +export function CustomModelsSection({ + harness, +}: { + harness: CustomModelHarness; +}) { + const version = useSyncExternalStore( + subscribeModels, + getModelSnapshot, + getModelSnapshot, + ); + const id = useId(); + const entries = loadCustomModels(harness); + const [expanded, setExpanded] = useState(false); + const [adding, setAdding] = useState(false); + const [input, setInput] = useState(""); + const [filter, setFilter] = useState(""); + const [error, setError] = useState(null); + const [editingSlug, setEditingSlug] = useState(null); + const addButton = useRef(null); + const list = useRef(null); + const scrollToSlug = useRef(null); + const showFilter = entries.length > 8; + const needle = showFilter ? filter.trim().toLowerCase() : ""; + const visible = entries.filter((entry) => + `${entry.name} ${entry.slug}`.toLowerCase().includes(needle), + ); + + useEffect(() => { + if (!scrollToSlug.current) return; + const row = list.current?.querySelector( + `[data-custom-model-slug="${CSS.escape(scrollToSlug.current)}"]`, + ); + if (!row) return; + row.scrollIntoView({ block: "nearest" }); + scrollToSlug.current = null; + }, [version]); + + const cancelAdd = () => { + setAdding(false); + setInput(""); + setError(null); + addButton.current?.focus(); + }; + + return ( +
+
+ + +
+ + {expanded ? ( +
+

+ Add a model ID supported by your {HARNESS_TITLE[harness]} CLI + configuration. Models appear in the picker and can be used by + default. +

+ {adding ? ( +
{ + if (event.key !== "Escape" || event.nativeEvent.isComposing) + return; + event.preventDefault(); + event.stopPropagation(); + cancelAdd(); + }} + onSubmit={(event) => { + event.preventDefault(); + const problem = addCustomModel(harness, input); + setError(problem); + if (problem) return; + scrollToSlug.current = normalizeCustomModelSlug(input); + setFilter(""); + cancelAdd(); + }} + > + { + setInput(event.target.value); + setError(null); + }} + /> + + +
+ ) : null} + {error ? ( + + ) : null} + {showFilter ? ( + setFilter(event.target.value)} + /> + ) : null} +
+ {visible.map((entry) => ( +
+
+
+
+ {entry.name} +
+ {entry.name !== entry.slug ? ( +
+ {entry.slug} +
+ ) : null} +
+ + +
+ {editingSlug === entry.slug ? ( + setEditingSlug(null)} + onSave={(next) => { + const problem = updateCustomModel(harness, next); + if (!problem) setEditingSlug(null); + return problem; + }} + /> + ) : null} +
+ ))} + {needle && visible.length === 0 ? ( +

+ No matching models. +

+ ) : null} +
+
+ ) : null} +
+ ); +} diff --git a/src/chrome/ModelPicker.tsx b/src/chrome/ModelPicker.tsx index 7f0baf9b..ce9cd4c9 100644 --- a/src/chrome/ModelPicker.tsx +++ b/src/chrome/ModelPicker.tsx @@ -212,6 +212,10 @@ export function ModelPicker({ if (open) search.current?.focus(); }, [open]); + useEffect(() => { + if (open) setFavorites(loadFavoriteModels()); + }, [open, catalogVersion]); + const visible = useMemo(() => { const needle = query.trim().toLowerCase(); const pool = @@ -226,7 +230,7 @@ export function ModelPicker({ if (!needle) return pool; return pool.filter((item) => { const hay = - `${item.name} ${HARNESS_TITLE[item.harness]} ${HARNESS_LABEL[item.harness]}`.toLowerCase(); + `${item.name} ${item.nativeId ?? ""} ${HARNESS_TITLE[item.harness]} ${HARNESS_LABEL[item.harness]}`.toLowerCase(); return hay.includes(needle); }); // Catalog, install probes, and picker-visibility all feed this list: @@ -536,7 +540,11 @@ function ModelList({ aria-disabled={disabled} disabled={disabled} title={ - disabled ? harnessUnavailableHint(item.harness) : undefined + disabled + ? harnessUnavailableHint(item.harness) + : item.isCustom + ? item.nativeId + : undefined } onMouseDown={(e) => e.preventDefault()} onClick={() => { @@ -558,7 +566,7 @@ function ModelList({ /> {HARNESS_TITLE[item.harness]} ·{" "} - {HARNESS_LABEL[item.harness]} + {item.isCustom ? "Custom" : HARNESS_LABEL[item.harness]} diff --git a/src/chrome/ModelSettings.tsx b/src/chrome/ModelSettings.tsx index 7dd36fd4..158b5e2d 100644 --- a/src/chrome/ModelSettings.tsx +++ b/src/chrome/ModelSettings.tsx @@ -9,6 +9,7 @@ import { import { Popover } from "./Popover"; import { getModelSnapshot, + mergeModelSettings, resolveModel, subscribeModels, type ModelSetting, @@ -56,8 +57,12 @@ export function ModelSettings({ if (settings.length === 0) return null; + const selected = resolveModel(harness, model); + const currentValues = selected.isCustom + ? mergeModelSettings(selected, values) + : values; const setValue = (id: string, value: string) => { - onChange({ ...values, [id]: value }); + onChange({ ...currentValues, [id]: value }); }; return ( @@ -67,14 +72,14 @@ export function ModelSettings({ setValue(setting.id, value)} /> ) : ( setValue(setting.id, value)} onClose={onClose} /> diff --git a/src/lib/customModelEditor.test.ts b/src/lib/customModelEditor.test.ts new file mode 100644 index 00000000..431e4a11 --- /dev/null +++ b/src/lib/customModelEditor.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from "vitest"; +import { + copyModelSettings, + CUSTOM_MODEL_PRESETS, + definitionFromDraft, + draftFromDefinition, + emptyEditorChoice, + emptyEditorSetting, + settingToEditor, + validateCustomModelDraft, + type CustomModelDraft, +} from "./customModelEditor"; +import { readCustomModelEntries, toCustomModelSetting } from "./customModels"; + +const draft = ( + overrides: Partial = {}, +): CustomModelDraft => ({ + slug: "private-model", + name: "", + settings: [], + ...overrides, +}); + +describe("custom model editing", () => { + it("round-trips names, choices and toggle defaults through storage", () => { + const reasoning = settingToEditor(CUSTOM_MODEL_PRESETS.claude[0]); + reasoning.choices = [ + { key: "a", value: " low ", label: "Low", isDefault: false }, + { key: "b", value: " max ", label: "", isDefault: true }, + ]; + const fast = { + ...settingToEditor(CUSTOM_MODEL_PRESETS.claude[1]), + enabled: true, + }; + const edited = draft({ name: " My model ", settings: [reasoning, fast] }); + expect(validateCustomModelDraft(edited)).toBeNull(); + const definition = definitionFromDraft(edited); + expect(definition).toMatchObject({ + name: "My model", + settings: [ + { + id: "effort", + value: "max", + options: [ + { value: "low", label: "Low" }, + { value: "max", label: "max" }, + ], + }, + { id: "fast", value: "true" }, + ], + }); + const [stored] = readCustomModelEntries([toCustomModelSetting(definition)]); + expect(definitionFromDraft(draftFromDefinition(stored))).toEqual( + definition, + ); + }); + + it("copies model options without Claude's built-in context and prompt mappings", () => { + const settings = [ + { + ...CUSTOM_MODEL_PRESETS.claude[0], + options: [ + { value: "high", label: "High" }, + { value: "ultrathink", label: "Ultrathink" }, + ], + value: "ultrathink", + description: "Reasoning level", + }, + { + id: "context", + label: "Context", + kind: "select" as const, + value: "1m", + options: [{ value: "1m", label: "1M" }], + }, + { ...CUSTOM_MODEL_PRESETS.claude[1], value: "true" }, + ]; + const copied = definitionFromDraft( + draft({ settings: copyModelSettings(settings, "claude") }), + ); + expect(copied.settings).toMatchObject([ + { + id: "effort", + value: "high", + options: [{ value: "high", label: "High" }], + description: "Reasoning level", + }, + { id: "fast", value: "true" }, + ]); + expect(settings[0].options).toHaveLength(2); + const codex = definitionFromDraft( + draft({ + settings: copyModelSettings(CUSTOM_MODEL_PRESETS.codex, "codex"), + }), + ); + expect(codex.settings).toEqual(CUSTOM_MODEL_PRESETS.codex); + }); + + it("uses the slug and provider options again when custom fields are cleared", () => { + expect( + toCustomModelSetting(definitionFromDraft(draft({ name: " " }))), + ).toBe("private-model"); + }); + + it("requires unique option IDs, labels and nonempty choices", () => { + const empty = emptyEditorSetting(); + expect(validateCustomModelDraft(draft({ settings: [empty] }))).toContain( + "needs an ID", + ); + empty.id = "effort"; + expect(validateCustomModelDraft(draft({ settings: [empty] }))).toContain( + "needs a label", + ); + empty.label = "Reasoning"; + expect(validateCustomModelDraft(draft({ settings: [empty] }))).toContain( + "at least one choice", + ); + empty.choices = [emptyEditorChoice()]; + expect(validateCustomModelDraft(draft({ settings: [empty] }))).toContain( + "without a value", + ); + empty.choices[0].value = "high"; + empty.choices.push({ ...emptyEditorChoice(), value: " high " }); + expect(validateCustomModelDraft(draft({ settings: [empty] }))).toContain( + 'choice "high" is used twice', + ); + empty.choices.pop(); + expect( + validateCustomModelDraft( + draft({ settings: [empty, { ...empty, id: " effort " }] }), + ), + ).toContain('ID "effort" is used twice'); + expect(validateCustomModelDraft(draft({ settings: [empty] }))).toBeNull(); + }); +}); diff --git a/src/lib/customModelEditor.ts b/src/lib/customModelEditor.ts new file mode 100644 index 00000000..71d008bf --- /dev/null +++ b/src/lib/customModelEditor.ts @@ -0,0 +1,211 @@ +// Adapted from T3 Code's customModelEditor.logic.ts. See NOTICE. +import type { CustomModelDefinition, CustomModelHarness } from "./customModels"; +import type { ModelSetting } from "./models"; + +export type EditorChoice = { + key: string; + value: string; + label: string; + isDefault: boolean; +}; + +export type EditorSetting = { + key: string; + id: string; + label: string; + kind: "select" | "toggle"; + choices: EditorChoice[]; + enabled?: boolean; + description?: string; +}; + +export type CustomModelDraft = { + slug: string; + name: string; + settings: EditorSetting[]; +}; + +const effortChoices = [ + { value: "low", label: "Low" }, + { value: "medium", label: "Medium" }, + { value: "high", label: "High" }, + { value: "xhigh", label: "Extra High" }, +]; + +const toggleChoices = [ + { value: "true", label: "On" }, + { value: "false", label: "Off" }, +]; + +/** IDs and values are the ones MonoCode's adapters read from each turn. */ +export const CUSTOM_MODEL_PRESETS: Record = + { + codex: [ + { + id: "reasoningEffort", + label: "Reasoning", + kind: "select", + value: "medium", + options: effortChoices, + }, + { + id: "serviceTier", + label: "Speed", + kind: "select", + value: "default", + options: [ + { value: "default", label: "Standard" }, + { value: "fast", label: "Fast" }, + ], + }, + ], + claude: [ + { + id: "effort", + label: "Reasoning", + kind: "select", + value: "high", + options: [...effortChoices, { value: "max", label: "Max" }], + }, + { + id: "fast", + label: "Fast mode", + kind: "toggle", + value: "false", + options: toggleChoices, + }, + { + id: "thinking", + label: "Thinking", + kind: "toggle", + value: "false", + options: toggleChoices, + }, + ], + }; + +let nextKey = 0; +function newKey(): string { + return `custom-model-${++nextKey}`; +} + +export function emptyEditorChoice(): EditorChoice { + return { key: newKey(), value: "", label: "", isDefault: false }; +} + +export function emptyEditorSetting(): EditorSetting { + return { key: newKey(), id: "", label: "", kind: "select", choices: [] }; +} + +export function settingToEditor(setting: ModelSetting): EditorSetting { + return { + key: newKey(), + id: setting.id, + label: setting.label, + kind: setting.kind, + enabled: setting.value === "true", + description: setting.description, + choices: + setting.kind === "select" + ? setting.options.map((option) => ({ + key: newKey(), + ...option, + isDefault: option.value === setting.value, + })) + : [], + }; +} + +export function draftFromDefinition( + entry: CustomModelDefinition, +): CustomModelDraft { + return { + slug: entry.slug, + name: entry.name === entry.slug ? "" : entry.name, + settings: (entry.settings ?? []).map(settingToEditor), + }; +} + +/** Custom Claude models do not inherit built-in context or prompt mappings. */ +export function copyModelSettings( + settings: ModelSetting[], + harness: CustomModelHarness, +): EditorSetting[] { + return settings + .filter((setting) => harness !== "claude" || setting.id !== "context") + .map((setting) => { + if (harness !== "claude" || setting.id !== "effort") + return settingToEditor(setting); + const options = setting.options.filter( + (option) => option.value !== "ultrathink", + ); + return settingToEditor({ + ...setting, + options, + value: options.some((option) => option.value === setting.value) + ? setting.value + : (options[0]?.value ?? ""), + }); + }); +} + +export function validateCustomModelDraft( + draft: CustomModelDraft, +): string | null { + const seen = new Set(); + for (const [index, setting] of draft.settings.entries()) { + const position = `Option ${index + 1}`; + const id = setting.id.trim(); + if (!id) return `${position} needs an ID.`; + if (seen.has(id)) return `${position}: ID "${id}" is used twice.`; + seen.add(id); + if (!setting.label.trim()) return `${position} needs a label.`; + if (setting.kind !== "select") continue; + if (setting.choices.length === 0) + return `${position} needs at least one choice.`; + const choices = new Set(); + for (const choice of setting.choices) { + const value = choice.value.trim(); + if (!value) return `${position} has a choice without a value.`; + if (choices.has(value)) + return `${position}: choice "${value}" is used twice.`; + choices.add(value); + } + } + return null; +} + +export function definitionFromDraft( + draft: CustomModelDraft, +): CustomModelDefinition { + const settings = draft.settings.map((setting): ModelSetting => { + const options = + setting.kind === "toggle" + ? toggleChoices + : setting.choices.map((choice) => ({ + value: choice.value.trim(), + label: choice.label.trim() || choice.value.trim(), + })); + const value = + setting.kind === "toggle" + ? String(setting.enabled ?? false) + : (setting.choices.find((choice) => choice.isDefault)?.value.trim() ?? + options[0]?.value ?? + ""); + return { + id: setting.id.trim(), + label: setting.label.trim(), + kind: setting.kind, + value, + options, + ...(setting.description !== undefined + ? { description: setting.description } + : {}), + }; + }); + return { + slug: draft.slug, + name: draft.name.trim() || draft.slug, + settings: settings.length > 0 ? settings : null, + }; +} diff --git a/src/lib/customModels.test.ts b/src/lib/customModels.test.ts new file mode 100644 index 00000000..387a3919 --- /dev/null +++ b/src/lib/customModels.test.ts @@ -0,0 +1,275 @@ +// @vitest-environment happy-dom +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + customModelId, + MAX_CUSTOM_MODEL_COUNT, + MAX_CUSTOM_MODEL_LENGTH, + readCustomModelEntries, + type CustomModelHarness, +} from "./customModels"; +import { CUSTOM_MODEL_PRESETS } from "./customModelEditor"; +import { + addCustomModel, + allModels, + defaultSessionChoice, + findModel, + getModelSnapshot, + hasLiveCatalog, + loadCustomModels, + loadDefaultModels, + loadFavoriteModels, + modelsFor, + nativeModelId, + preferredModelSettings, + removeCustomModel, + resetHarnessModelOverlays, + resolveModel, + saveFavoriteModels, + saveLastModelChoice, + setHarnessModels, + subscribeModels, + updateCustomModel, + type AgentModel, +} from "./models"; + +const codex: AgentModel = { + id: "codex:gpt-catalog", + harness: "codex", + nativeId: "gpt-catalog", + name: "Catalog model", + settings: CUSTOM_MODEL_PRESETS.codex, +}; + +beforeEach(() => { + const data = new Map(); + vi.stubGlobal("localStorage", { + getItem: (key: string) => data.get(key) ?? null, + setItem: (key: string, value: string) => { + data.set(key, value); + }, + removeItem: (key: string) => { + data.delete(key); + }, + clear: () => data.clear(), + }); + resetHarnessModelOverlays(); +}); + +afterEach(() => { + vi.restoreAllMocks(); + localStorage.clear(); + resetHarnessModelOverlays(); + vi.unstubAllGlobals(); +}); + +describe("custom model catalogs", () => { + it.each(["claude", "codex"])( + "preserves opaque %s IDs through persistence and resolution", + (harness) => { + const slug = "Vendor/Model:Preview[1m]"; + const id = customModelId(harness, slug); + expect(addCustomModel(harness, ` ${slug} `)).toBeNull(); + saveLastModelChoice(harness, id); + resetHarnessModelOverlays(); + + expect(loadCustomModels(harness)).toEqual([ + { slug, name: slug, settings: null }, + ]); + expect(resolveModel(harness, defaultSessionChoice().model)).toMatchObject( + { id, harness, nativeId: slug, isCustom: true }, + ); + expect(nativeModelId(id)).toBe(slug); + expect(allModels().filter((model) => model.id === id)).toHaveLength(1); + }, + ); + + it("allows custom Claude slugs that collide with MonoCode's shortened built-in IDs", () => { + expect(addCustomModel("claude", "sonnet-5")).toBeNull(); + expect(nativeModelId("claude:sonnet-5")).toBe("claude-sonnet-5"); + expect(nativeModelId(customModelId("claude", "sonnet-5"))).toBe("sonnet-5"); + }); + + it("keeps providers separate and survives catalog refreshes", () => { + addCustomModel("claude", "private-model"); + addCustomModel("codex", "private-model"); + expect(hasLiveCatalog("codex")).toBe(false); + setHarnessModels("codex", [codex]); + expect(hasLiveCatalog("codex")).toBe(true); + expect(modelsFor("codex").map((model) => model.nativeId)).toEqual([ + "gpt-catalog", + "private-model", + ]); + expect(loadCustomModels("claude")).toHaveLength(1); + expect( + preferredModelSettings( + resolveModel("codex", customModelId("codex", "private-model")), + ), + ).toEqual({ reasoningEffort: "medium", serviceTier: "default" }); + expect( + preferredModelSettings( + resolveModel("claude", customModelId("claude", "private-model")), + ), + ).toEqual({}); + }); + + it("lets an exact CLI model take precedence without resurrecting removed custom entries", () => { + addCustomModel("codex", "gpt-catalog"); + setHarnessModels("codex", [codex]); + expect(modelsFor("codex")).toEqual([codex]); + expect(resolveModel("codex", customModelId("codex", "gpt-catalog"))).toBe( + codex, + ); + removeCustomModel("codex", "gpt-catalog"); + setHarnessModels("codex", [{ ...codex, id: "codex:new", nativeId: "new" }]); + expect(modelsFor("codex").map((model) => model.nativeId)).toEqual(["new"]); + }); + + it("preserves custom names and options over Codex defaults after a reload", () => { + addCustomModel("codex", "private-model"); + addCustomModel("claude", "other-model"); + const settings = [{ ...CUSTOM_MODEL_PRESETS.codex[0], value: "xhigh" }]; + expect( + updateCustomModel("codex", { + slug: "private-model", + name: "My reasoning model", + settings, + }), + ).toBeNull(); + resetHarnessModelOverlays(); + setHarnessModels("codex", [codex]); + const model = resolveModel( + "codex", + customModelId("codex", "private-model"), + ); + expect(model.name).toBe("My reasoning model"); + expect(preferredModelSettings(model)).toEqual({ reasoningEffort: "xhigh" }); + expect(loadCustomModels("claude")).toHaveLength(1); + }); + + it("invalidates cached catalogs and notifies subscribers on local and cross-window edits", () => { + const before = modelsFor("codex"); + expect(modelsFor("codex")).toBe(before); + const changed = vi.fn(); + const unsubscribe = subscribeModels(changed); + try { + addCustomModel("codex", "first"); + expect(changed).toHaveBeenCalledTimes(1); + expect(modelsFor("codex")).not.toBe(before); + const current = modelsFor("codex"); + expect(modelsFor("codex")).toBe(current); + + localStorage.setItem( + "monocode.customModels", + JSON.stringify({ codex: ["second"] }), + ); + window.dispatchEvent( + new StorageEvent("storage", { key: "monocode.customModels" }), + ); + expect(modelsFor("codex").map((model) => model.nativeId)).toEqual([ + "second", + ]); + expect(findModel(customModelId("codex", "first"))).toBeUndefined(); + expect(changed).toHaveBeenCalledTimes(2); + + localStorage.clear(); + window.dispatchEvent(new StorageEvent("storage", { key: null })); + expect(modelsFor("codex")).toEqual([]); + } finally { + unsubscribe(); + } + }); + + it("removes defaults and favorites without changing the model of an open session", () => { + setHarnessModels("codex", [codex]); + const slug = "gpt-catalog:private[preview]"; + const id = customModelId("codex", slug); + addCustomModel("codex", slug); + saveLastModelChoice("codex", id); + saveFavoriteModels([id, codex.id]); + + expect(removeCustomModel("codex", slug)).toBeNull(); + expect(loadDefaultModels().codex).toBe(codex.id); + expect(defaultSessionChoice()).toEqual({ + harness: "codex", + model: codex.id, + }); + expect(loadFavoriteModels()).toEqual([codex.id]); + expect(findModel(id)).toBeUndefined(); + expect(resolveModel("codex", id)).toMatchObject({ + id, + harness: "codex", + nativeId: slug, + }); + expect(nativeModelId(id)).toBe(slug); + expect( + updateCustomModel("codex", { slug, name: slug, settings: null }), + ).toContain("removed"); + }); +}); + +describe("custom model validation", () => { + it("rejects empty, duplicate, discovered and overlong IDs", () => { + setHarnessModels("codex", [codex]); + expect(addCustomModel("codex", " \n ")).toContain("Enter a model ID"); + expect(addCustomModel("codex", "gpt-catalog")).toContain( + "already provided", + ); + expect(addCustomModel("codex", "private")).toBeNull(); + expect(addCustomModel("codex", " private ")).toContain("already saved"); + expect( + addCustomModel("codex", "x".repeat(MAX_CUSTOM_MODEL_LENGTH + 1)), + ).toContain("256"); + expect( + addCustomModel("codex", "x".repeat(MAX_CUSTOM_MODEL_LENGTH)), + ).toBeNull(); + expect(loadCustomModels("codex")).toHaveLength(2); + }); + + it("limits each provider to 32 custom models", () => { + for (let index = 0; index < MAX_CUSTOM_MODEL_COUNT; index++) { + expect(addCustomModel("codex", `model-${index}`)).toBeNull(); + } + expect(addCustomModel("codex", "overflow")).toContain("32"); + expect(addCustomModel("claude", "overflow")).toBeNull(); + removeCustomModel("codex", "model-0"); + expect(addCustomModel("codex", "replacement")).toBeNull(); + }); + + it("tolerates malformed storage and drops invalid option definitions", () => { + expect( + readCustomModelEntries([ + null, + 10, + [], + " ", + { slug: false }, + " first ", + { slug: "first", name: "Duplicate" }, + { slug: "second", name: " Friendly name ", settings: "invalid" }, + { slug: "third", settings: [{ id: "effort", kind: "select" }] }, + "x".repeat(MAX_CUSTOM_MODEL_LENGTH + 1), + ]), + ).toEqual([ + { slug: "first", name: "first", settings: null }, + { slug: "second", name: "Friendly name", settings: null }, + { slug: "third", name: "third", settings: null }, + ]); + localStorage.setItem("monocode.customModels", "not JSON"); + expect(loadCustomModels("codex")).toEqual([]); + expect(addCustomModel("codex", "recovered")).toBeNull(); + }); + + it("reports a failed write without publishing unsaved models", () => { + addCustomModel("codex", "saved"); + const before = getModelSnapshot(); + vi.spyOn(localStorage, "setItem").mockImplementation(() => { + throw new Error("Quota exceeded"); + }); + expect(addCustomModel("codex", "unsaved")).toContain("Could not save"); + expect(removeCustomModel("codex", "saved")).toContain("Could not save"); + expect(loadCustomModels("codex").map((entry) => entry.slug)).toEqual([ + "saved", + ]); + expect(getModelSnapshot()).toBe(before); + }); +}); diff --git a/src/lib/customModels.ts b/src/lib/customModels.ts new file mode 100644 index 00000000..de742a9a --- /dev/null +++ b/src/lib/customModels.ts @@ -0,0 +1,181 @@ +// Adapted from T3 Code's custom model helpers. See NOTICE for attribution. +import type { AgentModel, ModelSetting } from "./models"; +import type { HarnessId } from "./session"; + +export const CUSTOM_MODEL_HARNESSES = ["claude", "codex"] as const; +export type CustomModelHarness = (typeof CUSTOM_MODEL_HARNESSES)[number]; +export const MAX_CUSTOM_MODEL_COUNT = 32; +export const MAX_CUSTOM_MODEL_LENGTH = 256; + +export type CustomModelDefinition = { + slug: string; + name: string; + /** null uses the provider's default options. */ + settings: ModelSetting[] | null; +}; + +type CustomModelSetting = + string | { slug: string; name?: string; settings?: ModelSetting[] }; + +export function supportsCustomModels( + harness: HarnessId, +): harness is CustomModelHarness { + return harness === "claude" || harness === "codex"; +} + +/** Provider-owned identifiers are only trimmed, never expanded as aliases. */ +export function normalizeCustomModelSlug( + model: string | null | undefined, +): string | null { + return typeof model === "string" ? model.trim() || null : null; +} + +/** Keep custom slugs separate from MonoCode's shortened built-in ids. */ +export function customModelId( + harness: CustomModelHarness, + slug: string, +): string { + return `${harness}:custom:${slug}`; +} + +export function customModelSlug(id: string): string | null { + for (const harness of CUSTOM_MODEL_HARNESSES) { + const prefix = `${harness}:custom:`; + if (id.startsWith(prefix)) return id.slice(prefix.length); + } + return null; +} + +export function readCustomModelEntries( + value: unknown, +): CustomModelDefinition[] { + if (!Array.isArray(value)) return []; + const entries: CustomModelDefinition[] = []; + const seen = new Set(); + for (const raw of value) { + const record = typeof raw === "string" ? { slug: raw } : asRecord(raw); + if (!record) continue; + const slug = normalizeCustomModelSlug( + typeof record.slug === "string" ? record.slug : null, + ); + if (!slug || slug.length > MAX_CUSTOM_MODEL_LENGTH || seen.has(slug)) + continue; + seen.add(slug); + const name = + normalizeCustomModelSlug( + typeof record.name === "string" ? record.name : null, + ) ?? slug; + entries.push({ slug, name, settings: readModelSettings(record.settings) }); + if (entries.length >= MAX_CUSTOM_MODEL_COUNT) break; + } + return entries; +} + +export function toCustomModelSetting( + entry: CustomModelDefinition, +): CustomModelSetting { + const settings = entry.settings ?? []; + const name = entry.name !== entry.slug ? entry.name : undefined; + if (!name && settings.length === 0) return entry.slug; + return { + slug: entry.slug, + ...(name ? { name } : {}), + ...(settings.length > 0 ? { settings } : {}), + }; +} + +/** Discovered models win; bare Codex entries inherit the catalog's options. */ +export function appendCustomModels( + harness: CustomModelHarness, + models: AgentModel[], + entries: CustomModelDefinition[], +): AgentModel[] { + if (entries.length === 0) return models; + const seen = new Set( + models.map((model) => model.nativeId ?? model.id.slice(harness.length + 1)), + ); + const fallbackSettings = + harness === "codex" + ? models.find((model) => model.settings?.length)?.settings + : undefined; + const custom: AgentModel[] = []; + for (const entry of entries) { + if (seen.has(entry.slug)) continue; + seen.add(entry.slug); + const settings = entry.settings ?? fallbackSettings; + custom.push({ + id: customModelId(harness, entry.slug), + harness, + nativeId: entry.slug, + name: entry.name, + isCustom: true, + ...(settings?.length ? { settings } : {}), + }); + } + return custom.length ? [...models, ...custom] : models; +} + +function asRecord(value: unknown): Record | null { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +/** Invalid options do not make an otherwise usable model disappear. */ +function readModelSettings(value: unknown): ModelSetting[] | null { + if (!Array.isArray(value) || value.length === 0) return null; + const settings: ModelSetting[] = []; + const seen = new Set(); + for (const raw of value) { + const row = asRecord(raw); + if ( + !row || + typeof row.id !== "string" || + !row.id.trim() || + typeof row.label !== "string" || + !row.label.trim() || + typeof row.value !== "string" || + (row.kind !== "select" && row.kind !== "toggle") || + !Array.isArray(row.options) || + row.options.length === 0 + ) + return null; + const id = row.id.trim(); + if (seen.has(id)) return null; + seen.add(id); + const options: ModelSetting["options"] = []; + const choices = new Set(); + for (const rawOption of row.options) { + const option = asRecord(rawOption); + if (!option || typeof option.value !== "string" || !option.value.trim()) + return null; + const optionValue = option.value.trim(); + if (choices.has(optionValue)) return null; + choices.add(optionValue); + options.push({ + value: optionValue, + label: + typeof option.label === "string" + ? option.label.trim() || optionValue + : optionValue, + }); + } + if (!choices.has(row.value)) return null; + if ( + row.kind === "toggle" && + (!choices.has("true") || !choices.has("false")) + ) + return null; + settings.push({ + id, + label: row.label.trim(), + kind: row.kind, + value: row.value, + options, + ...(typeof row.description === "string" + ? { description: row.description } + : {}), + }); + } + return settings; +} diff --git a/src/lib/harness/claude.ts b/src/lib/harness/claude.ts index 0df50e0b..9238ef07 100644 --- a/src/lib/harness/claude.ts +++ b/src/lib/harness/claude.ts @@ -1,4 +1,5 @@ import { nativeModelId } from "../models"; +import { customModelSlug } from "../customModels"; import type { RuntimeMode } from "../session"; import { loadClaudeHooks } from "../settings"; import { @@ -230,7 +231,10 @@ export async function steerClaudeTurn(input: SteerTurnInput): Promise { const message = buildClaudeUserMessage({ text: input.text, attachments: input.attachments, - effort: input.modelSettings?.effort, + effort: + customModelSlug(input.model) === null + ? input.modelSettings?.effort + : undefined, }); const content = (message.message as { content: unknown[] }).content; if (content.length === 0) return; @@ -441,7 +445,10 @@ async function ensureLive(input: HarnessSessionInput): Promise { } async function runTurn(live: Live, input: SendTurnInput): Promise { - const effort = input.modelSettings?.effort; + const effort = + customModelSlug(input.model) === null + ? input.modelSettings?.effort + : undefined; const message = buildClaudeUserMessage({ text: input.text, attachments: input.attachments, @@ -1171,7 +1178,10 @@ function writeJson( function settingsKeyFor(input: HarnessSessionInput): string { return claudeSettingsKey({ - model: nativeModelId(input.model), + model: + customModelSlug(input.model) === null + ? nativeModelId(input.model) + : input.model, effort: input.modelSettings?.effort, fast: input.modelSettings?.fast, thinking: input.modelSettings?.thinking, @@ -1194,6 +1204,7 @@ function launchOptions( settings?: ClaudeCliSettings; } { const native = nativeModelId(input.model); + const isCustom = customModelSlug(input.model) !== null; const effortRaw = input.modelSettings?.effort; const context = input.modelSettings?.context; const settings: ClaudeCliSettings = {}; @@ -1203,15 +1214,15 @@ function launchOptions( if (input.modelSettings?.fast === "true") { settings.fastMode = true; } - if (isClaudeUltracodeEffort(effortRaw)) { + if (!isCustom && isClaudeUltracodeEffort(effortRaw)) { settings.ultracode = true; } if (!loadClaudeHooks()) { settings.disableAllHooks = true; } return { - model: resolveClaudeApiModelId(native, context), - effort: normalizeClaudeCliEffort(effortRaw, native), + model: isCustom ? native : resolveClaudeApiModelId(native, context), + effort: isCustom ? effortRaw : normalizeClaudeCliEffort(effortRaw, native), permissionMode: input.intent === "plan" ? "plan" diff --git a/src/lib/harness/claudeLive.test.ts b/src/lib/harness/claudeLive.test.ts index 2bd15ed2..7b12a5b0 100644 --- a/src/lib/harness/claudeLive.test.ts +++ b/src/lib/harness/claudeLive.test.ts @@ -1,11 +1,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { customModelId } from "../customModels"; const sent: string[] = []; +const spawned: string[][] = []; let onLine: ((line: string) => void) | undefined; vi.mock("./child", () => ({ resolveClaudeBinary: async () => ({ path: "/fake/claude" }), - spawnChild: async () => undefined, + spawnChild: async (_id: string, _path: string, args: string[]) => { + spawned.push(args); + }, killChild: async () => undefined, unwatchChild: () => undefined, watchChild: (_id: string, line: (l: string) => void) => { @@ -19,6 +23,7 @@ vi.mock("./child", () => ({ const { compactClaudeContext, sendClaudeTurn, + steerClaudeTurn, stopClaudeSession, __claudeTestReset, } = await import("./claude"); @@ -45,14 +50,19 @@ const waitFor = async (pred: () => boolean, label: string) => { async function startTurn( sessionId: string, - options: { runtimeMode?: RuntimeMode; intent?: TurnIntent } = {}, + options: { + runtimeMode?: RuntimeMode; + intent?: TurnIntent; + model?: string; + modelSettings?: Record; + } = {}, ) { const events: HarnessEvent[] = []; const turn = sendClaudeTurn({ sessionId, cwd: "/repo", - model: "claude:claude-sonnet-5", - modelSettings: {}, + model: options.model ?? "claude:claude-sonnet-5", + modelSettings: options.modelSettings ?? {}, runtimeMode: options.runtimeMode ?? "supervised", intent: options.intent, text: "explore the codebase", @@ -79,6 +89,7 @@ async function startTurn( beforeEach(() => { sent.length = 0; + spawned.length = 0; onLine = undefined; __claudeTestReset(); }); @@ -310,6 +321,59 @@ describe("claude plan permissions", () => { }); }); +describe("claude custom models", () => { + it.each(["sonnet-5", "vendor/claude:preview[1m]"])( + "passes %s and custom options to the CLI verbatim", + async (slug) => { + const { turn } = await startTurn("s1", { + model: customModelId("claude", slug), + modelSettings: { + effort: "xhigh", + fast: "true", + thinking: "true", + context: "1m", + }, + }); + const args = spawned[0]; + expect(args[args.indexOf("--model") + 1]).toBe(slug); + expect(args[args.indexOf("--effort") + 1]).toBe("xhigh"); + expect(JSON.parse(args[args.indexOf("--settings") + 1])).toMatchObject({ + fastMode: true, + alwaysThinkingEnabled: true, + }); + emit({ type: "result", subtype: "success", session_id: "sess_1" }); + await turn; + }, + ); + + it("does not inject built-in prompt behavior for a custom effort value, including steering", async () => { + const model = customModelId("claude", "private-model"); + const { turn } = await startTurn("s1", { + model, + modelSettings: { effort: "ultrathink" }, + }); + const args = spawned[0]; + expect(args[args.indexOf("--effort") + 1]).toBe("ultrathink"); + expect(parse().find((message) => message.type === "user")).toMatchObject({ + message: { content: [{ type: "text", text: "explore the codebase" }] }, + }); + await steerClaudeTurn({ + sessionId: "s1", + cwd: "/repo", + model, + modelSettings: { effort: "ultrathink" }, + text: "include tests", + }); + expect( + parse().filter((message) => message.type === "user")[1], + ).toMatchObject({ + message: { content: [{ type: "text", text: "include tests" }] }, + }); + emit({ type: "result", subtype: "success", session_id: "sess_1" }); + await turn; + }); +}); + describe("claude manual compaction", () => { it("runs the built-in command and requires a compact boundary", async () => { const { turn } = await startTurn("s1"); diff --git a/src/lib/harness/codexLive.test.ts b/src/lib/harness/codexLive.test.ts index a1f5f07a..b8ee4c81 100644 --- a/src/lib/harness/codexLive.test.ts +++ b/src/lib/harness/codexLive.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { customModelId } from "../customModels"; const sent: string[] = []; let onLine: ((line: string) => void) | undefined; @@ -52,14 +53,16 @@ async function startTurn( options: { runtimeMode?: RuntimeMode; intent?: TurnIntent; + model?: string; + modelSettings?: Record; } = {}, ) { const events: HarnessEvent[] = []; const turn = sendCodexTurn({ sessionId, cwd: "/repo", - model: "codex:gpt-5.4", - modelSettings: {}, + model: options.model ?? "codex:gpt-5.4", + modelSettings: options.modelSettings ?? {}, runtimeMode: options.runtimeMode ?? "supervised", intent: options.intent, text: "summarize the changelog", @@ -102,6 +105,22 @@ describe("codex live turn sequence", () => { __codexTestReset(); }); + it("sends opaque custom IDs and options in thread and turn requests", async () => { + const slug = "gateway/gpt:preview[private]"; + const { turn } = await startTurn("codex-live", { + model: customModelId("codex", slug), + modelSettings: { reasoningEffort: "xhigh", serviceTier: "fast" }, + }); + expect( + parse().find((message) => message.method === "thread/start")?.params, + ).toMatchObject({ model: slug, serviceTier: "fast" }); + expect( + parse().find((message) => message.method === "turn/start")?.params, + ).toMatchObject({ model: slug, effort: "xhigh", serviceTier: "fast" }); + notify("turn/completed", { turn: { id: "turn_1", status: "completed" } }); + await turn; + }); + it("stays busy after an agent message until turn/completed", async () => { const { events, turn } = await startTurn("codex-live"); let settled = false; diff --git a/src/lib/harness/registry.test.ts b/src/lib/harness/registry.test.ts index 9ae847ae..01662026 100644 --- a/src/lib/harness/registry.test.ts +++ b/src/lib/harness/registry.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { resetHarnessModelOverlays, setHarnessModels } from "../models"; +import { customModelId, type CustomModelHarness } from "../customModels"; import type { HarnessId } from "../session"; import { HARNESS_IDLE_PARK_MS, @@ -56,6 +57,62 @@ describe("harness registry", () => { ).toEqual(["claude", "codex", "cursor"]); }); + it.each<[CustomModelHarness, string]>([ + ["codex", "reasoningEffort"], + ["claude", "effort"], + ])( + "applies edited %s options to existing sessions before sending or compacting", + async (harness, option) => { + const sendTurn = vi.fn(async () => undefined); + const compactContext = vi.fn(async () => undefined); + registerHarness(stub(harness, { sendTurn, compactContext })); + const model = customModelId(harness, "private-model"); + setHarnessModels(harness, [ + { + id: model, + harness, + nativeId: "private-model", + name: "Private", + isCustom: true, + settings: [ + { + id: option, + label: "Reasoning", + kind: "select", + value: "high", + options: [ + { value: "low", label: "Low" }, + { value: "high", label: "High" }, + ], + }, + ], + }, + ]); + const input = { + harness, + sessionId: "edited-custom", + cwd: "/tmp", + model, + modelSettings: { [option]: "removed-value", removedOption: "true" }, + text: "hi", + runtimeMode: "supervised" as const, + onEvent: () => undefined, + }; + await sendHarnessTurn(input); + await compactHarnessContext(input); + expect(sendTurn).toHaveBeenCalledWith( + expect.objectContaining({ modelSettings: { [option]: "high" } }), + ); + expect(compactContext).toHaveBeenCalledWith( + expect.objectContaining({ modelSettings: { [option]: "high" } }), + ); + await sendHarnessTurn({ ...input, modelSettings: { [option]: "low" } }); + expect(sendTurn).toHaveBeenLastCalledWith( + expect.objectContaining({ modelSettings: { [option]: "low" } }), + ); + }, + ); + it("advertises and dispatches compaction only when an adapter supports it", async () => { const compactContext = vi.fn(async () => undefined); registerHarness(stub("codex", { compactContext })); diff --git a/src/lib/harness/registry.ts b/src/lib/harness/registry.ts index 6e15caa1..9eacec89 100644 --- a/src/lib/harness/registry.ts +++ b/src/lib/harness/registry.ts @@ -1,7 +1,7 @@ import type { HarnessId } from "../session"; import type { GeneratedSessionTitle } from "../sessionTitle"; import type { PrContent } from "../gitText"; -import { hasLiveCatalog } from "../models"; +import { findModel, hasLiveCatalog, mergeModelSettings } from "../models"; import type { UserQuestionReply } from "../userQuestion"; import type { NativeCommandProvider } from "./nativeCommands"; import type { @@ -130,7 +130,16 @@ export async function sendHarnessTurn( } cancelIdlePark(input.sessionId); try { - await adapter.sendTurn(input); + // Settings can be edited after a session selects a custom model. + const model = findModel(input.model); + await adapter.sendTurn( + model?.isCustom + ? { + ...input, + modelSettings: mergeModelSettings(model, input.modelSettings), + } + : input, + ); } finally { scheduleIdlePark(input.harness, input.sessionId); } @@ -153,7 +162,15 @@ export async function compactHarnessContext( } cancelIdlePark(input.sessionId); try { - await adapter.compactContext(input); + const model = findModel(input.model); + await adapter.compactContext( + model?.isCustom + ? { + ...input, + modelSettings: mergeModelSettings(model, input.modelSettings), + } + : input, + ); } finally { scheduleIdlePark(input.harness, input.sessionId); } diff --git a/src/lib/models.ts b/src/lib/models.ts index a5a9ef1d..0eb65405 100644 --- a/src/lib/models.ts +++ b/src/lib/models.ts @@ -1,5 +1,19 @@ import type { HarnessId } from "./session"; import { HARNESSES } from "./session"; +import { + appendCustomModels, + CUSTOM_MODEL_HARNESSES, + customModelId, + customModelSlug, + MAX_CUSTOM_MODEL_COUNT, + MAX_CUSTOM_MODEL_LENGTH, + normalizeCustomModelSlug, + readCustomModelEntries, + supportsCustomModels, + toCustomModelSetting, + type CustomModelDefinition, + type CustomModelHarness, +} from "./customModels"; export type ModelSettingChoice = { value: string; @@ -21,6 +35,7 @@ export type AgentModel = { name: string; nativeId?: string; settings?: ModelSetting[]; + isCustom?: boolean; /** Context window, when the harness catalog reports one. */ contextWindow?: number; }; @@ -189,6 +204,7 @@ const HIDDEN_PICKER_PROVIDERS_KEY = "monocode.hiddenPickerProviders"; const LAST_MODEL_KEY = "monocode.lastModel"; const LAST_MODEL_SETTINGS_KEY = "monocode.lastModelSettings"; const DEFAULT_MODELS_KEY = "monocode.defaultModels"; +const CUSTOM_MODELS_KEY = "monocode.customModels"; export type ModelPickerTab = "favorites" | HarnessId; @@ -209,6 +225,9 @@ const HARNESS_ORDER: HarnessId[] = [ ]; const EMPTY_MODELS: AgentModel[] = []; +const EMPTY_CUSTOM_MODELS: CustomModelDefinition[] = []; +type CustomModelsStore = Partial>; +let customModelsStore: CustomModelsStore | null = null; let overlays: Partial> = {}; let overlayDefaults: Partial> = {}; @@ -218,6 +237,7 @@ const listeners = new Set<() => void>(); function emit() { catalogVersion += 1; baseByHarness = null; + mergedByHarness = {}; indexById = null; allCache = null; for (const listener of listeners) listener(); @@ -249,10 +269,11 @@ export function hasLiveCatalog(harness: HarnessId): boolean { return overlays[harness] != null; } -/** Test seam. */ +/** Test seam: clear live catalogs and reload persisted custom models. */ export function resetHarnessModelOverlays() { overlays = {}; overlayDefaults = {}; + customModelsStore = null; emit(); } @@ -264,6 +285,7 @@ export function defaultModelId(harness: HarnessId): string { // provider row, the picker itself), so they must not rebuild the catalog on // each call. These caches are dropped in `emit()` whenever an overlay lands. let baseByHarness: Partial> | null = null; +let mergedByHarness: Partial> = {}; let allCache: AgentModel[] | null = null; let indexById: Map | null = null; @@ -278,10 +300,154 @@ function baseModelsFor(harness: HarnessId): AgentModel[] { return baseByHarness[harness] ?? EMPTY_MODELS; } -export function modelsFor(harness: HarnessId): AgentModel[] { +/** The provider catalog before user-authored entries are appended. */ +export function providerModelsFor(harness: HarnessId): AgentModel[] { return overlays[harness] ?? baseModelsFor(harness); } +export function modelsFor(harness: HarnessId): AgentModel[] { + const catalog = providerModelsFor(harness); + if (!supportsCustomModels(harness)) return catalog; + return (mergedByHarness[harness] ??= appendCustomModels( + harness, + catalog, + loadCustomModels(harness), + )); +} + +function loadCustomModelsStore(): CustomModelsStore { + if (customModelsStore) return customModelsStore; + const next: CustomModelsStore = {}; + try { + const raw: unknown = JSON.parse( + localStorage.getItem(CUSTOM_MODELS_KEY) ?? "{}", + ); + if (raw && typeof raw === "object" && !Array.isArray(raw)) { + for (const harness of CUSTOM_MODEL_HARNESSES) { + next[harness] = readCustomModelEntries( + (raw as Record)[harness], + ); + } + } + } catch { + // Missing storage or malformed settings leave the CLI catalog usable. + } + return (customModelsStore = next); +} + +export function loadCustomModels( + harness: CustomModelHarness, +): CustomModelDefinition[] { + return loadCustomModelsStore()[harness] ?? EMPTY_CUSTOM_MODELS; +} + +function persistCustomModels( + harness: CustomModelHarness, + entries: CustomModelDefinition[], +): string | null { + const next = { ...loadCustomModelsStore(), [harness]: entries }; + try { + localStorage.setItem( + CUSTOM_MODELS_KEY, + JSON.stringify( + Object.fromEntries( + CUSTOM_MODEL_HARNESSES.map((id) => [ + id, + (next[id] ?? []).map(toCustomModelSetting), + ]), + ), + ), + ); + } catch { + return "Could not save custom models. Local storage may be full or unavailable."; + } + customModelsStore = next; + return null; +} + +export function addCustomModel( + harness: CustomModelHarness, + input: string, +): string | null { + const slug = normalizeCustomModelSlug(input); + if (!slug) return "Enter a model ID."; + if ( + providerModelsFor(harness).some((model) => nativeModelId(model) === slug) + ) { + return "That model is already provided by the CLI."; + } + if (slug.length > MAX_CUSTOM_MODEL_LENGTH) { + return `Model IDs must be ${MAX_CUSTOM_MODEL_LENGTH} characters or less.`; + } + const entries = loadCustomModels(harness); + if (entries.some((entry) => entry.slug === slug)) + return "That custom model is already saved."; + if (entries.length >= MAX_CUSTOM_MODEL_COUNT) { + return `You can save up to ${MAX_CUSTOM_MODEL_COUNT} custom models per provider.`; + } + const error = persistCustomModels(harness, [ + ...entries, + { slug, name: slug, settings: null }, + ]); + if (!error) emit(); + return error; +} + +export function updateCustomModel( + harness: CustomModelHarness, + entry: CustomModelDefinition, +): string | null { + const entries = loadCustomModels(harness); + if (!entries.some((candidate) => candidate.slug === entry.slug)) { + return "That custom model has been removed."; + } + const error = persistCustomModels( + harness, + entries.map((candidate) => + candidate.slug === entry.slug ? entry : candidate, + ), + ); + if (!error) emit(); + return error; +} + +export function removeCustomModel( + harness: CustomModelHarness, + slug: string, +): string | null { + const entries = loadCustomModels(harness).filter( + (entry) => entry.slug !== slug, + ); + const error = persistCustomModels(harness, entries); + if (error) return error; + const id = customModelId(harness, slug); + saveFavoriteModels( + loadFavoriteModels().filter((favorite) => favorite !== id), + ); + const remaining = appendCustomModels( + harness, + providerModelsFor(harness), + entries, + ); + const fallback = + remaining.find((model) => model.id === defaultModelId(harness))?.id ?? + remaining[0]?.id ?? + defaultModelId(harness); + if (loadDefaultModels()[harness] === id) saveDefaultModel(harness, fallback); + if (loadLastModelChoice()?.model === id) + saveLastModelChoice(harness, fallback); + emit(); + return null; +} + +if (typeof window !== "undefined") { + window.addEventListener("storage", (event) => { + if (event.key !== CUSTOM_MODELS_KEY && event.key !== null) return; + customModelsStore = null; + emit(); + }); +} + export function allModels(): AgentModel[] { return (allCache ??= HARNESS_ORDER.flatMap(modelsFor)); } @@ -308,6 +474,10 @@ export function resolveModel(harness: HarnessId, id?: string): AgentModel { (model) => (model.nativeId ?? nativeIdFrom(model.id)) === slug, ); if (byNative) return byNative; + // Removing a custom model from the picker must not retarget an open session. + if (supportsCustomModels(harness) && id.startsWith(`${harness}:custom:`)) { + return { id, harness, nativeId: slug, name: slug, isCustom: true }; + } const prefix = available.find((model) => { const native = model.nativeId ?? nativeIdFrom(model.id); return native.startsWith(slug) || slug.startsWith(native); @@ -654,6 +824,8 @@ function compatibleSettingValue( function nativeIdFrom(id: string): string { const trimmed = id.trim(); + const custom = customModelSlug(trimmed); + if (custom !== null) return custom; const colon = trimmed.indexOf(":"); const slug = colon >= 0 ? trimmed.slice(colon + 1) : trimmed; const bracket = slug.indexOf("["); diff --git a/src/surfaces/SettingsView.tsx b/src/surfaces/SettingsView.tsx index 2c6d5cd7..eecd7bcb 100644 --- a/src/surfaces/SettingsView.tsx +++ b/src/surfaces/SettingsView.tsx @@ -17,6 +17,7 @@ import { type ReactNode, } from "react"; import { HarnessIcon } from "../chrome/HarnessIcon"; +import { CustomModelsSection } from "../chrome/CustomModelsSection"; import { InboxProviderMark } from "../chrome/InboxProviderMark"; import { RemoveProjectDialog } from "../chrome/RemoveProjectDialog"; import { WindowControls } from "../chrome/WindowControls"; @@ -101,6 +102,7 @@ import { refreshHarnessCatalogs } from "../lib/harness/registry"; import { defaultModelId, getModelSnapshot, + hasLiveCatalog, isPickerProviderVisible, loadDefaultModels, loadLastModelChoice, @@ -111,6 +113,7 @@ import { savePickerProviderVisible, subscribeModels, } from "../lib/models"; +import { supportsCustomModels } from "../lib/customModels"; import { prettyCwd, projectKey, projectName } from "../lib/paths"; import { IS_MAC } from "../lib/platform"; import { @@ -223,6 +226,10 @@ export function SettingsView({ useEffect(() => { const onKey = (event: KeyboardEvent) => { if (event.key !== "Escape") return; + if ( + event.target instanceof Element && + event.target.closest("[data-custom-model-editor]") + ) return; event.preventDefault(); event.stopPropagation(); onCloseRef.current(); @@ -1371,7 +1378,11 @@ function KeybindingsPage() { } function ProvidersPage() { - useSyncExternalStore(subscribeModels, getModelSnapshot, getModelSnapshot); + const catalogVersion = useSyncExternalStore( + subscribeModels, + getModelSnapshot, + getModelSnapshot, + ); useSyncExternalStore( subscribeHarnessAvailability, getHarnessAvailabilitySnapshot, @@ -1380,6 +1391,11 @@ function ProvidersPage() { const [choice, setChoice] = useState(loadLastModelChoice); const [defaultModels, setDefaultModels] = useState(loadDefaultModels); + useEffect(() => { + setChoice(loadLastModelChoice()); + setDefaultModels(loadDefaultModels()); + }, [catalogVersion]); + useEffect(() => { void probeHarnessAvailability(); }, []); @@ -1441,6 +1457,7 @@ function ProviderRow({ onModelChange: (harness: HarnessId, model: string) => void; }) { const models = modelsFor(harness); + const liveCatalog = hasLiveCatalog(harness); const available = isHarnessAvailable(harness); const current = models.length > 0 ? resolveModel(harness, selectedModel) : null; @@ -1449,9 +1466,14 @@ function ProviderRow({ ); useEffect(() => { - if (!available || models.length > 0) return; + if ( + !available || + liveCatalog || + (!supportsCustomModels(harness) && models.length > 0) + ) + return; void refreshHarnessCatalogs([harness]); - }, [available, harness, models.length]); + }, [available, harness, liveCatalog, models.length]); const onPickerVisible = (visible: boolean) => { savePickerProviderVisible(harness, visible); @@ -1459,52 +1481,57 @@ function ProviderRow({ }; return ( - - - {HARNESS_TITLE[harness]} - {isDefault ? ( - - Default - - ) : null} - - } - description={ - available - ? `${models.length} ${models.length === 1 ? "model" : "models"} available.` - : harnessUnavailableHint(harness) - } - > - {current ? ( -