feat: add configurable reasoning effort to Coder agents (#26974)

This commit is contained in:
Danielle Maywood
2026-07-09 23:35:12 +01:00
committed by GitHub
parent 5fed583a46
commit d66e4d794f
72 changed files with 4389 additions and 1275 deletions
+20 -46
View File
@@ -80,6 +80,26 @@
"description": "Cache write or cache creation token price in USD per 1M tokens",
"required": false,
"input_type": "input"
},
{
"json_name": "reasoning_effort.default",
"go_name": "ReasoningEffort.Default",
"type": "string",
"description": "Reasoning effort used when the user has not selected one",
"label": "Default Reasoning Effort",
"required": false,
"enum": ["none", "minimal", "low", "medium", "high", "xhigh", "max"],
"input_type": "select"
},
{
"json_name": "reasoning_effort.max",
"go_name": "ReasoningEffort.Max",
"type": "string",
"description": "Maximum reasoning effort the user may select",
"label": "Max Reasoning Effort",
"required": false,
"enum": ["none", "minimal", "low", "medium", "high", "xhigh", "max"],
"input_type": "select"
}
]
},
@@ -102,16 +122,6 @@
"required": false,
"input_type": "input"
},
{
"json_name": "effort",
"go_name": "Effort",
"type": "string",
"description": "Controls the level of reasoning effort",
"label": "Reasoning Effort",
"required": false,
"enum": ["low", "medium", "high", "xhigh", "max"],
"input_type": "select"
},
{
"json_name": "thinking_display",
"go_name": "ThinkingDisplay",
@@ -288,15 +298,6 @@
"input_type": "input",
"hidden": true
},
{
"json_name": "reasoning_effort",
"go_name": "ReasoningEffort",
"type": "string",
"description": "Controls the level of reasoning effort",
"required": false,
"enum": ["none", "minimal", "low", "medium", "high", "xhigh"],
"input_type": "select"
},
{
"json_name": "reasoning_summary",
"go_name": "ReasoningSummary",
@@ -433,15 +434,6 @@
"required": false,
"input_type": "input",
"hidden": true
},
{
"json_name": "reasoning_effort",
"go_name": "ReasoningEffort",
"type": "string",
"description": "Controls the level of reasoning effort",
"required": false,
"enum": ["none", "minimal", "low", "medium", "high", "xhigh"],
"input_type": "select"
}
]
},
@@ -471,15 +463,6 @@
"required": false,
"input_type": "input"
},
{
"json_name": "reasoning.effort",
"go_name": "Reasoning.Effort",
"type": "string",
"description": "Controls the level of reasoning effort",
"required": false,
"enum": ["none", "minimal", "low", "medium", "high", "xhigh"],
"input_type": "select"
},
{
"json_name": "extra_body",
"go_name": "ExtraBody",
@@ -570,15 +553,6 @@
"required": false,
"input_type": "input"
},
{
"json_name": "reasoning.effort",
"go_name": "Reasoning.Effort",
"type": "string",
"description": "Controls the level of reasoning effort",
"required": false,
"enum": ["none", "minimal", "low", "medium", "high", "xhigh"],
"input_type": "select"
},
{
"json_name": "providerOptions",
"go_name": "ProviderOptions",
+67 -4
View File
@@ -1607,6 +1607,7 @@ export interface Chat {
readonly parent_chat_id?: string;
readonly root_chat_id?: string;
readonly last_model_config_id: string;
readonly last_reasoning_effort?: string;
readonly title: string;
readonly status: ChatStatus;
readonly plan_mode?: ChatPlanMode;
@@ -2490,7 +2491,6 @@ export interface ChatModel {
export interface ChatModelAnthropicProviderOptions {
readonly send_reasoning?: boolean;
readonly thinking?: ChatModelAnthropicThinkingOptions;
readonly effort?: string;
readonly thinking_display?: string;
readonly disable_parallel_tool_use?: boolean;
readonly web_search_enabled?: boolean;
@@ -2518,6 +2518,7 @@ export interface ChatModelCallConfig {
readonly presence_penalty?: number;
readonly frequency_penalty?: number;
readonly cost?: ModelCostConfig;
readonly reasoning_effort?: ChatModelReasoningEffortConfig;
readonly provider_options?: ChatModelProviderOptions;
}
@@ -2535,6 +2536,11 @@ export interface ChatModelConfig {
readonly context_limit: number;
readonly compression_threshold: number;
readonly model_config?: ChatModelCallConfig;
/**
* ReasoningEfforts lists selectable reasoning effort values through
* the model's configured maximum.
*/
readonly reasoning_efforts?: readonly string[];
readonly created_at: string;
readonly updated_at: string;
}
@@ -2575,7 +2581,6 @@ export interface ChatModelGoogleThinkingConfig {
*/
export interface ChatModelOpenAICompatProviderOptions {
readonly user?: string;
readonly reasoning_effort?: string;
}
// From codersdk/chats.go
@@ -2591,7 +2596,6 @@ export interface ChatModelOpenAIProviderOptions {
readonly max_tool_calls?: number;
readonly parallel_tool_calls?: boolean;
readonly user?: string;
readonly reasoning_effort?: string;
readonly reasoning_summary?: string;
readonly max_completion_tokens?: number;
readonly text_verbosity?: string;
@@ -2661,6 +2665,7 @@ export const ChatModelOverrideContexts: ChatModelOverrideContext[] = [
export interface ChatModelOverrideResponse {
readonly context: ChatModelOverrideContext;
readonly model_config_id: string;
readonly reasoning_effort?: string;
readonly is_malformed: boolean;
}
@@ -2700,6 +2705,59 @@ export type ChatModelProviderUnavailableReason =
export const ChatModelProviderUnavailableReasons: ChatModelProviderUnavailableReason[] =
["fetch_failed", "missing_api_key", "user_api_key_required"];
// From codersdk/chats.go
/**
* ChatModelReasoningEffortConfig configures per-model reasoning effort
* bounds. When configured, Default and Max must both be provided before
* storing.
*/
export interface ChatModelReasoningEffortConfig {
readonly default?: string;
readonly max?: string;
}
// From codersdk/chats.go
/**
* Reasoning effort levels, ordered low to high for clamping and comparison.
*/
export const ChatModelReasoningEffortHigh = "high";
// From codersdk/chats.go
/**
* Reasoning effort levels, ordered low to high for clamping and comparison.
*/
export const ChatModelReasoningEffortLow = "low";
// From codersdk/chats.go
/**
* Reasoning effort levels, ordered low to high for clamping and comparison.
*/
export const ChatModelReasoningEffortMax = "max";
// From codersdk/chats.go
/**
* Reasoning effort levels, ordered low to high for clamping and comparison.
*/
export const ChatModelReasoningEffortMedium = "medium";
// From codersdk/chats.go
/**
* Reasoning effort levels, ordered low to high for clamping and comparison.
*/
export const ChatModelReasoningEffortMinimal = "minimal";
// From codersdk/chats.go
/**
* Reasoning effort levels, ordered low to high for clamping and comparison.
*/
export const ChatModelReasoningEffortNone = "none";
// From codersdk/chats.go
/**
* Reasoning effort levels, ordered low to high for clamping and comparison.
*/
export const ChatModelReasoningEffortXHigh = "xhigh";
// From codersdk/chats.go
/**
* ChatModelReasoningOptions configures reasoning behavior for model
@@ -2709,7 +2767,6 @@ export interface ChatModelReasoningOptions {
readonly enabled?: boolean;
readonly exclude?: boolean;
readonly max_tokens?: number;
readonly effort?: string;
}
// From codersdk/chats.go
@@ -2758,6 +2815,7 @@ export interface ChatPersonalModelOverride {
readonly context: ChatPersonalModelOverrideContext;
readonly mode: ChatPersonalModelOverrideMode;
readonly model_config_id: string;
readonly reasoning_effort?: string;
readonly is_set: boolean;
readonly is_malformed: boolean;
}
@@ -3542,6 +3600,7 @@ export interface CreateChatMessageRequest {
* nil: no change, ptr to "plan": enable, ptr to "": clear.
*/
readonly plan_mode?: ChatPlanMode;
readonly reasoning_effort?: string;
}
// From codersdk/chats.go
@@ -3596,6 +3655,7 @@ export interface CreateChatRequest {
readonly system_prompt?: string;
readonly workspace_id?: string;
readonly model_config_id?: string;
readonly reasoning_effort?: string;
readonly mcp_server_ids?: readonly string[];
readonly labels?: Record<string, string>;
/**
@@ -4610,6 +4670,7 @@ export interface EditChatMessageRequest {
* When nil the original message's model is preserved.
*/
readonly model_config_id?: string;
readonly reasoning_effort?: string;
}
// From codersdk/chats.go
@@ -9023,6 +9084,7 @@ export interface UpdateChatModelConfigRequest {
*/
export interface UpdateChatModelOverrideRequest {
readonly model_config_id: string;
readonly reasoning_effort?: string;
}
// From codersdk/chats.go
@@ -9392,6 +9454,7 @@ export interface UpdateUserChatDebugLoggingRequest {
export interface UpdateUserChatPersonalModelOverrideRequest {
readonly mode: ChatPersonalModelOverrideMode;
readonly model_config_id: string;
readonly reasoning_effort?: string;
}
// From codersdk/notifications.go
@@ -19,7 +19,7 @@ import {
} from "./components/SubagentModelOverrideSettings";
type SaveModelOverride = (
req: { readonly model_config_id: string },
req: TypesGen.UpdateChatModelOverrideRequest,
options?: MutationCallbacks,
) => void;
@@ -6,6 +6,7 @@ import { useTemporarySavedState } from "#/components/TemporarySavedState/Tempora
import { ModelSelector } from "#/pages/AgentsPage/components/ChatElements/ModelSelector";
import { ModelOverrideAlerts } from "#/pages/AgentsPage/components/ModelOverrideAlerts";
import type { ProviderInfo } from "#/pages/AgentsPage/utils/modelOptions";
import { pickReasoningEffort } from "#/pages/AgentsPage/utils/reasoningEffort";
import { AgentSettingLayout } from "./AgentSettingLayout";
export interface MutationCallbacks {
@@ -15,11 +16,13 @@ export interface MutationCallbacks {
interface ModelOverrideData {
readonly model_config_id: string;
readonly reasoning_effort?: string;
readonly is_malformed: boolean;
}
interface UpdateModelOverrideRequest {
readonly model_config_id: string;
readonly reasoning_effort?: string;
}
interface SubagentModelOverrideSettingsProps {
@@ -65,6 +68,8 @@ export const SubagentModelOverrideSettings: FC<
const isMalformedOverride = modelOverrideData?.is_malformed ?? false;
const enabledModelOptions = enabledModelConfigs.map((modelConfig) => {
const providerInfo = providerInfoByID.get(modelConfig.ai_provider_id);
const reasoningEffort = modelConfig.model_config?.reasoning_effort;
const reasoningEfforts = modelConfig.reasoning_efforts ?? [];
return {
id: modelConfig.id,
provider: providerInfo?.provider ?? "",
@@ -74,6 +79,10 @@ export const SubagentModelOverrideSettings: FC<
model: modelConfig.model,
displayName: modelConfig.display_name.trim() || modelConfig.model,
contextLimit: modelConfig.context_limit,
...(reasoningEffort?.default
? { reasoningEffortDefault: reasoningEffort.default }
: {}),
...(reasoningEfforts.length > 0 ? { reasoningEfforts } : {}),
};
});
@@ -81,11 +90,15 @@ export const SubagentModelOverrideSettings: FC<
enableReinitialize: true,
initialValues: {
model_config_id: modelOverrideData?.model_config_id ?? "",
reasoning_effort: modelOverrideData?.reasoning_effort ?? "",
},
onSubmit: (values, { resetForm }) => {
onSaveModelOverride(
{
model_config_id: values.model_config_id,
...(values.reasoning_effort
? { reasoning_effort: values.reasoning_effort }
: {}),
},
{
onSuccess: () => {
@@ -101,11 +114,18 @@ export const SubagentModelOverrideSettings: FC<
const canSave =
hasLoadedModelOverride && !disabled && (form.dirty || isMalformedOverride);
const selectedModelOption = enabledModelOptions.find(
(option) => option.id === form.values.model_config_id,
);
const selectedReasoningEffort = selectedModelOption
? pickReasoningEffort(
form.values.reasoning_effort,
selectedModelOption.reasoningEfforts ?? [],
selectedModelOption.reasoningEffortDefault,
)
: undefined;
const isUnavailableSavedModel =
form.values.model_config_id !== "" &&
!enabledModelOptions.some(
(option) => option.id === form.values.model_config_id,
);
form.values.model_config_id !== "" && selectedModelOption === undefined;
return (
<AgentSettingLayout
@@ -124,9 +144,20 @@ export const SubagentModelOverrideSettings: FC<
<ModelSelector
options={enabledModelOptions}
value={form.values.model_config_id}
onValueChange={(value) =>
void form.setFieldValue("model_config_id", value)
}
onValueChange={(value) => {
const option = enabledModelOptions.find(
(option) => option.id === value,
);
void form.setValues({
model_config_id: value,
reasoning_effort:
pickReasoningEffort(
"",
option?.reasoningEfforts ?? [],
option?.reasoningEffortDefault,
) ?? "",
});
}}
disabled={isFormDisabled}
placeholder={
isUnavailableSavedModel ? "Unavailable model" : unsetPlaceholder
@@ -136,6 +167,10 @@ export const SubagentModelOverrideSettings: FC<
}
className="h-10 w-full justify-between rounded-md border border-border border-solid bg-transparent px-3 text-sm"
contentClassName="min-w-[18rem]"
reasoningEffort={selectedReasoningEffort}
onReasoningEffortChange={(value) =>
void form.setFieldValue("reasoning_effort", value)
}
/>
<ModelOverrideAlerts
isUnavailableSavedModel={isUnavailableSavedModel}
@@ -150,7 +185,10 @@ export const SubagentModelOverrideSettings: FC<
variant="outline"
type="button"
onClick={() => {
void form.setFieldValue("model_config_id", "");
void form.setValues({
model_config_id: "",
reasoning_effort: "",
});
}}
disabled={isFormDisabled}
className="h-10"
@@ -218,6 +218,79 @@ export const EditUpdateDisabledUntilDirty: Story = {
},
};
export const ReasoningEffortVisibleWithoutExpanding: Story = {
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const defaultSelect = canvas.getByRole("combobox", {
name: /default reasoning effort/i,
});
const maxSelect = canvas.getByRole("combobox", {
name: /max reasoning effort/i,
});
await expect(defaultSelect).toBeVisible();
await expect(maxSelect).toBeVisible();
await expect(defaultSelect).toHaveTextContent("Not set");
await expect(maxSelect).toHaveTextContent("Not set");
await userEvent.type(canvas.getByLabelText(/model identifier/i), "gpt-5");
await userEvent.type(canvas.getByLabelText(/context limit/i), "200000");
await userEvent.click(defaultSelect);
for (const option of [
"None",
"Minimal",
"Low",
"Medium",
"High",
"Xhigh",
"Max",
]) {
await expect(
await screen.findByRole("option", { name: option }),
).toBeInTheDocument();
}
await userEvent.click(
await screen.findByRole("option", { name: "Medium" }),
);
await userEvent.click(maxSelect);
await userEvent.click(await screen.findByRole("option", { name: "Max" }));
await userEvent.click(canvas.getByRole("button", { name: /add model/i }));
await expect(args.onCreateModel).toHaveBeenCalledWith(
expect.objectContaining({
model_config: expect.objectContaining({
reasoning_effort: { default: "medium", max: "max" },
}),
}),
);
},
};
export const ReasoningEffortValidationError: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const defaultSelect = canvas.getByRole("combobox", {
name: /default reasoning effort/i,
});
const maxSelect = canvas.getByRole("combobox", {
name: /max reasoning effort/i,
});
await userEvent.click(defaultSelect);
await userEvent.click(await screen.findByRole("option", { name: "High" }));
await userEvent.click(maxSelect);
await userEvent.click(await screen.findByRole("option", { name: "Low" }));
await expect(
canvas.getByText(
"Default reasoning effort must not exceed the max reasoning effort.",
),
).toBeVisible();
},
};
export const CostTrackingExpanded: Story = {
args: {
editingModel: mockGPT5,
@@ -29,6 +29,7 @@ import {
GeneralModelConfigFields,
ModelConfigFields,
PricingModelConfigFields,
ReasoningEffortConfigFields,
} from "#/pages/AgentsPage/components/ChatModelAdminPanel/ModelConfigFields";
import { ModelIdentifierField } from "#/pages/AgentsPage/components/ChatModelAdminPanel/ModelIdentifierField";
import type {
@@ -239,6 +240,12 @@ export const ModelFormFields: FC<{
</InputGroupAddon>
</InputGroup>
</div>
<ReasoningEffortConfigFields
provider={selectedProviderState.provider}
form={form}
fieldErrors={modelConfigFormBuildResult.fieldErrors}
disabled={isSaving}
/>
</div>
<div className="overflow-hidden rounded-lg border border-solid border-border">
@@ -21,6 +21,7 @@ import {
} from "#/api/queries/chats";
import { workspaceByIdKey } from "#/api/queries/workspaces";
import type * as TypesGen from "#/api/typesGenerated";
import { MockChatMessage } from "#/testHelpers/chatEntities";
import { MockChatModelConfig } from "#/testHelpers/chatModels";
import {
MockGroup,
@@ -122,6 +123,10 @@ const mockModelConfigs: TypesGen.ChatModelConfig[] = [
model: "gpt-4o",
display_name: "GPT-4o",
is_default: true,
model_config: {
reasoning_effort: { default: "medium", max: "high" },
},
reasoning_efforts: ["low", "medium", "high"],
created_at: "2026-02-18T00:00:00.000Z",
updated_at: "2026-02-18T00:00:00.000Z",
},
@@ -1189,8 +1194,38 @@ export const WithMessageHistory: Story = {
{ diffUrl: undefined },
),
},
beforeEach: () => {
spyOn(API.experimental, "getChat").mockResolvedValue({
id: CHAT_ID,
...baseChatFields,
title: "Markdown rendering showcase",
status: "waiting",
});
spyOn(API.experimental, "editChatMessage").mockResolvedValue({
message: {
...MockChatMessage,
id: 5,
created_at: "2026-02-18T00:03:00.000Z",
},
});
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const body = within(document.body);
const user = userEvent.setup();
const changeReasoningEffort = async (key: string) => {
const modelSelector = canvas.getByRole("combobox", { name: "GPT-4o" });
await user.click(modelSelector);
const slider = await body.findByRole("slider");
slider.focus();
await user.keyboard(key);
await user.click(modelSelector);
};
const editLastMessage = async () => {
const buttons = canvas.getAllByRole("button", { name: "Edit message" });
await user.click(buttons[buttons.length - 1]);
};
expect(
await canvas.findByText("Markdown rendering showcase"),
).toBeVisible();
@@ -1199,6 +1234,35 @@ export const WithMessageHistory: Story = {
canvas.queryByText(/^This chat is owned by/),
).not.toBeInTheDocument();
});
await changeReasoningEffort("{ArrowRight}");
await editLastMessage();
await user.click(canvas.getByRole("button", { name: "Save Edit" }));
await waitFor(() => {
expect(API.experimental.editChatMessage).toHaveBeenCalledTimes(1);
expect(
canvas.getByRole("textbox", { name: "Chat message" }),
).toBeEnabled();
});
await editLastMessage();
await changeReasoningEffort("{ArrowLeft}");
await user.click(canvas.getByRole("button", { name: "Save Edit" }));
await waitFor(() => {
expect(API.experimental.editChatMessage).toHaveBeenCalledTimes(2);
});
expect(API.experimental.editChatMessage).toHaveBeenNthCalledWith(
1,
CHAT_ID,
5,
expect.not.objectContaining({ reasoning_effort: expect.anything() }),
);
expect(API.experimental.editChatMessage).toHaveBeenNthCalledWith(
2,
CHAT_ID,
5,
expect.objectContaining({ reasoning_effort: "medium" }),
);
},
};
+33 -1
View File
@@ -96,6 +96,7 @@ import {
resolveModelSelector,
} from "./utils/modelOptions";
import { parsePullRequestUrl } from "./utils/pullRequest";
import { pickReasoningEffort } from "./utils/reasoningEffort";
import {
type ChatDetailError,
formatUsageLimitMessage,
@@ -720,6 +721,8 @@ const AgentChatPage: FC = () => {
const { organizations, experiments } = useDashboard();
const organizationName = getDefaultOrganizationName(organizations);
const [selectedModel, setSelectedModel] = useState("");
const [selectedReasoningEffort, setSelectedReasoningEffort] = useState("");
const isEditReasoningEffortDirtyRef = useRef(false);
const scrollToBottomRef = useRef<(() => void) | null>(null);
const chatInputRef = useRef<ChatMessageInputRef | null>(null);
const inputValueRef = useRef(
@@ -1133,6 +1136,17 @@ const AgentChatPage: FC = () => {
return modelOptions[0]?.id ?? "";
})();
const effectiveModelOption = modelOptions.find(
(option) => option.id === effectiveSelectedModel,
);
const effectiveReasoningEffort = effectiveModelOption
? pickReasoningEffort(
selectedReasoningEffort || chatRecord?.last_reasoning_effort,
effectiveModelOption.reasoningEfforts ?? [],
effectiveModelOption.reasoningEffortDefault,
)
: undefined;
const compressionThreshold = resolveCompactionThreshold(
chatLastModelConfigID,
userThresholdsQuery.data?.thresholds,
@@ -1257,6 +1271,12 @@ const AgentChatPage: FC = () => {
chatInputRef,
inputValueRef,
});
const handleEditUserMessage = (
...args: Parameters<typeof editing.handleEditUserMessage>
) => {
isEditReasoningEffortDirtyRef.current = false;
editing.handleEditUserMessage(...args);
};
const chatTitle = chatQuery.data?.title;
@@ -1453,9 +1473,13 @@ const AgentChatPage: FC = () => {
pickerModelConfigID !== originalModelConfigID
? pickerModelConfigID
: undefined;
// Omit so the backend preserves the original effort.
const request: TypesGen.EditChatMessageRequest = {
content,
model_config_id: editSelectedModelConfigID,
reasoning_effort: isEditReasoningEffortDirtyRef.current
? effectiveReasoningEffort
: undefined,
};
const optimisticMessage = originalEditedMessage
? buildOptimisticEditedMessage({
@@ -1498,6 +1522,7 @@ const AgentChatPage: FC = () => {
const request: CreateChatMessageRequestWithClearablePlanMode = {
content,
model_config_id: selectedModelConfigID,
reasoning_effort: effectiveReasoningEffort,
mcp_server_ids:
effectiveMCPServerIds.length > 0
? [...effectiveMCPServerIds]
@@ -1643,12 +1668,19 @@ const AgentChatPage: FC = () => {
workspaceAgent={workspaceAgent}
chatBuildId={chatQuery.data?.build_id}
store={store}
editing={editing}
editing={{ ...editing, handleEditUserMessage }}
effectiveSelectedModel={effectiveSelectedModel}
setSelectedModel={setSelectedModel}
modelOptions={modelOptions}
modelSelectorPlaceholder={modelSelectorPlaceholder}
modelSelectorHelp={modelSelectorHelp}
reasoningEffort={effectiveReasoningEffort}
onReasoningEffortChange={(value) => {
setSelectedReasoningEffort(value);
if (editing.editingMessageId !== null) {
isEditReasoningEffortDirtyRef.current = true;
}
}}
canConfigureAgentSetup={permissions.editDeploymentConfig}
providerCount={providerCount}
modelCount={modelCount}
@@ -136,6 +136,8 @@ interface AgentChatPageViewProps {
modelOptions: readonly ModelSelectorOption[];
modelSelectorPlaceholder: string;
modelSelectorHelp?: ReactNode;
reasoningEffort?: string;
onReasoningEffortChange?: (value: string) => void;
canConfigureAgentSetup: boolean;
providerCount?: number;
modelCount?: number;
@@ -328,6 +330,8 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
modelOptions,
modelSelectorPlaceholder,
modelSelectorHelp,
reasoningEffort,
onReasoningEffortChange,
canConfigureAgentSetup,
providerCount,
modelCount,
@@ -941,6 +945,8 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
modelOptions={modelOptions}
modelSelectorPlaceholder={modelSelectorPlaceholder}
modelSelectorHelp={modelSelectorHelp}
reasoningEffort={reasoningEffort}
onReasoningEffortChange={onReasoningEffortChange}
planModeEnabled={planModeEnabled}
onPlanModeToggle={onPlanModeToggle}
isModelCatalogLoading={isModelCatalogLoading}
@@ -88,6 +88,7 @@ const AgentCreatePage: FC = () => {
fileIDs,
workspaceId,
model,
reasoningEffort,
mcpServerIds,
organizationId,
planMode,
@@ -110,6 +111,7 @@ const AgentCreatePage: FC = () => {
plan_mode: planMode === "plan" ? "plan" : undefined,
client_type: "ui",
...(model ? { model_config_id: model } : {}),
...(reasoningEffort ? { reasoning_effort: reasoningEffort } : {}),
};
const createdChat = await createMutation.mutateAsync(createRequest);
@@ -119,6 +119,8 @@ interface AgentChatInputProps {
modelOptions: readonly ModelSelectorOption[];
modelSelectorPlaceholder: string;
hasModelOptions: boolean;
reasoningEffort?: string;
onReasoningEffortChange?: (value: string) => void;
planModeEnabled?: boolean;
onPlanModeToggle?: (enabled: boolean) => void;
isModelCatalogLoading?: boolean;
@@ -354,6 +356,8 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
modelOptions,
modelSelectorPlaceholder,
hasModelOptions,
reasoningEffort,
onReasoningEffortChange,
planModeEnabled = false,
onPlanModeToggle,
isModelCatalogLoading = false,
@@ -1428,6 +1432,8 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
dropdownSide="top"
dropdownAlign="start"
enableMobileFullWidthDropdown
reasoningEffort={reasoningEffort}
onReasoningEffortChange={onReasoningEffortChange}
/>
)}
{planModeEnabled && !shouldOverflowPlanningBadge && (
@@ -157,6 +157,7 @@ const getCreateOptions = (onCreateChat: unknown): CreateChatSubmission => {
type CreateChatSubmission = {
model?: string;
reasoningEffort?: string;
};
export const RootPersonalModelOverrideModelSelected: Story = {
@@ -305,6 +306,64 @@ export const ManualSelectionOverridesRootChatDefault: Story = {
},
};
// Model options with reasoning effort bounds configured. GPT-4o uses the
// full global scale; Claude is capped at medium.
const effortModelOptions = [
{
...modelOptions[0],
reasoningEffortDefault: "medium",
reasoningEfforts: [
"none",
"minimal",
"low",
"medium",
"high",
"xhigh",
"max",
],
},
{
...modelOptions[1],
reasoningEffortDefault: "low",
reasoningEfforts: ["low", "medium"],
},
] as const;
export const SubmitsReasoningEffort: Story = {
args: {
...defaultArgs,
onCreateChat: fn().mockResolvedValue(undefined),
modelOptions: [...effortModelOptions],
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const body = within(canvasElement.ownerDocument.body);
// Open the model selector; the effort row shows the model default.
await userEvent.click(canvas.getByRole("combobox", { name: "GPT-4o" }));
const slider = await body.findByRole("slider");
// "medium" is the fourth of seven selectable efforts.
expect(slider).toHaveAttribute("aria-valuenow", "3");
// Bump the effort to "high" with the keyboard, then close.
await userEvent.tab();
expect(slider).toHaveFocus();
await userEvent.keyboard("{ArrowRight}");
await waitFor(() => {
expect(slider).toHaveAttribute("aria-valuenow", "4");
});
await userEvent.keyboard("{Escape}");
await submitMessage(canvasElement, "create with reasoning effort");
await waitFor(() => {
expect(args.onCreateChat).toHaveBeenCalled();
});
const options = getCreateOptions(args.onCreateChat);
expect(options.model).toBe(modelConfigID);
expect(options.reasoningEffort).toBe("high");
},
};
const mockWorkspaces = [
{
...MockWorkspace,
@@ -20,6 +20,7 @@ import {
hasConfiguredModelsInCatalog,
hasUserFixableProviders,
} from "../utils/modelOptions";
import { pickReasoningEffort } from "../utils/reasoningEffort";
import {
formatUsageLimitMessage,
isChatUsageLimitExceededResponse,
@@ -47,6 +48,7 @@ export type CreateChatOptions = {
fileIDs?: string[];
workspaceId?: string;
model?: string;
reasoningEffort?: string;
mcpServerIds?: string[];
organizationId: string;
planMode?: TypesGen.ChatPlanMode;
@@ -238,6 +240,17 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({
}
return selectedModel || undefined;
})();
const [selectedReasoningEffort, setSelectedReasoningEffort] = useState("");
const selectedModelOption = modelOptions.find(
(option) => option.id === selectedModel,
);
const effectiveReasoningEffort = selectedModelOption
? pickReasoningEffort(
selectedReasoningEffort,
selectedModelOption.reasoningEfforts ?? [],
selectedModelOption.reasoningEffortDefault,
)
: undefined;
const initialOrg =
organizations.find((o) => o.is_default) ?? organizations[0];
const [selectedWorkspaceId, setSelectedWorkspaceId] = useState<string | null>(
@@ -367,6 +380,7 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({
fileIDs,
workspaceId: effectiveWorkspaceId ?? undefined,
model: submittedModel,
reasoningEffort: effectiveReasoningEffort,
organizationId,
mcpServerIds:
effectiveMCPServerIds.length > 0
@@ -529,6 +543,8 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({
onModelChange={handleModelChange}
modelOptions={modelOptions}
modelSelectorPlaceholder={modelSelectorPlaceholder}
reasoningEffort={effectiveReasoningEffort}
onReasoningEffortChange={setSelectedReasoningEffort}
isModelCatalogLoading={isModelCatalogLoading}
hasModelOptions={hasModelOptions}
planModeEnabled={planModeEnabled}
@@ -1,5 +1,6 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, userEvent, waitFor, within } from "storybook/test";
import { useState } from "react";
import { expect, fn, screen, userEvent, waitFor, within } from "storybook/test";
import { ModelSelector, type ModelSelectorOption } from "./ModelSelector";
import { MockModelSelectorOption } from "./modelSelectorFixtures";
@@ -48,6 +49,24 @@ const anthropicModels: ModelSelectorOption[] = [
const allModels: ModelSelectorOption[] = [...openAIModels, ...anthropicModels];
const effortModel: ModelSelectorOption = {
...MockModelSelectorOption,
id: "openai/gpt-5",
model: "gpt-5",
displayName: "GPT-5",
contextLimit: 400_000,
reasoningEffortDefault: "medium",
reasoningEfforts: [
"none",
"minimal",
"low",
"medium",
"high",
"xhigh",
"max",
],
};
const meta: Meta<typeof ModelSelector> = {
title: "pages/AgentsPage/ChatElements/ModelSelector",
component: ModelSelector,
@@ -254,3 +273,138 @@ export const FiltersModels: Story = {
);
},
};
// ---------------------------------------------------------------------------
// Reasoning effort row
// ---------------------------------------------------------------------------
export const EffortRowHiddenWithoutConfig: Story = {
args: {
options: openAIModels,
value: "openai/gpt-4o",
reasoningEffort: "medium",
onReasoningEffortChange: fn(),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const body = within(document.body);
await userEvent.click(canvas.getByRole("combobox"));
await body.findByRole("listbox");
expect(body.queryByRole("slider")).not.toBeInTheDocument();
expect(body.queryByText("Effort")).not.toBeInTheDocument();
},
};
const EffortRowStory = ({
onReasoningEffortChange,
}: {
onReasoningEffortChange: (value: string) => void;
}) => {
const [effort, setEffort] = useState("medium");
return (
<ModelSelector
options={[...openAIModels, effortModel]}
value="openai/gpt-5"
onValueChange={fn()}
reasoningEffort={effort}
onReasoningEffortChange={(value) => {
onReasoningEffortChange(value);
setEffort(value);
}}
/>
);
};
export const EffortRow: Story = {
args: {
onReasoningEffortChange: fn(),
},
render: (args) => (
<EffortRowStory
onReasoningEffortChange={(value) => args.onReasoningEffortChange?.(value)}
/>
),
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const body = within(document.body);
await userEvent.click(canvas.getByRole("combobox", { name: "GPT-5" }));
await body.findByRole("listbox");
// The row is visible with one discrete step per selectable effort.
await waitFor(() => {
expect(body.getByText("Effort")).toBeVisible();
});
const slider = await body.findByRole("slider");
expect(slider).toHaveAttribute("aria-valuemin", "0");
expect(slider).toHaveAttribute("aria-valuemax", "6");
// "medium" is the fourth of seven selectable efforts.
expect(slider).toHaveAttribute("aria-valuenow", "3");
expect(body.getByText("Medium")).toBeVisible();
const infoTrigger = body.getByRole("button", {
name: "About reasoning effort",
});
await userEvent.tab();
expect(infoTrigger).toHaveFocus();
expect(await screen.findByRole("tooltip")).toHaveTextContent(
"Controls how much reasoning the model performs before responding.",
);
await userEvent.tab();
expect(slider).toHaveFocus();
await userEvent.keyboard("{ArrowRight}");
await waitFor(() => {
expect(slider).toHaveAttribute("aria-valuenow", "4");
});
expect(args.onReasoningEffortChange).toHaveBeenCalledWith("high");
expect(body.getByText("High")).toBeVisible();
await userEvent.keyboard("{ArrowRight}{ArrowRight}");
await waitFor(() => {
expect(slider).toHaveAttribute("aria-valuenow", "6");
});
expect(args.onReasoningEffortChange).toHaveBeenCalledWith("max");
expect(body.getByText("Max")).toBeVisible();
await userEvent.keyboard("{ArrowLeft}{ArrowLeft}{ArrowLeft}{ArrowLeft}");
await waitFor(() => {
expect(slider).toHaveAttribute("aria-valuenow", "2");
});
expect(args.onReasoningEffortChange).toHaveBeenCalledWith("low");
expect(body.getByText("Low")).toBeVisible();
},
};
export const EffortRowClampedToMax: Story = {
args: {
options: [
{
...effortModel,
reasoningEffortDefault: "low",
reasoningEfforts: ["none", "minimal", "low", "medium"],
},
],
value: "openai/gpt-5",
reasoningEffort: "low",
onReasoningEffortChange: fn(),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const body = within(document.body);
await userEvent.click(canvas.getByRole("combobox"));
await body.findByRole("listbox");
// Selectable efforts stop at the configured max.
const slider = await body.findByRole("slider");
expect(slider).toHaveAttribute("aria-valuemax", "3");
expect(slider).toHaveAttribute("aria-valuenow", "2");
await waitFor(() => {
expect(body.getByText("Low")).toBeVisible();
});
},
};
@@ -1,4 +1,4 @@
import { CheckIcon } from "lucide-react";
import { CheckIcon, InfoIcon } from "lucide-react";
import { type FC, useState } from "react";
import { ChevronDownIcon } from "#/components/AnimatedIcons/ChevronDown";
import { Button } from "#/components/Button/Button";
@@ -15,9 +15,16 @@ import {
PopoverContent,
PopoverTrigger,
} from "#/components/Popover/Popover";
import { Slider } from "#/components/Slider/Slider";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "#/components/Tooltip/Tooltip";
import { ProviderIcon } from "#/pages/AISettingsPage/ProvidersPage/components/ProviderIcon";
import { formatProviderLabel as defaultFormatProviderLabel } from "#/utils/aiProviders";
import { cn } from "#/utils/cn";
import { formatReasoningEffort } from "../../utils/reasoningEffort";
export interface ModelSelectorOption {
id: string;
@@ -28,6 +35,8 @@ export interface ModelSelectorOption {
model: string;
displayName: string;
contextLimit?: number;
reasoningEffortDefault?: string;
reasoningEfforts?: readonly string[];
}
interface ModelSelectorProps {
@@ -44,6 +53,8 @@ interface ModelSelectorProps {
contentClassName?: string;
onTriggerTouchStart?: () => void;
enableMobileFullWidthDropdown?: boolean;
reasoningEffort?: string;
onReasoningEffortChange?: (value: string) => void;
}
const formatContextLimit = (tokens: number): string => {
@@ -85,6 +96,8 @@ export const ModelSelector: FC<ModelSelectorProps> = ({
contentClassName,
onTriggerTouchStart,
enableMobileFullWidthDropdown = false,
reasoningEffort,
onReasoningEffortChange,
}) => {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
@@ -224,11 +237,83 @@ export const ModelSelector: FC<ModelSelectorProps> = ({
})}
</CommandList>
</Command>
{selectedModel &&
reasoningEffort !== undefined &&
onReasoningEffortChange && (
<ReasoningEffortRow
option={selectedModel}
value={reasoningEffort}
onChange={onReasoningEffortChange}
/>
)}
</PopoverContent>
</Popover>
);
};
interface ReasoningEffortRowProps {
option: ModelSelectorOption;
value: string;
onChange: (value: string) => void;
}
// Effort row pinned below the model list. Lives outside the Command
// so it stays visible while the list scrolls and cmdk's arrow-key
// navigation does not capture the slider's keyboard interaction.
const ReasoningEffortRow: FC<ReasoningEffortRowProps> = ({
option,
value,
onChange,
}) => {
const selectableEfforts = option.reasoningEfforts ?? [];
if (selectableEfforts.length === 0) {
return null;
}
const valueIndex = selectableEfforts.indexOf(value);
const effortIndex = valueIndex >= 0 ? valueIndex : 0;
return (
<div className="flex items-center gap-3 border-0 border-t border-solid border-border-default px-3 py-2">
<div className="flex shrink-0 items-center gap-1">
<span className="text-xs font-medium leading-[18px] text-content-secondary">
Effort
</span>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label="About reasoning effort"
className="inline-flex size-3 items-center justify-center rounded-sm border-none bg-transparent p-0 text-content-secondary outline-none focus-visible:ring-2 focus-visible:ring-content-link"
>
<InfoIcon aria-hidden="true" className="size-3" />
</button>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[240px]">
Controls how much reasoning the model performs before responding.
Higher effort can improve quality but is slower and costs more.
</TooltipContent>
</Tooltip>
</div>
<Slider
aria-label="Reasoning effort"
value={[effortIndex]}
onValueChange={([index]) => {
const nextEffort = selectableEfforts[index];
if (nextEffort && nextEffort !== value) {
onChange(nextEffort);
}
}}
min={0}
max={selectableEfforts.length - 1}
step={1}
/>
<span className="shrink-0 rounded bg-surface-secondary px-1.5 py-0.5 text-xs font-medium leading-[18px] text-content-secondary">
{formatReasoningEffort(value)}
</span>
</div>
);
};
interface ModelOptionItemProps {
option: ModelSelectorOption;
isSelected: boolean;
@@ -50,6 +50,10 @@ const booleanFieldOptions = [
/** Sentinel value for Select components to represent "no selection". */
const unsetSelectValue = "__unset__";
const isReasoningEffortField = (jsonName: string): boolean =>
jsonName === "reasoning_effort.default" ||
jsonName === "reasoning_effort.max";
// ── Helpers ────────────────────────────────────────────────────
/** Short display labels for pricing fields to avoid overly verbose names. */
@@ -79,10 +83,10 @@ const fieldSuffix: Record<string, string> = {
* where the valid range is more useful than an empty box.
*/
const placeholderOverrides: Record<string, string> = {
temperature: "0.0–2.0",
top_p: "0.0–1.0",
presence_penalty: "-2.0–2.0",
frequency_penalty: "-2.0–2.0",
temperature: "0.0 to 2.0",
top_p: "0.0 to 1.0",
presence_penalty: "-2.0 to 2.0",
frequency_penalty: "-2.0 to 2.0",
};
/**
@@ -240,6 +244,7 @@ const SelectField: FC<
label: string;
description?: string;
options: readonly string[];
placeholderLabel?: string;
}
> = ({
form,
@@ -250,6 +255,7 @@ const SelectField: FC<
label,
description,
options,
placeholderLabel = "Default",
}) => {
const errorId = `${fieldKey}-error`;
const fieldError = fieldErrors[errorKey ?? fieldKey];
@@ -273,10 +279,10 @@ const SelectField: FC<
aria-invalid={Boolean(fieldError)}
aria-describedby={fieldError ? errorId : undefined}
>
<SelectValue placeholder="Default" />
<SelectValue placeholder={placeholderLabel} />
</SelectTrigger>
<SelectContent>
<SelectItem value={unsetSelectValue}>Default</SelectItem>
<SelectItem value={unsetSelectValue}>{placeholderLabel}</SelectItem>
{options.map((option) => (
<SelectItem key={option} value={option}>
{capitalize(option)}
@@ -655,13 +661,44 @@ export const PricingModelConfigFields: FC<ModelConfigFieldsProps> = ({
);
};
/**
* General model config fields (max output tokens, temperature,
* top P, etc.) intended to be shown under an "Advanced" section.
*
* Fields are driven by the auto-generated schema in
* `api/chatModelOptions`.
*/
/** Reasoning effort selects, outside Advanced. */
export const ReasoningEffortConfigFields: FC<ModelConfigFieldsProps> = ({
form,
fieldErrors,
disabled,
}) => {
const ctx: FieldRenderContext = { form, fieldErrors, disabled };
const fields = getVisibleGeneralFields().filter(({ json_name }) =>
isReasoningEffortField(json_name),
);
return (
<>
{fields.map((field) => {
const camelName = field.json_name
.split(".")
.map(snakeToCamel)
.join(".");
const fieldKey = `config.${camelName}`;
return (
<SelectField
key={fieldKey}
{...ctx}
fieldKey={fieldKey}
errorKey={camelName}
label={snakeToPrettyLabel(field)}
description={field.description}
options={field.enum ?? []}
placeholderLabel="Not set"
/>
);
})}
</>
);
};
/** See ReasoningEffortConfigFields for reasoning effort fields. */
export const GeneralModelConfigFields: FC<ModelConfigFieldsProps> = ({
form,
fieldErrors,
@@ -669,7 +706,8 @@ export const GeneralModelConfigFields: FC<ModelConfigFieldsProps> = ({
}) => {
const ctx: FieldRenderContext = { form, fieldErrors, disabled };
const fields = getVisibleGeneralFields().filter(
({ json_name }) => !pricingFieldNames.has(json_name),
({ json_name }) =>
!pricingFieldNames.has(json_name) && !isReasoningEffortField(json_name),
);
return (
@@ -284,7 +284,7 @@ describe("applyKnownModelDefaults", () => {
expect(result.appliedFields).not.toContain("compressionThreshold");
});
it("does not set OpenAI reasoning fields without catalog defaults", () => {
it("does not set reasoning effort fields without catalog defaults", () => {
const result = applyDefaults({
values: buildInitialModelFormValues(),
initialValues: buildInitialModelFormValues(),
@@ -292,15 +292,19 @@ describe("applyKnownModelDefaults", () => {
knownModel: requireKnownModel("openai", "gpt-5.4"),
});
expect(getPath(result.values, "config.openai.reasoningEffort")).toBe("");
expect(getPath(result.values, "config.reasoningEffort.default")).toBe("");
expect(getPath(result.values, "config.reasoningEffort.max")).toBe("");
expect(getPath(result.values, "config.openai.reasoningSummary")).toBe("");
expect(result.appliedFields).not.toContain("config.openai.reasoningEffort");
expect(result.appliedFields).not.toContain(
"config.reasoningEffort.default",
);
expect(result.appliedFields).not.toContain("config.reasoningEffort.max");
expect(result.appliedFields).not.toContain(
"config.openai.reasoningSummary",
);
});
it("sets OpenAI reasoning effort for reasoning-capable catalog entries", () => {
it("sets reasoning effort bounds for reasoning-capable catalog entries", () => {
const result = applyDefaults({
values: buildInitialModelFormValues(),
initialValues: buildInitialModelFormValues(),
@@ -308,17 +312,19 @@ describe("applyKnownModelDefaults", () => {
knownModel: requireKnownModel("openai", "gpt-5.5"),
});
expect(getPath(result.values, "config.openai.reasoningEffort")).toBe(
expect(getPath(result.values, "config.reasoningEffort.default")).toBe(
"medium",
);
expect(getPath(result.values, "config.reasoningEffort.max")).toBe("medium");
expect(getPath(result.values, "config.openai.reasoningSummary")).toBe("");
expect(result.appliedFields).toContain("config.openai.reasoningEffort");
expect(result.appliedFields).toContain("config.reasoningEffort.default");
expect(result.appliedFields).toContain("config.reasoningEffort.max");
expect(result.appliedFields).not.toContain(
"config.openai.reasoningSummary",
);
});
it("sets Anthropic effort for extended-thinking catalog entries", () => {
it("sets reasoning effort bounds for Anthropic extended-thinking catalog entries", () => {
const result = applyDefaults({
values: buildInitialModelFormValues(),
initialValues: buildInitialModelFormValues(),
@@ -326,8 +332,31 @@ describe("applyKnownModelDefaults", () => {
knownModel: requireKnownModel("anthropic", "claude-opus-4-8"),
});
expect(getPath(result.values, "config.anthropic.effort")).toBe("high");
expect(result.appliedFields).toContain("config.anthropic.effort");
expect(getPath(result.values, "config.reasoningEffort.default")).toBe(
"high",
);
expect(getPath(result.values, "config.reasoningEffort.max")).toBe("high");
expect(result.appliedFields).toContain("config.reasoningEffort.default");
expect(result.appliedFields).toContain("config.reasoningEffort.max");
});
it("sets reasoning effort for any provider with a catalog default", () => {
const result = applyDefaults({
values: buildInitialModelFormValues(),
initialValues: buildInitialModelFormValues(),
provider: "google",
knownModel: customKnownModel({
provider: "google",
reasoningEffort: "medium",
}),
});
expect(getPath(result.values, "config.reasoningEffort.default")).toBe(
"medium",
);
expect(getPath(result.values, "config.reasoningEffort.max")).toBe("medium");
expect(result.appliedFields).toContain("config.reasoningEffort.default");
expect(result.appliedFields).toContain("config.reasoningEffort.max");
});
it.each([
@@ -347,8 +376,12 @@ describe("applyKnownModelDefaults", () => {
expect(result.appliedFields).toContain(
"config.anthropic.thinking.budgetTokens",
);
expect(getPath(result.values, "config.anthropic.effort")).toBe("");
expect(result.appliedFields).not.toContain("config.anthropic.effort");
expect(getPath(result.values, "config.reasoningEffort.default")).toBe("");
expect(getPath(result.values, "config.reasoningEffort.max")).toBe("");
expect(result.appliedFields).not.toContain(
"config.reasoningEffort.default",
);
expect(result.appliedFields).not.toContain("config.reasoningEffort.max");
});
it("does not set Anthropic sendReasoning or thinking budget fields", () => {
@@ -35,11 +35,6 @@ const pricingModelFieldByName = {
KnownModelCostField
>;
const reasoningEffortPathByProvider: Record<string, string> = {
openai: "config.openai.reasoningEffort",
anthropic: "config.anthropic.effort",
};
const thinkingBudgetTokensPathByProvider: Record<string, string> = {
anthropic: "config.anthropic.thinking.budgetTokens",
};
@@ -118,16 +113,17 @@ export const applyKnownModelDefaults = ({
}
if (knownModel.reasoningEffort !== undefined) {
// The catalog uses a single `reasoningEffort` field, but each provider
// exposes it under a different form path: OpenAI as `reasoningEffort`,
// Anthropic as `effort`. Providers without a mapping skip this default.
const reasoningEffortPath = reasoningEffortPathByProvider[provider];
if (reasoningEffortPath !== undefined) {
// The catalog carries a single curated effort value. Write both
// reasoning_effort bounds because the API requires default and max.
for (const path of [
"config.reasoningEffort.default",
"config.reasoningEffort.max",
]) {
maybeApplyDefault({
appliedFields,
initialValues,
nextValues,
path: reasoningEffortPath,
path,
value: knownModel.reasoningEffort,
values,
});
@@ -262,13 +262,28 @@ describe("extractModelConfigFormState", () => {
"0.3",
);
});
it("extracts reasoning effort bounds", () => {
const model: TypesGen.ChatModelConfig = {
...baseChatModelConfig,
model_config: {
reasoning_effort: {
default: "medium",
max: "xhigh",
},
},
};
const result = extractModelConfigFormState(model);
expect(deepGet(result, ["reasoningEffort", "default"])).toBe("medium");
expect(deepGet(result, ["reasoningEffort", "max"])).toBe("xhigh");
});
it("extracts OpenAI provider options", () => {
const model: TypesGen.ChatModelConfig = {
...baseChatModelConfig,
model_config: {
provider_options: {
openai: {
reasoning_effort: "high",
parallel_tool_calls: true,
text_verbosity: "medium",
service_tier: "auto",
@@ -281,7 +296,6 @@ describe("extractModelConfigFormState", () => {
};
const result = extractModelConfigFormState(model);
const openai = result.openai as Record<string, unknown>;
expect(openai.reasoningEffort).toBe("high");
expect(openai.parallelToolCalls).toBe("true");
expect(openai.textVerbosity).toBe("medium");
expect(openai.serviceTier).toBe("auto");
@@ -296,7 +310,6 @@ describe("extractModelConfigFormState", () => {
model_config: {
provider_options: {
anthropic: {
effort: "high",
thinking: { budget_tokens: 1024 },
send_reasoning: true,
disable_parallel_tool_use: false,
@@ -306,7 +319,6 @@ describe("extractModelConfigFormState", () => {
};
const result = extractModelConfigFormState(model);
const anthropic = result.anthropic as Record<string, unknown>;
expect(anthropic.effort).toBe("high");
expect(deepGet(anthropic, ["thinking", "budgetTokens"])).toBe("1024");
expect(anthropic.sendReasoning).toBe("true");
expect(anthropic.disableParallelToolUse).toBe("false");
@@ -357,7 +369,6 @@ describe("extractModelConfigFormState", () => {
model_config: {
provider_options: {
openaicompat: {
reasoning_effort: "low",
user: "compat-user",
},
},
@@ -365,7 +376,6 @@ describe("extractModelConfigFormState", () => {
};
const result = extractModelConfigFormState(model);
const openaicompat = result.openaicompat as Record<string, unknown>;
expect(openaicompat.reasoningEffort).toBe("low");
expect(openaicompat.user).toBe("compat-user");
});
@@ -377,7 +387,6 @@ describe("extractModelConfigFormState", () => {
openrouter: {
reasoning: {
enabled: true,
effort: "medium",
max_tokens: 500,
exclude: false,
},
@@ -391,7 +400,6 @@ describe("extractModelConfigFormState", () => {
const result = extractModelConfigFormState(model);
const openrouter = result.openrouter as Record<string, unknown>;
expect(deepGet(openrouter, ["reasoning", "enabled"])).toBe("true");
expect(deepGet(openrouter, ["reasoning", "effort"])).toBe("medium");
expect(deepGet(openrouter, ["reasoning", "maxTokens"])).toBe("500");
expect(deepGet(openrouter, ["reasoning", "exclude"])).toBe("false");
expect(openrouter.parallelToolCalls).toBe("true");
@@ -407,7 +415,6 @@ describe("extractModelConfigFormState", () => {
vercel: {
reasoning: {
enabled: false,
effort: "high",
max_tokens: 1000,
exclude: true,
},
@@ -420,7 +427,6 @@ describe("extractModelConfigFormState", () => {
const result = extractModelConfigFormState(model);
const vercel = result.vercel as Record<string, unknown>;
expect(deepGet(vercel, ["reasoning", "enabled"])).toBe("false");
expect(deepGet(vercel, ["reasoning", "effort"])).toBe("high");
expect(deepGet(vercel, ["reasoning", "maxTokens"])).toBe("1000");
expect(deepGet(vercel, ["reasoning", "exclude"])).toBe("true");
expect(vercel.parallelToolCalls).toBe("false");
@@ -438,9 +444,9 @@ describe("extractModelConfigFormState", () => {
expect(result.temperature).toBe("0.5");
// All provider-specific fields should be empty.
const openai = result.openai as Record<string, unknown>;
expect(openai.reasoningEffort).toBe("");
expect(openai.textVerbosity).toBe("");
const anthropic = result.anthropic as Record<string, unknown>;
expect(anthropic.effort).toBe("");
expect(anthropic.sendReasoning).toBe("");
const google = result.google as Record<string, unknown>;
expect(deepGet(google, ["thinkingConfig", "thinkingBudget"])).toBe("");
});
@@ -471,6 +477,83 @@ describe("buildModelConfigFromForm", () => {
});
});
describe("reasoning effort bounds", () => {
it("builds config with valid default and max", () => {
const result = buildModelConfigFromForm(
"openai",
formWith({ reasoningEffort: { default: "medium", max: "xhigh" } }),
);
expect(result.fieldErrors).toEqual({});
expect(result.modelConfig?.reasoning_effort).toEqual({
default: "medium",
max: "xhigh",
});
});
it("builds config with equal default and max", () => {
const result = buildModelConfigFromForm(
"anthropic",
formWith({ reasoningEffort: { default: "high", max: "high" } }),
);
expect(result.fieldErrors).toEqual({});
expect(result.modelConfig?.reasoning_effort).toEqual({
default: "high",
max: "high",
});
});
it("omits reasoning effort when both fields are unset", () => {
const result = buildModelConfigFromForm(
"openai",
formWith({ temperature: "0.5" }),
);
expect(result.fieldErrors).toEqual({});
expect(result.modelConfig?.reasoning_effort).toBeUndefined();
});
it("reports error when default exceeds max on the global ordering", () => {
const result = buildModelConfigFromForm(
"openai",
formWith({ reasoningEffort: { default: "high", max: "low" } }),
);
expect(result.fieldErrors["reasoningEffort.default"]).toContain(
"must not exceed the max reasoning effort",
);
expect(result.modelConfig).toBeUndefined();
});
it("requires default and max together", () => {
const defaultOnly = buildModelConfigFromForm(
"openai",
formWith({ reasoningEffort: { default: "high" } }),
);
expect(defaultOnly.fieldErrors["reasoningEffort.max"]).toContain(
"must both be set",
);
expect(defaultOnly.modelConfig).toBeUndefined();
const maxOnly = buildModelConfigFromForm(
"openai",
formWith({ reasoningEffort: { max: "high" } }),
);
expect(maxOnly.fieldErrors["reasoningEffort.default"]).toContain(
"must both be set",
);
expect(maxOnly.modelConfig).toBeUndefined();
});
it("reports error for values outside the effort enum", () => {
const result = buildModelConfigFromForm(
"openai",
formWith({ reasoningEffort: { default: "extreme" } }),
);
expect(result.fieldErrors["reasoningEffort.default"]).toContain(
"invalid value",
);
expect(result.modelConfig).toBeUndefined();
});
});
describe("top-level numeric fields", () => {
it("builds config with valid maxOutputTokens", () => {
const result = buildModelConfigFromForm(
@@ -606,14 +689,14 @@ describe("buildModelConfigFromForm", () => {
});
});
describe("OpenAI / Azure provider", () => {
it("builds OpenAI provider options with reasoning effort", () => {
it("builds OpenAI provider options with text verbosity", () => {
const result = buildModelConfigFromForm(
"openai",
formWith({ openai: { reasoningEffort: "high" } }),
formWith({ openai: { textVerbosity: "high" } }),
);
expect(result.fieldErrors).toEqual({});
expect(result.modelConfig?.provider_options?.openai).toEqual({
reasoning_effort: "high",
text_verbosity: "high",
});
});
@@ -633,7 +716,6 @@ describe("buildModelConfigFromForm", () => {
"openai",
formWith({
openai: {
reasoningEffort: "medium",
parallelToolCalls: "false",
textVerbosity: "low",
serviceTier: "auto",
@@ -648,7 +730,6 @@ describe("buildModelConfigFromForm", () => {
string,
unknown
>;
expect(openai.reasoning_effort).toBe("medium");
expect(openai.parallel_tool_calls).toBe(false);
expect(openai.text_verbosity).toBe("low");
expect(openai.service_tier).toBe("auto");
@@ -657,16 +738,6 @@ describe("buildModelConfigFromForm", () => {
expect(openai.prompt_cache_key).toBe("cache-key-1");
});
it("reports error for invalid reasoning effort option", () => {
const result = buildModelConfigFromForm(
"openai",
formWith({ openai: { reasoningEffort: "invalid_value" } }),
);
expect(result.fieldErrors["openai.reasoningEffort"]).toContain(
"invalid value",
);
});
it("reports error for invalid parallel tool calls boolean", () => {
const result = buildModelConfigFromForm(
"openai",
@@ -742,14 +813,14 @@ describe("buildModelConfigFromForm", () => {
});
describe("Anthropic / Bedrock provider", () => {
it("builds Anthropic provider options with effort", () => {
it("builds Anthropic provider options with thinking display", () => {
const result = buildModelConfigFromForm(
"anthropic",
formWith({ anthropic: { effort: "high" } }),
formWith({ anthropic: { thinkingDisplay: "summarized" } }),
);
expect(result.fieldErrors).toEqual({});
expect(result.modelConfig?.provider_options?.anthropic).toEqual({
effort: "high",
thinking_display: "summarized",
});
});
@@ -782,7 +853,6 @@ describe("buildModelConfigFromForm", () => {
"anthropic",
formWith({
anthropic: {
effort: "max",
thinking: { budgetTokens: "1024" },
sendReasoning: "false",
disableParallelToolUse: "true",
@@ -792,31 +862,11 @@ describe("buildModelConfigFromForm", () => {
expect(result.fieldErrors).toEqual({});
const anthropic = result.modelConfig?.provider_options
?.anthropic as Record<string, unknown>;
expect(anthropic.effort).toBe("max");
expect(anthropic.thinking).toEqual({ budget_tokens: 1024 });
expect(anthropic.send_reasoning).toBe(false);
expect(anthropic.disable_parallel_tool_use).toBe(true);
});
it("accepts xhigh for Anthropic effort", () => {
const result = buildModelConfigFromForm(
"anthropic",
formWith({ anthropic: { effort: "xhigh" } }),
);
expect(result.fieldErrors).toEqual({});
const anthropic = result.modelConfig?.provider_options
?.anthropic as Record<string, unknown>;
expect(anthropic.effort).toBe("xhigh");
});
it("reports error for invalid Anthropic effort option", () => {
const result = buildModelConfigFromForm(
"anthropic",
formWith({ anthropic: { effort: "ultra" } }),
);
expect(result.fieldErrors["anthropic.effort"]).toContain("invalid value");
});
it("reports error for non-numeric thinking budget tokens", () => {
const result = buildModelConfigFromForm(
"anthropic",
@@ -946,28 +996,16 @@ describe("buildModelConfigFromForm", () => {
"openaicompat",
formWith({
openaicompat: {
reasoningEffort: "low",
user: "compat-user",
},
}),
);
expect(result.fieldErrors).toEqual({});
expect(result.modelConfig?.provider_options?.openaicompat).toEqual({
reasoning_effort: "low",
user: "compat-user",
});
});
it("reports error for invalid reasoning effort", () => {
const result = buildModelConfigFromForm(
"openaicompat",
formWith({ openaicompat: { reasoningEffort: "super" } }),
);
expect(result.fieldErrors["openaicompat.reasoningEffort"]).toContain(
"invalid value",
);
});
it("does not set provider_options when all fields empty", () => {
const result = buildModelConfigFromForm(
"openaicompat",
@@ -985,7 +1023,6 @@ describe("buildModelConfigFromForm", () => {
openrouter: {
reasoning: {
enabled: "true",
effort: "high",
maxTokens: "500",
exclude: "false",
},
@@ -997,7 +1034,6 @@ describe("buildModelConfigFromForm", () => {
?.openrouter as Record<string, unknown>;
expect(openrouter.reasoning).toEqual({
enabled: true,
effort: "high",
max_tokens: 500,
exclude: false,
});
@@ -1031,18 +1067,6 @@ describe("buildModelConfigFromForm", () => {
expect(openrouter.include_usage).toBe(true);
});
it("reports error for invalid reasoning effort", () => {
const result = buildModelConfigFromForm(
"openrouter",
formWith({
openrouter: { reasoning: { effort: "turbo" } },
}),
);
expect(result.fieldErrors["openrouter.reasoning.effort"]).toContain(
"invalid value",
);
});
it("reports error for invalid boolean in reasoning enabled", () => {
const result = buildModelConfigFromForm(
"openrouter",
@@ -1064,7 +1088,6 @@ describe("buildModelConfigFromForm", () => {
vercel: {
reasoning: {
enabled: "true",
effort: "medium",
maxTokens: "1000",
exclude: "true",
},
@@ -1078,7 +1101,6 @@ describe("buildModelConfigFromForm", () => {
>;
expect(vercel.reasoning).toEqual({
enabled: true,
effort: "medium",
max_tokens: 1000,
exclude: true,
});
@@ -1135,7 +1157,7 @@ describe("buildModelConfigFromForm", () => {
it("normalizes provider case (e.g. 'OpenAI' → 'openai')", () => {
const result = buildModelConfigFromForm(
"OpenAI",
formWith({ openai: { reasoningEffort: "high" } }),
formWith({ openai: { textVerbosity: "high" } }),
);
expect(result.fieldErrors).toEqual({});
expect(result.modelConfig?.provider_options?.openai).toBeDefined();
@@ -1144,7 +1166,7 @@ describe("buildModelConfigFromForm", () => {
it("trims provider whitespace", () => {
const result = buildModelConfigFromForm(
" anthropic ",
formWith({ anthropic: { effort: "low" } }),
formWith({ anthropic: { sendReasoning: "true" } }),
);
expect(result.fieldErrors).toEqual({});
expect(result.modelConfig?.provider_options?.anthropic).toBeDefined();
@@ -271,6 +271,14 @@ function isNonNegativePricingField(field: FieldSchema): boolean {
return pricingFieldNames.has(field.json_name);
}
const reasoningEffortEnum =
getGeneralFields().find(
(field) => field.json_name === "reasoning_effort.default",
)?.enum ?? [];
const reasoningEffortRank = (value: string): number =>
reasoningEffortEnum.indexOf(value.trim().toLowerCase());
function isValidOptionalNumber(
value: string | undefined,
minimum?: number,
@@ -445,8 +453,39 @@ function buildYupSchema(
return Yup.object(shape) as Yup.ObjectSchema<Record<string, unknown>>;
}
// Pre-built general-fields schema.
const generalFieldsSchema = buildYupSchema(getGeneralFields());
// Pre-built general-fields schema with reasoning effort bounds.
const generalFieldsSchema = buildYupSchema(getGeneralFields()).test(
"reasoning-effort-default-lte-max",
"Default reasoning effort must not exceed the max reasoning effort.",
function validate(value) {
const efforts = deepGet(value, ["reasoningEffort"]);
const defaultValue = deepGet(efforts, ["default"]);
const maxValue = deepGet(efforts, ["max"]);
const defaultSet =
typeof defaultValue === "string" && defaultValue.trim() !== "";
const maxSet = typeof maxValue === "string" && maxValue.trim() !== "";
if (defaultSet !== maxSet) {
return this.createError({
path: defaultSet ? "reasoningEffort.max" : "reasoningEffort.default",
message: "Default and max reasoning effort must both be set.",
});
}
if (!defaultSet || !maxSet) {
return true;
}
const defaultRank = reasoningEffortRank(defaultValue);
const maxRank = reasoningEffortRank(maxValue);
// Unset or invalid values are covered by the per-field enum tests.
if (defaultRank < 0 || maxRank < 0 || defaultRank <= maxRank) {
return true;
}
return this.createError({
path: "reasoningEffort.default",
message:
"Default reasoning effort must not exceed the max reasoning effort.",
});
},
);
// Cache of per-provider Yup schemas, built lazily.
const providerSchemaCache = new Map<
@@ -173,6 +173,8 @@ interface ChatPageInputProps {
modelOptions: readonly ModelSelectorOption[];
modelSelectorPlaceholder: string;
modelSelectorHelp?: ReactNode;
reasoningEffort?: string;
onReasoningEffortChange?: (value: string) => void;
canConfigureAgentSetup: boolean;
providerCount?: number;
modelCount?: number;
@@ -244,6 +246,8 @@ export const ChatPageInput: FC<ChatPageInputProps> = ({
modelOptions,
modelSelectorPlaceholder,
modelSelectorHelp,
reasoningEffort,
onReasoningEffortChange,
canConfigureAgentSetup,
providerCount,
modelCount,
@@ -504,6 +508,8 @@ export const ChatPageInput: FC<ChatPageInputProps> = ({
onModelChange={onModelChange}
modelOptions={modelOptions}
modelSelectorPlaceholder={modelSelectorPlaceholder}
reasoningEffort={reasoningEffort}
onReasoningEffortChange={onReasoningEffortChange}
planModeEnabled={planModeEnabled}
onPlanModeToggle={onPlanModeToggle}
isModelCatalogLoading={isModelCatalogLoading}
@@ -12,6 +12,11 @@ import {
SelectTrigger,
SelectValue,
} from "#/components/Select/Select";
import { Slider } from "#/components/Slider/Slider";
import {
formatReasoningEffort,
pickReasoningEffort,
} from "../utils/reasoningEffort";
import type { ModelSelectorOption } from "./ChatElements";
import { ModelOverrideAlerts } from "./ModelOverrideAlerts";
import { SectionHeader } from "./SectionHeader";
@@ -35,6 +40,7 @@ export type SavePersonalOverride = (
interface PersonalOverrideFormValues {
mode: PersonalOverrideMode;
model_config_id: string;
reasoning_effort: string;
}
interface PersonalModelOverrideRowProps {
@@ -65,12 +71,20 @@ const toFormValues = (
context: PersonalOverrideContext,
): PersonalOverrideFormValues => {
if (!overrideData || overrideData.is_malformed) {
return { mode: getDefaultMode(context), model_config_id: "" };
return {
mode: getDefaultMode(context),
model_config_id: "",
reasoning_effort: "",
};
}
return {
mode: overrideData.mode,
model_config_id:
overrideData.mode === "model" ? overrideData.model_config_id : "",
reasoning_effort:
overrideData.mode === "model"
? (overrideData.reasoning_effort ?? "")
: "",
};
};
@@ -81,6 +95,9 @@ const toUpdateRequest = (
return {
mode: "model",
model_config_id: values.model_config_id,
...(values.reasoning_effort
? { reasoning_effort: values.reasoning_effort }
: {}),
};
}
return { mode: values.mode, model_config_id: "" };
@@ -258,6 +275,17 @@ export const PersonalModelOverrideRow: FC<PersonalModelOverrideRowProps> = ({
form.values.mode === "model"
? form.values.model_config_id
: form.values.mode;
const selectedModelOption = modelOptions.find(
(option) => option.id === form.values.model_config_id,
);
const selectedReasoningEffort =
form.values.mode === "model" && selectedModelOption
? pickReasoningEffort(
form.values.reasoning_effort,
selectedModelOption.reasoningEfforts ?? [],
selectedModelOption.reasoningEffortDefault,
)
: undefined;
const selectionLabel = getSelectionLabel({
context,
deploymentDefault,
@@ -280,10 +308,27 @@ export const PersonalModelOverrideRow: FC<PersonalModelOverrideRowProps> = ({
value={selectionValue}
onValueChange={(value) => {
if (isDefaultModeOption(value)) {
void form.setValues({ mode: value, model_config_id: "" });
void form.setValues({
mode: value,
model_config_id: "",
reasoning_effort: "",
});
return;
}
void form.setValues({ mode: "model", model_config_id: value });
const option = modelOptions.find((option) => option.id === value);
const reasoningEffortDefault = option
? option.reasoningEffortDefault
: undefined;
void form.setValues({
mode: "model",
model_config_id: value,
reasoning_effort:
pickReasoningEffort(
"",
option?.reasoningEfforts ?? [],
reasoningEffortDefault,
) ?? "",
});
}}
disabled={isFormDisabled}
>
@@ -339,6 +384,15 @@ export const PersonalModelOverrideRow: FC<PersonalModelOverrideRowProps> = ({
</SelectGroup>
</SelectContent>
</Select>
{selectedReasoningEffort !== undefined && selectedModelOption && (
<PersonalReasoningEffortRow
option={selectedModelOption}
value={selectedReasoningEffort}
onChange={(value) =>
void form.setFieldValue("reasoning_effort", value)
}
/>
)}
<ModelOverrideAlerts
isUnavailableSavedModel={isUnavailableSavedModel}
unavailableMessage="The saved model is unavailable and will be ignored until you choose a valid model override."
@@ -375,6 +429,47 @@ export const PersonalModelOverrideRow: FC<PersonalModelOverrideRowProps> = ({
);
};
interface PersonalReasoningEffortRowProps {
option: ModelSelectorOption;
value: string;
onChange: (value: string) => void;
}
const PersonalReasoningEffortRow: FC<PersonalReasoningEffortRowProps> = ({
option,
value,
onChange,
}) => {
const selectableEfforts = option.reasoningEfforts ?? [];
if (selectableEfforts.length === 0) {
return null;
}
const valueIndex = selectableEfforts.indexOf(value);
const effortIndex = valueIndex >= 0 ? valueIndex : 0;
return (
<div className="flex items-center gap-3 md:w-[18rem]">
<span className="shrink-0 text-content-secondary text-sm">Effort</span>
<Slider
aria-label={`${option.displayName} reasoning effort`}
value={[effortIndex]}
onValueChange={([index]) => {
const nextEffort = selectableEfforts[index];
if (nextEffort && nextEffort !== value) {
onChange(nextEffort);
}
}}
min={0}
max={selectableEfforts.length - 1}
step={1}
/>
<span className="shrink-0 rounded bg-surface-secondary px-1.5 py-0.5 text-content-secondary text-xs font-medium leading-[18px]">
{formatReasoningEffort(value)}
</span>
</div>
);
};
interface DefaultModeSelectItemProps {
mode: Exclude<PersonalOverrideMode, "model">;
context: PersonalOverrideContext;
@@ -323,6 +323,58 @@ describe("getModelOptionsFromConfigs", () => {
]);
});
it("populates reasoning effort bounds from the model config", () => {
const configs = [
createConfig({
id: "config-effort",
ai_provider_id: "prov-openai",
model: "gpt-5",
display_name: "GPT-5",
model_config: {
reasoning_effort: { default: "medium", max: "xhigh" },
},
reasoning_efforts: ["minimal", "low", "medium", "high", "xhigh"],
}),
createConfig({
id: "config-no-effort",
ai_provider_id: "prov-openai",
model: "gpt-4o",
display_name: "GPT-4o",
model_config: {},
}),
];
const catalog = createCatalog([
{ provider: "openai", available: true, models: [] },
]);
expect(
getModelOptionsFromConfigs(configs, catalog, providerInfoByID),
).toEqual([
{
id: "config-no-effort",
provider: "openai",
providerId: "prov-openai",
providerLabel: "OpenAI",
providerIcon: "",
model: "gpt-4o",
displayName: "GPT-4o",
contextLimit: 0,
},
{
id: "config-effort",
provider: "openai",
providerId: "prov-openai",
providerLabel: "OpenAI",
providerIcon: "",
model: "gpt-5",
displayName: "GPT-5",
contextLimit: 0,
reasoningEffortDefault: "medium",
reasoningEfforts: ["minimal", "low", "medium", "high", "xhigh"],
},
]);
});
it("excludes configs whose providers are unavailable", () => {
const configs = [
createConfig({
@@ -253,6 +253,9 @@ export const getModelOptionsFromConfigs = (
const displayName = config.display_name.trim() || model;
const contextLimit = asNumber(config.context_limit);
const reasoningEffort = config.model_config?.reasoning_effort;
const reasoningEffortDefault = asString(reasoningEffort?.default).trim();
const reasoningEfforts = config.reasoning_efforts ?? [];
options.push({
id: configID,
provider,
@@ -262,6 +265,8 @@ export const getModelOptionsFromConfigs = (
model,
displayName,
...(contextLimit !== undefined ? { contextLimit } : {}),
...(reasoningEffortDefault ? { reasoningEffortDefault } : {}),
...(reasoningEfforts.length > 0 ? { reasoningEfforts } : {}),
});
}
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { formatReasoningEffort, pickReasoningEffort } from "./reasoningEffort";
describe("formatReasoningEffort", () => {
it("formats xhigh", () => {
expect(formatReasoningEffort("xhigh")).toBe("Xhigh");
});
});
describe("pickReasoningEffort", () => {
const efforts = ["low", "medium", "high"];
it("keeps a selectable value", () => {
expect(pickReasoningEffort("high", efforts, "medium")).toBe("high");
});
it("falls back to the default when the value is not exact", () => {
expect(pickReasoningEffort(" High ", efforts, "medium")).toBe("medium");
});
it("falls back to the highest server-provided effort", () => {
expect(pickReasoningEffort("xhigh", efforts, "max")).toBe("high");
});
it("returns undefined without selectable efforts", () => {
expect(pickReasoningEffort("high", [], "medium")).toBeUndefined();
});
});
@@ -0,0 +1,24 @@
/** Display label for an effort value, e.g. "xhigh" renders as "Xhigh". */
export const formatReasoningEffort = (value: string): string =>
value.charAt(0).toUpperCase() + value.slice(1);
/** Chooses requested effort, then default effort, then the last selectable effort. */
export const pickReasoningEffort = (
value: string | undefined,
efforts: readonly string[],
defaultValue?: string,
): string | undefined => {
if (efforts.length === 0) {
return undefined;
}
if (value && efforts.includes(value)) {
return value;
}
if (defaultValue && efforts.includes(defaultValue)) {
return defaultValue;
}
return efforts[efforts.length - 1];
};