Unified · Split
| @@ -7,9 +7,11 @@ import { · 6 unmodified lines | ||
| 7 | 7 | formatClaudeVersionUpgradeMessage, |
| 8 | 8 | normalizeClaudeCatalogEffort, |
| 9 | 9 | resolveClaudeCatalogApiModelId, |
| 10 | + resolveClaudeCatalogEffort, | |
| 10 | 11 | resolveClaudeModelCatalog, |
| 11 | 12 | resolveClaudeModelsForVersion, |
| 12 | 13 | resolveClaudeModelSlug, |
| 14 | + scopeClaudeModelCatalog, | |
| 13 | 15 | } from "./ClaudeModelCatalog.ts"; |
| 14 | 16 | |
| 15 | 17 | /** |
| @@ -134,4 +136,58 @@ describe("Claude model catalog", () => { · 118 unmodified lines | ||
| 134 | 136 | }; |
| 135 | 137 | assert.isFalse(hasValidClaudeManifestAdapters(malformed)); |
| 136 | 138 | }); |
| 139 | + | |
| 140 | + it("appends custom models with their own descriptors and keeps bare slugs opaque", () => { | |
| 141 | + const catalog = scopeClaudeModelCatalog(resolveClaudeModelCatalog(manifest()), [ | |
| 142 | + "synthetic", | |
| 143 | + { | |
| 144 | + slug: "claude-custom-tuned", | |
| 145 | + name: "Tuned", | |
| 146 | + capabilities: { | |
| 147 | + optionDescriptors: [ | |
| 148 | + { | |
| 149 | + id: "effort", | |
| 150 | + label: "Reasoning", | |
| 151 | + type: "select", | |
| 152 | + options: [ | |
| 153 | + { id: "gentle", label: "Gentle", isDefault: true }, | |
| 154 | + { id: "brutal", label: "Brutal" }, | |
| 155 | + ], | |
| 156 | + }, | |
| 157 | + ], | |
| 158 | + }, | |
| 159 | + }, | |
| 160 | + ]); | |
| 161 | + | |
| 162 | + // The bare custom slug shadows the built-in alias, so it no longer resolves to it. | |
| 163 | + assert.strictEqual(resolveClaudeModelSlug(catalog, "synthetic"), "synthetic"); | |
| 164 | + assert.strictEqual(resolveClaudeCatalogEffort(catalog, "synthetic", "extreme"), undefined); | |
| 165 | + | |
| 166 | + // The entry with descriptors resolves user-defined effort ids and passes | |
| 167 | + // them through untouched (no effortMap, no model suffix). | |
| 168 | + assert.strictEqual( | |
| 169 | + resolveClaudeCatalogEffort(catalog, "claude-custom-tuned", "brutal"), | |
| 170 | + "brutal", | |
| 171 | + ); | |
| 172 | + assert.strictEqual( | |
| 173 | + resolveClaudeCatalogEffort(catalog, "claude-custom-tuned", "bogus"), | |
| 174 | + "gentle", | |
| 175 | + ); | |
| 176 | + assert.strictEqual( | |
| 177 | + normalizeClaudeCatalogEffort(catalog, "brutal", "claude-custom-tuned"), | |
| 178 | + "brutal", | |
| 179 | + ); | |
| 180 | + assert.strictEqual( | |
| 181 | + resolveClaudeCatalogApiModelId(catalog, { | |
| 182 | + instanceId: ProviderInstanceId.make("claudeAgent"), | |
| 183 | + model: "claude-custom-tuned", | |
| 184 | + options: [{ id: "effort", value: "brutal" }], | |
| 185 | + }), | |
| 186 | + "claude-custom-tuned", | |
| 187 | + ); | |
| 188 | + assert.deepStrictEqual( | |
| 189 | + resolveClaudeModelsForVersion(catalog, "3.2.0").map((model) => model.slug), | |
| 190 | + ["claude-synthetic-next", "claude-custom-tuned"], | |
| 191 | + ); | |
| 192 | + }); | |
| 137 | 193 | }); |
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import { |
| 2 | + type CustomModelSetting, | |
| 2 | 3 | type ModelCapabilities, |
| 3 | 4 | type ModelSelection, |
| 4 | 5 | ProviderDriverKind, |
| @@ -9,7 +10,7 @@ import { · 4 unmodified lines | ||
| 9 | 10 | getModelSelectionStringOptionValue, |
| 10 | 11 | getProviderOptionCurrentValue, |
| 11 | 12 | getProviderOptionDescriptors, |
| 12 | − normalizeCustomModelSlug, | |
| 13 | + readCustomModelEntries, | |
| 13 | 14 | } from "@t3tools/shared/model"; |
| 14 | 15 | import { compareSemverVersions } from "@t3tools/shared/semver"; |
| 15 | 16 | |
| @@ -70,33 +71,51 @@ export function resolveClaudeModelCatalog(manifest: ModelManifestData): ClaudeMo · 54 unmodified lines | ||
| 70 | 71 | |
| 71 | 72 | export const BUNDLED_CLAUDE_MODEL_CATALOG = resolveClaudeModelCatalog(BUNDLED_MODEL_MANIFEST); |
| 72 | 73 | |
| 73 | −/** Keeps custom model aliases opaque while preserving canonical built-in models and capabilities. */ | |
| 74 | +/** | |
| 75 | + * Scope the catalog to one instance's settings: custom model slugs stay opaque | |
| 76 | + * (a built-in alias they shadow is dropped, canonical slugs and capabilities | |
| 77 | + * are preserved), and custom entries that declare their own capabilities are | |
| 78 | + * appended so the adapter resolves effort / fast mode / thinking against the | |
| 79 | + * user's descriptors instead of the empty default. Custom entries carry no | |
| 80 | + * runtime profile, so option values pass through to Claude Code verbatim. | |
| 81 | + */ | |
| 74 | 82 | export function scopeClaudeModelCatalog( |
| 75 | 83 | catalog: ClaudeModelCatalog, |
| 76 | − customModels: ReadonlyArray<string>, | |
| 84 | + customModels: ReadonlyArray<CustomModelSetting>, | |
| 77 | 85 | ): ClaudeModelCatalog { |
| 78 | − const customAliases = new Set( | |
| 79 | − customModels.flatMap((model) => { | |
| 80 | − const slug = normalizeCustomModelSlug(model); | |
| 81 | − return slug ? [slug.toLowerCase()] : []; | |
| 82 | − }), | |
| 83 | − ); | |
| 84 | − if (customAliases.size === 0) return catalog; | |
| 86 | + const customEntries = readCustomModelEntries(customModels); | |
| 87 | + if (customEntries.length === 0) return catalog; | |
| 88 | + const customAliases = new Set(customEntries.map((entry) => entry.slug.toLowerCase())); | |
| 85 | 89 | |
| 86 | − return { | |
| 87 | − models: catalog.models.map((entry) => { | |
| 88 | − if (!entry.model.aliases?.some((alias) => customAliases.has(alias.toLowerCase()))) { | |
| 89 | − return entry; | |
| 90 | − } | |
| 91 | − return { | |
| 92 | − ...entry, | |
| 93 | − model: { | |
| 94 | − ...entry.model, | |
| 95 | − aliases: entry.model.aliases.filter((alias) => !customAliases.has(alias.toLowerCase())), | |
| 96 | − }, | |
| 97 | − }; | |
| 98 | − }), | |
| 99 | − }; | |
| 90 | + const builtInModels = catalog.models.map((entry) => { | |
| 91 | + if (!entry.model.aliases?.some((alias) => customAliases.has(alias.toLowerCase()))) { | |
| 92 | + return entry; | |
| 93 | + } | |
| 94 | + return { | |
| 95 | + ...entry, | |
| 96 | + model: { | |
| 97 | + ...entry.model, | |
| 98 | + aliases: entry.model.aliases.filter((alias) => !customAliases.has(alias.toLowerCase())), | |
| 99 | + }, | |
| 100 | + }; | |
| 101 | + }); | |
| 102 | + const builtInSlugs = new Set(builtInModels.map((entry) => entry.model.slug)); | |
| 103 | + const customCatalogModels: Array<ClaudeCatalogModel> = []; | |
| 104 | + for (const entry of customEntries) { | |
| 105 | + if (!entry.capabilities || builtInSlugs.has(entry.slug)) continue; | |
| 106 | + customCatalogModels.push({ | |
| 107 | + model: { | |
| 108 | + slug: entry.slug, | |
| 109 | + name: entry.name, | |
| 110 | + isCustom: true, | |
| 111 | + capabilities: entry.capabilities, | |
| 112 | + }, | |
| 113 | + runtime: {}, | |
| 114 | + compatibility: {}, | |
| 115 | + }); | |
| 116 | + } | |
| 117 | + | |
| 118 | + return { models: [...builtInModels, ...customCatalogModels] }; | |
| 100 | 119 | } |
| 101 | 120 | |
| 102 | 121 | export function resolveClaudeCatalogModel( |
| @@ -15,6 +15,7 @@ import * as CodexErrors from "effect-codex-app-server/errors"; · 14 unmodified lines | ||
| 15 | 15 | |
| 16 | 16 | import type { |
| 17 | 17 | CodexSettings, |
| 18 | + CustomModelSetting, | |
| 18 | 19 | ServerProvider, |
| 19 | 20 | ServerProviderState, |
| 20 | 21 | ModelCapabilities, |
| @@ -24,7 +25,7 @@ import type { · 3 unmodified lines | ||
| 24 | 25 | } from "@t3tools/contracts"; |
| 25 | 26 | import { PREFERRED_DEFAULT_CODEX_MODELS, ServerSettingsError } from "@t3tools/contracts"; |
| 26 | 27 | |
| 27 | −import { createModelCapabilities } from "@t3tools/shared/model"; | |
| 28 | +import { createModelCapabilities, readCustomModelEntries } from "@t3tools/shared/model"; | |
| 28 | 29 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; |
| 29 | 30 | import { codexAppServerArgs, resolveCodexLaunchArgs } from "./codexLaunchArgs.ts"; |
| 30 | 31 | import { |
| @@ -244,9 +245,14 @@ export function applyPreferredCodexDefaultModel( · 213 unmodified lines | ||
| 244 | 245 | }); |
| 245 | 246 | } |
| 246 | 247 | |
| 248 | +/** | |
| 249 | + * Codex has no static default capability set, so a bare custom slug borrows | |
| 250 | + * the first built-in's descriptors; an entry with its own capabilities keeps | |
| 251 | + * them. | |
| 252 | + */ | |
| 247 | 253 | function appendCustomCodexModels( |
| 248 | 254 | models: ReadonlyArray<ServerProviderModel>, |
| 249 | − customModels: ReadonlyArray<string>, | |
| 255 | + customModels: ReadonlyArray<CustomModelSetting>, | |
| 250 | 256 | ): ReadonlyArray<ServerProviderModel> { |
| 251 | 257 | if (customModels.length === 0) { |
| 252 | 258 | return models; |
| @@ -255,17 +261,16 @@ function appendCustomCodexModels( · 2 unmodified lines | ||
| 255 | 261 | const seen = new Set(models.map((model) => model.slug)); |
| 256 | 262 | const fallbackCapabilities = models.find((model) => model.capabilities)?.capabilities ?? null; |
| 257 | 263 | const customEntries: ServerProviderModel[] = []; |
| 258 | − for (const rawModel of customModels) { | |
| 259 | − const slug = rawModel.trim(); | |
| 260 | − if (!slug || seen.has(slug)) { | |
| 264 | + for (const entry of readCustomModelEntries(customModels)) { | |
| 265 | + if (seen.has(entry.slug)) { | |
| 261 | 266 | continue; |
| 262 | 267 | } |
| 263 | − seen.add(slug); | |
| 268 | + seen.add(entry.slug); | |
| 264 | 269 | customEntries.push({ |
| 265 | − slug, | |
| 266 | − name: slug, | |
| 270 | + slug: entry.slug, | |
| 271 | + name: entry.name, | |
| 267 | 272 | isCustom: true, |
| 268 | − capabilities: fallbackCapabilities, | |
| 273 | + capabilities: entry.capabilities ?? fallbackCapabilities, | |
| 269 | 274 | }); |
| 270 | 275 | } |
| 271 | 276 | return customEntries.length === 0 ? models : [...models, ...customEntries]; |
| @@ -399,7 +404,7 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun · 127 unmodified lines | ||
| 399 | 404 | readonly homePath?: string; |
| 400 | 405 | readonly launchArgs?: string; |
| 401 | 406 | readonly cwd: string; |
| 402 | − readonly customModels?: ReadonlyArray<string>; | |
| 407 | + readonly customModels?: ReadonlyArray<CustomModelSetting>; | |
| 403 | 408 | readonly environment?: NodeJS.ProcessEnv; |
| 404 | 409 | }) { |
| 405 | 410 | const { client, initialize } = yield* withCodexAppServerClient(input); |
| @@ -470,21 +475,8 @@ export const probeCodexSkillsForCwd = Effect.fn("probeCodexSkillsForCwd")(functi · 64 unmodified lines | ||
| 470 | 475 | return parseCodexSkillsListResponse(skillsResponse, input.cwd); |
| 471 | 476 | }); |
| 472 | 477 | |
| 473 | −const emptyCodexModelsFromSettings = (codexSettings: CodexSettings): ServerProvider["models"] => { | |
| 474 | − const models = new Set<string>(); | |
| 475 | − for (const model of codexSettings.customModels) { | |
| 476 | − const trimmed = model.trim(); | |
| 477 | − if (trimmed.length > 0) { | |
| 478 | − models.add(trimmed); | |
| 479 | − } | |
| 480 | − } | |
| 481 | − return Array.from(models, (model) => ({ | |
| 482 | − slug: model, | |
| 483 | − name: model, | |
| 484 | − isCustom: true, | |
| 485 | − capabilities: null, | |
| 486 | − })); | |
| 487 | −}; | |
| 478 | +const emptyCodexModelsFromSettings = (codexSettings: CodexSettings): ServerProvider["models"] => | |
| 479 | + appendCustomCodexModels([], codexSettings.customModels); | |
| 488 | 480 | |
| 489 | 481 | const makePendingCodexProvider = ( |
| 490 | 482 | codexSettings: CodexSettings, |
| @@ -562,7 +554,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu · 71 unmodified lines | ||
| 562 | 554 | readonly homePath?: string; |
| 563 | 555 | readonly launchArgs?: string; |
| 564 | 556 | readonly cwd: string; |
| 565 | − readonly customModels: ReadonlyArray<string>; | |
| 557 | + readonly customModels: ReadonlyArray<CustomModelSetting>; | |
| 566 | 558 | readonly environment?: NodeJS.ProcessEnv; |
| 567 | 559 | }) => Effect.Effect< |
| 568 | 560 | CodexAppServerProviderSnapshot, |
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import { |
| 2 | + type CustomModelSetting, | |
| 2 | 3 | type GrokSettings, |
| 3 | 4 | type ModelCapabilities, |
| 4 | 5 | type ServerProvider, |
| @@ -104,7 +105,7 @@ export function buildInitialGrokProviderSnapshot( · 99 unmodified lines | ||
| 104 | 105 | } |
| 105 | 106 | |
| 106 | 107 | function grokModelsFromSettings( |
| 107 | − customModels: ReadonlyArray<string> | undefined, | |
| 108 | + customModels: ReadonlyArray<CustomModelSetting> | undefined, | |
| 108 | 109 | builtInModels: ReadonlyArray<ServerProviderModel> = GROK_BUILT_IN_MODELS, |
| 109 | 110 | ): ReadonlyArray<ServerProviderModel> { |
| 110 | 111 | return providerModelsFromSettings(builtInModels, customModels ?? [], EMPTY_CAPABILITIES); |
| @@ -52,6 +52,27 @@ describe("providerModelsFromSettings", () => { · 51 unmodified lines | ||
| 52 | 52 | ]); |
| 53 | 53 | }); |
| 54 | 54 | |
| 55 | + it("keeps an entry's own name and capabilities over the driver default", () => { | |
| 56 | + const capabilities = createModelCapabilities({ | |
| 57 | + optionDescriptors: [{ id: "fastMode", label: "Fast Mode", type: "boolean" }], | |
| 58 | + }); | |
| 59 | + const models = providerModelsFromSettings( | |
| 60 | + [], | |
| 61 | + ["bare", { slug: "named", name: "Named", capabilities }], | |
| 62 | + OPENCODE_CUSTOM_MODEL_CAPABILITIES, | |
| 63 | + ); | |
| 64 | + | |
| 65 | + expect(models).toEqual([ | |
| 66 | + { | |
| 67 | + slug: "bare", | |
| 68 | + name: "bare", | |
| 69 | + isCustom: true, | |
| 70 | + capabilities: OPENCODE_CUSTOM_MODEL_CAPABILITIES, | |
| 71 | + }, | |
| 72 | + { slug: "named", name: "Named", isCustom: true, capabilities }, | |
| 73 | + ]); | |
| 74 | + }); | |
| 75 | + | |
| 55 | 76 | it("preserves a custom slug that collides with a provider alias", () => { |
| 56 | 77 | const capabilities = createModelCapabilities({ optionDescriptors: [] }); |
| 57 | 78 | const models = providerModelsFromSettings( |
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import type { |
| 2 | + CustomModelSetting, | |
| 2 | 3 | ProviderDriverKind, |
| 3 | 4 | ModelCapabilities, |
| 4 | 5 | ServerProvider, |
| @@ -14,7 +15,7 @@ import * as PlatformError from "effect/PlatformError"; · 9 unmodified lines | ||
| 14 | 15 | import * as Schema from "effect/Schema"; |
| 15 | 16 | import * as Stream from "effect/Stream"; |
| 16 | 17 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 17 | −import { normalizeCustomModelSlug } from "@t3tools/shared/model"; | |
| 18 | +import { readCustomModelEntries } from "@t3tools/shared/model"; | |
| 18 | 19 | import { isWindowsCommandNotFound } from "../processRunner.ts"; |
| 19 | 20 | import { createProviderVersionAdvisory } from "./providerMaintenance.ts"; |
| 20 | 21 | import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts"; |
| @@ -145,26 +146,30 @@ export function parseGenericCliVersion(output: string): string | null { · 124 unmodified lines | ||
| 145 | 146 | return match?.[1] ?? null; |
| 146 | 147 | } |
| 147 | 148 | |
| 149 | +/** | |
| 150 | + * Append the user's custom models after the built-ins. A custom entry that | |
| 151 | + * declares its own capabilities keeps them; a bare slug gets the driver's | |
| 152 | + * default set. Slugs that collide with a built-in are dropped. | |
| 153 | + */ | |
| 148 | 154 | export function providerModelsFromSettings( |
| 149 | 155 | builtInModels: ReadonlyArray<ServerProviderModel>, |
| 150 | − customModels: ReadonlyArray<string>, | |
| 156 | + customModels: ReadonlyArray<CustomModelSetting>, | |
| 151 | 157 | customModelCapabilities: ModelCapabilities, |
| 152 | 158 | ): ReadonlyArray<ServerProviderModel> { |
| 153 | 159 | const resolvedBuiltInModels = [...builtInModels]; |
| 154 | 160 | const seen = new Set(resolvedBuiltInModels.map((model) => model.slug)); |
| 155 | 161 | const customEntries: ServerProviderModel[] = []; |
| 156 | 162 | |
| 157 | − for (const candidate of customModels) { | |
| 158 | − const normalized = normalizeCustomModelSlug(candidate); | |
| 159 | − if (!normalized || seen.has(normalized)) { | |
| 163 | + for (const entry of readCustomModelEntries(customModels)) { | |
| 164 | + if (seen.has(entry.slug)) { | |
| 160 | 165 | continue; |
| 161 | 166 | } |
| 162 | − seen.add(normalized); | |
| 167 | + seen.add(entry.slug); | |
| 163 | 168 | customEntries.push({ |
| 164 | − slug: normalized, | |
| 165 | − name: normalized, | |
| 169 | + slug: entry.slug, | |
| 170 | + name: entry.name, | |
| 166 | 171 | isCustom: true, |
| 167 | − capabilities: customModelCapabilities, | |
| 172 | + capabilities: entry.capabilities ?? customModelCapabilities, | |
| 168 | 173 | }); |
| 169 | 174 | } |
| 170 | 175 | |
| @@ -0,0 +1,387 @@ | ||
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import { PlusIcon, XIcon } from "lucide-react"; | |
| 4 | +import { useMemo, useState } from "react"; | |
| 5 | +import type { ProviderDriverKind, ServerProviderModel } from "@t3tools/contracts"; | |
| 6 | +import type { CustomModelDefinition } from "@t3tools/shared/model"; | |
| 7 | + | |
| 8 | +import { cn } from "../../lib/utils"; | |
| 9 | +import { Button } from "../ui/button"; | |
| 10 | +import { Input } from "../ui/input"; | |
| 11 | +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; | |
| 12 | +import { Switch } from "../ui/switch"; | |
| 13 | +import { | |
| 14 | + DESCRIPTOR_PRESETS_BY_KIND, | |
| 15 | + type CustomModelDraft, | |
| 16 | + type EditorChoice, | |
| 17 | + type EditorDescriptor, | |
| 18 | + choiceFromPreset, | |
| 19 | + definitionFromDraft, | |
| 20 | + descriptorFromPreset, | |
| 21 | + descriptorsFromCapabilities, | |
| 22 | + draftFromDefinition, | |
| 23 | + emptyEditorChoice, | |
| 24 | + emptyEditorDescriptor, | |
| 25 | + validateDraft, | |
| 26 | +} from "./customModelEditor.logic"; | |
| 27 | + | |
| 28 | +const CUSTOM_ID_VALUE = "__custom__"; | |
| 29 | +const START_FROM_NONE = "__none__"; | |
| 30 | + | |
| 31 | +interface CustomModelEditorProps { | |
| 32 | + readonly instanceId: string; | |
| 33 | + readonly driverKind: ProviderDriverKind | null; | |
| 34 | + readonly entry: CustomModelDefinition; | |
| 35 | + /** Built-in models whose descriptors can be copied as a starting point. */ | |
| 36 | + readonly builtInModels: ReadonlyArray<ServerProviderModel>; | |
| 37 | + readonly onSave: (next: CustomModelDefinition) => void; | |
| 38 | + readonly onCancel: () => void; | |
| 39 | +} | |
| 40 | + | |
| 41 | +/** | |
| 42 | + * Inline editor for one custom model: display name plus the option | |
| 43 | + * descriptors the composer should offer for it (Reasoning effort, Fast | |
| 44 | + * mode, ...). Draft state is local; nothing is persisted until Save. | |
| 45 | + */ | |
| 46 | +export function CustomModelEditor({ | |
| 47 | + instanceId, | |
| 48 | + driverKind, | |
| 49 | + entry, | |
| 50 | + builtInModels, | |
| 51 | + onSave, | |
| 52 | + onCancel, | |
| 53 | +}: CustomModelEditorProps) { | |
| 54 | + const [draft, setDraft] = useState<CustomModelDraft>(() => draftFromDefinition(entry)); | |
| 55 | + const [error, setError] = useState<string | null>(null); | |
| 56 | + const presets = useMemo( | |
| 57 | + () => (driverKind ? (DESCRIPTOR_PRESETS_BY_KIND[driverKind] ?? []) : []), | |
| 58 | + [driverKind], | |
| 59 | + ); | |
| 60 | + const startFromCandidates = useMemo( | |
| 61 | + () => builtInModels.filter((model) => (model.capabilities?.optionDescriptors?.length ?? 0) > 0), | |
| 62 | + [builtInModels], | |
| 63 | + ); | |
| 64 | + const domId = (suffix: string) => `provider-instance-${instanceId}-custom-model-${suffix}`; | |
| 65 | + | |
| 66 | + const updateDescriptor = (key: string, patch: Partial<EditorDescriptor>) => { | |
| 67 | + setError(null); | |
| 68 | + setDraft((current) => ({ | |
| 69 | + ...current, | |
| 70 | + descriptors: current.descriptors.map((descriptor) => | |
| 71 | + descriptor.key === key ? { ...descriptor, ...patch } : descriptor, | |
| 72 | + ), | |
| 73 | + })); | |
| 74 | + }; | |
| 75 | + | |
| 76 | + const updateChoice = (descriptorKey: string, choiceKey: string, patch: Partial<EditorChoice>) => { | |
| 77 | + setError(null); | |
| 78 | + setDraft((current) => ({ | |
| 79 | + ...current, | |
| 80 | + descriptors: current.descriptors.map((descriptor) => { | |
| 81 | + if (descriptor.key !== descriptorKey) return descriptor; | |
| 82 | + return { | |
| 83 | + ...descriptor, | |
| 84 | + choices: descriptor.choices.map((choice) => { | |
| 85 | + if (choice.key === choiceKey) return { ...choice, ...patch }; | |
| 86 | + // Only one choice can be the default. | |
| 87 | + return patch.isDefault ? { ...choice, isDefault: false } : choice; | |
| 88 | + }), | |
| 89 | + }; | |
| 90 | + }), | |
| 91 | + })); | |
| 92 | + }; | |
| 93 | + | |
| 94 | + const removeDescriptor = (key: string) => { | |
| 95 | + setError(null); | |
| 96 | + setDraft((current) => ({ | |
| 97 | + ...current, | |
| 98 | + descriptors: current.descriptors.filter((descriptor) => descriptor.key !== key), | |
| 99 | + })); | |
| 100 | + }; | |
| 101 | + | |
| 102 | + const addDescriptor = (descriptor: EditorDescriptor) => { | |
| 103 | + setError(null); | |
| 104 | + setDraft((current) => ({ ...current, descriptors: [...current.descriptors, descriptor] })); | |
| 105 | + }; | |
| 106 | + | |
| 107 | + // Selecting a preset id replaces the descriptor's label/type/choices so the | |
| 108 | + // usual values are one click away; "Custom…" leaves the row blank to type into. | |
| 109 | + const applyPresetId = (descriptor: EditorDescriptor, value: string | null) => { | |
| 110 | + if (value === null) return; | |
| 111 | + if (value === CUSTOM_ID_VALUE) { | |
| 112 | + updateDescriptor(descriptor.key, { id: "" }); | |
| 113 | + return; | |
| 114 | + } | |
| 115 | + const preset = presets.find((candidate) => candidate.id === value); | |
| 116 | + if (!preset) return; | |
| 117 | + updateDescriptor(descriptor.key, { | |
| 118 | + id: preset.id, | |
| 119 | + label: preset.label, | |
| 120 | + type: preset.type, | |
| 121 | + choices: (preset.choices ?? []).map(choiceFromPreset), | |
| 122 | + currentBooleanValue: undefined, | |
| 123 | + description: undefined, | |
| 124 | + }); | |
| 125 | + }; | |
| 126 | + | |
| 127 | + const handleStartFrom = (slug: string | null) => { | |
| 128 | + if (slug === null || slug === START_FROM_NONE) return; | |
| 129 | + const model = startFromCandidates.find((candidate) => candidate.slug === slug); | |
| 130 | + if (!model) return; | |
| 131 | + setError(null); | |
| 132 | + setDraft((current) => ({ | |
| 133 | + ...current, | |
| 134 | + descriptors: descriptorsFromCapabilities(model.capabilities, driverKind), | |
| 135 | + })); | |
| 136 | + }; | |
| 137 | + | |
| 138 | + const handleSave = () => { | |
| 139 | + const problem = validateDraft(draft); | |
| 140 | + if (problem) { | |
| 141 | + setError(problem); | |
| 142 | + return; | |
| 143 | + } | |
| 144 | + onSave(definitionFromDraft(draft)); | |
| 145 | + }; | |
| 146 | + | |
| 147 | + const idSelectValue = (descriptor: EditorDescriptor) => | |
| 148 | + presets.some((preset) => preset.id === descriptor.id) ? descriptor.id : CUSTOM_ID_VALUE; | |
| 149 | + | |
| 150 | + const renderChoice = (descriptor: EditorDescriptor, choice: EditorChoice) => ( | |
| 151 | + <div key={choice.key} className="flex items-center gap-2"> | |
| 152 | + <Input | |
| 153 | + size="compact" | |
| 154 | + value={choice.id} | |
| 155 | + onChange={(event) => updateChoice(descriptor.key, choice.key, { id: event.target.value })} | |
| 156 | + placeholder="value" | |
| 157 | + className="w-28 font-mono" | |
| 158 | + spellCheck={false} | |
| 159 | + aria-label="Choice value" | |
| 160 | + /> | |
| 161 | + <Input | |
| 162 | + size="compact" | |
| 163 | + value={choice.label} | |
| 164 | + onChange={(event) => | |
| 165 | + updateChoice(descriptor.key, choice.key, { label: event.target.value }) | |
| 166 | + } | |
| 167 | + placeholder="Label" | |
| 168 | + className="min-w-0 flex-1" | |
| 169 | + aria-label="Choice label" | |
| 170 | + /> | |
| 171 | + <label className="flex shrink-0 items-center gap-1.5 text-[11px] text-muted-foreground"> | |
| 172 | + <Switch | |
| 173 | + size="sm" | |
| 174 | + checked={choice.isDefault} | |
| 175 | + onCheckedChange={(checked) => | |
| 176 | + updateChoice(descriptor.key, choice.key, { isDefault: checked }) | |
| 177 | + } | |
| 178 | + aria-label="Default choice" | |
| 179 | + /> | |
| 180 | + Default | |
| 181 | + </label> | |
| 182 | + <Button | |
| 183 | + size="icon-micro" | |
| 184 | + variant="ghost-muted" | |
| 185 | + aria-label="Remove choice" | |
| 186 | + onClick={() => | |
| 187 | + updateDescriptor(descriptor.key, { | |
| 188 | + choices: descriptor.choices.filter((candidate) => candidate.key !== choice.key), | |
| 189 | + }) | |
| 190 | + } | |
| 191 | + > | |
| 192 | + <XIcon className="size-3" /> | |
| 193 | + </Button> | |
| 194 | + </div> | |
| 195 | + ); | |
| 196 | + | |
| 197 | + const renderDescriptor = (descriptor: EditorDescriptor, index: number) => ( | |
| 198 | + <div | |
| 199 | + key={descriptor.key} | |
| 200 | + className="flex flex-col gap-2 rounded-md border border-border/60 bg-background/40 p-2.5" | |
| 201 | + > | |
| 202 | + <div className="flex flex-wrap items-center gap-2"> | |
| 203 | + <span className="w-14 shrink-0 text-[11px] text-muted-foreground">Option {index + 1}</span> | |
| 204 | + {presets.length > 0 ? ( | |
| 205 | + <Select | |
| 206 | + value={idSelectValue(descriptor)} | |
| 207 | + onValueChange={(value) => applyPresetId(descriptor, value)} | |
| 208 | + > | |
| 209 | + <SelectTrigger size="compact" className="w-40" aria-label="Option id"> | |
| 210 | + <SelectValue> | |
| 211 | + {idSelectValue(descriptor) === CUSTOM_ID_VALUE ? "Custom…" : descriptor.id} | |
| 212 | + </SelectValue> | |
| 213 | + </SelectTrigger> | |
| 214 | + <SelectPopup align="start" alignItemWithTrigger={false}> | |
| 215 | + {presets.map((preset) => ( | |
| 216 | + <SelectItem key={preset.id} value={preset.id}> | |
| 217 | + <span className="flex items-baseline gap-2"> | |
| 218 | + <code className="font-mono text-xs">{preset.id}</code> | |
| 219 | + <span className="text-muted-foreground">{preset.label}</span> | |
| 220 | + </span> | |
| 221 | + </SelectItem> | |
| 222 | + ))} | |
| 223 | + <SelectItem value={CUSTOM_ID_VALUE}>Custom…</SelectItem> | |
| 224 | + </SelectPopup> | |
| 225 | + </Select> | |
| 226 | + ) : null} | |
| 227 | + {idSelectValue(descriptor) === CUSTOM_ID_VALUE ? ( | |
| 228 | + <Input | |
| 229 | + size="compact" | |
| 230 | + value={descriptor.id} | |
| 231 | + onChange={(event) => updateDescriptor(descriptor.key, { id: event.target.value })} | |
| 232 | + placeholder="optionId" | |
| 233 | + className="w-36 font-mono" | |
| 234 | + spellCheck={false} | |
| 235 | + aria-label="Option id" | |
| 236 | + /> | |
| 237 | + ) : null} | |
| 238 | + <Input | |
| 239 | + size="compact" | |
| 240 | + value={descriptor.label} | |
| 241 | + onChange={(event) => updateDescriptor(descriptor.key, { label: event.target.value })} | |
| 242 | + placeholder="Label" | |
| 243 | + className="min-w-0 flex-1" | |
| 244 | + aria-label="Option label" | |
| 245 | + /> | |
| 246 | + <Select | |
| 247 | + value={descriptor.type} | |
| 248 | + onValueChange={(value) => | |
| 249 | + updateDescriptor(descriptor.key, { type: value === "boolean" ? "boolean" : "select" }) | |
| 250 | + } | |
| 251 | + > | |
| 252 | + <SelectTrigger size="compact" className="w-24" aria-label="Option type"> | |
| 253 | + <SelectValue>{descriptor.type === "boolean" ? "Toggle" : "Choices"}</SelectValue> | |
| 254 | + </SelectTrigger> | |
| 255 | + <SelectPopup align="end" alignItemWithTrigger={false}> | |
| 256 | + <SelectItem value="select">Choices</SelectItem> | |
| 257 | + <SelectItem value="boolean">Toggle</SelectItem> | |
| 258 | + </SelectPopup> | |
| 259 | + </Select> | |
| 260 | + <Button | |
| 261 | + size="icon-micro" | |
| 262 | + variant="ghost-muted" | |
| 263 | + aria-label={`Remove option ${index + 1}`} | |
| 264 | + onClick={() => removeDescriptor(descriptor.key)} | |
| 265 | + > | |
| 266 | + <XIcon className="size-3" /> | |
| 267 | + </Button> | |
| 268 | + </div> | |
| 269 | + {descriptor.type === "select" ? ( | |
| 270 | + <div className="flex flex-col gap-1.5 pl-16"> | |
| 271 | + {descriptor.choices.map((choice) => renderChoice(descriptor, choice))} | |
| 272 | + <Button | |
| 273 | + type="button" | |
| 274 | + size="xs" | |
| 275 | + variant="ghost-muted" | |
| 276 | + className="-ml-2 self-start" | |
| 277 | + onClick={() => | |
| 278 | + updateDescriptor(descriptor.key, { | |
| 279 | + choices: [...descriptor.choices, emptyEditorChoice()], | |
| 280 | + }) | |
| 281 | + } | |
| 282 | + > | |
| 283 | + <PlusIcon className="size-3" /> | |
| 284 | + Add choice | |
| 285 | + </Button> | |
| 286 | + </div> | |
| 287 | + ) : null} | |
| 288 | + </div> | |
| 289 | + ); | |
| 290 | + | |
| 291 | + return ( | |
| 292 | + <div | |
| 293 | + className="mx-2 mt-1 mb-2 flex flex-col gap-3 rounded-md border border-border bg-muted/20 p-3" | |
| 294 | + onKeyDown={(event) => { | |
| 295 | + if (event.key === "Escape") { | |
| 296 | + event.preventDefault(); | |
| 297 | + onCancel(); | |
| 298 | + } | |
| 299 | + }} | |
| 300 | + > | |
| 301 | + <div className="flex flex-col gap-1"> | |
| 302 | + <label htmlFor={domId("name")} className="text-xs text-muted-foreground"> | |
| 303 | + Display name | |
| 304 | + </label> | |
| 305 | + <Input | |
| 306 | + id={domId("name")} | |
| 307 | + size="sm" | |
| 308 | + autoFocus | |
| 309 | + value={draft.name} | |
| 310 | + onChange={(event) => setDraft((current) => ({ ...current, name: event.target.value }))} | |
| 311 | + placeholder={draft.slug} | |
| 312 | + className="sm:w-72" | |
| 313 | + spellCheck={false} | |
| 314 | + /> | |
| 315 | + </div> | |
| 316 | + | |
| 317 | + <div className="flex flex-col gap-2"> | |
| 318 | + <div className="flex flex-wrap items-center justify-between gap-2"> | |
| 319 | + <span className="text-xs text-muted-foreground">Options shown in the composer</span> | |
| 320 | + {startFromCandidates.length > 0 ? ( | |
| 321 | + <Select value={START_FROM_NONE} onValueChange={handleStartFrom}> | |
| 322 | + <SelectTrigger | |
| 323 | + size="compact" | |
| 324 | + className="w-44" | |
| 325 | + aria-label="Copy options from a built-in model" | |
| 326 | + > | |
| 327 | + <SelectValue>Copy from…</SelectValue> | |
| 328 | + </SelectTrigger> | |
| 329 | + <SelectPopup align="end" alignItemWithTrigger={false}> | |
| 330 | + {startFromCandidates.map((model) => ( | |
| 331 | + <SelectItem key={model.slug} value={model.slug}> | |
| 332 | + {model.name} | |
| 333 | + </SelectItem> | |
| 334 | + ))} | |
| 335 | + </SelectPopup> | |
| 336 | + </Select> | |
| 337 | + ) : null} | |
| 338 | + </div> | |
| 339 | + {draft.descriptors.length === 0 ? ( | |
| 340 | + <p className="text-xs text-muted-foreground/70"> | |
| 341 | + No custom options. The composer uses the provider's default options. | |
| 342 | + </p> | |
| 343 | + ) : null} | |
| 344 | + {draft.descriptors.map(renderDescriptor)} | |
| 345 | + <div className="flex flex-wrap gap-1"> | |
| 346 | + {presets | |
| 347 | + .filter( | |
| 348 | + (preset) => !draft.descriptors.some((descriptor) => descriptor.id === preset.id), | |
| 349 | + ) | |
| 350 | + .map((preset) => ( | |
| 351 | + <Button | |
| 352 | + key={preset.id} | |
| 353 | + type="button" | |
| 354 | + size="xs" | |
| 355 | + variant="ghost-muted" | |
| 356 | + className={cn("-ml-2 first:ml-0")} | |
| 357 | + onClick={() => addDescriptor(descriptorFromPreset(preset))} | |
| 358 | + > | |
| 359 | + <PlusIcon className="size-3" /> | |
| 360 | + {preset.label} | |
| 361 | + </Button> | |
| 362 | + ))} | |
| 363 | + <Button | |
| 364 | + type="button" | |
| 365 | + size="xs" | |
| 366 | + variant="ghost-muted" | |
| 367 | + onClick={() => addDescriptor(emptyEditorDescriptor())} | |
| 368 | + > | |
| 369 | + <PlusIcon className="size-3" /> | |
| 370 | + Custom option | |
| 371 | + </Button> | |
| 372 | + </div> | |
| 373 | + </div> | |
| 374 | + | |
| 375 | + {error ? <p className="text-xs text-destructive">{error}</p> : null} | |
| 376 | + | |
| 377 | + <div className="flex gap-2"> | |
| 378 | + <Button size="sm" variant="outline" onClick={handleSave}> | |
| 379 | + Save | |
| 380 | + </Button> | |
| 381 | + <Button size="sm" variant="ghost" onClick={onCancel}> | |
| 382 | + Cancel | |
| 383 | + </Button> | |
| 384 | + </div> | |
| 385 | + </div> | |
| 386 | + ); | |
| 387 | +} | |
| @@ -36,11 +36,52 @@ describe("deriveProviderModelsForDisplay", () => { · 35 unmodified lines | ||
| 36 | 36 | expect( |
| 37 | 37 | deriveProviderModelsForDisplay({ |
| 38 | 38 | liveModels, |
| 39 | − customModels: ["kept-custom"], | |
| 39 | + customModels: [{ slug: "kept-custom", name: "kept-custom", capabilities: null }], | |
| 40 | 40 | }).map((model) => model.slug), |
| 41 | 41 | ).toEqual(["server-model", "kept-custom"]); |
| 42 | 42 | }); |
| 43 | 43 | |
| 44 | + it("prefers the entry's name and capabilities over the stale live custom row", () => { | |
| 45 | + const liveCapabilities = { optionDescriptors: [] }; | |
| 46 | + const customCapabilities = { | |
| 47 | + optionDescriptors: [ | |
| 48 | + { | |
| 49 | + id: "reasoningEffort", | |
| 50 | + label: "Reasoning", | |
| 51 | + type: "select" as const, | |
| 52 | + options: [{ id: "high", label: "High", isDefault: true }], | |
| 53 | + currentValue: "high", | |
| 54 | + }, | |
| 55 | + ], | |
| 56 | + }; | |
| 57 | + const liveModels: ReadonlyArray<ServerProviderModel> = [ | |
| 58 | + { slug: "bare", name: "bare", isCustom: true, capabilities: liveCapabilities }, | |
| 59 | + { slug: "named", name: "named", isCustom: true, capabilities: liveCapabilities }, | |
| 60 | + ]; | |
| 61 | + | |
| 62 | + const display = deriveProviderModelsForDisplay({ | |
| 63 | + liveModels, | |
| 64 | + customModels: [ | |
| 65 | + { slug: "bare", name: "bare", capabilities: null }, | |
| 66 | + { slug: "named", name: "My Model", capabilities: customCapabilities }, | |
| 67 | + ], | |
| 68 | + }); | |
| 69 | + | |
| 70 | + // A bare entry keeps the driver default the server filled in. | |
| 71 | + expect(display[0]).toEqual({ | |
| 72 | + slug: "bare", | |
| 73 | + name: "bare", | |
| 74 | + isCustom: true, | |
| 75 | + capabilities: liveCapabilities, | |
| 76 | + }); | |
| 77 | + expect(display[1]).toEqual({ | |
| 78 | + slug: "named", | |
| 79 | + name: "My Model", | |
| 80 | + isCustom: true, | |
| 81 | + capabilities: customCapabilities, | |
| 82 | + }); | |
| 83 | + }); | |
| 84 | + | |
| 44 | 85 | it("shows a redacted provider email in the editor header status line", () => { |
| 45 | 86 | const instanceId = ProviderInstanceId.make("codex"); |
| 46 | 87 | const driver = ProviderDriverKind.make("codex"); |
| @@ -25,6 +25,11 @@ import { · 24 unmodified lines | ||
| 25 | 25 | type ServerProviderModel, |
| 26 | 26 | } from "@t3tools/contracts"; |
| 27 | 27 | |
| 28 | +import { | |
| 29 | + type CustomModelDefinition, | |
| 30 | + readCustomModelEntries, | |
| 31 | + toCustomModelSetting, | |
| 32 | +} from "@t3tools/shared/model"; | |
| 28 | 33 | import { cn } from "../../lib/utils"; |
| 29 | 34 | import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; |
| 30 | 35 | import { normalizeProviderAccentColor } from "../../providerInstances"; |
| @@ -96,16 +101,13 @@ function providerEnvironmentsEqual( · 65 unmodified lines | ||
| 96 | 101 | } |
| 97 | 102 | |
| 98 | 103 | /** |
| 99 | − * Read a string[] at `key` from the opaque config blob, filtering out | |
| 100 | − * non-string entries. Used for `customModels`, which is always typed as | |
| 101 | − * `string[]` by the concrete driver schemas but arrives here as | |
| 102 | − * `Schema.Unknown`. | |
| 104 | + * Read `customModels` from the opaque config blob. The concrete driver | |
| 105 | + * schemas type it as `CustomModelSetting[]`, but it arrives here as | |
| 106 | + * `Schema.Unknown`, so the shared reader does the shape checking. | |
| 103 | 107 | */ |
| 104 | −function readConfigStringArray(config: unknown, key: string): ReadonlyArray<string> { | |
| 108 | +function readConfigCustomModels(config: unknown): ReadonlyArray<CustomModelDefinition> { | |
| 105 | 109 | if (config === null || typeof config !== "object") return []; |
| 106 | − const value = (config as Record<string, unknown>)[key]; | |
| 107 | − if (!Array.isArray(value)) return []; | |
| 108 | − return value.filter((entry): entry is string => typeof entry === "string"); | |
| 110 | + return readCustomModelEntries((config as Record<string, unknown>).customModels); | |
| 109 | 111 | } |
| 110 | 112 | |
| 111 | 113 | /** |
| @@ -127,9 +129,14 @@ function nextConfigBlobWithValue( · 15 unmodified lines | ||
| 127 | 129 | return base; |
| 128 | 130 | } |
| 129 | 131 | |
| 132 | +/** | |
| 133 | + * Custom rows come from current settings so name/descriptor edits show | |
| 134 | + * instantly; a bare entry falls back to the live row's driver-default | |
| 135 | + * capabilities (the server fills those in on its next probe). | |
| 136 | + */ | |
| 130 | 137 | export function deriveProviderModelsForDisplay(input: { |
| 131 | 138 | readonly liveModels: ReadonlyArray<ServerProviderModel> | undefined; |
| 132 | − readonly customModels: ReadonlyArray<string>; | |
| 139 | + readonly customModels: ReadonlyArray<CustomModelDefinition>; | |
| 133 | 140 | }): ReadonlyArray<ServerProviderModel> { |
| 134 | 141 | const liveCustomModelsBySlug = new Map( |
| 135 | 142 | Arr.filterMap(input.liveModels ?? [], (model) => |
| @@ -137,15 +144,13 @@ export function deriveProviderModelsForDisplay(input: { · 1 unmodified line | ||
| 137 | 144 | ), |
| 138 | 145 | ); |
| 139 | 146 | const serverModels = input.liveModels?.filter((model) => !model.isCustom) ?? []; |
| 140 | − const customModels = input.customModels.map( | |
| 141 | − (slug) => | |
| 142 | − liveCustomModelsBySlug.get(slug) ?? { | |
| 143 | − slug, | |
| 144 | − name: slug, | |
| 145 | − isCustom: true, | |
| 146 | − capabilities: null, | |
| 147 | − }, | |
| 148 | − ); | |
| 147 | + const customModels = input.customModels.map((entry) => ({ | |
| 148 | + slug: entry.slug, | |
| 149 | + name: entry.name, | |
| 150 | + isCustom: true, | |
| 151 | + capabilities: | |
| 152 | + entry.capabilities ?? liveCustomModelsBySlug.get(entry.slug)?.capabilities ?? null, | |
| 153 | + })); | |
| 149 | 154 | return [...serverModels, ...customModels]; |
| 150 | 155 | } |
| 151 | 156 | |
| @@ -463,7 +468,7 @@ export function ProviderInstanceCard({ · 311 unmodified lines | ||
| 463 | 468 | ? instance.driver |
| 464 | 469 | : null; |
| 465 | 470 | const customModels = |
| 466 | − instance.driver === "antigravity" ? [] : readConfigStringArray(instance.config, "customModels"); | |
| 471 | + instance.driver === "antigravity" ? [] : readConfigCustomModels(instance.config); | |
| 467 | 472 | // Server-returned models may lag behind settings writes. Treat probe |
| 468 | 473 | // models as the source for built-ins only; custom rows come directly |
| 469 | 474 | // from the current instance config so add/remove reflects immediately. |
| @@ -504,8 +509,12 @@ export function ProviderInstanceCard({ · 34 unmodified lines | ||
| 504 | 509 | ); |
| 505 | 510 | }; |
| 506 | 511 | |
| 507 | − const updateCustomModels = (next: ReadonlyArray<string>) => { | |
| 508 | − const nextConfig = nextConfigBlobWithValue(instance.config, "customModels", [...next]); | |
| 512 | + const updateCustomModels = (next: ReadonlyArray<CustomModelDefinition>) => { | |
| 513 | + const nextConfig = nextConfigBlobWithValue( | |
| 514 | + instance.config, | |
| 515 | + "customModels", | |
| 516 | + next.map(toCustomModelSetting), | |
| 517 | + ); | |
| 509 | 518 | const { config: _omit, ...rest } = instance; |
| 510 | 519 | onUpdate({ ...rest, config: nextConfig } as ProviderInstanceConfig); |
| 511 | 520 | }; |
| @@ -1,13 +1,13 @@ | ||
| 1 | 1 | "use client"; |
| 2 | 2 | |
| 3 | −import { ArrowDownIcon, ArrowUpIcon, PlusIcon, StarIcon, XIcon } from "lucide-react"; | |
| 3 | +import { ArrowDownIcon, ArrowUpIcon, PencilIcon, PlusIcon, StarIcon, XIcon } from "lucide-react"; | |
| 4 | 4 | import { useEffect, useMemo, useRef, useState } from "react"; |
| 5 | 5 | import { |
| 6 | 6 | ProviderDriverKind, |
| 7 | 7 | type ProviderInstanceId, |
| 8 | 8 | type ServerProviderModel, |
| 9 | 9 | } from "@t3tools/contracts"; |
| 10 | −import { normalizeCustomModelSlug } from "@t3tools/shared/model"; | |
| 10 | +import { type CustomModelDefinition, normalizeCustomModelSlug } from "@t3tools/shared/model"; | |
| 11 | 11 | |
| 12 | 12 | import { cn } from "../../lib/utils"; |
| 13 | 13 | import { sortModelsForProviderInstance } from "../../modelOrdering"; |
| @@ -16,6 +16,7 @@ import { Button } from "../ui/button"; · 2 unmodified lines | ||
| 16 | 16 | import { Input } from "../ui/input"; |
| 17 | 17 | import { Switch } from "../ui/switch"; |
| 18 | 18 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 19 | +import { CustomModelEditor } from "./CustomModelEditor"; | |
| 19 | 20 | |
| 20 | 21 | /** |
| 21 | 22 | * Placeholder text for the "add a custom model" input, keyed by driver |
| @@ -109,11 +110,11 @@ interface ProviderModelsSectionProps { · 87 unmodified lines | ||
| 109 | 110 | */ |
| 110 | 111 | readonly models: ReadonlyArray<ServerProviderModel>; |
| 111 | 112 | /** |
| 112 | − * The persisted custom-model slug list for this instance. Drives dedup, | |
| 113 | − * and is the array we hand back verbatim (with the new slug appended / | |
| 113 | + * The persisted custom-model list for this instance, resolved. Drives | |
| 114 | + * dedup, and is the list we hand back (with an entry appended / replaced / | |
| 114 | 115 | * removed) via `onChange`. |
| 115 | 116 | */ |
| 116 | − readonly customModels: ReadonlyArray<string>; | |
| 117 | + readonly customModels: ReadonlyArray<CustomModelDefinition>; | |
| 117 | 118 | /** Server-returned model slugs hidden from the model picker. */ |
| 118 | 119 | readonly hiddenModels: ReadonlyArray<string>; |
| 119 | 120 | /** Model slugs favorited for this provider instance. */ |
| @@ -125,7 +126,7 @@ interface ProviderModelsSectionProps { · 5 unmodified lines | ||
| 125 | 126 | * write to the correct storage (legacy `settings.providers[kind]` vs. |
| 126 | 127 | * `providerInstances[id].config`). |
| 127 | 128 | */ |
| 128 | − readonly onChange: (next: ReadonlyArray<string>) => void; | |
| 129 | + readonly onChange: (next: ReadonlyArray<CustomModelDefinition>) => void; | |
| 129 | 130 | readonly onHiddenModelsChange: (next: ReadonlyArray<string>) => void; |
| 130 | 131 | readonly onFavoriteModelsChange: (next: ReadonlyArray<string>) => void; |
| 131 | 132 | readonly onModelOrderChange: (next: ReadonlyArray<string>) => void; |
| @@ -159,6 +160,8 @@ export function ProviderModelsSection({ · 27 unmodified lines | ||
| 159 | 160 | const [isAdding, setIsAdding] = useState(false); |
| 160 | 161 | const [filter, setFilter] = useState(""); |
| 161 | 162 | const [error, setError] = useState<string | null>(null); |
| 163 | + // Slug of the custom model whose inline editor is open, if any. | |
| 164 | + const [editingSlug, setEditingSlug] = useState<string | null>(null); | |
| 162 | 165 | const listRef = useRef<HTMLDivElement>(null); |
| 163 | 166 | // Slug of a just-added custom model, scrolled into view once its row exists. |
| 164 | 167 | const scrollToSlugRef = useRef<string | null>(null); |
| @@ -177,6 +180,7 @@ export function ProviderModelsSection({ · 12 unmodified lines | ||
| 177 | 180 | const hiddenCount = displayModels.filter( |
| 178 | 181 | (model) => !model.isCustom && hiddenModelSet.has(model.slug), |
| 179 | 182 | ).length; |
| 183 | + const builtInModels = useMemo(() => models.filter((model) => !model.isCustom), [models]); | |
| 180 | 184 | const showFilter = models.length > FILTER_THRESHOLD; |
| 181 | 185 | const normalizedFilter = filter.trim().toLowerCase(); |
| 182 | 186 | const isFiltering = showFilter && normalizedFilter.length > 0; |
| @@ -216,7 +220,7 @@ export function ProviderModelsSection({ · 33 unmodified lines | ||
| 216 | 220 | setError(`Model slugs must be ${MAX_CUSTOM_MODEL_LENGTH} characters or less.`); |
| 217 | 221 | return; |
| 218 | 222 | } |
| 219 | − if (customModels.includes(normalized)) { | |
| 223 | + if (customModels.some((entry) => entry.slug === normalized)) { | |
| 220 | 224 | setError("That custom model is already saved."); |
| 221 | 225 | return; |
| 222 | 226 | } |
| @@ -225,7 +229,7 @@ export function ProviderModelsSection({ · 2 unmodified lines | ||
| 225 | 229 | // which is also what lets the pending scroll target resolve and clear. |
| 226 | 230 | scrollToSlugRef.current = normalized; |
| 227 | 231 | setFilter(""); |
| 228 | − onChange([...customModels, normalized]); | |
| 232 | + onChange([...customModels, { slug: normalized, name: normalized, capabilities: null }]); | |
| 229 | 233 | setInput(""); |
| 230 | 234 | setError(null); |
| 231 | 235 | setIsAdding(false); |
| @@ -238,12 +242,18 @@ export function ProviderModelsSection({ · 6 unmodified lines | ||
| 238 | 242 | }; |
| 239 | 243 | |
| 240 | 244 | const handleRemove = (slug: string) => { |
| 241 | − onChange(customModels.filter((model) => model !== slug)); | |
| 245 | + if (editingSlug === slug) setEditingSlug(null); | |
| 246 | + onChange(customModels.filter((entry) => entry.slug !== slug)); | |
| 242 | 247 | onModelOrderChange(modelOrder.filter((model) => model !== slug)); |
| 243 | 248 | onFavoriteModelsChange(favoriteModels.filter((model) => model !== slug)); |
| 244 | 249 | setError(null); |
| 245 | 250 | }; |
| 246 | 251 | |
| 252 | + const handleSaveEdit = (next: CustomModelDefinition) => { | |
| 253 | + onChange(customModels.map((entry) => (entry.slug === next.slug ? next : entry))); | |
| 254 | + setEditingSlug(null); | |
| 255 | + }; | |
| 256 | + | |
| 247 | 257 | const setHidden = (slug: string, hidden: boolean) => { |
| 248 | 258 | if (hidden === hiddenModelSet.has(slug)) return; |
| 249 | 259 | onHiddenModelsChange( |
| @@ -355,21 +365,40 @@ export function ProviderModelsSection({ · 105 unmodified lines | ||
| 355 | 365 | </> |
| 356 | 366 | ) : null} |
| 357 | 367 | {model.isCustom ? ( |
| 358 | − <Tooltip> | |
| 359 | − <TooltipTrigger | |
| 360 | − render={ | |
| 361 | − <Button | |
| 362 | − size="icon-micro" | |
| 363 | − variant="ghost-muted" | |
| 364 | − aria-label={`Remove ${model.slug}`} | |
| 365 | − onClick={() => handleRemove(model.slug)} | |
| 366 | − /> | |
| 367 | − } | |
| 368 | − > | |
| 369 | − <XIcon className="size-3" /> | |
| 370 | − </TooltipTrigger> | |
| 371 | − <TooltipPopup side="top">Remove custom model</TooltipPopup> | |
| 372 | − </Tooltip> | |
| 368 | + <> | |
| 369 | + <Tooltip> | |
| 370 | + <TooltipTrigger | |
| 371 | + render={ | |
| 372 | + <Button | |
| 373 | + size="icon-micro" | |
| 374 | + variant="ghost-muted" | |
| 375 | + aria-label={`Edit ${model.slug}`} | |
| 376 | + onClick={() => | |
| 377 | + setEditingSlug((current) => (current === model.slug ? null : model.slug)) | |
| 378 | + } | |
| 379 | + /> | |
| 380 | + } | |
| 381 | + > | |
| 382 | + <PencilIcon className="size-3" /> | |
| 383 | + </TooltipTrigger> | |
| 384 | + <TooltipPopup side="top">Edit name and options</TooltipPopup> | |
| 385 | + </Tooltip> | |
| 386 | + <Tooltip> | |
| 387 | + <TooltipTrigger | |
| 388 | + render={ | |
| 389 | + <Button | |
| 390 | + size="icon-micro" | |
| 391 | + variant="ghost-muted" | |
| 392 | + aria-label={`Remove ${model.slug}`} | |
| 393 | + onClick={() => handleRemove(model.slug)} | |
| 394 | + /> | |
| 395 | + } | |
| 396 | + > | |
| 397 | + <XIcon className="size-3" /> | |
| 398 | + </TooltipTrigger> | |
| 399 | + <TooltipPopup side="top">Remove custom model</TooltipPopup> | |
| 400 | + </Tooltip> | |
| 401 | + </> | |
| 373 | 402 | ) : null} |
| 374 | 403 | </span> |
| 375 | 404 | ); |
| @@ -420,20 +449,23 @@ export function ProviderModelsSection({ · 44 unmodified lines | ||
| 420 | 449 | key={`${instanceId}:${model.slug}`} |
| 421 | 450 | data-model-slug={model.slug} |
| 422 | 451 | className={cn( |
| 423 | − "grid h-7 grid-cols-[1.5rem_minmax(0,1fr)_auto_4rem_auto] items-center gap-2 rounded-md px-2 transition-colors hover:bg-muted/30", | |
| 452 | + // Actions column is at least wide enough for the four custom-row | |
| 453 | + // buttons so capability labels line up across built-in and custom rows. | |
| 454 | + "grid h-7 grid-cols-[1.5rem_minmax(0,1fr)_auto_minmax(5.5rem,auto)_auto] items-center gap-2 rounded-md px-2 transition-colors hover:bg-muted/30", | |
| 424 | 455 | isHidden && "opacity-50", |
| 425 | 456 | )} |
| 426 | 457 | > |
| 427 | 458 | {starButton(model, isFavorite)} |
| 428 | 459 | <span className="flex min-w-0 items-baseline gap-2"> |
| 429 | 460 | <span className={cn(nameClassName, "truncate")}>{model.name}</span> |
| 430 | − {model.isCustom ? ( | |
| 431 | − <span className="text-[11px] text-muted-foreground/70">custom</span> | |
| 432 | − ) : model.name !== model.slug ? ( | |
| 461 | + {model.name !== model.slug ? ( | |
| 433 | 462 | <code className="truncate font-mono text-[11px] text-muted-foreground/70"> |
| 434 | 463 | {model.slug} |
| 435 | 464 | </code> |
| 436 | 465 | ) : null} |
| 466 | + {model.isCustom ? ( | |
| 467 | + <span className="text-[11px] text-muted-foreground/70">custom</span> | |
| 468 | + ) : null} | |
| 437 | 469 | </span> |
| 438 | 470 | {/* |
| 439 | 471 | Always a grid item so the columns line up across rows; the text |
| @@ -489,6 +521,10 @@ export function ProviderModelsSection({ · 49 unmodified lines | ||
| 489 | 521 | const group = groupOf(model); |
| 490 | 522 | const previous = visibleModels[index - 1]; |
| 491 | 523 | const startsGroup = previous === undefined || groupOf(previous) !== group; |
| 524 | + const editingEntry = | |
| 525 | + model.isCustom && editingSlug === model.slug | |
| 526 | + ? customModels.find((entry) => entry.slug === model.slug) | |
| 527 | + : undefined; | |
| 492 | 528 | return ( |
| 493 | 529 | <div key={`${instanceId}:${model.slug}:group`}> |
| 494 | 530 | {startsGroup && favoriteCount > 0 && group === "favorite" |
| @@ -501,6 +537,17 @@ export function ProviderModelsSection({ · 6 unmodified lines | ||
| 501 | 537 | ? groupLabel("Hidden from picker", index === 0) |
| 502 | 538 | : null} |
| 503 | 539 | {renderRow(model)} |
| 540 | + {editingEntry ? ( | |
| 541 | + <CustomModelEditor | |
| 542 | + key={`${instanceId}:${model.slug}:editor`} | |
| 543 | + instanceId={instanceId} | |
| 544 | + driverKind={driverKind} | |
| 545 | + entry={editingEntry} | |
| 546 | + builtInModels={builtInModels} | |
| 547 | + onSave={handleSaveEdit} | |
| 548 | + onCancel={() => setEditingSlug(null)} | |
| 549 | + /> | |
| 550 | + ) : null} | |
| 504 | 551 | </div> |
| 505 | 552 | ); |
| 506 | 553 | })} |
| @@ -0,0 +1,259 @@ | ||
| 1 | +import { describe, expect, it } from "vite-plus/test"; | |
| 2 | +import { ProviderDriverKind, type ModelCapabilities } from "@t3tools/contracts"; | |
| 3 | + | |
| 4 | +import { | |
| 5 | + DESCRIPTOR_PRESETS_BY_KIND, | |
| 6 | + descriptorFromPreset, | |
| 7 | + definitionFromDraft, | |
| 8 | + descriptorsFromCapabilities, | |
| 9 | + draftFromDefinition, | |
| 10 | + validateDraft, | |
| 11 | + type CustomModelDraft, | |
| 12 | +} from "./customModelEditor.logic"; | |
| 13 | + | |
| 14 | +const draft = (overrides: Partial<CustomModelDraft>): CustomModelDraft => ({ | |
| 15 | + slug: "my-model", | |
| 16 | + name: "", | |
| 17 | + descriptors: [], | |
| 18 | + ...overrides, | |
| 19 | +}); | |
| 20 | + | |
| 21 | +describe("customModelEditor.logic", () => { | |
| 22 | + it("round-trips a definition through the draft, marking the current value as default", () => { | |
| 23 | + const definition = definitionFromDraft( | |
| 24 | + draft({ | |
| 25 | + name: " My Model ", | |
| 26 | + descriptors: [ | |
| 27 | + { | |
| 28 | + key: "a", | |
| 29 | + type: "select", | |
| 30 | + id: "reasoningEffort", | |
| 31 | + label: "Reasoning", | |
| 32 | + choices: [ | |
| 33 | + { key: "a1", id: "low", label: "Low", isDefault: false }, | |
| 34 | + { key: "a2", id: "high", label: "", isDefault: true }, | |
| 35 | + ], | |
| 36 | + }, | |
| 37 | + { key: "b", type: "boolean", id: "fastMode", label: "Fast Mode", choices: [] }, | |
| 38 | + ], | |
| 39 | + }), | |
| 40 | + ); | |
| 41 | + | |
| 42 | + expect(definition).toEqual({ | |
| 43 | + slug: "my-model", | |
| 44 | + name: "My Model", | |
| 45 | + capabilities: { | |
| 46 | + optionDescriptors: [ | |
| 47 | + { | |
| 48 | + id: "reasoningEffort", | |
| 49 | + label: "Reasoning", | |
| 50 | + type: "select", | |
| 51 | + options: [ | |
| 52 | + { id: "low", label: "Low" }, | |
| 53 | + { id: "high", label: "high", isDefault: true }, | |
| 54 | + ], | |
| 55 | + currentValue: "high", | |
| 56 | + }, | |
| 57 | + { id: "fastMode", label: "Fast Mode", type: "boolean" }, | |
| 58 | + ], | |
| 59 | + }, | |
| 60 | + }); | |
| 61 | + | |
| 62 | + const reopened = draftFromDefinition(definition); | |
| 63 | + expect(reopened.name).toBe("My Model"); | |
| 64 | + expect(reopened.descriptors.map((descriptor) => descriptor.id)).toEqual([ | |
| 65 | + "reasoningEffort", | |
| 66 | + "fastMode", | |
| 67 | + ]); | |
| 68 | + expect(reopened.descriptors[0]!.choices.map((choice) => choice.isDefault)).toEqual([ | |
| 69 | + false, | |
| 70 | + true, | |
| 71 | + ]); | |
| 72 | + }); | |
| 73 | + | |
| 74 | + it("preserves the current choice when it differs from the built-in default", () => { | |
| 75 | + const descriptors = descriptorsFromCapabilities( | |
| 76 | + { | |
| 77 | + optionDescriptors: [ | |
| 78 | + { | |
| 79 | + id: "effort", | |
| 80 | + label: "Reasoning", | |
| 81 | + type: "select", | |
| 82 | + currentValue: "high", | |
| 83 | + options: [ | |
| 84 | + { id: "low", label: "Low", isDefault: true }, | |
| 85 | + { id: "high", label: "High" }, | |
| 86 | + ], | |
| 87 | + }, | |
| 88 | + ], | |
| 89 | + }, | |
| 90 | + ProviderDriverKind.make("claudeAgent"), | |
| 91 | + ); | |
| 92 | + expect(descriptors[0]!.choices.map((choice) => choice.isDefault)).toEqual([false, true]); | |
| 93 | + expect( | |
| 94 | + definitionFromDraft(draft({ descriptors })).capabilities?.optionDescriptors?.[0], | |
| 95 | + ).toMatchObject({ currentValue: "high" }); | |
| 96 | + }); | |
| 97 | + | |
| 98 | + it("drops prompt-injected choices when copying a built-in's descriptors", () => { | |
| 99 | + const [copied] = descriptorsFromCapabilities( | |
| 100 | + { | |
| 101 | + optionDescriptors: [ | |
| 102 | + { | |
| 103 | + id: "effort", | |
| 104 | + label: "Reasoning", | |
| 105 | + type: "select", | |
| 106 | + options: [ | |
| 107 | + { id: "high", label: "High", isDefault: true }, | |
| 108 | + { id: "ultrathink", label: "Ultrathink" }, | |
| 109 | + ], | |
| 110 | + promptInjectedValues: ["ultrathink"], | |
| 111 | + }, | |
| 112 | + ], | |
| 113 | + }, | |
| 114 | + ProviderDriverKind.make("claudeAgent"), | |
| 115 | + ); | |
| 116 | + expect(copied!.choices.map((choice) => choice.id)).toEqual(["high"]); | |
| 117 | + }); | |
| 118 | + | |
| 119 | + it.each([true, false, undefined])( | |
| 120 | + "preserves boolean values through copy and edit: %s", | |
| 121 | + (currentValue) => { | |
| 122 | + const capabilities: ModelCapabilities = { | |
| 123 | + optionDescriptors: [ | |
| 124 | + { | |
| 125 | + id: "thinking", | |
| 126 | + label: "Thinking", | |
| 127 | + type: "boolean", | |
| 128 | + ...(currentValue !== undefined ? { currentValue } : {}), | |
| 129 | + }, | |
| 130 | + ], | |
| 131 | + }; | |
| 132 | + const copied = definitionFromDraft( | |
| 133 | + draft({ | |
| 134 | + descriptors: descriptorsFromCapabilities(capabilities, ProviderDriverKind.make("cursor")), | |
| 135 | + }), | |
| 136 | + ); | |
| 137 | + expect(copied.capabilities).toEqual(capabilities); | |
| 138 | + const reopened = draftFromDefinition(copied); | |
| 139 | + expect(definitionFromDraft({ ...reopened, name: "Renamed" }).capabilities).toEqual( | |
| 140 | + capabilities, | |
| 141 | + ); | |
| 142 | + }, | |
| 143 | + ); | |
| 144 | + | |
| 145 | + it("excludes Claude context choices from presets and copies without changing other providers or authored entries", () => { | |
| 146 | + const capabilities: ModelCapabilities = { | |
| 147 | + optionDescriptors: [ | |
| 148 | + { | |
| 149 | + id: "contextWindow", | |
| 150 | + label: "Context", | |
| 151 | + type: "select", | |
| 152 | + options: [{ id: "1m", label: "1M", isDefault: true }], | |
| 153 | + }, | |
| 154 | + { id: "thinking", label: "Thinking", type: "boolean", currentValue: true }, | |
| 155 | + ], | |
| 156 | + }; | |
| 157 | + const claude = ProviderDriverKind.make("claudeAgent"); | |
| 158 | + const copied = definitionFromDraft( | |
| 159 | + draft({ descriptors: descriptorsFromCapabilities(capabilities, claude) }), | |
| 160 | + ); | |
| 161 | + expect(copied.capabilities?.optionDescriptors).toEqual([capabilities.optionDescriptors![1]]); | |
| 162 | + const presets = definitionFromDraft( | |
| 163 | + draft({ | |
| 164 | + descriptors: (DESCRIPTOR_PRESETS_BY_KIND[claude] ?? []).map(descriptorFromPreset), | |
| 165 | + }), | |
| 166 | + ); | |
| 167 | + expect( | |
| 168 | + presets.capabilities?.optionDescriptors?.some((option) => option.id === "contextWindow"), | |
| 169 | + ).toBe(false); | |
| 170 | + const cursorCopy = descriptorsFromCapabilities(capabilities, ProviderDriverKind.make("cursor")); | |
| 171 | + expect(cursorCopy.map((option) => option.id)).toEqual(["contextWindow", "thinking"]); | |
| 172 | + const authored = { slug: "custom", name: "Custom", capabilities }; | |
| 173 | + expect( | |
| 174 | + definitionFromDraft(draftFromDefinition(authored)).capabilities?.optionDescriptors?.[0], | |
| 175 | + ).toMatchObject(capabilities.optionDescriptors![0]!); | |
| 176 | + }); | |
| 177 | + | |
| 178 | + it("preserves choice descriptions when copying, renaming, and saving", () => { | |
| 179 | + const capabilities: ModelCapabilities = { | |
| 180 | + optionDescriptors: [ | |
| 181 | + { | |
| 182 | + id: "effort", | |
| 183 | + label: "Reasoning", | |
| 184 | + description: "Choose a reasoning level.", | |
| 185 | + type: "select", | |
| 186 | + options: [ | |
| 187 | + { id: "high", label: "High", isDefault: true }, | |
| 188 | + { id: "ultracode", label: "Ultracode", description: "Uses additional reasoning." }, | |
| 189 | + ], | |
| 190 | + }, | |
| 191 | + ], | |
| 192 | + }; | |
| 193 | + const copied = definitionFromDraft( | |
| 194 | + draft({ | |
| 195 | + descriptors: descriptorsFromCapabilities( | |
| 196 | + capabilities, | |
| 197 | + ProviderDriverKind.make("claudeAgent"), | |
| 198 | + ), | |
| 199 | + }), | |
| 200 | + ); | |
| 201 | + const reopened = draftFromDefinition(copied); | |
| 202 | + const saved = definitionFromDraft({ ...reopened, name: "Renamed" }); | |
| 203 | + expect(saved.capabilities?.optionDescriptors?.[0]).toMatchObject( | |
| 204 | + capabilities.optionDescriptors![0]!, | |
| 205 | + ); | |
| 206 | + expect(copied.capabilities?.optionDescriptors?.[0]).toMatchObject( | |
| 207 | + capabilities.optionDescriptors![0]!, | |
| 208 | + ); | |
| 209 | + }); | |
| 210 | + | |
| 211 | + it.each(Object.entries(DESCRIPTOR_PRESETS_BY_KIND))( | |
| 212 | + "offers saveable presets for %s", | |
| 213 | + (_driver, presets) => { | |
| 214 | + expect( | |
| 215 | + validateDraft(draft({ descriptors: (presets ?? []).map(descriptorFromPreset) })), | |
| 216 | + ).toBeNull(); | |
| 217 | + }, | |
| 218 | + ); | |
| 219 | + | |
| 220 | + it("collapses a blank name and no options back to a bare definition", () => { | |
| 221 | + expect(definitionFromDraft(draft({ name: " " }))).toEqual({ | |
| 222 | + slug: "my-model", | |
| 223 | + name: "my-model", | |
| 224 | + capabilities: null, | |
| 225 | + }); | |
| 226 | + expect(draftFromDefinition({ slug: "x", name: "x", capabilities: null }).name).toBe(""); | |
| 227 | + }); | |
| 228 | + | |
| 229 | + it("rejects duplicate ids, blank ids, and selects without choices", () => { | |
| 230 | + const select = (id: string, choices: Array<{ id: string }>) => ({ | |
| 231 | + key: id, | |
| 232 | + type: "select" as const, | |
| 233 | + id, | |
| 234 | + label: "Label", | |
| 235 | + choices: choices.map((choice) => ({ | |
| 236 | + key: choice.id, | |
| 237 | + label: "", | |
| 238 | + isDefault: false, | |
| 239 | + ...choice, | |
| 240 | + })), | |
| 241 | + }); | |
| 242 | + | |
| 243 | + expect(validateDraft(draft({ descriptors: [select("", [{ id: "a" }])] }))).toBe( | |
| 244 | + "Option 1 needs an id.", | |
| 245 | + ); | |
| 246 | + expect( | |
| 247 | + validateDraft( | |
| 248 | + draft({ descriptors: [select("effort", [{ id: "a" }]), select("effort", [{ id: "b" }])] }), | |
| 249 | + ), | |
| 250 | + ).toBe('Option 2: id "effort" is used twice.'); | |
| 251 | + expect(validateDraft(draft({ descriptors: [select("effort", [])] }))).toBe( | |
| 252 | + "Option 1 needs at least one choice.", | |
| 253 | + ); | |
| 254 | + expect( | |
| 255 | + validateDraft(draft({ descriptors: [select("effort", [{ id: "a" }, { id: "a" }])] })), | |
| 256 | + ).toBe('Option 1: choice "a" is used twice.'); | |
| 257 | + expect(validateDraft(draft({ descriptors: [select("effort", [{ id: "a" }])] }))).toBeNull(); | |
| 258 | + }); | |
| 259 | +}); | |
| @@ -0,0 +1,258 @@ | ||
| 1 | +import { | |
| 2 | + type ModelCapabilities, | |
| 3 | + ProviderDriverKind, | |
| 4 | + type ProviderOptionDescriptor, | |
| 5 | +} from "@t3tools/contracts"; | |
| 6 | +import { type CustomModelDefinition, createModelCapabilities } from "@t3tools/shared/model"; | |
| 7 | + | |
| 8 | +/** Editable mirror of a `ProviderOptionChoice`. `key` is only a React key. */ | |
| 9 | +export interface EditorChoice { | |
| 10 | + readonly key: string; | |
| 11 | + readonly id: string; | |
| 12 | + readonly label: string; | |
| 13 | + readonly isDefault: boolean; | |
| 14 | + readonly description?: string; | |
| 15 | +} | |
| 16 | + | |
| 17 | +/** Editable mirror of a `ProviderOptionDescriptor`. `key` is only a React key. */ | |
| 18 | +export interface EditorDescriptor { | |
| 19 | + readonly key: string; | |
| 20 | + readonly type: "select" | "boolean"; | |
| 21 | + readonly id: string; | |
| 22 | + readonly label: string; | |
| 23 | + readonly choices: ReadonlyArray<EditorChoice>; | |
| 24 | + readonly currentBooleanValue?: boolean | undefined; | |
| 25 | + readonly description?: string | undefined; | |
| 26 | +} | |
| 27 | + | |
| 28 | +export interface CustomModelDraft { | |
| 29 | + readonly slug: string; | |
| 30 | + readonly name: string; | |
| 31 | + readonly descriptors: ReadonlyArray<EditorDescriptor>; | |
| 32 | +} | |
| 33 | + | |
| 34 | +export interface DescriptorPreset { | |
| 35 | + readonly id: string; | |
| 36 | + readonly label: string; | |
| 37 | + readonly type: "select" | "boolean"; | |
| 38 | + readonly choices?: ReadonlyArray<{ id: string; label: string; isDefault?: boolean }>; | |
| 39 | +} | |
| 40 | + | |
| 41 | +const EFFORT_CHOICES = [ | |
| 42 | + { id: "low", label: "Low" }, | |
| 43 | + { id: "medium", label: "Medium", isDefault: true }, | |
| 44 | + { id: "high", label: "High" }, | |
| 45 | + { id: "xhigh", label: "Extra High" }, | |
| 46 | +] as const; | |
| 47 | + | |
| 48 | +/** | |
| 49 | + * Option ids each adapter actually reads off a turn's model selection, with | |
| 50 | + * the usual choices pre-filled. Anything else the user types is stored | |
| 51 | + * verbatim but will be ignored by the driver. | |
| 52 | + */ | |
| 53 | +export const DESCRIPTOR_PRESETS_BY_KIND: Partial< | |
| 54 | + Record<ProviderDriverKind, ReadonlyArray<DescriptorPreset>> | |
| 55 | +> = { | |
| 56 | + [ProviderDriverKind.make("codex")]: [ | |
| 57 | + { id: "reasoningEffort", label: "Reasoning", type: "select", choices: EFFORT_CHOICES }, | |
| 58 | + { | |
| 59 | + id: "serviceTier", | |
| 60 | + label: "Speed", | |
| 61 | + type: "select", | |
| 62 | + choices: [ | |
| 63 | + { id: "default", label: "Standard", isDefault: true }, | |
| 64 | + { id: "fast", label: "Fast" }, | |
| 65 | + ], | |
| 66 | + }, | |
| 67 | + ], | |
| 68 | + [ProviderDriverKind.make("claudeAgent")]: [ | |
| 69 | + { | |
| 70 | + id: "effort", | |
| 71 | + label: "Reasoning", | |
| 72 | + type: "select", | |
| 73 | + choices: [ | |
| 74 | + { id: "low", label: "Low" }, | |
| 75 | + { id: "medium", label: "Medium" }, | |
| 76 | + { id: "high", label: "High", isDefault: true }, | |
| 77 | + { id: "xhigh", label: "Extra High" }, | |
| 78 | + { id: "max", label: "Max" }, | |
| 79 | + ], | |
| 80 | + }, | |
| 81 | + { id: "fastMode", label: "Fast Mode", type: "boolean" }, | |
| 82 | + { id: "thinking", label: "Thinking", type: "boolean" }, | |
| 83 | + ], | |
| 84 | + [ProviderDriverKind.make("cursor")]: [ | |
| 85 | + { id: "reasoning", label: "Reasoning", type: "select", choices: EFFORT_CHOICES }, | |
| 86 | + { id: "fastMode", label: "Fast Mode", type: "boolean" }, | |
| 87 | + { id: "thinking", label: "Thinking", type: "boolean" }, | |
| 88 | + ], | |
| 89 | + [ProviderDriverKind.make("grok")]: [ | |
| 90 | + { id: "reasoningEffort", label: "Reasoning", type: "select", choices: EFFORT_CHOICES }, | |
| 91 | + ], | |
| 92 | + [ProviderDriverKind.make("opencode")]: [ | |
| 93 | + { id: "variant", label: "Reasoning", type: "select", choices: EFFORT_CHOICES }, | |
| 94 | + { | |
| 95 | + id: "agent", | |
| 96 | + label: "Agent", | |
| 97 | + type: "select", | |
| 98 | + choices: [ | |
| 99 | + { id: "build", label: "Build", isDefault: true }, | |
| 100 | + { id: "plan", label: "Plan" }, | |
| 101 | + ], | |
| 102 | + }, | |
| 103 | + ], | |
| 104 | +}; | |
| 105 | + | |
| 106 | +let nextKey = 0; | |
| 107 | +export function newEditorKey(): string { | |
| 108 | + nextKey += 1; | |
| 109 | + return `k${nextKey}`; | |
| 110 | +} | |
| 111 | + | |
| 112 | +export function choiceFromPreset(choice: { | |
| 113 | + id: string; | |
| 114 | + label: string; | |
| 115 | + isDefault?: boolean; | |
| 116 | +}): EditorChoice { | |
| 117 | + return { key: newEditorKey(), id: choice.id, label: choice.label, isDefault: !!choice.isDefault }; | |
| 118 | +} | |
| 119 | + | |
| 120 | +export function descriptorFromPreset(preset: DescriptorPreset): EditorDescriptor { | |
| 121 | + return { | |
| 122 | + key: newEditorKey(), | |
| 123 | + type: preset.type, | |
| 124 | + id: preset.id, | |
| 125 | + label: preset.label, | |
| 126 | + choices: (preset.choices ?? []).map(choiceFromPreset), | |
| 127 | + }; | |
| 128 | +} | |
| 129 | + | |
| 130 | +export function emptyEditorDescriptor(): EditorDescriptor { | |
| 131 | + return { key: newEditorKey(), type: "select", id: "", label: "", choices: [] }; | |
| 132 | +} | |
| 133 | + | |
| 134 | +export function emptyEditorChoice(): EditorChoice { | |
| 135 | + return { key: newEditorKey(), id: "", label: "", isDefault: false }; | |
| 136 | +} | |
| 137 | + | |
| 138 | +/** | |
| 139 | + * Prompt-injected choices (Claude's `ultrathink`) are delivered as prompt text | |
| 140 | + * by built-in runtime profiles a custom entry does not have, so they are | |
| 141 | + * dropped rather than stored as a plain option value. | |
| 142 | + */ | |
| 143 | +export function descriptorToEditor(descriptor: ProviderOptionDescriptor): EditorDescriptor { | |
| 144 | + const promptInjected = new Set( | |
| 145 | + descriptor.type === "select" ? (descriptor.promptInjectedValues ?? []) : [], | |
| 146 | + ); | |
| 147 | + const choices = | |
| 148 | + descriptor.type === "select" | |
| 149 | + ? descriptor.options.filter((option) => !promptInjected.has(option.id)) | |
| 150 | + : []; | |
| 151 | + const defaultChoice = | |
| 152 | + choices.find((option) => option.id === descriptor.currentValue) ?? | |
| 153 | + choices.find((option) => option.isDefault); | |
| 154 | + return { | |
| 155 | + key: newEditorKey(), | |
| 156 | + type: descriptor.type, | |
| 157 | + id: descriptor.id, | |
| 158 | + label: descriptor.label, | |
| 159 | + ...(descriptor.description !== undefined ? { description: descriptor.description } : {}), | |
| 160 | + ...(descriptor.type === "boolean" && descriptor.currentValue !== undefined | |
| 161 | + ? { currentBooleanValue: descriptor.currentValue } | |
| 162 | + : {}), | |
| 163 | + choices: choices.map((option) => ({ | |
| 164 | + key: newEditorKey(), | |
| 165 | + id: option.id, | |
| 166 | + label: option.label, | |
| 167 | + ...(option.description !== undefined ? { description: option.description } : {}), | |
| 168 | + isDefault: option === defaultChoice, | |
| 169 | + })), | |
| 170 | + }; | |
| 171 | +} | |
| 172 | + | |
| 173 | +export function draftFromDefinition(entry: CustomModelDefinition): CustomModelDraft { | |
| 174 | + return { | |
| 175 | + slug: entry.slug, | |
| 176 | + name: entry.name === entry.slug ? "" : entry.name, | |
| 177 | + descriptors: (entry.capabilities?.optionDescriptors ?? []).map(descriptorToEditor), | |
| 178 | + }; | |
| 179 | +} | |
| 180 | + | |
| 181 | +/** Claude context choices require runtime suffix mappings that custom entries do not carry. */ | |
| 182 | +export function descriptorsFromCapabilities( | |
| 183 | + capabilities: ModelCapabilities | null | undefined, | |
| 184 | + driverKind: ProviderDriverKind | null, | |
| 185 | +): EditorDescriptor[] { | |
| 186 | + return (capabilities?.optionDescriptors ?? []) | |
| 187 | + .filter((descriptor) => driverKind !== "claudeAgent" || descriptor.id !== "contextWindow") | |
| 188 | + .map(descriptorToEditor); | |
| 189 | +} | |
| 190 | + | |
| 191 | +/** | |
| 192 | + * Validate the draft before saving. Returns the first problem in reading | |
| 193 | + * order so the message is actionable, or `null` when the draft is sound. | |
| 194 | + */ | |
| 195 | +export function validateDraft(draft: CustomModelDraft): string | null { | |
| 196 | + const seenIds = new Set<string>(); | |
| 197 | + for (const [index, descriptor] of draft.descriptors.entries()) { | |
| 198 | + const position = `Option ${index + 1}`; | |
| 199 | + const id = descriptor.id.trim(); | |
| 200 | + if (!id) return `${position} needs an id.`; | |
| 201 | + if (seenIds.has(id)) return `${position}: id "${id}" is used twice.`; | |
| 202 | + seenIds.add(id); | |
| 203 | + if (!descriptor.label.trim()) return `${position} needs a label.`; | |
| 204 | + if (descriptor.type !== "select") continue; | |
| 205 | + if (descriptor.choices.length === 0) return `${position} needs at least one choice.`; | |
| 206 | + const seenChoices = new Set<string>(); | |
| 207 | + for (const choice of descriptor.choices) { | |
| 208 | + const choiceId = choice.id.trim(); | |
| 209 | + if (!choiceId) return `${position} has a choice without a value.`; | |
| 210 | + if (seenChoices.has(choiceId)) { | |
| 211 | + return `${position}: choice "${choiceId}" is used twice.`; | |
| 212 | + } | |
| 213 | + seenChoices.add(choiceId); | |
| 214 | + } | |
| 215 | + } | |
| 216 | + return null; | |
| 217 | +} | |
| 218 | + | |
| 219 | +/** Convert a validated draft back into a definition. Blank name → slug. */ | |
| 220 | +export function definitionFromDraft(draft: CustomModelDraft): CustomModelDefinition { | |
| 221 | + const descriptors: ProviderOptionDescriptor[] = draft.descriptors.map((descriptor) => { | |
| 222 | + const id = descriptor.id.trim(); | |
| 223 | + const label = descriptor.label.trim(); | |
| 224 | + if (descriptor.type === "boolean") { | |
| 225 | + return { | |
| 226 | + id, | |
| 227 | + label, | |
| 228 | + type: "boolean", | |
| 229 | + ...(descriptor.description !== undefined ? { description: descriptor.description } : {}), | |
| 230 | + ...(descriptor.currentBooleanValue !== undefined | |
| 231 | + ? { currentValue: descriptor.currentBooleanValue } | |
| 232 | + : {}), | |
| 233 | + }; | |
| 234 | + } | |
| 235 | + const options = descriptor.choices.map((choice) => ({ | |
| 236 | + id: choice.id.trim(), | |
| 237 | + label: choice.label.trim() || choice.id.trim(), | |
| 238 | + ...(choice.description !== undefined ? { description: choice.description } : {}), | |
| 239 | + ...(choice.isDefault ? { isDefault: true } : {}), | |
| 240 | + })); | |
| 241 | + const currentValue = options.find((option) => option.isDefault)?.id; | |
| 242 | + return { | |
| 243 | + id, | |
| 244 | + label, | |
| 245 | + type: "select", | |
| 246 | + ...(descriptor.description !== undefined ? { description: descriptor.description } : {}), | |
| 247 | + options, | |
| 248 | + ...(currentValue ? { currentValue } : {}), | |
| 249 | + }; | |
| 250 | + }); | |
| 251 | + const name = draft.name.trim(); | |
| 252 | + return { | |
| 253 | + slug: draft.slug, | |
| 254 | + name: name || draft.slug, | |
| 255 | + capabilities: | |
| 256 | + descriptors.length > 0 ? createModelCapabilities({ optionDescriptors: descriptors }) : null, | |
| 257 | + }; | |
| 258 | +} | |
| @@ -10,8 +10,10 @@ import { · 9 unmodified lines | ||
| 10 | 10 | type ServerSettingsPatch, |
| 11 | 11 | } from "@t3tools/contracts"; |
| 12 | 12 | import { |
| 13 | + type CustomModelDefinition, | |
| 13 | 14 | createModelSelection, |
| 14 | 15 | normalizeCustomModelSlug, |
| 16 | + readCustomModelEntries, | |
| 15 | 17 | resolveSelectableModel, |
| 16 | 18 | } from "@t3tools/shared/model"; |
| 17 | 19 | import { getComposerProviderState } from "./components/chat/composerProviderState"; |
| @@ -55,14 +57,14 @@ function readInstanceCustomModels( · 37 unmodified lines | ||
| 55 | 57 | settings: UnifiedSettings, |
| 56 | 58 | instanceId: ProviderInstanceId, |
| 57 | 59 | driverKind: ProviderDriverKind, |
| 58 | −): ReadonlyArray<string> { | |
| 60 | +): ReadonlyArray<CustomModelDefinition> { | |
| 59 | 61 | if (driverKind === "antigravity") return []; |
| 60 | 62 | const instance = settings.providerInstances?.[instanceId]; |
| 61 | 63 | const config = instance?.config; |
| 62 | 64 | if (config !== null && typeof config === "object") { |
| 63 | 65 | const value = (config as Record<string, unknown>).customModels; |
| 64 | 66 | if (Array.isArray(value)) { |
| 65 | − return value.filter((entry): entry is string => typeof entry === "string"); | |
| 67 | + return readCustomModelEntries(value); | |
| 66 | 68 | } |
| 67 | 69 | } |
| 68 | 70 | const defaultInstanceId = defaultInstanceIdForDriver(driverKind); |
| @@ -71,9 +73,9 @@ function readInstanceCustomModels( · 2 unmodified lines | ||
| 71 | 73 | } |
| 72 | 74 | const legacyProviders = settings.providers as Record< |
| 73 | 75 | string, |
| 74 | − { readonly customModels: ReadonlyArray<string> } | undefined | |
| 76 | + { readonly customModels: ReadonlyArray<unknown> } | undefined | |
| 75 | 77 | >; |
| 76 | − return legacyProviders[driverKind]?.customModels ?? []; | |
| 78 | + return readCustomModelEntries(legacyProviders[driverKind]?.customModels ?? []); | |
| 77 | 79 | } |
| 78 | 80 | |
| 79 | 81 | export interface AppModelOption { |
| @@ -151,26 +153,24 @@ function applyInstanceModelPreferences( · 71 unmodified lines | ||
| 151 | 153 | ); |
| 152 | 154 | } |
| 153 | 155 | |
| 154 | −export function normalizeCustomModelSlugs( | |
| 155 | − models: Iterable<string | null | undefined>, | |
| 156 | +export function normalizeCustomModelEntries( | |
| 157 | + models: ReadonlyArray<CustomModelDefinition>, | |
| 156 | 158 | builtInModelSlugs: ReadonlySet<string>, |
| 157 | −): string[] { | |
| 158 | − const normalizedModels: string[] = []; | |
| 159 | +): CustomModelDefinition[] { | |
| 160 | + const normalizedModels: CustomModelDefinition[] = []; | |
| 159 | 161 | const seen = new Set<string>(); |
| 160 | 162 | |
| 161 | 163 | for (const candidate of models) { |
| 162 | − const normalized = normalizeCustomModelSlug(candidate); | |
| 163 | 164 | if ( |
| 164 | − !normalized || | |
| 165 | − normalized.length > MAX_CUSTOM_MODEL_LENGTH || | |
| 166 | − builtInModelSlugs.has(normalized) || | |
| 167 | − seen.has(normalized) | |
| 165 | + candidate.slug.length > MAX_CUSTOM_MODEL_LENGTH || | |
| 166 | + builtInModelSlugs.has(candidate.slug) || | |
| 167 | + seen.has(candidate.slug) | |
| 168 | 168 | ) { |
| 169 | 169 | continue; |
| 170 | 170 | } |
| 171 | 171 | |
| 172 | − seen.add(normalized); | |
| 173 | − normalizedModels.push(normalized); | |
| 172 | + seen.add(candidate.slug); | |
| 173 | + normalizedModels.push(candidate); | |
| 174 | 174 | if (normalizedModels.length >= MAX_CUSTOM_MODEL_COUNT) { |
| 175 | 175 | break; |
| 176 | 176 | } |
| @@ -205,17 +205,13 @@ export function getAppModelOptions( · 28 unmodified lines | ||
| 205 | 205 | // see the user's authored custom models. |
| 206 | 206 | const defaultInstanceId = defaultInstanceIdForDriver(provider); |
| 207 | 207 | const customModels = readInstanceCustomModels(settings, defaultInstanceId, provider); |
| 208 | − for (const slug of normalizeCustomModelSlugs(customModels, builtInModelSlugs)) { | |
| 209 | − if (seen.has(slug)) { | |
| 208 | + for (const entry of normalizeCustomModelEntries(customModels, builtInModelSlugs)) { | |
| 209 | + if (seen.has(entry.slug)) { | |
| 210 | 210 | continue; |
| 211 | 211 | } |
| 212 | 212 | |
| 213 | − seen.add(slug); | |
| 214 | − options.push({ | |
| 215 | − slug, | |
| 216 | − name: slug, | |
| 217 | − isCustom: true, | |
| 218 | − }); | |
| 213 | + seen.add(entry.slug); | |
| 214 | + options.push({ slug: entry.slug, name: entry.name, isCustom: true }); | |
| 219 | 215 | } |
| 220 | 216 | |
| 221 | 217 | const preferences = readInstanceModelPreferences(settings, defaultInstanceId); |
| @@ -257,13 +253,13 @@ export function getAppModelOptionsForInstance( · 35 unmodified lines | ||
| 257 | 253 | ); |
| 258 | 254 | |
| 259 | 255 | const customModels = readInstanceCustomModels(settings, entry.instanceId, entry.driverKind); |
| 260 | − for (const slug of normalizeCustomModelSlugs(customModels, builtInModelSlugs)) { | |
| 261 | − if (seen.has(slug)) { | |
| 256 | + for (const custom of normalizeCustomModelEntries(customModels, builtInModelSlugs)) { | |
| 257 | + if (seen.has(custom.slug)) { | |
| 262 | 258 | continue; |
| 263 | 259 | } |
| 264 | 260 | |
| 265 | − seen.add(slug); | |
| 266 | − options.push({ slug, name: slug, isCustom: true }); | |
| 261 | + seen.add(custom.slug); | |
| 262 | + options.push({ slug: custom.slug, name: custom.name, isCustom: true }); | |
| 267 | 263 | } |
| 268 | 264 | |
| 269 | 265 | const preferences = readInstanceModelPreferences(settings, entry.instanceId); |
| @@ -30,6 +30,12 @@ messages while disconnected. Uploads resume when you reconnect. Drafts and queue · 29 unmodified lines | ||
| 30 | 30 | messages survive app restarts. Signing out of T3 Connect keeps that work on your |
| 31 | 31 | device until you sign back into the same account. |
| 32 | 32 | |
| 33 | +## Custom models | |
| 34 | + | |
| 35 | +On web and desktop, use Settings → Providers → **Models** to add an unlisted model with a custom | |
| 36 | +name and options. Only options supported by the provider integration affect turns. Antigravity | |
| 37 | +uses its account catalog and does not support custom models. | |
| 38 | + | |
| 33 | 39 | ## Model defaults |
| 34 | 40 | |
| 35 | 41 | T3 Code remembers your provider, model, and model options for new threads. A |
| @@ -127,6 +127,22 @@ export const ModelCapabilities = Schema.Struct({ · 126 unmodified lines | ||
| 127 | 127 | }); |
| 128 | 128 | export type ModelCapabilities = typeof ModelCapabilities.Type; |
| 129 | 129 | |
| 130 | +/** | |
| 131 | + * A user-authored custom model. `name` and `capabilities` are optional so a | |
| 132 | + * bare slug keeps its driver-default presentation; when `capabilities` is | |
| 133 | + * set, its descriptors replace the driver default in the model picker. | |
| 134 | + */ | |
| 135 | +export const CustomModelEntry = Schema.Struct({ | |
| 136 | + slug: TrimmedNonEmptyString, | |
| 137 | + name: Schema.optional(TrimmedNonEmptyString), | |
| 138 | + capabilities: Schema.optional(ModelCapabilities), | |
| 139 | +}); | |
| 140 | +export type CustomModelEntry = typeof CustomModelEntry.Type; | |
| 141 | + | |
| 142 | +/** On-disk custom model setting: the legacy bare slug, or a full entry. */ | |
| 143 | +export const CustomModelSetting = Schema.Union([Schema.String, CustomModelEntry]); | |
| 144 | +export type CustomModelSetting = typeof CustomModelSetting.Type; | |
| 145 | + | |
| 130 | 146 | const CODEX_DRIVER_KIND = ProviderDriverKind.make("codex"); |
| 131 | 147 | const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); |
| 132 | 148 | const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor"); |
| @@ -73,6 +73,40 @@ describe("ServerSettings usage price overrides", () => { · 72 unmodified lines | ||
| 73 | 73 | }); |
| 74 | 74 | }); |
| 75 | 75 | |
| 76 | +describe("custom model settings", () => { | |
| 77 | + const capabilities = { | |
| 78 | + optionDescriptors: [ | |
| 79 | + { | |
| 80 | + id: "effort", | |
| 81 | + label: "Reasoning", | |
| 82 | + type: "select", | |
| 83 | + options: [{ id: "high", label: "High", isDefault: true }], | |
| 84 | + }, | |
| 85 | + ], | |
| 86 | + }; | |
| 87 | + | |
| 88 | + it("accepts legacy bare slugs alongside full entries", () => { | |
| 89 | + const decoded = decodeClaudeSettings({ | |
| 90 | + customModels: ["bare-slug", { slug: "named", name: "Named", capabilities }], | |
| 91 | + }); | |
| 92 | + expect(decoded.customModels).toEqual([ | |
| 93 | + "bare-slug", | |
| 94 | + { slug: "named", name: "Named", capabilities }, | |
| 95 | + ]); | |
| 96 | + }); | |
| 97 | + | |
| 98 | + it("accepts entries at the settings patch boundary", () => { | |
| 99 | + expect( | |
| 100 | + decodeServerSettingsPatch({ | |
| 101 | + providers: { codex: { customModels: [{ slug: "x", capabilities }] } }, | |
| 102 | + }).providers?.codex?.customModels, | |
| 103 | + ).toEqual([{ slug: "x", capabilities }]); | |
| 104 | + expect(() => | |
| 105 | + decodeServerSettingsPatch({ providers: { codex: { customModels: [{ name: "no slug" }] } } }), | |
| 106 | + ).toThrow(); | |
| 107 | + }); | |
| 108 | +}); | |
| 109 | + | |
| 76 | 110 | describe("ClaudeSettings auto-compaction", () => { |
| 77 | 111 | it("uses Claude's default threshold when no override is configured", () => { |
| 78 | 112 | expect(decodeClaudeSettings({}).autoCompactWindow).toBe(""); |
| @@ -6,6 +6,7 @@ import { ForwardCompatibleNullable, TrimmedNonEmptyString, TrimmedString } from · 5 unmodified lines | ||
| 6 | 6 | import { UsageLimitSourceId } from "./usageLimitSourceId.ts"; |
| 7 | 7 | import { EnvironmentMachineKind, ThreadEnvMode } from "./environment.ts"; |
| 8 | 8 | import { |
| 9 | + CustomModelSetting, | |
| 9 | 10 | DEFAULT_TEXT_GENERATION_MODEL, |
| 10 | 11 | DEFAULT_TEXT_GENERATION_REASONING_EFFORT, |
| 11 | 12 | ProviderOptionSelections, |
| @@ -472,7 +473,7 @@ export const CodexSettings = makeProviderSettingsSchema( · 460 unmodified lines | ||
| 472 | 473 | description: "Additional CLI arguments passed to codex app-server on session start.", |
| 473 | 474 | }), |
| 474 | 475 | ), |
| 475 | − customModels: Schema.Array(Schema.String).pipe( | |
| 476 | + customModels: Schema.Array(CustomModelSetting).pipe( | |
| 476 | 477 | Schema.withDecodingDefault(Effect.succeed([])), |
| 477 | 478 | Schema.annotateKey({ providerSettingsForm: { hidden: true } }), |
| 478 | 479 | ), |
| @@ -510,7 +511,7 @@ export const ClaudeSettings = makeProviderSettingsSchema( · 31 unmodified lines | ||
| 510 | 511 | providerSettingsForm: { placeholder: "~/.claude", clearWhenEmpty: "omit" }, |
| 511 | 512 | }), |
| 512 | 513 | ), |
| 513 | − customModels: Schema.Array(Schema.String).pipe( | |
| 514 | + customModels: Schema.Array(CustomModelSetting).pipe( | |
| 514 | 515 | Schema.withDecodingDefault(Effect.succeed([])), |
| 515 | 516 | Schema.annotateKey({ providerSettingsForm: { hidden: true } }), |
| 516 | 517 | ), |
| @@ -571,7 +572,7 @@ export const CursorSettings = makeProviderSettingsSchema( · 54 unmodified lines | ||
| 571 | 572 | }, |
| 572 | 573 | }), |
| 573 | 574 | ), |
| 574 | − customModels: Schema.Array(Schema.String).pipe( | |
| 575 | + customModels: Schema.Array(CustomModelSetting).pipe( | |
| 575 | 576 | Schema.withDecodingDefault(Effect.succeed([])), |
| 576 | 577 | Schema.annotateKey({ providerSettingsForm: { hidden: true } }), |
| 577 | 578 | ), |
| @@ -597,7 +598,7 @@ export const GrokSettings = makeProviderSettingsSchema( · 19 unmodified lines | ||
| 597 | 598 | providerSettingsForm: { placeholder: "grok", clearWhenEmpty: "omit" }, |
| 598 | 599 | }), |
| 599 | 600 | ), |
| 600 | − customModels: Schema.Array(Schema.String).pipe( | |
| 601 | + customModels: Schema.Array(CustomModelSetting).pipe( | |
| 601 | 602 | Schema.withDecodingDefault(Effect.succeed([])), |
| 602 | 603 | Schema.annotateKey({ providerSettingsForm: { hidden: true } }), |
| 603 | 604 | ), |
| @@ -682,7 +683,7 @@ export const AntigravitySettings = makeProviderSettingsSchema( · 78 unmodified lines | ||
| 682 | 683 | providerSettingsForm: { placeholder: "Automatic", clearWhenEmpty: "persist" }, |
| 683 | 684 | }), |
| 684 | 685 | ), |
| 685 | − customModels: Schema.Array(Schema.String).pipe( | |
| 686 | + customModels: Schema.Array(CustomModelSetting).pipe( | |
| 686 | 687 | Schema.withDecodingDefault(Effect.succeed([])), |
| 687 | 688 | Schema.annotateKey({ providerSettingsForm: { hidden: true } }), |
| 688 | 689 | ), |
| @@ -732,7 +733,7 @@ export const OpenCodeSettings = makeProviderSettingsSchema( · 43 unmodified lines | ||
| 732 | 733 | }, |
| 733 | 734 | }), |
| 734 | 735 | ), |
| 735 | − customModels: Schema.Array(Schema.String).pipe( | |
| 736 | + customModels: Schema.Array(CustomModelSetting).pipe( | |
| 736 | 737 | Schema.withDecodingDefault(Effect.succeed([])), |
| 737 | 738 | Schema.annotateKey({ providerSettingsForm: { hidden: true } }), |
| 738 | 739 | ), |
| @@ -1055,14 +1056,14 @@ const CodexSettingsPatch = Schema.Struct({ · 316 unmodified lines | ||
| 1055 | 1056 | homePath: Schema.optionalKey(TrimmedString), |
| 1056 | 1057 | shadowHomePath: Schema.optionalKey(TrimmedString), |
| 1057 | 1058 | launchArgs: Schema.optionalKey(TrimmedString), |
| 1058 | − customModels: Schema.optionalKey(Schema.Array(Schema.String)), | |
| 1059 | + customModels: Schema.optionalKey(Schema.Array(CustomModelSetting)), | |
| 1059 | 1060 | }); |
| 1060 | 1061 | |
| 1061 | 1062 | const ClaudeSettingsPatch = Schema.Struct({ |
| 1062 | 1063 | enabled: Schema.optionalKey(Schema.Boolean), |
| 1063 | 1064 | binaryPath: Schema.optionalKey(TrimmedString), |
| 1064 | 1065 | homePath: Schema.optionalKey(TrimmedString), |
| 1065 | − customModels: Schema.optionalKey(Schema.Array(Schema.String)), | |
| 1066 | + customModels: Schema.optionalKey(Schema.Array(CustomModelSetting)), | |
| 1066 | 1067 | launchArgs: Schema.optionalKey(TrimmedString), |
| 1067 | 1068 | // Validated at the patch boundary so a typo fails the one update with a |
| 1068 | 1069 | // schema error instead of a generic whole-settings failure. |
| @@ -1075,13 +1076,13 @@ const CursorSettingsPatch = Schema.Struct({ · 6 unmodified lines | ||
| 1075 | 1076 | enabled: Schema.optionalKey(Schema.Boolean), |
| 1076 | 1077 | binaryPath: Schema.optionalKey(TrimmedString), |
| 1077 | 1078 | apiEndpoint: Schema.optionalKey(TrimmedString), |
| 1078 | − customModels: Schema.optionalKey(Schema.Array(Schema.String)), | |
| 1079 | + customModels: Schema.optionalKey(Schema.Array(CustomModelSetting)), | |
| 1079 | 1080 | }); |
| 1080 | 1081 | |
| 1081 | 1082 | const GrokSettingsPatch = Schema.Struct({ |
| 1082 | 1083 | enabled: Schema.optionalKey(Schema.Boolean), |
| 1083 | 1084 | binaryPath: Schema.optionalKey(TrimmedString), |
| 1084 | − customModels: Schema.optionalKey(Schema.Array(Schema.String)), | |
| 1085 | + customModels: Schema.optionalKey(Schema.Array(CustomModelSetting)), | |
| 1085 | 1086 | }); |
| 1086 | 1087 | |
| 1087 | 1088 | const AntigravitySettingsPatch = Schema.Struct({ |
| @@ -1091,7 +1092,7 @@ const AntigravitySettingsPatch = Schema.Struct({ · 3 unmodified lines | ||
| 1091 | 1092 | gcpProject: Schema.optionalKey(TrimmedString), |
| 1092 | 1093 | gcpLocation: Schema.optionalKey(TrimmedString), |
| 1093 | 1094 | binaryPath: Schema.optionalKey(TrimmedString), |
| 1094 | − customModels: Schema.optionalKey(Schema.Array(Schema.String)), | |
| 1095 | + customModels: Schema.optionalKey(Schema.Array(CustomModelSetting)), | |
| 1095 | 1096 | }); |
| 1096 | 1097 | |
| 1097 | 1098 | const OpenCodeSettingsPatch = Schema.Struct({ |
| @@ -1099,7 +1100,7 @@ const OpenCodeSettingsPatch = Schema.Struct({ · 1 unmodified line | ||
| 1099 | 1100 | binaryPath: Schema.optionalKey(TrimmedString), |
| 1100 | 1101 | serverUrl: Schema.optionalKey(TrimmedString), |
| 1101 | 1102 | serverPassword: Schema.optionalKey(TrimmedString), |
| 1102 | − customModels: Schema.optionalKey(Schema.Array(Schema.String)), | |
| 1103 | + customModels: Schema.optionalKey(Schema.Array(CustomModelSetting)), | |
| 1103 | 1104 | }); |
| 1104 | 1105 | |
| 1105 | 1106 | export const ServerSettingsPatch = Schema.Struct({ |
| @@ -10,6 +10,8 @@ import { · 9 unmodified lines | ||
| 10 | 10 | getModelSelectionBooleanOptionValue, |
| 11 | 11 | getModelSelectionStringOptionValue, |
| 12 | 12 | getProviderOptionDescriptors, |
| 13 | + readCustomModelEntries, | |
| 14 | + toCustomModelSetting, | |
| 13 | 15 | getProviderOptionBooleanSelectionValue, |
| 14 | 16 | getProviderOptionStringSelectionValue, |
| 15 | 17 | } from "./model.ts"; |
| @@ -192,3 +194,53 @@ describe("applyClaudePromptEffortPrefix", () => { · 176 unmodified lines | ||
| 192 | 194 | ); |
| 193 | 195 | }); |
| 194 | 196 | }); |
| 197 | + | |
| 198 | +describe("readCustomModelEntries", () => { | |
| 199 | + const capabilities: ModelCapabilities = { | |
| 200 | + optionDescriptors: [ | |
| 201 | + { | |
| 202 | + id: "effort", | |
| 203 | + label: "Reasoning", | |
| 204 | + type: "select", | |
| 205 | + options: [{ id: "high", label: "High", isDefault: true }], | |
| 206 | + currentValue: "high", | |
| 207 | + }, | |
| 208 | + ], | |
| 209 | + }; | |
| 210 | + | |
| 211 | + it("resolves bare slugs and entries, trimming and deduplicating on slug", () => { | |
| 212 | + expect( | |
| 213 | + readCustomModelEntries([ | |
| 214 | + " bare ", | |
| 215 | + { slug: "named", name: " Named ", capabilities }, | |
| 216 | + "bare", | |
| 217 | + { slug: "named", name: "Second" }, | |
| 218 | + "", | |
| 219 | + { name: "no slug" }, | |
| 220 | + 42, | |
| 221 | + ]), | |
| 222 | + ).toEqual([ | |
| 223 | + { slug: "bare", name: "bare", capabilities: null }, | |
| 224 | + { slug: "named", name: "Named", capabilities }, | |
| 225 | + ]); | |
| 226 | + }); | |
| 227 | + | |
| 228 | + it("drops unparseable capabilities but keeps the entry", () => { | |
| 229 | + expect( | |
| 230 | + readCustomModelEntries([{ slug: "x", capabilities: { optionDescriptors: "nope" } }]), | |
| 231 | + ).toEqual([{ slug: "x", name: "x", capabilities: null }]); | |
| 232 | + expect(readCustomModelEntries("not a list")).toEqual([]); | |
| 233 | + }); | |
| 234 | + | |
| 235 | + it("writes the compact stored shape back", () => { | |
| 236 | + expect(toCustomModelSetting({ slug: "x", name: "x", capabilities: null })).toBe("x"); | |
| 237 | + expect( | |
| 238 | + toCustomModelSetting({ slug: "x", name: "x", capabilities: { optionDescriptors: [] } }), | |
| 239 | + ).toBe("x"); | |
| 240 | + expect(toCustomModelSetting({ slug: "x", name: "X", capabilities })).toEqual({ | |
| 241 | + slug: "x", | |
| 242 | + name: "X", | |
| 243 | + capabilities, | |
| 244 | + }); | |
| 245 | + }); | |
| 246 | +}); | |
| @@ -1,12 +1,15 @@ | ||
| 1 | 1 | import { |
| 2 | + type CustomModelSetting, | |
| 2 | 3 | MODEL_SLUG_ALIASES_BY_PROVIDER, |
| 3 | − type ModelCapabilities, | |
| 4 | + ModelCapabilities, | |
| 4 | 5 | type ModelSelection, |
| 5 | 6 | ProviderDriverKind, |
| 6 | 7 | ProviderInstanceId, |
| 7 | 8 | type ProviderOptionDescriptor, |
| 8 | 9 | type ProviderOptionSelection, |
| 9 | 10 | } from "@t3tools/contracts"; |
| 11 | +import * as Option from "effect/Option"; | |
| 12 | +import * as Schema from "effect/Schema"; | |
| 10 | 13 | |
| 11 | 14 | const DEFAULT_PROVIDER_DRIVER_KIND = ProviderDriverKind.make("codex"); |
| 12 | 15 | |
| @@ -247,6 +250,76 @@ export function normalizeCustomModelSlug(model: string | null | undefined): stri · 234 unmodified lines | ||
| 247 | 250 | return model.trim() || null; |
| 248 | 251 | } |
| 249 | 252 | |
| 253 | +/** A custom model setting with its optional fields resolved. */ | |
| 254 | +export interface CustomModelDefinition { | |
| 255 | + readonly slug: string; | |
| 256 | + readonly name: string; | |
| 257 | + readonly capabilities: ModelCapabilities | null; | |
| 258 | +} | |
| 259 | + | |
| 260 | +const decodeCustomModelCapabilities = Schema.decodeUnknownOption(ModelCapabilities); | |
| 261 | + | |
| 262 | +/** | |
| 263 | + * Read a `customModels` setting into resolved definitions. Accepts the typed | |
| 264 | + * union as well as the opaque `providerInstances[id].config` blob clients see, | |
| 265 | + * so it tolerates bare slugs, malformed rows, and unparseable capabilities | |
| 266 | + * (dropped rather than failing the whole list). Slugs are trimmed and | |
| 267 | + * deduplicated, first occurrence wins; `name` falls back to the slug. | |
| 268 | + */ | |
| 269 | +export function readCustomModelEntries(value: unknown): CustomModelDefinition[] { | |
| 270 | + if (!Array.isArray(value)) return []; | |
| 271 | + const entries: CustomModelDefinition[] = []; | |
| 272 | + const seen = new Set<string>(); | |
| 273 | + for (const raw of value) { | |
| 274 | + const record = | |
| 275 | + typeof raw === "string" | |
| 276 | + ? { slug: raw } | |
| 277 | + : raw !== null && typeof raw === "object" | |
| 278 | + ? (raw as { slug?: unknown; name?: unknown; capabilities?: unknown }) | |
| 279 | + : null; | |
| 280 | + if (!record) continue; | |
| 281 | + const slug = normalizeCustomModelSlug(typeof record.slug === "string" ? record.slug : null); | |
| 282 | + if (!slug || seen.has(slug)) continue; | |
| 283 | + seen.add(slug); | |
| 284 | + const name = | |
| 285 | + (typeof record.name === "string" ? normalizeCustomModelSlug(record.name) : null) ?? slug; | |
| 286 | + const capabilities = | |
| 287 | + record.capabilities === undefined || record.capabilities === null | |
| 288 | + ? null | |
| 289 | + : Option.getOrNull(decodeCustomModelCapabilities(record.capabilities)); | |
| 290 | + entries.push({ | |
| 291 | + slug, | |
| 292 | + name, | |
| 293 | + capabilities: capabilities | |
| 294 | + ? createModelCapabilities({ optionDescriptors: capabilities.optionDescriptors ?? [] }) | |
| 295 | + : null, | |
| 296 | + }); | |
| 297 | + } | |
| 298 | + return entries; | |
| 299 | +} | |
| 300 | + | |
| 301 | +/** Slugs of a `customModels` setting, in stored order. */ | |
| 302 | +export function readCustomModelSlugs(value: unknown): string[] { | |
| 303 | + return readCustomModelEntries(value).map((entry) => entry.slug); | |
| 304 | +} | |
| 305 | + | |
| 306 | +/** | |
| 307 | + * Write a definition back to the compact stored shape: a bare slug when it | |
| 308 | + * carries nothing custom, otherwise an entry with only the set fields. | |
| 309 | + */ | |
| 310 | +export function toCustomModelSetting(entry: CustomModelDefinition): CustomModelSetting { | |
| 311 | + const descriptors = entry.capabilities?.optionDescriptors ?? []; | |
| 312 | + const name = entry.name !== entry.slug ? entry.name : undefined; | |
| 313 | + if (!name && descriptors.length === 0) return entry.slug; | |
| 314 | + return { | |
| 315 | + slug: entry.slug, | |
| 316 | + ...(name ? { name } : {}), | |
| 317 | + ...(descriptors.length > 0 | |
| 318 | + ? { capabilities: createModelCapabilities({ optionDescriptors: descriptors }) } | |
| 319 | + : {}), | |
| 320 | + }; | |
| 321 | +} | |
| 322 | + | |
| 250 | 323 | export function resolveSelectableModel( |
| 251 | 324 | provider: ProviderDriverKind, |
| 252 | 325 | value: string | null | undefined, |