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";...@@ -11,6 +11,7 @@ import { Effect, Layer, Option, Schema, Stream } from "effect";
11import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";11import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
1212
13import { ClaudeModelSelection } from "@t3tools/contracts";13import { ClaudeModelSelection } from "@t3tools/contracts";
14import { resolveApiModelId } from "@t3tools/shared/model";
14import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";15import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";
1516
16import { TextGenerationError } from "../Errors.ts";17import { TextGenerationError } from "../Errors.ts";
...@@ -103,7 +104,7 @@ const makeClaudeTextGeneration = Effect.gen(function* () {...@@ -103,7 +104,7 @@ const makeClaudeTextGeneration = Effect.gen(function* () {
103 "--json-schema",104 "--json-schema",
104 jsonSchemaStr,105 jsonSchemaStr,
105 "--model",106 "--model",
106 modelSelection.model,107 resolveApiModelId(modelSelection),
107 ...(normalizedOptions?.effort ? ["--effort", normalizedOptions.effort] : []),108 ...(normalizedOptions?.effort ? ["--effort", normalizedOptions.effort] : []),
108 ...(Object.keys(settings).length > 0 ? ["--settings", JSON.stringify(settings)] : []),109 ...(Object.keys(settings).length > 0 ? ["--settings", JSON.stringify(settings)] : []),
109 "--dangerously-skip-permissions",110 "--dangerously-skip-permissions",
apps/server/src/provider/Layers/ClaudeAdapter.test.ts+3-3
...@@ -347,7 +347,7 @@ describe("ClaudeAdapterLive", () => {...@@ -347,7 +347,7 @@ describe("ClaudeAdapterLive", () => {
347 );347 );
348 });348 });
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", () => {
351 const harness = makeHarness();351 const harness = makeHarness();
352 return Effect.gen(function* () {352 return Effect.gen(function* () {
353 const adapter = yield* ClaudeAdapter;353 const adapter = yield* ClaudeAdapter;
...@@ -365,7 +365,7 @@ describe("ClaudeAdapterLive", () => {...@@ -365,7 +365,7 @@ describe("ClaudeAdapterLive", () => {
365 });365 });
366366
367 const createInput = harness.getLastCreateQueryInput();367 const createInput = harness.getLastCreateQueryInput();
368 assert.equal(createInput?.options.effort, undefined);368 assert.equal(createInput?.options.effort, "high");
369 }).pipe(369 }).pipe(
370 Effect.provideService(Random.Random, makeDeterministicRandomService()),370 Effect.provideService(Random.Random, makeDeterministicRandomService()),
371 Effect.provide(harness.layer),371 Effect.provide(harness.layer),
...@@ -532,7 +532,7 @@ describe("ClaudeAdapterLive", () => {...@@ -532,7 +532,7 @@ describe("ClaudeAdapterLive", () => {
532 });532 });
533533
534 const createInput = harness.getLastCreateQueryInput();534 const createInput = harness.getLastCreateQueryInput();
535 assert.equal(createInput?.options.effort, undefined);535 assert.equal(createInput?.options.effort, "high");
536 const promptText = yield* Effect.promise(() => readFirstPromptText(createInput));536 const promptText = yield* Effect.promise(() => readFirstPromptText(createInput));
537 assert.equal(promptText, "Ultrathink:\nInvestigate the edge cases");537 assert.equal(promptText, "Ultrathink:\nInvestigate the edge cases");
538 }).pipe(538 }).pipe(
apps/server/src/provider/Layers/ClaudeAdapter.ts+18-13
...@@ -40,7 +40,12 @@ import {...@@ -40,7 +40,12 @@ import {
40 type UserInputQuestion,40 type UserInputQuestion,
41 ClaudeCodeEffort,41 ClaudeCodeEffort,
42} from "@t3tools/contracts";42} 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";
44import {49import {
45 Cause,50 Cause,
46 DateTime,51 DateTime,
...@@ -506,16 +511,15 @@ const CLAUDE_SETTING_SOURCES = [...@@ -506,16 +511,15 @@ const CLAUDE_SETTING_SOURCES = [
506function buildPromptText(input: ProviderSendTurnInput): string {511function buildPromptText(input: ProviderSendTurnInput): string {
507 const rawEffort =512 const rawEffort =
508 input.modelSelection?.provider === "claudeAgent" ? input.modelSelection.options?.effort : null;513 input.modelSelection?.provider === "claudeAgent" ? input.modelSelection.options?.effort : null;
509 const requestedEffort = trimOrNull(rawEffort);
510 const claudeModel =514 const claudeModel =
511 input.modelSelection?.provider === "claudeAgent" ? input.modelSelection.model : undefined;515 input.modelSelection?.provider === "claudeAgent" ? input.modelSelection.model : undefined;
512 const caps = getClaudeModelCapabilities(claudeModel);516 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);
513 const promptEffort =521 const promptEffort =
514 requestedEffort === "ultrathink" && caps.reasoningEffortLevels.length > 0522 trimmedEffort && caps.promptInjectedEffortLevels.includes(trimmedEffort) ? trimmedEffort : null;
515 ? "ultrathink"
516 : requestedEffort && hasEffortLevel(caps, requestedEffort)
517 ? requestedEffort
518 : null;
519 return applyClaudePromptEffortPrefix(input.input?.trim() ?? "", promptEffort);523 return applyClaudePromptEffortPrefix(input.input?.trim() ?? "", promptEffort);
520}524}
521525
...@@ -2727,10 +2731,10 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) {...@@ -2727,10 +2731,10 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) {
2727 const claudeBinaryPath = claudeSettings.binaryPath;2731 const claudeBinaryPath = claudeSettings.binaryPath;
2728 const modelSelection =2732 const modelSelection =
2729 input.modelSelection?.provider === "claudeAgent" ? input.modelSelection : undefined;2733 input.modelSelection?.provider === "claudeAgent" ? input.modelSelection : undefined;
2730 const requestedEffort = trimOrNull(modelSelection?.options?.effort ?? null);
2731 const caps = getClaudeModelCapabilities(modelSelection?.model);2734 const caps = getClaudeModelCapabilities(modelSelection?.model);
2732 const effort =2735 const apiModelId = modelSelection ? resolveApiModelId(modelSelection) : undefined;
2733 requestedEffort && hasEffortLevel(caps, requestedEffort) ? requestedEffort : null;2736 const effort = (resolveEffort(caps, modelSelection?.options?.effort) ??
2737 null) as ClaudeCodeEffort | null;
2734 const fastMode = modelSelection?.options?.fastMode === true && caps.supportsFastMode;2738 const fastMode = modelSelection?.options?.fastMode === true && caps.supportsFastMode;
2735 const thinking =2739 const thinking =
2736 typeof modelSelection?.options?.thinking === "boolean" && caps.supportsThinkingToggle2740 typeof modelSelection?.options?.thinking === "boolean" && caps.supportsThinkingToggle
...@@ -2746,7 +2750,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) {...@@ -2746,7 +2750,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) {
27462750
2747 const queryOptions: ClaudeQueryOptions = {2751 const queryOptions: ClaudeQueryOptions = {
2748 ...(input.cwd ? { cwd: input.cwd } : {}),2752 ...(input.cwd ? { cwd: input.cwd } : {}),
2749 ...(modelSelection?.model ? { model: modelSelection.model } : {}),2753 ...(apiModelId ? { model: apiModelId } : {}),
2750 pathToClaudeCodeExecutable: claudeBinaryPath,2754 pathToClaudeCodeExecutable: claudeBinaryPath,
2751 settingSources: [...CLAUDE_SETTING_SOURCES],2755 settingSources: [...CLAUDE_SETTING_SOURCES],
2752 ...(effectiveEffort ? { effort: effectiveEffort } : {}),2756 ...(effectiveEffort ? { effort: effectiveEffort } : {}),
...@@ -2840,7 +2844,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) {...@@ -2840,7 +2844,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) {
2840 threadId,2844 threadId,
2841 payload: {2845 payload: {
2842 config: {2846 config: {
2843 ...(modelSelection?.model ? { model: modelSelection.model } : {}),2847 ...(apiModelId ? { model: apiModelId } : {}),
2844 ...(input.cwd ? { cwd: input.cwd } : {}),2848 ...(input.cwd ? { cwd: input.cwd } : {}),
2845 ...(effectiveEffort ? { effort: effectiveEffort } : {}),2849 ...(effectiveEffort ? { effort: effectiveEffort } : {}),
2846 ...(permissionMode ? { permissionMode } : {}),2850 ...(permissionMode ? { permissionMode } : {}),
...@@ -2893,8 +2897,9 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) {...@@ -2893,8 +2897,9 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) {
2893 }2897 }
28942898
2895 if (modelSelection?.model) {2899 if (modelSelection?.model) {
2900 const apiModelId = resolveApiModelId(modelSelection);
2896 yield* Effect.tryPromise({2901 yield* Effect.tryPromise({
2897 try: () => context.query.setModel(modelSelection.model),2902 try: () => context.query.setModel(apiModelId),
2898 catch: (cause) => toRequestError(input.threadId, "turn/setModel", cause),2903 catch: (cause) => toRequestError(input.threadId, "turn/setModel", cause),
2899 });2904 });
2900 }2905 }
apps/server/src/provider/Layers/ClaudeProvider.ts+15-12
...@@ -9,7 +9,7 @@ import type {...@@ -9,7 +9,7 @@ import type {
9} from "@t3tools/contracts";9} from "@t3tools/contracts";
10import { Effect, Equal, Layer, Option, Result, Stream } from "effect";10import { Effect, Equal, Layer, Option, Result, Stream } from "effect";
11import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";11import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
12import { getDefaultEffort, hasEffortLevel, trimOrNull } from "@t3tools/shared/model";12import { resolveContextWindow, resolveEffort } from "@t3tools/shared/model";
1313
14import {14import {
15 buildServerProvider,15 buildServerProvider,
...@@ -42,6 +42,10 @@ const BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [...@@ -42,6 +42,10 @@ const BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [
42 ],42 ],
43 supportsFastMode: true,43 supportsFastMode: true,
44 supportsThinkingToggle: false,44 supportsThinkingToggle: false,
45 contextWindowOptions: [
46 { value: "200k", label: "200k" },
47 { value: "1m", label: "1M", isDefault: true },
48 ],
45 promptInjectedEffortLevels: ["ultrathink"],49 promptInjectedEffortLevels: ["ultrathink"],
46 } satisfies ModelCapabilities,50 } satisfies ModelCapabilities,
47 },51 },
...@@ -58,6 +62,10 @@ const BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [...@@ -58,6 +62,10 @@ const BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [
58 ],62 ],
59 supportsFastMode: false,63 supportsFastMode: false,
60 supportsThinkingToggle: false,64 supportsThinkingToggle: false,
65 contextWindowOptions: [
66 { value: "200k", label: "200k" },
67 { value: "1m", label: "1M", isDefault: true },
68 ],
61 promptInjectedEffortLevels: ["ultrathink"],69 promptInjectedEffortLevels: ["ultrathink"],
62 } satisfies ModelCapabilities,70 } satisfies ModelCapabilities,
63 },71 },
...@@ -69,6 +77,7 @@ const BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [...@@ -69,6 +77,7 @@ const BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [
69 reasoningEffortLevels: [],77 reasoningEffortLevels: [],
70 supportsFastMode: false,78 supportsFastMode: false,
71 supportsThinkingToggle: true,79 supportsThinkingToggle: true,
80 contextWindowOptions: [],
72 promptInjectedEffortLevels: [],81 promptInjectedEffortLevels: [],
73 } satisfies ModelCapabilities,82 } satisfies ModelCapabilities,
74 },83 },
...@@ -81,6 +90,7 @@ export function getClaudeModelCapabilities(model: string | null | undefined): Mo...@@ -81,6 +90,7 @@ export function getClaudeModelCapabilities(model: string | null | undefined): Mo
81 reasoningEffortLevels: [],90 reasoningEffortLevels: [],
82 supportsFastMode: false,91 supportsFastMode: false,
83 supportsThinkingToggle: false,92 supportsThinkingToggle: false,
93 contextWindowOptions: [],
84 promptInjectedEffortLevels: [],94 promptInjectedEffortLevels: [],
85 }95 }
86 );96 );
...@@ -91,23 +101,16 @@ export function normalizeClaudeModelOptions(...@@ -91,23 +101,16 @@ export function normalizeClaudeModelOptions(
91 modelOptions: ClaudeModelOptions | null | undefined,101 modelOptions: ClaudeModelOptions | null | undefined,
92): ClaudeModelOptions | undefined {102): ClaudeModelOptions | undefined {
93 const caps = getClaudeModelCapabilities(model);103 const caps = getClaudeModelCapabilities(model);
94 const defaultReasoningEffort = getDefaultEffort(caps);104 const effort = resolveEffort(caps, modelOptions?.effort);
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 thinking =105 const thinking =
105 caps.supportsThinkingToggle && modelOptions?.thinking === false ? false : undefined;106 caps.supportsThinkingToggle && modelOptions?.thinking === false ? false : undefined;
106 const fastMode = caps.supportsFastMode && modelOptions?.fastMode === true ? true : undefined;107 const fastMode = caps.supportsFastMode && modelOptions?.fastMode === true ? true : undefined;
108 const contextWindow = resolveContextWindow(caps, modelOptions?.contextWindow);
107 const nextOptions: ClaudeModelOptions = {109 const nextOptions: ClaudeModelOptions = {
108 ...(thinking === false ? { thinking: false } : {}),110 ...(thinking === false ? { thinking: false } : {}),
109 ...(effort ? { effort } : {}),111 ...(effort ? { effort: effort as ClaudeModelOptions["effort"] } : {}),
110 ...(fastMode ? { fastMode: true } : {}),112 ...(fastMode ? { fastMode: true } : {}),
113 ...(contextWindow ? { contextWindow } : {}),
111 };114 };
112 return Object.keys(nextOptions).length > 0 ? nextOptions : undefined;115 return Object.keys(nextOptions).length > 0 ? nextOptions : undefined;
113}116}
apps/server/src/provider/Layers/CodexProvider.ts+10-4
...@@ -10,7 +10,7 @@ import type {...@@ -10,7 +10,7 @@ import type {
10} from "@t3tools/contracts";10} from "@t3tools/contracts";
11import { Effect, Equal, FileSystem, Layer, Option, Path, Result, Stream } from "effect";11import { Effect, Equal, FileSystem, Layer, Option, Path, Result, Stream } from "effect";
12import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";12import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
13import { getDefaultEffort, trimOrNull } from "@t3tools/shared/model";13import { resolveEffort } from "@t3tools/shared/model";
1414
15import {15import {
16 buildServerProvider,16 buildServerProvider,
...@@ -48,6 +48,7 @@ const BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [...@@ -48,6 +48,7 @@ const BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [
48 ],48 ],
49 supportsFastMode: true,49 supportsFastMode: true,
50 supportsThinkingToggle: false,50 supportsThinkingToggle: false,
51 contextWindowOptions: [],
51 promptInjectedEffortLevels: [],52 promptInjectedEffortLevels: [],
52 },53 },
53 },54 },
...@@ -64,6 +65,7 @@ const BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [...@@ -64,6 +65,7 @@ const BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [
64 ],65 ],
65 supportsFastMode: true,66 supportsFastMode: true,
66 supportsThinkingToggle: false,67 supportsThinkingToggle: false,
68 contextWindowOptions: [],
67 promptInjectedEffortLevels: [],69 promptInjectedEffortLevels: [],
68 },70 },
69 },71 },
...@@ -80,6 +82,7 @@ const BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [...@@ -80,6 +82,7 @@ const BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [
80 ],82 ],
81 supportsFastMode: true,83 supportsFastMode: true,
82 supportsThinkingToggle: false,84 supportsThinkingToggle: false,
85 contextWindowOptions: [],
83 promptInjectedEffortLevels: [],86 promptInjectedEffortLevels: [],
84 },87 },
85 },88 },
...@@ -96,6 +99,7 @@ const BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [...@@ -96,6 +99,7 @@ const BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [
96 ],99 ],
97 supportsFastMode: true,100 supportsFastMode: true,
98 supportsThinkingToggle: false,101 supportsThinkingToggle: false,
102 contextWindowOptions: [],
99 promptInjectedEffortLevels: [],103 promptInjectedEffortLevels: [],
100 },104 },
101 },105 },
...@@ -112,6 +116,7 @@ const BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [...@@ -112,6 +116,7 @@ const BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [
112 ],116 ],
113 supportsFastMode: true,117 supportsFastMode: true,
114 supportsThinkingToggle: false,118 supportsThinkingToggle: false,
119 contextWindowOptions: [],
115 promptInjectedEffortLevels: [],120 promptInjectedEffortLevels: [],
116 },121 },
117 },122 },
...@@ -128,6 +133,7 @@ const BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [...@@ -128,6 +133,7 @@ const BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [
128 ],133 ],
129 supportsFastMode: true,134 supportsFastMode: true,
130 supportsThinkingToggle: false,135 supportsThinkingToggle: false,
136 contextWindowOptions: [],
131 promptInjectedEffortLevels: [],137 promptInjectedEffortLevels: [],
132 },138 },
133 },139 },
...@@ -140,6 +146,7 @@ export function getCodexModelCapabilities(model: string | null | undefined): Mod...@@ -140,6 +146,7 @@ export function getCodexModelCapabilities(model: string | null | undefined): Mod
140 reasoningEffortLevels: [],146 reasoningEffortLevels: [],
141 supportsFastMode: false,147 supportsFastMode: false,
142 supportsThinkingToggle: false,148 supportsThinkingToggle: false,
149 contextWindowOptions: [],
143 promptInjectedEffortLevels: [],150 promptInjectedEffortLevels: [],
144 }151 }
145 );152 );
...@@ -150,11 +157,10 @@ export function normalizeCodexModelOptions(...@@ -150,11 +157,10 @@ export function normalizeCodexModelOptions(
150 modelOptions: CodexModelOptions | null | undefined,157 modelOptions: CodexModelOptions | null | undefined,
151): CodexModelOptions | undefined {158): CodexModelOptions | undefined {
152 const caps = getCodexModelCapabilities(model);159 const caps = getCodexModelCapabilities(model);
153 const defaultReasoningEffort = getDefaultEffort(caps);160 const reasoningEffort = resolveEffort(caps, modelOptions?.reasoningEffort);
154 const reasoningEffort = trimOrNull(modelOptions?.reasoningEffort) ?? defaultReasoningEffort;
155 const fastModeEnabled = modelOptions?.fastMode === true;161 const fastModeEnabled = modelOptions?.fastMode === true;
156 const nextOptions: CodexModelOptions = {162 const nextOptions: CodexModelOptions = {
157 ...(reasoningEffort && reasoningEffort !== defaultReasoningEffort163 ...(reasoningEffort
158 ? { reasoningEffort: reasoningEffort as CodexModelOptions["reasoningEffort"] }164 ? { reasoningEffort: reasoningEffort as CodexModelOptions["reasoningEffort"] }
159 : {}),165 : {}),
160 ...(fastModeEnabled ? { fastMode: true } : {}),166 ...(fastModeEnabled ? { fastMode: true } : {}),
apps/web/src/components/ChatView.tsx+1-2
...@@ -5,7 +5,6 @@ import {...@@ -5,7 +5,6 @@ import {
5 type MessageId,5 type MessageId,
6 type ModelSelection,6 type ModelSelection,
7 type ProjectScript,7 type ProjectScript,
8 type ModelSlug,
9 type ProviderKind,8 type ProviderKind,
10 type ProjectEntry,9 type ProjectEntry,
11 type ProjectId,10 type ProjectId,
...@@ -3105,7 +3104,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -3105,7 +3104,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
3105 ]);3104 ]);
31063105
3107 const onProviderModelSelect = useCallback(3106 const onProviderModelSelect = useCallback(
3108 (provider: ProviderKind, model: ModelSlug) => {3107 (provider: ProviderKind, model: string) => {
3109 if (!activeThread) return;3108 if (!activeThread) return;
3110 if (lockedProvider !== null && provider !== lockedProvider) {3109 if (lockedProvider !== null && provider !== lockedProvider) {
3111 scheduleComposerFocus();3110 scheduleComposerFocus();
apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx+4
...@@ -60,6 +60,7 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str...@@ -60,6 +60,7 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str
60 ],60 ],
61 supportsFastMode: true,61 supportsFastMode: true,
62 supportsThinkingToggle: false,62 supportsThinkingToggle: false,
63 contextWindowOptions: [],
63 promptInjectedEffortLevels: ["ultrathink"],64 promptInjectedEffortLevels: ["ultrathink"],
64 },65 },
65 },66 },
...@@ -71,6 +72,7 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str...@@ -71,6 +72,7 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str
71 reasoningEffortLevels: [],72 reasoningEffortLevels: [],
72 supportsFastMode: false,73 supportsFastMode: false,
73 supportsThinkingToggle: true,74 supportsThinkingToggle: true,
75 contextWindowOptions: [],
74 promptInjectedEffortLevels: [],76 promptInjectedEffortLevels: [],
75 },77 },
76 },78 },
...@@ -87,6 +89,7 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str...@@ -87,6 +89,7 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str
87 ],89 ],
88 supportsFastMode: false,90 supportsFastMode: false,
89 supportsThinkingToggle: false,91 supportsThinkingToggle: false,
92 contextWindowOptions: [],
90 promptInjectedEffortLevels: ["ultrathink"],93 promptInjectedEffortLevels: ["ultrathink"],
91 },94 },
92 },95 },
...@@ -103,6 +106,7 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str...@@ -103,6 +106,7 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str
103 ],106 ],
104 supportsFastMode: true,107 supportsFastMode: true,
105 supportsThinkingToggle: false,108 supportsThinkingToggle: false,
109 contextWindowOptions: [],
106 promptInjectedEffortLevels: [],110 promptInjectedEffortLevels: [],
107 },111 },
108 },112 },
apps/web/src/components/chat/ComposerCommandMenu.tsx+2-2
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import { type ProjectEntry, type ModelSlug, type ProviderKind } from "@t3tools/contracts";1import { type ProjectEntry, type ProviderKind } from "@t3tools/contracts";
2import { memo } from "react";2import { memo } from "react";
3import { type ComposerSlashCommand, type ComposerTriggerKind } from "../../composer-logic";3import { type ComposerSlashCommand, type ComposerTriggerKind } from "../../composer-logic";
4import { BotIcon } from "lucide-react";4import { BotIcon } from "lucide-react";
...@@ -27,7 +27,7 @@ export type ComposerCommandItem =...@@ -27,7 +27,7 @@ export type ComposerCommandItem =
27 id: string;27 id: string;
28 type: "model";28 type: "model";
29 provider: ProviderKind;29 provider: ProviderKind;
30 model: ModelSlug;30 model: string;
31 label: string;31 label: string;
32 description: string;32 description: string;
33 };33 };
apps/web/src/components/chat/ProviderModelPicker.browser.tsx+7-2
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import { type ModelSlug, type ProviderKind, type ServerProvider } from "@t3tools/contracts";1import { type ProviderKind, type ServerProvider } from "@t3tools/contracts";
2import { page } from "vitest/browser";2import { page } from "vitest/browser";
3import { afterEach, describe, expect, it, vi } from "vitest";3import { afterEach, describe, expect, it, vi } from "vitest";
4import { render } from "vitest-browser-react";4import { render } from "vitest-browser-react";
...@@ -33,6 +33,7 @@ const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [...@@ -33,6 +33,7 @@ const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [
33 reasoningEffortLevels: [effort("low"), effort("medium", true), effort("high")],33 reasoningEffortLevels: [effort("low"), effort("medium", true), effort("high")],
34 supportsFastMode: true,34 supportsFastMode: true,
35 supportsThinkingToggle: false,35 supportsThinkingToggle: false,
36 contextWindowOptions: [],
36 promptInjectedEffortLevels: [],37 promptInjectedEffortLevels: [],
37 },38 },
38 },39 },
...@@ -44,6 +45,7 @@ const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [...@@ -44,6 +45,7 @@ const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [
44 reasoningEffortLevels: [effort("low"), effort("medium", true), effort("high")],45 reasoningEffortLevels: [effort("low"), effort("medium", true), effort("high")],
45 supportsFastMode: true,46 supportsFastMode: true,
46 supportsThinkingToggle: false,47 supportsThinkingToggle: false,
48 contextWindowOptions: [],
47 promptInjectedEffortLevels: [],49 promptInjectedEffortLevels: [],
48 },50 },
49 },51 },
...@@ -71,6 +73,7 @@ const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [...@@ -71,6 +73,7 @@ const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [
71 ],73 ],
72 supportsFastMode: false,74 supportsFastMode: false,
73 supportsThinkingToggle: true,75 supportsThinkingToggle: true,
76 contextWindowOptions: [],
74 promptInjectedEffortLevels: [],77 promptInjectedEffortLevels: [],
75 },78 },
76 },79 },
...@@ -87,6 +90,7 @@ const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [...@@ -87,6 +90,7 @@ const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [
87 ],90 ],
88 supportsFastMode: false,91 supportsFastMode: false,
89 supportsThinkingToggle: true,92 supportsThinkingToggle: true,
93 contextWindowOptions: [],
90 promptInjectedEffortLevels: [],94 promptInjectedEffortLevels: [],
91 },95 },
92 },96 },
...@@ -98,6 +102,7 @@ const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [...@@ -98,6 +102,7 @@ const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [
98 reasoningEffortLevels: [effort("low"), effort("medium", true), effort("high")],102 reasoningEffortLevels: [effort("low"), effort("medium", true), effort("high")],
99 supportsFastMode: false,103 supportsFastMode: false,
100 supportsThinkingToggle: true,104 supportsThinkingToggle: true,
105 contextWindowOptions: [],
101 promptInjectedEffortLevels: [],106 promptInjectedEffortLevels: [],
102 },107 },
103 },108 },
...@@ -107,7 +112,7 @@ const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [...@@ -107,7 +112,7 @@ const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [
107112
108async function mountPicker(props: {113async function mountPicker(props: {
109 provider: ProviderKind;114 provider: ProviderKind;
110 model: ModelSlug;115 model: string;
111 lockedProvider: ProviderKind | null;116 lockedProvider: ProviderKind | null;
112 providers?: ReadonlyArray<ServerProvider>;117 providers?: ReadonlyArray<ServerProvider>;
113 triggerVariant?: "ghost" | "outline";118 triggerVariant?: "ghost" | "outline";
apps/web/src/components/chat/ProviderModelPicker.tsx+3-3
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import { type ModelSlug, type ProviderKind, type ServerProvider } from "@t3tools/contracts";1import { type ProviderKind, type ServerProvider } from "@t3tools/contracts";
2import { resolveSelectableModel } from "@t3tools/shared/model";2import { resolveSelectableModel } from "@t3tools/shared/model";
3import { memo, useState } from "react";3import { memo, useState } from "react";
4import type { VariantProps } from "class-variance-authority";4import type { VariantProps } from "class-variance-authority";
...@@ -52,7 +52,7 @@ function providerIconClassName(...@@ -52,7 +52,7 @@ function providerIconClassName(
5252
53export const ProviderModelPicker = memo(function ProviderModelPicker(props: {53export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
54 provider: ProviderKind;54 provider: ProviderKind;
55 model: ModelSlug;55 model: string;
56 lockedProvider: ProviderKind | null;56 lockedProvider: ProviderKind | null;
57 providers?: ReadonlyArray<ServerProvider>;57 providers?: ReadonlyArray<ServerProvider>;
58 modelOptionsByProvider: Record<ProviderKind, ReadonlyArray<{ slug: string; name: string }>>;58 modelOptionsByProvider: Record<ProviderKind, ReadonlyArray<{ slug: string; name: string }>>;
...@@ -61,7 +61,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {...@@ -61,7 +61,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
61 disabled?: boolean;61 disabled?: boolean;
62 triggerVariant?: VariantProps<typeof buttonVariants>["variant"];62 triggerVariant?: VariantProps<typeof buttonVariants>["variant"];
63 triggerClassName?: string;63 triggerClassName?: string;
64 onProviderModelChange: (provider: ProviderKind, model: ModelSlug) => void;64 onProviderModelChange: (provider: ProviderKind, model: string) => void;
65}) {65}) {
66 const [isMenuOpen, setIsMenuOpen] = useState(false);66 const [isMenuOpen, setIsMenuOpen] = useState(false);
67 const activeProvider = props.lockedProvider ?? props.provider;67 const activeProvider = props.lockedProvider ?? props.provider;
apps/web/src/components/chat/TraitsPicker.browser.tsx+4
...@@ -49,6 +49,7 @@ const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [...@@ -49,6 +49,7 @@ const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [
49 ],49 ],
50 supportsFastMode: true,50 supportsFastMode: true,
51 supportsThinkingToggle: false,51 supportsThinkingToggle: false,
52 contextWindowOptions: [],
52 promptInjectedEffortLevels: [],53 promptInjectedEffortLevels: [],
53 },54 },
54 },55 },
...@@ -77,6 +78,7 @@ const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [...@@ -77,6 +78,7 @@ const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [
77 ],78 ],
78 supportsFastMode: true,79 supportsFastMode: true,
79 supportsThinkingToggle: false,80 supportsThinkingToggle: false,
81 contextWindowOptions: [],
80 promptInjectedEffortLevels: ["ultrathink"],82 promptInjectedEffortLevels: ["ultrathink"],
81 },83 },
82 },84 },
...@@ -93,6 +95,7 @@ const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [...@@ -93,6 +95,7 @@ const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [
93 ],95 ],
94 supportsFastMode: false,96 supportsFastMode: false,
95 supportsThinkingToggle: false,97 supportsThinkingToggle: false,
98 contextWindowOptions: [],
96 promptInjectedEffortLevels: ["ultrathink"],99 promptInjectedEffortLevels: ["ultrathink"],
97 },100 },
98 },101 },
...@@ -104,6 +107,7 @@ const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [...@@ -104,6 +107,7 @@ const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [
104 reasoningEffortLevels: [],107 reasoningEffortLevels: [],
105 supportsFastMode: false,108 supportsFastMode: false,
106 supportsThinkingToggle: true,109 supportsThinkingToggle: true,
110 contextWindowOptions: [],
107 promptInjectedEffortLevels: [],111 promptInjectedEffortLevels: [],
108 },112 },
109 },113 },
apps/web/src/components/chat/TraitsPicker.tsx+66-15
...@@ -11,7 +11,9 @@ import {...@@ -11,7 +11,9 @@ import {
11 isClaudeUltrathinkPrompt,11 isClaudeUltrathinkPrompt,
12 trimOrNull,12 trimOrNull,
13 getDefaultEffort,13 getDefaultEffort,
14 hasEffortLevel,14 getDefaultContextWindow,
15 hasContextWindowOption,
16 resolveEffort,
15} from "@t3tools/shared/model";17} from "@t3tools/shared/model";
16import { memo, useCallback, useState } from "react";18import { memo, useCallback, useState } from "react";
17import type { VariantProps } from "class-variance-authority";19import type { VariantProps } from "class-variance-authority";
...@@ -53,6 +55,16 @@ function getRawEffort(...@@ -53,6 +55,16 @@ function getRawEffort(
53 return trimOrNull((modelOptions as ClaudeModelOptions | undefined)?.effort);55 return trimOrNull((modelOptions as ClaudeModelOptions | undefined)?.effort);
54}56}
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
56function buildNextOptions(68function buildNextOptions(
57 provider: ProviderKind,69 provider: ProviderKind,
58 modelOptions: ProviderOptions | null | undefined,70 modelOptions: ProviderOptions | null | undefined,
...@@ -78,21 +90,10 @@ function getSelectedTraits(...@@ -78,21 +90,10 @@ function getSelectedTraits(
78 : caps.reasoningEffortLevels.filter(90 : caps.reasoningEffortLevels.filter(
79 (option) => !caps.promptInjectedEffortLevels.includes(option.value),91 (option) => !caps.promptInjectedEffortLevels.includes(option.value),
80 );92 );
81 const defaultEffort = getDefaultEffort(caps);
8293
83 // Resolve effort from options (provider-specific key)94 // Resolve effort from options (provider-specific key)
84 const resolvedEffort = getRawEffort(provider, modelOptions);95 const rawEffort = getRawEffort(provider, modelOptions);
8596 const effort = resolveEffort(caps, rawEffort) ?? null;
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;
9697
97 // Thinking toggle (only for models that support it)98 // Thinking toggle (only for models that support it)
98 const thinkingEnabled = caps.supportsThinkingToggle99 const thinkingEnabled = caps.supportsThinkingToggle
...@@ -104,6 +105,15 @@ function getSelectedTraits(...@@ -104,6 +105,15 @@ function getSelectedTraits(
104 caps.supportsFastMode &&105 caps.supportsFastMode &&
105 (modelOptions as { fastMode?: boolean } | undefined)?.fastMode === true;106 (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
107 // Prompt-controlled effort (e.g. ultrathink in prompt text)117 // Prompt-controlled effort (e.g. ultrathink in prompt text)
108 const ultrathinkPromptControlled =118 const ultrathinkPromptControlled =
109 allowPromptInjectedEffort &&119 allowPromptInjectedEffort &&
...@@ -116,6 +126,9 @@ function getSelectedTraits(...@@ -116,6 +126,9 @@ function getSelectedTraits(
116 effortLevels,126 effortLevels,
117 thinkingEnabled,127 thinkingEnabled,
118 fastModeEnabled,128 fastModeEnabled,
129 contextWindowOptions,
130 contextWindow,
131 defaultContextWindow,
119 ultrathinkPromptControlled,132 ultrathinkPromptControlled,
120 };133 };
121}134}
...@@ -159,6 +172,9 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({...@@ -159,6 +172,9 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({
159 effortLevels,172 effortLevels,
160 thinkingEnabled,173 thinkingEnabled,
161 fastModeEnabled,174 fastModeEnabled,
175 contextWindowOptions,
176 contextWindow,
177 defaultContextWindow,
162 ultrathinkPromptControlled,178 ultrathinkPromptControlled,
163 } = getSelectedTraits(provider, models, model, prompt, modelOptions, allowPromptInjectedEffort);179 } = getSelectedTraits(provider, models, model, prompt, modelOptions, allowPromptInjectedEffort);
164 const defaultEffort = getDefaultEffort(caps);180 const defaultEffort = getDefaultEffort(caps);
...@@ -194,7 +210,7 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({...@@ -194,7 +210,7 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({
194 ],210 ],
195 );211 );
196212
197 if (effort === null && thinkingEnabled === null) {213 if (effort === null && thinkingEnabled === null && contextWindowOptions.length <= 1) {
198 return null;214 return null;
199 }215 }
200216
...@@ -258,6 +274,33 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({...@@ -258,6 +274,33 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({
258 </MenuGroup>274 </MenuGroup>
259 </>275 </>
260 ) : null}276 ) : 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}
261 </>304 </>
262 );305 );
263});306});
...@@ -281,12 +324,19 @@ export const TraitsPicker = memo(function TraitsPicker({...@@ -281,12 +324,19 @@ export const TraitsPicker = memo(function TraitsPicker({
281 effortLevels,324 effortLevels,
282 thinkingEnabled,325 thinkingEnabled,
283 fastModeEnabled,326 fastModeEnabled,
327 contextWindowOptions,
328 contextWindow,
329 defaultContextWindow,
284 ultrathinkPromptControlled,330 ultrathinkPromptControlled,
285 } = getSelectedTraits(provider, models, model, prompt, modelOptions, allowPromptInjectedEffort);331 } = getSelectedTraits(provider, models, model, prompt, modelOptions, allowPromptInjectedEffort);
286332
287 const effortLabel = effort333 const effortLabel = effort
288 ? (effortLevels.find((l) => l.value === effort)?.label ?? effort)334 ? (effortLevels.find((l) => l.value === effort)?.label ?? effort)
289 : null;335 : null;
336 const contextWindowLabel =
337 contextWindowOptions.length > 1 && contextWindow !== defaultContextWindow
338 ? (contextWindowOptions.find((o) => o.value === contextWindow)?.label ?? null)
339 : null;
290 const triggerLabel = [340 const triggerLabel = [
291 ultrathinkPromptControlled341 ultrathinkPromptControlled
292 ? "Ultrathink"342 ? "Ultrathink"
...@@ -296,6 +346,7 @@ export const TraitsPicker = memo(function TraitsPicker({...@@ -296,6 +346,7 @@ export const TraitsPicker = memo(function TraitsPicker({
296 ? null346 ? null
297 : `Thinking ${thinkingEnabled ? "On" : "Off"}`,347 : `Thinking ${thinkingEnabled ? "On" : "Off"}`,
298 ...(caps.supportsFastMode && fastModeEnabled ? ["Fast"] : []),348 ...(caps.supportsFastMode && fastModeEnabled ? ["Fast"] : []),
349 ...(contextWindowLabel ? [contextWindowLabel] : []),
299 ]350 ]
300 .filter(Boolean)351 .filter(Boolean)
301 .join(" · ");352 .join(" · ");
apps/web/src/components/chat/composerProviderRegistry.test.tsx+20-6
...@@ -16,6 +16,7 @@ const CODEX_MODELS: ReadonlyArray<ServerProviderModel> = [...@@ -16,6 +16,7 @@ const CODEX_MODELS: ReadonlyArray<ServerProviderModel> = [
16 ],16 ],
17 supportsFastMode: true,17 supportsFastMode: true,
18 supportsThinkingToggle: false,18 supportsThinkingToggle: false,
19 contextWindowOptions: [],
19 promptInjectedEffortLevels: [],20 promptInjectedEffortLevels: [],
20 },21 },
21 },22 },
...@@ -35,6 +36,7 @@ const CLAUDE_MODELS: ReadonlyArray<ServerProviderModel> = [...@@ -35,6 +36,7 @@ const CLAUDE_MODELS: ReadonlyArray<ServerProviderModel> = [
35 ],36 ],
36 supportsFastMode: true,37 supportsFastMode: true,
37 supportsThinkingToggle: false,38 supportsThinkingToggle: false,
39 contextWindowOptions: [],
38 promptInjectedEffortLevels: ["ultrathink"],40 promptInjectedEffortLevels: ["ultrathink"],
39 },41 },
40 },42 },
...@@ -51,6 +53,7 @@ const CLAUDE_MODELS: ReadonlyArray<ServerProviderModel> = [...@@ -51,6 +53,7 @@ const CLAUDE_MODELS: ReadonlyArray<ServerProviderModel> = [
51 ],53 ],
52 supportsFastMode: false,54 supportsFastMode: false,
53 supportsThinkingToggle: false,55 supportsThinkingToggle: false,
56 contextWindowOptions: [],
54 promptInjectedEffortLevels: ["ultrathink"],57 promptInjectedEffortLevels: ["ultrathink"],
55 },58 },
56 },59 },
...@@ -62,6 +65,7 @@ const CLAUDE_MODELS: ReadonlyArray<ServerProviderModel> = [...@@ -62,6 +65,7 @@ const CLAUDE_MODELS: ReadonlyArray<ServerProviderModel> = [
62 reasoningEffortLevels: [],65 reasoningEffortLevels: [],
63 supportsFastMode: false,66 supportsFastMode: false,
64 supportsThinkingToggle: true,67 supportsThinkingToggle: true,
68 contextWindowOptions: [],
65 promptInjectedEffortLevels: [],69 promptInjectedEffortLevels: [],
66 },70 },
67 },71 },
...@@ -80,7 +84,9 @@ describe("getComposerProviderState", () => {...@@ -80,7 +84,9 @@ describe("getComposerProviderState", () => {
80 expect(state).toEqual({84 expect(state).toEqual({
81 provider: "codex",85 provider: "codex",
82 promptEffort: "high",86 promptEffort: "high",
83 modelOptionsForDispatch: undefined,87 modelOptionsForDispatch: {
88 reasoningEffort: "high",
89 },
84 });90 });
85 });91 });
8692
...@@ -125,12 +131,13 @@ describe("getComposerProviderState", () => {...@@ -125,12 +131,13 @@ describe("getComposerProviderState", () => {
125 provider: "codex",131 provider: "codex",
126 promptEffort: "high",132 promptEffort: "high",
127 modelOptionsForDispatch: {133 modelOptionsForDispatch: {
134 reasoningEffort: "high",
128 fastMode: true,135 fastMode: true,
129 },136 },
130 });137 });
131 });138 });
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", () => {
134 const state = getComposerProviderState({141 const state = getComposerProviderState({
135 provider: "codex",142 provider: "codex",
136 model: "gpt-5.4",143 model: "gpt-5.4",
...@@ -147,7 +154,9 @@ describe("getComposerProviderState", () => {...@@ -147,7 +154,9 @@ describe("getComposerProviderState", () => {
147 expect(state).toEqual({154 expect(state).toEqual({
148 provider: "codex",155 provider: "codex",
149 promptEffort: "high",156 promptEffort: "high",
150 modelOptionsForDispatch: undefined,157 modelOptionsForDispatch: {
158 reasoningEffort: "high",
159 },
151 });160 });
152 });161 });
153162
...@@ -163,7 +172,9 @@ describe("getComposerProviderState", () => {...@@ -163,7 +172,9 @@ describe("getComposerProviderState", () => {
163 expect(state).toEqual({172 expect(state).toEqual({
164 provider: "claudeAgent",173 provider: "claudeAgent",
165 promptEffort: "high",174 promptEffort: "high",
166 modelOptionsForDispatch: undefined,175 modelOptionsForDispatch: {
176 effort: "high",
177 },
167 });178 });
168 });179 });
169180
...@@ -232,12 +243,13 @@ describe("getComposerProviderState", () => {...@@ -232,12 +243,13 @@ describe("getComposerProviderState", () => {
232 provider: "claudeAgent",243 provider: "claudeAgent",
233 promptEffort: "high",244 promptEffort: "high",
234 modelOptionsForDispatch: {245 modelOptionsForDispatch: {
246 effort: "high",
235 fastMode: true,247 fastMode: true,
236 },248 },
237 });249 });
238 });250 });
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", () => {
241 const state = getComposerProviderState({253 const state = getComposerProviderState({
242 provider: "claudeAgent",254 provider: "claudeAgent",
243 model: "claude-opus-4-6",255 model: "claude-opus-4-6",
...@@ -254,7 +266,9 @@ describe("getComposerProviderState", () => {...@@ -254,7 +266,9 @@ describe("getComposerProviderState", () => {
254 expect(state).toEqual({266 expect(state).toEqual({
255 provider: "claudeAgent",267 provider: "claudeAgent",
256 promptEffort: "high",268 promptEffort: "high",
257 modelOptionsForDispatch: undefined,269 modelOptionsForDispatch: {
270 effort: "high",
271 },
258 });272 });
259 });273 });
260});274});
apps/web/src/components/chat/composerProviderRegistry.tsx+7-23
...@@ -1,16 +1,10 @@...@@ -1,16 +1,10 @@
1import {1import {
2 type ModelSlug,
3 type ProviderKind,2 type ProviderKind,
4 type ProviderModelOptions,3 type ProviderModelOptions,
5 type ServerProviderModel,4 type ServerProviderModel,
6 type ThreadId,5 type ThreadId,
7} from "@t3tools/contracts";6} from "@t3tools/contracts";
8import {7import { isClaudeUltrathinkPrompt, resolveEffort } from "@t3tools/shared/model";
9 isClaudeUltrathinkPrompt,
10 trimOrNull,
11 getDefaultEffort,
12 hasEffortLevel,
13} from "@t3tools/shared/model";
14import type { ReactNode } from "react";8import type { ReactNode } from "react";
15import {9import {
16 getProviderModelCapabilities,10 getProviderModelCapabilities,
...@@ -21,7 +15,7 @@ import { TraitsMenuContent, TraitsPicker } from "./TraitsPicker";...@@ -21,7 +15,7 @@ import { TraitsMenuContent, TraitsPicker } from "./TraitsPicker";
2115
22export type ComposerProviderStateInput = {16export type ComposerProviderStateInput = {
23 provider: ProviderKind;17 provider: ProviderKind;
24 model: ModelSlug;18 model: string;
25 models: ReadonlyArray<ServerProviderModel>;19 models: ReadonlyArray<ServerProviderModel>;
26 prompt: string;20 prompt: string;
27 modelOptions: ProviderModelOptions | null | undefined;21 modelOptions: ProviderModelOptions | null | undefined;
...@@ -40,7 +34,7 @@ type ProviderRegistryEntry = {...@@ -40,7 +34,7 @@ type ProviderRegistryEntry = {
40 getState: (input: ComposerProviderStateInput) => ComposerProviderState;34 getState: (input: ComposerProviderStateInput) => ComposerProviderState;
41 renderTraitsMenuContent: (input: {35 renderTraitsMenuContent: (input: {
42 threadId: ThreadId;36 threadId: ThreadId;
43 model: ModelSlug;37 model: string;
44 models: ReadonlyArray<ServerProviderModel>;38 models: ReadonlyArray<ServerProviderModel>;
45 modelOptions: ProviderModelOptions[ProviderKind] | undefined;39 modelOptions: ProviderModelOptions[ProviderKind] | undefined;
46 prompt: string;40 prompt: string;
...@@ -48,7 +42,7 @@ type ProviderRegistryEntry = {...@@ -48,7 +42,7 @@ type ProviderRegistryEntry = {
48 }) => ReactNode;42 }) => ReactNode;
49 renderTraitsPicker: (input: {43 renderTraitsPicker: (input: {
50 threadId: ThreadId;44 threadId: ThreadId;
51 model: ModelSlug;45 model: string;
52 models: ReadonlyArray<ServerProviderModel>;46 models: ReadonlyArray<ServerProviderModel>;
53 modelOptions: ProviderModelOptions[ProviderKind] | undefined;47 modelOptions: ProviderModelOptions[ProviderKind] | undefined;
54 prompt: string;48 prompt: string;
...@@ -72,17 +66,7 @@ function getProviderStateFromCapabilities(...@@ -72,17 +66,7 @@ function getProviderStateFromCapabilities(
72 : null66 : null
73 : null;67 : null;
7468
75 const draftEffort = trimOrNull(rawEffort);69 const promptEffort = resolveEffort(caps, rawEffort) ?? null;
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;
8670
87 // Normalize options for dispatch71 // Normalize options for dispatch
88 const normalizedOptions =72 const normalizedOptions =
...@@ -180,7 +164,7 @@ export function getComposerProviderState(input: ComposerProviderStateInput): Com...@@ -180,7 +164,7 @@ export function getComposerProviderState(input: ComposerProviderStateInput): Com
180export function renderProviderTraitsMenuContent(input: {164export function renderProviderTraitsMenuContent(input: {
181 provider: ProviderKind;165 provider: ProviderKind;
182 threadId: ThreadId;166 threadId: ThreadId;
183 model: ModelSlug;167 model: string;
184 models: ReadonlyArray<ServerProviderModel>;168 models: ReadonlyArray<ServerProviderModel>;
185 modelOptions: ProviderModelOptions[ProviderKind] | undefined;169 modelOptions: ProviderModelOptions[ProviderKind] | undefined;
186 prompt: string;170 prompt: string;
...@@ -199,7 +183,7 @@ export function renderProviderTraitsMenuContent(input: {...@@ -199,7 +183,7 @@ export function renderProviderTraitsMenuContent(input: {
199export function renderProviderTraitsPicker(input: {183export function renderProviderTraitsPicker(input: {
200 provider: ProviderKind;184 provider: ProviderKind;
201 threadId: ThreadId;185 threadId: ThreadId;
202 model: ModelSlug;186 model: string;
203 models: ReadonlyArray<ServerProviderModel>;187 models: ReadonlyArray<ServerProviderModel>;
204 modelOptions: ProviderModelOptions[ProviderKind] | undefined;188 modelOptions: ProviderModelOptions[ProviderKind] | undefined;
205 prompt: string;189 prompt: string;
apps/web/src/composerDraftStore.ts+16-8
...@@ -2,7 +2,7 @@ import {...@@ -2,7 +2,7 @@ import {
2 CODEX_REASONING_EFFORT_OPTIONS,2 CODEX_REASONING_EFFORT_OPTIONS,
3 type ClaudeCodeEffort,3 type ClaudeCodeEffort,
4 type CodexReasoningEffort,4 type CodexReasoningEffort,
5 type ModelSlug,5 DEFAULT_MODEL_BY_PROVIDER,
6 ModelSelection,6 ModelSelection,
7 ProjectId,7 ProjectId,
8 ProviderInteractionMode,8 ProviderInteractionMode,
...@@ -15,7 +15,7 @@ import {...@@ -15,7 +15,7 @@ import {
15import * as Schema from "effect/Schema";15import * as Schema from "effect/Schema";
16import * as Equal from "effect/Equal";16import * as Equal from "effect/Equal";
17import { DeepMutable } from "effect/Types";17import { DeepMutable } from "effect/Types";
18import { getDefaultModel, normalizeModelSlug } from "@t3tools/shared/model";18import { normalizeModelSlug } from "@t3tools/shared/model";
19import { useMemo } from "react";19import { useMemo } from "react";
20import { getLocalStorageItem } from "./hooks/useLocalStorage";20import { getLocalStorageItem } from "./hooks/useLocalStorage";
21import { resolveAppModelSelection } from "./modelSelection";21import { resolveAppModelSelection } from "./modelSelection";
...@@ -263,7 +263,7 @@ interface ComposerDraftStoreState {...@@ -263,7 +263,7 @@ interface ComposerDraftStoreState {
263}263}
264264
265export interface EffectiveComposerModelState {265export interface EffectiveComposerModelState {
266 selectedModel: ModelSlug;266 selectedModel: string;
267 modelOptions: ProviderModelOptions | null;267 modelOptions: ProviderModelOptions | null;
268}268}
269269
...@@ -475,12 +475,20 @@ function normalizeProviderModelOptions(...@@ -475,12 +475,20 @@ function normalizeProviderModelOptions(
475 : claudeCandidate?.fastMode === false475 : claudeCandidate?.fastMode === false
476 ? false476 ? false
477 : undefined;477 : undefined;
478 const claudeContextWindow =
479 typeof claudeCandidate?.contextWindow === "string" && claudeCandidate.contextWindow.length > 0
480 ? claudeCandidate.contextWindow
481 : undefined;
478 const claude =482 const claude =
479 claudeThinking !== undefined || claudeEffort !== undefined || claudeFastMode !== undefined483 claudeThinking !== undefined ||
484 claudeEffort !== undefined ||
485 claudeFastMode !== undefined ||
486 claudeContextWindow !== undefined
480 ? {487 ? {
481 ...(claudeThinking !== undefined ? { thinking: claudeThinking } : {}),488 ...(claudeThinking !== undefined ? { thinking: claudeThinking } : {}),
482 ...(claudeEffort !== undefined ? { effort: claudeEffort } : {}),489 ...(claudeEffort !== undefined ? { effort: claudeEffort } : {}),
483 ...(claudeFastMode !== undefined ? { fastMode: claudeFastMode } : {}),490 ...(claudeFastMode !== undefined ? { fastMode: claudeFastMode } : {}),
491 ...(claudeContextWindow !== undefined ? { contextWindow: claudeContextWindow } : {}),
484 }492 }
485 : undefined;493 : undefined;
486494
...@@ -594,7 +602,7 @@ function legacyToModelSelectionByProvider(...@@ -594,7 +602,7 @@ function legacyToModelSelectionByProvider(
594 model:602 model:
595 modelSelection?.provider === provider603 modelSelection?.provider === provider
596 ? modelSelection.model604 ? modelSelection.model
597 : getDefaultModel(provider),605 : DEFAULT_MODEL_BY_PROVIDER[provider],
598 options,606 options,
599 };607 };
600 }608 }
...@@ -1676,7 +1684,7 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(...@@ -1676,7 +1684,7 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
1676 if (opts) {1684 if (opts) {
1677 nextMap[provider] = {1685 nextMap[provider] = {
1678 provider,1686 provider,
1679 model: current?.model ?? getDefaultModel(provider),1687 model: current?.model ?? DEFAULT_MODEL_BY_PROVIDER[provider],
1680 options: opts,1688 options: opts,
1681 };1689 };
1682 } else if (current?.options) {1690 } else if (current?.options) {
...@@ -1726,7 +1734,7 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(...@@ -1726,7 +1734,7 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
1726 if (providerOpts) {1734 if (providerOpts) {
1727 nextMap[normalizedProvider] = {1735 nextMap[normalizedProvider] = {
1728 provider: normalizedProvider,1736 provider: normalizedProvider,
1729 model: currentForProvider?.model ?? getDefaultModel(normalizedProvider),1737 model: currentForProvider?.model ?? DEFAULT_MODEL_BY_PROVIDER[normalizedProvider],
1730 options: providerOpts,1738 options: providerOpts,
1731 };1739 };
1732 } else if (currentForProvider?.options) {1740 } else if (currentForProvider?.options) {
...@@ -1744,7 +1752,7 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(...@@ -1744,7 +1752,7 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
1744 base.modelSelectionByProvider[normalizedProvider] ??1752 base.modelSelectionByProvider[normalizedProvider] ??
1745 ({1753 ({
1746 provider: normalizedProvider,1754 provider: normalizedProvider,
1747 model: getDefaultModel(normalizedProvider),1755 model: DEFAULT_MODEL_BY_PROVIDER[normalizedProvider],
1748 } as ModelSelection);1756 } as ModelSelection);
1749 if (providerOpts) {1757 if (providerOpts) {
1750 nextStickyMap[normalizedProvider] = {1758 nextStickyMap[normalizedProvider] = {
apps/web/src/providerModels.ts+8-20
...@@ -7,17 +7,13 @@ import {...@@ -7,17 +7,13 @@ import {
7 type ServerProvider,7 type ServerProvider,
8 type ServerProviderModel,8 type ServerProviderModel,
9} from "@t3tools/contracts";9} from "@t3tools/contracts";
10import {10import { normalizeModelSlug, resolveContextWindow, resolveEffort } from "@t3tools/shared/model";
11 getDefaultEffort,
12 hasEffortLevel,
13 normalizeModelSlug,
14 trimOrNull,
15} from "@t3tools/shared/model";
1611
17const EMPTY_CAPABILITIES: ModelCapabilities = {12const EMPTY_CAPABILITIES: ModelCapabilities = {
18 reasoningEffortLevels: [],13 reasoningEffortLevels: [],
19 supportsFastMode: false,14 supportsFastMode: false,
20 supportsThinkingToggle: false,15 supportsThinkingToggle: false,
16 contextWindowOptions: [],
21 promptInjectedEffortLevels: [],17 promptInjectedEffortLevels: [],
22};18};
2319
...@@ -78,11 +74,10 @@ export function normalizeCodexModelOptionsWithCapabilities(...@@ -78,11 +74,10 @@ export function normalizeCodexModelOptionsWithCapabilities(
78 caps: ModelCapabilities,74 caps: ModelCapabilities,
79 modelOptions: CodexModelOptions | null | undefined,75 modelOptions: CodexModelOptions | null | undefined,
80): CodexModelOptions | undefined {76): CodexModelOptions | undefined {
81 const defaultReasoningEffort = getDefaultEffort(caps);77 const reasoningEffort = resolveEffort(caps, modelOptions?.reasoningEffort);
82 const reasoningEffort = trimOrNull(modelOptions?.reasoningEffort) ?? defaultReasoningEffort;
83 const fastModeEnabled = modelOptions?.fastMode === true;78 const fastModeEnabled = modelOptions?.fastMode === true;
84 const nextOptions: CodexModelOptions = {79 const nextOptions: CodexModelOptions = {
85 ...(reasoningEffort && reasoningEffort !== defaultReasoningEffort80 ...(reasoningEffort
86 ? { reasoningEffort: reasoningEffort as CodexModelOptions["reasoningEffort"] }81 ? { reasoningEffort: reasoningEffort as CodexModelOptions["reasoningEffort"] }
87 : {}),82 : {}),
88 ...(fastModeEnabled ? { fastMode: true } : {}),83 ...(fastModeEnabled ? { fastMode: true } : {}),
...@@ -94,23 +89,16 @@ export function normalizeClaudeModelOptionsWithCapabilities(...@@ -94,23 +89,16 @@ export function normalizeClaudeModelOptionsWithCapabilities(
94 caps: ModelCapabilities,89 caps: ModelCapabilities,
95 modelOptions: ClaudeModelOptions | null | undefined,90 modelOptions: ClaudeModelOptions | null | undefined,
96): ClaudeModelOptions | undefined {91): ClaudeModelOptions | undefined {
97 const defaultReasoningEffort = getDefaultEffort(caps);92 const effort = resolveEffort(caps, modelOptions?.effort);
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;
107 const thinking =93 const thinking =
108 caps.supportsThinkingToggle && modelOptions?.thinking === false ? false : undefined;94 caps.supportsThinkingToggle && modelOptions?.thinking === false ? false : undefined;
109 const fastMode = caps.supportsFastMode && modelOptions?.fastMode === true ? true : undefined;95 const fastMode = caps.supportsFastMode && modelOptions?.fastMode === true ? true : undefined;
96 const contextWindow = resolveContextWindow(caps, modelOptions?.contextWindow);
110 const nextOptions: ClaudeModelOptions = {97 const nextOptions: ClaudeModelOptions = {
111 ...(thinking === false ? { thinking: false } : {}),98 ...(thinking === false ? { thinking: false } : {}),
112 ...(effort ? { effort } : {}),99 ...(effort ? { effort: effort as ClaudeModelOptions["effort"] } : {}),
113 ...(fastMode ? { fastMode: true } : {}),100 ...(fastMode ? { fastMode: true } : {}),
101 ...(contextWindow ? { contextWindow } : {}),
114 };102 };
115 return Object.keys(nextOptions).length > 0 ? nextOptions : undefined;103 return Object.keys(nextOptions).length > 0 ? nextOptions : undefined;
116}104}
packages/contracts/src/model.ts+11-4
...@@ -18,6 +18,7 @@ export const ClaudeModelOptions = Schema.Struct({...@@ -18,6 +18,7 @@ export const ClaudeModelOptions = Schema.Struct({
18 thinking: Schema.optional(Schema.Boolean),18 thinking: Schema.optional(Schema.Boolean),
19 effort: Schema.optional(Schema.Literals(CLAUDE_CODE_EFFORT_OPTIONS)),19 effort: Schema.optional(Schema.Literals(CLAUDE_CODE_EFFORT_OPTIONS)),
20 fastMode: Schema.optional(Schema.Boolean),20 fastMode: Schema.optional(Schema.Boolean),
21 contextWindow: Schema.optional(Schema.String),
21});22});
22export type ClaudeModelOptions = typeof ClaudeModelOptions.Type;23export type ClaudeModelOptions = typeof ClaudeModelOptions.Type;
2324
...@@ -34,17 +35,23 @@ export const EffortOption = Schema.Struct({...@@ -34,17 +35,23 @@ export const EffortOption = Schema.Struct({
34});35});
35export type EffortOption = typeof EffortOption.Type;36export 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
37export const ModelCapabilities = Schema.Struct({45export const ModelCapabilities = Schema.Struct({
38 reasoningEffortLevels: Schema.Array(EffortOption),46 reasoningEffortLevels: Schema.Array(EffortOption),
39 supportsFastMode: Schema.Boolean,47 supportsFastMode: Schema.Boolean,
40 supportsThinkingToggle: Schema.Boolean,48 supportsThinkingToggle: Schema.Boolean,
49 contextWindowOptions: Schema.Array(ContextWindowOption),
41 promptInjectedEffortLevels: Schema.Array(TrimmedNonEmptyString),50 promptInjectedEffortLevels: Schema.Array(TrimmedNonEmptyString),
42});51});
43export type ModelCapabilities = typeof ModelCapabilities.Type;52export type ModelCapabilities = typeof ModelCapabilities.Type;
4453
45export type ModelSlug = string & {};54export const DEFAULT_MODEL_BY_PROVIDER: Record<ProviderKind, string> = {
46
47export const DEFAULT_MODEL_BY_PROVIDER: Record<ProviderKind, ModelSlug> = {
48 codex: "gpt-5.4",55 codex: "gpt-5.4",
49 claudeAgent: "claude-sonnet-4-6",56 claudeAgent: "claude-sonnet-4-6",
50};57};
...@@ -57,7 +64,7 @@ export const DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER: Record<ProviderKind,...@@ -57,7 +64,7 @@ export const DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER: Record<ProviderKind,
57 claudeAgent: "claude-haiku-4-5",64 claudeAgent: "claude-haiku-4-5",
58};65};
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>> = {
61 codex: {68 codex: {
62 "5.4": "gpt-5.4",69 "5.4": "gpt-5.4",
63 "5.3": "gpt-5.3-codex",70 "5.3": "gpt-5.3-codex",
packages/shared/src/model.test.ts+124-7
...@@ -1,16 +1,17 @@...@@ -1,16 +1,17 @@
1import { describe, expect, it } from "vitest";1import { describe, expect, it } from "vitest";
2import {2import { DEFAULT_MODEL_BY_PROVIDER, type ModelCapabilities } from "@t3tools/contracts";
3 DEFAULT_MODEL,
4 DEFAULT_MODEL_BY_PROVIDER,
5 type ModelCapabilities,
6} from "@t3tools/contracts";
73
8import {4import {
9 applyClaudePromptEffortPrefix,5 applyClaudePromptEffortPrefix,
6 getDefaultContextWindow,
10 getDefaultEffort,7 getDefaultEffort,
8 hasContextWindowOption,
11 hasEffortLevel,9 hasEffortLevel,
12 isClaudeUltrathinkPrompt,10 isClaudeUltrathinkPrompt,
13 normalizeModelSlug,11 normalizeModelSlug,
12 resolveApiModelId,
13 resolveContextWindow,
14 resolveEffort,
14 resolveModelSlug,15 resolveModelSlug,
15 resolveModelSlugForProvider,16 resolveModelSlugForProvider,
16 resolveSelectableModel,17 resolveSelectableModel,
...@@ -24,6 +25,7 @@ const codexCaps: ModelCapabilities = {...@@ -24,6 +25,7 @@ const codexCaps: ModelCapabilities = {
24 ],25 ],
25 supportsFastMode: true,26 supportsFastMode: true,
26 supportsThinkingToggle: false,27 supportsThinkingToggle: false,
28 contextWindowOptions: [],
27 promptInjectedEffortLevels: [],29 promptInjectedEffortLevels: [],
28};30};
2931
...@@ -35,6 +37,10 @@ const claudeCaps: ModelCapabilities = {...@@ -35,6 +37,10 @@ const claudeCaps: ModelCapabilities = {
35 ],37 ],
36 supportsFastMode: false,38 supportsFastMode: false,
37 supportsThinkingToggle: false,39 supportsThinkingToggle: false,
40 contextWindowOptions: [
41 { value: "200k", label: "200k" },
42 { value: "1m", label: "1M", isDefault: true },
43 ],
38 promptInjectedEffortLevels: ["ultrathink"],44 promptInjectedEffortLevels: ["ultrathink"],
39};45};
4046
...@@ -54,14 +60,15 @@ describe("normalizeModelSlug", () => {...@@ -54,14 +60,15 @@ describe("normalizeModelSlug", () => {
5460
55describe("resolveModelSlug", () => {61describe("resolveModelSlug", () => {
56 it("returns defaults when the model is missing", () => {62 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
58 expect(resolveModelSlugForProvider("claudeAgent", undefined)).toBe(65 expect(resolveModelSlugForProvider("claudeAgent", undefined)).toBe(
59 DEFAULT_MODEL_BY_PROVIDER.claudeAgent,66 DEFAULT_MODEL_BY_PROVIDER.claudeAgent,
60 );67 );
61 });68 });
6269
63 it("preserves normalized unknown models", () => {70 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");
65 });72 });
66});73});
6774
...@@ -89,6 +96,42 @@ describe("capability helpers", () => {...@@ -89,6 +96,42 @@ describe("capability helpers", () => {
89 });96 });
90});97});
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
92describe("misc helpers", () => {135describe("misc helpers", () => {
93 it("detects ultrathink prompts", () => {136 it("detects ultrathink prompts", () => {
94 expect(isClaudeUltrathinkPrompt("Ultrathink:\nInvestigate")).toBe(true);137 expect(isClaudeUltrathinkPrompt("Ultrathink:\nInvestigate")).toBe(true);
...@@ -109,3 +152,77 @@ describe("misc helpers", () => {...@@ -109,3 +152,77 @@ describe("misc helpers", () => {
109 expect(trimOrNull(" ")).toBeNull();152 expect(trimOrNull(" ")).toBeNull();
110 });153 });
111});154});
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 {...@@ -3,7 +3,7 @@ import {
3 MODEL_SLUG_ALIASES_BY_PROVIDER,3 MODEL_SLUG_ALIASES_BY_PROVIDER,
4 type ClaudeCodeEffort,4 type ClaudeCodeEffort,
5 type ModelCapabilities,5 type ModelCapabilities,
6 type ModelSlug,6 type ModelSelection,
7 type ProviderKind,7 type ProviderKind,
8} from "@t3tools/contracts";8} from "@t3tools/contracts";
99
...@@ -12,10 +12,6 @@ export interface SelectableModelOption {...@@ -12,10 +12,6 @@ export interface SelectableModelOption {
12 name: string;12 name: string;
13}13}
1414
15export function getDefaultModel(provider: ProviderKind = "codex"): ModelSlug {
16 return DEFAULT_MODEL_BY_PROVIDER[provider];
17}
18
19// ── Effort helpers ────────────────────────────────────────────────────15// ── Effort helpers ────────────────────────────────────────────────────
2016
21/** Check whether a capabilities object includes a given effort value. */17/** Check whether a capabilities object includes a given effort value. */
...@@ -28,6 +24,65 @@ export function getDefaultEffort(caps: ModelCapabilities): string | null {...@@ -28,6 +24,65 @@ export function getDefaultEffort(caps: ModelCapabilities): string | null {
28 return caps.reasoningEffortLevels.find((l) => l.isDefault)?.value ?? null;24 return caps.reasoningEffortLevels.find((l) => l.isDefault)?.value ?? null;
29}25}
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
31export function isClaudeUltrathinkPrompt(text: string | null | undefined): boolean {86export function isClaudeUltrathinkPrompt(text: string | null | undefined): boolean {
32 return typeof text === "string" && /\bultrathink\b/i.test(text);87 return typeof text === "string" && /\bultrathink\b/i.test(text);
33}88}
...@@ -35,7 +90,7 @@ export function isClaudeUltrathinkPrompt(text: string | null | undefined): boole...@@ -35,7 +90,7 @@ export function isClaudeUltrathinkPrompt(text: string | null | undefined): boole
35export function normalizeModelSlug(90export function normalizeModelSlug(
36 model: string | null | undefined,91 model: string | null | undefined,
37 provider: ProviderKind = "codex",92 provider: ProviderKind = "codex",
38): ModelSlug | null {93): string | null {
39 if (typeof model !== "string") {94 if (typeof model !== "string") {
40 return null;95 return null;
41 }96 }
...@@ -45,18 +100,18 @@ export function normalizeModelSlug(...@@ -45,18 +100,18 @@ export function normalizeModelSlug(
45 return null;100 return null;
46 }101 }
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>;
49 const aliased = Object.prototype.hasOwnProperty.call(aliases, trimmed)104 const aliased = Object.prototype.hasOwnProperty.call(aliases, trimmed)
50 ? aliases[trimmed]105 ? aliases[trimmed]
51 : undefined;106 : undefined;
52 return typeof aliased === "string" ? aliased : (trimmed as ModelSlug);107 return typeof aliased === "string" ? aliased : trimmed;
53}108}
54109
55export function resolveSelectableModel(110export function resolveSelectableModel(
56 provider: ProviderKind,111 provider: ProviderKind,
57 value: string | null | undefined,112 value: string | null | undefined,
58 options: ReadonlyArray<SelectableModelOption>,113 options: ReadonlyArray<SelectableModelOption>,
59): ModelSlug | null {114): string | null {
60 if (typeof value !== "string") {115 if (typeof value !== "string") {
61 return null;116 return null;
62 }117 }
...@@ -85,10 +140,7 @@ export function resolveSelectableModel(...@@ -85,10 +140,7 @@ export function resolveSelectableModel(
85 return resolved ? resolved.slug : null;140 return resolved ? resolved.slug : null;
86}141}
87142
88export function resolveModelSlug(143export function resolveModelSlug(model: string | null | undefined, provider: ProviderKind): string {
89 model: string | null | undefined,
90 provider: ProviderKind = "codex",
91): ModelSlug {
92 const normalized = normalizeModelSlug(model, provider);144 const normalized = normalizeModelSlug(model, provider);
93 if (!normalized) {145 if (!normalized) {
94 return DEFAULT_MODEL_BY_PROVIDER[provider];146 return DEFAULT_MODEL_BY_PROVIDER[provider];
...@@ -99,7 +151,7 @@ export function resolveModelSlug(...@@ -99,7 +151,7 @@ export function resolveModelSlug(
99export function resolveModelSlugForProvider(151export function resolveModelSlugForProvider(
100 provider: ProviderKind,152 provider: ProviderKind,
101 model: string | null | undefined,153 model: string | null | undefined,
102): ModelSlug {154): string {
103 return resolveModelSlug(model, provider);155 return resolveModelSlug(model, provider);
104}156}
105157
...@@ -110,6 +162,33 @@ export function trimOrNull<T extends string>(value: T | null | undefined): T | n...@@ -110,6 +162,33 @@ export function trimOrNull<T extends string>(value: T | null | undefined): T | n
110 return trimmed || null;162 return trimmed || null;
111}163}
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
113export function applyClaudePromptEffortPrefix(192export function applyClaudePromptEffortPrefix(
114 text: string,193 text: string,
115 effort: ClaudeCodeEffort | null | undefined,194 effort: ClaudeCodeEffort | null | undefined,