diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index 68580c39a1e3e..abfc3d8b62aea 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -50,6 +50,28 @@ import { useComposerMenuState } from "./useComposerMenuState"; type ProviderOptions = ReadonlyArray; +function TraitsTriggerIcon({ level, size }: { level: number | null; size: ComposerControlSize }) { + if (level === null) return ; + return ( + + ); +} + /** * All the picker reads from the prompt while rendering. Passing this instead of * the prompt keeps the picker from re-rendering on every keystroke. @@ -555,7 +577,7 @@ export const TraitsPicker = memo(function TraitsPicker({ return null; } - const { label: triggerLabel } = buildTraitsTriggerDisplay({ + const { label: triggerLabel, reasoningLevel } = buildTraitsTriggerDisplay({ provider, descriptors, primarySelectDescriptorId: primarySelectDescriptor?.id ?? null, @@ -600,7 +622,7 @@ export const TraitsPicker = memo(function TraitsPicker({ size === "xs" ? "gap-1" : "gap-1.5", )} > - + {triggerLabel} @@ -608,7 +630,7 @@ export const TraitsPicker = memo(function TraitsPicker({ ) : ( <> - + {triggerLabel} diff --git a/packages/client-runtime/src/modelTraits.test.ts b/packages/client-runtime/src/modelTraits.test.ts new file mode 100644 index 0000000000000..1bb60634a4df2 --- /dev/null +++ b/packages/client-runtime/src/modelTraits.test.ts @@ -0,0 +1,42 @@ +import { ProviderDriverKind, type ProviderOptionDescriptor } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; +import { buildTraitsTriggerDisplay } from "./modelTraits.ts"; + +function highEffortLevel(efforts: ReadonlyArray, thinking?: boolean) { + const descriptors: Array = [ + { + id: "effort", + label: "Reasoning", + type: "select", + options: efforts.map((id) => ({ id, label: id })), + currentValue: "high", + }, + ]; + if (thinking !== undefined) { + descriptors.push({ + id: "thinking", + label: "Thinking", + type: "boolean", + currentValue: thinking, + }); + } + return buildTraitsTriggerDisplay({ + provider: ProviderDriverKind.make("cursor"), + descriptors, + primarySelectDescriptorId: "effort", + ultrathinkPromptControlled: false, + }).reasoningLevel; +} + +describe("buildTraitsTriggerDisplay reasoningLevel", () => { + it("reads High the same however the provider orders or trims its efforts", () => { + const claude = highEffortLevel(["low", "medium", "high", "xhigh", "max"]); + expect(claude).toBeGreaterThan(0); + expect(highEffortLevel(["xhigh", "high", "medium", "low"])).toBe(claude); + expect(highEffortLevel(["high", "max"])).toBe(claude); + }); + + it("reads zero when thinking is switched off", () => { + expect(highEffortLevel(["low", "high"], false)).toBe(0); + }); +}); diff --git a/packages/client-runtime/src/modelTraits.ts b/packages/client-runtime/src/modelTraits.ts index 198d09e3a13c6..fd26ec12ed5e4 100644 --- a/packages/client-runtime/src/modelTraits.ts +++ b/packages/client-runtime/src/modelTraits.ts @@ -110,12 +110,17 @@ export function buildTraitsTriggerDisplay(input: { ultrathinkPromptControlled: boolean; modelSelection?: ModelSelection | null; reportedModelSelection?: ModelSelection | null | undefined; -}): { label: string } { +}): { label: string; reasoningLevel: number | null } { let fastModeFallbackLabel: string | null = null; let speedLabel: string | null = null; let reasoningLabelIndex = -1; + let reasoningLevel: number | null = null; + let thinkingOff = false; const labels: Array = []; for (const descriptor of input.descriptors) { + if (descriptor.id === "thinking" && descriptor.type === "boolean") { + thinkingOff = descriptor.currentValue === false; + } if (descriptor.id === "fastMode" && descriptor.type === "boolean") { speedLabel = descriptor.currentValue === true ? "Fast" : null; fastModeFallbackLabel = speedLabel ?? "Normal"; @@ -162,13 +167,21 @@ export function buildTraitsTriggerDisplay(input: { REASONING_SELECT_DESCRIPTOR_IDS.has(descriptor.id) ) { reasoningLabelIndex = labels.length; + reasoningLevel = getReasoningLevel( + getProviderOptionCurrentValue( + descriptor, + input.modelSelection, + input.reportedModelSelection, + ), + ); } labels.push(label); } } + if (thinkingOff) reasoningLevel = 0; if (labels.length === 0 && fastModeFallbackLabel !== null) { - return { label: fastModeFallbackLabel }; + return { label: fastModeFallbackLabel, reasoningLevel }; } if (speedLabel) { if (reasoningLabelIndex >= 0) { @@ -177,5 +190,28 @@ export function buildTraitsTriggerDisplay(input: { labels.push(speedLabel); } } - return { label: labels.join(" · ") }; + return { label: labels.join(" · "), reasoningLevel }; +} + +// One scale for every provider so the same effort reads the same on any model. +// Ultrathink is absent: it leaves the effort sent to Claude unchanged. +const REASONING_LEVEL_RANKS: ReadonlyMap = new Map([ + ["none", 0], + ["off", 0], + ["minimal", 1], + ["low", 2], + ["medium", 3], + ["high", 4], + ["xhigh", 5], + // Claude runs Ultracode at xhigh effort. + ["ultracode", 5], + ["max", 6], + ["ultra", 7], +]); +const MAX_REASONING_LEVEL_RANK = 7; + +function getReasoningLevel(value: string | boolean | undefined): number | null { + const rank = + typeof value === "string" ? REASONING_LEVEL_RANKS.get(value.toLowerCase()) : undefined; + return rank === undefined ? null : rank / MAX_REASONING_LEVEL_RANK; }