From 2f09d375d3309e841a9989f846a460b611435e22 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 12 Feb 2026 19:37:28 -0800 Subject: [PATCH] feat: add zen model selection to settings (#415) --- .../sections/openchamber/DefaultsSettings.tsx | 101 ++++++++++++- .../openchamber/OpenChamberSidebar.tsx | 2 +- packages/ui/src/components/views/GitView.tsx | 4 +- .../views/git/PullRequestSection.tsx | 2 + packages/ui/src/hooks/useServerTTS.ts | 5 +- packages/ui/src/lib/api/types.ts | 4 +- packages/ui/src/lib/desktop.ts | 1 + packages/ui/src/lib/gitApi.ts | 7 +- packages/ui/src/lib/gitApiHttp.ts | 19 ++- packages/ui/src/lib/voice/summarize.ts | 3 +- packages/ui/src/stores/useConfigStore.ts | 14 ++ packages/vscode/src/bridge.ts | 5 +- packages/web/server/index.js | 142 +++++++++++++++++- .../web/server/lib/summarization-service.js | 6 +- 14 files changed, 287 insertions(+), 28 deletions(-) diff --git a/packages/ui/src/components/sections/openchamber/DefaultsSettings.tsx b/packages/ui/src/components/sections/openchamber/DefaultsSettings.tsx index 6f76a324..3c050f73 100644 --- a/packages/ui/src/components/sections/openchamber/DefaultsSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/DefaultsSettings.tsx @@ -11,6 +11,11 @@ import { useConfigStore } from '@/stores/useConfigStore'; import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; import { getModifierLabel } from '@/lib/utils'; +interface ZenModel { + id: string; + owned_by?: string; +} + const FALLBACK_PROVIDER_ID = 'opencode'; const FALLBACK_MODEL_ID = 'big-pickle'; @@ -48,12 +53,16 @@ export const DefaultsSettings: React.FC = () => { const setSettingsDefaultAgent = useConfigStore((state) => state.setSettingsDefaultAgent); const settingsAutoCreateWorktree = useConfigStore((state) => state.settingsAutoCreateWorktree); const setSettingsAutoCreateWorktree = useConfigStore((state) => state.setSettingsAutoCreateWorktree); + const settingsZenModel = useConfigStore((state) => state.settingsZenModel); + const setSettingsZenModel = useConfigStore((state) => state.setSettingsZenModel); const providers = useConfigStore((state) => state.providers); const [defaultModel, setDefaultModel] = React.useState(); const [defaultVariant, setDefaultVariant] = React.useState(); const [defaultAgent, setDefaultAgent] = React.useState(); const [isLoading, setIsLoading] = React.useState(true); + const [zenModels, setZenModels] = React.useState([]); + const [zenModelsLoading, setZenModelsLoading] = React.useState(true); const parsedModel = React.useMemo(() => { return getDisplayModel(defaultModel, providers); @@ -61,11 +70,43 @@ export const DefaultsSettings: React.FC = () => { 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 } | null = null; + let data: { defaultModel?: string; defaultVariant?: string; defaultAgent?: string; zenModel?: string } | null = null; // 1. Runtime settings API (VSCode) if (!data) { @@ -79,6 +120,7 @@ export const DefaultsSettings: React.FC = () => { defaultModel: typeof settings.defaultModel === 'string' ? settings.defaultModel : undefined, defaultVariant: typeof (settings as Record).defaultVariant === 'string' ? ((settings as Record).defaultVariant as string) : undefined, defaultAgent: typeof settings.defaultAgent === 'string' ? settings.defaultAgent : undefined, + zenModel: typeof (settings as Record).zenModel === 'string' ? ((settings as Record).zenModel as string) : undefined, }; } } catch { @@ -102,6 +144,7 @@ export const DefaultsSettings: React.FC = () => { 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 (model !== undefined) { setDefaultModel(model); @@ -112,6 +155,9 @@ export const DefaultsSettings: React.FC = () => { if (agent !== undefined) { setDefaultAgent(agent); } + if (zen !== undefined) { + setSettingsZenModel(zen); + } } } catch (error) { console.warn('Failed to load defaults settings:', error); @@ -120,7 +166,7 @@ export const DefaultsSettings: React.FC = () => { } }; loadSettings(); - }, []); + }, [setSettingsZenModel]); const handleModelChange = React.useCallback(async (providerId: string, modelId: string) => { @@ -241,6 +287,16 @@ export const DefaultsSettings: React.FC = () => { } }, [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]); if (isLoading) { return null; @@ -333,6 +389,47 @@ export const DefaultsSettings: React.FC = () => {

)} + +
+
+
+

Zen Model

+ + + + + + The free model used for lightweight internal tasks like commit message generation, PR descriptions, notification summarization, and TTS text summarization. + + +
+

+ Used for commit messages, PR descriptions, and text summarization. +

+
+ +
+ + {zenModelsLoading ? ( + Loading models... + ) : zenModels.length > 0 ? ( + + ) : ( + No free models available + )} +
+
); }; diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberSidebar.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberSidebar.tsx index 5c0bfebd..45e41c40 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberSidebar.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberSidebar.tsx @@ -38,7 +38,7 @@ const OPENCHAMBER_SECTION_GROUPS: SectionGroup[] = [ { id: 'sessions', label: 'Sessions', - items: ['Defaults', 'Retention'], + items: ['Defaults', 'Zen Model', 'Retention'], }, { id: 'git', diff --git a/packages/ui/src/components/views/GitView.tsx b/packages/ui/src/components/views/GitView.tsx index c660e23e..03ae5444 100644 --- a/packages/ui/src/components/views/GitView.tsx +++ b/packages/ui/src/components/views/GitView.tsx @@ -839,9 +839,11 @@ export const GitView: React.FC = ({ mode = 'full' }) => { setIsGeneratingMessage(true); try { + const zenModel = useConfigStore.getState().settingsZenModel; const { message } = await git.generateCommitMessage( currentDirectory, - Array.from(selectedPaths) + Array.from(selectedPaths), + zenModel ? { zenModel } : undefined ); const subject = message.subject?.trim() ?? ''; const highlights = Array.isArray(message.highlights) ? message.highlights : []; diff --git a/packages/ui/src/components/views/git/PullRequestSection.tsx b/packages/ui/src/components/views/git/PullRequestSection.tsx index 3c1dfe81..a58af7e7 100644 --- a/packages/ui/src/components/views/git/PullRequestSection.tsx +++ b/packages/ui/src/components/views/git/PullRequestSection.tsx @@ -951,10 +951,12 @@ export const PullRequestSection: React.FC<{ if (!directory) return; setIsGenerating(true); try { + const zenModel = useConfigStore.getState().settingsZenModel; const generated = await generatePullRequestDescription(directory, { base: baseBranch, head: branch, context: additionalContext, + ...(zenModel ? { zenModel } : {}), }); if (generated.title?.trim()) { diff --git a/packages/ui/src/hooks/useServerTTS.ts b/packages/ui/src/hooks/useServerTTS.ts index b8ed4295..e1f5c2ad 100644 --- a/packages/ui/src/hooks/useServerTTS.ts +++ b/packages/ui/src/hooks/useServerTTS.ts @@ -78,7 +78,7 @@ export function useServerTTS(): UseServerTTSReturn { const abortControllerRef = useRef(null); // Get current model, threshold, and max length from config store for summarization - const { currentProviderId, currentModelId, summarizeCharacterThreshold, summarizeMaxLength, openaiApiKey } = useConfigStore(); + const { currentProviderId, currentModelId, summarizeCharacterThreshold, summarizeMaxLength, openaiApiKey, settingsZenModel } = useConfigStore(); // Check if server TTS is available const checkAvailability = useCallback(async (): Promise => { @@ -209,6 +209,7 @@ export function useServerTTS(): UseServerTTSReturn { maxLength: summarizeMaxLength ?? 500, // Send API key from settings if available apiKey: openaiApiKey || undefined, + ...(settingsZenModel ? { zenModel: settingsZenModel } : {}), }), signal: abortControllerRef.current.signal, }); @@ -258,7 +259,7 @@ export function useServerTTS(): UseServerTTSReturn { options?.onError?.(errorMsg); setIsPlaying(false); } - }, [stop, currentProviderId, currentModelId, summarizeCharacterThreshold, summarizeMaxLength, openaiApiKey]); + }, [stop, currentProviderId, currentModelId, summarizeCharacterThreshold, summarizeMaxLength, openaiApiKey, settingsZenModel]); // Cleanup on unmount useEffect(() => { diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index c60cdd02..df86fe2a 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -332,10 +332,10 @@ export interface GitAPI { getGitBranches(directory: string): Promise; deleteGitBranch(directory: string, payload: GitDeleteBranchPayload): Promise<{ success: boolean }>; deleteRemoteBranch(directory: string, payload: GitDeleteRemoteBranchPayload): Promise<{ success: boolean }>; - generateCommitMessage(directory: string, files: string[]): Promise<{ message: GeneratedCommitMessage }>; + generateCommitMessage(directory: string, files: string[], options?: { zenModel?: string }): Promise<{ message: GeneratedCommitMessage }>; generatePullRequestDescription( directory: string, - payload: { base: string; head: string; context?: string } + payload: { base: string; head: string; context?: string; zenModel?: string } ): Promise; listGitWorktrees(directory: string): Promise; createGitCommit(directory: string, message: string, options?: CreateGitCommitOptions): Promise; diff --git a/packages/ui/src/lib/desktop.ts b/packages/ui/src/lib/desktop.ts index d888feed..c7219548 100644 --- a/packages/ui/src/lib/desktop.ts +++ b/packages/ui/src/lib/desktop.ts @@ -91,6 +91,7 @@ export type DesktopSettings = { autoCreateWorktree?: boolean; queueModeEnabled?: boolean; gitmojiEnabled?: boolean; + zenModel?: string; toolCallExpansion?: 'collapsed' | 'activity' | 'detailed'; fontSize?: number; terminalFontSize?: number; diff --git a/packages/ui/src/lib/gitApi.ts b/packages/ui/src/lib/gitApi.ts index 6d6431d7..f174c559 100644 --- a/packages/ui/src/lib/gitApi.ts +++ b/packages/ui/src/lib/gitApi.ts @@ -99,16 +99,17 @@ export async function deleteRemoteBranch(directory: string, payload: import('./a export async function generateCommitMessage( directory: string, - files: string[] + files: string[], + options?: { zenModel?: string } ): Promise<{ message: import('./api/types').GeneratedCommitMessage }> { const runtime = getRuntimeGit(); if (runtime) return runtime.generateCommitMessage(directory, files); - return gitHttp.generateCommitMessage(directory, files); + return gitHttp.generateCommitMessage(directory, files, options); } export async function generatePullRequestDescription( directory: string, - payload: { base: string; head: string; context?: string } + payload: { base: string; head: string; context?: string; zenModel?: string } ): Promise { const runtime = getRuntimeGit(); if (runtime?.generatePullRequestDescription) { diff --git a/packages/ui/src/lib/gitApiHttp.ts b/packages/ui/src/lib/gitApiHttp.ts index 75821b7d..50186775 100644 --- a/packages/ui/src/lib/gitApiHttp.ts +++ b/packages/ui/src/lib/gitApiHttp.ts @@ -205,16 +205,22 @@ export async function deleteRemoteBranch(directory: string, payload: GitDeleteRe export async function generateCommitMessage( directory: string, - files: string[] + files: string[], + options?: { zenModel?: string } ): Promise<{ message: GeneratedCommitMessage }> { if (!Array.isArray(files) || files.length === 0) { throw new Error('No files provided to generate commit message'); } + const body: Record = { files }; + if (options?.zenModel) { + body.zenModel = options.zenModel; + } + const response = await fetch(buildUrl(`${API_BASE}/commit-message`, directory), { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ files }), + body: JSON.stringify(body), }); if (!response.ok) { @@ -249,17 +255,20 @@ export async function generateCommitMessage( export async function generatePullRequestDescription( directory: string, - payload: { base: string; head: string; context?: string } + payload: { base: string; head: string; context?: string; zenModel?: string } ): Promise<{ title: string; body: string }> { - const { base, head, context } = payload; + const { base, head, context, zenModel } = payload; if (!base || !head) { throw new Error('base and head are required'); } - const requestBody: { base: string; head: string; context?: string } = { base, head }; + const requestBody: { base: string; head: string; context?: string; zenModel?: string } = { base, head }; if (context?.trim()) { requestBody.context = context.trim(); } + if (zenModel) { + requestBody.zenModel = zenModel; + } const response = await fetch(buildUrl(`${API_BASE}/pr-description`, directory), { method: 'POST', diff --git a/packages/ui/src/lib/voice/summarize.ts b/packages/ui/src/lib/voice/summarize.ts index 97fe1396..ab17e04c 100644 --- a/packages/ui/src/lib/voice/summarize.ts +++ b/packages/ui/src/lib/voice/summarize.ts @@ -33,12 +33,13 @@ export async function summarizeText( } try { + const zenModel = store.settingsZenModel; const response = await fetch('/api/tts/summarize', { method: 'POST', headers: { 'Content-Type': 'application/json', }, - body: JSON.stringify({ text, threshold, maxLength }), + body: JSON.stringify({ text, threshold, maxLength, ...(zenModel ? { zenModel } : {}) }), }); if (!response.ok) { diff --git a/packages/ui/src/stores/useConfigStore.ts b/packages/ui/src/stores/useConfigStore.ts index a4b198cb..2bfc1d74 100644 --- a/packages/ui/src/stores/useConfigStore.ts +++ b/packages/ui/src/stores/useConfigStore.ts @@ -25,6 +25,7 @@ interface OpenChamberDefaults { defaultAgent?: string; autoCreateWorktree?: boolean; gitmojiEnabled?: boolean; + zenModel?: string; } const fetchOpenChamberDefaults = async (): Promise => { @@ -40,6 +41,7 @@ const fetchOpenChamberDefaults = async (): Promise => { const defaultVariant = typeof data?.defaultVariant === 'string' ? data.defaultVariant.trim() : ''; 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() : ''; return { defaultModel: defaultModel.length > 0 ? defaultModel : undefined, @@ -47,6 +49,7 @@ const fetchOpenChamberDefaults = async (): Promise => { defaultAgent: defaultAgent.length > 0 ? defaultAgent : undefined, autoCreateWorktree: typeof data?.autoCreateWorktree === 'boolean' ? data.autoCreateWorktree : undefined, gitmojiEnabled, + zenModel: zenModel.length > 0 ? zenModel : undefined, }; } } catch { @@ -67,6 +70,7 @@ const fetchOpenChamberDefaults = async (): Promise => { const defaultVariant = typeof data?.defaultVariant === 'string' ? data.defaultVariant.trim() : ''; 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() : ''; return { defaultModel: defaultModel.length > 0 ? defaultModel : undefined, @@ -74,6 +78,7 @@ const fetchOpenChamberDefaults = async (): Promise => { defaultAgent: defaultAgent.length > 0 ? defaultAgent : undefined, autoCreateWorktree: typeof data?.autoCreateWorktree === 'boolean' ? data.autoCreateWorktree : undefined, gitmojiEnabled, + zenModel: zenModel.length > 0 ? zenModel : undefined, }; } catch { return {}; @@ -366,6 +371,7 @@ interface ConfigStore { settingsDefaultAgent: string | undefined; settingsAutoCreateWorktree: boolean; settingsGitmojiEnabled: boolean; + settingsZenModel: string | undefined; // Voice provider preference ('browser', 'openai', or 'say' for macOS) voiceProvider: 'browser' | 'openai' | 'say'; setVoiceProvider: (provider: 'browser' | 'openai' | 'say') => void; @@ -414,6 +420,7 @@ interface ConfigStore { setSettingsDefaultAgent: (agent: string | undefined) => void; setSettingsAutoCreateWorktree: (enabled: boolean) => void; setSettingsGitmojiEnabled: (enabled: boolean) => void; + setSettingsZenModel: (model: string | undefined) => void; saveAgentModelSelection: (agentName: string, providerId: string, modelId: string) => void; getAgentModelSelection: (agentName: string) => { providerId: string; modelId: string } | null; checkConnection: () => Promise; @@ -458,6 +465,7 @@ export const useConfigStore = create()( settingsDefaultAgent: undefined, settingsAutoCreateWorktree: false, settingsGitmojiEnabled: false, + settingsZenModel: undefined, // Voice provider preference - load from localStorage or default to 'browser' voiceProvider: (() => { if (typeof window !== 'undefined') { @@ -1023,6 +1031,7 @@ export const useConfigStore = create()( settingsDefaultAgent: openChamberDefaults.defaultAgent, settingsAutoCreateWorktree: openChamberDefaults.autoCreateWorktree ?? false, settingsGitmojiEnabled: openChamberDefaults.gitmojiEnabled ?? false, + settingsZenModel: openChamberDefaults.zenModel, directoryScoped: { ...state.directoryScoped, [directoryKey]: nextSnapshot, @@ -1447,6 +1456,10 @@ export const useConfigStore = create()( set({ settingsGitmojiEnabled: enabled }); }, + setSettingsZenModel: (model: string | undefined) => { + set({ settingsZenModel: model }); + }, + setVoiceProvider: (provider: 'browser' | 'openai' | 'say') => { set({ voiceProvider: provider }); if (typeof window !== 'undefined') { @@ -1656,6 +1669,7 @@ export const useConfigStore = create()( settingsDefaultAgent: state.settingsDefaultAgent, settingsAutoCreateWorktree: state.settingsAutoCreateWorktree, settingsGitmojiEnabled: state.settingsGitmojiEnabled, + settingsZenModel: state.settingsZenModel, speechRate: state.speechRate, speechPitch: state.speechPitch, speechVolume: state.speechVolume, diff --git a/packages/vscode/src/bridge.ts b/packages/vscode/src/bridge.ts index 9e7a7edb..f359cc7b 100644 --- a/packages/vscode/src/bridge.ts +++ b/packages/vscode/src/bridge.ts @@ -2540,11 +2540,14 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo 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}`; try { + const zenSettings = readSettings(ctx) as Record; + 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: 'gpt-5-nano', + model: zenModel, input: [{ role: 'user', content: prompt }], max_output_tokens: 1200, stream: false, diff --git a/packages/web/server/index.js b/packages/web/server/index.js index b5134032..d51e4cd9 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -588,7 +588,76 @@ const shouldApplyResolvedTemplateMessage = (template, resolved, variables) => { return true; }; -const summarizeText = async (text, targetLength) => { +const ZEN_DEFAULT_MODEL = 'gpt-5-nano'; + +/** + * Validated fallback zen model determined at startup by checking available free + * models from the zen API. When `null`, startup validation hasn't run yet (or + * failed), so `resolveZenModel` falls back to `ZEN_DEFAULT_MODEL`. + */ +let validatedZenFallback = null; + +/** Cached free zen models response and timestamp (shared by startup + endpoint). */ +let cachedZenModels = null; +let cachedZenModelsTimestamp = 0; +const ZEN_MODELS_CACHE_TTL = 5 * 60 * 1000; // 5 minutes + +/** + * Fetch free models from the zen API with caching. Returns an array of + * `{ id, owned_by }` objects (may be empty on failure). Results are cached + * for `ZEN_MODELS_CACHE_TTL` ms. + */ +const fetchFreeZenModels = async () => { + const now = Date.now(); + if (cachedZenModels && now - cachedZenModelsTimestamp < ZEN_MODELS_CACHE_TTL) { + return cachedZenModels.models; + } + + const controller = typeof AbortController !== 'undefined' ? new AbortController() : null; + const timeout = controller ? setTimeout(() => controller.abort(), 8000) : null; + try { + const response = await fetch('https://opencode.ai/zen/v1/models', { + signal: controller?.signal, + headers: { Accept: 'application/json' }, + }); + if (!response.ok) { + throw new Error(`zen/v1/models responded with status ${response.status}`); + } + const data = await response.json(); + const allModels = Array.isArray(data?.data) ? data.data : []; + const freeModels = allModels + .filter((m) => typeof m?.id === 'string' && m.id.endsWith('-free')) + .map((m) => ({ id: m.id, owned_by: m.owned_by })); + + cachedZenModels = { models: freeModels }; + cachedZenModelsTimestamp = Date.now(); + return freeModels; + } finally { + if (timeout) clearTimeout(timeout); + } +}; + +/** + * Resolve the zen model to use. Checks the provided override first, + * then falls back to the stored zenModel setting, then to the validated + * startup fallback, then to the hardcoded default. + */ +const resolveZenModel = async (override) => { + if (typeof override === 'string' && override.trim().length > 0) { + return override.trim(); + } + try { + const settings = await readSettingsFromDisk(); + if (typeof settings?.zenModel === 'string' && settings.zenModel.trim().length > 0) { + return settings.zenModel.trim(); + } + } catch { + // ignore + } + return validatedZenFallback || ZEN_DEFAULT_MODEL; +}; + +const summarizeText = async (text, targetLength, zenModel) => { if (!text || typeof text !== 'string' || text.trim().length === 0) return text; try { @@ -601,7 +670,7 @@ const summarizeText = async (text, targetLength) => { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - model: 'gpt-5-nano', + model: zenModel || ZEN_DEFAULT_MODEL, input: [{ role: 'user', content: prompt }], max_output_tokens: 1000, stream: false, @@ -1451,6 +1520,10 @@ const sanitizeSettingsUpdate = (payload) => { if (typeof candidate.gitmojiEnabled === 'boolean') { result.gitmojiEnabled = candidate.gitmojiEnabled; } + if (typeof candidate.zenModel === 'string') { + const trimmed = candidate.zenModel.trim(); + result.zenModel = trimmed.length > 0 ? trimmed : undefined; + } if (typeof candidate.toolCallExpansion === 'string') { const mode = candidate.toolCallExpansion.trim(); if (mode === 'collapsed' || mode === 'activity' || mode === 'detailed') { @@ -3901,10 +3974,11 @@ const maybeSendPushForTrigger = async (payload) => { lastMessage = await fetchLastAssistantMessageText(sessionId, messageId); } + const notifZenModel = await resolveZenModel(settings?.zenModel); variables.last_message = await prepareNotificationLastMessage({ message: lastMessage, settings, - summarize: summarizeText, + summarize: (text, len) => summarizeText(text, len, notifZenModel), }); const resolvedTitle = resolveNotificationTemplate(completionTemplate.title, variables); @@ -3961,10 +4035,11 @@ const maybeSendPushForTrigger = async (payload) => { lastMessage = await fetchLastAssistantMessageText(sessionId, errorMessageId); } + const errZenModel = await resolveZenModel(settings?.zenModel); variables.last_message = await prepareNotificationLastMessage({ message: lastMessage, settings, - summarize: summarizeText, + summarize: (text, len) => summarizeText(text, len, errZenModel), }); const errorTemplate = (settings.notificationTemplates || {}).error || { title: 'Tool error', message: '{last_message}' }; @@ -5159,6 +5234,36 @@ async function main(options = {}) { sayTTSCapability = { available: false, voices: [], reason: 'Not macOS' }; } + // Validate stored zen model at startup – best-effort, never blocks startup + try { + const freeModels = await fetchFreeZenModels(); + const freeModelIds = freeModels.map((m) => m.id); + + if (freeModelIds.length > 0) { + // Set the validated fallback to the first available free model + validatedZenFallback = freeModelIds[0]; + + const settings = await readSettingsFromDisk(); + const storedModel = typeof settings?.zenModel === 'string' ? settings.zenModel.trim() : ''; + + if (!storedModel || !freeModelIds.includes(storedModel)) { + const fallback = freeModelIds[0]; + console.log( + storedModel + ? `[zen] Stored model "${storedModel}" not found in free models, falling back to "${fallback}"` + : `[zen] No model configured, setting default to "${fallback}"` + ); + await persistSettings({ zenModel: fallback }); + } else { + console.log(`[zen] Stored model "${storedModel}" verified as available`); + } + } else { + console.warn('[zen] No free models returned from API, skipping validation'); + } + } catch (error) { + console.warn('[zen] Startup model validation failed (non-blocking):', error?.message || error); + } + const app = express(); app.set('trust proxy', true); expressApp = app; @@ -5417,7 +5522,8 @@ async function main(options = {}) { if (summarize && textToSpeak.length > threshold) { try { const { summarizeText } = await import('./lib/summarization-service.js'); - const result = await summarizeText({ text: textToSpeak, threshold, maxLength }); + const speakZenModel = await resolveZenModel(typeof req.body?.zenModel === 'string' ? req.body.zenModel : undefined); + const result = await summarizeText({ text: textToSpeak, threshold, maxLength, zenModel: speakZenModel }); if (result.summarized && result.summary) { textToSpeak = result.summary; @@ -5485,7 +5591,8 @@ async function main(options = {}) { return res.status(400).json({ error: 'Text is required' }); } - const result = await summarizeText({ text, threshold, maxLength }); + const sumZenModel = await resolveZenModel(typeof req.body?.zenModel === 'string' ? req.body.zenModel : undefined); + const result = await summarizeText({ text, threshold, maxLength, zenModel: sumZenModel }); return res.json(result); } catch (error) { @@ -5854,6 +5961,25 @@ async function main(options = {}) { } }); + // Zen models endpoint - returns available free models from the zen API + app.get('/api/zen/models', async (_req, res) => { + try { + const models = await fetchFreeZenModels(); + res.setHeader('Cache-Control', 'public, max-age=300'); + res.json({ models }); + } catch (error) { + console.warn('Failed to fetch zen models:', error); + // Serve stale cache if available + if (cachedZenModels) { + res.setHeader('Cache-Control', 'public, max-age=60'); + res.json(cachedZenModels); + } else { + const statusCode = error?.name === 'AbortError' ? 504 : 502; + res.status(statusCode).json({ error: 'Failed to retrieve zen models' }); + } + } + }); + app.get('/api/global/event', async (req, res) => { let targetUrl; try { @@ -8811,7 +8937,7 @@ highlights: Diff summary (may be truncated): ${diffSummaries}`; - const model = 'gpt-5-nano'; + const model = await resolveZenModel(typeof req.body?.zenModel === 'string' ? req.body.zenModel : undefined); const completionTimeout = createTimeoutSignal(LONG_REQUEST_TIMEOUT_MS); let response; @@ -8924,7 +9050,7 @@ Context: prompt += `\n\nDiff summary:\n${diffSummaries}`; - const model = 'gpt-5-nano'; + const model = await resolveZenModel(typeof req.body?.zenModel === 'string' ? req.body.zenModel : undefined); const completionTimeout = createTimeoutSignal(LONG_REQUEST_TIMEOUT_MS); let response; diff --git a/packages/web/server/lib/summarization-service.js b/packages/web/server/lib/summarization-service.js index 1b0c0502..cc0dfaac 100644 --- a/packages/web/server/lib/summarization-service.js +++ b/packages/web/server/lib/summarization-service.js @@ -78,18 +78,20 @@ function extractZenOutputText(data) { } /** - * Summarize text using the opencode.ai zen API with gpt-5-nano + * Summarize text using the opencode.ai zen API * * @param {Object} options * @param {string} options.text - The text to summarize * @param {number} options.threshold - Character threshold (don't summarize if under this length) * @param {number} options.maxLength - Maximum character length for the summary output (50-2000) + * @param {string} [options.zenModel] - Override zen model (defaults to gpt-5-nano) * @returns {Promise<{summary: string, summarized: boolean, reason?: string}>} */ export async function summarizeText({ text, threshold = 200, maxLength = 500, + zenModel, }) { // Don't summarize if text is under threshold if (!text || text.length <= threshold) { @@ -110,7 +112,7 @@ export async function summarizeText({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - model: 'gpt-5-nano', + model: zenModel || 'gpt-5-nano', input: [ { role: 'user', content: `${prompt}\n\nText to summarize:\n${text}` }, ],