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:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
8647e0c1a4
commit
7a11867a19
+237
-58
@@ -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' });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user