feat: allow overriding advisor reasoning effort (#27196)

This commit is contained in:
Michael Suchacz
2026-07-14 11:01:51 +02:00
committed by GitHub
parent 162bd0e015
commit 55b06f14a3
11 changed files with 335 additions and 78 deletions
+19 -4
View File
@@ -5572,6 +5572,9 @@ func (api *API) getChatAdvisorConfig(rw http.ResponseWriter, r *http.Request) {
}
resp.MaxUsesPerRun = max(resp.MaxUsesPerRun, 0)
resp.MaxOutputTokens = max(resp.MaxOutputTokens, 0)
if resp.ModelConfigID == uuid.Nil {
resp.ReasoningEffort = nil
}
resp.Enabled = api.Experiments.Enabled(codersdk.ExperimentChatAdvisor)
httpapi.Write(ctx, rw, http.StatusOK, resp)
@@ -5601,13 +5604,21 @@ func (api *API) putChatAdvisorConfig(rw http.ResponseWriter, r *http.Request) {
})
return
}
if req.ModelConfigID != uuid.Nil {
if req.ModelConfigID == uuid.Nil {
if req.ReasoningEffort != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "reasoning_effort requires model_config_id.",
})
return
}
} else {
// Use system context because GetChatModelConfigByID requires
// deployment-config read access, which can be broader than the
// handler's explicit update check. The lookup only validates that
// the referenced model exists before persisting deployment config.
// handler's explicit update check. The lookup validates the model and
// any selected reasoning effort before persisting deployment config.
//nolint:gocritic // This admin-authorized validation lookup intentionally bypasses read authz.
if _, err := api.Database.GetChatModelConfigByID(dbauthz.AsSystemRestricted(ctx), req.ModelConfigID); err != nil {
modelConfig, err := api.Database.GetChatModelConfigByID(dbauthz.AsSystemRestricted(ctx), req.ModelConfigID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) || httpapi.Is404Error(err) {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: fmt.Sprintf("model_config_id %q does not match any existing model config.", req.ModelConfigID),
@@ -5620,6 +5631,10 @@ func (api *API) putChatAdvisorConfig(rw http.ResponseWriter, r *http.Request) {
})
return
}
if status, response := validateChatModelOverrideEffort(modelConfig, req.ReasoningEffort); response != nil {
httpapi.Write(ctx, rw, status, *response)
return
}
}
raw, err := json.Marshal(req)
+56 -2
View File
@@ -13988,13 +13988,21 @@ func TestChatAdvisorConfig_RoundTripModelConfigID(t *testing.T) {
adminClient := newChatClient(t)
coderdtest.CreateFirstUser(t, adminClient.Client)
modelConfig := createChatModelConfig(t, adminClient)
modelConfig := createAdditionalChatModelConfigWithReasoningEffort(
t,
adminClient,
"openai",
"gpt-5.2",
codersdk.ChatModelReasoningEffortMedium,
codersdk.ChatModelReasoningEffortXHigh,
)
want := codersdk.AdvisorConfig{
Enabled: true,
MaxUsesPerRun: 3,
MaxOutputTokens: 2048,
ModelConfigID: modelConfig.ID,
ReasoningEffort: ptr.Ref(codersdk.ChatModelReasoningEffortHigh),
}
err := adminClient.UpdateChatAdvisorConfig(ctx, want)
@@ -14021,6 +14029,44 @@ func TestChatAdvisorConfig_InvalidModelConfigID(t *testing.T) {
require.Contains(t, sdkErr.Message, "does not match any existing model config")
}
func TestChatAdvisorConfig_ReasoningEffortRequiresModelConfig(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
adminClient := newChatClient(t)
coderdtest.CreateFirstUser(t, adminClient.Client)
err := adminClient.UpdateChatAdvisorConfig(ctx, codersdk.UpdateAdvisorConfigRequest{
ReasoningEffort: ptr.Ref(codersdk.ChatModelReasoningEffortHigh),
})
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
require.Equal(t, "reasoning_effort requires model_config_id.", sdkErr.Message)
}
func TestChatAdvisorConfig_ReasoningEffortMustBeSelectable(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
adminClient := newChatClient(t)
coderdtest.CreateFirstUser(t, adminClient.Client)
modelConfig := createAdditionalChatModelConfigWithReasoningEffort(
t,
adminClient,
"openai",
"gpt-5.2",
codersdk.ChatModelReasoningEffortLow,
codersdk.ChatModelReasoningEffortMedium,
)
err := adminClient.UpdateChatAdvisorConfig(ctx, codersdk.UpdateAdvisorConfigRequest{
ModelConfigID: modelConfig.ID,
ReasoningEffort: ptr.Ref(codersdk.ChatModelReasoningEffortHigh),
})
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
require.Equal(t, "Invalid reasoning_effort value.", sdkErr.Message)
require.Equal(t, "Must be one of none, minimal, low, medium.", sdkErr.Detail)
}
func TestChatAdvisorConfig_RoundTripZeroValues(t *testing.T) {
t.Parallel()
@@ -14053,13 +14099,21 @@ func TestChatAdvisorConfig_OverwriteClearsPreviousValues(t *testing.T) {
adminClient := newChatClient(t)
coderdtest.CreateFirstUser(t, adminClient.Client)
modelConfig := createChatModelConfig(t, adminClient)
modelConfig := createAdditionalChatModelConfigWithReasoningEffort(
t,
adminClient,
"openai",
"gpt-5.2",
codersdk.ChatModelReasoningEffortMedium,
codersdk.ChatModelReasoningEffortXHigh,
)
rich := codersdk.AdvisorConfig{
Enabled: true,
MaxUsesPerRun: 5,
MaxOutputTokens: 1024,
ModelConfigID: modelConfig.ID,
ReasoningEffort: ptr.Ref(codersdk.ChatModelReasoningEffortHigh),
}
err := adminClient.UpdateChatAdvisorConfig(ctx, rich)
require.NoError(t, err)
+11 -1
View File
@@ -316,6 +316,10 @@ func TestResolveAdvisorModelOverride(t *testing.T) {
providerID := uuid.New()
rawOptions, err := json.Marshal(codersdk.ChatModelCallConfig{
Temperature: func() *float64 { v := 0.42; return &v }(),
ReasoningEffort: &codersdk.ChatModelReasoningEffortConfig{
Default: ptr.Ref(codersdk.ChatModelReasoningEffortLow),
Max: ptr.Ref(codersdk.ChatModelReasoningEffortXHigh),
},
})
require.NoError(t, err)
store := &advisorOverrideStubStore{
@@ -343,7 +347,10 @@ func TestResolveAdvisorModelOverride(t *testing.T) {
gotModel, gotCfg := p.resolveAdvisorModelOverrideOrFallback(
ctx,
database.Chat{},
codersdk.AdvisorConfig{ModelConfigID: configID},
codersdk.AdvisorConfig{
ModelConfigID: configID,
ReasoningEffort: ptr.Ref(codersdk.ChatModelReasoningEffortHigh),
},
fallbackModel,
fallbackCallConfig,
modelBuildOptions{ActiveAPIKeyID: uuid.NewString()},
@@ -359,6 +366,9 @@ func TestResolveAdvisorModelOverride(t *testing.T) {
require.Equal(t, "gpt-5.2", gotModel.Model())
require.NotNil(t, gotCfg.Temperature)
require.InDelta(t, 0.42, *gotCfg.Temperature, 1e-9)
require.NotNil(t, gotCfg.ReasoningEffort)
require.Equal(t, codersdk.ChatModelReasoningEffortHigh, *gotCfg.ReasoningEffort.Default)
require.Equal(t, codersdk.ChatModelReasoningEffortHigh, *gotCfg.ReasoningEffort.Max)
})
t.Run("AIProviderIDResolvesOverrideProviderKeys", func(t *testing.T) {
t.Parallel()
+15 -2
View File
@@ -350,6 +350,19 @@ func (p *Server) resolveAdvisorModelOverride(
return fallbackModel, fallbackCallConfig, nil
}
if advisorCfg.ReasoningEffort != nil {
resolvedEffort := chatprovider.ResolveReasoningEffort(
advisorCfg.ReasoningEffort,
overrideCallConfig.ReasoningEffort,
)
if resolvedEffort != nil {
overrideCallConfig.ReasoningEffort = &codersdk.ChatModelReasoningEffortConfig{
Default: resolvedEffort,
Max: resolvedEffort,
}
}
}
return overrideModel, overrideCallConfig, nil
}
@@ -398,8 +411,8 @@ func (p *Server) newAdvisorRuntime(
}
advisorCallConfig.MaxOutputTokens = ptr.Ref(maxOutputTokens)
// The advisor has no per-turn effort selection; its model config's
// default effort applies.
// The override resolver pins an explicit advisor effort into the model
// config. Fallback models keep their configured default effort.
advisorReasoningEffort := chatprovider.ResolveReasoningEffort(
nil,
advisorCallConfig.ReasoningEffort,
+3
View File
@@ -903,6 +903,9 @@ type AdvisorConfig struct {
// resolved (e.g. the referenced model config was soft-deleted or
// its provider was disabled after the admin saved this config).
ModelConfigID uuid.UUID `json:"model_config_id" format:"uuid"`
// ReasoningEffort overrides the selected advisor model's configured default.
// It requires a non-zero ModelConfigID.
ReasoningEffort *string `json:"reasoning_effort,omitempty"`
}
// UpdateAdvisorConfigRequest is the request body for updating advisor
@@ -37,6 +37,7 @@ Once the experiment is enabled, configure the advisor under **AI Settings** >
| Max uses per turn | `0` (unlimited) | Caps how many times the root agent can call the advisor in a single chat turn. Must be a non-negative integer. |
| Max output tokens | `0` (server default) | Caps the advisor model's response length. `0` uses the server default of 16,384 tokens. Must be a non-negative integer. |
| Advisor model | Use chat model | Optional dedicated chat model config for the advisor. When unset, the advisor reuses the root agent's model. |
| Reasoning effort | Model default | Overrides the selected advisor model's reasoning effort. Available only when the model supports selectable effort. |
The advisor is not available in plan mode or to subagents. Failed advisor
invocations refund the per-turn budget, and advisor calls are not metered
+10
View File
@@ -1077,6 +1077,11 @@ export interface AdvisorConfig {
* its provider was disabled after the admin saved this config).
*/
readonly model_config_id: string;
/**
* ReasoningEffort overrides the selected advisor model's configured default.
* It requires a non-zero ModelConfigID.
*/
readonly reasoning_effort?: string;
}
// From codersdk/users.go
@@ -9025,6 +9030,11 @@ export interface UpdateAdvisorConfigRequest {
* its provider was disabled after the admin saved this config).
*/
readonly model_config_id: string;
/**
* ReasoningEffort overrides the selected advisor model's configured default.
* It requires a non-zero ModelConfigID.
*/
readonly reasoning_effort?: string;
}
// From codersdk/deployment.go
@@ -55,6 +55,17 @@ const claudeSonnetModelConfig = buildModelConfig({
context_limit: 200_000,
});
const advisorReasoningModelConfig = buildModelConfig({
id: "model-advisor-gpt-5.2",
model: "gpt-5.2",
display_name: "GPT 5.2",
context_limit: 200_000,
model_config: {
reasoning_effort: { default: "medium", max: "xhigh" },
},
reasoning_efforts: ["none", "minimal", "low", "medium", "high", "xhigh"],
});
const titleModelConfig = buildModelConfig({
id: "model-title-gpt-4o-mini",
model: "gpt-4o-mini",
@@ -97,6 +108,7 @@ const exploreDisabledModelConfig = buildModelConfig({
const allModelConfigs: TypesGen.ChatModelConfig[] = [
generalModelConfig,
claudeSonnetModelConfig,
advisorReasoningModelConfig,
titleModelConfig,
exploreFallbackModelConfig,
generalDisabledModelConfig,
@@ -514,7 +526,7 @@ export const AdvisorSettingsVisible: Story = {
}),
).toHaveValue(16384);
expect(
within(section).getByRole("combobox", { name: "Advisor model" }),
within(section).getByRole("combobox", { name: "Use chat model" }),
).toBeInTheDocument();
// Changing a value exposes the Save button.
@@ -537,6 +549,47 @@ export const AdvisorSettingsVisible: Story = {
},
};
export const AdvisorReasoningEffort: Story = {
args: buildArgs({
showAdvisorSettings: true,
advisorConfigData: {
enabled: true,
max_uses_per_run: 3,
max_output_tokens: 16384,
model_config_id: advisorReasoningModelConfig.id,
reasoning_effort: "medium",
},
}),
play: async ({ canvasElement, args }) => {
const section = await getSection(canvasElement, "Advisor");
const modelSelector = within(section).getByRole("combobox", {
name: "GPT 5.2",
});
expect(modelSelector).toBeInTheDocument();
const maxUses = within(section).getByRole("spinbutton", {
name: "Max uses per turn",
});
await userEvent.clear(maxUses);
await userEvent.type(maxUses, "5");
const saveButton = await within(section).findByRole("button", {
name: "Save",
});
expect(saveButton).toBeEnabled();
await userEvent.click(saveButton);
await waitFor(() => {
expect(args.onSaveAdvisorConfig).toHaveBeenCalledWith(
expect.objectContaining({
max_uses_per_run: 5,
model_config_id: advisorReasoningModelConfig.id,
reasoning_effort: "medium",
}),
expect.anything(),
);
});
},
};
export const AdvisorClearButton: Story = {
args: buildArgs({
showAdvisorSettings: true,
@@ -544,10 +597,11 @@ export const AdvisorClearButton: Story = {
enabled: true,
max_uses_per_run: 3,
max_output_tokens: 16384,
model_config_id: generalModelConfig.id,
model_config_id: advisorReasoningModelConfig.id,
reasoning_effort: "high",
},
}),
play: async ({ canvasElement }) => {
play: async ({ canvasElement, args }) => {
const section = await getSection(canvasElement, "Advisor");
const clearButton = within(section).getByRole("button", { name: "Clear" });
await userEvent.click(clearButton);
@@ -562,8 +616,24 @@ export const AdvisorClearButton: Story = {
}),
).toHaveValue(0);
expect(
within(section).getByRole("combobox", { name: "Advisor model" }),
within(section).getByRole("combobox", { name: "Use chat model" }),
).toHaveTextContent("Use chat model");
const saveButton = within(section).getByRole("button", { name: "Save" });
await waitFor(() => {
expect(saveButton).toBeEnabled();
});
await userEvent.click(saveButton);
await waitFor(() => {
expect(args.onSaveAdvisorConfig).toHaveBeenCalledWith(
{
enabled: true,
max_uses_per_run: 0,
max_output_tokens: 0,
model_config_id: "00000000-0000-0000-0000-000000000000",
},
expect.anything(),
);
});
},
};
@@ -201,6 +201,7 @@ export const CoderAgentsPageView: FC<CoderAgentsPageViewProps> = ({
isAdvisorConfigFetching={isAdvisorConfigFetching}
isAdvisorConfigLoadError={isAdvisorConfigLoadError}
modelConfigs={modelConfigsData ?? []}
providerInfoByID={providerInfoByID}
modelConfigsError={modelConfigsError}
isLoadingModelConfigs={isLoadingModelConfigs}
isFetchingModelConfigs={isFetchingModelConfigs}
@@ -7,20 +7,14 @@ import type {
UpdateAdvisorConfigRequest,
} from "#/api/typesGenerated";
import { Button } from "#/components/Button/Button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "#/components/Select/Select";
import { useTemporarySavedState } from "#/components/TemporarySavedState/TemporarySavedState";
import { ModelSelector } from "#/pages/AgentsPage/components/ChatElements/ModelSelector";
import type { ProviderInfo } from "#/pages/AgentsPage/utils/modelOptions";
import { pickReasoningEffort } from "#/pages/AgentsPage/utils/reasoningEffort";
import { AgentSettingLayout } from "#/pages/AISettingsPage/CoderAgentsPage/components/AgentSettingLayout";
import { cn } from "#/utils/cn";
const nilUUID = "00000000-0000-0000-0000-000000000000";
const chatModelFallbackValue = "__use-chat-model__";
const unavailableModelValue = "__unavailable-model__";
interface MutationCallbacks {
onSuccess?: () => void;
@@ -33,6 +27,7 @@ interface AdvisorSettingsProps {
isAdvisorConfigFetching: boolean;
isAdvisorConfigLoadError: boolean;
modelConfigs: readonly ChatModelConfig[];
providerInfoByID: ReadonlyMap<string, ProviderInfo>;
modelConfigsError: unknown;
isLoadingModelConfigs: boolean;
isFetchingModelConfigs: boolean;
@@ -49,6 +44,7 @@ type AdvisorSettingsFormValues = {
max_uses_per_run: string;
max_output_tokens: string;
model_config_id: string;
reasoning_effort: string;
};
const isUnsetModelConfigId = (id: string): boolean =>
@@ -78,6 +74,7 @@ const normalizeAdvisorConfig = (
!isUnsetModelConfigId(config.model_config_id)
? config.model_config_id
: "",
reasoning_effort: config?.reasoning_effort ?? "",
});
const toAdvisorConfigRequest = (
@@ -89,6 +86,9 @@ const toAdvisorConfigRequest = (
model_config_id: isUnsetModelConfigId(values.model_config_id)
? nilUUID
: values.model_config_id,
...(!isUnsetModelConfigId(values.model_config_id) && values.reasoning_effort
? { reasoning_effort: values.reasoning_effort }
: {}),
});
const isNonNegativeIntegerString = (value: string): boolean => {
@@ -115,15 +115,13 @@ const validateAdvisorConfig = (values: AdvisorSettingsFormValues) => {
return errors;
};
const getModelDisplayName = (config: ChatModelConfig): string =>
config.display_name.trim() || config.model;
export const AdvisorSettings: FC<AdvisorSettingsProps> = ({
advisorConfigData,
isAdvisorConfigLoading,
isAdvisorConfigFetching,
isAdvisorConfigLoadError,
modelConfigs,
providerInfoByID,
modelConfigsError,
isLoadingModelConfigs,
isFetchingModelConfigs,
@@ -136,7 +134,27 @@ export const AdvisorSettings: FC<AdvisorSettingsProps> = ({
const maxOutputTokensId = useId();
const { isSavedVisible, showSavedState } = useTemporarySavedState();
const hasLoadedAdvisorConfig = advisorConfigData !== undefined;
const enabledModelConfigs = modelConfigs.filter((config) => config.enabled);
const enabledModelOptions = modelConfigs
.filter((config) => config.enabled)
.map((config) => {
const providerInfo = providerInfoByID.get(config.ai_provider_id);
const reasoningEffort = config.model_config?.reasoning_effort;
const reasoningEfforts = config.reasoning_efforts ?? [];
return {
id: config.id,
provider: providerInfo?.provider ?? "",
providerId: config.ai_provider_id,
providerLabel: providerInfo?.displayName,
providerIcon: providerInfo?.icon,
model: config.model,
displayName: config.display_name.trim() || config.model,
contextLimit: config.context_limit,
...(reasoningEffort?.default
? { reasoningEffortDefault: reasoningEffort.default }
: {}),
...(reasoningEfforts.length > 0 ? { reasoningEfforts } : {}),
};
});
const form = useFormik<AdvisorSettingsFormValues>({
enableReinitialize: true,
@@ -157,7 +175,37 @@ export const AdvisorSettings: FC<AdvisorSettingsProps> = ({
!modelConfigsError &&
!modelConfigs.some((config) => config.id === source.model_config_id)
) {
source = { ...source, model_config_id: "" };
source = { ...source, model_config_id: "", reasoning_effort: "" };
}
// A stored effort can become unselectable if the model config's
// efforts changed after the setting was saved. Submit the same
// sanitized effort the slider displays so the backend does not
// reject the stale value. An empty effort stays empty so the
// advisor keeps following the model config's default. When the
// saved model is proven unavailable (disabled), drop the effort so
// unrelated edits still save; while configs are loading,
// refetching, or errored, preserve the stored effort.
const submitOption = enabledModelOptions.find(
(option) => option.id === source.model_config_id,
);
if (source.reasoning_effort) {
if (submitOption) {
source = {
...source,
reasoning_effort:
pickReasoningEffort(
source.reasoning_effort,
submitOption.reasoningEfforts ?? [],
submitOption.reasoningEffortDefault,
) ?? "",
};
} else if (
!isLoadingModelConfigs &&
!isFetchingModelConfigs &&
!modelConfigsError
) {
source = { ...source, reasoning_effort: "" };
}
}
const request = toAdvisorConfigRequest(source);
onSaveAdvisorConfig(request, {
@@ -177,27 +225,20 @@ export const AdvisorSettings: FC<AdvisorSettingsProps> = ({
!hasLoadedAdvisorConfig;
const isModelSelectDisabled =
isFormDisabled || isLoadingModelConfigs || Boolean(modelConfigsError);
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 hasUnavailableSelectedModel =
!isLoadingModelConfigs &&
!isUnsetModelConfigId(form.values.model_config_id) &&
!enabledModelConfigs.some(
(config) => config.id === form.values.model_config_id,
);
const selectedModelConfig = modelConfigs.find(
(config) => config.id === form.values.model_config_id,
);
const selectedModelLabel = isUnsetModelConfigId(form.values.model_config_id)
? "Use chat model"
: isLoadingModelConfigs
? "Loading..."
: selectedModelConfig
? getModelDisplayName(selectedModelConfig)
: `Unavailable model (${form.values.model_config_id})`;
const selectedModelValue = isUnsetModelConfigId(form.values.model_config_id)
? chatModelFallbackValue
: hasUnavailableSelectedModel
? unavailableModelValue
: form.values.model_config_id;
selectedModelOption === undefined;
const canSave = hasLoadedAdvisorConfig && form.dirty && form.isValid;
return (
@@ -248,42 +289,45 @@ export const AdvisorSettings: FC<AdvisorSettingsProps> = ({
disabled={isFormDisabled}
className="w-36"
/>
<Select
value={selectedModelValue}
<ModelSelector
options={enabledModelOptions}
value={form.values.model_config_id}
onValueChange={(value) => {
if (value === chatModelFallbackValue) {
void form.setFieldValue("model_config_id", "");
return;
const option = enabledModelOptions.find(
(option) => option.id === value,
);
let reasoningEffort = "";
if (option) {
reasoningEffort =
pickReasoningEffort(
"",
option.reasoningEfforts ?? [],
option.reasoningEffortDefault,
) ?? "";
}
if (value === unavailableModelValue) {
return;
}
void form.setFieldValue("model_config_id", value);
void form.setValues({
...form.values,
model_config_id: value,
reasoning_effort: reasoningEffort,
});
}}
disabled={isModelSelectDisabled}
>
<SelectTrigger
className="h-10 w-[22rem] max-w-full justify-between rounded-md border border-border border-solid bg-transparent px-3 text-sm shadow-none"
aria-label="Advisor model"
>
<SelectValue placeholder="Use chat model">
{selectedModelLabel}
</SelectValue>
</SelectTrigger>
<SelectContent>
{hasUnavailableSelectedModel && (
<SelectItem value={unavailableModelValue}>
{selectedModelLabel}
</SelectItem>
)}
<SelectItem value={chatModelFallbackValue}>Use chat model</SelectItem>
{enabledModelConfigs.map((config) => (
<SelectItem key={config.id} value={config.id}>
{getModelDisplayName(config)}
</SelectItem>
))}
</SelectContent>
</Select>
placeholder={
hasUnavailableSelectedModel ? "Unavailable model" : "Use chat model"
}
unsetLabel="Use chat model"
emptyMessage={
isLoadingModelConfigs
? "Loading models..."
: "No enabled models found."
}
className="h-10 w-[22rem] max-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)
}
/>
<Button
size="lg"
variant="outline"
@@ -293,6 +337,7 @@ export const AdvisorSettings: FC<AdvisorSettingsProps> = ({
max_uses_per_run: "0",
max_output_tokens: "0",
model_config_id: "",
reasoning_effort: "",
});
}}
disabled={isFormDisabled}
@@ -45,6 +45,12 @@ interface ModelSelectorProps {
onValueChange: (value: string) => void;
disabled?: boolean;
placeholder?: string;
/**
* When set, renders an option above the model list that selects the
* empty value, letting users unset the model without a separate
* clear control.
*/
unsetLabel?: string;
emptyMessage?: string;
formatProviderLabel?: (provider: string) => string;
className?: string;
@@ -88,6 +94,7 @@ export const ModelSelector: FC<ModelSelectorProps> = ({
onValueChange,
disabled = false,
placeholder = "Select model",
unsetLabel,
emptyMessage = "No models found.",
formatProviderLabel = defaultFormatProviderLabel,
className,
@@ -108,7 +115,9 @@ export const ModelSelector: FC<ModelSelectorProps> = ({
setOpen(nextOpen);
};
const selectedModel = options.find((option) => option.id === value);
const isDisabled = disabled || options.length === 0;
// With an unset option the selector stays usable even when no model
// options exist, so a saved override can still be switched back.
const isDisabled = disabled || (options.length === 0 && !unsetLabel);
const query = search.trim().toLowerCase();
const optionsByProvider = (() => {
const grouped = new Map<string, ModelSelectorOption[]>();
@@ -196,6 +205,32 @@ export const ModelSelector: FC<ModelSelectorProps> = ({
<CommandEmpty className="py-3 text-xs font-normal leading-[18px] text-content-secondary">
{emptyMessage}
</CommandEmpty>
{unsetLabel &&
(!query || unsetLabel.toLowerCase().includes(query)) && (
<CommandGroup className="p-1">
<CommandItem
value="__unset__"
onSelect={() => {
onValueChange("");
handleOpenChange(false);
}}
className={cn(
"gap-2 px-2 py-1 font-medium text-content-secondary data-[selected=true]:bg-surface-tertiary",
!value && "bg-surface-secondary",
)}
>
<span className="min-w-0 truncate text-left text-xs font-medium leading-[18px] text-content-secondary">
{unsetLabel}
</span>
<CheckIcon
className={cn(
"ml-auto size-4 shrink-0",
value && "opacity-0",
)}
/>
</CommandItem>
</CommandGroup>
)}
{optionsByProvider.map(([providerKey, providerOptions], index) => {
const firstOption = providerOptions[0];
const providerLabel = getProviderLabel(