mirror of
https://github.com/coder/coder.git
synced 2026-10-30 18:12:29 +08:00
feat: allow overriding advisor reasoning effort (#27196)
This commit is contained in:
+19
-4
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+10
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user