diff --git a/apps/server/src/git/Layers/ClaudeTextGeneration.ts b/apps/server/src/git/Layers/ClaudeTextGeneration.ts index f4d3833627..19ed40e65b 100644 --- a/apps/server/src/git/Layers/ClaudeTextGeneration.ts +++ b/apps/server/src/git/Layers/ClaudeTextGeneration.ts @@ -29,8 +29,9 @@ import { sanitizeThreadTitle, toJsonSchemaObject, } from "../Utils.ts"; -import { normalizeClaudeModelOptions } from "../../provider/Layers/ClaudeProvider.ts"; +import { normalizeClaudeModelOptionsWithCapabilities } from "@t3tools/shared/model"; import { ServerSettingsService } from "../../serverSettings.ts"; +import { getClaudeModelCapabilities } from "../../provider/Layers/ClaudeProvider.ts"; const CLAUDE_TIMEOUT_MS = 180_000; @@ -84,8 +85,8 @@ const makeClaudeTextGeneration = Effect.gen(function* () { }): Effect.Effect => Effect.gen(function* () { const jsonSchemaStr = JSON.stringify(toJsonSchemaObject(outputSchemaJson)); - const normalizedOptions = normalizeClaudeModelOptions( - modelSelection.model, + const normalizedOptions = normalizeClaudeModelOptionsWithCapabilities( + getClaudeModelCapabilities(modelSelection.model), modelSelection.options, ); const settings = { diff --git a/apps/server/src/git/Layers/CodexTextGeneration.ts b/apps/server/src/git/Layers/CodexTextGeneration.ts index c82923f93e..8f0556ee34 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.ts +++ b/apps/server/src/git/Layers/CodexTextGeneration.ts @@ -28,8 +28,9 @@ import { sanitizeThreadTitle, toJsonSchemaObject, } from "../Utils.ts"; -import { normalizeCodexModelOptions } from "../../provider/Layers/CodexProvider.ts"; +import { getCodexModelCapabilities } from "../../provider/Layers/CodexProvider.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; +import { normalizeCodexModelOptionsWithCapabilities } from "@t3tools/shared/model"; const CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT = "low"; const CODEX_TIMEOUT_MS = 180_000; @@ -156,8 +157,8 @@ const makeCodexTextGeneration = Effect.gen(function* () { ).pipe(Effect.catch(() => Effect.undefined)); const runCodexCommand = Effect.gen(function* () { - const normalizedOptions = normalizeCodexModelOptions( - modelSelection.model, + const normalizedOptions = normalizeCodexModelOptionsWithCapabilities( + getCodexModelCapabilities(modelSelection.model), modelSelection.options, ); const reasoningEffort = diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 5a11af1c5f..f2243ef1b0 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -1,6 +1,5 @@ import type { ClaudeSettings, - ClaudeModelOptions, ModelCapabilities, ServerProvider, ServerProviderModel, @@ -9,7 +8,6 @@ import type { } from "@t3tools/contracts"; import { Cache, Duration, Effect, Equal, Layer, Option, Result, Schema, Stream } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { resolveContextWindow, resolveEffort } from "@t3tools/shared/model"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; import { query as claudeQuery } from "@anthropic-ai/claude-agent-sdk"; @@ -98,25 +96,6 @@ export function getClaudeModelCapabilities(model: string | null | undefined): Mo ); } -export function normalizeClaudeModelOptions( - model: string | null | undefined, - modelOptions: ClaudeModelOptions | null | undefined, -): ClaudeModelOptions | undefined { - const caps = getClaudeModelCapabilities(model); - const effort = resolveEffort(caps, modelOptions?.effort); - const thinking = - caps.supportsThinkingToggle && modelOptions?.thinking === false ? false : undefined; - const fastMode = caps.supportsFastMode && modelOptions?.fastMode === true ? true : undefined; - const contextWindow = resolveContextWindow(caps, modelOptions?.contextWindow); - const nextOptions: ClaudeModelOptions = { - ...(thinking === false ? { thinking: false } : {}), - ...(effort ? { effort: effort as ClaudeModelOptions["effort"] } : {}), - ...(fastMode ? { fastMode: true } : {}), - ...(contextWindow ? { contextWindow } : {}), - }; - return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; -} - export function parseClaudeAuthStatusFromOutput(result: CommandResult): { readonly status: Exclude; readonly auth: Pick; diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index e2f31c13f1..1fe98ea798 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -1,7 +1,6 @@ import * as OS from "node:os"; import type { ModelCapabilities, - CodexModelOptions, CodexSettings, ServerProvider, ServerProviderModel, @@ -21,7 +20,6 @@ import { Stream, } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { resolveEffort } from "@t3tools/shared/model"; import { buildServerProvider, @@ -170,22 +168,6 @@ export function getCodexModelCapabilities(model: string | null | undefined): Mod ); } -export function normalizeCodexModelOptions( - model: string | null | undefined, - modelOptions: CodexModelOptions | null | undefined, -): CodexModelOptions | undefined { - const caps = getCodexModelCapabilities(model); - const reasoningEffort = resolveEffort(caps, modelOptions?.reasoningEffort); - const fastModeEnabled = modelOptions?.fastMode === true; - const nextOptions: CodexModelOptions = { - ...(reasoningEffort - ? { reasoningEffort: reasoningEffort as CodexModelOptions["reasoningEffort"] } - : {}), - ...(fastModeEnabled ? { fastMode: true } : {}), - }; - return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; -} - export function parseAuthStatusFromOutput(result: CommandResult): { readonly status: Exclude; readonly auth: Pick; diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index d5df84724e..a5b4345d50 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -136,7 +136,7 @@ const ATOMIC_SETTINGS_KEYS: ReadonlySet = new Set(["textGenerationModelS function stripDefaultServerSettings(current: unknown, defaults: unknown): unknown | undefined { if (Array.isArray(current) || Array.isArray(defaults)) { - return JSON.stringify(current) === JSON.stringify(defaults) ? undefined : current; + return Equal.equals(current, defaults) ? undefined : current; } if ( diff --git a/apps/web/src/components/chat/composerProviderRegistry.test.tsx b/apps/web/src/components/chat/composerProviderRegistry.test.tsx index cc17335cd5..4dc79832d4 100644 --- a/apps/web/src/components/chat/composerProviderRegistry.test.tsx +++ b/apps/web/src/components/chat/composerProviderRegistry.test.tsx @@ -71,6 +71,41 @@ const CLAUDE_MODELS: ReadonlyArray = [ }, ]; +const CLAUDE_MODELS_WITH_CONTEXT_WINDOW: ReadonlyArray = [ + { + slug: "claude-opus-4-6", + name: "Claude Opus 4.6", + isCustom: false, + capabilities: { + reasoningEffortLevels: [ + { value: "medium", label: "Medium" }, + { value: "high", label: "High", isDefault: true }, + { value: "max", label: "Max" }, + { value: "ultrathink", label: "Ultrathink" }, + ], + supportsFastMode: true, + supportsThinkingToggle: false, + contextWindowOptions: [ + { value: "200k", label: "200k", isDefault: true }, + { value: "1m", label: "1M" }, + ], + promptInjectedEffortLevels: ["ultrathink"], + }, + }, + { + slug: "claude-haiku-4-5", + name: "Claude Haiku 4.5", + isCustom: false, + capabilities: { + reasoningEffortLevels: [], + supportsFastMode: false, + supportsThinkingToggle: true, + contextWindowOptions: [], + promptInjectedEffortLevels: [], + }, + }, +]; + describe("getComposerProviderState", () => { it("returns codex defaults when no codex draft options exist", () => { const state = getComposerProviderState({ @@ -156,6 +191,7 @@ describe("getComposerProviderState", () => { promptEffort: "high", modelOptionsForDispatch: { reasoningEffort: "high", + fastMode: false, }, }); }); @@ -268,7 +304,116 @@ describe("getComposerProviderState", () => { promptEffort: "high", modelOptionsForDispatch: { effort: "high", + fastMode: false, + }, + }); + }); + + it("preserves explicit fastMode: false so deepMerge can overwrite a prior true", () => { + // Regression: normalizeClaudeModelOptionsWithCapabilities used to strip + // fastMode: false, which meant deepMerge could never clear a previous true. + const state = getComposerProviderState({ + provider: "claudeAgent", + model: "claude-opus-4-6", + models: CLAUDE_MODELS, + prompt: "", + modelOptions: { + claudeAgent: { + effort: "high", + fastMode: false, + }, + }, + }); + + expect(state.modelOptionsForDispatch).toHaveProperty("fastMode", false); + }); + + it("preserves explicit thinking: true so deepMerge can overwrite a prior false", () => { + // Regression: thinking: true (the default) used to be stripped, which + // meant deepMerge could never clear a previous thinking: false. + const state = getComposerProviderState({ + provider: "claudeAgent", + model: "claude-haiku-4-5", + models: CLAUDE_MODELS, + prompt: "", + modelOptions: { + claudeAgent: { + thinking: true, + }, + }, + }); + + expect(state.modelOptionsForDispatch).toHaveProperty("thinking", true); + }); + + it("preserves Claude default context window explicitly in dispatch options", () => { + const state = getComposerProviderState({ + provider: "claudeAgent", + model: "claude-opus-4-6", + models: CLAUDE_MODELS_WITH_CONTEXT_WINDOW, + prompt: "", + modelOptions: { + claudeAgent: { + effort: "high", + contextWindow: "200k", + }, + }, + }); + + expect(state.modelOptionsForDispatch).toMatchObject({ + effort: "high", + contextWindow: "200k", + }); + }); + + it("preserves explicit contextWindow default so deepMerge can overwrite a prior 1m", () => { + // Regression: the default contextWindow must survive normalization so + // deepMerge can clear an older non-default 1m selection. + const state = getComposerProviderState({ + provider: "claudeAgent", + model: "claude-opus-4-6", + models: CLAUDE_MODELS_WITH_CONTEXT_WINDOW, + prompt: "", + modelOptions: { + claudeAgent: { + contextWindow: "200k", + }, }, }); + + expect(state.modelOptionsForDispatch).toHaveProperty("contextWindow", "200k"); + }); + + it("omits contextWindow when the model does not support it", () => { + const state = getComposerProviderState({ + provider: "claudeAgent", + model: "claude-haiku-4-5", + models: CLAUDE_MODELS_WITH_CONTEXT_WINDOW, + prompt: "", + modelOptions: { + claudeAgent: { + contextWindow: "1m", + }, + }, + }); + + expect(state.modelOptionsForDispatch).toBeUndefined(); + }); + + it("omits fastMode when the model does not support it", () => { + const state = getComposerProviderState({ + provider: "claudeAgent", + model: "claude-sonnet-4-6", + models: CLAUDE_MODELS, + prompt: "", + modelOptions: { + claudeAgent: { + effort: "high", + fastMode: true, + }, + }, + }); + + expect(state.modelOptionsForDispatch).not.toHaveProperty("fastMode"); }); }); diff --git a/apps/web/src/components/chat/composerProviderRegistry.tsx b/apps/web/src/components/chat/composerProviderRegistry.tsx index 1a2080d441..3307442db2 100644 --- a/apps/web/src/components/chat/composerProviderRegistry.tsx +++ b/apps/web/src/components/chat/composerProviderRegistry.tsx @@ -6,12 +6,12 @@ import { } from "@t3tools/contracts"; import { isClaudeUltrathinkPrompt, resolveEffort } from "@t3tools/shared/model"; import type { ReactNode } from "react"; +import { getProviderModelCapabilities } from "../../providerModels"; +import { TraitsMenuContent, TraitsPicker } from "./TraitsPicker"; import { - getProviderModelCapabilities, normalizeClaudeModelOptionsWithCapabilities, normalizeCodexModelOptionsWithCapabilities, -} from "../../providerModels"; -import { TraitsMenuContent, TraitsPicker } from "./TraitsPicker"; +} from "@t3tools/shared/model"; export type ComposerProviderStateInput = { provider: ProviderKind; diff --git a/apps/web/src/providerModels.ts b/apps/web/src/providerModels.ts index f2e8692eff..298c794de3 100644 --- a/apps/web/src/providerModels.ts +++ b/apps/web/src/providerModels.ts @@ -1,13 +1,11 @@ import { DEFAULT_MODEL_BY_PROVIDER, - type ClaudeModelOptions, - type CodexModelOptions, type ModelCapabilities, type ProviderKind, type ServerProvider, type ServerProviderModel, } from "@t3tools/contracts"; -import { normalizeModelSlug, resolveContextWindow, resolveEffort } from "@t3tools/shared/model"; +import { normalizeModelSlug } from "@t3tools/shared/model"; const EMPTY_CAPABILITIES: ModelCapabilities = { reasoningEffortLevels: [], @@ -69,36 +67,3 @@ export function getDefaultServerModel( DEFAULT_MODEL_BY_PROVIDER[provider] ); } - -export function normalizeCodexModelOptionsWithCapabilities( - caps: ModelCapabilities, - modelOptions: CodexModelOptions | null | undefined, -): CodexModelOptions | undefined { - const reasoningEffort = resolveEffort(caps, modelOptions?.reasoningEffort); - const fastModeEnabled = modelOptions?.fastMode === true; - const nextOptions: CodexModelOptions = { - ...(reasoningEffort - ? { reasoningEffort: reasoningEffort as CodexModelOptions["reasoningEffort"] } - : {}), - ...(fastModeEnabled ? { fastMode: true } : {}), - }; - return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; -} - -export function normalizeClaudeModelOptionsWithCapabilities( - caps: ModelCapabilities, - modelOptions: ClaudeModelOptions | null | undefined, -): ClaudeModelOptions | undefined { - const effort = resolveEffort(caps, modelOptions?.effort); - const thinking = - caps.supportsThinkingToggle && modelOptions?.thinking === false ? false : undefined; - const fastMode = caps.supportsFastMode && modelOptions?.fastMode === true ? true : undefined; - const contextWindow = resolveContextWindow(caps, modelOptions?.contextWindow); - const nextOptions: ClaudeModelOptions = { - ...(thinking === false ? { thinking: false } : {}), - ...(effort ? { effort: effort as ClaudeModelOptions["effort"] } : {}), - ...(fastMode ? { fastMode: true } : {}), - ...(contextWindow ? { contextWindow } : {}), - }; - return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; -} diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts deleted file mode 100644 index e7f638c7f3..0000000000 --- a/packages/contracts/src/settings.test.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { DEFAULT_CLIENT_SETTINGS } from "./settings"; - -describe("DEFAULT_CLIENT_SETTINGS", () => { - it("includes archive confirmation with a false default", () => { - expect(DEFAULT_CLIENT_SETTINGS.confirmThreadArchive).toBe(false); - }); -}); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 9cd8a5f251..51fe683f99 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -112,6 +112,7 @@ const ClaudeModelOptionsPatch = Schema.Struct({ thinking: Schema.optionalKey(ClaudeModelOptions.fields.thinking), effort: Schema.optionalKey(ClaudeModelOptions.fields.effort), fastMode: Schema.optionalKey(ClaudeModelOptions.fields.fastMode), + contextWindow: Schema.optionalKey(ClaudeModelOptions.fields.contextWindow), }); const ModelSelectionPatch = Schema.Union([ diff --git a/packages/shared/src/model.test.ts b/packages/shared/src/model.test.ts index 535601762d..232fa71188 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -8,6 +8,8 @@ import { hasContextWindowOption, hasEffortLevel, isClaudeUltrathinkPrompt, + normalizeClaudeModelOptionsWithCapabilities, + normalizeCodexModelOptionsWithCapabilities, normalizeModelSlug, resolveApiModelId, resolveContextWindow, @@ -226,3 +228,57 @@ describe("resolveApiModelId", () => { expect(resolveApiModelId({ provider: "codex", model: "gpt-5.4" })).toBe("gpt-5.4"); }); }); + +describe("normalize*ModelOptionsWithCapabilities", () => { + it("preserves explicit false codex fast mode", () => { + expect( + normalizeCodexModelOptionsWithCapabilities(codexCaps, { + reasoningEffort: "high", + fastMode: false, + }), + ).toEqual({ + reasoningEffort: "high", + fastMode: false, + }); + }); + + it("preserves the default Claude context window explicitly", () => { + expect( + normalizeClaudeModelOptionsWithCapabilities( + { + ...claudeCaps, + contextWindowOptions: [ + { value: "200k", label: "200k", isDefault: true }, + { value: "1m", label: "1M" }, + ], + }, + { + effort: "high", + contextWindow: "200k", + }, + ), + ).toEqual({ + effort: "high", + contextWindow: "200k", + }); + }); + + it("omits unsupported Claude context window options", () => { + expect( + normalizeClaudeModelOptionsWithCapabilities( + { + ...claudeCaps, + reasoningEffortLevels: [], + supportsThinkingToggle: true, + contextWindowOptions: [], + }, + { + thinking: true, + contextWindow: "1m", + }, + ), + ).toEqual({ + thinking: true, + }); + }); +}); diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index 1598b0407d..2aa378cf63 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -2,6 +2,8 @@ import { DEFAULT_MODEL_BY_PROVIDER, MODEL_SLUG_ALIASES_BY_PROVIDER, type ClaudeCodeEffort, + type ClaudeModelOptions, + type CodexModelOptions, type ModelCapabilities, type ModelSelection, type ProviderKind, @@ -83,6 +85,38 @@ export function resolveContextWindow( return hasContextWindowOption(caps, raw) ? raw : (defaultValue ?? undefined); } +export function normalizeCodexModelOptionsWithCapabilities( + caps: ModelCapabilities, + modelOptions: CodexModelOptions | null | undefined, +): CodexModelOptions | undefined { + const reasoningEffort = resolveEffort(caps, modelOptions?.reasoningEffort); + const fastMode = caps.supportsFastMode ? modelOptions?.fastMode : undefined; + const nextOptions: CodexModelOptions = { + ...(reasoningEffort + ? { reasoningEffort: reasoningEffort as CodexModelOptions["reasoningEffort"] } + : {}), + ...(fastMode !== undefined ? { fastMode } : {}), + }; + return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; +} + +export function normalizeClaudeModelOptionsWithCapabilities( + caps: ModelCapabilities, + modelOptions: ClaudeModelOptions | null | undefined, +): ClaudeModelOptions | undefined { + const effort = resolveEffort(caps, modelOptions?.effort); + const thinking = caps.supportsThinkingToggle ? modelOptions?.thinking : undefined; + const fastMode = caps.supportsFastMode ? modelOptions?.fastMode : undefined; + const contextWindow = resolveContextWindow(caps, modelOptions?.contextWindow); + const nextOptions: ClaudeModelOptions = { + ...(thinking !== undefined ? { thinking } : {}), + ...(effort ? { effort: effort as ClaudeModelOptions["effort"] } : {}), + ...(fastMode !== undefined ? { fastMode } : {}), + ...(contextWindow !== undefined ? { contextWindow } : {}), + }; + return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; +} + export function isClaudeUltrathinkPrompt(text: string | null | undefined): boolean { return typeof text === "string" && /\bultrathink\b/i.test(text); }