Unify utility model settings and align git generation (#486)

* refactor(api): extend git generation payload types

* refactor(settings): add git provider model fields

* feat(config): persist git provider model defaults

* feat(git-api): send provider and model ids

* fix(git-api): forward generation options in runtime

* feat(git-view): use configured model for commit generation

* feat(git-view): pass configured model for PR generation

* feat(vscode): forward model selection in git bridge payload

* feat(vscode): align PR generation with session model flow

* feat(web): resolve and generate git text with provider model

* refactor(settings): unify utility model picker across providers

* chore(settings): rename sidebar item to utility model

* fix(git-model): validate and auto-heal stale utility selections

---------

Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
Nelson Pires
2026-02-24 10:23:57 +02:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent 8647e0c1a4
commit 7a11867a19
11 changed files with 978 additions and 335 deletions
@@ -12,10 +12,8 @@ import { useUIStore } from '@/stores/useUIStore';
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { getModifierLabel, cn } from '@/lib/utils';
interface ZenModel {
id: string;
owned_by?: string;
}
const UTILITY_PROVIDER_ID = 'zen';
const UTILITY_PREFERRED_MODEL_ID = 'big-pickle';
const getDisplayModel = (
storedModel: string | undefined
@@ -26,9 +24,46 @@ const getDisplayModel = (
return { providerId: parts[0], modelId: parts[1] };
}
}
// Return empty values when no model is explicitly set
// This allows showing "Not selected" instead of a fallback
return { providerId: '', modelId: '' };
};
const getUtilityDisplayModel = (
storedGitProviderId: string | undefined,
storedGitModelId: string | undefined,
zenModel: string | undefined,
providers: Array<{ id: string; models: Array<{ id: string }> }>
): { providerId: string; modelId: string } => {
if (storedGitProviderId && storedGitModelId) {
const provider = providers.find((p) => p.id === storedGitProviderId);
if (provider?.models.some((m) => m.id === storedGitModelId)) {
return { providerId: storedGitProviderId, modelId: storedGitModelId };
}
}
const utilityProvider = providers.find((p) => p.id === UTILITY_PROVIDER_ID);
if (zenModel && utilityProvider?.models.some((m) => m.id === zenModel)) {
return { providerId: UTILITY_PROVIDER_ID, modelId: zenModel };
}
const preferredUtilityModel = utilityProvider?.models.find((m) => m.id === UTILITY_PREFERRED_MODEL_ID);
if (preferredUtilityModel) {
return { providerId: UTILITY_PROVIDER_ID, modelId: preferredUtilityModel.id };
}
if (utilityProvider?.models.length) {
const randomIndex = Math.floor(Math.random() * utilityProvider.models.length);
const randomModel = utilityProvider.models[randomIndex];
if (randomModel?.id) {
return { providerId: UTILITY_PROVIDER_ID, modelId: randomModel.id };
}
}
const firstProvider = providers[0];
if (firstProvider?.models[0]) {
return { providerId: firstProvider.id, modelId: firstProvider.models[0].id };
}
return { providerId: '', modelId: '' };
};
@@ -44,6 +79,10 @@ export const DefaultsSettings: React.FC = () => {
const setSettingsAutoCreateWorktree = useConfigStore((state) => state.setSettingsAutoCreateWorktree);
const settingsZenModel = useConfigStore((state) => state.settingsZenModel);
const setSettingsZenModel = useConfigStore((state) => state.setSettingsZenModel);
const settingsGitProviderId = useConfigStore((state) => state.settingsGitProviderId);
const settingsGitModelId = useConfigStore((state) => state.settingsGitModelId);
const setSettingsGitProviderId = useConfigStore((state) => state.setSettingsGitProviderId);
const setSettingsGitModelId = useConfigStore((state) => state.setSettingsGitModelId);
const showDeletionDialog = useUIStore((state) => state.showDeletionDialog);
const setShowDeletionDialog = useUIStore((state) => state.setShowDeletionDialog);
const providers = useConfigStore((state) => state.providers);
@@ -52,54 +91,25 @@ export const DefaultsSettings: React.FC = () => {
const [defaultVariant, setDefaultVariant] = React.useState<string | undefined>();
const [defaultAgent, setDefaultAgent] = React.useState<string | undefined>();
const [isLoading, setIsLoading] = React.useState(true);
const [zenModels, setZenModels] = React.useState<ZenModel[]>([]);
const [zenModelsLoading, setZenModelsLoading] = React.useState(true);
const parsedModel = React.useMemo(() => {
return getDisplayModel(defaultModel);
}, [defaultModel]);
const parsedModel = React.useMemo(() => getDisplayModel(defaultModel), [defaultModel]);
const utilityDisplayModel = React.useMemo(() => {
return getUtilityDisplayModel(settingsGitProviderId, settingsGitModelId, settingsZenModel, providers);
}, [settingsGitProviderId, settingsGitModelId, settingsZenModel, providers]);
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
// Load zen models list
React.useEffect(() => {
const loadZenModels = async () => {
try {
const response = await fetch('/api/zen/models', {
method: 'GET',
headers: { Accept: 'application/json' },
});
if (response.ok) {
const data = await response.json() as { models?: ZenModel[] };
if (Array.isArray(data?.models)) {
setZenModels(data.models);
}
}
} catch (error) {
console.warn('Failed to load zen models:', error);
} finally {
setZenModelsLoading(false);
}
};
loadZenModels();
}, []);
// Resolve which zen model to display as selected
const selectedZenModel = React.useMemo(() => {
if (settingsZenModel && zenModels.some((m) => m.id === settingsZenModel)) {
return settingsZenModel;
}
// Default to first free model in the list
return zenModels[0]?.id ?? '';
}, [settingsZenModel, zenModels]);
// Load current settings
React.useEffect(() => {
const loadSettings = async () => {
try {
let data: { defaultModel?: string; defaultVariant?: string; defaultAgent?: string; zenModel?: string } | null = null;
let data: {
defaultModel?: string;
defaultVariant?: string;
defaultAgent?: string;
zenModel?: string;
gitProviderId?: string;
gitModelId?: string;
} | null = null;
// 1. Runtime settings API (VSCode)
if (!data) {
const runtimeSettings = getRegisteredRuntimeAPIs()?.settings;
if (runtimeSettings) {
@@ -109,9 +119,23 @@ export const DefaultsSettings: React.FC = () => {
if (settings) {
data = {
defaultModel: typeof settings.defaultModel === 'string' ? settings.defaultModel : undefined,
defaultVariant: typeof (settings as Record<string, unknown>).defaultVariant === 'string' ? ((settings as Record<string, unknown>).defaultVariant as string) : undefined,
defaultVariant:
typeof (settings as Record<string, unknown>).defaultVariant === 'string'
? ((settings as Record<string, unknown>).defaultVariant as string)
: undefined,
defaultAgent: typeof settings.defaultAgent === 'string' ? settings.defaultAgent : undefined,
zenModel: typeof (settings as Record<string, unknown>).zenModel === 'string' ? ((settings as Record<string, unknown>).zenModel as string) : undefined,
zenModel:
typeof (settings as Record<string, unknown>).zenModel === 'string'
? ((settings as Record<string, unknown>).zenModel as string)
: undefined,
gitProviderId:
typeof (settings as Record<string, unknown>).gitProviderId === 'string'
? ((settings as Record<string, unknown>).gitProviderId as string)
: undefined,
gitModelId:
typeof (settings as Record<string, unknown>).gitModelId === 'string'
? ((settings as Record<string, unknown>).gitModelId as string)
: undefined,
};
}
} catch {
@@ -120,7 +144,6 @@ export const DefaultsSettings: React.FC = () => {
}
}
// 2. Fetch API (Web/server)
if (!data) {
const response = await fetch('/api/config/settings', {
method: 'GET',
@@ -131,25 +154,39 @@ export const DefaultsSettings: React.FC = () => {
}
}
if (data) {
const model = typeof data.defaultModel === 'string' && data.defaultModel.trim().length > 0 ? data.defaultModel.trim() : undefined;
const variant = typeof data.defaultVariant === 'string' && data.defaultVariant.trim().length > 0 ? data.defaultVariant.trim() : undefined;
const agent = typeof data.defaultAgent === 'string' && data.defaultAgent.trim().length > 0 ? data.defaultAgent.trim() : undefined;
const zen = typeof data.zenModel === 'string' && data.zenModel.trim().length > 0 ? data.zenModel.trim() : undefined;
if (data) {
const model =
typeof data.defaultModel === 'string' && data.defaultModel.trim().length > 0
? data.defaultModel.trim()
: undefined;
const variant =
typeof data.defaultVariant === 'string' && data.defaultVariant.trim().length > 0
? data.defaultVariant.trim()
: undefined;
const agent =
typeof data.defaultAgent === 'string' && data.defaultAgent.trim().length > 0
? data.defaultAgent.trim()
: undefined;
const zen =
typeof data.zenModel === 'string' && data.zenModel.trim().length > 0
? data.zenModel.trim()
: undefined;
const gitProviderId =
typeof data.gitProviderId === 'string' && data.gitProviderId.trim().length > 0
? data.gitProviderId.trim()
: undefined;
const gitModelId =
typeof data.gitModelId === 'string' && data.gitModelId.trim().length > 0
? data.gitModelId.trim()
: undefined;
if (model !== undefined) {
setDefaultModel(model);
}
if (variant !== undefined) {
setDefaultVariant(variant);
}
if (agent !== undefined) {
setDefaultAgent(agent);
}
if (zen !== undefined) {
setSettingsZenModel(zen);
}
}
if (model !== undefined) setDefaultModel(model);
if (variant !== undefined) setDefaultVariant(variant);
if (agent !== undefined) setDefaultAgent(agent);
if (zen !== undefined) setSettingsZenModel(zen);
if (gitProviderId !== undefined) setSettingsGitProviderId(gitProviderId);
if (gitModelId !== undefined) setSettingsGitModelId(gitModelId);
}
} catch (error) {
console.warn('Failed to load defaults settings:', error);
} finally {
@@ -157,88 +194,78 @@ export const DefaultsSettings: React.FC = () => {
}
};
loadSettings();
}, [setSettingsZenModel]);
}, [setSettingsGitModelId, setSettingsGitProviderId, setSettingsZenModel]);
const handleModelChange = React.useCallback(
async (providerId: string, modelId: string) => {
const newValue = providerId && modelId ? `${providerId}/${modelId}` : undefined;
setDefaultModel(newValue);
setDefaultVariant(undefined);
setSettingsDefaultVariant(undefined);
setCurrentVariant(undefined);
setSettingsDefaultModel(newValue);
const handleModelChange = React.useCallback(async (providerId: string, modelId: string) => {
const newValue = providerId && modelId ? `${providerId}/${modelId}` : undefined;
setDefaultModel(newValue);
// Reset variant when model changes (model-specific)
setDefaultVariant(undefined);
setSettingsDefaultVariant(undefined);
setCurrentVariant(undefined);
// Update config store settings default (used by setAgent logic)
setSettingsDefaultModel(newValue);
// Also update current model immediately so new sessions use this model
if (providerId && modelId) {
const provider = providers.find((p) => p.id === providerId);
if (provider) {
setProvider(providerId);
setModel(modelId);
if (providerId && modelId) {
const provider = providers.find((p) => p.id === providerId);
if (provider) {
setProvider(providerId);
setModel(modelId);
}
}
}
try {
await updateDesktopSettings({
defaultModel: newValue ?? '',
defaultVariant: '',
});
{
const response = await fetch('/api/config/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ defaultModel: newValue }),
});
if (!response.ok) {
console.warn('Failed to save default model to server:', response.status, response.statusText);
}
}
} catch (error) {
console.warn('Failed to save default model:', error);
}
}, [providers, setCurrentVariant, setProvider, setModel, setSettingsDefaultModel, setSettingsDefaultVariant]);
try {
await updateDesktopSettings({ defaultModel: newValue ?? '', defaultVariant: '' });
const response = await fetch('/api/config/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ defaultModel: newValue }),
});
if (!response.ok) {
console.warn('Failed to save default model to server:', response.status, response.statusText);
}
} catch (error) {
console.warn('Failed to save default model:', error);
}
},
[providers, setCurrentVariant, setModel, setProvider, setSettingsDefaultModel, setSettingsDefaultVariant]
);
const DEFAULT_VARIANT_VALUE = '__default__';
const handleVariantChange = React.useCallback(async (variant: string) => {
const newValue = variant === DEFAULT_VARIANT_VALUE ? undefined : (variant || undefined);
setDefaultVariant(newValue);
setSettingsDefaultVariant(newValue);
setCurrentVariant(newValue);
const handleVariantChange = React.useCallback(
async (variant: string) => {
const newValue = variant === DEFAULT_VARIANT_VALUE ? undefined : variant || undefined;
setDefaultVariant(newValue);
setSettingsDefaultVariant(newValue);
setCurrentVariant(newValue);
try {
await updateDesktopSettings({
defaultVariant: newValue ?? '',
});
} catch (error) {
console.warn('Failed to save default variant:', error);
}
}, [setCurrentVariant, setSettingsDefaultVariant]);
try {
await updateDesktopSettings({ defaultVariant: newValue ?? '' });
} catch (error) {
console.warn('Failed to save default variant:', error);
}
},
[setCurrentVariant, setSettingsDefaultVariant]
);
const handleAgentChange = React.useCallback(async (agentName: string) => {
const newValue = agentName || undefined;
setDefaultAgent(newValue);
const handleAgentChange = React.useCallback(
async (agentName: string) => {
const newValue = agentName || undefined;
setDefaultAgent(newValue);
setSettingsDefaultAgent(newValue);
// Update config store settings default
setSettingsDefaultAgent(newValue);
if (agentName) {
setAgent(agentName);
}
// Update current agent (setAgent will respect settingsDefaultModel)
if (agentName) {
setAgent(agentName);
}
try {
await updateDesktopSettings({
defaultAgent: newValue ?? '',
});
} catch (error) {
console.warn('Failed to save default agent:', error);
}
}, [setAgent, setSettingsDefaultAgent]);
try {
await updateDesktopSettings({ defaultAgent: newValue ?? '' });
} catch (error) {
console.warn('Failed to save default agent:', error);
}
},
[setAgent, setSettingsDefaultAgent]
);
const availableVariants = React.useMemo(() => {
if (!parsedModel.providerId || !parsedModel.modelId) return [];
@@ -247,9 +274,7 @@ export const DefaultsSettings: React.FC = () => {
| { variants?: Record<string, unknown> }
| undefined;
const variants = model?.variants;
if (!variants) {
return [];
}
if (!variants) return [];
return Object.keys(variants);
}, [parsedModel.modelId, parsedModel.providerId, providers]);
@@ -266,27 +291,37 @@ export const DefaultsSettings: React.FC = () => {
}
}, [defaultVariant, setCurrentVariant, setSettingsDefaultVariant, supportsVariants]);
const handleAutoWorktreeChange = React.useCallback(async (enabled: boolean) => {
setSettingsAutoCreateWorktree(enabled);
try {
await updateDesktopSettings({
autoCreateWorktree: enabled,
});
} catch (error) {
console.warn('Failed to save auto create worktree setting:', error);
}
}, [setSettingsAutoCreateWorktree]);
const handleAutoWorktreeChange = React.useCallback(
async (enabled: boolean) => {
setSettingsAutoCreateWorktree(enabled);
try {
await updateDesktopSettings({ autoCreateWorktree: enabled });
} catch (error) {
console.warn('Failed to save auto create worktree setting:', error);
}
},
[setSettingsAutoCreateWorktree]
);
const handleZenModelChange = React.useCallback(async (modelId: string) => {
setSettingsZenModel(modelId);
try {
await updateDesktopSettings({
zenModel: modelId,
});
} catch (error) {
console.warn('Failed to save zen model setting:', error);
}
}, [setSettingsZenModel]);
const handleUtilityModelChange = React.useCallback(
async (providerId: string, modelId: string) => {
setSettingsGitProviderId(providerId);
setSettingsGitModelId(modelId);
if (providerId === UTILITY_PROVIDER_ID) {
setSettingsZenModel(modelId);
}
try {
await updateDesktopSettings({
gitProviderId: providerId,
gitModelId: modelId,
...(providerId === UTILITY_PROVIDER_ID ? { zenModel: modelId } : {}),
});
} catch (error) {
console.warn('Failed to save utility model setting:', error);
}
},
[setSettingsGitModelId, setSettingsGitProviderId, setSettingsZenModel]
);
if (isLoading) {
return null;
@@ -319,85 +354,65 @@ export const DefaultsSettings: React.FC = () => {
)}
</div>
<div className={cn("flex flex-col gap-2 py-1 sm:flex-row sm:items-center sm:gap-8")}>
<div className={cn('flex flex-col gap-2 py-1 sm:flex-row sm:items-center sm:gap-8')}>
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
<span className="typography-ui-label text-foreground">Default Model</span>
</div>
<div className="flex min-w-0 flex-1 items-center gap-2 sm:w-fit sm:flex-initial">
<ModelSelector
providerId={parsedModel.providerId}
modelId={parsedModel.modelId}
onChange={handleModelChange}
/>
<ModelSelector providerId={parsedModel.providerId} modelId={parsedModel.modelId} onChange={handleModelChange} />
</div>
</div>
<div className="flex flex-col gap-2 py-1 sm:flex-row sm:items-center sm:gap-8">
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
<span className="typography-ui-label text-foreground">Default Thinking</span>
</div>
<div className="flex items-center gap-2 sm:w-fit">
<Select value={defaultVariant ?? DEFAULT_VARIANT_VALUE} onValueChange={handleVariantChange} disabled={!supportsVariants}>
<SelectTrigger className="w-fit min-w-[120px]">
<SelectValue placeholder="Thinking" />
</SelectTrigger>
<SelectContent>
<SelectItem value={DEFAULT_VARIANT_VALUE}>Default</SelectItem>
{availableVariants.map((variant) => (
<SelectItem key={variant} value={variant}>
{variant}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2 py-1 sm:flex-row sm:items-center sm:gap-8">
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
<span className="typography-ui-label text-foreground">Default Thinking</span>
</div>
<div className="flex items-center gap-2 sm:w-fit">
<Select value={defaultVariant ?? DEFAULT_VARIANT_VALUE} onValueChange={handleVariantChange} disabled={!supportsVariants}>
<SelectTrigger className="w-fit min-w-[120px]">
<SelectValue placeholder="Thinking" />
</SelectTrigger>
<SelectContent>
<SelectItem value={DEFAULT_VARIANT_VALUE}>Default</SelectItem>
{availableVariants.map((variant) => (
<SelectItem key={variant} value={variant}>
{variant}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex flex-col gap-2 py-1 sm:flex-row sm:items-center sm:gap-8">
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
<span className="typography-ui-label text-foreground">Default Agent</span>
</div>
<div className="flex min-w-0 flex-1 items-center gap-2 sm:w-fit sm:flex-initial">
<AgentSelector
agentName={defaultAgent || ''}
onChange={handleAgentChange}
/>
<AgentSelector agentName={defaultAgent || ''} onChange={handleAgentChange} />
</div>
</div>
<div className="flex flex-col gap-2 py-1 sm:flex-row sm:items-center sm:gap-8">
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
<div className="flex items-center gap-2">
<span className="typography-ui-label text-foreground">Zen Model</span>
<span className="typography-ui-label text-foreground">Utility Model</span>
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
The free model used for lightweight internal tasks like commit message generation, PR descriptions, notification summarization, and TTS text summarization.
The model used for lightweight background tasks like commit messages, PR descriptions, and summarization.
</TooltipContent>
</Tooltip>
</div>
</div>
<div className="flex items-center gap-2 sm:w-fit">
{zenModelsLoading ? (
<span className="typography-meta text-muted-foreground">Loading models...</span>
) : zenModels.length > 0 ? (
<Select value={selectedZenModel} onValueChange={handleZenModelChange}>
<SelectTrigger className="w-fit min-w-[120px]">
<SelectValue placeholder="Select model" />
</SelectTrigger>
<SelectContent>
{zenModels.map((model) => (
<SelectItem key={model.id} value={model.id}>
{model.id}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<span className="typography-meta text-muted-foreground">No free models available</span>
)}
<div className="flex min-w-0 flex-1 items-center gap-2 sm:w-fit sm:flex-initial">
<ModelSelector
providerId={utilityDisplayModel.providerId}
modelId={utilityDisplayModel.modelId}
onChange={handleUtilityModelChange}
/>
</div>
</div>
@@ -414,11 +429,7 @@ export const DefaultsSettings: React.FC = () => {
}
}}
>
<Checkbox
checked={showDeletionDialog}
onChange={setShowDeletionDialog}
ariaLabel="Show deletion dialog"
/>
<Checkbox checked={showDeletionDialog} onChange={setShowDeletionDialog} ariaLabel="Show deletion dialog" />
<span className="typography-ui-label text-foreground">Show Deletion Dialog</span>
</div>
@@ -462,9 +473,7 @@ export const DefaultsSettings: React.FC = () => {
</div>
</div>
)}
</section>
</div>
);
};