Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,49 @@ export const EditUpdateDisabledUntilDirty: Story = {
},
};

export const ReasoningEffortVisibleWithoutExpanding: Story = {
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);

// The effort selects sit in the always-visible top grid; no
// collapsible section is expanded in this story.
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 userEvent.type(canvas.getByLabelText(/model identifier/i), "gpt-5");
await userEvent.type(canvas.getByLabelText(/context limit/i), "200000");

// Options are limited to OpenAI's supported effort set:
// "max" (Anthropic-only) is not offered.
await userEvent.click(defaultSelect);
await expect(
await screen.findByRole("option", { name: "Medium" }),
).toBeInTheDocument();
await expect(
screen.queryByRole("option", { name: "Max" }),
).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("option", { name: "Medium" }));

await userEvent.click(maxSelect);
await userEvent.click(await screen.findByRole("option", { name: "Xhigh" }));

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: "xhigh" },
}),
}),
);
},
};

export const CostTrackingExpanded: Story = {
args: {
editingModel: mockGPT5,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
} from "#/components/Tooltip/Tooltip";
import { normalizeProvider } from "#/modules/aiModels/helpers";
import { cn } from "#/utils/cn";
import { getSupportedReasoningEfforts } from "../../utils/reasoningEffort";
import {
isFieldConflictDisabled,
isVisibleWhenSatisfied,
Expand All @@ -50,6 +51,11 @@ const booleanFieldOptions = [
/** Sentinel value for Select components to represent "no selection". */
const unsetSelectValue = "__unset__";

/** General fields configuring the per-model reasoning effort bounds. */
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. */
Expand Down Expand Up @@ -655,12 +661,103 @@ export const PricingModelConfigFields: FC<ModelConfigFieldsProps> = ({
);
};

/**
* Default/Max reasoning effort selects, schema-driven from the
* general `reasoning_effort.default` / `reasoning_effort.max` fields.
* Options are limited to the selected provider's supported effort
* set; renders nothing for providers without reasoning effort
* support. Kept out of the Advanced section so admins can configure
* effort bounds without expanding anything.
*/
export const ReasoningEffortConfigFields: FC<ModelConfigFieldsProps> = ({
provider,
form,
fieldErrors,
disabled,
}) => {
const supportedEfforts = getSupportedReasoningEfforts(
normalizeProvider(provider),
);
if (supportedEfforts.length === 0) {
return null;
}
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}`;
const errorId = `${fieldKey}-error`;
const fieldError = fieldErrors[camelName];
const currentValue = (getIn(form.values, fieldKey) as string) || "";
// Rendered inline rather than through SelectField so the
// unset choice can read "Not set": next to a field named
// "Default Reasoning Effort", the generic "Default" label
// would be ambiguous.
return (
<div key={fieldKey} className="flex min-w-0 flex-col gap-1.5">
<FieldLabel
htmlFor={fieldKey}
label={snakeToPrettyLabel(field)}
description={field.description}
/>
<Select
value={currentValue || unsetSelectValue}
onValueChange={(value) =>
void form.setFieldValue(
fieldKey,
value === unsetSelectValue ? "" : value,
)
}
disabled={disabled}
>
<SelectTrigger
id={fieldKey}
className={cn(
"min-w-0",
fieldError && "border-content-destructive",
)}
aria-invalid={Boolean(fieldError)}
aria-describedby={fieldError ? errorId : undefined}
>
<SelectValue placeholder="Not set" />
</SelectTrigger>
<SelectContent>
<SelectItem value={unsetSelectValue}>Not set</SelectItem>
{(field.enum ?? [])
.filter((value) => supportedEfforts.includes(value))
.map((option) => (
<SelectItem key={option} value={option}>
{capitalize(option)}
</SelectItem>
))}
</SelectContent>
</Select>
{fieldError && (
<p id={errorId} className="m-0 text-xs text-content-destructive">
{fieldError}
</p>
)}
</div>
);
})}
</>
);
};

/**
* 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`.
* `api/chatModelOptions`. The reasoning effort bounds are excluded
* here; they render prominently via ReasoningEffortConfigFields.
*/
export const GeneralModelConfigFields: FC<ModelConfigFieldsProps> = ({
form,
Expand All @@ -669,7 +766,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 (
Expand Down
Loading