mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: add configurable reasoning effort to Coder agents (#26974)
This commit is contained in:
@@ -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",
|
||||
|
||||
Generated
+67
-4
@@ -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;
|
||||
|
||||
|
||||
+46
-8
@@ -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" }),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
+44
-11
@@ -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", () => {
|
||||
|
||||
+7
-11
@@ -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,
|
||||
});
|
||||
|
||||
+103
-81
@@ -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];
|
||||
};
|
||||
Reference in New Issue
Block a user