Files
coder/site/src/pages/AgentsPage/components/SubagentModelOverrideSettings.tsx
T
Michael Suchacz 033ed0bb82 feat: add admin-configurable chat title generation model (#24838)
Adds an admin-configurable deployment-wide setting that controls which
model is used for chat title generation. Admins can pick any enabled
chat model config from the Agents settings page, or leave the setting
unset to keep the existing fast-models-then-chat-model fallback
algorithm.

When a model is selected, both automatic and manual title generation use
only that model, with no silent fallback. When the configured model is
disabled, missing credentials, or otherwise unusable, automatic title
generation skips entirely (best-effort) and manual title regeneration
returns a clear error, so admins notice the misconfiguration instead of
silently routing title traffic through another provider.

## Surface

- New deployment-wide setting stored as a `site_configs` row
(`agents_chat_title_generation_model_override`).
- New experimental endpoint `GET/PUT
/api/experimental/chats/config/model-override/{context}`.
- Frontend: title generation now appears as a third dropdown on the
Agents admin settings page alongside the existing general and explore
context overrides.

## DRY refactors folded in

Title generation is integrated as a third value of the existing
`ChatModelOverrideContext` type alongside `general` and `explore`,
sharing the parameterized HTTP route, SDK methods, generated types, and
frontend API plumbing rather than introducing a parallel surface. The
`Agent` prefix was dropped from the type and route since title
generation is not a delegated agent.

The chatd model-override resolver is also shared.
`resolveConfiguredModelOverride` now takes a `failureMode` parameter:

- Subagent overrides use soft failure: misconfigured overrides are
logged and the parent model is used.
- Title generation uses hard failure: misconfigured overrides return an
explicit error so manual title regeneration surfaces the
misconfiguration and automatic title generation skips instead of
silently falling back.

> Mux is acting on Mike's behalf.
2026-05-04 13:13:00 +02:00

178 lines
4.7 KiB
TypeScript

import { useFormik } from "formik";
import type { FC, ReactNode } from "react";
import type * as TypesGen from "#/api/typesGenerated";
import { Alert, AlertDescription } from "#/components/Alert/Alert";
import { Button } from "#/components/Button/Button";
import type { ModelSelectorOption } from "./ChatElements/ModelSelector";
import { ModelSelector } from "./ChatElements/ModelSelector";
export interface MutationCallbacks {
onSuccess?: () => void;
onError?: () => void;
}
interface ModelOverrideData {
readonly model_config_id: string;
readonly is_malformed: boolean;
}
interface UpdateModelOverrideRequest {
readonly model_config_id: string;
}
interface SubagentModelOverrideSettingsProps {
title: string;
description?: ReactNode;
modelOverrideData: ModelOverrideData | undefined;
enabledModelConfigs: readonly TypesGen.ChatModelConfig[];
modelConfigsError: unknown;
isLoading: boolean;
onSaveModelOverride: (
req: UpdateModelOverrideRequest,
options?: MutationCallbacks,
) => void;
isSaving: boolean;
isSaveError: boolean;
saveErrorMessage: string;
unsetPlaceholder?: string;
unavailableModelWarning?: string;
showHeader?: boolean;
disabled?: boolean;
}
const toModelSelectorOption = (
modelConfig: TypesGen.ChatModelConfig,
): ModelSelectorOption => ({
id: modelConfig.id,
provider: modelConfig.provider,
model: modelConfig.model,
displayName: modelConfig.display_name.trim() || modelConfig.model,
contextLimit: modelConfig.context_limit,
});
export const SubagentModelOverrideSettings: FC<
SubagentModelOverrideSettingsProps
> = ({
title,
description,
modelOverrideData,
enabledModelConfigs,
modelConfigsError,
isLoading,
onSaveModelOverride,
isSaving,
isSaveError,
saveErrorMessage,
unsetPlaceholder = "Use chat default",
unavailableModelWarning = "The saved model is no longer enabled and will be ignored until you choose a new override.",
showHeader = true,
disabled = false,
}) => {
const hasLoadedModelOverride = modelOverrideData !== undefined;
const enabledModelOptions = enabledModelConfigs.map(toModelSelectorOption);
const form = useFormik({
enableReinitialize: true,
initialValues: {
model_config_id: modelOverrideData?.model_config_id ?? "",
},
onSubmit: (values, { resetForm }) => {
onSaveModelOverride(
{
model_config_id: values.model_config_id,
},
{
onSuccess: () => {
resetForm({ values });
},
},
);
},
});
const isUnavailableSavedModel =
form.values.model_config_id !== "" &&
!enabledModelOptions.some(
(option) => option.id === form.values.model_config_id,
);
const isMalformedOverride = modelOverrideData?.is_malformed ?? false;
const isModelOverrideDisabled =
disabled || isSaving || isLoading || !hasLoadedModelOverride;
const canSaveModelOverride =
hasLoadedModelOverride && (form.dirty || isMalformedOverride);
return (
<form aria-label={title} className="space-y-2" onSubmit={form.handleSubmit}>
{showHeader && (
<>
<h3 className="m-0 text-[13px] font-semibold text-content-primary">
{title}
</h3>
{description && (
<p className="!mt-0.5 m-0 text-xs text-content-secondary">
{description}
</p>
)}
</>
)}
<ModelSelector
options={enabledModelOptions}
value={form.values.model_config_id}
onValueChange={(value) => form.setFieldValue("model_config_id", value)}
disabled={isModelOverrideDisabled}
placeholder={
isUnavailableSavedModel ? "Unavailable model" : unsetPlaceholder
}
emptyMessage={
isLoading ? "Loading models..." : "No enabled models found."
}
className="h-10 w-full justify-between rounded-md border border-border border-solid bg-transparent px-3 text-sm shadow-sm"
contentClassName="min-w-[18rem]"
/>
{isUnavailableSavedModel && (
<Alert severity="warning">
<AlertDescription>{unavailableModelWarning}</AlertDescription>
</Alert>
)}
{isMalformedOverride && (
<Alert severity="warning">
<AlertDescription>
The saved override is malformed and is being treated as unset. Click
Save to clear it.
</AlertDescription>
</Alert>
)}
{Boolean(modelConfigsError) && (
<p className="m-0 text-xs text-content-destructive">
Failed to load model configs.
</p>
)}
<div className="flex justify-end gap-2">
<Button
size="sm"
variant="outline"
type="button"
onClick={() => {
form.setFieldValue("model_config_id", "");
}}
disabled={isModelOverrideDisabled}
>
Clear
</Button>
<Button
size="sm"
type="submit"
disabled={isModelOverrideDisabled || !canSaveModelOverride}
>
Save
</Button>
</div>
{isSaveError && (
<p className="m-0 text-xs text-content-destructive">
{saveErrorMessage}
</p>
)}
</form>
);
};