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