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 { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { getModifierLabel, cn } from '@/lib/utils'; import { getModifierLabel, cn } from '@/lib/utils';
interface ZenModel { const UTILITY_PROVIDER_ID = 'zen';
id: string; const UTILITY_PREFERRED_MODEL_ID = 'big-pickle';
owned_by?: string;
}
const getDisplayModel = ( const getDisplayModel = (
storedModel: string | undefined storedModel: string | undefined
@@ -26,9 +24,46 @@ const getDisplayModel = (
return { providerId: parts[0], modelId: parts[1] }; return { providerId: parts[0], modelId: parts[1] };
} }
} }
// Return empty values when no model is explicitly set return { providerId: '', modelId: '' };
// This allows showing "Not selected" instead of a fallback };
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: '' }; return { providerId: '', modelId: '' };
}; };
@@ -44,6 +79,10 @@ export const DefaultsSettings: React.FC = () => {
const setSettingsAutoCreateWorktree = useConfigStore((state) => state.setSettingsAutoCreateWorktree); const setSettingsAutoCreateWorktree = useConfigStore((state) => state.setSettingsAutoCreateWorktree);
const settingsZenModel = useConfigStore((state) => state.settingsZenModel); const settingsZenModel = useConfigStore((state) => state.settingsZenModel);
const setSettingsZenModel = useConfigStore((state) => state.setSettingsZenModel); 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 showDeletionDialog = useUIStore((state) => state.showDeletionDialog);
const setShowDeletionDialog = useUIStore((state) => state.setShowDeletionDialog); const setShowDeletionDialog = useUIStore((state) => state.setShowDeletionDialog);
const providers = useConfigStore((state) => state.providers); const providers = useConfigStore((state) => state.providers);
@@ -52,54 +91,25 @@ export const DefaultsSettings: React.FC = () => {
const [defaultVariant, setDefaultVariant] = React.useState<string | undefined>(); const [defaultVariant, setDefaultVariant] = React.useState<string | undefined>();
const [defaultAgent, setDefaultAgent] = React.useState<string | undefined>(); const [defaultAgent, setDefaultAgent] = React.useState<string | undefined>();
const [isLoading, setIsLoading] = React.useState(true); 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(), []); 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(() => { React.useEffect(() => {
const loadSettings = async () => { const loadSettings = async () => {
try { 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) { if (!data) {
const runtimeSettings = getRegisteredRuntimeAPIs()?.settings; const runtimeSettings = getRegisteredRuntimeAPIs()?.settings;
if (runtimeSettings) { if (runtimeSettings) {
@@ -109,9 +119,23 @@ export const DefaultsSettings: React.FC = () => {
if (settings) { if (settings) {
data = { data = {
defaultModel: typeof settings.defaultModel === 'string' ? settings.defaultModel : undefined, 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, 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 { } catch {
@@ -120,7 +144,6 @@ export const DefaultsSettings: React.FC = () => {
} }
} }
// 2. Fetch API (Web/server)
if (!data) { if (!data) {
const response = await fetch('/api/config/settings', { const response = await fetch('/api/config/settings', {
method: 'GET', method: 'GET',
@@ -131,25 +154,39 @@ export const DefaultsSettings: React.FC = () => {
} }
} }
if (data) { if (data) {
const model = typeof data.defaultModel === 'string' && data.defaultModel.trim().length > 0 ? data.defaultModel.trim() : undefined; const model =
const variant = typeof data.defaultVariant === 'string' && data.defaultVariant.trim().length > 0 ? data.defaultVariant.trim() : undefined; typeof data.defaultModel === 'string' && data.defaultModel.trim().length > 0
const agent = typeof data.defaultAgent === 'string' && data.defaultAgent.trim().length > 0 ? data.defaultAgent.trim() : undefined; ? data.defaultModel.trim()
const zen = typeof data.zenModel === 'string' && data.zenModel.trim().length > 0 ? data.zenModel.trim() : undefined; : 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) { if (model !== undefined) setDefaultModel(model);
setDefaultModel(model); if (variant !== undefined) setDefaultVariant(variant);
} if (agent !== undefined) setDefaultAgent(agent);
if (variant !== undefined) { if (zen !== undefined) setSettingsZenModel(zen);
setDefaultVariant(variant); if (gitProviderId !== undefined) setSettingsGitProviderId(gitProviderId);
} if (gitModelId !== undefined) setSettingsGitModelId(gitModelId);
if (agent !== undefined) { }
setDefaultAgent(agent);
}
if (zen !== undefined) {
setSettingsZenModel(zen);
}
}
} catch (error) { } catch (error) {
console.warn('Failed to load defaults settings:', error); console.warn('Failed to load defaults settings:', error);
} finally { } finally {
@@ -157,88 +194,78 @@ export const DefaultsSettings: React.FC = () => {
} }
}; };
loadSettings(); 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) => { if (providerId && modelId) {
const newValue = providerId && modelId ? `${providerId}/${modelId}` : undefined; const provider = providers.find((p) => p.id === providerId);
setDefaultModel(newValue); if (provider) {
setProvider(providerId);
// Reset variant when model changes (model-specific) setModel(modelId);
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);
} }
}
try { try {
await updateDesktopSettings({ await updateDesktopSettings({ defaultModel: newValue ?? '', defaultVariant: '' });
defaultModel: newValue ?? '', const response = await fetch('/api/config/settings', {
defaultVariant: '', method: 'PUT',
}); headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ defaultModel: newValue }),
{ });
const response = await fetch('/api/config/settings', { if (!response.ok) {
method: 'PUT', console.warn('Failed to save default model to server:', response.status, response.statusText);
headers: { 'Content-Type': 'application/json' }, }
body: JSON.stringify({ defaultModel: newValue }), } catch (error) {
}); console.warn('Failed to save default model:', error);
if (!response.ok) { }
console.warn('Failed to save default model to server:', response.status, response.statusText); },
} [providers, setCurrentVariant, setModel, setProvider, setSettingsDefaultModel, setSettingsDefaultVariant]
} );
} catch (error) {
console.warn('Failed to save default model:', error);
}
}, [providers, setCurrentVariant, setProvider, setModel, setSettingsDefaultModel, setSettingsDefaultVariant]);
const DEFAULT_VARIANT_VALUE = '__default__'; const DEFAULT_VARIANT_VALUE = '__default__';
const handleVariantChange = React.useCallback(async (variant: string) => { const handleVariantChange = React.useCallback(
const newValue = variant === DEFAULT_VARIANT_VALUE ? undefined : (variant || undefined); async (variant: string) => {
setDefaultVariant(newValue); const newValue = variant === DEFAULT_VARIANT_VALUE ? undefined : variant || undefined;
setSettingsDefaultVariant(newValue); setDefaultVariant(newValue);
setCurrentVariant(newValue); setSettingsDefaultVariant(newValue);
setCurrentVariant(newValue);
try { try {
await updateDesktopSettings({ await updateDesktopSettings({ defaultVariant: newValue ?? '' });
defaultVariant: newValue ?? '', } catch (error) {
}); console.warn('Failed to save default variant:', error);
} catch (error) { }
console.warn('Failed to save default variant:', error); },
} [setCurrentVariant, setSettingsDefaultVariant]
}, [setCurrentVariant, setSettingsDefaultVariant]); );
const handleAgentChange = React.useCallback(async (agentName: string) => { const handleAgentChange = React.useCallback(
const newValue = agentName || undefined; async (agentName: string) => {
setDefaultAgent(newValue); const newValue = agentName || undefined;
setDefaultAgent(newValue);
setSettingsDefaultAgent(newValue);
// Update config store settings default if (agentName) {
setSettingsDefaultAgent(newValue); setAgent(agentName);
}
// Update current agent (setAgent will respect settingsDefaultModel) try {
if (agentName) { await updateDesktopSettings({ defaultAgent: newValue ?? '' });
setAgent(agentName); } catch (error) {
} console.warn('Failed to save default agent:', error);
}
try { },
await updateDesktopSettings({ [setAgent, setSettingsDefaultAgent]
defaultAgent: newValue ?? '', );
});
} catch (error) {
console.warn('Failed to save default agent:', error);
}
}, [setAgent, setSettingsDefaultAgent]);
const availableVariants = React.useMemo(() => { const availableVariants = React.useMemo(() => {
if (!parsedModel.providerId || !parsedModel.modelId) return []; if (!parsedModel.providerId || !parsedModel.modelId) return [];
@@ -247,9 +274,7 @@ export const DefaultsSettings: React.FC = () => {
| { variants?: Record<string, unknown> } | { variants?: Record<string, unknown> }
| undefined; | undefined;
const variants = model?.variants; const variants = model?.variants;
if (!variants) { if (!variants) return [];
return [];
}
return Object.keys(variants); return Object.keys(variants);
}, [parsedModel.modelId, parsedModel.providerId, providers]); }, [parsedModel.modelId, parsedModel.providerId, providers]);
@@ -266,27 +291,37 @@ export const DefaultsSettings: React.FC = () => {
} }
}, [defaultVariant, setCurrentVariant, setSettingsDefaultVariant, supportsVariants]); }, [defaultVariant, setCurrentVariant, setSettingsDefaultVariant, supportsVariants]);
const handleAutoWorktreeChange = React.useCallback(async (enabled: boolean) => { const handleAutoWorktreeChange = React.useCallback(
setSettingsAutoCreateWorktree(enabled); async (enabled: boolean) => {
try { setSettingsAutoCreateWorktree(enabled);
await updateDesktopSettings({ try {
autoCreateWorktree: enabled, await updateDesktopSettings({ autoCreateWorktree: enabled });
}); } catch (error) {
} catch (error) { console.warn('Failed to save auto create worktree setting:', error);
console.warn('Failed to save auto create worktree setting:', error); }
} },
}, [setSettingsAutoCreateWorktree]); [setSettingsAutoCreateWorktree]
);
const handleZenModelChange = React.useCallback(async (modelId: string) => { const handleUtilityModelChange = React.useCallback(
setSettingsZenModel(modelId); async (providerId: string, modelId: string) => {
try { setSettingsGitProviderId(providerId);
await updateDesktopSettings({ setSettingsGitModelId(modelId);
zenModel: modelId, if (providerId === UTILITY_PROVIDER_ID) {
}); setSettingsZenModel(modelId);
} catch (error) { }
console.warn('Failed to save zen model setting:', error); try {
} await updateDesktopSettings({
}, [setSettingsZenModel]); 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) { if (isLoading) {
return null; return null;
@@ -319,85 +354,65 @@ export const DefaultsSettings: React.FC = () => {
)} )}
</div> </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"> <div className="flex min-w-0 flex-col sm:w-56 shrink-0">
<span className="typography-ui-label text-foreground">Default Model</span> <span className="typography-ui-label text-foreground">Default Model</span>
</div> </div>
<div className="flex min-w-0 flex-1 items-center gap-2 sm:w-fit sm:flex-initial"> <div className="flex min-w-0 flex-1 items-center gap-2 sm:w-fit sm:flex-initial">
<ModelSelector <ModelSelector providerId={parsedModel.providerId} modelId={parsedModel.modelId} onChange={handleModelChange} />
providerId={parsedModel.providerId}
modelId={parsedModel.modelId}
onChange={handleModelChange}
/>
</div> </div>
</div> </div>
<div className="flex flex-col gap-2 py-1 sm:flex-row sm:items-center sm:gap-8"> <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 min-w-0 flex-col sm:w-56 shrink-0">
<span className="typography-ui-label text-foreground">Default Thinking</span> <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>
<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 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 min-w-0 flex-col sm:w-56 shrink-0">
<span className="typography-ui-label text-foreground">Default Agent</span> <span className="typography-ui-label text-foreground">Default Agent</span>
</div> </div>
<div className="flex min-w-0 flex-1 items-center gap-2 sm:w-fit sm:flex-initial"> <div className="flex min-w-0 flex-1 items-center gap-2 sm:w-fit sm:flex-initial">
<AgentSelector <AgentSelector agentName={defaultAgent || ''} onChange={handleAgentChange} />
agentName={defaultAgent || ''}
onChange={handleAgentChange}
/>
</div> </div>
</div> </div>
<div className="flex flex-col gap-2 py-1 sm:flex-row sm:items-center sm:gap-8"> <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 min-w-0 flex-col sm:w-56 shrink-0">
<div className="flex items-center gap-2"> <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}> <Tooltip delayDuration={1000}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" /> <RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger> </TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs"> <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> </TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
</div> </div>
<div className="flex items-center gap-2 sm:w-fit"> <div className="flex min-w-0 flex-1 items-center gap-2 sm:w-fit sm:flex-initial">
{zenModelsLoading ? ( <ModelSelector
<span className="typography-meta text-muted-foreground">Loading models...</span> providerId={utilityDisplayModel.providerId}
) : zenModels.length > 0 ? ( modelId={utilityDisplayModel.modelId}
<Select value={selectedZenModel} onValueChange={handleZenModelChange}> onChange={handleUtilityModelChange}
<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> </div>
</div> </div>
@@ -414,11 +429,7 @@ export const DefaultsSettings: React.FC = () => {
} }
}} }}
> >
<Checkbox <Checkbox checked={showDeletionDialog} onChange={setShowDeletionDialog} ariaLabel="Show deletion dialog" />
checked={showDeletionDialog}
onChange={setShowDeletionDialog}
ariaLabel="Show deletion dialog"
/>
<span className="typography-ui-label text-foreground">Show Deletion Dialog</span> <span className="typography-ui-label text-foreground">Show Deletion Dialog</span>
</div> </div>
@@ -462,9 +473,7 @@ export const DefaultsSettings: React.FC = () => {
</div> </div>
</div> </div>
)} )}
</section> </section>
</div> </div>
); );
}; };
+13 -2
View File
@@ -818,11 +818,22 @@ export const GitView: React.FC<GitViewProps> = ({ mode = 'full' }) => {
setIsGeneratingMessage(true); setIsGeneratingMessage(true);
try { try {
const zenModel = useConfigStore.getState().settingsZenModel; const { getResolvedGitGenerationModel, settingsZenModel } = useConfigStore.getState();
const resolvedModel = getResolvedGitGenerationModel();
const options: { zenModel?: string; providerId?: string; modelId?: string } = {};
if (resolvedModel) {
options.providerId = resolvedModel.providerId;
options.modelId = resolvedModel.modelId;
if (resolvedModel.providerId === 'zen') {
options.zenModel = resolvedModel.modelId;
}
} else if (settingsZenModel) {
options.zenModel = settingsZenModel;
}
const { message } = await git.generateCommitMessage( const { message } = await git.generateCommitMessage(
currentDirectory, currentDirectory,
Array.from(selectedPaths), Array.from(selectedPaths),
zenModel ? { zenModel } : undefined Object.keys(options).length > 0 ? options : undefined
); );
const subject = message.subject?.trim() ?? ''; const subject = message.subject?.trim() ?? '';
const highlights = Array.isArray(message.highlights) ? message.highlights : []; const highlights = Array.isArray(message.highlights) ? message.highlights : [];
@@ -1128,13 +1128,25 @@ export const PullRequestSection: React.FC<{
if (!directory) return; if (!directory) return;
setIsGenerating(true); setIsGenerating(true);
try { try {
const zenModel = useConfigStore.getState().settingsZenModel; const { getResolvedGitGenerationModel, settingsZenModel } = useConfigStore.getState();
const generated = await generatePullRequestDescription(directory, { const resolvedModel = getResolvedGitGenerationModel();
const payload: { base: string; head: string; context?: string; zenModel?: string; providerId?: string; modelId?: string } = {
base: targetBaseBranch, base: targetBaseBranch,
head: branch, head: branch,
context: additionalContext, };
...(zenModel ? { zenModel } : {}), if (additionalContext) {
}); payload.context = additionalContext;
}
if (resolvedModel) {
payload.providerId = resolvedModel.providerId;
payload.modelId = resolvedModel.modelId;
if (resolvedModel.providerId === 'zen') {
payload.zenModel = resolvedModel.modelId;
}
} else if (settingsZenModel) {
payload.zenModel = settingsZenModel;
}
const generated = await generatePullRequestDescription(directory, payload);
if (generated.title?.trim()) { if (generated.title?.trim()) {
setTitle(generated.title.trim()); setTitle(generated.title.trim());
+4 -2
View File
@@ -390,10 +390,10 @@ export interface GitAPI {
getGitBranches(directory: string): Promise<GitBranch>; getGitBranches(directory: string): Promise<GitBranch>;
deleteGitBranch(directory: string, payload: GitDeleteBranchPayload): Promise<{ success: boolean }>; deleteGitBranch(directory: string, payload: GitDeleteBranchPayload): Promise<{ success: boolean }>;
deleteRemoteBranch(directory: string, payload: GitDeleteRemoteBranchPayload): Promise<{ success: boolean }>; deleteRemoteBranch(directory: string, payload: GitDeleteRemoteBranchPayload): Promise<{ success: boolean }>;
generateCommitMessage(directory: string, files: string[], options?: { zenModel?: string }): Promise<{ message: GeneratedCommitMessage }>; generateCommitMessage(directory: string, files: string[], options?: { zenModel?: string; providerId?: string; modelId?: string }): Promise<{ message: GeneratedCommitMessage }>;
generatePullRequestDescription( generatePullRequestDescription(
directory: string, directory: string,
payload: { base: string; head: string; context?: string; zenModel?: string } payload: { base: string; head: string; context?: string; zenModel?: string; providerId?: string; modelId?: string }
): Promise<GeneratedPullRequestDescription>; ): Promise<GeneratedPullRequestDescription>;
listGitWorktrees(directory: string): Promise<GitWorktreeInfo[]>; listGitWorktrees(directory: string): Promise<GitWorktreeInfo[]>;
validateGitWorktree?(directory: string, payload: CreateGitWorktreePayload): Promise<GitWorktreeValidationResult>; validateGitWorktree?(directory: string, payload: CreateGitWorktreePayload): Promise<GitWorktreeValidationResult>;
@@ -529,6 +529,8 @@ export interface SettingsPayload {
directoryShowHidden?: boolean; directoryShowHidden?: boolean;
filesViewShowGitignored?: boolean; filesViewShowGitignored?: boolean;
openInAppId?: string; openInAppId?: string;
gitProviderId?: string;
gitModelId?: string;
[key: string]: unknown; [key: string]: unknown;
} }
+2
View File
@@ -93,6 +93,8 @@ export type DesktopSettings = {
queueModeEnabled?: boolean; queueModeEnabled?: boolean;
gitmojiEnabled?: boolean; gitmojiEnabled?: boolean;
zenModel?: string; zenModel?: string;
gitProviderId?: string;
gitModelId?: string;
toolCallExpansion?: 'collapsed' | 'activity' | 'detailed'; toolCallExpansion?: 'collapsed' | 'activity' | 'detailed';
fontSize?: number; fontSize?: number;
terminalFontSize?: number; terminalFontSize?: number;
+3 -3
View File
@@ -105,16 +105,16 @@ export async function deleteRemoteBranch(directory: string, payload: import('./a
export async function generateCommitMessage( export async function generateCommitMessage(
directory: string, directory: string,
files: string[], files: string[],
options?: { zenModel?: string } options?: { zenModel?: string; providerId?: string; modelId?: string }
): Promise<{ message: import('./api/types').GeneratedCommitMessage }> { ): Promise<{ message: import('./api/types').GeneratedCommitMessage }> {
const runtime = getRuntimeGit(); const runtime = getRuntimeGit();
if (runtime) return runtime.generateCommitMessage(directory, files); if (runtime) return runtime.generateCommitMessage(directory, files, options);
return gitHttp.generateCommitMessage(directory, files, options); return gitHttp.generateCommitMessage(directory, files, options);
} }
export async function generatePullRequestDescription( export async function generatePullRequestDescription(
directory: string, directory: string,
payload: { base: string; head: string; context?: string; zenModel?: string } payload: { base: string; head: string; context?: string; zenModel?: string; providerId?: string; modelId?: string }
): Promise<import('./api/types').GeneratedPullRequestDescription> { ): Promise<import('./api/types').GeneratedPullRequestDescription> {
const runtime = getRuntimeGit(); const runtime = getRuntimeGit();
if (runtime?.generatePullRequestDescription) { if (runtime?.generatePullRequestDescription) {
+16 -4
View File
@@ -210,7 +210,7 @@ export async function deleteRemoteBranch(directory: string, payload: GitDeleteRe
export async function generateCommitMessage( export async function generateCommitMessage(
directory: string, directory: string,
files: string[], files: string[],
options?: { zenModel?: string } options?: { zenModel?: string; providerId?: string; modelId?: string }
): Promise<{ message: GeneratedCommitMessage }> { ): Promise<{ message: GeneratedCommitMessage }> {
if (!Array.isArray(files) || files.length === 0) { if (!Array.isArray(files) || files.length === 0) {
throw new Error('No files provided to generate commit message'); throw new Error('No files provided to generate commit message');
@@ -220,6 +220,12 @@ export async function generateCommitMessage(
if (options?.zenModel) { if (options?.zenModel) {
body.zenModel = options.zenModel; body.zenModel = options.zenModel;
} }
if (options?.providerId) {
body.providerId = options.providerId;
}
if (options?.modelId) {
body.modelId = options.modelId;
}
const response = await fetch(buildUrl(`${API_BASE}/commit-message`, directory), { const response = await fetch(buildUrl(`${API_BASE}/commit-message`, directory), {
method: 'POST', method: 'POST',
@@ -259,20 +265,26 @@ export async function generateCommitMessage(
export async function generatePullRequestDescription( export async function generatePullRequestDescription(
directory: string, directory: string,
payload: { base: string; head: string; context?: string; zenModel?: string } payload: { base: string; head: string; context?: string; zenModel?: string; providerId?: string; modelId?: string }
): Promise<{ title: string; body: string }> { ): Promise<{ title: string; body: string }> {
const { base, head, context, zenModel } = payload; const { base, head, context, zenModel, providerId, modelId } = payload;
if (!base || !head) { if (!base || !head) {
throw new Error('base and head are required'); throw new Error('base and head are required');
} }
const requestBody: { base: string; head: string; context?: string; zenModel?: string } = { base, head }; const requestBody: { base: string; head: string; context?: string; zenModel?: string; providerId?: string; modelId?: string } = { base, head };
if (context?.trim()) { if (context?.trim()) {
requestBody.context = context.trim(); requestBody.context = context.trim();
} }
if (zenModel) { if (zenModel) {
requestBody.zenModel = zenModel; requestBody.zenModel = zenModel;
} }
if (providerId) {
requestBody.providerId = providerId;
}
if (modelId) {
requestBody.modelId = modelId;
}
const response = await fetch(buildUrl(`${API_BASE}/pr-description`, directory), { const response = await fetch(buildUrl(`${API_BASE}/pr-description`, directory), {
method: 'POST', method: 'POST',
+177 -1
View File
@@ -18,6 +18,8 @@ const MODELS_DEV_PROXY_URL = "/api/openchamber/models-metadata";
const FALLBACK_PROVIDER_ID = "opencode"; const FALLBACK_PROVIDER_ID = "opencode";
const FALLBACK_MODEL_ID = "big-pickle"; const FALLBACK_MODEL_ID = "big-pickle";
const GIT_UTILITY_PROVIDER_ID = "zen";
const GIT_UTILITY_PREFERRED_MODEL_ID = "big-pickle";
interface OpenChamberDefaults { interface OpenChamberDefaults {
defaultModel?: string; defaultModel?: string;
@@ -26,6 +28,8 @@ interface OpenChamberDefaults {
autoCreateWorktree?: boolean; autoCreateWorktree?: boolean;
gitmojiEnabled?: boolean; gitmojiEnabled?: boolean;
zenModel?: string; zenModel?: string;
gitProviderId?: string;
gitModelId?: string;
} }
const fetchOpenChamberDefaults = async (): Promise<OpenChamberDefaults> => { const fetchOpenChamberDefaults = async (): Promise<OpenChamberDefaults> => {
@@ -42,6 +46,8 @@ const fetchOpenChamberDefaults = async (): Promise<OpenChamberDefaults> => {
const defaultAgent = typeof data?.defaultAgent === 'string' ? data.defaultAgent.trim() : ''; const defaultAgent = typeof data?.defaultAgent === 'string' ? data.defaultAgent.trim() : '';
const gitmojiEnabled = typeof data?.gitmojiEnabled === 'boolean' ? data.gitmojiEnabled : undefined; const gitmojiEnabled = typeof data?.gitmojiEnabled === 'boolean' ? data.gitmojiEnabled : undefined;
const zenModel = typeof data?.zenModel === 'string' ? data.zenModel.trim() : ''; const zenModel = typeof data?.zenModel === 'string' ? data.zenModel.trim() : '';
const gitProviderId = typeof data?.gitProviderId === 'string' ? data.gitProviderId.trim() : '';
const gitModelId = typeof data?.gitModelId === 'string' ? data.gitModelId.trim() : '';
return { return {
defaultModel: defaultModel.length > 0 ? defaultModel : undefined, defaultModel: defaultModel.length > 0 ? defaultModel : undefined,
@@ -50,6 +56,8 @@ const fetchOpenChamberDefaults = async (): Promise<OpenChamberDefaults> => {
autoCreateWorktree: typeof data?.autoCreateWorktree === 'boolean' ? data.autoCreateWorktree : undefined, autoCreateWorktree: typeof data?.autoCreateWorktree === 'boolean' ? data.autoCreateWorktree : undefined,
gitmojiEnabled, gitmojiEnabled,
zenModel: zenModel.length > 0 ? zenModel : undefined, zenModel: zenModel.length > 0 ? zenModel : undefined,
gitProviderId: gitProviderId.length > 0 ? gitProviderId : undefined,
gitModelId: gitModelId.length > 0 ? gitModelId : undefined,
}; };
} }
} catch { } catch {
@@ -71,6 +79,8 @@ const fetchOpenChamberDefaults = async (): Promise<OpenChamberDefaults> => {
const defaultAgent = typeof data?.defaultAgent === 'string' ? data.defaultAgent.trim() : ''; const defaultAgent = typeof data?.defaultAgent === 'string' ? data.defaultAgent.trim() : '';
const gitmojiEnabled = typeof data?.gitmojiEnabled === 'boolean' ? data.gitmojiEnabled : undefined; const gitmojiEnabled = typeof data?.gitmojiEnabled === 'boolean' ? data.gitmojiEnabled : undefined;
const zenModel = typeof data?.zenModel === 'string' ? data.zenModel.trim() : ''; const zenModel = typeof data?.zenModel === 'string' ? data.zenModel.trim() : '';
const gitProviderId = typeof data?.gitProviderId === 'string' ? data.gitProviderId.trim() : '';
const gitModelId = typeof data?.gitModelId === 'string' ? data.gitModelId.trim() : '';
return { return {
defaultModel: defaultModel.length > 0 ? defaultModel : undefined, defaultModel: defaultModel.length > 0 ? defaultModel : undefined,
@@ -79,6 +89,8 @@ const fetchOpenChamberDefaults = async (): Promise<OpenChamberDefaults> => {
autoCreateWorktree: typeof data?.autoCreateWorktree === 'boolean' ? data.autoCreateWorktree : undefined, autoCreateWorktree: typeof data?.autoCreateWorktree === 'boolean' ? data.autoCreateWorktree : undefined,
gitmojiEnabled, gitmojiEnabled,
zenModel: zenModel.length > 0 ? zenModel : undefined, zenModel: zenModel.length > 0 ? zenModel : undefined,
gitProviderId: gitProviderId.length > 0 ? gitProviderId : undefined,
gitModelId: gitModelId.length > 0 ? gitModelId : undefined,
}; };
} catch { } catch {
return {}; return {};
@@ -103,6 +115,80 @@ const isPrimaryMode = (mode?: string) => mode === "primary" || mode === "all" ||
type ProviderModel = Provider["models"][string]; type ProviderModel = Provider["models"][string];
type ProviderWithModelList = Omit<Provider, "models"> & { models: ProviderModel[] }; type ProviderWithModelList = Omit<Provider, "models"> & { models: ProviderModel[] };
type GitModelSelection = { providerId: string; modelId: string };
const normalizeOptionalString = (value: unknown): string | undefined => {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
};
const hasProviderModel = (
providers: ProviderWithModelList[],
providerId: string,
modelId: string
): boolean => {
const provider = providers.find((item) => item.id === providerId);
if (!provider) {
return false;
}
return provider.models.some((model) => model.id === modelId);
};
const resolveGitGenerationModelSelection = ({
providers,
settingsGitProviderId,
settingsGitModelId,
settingsZenModel,
}: {
providers: ProviderWithModelList[];
settingsGitProviderId?: string;
settingsGitModelId?: string;
settingsZenModel?: string;
}): GitModelSelection | null => {
const gitProviderId = normalizeOptionalString(settingsGitProviderId);
const gitModelId = normalizeOptionalString(settingsGitModelId);
const zenModel = normalizeOptionalString(settingsZenModel);
if (!Array.isArray(providers) || providers.length === 0) {
if (zenModel) {
return { providerId: GIT_UTILITY_PROVIDER_ID, modelId: zenModel };
}
return null;
}
if (gitProviderId && gitModelId && hasProviderModel(providers, gitProviderId, gitModelId)) {
return { providerId: gitProviderId, modelId: gitModelId };
}
if (zenModel && hasProviderModel(providers, GIT_UTILITY_PROVIDER_ID, zenModel)) {
return { providerId: GIT_UTILITY_PROVIDER_ID, modelId: zenModel };
}
if (hasProviderModel(providers, GIT_UTILITY_PROVIDER_ID, GIT_UTILITY_PREFERRED_MODEL_ID)) {
return { providerId: GIT_UTILITY_PROVIDER_ID, modelId: GIT_UTILITY_PREFERRED_MODEL_ID };
}
const zenProvider = providers.find((provider) => provider.id === GIT_UTILITY_PROVIDER_ID);
if (zenProvider?.models.length) {
const randomIndex = Math.floor(Math.random() * zenProvider.models.length);
const randomModelId = normalizeOptionalString(zenProvider.models[randomIndex]?.id);
if (randomModelId) {
return { providerId: GIT_UTILITY_PROVIDER_ID, modelId: randomModelId };
}
}
const firstProvider = providers.find((provider) => provider.models.length > 0);
const firstModelId = normalizeOptionalString(firstProvider?.models[0]?.id);
if (firstProvider?.id && firstModelId) {
return { providerId: firstProvider.id, modelId: firstModelId };
}
return null;
};
interface ModelsDevModelEntry { interface ModelsDevModelEntry {
id?: string; id?: string;
name?: string; name?: string;
@@ -372,6 +458,8 @@ interface ConfigStore {
settingsAutoCreateWorktree: boolean; settingsAutoCreateWorktree: boolean;
settingsGitmojiEnabled: boolean; settingsGitmojiEnabled: boolean;
settingsZenModel: string | undefined; settingsZenModel: string | undefined;
settingsGitProviderId: string | undefined;
settingsGitModelId: string | undefined;
// Voice provider preference ('browser', 'openai', or 'say' for macOS) // Voice provider preference ('browser', 'openai', or 'say' for macOS)
voiceProvider: 'browser' | 'openai' | 'say'; voiceProvider: 'browser' | 'openai' | 'say';
setVoiceProvider: (provider: 'browser' | 'openai' | 'say') => void; setVoiceProvider: (provider: 'browser' | 'openai' | 'say') => void;
@@ -421,6 +509,9 @@ interface ConfigStore {
setSettingsAutoCreateWorktree: (enabled: boolean) => void; setSettingsAutoCreateWorktree: (enabled: boolean) => void;
setSettingsGitmojiEnabled: (enabled: boolean) => void; setSettingsGitmojiEnabled: (enabled: boolean) => void;
setSettingsZenModel: (model: string | undefined) => void; setSettingsZenModel: (model: string | undefined) => void;
setSettingsGitProviderId: (providerId: string | undefined) => void;
setSettingsGitModelId: (modelId: string | undefined) => void;
getResolvedGitGenerationModel: () => { providerId: string; modelId: string } | null;
saveAgentModelSelection: (agentName: string, providerId: string, modelId: string) => void; saveAgentModelSelection: (agentName: string, providerId: string, modelId: string) => void;
getAgentModelSelection: (agentName: string) => { providerId: string; modelId: string } | null; getAgentModelSelection: (agentName: string) => { providerId: string; modelId: string } | null;
checkConnection: () => Promise<boolean>; checkConnection: () => Promise<boolean>;
@@ -466,6 +557,8 @@ export const useConfigStore = create<ConfigStore>()(
settingsAutoCreateWorktree: false, settingsAutoCreateWorktree: false,
settingsGitmojiEnabled: false, settingsGitmojiEnabled: false,
settingsZenModel: undefined, settingsZenModel: undefined,
settingsGitProviderId: undefined,
settingsGitModelId: undefined,
// Voice provider preference - load from localStorage or default to 'browser' // Voice provider preference - load from localStorage or default to 'browser'
voiceProvider: (() => { voiceProvider: (() => {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
@@ -1007,6 +1100,36 @@ export const useConfigStore = create<ConfigStore>()(
? get().providers ? get().providers
: (get().directoryScoped[directoryKey]?.providers ?? []); : (get().directoryScoped[directoryKey]?.providers ?? []);
const existingGitProviderId = normalizeOptionalString(get().settingsGitProviderId);
const existingGitModelId = normalizeOptionalString(get().settingsGitModelId);
const existingZenModel = normalizeOptionalString(get().settingsZenModel);
const defaultGitProviderId = normalizeOptionalString(openChamberDefaults.gitProviderId);
const defaultGitModelId = normalizeOptionalString(openChamberDefaults.gitModelId);
const defaultZenModel = normalizeOptionalString(openChamberDefaults.zenModel);
const resolvedExistingGitSelection = resolveGitGenerationModelSelection({
providers,
settingsGitProviderId: existingGitProviderId,
settingsGitModelId: existingGitModelId,
settingsZenModel: existingZenModel,
});
const resolvedDefaultGitSelection = resolveGitGenerationModelSelection({
providers,
settingsGitProviderId: defaultGitProviderId,
settingsGitModelId: defaultGitModelId,
settingsZenModel: defaultZenModel,
});
const resolvedGitSelection = resolvedExistingGitSelection || resolvedDefaultGitSelection;
const resolvedGitProviderId = resolvedGitSelection?.providerId;
const resolvedGitModelId = resolvedGitSelection?.modelId;
const resolvedZenModel =
resolvedGitProviderId === GIT_UTILITY_PROVIDER_ID && resolvedGitModelId
? resolvedGitModelId
: (defaultZenModel || existingZenModel);
set((state) => { set((state) => {
const baseSnapshot: DirectoryScopedConfig = state.directoryScoped[directoryKey] ?? { const baseSnapshot: DirectoryScopedConfig = state.directoryScoped[directoryKey] ?? {
providers, providers,
@@ -1031,7 +1154,9 @@ export const useConfigStore = create<ConfigStore>()(
settingsDefaultAgent: openChamberDefaults.defaultAgent, settingsDefaultAgent: openChamberDefaults.defaultAgent,
settingsAutoCreateWorktree: openChamberDefaults.autoCreateWorktree ?? false, settingsAutoCreateWorktree: openChamberDefaults.autoCreateWorktree ?? false,
settingsGitmojiEnabled: openChamberDefaults.gitmojiEnabled ?? false, settingsGitmojiEnabled: openChamberDefaults.gitmojiEnabled ?? false,
settingsZenModel: openChamberDefaults.zenModel, settingsZenModel: resolvedZenModel,
settingsGitProviderId: resolvedGitProviderId,
settingsGitModelId: resolvedGitModelId,
directoryScoped: { directoryScoped: {
...state.directoryScoped, ...state.directoryScoped,
[directoryKey]: nextSnapshot, [directoryKey]: nextSnapshot,
@@ -1045,6 +1170,37 @@ export const useConfigStore = create<ConfigStore>()(
return nextState; return nextState;
}); });
const shouldPersistResolvedGitSelection =
!!resolvedGitProviderId &&
!!resolvedGitModelId &&
(
defaultGitProviderId !== resolvedGitProviderId ||
defaultGitModelId !== resolvedGitModelId ||
(
resolvedGitProviderId === GIT_UTILITY_PROVIDER_ID &&
resolvedZenModel !== defaultZenModel
)
);
if (shouldPersistResolvedGitSelection && resolvedGitProviderId && resolvedGitModelId) {
const gitSettingsUpdate: {
gitProviderId: string;
gitModelId: string;
zenModel?: string;
} = {
gitProviderId: resolvedGitProviderId,
gitModelId: resolvedGitModelId,
};
if (resolvedGitProviderId === GIT_UTILITY_PROVIDER_ID && resolvedZenModel) {
gitSettingsUpdate.zenModel = resolvedZenModel;
}
updateDesktopSettings(gitSettingsUpdate).catch(() => {
// Ignore errors - best effort cleanup
});
}
if (safeAgents.length === 0) { if (safeAgents.length === 0) {
set((state) => { set((state) => {
const baseSnapshot: DirectoryScopedConfig = state.directoryScoped[directoryKey] ?? { const baseSnapshot: DirectoryScopedConfig = state.directoryScoped[directoryKey] ?? {
@@ -1460,6 +1616,24 @@ export const useConfigStore = create<ConfigStore>()(
set({ settingsZenModel: model }); set({ settingsZenModel: model });
}, },
setSettingsGitProviderId: (providerId: string | undefined) => {
set({ settingsGitProviderId: providerId });
},
setSettingsGitModelId: (modelId: string | undefined) => {
set({ settingsGitModelId: modelId });
},
getResolvedGitGenerationModel: () => {
const state = get();
return resolveGitGenerationModelSelection({
providers: state.providers,
settingsGitProviderId: state.settingsGitProviderId,
settingsGitModelId: state.settingsGitModelId,
settingsZenModel: state.settingsZenModel,
});
},
setVoiceProvider: (provider: 'browser' | 'openai' | 'say') => { setVoiceProvider: (provider: 'browser' | 'openai' | 'say') => {
set({ voiceProvider: provider }); set({ voiceProvider: provider });
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
@@ -1670,6 +1844,8 @@ export const useConfigStore = create<ConfigStore>()(
settingsAutoCreateWorktree: state.settingsAutoCreateWorktree, settingsAutoCreateWorktree: state.settingsAutoCreateWorktree,
settingsGitmojiEnabled: state.settingsGitmojiEnabled, settingsGitmojiEnabled: state.settingsGitmojiEnabled,
settingsZenModel: state.settingsZenModel, settingsZenModel: state.settingsZenModel,
settingsGitProviderId: state.settingsGitProviderId,
settingsGitModelId: state.settingsGitModelId,
speechRate: state.speechRate, speechRate: state.speechRate,
speechPitch: state.speechPitch, speechPitch: state.speechPitch,
speechVolume: state.speechVolume, speechVolume: state.speechVolume,
+269 -38
View File
@@ -304,28 +304,253 @@ const normalizeMergeMethod = (value: string): 'merge' | 'squash' | 'rebase' => {
return 'merge'; return 'merge';
}; };
const extractZenOutputText = (value: unknown): string | null => { const BRIDGE_ZEN_DEFAULT_MODEL = 'gpt-5-nano';
if (!value || typeof value !== 'object') return null; const BRIDGE_GIT_GENERATION_TIMEOUT_MS = 2 * 60 * 1000;
const root = value as Record<string, unknown>; const BRIDGE_GIT_GENERATION_POLL_INTERVAL_MS = 500;
const output = root.output; let bridgeGitModelCatalogCache: Set<string> | null = null;
if (!Array.isArray(output)) return null; let bridgeGitModelCatalogCacheAt = 0;
const BRIDGE_GIT_MODEL_CATALOG_CACHE_TTL_MS = 30 * 1000;
const messageItem = output.find((item) => { const sleep = (ms: number) => new Promise<void>((resolve) => {
if (!item || typeof item !== 'object') return false; setTimeout(resolve, ms);
return (item as Record<string, unknown>).type === 'message'; });
}) as Record<string, unknown> | undefined;
if (!messageItem) return null;
const content = messageItem.content; const fetchBridgeGitModelCatalog = async (
if (!Array.isArray(content)) return null; apiUrl: string,
authHeaders?: Record<string, string>
): Promise<Set<string>> => {
const now = Date.now();
if (bridgeGitModelCatalogCache && now - bridgeGitModelCatalogCacheAt < BRIDGE_GIT_MODEL_CATALOG_CACHE_TTL_MS) {
return bridgeGitModelCatalogCache;
}
const textItem = content.find((item) => { const headers = authHeaders || {};
if (!item || typeof item !== 'object') return false; const modelsUrl = new URL(`${apiUrl.replace(/\/+$/, '')}/model`);
return (item as Record<string, unknown>).type === 'output_text'; const response = await fetch(modelsUrl.toString(), {
}) as Record<string, unknown> | undefined; method: 'GET',
headers: {
Accept: 'application/json',
...headers,
},
signal: AbortSignal.timeout(8_000),
});
const text = typeof textItem?.text === 'string' ? textItem.text.trim() : ''; if (!response.ok) {
return text || null; throw new Error('Failed to fetch model catalog');
}
const payload = await response.json().catch(() => null) as unknown;
const refs = new Set<string>();
if (Array.isArray(payload)) {
for (const item of payload) {
if (!item || typeof item !== 'object') {
continue;
}
const record = item as Record<string, unknown>;
const providerID = typeof record.providerID === 'string' ? record.providerID.trim() : '';
const modelID = typeof record.modelID === 'string' ? record.modelID.trim() : '';
if (providerID && modelID) {
refs.add(`${providerID}/${modelID}`);
}
}
}
bridgeGitModelCatalogCache = refs;
bridgeGitModelCatalogCacheAt = now;
return refs;
};
const resolveBridgeGitGenerationModel = async (
payloadModel: { providerId?: string; modelId?: string; zenModel?: string },
settings: Record<string, unknown>,
apiUrl: string,
authHeaders?: Record<string, string>
): Promise<{ providerID: string; modelID: string }> => {
let catalog: Set<string> | null = null;
try {
catalog = await fetchBridgeGitModelCatalog(apiUrl, authHeaders);
} catch {
catalog = null;
}
const hasModel = (providerID: string, modelID: string): boolean => {
if (!catalog) {
return false;
}
return catalog.has(`${providerID}/${modelID}`);
};
const requestProviderId = typeof payloadModel.providerId === 'string' ? payloadModel.providerId.trim() : '';
const requestModelId = typeof payloadModel.modelId === 'string' ? payloadModel.modelId.trim() : '';
if (requestProviderId && requestModelId && hasModel(requestProviderId, requestModelId)) {
return { providerID: requestProviderId, modelID: requestModelId };
}
const settingsProviderId = readStringField(settings, 'gitProviderId');
const settingsModelId = readStringField(settings, 'gitModelId');
if (settingsProviderId && settingsModelId && hasModel(settingsProviderId, settingsModelId)) {
return { providerID: settingsProviderId, modelID: settingsModelId };
}
const payloadZenModel = typeof payloadModel.zenModel === 'string' ? payloadModel.zenModel.trim() : '';
const settingsZenModel = readStringField(settings, 'zenModel');
return {
providerID: 'zen',
modelID: payloadZenModel || settingsZenModel || BRIDGE_ZEN_DEFAULT_MODEL,
};
};
const extractTextFromMessageParts = (parts: unknown): string => {
if (!Array.isArray(parts)) {
return '';
}
const textParts = parts
.filter((part) => {
if (!part || typeof part !== 'object') return false;
const record = part as Record<string, unknown>;
return record.type === 'text' && typeof record.text === 'string';
})
.map((part) => (part as Record<string, unknown>).text as string)
.map((text) => text.trim())
.filter((text) => text.length > 0);
return textParts.join('\n').trim();
};
const generateBridgeTextWithSessionFlow = async ({
apiUrl,
directory,
prompt,
providerID,
modelID,
authHeaders,
}: {
apiUrl: string;
directory: string;
prompt: string;
providerID: string;
modelID: string;
authHeaders?: Record<string, string>;
}): Promise<string> => {
const headers = authHeaders || {};
const apiBase = apiUrl.replace(/\/+$/, '');
const deadlineAt = Date.now() + BRIDGE_GIT_GENERATION_TIMEOUT_MS;
const remainingMs = () => Math.max(1_000, deadlineAt - Date.now());
let sessionId: string | null = null;
try {
const sessionUrl = new URL(`${apiBase}/session`);
if (directory) {
sessionUrl.searchParams.set('directory', directory);
}
const createResponse = await fetch(sessionUrl.toString(), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...headers,
},
body: JSON.stringify({ title: 'Git Generation' }),
signal: AbortSignal.timeout(remainingMs()),
});
if (!createResponse.ok) {
throw new Error('Failed to create OpenCode session');
}
const session = await createResponse.json().catch(() => null) as unknown;
const sessionObj = session && typeof session === 'object' ? session as Record<string, unknown> : null;
const createdSessionId = sessionObj && typeof sessionObj.id === 'string' ? sessionObj.id : '';
if (!createdSessionId) {
throw new Error('Invalid session response');
}
sessionId = createdSessionId;
const promptUrl = new URL(`${apiBase}/session/${encodeURIComponent(sessionId)}/prompt_async`);
if (directory) {
promptUrl.searchParams.set('directory', directory);
}
const promptResponse = await fetch(promptUrl.toString(), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...headers,
},
body: JSON.stringify({
model: {
providerID,
modelID,
},
parts: [{ type: 'text', text: prompt }],
}),
signal: AbortSignal.timeout(remainingMs()),
});
if (!promptResponse.ok) {
throw new Error('Failed to send prompt');
}
const messagesUrl = new URL(`${apiBase}/session/${encodeURIComponent(sessionId)}/message`);
if (directory) {
messagesUrl.searchParams.set('directory', directory);
}
messagesUrl.searchParams.set('limit', '10');
while (Date.now() < deadlineAt) {
await sleep(BRIDGE_GIT_GENERATION_POLL_INTERVAL_MS);
const messagesResponse = await fetch(messagesUrl.toString(), {
method: 'GET',
headers: {
Accept: 'application/json',
...headers,
},
signal: AbortSignal.timeout(remainingMs()),
});
if (!messagesResponse.ok) {
continue;
}
const messages = await messagesResponse.json().catch(() => null) as unknown;
if (!Array.isArray(messages)) {
continue;
}
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i] as Record<string, unknown> | null;
if (!message || typeof message !== 'object') {
continue;
}
const info = message.info as Record<string, unknown> | undefined;
if (info?.role !== 'assistant' || info?.finish !== 'stop') {
continue;
}
const text = extractTextFromMessageParts(message.parts);
if (text) {
return text;
}
}
}
throw new Error('Timeout waiting for generation to complete');
} finally {
if (sessionId) {
const deleteUrl = new URL(`${apiBase}/session/${encodeURIComponent(sessionId)}`);
try {
await fetch(deleteUrl.toString(), {
method: 'DELETE',
headers,
signal: AbortSignal.timeout(5_000),
});
} catch {
// ignore cleanup failures
}
}
}
}; };
const parseJsonObjectSafe = (value: string): Record<string, unknown> | null => { const parseJsonObjectSafe = (value: string): Record<string, unknown> | null => {
@@ -2920,10 +3145,14 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo
} }
case 'api:git/pr-description': { case 'api:git/pr-description': {
const { directory, base, head } = (payload || {}) as { const { directory, base, head, context, providerId, modelId, zenModel: payloadZenModel } = (payload || {}) as {
directory?: string; directory?: string;
base?: string; base?: string;
head?: string; head?: string;
context?: string;
providerId?: string;
modelId?: string;
zenModel?: string;
}; };
if (!directory) { if (!directory) {
return { id, type, success: false, error: 'Directory is required' }; return { id, type, success: false, error: 'Directory is required' };
@@ -2961,31 +3190,33 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo
return { id, type, success: false, error: 'No diffs available for selected files' }; return { id, type, success: false, error: 'No diffs available for selected files' };
} }
const prompt = `You are drafting a GitHub Pull Request title + description. Respond in JSON of the shape {"title": string, "body": string} (ONLY JSON in response, no markdown fences) with these rules:\n- title: concise, sentence case, <= 80 chars, no trailing punctuation, no commit-style prefixes (no "feat:", "fix:")\n- body: GitHub-flavored markdown with these sections in this order: Summary, Testing, Notes\n- Summary: 3-6 bullet points describing user-visible changes; avoid internal helper function names\n- Testing: bullet list ("- Not tested" allowed)\n- Notes: bullet list; include breaking/rollout notes only when relevant\n\nContext:\n- base branch: ${base}\n- head branch: ${head}\n\nDiff summary:\n${diffSummaries}`; const prompt = `You are drafting a GitHub Pull Request title + description. Respond in JSON of the shape {"title": string, "body": string} (ONLY JSON in response, no markdown fences) with these rules:\n- title: concise, sentence case, <= 80 chars, no trailing punctuation, no commit-style prefixes (no "feat:", "fix:")\n- body: GitHub-flavored markdown with these sections in this order: Summary, Testing, Notes\n- Summary: 3-6 bullet points describing user-visible changes; avoid internal helper function names\n- Testing: bullet list ("- Not tested" allowed)\n- Notes: bullet list; include breaking/rollout notes only when relevant\n\nContext:\n- base branch: ${base}\n- head branch: ${head}${context?.trim() ? `\n- Additional context: ${context.trim()}` : ''}\n\nDiff summary:\n${diffSummaries}`;
try { try {
const zenSettings = readSettings(ctx) as Record<string, unknown>; const apiUrl = ctx?.manager?.getApiUrl();
const zenModelRaw = typeof zenSettings?.zenModel === 'string' ? (zenSettings.zenModel as string).trim() : ''; if (!apiUrl) {
const zenModel = zenModelRaw.length > 0 ? zenModelRaw : 'gpt-5-nano'; return { id, type, success: false, error: 'OpenCode API unavailable' };
const response = await fetch('https://opencode.ai/zen/v1/responses', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: zenModel,
input: [{ role: 'user', content: prompt }],
max_output_tokens: 1200,
stream: false,
reasoning: { effort: 'low' },
}),
});
if (!response.ok) {
return { id, type, success: false, error: 'Failed to generate PR description' };
} }
const data = await response.json().catch(() => null) as unknown;
const raw = extractZenOutputText(data); const settings = readSettings(ctx) as Record<string, unknown>;
const { providerID, modelID } = await resolveBridgeGitGenerationModel(
{ providerId, modelId, zenModel: payloadZenModel },
settings,
apiUrl,
ctx?.manager?.getOpenCodeAuthHeaders()
);
const raw = await generateBridgeTextWithSessionFlow({
apiUrl,
directory,
prompt,
providerID,
modelID,
authHeaders: ctx?.manager?.getOpenCodeAuthHeaders(),
});
if (!raw) { if (!raw) {
return { id, type, success: false, error: 'No PR description returned by generator' }; return { id, type, success: false, error: 'No PR description returned by generator' };
} }
const cleaned = String(raw) const cleaned = String(raw)
.trim() .trim()
.replace(/^```json\s*/i, '') .replace(/^```json\s*/i, '')
+11 -2
View File
@@ -90,10 +90,15 @@ export const createVSCodeGitAPI = (): GitAPI => ({
}); });
}, },
generateCommitMessage: async (directory: string, files: string[]): Promise<{ message: GeneratedCommitMessage }> => { generateCommitMessage: async (
directory: string,
files: string[],
options?: { zenModel?: string; providerId?: string; modelId?: string }
): Promise<{ message: GeneratedCommitMessage }> => {
// This requires AI integration - stubbed for now // This requires AI integration - stubbed for now
void directory; // Unused for now void directory; // Unused for now
void files; // Unused for now void files; // Unused for now
void options; // Unused for now
return { return {
message: { message: {
subject: '', subject: '',
@@ -104,12 +109,16 @@ export const createVSCodeGitAPI = (): GitAPI => ({
generatePullRequestDescription: async ( generatePullRequestDescription: async (
directory: string, directory: string,
payload: { base: string; head: string } payload: { base: string; head: string; context?: string; zenModel?: string; providerId?: string; modelId?: string }
): Promise<GeneratedPullRequestDescription> => { ): Promise<GeneratedPullRequestDescription> => {
return sendBridgeMessage<GeneratedPullRequestDescription>('api:git/pr-description', { return sendBridgeMessage<GeneratedPullRequestDescription>('api:git/pr-description', {
directory, directory,
base: payload.base, base: payload.base,
head: payload.head, head: payload.head,
context: payload.context,
zenModel: payload.zenModel,
providerId: payload.providerId,
modelId: payload.modelId,
}); });
}, },
+237 -58
View File
@@ -604,6 +604,9 @@ let validatedZenFallback = null;
let cachedZenModels = null; let cachedZenModels = null;
let cachedZenModelsTimestamp = 0; let cachedZenModelsTimestamp = 0;
const ZEN_MODELS_CACHE_TTL = 5 * 60 * 1000; // 5 minutes const ZEN_MODELS_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
let cachedGitModelCatalog = null;
let cachedGitModelCatalogTimestamp = 0;
const GIT_MODEL_CATALOG_CACHE_TTL = 30 * 1000;
/** /**
* Fetch free models from the zen API with caching. Returns an array of * Fetch free models from the zen API with caching. Returns an array of
@@ -660,6 +663,220 @@ const resolveZenModel = async (override) => {
return validatedZenFallback || ZEN_DEFAULT_MODEL; return validatedZenFallback || ZEN_DEFAULT_MODEL;
}; };
const getGitModelCatalog = async () => {
const now = Date.now();
if (cachedGitModelCatalog && now - cachedGitModelCatalogTimestamp < GIT_MODEL_CATALOG_CACHE_TTL) {
return cachedGitModelCatalog;
}
const response = await fetch(buildOpenCodeUrl('/model', ''), {
method: 'GET',
headers: {
Accept: 'application/json',
...getOpenCodeAuthHeaders(),
},
signal: AbortSignal.timeout(8_000),
});
if (!response.ok) {
throw new Error(`Failed to fetch model catalog: ${response.status}`);
}
const payload = await response.json().catch(() => null);
const modelRefs = new Set();
if (Array.isArray(payload)) {
for (const item of payload) {
if (!item || typeof item !== 'object') {
continue;
}
const providerID = typeof item.providerID === 'string' ? item.providerID.trim() : '';
const modelID = typeof item.modelID === 'string' ? item.modelID.trim() : '';
if (providerID && modelID) {
modelRefs.add(`${providerID}/${modelID}`);
}
}
}
cachedGitModelCatalog = modelRefs;
cachedGitModelCatalogTimestamp = now;
return modelRefs;
};
/**
* Resolve git generation model based on priority:
* 1) request providerId+modelId
* 2) saved settings gitProviderId+gitModelId
* 3) legacy zenModel from request/settings as zen/<model>
* 4) Zen default (validatedZenFallback || ZEN_DEFAULT_MODEL)
*/
const resolveGitModel = async (requestParams) => {
const { providerId, modelId, zenModel } = requestParams || {};
const requestProviderId = typeof providerId === 'string' ? providerId.trim() : '';
const requestModelId = typeof modelId === 'string' ? modelId.trim() : '';
let modelCatalog = null;
try {
modelCatalog = await getGitModelCatalog();
} catch {
modelCatalog = null;
}
const hasModel = (providerID, modelID) => {
if (!modelCatalog) {
return false;
}
return modelCatalog.has(`${providerID}/${modelID}`);
};
if (requestProviderId && requestModelId && hasModel(requestProviderId, requestModelId)) {
return { providerID: requestProviderId, modelID: requestModelId };
}
try {
const settings = await readSettingsFromDisk();
const settingsProviderId = typeof settings?.gitProviderId === 'string' ? settings.gitProviderId.trim() : '';
const settingsModelId = typeof settings?.gitModelId === 'string' ? settings.gitModelId.trim() : '';
if (settingsProviderId && settingsModelId && hasModel(settingsProviderId, settingsModelId)) {
return { providerID: settingsProviderId, modelID: settingsModelId };
}
} catch {
// ignore
}
const fallbackZenModel = typeof zenModel === 'string' && zenModel.trim().length > 0
? zenModel.trim()
: (await resolveZenModel(zenModel));
return { providerID: 'zen', modelID: fallbackZenModel };
};
const GIT_GENERATION_TIMEOUT_MS = 2 * 60 * 1000;
const GIT_GENERATION_POLL_INTERVAL_MS = 500;
/**
* Generate text using OpenCode session flow:
* - Create short-lived session
* - POST prompt_async with model and text prompt
* - Poll session messages until final assistant response
* - Extract text from parts
* - Best-effort cleanup of temporary session
*/
const generateWithSessionFlow = async ({ prompt, providerID, modelID }) => {
const completionTimeout = createTimeoutSignal(GIT_GENERATION_TIMEOUT_MS);
let sessionId = null;
try {
const createUrl = buildOpenCodeUrl('/session', '');
const createResponse = await fetch(createUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
...getOpenCodeAuthHeaders(),
},
body: JSON.stringify({
title: 'Git Generation',
}),
signal: completionTimeout.signal,
});
if (!createResponse.ok) {
const errorBody = await createResponse.json().catch(() => ({}));
throw new Error(`Failed to create session: ${createResponse.status} ${JSON.stringify(errorBody)}`);
}
const sessionData = await createResponse.json();
sessionId = sessionData?.id;
if (!sessionId) {
throw new Error('Session created but no ID returned');
}
const promptUrl = buildOpenCodeUrl(`/session/${encodeURIComponent(sessionId)}/prompt_async`, '');
const promptResponse = await fetch(promptUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
...getOpenCodeAuthHeaders(),
},
body: JSON.stringify({
model: { providerID, modelID },
parts: [{ type: 'text', text: prompt }],
}),
signal: completionTimeout.signal,
});
if (!promptResponse.ok) {
const errorBody = await promptResponse.json().catch(() => ({}));
throw new Error(`Failed to send prompt: ${promptResponse.status} ${JSON.stringify(errorBody)}`);
}
const messagesUrl = buildOpenCodeUrl(`/session/${encodeURIComponent(sessionId)}/message`, '');
let lastAssistantText = '';
let pollingAttempts = 0;
const maxPollingAttempts = Math.ceil(GIT_GENERATION_TIMEOUT_MS / GIT_GENERATION_POLL_INTERVAL_MS);
while (pollingAttempts < maxPollingAttempts) {
pollingAttempts++;
await new Promise((resolve) => setTimeout(resolve, GIT_GENERATION_POLL_INTERVAL_MS));
const messagesResponse = await fetch(`${messagesUrl}?limit=10`, {
method: 'GET',
headers: {
Accept: 'application/json',
...getOpenCodeAuthHeaders(),
},
signal: completionTimeout.signal,
});
if (!messagesResponse.ok) {
console.warn(`Session messages poll failed: ${messagesResponse.status}`);
continue;
}
const messages = await messagesResponse.json().catch(() => null);
if (!Array.isArray(messages)) {
continue;
}
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
if (msg?.info?.role === 'assistant' && msg?.info?.finish === 'stop') {
if (Array.isArray(msg.parts)) {
const textParts = msg.parts
.filter((p) => p?.type === 'text' && typeof p?.text === 'string')
.map((p) => p.text)
.filter(Boolean);
if (textParts.length > 0) {
return textParts.join('\n').trim();
}
}
}
}
}
throw new Error('Timeout waiting for generation to complete');
} finally {
completionTimeout.cleanup();
if (sessionId) {
try {
const deleteUrl = buildOpenCodeUrl(`/session/${encodeURIComponent(sessionId)}`, '');
await fetch(deleteUrl, {
method: 'DELETE',
headers: getOpenCodeAuthHeaders(),
signal: AbortSignal.timeout(5000),
}).catch((err) => {
console.warn('Failed to cleanup temporary session:', err?.message || err);
});
} catch (err) {
console.warn('Failed to cleanup temporary session:', err?.message || err);
}
}
}
};
const summarizeText = async (text, targetLength, zenModel) => { const summarizeText = async (text, targetLength, zenModel) => {
if (!text || typeof text !== 'string' || text.trim().length === 0) return text; if (!text || typeof text !== 'string' || text.trim().length === 0) return text;
@@ -1534,6 +1751,14 @@ const sanitizeSettingsUpdate = (payload) => {
const trimmed = candidate.zenModel.trim(); const trimmed = candidate.zenModel.trim();
result.zenModel = trimmed.length > 0 ? trimmed : undefined; result.zenModel = trimmed.length > 0 ? trimmed : undefined;
} }
if (typeof candidate.gitProviderId === 'string') {
const trimmed = candidate.gitProviderId.trim();
result.gitProviderId = trimmed.length > 0 ? trimmed : undefined;
}
if (typeof candidate.gitModelId === 'string') {
const trimmed = candidate.gitModelId.trim();
result.gitModelId = trimmed.length > 0 ? trimmed : undefined;
}
if (typeof candidate.toolCallExpansion === 'string') { if (typeof candidate.toolCallExpansion === 'string') {
const mode = candidate.toolCallExpansion.trim(); const mode = candidate.toolCallExpansion.trim();
if (mode === 'collapsed' || mode === 'activity' || mode === 'detailed') { if (mode === 'collapsed' || mode === 'activity' || mode === 'detailed') {
@@ -9713,37 +9938,13 @@ highlights:
Diff summary (may be truncated): Diff summary (may be truncated):
${diffSummaries}`; ${diffSummaries}`;
const model = await resolveZenModel(typeof req.body?.zenModel === 'string' ? req.body.zenModel : undefined); const { providerID, modelID } = await resolveGitModel({
providerId: req.body?.providerId,
modelId: req.body?.modelId,
zenModel: req.body?.zenModel,
});
const completionTimeout = createTimeoutSignal(LONG_REQUEST_TIMEOUT_MS); const raw = await generateWithSessionFlow({ prompt, providerID, modelID });
let response;
try {
response = await fetch('https://opencode.ai/zen/v1/responses', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model,
input: [{ role: 'user', content: prompt }],
max_output_tokens: 1000,
stream: false,
reasoning: {
effort: 'low'
}
}),
signal: completionTimeout.signal,
});
} finally {
completionTimeout.cleanup();
}
if (!response.ok) {
const errorBody = await response.json().catch(() => ({}));
console.error('Commit message generation failed:', errorBody);
return res.status(502).json({ error: 'Failed to generate commit message' });
}
const data = await response.json();
const raw = data?.output?.find((item) => item?.type === 'message')?.content?.find((item) => item?.type === 'output_text')?.text?.trim();
if (!raw) { if (!raw) {
return res.status(502).json({ error: 'No commit message returned by generator' }); return res.status(502).json({ error: 'No commit message returned by generator' });
@@ -9826,35 +10027,13 @@ Context:
prompt += `\n\nDiff summary:\n${diffSummaries}`; prompt += `\n\nDiff summary:\n${diffSummaries}`;
const model = await resolveZenModel(typeof req.body?.zenModel === 'string' ? req.body.zenModel : undefined); const { providerID, modelID } = await resolveGitModel({
providerId: req.body?.providerId,
modelId: req.body?.modelId,
zenModel: req.body?.zenModel,
});
const completionTimeout = createTimeoutSignal(LONG_REQUEST_TIMEOUT_MS); const raw = await generateWithSessionFlow({ prompt, providerID, modelID });
let response;
try {
response = await fetch('https://opencode.ai/zen/v1/responses', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model,
input: [{ role: 'user', content: prompt }],
max_output_tokens: 1200,
stream: false,
reasoning: { effort: 'low' },
}),
signal: completionTimeout.signal,
});
} finally {
completionTimeout.cleanup();
}
if (!response.ok) {
const errorBody = await response.json().catch(() => ({}));
console.error('PR description generation failed:', errorBody);
return res.status(502).json({ error: 'Failed to generate PR description' });
}
const data = await response.json();
const raw = data?.output?.find((item) => item?.type === 'message')?.content?.find((item) => item?.type === 'output_text')?.text?.trim();
if (!raw) { if (!raw) {
return res.status(502).json({ error: 'No PR description returned by generator' }); return res.status(502).json({ error: 'No PR description returned by generator' });
} }