| author | |
| committer | |
| log | 5a433244d0b827176df8b76a05959cd1a7a98ce2 |
| tree | 0c1977f93448345050a781a6bb37ad321bd4ddec |
| parent | dd7bc147f799f290eb58578a7f81643ecf9ad52e |
| signature | Signed by PGP key B5690EEEBB952194 |
19 files changed, 1423 insertions(+), 150 deletions(-)
apps/server/src/provider/ClaudeModelCatalog.test.ts+56| ... | @@ -7,9 +7,11 @@ import { | ... | @@ -7,9 +7,11 @@ import { |
| 7 | formatClaudeVersionUpgradeMessage, | 7 | formatClaudeVersionUpgradeMessage, |
| 8 | normalizeClaudeCatalogEffort, | 8 | normalizeClaudeCatalogEffort, |
| 9 | resolveClaudeCatalogApiModelId, | 9 | resolveClaudeCatalogApiModelId, |
| 10 | resolveClaudeCatalogEffort, | ||
| 10 | resolveClaudeModelCatalog, | 11 | resolveClaudeModelCatalog, |
| 11 | resolveClaudeModelsForVersion, | 12 | resolveClaudeModelsForVersion, |
| 12 | resolveClaudeModelSlug, | 13 | resolveClaudeModelSlug, |
| 14 | scopeClaudeModelCatalog, | ||
| 13 | } from "./ClaudeModelCatalog.ts"; | 15 | } from "./ClaudeModelCatalog.ts"; |
| 14 | 16 | ||
| 15 | /** | 17 | /** |
| ... | @@ -134,4 +136,58 @@ describe("Claude model catalog", () => { | ... | @@ -134,4 +136,58 @@ describe("Claude model catalog", () => { |
| 134 | }; | 136 | }; |
| 135 | assert.isFalse(hasValidClaudeManifestAdapters(malformed)); | 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 | }); |
apps/server/src/provider/ClaudeModelCatalog.ts+43-24| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import { | 1 | import { |
| 2 | type CustomModelSetting, | ||
| 2 | type ModelCapabilities, | 3 | type ModelCapabilities, |
| 3 | type ModelSelection, | 4 | type ModelSelection, |
| 4 | ProviderDriverKind, | 5 | ProviderDriverKind, |
| ... | @@ -9,7 +10,7 @@ import { | ... | @@ -9,7 +10,7 @@ import { |
| 9 | getModelSelectionStringOptionValue, | 10 | getModelSelectionStringOptionValue, |
| 10 | getProviderOptionCurrentValue, | 11 | getProviderOptionCurrentValue, |
| 11 | getProviderOptionDescriptors, | 12 | getProviderOptionDescriptors, |
| 12 | normalizeCustomModelSlug, | 13 | readCustomModelEntries, |
| 13 | } from "@t3tools/shared/model"; | 14 | } from "@t3tools/shared/model"; |
| 14 | import { compareSemverVersions } from "@t3tools/shared/semver"; | 15 | import { compareSemverVersions } from "@t3tools/shared/semver"; |
| 15 | 16 | ||
| ... | @@ -70,33 +71,51 @@ export function resolveClaudeModelCatalog(manifest: ModelManifestData): ClaudeMo | ... | @@ -70,33 +71,51 @@ export function resolveClaudeModelCatalog(manifest: ModelManifestData): ClaudeMo |
| 70 | 71 | ||
| 71 | export const BUNDLED_CLAUDE_MODEL_CATALOG = resolveClaudeModelCatalog(BUNDLED_MODEL_MANIFEST); | 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 | export function scopeClaudeModelCatalog( | 82 | export function scopeClaudeModelCatalog( |
| 75 | catalog: ClaudeModelCatalog, | 83 | catalog: ClaudeModelCatalog, |
| 76 | customModels: ReadonlyArray<string>, | 84 | customModels: ReadonlyArray<CustomModelSetting>, |
| 77 | ): ClaudeModelCatalog { | 85 | ): ClaudeModelCatalog { |
| 78 | const customAliases = new Set( | 86 | const customEntries = readCustomModelEntries(customModels); |
| 79 | customModels.flatMap((model) => { | 87 | if (customEntries.length === 0) return catalog; |
| 80 | const slug = normalizeCustomModelSlug(model); | 88 | const customAliases = new Set(customEntries.map((entry) => entry.slug.toLowerCase())); |
| 81 | return slug ? [slug.toLowerCase()] : []; | ||
| 82 | }), | ||
| 83 | ); | ||
| 84 | if (customAliases.size === 0) return catalog; | ||
| 85 | 89 | ||
| 86 | return { | 90 | const builtInModels = catalog.models.map((entry) => { |
| 87 | models: catalog.models.map((entry) => { | 91 | if (!entry.model.aliases?.some((alias) => customAliases.has(alias.toLowerCase()))) { |
| 88 | if (!entry.model.aliases?.some((alias) => customAliases.has(alias.toLowerCase()))) { | 92 | return entry; |
| 89 | return entry; | 93 | } |
| 90 | } | 94 | return { |
| 91 | return { | 95 | ...entry, |
| 92 | ...entry, | 96 | model: { |
| 93 | model: { | 97 | ...entry.model, |
| 94 | ...entry.model, | 98 | aliases: entry.model.aliases.filter((alias) => !customAliases.has(alias.toLowerCase())), |
| 95 | aliases: entry.model.aliases.filter((alias) => !customAliases.has(alias.toLowerCase())), | 99 | }, |
| 96 | }, | 100 | }; |
| 97 | }; | 101 | }); |
| 98 | }), | 102 | const builtInSlugs = new Set(builtInModels.map((entry) => entry.model.slug)); |
| 99 | }; | 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 | export function resolveClaudeCatalogModel( | 121 | export function resolveClaudeCatalogModel( |
apps/server/src/provider/Layers/CodexProvider.ts+18-26| ... | @@ -15,6 +15,7 @@ import * as CodexErrors from "effect-codex-app-server/errors"; | ... | @@ -15,6 +15,7 @@ import * as CodexErrors from "effect-codex-app-server/errors"; |
| 15 | 15 | ||
| 16 | import type { | 16 | import type { |
| 17 | CodexSettings, | 17 | CodexSettings, |
| 18 | CustomModelSetting, | ||
| 18 | ServerProvider, | 19 | ServerProvider, |
| 19 | ServerProviderState, | 20 | ServerProviderState, |
| 20 | ModelCapabilities, | 21 | ModelCapabilities, |
| ... | @@ -24,7 +25,7 @@ import type { | ... | @@ -24,7 +25,7 @@ import type { |
| 24 | } from "@t3tools/contracts"; | 25 | } from "@t3tools/contracts"; |
| 25 | import { PREFERRED_DEFAULT_CODEX_MODELS, ServerSettingsError } from "@t3tools/contracts"; | 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 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; | 29 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; |
| 29 | import { codexAppServerArgs, resolveCodexLaunchArgs } from "./codexLaunchArgs.ts"; | 30 | import { codexAppServerArgs, resolveCodexLaunchArgs } from "./codexLaunchArgs.ts"; |
| 30 | import { | 31 | import { |
| ... | @@ -244,9 +245,14 @@ export function applyPreferredCodexDefaultModel( | ... | @@ -244,9 +245,14 @@ export function applyPreferredCodexDefaultModel( |
| 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 | function appendCustomCodexModels( | 253 | function appendCustomCodexModels( |
| 248 | models: ReadonlyArray<ServerProviderModel>, | 254 | models: ReadonlyArray<ServerProviderModel>, |
| 249 | customModels: ReadonlyArray<string>, | 255 | customModels: ReadonlyArray<CustomModelSetting>, |
| 250 | ): ReadonlyArray<ServerProviderModel> { | 256 | ): ReadonlyArray<ServerProviderModel> { |
| 251 | if (customModels.length === 0) { | 257 | if (customModels.length === 0) { |
| 252 | return models; | 258 | return models; |
| ... | @@ -255,17 +261,16 @@ function appendCustomCodexModels( | ... | @@ -255,17 +261,16 @@ function appendCustomCodexModels( |
| 255 | const seen = new Set(models.map((model) => model.slug)); | 261 | const seen = new Set(models.map((model) => model.slug)); |
| 256 | const fallbackCapabilities = models.find((model) => model.capabilities)?.capabilities ?? null; | 262 | const fallbackCapabilities = models.find((model) => model.capabilities)?.capabilities ?? null; |
| 257 | const customEntries: ServerProviderModel[] = []; | 263 | const customEntries: ServerProviderModel[] = []; |
| 258 | for (const rawModel of customModels) { | 264 | for (const entry of readCustomModelEntries(customModels)) { |
| 259 | const slug = rawModel.trim(); | 265 | if (seen.has(entry.slug)) { |
| 260 | if (!slug || seen.has(slug)) { | ||
| 261 | continue; | 266 | continue; |
| 262 | } | 267 | } |
| 263 | seen.add(slug); | 268 | seen.add(entry.slug); |
| 264 | customEntries.push({ | 269 | customEntries.push({ |
| 265 | slug, | 270 | slug: entry.slug, |
| 266 | name: slug, | 271 | name: entry.name, |
| 267 | isCustom: true, | 272 | isCustom: true, |
| 268 | capabilities: fallbackCapabilities, | 273 | capabilities: entry.capabilities ?? fallbackCapabilities, |
| 269 | }); | 274 | }); |
| 270 | } | 275 | } |
| 271 | return customEntries.length === 0 ? models : [...models, ...customEntries]; | 276 | return customEntries.length === 0 ? models : [...models, ...customEntries]; |
| ... | @@ -399,7 +404,7 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun | ... | @@ -399,7 +404,7 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun |
| 399 | readonly homePath?: string; | 404 | readonly homePath?: string; |
| 400 | readonly launchArgs?: string; | 405 | readonly launchArgs?: string; |
| 401 | readonly cwd: string; | 406 | readonly cwd: string; |
| 402 | readonly customModels?: ReadonlyArray<string>; | 407 | readonly customModels?: ReadonlyArray<CustomModelSetting>; |
| 403 | readonly environment?: NodeJS.ProcessEnv; | 408 | readonly environment?: NodeJS.ProcessEnv; |
| 404 | }) { | 409 | }) { |
| 405 | const { client, initialize } = yield* withCodexAppServerClient(input); | 410 | const { client, initialize } = yield* withCodexAppServerClient(input); |
| ... | @@ -470,21 +475,8 @@ export const probeCodexSkillsForCwd = Effect.fn("probeCodexSkillsForCwd")(functi | ... | @@ -470,21 +475,8 @@ export const probeCodexSkillsForCwd = Effect.fn("probeCodexSkillsForCwd")(functi |
| 470 | return parseCodexSkillsListResponse(skillsResponse, input.cwd); | 475 | return parseCodexSkillsListResponse(skillsResponse, input.cwd); |
| 471 | }); | 476 | }); |
| 472 | 477 | ||
| 473 | const emptyCodexModelsFromSettings = (codexSettings: CodexSettings): ServerProvider["models"] => { | 478 | const emptyCodexModelsFromSettings = (codexSettings: CodexSettings): ServerProvider["models"] => |
| 474 | const models = new Set<string>(); | 479 | appendCustomCodexModels([], codexSettings.customModels); |
| 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 | }; | ||
| 488 | 480 | ||
| 489 | const makePendingCodexProvider = ( | 481 | const makePendingCodexProvider = ( |
| 490 | codexSettings: CodexSettings, | 482 | codexSettings: CodexSettings, |
| ... | @@ -562,7 +554,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu | ... | @@ -562,7 +554,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu |
| 562 | readonly homePath?: string; | 554 | readonly homePath?: string; |
| 563 | readonly launchArgs?: string; | 555 | readonly launchArgs?: string; |
| 564 | readonly cwd: string; | 556 | readonly cwd: string; |
| 565 | readonly customModels: ReadonlyArray<string>; | 557 | readonly customModels: ReadonlyArray<CustomModelSetting>; |
| 566 | readonly environment?: NodeJS.ProcessEnv; | 558 | readonly environment?: NodeJS.ProcessEnv; |
| 567 | }) => Effect.Effect< | 559 | }) => Effect.Effect< |
| 568 | CodexAppServerProviderSnapshot, | 560 | CodexAppServerProviderSnapshot, |
apps/server/src/provider/Layers/GrokProvider.ts+2-1| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import { | 1 | import { |
| 2 | type CustomModelSetting, | ||
| 2 | type GrokSettings, | 3 | type GrokSettings, |
| 3 | type ModelCapabilities, | 4 | type ModelCapabilities, |
| 4 | type ServerProvider, | 5 | type ServerProvider, |
| ... | @@ -104,7 +105,7 @@ export function buildInitialGrokProviderSnapshot( | ... | @@ -104,7 +105,7 @@ export function buildInitialGrokProviderSnapshot( |
| 104 | } | 105 | } |
| 105 | 106 | ||
| 106 | function grokModelsFromSettings( | 107 | function grokModelsFromSettings( |
| 107 | customModels: ReadonlyArray<string> | undefined, | 108 | customModels: ReadonlyArray<CustomModelSetting> | undefined, |
| 108 | builtInModels: ReadonlyArray<ServerProviderModel> = GROK_BUILT_IN_MODELS, | 109 | builtInModels: ReadonlyArray<ServerProviderModel> = GROK_BUILT_IN_MODELS, |
| 109 | ): ReadonlyArray<ServerProviderModel> { | 110 | ): ReadonlyArray<ServerProviderModel> { |
| 110 | return providerModelsFromSettings(builtInModels, customModels ?? [], EMPTY_CAPABILITIES); | 111 | return providerModelsFromSettings(builtInModels, customModels ?? [], EMPTY_CAPABILITIES); |
apps/server/src/provider/providerSnapshot.test.ts+21| ... | @@ -52,6 +52,27 @@ describe("providerModelsFromSettings", () => { | ... | @@ -52,6 +52,27 @@ describe("providerModelsFromSettings", () => { |
| 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 | it("preserves a custom slug that collides with a provider alias", () => { | 76 | it("preserves a custom slug that collides with a provider alias", () => { |
| 56 | const capabilities = createModelCapabilities({ optionDescriptors: [] }); | 77 | const capabilities = createModelCapabilities({ optionDescriptors: [] }); |
| 57 | const models = providerModelsFromSettings( | 78 | const models = providerModelsFromSettings( |
apps/server/src/provider/providerSnapshot.ts+14-9| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import type { | 1 | import type { |
| 2 | CustomModelSetting, | ||
| 2 | ProviderDriverKind, | 3 | ProviderDriverKind, |
| 3 | ModelCapabilities, | 4 | ModelCapabilities, |
| 4 | ServerProvider, | 5 | ServerProvider, |
| ... | @@ -14,7 +15,7 @@ import * as PlatformError from "effect/PlatformError"; | ... | @@ -14,7 +15,7 @@ import * as PlatformError from "effect/PlatformError"; |
| 14 | import * as Schema from "effect/Schema"; | 15 | import * as Schema from "effect/Schema"; |
| 15 | import * as Stream from "effect/Stream"; | 16 | import * as Stream from "effect/Stream"; |
| 16 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | 17 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 17 | import { normalizeCustomModelSlug } from "@t3tools/shared/model"; | 18 | import { readCustomModelEntries } from "@t3tools/shared/model"; |
| 18 | import { isWindowsCommandNotFound } from "../processRunner.ts"; | 19 | import { isWindowsCommandNotFound } from "../processRunner.ts"; |
| 19 | import { createProviderVersionAdvisory } from "./providerMaintenance.ts"; | 20 | import { createProviderVersionAdvisory } from "./providerMaintenance.ts"; |
| 20 | import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts"; | 21 | import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts"; |
| ... | @@ -145,26 +146,30 @@ export function parseGenericCliVersion(output: string): string | null { | ... | @@ -145,26 +146,30 @@ export function parseGenericCliVersion(output: string): string | null { |
| 145 | return match?.[1] ?? null; | 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 | export function providerModelsFromSettings( | 154 | export function providerModelsFromSettings( |
| 149 | builtInModels: ReadonlyArray<ServerProviderModel>, | 155 | builtInModels: ReadonlyArray<ServerProviderModel>, |
| 150 | customModels: ReadonlyArray<string>, | 156 | customModels: ReadonlyArray<CustomModelSetting>, |
| 151 | customModelCapabilities: ModelCapabilities, | 157 | customModelCapabilities: ModelCapabilities, |
| 152 | ): ReadonlyArray<ServerProviderModel> { | 158 | ): ReadonlyArray<ServerProviderModel> { |
| 153 | const resolvedBuiltInModels = [...builtInModels]; | 159 | const resolvedBuiltInModels = [...builtInModels]; |
| 154 | const seen = new Set(resolvedBuiltInModels.map((model) => model.slug)); | 160 | const seen = new Set(resolvedBuiltInModels.map((model) => model.slug)); |
| 155 | const customEntries: ServerProviderModel[] = []; | 161 | const customEntries: ServerProviderModel[] = []; |
| 156 | 162 | ||
| 157 | for (const candidate of customModels) { | 163 | for (const entry of readCustomModelEntries(customModels)) { |
| 158 | const normalized = normalizeCustomModelSlug(candidate); | 164 | if (seen.has(entry.slug)) { |
| 159 | if (!normalized || seen.has(normalized)) { | ||
| 160 | continue; | 165 | continue; |
| 161 | } | 166 | } |
| 162 | seen.add(normalized); | 167 | seen.add(entry.slug); |
| 163 | customEntries.push({ | 168 | customEntries.push({ |
| 164 | slug: normalized, | 169 | slug: entry.slug, |
| 165 | name: normalized, | 170 | name: entry.name, |
| 166 | isCustom: true, | 171 | isCustom: true, |
| 167 | capabilities: customModelCapabilities, | 172 | capabilities: entry.capabilities ?? customModelCapabilities, |
| 168 | }); | 173 | }); |
| 169 | } | 174 | } |
| 170 | 175 |
apps/web/src/components/settings/CustomModelEditor.tsx created+387| ... | @@ -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 | } | ||
apps/web/src/components/settings/ProviderInstanceCard.test.ts+42-1| ... | @@ -36,11 +36,52 @@ describe("deriveProviderModelsForDisplay", () => { | ... | @@ -36,11 +36,52 @@ describe("deriveProviderModelsForDisplay", () => { |
| 36 | expect( | 36 | expect( |
| 37 | deriveProviderModelsForDisplay({ | 37 | deriveProviderModelsForDisplay({ |
| 38 | liveModels, | 38 | liveModels, |
| 39 | customModels: ["kept-custom"], | 39 | customModels: [{ slug: "kept-custom", name: "kept-custom", capabilities: null }], |
| 40 | }).map((model) => model.slug), | 40 | }).map((model) => model.slug), |
| 41 | ).toEqual(["server-model", "kept-custom"]); | 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 | it("shows a redacted provider email in the editor header status line", () => { | 85 | it("shows a redacted provider email in the editor header status line", () => { |
| 45 | const instanceId = ProviderInstanceId.make("codex"); | 86 | const instanceId = ProviderInstanceId.make("codex"); |
| 46 | const driver = ProviderDriverKind.make("codex"); | 87 | const driver = ProviderDriverKind.make("codex"); |
apps/web/src/components/settings/ProviderInstanceCard.tsx+30-21| ... | @@ -25,6 +25,11 @@ import { | ... | @@ -25,6 +25,11 @@ import { |
| 25 | type ServerProviderModel, | 25 | type ServerProviderModel, |
| 26 | } from "@t3tools/contracts"; | 26 | } from "@t3tools/contracts"; |
| 27 | 27 | ||
| 28 | import { | ||
| 29 | type CustomModelDefinition, | ||
| 30 | readCustomModelEntries, | ||
| 31 | toCustomModelSetting, | ||
| 32 | } from "@t3tools/shared/model"; | ||
| 28 | import { cn } from "../../lib/utils"; | 33 | import { cn } from "../../lib/utils"; |
| 29 | import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; | 34 | import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; |
| 30 | import { normalizeProviderAccentColor } from "../../providerInstances"; | 35 | import { normalizeProviderAccentColor } from "../../providerInstances"; |
| ... | @@ -96,16 +101,13 @@ function providerEnvironmentsEqual( | ... | @@ -96,16 +101,13 @@ function providerEnvironmentsEqual( |
| 96 | } | 101 | } |
| 97 | 102 | ||
| 98 | /** | 103 | /** |
| 99 | * Read a string[] at `key` from the opaque config blob, filtering out | 104 | * Read `customModels` from the opaque config blob. The concrete driver |
| 100 | * non-string entries. Used for `customModels`, which is always typed as | 105 | * schemas type it as `CustomModelSetting[]`, but it arrives here as |
| 101 | * `string[]` by the concrete driver schemas but arrives here as | 106 | * `Schema.Unknown`, so the shared reader does the shape checking. |
| 102 | * `Schema.Unknown`. | ||
| 103 | */ | 107 | */ |
| 104 | function readConfigStringArray(config: unknown, key: string): ReadonlyArray<string> { | 108 | function readConfigCustomModels(config: unknown): ReadonlyArray<CustomModelDefinition> { |
| 105 | if (config === null || typeof config !== "object") return []; | 109 | if (config === null || typeof config !== "object") return []; |
| 106 | const value = (config as Record<string, unknown>)[key]; | 110 | return readCustomModelEntries((config as Record<string, unknown>).customModels); |
| 107 | if (!Array.isArray(value)) return []; | ||
| 108 | return value.filter((entry): entry is string => typeof entry === "string"); | ||
| 109 | } | 111 | } |
| 110 | 112 | ||
| 111 | /** | 113 | /** |
| ... | @@ -127,9 +129,14 @@ function nextConfigBlobWithValue( | ... | @@ -127,9 +129,14 @@ function nextConfigBlobWithValue( |
| 127 | return base; | 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 | export function deriveProviderModelsForDisplay(input: { | 137 | export function deriveProviderModelsForDisplay(input: { |
| 131 | readonly liveModels: ReadonlyArray<ServerProviderModel> | undefined; | 138 | readonly liveModels: ReadonlyArray<ServerProviderModel> | undefined; |
| 132 | readonly customModels: ReadonlyArray<string>; | 139 | readonly customModels: ReadonlyArray<CustomModelDefinition>; |
| 133 | }): ReadonlyArray<ServerProviderModel> { | 140 | }): ReadonlyArray<ServerProviderModel> { |
| 134 | const liveCustomModelsBySlug = new Map( | 141 | const liveCustomModelsBySlug = new Map( |
| 135 | Arr.filterMap(input.liveModels ?? [], (model) => | 142 | Arr.filterMap(input.liveModels ?? [], (model) => |
| ... | @@ -137,15 +144,13 @@ export function deriveProviderModelsForDisplay(input: { | ... | @@ -137,15 +144,13 @@ export function deriveProviderModelsForDisplay(input: { |
| 137 | ), | 144 | ), |
| 138 | ); | 145 | ); |
| 139 | const serverModels = input.liveModels?.filter((model) => !model.isCustom) ?? []; | 146 | const serverModels = input.liveModels?.filter((model) => !model.isCustom) ?? []; |
| 140 | const customModels = input.customModels.map( | 147 | const customModels = input.customModels.map((entry) => ({ |
| 141 | (slug) => | 148 | slug: entry.slug, |
| 142 | liveCustomModelsBySlug.get(slug) ?? { | 149 | name: entry.name, |
| 143 | slug, | 150 | isCustom: true, |
| 144 | name: slug, | 151 | capabilities: |
| 145 | isCustom: true, | 152 | entry.capabilities ?? liveCustomModelsBySlug.get(entry.slug)?.capabilities ?? null, |
| 146 | capabilities: null, | 153 | })); |
| 147 | }, | ||
| 148 | ); | ||
| 149 | return [...serverModels, ...customModels]; | 154 | return [...serverModels, ...customModels]; |
| 150 | } | 155 | } |
| 151 | 156 | ||
| ... | @@ -463,7 +468,7 @@ export function ProviderInstanceCard({ | ... | @@ -463,7 +468,7 @@ export function ProviderInstanceCard({ |
| 463 | ? instance.driver | 468 | ? instance.driver |
| 464 | : null; | 469 | : null; |
| 465 | const customModels = | 470 | const customModels = |
| 466 | instance.driver === "antigravity" ? [] : readConfigStringArray(instance.config, "customModels"); | 471 | instance.driver === "antigravity" ? [] : readConfigCustomModels(instance.config); |
| 467 | // Server-returned models may lag behind settings writes. Treat probe | 472 | // Server-returned models may lag behind settings writes. Treat probe |
| 468 | // models as the source for built-ins only; custom rows come directly | 473 | // models as the source for built-ins only; custom rows come directly |
| 469 | // from the current instance config so add/remove reflects immediately. | 474 | // from the current instance config so add/remove reflects immediately. |
| ... | @@ -504,8 +509,12 @@ export function ProviderInstanceCard({ | ... | @@ -504,8 +509,12 @@ export function ProviderInstanceCard({ |
| 504 | ); | 509 | ); |
| 505 | }; | 510 | }; |
| 506 | 511 | ||
| 507 | const updateCustomModels = (next: ReadonlyArray<string>) => { | 512 | const updateCustomModels = (next: ReadonlyArray<CustomModelDefinition>) => { |
| 508 | const nextConfig = nextConfigBlobWithValue(instance.config, "customModels", [...next]); | 513 | const nextConfig = nextConfigBlobWithValue( |
| 514 | instance.config, | ||
| 515 | "customModels", | ||
| 516 | next.map(toCustomModelSetting), | ||
| 517 | ); | ||
| 509 | const { config: _omit, ...rest } = instance; | 518 | const { config: _omit, ...rest } = instance; |
| 510 | onUpdate({ ...rest, config: nextConfig } as ProviderInstanceConfig); | 519 | onUpdate({ ...rest, config: nextConfig } as ProviderInstanceConfig); |
| 511 | }; | 520 | }; |
apps/web/src/components/settings/ProviderModelsSection.tsx+75-28| ... | @@ -1,13 +1,13 @@ | ... | @@ -1,13 +1,13 @@ |
| 1 | "use client"; | 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 | import { useEffect, useMemo, useRef, useState } from "react"; | 4 | import { useEffect, useMemo, useRef, useState } from "react"; |
| 5 | import { | 5 | import { |
| 6 | ProviderDriverKind, | 6 | ProviderDriverKind, |
| 7 | type ProviderInstanceId, | 7 | type ProviderInstanceId, |
| 8 | type ServerProviderModel, | 8 | type ServerProviderModel, |
| 9 | } from "@t3tools/contracts"; | 9 | } from "@t3tools/contracts"; |
| 10 | import { normalizeCustomModelSlug } from "@t3tools/shared/model"; | 10 | import { type CustomModelDefinition, normalizeCustomModelSlug } from "@t3tools/shared/model"; |
| 11 | 11 | ||
| 12 | import { cn } from "../../lib/utils"; | 12 | import { cn } from "../../lib/utils"; |
| 13 | import { sortModelsForProviderInstance } from "../../modelOrdering"; | 13 | import { sortModelsForProviderInstance } from "../../modelOrdering"; |
| ... | @@ -16,6 +16,7 @@ import { Button } from "../ui/button"; | ... | @@ -16,6 +16,7 @@ import { Button } from "../ui/button"; |
| 16 | import { Input } from "../ui/input"; | 16 | import { Input } from "../ui/input"; |
| 17 | import { Switch } from "../ui/switch"; | 17 | import { Switch } from "../ui/switch"; |
| 18 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | 18 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 19 | import { CustomModelEditor } from "./CustomModelEditor"; | ||
| 19 | 20 | ||
| 20 | /** | 21 | /** |
| 21 | * Placeholder text for the "add a custom model" input, keyed by driver | 22 | * Placeholder text for the "add a custom model" input, keyed by driver |
| ... | @@ -109,11 +110,11 @@ interface ProviderModelsSectionProps { | ... | @@ -109,11 +110,11 @@ interface ProviderModelsSectionProps { |
| 109 | */ | 110 | */ |
| 110 | readonly models: ReadonlyArray<ServerProviderModel>; | 111 | readonly models: ReadonlyArray<ServerProviderModel>; |
| 111 | /** | 112 | /** |
| 112 | * The persisted custom-model slug list for this instance. Drives dedup, | 113 | * The persisted custom-model list for this instance, resolved. Drives |
| 113 | * and is the array we hand back verbatim (with the new slug appended / | 114 | * dedup, and is the list we hand back (with an entry appended / replaced / |
| 114 | * removed) via `onChange`. | 115 | * removed) via `onChange`. |
| 115 | */ | 116 | */ |
| 116 | readonly customModels: ReadonlyArray<string>; | 117 | readonly customModels: ReadonlyArray<CustomModelDefinition>; |
| 117 | /** Server-returned model slugs hidden from the model picker. */ | 118 | /** Server-returned model slugs hidden from the model picker. */ |
| 118 | readonly hiddenModels: ReadonlyArray<string>; | 119 | readonly hiddenModels: ReadonlyArray<string>; |
| 119 | /** Model slugs favorited for this provider instance. */ | 120 | /** Model slugs favorited for this provider instance. */ |
| ... | @@ -125,7 +126,7 @@ interface ProviderModelsSectionProps { | ... | @@ -125,7 +126,7 @@ interface ProviderModelsSectionProps { |
| 125 | * write to the correct storage (legacy `settings.providers[kind]` vs. | 126 | * write to the correct storage (legacy `settings.providers[kind]` vs. |
| 126 | * `providerInstances[id].config`). | 127 | * `providerInstances[id].config`). |
| 127 | */ | 128 | */ |
| 128 | readonly onChange: (next: ReadonlyArray<string>) => void; | 129 | readonly onChange: (next: ReadonlyArray<CustomModelDefinition>) => void; |
| 129 | readonly onHiddenModelsChange: (next: ReadonlyArray<string>) => void; | 130 | readonly onHiddenModelsChange: (next: ReadonlyArray<string>) => void; |
| 130 | readonly onFavoriteModelsChange: (next: ReadonlyArray<string>) => void; | 131 | readonly onFavoriteModelsChange: (next: ReadonlyArray<string>) => void; |
| 131 | readonly onModelOrderChange: (next: ReadonlyArray<string>) => void; | 132 | readonly onModelOrderChange: (next: ReadonlyArray<string>) => void; |
| ... | @@ -159,6 +160,8 @@ export function ProviderModelsSection({ | ... | @@ -159,6 +160,8 @@ export function ProviderModelsSection({ |
| 159 | const [isAdding, setIsAdding] = useState(false); | 160 | const [isAdding, setIsAdding] = useState(false); |
| 160 | const [filter, setFilter] = useState(""); | 161 | const [filter, setFilter] = useState(""); |
| 161 | const [error, setError] = useState<string | null>(null); | 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 | const listRef = useRef<HTMLDivElement>(null); | 165 | const listRef = useRef<HTMLDivElement>(null); |
| 163 | // Slug of a just-added custom model, scrolled into view once its row exists. | 166 | // Slug of a just-added custom model, scrolled into view once its row exists. |
| 164 | const scrollToSlugRef = useRef<string | null>(null); | 167 | const scrollToSlugRef = useRef<string | null>(null); |
| ... | @@ -177,6 +180,7 @@ export function ProviderModelsSection({ | ... | @@ -177,6 +180,7 @@ export function ProviderModelsSection({ |
| 177 | const hiddenCount = displayModels.filter( | 180 | const hiddenCount = displayModels.filter( |
| 178 | (model) => !model.isCustom && hiddenModelSet.has(model.slug), | 181 | (model) => !model.isCustom && hiddenModelSet.has(model.slug), |
| 179 | ).length; | 182 | ).length; |
| 183 | const builtInModels = useMemo(() => models.filter((model) => !model.isCustom), [models]); | ||
| 180 | const showFilter = models.length > FILTER_THRESHOLD; | 184 | const showFilter = models.length > FILTER_THRESHOLD; |
| 181 | const normalizedFilter = filter.trim().toLowerCase(); | 185 | const normalizedFilter = filter.trim().toLowerCase(); |
| 182 | const isFiltering = showFilter && normalizedFilter.length > 0; | 186 | const isFiltering = showFilter && normalizedFilter.length > 0; |
| ... | @@ -216,7 +220,7 @@ export function ProviderModelsSection({ | ... | @@ -216,7 +220,7 @@ export function ProviderModelsSection({ |
| 216 | setError(`Model slugs must be ${MAX_CUSTOM_MODEL_LENGTH} characters or less.`); | 220 | setError(`Model slugs must be ${MAX_CUSTOM_MODEL_LENGTH} characters or less.`); |
| 217 | return; | 221 | return; |
| 218 | } | 222 | } |
| 219 | if (customModels.includes(normalized)) { | 223 | if (customModels.some((entry) => entry.slug === normalized)) { |
| 220 | setError("That custom model is already saved."); | 224 | setError("That custom model is already saved."); |
| 221 | return; | 225 | return; |
| 222 | } | 226 | } |
| ... | @@ -225,7 +229,7 @@ export function ProviderModelsSection({ | ... | @@ -225,7 +229,7 @@ export function ProviderModelsSection({ |
| 225 | // which is also what lets the pending scroll target resolve and clear. | 229 | // which is also what lets the pending scroll target resolve and clear. |
| 226 | scrollToSlugRef.current = normalized; | 230 | scrollToSlugRef.current = normalized; |
| 227 | setFilter(""); | 231 | setFilter(""); |
| 228 | onChange([...customModels, normalized]); | 232 | onChange([...customModels, { slug: normalized, name: normalized, capabilities: null }]); |
| 229 | setInput(""); | 233 | setInput(""); |
| 230 | setError(null); | 234 | setError(null); |
| 231 | setIsAdding(false); | 235 | setIsAdding(false); |
| ... | @@ -238,12 +242,18 @@ export function ProviderModelsSection({ | ... | @@ -238,12 +242,18 @@ export function ProviderModelsSection({ |
| 238 | }; | 242 | }; |
| 239 | 243 | ||
| 240 | const handleRemove = (slug: string) => { | 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 | onModelOrderChange(modelOrder.filter((model) => model !== slug)); | 247 | onModelOrderChange(modelOrder.filter((model) => model !== slug)); |
| 243 | onFavoriteModelsChange(favoriteModels.filter((model) => model !== slug)); | 248 | onFavoriteModelsChange(favoriteModels.filter((model) => model !== slug)); |
| 244 | setError(null); | 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 | const setHidden = (slug: string, hidden: boolean) => { | 257 | const setHidden = (slug: string, hidden: boolean) => { |
| 248 | if (hidden === hiddenModelSet.has(slug)) return; | 258 | if (hidden === hiddenModelSet.has(slug)) return; |
| 249 | onHiddenModelsChange( | 259 | onHiddenModelsChange( |
| ... | @@ -355,21 +365,40 @@ export function ProviderModelsSection({ | ... | @@ -355,21 +365,40 @@ export function ProviderModelsSection({ |
| 355 | </> | 365 | </> |
| 356 | ) : null} | 366 | ) : null} |
| 357 | {model.isCustom ? ( | 367 | {model.isCustom ? ( |
| 358 | <Tooltip> | 368 | <> |
| 359 | <TooltipTrigger | 369 | <Tooltip> |
| 360 | render={ | 370 | <TooltipTrigger |
| 361 | <Button | 371 | render={ |
| 362 | size="icon-micro" | 372 | <Button |
| 363 | variant="ghost-muted" | 373 | size="icon-micro" |
| 364 | aria-label={`Remove ${model.slug}`} | 374 | variant="ghost-muted" |
| 365 | onClick={() => handleRemove(model.slug)} | 375 | aria-label={`Edit ${model.slug}`} |
| 366 | /> | 376 | onClick={() => |
| 367 | } | 377 | setEditingSlug((current) => (current === model.slug ? null : model.slug)) |
| 368 | > | 378 | } |
| 369 | <XIcon className="size-3" /> | 379 | /> |
| 370 | </TooltipTrigger> | 380 | } |
| 371 | <TooltipPopup side="top">Remove custom model</TooltipPopup> | 381 | > |
| 372 | </Tooltip> | 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 | ) : null} | 402 | ) : null} |
| 374 | </span> | 403 | </span> |
| 375 | ); | 404 | ); |
| ... | @@ -420,20 +449,23 @@ export function ProviderModelsSection({ | ... | @@ -420,20 +449,23 @@ export function ProviderModelsSection({ |
| 420 | key={`${instanceId}:${model.slug}`} | 449 | key={`${instanceId}:${model.slug}`} |
| 421 | data-model-slug={model.slug} | 450 | data-model-slug={model.slug} |
| 422 | className={cn( | 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 | isHidden && "opacity-50", | 455 | isHidden && "opacity-50", |
| 425 | )} | 456 | )} |
| 426 | > | 457 | > |
| 427 | {starButton(model, isFavorite)} | 458 | {starButton(model, isFavorite)} |
| 428 | <span className="flex min-w-0 items-baseline gap-2"> | 459 | <span className="flex min-w-0 items-baseline gap-2"> |
| 429 | <span className={cn(nameClassName, "truncate")}>{model.name}</span> | 460 | <span className={cn(nameClassName, "truncate")}>{model.name}</span> |
| 430 | {model.isCustom ? ( | 461 | {model.name !== model.slug ? ( |
| 431 | <span className="text-[11px] text-muted-foreground/70">custom</span> | ||
| 432 | ) : model.name !== model.slug ? ( | ||
| 433 | <code className="truncate font-mono text-[11px] text-muted-foreground/70"> | 462 | <code className="truncate font-mono text-[11px] text-muted-foreground/70"> |
| 434 | {model.slug} | 463 | {model.slug} |
| 435 | </code> | 464 | </code> |
| 436 | ) : null} | 465 | ) : null} |
| 466 | {model.isCustom ? ( | ||
| 467 | <span className="text-[11px] text-muted-foreground/70">custom</span> | ||
| 468 | ) : null} | ||
| 437 | </span> | 469 | </span> |
| 438 | {/* | 470 | {/* |
| 439 | Always a grid item so the columns line up across rows; the text | 471 | Always a grid item so the columns line up across rows; the text |
| ... | @@ -489,6 +521,10 @@ export function ProviderModelsSection({ | ... | @@ -489,6 +521,10 @@ export function ProviderModelsSection({ |
| 489 | const group = groupOf(model); | 521 | const group = groupOf(model); |
| 490 | const previous = visibleModels[index - 1]; | 522 | const previous = visibleModels[index - 1]; |
| 491 | const startsGroup = previous === undefined || groupOf(previous) !== group; | 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 | return ( | 528 | return ( |
| 493 | <div key={`${instanceId}:${model.slug}:group`}> | 529 | <div key={`${instanceId}:${model.slug}:group`}> |
| 494 | {startsGroup && favoriteCount > 0 && group === "favorite" | 530 | {startsGroup && favoriteCount > 0 && group === "favorite" |
| ... | @@ -501,6 +537,17 @@ export function ProviderModelsSection({ | ... | @@ -501,6 +537,17 @@ export function ProviderModelsSection({ |
| 501 | ? groupLabel("Hidden from picker", index === 0) | 537 | ? groupLabel("Hidden from picker", index === 0) |
| 502 | : null} | 538 | : null} |
| 503 | {renderRow(model)} | 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 | </div> | 551 | </div> |
| 505 | ); | 552 | ); |
| 506 | })} | 553 | })} |
apps/web/src/components/settings/customModelEditor.logic.test.ts created+259| ... | @@ -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 | }); | ||
apps/web/src/components/settings/customModelEditor.logic.ts created+258| ... | @@ -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 | } | ||
apps/web/src/modelSelection.ts+23-27| ... | @@ -10,8 +10,10 @@ import { | ... | @@ -10,8 +10,10 @@ import { |
| 10 | type ServerSettingsPatch, | 10 | type ServerSettingsPatch, |
| 11 | } from "@t3tools/contracts"; | 11 | } from "@t3tools/contracts"; |
| 12 | import { | 12 | import { |
| 13 | type CustomModelDefinition, | ||
| 13 | createModelSelection, | 14 | createModelSelection, |
| 14 | normalizeCustomModelSlug, | 15 | normalizeCustomModelSlug, |
| 16 | readCustomModelEntries, | ||
| 15 | resolveSelectableModel, | 17 | resolveSelectableModel, |
| 16 | } from "@t3tools/shared/model"; | 18 | } from "@t3tools/shared/model"; |
| 17 | import { getComposerProviderState } from "./components/chat/composerProviderState"; | 19 | import { getComposerProviderState } from "./components/chat/composerProviderState"; |
| ... | @@ -55,14 +57,14 @@ function readInstanceCustomModels( | ... | @@ -55,14 +57,14 @@ function readInstanceCustomModels( |
| 55 | settings: UnifiedSettings, | 57 | settings: UnifiedSettings, |
| 56 | instanceId: ProviderInstanceId, | 58 | instanceId: ProviderInstanceId, |
| 57 | driverKind: ProviderDriverKind, | 59 | driverKind: ProviderDriverKind, |
| 58 | ): ReadonlyArray<string> { | 60 | ): ReadonlyArray<CustomModelDefinition> { |
| 59 | if (driverKind === "antigravity") return []; | 61 | if (driverKind === "antigravity") return []; |
| 60 | const instance = settings.providerInstances?.[instanceId]; | 62 | const instance = settings.providerInstances?.[instanceId]; |
| 61 | const config = instance?.config; | 63 | const config = instance?.config; |
| 62 | if (config !== null && typeof config === "object") { | 64 | if (config !== null && typeof config === "object") { |
| 63 | const value = (config as Record<string, unknown>).customModels; | 65 | const value = (config as Record<string, unknown>).customModels; |
| 64 | if (Array.isArray(value)) { | 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 | const defaultInstanceId = defaultInstanceIdForDriver(driverKind); | 70 | const defaultInstanceId = defaultInstanceIdForDriver(driverKind); |
| ... | @@ -71,9 +73,9 @@ function readInstanceCustomModels( | ... | @@ -71,9 +73,9 @@ function readInstanceCustomModels( |
| 71 | } | 73 | } |
| 72 | const legacyProviders = settings.providers as Record< | 74 | const legacyProviders = settings.providers as Record< |
| 73 | string, | 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 | export interface AppModelOption { | 81 | export interface AppModelOption { |
| ... | @@ -151,26 +153,24 @@ function applyInstanceModelPreferences( | ... | @@ -151,26 +153,24 @@ function applyInstanceModelPreferences( |
| 151 | ); | 153 | ); |
| 152 | } | 154 | } |
| 153 | 155 | ||
| 154 | export function normalizeCustomModelSlugs( | 156 | export function normalizeCustomModelEntries( |
| 155 | models: Iterable<string | null | undefined>, | 157 | models: ReadonlyArray<CustomModelDefinition>, |
| 156 | builtInModelSlugs: ReadonlySet<string>, | 158 | builtInModelSlugs: ReadonlySet<string>, |
| 157 | ): string[] { | 159 | ): CustomModelDefinition[] { |
| 158 | const normalizedModels: string[] = []; | 160 | const normalizedModels: CustomModelDefinition[] = []; |
| 159 | const seen = new Set<string>(); | 161 | const seen = new Set<string>(); |
| 160 | 162 | ||
| 161 | for (const candidate of models) { | 163 | for (const candidate of models) { |
| 162 | const normalized = normalizeCustomModelSlug(candidate); | ||
| 163 | if ( | 164 | if ( |
| 164 | !normalized || | 165 | candidate.slug.length > MAX_CUSTOM_MODEL_LENGTH || |
| 165 | normalized.length > MAX_CUSTOM_MODEL_LENGTH || | 166 | builtInModelSlugs.has(candidate.slug) || |
| 166 | builtInModelSlugs.has(normalized) || | 167 | seen.has(candidate.slug) |
| 167 | seen.has(normalized) | ||
| 168 | ) { | 168 | ) { |
| 169 | continue; | 169 | continue; |
| 170 | } | 170 | } |
| 171 | 171 | ||
| 172 | seen.add(normalized); | 172 | seen.add(candidate.slug); |
| 173 | normalizedModels.push(normalized); | 173 | normalizedModels.push(candidate); |
| 174 | if (normalizedModels.length >= MAX_CUSTOM_MODEL_COUNT) { | 174 | if (normalizedModels.length >= MAX_CUSTOM_MODEL_COUNT) { |
| 175 | break; | 175 | break; |
| 176 | } | 176 | } |
| ... | @@ -205,17 +205,13 @@ export function getAppModelOptions( | ... | @@ -205,17 +205,13 @@ export function getAppModelOptions( |
| 205 | // see the user's authored custom models. | 205 | // see the user's authored custom models. |
| 206 | const defaultInstanceId = defaultInstanceIdForDriver(provider); | 206 | const defaultInstanceId = defaultInstanceIdForDriver(provider); |
| 207 | const customModels = readInstanceCustomModels(settings, defaultInstanceId, provider); | 207 | const customModels = readInstanceCustomModels(settings, defaultInstanceId, provider); |
| 208 | for (const slug of normalizeCustomModelSlugs(customModels, builtInModelSlugs)) { | 208 | for (const entry of normalizeCustomModelEntries(customModels, builtInModelSlugs)) { |
| 209 | if (seen.has(slug)) { | 209 | if (seen.has(entry.slug)) { |
| 210 | continue; | 210 | continue; |
| 211 | } | 211 | } |
| 212 | 212 | ||
| 213 | seen.add(slug); | 213 | seen.add(entry.slug); |
| 214 | options.push({ | 214 | options.push({ slug: entry.slug, name: entry.name, isCustom: true }); |
| 215 | slug, | ||
| 216 | name: slug, | ||
| 217 | isCustom: true, | ||
| 218 | }); | ||
| 219 | } | 215 | } |
| 220 | 216 | ||
| 221 | const preferences = readInstanceModelPreferences(settings, defaultInstanceId); | 217 | const preferences = readInstanceModelPreferences(settings, defaultInstanceId); |
| ... | @@ -257,13 +253,13 @@ export function getAppModelOptionsForInstance( | ... | @@ -257,13 +253,13 @@ export function getAppModelOptionsForInstance( |
| 257 | ); | 253 | ); |
| 258 | 254 | ||
| 259 | const customModels = readInstanceCustomModels(settings, entry.instanceId, entry.driverKind); | 255 | const customModels = readInstanceCustomModels(settings, entry.instanceId, entry.driverKind); |
| 260 | for (const slug of normalizeCustomModelSlugs(customModels, builtInModelSlugs)) { | 256 | for (const custom of normalizeCustomModelEntries(customModels, builtInModelSlugs)) { |
| 261 | if (seen.has(slug)) { | 257 | if (seen.has(custom.slug)) { |
| 262 | continue; | 258 | continue; |
| 263 | } | 259 | } |
| 264 | 260 | ||
| 265 | seen.add(slug); | 261 | seen.add(custom.slug); |
| 266 | options.push({ slug, name: slug, isCustom: true }); | 262 | options.push({ slug: custom.slug, name: custom.name, isCustom: true }); |
| 267 | } | 263 | } |
| 268 | 264 | ||
| 269 | const preferences = readInstanceModelPreferences(settings, entry.instanceId); | 265 | const preferences = readInstanceModelPreferences(settings, entry.instanceId); |
docs/user/composer.md+6| ... | @@ -30,6 +30,12 @@ messages while disconnected. Uploads resume when you reconnect. Drafts and queue | ... | @@ -30,6 +30,12 @@ messages while disconnected. Uploads resume when you reconnect. Drafts and queue |
| 30 | messages survive app restarts. Signing out of T3 Connect keeps that work on your | 30 | messages survive app restarts. Signing out of T3 Connect keeps that work on your |
| 31 | device until you sign back into the same account. | 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 | ## Model defaults | 39 | ## Model defaults |
| 34 | 40 | ||
| 35 | T3 Code remembers your provider, model, and model options for new threads. A | 41 | T3 Code remembers your provider, model, and model options for new threads. A |
packages/contracts/src/model.ts+16| ... | @@ -127,6 +127,22 @@ export const ModelCapabilities = Schema.Struct({ | ... | @@ -127,6 +127,22 @@ export const ModelCapabilities = Schema.Struct({ |
| 127 | }); | 127 | }); |
| 128 | export type ModelCapabilities = typeof ModelCapabilities.Type; | 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 | const CODEX_DRIVER_KIND = ProviderDriverKind.make("codex"); | 146 | const CODEX_DRIVER_KIND = ProviderDriverKind.make("codex"); |
| 131 | const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); | 147 | const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); |
| 132 | const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor"); | 148 | const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor"); |
packages/contracts/src/settings.test.ts+34| ... | @@ -73,6 +73,40 @@ describe("ServerSettings usage price overrides", () => { | ... | @@ -73,6 +73,40 @@ describe("ServerSettings usage price overrides", () => { |
| 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 | describe("ClaudeSettings auto-compaction", () => { | 110 | describe("ClaudeSettings auto-compaction", () => { |
| 77 | it("uses Claude's default threshold when no override is configured", () => { | 111 | it("uses Claude's default threshold when no override is configured", () => { |
| 78 | expect(decodeClaudeSettings({}).autoCompactWindow).toBe(""); | 112 | expect(decodeClaudeSettings({}).autoCompactWindow).toBe(""); |
packages/contracts/src/settings.ts+13-12| ... | @@ -6,6 +6,7 @@ import { ForwardCompatibleNullable, TrimmedNonEmptyString, TrimmedString } from | ... | @@ -6,6 +6,7 @@ import { ForwardCompatibleNullable, TrimmedNonEmptyString, TrimmedString } from |
| 6 | import { UsageLimitSourceId } from "./usageLimitSourceId.ts"; | 6 | import { UsageLimitSourceId } from "./usageLimitSourceId.ts"; |
| 7 | import { EnvironmentMachineKind, ThreadEnvMode } from "./environment.ts"; | 7 | import { EnvironmentMachineKind, ThreadEnvMode } from "./environment.ts"; |
| 8 | import { | 8 | import { |
| 9 | CustomModelSetting, | ||
| 9 | DEFAULT_TEXT_GENERATION_MODEL, | 10 | DEFAULT_TEXT_GENERATION_MODEL, |
| 10 | DEFAULT_TEXT_GENERATION_REASONING_EFFORT, | 11 | DEFAULT_TEXT_GENERATION_REASONING_EFFORT, |
| 11 | ProviderOptionSelections, | 12 | ProviderOptionSelections, |
| ... | @@ -472,7 +473,7 @@ export const CodexSettings = makeProviderSettingsSchema( | ... | @@ -472,7 +473,7 @@ export const CodexSettings = makeProviderSettingsSchema( |
| 472 | description: "Additional CLI arguments passed to codex app-server on session start.", | 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 | Schema.withDecodingDefault(Effect.succeed([])), | 477 | Schema.withDecodingDefault(Effect.succeed([])), |
| 477 | Schema.annotateKey({ providerSettingsForm: { hidden: true } }), | 478 | Schema.annotateKey({ providerSettingsForm: { hidden: true } }), |
| 478 | ), | 479 | ), |
| ... | @@ -510,7 +511,7 @@ export const ClaudeSettings = makeProviderSettingsSchema( | ... | @@ -510,7 +511,7 @@ export const ClaudeSettings = makeProviderSettingsSchema( |
| 510 | providerSettingsForm: { placeholder: "~/.claude", clearWhenEmpty: "omit" }, | 511 | providerSettingsForm: { placeholder: "~/.claude", clearWhenEmpty: "omit" }, |
| 511 | }), | 512 | }), |
| 512 | ), | 513 | ), |
| 513 | customModels: Schema.Array(Schema.String).pipe( | 514 | customModels: Schema.Array(CustomModelSetting).pipe( |
| 514 | Schema.withDecodingDefault(Effect.succeed([])), | 515 | Schema.withDecodingDefault(Effect.succeed([])), |
| 515 | Schema.annotateKey({ providerSettingsForm: { hidden: true } }), | 516 | Schema.annotateKey({ providerSettingsForm: { hidden: true } }), |
| 516 | ), | 517 | ), |
| ... | @@ -571,7 +572,7 @@ export const CursorSettings = makeProviderSettingsSchema( | ... | @@ -571,7 +572,7 @@ export const CursorSettings = makeProviderSettingsSchema( |
| 571 | }, | 572 | }, |
| 572 | }), | 573 | }), |
| 573 | ), | 574 | ), |
| 574 | customModels: Schema.Array(Schema.String).pipe( | 575 | customModels: Schema.Array(CustomModelSetting).pipe( |
| 575 | Schema.withDecodingDefault(Effect.succeed([])), | 576 | Schema.withDecodingDefault(Effect.succeed([])), |
| 576 | Schema.annotateKey({ providerSettingsForm: { hidden: true } }), | 577 | Schema.annotateKey({ providerSettingsForm: { hidden: true } }), |
| 577 | ), | 578 | ), |
| ... | @@ -597,7 +598,7 @@ export const GrokSettings = makeProviderSettingsSchema( | ... | @@ -597,7 +598,7 @@ export const GrokSettings = makeProviderSettingsSchema( |
| 597 | providerSettingsForm: { placeholder: "grok", clearWhenEmpty: "omit" }, | 598 | providerSettingsForm: { placeholder: "grok", clearWhenEmpty: "omit" }, |
| 598 | }), | 599 | }), |
| 599 | ), | 600 | ), |
| 600 | customModels: Schema.Array(Schema.String).pipe( | 601 | customModels: Schema.Array(CustomModelSetting).pipe( |
| 601 | Schema.withDecodingDefault(Effect.succeed([])), | 602 | Schema.withDecodingDefault(Effect.succeed([])), |
| 602 | Schema.annotateKey({ providerSettingsForm: { hidden: true } }), | 603 | Schema.annotateKey({ providerSettingsForm: { hidden: true } }), |
| 603 | ), | 604 | ), |
| ... | @@ -682,7 +683,7 @@ export const AntigravitySettings = makeProviderSettingsSchema( | ... | @@ -682,7 +683,7 @@ export const AntigravitySettings = makeProviderSettingsSchema( |
| 682 | providerSettingsForm: { placeholder: "Automatic", clearWhenEmpty: "persist" }, | 683 | providerSettingsForm: { placeholder: "Automatic", clearWhenEmpty: "persist" }, |
| 683 | }), | 684 | }), |
| 684 | ), | 685 | ), |
| 685 | customModels: Schema.Array(Schema.String).pipe( | 686 | customModels: Schema.Array(CustomModelSetting).pipe( |
| 686 | Schema.withDecodingDefault(Effect.succeed([])), | 687 | Schema.withDecodingDefault(Effect.succeed([])), |
| 687 | Schema.annotateKey({ providerSettingsForm: { hidden: true } }), | 688 | Schema.annotateKey({ providerSettingsForm: { hidden: true } }), |
| 688 | ), | 689 | ), |
| ... | @@ -732,7 +733,7 @@ export const OpenCodeSettings = makeProviderSettingsSchema( | ... | @@ -732,7 +733,7 @@ export const OpenCodeSettings = makeProviderSettingsSchema( |
| 732 | }, | 733 | }, |
| 733 | }), | 734 | }), |
| 734 | ), | 735 | ), |
| 735 | customModels: Schema.Array(Schema.String).pipe( | 736 | customModels: Schema.Array(CustomModelSetting).pipe( |
| 736 | Schema.withDecodingDefault(Effect.succeed([])), | 737 | Schema.withDecodingDefault(Effect.succeed([])), |
| 737 | Schema.annotateKey({ providerSettingsForm: { hidden: true } }), | 738 | Schema.annotateKey({ providerSettingsForm: { hidden: true } }), |
| 738 | ), | 739 | ), |
| ... | @@ -1055,14 +1056,14 @@ const CodexSettingsPatch = Schema.Struct({ | ... | @@ -1055,14 +1056,14 @@ const CodexSettingsPatch = Schema.Struct({ |
| 1055 | homePath: Schema.optionalKey(TrimmedString), | 1056 | homePath: Schema.optionalKey(TrimmedString), |
| 1056 | shadowHomePath: Schema.optionalKey(TrimmedString), | 1057 | shadowHomePath: Schema.optionalKey(TrimmedString), |
| 1057 | launchArgs: Schema.optionalKey(TrimmedString), | 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 | const ClaudeSettingsPatch = Schema.Struct({ | 1062 | const ClaudeSettingsPatch = Schema.Struct({ |
| 1062 | enabled: Schema.optionalKey(Schema.Boolean), | 1063 | enabled: Schema.optionalKey(Schema.Boolean), |
| 1063 | binaryPath: Schema.optionalKey(TrimmedString), | 1064 | binaryPath: Schema.optionalKey(TrimmedString), |
| 1064 | homePath: Schema.optionalKey(TrimmedString), | 1065 | homePath: Schema.optionalKey(TrimmedString), |
| 1065 | customModels: Schema.optionalKey(Schema.Array(Schema.String)), | 1066 | customModels: Schema.optionalKey(Schema.Array(CustomModelSetting)), |
| 1066 | launchArgs: Schema.optionalKey(TrimmedString), | 1067 | launchArgs: Schema.optionalKey(TrimmedString), |
| 1067 | // Validated at the patch boundary so a typo fails the one update with a | 1068 | // Validated at the patch boundary so a typo fails the one update with a |
| 1068 | // schema error instead of a generic whole-settings failure. | 1069 | // schema error instead of a generic whole-settings failure. |
| ... | @@ -1075,13 +1076,13 @@ const CursorSettingsPatch = Schema.Struct({ | ... | @@ -1075,13 +1076,13 @@ const CursorSettingsPatch = Schema.Struct({ |
| 1075 | enabled: Schema.optionalKey(Schema.Boolean), | 1076 | enabled: Schema.optionalKey(Schema.Boolean), |
| 1076 | binaryPath: Schema.optionalKey(TrimmedString), | 1077 | binaryPath: Schema.optionalKey(TrimmedString), |
| 1077 | apiEndpoint: Schema.optionalKey(TrimmedString), | 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 | const GrokSettingsPatch = Schema.Struct({ | 1082 | const GrokSettingsPatch = Schema.Struct({ |
| 1082 | enabled: Schema.optionalKey(Schema.Boolean), | 1083 | enabled: Schema.optionalKey(Schema.Boolean), |
| 1083 | binaryPath: Schema.optionalKey(TrimmedString), | 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 | const AntigravitySettingsPatch = Schema.Struct({ | 1088 | const AntigravitySettingsPatch = Schema.Struct({ |
| ... | @@ -1091,7 +1092,7 @@ const AntigravitySettingsPatch = Schema.Struct({ | ... | @@ -1091,7 +1092,7 @@ const AntigravitySettingsPatch = Schema.Struct({ |
| 1091 | gcpProject: Schema.optionalKey(TrimmedString), | 1092 | gcpProject: Schema.optionalKey(TrimmedString), |
| 1092 | gcpLocation: Schema.optionalKey(TrimmedString), | 1093 | gcpLocation: Schema.optionalKey(TrimmedString), |
| 1093 | binaryPath: Schema.optionalKey(TrimmedString), | 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 | const OpenCodeSettingsPatch = Schema.Struct({ | 1098 | const OpenCodeSettingsPatch = Schema.Struct({ |
| ... | @@ -1099,7 +1100,7 @@ const OpenCodeSettingsPatch = Schema.Struct({ | ... | @@ -1099,7 +1100,7 @@ const OpenCodeSettingsPatch = Schema.Struct({ |
| 1099 | binaryPath: Schema.optionalKey(TrimmedString), | 1100 | binaryPath: Schema.optionalKey(TrimmedString), |
| 1100 | serverUrl: Schema.optionalKey(TrimmedString), | 1101 | serverUrl: Schema.optionalKey(TrimmedString), |
| 1101 | serverPassword: Schema.optionalKey(TrimmedString), | 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 | export const ServerSettingsPatch = Schema.Struct({ | 1106 | export const ServerSettingsPatch = Schema.Struct({ |
packages/shared/src/model.test.ts+52| ... | @@ -10,6 +10,8 @@ import { | ... | @@ -10,6 +10,8 @@ import { |
| 10 | getModelSelectionBooleanOptionValue, | 10 | getModelSelectionBooleanOptionValue, |
| 11 | getModelSelectionStringOptionValue, | 11 | getModelSelectionStringOptionValue, |
| 12 | getProviderOptionDescriptors, | 12 | getProviderOptionDescriptors, |
| 13 | readCustomModelEntries, | ||
| 14 | toCustomModelSetting, | ||
| 13 | getProviderOptionBooleanSelectionValue, | 15 | getProviderOptionBooleanSelectionValue, |
| 14 | getProviderOptionStringSelectionValue, | 16 | getProviderOptionStringSelectionValue, |
| 15 | } from "./model.ts"; | 17 | } from "./model.ts"; |
| ... | @@ -192,3 +194,53 @@ describe("applyClaudePromptEffortPrefix", () => { | ... | @@ -192,3 +194,53 @@ describe("applyClaudePromptEffortPrefix", () => { |
| 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 | }); |
packages/shared/src/model.ts+74-1| ... | @@ -1,12 +1,15 @@ | ... | @@ -1,12 +1,15 @@ |
| 1 | import { | 1 | import { |
| 2 | type CustomModelSetting, | ||
| 2 | MODEL_SLUG_ALIASES_BY_PROVIDER, | 3 | MODEL_SLUG_ALIASES_BY_PROVIDER, |
| 3 | type ModelCapabilities, | 4 | ModelCapabilities, |
| 4 | type ModelSelection, | 5 | type ModelSelection, |
| 5 | ProviderDriverKind, | 6 | ProviderDriverKind, |
| 6 | ProviderInstanceId, | 7 | ProviderInstanceId, |
| 7 | type ProviderOptionDescriptor, | 8 | type ProviderOptionDescriptor, |
| 8 | type ProviderOptionSelection, | 9 | type ProviderOptionSelection, |
| 9 | } from "@t3tools/contracts"; | 10 | } from "@t3tools/contracts"; |
| 11 | import * as Option from "effect/Option"; | ||
| 12 | import * as Schema from "effect/Schema"; | ||
| 10 | 13 | ||
| 11 | const DEFAULT_PROVIDER_DRIVER_KIND = ProviderDriverKind.make("codex"); | 14 | const DEFAULT_PROVIDER_DRIVER_KIND = ProviderDriverKind.make("codex"); |
| 12 | 15 | ||
| ... | @@ -247,6 +250,76 @@ export function normalizeCustomModelSlug(model: string | null | undefined): stri | ... | @@ -247,6 +250,76 @@ export function normalizeCustomModelSlug(model: string | null | undefined): stri |
| 247 | return model.trim() || null; | 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 | export function resolveSelectableModel( | 323 | export function resolveSelectableModel( |
| 251 | provider: ProviderDriverKind, | 324 | provider: ProviderDriverKind, |
| 252 | value: string | null | undefined, | 325 | value: string | null | undefined, |