From 0285a40a5dd123e69661ac5d4203b550476ad5e4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 25 Mar 2026 13:45:17 -0700 Subject: [PATCH 01/13] Add Claude context window selection support - Add context window options to Claude model capabilities and normalization - Surface the setting in the web traits picker and persist it in drafts - Resolve API model IDs with context window suffixes for Claude requests --- .../src/git/Layers/ClaudeTextGeneration.ts | 3 +- .../src/provider/Layers/ClaudeAdapter.ts | 17 +- .../src/provider/Layers/ClaudeProvider.ts | 10 + .../src/provider/Layers/CodexProvider.ts | 6 + apps/web/src/components/chat/TraitsPicker.tsx | 52 +++++ apps/web/src/composerDraftStore.ts | 10 +- apps/web/src/providerModels.ts | 1 + packages/contracts/src/model.ts | 178 +++++++++++++++++- packages/shared/src/model.test.ts | 105 +++++++++++ packages/shared/src/model.ts | 124 ++++++++++++ 10 files changed, 499 insertions(+), 7 deletions(-) diff --git a/apps/server/src/git/Layers/ClaudeTextGeneration.ts b/apps/server/src/git/Layers/ClaudeTextGeneration.ts index 6ffedbf7b4..ffcd870616 100644 --- a/apps/server/src/git/Layers/ClaudeTextGeneration.ts +++ b/apps/server/src/git/Layers/ClaudeTextGeneration.ts @@ -11,6 +11,7 @@ import { Effect, Layer, Option, Schema, Stream } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { ClaudeModelSelection } from "@t3tools/contracts"; +import { resolveClaudeApiModelId } from "@t3tools/shared/model"; import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; import { TextGenerationError } from "../Errors.ts"; @@ -103,7 +104,7 @@ const makeClaudeTextGeneration = Effect.gen(function* () { "--json-schema", jsonSchemaStr, "--model", - modelSelection.model, + resolveClaudeApiModelId(modelSelection.model, modelSelection.options), ...(normalizedOptions?.effort ? ["--effort", normalizedOptions.effort] : []), ...(Object.keys(settings).length > 0 ? ["--settings", JSON.stringify(settings)] : []), "--dangerously-skip-permissions", diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 7ab8bc44ab..ea4da086ea 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -40,7 +40,12 @@ import { type UserInputQuestion, ClaudeCodeEffort, } from "@t3tools/contracts"; -import { hasEffortLevel, applyClaudePromptEffortPrefix, trimOrNull } from "@t3tools/shared/model"; +import { + hasEffortLevel, + applyClaudePromptEffortPrefix, + resolveClaudeApiModelId, + trimOrNull, +} from "@t3tools/shared/model"; import { Cause, DateTime, @@ -2727,6 +2732,9 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { const claudeBinaryPath = claudeSettings.binaryPath; const modelSelection = input.modelSelection?.provider === "claudeAgent" ? input.modelSelection : undefined; + const apiModelId = modelSelection?.model + ? resolveClaudeApiModelId(modelSelection.model, modelSelection.options) + : undefined; const requestedEffort = trimOrNull(modelSelection?.options?.effort ?? null); const caps = getClaudeModelCapabilities(modelSelection?.model); const effort = @@ -2746,7 +2754,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { const queryOptions: ClaudeQueryOptions = { ...(input.cwd ? { cwd: input.cwd } : {}), - ...(modelSelection?.model ? { model: modelSelection.model } : {}), + ...(apiModelId ? { model: apiModelId } : {}), pathToClaudeCodeExecutable: claudeBinaryPath, settingSources: [...CLAUDE_SETTING_SOURCES], ...(effectiveEffort ? { effort: effectiveEffort } : {}), @@ -2840,7 +2848,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { threadId, payload: { config: { - ...(modelSelection?.model ? { model: modelSelection.model } : {}), + ...(apiModelId ? { model: apiModelId } : {}), ...(input.cwd ? { cwd: input.cwd } : {}), ...(effectiveEffort ? { effort: effectiveEffort } : {}), ...(permissionMode ? { permissionMode } : {}), @@ -2893,8 +2901,9 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { } if (modelSelection?.model) { + const apiModelId = resolveClaudeApiModelId(modelSelection.model, modelSelection.options); yield* Effect.tryPromise({ - try: () => context.query.setModel(modelSelection.model), + try: () => context.query.setModel(apiModelId), catch: (cause) => toRequestError(input.threadId, "turn/setModel", cause), }); } diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index e51f5096db..7777d739bf 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -42,6 +42,10 @@ const BUILT_IN_MODELS: ReadonlyArray = [ ], supportsFastMode: true, supportsThinkingToggle: false, + contextWindowOptions: [ + { value: "", label: "200k", isDefault: true }, + { value: "[1m]", label: "1M" }, + ], promptInjectedEffortLevels: ["ultrathink"], } satisfies ModelCapabilities, }, @@ -58,6 +62,10 @@ const BUILT_IN_MODELS: ReadonlyArray = [ ], supportsFastMode: false, supportsThinkingToggle: false, + contextWindowOptions: [ + { value: "", label: "200k", isDefault: true }, + { value: "[1m]", label: "1M" }, + ], promptInjectedEffortLevels: ["ultrathink"], } satisfies ModelCapabilities, }, @@ -69,6 +77,7 @@ const BUILT_IN_MODELS: ReadonlyArray = [ reasoningEffortLevels: [], supportsFastMode: false, supportsThinkingToggle: true, + contextWindowOptions: [], promptInjectedEffortLevels: [], } satisfies ModelCapabilities, }, @@ -81,6 +90,7 @@ export function getClaudeModelCapabilities(model: string | null | undefined): Mo reasoningEffortLevels: [], supportsFastMode: false, supportsThinkingToggle: false, + contextWindowOptions: [], promptInjectedEffortLevels: [], } ); diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 913fbb58d5..b6285e6e5f 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -48,6 +48,7 @@ const BUILT_IN_MODELS: ReadonlyArray = [ ], supportsFastMode: true, supportsThinkingToggle: false, + contextWindowOptions: [], promptInjectedEffortLevels: [], }, }, @@ -64,6 +65,7 @@ const BUILT_IN_MODELS: ReadonlyArray = [ ], supportsFastMode: true, supportsThinkingToggle: false, + contextWindowOptions: [], promptInjectedEffortLevels: [], }, }, @@ -80,6 +82,7 @@ const BUILT_IN_MODELS: ReadonlyArray = [ ], supportsFastMode: true, supportsThinkingToggle: false, + contextWindowOptions: [], promptInjectedEffortLevels: [], }, }, @@ -96,6 +99,7 @@ const BUILT_IN_MODELS: ReadonlyArray = [ ], supportsFastMode: true, supportsThinkingToggle: false, + contextWindowOptions: [], promptInjectedEffortLevels: [], }, }, @@ -112,6 +116,7 @@ const BUILT_IN_MODELS: ReadonlyArray = [ ], supportsFastMode: true, supportsThinkingToggle: false, + contextWindowOptions: [], promptInjectedEffortLevels: [], }, }, @@ -128,6 +133,7 @@ const BUILT_IN_MODELS: ReadonlyArray = [ ], supportsFastMode: true, supportsThinkingToggle: false, + contextWindowOptions: [], promptInjectedEffortLevels: [], }, }, diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index 5fd97b8cde..47327838ab 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -11,7 +11,9 @@ import { isClaudeUltrathinkPrompt, trimOrNull, getDefaultEffort, + getDefaultContextWindow, hasEffortLevel, + hasContextWindowOption, } from "@t3tools/shared/model"; import { memo, useCallback, useState } from "react"; import type { VariantProps } from "class-variance-authority"; @@ -104,6 +106,15 @@ function getSelectedTraits( caps.supportsFastMode && (modelOptions as { fastMode?: boolean } | undefined)?.fastMode === true; + // Context window + const contextWindowOptions = caps.contextWindowOptions; + const rawContextWindow = (modelOptions as ClaudeModelOptions | undefined)?.contextWindow ?? ""; + const defaultContextWindow = getDefaultContextWindow(caps); + const contextWindow = + rawContextWindow && hasContextWindowOption(caps, rawContextWindow) + ? rawContextWindow + : defaultContextWindow; + // Prompt-controlled effort (e.g. ultrathink in prompt text) const ultrathinkPromptControlled = allowPromptInjectedEffort && @@ -116,6 +127,9 @@ function getSelectedTraits( effortLevels, thinkingEnabled, fastModeEnabled, + contextWindowOptions, + contextWindow, + defaultContextWindow, ultrathinkPromptControlled, }; } @@ -159,6 +173,9 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ effortLevels, thinkingEnabled, fastModeEnabled, + contextWindowOptions, + contextWindow, + defaultContextWindow, ultrathinkPromptControlled, } = getSelectedTraits(provider, models, model, prompt, modelOptions, allowPromptInjectedEffort); const defaultEffort = getDefaultEffort(caps); @@ -258,6 +275,33 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ ) : null} + {contextWindowOptions.length > 1 ? ( + <> + + +
+ Context Window +
+ { + updateModelOptions( + buildNextOptions(provider, modelOptions, { + contextWindow: value || undefined, + }), + ); + }} + > + {contextWindowOptions.map((option) => ( + + {option.label} + {option.value === defaultContextWindow ? " (default)" : ""} + + ))} + +
+ + ) : null} ); }); @@ -281,12 +325,19 @@ export const TraitsPicker = memo(function TraitsPicker({ effortLevels, thinkingEnabled, fastModeEnabled, + contextWindowOptions, + contextWindow, + defaultContextWindow, ultrathinkPromptControlled, } = getSelectedTraits(provider, models, model, prompt, modelOptions, allowPromptInjectedEffort); const effortLabel = effort ? (effortLevels.find((l) => l.value === effort)?.label ?? effort) : null; + const contextWindowLabel = + contextWindowOptions.length > 1 && contextWindow !== defaultContextWindow + ? (contextWindowOptions.find((o) => o.value === contextWindow)?.label ?? null) + : null; const triggerLabel = [ ultrathinkPromptControlled ? "Ultrathink" @@ -296,6 +347,7 @@ export const TraitsPicker = memo(function TraitsPicker({ ? null : `Thinking ${thinkingEnabled ? "On" : "Off"}`, ...(caps.supportsFastMode && fastModeEnabled ? ["Fast"] : []), + ...(contextWindowLabel ? [contextWindowLabel] : []), ] .filter(Boolean) .join(" · "); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 3d54c526f1..2c7677312f 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -475,12 +475,20 @@ function normalizeProviderModelOptions( : claudeCandidate?.fastMode === false ? false : undefined; + const claudeContextWindow = + typeof claudeCandidate?.contextWindow === "string" && claudeCandidate.contextWindow.length > 0 + ? claudeCandidate.contextWindow + : undefined; const claude = - claudeThinking !== undefined || claudeEffort !== undefined || claudeFastMode !== undefined + claudeThinking !== undefined || + claudeEffort !== undefined || + claudeFastMode !== undefined || + claudeContextWindow !== undefined ? { ...(claudeThinking !== undefined ? { thinking: claudeThinking } : {}), ...(claudeEffort !== undefined ? { effort: claudeEffort } : {}), ...(claudeFastMode !== undefined ? { fastMode: claudeFastMode } : {}), + ...(claudeContextWindow !== undefined ? { contextWindow: claudeContextWindow } : {}), } : undefined; diff --git a/apps/web/src/providerModels.ts b/apps/web/src/providerModels.ts index a925ed690f..5fb1880488 100644 --- a/apps/web/src/providerModels.ts +++ b/apps/web/src/providerModels.ts @@ -18,6 +18,7 @@ const EMPTY_CAPABILITIES: ModelCapabilities = { reasoningEffortLevels: [], supportsFastMode: false, supportsThinkingToggle: false, + contextWindowOptions: [], promptInjectedEffortLevels: [], }; diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 68ca110473..30f7d867d7 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -18,6 +18,8 @@ export const ClaudeModelOptions = Schema.Struct({ thinking: Schema.optional(Schema.Boolean), effort: Schema.optional(Schema.Literals(CLAUDE_CODE_EFFORT_OPTIONS)), fastMode: Schema.optional(Schema.Boolean), + /** API suffix for context window selection (e.g. `"[1m]"`). Omit for default. */ + contextWindow: Schema.optional(Schema.String), }); export type ClaudeModelOptions = typeof ClaudeModelOptions.Type; @@ -34,15 +36,189 @@ export const EffortOption = Schema.Struct({ }); export type EffortOption = typeof EffortOption.Type; +export const ContextWindowOption = Schema.Struct({ + /** API suffix appended to the model slug (e.g. `""` for default, `"[1m]"` for 1M). */ + value: Schema.String, + label: TrimmedNonEmptyString, + isDefault: Schema.optional(Schema.Boolean), +}); +export type ContextWindowOption = typeof ContextWindowOption.Type; + export const ModelCapabilities = Schema.Struct({ reasoningEffortLevels: Schema.Array(EffortOption), supportsFastMode: Schema.Boolean, supportsThinkingToggle: Schema.Boolean, + contextWindowOptions: Schema.Array(ContextWindowOption), promptInjectedEffortLevels: Schema.Array(TrimmedNonEmptyString), }); export type ModelCapabilities = typeof ModelCapabilities.Type; -export type ModelSlug = string & {}; +type ModelDefinition = { + readonly slug: string; + readonly name: string; + readonly capabilities: ModelCapabilities; +}; + +/** + * TODO: This should not be a static array, each provider + * should return its own model list over the WS API. + */ +export const MODEL_OPTIONS_BY_PROVIDER = { + codex: [ + { + slug: "gpt-5.4", + name: "GPT-5.4", + capabilities: { + reasoningEffortLevels: [ + { value: "xhigh", label: "Extra High" }, + { value: "high", label: "High", isDefault: true }, + { value: "medium", label: "Medium" }, + { value: "low", label: "Low" }, + ], + supportsFastMode: true, + supportsThinkingToggle: false, + contextWindowOptions: [], + promptInjectedEffortLevels: [], + }, + }, + { + slug: "gpt-5.4-mini", + name: "GPT-5.4 Mini", + capabilities: { + reasoningEffortLevels: [ + { value: "xhigh", label: "Extra High" }, + { value: "high", label: "High", isDefault: true }, + { value: "medium", label: "Medium" }, + { value: "low", label: "Low" }, + ], + supportsFastMode: true, + supportsThinkingToggle: false, + contextWindowOptions: [], + promptInjectedEffortLevels: [], + }, + }, + { + slug: "gpt-5.3-codex", + name: "GPT-5.3 Codex", + capabilities: { + reasoningEffortLevels: [ + { value: "xhigh", label: "Extra High" }, + { value: "high", label: "High", isDefault: true }, + { value: "medium", label: "Medium" }, + { value: "low", label: "Low" }, + ], + supportsFastMode: true, + supportsThinkingToggle: false, + contextWindowOptions: [], + promptInjectedEffortLevels: [], + }, + }, + { + slug: "gpt-5.3-codex-spark", + name: "GPT-5.3 Codex Spark", + capabilities: { + reasoningEffortLevels: [ + { value: "xhigh", label: "Extra High" }, + { value: "high", label: "High", isDefault: true }, + { value: "medium", label: "Medium" }, + { value: "low", label: "Low" }, + ], + supportsFastMode: true, + supportsThinkingToggle: false, + contextWindowOptions: [], + promptInjectedEffortLevels: [], + }, + }, + { + slug: "gpt-5.2-codex", + name: "GPT-5.2 Codex", + capabilities: { + reasoningEffortLevels: [ + { value: "xhigh", label: "Extra High" }, + { value: "high", label: "High", isDefault: true }, + { value: "medium", label: "Medium" }, + { value: "low", label: "Low" }, + ], + supportsFastMode: true, + supportsThinkingToggle: false, + contextWindowOptions: [], + promptInjectedEffortLevels: [], + }, + }, + { + slug: "gpt-5.2", + name: "GPT-5.2", + capabilities: { + reasoningEffortLevels: [ + { value: "xhigh", label: "Extra High" }, + { value: "high", label: "High", isDefault: true }, + { value: "medium", label: "Medium" }, + { value: "low", label: "Low" }, + ], + supportsFastMode: true, + supportsThinkingToggle: false, + contextWindowOptions: [], + promptInjectedEffortLevels: [], + }, + }, + ], + claudeAgent: [ + { + slug: "claude-opus-4-6", + name: "Claude Opus 4.6", + capabilities: { + reasoningEffortLevels: [ + { value: "low", label: "Low" }, + { value: "medium", label: "Medium" }, + { value: "high", label: "High", isDefault: true }, + { value: "max", label: "Max" }, + { value: "ultrathink", label: "Ultrathink" }, + ], + supportsFastMode: true, + supportsThinkingToggle: false, + contextWindowOptions: [ + { value: "", label: "200k", isDefault: true }, + { value: "[1m]", label: "1M" }, + ], + promptInjectedEffortLevels: ["ultrathink"], + }, + }, + { + slug: "claude-sonnet-4-6", + name: "Claude Sonnet 4.6", + capabilities: { + reasoningEffortLevels: [ + { value: "low", label: "Low" }, + { value: "medium", label: "Medium" }, + { value: "high", label: "High", isDefault: true }, + { value: "ultrathink", label: "Ultrathink" }, + ], + supportsFastMode: false, + supportsThinkingToggle: false, + contextWindowOptions: [ + { value: "", label: "200k", isDefault: true }, + { value: "[1m]", label: "1M" }, + ], + promptInjectedEffortLevels: ["ultrathink"], + }, + }, + { + slug: "claude-haiku-4-5", + name: "Claude Haiku 4.5", + capabilities: { + reasoningEffortLevels: [], + supportsFastMode: false, + supportsThinkingToggle: true, + contextWindowOptions: [], + promptInjectedEffortLevels: [], + }, + }, + ], +} as const satisfies Record; +export type ModelOptionsByProvider = typeof MODEL_OPTIONS_BY_PROVIDER; + +type BuiltInModelSlug = (typeof MODEL_OPTIONS_BY_PROVIDER)[ProviderKind][number]["slug"]; +export type ModelSlug = BuiltInModelSlug | (string & {}); export const DEFAULT_MODEL_BY_PROVIDER: Record = { codex: "gpt-5.4", diff --git a/packages/shared/src/model.test.ts b/packages/shared/src/model.test.ts index 31f0d0a112..110ab11632 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -7,10 +7,15 @@ import { import { applyClaudePromptEffortPrefix, + getDefaultContextWindow, getDefaultEffort, + getModelCapabilities, + hasContextWindowOption, hasEffortLevel, isClaudeUltrathinkPrompt, + normalizeClaudeModelOptions, normalizeModelSlug, + resolveClaudeApiModelId, resolveModelSlug, resolveModelSlugForProvider, resolveSelectableModel, @@ -24,6 +29,7 @@ const codexCaps: ModelCapabilities = { ], supportsFastMode: true, supportsThinkingToggle: false, + contextWindowOptions: [], promptInjectedEffortLevels: [], }; @@ -35,6 +41,7 @@ const claudeCaps: ModelCapabilities = { ], supportsFastMode: false, supportsThinkingToggle: false, + contextWindowOptions: [], promptInjectedEffortLevels: ["ultrathink"], }; @@ -109,3 +116,101 @@ describe("misc helpers", () => { expect(trimOrNull(" ")).toBeNull(); }); }); + +describe("contextWindowOptions capability", () => { + it("offers context window options for Opus 4.6 and Sonnet 4.6", () => { + const opusOpts = getModelCapabilities("claudeAgent", "claude-opus-4-6").contextWindowOptions; + expect(opusOpts.length).toBeGreaterThan(1); + expect(opusOpts.find((o) => o.isDefault)?.value).toBe(""); + expect( + hasContextWindowOption(getModelCapabilities("claudeAgent", "claude-opus-4-6"), "[1m]"), + ).toBe(true); + + const sonnetOpts = getModelCapabilities( + "claudeAgent", + "claude-sonnet-4-6", + ).contextWindowOptions; + expect(sonnetOpts.length).toBeGreaterThan(1); + expect( + hasContextWindowOption(getModelCapabilities("claudeAgent", "claude-sonnet-4-6"), "[1m]"), + ).toBe(true); + }); + + it("has no context window options for Haiku 4.5, unknown models, and Codex", () => { + expect(getModelCapabilities("claudeAgent", "claude-haiku-4-5").contextWindowOptions).toEqual( + [], + ); + expect(getModelCapabilities("claudeAgent", undefined).contextWindowOptions).toEqual([]); + expect(getModelCapabilities("codex", "gpt-5.4").contextWindowOptions).toEqual([]); + }); +}); + +describe("getDefaultContextWindow", () => { + it("returns empty string (default suffix) for models with context window options", () => { + expect(getDefaultContextWindow(getModelCapabilities("claudeAgent", "claude-opus-4-6"))).toBe( + "", + ); + }); + + it("returns empty string for models without context window options", () => { + expect(getDefaultContextWindow(getModelCapabilities("claudeAgent", "claude-haiku-4-5"))).toBe( + "", + ); + }); +}); + +describe("resolveClaudeApiModelId", () => { + it("appends context window suffix when set on a supported model", () => { + expect(resolveClaudeApiModelId("claude-opus-4-6", { contextWindow: "[1m]" })).toBe( + "claude-opus-4-6[1m]", + ); + expect(resolveClaudeApiModelId("claude-sonnet-4-6", { contextWindow: "[1m]" })).toBe( + "claude-sonnet-4-6[1m]", + ); + }); + + it("returns the model as-is when contextWindow is not set", () => { + expect(resolveClaudeApiModelId("claude-opus-4-6", {})).toBe("claude-opus-4-6"); + expect(resolveClaudeApiModelId("claude-opus-4-6", null)).toBe("claude-opus-4-6"); + expect(resolveClaudeApiModelId("claude-opus-4-6", undefined)).toBe("claude-opus-4-6"); + }); + + it("returns the model as-is for the default context window value", () => { + expect(resolveClaudeApiModelId("claude-opus-4-6", { contextWindow: "" })).toBe( + "claude-opus-4-6", + ); + }); + + it("ignores unsupported context window values", () => { + expect(resolveClaudeApiModelId("claude-haiku-4-5", { contextWindow: "[1m]" })).toBe( + "claude-haiku-4-5", + ); + expect(resolveClaudeApiModelId("claude-opus-4-6", { contextWindow: "[bogus]" })).toBe( + "claude-opus-4-6", + ); + }); +}); + +describe("normalizeClaudeModelOptions with contextWindow", () => { + it("preserves non-default contextWindow for supported models", () => { + expect(normalizeClaudeModelOptions("claude-opus-4-6", { contextWindow: "[1m]" })).toEqual({ + contextWindow: "[1m]", + }); + }); + + it("strips contextWindow for unsupported models", () => { + expect( + normalizeClaudeModelOptions("claude-haiku-4-5", { contextWindow: "[1m]" }), + ).toBeUndefined(); + }); + + it("strips contextWindow when it is the default value", () => { + expect(normalizeClaudeModelOptions("claude-opus-4-6", { contextWindow: "" })).toBeUndefined(); + }); + + it("strips unknown contextWindow values", () => { + expect( + normalizeClaudeModelOptions("claude-opus-4-6", { contextWindow: "[bogus]" }), + ).toBeUndefined(); + }); +}); diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index e633aeb293..b055069709 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -1,7 +1,11 @@ import { DEFAULT_MODEL_BY_PROVIDER, + MODEL_OPTIONS_BY_PROVIDER, MODEL_SLUG_ALIASES_BY_PROVIDER, type ClaudeCodeEffort, + type ClaudeModelOptions, + type CodexModelOptions, + type CodexReasoningEffort, type ModelCapabilities, type ModelSlug, type ProviderKind, @@ -28,6 +32,65 @@ export function getDefaultEffort(caps: ModelCapabilities): string | null { return caps.reasoningEffortLevels.find((l) => l.isDefault)?.value ?? null; } +// ── Context window helpers ─────────────────────────────────────────── + +/** Check whether a capabilities object includes a given context window value. */ +export function hasContextWindowOption(caps: ModelCapabilities, value: string): boolean { + return caps.contextWindowOptions.some((o) => o.value === value); +} + +/** Return the default context window value, or `""` if none is defined. */ +export function getDefaultContextWindow(caps: ModelCapabilities): string { + return caps.contextWindowOptions.find((o) => o.isDefault)?.value ?? ""; +} + +/** + * Resolve a raw `contextWindow` option against capabilities. + * Returns the validated non-default value, or `undefined` when it should be + * omitted (default or unsupported). + */ +function resolveContextWindow( + caps: ModelCapabilities, + raw: string | null | undefined, +): string | undefined { + if (!raw) return undefined; + const defaultValue = getDefaultContextWindow(caps); + if (raw === defaultValue) return undefined; + return hasContextWindowOption(caps, raw) ? raw : undefined; +} + +// ── Data-driven capability resolver ─────────────────────────────────── + +const MODEL_CAPABILITIES_INDEX: Record> = (() => { + const index: Record> = {}; + for (const [provider, models] of Object.entries(MODEL_OPTIONS_BY_PROVIDER)) { + const map: Record = {}; + for (const m of models) { + map[m.slug] = m.capabilities as ModelCapabilities; + } + index[provider] = map; + } + return index as Record>; +})(); + +export function getModelCapabilities( + provider: ProviderKind, + model: string | null | undefined, +): ModelCapabilities { + const slug = normalizeModelSlug(model, provider); + if (slug && MODEL_CAPABILITIES_INDEX[provider]?.[slug]) { + return MODEL_CAPABILITIES_INDEX[provider][slug]; + } + return { + reasoningEffortLevels: [], + supportsFastMode: false, + supportsThinkingToggle: false, + contextWindowOptions: [], + promptInjectedEffortLevels: [], + }; +} + + export function isClaudeUltrathinkPrompt(text: string | null | undefined): boolean { return typeof text === "string" && /\bultrathink\b/i.test(text); } @@ -110,6 +173,67 @@ export function trimOrNull(value: T | null | undefined): T | n return trimmed || null; } +export function normalizeCodexModelOptions( + model: string | null | undefined, + modelOptions: CodexModelOptions | null | undefined, +): CodexModelOptions | undefined { + const caps = getModelCapabilities("codex", model); + const defaultReasoningEffort = getDefaultEffort(caps) as CodexReasoningEffort; + const reasoningEffort = trimOrNull(modelOptions?.reasoningEffort) ?? defaultReasoningEffort; + const fastModeEnabled = modelOptions?.fastMode === true; + const nextOptions: CodexModelOptions = { + ...(reasoningEffort !== defaultReasoningEffort ? { reasoningEffort } : {}), + ...(fastModeEnabled ? { fastMode: true } : {}), + }; + return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; +} + +export function normalizeClaudeModelOptions( + model: string | null | undefined, + modelOptions: ClaudeModelOptions | null | undefined, +): ClaudeModelOptions | undefined { + const caps = getModelCapabilities("claudeAgent", model); + const defaultReasoningEffort = getDefaultEffort(caps); + const resolvedEffort = trimOrNull(modelOptions?.effort); + const isPromptInjected = caps.promptInjectedEffortLevels.includes(resolvedEffort ?? ""); + const effort = + resolvedEffort && + !isPromptInjected && + hasEffortLevel(caps, resolvedEffort) && + resolvedEffort !== defaultReasoningEffort + ? resolvedEffort + : undefined; + 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 } : {}), + ...(fastMode ? { fastMode: true } : {}), + ...(contextWindow ? { contextWindow } : {}), + }; + return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; +} + +/** + * Resolve the actual API model identifier for a Claude model selection. + * + * The `contextWindow` option stores an API suffix (e.g. `"[1m]"`) that gets + * appended to the canonical model slug. The slug itself stays unchanged in + * model selections so the capabilities system keeps working. + */ +export function resolveClaudeApiModelId( + model: string, + options: ClaudeModelOptions | null | undefined, +): string { + const suffix = options?.contextWindow; + if (!suffix) return model; + const caps = getModelCapabilities("claudeAgent", model); + return hasContextWindowOption(caps, suffix) ? `${model}${suffix}` : model; +} + + export function applyClaudePromptEffortPrefix( text: string, effort: ClaudeCodeEffort | null | undefined, From 975380b579e89fcddfb191bc623d07ae18d9f915 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 25 Mar 2026 13:57:14 -0700 Subject: [PATCH 02/13] Normalize Claude context window handling - Store context window options as semantic values - Centralize API model-id resolution for Claude selections - Update UI and shared tests for new default handling --- .../src/git/Layers/ClaudeTextGeneration.ts | 4 +- .../src/provider/Layers/ClaudeAdapter.ts | 8 +- .../src/provider/Layers/ClaudeProvider.ts | 8 +- apps/web/src/components/chat/TraitsPicker.tsx | 16 ++- packages/contracts/src/model.ts | 10 +- packages/shared/src/model.test.ts | 99 ++++++++++++------- packages/shared/src/model.ts | 43 +++++--- 7 files changed, 119 insertions(+), 69 deletions(-) diff --git a/apps/server/src/git/Layers/ClaudeTextGeneration.ts b/apps/server/src/git/Layers/ClaudeTextGeneration.ts index ffcd870616..919c3a323d 100644 --- a/apps/server/src/git/Layers/ClaudeTextGeneration.ts +++ b/apps/server/src/git/Layers/ClaudeTextGeneration.ts @@ -11,7 +11,7 @@ import { Effect, Layer, Option, Schema, Stream } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { ClaudeModelSelection } from "@t3tools/contracts"; -import { resolveClaudeApiModelId } from "@t3tools/shared/model"; +import { resolveApiModelId } from "@t3tools/shared/model"; import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; import { TextGenerationError } from "../Errors.ts"; @@ -104,7 +104,7 @@ const makeClaudeTextGeneration = Effect.gen(function* () { "--json-schema", jsonSchemaStr, "--model", - resolveClaudeApiModelId(modelSelection.model, modelSelection.options), + resolveApiModelId(modelSelection), ...(normalizedOptions?.effort ? ["--effort", normalizedOptions.effort] : []), ...(Object.keys(settings).length > 0 ? ["--settings", JSON.stringify(settings)] : []), "--dangerously-skip-permissions", diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index ea4da086ea..0a6cf7546b 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -43,7 +43,7 @@ import { import { hasEffortLevel, applyClaudePromptEffortPrefix, - resolveClaudeApiModelId, + resolveApiModelId, trimOrNull, } from "@t3tools/shared/model"; import { @@ -2732,9 +2732,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { const claudeBinaryPath = claudeSettings.binaryPath; const modelSelection = input.modelSelection?.provider === "claudeAgent" ? input.modelSelection : undefined; - const apiModelId = modelSelection?.model - ? resolveClaudeApiModelId(modelSelection.model, modelSelection.options) - : undefined; + const apiModelId = modelSelection ? resolveApiModelId(modelSelection) : undefined; const requestedEffort = trimOrNull(modelSelection?.options?.effort ?? null); const caps = getClaudeModelCapabilities(modelSelection?.model); const effort = @@ -2901,7 +2899,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { } if (modelSelection?.model) { - const apiModelId = resolveClaudeApiModelId(modelSelection.model, modelSelection.options); + const apiModelId = resolveApiModelId(modelSelection); yield* Effect.tryPromise({ try: () => context.query.setModel(apiModelId), catch: (cause) => toRequestError(input.threadId, "turn/setModel", cause), diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 7777d739bf..e3b1a671a0 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -43,8 +43,8 @@ const BUILT_IN_MODELS: ReadonlyArray = [ supportsFastMode: true, supportsThinkingToggle: false, contextWindowOptions: [ - { value: "", label: "200k", isDefault: true }, - { value: "[1m]", label: "1M" }, + { value: "200k", label: "200k", isDefault: true }, + { value: "1m", label: "1M" }, ], promptInjectedEffortLevels: ["ultrathink"], } satisfies ModelCapabilities, @@ -63,8 +63,8 @@ const BUILT_IN_MODELS: ReadonlyArray = [ supportsFastMode: false, supportsThinkingToggle: false, contextWindowOptions: [ - { value: "", label: "200k", isDefault: true }, - { value: "[1m]", label: "1M" }, + { value: "200k", label: "200k", isDefault: true }, + { value: "1m", label: "1M" }, ], promptInjectedEffortLevels: ["ultrathink"], } satisfies ModelCapabilities, diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index 47327838ab..f861f4a6d6 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -55,6 +55,16 @@ function getRawEffort( return trimOrNull((modelOptions as ClaudeModelOptions | undefined)?.effort); } +function getRawContextWindow( + provider: ProviderKind, + modelOptions: ProviderOptions | null | undefined, +): string | null { + if (provider === "claudeAgent") { + return trimOrNull((modelOptions as ClaudeModelOptions | undefined)?.contextWindow); + } + return null; +} + function buildNextOptions( provider: ProviderKind, modelOptions: ProviderOptions | null | undefined, @@ -108,7 +118,7 @@ function getSelectedTraits( // Context window const contextWindowOptions = caps.contextWindowOptions; - const rawContextWindow = (modelOptions as ClaudeModelOptions | undefined)?.contextWindow ?? ""; + const rawContextWindow = getRawContextWindow(provider, modelOptions); const defaultContextWindow = getDefaultContextWindow(caps); const contextWindow = rawContextWindow && hasContextWindowOption(caps, rawContextWindow) @@ -283,11 +293,11 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ Context Window { updateModelOptions( buildNextOptions(provider, modelOptions, { - contextWindow: value || undefined, + contextWindow: value === defaultContextWindow ? undefined : value, }), ); }} diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 30f7d867d7..39d88bcd33 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -37,7 +37,7 @@ export const EffortOption = Schema.Struct({ export type EffortOption = typeof EffortOption.Type; export const ContextWindowOption = Schema.Struct({ - /** API suffix appended to the model slug (e.g. `""` for default, `"[1m]"` for 1M). */ + /** Semantic identifier stored in model options (e.g. `"200k"`, `"1m"`). */ value: Schema.String, label: TrimmedNonEmptyString, isDefault: Schema.optional(Schema.Boolean), @@ -177,8 +177,8 @@ export const MODEL_OPTIONS_BY_PROVIDER = { supportsFastMode: true, supportsThinkingToggle: false, contextWindowOptions: [ - { value: "", label: "200k", isDefault: true }, - { value: "[1m]", label: "1M" }, + { value: "200k", label: "200k", isDefault: true }, + { value: "1m", label: "1M" }, ], promptInjectedEffortLevels: ["ultrathink"], }, @@ -196,8 +196,8 @@ export const MODEL_OPTIONS_BY_PROVIDER = { supportsFastMode: false, supportsThinkingToggle: false, contextWindowOptions: [ - { value: "", label: "200k", isDefault: true }, - { value: "[1m]", label: "1M" }, + { value: "200k", label: "200k", isDefault: true }, + { value: "1m", label: "1M" }, ], promptInjectedEffortLevels: ["ultrathink"], }, diff --git a/packages/shared/src/model.test.ts b/packages/shared/src/model.test.ts index 110ab11632..2c4c3a1d93 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -15,7 +15,7 @@ import { isClaudeUltrathinkPrompt, normalizeClaudeModelOptions, normalizeModelSlug, - resolveClaudeApiModelId, + resolveApiModelId, resolveModelSlug, resolveModelSlugForProvider, resolveSelectableModel, @@ -121,9 +121,9 @@ describe("contextWindowOptions capability", () => { it("offers context window options for Opus 4.6 and Sonnet 4.6", () => { const opusOpts = getModelCapabilities("claudeAgent", "claude-opus-4-6").contextWindowOptions; expect(opusOpts.length).toBeGreaterThan(1); - expect(opusOpts.find((o) => o.isDefault)?.value).toBe(""); + expect(opusOpts.find((o) => o.isDefault)?.value).toBe("200k"); expect( - hasContextWindowOption(getModelCapabilities("claudeAgent", "claude-opus-4-6"), "[1m]"), + hasContextWindowOption(getModelCapabilities("claudeAgent", "claude-opus-4-6"), "1m"), ).toBe(true); const sonnetOpts = getModelCapabilities( @@ -132,7 +132,7 @@ describe("contextWindowOptions capability", () => { ).contextWindowOptions; expect(sonnetOpts.length).toBeGreaterThan(1); expect( - hasContextWindowOption(getModelCapabilities("claudeAgent", "claude-sonnet-4-6"), "[1m]"), + hasContextWindowOption(getModelCapabilities("claudeAgent", "claude-sonnet-4-6"), "1m"), ).toBe(true); }); @@ -146,71 +146,100 @@ describe("contextWindowOptions capability", () => { }); describe("getDefaultContextWindow", () => { - it("returns empty string (default suffix) for models with context window options", () => { + it("returns the default option value for models with context window options", () => { expect(getDefaultContextWindow(getModelCapabilities("claudeAgent", "claude-opus-4-6"))).toBe( - "", + "200k", ); }); - it("returns empty string for models without context window options", () => { - expect(getDefaultContextWindow(getModelCapabilities("claudeAgent", "claude-haiku-4-5"))).toBe( - "", - ); + it("returns null for models without context window options", () => { + expect( + getDefaultContextWindow(getModelCapabilities("claudeAgent", "claude-haiku-4-5")), + ).toBeNull(); }); }); -describe("resolveClaudeApiModelId", () => { - it("appends context window suffix when set on a supported model", () => { - expect(resolveClaudeApiModelId("claude-opus-4-6", { contextWindow: "[1m]" })).toBe( - "claude-opus-4-6[1m]", - ); - expect(resolveClaudeApiModelId("claude-sonnet-4-6", { contextWindow: "[1m]" })).toBe( - "claude-sonnet-4-6[1m]", - ); +describe("resolveApiModelId", () => { + it("appends provider-specific suffix for Claude context window", () => { + expect( + resolveApiModelId({ + provider: "claudeAgent", + model: "claude-opus-4-6", + options: { contextWindow: "1m" }, + }), + ).toBe("claude-opus-4-6[1m]"); + expect( + resolveApiModelId({ + provider: "claudeAgent", + model: "claude-sonnet-4-6", + options: { contextWindow: "1m" }, + }), + ).toBe("claude-sonnet-4-6[1m]"); }); it("returns the model as-is when contextWindow is not set", () => { - expect(resolveClaudeApiModelId("claude-opus-4-6", {})).toBe("claude-opus-4-6"); - expect(resolveClaudeApiModelId("claude-opus-4-6", null)).toBe("claude-opus-4-6"); - expect(resolveClaudeApiModelId("claude-opus-4-6", undefined)).toBe("claude-opus-4-6"); + expect(resolveApiModelId({ provider: "claudeAgent", model: "claude-opus-4-6" })).toBe( + "claude-opus-4-6", + ); + expect( + resolveApiModelId({ provider: "claudeAgent", model: "claude-opus-4-6", options: {} }), + ).toBe("claude-opus-4-6"); }); it("returns the model as-is for the default context window value", () => { - expect(resolveClaudeApiModelId("claude-opus-4-6", { contextWindow: "" })).toBe( - "claude-opus-4-6", - ); + expect( + resolveApiModelId({ + provider: "claudeAgent", + model: "claude-opus-4-6", + options: { contextWindow: "200k" }, + }), + ).toBe("claude-opus-4-6"); }); it("ignores unsupported context window values", () => { - expect(resolveClaudeApiModelId("claude-haiku-4-5", { contextWindow: "[1m]" })).toBe( - "claude-haiku-4-5", - ); - expect(resolveClaudeApiModelId("claude-opus-4-6", { contextWindow: "[bogus]" })).toBe( - "claude-opus-4-6", - ); + expect( + resolveApiModelId({ + provider: "claudeAgent", + model: "claude-haiku-4-5", + options: { contextWindow: "1m" }, + }), + ).toBe("claude-haiku-4-5"); + expect( + resolveApiModelId({ + provider: "claudeAgent", + model: "claude-opus-4-6", + options: { contextWindow: "bogus" }, + }), + ).toBe("claude-opus-4-6"); + }); + + it("returns the model as-is for Codex selections", () => { + expect(resolveApiModelId({ provider: "codex", model: "gpt-5.4" })).toBe("gpt-5.4"); }); }); describe("normalizeClaudeModelOptions with contextWindow", () => { it("preserves non-default contextWindow for supported models", () => { - expect(normalizeClaudeModelOptions("claude-opus-4-6", { contextWindow: "[1m]" })).toEqual({ - contextWindow: "[1m]", + expect(normalizeClaudeModelOptions("claude-opus-4-6", { contextWindow: "1m" })).toEqual({ + contextWindow: "1m", }); }); it("strips contextWindow for unsupported models", () => { expect( - normalizeClaudeModelOptions("claude-haiku-4-5", { contextWindow: "[1m]" }), + normalizeClaudeModelOptions("claude-haiku-4-5", { contextWindow: "1m" }), ).toBeUndefined(); }); it("strips contextWindow when it is the default value", () => { - expect(normalizeClaudeModelOptions("claude-opus-4-6", { contextWindow: "" })).toBeUndefined(); + expect( + normalizeClaudeModelOptions("claude-opus-4-6", { contextWindow: "200k" }), + ).toBeUndefined(); }); it("strips unknown contextWindow values", () => { expect( - normalizeClaudeModelOptions("claude-opus-4-6", { contextWindow: "[bogus]" }), + normalizeClaudeModelOptions("claude-opus-4-6", { contextWindow: "bogus" }), ).toBeUndefined(); }); }); diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index b055069709..b7b1f0e034 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -7,6 +7,7 @@ import { type CodexModelOptions, type CodexReasoningEffort, type ModelCapabilities, + type ModelSelection, type ModelSlug, type ProviderKind, } from "@t3tools/contracts"; @@ -39,9 +40,9 @@ export function hasContextWindowOption(caps: ModelCapabilities, value: string): return caps.contextWindowOptions.some((o) => o.value === value); } -/** Return the default context window value, or `""` if none is defined. */ -export function getDefaultContextWindow(caps: ModelCapabilities): string { - return caps.contextWindowOptions.find((o) => o.isDefault)?.value ?? ""; +/** Return the default context window value, or `null` if none is defined. */ +export function getDefaultContextWindow(caps: ModelCapabilities): string | null { + return caps.contextWindowOptions.find((o) => o.isDefault)?.value ?? null; } /** @@ -216,21 +217,33 @@ export function normalizeClaudeModelOptions( return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; } +// ── Claude context-window → API model-id mapping ──────────────────── + +/** Maps a context-window option value to the Claude API model suffix. */ +const CLAUDE_CONTEXT_WINDOW_MODEL_SUFFIX: Record = { + "1m": "[1m]", +}; + /** - * Resolve the actual API model identifier for a Claude model selection. + * Resolve the actual API model identifier from a full model selection. * - * The `contextWindow` option stores an API suffix (e.g. `"[1m]"`) that gets - * appended to the canonical model slug. The slug itself stays unchanged in - * model selections so the capabilities system keeps working. + * Provider-aware: each provider can map `contextWindow` (or other options) + * to whatever the API requires — a model-id suffix, a separate parameter, etc. + * The canonical slug stored in the selection stays unchanged so the + * capabilities system keeps working. */ -export function resolveClaudeApiModelId( - model: string, - options: ClaudeModelOptions | null | undefined, -): string { - const suffix = options?.contextWindow; - if (!suffix) return model; - const caps = getModelCapabilities("claudeAgent", model); - return hasContextWindowOption(caps, suffix) ? `${model}${suffix}` : model; +export function resolveApiModelId(modelSelection: ModelSelection): string { + if (modelSelection.provider === "claudeAgent") { + const contextWindow = modelSelection.options?.contextWindow; + if (contextWindow) { + const caps = getModelCapabilities("claudeAgent", modelSelection.model); + if (hasContextWindowOption(caps, contextWindow)) { + const suffix = CLAUDE_CONTEXT_WINDOW_MODEL_SUFFIX[contextWindow]; + if (suffix) return `${modelSelection.model}${suffix}`; + } + } + } + return modelSelection.model; } From 7524b128e4af5114338cc0df7888079834a87d50 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 26 Mar 2026 09:32:01 -0700 Subject: [PATCH 03/13] Add context window options to model capabilities - Extend server and test model capability shapes with `contextWindowOptions` - Update chat picker fixtures to match the new shared model contract --- apps/server/src/provider/Layers/CodexProvider.ts | 1 + .../components/chat/CompactComposerControlsMenu.browser.tsx | 4 ++++ apps/web/src/components/chat/ProviderModelPicker.browser.tsx | 5 +++++ apps/web/src/components/chat/TraitsPicker.browser.tsx | 4 ++++ .../src/components/chat/composerProviderRegistry.test.tsx | 4 ++++ packages/shared/src/model.ts | 2 -- 6 files changed, 18 insertions(+), 2 deletions(-) diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index b6285e6e5f..6123553a31 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -146,6 +146,7 @@ export function getCodexModelCapabilities(model: string | null | undefined): Mod reasoningEffortLevels: [], supportsFastMode: false, supportsThinkingToggle: false, + contextWindowOptions: [], promptInjectedEffortLevels: [], } ); diff --git a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx index 8770e58138..aa3550e007 100644 --- a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx +++ b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx @@ -60,6 +60,7 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str ], supportsFastMode: true, supportsThinkingToggle: false, + contextWindowOptions: [], promptInjectedEffortLevels: ["ultrathink"], }, }, @@ -71,6 +72,7 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str reasoningEffortLevels: [], supportsFastMode: false, supportsThinkingToggle: true, + contextWindowOptions: [], promptInjectedEffortLevels: [], }, }, @@ -87,6 +89,7 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str ], supportsFastMode: false, supportsThinkingToggle: false, + contextWindowOptions: [], promptInjectedEffortLevels: ["ultrathink"], }, }, @@ -103,6 +106,7 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str ], supportsFastMode: true, supportsThinkingToggle: false, + contextWindowOptions: [], promptInjectedEffortLevels: [], }, }, diff --git a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx index fe878e7c18..c911c9c0fe 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx @@ -33,6 +33,7 @@ const TEST_PROVIDERS: ReadonlyArray = [ reasoningEffortLevels: [effort("low"), effort("medium", true), effort("high")], supportsFastMode: true, supportsThinkingToggle: false, + contextWindowOptions: [], promptInjectedEffortLevels: [], }, }, @@ -44,6 +45,7 @@ const TEST_PROVIDERS: ReadonlyArray = [ reasoningEffortLevels: [effort("low"), effort("medium", true), effort("high")], supportsFastMode: true, supportsThinkingToggle: false, + contextWindowOptions: [], promptInjectedEffortLevels: [], }, }, @@ -71,6 +73,7 @@ const TEST_PROVIDERS: ReadonlyArray = [ ], supportsFastMode: false, supportsThinkingToggle: true, + contextWindowOptions: [], promptInjectedEffortLevels: [], }, }, @@ -87,6 +90,7 @@ const TEST_PROVIDERS: ReadonlyArray = [ ], supportsFastMode: false, supportsThinkingToggle: true, + contextWindowOptions: [], promptInjectedEffortLevels: [], }, }, @@ -98,6 +102,7 @@ const TEST_PROVIDERS: ReadonlyArray = [ reasoningEffortLevels: [effort("low"), effort("medium", true), effort("high")], supportsFastMode: false, supportsThinkingToggle: true, + contextWindowOptions: [], promptInjectedEffortLevels: [], }, }, diff --git a/apps/web/src/components/chat/TraitsPicker.browser.tsx b/apps/web/src/components/chat/TraitsPicker.browser.tsx index bd8c61ee56..99d09fd634 100644 --- a/apps/web/src/components/chat/TraitsPicker.browser.tsx +++ b/apps/web/src/components/chat/TraitsPicker.browser.tsx @@ -49,6 +49,7 @@ const TEST_PROVIDERS: ReadonlyArray = [ ], supportsFastMode: true, supportsThinkingToggle: false, + contextWindowOptions: [], promptInjectedEffortLevels: [], }, }, @@ -77,6 +78,7 @@ const TEST_PROVIDERS: ReadonlyArray = [ ], supportsFastMode: true, supportsThinkingToggle: false, + contextWindowOptions: [], promptInjectedEffortLevels: ["ultrathink"], }, }, @@ -93,6 +95,7 @@ const TEST_PROVIDERS: ReadonlyArray = [ ], supportsFastMode: false, supportsThinkingToggle: false, + contextWindowOptions: [], promptInjectedEffortLevels: ["ultrathink"], }, }, @@ -104,6 +107,7 @@ const TEST_PROVIDERS: ReadonlyArray = [ reasoningEffortLevels: [], supportsFastMode: false, supportsThinkingToggle: true, + contextWindowOptions: [], promptInjectedEffortLevels: [], }, }, diff --git a/apps/web/src/components/chat/composerProviderRegistry.test.tsx b/apps/web/src/components/chat/composerProviderRegistry.test.tsx index d5fbb1333a..ed9062a6a8 100644 --- a/apps/web/src/components/chat/composerProviderRegistry.test.tsx +++ b/apps/web/src/components/chat/composerProviderRegistry.test.tsx @@ -16,6 +16,7 @@ const CODEX_MODELS: ReadonlyArray = [ ], supportsFastMode: true, supportsThinkingToggle: false, + contextWindowOptions: [], promptInjectedEffortLevels: [], }, }, @@ -35,6 +36,7 @@ const CLAUDE_MODELS: ReadonlyArray = [ ], supportsFastMode: true, supportsThinkingToggle: false, + contextWindowOptions: [], promptInjectedEffortLevels: ["ultrathink"], }, }, @@ -51,6 +53,7 @@ const CLAUDE_MODELS: ReadonlyArray = [ ], supportsFastMode: false, supportsThinkingToggle: false, + contextWindowOptions: [], promptInjectedEffortLevels: ["ultrathink"], }, }, @@ -62,6 +65,7 @@ const CLAUDE_MODELS: ReadonlyArray = [ reasoningEffortLevels: [], supportsFastMode: false, supportsThinkingToggle: true, + contextWindowOptions: [], promptInjectedEffortLevels: [], }, }, diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index b7b1f0e034..a128929952 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -91,7 +91,6 @@ export function getModelCapabilities( }; } - export function isClaudeUltrathinkPrompt(text: string | null | undefined): boolean { return typeof text === "string" && /\bultrathink\b/i.test(text); } @@ -246,7 +245,6 @@ export function resolveApiModelId(modelSelection: ModelSelection): string { return modelSelection.model; } - export function applyClaudePromptEffortPrefix( text: string, effort: ClaudeCodeEffort | null | undefined, From 60610ce031596964fdf6429d6fb383412ab04ce7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 26 Mar 2026 09:47:51 -0700 Subject: [PATCH 04/13] nit --- packages/contracts/src/model.ts | 9 ++++----- packages/shared/src/model.ts | 31 ++++++++++++++++--------------- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 39d88bcd33..2d91de7656 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -18,7 +18,6 @@ export const ClaudeModelOptions = Schema.Struct({ thinking: Schema.optional(Schema.Boolean), effort: Schema.optional(Schema.Literals(CLAUDE_CODE_EFFORT_OPTIONS)), fastMode: Schema.optional(Schema.Boolean), - /** API suffix for context window selection (e.g. `"[1m]"`). Omit for default. */ contextWindow: Schema.optional(Schema.String), }); export type ClaudeModelOptions = typeof ClaudeModelOptions.Type; @@ -177,8 +176,8 @@ export const MODEL_OPTIONS_BY_PROVIDER = { supportsFastMode: true, supportsThinkingToggle: false, contextWindowOptions: [ - { value: "200k", label: "200k", isDefault: true }, - { value: "1m", label: "1M" }, + { value: "200k", label: "200k" }, + { value: "1m", label: "1M", isDefault: true }, ], promptInjectedEffortLevels: ["ultrathink"], }, @@ -196,8 +195,8 @@ export const MODEL_OPTIONS_BY_PROVIDER = { supportsFastMode: false, supportsThinkingToggle: false, contextWindowOptions: [ - { value: "200k", label: "200k", isDefault: true }, - { value: "1m", label: "1M" }, + { value: "200k", label: "200k" }, + { value: "1m", label: "1M", isDefault: true }, ], promptInjectedEffortLevels: ["ultrathink"], }, diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index a128929952..ed94292cc5 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -216,13 +216,6 @@ export function normalizeClaudeModelOptions( return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; } -// ── Claude context-window → API model-id mapping ──────────────────── - -/** Maps a context-window option value to the Claude API model suffix. */ -const CLAUDE_CONTEXT_WINDOW_MODEL_SUFFIX: Record = { - "1m": "[1m]", -}; - /** * Resolve the actual API model identifier from a full model selection. * @@ -232,17 +225,25 @@ const CLAUDE_CONTEXT_WINDOW_MODEL_SUFFIX: Record = { * capabilities system keeps working. */ export function resolveApiModelId(modelSelection: ModelSelection): string { - if (modelSelection.provider === "claudeAgent") { - const contextWindow = modelSelection.options?.contextWindow; - if (contextWindow) { - const caps = getModelCapabilities("claudeAgent", modelSelection.model); - if (hasContextWindowOption(caps, contextWindow)) { - const suffix = CLAUDE_CONTEXT_WINDOW_MODEL_SUFFIX[contextWindow]; - if (suffix) return `${modelSelection.model}${suffix}`; + const caps = getModelCapabilities(modelSelection.provider, modelSelection.model); + switch (modelSelection.provider) { + case "claudeAgent": { + const contextWindow = modelSelection.options?.contextWindow; + if (contextWindow) { + if (hasContextWindowOption(caps, contextWindow)) { + switch (contextWindow) { + case "1m": + return `${modelSelection.model}[1m]`; + default: + return modelSelection.model; + } + } } } + default: { + return modelSelection.model; + } } - return modelSelection.model; } export function applyClaudePromptEffortPrefix( From 2e89766ee8e6ec28800c2d5a5283e7c3fd9980a3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 26 Mar 2026 10:17:01 -0700 Subject: [PATCH 05/13] Widen model IDs and preserve Claude context windows - Allow arbitrary model strings across client and shared model code - Normalize Claude context-window options through shared helpers - Update shared model tests for the revised capability handling --- .../src/provider/Layers/ClaudeProvider.ts | 9 +- apps/web/src/components/ChatView.tsx | 3 +- .../components/chat/ComposerCommandMenu.tsx | 4 +- .../chat/ProviderModelPicker.browser.tsx | 4 +- .../components/chat/ProviderModelPicker.tsx | 6 +- .../chat/composerProviderRegistry.tsx | 11 +- apps/web/src/composerDraftStore.ts | 3 +- apps/web/src/providerModels.ts | 3 + packages/contracts/src/model.ts | 172 +----------------- packages/shared/src/model.test.ts | 87 ++------- packages/shared/src/model.ts | 120 ++---------- 11 files changed, 61 insertions(+), 361 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index e3b1a671a0..0bdbbee312 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -9,7 +9,12 @@ import type { } from "@t3tools/contracts"; import { Effect, Equal, Layer, Option, Result, Stream } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { getDefaultEffort, hasEffortLevel, trimOrNull } from "@t3tools/shared/model"; +import { + getDefaultEffort, + hasEffortLevel, + resolveContextWindow, + trimOrNull, +} from "@t3tools/shared/model"; import { buildServerProvider, @@ -114,10 +119,12 @@ export function normalizeClaudeModelOptions( 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 } : {}), ...(fastMode ? { fastMode: true } : {}), + ...(contextWindow ? { contextWindow } : {}), }; return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 849f59e088..b0129ab75a 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -5,7 +5,6 @@ import { type MessageId, type ModelSelection, type ProjectScript, - type ModelSlug, type ProviderKind, type ProjectEntry, type ProjectId, @@ -3105,7 +3104,7 @@ export default function ChatView({ threadId }: ChatViewProps) { ]); const onProviderModelSelect = useCallback( - (provider: ProviderKind, model: ModelSlug) => { + (provider: ProviderKind, model: string) => { if (!activeThread) return; if (lockedProvider !== null && provider !== lockedProvider) { scheduleComposerFocus(); diff --git a/apps/web/src/components/chat/ComposerCommandMenu.tsx b/apps/web/src/components/chat/ComposerCommandMenu.tsx index 818c3c20f8..7af4e6da43 100644 --- a/apps/web/src/components/chat/ComposerCommandMenu.tsx +++ b/apps/web/src/components/chat/ComposerCommandMenu.tsx @@ -1,4 +1,4 @@ -import { type ProjectEntry, type ModelSlug, type ProviderKind } from "@t3tools/contracts"; +import { type ProjectEntry, type ProviderKind } from "@t3tools/contracts"; import { memo } from "react"; import { type ComposerSlashCommand, type ComposerTriggerKind } from "../../composer-logic"; import { BotIcon } from "lucide-react"; @@ -27,7 +27,7 @@ export type ComposerCommandItem = id: string; type: "model"; provider: ProviderKind; - model: ModelSlug; + model: string; label: string; description: string; }; diff --git a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx index c911c9c0fe..679cbff321 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx @@ -1,4 +1,4 @@ -import { type ModelSlug, type ProviderKind, type ServerProvider } from "@t3tools/contracts"; +import { type ProviderKind, type ServerProvider } from "@t3tools/contracts"; import { page } from "vitest/browser"; import { afterEach, describe, expect, it, vi } from "vitest"; import { render } from "vitest-browser-react"; @@ -112,7 +112,7 @@ const TEST_PROVIDERS: ReadonlyArray = [ async function mountPicker(props: { provider: ProviderKind; - model: ModelSlug; + model: string; lockedProvider: ProviderKind | null; providers?: ReadonlyArray; triggerVariant?: "ghost" | "outline"; diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index 5a09defc72..565a9d399d 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -1,4 +1,4 @@ -import { type ModelSlug, type ProviderKind, type ServerProvider } from "@t3tools/contracts"; +import { type ProviderKind, type ServerProvider } from "@t3tools/contracts"; import { resolveSelectableModel } from "@t3tools/shared/model"; import { memo, useState } from "react"; import type { VariantProps } from "class-variance-authority"; @@ -52,7 +52,7 @@ function providerIconClassName( export const ProviderModelPicker = memo(function ProviderModelPicker(props: { provider: ProviderKind; - model: ModelSlug; + model: string; lockedProvider: ProviderKind | null; providers?: ReadonlyArray; modelOptionsByProvider: Record>; @@ -61,7 +61,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { disabled?: boolean; triggerVariant?: VariantProps["variant"]; triggerClassName?: string; - onProviderModelChange: (provider: ProviderKind, model: ModelSlug) => void; + onProviderModelChange: (provider: ProviderKind, model: string) => void; }) { const [isMenuOpen, setIsMenuOpen] = useState(false); const activeProvider = props.lockedProvider ?? props.provider; diff --git a/apps/web/src/components/chat/composerProviderRegistry.tsx b/apps/web/src/components/chat/composerProviderRegistry.tsx index 2cebd8d4f4..a1d9a1dab8 100644 --- a/apps/web/src/components/chat/composerProviderRegistry.tsx +++ b/apps/web/src/components/chat/composerProviderRegistry.tsx @@ -1,5 +1,4 @@ import { - type ModelSlug, type ProviderKind, type ProviderModelOptions, type ServerProviderModel, @@ -21,7 +20,7 @@ import { TraitsMenuContent, TraitsPicker } from "./TraitsPicker"; export type ComposerProviderStateInput = { provider: ProviderKind; - model: ModelSlug; + model: string; models: ReadonlyArray; prompt: string; modelOptions: ProviderModelOptions | null | undefined; @@ -40,7 +39,7 @@ type ProviderRegistryEntry = { getState: (input: ComposerProviderStateInput) => ComposerProviderState; renderTraitsMenuContent: (input: { threadId: ThreadId; - model: ModelSlug; + model: string; models: ReadonlyArray; modelOptions: ProviderModelOptions[ProviderKind] | undefined; prompt: string; @@ -48,7 +47,7 @@ type ProviderRegistryEntry = { }) => ReactNode; renderTraitsPicker: (input: { threadId: ThreadId; - model: ModelSlug; + model: string; models: ReadonlyArray; modelOptions: ProviderModelOptions[ProviderKind] | undefined; prompt: string; @@ -180,7 +179,7 @@ export function getComposerProviderState(input: ComposerProviderStateInput): Com export function renderProviderTraitsMenuContent(input: { provider: ProviderKind; threadId: ThreadId; - model: ModelSlug; + model: string; models: ReadonlyArray; modelOptions: ProviderModelOptions[ProviderKind] | undefined; prompt: string; @@ -199,7 +198,7 @@ export function renderProviderTraitsMenuContent(input: { export function renderProviderTraitsPicker(input: { provider: ProviderKind; threadId: ThreadId; - model: ModelSlug; + model: string; models: ReadonlyArray; modelOptions: ProviderModelOptions[ProviderKind] | undefined; prompt: string; diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 2c7677312f..ccb0a38f17 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -2,7 +2,6 @@ import { CODEX_REASONING_EFFORT_OPTIONS, type ClaudeCodeEffort, type CodexReasoningEffort, - type ModelSlug, ModelSelection, ProjectId, ProviderInteractionMode, @@ -263,7 +262,7 @@ interface ComposerDraftStoreState { } export interface EffectiveComposerModelState { - selectedModel: ModelSlug; + selectedModel: string; modelOptions: ProviderModelOptions | null; } diff --git a/apps/web/src/providerModels.ts b/apps/web/src/providerModels.ts index 5fb1880488..3e3244ca28 100644 --- a/apps/web/src/providerModels.ts +++ b/apps/web/src/providerModels.ts @@ -11,6 +11,7 @@ import { getDefaultEffort, hasEffortLevel, normalizeModelSlug, + resolveContextWindow, trimOrNull, } from "@t3tools/shared/model"; @@ -108,10 +109,12 @@ export function normalizeClaudeModelOptionsWithCapabilities( 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 } : {}), ...(fastMode ? { fastMode: true } : {}), + ...(contextWindow ? { contextWindow } : {}), }; return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; } diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 2d91de7656..4e4db4acb2 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -36,7 +36,6 @@ export const EffortOption = Schema.Struct({ export type EffortOption = typeof EffortOption.Type; export const ContextWindowOption = Schema.Struct({ - /** Semantic identifier stored in model options (e.g. `"200k"`, `"1m"`). */ value: Schema.String, label: TrimmedNonEmptyString, isDefault: Schema.optional(Schema.Boolean), @@ -52,174 +51,7 @@ export const ModelCapabilities = Schema.Struct({ }); export type ModelCapabilities = typeof ModelCapabilities.Type; -type ModelDefinition = { - readonly slug: string; - readonly name: string; - readonly capabilities: ModelCapabilities; -}; - -/** - * TODO: This should not be a static array, each provider - * should return its own model list over the WS API. - */ -export const MODEL_OPTIONS_BY_PROVIDER = { - codex: [ - { - slug: "gpt-5.4", - name: "GPT-5.4", - capabilities: { - reasoningEffortLevels: [ - { value: "xhigh", label: "Extra High" }, - { value: "high", label: "High", isDefault: true }, - { value: "medium", label: "Medium" }, - { value: "low", label: "Low" }, - ], - supportsFastMode: true, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, - }, - { - slug: "gpt-5.4-mini", - name: "GPT-5.4 Mini", - capabilities: { - reasoningEffortLevels: [ - { value: "xhigh", label: "Extra High" }, - { value: "high", label: "High", isDefault: true }, - { value: "medium", label: "Medium" }, - { value: "low", label: "Low" }, - ], - supportsFastMode: true, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, - }, - { - slug: "gpt-5.3-codex", - name: "GPT-5.3 Codex", - capabilities: { - reasoningEffortLevels: [ - { value: "xhigh", label: "Extra High" }, - { value: "high", label: "High", isDefault: true }, - { value: "medium", label: "Medium" }, - { value: "low", label: "Low" }, - ], - supportsFastMode: true, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, - }, - { - slug: "gpt-5.3-codex-spark", - name: "GPT-5.3 Codex Spark", - capabilities: { - reasoningEffortLevels: [ - { value: "xhigh", label: "Extra High" }, - { value: "high", label: "High", isDefault: true }, - { value: "medium", label: "Medium" }, - { value: "low", label: "Low" }, - ], - supportsFastMode: true, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, - }, - { - slug: "gpt-5.2-codex", - name: "GPT-5.2 Codex", - capabilities: { - reasoningEffortLevels: [ - { value: "xhigh", label: "Extra High" }, - { value: "high", label: "High", isDefault: true }, - { value: "medium", label: "Medium" }, - { value: "low", label: "Low" }, - ], - supportsFastMode: true, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, - }, - { - slug: "gpt-5.2", - name: "GPT-5.2", - capabilities: { - reasoningEffortLevels: [ - { value: "xhigh", label: "Extra High" }, - { value: "high", label: "High", isDefault: true }, - { value: "medium", label: "Medium" }, - { value: "low", label: "Low" }, - ], - supportsFastMode: true, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, - }, - ], - claudeAgent: [ - { - slug: "claude-opus-4-6", - name: "Claude Opus 4.6", - capabilities: { - reasoningEffortLevels: [ - { value: "low", label: "Low" }, - { 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" }, - { value: "1m", label: "1M", isDefault: true }, - ], - promptInjectedEffortLevels: ["ultrathink"], - }, - }, - { - slug: "claude-sonnet-4-6", - name: "Claude Sonnet 4.6", - capabilities: { - reasoningEffortLevels: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High", isDefault: true }, - { value: "ultrathink", label: "Ultrathink" }, - ], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [ - { value: "200k", label: "200k" }, - { value: "1m", label: "1M", isDefault: true }, - ], - promptInjectedEffortLevels: ["ultrathink"], - }, - }, - { - slug: "claude-haiku-4-5", - name: "Claude Haiku 4.5", - capabilities: { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: true, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, - }, - ], -} as const satisfies Record; -export type ModelOptionsByProvider = typeof MODEL_OPTIONS_BY_PROVIDER; - -type BuiltInModelSlug = (typeof MODEL_OPTIONS_BY_PROVIDER)[ProviderKind][number]["slug"]; -export type ModelSlug = BuiltInModelSlug | (string & {}); - -export const DEFAULT_MODEL_BY_PROVIDER: Record = { +export const DEFAULT_MODEL_BY_PROVIDER: Record = { codex: "gpt-5.4", claudeAgent: "claude-sonnet-4-6", }; @@ -232,7 +64,7 @@ export const DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER: Record> = { +export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record> = { codex: { "5.4": "gpt-5.4", "5.3": "gpt-5.3-codex", diff --git a/packages/shared/src/model.test.ts b/packages/shared/src/model.test.ts index 2c4c3a1d93..c30150d825 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -9,11 +9,9 @@ import { applyClaudePromptEffortPrefix, getDefaultContextWindow, getDefaultEffort, - getModelCapabilities, hasContextWindowOption, hasEffortLevel, isClaudeUltrathinkPrompt, - normalizeClaudeModelOptions, normalizeModelSlug, resolveApiModelId, resolveModelSlug, @@ -41,7 +39,10 @@ const claudeCaps: ModelCapabilities = { ], supportsFastMode: false, supportsThinkingToggle: false, - contextWindowOptions: [], + contextWindowOptions: [ + { value: "200k", label: "200k", isDefault: true }, + { value: "1m", label: "1M" }, + ], promptInjectedEffortLevels: ["ultrathink"], }; @@ -117,45 +118,20 @@ describe("misc helpers", () => { }); }); -describe("contextWindowOptions capability", () => { - it("offers context window options for Opus 4.6 and Sonnet 4.6", () => { - const opusOpts = getModelCapabilities("claudeAgent", "claude-opus-4-6").contextWindowOptions; - expect(opusOpts.length).toBeGreaterThan(1); - expect(opusOpts.find((o) => o.isDefault)?.value).toBe("200k"); - expect( - hasContextWindowOption(getModelCapabilities("claudeAgent", "claude-opus-4-6"), "1m"), - ).toBe(true); - - const sonnetOpts = getModelCapabilities( - "claudeAgent", - "claude-sonnet-4-6", - ).contextWindowOptions; - expect(sonnetOpts.length).toBeGreaterThan(1); - expect( - hasContextWindowOption(getModelCapabilities("claudeAgent", "claude-sonnet-4-6"), "1m"), - ).toBe(true); - }); - - it("has no context window options for Haiku 4.5, unknown models, and Codex", () => { - expect(getModelCapabilities("claudeAgent", "claude-haiku-4-5").contextWindowOptions).toEqual( - [], - ); - expect(getModelCapabilities("claudeAgent", undefined).contextWindowOptions).toEqual([]); - expect(getModelCapabilities("codex", "gpt-5.4").contextWindowOptions).toEqual([]); +describe("context window helpers", () => { + it("reads default context window", () => { + expect(getDefaultContextWindow(claudeCaps)).toBe("200k"); }); -}); -describe("getDefaultContextWindow", () => { - it("returns the default option value for models with context window options", () => { - expect(getDefaultContextWindow(getModelCapabilities("claudeAgent", "claude-opus-4-6"))).toBe( - "200k", - ); + it("returns null for models without context window options", () => { + expect(getDefaultContextWindow(codexCaps)).toBeNull(); }); - it("returns null for models without context window options", () => { - expect( - getDefaultContextWindow(getModelCapabilities("claudeAgent", "claude-haiku-4-5")), - ).toBeNull(); + it("checks context window support", () => { + expect(hasContextWindowOption(claudeCaps, "1m")).toBe(true); + expect(hasContextWindowOption(claudeCaps, "200k")).toBe(true); + expect(hasContextWindowOption(claudeCaps, "bogus")).toBe(false); + expect(hasContextWindowOption(codexCaps, "1m")).toBe(false); }); }); @@ -196,14 +172,7 @@ describe("resolveApiModelId", () => { ).toBe("claude-opus-4-6"); }); - it("ignores unsupported context window values", () => { - expect( - resolveApiModelId({ - provider: "claudeAgent", - model: "claude-haiku-4-5", - options: { contextWindow: "1m" }, - }), - ).toBe("claude-haiku-4-5"); + it("returns the model as-is for unknown context window values", () => { expect( resolveApiModelId({ provider: "claudeAgent", @@ -217,29 +186,3 @@ describe("resolveApiModelId", () => { expect(resolveApiModelId({ provider: "codex", model: "gpt-5.4" })).toBe("gpt-5.4"); }); }); - -describe("normalizeClaudeModelOptions with contextWindow", () => { - it("preserves non-default contextWindow for supported models", () => { - expect(normalizeClaudeModelOptions("claude-opus-4-6", { contextWindow: "1m" })).toEqual({ - contextWindow: "1m", - }); - }); - - it("strips contextWindow for unsupported models", () => { - expect( - normalizeClaudeModelOptions("claude-haiku-4-5", { contextWindow: "1m" }), - ).toBeUndefined(); - }); - - it("strips contextWindow when it is the default value", () => { - expect( - normalizeClaudeModelOptions("claude-opus-4-6", { contextWindow: "200k" }), - ).toBeUndefined(); - }); - - it("strips unknown contextWindow values", () => { - expect( - normalizeClaudeModelOptions("claude-opus-4-6", { contextWindow: "bogus" }), - ).toBeUndefined(); - }); -}); diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index ed94292cc5..1bc483c7e5 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -1,14 +1,9 @@ import { DEFAULT_MODEL_BY_PROVIDER, - MODEL_OPTIONS_BY_PROVIDER, MODEL_SLUG_ALIASES_BY_PROVIDER, type ClaudeCodeEffort, - type ClaudeModelOptions, - type CodexModelOptions, - type CodexReasoningEffort, type ModelCapabilities, type ModelSelection, - type ModelSlug, type ProviderKind, } from "@t3tools/contracts"; @@ -17,7 +12,7 @@ export interface SelectableModelOption { name: string; } -export function getDefaultModel(provider: ProviderKind = "codex"): ModelSlug { +export function getDefaultModel(provider: ProviderKind = "codex"): string { return DEFAULT_MODEL_BY_PROVIDER[provider]; } @@ -50,7 +45,7 @@ export function getDefaultContextWindow(caps: ModelCapabilities): string | null * Returns the validated non-default value, or `undefined` when it should be * omitted (default or unsupported). */ -function resolveContextWindow( +export function resolveContextWindow( caps: ModelCapabilities, raw: string | null | undefined, ): string | undefined { @@ -60,37 +55,6 @@ function resolveContextWindow( return hasContextWindowOption(caps, raw) ? raw : undefined; } -// ── Data-driven capability resolver ─────────────────────────────────── - -const MODEL_CAPABILITIES_INDEX: Record> = (() => { - const index: Record> = {}; - for (const [provider, models] of Object.entries(MODEL_OPTIONS_BY_PROVIDER)) { - const map: Record = {}; - for (const m of models) { - map[m.slug] = m.capabilities as ModelCapabilities; - } - index[provider] = map; - } - return index as Record>; -})(); - -export function getModelCapabilities( - provider: ProviderKind, - model: string | null | undefined, -): ModelCapabilities { - const slug = normalizeModelSlug(model, provider); - if (slug && MODEL_CAPABILITIES_INDEX[provider]?.[slug]) { - return MODEL_CAPABILITIES_INDEX[provider][slug]; - } - return { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }; -} - export function isClaudeUltrathinkPrompt(text: string | null | undefined): boolean { return typeof text === "string" && /\bultrathink\b/i.test(text); } @@ -98,7 +62,7 @@ export function isClaudeUltrathinkPrompt(text: string | null | undefined): boole export function normalizeModelSlug( model: string | null | undefined, provider: ProviderKind = "codex", -): ModelSlug | null { +): string | null { if (typeof model !== "string") { return null; } @@ -108,18 +72,18 @@ export function normalizeModelSlug( return null; } - const aliases = MODEL_SLUG_ALIASES_BY_PROVIDER[provider] as Record; + const aliases = MODEL_SLUG_ALIASES_BY_PROVIDER[provider] as Record; const aliased = Object.prototype.hasOwnProperty.call(aliases, trimmed) ? aliases[trimmed] : undefined; - return typeof aliased === "string" ? aliased : (trimmed as ModelSlug); + return typeof aliased === "string" ? aliased : trimmed; } export function resolveSelectableModel( provider: ProviderKind, value: string | null | undefined, options: ReadonlyArray, -): ModelSlug | null { +): string | null { if (typeof value !== "string") { return null; } @@ -151,7 +115,7 @@ export function resolveSelectableModel( export function resolveModelSlug( model: string | null | undefined, provider: ProviderKind = "codex", -): ModelSlug { +): string { const normalized = normalizeModelSlug(model, provider); if (!normalized) { return DEFAULT_MODEL_BY_PROVIDER[provider]; @@ -162,7 +126,7 @@ export function resolveModelSlug( export function resolveModelSlugForProvider( provider: ProviderKind, model: string | null | undefined, -): ModelSlug { +): string { return resolveModelSlug(model, provider); } @@ -173,72 +137,26 @@ export function trimOrNull(value: T | null | undefined): T | n return trimmed || null; } -export function normalizeCodexModelOptions( - model: string | null | undefined, - modelOptions: CodexModelOptions | null | undefined, -): CodexModelOptions | undefined { - const caps = getModelCapabilities("codex", model); - const defaultReasoningEffort = getDefaultEffort(caps) as CodexReasoningEffort; - const reasoningEffort = trimOrNull(modelOptions?.reasoningEffort) ?? defaultReasoningEffort; - const fastModeEnabled = modelOptions?.fastMode === true; - const nextOptions: CodexModelOptions = { - ...(reasoningEffort !== defaultReasoningEffort ? { reasoningEffort } : {}), - ...(fastModeEnabled ? { fastMode: true } : {}), - }; - return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; -} - -export function normalizeClaudeModelOptions( - model: string | null | undefined, - modelOptions: ClaudeModelOptions | null | undefined, -): ClaudeModelOptions | undefined { - const caps = getModelCapabilities("claudeAgent", model); - const defaultReasoningEffort = getDefaultEffort(caps); - const resolvedEffort = trimOrNull(modelOptions?.effort); - const isPromptInjected = caps.promptInjectedEffortLevels.includes(resolvedEffort ?? ""); - const effort = - resolvedEffort && - !isPromptInjected && - hasEffortLevel(caps, resolvedEffort) && - resolvedEffort !== defaultReasoningEffort - ? resolvedEffort - : undefined; - 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 } : {}), - ...(fastMode ? { fastMode: true } : {}), - ...(contextWindow ? { contextWindow } : {}), - }; - return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; -} - /** - * Resolve the actual API model identifier from a full model selection. - * - * Provider-aware: each provider can map `contextWindow` (or other options) - * to whatever the API requires — a model-id suffix, a separate parameter, etc. - * The canonical slug stored in the selection stays unchanged so the - * capabilities system keeps working. + * Known context-window value → API model-id suffix for the Claude provider. + * Only values that require a suffix are listed; the default context window + * needs no suffix and is therefore absent. */ +const CLAUDE_CONTEXT_WINDOW_SUFFIX: Record = { + "1m": "[1m]", +}; + export function resolveApiModelId(modelSelection: ModelSelection): string { - const caps = getModelCapabilities(modelSelection.provider, modelSelection.model); switch (modelSelection.provider) { case "claudeAgent": { const contextWindow = modelSelection.options?.contextWindow; if (contextWindow) { - if (hasContextWindowOption(caps, contextWindow)) { - switch (contextWindow) { - case "1m": - return `${modelSelection.model}[1m]`; - default: - return modelSelection.model; - } + const suffix = CLAUDE_CONTEXT_WINDOW_SUFFIX[contextWindow]; + if (suffix) { + return `${modelSelection.model}${suffix}`; } } + return modelSelection.model; } default: { return modelSelection.model; From ff503d1d62ba6983c91d90408fb9f4ea17ca17fb Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 26 Mar 2026 10:19:42 -0700 Subject: [PATCH 06/13] nit --- packages/shared/src/model.ts | 20 +++++--------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index 1bc483c7e5..038a0a2eac 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -137,26 +137,16 @@ export function trimOrNull(value: T | null | undefined): T | n return trimmed || null; } -/** - * Known context-window value → API model-id suffix for the Claude provider. - * Only values that require a suffix are listed; the default context window - * needs no suffix and is therefore absent. - */ -const CLAUDE_CONTEXT_WINDOW_SUFFIX: Record = { - "1m": "[1m]", -}; - export function resolveApiModelId(modelSelection: ModelSelection): string { switch (modelSelection.provider) { case "claudeAgent": { const contextWindow = modelSelection.options?.contextWindow; - if (contextWindow) { - const suffix = CLAUDE_CONTEXT_WINDOW_SUFFIX[contextWindow]; - if (suffix) { - return `${modelSelection.model}${suffix}`; - } + switch (contextWindow) { + case "1m": + return `${modelSelection.model}[1m]`; + default: + return modelSelection.model; } - return modelSelection.model; } default: { return modelSelection.model; From e988d612fce232b0a91f8977290a6e122e9f9676 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 26 Mar 2026 10:26:19 -0700 Subject: [PATCH 07/13] nit --- apps/web/src/composerDraftStore.ts | 11 ++++++----- packages/shared/src/model.test.ts | 5 +++-- packages/shared/src/model.ts | 9 +-------- 3 files changed, 10 insertions(+), 15 deletions(-) diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index ccb0a38f17..17b06e7bd1 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -2,6 +2,7 @@ import { CODEX_REASONING_EFFORT_OPTIONS, type ClaudeCodeEffort, type CodexReasoningEffort, + DEFAULT_MODEL_BY_PROVIDER, ModelSelection, ProjectId, ProviderInteractionMode, @@ -14,7 +15,7 @@ import { import * as Schema from "effect/Schema"; import * as Equal from "effect/Equal"; import { DeepMutable } from "effect/Types"; -import { getDefaultModel, normalizeModelSlug } from "@t3tools/shared/model"; +import { normalizeModelSlug } from "@t3tools/shared/model"; import { useMemo } from "react"; import { getLocalStorageItem } from "./hooks/useLocalStorage"; import { resolveAppModelSelection } from "./modelSelection"; @@ -601,7 +602,7 @@ function legacyToModelSelectionByProvider( model: modelSelection?.provider === provider ? modelSelection.model - : getDefaultModel(provider), + : DEFAULT_MODEL_BY_PROVIDER[provider], options, }; } @@ -1683,7 +1684,7 @@ export const useComposerDraftStore = create()( if (opts) { nextMap[provider] = { provider, - model: current?.model ?? getDefaultModel(provider), + model: current?.model ?? DEFAULT_MODEL_BY_PROVIDER[provider], options: opts, }; } else if (current?.options) { @@ -1733,7 +1734,7 @@ export const useComposerDraftStore = create()( if (providerOpts) { nextMap[normalizedProvider] = { provider: normalizedProvider, - model: currentForProvider?.model ?? getDefaultModel(normalizedProvider), + model: currentForProvider?.model ?? DEFAULT_MODEL_BY_PROVIDER[normalizedProvider], options: providerOpts, }; } else if (currentForProvider?.options) { @@ -1751,7 +1752,7 @@ export const useComposerDraftStore = create()( base.modelSelectionByProvider[normalizedProvider] ?? ({ provider: normalizedProvider, - model: getDefaultModel(normalizedProvider), + model: DEFAULT_MODEL_BY_PROVIDER[normalizedProvider], } as ModelSelection); if (providerOpts) { nextStickyMap[normalizedProvider] = { diff --git a/packages/shared/src/model.test.ts b/packages/shared/src/model.test.ts index c30150d825..523537439d 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -62,14 +62,15 @@ describe("normalizeModelSlug", () => { describe("resolveModelSlug", () => { it("returns defaults when the model is missing", () => { - expect(resolveModelSlug(undefined)).toBe(DEFAULT_MODEL); + expect(resolveModelSlug(undefined, "codex")).toBe(DEFAULT_MODEL_BY_PROVIDER.codex); + expect(resolveModelSlugForProvider("claudeAgent", undefined)).toBe( DEFAULT_MODEL_BY_PROVIDER.claudeAgent, ); }); it("preserves normalized unknown models", () => { - expect(resolveModelSlug("custom/internal-model")).toBe("custom/internal-model"); + expect(resolveModelSlug("custom/internal-model", "codex")).toBe("custom/internal-model"); }); }); diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index 038a0a2eac..111c67ff6a 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -12,10 +12,6 @@ export interface SelectableModelOption { name: string; } -export function getDefaultModel(provider: ProviderKind = "codex"): string { - return DEFAULT_MODEL_BY_PROVIDER[provider]; -} - // ── Effort helpers ──────────────────────────────────────────────────── /** Check whether a capabilities object includes a given effort value. */ @@ -112,10 +108,7 @@ export function resolveSelectableModel( return resolved ? resolved.slug : null; } -export function resolveModelSlug( - model: string | null | undefined, - provider: ProviderKind = "codex", -): string { +export function resolveModelSlug(model: string | null | undefined, provider: ProviderKind): string { const normalized = normalizeModelSlug(model, provider); if (!normalized) { return DEFAULT_MODEL_BY_PROVIDER[provider]; From 68069644be9f818bef47b7e042ed75bd416afe4b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 26 Mar 2026 10:32:31 -0700 Subject: [PATCH 08/13] Hide trait picker when no options are available - Suppress the picker when effort, thinking, and context window controls are all unavailable - Keep shared model tests aligned with the current contract imports --- apps/web/src/components/chat/TraitsPicker.tsx | 2 +- packages/shared/src/model.test.ts | 6 +----- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index f861f4a6d6..cb93476e72 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -221,7 +221,7 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ ], ); - if (effort === null && thinkingEnabled === null) { + if (effort === null && thinkingEnabled === null && contextWindowOptions.length <= 1) { return null; } diff --git a/packages/shared/src/model.test.ts b/packages/shared/src/model.test.ts index 523537439d..fc3a7e41bc 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -1,9 +1,5 @@ import { describe, expect, it } from "vitest"; -import { - DEFAULT_MODEL, - DEFAULT_MODEL_BY_PROVIDER, - type ModelCapabilities, -} from "@t3tools/contracts"; +import { DEFAULT_MODEL_BY_PROVIDER, type ModelCapabilities } from "@t3tools/contracts"; import { applyClaudePromptEffortPrefix, From 83a889f0926a8d030ae2a10c7d8f6b5b7c86786e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 26 Mar 2026 10:33:28 -0700 Subject: [PATCH 09/13] trimmed --- packages/contracts/src/model.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 4e4db4acb2..e62a957e05 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -36,7 +36,7 @@ export const EffortOption = Schema.Struct({ export type EffortOption = typeof EffortOption.Type; export const ContextWindowOption = Schema.Struct({ - value: Schema.String, + value: TrimmedNonEmptyString, label: TrimmedNonEmptyString, isDefault: Schema.optional(Schema.Boolean), }); From 8d7133f6fe70693a85408ce6aad0668695a8146d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 26 Mar 2026 10:40:30 -0700 Subject: [PATCH 10/13] Gate Claude model IDs on supported context windows - Pass model capabilities into Claude API model resolution - Avoid appending unsupported context-window suffixes - Add coverage for unsupported Claude context-window selections --- .../src/git/Layers/ClaudeTextGeneration.ts | 7 +- .../src/provider/Layers/ClaudeAdapter.ts | 7 +- packages/shared/src/model.test.ts | 65 ++++++++++++------- packages/shared/src/model.ts | 26 ++++++-- 4 files changed, 69 insertions(+), 36 deletions(-) diff --git a/apps/server/src/git/Layers/ClaudeTextGeneration.ts b/apps/server/src/git/Layers/ClaudeTextGeneration.ts index 919c3a323d..e07d54d2e5 100644 --- a/apps/server/src/git/Layers/ClaudeTextGeneration.ts +++ b/apps/server/src/git/Layers/ClaudeTextGeneration.ts @@ -27,7 +27,10 @@ import { sanitizePrTitle, toJsonSchemaObject, } from "../Utils.ts"; -import { normalizeClaudeModelOptions } from "../../provider/Layers/ClaudeProvider.ts"; +import { + getClaudeModelCapabilities, + normalizeClaudeModelOptions, +} from "../../provider/Layers/ClaudeProvider.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; const CLAUDE_TIMEOUT_MS = 180_000; @@ -104,7 +107,7 @@ const makeClaudeTextGeneration = Effect.gen(function* () { "--json-schema", jsonSchemaStr, "--model", - resolveApiModelId(modelSelection), + resolveApiModelId(modelSelection, getClaudeModelCapabilities(modelSelection.model)), ...(normalizedOptions?.effort ? ["--effort", normalizedOptions.effort] : []), ...(Object.keys(settings).length > 0 ? ["--settings", JSON.stringify(settings)] : []), "--dangerously-skip-permissions", diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 0a6cf7546b..8084298b97 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -2732,9 +2732,9 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { const claudeBinaryPath = claudeSettings.binaryPath; const modelSelection = input.modelSelection?.provider === "claudeAgent" ? input.modelSelection : undefined; - const apiModelId = modelSelection ? resolveApiModelId(modelSelection) : undefined; - const requestedEffort = trimOrNull(modelSelection?.options?.effort ?? null); const caps = getClaudeModelCapabilities(modelSelection?.model); + const apiModelId = modelSelection ? resolveApiModelId(modelSelection, caps) : undefined; + const requestedEffort = trimOrNull(modelSelection?.options?.effort ?? null); const effort = requestedEffort && hasEffortLevel(caps, requestedEffort) ? requestedEffort : null; const fastMode = modelSelection?.options?.fastMode === true && caps.supportsFastMode; @@ -2899,7 +2899,8 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { } if (modelSelection?.model) { - const apiModelId = resolveApiModelId(modelSelection); + const caps = getClaudeModelCapabilities(modelSelection.model); + const apiModelId = resolveApiModelId(modelSelection, caps); yield* Effect.tryPromise({ try: () => context.query.setModel(apiModelId), catch: (cause) => toRequestError(input.threadId, "turn/setModel", cause), diff --git a/packages/shared/src/model.test.ts b/packages/shared/src/model.test.ts index fc3a7e41bc..1c6b7c9ef1 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -135,51 +135,66 @@ describe("context window helpers", () => { describe("resolveApiModelId", () => { it("appends provider-specific suffix for Claude context window", () => { expect( - resolveApiModelId({ - provider: "claudeAgent", - model: "claude-opus-4-6", - options: { contextWindow: "1m" }, - }), + resolveApiModelId( + { provider: "claudeAgent", model: "claude-opus-4-6", options: { contextWindow: "1m" } }, + claudeCaps, + ), ).toBe("claude-opus-4-6[1m]"); expect( - resolveApiModelId({ - provider: "claudeAgent", - model: "claude-sonnet-4-6", - options: { contextWindow: "1m" }, - }), + resolveApiModelId( + { provider: "claudeAgent", model: "claude-sonnet-4-6", options: { contextWindow: "1m" } }, + claudeCaps, + ), ).toBe("claude-sonnet-4-6[1m]"); }); it("returns the model as-is when contextWindow is not set", () => { - expect(resolveApiModelId({ provider: "claudeAgent", model: "claude-opus-4-6" })).toBe( - "claude-opus-4-6", - ); expect( - resolveApiModelId({ provider: "claudeAgent", model: "claude-opus-4-6", options: {} }), + resolveApiModelId({ provider: "claudeAgent", model: "claude-opus-4-6" }, claudeCaps), + ).toBe("claude-opus-4-6"); + expect( + resolveApiModelId( + { provider: "claudeAgent", model: "claude-opus-4-6", options: {} }, + claudeCaps, + ), ).toBe("claude-opus-4-6"); }); it("returns the model as-is for the default context window value", () => { expect( - resolveApiModelId({ - provider: "claudeAgent", - model: "claude-opus-4-6", - options: { contextWindow: "200k" }, - }), + resolveApiModelId( + { provider: "claudeAgent", model: "claude-opus-4-6", options: { contextWindow: "200k" } }, + claudeCaps, + ), ).toBe("claude-opus-4-6"); }); it("returns the model as-is for unknown context window values", () => { expect( - resolveApiModelId({ - provider: "claudeAgent", - model: "claude-opus-4-6", - options: { contextWindow: "bogus" }, - }), + resolveApiModelId( + { provider: "claudeAgent", model: "claude-opus-4-6", options: { contextWindow: "bogus" } }, + claudeCaps, + ), ).toBe("claude-opus-4-6"); }); + it("ignores contextWindow when model capabilities don't support it", () => { + const haikuCaps: ModelCapabilities = { + reasoningEffortLevels: [], + supportsFastMode: false, + supportsThinkingToggle: true, + contextWindowOptions: [], + promptInjectedEffortLevels: [], + }; + expect( + resolveApiModelId( + { provider: "claudeAgent", model: "claude-haiku-4-5", options: { contextWindow: "1m" } }, + haikuCaps, + ), + ).toBe("claude-haiku-4-5"); + }); + it("returns the model as-is for Codex selections", () => { - expect(resolveApiModelId({ provider: "codex", model: "gpt-5.4" })).toBe("gpt-5.4"); + expect(resolveApiModelId({ provider: "codex", model: "gpt-5.4" }, codexCaps)).toBe("gpt-5.4"); }); }); diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index 111c67ff6a..53549d659c 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -130,16 +130,30 @@ export function trimOrNull(value: T | null | undefined): T | n return trimmed || null; } -export function resolveApiModelId(modelSelection: ModelSelection): string { +/** + * Resolve the actual API model identifier from a model selection. + * + * Provider-aware: each provider can map `contextWindow` (or other options) + * to whatever the API requires — a model-id suffix, a separate parameter, etc. + * The canonical slug stored in the selection stays unchanged so the + * capabilities system keeps working. + * + * @param caps - The model's capabilities, used to validate that the requested + * context window is actually supported before applying any suffix. + */ +export function resolveApiModelId(modelSelection: ModelSelection, caps: ModelCapabilities): string { switch (modelSelection.provider) { case "claudeAgent": { const contextWindow = modelSelection.options?.contextWindow; - switch (contextWindow) { - case "1m": - return `${modelSelection.model}[1m]`; - default: - return modelSelection.model; + if (contextWindow && hasContextWindowOption(caps, contextWindow)) { + switch (contextWindow) { + case "1m": + return `${modelSelection.model}[1m]`; + default: + return modelSelection.model; + } } + return modelSelection.model; } default: { return modelSelection.model; From bf19a06c7364783eaf2111ab90e7240990217ae3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 26 Mar 2026 11:14:02 -0700 Subject: [PATCH 11/13] Apply default Claude context window suffix - Make Claude models resolve to the default 1M API suffix when no context window is set - Update shared model tests and built-in Claude capabilities to reflect 1M as default --- .../src/provider/Layers/ClaudeProvider.ts | 8 ++--- packages/shared/src/model.test.ts | 30 ++++++++----------- packages/shared/src/model.ts | 22 ++++++++------ 3 files changed, 30 insertions(+), 30 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 0bdbbee312..4b52f823fa 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -48,8 +48,8 @@ const BUILT_IN_MODELS: ReadonlyArray = [ supportsFastMode: true, supportsThinkingToggle: false, contextWindowOptions: [ - { value: "200k", label: "200k", isDefault: true }, - { value: "1m", label: "1M" }, + { value: "200k", label: "200k" }, + { value: "1m", label: "1M", isDefault: true }, ], promptInjectedEffortLevels: ["ultrathink"], } satisfies ModelCapabilities, @@ -68,8 +68,8 @@ const BUILT_IN_MODELS: ReadonlyArray = [ supportsFastMode: false, supportsThinkingToggle: false, contextWindowOptions: [ - { value: "200k", label: "200k", isDefault: true }, - { value: "1m", label: "1M" }, + { value: "200k", label: "200k" }, + { value: "1m", label: "1M", isDefault: true }, ], promptInjectedEffortLevels: ["ultrathink"], } satisfies ModelCapabilities, diff --git a/packages/shared/src/model.test.ts b/packages/shared/src/model.test.ts index 1c6b7c9ef1..9a0763016c 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -36,8 +36,8 @@ const claudeCaps: ModelCapabilities = { supportsFastMode: false, supportsThinkingToggle: false, contextWindowOptions: [ - { value: "200k", label: "200k", isDefault: true }, - { value: "1m", label: "1M" }, + { value: "200k", label: "200k" }, + { value: "1m", label: "1M", isDefault: true }, ], promptInjectedEffortLevels: ["ultrathink"], }; @@ -117,7 +117,7 @@ describe("misc helpers", () => { describe("context window helpers", () => { it("reads default context window", () => { - expect(getDefaultContextWindow(claudeCaps)).toBe("200k"); + expect(getDefaultContextWindow(claudeCaps)).toBe("1m"); }); it("returns null for models without context window options", () => { @@ -133,34 +133,29 @@ describe("context window helpers", () => { }); describe("resolveApiModelId", () => { - it("appends provider-specific suffix for Claude context window", () => { + it("appends [1m] suffix for explicit 1m context window", () => { expect( resolveApiModelId( { provider: "claudeAgent", model: "claude-opus-4-6", options: { contextWindow: "1m" } }, claudeCaps, ), ).toBe("claude-opus-4-6[1m]"); - expect( - resolveApiModelId( - { provider: "claudeAgent", model: "claude-sonnet-4-6", options: { contextWindow: "1m" } }, - claudeCaps, - ), - ).toBe("claude-sonnet-4-6[1m]"); }); - it("returns the model as-is when contextWindow is not set", () => { + it("applies default context window suffix when contextWindow is not set", () => { + // 1m is the default for claudeCaps, so [1m] suffix should be applied expect( resolveApiModelId({ provider: "claudeAgent", model: "claude-opus-4-6" }, claudeCaps), - ).toBe("claude-opus-4-6"); + ).toBe("claude-opus-4-6[1m]"); expect( resolveApiModelId( { provider: "claudeAgent", model: "claude-opus-4-6", options: {} }, claudeCaps, ), - ).toBe("claude-opus-4-6"); + ).toBe("claude-opus-4-6[1m]"); }); - it("returns the model as-is for the default context window value", () => { + it("returns the model as-is for explicit 200k (no suffix needed)", () => { expect( resolveApiModelId( { provider: "claudeAgent", model: "claude-opus-4-6", options: { contextWindow: "200k" } }, @@ -169,16 +164,17 @@ describe("resolveApiModelId", () => { ).toBe("claude-opus-4-6"); }); - it("returns the model as-is for unknown context window values", () => { + it("falls back to default when context window value is unsupported", () => { + // bogus is not in contextWindowOptions, so falls back to default (1m) expect( resolveApiModelId( { provider: "claudeAgent", model: "claude-opus-4-6", options: { contextWindow: "bogus" } }, claudeCaps, ), - ).toBe("claude-opus-4-6"); + ).toBe("claude-opus-4-6[1m]"); }); - it("ignores contextWindow when model capabilities don't support it", () => { + it("returns the model as-is when model has no context window options", () => { const haikuCaps: ModelCapabilities = { reasoningEffortLevels: [], supportsFastMode: false, diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index 53549d659c..e2914b9af8 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -144,16 +144,20 @@ export function trimOrNull(value: T | null | undefined): T | n export function resolveApiModelId(modelSelection: ModelSelection, caps: ModelCapabilities): string { switch (modelSelection.provider) { case "claudeAgent": { - const contextWindow = modelSelection.options?.contextWindow; - if (contextWindow && hasContextWindowOption(caps, contextWindow)) { - switch (contextWindow) { - case "1m": - return `${modelSelection.model}[1m]`; - default: - return modelSelection.model; - } + // Use the explicit context window if supported, otherwise fall back to the + // model's default. This ensures the correct API suffix is applied even when + // the user hasn't explicitly chosen a context window (i.e. using the default). + const explicit = modelSelection.options?.contextWindow; + const effectiveContextWindow = + explicit && hasContextWindowOption(caps, explicit) + ? explicit + : getDefaultContextWindow(caps); + switch (effectiveContextWindow) { + case "1m": + return `${modelSelection.model}[1m]`; + default: + return modelSelection.model; } - return modelSelection.model; } default: { return modelSelection.model; From 47baf02ef4301f59e2f18d16042073321025757a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 26 Mar 2026 13:51:30 -0700 Subject: [PATCH 12/13] Unify model option resolution - Add shared effort/context-window resolution helpers - Simplify Claude and Codex normalization paths - Update UI defaults and model-id handling tests --- .../src/git/Layers/ClaudeTextGeneration.ts | 7 +- .../src/provider/Layers/ClaudeAdapter.ts | 23 ++-- .../src/provider/Layers/ClaudeProvider.ts | 20 +-- .../src/provider/Layers/CodexProvider.ts | 7 +- apps/web/src/components/chat/TraitsPicker.tsx | 19 +-- .../chat/composerProviderRegistry.tsx | 19 +-- apps/web/src/providerModels.ts | 26 +--- packages/shared/src/model.test.ts | 124 +++++++++++------- packages/shared/src/model.ts | 58 +++++--- 9 files changed, 148 insertions(+), 155 deletions(-) diff --git a/apps/server/src/git/Layers/ClaudeTextGeneration.ts b/apps/server/src/git/Layers/ClaudeTextGeneration.ts index e07d54d2e5..919c3a323d 100644 --- a/apps/server/src/git/Layers/ClaudeTextGeneration.ts +++ b/apps/server/src/git/Layers/ClaudeTextGeneration.ts @@ -27,10 +27,7 @@ import { sanitizePrTitle, toJsonSchemaObject, } from "../Utils.ts"; -import { - getClaudeModelCapabilities, - normalizeClaudeModelOptions, -} from "../../provider/Layers/ClaudeProvider.ts"; +import { normalizeClaudeModelOptions } from "../../provider/Layers/ClaudeProvider.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; const CLAUDE_TIMEOUT_MS = 180_000; @@ -107,7 +104,7 @@ const makeClaudeTextGeneration = Effect.gen(function* () { "--json-schema", jsonSchemaStr, "--model", - resolveApiModelId(modelSelection, getClaudeModelCapabilities(modelSelection.model)), + resolveApiModelId(modelSelection), ...(normalizedOptions?.effort ? ["--effort", normalizedOptions.effort] : []), ...(Object.keys(settings).length > 0 ? ["--settings", JSON.stringify(settings)] : []), "--dangerously-skip-permissions", diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 8084298b97..e7602ea5c4 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -41,9 +41,9 @@ import { ClaudeCodeEffort, } from "@t3tools/contracts"; import { - hasEffortLevel, applyClaudePromptEffortPrefix, resolveApiModelId, + resolveEffort, trimOrNull, } from "@t3tools/shared/model"; import { @@ -511,16 +511,15 @@ const CLAUDE_SETTING_SOURCES = [ function buildPromptText(input: ProviderSendTurnInput): string { const rawEffort = input.modelSelection?.provider === "claudeAgent" ? input.modelSelection.options?.effort : null; - const requestedEffort = trimOrNull(rawEffort); const claudeModel = input.modelSelection?.provider === "claudeAgent" ? input.modelSelection.model : undefined; const caps = getClaudeModelCapabilities(claudeModel); + + // For prompt injection, we check if the raw effort is a prompt-injected level (e.g. "ultrathink"). + // resolveEffort strips prompt-injected values (returning the default instead), so we check the raw value directly. + const trimmedEffort = trimOrNull(rawEffort); const promptEffort = - requestedEffort === "ultrathink" && caps.reasoningEffortLevels.length > 0 - ? "ultrathink" - : requestedEffort && hasEffortLevel(caps, requestedEffort) - ? requestedEffort - : null; + trimmedEffort && caps.promptInjectedEffortLevels.includes(trimmedEffort) ? trimmedEffort : null; return applyClaudePromptEffortPrefix(input.input?.trim() ?? "", promptEffort); } @@ -2733,10 +2732,9 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { const modelSelection = input.modelSelection?.provider === "claudeAgent" ? input.modelSelection : undefined; const caps = getClaudeModelCapabilities(modelSelection?.model); - const apiModelId = modelSelection ? resolveApiModelId(modelSelection, caps) : undefined; - const requestedEffort = trimOrNull(modelSelection?.options?.effort ?? null); - const effort = - requestedEffort && hasEffortLevel(caps, requestedEffort) ? requestedEffort : null; + const apiModelId = modelSelection ? resolveApiModelId(modelSelection) : undefined; + const effort = (resolveEffort(caps, modelSelection?.options?.effort) ?? + null) as ClaudeCodeEffort | null; const fastMode = modelSelection?.options?.fastMode === true && caps.supportsFastMode; const thinking = typeof modelSelection?.options?.thinking === "boolean" && caps.supportsThinkingToggle @@ -2899,8 +2897,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { } if (modelSelection?.model) { - const caps = getClaudeModelCapabilities(modelSelection.model); - const apiModelId = resolveApiModelId(modelSelection, caps); + const apiModelId = resolveApiModelId(modelSelection); yield* Effect.tryPromise({ try: () => context.query.setModel(apiModelId), catch: (cause) => toRequestError(input.threadId, "turn/setModel", cause), diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 4b52f823fa..b67b90e879 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -9,12 +9,7 @@ import type { } from "@t3tools/contracts"; import { Effect, Equal, Layer, Option, Result, Stream } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { - getDefaultEffort, - hasEffortLevel, - resolveContextWindow, - trimOrNull, -} from "@t3tools/shared/model"; +import { resolveContextWindow, resolveEffort } from "@t3tools/shared/model"; import { buildServerProvider, @@ -106,23 +101,14 @@ export function normalizeClaudeModelOptions( modelOptions: ClaudeModelOptions | null | undefined, ): ClaudeModelOptions | undefined { const caps = getClaudeModelCapabilities(model); - const defaultReasoningEffort = getDefaultEffort(caps); - const resolvedEffort = trimOrNull(modelOptions?.effort); - const isPromptInjected = caps.promptInjectedEffortLevels.includes(resolvedEffort ?? ""); - const effort = - resolvedEffort && - !isPromptInjected && - hasEffortLevel(caps, resolvedEffort) && - resolvedEffort !== defaultReasoningEffort - ? resolvedEffort - : 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 ? { effort: effort as ClaudeModelOptions["effort"] } : {}), ...(fastMode ? { fastMode: true } : {}), ...(contextWindow ? { contextWindow } : {}), }; diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 6123553a31..6497469a2a 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -10,7 +10,7 @@ import type { } from "@t3tools/contracts"; import { Effect, Equal, FileSystem, Layer, Option, Path, Result, Stream } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { getDefaultEffort, trimOrNull } from "@t3tools/shared/model"; +import { resolveEffort } from "@t3tools/shared/model"; import { buildServerProvider, @@ -157,11 +157,10 @@ export function normalizeCodexModelOptions( modelOptions: CodexModelOptions | null | undefined, ): CodexModelOptions | undefined { const caps = getCodexModelCapabilities(model); - const defaultReasoningEffort = getDefaultEffort(caps); - const reasoningEffort = trimOrNull(modelOptions?.reasoningEffort) ?? defaultReasoningEffort; + const reasoningEffort = resolveEffort(caps, modelOptions?.reasoningEffort); const fastModeEnabled = modelOptions?.fastMode === true; const nextOptions: CodexModelOptions = { - ...(reasoningEffort && reasoningEffort !== defaultReasoningEffort + ...(reasoningEffort ? { reasoningEffort: reasoningEffort as CodexModelOptions["reasoningEffort"] } : {}), ...(fastModeEnabled ? { fastMode: true } : {}), diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index cb93476e72..a3b6cbb48f 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -12,8 +12,8 @@ import { trimOrNull, getDefaultEffort, getDefaultContextWindow, - hasEffortLevel, hasContextWindowOption, + resolveEffort, } from "@t3tools/shared/model"; import { memo, useCallback, useState } from "react"; import type { VariantProps } from "class-variance-authority"; @@ -90,21 +90,10 @@ function getSelectedTraits( : caps.reasoningEffortLevels.filter( (option) => !caps.promptInjectedEffortLevels.includes(option.value), ); - const defaultEffort = getDefaultEffort(caps); // Resolve effort from options (provider-specific key) - const resolvedEffort = getRawEffort(provider, modelOptions); - - // Filter out prompt-injected efforts from the "current effort" display - const isPromptInjected = resolvedEffort - ? caps.promptInjectedEffortLevels.includes(resolvedEffort) - : false; - const effort = - resolvedEffort && !isPromptInjected && hasEffortLevel(caps, resolvedEffort) - ? resolvedEffort - : defaultEffort && hasEffortLevel(caps, defaultEffort) - ? defaultEffort - : null; + const rawEffort = getRawEffort(provider, modelOptions); + const effort = resolveEffort(caps, rawEffort) ?? null; // Thinking toggle (only for models that support it) const thinkingEnabled = caps.supportsThinkingToggle @@ -297,7 +286,7 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ onValueChange={(value) => { updateModelOptions( buildNextOptions(provider, modelOptions, { - contextWindow: value === defaultContextWindow ? undefined : value, + contextWindow: value, }), ); }} diff --git a/apps/web/src/components/chat/composerProviderRegistry.tsx b/apps/web/src/components/chat/composerProviderRegistry.tsx index a1d9a1dab8..1a2080d441 100644 --- a/apps/web/src/components/chat/composerProviderRegistry.tsx +++ b/apps/web/src/components/chat/composerProviderRegistry.tsx @@ -4,12 +4,7 @@ import { type ServerProviderModel, type ThreadId, } from "@t3tools/contracts"; -import { - isClaudeUltrathinkPrompt, - trimOrNull, - getDefaultEffort, - hasEffortLevel, -} from "@t3tools/shared/model"; +import { isClaudeUltrathinkPrompt, resolveEffort } from "@t3tools/shared/model"; import type { ReactNode } from "react"; import { getProviderModelCapabilities, @@ -71,17 +66,7 @@ function getProviderStateFromCapabilities( : null : null; - const draftEffort = trimOrNull(rawEffort); - const defaultEffort = getDefaultEffort(caps); - const isPromptInjected = draftEffort - ? caps.promptInjectedEffortLevels.includes(draftEffort) - : false; - const promptEffort = - draftEffort && !isPromptInjected && hasEffortLevel(caps, draftEffort) - ? draftEffort - : defaultEffort && hasEffortLevel(caps, defaultEffort) - ? defaultEffort - : null; + const promptEffort = resolveEffort(caps, rawEffort) ?? null; // Normalize options for dispatch const normalizedOptions = diff --git a/apps/web/src/providerModels.ts b/apps/web/src/providerModels.ts index 3e3244ca28..f2e8692eff 100644 --- a/apps/web/src/providerModels.ts +++ b/apps/web/src/providerModels.ts @@ -7,13 +7,7 @@ import { type ServerProvider, type ServerProviderModel, } from "@t3tools/contracts"; -import { - getDefaultEffort, - hasEffortLevel, - normalizeModelSlug, - resolveContextWindow, - trimOrNull, -} from "@t3tools/shared/model"; +import { normalizeModelSlug, resolveContextWindow, resolveEffort } from "@t3tools/shared/model"; const EMPTY_CAPABILITIES: ModelCapabilities = { reasoningEffortLevels: [], @@ -80,11 +74,10 @@ export function normalizeCodexModelOptionsWithCapabilities( caps: ModelCapabilities, modelOptions: CodexModelOptions | null | undefined, ): CodexModelOptions | undefined { - const defaultReasoningEffort = getDefaultEffort(caps); - const reasoningEffort = trimOrNull(modelOptions?.reasoningEffort) ?? defaultReasoningEffort; + const reasoningEffort = resolveEffort(caps, modelOptions?.reasoningEffort); const fastModeEnabled = modelOptions?.fastMode === true; const nextOptions: CodexModelOptions = { - ...(reasoningEffort && reasoningEffort !== defaultReasoningEffort + ...(reasoningEffort ? { reasoningEffort: reasoningEffort as CodexModelOptions["reasoningEffort"] } : {}), ...(fastModeEnabled ? { fastMode: true } : {}), @@ -96,23 +89,14 @@ export function normalizeClaudeModelOptionsWithCapabilities( caps: ModelCapabilities, modelOptions: ClaudeModelOptions | null | undefined, ): ClaudeModelOptions | undefined { - const defaultReasoningEffort = getDefaultEffort(caps); - const resolvedEffort = trimOrNull(modelOptions?.effort); - const isPromptInjected = caps.promptInjectedEffortLevels.includes(resolvedEffort ?? ""); - const effort = - resolvedEffort && - !isPromptInjected && - hasEffortLevel(caps, resolvedEffort) && - resolvedEffort !== defaultReasoningEffort - ? resolvedEffort - : 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 ? { effort: effort as ClaudeModelOptions["effort"] } : {}), ...(fastMode ? { fastMode: true } : {}), ...(contextWindow ? { contextWindow } : {}), }; diff --git a/packages/shared/src/model.test.ts b/packages/shared/src/model.test.ts index 9a0763016c..535601762d 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -10,6 +10,8 @@ import { isClaudeUltrathinkPrompt, normalizeModelSlug, resolveApiModelId, + resolveContextWindow, + resolveEffort, resolveModelSlug, resolveModelSlugForProvider, resolveSelectableModel, @@ -94,6 +96,42 @@ describe("capability helpers", () => { }); }); +describe("resolveEffort", () => { + it("returns the explicit value when supported and not prompt-injected", () => { + expect(resolveEffort(codexCaps, "xhigh")).toBe("xhigh"); + expect(resolveEffort(codexCaps, "high")).toBe("high"); + expect(resolveEffort(claudeCaps, "medium")).toBe("medium"); + }); + + it("falls back to default when value is unsupported", () => { + expect(resolveEffort(codexCaps, "bogus")).toBe("high"); + expect(resolveEffort(claudeCaps, "bogus")).toBe("high"); + }); + + it("returns the default when no value is provided", () => { + expect(resolveEffort(codexCaps, undefined)).toBe("high"); + expect(resolveEffort(codexCaps, null)).toBe("high"); + expect(resolveEffort(codexCaps, "")).toBe("high"); + expect(resolveEffort(codexCaps, " ")).toBe("high"); + }); + + it("excludes prompt-injected efforts and falls back to default", () => { + expect(resolveEffort(claudeCaps, "ultrathink")).toBe("high"); + }); + + it("returns undefined for models with no effort levels", () => { + const noCaps: ModelCapabilities = { + reasoningEffortLevels: [], + supportsFastMode: false, + supportsThinkingToggle: false, + contextWindowOptions: [], + promptInjectedEffortLevels: [], + }; + expect(resolveEffort(noCaps, undefined)).toBeUndefined(); + expect(resolveEffort(noCaps, "high")).toBeUndefined(); + }); +}); + describe("misc helpers", () => { it("detects ultrathink prompts", () => { expect(isClaudeUltrathinkPrompt("Ultrathink:\nInvestigate")).toBe(true); @@ -132,65 +170,59 @@ describe("context window helpers", () => { }); }); -describe("resolveApiModelId", () => { - it("appends [1m] suffix for explicit 1m context window", () => { - expect( - resolveApiModelId( - { provider: "claudeAgent", model: "claude-opus-4-6", options: { contextWindow: "1m" } }, - claudeCaps, - ), - ).toBe("claude-opus-4-6[1m]"); +describe("resolveContextWindow", () => { + it("returns the explicit value when supported", () => { + expect(resolveContextWindow(claudeCaps, "200k")).toBe("200k"); + expect(resolveContextWindow(claudeCaps, "1m")).toBe("1m"); }); - it("applies default context window suffix when contextWindow is not set", () => { - // 1m is the default for claudeCaps, so [1m] suffix should be applied - expect( - resolveApiModelId({ provider: "claudeAgent", model: "claude-opus-4-6" }, claudeCaps), - ).toBe("claude-opus-4-6[1m]"); - expect( - resolveApiModelId( - { provider: "claudeAgent", model: "claude-opus-4-6", options: {} }, - claudeCaps, - ), - ).toBe("claude-opus-4-6[1m]"); + it("falls back to default when value is unsupported", () => { + expect(resolveContextWindow(claudeCaps, "bogus")).toBe("1m"); }); - it("returns the model as-is for explicit 200k (no suffix needed)", () => { - expect( - resolveApiModelId( - { provider: "claudeAgent", model: "claude-opus-4-6", options: { contextWindow: "200k" } }, - claudeCaps, - ), - ).toBe("claude-opus-4-6"); + it("returns the default when no value is provided", () => { + expect(resolveContextWindow(claudeCaps, undefined)).toBe("1m"); + expect(resolveContextWindow(claudeCaps, null)).toBe("1m"); + expect(resolveContextWindow(claudeCaps, "")).toBe("1m"); }); - it("falls back to default when context window value is unsupported", () => { - // bogus is not in contextWindowOptions, so falls back to default (1m) + it("returns undefined for models with no context window options", () => { + expect(resolveContextWindow(codexCaps, undefined)).toBeUndefined(); + expect(resolveContextWindow(codexCaps, "1m")).toBeUndefined(); + }); +}); + +describe("resolveApiModelId", () => { + it("appends [1m] suffix for 1m context window", () => { expect( - resolveApiModelId( - { provider: "claudeAgent", model: "claude-opus-4-6", options: { contextWindow: "bogus" } }, - claudeCaps, - ), + resolveApiModelId({ + provider: "claudeAgent", + model: "claude-opus-4-6", + options: { contextWindow: "1m" }, + }), ).toBe("claude-opus-4-6[1m]"); }); - it("returns the model as-is when model has no context window options", () => { - const haikuCaps: ModelCapabilities = { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: true, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }; + it("returns the model as-is for 200k context window", () => { + expect( + resolveApiModelId({ + provider: "claudeAgent", + model: "claude-opus-4-6", + options: { contextWindow: "200k" }, + }), + ).toBe("claude-opus-4-6"); + }); + + it("returns the model as-is when no context window is set", () => { + expect(resolveApiModelId({ provider: "claudeAgent", model: "claude-opus-4-6" })).toBe( + "claude-opus-4-6", + ); expect( - resolveApiModelId( - { provider: "claudeAgent", model: "claude-haiku-4-5", options: { contextWindow: "1m" } }, - haikuCaps, - ), - ).toBe("claude-haiku-4-5"); + resolveApiModelId({ provider: "claudeAgent", model: "claude-opus-4-6", options: {} }), + ).toBe("claude-opus-4-6"); }); it("returns the model as-is for Codex selections", () => { - expect(resolveApiModelId({ provider: "codex", model: "gpt-5.4" }, codexCaps)).toBe("gpt-5.4"); + expect(resolveApiModelId({ provider: "codex", model: "gpt-5.4" })).toBe("gpt-5.4"); }); }); diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index e2914b9af8..1598b0407d 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -24,6 +24,32 @@ export function getDefaultEffort(caps: ModelCapabilities): string | null { return caps.reasoningEffortLevels.find((l) => l.isDefault)?.value ?? null; } +/** + * Resolve a raw effort option against capabilities. + * + * Returns the effective effort value — the explicit value if supported and not + * prompt-injected, otherwise the model's default. Returns `undefined` only + * when the model has no effort levels at all. + * + * Prompt-injected efforts (e.g. "ultrathink") are excluded because they are + * applied via prompt text, not the effort API parameter. + */ +export function resolveEffort( + caps: ModelCapabilities, + raw: string | null | undefined, +): string | undefined { + const defaultValue = getDefaultEffort(caps); + const trimmed = typeof raw === "string" ? raw.trim() : null; + if ( + trimmed && + !caps.promptInjectedEffortLevels.includes(trimmed) && + hasEffortLevel(caps, trimmed) + ) { + return trimmed; + } + return defaultValue ?? undefined; +} + // ── Context window helpers ─────────────────────────────────────────── /** Check whether a capabilities object includes a given context window value. */ @@ -38,17 +64,23 @@ export function getDefaultContextWindow(caps: ModelCapabilities): string | null /** * Resolve a raw `contextWindow` option against capabilities. - * Returns the validated non-default value, or `undefined` when it should be - * omitted (default or unsupported). + * + * Returns the effective context window value — the explicit value if supported, + * otherwise the model's default. Returns `undefined` only when the model has + * no context window options at all. + * + * Unlike effort levels (where the API has matching defaults), the context + * window requires an explicit API suffix (e.g. `[1m]`), so we always preserve + * the resolved value to avoid ambiguity between "user chose the default" and + * "not specified". */ export function resolveContextWindow( caps: ModelCapabilities, raw: string | null | undefined, ): string | undefined { - if (!raw) return undefined; const defaultValue = getDefaultContextWindow(caps); - if (raw === defaultValue) return undefined; - return hasContextWindowOption(caps, raw) ? raw : undefined; + if (!raw) return defaultValue ?? undefined; + return hasContextWindowOption(caps, raw) ? raw : (defaultValue ?? undefined); } export function isClaudeUltrathinkPrompt(text: string | null | undefined): boolean { @@ -138,21 +170,13 @@ export function trimOrNull(value: T | null | undefined): T | n * The canonical slug stored in the selection stays unchanged so the * capabilities system keeps working. * - * @param caps - The model's capabilities, used to validate that the requested - * context window is actually supported before applying any suffix. + * Expects `contextWindow` to already be resolved (via `resolveContextWindow`) + * to the effective value, not stripped to `undefined` for defaults. */ -export function resolveApiModelId(modelSelection: ModelSelection, caps: ModelCapabilities): string { +export function resolveApiModelId(modelSelection: ModelSelection): string { switch (modelSelection.provider) { case "claudeAgent": { - // Use the explicit context window if supported, otherwise fall back to the - // model's default. This ensures the correct API suffix is applied even when - // the user hasn't explicitly chosen a context window (i.e. using the default). - const explicit = modelSelection.options?.contextWindow; - const effectiveContextWindow = - explicit && hasContextWindowOption(caps, explicit) - ? explicit - : getDefaultContextWindow(caps); - switch (effectiveContextWindow) { + switch (modelSelection.options?.contextWindow) { case "1m": return `${modelSelection.model}[1m]`; default: From c661417ce37edcb6cdbf4d9113e5a28f0cff2ab0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 26 Mar 2026 14:19:27 -0700 Subject: [PATCH 13/13] Preserve default effort in dispatch options - keep explicit default effort when composing Codex and Claude dispatch options - update adapter expectations for Sonnet 4.6 fallback behavior --- .../src/provider/Layers/ClaudeAdapter.test.ts | 6 ++--- .../chat/composerProviderRegistry.test.tsx | 22 ++++++++++++++----- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 4e8238dbe6..a10a40629c 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -347,7 +347,7 @@ describe("ClaudeAdapterLive", () => { ); }); - it.effect("ignores unsupported max effort for Sonnet 4.6", () => { + it.effect("falls back to default effort when unsupported max is requested for Sonnet 4.6", () => { const harness = makeHarness(); return Effect.gen(function* () { const adapter = yield* ClaudeAdapter; @@ -365,7 +365,7 @@ describe("ClaudeAdapterLive", () => { }); const createInput = harness.getLastCreateQueryInput(); - assert.equal(createInput?.options.effort, undefined); + assert.equal(createInput?.options.effort, "high"); }).pipe( Effect.provideService(Random.Random, makeDeterministicRandomService()), Effect.provide(harness.layer), @@ -532,7 +532,7 @@ describe("ClaudeAdapterLive", () => { }); const createInput = harness.getLastCreateQueryInput(); - assert.equal(createInput?.options.effort, undefined); + assert.equal(createInput?.options.effort, "high"); const promptText = yield* Effect.promise(() => readFirstPromptText(createInput)); assert.equal(promptText, "Ultrathink:\nInvestigate the edge cases"); }).pipe( diff --git a/apps/web/src/components/chat/composerProviderRegistry.test.tsx b/apps/web/src/components/chat/composerProviderRegistry.test.tsx index ed9062a6a8..cc17335cd5 100644 --- a/apps/web/src/components/chat/composerProviderRegistry.test.tsx +++ b/apps/web/src/components/chat/composerProviderRegistry.test.tsx @@ -84,7 +84,9 @@ describe("getComposerProviderState", () => { expect(state).toEqual({ provider: "codex", promptEffort: "high", - modelOptionsForDispatch: undefined, + modelOptionsForDispatch: { + reasoningEffort: "high", + }, }); }); @@ -129,12 +131,13 @@ describe("getComposerProviderState", () => { provider: "codex", promptEffort: "high", modelOptionsForDispatch: { + reasoningEffort: "high", fastMode: true, }, }); }); - it("drops explicit codex default/off overrides from dispatch while keeping the selected effort label", () => { + it("preserves codex default effort explicitly in dispatch options", () => { const state = getComposerProviderState({ provider: "codex", model: "gpt-5.4", @@ -151,7 +154,9 @@ describe("getComposerProviderState", () => { expect(state).toEqual({ provider: "codex", promptEffort: "high", - modelOptionsForDispatch: undefined, + modelOptionsForDispatch: { + reasoningEffort: "high", + }, }); }); @@ -167,7 +172,9 @@ describe("getComposerProviderState", () => { expect(state).toEqual({ provider: "claudeAgent", promptEffort: "high", - modelOptionsForDispatch: undefined, + modelOptionsForDispatch: { + effort: "high", + }, }); }); @@ -236,12 +243,13 @@ describe("getComposerProviderState", () => { provider: "claudeAgent", promptEffort: "high", modelOptionsForDispatch: { + effort: "high", fastMode: true, }, }); }); - it("drops explicit Claude default/off overrides from dispatch while keeping the selected effort label", () => { + it("preserves Claude default effort explicitly in dispatch options", () => { const state = getComposerProviderState({ provider: "claudeAgent", model: "claude-opus-4-6", @@ -258,7 +266,9 @@ describe("getComposerProviderState", () => { expect(state).toEqual({ provider: "claudeAgent", promptEffort: "high", - modelOptionsForDispatch: undefined, + modelOptionsForDispatch: { + effort: "high", + }, }); }); });