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.
+
+
+
+
+ Model
+ {zenModelsLoading ? (
+ Loading models...
+ ) : zenModels.length > 0 ? (
+
+
+
+
+
+ {zenModels.map((model) => (
+
+ {model.id}
+
+ ))}
+
+
+ ) : (
+ 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}` },
],