diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 1f971a6126ed..2d46a1579b91 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -38,6 +38,7 @@ import { ServerSettingsService } from "../../serverSettings.ts"; import { ProviderAdapterProcessError, ProviderAdapterValidationError } from "../Errors.ts"; import type { ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts"; import { makeClaudeAdapter, type ClaudeAdapterLiveOptions } from "./ClaudeAdapter.ts"; +import { getClaudeModelCapabilities } from "./ClaudeProvider.ts"; const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings); // Test-local service tag so the rest of the file can keep using `yield* ClaudeAdapter`. @@ -273,6 +274,51 @@ const THREAD_ID = ThreadId.make("thread-claude-1"); const RESUME_THREAD_ID = ThreadId.make("thread-claude-resume"); describe("ClaudeAdapterLive", () => { + it("keeps built-in capabilities ahead of custom metadata", () => { + const builtIn = getClaudeModelCapabilities("claude-opus-5"); + const resolved = getClaudeModelCapabilities("claude-opus-5", { + "claude-opus-5": { optionDescriptors: [] }, + }); + + assert.deepEqual(resolved, builtIn); + assert.equal( + resolved.optionDescriptors?.some((descriptor) => descriptor.id === "effort"), + true, + ); + }); + + it("keeps arbitrary custom controls in the Claude catalog", () => { + const resolved = getClaudeModelCapabilities("vendor/preview", { + "vendor/preview": { + optionDescriptors: [ + { + id: "effort", + label: "Reasoning", + type: "select", + options: [{ id: "high", label: "High" }], + }, + { + id: "serviceTier", + label: "Service Tier", + type: "select", + options: [{ id: "priority", label: "Priority" }], + }, + ], + }, + }); + + assert.deepEqual( + resolved.optionDescriptors?.map((descriptor) => descriptor.id), + ["effort", "serviceTier"], + ); + }); + + it("does not read inherited object keys as Claude custom capabilities", () => { + const resolved = getClaudeModelCapabilities("constructor", {}); + + assert.deepEqual(resolved, { optionDescriptors: [] }); + }); + it.effect("returns validation error for non-claude provider on startSession", () => { const harness = makeHarness(); return Effect.gen(function* () { @@ -700,6 +746,80 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("forwards declared custom-model controls to Claude SDK launch options", () => { + const customModelCapabilities = { + "gateway/model": { + optionDescriptors: [ + { + id: "effort", + label: "Reasoning", + type: "select" as const, + options: [ + { id: "low", label: "Low" }, + { id: "xhigh", label: "Extra High" }, + { id: "max", label: "Max", isDefault: true }, + ], + }, + { + id: "fastMode", + label: "Fast Mode", + type: "boolean" as const, + }, + { + id: "thinking", + label: "Thinking", + type: "boolean" as const, + }, + { + id: "contextWindow", + label: "Context Window", + type: "select" as const, + options: [ + { id: "200k", label: "200k" }, + { id: "1m", label: "1M", isDefault: true }, + ], + }, + ], + }, + }; + const harness = makeHarness({ + claudeConfig: { + customModels: ["gateway/model"], + customModelCapabilities, + } as Partial, + }); + + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "gateway/model", + [ + { id: "effort", value: "xhigh" }, + { id: "fastMode", value: false }, + { id: "thinking", value: false }, + { id: "contextWindow", value: "1m" }, + ], + ), + runtimeMode: "full-access", + }); + + const createInput = harness.getLastCreateQueryInput(); + assert.equal(createInput?.options.model, "gateway/model[1m]"); + assert.equal(createInput?.options.effort, "xhigh"); + assert.deepEqual(createInput?.options.settings, { + alwaysThinkingEnabled: false, + fastMode: false, + }); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("treats ultrathink as a prompt keyword instead of a session effort", () => { const harness = makeHarness(); return Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 5715b68a1e45..97d452e775c5 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -31,6 +31,7 @@ import { ProviderDriverKind, ProviderInstanceId, type ModelSelection, + type ModelCapabilities, ProviderItemId, type ProviderRuntimeEvent, type ProviderRuntimeTurnStatus, @@ -79,6 +80,7 @@ import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts"; import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; import { getClaudeModelCapabilities, + isConfiguredCustomClaudeModel, isClaudeUltracodeEffort, normalizeClaudeCliEffort, resolveClaudeApiModelId, @@ -351,8 +353,9 @@ function normalizeClaudeStreamMessages( function getEffectiveClaudeAgentEffort( effort: string | null | undefined, model: string | null | undefined, + isCustomModel = false, ): ClaudeSdkEffort | null { - const normalized = normalizeClaudeCliEffort(effort, model); + const normalized = normalizeClaudeCliEffort(effort, model, isCustomModel); return normalized ? (normalized as ClaudeSdkEffort) : null; } @@ -439,6 +442,7 @@ function maxClaudeContextWindowFromModelUsage( function selectedClaudeContextWindow( modelSelection: ModelSelection | undefined, + customModelCapabilities: Readonly> = {}, ): number | undefined { switch (modelSelection?.model) { case "claude-opus-4-8": @@ -447,7 +451,7 @@ function selectedClaudeContextWindow( return 1_000_000; } - switch (resolveClaudeContextWindow(modelSelection)) { + switch (resolveClaudeContextWindow(modelSelection, customModelCapabilities)) { case "1m": return 1_000_000; case "200k": @@ -1210,6 +1214,7 @@ const CLAUDE_SETTING_SOURCES = [ function buildPromptText( input: ProviderSendTurnInput, boundInstanceId: ProviderInstanceId, + customModelCapabilities: Readonly> = {}, ): string { const rawEffort = input.modelSelection?.instanceId === boundInstanceId @@ -1217,7 +1222,7 @@ function buildPromptText( : null; const claudeModel = input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection.model : undefined; - const caps = getClaudeModelCapabilities(claudeModel); + const caps = getClaudeModelCapabilities(claudeModel, customModelCapabilities); const promptEffort = resolvePromptInjectedEffort(caps, rawEffort); return applyClaudePromptEffortPrefix(input.input?.trim() ?? "", promptEffort); @@ -1257,9 +1262,14 @@ const buildUserMessageEffect = Effect.fn("buildUserMessageEffect")(function* ( readonly fileSystem: FileSystem.FileSystem; readonly attachmentsDir: string; readonly boundInstanceId: ProviderInstanceId; + readonly customModelCapabilities: Readonly>; }, ) { - const text = buildPromptText(input, dependencies.boundInstanceId); + const text = buildPromptText( + input, + dependencies.boundInstanceId, + dependencies.customModelCapabilities, + ); const sdkContent: Array> = []; if (text.length > 0) { @@ -1656,6 +1666,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( options?: ClaudeAdapterLiveOptions, ) { const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("claudeAgent"); + const customModelCapabilities = claudeSettings.customModelCapabilities ?? {}; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const serverConfig = yield* ServerConfig; @@ -4111,10 +4122,15 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const extraArgs = parseCliArgs(claudeSettings.launchArgs).flags; const modelSelection = input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; - const caps = getClaudeModelCapabilities(modelSelection?.model); + const caps = getClaudeModelCapabilities(modelSelection?.model, customModelCapabilities); const descriptors = getProviderOptionDescriptors({ caps }); - const apiModelId = modelSelection ? resolveClaudeApiModelId(modelSelection) : undefined; - const initialContextWindow = selectedClaudeContextWindow(modelSelection); + const apiModelId = modelSelection + ? resolveClaudeApiModelId(modelSelection, customModelCapabilities) + : undefined; + const initialContextWindow = selectedClaudeContextWindow( + modelSelection, + customModelCapabilities, + ); const rawEffort = getModelSelectionStringOptionValue(modelSelection, "effort"); const effort = resolveClaudeEffort(caps, rawEffort) ?? null; const fastModeSupported = descriptors.some( @@ -4123,14 +4139,18 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const thinkingSupported = descriptors.some( (descriptor) => descriptor.type === "boolean" && descriptor.id === "thinking", ); - const fastMode = - getModelSelectionBooleanOptionValue(modelSelection, "fastMode") === true && - fastModeSupported; + const fastMode = fastModeSupported + ? getModelSelectionBooleanOptionValue(modelSelection, "fastMode") + : undefined; const thinking = thinkingSupported ? getModelSelectionBooleanOptionValue(modelSelection, "thinking") : undefined; const ultracode = isClaudeUltracodeEffort(effort); - const effectiveEffort = getEffectiveClaudeAgentEffort(effort, modelSelection?.model); + const effectiveEffort = getEffectiveClaudeAgentEffort( + effort, + modelSelection?.model, + isConfiguredCustomClaudeModel(modelSelection?.model, customModelCapabilities), + ); const runtimeModeToPermission: Record = { "auto-accept-edits": "acceptEdits", auto: "auto", @@ -4139,7 +4159,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const permissionMode = runtimeModeToPermission[input.runtimeMode]; const settings = { ...(typeof thinking === "boolean" ? { alwaysThinkingEnabled: thinking } : {}), - ...(fastMode ? { fastMode: true } : {}), + ...(typeof fastMode === "boolean" ? { fastMode } : {}), ...(ultracode ? { ultracode: true } : {}), }; const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); @@ -4303,7 +4323,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...(input.cwd ? { cwd: input.cwd } : {}), ...(effectiveEffort ? { effort: effectiveEffort } : {}), ...(permissionMode ? { permissionMode } : {}), - ...(fastMode ? { fastMode: true } : {}), + ...(typeof fastMode === "boolean" ? { fastMode } : {}), }, }, providerRefs: {}, @@ -4372,7 +4392,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( } if (modelSelection?.model) { - const apiModelId = resolveClaudeApiModelId(modelSelection); + const apiModelId = resolveClaudeApiModelId(modelSelection, customModelCapabilities); if (context.currentApiModelId !== apiModelId) { yield* Effect.tryPromise({ try: () => context.query.setModel(apiModelId), @@ -4384,13 +4404,17 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...context.session, model: modelSelection.model, }; - const turnCaps = getClaudeModelCapabilities(modelSelection.model); + const turnCaps = getClaudeModelCapabilities(modelSelection.model, customModelCapabilities); const turnEffort = resolveClaudeEffort( turnCaps, getModelSelectionStringOptionValue(modelSelection, "effort"), ); context.currentEffort = - getEffectiveClaudeAgentEffort(turnEffort ?? null, modelSelection.model) ?? undefined; + getEffectiveClaudeAgentEffort( + turnEffort ?? null, + modelSelection.model, + isConfiguredCustomClaudeModel(modelSelection.model, customModelCapabilities), + ) ?? undefined; } // Apply interaction mode by switching the SDK's permission mode. @@ -4447,6 +4471,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( fileSystem, attachmentsDir: serverConfig.attachmentsDir, boundInstanceId, + customModelCapabilities, }); yield* Queue.offer(context.promptQueue, { diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index b08b5db68ee0..e23b9956dc98 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -14,6 +14,7 @@ import * as Result from "effect/Result"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { createModelCapabilities, + getDeclaredCustomModelCapabilities, getModelSelectionStringOptionValue, getProviderOptionCurrentValue, getProviderOptionDescriptors, @@ -46,7 +47,6 @@ import { discoverClaudeSkills } from "../Drivers/ClaudeSkills.ts"; const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ optionDescriptors: [], }); - const CLAUDE_PRESENTATION = { displayName: "Claude", showInteractionModeToggle: true, @@ -330,6 +330,7 @@ const CLAUDE_MODEL_CATALOG: ReadonlyArray = [ const BUILT_IN_MODELS: ReadonlyArray = CLAUDE_MODEL_CATALOG.map((model) => isLegacyClaudeModel(model.slug) ? { ...model, isLegacy: true } : model, ); +const BUILT_IN_MODEL_SLUGS = new Set(BUILT_IN_MODELS.map((model) => model.slug)); function supportsClaudeOpus5(version: string | null | undefined): boolean { return version ? compareSemverVersions(version, MINIMUM_CLAUDE_OPUS_5_VERSION) >= 0 : false; @@ -387,14 +388,31 @@ function formatClaudeOpus47UpgradeMessage(version: string | null): string { return `Claude Code ${versionLabel} is too old for Claude Opus 4.7. Upgrade to v${MINIMUM_CLAUDE_OPUS_4_7_VERSION} or newer to access it.`; } -export function getClaudeModelCapabilities(model: string | null | undefined): ModelCapabilities { +export function getClaudeModelCapabilities( + model: string | null | undefined, + customModelCapabilities: Readonly> = {}, +): ModelCapabilities { const slug = model?.trim(); + const customCapabilities = getDeclaredCustomModelCapabilities(customModelCapabilities, slug); return ( BUILT_IN_MODELS.find((candidate) => candidate.slug === slug)?.capabilities ?? + customCapabilities ?? DEFAULT_CLAUDE_MODEL_CAPABILITIES ); } +export function isConfiguredCustomClaudeModel( + model: string | null | undefined, + customModelCapabilities: Readonly>, +): boolean { + const slug = model?.trim(); + return Boolean( + slug && + !BUILT_IN_MODEL_SLUGS.has(slug) && + getDeclaredCustomModelCapabilities(customModelCapabilities, slug), + ); +} + export function resolveClaudeEffort( caps: ModelCapabilities, raw: string | null | undefined, @@ -421,6 +439,7 @@ export function resolveClaudeEffort( export function normalizeClaudeCliEffort( effort: string | null | undefined, model: string | null | undefined, + isCustomModel = false, ): string | undefined { if (!effort || effort === "ultrathink") { return undefined; @@ -428,6 +447,9 @@ export function normalizeClaudeCliEffort( if (effort === "ultracode") { return "xhigh"; } + if (isCustomModel) { + return effort; + } if ( effort === "xhigh" && model !== "claude-fable-5" && @@ -449,8 +471,9 @@ export function isClaudeUltracodeEffort(effort: string | null | undefined): bool export function resolveClaudeContextWindow( modelSelection: ModelSelection | undefined, + customModelCapabilities: Readonly> = {}, ): string | undefined { - const caps = getClaudeModelCapabilities(modelSelection?.model); + const caps = getClaudeModelCapabilities(modelSelection?.model, customModelCapabilities); const raw = getModelSelectionStringOptionValue(modelSelection, "contextWindow"); const descriptors = getProviderOptionDescriptors({ caps, @@ -461,10 +484,15 @@ export function resolveClaudeContextWindow( return typeof value === "string" ? value : undefined; } -export function resolveClaudeApiModelId(modelSelection: ModelSelection): string { - switch (resolveClaudeContextWindow(modelSelection)) { +export function resolveClaudeApiModelId( + modelSelection: ModelSelection, + customModelCapabilities: Readonly> = {}, +): string { + switch (resolveClaudeContextWindow(modelSelection, customModelCapabilities)) { case "1m": - return `${modelSelection.model}[1m]`; + return modelSelection.model.endsWith("[1m]") + ? modelSelection.model + : `${modelSelection.model}[1m]`; default: return modelSelection.model; } @@ -822,6 +850,9 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( BUILT_IN_MODELS, claudeSettings.customModels, DEFAULT_CLAUDE_MODEL_CAPABILITIES, + { + customModelCapabilities: claudeSettings.customModelCapabilities, + }, ); if (!claudeSettings.enabled) { @@ -910,8 +941,11 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( const models = providerModelsFromSettings( getBuiltInClaudeModelsForVersion(parsedVersion), - claudeSettings.customModels, + claudeSettings.customModels.filter((model) => !BUILT_IN_MODEL_SLUGS.has(model.trim())), DEFAULT_CLAUDE_MODEL_CAPABILITIES, + { + customModelCapabilities: claudeSettings.customModelCapabilities, + }, ); const versionUpgradeMessage = supportsClaudeOpus5(parsedVersion) ? undefined @@ -985,6 +1019,9 @@ export const makePendingClaudeProvider = ( BUILT_IN_MODELS, claudeSettings.customModels, DEFAULT_CLAUDE_MODEL_CAPABILITIES, + { + customModelCapabilities: claudeSettings.customModelCapabilities, + }, ); if (!claudeSettings.enabled) { diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 5358716aabe4..945fd64ad751 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -361,6 +361,52 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => { }), ); + it.effect("passes configured custom model IDs to session and turn runtime requests", () => { + const runtimeFactory = makeRuntimeFactory(); + const layer = Layer.effect( + CodexAdapter, + Effect.gen(function* () { + const codexConfig = decodeCodexSettings({ customModels: ["gpt-5-codex"] }); + return yield* makeCodexAdapter(codexConfig, { + makeRuntime: runtimeFactory.factory, + }); + }), + ).pipe( + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(providerSessionDirectoryTestLayer), + Layer.provideMerge(NodeServices.layer), + ); + + return Effect.gen(function* () { + const adapter = yield* CodexAdapter; + const selection = createModelSelection(ProviderInstanceId.make("codex"), "gpt-5-codex"); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("codex"), + threadId: asThreadId("sess-custom-model-id"), + runtimeMode: "full-access", + modelSelection: selection, + }); + + const runtime = runtimeFactory.lastRuntime; + NodeAssert.ok(runtime); + NodeAssert.equal(runtime.options.model, "gpt-5-codex"); + NodeAssert.deepStrictEqual(runtime.options.customModels, ["gpt-5-codex"]); + + yield* adapter.sendTurn({ + threadId: asThreadId("sess-custom-model-id"), + input: "hello", + modelSelection: selection, + attachments: [], + }); + + NodeAssert.deepStrictEqual(runtime.sendTurnImpl.mock.calls[0]?.[0], { + input: "hello", + model: "gpt-5-codex", + }); + }).pipe(Effect.provide(layer)); + }); + it.effect("passes configured launch args into the session runtime", () => { const runtimeFactory = makeRuntimeFactory(); const layer = Layer.effect( diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 065156d36473..00680d0863f3 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -1658,10 +1658,11 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( yield* Effect.suspend(() => stopSessionInternal(existing)); } - const serviceTier = - input.modelSelection?.instanceId === boundInstanceId - ? getCodexServiceTierOptionValue(input.modelSelection) - : undefined; + const modelSelection = + input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; + const serviceTier = modelSelection + ? getCodexServiceTierOptionValue(modelSelection) + : undefined; const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); const runtimeInput: CodexSessionRuntimeOptions = { threadId: input.threadId, @@ -1675,8 +1676,13 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( ? { resumeCursor: input.resumeCursor } : {}), runtimeMode: input.runtimeMode, - ...(input.modelSelection?.instanceId === boundInstanceId - ? { model: input.modelSelection.model } + ...(modelSelection + ? { + model: modelSelection.model, + } + : {}), + ...(codexConfig.customModels.length > 0 + ? { customModels: codexConfig.customModels } : {}), ...(serviceTier ? { serviceTier } : {}), ...(mcpSession @@ -1808,19 +1814,19 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( ); const session = yield* requireSession(input.threadId); - const reasoningEffort = - input.modelSelection?.instanceId === boundInstanceId - ? getModelSelectionStringOptionValue(input.modelSelection, "reasoningEffort") - : undefined; - const serviceTier = - input.modelSelection?.instanceId === boundInstanceId - ? getCodexServiceTierOptionValue(input.modelSelection) - : undefined; + const modelSelection = + input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; + const reasoningEffort = modelSelection + ? getModelSelectionStringOptionValue(modelSelection, "reasoningEffort") + : undefined; + const serviceTier = modelSelection ? getCodexServiceTierOptionValue(modelSelection) : undefined; return yield* session.runtime .sendTurn({ ...(input.input !== undefined ? { input: input.input } : {}), - ...(input.modelSelection?.instanceId === boundInstanceId - ? { model: input.modelSelection.model } + ...(modelSelection + ? { + model: modelSelection.model, + } : {}), ...(reasoningEffort ? { diff --git a/apps/server/src/provider/Layers/CodexProvider.test.ts b/apps/server/src/provider/Layers/CodexProvider.test.ts index 26e77f82a79e..c11bc257ddd8 100644 --- a/apps/server/src/provider/Layers/CodexProvider.test.ts +++ b/apps/server/src/provider/Layers/CodexProvider.test.ts @@ -1,11 +1,17 @@ import { assert, it } from "@effect/vitest"; +import { CodexSettings } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; import { applyPreferredCodexDefaultModel, isLegacyCodexModel, + makePendingCodexProvider, mapCodexModelCapabilities, } from "./CodexProvider.ts"; +const decodeCodexSettings = Schema.decodeSync(CodexSettings); + it("keeps only the GPT-5.6 Codex family out of legacy models", () => { assert.deepStrictEqual( ["gpt-5.6-luna", "gpt-5.6-terra", "gpt-5.6-sol", "gpt-5.4"].map((model) => [ @@ -83,6 +89,39 @@ it("maps current Codex model capability fields", () => { ]); }); +it.effect("keeps arbitrary custom controls in the Codex catalog", () => + Effect.gen(function* () { + const provider = yield* makePendingCodexProvider( + decodeCodexSettings({ + customModels: ["vendor/preview"], + customModelCapabilities: { + "vendor/preview": { + optionDescriptors: [ + { + id: "temperature", + label: "Temperature", + type: "select", + options: [{ id: "warm", label: "Warm" }], + }, + ], + }, + }, + }), + ); + + assert.deepStrictEqual(provider.models[0]?.capabilities, { + optionDescriptors: [ + { + id: "temperature", + label: "Temperature", + type: "select", + options: [{ id: "warm", label: "Warm" }], + }, + ], + }); + }), +); + it("uses standard routing when the catalog has no default service tier", () => { const capabilities = mapCodexModelCapabilities({ additionalSpeedTiers: ["fast"], diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 5c0f76dff4e3..20ad56123484 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -24,7 +24,7 @@ import type { } from "@t3tools/contracts"; import { PREFERRED_DEFAULT_CODEX_MODELS, ServerSettingsError } from "@t3tools/contracts"; -import { createModelCapabilities } from "@t3tools/shared/model"; +import { createModelCapabilities, getDeclaredCustomModelCapabilities } from "@t3tools/shared/model"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { codexAppServerArgs, resolveCodexLaunchArgs } from "./codexLaunchArgs.ts"; import { @@ -37,7 +37,6 @@ import packageJson from "../../../package.json" with { type: "json" }; const isCodexAppServerSpawnError = Schema.is(CodexErrors.CodexAppServerSpawnError); const CODEX_APP_SERVER_PROBE_FORCE_KILL_AFTER = "2 seconds" as const; - const CODEX_PRESENTATION = { displayName: "Codex", showInteractionModeToggle: true, @@ -228,6 +227,7 @@ export function applyPreferredCodexDefaultModel( function appendCustomCodexModels( models: ReadonlyArray, customModels: ReadonlyArray, + customModelCapabilities: Readonly> = {}, ): ReadonlyArray { if (customModels.length === 0) { return models; @@ -242,11 +242,12 @@ function appendCustomCodexModels( continue; } seen.add(slug); + const declaredCapabilities = getDeclaredCustomModelCapabilities(customModelCapabilities, slug); customEntries.push({ slug, name: slug, isCustom: true, - capabilities: fallbackCapabilities, + capabilities: declaredCapabilities ?? fallbackCapabilities, }); } return customEntries.length === 0 ? models : [...models, ...customEntries]; @@ -325,6 +326,7 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun readonly launchArgs?: string; readonly cwd: string; readonly customModels?: ReadonlyArray; + readonly customModelCapabilities?: Readonly>; readonly environment?: NodeJS.ProcessEnv; }) { // `~` is not shell-expanded when env vars are set via `child_process.spawn`, @@ -390,7 +392,7 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun return { account: accountResponse, version, - models: appendCustomCodexModels([], input.customModels ?? []), + models: appendCustomCodexModels([], input.customModels ?? [], input.customModelCapabilities), skills: [], } satisfies CodexAppServerProviderSnapshot; } @@ -409,7 +411,7 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun account: accountResponse, version, models: applyPreferredCodexDefaultModel( - appendCustomCodexModels(models, input.customModels ?? []), + appendCustomCodexModels(models, input.customModels ?? [], input.customModelCapabilities), ), skills: parseCodexSkillsListResponse(skillsResponse, input.cwd), } satisfies CodexAppServerProviderSnapshot; @@ -423,12 +425,18 @@ const emptyCodexModelsFromSettings = (codexSettings: CodexSettings): ServerProvi models.add(trimmed); } } - return Array.from(models, (model) => ({ - slug: model, - name: model, - isCustom: true, - capabilities: null, - })); + return Array.from(models, (model) => { + const capabilities = getDeclaredCustomModelCapabilities( + codexSettings.customModelCapabilities, + model, + ); + return { + slug: model, + name: model, + isCustom: true, + capabilities: capabilities ?? null, + }; + }); }; const makePendingCodexProvider = ( @@ -508,6 +516,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu readonly launchArgs?: string; readonly cwd: string; readonly customModels: ReadonlyArray; + readonly customModelCapabilities?: Readonly>; readonly environment?: NodeJS.ProcessEnv; }) => Effect.Effect< CodexAppServerProviderSnapshot, @@ -547,6 +556,9 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu launchArgs: resolveCodexLaunchArgs(codexSettings.launchArgs, resolvedEnvironment), cwd: process.cwd(), customModels: codexSettings.customModels, + ...(codexSettings.customModelCapabilities + ? { customModelCapabilities: codexSettings.customModelCapabilities } + : {}), environment: resolvedEnvironment, }).pipe( Effect.scoped, diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts index 2a2de6839209..0d8a31a3558b 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts @@ -181,6 +181,47 @@ describe("buildTurnStartParams", () => { }); }); + it.effect("keeps exact custom IDs and built-in aliases distinct across model switches", () => + Effect.gen(function* () { + const customModels = ["gpt-5-codex"]; + const custom = yield* buildTurnStartParams({ + threadId: "provider-thread-1", + runtimeMode: "full-access", + prompt: "Implement it", + model: "gpt-5-codex", + customModels, + interactionMode: "default", + }); + const builtIn = yield* buildTurnStartParams({ + threadId: "provider-thread-1", + runtimeMode: "full-access", + prompt: "Implement it", + model: "5.4", + customModels, + interactionMode: "default", + }); + const afterCustom = yield* buildTurnStartParams({ + threadId: "provider-thread-1", + runtimeMode: "full-access", + ...(custom.model ? { model: custom.model } : {}), + customModels, + }); + const afterBuiltIn = yield* buildTurnStartParams({ + threadId: "provider-thread-1", + runtimeMode: "full-access", + ...(builtIn.model ? { model: builtIn.model } : {}), + customModels, + }); + + NodeAssert.equal(custom.model, "gpt-5-codex"); + NodeAssert.equal(custom.collaborationMode?.settings.model, "gpt-5-codex"); + NodeAssert.equal(afterCustom.model, "gpt-5-codex"); + NodeAssert.equal(builtIn.model, "gpt-5.4"); + NodeAssert.equal(builtIn.collaborationMode?.settings.model, "gpt-5.4"); + NodeAssert.equal(afterBuiltIn.model, "gpt-5.4"); + }), + ); + it("reports the same fallback model and effort in settings and instructions", () => { const params = Effect.runSync( buildTurnStartParams({ @@ -545,6 +586,46 @@ describe("isRecoverableThreadResumeError", () => { }); describe("openCodexThread", () => { + it.effect("distinguishes exact custom model IDs from built-in aliases in thread requests", () => + Effect.gen(function* () { + const models: Array = []; + const client = { + request: ( + _method: M, + payload: CodexRpc.ClientRequestParamsByMethod[M], + ) => { + models.push("model" in payload ? (payload.model ?? undefined) : undefined); + return Effect.succeed( + makeThreadOpenResponse("fresh-thread") as CodexRpc.ClientRequestResponsesByMethod[M], + ); + }, + }; + + yield* openCodexThread({ + client, + threadId: ThreadId.make("custom-thread"), + runtimeMode: "full-access", + cwd: "/tmp/project", + requestedModel: "gpt-5-codex", + customModels: ["gpt-5-codex"], + serviceTier: undefined, + resumeThreadId: undefined, + }); + yield* openCodexThread({ + client, + threadId: ThreadId.make("built-in-thread"), + runtimeMode: "full-access", + cwd: "/tmp/project", + requestedModel: "5.4", + customModels: ["gpt-5-codex"], + serviceTier: undefined, + resumeThreadId: undefined, + }); + + NodeAssert.deepStrictEqual(models, ["gpt-5-codex", "gpt-5.4"]); + }), + ); + it.effect("falls back to thread/start when resume fails recoverably", () => Effect.gen(function* () { const calls: Array<{ method: "thread/start" | "thread/resume"; payload: unknown }> = []; diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index 8f887c1b5929..83911143e578 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -17,7 +17,7 @@ import { TurnId, } from "@t3tools/contracts"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; -import { normalizeModelSlug } from "@t3tools/shared/model"; +import { normalizeCustomModelSlug, normalizeModelSlug } from "@t3tools/shared/model"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; @@ -104,6 +104,7 @@ export interface CodexSessionRuntimeOptions { readonly cwd: string; readonly runtimeMode: RuntimeMode; readonly model?: string; + readonly customModels?: ReadonlyArray | undefined; readonly serviceTier?: CodexServiceTier | undefined; readonly resumeCursor?: CodexResumeCursor; readonly appServerArgs?: ReadonlyArray; @@ -244,16 +245,13 @@ function makeCodexServerNotification = [], ): string | undefined { - const normalized = normalizeModelSlug(model); - if (!normalized) { - return undefined; - } - if (preferredId?.endsWith("-codex") && preferredId !== normalized) { - return preferredId; - } - return normalized; + const customSlug = normalizeCustomModelSlug(model); + if (!customSlug) return undefined; + return customModels.some((candidate) => normalizeCustomModelSlug(candidate) === customSlug) + ? customSlug + : (normalizeModelSlug(customSlug) ?? undefined); } function readResumeCursorThreadId( @@ -302,15 +300,17 @@ function buildThreadStartParams(input: { readonly cwd: string; readonly runtimeMode: RuntimeMode; readonly model: string | undefined; + readonly customModels?: ReadonlyArray | undefined; readonly serviceTier: CodexServiceTier | undefined; }): EffectCodexSchema.V2ThreadStartParams { const config = runtimeModeToThreadConfig(input.runtimeMode); + const model = normalizeCodexModelSlug(input.model, input.customModels); return { cwd: input.cwd, approvalPolicy: config.approvalPolicy, sandbox: config.sandbox, approvalsReviewer: config.approvalsReviewer, - ...(input.model ? { model: input.model } : {}), + ...(model ? { model } : {}), ...(input.serviceTier ? { serviceTier: input.serviceTier } : {}), }; } @@ -344,7 +344,7 @@ function buildCodexCollaborationMode(input: { if (input.interactionMode === undefined) { return undefined; } - const model = normalizeCodexModelSlug(input.model) ?? DEFAULT_MODEL; + const model = input.model ?? DEFAULT_MODEL; const reasoningEffort = input.effort ?? "medium"; return { mode: input.interactionMode, @@ -368,6 +368,7 @@ export function buildTurnStartParams(input: { readonly url: string; }>; readonly model?: string; + readonly customModels?: ReadonlyArray | undefined; readonly serviceTier?: CodexServiceTier; readonly effort?: EffectCodexSchema.V2TurnStartParams__ReasoningEffort; readonly interactionMode?: ProviderInteractionMode; @@ -387,9 +388,10 @@ export function buildTurnStartParams(input: { } const config = runtimeModeToThreadConfig(input.runtimeMode); + const model = normalizeCodexModelSlug(input.model, input.customModels); const collaborationMode = buildCodexCollaborationMode({ ...(input.interactionMode ? { interactionMode: input.interactionMode } : {}), - ...(input.model ? { model: input.model } : {}), + ...(model ? { model } : {}), ...(input.effort ? { effort: input.effort } : {}), }); @@ -399,7 +401,7 @@ export function buildTurnStartParams(input: { approvalPolicy: config.approvalPolicy, approvalsReviewer: config.approvalsReviewer, sandboxPolicy: runtimeModeToTurnSandboxPolicy(input.runtimeMode), - ...(input.model ? { model: input.model } : {}), + ...(model ? { model } : {}), ...(input.serviceTier ? { serviceTier: input.serviceTier } : {}), ...(input.effort ? { effort: input.effort } : {}), ...(collaborationMode ? { collaborationMode } : {}), @@ -461,6 +463,7 @@ export const openCodexThread = (input: { readonly runtimeMode: RuntimeMode; readonly cwd: string; readonly requestedModel: string | undefined; + readonly customModels?: ReadonlyArray | undefined; readonly serviceTier: CodexServiceTier | undefined; readonly resumeThreadId: string | undefined; }): Effect.Effect => { @@ -469,6 +472,7 @@ export const openCodexThread = (input: { cwd: input.cwd, runtimeMode: input.runtimeMode, model: input.requestedModel, + customModels: input.customModels, serviceTier: input.serviceTier, }); @@ -1738,14 +1742,13 @@ export const makeCodexSessionRuntime = ( yield* client.request("initialize", buildCodexInitializeParams()); yield* client.notify("initialized", undefined); - const requestedModel = normalizeCodexModelSlug(options.model); - const opened = yield* openCodexThread({ client, threadId: options.threadId, runtimeMode: options.runtimeMode, cwd: options.cwd, - requestedModel, + requestedModel: options.model, + customModels: options.customModels, serviceTier: options.serviceTier, resumeThreadId: readResumeCursorThreadId(options.resumeCursor), }); @@ -1810,19 +1813,19 @@ export const makeCodexSessionRuntime = ( ), ); } - const normalizedModel = normalizeCodexModelSlug( - input.model ?? (yield* Ref.get(sessionRef)).model, - ); + const selectedModel = input.model ?? (yield* Ref.get(sessionRef)).model; const params = yield* buildTurnStartParams({ threadId: providerThreadId, runtimeMode: options.runtimeMode, ...(input.input ? { prompt: input.input } : {}), ...(input.attachments ? { attachments: input.attachments } : {}), - ...(normalizedModel ? { model: normalizedModel } : {}), + ...(selectedModel ? { model: selectedModel } : {}), + customModels: options.customModels, ...(input.serviceTier ? { serviceTier: input.serviceTier } : {}), ...(input.effort ? { effort: input.effort } : {}), ...(input.interactionMode ? { interactionMode: input.interactionMode } : {}), }); + const normalizedModel = params.model; const rawResponse = yield* client.raw.request("turn/start", params); const response = yield* decodeV2TurnStartResponse(rawResponse).pipe( Effect.mapError((error) => diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index fee4306c4c5c..210bf412a1a5 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -58,7 +58,6 @@ const CURSOR_PRESENTATION = { const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ optionDescriptors: [], }); - const CURSOR_ACP_MODEL_DISCOVERY_TIMEOUT_MS = 15_000; const CURSOR_PARAMETERIZED_MODEL_PICKER_MIN_VERSION_DATE = 2026_04_08; const CURSOR_CLI_INSTALLATION_DOCS_URL = "https://cursor.com/docs/cli/installation"; @@ -572,9 +571,11 @@ export const discoverCursorModelsViaAcp = ( ) => discoverCursorModelsViaListAvailableModels(cursorSettings, environment); export function getCursorFallbackModels( - cursorSettings: Pick, + cursorSettings: Pick, ): ReadonlyArray { - return providerModelsFromSettings([], cursorSettings.customModels, EMPTY_CAPABILITIES); + return providerModelsFromSettings([], cursorSettings.customModels, EMPTY_CAPABILITIES, { + customModelCapabilities: cursorSettings.customModelCapabilities, + }); } /** Timeout for `agent about` — it's slower than a simple `--version` probe. */ @@ -638,6 +639,9 @@ export function buildCursorProviderSnapshot(input: { input.discoveredModels ?? [], input.cursorSettings.customModels, EMPTY_CAPABILITIES, + { + customModelCapabilities: input.cursorSettings.customModelCapabilities, + }, ), probe: { installed: true, diff --git a/apps/server/src/provider/Layers/GrokProvider.test.ts b/apps/server/src/provider/Layers/GrokProvider.test.ts index 000243869c9e..2e4631b0c6a5 100644 --- a/apps/server/src/provider/Layers/GrokProvider.test.ts +++ b/apps/server/src/provider/Layers/GrokProvider.test.ts @@ -34,6 +34,40 @@ describe("buildInitialGrokProviderSnapshot", () => { expect(snapshot.requiresNewThreadForModelChange).toBe(true); }), ); + + it.effect("keeps arbitrary custom controls in the Grok catalog", () => + Effect.gen(function* () { + const snapshot = yield* buildInitialGrokProviderSnapshot( + decodeGrokSettings({ + customModels: ["vendor/preview"], + customModelCapabilities: { + "vendor/preview": { + optionDescriptors: [ + { + id: "effort", + label: "Reasoning", + type: "select", + options: [{ id: "high", label: "High" }], + }, + ], + }, + }, + }), + ); + const custom = snapshot.models.find((model) => model.slug === "vendor/preview"); + + expect(custom?.capabilities).toEqual({ + optionDescriptors: [ + { + id: "effort", + label: "Reasoning", + type: "select", + options: [{ id: "high", label: "High" }], + }, + ], + }); + }), + ); }); it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => { diff --git a/apps/server/src/provider/Layers/GrokProvider.ts b/apps/server/src/provider/Layers/GrokProvider.ts index 934eecdb5ae6..c4c014916dd2 100644 --- a/apps/server/src/provider/Layers/GrokProvider.ts +++ b/apps/server/src/provider/Layers/GrokProvider.ts @@ -40,7 +40,6 @@ const GROK_PRESENTATION = { const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ optionDescriptors: [], }); - const VERSION_PROBE_TIMEOUT_MS = 4_000; const GROK_ACP_MODEL_DISCOVERY_TIMEOUT_MS = 15_000; @@ -58,7 +57,7 @@ export function buildInitialGrokProviderSnapshot( ): Effect.Effect { return Effect.gen(function* () { const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); - const models = grokModelsFromSettings(grokSettings.customModels); + const models = grokModelsFromSettings(grokSettings); if (!grokSettings.enabled) { return buildServerProvider({ @@ -93,10 +92,12 @@ export function buildInitialGrokProviderSnapshot( } function grokModelsFromSettings( - customModels: ReadonlyArray | undefined, + grokSettings: Pick, builtInModels: ReadonlyArray = GROK_BUILT_IN_MODELS, ): ReadonlyArray { - return providerModelsFromSettings(builtInModels, customModels ?? [], EMPTY_CAPABILITIES); + return providerModelsFromSettings(builtInModels, grokSettings.customModels, EMPTY_CAPABILITIES, { + customModelCapabilities: grokSettings.customModelCapabilities, + }); } function buildGrokDiscoveredModelsFromSessionModelState( @@ -167,7 +168,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto > { const checkedAt = DateTime.formatIso(yield* DateTime.now); - const fallbackModels = grokModelsFromSettings(grokSettings.customModels); + const fallbackModels = grokModelsFromSettings(grokSettings); if (!grokSettings.enabled) { return buildServerProvider({ @@ -294,7 +295,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func const discoveredModels = discoveryExit.value.value; const models = discoveredModels.length > 0 - ? grokModelsFromSettings(grokSettings.customModels, discoveredModels) + ? grokModelsFromSettings(grokSettings, discoveredModels) : fallbackModels; return buildServerProvider({ diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.ts b/apps/server/src/provider/Layers/OpenCodeProvider.ts index 21014e33f08b..17a4622ca033 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.ts @@ -259,6 +259,9 @@ export const makePendingOpenCodeProvider = ( [], openCodeSettings.customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES, + { + customModelCapabilities: openCodeSettings.customModelCapabilities, + }, ); if (!openCodeSettings.enabled) { @@ -304,6 +307,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu const resolvedEnvironment = environment ?? process.env; const checkedAt = DateTime.formatIso(yield* DateTime.now); const customModels = openCodeSettings.customModels; + const customModelCapabilities = openCodeSettings.customModelCapabilities; const isExternalServer = openCodeSettings.serverUrl.trim().length > 0; const fallback = (cause: unknown, version: string | null = null) => { @@ -316,7 +320,9 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu presentation: OPENCODE_PRESENTATION, enabled: openCodeSettings.enabled, checkedAt, - models: providerModelsFromSettings([], customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES), + models: providerModelsFromSettings([], customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES, { + customModelCapabilities, + }), probe: { installed: failure.installed, version, @@ -332,7 +338,9 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu presentation: OPENCODE_PRESENTATION, enabled: false, checkedAt, - models: providerModelsFromSettings([], customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES), + models: providerModelsFromSettings([], customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES, { + customModelCapabilities, + }), probe: { installed: false, version: null, @@ -378,7 +386,9 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu presentation: OPENCODE_PRESENTATION, enabled: openCodeSettings.enabled, checkedAt, - models: providerModelsFromSettings([], customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES), + models: providerModelsFromSettings([], customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES, { + customModelCapabilities, + }), probe: { installed: true, version, @@ -428,6 +438,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu flattenOpenCodeModels(inventoryExit.value), customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES, + { customModelCapabilities }, ); const connectedCount = inventoryExit.value.providerList.connected.length; return buildServerProvider({ diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index c78ecb3952a3..1d843cc576da 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -1914,7 +1914,13 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te it.effect("hides Claude Opus 5 on older Claude Code versions", () => Effect.gen(function* () { const status = yield* checkClaudeProviderStatus( - defaultClaudeSettings, + { + ...defaultClaudeSettings, + customModels: ["claude-opus-5"], + customModelCapabilities: { + "claude-opus-5": { optionDescriptors: [] }, + }, + }, claudeCapabilities(), ); assert.strictEqual( diff --git a/apps/server/src/provider/providerSnapshot.test.ts b/apps/server/src/provider/providerSnapshot.test.ts index 011572780666..4c616303f326 100644 --- a/apps/server/src/provider/providerSnapshot.test.ts +++ b/apps/server/src/provider/providerSnapshot.test.ts @@ -70,6 +70,58 @@ describe("providerModelsFromSettings", () => { expect(models.map((model) => model.slug)).toEqual(["claude-opus-4-8", "opus"]); expect(models[1]?.isCustom).toBe(true); }); + + it("does not read inherited object keys as custom capabilities", () => { + const fallbackCapabilities = createModelCapabilities({ optionDescriptors: [] }); + const models = providerModelsFromSettings([], ["constructor"], fallbackCapabilities, { + customModelCapabilities: {}, + }); + + expect(models[0]?.capabilities).toBe(fallbackCapabilities); + }); + + it("uses per-model capabilities when a custom model declares them", () => { + const fallbackCapabilities = createModelCapabilities({ optionDescriptors: [] }); + const declaredCapabilities = createModelCapabilities({ + optionDescriptors: [ + { + id: "effort", + label: "Reasoning", + type: "select", + options: [ + { id: "low", label: "Low" }, + { id: "max", label: "Max", isDefault: true }, + ], + }, + ], + }); + + const models = providerModelsFromSettings([], ["gateway/model"], fallbackCapabilities, { + customModelCapabilities: { "gateway/model": declaredCapabilities }, + }); + + expect(models[0]?.capabilities).toEqual(declaredCapabilities); + }); + + it("keeps arbitrary descriptors without a provider allowlist", () => { + const fallbackCapabilities = createModelCapabilities({ optionDescriptors: [] }); + const declaredCapabilities = createModelCapabilities({ + optionDescriptors: [ + { + id: "temperature", + label: "Temperature", + type: "select", + options: [{ id: "warm", label: "Warm" }], + }, + ], + }); + + const models = providerModelsFromSettings([], ["vendor/preview"], fallbackCapabilities, { + customModelCapabilities: { "vendor/preview": declaredCapabilities }, + }); + + expect(models[0]?.capabilities).toEqual(declaredCapabilities); + }); }); describe("ProviderCommandNotFoundError", () => { diff --git a/apps/server/src/provider/providerSnapshot.ts b/apps/server/src/provider/providerSnapshot.ts index 03b4cf4a3b6e..ab0ed34b87aa 100644 --- a/apps/server/src/provider/providerSnapshot.ts +++ b/apps/server/src/provider/providerSnapshot.ts @@ -13,7 +13,10 @@ import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { normalizeCustomModelSlug } from "@t3tools/shared/model"; +import { + getDeclaredCustomModelCapabilities, + normalizeCustomModelSlug, +} from "@t3tools/shared/model"; import { isWindowsCommandNotFound } from "../processRunner.ts"; import { createProviderVersionAdvisory } from "./providerMaintenance.ts"; import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts"; @@ -141,7 +144,10 @@ export function parseGenericCliVersion(output: string): string | null { export function providerModelsFromSettings( builtInModels: ReadonlyArray, customModels: ReadonlyArray, - customModelCapabilities: ModelCapabilities, + fallbackCapabilities: ModelCapabilities, + options: { + readonly customModelCapabilities?: Readonly> | undefined; + } = {}, ): ReadonlyArray { const resolvedBuiltInModels = [...builtInModels]; const seen = new Set(resolvedBuiltInModels.map((model) => model.slug)); @@ -153,11 +159,15 @@ export function providerModelsFromSettings( continue; } seen.add(normalized); + const declaredCapabilities = getDeclaredCustomModelCapabilities( + options.customModelCapabilities, + normalized, + ); customEntries.push({ slug: normalized, name: normalized, isCustom: true, - capabilities: customModelCapabilities, + capabilities: declaredCapabilities ?? fallbackCapabilities, }); } diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index d38a3064910d..2527dc01b715 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -136,9 +136,94 @@ it.layer(NodeServices.layer)("server settings", (it) => { }), ); + it.effect("keeps old custom-model settings valid with empty capability metadata", () => + Effect.gen(function* () { + const decoded = yield* decodeServerSettings({ + providers: { + claudeAgent: { + customModels: ["gateway/model"], + }, + }, + }); + + assert.deepEqual(decoded.providers.claudeAgent, { + enabled: true, + binaryPath: "claude", + homePath: "", + customModels: ["gateway/model"], + launchArgs: "", + }); + }), + ); + + it.effect("decodes custom-model capability metadata for every provider and patches", () => + Effect.gen(function* () { + const customModelCapabilities = { + "gateway/model": { + optionDescriptors: [ + { + id: "contextWindow", + label: "Context Window", + type: "select" as const, + options: [ + { id: "200k", label: "200k" }, + { id: "1m", label: "1M", isDefault: true }, + ], + }, + ], + }, + }; + + const decoded = yield* decodeServerSettings({ + providers: { + codex: { customModels: ["gateway/model"], customModelCapabilities }, + claudeAgent: { customModels: ["gateway/model"], customModelCapabilities }, + cursor: { customModels: ["gateway/model"], customModelCapabilities }, + grok: { customModels: ["gateway/model"], customModelCapabilities }, + opencode: { customModels: ["gateway/model"], customModelCapabilities }, + }, + }); + const patch = yield* decodeSettingsPatch({ + providers: { + codex: { customModelCapabilities }, + claudeAgent: { customModelCapabilities }, + cursor: { customModelCapabilities }, + grok: { customModelCapabilities }, + opencode: { customModelCapabilities }, + }, + }); + + for (const provider of ["codex", "claudeAgent", "cursor", "grok", "opencode"] as const) { + assert.deepEqual( + decoded.providers[provider].customModelCapabilities, + customModelCapabilities, + ); + assert.deepEqual( + patch.providers?.[provider]?.customModelCapabilities, + customModelCapabilities, + ); + } + }), + ); + it.effect("deep merges nested settings updates without dropping siblings", () => Effect.gen(function* () { const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + const customModelCapabilities = { + "claude-custom": { + optionDescriptors: [ + { + id: "contextWindow", + label: "Context Window", + type: "select" as const, + options: [ + { id: "200k", label: "200k" }, + { id: "1m", label: "1M", isDefault: true }, + ], + }, + ], + }, + }; yield* serverSettings.updateSettings({ providers: { @@ -149,6 +234,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { claudeAgent: { binaryPath: "/usr/local/bin/claude", customModels: ["claude-custom"], + customModelCapabilities, }, }, textGenerationModelSelection: { @@ -189,6 +275,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { binaryPath: "/usr/local/bin/claude", homePath: "", customModels: ["claude-custom"], + customModelCapabilities, launchArgs: "", }); assert.deepEqual( diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts index d1bd68cb19d4..aa26787d0aae 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts @@ -290,6 +290,74 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGeneration", (it) => { ), ); + it.effect("forwards custom model controls to Claude CLI", () => + withFakeClaudeEnv( + { + output: JSON.stringify({ + structured_output: { + title: "Use gateway model", + body: "Body", + }, + }), + argsMustContain: '--model gateway/model[1m] --effort xhigh --settings {"fastMode":false}', + claudeConfig: { + customModels: ["gateway/model"], + customModelCapabilities: { + "gateway/model": { + optionDescriptors: [ + { + id: "effort", + label: "Reasoning", + type: "select", + options: [ + { id: "low", label: "Low" }, + { id: "xhigh", label: "Extra High", isDefault: true }, + ], + }, + { + id: "fastMode", + label: "Fast Mode", + type: "boolean", + }, + { + id: "contextWindow", + label: "Context Window", + type: "select", + options: [ + { id: "200k", label: "200k" }, + { id: "1m", label: "1M", isDefault: true }, + ], + }, + ], + }, + }, + }, + }, + (textGeneration) => + Effect.gen(function* () { + const generated = yield* textGeneration.generatePrContent({ + cwd: process.cwd(), + baseBranch: "main", + headBranch: "feature/custom-model", + commitSummary: "Use gateway model", + diffSummary: "1 file changed", + diffPatch: "diff --git a/README.md b/README.md", + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "gateway/model", + [ + { id: "effort", value: "xhigh" }, + { id: "fastMode", value: false }, + { id: "contextWindow", value: "1m" }, + ], + ), + }); + + expect(generated.title).toBe("Use gateway model"); + }), + ), + ); + it.effect("generates thread titles through the Claude provider", () => withFakeClaudeEnv( { diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.ts index aa3e59e2bf21..9fb9725e738f 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.ts @@ -38,6 +38,7 @@ import { } from "@t3tools/shared/model"; import { getClaudeModelCapabilities, + isConfiguredCustomClaudeModel, isClaudeUltracodeEffort, normalizeClaudeCliEffort, resolveClaudeApiModelId, @@ -126,7 +127,8 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu toJsonSchemaObject(outputSchemaJson), "Failed to encode structured output schema.", ); - const caps = getClaudeModelCapabilities(modelSelection.model); + const customModelCapabilities = claudeSettings.customModelCapabilities ?? {}; + const caps = getClaudeModelCapabilities(modelSelection.model, customModelCapabilities); const descriptors = getProviderOptionDescriptors({ caps, selections: modelSelection.options, @@ -134,7 +136,11 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu const findDescriptor = (id: string) => descriptors.find((descriptor) => descriptor.id === id); const rawEffortSelection = getModelSelectionStringOptionValue(modelSelection, "effort"); const resolvedEffort = resolveClaudeEffort(caps, rawEffortSelection); - const cliEffort = normalizeClaudeCliEffort(resolvedEffort, modelSelection.model); + const cliEffort = normalizeClaudeCliEffort( + resolvedEffort, + modelSelection.model, + isConfiguredCustomClaudeModel(modelSelection.model, customModelCapabilities), + ); const ultracode = isClaudeUltracodeEffort(resolvedEffort); const thinkingDescriptor = findDescriptor("thinking"); const fastModeDescriptor = findDescriptor("fastMode"); @@ -144,7 +150,7 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu fastModeDescriptor?.type === "boolean" ? fastModeDescriptor.currentValue : undefined; const settings = { ...(typeof thinking === "boolean" ? { alwaysThinkingEnabled: thinking } : {}), - ...(fastMode ? { fastMode: true } : {}), + ...(typeof fastMode === "boolean" ? { fastMode } : {}), ...(ultracode ? { ultracode: true } : {}), }; const settingsJson = @@ -166,7 +172,7 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu "--json-schema", jsonSchemaStr, "--model", - resolveClaudeApiModelId(modelSelection), + resolveClaudeApiModelId(modelSelection, customModelCapabilities), ...(cliEffort ? ["--effort", cliEffort] : []), ...(settingsJson ? ["--settings", settingsJson] : []), "--dangerously-skip-permissions", diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index 924fbddeab70..7b218ee8ef9c 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -190,7 +190,7 @@ function getTraitsSectionVisibility(input: { showFastMode, showContextWindow, showAgent, - hasAnyControls: showEffort || showThinking || showFastMode || showContextWindow || showAgent, + hasAnyControls: selected.selectDescriptors.length > 0 || selected.booleanDescriptors.length > 0, }; } diff --git a/apps/web/src/components/chat/composerProviderState.test.tsx b/apps/web/src/components/chat/composerProviderState.test.tsx index 067e71ef1bfc..44ee8e227a86 100644 --- a/apps/web/src/components/chat/composerProviderState.test.tsx +++ b/apps/web/src/components/chat/composerProviderState.test.tsx @@ -11,6 +11,7 @@ import { renderProviderTraitsMenuContent, renderProviderTraitsPicker, } from "./composerProviderState"; +import { DraftId } from "../../composerDraftStore"; // Everything in composerProviderState is now data-driven by the model's // optionDescriptors, so these tests use a single synthetic provider/model and @@ -141,6 +142,70 @@ describe("getComposerProviderState", () => { }); }); + it("shows only controls declared by a custom model", () => { + const models: ReadonlyArray = [ + { + slug: MODEL, + name: MODEL, + isCustom: true, + capabilities: { + optionDescriptors: [ + selectDescriptor("contextWindow", [ + { id: "200k", label: "200k" }, + { id: "1m", label: "1M", isDefault: true }, + ]), + ], + }, + }, + ]; + + const state = getComposerProviderState({ + provider: PROVIDER, + model: MODEL, + models, + modelOptions: selections(["effort", "max"], ["fastMode", true], ["contextWindow", "200k"]), + }); + + expect(state.modelOptionsForDispatch).toEqual(selections(["contextWindow", "200k"])); + }); + it("prefers an exact custom model ID over a built-in alias", () => { + const claude = ProviderDriverKind.make("claudeAgent"); + const models: ReadonlyArray = [ + { + slug: "claude-sonnet-5", + name: "Claude Sonnet 5", + isCustom: false, + capabilities: { + optionDescriptors: [ + selectDescriptor("effort", [{ id: "high", label: "High", isDefault: true }]), + ], + }, + }, + { + slug: "sonnet", + name: "sonnet", + isCustom: true, + capabilities: { + optionDescriptors: [ + selectDescriptor("contextWindow", [ + { id: "200k", label: "200K" }, + { id: "1m", label: "1M", isDefault: true }, + ]), + ], + }, + }, + ]; + + const state = getComposerProviderState({ + provider: claude, + model: "sonnet", + models, + modelOptions: selections(["effort", "high"], ["contextWindow", "1m"]), + }); + + expect(state.modelOptionsForDispatch).toEqual(selections(["contextWindow", "1m"])); + }); + it("derives promptEffort from the first select descriptor and preserves all others for dispatch", () => { const state = getComposerProviderState({ provider: PROVIDER, @@ -229,6 +294,21 @@ describe("getComposerProviderState", () => { }); describe("provider traits render guards", () => { + it("renders an arbitrary on/off descriptor for a custom model", () => { + const args = { + provider: PROVIDER, + model: MODEL, + models: modelWith([booleanDescriptor("option")]), + modelOptions: undefined, + prompt: "", + onPromptChange: () => {}, + draftId: DraftId.make("draft-custom-boolean"), + }; + + expect(renderProviderTraitsPicker(args)).not.toBeNull(); + expect(renderProviderTraitsMenuContent(args)).not.toBeNull(); + }); + it("returns null when no thread target is provided", () => { const models = modelWith([ selectDescriptor("effort", [{ id: "high", label: "High", isDefault: true }]), diff --git a/apps/web/src/components/settings/ProviderInstanceCard.test.ts b/apps/web/src/components/settings/ProviderInstanceCard.test.ts index 051045b030c3..3c62b6ba7aa2 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.test.ts +++ b/apps/web/src/components/settings/ProviderInstanceCard.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it } from "vite-plus/test"; -import type { ServerProviderModel } from "@t3tools/contracts"; +import type { ModelCapabilities, ServerProviderModel } from "@t3tools/contracts"; -import { deriveProviderModelsForDisplay } from "./ProviderInstanceCard"; +import { + deriveProviderModelsForDisplay, + reconcileCustomModelCapabilities, +} from "./ProviderInstanceCard"; describe("deriveProviderModelsForDisplay", () => { it("uses current config custom models instead of stale live custom rows", () => { @@ -29,8 +32,97 @@ describe("deriveProviderModelsForDisplay", () => { expect( deriveProviderModelsForDisplay({ liveModels, - customModels: ["kept-custom"], + customModels: ["server-model", "kept-custom"], }).map((model) => model.slug), ).toEqual(["server-model", "kept-custom"]); }); + + it("uses current config capabilities while the live custom row is stale", () => { + const capabilities: ModelCapabilities = { + optionDescriptors: [ + { + id: "effort", + label: "Reasoning", + type: "select", + options: [{ id: "high", label: "High", isDefault: true }], + }, + ], + }; + const input = { + liveModels: [ + { + slug: "gateway/model", + name: "gateway/model", + isCustom: true, + capabilities: null, + }, + ], + customModels: ["gateway/model"], + customModelCapabilities: { "gateway/model": capabilities }, + } satisfies Parameters[0] & { + readonly customModelCapabilities: Readonly>; + }; + + expect(deriveProviderModelsForDisplay(input)[0]?.capabilities).toEqual(capabilities); + }); + + it("preserves arbitrary capabilities in optimistic custom rows", () => { + const capabilities: ModelCapabilities = { + optionDescriptors: [ + { + id: "temperature", + label: "Temperature", + type: "select", + options: [{ id: "warm", label: "Warm" }], + }, + ], + }; + const [model] = deriveProviderModelsForDisplay({ + liveModels: [], + customModels: ["vendor/preview"], + customModelCapabilities: { "vendor/preview": capabilities }, + }); + + expect(model?.capabilities).toEqual(capabilities); + }); + + it("keeps legacy metadata absent and seeds newly added models with no controls", () => { + const configured: ModelCapabilities = { + optionDescriptors: [{ id: "fastMode", label: "Fast Mode", type: "boolean" }], + }; + + expect( + reconcileCustomModelCapabilities({ + capabilities: { configured, removed: { optionDescriptors: [] } }, + currentModels: ["legacy", "configured", "removed"], + nextModels: ["legacy", "configured", "new"], + }), + ).toEqual({ + configured, + new: { optionDescriptors: [] }, + }); + }); + + it("stores reserved model IDs as own capability keys", () => { + const capabilities = reconcileCustomModelCapabilities({ + capabilities: {}, + currentModels: [], + nextModels: ["__proto__", "constructor"], + }); + + expect(Object.hasOwn(capabilities, "__proto__")).toBe(true); + expect(capabilities["__proto__"]).toEqual({ optionDescriptors: [] }); + expect(Object.hasOwn(capabilities, "constructor")).toBe(true); + expect(capabilities.constructor).toEqual({ optionDescriptors: [] }); + }); + + it("does not read inherited properties as configured capabilities", () => { + const [model] = deriveProviderModelsForDisplay({ + liveModels: [], + customModels: ["constructor"], + customModelCapabilities: {}, + }); + + expect(model?.capabilities).toBeNull(); + }); }); diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index 11e108e7ca7c..360fe0da1723 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -12,9 +12,12 @@ import { } from "lucide-react"; import * as Arr from "effect/Array"; import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; import { useState, type ReactNode } from "react"; import { + CustomModelCapabilities, isProviderDriverKind, + type ModelCapabilities, type ProviderInstanceConfig, type ProviderInstanceEnvironmentVariable, type ProviderInstanceId, @@ -22,6 +25,7 @@ import { type ServerProvider, type ServerProviderModel, } from "@t3tools/contracts"; +import { getDeclaredCustomModelCapabilities } from "@t3tools/shared/model"; import { cn } from "../../lib/utils"; import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; @@ -53,6 +57,8 @@ import { const ENVIRONMENT_VARIABLE_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/; +const isCustomModelCapabilities = Schema.is(CustomModelCapabilities); + let environmentVariableDraftId = 0; const nextEnvironmentVariableDraftId = () => `provider-env-${environmentVariableDraftId++}`; @@ -90,6 +96,12 @@ function readConfigStringArray(config: unknown, key: string): ReadonlyArray typeof entry === "string"); } +function readCustomModelCapabilities(config: unknown): Readonly> { + if (config === null || typeof config !== "object") return {}; + const value = (config as Record).customModelCapabilities; + return isCustomModelCapabilities(value) ? value : {}; +} + /** * Set `key` to an arbitrary value on the opaque config blob. Unlike * provider settings field updates, does not drop empty-looking values — the @@ -109,9 +121,30 @@ function nextConfigBlobWithValue( return base; } +export function reconcileCustomModelCapabilities(input: { + readonly capabilities: Readonly>; + readonly currentModels: ReadonlyArray; + readonly nextModels: ReadonlyArray; +}): Readonly> { + const currentModels = new Set(input.currentModels); + const entries: Array = []; + for (const slug of input.nextModels) { + const capabilities = Object.hasOwn(input.capabilities, slug) + ? input.capabilities[slug] + : undefined; + if (capabilities) { + entries.push([slug, capabilities]); + } else if (!currentModels.has(slug)) { + entries.push([slug, { optionDescriptors: [] }]); + } + } + return Object.fromEntries(entries); +} + export function deriveProviderModelsForDisplay(input: { readonly liveModels: ReadonlyArray | undefined; readonly customModels: ReadonlyArray; + readonly customModelCapabilities?: Readonly>; }): ReadonlyArray { const liveCustomModelsBySlug = new Map( Arr.filterMap(input.liveModels ?? [], (model) => @@ -119,15 +152,26 @@ export function deriveProviderModelsForDisplay(input: { ), ); const serverModels = input.liveModels?.filter((model) => !model.isCustom) ?? []; - const customModels = input.customModels.map( - (slug) => - liveCustomModelsBySlug.get(slug) ?? { + const serverModelSlugs = new Set(serverModels.map((model) => model.slug)); + const customModels = input.customModels + .filter((slug) => !serverModelSlugs.has(slug)) + .map((slug) => { + const liveModel = liveCustomModelsBySlug.get(slug); + const configuredCapabilities = getDeclaredCustomModelCapabilities( + input.customModelCapabilities, + slug, + ); + const capabilities = configuredCapabilities; + if (liveModel) { + return capabilities ? { ...liveModel, capabilities } : liveModel; + } + return { slug, name: slug, isCustom: true, - capabilities: null, - }, - ); + capabilities: capabilities ?? null, + }; + }); return [...serverModels, ...customModels]; } @@ -323,6 +367,7 @@ interface ProviderInstanceCardProps { readonly instance: ProviderInstanceConfig; readonly driverOption: DriverOption | undefined; readonly liveProvider: ServerProvider | undefined; + readonly sourceProviders: ReadonlyArray; readonly isExpanded: boolean; readonly onExpandedChange: (open: boolean) => void; readonly onUpdate: (nextInstance: ProviderInstanceConfig) => void; @@ -380,6 +425,7 @@ export function ProviderInstanceCard({ instance, driverOption, liveProvider, + sourceProviders, isExpanded, onExpandedChange, onUpdate, @@ -444,12 +490,14 @@ export function ProviderInstanceCard({ : null; const customModels = readConfigStringArray(instance.config, "customModels"); + const customModelCapabilities = readCustomModelCapabilities(instance.config); // Server-returned models may lag behind settings writes. Treat probe // models as the source for built-ins only; custom rows come directly // from the current instance config so add/remove reflects immediately. const modelsForDisplay = deriveProviderModelsForDisplay({ liveModels: liveProvider?.models, customModels, + customModelCapabilities, }); const updateDisplayName = (value: string) => { @@ -487,6 +535,34 @@ export function ProviderInstanceCard({ const updateCustomModels = (next: ReadonlyArray) => { const nextConfig = nextConfigBlobWithValue(instance.config, "customModels", [...next]); + const remainingCapabilities = reconcileCustomModelCapabilities({ + capabilities: customModelCapabilities, + currentModels: customModels, + nextModels: next, + }); + if (Object.keys(remainingCapabilities).length > 0) { + nextConfig.customModelCapabilities = remainingCapabilities; + } else { + delete nextConfig.customModelCapabilities; + } + const { config: _omit, ...rest } = instance; + onUpdate({ ...rest, config: nextConfig } as ProviderInstanceConfig); + }; + + const updateCustomModelCapabilities = ( + slug: string, + capabilities: ModelCapabilities | undefined, + ) => { + const nextCapabilities = Object.fromEntries([ + ...Object.entries(customModelCapabilities).filter(([key]) => key !== slug), + ...(capabilities ? ([[slug, capabilities]] as const) : []), + ]); + const nextConfig = nextConfigBlobWithValue(instance.config, "customModels", [...customModels]); + if (Object.keys(nextCapabilities).length > 0) { + nextConfig.customModelCapabilities = nextCapabilities; + } else { + delete nextConfig.customModelCapabilities; + } const { config: _omit, ...rest } = instance; onUpdate({ ...rest, config: nextConfig } as ProviderInstanceConfig); }; @@ -778,7 +854,10 @@ export function ProviderInstanceCard({ instanceId={instanceId} driverKind={driverKind} models={modelsForDisplay} + sourceProviders={sourceProviders} customModels={customModels} + customModelCapabilities={customModelCapabilities} + onCustomModelCapabilitiesChange={updateCustomModelCapabilities} hiddenModels={hiddenModels} favoriteModels={favoriteModels} modelOrder={modelOrder} diff --git a/apps/web/src/components/settings/ProviderModelsSection.test.ts b/apps/web/src/components/settings/ProviderModelsSection.test.ts new file mode 100644 index 000000000000..60dc99e25ecb --- /dev/null +++ b/apps/web/src/components/settings/ProviderModelsSection.test.ts @@ -0,0 +1,644 @@ +import { describe, expect, it } from "vite-plus/test"; +import { + ProviderInstanceId, + ProviderDriverKind, + type ModelCapabilities, + type SelectProviderOptionDescriptor, + type ServerProviderModel, +} from "@t3tools/contracts"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { + CustomModelCapabilitiesEditor, + CustomModelCapabilityCopyPicker, + ProviderModelsSection, + addSelectCustomModelCapabilityValue, + applySelectCustomModelCapabilityUpdate, + collectCustomModelCapabilityCopySources, + copyCustomModelCapabilityDescriptors, + createCustomModelCapabilityDescriptor, + filterCustomModelCapabilityCopySources, + getConfiguredCustomModelOptionDescriptors, + getSelectedCustomModelCapabilityCopyDescriptors, + isSelectCustomModelCapabilityValueCommitKey, + makeSelectCustomModelCapabilityDescriptor, + replaceCustomModelCapabilityDescriptor, + setSelectCustomModelCapabilityDefault, +} from "./ProviderModelsSection"; +describe("custom model capability configuration", () => { + it("creates free-form select and boolean descriptors without provider templates", () => { + const select = createCustomModelCapabilityDescriptor([], "select"); + const boolean = createCustomModelCapabilityDescriptor([select], "boolean"); + + expect(select).toEqual({ + id: "option", + label: "Option", + type: "select", + options: [{ id: "default", label: "Default", isDefault: true }], + currentValue: "default", + }); + expect(boolean).toEqual({ + id: "option2", + label: "Option 2", + type: "boolean", + currentValue: false, + }); + }); + + it("offers free-form descriptor types when provider reports no templates", () => { + const model: ServerProviderModel = { + slug: "vendor/model", + name: "vendor/model", + isCustom: true, + capabilities: { optionDescriptors: [] }, + }; + const markup = renderToStaticMarkup( + createElement(CustomModelCapabilitiesEditor, { + model, + value: { optionDescriptors: [] }, + onChange: () => undefined, + copySources: [], + onSave: () => undefined, + }), + ); + + expect(markup).toContain('aria-label="Add select control for vendor/model"'); + expect(markup).toContain('aria-label="Add boolean control for vendor/model"'); + expect(markup).not.toContain("Provider has not reported configurable model controls"); + }); + + it("renders exact select values as tags so commas stay inside IDs", () => { + const model: ServerProviderModel = { + slug: "vendor/model", + name: "vendor/model", + isCustom: true, + capabilities: { optionDescriptors: [] }, + }; + const markup = renderToStaticMarkup( + createElement(CustomModelCapabilitiesEditor, { + model, + value: { + optionDescriptors: [ + { + id: "quality", + label: "Quality", + type: "select", + options: [{ id: "quality,high", label: "Quality high", isDefault: true }], + currentValue: "quality,high", + }, + ], + }, + onChange: () => undefined, + copySources: [], + onSave: () => undefined, + }), + ); + + expect(markup).toContain('aria-label="Set quality,high as default for Quality"'); + expect(markup).toContain('aria-pressed="true"'); + expect(markup).toContain("quality,high"); + expect(markup).toContain('placeholder="Add value…"'); + expect(markup).toContain("focus-within:ring-[3px]"); + }); + + it("adds one trimmed select value without changing existing labels", () => { + const descriptor = { + id: "quality", + label: "Quality", + type: "select" as const, + options: [{ id: "low", label: "Low quality", isDefault: true }], + currentValue: "low", + }; + + expect(addSelectCustomModelCapabilityValue(descriptor, " quality,high ")).toEqual({ + ...descriptor, + options: [ + { id: "low", label: "Low quality", isDefault: true }, + { id: "quality,high", label: "Quality,high" }, + ], + }); + expect(addSelectCustomModelCapabilityValue(descriptor, " low ")).toBe(descriptor); + }); + + it("makes a clicked select value the only default", () => { + const descriptor = { + id: "effort", + label: "Reasoning", + type: "select" as const, + options: [ + { id: "low", label: "Low", isDefault: true }, + { id: "high", label: "High" }, + ], + currentValue: "low", + }; + + expect(setSelectCustomModelCapabilityDefault(descriptor, "high")).toEqual({ + ...descriptor, + options: [ + { id: "low", label: "Low" }, + { id: "high", label: "High", isDefault: true }, + ], + currentValue: "high", + }); + }); + + it("keeps a blurred value when the next tag action sets the default", () => { + const descriptorRef: { current: SelectProviderOptionDescriptor } = { + current: { + id: "effort", + label: "Reasoning", + type: "select" as const, + options: [ + { id: "low", label: "Low", isDefault: true }, + { id: "high", label: "High" }, + ], + currentValue: "low", + }, + }; + let savedDescriptor: SelectProviderOptionDescriptor = descriptorRef.current; + const saveDescriptor = (descriptor: SelectProviderOptionDescriptor) => { + savedDescriptor = descriptor; + }; + + applySelectCustomModelCapabilityUpdate( + descriptorRef, + (descriptor) => addSelectCustomModelCapabilityValue(descriptor, "max"), + saveDescriptor, + ); + applySelectCustomModelCapabilityUpdate( + descriptorRef, + (descriptor) => setSelectCustomModelCapabilityDefault(descriptor, "high"), + saveDescriptor, + ); + + expect(savedDescriptor.options).toEqual([ + { id: "low", label: "Low" }, + { id: "high", label: "High", isDefault: true }, + { id: "max", label: "Max" }, + ]); + expect(savedDescriptor.currentValue).toBe("high"); + }); + + it("commits select values with space, comma, or Enter", () => { + expect([" ", ",", "Enter"].map(isSelectCustomModelCapabilityValueCommitKey)).toEqual([ + true, + true, + true, + ]); + expect(isSelectCustomModelCapabilityValueCommitKey("Tab")).toBe(false); + }); + + it("shows a Save action and capitalized boolean heading", () => { + const model: ServerProviderModel = { + slug: "vendor/model", + name: "vendor/model", + isCustom: true, + capabilities: { optionDescriptors: [] }, + }; + const markup = renderToStaticMarkup( + createElement(CustomModelCapabilitiesEditor, { + model, + value: { + optionDescriptors: [ + { id: "fastMode", label: "Fast Mode", type: "boolean", currentValue: false }, + ], + }, + onChange: () => undefined, + copySources: [], + onSave: () => undefined, + }), + ); + + expect(markup).toContain("ON / OFF"); + expect(markup).toContain(">Save"); + expect(markup).toContain(">Copy controls"); + }); + + it("collects declared controls from multiple harnesses", () => { + expect( + collectCustomModelCapabilityCopySources([ + { + instanceId: ProviderInstanceId.make("codex_default"), + driver: ProviderDriverKind.make("codex"), + displayName: "Work Codex", + models: [ + { + slug: "gpt-5", + name: "GPT 5", + isCustom: false, + capabilities: { + optionDescriptors: [ + { + id: "fastMode", + label: "Fast Mode", + type: "boolean", + currentValue: false, + }, + ], + }, + }, + { + slug: "plain-model", + name: "Plain model", + isCustom: false, + capabilities: null, + }, + ], + }, + { + instanceId: ProviderInstanceId.make("claude_default"), + driver: ProviderDriverKind.make("claudeAgent"), + models: [ + { + slug: "opus", + name: "Opus", + isCustom: false, + capabilities: { + optionDescriptors: [ + { + id: "fastMode", + label: "Fast Mode", + type: "boolean", + currentValue: false, + }, + ], + }, + }, + ], + }, + ]), + ).toEqual([ + { + providerInstanceId: ProviderInstanceId.make("codex_default"), + providerLabel: "Work Codex", + modelSlug: "gpt-5", + modelName: "GPT 5", + optionDescriptors: [ + { + id: "fastMode", + label: "Fast Mode", + type: "boolean", + currentValue: false, + }, + ], + }, + { + providerInstanceId: ProviderInstanceId.make("claude_default"), + providerLabel: "claudeAgent", + modelSlug: "opus", + modelName: "Opus", + optionDescriptors: [ + { + id: "fastMode", + label: "Fast Mode", + type: "boolean", + currentValue: false, + }, + ], + }, + ]); + }); + + it("disambiguates harness instances that share a display name", () => { + const providers = ["codex_one", "codex_two"].map((instanceId) => ({ + instanceId: ProviderInstanceId.make(instanceId), + driver: ProviderDriverKind.make("codex"), + displayName: "Codex", + models: [ + { + slug: `${instanceId}/model`, + name: `${instanceId} model`, + isCustom: false, + capabilities: { + optionDescriptors: [{ id: "effort", label: "Reasoning", type: "boolean" as const }], + }, + }, + ], + })); + + expect( + collectCustomModelCapabilityCopySources(providers).map((source) => source.providerLabel), + ).toEqual(["Codex (codex_one)", "Codex (codex_two)"]); + }); + + it("clears checked controls when a refreshed catalog changes the source model", () => { + const source = { + providerInstanceId: ProviderInstanceId.make("codex_default"), + providerLabel: "Codex", + modelSlug: "new-model", + modelName: "New model", + optionDescriptors: [{ id: "effort", label: "New effort", type: "boolean" as const }], + }; + + expect( + getSelectedCustomModelCapabilityCopyDescriptors(source, { + providerInstanceId: ProviderInstanceId.make("codex_default"), + modelSlug: "removed-model", + descriptorIds: new Set(["effort"]), + }), + ).toEqual([]); + }); + + it("excludes only the custom model currently being edited from copy sources", () => { + const sources = [ + { + providerInstanceId: ProviderInstanceId.make("codex_default"), + providerLabel: "Codex", + modelSlug: "target", + modelName: "Target", + optionDescriptors: [{ id: "effort", label: "Reasoning", type: "boolean" as const }], + }, + { + providerInstanceId: ProviderInstanceId.make("codex_default"), + providerLabel: "Codex", + modelSlug: "other", + modelName: "Other", + optionDescriptors: [{ id: "effort", label: "Reasoning", type: "boolean" as const }], + }, + { + providerInstanceId: ProviderInstanceId.make("claude_default"), + providerLabel: "Claude", + modelSlug: "target", + modelName: "Target", + optionDescriptors: [{ id: "effort", label: "Reasoning", type: "boolean" as const }], + }, + ]; + + expect( + filterCustomModelCapabilityCopySources( + sources, + ProviderInstanceId.make("codex_default"), + "target", + ).map((source) => `${source.providerInstanceId}:${source.modelSlug}`), + ).toEqual(["codex_default:other", "claude_default:target"]); + }); + + it("shows harness, model, and multiple controls in the copy picker", () => { + const markup = renderToStaticMarkup( + createElement(CustomModelCapabilityCopyPicker, { + sources: [ + { + providerInstanceId: ProviderInstanceId.make("codex_default"), + providerLabel: "Work Codex", + modelSlug: "gpt-5", + modelName: "GPT 5", + optionDescriptors: [ + { + id: "reasoningEffort", + label: "Reasoning", + type: "select", + options: [{ id: "high", label: "High", isDefault: true }], + currentValue: "high", + }, + { + id: "serviceTier", + label: "Fast service", + type: "boolean", + currentValue: false, + }, + ], + }, + ], + onCopy: () => undefined, + onCancel: () => undefined, + }), + ); + + expect(markup).toContain('aria-label="Harness to copy controls from"'); + expect(markup).toContain("Work Codex"); + expect(markup).toContain('aria-label="Model to copy controls from"'); + expect(markup).toContain("GPT 5"); + expect(markup).toContain('aria-label="Copy Reasoning (reasoningEffort)"'); + expect(markup).toContain('aria-label="Copy Fast service (serviceTier)"'); + expect(markup).toContain("Copies exact IDs and values"); + expect(markup).toContain(">Copy selected"); + }); + + it("replaces copied controls in place and appends new controls", () => { + expect( + copyCustomModelCapabilityDescriptors( + [ + { + id: "effort", + label: "Old effort", + type: "select", + options: [{ id: "medium", label: "Medium" }], + }, + { id: "thinking", label: "Thinking", type: "boolean", currentValue: false }, + ], + [ + { + id: "effort", + label: "Reasoning", + type: "select", + options: [ + { id: "low", label: "Low" }, + { id: "high", label: "High", isDefault: true }, + ], + currentValue: "high", + }, + { + id: "contextWindow", + label: "Context Window", + type: "select", + options: [{ id: "1m", label: "1M", isDefault: true }], + currentValue: "1m", + }, + ], + ), + ).toEqual([ + { + id: "effort", + label: "Reasoning", + type: "select", + options: [ + { id: "low", label: "Low" }, + { id: "high", label: "High", isDefault: true }, + ], + currentValue: "high", + }, + { id: "thinking", label: "Thinking", type: "boolean", currentValue: false }, + { + id: "contextWindow", + label: "Context Window", + type: "select", + options: [{ id: "1m", label: "1M", isDefault: true }], + currentValue: "1m", + }, + ]); + }); + + it("shows model details for arbitrary descriptors", () => { + const model: ServerProviderModel = { + slug: "vendor/model", + name: "vendor/model", + isCustom: true, + capabilities: { + optionDescriptors: [ + { + id: "temperature", + label: "Temperature", + type: "select", + options: [{ id: "balanced", label: "Balanced" }], + }, + ], + }, + }; + const markup = renderToStaticMarkup( + createElement(ProviderModelsSection, { + instanceId: ProviderInstanceId.make("test-provider"), + driverKind: null, + models: [model], + customModels: [model.slug], + sourceProviders: [], + customModelCapabilities: {}, + onCustomModelCapabilitiesChange: () => undefined, + hiddenModels: [], + favoriteModels: [], + modelOrder: [], + onChange: () => undefined, + onHiddenModelsChange: () => undefined, + onFavoriteModelsChange: () => undefined, + onModelOrderChange: () => undefined, + }), + ); + + expect(markup).toContain('aria-label="Details for vendor/model"'); + }); + + it("builds declared supported values with one explicit default", () => { + const template = { + id: "effort", + label: "Reasoning", + description: "Controls reasoning effort.", + type: "select" as const, + options: [ + { id: "low", label: "Low" }, + { id: "high", label: "High", isDefault: true }, + { id: "ultra", label: "Ultra" }, + ], + }; + + expect(makeSelectCustomModelCapabilityDescriptor(template, ["low", "ultra"], "ultra")).toEqual({ + id: "effort", + label: "Reasoning", + description: "Controls reasoning effort.", + type: "select", + options: [ + { id: "low", label: "Low" }, + { id: "ultra", label: "Ultra", isDefault: true }, + ], + currentValue: "ultra", + }); + }); + + it("uses the declared default when currentValue is absent", () => { + const template = { + id: "effort", + label: "Reasoning", + type: "select" as const, + options: [ + { id: "low", label: "Low" }, + { id: "high", label: "High", isDefault: true }, + ], + }; + + expect(makeSelectCustomModelCapabilityDescriptor(template, ["low", "high"], undefined)).toEqual( + { + id: "effort", + label: "Reasoning", + type: "select", + options: [ + { id: "low", label: "Low" }, + { id: "high", label: "High", isDefault: true }, + ], + currentValue: "high", + }, + ); + }); + + it("accepts arbitrary values for every select descriptor", () => { + const template = { + id: "contextWindow", + label: "Context Window", + type: "select" as const, + options: [ + { id: "200k", label: "200K", isDefault: true }, + { id: "1m", label: "1M" }, + ], + }; + + expect( + makeSelectCustomModelCapabilityDescriptor( + template, + ["200k", "unsupported", "1m"], + "unsupported", + ), + ).toEqual({ + id: "contextWindow", + label: "Context Window", + type: "select", + options: [ + { id: "200k", label: "200K" }, + { id: "unsupported", label: "Unsupported", isDefault: true }, + { id: "1m", label: "1M" }, + ], + currentValue: "unsupported", + }); + expect(makeSelectCustomModelCapabilityDescriptor(template, ["unsupported"], undefined)).toEqual( + { + id: "contextWindow", + label: "Context Window", + type: "select", + options: [{ id: "unsupported", label: "Unsupported", isDefault: true }], + currentValue: "unsupported", + }, + ); + expect(makeSelectCustomModelCapabilityDescriptor(template, [], undefined)).toBeUndefined(); + }); + + it("preserves an explicit empty capability set when the last control is disabled", () => { + expect( + replaceCustomModelCapabilityDescriptor( + [{ id: "fastMode", label: "Fast Mode", type: "boolean" }], + undefined, + "fastMode", + ), + ).toEqual({ optionDescriptors: [] }); + }); + + it("replaces a control without changing its position", () => { + const effort = { + id: "effort", + label: "Effort", + type: "select" as const, + options: [{ id: "high", label: "High", isDefault: true }], + }; + const fastMode = { id: "fastMode", label: "Fast Mode", type: "boolean" as const }; + + expect( + replaceCustomModelCapabilityDescriptor( + [effort, fastMode], + { ...effort, label: "Reasoning effort" }, + effort.id, + ), + ).toEqual({ + optionDescriptors: [{ ...effort, label: "Reasoning effort" }, fastMode], + }); + }); + + it("uses legacy fallback controls only when explicit metadata is absent", () => { + const inherited: ModelCapabilities = { + optionDescriptors: [{ id: "fastMode", label: "Fast Mode", type: "boolean" }], + }; + + expect(getConfiguredCustomModelOptionDescriptors(undefined, inherited)).toEqual( + inherited.optionDescriptors, + ); + expect(getConfiguredCustomModelOptionDescriptors({}, inherited)).toEqual([]); + expect(getConfiguredCustomModelOptionDescriptors({ optionDescriptors: [] }, inherited)).toEqual( + [], + ); + }); +}); diff --git a/apps/web/src/components/settings/ProviderModelsSection.tsx b/apps/web/src/components/settings/ProviderModelsSection.tsx index 9a42961d13ee..ac9a261fe266 100644 --- a/apps/web/src/components/settings/ProviderModelsSection.tsx +++ b/apps/web/src/components/settings/ProviderModelsSection.tsx @@ -3,26 +3,41 @@ import { ArrowDownIcon, ArrowUpIcon, + CopyIcon, EyeIcon, EyeOffIcon, InfoIcon, PlusIcon, + Settings2Icon, StarIcon, XIcon, } from "lucide-react"; import { useMemo, useRef, useState } from "react"; import { ProviderDriverKind, + type ModelCapabilities, type ProviderInstanceId, + type ProviderOptionDescriptor, + type SelectProviderOptionDescriptor, + type ServerProvider, type ServerProviderModel, } from "@t3tools/contracts"; -import { normalizeCustomModelSlug } from "@t3tools/shared/model"; +import { + getDeclaredCustomModelCapabilities, + normalizeCustomModelSlug, +} from "@t3tools/shared/model"; import { cn } from "../../lib/utils"; import { sortModelsForProviderInstance } from "../../modelOrdering"; import { MAX_CUSTOM_MODEL_LENGTH } from "../../modelSelection"; +import { Checkbox } from "../ui/checkbox"; +import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; +import { DraftInput } from "../ui/draft-input"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; import { Input } from "../ui/input"; +import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; +import { Switch } from "../ui/switch"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; /** @@ -37,6 +52,741 @@ const CUSTOM_MODEL_PLACEHOLDER_BY_KIND: Partial 0 ? words[0]!.toUpperCase() + words.slice(1) : id; +} + +function resolveSelectCustomModelCapabilityDefault( + descriptor: SelectProviderOptionDescriptor, + supportedValues: ReadonlyArray, + requestedDefault: string | undefined, +): string | undefined { + return ( + (requestedDefault && supportedValues.includes(requestedDefault) + ? requestedDefault + : undefined) ?? + descriptor.options.find((option) => option.isDefault && supportedValues.includes(option.id)) + ?.id ?? + supportedValues[0] + ); +} + +export function createCustomModelCapabilityDescriptor( + configured: ReadonlyArray, + type: ProviderOptionDescriptor["type"], +): ProviderOptionDescriptor { + const ids = new Set(configured.map((descriptor) => descriptor.id)); + let suffix = 1; + while (ids.has(suffix === 1 ? "option" : `option${suffix}`)) suffix += 1; + const id = suffix === 1 ? "option" : `option${suffix}`; + const label = suffix === 1 ? "Option" : `Option ${suffix}`; + + return type === "select" + ? { + id, + label, + type, + options: [{ id: "default", label: "Default", isDefault: true }], + currentValue: "default", + } + : { id, label, type, currentValue: false }; +} + +export function makeSelectCustomModelCapabilityDescriptor( + template: SelectProviderOptionDescriptor, + supportedValues: ReadonlyArray, + requestedDefault: string | undefined, +): SelectProviderOptionDescriptor | undefined { + const values = [ + ...new Set(supportedValues.map((value) => value.trim()).filter((value) => value.length > 0)), + ]; + if (values.length === 0) return undefined; + const defaultValue = resolveSelectCustomModelCapabilityDefault( + template, + values, + requestedDefault, + ); + const templateOptions = new Map(template.options.map((option) => [option.id, option])); + const options = values.map((id) => { + const templateOption = templateOptions.get(id); + const { isDefault: _isDefault, ...rest } = templateOption ?? { + id, + label: capabilityOptionLabel(id), + }; + return { + ...rest, + ...(id === defaultValue ? { isDefault: true } : {}), + }; + }); + const promptInjectedValues = template.promptInjectedValues?.filter((value) => + values.includes(value), + ); + + return { + id: template.id, + label: template.label, + type: "select", + options, + ...(template.description ? { description: template.description } : {}), + ...(defaultValue ? { currentValue: defaultValue } : {}), + ...(promptInjectedValues && promptInjectedValues.length > 0 ? { promptInjectedValues } : {}), + }; +} + +export function addSelectCustomModelCapabilityValue( + descriptor: SelectProviderOptionDescriptor, + rawValue: string, +): SelectProviderOptionDescriptor { + const id = rawValue.trim(); + if (!id || descriptor.options.some((option) => option.id === id)) return descriptor; + return ( + makeSelectCustomModelCapabilityDescriptor( + descriptor, + [...descriptor.options.map((option) => option.id), id], + descriptor.currentValue, + ) ?? descriptor + ); +} + +export function setSelectCustomModelCapabilityDefault( + descriptor: SelectProviderOptionDescriptor, + defaultValueId: string, +): SelectProviderOptionDescriptor { + if (!descriptor.options.some((option) => option.id === defaultValueId)) return descriptor; + return ( + makeSelectCustomModelCapabilityDescriptor( + descriptor, + descriptor.options.map((option) => option.id), + defaultValueId, + ) ?? descriptor + ); +} + +export function isSelectCustomModelCapabilityValueCommitKey(key: string): boolean { + return key === " " || key === "," || key === "Enter"; +} + +export function applySelectCustomModelCapabilityUpdate( + descriptorRef: { current: SelectProviderOptionDescriptor }, + update: (descriptor: SelectProviderOptionDescriptor) => SelectProviderOptionDescriptor, + onChange: (descriptor: SelectProviderOptionDescriptor) => void, +): void { + const descriptor = update(descriptorRef.current); + if (descriptor === descriptorRef.current) return; + descriptorRef.current = descriptor; + onChange(descriptor); +} + +function SelectCustomModelCapabilityValueTag(props: { + readonly descriptor: SelectProviderOptionDescriptor; + readonly option: SelectProviderOptionDescriptor["options"][number]; + readonly defaultValue: string | undefined; + readonly onSelect: () => void; + readonly onRemove: () => void; +}) { + const isDefault = props.option.id === props.defaultValue; + return ( + + + + + ); +} + +function SelectCustomModelCapabilityValues(props: { + readonly descriptor: SelectProviderOptionDescriptor; + readonly onChange: (descriptor: SelectProviderOptionDescriptor) => void; +}) { + const [draftValue, setDraftValue] = useState(""); + const descriptorRef = useRef(props.descriptor); + descriptorRef.current = props.descriptor; + const defaultValue = resolveSelectCustomModelCapabilityDefault( + props.descriptor, + props.descriptor.options.map((option) => option.id), + props.descriptor.currentValue, + ); + const updateDescriptor = ( + update: (descriptor: SelectProviderOptionDescriptor) => SelectProviderOptionDescriptor, + ) => applySelectCustomModelCapabilityUpdate(descriptorRef, update, props.onChange); + const commitInput = () => { + updateDescriptor((descriptor) => addSelectCustomModelCapabilityValue(descriptor, draftValue)); + setDraftValue(""); + }; + + return ( +
+ Values +
+ {props.descriptor.options.map((option) => ( + + updateDescriptor((descriptor) => + setSelectCustomModelCapabilityDefault(descriptor, option.id), + ) + } + onRemove={() => + updateDescriptor( + (descriptor) => + makeSelectCustomModelCapabilityDescriptor( + descriptor, + descriptor.options + .filter((candidate) => candidate.id !== option.id) + .map((candidate) => candidate.id), + descriptor.currentValue, + ) ?? descriptor, + ) + } + /> + ))} + setDraftValue(event.target.value)} + onKeyDown={(event) => { + if ( + event.nativeEvent.isComposing || + !isSelectCustomModelCapabilityValueCommitKey(event.key) + ) { + return; + } + event.preventDefault(); + commitInput(); + }} + onBlur={commitInput} + placeholder="Add value…" + aria-label={`Add value to ${props.descriptor.label}`} + spellCheck={false} + /> +
+ + Press Space, comma, or Enter to add. Click a value to make it the default. + +
+ ); +} + +export function replaceCustomModelCapabilityDescriptor( + configured: ReadonlyArray, + descriptor: ProviderOptionDescriptor | undefined, + id: string, +): ModelCapabilities { + const next = [...configured]; + const index = next.findIndex((candidate) => candidate.id === id); + if (index < 0) { + if (descriptor) next.push(descriptor); + } else if (descriptor) { + next[index] = descriptor; + } else { + next.splice(index, 1); + } + return { optionDescriptors: next }; +} + +export function getConfiguredCustomModelOptionDescriptors( + configured: ModelCapabilities | undefined, + fallback: ModelCapabilities | null, +): ReadonlyArray { + return configured === undefined + ? (fallback?.optionDescriptors ?? []) + : (configured.optionDescriptors ?? []); +} + +type CustomModelCapabilitySourceProvider = Pick< + ServerProvider, + "instanceId" | "driver" | "displayName" | "models" +>; + +interface CustomModelCapabilityCopySource { + readonly providerInstanceId: ProviderInstanceId; + readonly providerLabel: string; + readonly modelSlug: string; + readonly modelName: string; + readonly optionDescriptors: ReadonlyArray; +} + +interface CustomModelCapabilityCopySelection { + readonly providerInstanceId: ProviderInstanceId; + readonly modelSlug: string; + readonly descriptorIds: ReadonlySet; +} + +export function collectCustomModelCapabilityCopySources( + providers: ReadonlyArray, +): ReadonlyArray { + const sources: Array = []; + for (const provider of providers) { + const baseLabel = provider.displayName ?? String(provider.driver); + const hasDuplicateLabel = providers.some( + (candidate) => + candidate.instanceId !== provider.instanceId && + (candidate.displayName ?? String(candidate.driver)) === baseLabel, + ); + for (const model of provider.models) { + const optionDescriptors = model.capabilities?.optionDescriptors ?? []; + if (optionDescriptors.length === 0) continue; + sources.push({ + providerInstanceId: provider.instanceId, + providerLabel: hasDuplicateLabel ? `${baseLabel} (${provider.instanceId})` : baseLabel, + modelSlug: model.slug, + modelName: model.name, + optionDescriptors, + }); + } + } + return sources; +} + +export function filterCustomModelCapabilityCopySources( + sources: ReadonlyArray, + instanceId: ProviderInstanceId, + modelSlug: string, +): ReadonlyArray { + return sources.filter( + (source) => source.providerInstanceId !== instanceId || source.modelSlug !== modelSlug, + ); +} + +export function getSelectedCustomModelCapabilityCopyDescriptors( + source: CustomModelCapabilityCopySource, + selection: CustomModelCapabilityCopySelection | undefined, +): ReadonlyArray { + if ( + selection?.providerInstanceId !== source.providerInstanceId || + selection.modelSlug !== source.modelSlug + ) { + return []; + } + return source.optionDescriptors.filter((descriptor) => + selection.descriptorIds.has(descriptor.id), + ); +} + +export function copyCustomModelCapabilityDescriptors( + configured: ReadonlyArray, + copied: ReadonlyArray, +): ReadonlyArray { + const copiedById = new Map(copied.map((descriptor) => [descriptor.id, descriptor])); + const configuredIds = new Set(configured.map((descriptor) => descriptor.id)); + return [ + ...configured.map((descriptor) => copiedById.get(descriptor.id) ?? descriptor), + ...copied.filter((descriptor) => !configuredIds.has(descriptor.id)), + ]; +} + +export function CustomModelCapabilityCopyPicker(props: { + readonly sources: ReadonlyArray; + readonly onCopy: (descriptors: ReadonlyArray) => void; + readonly onCancel: () => void; +}) { + const providers = [ + ...new Map( + props.sources.map((source) => [ + source.providerInstanceId, + { + instanceId: source.providerInstanceId, + label: source.providerLabel, + }, + ]), + ).values(), + ]; + const [providerInstanceId, setProviderInstanceId] = useState( + props.sources[0]?.providerInstanceId, + ); + const provider = + providers.find((candidate) => candidate.instanceId === providerInstanceId) ?? providers[0]; + const models = props.sources.filter( + (candidate) => candidate.providerInstanceId === provider?.instanceId, + ); + const [modelSlug, setModelSlug] = useState(props.sources[0]?.modelSlug); + const [selection, setSelection] = useState(); + const source = models.find((candidate) => candidate.modelSlug === modelSlug) ?? models[0]; + + if (!provider || !source) return null; + const selectedDescriptors = getSelectedCustomModelCapabilityCopyDescriptors(source, selection); + const selectedIds = new Set(selectedDescriptors.map((descriptor) => descriptor.id)); + + const selectProvider = (value: ProviderInstanceId | null) => { + if (value === null) return; + const nextProvider = providers.find((candidate) => candidate.instanceId === value); + if (!nextProvider) return; + const nextSource = props.sources.find( + (candidate) => candidate.providerInstanceId === nextProvider.instanceId, + ); + setProviderInstanceId(nextProvider.instanceId); + setModelSlug(nextSource?.modelSlug); + setSelection(undefined); + }; + + const selectModel = (value: string | null) => { + if (value === null) return; + if (!models.some((candidate) => candidate.modelSlug === value)) return; + setModelSlug(value); + setSelection(undefined); + }; + + const toggleDescriptor = (id: string, checked: boolean) => { + const descriptorIds = new Set(selectedIds); + if (checked) descriptorIds.add(id); + else descriptorIds.delete(id); + setSelection({ + providerInstanceId: source.providerInstanceId, + modelSlug: source.modelSlug, + descriptorIds, + }); + }; + + return ( +
+
+ + +
+ +
+ {source.optionDescriptors.map((descriptor) => ( + + ))} +
+ +

+ Copies exact IDs and values. Target harness decides whether it can send them. +

+
+ + +
+
+ ); +} + +export function CustomModelCapabilitiesEditor(props: { + readonly model: ServerProviderModel; + readonly value: ModelCapabilities | undefined; + readonly copySources: ReadonlyArray; + readonly onChange: (value: ModelCapabilities | undefined) => void; + readonly onSave: () => void; +}) { + const [copyOpen, setCopyOpen] = useState(false); + const configuredDescriptors = getConfiguredCustomModelOptionDescriptors( + props.value, + props.model.capabilities, + ); + + const replaceDescriptor = (descriptor: ProviderOptionDescriptor | undefined, id: string) => { + props.onChange(replaceCustomModelCapabilityDescriptor(configuredDescriptors, descriptor, id)); + }; + const addDescriptor = (type: ProviderOptionDescriptor["type"]) => { + props.onChange({ + optionDescriptors: [ + ...configuredDescriptors, + createCustomModelCapabilityDescriptor(configuredDescriptors, type), + ], + }); + }; + + return ( +
+
+

Custom model controls

+

+ Add controls by exact option ID. Provider adapter decides how each value is sent. +

+
+ + {configuredDescriptors.length === 0 ? ( +

No controls added.

+ ) : null} + {configuredDescriptors.map((descriptor) => { + const commitId = (value: string) => { + const id = value.trim(); + if ( + !id || + configuredDescriptors.some( + (candidate) => candidate.id === id && candidate.id !== descriptor.id, + ) + ) { + return; + } + replaceDescriptor({ ...descriptor, id }, descriptor.id); + }; + const commitLabel = (value: string) => { + const label = value.trim(); + if (label) replaceDescriptor({ ...descriptor, label }, descriptor.id); + }; + + return ( +
+
+ + {descriptor.type === "select" ? "Select" : "ON / OFF"} + + +
+ +
+ + +
+ + {descriptor.type === "select" ? ( +
+ replaceDescriptor(next, descriptor.id)} + /> +
+ ) : ( +
+ Default {descriptor.currentValue ? "On" : "Off"} + + replaceDescriptor( + { ...descriptor, currentValue: Boolean(checked) }, + descriptor.id, + ) + } + aria-label={`Default ${descriptor.label} for ${props.model.name}`} + /> +
+ )} +
+ ); + })} + {copyOpen ? ( + props.copySources.length > 0 ? ( + { + props.onChange({ + optionDescriptors: copyCustomModelCapabilityDescriptors( + configuredDescriptors, + descriptors, + ), + }); + setCopyOpen(false); + }} + onCancel={() => setCopyOpen(false)} + /> + ) : ( +
+ + No model controls available to copy. + + +
+ ) + ) : null} + +
+
+ + + +
+ +
+
+ ); +} + +function CustomModelCapabilitiesPopover(props: { + readonly model: ServerProviderModel; + readonly value: ModelCapabilities | undefined; + readonly copySources: ReadonlyArray; + readonly onSave: (capabilities: ModelCapabilities | undefined) => void; +}) { + const [open, setOpen] = useState(false); + const [draft, setDraft] = useState(props.value); + const draftRef = useRef(props.value); + const updateDraft = (capabilities: ModelCapabilities | undefined) => { + draftRef.current = capabilities; + setDraft(capabilities); + }; + + return ( + { + if (nextOpen) updateDraft(props.value); + setOpen(nextOpen); + }} + > + + + } + /> + } + > + + + Configure model controls + + + { + props.onSave(draftRef.current); + setOpen(false); + }} + /> + + + ); +} + interface ProviderModelsSectionProps { /** Identifier used to namespace input ids within the DOM. */ readonly instanceId: ProviderInstanceId; @@ -50,12 +800,18 @@ interface ProviderModelsSectionProps { * and custom entries, distinguished by `isCustom`. */ readonly models: ReadonlyArray; + readonly sourceProviders: ReadonlyArray; /** * The persisted custom-model slug list for this instance. Drives dedup, * and is the array we hand back verbatim (with the new slug appended / * removed) via `onChange`. */ readonly customModels: ReadonlyArray; + readonly customModelCapabilities: Readonly>; + readonly onCustomModelCapabilitiesChange: ( + slug: string, + capabilities: ModelCapabilities | undefined, + ) => void; /** Server-returned model slugs hidden from the model picker. */ readonly hiddenModels: ReadonlyArray; /** Model slugs favorited for this provider instance. */ @@ -88,7 +844,10 @@ export function ProviderModelsSection({ instanceId, driverKind, models, + sourceProviders, customModels, + customModelCapabilities, + onCustomModelCapabilitiesChange, hiddenModels, favoriteModels, modelOrder, @@ -102,6 +861,10 @@ export function ProviderModelsSection({ const listRef = useRef(null); const hiddenModelSet = useMemo(() => new Set(hiddenModels), [hiddenModels]); const favoriteModelSet = useMemo(() => new Set(favoriteModels), [favoriteModels]); + const copySources = useMemo( + () => collectCustomModelCapabilityCopySources(sourceProviders), + [sourceProviders], + ); const orderedModels = useMemo(() => { return sortModelsForProviderInstance(models, { favoriteModels: favoriteModelSet, @@ -193,7 +956,6 @@ export function ProviderModelsSection({
{orderedModels.map((model, index) => { const caps = model.capabilities; - const capLabels: string[] = []; const isHidden = !model.isCustom && hiddenModelSet.has(model.slug); const isFavorite = favoriteModelSet.has(model.slug); const previousModel = orderedModels[index - 1]; @@ -203,24 +965,7 @@ export function ProviderModelsSection({ const canMoveDown = nextModel !== undefined && favoriteModelSet.has(nextModel.slug) === isFavorite; const descriptors = caps?.optionDescriptors ?? []; - if (descriptors.some((descriptor) => descriptor.id === "fastMode")) { - capLabels.push("Fast mode"); - } - if (descriptors.some((descriptor) => descriptor.id === "thinking")) { - capLabels.push("Thinking"); - } - if ( - descriptors.some( - (descriptor) => - descriptor.type === "select" && - (descriptor.id === "reasoningEffort" || - descriptor.id === "effort" || - descriptor.id === "reasoning" || - descriptor.id === "variant"), - ) - ) { - capLabels.push("Reasoning"); - } + const capLabels = [...new Set(descriptors.map((descriptor) => descriptor.label))]; const hasDetails = capLabels.length > 0 || model.name !== model.slug; return ( @@ -353,6 +1098,20 @@ export function ProviderModelsSection({ ) : null} + {model.isCustom ? ( + + onCustomModelCapabilitiesChange(model.slug, capabilities) + } + /> + ) : null} {model.isCustom ? ( setOpenInstanceDetails((existing) => ({ diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index 3e4106c583f1..6d519f2db61f 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -1415,6 +1415,21 @@ describe("composerDraftStore modelSelection", () => { ); }); + it("preserves an exact custom model ID when its first control changes", () => { + const store = useComposerDraftStore.getState(); + + store.setProviderModelOptions( + threadRef, + CLAUDE_AGENT_DRIVER, + toSelections({ effort: "high" }), + { model: "opus" }, + ); + + expect( + draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider[CLAUDE_AGENT_INSTANCE], + ).toEqual(modelSelection(CLAUDE_AGENT_DRIVER, "opus", { effort: "high" })); + }); + it("updates only the draft when sticky persistence is omitted", () => { const store = useComposerDraftStore.getState(); @@ -1599,6 +1614,21 @@ describe("composerDraftStore setModelSelection", () => { draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider[CODEX_INSTANCE], ).toEqual(modelSelection(CODEX_DRIVER, "gpt-5.3-codex")); }); + + it("preserves provider-owned IDs in canonical selections", () => { + const store = useComposerDraftStore.getState(); + + store.setModelSelection(threadRef, modelSelection(CODEX_DRIVER, "gpt-5-codex")); + store.setModelSelection(threadRef, modelSelection(CLAUDE_AGENT_DRIVER, "opus")); + + const draft = draftFor(threadId, TEST_ENVIRONMENT_ID); + expect(draft?.modelSelectionByProvider[CODEX_INSTANCE]).toEqual( + modelSelection(CODEX_DRIVER, "gpt-5-codex"), + ); + expect(draft?.modelSelectionByProvider[CLAUDE_AGENT_INSTANCE]).toEqual( + modelSelection(CLAUDE_AGENT_DRIVER, "opus"), + ); + }); }); describe("composerDraftStore sticky composer settings", () => { diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 3fe6681e09ed..33ef03ef5a60 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -29,7 +29,11 @@ import * as Schema from "effect/Schema"; import * as Equal from "effect/Equal"; import * as Effect from "effect/Effect"; import { DeepMutable } from "effect/Types"; -import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model"; +import { + createModelSelection, + normalizeCustomModelSlug, + normalizeModelSlug, +} from "@t3tools/shared/model"; import { useMemo } from "react"; import { getLocalStorageItem } from "./hooks/useLocalStorage"; import { resolveAppModelSelection, resolveAppModelSelectionForInstance } from "./modelSelection"; @@ -875,13 +879,16 @@ function normalizeModelSelection( if (typeof rawModel !== "string") { return null; } - // Slug normalization can use provider-kind-specific rules when a legacy - // driver key is present. Instance-only selections are not reverse-inferred - // into a driver kind here; they get generic default normalization. - const driverKindHint = - normalizeProviderDriverKind(candidate?.provider ?? legacy?.provider) ?? - ProviderDriverKind.make("codex"); - const model = normalizeModelSlug(rawModel, driverKindHint); + // Canonical selections preserve provider-owned IDs. Only legacy provider-shaped + // selections expand aliases with driver-specific rules. + const model = + candidate?.instanceId !== undefined + ? normalizeCustomModelSlug(rawModel) + : normalizeModelSlug( + rawModel, + normalizeProviderDriverKind(candidate?.provider ?? legacy?.provider) ?? + ProviderDriverKind.make("codex"), + ); if (!model) { return null; } @@ -2817,7 +2824,7 @@ const composerDraftStore = create()( } const instanceKey = options?.instanceId ?? defaultInstanceIdForDriver(normalizedProvider); const fallbackModel = - normalizeModelSlug(options?.model, normalizedProvider) ?? + normalizeCustomModelSlug(options?.model) ?? DEFAULT_MODEL_BY_PROVIDER[normalizedProvider] ?? DEFAULT_MODEL; const providerOpts = diff --git a/apps/web/src/providerModels.ts b/apps/web/src/providerModels.ts index 9715344cba80..5aae41a5724c 100644 --- a/apps/web/src/providerModels.ts +++ b/apps/web/src/providerModels.ts @@ -82,6 +82,10 @@ export function getProviderModelCapabilities( model: string | null | undefined, provider: ProviderDriverKind, ): ModelCapabilities { + const exactModel = model ? models.find((candidate) => candidate.slug === model) : undefined; + if (exactModel) { + return exactModel.capabilities ?? EMPTY_CAPABILITIES; + } const slug = normalizeModelSlug(model, provider); return models.find((candidate) => candidate.slug === slug)?.capabilities ?? EMPTY_CAPABILITIES; } diff --git a/docs/README.md b/docs/README.md index 30653e7d5035..8900122d3d1d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -12,7 +12,8 @@ - [Keeping app and server in sync](./user/updating.md) - [Source control integrations](./user/source-control.md) - [Background service (Linux)](./user/background-service.md) -- Providers: [Codex](./user/providers-codex.md) · [Claude](./user/providers-claude.md) +- Providers: [Custom models](./user/providers-custom-models.md) · + [Codex](./user/providers-codex.md) · [Claude](./user/providers-claude.md) Mobile app: [apps/mobile/README.md](../apps/mobile/README.md) diff --git a/docs/user/providers-claude.md b/docs/user/providers-claude.md index f9699388b7db..00c21bc4c31e 100644 --- a/docs/user/providers-claude.md +++ b/docs/user/providers-claude.md @@ -10,6 +10,31 @@ Common reasons: - run Claude through a router such as Claude Code Router - use external providers exposed through a Claude-compatible workflow +## Custom Model Controls + +Add a custom model in **Settings → Providers → Claude → Models**. Use the settings button beside +the model to add controls by exact option ID, then set their labels, values, and defaults. Claude's +adapter recognizes `effort`, `fastMode`, `contextWindow`, and `thinking`. The message composer shows +every control you add. See [Custom models](./providers-custom-models.md) for adapter behavior. + +The saved model ID stays exact. For example, a custom model saved as `gateway/model` stays visible as +`gateway/model`. If you declare a `1m` context option and select it, T3 Code gives Claude Code the +`gateway/model[1m]` launch selector. T3 Code stores the configured gateway model ID without that +selector. T3 Code does not assume 1M support for other custom models. + +Reasoning selections use the Claude Agent SDK effort option. Custom model names do not force a +built-in Claude effort mapping. Existing `ultracode` and `ultrathink` behavior still applies. Only +list values supported by your Claude Code version and gateway. + +Claude fast mode has an important limit: it is Claude Code's native `fastMode` setting. It is not +OpenAI priority service, and the Claude Agent SDK does not provide an OpenAI `serviceTier` option. +Enable fast mode only when your Claude runtime and gateway support Claude's native fast-mode +setting. A gateway that only supports OpenAI priority service cannot receive that choice through +the Claude provider. + +Existing custom models keep working without changes. A custom model with no declared controls keeps +the same empty capability metadata and shows no extra composer controls. + ## I Only Use One Claude Account Use the default provider. diff --git a/docs/user/providers-codex.md b/docs/user/providers-codex.md index 7c5ea91f043b..6accbde07436 100644 --- a/docs/user/providers-codex.md +++ b/docs/user/providers-codex.md @@ -10,6 +10,22 @@ Common reasons: - switch to another account when one account hits limits - keep one shared Codex history instead of maintaining two separate Codex setups +## Custom Model Controls + +Add a custom model in **Settings → Providers → Codex → Models**. Use the settings button beside the +model to add controls by exact option ID, then set their labels, values, and defaults. Codex's +adapter recognizes `reasoningEffort` and `serviceTier`. The message composer shows every control you +add. See [Custom models](./providers-custom-models.md) for adapter behavior. + +Codex reasoning choices are sent as `reasoningEffort`. Service-tier choices are sent as +`serviceTier`; this is where Standard, Fast, priority, or other tiers reported by the Codex app +server belong. A tier only works when the selected Codex endpoint supports it. + +T3 Code does not treat Claude fast mode as a Codex or OpenAI service tier. Each provider adapter uses +its own native request option. The Codex adapter has no `contextWindow` request translation today. + +Existing custom models with no declared controls continue to work as before. + ## I Only Use One Codex Account Use the default provider. diff --git a/docs/user/providers-custom-models.md b/docs/user/providers-custom-models.md new file mode 100644 index 000000000000..0a1f79a5551b --- /dev/null +++ b/docs/user/providers-custom-models.md @@ -0,0 +1,36 @@ +# Custom models + +Add a model in **Settings → Providers → your provider → Models**. Use the settings button beside the +custom model to add any select or on/off control by its exact option ID. Set its label, values, and +default. The message composer then shows the declared controls. + +T3 Code keeps the saved model ID exact. A provider adapter can change request syntax only when its +native API requires it. Existing custom models with no capability metadata keep their old behavior. + +## Adapter behavior + +| Provider | Control IDs with runtime translation | +| -------- | ------------------------------------------------------- | +| Claude | `effort`, `fastMode`, `contextWindow`, `thinking` | +| Codex | `reasoningEffort`, `serviceTier` | +| Cursor | `reasoning`, `contextWindow`, `fastMode`, `thinking` | +| OpenCode | `variant`, `agent` | +| Grok | None; T3 Code's Grok adapter sends only model selection | + +The capability editor and composer render every declared select or on/off descriptor without a +provider control list. This does not add a new provider API option. An adapter sends IDs it +understands and ignores other selections. + +Cursor sends choices through ACP session configuration. A choice is applied only when the active +Cursor CLI reports a matching configuration option for the selected model. + +OpenCode sends `variant` and `agent` through its SDK request. Its adapter has no model speed-tier or +context-window request option. The Grok adapter sends only model selection, so custom controls can be +displayed but do not change its request. + +Claude `fastMode` is a Claude Code setting. It is not OpenAI priority service. Codex sends speed as +its native `serviceTier` value. Do not use one as a substitute for the other. + +For Claude only, selecting a declared `1m` context value adds Claude Code's `[1m]` model selector at +launch. The saved and displayed custom model ID stays unchanged. T3 Code does not assume 1M support +for any model that did not declare it. diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 9fcd0d266dd6..52837304b592 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -127,6 +127,9 @@ export const ModelCapabilities = Schema.Struct({ }); export type ModelCapabilities = typeof ModelCapabilities.Type; +export const CustomModelCapabilities = Schema.Record(TrimmedNonEmptyString, ModelCapabilities); +export type CustomModelCapabilities = typeof CustomModelCapabilities.Type; + const CODEX_DRIVER_KIND = ProviderDriverKind.make("codex"); const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor"); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 22ce210ed898..67147787f086 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -5,6 +5,7 @@ import * as SchemaTransformation from "effect/SchemaTransformation"; import { TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts"; import { ThreadEnvMode } from "./environment.ts"; import { + CustomModelCapabilities, DEFAULT_TEXT_GENERATION_MODEL, DEFAULT_TEXT_GENERATION_REASONING_EFFORT, ProviderOptionSelections, @@ -315,6 +316,9 @@ export const CodexSettings = makeProviderSettingsSchema( Schema.withDecodingDefault(Effect.succeed([])), Schema.annotateKey({ providerSettingsForm: { hidden: true } }), ), + customModelCapabilities: Schema.optionalKey(CustomModelCapabilities).pipe( + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), }, { order: ["binaryPath", "homePath", "shadowHomePath", "launchArgs"], @@ -348,6 +352,9 @@ export const ClaudeSettings = makeProviderSettingsSchema( Schema.withDecodingDefault(Effect.succeed([])), Schema.annotateKey({ providerSettingsForm: { hidden: true } }), ), + customModelCapabilities: Schema.optionalKey(CustomModelCapabilities).pipe( + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), launchArgs: Schema.String.pipe( Schema.withDecodingDefault(Effect.succeed("")), Schema.annotateKey({ @@ -394,6 +401,9 @@ export const CursorSettings = makeProviderSettingsSchema( Schema.withDecodingDefault(Effect.succeed([])), Schema.annotateKey({ providerSettingsForm: { hidden: true } }), ), + customModelCapabilities: Schema.optionalKey(CustomModelCapabilities).pipe( + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), }, { order: ["binaryPath", "apiEndpoint"], @@ -418,6 +428,9 @@ export const GrokSettings = makeProviderSettingsSchema( Schema.withDecodingDefault(Effect.succeed([])), Schema.annotateKey({ providerSettingsForm: { hidden: true } }), ), + customModelCapabilities: Schema.optionalKey(CustomModelCapabilities).pipe( + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), }, { order: ["binaryPath"], @@ -468,6 +481,9 @@ export const OpenCodeSettings = makeProviderSettingsSchema( Schema.withDecodingDefault(Effect.succeed([])), Schema.annotateKey({ providerSettingsForm: { hidden: true } }), ), + customModelCapabilities: Schema.optionalKey(CustomModelCapabilities).pipe( + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), }, { order: ["binaryPath", "serverUrl", "serverPassword"], @@ -677,6 +693,7 @@ const CodexSettingsPatch = Schema.Struct({ shadowHomePath: Schema.optionalKey(TrimmedString), launchArgs: Schema.optionalKey(TrimmedString), customModels: Schema.optionalKey(Schema.Array(Schema.String)), + customModelCapabilities: Schema.optionalKey(CustomModelCapabilities), }); const ClaudeSettingsPatch = Schema.Struct({ @@ -684,6 +701,7 @@ const ClaudeSettingsPatch = Schema.Struct({ binaryPath: Schema.optionalKey(TrimmedString), homePath: Schema.optionalKey(TrimmedString), customModels: Schema.optionalKey(Schema.Array(Schema.String)), + customModelCapabilities: Schema.optionalKey(CustomModelCapabilities), launchArgs: Schema.optionalKey(TrimmedString), }); @@ -692,12 +710,14 @@ const CursorSettingsPatch = Schema.Struct({ binaryPath: Schema.optionalKey(TrimmedString), apiEndpoint: Schema.optionalKey(TrimmedString), customModels: Schema.optionalKey(Schema.Array(Schema.String)), + customModelCapabilities: Schema.optionalKey(CustomModelCapabilities), }); const GrokSettingsPatch = Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean), binaryPath: Schema.optionalKey(TrimmedString), customModels: Schema.optionalKey(Schema.Array(Schema.String)), + customModelCapabilities: Schema.optionalKey(CustomModelCapabilities), }); const OpenCodeSettingsPatch = Schema.Struct({ @@ -706,6 +726,7 @@ const OpenCodeSettingsPatch = Schema.Struct({ serverUrl: Schema.optionalKey(TrimmedString), serverPassword: Schema.optionalKey(TrimmedString), customModels: Schema.optionalKey(Schema.Array(Schema.String)), + customModelCapabilities: Schema.optionalKey(CustomModelCapabilities), }); export const ServerSettingsPatch = Schema.Struct({ diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index bdc0c0cc8efb..25a951c7e9e4 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -25,6 +25,15 @@ export function createModelCapabilities(input: { }; } +export function getDeclaredCustomModelCapabilities( + declarations: Readonly> | null | undefined, + model: string | null | undefined, +): ModelCapabilities | undefined { + return declarations && model && Object.hasOwn(declarations, model) + ? declarations[model] + : undefined; +} + function getRawSelectionValueById( selections: ReadonlyArray | null | undefined, id: string, diff --git a/packages/shared/src/serverSettings.test.ts b/packages/shared/src/serverSettings.test.ts index baa84a4e1aa8..815bc56691e6 100644 --- a/packages/shared/src/serverSettings.test.ts +++ b/packages/shared/src/serverSettings.test.ts @@ -298,6 +298,53 @@ describe("serverSettings helpers", () => { }); }); + it("replaces legacy custom model capability maps", () => { + const capabilities = { + optionDescriptors: [{ id: "fastMode", label: "Fast Mode", type: "boolean" as const }], + }; + const current = { + ...DEFAULT_SERVER_SETTINGS, + providers: { + codex: { + ...DEFAULT_SERVER_SETTINGS.providers.codex, + customModelCapabilities: { old: capabilities }, + }, + claudeAgent: { + ...DEFAULT_SERVER_SETTINGS.providers.claudeAgent, + customModelCapabilities: { old: capabilities }, + }, + cursor: { + ...DEFAULT_SERVER_SETTINGS.providers.cursor, + customModelCapabilities: { old: capabilities }, + }, + grok: { + ...DEFAULT_SERVER_SETTINGS.providers.grok, + customModelCapabilities: { old: capabilities }, + }, + opencode: { + ...DEFAULT_SERVER_SETTINGS.providers.opencode, + customModelCapabilities: { old: capabilities }, + }, + }, + }; + + const next = applyServerSettingsPatch(current, { + providers: { + codex: { customModelCapabilities: {} }, + claudeAgent: { customModelCapabilities: { next: capabilities } }, + cursor: { customModelCapabilities: {} }, + grok: { customModelCapabilities: {} }, + opencode: { customModelCapabilities: {} }, + }, + }); + + expect(next.providers.codex.customModelCapabilities).toEqual({}); + expect(next.providers.claudeAgent.customModelCapabilities).toEqual({ next: capabilities }); + expect(next.providers.cursor.customModelCapabilities).toEqual({}); + expect(next.providers.grok.customModelCapabilities).toEqual({}); + expect(next.providers.opencode.customModelCapabilities).toEqual({}); + }); + it("stores background activity profiles as a versioned object and syncs legacy aliases", () => { const next = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { backgroundActivity: { diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index 21d819a1c9ea..f9a0a4f909e4 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -121,6 +121,15 @@ function mergeModelSelectionOptionsById(input: { return [...merged.entries()].map(([id, value]) => ({ id, value })); } +function replaceCustomModelCapabilities< + Current extends { readonly customModelCapabilities?: unknown }, + Patch extends { readonly customModelCapabilities?: Current["customModelCapabilities"] }, +>(current: Current, patch: Patch | undefined): Current { + return patch?.customModelCapabilities === undefined + ? current + : ({ ...current, customModelCapabilities: patch.customModelCapabilities } as Current); +} + export function applyServerSettingsPatch( current: ServerSettings, patch: ServerSettingsPatch, @@ -171,6 +180,17 @@ export function applyServerSettingsPatch( const next = deepMerge(current, patchForMerge); const nextWithReplacementsBase = { ...next, + providers: { + ...next.providers, + claudeAgent: replaceCustomModelCapabilities( + next.providers.claudeAgent, + patch.providers?.claudeAgent, + ), + codex: replaceCustomModelCapabilities(next.providers.codex, patch.providers?.codex), + cursor: replaceCustomModelCapabilities(next.providers.cursor, patch.providers?.cursor), + grok: replaceCustomModelCapabilities(next.providers.grok, patch.providers?.grok), + opencode: replaceCustomModelCapabilities(next.providers.opencode, patch.providers?.opencode), + }, ...(backgroundActivity !== undefined ? { backgroundActivity: {