authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-26 14:31:35-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-03-26 14:31:35-07:00
logadd5f34619b65c3283317d8c77e1feef6fc63267
tree234dc14d4e31828d3e9d240f15f5e60fd91c0c90
parent9403429a7af114a1b96a79c96ecda218f9e0c0b2
signature Signed by PGP key B5690EEEBB952194

Add Claude context window selection support (#1422)


19 files changed, 414 insertions(+), 139 deletions(-)

apps/server/src/git/Layers/ClaudeTextGeneration.ts+2-1
......@@ -11,6 +11,7 @@ import { Effect, Layer, Option, Schema, Stream } from "effect";
1111import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
1212
1313import { ClaudeModelSelection } from "@t3tools/contracts";
14import { resolveApiModelId } from "@t3tools/shared/model";
1415import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";
1516
1617import { TextGenerationError } from "../Errors.ts";
......@@ -103,7 +104,7 @@ const makeClaudeTextGeneration = Effect.gen(function* () {
103104 "--json-schema",
104105 jsonSchemaStr,
105106 "--model",
106 modelSelection.model,
107 resolveApiModelId(modelSelection),
107108 ...(normalizedOptions?.effort ? ["--effort", normalizedOptions.effort] : []),
108109 ...(Object.keys(settings).length > 0 ? ["--settings", JSON.stringify(settings)] : []),
109110 "--dangerously-skip-permissions",
apps/server/src/provider/Layers/ClaudeAdapter.test.ts+3-3
......@@ -347,7 +347,7 @@ describe("ClaudeAdapterLive", () => {
347347 );
348348 });
349349
350 it.effect("ignores unsupported max effort for Sonnet 4.6", () => {
350 it.effect("falls back to default effort when unsupported max is requested for Sonnet 4.6", () => {
351351 const harness = makeHarness();
352352 return Effect.gen(function* () {
353353 const adapter = yield* ClaudeAdapter;
......@@ -365,7 +365,7 @@ describe("ClaudeAdapterLive", () => {
365365 });
366366
367367 const createInput = harness.getLastCreateQueryInput();
368 assert.equal(createInput?.options.effort, undefined);
368 assert.equal(createInput?.options.effort, "high");
369369 }).pipe(
370370 Effect.provideService(Random.Random, makeDeterministicRandomService()),
371371 Effect.provide(harness.layer),
......@@ -532,7 +532,7 @@ describe("ClaudeAdapterLive", () => {
532532 });
533533
534534 const createInput = harness.getLastCreateQueryInput();
535 assert.equal(createInput?.options.effort, undefined);
535 assert.equal(createInput?.options.effort, "high");
536536 const promptText = yield* Effect.promise(() => readFirstPromptText(createInput));
537537 assert.equal(promptText, "Ultrathink:\nInvestigate the edge cases");
538538 }).pipe(
apps/server/src/provider/Layers/ClaudeAdapter.ts+18-13
......@@ -40,7 +40,12 @@ import {
4040 type UserInputQuestion,
4141 ClaudeCodeEffort,
4242} from "@t3tools/contracts";
43import { hasEffortLevel, applyClaudePromptEffortPrefix, trimOrNull } from "@t3tools/shared/model";
43import {
44 applyClaudePromptEffortPrefix,
45 resolveApiModelId,
46 resolveEffort,
47 trimOrNull,
48} from "@t3tools/shared/model";
4449import {
4550 Cause,
4651 DateTime,
......@@ -506,16 +511,15 @@ const CLAUDE_SETTING_SOURCES = [
506511function buildPromptText(input: ProviderSendTurnInput): string {
507512 const rawEffort =
508513 input.modelSelection?.provider === "claudeAgent" ? input.modelSelection.options?.effort : null;
509 const requestedEffort = trimOrNull(rawEffort);
510514 const claudeModel =
511515 input.modelSelection?.provider === "claudeAgent" ? input.modelSelection.model : undefined;
512516 const caps = getClaudeModelCapabilities(claudeModel);
517
518 // For prompt injection, we check if the raw effort is a prompt-injected level (e.g. "ultrathink").
519 // resolveEffort strips prompt-injected values (returning the default instead), so we check the raw value directly.
520 const trimmedEffort = trimOrNull(rawEffort);
513521 const promptEffort =
514 requestedEffort === "ultrathink" && caps.reasoningEffortLevels.length > 0
515 ? "ultrathink"
516 : requestedEffort && hasEffortLevel(caps, requestedEffort)
517 ? requestedEffort
518 : null;
522 trimmedEffort && caps.promptInjectedEffortLevels.includes(trimmedEffort) ? trimmedEffort : null;
519523 return applyClaudePromptEffortPrefix(input.input?.trim() ?? "", promptEffort);
520524}
521525
......@@ -2727,10 +2731,10 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) {
27272731 const claudeBinaryPath = claudeSettings.binaryPath;
27282732 const modelSelection =
27292733 input.modelSelection?.provider === "claudeAgent" ? input.modelSelection : undefined;
2730 const requestedEffort = trimOrNull(modelSelection?.options?.effort ?? null);
27312734 const caps = getClaudeModelCapabilities(modelSelection?.model);
2732 const effort =
2733 requestedEffort && hasEffortLevel(caps, requestedEffort) ? requestedEffort : null;
2735 const apiModelId = modelSelection ? resolveApiModelId(modelSelection) : undefined;
2736 const effort = (resolveEffort(caps, modelSelection?.options?.effort) ??
2737 null) as ClaudeCodeEffort | null;
27342738 const fastMode = modelSelection?.options?.fastMode === true && caps.supportsFastMode;
27352739 const thinking =
27362740 typeof modelSelection?.options?.thinking === "boolean" && caps.supportsThinkingToggle
......@@ -2746,7 +2750,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) {
27462750
27472751 const queryOptions: ClaudeQueryOptions = {
27482752 ...(input.cwd ? { cwd: input.cwd } : {}),
2749 ...(modelSelection?.model ? { model: modelSelection.model } : {}),
2753 ...(apiModelId ? { model: apiModelId } : {}),
27502754 pathToClaudeCodeExecutable: claudeBinaryPath,
27512755 settingSources: [...CLAUDE_SETTING_SOURCES],
27522756 ...(effectiveEffort ? { effort: effectiveEffort } : {}),
......@@ -2840,7 +2844,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) {
28402844 threadId,
28412845 payload: {
28422846 config: {
2843 ...(modelSelection?.model ? { model: modelSelection.model } : {}),
2847 ...(apiModelId ? { model: apiModelId } : {}),
28442848 ...(input.cwd ? { cwd: input.cwd } : {}),
28452849 ...(effectiveEffort ? { effort: effectiveEffort } : {}),
28462850 ...(permissionMode ? { permissionMode } : {}),
......@@ -2893,8 +2897,9 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) {
28932897 }
28942898
28952899 if (modelSelection?.model) {
2900 const apiModelId = resolveApiModelId(modelSelection);
28962901 yield* Effect.tryPromise({
2897 try: () => context.query.setModel(modelSelection.model),
2902 try: () => context.query.setModel(apiModelId),
28982903 catch: (cause) => toRequestError(input.threadId, "turn/setModel", cause),
28992904 });
29002905 }
apps/server/src/provider/Layers/ClaudeProvider.ts+15-12
......@@ -9,7 +9,7 @@ import type {
99} from "@t3tools/contracts";
1010import { Effect, Equal, Layer, Option, Result, Stream } from "effect";
1111import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
12import { getDefaultEffort, hasEffortLevel, trimOrNull } from "@t3tools/shared/model";
12import { resolveContextWindow, resolveEffort } from "@t3tools/shared/model";
1313
1414import {
1515 buildServerProvider,
......@@ -42,6 +42,10 @@ const BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [
4242 ],
4343 supportsFastMode: true,
4444 supportsThinkingToggle: false,
45 contextWindowOptions: [
46 { value: "200k", label: "200k" },
47 { value: "1m", label: "1M", isDefault: true },
48 ],
4549 promptInjectedEffortLevels: ["ultrathink"],
4650 } satisfies ModelCapabilities,
4751 },
......@@ -58,6 +62,10 @@ const BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [
5862 ],
5963 supportsFastMode: false,
6064 supportsThinkingToggle: false,
65 contextWindowOptions: [
66 { value: "200k", label: "200k" },
67 { value: "1m", label: "1M", isDefault: true },
68 ],
6169 promptInjectedEffortLevels: ["ultrathink"],
6270 } satisfies ModelCapabilities,
6371 },
......@@ -69,6 +77,7 @@ const BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [
6977 reasoningEffortLevels: [],
7078 supportsFastMode: false,
7179 supportsThinkingToggle: true,
80 contextWindowOptions: [],
7281 promptInjectedEffortLevels: [],
7382 } satisfies ModelCapabilities,
7483 },
......@@ -81,6 +90,7 @@ export function getClaudeModelCapabilities(model: string | null | undefined): Mo
8190 reasoningEffortLevels: [],
8291 supportsFastMode: false,
8392 supportsThinkingToggle: false,
93 contextWindowOptions: [],
8494 promptInjectedEffortLevels: [],
8595 }
8696 );
......@@ -91,23 +101,16 @@ export function normalizeClaudeModelOptions(
91101 modelOptions: ClaudeModelOptions | null | undefined,
92102): ClaudeModelOptions | undefined {
93103 const caps = getClaudeModelCapabilities(model);
94 const defaultReasoningEffort = getDefaultEffort(caps);
95 const resolvedEffort = trimOrNull(modelOptions?.effort);
96 const isPromptInjected = caps.promptInjectedEffortLevels.includes(resolvedEffort ?? "");
97 const effort =
98 resolvedEffort &&
99 !isPromptInjected &&
100 hasEffortLevel(caps, resolvedEffort) &&
101 resolvedEffort !== defaultReasoningEffort
102 ? resolvedEffort
103 : undefined;
104 const effort = resolveEffort(caps, modelOptions?.effort);
104105 const thinking =
105106 caps.supportsThinkingToggle && modelOptions?.thinking === false ? false : undefined;
106107 const fastMode = caps.supportsFastMode && modelOptions?.fastMode === true ? true : undefined;
108 const contextWindow = resolveContextWindow(caps, modelOptions?.contextWindow);
107109 const nextOptions: ClaudeModelOptions = {
108110 ...(thinking === false ? { thinking: false } : {}),
109 ...(effort ? { effort } : {}),
111 ...(effort ? { effort: effort as ClaudeModelOptions["effort"] } : {}),
110112 ...(fastMode ? { fastMode: true } : {}),
113 ...(contextWindow ? { contextWindow } : {}),
111114 };
112115 return Object.keys(nextOptions).length > 0 ? nextOptions : undefined;
113116}
apps/server/src/provider/Layers/CodexProvider.ts+10-4
......@@ -10,7 +10,7 @@ import type {
1010} from "@t3tools/contracts";
1111import { Effect, Equal, FileSystem, Layer, Option, Path, Result, Stream } from "effect";
1212import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
13import { getDefaultEffort, trimOrNull } from "@t3tools/shared/model";
13import { resolveEffort } from "@t3tools/shared/model";
1414
1515import {
1616 buildServerProvider,
......@@ -48,6 +48,7 @@ const BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [
4848 ],
4949 supportsFastMode: true,
5050 supportsThinkingToggle: false,
51 contextWindowOptions: [],
5152 promptInjectedEffortLevels: [],
5253 },
5354 },
......@@ -64,6 +65,7 @@ const BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [
6465 ],
6566 supportsFastMode: true,
6667 supportsThinkingToggle: false,
68 contextWindowOptions: [],
6769 promptInjectedEffortLevels: [],
6870 },
6971 },
......@@ -80,6 +82,7 @@ const BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [
8082 ],
8183 supportsFastMode: true,
8284 supportsThinkingToggle: false,
85 contextWindowOptions: [],
8386 promptInjectedEffortLevels: [],
8487 },
8588 },
......@@ -96,6 +99,7 @@ const BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [
9699 ],
97100 supportsFastMode: true,
98101 supportsThinkingToggle: false,
102 contextWindowOptions: [],
99103 promptInjectedEffortLevels: [],
100104 },
101105 },
......@@ -112,6 +116,7 @@ const BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [
112116 ],
113117 supportsFastMode: true,
114118 supportsThinkingToggle: false,
119 contextWindowOptions: [],
115120 promptInjectedEffortLevels: [],
116121 },
117122 },
......@@ -128,6 +133,7 @@ const BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [
128133 ],
129134 supportsFastMode: true,
130135 supportsThinkingToggle: false,
136 contextWindowOptions: [],
131137 promptInjectedEffortLevels: [],
132138 },
133139 },
......@@ -140,6 +146,7 @@ export function getCodexModelCapabilities(model: string | null | undefined): Mod
140146 reasoningEffortLevels: [],
141147 supportsFastMode: false,
142148 supportsThinkingToggle: false,
149 contextWindowOptions: [],
143150 promptInjectedEffortLevels: [],
144151 }
145152 );
......@@ -150,11 +157,10 @@ export function normalizeCodexModelOptions(
150157 modelOptions: CodexModelOptions | null | undefined,
151158): CodexModelOptions | undefined {
152159 const caps = getCodexModelCapabilities(model);
153 const defaultReasoningEffort = getDefaultEffort(caps);
154 const reasoningEffort = trimOrNull(modelOptions?.reasoningEffort) ?? defaultReasoningEffort;
160 const reasoningEffort = resolveEffort(caps, modelOptions?.reasoningEffort);
155161 const fastModeEnabled = modelOptions?.fastMode === true;
156162 const nextOptions: CodexModelOptions = {
157 ...(reasoningEffort && reasoningEffort !== defaultReasoningEffort
163 ...(reasoningEffort
158164 ? { reasoningEffort: reasoningEffort as CodexModelOptions["reasoningEffort"] }
159165 : {}),
160166 ...(fastModeEnabled ? { fastMode: true } : {}),
apps/web/src/components/ChatView.tsx+1-2
......@@ -5,7 +5,6 @@ import {
55 type MessageId,
66 type ModelSelection,
77 type ProjectScript,
8 type ModelSlug,
98 type ProviderKind,
109 type ProjectEntry,
1110 type ProjectId,
......@@ -3105,7 +3104,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
31053104 ]);
31063105
31073106 const onProviderModelSelect = useCallback(
3108 (provider: ProviderKind, model: ModelSlug) => {
3107 (provider: ProviderKind, model: string) => {
31093108 if (!activeThread) return;
31103109 if (lockedProvider !== null && provider !== lockedProvider) {
31113110 scheduleComposerFocus();
apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx+4
......@@ -60,6 +60,7 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str
6060 ],
6161 supportsFastMode: true,
6262 supportsThinkingToggle: false,
63 contextWindowOptions: [],
6364 promptInjectedEffortLevels: ["ultrathink"],
6465 },
6566 },
......@@ -71,6 +72,7 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str
7172 reasoningEffortLevels: [],
7273 supportsFastMode: false,
7374 supportsThinkingToggle: true,
75 contextWindowOptions: [],
7476 promptInjectedEffortLevels: [],
7577 },
7678 },
......@@ -87,6 +89,7 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str
8789 ],
8890 supportsFastMode: false,
8991 supportsThinkingToggle: false,
92 contextWindowOptions: [],
9093 promptInjectedEffortLevels: ["ultrathink"],
9194 },
9295 },
......@@ -103,6 +106,7 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str
103106 ],
104107 supportsFastMode: true,
105108 supportsThinkingToggle: false,
109 contextWindowOptions: [],
106110 promptInjectedEffortLevels: [],
107111 },
108112 },
apps/web/src/components/chat/ComposerCommandMenu.tsx+2-2
......@@ -1,4 +1,4 @@
1import { type ProjectEntry, type ModelSlug, type ProviderKind } from "@t3tools/contracts";
1import { type ProjectEntry, type ProviderKind } from "@t3tools/contracts";
22import { memo } from "react";
33import { type ComposerSlashCommand, type ComposerTriggerKind } from "../../composer-logic";
44import { BotIcon } from "lucide-react";
......@@ -27,7 +27,7 @@ export type ComposerCommandItem =
2727 id: string;
2828 type: "model";
2929 provider: ProviderKind;
30 model: ModelSlug;
30 model: string;
3131 label: string;
3232 description: string;
3333 };
apps/web/src/components/chat/ProviderModelPicker.browser.tsx+7-2
......@@ -1,4 +1,4 @@
1import { type ModelSlug, type ProviderKind, type ServerProvider } from "@t3tools/contracts";
1import { type ProviderKind, type ServerProvider } from "@t3tools/contracts";
22import { page } from "vitest/browser";
33import { afterEach, describe, expect, it, vi } from "vitest";
44import { render } from "vitest-browser-react";
......@@ -33,6 +33,7 @@ const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [
3333 reasoningEffortLevels: [effort("low"), effort("medium", true), effort("high")],
3434 supportsFastMode: true,
3535 supportsThinkingToggle: false,
36 contextWindowOptions: [],
3637 promptInjectedEffortLevels: [],
3738 },
3839 },
......@@ -44,6 +45,7 @@ const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [
4445 reasoningEffortLevels: [effort("low"), effort("medium", true), effort("high")],
4546 supportsFastMode: true,
4647 supportsThinkingToggle: false,
48 contextWindowOptions: [],
4749 promptInjectedEffortLevels: [],
4850 },
4951 },
......@@ -71,6 +73,7 @@ const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [
7173 ],
7274 supportsFastMode: false,
7375 supportsThinkingToggle: true,
76 contextWindowOptions: [],
7477 promptInjectedEffortLevels: [],
7578 },
7679 },
......@@ -87,6 +90,7 @@ const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [
8790 ],
8891 supportsFastMode: false,
8992 supportsThinkingToggle: true,
93 contextWindowOptions: [],
9094 promptInjectedEffortLevels: [],
9195 },
9296 },
......@@ -98,6 +102,7 @@ const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [
98102 reasoningEffortLevels: [effort("low"), effort("medium", true), effort("high")],
99103 supportsFastMode: false,
100104 supportsThinkingToggle: true,
105 contextWindowOptions: [],
101106 promptInjectedEffortLevels: [],
102107 },
103108 },
......@@ -107,7 +112,7 @@ const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [
107112
108113async function mountPicker(props: {
109114 provider: ProviderKind;
110 model: ModelSlug;
115 model: string;
111116 lockedProvider: ProviderKind | null;
112117 providers?: ReadonlyArray<ServerProvider>;
113118 triggerVariant?: "ghost" | "outline";
apps/web/src/components/chat/ProviderModelPicker.tsx+3-3
......@@ -1,4 +1,4 @@
1import { type ModelSlug, type ProviderKind, type ServerProvider } from "@t3tools/contracts";
1import { type ProviderKind, type ServerProvider } from "@t3tools/contracts";
22import { resolveSelectableModel } from "@t3tools/shared/model";
33import { memo, useState } from "react";
44import type { VariantProps } from "class-variance-authority";
......@@ -52,7 +52,7 @@ function providerIconClassName(
5252
5353export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
5454 provider: ProviderKind;
55 model: ModelSlug;
55 model: string;
5656 lockedProvider: ProviderKind | null;
5757 providers?: ReadonlyArray<ServerProvider>;
5858 modelOptionsByProvider: Record<ProviderKind, ReadonlyArray<{ slug: string; name: string }>>;
......@@ -61,7 +61,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
6161 disabled?: boolean;
6262 triggerVariant?: VariantProps<typeof buttonVariants>["variant"];
6363 triggerClassName?: string;
64 onProviderModelChange: (provider: ProviderKind, model: ModelSlug) => void;
64 onProviderModelChange: (provider: ProviderKind, model: string) => void;
6565}) {
6666 const [isMenuOpen, setIsMenuOpen] = useState(false);
6767 const activeProvider = props.lockedProvider ?? props.provider;
apps/web/src/components/chat/TraitsPicker.browser.tsx+4
......@@ -49,6 +49,7 @@ const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [
4949 ],
5050 supportsFastMode: true,
5151 supportsThinkingToggle: false,
52 contextWindowOptions: [],
5253 promptInjectedEffortLevels: [],
5354 },
5455 },
......@@ -77,6 +78,7 @@ const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [
7778 ],
7879 supportsFastMode: true,
7980 supportsThinkingToggle: false,
81 contextWindowOptions: [],
8082 promptInjectedEffortLevels: ["ultrathink"],
8183 },
8284 },
......@@ -93,6 +95,7 @@ const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [
9395 ],
9496 supportsFastMode: false,
9597 supportsThinkingToggle: false,
98 contextWindowOptions: [],
9699 promptInjectedEffortLevels: ["ultrathink"],
97100 },
98101 },
......@@ -104,6 +107,7 @@ const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [
104107 reasoningEffortLevels: [],
105108 supportsFastMode: false,
106109 supportsThinkingToggle: true,
110 contextWindowOptions: [],
107111 promptInjectedEffortLevels: [],
108112 },
109113 },
apps/web/src/components/chat/TraitsPicker.tsx+66-15
......@@ -11,7 +11,9 @@ import {
1111 isClaudeUltrathinkPrompt,
1212 trimOrNull,
1313 getDefaultEffort,
14 hasEffortLevel,
14 getDefaultContextWindow,
15 hasContextWindowOption,
16 resolveEffort,
1517} from "@t3tools/shared/model";
1618import { memo, useCallback, useState } from "react";
1719import type { VariantProps } from "class-variance-authority";
......@@ -53,6 +55,16 @@ function getRawEffort(
5355 return trimOrNull((modelOptions as ClaudeModelOptions | undefined)?.effort);
5456}
5557
58function getRawContextWindow(
59 provider: ProviderKind,
60 modelOptions: ProviderOptions | null | undefined,
61): string | null {
62 if (provider === "claudeAgent") {
63 return trimOrNull((modelOptions as ClaudeModelOptions | undefined)?.contextWindow);
64 }
65 return null;
66}
67
5668function buildNextOptions(
5769 provider: ProviderKind,
5870 modelOptions: ProviderOptions | null | undefined,
......@@ -78,21 +90,10 @@ function getSelectedTraits(
7890 : caps.reasoningEffortLevels.filter(
7991 (option) => !caps.promptInjectedEffortLevels.includes(option.value),
8092 );
81 const defaultEffort = getDefaultEffort(caps);
8293
8394 // Resolve effort from options (provider-specific key)
84 const resolvedEffort = getRawEffort(provider, modelOptions);
85
86 // Filter out prompt-injected efforts from the "current effort" display
87 const isPromptInjected = resolvedEffort
88 ? caps.promptInjectedEffortLevels.includes(resolvedEffort)
89 : false;
90 const effort =
91 resolvedEffort && !isPromptInjected && hasEffortLevel(caps, resolvedEffort)
92 ? resolvedEffort
93 : defaultEffort && hasEffortLevel(caps, defaultEffort)
94 ? defaultEffort
95 : null;
95 const rawEffort = getRawEffort(provider, modelOptions);
96 const effort = resolveEffort(caps, rawEffort) ?? null;
9697
9798 // Thinking toggle (only for models that support it)
9899 const thinkingEnabled = caps.supportsThinkingToggle
......@@ -104,6 +105,15 @@ function getSelectedTraits(
104105 caps.supportsFastMode &&
105106 (modelOptions as { fastMode?: boolean } | undefined)?.fastMode === true;
106107
108 // Context window
109 const contextWindowOptions = caps.contextWindowOptions;
110 const rawContextWindow = getRawContextWindow(provider, modelOptions);
111 const defaultContextWindow = getDefaultContextWindow(caps);
112 const contextWindow =
113 rawContextWindow && hasContextWindowOption(caps, rawContextWindow)
114 ? rawContextWindow
115 : defaultContextWindow;
116
107117 // Prompt-controlled effort (e.g. ultrathink in prompt text)
108118 const ultrathinkPromptControlled =
109119 allowPromptInjectedEffort &&
......@@ -116,6 +126,9 @@ function getSelectedTraits(
116126 effortLevels,
117127 thinkingEnabled,
118128 fastModeEnabled,
129 contextWindowOptions,
130 contextWindow,
131 defaultContextWindow,
119132 ultrathinkPromptControlled,
120133 };
121134}
......@@ -159,6 +172,9 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({
159172 effortLevels,
160173 thinkingEnabled,
161174 fastModeEnabled,
175 contextWindowOptions,
176 contextWindow,
177 defaultContextWindow,
162178 ultrathinkPromptControlled,
163179 } = getSelectedTraits(provider, models, model, prompt, modelOptions, allowPromptInjectedEffort);
164180 const defaultEffort = getDefaultEffort(caps);
......@@ -194,7 +210,7 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({
194210 ],
195211 );
196212
197 if (effort === null && thinkingEnabled === null) {
213 if (effort === null && thinkingEnabled === null && contextWindowOptions.length <= 1) {
198214 return null;
199215 }
200216
......@@ -258,6 +274,33 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({
258274 </MenuGroup>
259275 </>
260276 ) : null}
277 {contextWindowOptions.length > 1 ? (
278 <>
279 <MenuDivider />
280 <MenuGroup>
281 <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">
282 Context Window
283 </div>
284 <MenuRadioGroup
285 value={contextWindow ?? defaultContextWindow ?? ""}
286 onValueChange={(value) => {
287 updateModelOptions(
288 buildNextOptions(provider, modelOptions, {
289 contextWindow: value,
290 }),
291 );
292 }}
293 >
294 {contextWindowOptions.map((option) => (
295 <MenuRadioItem key={option.value} value={option.value}>
296 {option.label}
297 {option.value === defaultContextWindow ? " (default)" : ""}
298 </MenuRadioItem>
299 ))}
300 </MenuRadioGroup>
301 </MenuGroup>
302 </>
303 ) : null}
261304 </>
262305 );
263306});
......@@ -281,12 +324,19 @@ export const TraitsPicker = memo(function TraitsPicker({
281324 effortLevels,
282325 thinkingEnabled,
283326 fastModeEnabled,
327 contextWindowOptions,
328 contextWindow,
329 defaultContextWindow,
284330 ultrathinkPromptControlled,
285331 } = getSelectedTraits(provider, models, model, prompt, modelOptions, allowPromptInjectedEffort);
286332
287333 const effortLabel = effort
288334 ? (effortLevels.find((l) => l.value === effort)?.label ?? effort)
289335 : null;
336 const contextWindowLabel =
337 contextWindowOptions.length > 1 && contextWindow !== defaultContextWindow
338 ? (contextWindowOptions.find((o) => o.value === contextWindow)?.label ?? null)
339 : null;
290340 const triggerLabel = [
291341 ultrathinkPromptControlled
292342 ? "Ultrathink"
......@@ -296,6 +346,7 @@ export const TraitsPicker = memo(function TraitsPicker({
296346 ? null
297347 : `Thinking ${thinkingEnabled ? "On" : "Off"}`,
298348 ...(caps.supportsFastMode && fastModeEnabled ? ["Fast"] : []),
349 ...(contextWindowLabel ? [contextWindowLabel] : []),
299350 ]
300351 .filter(Boolean)
301352 .join(" · ");
apps/web/src/components/chat/composerProviderRegistry.test.tsx+20-6
......@@ -16,6 +16,7 @@ const CODEX_MODELS: ReadonlyArray<ServerProviderModel> = [
1616 ],
1717 supportsFastMode: true,
1818 supportsThinkingToggle: false,
19 contextWindowOptions: [],
1920 promptInjectedEffortLevels: [],
2021 },
2122 },
......@@ -35,6 +36,7 @@ const CLAUDE_MODELS: ReadonlyArray<ServerProviderModel> = [
3536 ],
3637 supportsFastMode: true,
3738 supportsThinkingToggle: false,
39 contextWindowOptions: [],
3840 promptInjectedEffortLevels: ["ultrathink"],
3941 },
4042 },
......@@ -51,6 +53,7 @@ const CLAUDE_MODELS: ReadonlyArray<ServerProviderModel> = [
5153 ],
5254 supportsFastMode: false,
5355 supportsThinkingToggle: false,
56 contextWindowOptions: [],
5457 promptInjectedEffortLevels: ["ultrathink"],
5558 },
5659 },
......@@ -62,6 +65,7 @@ const CLAUDE_MODELS: ReadonlyArray<ServerProviderModel> = [
6265 reasoningEffortLevels: [],
6366 supportsFastMode: false,
6467 supportsThinkingToggle: true,
68 contextWindowOptions: [],
6569 promptInjectedEffortLevels: [],
6670 },
6771 },
......@@ -80,7 +84,9 @@ describe("getComposerProviderState", () => {
8084 expect(state).toEqual({
8185 provider: "codex",
8286 promptEffort: "high",
83 modelOptionsForDispatch: undefined,
87 modelOptionsForDispatch: {
88 reasoningEffort: "high",
89 },
8490 });
8591 });
8692
......@@ -125,12 +131,13 @@ describe("getComposerProviderState", () => {
125131 provider: "codex",
126132 promptEffort: "high",
127133 modelOptionsForDispatch: {
134 reasoningEffort: "high",
128135 fastMode: true,
129136 },
130137 });
131138 });
132139
133 it("drops explicit codex default/off overrides from dispatch while keeping the selected effort label", () => {
140 it("preserves codex default effort explicitly in dispatch options", () => {
134141 const state = getComposerProviderState({
135142 provider: "codex",
136143 model: "gpt-5.4",
......@@ -147,7 +154,9 @@ describe("getComposerProviderState", () => {
147154 expect(state).toEqual({
148155 provider: "codex",
149156 promptEffort: "high",
150 modelOptionsForDispatch: undefined,
157 modelOptionsForDispatch: {
158 reasoningEffort: "high",
159 },
151160 });
152161 });
153162
......@@ -163,7 +172,9 @@ describe("getComposerProviderState", () => {
163172 expect(state).toEqual({
164173 provider: "claudeAgent",
165174 promptEffort: "high",
166 modelOptionsForDispatch: undefined,
175 modelOptionsForDispatch: {
176 effort: "high",
177 },
167178 });
168179 });
169180
......@@ -232,12 +243,13 @@ describe("getComposerProviderState", () => {
232243 provider: "claudeAgent",
233244 promptEffort: "high",
234245 modelOptionsForDispatch: {
246 effort: "high",
235247 fastMode: true,
236248 },
237249 });
238250 });
239251
240 it("drops explicit Claude default/off overrides from dispatch while keeping the selected effort label", () => {
252 it("preserves Claude default effort explicitly in dispatch options", () => {
241253 const state = getComposerProviderState({
242254 provider: "claudeAgent",
243255 model: "claude-opus-4-6",
......@@ -254,7 +266,9 @@ describe("getComposerProviderState", () => {
254266 expect(state).toEqual({
255267 provider: "claudeAgent",
256268 promptEffort: "high",
257 modelOptionsForDispatch: undefined,
269 modelOptionsForDispatch: {
270 effort: "high",
271 },
258272 });
259273 });
260274});
apps/web/src/components/chat/composerProviderRegistry.tsx+7-23
......@@ -1,16 +1,10 @@
11import {
2 type ModelSlug,
32 type ProviderKind,
43 type ProviderModelOptions,
54 type ServerProviderModel,
65 type ThreadId,
76} from "@t3tools/contracts";
8import {
9 isClaudeUltrathinkPrompt,
10 trimOrNull,
11 getDefaultEffort,
12 hasEffortLevel,
13} from "@t3tools/shared/model";
7import { isClaudeUltrathinkPrompt, resolveEffort } from "@t3tools/shared/model";
148import type { ReactNode } from "react";
159import {
1610 getProviderModelCapabilities,
......@@ -21,7 +15,7 @@ import { TraitsMenuContent, TraitsPicker } from "./TraitsPicker";
2115
2216export type ComposerProviderStateInput = {
2317 provider: ProviderKind;
24 model: ModelSlug;
18 model: string;
2519 models: ReadonlyArray<ServerProviderModel>;
2620 prompt: string;
2721 modelOptions: ProviderModelOptions | null | undefined;
......@@ -40,7 +34,7 @@ type ProviderRegistryEntry = {
4034 getState: (input: ComposerProviderStateInput) => ComposerProviderState;
4135 renderTraitsMenuContent: (input: {
4236 threadId: ThreadId;
43 model: ModelSlug;
37 model: string;
4438 models: ReadonlyArray<ServerProviderModel>;
4539 modelOptions: ProviderModelOptions[ProviderKind] | undefined;
4640 prompt: string;
......@@ -48,7 +42,7 @@ type ProviderRegistryEntry = {
4842 }) => ReactNode;
4943 renderTraitsPicker: (input: {
5044 threadId: ThreadId;
51 model: ModelSlug;
45 model: string;
5246 models: ReadonlyArray<ServerProviderModel>;
5347 modelOptions: ProviderModelOptions[ProviderKind] | undefined;
5448 prompt: string;
......@@ -72,17 +66,7 @@ function getProviderStateFromCapabilities(
7266 : null
7367 : null;
7468
75 const draftEffort = trimOrNull(rawEffort);
76 const defaultEffort = getDefaultEffort(caps);
77 const isPromptInjected = draftEffort
78 ? caps.promptInjectedEffortLevels.includes(draftEffort)
79 : false;
80 const promptEffort =
81 draftEffort && !isPromptInjected && hasEffortLevel(caps, draftEffort)
82 ? draftEffort
83 : defaultEffort && hasEffortLevel(caps, defaultEffort)
84 ? defaultEffort
85 : null;
69 const promptEffort = resolveEffort(caps, rawEffort) ?? null;
8670
8771 // Normalize options for dispatch
8872 const normalizedOptions =
......@@ -180,7 +164,7 @@ export function getComposerProviderState(input: ComposerProviderStateInput): Com
180164export function renderProviderTraitsMenuContent(input: {
181165 provider: ProviderKind;
182166 threadId: ThreadId;
183 model: ModelSlug;
167 model: string;
184168 models: ReadonlyArray<ServerProviderModel>;
185169 modelOptions: ProviderModelOptions[ProviderKind] | undefined;
186170 prompt: string;
......@@ -199,7 +183,7 @@ export function renderProviderTraitsMenuContent(input: {
199183export function renderProviderTraitsPicker(input: {
200184 provider: ProviderKind;
201185 threadId: ThreadId;
202 model: ModelSlug;
186 model: string;
203187 models: ReadonlyArray<ServerProviderModel>;
204188 modelOptions: ProviderModelOptions[ProviderKind] | undefined;
205189 prompt: string;
apps/web/src/composerDraftStore.ts+16-8
......@@ -2,7 +2,7 @@ import {
22 CODEX_REASONING_EFFORT_OPTIONS,
33 type ClaudeCodeEffort,
44 type CodexReasoningEffort,
5 type ModelSlug,
5 DEFAULT_MODEL_BY_PROVIDER,
66 ModelSelection,
77 ProjectId,
88 ProviderInteractionMode,
......@@ -15,7 +15,7 @@ import {
1515import * as Schema from "effect/Schema";
1616import * as Equal from "effect/Equal";
1717import { DeepMutable } from "effect/Types";
18import { getDefaultModel, normalizeModelSlug } from "@t3tools/shared/model";
18import { normalizeModelSlug } from "@t3tools/shared/model";
1919import { useMemo } from "react";
2020import { getLocalStorageItem } from "./hooks/useLocalStorage";
2121import { resolveAppModelSelection } from "./modelSelection";
......@@ -263,7 +263,7 @@ interface ComposerDraftStoreState {
263263}
264264
265265export interface EffectiveComposerModelState {
266 selectedModel: ModelSlug;
266 selectedModel: string;
267267 modelOptions: ProviderModelOptions | null;
268268}
269269
......@@ -475,12 +475,20 @@ function normalizeProviderModelOptions(
475475 : claudeCandidate?.fastMode === false
476476 ? false
477477 : undefined;
478 const claudeContextWindow =
479 typeof claudeCandidate?.contextWindow === "string" && claudeCandidate.contextWindow.length > 0
480 ? claudeCandidate.contextWindow
481 : undefined;
478482 const claude =
479 claudeThinking !== undefined || claudeEffort !== undefined || claudeFastMode !== undefined
483 claudeThinking !== undefined ||
484 claudeEffort !== undefined ||
485 claudeFastMode !== undefined ||
486 claudeContextWindow !== undefined
480487 ? {
481488 ...(claudeThinking !== undefined ? { thinking: claudeThinking } : {}),
482489 ...(claudeEffort !== undefined ? { effort: claudeEffort } : {}),
483490 ...(claudeFastMode !== undefined ? { fastMode: claudeFastMode } : {}),
491 ...(claudeContextWindow !== undefined ? { contextWindow: claudeContextWindow } : {}),
484492 }
485493 : undefined;
486494
......@@ -594,7 +602,7 @@ function legacyToModelSelectionByProvider(
594602 model:
595603 modelSelection?.provider === provider
596604 ? modelSelection.model
597 : getDefaultModel(provider),
605 : DEFAULT_MODEL_BY_PROVIDER[provider],
598606 options,
599607 };
600608 }
......@@ -1676,7 +1684,7 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
16761684 if (opts) {
16771685 nextMap[provider] = {
16781686 provider,
1679 model: current?.model ?? getDefaultModel(provider),
1687 model: current?.model ?? DEFAULT_MODEL_BY_PROVIDER[provider],
16801688 options: opts,
16811689 };
16821690 } else if (current?.options) {
......@@ -1726,7 +1734,7 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
17261734 if (providerOpts) {
17271735 nextMap[normalizedProvider] = {
17281736 provider: normalizedProvider,
1729 model: currentForProvider?.model ?? getDefaultModel(normalizedProvider),
1737 model: currentForProvider?.model ?? DEFAULT_MODEL_BY_PROVIDER[normalizedProvider],
17301738 options: providerOpts,
17311739 };
17321740 } else if (currentForProvider?.options) {
......@@ -1744,7 +1752,7 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
17441752 base.modelSelectionByProvider[normalizedProvider] ??
17451753 ({
17461754 provider: normalizedProvider,
1747 model: getDefaultModel(normalizedProvider),
1755 model: DEFAULT_MODEL_BY_PROVIDER[normalizedProvider],
17481756 } as ModelSelection);
17491757 if (providerOpts) {
17501758 nextStickyMap[normalizedProvider] = {
apps/web/src/providerModels.ts+8-20
......@@ -7,17 +7,13 @@ import {
77 type ServerProvider,
88 type ServerProviderModel,
99} from "@t3tools/contracts";
10import {
11 getDefaultEffort,
12 hasEffortLevel,
13 normalizeModelSlug,
14 trimOrNull,
15} from "@t3tools/shared/model";
10import { normalizeModelSlug, resolveContextWindow, resolveEffort } from "@t3tools/shared/model";
1611
1712const EMPTY_CAPABILITIES: ModelCapabilities = {
1813 reasoningEffortLevels: [],
1914 supportsFastMode: false,
2015 supportsThinkingToggle: false,
16 contextWindowOptions: [],
2117 promptInjectedEffortLevels: [],
2218};
2319
......@@ -78,11 +74,10 @@ export function normalizeCodexModelOptionsWithCapabilities(
7874 caps: ModelCapabilities,
7975 modelOptions: CodexModelOptions | null | undefined,
8076): CodexModelOptions | undefined {
81 const defaultReasoningEffort = getDefaultEffort(caps);
82 const reasoningEffort = trimOrNull(modelOptions?.reasoningEffort) ?? defaultReasoningEffort;
77 const reasoningEffort = resolveEffort(caps, modelOptions?.reasoningEffort);
8378 const fastModeEnabled = modelOptions?.fastMode === true;
8479 const nextOptions: CodexModelOptions = {
85 ...(reasoningEffort && reasoningEffort !== defaultReasoningEffort
80 ...(reasoningEffort
8681 ? { reasoningEffort: reasoningEffort as CodexModelOptions["reasoningEffort"] }
8782 : {}),
8883 ...(fastModeEnabled ? { fastMode: true } : {}),
......@@ -94,23 +89,16 @@ export function normalizeClaudeModelOptionsWithCapabilities(
9489 caps: ModelCapabilities,
9590 modelOptions: ClaudeModelOptions | null | undefined,
9691): ClaudeModelOptions | undefined {
97 const defaultReasoningEffort = getDefaultEffort(caps);
98 const resolvedEffort = trimOrNull(modelOptions?.effort);
99 const isPromptInjected = caps.promptInjectedEffortLevels.includes(resolvedEffort ?? "");
100 const effort =
101 resolvedEffort &&
102 !isPromptInjected &&
103 hasEffortLevel(caps, resolvedEffort) &&
104 resolvedEffort !== defaultReasoningEffort
105 ? resolvedEffort
106 : undefined;
92 const effort = resolveEffort(caps, modelOptions?.effort);
10793 const thinking =
10894 caps.supportsThinkingToggle && modelOptions?.thinking === false ? false : undefined;
10995 const fastMode = caps.supportsFastMode && modelOptions?.fastMode === true ? true : undefined;
96 const contextWindow = resolveContextWindow(caps, modelOptions?.contextWindow);
11097 const nextOptions: ClaudeModelOptions = {
11198 ...(thinking === false ? { thinking: false } : {}),
112 ...(effort ? { effort } : {}),
99 ...(effort ? { effort: effort as ClaudeModelOptions["effort"] } : {}),
113100 ...(fastMode ? { fastMode: true } : {}),
101 ...(contextWindow ? { contextWindow } : {}),
114102 };
115103 return Object.keys(nextOptions).length > 0 ? nextOptions : undefined;
116104}
packages/contracts/src/model.ts+11-4
......@@ -18,6 +18,7 @@ export const ClaudeModelOptions = Schema.Struct({
1818 thinking: Schema.optional(Schema.Boolean),
1919 effort: Schema.optional(Schema.Literals(CLAUDE_CODE_EFFORT_OPTIONS)),
2020 fastMode: Schema.optional(Schema.Boolean),
21 contextWindow: Schema.optional(Schema.String),
2122});
2223export type ClaudeModelOptions = typeof ClaudeModelOptions.Type;
2324
......@@ -34,17 +35,23 @@ export const EffortOption = Schema.Struct({
3435});
3536export type EffortOption = typeof EffortOption.Type;
3637
38export const ContextWindowOption = Schema.Struct({
39 value: TrimmedNonEmptyString,
40 label: TrimmedNonEmptyString,
41 isDefault: Schema.optional(Schema.Boolean),
42});
43export type ContextWindowOption = typeof ContextWindowOption.Type;
44
3745export const ModelCapabilities = Schema.Struct({
3846 reasoningEffortLevels: Schema.Array(EffortOption),
3947 supportsFastMode: Schema.Boolean,
4048 supportsThinkingToggle: Schema.Boolean,
49 contextWindowOptions: Schema.Array(ContextWindowOption),
4150 promptInjectedEffortLevels: Schema.Array(TrimmedNonEmptyString),
4251});
4352export type ModelCapabilities = typeof ModelCapabilities.Type;
4453
45export type ModelSlug = string & {};
46
47export const DEFAULT_MODEL_BY_PROVIDER: Record<ProviderKind, ModelSlug> = {
54export const DEFAULT_MODEL_BY_PROVIDER: Record<ProviderKind, string> = {
4855 codex: "gpt-5.4",
4956 claudeAgent: "claude-sonnet-4-6",
5057};
......@@ -57,7 +64,7 @@ export const DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER: Record<ProviderKind,
5764 claudeAgent: "claude-haiku-4-5",
5865};
5966
60export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record<ProviderKind, Record<string, ModelSlug>> = {
67export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record<ProviderKind, Record<string, string>> = {
6168 codex: {
6269 "5.4": "gpt-5.4",
6370 "5.3": "gpt-5.3-codex",
packages/shared/src/model.test.ts+124-7
......@@ -1,16 +1,17 @@
11import { describe, expect, it } from "vitest";
2import {
3 DEFAULT_MODEL,
4 DEFAULT_MODEL_BY_PROVIDER,
5 type ModelCapabilities,
6} from "@t3tools/contracts";
2import { DEFAULT_MODEL_BY_PROVIDER, type ModelCapabilities } from "@t3tools/contracts";
73
84import {
95 applyClaudePromptEffortPrefix,
6 getDefaultContextWindow,
107 getDefaultEffort,
8 hasContextWindowOption,
119 hasEffortLevel,
1210 isClaudeUltrathinkPrompt,
1311 normalizeModelSlug,
12 resolveApiModelId,
13 resolveContextWindow,
14 resolveEffort,
1415 resolveModelSlug,
1516 resolveModelSlugForProvider,
1617 resolveSelectableModel,
......@@ -24,6 +25,7 @@ const codexCaps: ModelCapabilities = {
2425 ],
2526 supportsFastMode: true,
2627 supportsThinkingToggle: false,
28 contextWindowOptions: [],
2729 promptInjectedEffortLevels: [],
2830};
2931
......@@ -35,6 +37,10 @@ const claudeCaps: ModelCapabilities = {
3537 ],
3638 supportsFastMode: false,
3739 supportsThinkingToggle: false,
40 contextWindowOptions: [
41 { value: "200k", label: "200k" },
42 { value: "1m", label: "1M", isDefault: true },
43 ],
3844 promptInjectedEffortLevels: ["ultrathink"],
3945};
4046
......@@ -54,14 +60,15 @@ describe("normalizeModelSlug", () => {
5460
5561describe("resolveModelSlug", () => {
5662 it("returns defaults when the model is missing", () => {
57 expect(resolveModelSlug(undefined)).toBe(DEFAULT_MODEL);
63 expect(resolveModelSlug(undefined, "codex")).toBe(DEFAULT_MODEL_BY_PROVIDER.codex);
64
5865 expect(resolveModelSlugForProvider("claudeAgent", undefined)).toBe(
5966 DEFAULT_MODEL_BY_PROVIDER.claudeAgent,
6067 );
6168 });
6269
6370 it("preserves normalized unknown models", () => {
64 expect(resolveModelSlug("custom/internal-model")).toBe("custom/internal-model");
71 expect(resolveModelSlug("custom/internal-model", "codex")).toBe("custom/internal-model");
6572 });
6673});
6774
......@@ -89,6 +96,42 @@ describe("capability helpers", () => {
8996 });
9097});
9198
99describe("resolveEffort", () => {
100 it("returns the explicit value when supported and not prompt-injected", () => {
101 expect(resolveEffort(codexCaps, "xhigh")).toBe("xhigh");
102 expect(resolveEffort(codexCaps, "high")).toBe("high");
103 expect(resolveEffort(claudeCaps, "medium")).toBe("medium");
104 });
105
106 it("falls back to default when value is unsupported", () => {
107 expect(resolveEffort(codexCaps, "bogus")).toBe("high");
108 expect(resolveEffort(claudeCaps, "bogus")).toBe("high");
109 });
110
111 it("returns the default when no value is provided", () => {
112 expect(resolveEffort(codexCaps, undefined)).toBe("high");
113 expect(resolveEffort(codexCaps, null)).toBe("high");
114 expect(resolveEffort(codexCaps, "")).toBe("high");
115 expect(resolveEffort(codexCaps, " ")).toBe("high");
116 });
117
118 it("excludes prompt-injected efforts and falls back to default", () => {
119 expect(resolveEffort(claudeCaps, "ultrathink")).toBe("high");
120 });
121
122 it("returns undefined for models with no effort levels", () => {
123 const noCaps: ModelCapabilities = {
124 reasoningEffortLevels: [],
125 supportsFastMode: false,
126 supportsThinkingToggle: false,
127 contextWindowOptions: [],
128 promptInjectedEffortLevels: [],
129 };
130 expect(resolveEffort(noCaps, undefined)).toBeUndefined();
131 expect(resolveEffort(noCaps, "high")).toBeUndefined();
132 });
133});
134
92135describe("misc helpers", () => {
93136 it("detects ultrathink prompts", () => {
94137 expect(isClaudeUltrathinkPrompt("Ultrathink:\nInvestigate")).toBe(true);
......@@ -109,3 +152,77 @@ describe("misc helpers", () => {
109152 expect(trimOrNull(" ")).toBeNull();
110153 });
111154});
155
156describe("context window helpers", () => {
157 it("reads default context window", () => {
158 expect(getDefaultContextWindow(claudeCaps)).toBe("1m");
159 });
160
161 it("returns null for models without context window options", () => {
162 expect(getDefaultContextWindow(codexCaps)).toBeNull();
163 });
164
165 it("checks context window support", () => {
166 expect(hasContextWindowOption(claudeCaps, "1m")).toBe(true);
167 expect(hasContextWindowOption(claudeCaps, "200k")).toBe(true);
168 expect(hasContextWindowOption(claudeCaps, "bogus")).toBe(false);
169 expect(hasContextWindowOption(codexCaps, "1m")).toBe(false);
170 });
171});
172
173describe("resolveContextWindow", () => {
174 it("returns the explicit value when supported", () => {
175 expect(resolveContextWindow(claudeCaps, "200k")).toBe("200k");
176 expect(resolveContextWindow(claudeCaps, "1m")).toBe("1m");
177 });
178
179 it("falls back to default when value is unsupported", () => {
180 expect(resolveContextWindow(claudeCaps, "bogus")).toBe("1m");
181 });
182
183 it("returns the default when no value is provided", () => {
184 expect(resolveContextWindow(claudeCaps, undefined)).toBe("1m");
185 expect(resolveContextWindow(claudeCaps, null)).toBe("1m");
186 expect(resolveContextWindow(claudeCaps, "")).toBe("1m");
187 });
188
189 it("returns undefined for models with no context window options", () => {
190 expect(resolveContextWindow(codexCaps, undefined)).toBeUndefined();
191 expect(resolveContextWindow(codexCaps, "1m")).toBeUndefined();
192 });
193});
194
195describe("resolveApiModelId", () => {
196 it("appends [1m] suffix for 1m context window", () => {
197 expect(
198 resolveApiModelId({
199 provider: "claudeAgent",
200 model: "claude-opus-4-6",
201 options: { contextWindow: "1m" },
202 }),
203 ).toBe("claude-opus-4-6[1m]");
204 });
205
206 it("returns the model as-is for 200k context window", () => {
207 expect(
208 resolveApiModelId({
209 provider: "claudeAgent",
210 model: "claude-opus-4-6",
211 options: { contextWindow: "200k" },
212 }),
213 ).toBe("claude-opus-4-6");
214 });
215
216 it("returns the model as-is when no context window is set", () => {
217 expect(resolveApiModelId({ provider: "claudeAgent", model: "claude-opus-4-6" })).toBe(
218 "claude-opus-4-6",
219 );
220 expect(
221 resolveApiModelId({ provider: "claudeAgent", model: "claude-opus-4-6", options: {} }),
222 ).toBe("claude-opus-4-6");
223 });
224
225 it("returns the model as-is for Codex selections", () => {
226 expect(resolveApiModelId({ provider: "codex", model: "gpt-5.4" })).toBe("gpt-5.4");
227 });
228});
packages/shared/src/model.ts+93-14
......@@ -3,7 +3,7 @@ import {
33 MODEL_SLUG_ALIASES_BY_PROVIDER,
44 type ClaudeCodeEffort,
55 type ModelCapabilities,
6 type ModelSlug,
6 type ModelSelection,
77 type ProviderKind,
88} from "@t3tools/contracts";
99
......@@ -12,10 +12,6 @@ export interface SelectableModelOption {
1212 name: string;
1313}
1414
15export function getDefaultModel(provider: ProviderKind = "codex"): ModelSlug {
16 return DEFAULT_MODEL_BY_PROVIDER[provider];
17}
18
1915// ── Effort helpers ────────────────────────────────────────────────────
2016
2117/** Check whether a capabilities object includes a given effort value. */
......@@ -28,6 +24,65 @@ export function getDefaultEffort(caps: ModelCapabilities): string | null {
2824 return caps.reasoningEffortLevels.find((l) => l.isDefault)?.value ?? null;
2925}
3026
27/**
28 * Resolve a raw effort option against capabilities.
29 *
30 * Returns the effective effort value — the explicit value if supported and not
31 * prompt-injected, otherwise the model's default. Returns `undefined` only
32 * when the model has no effort levels at all.
33 *
34 * Prompt-injected efforts (e.g. "ultrathink") are excluded because they are
35 * applied via prompt text, not the effort API parameter.
36 */
37export function resolveEffort(
38 caps: ModelCapabilities,
39 raw: string | null | undefined,
40): string | undefined {
41 const defaultValue = getDefaultEffort(caps);
42 const trimmed = typeof raw === "string" ? raw.trim() : null;
43 if (
44 trimmed &&
45 !caps.promptInjectedEffortLevels.includes(trimmed) &&
46 hasEffortLevel(caps, trimmed)
47 ) {
48 return trimmed;
49 }
50 return defaultValue ?? undefined;
51}
52
53// ── Context window helpers ───────────────────────────────────────────
54
55/** Check whether a capabilities object includes a given context window value. */
56export function hasContextWindowOption(caps: ModelCapabilities, value: string): boolean {
57 return caps.contextWindowOptions.some((o) => o.value === value);
58}
59
60/** Return the default context window value, or `null` if none is defined. */
61export function getDefaultContextWindow(caps: ModelCapabilities): string | null {
62 return caps.contextWindowOptions.find((o) => o.isDefault)?.value ?? null;
63}
64
65/**
66 * Resolve a raw `contextWindow` option against capabilities.
67 *
68 * Returns the effective context window value — the explicit value if supported,
69 * otherwise the model's default. Returns `undefined` only when the model has
70 * no context window options at all.
71 *
72 * Unlike effort levels (where the API has matching defaults), the context
73 * window requires an explicit API suffix (e.g. `[1m]`), so we always preserve
74 * the resolved value to avoid ambiguity between "user chose the default" and
75 * "not specified".
76 */
77export function resolveContextWindow(
78 caps: ModelCapabilities,
79 raw: string | null | undefined,
80): string | undefined {
81 const defaultValue = getDefaultContextWindow(caps);
82 if (!raw) return defaultValue ?? undefined;
83 return hasContextWindowOption(caps, raw) ? raw : (defaultValue ?? undefined);
84}
85
3186export function isClaudeUltrathinkPrompt(text: string | null | undefined): boolean {
3287 return typeof text === "string" && /\bultrathink\b/i.test(text);
3388}
......@@ -35,7 +90,7 @@ export function isClaudeUltrathinkPrompt(text: string | null | undefined): boole
3590export function normalizeModelSlug(
3691 model: string | null | undefined,
3792 provider: ProviderKind = "codex",
38): ModelSlug | null {
93): string | null {
3994 if (typeof model !== "string") {
4095 return null;
4196 }
......@@ -45,18 +100,18 @@ export function normalizeModelSlug(
45100 return null;
46101 }
47102
48 const aliases = MODEL_SLUG_ALIASES_BY_PROVIDER[provider] as Record<string, ModelSlug>;
103 const aliases = MODEL_SLUG_ALIASES_BY_PROVIDER[provider] as Record<string, string>;
49104 const aliased = Object.prototype.hasOwnProperty.call(aliases, trimmed)
50105 ? aliases[trimmed]
51106 : undefined;
52 return typeof aliased === "string" ? aliased : (trimmed as ModelSlug);
107 return typeof aliased === "string" ? aliased : trimmed;
53108}
54109
55110export function resolveSelectableModel(
56111 provider: ProviderKind,
57112 value: string | null | undefined,
58113 options: ReadonlyArray<SelectableModelOption>,
59): ModelSlug | null {
114): string | null {
60115 if (typeof value !== "string") {
61116 return null;
62117 }
......@@ -85,10 +140,7 @@ export function resolveSelectableModel(
85140 return resolved ? resolved.slug : null;
86141}
87142
88export function resolveModelSlug(
89 model: string | null | undefined,
90 provider: ProviderKind = "codex",
91): ModelSlug {
143export function resolveModelSlug(model: string | null | undefined, provider: ProviderKind): string {
92144 const normalized = normalizeModelSlug(model, provider);
93145 if (!normalized) {
94146 return DEFAULT_MODEL_BY_PROVIDER[provider];
......@@ -99,7 +151,7 @@ export function resolveModelSlug(
99151export function resolveModelSlugForProvider(
100152 provider: ProviderKind,
101153 model: string | null | undefined,
102): ModelSlug {
154): string {
103155 return resolveModelSlug(model, provider);
104156}
105157
......@@ -110,6 +162,33 @@ export function trimOrNull<T extends string>(value: T | null | undefined): T | n
110162 return trimmed || null;
111163}
112164
165/**
166 * Resolve the actual API model identifier from a model selection.
167 *
168 * Provider-aware: each provider can map `contextWindow` (or other options)
169 * to whatever the API requires — a model-id suffix, a separate parameter, etc.
170 * The canonical slug stored in the selection stays unchanged so the
171 * capabilities system keeps working.
172 *
173 * Expects `contextWindow` to already be resolved (via `resolveContextWindow`)
174 * to the effective value, not stripped to `undefined` for defaults.
175 */
176export function resolveApiModelId(modelSelection: ModelSelection): string {
177 switch (modelSelection.provider) {
178 case "claudeAgent": {
179 switch (modelSelection.options?.contextWindow) {
180 case "1m":
181 return `${modelSelection.model}[1m]`;
182 default:
183 return modelSelection.model;
184 }
185 }
186 default: {
187 return modelSelection.model;
188 }
189 }
190}
191
113192export function applyClaudePromptEffortPrefix(
114193 text: string,
115194 effort: ClaudeCodeEffort | null | undefined,