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>
);
};
+13 -2
View File
@@ -818,11 +818,22 @@ export const GitView: React.FC<GitViewProps> = ({ mode = 'full' }) => {
setIsGeneratingMessage(true);
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(
currentDirectory,
Array.from(selectedPaths),
zenModel ? { zenModel } : undefined
Object.keys(options).length > 0 ? options : undefined
);
const subject = message.subject?.trim() ?? '';
const highlights = Array.isArray(message.highlights) ? message.highlights : [];
@@ -1128,13 +1128,25 @@ export const PullRequestSection: React.FC<{
if (!directory) return;
setIsGenerating(true);
try {
const zenModel = useConfigStore.getState().settingsZenModel;
const generated = await generatePullRequestDescription(directory, {
const { getResolvedGitGenerationModel, settingsZenModel } = useConfigStore.getState();
const resolvedModel = getResolvedGitGenerationModel();
const payload: { base: string; head: string; context?: string; zenModel?: string; providerId?: string; modelId?: string } = {
base: targetBaseBranch,
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()) {
setTitle(generated.title.trim());
+4 -2
View File
@@ -390,10 +390,10 @@ export interface GitAPI {
getGitBranches(directory: string): Promise<GitBranch>;
deleteGitBranch(directory: string, payload: GitDeleteBranchPayload): 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(
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>;
listGitWorktrees(directory: string): Promise<GitWorktreeInfo[]>;
validateGitWorktree?(directory: string, payload: CreateGitWorktreePayload): Promise<GitWorktreeValidationResult>;
@@ -529,6 +529,8 @@ export interface SettingsPayload {
directoryShowHidden?: boolean;
filesViewShowGitignored?: boolean;
openInAppId?: string;
gitProviderId?: string;
gitModelId?: string;
[key: string]: unknown;
}
+2
View File
@@ -93,6 +93,8 @@ export type DesktopSettings = {
queueModeEnabled?: boolean;
gitmojiEnabled?: boolean;
zenModel?: string;
gitProviderId?: string;
gitModelId?: string;
toolCallExpansion?: 'collapsed' | 'activity' | 'detailed';
fontSize?: number;
terminalFontSize?: number;
+3 -3
View File
@@ -105,16 +105,16 @@ export async function deleteRemoteBranch(directory: string, payload: import('./a
export async function generateCommitMessage(
directory: string,
files: string[],
options?: { zenModel?: string }
options?: { zenModel?: string; providerId?: string; modelId?: string }
): Promise<{ message: import('./api/types').GeneratedCommitMessage }> {
const runtime = getRuntimeGit();
if (runtime) return runtime.generateCommitMessage(directory, files);
if (runtime) return runtime.generateCommitMessage(directory, files, options);
return gitHttp.generateCommitMessage(directory, files, options);
}
export async function generatePullRequestDescription(
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> {
const runtime = getRuntimeGit();
if (runtime?.generatePullRequestDescription) {
+16 -4
View File
@@ -210,7 +210,7 @@ export async function deleteRemoteBranch(directory: string, payload: GitDeleteRe
export async function generateCommitMessage(
directory: string,
files: string[],
options?: { zenModel?: string }
options?: { zenModel?: string; providerId?: string; modelId?: string }
): Promise<{ message: GeneratedCommitMessage }> {
if (!Array.isArray(files) || files.length === 0) {
throw new Error('No files provided to generate commit message');
@@ -220,6 +220,12 @@ export async function generateCommitMessage(
if (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), {
method: 'POST',
@@ -259,20 +265,26 @@ export async function generateCommitMessage(
export async function generatePullRequestDescription(
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 }> {
const { base, head, context, zenModel } = payload;
const { base, head, context, zenModel, providerId, modelId } = payload;
if (!base || !head) {
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()) {
requestBody.context = context.trim();
}
if (zenModel) {
requestBody.zenModel = zenModel;
}
if (providerId) {
requestBody.providerId = providerId;
}
if (modelId) {
requestBody.modelId = modelId;
}
const response = await fetch(buildUrl(`${API_BASE}/pr-description`, directory), {
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_MODEL_ID = "big-pickle";
const GIT_UTILITY_PROVIDER_ID = "zen";
const GIT_UTILITY_PREFERRED_MODEL_ID = "big-pickle";
interface OpenChamberDefaults {
defaultModel?: string;
@@ -26,6 +28,8 @@ interface OpenChamberDefaults {
autoCreateWorktree?: boolean;
gitmojiEnabled?: boolean;
zenModel?: string;
gitProviderId?: string;
gitModelId?: string;
}
const fetchOpenChamberDefaults = async (): Promise<OpenChamberDefaults> => {
@@ -42,6 +46,8 @@ const fetchOpenChamberDefaults = async (): Promise<OpenChamberDefaults> => {
const defaultAgent = typeof data?.defaultAgent === 'string' ? data.defaultAgent.trim() : '';
const gitmojiEnabled = typeof data?.gitmojiEnabled === 'boolean' ? data.gitmojiEnabled : undefined;
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 {
defaultModel: defaultModel.length > 0 ? defaultModel : undefined,
@@ -50,6 +56,8 @@ const fetchOpenChamberDefaults = async (): Promise<OpenChamberDefaults> => {
autoCreateWorktree: typeof data?.autoCreateWorktree === 'boolean' ? data.autoCreateWorktree : undefined,
gitmojiEnabled,
zenModel: zenModel.length > 0 ? zenModel : undefined,
gitProviderId: gitProviderId.length > 0 ? gitProviderId : undefined,
gitModelId: gitModelId.length > 0 ? gitModelId : undefined,
};
}
} catch {
@@ -71,6 +79,8 @@ const fetchOpenChamberDefaults = async (): Promise<OpenChamberDefaults> => {
const defaultAgent = typeof data?.defaultAgent === 'string' ? data.defaultAgent.trim() : '';
const gitmojiEnabled = typeof data?.gitmojiEnabled === 'boolean' ? data.gitmojiEnabled : undefined;
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 {
defaultModel: defaultModel.length > 0 ? defaultModel : undefined,
@@ -79,6 +89,8 @@ const fetchOpenChamberDefaults = async (): Promise<OpenChamberDefaults> => {
autoCreateWorktree: typeof data?.autoCreateWorktree === 'boolean' ? data.autoCreateWorktree : undefined,
gitmojiEnabled,
zenModel: zenModel.length > 0 ? zenModel : undefined,
gitProviderId: gitProviderId.length > 0 ? gitProviderId : undefined,
gitModelId: gitModelId.length > 0 ? gitModelId : undefined,
};
} catch {
return {};
@@ -103,6 +115,80 @@ const isPrimaryMode = (mode?: string) => mode === "primary" || mode === "all" ||
type ProviderModel = Provider["models"][string];
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 {
id?: string;
name?: string;
@@ -372,6 +458,8 @@ interface ConfigStore {
settingsAutoCreateWorktree: boolean;
settingsGitmojiEnabled: boolean;
settingsZenModel: string | undefined;
settingsGitProviderId: string | undefined;
settingsGitModelId: string | undefined;
// Voice provider preference ('browser', 'openai', or 'say' for macOS)
voiceProvider: 'browser' | 'openai' | 'say';
setVoiceProvider: (provider: 'browser' | 'openai' | 'say') => void;
@@ -421,6 +509,9 @@ interface ConfigStore {
setSettingsAutoCreateWorktree: (enabled: boolean) => void;
setSettingsGitmojiEnabled: (enabled: boolean) => 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;
getAgentModelSelection: (agentName: string) => { providerId: string; modelId: string } | null;
checkConnection: () => Promise<boolean>;
@@ -466,6 +557,8 @@ export const useConfigStore = create<ConfigStore>()(
settingsAutoCreateWorktree: false,
settingsGitmojiEnabled: false,
settingsZenModel: undefined,
settingsGitProviderId: undefined,
settingsGitModelId: undefined,
// Voice provider preference - load from localStorage or default to 'browser'
voiceProvider: (() => {
if (typeof window !== 'undefined') {
@@ -1007,6 +1100,36 @@ export const useConfigStore = create<ConfigStore>()(
? get().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) => {
const baseSnapshot: DirectoryScopedConfig = state.directoryScoped[directoryKey] ?? {
providers,
@@ -1031,7 +1154,9 @@ export const useConfigStore = create<ConfigStore>()(
settingsDefaultAgent: openChamberDefaults.defaultAgent,
settingsAutoCreateWorktree: openChamberDefaults.autoCreateWorktree ?? false,
settingsGitmojiEnabled: openChamberDefaults.gitmojiEnabled ?? false,
settingsZenModel: openChamberDefaults.zenModel,
settingsZenModel: resolvedZenModel,
settingsGitProviderId: resolvedGitProviderId,
settingsGitModelId: resolvedGitModelId,
directoryScoped: {
...state.directoryScoped,
[directoryKey]: nextSnapshot,
@@ -1045,6 +1170,37 @@ export const useConfigStore = create<ConfigStore>()(
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) {
set((state) => {
const baseSnapshot: DirectoryScopedConfig = state.directoryScoped[directoryKey] ?? {
@@ -1460,6 +1616,24 @@ export const useConfigStore = create<ConfigStore>()(
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') => {
set({ voiceProvider: provider });
if (typeof window !== 'undefined') {
@@ -1670,6 +1844,8 @@ export const useConfigStore = create<ConfigStore>()(
settingsAutoCreateWorktree: state.settingsAutoCreateWorktree,
settingsGitmojiEnabled: state.settingsGitmojiEnabled,
settingsZenModel: state.settingsZenModel,
settingsGitProviderId: state.settingsGitProviderId,
settingsGitModelId: state.settingsGitModelId,
speechRate: state.speechRate,
speechPitch: state.speechPitch,
speechVolume: state.speechVolume,
+269 -38
View File
@@ -304,28 +304,253 @@ const normalizeMergeMethod = (value: string): 'merge' | 'squash' | 'rebase' => {
return 'merge';
};
const extractZenOutputText = (value: unknown): string | null => {
if (!value || typeof value !== 'object') return null;
const root = value as Record<string, unknown>;
const output = root.output;
if (!Array.isArray(output)) return null;
const BRIDGE_ZEN_DEFAULT_MODEL = 'gpt-5-nano';
const BRIDGE_GIT_GENERATION_TIMEOUT_MS = 2 * 60 * 1000;
const BRIDGE_GIT_GENERATION_POLL_INTERVAL_MS = 500;
let bridgeGitModelCatalogCache: Set<string> | null = null;
let bridgeGitModelCatalogCacheAt = 0;
const BRIDGE_GIT_MODEL_CATALOG_CACHE_TTL_MS = 30 * 1000;
const messageItem = output.find((item) => {
if (!item || typeof item !== 'object') return false;
return (item as Record<string, unknown>).type === 'message';
}) as Record<string, unknown> | undefined;
if (!messageItem) return null;
const sleep = (ms: number) => new Promise<void>((resolve) => {
setTimeout(resolve, ms);
});
const content = messageItem.content;
if (!Array.isArray(content)) return null;
const fetchBridgeGitModelCatalog = async (
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) => {
if (!item || typeof item !== 'object') return false;
return (item as Record<string, unknown>).type === 'output_text';
}) as Record<string, unknown> | undefined;
const headers = authHeaders || {};
const modelsUrl = new URL(`${apiUrl.replace(/\/+$/, '')}/model`);
const response = await fetch(modelsUrl.toString(), {
method: 'GET',
headers: {
Accept: 'application/json',
...headers,
},
signal: AbortSignal.timeout(8_000),
});
const text = typeof textItem?.text === 'string' ? textItem.text.trim() : '';
return text || null;
if (!response.ok) {
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 => {
@@ -2920,10 +3145,14 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo
}
case 'api:git/pr-description': {
const { directory, base, head } = (payload || {}) as {
const { directory, base, head, context, providerId, modelId, zenModel: payloadZenModel } = (payload || {}) as {
directory?: string;
base?: string;
head?: string;
context?: string;
providerId?: string;
modelId?: string;
zenModel?: string;
};
if (!directory) {
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' };
}
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 {
const zenSettings = readSettings(ctx) as Record<string, unknown>;
const zenModelRaw = typeof zenSettings?.zenModel === 'string' ? (zenSettings.zenModel as string).trim() : '';
const zenModel = zenModelRaw.length > 0 ? zenModelRaw : 'gpt-5-nano';
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 apiUrl = ctx?.manager?.getApiUrl();
if (!apiUrl) {
return { id, type, success: false, error: 'OpenCode API unavailable' };
}
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) {
return { id, type, success: false, error: 'No PR description returned by generator' };
}
const cleaned = String(raw)
.trim()
.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
void directory; // Unused for now
void files; // Unused for now
void options; // Unused for now
return {
message: {
subject: '',
@@ -104,12 +109,16 @@ export const createVSCodeGitAPI = (): GitAPI => ({
generatePullRequestDescription: async (
directory: string,
payload: { base: string; head: string }
payload: { base: string; head: string; context?: string; zenModel?: string; providerId?: string; modelId?: string }
): Promise<GeneratedPullRequestDescription> => {
return sendBridgeMessage<GeneratedPullRequestDescription>('api:git/pr-description', {
directory,
base: payload.base,
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 cachedZenModelsTimestamp = 0;
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
@@ -660,6 +663,220 @@ const resolveZenModel = async (override) => {
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) => {
if (!text || typeof text !== 'string' || text.trim().length === 0) return text;
@@ -1534,6 +1751,14 @@ const sanitizeSettingsUpdate = (payload) => {
const trimmed = candidate.zenModel.trim();
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') {
const mode = candidate.toolCallExpansion.trim();
if (mode === 'collapsed' || mode === 'activity' || mode === 'detailed') {
@@ -9713,37 +9938,13 @@ highlights:
Diff summary (may be truncated):
${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);
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();
const raw = await generateWithSessionFlow({ prompt, providerID, modelID });
if (!raw) {
return res.status(502).json({ error: 'No commit message returned by generator' });
@@ -9826,35 +10027,13 @@ Context:
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);
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();
const raw = await generateWithSessionFlow({ prompt, providerID, modelID });
if (!raw) {
return res.status(502).json({ error: 'No PR description returned by generator' });
}