feat: no silent model fallback — explicit failure, gone models, per-profile fallback (#2385)

Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
Clément Fiere
2026-08-09 22:44:25 +02:00
committed by GitHub
parent 0b60af298e
commit e4bc3be8b1
94 changed files with 5867 additions and 636 deletions
@@ -1,6 +1,7 @@
"use client";
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Label } from "@kandev/ui/label";
import { Input } from "@kandev/ui/input";
import { Switch } from "@kandev/ui/switch";
@@ -10,6 +11,7 @@ import { useAppStore } from "@/components/state-provider";
import { useAvailableAgents } from "@/hooks/domains/settings/use-available-agents";
import { ModelCombobox } from "@/components/settings/model-combobox";
import { ModeCombobox } from "@/components/settings/mode-combobox";
import { ModelFallbackFields } from "@/components/agent/cli-profile-fallback-fields";
import { CLIFlagsField } from "@/components/settings/cli-flags-field";
import {
createAgentAction,
@@ -22,7 +24,6 @@ import {
buildDefaultPermissions,
PERMISSION_APPLY_AGENTCTL_AUTO_APPROVE,
} from "@/lib/agent-permissions";
import { useTranslation } from "react-i18next";
export type CliProfileEditorMode = "create" | "edit";
@@ -51,6 +52,8 @@ type FormState = {
agentName: string;
profileName: string;
model: string;
fallbackModel: string;
autoFallback: boolean;
mode: string;
cliFlags: CLIFlag[];
cliPassthrough: boolean;
@@ -67,6 +70,8 @@ function fromExistingProfile(profile: AgentProfile): FormState {
agentName: profile.agentId ?? "",
profileName: profile.name,
model: profile.model ?? "",
fallbackModel: profile.fallbackModel ?? "",
autoFallback: profile.autoFallback ?? false,
mode: profile.mode ?? "",
cliFlags: profile.cliFlags ?? [],
cliPassthrough: profile.cliPassthrough ?? false,
@@ -86,6 +91,8 @@ function fromDefaultAgent(
agentName: defaultAgent?.name ?? "",
profileName: defaultName,
model: cfg?.default_model ?? "",
fallbackModel: "",
autoFallback: false,
mode: cfg?.current_mode_id ?? "",
cliFlags: seedDefaultCLIFlags(permissionSettings),
cliPassthrough: false,
@@ -180,6 +187,10 @@ export function CliProfileEditor({
patch({
agentName: name,
model: installed.find((a) => a.name === name)?.model_config.default_model ?? "",
// A fallback model belongs to the previous client's model
// vocabulary; carrying it over would save a false gone-model
// configuration for the new client.
fallbackModel: "",
mode: installed.find((a) => a.name === name)?.model_config.current_mode_id ?? "",
cliFlags: seedDefaultCLIFlags(
installed.find((a) => a.name === name)?.permission_settings ?? {},
@@ -194,13 +205,7 @@ export function CliProfileEditor({
onChange={(profileName) => patch({ profileName })}
/>
<ModelModeFields
modelConfig={modelConfig ?? null}
model={form.model}
mode={form.mode}
onModelChange={(v) => patch({ model: v })}
onModeChange={(v) => patch({ mode: v })}
/>
<ModelModeFieldsBinding form={form} patch={patch} modelConfig={modelConfig ?? null} />
<AdvancedToggles
open={advancedOpen}
@@ -307,47 +312,108 @@ function CliClientPicker({
);
}
// ModelModeFieldsBinding forwards the editor's form state to ModelModeFields.
// Extracted so CliProfileEditor stays under the linter's line budget.
function ModelModeFieldsBinding({
form,
patch,
modelConfig,
}: {
form: FormState;
patch: (p: Partial<FormState>) => void;
modelConfig: NonNullable<AvailableAgent["model_config"]> | null;
}) {
return (
<ModelModeFields
modelConfig={modelConfig}
model={form.model}
fallbackModel={form.fallbackModel}
autoFallback={form.autoFallback}
mode={form.mode}
onModelChange={(v) => patch({ model: v })}
onFallbackModelChange={(v) => patch({ fallbackModel: v })}
onAutoFallbackChange={(v) => patch({ autoFallback: v })}
onModeChange={(v) => patch({ mode: v })}
/>
);
}
type ModelModeFieldsProps = {
modelConfig: NonNullable<AvailableAgent["model_config"]> | null;
model: string;
fallbackModel: string;
autoFallback: boolean;
mode: string;
onModelChange: (v: string) => void;
onFallbackModelChange: (v: string) => void;
onAutoFallbackChange: (v: boolean) => void;
onModeChange: (v: string) => void;
};
function ModelModeFields({
modelConfig,
model,
fallbackModel,
autoFallback,
mode,
onModelChange,
onFallbackModelChange,
onAutoFallbackChange,
onModeChange,
}: ModelModeFieldsProps) {
const { t } = useTranslation();
if (!modelConfig) {
return <p className="text-xs text-muted-foreground">{t("common:pickACliClientToLoad")}</p>;
}
const availableModels = modelConfig.available_models ?? [];
const startModelGone = Boolean(model && !availableModels.some((m) => m.id === model));
const fallbackModelGone = Boolean(
fallbackModel && !availableModels.some((m) => m.id === fallbackModel),
);
return (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<Label>{t("common:model")}</Label>
<ModelCombobox
value={model}
onChange={onModelChange}
models={modelConfig.available_models ?? []}
currentModelId={modelConfig.current_model_id}
/>
</div>
{(modelConfig.available_modes ?? []).length > 0 && (
<div className="grid grid-cols-1 gap-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<Label>{t("common:mode")}</Label>
<ModeCombobox
value={mode}
onChange={onModeChange}
modes={modelConfig.available_modes ?? []}
currentModeId={modelConfig.current_mode_id}
<Label>{t("common:model")}</Label>
<ModelCombobox
value={model}
onChange={onModelChange}
models={
startModelGone
? [
...availableModels,
{
id: model,
name: `${model} (${t("settings:startModelUnavailable")})`,
disabled: true,
},
]
: availableModels
}
currentModelId={modelConfig.current_model_id}
/>
</div>
)}
{(modelConfig.available_modes ?? []).length > 0 && (
<div>
<Label>{t("common:mode")}</Label>
<ModeCombobox
value={mode}
onChange={onModeChange}
modes={modelConfig.available_modes ?? []}
currentModeId={modelConfig.current_mode_id}
/>
</div>
)}
</div>
<ModelFallbackFields
availableModels={availableModels}
fallbackModel={fallbackModel}
fallbackModelGone={fallbackModelGone}
autoFallback={autoFallback}
currentModelId={modelConfig.current_model_id}
onFallbackModelChange={onFallbackModelChange}
onAutoFallbackChange={onAutoFallbackChange}
/>
</div>
);
}
@@ -500,6 +566,8 @@ async function saveExistingProfile(id: string, form: FormState): Promise<AgentPr
return updateAgentProfileAction(id, {
name: form.profileName.trim(),
model: form.model,
fallback_model: form.fallbackModel ?? "",
auto_fallback: form.autoFallback,
mode: form.mode || undefined,
allow_indexing: form.allowIndexing,
auto_approve: form.autoApprove,
@@ -513,6 +581,8 @@ async function saveNewProfile(form: FormState, settingsAgents: Agent[]): Promise
const profilePayload = {
name: form.profileName.trim(),
model: form.model,
fallback_model: form.fallbackModel ?? "",
auto_fallback: form.autoFallback,
mode: form.mode || undefined,
allow_indexing: form.allowIndexing,
auto_approve: form.autoApprove,