diff --git a/apps/server/src/git/Layers/ClaudeTextGeneration.ts b/apps/server/src/git/Layers/ClaudeTextGeneration.ts index 6ffedbf7b4..919c3a323d 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 { resolveApiModelId } 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, + 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.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/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 7ab8bc44ab..e7602ea5c4 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 { + applyClaudePromptEffortPrefix, + resolveApiModelId, + resolveEffort, + trimOrNull, +} from "@t3tools/shared/model"; import { Cause, DateTime, @@ -506,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); } @@ -2727,10 +2731,10 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { const claudeBinaryPath = claudeSettings.binaryPath; const modelSelection = input.modelSelection?.provider === "claudeAgent" ? input.modelSelection : undefined; - const requestedEffort = trimOrNull(modelSelection?.options?.effort ?? null); const caps = getClaudeModelCapabilities(modelSelection?.model); - 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 @@ -2746,7 +2750,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 +2844,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 +2897,9 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { } if (modelSelection?.model) { + const apiModelId = resolveApiModelId(modelSelection); 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..b67b90e879 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -9,7 +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, trimOrNull } from "@t3tools/shared/model"; +import { resolveContextWindow, resolveEffort } from "@t3tools/shared/model"; import { buildServerProvider, @@ -42,6 +42,10 @@ const BUILT_IN_MODELS: ReadonlyArray = [ ], supportsFastMode: true, supportsThinkingToggle: false, + contextWindowOptions: [ + { value: "200k", label: "200k" }, + { value: "1m", label: "1M", isDefault: true }, + ], promptInjectedEffortLevels: ["ultrathink"], } satisfies ModelCapabilities, }, @@ -58,6 +62,10 @@ const BUILT_IN_MODELS: ReadonlyArray = [ ], supportsFastMode: false, supportsThinkingToggle: false, + contextWindowOptions: [ + { value: "200k", label: "200k" }, + { value: "1m", label: "1M", isDefault: true }, + ], 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: [], } ); @@ -91,23 +101,16 @@ 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 } : {}), }; return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; } diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 913fbb58d5..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, @@ -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: [], }, }, @@ -140,6 +146,7 @@ export function getCodexModelCapabilities(model: string | null | undefined): Mod reasoningEffortLevels: [], supportsFastMode: false, supportsThinkingToggle: false, + contextWindowOptions: [], promptInjectedEffortLevels: [], } ); @@ -150,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/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/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/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 fe878e7c18..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"; @@ -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: [], }, }, @@ -107,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/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/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index 5fd97b8cde..a3b6cbb48f 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, - hasEffortLevel, + getDefaultContextWindow, + hasContextWindowOption, + resolveEffort, } from "@t3tools/shared/model"; import { memo, useCallback, useState } from "react"; import type { VariantProps } from "class-variance-authority"; @@ -53,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, @@ -78,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 @@ -104,6 +105,15 @@ function getSelectedTraits( caps.supportsFastMode && (modelOptions as { fastMode?: boolean } | undefined)?.fastMode === true; + // Context window + const contextWindowOptions = caps.contextWindowOptions; + const rawContextWindow = getRawContextWindow(provider, modelOptions); + 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 +126,9 @@ function getSelectedTraits( effortLevels, thinkingEnabled, fastModeEnabled, + contextWindowOptions, + contextWindow, + defaultContextWindow, ultrathinkPromptControlled, }; } @@ -159,6 +172,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); @@ -194,7 +210,7 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ ], ); - if (effort === null && thinkingEnabled === null) { + if (effort === null && thinkingEnabled === null && contextWindowOptions.length <= 1) { return null; } @@ -258,6 +274,33 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ ) : null} + {contextWindowOptions.length > 1 ? ( + <> + + +
+ Context Window +
+ { + updateModelOptions( + buildNextOptions(provider, modelOptions, { + contextWindow: value, + }), + ); + }} + > + {contextWindowOptions.map((option) => ( + + {option.label} + {option.value === defaultContextWindow ? " (default)" : ""} + + ))} + +
+ + ) : null} ); }); @@ -281,12 +324,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 +346,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/components/chat/composerProviderRegistry.test.tsx b/apps/web/src/components/chat/composerProviderRegistry.test.tsx index d5fbb1333a..cc17335cd5 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: [], }, }, @@ -80,7 +84,9 @@ describe("getComposerProviderState", () => { expect(state).toEqual({ provider: "codex", promptEffort: "high", - modelOptionsForDispatch: undefined, + modelOptionsForDispatch: { + reasoningEffort: "high", + }, }); }); @@ -125,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", @@ -147,7 +154,9 @@ describe("getComposerProviderState", () => { expect(state).toEqual({ provider: "codex", promptEffort: "high", - modelOptionsForDispatch: undefined, + modelOptionsForDispatch: { + reasoningEffort: "high", + }, }); }); @@ -163,7 +172,9 @@ describe("getComposerProviderState", () => { expect(state).toEqual({ provider: "claudeAgent", promptEffort: "high", - modelOptionsForDispatch: undefined, + modelOptionsForDispatch: { + effort: "high", + }, }); }); @@ -232,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", @@ -254,7 +266,9 @@ describe("getComposerProviderState", () => { expect(state).toEqual({ provider: "claudeAgent", promptEffort: "high", - modelOptionsForDispatch: undefined, + modelOptionsForDispatch: { + effort: "high", + }, }); }); }); diff --git a/apps/web/src/components/chat/composerProviderRegistry.tsx b/apps/web/src/components/chat/composerProviderRegistry.tsx index 2cebd8d4f4..1a2080d441 100644 --- a/apps/web/src/components/chat/composerProviderRegistry.tsx +++ b/apps/web/src/components/chat/composerProviderRegistry.tsx @@ -1,16 +1,10 @@ import { - type ModelSlug, type ProviderKind, type ProviderModelOptions, 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, @@ -21,7 +15,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 +34,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 +42,7 @@ type ProviderRegistryEntry = { }) => ReactNode; renderTraitsPicker: (input: { threadId: ThreadId; - model: ModelSlug; + model: string; models: ReadonlyArray; modelOptions: ProviderModelOptions[ProviderKind] | undefined; prompt: string; @@ -72,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 = @@ -180,7 +164,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 +183,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 3d54c526f1..17b06e7bd1 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -2,7 +2,7 @@ import { CODEX_REASONING_EFFORT_OPTIONS, type ClaudeCodeEffort, type CodexReasoningEffort, - type ModelSlug, + DEFAULT_MODEL_BY_PROVIDER, ModelSelection, ProjectId, ProviderInteractionMode, @@ -15,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"; @@ -263,7 +263,7 @@ interface ComposerDraftStoreState { } export interface EffectiveComposerModelState { - selectedModel: ModelSlug; + selectedModel: string; modelOptions: ProviderModelOptions | null; } @@ -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; @@ -594,7 +602,7 @@ function legacyToModelSelectionByProvider( model: modelSelection?.provider === provider ? modelSelection.model - : getDefaultModel(provider), + : DEFAULT_MODEL_BY_PROVIDER[provider], options, }; } @@ -1676,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) { @@ -1726,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) { @@ -1744,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/apps/web/src/providerModels.ts b/apps/web/src/providerModels.ts index a925ed690f..f2e8692eff 100644 --- a/apps/web/src/providerModels.ts +++ b/apps/web/src/providerModels.ts @@ -7,17 +7,13 @@ import { type ServerProvider, type ServerProviderModel, } from "@t3tools/contracts"; -import { - getDefaultEffort, - hasEffortLevel, - normalizeModelSlug, - trimOrNull, -} from "@t3tools/shared/model"; +import { normalizeModelSlug, resolveContextWindow, resolveEffort } from "@t3tools/shared/model"; const EMPTY_CAPABILITIES: ModelCapabilities = { reasoningEffortLevels: [], supportsFastMode: false, supportsThinkingToggle: false, + contextWindowOptions: [], promptInjectedEffortLevels: [], }; @@ -78,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 } : {}), @@ -94,23 +89,16 @@ 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 } : {}), }; return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; } diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 68ca110473..e62a957e05 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -18,6 +18,7 @@ export const ClaudeModelOptions = Schema.Struct({ thinking: Schema.optional(Schema.Boolean), effort: Schema.optional(Schema.Literals(CLAUDE_CODE_EFFORT_OPTIONS)), fastMode: Schema.optional(Schema.Boolean), + contextWindow: Schema.optional(Schema.String), }); export type ClaudeModelOptions = typeof ClaudeModelOptions.Type; @@ -34,17 +35,23 @@ export const EffortOption = Schema.Struct({ }); export type EffortOption = typeof EffortOption.Type; +export const ContextWindowOption = Schema.Struct({ + value: TrimmedNonEmptyString, + 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 & {}; - -export const DEFAULT_MODEL_BY_PROVIDER: Record = { +export const DEFAULT_MODEL_BY_PROVIDER: Record = { codex: "gpt-5.4", claudeAgent: "claude-sonnet-4-6", }; @@ -57,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 31f0d0a112..535601762d 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -1,16 +1,17 @@ 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, + getDefaultContextWindow, getDefaultEffort, + hasContextWindowOption, hasEffortLevel, isClaudeUltrathinkPrompt, normalizeModelSlug, + resolveApiModelId, + resolveContextWindow, + resolveEffort, resolveModelSlug, resolveModelSlugForProvider, resolveSelectableModel, @@ -24,6 +25,7 @@ const codexCaps: ModelCapabilities = { ], supportsFastMode: true, supportsThinkingToggle: false, + contextWindowOptions: [], promptInjectedEffortLevels: [], }; @@ -35,6 +37,10 @@ const claudeCaps: ModelCapabilities = { ], supportsFastMode: false, supportsThinkingToggle: false, + contextWindowOptions: [ + { value: "200k", label: "200k" }, + { value: "1m", label: "1M", isDefault: true }, + ], promptInjectedEffortLevels: ["ultrathink"], }; @@ -54,14 +60,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"); }); }); @@ -89,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); @@ -109,3 +152,77 @@ describe("misc helpers", () => { expect(trimOrNull(" ")).toBeNull(); }); }); + +describe("context window helpers", () => { + it("reads default context window", () => { + expect(getDefaultContextWindow(claudeCaps)).toBe("1m"); + }); + + it("returns null for models without context window options", () => { + expect(getDefaultContextWindow(codexCaps)).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); + }); +}); + +describe("resolveContextWindow", () => { + it("returns the explicit value when supported", () => { + expect(resolveContextWindow(claudeCaps, "200k")).toBe("200k"); + expect(resolveContextWindow(claudeCaps, "1m")).toBe("1m"); + }); + + it("falls back to default when value is unsupported", () => { + expect(resolveContextWindow(claudeCaps, "bogus")).toBe("1m"); + }); + + 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("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: "1m" }, + }), + ).toBe("claude-opus-4-6[1m]"); + }); + + 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-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" })).toBe("gpt-5.4"); + }); +}); diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index e633aeb293..1598b0407d 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -3,7 +3,7 @@ import { MODEL_SLUG_ALIASES_BY_PROVIDER, type ClaudeCodeEffort, type ModelCapabilities, - type ModelSlug, + type ModelSelection, type ProviderKind, } from "@t3tools/contracts"; @@ -12,10 +12,6 @@ export interface SelectableModelOption { name: string; } -export function getDefaultModel(provider: ProviderKind = "codex"): ModelSlug { - return DEFAULT_MODEL_BY_PROVIDER[provider]; -} - // ── Effort helpers ──────────────────────────────────────────────────── /** Check whether a capabilities object includes a given effort value. */ @@ -28,6 +24,65 @@ 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. */ +export function hasContextWindowOption(caps: ModelCapabilities, value: string): boolean { + return caps.contextWindowOptions.some((o) => o.value === 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; +} + +/** + * Resolve a raw `contextWindow` option against capabilities. + * + * 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 { + const defaultValue = getDefaultContextWindow(caps); + if (!raw) return defaultValue ?? undefined; + return hasContextWindowOption(caps, raw) ? raw : (defaultValue ?? undefined); +} + export function isClaudeUltrathinkPrompt(text: string | null | undefined): boolean { return typeof text === "string" && /\bultrathink\b/i.test(text); } @@ -35,7 +90,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; } @@ -45,18 +100,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; } @@ -85,10 +140,7 @@ export function resolveSelectableModel( return resolved ? resolved.slug : null; } -export function resolveModelSlug( - model: string | null | undefined, - provider: ProviderKind = "codex", -): ModelSlug { +export function resolveModelSlug(model: string | null | undefined, provider: ProviderKind): string { const normalized = normalizeModelSlug(model, provider); if (!normalized) { return DEFAULT_MODEL_BY_PROVIDER[provider]; @@ -99,7 +151,7 @@ export function resolveModelSlug( export function resolveModelSlugForProvider( provider: ProviderKind, model: string | null | undefined, -): ModelSlug { +): string { return resolveModelSlug(model, provider); } @@ -110,6 +162,33 @@ export function trimOrNull(value: T | null | undefined): T | n return trimmed || null; } +/** + * 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. + * + * Expects `contextWindow` to already be resolved (via `resolveContextWindow`) + * to the effective value, not stripped to `undefined` for defaults. + */ +export function resolveApiModelId(modelSelection: ModelSelection): string { + switch (modelSelection.provider) { + case "claudeAgent": { + switch (modelSelection.options?.contextWindow) { + case "1m": + return `${modelSelection.model}[1m]`; + default: + return modelSelection.model; + } + } + default: { + return modelSelection.model; + } + } +} + export function applyClaudePromptEffortPrefix( text: string, effort: ClaudeCodeEffort | null | undefined,