fix: use valid Zen summaries for notes
This commit is contained in:
@@ -71,18 +71,36 @@ export const createNotificationTemplateRuntime = (deps) => {
|
||||
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 [zenResponse, metadataResponse] = await Promise.all([
|
||||
fetch('https://opencode.ai/zen/v1/models', {
|
||||
signal: controller?.signal,
|
||||
headers: { Accept: 'application/json' },
|
||||
}),
|
||||
fetch('https://models.dev/api.json', {
|
||||
signal: controller?.signal,
|
||||
headers: { Accept: 'application/json' },
|
||||
}),
|
||||
]);
|
||||
if (!zenResponse.ok) {
|
||||
throw new Error(`zen/v1/models responded with status ${zenResponse.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
if (!metadataResponse.ok) {
|
||||
throw new Error(`models.dev responded with status ${metadataResponse.status}`);
|
||||
}
|
||||
|
||||
const data = await zenResponse.json();
|
||||
const metadata = await metadataResponse.json();
|
||||
const metadataModels = metadata?.opencode?.models && typeof metadata.opencode.models === 'object'
|
||||
? metadata.opencode.models
|
||||
: {};
|
||||
const allModels = Array.isArray(data?.data) ? data.data : [];
|
||||
const freeModels = allModels
|
||||
.filter((model) => typeof model?.id === 'string' && model.id.endsWith('-free'))
|
||||
.map((model) => ({ id: model.id, owned_by: model.owned_by }));
|
||||
.filter((model) => {
|
||||
const id = typeof model?.id === 'string' ? model.id.trim() : '';
|
||||
const cost = id ? metadataModels[id]?.cost : null;
|
||||
return id && cost?.input === 0 && cost?.output === 0;
|
||||
})
|
||||
.map((model) => ({ id: model.id.trim(), owned_by: model.owned_by }));
|
||||
|
||||
cachedZenModels = { models: freeModels };
|
||||
cachedZenModelsTimestamp = Date.now();
|
||||
@@ -93,16 +111,35 @@ export const createNotificationTemplateRuntime = (deps) => {
|
||||
};
|
||||
|
||||
const resolveZenModel = async (override) => {
|
||||
if (typeof override === 'string' && override.trim().length > 0) {
|
||||
return override.trim();
|
||||
}
|
||||
const overrideModel = typeof override === 'string' ? override.trim() : '';
|
||||
let settingsModel = '';
|
||||
try {
|
||||
const settings = await readSettingsFromDisk();
|
||||
if (typeof settings?.zenModel === 'string' && settings.zenModel.trim().length > 0) {
|
||||
return settings.zenModel.trim();
|
||||
settingsModel = settings.zenModel.trim();
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
|
||||
const candidate = overrideModel || settingsModel;
|
||||
try {
|
||||
const models = await fetchFreeZenModels();
|
||||
const modelIds = models.map((model) => model.id);
|
||||
if (candidate && modelIds.includes(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
if (modelIds.includes(ZEN_DEFAULT_MODEL)) {
|
||||
return ZEN_DEFAULT_MODEL;
|
||||
}
|
||||
if (modelIds.length > 0) {
|
||||
return modelIds[0];
|
||||
}
|
||||
} catch {
|
||||
if (candidate) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return validatedZenFallback || ZEN_DEFAULT_MODEL;
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createNotificationTemplateRuntime } from './template-runtime.js';
|
||||
|
||||
const createRuntime = (settings = {}) => createNotificationTemplateRuntime({
|
||||
readSettingsFromDisk: async () => settings,
|
||||
persistSettings: vi.fn(async () => {}),
|
||||
buildOpenCodeUrl: (path) => path,
|
||||
getOpenCodeAuthHeaders: () => ({}),
|
||||
resolveGitBinaryForSpawn: () => 'git',
|
||||
});
|
||||
|
||||
describe('notification template runtime zen models', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('uses zen models with zero-cost metadata as selectable', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async (url) => {
|
||||
if (String(url).includes('models.dev')) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
opencode: {
|
||||
models: {
|
||||
'big-pickle': { cost: { input: 0, output: 0 } },
|
||||
'gpt-5-nano': { cost: { input: 0, output: 0 } },
|
||||
'gpt-5.5': { cost: { input: 5, output: 30 } },
|
||||
'hy3-preview-free': { cost: { input: 0, output: 0 } },
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [
|
||||
{ id: 'big-pickle', owned_by: 'opencode' },
|
||||
{ id: 'gpt-5-nano', owned_by: 'opencode' },
|
||||
{ id: 'gpt-5.5', owned_by: 'opencode' },
|
||||
{ id: 'hy3-preview-free', owned_by: 'opencode' },
|
||||
],
|
||||
}),
|
||||
};
|
||||
}));
|
||||
|
||||
const runtime = createRuntime();
|
||||
const models = await runtime.fetchFreeZenModels();
|
||||
|
||||
expect(models.map((model) => model.id)).toEqual([
|
||||
'big-pickle',
|
||||
'gpt-5-nano',
|
||||
'hy3-preview-free',
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back to a valid unauthenticated model when stored zen model is stale', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async (url) => {
|
||||
if (String(url).includes('models.dev')) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
opencode: {
|
||||
models: {
|
||||
'big-pickle': { cost: { input: 0, output: 0 } },
|
||||
'gpt-5-nano': { cost: { input: 0, output: 0 } },
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [
|
||||
{ id: 'big-pickle', owned_by: 'opencode' },
|
||||
{ id: 'gpt-5-nano', owned_by: 'opencode' },
|
||||
],
|
||||
}),
|
||||
};
|
||||
}));
|
||||
|
||||
const runtime = createRuntime({ zenModel: 'trinity-large-preview-free' });
|
||||
|
||||
await expect(runtime.resolveZenModel()).resolves.toBe('gpt-5-nano');
|
||||
});
|
||||
});
|
||||
@@ -131,6 +131,42 @@ function extractZenOutputText(data) {
|
||||
return text || null;
|
||||
}
|
||||
|
||||
function extractZenChatCompletionText(data) {
|
||||
if (!data || typeof data !== 'object') return null;
|
||||
const choices = data.choices;
|
||||
if (!Array.isArray(choices)) return null;
|
||||
|
||||
const choice = choices.find((item) => item && typeof item === 'object');
|
||||
const content = choice?.message?.content;
|
||||
if (typeof content === 'string') {
|
||||
const text = content.trim();
|
||||
return text || null;
|
||||
}
|
||||
if (!Array.isArray(content)) return null;
|
||||
|
||||
const text = content
|
||||
.map((item) => {
|
||||
if (typeof item === 'string') return item;
|
||||
if (item && typeof item === 'object' && typeof item.text === 'string') return item.text;
|
||||
return '';
|
||||
})
|
||||
.join('')
|
||||
.trim();
|
||||
return text || null;
|
||||
}
|
||||
|
||||
function getZenCompletionEndpoint(model) {
|
||||
if (typeof model !== 'string') return 'responses';
|
||||
if (
|
||||
model.startsWith('gpt-')
|
||||
|| model.startsWith('claude-')
|
||||
|| model.startsWith('gemini-')
|
||||
) {
|
||||
return 'responses';
|
||||
}
|
||||
return 'chat/completions';
|
||||
}
|
||||
|
||||
function distillNoteFallback(text, maxLength) {
|
||||
const sanitized = sanitizeForNote(text);
|
||||
if (!sanitized) return '';
|
||||
@@ -176,15 +212,23 @@ export async function summarizeText({ text, threshold = 200, maxLength = 500, ze
|
||||
|
||||
try {
|
||||
const prompt = buildSummarizationPrompt(maxLength, mode);
|
||||
const response = await fetch('https://opencode.ai/zen/v1/responses', {
|
||||
const model = zenModel || 'gpt-5-nano';
|
||||
const endpoint = getZenCompletionEndpoint(model);
|
||||
const response = await fetch(`https://opencode.ai/zen/v1/${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: zenModel || 'gpt-5-nano',
|
||||
input: [{ role: 'user', content: `${prompt}\n\nText to summarize:\n${text}` }],
|
||||
stream: false,
|
||||
reasoning: { effort: 'low' },
|
||||
}),
|
||||
body: JSON.stringify(endpoint === 'responses'
|
||||
? {
|
||||
model,
|
||||
input: [{ role: 'user', content: `${prompt}\n\nText to summarize:\n${text}` }],
|
||||
stream: false,
|
||||
reasoning: { effort: 'low' },
|
||||
}
|
||||
: {
|
||||
model,
|
||||
messages: [{ role: 'user', content: `${prompt}\n\nText to summarize:\n${text}` }],
|
||||
stream: false,
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
@@ -199,7 +243,9 @@ export async function summarizeText({ text, threshold = 200, maxLength = 500, ze
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const summary = extractZenOutputText(data);
|
||||
const summary = endpoint === 'responses'
|
||||
? extractZenOutputText(data)
|
||||
: extractZenChatCompletionText(data);
|
||||
|
||||
if (summary) {
|
||||
const sanitized = sanitizeByMode(summary, mode);
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { summarizeText } from './summarization.js';
|
||||
|
||||
describe('text summarization zen requests', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('uses responses endpoint for gpt models', async () => {
|
||||
const fetchMock = vi.fn(async () => ({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
output: [{
|
||||
type: 'message',
|
||||
content: [{ type: 'output_text', text: 'Short summary' }],
|
||||
}],
|
||||
}),
|
||||
}));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const result = await summarizeText({
|
||||
text: 'Long text '.repeat(30),
|
||||
threshold: 0,
|
||||
maxLength: 100,
|
||||
zenModel: 'gpt-5-nano',
|
||||
mode: 'notification',
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'https://opencode.ai/zen/v1/responses',
|
||||
expect.objectContaining({
|
||||
body: expect.stringContaining('"input"'),
|
||||
}),
|
||||
);
|
||||
expect(result.summary).toBe('Short summary');
|
||||
});
|
||||
|
||||
it('uses chat completions endpoint for openai-compatible zen models', async () => {
|
||||
const fetchMock = vi.fn(async () => ({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
choices: [{ message: { content: 'Chat summary' } }],
|
||||
}),
|
||||
}));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const result = await summarizeText({
|
||||
text: 'Long text '.repeat(30),
|
||||
threshold: 0,
|
||||
maxLength: 100,
|
||||
zenModel: 'big-pickle',
|
||||
mode: 'notification',
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'https://opencode.ai/zen/v1/chat/completions',
|
||||
expect.objectContaining({
|
||||
body: expect.stringContaining('"messages"'),
|
||||
}),
|
||||
);
|
||||
expect(result.summary).toBe('Chat summary');
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import express from 'express';
|
||||
import { normalizeCustomOpenAIBaseURL } from './base-url.js';
|
||||
import { summarizeText, sanitizeForTTS, sanitizeForNote } from '../text/summarization.js';
|
||||
import { summarizeText, sanitizeForTTS, sanitizeForNote, sanitizeForNotification } from '../text/summarization.js';
|
||||
|
||||
export function registerTtsRoutes(app, { resolveZenModel, sayTTSCapability }) {
|
||||
let ttsModulePromise = null;
|
||||
@@ -124,7 +124,7 @@ export function registerTtsRoutes(app, { resolveZenModel, sayTTSCapability }) {
|
||||
}
|
||||
|
||||
const sumZenModel = await resolveZenModel(typeof req.body?.zenModel === 'string' ? req.body.zenModel : undefined);
|
||||
const result = await summarizeText({
|
||||
let result = await summarizeText({
|
||||
text,
|
||||
threshold,
|
||||
maxLength,
|
||||
@@ -132,6 +132,27 @@ export function registerTtsRoutes(app, { resolveZenModel, sayTTSCapability }) {
|
||||
mode: typeof mode === 'string' ? mode : 'tts',
|
||||
});
|
||||
|
||||
if (mode === 'note' && !result.summarized) {
|
||||
const notificationResult = await summarizeText({
|
||||
text,
|
||||
threshold,
|
||||
maxLength,
|
||||
zenModel: sumZenModel,
|
||||
mode: 'notification',
|
||||
});
|
||||
if (notificationResult.summarized && notificationResult.summary) {
|
||||
result = {
|
||||
...notificationResult,
|
||||
summary: sanitizeForNote(sanitizeForNotification(notificationResult.summary)),
|
||||
};
|
||||
} else {
|
||||
return res.status(502).json({
|
||||
error: 'Note summarization failed',
|
||||
reason: notificationResult.reason || result.reason || 'No distilled result from model',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return res.json(result);
|
||||
} catch (error) {
|
||||
console.error('[Summarize] Error:', error);
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
|
||||
import { registerTtsRoutes } from './routes.js';
|
||||
|
||||
const createApp = () => {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
registerTtsRoutes(app, {
|
||||
resolveZenModel: async () => 'gpt-5-nano',
|
||||
sayTTSCapability: null,
|
||||
});
|
||||
return app;
|
||||
};
|
||||
|
||||
describe('tts routes', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('retries note summarization with notification mode before failing', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({
|
||||
ok: false,
|
||||
status: 503,
|
||||
json: async () => ({ error: 'unavailable' }),
|
||||
})));
|
||||
|
||||
const response = await request(createApp())
|
||||
.post('/api/text/summarize')
|
||||
.send({
|
||||
text: 'First sentence. Second sentence with the useful insight.',
|
||||
threshold: 0,
|
||||
maxLength: 100,
|
||||
mode: 'note',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(502);
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
expect(response.body).toEqual({
|
||||
error: 'Note summarization failed',
|
||||
reason: 'zen API returned 503',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses notification summarizer result when note mode falls back', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 503,
|
||||
json: async () => ({ error: 'unavailable' }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
output: [{
|
||||
type: 'message',
|
||||
content: [{ type: 'output_text', text: '**Keep provider state stable** during streaming.' }],
|
||||
}],
|
||||
}),
|
||||
}));
|
||||
|
||||
const response = await request(createApp())
|
||||
.post('/api/text/summarize')
|
||||
.send({
|
||||
text: 'First sentence. Preserve provider state references during streaming to avoid wide rerenders.',
|
||||
threshold: 0,
|
||||
maxLength: 100,
|
||||
mode: 'note',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toMatchObject({
|
||||
summary: 'Keep provider state stable during streaming.',
|
||||
summarized: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps notification fallback behavior', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({
|
||||
ok: false,
|
||||
status: 503,
|
||||
json: async () => ({ error: 'unavailable' }),
|
||||
})));
|
||||
|
||||
const response = await request(createApp())
|
||||
.post('/api/text/summarize')
|
||||
.send({
|
||||
text: 'Notification text that should fall back cleanly.',
|
||||
threshold: 0,
|
||||
maxLength: 100,
|
||||
mode: 'notification',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toMatchObject({
|
||||
summary: 'Notification text that should fall back cleanly.',
|
||||
summarized: false,
|
||||
reason: 'zen API returned 503',
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user